feat: save songs to my playlists from the player, queue and history

This commit is contained in:
DiheChen committed 2026-11-09 21:31:18 +08:00
1 parent ef4558bd65
commit ffd2b390f0
14 files changed
+419 -14

No files matched your search

+2 -1
View File
@@ -5,7 +5,7 @@ import { chatStore } from '@/features/chat'
import { historyStore } from '@/features/history'
import { lyricsStore } from '@/features/lyrics'
import { playerStore, reminderStore, volumeStore } from '@/features/playback'
import { playlistsStore } from '@/features/playlists'
import { playlistsStore, setAccountSettingsOpener } from '@/features/playlists'
import { roomEntryStore } from '@/features/rooms'
import { searchStore } from '@/features/search'
import { appearanceStore, performanceStore, profileStore } from '@/features/settings'
@@ -28,6 +28,7 @@ volumeStore.start()
brandStore.start()
profileStore.start()
playlistsStore.start()
setAccountSettingsOpener(() => navigationStore.open('settings'))
playerStore.start()
reminderStore.start()
historyStore.start()
@@ -2,6 +2,7 @@ import type { PlayRecord } from './records'
import { CirclePlus, Download, History, Search, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { roomStore, sameTrack } from '@/core/room'
import { SaveToPlaylistButton } from '@/features/playlists'
import { enqueue, sourceName } from '@/features/queue'
import { bookTitle } from '@/shared/lib/book-title'
import { formatClock } from '@/shared/lib/time'
@@ -46,6 +47,7 @@ function HistoryRow({ record, compact, connected, playing }: { record: PlayRecor
await enqueue(record.track)
}}
/>
<SaveToPlaylistButton track={record.track} title={title} className="song-action song-action-secondary" />
<IconButton
icon={Trash2}
className="song-action song-action-secondary"
+1 -1
View File
@@ -43,7 +43,7 @@
.playback-progress { position: absolute; left: 0; right: 0; bottom: 0; margin-top: 0; }
.player-center { flex-shrink: 0; }
.player-track { position: static; }
.player-time { top: 30px; right: 54px; }
.player-time { top: 30px; right: 90px; }
.progress-track { height: 2px; border-radius: 0 0 8px 8px; }
.player-tools { display: none; }
}
+16 -2
View File
@@ -1,6 +1,8 @@
import { ListMusic, MessageCircle, SkipForward, Volume2, VolumeX } from 'lucide-react'
import { useEffect, useRef } from 'react'
import type { Track } from '@/core/realtime'
import { ListMusic, ListPlus, MessageCircle, SkipForward, Volume2, VolumeX } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { roomStore } from '@/core/room'
import { SaveToPlaylistDialog, saveToPlaylistLabel } from '@/features/playlists'
import { sourceName } from '@/features/queue'
import { formatDuration } from '@/shared/lib/time'
import { useStore } from '@/shared/lib/use-store'
@@ -69,6 +71,8 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
const artist = meta?.artist || '未知艺术家'
const source = nowPlaying ? sourceName(nowPlaying.entry.track) : undefined
const note = nowPlaying?.entry.by ? `${nowPlaying.entry.by.name} 点的歌` : undefined
// 记下打开时的歌:对话框开着时换了歌,或者这首播完了,勾选仍然作用于打开时的那首。
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
return (
<section className="player-bar" aria-label="播放器">
<button type="button" className="player-artwork" onClick={onLyrics} aria-label="查看歌词">
@@ -87,6 +91,15 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
</div>
<div className="player-right">
<div className="player-center">
<IconButton
icon={ListPlus}
label={meta ? saveToPlaylistLabel(meta.title) : '保存到歌单'}
disabled={!nowPlaying}
onClick={() => {
if (nowPlaying)
setSaving({ track: nowPlaying.entry.track, title: nowPlaying.entry.meta.title })
}}
/>
<AsyncIconButton icon={SkipForward} label="投票切歌" disabled={!nowPlaying || !connected} onAction={onSkip} />
</div>
<div className="player-tools">
@@ -112,6 +125,7 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
<IconButton icon={ListMusic} label="播放列表" className="player-panel-toggle" disabled={!canOpenPanels} aria-pressed={queueOpen} onClick={onQueue} />
</div>
</div>
{saving && <SaveToPlaylistDialog track={saving.track} title={saving.title} onClose={() => setSaving(null)} />}
</section>
)
}
@@ -0,0 +1,10 @@
// 游客在保存对话框里点“去账号设置”时打开账号设置。设置页在哪里、怎样打开由应用壳决定,启动时设置。
let open = (): void => {}
export function openAccountSettings(): void {
open()
}
export function setAccountSettingsOpener(opener: () => void): void {
open = opener
}
@@ -0,0 +1,41 @@
import type { MyPlaylist } from '@/core/realtime'
import { useState } from 'react'
import { playlistsStore } from './playlists-store'
export function CreatePlaylistForm({ onCreated, onCancel }: { onCreated: (playlist: MyPlaylist) => void, onCancel: () => void }) {
const [name, setName] = useState('')
const [saving, setSaving] = useState(false)
return (
<form
className="form-stack"
onKeyDown={(event) => {
if (event.key !== 'Escape' || event.nativeEvent.isComposing)
return
// 拦下对话框的 Esc:在“保存到歌单”里,Esc 只回到列表。
event.preventDefault()
onCancel()
}}
onSubmit={async (event) => {
event.preventDefault()
if (saving)
return
setSaving(true)
const playlist = await playlistsStore.create(name)
if (playlist)
onCreated(playlist)
else
setSaving(false)
}}
>
<label>
歌单名
{/* 刚打开的 Modal 按 data-autofocus 聚焦;在已经打开的对话框里切换过来时,由 autoFocus 聚焦。 */}
<input data-autofocus autoFocus required maxLength={50} value={name} onChange={event => setName(event.target.value)} />
</label>
<div className="dialog-actions">
<button type="button" className="button-secondary" onClick={onCancel}>取消</button>
<button type="submit" className="button-primary" disabled={saving || name.trim() === ''}>创建</button>
</div>
</form>
)
}
+3
View File
@@ -1 +1,4 @@
export { setAccountSettingsOpener } from './account-settings'
export { playlistsStore } from './playlists-store'
export { saveToPlaylistLabel } from './save-label'
export { SaveToPlaylistButton, SaveToPlaylistDialog } from './save-to-playlist'
@@ -0,0 +1,26 @@
import { Music2, Star } from 'lucide-react'
import { useState } from 'react'
export function FavoritesMark({ size = 14 }: { size?: number }) {
return <Star className="playlist-favorites-mark" size={size} fill="currentColor" strokeWidth={1.5} aria-hidden="true" />
}
function FilledStar() {
return <Star fill="currentColor" strokeWidth={1.5} aria-hidden="true" />
}
export function PlaylistCover({ cover, favorites, size }: { cover: string | null, favorites: boolean, size: 'sm' | 'md' | 'lg' }) {
const [failedSrc, setFailedSrc] = useState<string>()
if (!cover || cover === failedSrc) {
return (
<span className={`playlist-cover playlist-cover-${size}`} data-favorites={favorites || undefined}>
{favorites ? <FilledStar /> : <Music2 strokeWidth={1.5} aria-hidden="true" />}
</span>
)
}
return (
<span className={`playlist-cover playlist-cover-${size}`}>
<img src={cover} alt="" loading="lazy" decoding="async" onError={() => setFailedSrc(cover)} />
</span>
)
}
@@ -0,0 +1,69 @@
import type { PlaylistSort } from './playlist-sort'
import type { MyPlaylist } from '@/core/realtime'
import { describe, expect, it } from 'vitest'
import { sortPlaylists } from './playlist-sort'
function playlist(id: string, name: string, trackCount: number, createdAtMs: number, updatedAtMs: number, favorites = false): MyPlaylist {
return { id, name, favorites, trackCount, createdAtMs, updatedAtMs, coverUrl: null }
}
const favorites = playlist('p0', '我喜欢', 3, 50, 50, true)
const commute = playlist('p1', '通勤', 36, 100, 400)
const chill = playlist('p2', 'Chill', 5, 200, 300)
const running = playlist('p3', '晨跑', 12, 300, 200)
const night = playlist('p4', '安静的夜晚', 0, 400, 500)
const all = [commute, favorites, chill, running, night]
function names(sort: PlaylistSort, list: MyPlaylist[] = all): string[] {
return sortPlaylists(list, sort).map(p => p.name)
}
describe('sortPlaylists', () => {
it('按名称排序,中文按拼音', () => {
expect(names({ key: 'name', order: 'asc' })).toEqual(['我喜欢', '安静的夜晚', '晨跑', '通勤', 'Chill'])
expect(names({ key: 'name', order: 'desc' })).toEqual(['我喜欢', 'Chill', '通勤', '晨跑', '安静的夜晚'])
})
it('名称里的数字按大小排', () => {
const list = [playlist('a', '歌单 10', 0, 1, 1), playlist('b', '歌单 2', 0, 2, 2)]
expect(names({ key: 'name', order: 'asc' }, list)).toEqual(['歌单 2', '歌单 10'])
})
it('按创建时间排序', () => {
expect(names({ key: 'createdAt', order: 'desc' })).toEqual(['我喜欢', '安静的夜晚', '晨跑', 'Chill', '通勤'])
expect(names({ key: 'createdAt', order: 'asc' })).toEqual(['我喜欢', '通勤', 'Chill', '晨跑', '安静的夜晚'])
})
it('按更新时间排序', () => {
expect(names({ key: 'updatedAt', order: 'desc' })).toEqual(['我喜欢', '安静的夜晚', '通勤', 'Chill', '晨跑'])
expect(names({ key: 'updatedAt', order: 'asc' })).toEqual(['我喜欢', '晨跑', 'Chill', '通勤', '安静的夜晚'])
})
it('按歌曲数排序', () => {
expect(names({ key: 'trackCount', order: 'desc' })).toEqual(['我喜欢', '通勤', '晨跑', 'Chill', '安静的夜晚'])
expect(names({ key: 'trackCount', order: 'asc' })).toEqual(['我喜欢', '安静的夜晚', 'Chill', '晨跑', '通勤'])
})
it('“我喜欢”的值排在最后时也置顶', () => {
const empty = { ...favorites, name: '我喜欢', trackCount: 0, updatedAtMs: 0 }
expect(sortPlaylists([commute, empty], { key: 'trackCount', order: 'desc' })[0]).toBe(empty)
expect(sortPlaylists([commute, empty], { key: 'updatedAt', order: 'desc' })[0]).toBe(empty)
})
it('值相同时依次按创建时间、id 排,与输入顺序无关', () => {
const a = playlist('p7', '收藏', 4, 100, 900)
const b = playlist('p5', '收藏', 4, 200, 900)
const c = playlist('p6', '收藏', 4, 200, 900)
for (const key of ['name', 'updatedAt', 'trackCount'] as const) {
expect(sortPlaylists([c, b, a], { key, order: 'asc' })).toEqual([a, b, c])
expect(sortPlaylists([a, c, b], { key, order: 'asc' })).toEqual([a, b, c])
expect(sortPlaylists([b, a, c], { key, order: 'desc' })).toEqual([c, b, a])
}
})
it('不改动传入的数组', () => {
const list = [commute, favorites, chill]
sortPlaylists(list, { key: 'name', order: 'asc' })
expect(list).toEqual([commute, favorites, chill])
})
})
@@ -0,0 +1,35 @@
import type { MyPlaylist } from '@/core/realtime'
export type SortKey = 'name' | 'createdAt' | 'updatedAt' | 'trackCount'
export type SortOrder = 'asc' | 'desc'
export interface PlaylistSort {
key: SortKey
order: SortOrder
}
const collator = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' })
function compareBy(key: SortKey, a: MyPlaylist, b: MyPlaylist): number {
switch (key) {
case 'name':
return collator.compare(a.name, b.name)
case 'createdAt':
return a.createdAtMs - b.createdAtMs
case 'updatedAt':
return a.updatedAtMs - b.updatedAtMs
case 'trackCount':
return a.trackCount - b.trackCount
}
}
function compareIds(a: MyPlaylist, b: MyPlaylist): number {
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
}
// 方向作用于整个排序键,反向得到的正好是倒过来的列表;“我喜欢”不参与,始终在最前。
export function sortPlaylists(list: readonly MyPlaylist[], { key, order }: PlaylistSort): MyPlaylist[] {
const sign = order === 'asc' ? 1 : -1
return [...list].sort((a, b) => Number(b.favorites) - Number(a.favorites)
|| sign * (compareBy(key, a, b) || a.createdAtMs - b.createdAtMs || compareIds(a, b)))
}
+24
View File
@@ -0,0 +1,24 @@
.save-playlist-subject { margin-bottom: 12px; }
.save-playlist-list { display: flex; flex-direction: column; gap: 2px; max-height: min(52dvh, 432px); margin: 0 -12px; padding: 0; overflow-y: auto; overscroll-behavior: contain; list-style: none; }
.save-playlist-row { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 6px 12px; border-radius: 8px; cursor: pointer; }
.save-playlist-row:hover { background: var(--color-surface-hover); }
.save-playlist-row:has(input:focus-visible) { outline: 2px solid var(--color-brand); outline-offset: -2px; }
.save-playlist-row > .playlist-cover-sm { width: 36px; }
.save-playlist-name { display: flex; flex: 1; align-items: center; gap: 6px; min-width: 0; }
.save-playlist-row > .muted { flex-shrink: 0; font-variant-numeric: tabular-nums; }
/* 勾选框的位置空着,加号图块和上面各行的封面对齐。 */
.save-playlist-new { width: 100%; padding-left: 40px; border: 0; background: none; color: inherit; text-align: left; }
.save-playlist-new:focus-visible { outline-offset: -2px; }
.save-playlist-new-tile { display: grid; place-items: center; width: 36px; aspect-ratio: 1; border-radius: 8px; color: var(--color-foreground-subtle); }
.save-playlist-new-tile > svg { width: 20px; height: 20px; }
/* 深色主题下对话框的底色和 --color-card-hover 几乎相同,占位图块会看不见。 */
.save-playlist-new-tile, .save-playlist-row > .playlist-cover:not([data-favorites]) { background: var(--color-hover); }
.playlist-cover { position: relative; display: grid; grid-template: minmax(0, 1fr) / minmax(0, 1fr); place-items: center; flex-shrink: 0; aspect-ratio: 1; border-radius: 8px; background: var(--color-card-hover); color: var(--color-foreground-subtlest); }
.playlist-cover[data-favorites] { background: var(--color-accent); color: var(--color-brand); }
.playlist-cover > svg { width: 38%; height: 38%; }
.playlist-cover > img { display: block; width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
.playlist-cover-sm { width: 40px; }
.playlist-cover-md { width: 100%; }
.playlist-cover-lg { width: 232px; border-radius: 10px; }
.playlist-favorites-mark { flex-shrink: 0; color: var(--color-brand); }
+5
View File
@@ -0,0 +1,5 @@
import { bookTitle } from '@/shared/lib/book-title'
export function saveToPlaylistLabel(title: string): string {
return `把${bookTitle(title)}保存到歌单`
}
@@ -0,0 +1,165 @@
import type { PlaylistSort } from './playlist-sort'
import type { MyPlaylist, Track } from '@/core/realtime'
import { ListPlus, Plus } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { profileStore } from '@/features/settings'
import { useStore } from '@/shared/lib/use-store'
import { IconButton, Modal, Skeleton } from '@/shared/ui'
import { openAccountSettings } from './account-settings'
import { CreatePlaylistForm } from './create-playlist-form'
import { FavoritesMark, PlaylistCover } from './playlist-cover'
import { sortPlaylists } from './playlist-sort'
import { playlistsStore, trackKey } from './playlists-store'
import { saveToPlaylistLabel } from './save-label'
import './playlists.css'
const byUpdate: PlaylistSort = { key: 'updatedAt', order: 'desc' }
const none: ReadonlySet<string> = new Set()
// 保持打开时的先后:保存会更新歌单的更新时间,按实时的时间排,刚勾选的歌单会跳走。打开之后才出现的歌单排在最后。
function arrange(playlists: readonly MyPlaylist[], order: readonly string[]): MyPlaylist[] {
const rank = new Map(order.map((id, index) => [id, index]))
const at = (playlist: MyPlaylist) => rank.get(playlist.id) ?? order.length
return sortPlaylists(playlists, byUpdate).sort((a, b) => at(a) - at(b))
}
export function SaveToPlaylistDialog({ track, title, onClose }: { track: Track, title: string, onClose: () => void }) {
const account = useStore(profileStore, s => s.account)
const playlists = useStore(playlistsStore, s => s.playlists)
const failed = useStore(playlistsStore, s => s.failed)
const included = useStore(playlistsStore, s => s.memberships.get(trackKey(track))) ?? none
const [order, setOrder] = useState<string[] | null>(null)
const [readFailed, setReadFailed] = useState(false)
const [attempt, setAttempt] = useState(0)
const [creating, setCreating] = useState(false)
const listRef = useRef<HTMLUListElement>(null)
// 从新建页回到列表时要获得焦点的元素:取消时是“新建歌单”,建好之后是新歌单的勾选框,聚焦的同时把它滚进视野。
const returnFocusRef = useRef<string | null>(null)
// 打开时刷新歌单:歌曲数和其他设备上新建的歌单。
useEffect(() => {
if (!account)
return
let active = true
void Promise.all([playlistsStore.load(), playlistsStore.containing(track)]).then(([, read]) => {
if (!active)
return
setReadFailed(!read)
setOrder(sortPlaylists(playlistsStore.state.playlists ?? [], byUpdate).map(p => p.id))
})
return () => {
active = false
}
}, [account, track, attempt])
useEffect(() => {
if (creating || returnFocusRef.current === null)
return
listRef.current?.querySelector<HTMLElement>(returnFocusRef.current)?.focus()
returnFocusRef.current = null
}, [creating])
const rows = playlists && order ? arrange(playlists, order) : null
const choose = (playlist: MyPlaylist, checked: boolean) => {
void playlistsStore.setMembership(playlist, track, checked)
}
const backToList = (focus: string) => {
returnFocusRef.current = focus
setCreating(false)
}
let body
if (!account) {
body = (
<>
<p className="text-ui-base muted">注册或登录之后就能把歌保存到歌单。</p>
<div className="dialog-actions">
<button
type="button"
className="button-primary"
onClick={() => {
onClose()
openAccountSettings()
}}
>
去账号设置
</button>
</div>
</>
)
}
else if (readFailed || (playlists === null && failed)) {
body = (
<button
type="button"
className="button-secondary"
onClick={() => {
setReadFailed(false)
setOrder(null)
setAttempt(n => n + 1)
}}
>
重试
</button>
)
}
else if (rows === null) {
body = <Skeleton rows={3} />
}
else if (creating) {
body = (
<CreatePlaylistForm
onCreated={(playlist) => {
choose(playlist, true)
backToList(`input[value="${CSS.escape(playlist.id)}"]`)
}}
onCancel={() => backToList('.save-playlist-new')}
/>
)
}
else {
body = (
<>
<p className="save-playlist-subject text-ui-caption muted truncate">{title}</p>
<ul ref={listRef} className="save-playlist-list">
{rows.map(playlist => (
<li key={playlist.id}>
<label className="save-playlist-row">
<input
type="checkbox"
value={playlist.id}
checked={included.has(playlist.id)}
onChange={event => choose(playlist, event.target.checked)}
/>
<PlaylistCover cover={playlist.coverUrl} favorites={playlist.favorites} size="sm" />
<span className="save-playlist-name">
<span className="text-ui-base truncate">{playlist.name}</span>
{playlist.favorites && <FavoritesMark />}
</span>
<span className="text-ui-sm muted">{`${playlist.trackCount} 首`}</span>
</label>
</li>
))}
<li>
<button type="button" className="save-playlist-row save-playlist-new" onClick={() => setCreating(true)}>
<span className="save-playlist-new-tile"><Plus strokeWidth={1.7} aria-hidden="true" /></span>
<span className="text-ui-base">新建歌单</span>
</button>
</li>
</ul>
</>
)
}
return <Modal title={creating ? '新建歌单' : '保存到歌单'} onClose={onClose}>{body}</Modal>
}
export function SaveToPlaylistButton({ track, title, className = '' }: { track: Track, title: string, className?: string }) {
// 记下打开时的歌:对话框开着时换了歌,勾选仍然作用于打开时的那首。
const [target, setTarget] = useState<{ track: Track, title: string } | null>(null)
return (
<>
<IconButton icon={ListPlus} className={className} label={saveToPlaylistLabel(title)} onClick={() => setTarget({ track, title })} />
{target && <SaveToPlaylistDialog track={target.track} title={target.title} onClose={() => setTarget(null)} />}
</>
)
}
+20 -10
View File
@@ -1,10 +1,12 @@
import type { Entry, PlayMode } from '@/core/realtime'
import type { Entry, PlayMode, Track } from '@/core/realtime'
import type { SongItem } from '@/shared/ui'
import { Heart, ListMusic, Trash2 } from 'lucide-react'
import { Heart, ListMusic, ListPlus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { roomStore } from '@/core/room'
import { SaveToPlaylistDialog, saveToPlaylistLabel } from '@/features/playlists'
import { bookTitle } from '@/shared/lib/book-title'
import { useStore } from '@/shared/lib/use-store'
import { EmptyState, PlayingBars, Select, SongArtwork, SongList } from '@/shared/ui'
import { EmptyState, IconButton, PlayingBars, Select, SongArtwork, SongList } from '@/shared/ui'
import { likeEntry, removeEntry, setPlayMode } from './actions'
import { sourceName } from './source'
import './queue.css'
@@ -26,7 +28,7 @@ function songItem(entry: Entry): SongItem {
}
}
function QueueCard({ label, entry, className = '', playing = false }: { label: string, entry: Entry, className?: string, playing?: boolean }) {
function QueueCard({ label, entry, className = '', playing = false, onSave }: { label: string, entry: Entry, className?: string, playing?: boolean, onSave: () => void }) {
const song = songItem(entry)
return (
<div className={`queue-current ${className}`}>
@@ -37,6 +39,7 @@ function QueueCard({ label, entry, className = '', playing = false }: { label: s
<span className="text-ui-sm muted truncate">{[song.artist, song.sourceName, song.note].filter(Boolean).join(' · ')}</span>
</div>
{playing && <PlayingBars />}
<IconButton icon={ListPlus} label={saveToPlaylistLabel(song.title)} onClick={onSave} />
</div>
)
}
@@ -47,6 +50,8 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
const items = useStore(roomStore, s => s.room?.items ?? noEntries)
const playMode = useStore(roomStore, s => s.room?.playMode ?? 'sequential')
const connected = useStore(roomStore, s => s.status === 'open')
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
const save = (entry: Entry) => setSaving({ track: entry.track, title: entry.meta.title })
return (
<section className={compact ? 'queue-panel' : 'page-content queue-page'} aria-label="播放列表">
<div className="section-heading">
@@ -67,16 +72,20 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
]}
/>
</div>
{nowPlaying && <QueueCard label="正在播放" entry={nowPlaying.entry} playing />}
{pending && <QueueCard label="下一首" entry={pending} className="queue-pending" />}
{nowPlaying && <QueueCard label="正在播放" entry={nowPlaying.entry} playing onSave={() => save(nowPlaying.entry)} />}
{pending && <QueueCard label="下一首" entry={pending} className="queue-pending" onSave={() => save(pending)} />}
{items.length
? (
<SongList
songs={items.map(songItem)}
actions={song => [
{ icon: Heart, label: `点赞${bookTitle(song.title)}`, disabled: !connected, secondary: true, run: () => likeEntry(song.key, song.title) },
{ icon: Trash2, label: `移除${bookTitle(song.title)}`, disabled: !connected, secondary: true, run: () => removeEntry(song.key, song.title) },
]}
actions={(song, index) => {
const { track } = items[index]
return [
{ icon: ListPlus, label: saveToPlaylistLabel(song.title), secondary: true, run: async () => setSaving({ track, title: song.title }) },
{ icon: Heart, label: `点赞${bookTitle(song.title)}`, disabled: !connected, secondary: true, run: () => likeEntry(song.key, song.title) },
{ icon: Trash2, label: `移除${bookTitle(song.title)}`, disabled: !connected, secondary: true, run: () => removeEntry(song.key, song.title) },
]
}}
/>
)
: !pending && (
@@ -84,6 +93,7 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
每个人都可以点歌,一起决定下一首。
</EmptyState>
)}
{saving && <SaveToPlaylistDialog track={saving.track} title={saving.title} onClose={() => setSaving(null)} />}
</section>
)
}