mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
chore: commit current workspace changes
This commit is contained in:
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,以及失败任务、重试禁用、重试成功、深色主题四种状态。这样可以把本轮已修复的操作层级问题纳入持续视觉回归。
|
||||
Generated
+11
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(()=>{});});
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user