From 0f84f11fd2de52998532ef72435390f2b6388039 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Tue, 10 Nov 2026 10:33:17 +0800 Subject: [PATCH] feat: import playlists from files, links and search results --- web/src/features/playlists/account-prompt.tsx | 21 +++ .../features/playlists/account-settings.ts | 2 +- web/src/features/playlists/action-menu.tsx | 129 ++++++++++++++++++ web/src/features/playlists/import-menu.tsx | 112 +++++++++++++++ web/src/features/playlists/index.ts | 1 + .../features/playlists/my-playlist-page.tsx | 22 ++- web/src/features/playlists/playlist-menu.tsx | 118 +++------------- web/src/features/playlists/playlists-page.tsx | 12 +- web/src/features/playlists/playlists.css | 2 +- .../playlists/save-platform-playlist.tsx | 62 +++++++++ .../features/playlists/save-to-playlist.tsx | 20 +-- web/src/features/search/search-page.tsx | 5 +- web/src/features/search/search.css | 2 + 13 files changed, 382 insertions(+), 126 deletions(-) create mode 100644 web/src/features/playlists/account-prompt.tsx create mode 100644 web/src/features/playlists/action-menu.tsx create mode 100644 web/src/features/playlists/import-menu.tsx create mode 100644 web/src/features/playlists/save-platform-playlist.tsx diff --git a/web/src/features/playlists/account-prompt.tsx b/web/src/features/playlists/account-prompt.tsx new file mode 100644 index 0000000..9bf1052 --- /dev/null +++ b/web/src/features/playlists/account-prompt.tsx @@ -0,0 +1,21 @@ +import { openAccountSettings } from './account-settings' + +export function AccountPrompt({ message, onClose }: { message: string, onClose: () => void }) { + return ( + <> +

{message}

+
+ +
+ + ) +} diff --git a/web/src/features/playlists/account-settings.ts b/web/src/features/playlists/account-settings.ts index 0905973..6a23df0 100644 --- a/web/src/features/playlists/account-settings.ts +++ b/web/src/features/playlists/account-settings.ts @@ -1,4 +1,4 @@ -// 游客在保存对话框里点“去账号设置”时打开账号设置。设置页在哪里、怎样打开由应用壳决定,启动时设置。 +// 游客在保存歌曲或歌单的对话框里点“去账号设置”时打开账号设置。设置页在哪里、怎样打开由应用壳决定,启动时设置。 let open = (): void => {} export function openAccountSettings(): void { diff --git a/web/src/features/playlists/action-menu.tsx b/web/src/features/playlists/action-menu.tsx new file mode 100644 index 0000000..24cf725 --- /dev/null +++ b/web/src/features/playlists/action-menu.tsx @@ -0,0 +1,129 @@ +import type { LucideIcon } from 'lucide-react' +import type { KeyboardEvent, ReactNode, ToggleEvent } from 'react' +import { useEffect, useId, useRef, useState } from 'react' + +const menuGap = 4 +const viewportMargin = 8 + +const menuKeys: Record number> = { + ArrowDown: (index, count) => (index + 1) % count, + ArrowUp: (index, count) => (index - 1 + count) % count, + Home: () => 0, + End: (_, count) => count - 1, +} + +export interface MenuAction { + icon: LucideIcon + label: string + danger?: boolean + run: () => void +} + +export function ActionMenu({ actions, menuLabel, align = 'end', className, label, title, disabled, children }: { + actions: MenuAction[] + menuLabel: string + align?: 'start' | 'end' + className: string + label?: string + title?: string + disabled?: boolean + children: ReactNode +}) { + const menuId = useId() + const buttonRef = useRef(null) + const menuRef = useRef(null) + const [open, setOpen] = useState(false) + + // 菜单在顶层按视口定位,不跟随页面滚动,所以滚动和缩放窗口时收起。 + 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('[role="menuitem"]')] + + // 显示之前量不到菜单的尺寸:先贴着按钮下方对齐,显示之后超出视口再翻到按钮上方、收进视口。 + const place = (event: ToggleEvent) => { + 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) => { + 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) => { + 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() + } + + return ( + <> + + + + ) +} diff --git a/web/src/features/playlists/import-menu.tsx b/web/src/features/playlists/import-menu.tsx new file mode 100644 index 0000000..fd4b246 --- /dev/null +++ b/web/src/features/playlists/import-menu.tsx @@ -0,0 +1,112 @@ +import type { MyPlaylist } from '@/core/realtime' +import { FileUp, Import, Link } from 'lucide-react' +import { useRef, useState } from 'react' +import { Modal, notificationStore } from '@/shared/ui' +import { ActionMenu } from './action-menu' +import { importLimit, parsePlaylistFile } from './playlist-file' +import { playlistsStore } from './playlists-store' +import { defaultImportName } from './save-platform-playlist' + +async function importFile(file: File): Promise { + const content = await file.text().catch(() => null) + const parsed = content === null ? '没能读取这个文件。' : parsePlaylistFile(content) + if (typeof parsed === 'string') { + notificationStore.error(parsed) + return + } + const result = await playlistsStore.importFile(parsed.name, parsed.tracks) + if (!result) + return + const done = `已导入 ${result.imported} 首到“${result.playlist.name}”` + if (parsed.truncated) + notificationStore.success(`${done}。文件里超过 ${importLimit} 首,只导入了前 ${importLimit} 首。`, 6000) + else + notificationStore.success(done) +} + +export function ImportMenu({ onOpen }: { onOpen: (playlist: MyPlaylist) => void }) { + const fileRef = useRef(null) + const [importing, setImporting] = useState(false) + const [linking, setLinking] = useState(false) + return ( + <> + fileRef.current!.click() }, + { icon: Link, label: '从链接导入', run: () => setLinking(true) }, + ]} + menuLabel="导入歌单" + className="button-secondary" + disabled={importing} + > + + { + const file = event.target.files?.[0] + // 清空之后,再选同一个文件也会触发 change。 + event.target.value = '' + if (!file) + return + setImporting(true) + await importFile(file) + setImporting(false) + }} + /> + {linking && ( + setLinking(false)} + onImported={(playlist) => { + setLinking(false) + onOpen(playlist) + }} + /> + )} + + ) +} + +function ImportLinkDialog({ onClose, onImported }: { onClose: () => void, onImported: (playlist: MyPlaylist) => void }) { + const [link, setLink] = useState('') + const [name, setName] = useState(defaultImportName) + const [busy, setBusy] = useState(false) + return ( + +
{ + event.preventDefault() + if (busy) + return + setBusy(true) + const found = await playlistsStore.resolveLink(link) + const result = found && await playlistsStore.importPlatform(name, found.source, found.playlistId) + if (!result) { + setBusy(false) + return + } + notificationStore.success(`已导入 ${result.imported} 首`) + onImported(result.playlist) + }} + > + + +
+ + +
+
+
+ ) +} diff --git a/web/src/features/playlists/index.ts b/web/src/features/playlists/index.ts index 87aee2b..36a0673 100644 --- a/web/src/features/playlists/index.ts +++ b/web/src/features/playlists/index.ts @@ -3,6 +3,7 @@ import { lazyPage } from '@/shared/ui' export { setAccountSettingsOpener } from './account-settings' export { playlistsStore } from './playlists-store' export { saveToPlaylistLabel } from './save-label' +export { SavePlatformPlaylistButton } from './save-platform-playlist' 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)) diff --git a/web/src/features/playlists/my-playlist-page.tsx b/web/src/features/playlists/my-playlist-page.tsx index c054b67..65463d2 100644 --- a/web/src/features/playlists/my-playlist-page.tsx +++ b/web/src/features/playlists/my-playlist-page.tsx @@ -9,9 +9,10 @@ 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, Modal, Skeleton } from '@/shared/ui' +import { EmptyState, Modal, notificationStore, Skeleton } from '@/shared/ui' import { FavoritesMark, PlaylistCover } from './playlist-cover' import { formatPlaylistDate } from './playlist-date' +import { exportFileName, exportPlaylist } from './playlist-file' import { PlaylistMenu } from './playlist-menu' import { PlaylistTracks } from './playlist-tracks' import { playlistsStore, trackKey } from './playlists-store' @@ -27,6 +28,23 @@ function keysOf(items: readonly MyPlaylistItem[]): string[] { return items.map(item => trackKey(item.track)) } +// 导出服务端的曲目:页面上的曲目可能还没读到,或者带着没同步完的移动和移出。 +async function exportToFile(playlist: MyPlaylist): Promise { + try { + const { items } = await api.listMyPlaylistItems(playlist.id) + const url = URL.createObjectURL(new Blob([exportPlaylist(playlist.name, items, Date.now())], { type: 'application/json' })) + const link = document.createElement('a') + link.href = url + link.download = exportFileName(playlist.name) + link.click() + // 下载在 click() 时已经开始,到下一个任务再释放对象 URL。 + setTimeout(() => URL.revokeObjectURL(url)) + } + catch (error) { + notificationStore.error(`没能导出:${errorMessage(error)}`) + } +} + 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) @@ -224,7 +242,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void {selecting ? '完成' : '选择'} )} - {playlist && !playlist.favorites && } + {playlist && void exportToFile(playlist)} />} diff --git a/web/src/features/playlists/playlist-menu.tsx b/web/src/features/playlists/playlist-menu.tsx index 6a12049..203ce68 100644 --- a/web/src/features/playlists/playlist-menu.tsx +++ b/web/src/features/playlists/playlist-menu.tsx @@ -1,118 +1,38 @@ -import type { KeyboardEvent, ToggleEvent } from 'react' +import type { MenuAction } from './action-menu' import type { MyPlaylist } from '@/core/realtime' -import { Ellipsis, Pencil, Trash2 } from 'lucide-react' -import { useEffect, useId, useRef, useState } from 'react' +import { Ellipsis, FileDown, Pencil, Trash2 } from 'lucide-react' +import { useState } from 'react' import { Modal } from '@/shared/ui' +import { ActionMenu } from './action-menu' import { playlistsStore } from './playlists-store' -const menuGap = 4 -const viewportMargin = 8 - -const menuKeys: Record 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 }: { +export function PlaylistMenu({ playlist, align = 'end', className = '', onDeleted, onExport }: { playlist: MyPlaylist align?: 'start' | 'end' className?: string onDeleted?: () => void + onExport?: () => void }) { - const menuId = useId() - const buttonRef = useRef(null) - const menuRef = useRef(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('[role="menuitem"]')] - - // 显示之前量不到菜单的尺寸:先贴着按钮下方对齐,显示之后超出视口再翻到按钮上方、收进视口。 - const place = (event: ToggleEvent) => { - 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) => { - 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) => { - 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) - } - + const actions: MenuAction[] = [] + if (!playlist.favorites) + actions.push({ icon: Pencil, label: '重命名', run: () => setDialog('rename') }) + if (onExport) + actions.push({ icon: FileDown, label: '导出', run: onExport }) + if (!playlist.favorites) + actions.push({ icon: Trash2, label: '删除', danger: true, run: () => setDialog('delete') }) return ( <> - - + {dialog === 'rename' && setDialog(null)} />} {dialog === 'delete' && setDialog(null)} onDeleted={onDeleted} />} diff --git a/web/src/features/playlists/playlists-page.tsx b/web/src/features/playlists/playlists-page.tsx index eeaab4e..221e86c 100644 --- a/web/src/features/playlists/playlists-page.tsx +++ b/web/src/features/playlists/playlists-page.tsx @@ -9,6 +9,7 @@ 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 { ImportMenu } from './import-menu' import { FavoritesMark, PlaylistCover } from './playlist-cover' import { formatPlaylistDate } from './playlist-date' import { PlaylistMenu } from './playlist-menu' @@ -145,10 +146,13 @@ export function PlaylistsPage({ onOpen, onRegister }: { onOpen: (playlist: MyPla /> - +
+ + +
)} diff --git a/web/src/features/playlists/playlists.css b/web/src/features/playlists/playlists.css index 47efe61..caf7f62 100644 --- a/web/src/features/playlists/playlists.css +++ b/web/src/features/playlists/playlists.css @@ -48,7 +48,7 @@ .playlists-error { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; } .playlists-heading p { margin-top: 4px; } .playlists-controls { grid-area: controls; display: flex; align-items: center; gap: 8px; } -.playlists-create { grid-area: create; } +.playlists-actions { grid-area: create; display: flex; gap: 8px; } .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; } diff --git a/web/src/features/playlists/save-platform-playlist.tsx b/web/src/features/playlists/save-platform-playlist.tsx new file mode 100644 index 0000000..2f96dfb --- /dev/null +++ b/web/src/features/playlists/save-platform-playlist.tsx @@ -0,0 +1,62 @@ +import type { Source } from '@/core/realtime' +import { ListPlus } from 'lucide-react' +import { useState } from 'react' +import { profileStore } from '@/features/settings' +import { useStore } from '@/shared/lib/use-store' +import { Modal, notificationStore } from '@/shared/ui' +import { AccountPrompt } from './account-prompt' +import { playlistsStore } from './playlists-store' + +export const defaultImportName = '导入的歌单' + +export function SavePlatformPlaylistButton({ source, playlistId, name }: { source: Source, playlistId: string, name?: string }) { + const [open, setOpen] = useState(false) + return ( + <> + + {open && setOpen(false)} />} + + ) +} + +function SavePlatformPlaylistDialog({ source, playlistId, name: original, onClose }: { source: Source, playlistId: string, name: string, onClose: () => void }) { + const account = useStore(profileStore, s => s.account) + const [name, setName] = useState(original) + const [busy, setBusy] = useState(false) + return ( + + {account + ? ( +
{ + event.preventDefault() + if (busy) + return + setBusy(true) + const result = await playlistsStore.importPlatform(name, source, playlistId) + if (!result) { + setBusy(false) + return + } + notificationStore.success(`已保存为“${result.playlist.name}”,共 ${result.imported} 首`) + onClose() + }} + > + +
+ + +
+
+ ) + : } +
+ ) +} diff --git a/web/src/features/playlists/save-to-playlist.tsx b/web/src/features/playlists/save-to-playlist.tsx index 86b9b62..31931c4 100644 --- a/web/src/features/playlists/save-to-playlist.tsx +++ b/web/src/features/playlists/save-to-playlist.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react' import { profileStore } from '@/features/settings' import { useStore } from '@/shared/lib/use-store' import { IconButton, Modal, notificationStore, Skeleton } from '@/shared/ui' -import { openAccountSettings } from './account-settings' +import { AccountPrompt } from './account-prompt' import { CreatePlaylistForm } from './create-playlist-form' import { FavoritesMark, PlaylistCover } from './playlist-cover' import { sortPlaylists } from './playlist-sort' @@ -104,23 +104,7 @@ export function SaveToPlaylistDialog(props: SaveToPlaylistProps) { let body if (!account) { - body = ( - <> -

