YwYMusic/src/components/PlayerBar.vue

607 lines
13 KiB
Vue
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.

<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>