仰望星辰工作室

starmusic-pc

starmusic-pc/ frontend/src/views/PlayDetailView.vue 36.9 KB · 1,463 行 原始文件
1<script setup>
2/**
3 * PlayDetailView — 播放详情(全窗口覆盖层)
4 *
5 * 桌面端的「正在播放」体验:
6 * - 以封面模糊图 + 主题色渐变作为沉浸背景
7 * - 左侧:旋转唱片(播放时转动、暂停时停在当前角度)+ 呼吸光晕 + 歌曲信息 + 控制台 + 频谱
8 * - 右侧:歌词居中滚动,支持点行跳转
9 * - 音质 / 下载 / 收藏到歌单 / 新建歌单 均以对话框承载
10 * - 歌曲彩蛋(后台可配置)以粒子动画呈现
11 */
12import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
13import Icon from '../components/Icon.vue'
14import LrcView from '../components/LrcView.vue'
15import Visualizer from '../components/Visualizer.vue'
16import UiSlider from '../components/UiSlider.vue'
17import UiDialog from '../components/UiDialog.vue'
18import UiContextMenu from '../components/UiContextMenu.vue'
19import CoverImage from '../components/CoverImage.vue'
20
21import { playerState, playerActions, progressPercent, playMethodText, PLAY_MODE_ICON } from '../composables/usePlayer.js'
22import { themeColor, currentTheme } from '../composables/useTheme.js'
23import { queueOpen, toggleQueue } from '../composables/useShell.js'
24import { getAvailableQualities, getDownloadQualities, qualityText, qualityExt, qualitySize } from '../composables/useQuality.js'
25import {
26 getCollectableLists,
27 checkListContainsMusic,
28 addListMusics,
29 removeListMusics,
30 createCustomList,
31 LIST_IDS,
32} from '../common/playlist.js'
33import { getMusicUrl } from '../common/music/index.js'
34import {
35 ensureEasterEggLoaded,
36 getLastTrigger,
37 getEggText,
38 getEggAnimation,
39} from '../common/easter-egg.js'
40import { normalizePicUrl } from '../common/music/index.js'
41import { formatPlayTime2 } from '../common/utils.js'
42import { downloadToDisk, onEvent, showInFolder, getDefaultDownloadDir, pickDirectory } from '../uni/native.js'
43import { toast, toastOk, toastErr, confirm, errText } from '../composables/useFeedback.js'
44
45const emit = defineEmits(['close'])
46
47const hasMusic = computed(() => !!playerState.musicInfo)
48const music = computed(() => playerState.musicInfo || {})
49const isPlay = computed(() => playerState.isPlay)
50
51const coverSrc = computed(() => playerState.pic || (music.value && music.value.img) || '')
52
53// ============ 进度 ============
54
55const seekValue = ref(0)
56const dragging = ref(false)
57
58watch(
59 () => playerState.currentTime,
60 (v) => {
61 if (!dragging.value) seekValue.value = v
62 },
63 { immediate: true },
64)
65
66const onSeekInput = (v) => {
67 dragging.value = true
68 seekValue.value = v
69}
70
71const onSeekChange = (v) => {
72 dragging.value = false
73 playerActions.seek(v)
74}
75
76const timeText = computed(() => formatPlayTime2(dragging.value ? seekValue.value : playerState.currentTime))
77const durationText = computed(() => (playerState.duration > 0 ? formatPlayTime2(playerState.duration) : '--:--'))
78
79// ============ 播放控制 ============
80
81const bounceKey = ref(0)
82const handleToggle = () => {
83 if (!hasMusic.value) return
84 bounceKey.value++
85 playerActions.togglePlay()
86}
87
88const handleMode = () => {
89 playerActions.togglePlayMethod()
90 toast(`播放模式:${playMethodText.value}`, 'none', 1400)
91}
92
93// ============ 唱片 ============
94
95/**
96 * 唱片旋转:用 CSS 动画承载,暂停时通过 animation-play-state 定格,
97 * 这样恢复播放会从当前角度继续,而不是「跳回原点」。
98 */
99const discSpin = computed(() => ({
100 animationPlayState: isPlay.value ? 'running' : 'paused',
101}))
102
103// ============ 音质 ============
104
105const qualityOpen = ref(false)
106const qualitySwitching = ref(false)
107const qualities = computed(() => getAvailableQualities(playerState.musicInfo))
108
109const pickQuality = async (q) => {
110 if (qualitySwitching.value) return
111 if (q === playerState.quality) {
112 qualityOpen.value = false
113 return
114 }
115 qualitySwitching.value = true
116 playerActions.setQualitySetting(q)
117 uni.showLoading({ title: '切换音质中…', mask: true })
118 try {
119 await playerActions.refreshPlayUrl({ quality: q, isRefresh: true })
120 uni.hideLoading()
121 toastOk(`已切换至${qualityText(q)}`)
122 qualityOpen.value = false
123 } catch (err) {
124 uni.hideLoading()
125 toastErr(`切换失败:${errText(err)}`)
126 } finally {
127 qualitySwitching.value = false
128 }
129}
130
131// ============ 收藏到歌单 ============
132
133const collectOpen = ref(false)
134const createOpen = ref(false)
135const newListName = ref('')
136const createError = ref('')
137const listVersion = ref(0)
138
139const collectableLists = computed(() => {
140 void listVersion.value
141 return getCollectableLists()
142})
143
144const isInList = (listId) => {
145 void listVersion.value
146 return !!music.value.id && checkListContainsMusic(listId, music.value.id)
147}
148
149const toggleCollectList = (item) => {
150 if (!music.value.id) return
151 if (isInList(item.id)) {
152 removeListMusics(item.id, [music.value.id])
153 toast(`已从「${item.name}」移除`, 'none', 1600)
154 } else {
155 addListMusics(item.id, [music.value], 'top')
156 toastOk(`已添加到「${item.name}」`)
157 }
158 listVersion.value++
159}
160
161const submitCreateList = () => {
162 const name = newListName.value.trim()
163 if (!name) {
164 createError.value = '歌单名不能为空'
165 return
166 }
167 try {
168 const info = createCustomList(name)
169 if (music.value.id) addListMusics(info.id, [music.value], 'top')
170 listVersion.value++
171 createOpen.value = false
172 newListName.value = ''
173 createError.value = ''
174 toastOk(`已添加到「${info.name}」`)
175 } catch (err) {
176 createError.value = errText(err, '创建失败')
177 }
178}
179
180const inAnyList = computed(() => {
181 void listVersion.value
182 return collectableLists.value.some((l) => isInList(l.id))
183})
184
185// ============ 下载 ============
186
187const downloadOpen = ref(false)
188const downloadQualities = computed(() => getDownloadQualities(playerState.musicInfo))
189const downloadDir = ref('')
190const downloading = ref(false)
191const progress = ref({ percent: 0, received: 0, total: 0 })
192
193let offProgress = null
194
195onMounted(async () => {
196 downloadDir.value = (await getDefaultDownloadDir()) || ''
197 offProgress = onEvent('download:progress', (payload) => {
198 if (!payload) return
199 progress.value = {
200 percent: payload.percent || 0,
201 received: payload.received || 0,
202 total: payload.total || 0,
203 }
204 })
205})
206
207onBeforeUnmount(() => {
208 if (offProgress) offProgress()
209})
210
211const buildFileName = () => {
212 const m = music.value
213 const base = [m.name || '未命名', m.singer || ''].filter(Boolean).join(' - ')
214 return `${base.replace(/[\\/:*?"<>|\r\n]/g, '_').slice(0, 80)}${qualityExt(m.quality || playerState.quality)}`
215}
216
217const startDownload = async (quality) => {
218 if (!hasMusic.value) {
219 toast('没有可下载的歌曲', 'none', 1800)
220 return
221 }
222 downloading.value = true
223 progress.value = { percent: 0, received: 0, total: 0 }
224 try {
225 const info = await getMusicUrl({ musicInfo: music.value, quality, isRefresh: true })
226 if (!info || !info.url) throw new Error('获取下载地址失败')
227 const filename = `${[music.value.name || '未命名', music.value.singer || ''].filter(Boolean).join(' - ')
228 .replace(/[\\/:*?"<>|\r\n]/g, '_')
229 .slice(0, 80)}${qualityExt(quality)}`
230 const res = await downloadToDisk(info.url, filename, downloadDir.value)
231 downloadOpen.value = false
232 if (!res.path) {
233 toastOk('已交给浏览器下载')
234 return
235 }
236 const ok = await confirm({
237 title: '下载完成',
238 content: `${res.name}\n保存位置:${res.dir}`,
239 confirmText: '打开文件夹',
240 cancelText: '关闭',
241 })
242 if (ok) await showInFolder(res.path)
243 } catch (err) {
244 toastErr(errText(err, '下载失败'))
245 } finally {
246 downloading.value = false
247 }
248}
249
250const changeDownloadDir = async () => {
251 const dir = await pickDirectory('选择下载目录')
252 if (dir) {
253 downloadDir.value = dir
254 toast(`下载目录:${dir}`, 'none', 2400)
255 }
256}
257
258const onDownloadClick = () => {
259 if (!hasMusic.value) {
260 toast('没有可下载的歌曲', 'none', 1600)
261 return
262 }
263 if (!downloadQualities.value.length) {
264 toast('当前音源未声明可用音质', 'none', 2000)
265 return
266 }
267 // 仅有单一音质时跳过选择,直接下载
268 if (downloadQualities.value.length === 1) {
269 startDownload(downloadQualities.value[0])
270 return
271 }
272 downloadOpen.value = true
273}
274
275// ============ 更多菜单 ============
276
277const menu = ref({ visible: false, x: 0, y: 0 })
278
279const openMore = (e) => {
280 const rect = e.currentTarget.getBoundingClientRect()
281 menu.value = { visible: true, x: rect.right - 220, y: rect.bottom + 8 }
282}
283
284const menuItems = computed(() => {
285 const info = music.value
286 const inDefault = info.id ? checkListContainsMusic(LIST_IDS.DEFAULT, info.id) : false
287 return [
288 { key: 'quality', label: '切换音质', icon: 'music2', hint: qualityText(playerState.quality) },
289 { key: 'collect', label: '添加到歌单…', icon: 'heart' },
290 { key: 'download', label: '下载这首歌', icon: 'download' },
291 { divider: true },
292 { key: 'refresh', label: '重新获取播放地址', icon: 'refresh' },
293 { key: 'refreshLyric', label: '刷新歌词', icon: 'edit' },
294 {
295 key: 'removeTemp',
296 label: '从试听列表移除',
297 icon: 'trash',
298 danger: true,
299 disabled: !inDefault,
300 },
301 ]
302})
303
304const onMenuSelect = async (key) => {
305 switch (key) {
306 case 'quality':
307 qualityOpen.value = true
308 break
309 case 'collect':
310 collectOpen.value = true
311 break
312 case 'download':
313 onDownloadClick()
314 break
315 case 'refresh':
316 uni.showLoading({ title: '重新获取播放地址…', mask: true })
317 try {
318 await playerActions.refreshPlayUrl({ isRefresh: true })
319 uni.hideLoading()
320 toastOk('已刷新播放地址')
321 } catch (err) {
322 uni.hideLoading()
323 toastErr(errText(err, '刷新失败'))
324 }
325 break
326 case 'refreshLyric':
327 toast('正在刷新歌词…', 'none', 1400)
328 await playerActions.refreshLyric(true)
329 break
330 case 'removeTemp':
331 if (music.value.id) {
332 removeListMusics(LIST_IDS.DEFAULT, [music.value.id])
333 toastOk('已从试听列表移除')
334 listVersion.value++
335 }
336 break
337 }
338}
339
340// ============ 彩蛋 ============
341
342const egg = ref(null)
343const eggParticles = ref([])
344const eggAnim = ref('fireworks')
345const eggText = ref('')
346let eggTimer = null
347let eggCheckedId = ''
348
349const EGG_ICONS = {
350 fireworks: ['✦', '✧', '★', '✶', '✷', '●'],
351 hearts: ['♥', '♡', '❥'],
352 notes: ['♪', '♫', '♬', '♩'],
353 sparkle: ['✦', '✧', '✩', '✶'],
354 meteor: ['✧', '✦', '•'],
355}
356
357const pickIcon = (anim) => {
358 const pool = EGG_ICONS[anim] || EGG_ICONS.fireworks
359 return pool[Math.floor(Math.random() * pool.length)]
360}
361
362const buildParticles = (anim) => {
363 const count = anim === 'fireworks' ? 14 : 12
364 return Array.from({ length: count }, (_, i) => ({
365 id: i,
366 icon: pickIcon(anim),
367 left: 8 + Math.random() * 84,
368 top: 40 + Math.random() * 52,
369 size: 16 + Math.random() * 20,
370 delay: Math.random() * 0.9,
371 duration: anim === 'sparkle' ? 1.4 + Math.random() * 0.8 : 2.2 + Math.random() * 1.4,
372 dx: (Math.random() - 0.5) * 160,
373 rot: (Math.random() - 0.5) * 100,
374 color: Math.random() > 0.55 ? currentTheme.value.color : '#ffffff',
375 }))
376}
377
378const showEgg = (config) => {
379 eggAnim.value = getEggAnimation(config)
380 eggText.value = getEggText(config)
381 eggParticles.value = buildParticles(eggAnim.value)
382 egg.value = config
383 if (eggTimer) clearTimeout(eggTimer)
384 eggTimer = setTimeout(() => {
385 egg.value = null
386 eggParticles.value = []
387 }, 8000)
388}
389
390const checkEgg = async () => {
391 const info = music.value
392 if (!info || !info.id || eggCheckedId === info.id) return
393 eggCheckedId = info.id
394 const last = getLastTrigger()
395 if (last && last.songKey && last.songKey === `${String(info.name || '').toLowerCase()}|${String(info.singer || '').toLowerCase()}` && Date.now() - last.time < 60_000) {
396 showEgg(last.egg)
397 return
398 }
399 try {
400 const hit = await ensureEasterEggLoaded(info)
401 if (hit && (!music.value || music.value.id === info.id)) showEgg(hit)
402 } catch (e) {
403 /* 静默 */
404 }
405}
406
407watch(
408 () => (music.value ? music.value.id : ''),
409 () => {
410 nextTick(checkEgg)
411 },
412)
413
414onMounted(() => nextTick(checkEgg))
415
416onBeforeUnmount(() => {
417 if (eggTimer) clearTimeout(eggTimer)
418})
419
420// ============ 快捷键(组件内) ============
421
422const onKey = (e) => {
423 if (e.key === 'Escape') {
424 // 弹层优先级:先关弹层,再关覆盖层
425 if (createOpen.value) return
426 if (collectOpen.value) {
427 collectOpen.value = false
428 e.stopPropagation()
429 return
430 }
431 if (qualityOpen.value) {
432 qualityOpen.value = false
433 e.stopPropagation()
434 return
435 }
436 if (downloadOpen.value) {
437 downloadOpen.value = false
438 e.stopPropagation()
439 return
440 }
441 }
442}
443
444onMounted(() => window.addEventListener('keydown', onKey, true))
445onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
446</script>
447
448<template>
449 <div class="pd">
450 <!-- ============ 沉浸背景 ============ -->
451 <div class="pd-bg">
452 <div v-if="coverSrc" class="pd-bg-img" :style="{ backgroundImage: `url('${coverSrc}')` }" />
453 <div class="pd-bg-veil" />
454 <div class="pd-bg-tint" />
455 </div>
456
457 <!-- ============ 顶栏 ============ -->
458 <header class="pd-top">
459 <button class="top-btn press" title="收起播放页 (Esc)" @click="emit('close')">
460 <Icon name="chevron-down" :size="17" :width="2.2" />
461 <span>收起</span>
462 </button>
463 <div class="pd-top-title ellipsis">
464 <Icon name="music2" :size="13" />
465 <span>{{ hasMusic ? `${music.name || '未知歌曲'} · ${music.singer || '未知歌手'}` : '未在播放' }}</span>
466 </div>
467 <div class="pd-top-actions">
468 <button class="top-btn is-icon" :class="{ 'is-on': queueOpen }" title="播放队列 (Q)" @click="toggleQueue()">
469 <Icon name="queue" :size="16" />
470 </button>
471 </div>
472 </header>
473
474 <!-- ============ 主体 ============ -->
475 <main class="pd-main">
476 <!-- 左:唱片 + 信息 + 控制 -->
477 <section class="pd-left">
478 <div class="disc-wrap">
479 <div v-if="hasMusic" class="disc" :style="discSpin">
480 <span class="disc-groove g1" />
481 <span class="disc-groove g2" />
482 <span class="disc-groove g3" />
483 <CoverImage :src="coverSrc" :size="252" radius="var(--r-full)" :icon-size="52" class="disc-cover" />
484 <span class="disc-hole" />
485 </div>
486 <div v-else class="disc is-empty">
487 <Icon name="music" :size="60" :width="1.4" />
488 </div>
489 <span class="disc-glow" />
490 </div>
491
492 <div class="pd-meta">
493 <h1 class="pd-name ellipsis">{{ hasMusic ? music.name || '未知歌曲' : '未在播放' }}</h1>
494 <p class="pd-singer ellipsis">
495 <template v-if="hasMusic">
496 {{ music.singer || '未知歌手' }}
497 <template v-if="music.albumName"> · {{ music.albumName }}</template>
498 </template>
499 <template v-else>从搜索页或歌单里挑一首吧</template>
500 </p>
501 <div v-if="hasMusic" class="pd-badges">
502 <span class="badge">{{ qualityText(playerState.quality) }}</span>
503 <span v-if="music.source" class="badge soft">{{ music.source.toUpperCase() }}</span>
504 <span v-if="music.interval && music.interval !== '--/--'" class="badge soft">{{ music.interval }}</span>
505 </div>
506 </div>
507
508 <!-- 进度 -->
509 <div class="pd-progress">
510 <span class="pd-time">{{ timeText }}</span>
511 <UiSlider
512 class="pd-slider"
513 variant="progress"
514 :model-value="seekValue"
515 :min="0"
516 :max="playerState.duration || 0"
517 :step="0.1"
518 :disabled="!hasMusic || playerState.duration <= 0"
519 :format-tip="(v) => formatPlayTime2(v)"
520 @update:model-value="onSeekInput"
521 @change="onSeekChange"
522 />
523 <span class="pd-time">{{ durationText }}</span>
524 </div>
525
526 <!-- 控制台 -->
527 <div class="pd-controls">
528 <button class="ctrl" :title="`播放模式:${playMethodText}`" @click="handleMode">
529 <Icon :name="PLAY_MODE_ICON[playerState.playMethod] || 'repeat'" :size="19" />
530 </button>
531 <button class="ctrl" title="上一曲" :disabled="!hasMusic" @click="playerActions.playPrev()">
532 <Icon name="prev" :size="19" />
533 </button>
534 <button
535 :key="bounceKey"
536 class="ctrl is-play"
537 :class="{ 'a-bounce': bounceKey > 0 }"
538 :title="isPlay ? '暂停 (空格)' : '播放 (空格)'"
539 :disabled="!hasMusic"
540 @click="handleToggle"
541 >
542 <Icon :name="isPlay ? 'pause' : 'play'" :size="26" />
543 <span v-if="isPlay" class="play-ring" />
544 </button>
545 <button class="ctrl" title="下一曲" :disabled="!hasMusic" @click="playerActions.playNext(false)">
546 <Icon name="next" :size="19" />
547 </button>
548 <button class="ctrl" :title="`音质:${qualityText(playerState.quality)}`" :disabled="!hasMusic" @click="qualityOpen = true">
549 <span class="ctrl-text">{{ qualityText(playerState.quality) }}</span>
550 </button>
551 </div>
552
553 <!-- 次级操作 -->
554 <div class="pd-actions">
555 <button class="act" :disabled="!hasMusic" @click="collectOpen = true">
556 <Icon :name="inAnyList ? 'heart-fill' : 'heart'" :size="17" />
557 <span>{{ inAnyList ? '已收藏' : '收藏' }}</span>
558 </button>
559 <button class="act" :disabled="!hasMusic" @click="onDownloadClick">
560 <Icon name="download" :size="17" />
561 <span>下载</span>
562 </button>
563 <button class="act" :disabled="!hasMusic" @click="openMore">
564 <Icon name="more" :size="17" />
565 <span>更多</span>
566 </button>
567 </div>
568 </section>
569
570 <!-- 右:歌词 -->
571 <section class="pd-right">
572 <header class="lyric-head">
573 <span class="lyric-title">
574 <Icon name="edit" :size="13" />
575 歌词
576 </span>
577 <div class="lyric-actions">
578 <button class="mini-act" title="刷新歌词" @click="playerActions.refreshLyric(true)">
579 <Icon name="refresh" :size="13" />
580 </button>
581 </div>
582 </header>
583 <div class="lyric-body">
584 <LrcView
585 :lyric="playerState.lyric"
586 :tlyric="playerState.tlyric"
587 :current-time="playerState.currentTime"
588 :loading="playerState.gettingUrl"
589 @seek="playerActions.seek($event)"
590 />
591 </div>
592 </section>
593 </main>
594
595 <!-- ============ 频谱 ============ -->
596 <footer class="pd-viz">
597 <Visualizer
598 :playing="isPlay"
599 :music-info="music"
600 :color="themeColor"
601 :height="78"
602 :show-base="true"
603 />
604 </footer>
605
606 <!-- ============ 彩蛋层 ============ -->
607 <div v-if="egg" class="egg-layer" :class="`anim-${eggAnim}`">
608 <span
609 v-for="p in eggParticles"
610 :key="p.id"
611 class="egg-particle"
612 :style="{
613 left: `${p.left}%`,
614 top: `${p.top}%`,
615 fontSize: `${p.size}px`,
616 color: p.color,
617 animationDelay: `${p.delay}s`,
618 animationDuration: `${p.duration}s`,
619 '--dx': `${p.dx}px`,
620 '--rot': `${p.rot}deg`,
621 }"
622 >
623 {{ p.icon }}
624 </span>
625 <div class="egg-card a-pop-in">
626 <p class="egg-text">
627 <Icon name="sparkle" :size="16" />
628 {{ eggText }}
629 </p>
630 <p class="egg-foot">星音乐 · 隐藏彩蛋</p>
631 </div>
632 </div>
633
634 <!-- ============ 音质选择 ============ -->
635 <UiDialog v-model:visible="qualityOpen" title="选择音质" subtitle="受当前音源支持范围约束,会自动降级" :width="420">
636 <div class="sheet-list">
637 <button
638 v-for="q in qualities"
639 :key="q"
640 class="sheet-item"
641 :class="{ 'is-on': q === playerState.quality }"
642 :disabled="qualitySwitching"
643 @click="pickQuality(q)"
644 >
645 <span class="sheet-icon"><Icon name="music2" :size="15" /></span>
646 <span class="sheet-label">{{ qualityText(q) }}</span>
647 <span class="sheet-hint">{{ q }}</span>
648 <Icon v-if="q === playerState.quality" name="check" :size="16" :width="2.4" class="sheet-check" />
649 </button>
650 </div>
651 </UiDialog>
652
653 <!-- ============ 下载音质 ============ -->
654 <UiDialog
655 v-model:visible="downloadOpen"
656 title="选择下载音质"
657 :subtitle="downloadDir ? `保存到:${downloadDir}` : '保存到系统下载目录'"
658 :width="470"
659 :before-close="() => !downloading"
660 >
661 <div class="sheet-list">
662 <button
663 v-for="q in downloadQualities"
664 :key="q"
665 class="sheet-item"
666 :disabled="downloading"
667 @click="startDownload(q)"
668 >
669 <span class="sheet-icon"><Icon name="download" :size="15" /></span>
670 <span class="sheet-label">{{ qualityText(q) }}</span>
671 <span class="sheet-hint">{{ qualitySize(music, q) || q }}</span>
672 </button>
673 </div>
674
675 <div v-if="downloading" class="dl-progress">
676 <div class="dl-bar">
677 <span class="dl-fill" :style="{ width: `${progress.percent}%` }" />
678 </div>
679 <span class="dl-text">下载中 {{ progress.percent }}%</span>
680 </div>
681
682 <template #footer>
683 <button class="btn press" @click="changeDownloadDir">
684 <Icon name="folder" :size="14" />
685 <span>更改目录</span>
686 </button>
687 <button class="btn press" :disabled="downloading" @click="downloadOpen = false">取消</button>
688 </template>
689 </UiDialog>
690
691 <!-- ============ 添加到歌单 ============ -->
692 <UiDialog v-model:visible="collectOpen" title="添加到歌单" subtitle="点击列表项即可加入或移出" :width="440">
693 <div class="sheet-list">
694 <button
695 v-for="item in collectableLists"
696 :key="item.id"
697 class="sheet-item"
698 :class="{ 'is-on': isInList(item.id) }"
699 @click="toggleCollectList(item)"
700 >
701 <span class="sheet-icon">
702 <Icon :name="item.builtin ? 'heart' : 'list-music'" :size="15" />
703 </span>
704 <span class="sheet-label">{{ item.name }}</span>
705 <span v-if="item.builtin" class="sheet-hint">内置</span>
706 <Icon v-if="isInList(item.id)" name="check" :size="16" :width="2.4" class="sheet-check" />
707 </button>
708 </div>
709 <template #footer>
710 <button class="btn press" @click="createOpen = true">
711 <Icon name="plus" :size="14" />
712 <span>新建歌单</span>
713 </button>
714 <button class="btn is-primary press" @click="collectOpen = false">完成</button>
715 </template>
716 </UiDialog>
717
718 <!-- ============ 新建歌单 ============ -->
719 <UiDialog v-model:visible="createOpen" title="新建歌单" subtitle="创建后会自动把当前歌曲加入" :width="420">
720 <input
721 v-model="newListName"
722 class="ipt"
723 type="text"
724 maxlength="30"
725 placeholder="请输入歌单名称"
726 @keydown.enter="submitCreateList"
727 />
728 <p v-if="createError" class="err-text">{{ createError }}</p>
729 <template #footer>
730 <button class="btn press" @click="createOpen = false">取消</button>
731 <button class="btn is-primary press" @click="submitCreateList">创建</button>
732 </template>
733 </UiDialog>
734
735 <!-- ============ 更多菜单 ============ -->
736 <UiContextMenu
737 :visible="menu.visible"
738 :x="menu.x"
739 :y="menu.y"
740 :items="menuItems"
741 :width="228"
742 @select="onMenuSelect"
743 @close="menu.visible = false"
744 />
745 </div>
746</template>
747
748<style scoped>
749.pd {
750 position: absolute;
751 inset: 0;
752 z-index: var(--z-overlay);
753 display: flex;
754 flex-direction: column;
755 overflow: hidden;
756 animation: kFadeIn var(--t-slow) ease both;
757}
758
759/* ---------- 背景 ---------- */
760
761.pd-bg {
762 position: absolute;
763 inset: 0;
764 z-index: 0;
765 overflow: hidden;
766}
767
768.pd-bg-img {
769 position: absolute;
770 inset: -8%;
771 background-size: cover;
772 background-position: center;
773 filter: blur(58px) saturate(170%);
774 transform: scale(1.16);
775 opacity: 0.9;
776 transition: background-image var(--t-slower) var(--ease-out);
777}
778
779.pd-bg-veil {
780 position: absolute;
781 inset: 0;
782 background: rgba(255, 255, 255, 0.78);
783}
784
785.pd-bg-tint {
786 position: absolute;
787 inset: 0;
788 background: radial-gradient(120% 90% at 16% 8%, rgba(var(--theme-color-rgb), 0.24), transparent 58%),
789 linear-gradient(180deg, transparent 30%, rgba(255, 255, 255, 0.5) 100%);
790}
791
792/* ---------- 顶栏 ---------- */
793
794.pd-top {
795 position: relative;
796 z-index: 3;
797 flex: none;
798 display: flex;
799 align-items: center;
800 justify-content: space-between;
801 gap: 14px;
802 padding: 14px 22px 6px;
803}
804
805.top-btn {
806 display: inline-flex;
807 align-items: center;
808 gap: 7px;
809 height: 34px;
810 padding: 0 14px;
811 border-radius: var(--r-full);
812 background: var(--surface-glass-strong);
813 backdrop-filter: var(--blur-md);
814 -webkit-backdrop-filter: var(--blur-md);
815 border: 1px solid rgba(255, 255, 255, 0.6);
816 color: var(--text-2);
817 font-size: 12.5px;
818 box-shadow: var(--sh-sm);
819 transition: color var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-out);
820}
821
822.top-btn:hover {
823 color: var(--theme-color-dark);
824}
825
826.top-btn.is-icon {
827 padding: 0 10px;
828}
829
830.top-btn.is-on {
831 color: var(--theme-color-dark);
832 border-color: rgba(var(--theme-color-rgb), 0.4);
833}
834
835.pd-top-title {
836 display: inline-flex;
837 align-items: center;
838 gap: 7px;
839 min-width: 0;
840 font-size: 12.5px;
841 color: var(--text-3);
842}
843
844.pd-top-actions {
845 display: flex;
846 align-items: center;
847 gap: 8px;
848}
849
850/* ---------- 主体 ---------- */
851
852.pd-main {
853 position: relative;
854 z-index: 2;
855 flex: 1;
856 min-height: 0;
857 display: grid;
858 grid-template-columns: minmax(330px, 0.85fr) minmax(320px, 1.15fr);
859 gap: 26px;
860 padding: 6px 30px 8px;
861 align-items: center;
862}
863
864/* ---------- 左 ---------- */
865
866.pd-left {
867 display: flex;
868 flex-direction: column;
869 align-items: center;
870 text-align: center;
871 gap: 15px;
872 min-width: 0;
873}
874
875.disc-wrap {
876 position: relative;
877 display: grid;
878 place-items: center;
879 width: 274px;
880 height: 274px;
881 flex: none;
882}
883
884.disc {
885 position: relative;
886 display: grid;
887 place-items: center;
888 width: 258px;
889 height: 258px;
890 border-radius: var(--r-full);
891 background: radial-gradient(circle at 50% 50%, #2a2d33 0%, #1b1e23 42%, #0f1114 78%);
892 box-shadow: 0 26px 60px rgba(15, 20, 28, 0.32), inset 0 0 0 1px rgba(255, 255, 255, 0.08);
893 animation: kSpin 26s linear infinite;
894 overflow: hidden;
895}
896
897.disc.is-empty {
898 color: var(--text-5);
899 animation: none;
900 background: var(--surface-3);
901 box-shadow: var(--sh-md);
902}
903
904.disc-groove {
905 position: absolute;
906 border-radius: var(--r-full);
907 border: 1px solid rgba(255, 255, 255, 0.055);
908 pointer-events: none;
909}
910
911.disc-groove.g1 {
912 inset: 8%;
913}
914
915.disc-groove.g2 {
916 inset: 16%;
917}
918
919.disc-groove.g3 {
920 inset: 25%;
921}
922
923.disc-cover {
924 position: relative;
925 z-index: 2;
926 box-shadow: 0 0 0 5px rgba(255, 255, 255, 0.09);
927}
928
929.disc-hole {
930 position: absolute;
931 z-index: 3;
932 width: 26px;
933 height: 26px;
934 border-radius: var(--r-full);
935 background: radial-gradient(circle at 50% 50%, #eceff3 0 34%, #191c21 36% 100%);
936 box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.14);
937}
938
939.disc-glow {
940 position: absolute;
941 inset: -6px;
942 border-radius: var(--r-full);
943 pointer-events: none;
944 animation: kGlow 3.6s var(--ease-in-out) infinite;
945}
946
947/* 信息 */
948.pd-meta {
949 display: flex;
950 flex-direction: column;
951 align-items: center;
952 gap: 6px;
953 max-width: 100%;
954}
955
956.pd-name {
957 font-size: 24px;
958 font-weight: 700;
959 letter-spacing: 0.4px;
960 color: var(--text-1);
961 max-width: 100%;
962}
963
964.pd-singer {
965 font-size: 13px;
966 color: var(--text-3);
967 max-width: 100%;
968}
969
970.pd-badges {
971 display: flex;
972 flex-wrap: wrap;
973 justify-content: center;
974 gap: 6px;
975 margin-top: 3px;
976}
977
978.badge {
979 padding: 2px 9px;
980 border-radius: var(--r-full);
981 font-size: 11px;
982 color: var(--theme-color-dark);
983 background: var(--theme-color-soft);
984 border: 1px solid rgba(var(--theme-color-rgb), 0.24);
985}
986
987.badge.soft {
988 color: var(--text-3);
989 background: var(--surface-glass);
990 border-color: var(--line-1);
991}
992
993/* 进度 */
994.pd-progress {
995 display: flex;
996 align-items: center;
997 gap: 12px;
998 width: 100%;
999 max-width: 470px;
1000}
1001
1002.pd-time {
1003 flex: none;
1004 width: 42px;
1005 font-size: 11.5px;
1006 font-variant-numeric: tabular-nums;
1007 color: var(--text-3);
1008 text-align: center;
1009}
1010
1011.pd-slider {
1012 flex: 1;
1013 min-width: 0;
1014}
1015
1016/* 控制台 */
1017.pd-controls {
1018 display: flex;
1019 align-items: center;
1020 gap: 12px;
1021}
1022
1023.ctrl {
1024 position: relative;
1025 display: grid;
1026 place-items: center;
1027 width: 44px;
1028 height: 44px;
1029 border-radius: var(--r-full);
1030 color: var(--text-2);
1031 transition: color var(--t-fast) var(--ease-out), background-color var(--t-fast) var(--ease-out),
1032 transform var(--t-fast) var(--ease-overshoot);
1033}
1034
1035.ctrl:hover:not(:disabled) {
1036 color: var(--text-1);
1037 background: var(--surface-glass);
1038 transform: scale(1.06);
1039}
1040
1041.ctrl:disabled {
1042 opacity: 0.36;
1043 cursor: not-allowed;
1044}
1045
1046.ctrl-text {
1047 font-size: 12.5px;
1048 font-weight: 500;
1049}
1050
1051.ctrl.is-play {
1052 width: 66px;
1053 height: 66px;
1054 color: #fff;
1055 background: linear-gradient(135deg, var(--theme-color), var(--theme-color-dark));
1056 box-shadow: 0 12px 30px rgba(var(--theme-color-rgb), 0.44);
1057}
1058
1059.ctrl.is-play:hover:not(:disabled) {
1060 filter: brightness(1.07);
1061 transform: scale(1.04);
1062 background: linear-gradient(135deg, var(--theme-color), var(--theme-color-dark));
1063 color: #fff;
1064}
1065
1066.play-ring {
1067 position: absolute;
1068 inset: -4px;
1069 border-radius: var(--r-full);
1070 border: 2px solid rgba(var(--theme-color-rgb), 0.5);
1071 animation: kPulseRing 2.6s var(--ease-out) infinite;
1072 pointer-events: none;
1073}
1074
1075/* 次级操作 */
1076.pd-actions {
1077 display: flex;
1078 align-items: center;
1079 gap: 10px;
1080}
1081
1082.act {
1083 display: inline-flex;
1084 align-items: center;
1085 gap: 7px;
1086 height: 34px;
1087 padding: 0 14px;
1088 border-radius: var(--r-full);
1089 background: var(--surface-glass);
1090 backdrop-filter: var(--blur-sm);
1091 -webkit-backdrop-filter: var(--blur-sm);
1092 border: 1px solid rgba(255, 255, 255, 0.6);
1093 color: var(--text-2);
1094 font-size: 12.5px;
1095 transition: color var(--t-fast) var(--ease-out), background-color var(--t-fast) var(--ease-out),
1096 transform var(--t-fast) var(--ease-out);
1097}
1098
1099.act:hover:not(:disabled) {
1100 color: var(--theme-color-dark);
1101 transform: translateY(-1px);
1102}
1103
1104.act:disabled {
1105 opacity: 0.42;
1106 cursor: not-allowed;
1107}
1108
1109/* ---------- 右:歌词 ---------- */
1110
1111.pd-right {
1112 display: flex;
1113 flex-direction: column;
1114 min-height: 0;
1115 height: 100%;
1116 border-radius: var(--r-xl);
1117 background: var(--surface-glass);
1118 backdrop-filter: var(--blur-md);
1119 -webkit-backdrop-filter: var(--blur-md);
1120 border: 1px solid rgba(255, 255, 255, 0.6);
1121 box-shadow: var(--sh-md);
1122 overflow: hidden;
1123}
1124
1125.lyric-head {
1126 flex: none;
1127 display: flex;
1128 align-items: center;
1129 justify-content: space-between;
1130 padding: 12px 16px 8px;
1131 border-bottom: 1px solid rgba(255, 255, 255, 0.5);
1132}
1133
1134.lyric-title {
1135 display: inline-flex;
1136 align-items: center;
1137 gap: 7px;
1138 font-size: 12.5px;
1139 font-weight: 600;
1140 color: var(--text-2);
1141 letter-spacing: 0.5px;
1142}
1143
1144.lyric-actions {
1145 display: flex;
1146 gap: 4px;
1147}
1148
1149.mini-act {
1150 display: grid;
1151 place-items: center;
1152 width: 26px;
1153 height: 26px;
1154 border-radius: var(--r-sm);
1155 color: var(--text-4);
1156 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
1157}
1158
1159.mini-act:hover {
1160 background: var(--surface-3);
1161 color: var(--theme-color);
1162}
1163
1164.lyric-body {
1165 flex: 1;
1166 min-height: 0;
1167}
1168
1169/* ---------- 频谱 ---------- */
1170
1171.pd-viz {
1172 position: relative;
1173 z-index: 2;
1174 flex: none;
1175 padding: 0 26px 14px;
1176}
1177
1178/* ---------- 彩蛋 ---------- */
1179
1180.egg-layer {
1181 position: absolute;
1182 inset: 0;
1183 z-index: 40;
1184 pointer-events: none;
1185 overflow: hidden;
1186}
1187
1188.egg-particle {
1189 position: absolute;
1190 line-height: 1;
1191 animation-name: kEggFloatUp;
1192 animation-timing-function: var(--ease-out);
1193 animation-iteration-count: 1;
1194 animation-fill-mode: both;
1195 text-shadow: 0 2px 10px rgba(20, 26, 36, 0.22);
1196}
1197
1198.anim-sparkle .egg-particle {
1199 animation-name: kEggTwinkle;
1200 animation-iteration-count: infinite;
1201}
1202
1203.anim-meteor .egg-particle {
1204 animation-name: kEggMeteor;
1205}
1206
1207.egg-card {
1208 position: absolute;
1209 left: 50%;
1210 top: 50%;
1211 transform: translate(-50%, -50%);
1212 padding: 16px 26px;
1213 border-radius: var(--r-xl);
1214 background: var(--surface-glass-strong);
1215 backdrop-filter: var(--blur-md);
1216 -webkit-backdrop-filter: var(--blur-md);
1217 border: 1px solid rgba(var(--theme-color-rgb), 0.34);
1218 box-shadow: 0 20px 50px rgba(var(--theme-color-rgb), 0.28);
1219 text-align: center;
1220}
1221
1222.egg-text {
1223 display: inline-flex;
1224 align-items: center;
1225 gap: 9px;
1226 font-size: 16px;
1227 font-weight: 600;
1228 letter-spacing: 1.2px;
1229 color: var(--text-1);
1230}
1231
1232.egg-foot {
1233 margin-top: 7px;
1234 font-size: 11px;
1235 letter-spacing: 1.6px;
1236 color: var(--text-4);
1237}
1238
1239/* ---------- 对话框内列表 ---------- */
1240
1241.sheet-list {
1242 display: flex;
1243 flex-direction: column;
1244 gap: 6px;
1245 padding-bottom: 4px;
1246 max-height: 46vh;
1247 overflow-y: auto;
1248}
1249
1250.sheet-item {
1251 display: flex;
1252 align-items: center;
1253 gap: 11px;
1254 width: 100%;
1255 height: 44px;
1256 padding: 0 13px;
1257 border-radius: var(--r-md);
1258 border: 1px solid var(--line-1);
1259 background: var(--surface-2);
1260 color: var(--text-2);
1261 font-size: 13.5px;
1262 transition: border-color var(--t-fast) var(--ease-out), background-color var(--t-fast) var(--ease-out),
1263 color var(--t-fast) var(--ease-out);
1264}
1265
1266.sheet-item:hover:not(:disabled) {
1267 border-color: var(--theme-color);
1268 background: var(--theme-color-soft);
1269 color: var(--theme-color-dark);
1270}
1271
1272.sheet-item:disabled {
1273 opacity: 0.5;
1274 cursor: wait;
1275}
1276
1277.sheet-item.is-on {
1278 border-color: rgba(var(--theme-color-rgb), 0.5);
1279 background: var(--theme-color-soft);
1280 color: var(--theme-color-dark);
1281}
1282
1283.sheet-icon {
1284 display: grid;
1285 place-items: center;
1286 width: 26px;
1287 height: 26px;
1288 border-radius: var(--r-sm);
1289 background: var(--surface-1);
1290 color: inherit;
1291 flex: none;
1292}
1293
1294.sheet-label {
1295 flex: 1;
1296 min-width: 0;
1297 text-align: left;
1298 font-weight: 500;
1299}
1300
1301.sheet-hint {
1302 flex: none;
1303 font-size: 11.5px;
1304 color: var(--text-4);
1305}
1306
1307.sheet-check {
1308 flex: none;
1309 color: var(--theme-color);
1310}
1311
1312.dl-progress {
1313 margin-top: 14px;
1314}
1315
1316.dl-bar {
1317 height: 6px;
1318 border-radius: var(--r-full);
1319 background: var(--surface-4);
1320 overflow: hidden;
1321}
1322
1323.dl-fill {
1324 display: block;
1325 height: 100%;
1326 border-radius: inherit;
1327 background: linear-gradient(90deg, var(--theme-color), var(--theme-color-dark));
1328 transition: width 180ms linear;
1329}
1330
1331.dl-text {
1332 display: block;
1333 margin-top: 7px;
1334 font-size: 11.5px;
1335 color: var(--text-4);
1336 text-align: center;
1337}
1338
1339.ipt {
1340 width: 100%;
1341 height: 42px;
1342 padding: 0 13px;
1343 border-radius: var(--r-md);
1344 border: 1.5px solid var(--line-2);
1345 background: var(--surface-2);
1346 color: var(--text-1);
1347 font-size: 13.5px;
1348 outline: none;
1349 transition: border-color var(--t-base) var(--ease-out), box-shadow var(--t-base) var(--ease-out);
1350}
1351
1352.ipt:focus {
1353 border-color: var(--theme-color);
1354 box-shadow: 0 0 0 4px rgba(var(--theme-color-rgb), 0.12);
1355 background: var(--surface-1);
1356}
1357
1358.err-text {
1359 margin-top: 9px;
1360 font-size: 12px;
1361 color: var(--color-danger);
1362}
1363
1364.btn {
1365 display: inline-flex;
1366 align-items: center;
1367 gap: 6px;
1368 height: 34px;
1369 padding: 0 16px;
1370 border-radius: var(--r-sm);
1371 border: 1px solid var(--line-2);
1372 background: var(--surface-1);
1373 color: var(--text-2);
1374 font-size: 13px;
1375 transition: border-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out),
1376 background-color var(--t-fast) var(--ease-out);
1377}
1378
1379.btn:hover:not(:disabled) {
1380 border-color: var(--theme-color);
1381 color: var(--theme-color-dark);
1382 background: var(--theme-color-soft);
1383}
1384
1385.btn:disabled {
1386 opacity: 0.5;
1387 cursor: wait;
1388}
1389
1390.btn.is-primary {
1391 color: #fff;
1392 border-color: transparent;
1393 background: linear-gradient(135deg, var(--theme-color), var(--theme-color-dark));
1394 box-shadow: 0 4px 14px rgba(var(--theme-color-rgb), 0.3);
1395}
1396
1397.btn.is-primary:hover:not(:disabled) {
1398 filter: brightness(1.06);
1399 color: #fff;
1400}
1401
1402/* ---------- 响应式 ---------- */
1403
1404@media (max-width: 1040px) {
1405 .pd-main {
1406 grid-template-columns: minmax(0, 1fr);
1407 grid-template-rows: auto minmax(180px, 1fr);
1408 align-items: stretch;
1409 gap: 14px;
1410 padding: 4px 22px 8px;
1411 overflow-y: auto;
1412 }
1413
1414 .disc-wrap {
1415 width: 190px;
1416 height: 190px;
1417 }
1418
1419 .disc {
1420 width: 178px;
1421 height: 178px;
1422 }
1423
1424 .disc-cover {
1425 width: 176px !important;
1426 height: 176px !important;
1427 }
1428
1429 .pd-name {
1430 font-size: 20px;
1431 }
1432
1433 .pd-viz {
1434 display: none;
1435 }
1436}
1437
1438@media (max-height: 760px) {
1439 .disc-wrap {
1440 width: 208px;
1441 height: 208px;
1442 }
1443
1444 .disc {
1445 width: 196px;
1446 height: 196px;
1447 }
1448
1449 .disc-cover {
1450 width: 194px !important;
1451 height: 194px !important;
1452 }
1453
1454 .pd-name {
1455 font-size: 21px;
1456 }
1457
1458 .ctrl.is-play {
1459 width: 58px;
1460 height: 58px;
1461 }
1462}
1463</style>