From 5ca315f22684bfca2e61a8b992da4ec56e244faf Mon Sep 17 00:00:00 2001
From: DiheChen
Date: Mon, 9 Nov 2026 02:14:21 +0800
Subject: [PATCH] feat: select several playlist songs to remove, save or
reorder them and drag songs into place
---
.../features/playlists/my-playlist-page.tsx | 220 ++++++++++++++----
.../features/playlists/playlist-tracks.tsx | 151 ++++++++++++
web/src/features/playlists/playlists.css | 36 ++-
.../features/playlists/save-to-playlist.tsx | 65 +++++-
web/src/features/playlists/selection-bar.tsx | 36 +++
.../features/playlists/track-order.test.ts | 97 ++++++++
web/src/features/playlists/track-order.ts | 50 ++++
.../playlists/track-selection.test.ts | 58 +++++
web/src/features/playlists/track-selection.ts | 38 +++
web/src/features/playlists/use-track-drag.ts | 180 ++++++++++++++
10 files changed, 871 insertions(+), 60 deletions(-)
create mode 100644 web/src/features/playlists/playlist-tracks.tsx
create mode 100644 web/src/features/playlists/selection-bar.tsx
create mode 100644 web/src/features/playlists/track-order.test.ts
create mode 100644 web/src/features/playlists/track-order.ts
create mode 100644 web/src/features/playlists/track-selection.test.ts
create mode 100644 web/src/features/playlists/track-selection.ts
create mode 100644 web/src/features/playlists/use-track-drag.ts
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 && }
+
+ {items && items.length > 0 && (
+
+ )}
+ {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 (
+ - 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 => (
+
+ ))}
+
+
+
+
+ )
+ })}
+
+ {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 && (
+
+ void save(props.from, props.tracks, rows.filter(p => picked.has(p.id)))}
+ >
+ 保存
+
+
+ )}
>
)
}
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 (
+
+
+ {text}
+
+ )
+}
+
+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 }
+}