chore: commit current workspace changes

This commit is contained in:
hectorqin committed 2026-10-02 11:24:25 +08:00
1 parent c9f72f2879
commit 80124d8c4e
15 files changed
+333 -10

No files matched your search

@@ -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
```
@@ -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,以及失败任务、重试禁用、重试成功、深色主题四种状态。这样可以把本轮已修复的操作层级问题纳入持续视觉回归。
+11
View File
@@ -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",
+1
View File
@@ -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",
+43 -3
View File
@@ -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<Session | null>;
@@ -133,6 +134,8 @@ export class ReaderApi {
private refreshPromise: Promise<Session> | null = null;
private sessionGeneration = 0;
private readonly streams = new Set<AbortController>();
private readonly queryClient = createReaderQueryClient();
private queryScope = 0;
private sessionWrites: Promise<void> = 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<T>(path: string, method = 'GET', body?: unknown, options: RequestOptions = {}): Promise<T> {
if (!path.startsWith('/api/v1/media/')) throw new Error('invalid media API path');
if (method === 'GET' && body === undefined) return this.get<T>(path, options);
return this.call<T>(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<T>(path='',method='GET',body?:unknown,signal?:AbortSignal):Promise<T>{
return this.call<T>('/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<T>(requestPath, options)
: this.call<T>(requestPath, method, body, options);
}
async businessTtsPreview(values:Record<string,unknown>,voice:string):Promise<Blob>{
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<T>(path: string, options: RequestOptions = {}): Promise<T> {
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<T>({
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<T>(
@@ -730,6 +763,13 @@ export class ReaderApi {
options: RequestOptions = {},
): Promise<T> {
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;
}
+70
View File
@@ -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;
}
+1
View File
@@ -89,6 +89,7 @@ export function PlaybackControls({player,api,panel,onPanelChange,onBack,onFavori
<section className="media-playback-status" data-panel={view} data-channel={channel??'unknown'} aria-label="播放控制">
{(player.isVideo||!itemId)&&<header className="media-now-playing"><div><small>正在播放</small><strong>{player.title}</strong></div><button className="media-symbol-button" aria-label="结束" title="结束播放" onClick={()=>void player.stop()}><Square size={18} aria-hidden="true"/></button></header>}
{player.error&&<PlaybackProblem player={player} {...(item?.editions.length?{onVersions:()=>open('versions')}:{})} {...(onBack?{onBack}:{})} backLabel={view==='main'?'返回浏览':'返回播放'}/>}
{!player.error&&player.loadingStatus&&<p role="status" className="media-loading-status">{player.loadingStatus}</p>}
{player.canOpenNativeVideo&&<button onClick={()=>void player.openNativeVideo()}>打开 Android 视频播放器</button>}
{view==='chapters'?<PlayingChapters key={itemId} player={player} api={api} item={item} error={failed} cause={cause} onRetry={()=>setRetry(value=>value+1)}/>:view==='queue'?<CurrentPlaylist player={player}/>:!player.isVideo&&itemId&&<PlayingArtwork api={api} item={item} failed={!!failed} onRetry={()=>setRetry(value=>value+1)} player={player} lyrics={lyrics} onMore={()=>open('more')}/>}
{view!=='queue'&&view!=='chapters'&&<div className="media-audio-bottom">
+3 -2
View File
@@ -9,7 +9,8 @@ export function PlaybackProblem({player,onVersions,onBack,backLabel='返回浏
<Icon size={28} strokeWidth={1.5} aria-hidden="true"/>
<strong>{blocked?'播放会话已失效':code===4?'当前设备无法直接播放':code===3?'资源解码失败':'播放提示'}</strong>
<p>{player.error}</p>
{!blocked&&player.canRetryViaProxy&&<button className="media-primary" onClick={()=>player.retryViaProxy()}>通过服务器重试</button>}
{(onVersions||onBack)&&<div>{onVersions&&<button className="media-primary" onClick={onVersions}>{blocked?'重新选择播放':'选择播放版本'}</button>}{onBack&&<button onClick={onBack}>{backLabel}</button>}</div>}
{!blocked&&<button className="media-primary" onClick={()=>player.retryPlayback()}>重试播放</button>}
{!blocked&&player.canRetryViaProxy&&<button onClick={()=>player.retryViaProxy()}>通过服务器重试</button>}
{(onVersions||onBack)&&<div>{onVersions&&<button className={blocked?'media-primary':undefined} onClick={onVersions}>{blocked?'重新选择播放':'选择播放版本'}</button>}{onBack&&<button onClick={onBack}>{backLabel}</button>}</div>}
</section>;
}
+3
View File
@@ -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. */
+4 -3
View File
@@ -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<string,string>={queued:'排队中',running:'进行中',complete:'已完成',failed:'失败',cancelled:'已取消'};
const phases:Record<string,string>={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 <div className="media-scan-jobs">{jobs.slice(current*30,current*30+30).map(job=><article className={'media-scan-job'+(job.state==='running'?' is-running':'')} key={job.id}>
<div className="media-task-copy"><strong>{libraries?.find(library=>library.id===job.libraryId)?.name||libraryName} · 目录扫描</strong><small>已检查 {job.inspected} 个文件</small></div>
<span className="media-task-state" data-state={job.state}>{states[job.state]||job.state}</span>
{['running','queued'].includes(job.state)?<button className="media-task-icon" aria-label="取消扫描" title="取消扫描" disabled={busy} onClick={()=>onCancel(job.id)}><X size={18} aria-hidden="true"/></button>:<button className="media-task-icon" aria-label="删除扫描历史" title="删除扫描历史" disabled={busy} onClick={()=>onDelete(job.id)}><Trash2 size={17} aria-hidden="true"/></button>}
{['running','queued'].includes(job.state)?<button className="media-task-icon" aria-label="取消扫描" title="取消扫描" disabled={busy} onClick={()=>onCancel(job.id)}><X size={18} aria-hidden="true"/></button>:<><button className="media-task-icon" aria-label="删除扫描历史" title="删除扫描历史" disabled={busy} onClick={()=>onDelete(job.id)}><Trash2 size={17} aria-hidden="true"/></button>{job.state==='failed'&&<button className="media-task-retry" aria-label="重新扫描媒体库" title={scanningFor(job.libraryId)?'请等待当前扫描完成':'重新扫描媒体库'} disabled={busy||scanningFor(job.libraryId)} onClick={()=>onRetry(job.libraryId)}><RefreshCw size={16} aria-hidden="true"/><span>重试</span></button>}</>}
{job.state==='running'&&<progress className="media-task-progress" aria-label="扫描进度"/>}
{job.diagnostics&&<div className="media-task-note">
<p>耗时 {duration(job.diagnostics.elapsedMs)}{job.diagnostics.elapsedMs>0?` · 平均 ${(job.inspected*1000/job.diagnostics.elapsedMs).toFixed(1)} 个文件/秒`:''}</p>
+1 -1
View File
@@ -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}
+1
View File
@@ -655,6 +655,7 @@ export class ShelfScreen {
private async manualRefresh(): Promise<void> {
this.patch({ refreshing: true });
try {
this.options.api.refreshQueries();
await this.refresh();
} finally {
setTimeout(() => this.patch({ refreshing: false }), 900);
+62
View File
@@ -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<ReaderApi> {
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);
});
});
+13
View File
@@ -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(()=>{});});
+1 -1
View File
@@ -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');