feat: save songs to my playlists from the player, queue and history
This commit is contained in:
1 parent
5198fa5126
commit
0f01b1dfe9
14 files changed
+422
-14
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -0,0 +1,5 @@
|
||||
import { bookTitle } from '@/shared/lib/book-title'
|
||||
|
||||
export function saveToPlaylistLabel(title: string): string {
|
||||
return `把${bookTitle(title)}保存到歌单`
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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 error = useStore(playlistsStore, s => s.error)
|
||||
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 && error !== null)) {
|
||||
body = (
|
||||
<>
|
||||
<p className="text-ui-base muted">{error === null ? '读取失败' : `读取失败:${error}`}</p>
|
||||
<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)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user