feat: show listening history from the server and sync the recording switch across devices

This commit is contained in:
DiheChen committed 2026-11-11 00:06:40 +08:00
1 parent 17e486209e
commit 910e7339ad
8 files changed
+541 -185

No files matched your search

+72 -57
View File
@@ -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'])
})
})
+111 -33
View File
@@ -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 })
+2 -52
View File
@@ -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)
-29
View File
@@ -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)
})
})
+29 -6
View File
@@ -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':
+14 -8
View File
@@ -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>