仰望星辰工作室

starmusic-pc

starmusic-pc/ frontend/src/components/PlayerBar.vue 19.2 KB · 800 行 原始文件
1<script setup>
2/**
3 * PlayerBar — 底部播放条
4 *
5 * 桌面端三段式布局:
6 * 左:封面 + 歌名/歌手(+ 当前歌词行)+ 收藏
7 * 中:模式 / 上一曲 / 播放 / 下一曲 + 进度条(拖动预览、双击回到原位)
8 * 右:音质快切、音量、播放队列、展开详情
9 * 「展开详情」打开全窗口播放页;点封面同样可以打开。
10 */
11import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
12import Icon from './Icon.vue'
13import CoverImage from './CoverImage.vue'
14import UiSlider from './UiSlider.vue'
15import {
16 playerState,
17 playerActions,
18 progressPercent,
19 playMethodText,
20 PLAY_MODE_ICON,
21} from '../composables/usePlayer.js'
22import { openNowPlaying, toggleQueue, queueOpen } from '../composables/useShell.js'
23import { getAvailableQualities, qualityText } from '../composables/useQuality.js'
24import { formatPlayTime2 } from '../common/utils.js'
25import { addListMusics, removeListMusics, checkListContainsMusic, LIST_IDS } from '../common/playlist.js'
26import event from '../common/event.js'
27import { toast } from '../composables/useFeedback.js'
28
29const hasMusic = computed(() => !!playerState.musicInfo)
30
31// ============ 进度 ============
32
33const seekValue = ref(0)
34
35watch(
36 () => playerState.currentTime,
37 (v) => {
38 if (!playerState.dragging) seekValue.value = v
39 },
40)
41
42const onSeekInput = (v) => {
43 playerState.dragging = true
44 seekValue.value = v
45}
46
47const onSeekChange = (v) => {
48 playerState.dragging = false
49 playerActions.seek(v)
50}
51
52const displayPercent = computed(() => {
53 const total = playerState.duration
54 if (!total || total <= 0) return 0
55 return Math.min(100, Math.max(0, (seekValue.value / total) * 100))
56})
57
58const timeText = computed(() => formatPlayTime2(playerState.dragging ? seekValue.value : playerState.currentTime))
59const durationText = computed(() => (playerState.duration > 0 ? formatPlayTime2(playerState.duration) : '--:--'))
60
61// ============ 播放控制 ============
62
63const bounceKey = ref(0)
64const isPlay = computed(() => playerState.isPlay)
65
66const handleToggle = () => {
67 if (!hasMusic.value) return
68 bounceKey.value++
69 playerActions.togglePlay()
70}
71
72const handlePrev = () => {
73 if (!hasMusic.value) return
74 playerActions.playPrev()
75}
76
77const handleNext = () => {
78 if (!hasMusic.value) return
79 playerActions.playNext(false)
80}
81
82const handleMode = () => {
83 const mode = playerActions.togglePlayMethod()
84 toast(`播放模式:${playMethodText.value || mode}`, 'none', 1500)
85}
86
87// ============ 收藏 ============
88
89const collected = ref(false)
90const refreshCollected = () => {
91 const id = playerState.musicInfo && playerState.musicInfo.id
92 collected.value = !!id && checkListContainsMusic(LIST_IDS.LOVE, id)
93}
94
95let offMusic = null
96onMounted(() => {
97 refreshCollected()
98 offMusic = event.on('playerMusicInfoChanged', refreshCollected)
99})
100onBeforeUnmount(() => {
101 if (offMusic) offMusic()
102})
103
104const toggleCollect = (e) => {
105 e.stopPropagation()
106 const info = playerState.musicInfo
107 if (!info) return
108 if (collected.value) {
109 removeListMusics(LIST_IDS.LOVE, [info.id])
110 toast('已取消收藏', 'none', 1500)
111 } else {
112 addListMusics(LIST_IDS.LOVE, [info], 'top')
113 toast('已添加到「我的收藏」', 'success', 1600)
114 }
115 refreshCollected()
116}
117
118// ============ 歌词预览 ============
119
120/** 解析后的歌词(按歌词文本缓存,避免每次进度变化都重新解析) */
121let lyricCache = { text: '', lines: [] }
122const activeLyricText = computed(() => {
123 const raw = playerState.lyric
124 if (!raw) return ''
125 if (lyricCache.text !== raw) {
126 const lines = []
127 const rows = String(raw).split(/\r\n|\r|\n/)
128 const re = /\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/g
129 for (const row of rows) {
130 re.lastIndex = 0
131 const tags = []
132 let m
133 while ((m = re.exec(row)) !== null) tags.push(m[1])
134 const text = row.replace(re, '').trim()
135 if (!text) continue
136 for (const tag of tags) {
137 const t = /^(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?$/.exec(tag)
138 if (!t) continue
139 const min = parseInt(t[1], 10)
140 const sec = parseInt(t[2], 10)
141 const frac = t[3] ? (t[3].length === 3 ? parseInt(t[3], 10) / 1000 : parseInt(t[3], 10) / 100) : 0
142 lines.push({ time: min * 60 + sec + frac, text })
143 }
144 }
145 lines.sort((a, b) => a.time - b.time)
146 lyricCache = { text: raw, lines }
147 }
148 const lines = lyricCache.lines
149 if (!lines.length) return ''
150 const t = playerState.currentTime + 0.25
151 let index = -1
152 for (let i = 0; i < lines.length; i++) {
153 if (lines[i].time <= t) index = i
154 else break
155 }
156 return index >= 0 ? lines[index].text : ''
157})
158
159// ============ 弹层(音量 / 音质) ============
160
161const popover = ref('') // '' | 'volume' | 'quality'
162const barRef = ref(null)
163
164const togglePopover = (name) => {
165 popover.value = popover.value === name ? '' : name
166}
167
168const onClickOutside = (e) => {
169 if (!popover.value) return
170 if (barRef.value && !barRef.value.contains(e.target)) popover.value = ''
171}
172
173onMounted(() => document.addEventListener('mousedown', onClickOutside))
174onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
175
176const qualities = computed(() => getAvailableQualities(playerState.musicInfo))
177
178const pickQuality = async (q) => {
179 popover.value = ''
180 if (q === playerState.quality) return
181 playerActions.setQualitySetting(q)
182 toast(`已切换为${qualityText(q)}`, 'none', 1600)
183 try {
184 await playerActions.refreshPlayUrl({ quality: q, isRefresh: true })
185 } catch (err) {
186 toast(`切换失败:${(err && err.message) || ''}`, 'error', 2400)
187 }
188}
189
190// ============ 音量 ============
191
192const volumeValue = ref(1)
193const lastVolume = ref(1)
194
195watch(
196 () => playerState.volume,
197 (v) => {
198 volumeValue.value = v
199 if (v > 0) lastVolume.value = v
200 },
201 { immediate: true },
202)
203
204const onVolumeInput = (v) => {
205 volumeValue.value = v
206 playerActions.setVolume(v)
207}
208
209const toggleMute = () => {
210 if (volumeValue.value > 0) {
211 lastVolume.value = volumeValue.value
212 volumeValue.value = 0
213 playerActions.setVolume(0)
214 } else {
215 volumeValue.value = lastVolume.value || 1
216 playerActions.setVolume(volumeValue.value)
217 }
218}
219
220const volumeIcon = computed(() => {
221 if (volumeValue.value <= 0.001) return 'volume-mute'
222 if (volumeValue.value < 0.5) return 'volume-1'
223 return 'volume'
224})
225
226// ============ 迷你均衡器(播放中时在封面旁跳动) ============
227
228const eqBars = [0, 1, 2, 3]
229</script>
230
231<template>
232 <footer ref="barRef" class="player-bar" :class="{ 'is-idle': !hasMusic }">
233 <!-- ============ 左:当前歌曲 ============ -->
234 <div class="pb-left" :class="{ 'is-clickable': hasMusic }" @click="hasMusic && openNowPlaying()">
235 <div class="pb-cover">
236 <CoverImage :src="playerState.pic" :size="56" radius="var(--r-md)" :active="isPlay" :icon-size="22" />
237 <Transition name="fade">
238 <span v-if="hasMusic" class="pb-cover-mask">
239 <Icon name="chevron-up" :size="18" :width="2.2" />
240 </span>
241 </Transition>
242 </div>
243
244 <div class="pb-info">
245 <div class="pb-name-row">
246 <span class="pb-name ellipsis">{{ hasMusic ? playerState.musicInfo.name || '未知歌曲' : '未在播放' }}</span>
247 <span v-if="isPlay" class="pb-eq">
248 <i v-for="b in eqBars" :key="b" :style="{ animationDelay: `${b * 130}ms` }" />
249 </span>
250 </div>
251 <div class="pb-sub ellipsis">
252 <template v-if="hasMusic">
253 <span class="pb-singer">{{ playerState.musicInfo.singer || '未知歌手' }}</span>
254 <template v-if="activeLyricText">
255 <span class="pb-dot">·</span>
256 <Transition name="lyric" mode="out-in">
257 <span :key="activeLyricText" class="pb-lyric ellipsis">{{ activeLyricText }}</span>
258 </Transition>
259 </template>
260 </template>
261 <template v-else>选一首歌开始聆听</template>
262 </div>
263 </div>
264
265 <button
266 v-if="hasMusic"
267 class="pb-heart"
268 :class="{ 'is-on': collected }"
269 :title="collected ? '取消收藏' : '收藏到我的收藏'"
270 @click="toggleCollect"
271 >
272 <Icon :name="collected ? 'heart-fill' : 'heart'" :size="17" />
273 </button>
274 </div>
275
276 <!-- ============ 中:控制 + 进度 ============ -->
277 <div class="pb-center">
278 <div class="pb-controls">
279 <button class="ctl" :title="`播放模式:${playMethodText}`" @click="handleMode">
280 <Icon :name="PLAY_MODE_ICON[playerState.playMethod] || 'repeat'" :size="17" />
281 </button>
282 <button class="ctl" title="上一曲" :disabled="!hasMusic" @click="handlePrev">
283 <Icon name="prev" :size="17" />
284 </button>
285 <button
286 :key="bounceKey"
287 class="ctl is-primary"
288 :class="{ 'a-bounce': bounceKey > 0 }"
289 :title="isPlay ? '暂停' : '播放'"
290 :disabled="!hasMusic"
291 @click="handleToggle"
292 >
293 <Icon :name="isPlay ? 'pause' : 'play'" :size="20" />
294 <span v-if="isPlay" class="pb-ring" />
295 </button>
296 <button class="ctl" title="下一曲" :disabled="!hasMusic" @click="handleNext">
297 <Icon name="next" :size="17" />
298 </button>
299 <button
300 class="ctl"
301 :class="{ 'is-on': queueOpen }"
302 title="播放队列"
303 @click="toggleQueue()"
304 >
305 <Icon name="queue" :size="17" />
306 </button>
307 </div>
308
309 <div class="pb-progress">
310 <span class="pb-time">{{ timeText }}</span>
311 <UiSlider
312 class="pb-slider"
313 variant="progress"
314 :model-value="seekValue"
315 :min="0"
316 :max="playerState.duration || 0"
317 :step="0.1"
318 :disabled="!hasMusic || playerState.duration <= 0"
319 :format-tip="(v) => formatPlayTime2(v)"
320 @update:model-value="onSeekInput"
321 @change="onSeekChange"
322 />
323 <span class="pb-time">{{ durationText }}</span>
324 </div>
325 </div>
326
327 <!-- ============ 右:音质 / 音量 / 展开 ============ -->
328 <div class="pb-right">
329 <!-- 音质 -->
330 <div class="pb-pop-wrap">
331 <button class="chip" :disabled="!hasMusic" title="切换播放音质" @click="togglePopover('quality')">
332 <span>{{ qualityText(playerState.quality) }}</span>
333 <Icon name="chevron-up" :size="12" />
334 </button>
335 <Transition name="pop">
336 <div v-if="popover === 'quality'" class="popover pop-right">
337 <div class="pop-head">播放音质</div>
338 <button
339 v-for="q in qualities"
340 :key="q"
341 class="pop-item"
342 :class="{ 'is-active': q === playerState.quality }"
343 @click="pickQuality(q)"
344 >
345 <span>{{ qualityText(q) }}</span>
346 <Icon v-if="q === playerState.quality" name="check" :size="14" :width="2.4" />
347 </button>
348 </div>
349 </Transition>
350 </div>
351
352 <!-- 音量 -->
353 <div class="pb-pop-wrap">
354 <button class="ctl" :title="volumeValue > 0 ? '静音' : '取消静音'" @click="toggleMute">
355 <Icon :name="volumeIcon" :size="17" />
356 </button>
357 <Transition name="pop">
358 <div v-if="popover === 'volume'" class="popover pop-volume">
359 <button class="ctl sm" title="静音" @click="toggleMute">
360 <Icon :name="volumeIcon" :size="15" />
361 </button>
362 <UiSlider
363 class="vol-slider"
364 variant="volume"
365 :model-value="volumeValue"
366 :min="0"
367 :max="1"
368 :step="0.01"
369 :format-tip="(v) => `${Math.round(v * 100)}%`"
370 @update:model-value="onVolumeInput"
371 />
372 <span class="vol-num">{{ Math.round(volumeValue * 100) }}</span>
373 </div>
374 </Transition>
375 </div>
376
377 <button class="ctl" title="展开播放页" @click="openNowPlaying()">
378 <Icon name="fullscreen" :size="17" />
379 </button>
380 </div>
381 </footer>
382</template>
383
384<style scoped>
385.player-bar {
386 flex: none;
387 height: var(--h-player);
388 display: grid;
389 grid-template-columns: minmax(210px, 1fr) minmax(320px, 1.5fr) minmax(210px, 1fr);
390 align-items: center;
391 gap: 14px;
392 padding: 0 14px;
393 background: var(--surface-glass-strong);
394 backdrop-filter: var(--blur-md);
395 -webkit-backdrop-filter: var(--blur-md);
396 border-top: 1px solid var(--line-1);
397 position: relative;
398 z-index: var(--z-player);
399}
400
401/* ---------- 左 ---------- */
402
403.pb-left {
404 display: flex;
405 align-items: center;
406 gap: 12px;
407 min-width: 0;
408 padding: 6px;
409 border-radius: var(--r-md);
410 transition: background-color var(--t-fast) var(--ease-out);
411}
412
413.pb-left.is-clickable {
414 cursor: pointer;
415}
416
417.pb-left.is-clickable:hover {
418 background: var(--surface-3);
419}
420
421.pb-cover {
422 position: relative;
423 flex: none;
424 display: grid;
425 place-items: center;
426}
427
428.pb-cover-mask {
429 position: absolute;
430 inset: 0;
431 display: grid;
432 place-items: center;
433 border-radius: var(--r-md);
434 background: rgba(18, 22, 28, 0.42);
435 color: #fff;
436 opacity: 0;
437 transition: opacity var(--t-base) var(--ease-out);
438}
439
440.pb-left:hover .pb-cover-mask {
441 opacity: 1;
442}
443
444.pb-info {
445 flex: 1;
446 min-width: 0;
447 display: flex;
448 flex-direction: column;
449 gap: 1px;
450}
451
452.pb-name-row {
453 display: flex;
454 align-items: center;
455 gap: 8px;
456 min-width: 0;
457}
458
459.pb-name {
460 font-size: 13.5px;
461 font-weight: 600;
462 color: var(--text-1);
463}
464
465.pb-eq {
466 display: flex;
467 align-items: flex-end;
468 gap: 2px;
469 height: 12px;
470 flex: none;
471}
472
473.pb-eq i {
474 width: 2.5px;
475 height: 100%;
476 border-radius: 2px;
477 background: var(--theme-color);
478 transform-origin: bottom center;
479 animation: kEq 900ms var(--ease-in-out) infinite;
480}
481
482.pb-sub {
483 display: flex;
484 align-items: center;
485 gap: 5px;
486 font-size: 11.5px;
487 color: var(--text-4);
488 min-width: 0;
489}
490
491.pb-singer {
492 flex: none;
493 max-width: 45%;
494 overflow: hidden;
495 text-overflow: ellipsis;
496 white-space: nowrap;
497}
498
499.pb-dot {
500 flex: none;
501 opacity: 0.6;
502}
503
504.pb-lyric {
505 min-width: 0;
506 color: var(--text-3);
507}
508
509.lyric-enter-active,
510.lyric-leave-active {
511 transition: opacity var(--t-base) var(--ease-out), transform var(--t-base) var(--ease-out);
512}
513
514.lyric-enter-from {
515 opacity: 0;
516 transform: translateY(6px);
517}
518
519.lyric-leave-to {
520 opacity: 0;
521 transform: translateY(-6px);
522}
523
524.pb-heart {
525 flex: none;
526 display: grid;
527 place-items: center;
528 width: 30px;
529 height: 30px;
530 border-radius: var(--r-sm);
531 color: var(--text-4);
532 transition: color var(--t-fast) var(--ease-out), background-color var(--t-fast) var(--ease-out),
533 transform var(--t-fast) var(--ease-overshoot);
534}
535
536.pb-heart:hover {
537 background: var(--surface-1);
538 color: var(--color-heart);
539 transform: scale(1.08);
540}
541
542.pb-heart.is-on {
543 color: var(--color-heart);
544}
545
546.pb-heart.is-on:hover {
547 transform: scale(1.14);
548}
549
550/* ---------- 中 ---------- */
551
552.pb-center {
553 display: flex;
554 flex-direction: column;
555 align-items: center;
556 gap: 4px;
557 min-width: 0;
558}
559
560.pb-controls {
561 display: flex;
562 align-items: center;
563 gap: 8px;
564}
565
566.ctl {
567 position: relative;
568 display: grid;
569 place-items: center;
570 width: 34px;
571 height: 34px;
572 border-radius: var(--r-full);
573 color: var(--text-2);
574 transition: color var(--t-fast) var(--ease-out), background-color var(--t-fast) var(--ease-out),
575 transform var(--t-fast) var(--ease-overshoot);
576}
577
578.ctl:hover:not(:disabled) {
579 color: var(--text-1);
580 background: var(--surface-3);
581}
582
583.ctl:active:not(:disabled) {
584 transform: scale(0.92);
585}
586
587.ctl:disabled {
588 opacity: 0.38;
589 cursor: not-allowed;
590}
591
592.ctl.is-on {
593 color: var(--theme-color);
594 background: var(--theme-color-soft);
595}
596
597.ctl.is-primary {
598 width: 42px;
599 height: 42px;
600 color: #fff;
601 background: linear-gradient(135deg, var(--theme-color), var(--theme-color-dark));
602 box-shadow: 0 6px 18px rgba(var(--theme-color-rgb), 0.4);
603}
604
605.ctl.is-primary:hover:not(:disabled) {
606 filter: brightness(1.07);
607 transform: scale(1.05);
608}
609
610.ctl.sm {
611 width: 26px;
612 height: 26px;
613}
614
615.pb-ring {
616 position: absolute;
617 inset: -3px;
618 border-radius: var(--r-full);
619 border: 2px solid rgba(var(--theme-color-rgb), 0.55);
620 animation: kPulseRing 2.4s var(--ease-out) infinite;
621 pointer-events: none;
622}
623
624.pb-progress {
625 display: flex;
626 align-items: center;
627 gap: 10px;
628 width: 100%;
629 max-width: 620px;
630}
631
632.pb-time {
633 flex: none;
634 width: 42px;
635 font-size: 11.5px;
636 font-variant-numeric: tabular-nums;
637 color: var(--text-4);
638 text-align: center;
639}
640
641.pb-slider {
642 flex: 1;
643 min-width: 0;
644}
645
646/* ---------- 右 ---------- */
647
648.pb-right {
649 display: flex;
650 align-items: center;
651 justify-content: flex-end;
652 gap: 4px;
653 min-width: 0;
654}
655
656.pb-pop-wrap {
657 position: relative;
658 display: flex;
659 align-items: center;
660}
661
662.chip {
663 display: inline-flex;
664 align-items: center;
665 gap: 4px;
666 height: 26px;
667 padding: 0 9px;
668 border-radius: var(--r-full);
669 border: 1px solid var(--line-2);
670 background: var(--surface-2);
671 color: var(--text-3);
672 font-size: 11.5px;
673 white-space: nowrap;
674 transition: border-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out),
675 background-color var(--t-fast) var(--ease-out);
676}
677
678.chip:hover:not(:disabled) {
679 border-color: var(--theme-color);
680 color: var(--theme-color-dark);
681 background: var(--theme-color-soft);
682}
683
684.chip:disabled {
685 opacity: 0.4;
686 cursor: not-allowed;
687}
688
689/* ---------- 弹层 ---------- */
690
691.popover {
692 position: absolute;
693 bottom: calc(100% + 12px);
694 min-width: 148px;
695 padding: 6px;
696 border-radius: var(--r-lg);
697 /* 浮层用不透明底色:背景图模式下 --surface-1 半透明,下层内容会透上来 */
698 background: var(--surface-pop);
699 box-shadow: var(--sh-lg);
700 border: 1px solid var(--line-1);
701 z-index: var(--z-dropdown);
702}
703
704.pop-right {
705 right: 0;
706}
707
708.pop-head {
709 padding: 5px 10px 7px;
710 font-size: 10.5px;
711 letter-spacing: 0.7px;
712 color: var(--text-5);
713 text-transform: uppercase;
714}
715
716.pop-item {
717 display: flex;
718 align-items: center;
719 justify-content: space-between;
720 gap: 14px;
721 width: 100%;
722 height: 30px;
723 padding: 0 10px;
724 border-radius: var(--r-sm);
725 font-size: 12.5px;
726 color: var(--text-2);
727 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
728}
729
730.pop-item:hover {
731 background: var(--surface-3);
732}
733
734.pop-item.is-active {
735 color: var(--theme-color-dark);
736 background: var(--theme-color-soft);
737 font-weight: 500;
738}
739
740.pop-volume {
741 right: 0;
742 display: flex;
743 align-items: center;
744 gap: 8px;
745 min-width: 190px;
746}
747
748.vol-slider {
749 flex: 1;
750 min-width: 96px;
751}
752
753.vol-num {
754 flex: none;
755 width: 28px;
756 font-size: 11px;
757 font-variant-numeric: tabular-nums;
758 color: var(--text-4);
759 text-align: right;
760}
761
762.pop-enter-active,
763.pop-leave-active {
764 transition: opacity var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-spring);
765 transform-origin: bottom right;
766}
767
768.pop-enter-from,
769.pop-leave-to {
770 opacity: 0;
771 transform: translateY(8px) scale(0.94);
772}
773
774.fade-enter-active,
775.fade-leave-active {
776 transition: opacity var(--t-base) var(--ease-out);
777}
778
779.fade-enter-from,
780.fade-leave-to {
781 opacity: 0;
782}
783
784/* 窄窗口:隐藏次要控件 */
785@media (max-width: 1180px) {
786 .pb-right .ctl[title='展开播放页'] {
787 display: none;
788 }
789}
790
791@media (max-width: 980px) {
792 .player-bar {
793 grid-template-columns: minmax(160px, 1fr) minmax(280px, 1.6fr) auto;
794 }
795
796 .pb-heart {
797 display: none;
798 }
799}
800</style>