Skip to content

Commit c435ed1

Browse files
committed
feat: refactor music player architecture and add per-post audio support
- Add MusicManager.astro (singleton audio engine with fm:* event bus) - Add MusicPlayer.astro (pure UI widget, subscribes to fm:* events) - Add Music.astro sidebar widget wrapper - Add AudioPlayer.astro for per-post podcast audio playback - Implement cross-player mutual exclusion (firefly:podcast:play / firefly:music:play) - Add 18 new music i18n keys across all 5 language files - Enable music-player in sidebar (position: top, between announcement and categories) - Add music note button to navbar before theme color button - Add music-player to WidgetComponentType - Fix script ordering: MusicManager now renders before slot in Layout.astro
1 parent 1ededd9 commit c435ed1

22 files changed

Lines changed: 1769 additions & 257 deletions

File tree

src/components/features/MusicManager.astro

Lines changed: 454 additions & 0 deletions
Large diffs are not rendered by default.

src/components/features/MusicPlayer.astro

Lines changed: 680 additions & 0 deletions
Large diffs are not rendered by default.

src/components/layout/Navbar.astro

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import WallpaperSwitch from "@/components/interactive/WallpaperSwitch.svelte";
1313
import LanguageSwitch from "@/components/interactive/LanguageSwitch.svelte";
1414
import DropdownMenu from "./DropdownMenu.astro";
1515
import NavMenuPanel from "./NavMenuPanel.astro";
16+
import MusicPlayer from "@/components/features/MusicPlayer.astro";
17+
import { musicPlayerConfig } from "@/config/musicConfig";
1618
1719
const className = Astro.props.class;
1820
@@ -64,6 +66,11 @@ let links: NavBarLink[] = navBarConfig.links.map(
6466
<!--<SearchPanel client:load>-->
6567
<Search client:load></Search>
6668
<LanguageSwitch client:load />
69+
{musicPlayerConfig.showInNavbar && (
70+
<button aria-label="Music Player" class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="music-player-switch">
71+
<Icon name="material-symbols:music-note-rounded" class="text-[1.25rem]"></Icon>
72+
</button>
73+
)}
6774
{!siteConfig.themeColor.fixed && (
6875
<button aria-label="Display Settings" class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="display-settings-switch">
6976
<Icon name="material-symbols:palette-outline" class="text-[1.25rem]"></Icon>
@@ -78,6 +85,11 @@ let links: NavBarLink[] = navBarConfig.links.map(
7885
</div>
7986
<NavMenuPanel links={links}></NavMenuPanel>
8087
<DisplaySettings client:load></DisplaySettings>
88+
{musicPlayerConfig.showInNavbar && (
89+
<div id="music-nav-panel" class="float-panel float-panel-closed absolute top-[4.5rem] right-4 w-80 z-50 card-base p-4 shadow-xl">
90+
<MusicPlayer />
91+
</div>
92+
)}
8193
</div>
8294
</div>
8395

@@ -93,6 +105,16 @@ function switchTheme() {
93105
}
94106

95107
function loadButtonScript() {
108+
let musicBtn = document.getElementById("music-player-switch");
109+
if (musicBtn) {
110+
musicBtn.onclick = function () {
111+
let musicPanel = document.getElementById("music-nav-panel");
112+
if (musicPanel) {
113+
musicPanel.classList.toggle("float-panel-closed");
114+
}
115+
};
116+
}
117+
96118
let settingBtn = document.getElementById("display-settings-switch");
97119
if (settingBtn) {
98120
settingBtn.onclick = function () {

src/components/layout/SideBar.astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Profile from "@/components/content/Profile.astro";
77
import Tags from "@/components/widget/Tags.astro";
88
import TOC from "@/components/widget/TOC.astro";
99
import Advertisement from "@/components/widget/Advertisement.astro";
10-
import MusicPlayer from "@/components/widget/MusicPlayer.svelte";
10+
import Music from "@/components/widget/Music.astro";
1111
1212
interface Props {
1313
class?: string;
@@ -39,7 +39,7 @@ const componentMap = {
3939
tags: Tags,
4040
toc: TOC,
4141
advertisement: Advertisement,
42-
"music-player": MusicPlayer,
42+
"music-player": Music,
4343
};
4444
4545
// 渲染组件的辅助函数
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
---
2+
/**
3+
* AudioPlayer.astro
4+
* A podcast-style audio player for blog posts.
5+
* Usage: <AudioPlayer src="/path/to/audio.mp3" title="Post Title" />
6+
*/
7+
interface Props {
8+
src: string;
9+
title?: string;
10+
}
11+
12+
const { src, title = "AI Podcast Version" } = Astro.props;
13+
14+
// Generate a unique ID so multiple players on one page don't conflict
15+
const uid = `audio-player-${Math.random().toString(36).slice(2, 9)}`;
16+
---
17+
18+
<div
19+
id={uid}
20+
class="audio-player-root not-prose mb-6 rounded-[var(--radius-large)] overflow-hidden onload-animation"
21+
aria-label="Podcast audio player"
22+
>
23+
<!-- Hidden native audio element -->
24+
<audio id={`${uid}-audio`} src={src} preload="metadata" class="hidden"></audio>
25+
26+
<!-- Player card -->
27+
<div class="relative flex flex-col gap-3 p-4 bg-[var(--btn-regular-bg)] dark:bg-[oklch(0.22_0.015_var(--hue))] border border-[var(--line-color)] rounded-[var(--radius-large)] transition-all">
28+
29+
<!-- Top row: badge + title -->
30+
<div class="flex items-center gap-3">
31+
<!-- Podcast icon badge -->
32+
<div class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl bg-[var(--primary)] text-white shadow-md">
33+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
34+
<path d="M12 3a9 9 0 1 0 9 9 9 9 0 0 0-9-9Zm0 16a7 7 0 1 1 7-7 7 7 0 0 1-7 7Z"/>
35+
<circle cx="12" cy="12" r="3"/>
36+
<path d="M12 1v3M12 20v3M1 12h3M20 12h3"/>
37+
</svg>
38+
</div>
39+
40+
<div class="flex-1 min-w-0">
41+
<div class="flex items-center gap-2 mb-0.5">
42+
<span class="text-xs font-semibold uppercase tracking-wider text-[var(--primary)] select-none">
43+
AI Podcast
44+
</span>
45+
<!-- Animated waveform (visible when playing) -->
46+
<div id={`${uid}-wave`} class="wave-bars hidden items-center gap-[2px] h-3" aria-hidden="true">
47+
<span class="wave-bar"></span>
48+
<span class="wave-bar"></span>
49+
<span class="wave-bar"></span>
50+
<span class="wave-bar"></span>
51+
<span class="wave-bar"></span>
52+
</div>
53+
</div>
54+
<p class="text-sm font-medium text-black/80 dark:text-white/80 truncate leading-tight">
55+
{title}
56+
</p>
57+
</div>
58+
</div>
59+
60+
<!-- Progress bar row -->
61+
<div class="flex items-center gap-2">
62+
<span id={`${uid}-current`} class="text-xs tabular-nums text-black/40 dark:text-white/40 w-10 text-right flex-shrink-0">0:00</span>
63+
64+
<div
65+
id={`${uid}-track`}
66+
class="relative flex-1 h-1.5 rounded-full bg-black/10 dark:bg-white/10 cursor-pointer group"
67+
role="slider"
68+
aria-label="Playback progress"
69+
aria-valuemin="0"
70+
aria-valuemax="100"
71+
aria-valuenow="0"
72+
tabindex="0"
73+
>
74+
<!-- Filled portion -->
75+
<div
76+
id={`${uid}-fill`}
77+
class="absolute inset-y-0 left-0 rounded-full bg-[var(--primary)] transition-none"
78+
style="width: 0%"
79+
></div>
80+
<!-- Draggable thumb -->
81+
<div
82+
id={`${uid}-thumb`}
83+
class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rounded-full bg-[var(--primary)] shadow opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
84+
style="left: 0%"
85+
></div>
86+
</div>
87+
88+
<span id={`${uid}-duration`} class="text-xs tabular-nums text-black/40 dark:text-white/40 w-10 flex-shrink-0">0:00</span>
89+
</div>
90+
91+
<!-- Controls row -->
92+
<div class="flex items-center justify-between">
93+
<!-- Playback speed -->
94+
<button
95+
id={`${uid}-speed`}
96+
class="text-xs font-semibold px-2 py-1 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 hover:bg-[var(--btn-regular-bg-hover)] hover:text-[var(--primary)] transition-colors select-none"
97+
aria-label="Playback speed"
98+
title="Playback speed"
99+
>
100+
101+
</button>
102+
103+
<!-- Rewind 15s / Play / Forward 15s -->
104+
<div class="flex items-center gap-3">
105+
<!-- Rewind 15s -->
106+
<button
107+
id={`${uid}-back`}
108+
class="w-8 h-8 flex items-center justify-center rounded-full text-black/50 dark:text-white/50 hover:text-[var(--primary)] hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
109+
aria-label="Rewind 15 seconds"
110+
title="Back 15s"
111+
>
112+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
113+
<path d="M12 5V2L7 7l5 5V9c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
114+
<text x="7.5" y="16" font-size="6" fill="currentColor" font-family="sans-serif" font-weight="bold">15</text>
115+
</svg>
116+
</button>
117+
118+
<!-- Play / Pause -->
119+
<button
120+
id={`${uid}-play`}
121+
class="w-12 h-12 flex items-center justify-center rounded-full bg-[var(--primary)] text-white shadow-md hover:scale-105 active:scale-95 transition-transform"
122+
aria-label="Play"
123+
>
124+
<!-- Play icon -->
125+
<svg id={`${uid}-icon-play`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 ml-0.5">
126+
<path d="M8 5v14l11-7z"/>
127+
</svg>
128+
<!-- Pause icon (hidden by default) -->
129+
<svg id={`${uid}-icon-pause`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 hidden">
130+
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
131+
</svg>
132+
</button>
133+
134+
<!-- Forward 15s -->
135+
<button
136+
id={`${uid}-forward`}
137+
class="w-8 h-8 flex items-center justify-center rounded-full text-black/50 dark:text-white/50 hover:text-[var(--primary)] hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
138+
aria-label="Forward 15 seconds"
139+
title="Forward 15s"
140+
>
141+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
142+
<path d="M12 5V2l5 5-5 5V9c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
143+
<text x="7.5" y="16" font-size="6" fill="currentColor" font-family="sans-serif" font-weight="bold">15</text>
144+
</svg>
145+
</button>
146+
</div>
147+
148+
<!-- Volume / mute toggle -->
149+
<button
150+
id={`${uid}-mute`}
151+
class="w-8 h-8 flex items-center justify-center rounded-full text-black/50 dark:text-white/50 hover:text-[var(--primary)] hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
152+
aria-label="Mute / Unmute"
153+
title="Mute"
154+
>
155+
<svg id={`${uid}-icon-vol`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
156+
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
157+
</svg>
158+
<svg id={`${uid}-icon-mute`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 hidden">
159+
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
160+
</svg>
161+
</button>
162+
</div>
163+
</div>
164+
</div>
165+
166+
<style>
167+
/* Animated waveform bars */
168+
.wave-bar {
169+
display: block;
170+
width: 2px;
171+
border-radius: 2px;
172+
background-color: var(--primary);
173+
animation: wave 1s ease-in-out infinite;
174+
transform-origin: bottom;
175+
}
176+
.wave-bar:nth-child(1) { height: 8px; animation-delay: 0s; }
177+
.wave-bar:nth-child(2) { height: 12px; animation-delay: 0.1s; }
178+
.wave-bar:nth-child(3) { height: 6px; animation-delay: 0.2s; }
179+
.wave-bar:nth-child(4) { height: 10px; animation-delay: 0.3s; }
180+
.wave-bar:nth-child(5) { height: 8px; animation-delay: 0.15s; }
181+
182+
@keyframes wave {
183+
0%, 100% { transform: scaleY(0.5); }
184+
50% { transform: scaleY(1); }
185+
}
186+
</style>
187+
188+
<script define:vars={{ uid }}>
189+
(function () {
190+
const root = document.getElementById(uid);
191+
if (!root) return;
192+
193+
const audio = document.getElementById(`${uid}-audio`);
194+
const playBtn = document.getElementById(`${uid}-play`);
195+
const iconPlay = document.getElementById(`${uid}-icon-play`);
196+
const iconPause = document.getElementById(`${uid}-icon-pause`);
197+
const waveBars = document.getElementById(`${uid}-wave`);
198+
const backBtn = document.getElementById(`${uid}-back`);
199+
const fwdBtn = document.getElementById(`${uid}-forward`);
200+
const speedBtn = document.getElementById(`${uid}-speed`);
201+
const muteBtn = document.getElementById(`${uid}-mute`);
202+
const iconVol = document.getElementById(`${uid}-icon-vol`);
203+
const iconMute = document.getElementById(`${uid}-icon-mute`);
204+
const track = document.getElementById(`${uid}-track`);
205+
const fill = document.getElementById(`${uid}-fill`);
206+
const thumb = document.getElementById(`${uid}-thumb`);
207+
const currentEl = document.getElementById(`${uid}-current`);
208+
const durationEl= document.getElementById(`${uid}-duration`);
209+
210+
const SPEEDS = [0.8, 1, 1.25, 1.5, 2];
211+
let speedIdx = 1;
212+
213+
function fmt(s) {
214+
if (isNaN(s)) return '0:00';
215+
const m = Math.floor(s / 60);
216+
const sec = Math.floor(s % 60).toString().padStart(2, '0');
217+
return `${m}:${sec}`;
218+
}
219+
220+
function updateProgress() {
221+
const pct = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0;
222+
fill.style.width = `${pct}%`;
223+
thumb.style.left = `${pct}%`;
224+
track.setAttribute('aria-valuenow', Math.round(pct));
225+
currentEl.textContent = fmt(audio.currentTime);
226+
}
227+
228+
function setPlaying(playing) {
229+
if (playing) {
230+
iconPlay.classList.add('hidden');
231+
iconPause.classList.remove('hidden');
232+
waveBars.classList.remove('hidden');
233+
waveBars.classList.add('flex');
234+
playBtn.setAttribute('aria-label', 'Pause');
235+
} else {
236+
iconPlay.classList.remove('hidden');
237+
iconPause.classList.add('hidden');
238+
waveBars.classList.add('hidden');
239+
waveBars.classList.remove('flex');
240+
playBtn.setAttribute('aria-label', 'Play');
241+
}
242+
}
243+
244+
// Cross-player coordination via window events
245+
audio.addEventListener('play', () => {
246+
// Tell global music player to pause
247+
window.dispatchEvent(new CustomEvent('firefly:podcast:play'));
248+
setPlaying(true);
249+
});
250+
audio.addEventListener('pause', () => {
251+
window.dispatchEvent(new CustomEvent('firefly:podcast:pause'));
252+
setPlaying(false);
253+
});
254+
audio.addEventListener('ended', () => {
255+
window.dispatchEvent(new CustomEvent('firefly:podcast:pause'));
256+
setPlaying(false);
257+
});
258+
259+
// When global music player resumes, pause this podcast player
260+
function onMusicPlay() {
261+
if (!audio.paused) { audio.pause(); }
262+
}
263+
window.addEventListener('firefly:music:play', onMusicPlay);
264+
265+
// Clean up listener when navigating away (Astro view transitions)
266+
document.addEventListener('astro:before-swap', () => {
267+
window.removeEventListener('firefly:music:play', onMusicPlay);
268+
audio.pause();
269+
}, { once: true });
270+
271+
// Play / Pause button
272+
playBtn.addEventListener('click', () => {
273+
if (audio.paused) { audio.play(); } else { audio.pause(); }
274+
});
275+
276+
// Duration / time update
277+
audio.addEventListener('loadedmetadata', () => {
278+
durationEl.textContent = fmt(audio.duration);
279+
});
280+
audio.addEventListener('timeupdate', updateProgress);
281+
282+
// Rewind / Forward
283+
backBtn.addEventListener('click', () => { audio.currentTime = Math.max(0, audio.currentTime - 15); });
284+
fwdBtn.addEventListener('click', () => { audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + 15); });
285+
286+
// Speed cycle
287+
speedBtn.addEventListener('click', () => {
288+
speedIdx = (speedIdx + 1) % SPEEDS.length;
289+
audio.playbackRate = SPEEDS[speedIdx];
290+
const label = SPEEDS[speedIdx] === 1 ? '1×' : `${SPEEDS[speedIdx]}×`;
291+
speedBtn.textContent = label;
292+
});
293+
294+
// Mute toggle
295+
muteBtn.addEventListener('click', () => {
296+
audio.muted = !audio.muted;
297+
iconVol.classList.toggle('hidden', audio.muted);
298+
iconMute.classList.toggle('hidden', !audio.muted);
299+
});
300+
301+
// Seek on progress bar click / drag
302+
let isDragging = false;
303+
304+
function seek(e) {
305+
const rect = track.getBoundingClientRect();
306+
const x = (e.clientX ?? e.touches?.[0]?.clientX ?? 0) - rect.left;
307+
const pct = Math.max(0, Math.min(1, x / rect.width));
308+
if (audio.duration) { audio.currentTime = pct * audio.duration; }
309+
}
310+
311+
track.addEventListener('mousedown', (e) => { isDragging = true; seek(e); });
312+
track.addEventListener('touchstart', (e) => { isDragging = true; seek(e); }, { passive: true });
313+
document.addEventListener('mousemove', (e) => { if (isDragging) seek(e); });
314+
document.addEventListener('touchmove', (e) => { if (isDragging) seek(e); }, { passive: true });
315+
document.addEventListener('mouseup', () => { isDragging = false; });
316+
document.addEventListener('touchend', () => { isDragging = false; });
317+
318+
// Keyboard seek on track
319+
track.addEventListener('keydown', (e) => {
320+
if (e.key === 'ArrowRight') audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + 5);
321+
if (e.key === 'ArrowLeft') audio.currentTime = Math.max(0, audio.currentTime - 5);
322+
});
323+
})();
324+
</script>

0 commit comments

Comments
 (0)