Skip to content

Commit 543d048

Browse files
Kosthiclaude
andcommitted
feat: add floating music player with per-language theme songs
Add a MusicPlayer Vue component that plays background music based on the current locale (Dynasty for zh, Dominion for en, Dreams Collide for ja). The player auto-plays on load, persists play/pause state to localStorage, and retries on first user interaction when the browser blocks autoplay. Also switch body font to Geist Sans. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3e47888 commit 543d048

File tree

8 files changed

+167
-2
lines changed

8 files changed

+167
-2
lines changed

public/RushDB_Dominion.mp3

721 KB
Binary file not shown.

public/RushDB_Dreams_Collide.mp3

672 KB
Binary file not shown.

public/RushDB_Dynasty.mp4

6.62 MB
Binary file not shown.

public/fonts/GeistVF.woff2

68.3 KB
Binary file not shown.

src/components/vue/MusicPlayer.vue

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<script setup lang="ts">
2+
import { onMounted, onUnmounted, ref } from 'vue';
3+
import { ui, type Lang } from '../../i18n/ui';
4+
5+
const props = defineProps<{
6+
lang: Lang;
7+
}>();
8+
9+
const translations = ui[props.lang];
10+
const isPlaying = ref(false);
11+
const audioRef = ref<HTMLAudioElement | null>(null);
12+
13+
const musicSrc: Record<Lang, string> = {
14+
zh: '/RushDB_Dynasty.mp4',
15+
en: '/RushDB_Dominion.mp3',
16+
ja: '/RushDB_Dreams_Collide.mp3',
17+
};
18+
19+
const STORAGE_KEY = 'rushdb-music-playing';
20+
21+
function toggle() {
22+
const audio = audioRef.value;
23+
if (!audio) return;
24+
25+
if (isPlaying.value) {
26+
audio.pause();
27+
isPlaying.value = false;
28+
} else {
29+
audio.play().then(() => {
30+
isPlaying.value = true;
31+
}).catch(() => {
32+
isPlaying.value = false;
33+
});
34+
}
35+
36+
try {
37+
localStorage.setItem(STORAGE_KEY, String(isPlaying.value));
38+
} catch {}
39+
}
40+
41+
function onAudioEnded() {
42+
const audio = audioRef.value;
43+
if (audio) {
44+
audio.currentTime = 0;
45+
audio.play().catch(() => {
46+
isPlaying.value = false;
47+
});
48+
}
49+
}
50+
51+
onMounted(() => {
52+
const audio = audioRef.value;
53+
if (!audio) return;
54+
55+
audio.volume = 0.5;
56+
57+
// Autoplay unless user explicitly paused before
58+
try {
59+
const stored = localStorage.getItem(STORAGE_KEY);
60+
if (stored === 'false') return;
61+
} catch {}
62+
63+
audio.play().then(() => {
64+
isPlaying.value = true;
65+
}).catch(() => {
66+
// Browser blocked autoplay; retry on first user interaction
67+
isPlaying.value = false;
68+
const resumeOnce = () => {
69+
audio.play().then(() => {
70+
isPlaying.value = true;
71+
try { localStorage.setItem(STORAGE_KEY, 'true'); } catch {}
72+
}).catch(() => {});
73+
document.removeEventListener('click', resumeOnce);
74+
document.removeEventListener('touchstart', resumeOnce);
75+
document.removeEventListener('keydown', resumeOnce);
76+
};
77+
document.addEventListener('click', resumeOnce, { once: true });
78+
document.addEventListener('touchstart', resumeOnce, { once: true });
79+
document.addEventListener('keydown', resumeOnce, { once: true });
80+
});
81+
});
82+
83+
onUnmounted(() => {
84+
const audio = audioRef.value;
85+
if (audio) {
86+
audio.pause();
87+
}
88+
});
89+
</script>
90+
91+
<template>
92+
<button
93+
class="music-player"
94+
:class="{ 'is-playing': isPlaying }"
95+
type="button"
96+
:aria-label="translations.a11y.musicToggle"
97+
@click="toggle"
98+
>
99+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
100+
<path d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6Z" />
101+
</svg>
102+
</button>
103+
<audio ref="audioRef" preload="auto" :src="musicSrc[lang]" @ended="onAudioEnded" />
104+
</template>

