607 lines
13 KiB
Vue
607 lines
13 KiB
Vue
<template>
|
||
<footer class="player-bar">
|
||
<audio
|
||
ref="audioRef"
|
||
class="audio-hidden"
|
||
preload="auto"
|
||
@timeupdate="handleTimeUpdate"
|
||
@ended="handleEnded"
|
||
/>
|
||
|
||
<div class="player-left">
|
||
<div
|
||
class="cover-mini"
|
||
:style="current ? { background: coverBackground(current.cover) } : undefined"
|
||
@click="openFullscreen"
|
||
>
|
||
<AppIcon name="music" :size="20" class="cover-mini-mark" />
|
||
</div>
|
||
<div class="meta">
|
||
<p class="song-title">{{ current?.title ?? "未在播放" }}</p>
|
||
<p class="song-artist">{{ current?.artist ?? "选择一首歌开始播放" }}</p>
|
||
</div>
|
||
<button
|
||
class="icon-btn like-btn"
|
||
type="button"
|
||
:class="{ active: isLiked }"
|
||
:aria-label="isLiked ? '取消喜欢' : '喜欢'"
|
||
@click="toggleLike"
|
||
>
|
||
<AppIcon name="heart" :size="18" />
|
||
</button>
|
||
</div>
|
||
|
||
<div class="player-center">
|
||
<div class="controls">
|
||
<button
|
||
class="ctrl-btn"
|
||
type="button"
|
||
:class="{ active: shuffle }"
|
||
aria-label="随机播放"
|
||
@click="shuffle = !shuffle"
|
||
>
|
||
<AppIcon name="shuffle" :size="17" />
|
||
</button>
|
||
<button class="ctrl-btn" type="button" aria-label="上一首" @click="prev">
|
||
<AppIcon name="skip-back" :size="19" />
|
||
</button>
|
||
<button class="play-toggle" type="button" aria-label="播放或暂停" @click="togglePlay">
|
||
<AppIcon :name="playerState.isPlaying ? 'pause' : 'play'" :size="21" />
|
||
</button>
|
||
<button class="ctrl-btn" type="button" aria-label="下一首" @click="next">
|
||
<AppIcon name="skip-forward" :size="19" />
|
||
</button>
|
||
<button
|
||
class="ctrl-btn"
|
||
type="button"
|
||
:class="{ active: repeat }"
|
||
aria-label="单曲循环"
|
||
@click="repeat = !repeat"
|
||
>
|
||
<AppIcon name="repeat" :size="17" />
|
||
</button>
|
||
</div>
|
||
<div class="progress">
|
||
<span class="time">{{ formatTime(playerState.currentTime) }}</span>
|
||
<n-slider
|
||
v-model:value="playerState.currentTime"
|
||
:max="current?.duration ?? 100"
|
||
:disabled="!current"
|
||
:tooltip="false"
|
||
class="progress-slider"
|
||
/>
|
||
<span class="time">{{ formatTime(current?.duration ?? 0) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="player-right">
|
||
<button class="icon-btn" type="button" aria-label="静音或恢复音量" @click="toggleMute">
|
||
<AppIcon
|
||
:name="playerState.volume === 0 ? 'volume-mute' : 'volume-high'"
|
||
:size="18"
|
||
/>
|
||
</button>
|
||
<n-slider
|
||
v-model:value="playerState.volume"
|
||
:max="100"
|
||
:tooltip="false"
|
||
class="volume-slider"
|
||
/>
|
||
<n-popover trigger="click" placement="top-end" :width="340">
|
||
<template #trigger>
|
||
<button class="icon-btn queue-btn" type="button" aria-label="播放队列">
|
||
<AppIcon name="list-music" :size="18" />
|
||
</button>
|
||
</template>
|
||
<div class="queue-panel">
|
||
<p class="queue-title">播放队列({{ playerState.queue.length }})</p>
|
||
<div class="queue-list">
|
||
<button
|
||
v-for="song in playerState.queue"
|
||
:key="song.id"
|
||
class="queue-item"
|
||
type="button"
|
||
:class="{ current: current?.id === song.id }"
|
||
@click="playSong(song, playerState.queue)"
|
||
>
|
||
<span class="queue-index">
|
||
<AppIcon v-if="current?.id === song.id" name="volume-high" :size="13" />
|
||
<template v-else>
|
||
<AppIcon name="play" :size="12" />
|
||
</template>
|
||
</span>
|
||
<span class="queue-name">{{ song.title }}</span>
|
||
<span class="queue-artist">{{ song.artist }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</n-popover>
|
||
<button
|
||
class="icon-btn lyrics-btn"
|
||
type="button"
|
||
aria-label="歌词"
|
||
@click="openFullscreen"
|
||
>
|
||
<AppIcon name="mic" :size="18" />
|
||
</button>
|
||
</div>
|
||
|
||
<FullscreenPlayer
|
||
:show="showFullscreen"
|
||
@close="showFullscreen = false"
|
||
@toggle-play="togglePlay"
|
||
@next="next"
|
||
@prev="prev"
|
||
@toggle-like="toggleLike"
|
||
/>
|
||
</footer>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onUnmounted, ref, watch } from "vue";
|
||
import { NPopover, NSlider, useMessage } from "naive-ui";
|
||
import { playlists } from "@/mocks/music";
|
||
import { playerState, playSong } from "@/stores/player";
|
||
import { coverBackground } from "@/utils/cover";
|
||
import { formatTime } from "@/utils/time";
|
||
import AppIcon from "./AppIcon.vue";
|
||
import FullscreenPlayer from "./FullscreenPlayer.vue";
|
||
|
||
const message = useMessage();
|
||
|
||
const showFullscreen = ref(false);
|
||
|
||
const liked = ref(new Set<string | number>());
|
||
const shuffle = ref(false);
|
||
const repeat = ref(false);
|
||
|
||
const audioRef = ref<HTMLAudioElement | null>(null);
|
||
|
||
const current = computed(() => playerState.current);
|
||
|
||
const currentIndex = computed(() =>
|
||
playerState.queue.findIndex((song) => song.id === current.value?.id),
|
||
);
|
||
|
||
const isLiked = computed(() =>
|
||
current.value ? liked.value.has(current.value.id) : false,
|
||
);
|
||
|
||
function toggleLike() {
|
||
if (!current.value) return;
|
||
const next = new Set(liked.value);
|
||
if (next.has(current.value.id)) {
|
||
next.delete(current.value.id);
|
||
} else {
|
||
next.add(current.value.id);
|
||
}
|
||
liked.value = next;
|
||
}
|
||
|
||
function next() {
|
||
if (playerState.queue.length === 0) return;
|
||
let index = currentIndex.value;
|
||
if (shuffle.value) {
|
||
index = Math.floor(Math.random() * playerState.queue.length);
|
||
} else {
|
||
index = (index + 1) % playerState.queue.length;
|
||
}
|
||
playSong(playerState.queue[index]);
|
||
}
|
||
|
||
function prev() {
|
||
if (playerState.queue.length === 0) return;
|
||
let index = currentIndex.value;
|
||
if (shuffle.value) {
|
||
index = Math.floor(Math.random() * playerState.queue.length);
|
||
} else {
|
||
index = (index - 1 + playerState.queue.length) % playerState.queue.length;
|
||
}
|
||
playSong(playerState.queue[index]);
|
||
}
|
||
|
||
function togglePlay() {
|
||
if (!playerState.current) {
|
||
const songs = playlists[0].songs;
|
||
playSong(songs[0], songs);
|
||
message.success(`正在播放:${songs[0].title}`);
|
||
return;
|
||
}
|
||
playerState.isPlaying = !playerState.isPlaying;
|
||
}
|
||
|
||
function toggleMute() {
|
||
if (playerState.volume === 0) {
|
||
playerState.volume = 70;
|
||
} else {
|
||
playerState.volume = 0;
|
||
}
|
||
}
|
||
|
||
function openFullscreen() {
|
||
if (!playerState.current) {
|
||
const songs = playlists[0].songs;
|
||
playSong(songs[0], songs);
|
||
message.success(`正在播放:${songs[0].title}`);
|
||
}
|
||
showFullscreen.value = true;
|
||
}
|
||
|
||
function handleTimeUpdate() {
|
||
if (audioRef.value) {
|
||
playerState.currentTime = audioRef.value.currentTime;
|
||
}
|
||
}
|
||
|
||
function handleEnded() {
|
||
next();
|
||
}
|
||
|
||
// 切换歌曲时加载新的播放直链
|
||
watch(
|
||
() => playerState.src,
|
||
(src) => {
|
||
const audio = audioRef.value;
|
||
if (!audio) return;
|
||
if (src) {
|
||
audio.src = src;
|
||
audio.load();
|
||
if (playerState.isPlaying) {
|
||
audio.play().catch(() => {});
|
||
}
|
||
} else {
|
||
audio.removeAttribute("src");
|
||
audio.load();
|
||
}
|
||
},
|
||
);
|
||
|
||
// 播放/暂停
|
||
watch(
|
||
() => playerState.isPlaying,
|
||
(playing) => {
|
||
const audio = audioRef.value;
|
||
if (!audio || !playerState.src) return;
|
||
if (playing) {
|
||
audio.play().catch(() => {});
|
||
} else {
|
||
audio.pause();
|
||
}
|
||
},
|
||
);
|
||
|
||
// 音量同步
|
||
watch(
|
||
() => playerState.volume,
|
||
(vol) => {
|
||
if (audioRef.value) {
|
||
audioRef.value.volume = vol / 100;
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
// 进度跳转(拖动进度条时同步到 audio)
|
||
watch(
|
||
() => playerState.currentTime,
|
||
(time) => {
|
||
const audio = audioRef.value;
|
||
if (!audio || !Number.isFinite(audio.duration)) return;
|
||
if (Math.abs(audio.currentTime - time) > 0.8) {
|
||
audio.currentTime = time;
|
||
}
|
||
},
|
||
);
|
||
|
||
onUnmounted(() => {
|
||
const audio = audioRef.value;
|
||
if (audio) {
|
||
audio.pause();
|
||
audio.removeAttribute("src");
|
||
audio.load();
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.player-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: clamp(12px, 2vw, 20px);
|
||
height: clamp(72px, 9vh, 88px);
|
||
padding: 0 clamp(14px, 2vw, 22px);
|
||
flex-shrink: 0;
|
||
background: rgba(18, 18, 40, 0.92);
|
||
backdrop-filter: blur(14px);
|
||
border-top: 1px solid var(--app-border);
|
||
}
|
||
|
||
.audio-hidden {
|
||
display: none;
|
||
}
|
||
|
||
.player-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
width: clamp(190px, 20vw, 260px);
|
||
min-width: 170px;
|
||
}
|
||
|
||
.cover-mini {
|
||
position: relative;
|
||
display: grid;
|
||
place-items: center;
|
||
width: clamp(42px, 4.5vw, 52px);
|
||
height: clamp(42px, 4.5vw, 52px);
|
||
border-radius: 10px;
|
||
flex-shrink: 0;
|
||
background: var(--app-card);
|
||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.cover-mini:hover {
|
||
transform: scale(1.05);
|
||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
.cover-mini-mark {
|
||
color: rgba(255, 255, 255, 0.45);
|
||
}
|
||
|
||
.meta {
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.song-title {
|
||
margin: 0 0 3px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--app-text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.song-artist {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
color: var(--app-text-muted);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.player-center {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.controls {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.ctrl-btn {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
border: none;
|
||
border-radius: 50%;
|
||
background: transparent;
|
||
color: var(--app-text-secondary);
|
||
cursor: pointer;
|
||
transition:
|
||
color 0.15s ease,
|
||
background-color 0.15s ease;
|
||
}
|
||
|
||
.ctrl-btn:hover {
|
||
color: var(--app-text);
|
||
background: rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
.ctrl-btn.active {
|
||
color: var(--app-green);
|
||
}
|
||
|
||
.play-toggle {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 44px;
|
||
height: 44px;
|
||
border: none;
|
||
border-radius: 50%;
|
||
background: var(--app-green);
|
||
color: #052e16;
|
||
cursor: pointer;
|
||
box-shadow: 0 6px 16px rgba(34, 197, 94, 0.35);
|
||
transition:
|
||
transform 0.15s ease,
|
||
box-shadow 0.2s ease;
|
||
}
|
||
|
||
.play-toggle:hover {
|
||
transform: scale(1.06);
|
||
box-shadow: 0 8px 20px rgba(34, 197, 94, 0.5);
|
||
}
|
||
|
||
.progress {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
max-width: 620px;
|
||
margin: 4px auto 0;
|
||
}
|
||
|
||
.time {
|
||
min-width: 38px;
|
||
font-size: 12px;
|
||
color: var(--app-text-muted);
|
||
text-align: center;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.progress-slider {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.progress-slider :deep(.n-slider-fill),
|
||
.volume-slider :deep(.n-slider-fill) {
|
||
background: var(--app-green);
|
||
}
|
||
|
||
.volume-slider :deep(.n-slider-rail) {
|
||
background: rgba(255, 255, 255, 0.14);
|
||
}
|
||
|
||
.player-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: clamp(190px, 20vw, 260px);
|
||
min-width: 170px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.volume-slider {
|
||
width: 96px;
|
||
}
|
||
|
||
.icon-btn {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
color: var(--app-text-secondary);
|
||
cursor: pointer;
|
||
transition:
|
||
color 0.15s ease,
|
||
background-color 0.15s ease;
|
||
}
|
||
|
||
.icon-btn:hover {
|
||
color: var(--app-text);
|
||
background: rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
.icon-btn.active {
|
||
color: var(--app-green);
|
||
}
|
||
|
||
.queue-panel {
|
||
max-height: 320px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.queue-title {
|
||
margin: 0 0 8px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--app-text);
|
||
}
|
||
|
||
.queue-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.queue-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
width: 100%;
|
||
padding: 8px 10px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
color: var(--app-text-secondary);
|
||
text-align: left;
|
||
cursor: pointer;
|
||
transition: background-color 0.15s ease;
|
||
}
|
||
|
||
.queue-item:hover {
|
||
background: rgba(255, 255, 255, 0.06);
|
||
}
|
||
|
||
.queue-item.current .queue-name {
|
||
color: var(--app-green);
|
||
}
|
||
|
||
.queue-index {
|
||
width: 18px;
|
||
color: var(--app-text-muted);
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
|
||
.queue-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-size: 13px;
|
||
color: var(--app-text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.queue-artist {
|
||
font-size: 12px;
|
||
color: var(--app-text-muted);
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.player-left,
|
||
.player-right {
|
||
width: auto;
|
||
min-width: 0;
|
||
}
|
||
|
||
.volume-slider,
|
||
.queue-btn,
|
||
.lyrics-btn {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.like-btn {
|
||
display: none;
|
||
}
|
||
|
||
.controls {
|
||
gap: 8px;
|
||
}
|
||
|
||
.player-bar {
|
||
gap: 10px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 720px) {
|
||
.song-artist {
|
||
display: none;
|
||
}
|
||
|
||
.player-left {
|
||
flex: 0 0 150px;
|
||
}
|
||
|
||
.meta .song-title {
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
</style>
|