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}
+ >
+
+ {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 (
+
+
+
+ )
+}
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
+ ? (
+
+ )
+ : }
+
+ )
+}
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; }