src/i18n/ui.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ export const ui = {
196196
scrollProgress: '页面滚动进度',
197197
mainNavigation: '主导航',
198198
backToTop: '返回顶部',
199+
musicToggle: '播放/暂停背景音乐',
199200
},
200201
blog: {
201202
title: '博客',
@@ -404,6 +405,7 @@ export const ui = {
404405
scrollProgress: 'Page scroll progress',
405406
mainNavigation: 'Main navigation',
406407
backToTop: 'Back to top',
408+
musicToggle: 'Toggle background music',
407409
},
408410
blog: {
409411
title: 'Blog',
@@ -612,6 +614,7 @@ export const ui = {
612614
scrollProgress: 'ページスクロール進捗',
613615
mainNavigation: 'メインナビゲーション',
614616
backToTop: 'トップへ戻る',
617+
musicToggle: 'BGMの再生/一時停止',
615618
},
616619
blog: {
617620
title: 'ブログ',

src/layouts/BaseLayout.astro

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import '../styles/global.css';
33
import { getI18n, type Lang } from '../i18n';
44
import NavBar from '../components/vue/NavBar.vue';
55
import BackToTop from '../components/vue/BackToTop.vue';
6+
import MusicPlayer from '../components/vue/MusicPlayer.vue';
67
78
interface Props {
89
title: string;
@@ -61,7 +62,7 @@ const metaDescription = description || descriptions[lang];
6162
<meta name="theme-color" content="#050712" media="(prefers-color-scheme: dark)" />
6263
<meta name="theme-color" content="#fdf6e3" media="(prefers-color-scheme: light)" />
6364
<link
64-
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&display=swap"
65+
href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&display=swap"
6566
rel="stylesheet"
6667
/>
6768

@@ -152,6 +153,7 @@ const metaDescription = description || descriptions[lang];
152153
<slot />
153154

154155
<BackToTop client:idle lang={lang} />
156+
<MusicPlayer client:idle lang={lang} />
155157

156158
<script is:inline>
157159
document.documentElement.classList.add('js');

src/styles/global.css

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
@import './theme-tokens.css';
22

3+
@font-face {
4+
font-family: "Geist Sans";
5+
src: url("/fonts/GeistVF.woff2") format("woff2");
6+
font-weight: 100 900;
7+
font-style: normal;
8+
font-display: swap;
9+
}
10+
311
:root {
412
/* Typography Scale - 1.25 ratio (Major Third) */
513
--text-xs: 0.75rem;
@@ -113,7 +121,7 @@
113121
}
114122

115123
body {
116-
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
124+
font-family: "Geist Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
117125
Roboto, sans-serif;
118126
background: var(--bg-primary);
119127
color: var(--text-primary);
@@ -2542,6 +2550,54 @@
25422550
}
25432551
}
25442552

2553+
/* Music Player */
2554+
.music-player {
2555+
position: fixed;
2556+
bottom: 2rem;
2557+
left: 2rem;
2558+
z-index: 1000;
2559+
width: 48px;
2560+
height: 48px;
2561+
border-radius: 50%;
2562+
background: var(--accent-gradient);
2563+
border: none;
2564+
color: white;
2565+
cursor: pointer;
2566+
display: flex;
2567+
align-items: center;
2568+
justify-content: center;
2569+
box-shadow: var(--shadow-lg);
2570+
transition: transform 0.3s ease, opacity 0.3s ease, box-shadow 0.3s ease;
2571+
animation: fadeInUp 0.3s ease;
2572+
}
2573+
2574+
.music-player:hover {
2575+
transform: translateY(-4px);
2576+
box-shadow: 0 12px 40px rgba(34, 211, 238, 0.4);
2577+
}
2578+
2579+
.music-player:active {
2580+
transform: translateY(-2px);
2581+
}
2582+
2583+
.music-player.is-playing {
2584+
animation: spin 3s linear infinite;
2585+
}
2586+
2587+
@keyframes spin {
2588+
from { transform: rotate(0deg); }
2589+
to { transform: rotate(360deg); }
2590+
}
2591+
2592+
@media (max-width: 768px) {
2593+
.music-player {
2594+
bottom: 1.5rem;
2595+
left: 1.5rem;
2596+
width: 44px;
2597+
height: 44px;
2598+
}
2599+
}
2600+
25452601
@media (prefers-reduced-motion: reduce) {
25462602
* {
25472603
animation: none !important;

0 commit comments

Comments
 (0)