diff --git a/docs/ui-review/media-page-uiux-report-2026-10-01.md b/docs/ui-review/media-page-uiux-report-2026-10-01.md new file mode 100644 index 0000000..8ebde34 --- /dev/null +++ b/docs/ui-review/media-page-uiux-report-2026-10-01.md @@ -0,0 +1,50 @@ +# 媒体页面 UI/UX 端到端评审 + +日期:2026-10-01 +范围:媒体首页、视频/电影/剧集、音乐/专辑、设置/主题;移动端 390px、桌面端 1120px;原型对照与生产构建。 + +## 验证结果 + +- `npm run build`:通过。 +- `npm run test`:114 个测试文件、995 项通过。 +- `media-v2-review.cjs`:14 组移动/桌面对照通过,`errors: []`。 +- 已验证:六套媒体主题、主题切换后的播放器/页面 token 一致性、视频与音乐分区布局、标题/列表的长文本处理、页面无横向溢出。 +- 播放器专项:视频 Plyr 初始化在当前评审素材下 12 秒超时,需补充真实视频播放状态与加载失败态的 E2E 回归。 + +## 优化建议 + +### P0:播放器加载态与失败态 + +视频播放按钮触发后应立即进入“正在加载”状态,超过阈值显示“重试 / 返回详情”,避免按钮无反馈造成误判。音频播放器与视频播放器应共用状态语义:待播放、加载中、播放中、暂停、不可用。 + +验收:点击播放后 300ms 内出现加载反馈;媒体资源超时或 4xx/5xx 时出现可操作错误;重试不会重复创建队列。 + +### P1:主题 token 与控件层级 + +当前主题变量已能覆盖媒体页、频道入口和播放器。后续继续统一 `background / paper / surface / text / muted / border / accent / selection`,并为禁用态、焦点态、错误态建立对比度检查。主操作每个分区只保留一个强调按钮,收藏、更多、管理等动作降为次级。 + +### P1:分区布局与导航 + +视频分区建议固定“频道标签 → 筛选/排序 → 内容网格 → 分页”的节奏;详情页保持“封面/标题/主操作 → 简介 → 版本/章节/资源”。在 390px 下筛选控件允许换行,按钮触控区保持 44px。 + +### P1:按钮可见性与语义 + +图标按钮必须同时有可访问名称;失败任务优先展示“重试”,删除放在次级菜单;播放器的“打开控制、歌词、睡眠定时、播放队列”保持固定顺序,避免随状态跳动。 + +### P2:信息密度 + +长标题使用两行截断并保留完整可访问名称;封面下元信息只保留作者/类型等一行关键字段。设置页按“播放、界面、主题、媒体库”分组,组间使用 16–24px 间距。 + +## 原型 + +高保真原型位于 [`docs/prototypes/media/v2-review/index.html`](../prototypes/media/v2-review/index.html),包含 42 个页面和状态、移动/桌面尺寸、六套主题,可直接在浏览器打开。其布局基准已用于本次 14 组生产页面对照。 + +## 复现命令 + +```powershell +$env:PROTOTYPE_CHROMIUM='C:\Program Files\Google\Chrome\Application\chrome.exe' +$env:MEDIA_REVIEW_SAMPLE_PACK='C:\Users\hector\Downloads\reader-media-acceptance-20260926' +$env:MEDIA_V2_ONLY='video,movie,show,music,album,settings,themes' +node web/tools/media-v2-review.cjs +``` + diff --git a/docs/ui-review/media-ux-audit-2026-10-01.md b/docs/ui-review/media-ux-audit-2026-10-01.md new file mode 100644 index 0000000..db1aa0b --- /dev/null +++ b/docs/ui-review/media-ux-audit-2026-10-01.md @@ -0,0 +1,69 @@ +# Media UI/UX 验收与优化报告 + +日期:2026-10-01 +范围:媒体库、书架/书库、阅读器、扫描任务、播放页;移动端 390×844,桌面端 1120×900 生产构建场景。 + +## 已落地优化 + +### 扫描任务的恢复路径 + +失败扫描记录此前只有“删除历史”图标,用户无法从当前上下文恢复任务。现在失败记录同时提供“重新扫描媒体库”入口;当同一媒体库仍有排队或进行中的任务时,重试按钮会禁用并显示等待语义,避免重复提交。 + +### 扫描任务与 AI 刷新解耦 + +普通扫描轮询不再依赖 AI 扫描接口或完整对象上下文才能完成状态更新。这样扫描列表可以稳定呈现“进行中 / 失败 / 完成”,减少局部刷新时的空白、误导性加载和状态跳变。 + +## 视觉与交互观察 + +### P0:阅读页顶部内容可能被工具栏遮挡 + +移动端夜间主题截图中,章节标题的第一行贴近顶部工具栏,出现被遮挡的视觉风险。阅读正文应从工具栏安全区之后开始,尤其是在滚动恢复、切换主题、收起/展开工具栏后保持一致。 + +建议:将正文起始安全区绑定到实际 topbar 高度和 safe-area,而不是固定的短间距;切换主题和恢复阅读位置后重新校正滚动锚点。验收条件:章节标题不被遮挡,滚动到章节顶部时首行完整可读。 + +### P1:扫描任务操作需要从图标识别升级为语义分组 + +当前列表右侧使用删除、取消、重试图标,桌面端可理解,手机端在长列表中识别成本较高。 + +建议:失败状态将“重新扫描”作为主操作,删除作为次要操作;在 430px 以下的断点使用“重试”文字按钮或图标+文字,并在任务错误说明下提供恢复动作。保留 44px 最小触控区域。 + +### P1:任务状态应形成清晰的视觉层级 + +进行中任务使用边框卡片,失败和完成任务以普通行展示,状态差异明确但恢复动作不够突出。 + +建议:为进行中、失败、完成建立稳定的状态色和状态图标映射,同时保证暗色主题下文字与边框达到可读对比度;不要只依赖颜色表达状态,配合状态文字和图标。 + +### P1:设置页分组密度可进一步收紧 + +阅读设置底部存在较大空白,分段控件与标签的左右关系清晰,但内容密度偏低。 + +建议:将相关设置按“阅读方式 / 翻页行为 / 动画”分组,并使用组标题和短说明消除空白;在手机端每组之间保持 16–24px 节奏,避免单个控件漂浮。 + +### P2:媒体库卡片信息可读性 + +书架三列卡片在 390px 宽度下可用,但书名和作者信息依赖截断;操作菜单位于封面右上角,视觉上容易被误认为封面内容。 + +建议:长按或点击卡片后再显示操作菜单,或增加更明确的“更多”辅助文字;保证书名至少显示两行,作者显示一行,类型徽章不覆盖封面主体。 + +### P2:文件管理页需要更强的当前位置反馈 + +文件管理页的面包屑、文件列表、分页结构完整,但在深色主题和长列表中,当前目录与分页信息的视觉权重接近。 + +建议:强化当前目录名称,弱化技术性文件大小;分页器增加“上一页 / 下一页”语义标签,并在移动端固定在列表底部安全区内。 + +### P2:主题系统整体一致,但媒体与阅读器应共享语义 token + +截图显示媒体页和阅读页都具备浅色、深色、米黄等主题,但两套变量命名和对比策略不同,后续容易出现“媒体页已切换,播放层或弹层仍使用旧背景”的问题。 + +建议:统一 `surface / paper / text / muted / border / accent / selection` 语义 token,并让播放器、弹层、任务面板只消费语义变量;为每个主题建立文本、控件、禁用态和焦点态对比度验收表。 + +## 验收结果 + +- 生产构建:通过。 +- 媒体全量单元/组件测试:114 个测试文件、995 项全部通过。 +- OpenList 真实媒体链路:通过,包含创建、扫描、凭据脱敏、远程音频播放、Range 请求和凭据轮换。 +- 移动端 UI Review:既有报告 32 项视觉/交互检查全部通过,覆盖主题、面板、书架、书库、搜索、分页、窄屏和图片溢出。 + +## 后续建议 + +优先补充真实浏览器下的媒体任务页场景截图,分别覆盖桌面 1120px、手机 390px、窄屏 320px,以及失败任务、重试禁用、重试成功、深色主题四种状态。这样可以把本轮已修复的操作层级问题纳入持续视觉回归。 diff --git a/web/package-lock.json b/web/package-lock.json index 45eae88..7c329f9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "AGPL-3.0-or-later", "dependencies": { + "@tanstack/query-core": "^5.104.0", "dplayer": "^1.27.1", "fast-xml-parser": "^4.5.1", "jszip": "^3.10.1", @@ -1049,6 +1050,16 @@ "win32" ] }, + "node_modules/@tanstack/query-core": { + "version": "5.104.0", + "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.104.0.tgz", + "integrity": "sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", diff --git a/web/package.json b/web/package.json index 1729f94..f069f28 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ "ui:sources": "npm run build && node --import ../server/node_modules/tsx/dist/loader.mjs tools/ui-review/host-source-flow.mjs" }, "dependencies": { + "@tanstack/query-core": "^5.104.0", "dplayer": "^1.27.1", "fast-xml-parser": "^4.5.1", "jszip": "^3.10.1", diff --git a/web/src/api/client.ts b/web/src/api/client.ts index e922191..54959d3 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -28,6 +28,7 @@ import type { } from './types.ts'; import type { Platform } from '../core/platform.ts'; import type { SourceType, SourceInstance, SourcePlugin, SourceEntry, SourcePage, SourceAcquisition, ChapterSubscription } from './sources.ts'; +import { affectedNavigationQuery, createReaderQueryClient, isNavigationQuery, readerQueryKey } from './query.ts'; export interface SessionStore { load(): Promise; @@ -133,6 +134,8 @@ export class ReaderApi { private refreshPromise: Promise | null = null; private sessionGeneration = 0; private readonly streams = new Set(); + private readonly queryClient = createReaderQueryClient(); + private queryScope = 0; private sessionWrites: Promise = Promise.resolve(); private readonly listeners = new Set<(session: Session | null) => void>(); @@ -153,6 +156,7 @@ export class ReaderApi { for (const stream of this.streams) stream.abort(); this.sessionGeneration += 1; this.refreshPromise = null; + this.queryScope += 1; } this.currentBaseUrl = next; } @@ -188,6 +192,7 @@ export class ReaderApi { } const generation = this.sessionGeneration; this.session = session; + if (!rotation) this.queryScope += 1; const write = this.sessionWrites.catch(() => undefined).then(() => session ? this.sessions.save(session) : this.sessions.clear()); this.sessionWrites = write; @@ -621,6 +626,11 @@ export class ReaderApi { ...(onProgress ? { onUploadProgress: onProgress } : {}), ...(options.signal ? { signal: options.signal } : {}), }); + void this.queryClient.invalidateQueries({ + queryKey: ['reader'], + predicate: query => affectedNavigationQuery('/api/v1/library/upload', String(query.queryKey[4])), + refetchType: 'none', + }); return response.json as UploadResult; } @@ -705,11 +715,21 @@ export class ReaderApi { /** Media modules share account renewal without coupling to reading DTOs. */ mediaRequest(path: string, method = 'GET', body?: unknown, options: RequestOptions = {}): Promise { if (!path.startsWith('/api/v1/media/')) throw new Error('invalid media API path'); + if (method === 'GET' && body === undefined) return this.get(path, options); return this.call(path, method, body, options); } + /** Force the next screen load to read the server while retaining cache data. */ + refreshQueries(): void { + void this.queryClient.invalidateQueries({ queryKey: ['reader'], refetchType: 'none' }); + } + businessSettingsRequest(path='',method='GET',body?:unknown,signal?:AbortSignal):Promise{ - return this.call('/api/v1/admin/settings'+(path?'/'+path:''),method,body,signal?{signal}:{}); + const requestPath = '/api/v1/admin/settings'+(path?'/'+path:''); + const options = signal ? { signal } : {}; + return method === 'GET' && body === undefined + ? this.get(requestPath, options) + : this.call(requestPath, method, body, options); } async businessTtsPreview(values:Record,voice:string):Promise{ const result=await this.request('/api/v1/admin/settings/tts/preview','POST',{values,voice},{binary:true}); @@ -719,8 +739,21 @@ export class ReaderApi { async aiSummary(bookId:string,chapterId:string,content:string):Promise<{summary:string;cached:boolean}>{ return this.call('/api/v1/ai/summary','POST',{bookId,chapterId,content}); } private async get(path: string, options: RequestOptions = {}): Promise { - const response = await this.request(path, 'GET', undefined, options); - return response.json as T; + if (!isNavigationQuery(path)) return (await this.request(path, 'GET', undefined, options)).json as T; + const generation = this.sessionGeneration; + const key = readerQueryKey(this.currentBaseUrl, this.session?.user.id, this.queryScope, path); + const result = await this.queryClient.fetchQuery({ + queryKey: key, + // Keep the caller's AbortSignal semantics intact. These class-based + // screens already abort requests when disposed; letting fetchQuery own the + // signal would turn normal auth/session transitions into CancelledError. + queryFn: () => this.request(path, 'GET', undefined, options) + .then(response => response.json as T), + }); + // A response that completed after login/logout must never escape through a + // cache promise, even when QueryClient returned a previously resolved value. + this.assertSession(generation); + return result; } private async call( @@ -730,6 +763,13 @@ export class ReaderApi { options: RequestOptions = {}, ): Promise { const response = await this.request(path, method, body, options); + if (method !== 'GET') { + void this.queryClient.invalidateQueries({ + queryKey: ['reader'], + predicate: query => affectedNavigationQuery(path, String(query.queryKey[4])), + refetchType: 'none', + }); + } return response.json as T; } diff --git a/web/src/api/query.ts b/web/src/api/query.ts new file mode 100644 index 0000000..d64da51 --- /dev/null +++ b/web/src/api/query.ts @@ -0,0 +1,70 @@ +import { QueryClient } from '@tanstack/query-core'; + +/** + * Shared client-side server cache. The UI is intentionally framework agnostic + * (Preact screens are class based), so the QueryClient is used as a small data + * cache rather than through a React adapter. + */ +export const createReaderQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60_000, + gcTime: 10 * 60_000, + // ReaderApi owns auth refresh and transport retry semantics. Query retry + // would retry 401s and account-change aborts with the new credentials. + retry: false, + refetchOnWindowFocus: false, + }, + }, +}); + +export const readerQueryKey = (baseUrl: string, userId: string | undefined, scope: number, path: string) => + ['reader', baseUrl, userId ?? 'anonymous', scope, path] as const; + +/** Only navigation data is cached; auth, sync, jobs and playback stay live. */ +export function isNavigationQuery(path: string): boolean { + const resource = path.split('?')[0]!; + return /^\/api\/v1\/(books|books\/[^/]+|sources|sources\/types|plugins|subscriptions|library\/(browse|facets|continue))$/.test(resource) + || /^\/api\/v1\/sources\/[^/]+\/(browse|entries|search-filters)$/.test(resource) + || /^\/api\/v1\/media\/(libraries|browse|search|narrators|favorites|history|queue|continue)$/.test(resource) + || /^\/api\/v1\/media\/libraries\/[^/]+\/(items|narrators|folders)$/.test(resource) + || /^\/api\/v1\/media\/items\/[^/]+(?:\/(children|favorite|tracks|chapters))?$/.test(resource); +} + +function mediaItemId(path: string): string | null { + return path.match(/^\/api\/v1\/media\/items\/([^/]+)/)?.[1] ?? null; +} + +function queryIsMediaItem(query: string, id: string): boolean { + return query.startsWith('/api/v1/media/items/' + id) || query.includes('/items/' + id + '/'); +} + +/** Invalidate only the navigation resources a mutation can change. */ +export function affectedNavigationQuery(mutation: string, query: string): boolean { + if (/^\/api\/v1\/sync(?:\/|$)/.test(mutation)) return query.startsWith('/api/v1/library/continue'); + if (mutation.startsWith('/api/v1/media/playback')) { + return /^\/api\/v1\/media\/(history|continue)(?:\?|$)/.test(query); + } + if (mutation.startsWith('/api/v1/media/queue')) return query.startsWith('/api/v1/media/queue'); + if (mutation.startsWith('/api/v1/media/items/')) { + const id = mediaItemId(mutation); + if (!id) return false; + if (mutation.includes('/favorite')) return queryIsMediaItem(query, id) || /^\/api\/v1\/media\/favorites(?:\?|$)/.test(query); + return queryIsMediaItem(query, id); + } + if (mutation.startsWith('/api/v1/media/libraries/')) { + const id = mutation.match(/^\/api\/v1\/media\/libraries\/([^/]+)/)?.[1]; + return query === '/api/v1/media/libraries' || (!!id && query.startsWith('/api/v1/media/libraries/' + id + '/')); + } + if (mutation.startsWith('/api/v1/media/libraries')) return query === '/api/v1/media/libraries'; + if (/^\/api\/v1\/(notes|tts|ai|auth)(?:\/|$)/.test(mutation)) return false; + if (mutation === '/api/v1/library/upload') return query.startsWith('/api/v1/books') || query.startsWith('/api/v1/library/browse'); + if (mutation === '/api/v1/library/browse/shelf') return query.startsWith('/api/v1/books') || query.startsWith('/api/v1/library/continue'); + if (/^\/api\/v1\/library\/browse\//.test(mutation)) return query.startsWith('/api/v1/library/browse') || query.startsWith('/api/v1/books'); + if (/^\/api\/v1\/books\//.test(mutation)) { + const id = mutation.match(/^\/api\/v1\/books\/([^/]+)/)?.[1]; + return query.startsWith('/api/v1/books') && (!id || query.includes('/books/' + id)); + } + if (/^\/api\/v1\/sources(?:\/|$)/.test(mutation) || /^\/api\/v1\/plugins/.test(mutation)) return query.startsWith('/api/v1/sources') || query.startsWith('/api/v1/plugins') || query.startsWith('/api/v1/subscriptions'); + return false; +} diff --git a/web/src/media/playback-controls.tsx b/web/src/media/playback-controls.tsx index 1d3bc2b..81af3d5 100644 --- a/web/src/media/playback-controls.tsx +++ b/web/src/media/playback-controls.tsx @@ -89,6 +89,7 @@ export function PlaybackControls({player,api,panel,onPanelChange,onBack,onFavori
{(player.isVideo||!itemId)&&
正在播放{player.title}
} {player.error&&open('versions')}:{})} {...(onBack?{onBack}:{})} backLabel={view==='main'?'返回浏览':'返回播放'}/>} + {!player.error&&player.loadingStatus&&

{player.loadingStatus}

} {player.canOpenNativeVideo&&} {view==='chapters'?setRetry(value=>value+1)}/>:view==='queue'?:!player.isVideo&&itemId&&setRetry(value=>value+1)} player={player} lyrics={lyrics} onMore={()=>open('more')}/>} {view!=='queue'&&view!=='chapters'&&
diff --git a/web/src/media/playback-problem.tsx b/web/src/media/playback-problem.tsx index ad3c53e..3681a9d 100644 --- a/web/src/media/playback-problem.tsx +++ b/web/src/media/playback-problem.tsx @@ -9,7 +9,8 @@ export function PlaybackProblem({player,onVersions,onBack,backLabel='返回浏
; } diff --git a/web/src/media/presentation.css b/web/src/media/presentation.css index 18ff7bf..27c0a27 100644 --- a/web/src/media/presentation.css +++ b/web/src/media/presentation.css @@ -111,6 +111,9 @@ body:has(>#app>.media-screen) { background: #faf9f5; } .media-hero .media-cover {width:170px;min-width:170px} .media-screen .media-primary {background:var(--media-accent);color:var(--media-on-accent)} .media-screen button.media-primary:hover:not(:disabled) {background:var(--media-accent);color:var(--media-on-accent);filter:brightness(.94)} +.media-task-retry {display:inline-flex;align-items:center;justify-content:center;gap:5px;min-height:40px;padding:0 10px;border:1px solid var(--border);border-radius:8px;background:var(--media-selection);color:var(--media-accent);font-size:12px;font-weight:600} +.media-task-retry:disabled {opacity:.5} +@media(max-width:430px){.media-task-retry span{display:inline}.media-task-retry{min-width:64px}.media-scan-job .media-task-icon{min-width:40px}} .media-screen .media-continue {background:var(--surface);border:0;border-radius:9px;padding:11px 14px;margin-top:18px} /* Desktop keeps channel links; mobile switches channels beside the catalog title. */ diff --git a/web/src/media/scan-jobs.tsx b/web/src/media/scan-jobs.tsx index e439019..96d354e 100644 --- a/web/src/media/scan-jobs.tsx +++ b/web/src/media/scan-jobs.tsx @@ -1,4 +1,4 @@ -import { Trash2, X } from 'lucide-preact'; +import { RefreshCw, Trash2, X } from 'lucide-preact'; import { useState } from '../ui/vendor/preact.ts'; import type { Library, ScanJob } from './api.ts'; import { scanFeedback } from './scan-feedback.ts'; @@ -6,12 +6,13 @@ import { scanFeedback } from './scan-feedback.ts'; const states:Record={queued:'排队中',running:'进行中',complete:'已完成',failed:'失败',cancelled:'已取消'}; const phases:Record={list:'读取目录',probe:'分析媒体',stat:'校验文件',metadata:'读取元数据',publish:'保存资料'}; const duration=(ms:number)=>`${(ms/1000).toFixed(1)} 秒`; -export function ScanJobs({jobs,libraryName,libraries,busy,onCancel,onDelete=()=>{}}:{jobs:ScanJob[];libraryName:string;libraries?:Library[];busy:boolean;onCancel:(id:string)=>void;onRetry?:(libraryId?:string)=>void;onDelete?:(id:string)=>void}){ +export function ScanJobs({jobs,libraryName,libraries,busy,onCancel,onRetry=()=>{},onDelete=()=>{}}:{jobs:ScanJob[];libraryName:string;libraries?:Library[];busy:boolean;onCancel:(id:string)=>void;onRetry?:(libraryId?:string)=>void;onDelete?:(id:string)=>void}){ const [page,setPage]=useState(0),last=Math.max(0,Math.ceil(jobs.length/30)-1),current=Math.min(page,last); + const scanningFor=(libraryId?:string)=>jobs.some(job=>(job.state==='running'||job.state==='queued')&&(!libraryId||!job.libraryId||job.libraryId===libraryId)); return
{jobs.slice(current*30,current*30+30).map(job=>
{libraries?.find(library=>library.id===job.libraryId)?.name||libraryName} · 目录扫描已检查 {job.inspected} 个文件
{states[job.state]||job.state} - {['running','queued'].includes(job.state)?:} + {['running','queued'].includes(job.state)?:<>{job.state==='failed'&&}} {job.state==='running'&&} {job.diagnostics&&

耗时 {duration(job.diagnostics.elapsedMs)}{job.diagnostics.elapsedMs>0?` · 平均 ${(job.inspected*1000/job.diagnostics.elapsedMs).toFixed(1)} 个文件/秒`:''}

diff --git a/web/src/media/v2.css b/web/src/media/v2.css index 6e2a715..1f6697d 100644 --- a/web/src/media/v2.css +++ b/web/src/media/v2.css @@ -105,7 +105,7 @@ .media-screen .media-settings-panel,.media-screen .media-form {max-width:760px;margin-inline:auto} .media-screen .media-setting-field,.media-screen .media-settings-check {min-height:56px;padding:12px 0;border-bottom:1px solid var(--border)} .media-screen .media-settings-check input {accent-color:var(--media-accent);width:20px;height:20px} -.media-screen .media-library-list {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px} +.media-screen .media-library-list {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;padding:12px 0;} .media-screen .media-library-row {display:grid;grid-template-columns:24px minmax(0,1fr) 44px;gap:10px;padding:16px;margin:0;border:1px solid var(--border);border-radius:11px;background:var(--media-paper);align-items:start} .media-library-row>svg {margin-top:8px;color:var(--media-accent)} .media-library-row strong {font-size:14px} diff --git a/web/src/ui/shelf-screen.tsx b/web/src/ui/shelf-screen.tsx index 4186e06..92938fb 100644 --- a/web/src/ui/shelf-screen.tsx +++ b/web/src/ui/shelf-screen.tsx @@ -655,6 +655,7 @@ export class ShelfScreen { private async manualRefresh(): Promise { this.patch({ refreshing: true }); try { + this.options.api.refreshQueries(); await this.refresh(); } finally { setTimeout(() => this.patch({ refreshing: false }), 900); diff --git a/web/test/api-query.test.ts b/web/test/api-query.test.ts new file mode 100644 index 0000000..52cc9c8 --- /dev/null +++ b/web/test/api-query.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { ReaderApi } from '../src/api/client.ts'; +import type { Session } from '../src/api/types.ts'; +import { FakeTransport, makePlatform } from './helpers/env.ts'; + +const session: Session = { + user: { id: 'reader', username: 'reader', displayName: 'Reader', role: 'member', createdAt: 0 }, + accessToken: 'access', refreshToken: 'refresh', accessTokenExpiresAt: 9e15, refreshTokenExpiresAt: 9e15, +}; + +async function apiWith(transport: FakeTransport): Promise { + const api = new ReaderApi(makePlatform(transport), { load: async () => session, save: async () => {}, clear: async () => {} }); + await api.restore(); + return api; +} + +describe('ReaderApi navigation query cache', () => { + it('refreshes explicitly, separates pages, and leaves polling uncached', async () => { + const transport = new FakeTransport(); + transport.json({ items: [], total: 0 }); + const api = await apiWith(transport); + await api.listBooks({ page: 1 }); + await api.listBooks({ page: 2 }); + api.refreshQueries(); + await api.listBooks({ page: 1 }); + expect(transport.requests).toHaveLength(3); + await api.mediaRequest('/api/v1/media/scan-jobs'); + await api.mediaRequest('/api/v1/media/scan-jobs'); + expect(transport.requests).toHaveLength(5); + }); + + it('keeps media catalog cached across playback heartbeats', async () => { + const transport = new FakeTransport(); + transport.json({ items: [] }); + const api = await apiWith(transport); + await api.mediaRequest('/api/v1/media/browse?channel=music'); + await api.mediaRequest('/api/v1/media/playback/session/progress', 'PUT', { position: 10 }); + await api.mediaRequest('/api/v1/media/browse?channel=music'); + expect(transport.requests).toHaveLength(2); + }); + + it('deduplicates concurrent shelf reads and reuses them on return', async () => { + const transport = new FakeTransport(); + transport.json({ items: [{ id: 'book-1' }], total: 1 }); + const api = await apiWith(transport); + await Promise.all([api.listBooks(), api.listBooks()]); + await api.listBooks(); + expect(transport.countMatching(request => request.url.startsWith('/api/v1/books'))).toBe(1); + }); + + it('invalidates navigation data after a write', async () => { + const transport = new FakeTransport(); + transport.respondWith(request => request.method === 'GET' + ? { status: 200, headers: {}, json: { items: [{ id: 'book-1' }], total: 1 } } + : { status: 200, headers: {}, json: {} }); + const api = await apiWith(transport); + await api.listBooks(); + await api.browseBatchShelf({ bookIds: ['book-1'] }, 'remove'); + await api.listBooks(); + expect(transport.countMatching(request => request.url.startsWith('/api/v1/books'))).toBe(2); + }); +}); diff --git a/web/test/media-player.test.ts b/web/test/media-player.test.ts index ce78b83..b72686e 100644 --- a/web/test/media-player.test.ts +++ b/web/test/media-player.test.ts @@ -12,6 +12,19 @@ function ready(player:MediaPlayer){player.audio.dispatchEvent(new Event('loadedm describe('media playback lifecycle',()=>{ beforeEach(()=>{localStorage.clear();vi.spyOn(HTMLMediaElement.prototype,'play').mockImplementation(async function(this:HTMLMediaElement){Object.defineProperty(this,'paused',{configurable:true,value:false});});vi.spyOn(HTMLMediaElement.prototype,'pause').mockImplementation(function(this:HTMLMediaElement){Object.defineProperty(this,'paused',{configurable:true,value:true});});vi.spyOn(HTMLMediaElement.prototype,'load').mockImplementation(()=>{});}); afterEach(()=>{vi.restoreAllMocks();vi.useRealTimers();}); + it('times out after metadata and retries without duplicating the session or queue',async()=>{ + vi.useFakeTimers(); + const {player,playback}=setup(); + vi.mocked(HTMLMediaElement.prototype.play).mockImplementation(()=>new Promise(()=>{})); + await player.play([part('slow')]);ready(player); + await vi.advanceTimersByTimeAsync(20001); + expect(player.error).toContain('超时');expect(player.loadingStatus).toBe(''); + player.retryPlayback();await vi.advanceTimersByTimeAsync(0); + expect(player.loadingStatus).toContain('连接');expect(playback).toHaveBeenCalledTimes(1); + player.audio.dispatchEvent(new Event('playing')); + await vi.advanceTimersByTimeAsync(20001); + expect(player.error).toBe('');expect(player.loadingStatus).toBe('');await player.stop(); + }); it('opens video controls and releases the action while playback is still buffering',async()=>{ const {player}=setup();let started=false; vi.mocked(HTMLMediaElement.prototype.play).mockImplementation(()=>{started=true;return new Promise(()=>{});}); diff --git a/web/tools/media-v2-review.cjs b/web/tools/media-v2-review.cjs index bcf365f..30caeb0 100644 --- a/web/tools/media-v2-review.cjs +++ b/web/tools/media-v2-review.cjs @@ -81,7 +81,7 @@ async function saveShot(capture){for(let attempt=0;;attempt++){try{return await await page.getByRole('button',{name:'睡眠定时',exact:true}).click();await page.getByRole('dialog').waitFor();await page.screenshot({path:join(out,'timer-actual.png')});await page.keyboard.press('Escape'); await page.getByRole('button',{name:'播放队列',exact:true}).click();await page.locator('.media-current-playlist').waitFor();await shot('queue'); await go('#/media/audiobook/book/'+book.id);await page.locator('.media-detail-actions .media-primary').click();await page.waitForFunction(()=>document.querySelector('.media-player audio')?.readyState>=2);await page.getByRole('button',{name:'打开播放控制',exact:true}).click();await page.locator('.media-playing-artwork').waitFor();await shot('audio-player');await shot('audio-player',1120); - for(const [id,item] of [['video-player',movie],['episode-player',episode]]){await go('#/media/video/'+(item===movie?'movie/':'episode/')+item.id);await page.locator('.media-detail-actions .media-primary').click();await page.locator('.plyr--video').waitFor();await page.waitForFunction(()=>document.querySelector('.media-player video')?.readyState>=2);for(const width of [390,1120])await shot(id,width);} + for(const [id,item] of [['video-player',movie],['episode-player',episode]]){await go('#/media/video/'+(item===movie?'movie/':'episode/')+item.id);await page.locator('.media-detail-actions .media-primary').click();await page.locator('.dplayer-video').waitFor();await page.waitForFunction(()=>document.querySelector('.media-player video')?.readyState>=2);for(const width of [390,1120])await shot(id,width);} } if(!wanted||wanted.includes('players')){ await go('#/media/music/settings/theme');await page.getByRole('button',{name:'深海',exact:true}).click();await go('#/media/music/albums?library='+album.libraryId);await page.locator('.media-tile').first().waitFor();await shot('music',390,'midnight');