From ef4558bd6537e7c68fb5f1144febb77827f94e58 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Mon, 9 Nov 2026 21:30:19 +0800 Subject: [PATCH] feat: track member playlists in the web client --- web/nginx/default.conf | 2 +- web/src/app/main.tsx | 2 + web/src/app/navigation.ts | 4 +- web/src/app/route.test.ts | 8 + web/src/app/route.ts | 12 + web/src/features/playlists/index.ts | 1 + .../playlists/playlists-store.test.ts | 465 ++++++++++++++++++ web/src/features/playlists/playlists-store.ts | 243 +++++++++ web/vite.config.ts | 2 +- 9 files changed, 735 insertions(+), 4 deletions(-) create mode 100644 web/src/features/playlists/index.ts create mode 100644 web/src/features/playlists/playlists-store.test.ts create mode 100644 web/src/features/playlists/playlists-store.ts diff --git a/web/nginx/default.conf b/web/nginx/default.conf index e67e418..3385813 100644 --- a/web/nginx/default.conf +++ b/web/nginx/default.conf @@ -22,7 +22,7 @@ server { include /tmp/alisten.d/*.conf; - location ~ ^/(?:admin|settings|history)?$ { + location ~ ^/(?:admin|settings|history|playlists(?:/[^/]+)?)?$ { add_header Cache-Control "no-cache"; try_files /index.html =404; } diff --git a/web/src/app/main.tsx b/web/src/app/main.tsx index 14b56b7..1fb0b4c 100644 --- a/web/src/app/main.tsx +++ b/web/src/app/main.tsx @@ -5,6 +5,7 @@ import { chatStore } from '@/features/chat' import { historyStore } from '@/features/history' import { lyricsStore } from '@/features/lyrics' import { playerStore, reminderStore, volumeStore } from '@/features/playback' +import { playlistsStore } from '@/features/playlists' import { roomEntryStore } from '@/features/rooms' import { searchStore } from '@/features/search' import { appearanceStore, performanceStore, profileStore } from '@/features/settings' @@ -26,6 +27,7 @@ performanceStore.start() volumeStore.start() brandStore.start() profileStore.start() +playlistsStore.start() playerStore.start() reminderStore.start() historyStore.start() diff --git a/web/src/app/navigation.ts b/web/src/app/navigation.ts index d5aa3c2..ce726dd 100644 --- a/web/src/app/navigation.ts +++ b/web/src/app/navigation.ts @@ -65,11 +65,11 @@ class NavigationStore extends Store { } // 不在房间里时,房间的页面没有地址,去发现房间。换页面时侧栏保持原样。 - open(page: 'lobby' | 'admin' | 'settings' | 'history' | RoomPage): void { + open(page: 'lobby' | 'admin' | 'settings' | 'history' | 'playlists' | RoomPage): void { const { panel } = this.state.route if (page === 'lobby' || page === 'history') this.navigate({ name: page, filter: '', panel }) - else if (page === 'admin' || page === 'settings') + else if (page === 'admin' || page === 'settings' || page === 'playlists') this.navigate({ name: page, panel }) else this.#openRoomPage(ref => ({ name: page, ref, panel })) diff --git a/web/src/app/route.test.ts b/web/src/app/route.test.ts index fd2e7a1..f3a7bbb 100644 --- a/web/src/app/route.test.ts +++ b/web/src/app/route.test.ts @@ -15,6 +15,9 @@ describe('parseRoute', () => { ['/settings?panel=queue', { name: 'settings', panel: 'queue' }], ['/history', { name: 'history', filter: '', panel: null }], ['/history?q=%E6%99%B4&panel=history', { name: 'history', filter: '晴', panel: 'history' }], + ['/playlists', { name: 'playlists', panel: null }], + ['/playlists/abc', { name: 'my-playlist', id: 'abc', panel: null }], + ['/playlists/abc?panel=queue', { name: 'my-playlist', id: 'abc', panel: 'queue' }], ['/r/my-room', { name: 'listen', ref: 'my-room', panel: null }], ['/r/my-room?panel=history', { name: 'listen', ref: 'my-room', panel: 'history' }], ['/r/my-room/queue?panel=queue', { name: 'queue', ref: 'my-room', panel: 'queue' }], @@ -64,6 +67,11 @@ describe('isNewEntry', () => { expect(isNewEntry({ name: 'history', filter: '晴', panel: null }, { name: 'history', filter: '晴天', panel: null })).toBe(false) }) + it('打开另一个自己的歌单写入新记录,同一个歌单只换侧栏时替换当前地址', () => { + expect(isNewEntry({ name: 'my-playlist', id: 'a', panel: null }, { name: 'my-playlist', id: 'b', panel: null })).toBe(true) + expect(isNewEntry({ name: 'my-playlist', id: 'a', panel: null }, { name: 'my-playlist', id: 'a', panel: 'queue' })).toBe(false) + }) + it('提交新的关键词写入新记录,换来源、类别、页码和推荐只替换当前地址', () => { const search = (query: SearchQuery): Route => ({ name: 'search', ref: 'a', panel: null, query }) const sunny = search({ view: 'all', keyword: '晴天', source: 'qq', page: 1 }) diff --git a/web/src/app/route.ts b/web/src/app/route.ts index c0ef27a..b32e657 100644 --- a/web/src/app/route.ts +++ b/web/src/app/route.ts @@ -12,6 +12,8 @@ export type Route | { name: 'admin', panel: Panel | null } | { name: 'settings', panel: Panel | null } | { name: 'history', filter: string, panel: Panel | null } + | { name: 'playlists', panel: Panel | null } + | { name: 'my-playlist', id: string, panel: Panel | null } | { 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 } @@ -68,6 +70,11 @@ export function parseRoute({ pathname, search }: Pick() + + get length(): number { + return this.#items.size + } + + key(index: number): string | null { + return [...this.#items.keys()][index] ?? null + } + + getItem(key: string): string | null { + return this.#items.get(key) ?? null + } + + setItem(key: string, value: string): void { + this.#items.set(key, value) + } + + removeItem(key: string): void { + this.#items.delete(key) + } +} + +const favorites = { id: 'p1', name: '我喜欢', favorites: true, trackCount: 1, createdAtMs: 1, updatedAtMs: 1, coverUrl: null } +const sunny = { source: 'netease', id: '186016' } as const +const rain = { source: 'netease', id: '185811' } as const +const commute = { id: 'p2', name: '通勤', favorites: false, trackCount: 0, createdAtMs: 1, updatedAtMs: 1, coverUrl: null } +const meta = { title: '晴天', artist: '周杰伦', album: '叶惠美', coverUrl: '', pageUrl: '', durationMs: 0 } +const notReady = '这首歌还在准备,准备好之后再保存' +const full = '一个歌单最多 1000 首歌' +const changed = '歌单已经变了,刷新之后再试' + +let requests: string[] +let release: (() => void)[] +let failRemove: boolean +let failAdd: boolean +let failCopy: boolean +let failMove: boolean +let bodies: unknown[] +let holdReads: boolean +let failReads: boolean +let serverPlaylists: typeof favorites[] +let server: Map> + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status }) +} + +function key(track: Track): string { + return `${track.source}:${track.id}` +} + +function items(playlist: string): Map { + let tracks = server.get(playlist) + if (!tracks) { + tracks = new Map() + server.set(playlist, tracks) + } + return tracks +} + +// 读取的结果在请求到达服务端时确定,holdReads 时推迟返回,模拟带着旧状态返回的读取。 +async function read(body: unknown): Promise { + if (holdReads) + await new Promise(resolve => release.push(resolve)) + if (failReads) + return json(503, { error: { code: 'UNAVAILABLE', message: '暂时不可用' } }) + return json(200, body) +} + +beforeEach(() => { + vi.resetModules() + requests = [] + release = [] + failRemove = false + failAdd = false + failCopy = false + failMove = false + bodies = [] + holdReads = false + failReads = false + serverPlaylists = [favorites] + server = new Map([['p1', new Map([[key(sunny), sunny]])]]) + vi.stubGlobal('window', new EventTarget()) + const local = new FakeStorage() + local.setItem('alisten.member-token', 'token') + vi.stubGlobal('localStorage', local) + vi.stubGlobal('fetch', async (path: string, init: RequestInit) => { + const request = `${init.method} ${path}` + requests.push(request) + if (request === 'GET /api/me/playlists') + return read({ items: [...serverPlaylists] }) + if (request === 'POST /api/me/playlist-memberships') { + const { track } = JSON.parse(String(init.body)) as { track: Track } + return read({ playlistIds: [...server].filter(([, tracks]) => tracks.has(key(track))).map(([id]) => id) }) + } + // 让测试控制请求完成的先后,模拟连续点击。 + await new Promise(resolve => release.push(resolve)) + if (request === 'DELETE /api/me/playlists/p2') { + serverPlaylists = serverPlaylists.filter(p => p.id !== 'p2') + return new Response(null, { status: 204 }) + } + if (request === 'POST /api/me/playlists') { + serverPlaylists.push(commute) + return json(201, commute) + } + if (init.body) + bodies.push(JSON.parse(String(init.body))) + const write = /^POST \/api\/me\/playlists\/(\w+)\/(items|removals|copies|moves)$/.exec(request) + if (write?.[2] === 'items') { + if (failAdd) + return json(409, { error: { code: 'CONFLICT', message: notReady } }) + const { track } = JSON.parse(String(init.body)) as { track: Track } + items(write[1]).set(key(track), track) + return json(201, { item: { track, meta } }) + } + if (write?.[2] === 'removals') { + if (failRemove) + return json(503, { error: { code: 'UNAVAILABLE', message: '暂时不可用' } }) + const { tracks } = JSON.parse(String(init.body)) as { tracks: Track[] } + for (const track of tracks) + items(write[1]).delete(key(track)) + return new Response(null, { status: 204 }) + } + if (write?.[2] === 'copies') { + if (failCopy) + return json(409, { error: { code: 'CONFLICT', message: full } }) + const { tracks } = JSON.parse(String(init.body)) as { tracks: Track[] } + const target = items(write[1]) + const added = tracks.filter(track => !target.has(key(track))) + for (const track of added) + target.set(key(track), track) + return json(200, { added: added.length }) + } + if (write?.[2] === 'moves') { + if (failMove) + return json(409, { error: { code: 'CONFLICT', message: changed } }) + return new Response(null, { status: 204 }) + } + throw new Error(`没有预期的请求 ${request}`) + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +async function loaded() { + const { playlistsStore } = await import('./playlists-store') + await playlistsStore.load() + await playlistsStore.containing(sunny) + await playlistsStore.containing(rain) + requests = [] + const included = (track: Track) => playlistsStore.state.memberships.get(key(track))?.has('p1') ?? false + return { playlistsStore, included } +} + +function flush() { + return new Promise(resolve => setTimeout(resolve, 0)) +} + +// 每次先等一个宏任务,让已经开始的请求走到 fetch,再放行最早的一个。 +async function settle() { + for (;;) { + await new Promise(resolve => setTimeout(resolve, 0)) + const next = release.shift() + if (!next) + return + next() + } +} + +const writes = () => requests.filter(r => !r.startsWith('GET ') && r !== 'POST /api/me/playlist-memberships') + +describe('playlistsStore', () => { + it('load 读取歌单', async () => { + const { playlistsStore } = await import('./playlists-store') + await playlistsStore.load() + expect(playlistsStore.state.playlists).toEqual([favorites]) + expect(requests).toEqual(['GET /api/me/playlists']) + }) + + it('containing 读取包含这首歌的歌单', async () => { + const { included } = await loaded() + expect(included(sunny)).toBe(true) + expect(included(rain)).toBe(false) + }) + + it('加入请求进行中又取消勾选,完成后再发移出', async () => { + const { playlistsStore, included } = await loaded() + void playlistsStore.setMembership(favorites, rain, true) + void playlistsStore.setMembership(favorites, rain, false) + await settle() + expect(writes()).toEqual(['POST /api/me/playlists/p1/items', 'POST /api/me/playlists/p1/removals']) + expect(included(rain)).toBe(false) + }) + + it('连点三次只发一次加入', async () => { + const { playlistsStore, included } = await loaded() + void playlistsStore.setMembership(favorites, rain, true) + void playlistsStore.setMembership(favorites, rain, false) + void playlistsStore.setMembership(favorites, rain, true) + await settle() + expect(writes()).toEqual(['POST /api/me/playlists/p1/items']) + expect(included(rain)).toBe(true) + }) + + // 同步结束后会重新读取,读到的服务端状态也能把勾选改对。下面几个用例挡住这次读取,只看回滚本身。 + it('第一次请求失败时以服务端为准,提示服务端的文案', async () => { + const { playlistsStore, included } = await loaded() + const { notificationStore } = await import('@/shared/ui') + failAdd = true + holdReads = true + const done = playlistsStore.setMembership(favorites, rain, true) + void playlistsStore.setMembership(favorites, rain, false) + void playlistsStore.setMembership(favorites, rain, true) + await flush() + release.shift()!() + await done + expect(writes()).toEqual(['POST /api/me/playlists/p1/items']) + expect(included(rain)).toBe(false) + expect(notificationStore.state.notifications.map(n => n.message)).toEqual([notReady]) + holdReads = false + await settle() + expect(included(rain)).toBe(false) + }) + + it('移出失败时恢复勾选', async () => { + const { playlistsStore, included } = await loaded() + failRemove = true + holdReads = true + const done = playlistsStore.setMembership(favorites, sunny, false) + expect(included(sunny)).toBe(false) + await flush() + release.shift()!() + await done + expect(included(sunny)).toBe(true) + holdReads = false + await settle() + expect(included(sunny)).toBe(true) + }) + + it('加入成功后移出失败,勾选恢复为已加入', async () => { + const { playlistsStore, included } = await loaded() + failRemove = true + holdReads = true + const done = playlistsStore.setMembership(favorites, rain, true) + void playlistsStore.setMembership(favorites, rain, false) + await flush() + release.shift()!() + await flush() + release.shift()!() + await done + expect(writes()).toEqual(['POST /api/me/playlists/p1/items', 'POST /api/me/playlists/p1/removals']) + expect(included(rain)).toBe(true) + holdReads = false + await settle() + expect(included(rain)).toBe(true) + }) + + it('移出失败后重新读取也失败,勾选保持回滚的结果', async () => { + const { playlistsStore, included } = await loaded() + failRemove = true + failReads = true + const done = playlistsStore.setMembership(favorites, sunny, false) + await settle() + await done + expect(requests).toEqual(['POST /api/me/playlists/p1/removals', 'POST /api/me/playlist-memberships', 'GET /api/me/playlists']) + expect(included(sunny)).toBe(true) + expect(playlistsStore.state.failed).toBe(true) + }) + + it('读取期间正在加入的歌以本地为准', async () => { + const { playlistsStore, included } = await loaded() + void playlistsStore.setMembership(favorites, rain, true) + await flush() + await playlistsStore.containing(rain) + expect(included(rain)).toBe(true) + await settle() + expect(included(rain)).toBe(true) + }) + + it('读取期间正在移出的歌以本地为准', async () => { + const { playlistsStore, included } = await loaded() + void playlistsStore.setMembership(favorites, sunny, false) + await flush() + await playlistsStore.containing(sunny) + expect(included(sunny)).toBe(false) + await settle() + expect(included(sunny)).toBe(false) + }) + + it('同步完成之后才返回的旧读取不会取消勾选', async () => { + const { playlistsStore, included } = await loaded() + holdReads = true + void playlistsStore.setMembership(favorites, rain, true) + await flush() + void playlistsStore.containing(rain) + await flush() + // 先放行加入,同步结束;再放行加入之前发出的读取。 + release.shift()!() + await flush() + release.shift()!() + await flush() + expect(included(rain)).toBe(true) + holdReads = false + await settle() + expect(included(rain)).toBe(true) + }) + + it('同步结束后重新读取歌单和这首歌所在的歌单', async () => { + const { playlistsStore } = await loaded() + void playlistsStore.setMembership(favorites, rain, true) + await settle() + expect(requests).toEqual(['POST /api/me/playlists/p1/items', 'POST /api/me/playlist-memberships', 'GET /api/me/playlists']) + }) + + it('removeItems 一次移出多首,成功后重新读取歌单', async () => { + const { playlistsStore } = await loaded() + items('p1').set(key(rain), rain) + const done = playlistsStore.removeItems(favorites, [sunny, rain]) + await settle() + expect(await done).toBe(true) + expect(requests).toEqual(['POST /api/me/playlists/p1/removals', 'GET /api/me/playlists']) + expect(bodies).toEqual([{ tracks: [sunny, rain] }]) + expect(items('p1').size).toBe(0) + }) + + it('removeItems 失败时提示并返回 false', async () => { + const { playlistsStore } = await loaded() + const { notificationStore } = await import('@/shared/ui') + failRemove = true + const done = playlistsStore.removeItems(favorites, [sunny]) + await settle() + expect(await done).toBe(false) + expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['暂时不可用']) + }) + + it('copyItems 返回新加入的首数,目标歌单里已有的歌不算', async () => { + const { playlistsStore } = await loaded() + items('p2').set(key(sunny), sunny) + const done = playlistsStore.copyItems(favorites, commute, [sunny, rain]) + await settle() + expect(await done).toBe(1) + expect(requests).toEqual(['POST /api/me/playlists/p2/copies', 'GET /api/me/playlists']) + expect(bodies).toEqual([{ from: 'p1', tracks: [sunny, rain] }]) + }) + + it('copyItems 装不下时提示是哪个歌单并返回 null', async () => { + const { playlistsStore } = await loaded() + const { notificationStore } = await import('@/shared/ui') + failCopy = true + const done = playlistsStore.copyItems(favorites, commute, [sunny]) + await settle() + expect(await done).toBeNull() + expect(notificationStore.state.notifications.map(n => n.message)).toEqual([`没能保存到“通勤”:${full}`]) + }) + + it('moveItems 有 before 时放到它之前,没有时移到最后', async () => { + const { playlistsStore } = await loaded() + const first = playlistsStore.moveItems(favorites, [rain], sunny) + await settle() + const last = playlistsStore.moveItems(favorites, [sunny]) + await settle() + expect([await first, await last]).toEqual([true, true]) + expect(writes()).toEqual(['POST /api/me/playlists/p1/moves', 'POST /api/me/playlists/p1/moves']) + expect(bodies).toEqual([{ tracks: [rain], before: sunny }, { tracks: [sunny] }]) + expect(requests.filter(r => r === 'GET /api/me/playlists')).toHaveLength(2) + }) + + it('moveItems 失败时提示并返回 false', async () => { + const { playlistsStore } = await loaded() + const { notificationStore } = await import('@/shared/ui') + failMove = true + const done = playlistsStore.moveItems(favorites, [sunny]) + await settle() + expect(await done).toBe(false) + expect(notificationStore.state.notifications.map(n => n.message)).toEqual([changed]) + }) + + it('游客不读取歌单', async () => { + const { playlistsStore } = await import('./playlists-store') + playlistsStore.start() + window.dispatchEvent(new Event('focus')) + await flush() + expect(requests).toEqual([]) + }) + + it('注册之后读取,窗口获得焦点时刷新', async () => { + const { playlistsStore } = await import('./playlists-store') + const { profileStore } = await import('@/features/settings') + playlistsStore.start() + await profileStore.registered({ username: 'alice' }) + await flush() + expect(playlistsStore.state.playlists).toEqual([favorites]) + window.dispatchEvent(new Event('focus')) + await flush() + expect(requests.filter(r => r === 'GET /api/me/playlists')).toHaveLength(2) + }) + + it('换账号之后,上一个账号的读取结果作废', async () => { + const { playlistsStore } = await import('./playlists-store') + const { profileStore } = await import('@/features/settings') + holdReads = true + playlistsStore.start() + await profileStore.registered({ username: 'alice' }) + await profileStore.registered({ username: 'bob' }) + await flush() + release.shift()!() + await flush() + expect(playlistsStore.state.playlists).toBeNull() + release.shift()!() + await flush() + expect(playlistsStore.state.playlists).toEqual([favorites]) + }) + + it('换账号之后,上一个账号读到的所在歌单作废', async () => { + const { playlistsStore } = await import('./playlists-store') + const { profileStore } = await import('@/features/settings') + playlistsStore.start() + await profileStore.registered({ username: 'alice' }) + await flush() + holdReads = true + const reading = playlistsStore.containing(sunny) + await flush() + await profileStore.registered({ username: 'bob' }) + release.shift()!() + await reading + expect(playlistsStore.state.memberships.size).toBe(0) + }) + + // 读取在删除或新建之前到达服务端,却在之后才返回时,结果不能写回界面。 + it('读取进行中删除歌单,旧读取返回后歌单不会回来', async () => { + serverPlaylists.push(commute) + const { playlistsStore } = await loaded() + holdReads = true + void playlistsStore.load() + await flush() + const removed = playlistsStore.remove(commute) + await flush() + release.splice(1, 1)[0]() + await removed + holdReads = false + await settle() + expect(playlistsStore.state.playlists).toEqual([favorites]) + }) + + it('读取进行中新建歌单,旧读取返回后歌单不会消失', async () => { + const { playlistsStore } = await loaded() + holdReads = true + void playlistsStore.load() + await flush() + const created = playlistsStore.create('通勤') + await flush() + release.splice(1, 1)[0]() + await created + holdReads = false + await settle() + expect(playlistsStore.state.playlists).toEqual([favorites, commute]) + }) +}) diff --git a/web/src/features/playlists/playlists-store.ts b/web/src/features/playlists/playlists-store.ts new file mode 100644 index 0000000..8878a83 --- /dev/null +++ b/web/src/features/playlists/playlists-store.ts @@ -0,0 +1,243 @@ +import type { MyPlaylist, Track } from '@/core/realtime' +import { api } from '@/core/api' +import { errorMessage } from '@/core/errors' +import { profileStore } from '@/features/settings' +import { Store } from '@/shared/lib/store' +import { notificationStore } from '@/shared/ui' + +export function trackKey(track: Track): string { + return `${track.source}:${track.id}` +} + +function membershipKey(playlistId: string, track: Track): string { + return `${playlistId} ${trackKey(track)}` +} + +interface PlaylistsState { + // 游客,或者还没有读到时为 null。 + playlists: MyPlaylist[] | null + failed: boolean + // 曲目 → 包含它的歌单 id,只有读过的曲目才有。正在同步的歌单是最后一次勾选的结果,同步负责把服务端改成一致。 + memberships: ReadonlyMap> +} + +const empty: PlaylistsState = { playlists: null, failed: false, memberships: new Map() } + +const nameTexts = { INVALID_ARGUMENT: '歌单名要 1 到 50 个字。' } + +class PlaylistsStore extends Store { + #account: string | null = null + // 只有最后发起的读取能写入:换了账号,或者之后又读过一次时,旧的结果作废。 + #generation = 0 + #membershipReads = new Map() + #syncing = new Map>() + + constructor() { + super(empty) + } + + start(): void { + profileStore.addEventListener('change', this.#onProfile) + window.addEventListener('focus', this.#onFocus) + this.#onProfile() + } + + // 失败时只设置 failed,不抛出。 + load(): Promise { + return this.#load(++this.#generation) + } + + // 读取包含这首歌的歌单,写入 memberships;没读到时返回 false。读到了却已被之后的读取取代时也返回 true,由后来的读取写入。 + async containing(track: Track): Promise { + const account = this.#account + const key = trackKey(track) + const read = {} + this.#membershipReads.set(key, read) + try { + const { playlistIds } = await api.listMyTrackPlaylists({ track }) + if (this.#account === account && this.#membershipReads.get(key) === read) + this.#applyMemberships(track, playlistIds) + return true + } + catch { + return false + } + finally { + if (this.#membershipReads.get(key) === read) + this.#membershipReads.delete(key) + } + } + + setMembership(playlist: MyPlaylist, track: Track, included: boolean): Promise { + const key = membershipKey(playlist.id, track) + const saved = this.#includes(playlist.id, track) + this.#setIncluded(playlist.id, track, included) + let sync = this.#syncing.get(key) + if (!sync) { + sync = this.#sync(playlist.id, track, saved) + this.#syncing.set(key, sync) + void sync.finally(() => { + if (this.#syncing.get(key) !== sync) + return + this.#syncing.delete(key) + // 同步之前发出的读取可能带着旧的勾选返回,重新读一次让它们作废;请求超时但服务端已生效时,也以服务端为准。 + void this.containing(track) + void this.load() + }) + } + return sync + } + + async create(name: string): Promise { + try { + const playlist = await api.createMyPlaylist({ name }) + this.setState({ playlists: [...(this.state.playlists ?? []), playlist] }) + void this.load() + return playlist + } + catch (error) { + notificationStore.error(errorMessage(error, nameTexts)) + return null + } + } + + async rename(playlist: MyPlaylist, name: string): Promise { + try { + await api.renameMyPlaylist(playlist.id, { name }) + await this.load() + return true + } + catch (error) { + notificationStore.error(errorMessage(error, nameTexts)) + return false + } + } + + async remove(playlist: MyPlaylist): Promise { + try { + await api.deleteMyPlaylist(playlist.id) + this.setState({ playlists: (this.state.playlists ?? []).filter(p => p.id !== playlist.id) }) + void this.load() + return true + } + catch (error) { + notificationStore.error(errorMessage(error)) + return false + } + } + + async removeItems(playlist: MyPlaylist, tracks: Track[]): Promise { + try { + await api.removeMyPlaylistItems(playlist.id, { tracks }) + void this.load() + return true + } + catch (error) { + notificationStore.error(errorMessage(error)) + return false + } + } + + // 返回新加入的首数,to 里已经有的歌不算;失败时返回 null。 + async copyItems(from: MyPlaylist, to: MyPlaylist, tracks: Track[]): Promise { + try { + const { added } = await api.copyMyPlaylistItems(to.id, { from: from.id, tracks }) + void this.load() + return added + } + catch (error) { + notificationStore.error(`没能保存到“${to.name}”:${errorMessage(error)}`) + return null + } + } + + // 没有 before 时移到最后。 + async moveItems(playlist: MyPlaylist, tracks: Track[], before?: Track): Promise { + try { + await api.moveMyPlaylistItems(playlist.id, before ? { tracks, before } : { tracks }) + void this.load() + return true + } + catch (error) { + notificationStore.error(errorMessage(error)) + return false + } + } + + // 请求进行中又改了勾选时,等它完成之后按最新的勾选再发;失败时恢复成服务端的状态,不再发出。 + async #sync(playlistId: string, track: Track, saved: boolean): Promise { + const account = this.#account + while (this.#account === account && this.#includes(playlistId, track) !== saved) { + const included = this.#includes(playlistId, track) + try { + if (included) + await api.addMyPlaylistItem(playlistId, { track }) + else + await api.removeMyPlaylistItems(playlistId, { tracks: [track] }) + saved = included + } + catch (error) { + if (this.#account === account) + this.#setIncluded(playlistId, track, saved) + notificationStore.error(errorMessage(error)) + return + } + } + } + + async #load(generation: number): Promise { + try { + const { items } = await api.listMyPlaylists() + if (generation === this.#generation) + this.setState({ playlists: items, failed: false }) + } + catch { + if (generation === this.#generation) + this.setState({ failed: true }) + } + } + + // 正在同步的歌单以本地为准:读到的可能是同步完成之前的状态。 + #applyMemberships(track: Track, playlistIds: readonly string[]): void { + const syncing = (id: string) => this.#syncing.has(membershipKey(id, track)) + const next = new Set(playlistIds.filter(id => !syncing(id))) + for (const id of this.state.memberships.get(trackKey(track)) ?? []) { + if (syncing(id)) + next.add(id) + } + this.setState({ memberships: new Map(this.state.memberships).set(trackKey(track), next) }) + } + + #includes(playlistId: string, track: Track): boolean { + return this.state.memberships.get(trackKey(track))?.has(playlistId) ?? false + } + + #setIncluded(playlistId: string, track: Track, included: boolean): void { + const ids = new Set(this.state.memberships.get(trackKey(track))) + if (included) + ids.add(playlistId) + else + ids.delete(playlistId) + this.setState({ memberships: new Map(this.state.memberships).set(trackKey(track), ids) }) + } + + #onProfile = (): void => { + const username = profileStore.state.account?.username ?? null + if (username === this.#account) + return + this.#account = username + this.#generation++ + this.#membershipReads.clear() + this.#syncing.clear() + this.setState(empty) + if (username) + void this.load() + } + + #onFocus = (): void => { + if (this.#account) + void this.load() + } +} + +export const playlistsStore = new PlaylistsStore() diff --git a/web/vite.config.ts b/web/vite.config.ts index 84408eb..2b13cd1 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)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/], + navigateFallbackAllowlist: [/^\/(?:admin|settings|history|playlists(?:\/[^/?]+)?)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/], importScripts: ['notification-click.js'], }, }),