starmusic-pc
1<script setup>
2/**
3 * MusicList — 桌面化歌曲列表
4 *
5 * 相对移动端的关键差异:
6 * - 多列栅格:序号 / 封面+标题 / 歌手 / 专辑 / 音质 / 时长 / 操作
7 * - 悬停整行高亮,序号列变为播放按钮,「⋯」更多按钮浮出
8 * - 单击即播(与移动端一致),右键或「⋯」打开上下文菜单
9 * - 当前播放行:主题色标题 + 动态均衡器替代序号
10 * - 封面依旧走「分批 + 并发上限」的懒加载,避免长列表打爆接口
11 */
12import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
13import Icon from './Icon.vue'
14import CoverImage from './CoverImage.vue'
15import { getPicUrl, getPicKey } from '../common/music/index.js'
16import { SOURCE_NAMES } from '../common/constants.js'
17
18const props = defineProps({
19 list: { type: Array, default: () => [] },
20 loading: { type: Boolean, default: false },
21 hasMore: { type: Boolean, default: false },
22 emptyText: { type: String, default: '暂无歌曲' },
23 /** 列表标题(显示在表头下方的小字说明) */
24 subtitle: { type: String, default: '' },
25 showIndex: { type: Boolean, default: true },
26 showAlbum: { type: Boolean, default: true },
27 showQuality: { type: Boolean, default: false },
28 showActions: { type: Boolean, default: true },
29 currentMusicId: { type: String, default: '' },
30 /** 播放器是否正在播放(控制均衡器动画暂停) */
31 playing: { type: Boolean, default: false },
32 batchMode: { type: Boolean, default: false },
33 selectedIds: { type: Array, default: () => [] },
34 /** 是否需要触底加载更多 */
35 infinite: { type: Boolean, default: true },
36})
37
38const emit = defineEmits(['item-click', 'item-play', 'item-more', 'load-more', 'batch-toggle'])
39
40// ============ 封面懒加载 ============
41
42/**
43 * 封面懒加载的并发上限:同一个列表最多同时发 5 个封面请求,
44 * 长列表(我的收藏可能上千首)不会一次性打爆网络。
45 */
46const PIC_CONCURRENCY = 5
47
48/** 提前 320px 预加载:滚动到之前封面就已经在路上了 */
49const PIC_ROOT_MARGIN = '320px'
50
51const rootRef = ref(null)
52const picMap = ref({})
53const picFailed = ref({})
54/** 待加载的封面:key -> item(用 Map 而非 Set,消费队列时需要取出 item) */
55let picQueued = new Map()
56let picRunning = 0
57/** 观察每一行的可见性:谁快被看到就加载谁的封面 */
58let picObserver = null
59let destroyed = false
60
61const getItemPic = (item) => {
62 const key = item.id || getPicKey(item)
63 if (picFailed.value[key]) return ''
64 if (item.img) return item.img
65 if (item.meta && item.meta.picUrl) return item.meta.picUrl
66 return picMap.value[getPicKey(item)] || ''
67}
68
69const runPicQueue = () => {
70 if (destroyed) return
71 while (picRunning < PIC_CONCURRENCY && picQueued.size) {
72 const [key, item] = picQueued.entries().next().value
73 picQueued.delete(key)
74 picRunning += 1
75 getPicUrl(item)
76 .then((url) => {
77 if (destroyed) return
78 if (url) picMap.value = { ...picMap.value, [getPicKey(item)]: url }
79 else picFailed.value = { ...picFailed.value, [key]: true }
80 })
81 .catch(() => {
82 if (destroyed) return
83 picFailed.value = { ...picFailed.value, [key]: true }
84 })
85 .finally(() => {
86 picRunning -= 1
87 runPicQueue()
88 })
89 }
90}
91
92/** 把某一行加入封面请求队列(已带封面 / 已在队列 / 已知失败的行直接跳过) */
93const enqueuePic = (index) => {
94 const item = props.list[index]
95 if (!item) return
96 if (item.img || (item.meta && item.meta.picUrl)) return
97 const key = item.id || getPicKey(item)
98 const picKey = getPicKey(item)
99 if (picKey in picMap.value || picFailed.value[key] || picQueued.has(key)) return
100 picQueued.set(key, item)
101 runPicQueue()
102}
103
104/** 某一行进入视口(或即将进入)时触发 */
105const onPicIntersect = (entries) => {
106 if (destroyed) return
107 for (const entry of entries) {
108 if (!entry.isIntersecting) continue
109 const index = Number(entry.target.dataset.picIdx)
110 if (Number.isInteger(index)) enqueuePic(index)
111 }
112}
113
114/**
115 * 把当前渲染出来的每一行交给观察器
116 *
117 * 用"谁快被看到就加载谁"替代原来的"只加载前 N 条":
118 * 1. 不再依赖索引边界,第 25 条之后的封面同样会加载;
119 * 2. 与"父级用哪个元素滚动"无关 —— 真正滚动的是外层视图容器
120 * (.view-scroll),在组件里绑 scroll 事件会依赖父子约定,很容易失效。
121 *
122 * 列表每次变化都重建观察器:换一批搜索结果时行元素会被复用,
123 * 交叉状态本身没有变化,只有重新 observe 才能按当前可见性立刻回调一次。
124 */
125const schedulePics = () => {
126 if (destroyed) return
127 const root = rootRef.value
128 if (!root) return
129 if (picObserver) picObserver.disconnect()
130 picObserver = new IntersectionObserver(onPicIntersect, { rootMargin: PIC_ROOT_MARGIN })
131 for (const row of root.querySelectorAll('.row-item')) picObserver.observe(row)
132}
133
134/** 同一轮更新里被多次触发时,只重新观察一次 */
135let picSchedulePending = false
136const schedulePicsSoon = () => {
137 if (picSchedulePending || destroyed) return
138 picSchedulePending = true
139 nextTick(() => {
140 picSchedulePending = false
141 schedulePics()
142 })
143}
144
145watch(
146 () => props.list,
147 () => schedulePicsSoon(),
148)
149watch(
150 () => props.list.length,
151 () => schedulePicsSoon(),
152)
153
154onMounted(() => {
155 schedulePicsSoon()
156})
157
158onBeforeUnmount(() => {
159 destroyed = true
160 picQueued.clear()
161 if (picObserver) {
162 picObserver.disconnect()
163 picObserver = null
164 }
165})
166
167/** 暴露给父组件(外层滚动容器触底时调用) */
168defineExpose({ loadMorePics: schedulePics })
169
170// ============ 交互 ============
171
172const hoveredId = ref('')
173
174const rowKey = (item, index) => item.id || `${item.source || 'x'}_${item.songmid || index}`
175
176const isActive = (item) => !!props.currentMusicId && item.id === props.currentMusicId
177const isSelected = (item) => props.selectedIds.includes(item.id)
178
179const qualityText = (item) => {
180 if (!item.types || !item.types.length) return ''
181 // types 已按音质升序排列,取最高档
182 const top = item.types[item.types.length - 1]
183 return top ? top.type : ''
184}
185
186const onRowClick = (item, index) => {
187 if (props.batchMode) {
188 const ids = isSelected(item) ? props.selectedIds.filter((id) => id !== item.id) : [...props.selectedIds, item.id]
189 emit('batch-toggle', ids)
190 return
191 }
192 emit('item-click', item, index)
193}
194
195const onRowDblClick = (item, index) => {
196 if (props.batchMode) return
197 emit('item-play', item, index)
198}
199
200const onPlayClick = (item, index, e) => {
201 e.stopPropagation()
202 emit('item-play', item, index)
203}
204
205const onMoreClick = (item, e) => {
206 e.stopPropagation()
207 const rect = e.currentTarget.getBoundingClientRect()
208 emit('item-more', item, { x: rect.right - 190, y: rect.bottom + 6 })
209}
210
211const onContextMenu = (item, e) => {
212 if (props.batchMode) return
213 emit('item-more', item, { x: e.clientX, y: e.clientY })
214}
215
216const durationText = (item) => {
217 if (!item.interval) return '--:--'
218 // interval 可能是 '--/--'(0 时长)或 'mm:ss'
219 return item.interval === '--/--' ? '--:--' : item.interval
220}
221
222const sourceLabel = (item) => SOURCE_NAMES[item.source] || item.source || ''
223
224const equalizerBars = [0, 1, 2, 3]
225
226const showEmpty = computed(() => !props.list.length && !props.loading)
227</script>
228
229<template>
230 <div ref="rootRef" class="music-list">
231 <!-- 表头 -->
232 <div v-if="list.length" class="list-head" :class="{ 'no-album': !showAlbum }">
233 <span v-if="showIndex" class="c-index">#</span>
234 <span class="c-title">标题{{ subtitle ? ` · ${subtitle}` : '' }}</span>
235 <span class="c-singer">歌手</span>
236 <span v-if="showAlbum" class="c-album">专辑</span>
237 <span v-if="showQuality" class="c-quality">音质</span>
238 <span class="c-time">时长</span>
239 <span v-if="showActions" class="c-actions" />
240 </div>
241
242 <!-- 列表 -->
243 <div class="list-body row-list">
244 <div
245 v-for="(item, index) in list"
246 :key="rowKey(item, index)"
247 class="row-item"
248 :data-pic-idx="index"
249 :class="{
250 'is-active': isActive(item),
251 'is-selected': isSelected(item),
252 'is-hover': hoveredId === rowKey(item, index),
253 'is-batch': batchMode,
254 'd-none': item._hidden,
255 }"
256 :style="{ '--delay': `${Math.min(index, 18) * 26}ms` }"
257 @mouseenter="hoveredId = rowKey(item, index)"
258 @mouseleave="hoveredId = ''"
259 @click="onRowClick(item, index)"
260 @dblclick="onRowDblClick(item, index)"
261 @contextmenu.prevent="onContextMenu(item, $event)"
262 >
263 <!-- 批量选择 -->
264 <span v-if="batchMode" class="c-check">
265 <span class="checkbox" :class="{ 'is-on': isSelected(item) }">
266 <Icon v-if="isSelected(item)" name="check" :size="12" :width="2.6" />
267 </span>
268 </span>
269
270 <!-- 序号 / 播放态均衡器 / 悬停播放按钮 -->
271 <span v-if="showIndex" class="c-index">
272 <span v-if="isActive(item)" class="eq" :class="{ 'is-paused': !playing }">
273 <i v-for="b in equalizerBars" :key="b" :style="{ animationDelay: `${b * 140}ms` }" />
274 </span>
275 <template v-else>
276 <span class="index-num">{{ index + 1 }}</span>
277 <button class="index-play" title="播放" @click="onPlayClick(item, index, $event)">
278 <Icon name="play" :size="13" />
279 </button>
280 </template>
281 </span>
282
283 <!-- 封面 + 标题 -->
284 <span class="c-title">
285 <CoverImage
286 :src="getItemPic(item)"
287 :size="42"
288 radius="var(--r-sm)"
289 :active="isActive(item)"
290 class="row-cover"
291 />
292 <span class="title-text">
293 <span class="name ellipsis" :class="{ 'is-active': isActive(item) }">
294 {{ item.name || '未知歌曲' }}
295 <em v-if="isActive(item)" class="playing-tag">播放中</em>
296 </span>
297 <span class="sub ellipsis">
298 <span class="src-tag">{{ sourceLabel(item) }}</span>
299 <span v-if="item.albumName && !showAlbum" class="ellipsis">{{ item.albumName }}</span>
300 <span v-else-if="item.singer && showAlbum" class="ellipsis">{{ item.singer }}</span>
301 </span>
302 </span>
303 </span>
304
305 <!-- 歌手 -->
306 <span class="c-singer ellipsis">{{ item.singer || '未知歌手' }}</span>
307
308 <!-- 专辑 -->
309 <span v-if="showAlbum" class="c-album ellipsis">{{ item.albumName || '—' }}</span>
310
311 <!-- 音质 -->
312 <span v-if="showQuality" class="c-quality">
313 <span v-if="qualityText(item)" class="quality-badge" :class="`q-${qualityText(item)}`">
314 {{ qualityText(item).replace('flac24bit', 'Hi-Res').replace('flac', '无损') }}
315 </span>
316 <span v-else class="muted">—</span>
317 </span>
318
319 <!-- 时长 -->
320 <span class="c-time">{{ durationText(item) }}</span>
321
322 <!-- 操作 -->
323 <span v-if="showActions" class="c-actions">
324 <button class="act" title="播放" @click="onPlayClick(item, index, $event)">
325 <Icon name="play" :size="14" />
326 </button>
327 <button class="act" title="更多" @click="onMoreClick(item, $event)">
328 <Icon name="more" :size="15" />
329 </button>
330 </span>
331 </div>
332
333 <!-- 空态 / 加载态 -->
334 <div v-if="showEmpty" class="list-empty">
335 <span class="empty-icon">
336 <Icon name="music" :size="40" :width="1.5" />
337 </span>
338 <p class="empty-text">{{ emptyText }}</p>
339 </div>
340
341 <div v-else-if="loading" class="list-loading">
342 <span class="dot" /><span class="dot" /><span class="dot" />
343 <span class="loading-text">加载中…</span>
344 </div>
345
346 <div v-else-if="hasMore" class="list-more">
347 <button class="more-btn press" :disabled="loading" @click="emit('load-more')">
348 <Icon v-if="loading" name="refresh" :size="14" spin />
349 <span>加载更多</span>
350 </button>
351 </div>
352 </div>
353 </div>
354</template>
355
356<style scoped>
357.music-list {
358 display: flex;
359 flex-direction: column;
360 min-height: 0;
361}
362
363/* ============ 表头 ============ */
364
365.list-head {
366 display: grid;
367 grid-template-columns: 46px minmax(220px, 2.6fr) minmax(110px, 1.2fr) minmax(120px, 1.5fr) 78px 68px 74px;
368 align-items: center;
369 gap: 10px;
370 padding: 0 12px 8px;
371 border-bottom: 1px solid var(--line-1);
372 font-size: 11.5px;
373 letter-spacing: 0.4px;
374 color: var(--text-4);
375 position: sticky;
376 top: 0;
377 z-index: 2;
378 background: var(--surface-1);
379}
380
381.list-head.no-album {
382 grid-template-columns: 46px minmax(220px, 3fr) minmax(120px, 1.3fr) 78px 68px 74px;
383}
384
385/* ============ 行 ============ */
386
387.row-item {
388 display: grid;
389 grid-template-columns: 46px minmax(220px, 2.6fr) minmax(110px, 1.2fr) minmax(120px, 1.5fr) 78px 68px 74px;
390 align-items: center;
391 gap: 10px;
392 padding: 7px 12px;
393 border-radius: var(--r-md);
394 font-size: 13.5px;
395 color: var(--text-2);
396 cursor: default;
397 position: relative;
398 animation: kFadeUp var(--t-slow) var(--ease-out) both;
399 animation-delay: var(--delay, 0ms);
400 transition: background-color var(--t-fast) var(--ease-out);
401}
402
403.list-head.no-album + .list-body .row-item {
404 grid-template-columns: 46px minmax(220px, 3fr) minmax(120px, 1.3fr) 78px 68px 74px;
405}
406
407.row-item.is-batch {
408 grid-template-columns: 34px 46px minmax(200px, 2.6fr) minmax(100px, 1.2fr) minmax(120px, 1.5fr) 78px 68px 74px;
409}
410
411.row-item:hover,
412.row-item.is-hover {
413 background: var(--surface-3);
414}
415
416.row-item.is-selected {
417 background: var(--theme-color-soft);
418}
419
420.row-item.is-active {
421 background: rgba(var(--theme-color-rgb), 0.09);
422}
423
424.row-item.is-active::before {
425 content: '';
426 position: absolute;
427 left: 0;
428 top: 50%;
429 width: 3px;
430 height: 22px;
431 margin-top: -11px;
432 border-radius: 0 3px 3px 0;
433 background: var(--theme-color);
434}
435
436/* ============ 序号列 ============ */
437
438.c-index {
439 display: grid;
440 place-items: center;
441 font-variant-numeric: tabular-nums;
442 font-size: 12.5px;
443 color: var(--text-4);
444 position: relative;
445 height: 30px;
446}
447
448.index-num {
449 transition: opacity var(--t-fast) var(--ease-out);
450}
451
452.index-play {
453 position: absolute;
454 inset: 0;
455 display: grid;
456 place-items: center;
457 color: var(--text-1);
458 opacity: 0;
459 transform: scale(0.7);
460 transition: opacity var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-overshoot);
461}
462
463.row-item:hover .index-num {
464 opacity: 0;
465}
466
467.row-item:hover .index-play {
468 opacity: 1;
469 transform: scale(1);
470}
471
472.index-play:hover {
473 color: var(--theme-color);
474 transform: scale(1.18);
475}
476
477/* 播放态均衡器 */
478.eq {
479 display: flex;
480 align-items: flex-end;
481 justify-content: center;
482 gap: 2px;
483 height: 15px;
484}
485
486.eq i {
487 width: 2.5px;
488 height: 100%;
489 border-radius: 2px;
490 background: var(--theme-color);
491 transform-origin: bottom center;
492 animation: kEq 900ms var(--ease-in-out) infinite;
493}
494
495.eq.is-paused i {
496 animation-play-state: paused;
497 transform: scaleY(0.35);
498}
499
500/* ============ 标题列 ============ */
501
502.c-title {
503 display: flex;
504 align-items: center;
505 gap: 11px;
506 min-width: 0;
507}
508
509.row-cover {
510 flex: none;
511}
512
513.title-text {
514 display: flex;
515 flex-direction: column;
516 min-width: 0;
517 gap: 1px;
518}
519
520.name {
521 font-size: 13.5px;
522 color: var(--text-1);
523 font-weight: 500;
524 display: flex;
525 align-items: center;
526 gap: 7px;
527}
528
529.name.is-active {
530 color: var(--theme-color-dark);
531}
532
533.playing-tag {
534 font-style: normal;
535 font-size: 10px;
536 font-weight: 500;
537 padding: 1px 5px;
538 border-radius: var(--r-xs);
539 color: var(--theme-color-dark);
540 background: var(--theme-color-soft);
541 flex: none;
542}
543
544.sub {
545 display: flex;
546 align-items: center;
547 gap: 6px;
548 font-size: 11.5px;
549 color: var(--text-4);
550 min-width: 0;
551}
552
553.src-tag {
554 flex: none;
555 padding: 0 5px;
556 border-radius: var(--r-xs);
557 background: var(--surface-3);
558 color: var(--text-4);
559 font-size: 10.5px;
560 line-height: 15px;
561}
562
563/* ============ 其它列 ============ */
564
565.c-singer,
566.c-album {
567 color: var(--text-3);
568 font-size: 12.5px;
569}
570
571.c-quality {
572 display: flex;
573}
574
575.quality-badge {
576 padding: 1px 6px;
577 border-radius: var(--r-xs);
578 font-size: 10.5px;
579 line-height: 16px;
580 border: 1px solid var(--line-2);
581 color: var(--text-3);
582 background: var(--surface-2);
583 white-space: nowrap;
584}
585
586.quality-badge.q-flac24bit {
587 color: #b45309;
588 border-color: rgba(245, 158, 11, 0.4);
589 background: rgba(245, 158, 11, 0.1);
590}
591
592.quality-badge.q-flac,
593.quality-badge.q-ape,
594.quality-badge.q-wav {
595 color: var(--theme-color-dark);
596 border-color: rgba(var(--theme-color-rgb), 0.4);
597 background: var(--theme-color-soft);
598}
599
600.c-time {
601 font-variant-numeric: tabular-nums;
602 font-size: 12.5px;
603 color: var(--text-4);
604 text-align: right;
605}
606
607.c-actions {
608 display: flex;
609 align-items: center;
610 justify-content: flex-end;
611 gap: 2px;
612 opacity: 0;
613 transition: opacity var(--t-fast) var(--ease-out);
614}
615
616.row-item:hover .c-actions,
617.row-item.is-hover .c-actions {
618 opacity: 1;
619}
620
621.act {
622 display: grid;
623 place-items: center;
624 width: 27px;
625 height: 27px;
626 border-radius: var(--r-sm);
627 color: var(--text-3);
628 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out),
629 transform var(--t-fast) var(--ease-out);
630}
631
632.act:hover {
633 background: var(--surface-1);
634 color: var(--theme-color);
635 box-shadow: var(--sh-xs);
636}
637
638.act:active {
639 transform: scale(0.92);
640}
641
642/* 批量选择框 */
643.c-check {
644 display: grid;
645 place-items: center;
646}
647
648.checkbox {
649 width: 17px;
650 height: 17px;
651 border-radius: 5px;
652 border: 1.5px solid var(--line-3);
653 display: grid;
654 place-items: center;
655 color: #fff;
656 transition: background-color var(--t-fast) var(--ease-out), border-color var(--t-fast) var(--ease-out);
657}
658
659.checkbox.is-on {
660 background: var(--theme-color);
661 border-color: var(--theme-color);
662}
663
664.muted {
665 color: var(--text-5);
666}
667
668/* ============ 空态 / 加载 ============ */
669
670.list-empty {
671 display: flex;
672 flex-direction: column;
673 align-items: center;
674 justify-content: center;
675 gap: 14px;
676 padding: 66px 20px;
677 animation: kFadeIn var(--t-slow) ease both;
678}
679
680.empty-icon {
681 display: grid;
682 place-items: center;
683 width: 82px;
684 height: 82px;
685 border-radius: var(--r-full);
686 background: var(--surface-3);
687 color: var(--text-5);
688}
689
690.empty-text {
691 font-size: 13.5px;
692 color: var(--text-4);
693}
694
695.list-loading {
696 display: flex;
697 align-items: center;
698 justify-content: center;
699 gap: 6px;
700 padding: 34px;
701 color: var(--text-4);
702 font-size: 13px;
703}
704
705.dot {
706 width: 6px;
707 height: 6px;
708 border-radius: var(--r-full);
709 background: var(--theme-color);
710 animation: kEq 1s var(--ease-in-out) infinite;
711}
712
713.dot:nth-child(2) {
714 animation-delay: 160ms;
715}
716
717.dot:nth-child(3) {
718 animation-delay: 320ms;
719}
720
721.loading-text {
722 margin-left: 6px;
723}
724
725.list-more {
726 display: flex;
727 justify-content: center;
728 padding: 18px 0 26px;
729}
730
731.more-btn {
732 display: inline-flex;
733 align-items: center;
734 gap: 6px;
735 height: 34px;
736 padding: 0 20px;
737 border-radius: var(--r-full);
738 border: 1px solid var(--line-2);
739 color: var(--text-2);
740 font-size: 13px;
741 background: var(--surface-2);
742 transition: border-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out),
743 background-color var(--t-fast) var(--ease-out);
744}
745
746.more-btn:hover:not(:disabled) {
747 border-color: var(--theme-color);
748 color: var(--theme-color-dark);
749 background: var(--theme-color-soft);
750}
751
752.more-btn:disabled {
753 opacity: 0.6;
754 cursor: wait;
755}
756</style>