blog / src /components /features /MusicPlayer.astro
cacode's picture
Upload 434 files
96dd062 verified
---
import { Icon } from "astro-icon/components";
import { musicPlayerConfig } from "@/config/musicConfig";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import { url } from "@/utils/url-utils";
interface Props {
class?: string;
style?: string;
id?: string;
}
const { class: className, style, id } = Astro.props;
const config = musicPlayerConfig;
const localPlaylist =
config.mode === "local" && config.local?.playlist
? config.local.playlist.map((song) => {
const isFullUrl = (path: string) => /^https?:\/\//.test(path);
return {
name: song.name,
artist: song.artist,
url: isFullUrl(song.url) ? song.url : url(song.url),
pic: song.cover
? isFullUrl(song.cover)
? song.cover
: url(song.cover)
: undefined,
lrc: song.lrc
? isFullUrl(song.lrc)
? song.lrc
: url(song.lrc)
: undefined,
};
})
: [];
const playerConfigStr = JSON.stringify({
mode: config.mode,
meting: config.meting,
localPlaylist: localPlaylist,
volume: config.volume ?? 0.7,
playMode: config.playMode ?? "list",
showLyrics: config.showLyrics ?? true,
i18n: {
noPlaying: i18n(I18nKey.musicNoPlaying),
lyrics: i18n(I18nKey.musicLyrics),
volume: i18n(I18nKey.musicVolume),
playMode: i18n(I18nKey.musicPlayMode),
prev: i18n(I18nKey.musicPrev),
next: i18n(I18nKey.musicNext),
playlist: i18n(I18nKey.musicPlaylist),
noLyrics: i18n(I18nKey.musicNoLyrics),
loadingLyrics: i18n(I18nKey.musicLoadingLyrics),
failedLyrics: i18n(I18nKey.musicFailedLyrics),
noSongs: i18n(I18nKey.musicNoSongs),
error: i18n(I18nKey.musicError),
play: i18n(I18nKey.musicPlay),
pause: i18n(I18nKey.musicPause),
progress: i18n(I18nKey.musicProgress),
noCover: i18n(I18nKey.musicNoCover),
},
});
const widgetId =
id || `music-widget-${Math.random().toString(36).substring(2, 9)}`;
---
<div id={widgetId} class:list={["music-player-widget w-full relative transition-all duration-300", className]} style={style} role="region" aria-label={i18n(I18nKey.music)}>
<!-- Top Row: Cover & Info -->
<div class="flex items-center gap-2 mb-2 px-1">
<!-- Circular Cover -->
<div class="relative shrink-0 w-14 h-14 group">
<div class="w-full h-full rounded-full overflow-hidden shadow-lg border-2 border-white dark:border-neutral-700 relative z-10 bg-[var(--primary)]/10 flex items-center justify-center">
<Icon name="material-symbols:music-note-rounded" class="text-2xl text-[var(--primary)] opacity-40 absolute" aria-hidden="true" />
<img class="music-cover w-full h-full object-cover animate-spin-slow relative z-10 opacity-0 transition-opacity duration-300" src="" alt={i18n(I18nKey.musicCover)} style="animation-play-state: paused;" />
</div>
</div>
<!-- Info Section -->
<div class="flex-1 min-w-0 flex flex-col overflow-hidden">
<div class="flex items-center justify-between overflow-hidden gap-2">
<div class="flex-1 min-w-0 overflow-hidden relative">
<h3 class="music-title font-bold text-base text-neutral-800 dark:text-neutral-100 leading-tight truncate">
{i18n(I18nKey.music)}
</h3>
</div>
<!-- Top Right: Lyric Toggle -->
<button class={`btn-lrc-toggle hover:text-[var(--primary)] transition-all duration-300 p-0.5 pr-2 transform active:scale-95 text-neutral-400 shrink-0 ${!config.showLyrics ? 'hidden' : ''}`} title={i18n(I18nKey.musicLyrics)} aria-label={i18n(I18nKey.musicLyrics)}>
<Icon name="material-symbols:subtitles-off-outline-rounded" class="icon-lrc-off text-xl" aria-hidden="true" />
<Icon name="material-symbols:subtitles-outline-rounded" class="icon-lrc-on text-xl hidden" aria-hidden="true" />
</button>
</div>
<div class="min-w-0 overflow-hidden">
<p class="music-artist text-xs font-medium text-neutral-500 dark:text-neutral-400 truncate">
{i18n(I18nKey.musicNoPlaying)}
</p>
</div>
<!-- Time Display & Volume -->
<div class="flex items-center gap-3 text-neutral-400 h-5">
<div class="text-[10px] font-mono flex items-center gap-1 shrink-0 h-full" aria-live="polite">
<span class="current-time">0:00</span>
<span class="opacity-50" aria-hidden="true">/</span>
<span class="total-time">0:00</span>
</div>
<!-- Volume (Always visible) -->
<div class="flex items-center gap-1 bg-transparent h-full">
<button class="btn-mute hover:text-[var(--primary)] transition-colors p-0.5 rounded-md flex items-center" title={i18n(I18nKey.musicVolume)} aria-label={i18n(I18nKey.musicVolume)}>
<Icon name="material-symbols:volume-up-rounded" class=" icon-vol-high text-lg" aria-hidden="true" />
<Icon name="material-symbols:volume-off-rounded" class="icon-vol-mute text-lg hidden" aria-hidden="true" />
</button>
<div class="w-16 transition-all duration-300 ease-out flex items-center">
<div class="vol-container h-1 w-16 bg-neutral-200 dark:bg-neutral-600 rounded-full cursor-pointer relative ml-1" role="slider" aria-label={i18n(I18nKey.musicVolume)} aria-valuemin="0" aria-valuemax="100" aria-valuenow="70">
<div class="vol-bar absolute left-0 top-0 h-full bg-[var(--primary)] rounded-full w-[70%]"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Progress Bar (Slim) -->
<div class="progress-container relative w-full h-1 bg-neutral-100 dark:bg-neutral-700/50 rounded-full cursor-pointer touch-none mb-2 group mt-2" role="slider" aria-label={i18n(I18nKey.musicProgress)} aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="progress-bar absolute left-0 top-0 h-full bg-[var(--primary)] rounded-full w-0 transition-[width] duration-100"></div>
<!-- Hover Thumb -->
<div class="progress-thumb absolute top-1/2 -mt-1.5 -ml-1.5 w-3 h-3 bg-[var(--primary)] ring-2 ring-white dark:ring-neutral-800 rounded-full shadow-sm scale-0 group-hover:scale-100 transition-transform duration-200"></div>
</div>
<!-- Controls Row -->
<div class="flex items-center justify-between px-1 select-none">
<!-- Repeat Mode -->
<button class="btn-repeat text-neutral-300 dark:text-neutral-600 hover:text-[var(--primary)] transition-colors p-2 active:scale-95" title={i18n(I18nKey.musicPlayMode)} aria-label={i18n(I18nKey.musicPlayMode)}>
<Icon name="material-symbols:repeat-rounded" class="icon-repeat text-xl" aria-hidden="true" />
<Icon name="material-symbols:repeat-one-rounded" class="icon-repeat-one text-xl hidden" aria-hidden="true" />
<Icon name="material-symbols:shuffle-rounded" class="icon-shuffle text-xl hidden" aria-hidden="true" />
</button>
<!-- Prev -->
<button class="btn-prev text-neutral-600 dark:text-neutral-300 hover:text-[var(--primary)] transition-colors p-2 active:scale-95" title={i18n(I18nKey.musicPrev)} aria-label={i18n(I18nKey.musicPrev)}>
<Icon name="material-symbols:skip-previous-rounded" class="text-3xl" aria-hidden="true" />
</button>
<!-- Play/Pause (Feature) -->
<button class="btn-play w-12 h-12 bg-[var(--btn-regular-bg)] hover:bg-[var(--btn-regular-bg-hover)] active:bg-[var(--btn-regular-bg-active)] text-[var(--primary)] rounded-full flex items-center justify-center transition-all duration-300" title={i18n(I18nKey.musicPlay)} aria-label={i18n(I18nKey.musicPlay)}>
<Icon name="material-symbols:play-arrow-rounded" class="icon-play text-3xl ml-0.5" aria-hidden="true" />
<Icon name="material-symbols:pause-rounded" class="icon-pause text-3xl hidden" aria-hidden="true" />
</button>
<!-- Next -->
<button class="btn-next text-neutral-600 dark:text-neutral-300 hover:text-[var(--primary)] transition-colors p-2 active:scale-95" title={i18n(I18nKey.musicNext)} aria-label={i18n(I18nKey.musicNext)}>
<Icon name="material-symbols:skip-next-rounded" class="text-3xl" aria-hidden="true" />
</button>
<!-- Playlist Toggle Arrow -->
<button class="btn-drawer-toggle text-neutral-400 hover:text-[var(--primary)] transition-all duration-300 p-2 transform active:scale-95" title={i18n(I18nKey.musicPlaylist)} aria-label={i18n(I18nKey.musicPlaylist)}>
<Icon name="mdi:playlist-music" class="text-xl" aria-hidden="true" />
</button>
</div>
<!-- Lyrics Drawer -->
<div class="lrc-drawer grid transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] grid-rows-[0fr] opacity-0">
<div class="overflow-hidden min-h-0">
<div class="mt-2 pt-2 border-t border-neutral-100 dark:border-white/5 mx-1">
<div class="lrc-container h-48 overflow-y-auto custom-scrollbar flex flex-col items-center gap-2 p-4 py-24 text-center relative scroll-smooth" role="listbox" aria-label={i18n(I18nKey.musicLyrics)}>
<div class="text-neutral-400 text-sm py-10" role="option">{i18n(I18nKey.musicNoLyrics)}</div>
</div>
</div>
</div>
</div>
<!-- Playlist Drawer (Accordion) -->
<div class="playlist-drawer grid transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] grid-rows-[0fr] opacity-0">
<div class="overflow-hidden min-h-0">
<div class="mt-2 pt-2 border-t border-neutral-100 dark:border-white/5 mx-1">
<div class="playlist-container max-h-48 overflow-y-auto custom-scrollbar pr-1 pb-1 flex flex-col gap-1" role="listbox" aria-label={i18n(I18nKey.musicPlaylist)}>
<!-- Items -->
</div>
</div>
</div>
</div>
<!-- Hidden Audio -->
<audio class="audio-player hidden" crossorigin="anonymous" title={i18n(I18nKey.musicAudioPlayer)}></audio>
<!-- Loading Overlay -->
<div class="music-loading absolute inset-0 z-20 flex flex-col items-center justify-center bg-white/60 dark:bg-[#1e1e1e]/60 backdrop-blur-[2px] transition-opacity duration-300 opacity-0 pointer-events-none rounded-xl" aria-busy="true" aria-hidden="true">
<div class="w-8 h-8 text-[var(--primary)] animate-spin">
<Icon name="material-symbols:sync-rounded" class="text-3xl" aria-hidden="true" />
</div>
</div>
</div>
<template id={`playlist-item-template-${widgetId}`}>
<div class="playlist-item flex items-center gap-3 p-2 rounded-lg cursor-pointer hover:bg-neutral-50 dark:hover:bg-white/5 transition-colors group">
<div class="w-8 h-8 rounded-md overflow-hidden shrink-0 relative bg-neutral-200 dark:bg-neutral-700">
<img src="" class="item-cover w-full h-full object-cover" loading="lazy" alt="" />
<!-- Active Indicator overlay -->
<div class="item-active-overlay absolute inset-0 bg-[var(--primary)]/20 hidden items-center justify-center">
<Icon name="material-symbols:graphic-eq-rounded" class="text-[var(--primary)] text-sm" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="item-title text-xs font-bold text-neutral-700 dark:text-neutral-200 truncate group-hover:text-[var(--primary)] transition-colors"></div>
<div class="item-artist text-[10px] text-neutral-400 truncate"></div>
</div>
</div>
</template>
<script define:vars={{ playerConfigStr, widgetId }} is:inline>
(function () {
const config = JSON.parse(playerConfigStr);
const widget = document.getElementById(widgetId);
// If widget not found (e.g. removed), stop
if (!widget) return;
const ui = {
widget: widget,
loading: widget.querySelector('.music-loading'),
cover: widget.querySelector('.music-cover'),
title: widget.querySelector('.music-title'),
artist: widget.querySelector('.music-artist'),
progressBar: widget.querySelector('.progress-bar'),
progressThumb: widget.querySelector('.progress-thumb'),
progressContainer: widget.querySelector('.progress-container'),
currentTime: widget.querySelector('.current-time'),
totalTime: widget.querySelector('.total-time'),
btnPlay: widget.querySelector('.btn-play'),
iconPlay: widget.querySelector('.icon-play'),
iconPause: widget.querySelector('.icon-pause'),
btnPrev: widget.querySelector('.btn-prev'),
btnNext: widget.querySelector('.btn-next'),
btnRepeat: widget.querySelector('.btn-repeat'),
iconRepeat: widget.querySelector('.icon-repeat'),
iconRepeatOne: widget.querySelector('.icon-repeat-one'),
iconShuffle: widget.querySelector('.icon-shuffle'),
btnMute: widget.querySelector('.btn-mute'),
iconVolHigh: widget.querySelector('.icon-vol-high'),
iconVolMute: widget.querySelector('.icon-vol-mute'),
volContainer: widget.querySelector('.vol-container'),
volBar: widget.querySelector('.vol-bar'),
btnLrc: widget.querySelector('.btn-lrc-toggle'),
iconLrcOn: widget.querySelector('.icon-lrc-on'),
iconLrcOff: widget.querySelector('.icon-lrc-off'),
lrcDrawer: widget.querySelector('.lrc-drawer'),
lrcContainer: widget.querySelector('.lrc-container'),
btnDrawer: widget.querySelector('.btn-drawer-toggle'),
playlistDrawer: widget.querySelector('.playlist-drawer'),
playlistContainer: widget.querySelector('.playlist-container'),
audio: widget.querySelector('.audio-player'),
itemTemplate: document.getElementById(`playlist-item-template-${widgetId}`)
};
// Verify all critical UI elements exist (may be null during Swup page transitions)
const _critical = [ui.btnPlay, ui.audio, ui.btnRepeat, ui.btnMute, ui.volContainer,
ui.btnDrawer, ui.btnLrc, ui.lrcDrawer, ui.lrcContainer,
ui.progressContainer, ui.btnNext, ui.btnPrev, ui.loading,
ui.cover, ui.title, ui.artist, ui.playlistContainer, ui.itemTemplate];
if (_critical.some(function(el) { return !el; })) return;
const state = {
playlist: [],
currentIndex: 0,
isPlaying: false,
isLoading: false,
playMode: 0, // 0: List, 1: One, 2: Rand
lastVolume: localStorage.getItem('music-player-volume') !== null
? parseFloat(localStorage.getItem('music-player-volume'))
: (config.volume ?? 0.7),
isMuted: false,
lyrics: [], // Parsed lyrics: {time: seconds, text: string}
currentLrcIndex: -1,
isUserScrolling: false,
scrollTimeout: null
};
// Initialize state from config
if (config.playMode === 'random') {
state.playMode = 2; // Rand
} else if (config.playMode === 'one') {
state.playMode = 1; // One
} else {
state.playMode = 0; // List (default)
}
// UI Helpers
function setLoading(bool) {
state.isLoading = bool;
if (bool) {
ui.loading.classList.remove('opacity-0', 'pointer-events-none');
} else {
ui.loading.classList.add('opacity-0', 'pointer-events-none');
}
}
function formatTime(seconds) {
if (!seconds || isNaN(seconds)) return "0:00";
const min = Math.floor(seconds / 60);
const sec = Math.floor(seconds % 60);
return `${min}:${sec.toString().padStart(2, '0')}`;
}
function parseLRC(lrc) {
if (!lrc) return [];
const lines = lrc.split('\n');
const result = [];
const timeReg = /\[(\d{2}):(\d{2})\.(\d{2,3})\]/g;
lines.forEach(line => {
const matches = Array.from(line.matchAll(timeReg));
if (matches.length > 0) {
const text = line.replace(timeReg, '').trim();
if (text) {
matches.forEach(match => {
const m = parseInt(match[1]);
const s = parseInt(match[2]);
const ms = parseInt(match[3]);
const time = m * 60 + s + ms / (match[3].length === 3 ? 1000 : 100);
result.push({ time, text });
});
}
}
});
return result.sort((a, b) => a.time - b.time);
}
function renderLyrics(lrcText) {
state.lyrics = parseLRC(lrcText);
ui.lrcContainer.innerHTML = '';
if (state.lyrics.length === 0) {
ui.lrcContainer.innerHTML = `<div class="text-neutral-400 text-sm py-10" role="option">${config.i18n.noLyrics}</div>`;
return;
}
state.lyrics.forEach((line, index) => {
const lineEl = document.createElement('div');
lineEl.className = 'lrc-line transition-all duration-300 text-sm text-neutral-400 py-1 cursor-pointer hover:text-[var(--primary)]';
lineEl.innerText = line.text;
lineEl.dataset.index = index;
lineEl.setAttribute('role', 'option');
lineEl.setAttribute('aria-label', line.text);
lineEl.onclick = () => {
ui.audio.currentTime = line.time;
};
ui.lrcContainer.appendChild(lineEl);
});
}
function updateLyricSync(currentTime, forceScroll = false) {
if (!state.lyrics || state.lyrics.length === 0) return;
let index = -1;
for (let i = 0; i < state.lyrics.length; i++) {
if (currentTime >= state.lyrics[i].time) {
index = i;
} else {
break;
}
}
// Always update UI classes when index changes
if (index !== state.currentLrcIndex) {
const lines = ui.lrcContainer.querySelectorAll('.lrc-line');
lines.forEach((line, i) => {
if (i === index) {
line.classList.add('text-[var(--primary)]', 'font-bold', 'text-base');
line.classList.remove('text-neutral-400', 'text-sm');
} else {
line.classList.remove('text-[var(--primary)]', 'font-bold', 'text-base');
line.classList.add('text-neutral-400', 'text-sm');
}
});
state.currentLrcIndex = index;
}
// Handle scrolling separately so it can be forced even if index hasn't changed
if (index !== -1 && (!state.isUserScrolling || forceScroll)) {
const line = ui.lrcContainer.querySelector(`.lrc-line[data-index="${index}"]`);
if (line) {
const containerHeight = ui.lrcContainer.clientHeight;
// Use line.offsetTop relative to its offsetParent (the container with relative)
const lineOffset = line.offsetTop;
const lineHeight = line.offsetHeight;
const targetScroll = lineOffset - (containerHeight / 2) + (lineHeight / 2);
ui.lrcContainer.scrollTo({
top: targetScroll,
behavior: forceScroll ? 'auto' : 'smooth'
});
}
}
}
// Core Init
async function init() {
setLoading(true);
try {
if (config.mode === 'meting' && config.meting) {
await fetchMetingData();
} else if (config.mode === 'local') {
state.playlist = config.localPlaylist || [];
}
if (state.playlist.length > 0) {
renderPlaylist();
// If random mode is initial setting, shuffle start
let startIndex = 0;
if (state.playMode === 2) {
startIndex = Math.floor(Math.random() * state.playlist.length);
}
loadTrack(startIndex, false);
initVolume();
updateModeUI(); // Reflect initial mode in UI
} else {
ui.title.innerText = config.i18n.noSongs;
}
} catch (e) {
console.error("Music Init Error", e);
ui.title.innerText = config.i18n.error;
} finally {
setLoading(false);
}
}
async function fetchMetingData() {
if (!config.meting) return;
const { api, server, type, id, auth, fallbackApis } = config.meting;
const apis = [api, ...(fallbackApis || [])];
for (const baseApi of apis) {
if (!baseApi) continue;
try {
let fetchUrl = baseApi.replace(':server', server).replace(':type', type).replace(':id', id).replace(':r', Math.random());
if (auth) fetchUrl += `&auth=${auth}`;
const res = await fetch(fetchUrl);
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
state.playlist = data.map(item => ({
name: item.title || item.name || 'Unknown',
artist: item.author || item.artist || 'Unknown',
url: item.url,
pic: item.pic || item.cover || '',
lrc: item.lrc
}));
return; // Found data, exit loop
}
} catch (e) {
console.warn(`Meting API fetch failed for ${baseApi}`, e);
}
}
throw new Error("All Meting APIs failed");
}
function renderPlaylist() {
ui.playlistContainer.innerHTML = '';
state.playlist.forEach((track, idx) => {
const clone = ui.itemTemplate.content.cloneNode(true);
const itemEl = clone.querySelector('.playlist-item');
const img = clone.querySelector('.item-cover');
const title = clone.querySelector('.item-title');
const artist = clone.querySelector('.item-artist');
img.src = track.pic || '';
img.alt = `${track.name} - ${track.artist}`;
title.innerText = track.name;
artist.innerText = track.artist;
// Active State logic handled in updatePlaylistUI
itemEl.dataset.index = idx;
itemEl.setAttribute('role', 'option');
itemEl.setAttribute('aria-label', `${track.name} - ${track.artist}`);
itemEl.onclick = () => {
if (state.currentIndex === idx && !ui.audio.paused) {
togglePlay();
} else {
loadTrack(idx, true);
}
};
ui.playlistContainer.appendChild(clone);
});
}
function loadTrack(index, autoPlay = false) {
if (index < 0 || index >= state.playlist.length) return;
state.currentIndex = index;
const track = state.playlist[index];
ui.title.innerText = track.name;
ui.title.title = track.name;
ui.artist.innerText = track.artist;
ui.artist.title = track.artist;
if (track.pic) {
ui.cover.classList.add('opacity-0');
ui.cover.src = track.pic;
ui.cover.alt = `${track.name} - ${track.artist}`;
} else {
ui.cover.src = '';
ui.cover.classList.add('opacity-0');
ui.cover.alt = config.i18n.noCover;
}
ui.audio.src = track.url;
// Reset cover rotation
ui.cover.classList.remove('animate-spin-slow');
void ui.cover.offsetWidth;
ui.cover.classList.add('animate-spin-slow');
ui.cover.style.animationPlayState = 'paused';
// Reset Progress
ui.progressBar.style.width = '0%';
ui.progressContainer.setAttribute('aria-valuenow', '0');
ui.currentTime.innerText = '0:00';
ui.totalTime.innerText = '0:00';
// Reset Lyrics
state.lyrics = [];
state.currentLrcIndex = -1;
ui.lrcContainer.innerHTML = `<div class="text-neutral-400 text-sm py-10">${config.i18n.loadingLyrics}</div>`;
if (track.lrc) {
const isLrcUrl = /^(https?:)?\/\//.test(track.lrc)
|| track.lrc.startsWith('/')
|| /\.(lrc|txt)(\?|#|$)/i.test(track.lrc);
if (isLrcUrl) {
fetch(track.lrc)
.then(res => res.text())
.then(text => renderLyrics(text))
.catch(() => ui.lrcContainer.innerHTML = `<div class="text-neutral-400 text-sm py-10">${config.i18n.failedLyrics}</div>`);
} else {
renderLyrics(track.lrc);
}
} else {
ui.lrcContainer.innerHTML = `<div class="text-neutral-400 text-sm py-10">${config.i18n.noLyrics}</div>`;
}
updatePlaylistUI();
if (autoPlay) {
ui.audio.play().then(() => {
updatePlayState(true);
}).catch(e => console.warn(e));
} else {
updatePlayState(false);
}
}
function updatePlayState(isPlaying) {
state.isPlaying = isPlaying;
if (isPlaying) {
ui.btnPlay.classList.add('bg-[var(--primary)]', 'text-white', 'hover:brightness-110');
ui.btnPlay.classList.remove('bg-[var(--btn-regular-bg)]', 'hover:bg-[var(--btn-regular-bg-hover)]', 'active:bg-[var(--btn-regular-bg-active)]', 'text-[var(--primary)]');
ui.iconPlay.classList.add('hidden');
ui.iconPause.classList.remove('hidden');
ui.cover.style.animationPlayState = 'running';
ui.btnPlay.setAttribute('aria-label', config.i18n.pause);
ui.btnPlay.title = config.i18n.pause;
} else {
ui.btnPlay.classList.remove('bg-[var(--primary)]', 'text-white', 'hover:brightness-110');
ui.btnPlay.classList.add('bg-[var(--btn-regular-bg)]', 'hover:bg-[var(--btn-regular-bg-hover)]', 'active:bg-[var(--btn-regular-bg-active)]', 'text-[var(--primary)]');
ui.iconPlay.classList.remove('hidden');
ui.iconPause.classList.add('hidden');
ui.cover.style.animationPlayState = 'paused';
ui.btnPlay.setAttribute('aria-label', config.i18n.play);
ui.btnPlay.title = config.i18n.play;
}
}
function togglePlay() {
if (ui.audio.paused) {
ui.audio.play().then(() => updatePlayState(true));
} else {
ui.audio.pause();
updatePlayState(false);
}
}
function playNext(auto = false) {
let nextIndex;
if (state.playMode === 1 && auto) {
// Single Loop
ui.audio.currentTime = 0;
ui.audio.play();
return;
} else if (state.playMode === 2) {
// Random
nextIndex = Math.floor(Math.random() * state.playlist.length);
} else {
// List Loop
nextIndex = (state.currentIndex + 1) % state.playlist.length;
}
loadTrack(nextIndex, true);
}
function playPrev() {
let prevIndex = (state.currentIndex - 1 + state.playlist.length) % state.playlist.length;
if (state.playMode === 2) {
prevIndex = Math.floor(Math.random() * state.playlist.length);
}
loadTrack(prevIndex, true);
}
function updatePlaylistUI() {
const items = ui.playlistContainer.querySelectorAll('.playlist-item');
items.forEach(el => {
const idx = parseInt(el.dataset.index);
const overlay = el.querySelector('.item-active-overlay');
const title = el.querySelector('.item-title');
if (idx === state.currentIndex) {
el.classList.add('bg-neutral-100', 'dark:bg-white/10');
el.setAttribute('aria-current', 'true');
overlay.classList.remove('hidden');
overlay.classList.add('flex');
title.classList.add('text-[var(--primary)]');
} else {
el.classList.remove('bg-neutral-100', 'dark:bg-white/10');
el.removeAttribute('aria-current');
overlay.classList.add('hidden');
overlay.classList.remove('flex');
title.classList.remove('text-[var(--primary)]');
}
});
}
// Mode Logic
// Mode 0: List (Def), Mode 1: One, Mode 2: Rand
// Button Repeat: Cycle 0 -> 1 -> 2 -> 0
function updateModeUI() {
const primaryColor = 'text-[var(--primary)]';
// Always primary color if we want to indicate it's interactable, or conditionally
// Usually Loop List is 'inactive' (grey) or 'active' (primary).
// Let's make: List=Grey (default), One=Primary, Rand=Primary
if (state.playMode === 0) {
ui.btnRepeat.className = `p-2 active:scale-95 transition-colors text-neutral-300 dark:text-neutral-600 hover:text-[var(--primary)]`;
ui.iconRepeat.classList.remove('hidden');
ui.iconRepeatOne.classList.add('hidden');
ui.iconShuffle.classList.add('hidden');
} else if (state.playMode === 1) {
ui.btnRepeat.className = `p-2 active:scale-95 transition-colors ${primaryColor}`;
ui.iconRepeat.classList.add('hidden');
ui.iconRepeatOne.classList.remove('hidden');
ui.iconShuffle.classList.add('hidden');
} else {
ui.btnRepeat.className = `p-2 active:scale-95 transition-colors ${primaryColor}`;
ui.iconRepeat.classList.add('hidden');
ui.iconRepeatOne.classList.add('hidden');
ui.iconShuffle.classList.remove('hidden');
}
}
ui.btnRepeat.addEventListener('click', () => {
// Cycle: 0 -> 1 -> 2 -> 0
state.playMode = (state.playMode + 1) % 3;
updateModeUI();
});
// Volume Logic
function initVolume() {
ui.audio.volume = state.lastVolume;
updateVolumeUI();
}
function updateVolumeUI() {
const pct = state.isMuted ? 0 : state.lastVolume * 100;
ui.volBar.style.width = `${pct}%`;
ui.volContainer.setAttribute('aria-valuenow', Math.round(pct).toString());
if (state.isMuted || state.lastVolume === 0) {
ui.iconVolHigh.classList.add('hidden');
ui.iconVolMute.classList.remove('hidden');
} else {
ui.iconVolHigh.classList.remove('hidden');
ui.iconVolMute.classList.add('hidden');
}
}
ui.btnMute.addEventListener('click', () => {
state.isMuted = !state.isMuted;
ui.audio.muted = state.isMuted;
updateVolumeUI();
});
ui.volContainer.addEventListener('click', (e) => {
const rect = ui.volContainer.getBoundingClientRect();
const x = e.clientX - rect.left;
let val = x / rect.width;
val = Math.max(0, Math.min(1, val));
state.lastVolume = val;
state.isMuted = false;
ui.audio.muted = false;
ui.audio.volume = val;
localStorage.setItem('music-player-volume', val.toString());
updateVolumeUI();
});
// Drawer Logic
ui.btnLrc.addEventListener('click', () => {
const isOpen = ui.lrcDrawer.style.gridTemplateRows === '1fr';
if (isOpen) {
ui.lrcDrawer.style.gridTemplateRows = '0fr';
ui.lrcDrawer.classList.remove('opacity-100');
ui.lrcDrawer.classList.add('opacity-0');
ui.btnLrc.classList.remove('text-[var(--primary)]');
ui.btnLrc.classList.add('text-neutral-400');
ui.iconLrcOn.classList.add('hidden');
ui.iconLrcOff.classList.remove('hidden');
} else {
// Close playlist if open
ui.playlistDrawer.style.gridTemplateRows = '0fr';
ui.playlistDrawer.classList.remove('opacity-100');
ui.playlistDrawer.classList.add('opacity-0');
ui.btnDrawer.classList.remove('text-[var(--primary)]');
ui.btnDrawer.classList.add('text-neutral-400');
ui.lrcDrawer.style.gridTemplateRows = '1fr';
ui.lrcDrawer.classList.add('opacity-100');
ui.lrcDrawer.classList.remove('opacity-0');
ui.btnLrc.classList.add('text-[var(--primary)]');
ui.btnLrc.classList.remove('text-neutral-400');
ui.iconLrcOn.classList.remove('hidden');
ui.iconLrcOff.classList.add('hidden');
}
});
ui.btnDrawer.addEventListener('click', () => {
const isOpen = ui.playlistDrawer.style.gridTemplateRows === '1fr';
if (isOpen) {
ui.playlistDrawer.style.gridTemplateRows = '0fr';
ui.playlistDrawer.classList.remove('opacity-100');
ui.playlistDrawer.classList.add('opacity-0');
ui.btnDrawer.classList.add('text-neutral-400');
ui.btnDrawer.classList.remove('text-[var(--primary)]');
} else {
// Close lyrics if open
ui.lrcDrawer.style.gridTemplateRows = '0fr';
ui.lrcDrawer.classList.remove('opacity-100');
ui.lrcDrawer.classList.add('opacity-0');
ui.btnLrc.classList.remove('text-[var(--primary)]');
ui.btnLrc.classList.add('text-neutral-400');
ui.playlistDrawer.style.gridTemplateRows = '1fr';
ui.playlistDrawer.classList.add('opacity-100');
ui.playlistDrawer.classList.remove('opacity-0');
ui.btnDrawer.classList.remove('text-neutral-400');
ui.btnDrawer.classList.add('text-[var(--primary)]');
}
});
// Core Events
ui.btnPlay.addEventListener('click', togglePlay);
ui.btnNext.addEventListener('click', () => playNext(false));
ui.btnPrev.addEventListener('click', playPrev);
ui.lrcContainer.addEventListener('wheel', () => {
state.isUserScrolling = true;
resetScrollTimeout();
});
ui.lrcContainer.addEventListener('touchstart', () => {
state.isUserScrolling = true;
resetScrollTimeout();
});
function resetScrollTimeout() {
clearTimeout(state.scrollTimeout);
state.scrollTimeout = setTimeout(() => {
state.isUserScrolling = false;
updateLyricSync(ui.audio.currentTime, true); // Snap back immediately
}, 3000); // Wait for 3 seconds of no activity
}
ui.cover.addEventListener('load', () => {
ui.cover.classList.remove('opacity-0');
});
ui.cover.addEventListener('error', () => {
ui.cover.classList.add('opacity-0');
});
ui.audio.addEventListener('timeupdate', () => {
if (isNaN(ui.audio.duration)) return;
const currentTime = ui.audio.currentTime;
const duration = ui.audio.duration;
const progress = (currentTime / duration) * 100;
ui.progressBar.style.width = `${progress}%`;
ui.progressThumb.style.left = `${progress}%`;
ui.progressContainer.setAttribute('aria-valuenow', Math.round(progress).toString());
ui.currentTime.innerText = formatTime(currentTime);
ui.totalTime.innerText = formatTime(duration);
updateLyricSync(currentTime);
});
ui.audio.addEventListener('ended', () => playNext(true));
ui.audio.addEventListener('error', () => { /* Handle error */ });
// Global Mutual Exclusion
window.addEventListener('firefly-music-play', (e) => {
if (e.detail.id !== widgetId && !ui.audio.paused) {
ui.audio.pause();
updatePlayState(false);
}
});
ui.audio.addEventListener('play', () => {
window.dispatchEvent(new CustomEvent('firefly-music-play', { detail: { id: widgetId } }));
});
// Seek
ui.progressContainer.addEventListener('click', (e) => {
if (!ui.audio.duration) return;
const rect = ui.progressContainer.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const percent = Math.min(Math.max(clickX / rect.width, 0), 1);
ui.audio.currentTime = percent * ui.audio.duration;
});
init();
})();
</script>
<style>
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin-slow {
animation: spin-slow 10s linear infinite;
}
</style>