diff --git a/web/src/app/main.tsx b/web/src/app/main.tsx index 1fb0b4c..996fec4 100644 --- a/web/src/app/main.tsx +++ b/web/src/app/main.tsx @@ -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() diff --git a/web/src/features/history/history-page.tsx b/web/src/features/history/history-page.tsx index 245d0c6..c0dcf39 100644 --- a/web/src/features/history/history-page.tsx +++ b/web/src/features/history/history-page.tsx @@ -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) }} /> + (null) return ( @@ -87,6 +91,15 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp + { + if (nowPlaying) + setSaving({ track: nowPlaying.entry.track, title: nowPlaying.entry.meta.title }) + }} + /> @@ -112,6 +125,7 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp + {saving && setSaving(null)} />} ) } diff --git a/web/src/features/playlists/account-settings.ts b/web/src/features/playlists/account-settings.ts new file mode 100644 index 0000000..0905973 --- /dev/null +++ b/web/src/features/playlists/account-settings.ts @@ -0,0 +1,10 @@ +// 游客在保存对话框里点“去账号设置”时打开账号设置。设置页在哪里、怎样打开由应用壳决定,启动时设置。 +let open = (): void => {} + +export function openAccountSettings(): void { + open() +} + +export function setAccountSettingsOpener(opener: () => void): void { + open = opener +} diff --git a/web/src/features/playlists/create-playlist-form.tsx b/web/src/features/playlists/create-playlist-form.tsx new file mode 100644 index 0000000..8acbe54 --- /dev/null +++ b/web/src/features/playlists/create-playlist-form.tsx @@ -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 ( + { + 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) + }} + > + + 歌单名 + {/* 刚打开的 Modal 按 data-autofocus 聚焦;在已经打开的对话框里切换过来时,由 autoFocus 聚焦。 */} + setName(event.target.value)} /> + + + 取消 + 创建 + + + ) +} diff --git a/web/src/features/playlists/index.ts b/web/src/features/playlists/index.ts index 277bca5..f544eac 100644 --- a/web/src/features/playlists/index.ts +++ b/web/src/features/playlists/index.ts @@ -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' diff --git a/web/src/features/playlists/playlist-cover.tsx b/web/src/features/playlists/playlist-cover.tsx new file mode 100644 index 0000000..f967936 --- /dev/null +++ b/web/src/features/playlists/playlist-cover.tsx @@ -0,0 +1,26 @@ +import { Music2, Star } from 'lucide-react' +import { useState } from 'react' + +export function FavoritesMark({ size = 14 }: { size?: number }) { + return +} + +function FilledStar() { + return +} + +export function PlaylistCover({ cover, favorites, size }: { cover: string | null, favorites: boolean, size: 'sm' | 'md' | 'lg' }) { + const [failedSrc, setFailedSrc] = useState() + if (!cover || cover === failedSrc) { + return ( + + {favorites ? : } + + ) + } + return ( + + setFailedSrc(cover)} /> + + ) +} diff --git a/web/src/features/playlists/playlist-sort.test.ts b/web/src/features/playlists/playlist-sort.test.ts new file mode 100644 index 0000000..e323ac0 --- /dev/null +++ b/web/src/features/playlists/playlist-sort.test.ts @@ -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]) + }) +}) diff --git a/web/src/features/playlists/playlist-sort.ts b/web/src/features/playlists/playlist-sort.ts new file mode 100644 index 0000000..a504896 --- /dev/null +++ b/web/src/features/playlists/playlist-sort.ts @@ -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))) +} diff --git a/web/src/features/playlists/playlists.css b/web/src/features/playlists/playlists.css new file mode 100644 index 0000000..4a65a45 --- /dev/null +++ b/web/src/features/playlists/playlists.css @@ -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); } diff --git a/web/src/features/playlists/save-label.ts b/web/src/features/playlists/save-label.ts new file mode 100644 index 0000000..7bb9cd9 --- /dev/null +++ b/web/src/features/playlists/save-label.ts @@ -0,0 +1,5 @@ +import { bookTitle } from '@/shared/lib/book-title' + +export function saveToPlaylistLabel(title: string): string { + return `把${bookTitle(title)}保存到歌单` +} diff --git a/web/src/features/playlists/save-to-playlist.tsx b/web/src/features/playlists/save-to-playlist.tsx new file mode 100644 index 0000000..6bca64a --- /dev/null +++ b/web/src/features/playlists/save-to-playlist.tsx @@ -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 = 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(null) + const [readFailed, setReadFailed] = useState(false) + const [attempt, setAttempt] = useState(0) + const [creating, setCreating] = useState(false) + const listRef = useRef(null) + // 从新建页回到列表时要获得焦点的元素:取消时是“新建歌单”,建好之后是新歌单的勾选框,聚焦的同时把它滚进视野。 + const returnFocusRef = useRef(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(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 = ( + <> + 注册或登录之后就能把歌保存到歌单。 + + { + onClose() + openAccountSettings() + }} + > + 去账号设置 + + + > + ) + } + else if (readFailed || (playlists === null && failed)) { + body = ( + { + setReadFailed(false) + setOrder(null) + setAttempt(n => n + 1) + }} + > + 重试 + + ) + } + else if (rows === null) { + body = + } + else if (creating) { + body = ( + { + choose(playlist, true) + backToList(`input[value="${CSS.escape(playlist.id)}"]`) + }} + onCancel={() => backToList('.save-playlist-new')} + /> + ) + } + else { + body = ( + <> + {title} + + {rows.map(playlist => ( + + + choose(playlist, event.target.checked)} + /> + + + {playlist.name} + {playlist.favorites && } + + {`${playlist.trackCount} 首`} + + + ))} + + setCreating(true)}> + + 新建歌单 + + + + > + ) + } + return {body} +} + +export function SaveToPlaylistButton({ track, title, className = '' }: { track: Track, title: string, className?: string }) { + // 记下打开时的歌:对话框开着时换了歌,勾选仍然作用于打开时的那首。 + const [target, setTarget] = useState<{ track: Track, title: string } | null>(null) + return ( + <> + setTarget({ track, title })} /> + {target && setTarget(null)} />} + > + ) +} diff --git a/web/src/features/queue/queue-page.tsx b/web/src/features/queue/queue-page.tsx index c2921be..1ec6bca 100644 --- a/web/src/features/queue/queue-page.tsx +++ b/web/src/features/queue/queue-page.tsx @@ -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 ( @@ -37,6 +39,7 @@ function QueueCard({ label, entry, className = '', playing = false }: { label: s {[song.artist, song.sourceName, song.note].filter(Boolean).join(' · ')} {playing && } + ) } @@ -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 ( @@ -67,16 +72,20 @@ export function QueuePage({ compact = false }: { compact?: boolean }) { ]} /> - {nowPlaying && } - {pending && } + {nowPlaying && save(nowPlaying.entry)} />} + {pending && save(pending)} />} {items.length ? ( [ - { 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 }) { 每个人都可以点歌,一起决定下一首。 )} + {saving && setSaving(null)} />} ) }
注册或登录之后就能把歌保存到歌单。
{title}