注册或登录之后就能把歌保存到歌单。

-
- -
- - ) + body = } else if (readFailed || (playlists === null && error !== null)) { body = ( diff --git a/web/src/features/search/search-page.tsx b/web/src/features/search/search-page.tsx index 16cfb87..67554a0 100644 --- a/web/src/features/search/search-page.tsx +++ b/web/src/features/search/search-page.tsx @@ -5,6 +5,7 @@ import type { SongItem } from '@/shared/ui' import { ArrowLeft, ArrowRight, CirclePlus, Clock3, Search, Sparkles } from 'lucide-react' import { roomStore, sameTrack } from '@/core/room' import { historyStore } from '@/features/history' +import { SavePlatformPlaylistButton } from '@/features/playlists' import { enqueue } from '@/features/queue' import { bookTitle } from '@/shared/lib/book-title' import { useStore } from '@/shared/lib/use-store' @@ -346,6 +347,7 @@ export function SearchPage({ inlineSearchClassName, onSearch, onOpenPlaylist }: } export function PlaylistPage({ title, page, onPage }: { title: string | undefined, page: number, onPage: (page: number) => void }) { + const playlist = useStore(searchStore, s => s.playlist) const tracks = useStore(searchStore, s => s.tracks) const total = useStore(searchStore, s => s.tracksTotal) const loading = useStore(searchStore, s => s.loading) @@ -353,8 +355,9 @@ export function PlaylistPage({ title, page, onPage }: { title: string | undefine const connected = useStore(roomStore, s => s.status === 'open') return (
-
+

{title ?? '歌单'}

+ {playlist && }
{failed && (
diff --git a/web/src/features/search/search.css b/web/src/features/search/search.css index 4b589e3..2b3a8fc 100644 --- a/web/src/features/search/search.css +++ b/web/src/features/search/search.css @@ -24,6 +24,8 @@ .search-source .select-trigger { padding: 7px 12px; border-radius: 999px; background: transparent; } .search-page-heading { margin: 16px 0 28px; } .search-page-heading p { margin-top: 6px; overflow-wrap: anywhere; } +.search-playlist-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; } +.search-playlist-heading h1 { min-width: 0; overflow-wrap: anywhere; } .search-recommend-hint { display: none; align-items: center; gap: 6px; } .search-recommend-hint > svg { color: var(--color-brand); } .search-page .section-heading { margin-bottom: 16px; }