feat: open shared playlist links to request, save and copy their songs

This commit is contained in:
DiheChen committed 2026-11-11 00:10:12 +08:00
1 parent 1f99bd939b
commit 7863c02170
9 files changed
+237 -3

No files matched your search

+4
View File
@@ -30,6 +30,10 @@ server {
add_header Cache-Control "no-cache";
try_files /index.html =404;
}
location ~ ^/s/[^/]+$ {
add_header Cache-Control "no-cache";
try_files /index.html =404;
}
location ~ ^/.+/$ {
return 404;
+10 -1
View File
@@ -8,7 +8,7 @@ import { ChatPanel } from '@/features/chat'
import { HistoryPage } from '@/features/history'
import { ListenPage } from '@/features/lyrics'
import { AudioEngine, ManualStartPrompt, PlayerBar, playerStore } from '@/features/playback'
import { MyPlaylistPage, PlaylistsPage } from '@/features/playlists'
import { MyPlaylistPage, PlaylistsPage, SharedPlaylistPage } from '@/features/playlists'
import { QueuePage, voteSkip } from '@/features/queue'
import { copyInviteLink, EntryDialog, Lobby, roomEntryStore, RoomGate } from '@/features/rooms'
import { HeaderSearch, PlaylistPage, SearchPage, searchStore } from '@/features/search'
@@ -52,6 +52,7 @@ const pageClasses: Partial<Record<Page, string>> = {
'playlists': 'scrollbar-hidden',
'my-playlist': 'scrollbar-hidden',
'settings': 'settings-view',
'shared-playlist': 'scrollbar-hidden',
}
function closeMenu(element: HTMLElement): void {
@@ -222,6 +223,13 @@ function RoutedPlaylist() {
return <PlaylistPage title={title} page={route.page} onPage={page => navigationStore.navigate({ ...route, page })} />
}
function RoutedSharedPlaylist() {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'shared-playlist')
return null
return <SharedPlaylistPage shareId={route.shareId} />
}
export function App() {
const page = useStore(navigationStore, s => s.route.name)
const panel = useStore(navigationStore, s => s.route.panel)
@@ -280,6 +288,7 @@ export function App() {
)}
{page === 'my-playlist' && <RoutedMyPlaylist />}
{page === 'settings' && <SettingsPage />}
{page === 'shared-playlist' && <RoutedSharedPlaylist />}
</Suspense>
</ErrorBoundary>
</main>
+8 -1
View File
@@ -27,6 +27,8 @@ describe('parseRoute', () => {
['/r/my-room/search?source=netease&view=tracks', { name: 'search', ref: 'my-room', panel: null, query: { view: 'tracks', keyword: '', source: 'netease', page: 1 } }],
['/r/my-room/search?view=recommend', { name: 'search', ref: 'my-room', panel: null, query: { view: 'recommend' } }],
['/r/my-room/playlists/netease/123?page=3&panel=queue', { name: 'playlist', ref: 'my-room', panel: 'queue', source: 'netease', id: '123', page: 3 }],
['/s/AbC_-1x', { name: 'shared-playlist', shareId: 'AbC_-1x', panel: null }],
['/s/AbC_-1x?panel=queue', { name: 'shared-playlist', shareId: 'AbC_-1x', panel: 'queue' }],
])('%s', (url, route) => {
expect(parse(url)).toEqual(route)
expect(formatRoute(route)).toBe(url)
@@ -49,7 +51,7 @@ describe('parseRoute', () => {
expect(route && formatRoute(route)).toBe(canonical)
})
it.each(['/r/', '/r/my-room/listen', '/r/my-room/queue/extra', '/settings/profile', '/r/my-room/playlists/qq/123', '/r/my-room/playlists/netease'])('%s 不对应任何页面', (url) => {
it.each(['/r/', '/r/my-room/listen', '/r/my-room/queue/extra', '/settings/profile', '/r/my-room/playlists/qq/123', '/r/my-room/playlists/netease', '/s/', '/s/abc/def'])('%s 不对应任何页面', (url) => {
expect(parse(url)).toBeNull()
})
})
@@ -88,6 +90,11 @@ describe('isNewEntry', () => {
expect(isNewEntry(playlist('1', 1), playlist('2', 1))).toBe(true)
expect(isNewEntry(playlist('1', 1), playlist('1', 2))).toBe(false)
})
it('打开另一个分享写入新记录,同一个分享只换侧栏时替换当前地址', () => {
expect(isNewEntry({ name: 'shared-playlist', shareId: 'a', panel: null }, { name: 'shared-playlist', shareId: 'b', panel: null })).toBe(true)
expect(isNewEntry({ name: 'shared-playlist', shareId: 'a', panel: null }, { name: 'shared-playlist', shareId: 'a', panel: 'queue' })).toBe(false)
})
})
describe('leaveStep', () => {
+8
View File
@@ -17,6 +17,7 @@ export type Route
| { name: RoomPage, ref: string, panel: Panel | null }
| { name: 'search', ref: string, panel: Panel | null, query: SearchQuery }
| { name: 'playlist', ref: string, panel: Panel | null, source: Source, id: string, page: number }
| { name: 'shared-playlist', shareId: string, panel: Panel | null }
export type Page = Route['name']
@@ -75,6 +76,9 @@ export function parseRoute({ pathname, search }: Pick<Location, 'pathname' | 'se
const own = /^\/playlists\/([^/]+)$/.exec(pathname)
if (own)
return { name: 'my-playlist', id: own[1], panel }
const shared = /^\/s\/([^/]+)$/.exec(pathname)
if (shared)
return { name: 'shared-playlist', shareId: shared[1], panel }
// 不做 decodeURIComponent:UUID、别名和歌单 ID 不含需要转义的字符,而它遇到不合法的转义序列会抛出异常。
const room = /^\/r\/([^/]+)(\/.*)?$/.exec(pathname)
if (!room)
@@ -112,6 +116,8 @@ function pathOf(route: Route): string {
return `/r/${route.ref}`
case 'playlist':
return `/r/${route.ref}/playlists/${route.source}/${route.id}`
case 'shared-playlist':
return `/s/${route.shareId}`
default:
return `/r/${route.ref}/${route.name}`
}
@@ -142,6 +148,8 @@ export function isNewEntry(from: Route, to: Route): boolean {
return to.query.view !== 'recommend' && to.query.keyword !== (from.query.view === 'recommend' ? '' : from.query.keyword)
if (from.name === 'playlist' && to.name === 'playlist')
return to.source !== from.source || to.id !== from.id
if (from.name === 'shared-playlist' && to.name === 'shared-playlist')
return from.shareId !== to.shareId
return false
}
@@ -0,0 +1,59 @@
import { CopyPlus } 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 function CopySharedPlaylistButton({ shareId, name }: { shareId: string, name: string }) {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" className="button-primary" onClick={() => setOpen(true)}>
<CopyPlus size={18} strokeWidth={1.7} aria-hidden="true" />
复制到我的歌单
</button>
{open && <CopySharedPlaylistDialog shareId={shareId} name={name} onClose={() => setOpen(false)} />}
</>
)
}
function CopySharedPlaylistDialog({ shareId, name: original, onClose }: { shareId: string, name: string, onClose: () => void }) {
const account = useStore(profileStore, s => s.account)
const [name, setName] = useState(original)
const [busy, setBusy] = useState(false)
return (
<Modal title="复制到我的歌单" onClose={onClose}>
{account
? (
<form
className="form-stack"
onSubmit={async (event) => {
event.preventDefault()
if (busy)
return
setBusy(true)
const playlist = await playlistsStore.copyShared(shareId, name)
if (!playlist) {
setBusy(false)
return
}
notificationStore.success(`已复制为“${playlist.name}”,共 ${playlist.trackCount} 首`)
onClose()
}}
>
<label>
歌单名
<input data-autofocus required maxLength={50} value={name} onChange={event => setName(event.target.value)} />
</label>
<div className="dialog-actions">
<button type="button" className="button-secondary" onClick={onClose}>取消</button>
<button type="submit" className="button-primary" disabled={busy || name.trim() === ''}>{busy ? '正在复制…' : '复制'}</button>
</div>
</form>
)
: <AccountPrompt message="注册或登录之后就能把这个歌单复制到自己的歌单。" onClose={onClose} />}
</Modal>
)
}
+1
View File
@@ -7,3 +7,4 @@ 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))
export const SharedPlaylistPage = lazyPage(() => import('./shared-playlist-page').then(module => module.SharedPlaylistPage))
+3
View File
@@ -113,6 +113,9 @@
.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; }
.shared-playlist-owner { display: flex; align-items: center; gap: 8px; max-width: 100%; }
.shared-playlist-owner > .avatar { width: 24px; }
.shared-playlist-owner > span:not(.avatar) { min-width: 0; }
.playlist-tracks { position: relative; }
.playlist-track { user-select: none; -webkit-user-select: none; }
@@ -0,0 +1,143 @@
import type { MyPlaylistItem, SharedPlaylist, Track } from '@/core/realtime'
import type { SongAction, SongItem } from '@/shared/ui'
import { CirclePlus, Link2Off, ListMusic, ListPlus } from 'lucide-react'
import { useEffect, useState } from 'react'
import { api, ApiError } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { roomStore, sameTrack } from '@/core/room'
import { enqueue, sourceName } from '@/features/queue'
import { bookTitle } from '@/shared/lib/book-title'
import { useStore } from '@/shared/lib/use-store'
import { Avatar, EmptyState, Skeleton, SongList } from '@/shared/ui'
import { CopySharedPlaylistButton } from './copy-shared-playlist'
import { FavoritesMark, PlaylistCover } from './playlist-cover'
import { trackKey } from './playlists-store'
import { saveToPlaylistLabel } from './save-label'
import { SaveToPlaylistDialog } from './save-to-playlist'
import './playlists.css'
// 带上读取的分享 ID:换到另一个分享时,上一个的结果不再显示。
type Result = { shareId: string } & ({ kind: 'loaded', playlist: SharedPlaylist } | { kind: 'gone' } | { kind: 'failed', message: string })
function songItem({ track, meta }: MyPlaylistItem, playing: Track | undefined): SongItem {
return {
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),
}
}
export function SharedPlaylistPage({ shareId }: { shareId: string }) {
const connected = useStore(roomStore, s => s.status === 'open')
const ephemeral = useStore(roomStore, s => s.room?.room.kind === 'ephemeral')
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
const [result, setResult] = useState<Result | null>(null)
const [attempt, setAttempt] = useState(0)
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
useEffect(() => {
let active = true
api.getSharedPlaylist(shareId).then((playlist) => {
if (active)
setResult({ shareId, kind: 'loaded', playlist })
}, (error: unknown) => {
if (!active)
return
setResult(error instanceof ApiError && error.code === 'NOT_FOUND'
? { shareId, kind: 'gone' }
: { shareId, kind: 'failed', message: errorMessage(error) })
})
return () => {
active = false
}
}, [shareId, attempt])
const current = result?.shareId === shareId ? result : null
if (!current) {
return <section className="page-content my-playlist-page"><Skeleton rows={6} /></section>
}
if (current.kind === 'gone') {
return (
<section className="page-content my-playlist-page">
<EmptyState icon={Link2Off} title="分享已失效">
歌单被删除了,或者分享的人停止了分享。
</EmptyState>
</section>
)
}
if (current.kind === 'failed') {
return (
<section className="page-content my-playlist-page">
<EmptyState
icon={ListMusic}
title={current.message}
action={(
<button
type="button"
className="button-secondary"
onClick={() => {
setResult(null)
setAttempt(n => n + 1)
}}
>
重试
</button>
)}
/>
</section>
)
}
const { playlist } = current
const { items } = playlist
const actions = (_: SongItem, index: number): SongAction[] => {
const { track, meta } = items[index]
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 }),
},
]
}
return (
<section className="page-content my-playlist-page" aria-labelledby="shared-playlist-title">
<header className="my-playlist-header">
<PlaylistCover cover={items.find(item => item.meta.coverUrl)?.meta.coverUrl ?? null} favorites={playlist.favorites} size="lg" />
<div className="my-playlist-info">
<h1 id="shared-playlist-title" className="my-playlist-name">
<span>{playlist.name}</span>
{playlist.favorites && <FavoritesMark size={22} />}
</h1>
<p className="shared-playlist-owner text-ui-base muted">
<Avatar hash={playlist.ownerAvatarHash} name={playlist.ownerName} className="text-ui-xs" />
<span className="truncate">{`${playlist.ownerName} 分享,${items.length} 首`}</span>
</p>
<div className="my-playlist-actions">
<CopySharedPlaylistButton shareId={shareId} name={playlist.name} />
</div>
</div>
</header>
{items.length === 0
? <EmptyState icon={ListMusic} title="这个歌单还是空的" />
: <SongList songs={items.map(item => songItem(item, playing))} actions={actions} />}
{!connected && items.length > 0 && <p className="text-ui-caption muted my-playlist-hint">进入房间之后才能点歌。</p>}
{saving && <SaveToPlaylistDialog track={saving.track} title={saving.title} onClose={() => setSaving(null)} />}
</section>
)
}
+1 -1
View File
@@ -62,7 +62,7 @@ export default defineConfig({
workbox: {
// 与 nginx 一致:只有页面路径返回 index.html,/api、/ws 和 /media 照常走网络。
// Workbox 拿路径加查询参数来匹配,nginx 只看路径,所以这里的规则末尾允许查询参数。
navigateFallbackAllowlist: [/^\/(?:admin|settings|history|playlists(?:\/[^/?]+)?)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/],
navigateFallbackAllowlist: [/^\/(?:admin|settings|history|playlists(?:\/[^/?]+)?)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/, /^\/s\/[^/?]+(?:\?.*)?$/],
importScripts: ['notification-click.js'],
},
}),