mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
refactor(web): finish media feature migration
This commit is contained in:
1 parent
2aaa4280c7
commit
050b8e89db
184 files changed
+354
-510
No files matched your search
+45
-110
@@ -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/<domain>` 拆分,每个领域拥有自己的 `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/<id>`
|
||||
进来而尚未登录时,这个 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 的
|
||||
挂载、更新和卸载语义。
|
||||
|
||||
### 只有一份渲染层
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 贡献流程
|
||||
|
||||
|
||||
@@ -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 负责创建、刷新和销毁。
|
||||
|
||||
最终验证结果:
|
||||
|
||||
|
||||
@@ -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)是布局参考;实际功能与地址以源码及当前说明为准。
|
||||
+15
-14
@@ -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 原型内的示例路由只用于设计演示。
|
||||
+8
-8
@@ -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) |
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -68,5 +68,5 @@ export function LoginPage() {
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
const canRegister = !!instance?.registrationOpen;
|
||||
return <Center mih="100vh" p="md"><form onSubmit={submit} style={{ width: 'min(100%, 28rem)' }}><Stack gap="md"><Stack gap={2}><Title order={1}>reader</Title><Text c="dimmed">你的私人书房</Text></Stack><TextInput label="服务端地址" type="url" value={serverUrl} onChange={event => setServerUrl(event.currentTarget.value)} placeholder="http://127.0.0.1:5888" required rightSection={probing ? <Loader size="xs" /> : instance ? <Badge color="green">在线</Badge> : undefined} /><Group justify="space-between" align="center"><Text size="sm" c="dimmed">{instance?.name || '尚未连接服务端'}</Text>{instance && <Text size="xs" c="dimmed">API v{instance.apiVersion}</Text>}</Group><Divider /><TextInput label="用户名" autoComplete="username" value={username} onChange={event => setUsername(event.currentTarget.value)} required />{registering && <TextInput label="显示名(可选)" value={displayName} onChange={event => setDisplayName(event.currentTarget.value)} />}{registering && instance?.invitationRequired && <TextInput label="邀请码" value={inviteCode} onChange={event => setInviteCode(event.currentTarget.value)} required />}{registering && <Text size="xs" c="dimmed">注册后会自动登录并保存此服务端地址。</Text>}<PasswordInput label="密码" autoComplete={registering ? 'new-password' : 'current-password'} value={password} onChange={event => setPassword(event.currentTarget.value)} required />{notice && <Alert color={instance ? 'blue' : 'yellow'}>{notice}</Alert>}{error && <Alert color="red">{error}</Alert>}<Button type="submit" loading={busy} disabled={!validServerUrl(serverUrl)}>{registering ? '注册并登录' : '登录'}</Button>{(canRegister || registering) && <Button type="button" variant="subtle" disabled={busy} onClick={() => setRegistering(value => !value)}>{registering ? '返回登录' : '注册新账号'}</Button>}</Stack></form></Center>;
|
||||
return <Center className="login-screen" mih="100vh" p="md"><form onSubmit={submit} style={{ width: 'min(100%, 28rem)' }}><Stack gap="md"><Stack gap={2}><Title order={1}>reader</Title><Text c="dimmed">你的私人书房</Text></Stack><TextInput label="服务端地址" type="url" value={serverUrl} onChange={event => setServerUrl(event.currentTarget.value)} placeholder="http://127.0.0.1:5888" required rightSection={probing ? <Loader size="xs" /> : instance ? <Badge color="green">在线</Badge> : undefined} /><Group justify="space-between" align="center"><Text size="sm" c="dimmed">{instance?.name || '尚未连接服务端'}</Text>{instance && <Text size="xs" c="dimmed">API v{instance.apiVersion}</Text>}</Group><Divider /><TextInput label="用户名" autoComplete="username" value={username} onChange={event => setUsername(event.currentTarget.value)} required />{registering && <TextInput label="显示名(可选)" value={displayName} onChange={event => setDisplayName(event.currentTarget.value)} />}{registering && instance?.invitationRequired && <TextInput label="邀请码" value={inviteCode} onChange={event => setInviteCode(event.currentTarget.value)} required />}{registering && <Text size="xs" c="dimmed">注册后会自动登录并保存此服务端地址。</Text>}<PasswordInput label="密码" autoComplete={registering ? 'new-password' : 'current-password'} value={password} onChange={event => setPassword(event.currentTarget.value)} required />{notice && <Alert color={instance ? 'blue' : 'yellow'}>{notice}</Alert>}{error && <Alert color="red">{error}</Alert>}<Button type="submit" loading={busy} disabled={!validServerUrl(serverUrl)}>{registering ? '注册并登录' : '登录'}</Button>{(canRegister || registering) && <Button type="button" variant="subtle" disabled={busy} onClick={() => setRegistering(value => !value)}>{registering ? '返回登录' : '注册新账号'}</Button>}</Stack></form></Center>;
|
||||
}
|
||||
@@ -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 <Text c="dimmed">暂无内容。</Text>;
|
||||
return <SimpleGrid cols={{ base: 2, sm: 3, md: 5, lg: 6 }}>
|
||||
{items.map(item => <Card key={item.id} withBorder padding="sm"><UnstyledButton component={Link} to={`/media/${channel ?? favoriteChannel(item)}/items/${encodeURIComponent(item.id)}`}>
|
||||
if (!items.length) return <Text className="media-empty" c="dimmed">暂无内容。</Text>;
|
||||
return <div className="media-grid">
|
||||
{items.map(item => <UnstyledButton className="media-tile" key={item.id} component={Link} to={`/media/${channel ?? favoriteChannel(item)}/items/${encodeURIComponent(item.id)}`}>
|
||||
<MediaCover api={mediaApi} item={item} square={['album', 'artist', 'track'].includes(item.kind)} />
|
||||
<Text fw={600} mt="sm" lineClamp={2}>{item.title}</Text>
|
||||
</UnstyledButton></Card>)}
|
||||
</SimpleGrid>;
|
||||
<strong>{item.title}</strong>
|
||||
</UnstyledButton>)}
|
||||
</div>;
|
||||
}
|
||||
@@ -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 <Stack p="md" className="media-page"><Group justify="space-between">
|
||||
<Group><Button component={Link} to={`/media/${channel}`} variant="subtle">返回频道</Button><Title order={2}>{title}</Title></Group>{actions}
|
||||
</Group>{children}</Stack>;
|
||||
const rootClassName = ['media-screen', 'media-page', className].filter(Boolean).join(' ');
|
||||
return <div className={rootClassName}>
|
||||
<header className="media-heading">
|
||||
<div className="media-page-heading">
|
||||
<Button className="media-back-button" component={Link} to={`/media/${channel}`} variant="subtle" aria-label="返回频道" title="返回频道"><ArrowLeft size={18} aria-hidden="true" /></Button>
|
||||
<Title order={1}>{title}</Title>
|
||||
</div>
|
||||
{actions && <div className="media-heading-actions">{actions}</div>}
|
||||
</header>
|
||||
{children}
|
||||
</div>;
|
||||
}
|
||||
export function QueryFeedback({ pending, error, retry }: { pending?: boolean; error?: Error | null; retry?: () => void }) {
|
||||
if (pending) return <Group role="status"><Loader size="sm" /><span>正在加载…</span></Group>;
|
||||
|
||||
@@ -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<string,string>={queued:'排队中',running:'进行中',complete:'已完成',failed:'失败'};
|
||||
const category:Record<string,string>={movie:'电影',series:'剧集',music:'音乐',audiobook:'有声书',other:'其他'};
|
||||
@@ -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<HTMLDialogElement>(null);
|
||||
@@ -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 }
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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;
|
||||
@@ -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','有声书']];
|
||||
|
||||
+2
-2
@@ -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;
|
||||
+4
-4
@@ -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}}}
|
||||
@@ -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&&<MediaScreenError fullPage error={cause} message={error} busy={loading} retryLabel="重新加载" onRetry={()=>setRetry(value=>value+1)}/>}{actionError&&<div className="media-error" role="alert">{actionError}</div>}{notice&&<p role="status">{notice}</p>}
|
||||
{loading&&<MediaLoading layout={assetId?'tracks':'list'} square label={assetId?'正在读取文件与章节…':'正在读取目录…'}/>}
|
||||
{cleanupNotice&&<p role="status">{cleanupNotice}</p>}
|
||||
{listing&&<>{listing.items.map(entry=><div className="media-row media-folder-row" key={entry.path}><button disabled={busy} onClick={()=>entry.kind==='folder'?open(entry.path):setAssetId(entry.assetId)}>{entry.kind==='folder'?<Folder size={21} strokeWidth={1.5} aria-hidden="true"/>:<File size={21} strokeWidth={1.5} aria-hidden="true"/>}<span><strong>{entry.name}</strong><small>{entry.kind==='folder'?`${entry.files} 个媒体文件 · ${entry.availableFiles} 个可用`:`${(entry.size/1024/1024).toFixed(1)} MB · ${entry.availableFiles?'文件可用':'文件缺失'}`}</small></span><ChevronRight size={17} aria-hidden="true"/></button></div>)}{!listing.total&&<p className="media-empty">暂无已扫描媒体文件,请先扫描媒体库。</p>}{listing.total>60&&<nav className="media-toolbar" aria-label="文件分页"><button disabled={busy||!offset} onClick={()=>setOffset(Math.max(0,offset-60))}>上一页</button><span>{Math.floor(offset/60)+1} / {Math.ceil(listing.total/60)}</span><button disabled={busy||offset+60>=listing.total} onClick={()=>setOffset(offset+60)}>下一页</button></nav>}<p className="media-folder-note">按最近扫描结果显示媒体文件;不包含空目录及其他文件。原始目录只读。</p></>}
|
||||
{file&&<><header className="media-folder-file-heading"><File size={27} strokeWidth={1.4} aria-hidden="true"/><div><h2>{file.path.split('/').at(-1)}</h2><small>{file.available?'文件可用':'文件已缺失'} · {file.items.length} 部关联作品</small></div></header>
|
||||
<div className="media-folder-file-layout"><div>{file.items.map(item=><FileWork key={file.assetId+'-'+item.id} api={api} item={item} busy={busy} selected={editions[item.id]??''} onSelect={id=>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&&<div className="media-folder-unlinked"><FolderOpen size={30} strokeWidth={1.4} aria-hidden="true"/><h3>暂未关联作品</h3><p>{file.available?'请管理员重新扫描媒体库,读取文件资料。':'恢复文件并重新扫描后可播放。'}</p></div>}</div><aside className="media-folder-file-resources"><h2>文件信息</h2><ResourceInfo key={file.assetId} api={api} assetId={file.assetId}/><p className="media-folder-note">仅显示此文件关联的版本片段。完整章节和其他文件中的版本,请打开作品详情。原始文件只读。</p></aside></div></>}
|
||||
{listing&&<div className="media-folder-listing"><div>{listing.items.map(entry=><div className="media-row media-folder-row" key={entry.path}><button disabled={busy} onClick={()=>entry.kind==='folder'?open(entry.path):setAssetId(entry.assetId)}>{entry.kind==='folder'?<Folder size={21} strokeWidth={1.5} aria-hidden="true"/>:<File size={21} strokeWidth={1.5} aria-hidden="true"/>}<span><strong>{entry.name}</strong><small>{entry.kind==='folder'?`${entry.files} 个媒体文件 · ${entry.availableFiles} 个可用`:`${(entry.size/1024/1024).toFixed(1)} MB · ${entry.availableFiles?'文件可用':'文件缺失'}`}</small></span><ChevronRight size={17} strokeWidth={1.5} aria-hidden="true"/></button></div>)}</div>{!listing.total&&<p className="media-empty">暂无已扫描媒体文件,请先扫描媒体库。</p>}{listing.total>60&&<nav className="media-toolbar" aria-label="文件分页"><button disabled={busy||!offset} onClick={()=>setOffset(Math.max(0,offset-60))}>上一页</button><span>{Math.floor(offset/60)+1} / {Math.ceil(listing.total/60)}</span><button disabled={busy||offset+60>=listing.total} onClick={()=>setOffset(offset+60)}>下一页</button></nav>}<p className="media-folder-note">按最近扫描结果显示媒体文件;不包含空目录及其他文件。原始目录只读。</p></div>}
|
||||
{file&&<div className="media-folder-file"><header className="media-folder-file-heading"><File size={27} strokeWidth={1.4} aria-hidden="true"/><div><h2>{file.path.split('/').at(-1)}</h2><small>{file.available?'文件可用':'文件已缺失'} · {file.items.length} 部关联作品</small></div></header>
|
||||
<div className="media-folder-file-layout"><div><div>{file.items.map(item=><FileWork key={file.assetId+'-'+item.id} api={api} item={item} busy={busy} selected={editions[item.id]??''} onSelect={id=>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)}/>)}</div>{!file.items.length&&<div className="media-folder-unlinked"><FolderOpen size={30} strokeWidth={1.4} aria-hidden="true"/><h3>暂未关联作品</h3><p>{file.available?'请管理员重新扫描媒体库,读取文件资料。':'恢复文件并重新扫描后可播放。'}</p></div>}</div><aside className="media-folder-file-resources"><h2>文件信息</h2><ResourceInfo key={file.assetId} api={api} assetId={file.assetId}/><p className="media-folder-note">仅显示此文件关联的版本片段。完整章节和其他文件中的版本,请打开作品详情。原始文件只读。</p></aside></div></div>}
|
||||
</section>;
|
||||
}
|
||||
File renamed without changes.
@@ -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}){
|
||||
+2
-2
@@ -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';
|
||||
|
||||
+1
-1
@@ -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}){
|
||||
@@ -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';
|
||||
|
||||
|
||||
+2
-2
@@ -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';
|
||||
+1
-1
@@ -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}){
|
||||
+2
-2
@@ -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('');
|
||||
+2
-2
@@ -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}
|
||||
@@ -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}){
|
||||
|
||||
+2
-2
@@ -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}){
|
||||
File renamed without changes.
File renamed without changes.
@@ -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<string,string>={queued:'排队中',running:'进行中',complete:'已完成',failed:'失败',cancelled:'已取消'};
|
||||
@@ -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<string,number>;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;
|
||||
@@ -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<string,string>={movie:'电影',series:'剧集',season:'季',episode:'单集',album:'专辑',artist:'歌手',track:'曲目',audiobook:'有声书'};
|
||||
@@ -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<SelectHTMLAttributes<HTMLSelectElement>,'ref'|'multiple'|'size'> & {
|
||||
|
||||
@@ -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}){
|
||||
|
||||
@@ -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?.();}
|
||||
@@ -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' }
|
||||
|
||||
+1
-1
@@ -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);
|
||||
+3
-3
@@ -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<Tool,string>={episodes:'选集',subtitles:'字幕',tracks:'音轨',volume:'音量',more:'更多播放选项',versions:'播放版本'};
|
||||
+2
-2
@@ -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';
|
||||
@@ -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){
|
||||
@@ -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<string, string>) => { 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 <MediaPageFrame title={channelLabels[channel]} actions={<Group gap="xs"><Button component={Link} to={`/media/${channel}/folders${selectedLibrary ? `/${encodeURIComponent(selectedLibrary)}` : ''}`} variant="subtle">文件夹</Button><Button component={Link} to="settings" variant="subtle">设置</Button></Group>}>
|
||||
<Group>{categories.map(item => <Button key={item.path} component={NavLink} to={`/media/${channel}/${item.path}`} variant={item.path === current.path ? 'filled' : 'subtle'}>{item.label}</Button>)}</Group>
|
||||
<Group><Select aria-label="来源库" data={[{ value: '', label: '全部媒体库' }, ...(libraries.data?.items.filter(item => item.kind === channel).map(item => ({ value: item.id, label: item.name })) ?? [])]} value={params.get('library') ?? ''} onChange={value => patch({ library: value ?? '', offset: '' })} />
|
||||
return <MediaPageFrame className="media-catalog-page" title={channelLabels[channel]} actions={<Group gap="xs"><Button component={Link} to={`/media/${channel}/folders${selectedLibrary ? `/${encodeURIComponent(selectedLibrary)}` : ''}`} variant="subtle">文件夹</Button><Button component={Link} to="settings" variant="subtle">设置</Button></Group>}>
|
||||
<nav className="media-tabs" aria-label={`${channelLabels[channel]}分类`}>{categories.map(item => <Button key={item.path} component={NavLink} to={`/media/${channel}/${item.path}`} variant={item.path === current.path ? 'filled' : 'subtle'} aria-current={item.path === current.path ? 'page' : undefined}>{item.label}</Button>)}</nav>
|
||||
<div className="media-toolbar media-browse-tools"><Select aria-label="来源库" data={[{ value: '', label: '全部媒体库' }, ...(libraries.data?.items.filter(item => item.kind === channel).map(item => ({ value: item.id, label: item.name })) ?? [])]} value={params.get('library') ?? ''} onChange={value => patch({ library: value ?? '', offset: '' })} />
|
||||
<Select aria-label="排序" data={[{ value: 'default', label: '默认顺序' }, { value: 'title-asc', label: '名称升序' }, { value: 'title-desc', label: '名称降序' }]} value={params.get('sort') ?? 'default'} onChange={value => patch({ sort: value ?? 'default', offset: '' })} />
|
||||
</Group>
|
||||
</div>
|
||||
<QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />
|
||||
{!playerActive && offset === 0 && (current.kind === 'video' || current.kind === 'album' || current.kind === 'audiobook') && <ContinuePlaying
|
||||
api={runtime.mediaApi}
|
||||
@@ -30,6 +30,6 @@ export function MediaCatalogPage() {
|
||||
libraryIds={channelLibraries.map(item => item.id)}
|
||||
onPlay={async (parts, index, title) => { await runtime.player.play(parts.map(part => ({ part, title: `${title} · ${part.title}`, video: channel === 'video' })), index); }}
|
||||
/>}
|
||||
{query.data && <><MediaItemGrid items={query.data.items} channel={channel} /><Pagination total={Math.max(1, Math.ceil(query.data.total / 60))} value={Math.floor(offset / 60) + 1} onChange={page => patch({ offset: String((page - 1) * 60) })} /></>}
|
||||
{query.data && <><MediaItemGrid items={query.data.items} channel={channel} /><div className="media-toolbar media-pagination"><Pagination total={Math.max(1, Math.ceil(query.data.total / 60))} value={Math.floor(offset / 60) + 1} onChange={page => patch({ offset: String((page - 1) * 60) })} /></div></>}
|
||||
</MediaPageFrame>;
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export function MediaDetailPage() {
|
||||
const playEdition = (current: Detail, parts: Part[], index = 0) => play(parts.map(part => ({ part, title: current.title + ' · ' + part.title, video: channel === 'video' })), index);
|
||||
const queueParts = async (ids: string[]) => { await runtime.mediaApi.request('queue', 'POST', { partIds: ids }); };
|
||||
|
||||
return <MediaPageFrame title={detail?.title ?? '作品详情'}>
|
||||
return <MediaPageFrame className="media-detail-shell" title={detail?.title ?? '作品详情'}>
|
||||
<QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />
|
||||
{detail && <DetailContent detail={detail} channel={channel} favorite={favorite.data?.favorite ?? false} favoriteBusy={toggleFavorite.isPending} onToggleFavorite={() => toggleFavorite.mutate(!favorite.data?.favorite)} onPlay={(parts, index) => void playEdition(detail, parts, index)} onQueue={queueParts} onPlayEntries={play} runtime={runtime} navigate={navigate} />}
|
||||
{!query.isPending && !query.error && !detail && <Alert>找不到这个作品。</Alert>}
|
||||
@@ -59,11 +59,11 @@ function DetailContent({ detail, channel, favorite, favoriteBusy, onToggleFavori
|
||||
const plot = String(detail.overrides.plot ?? detail.metadata.plot ?? '').trim();
|
||||
const credit = String(detail.overrides.artist ?? detail.metadata.artist ?? detail.overrides.albumArtist ?? detail.metadata.albumArtist ?? '').trim();
|
||||
const assets = edition?.parts.map(part => ({ id: part.assetId, title: part.title })) ?? [];
|
||||
return <Stack>
|
||||
<Group align="start"><MediaCover api={runtime.mediaApi} item={detail} square={['album', 'artist', 'track'].includes(detail.kind)} /><Stack flex={1}><MediaDetailHeading item={detail} edition={edition} {...(detail.kind === 'album' ? { trackCount: detail.children.length } : {})} {...(detail.kind === 'series' ? { seasonCount: detail.children.length } : {})} /><Group><Button leftSection={<Play size={16} />} disabled={!playable.length} onClick={() => onPlay(playable)}>播放</Button>{detail.kind !== 'album' && <Button variant={favorite ? 'filled' : 'light'} {...(favorite ? { color: 'pink' as const } : {})} loading={favoriteBusy} leftSection={<Heart size={16} fill={favorite ? 'currentColor' : 'none'} />} onClick={onToggleFavorite}>{favorite ? '取消收藏' : '收藏'}</Button>}{admin && <Button component={Link} variant="light" to={`/media/${channel}/items/${encodeURIComponent(detail.id)}/metadata`}>编辑资料</Button>}</Group></Stack></Group>
|
||||
{plot && <Card withBorder><Title order={3}>简介</Title><Text>{plot}</Text></Card>}
|
||||
return <Stack className="media-detail-page">
|
||||
<Group className="media-hero" align="start"><MediaCover api={runtime.mediaApi} item={detail} square={['album', 'artist', 'track'].includes(detail.kind)} /><Stack className="media-detail-hero-copy" flex={1}><MediaDetailHeading item={detail} edition={edition} {...(detail.kind === 'album' ? { trackCount: detail.children.length } : {})} {...(detail.kind === 'series' ? { seasonCount: detail.children.length } : {})} /><Group className="media-detail-actions"><Button className="media-primary" leftSection={<Play size={16} />} disabled={!playable.length} onClick={() => onPlay(playable)}>播放</Button>{detail.kind !== 'album' && <Button className="media-detail-favorite" variant={favorite ? 'filled' : 'light'} {...(favorite ? { color: 'pink' as const } : {})} loading={favoriteBusy} leftSection={<Heart size={16} fill={favorite ? 'currentColor' : 'none'} />} onClick={onToggleFavorite}>{favorite ? '取消收藏' : '收藏'}</Button>}{admin && <Button component={Link} variant="light" to={`/media/${channel}/items/${encodeURIComponent(detail.id)}/metadata`}>编辑资料</Button>}</Group></Stack></Group>
|
||||
{plot && <Card className="media-detail-description" withBorder><Title order={3}>简介</Title><Text>{plot}</Text></Card>}
|
||||
<ResourcePanel api={runtime.mediaApi} assets={assets} summary={edition ? `${new Set(edition.parts.map(part => part.assetId)).size} 个文件 · ${edition.label}` : '暂无资源'} sourceInfo={<MetadataSources item={detail} />} />
|
||||
{admin && <Card withBorder><Title order={3}>管理</Title><Group><Button component={Link} variant="light" to={`/media/${channel}/items/${encodeURIComponent(detail.id)}/match`}>匹配元数据</Button>{detail.kind === 'audiobook' && <Button component={Link} variant="light" to={`/media/${channel}/items/${encodeURIComponent(detail.id)}/chapters`}>章节列表</Button>}{edition && <Button component={Link} variant="light" to={`/media/${channel}/items/${encodeURIComponent(detail.id)}/editions/${encodeURIComponent(edition.id)}`}>版本管理</Button>}{['track', 'album', 'season', 'episode'].includes(detail.kind) && <Button component={Link} variant="light" to={`/media/${channel}/items/${encodeURIComponent(detail.id)}/structure`}>结构整理</Button>}</Group></Card>}
|
||||
{admin && <Card className="media-detail-management" withBorder><Title order={3}>管理</Title><Group><Button component={Link} variant="light" to={`/media/${channel}/items/${encodeURIComponent(detail.id)}/match`}>匹配元数据</Button>{detail.kind === 'audiobook' && <Button component={Link} variant="light" to={`/media/${channel}/items/${encodeURIComponent(detail.id)}/chapters`}>章节列表</Button>}{edition && <Button component={Link} variant="light" to={`/media/${channel}/items/${encodeURIComponent(detail.id)}/editions/${encodeURIComponent(edition.id)}`}>版本管理</Button>}{['track', 'album', 'season', 'episode'].includes(detail.kind) && <Button component={Link} variant="light" to={`/media/${channel}/items/${encodeURIComponent(detail.id)}/structure`}>结构整理</Button>}</Group></Card>}
|
||||
{detail.kind === 'album' && <AlbumPlayback api={runtime.mediaApi} id={detail.id} credit={credit} onPlay={onPlayEntries} onQueue={onQueue} onDetail={id => navigate(`/media/music/items/${encodeURIComponent(id)}`)} />}
|
||||
{detail.kind === 'series' && <>
|
||||
<SeriesPlayback id={detail.id} api={runtime.mediaApi} currentPartId={runtime.player.currentPartId} onPlay={onPlayEntries} onDetail={id => navigate(`/media/video/items/${encodeURIComponent(id)}`)} />
|
||||
|
||||
@@ -8,9 +8,11 @@ import { MediaPageFrame, QueryFeedback } from '../components/MediaPageFrame.tsx'
|
||||
|
||||
export function MediaFavoritesPage() {
|
||||
const runtime = useRuntime(), [params, setParams] = useSearchParams(), query = useQuery(favoritesQuery(runtime, params));
|
||||
return <MediaPageFrame title="我的收藏">
|
||||
<Select aria-label="收藏类型" value={params.get('scope') ?? 'all'} data={[{ value: 'all', label: '全部' }, { value: 'video', label: '影视' }, { value: 'music', label: '音乐' }, { value: 'audiobook', label: '有声书' }]} onChange={value => setParams({ scope: value ?? 'all' })} />
|
||||
return <MediaPageFrame className="media-secondary-page" title="我的收藏">
|
||||
<section className="media-favorites">
|
||||
<div className="media-favorite-tools"><p>按类型筛选收藏</p><Select aria-label="收藏类型" value={params.get('scope') ?? 'all'} data={[{ value: 'all', label: '全部' }, { value: 'video', label: '影视' }, { value: 'music', label: '音乐' }, { value: 'audiobook', label: '有声书' }]} onChange={value => setParams({ scope: value ?? 'all' })} /></div>
|
||||
<QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />
|
||||
{query.data && <><MediaItemGrid items={query.data.items} /><Pagination total={Math.max(1, Math.ceil(query.data.total / 60))} value={Math.floor(Number(params.get('offset') ?? 0) / 60) + 1} onChange={page => { const next = new URLSearchParams(params); next.set('offset', String((page - 1) * 60)); setParams(next); }} /></>}
|
||||
{query.data && <><MediaItemGrid items={query.data.items} /><div className="media-toolbar media-pagination"><Pagination total={Math.max(1, Math.ceil(query.data.total / 60))} value={Math.floor(Number(params.get('offset') ?? 0) / 60) + 1} onChange={page => { const next = new URLSearchParams(params); next.set('offset', String((page - 1) * 60)); setParams(next); }} /></div></>}
|
||||
</section>
|
||||
</MediaPageFrame>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Button, Group, Loader, Select, Stack } from '@mantine/core';
|
||||
import { Alert, Button, Loader, Select, Stack } from '@mantine/core';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
@@ -24,6 +24,6 @@ export function MediaFoldersPage() {
|
||||
const location: FolderLocation = { path: params.get('path') ?? '', offset: Number(params.get('offset')) || 0, assetId: params.get('asset') || null, editions: {}, chapters: {} };
|
||||
const updateLocation = (next: FolderLocation) => { const values = new URLSearchParams(params); for (const [key, value] of [['path', next.path], ['offset', String(next.offset)], ['asset', next.assetId ?? '']]) value ? values.set(key, value) : values.delete(key); setParams(values, { replace: true }); };
|
||||
const play = async (parts: Part[], index: number, title: string) => { runtime.player.play(parts.map(part => ({ part, title: `${title} · ${part.title}`, video: channel === 'video' })), index); navigate(`/media/${channel}/player`); };
|
||||
return <MediaPageFrame title="文件夹浏览"><Stack><Group><Button variant="subtle" onClick={() => navigate(`/media/${channel}`)}>返回频道</Button><Select aria-label="媒体库" value={libraryId} data={libraries.map(item => ({ value: item.id, label: item.name }))} onChange={value => { if (value) navigate(`/media/${channel}/folders/${encodeURIComponent(value)}`); }} /></Group><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{query.isPending && <Loader />}{!query.isPending && !query.error && requestedLibraryId && !libraryId && <Alert color="red">该媒体库不存在,或不属于当前频道。</Alert>}{!query.isPending && !query.error && !requestedLibraryId && !libraryId && <Alert>当前频道没有可浏览的媒体库。</Alert>}{libraryId && <MediaFolders api={runtime.mediaApi} libraryId={libraryId} admin={role === 'admin'} video={channel === 'video'} initialLocation={location} onLocationChange={updateLocation} onPlay={play} onQueue={async ids => { await runtime.mediaApi.request('queue', 'POST', { partIds: ids }); }} onDetail={id => navigate(`/media/${channel}/items/${encodeURIComponent(id)}`)} />}</Stack></MediaPageFrame>;
|
||||
return <MediaPageFrame className="media-secondary-page" title="文件夹浏览"><Stack className="media-folders-page"><div className="media-toolbar media-folder-toolbar"><Button variant="subtle" onClick={() => navigate(`/media/${channel}`)}>返回频道</Button><Select aria-label="媒体库" value={libraryId} data={libraries.map(item => ({ value: item.id, label: item.name }))} onChange={value => { if (value) navigate(`/media/${channel}/folders/${encodeURIComponent(value)}`); }} /></div><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{query.isPending && <Loader />}{!query.isPending && !query.error && requestedLibraryId && !libraryId && <Alert color="red">该媒体库不存在,或不属于当前频道。</Alert>}{!query.isPending && !query.error && !requestedLibraryId && !libraryId && <Alert>当前频道没有可浏览的媒体库。</Alert>}{libraryId && <MediaFolders api={runtime.mediaApi} libraryId={libraryId} admin={role === 'admin'} video={channel === 'video'} initialLocation={location} onLocationChange={updateLocation} onPlay={play} onQueue={async ids => { await runtime.mediaApi.request('queue', 'POST', { partIds: ids }); }} onDetail={id => navigate(`/media/${channel}/items/${encodeURIComponent(id)}`)} />}</Stack></MediaPageFrame>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Card, Group, Pagination, Text } from '@mantine/core';
|
||||
import { Button, Group, Pagination, Text } from '@mantine/core';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
@@ -16,8 +16,9 @@ export function MediaHistoryPage() {
|
||||
if (index < 0) throw new Error('资源已不可用,请在作品详情中选择其他版本。');
|
||||
await runtime.player.play(parts.map(part => ({ part, title: detail.title, video: channel === 'video' })), index);
|
||||
} });
|
||||
return <MediaPageFrame title="播放历史"><QueryFeedback pending={query.isPending} error={query.error ?? play.error} retry={() => { void query.refetch(); }} />
|
||||
{query.data?.items.map(row => <Card key={row.id || row.partId} withBorder><Group justify="space-between"><div><Text component={Link} to={`/media/${channel}/items/${row.itemId}`} fw={600}>{row.title}</Text><Text size="sm" c="dimmed">{row.partTitle}{row.completed ? ' · 已完成' : ''}</Text></div><Button disabled={!row.available} loading={play.isPending} onClick={() => play.mutate(row)}>播放</Button></Group></Card>)}
|
||||
{query.data && <Pagination total={Math.max(1, Math.ceil(query.data.total / 60))} value={Math.floor(Number(params.get('offset') ?? 0) / 60) + 1} onChange={page => setParams({ offset: String((page - 1) * 60) })} />}
|
||||
return <MediaPageFrame className="media-secondary-page" title="播放历史"><section className="media-history-page"><QueryFeedback pending={query.isPending} error={query.error ?? play.error} retry={() => { void query.refetch(); }} />
|
||||
<div className="media-history-list">{query.data?.items.map(row => <article className="media-row media-history-row" key={row.id || row.partId}><Group justify="space-between"><div><Text className="media-personal-title" component={Link} to={`/media/${channel}/items/${row.itemId}`} fw={600}>{row.title}</Text><Text size="sm" c="dimmed">{row.partTitle}{row.completed ? ' · 已完成' : ''}</Text></div><Button className="media-history-play" disabled={!row.available} loading={play.isPending} onClick={() => play.mutate(row)}>播放</Button></Group></article>)}</div>
|
||||
{query.data && <div className="media-toolbar media-pagination"><Pagination total={Math.max(1, Math.ceil(query.data.total / 60))} value={Math.floor(Number(params.get('offset') ?? 0) / 60) + 1} onChange={page => setParams({ offset: String((page - 1) * 60) })} /></div>}
|
||||
</section>
|
||||
</MediaPageFrame>;
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { createPortal } from 'react-dom';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { usePlaybackStore } from '../stores/playback.store.ts';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { PlaybackControls, type PlaybackPanel } from '../../../media/playback-controls.tsx';
|
||||
import { VideoControls } from '../../../media/video-controls.tsx';
|
||||
import { PlaybackControls, type PlaybackPanel } from '../components/playback-controls.tsx';
|
||||
import { VideoControls } from '../components/video-controls.tsx';
|
||||
import { restorePlaybackEntries } from '../services/restore-playback.ts';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Card, Group, Stack, Text } from '@mantine/core';
|
||||
import { Button, Group, Stack, Text } from '@mantine/core';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
@@ -14,5 +14,5 @@ export function MediaQueuePage() {
|
||||
const index = Math.max(0, parts.findIndex(part => part.id === row.partId));
|
||||
await runtime.player.play(parts.map(part => ({ part, title: detail.title, video: detail.kind === 'movie' || detail.kind === 'series' })), index);
|
||||
} });
|
||||
return <MediaPageFrame title="待播队列"><QueryFeedback pending={query.isPending} error={query.error ?? play.error} retry={() => { void query.refetch(); }} /><Stack>{query.data?.items.map(row => <Card key={row.id || row.partId} withBorder><Group justify="space-between"><div><Text component={Link} to={`/media/video/items/${row.itemId}`} fw={600}>{row.title}</Text><Text size="sm" c="dimmed">{row.partTitle}</Text></div><Button loading={play.isPending} disabled={!row.available} onClick={() => play.mutate(row)}>播放</Button></Group></Card>)}</Stack></MediaPageFrame>;
|
||||
return <MediaPageFrame className="media-secondary-page" title="待播队列"><section className="media-queue-page"><QueryFeedback pending={query.isPending} error={query.error ?? play.error} retry={() => { void query.refetch(); }} /><Stack className="media-current-playlist">{query.data?.items.map(row => <article className="media-row media-queue-row" key={row.id || row.partId}><Group justify="space-between"><div><Text className="media-personal-title" component={Link} to={`/media/video/items/${row.itemId}`} fw={600}>{row.title}</Text><Text size="sm" c="dimmed">{row.partTitle}</Text></div><Button className="media-queue-play" loading={play.isPending} disabled={!row.available} onClick={() => play.mutate(row)}>播放</Button></Group></article>)}</Stack></section></MediaPageFrame>;
|
||||
}
|
||||
@@ -9,11 +9,11 @@ import { MediaPageFrame, QueryFeedback } from '../components/MediaPageFrame.tsx'
|
||||
export function MediaSearchPage() {
|
||||
const runtime = useRuntime(), [params, setParams] = useSearchParams();
|
||||
const query = useQuery(searchQuery(runtime, params));
|
||||
return <MediaPageFrame title="搜索">
|
||||
<form onSubmit={event => { event.preventDefault(); const values = new FormData(event.currentTarget); setParams({ q: String(values.get('q') ?? ''), scope: String(values.get('scope') ?? 'all') }); }}>
|
||||
<Group align="end"><TextInput name="q" label="搜索内容" defaultValue={params.get('q') ?? ''} key={params.get('q')} /><Select name="scope" label="范围" defaultValue={params.get('scope') ?? 'all'} data={[{ value: 'all', label: '全部' }, { value: 'video', label: '影视' }, { value: 'music', label: '音乐' }, { value: 'audiobook', label: '有声书' }]} /><Button type="submit">搜索</Button></Group>
|
||||
return <MediaPageFrame className="media-secondary-page" title="搜索">
|
||||
<form className="media-search" role="search" onSubmit={event => { event.preventDefault(); const values = new FormData(event.currentTarget); setParams({ q: String(values.get('q') ?? ''), scope: String(values.get('scope') ?? 'all') }); }}>
|
||||
<Group className="media-toolbar" align="end"><TextInput name="q" label="搜索内容" defaultValue={params.get('q') ?? ''} key={params.get('q')} /><Select name="scope" label="范围" defaultValue={params.get('scope') ?? 'all'} data={[{ value: 'all', label: '全部' }, { value: 'video', label: '影视' }, { value: 'music', label: '音乐' }, { value: 'audiobook', label: '有声书' }]} /><Button type="submit" className="media-primary">搜索</Button></Group>
|
||||
</form>
|
||||
{!!params.get('q') && <QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />}
|
||||
{query.data && <><MediaItemGrid items={query.data.items} /><Pagination total={Math.max(1, Math.ceil(query.data.total / 60))} value={Math.floor(Number(params.get('offset') ?? 0) / 60) + 1} onChange={page => { const next = new URLSearchParams(params); next.set('offset', String((page - 1) * 60)); setParams(next); }} /></>}
|
||||
{query.data && <><MediaItemGrid items={query.data.items} /><div className="media-toolbar media-pagination"><Pagination total={Math.max(1, Math.ceil(query.data.total / 60))} value={Math.floor(Number(params.get('offset') ?? 0) / 60) + 1} onChange={page => { const next = new URLSearchParams(params); next.set('offset', String((page - 1) * 60)); setParams(next); }} /></div></>}
|
||||
</MediaPageFrame>;
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export function MediaSettingsPage() {
|
||||
? `/media/${channel}/settings`
|
||||
: `/media/${channel}/settings/${next}`;
|
||||
|
||||
return <MediaPageFrame title={titles[panel]}>
|
||||
return <MediaPageFrame className="media-utility-workspace" title={titles[panel]}>
|
||||
<MediaSettings
|
||||
scope={runtime.mediaApi.preferenceScope()}
|
||||
preferences={preferences}
|
||||
|
||||
@@ -6,9 +6,9 @@ import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
import { aiJobsQuery, jobsQuery, librariesQuery } from '../queries/media.queries.ts';
|
||||
import { MediaPageFrame, QueryFeedback } from '../components/MediaPageFrame.tsx';
|
||||
import { ScanJobs } from '../../../media/scan-jobs.tsx';
|
||||
import { AiScanJobs } from '../../../media/ai-scan-jobs.tsx';
|
||||
import { ScrapeJobs } from '../../../media/scrape-jobs.tsx';
|
||||
import { ScanJobs } from '../components/scan-jobs.tsx';
|
||||
import { AiScanJobs } from '../components/ai-scan-jobs.tsx';
|
||||
import { ScrapeJobs } from '../components/scrape-jobs.tsx';
|
||||
import type { MediaChannel } from '../api/media-api.ts';
|
||||
|
||||
export function MediaTasksPage() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MediaPlayer } from '../../../media/player.ts';
|
||||
import type { MediaPlayer } from './player.ts';
|
||||
import { usePlaybackStore } from '../stores/playback.store.ts';
|
||||
import type { MediaApi } from '../api/media-api.ts';
|
||||
|
||||
|
||||
File renamed without changes.
@@ -1,4 +1,4 @@
|
||||
import type { MediaApi, Playback } from './api.ts';
|
||||
import type { MediaApi, Playback } from '../api/media-api.ts';
|
||||
|
||||
const EARLY_RENEWAL = 2 * 60_000;
|
||||
const RETRY_DELAY = 30_000;
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,13 +1,13 @@
|
||||
import { render } from '../shared/ui/render-root.ts';
|
||||
import {MiniNowPlaying} from './mini-now-playing.tsx';
|
||||
import { render } from '../../../shared/ui/render-root.ts';
|
||||
import {MiniNowPlaying} from '../components/mini-now-playing.tsx';
|
||||
import {readPlaybackPreferences} from './playback-preferences.ts';
|
||||
import { createElement } from 'react';
|
||||
import {Play,Pause,X} from 'lucide-react';
|
||||
import { MediaApi } from './api.ts';
|
||||
import type { Part, Playback, Progress } from './api.ts';
|
||||
import { MediaApi } from '../api/media-api.ts';
|
||||
import type { Part, Playback, Progress } from '../api/media-api.ts';
|
||||
import { PlaybackLease } from './playback-lease.ts';
|
||||
import { SubtitleControls } from './subtitles.ts';
|
||||
import { AudioTrackControls } from './audio-tracks.ts';
|
||||
import { SubtitleControls } from '../components/subtitles.ts';
|
||||
import { AudioTrackControls } from '../components/audio-tracks.ts';
|
||||
import { NativeAudio } from './native-audio.ts';
|
||||
import { playbackSupport, playbackFailure, type PlaybackStream } from './playback-support.ts';
|
||||
|
||||
@@ -22,7 +22,7 @@ export class MediaPlayer extends EventTarget {
|
||||
private webVideo:{destroy?:()=>void}|null=null;
|
||||
private webVideoLoading:Promise<void>|null=null;
|
||||
private ensureWebVideo(){
|
||||
return this.webVideoLoading??=import('./web-video.ts').then(({enhanceVideo})=>{this.webVideo=enhanceVideo(this.video,()=>this.toggle());}).catch(()=>{this.webVideoLoading=null;this.video.controls=true;});
|
||||
return this.webVideoLoading??=import('../components/web-video.ts').then(({enhanceVideo})=>{this.webVideo=enhanceVideo(this.video,()=>this.toggle());}).catch(()=>{this.webVideoLoading=null;this.video.controls=true;});
|
||||
}
|
||||
private readonly mini=document.createElement('div');
|
||||
private readonly miniTitle=document.createElement('button');
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -31,7 +31,7 @@ function BookCard({ book, showAuthor, showProgress }: { book: Book | ContinueRea
|
||||
const runtime = useRuntime();
|
||||
const src = useCover(runtime.api, book.coverUrl);
|
||||
const progress = 'percentage' in book ? book.percentage : undefined;
|
||||
return <Card withBorder padding="sm" component={Link} to={`/book/${encodeURIComponent(book.id)}`} style={{ textDecoration: 'none' }}>
|
||||
return <Card className="book-card" withBorder padding="sm" component={Link} to={`/book/${encodeURIComponent(book.id)}`} style={{ textDecoration: 'none' }}>
|
||||
<Stack gap="xs"><div style={{ position: 'relative', aspectRatio: '2 / 3', overflow: 'hidden', borderRadius: 6, background: 'var(--mantine-color-gray-1)' }}>{src ? <img src={src} alt="" loading="lazy" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : <Center h="100%"><Text c="dimmed" ta="center" lineClamp={3}>{book.title || '无封面'}</Text></Center>}{showProgress && progress !== undefined && progress > 0 && <div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 5, background: 'var(--mantine-color-gray-3)' }}><div style={{ width: `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%`, height: '100%', background: 'var(--mantine-color-blue-6)' }} /></div>}</div><Text fw={600} lineClamp={2}>{book.title || '未命名'}</Text>{showAuthor && <Text size="sm" c="dimmed" lineClamp={1}>{book.author || '未知作者'}</Text>}</Stack>
|
||||
</Card>;
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function ShelfPage() {
|
||||
const remove = async (book: Book) => { await runtime.api.browseBatchShelf({ bookIds: [book.id] }, 'remove'); await queryClient.invalidateQueries({ queryKey: ['shelf'] }); await queryClient.invalidateQueries({ queryKey: ['continue-reading'] }); };
|
||||
const removeSelected = async () => { if (!selected.length) return; await runtime.api.browseBatchShelf({ bookIds: selected }, 'remove'); setSelected([]); await queryClient.invalidateQueries({ queryKey: ['shelf'] }); await queryClient.invalidateQueries({ queryKey: ['continue-reading'] }); };
|
||||
const cards = useMemo(() => query.data?.items ?? [], [query.data]);
|
||||
return <Stack p="md" maw={1440} mx="auto"><Group justify="space-between" align="center"><Title order={1}>书架</Title><Group><Button component={Link} to="/library" variant="light">浏览书库</Button><Button component={Link} to="/media/video" variant="subtle">影音</Button></Group></Group>{selected.length > 0 && <Group><Badge>已选 {selected.length} 本</Badge><Button size="compact-sm" color="red" onClick={() => void removeSelected()}>批量移出书架</Button><Button size="compact-sm" variant="subtle" onClick={() => setSelected([])}>清除选择</Button></Group>}<Group align="end" wrap="wrap"><form onSubmit={onSearch} style={{ flex: '1 1 20rem' }}><TextInput label="搜索书名、作者" value={search} onChange={event => setSearch(event.currentTarget.value)} placeholder="输入关键词后回车" /></form><SegmentedControl aria-label="书架排序" value={sort} onChange={onSort} data={SHELF_SORTS} /></Group>{continueQuery.data && continueQuery.data.length > 0 && <Stack gap="xs"><Group justify="space-between"><Title order={3}>继续阅读</Title><Badge variant="light">{continueQuery.data.length} 本</Badge></Group><SimpleGrid cols={{ base: 2, xs: 3, sm: 4, md: 8 }}>{continueQuery.data.map(item => <BookCard key={item.id} book={item} showAuthor={settings.shelfShowAuthor} showProgress={settings.shelfShowProgress} />)}</SimpleGrid></Stack>}{query.isPending && <Center py="xl"><Loader /></Center>}{query.error && <Alert color="red" title="书架加载失败">{query.error.message}</Alert>}{query.data && cards.length === 0 && <Alert>书架为空。可以从书库浏览并加入书架。</Alert>}{cards.length > 0 && <SimpleGrid cols={{ base: 2, xs: 3, sm: 4, md: 6, lg: 8 }}>{cards.map(book => <Stack key={book.id} gap={4}><Group justify="space-between"><Checkbox aria-label={`选择${book.title}`} checked={selected.includes(book.id)} onChange={() => setSelected(s => s.includes(book.id) ? s.filter(x => x !== book.id) : [...s, book.id])} /><Button size="compact-xs" variant="subtle" color="red" onClick={() => void remove(book)}>移出书架</Button></Group><BookCard book={book} showAuthor={settings.shelfShowAuthor} showProgress={settings.shelfShowProgress} /></Stack>)}</SimpleGrid>}{query.data && pages > 1 && <Pagination total={pages} value={page} onChange={value => setParams({ ...(queryText ? { q: queryText } : {}), sort, page: String(value) })} />}</Stack>;
|
||||
return <Stack className="shelf-screen" p="md" maw={1440} mx="auto"><Group className="shelf-head" justify="space-between" align="center"><Title className="shelf-title" order={1}>书架</Title><Group className="shelf-head-actions"><Button component={Link} to="/library" variant="light">浏览书库</Button><Button component={Link} to="/media/video" variant="subtle">影音</Button></Group></Group>{selected.length > 0 && <Group><Badge>已选 {selected.length} 本</Badge><Button size="compact-sm" color="red" onClick={() => void removeSelected()}>批量移出书架</Button><Button size="compact-sm" variant="subtle" onClick={() => setSelected([])}>清除选择</Button></Group>}<Group className="shelf-search" align="end" wrap="wrap"><form onSubmit={onSearch} style={{ flex: '1 1 20rem' }}><TextInput label="搜索书名、作者" value={search} onChange={event => setSearch(event.currentTarget.value)} placeholder="输入关键词后回车" /></form><SegmentedControl aria-label="书架排序" value={sort} onChange={onSort} data={SHELF_SORTS} /></Group>{continueQuery.data && continueQuery.data.length > 0 && <Stack gap="xs"><Group justify="space-between"><Title order={3}>继续阅读</Title><Badge variant="light">{continueQuery.data.length} 本</Badge></Group><SimpleGrid className="book-grid continue-grid" cols={{ base: 2, xs: 3, sm: 4, md: 8 }}>{continueQuery.data.map(item => <BookCard key={item.id} book={item} showAuthor={settings.shelfShowAuthor} showProgress={settings.shelfShowProgress} />)}</SimpleGrid></Stack>}{query.isPending && <Center py="xl"><Loader /></Center>}{query.error && <Alert color="red" title="书架加载失败">{query.error.message}</Alert>}{query.data && cards.length === 0 && <Alert>书架为空。可以从书库浏览并加入书架。</Alert>}{cards.length > 0 && <SimpleGrid className="book-grid" cols={{ base: 2, xs: 3, sm: 4, md: 6, lg: 8 }}>{cards.map(book => <Stack key={book.id} gap={4}><Group justify="space-between"><Checkbox aria-label={`选择${book.title}`} checked={selected.includes(book.id)} onChange={() => setSelected(s => s.includes(book.id) ? s.filter(x => x !== book.id) : [...s, book.id])} /><Button size="compact-xs" variant="subtle" color="red" onClick={() => void remove(book)}>移出书架</Button></Group><BookCard book={book} showAuthor={settings.shelfShowAuthor} showProgress={settings.shelfShowProgress} /></Stack>)}</SimpleGrid>}{query.data && pages > 1 && <Pagination total={pages} value={page} onChange={value => setParams({ ...(queryText ? { q: queryText } : {}), sort, page: String(value) })} />}</Stack>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
+3
-3
@@ -2,9 +2,9 @@ import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import './styles/reader.css';
|
||||
import './app/app.css';
|
||||
import './media/media.css';
|
||||
import './media/presentation.css';
|
||||
import './media/v2.css';
|
||||
import './features/media/styles/media.css';
|
||||
import './features/media/styles/presentation.css';
|
||||
import './features/media/styles/v2.css';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { AppProviders } from './app/providers/AppProviders.tsx';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/action-error.ts';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/album-playback.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/api/media-api.ts';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/artist-info.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/artist-tracks.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/audiobook-chapters.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/child-list.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/cover.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/detail-heading.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/edition-assignment.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/edition-details.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/edition-order.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/edition-organizer.tsx';
|
||||
@@ -1,17 +0,0 @@
|
||||
.media-screen .media-favorites {width:100%;max-width:850px;margin:0 auto}
|
||||
.media-favorites .media-favorite-tools {display:flex;align-items:center;justify-content:space-between;gap:8px;margin:14px 0 6px}
|
||||
.media-favorites .media-favorite-tools p {margin:0;font-size:12px;color:var(--muted)}
|
||||
.media-screen .media-favorite-tools button {display:inline-flex;align-items:center;gap:6px;padding:0;border:0;background:transparent;color:var(--media-accent);font-size:12px;min-height:44px}
|
||||
.media-screen .media-favorites .media-favorite-list {max-width:none}
|
||||
.media-screen .media-favorites .media-favorite-row {display:flex;align-items:center;gap:12px;width:100%;min-height:60px;padding:0;border:0;border-bottom:1px solid var(--border);border-radius:0;background:transparent;text-align:left;color:var(--text)}
|
||||
.media-screen .media-favorites .media-favorite-row:hover {background:var(--surface)}
|
||||
.media-favorites .media-favorite-row>.media-cover {width:42px;min-width:42px;flex:none}
|
||||
.media-screen .media-favorites .media-favorite-row>.media-portrait {width:44px;min-width:44px;height:44px;border-radius:50%}
|
||||
.media-favorites .media-favorite-copy {flex:1;min-width:0;padding:9px 0}
|
||||
.media-favorites .media-favorite-copy strong {display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:13px;line-height:1.5;font-weight:550;margin:0}
|
||||
.media-favorites .media-favorite-copy small {display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:11px;line-height:1.5;color:var(--muted);margin-top:3px}
|
||||
.media-favorites .media-favorite-row>svg {flex:none;color:var(--muted)}
|
||||
.media-screen .media-favorite-filter {padding:0 16px 16px}
|
||||
.media-screen .media-favorite-filter button {display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;min-height:58px;padding:12px 0;border:0;border-bottom:1px solid var(--border);border-radius:0;background:transparent;font-size:13px;color:var(--text);text-align:left}
|
||||
.media-screen .media-favorite-filter button:last-child {border-bottom:0}
|
||||
.media-screen .media-favorite-filter button[aria-pressed=true] {color:var(--media-accent)}
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/favorites.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/folders.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/item-label.ts';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/library-create.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/library-editor.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/library-list.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/loading.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/metadata-editor.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/metadata-matcher.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/metadata-sources.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/missing-edition.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/narrators.tsx';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../features/media/components/parent-editor.tsx';
|
||||
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user