diff --git a/web/nginx/default.conf b/web/nginx/default.conf index 3385813..79b70d3 100644 --- a/web/nginx/default.conf +++ b/web/nginx/default.conf @@ -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; diff --git a/web/src/app/app.tsx b/web/src/app/app.tsx index 1f9c756..0ff4172 100644 --- a/web/src/app/app.tsx +++ b/web/src/app/app.tsx @@ -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> = { '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 navigationStore.navigate({ ...route, page })} /> } +function RoutedSharedPlaylist() { + const route = useStore(navigationStore, s => s.route) + if (route.name !== 'shared-playlist') + return null + return +} + 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' && } {page === 'settings' && } + {page === 'shared-playlist' && } diff --git a/web/src/app/route.test.ts b/web/src/app/route.test.ts index f3a7bbb..9bdd2a5 100644 --- a/web/src/app/route.test.ts +++ b/web/src/app/route.test.ts @@ -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', () => { diff --git a/web/src/app/route.ts b/web/src/app/route.ts index b32e657..a4dc6a1 100644 --- a/web/src/app/route.ts +++ b/web/src/app/route.ts @@ -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 + + {open && 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 ( + + {account + ? ( +
{ + 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() + }} + > + +
+ + +
+
+ ) + : } +
+ ) +} diff --git a/web/src/features/playlists/index.ts b/web/src/features/playlists/index.ts index 36a0673..d967de6 100644 --- a/web/src/features/playlists/index.ts +++ b/web/src/features/playlists/index.ts @@ -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)) diff --git a/web/src/features/playlists/playlists.css b/web/src/features/playlists/playlists.css index caf7f62..d41e87d 100644 --- a/web/src/features/playlists/playlists.css +++ b/web/src/features/playlists/playlists.css @@ -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; } diff --git a/web/src/features/playlists/shared-playlist-page.tsx b/web/src/features/playlists/shared-playlist-page.tsx new file mode 100644 index 0000000..39f0957 --- /dev/null +++ b/web/src/features/playlists/shared-playlist-page.tsx @@ -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(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
+ } + if (current.kind === 'gone') { + return ( +
+ + 歌单被删除了,或者分享的人停止了分享。 + +
+ ) + } + if (current.kind === 'failed') { + return ( +
+ { + setResult(null) + setAttempt(n => n + 1) + }} + > + 重试 + + )} + /> +
+ ) + } + + 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 ( +
+
+ item.meta.coverUrl)?.meta.coverUrl ?? null} favorites={playlist.favorites} size="lg" /> +
+

+ {playlist.name} + {playlist.favorites && } +

+

+ + {`${playlist.ownerName} 分享,${items.length} 首`} +

+
+ +
+
+
+ {items.length === 0 + ? + : songItem(item, playing))} actions={actions} />} + {!connected && items.length > 0 &&

进入房间之后才能点歌。

} + {saving && setSaving(null)} />} +
+ ) +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 2b13cd1..85687c5 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -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'], }, }),