YwYMusic/src/components/PlayerBar.vue

563 lines
12 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="playerState.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 { usePlayerStore } 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 playerState = usePlayerStore()
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
}
playerState.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
}
playerState.playSong(playerState.queue[index])
}
function togglePlay() {
if (!playerState.current) {
const songs = playlists[0].songs
playerState.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
playerState.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(255, 250, 240, 0.94);
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(63, 48, 40, 0.46);
}
.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(180, 83, 42, 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: #fffaf0;
cursor: pointer;
box-shadow: 0 6px 16px rgba(180, 83, 42, 0.25);
transition:
transform 0.15s ease,
box-shadow 0.2s ease;
}
.play-toggle:hover {
transform: scale(1.06);
box-shadow: 0 8px 20px rgba(180, 83, 42, 0.34);
}
.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: #d8c8b3;
}
.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(180, 83, 42, 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(180, 83, 42, 0.07);
}
.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>