diff --git a/docs/public/audio/.gitkeep b/docs/public/audio/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/theme/components/MusicPlayer.tsx b/theme/components/MusicPlayer.tsx new file mode 100644 index 0000000..30d05d0 --- /dev/null +++ b/theme/components/MusicPlayer.tsx @@ -0,0 +1,341 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { CSSProperties } from 'react'; + +const STORE_KEY = 'osc-bgm'; +const DEFAULT_VOLUME = 0.5; + +type Persist = { playing: boolean; volume: number }; + +function readPersist(): Persist { + try { + const raw = localStorage.getItem(STORE_KEY); + if (raw) { + const data = JSON.parse(raw) as Partial; + return { + playing: data.playing === true, + volume: + typeof data.volume === 'number' && data.volume >= 0 && data.volume <= 1 + ? data.volume + : DEFAULT_VOLUME, + }; + } + } catch { + /* localStorage 不可用(隐私模式 / SSR)时用默认值 */ + } + return { playing: false, volume: DEFAULT_VOLUME }; +} + +function writePersist(data: Persist) { + try { + localStorage.setItem(STORE_KEY, JSON.stringify(data)); + } catch { + /* 忽略写入失败 */ + } +} + +const CSS = ` +.osc-bgm { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 100; + display: flex; + align-items: center; + padding: 5px; + border-radius: 999px; + border: 1px solid var(--rp-c-divider, rgba(128, 128, 128, 0.18)); + background: var(--rp-c-bg-soft, rgba(20, 20, 20, 0.55)); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: 0 4px 16px -6px rgba(0, 0, 0, 0.3); + font-family: inherit; + user-select: none; + opacity: 0.45; + transition: opacity var(--dur-base, 320ms) var(--ease-out), + box-shadow var(--dur-base, 320ms) var(--ease-out); +} +.osc-bgm:hover, +.osc-bgm:focus-within, +.osc-bgm.is-playing { + opacity: 1; + box-shadow: 0 6px 20px -6px rgba(0, 0, 0, 0.35); +} +.osc-bgm.is-playing::before { + content: ''; + position: absolute; + inset: -5px; + border-radius: 999px; + border: 1px solid var(--color-accent-2, var(--rp-c-brand, #4e6ef2)); + opacity: 0; + animation: osc-bgm-breathe 3s ease-in-out infinite; + pointer-events: none; +} +.osc-bgm-btn { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: var(--rp-c-text, #fff); + cursor: pointer; + outline: none; + transition: background 0.2s ease, transform 0.2s ease; +} +.osc-bgm-btn:hover { + background: var(--rp-c-bg-mute, rgba(128, 128, 128, 0.16)); +} +.osc-bgm-btn:active { + transform: scale(0.92); +} +.osc-bgm-btn svg { + width: 16px; + height: 16px; + fill: currentColor; + transition: fill 0.25s ease; +} +.osc-bgm-btn.is-playing svg { + fill: var(--rp-c-brand, currentColor); + animation: osc-bgm-spin 3.6s linear infinite; +} +.osc-bgm-vol { + -webkit-appearance: none; + appearance: none; + width: 0; + height: 14px; + margin: 0; + padding: 0; + border: none; + background: transparent; + outline: none; + opacity: 0; + transition: width var(--dur-base, 320ms) var(--ease-out), + margin-left var(--dur-base, 320ms) var(--ease-out), + opacity var(--dur-fast, 160ms) ease-out; + cursor: pointer; +} +.osc-bgm-vol::-webkit-slider-runnable-track { + height: 4px; + border-radius: 2px; + background: linear-gradient( + to right, + var(--rp-c-brand, #4e6ef2) var(--osc-fill, 50%), + var(--rp-c-divider, rgba(128, 128, 128, 0.35)) var(--osc-fill, 50%) + ); +} +.osc-bgm-vol::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + margin-top: -4px; + border: none; + border-radius: 50%; + background: var(--rp-c-brand, #4e6ef2); + cursor: pointer; +} +.osc-bgm-vol::-moz-range-track { + height: 4px; + border-radius: 2px; + background: var(--rp-c-divider, rgba(128, 128, 128, 0.35)); +} +.osc-bgm-vol::-moz-range-progress { + height: 4px; + border-radius: 2px; + background: var(--rp-c-brand, #4e6ef2); +} +.osc-bgm-vol::-moz-range-thumb { + width: 12px; + height: 12px; + border: none; + border-radius: 50%; + background: var(--rp-c-brand, #4e6ef2); + cursor: pointer; +} +.osc-bgm:hover .osc-bgm-vol, +.osc-bgm:focus-within .osc-bgm-vol { + width: 60px; + margin-left: 6px; + opacity: 1; +} +@keyframes osc-bgm-spin { + to { transform: rotate(360deg); } +} +@keyframes osc-bgm-breathe { + 0%, 100% { opacity: 0; transform: scale(1); } + 50% { opacity: 0.3; transform: scale(1.05); } +} +@media (prefers-reduced-motion: reduce) { + .osc-bgm-btn.is-playing svg, + .osc-bgm.is-playing::before { animation: none; } +} +@media (max-width: 768px) { + .osc-bgm { right: 12px; bottom: 12px; opacity: 1; } + .osc-bgm-vol { width: 52px; margin-left: 6px; opacity: 1; } +} +`; + +// 唱片:外环 + 一段品牌色高光弧(转起来才看得出在转)+ 中心孔 +function DiscIcon() { + return ( + + ); +} + +function PlayIcon() { + return ( + + ); +} + +export interface MusicPlayerProps { + /** 音频地址,对应 docs/public 下的路径 */ + src?: string; + /** 悬浮条上显示的提示文案 */ + label?: string; +} + +export function MusicPlayer({ + src = '/audio/bgm.mp3', + label = '社团 BGM', +}: MusicPlayerProps) { + const audioRef = useRef(null); + const [playing, setPlaying] = useState(false); + const [volume, setVolume] = useState(DEFAULT_VOLUME); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + const saved = readPersist(); + const audio = new Audio(src); + audio.loop = true; + audio.preload = 'metadata'; + audio.volume = saved.volume; + audioRef.current = audio; + setVolume(saved.volume); + setMounted(true); + + const onPlay = () => setPlaying(true); + const onPause = () => setPlaying(false); + const onEnded = () => setPlaying(false); + const onError = () => + console.warn(`[MusicPlayer] 音频加载失败,请检查文件是否存在:${src}`); + + audio.addEventListener('play', onPlay); + audio.addEventListener('pause', onPause); + audio.addEventListener('ended', onEnded); + audio.addEventListener('error', onError); + + // 浏览器禁止无用户手势的自动播放:上次退出时若正在播放, + // 等用户第一次交互(点击 / 按键)后再恢复,不主动发声。 + const resume = () => { + audio.play().catch(() => setPlaying(false)); + }; + let bound = false; + if (saved.playing) { + bound = true; + window.addEventListener('pointerdown', resume, { once: true }); + window.addEventListener('keydown', resume, { once: true }); + } + + return () => { + audio.removeEventListener('play', onPlay); + audio.removeEventListener('pause', onPause); + audio.removeEventListener('ended', onEnded); + audio.removeEventListener('error', onError); + if (bound) { + window.removeEventListener('pointerdown', resume); + window.removeEventListener('keydown', resume); + } + audio.pause(); + audioRef.current = null; + }; + }, [src]); + + const toggle = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + if (audio.paused) { + audio + .play() + .then(() => writePersist({ playing: true, volume: audio.volume })) + .catch(() => { + setPlaying(false); + writePersist({ playing: false, volume: audio.volume }); + }); + } else { + audio.pause(); + writePersist({ playing: false, volume: audio.volume }); + } + }, []); + + const changeVolume = useCallback((value: number) => { + setVolume(value); + const audio = audioRef.current; + if (audio) { + audio.volume = value; + writePersist({ playing: !audio.paused, volume: value }); + } + }, []); + + // SSG 阶段在 Node 侧渲染,此时没有 Audio / localStorage, + // 等客户端挂载后再渲染,避免 hydration 不一致。 + if (!mounted) return null; + + return ( +
+ + + changeVolume(Number(e.target.value))} + aria-label="背景音乐音量" + /> +
+ ); +} + +/** 顶部滚动进度条:蓝到紫接力填充,样式见 styles.css(scroll-driven 渐进增强) */ +export function ScrollProgress() { + return