diff --git a/web/src/features/playlists/my-playlist-page.tsx b/web/src/features/playlists/my-playlist-page.tsx index 65a4039..be5d95d 100644 --- a/web/src/features/playlists/my-playlist-page.tsx +++ b/web/src/features/playlists/my-playlist-page.tsx @@ -1,22 +1,31 @@ -import type { MyPlaylistItem, Track } from '@/core/realtime' -import type { SongItem } from '@/shared/ui' +import type { MovePlan } from './track-order' +import type { MyPlaylist, MyPlaylistItem, Track } from '@/core/realtime' +import type { SongAction } from '@/shared/ui' import { ArrowLeft, CirclePlus, ListMusic, ListPlus, Trash2 } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useEffect, useRef, 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 { roomStore } from '@/core/room' +import { enqueue } 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 { EmptyState, Modal, Skeleton } from '@/shared/ui' import { FavoritesMark, PlaylistCover } from './playlist-cover' import { formatPlaylistDate } from './playlist-date' import { PlaylistMenu } from './playlist-menu' +import { PlaylistTracks } from './playlist-tracks' import { playlistsStore, trackKey } from './playlists-store' import { saveToPlaylistLabel } from './save-label' import { SaveToPlaylistDialog } from './save-to-playlist' +import { SelectionBar } from './selection-bar' +import { dropAt } from './track-order' +import { noSelection, retain, selectAll } from './track-selection' import './playlists.css' +function keysOf(items: readonly MyPlaylistItem[]): string[] { + return items.map(item => trackKey(item.track)) +} + export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void }) { const playlist = useStore(playlistsStore, s => s.playlists?.find(p => p.id === id) ?? null) const connected = useStore(roomStore, s => s.status === 'open') @@ -25,12 +34,21 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void const [items, setItems] = useState(null) const [failed, setFailed] = useState(null) const [attempt, setAttempt] = useState(0) + const [selection, setSelection] = useState(noSelection) + // 点了“选择”:没有选中项时也显示勾选框。有选中项时总是处于多选模式。 + const [picking, setPicking] = useState(false) const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null) + const [savingSelected, setSavingSelected] = useState(false) + const [confirmingRemoval, setConfirmingRemoval] = useState(false) + // 移动按发起的先后逐个请求:每次的 before 都基于上一次移动之后的顺序。一次失败之后,排在它后面的不再发出。 + const movesRef = useRef({ tail: Promise.resolve(), generation: 0 }) useEffect(() => { let active = true api.listMyPlaylistItems(id).then(({ items }) => { - if (active) - setItems(items) + if (!active) + return + setItems(items) + setSelection(selection => retain(selection, keysOf(items))) }, (error: unknown) => { if (active) setFailed(errorMessage(error)) @@ -44,8 +62,58 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void window.addEventListener('focus', refresh) return () => window.removeEventListener('focus', refresh) }, []) + const selecting = picking || selection.keys.size > 0 + // 对话框和菜单自己处理 Esc。 + useEffect(() => { + if (!selecting) + return + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented || (event.target instanceof Element && event.target.closest('dialog, [popover]'))) + return + setSelection(noSelection) + setPicking(false) + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [selecting]) const [now] = useState(Date.now) const name = playlist?.name ?? '歌单' + + const exitSelection = () => { + setSelection(noSelection) + setPicking(false) + } + + // 先在本地排好再请求;失败时回到原来的顺序,并重新读取:歌单可能在别处变过。 + const move = (target: MyPlaylist, current: MyPlaylistItem[], plan: MovePlan) => { + const byKey = new Map(current.map(item => [trackKey(item.track), item])) + const trackOf = (key: string) => byKey.get(key)!.track + const tracks = plan.moved.map(trackOf) + const before = plan.before === undefined ? undefined : trackOf(plan.before) + setItems(plan.order.map(key => byKey.get(key)!)) + const queue = movesRef.current + const generation = queue.generation + queue.tail = queue.tail.then(async () => { + if (queue.generation !== generation || await playlistsStore.moveItems(target, tracks, before)) + return + queue.generation++ + setItems(current) + setAttempt(n => n + 1) + }) + } + + const remove = async (target: MyPlaylist, current: MyPlaylistItem[], tracks: Track[]) => { + const gone = new Set(tracks.map(trackKey)) + const rest = current.filter(item => !gone.has(trackKey(item.track))) + const previousSelection = selection + setItems(rest) + setSelection(retain(selection, keysOf(rest))) + if (await playlistsStore.removeItems(target, tracks)) + return + setItems(current) + setSelection(previousSelection) + } + let body if (failed && items === null) { body = ( @@ -78,52 +146,53 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void ) } else { + const actions = ({ track, meta }: MyPlaylistItem): SongAction[] => { + const blocked = track.source === 'link' && ephemeral + return [ + { + icon: CirclePlus, + label: blocked ? '临时房间不能点链接里的歌' : `点歌${bookTitle(meta.title)}`, + disabled: !connected || blocked, + run: async () => { + await enqueue(track) + }, + }, + { + icon: ListPlus, + label: saveToPlaylistLabel(meta.title), + secondary: true, + run: async () => setSaving({ track, title: meta.title }), + }, + { + icon: Trash2, + label: `把${bookTitle(meta.title)}移出这个歌单`, + disabled: !playlist, + secondary: true, + run: async () => { + if (playlist) + await remove(playlist, items, [track]) + }, + }, + ] + } body = ( - ({ - 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 && await playlistsStore.removeItems(playlist, [track])) - setItems(list => list?.filter(item => !sameTrack(item.track, track)) ?? list) - }, - }, - ] + { + if (playlist) + move(playlist, items, plan) }} /> ) } + + const keys = items ? keysOf(items) : [] + const selected = items?.filter(item => selection.keys.has(trackKey(item.track))).map(item => item.track) ?? [] return (
+ )} + {playlist && !playlist.favorites && } + {body} {!connected && items?.length ?

进入房间之后才能点歌。

: null} + {playlist && items && selected.length > 0 && ( + setSavingSelected(true)} + onTop={() => { + const plan = dropAt(keys, selection.keys, 0) + if (plan) + move(playlist, items, plan) + }} + onBottom={() => { + const plan = dropAt(keys, selection.keys, keys.length) + if (plan) + move(playlist, items, plan) + }} + onRemove={() => { + if (selected.length > 1) + setConfirmingRemoval(true) + else + void remove(playlist, items, selected) + }} + onToggleAll={() => setSelection(selected.length === items.length ? noSelection : selectAll(keys))} + onClose={exitSelection} + /> + )} {saving && setSaving(null)} />} + {playlist && savingSelected && setSavingSelected(false)} />} + {playlist && items && confirmingRemoval && ( + setConfirmingRemoval(false)}> +

{`把 ${selected.length} 首歌移出这个歌单?`}

+
+ + +
+
+ )}
) } diff --git a/web/src/features/playlists/playlist-tracks.tsx b/web/src/features/playlists/playlist-tracks.tsx new file mode 100644 index 0000000..bc38ad4 --- /dev/null +++ b/web/src/features/playlists/playlist-tracks.tsx @@ -0,0 +1,151 @@ +import type { KeyboardEvent, MouseEvent } from 'react' +import type { MovePlan } from './track-order' +import type { Selection } from './track-selection' +import type { MyPlaylistItem, Track } from '@/core/realtime' +import type { SongAction } from '@/shared/ui' +import { GripVertical } from 'lucide-react' +import { useLayoutEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { sameTrack } from '@/core/room' +import { sourceName } from '@/features/queue' +import { bookTitle } from '@/shared/lib/book-title' +import { formatDuration } from '@/shared/lib/time' +import { AsyncIconButton, PlayingBars, SongArtwork } from '@/shared/ui' +import { trackKey } from './playlists-store' +import { moveByOne } from './track-order' +import { selectAll, selectRange, toggle } from './track-selection' +import { useTrackDrag } from './use-track-drag' + +export function PlaylistTracks({ items, playing, selection, selecting, actions, onSelect, onMove }: { + items: MyPlaylistItem[] + playing: Track | undefined + selection: Selection + selecting: boolean + actions: (item: MyPlaylistItem) => SongAction[] + onSelect: (selection: Selection) => void + onMove: (plan: MovePlan) => void +}) { + const listRef = useRef(null) + const lineRef = useRef(null) + const labelRef = useRef(null) + const focusAfterMoveRef = useRef(null) + const keys = items.map(item => trackKey(item.track)) + const { dragging, onPointerDown } = useTrackDrag({ + listRef, + lineRef, + labelRef, + keys, + selected: selection.keys, + onDrop: (plan) => { + if (plan) + onMove(plan) + }, + }) + + // 重新排列时 React 可能把获得焦点的行移出再插回,焦点会丢。 + useLayoutEffect(() => { + const key = focusAfterMoveRef.current + if (key === null) + return + focusAfterMoveRef.current = null + listRef.current?.querySelector(`[data-key="${CSS.escape(key)}"]`)?.focus() + }, [items]) + + const onRowClick = (event: MouseEvent, key: string) => { + if ((event.target as Element).closest('button, a')) + return + if (event.shiftKey) + onSelect(selectRange(selection, keys, key)) + else if (event.metaKey || event.ctrlKey || selecting) + onSelect(toggle(selection, key)) + } + + const onKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'a') { + event.preventDefault() + onSelect(selectAll(keys)) + return + } + // 其余按键只在焦点落在行本身时处理,行里的按钮保持原来的行为。 + const row = event.target as HTMLElement + const key = row.dataset.key + if (key === undefined) + return + if (event.key === ' ' && selecting) { + event.preventDefault() + onSelect(toggle(selection, key)) + return + } + if ((event.key !== 'ArrowUp' && event.key !== 'ArrowDown') || event.metaKey || event.ctrlKey || event.shiftKey) + return + event.preventDefault() + const delta = event.key === 'ArrowUp' ? -1 : 1 + if (!event.altKey) { + const next = delta < 0 ? row.previousElementSibling : row.nextElementSibling + if (next instanceof HTMLElement) + next.focus() + return + } + const plan = moveByOne(keys, selection.keys.has(key) ? selection.keys : new Set([key]), delta) + if (!plan) + return + focusAfterMoveRef.current = key + onMove(plan) + } + + let label = '' + if (dragging) { + const [first] = dragging + label = dragging.size > 1 ? `${dragging.size} 首` : items.find(item => trackKey(item.track) === first)?.meta.title ?? '' + } + + return ( +
+
    + {items.map((item, index) => { + const { track, meta } = item + const key = keys[index] + const selected = selection.keys.has(key) + const source = sourceName(track) + const isPlaying = sameTrack(track, playing) + return ( +
  1. onRowClick(event, key)} + onPointerDown={event => onPointerDown(event, key)} + > + {selecting + ? + : {String(index + 1).padStart(2, '0')}} + {/* 多选时点封面也是切换选中,不打开音源页面。 */} + +
    + + {isPlaying && } + {meta.title} + + {[meta.artist || '未知艺术家', source].filter(Boolean).join(' · ')} +
    + {meta.album} + {meta.durationMs > 0 ? formatDuration(meta.durationMs) : ''} + {actions(item).map(action => ( + + ))} + +
  2. + ) + })} +
+ {dragging &&
} + {dragging && createPortal(
{label}
, document.body)} +
+ ) +} diff --git a/web/src/features/playlists/playlists.css b/web/src/features/playlists/playlists.css index 3e055d2..d30dc1d 100644 --- a/web/src/features/playlists/playlists.css +++ b/web/src/features/playlists/playlists.css @@ -107,9 +107,39 @@ .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-actions { display: flex; align-items: center; gap: 8px; margin-top: 4px; } +.my-playlist-select { min-width: 64px; border-radius: 999px; } +.my-playlist-menu { border: 1px solid var(--color-border); border-radius: 50%; } .my-playlist-hint { margin-top: 12px; } +.playlist-tracks { position: relative; } +.playlist-track { user-select: none; -webkit-user-select: none; } +.playlist-track:focus-visible { outline: 2px solid var(--color-brand); outline-offset: -2px; } +.playlist-track[data-selected], .playlist-track[data-selected]:hover { background: var(--color-selected); } +.playlist-track[data-dragging] { opacity: .4; } +.playlist-track-check { margin: 0 3px; } +.playlist-track-handle { display: grid; place-items: center; flex-shrink: 0; width: 24px; height: 36px; margin-left: -4px; color: var(--color-foreground-subtlest); cursor: grab; touch-action: none; -webkit-touch-callout: none; } +.playlist-tracks[data-dragging], .playlist-tracks[data-dragging] .playlist-track-handle { cursor: grabbing; } +.playlist-tracks[data-dragging] .playlist-track:not([data-selected]):hover { background: transparent; } +@media (hover: hover) { + .playlist-track-handle { opacity: 0; transition: opacity 120ms ease; } + .playlist-tracks:not([data-dragging]) .playlist-track:is(:hover, :focus-visible) .playlist-track-handle { opacity: 1; } +} +.playlist-drop-line { position: absolute; top: 0; left: 8px; right: 8px; height: 2px; margin-top: -1px; border-radius: 1px; background: var(--color-brand); pointer-events: none; visibility: hidden; } +.playlist-drag-label { position: fixed; top: 0; left: 0; z-index: 40; max-width: 240px; padding: 4px 10px; overflow: hidden; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-popover); color: var(--color-foreground); box-shadow: 0 8px 24px var(--color-shadow); text-overflow: ellipsis; white-space: nowrap; pointer-events: none; visibility: hidden; } + +.playlist-selection-bar { position: sticky; bottom: 16px; z-index: 5; display: flex; align-items: center; gap: 2px; width: fit-content; max-width: 100%; margin: 20px auto 0; padding: 6px 6px 6px 16px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-popover); box-shadow: 0 12px 32px var(--color-shadow); animation: selection-bar-in 200ms cubic-bezier(.2, .8, .2, 1) both; } +.playlist-selection-count { margin-right: 8px; font-variant-numeric: tabular-nums; white-space: nowrap; } +.playlist-bar-button { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 6px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--color-foreground); white-space: nowrap; } +.playlist-bar-button:hover { background: var(--color-hover); } +.playlist-bar-button > svg { color: var(--color-foreground-subtle); } +.playlist-bar-close { margin-left: 4px; color: var(--color-foreground-subtle); } +:root[data-performance='low'] .playlist-selection-bar { animation-name: fade-in; } +@keyframes selection-bar-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: none; } +} + @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; } @@ -120,6 +150,10 @@ .playlists-controls { justify-content: space-between; } .playlist-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 12px; } } +@container my-playlist (max-width: 640px) { + .playlist-bar-button { justify-content: center; width: 36px; padding: 0; } + .playlist-bar-button > span { display: none; } +} @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; } diff --git a/web/src/features/playlists/save-to-playlist.tsx b/web/src/features/playlists/save-to-playlist.tsx index c89f24c..02e4fe5 100644 --- a/web/src/features/playlists/save-to-playlist.tsx +++ b/web/src/features/playlists/save-to-playlist.tsx @@ -4,7 +4,7 @@ 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 { IconButton, Modal, notificationStore, Skeleton } from '@/shared/ui' import { CreatePlaylistForm } from './create-playlist-form' import { FavoritesMark, PlaylistCover } from './playlist-cover' import { sortPlaylists } from './playlist-sort' @@ -22,15 +22,28 @@ function arrange(playlists: readonly MyPlaylist[], order: readonly string[]): My return sortPlaylists(playlists, byUpdate).sort((a, b) => at(a) - at(b)) } -export function SaveToPlaylistDialog({ track, title, onClose }: { track: Track, title: string, onClose: () => void }) { +function savedMessage(saved: readonly MyPlaylist[], skipped: number): string { + const where = saved.length === 1 ? `“${saved[0].name}”` : ` ${saved.length} 个歌单` + return `已保存到${where}${skipped > 0 ? `,其中 ${skipped} 首已经在里面` : ''}` +} + +// 一首歌:勾选立即生效。歌单里选中的多首:勾好目标歌单后一起保存,来源歌单不在列表里。 +type SaveToPlaylistProps = { onClose: () => void } & ({ track: Track, title: string } | { from: MyPlaylist, tracks: Track[] }) + +export function SaveToPlaylistDialog(props: SaveToPlaylistProps) { + const { onClose } = props + const track = 'track' in props ? props.track : null + const from = 'from' in props ? props.from : null 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 included = useStore(playlistsStore, s => track ? s.memberships.get(trackKey(track)) : undefined) ?? none + const [picked, setPicked] = useState>(none) const [order, setOrder] = useState(null) const [readFailed, setReadFailed] = useState(false) const [attempt, setAttempt] = useState(0) const [creating, setCreating] = useState(false) + const [saving, setSaving] = useState(false) const listRef = useRef(null) // 从新建页回到列表时要获得焦点的元素:取消时是“新建歌单”,建好之后是新歌单的勾选框,聚焦的同时把它滚进视野。 const returnFocusRef = useRef(null) @@ -39,7 +52,7 @@ export function SaveToPlaylistDialog({ track, title, onClose }: { track: Track, if (!account) return let active = true - void Promise.all([playlistsStore.load(), playlistsStore.containing(track)]).then(([, read]) => { + void Promise.all([playlistsStore.load(), track ? playlistsStore.containing(track) : true]).then(([, read]) => { if (!active) return setReadFailed(!read) @@ -56,10 +69,21 @@ export function SaveToPlaylistDialog({ track, title, onClose }: { track: Track, returnFocusRef.current = null }, [creating]) - const rows = playlists && order ? arrange(playlists, order) : null + const rows = playlists && order ? arrange(playlists, order).filter(p => p.id !== from?.id) : null const choose = (playlist: MyPlaylist, checked: boolean) => { - void playlistsStore.setMembership(playlist, track, checked) + if (track) { + void playlistsStore.setMembership(playlist, track, checked) + return + } + setPicked((current) => { + const next = new Set(current) + if (checked) + next.add(playlist.id) + else + next.delete(playlist.id) + return next + }) } const backToList = (focus: string) => { @@ -67,6 +91,16 @@ export function SaveToPlaylistDialog({ track, title, onClose }: { track: Track, setCreating(false) } + const save = async (source: MyPlaylist, tracks: Track[], targets: MyPlaylist[]) => { + setSaving(true) + const results = await Promise.all(targets.map(target => playlistsStore.copyItems(source, target, tracks))) + const saved = targets.filter((_, index) => results[index] !== null) + const skipped = results.reduce((sum, added) => added === null ? sum : sum + tracks.length - added, 0) + if (saved.length > 0) + notificationStore.success(savedMessage(saved, skipped)) + onClose() + } + let body if (!account) { body =

注册账号之后就能建自己的歌单。

@@ -103,7 +137,7 @@ export function SaveToPlaylistDialog({ track, title, onClose }: { track: Track, else { body = ( <> -

{title}

+

{'tracks' in props ? `已选 ${props.tracks.length} 首` : props.title}

    {rows.map(playlist => (
  • @@ -111,7 +145,8 @@ export function SaveToPlaylistDialog({ track, title, onClose }: { track: Track, choose(playlist, event.target.checked)} /> @@ -124,12 +159,24 @@ export function SaveToPlaylistDialog({ track, title, onClose }: { track: Track,
  • ))}
  • -
+ {'tracks' in props && ( +
+ +
+ )} ) } diff --git a/web/src/features/playlists/selection-bar.tsx b/web/src/features/playlists/selection-bar.tsx new file mode 100644 index 0000000..2b54563 --- /dev/null +++ b/web/src/features/playlists/selection-bar.tsx @@ -0,0 +1,36 @@ +import type { LucideIcon } from 'lucide-react' +import { ArrowDownToLine, ArrowUpToLine, ListChecks, ListPlus, ListX, Trash2, X } from 'lucide-react' +import { IconButton } from '@/shared/ui' + +// 窄屏只显示图标,按钮的名称要包含显示的文字。 +function BarButton({ icon: Icon, text, label, onClick }: { icon: LucideIcon, text: string, label: string, onClick: () => void }) { + return ( + + ) +} + +export function SelectionBar({ count, allSelected, onSave, onTop, onBottom, onRemove, onToggleAll, onClose }: { + count: number + allSelected: boolean + onSave: () => void + onTop: () => void + onBottom: () => void + onRemove: () => void + onToggleAll: () => void + onClose: () => void +}) { + return ( +
+ {`已选 ${count} 首`} + + + + + + +
+ ) +} diff --git a/web/src/features/playlists/track-order.test.ts b/web/src/features/playlists/track-order.test.ts new file mode 100644 index 0000000..f263dcb --- /dev/null +++ b/web/src/features/playlists/track-order.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { dropAt, moveByOne, moveTracks } from './track-order' + +const order = ['a', 'b', 'c', 'd', 'e'] + +// 与 internal/domain/playlist/order_test.go 的 TestMove 逐条对应。 +describe('moveTracks', () => { + const cases: { name: string, moved: string[], before?: string, want?: string[], error?: string }[] = [ + { name: '移到最前', moved: ['d'], before: 'a', want: ['d', 'a', 'b', 'c', 'e'] }, + { name: '移到最后', moved: ['b'], want: ['a', 'c', 'd', 'e', 'b'] }, + { name: '移到中间', moved: ['a'], before: 'd', want: ['b', 'c', 'a', 'd', 'e'] }, + { name: '多首保持给定先后', moved: ['e', 'b'], before: 'd', want: ['a', 'c', 'e', 'b', 'd'] }, + { name: '多首移到最后保持给定先后', moved: ['c', 'a'], want: ['b', 'd', 'e', 'c', 'a'] }, + { name: '忽略不存在的歌', moved: ['x', 'c', 'y'], before: 'a', want: ['c', 'a', 'b', 'd', 'e'] }, + { name: '重复的歌只算一次', moved: ['c', 'c', 'b'], want: ['a', 'd', 'e', 'c', 'b'] }, + { name: 'before 紧跟在移动的歌之后', moved: ['b'], before: 'c', want: order }, + { name: 'before 是被移动的歌', moved: ['b', 'c'], before: 'c', error: 'before 不能是要移动的歌' }, + { name: 'before 是不存在的被移动的歌', moved: ['x'], before: 'x', error: 'before 不能是要移动的歌' }, + { name: 'before 不存在', moved: ['b'], before: 'x', error: '歌单已经变了,刷新之后再试' }, + { name: '空移动', moved: [], before: 'c', want: order }, + { name: '移动的歌都不存在', moved: ['x'], before: 'y', want: order }, + ] + for (const { name, moved, before, want, error } of cases) { + it(name, () => { + const copy = [...order] + if (error) + expect(() => moveTracks(copy, moved, before)).toThrow(error) + else + expect(moveTracks(copy, moved, before)).toEqual(want) + expect(copy).toEqual(order) + }) + } +}) + +describe('dropAt', () => { + it('插入位置之后第一首不移动的歌作为 before', () => { + expect(dropAt(order, new Set(['d']), 1)).toEqual({ order: ['a', 'd', 'b', 'c', 'e'], moved: ['d'], before: 'b' }) + }) + + it('插入位置紧跟着要移动的歌时,跳过它们找 before', () => { + expect(dropAt(order, new Set(['b', 'e']), 1)).toEqual({ order: ['a', 'b', 'e', 'c', 'd'], moved: ['b', 'e'], before: 'c' }) + }) + + it('放到最后时不传 before', () => { + expect(dropAt(order, new Set(['a', 'c']), 5)).toEqual({ order: ['b', 'd', 'e', 'a', 'c'], moved: ['a', 'c'], before: undefined }) + }) + + it('多首按当前顺序一起移动', () => { + expect(dropAt(order, new Set(['e', 'b']), 0)).toEqual({ order: ['b', 'e', 'a', 'c', 'd'], moved: ['b', 'e'], before: 'a' }) + }) + + it('拖回原位置不必发请求', () => { + expect(dropAt(order, new Set(['c']), 2)).toBeNull() + expect(dropAt(order, new Set(['c']), 3)).toBeNull() + expect(dropAt(order, new Set(['b', 'c']), 2)).toBeNull() + }) + + it('全选时什么也不做', () => { + expect(dropAt(order, new Set(order), 0)).toBeNull() + expect(dropAt(order, new Set(order), 5)).toBeNull() + }) +}) + +describe('moveByOne', () => { + it('上移和下移一首', () => { + expect(moveByOne(order, new Set(['c']), -1)).toEqual({ order: ['a', 'c', 'b', 'd', 'e'], moved: ['c'], before: 'b' }) + expect(moveByOne(order, new Set(['c']), 1)).toEqual({ order: ['a', 'b', 'd', 'c', 'e'], moved: ['c'], before: 'e' }) + }) + + it('下移到最后时不传 before', () => { + expect(moveByOne(order, new Set(['d']), 1)).toEqual({ order: ['a', 'b', 'c', 'e', 'd'], moved: ['d'], before: undefined }) + }) + + it('相连的多首一起移动', () => { + expect(moveByOne(order, new Set(['b', 'c']), -1)).toEqual({ order: ['b', 'c', 'a', 'd', 'e'], moved: ['b', 'c'], before: 'a' }) + expect(moveByOne(order, new Set(['b', 'c']), 1)).toEqual({ order: ['a', 'd', 'b', 'c', 'e'], moved: ['b', 'c'], before: 'e' }) + }) + + it('不相连的多首先收拢', () => { + expect(moveByOne(order, new Set(['b', 'd']), -1)).toEqual({ order: ['b', 'd', 'a', 'c', 'e'], moved: ['b', 'd'], before: 'a' }) + expect(moveByOne(order, new Set(['b', 'd']), 1)).toEqual({ order: ['a', 'c', 'e', 'b', 'd'], moved: ['b', 'd'], before: undefined }) + }) + + it('已经在最前时上移只收拢', () => { + expect(moveByOne(order, new Set(['a', 'c']), -1)).toEqual({ order: ['a', 'c', 'b', 'd', 'e'], moved: ['a', 'c'], before: 'b' }) + }) + + it('已经到头时不动', () => { + expect(moveByOne(order, new Set(['a', 'b']), -1)).toBeNull() + expect(moveByOne(order, new Set(['e']), 1)).toBeNull() + expect(moveByOne(order, new Set(order), 1)).toBeNull() + }) + + it('要移动的歌都不在列表里时不动', () => { + expect(moveByOne(order, new Set(['x']), -1)).toBeNull() + }) +}) diff --git a/web/src/features/playlists/track-order.ts b/web/src/features/playlists/track-order.ts new file mode 100644 index 0000000..65b600b --- /dev/null +++ b/web/src/features/playlists/track-order.ts @@ -0,0 +1,50 @@ +// 与后端的 playlist.Move 一致:order 是显示顺序,把 moved 按给出的先后放到 before 之前,没有 before 时放到最后。 +// moved 里不在 order 中的忽略,重复的只算一次;都不在 order 中时原样返回,此时不检查 before 是否还在。 +export function moveTracks(order: readonly T[], moved: readonly T[], before?: T): T[] { + if (before !== undefined && moved.includes(before)) + throw new Error('before 不能是要移动的歌') + const present = new Set(order) + const block = [...new Set(moved)].filter(item => present.has(item)) + if (block.length === 0) + return [...order] + const blocked = new Set(block) + const rest = order.filter(item => !blocked.has(item)) + const at = before === undefined ? rest.length : rest.indexOf(before) + if (at < 0) + throw new Error('歌单已经变了,刷新之后再试') + return [...rest.slice(0, at), ...block, ...rest.slice(at)] +} + +// 一次移动请求:把 moved 按 order 中的先后放到 before 之前,结果是 order。 +export interface MovePlan { + order: T[] + moved: T[] + before: T | undefined +} + +// 把 moved 收拢成一段,放在其余的歌中第 at 首之前;顺序不变时返回 null,不必发请求。 +function place(order: readonly T[], moved: ReadonlySet, at: number): MovePlan | null { + const block = order.filter(item => moved.has(item)) + const before = order.filter(item => !moved.has(item))[at] + const next = moveTracks(order, block, before) + if (next.every((item, index) => item === order[index])) + return null + return { order: next, moved: block, before } +} + +// index 是插入位置,取 0 到 order.length,表示放在 order[index] 之前。before 取插入位置之后第一首不移动的歌。 +export function dropAt(order: readonly T[], moved: ReadonlySet, index: number): MovePlan | null { + return place(order, moved, order.slice(0, index).filter(item => !moved.has(item)).length) +} + +// 上移时整段放到第一首要移动的歌前面那首之前,下移时放到最后一首要移动的歌后面那首之后。 +// 不相连的歌会先收拢到一起,与拖动多首时一致,所以一次请求就能完成。 +export function moveByOne(order: readonly T[], moved: ReadonlySet, delta: -1 | 1): MovePlan | null { + const indexes = order.flatMap((item, index) => moved.has(item) ? [index] : []) + if (indexes.length === 0) + return null + const edge = delta < 0 ? indexes[0] : indexes[indexes.length - 1] + const restBefore = edge - indexes.filter(index => index < edge).length + const restCount = order.length - indexes.length + return place(order, moved, Math.min(restCount, Math.max(0, restBefore + delta))) +} diff --git a/web/src/features/playlists/track-selection.test.ts b/web/src/features/playlists/track-selection.test.ts new file mode 100644 index 0000000..beb5ac9 --- /dev/null +++ b/web/src/features/playlists/track-selection.test.ts @@ -0,0 +1,58 @@ +import type { Selection } from './track-selection' +import { describe, expect, it } from 'vitest' +import { noSelection, retain, selectAll, selectRange, toggle } from './track-selection' + +const order = ['a', 'b', 'c', 'd', 'e'] + +function keys(selection: Selection): string[] { + return [...selection.keys].sort() +} + +describe('track-selection', () => { + it('单击和 ⌘ 点击逐首切换,并把这首作为锚点', () => { + const one = toggle(noSelection, 'b') + expect(keys(one)).toEqual(['b']) + expect(one.anchor).toBe('b') + const two = toggle(one, 'd') + expect(keys(two)).toEqual(['b', 'd']) + const back = toggle(two, 'b') + expect(keys(back)).toEqual(['d']) + expect(back.anchor).toBe('b') + }) + + it('shift 点击把锚点到这首的一段加入选择,锚点不变', () => { + const selection = selectRange(toggle(toggle(noSelection, 'e'), 'b'), order, 'd') + expect(keys(selection)).toEqual(['b', 'c', 'd', 'e']) + expect(selection.anchor).toBe('b') + }) + + it('shift 点击锚点之前的歌时向上选', () => { + expect(keys(selectRange(toggle(noSelection, 'd'), order, 'b'))).toEqual(['b', 'c', 'd']) + }) + + it('没有锚点时 shift 点击只切换这首', () => { + const selection = selectRange(noSelection, order, 'c') + expect(keys(selection)).toEqual(['c']) + expect(selection.anchor).toBe('c') + }) + + it('锚点已经不在列表里时 shift 点击只切换这首', () => { + expect(keys(selectRange(toggle(noSelection, 'x'), order, 'c'))).toEqual(['c', 'x']) + }) + + it('全选和清空', () => { + expect(keys(selectAll(order))).toEqual(order) + expect(keys(noSelection)).toEqual([]) + }) + + it('列表变化后去掉不存在的歌和锚点', () => { + const selection = retain(toggle(toggle(noSelection, 'a'), 'c'), ['a', 'b']) + expect(keys(selection)).toEqual(['a']) + expect(selection.anchor).toBeNull() + }) + + it('列表变化不影响选择时返回原对象', () => { + const selection = toggle(noSelection, 'c') + expect(retain(selection, order)).toBe(selection) + }) +}) diff --git a/web/src/features/playlists/track-selection.ts b/web/src/features/playlists/track-selection.ts new file mode 100644 index 0000000..4b79f7d --- /dev/null +++ b/web/src/features/playlists/track-selection.ts @@ -0,0 +1,38 @@ +export interface Selection { + keys: ReadonlySet + // Shift 点击从这里选起:最后一次逐首切换的那首。 + anchor: string | null +} + +export const noSelection: Selection = { keys: new Set(), anchor: null } + +export function toggle(selection: Selection, key: string): Selection { + const keys = new Set(selection.keys) + if (!keys.delete(key)) + keys.add(key) + return { keys, anchor: key } +} + +// 把锚点到 key 的一段加入选择;没有锚点,或者锚点已经不在列表里时,只切换 key。 +export function selectRange(selection: Selection, order: readonly string[], key: string): Selection { + const from = selection.anchor === null ? -1 : order.indexOf(selection.anchor) + const to = order.indexOf(key) + if (from < 0 || to < 0) + return toggle(selection, key) + const range = order.slice(Math.min(from, to), Math.max(from, to) + 1) + return { keys: new Set([...selection.keys, ...range]), anchor: selection.anchor } +} + +export function selectAll(order: readonly string[]): Selection { + return { keys: new Set(order), anchor: null } +} + +// 列表重新读取之后去掉已经不在里面的歌;没有变化时返回原对象。 +export function retain(selection: Selection, order: readonly string[]): Selection { + const present = new Set(order) + const keys = [...selection.keys].filter(key => present.has(key)) + const anchor = selection.anchor !== null && present.has(selection.anchor) ? selection.anchor : null + if (keys.length === selection.keys.size && anchor === selection.anchor) + return selection + return { keys: new Set(keys), anchor } +} diff --git a/web/src/features/playlists/use-track-drag.ts b/web/src/features/playlists/use-track-drag.ts new file mode 100644 index 0000000..dea6290 --- /dev/null +++ b/web/src/features/playlists/use-track-drag.ts @@ -0,0 +1,180 @@ +import type { PointerEvent as ReactPointerEvent, RefObject } from 'react' +import type { MovePlan } from './track-order' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { dropAt } from './track-order' + +// 按下之后移动超过这个距离才算拖动,否则是点击。 +const dragThreshold = 4 +const scrollZone = 56 +const maxScrollStep = 18 + +function scrollParent(element: HTMLElement): HTMLElement { + for (let node = element.parentElement; node; node = node.parentElement) { + const { overflowY } = getComputedStyle(node) + if (overflowY === 'auto' || overflowY === 'scroll') + return node + } + return document.documentElement +} + +// 插入位置:指针在第几行的上半部分之上,取 0 到行数。 +function insertionIndex(rows: readonly HTMLElement[], y: number): number { + let low = 0 + let high = rows.length + while (low < high) { + const middle = (low + high) >> 1 + const rect = rows[middle].getBoundingClientRect() + if (y < rect.top + rect.height / 2) + high = middle + else + low = middle + 1 + } + return low +} + +// 拖动结束时,按下和松开的位置不在同一行,浏览器仍会派发 click:吞掉它,免得切换选中。 +function swallowNextClick(): void { + const swallow = (event: MouseEvent) => { + event.stopPropagation() + event.preventDefault() + } + window.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0) +} + +// 鼠标按住行的任意位置都能拖,触屏只能拖把手,行的其余部分留给滚动。 +// 插入线和指针旁的标签每一帧直接改样式,不经过 React,拖动时整张列表不会重新渲染。 +export function useTrackDrag({ listRef, lineRef, labelRef, keys, selected, onDrop }: { + listRef: RefObject + lineRef: RefObject + labelRef: RefObject + keys: readonly string[] + selected: ReadonlySet + onDrop: (plan: MovePlan | null) => void +}) { + const [dragging, setDragging] = useState | null>(null) + const latestRef = useRef({ keys, selected, onDrop }) + useLayoutEffect(() => { + latestRef.current = { keys, selected, onDrop } + }) + const stopRef = useRef<(() => void) | null>(null) + useEffect(() => () => stopRef.current?.(), []) + + const onPointerDown = (event: ReactPointerEvent, key: string) => { + const target = event.target as Element + if (event.button !== 0 || stopRef.current || target.closest('button, a')) + return + if (event.pointerType !== 'mouse' && !target.closest('[data-drag-handle]')) + return + const list = listRef.current + if (!list) + return + const { pointerId } = event + const scroller = scrollParent(list) + const start = { x: event.clientX, y: event.clientY } + const pointer = { ...start } + let moved: ReadonlySet | null = null + let frame = 0 + + const rows = () => [...list.querySelectorAll('[data-key]')] + + const autoScroll = () => { + const rect = scroller === document.documentElement ? new DOMRect(0, 0, innerWidth, innerHeight) : scroller.getBoundingClientRect() + const top = Math.max(rect.top, 0) + const bottom = Math.min(rect.bottom, innerHeight) + const into = pointer.y < top + scrollZone ? pointer.y - top - scrollZone : pointer.y > bottom - scrollZone ? pointer.y - bottom + scrollZone : 0 + if (into !== 0) + scroller.scrollTop += Math.max(-1, Math.min(1, into / scrollZone)) * maxScrollStep + } + + const paint = () => { + autoScroll() + const all = rows() + const index = insertionIndex(all, pointer.y) + const line = lineRef.current + if (line && all.length > 0) { + const plan = moved && dropAt(latestRef.current.keys, moved, index) + const edge = index < all.length ? all[index].getBoundingClientRect().top : all[all.length - 1].getBoundingClientRect().bottom + line.style.transform = `translateY(${edge - list.getBoundingClientRect().top}px)` + line.style.visibility = plan ? 'visible' : 'hidden' + } + const label = labelRef.current + if (label) { + label.style.transform = `translate(${pointer.x + 14}px, ${pointer.y + 12}px)` + label.style.visibility = 'visible' + } + frame = requestAnimationFrame(paint) + } + + function onMove(move: PointerEvent) { + if (move.pointerId !== pointerId) + return + pointer.x = move.clientX + pointer.y = move.clientY + if (moved || Math.hypot(pointer.x - start.x, pointer.y - start.y) <= dragThreshold) + return + const { selected } = latestRef.current + moved = selected.has(key) ? selected : new Set([key]) + setDragging(moved) + window.addEventListener('keydown', onKey, true) + frame = requestAnimationFrame(paint) + } + + function endVisuals() { + cancelAnimationFrame(frame) + window.removeEventListener('keydown', onKey, true) + setDragging(null) + } + + function onUp(up: PointerEvent) { + if (up.pointerId !== pointerId) + return + const dropped = moved + stop() + if (!dropped) + return + swallowNextClick() + latestRef.current.onDrop(dropAt(latestRef.current.keys, dropped, insertionIndex(rows(), up.clientY))) + } + + function onCancel(cancel: PointerEvent) { + if (cancel.pointerId === pointerId) + stop() + } + + // 按 Esc 取消之后指针仍然按着,松开时的 click 同样要吞掉。 + function onKey(keyboard: KeyboardEvent) { + if (keyboard.key !== 'Escape') + return + keyboard.preventDefault() + keyboard.stopPropagation() + endVisuals() + document.removeEventListener('pointermove', onMove) + document.removeEventListener('pointerup', onUp) + document.addEventListener('pointerup', onCancelledUp) + } + + function onCancelledUp(up: PointerEvent) { + if (up.pointerId !== pointerId) + return + stop() + swallowNextClick() + } + + function stop() { + endVisuals() + document.removeEventListener('pointermove', onMove) + document.removeEventListener('pointerup', onUp) + document.removeEventListener('pointerup', onCancelledUp) + document.removeEventListener('pointercancel', onCancel) + stopRef.current = null + } + + document.addEventListener('pointermove', onMove) + document.addEventListener('pointerup', onUp) + document.addEventListener('pointercancel', onCancel) + stopRef.current = stop + } + + return { dragging, onPointerDown } +}