Files
AboutMe/src/components/musicPlayer.svelte
2026-02-03 15:25:23 +04:00

796 lines
30 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { slide } from "svelte/transition";
import Icon from "@iconify/svelte";
import type { MusicPlayerTrack } from "@/types/config";
import { musicPlayerConfig } from "@/config";
import { i18n } from "@i18n/translation";
import Key from "@i18n/i18nKey";
import "@styles/musicplayer.css";
// 音乐播放器模式,可选 "local" 或 "meting"
let mode = $state(musicPlayerConfig.mode ?? "meting");
// Meting API 地址,从配置中获取或使用默认值
let meting_api = musicPlayerConfig.meting?.meting_api ?? "https://api.i-meto.com/meting/api";
// Meting API 的数据源,从配置中获取或使用默认值
let meting_server = musicPlayerConfig.meting?.server ?? "netease";
// Meting API 的类型,从配置中获取或使用默认值
let meting_type = musicPlayerConfig.meting?.type ?? "playlist";
// Meting API 的 ID从配置中获取或使用默认值
let meting_id = musicPlayerConfig.meting?.id ?? "2161912966";
// 是否启用自动播放,从配置中获取或使用默认值
let isAutoplayEnabled = $state(musicPlayerConfig.autoplay ?? false);
// 当前歌曲信息
let currentSong: MusicPlayerTrack = $state({
id: 0,
title: "Music",
artist: "Artist",
cover: "/favicon/icon-light.ico",
url: "",
duration: 0,
});
let playlist: MusicPlayerTrack[] = $state([]);
let currentIndex = $state(0);
let audio: HTMLAudioElement | undefined = $state();
let progressBar: HTMLElement | undefined = $state();
let volumeBar: HTMLElement | undefined = $state();
// 是否正在播放
let isPlaying = $state(false);
// 是否应该播放(用于切换歌曲时的自动播放)
let shouldPlay = $state(false);
// 是否折叠播放器
let isCollapsed = $state(true);
// 是否显示播放列表
let showPlaylist = $state(false);
// 当前播放时间
let currentTime = $state(0);
// 歌曲总时长
let duration = $state(0);
// 音量
let volume = $state(0.15);
// 是否静音
let isMuted = $state(false);
// 是否正在加载
let isLoading = $state(false);
// 是否随机播放
let isShuffled = $state(false);
// 循环模式0: 不循环, 1: 单曲循环, 2: 列表循环
let isRepeating = $state(0);
// 待恢复的进度
let pendingProgress = $state(0);
// 上次保存进度的时间,用于节流
let lastSaveTime = 0;
// 错误信息
let errorMessage = $state("");
// 是否显示错误信息
let showError = $state(false);
// 存储键名常量
const STORAGE_KEYS = {
USER_PAUSED: "player_user_paused",
VOLUME: "player_volume",
SHUFFLE: "player_shuffle",
REPEAT: "player_repeat",
LAST_SONG_ID: "player_last_song_id",
LAST_SONG_PROGRESS: "player_last_song_progress",
};
function restoreLastSong() {
if (playlist.length === 0) return;
if (typeof localStorage !== 'undefined') {
const lastId = localStorage.getItem(STORAGE_KEYS.LAST_SONG_ID);
let index = -1;
// 优先通过 ID 匹配
if (lastId) {
index = playlist.findIndex(s => s.id !== undefined && String(s.id) === String(lastId));
}
if (index !== -1) {
currentIndex = index;
// 获取保存的进度
const savedProgress = localStorage.getItem(STORAGE_KEYS.LAST_SONG_PROGRESS);
if (savedProgress) {
pendingProgress = parseFloat(savedProgress);
}
loadSong(playlist[currentIndex]);
return;
}
}
// 如果没有找到上次播放的歌曲,或者没有记录,加载第一首
currentIndex = 0;
loadSong(playlist[0]);
}
function showErrorMessage(message: string) {
errorMessage = message;
showError = true;
setTimeout(() => {
showError = false;
}, 3000);
}
async function fetchMetingPlaylist() {
if (!meting_api || !meting_id) return;
isLoading = true;
const query = new URLSearchParams({
server: meting_server,
type: meting_type,
id: meting_id,
});
const separator = meting_api.includes("?") ? "&" : "?";
const apiUrl = `${meting_api}${separator}${query.toString()}`;
try {
const res = await fetch(apiUrl);
if (!res.ok) throw new Error("meting api error");
const list = await res.json();
playlist = list.map((song: any, index: number) => {
let title = song.name ?? song.title ?? i18n(Key.musicUnknownTrack);
let artist = song.artist ?? song.author ?? i18n(Key.musicUnknownArtist);
let dur = song.duration ?? 0;
if (dur > 10000) dur = Math.floor(dur / 1000);
if (!Number.isFinite(dur) || dur <= 0) dur = 0;
return {
id: song.id ?? `meting-${index}`, // 确保每个歌曲都有 ID
title,
artist,
cover: song.pic ?? "",
url: song.url ?? "",
duration: dur,
};
});
if (playlist.length > 0) {
// 使用 setTimeout 确保 Svelte 响应式变量已更新
setTimeout(() => {
restoreLastSong();
}, 0);
}
isLoading = false;
} catch (e) {
showErrorMessage(i18n(Key.musicMetingFailed));
isLoading = false;
}
}
async function toggleMode() {
if (!musicPlayerConfig.enable) return;
mode = mode === "meting" ? "local" : "meting";
showPlaylist = false;
isLoading = false;
isPlaying = false;
currentIndex = 0;
playlist = [];
if (audio) {
audio.pause();
audio.currentTime = 0;
}
currentTime = 0;
duration = 0;
if (mode === "meting") {
await fetchMetingPlaylist();
} else {
playlist = [...(musicPlayerConfig.local?.playlist ?? [])];
if (playlist.length > 0) {
setTimeout(() => {
restoreLastSong();
}, 0);
} else {
showErrorMessage(i18n(Key.musicEmptyPlaylist));
}
}
}
function togglePlay() {
if (!audio || !currentSong.url) return;
if (isPlaying) {
audio.pause();
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEYS.USER_PAUSED, "true");
}
} else {
audio.play();
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEYS.USER_PAUSED, "false");
}
}
}
function toggleCollapse() {
isCollapsed = !isCollapsed;
if (isCollapsed) {
showPlaylist = false;
}
}
function togglePlaylist() {
showPlaylist = !showPlaylist;
}
function toggleShuffle() {
isShuffled = !isShuffled;
if (isShuffled) {
isRepeating = 0;
}
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEYS.SHUFFLE, String(isShuffled));
localStorage.setItem(STORAGE_KEYS.REPEAT, String(isRepeating));
}
}
function toggleRepeat() {
isRepeating = (isRepeating + 1) % 3;
if (isRepeating !== 0) {
isShuffled = false;
}
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEYS.REPEAT, String(isRepeating));
localStorage.setItem(STORAGE_KEYS.SHUFFLE, String(isShuffled));
}
}
function previousSong() {
if (playlist.length <= 1) return;
const newIndex = currentIndex > 0 ? currentIndex - 1 : playlist.length - 1;
playSong(newIndex);
}
function nextSong() {
if (playlist.length <= 1) return;
let newIndex: number;
if (isShuffled) {
do {
newIndex = Math.floor(Math.random() * playlist.length);
} while (newIndex === currentIndex && playlist.length > 1);
} else {
newIndex = currentIndex < playlist.length - 1 ? currentIndex + 1 : 0;
}
playSong(newIndex);
}
function playSong(index: number) {
if (index < 0 || index >= playlist.length) return;
currentIndex = index;
// 用户手动选择歌曲(或自动切换),标记为应该播放
shouldPlay = true;
// 用户手动选择歌曲,清除暂停偏好和待恢复进度
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEYS.USER_PAUSED, "false");
}
pendingProgress = 0;
// 加载歌曲
loadSong(playlist[currentIndex]);
}
function getAssetPath(path: string): string {
if (path.startsWith("http://") || path.startsWith("https://")) return path;
if (path.startsWith("/")) return path;
return `/${path}`;
}
function loadSong(song: MusicPlayerTrack) {
if (!song || !audio) return;
currentSong = { ...song };
// 记录最后播放的歌曲 ID (排除初始化的占位符 ID 0)
if (typeof localStorage !== 'undefined' && song.id !== undefined && song.id !== 0) {
localStorage.setItem(STORAGE_KEYS.LAST_SONG_ID, String(song.id));
// 如果不是恢复进度的情况,重置保存的进度
if (pendingProgress <= 0) {
localStorage.setItem(STORAGE_KEYS.LAST_SONG_PROGRESS, "0");
}
}
if (song.url) {
isLoading = true;
// 如果有待恢复的进度,先不要重置为 0以免进度条跳变
if (pendingProgress > 0) {
currentTime = pendingProgress;
} else {
audio.currentTime = 0;
currentTime = 0;
}
duration = song.duration ?? 0;
audio.removeEventListener("canplay", handleLoadSuccess);
audio.removeEventListener("error", handleLoadError);
audio.removeEventListener("loadstart", handleLoadStart);
audio.addEventListener("canplay", handleLoadSuccess, { once: true });
audio.addEventListener("error", handleLoadError, { once: true });
audio.addEventListener("loadstart", handleLoadStart, { once: true });
audio.src = getAssetPath(song.url);
audio.load();
} else {
isLoading = false;
}
}
let autoplayFailed = $state(false);
function handleLoadSuccess() {
isLoading = false;
if (audio?.duration && audio.duration > 1) {
duration = Math.floor(audio.duration);
if (playlist[currentIndex]) playlist[currentIndex].duration = duration;
currentSong.duration = duration;
}
// 恢复进度
if (pendingProgress > 0 && audio) {
// 确保进度不超出总时长
const targetTime = Math.min(pendingProgress, duration > 0 ? duration : Infinity);
audio.currentTime = targetTime;
currentTime = targetTime;
pendingProgress = 0; // 恢复后清除
}
// 如果是自动播放模式,或者当前处于播放状态(如切换歌曲),则尝试播放
if (isAutoplayEnabled || isPlaying || shouldPlay) {
const playPromise = audio?.play();
if (playPromise !== undefined) {
playPromise.then(() => {
// 播放成功后,关闭自动播放标记,后续由用户控制
isAutoplayEnabled = false;
autoplayFailed = false;
shouldPlay = false;
}).catch((error) => {
showErrorMessage(i18n(Key.musicAutoplayBlocked));
autoplayFailed = true;
// 确保 UI 状态为暂停
isPlaying = false;
shouldPlay = false;
});
}
}
}
function handleUserInteraction() {
// 如果自动播放失败且尚未开始播放,则在用户交互时尝试播放
if (autoplayFailed && audio && !isPlaying) {
const playPromise = audio.play();
if (playPromise !== undefined) {
playPromise.then(() => {
autoplayFailed = false;
}).catch(() => {});
}
}
}
function handleLoadError(event: Event) {
isLoading = false;
showErrorMessage(i18n(Key.musicPlayFailed).replace("{0}", currentSong.title));
if (playlist.length > 1) setTimeout(() => nextSong(), 1000);
else showErrorMessage(i18n(Key.musicNoSongsAvailable));
}
function handleLoadStart() {}
function hideError() {
showError = false;
}
function setProgress(event: MouseEvent) {
if (!audio || !progressBar) return;
const rect = progressBar.getBoundingClientRect();
const percent = (event.clientX - rect.left) / rect.width;
const newTime = percent * duration;
audio.currentTime = newTime;
currentTime = newTime;
}
let isVolumeDragging = $state(false);
let isMouseDown = $state(false);
let volumeBarRect: DOMRect | null = $state(null);
let rafId: number | null = $state(null);
function startVolumeDrag(event: MouseEvent) {
if (!volumeBar) return;
isMouseDown = true;
volumeBarRect = volumeBar.getBoundingClientRect();
updateVolumeLogic(event.clientX);
}
function handleVolumeMove(event: MouseEvent) {
if (!isMouseDown) return;
isVolumeDragging = true;
if (rafId) return;
rafId = requestAnimationFrame(() => {
updateVolumeLogic(event.clientX);
rafId = null;
});
}
function stopVolumeDrag() {
isMouseDown = false;
isVolumeDragging = false;
volumeBarRect = null;
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
function updateVolumeLogic(clientX: number) {
if (!audio || !volumeBar) return;
const rect = volumeBarRect || volumeBar.getBoundingClientRect();
const percent = Math.max(
0,
Math.min(1, (clientX - rect.left) / rect.width),
);
volume = percent;
audio.volume = volume;
isMuted = volume === 0;
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEYS.VOLUME, String(volume));
}
}
function toggleMute() {
if (!audio) return;
isMuted = !isMuted;
audio.muted = isMuted;
}
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function handleAudioEvents() {
if (!audio) return;
audio.addEventListener("play", () => {
isPlaying = true;
autoplayFailed = false;
isAutoplayEnabled = false;
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEYS.USER_PAUSED, "false");
}
});
audio.addEventListener("pause", () => {
isPlaying = false;
// 注意:这里不自动设置 userPaused 为 true因为音频结束或切换也可能触发 pause。只在 togglePlay 中显式记录用户的暂停操作。)
});
audio.addEventListener("timeupdate", () => {
if (!audio) return;
currentTime = audio.currentTime;
// 每 2.1 秒保存一次进度,或者在歌曲接近结束时(虽然结束时可能不需要记忆,但为了保险)
const now = Date.now();
if (now - lastSaveTime > 2100) {
if (typeof localStorage !== 'undefined' && currentSong.id !== 0) {
localStorage.setItem(STORAGE_KEYS.LAST_SONG_PROGRESS, String(currentTime));
lastSaveTime = now;
}
}
});
audio.addEventListener("ended", () => {
if (!audio) return;
// 歌曲结束,重置保存的进度
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEYS.LAST_SONG_PROGRESS, "0");
}
// 单曲循环时,重置进度到开始
if (isRepeating === 1) {
audio.currentTime = 0;
audio.play().catch(() => {});
} else if (
isRepeating === 2 ||
isShuffled ||
(isRepeating === 0 && currentIndex < playlist.length - 1)
) {
nextSong();
} else {
isPlaying = false;
}
});
audio.addEventListener("error", (event) => {
isLoading = false;
});
audio.addEventListener("stalled", () => {});
audio.addEventListener("waiting", () => {});
}
const interactionEvents = ['click', 'keydown', 'touchstart'];
onMount(() => {
// 从缓存中读取用户偏好
if (typeof localStorage !== 'undefined') {
const userPaused = localStorage.getItem(STORAGE_KEYS.USER_PAUSED) === "true";
if (userPaused) {
isAutoplayEnabled = false;
}
const savedVolume = localStorage.getItem(STORAGE_KEYS.VOLUME);
if (savedVolume !== null) {
volume = parseFloat(savedVolume);
}
const savedShuffle = localStorage.getItem(STORAGE_KEYS.SHUFFLE);
if (savedShuffle !== null) {
isShuffled = savedShuffle === "true";
}
const savedRepeat = localStorage.getItem(STORAGE_KEYS.REPEAT);
if (savedRepeat !== null) {
isRepeating = parseInt(savedRepeat);
}
}
audio = new Audio();
audio.volume = volume;
handleAudioEvents();
interactionEvents.forEach(event => {
document.addEventListener(event, handleUserInteraction, { capture: true });
});
if (!musicPlayerConfig.enable) {
return;
}
if (mode === "meting") {
fetchMetingPlaylist();
} else {
// 使用本地播放列表不发送任何API请求
playlist = [...(musicPlayerConfig.local?.playlist ?? [])];
if (playlist.length > 0) {
setTimeout(() => {
restoreLastSong();
}, 0);
} else {
showErrorMessage(i18n(Key.musicEmptyPlaylist));
}
}
});
onDestroy(() => {
if (typeof document !== 'undefined') {
interactionEvents.forEach(event => {
document.removeEventListener(event, handleUserInteraction, { capture: true });
});
}
if (audio) {
audio.pause();
audio.src = "";
}
});
</script>
<svelte:window
onmousemove={handleVolumeMove}
onmouseup={stopVolumeDrag}
/>
{#if musicPlayerConfig.enable}
{#if showError}
<div class="music-player-error fixed bottom-20 right-4 z-60 max-w-sm onload-animation-up">
<div class="bg-red-500 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-slide-up">
<Icon icon="material-symbols:error" class="text-xl shrink-0" />
<span class="text-sm flex-1">{errorMessage}</span>
<button onclick={hideError} class="text-white/80 hover:text-white transition-colors">
<Icon icon="material-symbols:close" class="text-lg" />
</button>
</div>
</div>
{/if}
<div class="music-player fixed bottom-4 right-4 z-101 transition-all duration-300 ease-in-out onload-animation-up flex flex-col items-end pointer-events-none"
class:expanded={!isCollapsed}
class:collapsed={isCollapsed}>
{#if showPlaylist}
<div class="playlist-panel float-panel w-80 max-h-96 overflow-hidden z-50 mb-4 pointer-events-auto"
transition:slide={{ duration: 300, axis: 'y' }}>
<div class="playlist-header flex items-center justify-between p-4 border-b border-(--line-divider)">
<h3 class="text-lg font-semibold text-90">{i18n(Key.playlist)}</h3>
<button class="btn-plain w-8 h-8 rounded-lg" onclick={togglePlaylist}>
<Icon icon="material-symbols:close" class="text-lg" />
</button>
</div>
<div class="playlist-content overflow-y-auto max-h-80">
{#each playlist as song, index}
<div class="playlist-item flex items-center gap-3 p-3 hover:bg-(--btn-plain-bg-hover) cursor-pointer transition-colors"
class:bg-(--btn-plain-bg)={index === currentIndex}
class:text-(--primary)={index === currentIndex}
onclick={() => playSong(index)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
playSong(index);
}
}}
role="button"
tabindex="0"
aria-label="播放 {song.title} - {song.artist}">
<div class="w-6 h-6 flex items-center justify-center">
{#if index === currentIndex && isPlaying}
<Icon icon="material-symbols:graphic-eq" class="text-(--primary) animate-pulse" />
{:else if index === currentIndex}
<Icon icon="material-symbols:pause" class="text-(--primary)" />
{:else}
<span class="text-sm text-(--content-meta)">{index + 1}</span>
{/if}
</div>
<!-- 歌单列表内封面仍为圆角矩形 -->
<div class="w-10 h-10 rounded-lg overflow-hidden shrink-0 shadow-sm">
<img src={getAssetPath(song.cover)} alt={song.title} class="w-full h-full object-cover" />
</div>
<div class="flex-1 min-w-0">
<div class="font-medium truncate" class:text-(--primary)={index === currentIndex} class:text-90={index !== currentIndex}>
{song.title}
</div>
<div class="text-sm text-(--content-meta) truncate" class:text-(--primary)={index === currentIndex}>
{song.artist}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- 折叠状态的小圆球 -->
<div class="orb-player w-12 h-12 bg-(--primary) rounded-full shadow-lg cursor-pointer transition-all duration-500 ease-in-out flex items-center justify-center hover:scale-110 active:scale-95"
class:opacity-0={!isCollapsed}
class:scale-0={!isCollapsed}
class:pointer-events-auto={isCollapsed}
class:pointer-events-none={!isCollapsed}
onclick={toggleCollapse}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleCollapse();
}
}}
role="button"
tabindex="0"
aria-label={i18n(Key.musicExpand)}>
{#if isLoading}
<Icon icon="eos-icons:loading" class="text-white text-lg" />
{:else if isPlaying}
<div class="flex space-x-0.5">
<div class="w-0.5 h-3 bg-white rounded-full animate-pulse"></div>
<div class="w-0.5 h-4 bg-white rounded-full animate-pulse" style="animation-delay: 150ms;"></div>
<div class="w-0.5 h-2 bg-white rounded-full animate-pulse" style="animation-delay: 300ms;"></div>
</div>
{:else}
<Icon icon="material-symbols:music-note" class="text-white text-lg" />
{/if}
</div>
<!-- 展开状态的完整播放器(封面圆形) -->
<div class="expanded-player card-base bg-(--float-panel-bg) shadow-xl rounded-2xl p-4 transition-all duration-500 ease-in-out"
class:opacity-0={isCollapsed}
class:scale-95={isCollapsed}
class:pointer-events-auto={!isCollapsed}
class:pointer-events-none={isCollapsed}>
<div class="flex items-center gap-4 mb-4">
<div class="cover-container relative w-16 h-16 rounded-full overflow-hidden shrink-0">
<img src={getAssetPath(currentSong.cover)} alt="封面"
class="w-full h-full object-cover transition-transform duration-300"
class:spinning={isPlaying && !isLoading}
class:animate-pulse={isLoading} />
</div>
<div class="flex-1 min-w-0">
<div class="song-title text-lg font-bold text-90 truncate mb-1">{currentSong.title}</div>
<div class="song-artist text-sm text-50 truncate">{currentSong.artist}</div>
<div class="text-xs text-30 mt-1">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
<div class="flex items-center gap-1">
<button class="btn-plain w-8 h-8 rounded-lg flex items-center justify-center"
onclick={toggleMode}
title={mode === "meting" ? i18n(Key.musicSwitchToLocal) : i18n(Key.musicSwitchToMeting)}>
<Icon icon={mode === "meting" ? "material-symbols:cloud" : "material-symbols:folder"} class="text-lg" />
</button>
<button class="btn-plain w-8 h-8 rounded-lg flex items-center justify-center"
class:text-(--primary)={showPlaylist}
onclick={togglePlaylist}
title={i18n(Key.playlist)}>
<Icon icon="material-symbols:queue-music" class="text-lg" />
</button>
</div>
</div>
<div class="progress-section mb-4">
<div class="progress-bar flex-1 h-2 bg-(--btn-regular-bg) rounded-full cursor-pointer"
bind:this={progressBar}
onclick={setProgress}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const rect = progressBar?.getBoundingClientRect();
if (rect) {
const percent = 0.5;
const newTime = percent * duration;
if (audio) {
audio.currentTime = newTime;
currentTime = newTime;
}
}
}
}}
role="slider"
tabindex="0"
aria-label={i18n(Key.musicProgress)}
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={duration > 0 ? (currentTime / duration * 100) : 0}>
<div class="h-full bg-(--primary) rounded-full transition-all duration-100"
style="width: {duration > 0 ? (currentTime / duration) * 100 : 0}%">
</div>
</div>
</div>
<div class="controls flex items-center justify-center gap-2 mb-4">
<!-- 随机按钮高亮 -->
<button class="w-10 h-10 rounded-lg"
class:btn-regular={isShuffled}
class:btn-plain={!isShuffled}
onclick={toggleShuffle}
disabled={playlist.length <= 1}>
<Icon icon="material-symbols:shuffle" class="text-lg" />
</button>
<button class="btn-plain w-10 h-10 rounded-lg" onclick={previousSong}
disabled={playlist.length <= 1}>
<Icon icon="material-symbols:skip-previous" class="text-xl" />
</button>
<button class="btn-regular w-12 h-12 rounded-full"
class:opacity-50={isLoading}
disabled={isLoading}
onclick={togglePlay}>
{#if isLoading}
<Icon icon="eos-icons:loading" class="text-xl" />
{:else if isPlaying}
<Icon icon="material-symbols:pause" class="text-xl" />
{:else}
<Icon icon="material-symbols:play-arrow" class="text-xl" />
{/if}
</button>
<button class="btn-plain w-10 h-10 rounded-lg" onclick={nextSong}
disabled={playlist.length <= 1}>
<Icon icon="material-symbols:skip-next" class="text-xl" />
</button>
<!-- 循环按钮高亮 -->
<button class="w-10 h-10 rounded-lg"
class:btn-regular={isRepeating > 0}
class:btn-plain={isRepeating === 0}
onclick={toggleRepeat}>
{#if isRepeating === 1}
<Icon icon="material-symbols:repeat-one" class="text-lg" />
{:else if isRepeating === 2}
<Icon icon="material-symbols:repeat" class="text-lg" />
{:else}
<Icon icon="material-symbols:repeat" class="text-lg opacity-50" />
{/if}
</button>
</div>
<div class="bottom-controls flex items-center gap-2">
<button class="btn-plain w-8 h-8 rounded-lg" onclick={toggleMute}>
{#if isMuted || volume === 0}
<Icon icon="material-symbols:volume-off" class="text-lg" />
{:else if volume < 0.5}
<Icon icon="material-symbols:volume-down" class="text-lg" />
{:else}
<Icon icon="material-symbols:volume-up" class="text-lg" />
{/if}
</button>
<div class="flex-1 h-2 bg-(--btn-regular-bg) rounded-full cursor-pointer"
bind:this={volumeBar}
onmousedown={startVolumeDrag}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (e.key === 'Enter') toggleMute();
}
}}
role="slider"
tabindex="0"
aria-label={i18n(Key.musicVolume)}
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={volume * 100}>
<div class="h-full bg-(--primary) rounded-full transition-all"
class:duration-100={!isVolumeDragging}
class:duration-0={isVolumeDragging}
style="width: {volume * 100}%">
</div>
</div>
<button class="btn-plain w-8 h-8 rounded-lg flex items-center justify-center"
onclick={toggleCollapse}
title={i18n(Key.musicCollapse)}>
<Icon icon="material-symbols:expand-more" class="text-lg" />
</button>
</div>
</div>
</div>
{/if}