feat: show listening history from the server and sync the recording switch across devices
This commit is contained in:
1 parent
17e486209e
commit
910e7339ad
8 files changed
+541
-185
No files matched your search
@@ -1,21 +1,21 @@
|
||||
import type { PlayRecord } from './records'
|
||||
import type { MyListen } from '@/core/realtime'
|
||||
import { CirclePlus, Download, History, Search, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { roomStore, sameTrack } from '@/core/room'
|
||||
import { SaveToPlaylistButton } from '@/features/playlists'
|
||||
import { enqueue, sourceName } from '@/features/queue'
|
||||
import { bookTitle } from '@/shared/lib/book-title'
|
||||
import { formatClock } from '@/shared/lib/time'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { AsyncIconButton, EmptyState, IconButton, Modal, notificationStore, PlayingBars, SongArtwork } from '@/shared/ui'
|
||||
import { AsyncIconButton, ConfirmDialog, EmptyState, IconButton, notificationStore, PlayingBars, Skeleton, SongArtwork } from '@/shared/ui'
|
||||
import { historyStore } from './history-store'
|
||||
import './history.css'
|
||||
|
||||
const batchSize = 80
|
||||
|
||||
function matches(record: PlayRecord, query: string): boolean {
|
||||
function matches(listen: MyListen, query: string): boolean {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
const { title, artist, album } = record.meta
|
||||
const { title, artist, album } = listen.meta
|
||||
return !keyword || `${title} ${artist} ${album}`.toLowerCase().includes(keyword)
|
||||
}
|
||||
|
||||
@@ -23,10 +23,10 @@ function playedOn(ms: number): string {
|
||||
return new Date(ms).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
}
|
||||
|
||||
function HistoryRow({ record, compact, connected, playing }: { record: PlayRecord, compact: boolean, connected: boolean, playing: boolean }) {
|
||||
const { title, artist, album, coverUrl, pageUrl } = record.meta
|
||||
function HistoryRow({ listen, compact, connected, playing }: { listen: MyListen, compact: boolean, connected: boolean, playing: boolean }) {
|
||||
const { title, artist, album, coverUrl, pageUrl } = listen.meta
|
||||
const performer = artist || '未知艺术家'
|
||||
const source = sourceName(record.track)
|
||||
const source = sourceName(listen.track)
|
||||
return (
|
||||
<div className="song-row" data-playing={playing || undefined}>
|
||||
<SongArtwork coverUrl={coverUrl} title={title} href={pageUrl} sourceName={source} />
|
||||
@@ -37,24 +37,24 @@ function HistoryRow({ record, compact, connected, playing }: { record: PlayRecor
|
||||
</span>
|
||||
<span className="text-ui-sm muted truncate">{[performer, source, album].filter(Boolean).join(' · ')}</span>
|
||||
</div>
|
||||
{!compact && <time className="history-time text-ui-sm muted" dateTime={new Date(record.playedAtMs).toISOString()}>{formatClock(record.playedAtMs)}</time>}
|
||||
{!compact && <time className="history-time text-ui-sm muted" dateTime={new Date(listen.heardAtMs).toISOString()}>{formatClock(listen.heardAtMs)}</time>}
|
||||
<AsyncIconButton
|
||||
icon={CirclePlus}
|
||||
className="song-action"
|
||||
label={`点歌${bookTitle(title)}`}
|
||||
disabled={!connected}
|
||||
onAction={async () => {
|
||||
await enqueue(record.track)
|
||||
await enqueue(listen.track)
|
||||
}}
|
||||
/>
|
||||
<SaveToPlaylistButton track={record.track} title={title} className="song-action song-action-secondary" />
|
||||
<IconButton
|
||||
<SaveToPlaylistButton track={listen.track} title={title} className="song-action song-action-secondary" />
|
||||
<AsyncIconButton
|
||||
icon={Trash2}
|
||||
className="song-action song-action-secondary"
|
||||
label={`删除${bookTitle(title)}的播放记录`}
|
||||
onClick={() => {
|
||||
historyStore.removePlay(record)
|
||||
notificationStore.success('已删除播放记录')
|
||||
onAction={async () => {
|
||||
if (await historyStore.remove(listen))
|
||||
notificationStore.success('已删除播放记录')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -62,13 +62,48 @@ function HistoryRow({ record, compact, connected, playing }: { record: PlayRecor
|
||||
}
|
||||
|
||||
export function HistoryPage({ compact = false, filter = '', onFilter }: { compact?: boolean, filter?: string, onFilter?: (filter: string) => void }) {
|
||||
const plays = useStore(historyStore, s => s.plays)
|
||||
const listens = useStore(historyStore, s => s.listens)
|
||||
const error = useStore(historyStore, s => s.error)
|
||||
const connected = useStore(roomStore, s => s.status === 'open')
|
||||
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
|
||||
const [limit, setLimit] = useState(batchSize)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const visible = plays.filter(record => matches(record, filter))
|
||||
useEffect(() => historyStore.open(), [])
|
||||
const all = listens ?? []
|
||||
const visible = all.filter(listen => matches(listen, filter))
|
||||
const shown = visible.slice(0, limit)
|
||||
let body
|
||||
if (listens === null) {
|
||||
body = error === null
|
||||
? <Skeleton rows={4} />
|
||||
: (
|
||||
<EmptyState icon={History} title="播放历史没有读出来" action={<button type="button" className="button-secondary" onClick={() => void historyStore.load()}>重试</button>}>
|
||||
{error}
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
else if (shown.length) {
|
||||
body = (
|
||||
<ol className="history-list">
|
||||
{shown.map((listen, index) => {
|
||||
const day = playedOn(listen.heardAtMs)
|
||||
return (
|
||||
<li key={`${listen.track.source}:${listen.track.id}`}>
|
||||
{(index === 0 || playedOn(shown[index - 1].heardAtMs) !== day) && <h2 className="history-date text-ui-sm muted">{day}</h2>}
|
||||
<HistoryRow listen={listen} compact={compact} connected={connected} playing={sameTrack(listen.track, playing)} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
else {
|
||||
body = (
|
||||
<EmptyState icon={History} title={filter ? '没有找到相关记录' : '你的音乐足迹,从这里开始'}>
|
||||
{filter ? '换个关键词,再找找看。' : '你在房间里听过的歌会记在这里。'}
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<section className={compact ? 'history-panel' : 'page-content history-page'} aria-label="播放历史">
|
||||
{!compact && (
|
||||
@@ -90,53 +125,33 @@ export function HistoryPage({ compact = false, filter = '', onFilter }: { compac
|
||||
aria-label="搜索播放历史"
|
||||
/>
|
||||
</label>
|
||||
<IconButton icon={Download} label="导出播放历史" disabled={!plays.length} onClick={() => historyStore.exportPlays()} />
|
||||
<IconButton icon={Trash2} label="清空播放历史" disabled={!plays.length} onClick={() => setConfirming(true)} />
|
||||
</div>
|
||||
<div className="section-heading">
|
||||
<span className="text-ui-sm muted">{`${plays.length} 首歌曲 · 保存在这台设备上`}</span>
|
||||
<IconButton icon={Download} label="导出播放历史" disabled={!all.length} onClick={() => historyStore.exportListens()} />
|
||||
<IconButton icon={Trash2} label="清空播放历史" disabled={!all.length} onClick={() => setConfirming(true)} />
|
||||
</div>
|
||||
{listens && (
|
||||
<div className="section-heading">
|
||||
<span className="text-ui-sm muted">{`${listens.length} 首歌曲`}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{shown.length
|
||||
? (
|
||||
<ol className="history-list">
|
||||
{shown.map((record, index) => {
|
||||
const day = playedOn(record.playedAtMs)
|
||||
return (
|
||||
<li key={`${record.track.source}:${record.track.id}`}>
|
||||
{(index === 0 || playedOn(shown[index - 1].playedAtMs) !== day) && <h2 className="history-date text-ui-sm muted">{day}</h2>}
|
||||
<HistoryRow record={record} compact={compact} connected={connected} playing={sameTrack(record.track, playing)} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)
|
||||
: (
|
||||
<EmptyState icon={History} title={filter ? '没有找到相关记录' : '你的音乐足迹,从这里开始'}>
|
||||
{filter ? '换个关键词,再找找看。' : '房间播放过的歌曲会保存在这台设备上,方便再次点歌。'}
|
||||
</EmptyState>
|
||||
)}
|
||||
{body}
|
||||
{visible.length > limit && (
|
||||
<button type="button" className="button-secondary load-more" onClick={() => setLimit(n => n + batchSize)}>显示更多</button>
|
||||
)}
|
||||
{confirming && (
|
||||
<Modal title="清空播放历史" onClose={() => setConfirming(false)}>
|
||||
<p className="text-ui-base muted">将清空这台设备上的全部播放记录。</p>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="button-secondary" onClick={() => setConfirming(false)}>取消</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button-destructive"
|
||||
onClick={() => {
|
||||
historyStore.clearPlays()
|
||||
setConfirming(false)
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
<ConfirmDialog
|
||||
title="清空播放历史"
|
||||
confirmLabel="清空"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
if (await historyStore.clear())
|
||||
setConfirming(false)
|
||||
}}
|
||||
onClose={() => setConfirming(false)}
|
||||
>
|
||||
将清空你的全部播放记录,登录同一个账号的其他设备上也会一起清空。
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import type { MyListen, NowPlaying, 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)
|
||||
}
|
||||
}
|
||||
|
||||
function listen(id: string, heardAtMs: number): MyListen {
|
||||
return {
|
||||
track: { source: 'netease', id },
|
||||
meta: { title: id, artist: '', album: '', coverUrl: '', pageUrl: '', durationMs: 1000 },
|
||||
heardAtMs,
|
||||
}
|
||||
}
|
||||
|
||||
function nowPlaying(entryId: string, startedAtMs: number): NowPlaying {
|
||||
return { entry: { id: entryId }, startedAtMs } as NowPlaying
|
||||
}
|
||||
|
||||
let requests: string[]
|
||||
let server: MyListen[]
|
||||
// 读取的结果在请求到达服务端时确定,holdReads 时推迟返回,模拟带着旧状态返回的读取。
|
||||
let holdReads: boolean
|
||||
let release: (() => void)[]
|
||||
let failReads: boolean
|
||||
// 服务端拒绝这个成员 token,客户端随即换成新的游客 renewed。
|
||||
let rejectedToken: string | null
|
||||
let room: EventTarget & { state: { room: { nowPlaying: NowPlaying | null } | null } }
|
||||
const renewed = { id: 'm2', name: '游客-9f8e', avatarHash: '' }
|
||||
|
||||
function json(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
requests = []
|
||||
server = [listen('2', 2), listen('1', 1)]
|
||||
holdReads = false
|
||||
release = []
|
||||
failReads = false
|
||||
rejectedToken = null
|
||||
room = Object.assign(new EventTarget(), { state: { room: null } })
|
||||
vi.doMock('@/core/room', () => ({ roomStore: room }))
|
||||
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 (new Headers(init.headers).get('Authorization') === `Bearer ${rejectedToken}`)
|
||||
return json(401, { error: { code: 'UNAUTHORIZED', message: '身份已经失效' } })
|
||||
if (request === 'POST /api/members')
|
||||
return json(201, { member: renewed, memberToken: 'token-2' })
|
||||
if (request === 'GET /api/me')
|
||||
return json(200, { member: renewed, recordListens: true })
|
||||
if (request === 'GET /api/me/listens') {
|
||||
const items = [...server]
|
||||
if (holdReads)
|
||||
await new Promise<void>(resolve => release.push(resolve))
|
||||
if (failReads)
|
||||
return json(503, { error: { code: 'UNAVAILABLE', message: '暂时不可用' } })
|
||||
return json(200, { items })
|
||||
}
|
||||
if (request === 'POST /api/me/listens/removals') {
|
||||
const { track } = JSON.parse(String(init.body)) as { track: Track }
|
||||
server = server.filter(l => l.track.id !== track.id)
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request === 'DELETE /api/me/listens') {
|
||||
server = []
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
throw new Error(`没有预期的请求 ${request}`)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock('@/core/room')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function flush() {
|
||||
return new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function play(next: NowPlaying | null) {
|
||||
room.state = { room: next && { nowPlaying: next } }
|
||||
room.dispatchEvent(new Event('change'))
|
||||
}
|
||||
|
||||
async function started() {
|
||||
const { historyStore } = await import('./history-store')
|
||||
historyStore.start()
|
||||
return historyStore
|
||||
}
|
||||
|
||||
const reads = () => requests.filter(r => r === 'GET /api/me/listens').length
|
||||
|
||||
describe('historyStore 的播放历史', () => {
|
||||
it('每次打开页面都从服务端读取', async () => {
|
||||
const historyStore = await started()
|
||||
expect(historyStore.state.listens).toBeNull()
|
||||
const close = historyStore.open()
|
||||
await flush()
|
||||
expect(historyStore.state.listens).toEqual(server)
|
||||
close()
|
||||
historyStore.open()
|
||||
await flush()
|
||||
expect(reads()).toBe(2)
|
||||
})
|
||||
|
||||
it('页面开着时,正在播放的歌一变就重新读取;同一首歌的其他房间变化不读', async () => {
|
||||
const historyStore = await started()
|
||||
historyStore.open()
|
||||
await flush()
|
||||
play(nowPlaying('1', 1000))
|
||||
await flush()
|
||||
expect(reads()).toBe(2)
|
||||
play(nowPlaying('1', 1000))
|
||||
play(null)
|
||||
await flush()
|
||||
expect(reads()).toBe(2)
|
||||
server = [listen('3', 3), ...server]
|
||||
play(nowPlaying('2', 2000))
|
||||
await flush()
|
||||
expect(reads()).toBe(3)
|
||||
expect(historyStore.state.listens?.[0]).toEqual(listen('3', 3))
|
||||
})
|
||||
|
||||
it('页面都关了之后换歌不读取', async () => {
|
||||
const historyStore = await started()
|
||||
const closeFull = historyStore.open()
|
||||
const closePanel = historyStore.open()
|
||||
await flush()
|
||||
closeFull()
|
||||
play(nowPlaying('1', 1000))
|
||||
await flush()
|
||||
expect(reads()).toBe(3)
|
||||
closePanel()
|
||||
closePanel()
|
||||
play(nowPlaying('2', 2000))
|
||||
await flush()
|
||||
expect(reads()).toBe(3)
|
||||
})
|
||||
|
||||
it('只有最后发起的读取能写入', async () => {
|
||||
const historyStore = await started()
|
||||
holdReads = true
|
||||
historyStore.open()
|
||||
await flush()
|
||||
server = [listen('3', 3), ...server]
|
||||
play(nowPlaying('1', 1000))
|
||||
await flush()
|
||||
release.pop()?.()
|
||||
await flush()
|
||||
release.pop()?.()
|
||||
await flush()
|
||||
expect(historyStore.state.listens?.map(l => l.track.id)).toEqual(['3', '2', '1'])
|
||||
})
|
||||
|
||||
it('删除之后,删除之前发起的读取作废,再读一次', async () => {
|
||||
const historyStore = await started()
|
||||
historyStore.open()
|
||||
await flush()
|
||||
holdReads = true
|
||||
play(nowPlaying('1', 1000))
|
||||
await flush()
|
||||
holdReads = false
|
||||
expect(await historyStore.remove(listen('2', 2))).toBe(true)
|
||||
expect(historyStore.state.listens).toEqual([listen('1', 1)])
|
||||
await flush()
|
||||
release.shift()?.()
|
||||
await flush()
|
||||
expect(historyStore.state.listens).toEqual([listen('1', 1)])
|
||||
expect(requests).toEqual(['GET /api/me/listens', 'GET /api/me/listens', 'POST /api/me/listens/removals', 'GET /api/me/listens'])
|
||||
})
|
||||
|
||||
it('清空', async () => {
|
||||
const historyStore = await started()
|
||||
historyStore.open()
|
||||
await flush()
|
||||
expect(await historyStore.clear()).toBe(true)
|
||||
expect(historyStore.state.listens).toEqual([])
|
||||
expect(requests).toEqual(['GET /api/me/listens', 'DELETE /api/me/listens'])
|
||||
})
|
||||
|
||||
it('读取失败时记下原因,已经读到的历史保留', async () => {
|
||||
const historyStore = await started()
|
||||
historyStore.open()
|
||||
await flush()
|
||||
failReads = true
|
||||
play(nowPlaying('1', 1000))
|
||||
await flush()
|
||||
expect(historyStore.state.error).toBe('暂时不可用')
|
||||
expect(historyStore.state.listens).toEqual(server)
|
||||
failReads = false
|
||||
play(nowPlaying('2', 2000))
|
||||
await flush()
|
||||
expect(historyStore.state.error).toBeNull()
|
||||
})
|
||||
|
||||
it('身份换成新的游客时清掉旧身份的历史,进行中的读取作废;页面开着时换好之后重新读取', async () => {
|
||||
const historyStore = await started()
|
||||
const { identityEvents } = await import('@/core/api')
|
||||
historyStore.open()
|
||||
await flush()
|
||||
holdReads = true
|
||||
play(nowPlaying('1', 1000))
|
||||
await flush()
|
||||
identityEvents.emit({ kind: 'rejected' })
|
||||
expect(historyStore.state.listens).toEqual([])
|
||||
holdReads = false
|
||||
server = [listen('9', 9)]
|
||||
identityEvents.emit({ kind: 'renewed', member: renewed })
|
||||
await flush()
|
||||
release.shift()?.()
|
||||
await flush()
|
||||
expect(historyStore.state.listens).toEqual([listen('9', 9)])
|
||||
expect(reads()).toBe(3)
|
||||
})
|
||||
|
||||
it('页面都关了时换身份只清掉旧身份的历史,不读取', async () => {
|
||||
const historyStore = await started()
|
||||
const { identityEvents } = await import('@/core/api')
|
||||
historyStore.open()()
|
||||
await flush()
|
||||
identityEvents.emit({ kind: 'rejected' })
|
||||
identityEvents.emit({ kind: 'renewed', member: renewed })
|
||||
await flush()
|
||||
expect(historyStore.state.listens).toEqual([])
|
||||
expect(reads()).toBe(1)
|
||||
})
|
||||
|
||||
it('读取本身被拒绝时,换成新的游客之后读取它的历史', async () => {
|
||||
const historyStore = await started()
|
||||
rejectedToken = 'token'
|
||||
server = [listen('9', 9)]
|
||||
historyStore.open()
|
||||
await flush()
|
||||
expect(historyStore.state).toMatchObject({ listens: [listen('9', 9)], error: null })
|
||||
expect(requests).toEqual(['GET /api/me/listens', 'POST /api/members', 'GET /api/me', 'GET /api/me/listens'])
|
||||
})
|
||||
})
|
||||
@@ -1,25 +1,30 @@
|
||||
import type { PlayRecord, SearchCategory, SearchRecord, SearchSource } from './records'
|
||||
import type { SearchCategory, SearchRecord, SearchSource } from './records'
|
||||
import type { IdentityEvent } from '@/core/api'
|
||||
import type { MyListen, Track } from '@/core/realtime'
|
||||
import { api, identityEvents } from '@/core/api'
|
||||
import { errorMessage } from '@/core/errors'
|
||||
import { roomStore } from '@/core/room'
|
||||
import { Store } from '@/shared/lib/store'
|
||||
import { parsePlays, parseSearches, recordPlay, recordSearch } from './records'
|
||||
import { notificationStore } from '@/shared/ui'
|
||||
import { parseSearches, recordSearch } from './records'
|
||||
|
||||
export const searchLimits = [5, 10, 20, 50]
|
||||
const defaultSearchLimit = 10
|
||||
|
||||
const playsKey = 'alisten.play-history'
|
||||
const playsEnabledKey = 'alisten.play-history.enabled'
|
||||
const searchesKey = 'alisten.search-history'
|
||||
const searchesEnabledKey = 'alisten.search-history.enabled'
|
||||
const searchLimitKey = 'alisten.search-history.limit'
|
||||
|
||||
export interface HistorySettings {
|
||||
playsEnabled: boolean
|
||||
export interface SearchSettings {
|
||||
searchesEnabled: boolean
|
||||
searchLimit: number
|
||||
}
|
||||
|
||||
interface HistoryState extends HistorySettings {
|
||||
plays: PlayRecord[]
|
||||
interface HistoryState extends SearchSettings {
|
||||
// 播放历史在服务端;还没有读到时为 null。
|
||||
listens: MyListen[] | null
|
||||
// 最近一次读取失败的原因,读取成功后为 null。
|
||||
error: string | null
|
||||
searches: SearchRecord[]
|
||||
}
|
||||
|
||||
@@ -32,11 +37,24 @@ function localDate(date: Date): string {
|
||||
return [date.getFullYear(), date.getMonth() + 1, date.getDate()].map(n => String(n).padStart(2, '0')).join('-')
|
||||
}
|
||||
|
||||
function playingKey(): string {
|
||||
const nowPlaying = roomStore.state.room?.nowPlaying
|
||||
return nowPlaying ? `${nowPlaying.entry.id}@${nowPlaying.startedAtMs}` : ''
|
||||
}
|
||||
|
||||
function sameTrack(a: Track, b: Track): boolean {
|
||||
return a.source === b.source && a.id === b.id
|
||||
}
|
||||
|
||||
class HistoryStore extends Store<HistoryState> {
|
||||
#playing = ''
|
||||
#pages = 0
|
||||
// 只有最后发起的读取能写入:之后又读过一次,或者删除、清空、换了身份之后,此前的读取作废。
|
||||
#generation = 0
|
||||
#reading = 0
|
||||
|
||||
constructor() {
|
||||
super({ plays: [], playsEnabled: true, searches: [], searchesEnabled: true, searchLimit: defaultSearchLimit })
|
||||
super({ listens: null, error: null, searches: [], searchesEnabled: true, searchLimit: defaultSearchLimit })
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -44,29 +62,67 @@ class HistoryStore extends Store<HistoryState> {
|
||||
const storedSearches = localStorage.getItem(searchesKey)
|
||||
const searches = parseSearches(storedSearches, searchLimit)
|
||||
this.setState({
|
||||
plays: parsePlays(localStorage.getItem(playsKey)),
|
||||
playsEnabled: localStorage.getItem(playsEnabledKey) !== 'false',
|
||||
searches,
|
||||
searchesEnabled: localStorage.getItem(searchesEnabledKey) !== 'false',
|
||||
searchLimit,
|
||||
})
|
||||
if (storedSearches !== null && JSON.stringify(searches) !== storedSearches)
|
||||
this.#saveSearches(searches)
|
||||
this.#playing = playingKey()
|
||||
roomStore.addEventListener('change', this.#onRoom)
|
||||
window.addEventListener('storage', this.#onStorage)
|
||||
identityEvents.on(this.#onIdentity)
|
||||
}
|
||||
|
||||
removePlay(record: PlayRecord): void {
|
||||
this.#savePlays(this.state.plays.filter(play => play !== record))
|
||||
// 每打开一个历史页读取一次;页面开着时,服务端在开播时写入历史,所以正在播放的歌一变就重新读取。返回的函数在页面关闭时调用。
|
||||
open(): () => void {
|
||||
this.#pages++
|
||||
void this.load()
|
||||
let open = true
|
||||
return () => {
|
||||
if (open)
|
||||
this.#pages--
|
||||
open = false
|
||||
}
|
||||
}
|
||||
|
||||
clearPlays(): void {
|
||||
this.#savePlays([])
|
||||
// 失败时只设置 error,不抛出。
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.#generation
|
||||
this.#reading++
|
||||
try {
|
||||
const { items } = await api.listMyListens()
|
||||
if (generation === this.#generation)
|
||||
this.setState({ listens: items, error: null })
|
||||
}
|
||||
catch (error) {
|
||||
if (generation === this.#generation)
|
||||
this.setState({ error: errorMessage(error) })
|
||||
}
|
||||
finally {
|
||||
this.#reading--
|
||||
}
|
||||
}
|
||||
|
||||
exportPlays(): void {
|
||||
// 返回 false 时,失败原因已经通过通知显示。
|
||||
async remove(listen: MyListen): Promise<boolean> {
|
||||
if (!await this.#write(() => api.removeMyListen({ track: listen.track })))
|
||||
return false
|
||||
this.#changed((this.state.listens ?? []).filter(l => !sameTrack(l.track, listen.track)))
|
||||
return true
|
||||
}
|
||||
|
||||
// 返回 false 时,失败原因已经通过通知显示。
|
||||
async clear(): Promise<boolean> {
|
||||
if (!await this.#write(() => api.clearMyListens()))
|
||||
return false
|
||||
this.#changed([])
|
||||
return true
|
||||
}
|
||||
|
||||
exportListens(): void {
|
||||
const now = new Date()
|
||||
const file = new Blob([JSON.stringify({ exportedAt: now.toISOString(), history: this.state.plays }, null, 2)], { type: 'application/json' })
|
||||
const file = new Blob([JSON.stringify({ exportedAt: now.toISOString(), history: this.state.listens ?? [] }, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(file)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
@@ -86,38 +142,60 @@ class HistoryStore extends Store<HistoryState> {
|
||||
this.#saveSearches([])
|
||||
}
|
||||
|
||||
updateSettings({ playsEnabled, searchesEnabled, searchLimit }: HistorySettings): void {
|
||||
localStorage.setItem(playsEnabledKey, String(playsEnabled))
|
||||
updateSettings({ searchesEnabled, searchLimit }: SearchSettings): void {
|
||||
localStorage.setItem(searchesEnabledKey, String(searchesEnabled))
|
||||
localStorage.setItem(searchLimitKey, String(searchLimit))
|
||||
this.setState({ playsEnabled, searchesEnabled, searchLimit })
|
||||
this.setState({ searchesEnabled, searchLimit })
|
||||
if (this.state.searches.length > searchLimit)
|
||||
this.#saveSearches(this.state.searches.slice(0, searchLimit))
|
||||
}
|
||||
|
||||
async #write(request: () => Promise<void>): Promise<boolean> {
|
||||
try {
|
||||
await request()
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(errorMessage(error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 改动之前发起的读取可能带着改动前的历史返回,作废它们;有读取被作废时再读一次,免得错过其间开播的歌。
|
||||
#changed(listens: MyListen[]): void {
|
||||
this.#generation++
|
||||
this.setState({ listens })
|
||||
if (this.#reading > 0)
|
||||
void this.load()
|
||||
}
|
||||
|
||||
#onRoom = (): void => {
|
||||
const nowPlaying = roomStore.state.room?.nowPlaying ?? null
|
||||
const playing = nowPlaying ? `${nowPlaying.entry.id}@${nowPlaying.startedAtMs}` : ''
|
||||
const playing = playingKey()
|
||||
if (playing === this.#playing)
|
||||
return
|
||||
this.#playing = playing
|
||||
if (nowPlaying && this.state.playsEnabled)
|
||||
this.#savePlays(recordPlay(this.state.plays, { track: nowPlaying.entry.track, meta: nowPlaying.entry.meta, playedAtMs: nowPlaying.startedAtMs }))
|
||||
if (playing && this.#pages > 0)
|
||||
void this.load()
|
||||
}
|
||||
|
||||
// 其他标签页写入历史时同步更新,本页之后的写入才不会覆盖它写入的记录。
|
||||
// 成员 token 被拒绝时页面不重新加载:旧身份的历史不能留在页面上,新的游客还没有播放历史。新身份也可能是其他标签页
|
||||
// 换来的、已经有历史的游客,所以换好之后页面开着就重新读取。
|
||||
#onIdentity = (event: IdentityEvent): void => {
|
||||
if (event.kind === 'rejected') {
|
||||
this.#generation++
|
||||
this.setState({ listens: [], error: null })
|
||||
}
|
||||
else if (event.kind === 'renewed' && this.#pages > 0) {
|
||||
void this.load()
|
||||
}
|
||||
}
|
||||
|
||||
// 其他标签页写入搜索记录时同步更新,本页之后的写入才不会覆盖它写入的记录。
|
||||
#onStorage = (event: StorageEvent): void => {
|
||||
if (event.key === playsKey)
|
||||
this.setState({ plays: parsePlays(event.newValue) })
|
||||
else if (event.key === searchesKey)
|
||||
if (event.key === searchesKey)
|
||||
this.setState({ searches: parseSearches(event.newValue, this.state.searchLimit) })
|
||||
}
|
||||
|
||||
#savePlays(plays: PlayRecord[]): void {
|
||||
localStorage.setItem(playsKey, JSON.stringify(plays))
|
||||
this.setState({ plays })
|
||||
}
|
||||
|
||||
#saveSearches(searches: SearchRecord[]): void {
|
||||
localStorage.setItem(searchesKey, JSON.stringify(searches))
|
||||
this.setState({ searches })
|
||||
|
||||
@@ -1,61 +1,11 @@
|
||||
import type { PlayRecord, SearchCategory, SearchRecord, SearchSource } from './records'
|
||||
import type { SearchCategory, SearchRecord, SearchSource } from './records'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parsePlays, parseSearches, playHistoryLimit, recordPlay, recordSearch } from './records'
|
||||
|
||||
function play(id: string, playedAtMs: number, source: PlayRecord['track']['source'] = 'netease'): PlayRecord {
|
||||
return {
|
||||
track: { source, id },
|
||||
meta: { title: id, artist: '', album: '', coverUrl: '', pageUrl: '', durationMs: 1000 },
|
||||
playedAtMs,
|
||||
}
|
||||
}
|
||||
import { parseSearches, recordSearch } from './records'
|
||||
|
||||
function search(query: string, searchedAtMs: number, source: SearchSource = 'netease', category: SearchCategory = 'all'): SearchRecord {
|
||||
return { query, source, category, searchedAtMs }
|
||||
}
|
||||
|
||||
describe('recordPlay', () => {
|
||||
it('新的记录排在最前,同一首歌只留最近一次', () => {
|
||||
const plays = recordPlay([play('2', 200), play('1', 100)], play('1', 300))
|
||||
expect(plays).toEqual([play('1', 300), play('2', 200)])
|
||||
})
|
||||
|
||||
it('来源不同的同一个 ID 是两首歌', () => {
|
||||
expect(recordPlay([play('1', 100, 'qq')], play('1', 200))).toEqual([play('1', 200), play('1', 100, 'qq')])
|
||||
})
|
||||
|
||||
it('最多保留 500 条', () => {
|
||||
const plays = Array.from({ length: playHistoryLimit }, (_, i) => play(String(i), i))
|
||||
const next = recordPlay(plays, play('new', 1000))
|
||||
expect(next).toHaveLength(500)
|
||||
expect(next[0]).toEqual(play('new', 1000))
|
||||
expect(next[499]).toEqual(play('498', 498))
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsePlays', () => {
|
||||
it('保留 B 站的播放记录', () => {
|
||||
const record = play('BV1xx411c7mD/2', 1, 'bilibili')
|
||||
expect(parsePlays(JSON.stringify([record]))).toEqual([record])
|
||||
})
|
||||
|
||||
it('丢弃不完整的记录', () => {
|
||||
const broken = [
|
||||
{ track: { source: 'netease', id: '2' }, playedAtMs: 1 },
|
||||
{ ...play('3', 3), track: { source: 'other', id: '3' } },
|
||||
{ ...play('4', 4), meta: { title: '4', artist: '', album: '', coverUrl: '', durationMs: 1 } },
|
||||
null,
|
||||
]
|
||||
expect(parsePlays(JSON.stringify([play('1', 1), ...broken]))).toEqual([play('1', 1)])
|
||||
})
|
||||
|
||||
it('没有记录或者内容无法解析时为空', () => {
|
||||
expect(parsePlays(null)).toEqual([])
|
||||
expect(parsePlays('{')).toEqual([])
|
||||
expect(parsePlays('{}')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('recordSearch', () => {
|
||||
it('不区分大小写和首尾空白,同一个查询只留最近的一条,平台和类别取最近一次', () => {
|
||||
const searches = recordSearch([search('晴天', 1), search('Jay', 2, 'qq', 'tracks')], search(' jay ', 3, 'netease', 'playlists'), 10)
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import type { Source, Track, TrackMeta } from '@/core/realtime'
|
||||
|
||||
export interface PlayRecord {
|
||||
track: Track
|
||||
meta: TrackMeta
|
||||
playedAtMs: number
|
||||
}
|
||||
|
||||
export type SearchSource = 'netease' | 'qq'
|
||||
export type SearchCategory = 'all' | 'tracks' | 'playlists'
|
||||
|
||||
@@ -16,12 +8,8 @@ export interface SearchRecord {
|
||||
searchedAtMs: number
|
||||
}
|
||||
|
||||
export const playHistoryLimit = 500
|
||||
|
||||
const trackSources: unknown[] = ['netease', 'qq', 'link', 'bilibili'] satisfies Source[]
|
||||
const searchSources: unknown[] = ['netease', 'qq'] satisfies SearchSource[]
|
||||
const searchCategories: unknown[] = ['all', 'tracks', 'playlists'] satisfies SearchCategory[]
|
||||
const metaText = ['title', 'artist', 'album', 'coverUrl', 'pageUrl'] as const
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
@@ -37,28 +25,11 @@ function readList(stored: string | null): unknown[] {
|
||||
}
|
||||
}
|
||||
|
||||
function isPlayRecord(value: unknown): value is PlayRecord {
|
||||
if (!isObject(value) || !isObject(value.track) || !isObject(value.meta) || typeof value.playedAtMs !== 'number')
|
||||
return false
|
||||
const { track, meta } = value
|
||||
return trackSources.includes(track.source) && typeof track.id === 'string'
|
||||
&& metaText.every(key => typeof meta[key] === 'string') && typeof meta.durationMs === 'number'
|
||||
}
|
||||
|
||||
function isSearchRecord(value: unknown): value is SearchRecord {
|
||||
return isObject(value) && typeof value.query === 'string' && typeof value.searchedAtMs === 'number'
|
||||
&& searchSources.includes(value.source) && searchCategories.includes(value.category)
|
||||
}
|
||||
|
||||
export function parsePlays(stored: string | null): PlayRecord[] {
|
||||
return readList(stored).filter(isPlayRecord).slice(0, playHistoryLimit)
|
||||
}
|
||||
|
||||
export function recordPlay(plays: PlayRecord[], record: PlayRecord): PlayRecord[] {
|
||||
const rest = plays.filter(play => play.track.source !== record.track.source || play.track.id !== record.track.id)
|
||||
return [record, ...rest].slice(0, playHistoryLimit)
|
||||
}
|
||||
|
||||
function queryKey(query: string): string {
|
||||
return query.trim().toLowerCase()
|
||||
}
|
||||
|
||||
@@ -337,3 +337,51 @@ describe('邮箱', () => {
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('记录播放历史', () => {
|
||||
beforeEach(() => {
|
||||
local.setItem(memberTokenKey, 'good')
|
||||
})
|
||||
|
||||
it('取回资料时一并取回开关,修改时调用对应的接口', async () => {
|
||||
me = { member, recordListens: false }
|
||||
const switched: string[] = []
|
||||
extraRoutes.set('PUT /api/me/listen-recording', () => {
|
||||
switched.push('on')
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
extraRoutes.set('DELETE /api/me/listen-recording', () => {
|
||||
switched.push('off')
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
const { profileStore } = await load()
|
||||
expect(profileStore.state.recordListens).toBeNull()
|
||||
await profileStore.load()
|
||||
expect(profileStore.state.recordListens).toBe(false)
|
||||
expect(await profileStore.setRecordListens(true)).toBe(true)
|
||||
expect(profileStore.state.recordListens).toBe(true)
|
||||
expect(await profileStore.setRecordListens(false)).toBe(true)
|
||||
expect(profileStore.state.recordListens).toBe(false)
|
||||
expect(switched).toEqual(['on', 'off'])
|
||||
})
|
||||
|
||||
it('修改失败时开关不变', async () => {
|
||||
me = { member, recordListens: true }
|
||||
extraRoutes.set('DELETE /api/me/listen-recording', () => new Response(JSON.stringify({ error: { code: 'UNAVAILABLE', message: '暂时不可用' } }), { status: 503 }))
|
||||
const { profileStore } = await load()
|
||||
await profileStore.load()
|
||||
expect(await profileStore.setRecordListens(false)).toBe(false)
|
||||
expect(profileStore.state.recordListens).toBe(true)
|
||||
})
|
||||
|
||||
it('身份被拒绝时开关未知,换成新的游客之后是默认的开启', async () => {
|
||||
me = { member, recordListens: false }
|
||||
const { profileStore, identityEvents } = await load()
|
||||
profileStore.start()
|
||||
await profileStore.load()
|
||||
identityEvents.emit({ kind: 'rejected' })
|
||||
expect(profileStore.state.recordListens).toBeNull()
|
||||
identityEvents.emit({ kind: 'renewed', member: renewed })
|
||||
expect(profileStore.state.recordListens).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -8,11 +8,20 @@ import { notificationStore } from '@/shared/ui'
|
||||
// 游客的邮箱只保存在本机,服务端只保存它的哈希;已注册账号的邮箱保存在账号里。
|
||||
const emailKey = 'alisten.email'
|
||||
|
||||
class ProfileStore extends Store<{ member: Member | null, account: Account | null, email: string, failed: boolean }> {
|
||||
interface ProfileState {
|
||||
member: Member | null
|
||||
account: Account | null
|
||||
email: string
|
||||
// 是否记录播放历史,保存在服务端;还没有读到时为 null。
|
||||
recordListens: boolean | null
|
||||
failed: boolean
|
||||
}
|
||||
|
||||
class ProfileStore extends Store<ProfileState> {
|
||||
#loading: Promise<void> | null = null
|
||||
|
||||
constructor() {
|
||||
super({ member: null, account: null, email: '', failed: false })
|
||||
super({ member: null, account: null, email: '', recordListens: null, failed: false })
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -58,6 +67,19 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
|
||||
return await this.#update(changes) === null
|
||||
}
|
||||
|
||||
// 返回 false 时,失败原因已经通过通知显示。
|
||||
async setRecordListens(on: boolean): Promise<boolean> {
|
||||
try {
|
||||
await (on ? api.enableListenRecording() : api.disableListenRecording())
|
||||
this.setState({ recordListens: on })
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(error instanceof IdentityRenewedError ? '设置没有保存,再保存一次。' : errorMessage(error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 失败时返回错误,失败原因已经通过通知显示。
|
||||
async #update(changes: { name?: string, email?: string }): Promise<unknown> {
|
||||
try {
|
||||
@@ -87,8 +109,8 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
|
||||
async #load(): Promise<void> {
|
||||
this.setState({ failed: false })
|
||||
try {
|
||||
const { member, account } = await api.getMe()
|
||||
this.setState({ member, account: account ?? null, email: account ? account.email ?? '' : localStorage.getItem(emailKey) ?? '', failed: false })
|
||||
const { member, account, recordListens } = await api.getMe()
|
||||
this.setState({ member, account: account ?? null, email: account ? account.email ?? '' : localStorage.getItem(emailKey) ?? '', recordListens, failed: false })
|
||||
if (account && !account.email)
|
||||
await this.#moveLocalEmail()
|
||||
}
|
||||
@@ -116,10 +138,11 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
|
||||
case 'rejected':
|
||||
// 本机保存的邮箱属于原来的身份。
|
||||
localStorage.removeItem(emailKey)
|
||||
this.setState({ member: null, account: null, email: '', failed: false })
|
||||
this.setState({ member: null, account: null, email: '', recordListens: null, failed: false })
|
||||
return
|
||||
case 'renewed':
|
||||
this.setState({ member: event.member, account: null, failed: false })
|
||||
// 新的游客按默认记录播放历史。
|
||||
this.setState({ member: event.member, account: null, recordListens: true, failed: false })
|
||||
notificationStore.info('原来的身份已经失效,已为你换了一个新的身份。', 8000)
|
||||
return
|
||||
case 'renewalFailed':
|
||||
|
||||
@@ -27,7 +27,7 @@ interface SettingsDraft {
|
||||
reducedMotion: boolean
|
||||
searchesEnabled: boolean
|
||||
searchLimit: number
|
||||
playsEnabled: boolean
|
||||
recordListens: boolean
|
||||
}
|
||||
|
||||
type DraftKey = keyof SettingsDraft
|
||||
@@ -70,6 +70,7 @@ export function SettingsPage() {
|
||||
const member = useStore(profileStore, s => s.member)
|
||||
const email = useStore(profileStore, s => s.email)
|
||||
const account = useStore(profileStore, s => s.account)
|
||||
const recordListens = useStore(profileStore, s => s.recordListens)
|
||||
const profileFailed = useStore(profileStore, s => s.failed)
|
||||
const theme = useStore(appearanceStore, s => s.theme)
|
||||
const fontSize = useStore(appearanceStore, s => s.fontSize)
|
||||
@@ -81,7 +82,6 @@ export function SettingsPage() {
|
||||
const reducedMotion = useStore(performanceStore, s => s.reducedMotion)
|
||||
const searchesEnabled = useStore(historyStore, s => s.searchesEnabled)
|
||||
const searchLimit = useStore(historyStore, s => s.searchLimit)
|
||||
const playsEnabled = useStore(historyStore, s => s.playsEnabled)
|
||||
const [changes, setChanges] = useState<Partial<SettingsDraft>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
// state 要到下一次渲染才更新,连续提交时由这个 ref 阻止重复保存。
|
||||
@@ -90,7 +90,7 @@ export function SettingsPage() {
|
||||
headingRef.current?.focus({ preventScroll: true })
|
||||
loadProfile()
|
||||
}, [])
|
||||
const current: SettingsDraft = { name: member?.name ?? '', email, theme, fontSize, volume, muted, reminderEnabled, systemNotifications, level, reducedMotion, searchesEnabled, searchLimit, playsEnabled }
|
||||
const current: SettingsDraft = { name: member?.name ?? '', email, theme, fontSize, volume, muted, reminderEnabled, systemNotifications, level, reducedMotion, searchesEnabled, searchLimit, recordListens: recordListens ?? false }
|
||||
const draft: SettingsDraft = { ...current, ...changes }
|
||||
const changed = (keys: DraftKey[]) => keys.some(key => draft[key] !== current[key])
|
||||
const dirty = changed(Object.keys(changes) as DraftKey[])
|
||||
@@ -146,9 +146,15 @@ export function SettingsPage() {
|
||||
performanceStore.update(draft.level, draft.reducedMotion)
|
||||
saved.push('level', 'reducedMotion')
|
||||
}
|
||||
if (changed(['searchesEnabled', 'searchLimit', 'playsEnabled'])) {
|
||||
historyStore.updateSettings({ searchesEnabled: draft.searchesEnabled, searchLimit: draft.searchLimit, playsEnabled: draft.playsEnabled })
|
||||
saved.push('searchesEnabled', 'searchLimit', 'playsEnabled')
|
||||
if (changed(['searchesEnabled', 'searchLimit'])) {
|
||||
historyStore.updateSettings({ searchesEnabled: draft.searchesEnabled, searchLimit: draft.searchLimit })
|
||||
saved.push('searchesEnabled', 'searchLimit')
|
||||
}
|
||||
if (changed(['recordListens'])) {
|
||||
if (await profileStore.setRecordListens(draft.recordListens))
|
||||
saved.push('recordListens')
|
||||
else
|
||||
failed = true
|
||||
}
|
||||
setChanges((previous) => {
|
||||
const rest = { ...previous }
|
||||
@@ -268,8 +274,8 @@ export function SettingsPage() {
|
||||
options={searchLimits.map(limit => ({ value: String(limit), label: `${limit} 条` }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow id="settings-play-history" title="记录播放历史" description="把房间播放过的歌曲保存在这台设备上。">
|
||||
<input id="settings-play-history" aria-describedby="settings-play-history-description" className="settings-switch" type="checkbox" role="switch" checked={draft.playsEnabled} onChange={event => update('playsEnabled', event.target.checked)} />
|
||||
<SettingRow id="settings-play-history" title="记录播放历史" description={recordListens === null && profileFailed ? '暂时无法获取这项设置。' : '把你在房间里听过的歌记进播放历史。'}>
|
||||
<input id="settings-play-history" aria-describedby="settings-play-history-description" className="settings-switch" type="checkbox" role="switch" checked={draft.recordListens} disabled={recordListens === null} onChange={event => update('recordListens', event.target.checked)} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in new issue
Block a user