feat: track member playlists in the web client

This commit is contained in:
DiheChen committed 2026-11-09 01:56:06 +08:00
1 parent 24abca6dc7
commit d6a77bb04b
9 files changed
+699 -4

No files matched your search

+1 -1
View File
@@ -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;
}
+2
View File
@@ -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()
+2 -2
View File
@@ -65,11 +65,11 @@ class NavigationStore extends Store<NavigationState> {
}
// 不在房间里时,房间的页面没有地址,去发现房间。换页面时侧栏保持原样。
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 }))
+8
View File
@@ -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 })
+12
View File
@@ -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<Location, 'pathname' | 'se
return { name: 'settings', panel }
if (pathname === '/history')
return { name: 'history', filter: params.get('q') ?? '', panel }
if (pathname === '/playlists')
return { name: 'playlists', panel }
const own = /^\/playlists\/([^/]+)$/.exec(pathname)
if (own)
return { name: 'my-playlist', id: own[1], panel }
// 不做 decodeURIComponent:UUID、别名和歌单 ID 不含需要转义的字符,而它遇到不合法的转义序列会抛出异常。
const room = /^\/r\/([^/]+)(\/.*)?$/.exec(pathname)
if (!room)
@@ -97,7 +104,10 @@ function pathOf(route: Route): string {
case 'admin':
case 'settings':
case 'history':
case 'playlists':
return `/${route.name}`
case 'my-playlist':
return `/playlists/${route.id}`
case 'listen':
return `/r/${route.ref}`
case 'playlist':
@@ -126,6 +136,8 @@ export function formatRoute(route: Route): string {
export function isNewEntry(from: Route, to: Route): boolean {
if (from.name !== to.name || ('ref' in from && 'ref' in to && from.ref !== to.ref))
return true
if (from.name === 'my-playlist' && to.name === 'my-playlist')
return from.id !== to.id
if (from.name === 'search' && to.name === 'search')
return to.query.view !== 'recommend' && to.query.keyword !== (from.query.view === 'recommend' ? '' : from.query.keyword)
if (from.name === 'playlist' && to.name === 'playlist')
+1
View File
@@ -0,0 +1 @@
export { playlistsStore } from './playlists-store'
@@ -0,0 +1,429 @@
import type { Track } from '@/core/realtime'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
class FakeStorage {
readonly #items = new Map<string, string>()
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 serverPlaylists: typeof favorites[]
let server: Map<string, Map<string, Track>>
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<string, Track> {
let tracks = server.get(playlist)
if (!tracks) {
tracks = new Map()
server.set(playlist, tracks)
}
return tracks
}
// 读取的结果在请求到达服务端时确定,holdReads 时推迟返回,模拟带着旧状态返回的读取。
async function read(body: unknown): Promise<Response> {
if (holdReads)
await new Promise<void>(resolve => release.push(resolve))
return json(200, body)
}
beforeEach(() => {
vi.resetModules()
requests = []
release = []
failRemove = false
failAdd = false
failCopy = false
failMove = false
bodies = []
holdReads = 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<void>(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
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(false)
expect(notificationStore.state.notifications.map(n => n.message)).toEqual([notReady])
})
it('移出失败时恢复勾选', async () => {
const { playlistsStore, included } = await loaded()
failRemove = true
const done = playlistsStore.setMembership(favorites, sunny, false)
expect(included(sunny)).toBe(false)
await settle()
await done
expect(included(sunny)).toBe(true)
})
it('加入成功后移出失败,勾选恢复为已加入', async () => {
const { playlistsStore, included } = await loaded()
failRemove = true
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(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])
})
})
@@ -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<string, ReadonlySet<string>>
}
const empty: PlaylistsState = { playlists: null, failed: false, memberships: new Map() }
const nameTexts = { INVALID_ARGUMENT: '歌单名要 1 到 50 个字。' }
class PlaylistsStore extends Store<PlaylistsState> {
#account: string | null = null
// 只有最后发起的读取能写入:换了账号,或者之后又读过一次时,旧的结果作废。
#generation = 0
#membershipReads = new Map<string, object>()
#syncing = new Map<string, Promise<void>>()
constructor() {
super(empty)
}
start(): void {
profileStore.addEventListener('change', this.#onProfile)
window.addEventListener('focus', this.#onFocus)
this.#onProfile()
}
// 失败时只设置 failed,不抛出。
load(): Promise<void> {
return this.#load(++this.#generation)
}
// 读取包含这首歌的歌单,写入 memberships;没读到时返回 false。读到了却已被之后的读取取代时也返回 true,由后来的读取写入。
async containing(track: Track): Promise<boolean> {
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<void> {
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<MyPlaylist | null> {
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<boolean> {
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<boolean> {
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<boolean> {
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<number | null> {
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<boolean> {
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<void> {
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<void> {
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()
+1 -1
View File
@@ -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'],
},
}),