starmusic-pc
1<script setup>
2/**
3 * PlayQueue — 右侧播放队列
4 *
5 * 桌面端专属面板:让「正在播放 / 接下来播放」一目了然。
6 * - 顶部固定当前曲目卡片,带跳动的均衡器
7 * - 下方为队列,支持点击直达、悬停移除、拖拽排序
8 * - 头部显示队列长度与总时长,并提供清空操作
9 */
10import { computed, ref } from 'vue'
11import Icon from './Icon.vue'
12import CoverImage from './CoverImage.vue'
13import { playerState, playerActions } from '../composables/usePlayer.js'
14import { queueOpen } from '../composables/useShell.js'
15import { formatPlayTime2 } from '../common/utils.js'
16import { getPicUrl, getPicKey } from '../common/music/index.js'
17import { confirm, toast } from '../composables/useFeedback.js'
18
19const picMap = ref({})
20const pending = new Set()
21
22const getPic = (item) => {
23 if (!item) return ''
24 if (item.img) return item.img
25 const key = getPicKey(item)
26 if (picMap.value[key]) return picMap.value[key]
27 if (!pending.has(key)) {
28 pending.add(key)
29 getPicUrl(item)
30 .then((url) => {
31 if (url) picMap.value = { ...picMap.value, [key]: url }
32 })
33 .catch(() => {})
34 .finally(() => pending.delete(key))
35 }
36 return ''
37}
38
39const list = computed(() => playerState.playList || [])
40const currentIndex = computed(() => playerState.playIndex)
41
42/** 队列总时长文案 */
43const totalText = computed(() => {
44 let seconds = 0
45 for (const item of list.value) {
46 const t = item && item.interval
47 if (!t || t === '--/--') continue
48 const parts = String(t).split(':')
49 if (parts.length === 2) seconds += parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10)
50 }
51 if (!seconds) return ''
52 const h = Math.floor(seconds / 3600)
53 const m = Math.round((seconds % 3600) / 60)
54 return h > 0 ? `${h} 小时 ${m} 分` : `${m} 分钟`
55})
56
57const playAt = (index) => {
58 if (index === currentIndex.value) {
59 playerActions.togglePlay()
60 return
61 }
62 playerActions.playListByList([...list.value], index)
63}
64
65const removeAt = (index, e) => {
66 e.stopPropagation()
67 playerActions.removePlayListIndex(index)
68}
69
70const clearAll = async () => {
71 if (!list.value.length) return
72 const ok = await confirm({
73 title: '清空播放队列',
74 content: '将移除队列中的其它歌曲,当前播放不会中断。',
75 confirmText: '清空',
76 danger: true,
77 })
78 if (!ok) return
79 playerActions.clearPlayListQueue()
80 toast('已清空播放队列', 'none', 1600)
81}
82
83// ============ 拖拽排序 ============
84
85const dragIndex = ref(-1)
86const dragOverIndex = ref(-1)
87
88const onDragStart = (index, e) => {
89 dragIndex.value = index
90 try {
91 e.dataTransfer.effectAllowed = 'move'
92 e.dataTransfer.setData('text/plain', String(index))
93 } catch (err) {
94 /* 忽略 */
95 }
96}
97
98const onDragOver = (index, e) => {
99 e.preventDefault()
100 dragOverIndex.value = index
101}
102
103const onDrop = (index, e) => {
104 e.preventDefault()
105 const from = dragIndex.value
106 dragIndex.value = -1
107 dragOverIndex.value = -1
108 if (from < 0 || from === index) return
109 playerActions.movePlayListIndex(from, index)
110}
111
112const onDragEnd = () => {
113 dragIndex.value = -1
114 dragOverIndex.value = -1
115}
116
117const eqBars = [0, 1, 2, 3]
118</script>
119
120<template>
121 <aside class="queue" :class="{ 'is-open': queueOpen }">
122 <header class="q-head">
123 <div class="q-title">
124 <Icon name="queue" :size="16" />
125 <span>播放队列</span>
126 <em class="q-count">{{ list.length }}</em>
127 </div>
128 <div class="q-actions">
129 <span v-if="totalText" class="q-total">{{ totalText }}</span>
130 <button class="q-btn" title="清空队列" :disabled="!list.length" @click="clearAll">
131 <Icon name="trash" :size="15" />
132 </button>
133 </div>
134 </header>
135
136 <!-- 当前播放 -->
137 <div v-if="playerState.musicInfo" class="now-card">
138 <CoverImage
139 :src="playerState.pic || getPic(playerState.musicInfo)"
140 :size="46"
141 radius="var(--r-md)"
142 :active="playerState.isPlay"
143 :icon-size="20"
144 />
145 <div class="now-info">
146 <span class="now-name ellipsis">{{ playerState.musicInfo.name || '未知歌曲' }}</span>
147 <span class="now-singer ellipsis">{{ playerState.musicInfo.singer || '未知歌手' }}</span>
148 </div>
149 <span class="now-eq" :class="{ 'is-paused': !playerState.isPlay }">
150 <i v-for="b in eqBars" :key="b" :style="{ animationDelay: `${b * 130}ms` }" />
151 </span>
152 </div>
153
154 <!-- 队列列表 -->
155 <div class="q-list scroll-y">
156 <p v-if="!list.length" class="q-empty">
157 <Icon name="music" :size="30" :width="1.5" />
158 <span>队列是空的</span>
159 </p>
160
161 <div
162 v-for="(item, index) in list"
163 :key="item.id || index"
164 class="q-item"
165 :class="{
166 'is-active': index === currentIndex,
167 'is-dragging': dragIndex === index,
168 'is-over': dragOverIndex === index && dragIndex !== index,
169 }"
170 draggable="true"
171 @click="playAt(index)"
172 @dragstart="onDragStart(index, $event)"
173 @dragover="onDragOver(index, $event)"
174 @drop="onDrop(index, $event)"
175 @dragend="onDragEnd"
176 @dblclick="playAt(index)"
177 >
178 <span class="q-index">
179 <span v-if="index === currentIndex" class="q-eq" :class="{ 'is-paused': !playerState.isPlay }">
180 <i v-for="b in eqBars" :key="b" :style="{ animationDelay: `${b * 120}ms` }" />
181 </span>
182 <span v-else>{{ index + 1 }}</span>
183 </span>
184
185 <CoverImage :src="getPic(item)" :size="34" radius="var(--r-xs)" :icon-size="15" />
186
187 <span class="q-info">
188 <span class="q-name ellipsis" :class="{ 'is-active': index === currentIndex }">{{ item.name || '未知歌曲' }}</span>
189 <span class="q-singer ellipsis">{{ item.singer || '未知歌手' }}</span>
190 </span>
191
192 <span class="q-time">{{ item.interval && item.interval !== '--/--' ? item.interval : '' }}</span>
193
194 <button class="q-del" title="从队列移除" @click="removeAt(index, $event)">
195 <Icon name="close" :size="13" :width="2.2" />
196 </button>
197 </div>
198 </div>
199
200 <footer class="q-foot">
201 <span>提示:拖动可调整播放顺序,双击立即播放</span>
202 </footer>
203 </aside>
204</template>
205
206<style scoped>
207.queue {
208 width: var(--w-queue);
209 flex: none;
210 display: flex;
211 flex-direction: column;
212 min-height: 0;
213 background: var(--surface-1);
214 border-left: 1px solid var(--line-1);
215 position: relative;
216 z-index: 15;
217}
218
219.q-head {
220 flex: none;
221 display: flex;
222 align-items: center;
223 justify-content: space-between;
224 gap: 8px;
225 padding: 12px 12px 10px;
226 border-bottom: 1px solid var(--line-1);
227}
228
229.q-title {
230 display: flex;
231 align-items: center;
232 gap: 7px;
233 font-size: 13.5px;
234 font-weight: 600;
235 color: var(--text-1);
236}
237
238.q-count {
239 font-style: normal;
240 padding: 0 6px;
241 height: 16px;
242 line-height: 16px;
243 border-radius: var(--r-full);
244 background: var(--theme-color-soft);
245 color: var(--theme-color-dark);
246 font-size: 10.5px;
247 font-weight: 500;
248}
249
250.q-actions {
251 display: flex;
252 align-items: center;
253 gap: 8px;
254}
255
256.q-total {
257 font-size: 11px;
258 color: var(--text-5);
259 white-space: nowrap;
260}
261
262.q-btn {
263 display: grid;
264 place-items: center;
265 width: 26px;
266 height: 26px;
267 border-radius: var(--r-sm);
268 color: var(--text-4);
269 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
270}
271
272.q-btn:hover:not(:disabled) {
273 background: rgba(244, 63, 94, 0.1);
274 color: var(--color-danger);
275}
276
277.q-btn:disabled {
278 opacity: 0.38;
279 cursor: not-allowed;
280}
281
282/* 当前播放卡片 */
283.now-card {
284 flex: none;
285 display: flex;
286 align-items: center;
287 gap: 10px;
288 margin: 10px 10px 6px;
289 padding: 10px;
290 border-radius: var(--r-lg);
291 background: linear-gradient(135deg, var(--theme-color-soft), var(--surface-1));
292 border: 1px solid rgba(var(--theme-color-rgb), 0.2);
293 animation: kFadeUp var(--t-slow) var(--ease-out) both;
294}
295
296.now-info {
297 flex: 1;
298 min-width: 0;
299 display: flex;
300 flex-direction: column;
301 gap: 2px;
302}
303
304.now-name {
305 font-size: 13px;
306 font-weight: 600;
307 color: var(--text-1);
308}
309
310.now-singer {
311 font-size: 11.5px;
312 color: var(--text-4);
313}
314
315.now-eq,
316.q-eq {
317 display: flex;
318 align-items: flex-end;
319 gap: 2px;
320 height: 14px;
321 flex: none;
322}
323
324.now-eq i,
325.q-eq i {
326 width: 2.5px;
327 height: 100%;
328 border-radius: 2px;
329 background: var(--theme-color);
330 transform-origin: bottom center;
331 animation: kEq 900ms var(--ease-in-out) infinite;
332}
333
334.now-eq.is-paused i,
335.q-eq.is-paused i {
336 animation-play-state: paused;
337 transform: scaleY(0.32);
338}
339
340/* 队列 */
341.q-list {
342 flex: 1;
343 min-height: 0;
344 padding: 4px 8px 8px;
345 display: flex;
346 flex-direction: column;
347 gap: 1px;
348}
349
350.q-empty {
351 flex: 1;
352 display: flex;
353 flex-direction: column;
354 align-items: center;
355 justify-content: center;
356 gap: 10px;
357 color: var(--text-5);
358 font-size: 12.5px;
359 padding: 40px 0;
360}
361
362.q-item {
363 display: grid;
364 grid-template-columns: 22px 34px minmax(0, 1fr) auto 24px;
365 align-items: center;
366 gap: 9px;
367 padding: 6px 7px;
368 border-radius: var(--r-md);
369 cursor: default;
370 transition: background-color var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-out);
371}
372
373.q-item:hover {
374 background: var(--surface-3);
375}
376
377.q-item.is-active {
378 background: rgba(var(--theme-color-rgb), 0.1);
379}
380
381.q-item.is-dragging {
382 opacity: 0.45;
383}
384
385.q-item.is-over {
386 box-shadow: 0 -2px 0 0 var(--theme-color) inset;
387}
388
389.q-index {
390 display: grid;
391 place-items: center;
392 font-size: 11.5px;
393 font-variant-numeric: tabular-nums;
394 color: var(--text-4);
395}
396
397.q-info {
398 display: flex;
399 flex-direction: column;
400 min-width: 0;
401 gap: 1px;
402}
403
404.q-name {
405 font-size: 12.5px;
406 color: var(--text-1);
407}
408
409.q-name.is-active {
410 color: var(--theme-color-dark);
411 font-weight: 600;
412}
413
414.q-singer {
415 font-size: 11px;
416 color: var(--text-4);
417}
418
419.q-time {
420 font-size: 11px;
421 font-variant-numeric: tabular-nums;
422 color: var(--text-5);
423}
424
425.q-del {
426 display: grid;
427 place-items: center;
428 width: 22px;
429 height: 22px;
430 border-radius: var(--r-xs);
431 color: var(--text-5);
432 opacity: 0;
433 transition: opacity var(--t-fast) var(--ease-out), background-color var(--t-fast) var(--ease-out),
434 color var(--t-fast) var(--ease-out);
435}
436
437.q-item:hover .q-del {
438 opacity: 1;
439}
440
441.q-del:hover {
442 background: rgba(244, 63, 94, 0.12);
443 color: var(--color-danger);
444}
445
446.q-foot {
447 flex: none;
448 padding: 8px 12px;
449 border-top: 1px solid var(--line-1);
450 font-size: 10.5px;
451 color: var(--text-5);
452 text-align: center;
453}
454</style>