feat: browse my playlists as a sortable list or a glowing cover grid and open each one

This commit is contained in:
DiheChen committed 2026-11-09 21:32:21 +08:00
1 parent ffd2b390f0
commit d73a161e84
14 files changed
+881 -20

No files matched your search

+41 -20
View File
@@ -1,13 +1,14 @@
import type { LucideIcon } from 'lucide-react'
import type { Page, Panel, RoomPage } from './route'
import type { SearchQuery } from '@/features/search'
import { ChevronLeft, ChevronRight, Headphones, History, House, ListMusic, MessageCircle, MoreHorizontal, Search, Settings, Share2, UserRound, Wifi, WifiOff, X } from 'lucide-react'
import { ChevronLeft, ChevronRight, Headphones, History, House, Library, ListMusic, MessageCircle, MoreHorizontal, Search, Settings, Share2, UserRound, Wifi, WifiOff, X } from 'lucide-react'
import { Suspense } from 'react'
import { roomStore } from '@/core/room'
import { ChatPanel } from '@/features/chat'
import { HistoryPage } from '@/features/history'
import { ListenPage } from '@/features/lyrics'
import { AudioEngine, ManualStartPrompt, PlayerBar, playerStore } from '@/features/playback'
import { MyPlaylistPage, PlaylistsPage } from '@/features/playlists'
import { QueuePage, voteSkip } from '@/features/queue'
import { copyInviteLink, EntryDialog, Lobby, roomEntryStore, RoomGate } from '@/features/rooms'
import { HeaderSearch, PlaylistPage, SearchPage, searchStore } from '@/features/search'
@@ -29,11 +30,12 @@ const AdminPage = lazyPage(() => import('@/features/admin').then((module) => {
const pageLoading = <div className="page-content"><Skeleton rows={6} /></div>
const menu: { page: 'lobby' | 'settings' | 'history' | RoomPage, label: string, icon: LucideIcon }[] = [
const menu: { page: 'lobby' | 'settings' | 'history' | 'playlists' | RoomPage, label: string, icon: LucideIcon }[] = [
{ page: 'listen', label: '正在聆听', icon: Headphones },
{ page: 'queue', label: '播放列表', icon: ListMusic },
{ page: 'chat', label: '房间聊天', icon: MessageCircle },
{ page: 'history', label: '播放历史', icon: History },
{ page: 'playlists', label: '我的歌单', icon: Library },
{ page: 'lobby', label: '发现房间', icon: House },
{ page: 'settings', label: '设置', icon: Settings },
]
@@ -45,9 +47,11 @@ const mobileMenu: { page: RoomPage, label: string, icon: LucideIcon }[] = [
]
const pageClasses: Partial<Record<Page, string>> = {
queue: 'scrollbar-hidden',
history: 'scrollbar-hidden',
settings: 'settings-view',
'queue': 'scrollbar-hidden',
'history': 'scrollbar-hidden',
'playlists': 'scrollbar-hidden',
'my-playlist': 'scrollbar-hidden',
'settings': 'settings-view',
}
function closeMenu(element: HTMLElement): void {
@@ -112,21 +116,24 @@ function Header({ page }: { page: Page }) {
<MoreHorizontal size={20} />
</summary>
<nav className="main-nav" aria-label="主导航">
{menu.map(({ page: target, label, icon: Icon }) => (
<button
type="button"
key={target}
className={`nav-item text-ui-base ${page === target ? 'selected' : ''}`}
aria-current={page === target ? 'page' : undefined}
onClick={(event) => {
navigationStore.open(target)
closeMenu(event.currentTarget)
}}
>
<Icon size={20} strokeWidth={1.7} />
{label}
</button>
))}
{menu.map(({ page: target, label, icon: Icon }) => {
const selected = page === target || (target === 'playlists' && page === 'my-playlist')
return (
<button
type="button"
key={target}
className={`nav-item text-ui-base ${selected ? 'selected' : ''}`}
aria-current={selected ? 'page' : undefined}
onClick={(event) => {
navigationStore.open(target)
closeMenu(event.currentTarget)
}}
>
<Icon size={20} strokeWidth={1.7} />
{label}
</button>
)
})}
<button
type="button"
className="nav-item text-ui-base"
@@ -187,6 +194,13 @@ function RoutedHistory() {
return <HistoryPage filter={route.filter} onFilter={filter => navigationStore.navigate({ ...route, filter })} />
}
function RoutedMyPlaylist() {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'my-playlist')
return null
return <MyPlaylistPage id={route.id} onBack={() => navigationStore.open('playlists')} />
}
function RoutedSearch() {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'search')
@@ -258,6 +272,13 @@ export function App() {
{page === 'playlist' && <RoomGate onBrowse={browse}><RoutedPlaylist /></RoomGate>}
{page === 'chat' && <RoomGate onBrowse={browse}><ChatPanel /></RoomGate>}
{page === 'history' && <RoutedHistory />}
{page === 'playlists' && (
<PlaylistsPage
onOpen={playlist => navigationStore.navigate({ name: 'my-playlist', id: playlist.id, panel: navigationStore.state.route.panel }, playlist.name)}
onRegister={() => navigationStore.open('settings')}
/>
)}
{page === 'my-playlist' && <RoutedMyPlaylist />}
{page === 'settings' && <SettingsPage />}
</Suspense>
</ErrorBoundary>
+4
View File
@@ -1,4 +1,8 @@
import { lazyPage } from '@/shared/ui'
export { setAccountSettingsOpener } from './account-settings'
export { playlistsStore } from './playlists-store'
export { saveToPlaylistLabel } from './save-label'
export { SaveToPlaylistButton, SaveToPlaylistDialog } from './save-to-playlist'
export const PlaylistsPage = lazyPage(() => import('./playlists-page').then(module => module.PlaylistsPage))
export const MyPlaylistPage = lazyPage(() => import('./my-playlist-page').then(module => module.MyPlaylistPage))
@@ -0,0 +1,175 @@
import type { MyPlaylistItem, Track } from '@/core/realtime'
import type { SongItem } from '@/shared/ui'
import { ArrowLeft, CirclePlus, ListMusic, ListPlus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { api } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { roomStore, sameTrack } from '@/core/room'
import { enqueue, sourceName } from '@/features/queue'
import { bookTitle } from '@/shared/lib/book-title'
import { useStore } from '@/shared/lib/use-store'
import { EmptyState, Skeleton, SongList } from '@/shared/ui'
import { FavoritesMark, PlaylistCover } from './playlist-cover'
import { formatPlaylistDate } from './playlist-date'
import { PlaylistMenu } from './playlist-menu'
import { playlistsStore, trackKey } from './playlists-store'
import { saveToPlaylistLabel } from './save-label'
import { SaveToPlaylistDialog } from './save-to-playlist'
import './playlists.css'
export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void }) {
const playlist = useStore(playlistsStore, s => s.playlists?.find(p => p.id === id) ?? null)
const listed = useStore(playlistsStore, s => s.playlists !== null)
const connected = useStore(roomStore, s => s.status === 'open')
const ephemeral = useStore(roomStore, s => s.room?.room.kind === 'ephemeral')
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
const [items, setItems] = useState<MyPlaylistItem[] | null>(null)
const [failed, setFailed] = useState<string | null>(null)
const [attempt, setAttempt] = useState(0)
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
// 歌单的更新时间变了就重新读取:加入、移出、移动和复制都会推进它,歌曲数不变时顺序也可能变了。
useEffect(() => {
let active = true
api.listMyPlaylistItems(id).then(({ items }) => {
if (!active)
return
setItems(items)
setFailed(null)
}, (error: unknown) => {
if (active)
setFailed(errorMessage(error))
})
return () => {
active = false
}
}, [id, attempt, playlist?.updatedAtMs])
useEffect(() => {
const refresh = () => setAttempt(n => n + 1)
window.addEventListener('focus', refresh)
return () => window.removeEventListener('focus', refresh)
}, [])
const [now] = useState(Date.now)
const name = playlist?.name ?? '歌单'
// 读到了自己的歌单,里面却没有它:在别处删掉了,或者换了账号。
const gone = listed && !playlist
// 读不到时不再显示之前的曲目:它们可能已经不在了,也可能属于之前的账号。
const shown = gone || failed ? null : items
let body
if (gone) {
body = (
<EmptyState
icon={ListMusic}
title="这个歌单不存在了"
action={<button type="button" className="button-secondary" onClick={onBack}>返回我的歌单</button>}
/>
)
}
else if (failed) {
body = (
<EmptyState
icon={ListMusic}
title={failed}
action={(
<button
type="button"
className="button-secondary"
onClick={() => {
setFailed(null)
setAttempt(n => n + 1)
}}
>
重试
</button>
)}
/>
)
}
else if (items === null) {
body = <Skeleton rows={6} />
}
else if (items.length === 0) {
body = (
<EmptyState icon={ListMusic} title="这个歌单还是空的">
在播放栏、播放列表和播放历史里,可以把歌保存到歌单。
</EmptyState>
)
}
else {
body = (
<SongList
songs={items.map(({ track, meta }): SongItem => ({
key: trackKey(track),
title: meta.title,
artist: meta.artist || '未知艺术家',
album: meta.album,
coverUrl: meta.coverUrl,
durationMs: meta.durationMs,
sourceUrl: meta.pageUrl,
sourceName: sourceName(track),
playing: sameTrack(track, playing),
}))}
actions={(song, index) => {
const { track } = items[index]
const blocked = track.source === 'link' && ephemeral
return [
{
icon: CirclePlus,
label: blocked ? '临时房间不能点链接里的歌' : `点歌${bookTitle(song.title)}`,
disabled: !connected || blocked,
run: async () => {
await enqueue(track)
},
},
{
icon: ListPlus,
label: saveToPlaylistLabel(song.title),
secondary: true,
run: async () => setSaving({ track, title: song.title }),
},
{
icon: Trash2,
label: `把${bookTitle(song.title)}移出这个歌单`,
disabled: !playlist,
secondary: true,
run: async () => {
if (!playlist)
return
// 失败时歌单可能已经在别处变了,重新读取。
if (await playlistsStore.removeItems(playlist, [track]))
setItems(list => list?.filter(item => !sameTrack(item.track, track)) ?? list)
else
setAttempt(n => n + 1)
},
},
]
}}
/>
)
}
return (
<section className="page-content my-playlist-page" aria-labelledby="my-playlist-title">
<button type="button" className="button-ghost my-playlist-back text-ui-sm" aria-label="返回我的歌单" onClick={onBack}>
<ArrowLeft size={16} strokeWidth={1.7} aria-hidden="true" />
我的歌单
</button>
<header className="my-playlist-header">
<PlaylistCover cover={playlist?.coverUrl ?? null} favorites={playlist?.favorites ?? false} size="lg" />
<div className="my-playlist-info">
<h1 id="my-playlist-title" className="my-playlist-name">
<span>{name}</span>
{playlist?.favorites && <FavoritesMark size={22} />}
</h1>
{playlist && (
<p className="text-ui-base muted">
{`${playlist.trackCount} 首,更新于${formatPlaylistDate(playlist.updatedAtMs, now)},创建于${formatPlaylistDate(playlist.createdAtMs, now)}`}
</p>
)}
{playlist && !playlist.favorites && <PlaylistMenu playlist={playlist} align="start" className="my-playlist-menu" onDeleted={onBack} />}
</div>
</header>
{body}
{!connected && shown?.length ? <p className="text-ui-caption muted my-playlist-hint">进入房间之后才能点歌。</p> : null}
{saving && <SaveToPlaylistDialog track={saving.track} title={saving.title} onClose={() => setSaving(null)} />}
</section>
)
}
@@ -11,6 +11,7 @@ function FilledStar() {
export function PlaylistCover({ cover, favorites, size }: { cover: string | null, favorites: boolean, size: 'sm' | 'md' | 'lg' }) {
const [failedSrc, setFailedSrc] = useState<string>()
const large = size !== 'sm'
if (!cover || cover === failedSrc) {
return (
<span className={`playlist-cover playlist-cover-${size}`} data-favorites={favorites || undefined}>
@@ -20,7 +21,9 @@ export function PlaylistCover({ cover, favorites, size }: { cover: string | null
}
return (
<span className={`playlist-cover playlist-cover-${size}`}>
{large && <img className="playlist-cover-glow" src={cover} alt="" loading="lazy" decoding="async" />}
<img src={cover} alt="" loading="lazy" decoding="async" onError={() => setFailedSrc(cover)} />
{large && favorites && <span className="playlist-cover-badge"><FilledStar /></span>}
</span>
)
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { formatPlaylistDate } from './playlist-date'
// 用本地时间构造时刻:断言与运行测试的时区无关。
function at(year: number, month: number, day: number, hour = 0, minute = 0): number {
return new Date(year, month - 1, day, hour, minute).getTime()
}
const now = at(2026, 11, 9, 15, 30)
describe('formatPlaylistDate', () => {
it('今天的显示时刻', () => {
expect(formatPlaylistDate(at(2026, 11, 9, 14, 20), now)).toBe('今天 14:20')
expect(formatPlaylistDate(at(2026, 11, 9, 0, 5), now)).toBe('今天 00:05')
})
it('昨天的显示时刻', () => {
expect(formatPlaylistDate(at(2026, 11, 8, 21, 4), now)).toBe('昨天 21:04')
expect(formatPlaylistDate(at(2026, 11, 8, 0, 0), at(2026, 11, 9, 23, 59))).toBe('昨天 00:00')
})
it('跨月的昨天', () => {
expect(formatPlaylistDate(at(2026, 10, 31, 23, 59), at(2026, 11, 1, 0, 1))).toBe('昨天 23:59')
})
it('同一年更早的只显示月日', () => {
expect(formatPlaylistDate(at(2026, 11, 7, 9, 0), now)).toBe('11月7日')
expect(formatPlaylistDate(at(2026, 1, 1, 0, 0), now)).toBe('1月1日')
})
it('跨年显示年份', () => {
expect(formatPlaylistDate(at(2025, 12, 31, 22, 0), now)).toBe('2025年12月31日')
expect(formatPlaylistDate(at(2025, 12, 31, 22, 0), at(2026, 1, 1, 8, 0))).toBe('昨天 22:00')
})
})
@@ -0,0 +1,20 @@
const dayMs = 24 * 60 * 60 * 1000
function dayStart(date: Date): number {
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
}
function pad(n: number): string {
return String(n).padStart(2, '0')
}
export function formatPlaylistDate(ms: number, now: number): string {
const date = new Date(ms)
const today = new Date(now)
// 夏令时切换的那天不是 24 小时,按天取整。
const days = Math.round((dayStart(today) - dayStart(date)) / dayMs)
if (days === 0 || days === 1)
return `${days === 0 ? '今天' : '昨天'} ${pad(date.getHours())}:${pad(date.getMinutes())}`
const day = `${date.getMonth() + 1}月${date.getDate()}日`
return date.getFullYear() === today.getFullYear() ? day : `${date.getFullYear()}年${day}`
}
@@ -0,0 +1,177 @@
import type { KeyboardEvent, ToggleEvent } from 'react'
import type { MyPlaylist } from '@/core/realtime'
import { Ellipsis, Pencil, Trash2 } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { Modal } from '@/shared/ui'
import { playlistsStore } from './playlists-store'
const menuGap = 4
const viewportMargin = 8
const menuKeys: Record<string, (index: number, count: number) => number> = {
ArrowDown: (index, count) => (index + 1) % count,
ArrowUp: (index, count) => (index - 1 + count) % count,
Home: () => 0,
End: (_, count) => count - 1,
}
export function PlaylistMenu({ playlist, align = 'end', className = '', onDeleted }: {
playlist: MyPlaylist
align?: 'start' | 'end'
className?: string
onDeleted?: () => void
}) {
const menuId = useId()
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const [dialog, setDialog] = useState<'rename' | 'delete' | null>(null)
// 菜单在顶层按视口定位,不跟随页面滚动,所以滚动和缩放窗口时收起。
useEffect(() => {
if (!open)
return
const close = () => menuRef.current?.hidePopover()
document.addEventListener('scroll', close, true)
window.addEventListener('resize', close)
return () => {
document.removeEventListener('scroll', close, true)
window.removeEventListener('resize', close)
}
}, [open])
const items = () => [...menuRef.current!.querySelectorAll<HTMLElement>('[role="menuitem"]')]
// 显示之前量不到菜单的尺寸:先贴着按钮下方对齐,显示之后超出视口再翻到按钮上方、收进视口。
const place = (event: ToggleEvent<HTMLDivElement>) => {
if (event.newState !== 'open')
return
const button = buttonRef.current!.getBoundingClientRect()
const { style } = event.currentTarget
style.top = `${button.bottom + menuGap}px`
style.left = align === 'start' ? `${button.left}px` : 'auto'
style.right = align === 'end' ? `${document.documentElement.clientWidth - button.right}px` : 'auto'
}
const onToggle = (event: ToggleEvent<HTMLDivElement>) => {
const opened = event.newState === 'open'
setOpen(opened)
if (!opened)
return
const menu = event.currentTarget
const button = buttonRef.current!.getBoundingClientRect()
const rect = menu.getBoundingClientRect()
if (rect.bottom > window.innerHeight - viewportMargin)
menu.style.top = `${Math.max(viewportMargin, button.top - menuGap - rect.height)}px`
const left = Math.max(viewportMargin, Math.min(rect.left, document.documentElement.clientWidth - viewportMargin - rect.width))
if (left !== rect.left) {
menu.style.left = `${left}px`
menu.style.right = 'auto'
}
items()[0]?.focus()
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Tab') {
event.currentTarget.hidePopover()
return
}
const move = menuKeys[event.key]
if (!move)
return
event.preventDefault()
const list = items()
list[move(list.indexOf(document.activeElement as HTMLElement), list.length)]?.focus()
}
const choose = (next: 'rename' | 'delete') => {
menuRef.current!.hidePopover()
setDialog(next)
}
return (
<>
<button
ref={buttonRef}
type="button"
className={`icon-button playlist-menu-button ${className}`}
popoverTarget={menuId}
aria-haspopup="menu"
aria-expanded={open}
aria-label={`“${playlist.name}”的更多操作`}
title="更多操作"
>
<Ellipsis size={20} strokeWidth={1.7} aria-hidden="true" />
</button>
<div ref={menuRef} id={menuId} className="playlist-menu" popover="auto" role="menu" aria-label={`“${playlist.name}”的操作`} onBeforeToggle={place} onToggle={onToggle} onKeyDown={onKeyDown}>
<button type="button" role="menuitem" tabIndex={-1} className="playlist-menu-item" onClick={() => choose('rename')}>
<Pencil size={16} strokeWidth={1.7} aria-hidden="true" />
重命名
</button>
<button type="button" role="menuitem" tabIndex={-1} className="playlist-menu-item playlist-menu-danger" onClick={() => choose('delete')}>
<Trash2 size={16} strokeWidth={1.7} aria-hidden="true" />
删除
</button>
</div>
{dialog === 'rename' && <RenameDialog playlist={playlist} onClose={() => setDialog(null)} />}
{dialog === 'delete' && <DeleteDialog playlist={playlist} onClose={() => setDialog(null)} onDeleted={onDeleted} />}
</>
)
}
function RenameDialog({ playlist, onClose }: { playlist: MyPlaylist, onClose: () => void }) {
const [name, setName] = useState(playlist.name)
const [saving, setSaving] = useState(false)
return (
<Modal title="重命名" onClose={onClose}>
<form
className="form-stack"
onSubmit={async (event) => {
event.preventDefault()
if (saving)
return
setSaving(true)
if (await playlistsStore.rename(playlist, name))
onClose()
else
setSaving(false)
}}
>
<label>
歌单名
<input data-autofocus required maxLength={50} value={name} onChange={event => setName(event.target.value)} />
</label>
<button type="submit" className="button-primary" disabled={saving || name.trim() === ''}>保存</button>
</form>
</Modal>
)
}
function DeleteDialog({ playlist, onClose, onDeleted }: { playlist: MyPlaylist, onClose: () => void, onDeleted?: () => void }) {
const [removing, setRemoving] = useState(false)
return (
<Modal title="删除歌单" onClose={onClose}>
<p className="text-ui-base muted">{`删除“${playlist.name}”之后,里面的歌也一起删除。`}</p>
<div className="dialog-actions">
<button type="button" className="button-secondary" onClick={onClose}>取消</button>
<button
type="button"
className="button-destructive"
disabled={removing}
onClick={async () => {
setRemoving(true)
if (await playlistsStore.remove(playlist)) {
onClose()
onDeleted?.()
}
else {
setRemoving(false)
}
}}
>
删除
</button>
</div>
</Modal>
)
}
@@ -8,6 +8,14 @@ export interface PlaylistSort {
order: SortOrder
}
export const sortKeys: SortKey[] = ['name', 'createdAt', 'updatedAt', 'trackCount']
export const sortOrders: SortOrder[] = ['asc', 'desc']
// 换一个字段排序时的方向:名称从 A 到 Z,时间最新在前,歌曲数最多在前。
export function defaultOrder(key: SortKey): SortOrder {
return key === 'name' ? 'asc' : 'desc'
}
const collator = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' })
function compareBy(key: SortKey, a: MyPlaylist, b: MyPlaylist): number {
@@ -0,0 +1,220 @@
import type { LucideIcon } from 'lucide-react'
import type { PlaylistSort, SortKey, SortOrder } from './playlist-sort'
import type { ViewPreference } from './view-preference'
import type { MyPlaylist } from '@/core/realtime'
import { ArrowDown, ArrowDownAZ, ArrowDownNarrowWide, ArrowDownWideNarrow, ArrowDownZA, ArrowUp, LayoutGrid, List, Plus, Star } from 'lucide-react'
import { useEffect, useState } from 'react'
import { errorMessage } from '@/core/errors'
import { profileStore } from '@/features/settings'
import { useStore } from '@/shared/lib/use-store'
import { EmptyState, IconButton, Modal, notificationStore, Select, Skeleton } from '@/shared/ui'
import { CreatePlaylistForm } from './create-playlist-form'
import { FavoritesMark, PlaylistCover } from './playlist-cover'
import { formatPlaylistDate } from './playlist-date'
import { PlaylistMenu } from './playlist-menu'
import { defaultOrder, sortKeys, sortPlaylists } from './playlist-sort'
import { playlistsStore } from './playlists-store'
import { loadViewPreference, saveViewPreference } from './view-preference'
import './playlists.css'
const sortLabels: Record<SortKey, string> = { name: '名称', createdAt: '创建时间', updatedAt: '更新时间', trackCount: '歌曲数' }
const sortOptions = sortKeys.map(value => ({ value, label: sortLabels[value] }))
const columns: SortKey[] = ['name', 'trackCount', 'updatedAt', 'createdAt']
const orderLabels: Record<SortKey, Record<SortOrder, string>> = {
name: { asc: 'A–Z', desc: 'Z–A' },
createdAt: { asc: '最早在前', desc: '最新在前' },
updatedAt: { asc: '最早在前', desc: '最新在前' },
trackCount: { asc: '最少在前', desc: '最多在前' },
}
function orderIcon({ key, order }: PlaylistSort): LucideIcon {
if (key === 'name')
return order === 'asc' ? ArrowDownAZ : ArrowDownZA
return order === 'asc' ? ArrowDownNarrowWide : ArrowDownWideNarrow
}
function reversed(order: SortOrder): SortOrder {
return order === 'asc' ? 'desc' : 'asc'
}
function loadProfile(): void {
void profileStore.load().catch((error: unknown) => {
notificationStore.error(errorMessage(error))
})
}
export function PlaylistsPage({ onOpen, onRegister }: { onOpen: (playlist: MyPlaylist) => void, onRegister: () => void }) {
const member = useStore(profileStore, s => s.member)
const account = useStore(profileStore, s => s.account)
const playlists = useStore(playlistsStore, s => s.playlists)
const failed = useStore(playlistsStore, s => s.failed)
const profileFailed = useStore(profileStore, s => s.failed)
const [preference, setPreference] = useState(loadViewPreference)
const [creating, setCreating] = useState(false)
const [now] = useState(Date.now)
useEffect(() => {
if (!member)
loadProfile()
}, [member])
useEffect(() => {
if (account)
void playlistsStore.load()
}, [account])
const { view, sort } = preference
const update = (next: ViewPreference) => {
saveViewPreference(next)
setPreference(next)
}
const sortBy = (key: SortKey) => update({ view, sort: { key, order: key === sort.key ? reversed(sort.order) : defaultOrder(key) } })
const shown = account ? playlists : null
let body
if (!member) {
body = profileFailed
? <button type="button" className="button-secondary" onClick={loadProfile}>重试</button>
: <Skeleton rows={4} />
}
else if (!account) {
body = (
<EmptyState icon={Star} title="收藏你喜欢的歌" action={<button type="button" className="button-secondary" onClick={onRegister}>去注册</button>}>
注册账号之后,就能收藏歌曲、建自己的歌单,在其他设备上也能看到。
</EmptyState>
)
}
else if (playlists === null) {
body = failed
? <button type="button" className="button-secondary" onClick={() => void playlistsStore.load()}>重试</button>
: <Skeleton rows={4} />
}
else {
const sorted = sortPlaylists(playlists, sort)
body = view === 'list'
? <PlaylistTable playlists={sorted} sort={sort} now={now} onSort={sortBy} onOpen={onOpen} />
: <PlaylistGrid playlists={sorted} now={now} onOpen={onOpen} />
}
return (
<section className="page-content playlists-page" aria-labelledby="playlists-title">
<header className="playlists-toolbar">
<div className="playlists-heading">
<h1 id="playlists-title" className="text-ui-xl">我的歌单</h1>
{shown && <p className="text-ui-sm muted">{`${shown.length} 个歌单`}</p>}
</div>
{shown && (
<>
<div className="playlists-controls">
<div className="playlists-view-switch" role="group" aria-label="视图">
<IconButton icon={List} label="列表" aria-pressed={view === 'list'} onClick={() => update({ view: 'list', sort })} />
<IconButton icon={LayoutGrid} label="网格" aria-pressed={view === 'grid'} onClick={() => update({ view: 'grid', sort })} />
</div>
<div className="playlists-sort">
<Select
className="playlists-sort-key"
ariaLabel={`排序方式:${sortLabels[sort.key]}`}
value={sort.key}
options={sortOptions}
onChange={(value) => {
const key = value as SortKey
update({ view, sort: { key, order: key === sort.key ? sort.order : defaultOrder(key) } })
}}
/>
<IconButton
className="playlists-sort-order"
icon={orderIcon(sort)}
label={orderLabels[sort.key][sort.order]}
onClick={() => update({ view, sort: { key: sort.key, order: reversed(sort.order) } })}
/>
</div>
</div>
<button type="button" className="button-primary playlists-create" onClick={() => setCreating(true)}>
<Plus size={18} strokeWidth={2} aria-hidden="true" />
新建歌单
</button>
</>
)}
</header>
{body}
{creating && <CreateDialog onClose={() => setCreating(false)} />}
</section>
)
}
function PlaylistTable({ playlists, sort, now, onSort, onOpen }: {
playlists: MyPlaylist[]
sort: PlaylistSort
now: number
onSort: (key: SortKey) => void
onOpen: (playlist: MyPlaylist) => void
}) {
return (
<div className="playlist-table" role="table" aria-label="我的歌单">
<div className="playlist-table-head" role="rowgroup">
<div className="playlist-table-row" role="row">
{columns.map((key) => {
const active = key === sort.key
const Arrow = sort.order === 'asc' ? ArrowUp : ArrowDown
return (
<div key={key} className={`playlist-col-${key}`} role="columnheader" aria-sort={active ? (sort.order === 'asc' ? 'ascending' : 'descending') : undefined}>
<button type="button" className="playlist-sort-button text-ui-sm" data-active={active || undefined} onClick={() => onSort(key)}>
{sortLabels[key]}
{active && <Arrow size={14} strokeWidth={2} aria-hidden="true" />}
</button>
</div>
)
})}
<div className="playlist-col-menu" role="columnheader"><span className="sr-only">操作</span></div>
</div>
</div>
<div role="rowgroup">
{playlists.map(playlist => (
<div key={playlist.id} className="playlist-table-row playlist-row" role="row">
<div className="playlist-col-name" role="cell">
<button type="button" className="playlist-row-open" onClick={() => onOpen(playlist)}>
<PlaylistCover cover={playlist.coverUrl} favorites={playlist.favorites} size="sm" />
<span className="playlist-row-copy">
<span className="playlist-row-title">
<strong className="text-ui-base truncate">{playlist.name}</strong>
{playlist.favorites && <FavoritesMark />}
</span>
<span className="playlist-row-count text-ui-sm muted">{`${playlist.trackCount} 首`}</span>
</span>
</button>
</div>
<div className="playlist-col-trackCount text-ui-sm muted" role="cell">{playlist.trackCount}</div>
<div className="playlist-col-updatedAt text-ui-sm muted" role="cell">{formatPlaylistDate(playlist.updatedAtMs, now)}</div>
<div className="playlist-col-createdAt text-ui-sm muted" role="cell">{formatPlaylistDate(playlist.createdAtMs, now)}</div>
<div className="playlist-col-menu" role="cell">
{!playlist.favorites && <PlaylistMenu playlist={playlist} />}
</div>
</div>
))}
</div>
</div>
)
}
function PlaylistGrid({ playlists, now, onOpen }: { playlists: MyPlaylist[], now: number, onOpen: (playlist: MyPlaylist) => void }) {
return (
<ul className="playlist-grid">
{playlists.map(playlist => (
<li key={playlist.id} className="playlist-card">
<button type="button" className="playlist-card-open" onClick={() => onOpen(playlist)}>
<span className="playlist-card-art">
<PlaylistCover cover={playlist.coverUrl} favorites={playlist.favorites} size="md" />
</span>
<strong className="text-ui-base truncate">{playlist.name}</strong>
<span className="text-ui-sm muted truncate">{`${playlist.trackCount} 首 · ${formatPlaylistDate(playlist.updatedAtMs, now)}`}</span>
</button>
{!playlist.favorites && <PlaylistMenu playlist={playlist} className="playlist-card-menu" />}
</li>
))}
</ul>
)
}
function CreateDialog({ onClose }: { onClose: () => void }) {
return (
<Modal title="新建歌单" onClose={onClose}>
<CreatePlaylistForm onCreated={onClose} onCancel={onClose} />
</Modal>
)
}
+103
View File
@@ -21,4 +21,107 @@
.playlist-cover-sm { width: 40px; }
.playlist-cover-md { width: 100%; }
.playlist-cover-lg { width: 232px; border-radius: 10px; }
/* z-index 为负:光晕画在最近的层叠上下文的最底层,在文字之下。放大封面的容器要用 isolation 建立层叠上下文,否则光晕会落到页面背景下面。 */
.playlist-cover-glow { position: absolute; inset: 0; z-index: -1; opacity: var(--glow-opacity); filter: blur(24px) saturate(1.8); transform: translateY(10%) scale(.95); transition: opacity 240ms ease, transform 240ms ease; pointer-events: none; }
.playlist-cover-lg > .playlist-cover-glow { filter: blur(32px) saturate(1.8); }
:root:is([data-performance='low'], [data-performance='off']) .playlist-cover-glow { display: none; }
.playlist-cover-badge { position: absolute; bottom: 8px; left: 8px; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: var(--color-primary); color: var(--color-primary-foreground); box-shadow: 0 1px 4px rgb(0 0 0 / 35%); }
.playlist-cover-badge > svg { width: 13px; height: 13px; }
.playlist-cover-lg > .playlist-cover-badge { bottom: 12px; left: 12px; width: 32px; height: 32px; }
.playlist-cover-lg > .playlist-cover-badge > svg { width: 17px; height: 17px; }
.playlist-favorites-mark { flex-shrink: 0; color: var(--color-brand); }
.playlist-menu { inset: auto; margin: 0; min-width: 148px; padding: 6px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-popover); color: var(--color-foreground); box-shadow: 0 12px 32px var(--color-shadow); transform-origin: top center; }
.playlist-menu:popover-open { display: flex; flex-direction: column; gap: 2px; animation: popover-in 160ms cubic-bezier(.2, .8, .2, 1) both; }
.playlist-menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--color-foreground); text-align: left; white-space: nowrap; }
.playlist-menu-item:hover, .playlist-menu-item:focus-visible { outline: none; background: var(--color-hover); }
.playlist-menu-item > svg { color: var(--color-foreground-subtle); }
.playlist-menu-danger, .playlist-menu-danger > svg { color: var(--color-destructive); }
.playlist-menu-button { color: var(--color-foreground-subtle); }
.playlist-menu-button:hover:not(:disabled), .playlist-menu-button[aria-expanded='true'] { color: var(--color-foreground); }
:root[data-performance='low'] .playlist-menu:popover-open { animation: fade-in 160ms ease both; }
.playlists-page { container: playlists / inline-size; }
.playlists-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: 'heading controls create'; align-items: end; gap: 12px; margin-bottom: 28px; }
.playlists-heading { grid-area: heading; min-width: 0; }
.playlists-heading h1 { line-height: 1.25; }
.playlists-heading p { margin-top: 4px; }
.playlists-controls { grid-area: controls; display: flex; align-items: center; gap: 8px; }
.playlists-create { grid-area: create; }
.playlists-view-switch { display: flex; gap: 2px; padding: 2px; border: 1px solid var(--color-border); border-radius: 8px; }
.playlists-view-switch .icon-button { width: 32px; height: 30px; border-radius: 6px; color: var(--color-foreground-subtle); }
.playlists-view-switch .icon-button svg { width: 18px; height: 18px; }
.playlists-view-switch .icon-button:hover:not(:disabled) { color: var(--color-foreground); }
.playlists-view-switch .icon-button[aria-pressed='true'] { background: var(--color-selected); color: var(--color-foreground); }
.playlists-sort { display: flex; }
.playlists-sort-key .select-trigger { min-width: calc(4em + 48px); min-height: 36px; padding: 6px 10px 6px 12px; gap: 8px; border-color: var(--color-border); border-radius: 8px 0 0 8px; background: transparent; }
.playlists-sort-order { width: 36px; height: 36px; border: 1px solid var(--color-border); border-left: 0; border-radius: 0 8px 8px 0; color: var(--color-foreground-subtle); }
.playlists-sort-order svg { width: 18px; height: 18px; }
.playlists-sort-order:hover:not(:disabled) { color: var(--color-foreground); }
.playlist-table { --playlist-columns: minmax(0, 1fr) 64px 132px 132px 36px; }
.playlist-table-row { display: grid; grid-template-columns: var(--playlist-columns); align-items: center; gap: 24px; padding: 0 8px; }
.playlist-table-head .playlist-table-row { min-height: 36px; margin-bottom: 6px; border-bottom: 1px solid var(--color-border); }
.playlist-sort-button { display: inline-flex; align-items: center; gap: 4px; padding: 4px 0; border: 0; background: none; color: var(--color-foreground-subtle); white-space: nowrap; }
.playlist-sort-button:hover, .playlist-sort-button[data-active] { color: var(--color-foreground); }
.playlist-sort-button > svg { color: var(--color-brand); }
.playlist-col-trackCount { text-align: right; font-variant-numeric: tabular-nums; }
.playlist-col-updatedAt, .playlist-col-createdAt { overflow: hidden; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
.playlist-col-menu { display: flex; justify-content: flex-end; }
.playlist-row { position: relative; min-height: 56px; border-radius: 8px; }
.playlist-row:hover { background: var(--color-surface); }
.playlist-row-open { display: flex; align-items: center; gap: 12px; width: 100%; min-width: 0; padding: 8px 0; border: 0; background: none; color: inherit; text-align: left; }
/* 按钮只在名称这一格里,伸出的伪元素让整行都能点开歌单。 */
.playlist-row-open::after { content: ''; position: absolute; inset: 0; border-radius: 8px; }
.playlist-row-open:focus-visible { outline: none; }
.playlist-row-open:focus-visible::after { outline: 2px solid var(--color-brand); outline-offset: -2px; }
.playlist-row-open:active:not(:disabled) { transform: none; }
.playlist-row-copy { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.playlist-row-title { display: flex; align-items: center; gap: 6px; min-width: 0; }
.playlist-row-title > strong { font-weight: 500; }
.playlist-row-count { display: none; }
.playlist-row .playlist-menu-button { position: relative; z-index: 1; }
.playlist-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); gap: 28px 20px; margin: 0; padding: 0; list-style: none; }
.playlist-card { position: relative; min-width: 0; transition: transform 200ms ease; }
.playlist-card-open { isolation: isolate; display: flex; flex-direction: column; gap: 2px; width: 100%; min-width: 0; padding: 0; border: 0; border-radius: 10px; background: none; color: inherit; text-align: left; }
.playlist-card-open:focus-visible { outline-offset: 6px; }
.playlist-card-art { position: relative; display: block; margin-bottom: 12px; }
.playlist-card-menu { position: absolute; top: 8px; right: 8px; z-index: 1; width: 32px; height: 32px; border-radius: 50%; background: var(--color-popover); color: var(--color-foreground); transition: opacity 150ms ease, background-color 150ms ease; }
.playlist-card-menu:hover:not(:disabled), .playlist-card-menu[aria-expanded='true'] { background: var(--color-popover); color: var(--color-brand); }
@media (hover: hover) {
.playlist-row .playlist-menu-button, .playlist-card-menu { opacity: 0; }
.playlist-row:hover .playlist-menu-button, .playlist-row:focus-within .playlist-menu-button,
.playlist-card:hover .playlist-card-menu, .playlist-card:focus-within .playlist-card-menu,
.playlist-menu-button[aria-expanded='true'] { opacity: 1; }
.playlist-card:is(:hover, :has(:focus-visible)) .playlist-cover-glow { opacity: calc(var(--glow-opacity) + .2); transform: translateY(12%) scale(1); }
}
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
:root:not([data-performance='low'], [data-performance='off'], [data-reduced-motion]) .playlist-card:is(:hover, :has(:focus-visible)) { transform: translateY(-4px); }
}
.my-playlist-page { container: my-playlist / inline-size; }
.my-playlist-back { margin: -6px 0 16px -8px; color: var(--color-foreground-subtle); }
.my-playlist-back:hover:not(:disabled) { color: var(--color-foreground); }
.my-playlist-header { isolation: isolate; display: flex; align-items: flex-end; gap: 28px; margin-bottom: 16px; padding-bottom: 28px; border-bottom: 1px solid var(--color-border); }
.my-playlist-info { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; }
.my-playlist-name { display: flex; align-items: center; gap: 10px; max-width: 100%; font-size: calc(var(--text-ui-xl) + 8px); font-weight: 700; line-height: 1.2; }
.my-playlist-name > span { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
.my-playlist-menu { margin-top: 4px; border: 1px solid var(--color-border); border-radius: 50%; }
.my-playlist-hint { margin-top: 12px; }
@container playlists (max-width: 720px) {
.playlist-table { --playlist-columns: minmax(0, 1fr) 36px; }
.playlist-table-head, .playlist-col-trackCount, .playlist-col-updatedAt, .playlist-col-createdAt { display: none; }
.playlist-row-count { display: block; }
}
@container playlists (max-width: 560px) {
.playlists-toolbar { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: 'heading create' 'controls controls'; align-items: center; gap: 16px 12px; margin-bottom: 20px; }
.playlists-controls { justify-content: space-between; }
.playlist-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 12px; }
}
@container my-playlist (max-width: 560px) {
.my-playlist-header { flex-direction: column; align-items: flex-start; gap: 16px; padding-bottom: 20px; }
.playlist-cover-lg { width: 176px; }
.my-playlist-name { font-size: var(--text-ui-xl); }
}
@@ -0,0 +1,49 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadViewPreference, saveViewPreference } from './view-preference'
const key = 'alisten.playlists-view'
let items: Map<string, string>
beforeEach(() => {
items = new Map()
vi.stubGlobal('localStorage', {
getItem: (name: string) => items.get(name) ?? null,
setItem: (name: string, value: string) => items.set(name, value),
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
const fallback = { view: 'grid', sort: { key: 'updatedAt', order: 'desc' } }
describe('视图偏好', () => {
it('没有保存过时用网格视图,按更新时间、最新在前', () => {
expect(loadViewPreference()).toEqual(fallback)
})
it('保存之后读到同样的偏好', () => {
const preference = { view: 'list', sort: { key: 'name', order: 'asc' } } as const
saveViewPreference(preference)
expect(loadViewPreference()).toEqual(preference)
})
it('不是 JSON 时回退默认', () => {
items.set(key, '{')
expect(loadViewPreference()).toEqual(fallback)
items.set(key, 'null')
expect(loadViewPreference()).toEqual(fallback)
})
it('无效的字段各自回退默认', () => {
items.set(key, JSON.stringify({ view: 'list', sort: { key: 'size', order: 'asc' } }))
expect(loadViewPreference()).toEqual({ view: 'list', sort: fallback.sort })
items.set(key, JSON.stringify({ view: 'columns', sort: { key: 'trackCount', order: 'asc' } }))
expect(loadViewPreference()).toEqual({ view: 'grid', sort: { key: 'trackCount', order: 'asc' } })
items.set(key, JSON.stringify({ view: 'list', sort: { key: 'name', order: 'up' } }))
expect(loadViewPreference()).toEqual({ view: 'list', sort: fallback.sort })
items.set(key, JSON.stringify({ view: 'list', sort: 'name' }))
expect(loadViewPreference()).toEqual({ view: 'list', sort: fallback.sort })
})
})
@@ -0,0 +1,42 @@
import type { PlaylistSort } from './playlist-sort'
import { sortKeys, sortOrders } from './playlist-sort'
export type PlaylistView = 'list' | 'grid'
export interface ViewPreference {
view: PlaylistView
sort: PlaylistSort
}
const preferenceKey = 'alisten.playlists-view'
const views: PlaylistView[] = ['list', 'grid']
const defaultPreference: ViewPreference = { view: 'grid', sort: { key: 'updatedAt', order: 'desc' } }
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function readStored(): Record<string, unknown> {
try {
const value: unknown = JSON.parse(localStorage.getItem(preferenceKey) ?? 'null')
return isObject(value) ? value : {}
}
catch {
return {}
}
}
export function loadViewPreference(): ViewPreference {
const stored = readStored()
const sort = isObject(stored.sort) ? stored.sort : {}
const key = sortKeys.find(k => k === sort.key)
const order = sortOrders.find(o => o === sort.order)
return {
view: views.find(v => v === stored.view) ?? defaultPreference.view,
sort: key && order ? { key, order } : defaultPreference.sort,
}
}
export function saveViewPreference(preference: ViewPreference): void {
localStorage.setItem(preferenceKey, JSON.stringify(preference))
}
+3
View File
@@ -53,6 +53,7 @@
--color-toast: #303034;
--color-overlay: rgb(0 0 0 / 55%);
--color-shadow: rgb(0 0 0 / 24%);
--glow-opacity: .8;
color-scheme: dark;
font-synthesis: none;
}
@@ -87,6 +88,7 @@
--color-success: #287144;
--color-toast: #fff;
--color-shadow: rgb(30 30 45 / 12%);
--glow-opacity: .5;
color-scheme: light;
}
@media (prefers-color-scheme: light) {
@@ -121,6 +123,7 @@
--color-success: #287144;
--color-toast: #fff;
--color-shadow: rgb(30 30 45 / 12%);
--glow-opacity: .5;
color-scheme: light;
}
}
+1
View File
@@ -108,3 +108,4 @@
.modal-body { padding: 20px 20px calc(20px + env(safe-area-inset-bottom)); }
.notifications { --notification-top: calc(76px + env(safe-area-inset-top)); }
}
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }