diff --git a/docs/architecture.md b/docs/architecture.md index 22d13e8..6c0daba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -316,49 +316,28 @@ Readium 用 Readium CSS 主动接管排版、覆盖出版方样式,这与本 - 渲染层是 **WebView + 自研 CSS 干预策略**,保留作者样式、只做最小覆盖 - 参考 readium-css 的变量组织方式,但**不引入它的默认接管行为** -### UI 框架选型:Preact,而且只用在「没有布局要量测」的那一半 +### UI 框架:完整 React 应用 -界面原来是纯命令式 DOM:每个屏幕在构造函数里建好全部节点、把它们存成实例字段, -再由若干 `render*()` 方法在状态变化时挑选该改哪个。它有两个具体的坏味道: +前端现在由 React 19 负责组件树和页面生命周期,入口是 +`web/src/main.tsx` 的 `createRoot()`。所有登录后页面都挂在 +`AppProviders` 下,由成熟的 Provider 统一承载运行时、服务端状态和设计系统: -1. **同一份状态有多个副本。** 分段控件的选中态既在 `settings` 里也在 - `aria-pressed` 上,两者靠点击处理器手工同步;`LoginScreen` 的通知、错误、 - 注册开关、按钮文案是四个节点被五个地方改。改一处漏一处不会报错。 -2. **重建与状态的矛盾。** 朗读引擎行必须在 HTTP 能力探测回来时重新生成, - 而整块面板重建会丢掉读者在面板里的滚动位置,于是长出一套 - 「只替换 `[data-speech=group]`」的定点重建。 +- `react-router-dom` 的 `createHashRouter` 负责路由、嵌套路由、参数和错误边界; +- `@tanstack/react-query` 的 `QueryClientProvider` 负责服务端状态、缓存、加载、重试和失效; +- Zustand stores 负责认证、设置、同步和播放器等跨页面客户端状态; +- Mantine 提供基础组件、主题和通知,Lucide React 提供统一图标; +- `RuntimeContext` 只暴露 API、同步、播放和设置等应用运行时依赖。 -选型的硬约束有三个,都来自「一份产物两个宿主」: +业务按 `features/` 拆分,每个领域拥有自己的 `pages`、`components`、 +`queries`、`mutations`、`services` 和 `styles`。页面只组合查询、变更和组件, +不再通过单文件的 URL 判断来调度所有屏幕。影音的实现位于 +`web/src/features/media`,阅读器仍保留一个命令式排版边界,因为它需要直接测量 +`getBoundingClientRect()`、维护 shadow root 和处理章节分页;这个边界由 +`ReaderPage` 的 effect 创建和销毁,不会把整个应用退回到手写 Screen 调度。 -- **单文件 bundle、`base: './'`**:WebView 从 `https://appassets...` 加载, - 不能按需拉分片,所以框架必须能被 tree-shake 进一个文件。 -- **体积**:这是要打进 APK、也要由 NAS 上的 Node 托管的资源。 -- **不认识的东西不能碰布局**:阅读舞台的分页依赖 `getBoundingClientRect()`, - 虚拟 DOM 不能在它和布局之间。 - -对比过的四个: - -| 方案 | gzip 净成本 | 结论 | -| --- | --- | --- | -| Preact + `preact/hooks` | 最小 | **选用**(无 `preact/compat`、无 `react-dom`,仅这两个模块) | -| React + react-dom | ~45KB | 一个阅读器不该为 `react-dom` 付这个价钱 | -| Vue 3 runtime | ~34KB | 需要 SFC 预编译或运行时模板编译,产物形状与单文件 bundle 相冲 | -| 纯 Web Components / lit | ~5KB / ~7KB | 手写 `render()` 等于自建一个更窄的框架;lit 又把模板塞进字符串,失去类型检查 | - -**边界写在 `ReaderScreen` 里,而不是靠自觉**:舞台(`stage`)由类持有、 -交给 `ReaderView`,并通过一个 ref 回调挂进组件树;组件只决定它**放在哪** -(状态行与页脚之间,stacking context 来自这里),内容一概不碰。于是: - -- 排版、分页、shadow root、手势、量测:**零框架**。 -- 顶栏、页脚进度、目录、设置面板、朗读条、状态行、书架、登录、书库: - `ChromeState` 的函数。 - -副作用也是可见的:`settingsPanel` 那套「只重建朗读行」的定点更新没有了, -探测答案回来时只是改状态,Preact 的 diff 让读者的滚动位置天然保住。 - -组件只用了 `preact` 与 `preact/hooks`(`useState` / `useRef` / `useEffect`), -没有引入 `@preact/signals` 之类的额外状态层——屏幕自己就是状态的持有者, -`update()` 把整份状态交给树。少一个依赖,就少一处「值在哪」的歧义。 +React 组件只声明布局和状态,副作用放在 effect、query 或 service 中。这样登录、 +书架、书库、书源、设置和影音页面共享同一套生命周期语义,加载、错误、空状态和 +返回行为都能由路由与查询状态复现和测试。 ### 路由:URL 就是屏幕 @@ -369,9 +348,9 @@ URL 从头到尾不变。手机上这一点几乎看不见——直到读者按 浏览器上则更糟,地址栏是自托管书库唯一的分发方式,而它既不能分享也不能收藏: 一本书链接不出去,刷新永远回到书架。 -所以路由是**值**,不是调用:`ui/router.ts` 把 fragment 解析成 -`{ name: 'shelf' } | { name: 'library', path } | { name: 'book', bookId }`, -shell 渲染 router 报上来的那个 route,导航就是改 URL、让变化回流。 +所以路由是**值**,不是调用:`web/src/app/router/routes.tsx` 声明 +`RouteObject`,`createHashRouter()` 将 fragment 映射到嵌套 React 页面,导航就是 +改 URL、让变化回流。 **为什么用 fragment 而不是 `history.pushState` 的路径。** 同一份产物要被两个宿主加载, 而两个宿主都服务不了任意路径:Android 从 `https://appassets.androidplatform.net/` @@ -380,79 +359,35 @@ shell 渲染 router 报上来的那个 route,导航就是改 URL、让变化 在两个生产宿主里 404。fragment 是 URL 里唯一保证属于客户端的一段,所以它是唯一 三处都成立的方案——代价是一个 `#`。 -**返回走应用自己的轨迹,不是 `history.back()`。** 应用里的「返回」意思是「上一个屏幕」, -而浏览器历史里可能有登录表单、`vite dev` 的页面、甚至别的站点。`Router` 自己维护一条 -屏幕轨迹,`back()` 从它上面弹;轨迹为空时(新标签页里粘贴的链接)就**替换**成父级路由, -而不是压入——否则那个粘贴的链接会永远压在书架上,返回键会一次次把读者送回书里。 +**返回由 React Router 管理。** 页面使用 `Link`、`useNavigate` 和路由层的父级关系, +不再由每个 Screen 自己维护一份 history。嵌套页面通过显式返回按钮回到父路由,浏览器 +的 Back/Forward 和刷新都能重建同一页面状态。 -**进入文件夹是 `replace`,不是 `push`。** 书库是一个带面包屑的屏幕,走进子目录是同一个 -屏幕改自己的参数,所以它覆写当前条目而不是新增。压栈的话,读者走过四个文件夹再按返回, -会一个一个退出来——而面包屑存在的意义正是为了不用这样做。 +当前核心路由是: -**书架和书库是两个路由,不是同一屏幕的两个标签。** `#/shelf` 与 `#/library/<路径>`。 -两者差的是数据源(`books` 表 vs 挂载目录),而这恰好是「标签」这种做工会同时坏掉的三件事: -标签切换进同一段历史,于是 Back 变成「退回上一个标签」而不是离开屏幕;`#/library/科幻` -是要能发给别人的链接,标签不产出链接;一个屏幕塞两个标题就得再占一行标签栏。 -两个路由共用一个 **切换控件**(书架右上是「书库」,书库左上是「书架」),而不是共用一个 -屏幕——控件是一步,轨迹是两条。 +| 路由 | 页面 | +| --- | --- | +| `#/shelf` | 书架 | +| `#/library`、`#/library/files` | 书库浏览、文件管理 | +| `#/sources`、`#/settings` | 书源、设置 | +| `#/book/:bookId` | 阅读器 | +| `#/media/:channel` | 影音频道首页 | +| `#/media/:channel/items/:itemId` | 影音详情 | +| `#/media/:channel/player?item=&part=` | 播放器 | +| `#/media/:channel/settings/...` | 影音设置及管理子页 | -**页在 URL 里。** `#/shelf/3`、`#/library/科幻/2`。两个列表都分页,页是**位置**: -没有它,「我读到第三屏了」只存在于一个滚动偏移里,重绘就没了,而 Back 无处可回。 -书架的 `page` 是自己的一段(`#/shelf/<页>`),书库的页是路径之后的**后缀** -(`#/library/<路径>/<页>`)——把最后一段数字读成页只在一个前提下成立:只有 -`routeHash` 会写这种 URL。一个真的叫 `2` 的文件夹是 `#/library/2`(前面没有段), -所以它仍然是文件夹。 +书架、书库、书源、设置和影音是独立页面;影音内部再按频道、收藏、历史、队列、 +搜索、详情和管理功能拆成嵌套路由。分页、筛选和播放器参数放在路由参数或 query 中, +页面不会通过单文件里的 pathname 分支来决定渲染哪一组业务页面。 -**书库自己也是两半个,而它们是两条路由,不是标签。** `#/library/<路径>` 是 -**浏览页**(读者那一半:商城式布局、搜索框、封面网格),`#/library/files/<路径>` 是 -**文件管理页**(管理员那一半:文件行,且每一行控件都写磁盘)。URL 是 -`#/library[/files][/<路径>][/<页>][?q=<搜索>]`。 +Hash 路由适配 WebView 和静态服务器:宿主只需要返回同一个 `index.html`,客户端从 +`#` 后解析页面,不依赖服务端为每个深层路径配置回退规则。登录是认证边界而不是业务 +页面;未认证时由 `AuthBoundary` 显示登录表单,认证完成后 React Router 渲染目标地址。 -这一版**改了上一版**:上一版把两半做成一个屏幕的两个页签,理由是「标签进历史会让 Back -走标签」。那条理由在下一层就不成立了——两半**不是同一份数据的两种读法**:一个问 -`books` 表(书名、封面),一个问挂载目录(文件名、大小、扫描标记),端点不同、 -一页条数不同、受众不同。共用一个头部会逼着两边都让步,而最大的代价是可达性: -`#/library/科幻` 这条**能给别人的链接**离「能删书的那一页」只差一次页签点击。 -所以两半是两个路由,`files` 段只在紧跟路由之后那一格被读走——一个真叫 `files` 的 -文件夹是 `#/library/files/files`,仍然是文件夹。搜索是 `?q=`:自由文本、可以没有、 -并且落在路径之外,所以文件夹里叫什么都无所谓。 - -其余的路由理由(页在 URL 里、哈希说不出来的从轨迹里带、屏幕不自己改页、登录不是路由) -原封不动。`fromShelf` 那一条现在也决定了**哪一半**的 `back()` 是回书架还是退出应用。 - -**哈希说不出来的两件事,从轨迹里带。** `#/shelf` 对所有文件夹是同一条 URL, -`#/library` 也说不清读者是怎么到这儿的。所以 `Router` 在解析当前哈希时会把上一次 -路由里的两件事带过来:书架的 `libraryPath` / `libraryPage`(切回来时回到原来那个 -文件夹和那一页),以及书库的 `fromShelf`(离开书库时是回书架还是退出应用)。 -这两件事都必须**由路由带**而不是由屏幕记:屏幕是 `replace` 遍历文件夹的, -它记的是「进来的那个文件夹」,而读者已经走到别处了。 - -**屏幕不自己改页。** 翻页是导航:分页器上报意图 → shell 写 URL → URL 回落到 -`showPage(page)`。这一趟往返就是页能活过 Back、Forward、刷新的全部原因。 - -**登录不是路由。** 「登录页」是一个瞬时状态,给它一个 URL 等于给门一个门牌。真正需要 -活下来的东西是读者**本来要去的地方**,所以它存在 `App.pending` 里:带着 `#/book/` -进来而尚未登录时,这个 route 会等在登录页后面,登录完成后直接落到那本书上, -而不是落到一个他从未见过的书架。 - -各屏仍然是有 `element` 和 `dispose()` 的类——路由不认识框架,屏幕也不认识 hash。 -两者之间唯一的交汇点是 `RouteLocation`,屏幕通过它的 `back()` 请求离开。 - -### `mountUI`:类与组件之间唯一的接缝 - -屏幕仍然是类,仍然有 `element` 和 `dispose()`——路由不需要知道框架, -`ReaderScreen` 这种以命令式为主的屏幕也不必假装自己不是。 -`mountUI(container, tree, initial)` 返回 `{ element, update, unmount }`, -`update` 传入「整棵树应该是什么状态的函数」的那个值。测试保持原样: -断言仍然打在真实 DOM 上(`test/manager.test.ts` 一行没改逻辑,只改了 import 后缀)。 - -**unmount 之后的 update 会被丢弃,这一点是有意的。** 每个屏幕都是异步加载的, -每个屏幕也都可能在加载完成前被离开——读者点开一本书又马上按返回,深链在书架还在取数据 -时就把它换掉了。`dispose()` 取消不掉一个已经在飞的 promise,所以那个延续会跑在一个 -已经不在屏幕上的屏幕上并调用 `patch()`。没有这道闸,它会往一个刚刚被 Preact 清空的容器 -里重新画**整棵树**:书架那条迟到的响应会把自己一整屏封面画进文件列表里,而之后的 -`render(null)` 也清不掉它们,因为在 Preact 看来它们从来不是自己的孩子。 -所以一个标志位就顶得上每个屏幕各写一个取消令牌,而且没法在哪一个屏幕里漏掉。 +阅读器是唯一的特殊生命周期边界:`ReaderPage` 创建 `ReaderScreen`,将其舞台 +通过 `StageHost` 插入 React 树,并在 effect 清理时调用 `dispose()`。页面离开后, +reader 的异步请求和分页更新不会再写入已卸载的 DOM;其它页面完全遵循 React 的 +挂载、更新和卸载语义。 ### 只有一份渲染层 diff --git a/docs/development.en.md b/docs/development.en.md index 3ea14f7..bdfed59 100644 --- a/docs/development.en.md +++ b/docs/development.en.md @@ -113,14 +113,14 @@ Use `gradlew.bat` from a native Windows terminal. The output is `android/app/bui | `server/src/indexer` | File formats, scanning, identity, and metadata | | `server/src/services`, `http` | Business services, API, authentication, and resources | | `server/src/media` | Media catalog, scans, metadata, and playback | -| `web/src/media` | Media pages, routes, themes, and players | +| `web/src/features/media` | Media pages, components, services, themes, and players | | `server/src/sources` | Local library and OPDS integration | | `web/src/formats` | EPUB, TXT, comics, and other format handling | -| `web/src/ui`, `styles` | Preact UI, reading stage, and styles | +| `web/src/ui`, `styles` | Shared interactions, imperative reading stage, and reader styles | | `web/src/core`, `store` | Platform capabilities, synchronization, and offline storage | | `android/app/src/main/java/cool/cnb/reader` | Android Activity, WebView, native image and speech bridges | -Preact manages UI state; the reading stage handles content layout and measurement. Shadow DOM isolates book styles, and icons use Lucide SVG. Both the server and Android consume the Web build. See [architecture (中文)](architecture.md) and [UI guidelines (中文)](ui.md) for details. +The application UI uses React 19 and React Router. React Query owns server data and Zustand owns shared client state; the imperative reading stage handles book layout and measurement. Mantine provides base interaction components, Lucide React provides icons, and Shadow DOM isolates book styles. Both the server and Android consume the Web build. See [architecture (中文)](architecture.md) and [UI guidelines (中文)](ui.md) for details. ## Contributing workflow diff --git a/docs/development.zh-CN.md b/docs/development.zh-CN.md index 214bc79..2af4b64 100644 --- a/docs/development.zh-CN.md +++ b/docs/development.zh-CN.md @@ -113,14 +113,14 @@ Windows 原生终端使用 `gradlew.bat`。产物为 `android/app/build/outputs/ | `server/src/indexer` | 文件格式、扫描、身份与元数据 | | `server/src/services`、`http` | 业务服务、API、鉴权和资源交付 | | `server/src/media` | 影音目录、扫描、资料与播放服务 | -| `web/src/media` | 影音页面、路由、主题与播放器 | +| `web/src/features/media` | 影音页面、组件、服务、主题与播放器 | | `server/src/sources` | 本地书库与 OPDS 接入 | | `web/src/formats` | EPUB、TXT、漫画等格式处理 | -| `web/src/ui`、`styles` | Preact 界面、阅读舞台和样式 | +| `web/src/ui`、`styles` | 共享交互、命令式阅读舞台和阅读样式 | | `web/src/core`、`store` | 平台能力、同步、离线存储 | | `android/app/src/main/java/cool/cnb/reader` | Android Activity、WebView、原生图片和语音桥接 | -界面状态使用 Preact,阅读内容布局与测量由阅读舞台管理;书籍样式通过 Shadow DOM 隔离,图标使用 Lucide SVG。Web 产物由服务端和 Android 共同使用。详细取舍见[架构文档](architecture.md)和[界面规范](ui.md)。 +应用界面使用 React 19 和 React Router,服务端数据使用 React Query,共享客户端状态使用 Zustand;阅读内容布局与测量由命令式阅读舞台管理。基础交互组件使用 Mantine,图标使用 Lucide React,书籍样式通过 Shadow DOM 隔离。Web 产物由服务端和 Android 共同使用。详细取舍见[架构文档](architecture.md)和[界面规范](ui.md)。 ## 贡献流程 diff --git a/docs/frontend-react-migration.md b/docs/frontend-react-migration.md index bccc377..94cbc5f 100644 --- a/docs/frontend-react-migration.md +++ b/docs/frontend-react-migration.md @@ -11,7 +11,7 @@ | 能力 | 方案 | 责任 | | --- | --- | --- | | UI 运行时 | React 19 + React DOM | 所有页面和组件的唯一 UI 运行时 | -| 路由 | React Router 7 `createHashRouter` | Hash URL、嵌套路由、Layout、Outlet、loader、错误边界和权限守卫 | +| 路由 | React Router 7 `createHashRouter` | Hash URL、嵌套路由、Layout、Outlet、错误边界和权限守卫 | | 服务端状态 | `@tanstack/react-query` | 查询缓存、去重、mutation、失效和预取 | | 客户端共享状态 | Zustand | 认证、设置、同步、播放等跨页面状态 | | 基础组件 | Mantine | 对话框、表单、菜单、选择器、通知、加载和管理界面 | @@ -42,7 +42,7 @@ web/src/ mutations/ stores/ services/ - routes.tsx + styles/ reader/ pages/ components/ @@ -59,7 +59,9 @@ web/src/ 页面组件只处理页面展示和交互。API 调用通过领域 query/mutation hooks;跨页面状态通过领域 store;页面临时状态留在组件内部。路由页面不得通过布尔字段判断其它页面。 -影音领域的 React 组件、API 类型和跨页面服务统一放在 `features/media` 下;`src/media` 中保留的文件只作为旧测试、Android diagnostics 和历史模块的兼容 re-export,不参与生产路由调度。阅读器的命令式引擎和 Android 宿主仍保留在 `ui`/`core` 边界,由 `features/reader/pages/ReaderPage` 明确托管生命周期。 +影音领域的 React 组件、API 类型、跨页面服务和样式统一放在 `features/media` 下;旧的 `src/media` 目录已删除,测试与 Android 诊断也直接引用新目录,没有旧媒体路径转导层。领域内同目录组件使用 `./` 导入,跨职责目录使用 `../api`、`../services`、`../styles` 等明确路径;样式唯一存放在 `styles`,避免组件目录与样式目录维护重复副本。路由入口集中在 `app/router/routes.tsx`。 + +阅读器的命令式引擎和 Android 宿主仍保留在 `ui`/`core` 边界,由 `features/reader/pages/ReaderPage` 明确托管生命周期。`app.ts` 的诊断兼容接口只提供平台、flush 等宿主能力,不参与生产页面调度。 ## 生命周期边界 @@ -78,7 +80,7 @@ web/src/ 3. 影音 Layout、路由树、Query/Mutation 层和目录页面。 4. 详情、元数据、章节、媒体库管理、设置和任务页面。 5. PlaybackService、播放器页和 MiniPlayer。 -6. 删除 `MediaScreen`、旧媒体路由分支和媒体侧 `mountUI` 使用。 +6. 删除 `MediaScreen`、旧媒体路由分支、旧 `media` 目录和媒体侧 `mountUI` 使用。 7. 迁移其它非阅读页面。 8. React 化阅读器外围页面,保留 ReaderEngine。 9. 删除 Preact、`lucide-preact`、旧 `mountUI` 和旧 Screen 调度链。 @@ -86,7 +88,7 @@ web/src/ ## 验收标准 - 所有页面由 React Router 管理,`App` 不再手写页面 switch。 -- `MediaScreen` 删除,媒体页面按 route/page/query/mutation 拆分。 +- `MediaScreen` 删除,旧 `src/media` 目录删除,媒体页面按 route/page/query/mutation 拆分。 - React Query 统一管理服务端状态,Zustand 管理客户端共享状态。 - 播放器跨路由持续播放,阅读引擎拥有明确的 React 托管生命周期。 - Mantine 负责基础交互组件;品牌样式只做必要覆盖,不强行改写组件内部实现。 @@ -105,7 +107,7 @@ web/src/ - `MediaPlayer` 已由 `PlaybackService` 管理并在 Provider 中启动,跨路由播放状态通过 Zustand 暴露,播放器 DOM 不随页面路由销毁。 - 已用浏览器验证开发服务器能够启动并显示 React 登录页面;`npm run typecheck` 与 `npm run build` 通过。 -当前迁移已完成生产入口切换。影音管理、播放器控制、登录、书架、书库、书源、设置和阅读路由均由 React Router 管理;旧 `MediaScreen`、旧媒体路由、旧 `mountUI`、Preact 适配层和旧 Screen 页面已从生产代码删除。阅读内容引擎仍作为 `ReaderPage` 的命令式服务边界存在,并由 React effect 负责创建、刷新和销毁。 +当前迁移已完成生产入口切换和影音目录收敛。影音管理、播放器控制、登录、书架、书库、书源、设置和阅读路由均由 React Router 管理;旧 `MediaScreen`、旧媒体路由、旧 `mountUI`、Preact 适配层、旧 Screen 页面和 `src/media` 目录已删除。阅读内容引擎仍作为 `ReaderPage` 的命令式服务边界存在,并由 React effect 负责创建、刷新和销毁。 最终验证结果: diff --git a/docs/media-implementation.md b/docs/media-implementation.md index 61121c3..2321fa6 100644 --- a/docs/media-implementation.md +++ b/docs/media-implementation.md @@ -1,6 +1,6 @@ # 影音模块架构 -影音覆盖电影、剧集、音乐和有声书,使用独立路由、主题和服务线程。默认入口仍为阅读,阅读界面和书架设置沿用原有实现。 +影音覆盖电影、剧集、音乐和有声书,使用独立 React 路由、主题和服务线程。首页进入影视频道,阅读通过书架入口访问;阅读正文引擎由 React 页面托管生命周期。 ## 数据与服务边界 @@ -22,11 +22,11 @@ OpenList 使用 fs/list 与 fs/get,只读扫描,按需解析临时下载地址,由 Reader 授权接口代理 Range。令牌不交给前端或下载源;远程内嵌资料暂不探测。具体配置与驱动限制见[OpenList](media-openlist.md)。 -Web 音频使用浏览器媒体能力,视频采用 Plyr;Android 使用 Media3。播放会话、流票据、进度并发和即时撤权由服务端控制。后台授权与进程恢复见[后台播放](media-background-auth.md)。不提供转码、TVBox/JAR、云盘登录或影音离线下载。 +Web 音频使用浏览器媒体能力,视频采用 DPlayer;Android 使用 Media3。播放会话、流票据、进度并发和即时撤权由服务端控制。后台授权与进程恢复见[后台播放](media-background-auth.md)。不提供转码、TVBox/JAR、云盘登录或影音离线下载。 ## 代码与维护入口 - 服务端:[media 模块](../server/src/media/)、[HTTP 路由](../server/src/http/routes/media.ts)。 -- 前端:[media 模块](../web/src/media/)、[页面路由](../web/src/media/page-route.ts)。 +- 前端:[media feature 模块](../web/src/features/media/)、[React 页面路由](../web/src/app/router/routes.tsx)。 - [使用说明](media-user-guide.zh-CN.md)、[导航约定](media-navigation.md)、[验证方式](media-validation.md)、[性能测量](media-catalog-performance.md)。 - [冻结的 v2 原型](prototypes/media/v2-review/index.html)是布局参考;实际功能与地址以源码及当前说明为准。 diff --git a/docs/media-navigation.md b/docs/media-navigation.md index 223e96a..6d9e9d4 100644 --- a/docs/media-navigation.md +++ b/docs/media-navigation.md @@ -8,28 +8,29 @@ ## 路由表 -channel 表示 video、music 或 audiobook;id、part、library 均为真实资源标识。 +channel 表示 video、music 或 audiobook;itemId、part、libraryId 均为真实资源标识。 | 页面 | Hash 地址 | | --- | --- | -| 频道首页 | #/media/video、#/media/music/albums、#/media/audiobook/books | +| 频道首页 | #/media/video、#/media/music、#/media/audiobook | | 影视分类 | #/media/video/movies、#/media/video/series | -| 音乐分类 | #/media/music/albums、artists、tracks | -| 有声书分类 | #/media/audiobook/books、narrators | +| 音乐分类 | #/media/music/albums、#/media/music/artists、#/media/music/tracks | +| 有声书分类 | #/media/audiobook/books、#/media/audiobook/narrators | | 收藏 | #/media/favorites,scope 和 offset 保存筛选、分页 | | 搜索 | #/media/search?q=…&scope=… | -| 历史、待播队列 | #/media/:channel/history、queue | +| 历史、待播队列 | #/media/:channel/history;#/media/queue | | 设置 | #/media/:channel/settings,以及 theme、browse、playback、plugins、account 子页 | | 媒体库管理 | #/media/:channel/settings/libraries | -| 新建、编辑、权限 | settings/libraries/new、settings/libraries/:library/edit、permissions | +| 新建、编辑、权限 | #/media/:channel/settings/libraries/new、#/media/:channel/settings/libraries/:libraryId/edit、#/media/:channel/settings/libraries/:libraryId/permissions | | 扫描与刮削 | #/media/:channel/settings/tasks | -| 作品详情 | #/media/:channel/:kind/:id;kind 使用 movie、show、season、episode、album、artist、track 或 book | -| 资料、匹配、章节 | #/media/:channel/items/:id/metadata、match、chapters | -| 播放 | #/media/:channel/player/:id/:part | -| 歌词、播放队列、播放章节 | 播放地址追加 /lyrics、/queue、/chapters | -| 文件夹、文件 | #/media/:channel/folders?library=…&path=…;#/media/:channel/files/:asset | -| 演播者、演播作品 | #/media/audiobook/narrators/:name,以及 /works/:work | +| 作品详情 | #/media/:channel/items/:itemId | +| 资料、匹配、章节 | #/media/:channel/items/:itemId/metadata、match、chapters | +| 版本、结构管理 | #/media/:channel/items/:itemId/editions/:editionId;#/media/:channel/items/:itemId/structure | +| 播放 | #/media/:channel/player?item=…&part=… | +| 歌词、播放队列、播放章节 | #/media/:channel/player/lyrics、#/media/:channel/player/queue、#/media/:channel/player/chapters | +| 文件夹、文件 | #/media/:channel/folders/:libraryId?path=…&offset=…;文件继续使用该路由并追加 asset=… | +| 演播者、演播作品 | #/media/audiobook/narrators/:narrator,以及 /media/audiobook/narrators/:narrator/works/:workId | -旧频道收藏地址会规范化到全局收藏地址。返回按实际浏览器历史和保存的来源状态处理;不为每个页面生成 return 查询参数。播放器路径包含作品及播放部分,可刷新后重新校验授权并恢复页面。 +旧路由不承担兼容承诺。返回按实际浏览器历史和保存的来源状态处理;不为每个页面生成 return 查询参数。播放器的 item、part 查询参数保存作品及播放部分,可刷新后重新校验授权并恢复页面。 -协议实现以[页面路由](../web/src/media/page-route.ts)及[路由器](../web/src/ui/router.ts)为准。冻结 v2 原型内的示例路由只用于设计演示。 +协议实现以[React 页面路由](../web/src/app/router/routes.tsx)及[影音 feature](../web/src/features/media/)为准。冻结 v2 原型内的示例路由只用于设计演示。 diff --git a/docs/ui.md b/docs/ui.md index f4a205e..85dd052 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -12,13 +12,13 @@ ## 1. 图标 -图标统一使用 `lucide-preact` 的 SVG,`src/ui/icon.tsx` 集中维护名称映射,线宽 1.8,尺寸 1em。 +图标统一使用 `lucide-react` 的 SVG,`web/src/ui/icon.tsx` 集中维护名称映射,线宽 1.8,尺寸 1em。 界面使用 Icon / IconButton / IconTextButton,不直接嵌入字体码位或 emoji。 装饰图标标记 aria-hidden;没有文字的按钮必须有中文 aria-label。原字体文件与生成工具仅供历史兼容,不再加载到阅读界面。 ## 2. 设计令牌 -全部定义在 `src/styles/reader.css` 的 `:root` 里。**界面层不应出现字面量**。 +全部定义在 `web/src/styles/reader.css` 的 `:root` 里。**界面层不应出现字面量**。 ### 2.1 间距:四档 @@ -699,17 +699,17 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「 | `web/tools/icons/` | 生成器 | | `web/test/icons.test.ts` | 字体度量、字形包围盒、码位表、**没有借来的名字**、词汇表与 `.icon` 规则的契约 | | `web/src/ui/toolkit.tsx` | `IconButton` / `Button` / `Segmented` / `SwitchRow` / `Scrim` | -| `web/src/ui/shelf-screen.tsx` | 书架;排序(§3.4.2)、分页器(§3.5)与书库入口(§3.4) | -| `web/src/ui/library-screen.tsx` | 书库的两个屏幕:`LibraryBrowseScreen`(商城式浏览页,§3.4.1.1)与 `LibraryFilesScreen`(文件管理页,§3.4.4);面包屑、分页器、上传后重读(§3.5) | +| `web/src/features/shelf/pages/ShelfPage.tsx` | 书架;排序、分页器与书库入口 | +| `web/src/features/library/pages/LibraryPage.tsx` | 书库浏览、书籍列表和文件管理路由;搜索、面包屑、分页器、上传后重读 | | `web/src/ui/shelf-membership.ts` | 两个方向的措辞与批量报告那句话的唯一来源(§3.5.2) | | `web/src/ui/shelf-order.ts` | 排序的**唯一**定义:方向、客户端/服务端的键怎么翻译、阅读时间怎么排 | | `web/src/ui/dialog.tsx` | 提问(文本框/确认/选择/改资料),两个书库页面共用 | | `web/src/ui/pager.tsx` | 翻页器,书架与书库共用(§3.5) | -| `web/test/shelf-screen.test.ts` | 书架排序(默认项、`recent` 是客户端排)、分页的算术、超界怎么夹 | +| `web/test/shelf-order.test.ts` | 书架排序(默认项、`recent` 是客户端排)、排序方向和稳定性 | | `web/test/shelf-order.test.ts` | 排序的方向、客户端/服务端的键、阅读时间排序与并列时的稳定 | -| `web/test/library-screen.test.ts` | 浏览页:封面网格、文件夹作用域的搜索、「加入书架」发的是哪个书 id | -| `web/test/library-access.test.ts` | 文件管理页的管理员判定(§3.4.4):路由词汇,以及非管理员拿到的是浏览页 | -| `web/test/manager.test.ts` | 书库文件页的分页、上传后的重读、「破坏性操作必须先选中再确认」,以及**这一页没有上架/下架**(§3.5.1) | +| `web/test/react-router-routes.test.tsx` | React Router 的页面、影音嵌套路由和权限边界 | +| `web/test/media-library-list.test.tsx` | 媒体库列表、分页和空状态 | +| `web/test/media-library-editor.test.tsx` | 媒体库编辑、权限和保存后的重读 | | `web/src/ui/gestures.ts` | 手势与事件层级(§3.6) | | `web/src/ui/reader-chrome.tsx` | 阅读页的顶栏/页脚/面板/朗读条/沉浸式按钮列 | | `web/src/ui/shadow.ts` | 书内容 shadow root;含纯文本排版表(§3.9) | diff --git a/web/src/api/query.ts b/web/src/api/query.ts index d64da51..66208a5 100644 --- a/web/src/api/query.ts +++ b/web/src/api/query.ts @@ -1,9 +1,9 @@ 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. + * Shared client-side server cache for transport-level invalidation. React pages + * consume the same client through QueryClientProvider and React Query hooks; + * this module only owns the API client's framework-neutral cache operations. */ export const createReaderQueryClient = () => new QueryClient({ defaultOptions: { diff --git a/web/src/app/legacy-compat.ts b/web/src/app/legacy-compat.ts index 2ca4d0d..052c7b6 100644 --- a/web/src/app/legacy-compat.ts +++ b/web/src/app/legacy-compat.ts @@ -1,7 +1,7 @@ import { createAndroidPlatform, detectAndroidBridge, androidPageHost, androidSpeechBridge } from '../core/android-platform.ts'; import { createWebPlatform } from '../core/web-platform.ts'; import type { Platform } from '../core/platform.ts'; -import { renderChannelLinks } from '../media/channel-navigation.tsx'; +import { renderChannelLinks } from '../features/media/components/channel-navigation.tsx'; import type { AppSettings } from '../store/settings.ts'; /** diff --git a/web/src/app/runtime.ts b/web/src/app/runtime.ts index ffd0854..62f3fb7 100644 --- a/web/src/app/runtime.ts +++ b/web/src/app/runtime.ts @@ -8,7 +8,7 @@ import { OfflineStore } from '../store/offline.ts'; import { SettingsStore, type AppSettings } from '../store/settings.ts'; import { publicationScope } from '../store/publications.ts'; import { MediaApi } from '../features/media/api/media-api.ts'; -import { MediaPlayer } from '../media/player.ts'; +import { MediaPlayer } from '../features/media/services/player.ts'; import { createAppQueryClient } from '../shared/query/query-client.ts'; import { useAuthStore } from '../shared/stores/auth.store.ts'; import { useSettingsStore } from '../shared/stores/settings.store.ts'; diff --git a/web/src/features/auth/pages/LoginPage.tsx b/web/src/features/auth/pages/LoginPage.tsx index f153083..444decd 100644 --- a/web/src/features/auth/pages/LoginPage.tsx +++ b/web/src/features/auth/pages/LoginPage.tsx @@ -68,5 +68,5 @@ export function LoginPage() { } finally { setBusy(false); } }; const canRegister = !!instance?.registrationOpen; - return
reader你的私人书房 setServerUrl(event.currentTarget.value)} placeholder="http://127.0.0.1:5888" required rightSection={probing ? : instance ? 在线 : undefined} />{instance?.name || '尚未连接服务端'}{instance && API v{instance.apiVersion}} setUsername(event.currentTarget.value)} required />{registering && setDisplayName(event.currentTarget.value)} />}{registering && instance?.invitationRequired && setInviteCode(event.currentTarget.value)} required />}{registering && 注册后会自动登录并保存此服务端地址。} setPassword(event.currentTarget.value)} required />{notice && {notice}}{error && {error}}{(canRegister || registering) && }
; + return
reader你的私人书房 setServerUrl(event.currentTarget.value)} placeholder="http://127.0.0.1:5888" required rightSection={probing ? : instance ? 在线 : undefined} />{instance?.name || '尚未连接服务端'}{instance && API v{instance.apiVersion}} setUsername(event.currentTarget.value)} required />{registering && setDisplayName(event.currentTarget.value)} />}{registering && instance?.invitationRequired && setInviteCode(event.currentTarget.value)} required />}{registering && 注册后会自动登录并保存此服务端地址。} setPassword(event.currentTarget.value)} required />{notice && {notice}}{error && {error}}{(canRegister || registering) && }
; } diff --git a/web/src/features/media/components/MediaItemGrid.tsx b/web/src/features/media/components/MediaItemGrid.tsx index b3f5578..52b80a7 100644 --- a/web/src/features/media/components/MediaItemGrid.tsx +++ b/web/src/features/media/components/MediaItemGrid.tsx @@ -1,17 +1,17 @@ -import { Card, SimpleGrid, Text, UnstyledButton } from '@mantine/core'; +import { Text, UnstyledButton } from '@mantine/core'; import { Link } from 'react-router-dom'; -import { MediaCover } from '../components/cover.tsx'; +import { MediaCover } from './cover.tsx'; import type { Item, MediaChannel } from '../api/media-api.ts'; import { useRuntime } from '../../../app/providers/runtime-context.tsx'; -import { favoriteChannel } from '../components/favorites.tsx'; +import { favoriteChannel } from './favorites.tsx'; export function MediaItemGrid({ items, channel }: { items: Item[]; channel?: MediaChannel }) { const { mediaApi } = useRuntime(); - if (!items.length) return 暂无内容。; - return - {items.map(item => + if (!items.length) return 暂无内容。; + return
+ {items.map(item => - {item.title} - )} - ; + {item.title} + )} +
; } diff --git a/web/src/features/media/components/MediaPageFrame.tsx b/web/src/features/media/components/MediaPageFrame.tsx index 230a11d..b0cf453 100644 --- a/web/src/features/media/components/MediaPageFrame.tsx +++ b/web/src/features/media/components/MediaPageFrame.tsx @@ -1,13 +1,22 @@ -import { Alert, Button, Group, Loader, Stack, Title } from '@mantine/core'; +import { Alert, Button, Group, Loader, Title } from '@mantine/core'; +import { ArrowLeft } from 'lucide-react'; import { Link } from 'react-router-dom'; import type { ReactNode } from 'react'; import { useMediaChannel } from '../hooks/use-media-channel.ts'; -export function MediaPageFrame({ title, children, actions }: { title: string; children: ReactNode; actions?: ReactNode }) { +export function MediaPageFrame({ title, children, actions, className }: { title: string; children: ReactNode; actions?: ReactNode; className?: string }) { const channel = useMediaChannel(); - return - {title}{actions} - {children}; + const rootClassName = ['media-screen', 'media-page', className].filter(Boolean).join(' '); + return
+
+
+ + {title} +
+ {actions &&
{actions}
} +
+ {children} +
; } export function QueryFeedback({ pending, error, retry }: { pending?: boolean; error?: Error | null; retry?: () => void }) { if (pending) return 正在加载…; diff --git a/web/src/media/ai-scan-jobs.tsx b/web/src/features/media/components/ai-scan-jobs.tsx similarity index 97% rename from web/src/media/ai-scan-jobs.tsx rename to web/src/features/media/components/ai-scan-jobs.tsx index 6067b3b..0502d3f 100644 --- a/web/src/media/ai-scan-jobs.tsx +++ b/web/src/features/media/components/ai-scan-jobs.tsx @@ -1,6 +1,6 @@ import {ChevronDown,ChevronRight,Trash2} from 'lucide-react'; import { useEffect, useState } from 'react'; -import type {AiScanBatch,AiScanJob,Library,MediaApi} from './api.ts'; +import type {AiScanBatch,AiScanJob,Library,MediaApi} from '../api/media-api.ts'; const labels:Record={queued:'排队中',running:'进行中',complete:'已完成',failed:'失败'}; const category:Record={movie:'电影',series:'剧集',music:'音乐',audiobook:'有声书',other:'其他'}; diff --git a/web/src/media/audio-tools.tsx b/web/src/features/media/components/audio-tools.tsx similarity index 98% rename from web/src/media/audio-tools.tsx rename to web/src/features/media/components/audio-tools.tsx index 54f404a..54085e5 100644 --- a/web/src/media/audio-tools.tsx +++ b/web/src/features/media/components/audio-tools.tsx @@ -1,6 +1,6 @@ import { Heart, X } from 'lucide-react'; import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; -import type { MediaApi } from './api.ts'; +import type { MediaApi } from '../api/media-api.ts'; export function AudioToolSheet({title,onClose,children}:{title:string;onClose:()=>void;children:ReactNode}){ const ref=useRef(null); diff --git a/web/src/media/audio-tracks.ts b/web/src/features/media/components/audio-tracks.ts similarity index 98% rename from web/src/media/audio-tracks.ts rename to web/src/features/media/components/audio-tracks.ts index 528c08a..1a6d482 100644 --- a/web/src/media/audio-tracks.ts +++ b/web/src/features/media/components/audio-tracks.ts @@ -1,4 +1,4 @@ -import type { MediaApi } from './api.ts'; +import type { MediaApi } from '../api/media-api.ts'; interface AudioTrack { label:string;language:string;enabled:boolean } interface AudioTrackList extends EventTarget { readonly length:number;[index:number]:AudioTrack } diff --git a/web/src/media/channel-navigation.tsx b/web/src/features/media/components/channel-navigation.tsx similarity index 97% rename from web/src/media/channel-navigation.tsx rename to web/src/features/media/components/channel-navigation.tsx index 8e8bbaf..a59acb7 100644 --- a/web/src/media/channel-navigation.tsx +++ b/web/src/features/media/components/channel-navigation.tsx @@ -1,4 +1,4 @@ -import { render } from '../shared/ui/render-root.ts'; +import { render } from '../../../shared/ui/render-root.ts'; import { BookOpen, Clapperboard, Music2, Headphones, ChevronsUpDown, Check } from 'lucide-react'; import { useEffect, useRef } from 'react'; diff --git a/web/src/media/continue-playing.tsx b/web/src/features/media/components/continue-playing.tsx similarity index 98% rename from web/src/media/continue-playing.tsx rename to web/src/features/media/components/continue-playing.tsx index dc6f837..dbb5171 100644 --- a/web/src/media/continue-playing.tsx +++ b/web/src/features/media/components/continue-playing.tsx @@ -1,6 +1,6 @@ import {Play} from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; -import type { MediaApi, Part, Detail } from './api.ts'; +import type { MediaApi, Part, Detail } from '../api/media-api.ts'; import {MediaCover} from './cover.tsx'; import {historyPosition} from './history-labels.ts'; diff --git a/web/src/media/empty-library.tsx b/web/src/features/media/components/empty-library.tsx similarity index 95% rename from web/src/media/empty-library.tsx rename to web/src/features/media/components/empty-library.tsx index d6d977c..665346a 100644 --- a/web/src/media/empty-library.tsx +++ b/web/src/features/media/components/empty-library.tsx @@ -1,5 +1,5 @@ import { FolderOpen } from 'lucide-react'; -import type { MediaChannel } from './api.ts'; +import type { MediaChannel } from '../api/media-api.ts'; export function EmptyMediaLibrary({channel,hasLibraries,admin,category,onCreate,onManage}: { channel:MediaChannel;hasLibraries:boolean;admin:boolean;category:string; diff --git a/web/src/features/media/components/favorites.tsx b/web/src/features/media/components/favorites.tsx index b8706d5..63c89bc 100644 --- a/web/src/features/media/components/favorites.tsx +++ b/web/src/features/media/components/favorites.tsx @@ -4,7 +4,7 @@ import {Modal} from '../../../ui/modal.tsx'; import {MediaCover} from './cover.tsx'; import {itemLabel} from './item-label.ts'; import type {Item,MediaApi,MediaChannel} from '../api/media-api.ts'; -import './favorites.css'; +import '../styles/favorites.css'; export type FavoriteScope=MediaChannel|'all'; const scopes:Array<[FavoriteScope,string]>=[['all','全部'],['video','影视'],['music','音乐'],['audiobook','有声书']]; diff --git a/web/src/media/folder-cleanup.tsx b/web/src/features/media/components/folder-cleanup.tsx similarity index 96% rename from web/src/media/folder-cleanup.tsx rename to web/src/features/media/components/folder-cleanup.tsx index 69c70a0..c47b154 100644 --- a/web/src/media/folder-cleanup.tsx +++ b/web/src/features/media/components/folder-cleanup.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import {Trash2} from 'lucide-react'; -import {FloatingConfirm} from '../ui/floating-confirm.tsx'; -import type {MediaApi} from './api.ts'; +import {FloatingConfirm} from '../../../ui/floating-confirm.tsx'; +import type {MediaApi} from '../api/media-api.ts'; export interface CleanupPreview { path:string;revision:string;assets:number;parts:number;editions:number;items:number;favorites:number;progress:number;queue:number; diff --git a/web/src/media/folder-recognition.tsx b/web/src/features/media/components/folder-recognition.tsx similarity index 98% rename from web/src/media/folder-recognition.tsx rename to web/src/features/media/components/folder-recognition.tsx index 6a4ff66..0f5bc2c 100644 --- a/web/src/media/folder-recognition.tsx +++ b/web/src/features/media/components/folder-recognition.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from 'react'; import {Settings2} from 'lucide-react'; -import './folder-recognition.css'; -import {Modal} from '../ui/modal.tsx'; -import {FloatingConfirm} from '../ui/floating-confirm.tsx'; +import '../styles/folder-recognition.css'; +import {Modal} from '../../../ui/modal.tsx'; +import {FloatingConfirm} from '../../../ui/floating-confirm.tsx'; import {MediaSelect} from './select.tsx'; -import type {MediaApi} from './api.ts'; +import type {MediaApi} from '../api/media-api.ts'; type Mode='auto'|'movie'|'series'|'season'|'ignore'; interface Rule {path:string;mode:Mode;title?:string;season?:number|string;year?:number;stripLeadingNumber?:boolean;filePattern?:string} interface Proposal {assetId:string;ref:string;before:{id:string;title:string;kind:string}|null;status:'ready'|'review'|'protected'|'ignored';reason:string;after:{kind:string;confidence:string;metadata:{title:string;show?:string;season?:number|string;episode?:number}}} diff --git a/web/src/features/media/components/folders.tsx b/web/src/features/media/components/folders.tsx index 8403343..58fa05e 100644 --- a/web/src/features/media/components/folders.tsx +++ b/web/src/features/media/components/folders.tsx @@ -1,10 +1,10 @@ -import {FolderRecognition} from '../../../media/folder-recognition.tsx'; -import {FolderCleanup} from '../../../media/folder-cleanup.tsx'; +import {FolderRecognition} from './folder-recognition.tsx'; +import {FolderCleanup} from './folder-cleanup.tsx'; import {MediaSelect} from './select.tsx'; import { useEffect, useRef, useState } from 'react'; import {Folder,FolderOpen,File,ChevronRight,ChevronLeft,ArrowUpRight} from 'lucide-react'; import type {Detail,MediaApi,Part} from '../api/media-api.ts'; -import {EditionDetails,type ChapterPosition} from '../components/edition-details.tsx'; +import {EditionDetails,type ChapterPosition} from './edition-details.tsx'; import {ResourceInfo} from './resource-info.tsx'; import {MediaLoading} from './loading.tsx'; import {MediaScreenError} from './screen-error.tsx'; @@ -51,8 +51,8 @@ export function MediaFolders({api,admin=false,video=false,libraryId,initialLocat {error&&setRetry(value=>value+1)}/>}{actionError&&
{actionError}
}{notice&&

{notice}

} {loading&&} {cleanupNotice&&

{cleanupNotice}

} - {listing&&<>{listing.items.map(entry=>
)}{!listing.total&&

暂无已扫描媒体文件,请先扫描媒体库。

}{listing.total>60&&}

按最近扫描结果显示媒体文件;不包含空目录及其他文件。原始目录只读。

} - {file&&<>
-
{file.items.map(item=>setEditions({...editions,[item.id]:id})} positions={chapters} onPosition={(id,position)=>setChapters({...chapters,[id]:position})} onDetail={()=>onDetail(item.id,{path,offset,assetId,editions,chapters})} onRefresh={()=>setRetry(value=>value+1)} onPlay={(parts,index)=>void action(()=>onPlay(parts,index,item.title))} onQueue={ids=>void action(()=>onQueue(ids),true)}/>)}{!file.items.length&&
}
} + {listing&&
{listing.items.map(entry=>
)}
{!listing.total&&

暂无已扫描媒体文件,请先扫描媒体库。

}{listing.total>60&&}

按最近扫描结果显示媒体文件;不包含空目录及其他文件。原始目录只读。

} + {file&&
+
{file.items.map(item=>setEditions({...editions,[item.id]:id})} positions={chapters} onPosition={(id,position)=>setChapters({...chapters,[id]:position})} onDetail={()=>onDetail(item.id,{path,offset,assetId,editions,chapters})} onRefresh={()=>setRetry(value=>value+1)} onPlay={(parts,index)=>void action(()=>onPlay(parts,index,item.title))} onQueue={ids=>void action(()=>onQueue(ids),true)}/>)}
{!file.items.length&&
}
} ; } diff --git a/web/src/media/history-labels.ts b/web/src/features/media/components/history-labels.ts similarity index 100% rename from web/src/media/history-labels.ts rename to web/src/features/media/components/history-labels.ts diff --git a/web/src/media/lyrics.tsx b/web/src/features/media/components/lyrics.tsx similarity index 97% rename from web/src/media/lyrics.tsx rename to web/src/features/media/components/lyrics.tsx index 1dfcded..fc77e67 100644 --- a/web/src/media/lyrics.tsx +++ b/web/src/features/media/components/lyrics.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; -import type { MediaApi } from './api.ts'; -import type { MediaPlayer } from './player.ts'; +import type { MediaApi } from '../api/media-api.ts'; +import type { MediaPlayer } from '../services/player.ts'; import {LocateFixed,Music2} from 'lucide-react'; interface LyricsData {synced:boolean;lines:Array<{time:number|null;text:string}>;source:string} export function Lyrics({api,player}:{api:MediaApi;player:MediaPlayer}){ diff --git a/web/src/media/metadata-page.tsx b/web/src/features/media/components/metadata-page.tsx similarity index 95% rename from web/src/media/metadata-page.tsx rename to web/src/features/media/components/metadata-page.tsx index 5cb5408..676f594 100644 --- a/web/src/media/metadata-page.tsx +++ b/web/src/features/media/components/metadata-page.tsx @@ -1,7 +1,7 @@ -import {FloatingConfirm} from '../ui/floating-confirm.tsx'; +import {FloatingConfirm} from '../../../ui/floating-confirm.tsx'; import { useEffect, useRef, useState } from 'react'; import {ChevronLeft} from 'lucide-react'; -import type {Detail,MediaApi} from './api.ts'; +import type {Detail,MediaApi} from '../api/media-api.ts'; import {MetadataEditor,type MetadataEditState} from './metadata-editor.tsx'; import {MetadataMatcher} from './metadata-matcher.tsx'; diff --git a/web/src/media/mini-now-playing.tsx b/web/src/features/media/components/mini-now-playing.tsx similarity index 94% rename from web/src/media/mini-now-playing.tsx rename to web/src/features/media/components/mini-now-playing.tsx index 881d13c..921d321 100644 --- a/web/src/media/mini-now-playing.tsx +++ b/web/src/features/media/components/mini-now-playing.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import type {MediaApi,Detail} from './api.ts'; +import type {MediaApi,Detail} from '../api/media-api.ts'; import {MediaCover} from './cover.tsx'; import {Music2} from 'lucide-react'; export function MiniNowPlaying({api,itemId,title}:{api:MediaApi;itemId:string;title:string}){ diff --git a/web/src/features/media/components/narrators.tsx b/web/src/features/media/components/narrators.tsx index 8a41373..cab61a3 100644 --- a/web/src/features/media/components/narrators.tsx +++ b/web/src/features/media/components/narrators.tsx @@ -2,7 +2,7 @@ import {MediaSelect} from './select.tsx'; import { useEffect, useState, type ReactNode } from 'react'; import type { Detail, MediaApi, Narrator, Part } from '../api/media-api.ts'; import { MediaCover } from './cover.tsx'; -import { EditionDetails } from '../components/edition-details.tsx'; +import { EditionDetails } from './edition-details.tsx'; import {PersonPortrait} from './person-portrait.tsx'; import {ChevronLeft,Play,ArrowUpRight} from 'lucide-react'; diff --git a/web/src/media/playback-controls.tsx b/web/src/features/media/components/playback-controls.tsx similarity index 99% rename from web/src/media/playback-controls.tsx rename to web/src/features/media/components/playback-controls.tsx index 1f923ba..eb5e62e 100644 --- a/web/src/media/playback-controls.tsx +++ b/web/src/features/media/components/playback-controls.tsx @@ -1,9 +1,9 @@ import {MediaSelect} from './select.tsx'; import {SleepTimerOptions} from './sleep-timer.tsx'; import { ChevronLeft, Layers, Play, Pause, SkipBack, SkipForward, RotateCcw, RotateCw, Square, Timer, Gauge, Ellipsis, BookOpen, ListMusic, Search, ArrowUp, ArrowDown, X, Volume2, Pencil } from 'lucide-react'; -import type { MediaPlayer } from './player.ts'; +import type { MediaPlayer } from '../services/player.ts'; import { useEffect, useState, useRef, useLayoutEffect } from 'react'; -import type {Detail,MediaApi,Part} from './api.ts'; +import type {Detail,MediaApi,Part} from '../api/media-api.ts'; import {MediaCover} from './cover.tsx'; import {AudiobookChapters} from './audiobook-chapters.tsx'; import type {ChapterPosition} from './edition-details.tsx'; diff --git a/web/src/media/playback-problem.tsx b/web/src/features/media/components/playback-problem.tsx similarity index 95% rename from web/src/media/playback-problem.tsx rename to web/src/features/media/components/playback-problem.tsx index ae892ce..38fa3cf 100644 --- a/web/src/media/playback-problem.tsx +++ b/web/src/features/media/components/playback-problem.tsx @@ -1,5 +1,5 @@ import {LockKeyhole,TriangleAlert} from 'lucide-react'; -import type {MediaPlayer} from './player.ts'; +import type {MediaPlayer} from '../services/player.ts'; /** Recovery opens an existing selector; it never creates a session automatically. */ export function PlaybackProblem({player,onVersions,onBack,backLabel='返回浏览'}:{player:MediaPlayer;onVersions?:()=>void;onBack?:()=>void;backLabel?:string}){ diff --git a/web/src/media/playback-settings.tsx b/web/src/features/media/components/playback-settings.tsx similarity index 94% rename from web/src/media/playback-settings.tsx rename to web/src/features/media/components/playback-settings.tsx index 95cdff4..0df0624 100644 --- a/web/src/media/playback-settings.tsx +++ b/web/src/features/media/components/playback-settings.tsx @@ -1,7 +1,7 @@ import {MediaSelect} from './select.tsx'; import { useState } from 'react'; -import {readPlaybackPreferences,savePlaybackPreferences,playbackRates} from './playback-preferences.ts'; -import type {MediaPlayer} from './player.ts'; +import {readPlaybackPreferences,savePlaybackPreferences,playbackRates} from '../services/playback-preferences.ts'; +import type {MediaPlayer} from '../services/player.ts'; import {SleepTimerOptions} from './sleep-timer.tsx'; export function PlaybackSettings({scope,player,onPlayback}:{scope:string;player:MediaPlayer;onPlayback?:()=>void}) { const [value,setValue]=useState(()=>readPlaybackPreferences(scope)),[error,setError]=useState(''); diff --git a/web/src/media/provider-settings.tsx b/web/src/features/media/components/provider-settings.tsx similarity index 97% rename from web/src/media/provider-settings.tsx rename to web/src/features/media/components/provider-settings.tsx index 933133a..6497977 100644 --- a/web/src/media/provider-settings.tsx +++ b/web/src/features/media/components/provider-settings.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from 'react'; import {ChevronRight,FileText,Search} from 'lucide-react'; -import type {MediaApi} from './api.ts'; +import type {MediaApi} from '../api/media-api.ts'; import {MediaLoading} from './loading.tsx'; import {MediaScreenError} from './screen-error.tsx'; -import {BusinessSettings} from '../ui/business-settings.tsx'; +import {BusinessSettings} from '../../../ui/business-settings.tsx'; import {MediaSelect} from './select.tsx'; interface Provider {id:string;label:string;kinds:string[];configured:boolean} diff --git a/web/src/features/media/components/resource-panel.tsx b/web/src/features/media/components/resource-panel.tsx index 3685a24..19873dd 100644 --- a/web/src/features/media/components/resource-panel.tsx +++ b/web/src/features/media/components/resource-panel.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import {Ellipsis,FileText,Layers} from 'lucide-react'; import {Modal} from '../../../ui/modal.tsx'; import {ResourceInfo} from './resource-info.tsx'; -import {EditionTools,type EditionDetailsProps} from '../components/edition-details.tsx'; +import {EditionTools,type EditionDetailsProps} from './edition-details.tsx'; import type {MediaApi} from '../api/media-api.ts'; export function ResourcePanel({api,assets,summary,editionOptions,versionPicker,sourceInfo,openPanel,onPanelChange}:{api:MediaApi;assets:Array<{id:string;title:string}>;summary:string;versionPicker?:ReactNode;sourceInfo?:ReactNode;openPanel?:'files'|'source'|null;onPanelChange?:(panel:'files'|'source'|null)=>void;editionOptions?:EditionDetailsProps|undefined}){ diff --git a/web/src/media/save-playlist.tsx b/web/src/features/media/components/save-playlist.tsx similarity index 95% rename from web/src/media/save-playlist.tsx rename to web/src/features/media/components/save-playlist.tsx index 0da187d..9b6c278 100644 --- a/web/src/media/save-playlist.tsx +++ b/web/src/features/media/components/save-playlist.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; -import type { MediaApi } from './api.ts'; -import type { MediaPlayer } from './player.ts'; +import type { MediaApi } from '../api/media-api.ts'; +import type { MediaPlayer } from '../services/player.ts'; interface Preview {revision:string;channel:'video'|'music'|'audiobook';existingCount:number;newCount:number;partIds:string[]} export function SavePlaylist({api,player}:{api:MediaApi;player:MediaPlayer}){ diff --git a/web/src/media/saved-queue.tsx b/web/src/features/media/components/saved-queue.tsx similarity index 100% rename from web/src/media/saved-queue.tsx rename to web/src/features/media/components/saved-queue.tsx diff --git a/web/src/media/scan-feedback.ts b/web/src/features/media/components/scan-feedback.ts similarity index 100% rename from web/src/media/scan-feedback.ts rename to web/src/features/media/components/scan-feedback.ts diff --git a/web/src/media/scan-jobs.tsx b/web/src/features/media/components/scan-jobs.tsx similarity index 98% rename from web/src/media/scan-jobs.tsx rename to web/src/features/media/components/scan-jobs.tsx index 11f642f..45192d1 100644 --- a/web/src/media/scan-jobs.tsx +++ b/web/src/features/media/components/scan-jobs.tsx @@ -1,6 +1,6 @@ import { RefreshCw, Trash2, X } from 'lucide-react'; import { useState } from 'react'; -import type { Library, ScanJob } from './api.ts'; +import type { Library, ScanJob } from '../api/media-api.ts'; import { scanFeedback } from './scan-feedback.ts'; const states:Record={queued:'排队中',running:'进行中',complete:'已完成',failed:'失败',cancelled:'已取消'}; diff --git a/web/src/media/scrape-jobs.tsx b/web/src/features/media/components/scrape-jobs.tsx similarity index 99% rename from web/src/media/scrape-jobs.tsx rename to web/src/features/media/components/scrape-jobs.tsx index b773dd6..cb7ab45 100644 --- a/web/src/media/scrape-jobs.tsx +++ b/web/src/features/media/components/scrape-jobs.tsx @@ -1,8 +1,8 @@ -import {FloatingConfirm} from '../ui/floating-confirm.tsx'; +import {FloatingConfirm} from '../../../ui/floating-confirm.tsx'; import {MediaSelect} from './select.tsx'; import { ChevronDown, ChevronRight } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; -import type { Item, Library, MediaApi, MediaChannel } from './api.ts'; +import type { Item, Library, MediaApi, MediaChannel } from '../api/media-api.ts'; interface Job {id:string;provider:string;state:string;created_at?:number;total?:number;counts?:Record;items:Array<{itemId:string;state:string;error:string|null;title?:string;channel?:MediaChannel}>} const countState=(job:Job,state:string)=>job.counts?.[state]??job.items.filter(row=>row.state===state).length; const jobTotal=(job:Job)=>job.total??job.items.length; diff --git a/web/src/media/search.tsx b/web/src/features/media/components/search.tsx similarity index 98% rename from web/src/media/search.tsx rename to web/src/features/media/components/search.tsx index 1c36962..bdd85a7 100644 --- a/web/src/media/search.tsx +++ b/web/src/features/media/components/search.tsx @@ -3,8 +3,8 @@ import { Search, ChevronRight } from 'lucide-react'; import { MediaCover } from './cover.tsx'; import { MediaLoading } from './loading.tsx'; import { MediaScreenError } from './screen-error.tsx'; -import type { MediaApi, MediaChannel, SearchItem } from './api.ts'; -import { newId } from '../core/id.ts'; +import type { MediaApi, MediaChannel, SearchItem } from '../api/media-api.ts'; +import { newId } from '../../../core/id.ts'; const labels={video:'影视',music:'音乐',audiobook:'有声书'}; const kinds:Record={movie:'电影',series:'剧集',season:'季',episode:'单集',album:'专辑',artist:'歌手',track:'曲目',audiobook:'有声书'}; diff --git a/web/src/features/media/components/select.tsx b/web/src/features/media/components/select.tsx index 9cd38b6..c04552c 100644 --- a/web/src/features/media/components/select.tsx +++ b/web/src/features/media/components/select.tsx @@ -1,7 +1,7 @@ import type { SelectHTMLAttributes, KeyboardEvent as ReactKeyboardEvent } from 'react'; import { useLayoutEffect, useRef, useState } from 'react'; import {Check,ChevronDown} from 'lucide-react'; -import './select.css'; +import '../styles/select.css'; type Choice={value:string;label:string;disabled:boolean;group:string}; type Props=Omit,'ref'|'multiple'|'size'> & { diff --git a/web/src/features/media/components/settings.tsx b/web/src/features/media/components/settings.tsx index 8f2a653..ddf65c6 100644 --- a/web/src/features/media/components/settings.tsx +++ b/web/src/features/media/components/settings.tsx @@ -1,14 +1,14 @@ import {FloatingConfirm} from '../../../ui/floating-confirm.tsx'; import {MediaSelect} from './select.tsx'; -import {PlaybackSettings} from '../../../media/playback-settings.tsx'; -import {MediaThemeSettings} from '../../../media/theme-settings.tsx'; -import {readMediaTheme,mediaThemes,type MediaThemeId} from '../../../media/theme.ts'; +import {PlaybackSettings} from './playback-settings.tsx'; +import {MediaThemeSettings} from './theme-settings.tsx'; +import {readMediaTheme,mediaThemes,type MediaThemeId} from '../services/theme.ts'; export type SettingsPanel = 'home'|'theme'|'browse'|'playback'|'plugins'|'account'; import { useState } from 'react'; import {ChevronLeft,ChevronRight,Clock3,Heart,Headphones,FolderOpen,SlidersHorizontal,RefreshCw,UserRound,Blocks,Palette} from 'lucide-react'; -import type {MediaPlayer} from '../../../media/player.ts'; +import type {MediaPlayer} from '../services/player.ts'; import type {MediaApi,MediaAccount} from '../api/media-api.ts'; -import {MediaProviderSettings} from '../../../media/provider-settings.tsx'; +import {MediaProviderSettings} from './provider-settings.tsx'; import {defaultMediaPreferences,saveMediaPreferences,type MediaPreferences} from '../services/preferences.ts'; export function MediaSettings({scope,preferences,player,admin,api,account,onSaved,onBack,onPersonal,onManage,onTasks,onPlayback,panel:routePanel,onPanelChange,theme,onThemeChange}:{scope:string;preferences:MediaPreferences;player:MediaPlayer;admin:boolean;api?:MediaApi;account?:MediaAccount|null;onSaved:(value:MediaPreferences)=>void;onBack:()=>void;onPersonal:(view:'favorites'|'history'|'queue')=>void;onManage:()=>void;onTasks?:()=>void;onPlayback?:()=>void;panel?:SettingsPanel;onPanelChange?:(panel:SettingsPanel)=>void;channelLabel?:string;theme?:MediaThemeId;onThemeChange?:(theme:MediaThemeId)=>void}){ diff --git a/web/src/media/sleep-timer.tsx b/web/src/features/media/components/sleep-timer.tsx similarity index 93% rename from web/src/media/sleep-timer.tsx rename to web/src/features/media/components/sleep-timer.tsx index 866d4e2..0141f8f 100644 --- a/web/src/media/sleep-timer.tsx +++ b/web/src/features/media/components/sleep-timer.tsx @@ -1,5 +1,5 @@ import {Check,TimerOff} from 'lucide-react'; -import type {MediaPlayer} from './player.ts'; +import type {MediaPlayer} from '../services/player.ts'; export function SleepTimerOptions({player,onSelected}:{player:MediaPlayer;onSelected?:()=>void}){ function choose(minutes:number){player.sleep(minutes);onSelected?.();} diff --git a/web/src/media/subtitles.ts b/web/src/features/media/components/subtitles.ts similarity index 99% rename from web/src/media/subtitles.ts rename to web/src/features/media/components/subtitles.ts index 63318fd..4c247d8 100644 --- a/web/src/media/subtitles.ts +++ b/web/src/features/media/components/subtitles.ts @@ -1,4 +1,4 @@ -import type { MediaApi } from './api.ts'; +import type { MediaApi } from '../api/media-api.ts'; interface Subtitle { id: string; label: string; language: string; format: string; source?:'embedded'|'external' } diff --git a/web/src/media/theme-settings.tsx b/web/src/features/media/components/theme-settings.tsx similarity index 96% rename from web/src/media/theme-settings.tsx rename to web/src/features/media/components/theme-settings.tsx index 2ae0a9e..70063e1 100644 --- a/web/src/media/theme-settings.tsx +++ b/web/src/features/media/components/theme-settings.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import {Check,Monitor} from 'lucide-react'; -import {mediaThemes,saveMediaTheme,type MediaThemeId} from './theme.ts'; +import {mediaThemes,saveMediaTheme,type MediaThemeId} from '../services/theme.ts'; export function MediaThemeSettings({scope,selected,onChange}:{scope:string;selected:MediaThemeId;onChange:(theme:MediaThemeId)=>void}){ const [error,setError]=useState(''),[saved,setSaved]=useState(false); diff --git a/web/src/media/video-controls.tsx b/web/src/features/media/components/video-controls.tsx similarity index 97% rename from web/src/media/video-controls.tsx rename to web/src/features/media/components/video-controls.tsx index ee4bd67..911d3bc 100644 --- a/web/src/media/video-controls.tsx +++ b/web/src/features/media/components/video-controls.tsx @@ -2,12 +2,12 @@ import {VideoEpisodeRail} from './video-episode-rail.tsx'; import {SleepTimerOptions} from './sleep-timer.tsx'; import {Ellipsis,X,Square,Check,Subtitles,AudioLines,Layers} from 'lucide-react'; import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; -import type {MediaPlayer} from './player.ts'; -import type {Detail,MediaApi} from './api.ts'; +import type {MediaPlayer} from '../services/player.ts'; +import type {Detail,MediaApi} from '../api/media-api.ts'; import {MediaLoading} from './loading.tsx'; import {MediaScreenError} from './screen-error.tsx'; import {PlaybackProblem} from './playback-problem.tsx'; -import {FloatingNotice} from '../ui/floating-notice.tsx'; +import {FloatingNotice} from '../../../ui/floating-notice.tsx'; type Tool='episodes'|'subtitles'|'tracks'|'volume'|'more'|'versions'; const titles:Record={episodes:'选集',subtitles:'字幕',tracks:'音轨',volume:'音量',more:'更多播放选项',versions:'播放版本'}; diff --git a/web/src/media/video-episode-rail.tsx b/web/src/features/media/components/video-episode-rail.tsx similarity index 97% rename from web/src/media/video-episode-rail.tsx rename to web/src/features/media/components/video-episode-rail.tsx index 5d49f2a..78b6a5c 100644 --- a/web/src/media/video-episode-rail.tsx +++ b/web/src/features/media/components/video-episode-rail.tsx @@ -1,7 +1,7 @@ import {MediaSelect} from './select.tsx'; import { useEffect, useState } from 'react'; -import type {Detail,Item,MediaApi} from './api.ts'; -import type {MediaPlayer} from './player.ts'; +import type {Detail,Item,MediaApi} from '../api/media-api.ts'; +import type {MediaPlayer} from '../services/player.ts'; import {seasonQueue,episodeDisplayTitle,type SeasonEpisode} from './season-playback.tsx'; import {Check} from 'lucide-react'; import {MediaLoading} from './loading.tsx'; diff --git a/web/src/media/web-video.ts b/web/src/features/media/components/web-video.ts similarity index 96% rename from web/src/media/web-video.ts rename to web/src/features/media/components/web-video.ts index 03f47df..b558e87 100644 --- a/web/src/media/web-video.ts +++ b/web/src/features/media/components/web-video.ts @@ -1,5 +1,5 @@ import DPlayer from 'dplayer'; -import './dplayer.css'; +import '../styles/dplayer.css'; /** Bind DPlayer before its media listeners are installed, retaining Reader's session node. */ export function enhanceVideo(video:HTMLVideoElement,onToggle:()=>void){ diff --git a/web/src/features/media/pages/MediaCatalogPage.tsx b/web/src/features/media/pages/MediaCatalogPage.tsx index 1c6a0bd..45dab69 100644 --- a/web/src/features/media/pages/MediaCatalogPage.tsx +++ b/web/src/features/media/pages/MediaCatalogPage.tsx @@ -6,7 +6,7 @@ import { catalogQuery, useMediaLibraries } from '../queries/media.queries.ts'; import { channelCategories, channelLabels, useMediaChannel } from '../hooks/use-media-channel.ts'; import { MediaItemGrid } from '../components/MediaItemGrid.tsx'; import { MediaPageFrame, QueryFeedback } from '../components/MediaPageFrame.tsx'; -import { ContinuePlaying } from '../../../media/continue-playing.tsx'; +import { ContinuePlaying } from '../components/continue-playing.tsx'; import { usePlaybackStore } from '../stores/playback.store.ts'; export function MediaCatalogPage() { @@ -18,11 +18,11 @@ export function MediaCatalogPage() { const patch = (values: Record) => { const next = new URLSearchParams(params); for (const [key, value] of Object.entries(values)) value ? next.set(key, value) : next.delete(key); setParams(next); }; const channelLibraries = libraries.data?.items.filter(item => item.kind === channel) ?? []; const selectedLibrary = params.get('library') ?? ''; - return }> - {categories.map(item => )} - item.kind === channel).map(item => ({ value: item.id, label: item.name })) ?? [])]} value={params.get('library') ?? ''} onChange={value => patch({ library: value ?? '', offset: '' })} /> setParams({ scope: value ?? 'all' })} /> + return +
+

按类型筛选收藏

({ value: item.id, label: item.name }))} onChange={value => { if (value) navigate(`/media/${channel}/folders/${encodeURIComponent(value)}`); }} /> { void query.refetch(); }} />{query.isPending && }{!query.isPending && !query.error && requestedLibraryId && !libraryId && 该媒体库不存在,或不属于当前频道。}{!query.isPending && !query.error && !requestedLibraryId && !libraryId && 当前频道没有可浏览的媒体库。}{libraryId && { await runtime.mediaApi.request('queue', 'POST', { partIds: ids }); }} onDetail={id => navigate(`/media/${channel}/items/${encodeURIComponent(id)}`)} />}; + return
+ return +
{ event.preventDefault(); const values = new FormData(event.currentTarget); setParams({ q: String(values.get('q') ?? ''), scope: String(values.get('scope') ?? 'all') }); }}> +