mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-14 06:50:54 +08:00
176 files changed
+6882
-2726
No files matched your search
+16
@@ -632,6 +632,22 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
|
||||
---
|
||||
|
||||
### `PUT /books/:id/reading-status`
|
||||
|
||||
把书架上的书标记为想读、在读或已读,或清除标记(`null`),返回 204。不在当前账号书架上的书返回 404。
|
||||
|
||||
```json
|
||||
{ "status": "want" }
|
||||
```
|
||||
|
||||
`GET /books` 在书架范围内(默认 `scope`)接受 `status=reading|want|finished` 筛选,并给每本书带上 `readingState`(`"reading" | "want" | "finished" | null`):
|
||||
|
||||
- 手动标记的在读、已读优先;
|
||||
- 否则按进度推断:进度 ≥ 99% 为已读,已开始为在读;
|
||||
- 想读只来自标记,开始阅读后让位于在读。
|
||||
|
||||
`scope=library` 和单本 `GET /books/:id` 不带这个字段。
|
||||
|
||||
## 朗读(HTTP TTS)
|
||||
|
||||
这两条路由只在服务端配置了 HTTP 朗读合成接口 时才有意义,并且分工明确:
|
||||
|
||||
@@ -376,7 +376,7 @@ URL 从头到尾不变。手机上这一点几乎看不见——直到读者按
|
||||
| `#/media/:channel` | 影音频道目录 |
|
||||
| `#/media/:channel/items/:itemId` | 影音详情及管理子页 |
|
||||
| `#/media/:channel/player?item=&part=` | 播放器 |
|
||||
| `#/media/:channel/settings/...` | 影音设置及媒体库管理 |
|
||||
| `#/media/:channel/settings/libraries`、`.../tasks` | 媒体库管理、扫描与刮削(旧的 `#/media/:channel/settings[/:panel]` 重定向到「我的」) |
|
||||
|
||||
`/sources/search`、旧的 `/sources/:sourceId/:pageId` 扩展页形状和
|
||||
`/media`、`/media/music`、`/media/audiobook` 默认入口仍由路由表兼容;
|
||||
@@ -403,10 +403,12 @@ ReaderScreen 持有的舞台节点放到 chrome 树中的正确位置。React
|
||||
导航到带 `item` 和 `part` 查询参数的播放器路由。Provider 卸载时停止监听并移除
|
||||
播放器节点,应用隐藏或离开时统一调用 runtime flush,重新联网时续期会话。
|
||||
|
||||
影音主题只在 `MediaLayout` 挂载期间生效。它创建 `MediaThemeController`,作用域来自
|
||||
`runtime.mediaApi.preferenceScope()`,并只向 `body` 写入带 `--media-theme-` 前缀的
|
||||
变量;系统主题、同页主题事件和存储变化会更新该作用域。离开影音路由时清理变量和监听器,
|
||||
因此不会污染书架、设置或阅读器主题。
|
||||
应用主题由 `AuthenticatedShell` 创建的 `ThemeController`(`web/src/shared/theme/theme.ts`)
|
||||
统一应用,作用域来自 `runtime.mediaApi.preferenceScope()`。控制器只在 `<html>` 上写
|
||||
`data-theme`(warm、blue、green)和 `data-mode`(light、dark),颜色由
|
||||
`web/src/styles/theme-tokens.css` 的 `--color-*` 语义令牌给出;系统深浅色、同页保存和
|
||||
其他标签页的存储变化都会重新应用。阅读器选择黑白灰时只在自己的容器上写
|
||||
`data-theme="gray"`,视频和音乐播放页在自己的根节点上使用当前主题的深色取值。
|
||||
|
||||
### 只有一份渲染层
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ reader 代理语音请求,不内置合成模型。上游接口需兼容项目
|
||||
|
||||
## 影音媒体库
|
||||
|
||||
本地电影、剧集、音乐和有声书目录单独挂载到容器(建议只读),在「影音设置 → 媒体库管理」中填写容器路径。直接运行服务端时填写本机绝对路径;媒体根目录不要使用 DATA_DIR。普通用户只可访问已授权的库,扫描、刮削与资料编辑由管理员操作。
|
||||
本地电影、剧集、音乐和有声书目录单独挂载到容器(建议只读),在「我的 → 媒体库」中填写容器路径。直接运行服务端时填写本机绝对路径;媒体根目录不要使用 DATA_DIR。普通用户只可访问已授权的库,扫描、刮削与资料编辑由管理员操作。
|
||||
|
||||
官方镜像包含 ffprobe 和 ffmpeg。本机启动时将二者加入 PATH,或设置 MEDIA_FFPROBE_PATH / MEDIA_FFMPEG_PATH 为可执行文件路径。ffprobe 用于时长、编码、标签和章节,ffmpeg 用于内嵌资源提取,均不提供转码。
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
- 恢复本地会话、服务地址、离线作用域和同步状态;
|
||||
- 暴露 flush、登录状态、阅读器注册和设置更新等宿主能力。
|
||||
|
||||
`AppProviders` 提供 `RuntimeContext`、`QueryClientProvider`、`MantineProvider` 和全局通知。Provider 生命周期负责启动/停止播放服务、连接会话与同步监听、写入根主题属性,并在页面隐藏或离开时刷新进度、离线数据和播放器状态,重新联网时续期会话。
|
||||
`AppProviders` 提供 `RuntimeContext`、`QueryClientProvider`、`MantineProvider` 和全局通知。Provider 生命周期负责启动/停止播放服务、连接会话与同步监听、让 Mantine 跟随主题明暗,并在页面隐藏或离开时刷新进度、离线数据和播放器状态,重新联网时续期会话。
|
||||
|
||||
## 目录边界
|
||||
|
||||
@@ -53,7 +53,7 @@ web/src/
|
||||
|
||||
## 路由与兼容
|
||||
|
||||
路由定义集中在 `web/src/app/router/routes.tsx`,根级 `AppShell` 先经过 `AuthBoundary`,认证后渲染页面 Outlet。影音由 `MediaLayout` 提供频道上下文、主题生命周期和公共频道入口,具体页面继续按路由拆分。
|
||||
路由定义集中在 `web/src/app/router/routes.tsx`,根级 `AppShell` 先经过 `AuthBoundary`,认证后渲染页面 Outlet。影音由 `MediaLayout` 提供频道上下文和公共频道入口,具体页面继续按路由拆分;主题由 `AuthenticatedShell` 统一应用。
|
||||
|
||||
保留的兼容入口是有意的:
|
||||
|
||||
@@ -70,11 +70,11 @@ web/src/
|
||||
|
||||
`ReaderScreen` 内部仍负责章节加载、Shadow DOM、分页量测、手势、朗读和阅读工具;React 只托管它的创建、更新和销毁,不介入书籍正文的布局量测。ReaderScreen 的 chrome 使用 React 组件,正文舞台保持命令式 DOM,以确保 `getBoundingClientRect()` 和章节分页的节点生命周期稳定。
|
||||
|
||||
## 播放器和媒体主题生命周期
|
||||
## 播放器和主题生命周期
|
||||
|
||||
播放器是应用级资源。`AppProviders` 把 `runtime.player.element` 挂到 `document.body`,调用 `runtime.playback.start()` 建立 `PlaybackService` 对 `MediaPlayer` 的监听;路由切换只卸载控制界面,不销毁播放会话。`AuthenticatedShell` 根据当前 URL 控制播放器可见性,并处理播放器发出的 `open-controls` 事件,将用户带到带有 `item` 和 `part` 查询参数的播放器路由。Provider 卸载时停止监听并移除播放器节点。
|
||||
|
||||
影音主题由 `MediaLayout` 创建 `MediaThemeController`,作用域来自 `runtime.mediaApi.preferenceScope()`。控制器只向 `body` 写入带 `--media-theme-` 前缀的变量,响应系统主题、同页主题事件和存储变化;离开影音路由时清理变量和监听器,因此不会污染书架或阅读器主题。
|
||||
应用主题由 `AuthenticatedShell` 创建 `ThemeController`(`web/src/shared/theme/theme.ts`),作用域来自 `runtime.mediaApi.preferenceScope()`。控制器只在 `<html>` 上写 `data-theme` 与 `data-mode`,组件只读 `web/src/styles/theme-tokens.css` 的 `--color-*` 令牌;响应系统深浅色、同页保存和存储变化,退出登录时清理属性和监听器。阅读器的黑白灰主题只写在阅读器容器上。
|
||||
|
||||
## 后续改动约定
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ Windows 推荐使用上面的 Compose 配置,避免不同 shell 的路径和
|
||||
- /path/to/your/audiobooks:/media/audiobook:ro
|
||||
```
|
||||
|
||||
执行 `docker compose up -d` 后,打开影视页(`#/media/video`),从右上角「更多操作」进入「影音设置 → 媒体库管理」,选择影视、音乐或有声书,填写容器内目录(例如 `/media/video`)及访问范围。创建后自动扫描;扫描任务和失败原因可在「扫描与刮削」中查看。
|
||||
执行 `docker compose up -d` 后,打开「我的 → 媒体库」(或影视页右上角「更多操作 → 媒体库管理」),选择影视、音乐或有声书,填写容器内目录(例如 `/media/video`)及访问范围。创建后自动扫描;扫描任务和失败原因可在「扫描与刮削」中查看。
|
||||
|
||||
Windows 直接运行服务时填本机绝对路径,例如 `D:\Media\Music`;Docker Desktop 则挂载该目录并填写容器内路径。
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 新建与管理媒体库
|
||||
|
||||
管理员从影视页的「更多操作」进入「影音设置 → 媒体库管理 → 新建媒体库」,选择内容类型、接入方式和访问范围。普通用户只看到已获授权的媒体库。
|
||||
管理员从「我的 → 媒体库」(或影视页「更多操作 → 媒体库管理」)进入,点「新建媒体库」,选择内容类型、接入方式和访问范围。普通用户只看到已获授权的媒体库。
|
||||
|
||||
- **服务器目录**:填写 Reader 服务端能读取的路径。Docker 部署填写容器内路径,例如将宿主目录只读挂载到 /media/music 后,填 /media/music。
|
||||
- **OpenList**:填写服务地址、远端绝对目录,以及可选访问令牌和目录密码。创建前验证连接。编辑已有库可更新或清除凭据;不改服务地址和根目录。见[OpenList 接入说明](media-openlist.md)。
|
||||
@@ -13,6 +13,13 @@
|
||||
|
||||
扫描只读源文件,不移动、改名或写回标签。目录或远端读取失败会保留已发布目录和资料。缺失文件可在详情查看状态;恢复文件后重扫。不同播放版本需要明确选择,不能按文件名推断为同一版。
|
||||
|
||||
媒体库管理页的每个媒体库卡片显示最近一次扫描:完成时间、进行中的进度(已处理百分比)和“取消”,或失败原因与“重试”“查看详情”。右侧“扫描与元数据”是全部媒体库共用的四个开关,也出现在系统设置的“扫描设置”中:
|
||||
|
||||
- 自动扫描新文件(默认开):每小时检查一次所有媒体库,未变化的文件沿用上次的技术信息。
|
||||
- 刮削封面与简介(默认关):扫描完成后,用已配置的 TMDB 或 MusicBrainz 为还没有在线资料、也从未刮削过的作品发起一次批量刮削;未匹配的作品不会每次重试。
|
||||
- 读取内嵌字幕与歌词(默认开):关闭后不再提供视频内嵌字幕流和音频标签里的歌词。
|
||||
- 扫描隐藏文件夹(默认关):名称以“.”开头的文件夹和文件(例如 macOS 的 `._影片.mp4`)不参与扫描;原来扫描到的这类文件会在下一次扫描后标记为缺失,打开开关重扫即可恢复。
|
||||
|
||||
## 剧集与多季命名
|
||||
|
||||
影视库综合目录规则、NFO、季目录和文件名识别单集。支持 S01E01、1x02、“第一季第二集”;Season 01、S01、“第一季”和 Specials(特别篇,第 0 季)目录下还支持 EP01、E01、“第01集”和 01.mp4。缺少可靠季集信息或遇到多集合集时保留为独立文件,重新识别预览中标记待确认,不自动拆分。
|
||||
@@ -68,7 +75,7 @@
|
||||
|
||||
本地库优先读取文件标签、NFO 和同目录封面;ffprobe 用于编码、时长及章节探测,ffmpeg 用于部分内嵌资源提取,两者不等于转码功能。OpenList 支持外置 NFO、封面、歌词和字幕,暂不读取远端内嵌标签、封面及章节。
|
||||
|
||||
管理员从频道导航切换到「阅读」进入书架,打开右上角「系统设置 → 服务配置」,在 TMDB 和 MusicBrainz 分类中配置令牌、API Key 或应用标识,保存后生效。管理员在作品菜单中匹配资料,审阅候选后确认;也可从「影音设置 → 扫描与刮削」发起批量任务并重试未完成项。来源网络、凭据或封面下载失败会单独提示。
|
||||
管理员从频道导航切换到「阅读」进入书架,打开右上角「系统设置 → 服务配置」,在 TMDB 和 MusicBrainz 分类中配置令牌、API Key 或应用标识,保存后生效。管理员在作品菜单中匹配资料,审阅候选后确认;也可从「我的 → 扫描与刮削」发起批量任务并重试未完成项。来源网络、凭据或封面下载失败会单独提示。
|
||||
|
||||
详情的资源信息菜单可查看文件与技术信息、管理版本。手工资料、作品归属、版本拆分或合并会在重扫时保留,不修改原文件。移动版本不转移收藏,也不复制原作品的人工资料;更改剧集、季和单集关系后需重新确认受影响的在线匹配。
|
||||
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Reader V3 · 统一设计系统与完整原型</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #faf9f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
#__bundler_loading { position: fixed; bottom: 20px; right: 20px; font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; color: #666; background: #fff; padding: 8px 14px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.12); z-index: 10000; }
|
||||
#__bundler_thumbnail { position: fixed; inset: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #faf9f5; z-index: 9999; }
|
||||
#__bundler_thumbnail svg { width: 100%; height: 100%; object-fit: contain; }
|
||||
#__bundler_placeholder { color: #999; font-size: 14px; }
|
||||
</style>
|
||||
<noscript>
|
||||
<style>#__bundler_loading { display: none; }</style>
|
||||
<div style="position:fixed;bottom:12px;left:12px;font:13px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;color:#999;background:rgba(255,255,255,0.9);padding:6px 12px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:10000;">
|
||||
This page requires JavaScript to display.
|
||||
</div>
|
||||
</noscript>
|
||||
</head>
|
||||
<body>
|
||||
<div id="__bundler_placeholder">Loading Reader V3 · 统一设计系统与完整原型...</div>
|
||||
<div id="__bundler_loading">Unpacking...</div>
|
||||
|
||||
<script>
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const loading = document.getElementById('__bundler_loading');
|
||||
function setStatus(msg) { if (loading) loading.textContent = msg; }
|
||||
|
||||
const FONT_MIME = /^(font[/]|application[/](x-)?font-|application[/]vnd\.ms-fontobject)/i;
|
||||
const MIME_TOKEN = /^[\w.+-]+[/][\w.+-]+$/;
|
||||
function toBase64(bytes) {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
// Error sink persists across replaceWith since it's on window, not the DOM.
|
||||
window.addEventListener('error', function(e) {
|
||||
// Failed resource loads (CSP-blocked links, scripts, images) fire plain
|
||||
// events at the element — warn only; real JS errors carry message/error.
|
||||
if (!e.message && !e.error && e.target && e.target !== window) {
|
||||
console.warn('[bundle] resource failed to load:',
|
||||
e.target.tagName, String(e.target.src || e.target.href || ''));
|
||||
return;
|
||||
}
|
||||
var p = document.body || document.documentElement;
|
||||
var d = document.getElementById('__bundler_err') || p.appendChild(document.createElement('div'));
|
||||
d.id = '__bundler_err';
|
||||
d.style.cssText = 'position:fixed;bottom:12px;left:12px;right:12px;font:12px/1.4 ui-monospace,monospace;background:#2a1215;color:#ff8a80;padding:10px 14px;border-radius:8px;border:1px solid #5c2b2e;z-index:99999;white-space:pre-wrap;max-height:40vh;overflow:auto';
|
||||
d.textContent = (d.textContent ? d.textContent + String.fromCharCode(10) : '') +
|
||||
'[bundle] ' + (e.message || e.type) +
|
||||
(e.filename ? ' (' + e.filename.slice(0, 60) + ':' + e.lineno + ')' : '');
|
||||
}, true);
|
||||
|
||||
try {
|
||||
const manifestEl = document.querySelector('script[type="__bundler/manifest"]');
|
||||
const templateEl = document.querySelector('script[type="__bundler/template"]');
|
||||
if (!manifestEl || !templateEl) {
|
||||
setStatus('Error: missing bundle data');
|
||||
console.error('[bundler] Missing script tags — manifestEl:', !!manifestEl, 'templateEl:', !!templateEl);
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(manifestEl.textContent);
|
||||
let template = JSON.parse(templateEl.textContent);
|
||||
|
||||
// Nested page bundles (iframe targets). Each ships ONCE, in the ROOT
|
||||
// document's manifest; frame srcs carry an about:blank#<uuid> marker
|
||||
// instead of a substitutable uuid. Every document mints the blobs for
|
||||
// its OWN frames — from local text at the root, or text obtained from
|
||||
// its DIRECT parent over the relay protocol below — so each frame
|
||||
// navigation stays same-origin with its initiator and a deduped page
|
||||
// loads at any depth on any host, including a file:// download's
|
||||
// opaque origins (where a blob minted by one document cannot be
|
||||
// navigated to by another).
|
||||
const pageOrderEl = document.querySelector('script[type="__bundler/page_order"]');
|
||||
const pageOrder = pageOrderEl ? JSON.parse(pageOrderEl.textContent) : [];
|
||||
const pageSet = new Set(pageOrder);
|
||||
const pageTexts = {};
|
||||
|
||||
// ── Nested-page text protocol (parent-chain relay) ──
|
||||
// Page bundles ship once, in the ROOT document's manifest. Every
|
||||
// document mints the blobs for its OWN frames, obtaining page text
|
||||
// through a strict parent-chain relay: a document only ever SENDS to
|
||||
// its direct parent (which, for every document the publisher's own
|
||||
// runtime minted, is another publisher document) and only ACCEPTS
|
||||
// text from that direct parent; requests from below are honored only
|
||||
// for the document's own child frames, and the ROOT never sends
|
||||
// upward at all. That last rule is load-bearing: the artifact host's
|
||||
// frame-ancestors allows claude.ai (and friends) to frame an
|
||||
// artifact, so window.top can be a FOREIGN document — a top-addressed
|
||||
// protocol would hand page uuids to that ancestor and accept forged
|
||||
// "page text" from it, minting attacker HTML as an artifact-origin
|
||||
// blob document. The chain never touches the ancestor: requests stop
|
||||
// at the root, replies are origin-addressed per hop (never '*' when
|
||||
// an origin exists), and both directions are origin-gated (on https
|
||||
// a foreign sender's origin can never equal this document's; in
|
||||
// opaque contexts — file:// — everything serializes as 'null', where
|
||||
// an embedded EXTERNAL frame still fails the gate by carrying its
|
||||
// own real origin, and a sandboxed child of such a frame is excluded
|
||||
// by the own-child-frames check plus uuid unguessability). Shape is
|
||||
// deny-by-default: one request form, one reply form, no error
|
||||
// replies.
|
||||
// window.origin is the SERIALIZED SECURITY origin ('null' in opaque
|
||||
// contexts such as a sandboxed preview iframe) — location.origin is
|
||||
// computed from the URL and stays 'https://…' even when the document
|
||||
// is sandboxed, which would misgate every opaque chain.
|
||||
const OWN_TARGET = /^https?:[/][/]/.test(window.origin || '')
|
||||
? window.origin
|
||||
: '*';
|
||||
function trustedOrigin(e) {
|
||||
if (e.origin === window.origin) return true; // https chains; opaque==opaque
|
||||
// file:// trees: Chrome serializes the file document's origin as
|
||||
// 'file://' while the blob: documents it mints serialize as 'null',
|
||||
// so the two legitimate shapes can never string-match — accept the
|
||||
// pair in both directions. https origins never serialize as either,
|
||||
// and sender identity (own child frame / direct parent) is checked
|
||||
// separately.
|
||||
return (
|
||||
(e.origin === 'null' &&
|
||||
(window.origin === 'file://' ||
|
||||
window.location.protocol === 'file:')) ||
|
||||
(/^file:/.test(e.origin) && window.origin === 'null')
|
||||
);
|
||||
}
|
||||
// Non-empty only in the root document (nested bundles ship an empty
|
||||
// island), so it doubles as the root marker: the root answers from
|
||||
// local text and treats a miss as authoritative — never relaying to
|
||||
// its (possibly foreign) parent.
|
||||
const isPageRoot = pageOrder.length > 0;
|
||||
const blobUrls = {};
|
||||
const resourceBlobs = {};
|
||||
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const pendingFrames = {}; // uuid -> [{el, frag}] awaiting text for own frames
|
||||
const pendingRelays = {}; // uuid -> [childWindow] awaiting text to pass down
|
||||
function mountPage(el, frag, text) {
|
||||
const pageBlob = new Blob([text], { type: 'text/html' });
|
||||
const u = URL.createObjectURL(pageBlob);
|
||||
resourceBlobs[u] = pageBlob;
|
||||
el.src = u + frag;
|
||||
}
|
||||
function deliverPage(uuid, text) {
|
||||
const frames = pendingFrames[uuid];
|
||||
delete pendingFrames[uuid];
|
||||
if (frames) for (const f of frames) mountPage(f.el, f.frag, text);
|
||||
const relays = pendingRelays[uuid];
|
||||
delete pendingRelays[uuid];
|
||||
if (relays) {
|
||||
for (const cw of relays) {
|
||||
try { cw.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
function ownFrameWindows() {
|
||||
const out = [];
|
||||
for (const f of Array.from(document.querySelectorAll('iframe'))) {
|
||||
try { if (f.contentWindow) out.push(f.contentWindow); } catch (err) { /* detached */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
window.addEventListener('message', function (e) {
|
||||
const d = e.data;
|
||||
if (!d || typeof d !== 'object' || !e.source) return;
|
||||
// Origin gate first; the structural checks below (own-child-frames
|
||||
// for requests, direct-parent + solicited-uuid for replies, the
|
||||
// root's never-relay rule) are what hold in fully opaque chains
|
||||
// where origin strings can't discriminate — a sandboxed root's
|
||||
// FOREIGN wrapper still fails all three even though both serialize
|
||||
// as real-vs-'null' rather than matching.
|
||||
if (!trustedOrigin(e)) return;
|
||||
if (typeof d.__bundler_need === 'string' && UUID_SHAPE.test(d.__bundler_need)) {
|
||||
// Requests are honored only for this document's own child frames.
|
||||
if (ownFrameWindows().indexOf(e.source) < 0) return;
|
||||
const uuid = d.__bundler_need;
|
||||
const text = pageTexts[uuid];
|
||||
if (typeof text === 'string') {
|
||||
try { e.source.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
|
||||
return;
|
||||
}
|
||||
if (isPageRoot || window.parent === window) return; // authoritative miss
|
||||
const waiting = pendingRelays[uuid];
|
||||
if (waiting) {
|
||||
if (waiting.indexOf(e.source) < 0) waiting.push(e.source);
|
||||
return;
|
||||
}
|
||||
// Never-answered uuids would otherwise accumulate forever under a
|
||||
// request spray; past the cap (far above any real page count) new
|
||||
// relays are dropped rather than remembered.
|
||||
if (Object.keys(pendingRelays).length >= 128) return;
|
||||
pendingRelays[uuid] = [e.source];
|
||||
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
|
||||
} else if (
|
||||
typeof d.__bundler_page === 'string' &&
|
||||
UUID_SHAPE.test(d.__bundler_page) &&
|
||||
typeof d.__bundler_text === 'string'
|
||||
) {
|
||||
// The shape test is load-bearing beyond tidiness: the solicited
|
||||
// check below uses the in operator, which matches prototype keys
|
||||
// ('__proto__', 'constructor') — an unshaped key would reach
|
||||
// deliverPage and throw, painting the error banner.
|
||||
// Text is accepted only from the direct parent — the document
|
||||
// that minted this one.
|
||||
if (e.source !== window.parent || window.parent === window) return;
|
||||
if (!(d.__bundler_page in pendingFrames) && !(d.__bundler_page in pendingRelays)) return;
|
||||
deliverPage(d.__bundler_page, d.__bundler_text);
|
||||
}
|
||||
});
|
||||
|
||||
const uuids = Object.keys(manifest);
|
||||
setStatus('Unpacking ' + uuids.length + ' assets...');
|
||||
|
||||
await Promise.all(uuids.map(async (uuid) => {
|
||||
const entry = manifest[uuid];
|
||||
try {
|
||||
const binaryStr = atob(entry.data);
|
||||
const bytes = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
||||
|
||||
let finalBytes = bytes;
|
||||
if (entry.compressed) {
|
||||
if (typeof DecompressionStream !== 'undefined') {
|
||||
const ds = new DecompressionStream('gzip');
|
||||
const writer = ds.writable.getWriter();
|
||||
const reader = ds.readable.getReader();
|
||||
writer.write(bytes);
|
||||
writer.close();
|
||||
const chunks = [];
|
||||
let totalLen = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
totalLen += value.length;
|
||||
}
|
||||
finalBytes = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) { finalBytes.set(chunk, offset); offset += chunk.length; }
|
||||
} else {
|
||||
console.warn('DecompressionStream not available, asset ' + uuid + ' may not render');
|
||||
}
|
||||
}
|
||||
|
||||
if (pageSet.has(uuid)) {
|
||||
pageTexts[uuid] = new TextDecoder().decode(finalBytes);
|
||||
return;
|
||||
}
|
||||
if (FONT_MIME.test(entry.mime) && MIME_TOKEN.test(entry.mime)) {
|
||||
// Strict artifact hosts allow font-src data: but not blob:.
|
||||
blobUrls[uuid] = 'data:' + entry.mime + ';base64,' +
|
||||
(entry.compressed ? toBase64(finalBytes) : entry.data);
|
||||
} else {
|
||||
const blob = new Blob([finalBytes], { type: entry.mime });
|
||||
blobUrls[uuid] = URL.createObjectURL(blob);
|
||||
resourceBlobs[blobUrls[uuid]] = blob;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decode asset ' + uuid + ':', err);
|
||||
const blob = new Blob([], { type: entry.mime });
|
||||
blobUrls[uuid] = URL.createObjectURL(blob);
|
||||
resourceBlobs[blobUrls[uuid]] = blob;
|
||||
}
|
||||
}));
|
||||
|
||||
const extResEl = document.querySelector('script[type="__bundler/ext_resources"]');
|
||||
const extResources = extResEl ? JSON.parse(extResEl.textContent) : [];
|
||||
const resourceMap = {};
|
||||
for (const entry of extResources) {
|
||||
if (blobUrls[entry.uuid]) resourceMap[entry.id] = blobUrls[entry.uuid];
|
||||
}
|
||||
|
||||
// Artifact-host CSP (connect-src 'self') refuses fetch() of blob: URLs —
|
||||
// consumers read these Blobs directly. Survives the swap like the error sink.
|
||||
window.__resourceBlobs = resourceBlobs;
|
||||
|
||||
setStatus('Rendering...');
|
||||
// Page uuids have no blob here — they ride inside about:blank#<uuid>
|
||||
// frame markers the pass below resolves after the swap.
|
||||
for (const uuid of uuids) {
|
||||
if (pageSet.has(uuid)) continue;
|
||||
template = template.split(uuid).join(blobUrls[uuid]);
|
||||
}
|
||||
|
||||
// Strip integrity + crossorigin — blob URLs from a file:// document inherit
|
||||
// a null origin, so crossorigin forces a CORS fetch that SRI then rejects.
|
||||
// The manifest bytes are ours; SRI protects against CDN compromise, not this.
|
||||
// appifacts: only on tags now pointing at OUR blob: URLs. A reference the
|
||||
// bundler left live-fetched (an absolute x-import, a runtime dependency it
|
||||
// could not embed, an authored external script) keeps the pin it was
|
||||
// written with: that fetch is exactly what SRI protects. (A crossorigin
|
||||
// on a parent whose child carries the blob — <video crossorigin><source>
|
||||
// — is left alone too; the attribute is then the author's to drop.)
|
||||
template = template.replace(/<[a-z][^>]*>/gi, (tag) => /\s(?:src|href|srcset|poster|data|xlink:href)="blob:/i.test(tag) ? tag.replace(/\s+integrity="[^"]*"/gi, '').replace(/\s+crossorigin="[^"]*"/gi, '') : tag);
|
||||
|
||||
const resourceScript = '<script>window.__resources = ' +
|
||||
JSON.stringify(resourceMap).replace(/<\//g, '<\\/') +
|
||||
';</' + 'script>';
|
||||
// Inject after <head> so the DOCTYPE stays first; prepending the script
|
||||
// would push the parser into quirks mode. DOMParser always emits a <head>
|
||||
// (synthesizing one if the source HTML omitted it) but may carry
|
||||
// attributes through, so match the full opening tag. slice() rather than
|
||||
// replace() keeps us clear of $-pattern substitution in resourceScript.
|
||||
const headOpen = template.match(/<head[^>]*>/i);
|
||||
if (headOpen) {
|
||||
const i = headOpen.index + headOpen[0].length;
|
||||
template = template.slice(0, i) + resourceScript + template.slice(i);
|
||||
}
|
||||
|
||||
// Parse the template and swap the root element. Scripts inserted via
|
||||
// DOMParser/replaceWith are inert per spec — re-create each with
|
||||
// createElement so they execute, awaiting onload for src scripts to
|
||||
// preserve ordering (React before ReactDOM before Babel before text/babel).
|
||||
const doc = new DOMParser().parseFromString(template, 'text/html');
|
||||
document.documentElement.replaceWith(doc.documentElement);
|
||||
|
||||
// Resolve page-frame markers: mint this document's own blob for each
|
||||
// framed page — from local text when this is the root, otherwise by
|
||||
// asking the direct parent over the relay protocol above. An
|
||||
// unanswered request leaves the frame on about:blank, the same
|
||||
// degraded state as a refused embed.
|
||||
{
|
||||
const MARKER = /^about:blank#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(#.*)?$/;
|
||||
const needed = [];
|
||||
for (const el of Array.from(document.querySelectorAll('iframe'))) {
|
||||
const m = MARKER.exec(el.getAttribute('src') || '');
|
||||
if (!m) continue;
|
||||
const text = pageTexts[m[1]];
|
||||
if (typeof text === 'string') {
|
||||
mountPage(el, m[2] || '', text);
|
||||
} else if (window.parent !== window && !isPageRoot) {
|
||||
const had = pendingFrames[m[1]];
|
||||
(pendingFrames[m[1]] = had || []).push({ el: el, frag: m[2] || '' });
|
||||
if (!had) needed.push(m[1]);
|
||||
}
|
||||
}
|
||||
for (const uuid of needed) {
|
||||
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
|
||||
}
|
||||
}
|
||||
|
||||
const dead = Array.from(document.scripts);
|
||||
for (const old of dead) {
|
||||
const s = document.createElement('script');
|
||||
for (const a of old.attributes) s.setAttribute(a.name, a.value);
|
||||
s.textContent = old.textContent;
|
||||
// text/babel scripts with a src: read and inline. transformScriptTags
|
||||
// does XHR against the src, but blob:null/ from a file:// origin is
|
||||
// silently dropped. Inlining makes it a plain inline babel script,
|
||||
// which transformScriptTags handles unconditionally.
|
||||
if ((s.type === 'text/babel' || s.type === 'text/jsx') && s.src) {
|
||||
const pre = resourceBlobs[s.src.split('#')[0]];
|
||||
s.textContent = pre ? await pre.text() : await (await fetch(s.src)).text();
|
||||
s.removeAttribute('src');
|
||||
}
|
||||
const p = s.src ? new Promise(function(r) { s.onload = s.onerror = r; }) : null;
|
||||
old.replaceWith(s);
|
||||
if (p) await p;
|
||||
}
|
||||
// Babel standalone auto-transforms type=text/babel on DOMContentLoaded,
|
||||
// which fired before we swapped the document. Trigger manually if present.
|
||||
if (window.Babel && typeof window.Babel.transformScriptTags === 'function') {
|
||||
window.Babel.transformScriptTags();
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Error unpacking: ' + err.message);
|
||||
console.error('Bundle unpack error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<script type="__bundler/manifest">
|
||||
{"801f58b9-2cf4-4192-9e34-2d8498f779c6":{"mime":"text/html","compressed":true,"data":"H4sIAAAAAAAACry82ZLiSLYo+t5foc4+pyOiFRFi0ERkZZWBkEAgECAkBNW1yzS4BtCEZlFVZvsjtt33+3L+4P7Qfjt/cc0lICAyus8ert0sK0shuS9fvnzNa3n+8OehyKy3CxZxUt/78U8/XP4CmvnjnxDkBx+kGmI4WpyA9NuXLLVe6C/1h9RNPfDj//5f/9f//r//nx+w5hd8n6RV84Qgf0N+Q3wttt3gDWl9RSLNNN3Arp/1sHxJ3FP9Uw9jE8Qvelh+Rf6oJ+qhWSG/IbpmHOw4zALzDfmLpVk9i/iKmG4SeVr1hlgeKL8imufawYubAj95QwwQpCD+iuyzJHWt6sUIgxQE6fsH3w1eHODaTvqGtFut3PmKWGGQvlia73rVG/KiRZEHXpIqSYH/jAw8NzjMNEOqf3NhkD4jiRYkLwmIXeuC7V9+/VXPAtMD8a9eqMEtIr8hUZi4qRsGb4jllsCEO07T0H9DOq2o/IrEDQ7ND4jCG9LuRiXWfsX/s0gYoRfGb8hfSJL8+oFklnVDdToqkTYO1zsTPNZMN0vqD+cDcTQzLN6QFtKOSgSPSiS2de2x9Vz/99ruPH1FTi9uYIKyJl+r9QkJUifz9UBzvc+I4AYJSGsGKFwzdWoo//MrcnMk//O/ecKf8swV6V6v1/unOCc5PLx/glyo74GRvlguXDMMUs0NPgEYeZoBnNAzQYz8dj2hevGa3xL3BN7OpwEn/4BdxeaHIEyM2I3SRobO8vQpj10JFYQB+Ir8cQMFQX4w3Rypf3/7cj2H5hjOrNjuROVXD1hp81Rz4T9gwk948IYFm/3V27shf807HYJ4vvzfeu09fb2wIwm5ES57z4xk/ebKiv+AE1v009fLmTZ8+KXZNIKsHTdBIs0GSAyOmRuDBJlouSbVJEXS8EK014ZImOnmNdWxd7L/gDXa7weohuqPkJSu+e3Lpwf85UfhfCKNMnx9fX0H+/3M8/F9+VEOIs04uIH9PqHWnzeH/yczNDIfBOmrZppsDoJUcJMUBCB+fBiKM6Zhfrg8MB+eES2pAgOxssCAh/34hPz2JwQyaZIiF6b5hlxB2iBlPQAfBxVvPj58h+LD09c/IVdwSAJSKdXSLHn0E/sJ+Q1xLeTxPPTpssBrCsr0jBfyDfETGzL4FQ1OnK9/nfEzFvmGYP/yCFnuZ+yX3yGvuYYGl/kZ++WxfHn6qRaTDx/ywPz7q5+8wG+NHD5h7tcrcAj317U4Zec19J//XryiL7+gP2O/XB7/B3a3ozQcaAkg8Ue9SkHSkAtBPJAiuhsg35CHh6/1GyuMkUf42kW+Qd3lIj8g9ZRXDwR26sA36DekVdKtVusCBqmBoN8QKY0hYaw49BlHi5nQBK9wX9VjkHne8xlSkulaHGvVo/sMoV2APTUYNAomBmkWB4iehtqj7gb1t5q4GIawcRzGSOIGByQCceImaYJoRhwmCRKDmls3burAAQZA3PQhQcIAKdzADItnJAhTJHUAMhRnUC6a15/wHICLPDy/sxi47BbDEE5zPWAiMUjCLDZAzREJ8shIixfdC40DMBGoRZJnpGHx5Blxfc2GhLfcGCCRp7nBBRiACyeI1uAFGj5F/v1f/w0ptDhAwsCrviIx0DxkIiE1WgliaHFcIT5IEs0GWP2ykXLIqH8Gr+cvyF//ivwZvNbf4TN4TbXYBund85+/fTuT4f08IZeFHniFGDw+/NxIyy/vG7YaAqRhvfW3h+fzPOQK9jXV7Lnmg+czUzxePySxgfz++/tAJwYWfPHwcGGBy/HfMkSuxUh0K9O14/T77+8vLg9nSf96nWb+x1QBiOOHJwgxgjwLApNxXM98vM40YqCl4Dz58cF08yvC5qsLF/kA7PKttkyvRpKsQQk1xcN/yEY1btONueqczVXmvvhhECaRZoDn69OtOfpLR2t32sTFWFkWrdGtqzlqty7e0b09oq9v3qAlSkLPNZG/EEZH74CrCerVbkXhuCl4qdd9i2LwUsRa9NXXyovDiUN3M8xBbHlh8aZlaXglxr3SfLx/8dOHAehnGuWx3XpC3iC7IOiZXd459AFBkcd3/m/4rIrA+9hH8Gq5Hgg0HyA/IQ/II5zy/u418VwDPLaeEbL1hKDIw1vz3XMDEITwxdNDs3qtkp6RNM7A01eomtK4OgtQo6R9LXAtkKSsd8uAxwzElQQ8YKRh/PjQKIifIY43phO7zP3yy8OZxxqYKfAjr2bD/zzMy9x3mLW2uEHz99+RP7+v8K4O3u3hQ6183xDfTRJoZBvYiKml2sNVei/ao9Y7V/UR/4LMzrOSs4ui2Umt6N5ReHt4Rv58g9Iz8vCOUPPxBsHP1cUnR4B8QyaSOH+NYHD3+A7+ltvO0KDxuyxxP+194Q/TLpp8DpIUmI1L1uw5QR5dK4as1ii75OkVYTXDQRLHjRJEnDPsM+IGteZfieL6Aulysg/JdQtfkQZQEhsX/a8FiKaHWfqme1pw+MsPWeaaP8Iw9ADiCyQ3SFKgmUhoIRqSZHqSummWaroHEDj8FWFzEFfXBWHYmCY1ProX6gl0CK6g0gQRN/MGj+bgoFwiXmhoHgJJcjFicRimz0gYNy9DHYYOwKxH3wIb8iuWWSORFsOVob5oZgNPq5AoDtPQCD1EB15Y1KslIQIg8WoELoACLXdtrXHbUq1KkETzwUsYu7YLrX7q1Eu5gZu6WhrGiBaYiIaYwMyi81ldIDVWXEsRLagQE0SpAx0H+MMJkxSek+FltWupIVBdvNUHVQRw3kNygRJG2jEDSINAgjwWDogBotXkrKkLTESvkDAA70Q3tAD6JfrHTTUmVocnHaYOiJ9eb3gboi5Cdf1f0QVw8q+1sv+oYa5gkW93S/x0Kwo3H+5kAXlDfv7lIzQJQPkLQIFIIH2f+t2q0DwmyDfktz/eRerf/+1f//3f/vUsWS+1ZNU8deWOx4Z7XgxHc4OGdZ7Osy4wFrfyCAUPCQMD3MndZwJ3Fo2PIvmZhHwQjeczy0NWuaJ8gZM6cZjZDpTGNHaNFPl+A2+QQS/LQf8P+ogxIrHzoYSk4a0ImW4MrjAgs7mG81yjBO7lGuIbZbrnJg6IoUtcXB3QOAtS1wdn5nxG3OTCb+8zroCeagGqkeozDLtYS9eNwXOpFULqaOk9Zl/rEBVAT70e0ci0FgPECYMwBg3ECySIPsT35lDCIkAM6I9dSQzRuB5fUBMoAYF5lcMsKrTYrMXZ816RNcTJ05IUiTMPwD1CsX3RgRbXKSMISotT19KMtJb3d4muV3zRAgMkKXS+Nc8LiwQxPC2D8Y2LPEJcrNiFyz9BiW00tXal8AXwM1Rh55AjDSMo94gOlQMnrlh+NH8/LqjuNCQNoxfNNGOQJMC8ALuyfhFmnok4cPGay6A+T+DyNf0v+DYKzzBAlELC2u+Avlx580tzKm76XDNBreLSVDMOIEbG65mAaEltbM7bOGvXC5xas131D7J2ANIwc3MqaZgZDmgk5oLU2zs/JGkYXel0az5gLOeCpOaSZsF3WsDAD3HCCHls1nj42wNSOOCd4MF5CgJKGB8+Nfyih6lzZkw3DO5AN9r2MQwQJ02j69lrkGbAtYOauRrJaQDDs2sWB8cM2j+YjXnnWJgA/GAS6gQeVHG15TwbEPhci2rq1J4RiF3Nc09w3wnyAMPmh2ektiE3ewO+DkwTmAirrtnVvC9cfIPU9bw6OGvIDTcFzUftLkDwbvpup4qgiSqb/TyfDWOiBaYelsA8i1toIUlmQHXVLOEmCCihHXznI72qFwuL4KWe83J2EAwHGAck8rKkZk0kC+wM+uW667lp9fSKSI4WQYhXFQuC6kWvXkxgaZmXvtVG8swo8Bz85/ObyKvOv4OwCYmvuqxhmtfL77Ownc/MbcgisSu+L/A7dohILCOv+PX2cqqPDcmhbWhO7QLoengNNZI7UkUxyF1QII2v91QfKvSKIJe9r/0Oyo+y9OwP1QjJK6Emf+PAPNQc+IZh//6v/+uhTg/UvH2nEq82IHlHA/KJazhnveC7SX38jR24sCCUy1snQtzMf133VyN2XSeS6pV/eoN5JOwX7DUFSfp4T8ImUD/73T/d0/f89g2K4zmddM0/xRm04GI97j2X0oQg4AL82zUVcX7zdEkEwSjrK9xwjWGzj+TreVvfvt2cVaOyz9KVxgAkbwjjxCEUj3fhgrSEg+5sTIOElrzDeTgDgtoFjr5Y/bfrvARxLy7BFfyN6L6DSsJ6dlqEiAdsN3V9eDoJFIHkRpkkdcD74mup4TRWoFHdqXOzvUhzY8iiH/TZ65k6F9/zYhVv0AIutOs3aNVsV6s2xDVBkLpphTx+sLYIdm/NnyDb1dJ9VgLNBkGkxVoKvHPC+ZrEe7wmh+5P+ixqf/3r9TuCfOC2etj1DH7//WbkNXd3FbSrYXyf9fD0dDvrEfuXBljD2eDKZX/9K/LJwjV+F16/S0/CYC8MXoAfpVXjDJ2dSWi63q34Y9BEhHfOZ62/o7R6F2FPC8yn2jdw4eRM9xr1fwXYhHRv7y+0IClAnNxFVLdhGPSNYLoKqikYqtcKK0udMHZTLXVzULNWwx+1ywmNw71n+RiFSeLqjaaFFvDpfPq36sNNoHO9gijdRAvnXDHyI9K69fGh4Mixd3Hx3z9c0oqD2p/++FWW+eGv0ri/aDLpP7deetqL9ctv9B8v12f8P/Dc7vzRJMWvMQcIYDTHNdaqXrZ2HKGtevkR+fk34D1DAbD/+AXRCs2t/aLGyw3j2n42hu57kCtI0s9A1kK1qTntI8w0RCItSeqI8l53+mEWpJDOjxeEnusp9+naJoaCJDwHW/Dx8Wc48Jdn5DcERoBvMJtSphisfD8gf9wlbFIkQ75BS3ROdIp11UFeCY8XuDfplpvz+jn75Xz28OdlCLine truncated
|
||||
</script>
|
||||
|
||||
<script type="__bundler/ext_resources">
|
||||
[]
|
||||
</script>
|
||||
|
||||
<script type="__bundler/page_order">
|
||||
["801f58b9-2cf4-4192-9e34-2d8498f779c6","e1a9c43f-5bb0-4195-bf86-05987c44f630","36f0df23-1b83-44bd-9a01-a40c552af612","07971d62-3759-46b1-acd0-23b5f58ddb2f","235b90e7-6856-45e7-bf0f-8e4e786d131d","c92429a5-edf6-4971-a16e-aa7f734bfd47","2aeaa2e7-1433-4d80-b3cb-e66674b2f47c","bd44616d-8355-46fe-b629-565d747432b7","4a9d128d-e698-444d-a855-24cc10c8b7de","2aceed2a-3d36-4114-8394-6c422450294d","2829a2db-ca86-41af-84d2-1640a0b665de","2b3bef1e-c2b5-4a63-a85b-012d65b14cfb","7ccb7574-5981-43ef-8032-b82033e6ab90","5121e7b1-aa8b-480d-8048-ef1f18feccd2","c1daf045-d813-4b9f-a36c-1e568dd681b3","a985188c-af61-4f7a-ad00-c670ae7fa3dd","ea76b2d3-8c37-4552-b2e5-c4d10b93b68e","b5c69603-014d-44e3-b165-d0730199c884","c8f1aa84-538e-4784-8b01-45b73d034dcd","0452ad50-ba17-4521-98e3-cac7420c5918","c47cddbf-8756-4cab-8d0f-b10dc8286177","716f9625-1180-4f90-8a17-5d806e61b896","3ac56421-6f90-4b59-9451-e05e0477b44b","f040e75b-38b2-4832-909e-090ef7dbbe46","5e7750f8-fafc-40fa-952c-f0b965789d6b","19776c1f-c18f-48d7-98aa-f9ecc4fc7e4f","6772e9ed-e833-4e48-90c6-a3a564d628d6","ebfadca5-ae07-4e63-9f58-dc718bc1768c","41cdce17-b7f8-48df-97c6-0e57f6362891","793f1f4a-9cf8-4d97-880b-33347ad3e335","a4062aa0-5890-40ba-9437-18244f4220a4","4b78be80-df5f-48aa-a46f-ba1e57110415","f3b8ec16-2a87-47a6-bcd5-28dc93d2528f"]
|
||||
</script>
|
||||
|
||||
<script type="__bundler/template">
|
||||
"<!DOCTYPE html>\n<html lang=\"en\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Reader V3 · 统一设计系统与完整原型<\u002Ftitle>\n<style>\nhtml{background:#f5f4ef;color:#1f1e1d;font:14px/1.4 system-ui,-apple-system,\"Segoe UI\",sans-serif}\nbody{margin:0;padding:48px;display:flex;flex-direction:column;align-items:flex-start;gap:48px}\n.board h2{margin:0 0 12px;font-size:13px;font-weight:500;color:#6b6a68}\n.board iframe{display:block;border:0;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.12)}\n<\u002Fstyle>\n<\u002Fhead>\n<body>\n<section class=\"board\"><h2>1 首页<\u002Fh2><iframe src=\"about:blank#801f58b9-2cf4-4192-9e34-2d8498f779c6\" title=\"1 首页\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>2 书架<\u002Fh2><iframe src=\"about:blank#e1a9c43f-5bb0-4195-bf86-05987c44f630\" title=\"2 书架\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>3 阅读器(黑白灰阅读主题)<\u002Fh2><iframe src=\"about:blank#36f0df23-1b83-44bd-9a01-a40c552af612\" title=\"3 阅读器(黑白灰阅读主题)\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>4 外观抽屉<\u002Fh2><iframe src=\"about:blank#07971d62-3759-46b1-acd0-23b5f58ddb2f\" title=\"4 外观抽屉\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>5 影音首页<\u002Fh2><iframe src=\"about:blank#235b90e7-6856-45e7-bf0f-8e4e786d131d\" title=\"5 影音首页\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>6 视频播放(竖屏)<\u002Fh2><iframe src=\"about:blank#c92429a5-edf6-4971-a16e-aa7f734bfd47\" title=\"6 视频播放(竖屏)\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>7 音乐播放<\u002Fh2><iframe src=\"about:blank#2aeaa2e7-1433-4d80-b3cb-e66674b2f47c\" title=\"7 音乐播放\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>8 我的(已登录)<\u002Fh2><iframe src=\"about:blank#bd44616d-8355-46fe-b629-565d747432b7\" title=\"8 我的(已登录)\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>9 登录<\u002Fh2><iframe src=\"about:blank#4a9d128d-e698-444d-a855-24cc10c8b7de\" title=\"9 登录\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>10 有声书播放<\u002Fh2><iframe src=\"about:blank#2aceed2a-3d36-4114-8394-6c422450294d\" title=\"10 有声书播放\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>11 倍速与定时<\u002Fh2><iframe src=\"about:blank#2829a2db-ca86-41af-84d2-1640a0b665de\" title=\"11 倍速与定时\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>12 字幕与音轨<\u002Fh2><iframe src=\"about:blank#2b3bef1e-c2b5-4a63-a85b-012d65b14cfb\" title=\"12 字幕与音轨\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>13 视频横屏播放<\u002Fh2><iframe src=\"about:blank#7ccb7574-5981-43ef-8032-b82033e6ab90\" title=\"13 视频横屏播放\" width=\"844\" height=\"390\" loading=\"eager\" style=\"width:844px;height:390px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>14 设计系统<\u002Fh2><iframe src=\"about:blank#5121e7b1-aa8b-480d-8048-ef1f18feccd2\" title=\"14 设计系统\" width=\"1280\" height=\"900\" loading=\"eager\" style=\"width:1280px;height:900px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>15 桌面首页(侧栏外壳)<\u002Fh2><iframe src=\"about:blank#c1daf045-d813-4b9f-a36c-1e568dd681b3\" title=\"15 桌面首页(侧栏外壳)\" width=\"1280\" height=\"800\" loading=\"eager\" style=\"width:1280px;height:800px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>16 桌面阅读器(目录常驻)<\u002Fh2><iframe src=\"about:blank#a985188c-af61-4f7a-ad00-c670ae7fa3dd\" title=\"16 桌面阅读器(目录常驻)\" width=\"1280\" height=\"800\" loading=\"eager\" style=\"width:1280px;height:800px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>17 桌面视频播放<\u002Fh2><iframe src=\"about:blank#ea76b2d3-8c37-Line truncated
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,240 @@
|
||||
# Reader V3 主题语义令牌
|
||||
|
||||
版本 v3.1 · 2026-10-20 · 对应线上原型“Reader V3 · 统一设计系统与完整原型”
|
||||
|
||||
## 1. 概述
|
||||
|
||||
主题 = 一组语义令牌的取值。组件只引用语义令牌,不写死颜色;换主题只换这一组值,界面结构、间距、字号完全不变。
|
||||
|
||||
Reader V3 内置 4 个主题,每个主题含浅色和深色两套取值,共 8 套配色:
|
||||
|
||||
| 主题 | 定位 | 色相 |
|
||||
| --- | --- | --- |
|
||||
| 暖陶 | 默认应用主题 | 陶土橙 |
|
||||
| 雾蓝 | 应用主题 | 蓝 |
|
||||
| 青绿 | 应用主题 | 绿 |
|
||||
| 黑白灰 | 阅读专用主题,只在阅读器内生效 | 无彩色 |
|
||||
|
||||
设计原则:
|
||||
|
||||
- 同色系:每个主题的底色、卡片、描边、文字、按钮都从同一个色相派生,只调明暗和浓淡。
|
||||
- 四个频道(阅读、影视、音乐、听书)取主色相邻的色相,明度和彩度保持一致,只做小面积色标。
|
||||
- 浅色主题整体提亮,按钮用柔和的浅色底配同色相的近黑文字;深色主题底色不用纯黑。
|
||||
- 组件之间不直接比较颜色,只引用令牌。
|
||||
|
||||
## 2. 令牌角色与命名
|
||||
|
||||
原有 13 个角色,另新增 2 个文字色角色(brand-text、ch-\*-text),共 15 类。CSS 变量名如下。
|
||||
|
||||
| 角色 | CSS 变量 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| bg | `--color-bg` | 页面底色 |
|
||||
| surface | `--color-surface` | 卡片、抽屉、导航栏 |
|
||||
| surface-2 | `--color-surface-2` | 分段控件、输入框底、悬停 |
|
||||
| ink | `--color-ink` | 正文与标题 |
|
||||
| muted | `--color-muted` | 次要文字、图标 |
|
||||
| line | `--color-line` | 分隔线、描边 |
|
||||
| brand | `--color-brand` | 主按钮底色、选中态、进度条填充(只作填充,不作文字色) |
|
||||
| brand-soft | `--color-brand-soft` | 选中底色、标签底色 |
|
||||
| on-brand | `--color-on-brand` | 品牌色填充上的文字和图标 |
|
||||
| brand-text | `--color-brand-text` | 品牌色的文字、图标、链接(新增) |
|
||||
| ch-{read\|video\|music\|audio} | `--color-ch-read` 等 | 频道图标底色、进度条填充 |
|
||||
| ch-\*-soft | `--color-ch-read-soft` 等 | 继续卡片、频道入口底色 |
|
||||
| ch-\*-text | `--color-ch-read-text` 等 | 频道色的文字(新增) |
|
||||
| on-ch | `--color-on-ch` | 频道色填充上的图标和字 |
|
||||
| scrim | `--color-scrim` | 抽屉遮罩 |
|
||||
|
||||
新增 brand-text 和 ch-\*-text 的原因:浅色主题的 brand 和频道色已改为柔和的浅色,直接当文字用对比度不够,所以文字和图标改用更深的同色相取值。
|
||||
|
||||
## 3. 主题取值
|
||||
|
||||
### 3.1 暖陶
|
||||
|
||||
| 令牌 | 浅色 | 深色 |
|
||||
| --- | --- | --- |
|
||||
| bg | `#FDF9F6` | `#1F1713` |
|
||||
| surface | `#FFFFFF` | `#2A201A` |
|
||||
| surface-2 | `#F8EFE8` | `#352A23` |
|
||||
| ink | `#3A2A22` | `#F7EDE6` |
|
||||
| muted | `#82685B` | `#BBA79B` |
|
||||
| line | `#F0E3DA` | `#43342B` |
|
||||
| brand | `#F2B79F` | `#E88F73` |
|
||||
| brand-soft | `#FBE9E0` | `#3F2A21` |
|
||||
| on-brand | `#2B1810` | `#1A100C` |
|
||||
| brand-text | `#A8482A` | `#F0987C` |
|
||||
| on-ch | `#2B1810` | `#1A100C` |
|
||||
| scrim | `rgba(58,42,34,.40)` | `rgba(0,0,0,.60)` |
|
||||
|
||||
| 频道 | 浅色 ch | 浅色 soft | 浅色 text | 深色 ch | 深色 soft | 深色 text |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| read 阅读 | `#F2B79F` | `#FBE9E0` | `#A8482A` | `#E88F73` | `#3F2A21` | `#E88F73` |
|
||||
| video 影视 | `#F6CA97` | `#FCF0DD` | `#8A5200` | `#E9AA6E` | `#3A2E1E` | `#E9AA6E` |
|
||||
| music 音乐 | `#EFA9A3` | `#FAE4E1` | `#A8343E` | `#E58A8A` | `#3D2526` | `#E58A8A` |
|
||||
| audio 听书 | `#E6BE9E` | `#F7EBDD` | `#7A5232` | `#D9A07F` | `#36291F` | `#D9A07F` |
|
||||
|
||||
### 3.2 雾蓝
|
||||
|
||||
| 令牌 | 浅色 | 深色 |
|
||||
| --- | --- | --- |
|
||||
| bg | `#F8FAFE` | `#0F1722` |
|
||||
| surface | `#FFFFFF` | `#172131` |
|
||||
| surface-2 | `#EDF2FA` | `#202C40` |
|
||||
| ink | `#1F3250` | `#E8EEF7` |
|
||||
| muted | `#5A6C8C` | `#9FB0C8` |
|
||||
| line | `#E4EBF6` | `#2A3850` |
|
||||
| brand | `#9DC0F2` | `#7FB1F0` |
|
||||
| brand-soft | `#E6EFFC` | `#1C3050` |
|
||||
| on-brand | `#0F1D36` | `#0A1220` |
|
||||
| brand-text | `#2F63B0` | `#8DB8F0` |
|
||||
| on-ch | `#0F1D36` | `#0A1220` |
|
||||
| scrim | `rgba(31,50,80,.40)` | `rgba(0,0,0,.60)` |
|
||||
|
||||
| 频道 | 浅色 ch | 浅色 soft | 浅色 text | 深色 ch | 深色 soft | 深色 text |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| read 阅读 | `#9DC0F2` | `#E6EFFC` | `#2F63B0` | `#7FB1F0` | `#1C3050` | `#7FB1F0` |
|
||||
| video 影视 | `#9FD2EE` | `#E3F2FA` | `#1F6F93` | `#7CC3E8` | `#1A3347` | `#7CC3E8` |
|
||||
| music 音乐 | `#B2B8F2` | `#ECEEFC` | `#4A52B0` | `#9AA3F0` | `#25274D` | `#9AA3F0` |
|
||||
| audio 听书 | `#90CBD8` | `#E0F2F5` | `#1F6F7C` | `#70C1CF` | `#17353A` | `#70C1CF` |
|
||||
|
||||
### 3.3 青绿
|
||||
|
||||
| 令牌 | 浅色 | 深色 |
|
||||
| --- | --- | --- |
|
||||
| bg | `#F7FBF8` | `#0E1914` |
|
||||
| surface | `#FFFFFF` | `#15251E` |
|
||||
| surface-2 | `#EAF4EE` | `#1E3229` |
|
||||
| ink | `#1B3329` | `#E6F2EC` |
|
||||
| muted | `#52705F` | `#98B3A7` |
|
||||
| line | `#E0EEE5` | `#28403A` |
|
||||
| brand | `#8AD6B1` | `#6ED6A8` |
|
||||
| brand-soft | `#E1F4EA` | `#1A3B2E` |
|
||||
| on-brand | `#0F2A1E` | `#07130E` |
|
||||
| brand-text | `#1F6B4F` | `#7FDDB4` |
|
||||
| on-ch | `#0F2A1E` | `#07130E` |
|
||||
| scrim | `rgba(27,51,41,.40)` | `rgba(0,0,0,.60)` |
|
||||
|
||||
| 频道 | 浅色 ch | 浅色 soft | 浅色 text | 深色 ch | 深色 soft | 深色 text |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| read 阅读 | `#8AD6B1` | `#E1F4EA` | `#1F6B4F` | `#6ED6A8` | `#1A3B2E` | `#6ED6A8` |
|
||||
| video 影视 | `#A9DB9B` | `#EBF6E2` | `#3F6E1F` | `#8ED67A` | `#243A1E` | `#8ED67A` |
|
||||
| music 音乐 | `#8CD2CF` | `#E1F3F2` | `#1F6B68` | `#6CCBC6` | `#173A38` | `#6CCBC6` |
|
||||
| audio 听书 | `#B6D98E` | `#F0F5E0` | `#5B6B1F` | `#A6CF72` | `#2C3818` | `#A6CF72` |
|
||||
|
||||
### 3.4 黑白灰(阅读专用)
|
||||
|
||||
| 令牌 | 浅色 | 深色 |
|
||||
| --- | --- | --- |
|
||||
| bg | `#F5F5F5` | `#121212` |
|
||||
| surface | `#FFFFFF` | `#1B1B1B` |
|
||||
| surface-2 | `#EBEBEB` | `#262626` |
|
||||
| ink | `#1A1A1A` | `#E6E6E6` |
|
||||
| muted | `#666666` | `#9E9E9E` |
|
||||
| line | `#E0E0E0` | `#2E2E2E` |
|
||||
| brand | `#3D3D3D` | `#E6E6E6` |
|
||||
| brand-soft | `#EDEDED` | `#2A2A2A` |
|
||||
| on-brand | `#FFFFFF` | `#121212` |
|
||||
| brand-text | `#3D3D3D` | `#E6E6E6` |
|
||||
| on-ch | `#FFFFFF` | `#121212` |
|
||||
| scrim | `rgba(26,26,26,.40)` | `rgba(0,0,0,.60)` |
|
||||
|
||||
| 频道 | 浅色 ch | 浅色 soft | 浅色 text | 深色 ch | 深色 soft | 深色 text |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| read 阅读 | `#3D3D3D` | `#EDEDED` | `#3D3D3D` | `#CFCFCF` | `#2A2A2A` | `#CFCFCF` |
|
||||
| video 影视 | `#4A4A4A` | `#EDEDED` | `#4A4A4A` | `#BDBDBD` | `#2A2A2A` | `#BDBDBD` |
|
||||
| music 音乐 | `#575757` | `#EDEDED` | `#575757` | `#ABABAB` | `#2A2A2A` | `#ABABAB` |
|
||||
| audio 听书 | `#666666` | `#EDEDED` | `#666666` | `#999999` | `#2A2A2A` | `#999999` |
|
||||
|
||||
黑白灰没有彩色,四个频道只靠明度的细微差别区分,实际界面里频道主要靠图标和文字标签区分。
|
||||
|
||||
## 4. 使用规则
|
||||
|
||||
1. 组件不写死颜色。除了封面、剧照等内容图片,界面里不出现十六进制色值。
|
||||
2. brand 和 ch-\* 只作填充。带这些底色的文字和图标,用 on-brand 或 on-ch。
|
||||
3. 品牌色的文字、链接、选中态图标,用 brand-text;频道色的文字用 ch-*-text。不要直接把 brand 或 ch-* 当文字色,浅色主题下对比度不够。
|
||||
4. 继续卡片、频道入口的底色用 ch-*-soft,文字用 ink 或 ch-*-text。
|
||||
5. 选中的标签、分段控件用 brand-soft 底加 brand-text 文字。
|
||||
6. 抽屉、弹层的遮罩用 scrim。
|
||||
7. 影视、音乐播放页是固定深色沉浸界面,使用当前主题的深色取值,不随浅色切换。
|
||||
8. 不使用纯黑 `#000` 作背景;纯白只作浅色主题的 surface。
|
||||
|
||||
## 5. 对比度要求
|
||||
|
||||
正文、次要文字、频道色文字至少 4.5:1,图标与控件至少 3:1。以下组合已用脚本对 8 套配色逐一计算,全部达标:
|
||||
|
||||
| 组合 | 要求 |
|
||||
| --- | --- |
|
||||
| ink 对 bg、surface | 4.5:1 以上 |
|
||||
| muted 对 bg、surface-2 | 4.5:1 以上 |
|
||||
| brand-text 对 brand-soft、bg | 4.5:1 以上 |
|
||||
| on-brand 对 brand | 4.5:1 以上 |
|
||||
| ch-*-text 对 ch-*-soft | 4.5:1 以上 |
|
||||
| on-ch 对 ch-\* | 4.5:1 以上 |
|
||||
|
||||
新增主题或调色时,必须重新计算这几组。
|
||||
|
||||
## 6. 主题选择与切换
|
||||
|
||||
- 应用主题可选暖陶、雾蓝、青绿,默认暖陶。
|
||||
- 明暗模式可选“跟随系统”“浅色”“深色”,默认跟随系统。
|
||||
- “跟随系统”= 指定一个浅色时使用的主题和一个深色时使用的主题,默认都是暖陶。
|
||||
- 黑白灰是阅读专用主题,不作为应用主题,只在阅读器内生效。阅读主题可选“跟随应用”或“黑白灰”;选黑白灰时,浅色和夜间分别用它的浅色和深色取值。
|
||||
- 主题偏好存在当前设备,登录后可同步到账号。
|
||||
|
||||
## 7. CSS 实现示例
|
||||
|
||||
以暖陶为例,其他主题结构相同,换成第 3 节的取值即可。
|
||||
|
||||
```css
|
||||
[data-theme="warm"][data-mode="light"] {
|
||||
--color-bg: #FDF9F6;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-surface-2: #F8EFE8;
|
||||
--color-ink: #3A2A22;
|
||||
--color-muted: #82685B;
|
||||
--color-line: #F0E3DA;
|
||||
--color-brand: #F2B79F;
|
||||
--color-brand-soft: #FBE9E0;
|
||||
--color-on-brand: #2B1810;
|
||||
--color-brand-text: #A8482A;
|
||||
--color-ch-read: #F2B79F; --color-ch-read-soft: #FBE9E0; --color-ch-read-text: #A8482A;
|
||||
--color-ch-video: #F6CA97; --color-ch-video-soft: #FCF0DD; --color-ch-video-text: #8A5200;
|
||||
--color-ch-music: #EFA9A3; --color-ch-music-soft: #FAE4E1; --color-ch-music-text: #A8343E;
|
||||
--color-ch-audio: #E6BE9E; --color-ch-audio-soft: #F7EBDD; --color-ch-audio-text: #7A5232;
|
||||
--color-on-ch: #2B1810;
|
||||
--color-scrim: rgba(58, 42, 34, .40);
|
||||
}
|
||||
|
||||
[data-theme="warm"][data-mode="dark"] {
|
||||
--color-bg: #1F1713;
|
||||
--color-surface: #2A201A;
|
||||
--color-surface-2: #352A23;
|
||||
--color-ink: #F7EDE6;
|
||||
--color-muted: #BBA79B;
|
||||
--color-line: #43342B;
|
||||
--color-brand: #E88F73;
|
||||
--color-brand-soft: #3F2A21;
|
||||
--color-on-brand: #1A100C;
|
||||
--color-brand-text: #F0987C;
|
||||
--color-ch-read: #E88F73; --color-ch-read-soft: #3F2A21; --color-ch-read-text: #E88F73;
|
||||
--color-ch-video: #E9AA6E; --color-ch-video-soft: #3A2E1E; --color-ch-video-text: #E9AA6E;
|
||||
--color-ch-music: #E58A8A; --color-ch-music-soft: #3D2526; --color-ch-music-text: #E58A8A;
|
||||
--color-ch-audio: #D9A07F; --color-ch-audio-soft: #36291F; --color-ch-audio-text: #D9A07F;
|
||||
--color-on-ch: #1A100C;
|
||||
--color-scrim: rgba(0, 0, 0, .60);
|
||||
}
|
||||
```
|
||||
|
||||
切换方式:在根元素上设置 `data-theme`(warm、blue、green)和 `data-mode`(light、dark)。选择“跟随系统”时,由 `prefers-color-scheme` 决定 `data-mode`。阅读器容器单独设置 `data-theme="gray"`,使其不受应用主题影响。
|
||||
|
||||
## 8. 与旧主题的对应关系
|
||||
|
||||
| 旧主题 | 处理方式 |
|
||||
| --- | --- |
|
||||
| 暖纸(浅色) | 替换为暖陶浅色 |
|
||||
| 深夜(深色) | 替换为暖陶深色 |
|
||||
| 雾蓝(浅色) | 替换为新的雾蓝浅色 |
|
||||
| 蔷薇(浅色) | 取消 |
|
||||
| 原野(浅色) | 取消,由青绿浅色替代 |
|
||||
| 石墨(深色) | 取消,由雾蓝深色替代 |
|
||||
|
||||
迁移时,已选择旧主题的用户按上表映射;取消的主题统一回落到暖陶,并保留原来的明暗模式。
|
||||
+54
-12
@@ -97,7 +97,8 @@
|
||||
|
||||
### 3.2 一列,封顶
|
||||
|
||||
内容列封顶 68rem(`--shelf-measure`),宽屏加的是留白而不是更多列。
|
||||
首页、书架、影音、我的共用一条内容列:内容宽度封顶 `--page-measure`(`--collection-measure` 80rem 减去书架带内边距,约 1251px),
|
||||
两侧留白 `--page-gutter`(手机 16px,侧栏模式 32px),切换标签页时内容边缘不动。宽屏加的是留白而不是更多列。
|
||||
1600px 窗口排十一个封面、每个书名十二个字,比手机上的三列**更难扫**——
|
||||
这与「多出来的宽度是为了好读」正好相反。
|
||||
|
||||
@@ -418,7 +419,27 @@ URL 再回落到 `showPage(page)`。这一趟往返就是页能活过 Back、For
|
||||
关闭后回到 `body`;消息不抢焦点。提示出现、更新和关闭不得改变内容区尺寸或滚动位置,
|
||||
这些行为在手机和桌面 Chromium 中验证。数量摘要、搜索统计和操作说明继续作为页面内容展示。
|
||||
|
||||
### 3.7 半屏面板
|
||||
### 3.7 弹层:只有一种 Sheet
|
||||
|
||||
所有设置、编辑表单、选择列表、详情和确认框都用 `web/src/ui/sheet.tsx` 的 `Sheet`
|
||||
(确认框是 `ConfirmSheet`),样式只在 `web/src/styles/sheet.css`。不再使用居中弹窗、
|
||||
SweetAlert、Mantine `Modal`、自绘遮罩或角落浮动卡片。
|
||||
|
||||
| 视口 | 形态 |
|
||||
| --- | --- |
|
||||
| ≤700px | 底部 Sheet:圆角顶边、把手,高度随内容,最高到安全区下方;阅读器内最高 62% |
|
||||
| ≥701px | 右侧抽屉:距边 1rem,宽 30rem;`size="wide"` 宽 42rem 给复杂表单 |
|
||||
| ≥701px,`size="compact"` | 确认、单行输入、短选择列表:跟手,出现在触发它的点按位置或聚焦控件的下方(放不下则上方),宽 24rem,始终留在窗口内;找不到触发点时回到右上角 |
|
||||
|
||||
- 原生 `<dialog>` + `showModal()`:顶层、背景惰性、焦点限制在内部,关闭后焦点回到打开它的控件。
|
||||
- 三个出口始终相同:标题栏 ✕(「关闭」)、Esc、点背景;`busy` 时三者都禁用。
|
||||
- 就地渲染、不走 portal,所以继承打开它的页面主题(阅读器黑白灰、影音别名)。
|
||||
- 调用方的 `className` 只管**内容**样式,不得设置位置、尺寸、圆角、背景遮罩或标题栏。
|
||||
- 确认框焦点默认在「取消」;破坏性操作传 `danger`,按钮用危险色。
|
||||
- `⋯` 操作菜单和单选筛选(收藏类型等)用 `web/src/ui/action-menu.tsx` 的 `ActionMenu`:从触发按钮下方弹出的下拉菜单(手机和桌面一致),没有标题栏和遮罩,选中、Esc、Tab 或点外部即关闭,支持方向键;样式在 `web/src/styles/action-menu.css`。需要以问题形式出现的单选(书架的选择对话框)用 `ActionSheet`,即 compact Sheet 里的同一列菜单项。动作是数据(`label`、`onSelect`、`danger`、`checked`),菜单先关再执行动作,所以动作可以再开一个 Sheet 或跳转。不再使用 Mantine `Menu` 或 `<details>` 下拉。
|
||||
- 锚定在控件上的只有 `ActionMenu` 下拉、选择框(`MediaSelect`、`SearchableSelect`)和正文选区工具条,它们不是 Sheet。
|
||||
|
||||
以下是阅读器面板最初改成底部半屏的理由,仍然适用于手机形态:
|
||||
|
||||
目录和阅读设置是**从底部升起的半屏面板**,带遮罩和把手,上半屏仍然看得见
|
||||
正文。
|
||||
@@ -434,8 +455,8 @@ URL 再回落到 `showPage(page)`。这一趟往返就是页能活过 Back、For
|
||||
- **把手**:从底部升起的面板被理解为可拖拽的。拖动本身没有实现——把手是
|
||||
*可供性*,遮罩和 ✕ 是两个真实的出口。画一个做不到的把手比不画更糟,所以
|
||||
它只是视觉线索,真正移动的是面板自己的滚动。
|
||||
- **高度**:`min(62%, 34rem)`,且不超过 78%。一千二百章的目录想要尽可能多的
|
||||
空间,十一行的设置不想要比它需要更多的空间。
|
||||
- **高度**:随内容,阅读器内最高 `min(62dvh, 36rem)`。一千二百章的目录会撑到上限,
|
||||
十一行的设置不占比它需要更多的空间。
|
||||
|
||||
面板打开时**它拥有点击**:旁边露出的那一条正文不翻页(§7 的优先级)。
|
||||
|
||||
@@ -542,18 +563,20 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
### 3.10 手机阅读
|
||||
|
||||
- 顶部:返回、书名、当前章节。
|
||||
- 底部:上一章、本章页数滑杆、下一章;下一行是目录、朗读、界面、设置。
|
||||
- 底部:上一章、本章页数滑杆、下一章;下一行是目录、朗读、外观、设置。
|
||||
- 中间滑杆始终控制本章页数,不能变成全书进度。固定页章节只有一页时禁用滑杆。
|
||||
- 顶部左/右、底部左/右阅读信息可在“界面”分别选择书名、章节名、章节进度(章内页码与全书百分比)、时间或不显示。默认顶部左侧章节名,底部左侧章节进度,底部右侧时间。配置按设备保存,工具栏隐藏后仍显示;长文本省略。关闭信息仍保留固定边界,避免改变正文分页。日夜圆形快捷按钮随工具栏一起显隐。
|
||||
- 顶部左/右、底部左/右阅读信息可在“外观”分别选择书名、章节名、章节进度(章内页码与全书百分比)、时间或不显示。默认顶部左侧章节名,底部左侧章节进度,底部右侧时间。配置按设备保存,工具栏隐藏后仍显示;长文本省略。关闭信息仍保留固定边界,避免改变正文分页。日夜圆形快捷按钮随工具栏一起显隐。
|
||||
- 正文宿主的上下边界不依赖工具栏状态。工具栏覆盖正文,显隐不得改变页码、滚动位置、正文尺寸。
|
||||
- 四个面板使用统一底部半屏布局;界面放排版与主题,设置放翻页行为,朗读放语音设置和播放控制。
|
||||
- 四个面板使用统一底部半屏布局;外观放排版与主题,设置放翻页行为,朗读放语音设置和播放控制。
|
||||
- “外观”面板按使用频率排序:纸色(跟随主题、白纸、暖黄、浅绿、灰调、夜间)、字号、行距、主题(阅读主题:“跟随应用”或黑白灰,用各主题真实的底色与墨色绘制的色块)、明暗(跟随系统/浅色/深色)、亮度排在首屏;“更多排版”(页边距、对齐、字体,以及 TXT 的缩进、段间距、编码)和“页眉页脚”(四个阅读信息)收进默认折叠的区块。漫画不显示文字排版,只保留主题、明暗、亮度和“页眉页脚”。
|
||||
- 纸色是阅读页单独的设置(`AppSettings.paper`,按设备保存,与字号一样),只改页面的底色、文字和页眉页脚(`.reader-screen[data-paper]` 覆盖 `--reader-paper/ink/muted/line`),工具栏和面板仍用阅读主题。夜间纸色让阅读器整体变深;浅色纸色在应用为深色时默认让位给主题的深色取值,可在“跟随系统深色模式”开关关掉。外观与主题页也提供同一设置。V3 之前的 sepia/green/dark 页面主题首次读取时迁移成暖黄/浅绿/夜间。
|
||||
- TXT 默认字号为基准的 1.125 倍、行距 1.8,支持两字缩进与段间距;EPUB 默认保留作者排版。
|
||||
- 白、米黄、浅绿、夜间四个主题。漫画隐藏文字排版选项。
|
||||
- 阅读主题默认“跟随应用”;也可选阅读专用的黑白灰,只写在阅读器容器上(`data-theme="gray"`),浅色和夜间跟随应用的明暗。日夜快捷按钮把明暗设为浅色或深色(离开“跟随系统”),保留当前应用主题。电纸书预设使用黑白灰浅色。漫画隐藏文字排版选项。
|
||||
|
||||
### 3.11 朗读
|
||||
|
||||
朗读控制集中在朗读面板。上一句、播放/暂停、下一句、停止均有独立按钮;关闭面板不停止播放,再从底部“朗读”进入可暂停或停止。
|
||||
“从当前位置朗读”使用当前可见正文或选区作为起点。不可用的语音设置根据引擎能力隐藏。
|
||||
“从当前位置朗读”使用当前可见正文或选区作为起点,是面板里的第一项和唯一主按钮。其后依次是语速(滑杆加 0.8/1.0/1.2/1.5/2.0 常用档)、语音、章节播完;音调与音量收进默认折叠的“更多”。不可用的语音设置根据引擎能力隐藏。
|
||||
|
||||
## 4. 文案
|
||||
|
||||
@@ -586,9 +609,12 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
|
||||
## 6. 暗色与主题
|
||||
|
||||
三套主题:`light` / `sepia` / `dark`,通过 `:root` 上的
|
||||
`data-theme` 切换调色板变量。
|
||||
主题体系见《[Reader V3 主题语义令牌](prototypes/Reader%20V3%20主题语义令牌.md)》(v3.1):暖陶(默认)、雾蓝、青绿三个应用主题,加阅读专用的黑白灰,每个都有浅色和深色两套取值。
|
||||
|
||||
- 组件只读 `--color-*` 语义令牌(`web/src/styles/theme-tokens.css`)。`<html>` 上的 `data-theme` 与 `data-mode` 选中一套取值;一个容器可以带自己的这对属性而穿另一套主题(阅读器的黑白灰、视频和音乐播放页的深色沉浸)。
|
||||
- `brand` 和 `ch-*` 只作填充,上面的字用 `on-brand`/`on-ch`;品牌色的文字、链接、选中图标用 `brand-text`(旧名 `--reader-accent`、`--media-accent`),填充用 `--reader-fill`/`--media-fill`。选中的标签和分段控件用 `brand-soft` 底加 `brand-text` 字。
|
||||
- TypeScript 镜像在 `web/src/shared/theme/theme.ts`(预览色块、对比度);`test/theme-tokens.test.ts` 保证两边取值一致,并按规范 §5 的组合检查 8 套配色的对比度。新增或调色时两边一起改。
|
||||
- 偏好按服务器和账号存在本机(`reader.theme.v2:<scope>`):应用主题、明暗(跟随系统/浅色/深色)、跟随系统时浅色和深色各用哪个主题、阅读主题。第一次读取时把 V3 的六套主题按规范 §8 迁移(蔷薇回落到暖陶并保留浅色)。
|
||||
- 主题只改**界面**的颜色。书的颜色由书自己决定——一个硬编码
|
||||
`color: #333` 的段落,在暗色主题下保持它自己的颜色。这是正确的取舍:
|
||||
暗色主题悄悄破坏一本印刷取向的书的对比度是缺陷,而一本书在「暗色」背景上
|
||||
@@ -663,7 +689,8 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
| `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/sheet.tsx` / `web/src/styles/sheet.css` | 唯一的弹层 `Sheet` 与 `ConfirmSheet`(§3.7) |
|
||||
| `web/src/ui/dialog.tsx` | 提问(文本框/确认/选择),基于 `Sheet`,书架与文件管理共用 |
|
||||
| `web/src/ui/pager.tsx` | 翻页器,书架与书库共用(§3.5) |
|
||||
| `web/test/shelf-order.test.ts` | 书架排序(默认项、`recent` 是客户端排)、排序方向和稳定性 |
|
||||
| `web/test/shelf-order.test.ts` | 排序的方向、客户端/服务端的键、阅读时间排序与并列时的稳定 |
|
||||
@@ -680,3 +707,18 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
| `web/tools/ui-review/` | UI 评审:截图 + 尺寸断言(§7) |
|
||||
| `artifacts/ui-review/react-app/` | 当前 React 评审截图与 `verification.json`(生成,不提交) |
|
||||
| `web/src/styles/reader.css` | 令牌、组件样式、交互状态、`prefers-reduced-motion` |
|
||||
|
||||
|
||||
## 9. V3 外壳与导航
|
||||
|
||||
- 一个导航:手机底部四个标签(首页、书架、影音、我的),701px 以上改为左侧栏,影音下列出影视、音乐、有声书三个频道。取代旧的媒体频道栏。
|
||||
- 导航按页面类型显示:顶层页面显示;详情、设置、书库文件等二级页面在手机上由自己的返回箭头取代底栏,桌面端侧栏保留;阅读器和播放页不显示。规则在 `web/src/app/AppNav.tsx` 的 `navMode`。
|
||||
- 主题在应用壳层统一应用(`ThemeController` 写 `<html data-theme data-mode>`)。登录页在应用主题之前显示,使用暖陶并跟随设备深浅色。书架、书库、书源、首页、阅读器的 `--reader-*` 是 `--color-*` 的别名(`theme-tokens.css`),影音页的 `--text`、`--media-*` 别名在 `presentation.css` 顶部。
|
||||
- 频道色(阅读、影视、音乐、有声书)是每个主题自己的 `--color-ch-*`(填充)、`-soft`(卡片底)和 `-text`(文字),由 `theme-tokens.test.ts` 守住对比度。
|
||||
- 首页显示在读的书、未看完的影视和有声书、书架一行;我的页只做入口,不拥有任何设置。
|
||||
- “跟随系统”= 浅色时用的主题加深色时用的主题(默认都是暖陶),在外观与主题页指定;关掉跟随系统时保留屏幕上当前的主题与明暗,再选浅色或深色。读取与保存只走 `web/src/shared/theme/theme.ts` 的 `readThemePreference` / `saveThemePreference`;判断当前主题一律用 `resolveAppTheme` / `resolveReadingTheme`,React 里用 `useThemeState`,不要自己读存储。
|
||||
- 搜索:桌面侧栏有“搜索书名、片名、歌曲、有声书”,打开 `#/search?q=`,同时列出书库里的书和有权访问的影音,各自链接到完整的书库搜索和影音搜索。侧栏账号下方显示同步引擎对服务器的判断:已连接、正在同步、离线或同步出错。
|
||||
- 书架:搜索框下是“全部 / 在读 / 想读 / 已读”和“分组”(按书的系列或标签筛选),都写进 URL(`?status=`、`?series=`、`?tag=`)。封面左上角标出在读、想读、已读;“⋯”菜单可标记想读、在读、已读或清除标记,并保留“从书架拿掉”。状态以手动标记为准,没有标记时按进度推断。
|
||||
- 视频播放:字幕、音轨、版本是同一个“字幕与音轨”抽屉的三个分页。字幕页列出关闭、外挂(服务器上的 srt/ass/vtt)、内嵌,可“添加外挂字幕”(本机文件在浏览器里转换为 WebVTT,仅本次播放)、以 0.5 秒调整字幕延迟(外挂与本机字幕),并选择字号(小/标准/大,按设备保存)。工具栏的“全屏”进入横屏全屏:选集、倍速、适应屏幕(铺满裁边)、字幕和锁定浮在画面上,随 DPlayer 控制条一起淡出;锁定后点击不再作用于播放器,只留解锁按钮。这些只在网页播放器提供,Android 播放器用自己的菜单。
|
||||
- 媒体库管理:每个媒体库卡片显示最近一次扫描——完成时间和“重新扫描”、进行中的进度(“已处理 62%”)和“取消”、失败原因和“重试”“查看详情”。右侧“扫描与元数据”是服务器业务配置里的四个开关,逐项保存。
|
||||
- 播放页的循环模式与定时:音乐的传输行是“循环模式、上一首、播放、下一首、队列”,有声书仍是快退、播放、快进,没有播放模式。循环模式按钮依次切换顺序播放、随机、单曲循环;播放队列面板用分段控件给出同一选择(`web/src/features/media/components/play-mode.tsx`,类型与文案在 `services/play-mode.ts`)。随机在所有条目播过一遍前不重复,上一首沿已播顺序回退;单曲循环不受“连续播放”偏好影响,手动下一首仍按队列顺序。睡眠定时抽屉在分钟档位之外提供“播完本章/本曲/本集”:当前条目结束后停下一次并保留进度,与分钟定时互斥,选中项带按下态,触发按钮显示“播完本曲”等。模式与播完即停只在网页播放器可用——Android 原生播放服务没有对应命令,此时循环按钮置灰、队列面板不显示分段控件、“播完…”置灰并说明原因。视频控制与设置页里的睡眠定时暂未接入“播完即停”。
|
||||
@@ -47,7 +47,7 @@
|
||||
| 分页 / 滚动 | 按屏翻页或连续阅读 |
|
||||
| 字号、行距、页边距、对齐 | 调整正文,部分设置可选择沿用原书 |
|
||||
| 字体 | 在原字体栈之前加入选定字体 |
|
||||
| 主题 | 白、米黄、浅绿、夜间 |
|
||||
| 主题 | 跟随应用或黑白灰;明暗可跟随系统、浅色或深色 |
|
||||
| 亮度 | 界面叠加调暗,不等同于修改设备系统亮度 |
|
||||
| 图片适配 | 固定版式内容完整显示或适宽 |
|
||||
| 翻页方向 | 漫画等固定版式内容支持左右方向切换 |
|
||||
|
||||
@@ -21,6 +21,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS source_single_default ON source_instances(is_d
|
||||
ALTER TABLE book_files ADD COLUMN parse_version INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE books ADD COLUMN content_hash_kind TEXT NOT NULL DEFAULT 'file';
|
||||
ALTER TABLE chapter_resources ADD COLUMN media_type TEXT NOT NULL DEFAULT 'text/plain';
|
||||
ALTER TABLE user_books ADD COLUMN reading_status TEXT;
|
||||
`;
|
||||
|
||||
export const SCHEMA_SQL = `
|
||||
|
||||
@@ -19,6 +19,7 @@ import { FilePublications } from '../../publications/files.ts';
|
||||
import { withSignal } from '../request-signal.ts';
|
||||
import { searchBookPage } from '../../services/book-search.ts';
|
||||
import { ReadingOverrideService } from '../../services/reading-overrides.ts';
|
||||
import { READING_STATES, type ReadingState } from '../../services/shelf.ts';
|
||||
|
||||
/**
|
||||
* The handler that owns a book, resolved from its recorded format.
|
||||
@@ -118,6 +119,8 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
|
||||
// the only reason a card on it can offer 加入书架 at all. Any other value is
|
||||
// the shelf, which is where the parameter's absence already pointed.
|
||||
...(q.scope === 'library' ? { scope: 'library' as const } : {}),
|
||||
// 在读 / 想读 / 已读; an unknown value is rejected by the service.
|
||||
...(q.status ? { status: q.status as ReadingState } : {}),
|
||||
...(q.sort !== undefined ? { sort: q.sort as 'title' } : {}),
|
||||
...(q.order !== undefined ? { order: q.order as 'asc' } : {}),
|
||||
...(q.page !== undefined ? { page: Number.parseInt(q.page, 10) || 1 } : {}),
|
||||
@@ -125,6 +128,14 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
|
||||
});
|
||||
});
|
||||
|
||||
// 想读 / 在读 / 已读 mark on the reader's shelf; `null` clears it so progress decides.
|
||||
app.put('/api/v1/books/:id/reading-status', { preHandler: auth, schema: { body: { type: 'object', additionalProperties: false, required: ['status'], properties: { status: { anyOf: [{ type: 'string', enum: [...READING_STATES] }, { type: 'null' }] } } } } }, async (request, reply) => {
|
||||
const user = currentUser(request);
|
||||
const { id } = request.params as { id: string };
|
||||
ctx.shelf.setReadingStatus(user.id, id, (request.body as { status: ReadingState | null }).status);
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
app.get('/api/v1/books/:id', { preHandler: auth }, async (request) => {
|
||||
const user = currentUser(request);
|
||||
const { id } = request.params as { id: string };
|
||||
|
||||
@@ -25,6 +25,7 @@ import {MediaDirectoryRules} from '../../media/directory-rules.ts';
|
||||
import {MediaRecognitionReview} from '../../media/recognition-review.ts';
|
||||
import type {VideoRule} from '../../media/video-recognition.ts';
|
||||
import { MediaScrapeJobs,SCRAPE_RESULT_STATES } from '../../media/scrape-jobs.ts';
|
||||
import { MediaAutomation } from '../../media/automation.ts';
|
||||
import { MediaBackgroundGrants } from '../../media/background-grants.ts';
|
||||
import { DatabaseMediaAccounts, MediaAccountReferences } from '../../media/accounts.ts';
|
||||
import type { MediaDatabase } from '../../media/read-database.ts';
|
||||
@@ -50,8 +51,11 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op
|
||||
const hierarchy=new MediaHierarchy(database,scanner.catalog);
|
||||
const scrapeJobs=new MediaScrapeJobs(database,scanner.catalog,scraping,accounts);
|
||||
const artwork=new MediaArtwork(database,libraries);
|
||||
const subtitles=new MediaSubtitles(database,libraries);
|
||||
const lyrics=new MediaLyrics(database,libraries);
|
||||
const embeddedEnabled=()=>settings.read('scanning').mediaEmbedded;
|
||||
const subtitles=new MediaSubtitles(database,libraries,undefined,embeddedEnabled);
|
||||
const lyrics=new MediaLyrics(database,libraries,embeddedEnabled);
|
||||
// 扫描与元数据 (业务配置 · 扫描设置): hourly automatic scans, and scraping after a scan.
|
||||
const automation=new MediaAutomation(ctx.db,database,libraries,scanner,scraping,scrapeJobs,()=>settings.read('scanning'),app.log);
|
||||
const folders=new MediaFolders(database,libraries,scanner.catalog);
|
||||
const cleanup=new MediaCleanup(database,libraries,scanner.catalog);
|
||||
const directoryRules=new MediaDirectoryRules(database),recognition=new MediaRecognitionReview(database,libraries,scanner.catalog,directoryRules);
|
||||
@@ -134,7 +138,7 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op
|
||||
preHandler:scrapeAdmin,schema:{body:{type:'object',additionalProperties:false,required:['candidateId'],properties:{candidateId:{type:'string',minLength:1,maxLength:100}}}},
|
||||
},async request=>scraping.confirm(currentUser(request),request.params.id,request.body.candidateId));
|
||||
app.delete<{Params:{id:string}}>('/api/v1/media/items/:id/match',{preHandler:scrapeAdmin},async request=>scraping.clear(currentUser(request),request.params.id));
|
||||
app.addHook('onClose',async()=>{await ai.close();await catalogReader?.close();await scrapeJobs.close();await scanner.close();});
|
||||
app.addHook('onClose',async()=>{automation.stop();await ai.close();await catalogReader?.close();await scrapeJobs.close();await scanner.close();});
|
||||
const pagination={type:'object',properties:{offset:{type:'integer',minimum:0,default:0},limit:{type:'integer',minimum:1,maximum:200,default:100}}};
|
||||
app.get('/api/v1/media/libraries',{preHandler:auth},async request=>({items:libraries.list(currentUser(request))}));
|
||||
app.get('/api/v1/media/library-summaries',{preHandler:scrapeAdmin},async(request,reply)=>{reply.header('cache-control','private, no-store');return queryCatalog(request,reply,{method:'librarySummaries',args:[currentUser(request)]});});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { MediaDatabase } from './read-database.ts';
|
||||
import type { MediaActor, MediaLibraries, MediaLibraryKind } from './libraries.ts';
|
||||
import type { MediaScanner, ScanFinished } from './scanner.ts';
|
||||
import type { MediaScraping } from './scraping.ts';
|
||||
import type { MediaScrapeJobs } from './scrape-jobs.ts';
|
||||
import type { MediaItemKind } from './catalog.ts';
|
||||
|
||||
export const AUTO_SCAN_INTERVAL_MS = 60 * 60 * 1000;
|
||||
/** The items a library is scraped by: the top of each hierarchy, never episodes or tracks. */
|
||||
const scrapeKinds: Record<MediaLibraryKind, MediaItemKind[]> = { video: ['movie', 'series'], music: ['album'], audiobook: ['audiobook'] };
|
||||
|
||||
interface Settings { mediaAutoScan: boolean; mediaScrape: boolean }
|
||||
interface Log { info(data: object, message: string): void; warn(data: object, message: string): void }
|
||||
|
||||
/**
|
||||
* 扫描与元数据 on the media library page:
|
||||
* - 自动扫描新文件: every library is rescanned hourly (unchanged files reuse their probe);
|
||||
* - 刮削封面与简介: after a scan completes, items that have neither online metadata
|
||||
* nor an earlier scrape attempt are scraped with the first configured provider that
|
||||
* supports them. One attempt per item, so an unmatched film is not retried hourly.
|
||||
*
|
||||
* Automatic work runs as the first active administrator; with none, it does nothing.
|
||||
*/
|
||||
export class MediaAutomation {
|
||||
private timer: ReturnType<typeof setInterval> | undefined;
|
||||
constructor(
|
||||
private readonly accounts: Pick<MediaDatabase, 'get'>,
|
||||
private readonly db: MediaDatabase,
|
||||
private readonly libraries: MediaLibraries,
|
||||
private readonly scanner: MediaScanner,
|
||||
private readonly scraping: MediaScraping,
|
||||
private readonly scrapeJobs: MediaScrapeJobs,
|
||||
private readonly settings: () => Settings,
|
||||
private readonly log?: Log,
|
||||
interval = AUTO_SCAN_INTERVAL_MS,
|
||||
) {
|
||||
scanner.onFinished = event => this.afterScan(event);
|
||||
if (interval > 0) { this.timer = setInterval(() => this.scanAll(), interval); this.timer.unref?.(); }
|
||||
}
|
||||
|
||||
stop() { if (this.timer) clearInterval(this.timer); this.timer = undefined; }
|
||||
|
||||
private systemActor(): MediaActor | null {
|
||||
const row = this.accounts.get<{ id: string }>("SELECT id FROM users WHERE role='admin' AND disabled=0 ORDER BY created_at,id LIMIT 1");
|
||||
return row ? { id: row.id, role: 'admin' } : null;
|
||||
}
|
||||
|
||||
/** Starts a scan of every library not already scanning. Returns the libraries started. */
|
||||
scanAll(): string[] {
|
||||
if (!this.settings().mediaAutoScan) return [];
|
||||
const actor = this.systemActor();
|
||||
if (!actor) return [];
|
||||
const started: string[] = [];
|
||||
for (const library of this.libraries.list(actor)) {
|
||||
if (this.scanner.isScanning(library.id)) continue;
|
||||
try { this.scanner.start(actor, library.id); started.push(library.id); }
|
||||
catch (error) { this.log?.warn({ err: error, libraryId: library.id }, 'automatic media scan skipped'); }
|
||||
}
|
||||
if (started.length) this.log?.info({ libraries: started.length }, 'automatic media scan started');
|
||||
return started;
|
||||
}
|
||||
|
||||
/** Items in a library that were never scraped and have no online metadata. */
|
||||
unscraped(libraryId: string, kinds: readonly MediaItemKind[], limit = 500): string[] {
|
||||
if (!kinds.length) return [];
|
||||
return this.db.all<{ id: string }>(`SELECT i.id FROM media_items i WHERE i.library_id=? AND i.kind IN (${kinds.map(() => '?').join(',')})
|
||||
AND NOT EXISTS (SELECT 1 FROM media_online_metadata m WHERE m.item_id=i.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM media_scrape_job_items j WHERE j.item_id=i.id)
|
||||
ORDER BY i.rowid DESC LIMIT ?`, libraryId, ...kinds, limit).map(row => row.id);
|
||||
}
|
||||
|
||||
/** Returns the started scrape job's id, or null when there was nothing to do. */
|
||||
afterScan(event: ScanFinished): string | null {
|
||||
if (event.state !== 'complete' || !this.settings().mediaScrape) return null;
|
||||
const actor: MediaActor = event.actor.role === 'admin' ? event.actor : this.systemActor() ?? event.actor;
|
||||
try {
|
||||
const library = this.libraries.get(actor, event.libraryId);
|
||||
const wanted = scrapeKinds[library.kind];
|
||||
for (const provider of this.scraping.status(actor).items) {
|
||||
if (!provider.configured) continue;
|
||||
const ids = this.unscraped(library.id, wanted.filter(kind => provider.kinds.includes(kind)));
|
||||
if (!ids.length) continue;
|
||||
const job = this.scrapeJobs.start(actor, provider.id, ids, 'strong');
|
||||
this.log?.info({ libraryId: library.id, provider: provider.id, items: ids.length }, 'automatic scrape started');
|
||||
return job.id;
|
||||
}
|
||||
} catch (error) {
|
||||
// Typically another scrape job is running; the next scan tries again.
|
||||
this.log?.warn({ err: error, libraryId: event.libraryId }, 'automatic scrape skipped');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function parseLyrics(bytes:Uint8Array):Omit<Lyrics,'source'> {
|
||||
|
||||
/** Part IDs resolve through the catalog; the client never supplies a file path. */
|
||||
export class MediaLyrics {
|
||||
constructor(private readonly db:MediaDatabase,private readonly libraries:MediaLibraries){}
|
||||
constructor(private readonly db:MediaDatabase,private readonly libraries:MediaLibraries,private readonly embeddedEnabled:()=>boolean=()=>true){}
|
||||
private asset(actor:MediaActor,partId:string){
|
||||
const asset=this.db.get<{id:string;library_id:string;ref:string;available:number;technical_json:string|null}>(`SELECT a.id,a.library_id,a.ref,a.available,a.technical_json FROM media_assets a JOIN media_parts p ON p.asset_id=a.id WHERE p.id=? AND p.active=1`,partId);
|
||||
if(!asset)throw notFound('media part not found');
|
||||
@@ -54,7 +54,7 @@ export class MediaLyrics {
|
||||
const {stream}=await storage.open(sidecar.ref);const chunks:Buffer[]=[];let size=0;
|
||||
for await(const chunk of stream){size+=chunk.length;if(size>MAX_BYTES){stream.destroy();throw invalid();}chunks.push(Buffer.from(chunk));}
|
||||
bytes=Buffer.concat(chunks);source='sidecar';
|
||||
}else{
|
||||
}else if(this.embeddedEnabled()){
|
||||
const info:MediaTechnicalInfo|null=asset.technical_json?JSON.parse(asset.technical_json):null;
|
||||
const tag=Object.entries(info?.tags??{}).find(([key])=>/^(?:lyrics|unsyncedlyrics|unsynced_lyrics)(?:-|$)/i.test(key))?.[1];
|
||||
if(tag){bytes=Buffer.from(tag);source='embedded';}
|
||||
|
||||
@@ -27,10 +27,15 @@ export interface MediaAsset {
|
||||
}
|
||||
export interface MediaScanJob {
|
||||
id:string; libraryId:string; state:'queued'|'running'|'complete'|'failed'|'cancelled';
|
||||
inspected:number; error:string|null; startedAt:number; finishedAt:number|null;
|
||||
/** Files checked so far, and (once the listing finished) how many there are in all. */
|
||||
inspected:number; total:number|null; error:string|null; startedAt:number; finishedAt:number|null;
|
||||
diagnostics?:ScanDiagnostics;
|
||||
}
|
||||
interface JobRow { id:string;library_id:string;state:MediaScanJob['state'];inspected:number;error:string|null;started_at:number;finished_at:number|null;diagnostics_json:string|null }
|
||||
interface JobRow { id:string;library_id:string;state:MediaScanJob['state'];inspected:number;total:number|null;error:string|null;started_at:number;finished_at:number|null;diagnostics_json:string|null }
|
||||
/** `hidden`: also scan names starting with "." (the default here keeps a bare scanner's old behaviour). */
|
||||
export interface ScanSettings { libraries:number; files:number; mediaHidden?:boolean }
|
||||
export interface ScanFinished { actor:MediaActor; libraryId:string; jobId:string; state:'complete'|'failed'|'cancelled' }
|
||||
const hiddenRef=(ref:string)=>ref.split('/').some(segment=>segment.startsWith('.'));
|
||||
|
||||
/** Stage a complete snapshot before publishing it; failed scans never mark missing. */
|
||||
export class MediaScanner {
|
||||
@@ -40,7 +45,9 @@ export class MediaScanner {
|
||||
private running=0;
|
||||
private pending:Array<()=>void>=[];
|
||||
private readonly traces=new Map<string,ScanTrace>();
|
||||
constructor(db:MediaDatabase & Pick<Db,'prepare'>,private readonly libraries:MediaLibraries,private readonly probe:MediaProbe=probeMedia,private readonly logger?:ScanLogger,private readonly settings=()=>({libraries:2,files:4})) {
|
||||
/** Called once per scan when it ends, e.g. to start automatic scraping. Errors are the caller's. */
|
||||
onFinished?:(event:ScanFinished)=>void;
|
||||
constructor(db:MediaDatabase & Pick<Db,'prepare'>,private readonly libraries:MediaLibraries,private readonly probe:MediaProbe=probeMedia,private readonly logger?:ScanLogger,private readonly settings:()=>ScanSettings=()=>({libraries:2,files:4,mediaHidden:true})) {
|
||||
this.db=new MediaStatementDatabase(db);
|
||||
db.transaction(()=>{
|
||||
db.run(`CREATE TABLE IF NOT EXISTS media_assets (
|
||||
@@ -57,6 +64,8 @@ export class MediaScanner {
|
||||
db.run('CREATE INDEX IF NOT EXISTS media_scan_jobs_library_time ON media_scan_jobs(library_id,started_at)');
|
||||
if(!db.all<{name:string}>('PRAGMA table_info(media_scan_jobs)').some(column=>column.name==='diagnostics_json'))
|
||||
db.run('ALTER TABLE media_scan_jobs ADD COLUMN diagnostics_json TEXT');
|
||||
if(!db.all<{name:string}>('PRAGMA table_info(media_scan_jobs)').some(column=>column.name==='total'))
|
||||
db.run('ALTER TABLE media_scan_jobs ADD COLUMN total INTEGER');
|
||||
db.run(`CREATE TABLE IF NOT EXISTS media_scan_stage (
|
||||
job_id TEXT NOT NULL REFERENCES media_scan_jobs(id) ON DELETE CASCADE,ref TEXT NOT NULL,payload TEXT NOT NULL,
|
||||
PRIMARY KEY(job_id,ref))`);
|
||||
@@ -77,7 +86,7 @@ export class MediaScanner {
|
||||
const execute=()=>{
|
||||
entry.cancelQueued=undefined;this.running++;
|
||||
this.db.run("UPDATE media_scan_jobs SET state='running' WHERE id=?",id);
|
||||
void this.scan(actor,libraryId,id,controller.signal,options.files).finally(()=>{this.active.delete(libraryId);this.running--;finish();this.drain();});
|
||||
void this.scan(actor,libraryId,id,controller.signal,options.files,options.mediaHidden!==false).finally(()=>{this.active.delete(libraryId);this.running--;finish();this.drain();this.finished(actor,libraryId,id);});
|
||||
};
|
||||
const entry:{controller:AbortController;done:Promise<void>;cancelQueued:(()=>void)|undefined}={controller,done,cancelQueued:()=>{
|
||||
this.pending=this.pending.filter(task=>task!==execute);this.active.delete(libraryId);
|
||||
@@ -88,6 +97,13 @@ export class MediaScanner {
|
||||
}
|
||||
|
||||
private drain(){while(this.running<this.settings().libraries&&this.pending.length)this.pending.shift()!();}
|
||||
private finished(actor:MediaActor,libraryId:string,jobId:string){
|
||||
const state=this.db.get<{state:MediaScanJob['state']}>('SELECT state FROM media_scan_jobs WHERE id=?',jobId)?.state;
|
||||
if(state!=='complete'&&state!=='failed'&&state!=='cancelled')return;
|
||||
try{this.onFinished?.({actor,libraryId,jobId,state});}catch(error){this.logger?.warn?.({err:error,libraryId},'scan completion hook failed');}
|
||||
}
|
||||
/** Libraries with a scan queued or running right now. */
|
||||
isScanning(libraryId:string):boolean{return this.active.has(libraryId);}
|
||||
startAll(actor:MediaActor){
|
||||
if(actor.role!=='admin')throw forbidden('admin role required','ADMIN_REQUIRED');
|
||||
const items:MediaScanJob[]=[],skipped:string[]=[];
|
||||
@@ -129,7 +145,7 @@ export class MediaScanner {
|
||||
}
|
||||
|
||||
private jobDto(row:JobRow):MediaScanJob {
|
||||
return {id:row.id,libraryId:row.library_id,state:row.state,inspected:row.inspected,error:row.error,startedAt:row.started_at,finishedAt:row.finished_at,
|
||||
return {id:row.id,libraryId:row.library_id,state:row.state,inspected:row.inspected,total:row.total??null,error:row.error,startedAt:row.started_at,finishedAt:row.finished_at,
|
||||
diagnostics:this.traces.get(row.id)?.snapshot()??(row.diagnostics_json?JSON.parse(row.diagnostics_json):undefined)};
|
||||
}
|
||||
|
||||
@@ -154,7 +170,7 @@ export class MediaScanner {
|
||||
probe:{status:row.probe_status,info:row.technical_json?JSON.parse(row.technical_json):null}};
|
||||
}
|
||||
|
||||
private async scan(actor:MediaActor,libraryId:string,jobId:string,signal:AbortSignal,files=4):Promise<void> {
|
||||
private async scan(actor:MediaActor,libraryId:string,jobId:string,signal:AbortSignal,files=4,hidden=true):Promise<void> {
|
||||
const trace=new ScanTrace(jobId,libraryId,this.logger);this.traces.set(jobId,trace);trace.note('开始扫描');
|
||||
const heartbeat=setInterval(()=>{
|
||||
trace.note('扫描进度');
|
||||
@@ -204,7 +220,8 @@ export class MediaScanner {
|
||||
// every batch before failure/cleanup so no worker writes after staging is removed.
|
||||
const concurrency=storage.filePath?1:files;
|
||||
const entries:StorageEntry[]=[];const iterator=storage.list(signal)[Symbol.asyncIterator]();
|
||||
try{for(;;){const next=await trace.measure('list',undefined,()=>iterator.next());if(next.done)break;if(supported.has(extname(next.value.ref).toLowerCase()))entries.push(next.value);}}finally{await iterator.return?.();}
|
||||
try{for(;;){const next=await trace.measure('list',undefined,()=>iterator.next());if(next.done)break;if(!hidden&&hiddenRef(next.value.ref))continue;if(supported.has(extname(next.value.ref).toLowerCase()))entries.push(next.value);}}finally{await iterator.return?.();}
|
||||
this.db.run('UPDATE media_scan_jobs SET total=? WHERE id=?',entries.length,jobId);
|
||||
if(library.kind==='video'){
|
||||
const groups=new Map<string,StorageEntry[]>();
|
||||
for(const entry of entries){const rule=rules.filter(value=>withinDirectory(entry.ref,value.path)).sort((a,b)=>b.path.length-a.path.length)[0];if(!rule||!['series','season'].includes(rule.mode))continue;const key=rule.path+'\0'+String(rule.season??'正片');const group=groups.get(key)??[];group.push(entry);groups.set(key,group);}
|
||||
|
||||
@@ -74,7 +74,8 @@ export function subtitleToVtt(bytes: Uint8Array, format: string): string {
|
||||
|
||||
/** Subtitle IDs are derived from an authorized asset's matching sidecars, not arbitrary paths. */
|
||||
export class MediaSubtitles {
|
||||
constructor(private readonly db:MediaDatabase, private readonly libraries: MediaLibraries, private readonly extract=extractEmbeddedSubtitle) {}
|
||||
/** `embedded`: whether streams inside the video are offered (业务配置 · 读取内嵌字幕与歌词). */
|
||||
constructor(private readonly db:MediaDatabase, private readonly libraries: MediaLibraries, private readonly extract=extractEmbeddedSubtitle, private readonly embeddedEnabled:()=>boolean=()=>true) {}
|
||||
private asset(actor: MediaActor, id: string): Asset {
|
||||
const asset = this.db.get<Asset>('SELECT id,library_id,ref,available,technical_json FROM media_assets WHERE id=?', id);
|
||||
if (!asset) throw notFound('media asset not found');
|
||||
@@ -83,6 +84,7 @@ export class MediaSubtitles {
|
||||
return asset;
|
||||
}
|
||||
private embedded(asset:Asset) {
|
||||
if(!this.embeddedEnabled())return [];
|
||||
const info:MediaTechnicalInfo|null=asset.technical_json?JSON.parse(asset.technical_json):null;
|
||||
if(!['.mp4','.m4v','.mov','.mkv','.webm'].includes(posix.extname(asset.ref).toLowerCase()))return [];
|
||||
return (info?.streams??[]).filter(stream=>stream.type==='subtitle'&&Number.isSafeInteger(stream.index)&&stream.index>=0&&
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface BusinessValues {
|
||||
tmdb:{enabled:boolean;token:string;apiKey:string;language:string};
|
||||
musicbrainz:{enabled:boolean;userAgent:string};
|
||||
tts:{enabled:boolean;url:string;token:string;voicesUrl:string;timeoutMs:number;cacheMaxBytes:number};
|
||||
scanning:{interval:number;libraries:number;files:number};
|
||||
scanning:{interval:number;libraries:number;files:number;mediaAutoScan:boolean;mediaScrape:boolean;mediaEmbedded:boolean;mediaHidden:boolean};
|
||||
scraping:{timeoutMs:number;retries:number};
|
||||
access:{publicUrl:string;corsOrigins:string;accessTokenTtl:number;refreshTokenTtl:number};
|
||||
webdav:{url:string;username:string;password:string;maxBytes:number};
|
||||
@@ -51,6 +51,10 @@ export const SETTINGS_SCHEMA:Record<SettingsGroup,{label:string;fields:Field[]}>
|
||||
{key:'interval',label:'阅读书库自动检查间隔(秒,0 关闭)',type:'number',default:60,min:0,max:604800,help:'统一原定时扫描与变化检查;保存后重新计时'},
|
||||
{key:'libraries',label:'同时扫描的媒体库数',type:'number',default:2,min:1,max:4},
|
||||
{key:'files',label:'远端文件处理并发数',type:'number',default:4,min:1,max:8},
|
||||
{key:'mediaAutoScan',label:'自动扫描新文件',type:'checkbox',default:true,help:'影音库每小时检查一次新增和变化的文件'},
|
||||
{key:'mediaScrape',label:'刮削封面与简介',type:'checkbox',default:false,help:'扫描完成后,用已配置的 TMDB 或 MusicBrainz 为还没有在线资料的作品刮削'},
|
||||
{key:'mediaEmbedded',label:'读取内嵌字幕与歌词',type:'checkbox',default:true},
|
||||
{key:'mediaHidden',label:'扫描隐藏文件夹',type:'checkbox',default:false,help:'名称以“.”开头的文件夹和文件;关闭后它们在下次扫描时视为不存在'},
|
||||
]},
|
||||
scraping:{label:'刮削高级设置',fields:[
|
||||
{key:'timeoutMs',label:'请求超时(毫秒)',type:'number',default:12000,min:1000,max:120000},
|
||||
@@ -158,7 +162,7 @@ function legacySettings<K extends SettingsGroup>(group:K,config:AppConfig):Busin
|
||||
tmdb:{enabled:!!(text('MEDIA_TMDB_TOKEN')||text('MEDIA_TMDB_API_KEY')),token:text('MEDIA_TMDB_TOKEN'),apiKey:text('MEDIA_TMDB_API_KEY'),language:'zh-CN'},
|
||||
musicbrainz:{enabled:!!text('MEDIA_MUSICBRAINZ_USER_AGENT'),userAgent:text('MEDIA_MUSICBRAINZ_USER_AGENT')},
|
||||
tts:{enabled:!!text('TTS_URL'),url:text('TTS_URL'),token:text('TTS_TOKEN'),voicesUrl:text('TTS_VOICES_URL'),timeoutMs:integer('TTS_TIMEOUT_MS',20000),cacheMaxBytes:integer('TTS_CACHE_BYTES',268435456)},
|
||||
scanning:{interval:intervals.length?Math.min(...intervals):0,libraries:2,files:4},
|
||||
scanning:{interval:intervals.length?Math.min(...intervals):0,libraries:2,files:4,mediaAutoScan:true,mediaScrape:false,mediaEmbedded:true,mediaHidden:false},
|
||||
access:{publicUrl:config.publicUrl,corsOrigins:config.corsOrigins.join(','),accessTokenTtl:config.accessTokenTtl,refreshTokenTtl:config.refreshTokenTtl},
|
||||
webdav:{url:text('WEBDAV_URL'),username:text('WEBDAV_USERNAME'),password:text('WEBDAV_PASSWORD'),maxBytes:integer('WEBDAV_MAX_BYTES',2147483648)},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { applyOverrides, type BaseMetadata } from './merge.ts';
|
||||
import { safeJsonParse } from '../lib/text.ts';
|
||||
import { notFound } from '../lib/errors.ts';
|
||||
import { badRequest, notFound } from '../lib/errors.ts';
|
||||
|
||||
export interface BookDto {
|
||||
id: string;
|
||||
@@ -51,6 +51,11 @@ export interface BookDto {
|
||||
* the library, add it to your shelf" could not say which of the two a card was.
|
||||
*/
|
||||
shelfState?: 'on' | 'off';
|
||||
/**
|
||||
* 在读 / 想读 / 已读, on the shelf's own list only (see READING_STATE). Absent
|
||||
* elsewhere, so a single-book lookup never reports a state it did not compute.
|
||||
*/
|
||||
readingState?: ReadingState | null;
|
||||
}
|
||||
|
||||
interface BookRow {
|
||||
@@ -59,6 +64,8 @@ interface BookRow {
|
||||
added_at?: number;
|
||||
/** `user_books.hidden`, or `null` when there is no row at all. */
|
||||
shelf_hidden?: number | null;
|
||||
/** READING_STATE, selected only by the shelf's own list. */
|
||||
reading_state?: ReadingState | null;
|
||||
identifier: string | null;
|
||||
content_hash: string;
|
||||
format: string;
|
||||
@@ -79,6 +86,23 @@ interface BookRow {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export const READING_STATES = ['reading', 'want', 'finished'] as const;
|
||||
export type ReadingState = typeof READING_STATES[number];
|
||||
/**
|
||||
* A book's reading state on this account's shelf.
|
||||
*
|
||||
* An explicit 在读 or 已读 mark is the reader's word and wins. Otherwise progress
|
||||
* decides: 99% or more is 已读, anything started is 在读. 想读 is only ever a mark, and
|
||||
* it gives way as soon as the book is opened and read — a book being read is not
|
||||
* one the reader still "wants to read".
|
||||
*/
|
||||
const READING_STATE = `CASE
|
||||
WHEN ub.reading_status IN ('reading','finished') THEN ub.reading_status
|
||||
WHEN rp.percentage >= 0.99 THEN 'finished'
|
||||
WHEN rp.percentage > 0 THEN 'reading'
|
||||
WHEN ub.reading_status = 'want' THEN 'want'
|
||||
ELSE NULL END`;
|
||||
|
||||
const BOOK_COLUMNS = `id, identifier, content_hash, format, title, author, publisher, language, isbn,
|
||||
description, series, series_index, tags, pubdate, cover_path, file_size, page_count, source, updated_at`;
|
||||
|
||||
@@ -117,6 +141,8 @@ export interface ListOptions {
|
||||
* on the shelf yet.
|
||||
*/
|
||||
scope?: 'shelf' | 'library';
|
||||
/** 在读 / 想读 / 已读 filter; shelf scope only. */
|
||||
status?: ReadingState;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -223,6 +249,12 @@ export class ShelfService {
|
||||
params.push(`${escapeLike(prefix)}/%`);
|
||||
}
|
||||
|
||||
// 在读 / 想读 / 已读 reads reading_progress through the shelf join (below).
|
||||
if (!library && options.status) {
|
||||
if (!READING_STATES.includes(options.status)) throw badRequest('invalid reading status');
|
||||
where.push(`(${READING_STATE}) = ?`);
|
||||
params.push(options.status);
|
||||
}
|
||||
const sortColumn = {
|
||||
title: 'b.title',
|
||||
author: 'b.author',
|
||||
@@ -242,14 +274,14 @@ export class ShelfService {
|
||||
const orderColumn = sortColumn;
|
||||
const join = library
|
||||
? 'LEFT JOIN user_books ub ON ub.book_id = b.id AND ub.user_id = ?'
|
||||
: 'JOIN user_books ub ON ub.book_id = b.id';
|
||||
: 'JOIN user_books ub ON ub.book_id = b.id LEFT JOIN reading_progress rp ON rp.user_id = ub.user_id AND rp.book_id = b.id';
|
||||
const joinParams: Array<string | number> = library ? [userId] : [];
|
||||
/*
|
||||
* `hidden` is selected only in library scope: on the shelf every row is
|
||||
* `hidden = 0` by construction, and a selected constant would become a field the
|
||||
* client reads as if it could say something.
|
||||
*/
|
||||
const shelfColumn = library ? ', ub.hidden AS shelf_hidden' : '';
|
||||
const shelfColumn = library ? ', ub.hidden AS shelf_hidden' : `, (${READING_STATE}) AS reading_state`;
|
||||
|
||||
const totalRow = this.db.get<{ n: number }>(
|
||||
`SELECT COUNT(*) AS n FROM books b ${join} WHERE ${whereSql}`,
|
||||
@@ -362,6 +394,7 @@ export class ShelfService {
|
||||
...(row.shelf_hidden === undefined
|
||||
? {}
|
||||
: { shelfState: row.shelf_hidden === 0 ? ('on' as const) : ('off' as const) }),
|
||||
...(row.reading_state === undefined ? {} : { readingState: row.reading_state }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -396,6 +429,17 @@ export class ShelfService {
|
||||
this.db.run('UPDATE books SET updated_at = ? WHERE id = ?', Date.now(), bookId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a book on this account's shelf 想读, 在读 or 已读, or clears the mark
|
||||
* (`null`) so progress decides again. A book not on the shelf has no state.
|
||||
*/
|
||||
setReadingStatus(userId: string, bookId: string, status: ReadingState | null): void {
|
||||
if (status !== null && !READING_STATES.includes(status)) throw badRequest('invalid reading status');
|
||||
const row = this.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM user_books WHERE user_id = ? AND book_id = ? AND hidden = 0', userId, bookId);
|
||||
if (!row?.n) throw notFound('book not on shelf');
|
||||
this.db.run('UPDATE user_books SET reading_status = ? WHERE user_id = ? AND book_id = ?', status, userId, bookId);
|
||||
}
|
||||
|
||||
/** Library-wide facets, used to build the client's filter UI. */
|
||||
facets(userId: string): { authors: string[]; series: string[]; tags: string[]; formats: string[] } {
|
||||
const rows = this.db.all<{ author: string; series: string; tags: string; format: string }>(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Db } from '../src/db/index.ts';
|
||||
import { MediaLibraries } from '../src/media/libraries.ts';
|
||||
import { MediaScanner, type ScanFinished } from '../src/media/scanner.ts';
|
||||
import { MediaScraping } from '../src/media/scraping.ts';
|
||||
import { MediaScrapeJobs } from '../src/media/scrape-jobs.ts';
|
||||
import { MediaAutomation } from '../src/media/automation.ts';
|
||||
import { MediaSubtitles } from '../src/media/subtitles.ts';
|
||||
import { BusinessSettingsReader, settingsDefaults } from '../src/services/business-settings.ts';
|
||||
|
||||
const admin = { id: 'admin', role: 'admin' } as const;
|
||||
const probe = async () => ({ status: 'unavailable' as const, info: null });
|
||||
|
||||
test('scan jobs report a total, and hidden names are skipped unless the setting allows them', async t => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'media-hidden-')), db = new Db(':memory:'), libraries = new MediaLibraries(db);
|
||||
let hidden = false;
|
||||
const finished: ScanFinished[] = [];
|
||||
const scanner = new MediaScanner(db, libraries, probe, undefined, () => ({ libraries: 1, files: 1, mediaHidden: hidden }));
|
||||
scanner.onFinished = event => finished.push(event);
|
||||
t.after(async () => { await scanner.close(); db.close(); await rm(root, { recursive: true, force: true }); });
|
||||
await mkdir(join(root, '.thumbs')); await writeFile(join(root, '.thumbs', 'clip.mp4'), 'x');
|
||||
await writeFile(join(root, '._film.mp4'), 'x'); await writeFile(join(root, 'film.mp4'), 'x');
|
||||
const library = await libraries.create(admin, { name: 'Films', kind: 'video', root, access: 'all' });
|
||||
const scan = async () => { const job = scanner.start(admin, library.id); await scanner.wait(library.id); return scanner.job(admin, job.id); };
|
||||
const first = await scan();
|
||||
assert.equal(first.state, 'complete');
|
||||
assert.deepEqual([first.inspected, first.total], [1, 1]);
|
||||
assert.deepEqual(scanner.assets(admin, library.id).items.map(asset => asset.ref), ['film.mp4']);
|
||||
hidden = true;
|
||||
const second = await scan();
|
||||
assert.deepEqual([second.inspected, second.total], [3, 3]);
|
||||
assert.deepEqual(finished.map(event => [event.libraryId, event.state]), [[library.id, 'complete'], [library.id, 'complete']]);
|
||||
// A bare scanner (no settings) keeps the old behaviour of scanning hidden names.
|
||||
assert.equal(new MediaScanner(db, libraries).isScanning(library.id), false);
|
||||
});
|
||||
|
||||
test('automation scans every idle library, and after a scan scrapes only never-attempted items once', async t => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'media-auto-')), db = new Db(':memory:'), libraries = new MediaLibraries(db);
|
||||
db.run("INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES('admin','admin','x','admin',0,0)");
|
||||
const scanner = new MediaScanner(db, libraries, probe);
|
||||
const scraping = new MediaScraping(db, scanner.catalog, [{ id: 'tmdb', label: 'test', configured: true, kinds: ['movie'], async search() { return []; }, async detail() { throw Error('no candidates'); } }]);
|
||||
const jobs = new MediaScrapeJobs(db, scanner.catalog, scraping);
|
||||
const settings = { mediaAutoScan: true, mediaScrape: true };
|
||||
const automation = new MediaAutomation(db, db, libraries, scanner, scraping, jobs, () => settings, undefined, 0);
|
||||
t.after(async () => { automation.stop(); await jobs.close(); await scanner.close(); db.close(); await rm(root, { recursive: true, force: true }); });
|
||||
await writeFile(join(root, 'Film (2020).mp4'), 'x');
|
||||
const library = await libraries.create(admin, { name: 'Films', kind: 'video', root, access: 'all' });
|
||||
assert.deepEqual(automation.scanAll(), [library.id]);
|
||||
assert.deepEqual(automation.scanAll(), [], 'a library already scanning is skipped');
|
||||
await scanner.wait(library.id);
|
||||
const scraped = db.all<{ job_id: string }>('SELECT job_id FROM media_scrape_job_items');
|
||||
assert.equal(scraped.length, 1, 'the completed scan started one scrape for the new film');
|
||||
await jobs.wait(scraped[0]!.job_id);
|
||||
// Unmatched once is not retried by the next scan.
|
||||
assert.deepEqual(automation.unscraped(library.id, ['movie']), []);
|
||||
assert.equal(automation.afterScan({ actor: admin, libraryId: library.id, jobId: 'x', state: 'complete' }), null);
|
||||
settings.mediaAutoScan = false;
|
||||
assert.deepEqual(automation.scanAll(), []);
|
||||
});
|
||||
|
||||
test('embedded subtitle streams can be turned off, and the new scanning settings have defaults', async () => {
|
||||
const db = new Db(':memory:'), libraries = new MediaLibraries(db);
|
||||
db.run("INSERT INTO media_libraries(id,name,kind,root,access,created_at,updated_at) VALUES('lib','Films','video',?,'all',0,0)", tmpdir());
|
||||
new MediaScanner(db, libraries, probe);
|
||||
const technical = JSON.stringify({ streams: [{ index: 2, type: 'subtitle', codec: 'subrip', language: 'en' }] });
|
||||
db.run("INSERT INTO media_assets(id,library_id,ref,size,modified_at,available,probe_status,technical_json) VALUES('a','lib','missing-dir/film.mkv',1,0,1,'ready',?)", technical);
|
||||
let embedded = true;
|
||||
const subtitles = new MediaSubtitles(db, libraries, undefined, () => embedded);
|
||||
const ids = async () => (await subtitles.list(admin, 'a').catch(() => ({ items: [] as Array<{ id: string }> }))).items.map(item => item.id);
|
||||
// Sidecar lookup may fail for the fake path; the embedded list is what matters.
|
||||
const withEmbedded = (subtitles as unknown as { embedded(asset: { ref: string; technical_json: string }): unknown[] }).embedded({ ref: 'film.mkv', technical_json: technical });
|
||||
assert.equal(withEmbedded.length, 1);
|
||||
embedded = false;
|
||||
assert.equal((subtitles as unknown as { embedded(asset: { ref: string; technical_json: string }): unknown[] }).embedded({ ref: 'film.mkv', technical_json: technical }).length, 0);
|
||||
assert.ok(!(await ids()).includes('stream:2'));
|
||||
assert.deepEqual(
|
||||
(({ mediaAutoScan, mediaScrape, mediaEmbedded, mediaHidden }) => ({ mediaAutoScan, mediaScrape, mediaEmbedded, mediaHidden }))(settingsDefaults('scanning')),
|
||||
{ mediaAutoScan: true, mediaScrape: false, mediaEmbedded: true, mediaHidden: false });
|
||||
// Rows saved before these fields existed read them as defaults.
|
||||
db.run(`CREATE TABLE IF NOT EXISTS business_settings(name TEXT PRIMARY KEY,value TEXT NOT NULL,revision INTEGER NOT NULL)`);
|
||||
db.run("INSERT OR REPLACE INTO business_settings VALUES('scanning','{\"interval\":60,\"libraries\":2,\"files\":4}',1)");
|
||||
assert.equal(new BusinessSettingsReader(db).read('scanning').mediaEmbedded, true);
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Db } from '../src/db/index.ts';
|
||||
import { ShelfService } from '../src/services/shelf.ts';
|
||||
|
||||
/** 在读 / 想读 / 已读 on the shelf: marks win, otherwise progress decides; 想读 yields to reading. */
|
||||
test('the shelf derives reading state from progress and marks, and filters by it', () => {
|
||||
const db = new Db(':memory:'), shelf = new ShelfService(db);
|
||||
try {
|
||||
db.run("INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES('u','reader','x','member',0,0),('v','other','x','member',0,0)");
|
||||
const books = ['new', 'started', 'done', 'wanted', 'wanted-started', 'marked-done', 'reread'];
|
||||
books.forEach((id, index) => {
|
||||
db.run(`INSERT INTO books(id,content_hash,format,title,author,created_at,updated_at) VALUES(?,?,'epub',?,'作者',1,?)`, id, 'h-' + id, id, index + 1);
|
||||
db.run('INSERT INTO book_files(id,book_id,rel_path,size,mtime_ms,first_seen,last_seen) VALUES(?,?,?,100,1,1,1)', 'f-' + id, id, id + '.epub');
|
||||
db.run('INSERT INTO user_books(user_id,book_id,added_at,hidden) VALUES(?,?,?,0)', 'u', id, index);
|
||||
});
|
||||
const progress = (book: string, percentage: number) => db.run(
|
||||
"INSERT INTO reading_progress(user_id,book_id,locator,percentage,chapter_title,device,updated_at) VALUES('u',?,'',?,'','web',0)", book, percentage);
|
||||
progress('started', 0.42); progress('done', 1); progress('wanted-started', 0.1); progress('reread', 1);
|
||||
shelf.setReadingStatus('u', 'wanted', 'want');
|
||||
shelf.setReadingStatus('u', 'wanted-started', 'want');
|
||||
shelf.setReadingStatus('u', 'marked-done', 'finished');
|
||||
// Reading a finished book again: an explicit 在读 beats the 100% progress.
|
||||
shelf.setReadingStatus('u', 'reread', 'reading');
|
||||
|
||||
const states = Object.fromEntries(shelf.list('u', { pageSize: 50 }).items.map(book => [book.id, book.readingState]));
|
||||
assert.deepEqual(states, { new: null, started: 'reading', done: 'finished', wanted: 'want', 'wanted-started': 'reading', 'marked-done': 'finished', reread: 'reading' });
|
||||
const ids = (status: 'reading' | 'want' | 'finished') => shelf.list('u', { status, pageSize: 50, sort: 'title', order: 'asc' });
|
||||
assert.deepEqual(ids('reading').items.map(book => book.id), ['reread', 'started', 'wanted-started']);
|
||||
assert.equal(ids('reading').total, 3);
|
||||
assert.deepEqual(ids('want').items.map(book => book.id), ['wanted']);
|
||||
assert.deepEqual(ids('finished').items.map(book => book.id), ['done', 'marked-done']);
|
||||
|
||||
// Clearing a mark lets progress decide again; the library scope never carries a state.
|
||||
shelf.setReadingStatus('u', 'reread', null);
|
||||
assert.equal(shelf.list('u', { status: 'finished', pageSize: 50 }).total, 3);
|
||||
assert.ok(shelf.list('u', { scope: 'library', pageSize: 50 }).items.every(book => !('readingState' in book)));
|
||||
assert.ok(!('readingState' in shelf.get('u', 'done')));
|
||||
|
||||
assert.throws(() => shelf.setReadingStatus('v', 'done', 'want'), { statusCode: 404 }, 'not on that account\'s shelf');
|
||||
assert.throws(() => shelf.setReadingStatus('u', 'done', 'paused' as never), { statusCode: 400 });
|
||||
assert.throws(() => shelf.list('u', { status: 'paused' as never }), { statusCode: 400 });
|
||||
} finally { db.close(); }
|
||||
});
|
||||
+6
-6
@@ -22,7 +22,7 @@
|
||||
covered by the same rule: nothing here reaches the network.
|
||||
-->
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#fbfaf7" />
|
||||
<meta name="theme-color" content="#fdf9f6" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="./icons/reader.svg" />
|
||||
<link rel="alternate icon" type="image/png" href="./icons/icon-192.png" />
|
||||
@@ -74,13 +74,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #fbfaf7;
|
||||
background: #fdf9f6;
|
||||
}
|
||||
html[data-theme='dark'],
|
||||
html[data-theme='dark'] body {
|
||||
background: #16171a;
|
||||
.app-startup { position: fixed; inset: 0; display: grid; place-items: center; background: #fdf9f6; z-index: 9999; }
|
||||
/* 暖陶 is the default theme; its dark half until the stored choice is applied. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html, body, .app-startup { background: #1f1713; }
|
||||
}
|
||||
.app-startup { position: fixed; inset: 0; display: grid; place-items: center; background: #fbfaf7; z-index: 9999; }
|
||||
.app-startup img { width: 96px; height: 96px; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
Generated
+2
-425
@@ -9,9 +9,6 @@
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@mantine/core": "^8.3.5",
|
||||
"@mantine/hooks": "^8.3.5",
|
||||
"@mantine/notifications": "^8.3.5",
|
||||
"@tanstack/query-core": "^5.104.0",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"dplayer": "^1.27.1",
|
||||
@@ -22,7 +19,6 @@
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"sweetalert2": "^11.26.25",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -317,15 +313,6 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz",
|
||||
@@ -987,59 +974,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.8.0",
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.27.20",
|
||||
"resolved": "https://registry.npmmirror.com/@floating-ui/react/-/react-0.27.20.tgz",
|
||||
"integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.9",
|
||||
"@floating-ui/utils": "^0.2.12",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17.0.0",
|
||||
"react-dom": ">=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
|
||||
"integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1090,59 +1024,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/core": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmmirror.com/@mantine/core/-/core-8.3.5.tgz",
|
||||
"integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.16",
|
||||
"clsx": "^2.1.1",
|
||||
"react-number-format": "^5.4.4",
|
||||
"react-remove-scroll": "^2.7.1",
|
||||
"react-textarea-autosize": "8.5.9",
|
||||
"type-fest": "^4.41.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/hooks": "8.3.5",
|
||||
"react": "^18.x || ^19.x",
|
||||
"react-dom": "^18.x || ^19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/hooks": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmmirror.com/@mantine/hooks/-/hooks-8.3.5.tgz",
|
||||
"integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.x || ^19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/notifications": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmmirror.com/@mantine/notifications/-/notifications-8.3.5.tgz",
|
||||
"integrity": "sha512-8TvzrPxfdtOLGTalv7Ei1hy2F6KbR3P7/V73yw3AOKhrf1ydS89sqV2ShbsucHGJk9Pto0wjdTPd8Q7pm5MAYw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mantine/store": "8.3.5",
|
||||
"react-transition-group": "4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "8.3.5",
|
||||
"@mantine/hooks": "8.3.5",
|
||||
"react": "^18.x || ^19.x",
|
||||
"react-dom": "^18.x || ^19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/store": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmmirror.com/@mantine/store/-/store-8.3.5.tgz",
|
||||
"integrity": "sha512-qN4fFsDMy86IV9oh1gZlDTv41RAsO0grjx90FGyT5QCv7NTgcavwxB74GBkhp45W8xn+Ms/awKy+6NxnmLmW1w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.x || ^19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
@@ -2002,15 +1883,6 @@
|
||||
"dev": true,
|
||||
"license": "BSL"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -2067,6 +1939,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
@@ -2142,22 +2015,6 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dplayer": {
|
||||
"version": "1.27.1",
|
||||
"resolved": "https://registry.npmmirror.com/dplayer/-/dplayer-1.27.1.tgz",
|
||||
@@ -2461,15 +2318,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
|
||||
@@ -2576,6 +2424,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
@@ -2666,18 +2515,6 @@
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz",
|
||||
@@ -2793,15 +2630,6 @@
|
||||
"integrity": "sha512-Mtnp+0qiZxgrH+TzVlzhWyZceHdAZ/UWK0/ju9U0HQeDpap1mZ8cC7H5wSI5mwgni6yeAjaxsTw0sbMK+aSuHw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
@@ -2928,17 +2756,6 @@
|
||||
"integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -2976,22 +2793,6 @@
|
||||
"react": "^19.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-number-format": {
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmmirror.com/react-number-format/-/react-number-format-5.4.5.tgz",
|
||||
"integrity": "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||
@@ -3002,53 +2803,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-remove-scroll-bar": "^2.3.7",
|
||||
"react-style-singleton": "^2.2.3",
|
||||
"tslib": "^2.1.0",
|
||||
"use-callback-ref": "^1.3.3",
|
||||
"use-sidecar": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll-bar": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-style-singleton": "^2.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.9.4",
|
||||
"resolved": "https://registry.npmmirror.com/react-router/-/react-router-7.9.4.tgz",
|
||||
@@ -3087,61 +2841,6 @@
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-nonce": "^1.0.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-textarea-autosize": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmmirror.com/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
|
||||
"integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"use-composed-ref": "^1.3.0",
|
||||
"use-latest": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmmirror.com/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
@@ -3332,16 +3031,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sweetalert2": {
|
||||
"version": "11.26.25",
|
||||
"resolved": "https://registry.npmmirror.com/sweetalert2/-/sweetalert2-11.26.25.tgz",
|
||||
"integrity": "sha512-+hunCOJdJ6FLj04T9YSLvvZXRjsvIkTeTKP2e4VF8CaBias961BTnWiSFAy7F/CM5eq3QK2Rraoc5Gzftslvkg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/limonte"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
@@ -3349,12 +3038,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/tabbable/-/tabbable-6.5.0.tgz",
|
||||
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -3462,12 +3145,6 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.13",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
|
||||
@@ -3971,18 +3648,6 @@
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -4045,94 +3710,6 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-composed-ref": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/use-composed-ref/-/use-composed-ref-1.4.0.tgz",
|
||||
"integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-isomorphic-layout-effect": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-latest": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/use-latest/-/use-latest-1.3.0.tgz",
|
||||
"integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-isomorphic-layout-effect": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sidecar": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-node-es": "^1.1.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
"ui:sources": "npm run build && node --import ../server/node_modules/tsx/dist/loader.mjs tools/ui-review/host-source-flow.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^8.3.5",
|
||||
"@mantine/hooks": "^8.3.5",
|
||||
"@mantine/notifications": "^8.3.5",
|
||||
"@tanstack/query-core": "^5.104.0",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"dplayer": "^1.27.1",
|
||||
@@ -31,7 +28,6 @@
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"sweetalert2": "^11.26.25",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
BookListPage,
|
||||
ContinueReadingItem,
|
||||
Facets,
|
||||
ReadingState,
|
||||
InstanceInfo,
|
||||
Manifest,
|
||||
Note,
|
||||
@@ -52,6 +53,8 @@ export interface TtsCapabilities {
|
||||
}
|
||||
|
||||
export interface ListQuery {
|
||||
/** 在读 / 想读 / 已读; shelf scope only. */
|
||||
status?: ReadingState;
|
||||
search?: string;
|
||||
author?: string;
|
||||
series?: string;
|
||||
@@ -440,6 +443,11 @@ export class ReaderApi {
|
||||
return result.toc;
|
||||
}
|
||||
|
||||
/** Marks a shelf book 想读 / 在读 / 已读, or clears the mark (`null`) so progress decides. */
|
||||
async setReadingStatus(bookId: string, status: ReadingState | null, options: RequestOptions = {}): Promise<void> {
|
||||
await this.call(`/api/v1/books/${encodeURIComponent(bookId)}/reading-status`, 'PUT', { status }, options);
|
||||
}
|
||||
|
||||
async facets(options: RequestOptions = {}): Promise<Facets> {
|
||||
return this.get<Facets>('/api/v1/library/facets', options);
|
||||
}
|
||||
|
||||
@@ -75,8 +75,16 @@ export interface Book {
|
||||
* already on the shelf is the one thing that decides whether it gets a control.
|
||||
*/
|
||||
shelfState?: 'on' | 'off';
|
||||
/**
|
||||
* 在读 / 想读 / 已读 on this account's shelf (the shelf list only). A reader's mark
|
||||
* wins; otherwise the server derives it from progress, and 想读 gives way to 在读
|
||||
* once the book is read. `null`: not started and not marked.
|
||||
*/
|
||||
readingState?: ReadingState | null;
|
||||
}
|
||||
|
||||
export type ReadingState = 'reading' | 'want' | 'finished';
|
||||
|
||||
export interface BookListPage {
|
||||
items: Book[];
|
||||
total: number;
|
||||
|
||||
+19
-30
@@ -1,10 +1,10 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../shared/stores/auth.store.ts';
|
||||
import { useRuntime } from './providers/runtime-context.tsx';
|
||||
import { LoginPage } from '../features/auth/pages/LoginPage.tsx';
|
||||
import { MediaChannelEntry } from '../features/media/components/channel-navigation.tsx';
|
||||
import { MediaThemeController } from '../features/media/services/theme.ts';
|
||||
import { AppNav, navMode } from './AppNav.tsx';
|
||||
import { ThemeController } from '../shared/theme/theme.ts';
|
||||
import { FloatingNotice } from '../ui/floating-notice.tsx';
|
||||
|
||||
export function AppShell() {
|
||||
@@ -24,11 +24,20 @@ function AuthenticatedShell() {
|
||||
return () => runtime.player.removeEventListener('open-controls', openControls);
|
||||
}, [runtime, navigate]);
|
||||
useEffect(() => runtime.player.setVisible(location.pathname.startsWith('/media/')), [runtime, location.pathname]);
|
||||
// Reading-adjacent pages own their header and discovery actions. The migrated
|
||||
// shell used to inject a second, generic navigation bar above every page, which
|
||||
// changed the vertical rhythm and made shelf/library diverge from the legacy UI.
|
||||
// Media keeps its own channel navigation through MediaLayout.
|
||||
return <Outlet />;
|
||||
const mode = navMode(location.pathname);
|
||||
// One app theme for every page; the reader may wear its own reading theme on top.
|
||||
useEffect(() => {
|
||||
const theme = new ThemeController(runtime.mediaApi.preferenceScope());
|
||||
return () => theme.dispose();
|
||||
}, [runtime]);
|
||||
useEffect(() => {
|
||||
if (mode === 'hidden') delete document.body.dataset.appNav; else document.body.dataset.appNav = mode;
|
||||
return () => { delete document.body.dataset.appNav; };
|
||||
}, [mode]);
|
||||
return <div className="app-shell">
|
||||
<div className="app-shell-body"><Outlet /></div>
|
||||
{mode !== 'hidden' && <AppNav mode={mode} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function AuthBoundary({ children }: { children?: ReactNode }) {
|
||||
@@ -37,28 +46,8 @@ export function AuthBoundary({ children }: { children?: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function MediaLayout() {
|
||||
const runtime = useRuntime();
|
||||
const location = useLocation();
|
||||
const current = location.pathname.startsWith('/media/music')
|
||||
? 'music'
|
||||
: location.pathname.startsWith('/media/audiobook')
|
||||
? 'audiobook'
|
||||
: 'video';
|
||||
// The legacy media shell applies its own palette while any media route is
|
||||
// mounted. Keep this scoped to MediaLayout so reading pages continue to use
|
||||
// the reader palette, and let React CSS consume the injected theme tokens.
|
||||
useEffect(() => {
|
||||
const theme = new MediaThemeController(runtime.mediaApi.preferenceScope(), () => undefined);
|
||||
return () => theme.dispose();
|
||||
}, [runtime]);
|
||||
// Keep the channel rail after the page content in the DOM, matching the legacy
|
||||
// shell. Its presentation stylesheet may pin it to the viewport, while the
|
||||
// route-owned page remains the first and primary content surface.
|
||||
return <><Outlet /><MediaChannelEntry current={current} /></>;
|
||||
}
|
||||
|
||||
export function HomeRedirect() { return <Navigate to="/media/video" replace />; }
|
||||
/** Media routes: the page itself. Theme and navigation belong to AuthenticatedShell. */
|
||||
export function MediaLayout() { return <Outlet />; }
|
||||
|
||||
export function LoadingPage() { return <FloatingNotice message="正在加载…" busy />; }
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { BookOpen, Clapperboard, House, Search, UserRound, type LucideIcon } from 'lucide-react';
|
||||
import { channelPath, mediaChannels } from '../features/media/components/channel-navigation.tsx';
|
||||
import { useAuthStore } from '../shared/stores/auth.store.ts';
|
||||
import { useSyncStore } from '../shared/stores/sync.store.ts';
|
||||
import type { SyncStatus } from '../core/sync.ts';
|
||||
import { useRuntime } from './providers/runtime-context.tsx';
|
||||
|
||||
/**
|
||||
* hidden: the book and the player own the whole screen.
|
||||
* top: a top-level destination; the bar shows everywhere.
|
||||
* push: a page reached from a top-level one (detail, settings, library files):
|
||||
* on a phone it replaces the bar with its own back arrow, on desktop the
|
||||
* sidebar stays.
|
||||
*/
|
||||
export type NavMode = 'hidden' | 'top' | 'push';
|
||||
|
||||
const mediaTop = /^\/media\/(video|music|audiobook)(?:\/([^/]+))?$/;
|
||||
const pushed = new Set(['favorites', 'queue', 'settings', 'history', 'folders', 'player', 'narrators']);
|
||||
|
||||
export function navMode(pathname: string): NavMode {
|
||||
if (pathname.startsWith('/book/') || /^\/media\/[^/]+\/player(\/|$)/.test(pathname)) return 'hidden';
|
||||
if (pathname === '/' || pathname === '/shelf' || pathname === '/me') return 'top';
|
||||
const media = mediaTop.exec(pathname);
|
||||
if (media) return media[2] && pushed.has(media[2]) ? 'push' : 'top';
|
||||
return 'push';
|
||||
}
|
||||
|
||||
interface Destination { to: string; label: string; icon: LucideIcon; current(path: string): boolean }
|
||||
const destinations: Destination[] = [
|
||||
{ to: '/', label: '首页', icon: House, current: path => path === '/' },
|
||||
{ to: '/shelf', label: '书架', icon: BookOpen, current: path => /^\/(shelf|library|sources)(\/|$)/.test(path) },
|
||||
{ to: '/media/video', label: '影音', icon: Clapperboard, current: path => path.startsWith('/media') },
|
||||
{ to: '/me', label: '我的', icon: UserRound, current: path => path === '/me' || path.startsWith('/me/') },
|
||||
];
|
||||
|
||||
/** The one navigation: a tab bar on a phone, a sidebar from 701px up. */
|
||||
export function AppNav({ mode }: { mode: Exclude<NavMode, 'hidden'> }) {
|
||||
const { pathname } = useLocation();
|
||||
const runtime = useRuntime();
|
||||
const user = useAuthStore(state => state.verifiedUser ?? state.session?.user ?? null);
|
||||
const channel = /^\/media\/(video|music|audiobook)/.exec(pathname)?.[1];
|
||||
const name = user?.displayName || user?.username || '';
|
||||
const sync = useSyncStore(state => state.status.state);
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState('');
|
||||
const search = (event: FormEvent) => { event.preventDefault(); const q = query.trim(); if (q) navigate(`/search?q=${encodeURIComponent(q)}`); };
|
||||
const connection = connectionLabel(sync);
|
||||
return <nav className="app-nav" aria-label="主导航" data-mode={mode}>
|
||||
<Link className="app-nav-brand" to="/" aria-label="reader. 首页">reader.</Link>
|
||||
{/* Desktop only: one search across books and media (the page is /search). */}
|
||||
<form className="app-nav-search" role="search" onSubmit={search}>
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input type="search" aria-label="搜索书籍和影音" placeholder="搜索书名、片名、歌曲、有声书" value={query} maxLength={200} onInput={event => setQuery(event.currentTarget.value)} />
|
||||
</form>
|
||||
<ul className="app-nav-list">
|
||||
{destinations.map(({ to, label, icon: Icon, current }) => {
|
||||
const active = current(pathname);
|
||||
return <li key={to}>
|
||||
<Link className="app-nav-item" to={to} aria-current={active ? 'page' : undefined}>
|
||||
<span className="app-nav-icon" aria-hidden="true"><Icon size={22} strokeWidth={active ? 2.1 : 1.8} /></span>
|
||||
<span className="app-nav-label">{label}</span>
|
||||
</Link>
|
||||
{to === '/media/video' && <ul className="app-nav-sub" aria-label="影音频道">
|
||||
{mediaChannels.map(item => <li key={item.id}>
|
||||
<Link className="app-nav-subitem" data-channel={item.id} to={channelPath(item.id)} aria-current={channel === item.id ? 'page' : undefined}>
|
||||
<i aria-hidden="true" />{item.label}
|
||||
</Link>
|
||||
</li>)}
|
||||
</ul>}
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
{user && <Link className="app-nav-user" to="/me" aria-label="我的账号">
|
||||
<span className="app-nav-avatar" aria-hidden="true">{Array.from(name)[0]}</span>
|
||||
<span className="app-nav-user-text"><strong>{name}</strong><small title={host(runtime.api.baseUrl)}><i className="app-nav-status" data-state={connection.state} aria-hidden="true" />{connection.label}</small></span>
|
||||
</Link>}
|
||||
</nav>;
|
||||
}
|
||||
|
||||
function host(baseUrl: string): string { try { return new URL(baseUrl).host; } catch { return baseUrl; } }
|
||||
|
||||
/** The sync engine's view of the server, in the sidebar's words. */
|
||||
export function connectionLabel(state: SyncStatus['state']): { state: 'online' | 'offline' | 'error'; label: string } {
|
||||
if (state === 'offline') return { state: 'offline', label: '离线' };
|
||||
if (state === 'error') return { state: 'error', label: '同步出错' };
|
||||
if (state === 'syncing') return { state: 'online', label: '正在同步' };
|
||||
return { state: 'online', label: '已连接' };
|
||||
}
|
||||
+1
-13
@@ -1,14 +1,2 @@
|
||||
.app-global-nav, .media-app-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: .75rem 1rem;
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
.app-global-nav a, .media-app-nav a { color: var(--mantine-color-text); text-decoration: none; }
|
||||
.app-global-nav a:hover, .media-app-nav a:hover { color: var(--mantine-primary-color-filled); }
|
||||
.media-page { max-width: 1440px; margin-inline: auto; padding-bottom: 7rem; }
|
||||
.react-mini-player { position: fixed; bottom: .75rem; left: 1rem; right: 1rem; z-index: 250; }
|
||||
.reader-route-host { height: 100dvh; overflow: hidden; }
|
||||
.reader-route-host { display: flex; flex-direction: column; height: 100dvh; min-height: 100vh; overflow: hidden; }
|
||||
@@ -1,20 +1,8 @@
|
||||
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 '../features/media/components/channel-navigation.tsx';
|
||||
import type { AppSettings } from '../store/settings.ts';
|
||||
|
||||
/**
|
||||
* Compatibility-only route shape used by the old Android diagnostics/tests.
|
||||
*
|
||||
* Production routing is owned by React Router. Keeping this tiny structural
|
||||
* type here prevents the compatibility boundary from pulling the removed
|
||||
* hand-written router back into the production module graph.
|
||||
*/
|
||||
type LegacyRoute =
|
||||
| { name: 'media'; channel: string }
|
||||
| { name: Exclude<string, 'media'>; channel?: never };
|
||||
|
||||
export interface LegacyCompatOptions { root: HTMLElement; defaultServerUrl?: string }
|
||||
|
||||
/**
|
||||
@@ -25,7 +13,6 @@ export interface LegacyCompatOptions { root: HTMLElement; defaultServerUrl?: str
|
||||
export class App {
|
||||
readonly root: HTMLElement;
|
||||
readonly options: LegacyCompatOptions;
|
||||
channelEntry: HTMLElement | null = null;
|
||||
mediaPlayer: { setVisible(visible: boolean): void; flush?(): Promise<void> } | null = null;
|
||||
reader: { flushProgress?(): Promise<void> } | null = null;
|
||||
offline: { flush?(): Promise<void> } | null = null;
|
||||
@@ -42,21 +29,6 @@ export class App {
|
||||
return createAndroidPlatform(serverUrl, bridge);
|
||||
}
|
||||
|
||||
showChannelEntry(route: LegacyRoute): void {
|
||||
this.mediaPlayer?.setVisible(route.name === 'media');
|
||||
if (route.name !== 'media') {
|
||||
this.channelEntry?.remove(); this.channelEntry = null; return;
|
||||
}
|
||||
if (!this.channelEntry) {
|
||||
this.channelEntry = document.createElement('nav');
|
||||
this.channelEntry.className = 'media-channel-entry';
|
||||
this.channelEntry.setAttribute('aria-label', '内容频道');
|
||||
this.root.insertAdjacentElement('afterend', this.channelEntry);
|
||||
}
|
||||
this.channelEntry.hidden = false;
|
||||
renderChannelLinks(this.channelEntry, route.channel ?? '');
|
||||
}
|
||||
|
||||
async saveBeforeUpdate(): Promise<void> {
|
||||
await this.mediaPlayer?.flush?.();
|
||||
await this.reader?.flushProgress?.();
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { AppRuntime } from '../runtime.ts';
|
||||
import { RuntimeContext } from './runtime-context.tsx';
|
||||
import { useSettingsStore } from '../../shared/stores/settings.store.ts';
|
||||
|
||||
export function AppProviders({ runtime, children }: { runtime: AppRuntime; children: ReactNode }) {
|
||||
const theme = useSettingsStore(state => state.settings.theme);
|
||||
useEffect(() => {
|
||||
const host = runtime.player.element;
|
||||
document.body.append(host);
|
||||
@@ -15,9 +11,6 @@ export function AppProviders({ runtime, children }: { runtime: AppRuntime; child
|
||||
return () => { runtime.playback.stop(); host.remove(); };
|
||||
}, [runtime]);
|
||||
useEffect(() => runtime.connect(), [runtime]);
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}, [theme]);
|
||||
useEffect(() => {
|
||||
const visibility = () => {
|
||||
if (document.visibilityState === 'hidden') void runtime.flush();
|
||||
@@ -35,10 +28,6 @@ export function AppProviders({ runtime, children }: { runtime: AppRuntime; child
|
||||
};
|
||||
}, [runtime]);
|
||||
return <RuntimeContext value={runtime}>
|
||||
<QueryClientProvider client={runtime.queryClient}>
|
||||
<MantineProvider forceColorScheme={theme === 'dark' ? 'dark' : 'light'}>
|
||||
<Notifications />{children}
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
<QueryClientProvider client={runtime.queryClient}>{children}</QueryClientProvider>
|
||||
</RuntimeContext>;
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Button, Center, Stack, Text, Title } from '@mantine/core';
|
||||
import { isRouteErrorResponse, useRouteError, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../ui/toolkit.tsx';
|
||||
|
||||
export function RouteErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
const navigate = useNavigate();
|
||||
const message = isRouteErrorResponse(error) ? `${error.status} ${error.statusText}` : error instanceof Error ? error.message : '页面加载失败';
|
||||
return <Center mih="60vh"><Stack align="center"><Title order={2}>页面暂时不可用</Title><Text c="dimmed">{message}</Text><Button onClick={() => navigate('/media/video')}>返回影音</Button></Stack></Center>;
|
||||
return <div className="page-state" role="alert">
|
||||
<h2>页面暂时不可用</h2>
|
||||
<p>{message}</p>
|
||||
<Button className="primary" onClick={() => navigate('/')}>返回首页</Button>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
import { Navigate, type RouteObject } from 'react-router-dom';
|
||||
import { AppShell, HomeRedirect, MediaLayout } from '../App.tsx';
|
||||
import { AppShell, MediaLayout } from '../App.tsx';
|
||||
import { MediaCatalogPage } from '../../features/media/pages/MediaCatalogPage.tsx';
|
||||
import { MediaSearchPage } from '../../features/media/pages/MediaSearchPage.tsx';
|
||||
import { MediaFavoritesPage } from '../../features/media/pages/MediaFavoritesPage.tsx';
|
||||
import { MediaHistoryPage } from '../../features/media/pages/MediaHistoryPage.tsx';
|
||||
import { MediaDetailPage } from '../../features/media/pages/MediaDetailPage.tsx';
|
||||
import { MediaQueuePage } from '../../features/media/pages/MediaQueuePage.tsx';
|
||||
import { MediaSettingsPage } from '../../features/media/pages/MediaSettingsPage.tsx';
|
||||
import { LegacyMediaSettingsRedirect, MediaSettingsPage } from '../../features/media/pages/MediaSettingsPage.tsx';
|
||||
import { MediaPlayerPage } from '../../features/media/pages/MediaPlayerPage.tsx';
|
||||
import { RouteErrorBoundary } from './error-boundary.tsx';
|
||||
import { ShelfPage } from '../../features/shelf/pages/ShelfPage.tsx';
|
||||
import { LibraryPage, LibraryBrowsePage, LibraryFilesPage } from '../../features/library/pages/LibraryPage.tsx';
|
||||
import { SourcesPage } from '../../features/sources/pages/SourcesPage.tsx';
|
||||
import { ReaderPage } from '../../features/reader/pages/ReaderPage.tsx';
|
||||
import { MinePage } from '../../features/account/pages/MinePage.tsx';
|
||||
import { HomePage } from '../../features/home/pages/HomePage.tsx';
|
||||
import { SearchPage } from '../../features/search/pages/SearchPage.tsx';
|
||||
import { MediaLibrariesPage, MediaLibraryCreatePage } from '../../features/media/pages/MediaLibrariesPage.tsx';
|
||||
import { MediaLibraryEditPage, MediaLibraryPermissionsPage } from '../../features/media/pages/MediaLibraryEditorPages.tsx';
|
||||
import { MediaTasksPage } from '../../features/media/pages/MediaTasksPage.tsx';
|
||||
@@ -33,8 +36,9 @@ import { PluginManagementPage } from '../../features/sources/pages/PluginManagem
|
||||
|
||||
export const routes = [{
|
||||
path: '/', element: <AppShell />, errorElement: <RouteErrorBoundary />, children: [
|
||||
{ index: true, element: <HomeRedirect /> },
|
||||
{ index: true, element: <HomePage /> },
|
||||
{ path: 'shelf', element: <ShelfPage /> },
|
||||
{ path: 'search', element: <SearchPage /> },
|
||||
{ path: 'library', element: <LibraryBrowsePage /> },
|
||||
{ path: 'library/books', element: <LibraryPage /> },
|
||||
{ path: 'library/files', element: <LibraryFilesPage /> },
|
||||
@@ -55,9 +59,16 @@ export const routes = [{
|
||||
{ path: 'sources/plugins/:pluginId/pages/:pageId', element: <SourceExtensionPage /> },
|
||||
// The legacy shell treats /settings as the shelf's admin settings overlay.
|
||||
// Keep the route compatible with old deep links; user preferences live in
|
||||
// the shelf settings sheet and media settings has its own nested route.
|
||||
// 我的 (/me).
|
||||
{ path: 'settings', element: <Navigate to="/shelf" replace /> },
|
||||
{ path: 'book/:bookId', element: <ReaderPage /> },
|
||||
// 我的 shares MediaLayout so it follows the chosen theme and keeps the channel rail.
|
||||
// Its settings rows open /me/:panel (theme, browse, playback, account),
|
||||
// so back from any of them returns to 我的.
|
||||
{ path: 'me', element: <MediaLayout />, children: [
|
||||
{ index: true, element: <MinePage /> },
|
||||
{ path: ':panel', element: <MediaSettingsPage /> },
|
||||
] },
|
||||
{ path: 'media', element: <MediaLayout />, children: [
|
||||
{ index: true, element: <Navigate to="video" replace /> },
|
||||
{ path: 'music', element: <Navigate to="/media/music/albums" replace /> },
|
||||
@@ -67,13 +78,14 @@ export const routes = [{
|
||||
{ path: 'queue', element: <MediaQueuePage /> },
|
||||
{ path: ':channel/favorites', element: <MediaFavoritesPage /> },
|
||||
{ path: ':channel/queue', element: <MediaQueuePage /> },
|
||||
{ path: ':channel/settings', element: <MediaSettingsPage /> },
|
||||
// 影音设置 is gone; its panels live in 我的.
|
||||
{ path: ':channel/settings', element: <LegacyMediaSettingsRedirect /> },
|
||||
{ path: ':channel/settings/libraries', element: <MediaLibrariesPage /> },
|
||||
{ path: ':channel/settings/libraries/new', element: <MediaLibraryCreatePage /> },
|
||||
{ path: ':channel/settings/libraries/:libraryId/edit', element: <MediaLibraryEditPage /> },
|
||||
{ path: ':channel/settings/libraries/:libraryId/permissions', element: <MediaLibraryPermissionsPage /> },
|
||||
{ path: ':channel/settings/tasks', element: <MediaTasksPage /> },
|
||||
{ path: ':channel/settings/:panel', element: <MediaSettingsPage /> },
|
||||
{ path: ':channel/settings/:panel', element: <LegacyMediaSettingsRedirect /> },
|
||||
{ path: ':channel/folders/:libraryId?', element: <MediaFoldersPage /> },
|
||||
{ path: 'audiobook/narrators', element: <MediaNarratorsPage /> },
|
||||
{ path: 'audiobook/narrators/:narrator/works/:workId', element: <MediaNarratorsPage /> },
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/* V3 shell: one navigation for reading and media.
|
||||
* A tab bar up to 700px, a sidebar from 701px up. Colours are the theme's --color-*
|
||||
* tokens (styles/theme-tokens.css), so every theme restyles it. */
|
||||
:root { --app-nav-width: 232px; --app-nav-bar: 64px; }
|
||||
|
||||
.app-shell { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; }
|
||||
/* Pages were direct children of #app (a flex column that clips); keep exactly that
|
||||
* contract for them one level down. */
|
||||
.app-shell-body { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; overflow: hidden; }
|
||||
|
||||
.app-nav {
|
||||
--nav-ink: var(--color-ink);
|
||||
--nav-muted: var(--color-muted);
|
||||
--nav-line: var(--color-line);
|
||||
--nav-surface: var(--color-surface);
|
||||
--nav-accent: var(--color-brand-text);
|
||||
--nav-soft: var(--color-brand-soft);
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
background: var(--nav-surface);
|
||||
color: var(--nav-ink);
|
||||
border-color: var(--nav-line);
|
||||
}
|
||||
.app-nav ul { list-style: none; margin: 0; padding: 0; }
|
||||
.app-nav a { color: inherit; text-decoration: none; }
|
||||
.app-nav a:focus-visible { outline: 2px solid var(--nav-accent); outline-offset: 2px; }
|
||||
.app-nav-icon { display: grid; place-items: center; }
|
||||
|
||||
/* ---- phone: tab bar ---- */
|
||||
@media (max-width: 700px) {
|
||||
.app-nav { order: 2; display: flex; height: calc(var(--app-nav-bar) + env(safe-area-inset-bottom)); padding: 6px 12px env(safe-area-inset-bottom); border-top: 1px solid var(--nav-line); }
|
||||
.app-nav[data-mode='push'] { display: none; }
|
||||
.app-nav-brand, .app-nav-sub, .app-nav-user, .app-nav-search { display: none; }
|
||||
.app-nav-list { display: flex; flex: 1; }
|
||||
.app-nav-list > li { flex: 1; min-width: 0; }
|
||||
.app-nav-item { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; height: 52px; font-size: 11px; font-weight: 500; color: var(--nav-muted); }
|
||||
.app-nav-item[aria-current='page'] { color: var(--nav-accent); font-weight: 600; }
|
||||
.app-nav-item .app-nav-icon { width: 52px; height: 30px; border-radius: 15px; }
|
||||
.app-nav-item[aria-current='page'] .app-nav-icon { background: var(--nav-soft); }
|
||||
}
|
||||
|
||||
/* ---- desktop: sidebar ---- */
|
||||
@media (min-width: 701px) {
|
||||
.app-shell { flex-direction: row; }
|
||||
/* After the page in the DOM (so the phone bar follows the content), before it on screen. */
|
||||
.app-nav { order: -1; display: flex; flex-direction: column; width: var(--app-nav-width); padding: 24px 14px; border-right: 1px solid var(--nav-line); overflow-y: auto; }
|
||||
.app-nav-brand { padding: 0 14px 22px; font-family: 'Songti SC', 'Noto Serif SC', 'Source Han Serif SC', serif; font-size: 26px; font-weight: 700; color: var(--nav-accent); }
|
||||
.app-nav-item { display: flex; align-items: center; gap: 12px; height: 44px; padding: 0 14px; border-radius: 12px; font-size: 15px; color: var(--nav-ink); }
|
||||
.app-nav-item:hover { background: var(--color-surface-2); }
|
||||
.app-nav-item[aria-current='page'] { background: var(--nav-soft); color: var(--nav-accent); font-weight: 600; }
|
||||
.app-nav-sub { margin-top: 2px; }
|
||||
.app-nav-subitem { display: flex; align-items: center; gap: 12px; height: 38px; padding: 0 14px 0 46px; border-radius: 10px; font-size: 14px; color: var(--nav-muted); }
|
||||
.app-nav-subitem i { width: 8px; height: 8px; border-radius: 50%; background: var(--color-ch-video); }
|
||||
.app-nav-subitem[data-channel='music'] i { background: var(--color-ch-music); }
|
||||
.app-nav-subitem[data-channel='audiobook'] i { background: var(--color-ch-audio); }
|
||||
.app-nav-subitem[aria-current='page'] { background: var(--color-surface-2); color: var(--nav-ink); font-weight: 600; }
|
||||
.app-nav-user { display: flex; align-items: center; gap: 10px; margin-top: auto; padding: 10px 12px; border-radius: 14px; background: var(--color-bg); }
|
||||
.app-nav-avatar { display: grid; place-items: center; flex: none; width: 34px; height: 34px; border-radius: 50%; background: var(--color-brand); color: var(--color-on-brand); font-weight: 600; }
|
||||
.app-nav-user-text { min-width: 0; }
|
||||
.app-nav-user-text strong, .app-nav-user-text small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.app-nav-user-text strong { font-size: 14px; }
|
||||
.app-nav-user-text small { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--nav-muted); }
|
||||
.app-nav-status { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--color-ch-audio); }
|
||||
.app-nav-status[data-state='offline'] { background: var(--nav-muted); }
|
||||
.app-nav-status[data-state='error'] { background: var(--color-danger); }
|
||||
.app-nav-search { display: flex; align-items: center; gap: 8px; margin: 0 0 16px; padding: 0 12px; border-radius: 12px; background: var(--color-surface-2); color: var(--nav-muted); }
|
||||
.app-nav-search input { flex: 1; min-width: 0; height: 40px; border: 0; background: transparent; color: var(--nav-ink); font: inherit; font-size: 13px; outline: none; }
|
||||
.app-nav-search input::-webkit-search-cancel-button { display: none; }
|
||||
.app-nav-search:focus-within { box-shadow: 0 0 0 2px var(--nav-accent); }
|
||||
}
|
||||
|
||||
/* ---- the persistent mini player sits clear of the bar / sidebar ---- */
|
||||
@media (max-width: 700px) {
|
||||
body[data-app-nav='top'] .media-player:is(.media-player-audio, .media-player-native-video) { bottom: calc(var(--app-nav-bar) + env(safe-area-inset-bottom) + 10px); }
|
||||
}
|
||||
@media (min-width: 701px) {
|
||||
body[data-app-nav] .media-player:is(.media-player-audio, .media-player-native-video) { left: calc(var(--app-nav-width) + 24px); }
|
||||
}
|
||||
|
||||
/* ---- reading pages wear the theme too; their --reader-* names alias the tokens
|
||||
* (theme-tokens.css). The reader may wear 黑白灰 on its own container, and a
|
||||
* reading paper on its page (--reader-paper / --reader-ink). ---- */
|
||||
:is(.shelf-screen, .library-screen, .sources-screen, .home-page) {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.reader-screen {
|
||||
background: var(--reader-paper);
|
||||
color: var(--reader-ink);
|
||||
}
|
||||
|
||||
/* ---- one page column for the four tabs ----
|
||||
* 首页, 书架, 影音 and 我的 share --page-measure / --page-gutter (styles/reader.css), so
|
||||
* switching tabs never moves the content edges. The media-family pages are their own
|
||||
* scrollers, so the column is made with padding (the scrollbar stays at the window
|
||||
* edge); the shelf caps its bands instead, which keeps its sticky toolbar working. */
|
||||
@media (min-width: 701px) {
|
||||
:root { --page-gutter: 32px; }
|
||||
}
|
||||
.media-screen:is(.home-page, .mine-page, .media-catalog-page) {
|
||||
max-width: none;
|
||||
padding-inline: max(var(--page-gutter), calc((100% - var(--page-measure)) / 2));
|
||||
}
|
||||
/* Phone: the same 20px top as the shelf's header, clear of the notch; the shelf's
|
||||
* 0.9rem band padding is topped up to the shared gutter. */
|
||||
@media (max-width: 700px) {
|
||||
.media-screen:is(.home-page, .mine-page) { padding-top: calc(20px + var(--safe-top)); }
|
||||
.shelf { padding-inline: calc(var(--page-gutter) - 0.9rem); }
|
||||
}
|
||||
@media (min-width: 701px) {
|
||||
.shelf { --shelf-measure: var(--collection-measure); }
|
||||
.shelf-head, .shelf-discovery, .shelf-section { width: calc(100% - 2 * var(--page-gutter) + 1.8rem); }
|
||||
.shelf-head { padding-top: 24px; }
|
||||
}
|
||||
/* Between the sidebar breakpoint and 64rem the discovery row is display: contents,
|
||||
* so its two children are bands of their own. */
|
||||
@media (min-width: 701px) and (max-width: 63.99rem) {
|
||||
.shelf-links, .shelf-search { width: calc(100% - 2 * var(--page-gutter) + 1.8rem); }
|
||||
}
|
||||
|
||||
/* The old fixed channel rail is gone; its top gutter goes with it. */
|
||||
@media (min-width: 701px) {
|
||||
/* Only pages whose header scrolls with them; pages with a fixed header keep their 68px. */
|
||||
body[data-app-nav] .media-screen:is(.media-catalog-page, .mine-page, .home-page) { padding-top: 24px; }
|
||||
/* Search and the ⋯ menu used to hang from the rail with a negative offset. They end
|
||||
* where the page column ends, not at the window edge. */
|
||||
body[data-app-nav] .media-heading:has(.media-mobile-title) .media-heading-actions { position: fixed; z-index: 81; top: 12px; right: max(var(--page-gutter), calc((100vw - var(--app-nav-width) - var(--page-measure)) / 2)); }
|
||||
}
|
||||
|
||||
/* Fixed page headers were placed to clear the old rail's brand; they now clear the sidebar. */
|
||||
@media (min-width: 701px) {
|
||||
body[data-app-nav] .media-screen .media-mobile-title,
|
||||
body[data-app-nav] .media-screen.media-secondary-page > .media-heading,
|
||||
body[data-app-nav] .media-narrators:not([data-view=directory]) > .media-narrator-heading,
|
||||
body[data-app-nav] .media-utility-workspace .media-settings-heading,
|
||||
body[data-app-nav] .media-manager-workspace > .media-heading { left: calc(var(--app-nav-width) + 24px); right: 32px; }
|
||||
body[data-app-nav] .media-screen.media-detail-shell > .media-heading { left: var(--app-nav-width); right: 0; }
|
||||
}
|
||||
|
||||
/* ---- reader chrome, V3: a rounded bottom sheet and a quiet top bar ---- */
|
||||
.reader-screen .footer {
|
||||
border-top: 0;
|
||||
border-radius: 24px 24px 0 0;
|
||||
box-shadow: 0 -8px 30px color-mix(in srgb, var(--color-ink) 10%, transparent);
|
||||
}
|
||||
.reader-screen .topbar { border-bottom: 1px solid var(--reader-line); box-shadow: none; }
|
||||
.reader-screen .reader-actions button[aria-current='true'],
|
||||
.reader-screen .reader-actions button:active { color: var(--reader-accent); }
|
||||
|
||||
/* Page titles in the serif face, the way the prototype sets them. */
|
||||
.shelf-title { font-family: 'Songti SC', 'Noto Serif SC', 'Source Han Serif SC', serif; }
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
/* ---- video player, wide screens: the episode rail sits beside the picture ----
|
||||
* Only when there is a rail (episodes). A film keeps the single centred column. The
|
||||
* wrappers around the controls become transparent so caption, toolbar and rail are
|
||||
* placed directly on the workspace grid. */
|
||||
@media (min-width: 1100px) {
|
||||
.media-player-expanded .media-video-workspace:has(.media-episode-rail) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
grid-auto-rows: min-content;
|
||||
column-gap: 24px;
|
||||
align-content: start;
|
||||
box-sizing: border-box;
|
||||
width: min(1280px, 100%);
|
||||
max-width: none;
|
||||
margin: 0 auto;
|
||||
padding: 20px 32px 32px;
|
||||
}
|
||||
.media-video-workspace:has(.media-episode-rail) > div:not(.media-video-stage),
|
||||
.media-video-workspace:has(.media-episode-rail) .media-video-controls { display: contents; }
|
||||
.media-video-workspace:has(.media-episode-rail) .media-video-stage { grid-column: 1; grid-row: 1; overflow: hidden; border-radius: 20px; }
|
||||
.media-video-workspace:has(.media-episode-rail) .media-video-controls > :not(.media-episode-rail) { grid-column: 1; width: auto; max-width: none; justify-self: stretch; }
|
||||
.media-video-workspace:has(.media-episode-rail) .media-video-caption { margin-top: 16px; }
|
||||
.media-video-workspace:has(.media-episode-rail) .media-episode-rail { position: static; inset: auto; width: auto; margin: 0; grid-column: 2; grid-row: 1 / span 8; align-self: start; max-height: calc(100dvh - 120px); overflow-y: auto; }
|
||||
}
|
||||
|
||||
/* ---- phone: the channel switch is three segments in the page header ---- */
|
||||
.media-channel-segments { display: flex; flex: 1; gap: 2px; padding: 4px; border-radius: 14px; background: var(--media-surface, var(--surface)); }
|
||||
.media-channel-segments a { flex: 1; display: grid; place-items: center; min-height: 36px; border-radius: 10px; color: var(--muted); font-size: 14px; text-decoration: none; }
|
||||
.media-channel-segments a[aria-current='page'] { background: var(--media-paper, #fff); color: var(--text); font-weight: 600; box-shadow: 0 1px 3px color-mix(in srgb, var(--color-ink) 18%, transparent); }
|
||||
.media-channel-segments a:focus-visible { outline: 2px solid var(--media-accent); outline-offset: 2px; }
|
||||
@media (min-width: 701px) { .media-mobile-title { display: none !important; } }
|
||||
@media (max-width: 700px) {
|
||||
.media-screen .media-mobile-title { width: 100%; max-width: 300px; }
|
||||
.media-channel-segments a { white-space: nowrap; }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { ChevronRight, Film, LayoutGrid, LibraryBig, LogOut, Palette, PlayCircle, RefreshCw, Rss, Settings, SlidersHorizontal, UserRound, type LucideIcon } from 'lucide-react';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
import { useSettingsStore } from '../../../shared/stores/settings.store.ts';
|
||||
import { themeSummary } from '../../../shared/theme/theme.ts';
|
||||
import { useThemeState } from '../../../shared/theme/use-theme.ts';
|
||||
import { readMediaPreferences } from '../../media/services/preferences.ts';
|
||||
import { DENSITY_LABELS, ShelfSettingsSheet } from '../../../ui/shelf-settings.tsx';
|
||||
import { OpdsAccess } from '../../../ui/opds-access.tsx';
|
||||
import { SystemSettings } from '../../../ui/system-settings.tsx';
|
||||
import '../../home/styles/home.css';
|
||||
import '../styles/mine.css';
|
||||
|
||||
function Row({ icon: Icon, label, value, to, state, onClick, danger = false }: { icon: LucideIcon; label: string; value?: string; to?: string; state?: unknown; onClick?: () => void; danger?: boolean }) {
|
||||
const body = <>
|
||||
<span className="mine-row-icon" aria-hidden="true"><Icon size={18} /></span>
|
||||
<span className="mine-row-label">{label}</span>
|
||||
{value && <span className="mine-row-value">{value}</span>}
|
||||
{!danger && <ChevronRight size={16} aria-hidden="true" className="mine-row-chevron" />}
|
||||
</>;
|
||||
const className = danger ? 'mine-row is-danger' : 'mine-row';
|
||||
return to
|
||||
? <Link className={className} to={to} state={state}>{body}</Link>
|
||||
: <button type="button" className={className} onClick={onClick}>{body}</button>;
|
||||
}
|
||||
|
||||
function Group({ title, order, children }: { title: string; order: number; children: ReactNode }) {
|
||||
return <section className="mine-group" aria-label={title} style={{ order }}><h2>{title}</h2><div className="mine-card">{children}</div></section>;
|
||||
}
|
||||
|
||||
function serverHost(baseUrl: string): string {
|
||||
try { return new URL(baseUrl).host; } catch { return baseUrl; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的: the settings hub. Every setting in the app is reachable from here, grouped by
|
||||
* what it is about rather than by which page happens to own it:
|
||||
* - 偏好: how things look and behave for *you* (theme, the shelf, media browsing, playback);
|
||||
* - 管理: what an administrator changes for everyone (users, services, media libraries);
|
||||
* - 账号: who you are on this server.
|
||||
* Each row opens the one place that setting lives; the shelf sheet is a shortcut
|
||||
* to a subset of this list, not a separate setting.
|
||||
*/
|
||||
export function MinePage() {
|
||||
const runtime = useRuntime();
|
||||
const user = useAuthStore(state => state.verifiedUser ?? state.session?.user ?? null);
|
||||
const settings = useSettingsStore(state => state.settings);
|
||||
const admin = user?.role === 'admin';
|
||||
const scope = runtime.mediaApi.preferenceScope();
|
||||
const themeName = themeSummary(useThemeState(scope).preference);
|
||||
const mediaDensity = readMediaPreferences(scope).density === 'compact' ? '紧凑' : '宽松';
|
||||
const [sheet, setSheet] = useState<'shelf' | 'opds' | 'system' | null>(null);
|
||||
const name = user?.displayName || user?.username || '';
|
||||
return <>
|
||||
<div className="media-screen media-page mine-page">
|
||||
<header className="home-header"><h1>我的</h1></header>
|
||||
{user && <section className="mine-profile" aria-label="当前账号">
|
||||
<div className="mine-avatar" aria-hidden="true">{Array.from(name)[0]}</div>
|
||||
<div className="mine-profile-text">
|
||||
<strong>{name}<span className="mine-role">{admin ? '管理员' : '成员'}</span></strong>
|
||||
<small><span className="mine-dot" aria-hidden="true" />已连接 {serverHost(runtime.api.baseUrl)}</small>
|
||||
</div>
|
||||
</section>}
|
||||
{/* Two columns on a wide window, each filled top to bottom so uneven groups don't
|
||||
* leave holes; on a narrow one the columns dissolve and `order` restores reading order. */}
|
||||
<div className="mine-groups">
|
||||
<div className="mine-column">
|
||||
<Group title="内容" order={1}>
|
||||
<Row icon={LibraryBig} label="书库" to="/library" />
|
||||
<Row icon={Rss} label="书源" to="/sources" />
|
||||
{admin && <Row icon={Film} label="媒体库" to="/media/video/settings/libraries" state={{ returnTo: '/me' }} />}
|
||||
</Group>
|
||||
{admin && <Group title="管理" order={3}>
|
||||
<Row icon={Settings} label="系统设置" value="用户、注册、服务配置" onClick={() => setSheet('system')} />
|
||||
<Row icon={RefreshCw} label="扫描与刮削" to="/media/video/settings/tasks" state={{ returnTo: '/me' }} />
|
||||
</Group>}
|
||||
</div>
|
||||
<div className="mine-column">
|
||||
<Group title="偏好" order={2}>
|
||||
<Row icon={Palette} label="外观与主题" value={themeName} to="/me/theme" />
|
||||
<Row icon={LayoutGrid} label="书架显示" value={DENSITY_LABELS[settings.shelfDensity]} onClick={() => setSheet('shelf')} />
|
||||
<Row icon={SlidersHorizontal} label="影音浏览" value={mediaDensity} to="/me/browse" />
|
||||
<Row icon={PlayCircle} label="播放" to="/me/playback" />
|
||||
</Group>
|
||||
<Group title="账号" order={4}>
|
||||
<Row icon={UserRound} label="账号与连接" to="/me/account" />
|
||||
<Row icon={LogOut} label="退出登录" danger onClick={() => { void runtime.api.signOut(); }} />
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Outside the media screen, so its sheet styles are the shelf's own, not media's. */}
|
||||
{sheet === 'shelf' && <ShelfSettingsSheet settings={settings} onPatch={patch => { void runtime.updateSettings(patch); }} onClose={() => setSheet(null)} onOpenExternalReader={() => setSheet('opds')} />}
|
||||
{sheet === 'opds' && <OpdsAccess api={runtime.api} onSignedOut={() => { void runtime.api.signOut(); }} onClose={() => setSheet('shelf')} />}
|
||||
{sheet === 'system' && <SystemSettings api={runtime.api} onClose={() => setSheet(null)} />}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/* 我的: reuses the media aliases of the theme tokens, so it follows the chosen theme.
|
||||
* The page column is the shared one (app/shell.css). */
|
||||
.mine-page .media-back-button { color: inherit; text-decoration: none; }
|
||||
|
||||
.mine-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 20px 0 4px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 35%, transparent);
|
||||
border-radius: 24px;
|
||||
background: var(--color-brand-soft);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.mine-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 0 3px var(--color-surface);
|
||||
}
|
||||
|
||||
.mine-profile-text { min-width: 0; }
|
||||
.mine-profile-text strong { display: flex; align-items: center; gap: 8px; font-size: 18px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.mine-role { flex: none; height: 20px; padding: 0 8px; border-radius: 10px; background: var(--color-surface); color: var(--color-brand-text); font-size: 11px; font-weight: 600; line-height: 20px; }
|
||||
.mine-profile-text small { display: flex; align-items: center; gap: 6px; margin-top: 4px; font-size: 12px; color: var(--color-muted); overflow-wrap: anywhere; }
|
||||
.mine-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--color-brand-text); }
|
||||
|
||||
/* Narrow: one list in reading order. Wide: two independent columns. */
|
||||
.mine-groups { display: flex; flex-direction: column; }
|
||||
.mine-column { display: contents; }
|
||||
@media (min-width: 64rem) {
|
||||
.mine-groups { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; align-items: start; }
|
||||
.mine-column { display: flex; flex-direction: column; min-width: 0; }
|
||||
}
|
||||
|
||||
.mine-group { margin: 24px 0 0; }
|
||||
.mine-group > h2 { margin: 0 0 8px 6px; font-size: 12px; font-weight: 600; letter-spacing: 0.08em; color: var(--color-muted); }
|
||||
|
||||
.mine-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 18px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.mine-page .mine-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 54px;
|
||||
padding: 0 14px;
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-ink);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background-color .12s ease;
|
||||
}
|
||||
|
||||
/* Separators start after the icon, so the icons read as one column. */
|
||||
.mine-page .mine-row + .mine-row::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 60px;
|
||||
right: 0;
|
||||
border-top: 1px solid var(--color-line);
|
||||
}
|
||||
.mine-page .mine-row:hover { background: var(--color-surface-2); }
|
||||
.mine-page .mine-row:focus-visible { outline: 2px solid var(--color-brand-text); outline-offset: -2px; border-radius: 18px; }
|
||||
|
||||
.mine-row-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: var(--color-brand-soft);
|
||||
color: var(--color-brand-text);
|
||||
}
|
||||
|
||||
.mine-row-label { flex: 1; min-width: 0; font-size: 15px; font-weight: 500; }
|
||||
.mine-row-value { flex: none; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--color-muted); }
|
||||
.mine-row-chevron { flex: none; color: var(--color-muted); opacity: .7; }
|
||||
|
||||
.mine-page .mine-row.is-danger { color: var(--media-error-ink); }
|
||||
.mine-page .mine-row.is-danger .mine-row-icon { background: var(--media-error-background); color: var(--media-error-ink); }
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronRight, Clapperboard, Headphones, Play, type LucideIcon } from 'lucide-react';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
import { BookCard, useCover } from '../../shelf/pages/ShelfPage.tsx';
|
||||
import { useShelf } from '../../shelf/queries/shelf.queries.ts';
|
||||
import { historyQuery, type Activity } from '../../media/queries/media.queries.ts';
|
||||
import type { ContinueReadingItem } from '../../../api/types.ts';
|
||||
import '../styles/home.css';
|
||||
|
||||
export function greeting(hour: number): string {
|
||||
return hour < 6 ? '夜深了' : hour < 12 ? '早上好' : hour < 18 ? '下午好' : '晚上好';
|
||||
}
|
||||
|
||||
function clock(seconds: number): string {
|
||||
const total = Math.max(0, Math.floor(seconds));
|
||||
const h = Math.floor(total / 3600), m = Math.floor((total % 3600) / 60), s = total % 60;
|
||||
return (h ? `${h}:${String(m).padStart(2, '0')}` : String(m)) + `:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Progress through the unfinished activity, 0..1; unknown length reads as 0 rather than a guess. */
|
||||
export function activityProgress(activity: Activity): number {
|
||||
const length = activity.end === null ? 0 : activity.end - activity.start;
|
||||
return length > 0 && activity.position !== undefined ? Math.max(0, Math.min(1, (activity.position - activity.start) / length)) : 0;
|
||||
}
|
||||
|
||||
function Resume({ book }: { book: ContinueReadingItem }) {
|
||||
const runtime = useRuntime();
|
||||
const src = useCover(runtime.api, book.coverUrl);
|
||||
const percent = Math.round(Math.max(0, Math.min(1, book.percentage)) * 100);
|
||||
return <Link className="home-hero" to={`/book/${encodeURIComponent(book.id)}`} aria-label={`继续阅读 ${book.title}`}>
|
||||
<span className="home-hero-cover" aria-hidden="true">{src ? <img src={src} alt="" /> : <b>{book.title}</b>}</span>
|
||||
<span className="home-hero-text">
|
||||
<strong>{book.title || '未命名'}</strong>
|
||||
{book.chapterTitle && <small>{book.chapterTitle}</small>}
|
||||
<span className="home-hero-foot">
|
||||
<span className="home-hero-action"><Play size={14} fill="currentColor" aria-hidden="true" />继续阅读</span>
|
||||
<span className="home-hero-meter">
|
||||
<span className="home-progress" aria-hidden="true"><i style={{ width: `${percent}%` }} /></span>
|
||||
<span className="home-hero-percent">已读 {percent}%</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</Link>;
|
||||
}
|
||||
|
||||
function ResumeMedia({ channel, label, icon: Icon, activity }: { channel: 'video' | 'audiobook'; label: string; icon: LucideIcon; activity: Activity }) {
|
||||
const progress = activityProgress(activity);
|
||||
const title = `${activity.title}${activity.partTitle && activity.partTitle !== activity.title ? ` · ${activity.partTitle}` : ''}`;
|
||||
const verb = channel === 'video' ? '观看' : '收听';
|
||||
// Without a known length there is no time to show; say what tapping does instead of
|
||||
// repeating the part title that the heading already carries.
|
||||
const meta = activity.end !== null && activity.position !== undefined
|
||||
? `${channel === 'video' ? '已看' : '已听'} ${clock(activity.position - activity.start)} / ${clock(activity.end - activity.start)}`
|
||||
: `继续${verb}`;
|
||||
return <Link className="home-card" data-channel={channel} to={`/media/${channel}/items/${encodeURIComponent(activity.itemId)}`}>
|
||||
<span className="home-card-icon" aria-hidden="true"><Icon size={20} /></span>
|
||||
<span className="home-card-text">
|
||||
<span className="home-card-label">{label}</span>
|
||||
<strong>{title}</strong>
|
||||
<small>{meta}</small>
|
||||
<span className="home-progress" aria-hidden="true"><i style={{ width: `${Math.round(progress * 100)}%` }} /></span>
|
||||
</span>
|
||||
<ChevronRight size={16} aria-hidden="true" className="home-card-chevron" />
|
||||
</Link>;
|
||||
}
|
||||
|
||||
/** 首页: where you left off, across reading and media, then the shelf. */
|
||||
export function HomePage() {
|
||||
const runtime = useRuntime();
|
||||
const user = useAuthStore(state => state.verifiedUser ?? state.session?.user ?? null);
|
||||
const reading = useQuery({
|
||||
queryKey: ['home', 'reading', runtime.api.baseUrl, user?.id],
|
||||
queryFn: ({ signal }) => runtime.api.continueReading(1, { signal }),
|
||||
});
|
||||
const shelf = useShelf(1, 'recent');
|
||||
const video = useQuery(historyQuery(runtime, 'video', new URLSearchParams()));
|
||||
const audio = useQuery(historyQuery(runtime, 'audiobook', new URLSearchParams()));
|
||||
const unfinished = (items?: Activity[]) => items?.find(item => !item.completed);
|
||||
const book = reading.data?.[0];
|
||||
const videoActivity = unfinished(video.data?.items), audioActivity = unfinished(audio.data?.items);
|
||||
const books = (shelf.data?.items ?? []).slice(0, 8);
|
||||
const name = user?.displayName || user?.username || '';
|
||||
return <div className="media-screen media-page home-page">
|
||||
<header className="home-header"><h1>{greeting(new Date().getHours())}{name ? `,${name}` : ''}</h1><p>从上次停下的地方继续</p></header>
|
||||
<div className="home-continue" data-media={videoActivity || audioActivity ? 'true' : undefined}>
|
||||
{book
|
||||
? <Resume book={book} />
|
||||
: <section className="home-empty" aria-label="继续阅读"><strong>还没有在读的书</strong><Link to="/shelf">去书架挑一本<ChevronRight size={16} aria-hidden="true" /></Link></section>}
|
||||
{(videoActivity || audioActivity) && <div className="home-cards">
|
||||
{videoActivity && <ResumeMedia channel="video" label="影视" icon={Clapperboard} activity={videoActivity} />}
|
||||
{audioActivity && <ResumeMedia channel="audiobook" label="听书" icon={Headphones} activity={audioActivity} />}
|
||||
</div>}
|
||||
</div>
|
||||
{books.length > 0 && <section className="home-shelf" aria-label="我的书架">
|
||||
<div className="home-section"><h2>我的书架</h2><Link to="/shelf">全部<ChevronRight size={16} aria-hidden="true" /></Link></div>
|
||||
<div className="home-shelf-row">{books.map(item => <BookCard key={item.id} book={item} showAuthor={false} showProgress />)}</div>
|
||||
</section>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/* 首页: continue where you left off. Uses the shared theme tokens only:
|
||||
* continue cards sit on ch-*-soft with ink text, brand fills carry on-brand text. */
|
||||
.home-header h1 { margin: 6px 0 0; font-family: 'Songti SC', 'Noto Serif SC', 'Source Han Serif SC', serif; font-size: 28px; font-weight: 600; line-height: 1.2; color: var(--color-ink); }
|
||||
.home-header p { margin: 6px 0 0; font-size: 14px; color: var(--color-muted); }
|
||||
|
||||
.home-progress { display: block; height: 4px; border-radius: 2px; background: color-mix(in srgb, var(--color-ink) 10%, transparent); overflow: hidden; }
|
||||
.home-progress > i { display: block; height: 100%; min-width: 4px; border-radius: 2px; background: var(--color-brand); }
|
||||
|
||||
/* Reading takes the lead; unfinished media sits beside it on a wide window, below it otherwise. */
|
||||
.home-continue { display: grid; gap: 12px; margin-top: 20px; }
|
||||
@media (min-width: 64rem) {
|
||||
.home-continue[data-media] { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); align-items: stretch; }
|
||||
}
|
||||
|
||||
.home-hero, .home-empty {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-ch-read) 35%, transparent);
|
||||
border-radius: 24px;
|
||||
background: var(--color-ch-read-soft);
|
||||
color: var(--color-ink);
|
||||
text-decoration: none;
|
||||
transition: border-color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.home-hero:hover { border-color: var(--color-ch-read); box-shadow: 0 8px 24px color-mix(in srgb, var(--color-ink) 8%, transparent); }
|
||||
.home-hero:focus-visible, .home-card:focus-visible, .home-empty a:focus-visible { outline: 2px solid var(--color-brand-text); outline-offset: 3px; }
|
||||
|
||||
.home-hero-cover {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 96px;
|
||||
aspect-ratio: 5 / 7;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-ink);
|
||||
box-shadow: 0 1px 2px color-mix(in srgb, var(--color-ink) 12%, transparent), 0 10px 22px color-mix(in srgb, var(--color-ink) 16%, transparent);
|
||||
}
|
||||
.home-hero-cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.home-hero-cover b { padding: 10px; font-family: 'Songti SC', 'Noto Serif SC', serif; font-size: 15px; font-weight: 600; line-height: 1.35; letter-spacing: 0.05em; text-align: center; overflow-wrap: anywhere; }
|
||||
|
||||
.home-hero-text { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; }
|
||||
.home-hero-text strong { margin-top: 8px; font-family: 'Songti SC', 'Noto Serif SC', serif; font-size: 22px; font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; }
|
||||
.home-hero-text small { font-size: 13px; color: var(--color-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.home-hero-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; margin-top: auto; padding-top: 14px; }
|
||||
.home-hero-action { display: inline-flex; align-items: center; gap: 6px; flex: none; height: 38px; padding: 0 18px; border-radius: 19px; background: var(--color-brand); color: var(--color-on-brand); font-size: 14px; font-weight: 600; }
|
||||
.home-hero-meter { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 140px; max-width: 320px; }
|
||||
.home-hero-meter .home-progress { flex: 1; }
|
||||
.home-hero-percent { flex: none; font-size: 12px; color: var(--color-muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.home-empty { flex-direction: column; justify-content: center; align-items: flex-start; gap: 8px; min-height: 120px; }
|
||||
.home-empty strong { font-family: 'Songti SC', 'Noto Serif SC', serif; font-size: 18px; }
|
||||
.home-empty a { display: inline-flex; align-items: center; color: var(--color-ch-read-text); font-size: 14px; font-weight: 600; text-decoration: none; }
|
||||
|
||||
.home-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; align-content: start; }
|
||||
@media (min-width: 64rem) {
|
||||
.home-continue[data-media] .home-cards { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
.home-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 14px 14px 16px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 20px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-ink);
|
||||
text-decoration: none;
|
||||
transition: border-color .15s ease;
|
||||
--ch: var(--color-ch-video); --ch-soft: var(--color-ch-video-soft); --ch-text: var(--color-ch-video-text);
|
||||
}
|
||||
.home-card[data-channel='audiobook'] { --ch: var(--color-ch-audio); --ch-soft: var(--color-ch-audio-soft); --ch-text: var(--color-ch-audio-text); }
|
||||
.home-card:hover { border-color: var(--ch); }
|
||||
.home-card-icon { display: grid; place-items: center; flex: none; width: 44px; height: 44px; border-radius: 14px; background: var(--ch-soft); color: var(--ch-text); }
|
||||
.home-card-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.home-card-label { font-size: 12px; font-weight: 600; color: var(--ch-text); }
|
||||
.home-card strong { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.home-card small { margin-bottom: 6px; font-size: 12px; color: var(--color-muted); }
|
||||
.home-card .home-progress > i { background: var(--ch); }
|
||||
.home-card-chevron { flex: none; color: var(--color-muted); }
|
||||
|
||||
.home-section { display: flex; align-items: baseline; margin: 32px 0 12px; }
|
||||
.home-section h2 { flex: 1; margin: 0; font-family: 'Songti SC', 'Noto Serif SC', serif; font-size: 19px; font-weight: 600; }
|
||||
.home-section a { display: inline-flex; align-items: center; color: var(--color-muted); font-size: 13px; text-decoration: none; }
|
||||
.home-section a:hover { color: var(--color-brand-text); }
|
||||
.home-shelf-row { display: flex; gap: 16px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; }
|
||||
.home-shelf-row .book-card { flex: none; width: 112px; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.home-hero { gap: 14px; padding: 16px; }
|
||||
.home-hero-cover { width: 80px; }
|
||||
.home-hero-text strong { font-size: 19px; }
|
||||
.home-hero-meter { max-width: none; }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Alert, Badge, Button, ActionIcon, Menu } from '@mantine/core';
|
||||
import { ArrowLeft, BookOpen, FileText, Folder, MoreHorizontal, Plus, Search, Upload } from 'lucide-react';
|
||||
import { BookOpen, FileText, Folder, Plus, Search, Upload } from 'lucide-react';
|
||||
import { ActionMenu } from '../../../ui/action-menu.tsx';
|
||||
import { Callout, Icon } from '../../../ui/toolkit.tsx';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
@@ -7,7 +8,7 @@ import type { Book, BrowseEntry } from '../../../api/types.ts';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
import { DialogView, type Dialog } from '../../../ui/dialog.tsx';
|
||||
import { formatBytes, formatDate } from '../../../ui/dom.ts';
|
||||
import { formatBytes, formatDate } from '../../../lib/format.ts';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
|
||||
function Cover({ book }: { book: Book }) {
|
||||
@@ -28,7 +29,7 @@ function Cover({ book }: { book: Book }) {
|
||||
}
|
||||
|
||||
function CollectionHeader({ title, subtitle, backTo, actions, className, footer }: { title: string; subtitle: string; backTo?: string; actions?: React.ReactNode; className?: string; footer?: React.ReactNode }) {
|
||||
return <header className={'collection-header' + (className ? ` ${className}` : '')}><div className="collection-heading">{backTo && <Button component={Link} to={backTo} className="icon-button" variant="subtle" aria-label="返回"><ArrowLeft size={20} /></Button>}<div className="collection-heading-text"><h1>{title}</h1><p className="muted">{subtitle}</p></div></div>{actions && <nav className="collection-links" aria-label="页面操作">{actions}</nav>}{footer}</header>;
|
||||
return <header className={'collection-header' + (className ? ` ${className}` : '')}><div className="collection-heading">{backTo && <Link to={backTo} className="icon-button" aria-label="返回"><Icon name="arrow-left" /></Link>}<div className="collection-heading-text"><h1>{title}</h1><p className="muted">{subtitle}</p></div></div>{actions && <nav className="collection-links" aria-label="页面操作">{actions}</nav>}{footer}</header>;
|
||||
}
|
||||
|
||||
export function LibraryPage() { return <LibraryBrowsePage />; }
|
||||
@@ -47,7 +48,7 @@ export function LibraryBrowsePage() {
|
||||
{listing.data && listing.data.crumbs.length > 1 && <div className="manager-crumbs library-path" aria-label="当前文件夹">{listing.data.crumbs.map((crumb, index) => <span key={`${crumb.path}-${index}`}>{index > 0 && <span className="manager-crumb-sep">/</span>}<button type="button" className="manager-crumb" onClick={() => navigate(crumb.path)}>{crumb.name}</button></span>)}</div>}
|
||||
<main className="manager-body library-browse-body">
|
||||
<form className="shelf-search library-search" role="search" onSubmit={(event: FormEvent) => { event.preventDefault(); navigate(path, 1, draft.trim()); }}><Search className="search-glyph" size={18} aria-hidden="true" /><input type="search" aria-label="搜索书库" placeholder="搜索书名、作者" value={draft} onChange={event => setDraft(event.currentTarget.value)} />{draft && <button type="button" className="search-clear" aria-label="清除搜索" onClick={() => { setDraft(''); navigate(path); }}>×</button>}</form>
|
||||
{books.isPending && <FloatingNotice message="正在加载书库…" busy />}{books.error && <Alert color="red" title="书库加载失败">{books.error.message}</Alert>}
|
||||
{books.isPending && <FloatingNotice message="正在加载书库…" busy />}{books.error && <Callout tone="error" title="书库加载失败">{books.error.message}</Callout>}
|
||||
{empty && <div className="empty-state collection-empty"><BookOpen className="empty-glyph" aria-hidden="true" /><p>{search ? '没有匹配的书' : '书库还没有书'}</p><p className="muted">{search ? '换个关键词,或者检查一下作者名的写法。' : '前往文件管理添加书籍,再回到这里浏览。'}</p><div className="empty-actions">{search ? <button type="button" className="button" onClick={() => { setDraft(''); navigate(path); }}>清除搜索</button> : role === 'admin' ? <Link className="button primary" to="/library/files"><Folder size={16} />文件管理</Link> : null}</div></div>}
|
||||
{books.data && books.data.items.length > 0 && <div className="book-grid library-browse-grid" data-density="cozy" data-show-author="true">{books.data.items.map(book => <div className="book-card is-row" key={book.id}><Link className="book-open" to={`/book/${encodeURIComponent(book.id)}`} aria-label={`${book.title} ${book.author ?? ''}`}><Cover book={book} /><div className="title">{book.title || '未命名'}</div><div className="author">{book.author || '未知作者'}</div></Link>{book.shelfState === 'off' && <button type="button" className="book-shelve" aria-label={`把${book.title}加入书架`} disabled={busy === book.id} onClick={() => void toggle(book)}>+ 加入书架</button>}</div>)}</div>}
|
||||
{books.data && <div className="manager-status muted">{search ? `找到 ${count} 本` : `${count} 本 · 第 ${page} / ${pages} 页`}</div>}
|
||||
@@ -88,10 +89,10 @@ function LibraryFilesAdminPage() {
|
||||
const crumbs = <div className="manager-crumbs">{(query.data?.crumbs ?? [{ name: '书库', path: '' }]).map((crumb, index, all) => <span key={`${crumb.path}-${index}`}>{index > 0 && <span className="manager-crumb-sep">/</span>}<button type="button" className="manager-crumb" aria-current={index === all.length - 1 ? 'true' : undefined} onClick={() => go(crumb.path)}>{crumb.name}</button></span>)}</div>;
|
||||
return <div className="library-screen library-files-screen"><CollectionHeader className="file-manager-header" title="文件管理" subtitle="管理书库中的文件与文件夹" backTo="/library" actions={<><label className={'button collection-link' + (!query.data?.writable ? ' is-disabled' : '')}><Upload size={18} /><span>上传书籍</span><input hidden type="file" multiple disabled={!query.data?.writable} onChange={event => { const files = Array.from(event.currentTarget.files ?? []); if (files.length) void mutate(() => runtime.api.upload(files, path, 'rename')); event.currentTarget.value = ''; }} /></label><button type="button" className="button collection-link" disabled={!query.data?.writable} onClick={mkdir}><Plus size={18} /><span>新建文件夹</span></button></>} footer={crumbs} />
|
||||
<main className="manager-body">
|
||||
{selected.length > 0 && <div className="manager-actions"><Badge>已选 {selected.length} 项</Badge><button type="button" className="button" onClick={() => void move()}>移动</button><button type="button" className="button danger" onClick={() => void remove()}>删除</button><button type="button" className="button" onClick={() => setSelected([])}>清除</button></div>}
|
||||
{feedback && <Alert color={feedback.error ? 'red' : 'green'} withCloseButton onClose={() => setFeedback(null)}>{feedback.text}</Alert>}
|
||||
{query.isPending && <FloatingNotice message="正在读取文件列表…" busy />}{query.error && <Alert color="red">{query.error.message}</Alert>}
|
||||
{query.data && <div className={'manager-list' + (selected.length ? ' selection-mode' : '')}>{query.data.entries.length === 0 ? <div className="empty-state collection-empty"><Folder className="empty-glyph" aria-hidden="true" /><p>这个文件夹是空的</p><p className="muted">{query.data.writable ? '使用上方的上传或新建文件夹,整理你的书库。' : '当前目录只读,可以浏览已有文件。'}</p></div> : query.data.entries.map(entry => { const checked = selected.includes(entry.path); return <div className="manager-row" data-type={entry.type} data-selected={checked ? 'true' : undefined} key={entry.path} tabIndex={0}><button type="button" className="manager-check" role="checkbox" aria-label={`选择${entry.name}`} aria-checked={checked} onClick={event => { event.stopPropagation(); setSelected(items => items.includes(entry.path) ? items.filter(item => item !== entry.path) : [...items, entry.path]); }} /><div className="manager-name"><span className="manager-icon">{entry.type === 'dir' ? <Folder size={20} /> : <FileText size={20} />}</span><span className="manager-label">{entry.type === 'dir' ? <button type="button" className="manager-crumb" onClick={() => go(entry.path)}>{entry.name}</button> : entry.name}</span></div><div className="manager-meta muted">{entryMeta(entry)}</div><Menu><Menu.Target><ActionIcon className="manager-more" variant="subtle" aria-label={`${entry.name} 的操作`}><MoreHorizontal size={19} /></ActionIcon></Menu.Target><Menu.Dropdown className="library-file-menu"><Menu.Item disabled={!query.data.writable} onClick={() => void rename(entry)}>重命名</Menu.Item><Menu.Item onClick={() => setSelected([entry.path])}>选择</Menu.Item><Menu.Item disabled={!query.data.writable} onClick={() => void move([entry.path])}>移动…</Menu.Item><Menu.Item disabled={!query.data.writable} color="red" onClick={() => void remove([entry.path])}>删除</Menu.Item></Menu.Dropdown></Menu></div>; })}</div>}
|
||||
{selected.length > 0 && <div className="manager-actions"><span className="manager-selected-count">已选 {selected.length} 项</span><button type="button" className="button" onClick={() => void move()}>移动</button><button type="button" className="button danger" onClick={() => void remove()}>删除</button><button type="button" className="button" onClick={() => setSelected([])}>清除</button></div>}
|
||||
<FloatingNotice message={feedback?.text ?? ''} error={!!feedback?.error} kind="success" />
|
||||
{query.isPending && <FloatingNotice message="正在读取文件列表…" busy />}{query.error && <Callout tone="error">{query.error.message}</Callout>}
|
||||
{query.data && <div className={'manager-list' + (selected.length ? ' selection-mode' : '')}>{query.data.entries.length === 0 ? <div className="empty-state collection-empty"><Folder className="empty-glyph" aria-hidden="true" /><p>这个文件夹是空的</p><p className="muted">{query.data.writable ? '使用上方的上传或新建文件夹,整理你的书库。' : '当前目录只读,可以浏览已有文件。'}</p></div> : query.data.entries.map(entry => { const checked = selected.includes(entry.path); return <div className="manager-row" data-type={entry.type} data-selected={checked ? 'true' : undefined} key={entry.path} tabIndex={0}><button type="button" className="manager-check" role="checkbox" aria-label={`选择${entry.name}`} aria-checked={checked} onClick={event => { event.stopPropagation(); setSelected(items => items.includes(entry.path) ? items.filter(item => item !== entry.path) : [...items, entry.path]); }} /><div className="manager-name"><span className="manager-icon">{entry.type === 'dir' ? <Folder size={20} /> : <FileText size={20} />}</span><span className="manager-label">{entry.type === 'dir' ? <button type="button" className="manager-crumb" onClick={() => go(entry.path)}>{entry.name}</button> : entry.name}</span></div><div className="manager-meta muted">{entryMeta(entry)}</div><ActionMenu className="manager-more" label={`${entry.name} 的操作`} title={entry.name} items={[{ label: '重命名', disabled: !query.data.writable, onSelect: () => void rename(entry) }, { label: '选择', onSelect: () => setSelected([entry.path]) }, { label: '移动…', disabled: !query.data.writable, onSelect: () => void move([entry.path]) }, { label: '删除', danger: true, disabled: !query.data.writable, onSelect: () => void remove([entry.path]) }]} /></div>; })}</div>}
|
||||
{query.data && <div className="manager-status muted">{query.data.dirs} 个文件夹 · {query.data.files} 个文件 · {formatBytes(query.data.size)}</div>}
|
||||
{pages > 1 && <nav className="manager-pager shelf-pager"><button className="pager-step" type="button" disabled={page <= 1} onClick={() => go(path, page - 1)}>上一页</button><span>{page} / {pages}</span><button className="pager-step" type="button" disabled={page >= pages} onClick={() => go(path, page + 1)}>下一页</button></nav>}
|
||||
</main>{dialog && <DialogView dialog={dialog} destructive="删除" onClose={answer => { const current = dialog; setDialog(null); current.resolve(answer as never); }} />}</div>;
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface ScanDiagnostics {
|
||||
elapsedMs:number;active:Array<{phase:string;ref?:string;elapsedMs:number}>;
|
||||
timings:Record<string,number>;logs:Array<{at:number;message:string}>;
|
||||
}
|
||||
export interface ScanJob {id:string;libraryId?:string;state:string;inspected:number;error:string|null;diagnostics?:ScanDiagnostics}
|
||||
export interface ScanJob {id:string;libraryId?:string;state:string;inspected:number;/** Files in the listing; null until it is known (older servers: absent). */total?:number|null;error:string|null;startedAt?:number;finishedAt?:number|null;diagnostics?:ScanDiagnostics}
|
||||
export interface AiScanJob {id:string;libraryId:string;path:string;state:'queued'|'running'|'complete'|'failed';totalPaths:number;processedPaths:number;batchCount:number;resultCount:number;error:string|null;startedAt:number;finishedAt:number|null}
|
||||
export interface AiScanBatch {id:string;jobId:string;batch:number;pathCount:number;paths:string[];raw:string|null;items:unknown[];state:'running'|'complete'|'failed';error:string|null;startedAt:number;finishedAt:number|null}
|
||||
export interface Playback {id:string;itemId?:string;partId:string;streamUrl:string;contentType:string;expiresAt:number;position:number;start:number;end:number|null;revision:number;playbackMode?:'auto'|'direct'|'proxy'}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Text, UnstyledButton } from '@mantine/core';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MediaCover } from './cover.tsx';
|
||||
import type { Item, MediaChannel } from '../api/media-api.ts';
|
||||
@@ -8,15 +7,15 @@ import { itemLabel } from './item-label.ts';
|
||||
|
||||
export function MediaItemGrid({ items, channel }: { items: Item[]; channel?: MediaChannel }) {
|
||||
const { mediaApi } = useRuntime();
|
||||
if (!items.length) return <Text className="media-empty" c="dimmed">暂无内容。</Text>;
|
||||
if (!items.length) return <p className="media-empty muted">暂无内容</p>;
|
||||
return <div className="media-grid">
|
||||
{items.map(item => {
|
||||
const subtitle = itemLabel(item);
|
||||
return <UnstyledButton className="media-tile" key={item.id} component={Link} to={`/media/${channel ?? favoriteChannel(item)}/items/${encodeURIComponent(item.id)}`}>
|
||||
return <Link className="media-tile" key={item.id} to={`/media/${channel ?? favoriteChannel(item)}/items/${encodeURIComponent(item.id)}`}>
|
||||
<MediaCover api={mediaApi} item={item} square={['album', 'artist', 'track'].includes(item.kind)} />
|
||||
<strong title={item.title}>{item.title}</strong>
|
||||
{subtitle && <small title={subtitle}>{subtitle}</small>}
|
||||
</UnstyledButton>;
|
||||
</Link>;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Alert, Button, Title, UnstyledButton } from '@mantine/core';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Callout, Icon } from '../../../ui/toolkit.tsx';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMediaChannel } from '../hooks/use-media-channel.ts';
|
||||
import { ChannelSwitcher } from './channel-navigation.tsx';
|
||||
import { ChannelSegments } from './channel-navigation.tsx';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
|
||||
export function MediaPageFrame({ title, subtitle, children, actions, className, backTo, backLabel, hideHeader = false }: { title: string; subtitle?: ReactNode; children: ReactNode; actions?: ReactNode; className?: string; backTo?: string; backLabel?: string; hideHeader?: boolean }) {
|
||||
@@ -17,10 +16,14 @@ export function MediaPageFrame({ title, subtitle, children, actions, className,
|
||||
return <div className={rootClassName}>
|
||||
{!hideHeader && <header className="media-heading">
|
||||
{catalog
|
||||
? <span className="media-mobile-title"><ChannelSwitcher current={channel} label={title} /></span>
|
||||
? <>
|
||||
{/* The visible title is the segmented switch; the page still needs its one h1. */}
|
||||
<h1 className="media-visually-hidden">{title}</h1>
|
||||
<span className="media-mobile-title"><ChannelSegments current={channel} /></span>
|
||||
</>
|
||||
: <div className="media-page-heading">
|
||||
<UnstyledButton className="media-back-button" component={Link} to={target} aria-label="← 返回" title={label}><ChevronLeft size={20} aria-hidden="true" /></UnstyledButton>
|
||||
<Title order={1}>{title}</Title>
|
||||
<Link className="media-back-button" to={target} aria-label="← 返回" title={label}><Icon name="chevron-left" /></Link>
|
||||
<h1>{title}</h1>
|
||||
{subtitle && <span className="media-heading-context">{subtitle}</span>}
|
||||
</div>}
|
||||
<div id={className?.split(/\s+/).includes('media-secondary-page') ? 'media-heading-actions-host' : undefined} className="media-heading-actions">{actions}</div>
|
||||
@@ -30,6 +33,6 @@ export function MediaPageFrame({ title, subtitle, children, actions, className,
|
||||
}
|
||||
export function QueryFeedback({ pending, error, retry }: { pending?: boolean; error?: Error | null; retry?: () => void }) {
|
||||
if (pending) return <FloatingNotice message="正在加载…" busy />;
|
||||
if (error) return <Alert color="red" title="加载失败" role="alert">{error.message}{retry && <Button variant="subtle" onClick={retry}>重试</Button>}</Alert>;
|
||||
if (error) return <Callout tone="error" title="加载失败" {...(retry ? { action: { label: '重试', onClick: retry } } : {})}>{error.message}</Callout>;
|
||||
return null;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ActionIcon, Group, Paper, Text } from '@mantine/core';
|
||||
import { Pause, Play, X } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { usePlaybackStore } from '../stores/playback.store.ts';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
export function MiniPlayer() {
|
||||
const runtime = useRuntime();
|
||||
const { active, title, paused, error, loadingStatus, channel, itemId, partId } = usePlaybackStore();
|
||||
const location = useLocation();
|
||||
if (!active || location.pathname.includes('/player')) return null;
|
||||
return <Paper className="react-mini-player" withBorder shadow="sm" p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text component={Link} to={`/media/${channel}/player?item=${encodeURIComponent(itemId)}&part=${encodeURIComponent(partId)}`} truncate fw={600}>{title || '正在播放'}</Text>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{loadingStatus && <Text size="xs" c="dimmed">{loadingStatus}</Text>}
|
||||
{error && <Text size="xs" c="red">{error}</Text>}
|
||||
<ActionIcon aria-label={paused ? '继续播放' : '暂停播放'} onClick={() => runtime.player.toggle()}>{paused ? <Play size={16} /> : <Pause size={16} />}</ActionIcon>
|
||||
<ActionIcon aria-label="停止播放" onClick={() => void runtime.player.stop()}><X size={16} /></ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>;
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Heart, X } from 'lucide-react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { Heart } from 'lucide-react';
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import type { MediaApi } from '../api/media-api.ts';
|
||||
import { Sheet } from '../../../ui/sheet.tsx';
|
||||
|
||||
/** The audio player's menus: the app's one sheet, with the audio content styles. */
|
||||
export function AudioToolSheet({title,onClose,children}:{title:string;onClose:()=>void;children:ReactNode}){
|
||||
const ref=useRef<HTMLDialogElement>(null);
|
||||
useLayoutEffect(()=>{const dialog=ref.current;if(!dialog)return;const previous=document.activeElement as HTMLElement|null;dialog.showModal();return ()=>{dialog.close();queueMicrotask(()=>{if(previous?.isConnected)previous.focus({preventScroll:true});});};},[]);
|
||||
return <dialog ref={ref} className="media-audio-sheet" aria-label={title} onCancel={event=>{event.preventDefault();onClose();}} onKeyDown={event=>{if(event.key==='Escape')event.stopPropagation();}} onClick={event=>{if(event.target===event.currentTarget){const rect=event.currentTarget.getBoundingClientRect();if(event.clientX<rect.left||event.clientX>rect.right||event.clientY<rect.top||event.clientY>rect.bottom)onClose();}}}>
|
||||
<header><h2>{title}</h2><button aria-label="关闭播放菜单" onClick={onClose}><X size={20} aria-hidden="true"/></button></header>{children}
|
||||
</dialog>;
|
||||
return <Sheet className="media-audio-sheet" title={title} onClose={onClose}>{children}</Sheet>;
|
||||
}
|
||||
|
||||
export function PlaybackFavorite({api,itemId,onChanged}:{api:MediaApi;itemId:string;onChanged?:(id:string,favorite:boolean)=>void}){
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { History, LibraryBig, ListVideo, Star } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ActionMenu } from '../../../ui/action-menu.tsx';
|
||||
import type { MediaChannel } from '../api/media-api.ts';
|
||||
|
||||
/** The ⋯ menu on a channel's landing page: the same four destinations on every channel. */
|
||||
export function ChannelMenu({ channel }: { channel: MediaChannel }) {
|
||||
const navigate = useNavigate();
|
||||
return <ActionMenu className="media-icon-button" label="更多操作" items={[
|
||||
{ label: '收藏', icon: <Star size={16} />, onSelect: () => navigate('/media/favorites') },
|
||||
{ label: '历史', icon: <History size={16} />, onSelect: () => navigate(`/media/${channel}/history`) },
|
||||
{ label: '队列', icon: <ListVideo size={16} />, onSelect: () => navigate(`/media/${channel}/queue`) },
|
||||
{ label: '媒体库管理', icon: <LibraryBig size={16} />, onSelect: () => navigate(`/media/${channel}/settings/libraries`) },
|
||||
]} />;
|
||||
}
|
||||
@@ -1,35 +1,21 @@
|
||||
import { render } from '../../../shared/ui/render-root.ts';
|
||||
import { BookOpen, Clapperboard, Music2, Headphones, ChevronsUpDown, Check } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Clapperboard, Music2, Headphones } from 'lucide-react';
|
||||
|
||||
const channels = [
|
||||
{ id: '', label: '阅读', icon: BookOpen },
|
||||
{ id: 'video', label: '影视', icon: Clapperboard },
|
||||
{ id: 'music', label: '音乐', icon: Music2 },
|
||||
{ id: 'audiobook', label: '有声书', icon: Headphones },
|
||||
];
|
||||
export function ChannelSwitcher({current,label}:{current:string;label?:string}) {
|
||||
const root=useRef<HTMLDetailsElement>(null);
|
||||
useEffect(()=>{const close=(event:PointerEvent)=>{if(root.current&&!root.current.contains(event.target as Node))root.current.open=false;};document.addEventListener('pointerdown',close);return()=>document.removeEventListener('pointerdown',close);},[]);
|
||||
return <details className="media-channel-switcher" ref={root} onKeyDown={event=>{if(event.key==='Escape'){event.preventDefault();event.stopPropagation();root.current!.open=false;root.current!.querySelector('summary')?.focus();}}}>
|
||||
<summary aria-label="切换频道" title="切换频道">{label&&<strong>{label}</strong>}<ChevronsUpDown size={18} aria-hidden="true"/></summary>
|
||||
<nav aria-label="切换内容频道">{channels.map(({id,label,icon:Icon})=><a key={id} href={id?'#/media/'+id:'#/shelf'} aria-current={id===current?'page':undefined} onClick={()=>{if(root.current)root.current.open=false;}}><Icon size={18} aria-hidden="true"/><span>{label}</span>{id===current&&<Check size={16} aria-hidden="true"/>}</a>)}</nav>
|
||||
</details>;
|
||||
}
|
||||
export function MediaChannelEntry({ current }: { current: string }) {
|
||||
return <nav className="media-channel-entry" aria-label="内容频道">
|
||||
<span className="media-brand" aria-hidden="true">reader.</span>{channels.map(({ id, label, icon: Glyph }) =>
|
||||
<Link key={id} to={id ? `/media/${id}` : '/shelf'} aria-current={current === id ? 'page' : undefined}>
|
||||
<span className="media-channel-icon"><Glyph size={22} strokeWidth={1.8} aria-hidden="true" /></span>
|
||||
<span>{label}</span>
|
||||
</Link>)}
|
||||
/** The three media channels. Reading, home and the account page live in the app navigation. */
|
||||
export const mediaChannels = [
|
||||
{ id: 'video', label: '影视', icon: Clapperboard },
|
||||
{ id: 'music', label: '音乐', icon: Music2 },
|
||||
{ id: 'audiobook', label: '有声书', icon: Headphones },
|
||||
] as const;
|
||||
|
||||
export const channelPath = (id: string) => `/media/${id}`;
|
||||
|
||||
/**
|
||||
* Phone channel switch: three always-visible segments instead of a hidden menu.
|
||||
* On wider screens the sidebar lists the same channels, so this is hidden there.
|
||||
*/
|
||||
export function ChannelSegments({ current }: { current: string }) {
|
||||
return <nav className="media-channel-segments" aria-label="切换内容频道">
|
||||
{mediaChannels.map(({ id, label }) =>
|
||||
<a key={id} href={'#' + channelPath(id)} aria-current={id === current ? 'page' : undefined}>{label}</a>)}
|
||||
</nav>;
|
||||
}
|
||||
export function renderChannelLinks(element: HTMLElement, current: string) {
|
||||
render(<><span className="media-brand" aria-hidden="true">reader.</span>{channels.map(({ id, label, icon: Glyph }) =>
|
||||
<a key={id} href={id ? '#/media/' + id : '#/shelf'} aria-current={current === id ? 'page' : undefined}>
|
||||
<span className="media-channel-icon"><Glyph size={22} strokeWidth={1.8} aria-hidden="true" /></span>
|
||||
<span>{label}</span>
|
||||
</a>)}</>, element);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import {MediaResourceSummary} from './resource-summary.tsx';
|
||||
import {mediaActionError} from './action-error.ts';
|
||||
import { useState } from 'react';
|
||||
import {Search,ListPlus,Ellipsis} from 'lucide-react';
|
||||
import {Search,ListPlus} from 'lucide-react';
|
||||
import {ActionMenu} from '../../../ui/action-menu.tsx';
|
||||
import type { Detail, Edition, MediaApi, Part } from '../api/media-api.ts';
|
||||
import { ResourceInfo } from './resource-info.tsx';
|
||||
import { EditionAssignment } from './edition-assignment.tsx';
|
||||
@@ -32,7 +33,7 @@ export function EditionDetails(props:EditionDetailsProps) {
|
||||
{!preview&&<div className="media-toolbar media-chapter-toolbar"><span>{edition.parts.length} 节 · {playable.length} 节可用</span><div>{layout==='chapters'&&edition.parts.length>10&&<button className="media-chapter-icon" aria-label={location.searchOpen?'收起章节查找':'展开章节查找'} title="查找章节" aria-expanded={!!location.searchOpen} onClick={()=>changePosition(location.searchOpen?{query:'',page:0,searchOpen:false}:{...location,searchOpen:true})}><Search size={18} aria-hidden="true"/></button>}<button className={layout==='chapters'?'media-chapter-icon':undefined} aria-label="加入队列" title="加入待播队列" disabled={busy||!playable.length} onClick={()=>onQueue(playable.map(part=>part.id))}>{layout==='chapters'?<ListPlus size={18} aria-hidden="true"/>:'加入队列'}</button></div></div>}
|
||||
{edition.parts.length>0&&!playable.length?<MissingEdition label={edition.label} busy={busy} onRefresh={onRefresh} onChooseVersion={props.onChooseVersion}/>:playable.length<edition.parts.length&&<div className="media-missing-resource" role="status"><p>{edition.parts.length-playable.length} 节资源缺失,作品和播放进度仍保留。可以选择其他可用版本;请管理员确认文件或挂载恢复后重新扫描媒体库,再刷新状态。</p>{onRefresh&&<button disabled={busy} onClick={onRefresh}>刷新资源状态</button>}</div>}
|
||||
{!preview&&edition.parts.length>10&&(layout!=='chapters'||location.searchOpen)&&<div className="media-chapter-filter"><label>查找章节<input type="search" value={query} onInput={event=>changePosition({...location,query:event.currentTarget.value,page:0})}/></label><span>{filtered.length} 节</span></div>}
|
||||
{visible.map(part=><div className="media-row" key={part.id}>{edition.parts.length>1&&<span className="media-part-number">{String(edition.parts.indexOf(part)+1).padStart(2,'0')}</span>}<button className="media-part-copy" aria-label={part.available?'播放':'资源缺失'} aria-current={props.currentPartId===part.id?'true':undefined} disabled={busy||!part.available} title={part.available?'播放 '+part.title:'资源缺失'} onClick={()=>onPlay(playable,playable.findIndex(entry=>entry.id===part.id))}><strong>{part.title}</strong>{!part.available?<small>资源缺失</small>:props.currentPartId===part.id?<small>当前播放</small>:preview&&<small>第 {edition.parts.indexOf(part)+1} 章</small>}</button><span className="media-part-duration">{duration(part)}</span>{preview&&<details className="media-actions"><summary aria-label={'更多 '+part.title+' 操作'}><Ellipsis size={20} aria-hidden="true"/></summary><nav><button disabled={busy||!part.available} onClick={()=>onQueue([part.id])}>加入播放队列</button></nav></details>}</div>)}
|
||||
{visible.map(part=><div className="media-row" key={part.id}>{edition.parts.length>1&&<span className="media-part-number">{String(edition.parts.indexOf(part)+1).padStart(2,'0')}</span>}<button className="media-part-copy" aria-label={part.available?'播放':'资源缺失'} aria-current={props.currentPartId===part.id?'true':undefined} disabled={busy||!part.available} title={part.available?'播放 '+part.title:'资源缺失'} onClick={()=>onPlay(playable,playable.findIndex(entry=>entry.id===part.id))}><strong>{part.title}</strong>{!part.available?<small>资源缺失</small>:props.currentPartId===part.id?<small>当前播放</small>:preview&&<small>第 {edition.parts.indexOf(part)+1} 章</small>}</button><span className="media-part-duration">{duration(part)}</span>{preview&&<ActionMenu className="media-icon-button" label={'更多 '+part.title+' 操作'} title={part.title} items={[{label:'加入播放队列',disabled:busy||!part.available,onSelect:()=>onQueue([part.id])}]}/>}</div>)}
|
||||
{!visible.length&&<p>{edition.parts.length?'没有匹配的章节。':'暂无章节。'}</p>}
|
||||
{!preview&&pages>1&&<nav className="media-toolbar" aria-label="章节分页"><button disabled={current===0} onClick={()=>changePosition({...location,page:current-1})}>上一页</button><span>{current+1} / {pages}</span><button disabled={current+1>=pages} onClick={()=>changePosition({...location,page:current+1})}>下一页</button></nav>}
|
||||
</div>{showTools&&<EditionTools {...props}/>}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {Check,ChevronRight,Heart,SlidersHorizontal} from 'lucide-react';
|
||||
import {ChevronRight,Heart} from 'lucide-react';
|
||||
import {ActionMenu} from '../../../ui/action-menu.tsx';
|
||||
import {MediaCover} from './cover.tsx';
|
||||
import {itemLabel} from './item-label.ts';
|
||||
import type {Item,MediaApi,MediaChannel} from '../api/media-api.ts';
|
||||
@@ -15,7 +16,7 @@ export function MediaFavorites({api,items,total,offset,scope,busy,onScope,onPage
|
||||
onScope:(scope:FavoriteScope)=>void;onPage:(offset:number)=>void;onOpen:(item:Item)=>void;
|
||||
}){
|
||||
return <section className="media-favorites" aria-label="收藏列表">
|
||||
<div className="media-favorite-tools"><p>{total} 项收藏{scope!=='all'?' · '+scopes.find(([value])=>value===scope)![1]:''}</p><details className="media-favorite-filter-menu"><summary aria-label="筛选收藏"><SlidersHorizontal size={16} strokeWidth={1.8} aria-hidden="true"/><span>筛选</span></summary><div className="media-favorite-filter-options" role="listbox" aria-label="收藏类型">{scopes.map(([value,label])=><button key={value} type="button" role="option" aria-selected={scope===value} onClick={event=>{event.currentTarget.closest('details')?.removeAttribute('open');if(scope!==value)onScope(value);}}><span>{label}</span>{scope===value&&<Check size={16} strokeWidth={1.9} aria-hidden="true"/>}</button>)}</div></details></div>
|
||||
<div className="media-favorite-tools"><p>{total} 项收藏{scope!=='all'?' · '+scopes.find(([value])=>value===scope)![1]:''}</p><ActionMenu className="media-favorite-filter-menu" label="筛选收藏" title="收藏类型" text="筛选" icon="sliders" items={scopes.map(([value,label])=>({label,checked:scope===value,onSelect:()=>{if(scope!==value)onScope(value);}}))}/></div>
|
||||
<div className="media-favorite-list">{items.map(item=><button type="button" key={item.id} className="media-favorite-row" onClick={()=>onOpen(item)}>
|
||||
<MediaCover api={api} item={item} square={favoriteChannel(item)==='music'}/>
|
||||
<span className="media-favorite-copy"><strong title={item.title}>{item.title}</strong><small>{[kindNames[item.kind],itemLabel(item)].filter(Boolean).join(' · ')}</small></span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {Trash2} from 'lucide-react';
|
||||
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
|
||||
import { ConfirmSheet } from '../../../ui/sheet.tsx';
|
||||
import type {MediaApi} from '../api/media-api.ts';
|
||||
|
||||
export interface CleanupPreview {
|
||||
@@ -8,8 +8,9 @@ export interface CleanupPreview {
|
||||
}
|
||||
export interface CleanupResult extends CleanupPreview {returnPath:string}
|
||||
|
||||
export function FolderCleanup({api,libraryId,path,disabled,onBusy,onCleaned,menuOnly=false}:{
|
||||
api:MediaApi;libraryId:string;path:string;disabled:boolean;onBusy:(busy:boolean)=>void;onCleaned:(result:CleanupResult)=>void;menuOnly?:boolean;
|
||||
/** `startRef`: the screen opens this from its own menu, so no trigger is drawn here. */
|
||||
export function FolderCleanup({api,libraryId,path,disabled,onBusy,onCleaned,startRef}:{
|
||||
api:MediaApi;libraryId:string;path:string;disabled:boolean;onBusy:(busy:boolean)=>void;onCleaned:(result:CleanupResult)=>void;startRef?:{current:(()=>void)|null};
|
||||
}){
|
||||
const [preview,setPreview]=useState<CleanupPreview|null>(null),[message,setMessage]=useState(''),[error,setError]=useState('');
|
||||
const request=useRef<AbortController|null>(null);
|
||||
@@ -29,9 +30,10 @@ export function FolderCleanup({api,libraryId,path,disabled,onBusy,onCleaned,menu
|
||||
}catch(error){if(!abort.signal.aborted)setError((error instanceof Error?error.message:'清理失败')+(remove?'。请重新预览,核对当前记录后再操作。':''));}
|
||||
finally{if(!abort.signal.aborted){request.current=null;onBusy(false);}}
|
||||
}
|
||||
if(startRef)startRef.current=()=>void run();
|
||||
return <>
|
||||
<>{menuOnly?<button disabled={disabled} onClick={()=>void run()}><Trash2 size={16} aria-hidden="true"/>清理失效资源</button>:<div className="media-folder-operation"><span><strong>失效资源</strong><small>检查当前目录及子目录的缺失记录</small></span><button disabled={disabled} onClick={()=>void run()}><Trash2 size={16} aria-hidden="true"/>清理失效资源</button></div>}</>
|
||||
<>{startRef?null:<div className="media-folder-operation"><span><strong>失效资源</strong><small>检查当前目录及子目录的缺失记录</small></span><button disabled={disabled} onClick={()=>void run()}><Trash2 size={16} aria-hidden="true"/>清理失效资源</button></div>}</>
|
||||
{message&&<p role="status">{message}</p>}{error&&<p className="media-error" role="alert">{error}</p>}
|
||||
{preview&&<FloatingConfirm theme="media" title="清理失效资源" confirmText="确认清理" cancelText="取消" onCancel={()=>setPreview(null)} onConfirm={()=>void run(true)} text={`清理「${path||'库内根目录'}」及其子目录中的 ${preview.assets} 个缺失文件记录。将移除 ${preview.parts} 个播放片段、${preview.editions} 个空版本和 ${preview.items} 个空作品/分类,以及所有用户关联的 ${preview.favorites} 条收藏、${preview.progress} 条播放进度和 ${preview.queue} 条队列记录。不会删除原始文件或仍有资源的作品。此操作不可撤销;如目录暂未挂载,请取消并在恢复后重新扫描。`}/>}
|
||||
{preview&&<ConfirmSheet danger title="清理失效资源" confirmText="确认清理" cancelText="取消" onCancel={()=>setPreview(null)} onConfirm={()=>void run(true)} text={`清理「${path||'库内根目录'}」及其子目录中的 ${preview.assets} 个缺失文件记录。将移除 ${preview.parts} 个播放片段、${preview.editions} 个空版本和 ${preview.items} 个空作品/分类,以及所有用户关联的 ${preview.favorites} 条收藏、${preview.progress} 条播放进度和 ${preview.queue} 条队列记录。不会删除原始文件或仍有资源的作品。此操作不可撤销;如目录暂未挂载,请取消并在恢复后重新扫描。`}/>}
|
||||
</>;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {Settings2} from 'lucide-react';
|
||||
import '../styles/folder-recognition.css';
|
||||
import {Modal} from '../../../ui/modal.tsx';
|
||||
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
|
||||
import { ConfirmSheet, Sheet } from '../../../ui/sheet.tsx';
|
||||
import {MediaSelect} from './select.tsx';
|
||||
import type {MediaApi} from '../api/media-api.ts';
|
||||
type Mode='auto'|'movie'|'series'|'season'|'ignore';
|
||||
@@ -11,7 +10,8 @@ interface Proposal {assetId:string;ref:string;before:{id:string;title:string;kin
|
||||
interface Preview {id:string;items:Proposal[]}
|
||||
const labels:Record<Mode,string>={auto:'自动识别',movie:'电影目录',series:'剧集目录',season:'指定季目录',ignore:'忽略目录'};
|
||||
|
||||
export function FolderRecognition({api,libraryId,path,disabled,onBusy,onApplied,menuOnly=false}:{api:MediaApi;libraryId:string;path:string;disabled:boolean;onBusy:(busy:boolean)=>void;onApplied:()=>void;menuOnly?:boolean}){
|
||||
/** `startRef`: the screen opens this from its own menu, so no trigger is drawn here. */
|
||||
export function FolderRecognition({api,libraryId,path,disabled,onBusy,onApplied,startRef}:{api:MediaApi;libraryId:string;path:string;disabled:boolean;onBusy:(busy:boolean)=>void;onApplied:()=>void;startRef?:{current:(()=>void)|null}}){
|
||||
const [open,setOpen]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState('');
|
||||
const [mode,setMode]=useState<Mode|'inherit'>('inherit'),[title,setTitle]=useState(''),[season,setSeason]=useState(''),[year,setYear]=useState(''),[strip,setStrip]=useState(false),[filePattern,setFilePattern]=useState('');
|
||||
const [revision,setRevision]=useState(''),[inherited,setInherited]=useState<Rule|null>(null),[preview,setPreview]=useState<Preview|null>(null),[selected,setSelected]=useState<string[]>([]),[confirm,setConfirm]=useState(false),[page,setPage]=useState(0);
|
||||
@@ -40,9 +40,10 @@ export function FolderRecognition({api,libraryId,path,disabled,onBusy,onApplied,
|
||||
const selectable=(row:Proposal)=>['ready','review'].includes(row.status);
|
||||
const visible=preview?.items.slice(page*30,page*30+30)??[];
|
||||
const selectRows=(rows:Proposal[])=>setSelected(current=>[...new Set([...current,...rows.filter(selectable).map(row=>row.assetId)])]);
|
||||
if(startRef)startRef.current=show;
|
||||
return <>
|
||||
<>{menuOnly?<button disabled={disabled} onClick={show}><Settings2 size={16} aria-hidden="true"/>识别规则与预览</button>:<div className="media-folder-operation"><span><strong>影视识别</strong><small>设置当前目录及子目录的识别规则</small></span><button disabled={disabled} onClick={show}><Settings2 size={16} aria-hidden="true"/>识别规则与预览</button></div>}</>
|
||||
{open&&<Modal className="media-modal" title="目录识别规则" busy={busy} onClose={()=>setOpen(false)}><div className="media-form media-recognition-form">
|
||||
<>{startRef?null:<div className="media-folder-operation"><span><strong>影视识别</strong><small>设置当前目录及子目录的识别规则</small></span><button disabled={disabled} onClick={show}><Settings2 size={16} aria-hidden="true"/>识别规则与预览</button></div>}</>
|
||||
{open&&<Sheet className="media-modal" size="wide" title="目录识别规则" busy={busy} onClose={()=>setOpen(false)}><div className="media-form media-recognition-form">
|
||||
<p className="media-folder-note">{path||'库内根目录'} · 包含子目录,子目录自己的规则优先。目录内文件统一应用下方文件正则。规则保存后用于后续扫描;已有作品通过下方预览确认重新识别。</p>
|
||||
{inherited&&<p>继承自「{inherited.path||'库内根目录'}」:{labels[inherited.mode]}{inherited.title?' · '+inherited.title:''}</p>}
|
||||
{error&&<p className="media-error" role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}
|
||||
@@ -69,7 +70,7 @@ export function FolderRecognition({api,libraryId,path,disabled,onBusy,onApplied,
|
||||
{preview.items.slice(page*30,page*30+30).map(row=><label key={row.assetId} className="media-recognition-row"><input type="checkbox" aria-label={'应用 '+row.ref} disabled={busy||!['ready','review'].includes(row.status)} checked={selected.includes(row.assetId)} onChange={e=>setSelected(e.currentTarget.checked?[...selected,row.assetId]:selected.filter(id=>id!==row.assetId))}/><span><strong>{row.ref}</strong><small>当前:{row.before?.title??'未关联'}</small><b>{description(row)}</b><small>{({ready:'可应用',review:'待确认',protected:'保留现有',ignored:'已忽略'} as const)[row.status]} · {row.reason}</small></span></label>)}
|
||||
{preview.items.length>30&&<nav className="media-toolbar" aria-label="识别预览分页"><button disabled={busy||!page} onClick={()=>setPage(page-1)}>上一页</button><span>{page+1} / {Math.ceil(preview.items.length/30)}</span><button disabled={busy||(page+1)*30>=preview.items.length} onClick={()=>setPage(page+1)}>下一页</button></nav>}
|
||||
</section>}
|
||||
</div></Modal>}
|
||||
{confirm&&<FloatingConfirm theme="media" title="确认重新识别" text={`将按预览调整 ${selected.length} 个文件的标题和作品归属。同一剧、季、集的版本可能合并到一个作品,收藏随之保留。不修改原文件,保留播放进度与队列。`} confirmText="应用识别结果" onCancel={()=>setConfirm(false)} onConfirm={()=>{setConfirm(false);void work(async signal=>{await api.request(base+'/recognition-apply','POST',{path,previewId:preview!.id,assetIds:selected},signal);if(!signal.aborted){setOpen(false);onApplied();}});}}/>}
|
||||
</div></Sheet>}
|
||||
{confirm&&<ConfirmSheet title="确认重新识别" text={`将按预览调整 ${selected.length} 个文件的标题和作品归属。同一剧、季、集的版本可能合并到一个作品,收藏随之保留。不修改原文件,保留播放进度与队列。`} confirmText="应用识别结果" onCancel={()=>setConfirm(false)} onConfirm={()=>{setConfirm(false);void work(async signal=>{await api.request(base+'/recognition-apply','POST',{path,previewId:preview!.id,assetIds:selected},signal);if(!signal.aborted){setOpen(false);onApplied();}});}}/>}
|
||||
</>;
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import {FolderCleanup} from './folder-cleanup.tsx';
|
||||
import {MediaSelect} from './select.tsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {Folder,FolderOpen,File,ChevronRight,ChevronLeft,ArrowUpRight,Ellipsis} from 'lucide-react';
|
||||
import {Folder,FolderOpen,File,ChevronRight,ChevronLeft,ArrowUpRight,Settings2,Trash2} from 'lucide-react';
|
||||
import {ActionMenu} from '../../../ui/action-menu.tsx';
|
||||
import type {Detail,MediaApi,Part} from '../api/media-api.ts';
|
||||
import {EditionDetails,type ChapterPosition} from './edition-details.tsx';
|
||||
import {ResourceInfo} from './resource-info.tsx';
|
||||
@@ -40,13 +41,15 @@ export function MediaFolders({api,admin=false,video=false,libraryId,initialLocat
|
||||
useEffect(()=>{onLocationChange?.({path,offset,assetId,editions,chapters});},[path,offset,assetId]);
|
||||
function open(next:string){setCleanupNotice('');setPath(next);setOffset(0);setAssetId(null);}
|
||||
async function action(run:()=>Promise<void>,queue=false){if(busy)return;const current=generation.current;setBusy(true);setActionError('');setNotice('');try{await run();if(current===generation.current)setNotice(queue?'已加入队列':'');}catch(error){if(current===generation.current)setActionError((error instanceof ApiError&&error.kind==='offline'?'无法连接服务器':error instanceof Error?error.message:'操作失败')+(queue?'。加入结果尚未确认,请先到待播队列核对,再决定是否重新添加。':'。可以重新选择章节播放。'));}finally{if(current===generation.current)setBusy(false);}}
|
||||
const managementActions = admin && listing ? <details className="media-actions media-folder-actions" aria-label="文件管理操作">
|
||||
<summary aria-label="文件管理操作" title="文件管理操作"><Ellipsis size={20} aria-hidden="true" /></summary>
|
||||
<nav aria-label="文件管理操作">
|
||||
{video && <FolderRecognition key={libraryId+'-rules-'+path} api={api} libraryId={libraryId} path={path} disabled={busy||loading} menuOnly onBusy={setBusy} onApplied={()=>{setCleanupNotice('已应用识别结果,原文件与播放进度保留。');setRetry(value=>value+1);}}/>}
|
||||
<FolderCleanup key={libraryId+'-cleanup-'+path} api={api} libraryId={libraryId} path={path} disabled={busy||loading} menuOnly onBusy={setBusy} onCleaned={result=>{setCleanupNotice('已清理 '+result.assets+' 个失效资源记录'+(result.returnPath!==path?',已返回上级有效目录。':'。'));setPath(result.returnPath);setOffset(0);setRetry(value=>value+1);}}/>
|
||||
</nav>
|
||||
</details> : null;
|
||||
const startRecognition=useRef<(()=>void)|null>(null),startCleanup=useRef<(()=>void)|null>(null);
|
||||
const managementActions = admin && listing ? <>
|
||||
<ActionMenu className="media-folder-actions media-icon-button" label="文件管理操作" items={[
|
||||
video && {label:'识别规则与预览',icon:<Settings2 size={16}/>,disabled:busy||loading,onSelect:()=>startRecognition.current?.()},
|
||||
{label:'清理失效资源',icon:<Trash2 size={16}/>,danger:true,disabled:busy||loading,onSelect:()=>startCleanup.current?.()},
|
||||
]}/>
|
||||
{video && <FolderRecognition key={libraryId+'-rules-'+path} api={api} libraryId={libraryId} path={path} disabled={busy||loading} startRef={startRecognition} onBusy={setBusy} onApplied={()=>{setCleanupNotice('已应用识别结果,原文件与播放进度保留。');setRetry(value=>value+1);}}/>}
|
||||
<FolderCleanup key={libraryId+'-cleanup-'+path} api={api} libraryId={libraryId} path={path} disabled={busy||loading} startRef={startCleanup} onBusy={setBusy} onCleaned={result=>{setCleanupNotice('已清理 '+result.assets+' 个失效资源记录'+(result.returnPath!==path?',已返回上级有效目录。':'。'));setPath(result.returnPath);setOffset(0);setRetry(value=>value+1);}}/>
|
||||
</> : null;
|
||||
const segments=path?path.split('/'):[];
|
||||
return <section ref={folderRoot} className="media-folders" aria-label="影音文件夹">
|
||||
{actionsHost && managementActions ? createPortal(managementActions,actionsHost) : managementActions}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
|
||||
import { ConfirmSheet } from '../../../ui/sheet.tsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Library, MediaApi, MediaChannel } from '../api/media-api.ts';
|
||||
import { newId } from '../../../core/id.ts';
|
||||
@@ -37,7 +37,7 @@ export function MediaLibraryCreate({ api, channel, disabled, onCreated, onJobs,o
|
||||
};
|
||||
return <section className="media-library-create">
|
||||
{!hideHeader && <h2>新建{labels[channel]}媒体库</h2>}
|
||||
{leaving&&<FloatingConfirm theme="media" title="放弃新建媒体库?" text="媒体库尚未创建,离开后将丢弃已填写的内容。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel?.();}}/>}
|
||||
{leaving&&<ConfirmSheet danger title="放弃新建媒体库?" text="媒体库尚未创建,离开后将丢弃已填写的内容。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel?.();}}/>}
|
||||
{error && <p className="media-error" role="alert">{error}</p>}
|
||||
{created ? <>
|
||||
<p role="status">媒体库“{created.name}”已创建。{scanStarted ? '扫描请求已提交,可查看任务结果。' : '尚未确认扫描启动,请先查看任务;如无任务,再启动扫描。'}</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
|
||||
import { ConfirmSheet } from '../../../ui/sheet.tsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Library, LibraryConfiguration, MediaApi } from '../api/media-api.ts';
|
||||
import {ChevronLeft} from 'lucide-react';
|
||||
@@ -49,7 +49,7 @@ export function MediaLibraryEditor({ api, library, onSaved, onCancel }: {
|
||||
};
|
||||
|
||||
return <section className="media-library-workspace"><header className="media-settings-heading"><button className="media-back-button" data-media-back aria-label="返回媒体库" disabled={saving} onClick={close}><ChevronLeft size={20} aria-hidden="true"/></button><h1>编辑媒体库</h1></header>
|
||||
{leaving&&<FloatingConfirm theme="media" title="放弃媒体库修改?" text="媒体库修改尚未保存,离开后将丢弃本次修改。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel();}}/>}
|
||||
{leaving&&<ConfirmSheet danger title="放弃媒体库修改?" text="媒体库修改尚未保存,离开后将丢弃本次修改。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel();}}/>}
|
||||
<form className="media-form media-library-editor" aria-label="媒体库配置" onSubmit={event => { event.preventDefault(); void save(); }}>
|
||||
{error && <div role="alert" className="media-error">{error}{!configuration && <button type="button" onClick={() => setAttempt(value => value + 1)}>重试读取配置</button>}</div>}
|
||||
{!configuration && !error && <FloatingNotice message="正在读取媒体库配置…" busy />}
|
||||
|
||||
@@ -1,12 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Ellipsis,Folder } from 'lucide-react';
|
||||
import type { Library,MediaApi } from '../api/media-api.ts';
|
||||
import { Folder } from 'lucide-react';
|
||||
import { ActionMenu } from '../../../ui/action-menu.tsx';
|
||||
import type { Library,MediaApi,ScanJob } from '../api/media-api.ts';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
import { scanFeedback } from './scan-feedback.ts';
|
||||
interface Summary {id:string;root:string;counts:Record<string,number>;chapters:number;missingFiles:number}
|
||||
export interface LibraryListPosition {query:string;page:number}
|
||||
const labels={video:'影视',music:'音乐',audiobook:'有声书'};
|
||||
const count=(library:Library,summary:Summary)=>library.kind==='video'?`${summary.counts.movie||0} 部电影 · ${summary.counts.series||0} 部剧集`:library.kind==='music'?`${summary.counts.album||0} 张专辑 · ${summary.counts.track||0} 首曲目`:`${summary.counts.audiobook||0} 部作品 · ${summary.chapters} 章(全部版本)`;
|
||||
export function MediaLibraryList({api,libraries,busy,position,onPosition,onEdit,onScan,onPermissions}:{api:MediaApi;libraries:Library[];busy:boolean;position:LibraryListPosition;onPosition:(position:LibraryListPosition)=>void;onEdit:(library:Library)=>void;onScan:(id:string)=>void;onPermissions:(library:Library)=>void;onJobs?: (id:string)=>void}){
|
||||
/** "今天 09:12", "昨天 21:40", or "3月8日 09:12": when the last scan finished. */
|
||||
export function scanTime(at:number,now=Date.now()):string{
|
||||
const date=new Date(at),day=(value:Date)=>new Date(value.getFullYear(),value.getMonth(),value.getDate()).getTime();
|
||||
const clock=`${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`;
|
||||
const days=Math.round((day(new Date(now))-day(date))/86400000);
|
||||
return days===0?'今天 '+clock:days===1?'昨天 '+clock:`${date.getMonth()+1}月${date.getDate()}日 ${clock}`;
|
||||
}
|
||||
/**
|
||||
* The latest scan of one library, as prototype 20 draws it: a running scan with its
|
||||
* share of files processed and 取消; a failed one with what went wrong, 重试 and 查看详情;
|
||||
* otherwise when it last finished, beside 重新扫描.
|
||||
*/
|
||||
function ScanState({job,busy,onScan,onCancel,onDetails}:{job:ScanJob|undefined;busy:boolean;onScan:()=>void;onCancel?:(job:ScanJob)=>void;onDetails?:()=>void}){
|
||||
if(job&&(job.state==='running'||job.state==='queued')){
|
||||
const percent=job.total?Math.min(100,Math.round(job.inspected/job.total*100)):null;
|
||||
return <div className="media-library-scan" data-state="running">
|
||||
<span role="status">{job.state==='queued'?'排队等待扫描':percent!==null?`正在扫描 · 已处理 ${percent}%`:`正在扫描 · 已检查 ${job.inspected} 个文件`}</span>
|
||||
<progress className="media-library-progress" max={100} {...(percent!==null?{value:percent}:{})} aria-label="扫描进度"/>
|
||||
{onCancel&&<button disabled={busy} onClick={()=>onCancel(job)}>取消</button>}
|
||||
</div>;
|
||||
}
|
||||
if(job?.state==='failed')return <div className="media-library-scan" data-state="failed">
|
||||
<span className="media-library-scan-error" role="alert">{scanFeedback(job.error)}</span>
|
||||
<span className="media-library-scan-actions">{onDetails&&<button className="media-library-details" onClick={onDetails}>查看详情</button>}<button disabled={busy} onClick={onScan}>重试</button></span>
|
||||
</div>;
|
||||
return <div className="media-library-scan" data-state="idle">
|
||||
{job?.finishedAt?<span className="media-library-scan-time">{job.state==='cancelled'?'上次扫描已取消 · ':'上次扫描 '}{scanTime(job.finishedAt)}</span>:job?null:<span className="media-library-scan-time">尚未扫描</span>}
|
||||
<button disabled={busy} onClick={onScan}>重新扫描</button>
|
||||
</div>;
|
||||
}
|
||||
export function MediaLibraryList({api,libraries,busy,position,onPosition,onEdit,onScan,onPermissions,jobs,onCancel,onDetails}:{api:MediaApi;libraries:Library[];busy:boolean;position:LibraryListPosition;onPosition:(position:LibraryListPosition)=>void;onEdit:(library:Library)=>void;onScan:(id:string)=>void;onPermissions:(library:Library)=>void;onJobs?: (id:string)=>void;
|
||||
/** Latest scan per library (GET scan-jobs); without it the row only offers 重新扫描. */
|
||||
jobs?:ScanJob[];onCancel?:(job:ScanJob)=>void;onDetails?:(library:Library)=>void}){
|
||||
const latest=new Map((jobs??[]).filter(job=>job.libraryId).map(job=>[job.libraryId!,job]));
|
||||
const [summaries,setSummaries]=useState<Summary[]>([]),[status,setStatus]=useState<'loading'|'ready'|'error'>('loading'),[retry,setRetry]=useState(0);
|
||||
const ids=libraries.map(row=>row.id).join(',');
|
||||
useEffect(()=>{const abort=new AbortController();setStatus('loading');setSummaries([]);void api.request<{items:Summary[]}>('library-summaries','GET',undefined,abort.signal).then(result=>{if(!abort.signal.aborted){setSummaries(result.items);setStatus('ready');}}).catch(()=>{if(!abort.signal.aborted)setStatus('error');});return ()=>abort.abort();},[api,ids,retry]);
|
||||
@@ -17,7 +52,7 @@ export function MediaLibraryList({api,libraries,busy,position,onPosition,onEdit,
|
||||
{(libraries.length>10||position.query)&&<div className="media-library-find"><label>查找媒体库<input type="search" value={position.query} placeholder="名称、类型或目录" onInput={event=>onPosition({query:event.currentTarget.value,page:0})}/></label><small>{filtered.length} 个</small></div>}
|
||||
{status==='loading'&&<FloatingNotice message="正在读取目录与索引数量…" busy />}
|
||||
{status==='error'&&<div className="media-error" role="alert"><p>媒体库摘要读取失败,仍可管理媒体库。</p><button onClick={()=>setRetry(value=>value+1)}>重试读取摘要</button></div>}
|
||||
<div className="media-library-list">{filtered.slice(page*30,page*30+30).map(library=>{const summary=summariesById.get(library.id);return <div className="media-library-row" key={library.id}><Folder size={22} strokeWidth={1.5} aria-hidden="true"/><span><strong>{library.name} <em className="media-library-kind" title={library.access==='all'?'所有用户可见':'指定用户可见'}>{labels[library.kind]}</em></strong>{summary?<><small>{count(library,summary)}</small><small className="media-library-directory" title={summary.root}>{library.storage==='openlist'?'OpenList · ':''}{summary.root}</small></>:status==='ready'?<small>目录与数量暂不可用</small>:null}</span><details className="media-library-actions"><summary aria-label={'媒体库操作 '+library.name}><Ellipsis size={20} aria-hidden="true"/></summary><nav aria-label={library.name+'的操作'}><button disabled={busy} onClick={()=>onEdit(library)}>编辑媒体库</button><button onClick={()=>onPermissions(library)}>权限</button></nav></details><footer className="media-library-footer"><span>{summary?summary.missingFiles>0?summary.missingFiles+' 个文件缺失':'无缺失文件记录':'摘要暂不可用'}</span><button disabled={busy} onClick={()=>onScan(library.id)}>重新扫描</button></footer></div>;})}</div>
|
||||
<div className="media-library-list">{filtered.slice(page*30,page*30+30).map(library=>{const summary=summariesById.get(library.id);return <div className="media-library-row" key={library.id}><Folder size={22} strokeWidth={1.5} aria-hidden="true"/><span><strong>{library.name} <em className="media-library-kind" title={library.access==='all'?'所有用户可见':'指定用户可见'}>{labels[library.kind]}</em></strong>{summary?<><small>{count(library,summary)}</small><small className="media-library-directory" title={summary.root}>{library.storage==='openlist'?'OpenList · ':''}{summary.root}</small></>:status==='ready'?<small>目录与数量暂不可用</small>:null}</span><ActionMenu className="media-library-actions media-icon-button" label={'媒体库操作 '+library.name} title={library.name} items={[{label:'编辑媒体库',disabled:busy,onSelect:()=>onEdit(library)},{label:'权限',onSelect:()=>onPermissions(library)}]}/><footer className="media-library-footer"><span>{summary?summary.missingFiles>0?summary.missingFiles+' 个文件缺失':'无缺失文件记录':'摘要暂不可用'}</span>{jobs?<ScanState job={latest.get(library.id)} busy={busy} onScan={()=>onScan(library.id)} {...(onCancel?{onCancel}:{})} {...(onDetails?{onDetails:()=>onDetails(library)}:{})}/>:<button disabled={busy} onClick={()=>onScan(library.id)}>重新扫描</button>}</footer></div>;})}</div>
|
||||
{!filtered.length&&!!libraries.length&&<p className="media-manager-note">没有符合查找条件的媒体库。<button onClick={()=>onPosition({query:'',page:0})}>清除查找</button></p>}
|
||||
{last>0&&<nav className="media-toolbar" aria-label="媒体库分页"><button disabled={!page} onClick={()=>onPosition({...position,page:page-1})}>上一页</button><span>{page+1} / {last+1}</span><button disabled={page===last} onClick={()=>onPosition({...position,page:page+1})}>下一页</button></nav>}
|
||||
</section>;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {Search} from 'lucide-react';
|
||||
import {MediaLoading} from './loading.tsx';
|
||||
import {MediaScreenError} from './screen-error.tsx';
|
||||
import {ApiError} from '../../../api/errors.ts';
|
||||
import {Modal} from '../../../ui/modal.tsx';
|
||||
import {ConfirmSheet} from '../../../ui/sheet.tsx';
|
||||
import {FloatingNotice} from '../../../ui/floating-notice.tsx';
|
||||
|
||||
interface Provider { id: string; label: string; kinds: string[]; configured: boolean }
|
||||
@@ -123,10 +123,13 @@ export function MetadataMatcher({ api, item, onUpdated,layout='inline',onBusyCha
|
||||
<div className="media-match-copy"><strong>{candidate.title}</strong><small>{candidate.year || ''} {candidate.artist || ''} · {candidate.provider} / {candidate.externalId}</small>{candidate.evidence&&<small>{({strong:'较强匹配',review:'需要核对',conflict:'存在冲突'})[candidate.evidence.level]}:{candidate.evidence.reasons.join('、')}</small>}{candidate.description && <small className="media-match-excerpt">{candidate.description.length>160?candidate.description.slice(0,160)+'…':candidate.description}</small>}{candidate.description&&candidate.description.length>160&&<details className="media-match-description"><summary>完整简介</summary><p tabIndex={0} aria-label="候选完整简介">{candidate.description}</p></details>}</div>
|
||||
<button aria-label="预览匹配" disabled={busy} onClick={() => setSelected(candidate)}>{layout==='page'?'选择':'预览匹配'}</button>
|
||||
</div>)}
|
||||
{selected && <Modal className="media-modal" title="确认元数据匹配" busy={busy} onClose={()=>{if(!busy)setSelected(null);}}><div className="media-match-confirm" aria-label="确认元数据匹配"><p>将“{item.title}”匹配为“{selected.title}”。在线标题、年份和简介等字段将采用此来源;人工编辑的字段仍优先显示。</p><div className="dialog-actions"><button className="media-primary" disabled={busy} onClick={() => void run(async signal => {
|
||||
{selected && <ConfirmSheet title="确认元数据匹配" busy={busy} confirmText="确认此匹配"
|
||||
text={`将“${item.title}”匹配为“${selected.title}”。在线标题、年份和简介等字段将采用此来源;人工编辑的字段仍优先显示。`}
|
||||
onCancel={() => setSelected(null)}
|
||||
onConfirm={() => void run(async signal => {
|
||||
const detail = await api.request<Detail>(`items/${item.id}/match`, 'PUT', { candidateId: selected.candidateId }, signal);
|
||||
if (!signal.aborted) { onUpdated(detail); setSelected(null); setCandidates([]); setSearched(false); }
|
||||
})}>确认此匹配</button><button disabled={busy} onClick={()=>setSelected(null)}>取消</button></div></div></Modal>}
|
||||
})} />}
|
||||
<small>TMDB 数据来源于 TMDB,本应用未经 TMDB 认可或认证。MusicBrainz 数据来源于 MetaBrainz 社区。</small>
|
||||
</Wrapper>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
|
||||
import { ConfirmSheet } from '../../../ui/sheet.tsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {ChevronLeft} from 'lucide-react';
|
||||
import type {Detail,MediaApi} from '../api/media-api.ts';
|
||||
@@ -18,7 +18,7 @@ export function MediaMetadataPage({api,item,initialView,onViewChange,onUpdated,o
|
||||
const requestLeave=(next:MetadataView|'back')=>{if(busy)return;if(view==='edit'&&editState.dirty)setDestination(next);else navigate(next);};
|
||||
return <><header className="media-heading"><div className="media-page-heading"><button className="media-back-button" aria-label="返回作品详情" disabled={busy} onClick={()=>requestLeave('back')}><ChevronLeft size={20} aria-hidden="true"/></button><h1>{view==='edit'?'元数据':'匹配作品'}</h1></div><button className="media-metadata-mode" disabled={busy} onClick={()=>requestLeave(view==='edit'?'match':'edit')}>{view==='edit'?'在线匹配':'手动编辑'}</button></header>
|
||||
<article className="media-metadata-page" data-view={view} ref={page}>
|
||||
{destination&&<FloatingConfirm theme="media" title="放弃未保存的修改?" text="有未保存的修改。离开后本次修改会丢失,已保存的资料不受影响。" confirmText="放弃修改并离开" cancelText="继续编辑" onCancel={()=>setDestination(null)} onConfirm={()=>navigate(destination)}/>}
|
||||
{destination&&<ConfirmSheet danger title="放弃未保存的修改?" text="有未保存的修改。离开后本次修改会丢失,已保存的资料不受影响。" confirmText="放弃修改并离开" cancelText="继续编辑" onCancel={()=>setDestination(null)} onConfirm={()=>navigate(destination)}/>}
|
||||
{notice&&<p role="status" className="media-metadata-notice">{notice}</p>}
|
||||
{view==='edit'?<MetadataEditor api={api} item={item} onStateChange={setEditState} onUpdated={updated=>{setNotice('已保存修改。');onUpdated(updated);}}/>:<MetadataMatcher api={api} item={item} layout="page" onBusyChange={setMatching} onUpdated={updated=>{setNotice('在线匹配已更新,人工修正已保留。');onUpdated(updated);}}/>}
|
||||
</article>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
|
||||
import { ConfirmSheet } from '../../../ui/sheet.tsx';
|
||||
import {MediaSelect} from './select.tsx';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import {ChevronLeft,Search} from 'lucide-react';
|
||||
@@ -42,7 +42,7 @@ export function MediaPermissions({api,library,onSaved,onCancel}:{api:MediaApi;li
|
||||
finally {if(pending.current===controller)pending.current=null;if(!controller.signal.aborted)setSaving(false);}
|
||||
};
|
||||
return <section className="media-permissions-page"><header className="media-settings-heading"><button className="media-back-button" data-media-back aria-label="返回媒体库" disabled={saving} onClick={close}><ChevronLeft size={20} aria-hidden="true"/></button><h1>访问权限</h1></header>
|
||||
{leaving&&<FloatingConfirm theme="media" title="放弃访问权限修改?" text="访问权限尚未保存,离开后将丢弃本次选择。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel();}}/>}
|
||||
{leaving&&<ConfirmSheet danger title="放弃访问权限修改?" text="访问权限尚未保存,离开后将丢弃本次选择。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel();}}/>}
|
||||
<form className="media-form media-permissions-form" aria-label="媒体库访问权限" onSubmit={event=>{event.preventDefault();if(ready&&!saving)void save();}}>
|
||||
<div className="media-permissions-intro"><h2>{library.name}</h2><p>允许访问的用户共享作品资料,收藏与进度各自独立。</p></div>
|
||||
{error&&(ready?<div role="alert" className="media-error">{error}</div>:<MediaScreenError fullPage error={cause} message={error} busy={false} onRetry={()=>setAttempt(value=>value+1)}/>)}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import {Repeat,Repeat1,Shuffle} from 'lucide-react';
|
||||
import type {MediaPlayer} from '../services/player.ts';
|
||||
import {playModeLabels,playModes} from '../services/play-mode.ts';
|
||||
|
||||
/** The 播放队列 panel's choice of how the queue advances: in order, at random, or the same entry again. */
|
||||
export function PlayModeSegments({player}:{player:MediaPlayer}){
|
||||
return <div className="media-play-mode" role="group" aria-label="播放模式"><span aria-hidden="true">播放模式</span>
|
||||
<div>{playModes.map(mode=><button key={mode} aria-pressed={player.playMode===mode} onClick={()=>player.setPlayMode(mode)}>{playModeLabels[mode]}</button>)}</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/** One tap on the transport row steps through the modes. Disabled, not hidden, where the player cannot honour a mode, so the row keeps its balance. */
|
||||
export function PlayModeCycle({player}:{player:MediaPlayer}){
|
||||
const mode=player.playMode??'sequential',next=playModes[(playModes.indexOf(mode)+1)%playModes.length]!;
|
||||
const Icon=mode==='shuffle'?Shuffle:mode==='repeat-one'?Repeat1:Repeat;
|
||||
const label='播放模式:'+playModeLabels[mode];
|
||||
return <button className="media-symbol-button media-play-mode-cycle" data-mode={mode} aria-label={label} disabled={!player.canChoosePlayMode} title={player.canChoosePlayMode?label+',点按切换为'+playModeLabels[next]:'当前播放器不支持切换播放模式'} onClick={()=>player.setPlayMode(next)}><Icon size={22} aria-hidden="true"/></button>;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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 {PlayModeCycle,PlayModeSegments} from './play-mode.tsx';
|
||||
import { ActionMenu } from '../../../ui/action-menu.tsx';
|
||||
import { ChevronLeft, Layers, Play, Pause, SkipBack, SkipForward, RotateCcw, RotateCw, Square, Timer, Gauge, Ellipsis, BookOpen, MicVocal, ListMusic, Search, ArrowUp, ArrowDown, X, Volume2, Pencil } from 'lucide-react';
|
||||
import type { MediaPlayer } from '../services/player.ts';
|
||||
import { useEffect, useState, useRef, useLayoutEffect } from 'react';
|
||||
import type {Detail,MediaApi,Part} from '../api/media-api.ts';
|
||||
@@ -15,7 +17,7 @@ import {MediaLoading} from './loading.tsx';
|
||||
import {MediaScreenError} from './screen-error.tsx';
|
||||
import {PlaybackProblem} from './playback-problem.tsx';
|
||||
|
||||
function PlayingArtwork({api,item,failed,onRetry,player,lyrics,onMore}:{api:MediaApi;item:Detail|null;failed:boolean;onRetry:()=>void;player:MediaPlayer;lyrics:boolean;onMore:()=>void}){
|
||||
function PlayingArtwork({api,item,failed,onRetry,player,lyrics}:{api:MediaApi;item:Detail|null;failed:boolean;onRetry:()=>void;player:MediaPlayer;lyrics:boolean}){
|
||||
const field=(key:string)=>item?.overrides[key]??item?.metadata[key];
|
||||
const edition=item?.editions?.find(entry=>entry.parts.some(part=>part.id===player.currentPartId)),chapter=edition?.parts.find(part=>part.id===player.currentPartId);
|
||||
const book=item?.kind==='audiobook';
|
||||
@@ -24,7 +26,7 @@ function PlayingArtwork({api,item,failed,onRetry,player,lyrics,onMore}:{api:Medi
|
||||
return <>{lyrics?<><div className="media-lyrics-heading"><h2 className="media-playing-title">{title}</h2>{credit&&<p>{credit}</p>}</div><Lyrics key={player.currentPartId} api={api} player={player}/></>:<div className="media-playing-artwork">
|
||||
{!item&&!failed&&<FloatingNotice message="正在读取作品资料…" busy />}{item?<MediaCover api={api} item={item} square={!book} retryable/>:<div className="media-artwork-placeholder" role="status" aria-label={failed?'作品资料暂不可用':'正在读取作品资料'}>{failed?'作品资料暂不可用':null}</div>}
|
||||
{book&&chapter&&<p className="media-playing-chapter">第 {edition!.parts.indexOf(chapter)+1} / {edition!.parts.length} 章 · {item.title}</p>}
|
||||
<div className="media-playing-copy"><div><h2 className="media-playing-title">{title}</h2>{credit&&<p>{credit}</p>}{failed&&<p role="alert">作品资料读取失败。<button onClick={onRetry}>重新读取作品资料</button></p>}</div><button className="media-icon-button" aria-label="当前内容操作" onClick={onMore}><Ellipsis size={20} aria-hidden="true"/></button></div>
|
||||
<div className="media-playing-copy"><div><h2 className="media-playing-title">{title}</h2>{credit&&<p>{credit}</p>}{failed&&<p role="alert">作品资料读取失败。<button onClick={onRetry}>重新读取作品资料</button></p>}</div></div>
|
||||
</div>}</>;
|
||||
}
|
||||
|
||||
@@ -38,14 +40,15 @@ function PlayingChapters({api,player,item,error,cause,onRetry}:{api:MediaApi;pla
|
||||
return <><AudiobookChapters api={api} item={item} editionId={editionId} currentPartId={player.currentPartId} busy={busy} onEditionChange={id=>{setEditionId(id);setPosition({query:'',page:0});setNotice('');}} onPlay={(parts:Part[],index:number)=>void run(()=>player.play(parts.map(part=>({part,title:item.title+' · '+part.title,video:false})),index))} onQueue={ids=>void run(async()=>{await api.request('queue','POST',{partIds:ids});},true)} onRefresh={onRetry} position={position} onPositionChange={setPosition}/>{notice&&<p role="status">{notice}</p>}</>;
|
||||
}
|
||||
|
||||
function CurrentPlaylist({player}:{player:MediaPlayer}){
|
||||
function CurrentPlaylist({player,modes}:{player:MediaPlayer;modes:boolean}){
|
||||
const [query,setQuery]=useState(''),[page,setPage]=useState(0);
|
||||
const [editing,setEditing]=useState(false),[selected,setSelected]=useState<number|null>(null);
|
||||
const [searchOpen,setSearchOpen]=useState(player.currentPlaylist.length>10);
|
||||
const entries=player.currentPlaylist,filtered=entries.filter(entry=>entry.title.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
|
||||
const last=Math.max(0,Math.ceil(filtered.length/50)-1),currentPage=Math.min(page,last);
|
||||
const active=entries.find(entry=>entry.index===selected);
|
||||
return <section className="media-current-playlist" aria-label="当前播放列表"><div className="media-playlist-toolbar"><h2>当前队列</h2><small>{entries.length} 首 · 正在播放第 {Math.max(0,entries.findIndex(entry=>entry.current))+1} 首</small><details className="media-actions media-playlist-tools"><summary aria-label="队列操作"><Ellipsis size={20} aria-hidden="true"/></summary><nav><button aria-label="查找播放列表" title="查找播放列表" aria-expanded={searchOpen} onClick={()=>{setSearchOpen(!searchOpen);if(searchOpen){setQuery('');setPage(0);}}}><Search size={18} aria-hidden="true"/>查找曲目</button><button aria-label={editing?'完成编辑':'编辑播放列表'} title={editing?'完成编辑':'编辑播放列表'} aria-pressed={editing} disabled={!player.canEditPlaylist} onClick={()=>setEditing(!editing)}><Pencil size={18} aria-hidden="true"/>{editing?'完成编辑':'编辑队列'}</button></nav></details></div>
|
||||
return <section className="media-current-playlist" aria-label="当前播放列表"><div className="media-playlist-toolbar"><h2>当前队列</h2><small>{entries.length} 首 · 正在播放第 {Math.max(0,entries.findIndex(entry=>entry.current))+1} 首</small><ActionMenu className="media-playlist-tools media-icon-button" label="队列操作" items={[{label:searchOpen?'收起查找':'查找曲目',icon:<Search size={18}/>,onSelect:()=>{setSearchOpen(!searchOpen);if(searchOpen){setQuery('');setPage(0);}}},{label:editing?'完成编辑':'编辑队列',icon:<Pencil size={18}/>,disabled:!player.canEditPlaylist,onSelect:()=>setEditing(!editing)}]}/></div>
|
||||
{modes&&player.canChoosePlayMode&&<PlayModeSegments player={player}/>}
|
||||
{searchOpen&&<div className="media-playlist-search"><label>查找曲目或章节<input type="search" value={query} onInput={event=>{setQuery(event.currentTarget.value);setPage(0);}}/></label><button onClick={()=>{setQuery('');setPage(Math.floor(Math.max(0,entries.findIndex(entry=>entry.current))/50));}}>定位当前播放</button></div>}
|
||||
{editing&&<p>调整本次播放顺序,或移除其他条目;正在播放的内容不中断。已保存的待播队列不随此处修改。</p>}
|
||||
<ol start={currentPage*50+1}>{filtered.slice(currentPage*50,currentPage*50+50).map(entry=><li key={entry.index}><button className="media-playlist-select" aria-current={entry.current?'true':undefined} disabled={!player.canSelectPlaylist||entry.current} onClick={()=>void player.selectPlaylist(entry.index)}><span className="media-playlist-number" aria-hidden="true">{entry.current?<Volume2 size={17}/>:String(entry.index+1).padStart(2,'0')}</span><span><strong>{entry.title}</strong>{entry.credit&&<small>{entry.credit}</small>}</span></button>{editing&&<div className="media-playlist-edit"><button aria-label={`上移 ${entry.title}`} title="上移" disabled={!player.canEditPlaylist||entry.index===0} onClick={()=>player.editPlaylist(entry.index,-1)}><ArrowUp size={17} aria-hidden="true"/></button><button aria-label={`下移 ${entry.title}`} title="下移" disabled={!player.canEditPlaylist||entry.index===entries.length-1} onClick={()=>player.editPlaylist(entry.index,1)}><ArrowDown size={17} aria-hidden="true"/></button></div>}{entry.duration!==null&&entry.duration!==undefined&&<time>{Math.floor(entry.duration/60)}:{String(Math.floor(entry.duration)%60).padStart(2,'0')}</time>}{editing?<button className="media-playlist-remove" aria-label={`移除 ${entry.title}`} title="移出当前播放列表" disabled={!player.canEditPlaylist||entry.current} onClick={()=>player.editPlaylist(entry.index,0)}><X size={17} aria-hidden="true"/></button>:<button className="media-playlist-more" aria-label={`更多 ${entry.title} 操作`} onClick={()=>setSelected(entry.index)}><Ellipsis size={20} aria-hidden="true"/></button>}</li>)}</ol>
|
||||
@@ -81,6 +84,7 @@ export function PlaybackControls({player,api,panel,onPanelChange,onBack,onFavori
|
||||
const requestedView=panel??localPanel,view=requestedView==='chapters'&&!!channel&&channel!=='audiobook'?'main':requestedView==='lyrics'&&channel!=='music'?'main':requestedView,lyrics=view==='lyrics';
|
||||
const setView=(value:PlaybackPanel)=>{if(onPanelChange)onPanelChange(value);else setLocalPanel(value);};
|
||||
const edition=item?.editions?.find(entry=>entry.parts.some(part=>part.id===player.currentPartId));
|
||||
const partLabel=channel==='audiobook'?'本章':channel==='music'?'本曲':channel==='video'?'本集':undefined;
|
||||
function open(value:'more'|'versions'|'sleep'){setNotice('');setTool(value);}
|
||||
async function changeVersion(id:string){if(busy||!item)return;const target=item.editions.find(entry=>entry.id===id);if(!target||!target.parts.length||target.parts.some(part=>!part.available))return;setBusy(true);setNotice('');try{await player.play(target.parts.map(part=>({part,title:item.title+' · '+part.title,video:false})));if(!player.error)setTool(null);}catch(error){setNotice(error instanceof Error?error.message:'版本切换失败,请重试。');}finally{setBusy(false);}}
|
||||
if(!player.active)return null;
|
||||
@@ -92,26 +96,28 @@ export function PlaybackControls({player,api,panel,onPanelChange,onBack,onFavori
|
||||
{player.error&&<PlaybackProblem player={player} {...(item?.editions.length?{onVersions:()=>open('versions')}:{})} {...(onBack?{onBack}:{})} backLabel={view==='main'?'返回浏览':'返回播放'}/>}
|
||||
{!player.error&&player.loadingStatus&&<FloatingNotice message={player.loadingStatus} busy />}
|
||||
{player.canOpenNativeVideo&&<button onClick={()=>void player.openNativeVideo()}>打开 Android 视频播放器</button>}
|
||||
{view==='chapters'?<PlayingChapters key={itemId} player={player} api={api} item={item} error={failed} cause={cause} onRetry={()=>setRetry(value=>value+1)}/>:view==='queue'?<CurrentPlaylist player={player}/>:!player.isVideo&&itemId&&<PlayingArtwork api={api} item={item} failed={!!failed} onRetry={()=>setRetry(value=>value+1)} player={player} lyrics={lyrics} onMore={()=>open('more')}/>}
|
||||
{view==='chapters'?<PlayingChapters key={itemId} player={player} api={api} item={item} error={failed} cause={cause} onRetry={()=>setRetry(value=>value+1)}/>:view==='queue'?<CurrentPlaylist player={player} modes={channel==='music'}/>:!player.isVideo&&itemId&&<PlayingArtwork api={api} item={item} failed={!!failed} onRetry={()=>setRetry(value=>value+1)} player={player} lyrics={lyrics}/>}
|
||||
{view!=='queue'&&view!=='chapters'&&<div className="media-audio-bottom">
|
||||
{!player.isVideo&&<AudioTimeline player={player}/>}
|
||||
<div className="media-transport">
|
||||
{channel==='audiobook'&&<button className="media-symbol-button media-skip" aria-label="快退 15 秒" title="快退 15 秒" disabled={player.playbackUnavailable} onClick={()=>player.skip(-15)}><RotateCcw size={24} aria-hidden="true"/><small aria-hidden="true">15</small></button>}
|
||||
{channel==='music'&&<PlayModeCycle player={player}/>}
|
||||
{channel!=='audiobook'&&<button className="media-symbol-button" aria-label="上一首 / 章" title="上一首 / 章" disabled={!player.canPrevious} onClick={()=>void player.move(-1)}><SkipBack size={26} aria-hidden="true"/></button>}
|
||||
<button disabled={player.playbackUnavailable} className="media-toggle-play" aria-label={player.playbackUnavailable?'请重新选择播放':player.paused?'继续':'暂停'} onClick={()=>player.toggle()}>{player.paused?<Play size={30} fill="currentColor" aria-hidden="true"/>:<Pause size={30} fill="currentColor" aria-hidden="true"/>}</button>
|
||||
{channel!=='audiobook'&&<button className="media-symbol-button" aria-label="下一首 / 章" title="下一首 / 章" disabled={!player.canNext} onClick={()=>void player.move(1)}><SkipForward size={26} aria-hidden="true"/></button>}
|
||||
{channel==='music'&&<button className="media-symbol-button" aria-label="播放队列" title="播放队列" onClick={()=>setView('queue')}><ListMusic size={22} aria-hidden="true"/></button>}
|
||||
{channel==='audiobook'&&<button className="media-symbol-button media-skip" aria-label="快进 30 秒" title="快进 30 秒" disabled={player.playbackUnavailable} onClick={()=>player.skip(30)}><RotateCw size={24} aria-hidden="true"/><small aria-hidden="true">30</small></button>}
|
||||
</div>
|
||||
<div className="media-playback-options">
|
||||
{channel==='music'?<PlaybackFavorite key={itemId} api={api} itemId={itemId} {...(onFavoriteChange?{onChanged:onFavoriteChange}:{})}/>:speed}
|
||||
<button className="media-lyrics-toggle" aria-label="睡眠定时" aria-haspopup="dialog" onClick={()=>open('sleep')}><Timer size={18} aria-hidden="true"/><span>{player.sleepAt?player.sleepRemainingMinutes+' 分钟':'定时'}</span></button>
|
||||
<button className="media-lyrics-toggle" aria-label={channel==='audiobook'?'全部章节':'播放队列'} onClick={()=>setView(channel==='audiobook'?'chapters':'queue')}>{channel==='audiobook'?<BookOpen size={18} aria-hidden="true"/>:<ListMusic size={18} aria-hidden="true"/>}<span>{channel==='audiobook'?'章节':'队列'}</span></button>
|
||||
{channel==='audiobook'?<button className="media-lyrics-toggle" aria-label="播放版本" onClick={()=>open('versions')}><Layers size={18} aria-hidden="true"/><span>版本</span></button>:channel==='music'?<button className="media-lyrics-toggle" aria-label={lyrics?'封面':'歌词'} aria-pressed={lyrics} onClick={()=>setView(lyrics?'main':'lyrics')}><BookOpen size={18} aria-hidden="true"/><span>{lyrics?'封面':'歌词'}</span></button>:null}
|
||||
<button className="media-lyrics-toggle" aria-label="睡眠定时" aria-haspopup="dialog" onClick={()=>open('sleep')}><Timer size={18} aria-hidden="true"/><span>{player.sleepAt?player.sleepRemainingMinutes+' 分钟':player.sleepAfterPart&&partLabel?'播完'+partLabel:'定时'}</span></button>
|
||||
{channel!=='music'&&<button className="media-lyrics-toggle" aria-label={channel==='audiobook'?'全部章节':'播放队列'} onClick={()=>setView(channel==='audiobook'?'chapters':'queue')}>{channel==='audiobook'?<BookOpen size={18} aria-hidden="true"/>:<ListMusic size={18} aria-hidden="true"/>}<span>{channel==='audiobook'?'章节':'队列'}</span></button>}
|
||||
{channel==='audiobook'?<button className="media-lyrics-toggle" aria-label="播放版本" onClick={()=>open('versions')}><Layers size={18} aria-hidden="true"/><span>版本</span></button>:channel==='music'?<button className="media-lyrics-toggle" aria-label={lyrics?'封面':'歌词'} aria-pressed={lyrics} onClick={()=>setView(lyrics?'main':'lyrics')}><MicVocal size={18} aria-hidden="true"/><span>{lyrics?'封面':'歌词'}</span></button>:null}
|
||||
{!onBack&&<button className="media-lyrics-toggle" aria-label="更多播放选项" onClick={()=>open('more')}><Ellipsis size={20} aria-hidden="true"/></button>}
|
||||
</div>
|
||||
</div>}
|
||||
{tool&&<AudioToolSheet key={tool} title={tool==='sleep'?'睡眠定时':tool==='more'?'更多播放选项':'播放版本'} onClose={()=>{if(!busy)setTool(null);}}>
|
||||
{tool==='sleep'?<SleepTimerOptions player={player} onSelected={()=>setTool(null)}/>:tool==='versions'?<div className="media-audio-versions">{item?.editions?.map(value=><button key={value.id} disabled={busy||value.id===edition?.id&&!player.error||!value.parts.length||value.parts.some(part=>!part.available)} aria-current={value.id===edition?.id?'true':undefined} onClick={()=>void changeVersion(value.id)}><strong>{value.label}</strong><small>{value.parts.some(part=>!part.available)?'资源缺失':value.id===edition?.id?'当前版本':value.parts.length+' 个章节'}</small></button>)}</div>:<>
|
||||
{tool==='sleep'?<SleepTimerOptions player={player} {...(partLabel?{partLabel}:{})} onSelected={()=>setTool(null)}/>:tool==='versions'?<div className="media-audio-versions">{item?.editions?.map(value=><button key={value.id} disabled={busy||value.id===edition?.id&&!player.error||!value.parts.length||value.parts.some(part=>!part.available)} aria-current={value.id===edition?.id?'true':undefined} onClick={()=>void changeVersion(value.id)}><strong>{value.label}</strong><small>{value.parts.some(part=>!part.available)?'资源缺失':value.id===edition?.id?'当前版本':value.parts.length+' 个章节'}</small></button>)}</div>:<>
|
||||
<VolumeControl player={player}/>{channel!=='audiobook'&&<div className="media-audio-speed">{speed}</div>}
|
||||
<div className="media-audio-shortcuts"><button aria-label="上一首 / 章" disabled={!player.canPrevious} onClick={()=>void player.move(-1)}><SkipBack size={18} aria-hidden="true"/>上一{channel==='music'?'首':'章'}</button><button aria-label="下一首 / 章" disabled={!player.canNext} onClick={()=>void player.move(1)}><SkipForward size={18} aria-hidden="true"/>下一{channel==='music'?'首':'章'}</button></div>
|
||||
<div className="media-audio-shortcuts"><button aria-label="快退 15 秒" disabled={player.playbackUnavailable} onClick={()=>player.skip(-15)}><RotateCcw size={18} aria-hidden="true"/>15 秒</button><button aria-label="快进 30 秒" disabled={player.playbackUnavailable} onClick={()=>player.skip(30)}><RotateCw size={18} aria-hidden="true"/>30 秒</button></div>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {ChevronRight,FileText,Search} from 'lucide-react';
|
||||
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 {MediaSelect} from './select.tsx';
|
||||
|
||||
interface Provider {id:string;label:string;kinds:string[];configured:boolean}
|
||||
const kinds:Record<string,string>={movie:'电影',series:'剧集',season:'季',episode:'单集',artist:'艺人',album:'专辑',track:'曲目',audiobook:'有声书'};
|
||||
export function MediaProviderSettings({api}:{api:MediaApi}){
|
||||
const [providers,setProviders]=useState<Provider[]>([]),[loading,setLoading]=useState(true),[error,setError]=useState(''),[retry,setRetry]=useState(0),[selected,setSelected]=useState('');
|
||||
const [cause,setCause]=useState<unknown>();
|
||||
useEffect(()=>{const abort=new AbortController();setLoading(true);setError('');setCause(undefined);void api.request<{items:Provider[]}>('metadata/providers','GET',undefined,abort.signal).then(result=>{if(!abort.signal.aborted)setProviders(result.items);}).catch(error=>{if(!abort.signal.aborted){setError(error instanceof Error?error.message:'来源状态读取失败');setCause(error);}}).finally(()=>{if(!abort.signal.aborted)setLoading(false);});return ()=>abort.abort();},[api,retry]);
|
||||
const provider=providers.find(value=>value.id===selected);
|
||||
return <div className="media-provider-settings">
|
||||
<h2>元数据刮削</h2>
|
||||
{loading&&<MediaLoading layout="list" square label="正在读取来源状态…" count={2}/>} {error&&<MediaScreenError error={cause} message={error} busy={loading} retryLabel="重试读取来源" onRetry={()=>setRetry(value=>value+1)}/>}
|
||||
{!loading&&!error&&providers.map(value=><button key={value.id} className="media-provider-row" aria-expanded={selected===value.id} onClick={()=>setSelected(selected===value.id?'':value.id)}><Search size={20} aria-hidden="true"/><span><strong>{value.label}</strong><small>{value.kinds.map(kind=>kinds[kind]||kind).join('、')}</small></span><small className="media-status-badge" data-configured={value.configured}>{value.configured?'已配置':'未配置'}</small><ChevronRight size={16} aria-hidden="true"/></button>)}
|
||||
{!loading&&!error&&!providers.length&&<p>服务器尚未提供在线元数据来源。</p>}
|
||||
{provider&&!loading&&!error&&<section className="media-provider-note" aria-label={provider.label+'配置说明'}><h3>{provider.label}</h3><p>{provider.configured?'服务器已配置此来源,可以从作品详情搜索候选,或从扫描与刮削页面发起匹配。':'此来源尚未配置,配置完成后才能搜索和匹配元数据。'}</p>
|
||||
{['tmdb','musicbrainz'].includes(provider.id)?<BusinessSettings selectControl={MediaSelect} api={api} group={provider.id} onSaved={()=>setRetry(value=>value+1)}/>:<p>配置方式由服务器提供此来源的适配器决定。</p>}
|
||||
<button onClick={()=>{setSelected('');setRetry(value=>value+1);}}>刷新配置状态</button></section>}
|
||||
<button className="media-provider-row" aria-expanded={selected==='local'} onClick={()=>setSelected(selected==='local'?'':'local')}><FileText size={20} aria-hidden="true"/><span><strong>本地资料</strong><small>NFO、内嵌标签、同目录图片</small></span><small className="media-status-badge">内置</small><ChevronRight size={16} aria-hidden="true"/></button>
|
||||
{selected==='local'&&<p className="media-provider-note">扫描时读取本地资料,人工覆盖优先于资料源。在线匹配不会修改原始媒体文件。</p>}
|
||||
<h2>内容来源</h2><p className="media-manager-note">阅读书源保持独立管理。TVBox 兼容由后续影音来源插件提供,当前没有配置入口。</p>
|
||||
<p className="media-source-attribution">TMDB 数据来源于 TMDB,本应用未经 TMDB 认可或认证。MusicBrainz 数据来源于 MetaBrainz 社区。</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { Ellipsis, FileText, Layers } from 'lucide-react';
|
||||
import { Modal } from '../../../ui/modal.tsx';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { FileText, Layers } from 'lucide-react';
|
||||
import { ActionMenu } from '../../../ui/action-menu.tsx';
|
||||
import { Sheet } from '../../../ui/sheet.tsx';
|
||||
import { ResourceInfo } from './resource-info.tsx';
|
||||
import { EditionTools, type EditionDetailsProps } from './edition-details.tsx';
|
||||
import type { MediaApi } from '../api/media-api.ts';
|
||||
@@ -19,7 +20,7 @@ type ResourcePanelProps = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Resource details are rendered in a modal. The legacy detail screen exposes
|
||||
* Resource details are rendered in a sheet. The legacy detail screen exposes
|
||||
* the entry point from the page action menu, so the summary trigger is opt-in
|
||||
* for standalone consumers and hidden on the detail page.
|
||||
*/
|
||||
@@ -35,7 +36,6 @@ export function ResourcePanel({
|
||||
showTrigger = true,
|
||||
}: ResourcePanelProps) {
|
||||
const [panel, setPanel] = useState<ResourcePanelKind | null>(null);
|
||||
const menu = useRef<HTMLDetailsElement>(null);
|
||||
const files = [...new Map(assets.map(asset => [asset.id, asset])).values()];
|
||||
const manage = !!(editionOptions?.onUpdated || editionOptions?.onAssigned || editionOptions?.onRename);
|
||||
|
||||
@@ -43,19 +43,7 @@ export function ResourcePanel({
|
||||
setPanel(openPanel ?? null);
|
||||
}, [openPanel]);
|
||||
|
||||
useEffect(() => {
|
||||
const close = (event: PointerEvent) => {
|
||||
if (menu.current && !menu.current.contains(event.target as Node)) menu.current.open = false;
|
||||
};
|
||||
document.addEventListener('pointerdown', close);
|
||||
return () => document.removeEventListener('pointerdown', close);
|
||||
}, []);
|
||||
|
||||
function open(value: ResourcePanelKind) {
|
||||
if (menu.current) {
|
||||
menu.current.open = false;
|
||||
menu.current.querySelector('summary')?.focus();
|
||||
}
|
||||
setPanel(value);
|
||||
onPanelChange?.(value);
|
||||
}
|
||||
@@ -63,12 +51,12 @@ export function ResourcePanel({
|
||||
function close() {
|
||||
setPanel(null);
|
||||
onPanelChange?.(null);
|
||||
menu.current?.querySelector('summary')?.focus();
|
||||
}
|
||||
|
||||
const modal = panel && (
|
||||
<Modal
|
||||
<Sheet
|
||||
className="media-modal"
|
||||
size="wide"
|
||||
title={panel === 'files' ? '资源信息' : panel === 'source' ? '来源' : manage ? '版本管理' : '播放版本'}
|
||||
busy={!!editionOptions?.busy}
|
||||
onClose={close}
|
||||
@@ -80,33 +68,18 @@ export function ResourcePanel({
|
||||
? sourceInfo
|
||||
: <>{versionPicker}{manage && editionOptions && <EditionTools {...editionOptions} managementOnly />}</>}
|
||||
</div>
|
||||
</Modal>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
if (!showTrigger) return modal;
|
||||
return <>{modal}<section className="media-resource-panel media-resource-trigger" onClick={event => {
|
||||
if (event.target instanceof HTMLDialogElement && !editionOptions?.busy) {
|
||||
const rect = event.target.getBoundingClientRect();
|
||||
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) close();
|
||||
}
|
||||
}}>
|
||||
return <>{modal}<section className="media-resource-panel media-resource-trigger">
|
||||
<header>
|
||||
<h2>资源信息</h2>
|
||||
{(files.length > 0 || manage || versionPicker || sourceInfo) && <details className="media-actions" ref={menu} onKeyDown={event => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
menu.current!.open = false;
|
||||
menu.current!.querySelector('summary')?.focus();
|
||||
}
|
||||
}}>
|
||||
<summary aria-label="资源信息操作" title="资源信息操作"><Ellipsis size={19} aria-hidden="true" /></summary>
|
||||
<nav aria-label="资源信息操作">
|
||||
{files.length > 0 && <button onClick={() => open('files')}><FileText size={16} aria-hidden="true" />资源信息</button>}
|
||||
{(manage || versionPicker) && <button data-media-versions onClick={() => open('versions')}><Layers size={16} aria-hidden="true" />{manage ? '版本管理' : '播放版本'}</button>}
|
||||
{sourceInfo && <button onClick={() => open('source')}><FileText size={16} aria-hidden="true" />来源</button>}
|
||||
</nav>
|
||||
</details>}
|
||||
<ActionMenu className="media-icon-button" label="资源信息操作" items={[
|
||||
files.length > 0 && { label: '资源信息', icon: <FileText size={16} />, onSelect: () => open('files') },
|
||||
(manage || !!versionPicker) && { label: manage ? '版本管理' : '播放版本', icon: <Layers size={16} />, onSelect: () => open('versions') },
|
||||
!!sourceInfo && { label: '来源', icon: <FileText size={16} />, onSelect: () => open('source') },
|
||||
]} />
|
||||
</header>
|
||||
<p>{summary}</p>
|
||||
</section></>;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { MediaApi } from '../api/media-api.ts';
|
||||
|
||||
interface SettingsView {
|
||||
group: string; revision: number; values: Record<string, string | number | boolean>;
|
||||
fields: Array<{ key: string; label: string; type: string; help?: string }>;
|
||||
}
|
||||
/** The 扫描设置 business-settings fields this card owns, in the prototype's order. */
|
||||
export const scanSettingKeys = ['mediaAutoScan', 'mediaScrape', 'mediaEmbedded', 'mediaHidden'] as const;
|
||||
|
||||
/**
|
||||
* 扫描与元数据 (prototype 20): four switches for every media library. They are the
|
||||
* server's 扫描设置 business settings, so the same values appear in 系统设置 too;
|
||||
* a switch saves on its own, with the settings revision guarding concurrent edits.
|
||||
*/
|
||||
export function MediaScanSettings({ api }: { api: Pick<MediaApi, 'businessSettingsRequest'> }) {
|
||||
const [view, setView] = useState<SettingsView | null>(null);
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [error, setError] = useState(''), [busy, setBusy] = useState(false), [retry, setRetry] = useState(0);
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
setStatus('loading');
|
||||
void api.businessSettingsRequest<{ groups: SettingsView[] }>('', 'GET', undefined, abort.signal).then(result => {
|
||||
if (abort.signal.aborted) return;
|
||||
setView(result.groups.find(group => group.group === 'scanning') ?? null); setStatus('ready');
|
||||
}).catch(() => { if (!abort.signal.aborted) setStatus('error'); });
|
||||
return () => abort.abort();
|
||||
}, [api, retry]);
|
||||
const fields = scanSettingKeys.map(key => view?.fields.find(field => field.key === key)).filter(field => field !== undefined);
|
||||
// An older server without these settings: nothing to show.
|
||||
if (status === 'ready' && !fields.length) return null;
|
||||
async function toggle(key: string) {
|
||||
if (!view || busy) return;
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
setView(await api.businessSettingsRequest<SettingsView>('scanning', 'PATCH', { values: { [key]: !view.values[key] }, revision: view.revision }));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '设置保存失败');
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
return <aside className="media-scan-settings" aria-label="扫描与元数据">
|
||||
<h2>扫描与元数据</h2>
|
||||
{status === 'loading' && <p className="media-scan-settings-note" role="status">正在读取设置…</p>}
|
||||
{status === 'error' && <div className="media-error" role="alert"><p>扫描设置读取失败。</p><button onClick={() => setRetry(value => value + 1)}>重试</button></div>}
|
||||
{view && <div className="media-scan-settings-list">{fields.map(field => {
|
||||
const on = view.values[field.key] === true;
|
||||
return <button type="button" role="switch" key={field.key} aria-checked={on} disabled={busy} onClick={() => void toggle(field.key)}>
|
||||
<span><strong>{field.label}</strong>{field.help && <small>{field.help}</small>}</span>
|
||||
<i className="media-scan-switch" aria-hidden="true" />
|
||||
</button>;
|
||||
})}</div>}
|
||||
{error && <p className="media-error" role="alert">{error}</p>}
|
||||
<p className="media-scan-settings-note">元数据优先使用本地 NFO 与内嵌标签,缺失时再联网刮削。</p>
|
||||
</aside>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
|
||||
import { ConfirmSheet } from '../../../ui/sheet.tsx';
|
||||
import {FloatingNotice} from '../../../ui/floating-notice.tsx';
|
||||
import {MediaSelect} from './select.tsx';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
@@ -113,6 +113,6 @@ export function ScrapeJobs({api,libraries,navigate}:{api:MediaApi;libraries:Libr
|
||||
<button disabled={busy||loading||jobsState!=='ready'||running||!selected.length||!allowed.includes(kind)} onClick={()=>void action('scrape-jobs',{provider,itemIds:selected,...(matchMode!=='strong'?{matchMode}: {})})}>开始批量匹配</button>
|
||||
</details>
|
||||
{taskHistory}
|
||||
{deleteId&&<FloatingConfirm theme="media" title="删除刮削任务" text="仅删除这条任务及结果记录,已保存的媒体资料和匹配候选保留。" confirmText="删除记录" cancelText="取消" onCancel={()=>setDeleteId('')} onConfirm={()=>{const id=deleteId;setDeleteId('');void action('scrape-jobs/'+encodeURIComponent(id),undefined,'DELETE');}}/>}
|
||||
{deleteId&&<ConfirmSheet danger title="删除刮削任务" text="仅删除这条任务及结果记录,已保存的媒体资料和匹配候选保留。" confirmText="删除记录" cancelText="取消" onCancel={()=>setDeleteId('')} onConfirm={()=>{const id=deleteId;setDeleteId('');void action('scrape-jobs/'+encodeURIComponent(id),undefined,'DELETE');}}/>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,54 +1,109 @@
|
||||
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
|
||||
import {MediaSelect} from './select.tsx';
|
||||
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,Palette} from 'lucide-react';
|
||||
import type {MediaPlayer} from '../services/player.ts';
|
||||
import type {MediaApi,MediaAccount} from '../api/media-api.ts';
|
||||
import {MediaProviderSettings} from './provider-settings.tsx';
|
||||
import {defaultMediaPreferences,saveMediaPreferences,type MediaPreferences} from '../services/preferences.ts';
|
||||
import { ConfirmSheet } from '../../../ui/sheet.tsx';
|
||||
import { Icon } from '../../../ui/toolkit.tsx';
|
||||
import { MediaSelect } from './select.tsx';
|
||||
import { PlaybackSettings } from './playback-settings.tsx';
|
||||
import { MediaThemeSettings } from './theme-settings.tsx';
|
||||
import type { AppSettings } from '../../../store/settings.ts';
|
||||
import type { MediaPlayer } from '../services/player.ts';
|
||||
import type { MediaAccount } from '../api/media-api.ts';
|
||||
import { defaultMediaPreferences, saveMediaPreferences, type MediaPreferences } from '../services/preferences.ts';
|
||||
|
||||
export function MediaSettings({scope,preferences,player,admin,api: _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}){
|
||||
const [localTheme,setLocalTheme]=useState(()=>readMediaTheme(scope));
|
||||
const selectedTheme=theme??localTheme;
|
||||
const [localPanel,setLocalPanel]=useState<SettingsPanel>('home');
|
||||
const panel=routePanel??localPanel;
|
||||
function setPanel(value:SettingsPanel){if(onPanelChange)onPanelChange(value);else setLocalPanel(value);}
|
||||
const [draft,setDraft]=useState({...preferences}),[error,setError]=useState(''),[saved,setSaved]=useState(false);
|
||||
const [leaving,setLeaving]=useState(false);
|
||||
const dirty=draft.density!==preferences.density||draft.showContinue!==preferences.showContinue||draft.showLibraryName!==preferences.showLibraryName||(draft.sort??'default')!==(preferences.sort??'default');
|
||||
function back(){if(panel==='browse'&&dirty){setLeaving(true);return;}setLeaving(false);if(panel==='home')onBack();else setPanel('home');}
|
||||
function save(){setError('');setSaved(false);try{const value=saveMediaPreferences(scope,draft);onSaved(value);setSaved(true);}catch(error){setError(error instanceof Error?error.message:'保存失败');}}
|
||||
function change(patch:Partial<MediaPreferences>){setDraft({...draft,...patch});setSaved(false);}
|
||||
function openBrowse(){if(!dirty)setDraft({...preferences});setError('');setSaved(false);setPanel('browse');}
|
||||
const title=panel==='theme'?'主题外观':panel==='browse'?'浏览偏好':panel==='playback'?'播放设置':panel==='plugins'?'来源与刮削':panel==='account'?'账号':'影音设置';
|
||||
const link=(Icon:typeof Clock3,label:string,description:string,onClick:()=>void)=><button className="media-setting-row" onClick={onClick}><Icon size={19} strokeWidth={1.6} aria-hidden="true"/><span>{label}</span><small>{description}</small><ChevronRight size={16} aria-hidden="true"/></button>;
|
||||
return <section className="media-settings" aria-label="影音设置">
|
||||
<div className="media-settings-heading"><button className="media-back-button" data-media-back aria-label={panel==='home'?'← 返回影音':'返回影音设置'} title={panel==='home'?'返回影音':'返回影音设置'} onClick={back}><ChevronLeft size={20} aria-hidden="true"/></button><h1>{title}</h1></div>
|
||||
{leaving&&<FloatingConfirm theme="media" title="放弃浏览偏好修改?" text="浏览偏好尚未保存,离开后将丢弃本次修改。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);setDraft({...preferences});setPanel('home');}}/>}
|
||||
{panel==='home'?<>
|
||||
{account&&<div className="media-settings-intro"><div className="media-account-avatar" aria-hidden="true">{Array.from(account.displayName||account.username)[0]}</div><div><strong>{account.displayName||account.username}</strong><small>{account.role==='admin'?'管理员':'普通用户'} · 当前服务器</small></div></div>}
|
||||
<section className="media-settings-group"><h2>我的内容</h2>
|
||||
{link(Heart,'我的收藏','',()=>onPersonal('favorites'))}
|
||||
{link(Clock3,'播放历史','',()=>onPersonal('history'))}
|
||||
</section><section className="media-settings-group"><h2>偏好</h2>
|
||||
{link(Palette,'主题外观',selectedTheme==='system'?'跟随系统':mediaThemes.find(theme=>theme.id===selectedTheme)!.name,()=>setPanel('theme'))}
|
||||
{link(SlidersHorizontal,'浏览偏好',preferences.density==='compact'?'紧凑':'宽松',openBrowse)}
|
||||
{link(Headphones,'播放设置','倍速与连续播放',()=>setPanel('playback'))}
|
||||
</section>
|
||||
{admin&&<section className="media-settings-group"><h2>管理员</h2>{link(FolderOpen,'媒体库管理','全部影音媒体库',onManage)}{onTasks&&link(RefreshCw,'扫描与刮削','任务与匹配结果',onTasks)}</section>}
|
||||
{account&&<section className="media-settings-group"><h2>账号</h2>{link(UserRound,'账号与连接','',()=>setPanel('account'))}</section>}
|
||||
</>:panel==='theme'?<MediaThemeSettings scope={scope} selected={selectedTheme} onChange={value=>{setLocalTheme(value);onThemeChange?.(value);}}/>:panel==='browse'?<form className="media-form media-settings-panel" onSubmit={event=>{event.preventDefault();save();}}>
|
||||
<p>仅影响影音页面,在当前设备为此服务器和账号保存。</p>
|
||||
<label className="media-setting-field">封面密度<MediaSelect aria-label="封面密度" value={draft.density} onChange={event=>change({density:event.currentTarget.value as MediaPreferences['density']})}><option value="compact">紧凑(手机三列)</option><option value="comfortable">宽松(手机两列)</option></MediaSelect></label>
|
||||
<label className="media-setting-field">默认排序<MediaSelect aria-label="默认排序" value={draft.sort??'default'} onChange={event=>change({sort:event.currentTarget.value as NonNullable<MediaPreferences['sort']>})}><option value="default">默认顺序</option><option value="title-asc">名称升序</option><option value="title-desc">名称降序</option></MediaSelect></label>
|
||||
<label className="media-settings-check"><span>首页显示最近续播</span><input type="checkbox" checked={draft.showContinue} onChange={event=>change({showContinue:event.currentTarget.checked})}/></label>
|
||||
<label className="media-settings-check"><span>列表工具栏显示来源库</span><input type="checkbox" checked={draft.showLibraryName} onChange={event=>change({showLibraryName:event.currentTarget.checked})}/></label>
|
||||
<div className="media-settings-footer"><button type="button" onClick={()=>{setDraft({...defaultMediaPreferences});setSaved(false);}}>恢复默认值</button><button className="media-primary" type="submit">保存偏好</button></div>
|
||||
{saved&&<p role="status">影音偏好已保存</p>}{error&&<p role="alert">{error}</p>}
|
||||
</form>:panel==='plugins'?admin&&_api?<MediaProviderSettings api={_api}/>:<section className="media-settings-panel" role="alert"><h2>需要管理员权限</h2><p>请返回影音设置查看可用功能。</p></section>:panel==='account'&&account?<section className="media-account-panel"><div className="media-account-avatar" aria-hidden="true">{Array.from(account.displayName||account.username)[0]}</div><h2>{account.displayName||account.username}</h2><p>@{account.username} · {account.role==='admin'?'管理员':'普通用户'}</p><p className="media-manager-note">共享作品资料,收藏、队列和播放进度按账号隔离。</p><dl><dt>当前服务器</dt><dd>{account.server||window.location.origin}</dd></dl></section>:<PlaybackSettings scope={scope} player={player} {...(onPlayback?{onPlayback}:{})}/>}
|
||||
export type SettingsPanel = 'theme' | 'browse' | 'playback' | 'account';
|
||||
|
||||
export const SETTINGS_TITLES: Record<SettingsPanel, string> = {
|
||||
theme: '外观与主题',
|
||||
browse: '影音浏览',
|
||||
playback: '播放',
|
||||
account: '账号与连接',
|
||||
};
|
||||
|
||||
/**
|
||||
* One settings panel of 我的 (/me/:panel). There is no media settings home any
|
||||
* more: 我的 is the list, and each row opens one of these panels.
|
||||
*
|
||||
* Only 影音浏览 holds a draft; leaving it with unsaved changes asks first.
|
||||
*/
|
||||
export function MediaSettings({ scope, preferences, player, account, onSaved, onBack, panel, onReaderSettings, onPlayback, backLabel = '返回我的' }: {
|
||||
scope: string;
|
||||
preferences: MediaPreferences;
|
||||
player: MediaPlayer;
|
||||
account?: MediaAccount | null;
|
||||
onSaved: (value: MediaPreferences) => void;
|
||||
onBack: () => void;
|
||||
panel: SettingsPanel;
|
||||
onReaderSettings?: (patch: Partial<AppSettings>) => void;
|
||||
onPlayback?: () => void;
|
||||
backLabel?: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState({ ...preferences });
|
||||
const [error, setError] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const dirty = draft.density !== preferences.density
|
||||
|| draft.showContinue !== preferences.showContinue
|
||||
|| draft.showLibraryName !== preferences.showLibraryName
|
||||
|| (draft.sort ?? 'default') !== (preferences.sort ?? 'default');
|
||||
|
||||
function back() {
|
||||
if (panel === 'browse' && dirty) { setLeaving(true); return; }
|
||||
onBack();
|
||||
}
|
||||
function save() {
|
||||
setError('');
|
||||
setSaved(false);
|
||||
try {
|
||||
onSaved(saveMediaPreferences(scope, draft));
|
||||
setSaved(true);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '保存失败');
|
||||
}
|
||||
}
|
||||
function change(patch: Partial<MediaPreferences>) {
|
||||
setDraft({ ...draft, ...patch });
|
||||
setSaved(false);
|
||||
}
|
||||
|
||||
return <section className="media-settings" aria-label={SETTINGS_TITLES[panel]}>
|
||||
<div className="media-settings-heading">
|
||||
<button type="button" className="media-back-button" data-media-back aria-label={backLabel} title={backLabel} onClick={back}><Icon name="chevron-left" /></button>
|
||||
<h1>{SETTINGS_TITLES[panel]}</h1>
|
||||
</div>
|
||||
{leaving && <ConfirmSheet danger title="放弃浏览偏好修改?" text="浏览偏好尚未保存,离开后将丢弃本次修改。" confirmText="放弃修改" cancelText="继续编辑"
|
||||
onCancel={() => setLeaving(false)}
|
||||
onConfirm={() => { setLeaving(false); setDraft({ ...preferences }); onBack(); }} />}
|
||||
{panel === 'theme' && <MediaThemeSettings scope={scope} {...(onReaderSettings ? { onReaderSettings } : {})} />}
|
||||
{panel === 'browse' && <form className="media-form media-settings-panel" onSubmit={event => { event.preventDefault(); save(); }}>
|
||||
<p>仅影响影音页面,在当前设备为此服务器和账号保存</p>
|
||||
<label className="media-setting-field">封面密度
|
||||
<MediaSelect aria-label="封面密度" value={draft.density} onChange={event => change({ density: event.currentTarget.value as MediaPreferences['density'] })}>
|
||||
<option value="compact">紧凑(手机三列)</option>
|
||||
<option value="comfortable">宽松(手机两列)</option>
|
||||
</MediaSelect>
|
||||
</label>
|
||||
<label className="media-setting-field">默认排序
|
||||
<MediaSelect aria-label="默认排序" value={draft.sort ?? 'default'} onChange={event => change({ sort: event.currentTarget.value as NonNullable<MediaPreferences['sort']> })}>
|
||||
<option value="default">默认顺序</option>
|
||||
<option value="title-asc">名称升序</option>
|
||||
<option value="title-desc">名称降序</option>
|
||||
</MediaSelect>
|
||||
</label>
|
||||
<label className="media-settings-check"><span>首页显示最近续播</span><input type="checkbox" checked={draft.showContinue} onChange={event => change({ showContinue: event.currentTarget.checked })} /></label>
|
||||
<label className="media-settings-check"><span>列表工具栏显示来源库</span><input type="checkbox" checked={draft.showLibraryName} onChange={event => change({ showLibraryName: event.currentTarget.checked })} /></label>
|
||||
<div className="media-settings-footer">
|
||||
<button type="button" onClick={() => { setDraft({ ...defaultMediaPreferences }); setSaved(false); }}>恢复默认值</button>
|
||||
<button className="media-primary" type="submit">保存偏好</button>
|
||||
</div>
|
||||
{saved && <p role="status">影音偏好已保存</p>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</form>}
|
||||
{panel === 'account' && account && <section className="media-account-panel">
|
||||
<div className="media-account-avatar" aria-hidden="true">{Array.from(account.displayName || account.username)[0]}</div>
|
||||
<h2>{account.displayName || account.username}</h2>
|
||||
<p>@{account.username} · {account.role === 'admin' ? '管理员' : '普通用户'}</p>
|
||||
<p className="media-manager-note">共享作品资料,收藏、队列和播放进度按账号隔离</p>
|
||||
<dl><dt>当前服务器</dt><dd>{account.server || window.location.origin}</dd></dl>
|
||||
</section>}
|
||||
{panel === 'playback' && <PlaybackSettings scope={scope} player={player} {...(onPlayback ? { onPlayback } : {})} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import {Check,TimerOff} from 'lucide-react';
|
||||
import type {MediaPlayer} from '../services/player.ts';
|
||||
|
||||
export function SleepTimerOptions({player,onSelected}:{player:MediaPlayer;onSelected?:()=>void}){
|
||||
/** `partLabel` names what one entry is ("本章", "本曲", "本集"); without it the stop-after-entry choice is not offered. */
|
||||
export function SleepTimerOptions({player,onSelected,partLabel}:{player:MediaPlayer;onSelected?:()=>void;partLabel?:string}){
|
||||
const stopAfterPart=player.sleepAfterPart===true,timed=!!player.sleepAt,off=!timed&&!stopAfterPart;
|
||||
function choose(minutes:number){player.sleep(minutes);onSelected?.();}
|
||||
return <div className="media-sleep-options"><p role="status">{player.sleepAt?'将在 '+player.sleepRemainingMinutes+' 分钟后暂停播放':'选择播放多久后自动暂停'}</p>
|
||||
function chooseAfterPart(){player.setSleepAfterPart(true);onSelected?.();}
|
||||
return <div className="media-sleep-options"><p role="status">{stopAfterPart?'播完'+partLabel+'后自动暂停,并保留当前进度。':timed?'将在 '+player.sleepRemainingMinutes+' 分钟后暂停播放':'选择播放多久后自动暂停'}</p>
|
||||
<div className="media-sleep-presets">{[15,30,45,60,90,120].map(minutes=><button key={minutes} onClick={()=>choose(minutes)}>{minutes}<small>分钟</small></button>)}</div>
|
||||
<button className="media-sleep-off" onClick={()=>choose(0)}><TimerOff size={18} aria-hidden="true"/><span>关闭定时</span>{!player.sleepAt&&<Check size={17} aria-hidden="true"/>}</button>
|
||||
<div className="media-sleep-actions">
|
||||
{partLabel&&<button className="media-sleep-part" aria-pressed={stopAfterPart} disabled={!player.canSleepAfterPart} onClick={chooseAfterPart}>播完{partLabel}</button>}
|
||||
<button className="media-sleep-off" aria-label="关闭定时" aria-pressed={off} onClick={()=>choose(0)}><TimerOff size={18} aria-hidden="true"/><span>关闭定时</span>{off&&<Check size={17} aria-hidden="true"/>}</button>
|
||||
</div>
|
||||
{partLabel&&player.canSleepAfterPart===false&&<p className="media-sleep-note">当前播放器不支持播完后暂停。</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { MediaApi } from '../api/media-api.ts';
|
||||
import { decodeSubtitleBytes, subtitleFormatOf, subtitleTextToVtt, SubtitleFormatError } from '../services/subtitle-format.ts';
|
||||
|
||||
interface Subtitle { id: string; label: string; language: string; format: string; source?:'embedded'|'external' }
|
||||
interface LocalSubtitle { id: string; label: string; format: string; webvtt: string }
|
||||
|
||||
/** Embedded decoder tracks and fetched sidecars share one exclusive selector. */
|
||||
export class SubtitleControls {
|
||||
/** One row of the 字幕 list: what it is, and where it came from (外挂 · srt, 内嵌). */
|
||||
export interface SubtitleOption { value: string; label: string; note: string }
|
||||
|
||||
/**
|
||||
* Embedded decoder tracks, fetched sidecars and files picked from this device share one
|
||||
* exclusive selector. The `<select>` is the imperative control (kept for the Android
|
||||
* shell and the tests); the 字幕与音轨 sheet draws the same state from `options` and
|
||||
* listens for `change`.
|
||||
*/
|
||||
export class SubtitleControls extends EventTarget {
|
||||
readonly element = document.createElement('label');
|
||||
private readonly select = document.createElement('select');
|
||||
private readonly status = document.createElement('span');
|
||||
@@ -18,6 +28,12 @@ export class SubtitleControls {
|
||||
private embedded: TextTrack[] = [];
|
||||
private textTracks: TextTrackList | null = null;
|
||||
private externalSelection = '';
|
||||
private locals: LocalSubtitle[] = [];
|
||||
private delayMs = 0;
|
||||
/** Each cue's own timing, so a delay is always an offset from the file, never cumulative. */
|
||||
private readonly originals = new WeakMap<TextTrackCue, [number, number]>();
|
||||
private revision = 0;
|
||||
private notify() { this.revision += 1; this.dispatchEvent(new Event('change')); }
|
||||
private readonly refreshEmbedded = () => {
|
||||
if (!this.active) return;
|
||||
const selected = this.select.value;
|
||||
@@ -37,15 +53,21 @@ export class SubtitleControls {
|
||||
const option = document.createElement('option'); option.value = item.id;
|
||||
option.textContent = `${item.label} · ${item.format.toUpperCase()} · ${item.source==='embedded'?'内嵌提取':'外置'}`; this.select.append(option);
|
||||
}
|
||||
for (const local of this.locals) {
|
||||
const option = document.createElement('option'); option.value = local.id;
|
||||
option.textContent = `${local.label} · ${local.format.toUpperCase()} · 本机`; this.select.append(option);
|
||||
}
|
||||
const showingIndex = this.embedded.findIndex(track => track.mode === 'showing');
|
||||
if (showingIndex >= 0) { this.loading?.abort(); this.releaseTrack(); this.status.textContent = ''; }
|
||||
this.select.value = this.externalSelection || (showingIndex >= 0 ? `embedded:${showingIndex}` :
|
||||
this.items.some(item => item.id === selected) ? selected : '');
|
||||
this.select.hidden = this.items.length + this.embedded.length === 0;
|
||||
[...this.items, ...this.locals].some(item => item.id === selected) ? selected : '');
|
||||
this.select.hidden = this.items.length + this.embedded.length + this.locals.length === 0;
|
||||
this.element.hidden = this.select.hidden && !this.listFailed;
|
||||
this.notify();
|
||||
};
|
||||
|
||||
constructor(private readonly api: MediaApi, private readonly video: HTMLVideoElement) {
|
||||
super();
|
||||
this.element.className = 'media-subtitle-controls';
|
||||
this.element.hidden = true;
|
||||
this.element.append(document.createTextNode('字幕 '), this.select, this.status);
|
||||
@@ -71,39 +93,94 @@ export class SubtitleControls {
|
||||
}
|
||||
}
|
||||
private releaseTrack() {
|
||||
if (this.track) { this.track.track.mode = 'disabled'; this.track.remove(); this.track = null; }
|
||||
if (this.track) { if (this.track.track) this.track.track.mode = 'disabled'; this.track.remove(); this.track = null; }
|
||||
if (this.url) URL.revokeObjectURL(this.url);
|
||||
this.url = '';
|
||||
}
|
||||
private async choose(id: string) {
|
||||
this.externalSelection = this.items.some(item => item.id === id) ? id : '';
|
||||
this.externalSelection = [...this.items, ...this.locals].some(item => item.id === id) ? id : '';
|
||||
this.loading?.abort(); this.releaseTrack(); this.status.textContent = '';
|
||||
const embedded = id.startsWith('embedded:') ? this.embedded[Number(id.slice(9))] : null;
|
||||
for (const track of this.embedded) track.mode = track === embedded ? 'showing' : 'disabled';
|
||||
this.notify();
|
||||
if (embedded) return;
|
||||
if (!id) return;
|
||||
const local = this.locals.find(candidate => candidate.id === id);
|
||||
if (local) { this.attach(local.label, 'und', local.webvtt); return; }
|
||||
const item = this.items.find(candidate => candidate.id === id);
|
||||
if (!item) return;
|
||||
const controller = new AbortController(); this.loading = controller;
|
||||
this.status.textContent = '加载中…';
|
||||
this.status.textContent = '加载中…'; this.notify();
|
||||
try {
|
||||
const result = await this.api.request<{ webvtt: string }>(`assets/${encodeURIComponent(this.assetId)}/subtitles/${encodeURIComponent(id)}`, 'GET', undefined, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
this.url = URL.createObjectURL(new Blob([result.webvtt], { type: 'text/vtt' }));
|
||||
const track = document.createElement('track'); this.track = track;
|
||||
track.kind = 'subtitles'; track.label = item.label; track.srclang = item.language; track.src = this.url;
|
||||
track.addEventListener('load', () => {
|
||||
if (this.track !== track) return;
|
||||
track.track.mode = 'showing'; this.status.textContent = '';
|
||||
});
|
||||
track.addEventListener('error', () => {
|
||||
if (this.track === track) { this.externalSelection = ''; this.status.textContent = '字幕加载失败,可重新选择。'; this.select.value = ''; this.releaseTrack(); }
|
||||
});
|
||||
this.video.append(track); track.track.mode = 'showing';
|
||||
this.attach(item.label, item.language, result.webvtt);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) { this.externalSelection = ''; this.status.textContent = error instanceof Error ? error.message : '字幕加载失败,可重新选择。'; this.select.value = ''; }
|
||||
if (!controller.signal.aborted) { this.externalSelection = ''; this.status.textContent = error instanceof Error ? error.message : '字幕加载失败,可重新选择。'; this.select.value = ''; this.notify(); }
|
||||
}
|
||||
}
|
||||
/** Shows WebVTT as a sidecar `<track>` on the video; the delay is applied once its cues load. */
|
||||
private attach(label: string, language: string, webvtt: string) {
|
||||
this.url = URL.createObjectURL(new Blob([webvtt], { type: 'text/vtt' }));
|
||||
const track = document.createElement('track'); this.track = track;
|
||||
track.kind = 'subtitles'; track.label = label; track.srclang = language; track.src = this.url;
|
||||
track.addEventListener('load', () => {
|
||||
if (this.track !== track) return;
|
||||
track.track.mode = 'showing'; this.status.textContent = ''; this.shiftCues(); this.notify();
|
||||
});
|
||||
track.addEventListener('error', () => {
|
||||
if (this.track === track) { this.externalSelection = ''; this.status.textContent = '字幕加载失败,可重新选择。'; this.select.value = ''; this.releaseTrack(); this.notify(); }
|
||||
});
|
||||
this.video.append(track); if (track.track) track.track.mode = 'showing';
|
||||
this.notify();
|
||||
}
|
||||
private shiftCues() {
|
||||
const cues = this.track?.track?.cues;
|
||||
if (!cues) return;
|
||||
for (const cue of Array.from(cues)) {
|
||||
let original = this.originals.get(cue);
|
||||
if (!original) { original = [cue.startTime, cue.endTime]; this.originals.set(cue, original); }
|
||||
const start = Math.max(0, original[0] + this.delayMs / 1000);
|
||||
cue.startTime = start; cue.endTime = Math.max(start, original[1] + this.delayMs / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the 字幕与音轨 sheet ----
|
||||
|
||||
/** Changes whenever the list, the selection or the status does (for useSyncExternalStore). */
|
||||
get version(): number { return this.revision; }
|
||||
get value(): string { return this.select.value; }
|
||||
get message(): string { return this.status.textContent ?? ''; }
|
||||
get options(): SubtitleOption[] {
|
||||
return [
|
||||
{ value: '', label: '关闭', note: '' },
|
||||
...this.embedded.map((track, index) => ({ value: `embedded:${index}`, label: track.label || track.language || `字幕 ${index + 1}`, note: '内嵌' })),
|
||||
...this.items.map(item => ({ value: item.id, label: item.label, note: item.source === 'embedded' ? '内嵌' : `外挂 · ${item.format.toLowerCase()}` })),
|
||||
...this.locals.map(local => ({ value: local.id, label: local.label, note: `本机 · ${local.format}` })),
|
||||
];
|
||||
}
|
||||
/** Picks a row, exactly as choosing it in the `<select>` would. */
|
||||
pick(value: string) { this.select.value = value; void this.choose(value); }
|
||||
/** 添加外挂字幕: converts a device file to WebVTT here and selects it. Session-only. */
|
||||
async addLocal(file: { name: string; arrayBuffer(): Promise<ArrayBuffer> }): Promise<void> {
|
||||
if (!this.active) throw new SubtitleFormatError('开始播放视频后才能添加字幕。');
|
||||
const format = subtitleFormatOf(file.name);
|
||||
if (!format) throw new SubtitleFormatError('请选择 SRT、VTT 或 ASS 字幕文件。');
|
||||
const webvtt = subtitleTextToVtt(decodeSubtitleBytes(new Uint8Array(await file.arrayBuffer())), format);
|
||||
if (!this.active) return;
|
||||
const local: LocalSubtitle = { id: `local:${this.locals.length + 1}`, label: file.name.replace(/\.[^.]+$/, '') || '本机字幕', format, webvtt };
|
||||
this.locals.push(local);
|
||||
this.refreshEmbedded();
|
||||
this.pick(local.id);
|
||||
}
|
||||
/** Seconds the subtitles are moved by: positive shows them later. Sidecar and local files only. */
|
||||
get delay(): number { return this.delayMs / 1000; }
|
||||
get canDelay(): boolean { return this.track !== null; }
|
||||
setDelay(seconds: number) {
|
||||
if (!Number.isFinite(seconds)) return;
|
||||
this.delayMs = Math.round(Math.max(-60, Math.min(60, seconds)) * 10) * 100;
|
||||
this.shiftCues(); this.notify();
|
||||
}
|
||||
clear() {
|
||||
this.active = false;
|
||||
this.externalSelection = '';
|
||||
@@ -112,7 +189,8 @@ export class SubtitleControls {
|
||||
for (const track of this.embedded) track.mode = 'disabled';
|
||||
this.textTracks = null; this.embedded = []; this.listFailed = false;
|
||||
this.listing?.abort(); this.loading?.abort(); this.releaseTrack();
|
||||
this.items = []; this.assetId = ''; this.status.textContent = '';
|
||||
this.items = []; this.locals = []; this.delayMs = 0; this.assetId = ''; this.status.textContent = '';
|
||||
this.select.replaceChildren(); this.select.hidden = false; this.element.hidden = true;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,114 @@
|
||||
import { useState } from 'react';
|
||||
import {Check,Monitor} from 'lucide-react';
|
||||
import {mediaThemes,saveMediaTheme,type MediaThemeId} from '../services/theme.ts';
|
||||
import { Check, SunMoon } from 'lucide-react';
|
||||
import {
|
||||
appThemes, paperIds, readerPapers, resolveAppTheme, resolveReadingTheme, saveThemePreference, themes,
|
||||
type AppThemeId, type ReaderPaper, type ThemeId, type ThemeMode, type ThemePreference,
|
||||
} from '../../../shared/theme/theme.ts';
|
||||
import { useThemeState } from '../../../shared/theme/use-theme.ts';
|
||||
import { useSettingsStore } from '../../../shared/stores/settings.store.ts';
|
||||
import type { AppSettings } from '../../../store/settings.ts';
|
||||
|
||||
export function MediaThemeSettings({scope,selected,onChange}:{scope:string;selected:MediaThemeId;onChange:(theme:MediaThemeId)=>void}){
|
||||
const [error,setError]=useState(''),[saved,setSaved]=useState(false);
|
||||
function choose(theme:MediaThemeId){setError('');setSaved(false);try{saveMediaTheme(scope,theme);onChange(theme);setSaved(true);}catch(error){setError(error instanceof Error?error.message:'主题保存失败');}}
|
||||
return <section className="media-theme-settings">
|
||||
<button className="media-theme-system" aria-pressed={selected==='system'} onClick={()=>choose('system')}><Monitor size={21} aria-hidden="true"/><span><strong>跟随系统</strong><small>随设备自动切换浅色与深色</small></span>{selected==='system'&&<Check size={19} aria-hidden="true"/>}</button>
|
||||
<h2>内置主题</h2>
|
||||
<div className="media-theme-grid" role="group" aria-label="内置主题">{mediaThemes.map(theme=><button key={theme.id} className="media-theme-card" aria-label={theme.name} aria-pressed={selected===theme.id} onClick={()=>choose(theme.id)}>
|
||||
<span className="media-theme-preview" aria-hidden="true" style={{background:theme.colors[0],color:theme.colors[6]}}><span className="media-theme-preview-heading"><i style={{background:theme.colors[3]}}/><i style={{background:theme.colors[6]}}/></span><span className="media-theme-preview-covers">{[6,8,2].map(index=><i key={index} style={{background:theme.colors[index]}}/>)}</span><span className="media-theme-preview-line" style={{background:theme.colors[5]}}/></span>
|
||||
<span className="media-theme-card-label"><strong>{theme.name}</strong>{selected===theme.id&&<Check size={17} aria-hidden="true"/>}</span><small>{theme.description}</small>
|
||||
</button>)}</div>
|
||||
<p className="media-theme-note">选择后立即生效,仅应用于影音。在此设备按服务器和账号保存。</p>
|
||||
{saved&&<p role="status">主题已保存</p>}{error&&<p className="media-error" role="alert">{error}</p>}
|
||||
/** Both halves of a theme, light on the left and dark on the right, in its own colours. */
|
||||
function ThemePreview({ id }: { id: ThemeId }) {
|
||||
return <span className="theme-preview" aria-hidden="true">{(['light', 'dark'] as const).map(mode => {
|
||||
const t = themes[id][mode];
|
||||
return <span key={mode} style={{ background: t.bg }}>
|
||||
<i className="theme-preview-title" style={{ background: t.ink }} />
|
||||
<span className="theme-preview-chips"><i style={{ background: t.brand }} /><i style={{ background: t.brandSoft }} /></span>
|
||||
</span>;
|
||||
})}</span>;
|
||||
}
|
||||
|
||||
function ThemeCard({ id, label, note, pressed, onClick }: { id: ThemeId; label: string; note: string; pressed: boolean; onClick(): void }) {
|
||||
return <button type="button" className="theme-card" aria-label={label} aria-pressed={pressed} onClick={onClick}>
|
||||
<ThemePreview id={id} />
|
||||
<span className="theme-card-label"><strong>{label}</strong>{pressed && <Check size={18} aria-hidden="true" />}</span>
|
||||
<small>{note}</small>
|
||||
</button>;
|
||||
}
|
||||
|
||||
function Chips<T extends string>({ label, options, value, onChange, labelPrefix = '' }: { label: string; options: readonly { value: T; label: string }[]; value: T; onChange(value: T): void; labelPrefix?: string }) {
|
||||
return <div className="theme-chips" role="group" aria-label={label}>{options.map(option =>
|
||||
<button type="button" key={option.value} aria-label={labelPrefix ? labelPrefix + ' ' + option.label : undefined} aria-pressed={value === option.value} onClick={() => onChange(option.value)}>{option.label}</button>)}</div>;
|
||||
}
|
||||
|
||||
const themeOptions = appThemes.map(theme => ({ value: theme.id as AppThemeId, label: theme.name }));
|
||||
const modeOptions: readonly { value: ThemeMode; label: string }[] = [{ value: 'light', label: '浅色' }, { value: 'dark', label: '深色' }];
|
||||
|
||||
/**
|
||||
* 外观与主题 (V3.1): the app theme (暖陶, 雾蓝, 青绿), its light/dark mode or the
|
||||
* follow-system pair, and the reading theme (跟随应用 or the reading-only 黑白灰).
|
||||
*/
|
||||
/**
|
||||
* The reading paper: the page's own colours, a per-device reader setting (the same
|
||||
* one as the reader's 外观 sheet), shown here so the whole look is set in one place.
|
||||
*/
|
||||
function ReadingPapers({ themeId, settings, onChange }: { themeId: ThemeId; settings: Pick<AppSettings, 'paper' | 'paperFollowsDark'>; onChange(patch: Partial<AppSettings>): void }) {
|
||||
const themed = themes[themeId].light;
|
||||
const options: Array<{ value: ReaderPaper; label: string; bg: string; ink: string }> = [
|
||||
{ value: 'theme', label: '跟随主题', bg: themed.bg, ink: themed.ink },
|
||||
...paperIds.map(id => ({ value: id, label: readerPapers[id].name, bg: readerPapers[id].bg, ink: readerPapers[id].ink })),
|
||||
];
|
||||
const lightPaper = settings.paper !== 'theme' && !readerPapers[settings.paper].dark;
|
||||
return <>
|
||||
<div className="theme-papers" role="group" aria-label="阅读纸色">{options.map(option =>
|
||||
<button type="button" key={option.value} aria-label={option.label} aria-pressed={settings.paper === option.value} onClick={() => onChange({ paper: option.value })}>
|
||||
<span aria-hidden="true" style={{ background: option.bg, color: option.ink }}>Aa</span>{option.label}
|
||||
</button>)}</div>
|
||||
{lightPaper && <div className="theme-settings-card theme-paper-follow">
|
||||
<button type="button" className="theme-follow" aria-label="跟随系统深色模式" aria-pressed={settings.paperFollowsDark} onClick={() => onChange({ paperFollowsDark: !settings.paperFollowsDark })}>
|
||||
<span className="theme-follow-text"><strong>跟随系统深色模式</strong><small>应用为深色时,纸色换成主题的深色</small></span>
|
||||
<span className="theme-switch" aria-hidden="true" />
|
||||
</button>
|
||||
</div>}
|
||||
</>;
|
||||
}
|
||||
|
||||
export function MediaThemeSettings({ scope, onReaderSettings }: { scope: string; onReaderSettings?: (patch: Partial<AppSettings>) => void }) {
|
||||
const { preference, systemDark } = useThemeState(scope);
|
||||
const paper = useSettingsStore(state => state.settings.paper);
|
||||
const paperFollowsDark = useSettingsStore(state => state.settings.paperFollowsDark);
|
||||
const [error, setError] = useState(''), [saved, setSaved] = useState(false);
|
||||
const following = preference.mode === 'system';
|
||||
const resolved = resolveAppTheme(preference, systemDark);
|
||||
function save(patch: Partial<ThemePreference>) {
|
||||
setError(''); setSaved(false);
|
||||
try { saveThemePreference(scope, patch); setSaved(true); } catch (cause) { setError(cause instanceof Error ? cause.message : '主题保存失败'); }
|
||||
}
|
||||
// Leaving follow-system keeps what is on screen now, so the switch never makes the page jump.
|
||||
const toggleFollow = () => save(following ? { mode: resolved.mode, theme: resolved.theme } : { mode: 'system' });
|
||||
const chooseTheme = (id: AppThemeId) => save(following ? { theme: id, system: { light: id, dark: id } } : { theme: id });
|
||||
const themePressed = (id: AppThemeId) => following ? preference.system.light === id && preference.system.dark === id : preference.theme === id;
|
||||
return <section className="theme-settings">
|
||||
<div className="theme-settings-card">
|
||||
<button type="button" className="theme-follow" aria-label="跟随系统" aria-pressed={following} onClick={toggleFollow}>
|
||||
<span className="theme-follow-icon" aria-hidden="true"><SunMoon size={20} /></span>
|
||||
<span className="theme-follow-text"><strong>跟随系统</strong><small>按系统深浅色自动切换</small></span>
|
||||
<span className="theme-switch" aria-hidden="true" />
|
||||
</button>
|
||||
{following ? <>
|
||||
<div className="theme-settings-row"><span>浅色时使用</span><Chips label="浅色时使用的主题" labelPrefix="浅色时使用" options={themeOptions} value={preference.system.light} onChange={light => save({ system: { ...preference.system, light } })} /></div>
|
||||
<div className="theme-settings-row"><span>深色时使用</span><Chips label="深色时使用的主题" labelPrefix="深色时使用" options={themeOptions} value={preference.system.dark} onChange={dark => save({ system: { ...preference.system, dark } })} /></div>
|
||||
</> : <div className="theme-settings-row"><span>明暗</span><Chips label="明暗" options={modeOptions} value={resolved.mode} onChange={mode => save({ mode })} /></div>}
|
||||
</div>
|
||||
|
||||
<h2>应用主题</h2>
|
||||
<div className="theme-grid" role="group" aria-label="应用主题">
|
||||
{appThemes.map(theme => <ThemeCard key={theme.id} id={theme.id} label={theme.name} note="浅色 · 深色" pressed={themePressed(theme.id)} onClick={() => chooseTheme(theme.id)} />)}
|
||||
</div>
|
||||
|
||||
<h2>阅读主题</h2>
|
||||
<div className="theme-grid" role="group" aria-label="阅读主题">
|
||||
<ThemeCard id={resolved.theme} label="跟随应用" note={'与应用一致 · ' + themes[resolved.theme].name} pressed={preference.reading === 'app'} onClick={() => save({ reading: 'app' })} />
|
||||
<ThemeCard id="gray" label={themes.gray.name} note="阅读专用 · 浅色 · 深色" pressed={preference.reading === 'gray'} onClick={() => save({ reading: 'gray' })} />
|
||||
</div>
|
||||
<p className="theme-settings-note">黑白灰只用于阅读器,可以与应用主题分开设置;浅色和夜间跟随应用的明暗。主题偏好按服务器和账号保存在此设备。</p>
|
||||
|
||||
{onReaderSettings && <>
|
||||
<h2>阅读纸色</h2>
|
||||
<ReadingPapers themeId={resolveReadingTheme(preference, systemDark).theme} settings={{ paper, paperFollowsDark }} onChange={patch => { onReaderSettings(patch); setSaved(true); setError(''); }} />
|
||||
<p className="theme-settings-note">纸色只改阅读页的底色和文字,工具栏仍用阅读主题;也可在阅读页的“外观”里切换。纸色按设备保存。</p>
|
||||
</>}
|
||||
{saved && <p role="status">主题已保存</p>}{error && <p className="media-error" role="alert">{error}</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,15 +1,55 @@
|
||||
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 '../services/player.ts';
|
||||
import {Sheet} from '../../../ui/sheet.tsx';
|
||||
import {Ellipsis,Square,Check,Subtitles,AudioLines,Layers,ListVideo,Maximize,Minimize,Lock,LockOpen,Gauge,Expand,Shrink,Upload} from 'lucide-react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
|
||||
import {subtitleSizes,type MediaPlayer,type SubtitleSize} 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';
|
||||
|
||||
type Tool='episodes'|'subtitles'|'tracks'|'volume'|'more'|'versions';
|
||||
const titles:Record<Tool,string>={episodes:'选集',subtitles:'字幕',tracks:'音轨',volume:'音量',more:'更多播放选项',versions:'播放版本'};
|
||||
type Tool='episodes'|'subtitles'|'tracks'|'volume'|'more'|'versions'|'speed';
|
||||
const titles:Record<Tool,string>={episodes:'选集',subtitles:'字幕与音轨',tracks:'字幕与音轨',volume:'音量',more:'更多播放选项',versions:'字幕与音轨',speed:'倍速'};
|
||||
/** 字幕, 音轨 and 版本 are tabs of one sheet (prototype 12); switching tabs keeps it open. */
|
||||
const trackTabs=[{tool:'subtitles',label:'字幕'},{tool:'tracks',label:'音轨'},{tool:'versions',label:'版本'}] as const;
|
||||
const isTrackTab=(tool:Tool)=>trackTabs.some(tab=>tab.tool===tool);
|
||||
const speeds=[0.5,0.75,1,1.25,1.5,2,3];
|
||||
const sizeLabels:Record<SubtitleSize,string>={small:'小',normal:'标准',large:'大'};
|
||||
const speedLabel=(rate:number)=>`${Number.isInteger(rate)?rate.toFixed(1):String(rate)}×`;
|
||||
|
||||
/** 字幕 tab: the list (关闭, 内嵌, 外挂), 添加外挂字幕, 字幕延迟 and 字号. Web video only. */
|
||||
function SubtitlePanel({player}:{player:MediaPlayer}){
|
||||
const controls=player.subtitleControls;
|
||||
useSyncExternalStore(notify=>{controls?.addEventListener('change',notify);return()=>controls?.removeEventListener('change',notify);},()=>controls?.version??0);
|
||||
const file=useRef<HTMLInputElement>(null);
|
||||
const [error,setError]=useState(''),[size,setSize]=useState<SubtitleSize>(player.subtitleSize??'normal');
|
||||
if(!controls||!player.isWebVideo)return <p className="media-video-empty-track">使用 Android 播放器时,请在播放器自己的字幕菜单中选择。</p>;
|
||||
const options=controls.options,value=controls.value;
|
||||
const add=async(picked:File|undefined)=>{if(!picked)return;setError('');try{await controls.addLocal(picked);}catch(cause){setError(cause instanceof Error?cause.message:'无法读取这个字幕文件。');}finally{if(file.current)file.current.value='';}};
|
||||
const nudge=(delta:number)=>controls.setDelay(controls.delay+delta);
|
||||
return <div className="media-subtitle-panel">
|
||||
<div className="media-subtitle-list" role="radiogroup" aria-label="字幕">{options.map(option=><button type="button" role="radio" key={option.value||'off'} aria-checked={value===option.value} onClick={()=>controls.pick(option.value)}>
|
||||
<span><strong>{option.label}</strong>{option.note&&<small>{option.note}</small>}</span>{value===option.value&&<Check size={18} aria-hidden="true"/>}
|
||||
</button>)}</div>
|
||||
{controls.message&&<p className="media-subtitle-status" role="status">{controls.message}</p>}
|
||||
<button type="button" className="media-subtitle-add" onClick={()=>file.current?.click()}><Upload size={18} aria-hidden="true"/>添加外挂字幕</button>
|
||||
<input ref={file} type="file" accept=".srt,.vtt,.ass,.ssa" hidden aria-label="选择字幕文件" onChange={event=>void add(event.currentTarget.files?.[0])}/>
|
||||
{error&&<p className="media-error" role="alert">{error}</p>}
|
||||
<div className="media-subtitle-row"><span>字幕延迟</span><div className="media-subtitle-steps" role="group" aria-label="字幕延迟">
|
||||
<button type="button" disabled={!controls.canDelay} onClick={()=>nudge(-0.5)}>−0.5s</button>
|
||||
<button type="button" disabled={!controls.canDelay||controls.delay===0} aria-label="恢复字幕时间" title="恢复字幕时间" onClick={()=>controls.setDelay(0)}>{controls.delay>0?'+':''}{controls.delay.toFixed(1)}s</button>
|
||||
<button type="button" disabled={!controls.canDelay} onClick={()=>nudge(0.5)}>+0.5s</button>
|
||||
</div></div>
|
||||
{!controls.canDelay&&value!==''&&<p className="media-subtitle-hint">浏览器内嵌字幕不支持调整延迟。</p>}
|
||||
<div className="media-subtitle-row"><span>字号</span><div className="media-subtitle-sizes" role="group" aria-label="字幕字号">{subtitleSizes.map(option=><button type="button" key={option} aria-pressed={size===option} onClick={()=>{player.setSubtitleSize(option);setSize(option);}}>{sizeLabels[option]}</button>)}</div></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SpeedOptions({player,onDone}:{player:MediaPlayer;onDone?:()=>void}){
|
||||
const [rate,setRate]=useState(player.playbackRate??1);
|
||||
return <div className="media-video-speeds" role="group" aria-label="播放速度">{speeds.map(value=><button type="button" key={value} aria-pressed={rate===value} onClick={()=>{player.speed(value);setRate(value);onDone?.();}}>{speedLabel(value)}</button>)}</div>;
|
||||
}
|
||||
|
||||
function TrackOptions({player,kind}:{player:MediaPlayer;kind:'subtitles'|'tracks'}){
|
||||
const host=useRef<HTMLDivElement>(null);
|
||||
@@ -23,12 +63,8 @@ function Volume({player}:{player:MediaPlayer}){
|
||||
return <div ref={host}/>;
|
||||
}
|
||||
|
||||
function ToolSheet({tool,onClose,children}:{tool:Tool;onClose:()=>void;children:ReactNode}){
|
||||
const ref=useRef<HTMLDialogElement>(null);
|
||||
useLayoutEffect(()=>{const dialog=ref.current;if(!dialog)return;dialog.showModal();return ()=>dialog.close();},[]);
|
||||
return <dialog ref={ref} className="media-video-sheet" aria-label={titles[tool]} onCancel={event=>{event.preventDefault();onClose();}} onKeyDown={event=>{if(event.key==='Escape')event.stopPropagation();}} onClick={event=>{if(event.target===event.currentTarget){const rect=event.currentTarget.getBoundingClientRect();if(event.clientX<rect.left||event.clientX>rect.right||event.clientY<rect.top||event.clientY>rect.bottom)onClose();}}}>
|
||||
<header><h2>{titles[tool]}</h2><button className="media-video-icon" aria-label="关闭播放菜单" onClick={onClose}><X size={20} aria-hidden="true"/></button></header>{children}
|
||||
</dialog>;
|
||||
function ToolSheet({tool,onClose,children,tabs}:{tool:Tool;onClose:()=>void;children:ReactNode;tabs?:ReactNode}){
|
||||
return <Sheet className="media-video-sheet" title={titles[tool]} onClose={onClose}>{tabs}{children}</Sheet>;
|
||||
}
|
||||
|
||||
/** Rendered in the permanent player's controls host, alongside the live video. */
|
||||
@@ -41,24 +77,56 @@ export function VideoControls({player,api,onBack}:{player:MediaPlayer;api:MediaA
|
||||
const [query,setQuery]=useState(''),[page,setPage]=useState(0);
|
||||
const filtered=entries.filter(entry=>entry.title.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())),last=Math.max(0,Math.ceil(filtered.length/50)-1),currentPage=Math.min(page,last);
|
||||
function open(value:Tool){setNotice('');setTool(value);setQuery('');setPage(0);}
|
||||
// ---- landscape / fullscreen (prototype 13) ----
|
||||
const [fullscreen,setFullscreen]=useState(false),[locked,setLocked]=useState(false),[fill,setFill]=useState(false);
|
||||
useEffect(()=>{
|
||||
const sync=()=>{const on=!!player.element&&document.fullscreenElement===player.element;setFullscreen(on);if(!on)setLocked(false);};
|
||||
document.addEventListener('fullscreenchange',sync);return()=>document.removeEventListener('fullscreenchange',sync);
|
||||
},[player]);
|
||||
useEffect(()=>{const element=player.element;if(!element)return;if(locked)element.dataset.locked='true';else delete element.dataset.locked;return()=>{delete element.dataset.locked;};},[player,locked]);
|
||||
useEffect(()=>{const element=player.element;if(!element)return;if(fill)element.dataset.fit='cover';else delete element.dataset.fit;return()=>{delete element.dataset.fit;};},[player,fill]);
|
||||
const orientation=()=>(typeof screen!=='undefined'?screen.orientation:undefined) as (ScreenOrientation&{lock?:(value:string)=>Promise<void>})|undefined;
|
||||
async function toggleFullscreen(){
|
||||
try{
|
||||
const entering=document.fullscreenElement!==player.element;
|
||||
await player.fullscreenVideo();
|
||||
// Phones turn sideways for a film; desktop browsers refuse, which is fine.
|
||||
if(entering)await orientation()?.lock?.('landscape').catch(()=>undefined);else orientation()?.unlock?.();
|
||||
}catch(error){setNotice(error instanceof Error?error.message:'无法进入全屏。');}
|
||||
}
|
||||
async function run(action:()=>Promise<void>,close=false){if(busy)return;setBusy(true);setNotice('');try{await action();if(close&&!player.error)setTool(null);}catch(error){setNotice(error instanceof Error?error.message:'操作失败,请重试。');}finally{setBusy(false);}}
|
||||
return <section className="media-playback-status media-video-controls" aria-label="播放控制">
|
||||
<div className="media-video-caption"><div><strong>{item?.title||player.title}</strong>{edition&&<small>{edition.label}</small>}</div></div>
|
||||
<div className="media-video-toolbar media-video-tools">{entries.length>1&&<button onClick={()=>open('episodes')}><Layers size={20} aria-hidden="true"/>选集</button>}<button onClick={()=>open('subtitles')}><Subtitles size={20} aria-hidden="true"/>字幕</button><button onClick={()=>open('tracks')}><AudioLines size={20} aria-hidden="true"/>音轨</button><button onClick={()=>open('versions')}><Layers size={20} aria-hidden="true"/>版本</button><button aria-label="更多播放选项" onClick={()=>open('more')}><Ellipsis size={20} aria-hidden="true"/>更多</button></div>
|
||||
<div className="media-video-toolbar media-video-tools">{entries.length>1&&<button onClick={()=>open('episodes')}><ListVideo size={20} aria-hidden="true"/>选集</button>}<button onClick={()=>open('subtitles')}><Subtitles size={20} aria-hidden="true"/>字幕</button><button onClick={()=>open('tracks')}><AudioLines size={20} aria-hidden="true"/>音轨</button><button onClick={()=>open('versions')}><Layers size={20} aria-hidden="true"/>版本</button>{player.isWebVideo&&<button aria-label="横屏全屏播放" onClick={()=>void toggleFullscreen()}><Maximize size={20} aria-hidden="true"/>全屏</button>}<button aria-label="更多播放选项" onClick={()=>open('more')}><Ellipsis size={20} aria-hidden="true"/>更多</button></div>
|
||||
{fullscreen&&<div className="media-video-landscape" data-locked={locked?'true':undefined}>
|
||||
{locked?<button type="button" className="media-video-unlock" aria-label="解除锁定" onClick={()=>setLocked(false)}><LockOpen size={22} aria-hidden="true"/></button>:<>
|
||||
<div className="media-video-landscape-title"><button type="button" className="media-video-icon" aria-label="退出全屏" onClick={()=>void toggleFullscreen()}><Minimize size={20} aria-hidden="true"/></button><strong>{item?.title||player.title}</strong></div>
|
||||
<div className="media-video-landscape-tools" role="toolbar" aria-label="横屏播放工具">
|
||||
{entries.length>1&&<button type="button" onClick={()=>open('episodes')}><ListVideo size={18} aria-hidden="true"/>选集</button>}
|
||||
<button type="button" onClick={()=>open('speed')}><Gauge size={18} aria-hidden="true"/>倍速 {speedLabel(player.playbackRate??1)}</button>
|
||||
<button type="button" aria-pressed={fill} onClick={()=>setFill(value=>!value)}>{fill?<Shrink size={18} aria-hidden="true"/>:<Expand size={18} aria-hidden="true"/>}{fill?'完整画面':'适应屏幕'}</button>
|
||||
<button type="button" onClick={()=>open('subtitles')}><Subtitles size={18} aria-hidden="true"/>字幕</button>
|
||||
<button type="button" onClick={()=>{setTool(null);setLocked(true);}}><Lock size={18} aria-hidden="true"/>锁定</button>
|
||||
</div>
|
||||
</>}
|
||||
</div>}
|
||||
{player.error&&<PlaybackProblem player={player} {...(item?.editions.length?{onVersions:()=>open('versions')}:{})} {...(onBack?{onBack}:{})}/>}
|
||||
{notice&&<p className="media-error" role="alert">{notice}</p>}
|
||||
{plot&&<details className="media-video-description" open><summary>剧情简介</summary><p>{plot}</p></details>}
|
||||
{item?.kind==='episode'&&item.parentId&&<VideoEpisodeRail key={item.parentId} api={api} item={item} player={player}/>}
|
||||
{!item&&itemId&&!failed&&<MediaLoading layout="tracks" count={2} label="正在读取作品资料…"/>}
|
||||
{failed&&<MediaScreenError error={cause} message="作品资料读取失败,播放控制仍可使用。" busy={false} onRetry={()=>setRetry(value=>value+1)} retryLabel="重试作品资料"/>}
|
||||
{tool&&<ToolSheet key={tool} tool={tool} onClose={()=>{if(!busy)setTool(null);}}>
|
||||
{tool==='volume'?<Volume player={player}/>:tool==='subtitles'||tool==='tracks'?<TrackOptions player={player} kind={tool}/>:tool==='episodes'?<>
|
||||
{tool&&<ToolSheet key={isTrackTab(tool)?'tracks':tool} tool={tool} onClose={()=>{if(!busy)setTool(null);}}
|
||||
tabs={isTrackTab(tool)?<div className="media-video-tabs" role="tablist" aria-label="字幕与音轨">{trackTabs.map(tab=><button type="button" role="tab" key={tab.tool} aria-selected={tool===tab.tool} onClick={()=>setTool(tab.tool)}>{tab.label}</button>)}</div>:undefined}>
|
||||
{tool==='volume'?<Volume player={player}/>:tool==='subtitles'?<SubtitlePanel player={player}/>:tool==='tracks'?<TrackOptions player={player} kind={tool}/>:tool==='speed'?<SpeedOptions player={player} onDone={()=>setTool(null)}/>:tool==='episodes'?<>
|
||||
<p>本次播放列表 · {entries.length} 项</p>
|
||||
{entries.length>10&&<label className="media-video-search">查找集数<input type="search" value={query} onInput={event=>{setQuery(event.currentTarget.value);setPage(0);}}/></label>}
|
||||
<div className="media-video-episodes">{filtered.slice(currentPage*50,currentPage*50+50).map(entry=><button key={entry.index} aria-current={entry.current?'true':undefined} disabled={busy||entry.current||!player.canSelectPlaylist} onClick={()=>void run(()=>player.selectPlaylist(entry.index),true)}><span>{entry.index+1}</span><span>{entry.title}</span>{entry.current&&<Check size={17} aria-label="正在播放"/>}</button>)}</div>
|
||||
{!filtered.length&&<p>没有匹配的集数。</p>}{last>0&&<nav aria-label="选集分页"><button disabled={!currentPage} onClick={()=>setPage(currentPage-1)}>上一页</button><span>{currentPage+1} / {last+1}</span><button disabled={currentPage===last} onClick={()=>setPage(currentPage+1)}>下一页</button></nav>}
|
||||
</>:tool==='versions'?<div className="media-video-episodes">{item?.editions.map(value=><button disabled={busy||value.id===edition?.id&&!player.error||!value.parts.length||value.parts.some(part=>!part.available)} aria-current={value.id===edition?.id?'true':undefined} onClick={()=>void run(()=>player.play(value.parts.map(part=>({part,title:item.title+' · '+part.title,video:true}))),true)}><span>{value.label}</span><small>{value.parts.some(part=>!part.available)?'资源缺失':value.id===edition?.id?'当前版本':`${value.parts.length} 个片段`}</small></button>)}</div>:<>
|
||||
{entries.length>1&&item?.kind!=='episode'&&<button onClick={()=>open('episodes')}>本次播放列表 · {entries.length} 项</button>}<div className="media-video-seek"><button disabled={player.playbackUnavailable} onClick={()=>player.skip(-15)}>快退 15 秒</button><button disabled={player.playbackUnavailable} onClick={()=>player.skip(30)}>快进 30 秒</button></div>
|
||||
{entries.length>1&&item?.kind!=='episode'&&<button onClick={()=>open('episodes')}>本次播放列表 · {entries.length} 项</button>}
|
||||
<p className="media-video-more-label">倍速 · {speedLabel(player.playbackRate??1)}</p><SpeedOptions player={player}/>
|
||||
<div className="media-video-seek"><button disabled={player.playbackUnavailable} onClick={()=>player.skip(-15)}>快退 15 秒</button><button disabled={player.playbackUnavailable} onClick={()=>player.skip(30)}>快进 30 秒</button></div>
|
||||
<SleepTimerOptions player={player}/>
|
||||
{player.canOpenNativeVideo&&<button onClick={()=>void player.openNativeVideo()}>打开 Android 视频播放器</button>}
|
||||
{player.support&&<p>{player.support}</p>}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from '@mantine/core';
|
||||
import { FolderOpen, History, LibraryBig, ListVideo, MoreHorizontal, Search, Settings2, SlidersHorizontal, Star } from 'lucide-react';
|
||||
import { FolderOpen, Search, SlidersHorizontal } from 'lucide-react';
|
||||
import { ChannelMenu } from '../components/channel-menu.tsx';
|
||||
import { Link, NavLink, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -43,17 +43,8 @@ export function MediaCatalogPage() {
|
||||
}
|
||||
}, [channelLibraries, params, selectedFromUrl, setParams]);
|
||||
return <MediaPageFrame className="media-catalog-page" title={channelLabels[channel]} actions={<>
|
||||
<Button component={Link} to="/media/search" className="media-icon-button" aria-label="搜索" title="搜索"><Search size={19} aria-hidden="true" /></Button>
|
||||
<details className="media-actions media-catalog-actions">
|
||||
<summary aria-label="更多操作" title="更多操作"><MoreHorizontal size={20} aria-hidden="true" /></summary>
|
||||
<nav aria-label="更多操作">
|
||||
<Link to={`/media/${channel}/settings`}><Settings2 size={16} aria-hidden="true" />影音设置</Link>
|
||||
<Link to="/media/favorites"><Star size={16} aria-hidden="true" />收藏</Link>
|
||||
<Link to={`/media/${channel}/history`}><History size={16} aria-hidden="true" />历史</Link>
|
||||
<Link to={`/media/${channel}/queue`}><ListVideo size={16} aria-hidden="true" />队列</Link>
|
||||
<Link to={`/media/${channel}/settings/libraries`}><LibraryBig size={16} aria-hidden="true" />媒体库管理</Link>
|
||||
</nav>
|
||||
</details>
|
||||
<Link to="/media/search" className="media-icon-button" aria-label="搜索" title="搜索"><Search size={19} aria-hidden="true" /></Link>
|
||||
<ChannelMenu channel={channel} />
|
||||
</>}>
|
||||
{!emptyWithoutLibraries && <>
|
||||
<nav className="media-tabs" aria-label={`${channelLabels[channel]}分类`}>{categories.map(item => {
|
||||
@@ -78,7 +69,7 @@ export function MediaCatalogPage() {
|
||||
{query.data && <span className="media-catalog-count">{query.data.total} {countLabels[current.kind] ?? '项'}</span>}
|
||||
</div>
|
||||
<div className="media-browse-actions">
|
||||
<Button component={Link} to={`/media/${channel}/folders${selectedLibrary ? `/${encodeURIComponent(selectedLibrary)}` : ''}`} className="media-icon-button" aria-label="打开文件夹" title="打开文件夹"><FolderOpen size={19} aria-hidden="true" /></Button>
|
||||
<Link to={`/media/${channel}/folders${selectedLibrary ? `/${encodeURIComponent(selectedLibrary)}` : ''}`} className="media-icon-button" aria-label="打开文件夹" title="打开文件夹"><FolderOpen size={19} aria-hidden="true" /></Link>
|
||||
<details className="media-browse-filters">
|
||||
<summary aria-label="筛选与排序" title="筛选与排序"><SlidersHorizontal size={19} aria-hidden="true" />{params.get('sort') && params.get('sort') !== 'default' && <i aria-label="已应用筛选" />}</summary>
|
||||
<div className="media-browse-filter-panel">
|
||||
@@ -90,9 +81,9 @@ export function MediaCatalogPage() {
|
||||
</div>
|
||||
</div>}
|
||||
{!emptyCatalog && query.data && <><MediaItemGrid items={query.data.items} channel={channel} />{query.data.total > 60 && <nav className="media-toolbar media-pagination" aria-label="分页">
|
||||
<Button disabled={offset === 0 || query.isFetching} onClick={() => patch({ offset: offset >= 60 ? String(offset - 60) : '' })}>上一页</Button>
|
||||
<button type="button" disabled={offset === 0 || query.isFetching} onClick={() => patch({ offset: offset >= 60 ? String(offset - 60) : '' })}>上一页</button>
|
||||
<span>{Math.floor(offset / 60) + 1} / {Math.ceil(query.data.total / 60)}</span>
|
||||
<Button disabled={offset + 60 >= query.data.total || query.isFetching} onClick={() => patch({ offset: String(offset + 60) })}>下一页</Button>
|
||||
<button type="button" disabled={offset + 60 >= query.data.total || query.isFetching} onClick={() => patch({ offset: String(offset + 60) })}>下一页</button>
|
||||
</nav>}</>}
|
||||
</MediaPageFrame>;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Alert, Button } from '@mantine/core';
|
||||
import { Ellipsis, Heart, Play } from 'lucide-react';
|
||||
import { Heart, Play } from 'lucide-react';
|
||||
import { ActionMenu } from '../../../ui/action-menu.tsx';
|
||||
import { Callout } from '../../../ui/toolkit.tsx';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { detailQuery } from '../queries/media.queries.ts';
|
||||
import { MediaPageFrame, QueryFeedback } from '../components/MediaPageFrame.tsx';
|
||||
@@ -32,8 +33,7 @@ export function MediaDetailPage() {
|
||||
const { itemId = '' } = useParams();
|
||||
const admin = useAuthStore(state => state.verifiedUser?.role === 'admin');
|
||||
const [resourcePanel, setResourcePanel] = useState<'files' | 'versions' | 'source' | null>(null);
|
||||
const detailMenu = useRef<HTMLDetailsElement>(null);
|
||||
useEffect(() => { setResourcePanel(null); detailMenu.current?.removeAttribute('open'); }, [itemId]);
|
||||
useEffect(() => { setResourcePanel(null); }, [itemId]);
|
||||
const query = useQuery(detailQuery(runtime, itemId));
|
||||
const favorite = useQuery({
|
||||
queryKey: ['media', runtime.mediaApi.preferenceScope(), 'favorite', itemId],
|
||||
@@ -49,31 +49,22 @@ 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 }); };
|
||||
|
||||
const openResourcePanel = (panel: 'files' | 'versions' | 'source') => {
|
||||
detailMenu.current?.removeAttribute('open');
|
||||
setResourcePanel(panel);
|
||||
};
|
||||
const detailActions = detail ? <details className="media-actions media-item-actions" ref={detailMenu}>
|
||||
<summary aria-label={detail.kind === 'artist' ? '歌手操作' : '作品操作'} title={detail.kind === 'artist' ? '歌手操作' : '作品操作'}><Ellipsis size={20} aria-hidden="true" /></summary>
|
||||
<nav aria-label={detail.kind === 'artist' ? '歌手操作' : '作品操作'}>
|
||||
<button type="button" disabled={toggleFavorite.isPending} onClick={() => { detailMenu.current?.removeAttribute('open'); toggleFavorite.mutate(!favorite.data?.favorite); }}>{favorite.data?.favorite ? (detail.kind === 'artist' ? '取消收藏歌手' : '取消收藏作品') : (detail.kind === 'artist' ? '收藏歌手' : '收藏作品')}</button>
|
||||
{detail.kind !== 'artist' && <>
|
||||
{detail.editions.some(edition => edition.parts.length > 0) && <button type="button" onClick={() => openResourcePanel('files')}>资源信息</button>}
|
||||
{(detail.editions.length > 1 || (admin && detail.editions.length > 0)) && <button type="button" onClick={() => openResourcePanel('versions')}>{admin ? '版本管理' : '播放版本'}</button>}
|
||||
<button type="button" onClick={() => openResourcePanel('source')}>资料来源</button>
|
||||
</>}
|
||||
{admin && <>
|
||||
<button type="button" onClick={() => navigate(`/media/${channel}/items/${encodeURIComponent(detail.id)}/metadata`)}>{detail.kind === 'artist' ? '编辑歌手资料' : '编辑资料'}</button>
|
||||
<button type="button" onClick={() => navigate(`/media/${channel}/items/${encodeURIComponent(detail.id)}/match`)}>{detail.kind === 'artist' ? '匹配歌手资料' : '匹配元数据'}</button>
|
||||
{detail.kind === 'audiobook' && <button type="button" onClick={() => navigate(`/media/${channel}/items/${encodeURIComponent(detail.id)}/chapters`)}>章节列表</button>}
|
||||
{['track', 'album', 'season', 'episode'].includes(detail.kind) && <button type="button" onClick={() => navigate(`/media/${channel}/items/${encodeURIComponent(detail.id)}/structure`)}>结构整理</button>}
|
||||
</>}
|
||||
</nav>
|
||||
</details> : undefined;
|
||||
const artist = detail?.kind === 'artist';
|
||||
const itemPath = (suffix: string) => `/media/${channel}/items/${encodeURIComponent(detail!.id)}/${suffix}`;
|
||||
const detailActions = detail ? <ActionMenu className="media-icon-button" label={artist ? '歌手操作' : '作品操作'} items={[
|
||||
{ label: favorite.data?.favorite ? (artist ? '取消收藏歌手' : '取消收藏作品') : (artist ? '收藏歌手' : '收藏作品'), disabled: toggleFavorite.isPending, onSelect: () => toggleFavorite.mutate(!favorite.data?.favorite) },
|
||||
!artist && detail.editions.some(edition => edition.parts.length > 0) && { label: '资源信息', onSelect: () => setResourcePanel('files') },
|
||||
!artist && (detail.editions.length > 1 || (admin && detail.editions.length > 0)) && { label: admin ? '版本管理' : '播放版本', onSelect: () => setResourcePanel('versions') },
|
||||
!artist && { label: '资料来源', onSelect: () => setResourcePanel('source') },
|
||||
admin && { label: artist ? '编辑歌手资料' : '编辑资料', onSelect: () => navigate(itemPath('metadata')) },
|
||||
admin && { label: artist ? '匹配歌手资料' : '匹配元数据', onSelect: () => navigate(itemPath('match')) },
|
||||
admin && detail.kind === 'audiobook' && { label: '章节列表', onSelect: () => navigate(itemPath('chapters')) },
|
||||
admin && ['track', 'album', 'season', 'episode'].includes(detail.kind) && { label: '结构整理', onSelect: () => navigate(itemPath('structure')) },
|
||||
]} /> : undefined;
|
||||
return <MediaPageFrame className="media-detail-shell" title={detail ? mediaDetailLabel(detail.kind) : '作品详情'} actions={detailActions}>
|
||||
<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} onRefresh={() => { void query.refetch(); }} onUpdated={updated => { queryClient.setQueryData(detailQuery(runtime, itemId).queryKey, updated); }} runtime={runtime} navigate={navigate} resourcePanel={resourcePanel} onResourcePanelChange={setResourcePanel} />}
|
||||
{!query.isPending && !query.error && !detail && <Alert>找不到这个作品。</Alert>}
|
||||
{!query.isPending && !query.error && !detail && <Callout>找不到这个作品。</Callout>}
|
||||
</MediaPageFrame>;
|
||||
}
|
||||
|
||||
@@ -120,7 +111,7 @@ function DetailContent({ detail, channel, favorite, favoriteBusy, onToggleFavori
|
||||
if (edition?.parts.length && !playable.length) return <>{resourceControl}<MissingEdition title={detail.title} label={edition.label} busy={false} onRefresh={onRefresh} onChooseVersion={detail.editions.length > 1 ? () => onResourcePanelChange('versions') : undefined} /></>;
|
||||
return <>{resourceControl}<article className={detailClass}>
|
||||
<div className="media-hero"><MediaCover api={runtime.mediaApi} item={detail} square={['album', 'artist', 'track'].includes(detail.kind)} /><div className={detail.kind === 'artist' ? 'media-person-copy' : 'media-detail-hero-copy'}><MediaDetailHeading item={detail} edition={edition} {...(detail.kind === 'album' ? { trackCount: detail.children.length } : {})} {...(detail.kind === 'series' ? { seasonCount: detail.children.length } : {})} />{detail.kind === 'artist' && <span className="media-person-count">{detail.children.filter(item => item.kind === 'album').length} 张专辑</span>}</div></div>
|
||||
<div className="media-detail-actions">{detail.kind === 'series' && <Button className="media-primary" leftSection={<Play size={16} />} disabled={!detail.children.length} onClick={() => void playSeries()}>播放剧集</Button>}{edition && <Button className="media-primary" leftSection={<Play size={16} />} disabled={!playable.length} onClick={() => onPlay(playable)}>{detail.kind === 'movie' ? '播放电影' : detail.kind === 'track' ? '播放音频' : detail.kind === 'audiobook' ? '开始收听' : '播放此版本'}</Button>}{detail.kind !== 'album' && <Button className="media-detail-favorite" variant="subtle" loading={favoriteBusy} aria-label={favorite ? '取消收藏' : '收藏'} title={favorite ? '取消收藏' : '收藏'} onClick={onToggleFavorite}><Heart size={18} fill={favorite ? 'currentColor' : 'none'} aria-hidden="true" /></Button>}</div>
|
||||
<div className="media-detail-actions">{detail.kind === 'series' && <button type="button" className="media-primary" disabled={!detail.children.length} onClick={() => void playSeries()}><Play size={16} aria-hidden="true" />播放剧集</button>}{edition && <button type="button" className="media-primary" disabled={!playable.length} onClick={() => onPlay(playable)}><Play size={16} aria-hidden="true" />{detail.kind === 'movie' ? '播放电影' : detail.kind === 'track' ? '播放音频' : detail.kind === 'audiobook' ? '开始收听' : '播放此版本'}</button>}{detail.kind !== 'album' && <button type="button" className="media-detail-favorite" aria-label={favorite ? '取消收藏' : '收藏'} title={favorite ? '取消收藏' : '收藏'} onClick={onToggleFavorite} disabled={favoriteBusy}><Heart size={18} fill={favorite ? 'currentColor' : 'none'} aria-hidden="true" /></button>}</div>
|
||||
{artistPlot && <p className="media-person-bio">{artistPlot}</p>}
|
||||
<div className={'media-detail-columns' + (detail.kind === 'album' ? ' media-album-detail' : '')}><div className="media-detail-main">
|
||||
{!['artist', 'album'].includes(detail.kind) && plot && <section className="media-detail-description"><h2>{['movie', 'series', 'season', 'episode'].includes(detail.kind) ? '剧情简介' : '内容简介'}</h2><p>{plot}</p></section>}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert } from '@mantine/core';
|
||||
import { Callout } from '../../../ui/toolkit.tsx';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -27,6 +27,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 className="media-secondary-page" title="文件夹浏览" backLabel="返回分类"><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{!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)}`)} actionsHost={actionsHost} />}</MediaPageFrame>;
|
||||
return <MediaPageFrame className="media-secondary-page" title="文件夹浏览" backLabel="返回分类"><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{!query.isPending && !query.error && requestedLibraryId && !libraryId && <Callout tone="error">该媒体库不存在,或不属于当前频道。</Callout>}{!query.isPending && !query.error && !requestedLibraryId && !libraryId && <Callout>当前频道没有可浏览的媒体库。</Callout>}{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)}`)} actionsHost={actionsHost} />}</MediaPageFrame>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Button } from '@mantine/core';
|
||||
import { Clock3, Play } from 'lucide-react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||
@@ -25,9 +24,9 @@ export function MediaHistoryPage() {
|
||||
</div>)}</div>
|
||||
{query.data && query.data.items.length === 0 && <div className="media-personal-empty"><Clock3 size={32} strokeWidth={1.4} aria-hidden="true" /><p>暂无记录。</p></div>}
|
||||
{query.data && query.data.total > 60 && <nav className="media-toolbar media-pagination" aria-label="分页">
|
||||
<Button disabled={Number(params.get('offset') ?? 0) <= 0 || query.isFetching} onClick={() => setParams({ offset: Math.max(0, Number(params.get('offset') ?? 0) - 60) ? String(Math.max(0, Number(params.get('offset') ?? 0) - 60)) : '' })}>上一页</Button>
|
||||
<button type="button" disabled={Number(params.get('offset') ?? 0) <= 0 || query.isFetching} onClick={() => setParams({ offset: Math.max(0, Number(params.get('offset') ?? 0) - 60) ? String(Math.max(0, Number(params.get('offset') ?? 0) - 60)) : '' })}>上一页</button>
|
||||
<span>{Math.floor(Number(params.get('offset') ?? 0) / 60) + 1} / {Math.ceil(query.data.total / 60)}</span>
|
||||
<Button disabled={Number(params.get('offset') ?? 0) + 60 >= query.data.total || query.isFetching} onClick={() => setParams({ offset: String(Number(params.get('offset') ?? 0) + 60) })}>下一页</Button>
|
||||
<button type="button" disabled={Number(params.get('offset') ?? 0) + 60 >= query.data.total || query.isFetching} onClick={() => setParams({ offset: String(Number(params.get('offset') ?? 0) + 60) })}>下一页</button>
|
||||
</nav>}
|
||||
</section>
|
||||
</MediaPageFrame>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Button, Card, Stack, Text, Title } from '@mantine/core';
|
||||
import { Callout } from '../../../ui/toolkit.tsx';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
@@ -34,19 +34,19 @@ function useItemPage() {
|
||||
function ItemPageFrame({ title, channel, itemId, children, admin = false, className }: {
|
||||
title: string; channel: MediaChannel; itemId: string; children: React.ReactNode; admin?: boolean; className?: string;
|
||||
}) {
|
||||
return <MediaPageFrame {...(className ? { className } : {})} title={title} hideHeader={className === 'media-metadata-workspace'} actions={className === 'media-metadata-workspace' ? undefined : <Button component={Link} to={`/media/${channel}/items/${encodeURIComponent(itemId)}`} variant="subtle">返回作品详情</Button>}>
|
||||
{admin && className !== 'media-metadata-workspace' && <Text size="sm" c="dimmed">管理员操作会保留文件、版本和播放进度;保存后可返回作品详情继续核对。</Text>}
|
||||
return <MediaPageFrame {...(className ? { className } : {})} title={title} hideHeader={className === 'media-metadata-workspace'} actions={className === 'media-metadata-workspace' ? undefined : <Link className="media-text-link" to={`/media/${channel}/items/${encodeURIComponent(itemId)}`}>返回作品详情</Link>}>
|
||||
{admin && className !== 'media-metadata-workspace' && <p className="media-manager-note">管理员操作会保留文件、版本和播放进度;保存后可返回作品详情继续核对</p>}
|
||||
{children}
|
||||
</MediaPageFrame>;
|
||||
}
|
||||
|
||||
function ItemQuery({ query, children }: { query: ReturnType<typeof useQuery<Detail>>; children: (detail: Detail) => React.ReactNode }) {
|
||||
return <><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{query.data && children(query.data)}{!query.isPending && !query.error && !query.data && <Alert color="red">找不到这个作品。</Alert>}</>;
|
||||
return <><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{query.data && children(query.data)}{!query.isPending && !query.error && !query.data && <Callout tone="error">找不到这个作品。</Callout>}</>;
|
||||
}
|
||||
|
||||
function AdminOnly({ children }: { children: React.ReactNode }) {
|
||||
const admin = useAuthStore(state => state.verifiedUser?.role === 'admin');
|
||||
return admin ? <>{children}</> : <Alert color="red">需要管理员权限才能编辑媒体资料。</Alert>;
|
||||
return admin ? <>{children}</> : <Callout tone="error">需要管理员权限才能编辑媒体资料。</Callout>;
|
||||
}
|
||||
|
||||
function playParts(runtime: ReturnType<typeof useRuntime>, channel: MediaChannel, title: string, parts: Part[], index = 0) {
|
||||
@@ -59,7 +59,7 @@ export function MediaMetadataEditPage() {
|
||||
const edition = detail.editions[0];
|
||||
return <div className="media-metadata-host">
|
||||
<MediaMetadataPage api={runtime.mediaApi} item={detail} initialView="edit" onViewChange={view => navigate(`/media/${channel}/items/${encodeURIComponent(itemId)}/${view === 'edit' ? 'metadata' : 'match'}`)} onUpdated={updateDetail} onBack={() => navigate(`/media/${channel}/items/${encodeURIComponent(itemId)}`)} />
|
||||
{edition && <Card withBorder><Title order={3}>版本与资源管理</Title><EditionTools api={runtime.mediaApi} item={detail} edition={edition} busy={false} onPlay={(parts, index) => { void playParts(runtime, channel, detail.title, parts, index); }} onQueue={ids => { void runtime.mediaApi.request('queue', 'POST', { partIds: ids }); }} onUpdated={updateDetail} onAssigned={target => navigate(`/media/${channel}/items/${encodeURIComponent(target.id)}`)} onRename={() => { void query.refetch(); }} /></Card>}
|
||||
{edition && <section className="media-manager-card"><h3>版本与资源管理</h3><EditionTools api={runtime.mediaApi} item={detail} edition={edition} busy={false} onPlay={(parts, index) => { void playParts(runtime, channel, detail.title, parts, index); }} onQueue={ids => { void runtime.mediaApi.request('queue', 'POST', { partIds: ids }); }} onUpdated={updateDetail} onAssigned={target => navigate(`/media/${channel}/items/${encodeURIComponent(target.id)}`)} onRename={() => { void query.refetch(); }} /></section>}
|
||||
<ArtistInfo item={detail} />
|
||||
<VideoHierarchyEditor api={runtime.mediaApi} item={detail} onUpdated={updateDetail} />
|
||||
<MusicParentEditor api={runtime.mediaApi} item={detail} onUpdated={updateDetail} />
|
||||
@@ -77,7 +77,7 @@ export function MediaChaptersPage() {
|
||||
const [editionId, setEditionId] = useState('');
|
||||
const [position, setPosition] = useState<ChapterPosition>({ query: '', page: 0 });
|
||||
return <ItemPageFrame title="章节列表" channel={channel} itemId={itemId}><ItemQuery query={query}>{detail => {
|
||||
if (detail.kind !== 'audiobook') return <Alert color="yellow">只有有声书作品支持章节管理。</Alert>;
|
||||
if (detail.kind !== 'audiobook') return <Callout tone="warning">只有有声书作品支持章节管理。</Callout>;
|
||||
const selected = editionId || detail.editions[0]?.id || '';
|
||||
return <AudiobookChapters api={runtime.mediaApi} item={detail} editionId={selected} currentPartId={runtime.player.currentPartId} busy={false} onEditionChange={id => { setEditionId(id); setPosition({ query: '', page: 0 }); }} onPlay={(parts, index) => { void playParts(runtime, channel, detail.title, parts, index); }} onQueue={async ids => { await runtime.mediaApi.request('queue', 'POST', { partIds: ids }); }} onRefresh={() => { void query.refetch(); }} position={position} onPositionChange={setPosition} />;
|
||||
}}</ItemQuery></ItemPageFrame>;
|
||||
@@ -87,14 +87,14 @@ export function MediaEditionPage() {
|
||||
const { runtime, navigate, channel, itemId, editionId, query, updateDetail } = useItemPage();
|
||||
return <ItemPageFrame title="版本管理" channel={channel} itemId={itemId} admin><AdminOnly><ItemQuery query={query}>{detail => {
|
||||
const edition = detail.editions.find(value => value.id === editionId) ?? detail.editions[0];
|
||||
if (!edition) return <Alert color="yellow">这个作品没有可管理的版本。</Alert>;
|
||||
if (!edition) return <Callout tone="warning">这个作品没有可管理的版本。</Callout>;
|
||||
return <EditionDetails api={runtime.mediaApi} item={detail} edition={edition} busy={false} layout="detail" showTools onPlay={(parts, index) => { void playParts(runtime, channel, detail.title, parts, index); }} onQueue={ids => { void runtime.mediaApi.request('queue', 'POST', { partIds: ids }); }} onUpdated={updateDetail} onAssigned={target => navigate(`/media/${channel}/items/${encodeURIComponent(target.id)}`)} onRename={() => { void query.refetch(); }} onRefresh={() => { void query.refetch(); }} />;
|
||||
}}</ItemQuery></AdminOnly></ItemPageFrame>;
|
||||
}
|
||||
|
||||
export function MediaStructurePage() {
|
||||
const { runtime, navigate, channel, itemId, query, updateDetail } = useItemPage();
|
||||
return <ItemPageFrame title="结构整理" channel={channel} itemId={itemId} admin><AdminOnly><ItemQuery query={query}>{detail => <Stack><Card withBorder><Title order={3}>作品归属与结构</Title><Text size="sm" c="dimmed">调整所属专辑、歌手、剧集、季和集号。文件、版本与播放进度会保留。</Text><VideoHierarchyEditor api={runtime.mediaApi} item={detail} onUpdated={updateDetail} /><MusicParentEditor api={runtime.mediaApi} item={detail} onUpdated={updateDetail} /></Card>{detail.editions.map(edition => <Card withBorder key={edition.id}><Title order={4}>{edition.label}</Title><EditionTools api={runtime.mediaApi} item={detail} edition={edition} busy={false} onPlay={(parts, index) => { void playParts(runtime, channel, detail.title, parts, index); }} onQueue={ids => { void runtime.mediaApi.request('queue', 'POST', { partIds: ids }); }} onUpdated={updateDetail} onAssigned={target => navigate(`/media/${channel}/items/${encodeURIComponent(target.id)}`)} /></Card>)}</Stack>}</ItemQuery></AdminOnly></ItemPageFrame>;
|
||||
return <ItemPageFrame title="结构整理" channel={channel} itemId={itemId} admin><AdminOnly><ItemQuery query={query}>{detail => <div className="media-task-panel"><section className="media-manager-card"><h3>作品归属与结构</h3><p className="media-manager-note">调整所属专辑、歌手、剧集、季和集号。文件、版本与播放进度会保留</p><VideoHierarchyEditor api={runtime.mediaApi} item={detail} onUpdated={updateDetail} /><MusicParentEditor api={runtime.mediaApi} item={detail} onUpdated={updateDetail} /></section>{detail.editions.map(edition => <section className="media-manager-card" key={edition.id}><h4>{edition.label}</h4><EditionTools api={runtime.mediaApi} item={detail} edition={edition} busy={false} onPlay={(parts, index) => { void playParts(runtime, channel, detail.title, parts, index); }} onQueue={ids => { void runtime.mediaApi.request('queue', 'POST', { partIds: ids }); }} onUpdated={updateDetail} onAssigned={target => navigate(`/media/${channel}/items/${encodeURIComponent(target.id)}`)} /></section>)}</div>}</ItemQuery></AdminOnly></ItemPageFrame>;
|
||||
}
|
||||
|
||||
// Short route-facing names keep the route module readable while preserving
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Alert } from '@mantine/core';
|
||||
import { MoreHorizontal, Plus, RefreshCw } from 'lucide-react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Callout } from '../../../ui/toolkit.tsx';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
import { Plus, RefreshCw } from 'lucide-react';
|
||||
import { ActionMenu } from '../../../ui/action-menu.tsx';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
import { librariesQuery } from '../queries/media.queries.ts';
|
||||
import { jobsQuery, librariesQuery } from '../queries/media.queries.ts';
|
||||
import { MediaPageFrame, QueryFeedback } from '../components/MediaPageFrame.tsx';
|
||||
import { MediaLibraryList } from '../components/library-list.tsx';
|
||||
import { MediaLibraryCreate } from '../components/library-create.tsx';
|
||||
import type { Library, MediaChannel } from '../api/media-api.ts';
|
||||
import { MediaScanSettings } from '../components/scan-settings.tsx';
|
||||
import type { Library, MediaChannel, ScanJob } from '../api/media-api.ts';
|
||||
|
||||
const channels: MediaChannel[] = ['video', 'music', 'audiobook'];
|
||||
function safeChannel(value: string | undefined): MediaChannel {
|
||||
@@ -19,6 +22,8 @@ function safeChannel(value: string | undefined): MediaChannel {
|
||||
export function MediaLibrariesPage() {
|
||||
const runtime = useRuntime();
|
||||
const navigate = useNavigate();
|
||||
// Opened from 我的, back returns there; otherwise to the channel's 影音设置.
|
||||
const fromMine = useLocation().state?.returnTo === '/me';
|
||||
const { channel: routeChannel } = useParams();
|
||||
const channel = safeChannel(routeChannel);
|
||||
const role = useAuthStore(state => state.verifiedUser?.role);
|
||||
@@ -27,31 +32,34 @@ export function MediaLibrariesPage() {
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState('');
|
||||
const query = useQuery(librariesQuery(runtime));
|
||||
if (role !== 'admin') return <MediaPageFrame title="媒体库管理"><Alert color="red">需要管理员权限。</Alert></MediaPageFrame>;
|
||||
// The latest scan of every library, polled so a running scan's progress moves on its card.
|
||||
const jobs = useQuery({ ...jobsQuery(runtime), enabled: role === 'admin' });
|
||||
if (role !== 'admin') return <MediaPageFrame title="媒体库管理"><Callout tone="error">需要管理员权限。</Callout></MediaPageFrame>;
|
||||
// The legacy manager is a shared workspace: opening it from any channel
|
||||
// shows every media library, while the channel only controls the return path
|
||||
// and the default type when creating a new library.
|
||||
const libraries = query.data?.items ?? [];
|
||||
const scan = async (id: string) => {
|
||||
const tasks = (id: string) => navigate(`/media/${channel}/settings/tasks?library=${encodeURIComponent(id)}`, { state: { returnTo: `/media/${channel}/settings/libraries` } });
|
||||
// Progress is shown on the library's own card, so starting or cancelling stays on this page.
|
||||
const act = async (id: string, action: () => Promise<unknown>, fallback: string) => {
|
||||
if (busyId) return;
|
||||
setBusyId(id); setActionError('');
|
||||
try {
|
||||
await runtime.mediaApi.request(`libraries/${encodeURIComponent(id)}/scan`, 'POST');
|
||||
await action();
|
||||
await queryClient.invalidateQueries({ queryKey: ['media'] });
|
||||
navigate(`/media/${channel}/settings/tasks?library=${encodeURIComponent(id)}`, { state: { returnTo: `/media/${channel}/settings/libraries` } });
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : '扫描请求失败,请稍后重试。');
|
||||
setActionError(error instanceof Error ? error.message : fallback);
|
||||
} finally { setBusyId(null); }
|
||||
};
|
||||
return <MediaPageFrame className="media-manager-workspace" title="媒体库管理" backTo={`/media/${channel}/settings`} backLabel="返回影音设置" actions={<details className="media-actions media-library-management-actions">
|
||||
<summary aria-label="媒体库管理操作" title="媒体库管理操作"><MoreHorizontal size={20} aria-hidden="true" /></summary>
|
||||
<nav aria-label="媒体库管理操作">
|
||||
<Link to={`/media/${channel}/settings/libraries/new`}><Plus size={16} aria-hidden="true" />新建媒体库</Link>
|
||||
<Link to={`/media/${channel}/settings/tasks`} state={{ returnTo: `/media/${channel}/settings/libraries` }}><RefreshCw size={16} aria-hidden="true" />扫描与刮削</Link>
|
||||
</nav>
|
||||
</details>}>
|
||||
{actionError && <Alert color="red" title="操作失败">{actionError}</Alert>}
|
||||
const scan = (id: string) => act(id, () => runtime.mediaApi.request(`libraries/${encodeURIComponent(id)}/scan`, 'POST'), '扫描请求失败,请稍后重试。');
|
||||
const cancel = (job: ScanJob) => act(job.libraryId ?? job.id, () => runtime.mediaApi.request(`jobs/${encodeURIComponent(job.id)}/cancel`, 'POST'), '取消扫描失败,请稍后重试。');
|
||||
return <MediaPageFrame className="media-manager-workspace" title="媒体库管理" backTo={fromMine ? '/me' : `/media/${channel}`} backLabel={fromMine ? '返回我的' : '返回频道'} actions={<ActionMenu className="media-icon-button" label="媒体库管理操作" items={[
|
||||
{ label: '新建媒体库', icon: <Plus size={16} />, onSelect: () => navigate(`/media/${channel}/settings/libraries/new`) },
|
||||
{ label: '扫描与刮削', icon: <RefreshCw size={16} />, onSelect: () => navigate(`/media/${channel}/settings/tasks`, { state: { returnTo: `/media/${channel}/settings/libraries` } }) },
|
||||
]} />}>
|
||||
<FloatingNotice message={actionError} error />
|
||||
<QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />
|
||||
<div className="media-libraries-layout"><div>
|
||||
{!query.isPending && !query.error && <MediaLibraryList
|
||||
api={runtime.mediaApi}
|
||||
libraries={libraries}
|
||||
@@ -61,8 +69,12 @@ export function MediaLibrariesPage() {
|
||||
onEdit={(library: Library) => navigate(`/media/${channel}/settings/libraries/${encodeURIComponent(library.id)}/edit`)}
|
||||
onPermissions={(library: Library) => navigate(`/media/${channel}/settings/libraries/${encodeURIComponent(library.id)}/permissions`)}
|
||||
onScan={id => { void scan(id); }}
|
||||
jobs={jobs.data?.items ?? []}
|
||||
onCancel={job => { void cancel(job); }}
|
||||
onDetails={library => tasks(library.id)}
|
||||
/>}
|
||||
{!query.isPending && !query.error && libraries.length === 0 && <Alert>还没有媒体库。请先创建一个媒体库并扫描目录。</Alert>}
|
||||
{!query.isPending && !query.error && libraries.length === 0 && <Callout>还没有媒体库。请先创建一个媒体库并扫描目录。</Callout>}
|
||||
</div><MediaScanSettings api={runtime.mediaApi} /></div>
|
||||
</MediaPageFrame>;
|
||||
}
|
||||
|
||||
@@ -73,7 +85,7 @@ export function MediaLibraryCreatePage() {
|
||||
const channel = safeChannel(routeChannel);
|
||||
const queryClient = useQueryClient();
|
||||
const role = useAuthStore(state => state.verifiedUser?.role);
|
||||
if (role !== 'admin') return <MediaPageFrame title="新建媒体库"><Alert color="red">需要管理员权限。</Alert></MediaPageFrame>;
|
||||
if (role !== 'admin') return <MediaPageFrame title="新建媒体库"><Callout tone="error">需要管理员权限。</Callout></MediaPageFrame>;
|
||||
const mediaChannel = channel;
|
||||
return <MediaPageFrame className="media-manager-workspace" title="新建媒体库" backTo={`/media/${channel}/settings/libraries`} backLabel="返回媒体库管理"><MediaLibraryCreate
|
||||
api={runtime.mediaApi}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert } from '@mantine/core';
|
||||
import { Callout } from '../../../ui/toolkit.tsx';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -19,7 +19,7 @@ function channelOf(value: string | undefined): typeof channels[number] {
|
||||
|
||||
function AdminOnly({ children, title }: { children: ReactNode; title: string }) {
|
||||
const role = useAuthStore(state => state.verifiedUser?.role);
|
||||
return role === 'admin' ? <>{children}</> : <MediaPageFrame title={title}><Alert color="red">需要管理员权限。</Alert></MediaPageFrame>;
|
||||
return role === 'admin' ? <>{children}</> : <MediaPageFrame title={title}><Callout tone="error">需要管理员权限。</Callout></MediaPageFrame>;
|
||||
}
|
||||
|
||||
export function MediaLibraryEditPage() {
|
||||
@@ -31,7 +31,7 @@ export function MediaLibraryEditPage() {
|
||||
const query = useQuery(librariesQuery(runtime));
|
||||
const library = query.data?.items.find(item => item.id === libraryId);
|
||||
const back = `/media/${channel}/settings/libraries`;
|
||||
return <AdminOnly title="编辑媒体库"><MediaPageFrame className="media-manager-workspace" title="编辑媒体库" backTo={back} backLabel="返回媒体库管理"><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{library && <MediaLibraryEditor api={runtime.mediaApi} library={library} onSaved={updated => { queryClient.setQueryData(librariesQuery(runtime).queryKey, (value: { items: Library[] } | undefined) => value ? { items: value.items.map(item => item.id === updated.id ? updated : item) } : value); navigate(back); }} onCancel={() => navigate(back)} />}{!query.isPending && !query.error && !library && <Alert color="red">媒体库不存在。</Alert>}</MediaPageFrame></AdminOnly>;
|
||||
return <AdminOnly title="编辑媒体库"><MediaPageFrame className="media-manager-workspace" title="编辑媒体库" backTo={back} backLabel="返回媒体库管理"><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{library && <MediaLibraryEditor api={runtime.mediaApi} library={library} onSaved={updated => { queryClient.setQueryData(librariesQuery(runtime).queryKey, (value: { items: Library[] } | undefined) => value ? { items: value.items.map(item => item.id === updated.id ? updated : item) } : value); navigate(back); }} onCancel={() => navigate(back)} />}{!query.isPending && !query.error && !library && <Callout tone="error">媒体库不存在。</Callout>}</MediaPageFrame></AdminOnly>;
|
||||
}
|
||||
|
||||
export function MediaLibraryPermissionsPage() {
|
||||
@@ -43,7 +43,7 @@ export function MediaLibraryPermissionsPage() {
|
||||
const query = useQuery(librariesQuery(runtime));
|
||||
const library = query.data?.items.find(item => item.id === libraryId);
|
||||
const back = `/media/${channel}/settings/libraries`;
|
||||
return <AdminOnly title="访问权限"><MediaPageFrame className="media-manager-workspace" title="访问权限" backTo={back} backLabel="返回媒体库管理"><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{library && <MediaPermissions api={runtime.mediaApi} library={library} onSaved={access => { queryClient.setQueryData(librariesQuery(runtime).queryKey, (value: { items: Library[] } | undefined) => value ? { items: value.items.map(item => item.id === library.id ? { ...item, access } : item) } : value); navigate(back); }} onCancel={() => navigate(back)} />}{!query.isPending && !query.error && !library && <Alert color="red">媒体库不存在。</Alert>}</MediaPageFrame></AdminOnly>;
|
||||
return <AdminOnly title="访问权限"><MediaPageFrame className="media-manager-workspace" title="访问权限" backTo={back} backLabel="返回媒体库管理"><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{library && <MediaPermissions api={runtime.mediaApi} library={library} onSaved={access => { queryClient.setQueryData(librariesQuery(runtime).queryKey, (value: { items: Library[] } | undefined) => value ? { items: value.items.map(item => item.id === library.id ? { ...item, access } : item) } : value); navigate(back); }} onCancel={() => navigate(back)} />}{!query.isPending && !query.error && !library && <Callout tone="error">媒体库不存在。</Callout>}</MediaPageFrame></AdminOnly>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from '@mantine/core';
|
||||
import { History, LibraryBig, ListVideo, MoreHorizontal, Search, Settings2, Star } from 'lucide-react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { ChannelMenu } from '../components/channel-menu.tsx';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -65,17 +65,8 @@ export function MediaNarratorsPage() {
|
||||
};
|
||||
|
||||
const actions = <>
|
||||
<Button component={Link} to="/media/search" className="media-icon-button" aria-label="搜索" title="搜索"><Search size={19} aria-hidden="true" /></Button>
|
||||
<details className="media-actions media-catalog-actions">
|
||||
<summary aria-label="更多操作" title="更多操作"><MoreHorizontal size={20} aria-hidden="true" /></summary>
|
||||
<nav aria-label="更多操作">
|
||||
<Link to="/media/audiobook/settings"><Settings2 size={16} aria-hidden="true" />影音设置</Link>
|
||||
<Link to="/media/favorites"><Star size={16} aria-hidden="true" />收藏</Link>
|
||||
<Link to="/media/audiobook/history"><History size={16} aria-hidden="true" />历史</Link>
|
||||
<Link to="/media/audiobook/queue"><ListVideo size={16} aria-hidden="true" />队列</Link>
|
||||
<Link to="/media/audiobook/settings/libraries"><LibraryBig size={16} aria-hidden="true" />媒体库管理</Link>
|
||||
</nav>
|
||||
</details>
|
||||
<Link to="/media/search" className="media-icon-button" aria-label="搜索" title="搜索"><Search size={19} aria-hidden="true" /></Link>
|
||||
<ChannelMenu channel="audiobook" />
|
||||
</>;
|
||||
return <MediaPageFrame className="media-catalog-page" title="有声书" actions={actions}>
|
||||
<QueryFeedback pending={libraries.isPending} error={libraries.error} retry={() => { void libraries.refetch(); }} />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Button, Center, Stack } from '@mantine/core';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
@@ -8,6 +7,8 @@ import { PlaybackControls, type PlaybackPanel } from '../components/playback-con
|
||||
import { VideoControls } from '../components/video-controls.tsx';
|
||||
import { restorePlaybackEntries } from '../services/restore-playback.ts';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { resolveAppTheme, themes } from '../../../shared/theme/theme.ts';
|
||||
import { useThemeState } from '../../../shared/theme/use-theme.ts';
|
||||
|
||||
export function MediaPlayerPage() {
|
||||
const runtime = useRuntime();
|
||||
@@ -24,6 +25,19 @@ export function MediaPlayerPage() {
|
||||
const videoPage = video || channel === 'video';
|
||||
const rootClassName = ['media-screen', 'media-secondary-page', 'media-playback-page', 'media-player-page', videoPage ? 'media-video-page' : undefined].filter(Boolean).join(' ');
|
||||
const [restoreError, setRestoreError] = useState('');
|
||||
// Video and music play on a fixed dark, immersive surface: the current theme's dark
|
||||
// values, whatever the app's mode (theme spec §4.7). Audiobooks keep the app's mode.
|
||||
const { preference, systemDark } = useThemeState(runtime.mediaApi.preferenceScope());
|
||||
const immersiveTheme = videoPage || channel === 'music' ? resolveAppTheme(preference, systemDark).theme : undefined;
|
||||
useEffect(() => {
|
||||
if (!immersiveTheme) return;
|
||||
// The live player sits outside this tree (it survives navigation), and the page
|
||||
// column does not span wide screens, so both the player and the body follow too.
|
||||
const player = runtime.player.element, body = document.body;
|
||||
player.dataset.theme = immersiveTheme; player.dataset.mode = 'dark';
|
||||
body.style.background = themes[immersiveTheme].dark.bg;
|
||||
return () => { delete player.dataset.theme; delete player.dataset.mode; body.style.removeProperty('background'); };
|
||||
}, [runtime, immersiveTheme]);
|
||||
// The player element lives outside the routed React tree so playback survives
|
||||
// navigation. A player route is the explicit full-screen/expanded view; mirror
|
||||
// that lifecycle here and collapse the persistent mini-player when leaving it.
|
||||
@@ -50,13 +64,13 @@ export function MediaPlayerPage() {
|
||||
}
|
||||
navigate(-1);
|
||||
};
|
||||
return <div className={rootClassName}>
|
||||
return <div className={rootClassName} data-theme={immersiveTheme} data-mode={immersiveTheme ? 'dark' : undefined}>
|
||||
{videoPage && <header className="media-heading">
|
||||
<button type="button" className="media-back-button" aria-label="← 返回浏览" title="返回浏览" onClick={back}><ChevronLeft size={20} aria-hidden="true" /></button>
|
||||
<strong>视频播放</strong>
|
||||
</header>}
|
||||
{restoreError ? <Center mih="60vh"><Stack align="center"><strong>无法恢复播放</strong><div role="alert">{restoreError}</div><Button variant="subtle" onClick={() => navigate(`/media/${channel}`)}>返回频道</Button></Stack></Center>
|
||||
: !active ? <div className="media-player-empty"><p>当前没有播放内容。</p><Button variant="subtle" onClick={() => navigate(`/media/${channel}`)}>选择作品</Button></div>
|
||||
{restoreError ? <div className="page-state" role="alert"><h2>无法恢复播放</h2><p>{restoreError}</p><button type="button" onClick={() => navigate(`/media/${channel}`)}>返回频道</button></div>
|
||||
: !active ? <div className="media-player-empty"><p>当前没有播放内容。</p><button type="button" onClick={() => navigate(`/media/${channel}`)}>选择作品</button></div>
|
||||
: video ? createPortal(<VideoControls player={runtime.player} api={runtime.mediaApi} onBack={back} />, runtime.player.videoControlsHost)
|
||||
: <PlaybackControls player={runtime.player} api={runtime.mediaApi} panel={requested} onPanelChange={value => navigate(value === 'main' ? `/media/${channel}/player` : `/media/${channel}/player/${value}`, { replace: true })} onBack={back} />}
|
||||
</div>;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { queueQuery, type Activity } from '../queries/media.queries.ts';
|
||||
import { channelLabels } from '../hooks/use-media-channel.ts';
|
||||
import { MediaPageFrame, QueryFeedback } from '../components/MediaPageFrame.tsx';
|
||||
import { SavedQueue } from '../components/saved-queue.tsx';
|
||||
import { FloatingConfirm } from '../../../ui/floating-confirm.tsx';
|
||||
import { ConfirmSheet } from '../../../ui/sheet.tsx';
|
||||
|
||||
function queueChannel(kind: string): 'video' | 'music' | 'audiobook' {
|
||||
return kind === 'audiobook' ? 'audiobook' : ['album', 'artist', 'track'].includes(kind) ? 'music' : 'video';
|
||||
@@ -72,6 +72,6 @@ export function MediaQueuePage() {
|
||||
onClear={() => setConfirming(true)}
|
||||
/>}
|
||||
{!query.isPending && !query.error && entries.length === 0 && <div className="media-personal-empty"><ListMusic size={30} strokeWidth={1.4} aria-hidden="true" /><p>暂无记录。</p></div>}
|
||||
{confirming && <FloatingConfirm theme="media" title="清空队列" text={`移除${channel ? `当前${channel === 'video' ? '影视' : channel === 'music' ? '音乐' : '有声书'}` : '全部'}队列中的 ${entries.length} 条记录?不会删除文件或停止当前播放。`} confirmText="确认清空" cancelText="保留队列" onConfirm={() => { setConfirming(false); action.mutate({ type: 'clear' }); }} onCancel={() => setConfirming(false)} />}
|
||||
{confirming && <ConfirmSheet danger title="清空队列" text={`移除${channel ? `当前${channel === 'video' ? '影视' : channel === 'music' ? '音乐' : '有声书'}` : '全部'}队列中的 ${entries.length} 条记录?不会删除文件或停止当前播放。`} confirmText="确认清空" cancelText="保留队列" onConfirm={() => { setConfirming(false); action.mutate({ type: 'clear' }); }} onCancel={() => setConfirming(false)} />}
|
||||
</section></MediaPageFrame>;
|
||||
}
|
||||
@@ -1,50 +1,43 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||
import { MediaPageFrame } from '../components/MediaPageFrame.tsx';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { MediaSettings } from '../components/settings.tsx';
|
||||
import type { SettingsPanel } from '../components/settings.tsx';
|
||||
import { MediaSettings, SETTINGS_TITLES, type SettingsPanel } from '../components/settings.tsx';
|
||||
import { readMediaPreferences } from '../services/preferences.ts';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
|
||||
const panels: readonly SettingsPanel[] = ['home', 'theme', 'browse', 'playback', 'plugins', 'account'];
|
||||
const titles: Record<SettingsPanel, string> = {
|
||||
home: '影音设置', theme: '主题外观', browse: '浏览偏好', playback: '播放设置',
|
||||
plugins: '来源与刮削', account: '账号与连接',
|
||||
};
|
||||
|
||||
function safePanel(value: string | undefined): SettingsPanel {
|
||||
return value && panels.includes(value as SettingsPanel) ? value as SettingsPanel : 'home';
|
||||
function isPanel(value: string | undefined): value is SettingsPanel {
|
||||
return (Object.keys(SETTINGS_TITLES) as string[]).includes(value ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* One settings panel of 我的, at /me/:panel. Back returns to 我的. An unknown panel,
|
||||
* and the old media settings home (/media/:channel/settings[/:panel]), lead to 我的.
|
||||
*/
|
||||
export function MediaSettingsPage() {
|
||||
const runtime = useRuntime();
|
||||
const navigate = useNavigate();
|
||||
const { channel: routeChannel, panel: routePanel } = useParams();
|
||||
const { channel: routeChannel, panel } = useParams();
|
||||
const channel = routeChannel === 'music' || routeChannel === 'audiobook' ? routeChannel : 'video';
|
||||
const panel = safePanel(routePanel);
|
||||
const admin = useAuthStore(state => state.verifiedUser?.role === 'admin');
|
||||
const [preferences, setPreferences] = useState(() => readMediaPreferences(runtime.mediaApi.preferenceScope()));
|
||||
const settingsPath = (next: SettingsPanel) => next === 'home'
|
||||
? `/media/${channel}/settings`
|
||||
: `/media/${channel}/settings/${next}`;
|
||||
if (!isPanel(panel)) return <Navigate to="/me" replace />;
|
||||
|
||||
return <MediaPageFrame className="media-utility-workspace" title={titles[panel]} {...(panel !== 'home' ? { backTo: `/media/${channel}/settings`, backLabel: '返回影音设置' } : {})}>
|
||||
return <MediaPageFrame className="media-utility-workspace" title={SETTINGS_TITLES[panel]} backTo="/me" backLabel="返回我的">
|
||||
<MediaSettings
|
||||
scope={runtime.mediaApi.preferenceScope()}
|
||||
preferences={preferences}
|
||||
player={runtime.player}
|
||||
admin={admin}
|
||||
api={runtime.mediaApi}
|
||||
account={runtime.mediaApi.accountInfo()}
|
||||
panel={panel}
|
||||
onSaved={setPreferences}
|
||||
onBack={() => navigate(`/media/${channel}`)}
|
||||
onPanelChange={next => navigate(settingsPath(next))}
|
||||
onPersonal={view => navigate(view === 'favorites' ? '/media/favorites' : view === 'queue' ? `/media/${channel}/queue` : `/media/${channel}/history`, { state: { returnTo: `/media/${channel}/settings` } })}
|
||||
onManage={() => navigate(`/media/${channel}/settings/libraries`)}
|
||||
onTasks={() => navigate(`/media/${channel}/settings/tasks`, { state: { returnTo: `/media/${channel}/settings` } })}
|
||||
onBack={() => navigate('/me')}
|
||||
onPlayback={() => navigate(`/media/${channel}/player`)}
|
||||
onReaderSettings={patch => { void runtime.updateSettings(patch); }}
|
||||
/>
|
||||
</MediaPageFrame>;
|
||||
}
|
||||
|
||||
/** Old links to the media settings home and its panels: the panels now live in 我的. */
|
||||
export function LegacyMediaSettingsRedirect() {
|
||||
const { panel } = useParams();
|
||||
return <Navigate to={isPanel(panel) ? `/me/${panel}` : '/me'} replace />;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Alert, Button, Group, Stack, Text } from '@mantine/core';
|
||||
import { Callout } from '../../../ui/toolkit.tsx';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
@@ -17,8 +18,6 @@ export function MediaTasksPage() {
|
||||
const runtime = useRuntime();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { channel: routeChannel } = useParams();
|
||||
const channel = routeChannel === 'music' || routeChannel === 'audiobook' ? routeChannel : 'video';
|
||||
const [params, setParams] = useSearchParams();
|
||||
const role = useAuthStore(state => state.verifiedUser?.role);
|
||||
const queryClient = useQueryClient();
|
||||
@@ -27,17 +26,18 @@ export function MediaTasksPage() {
|
||||
const [actionNotice, setActionNotice] = useState('');
|
||||
const tab = params.get('tab') === 'ai' || params.get('tab') === 'scrape' ? params.get('tab')! : 'scan';
|
||||
const requestedLibraryId = params.get('library') ?? '';
|
||||
const returnTo = typeof location.state?.returnTo === 'string' && location.state.returnTo.startsWith('/media/')
|
||||
? location.state.returnTo
|
||||
: `/media/${channel}/settings`;
|
||||
const returnLabel = returnTo.includes('/settings/libraries') ? '返回媒体库管理' : '返回影音设置';
|
||||
const stateReturn = location.state?.returnTo;
|
||||
const returnTo = typeof stateReturn === 'string' && (stateReturn.startsWith('/media/') || stateReturn === '/me')
|
||||
? stateReturn
|
||||
: '/me';
|
||||
const returnLabel = returnTo === '/me' ? '返回我的' : returnTo.includes('/settings/libraries') ? '返回媒体库管理' : '返回上一页';
|
||||
const libraries = useQuery(librariesQuery(runtime));
|
||||
const libraryId = libraries.data?.items.some(item => item.id === requestedLibraryId)
|
||||
? requestedLibraryId
|
||||
: '';
|
||||
const jobs = useQuery({ ...jobsQuery(runtime, libraryId), enabled: tab === 'scan' });
|
||||
const aiJobs = useQuery({ ...aiJobsQuery(runtime, libraryId), enabled: tab === 'ai' });
|
||||
if (role !== 'admin') return <MediaPageFrame title="扫描与刮削"><Alert color="red">需要管理员权限。</Alert></MediaPageFrame>;
|
||||
if (role !== 'admin') return <MediaPageFrame title="扫描与刮削"><Callout tone="error">需要管理员权限。</Callout></MediaPageFrame>;
|
||||
const setTab = (value: string) => { const next = new URLSearchParams(params); next.set('tab', value); setParams(next, { state: location.state }); };
|
||||
const runAction = async (action: () => Promise<void>, notice: string) => {
|
||||
if (actionBusy) return;
|
||||
@@ -69,20 +69,20 @@ export function MediaTasksPage() {
|
||||
return <MediaPageFrame className="media-manager-workspace" title="扫描与刮削" backTo={returnTo} backLabel={returnLabel}>
|
||||
<section className="media-manager">
|
||||
<nav className="media-task-tabs" aria-label="任务类型">{[['scan','媒体库扫描'],['ai','AI 扫描'],['scrape','刮削']].map(([value,label]) => <button type="button" key={value} aria-current={tab === value ? 'page' : undefined} onClick={() => setTab(value)}>{label}</button>)}</nav>
|
||||
{requestedLibraryId && !libraryId && !libraries.isPending && !libraries.error && <Alert color="yellow">所选媒体库不存在,已切换为全部媒体库。</Alert>}
|
||||
{actionError && <Alert color="red" title="操作失败">{actionError}</Alert>}
|
||||
{actionNotice && <Alert color="green">{actionNotice}</Alert>}
|
||||
{requestedLibraryId && !libraryId && !libraries.isPending && !libraries.error && <Callout tone="warning">所选媒体库不存在,已切换为全部媒体库。</Callout>}
|
||||
<FloatingNotice message={actionError} error />
|
||||
<FloatingNotice message={actionNotice} kind="success" />
|
||||
<QueryFeedback pending={libraries.isPending || (tab === 'scan' ? jobs.isPending : tab === 'ai' ? aiJobs.isPending : false)} error={libraries.error || (tab === 'scan' ? jobs.error : tab === 'ai' ? aiJobs.error : null)} retry={() => { void libraries.refetch(); void jobs.refetch(); void aiJobs.refetch(); }} />
|
||||
{tab === 'scan' && !jobs.isPending && !jobs.error && <Stack>
|
||||
<div className="media-task-intro"><div><Text component="h2">媒体库扫描</Text><Text component="p">扫描所有媒体库并建立索引</Text></div><Button className="media-primary" loading={actionBusy} disabled={!libraries.data?.items.length} onClick={() => { void startScan(libraryId || undefined); }}>扫描{libraryId ? '当前库' : '所有媒体库'}</Button></div>
|
||||
<div className="media-task-library"><label>媒体库<MediaSelect aria-label="扫描媒体库" value={libraryId} onChange={event => { const next = new URLSearchParams(params); const value = event.currentTarget.value; value ? next.set('library', value) : next.delete('library'); setParams(next, { state: location.state }); }}><option value="">全部媒体库 · 最新任务</option>{(libraries.data?.items ?? []).map(library => <option key={library.id} value={library.id}>{library.name}</option>)}</MediaSelect></label><Button disabled={!libraryId || actionBusy} onClick={() => { void startScan(libraryId); }}><RefreshCw size={16} aria-hidden="true" />扫描当前库</Button><Button aria-label="刷新扫描任务" title="刷新扫描任务" variant="subtle" onClick={() => { void jobs.refetch(); }}><RefreshCw size={16} aria-hidden="true" /></Button></div>
|
||||
{tab === 'scan' && !jobs.isPending && !jobs.error && <div className="media-task-panel">
|
||||
<div className="media-task-intro"><div><h2>媒体库扫描</h2><p>扫描所有媒体库并建立索引</p></div><button type="button" className="media-primary" disabled={(!libraries.data?.items.length) || (actionBusy)} onClick={() => { void startScan(libraryId || undefined); }}>扫描{libraryId ? '当前库' : '所有媒体库'}</button></div>
|
||||
<div className="media-task-library"><label>媒体库<MediaSelect aria-label="扫描媒体库" value={libraryId} onChange={event => { const next = new URLSearchParams(params); const value = event.currentTarget.value; value ? next.set('library', value) : next.delete('library'); setParams(next, { state: location.state }); }}><option value="">全部媒体库 · 最新任务</option>{(libraries.data?.items ?? []).map(library => <option key={library.id} value={library.id}>{library.name}</option>)}</MediaSelect></label><button type="button" disabled={!libraryId || actionBusy} onClick={() => { void startScan(libraryId); }}><RefreshCw size={16} aria-hidden="true" />扫描当前库</button><button type="button" aria-label="刷新扫描任务" title="刷新扫描任务" onClick={() => { void jobs.refetch(); }}><RefreshCw size={16} aria-hidden="true" /></button></div>
|
||||
<h2 className="media-task-section-title">媒体库扫描历史</h2>
|
||||
<ScanJobs jobs={filteredJobs} libraries={libraries.data?.items ?? []} libraryName="媒体库" busy={jobs.isFetching || actionBusy} onRetry={id => { void startScan(id || libraryId || undefined); }} onCancel={id => { void runAction(async () => { await runtime.mediaApi.request(`jobs/${encodeURIComponent(id)}/cancel`, 'POST'); await jobs.refetch(); }, '扫描任务已取消。'); }} onDelete={id => { void runAction(async () => { await runtime.mediaApi.request(`scan-jobs/${encodeURIComponent(id)}`, 'DELETE'); await jobs.refetch(); }, '扫描历史已删除。'); }} />
|
||||
</Stack>}
|
||||
{tab === 'ai' && !aiJobs.isPending && !aiJobs.error && <Stack>
|
||||
<Group justify="space-between"><div><Text fw={600}>AI 扫描</Text><Text size="sm" c="dimmed">识别未归档文件并生成待处理结果。</Text></div><Group><Button loading={actionBusy} disabled={!libraryId} onClick={() => { void startAiScan(libraryId); }}>扫描当前库</Button><Button loading={actionBusy} variant="light" disabled={!libraries.data?.items.length} onClick={() => { void startAiScan(); }}>扫描所有媒体库</Button></Group></Group>
|
||||
</div>}
|
||||
{tab === 'ai' && !aiJobs.isPending && !aiJobs.error && <div className="media-task-panel">
|
||||
<div className="media-task-intro"><div><h2>AI 扫描</h2><p>识别未归档文件并生成待处理结果</p></div><div className="media-task-actions"><button type="button" disabled={(!libraryId) || (actionBusy)} onClick={() => { void startAiScan(libraryId); }}>扫描当前库</button><button type="button" disabled={(!libraries.data?.items.length) || (actionBusy)} onClick={() => { void startAiScan(); }}>扫描所有媒体库</button></div></div>
|
||||
<AiScanJobs api={runtime.mediaApi} jobs={(aiJobs.data?.items ?? []).filter(job => !libraryId || job.libraryId === libraryId)} libraries={libraries.data?.items ?? []} busy={aiJobs.isFetching || actionBusy} onRefresh={() => { void aiJobs.refetch(); }} onDelete={id => { void runAction(async () => { await runtime.mediaApi.deleteAiScanJob(id); await aiJobs.refetch(); }, 'AI 扫描历史已删除。'); }} />
|
||||
</Stack>}
|
||||
</div>}
|
||||
{tab === 'scrape' && !libraries.isPending && !libraries.error && <ScrapeJobs api={runtime.mediaApi} libraries={libraries.data?.items ?? []} navigate={(mediaChannel: MediaChannel, id: string) => { navigate(`/media/${mediaChannel}/items/${encodeURIComponent(id)}`); }} />}
|
||||
</section>
|
||||
</MediaPageFrame>;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** How a playback queue advances once an entry ends. Shared by the engine and its controls. */
|
||||
export type PlayMode='sequential'|'shuffle'|'repeat-one';
|
||||
export const playModes:readonly PlayMode[]=['sequential','shuffle','repeat-one'];
|
||||
export const playModeLabels:Readonly<Record<PlayMode,string>>={sequential:'顺序播放',shuffle:'随机','repeat-one':'单曲循环'};
|
||||
@@ -11,6 +11,8 @@ import { AudioTrackControls } from '../components/audio-tracks.ts';
|
||||
import { NativeAudio } from './native-audio.ts';
|
||||
import { playbackSupport, playbackFailure, type PlaybackStream } from './playback-support.ts';
|
||||
|
||||
import { playModes, type PlayMode } from './play-mode.ts';
|
||||
export { playModes, type PlayMode };
|
||||
/** Lives outside screen mounts. Navigation must not destroy background audio. */
|
||||
export class MediaPlayer extends EventTarget {
|
||||
readonly element=document.createElement('div');
|
||||
@@ -64,6 +66,24 @@ export class MediaPlayer extends EventTarget {
|
||||
private queueIndex=0;
|
||||
private switching=false;
|
||||
playbackRate=1;
|
||||
/** How the queue advances: in order, at random without repeats, or the same entry again. */
|
||||
playMode:PlayMode='sequential';
|
||||
private shuffleTrail:number[]=[];
|
||||
private trailBack=false;
|
||||
/** Pause once the current entry (chapter, track or episode) ends. */
|
||||
sleepAfterPart=false;
|
||||
// The Android service owns its own queue and has no mode or end-of-entry command, so these stay web-only.
|
||||
get canChoosePlayMode(){return this.active&&!this.nativeActive;}
|
||||
get canSleepAfterPart(){return this.active&&!this.nativeActive;}
|
||||
setPlayMode(mode:PlayMode){if(!this.canChoosePlayMode||!playModes.includes(mode))return;this.playMode=mode;this.shuffleTrail=[this.queueIndex];this.changed();}
|
||||
setSleepAfterPart(on:boolean){if(on&&!this.canSleepAfterPart)return;if(on)this.sleep(0);this.sleepAfterPart=on;this.changed();}
|
||||
private pickShuffle():number{
|
||||
const others=this.queue.map((_,index)=>index).filter(index=>index!==this.queueIndex);
|
||||
const fresh=others.filter(index=>!this.shuffleTrail.includes(index));
|
||||
if(!fresh.length)this.shuffleTrail=[this.queueIndex];
|
||||
const pool=fresh.length?fresh:others;
|
||||
return pool[Math.floor(Math.random()*pool.length)]!;
|
||||
}
|
||||
get isVideo(){return this.media===this.video;}
|
||||
get isWebVideo(){return this.isVideo&&!this.nativeActive;}
|
||||
get canOpenNativeVideo(){return !!this.native&&this.active&&this.media===this.video;}
|
||||
@@ -153,6 +173,16 @@ export class MediaPlayer extends EventTarget {
|
||||
this.chapterSeek.setAttribute('aria-label','当前章节播放位置');
|
||||
};
|
||||
}
|
||||
/** The subtitle state the 字幕与音轨 sheet draws (web video only; native has its own menu). */
|
||||
get subtitleControls():SubtitleControls{return this.subtitles;}
|
||||
/** 字号 in the subtitle sheet: drives `::cue` through a data attribute on the player. Per device. */
|
||||
subtitleSize:SubtitleSize=readSubtitleSize();
|
||||
setSubtitleSize(size:SubtitleSize){
|
||||
if(!subtitleSizes.includes(size))return;
|
||||
this.subtitleSize=size;this.element.dataset.subtitleSize=size;
|
||||
try{localStorage.setItem(subtitleSizeKey,size);}catch{/* the choice still applies for this session */}
|
||||
this.changed();
|
||||
}
|
||||
mountVideoTracks(host:HTMLElement,kind:'subtitles'|'tracks'):()=>void {
|
||||
const control=kind==='subtitles'?this.subtitles.element:this.audioTracks.element;
|
||||
host.append(control);return ()=>{this.element.append(control);};
|
||||
@@ -193,6 +223,7 @@ export class MediaPlayer extends EventTarget {
|
||||
this.volumeLabel.className='media-volume';this.volumeLabel.textContent='音量';this.volumeLabel.append(volume);
|
||||
this.audioControls.append(this.chapterSeek,this.volumeLabel);
|
||||
this.subtitles=new SubtitleControls(api,this.video);
|
||||
this.element.dataset.subtitleSize=this.subtitleSize;
|
||||
this.audioTracks=new AudioTrackControls(api);
|
||||
this.nativeControls.className='media-native-controls';this.nativeControls.hidden=true;
|
||||
const nativeTrackLabel=document.createElement('label');nativeTrackLabel.textContent='音轨 ';nativeTrackLabel.hidden=true;
|
||||
@@ -324,15 +355,24 @@ export class MediaPlayer extends EventTarget {
|
||||
const next=this.queue.slice();
|
||||
if(direction===0){next.splice(index,1);if(index<this.queueIndex)this.queueIndex--;}
|
||||
else{const target=index+direction;[next[index],next[target]]=[next[target]!,next[index]!];if(this.queueIndex===index)this.queueIndex=target;else if(this.queueIndex===target)this.queueIndex=index;}
|
||||
this.queue=next;this.changed();
|
||||
this.queue=next;this.shuffleTrail=[this.queueIndex];this.changed();
|
||||
}
|
||||
get paused(){return this.nativeActive?this.native!.state.paused:this.media.paused;}
|
||||
get hasQueueNavigation(){return this.active&&(this.nativeActive?this.native!.state.canPrevious===true||this.native!.state.canNext===true||this.native!.state.queueSwitching===true:this.queue.length>1);}
|
||||
get canPrevious(){return this.active&&!this.switching&&!this.playbackBlocked&&(this.nativeActive?this.native!.state.canPrevious===true:this.queueIndex>0);}
|
||||
get canNext(){return this.active&&!this.switching&&!this.playbackBlocked&&(this.nativeActive?this.native!.state.canNext===true:this.queueIndex+1<this.queue.length);}
|
||||
get canPrevious(){return this.active&&!this.switching&&!this.playbackBlocked&&(this.nativeActive?this.native!.state.canPrevious===true:this.playMode==='shuffle'?this.shuffleTrail.length>1:this.queueIndex>0);}
|
||||
get canNext(){return this.active&&!this.switching&&!this.playbackBlocked&&(this.nativeActive?this.native!.state.canNext===true:this.playMode==='shuffle'?this.queue.length>1:this.queueIndex+1<this.queue.length);}
|
||||
async move(direction:-1|1):Promise<void>{
|
||||
if(direction===-1?!this.canPrevious:direction===1?!this.canNext:true)return;
|
||||
if(this.nativeActive){this.native!.command(direction<0?'previous':'next');return;}
|
||||
if(this.playMode==='shuffle'){
|
||||
if(direction===1){await this.selectPlaylist(this.pickShuffle());return;}
|
||||
const trail=this.shuffleTrail.slice(0,-1),target=trail.at(-1);
|
||||
if(target===undefined)return;
|
||||
this.trailBack=true;
|
||||
try{await this.selectPlaylist(target);}finally{this.trailBack=false;}
|
||||
if(this.queueIndex===target)this.shuffleTrail=trail;
|
||||
return;
|
||||
}
|
||||
await this.selectPlaylist(this.queueIndex+direction);
|
||||
}
|
||||
async selectPlaylist(target:number):Promise<void>{
|
||||
@@ -364,6 +404,8 @@ export class MediaPlayer extends EventTarget {
|
||||
const session=await (options.signal?this.api.playback(selected.part.id,options.signal):this.api.playback(selected.part.id));
|
||||
if(generation!==this.generation||options.signal?.aborted)return;
|
||||
if(selected.video&&!nativeVideo){await this.ensureWebVideo();if(generation!==this.generation||options.signal?.aborted)return;}
|
||||
if(parts!==this.queue||!this.shuffleTrail.length)this.shuffleTrail=[index];
|
||||
else if(!this.trailBack&&this.shuffleTrail.at(-1)!==index)this.shuffleTrail=[...this.shuffleTrail,index].slice(-200);
|
||||
this.queue=parts;this.queueIndex=index;
|
||||
this.lease?.stop();this.lease=null;
|
||||
if(!this.active)this.playbackRate=readPlaybackPreferences(this.api.preferenceScope()).defaultRate;
|
||||
@@ -424,6 +466,9 @@ export class MediaPlayer extends EventTarget {
|
||||
this.finishedSession='';this.error='本章完成进度尚未保存,点击继续重试。';this.changed();return;
|
||||
}
|
||||
if(intent!==this.resumeIntent){this.finishedSession='';return;}
|
||||
if(this.sleepAfterPart){this.sleepAfterPart=false;this.changed();return;}
|
||||
if(this.playMode==='repeat-one'){await this.play(this.queue,this.queueIndex);return;}
|
||||
if(this.playMode==='shuffle'&&this.queue.length>1){await this.play(this.queue,this.pickShuffle());return;}
|
||||
if(readPlaybackPreferences(this.api.preferenceScope()).continuous&&this.queueIndex+1<this.queue.length)await this.play(this.queue,this.queueIndex+1);
|
||||
}
|
||||
async flush(completed=false):Promise<void> {
|
||||
@@ -504,7 +549,7 @@ export class MediaPlayer extends EventTarget {
|
||||
speed(value:number){if(Number.isFinite(value)&&value>=0.5&&value<=3){this.playbackRate=value;if(this.nativeActive)this.native?.command('speed',{value});else{this.media.defaultPlaybackRate=value;this.media.playbackRate=value;}this.changed();}}
|
||||
sleep(minutes:number){
|
||||
if(!Number.isFinite(minutes)||minutes<0||minutes>1440)return;
|
||||
if(this.timer)clearTimeout(this.timer);if(this.sleepTicker)clearInterval(this.sleepTicker);
|
||||
this.sleepAfterPart=false;if(this.timer)clearTimeout(this.timer);if(this.sleepTicker)clearInterval(this.sleepTicker);
|
||||
this.timer=null;this.sleepTicker=null;this.sleepAt=minutes>0?Date.now()+minutes*60000:0;
|
||||
if(this.nativeActive)this.native?.command('sleep',{minutes});
|
||||
else if(minutes>0){
|
||||
@@ -513,5 +558,12 @@ export class MediaPlayer extends EventTarget {
|
||||
}
|
||||
this.changed();
|
||||
}
|
||||
async stop(){this.restoreNative=false;++this.generation;this.clearStartupTimer();this.loadingStatus='';this.error='';this.pause();const pending=this.flush();this.native?.command('stop');this.nativeActive=false;this.nativeControls.hidden=true;this.subtitles.clear();this.audioTracks.clear();this.lease?.stop();this.lease=null;this.session=null;this.ready=false;this.audio.removeAttribute('src');this.video.removeAttribute('src');this.audio.load();this.video.load();this.active=false;this.element.hidden=true;this.sleep(0);this.queue=[];this.changed();await pending;}
|
||||
async stop(){this.restoreNative=false;++this.generation;this.clearStartupTimer();this.loadingStatus='';this.error='';this.pause();const pending=this.flush();this.native?.command('stop');this.nativeActive=false;this.nativeControls.hidden=true;this.subtitles.clear();this.audioTracks.clear();this.lease?.stop();this.lease=null;this.session=null;this.ready=false;this.audio.removeAttribute('src');this.video.removeAttribute('src');this.audio.load();this.video.load();this.active=false;this.element.hidden=true;this.sleep(0);this.queue=[];this.shuffleTrail=[];this.changed();await pending;}
|
||||
}
|
||||
|
||||
export const subtitleSizes=['small','normal','large'] as const;
|
||||
export type SubtitleSize=typeof subtitleSizes[number];
|
||||
const subtitleSizeKey='reader.media.subtitle-size.v1';
|
||||
function readSubtitleSize():SubtitleSize{
|
||||
try{const stored=localStorage.getItem(subtitleSizeKey);return subtitleSizes.includes(stored as SubtitleSize)?stored as SubtitleSize:'normal';}catch{return 'normal';}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Local subtitle files (添加外挂字幕) to text-only WebVTT, in the browser.
|
||||
*
|
||||
* The same normalisation as the server's `subtitleToVtt` (server/src/media/subtitles.ts):
|
||||
* cue timing and plain text survive; source styles, markup, scripts and ASS drawings do
|
||||
* not. A file picked from the device never leaves it, so the conversion happens here.
|
||||
*/
|
||||
export const LOCAL_SUBTITLE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
export const localSubtitleFormats = ['srt', 'vtt', 'ass', 'ssa'] as const;
|
||||
export type LocalSubtitleFormat = typeof localSubtitleFormats[number];
|
||||
|
||||
export class SubtitleFormatError extends Error {
|
||||
constructor(message = '无法读取这个字幕文件,请选择 SRT、VTT 或 ASS 字幕。') { super(message); this.name = 'SubtitleFormatError'; }
|
||||
}
|
||||
|
||||
export function subtitleFormatOf(name: string): LocalSubtitleFormat | null {
|
||||
const ext = /\.([a-z0-9]+)$/i.exec(name)?.[1]?.toLowerCase();
|
||||
return localSubtitleFormats.includes(ext as LocalSubtitleFormat) ? ext as LocalSubtitleFormat : null;
|
||||
}
|
||||
|
||||
/** UTF-16 by BOM, then strict UTF-8, then GB18030 — the encodings Chinese subtitle files come in. */
|
||||
export function decodeSubtitleBytes(bytes: Uint8Array): string {
|
||||
if (!bytes.length || bytes.length > LOCAL_SUBTITLE_MAX_BYTES) throw new SubtitleFormatError();
|
||||
try {
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder('utf-16le', { fatal: true }).decode(bytes);
|
||||
if (bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder('utf-16be', { fatal: true }).decode(bytes);
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
try { return new TextDecoder('gb18030', { fatal: true }).decode(bytes); }
|
||||
catch { throw new SubtitleFormatError('字幕文件的编码不受支持。'); }
|
||||
}
|
||||
}
|
||||
|
||||
function seconds(value: string): number | null {
|
||||
const match = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?$/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
const [, h = '0', m, s, fraction = '0'] = match;
|
||||
return ((Number(h) * 60 + Number(m)) * 60 + Number(s)) * 1000 + Number(fraction.padEnd(3, '0'));
|
||||
}
|
||||
function stamp(ms: number): string {
|
||||
const pad = (value: number, width = 2) => String(value).padStart(width, '0');
|
||||
return `${pad(Math.floor(ms / 3600000))}:${pad(Math.floor(ms / 60000) % 60)}:${pad(Math.floor(ms / 1000) % 60)}.${pad(ms % 1000, 3)}`;
|
||||
}
|
||||
|
||||
/** Throws SubtitleFormatError for an unknown format, an empty or oversized file, or no cues. */
|
||||
export function subtitleTextToVtt(text: string, format: LocalSubtitleFormat): string {
|
||||
if (!text || text.length > LOCAL_SUBTITLE_MAX_BYTES) throw new SubtitleFormatError();
|
||||
const source = text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
|
||||
if (source.includes('\0')) throw new SubtitleFormatError();
|
||||
const cues: Array<{ start: number; end: number; text: string }> = [];
|
||||
const entities: Record<string, string> = { '&': '&', '<': '<', '>': '>', ' ': ' ' };
|
||||
function add(start: number | null, end: number | null, body: string) {
|
||||
if (start === null || end === null || end <= start || end > 24 * 3600 * 1000) return;
|
||||
const plain = body.replace(/<[^>]*>/g, '').replace(/&(amp|lt|gt|nbsp);/g, entity => entities[entity]!)
|
||||
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '').trim();
|
||||
if (plain) cues.push({ start, end, text: plain });
|
||||
if (cues.length > 20000) throw new SubtitleFormatError('字幕文件过大。');
|
||||
}
|
||||
if (format === 'ass' || format === 'ssa') {
|
||||
let events = false, columns: string[] = [];
|
||||
for (const line of source.split('\n')) {
|
||||
if (/^\[/.test(line.trim())) { events = /^\[events\]$/i.test(line.trim()); continue; }
|
||||
if (!events) continue;
|
||||
if (/^format:/i.test(line)) { columns = line.slice(line.indexOf(':') + 1).split(',').map(value => value.trim().toLowerCase()); continue; }
|
||||
if (!/^dialogue:/i.test(line) || columns.at(-1) !== 'text') continue;
|
||||
const values = line.slice(line.indexOf(':') + 1).split(',');
|
||||
if (values.length < columns.length) continue;
|
||||
const body = values.slice(columns.length - 1).join(',');
|
||||
if (/\{[^}]*\\p[1-9]/i.test(body)) continue;
|
||||
add(seconds(values[columns.indexOf('start')] || ''), seconds(values[columns.indexOf('end')] || ''), body.replace(/\{[^}]*\}/g, '').replace(/\\[Nn]/g, '\n').replace(/\\h/g, ' '));
|
||||
}
|
||||
} else {
|
||||
for (const block of source.split(/\n[ \t]*\n/)) {
|
||||
const lines = block.trim().split('\n');
|
||||
if (/^(?:WEBVTT|NOTE|STYLE|REGION)(?:\s|$)/.test(lines[0] || '')) continue;
|
||||
const index = lines.findIndex(line => line.includes('-->'));
|
||||
if (index < 0 || index > 1) continue;
|
||||
const timing = /^(\S+)\s+-->\s+(\S+)/.exec(lines[index]!);
|
||||
if (timing) add(seconds(timing[1]!), seconds(timing[2]!), lines.slice(index + 1).join('\n'));
|
||||
}
|
||||
}
|
||||
if (!cues.length) throw new SubtitleFormatError();
|
||||
return 'WEBVTT\n\n' + cues.map((cue, index) => `${index + 1}\n${stamp(cue.start)} --> ${stamp(cue.end)}\n${cue.text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}\n`).join('\n');
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
export const mediaThemes = [
|
||||
{id:'forest',name:'原野',description:'柔和米白 · 橄榄绿',dark:false,colors:['#f8f9f5','#ffffff','#edf0e8','#202b24','#66715f','#dfe5d9','#466638','#ffffff','#e6eddf']},
|
||||
{id:'sand',name:'暖砂',description:'暖纸底色 · 赤陶棕',dark:false,colors:['#fbf7ee','#fffdf8','#f0e8dc','#382b21','#796751','#e3d6c4','#865333','#ffffff','#efe0cf']},
|
||||
{id:'ocean',name:'雾蓝',description:'清透灰白 · 湖水蓝',dark:false,colors:['#f3f7fa','#ffffff','#e6eff4','#20343d','#5c7380','#d4e1e8','#2b637c','#ffffff','#dbeaf2']},
|
||||
{id:'rose',name:'蔷薇',description:'轻盈粉白 · 梅子红',dark:false,colors:['#faf5f7','#ffffff','#f1e5eb','#3b2c34','#7d6470','#e4d5dc','#894d6a','#ffffff','#efdeea']},
|
||||
{id:'midnight',name:'深海',description:'静谧深绿 · 苔绿',dark:true,colors:['#141b19','#1d2722','#25312a','#eef2e9','#a3b49f','#354439','#b6ce9d','#23311d','#30402b']},
|
||||
{id:'graphite',name:'石墨',description:'纯粹深灰 · 银白',dark:true,colors:['#191c20','#23272c','#2e333a','#f1f3f5','#b0b7c1','#40464f','#c2cedc','#1f2832','#343f4c']},
|
||||
] as const;
|
||||
export type MediaThemeId = 'system' | typeof mediaThemes[number]['id'];
|
||||
const tokens=['background','paper','surface','text','muted','border','accent','on-accent','selection'] as const;
|
||||
const storageKey=(scope:string)=>'reader.media.theme.v1:'+scope;
|
||||
const themeChangeEvent='reader-media-theme-change';
|
||||
export function validMediaTheme(value:unknown):MediaThemeId{return value==='system'||mediaThemes.some(theme=>theme.id===value)?value as MediaThemeId:'system';}
|
||||
export function readMediaTheme(scope:string):MediaThemeId{try{return validMediaTheme(localStorage.getItem(storageKey(scope)));}catch{return 'system';}}
|
||||
export function saveMediaTheme(scope:string,value:MediaThemeId){
|
||||
const next=validMediaTheme(value);
|
||||
try{localStorage.setItem(storageKey(scope),next);}catch{throw new Error('设备无法保存主题,请检查浏览器存储空间或隐私设置。');}
|
||||
// Storage events do not fire in the tab that made the change. Notify the
|
||||
// active media shell explicitly so a theme choice is applied immediately.
|
||||
if(typeof window!=='undefined') window.dispatchEvent(new CustomEvent(themeChangeEvent,{detail:{scope,value:next}}));
|
||||
}
|
||||
export function mediaThemePalette(id:MediaThemeId,dark=false){return mediaThemes.find(theme=>theme.id===(id==='system'?(dark?'graphite':'forest'):id))!;}
|
||||
|
||||
/** Only prefixed media variables are placed on the body; reading tokens remain untouched. */
|
||||
export class MediaThemeController {
|
||||
current:MediaThemeId;
|
||||
private readonly system=typeof window.matchMedia==='function'?window.matchMedia('(prefers-color-scheme: dark)'):undefined;
|
||||
constructor(private readonly scope:string,private readonly onChanged:()=>void){
|
||||
this.current=readMediaTheme(scope);this.apply();
|
||||
this.system?.addEventListener('change',this.onSystem);
|
||||
window.addEventListener('storage',this.onStorage);
|
||||
window.addEventListener(themeChangeEvent,this.onThemeChange);
|
||||
}
|
||||
set(value:MediaThemeId){this.current=validMediaTheme(value);this.apply();this.onChanged();}
|
||||
private onSystem=()=>{if(this.current==='system'){this.apply();this.onChanged();}};
|
||||
private onStorage=(event:StorageEvent)=>{if(event.key===null||event.key===storageKey(this.scope))this.set(readMediaTheme(this.scope));};
|
||||
private onThemeChange=(event:Event)=>{
|
||||
const detail=(event as CustomEvent<{scope?:unknown;value?:unknown}>).detail;
|
||||
if(detail?.scope===this.scope)this.set(validMediaTheme(detail.value));
|
||||
};
|
||||
private apply(){
|
||||
const theme=mediaThemePalette(this.current,this.system?.matches);
|
||||
document.body.dataset.mediaTheme=this.current;
|
||||
tokens.forEach((token,index)=>document.body.style.setProperty('--media-theme-'+token,theme.colors[index]!));
|
||||
document.body.style.setProperty('--media-theme-scheme',theme.dark?'dark':'light');
|
||||
document.body.style.setProperty('--media-theme-error-ink',theme.dark?'#f4a89d':'#943c32');
|
||||
document.body.style.setProperty('--media-theme-error-background',theme.dark?'#f49a8c18':'#a4504515');
|
||||
}
|
||||
dispose(){
|
||||
this.system?.removeEventListener('change',this.onSystem);window.removeEventListener('storage',this.onStorage);window.removeEventListener(themeChangeEvent,this.onThemeChange);
|
||||
delete document.body.dataset.mediaTheme;
|
||||
tokens.forEach(token=>document.body.style.removeProperty('--media-theme-'+token));
|
||||
for(const token of ['scheme','error-ink','error-background'])document.body.style.removeProperty('--media-theme-'+token);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
/* Theme aliases are scoped to media roots; reading tokens are consumed, never overwritten. */
|
||||
.media-screen,.media-player{--text:var(--media-theme-text,var(--reader-ink));--surface:var(--media-theme-surface,var(--reader-surface));--border:var(--media-theme-border,var(--reader-line));--media-accent:var(--media-theme-accent,#4d682f);--media-on-accent:var(--media-theme-on-accent,#fff);--media-selection:var(--media-theme-selection,#e2ead8);--media-error-background:var(--media-theme-error-background,#a4504515);--media-error-ink:var(--media-theme-error-ink,#943c32);color:var(--text);color-scheme:var(--media-theme-scheme,light)}
|
||||
[data-theme=dark] .media-screen,[data-theme=dark] .media-player{--media-accent:#adc994;--media-on-accent:#18240e;--media-selection:#2c3826;--media-error-background:#f49a8c18;--media-error-ink:#f4a89d;color-scheme:dark}
|
||||
/* Theme aliases live in presentation.css; reading tokens are consumed, never overwritten. */
|
||||
.media-screen{max-width:1100px;margin:0 auto;padding:20px 24px 100px;color:var(--text,#30392c)}.media-screen button,.media-screen input,.media-screen select,.media-screen textarea{font:inherit;color:inherit;background:var(--surface,#f0f0e8);border:1px solid var(--border,#dce2d4);border-radius:7px;padding:9px 12px;min-height:40px}.media-screen button{cursor:pointer}.media-screen button:disabled{opacity:.45;cursor:default}.media-screen h1{font-size:23px}.media-screen h2{font-size:17px;margin-top:24px}.media-heading,.media-toolbar,.media-search,.media-row{display:flex;gap:12px;align-items:center;justify-content:space-between;margin:12px 0}.media-heading h1{margin:0}.media-search input{flex:1;min-width:0}.media-tabs{display:flex;gap:20px;border-bottom:1px solid var(--border,#dce2d4);margin:18px 0}.media-tabs button{border:0;background:transparent;border-radius:0}.media-tabs button[aria-current=page]{border-bottom:3px solid var(--media-accent);color:var(--media-accent)}.media-grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:22px 17px}.media-grid .media-tile{padding:0;border:0;background:transparent;text-align:left;min-width:0}.media-cover{display:flex;aspect-ratio:2/3;background:linear-gradient(145deg,#6b8060,#384d43);border-radius:5px 9px 9px 5px;align-items:center;justify-content:center;padding:15px;color:#f5f0df;font:22px/1.7 Georgia,serif;overflow-wrap:anywhere;text-align:center}.media-cover.square{aspect-ratio:1}.media-tile strong,.media-tile small{display:block;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;margin-top:7px}.media-tile strong{font-weight:500;font-size:13px}.media-tile small,.media-row small{font-size:12px;opacity:.65;display:block}.media-hero{display:flex;gap:24px;align-items:center}.media-hero .media-cover{width:140px;min-width:140px}.media-row{border-bottom:1px solid var(--border,#dce2d4);padding:10px 0}.media-row>span{flex:1;min-width:0;overflow-wrap:anywhere}.media-form{display:grid;gap:14px;max-width:560px;margin:20px 0}.media-form label{display:grid;gap:7px}.media-form textarea{min-height:110px}.media-empty{padding:60px 15px;text-align:center;opacity:.7}.media-error{padding:12px;background:var(--media-error-background);color:var(--media-error-ink);border-radius:8px}.media-player{max-width:1100px;margin:0 auto;padding:10px 20px 80px}.media-player[hidden]{display:none}.media-player audio{width:100%}.media-player video{width:100%;max-height:65vh;background:#101510}.media-player [hidden]{display:none}@media(max-width:600px){.media-screen{padding:16px 14px 80px}.media-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:17px 12px}.media-cover{font-size:18px}.media-hero .media-cover{width:105px;min-width:105px}.media-hero{gap:15px}}
|
||||
.media-cover.has-image{padding:0;overflow:hidden}.media-cover img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.media-cover.media-portrait{aspect-ratio:1;border-radius:50%;overflow:hidden}
|
||||
@@ -31,7 +29,7 @@
|
||||
.media-mini{display:flex;align-items:center;gap:8px;min-height:44px}.media-mini button{font:inherit;color:inherit;background:transparent;border:0;min-height:44px;padding:8px;cursor:pointer}.media-mini-title{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:left}.media-mini button:last-child{min-width:44px;font-size:20px}
|
||||
:is(.media-player-audio,.media-player-native-video):not(.media-player-expanded)>audio,:is(.media-player-audio,.media-player-native-video):not(.media-player-expanded)>label,:is(.media-player-audio,.media-player-native-video):not(.media-player-expanded)>div:not(.media-mini){display:none}.media-screen{padding-bottom:230px}
|
||||
@media(max-width:600px){.media-player:is(.media-player-audio,.media-player-native-video){bottom:calc(66px + env(safe-area-inset-bottom));left:10px;right:10px;padding:4px 8px;border-radius:10px}.media-screen{padding-bottom:240px}}
|
||||
.media-mini{flex-wrap:wrap;gap:0 8px}.media-mini .media-mini-toggle{min-width:44px;font-size:20px}.media-mini-time{font-size:11px;opacity:.7;font-variant-numeric:tabular-nums;flex-basis:100%;padding:0 8px 5px}.media-mini-progress{width:100%;height:3px;border:0;accent-color:var(--media-accent)}.media-mini-progress::-webkit-progress-bar{background:var(--border,#dce2d4)}.media-mini-progress::-webkit-progress-value{background:var(--media-accent)}.media-playback-support{font-size:12px;line-height:1.6}.media-playback-support summary{cursor:pointer;padding:6px 0}
|
||||
.media-mini{flex-wrap:wrap;gap:0 8px}.media-mini .media-mini-toggle{min-width:44px;font-size:20px}.media-mini-time{font-size:11px;opacity:.7;font-variant-numeric:tabular-nums;flex-basis:100%;padding:0 8px 5px}.media-mini-progress{width:100%;height:3px;border:0;accent-color:var(--media-accent)}.media-mini-progress::-webkit-progress-bar{background:var(--border,#dce2d4)}.media-mini-progress::-webkit-progress-value{background:var(--media-fill)}.media-playback-support{font-size:12px;line-height:1.6}.media-playback-support summary{cursor:pointer;padding:6px 0}
|
||||
.media-current-playlist{min-width:0}.media-current-playlist summary{cursor:pointer;padding:8px 0}.media-current-playlist label{display:grid;gap:6px;margin:10px 0}.media-current-playlist input{min-width:0;width:100%;box-sizing:border-box}.media-current-playlist ol{list-style:none;padding:0;max-height:320px;overflow:auto}.media-current-playlist li button{width:100%;text-align:left;margin:3px 0;overflow-wrap:anywhere}.media-current-playlist button[aria-current]{background:var(--media-selection);color:var(--media-accent)}.media-current-playlist nav{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||||
.media-continue{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 12px;align-items:center;padding:10px 12px;margin:8px 0;border:1px solid var(--border,#dce2d4);border-radius:10px}.media-continue>div{min-width:0;display:grid;gap:3px}.media-continue small,.media-continue span{font-size:11px;opacity:.7}.media-continue strong,.media-continue span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.media-continue strong{font-size:14px}.media-continue progress{width:100%;height:3px;accent-color:var(--media-accent)}.media-continue>p{grid-column:1/-1;margin:0;font-size:12px}
|
||||
.media-player:not(.media-player-expanded){position:fixed;bottom:80px;left:24px;right:24px;z-index:75;max-width:680px;padding:8px 12px;border:1px solid var(--border,#dce2d4);border-radius:12px;background:var(--surface,#f0f0e8);color:var(--text,#30392c);box-shadow:0 4px 20px #0002}.media-player:not(.media-player-expanded)>:not(.media-mini){display:none}@media(max-width:600px){.media-player:not(.media-player-expanded){bottom:calc(66px + env(safe-area-inset-bottom));left:10px;right:10px;padding:4px 8px;border-radius:10px}}
|
||||
@@ -72,9 +70,9 @@
|
||||
.media-edition{margin-top:20px;border-top:1px solid var(--border);padding-top:4px}.media-edition>h2{overflow-wrap:anywhere;margin-bottom:8px}.media-edition>.media-toolbar{font-size:12px;margin:8px 0}.media-edition>.media-row>span{font-size:14px;line-height:1.5}.media-edition>.media-row small{font-variant-numeric:tabular-nums;margin-top:3px}.media-edition>.media-row>button{color:var(--media-accent);background:var(--media-selection);border-color:transparent;min-width:64px;font-size:13px}.media-edition-admin{margin:20px 0 8px;border:1px solid var(--border);border-radius:10px;background:var(--surface);padding:12px}.media-edition-admin>summary{font-size:13px;font-weight:500;cursor:pointer;padding:2px 0}.media-edition-admin[open]>summary{margin-bottom:8px}.media-edition-admin>details>summary{padding:10px 0;font-size:13px;cursor:pointer}.media-edition-admin>button{margin-top:8px;font-size:13px}.media-edition-admin .media-toolbar{flex-wrap:wrap}
|
||||
.media-manager-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin:20px 0 16px}.media-manager-heading h1{margin:0}.media-manager-heading>span{font-size:12px;opacity:.65;white-space:nowrap}
|
||||
.media-manager .media-library-row{padding:14px;border:1px solid var(--border,#dce2d4);border-radius:12px;background:var(--surface,#f0f0e8);gap:8px}.media-library-row strong{font-size:15px;font-weight:600}.media-library-row small{margin-top:5px}.media-manager .media-library-row button{background:color-mix(in srgb,var(--surface) 96%,var(--text));min-height:40px;font-size:13px}
|
||||
.media-manager .media-library-create,.media-manager .media-library-editor{border:1px solid var(--border,#dce2d4);border-radius:12px;padding:16px;margin:18px 0;background:var(--surface,#f0f0e8);box-sizing:border-box;max-width:100%}.media-manager .media-library-create h2,.media-manager .media-library-editor h2{margin:0 0 14px;font-size:16px}.media-manager .media-form{max-width:100%;margin:0;gap:12px}.media-manager .media-form label{font-size:13px;gap:6px}.media-manager .media-form input,.media-manager .media-form select{width:100%;box-sizing:border-box;background:color-mix(in srgb,var(--surface) 96%,var(--text));min-width:0}.media-manager .media-form small{font-size:12px;line-height:1.6;opacity:.7}.media-manager .media-form button[type=submit]{background:var(--media-accent);color:var(--media-on-accent);border-color:var(--media-accent);font-weight:600}
|
||||
.media-manager .media-library-create,.media-manager .media-library-editor{border:1px solid var(--border,#dce2d4);border-radius:12px;padding:16px;margin:18px 0;background:var(--surface,#f0f0e8);box-sizing:border-box;max-width:100%}.media-manager .media-library-create h2,.media-manager .media-library-editor h2{margin:0 0 14px;font-size:16px}.media-manager .media-form{max-width:100%;margin:0;gap:12px}.media-manager .media-form label{font-size:13px;gap:6px}.media-manager .media-form input,.media-manager .media-form select{width:100%;box-sizing:border-box;background:color-mix(in srgb,var(--surface) 96%,var(--text));min-width:0}.media-manager .media-form small{font-size:12px;line-height:1.6;opacity:.7}.media-manager .media-form button[type=submit]{background:var(--media-fill);color:var(--media-on-accent);border-color:var(--media-fill);font-weight:600}
|
||||
@media(min-width:760px){.media-manager .media-library-row>span{flex-basis:auto}.media-manager .media-library-row button{flex:0 0 auto;min-width:66px}.media-manager .media-library-create,.media-manager .media-library-editor{max-width:720px}.media-manager .media-library-create .media-permission-fields{grid-template-columns:1fr 1fr;gap:14px 18px}.media-manager .media-library-create label:nth-child(2){grid-column:1/-1;grid-row:2}.media-manager .media-form button[type=submit]{justify-self:start;min-width:150px}}
|
||||
.media-album-playback .media-album-summary>button{font-size:12px;padding:4px 8px}.media-album-playback .media-album-actions>.media-primary{background:var(--media-accent);color:var(--media-on-accent);border-color:var(--media-accent)}
|
||||
.media-album-playback .media-album-summary>button{font-size:12px;padding:4px 8px}.media-album-playback .media-album-actions>.media-primary{background:var(--media-fill);color:var(--media-on-accent);border-color:var(--media-fill)}
|
||||
.media-album-summary{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;min-height:24px}.media-album-summary>button{font-size:12px;padding:4px 8px}.media-album-actions{justify-content:flex-start;gap:8px}.media-album-start{font-size:12px;line-height:1.5}.media-album-playback .media-season-list{gap:0}.media-album-playback .media-album-disc{font-size:12px;margin:18px 0 4px;color:var(--muted);font-weight:500}.media-album-playback .media-season-row{padding:2px 0;gap:4px}.media-album-playback .media-season-row[data-selected=true]{background:var(--media-selection);border-radius:6px}.media-album-playback .media-album-track-link{display:flex;align-items:center;gap:8px;min-width:0;background:transparent;border:0;padding:8px 6px;min-height:44px}.media-album-number{flex:0 0 22px;text-align:center;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}.media-album-track-title{flex:1;min-width:0;overflow-wrap:anywhere;font-size:14px}.media-album-track-title>small{display:block;font-size:11px;color:var(--muted);margin-top:3px}.media-album-duration{flex-shrink:0;font-size:12px;font-variant-numeric:tabular-nums;color:var(--muted)}
|
||||
.media-grid.media-season-picker{grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.media-season-picker .media-tile{display:grid;grid-template-columns:42px minmax(0,1fr);align-content:center;align-items:center;column-gap:10px;padding:8px;border:1px solid var(--border);border-radius:8px;background:var(--surface);min-height:80px}.media-season-picker .media-cover{grid-row:1 / span 3;width:42px;height:63px;min-width:42px;padding:3px;box-sizing:border-box;font-size:10px;line-height:1.4}.media-season-picker .media-cover-title{-webkit-line-clamp:3}.media-season-picker .media-tile>strong{margin:0;white-space:normal;overflow-wrap:anywhere;font-size:13px}.media-season-picker .media-tile>small{grid-column:2;margin-top:4px;min-width:0}.media-season-picker .media-tile>small:empty{display:none}@media(max-width:700px){.media-grid.media-season-picker{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}}
|
||||
.media-season-playback .media-toolbar>button{flex-shrink:0;white-space:nowrap}.media-season-playback .media-toolbar>span{min-width:0;overflow-wrap:anywhere}
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
/* Visual contract from docs/prototypes/media/prototype.css.
|
||||
Scoped to media: reading retains its own typography, palette and layout. */
|
||||
.media-screen, .media-player, .media-channel-entry {
|
||||
--text: #30392c;
|
||||
--surface: #f0f0e8;
|
||||
--media-paper: #fffefb;
|
||||
--media-background: #faf9f5;
|
||||
--border: #e1e4d8;
|
||||
--muted: #777e6e;
|
||||
--media-accent: #516c3d;
|
||||
--media-on-accent: #faf9f5;
|
||||
--media-selection: #e7eddd;
|
||||
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
/* Media aliases for the theme tokens (styles/theme-tokens.css). --media-accent is
|
||||
brand *text* (links, selected icons, outlines); --media-fill is a brand-filled
|
||||
control and --media-on-accent what sits on it. Dialogs and confirmations opened
|
||||
from media read the same names. */
|
||||
.media-screen, .media-player, .media-channel-entry, .media-modal {
|
||||
--text: var(--color-ink);
|
||||
--muted: var(--color-muted);
|
||||
--surface: var(--color-surface-2);
|
||||
--border: var(--color-line);
|
||||
--media-paper: var(--color-surface);
|
||||
--media-background: var(--color-bg);
|
||||
--media-accent: var(--color-brand-text);
|
||||
--media-fill: var(--color-brand);
|
||||
--media-on-accent: var(--color-on-brand);
|
||||
--media-selection: var(--color-brand-soft);
|
||||
--media-error-ink: var(--color-danger);
|
||||
--media-error-background: var(--color-danger-soft);
|
||||
--reader-accent: var(--color-brand-text); --reader-ink: var(--text); --reader-muted: var(--muted); --reader-line: var(--border);
|
||||
--reader-paper: var(--media-paper); --reader-surface: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
[data-theme=dark] .media-screen, [data-theme=dark] .media-player, [data-theme=dark] .media-channel-entry {
|
||||
--text: #e9eedf;
|
||||
--surface: #293025;
|
||||
--media-paper: #242b20;
|
||||
--media-background: #1e241c;
|
||||
--border: #3b4433;
|
||||
--muted: #a4af98;
|
||||
--media-accent: #b1c69b;
|
||||
--media-on-accent: #1e241c;
|
||||
--media-selection: #38472c;
|
||||
.media-screen, .media-player, .media-channel-entry {
|
||||
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
[data-theme=dark] body:has(>#app>.media-screen) { background: #1e241c; }
|
||||
body:has(>#app>.media-screen) { background: var(--color-bg); }
|
||||
.media-screen { max-width:1120px; padding:62px 30px 150px; background:var(--media-background); }
|
||||
.media-screen button, .media-screen input, .media-screen select, .media-screen textarea { font:inherit; }
|
||||
.media-screen button { border:0; background:transparent; border-radius:7px; min-height:38px; padding:9px 12px; }
|
||||
.media-screen button:hover:not(:disabled) { background:var(--surface); }
|
||||
/* The shared sheet's header and footer use the app's own buttons, not media's. */
|
||||
.media-screen button:not(:where(.sheet-header *, .sheet-footer *, .sheet-list *, .action-menu *, .action-menu-trigger)) { border:0; background:transparent; border-radius:7px; min-height:38px; padding:9px 12px; }
|
||||
.media-screen button:not(:where(.sheet-header *, .sheet-footer *, .sheet-list *, .action-menu *, .action-menu-trigger)):hover:not(:disabled) { background:var(--surface); }
|
||||
.media-screen button:focus-visible, .media-channel-entry a:focus-visible { outline:2px solid var(--media-accent); outline-offset:3px; }
|
||||
.media-screen input:not([type=range]):not([type=checkbox]):not([type=radio]), .media-screen textarea {
|
||||
background:var(--media-paper); border:1px solid var(--border); border-radius:7px;
|
||||
@@ -53,7 +51,7 @@ body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
.media-browse-filters {position:relative}
|
||||
.media-browse-filters>summary {position:relative;border-radius:7px}
|
||||
.media-browse-filters[open]>summary {background:var(--media-selection);color:var(--media-accent)}
|
||||
.media-browse-filters i {position:absolute;top:6px;right:6px;width:5px;height:5px;border-radius:50%;background:var(--media-accent)}
|
||||
.media-browse-filters i {position:absolute;top:6px;right:6px;width:5px;height:5px;border-radius:50%;background:var(--media-fill)}
|
||||
.media-browse-filter-panel {position:absolute;z-index:10;right:0;top:46px;width:280px;max-width:calc(100vw - 32px);padding:16px;background:var(--media-paper);border:1px solid var(--border);border-radius:9px;box-shadow:0 8px 24px #0002;color:var(--text)}
|
||||
.media-browse-filter-panel>label {display:grid;gap:8px;font-size:12px}
|
||||
.media-browse-filter-panel .media-form {margin:14px 0 0;font-size:12px}
|
||||
@@ -112,8 +110,8 @@ body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
.media-cover.square {border-radius:6px}
|
||||
.media-hero {gap:26px;margin-top:24px}
|
||||
.media-hero .media-cover {width:170px;min-width:170px}
|
||||
.media-screen .media-primary {background:var(--media-accent);color:var(--media-on-accent)}
|
||||
.media-screen button.media-primary:hover:not(:disabled) {background:var(--media-accent);color:var(--media-on-accent);filter:brightness(.94)}
|
||||
.media-screen .media-primary {background:var(--media-fill);color:var(--media-on-accent)}
|
||||
.media-screen button.media-primary:hover:not(:disabled) {background:var(--media-fill);color:var(--media-on-accent);filter:brightness(.94)}
|
||||
.media-task-retry {display:inline-flex;align-items:center;justify-content:center;gap:5px;min-height:40px;padding:0 10px;border:1px solid var(--border);border-radius:8px;background:var(--media-selection);color:var(--media-accent);font-size:12px;font-weight:600}
|
||||
.media-task-retry:disabled {opacity:.5}
|
||||
@media(max-width:430px){.media-task-retry span{display:inline}.media-task-retry{min-width:64px}.media-scan-job .media-task-icon{min-width:40px}}
|
||||
@@ -131,7 +129,8 @@ body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
/* Audio player: the prototype's 420px column, square cover and 58px play control. */
|
||||
.media-playback-page {max-width:1120px}
|
||||
.media-playback-page .media-heading {position:relative;top:auto;justify-content:flex-start;gap:6px;z-index:4;border:0}
|
||||
.media-screen .media-back-button {width:40px;min-width:40px;height:44px;display:grid;place-items:center;padding:10px}
|
||||
.media-screen .media-back-button {width:40px;min-width:40px;height:44px;display:grid;place-items:center;padding:10px;color:inherit}
|
||||
.media-back-button>.icon {font-size:1.25rem}
|
||||
.media-playback-status {display:grid;gap:14px;padding:18px;max-width:600px;margin:24px auto;border:1px solid var(--border);border-radius:9px;background:var(--surface)}
|
||||
.media-playback-status p {margin:0}
|
||||
.media-playback-page:not(.media-video-page) .media-playback-status {max-width:420px;gap:0;padding:25px 25px 35px;margin:0 auto;border:0;background:transparent}
|
||||
@@ -160,8 +159,8 @@ body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
.media-player-expanded.media-player-audio {display:none}
|
||||
.media-transport {display:flex;align-items:center;justify-content:center;gap:25px;margin:18px 0}
|
||||
.media-screen .media-symbol-button {display:grid;place-items:center;width:44px;height:44px;padding:10px;flex-shrink:0;border:0;background:transparent;color:var(--text)}
|
||||
.media-screen .media-toggle-play {display:grid;place-items:center;width:58px;height:58px;min-height:58px;padding:0;flex-shrink:0;border:0;border-radius:50%;background:var(--media-accent);color:var(--media-on-accent)}
|
||||
.media-screen .media-toggle-play:hover {background:var(--media-accent);filter:brightness(.95)}
|
||||
.media-screen .media-toggle-play {display:grid;place-items:center;width:58px;height:58px;min-height:58px;padding:0;flex-shrink:0;border:0;border-radius:50%;background:var(--media-fill);color:var(--media-on-accent)}
|
||||
.media-screen .media-toggle-play:hover {background:var(--media-fill);filter:brightness(.95)}
|
||||
.media-toggle-play svg {width:25px;height:25px}
|
||||
.media-screen .media-skip {position:relative}
|
||||
.media-skip small {position:absolute;inset:18px 0 auto;text-align:center;font-size:8px;font-weight:600}
|
||||
@@ -204,11 +203,11 @@ body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
@media(prefers-reduced-motion:reduce) {.media-screen *, .media-channel-entry * {transition:none!important}}
|
||||
.media-inline-timeline .media-time-separator {display:none}
|
||||
.media-inline-timeline input[type=range] {appearance:none;cursor:pointer;height:20px;background:transparent}
|
||||
.media-inline-timeline input[type=range]::-webkit-slider-runnable-track {height:3px;border-radius:4px;background:linear-gradient(to right,var(--media-accent) var(--media-progress,0%),var(--border) var(--media-progress,0%))}
|
||||
.media-inline-timeline input[type=range]::-webkit-slider-thumb {appearance:none;width:10px;height:10px;border-radius:50%;background:var(--media-accent);margin-top:-3.5px}
|
||||
.media-inline-timeline input[type=range]::-webkit-slider-runnable-track {height:3px;border-radius:4px;background:linear-gradient(to right,var(--media-fill) var(--media-progress,0%),var(--border) var(--media-progress,0%))}
|
||||
.media-inline-timeline input[type=range]::-webkit-slider-thumb {appearance:none;width:10px;height:10px;border-radius:50%;background:var(--media-fill);margin-top:-3.5px}
|
||||
.media-inline-timeline input[type=range]::-moz-range-track {height:3px;border-radius:4px;background:var(--border)}
|
||||
.media-inline-timeline input[type=range]::-moz-range-progress {height:3px;background:var(--media-accent)}
|
||||
.media-inline-timeline input[type=range]::-moz-range-thumb {width:10px;height:10px;border:0;background:var(--media-accent)}
|
||||
.media-inline-timeline input[type=range]::-moz-range-progress {height:3px;background:var(--media-fill)}
|
||||
.media-inline-timeline input[type=range]::-moz-range-thumb {width:10px;height:10px;border:0;background:var(--media-fill)}
|
||||
.media-screen .media-transport:has(.media-skip) {gap:10px}
|
||||
.media-mobile-title {display:none}
|
||||
.media-screen .media-continue {grid-template-columns:39px minmax(0,1fr) auto;gap:12px;align-items:center}
|
||||
@@ -261,18 +260,14 @@ body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
.media-lyrics-empty h3 {margin:0;font-size:16px;font-weight:500;color:var(--text)}
|
||||
.media-lyrics-loading i {display:block;width:65%;height:12px;background:var(--border);border-radius:5px;opacity:.5}
|
||||
.media-lyrics-loading i:nth-last-child(2) {width:80%}
|
||||
.media-screen .media-audio-sheet {position:fixed;inset:auto 0 0;margin:0 auto;padding:20px 22px 25px;width:480px;max-width:100%;max-height:80dvh;box-sizing:border-box;overflow:auto;border:1px solid var(--border);border-bottom:0;border-radius:15px 15px 0 0;background:var(--media-paper);color:var(--text);font-size:14px;line-height:1.6;font-family:inherit;box-shadow:0 -10px 40px #0003}
|
||||
.media-audio-sheet::backdrop {background:#10160c66}
|
||||
.media-audio-sheet>header {display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
|
||||
.media-screen .media-audio-sheet h2 {font-size:16px;margin:0;font-weight:600}
|
||||
.media-audio-sheet>header>button {display:grid;place-items:center;width:44px;height:44px;padding:10px;border:0;background:transparent}
|
||||
.media-audio-sheet .sheet-body {font-size:14px;line-height:1.6}
|
||||
.media-audio-sheet .media-volume,.media-audio-speed>label {display:flex;align-items:center;gap:12px;font-size:13px;min-height:48px}
|
||||
.media-audio-sheet input[type=range] {flex:1;min-width:0;width:100%;accent-color:var(--media-accent);padding:0;border:0;background:transparent}
|
||||
.media-audio-speed select {flex:1;min-width:0}
|
||||
.media-audio-shortcuts {display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:8px 0}
|
||||
.media-audio-shortcuts button,.media-audio-sheet .media-end-playback {display:flex;gap:8px;align-items:center;justify-content:center}
|
||||
.media-audio-sheet>[aria-label="保存播放列表"] {margin:12px 0}
|
||||
.media-audio-sheet>[aria-label="保存播放列表"] button {margin:4px 6px 4px 0}
|
||||
.media-audio-sheet .sheet-body>[aria-label="保存播放列表"] {margin:12px 0}
|
||||
.media-audio-sheet .sheet-body>[aria-label="保存播放列表"] button {margin:4px 6px 4px 0}
|
||||
.media-audio-sheet p {font-size:12px;line-height:1.7;overflow-wrap:anywhere}
|
||||
.media-audio-sheet .media-audio-support {color:var(--muted);margin:12px 0}
|
||||
.media-audio-versions {display:grid;gap:8px}
|
||||
@@ -280,7 +275,6 @@ body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
.media-audio-versions strong {min-width:0;font-weight:500;overflow-wrap:anywhere}
|
||||
.media-audio-versions small {flex-shrink:0;font-size:12px;color:var(--muted)}
|
||||
.media-audio-versions button[aria-current=true] {border-color:var(--media-accent);background:var(--media-selection);opacity:1}
|
||||
@media(max-width:700px){.media-screen .media-audio-sheet{padding:16px 16px calc(20px + env(safe-area-inset-bottom))}}
|
||||
@media(max-width:700px){
|
||||
.media-screen .media-heading:has(.media-mobile-title) {display:grid;grid-template-columns:1fr auto;gap:0;border:0}
|
||||
.media-mobile-title {display:flex;align-items:center;min-height:49px;grid-row:1;grid-column:1}
|
||||
@@ -319,7 +313,7 @@ body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
.media-screen .media-library-empty>button {display:flex;align-items:center;justify-content:center;gap:7px;min-height:44px;padding:10px 18px;margin:24px 0 12px}
|
||||
.media-library-empty>small {font-size:11px;color:var(--muted);line-height:1.8}
|
||||
.media-library-onboarding {max-width:560px;margin:18px auto}
|
||||
.media-library-onboarding>.media-library-create>.media-form>button {background:var(--media-accent);color:var(--media-on-accent);min-height:44px}
|
||||
.media-library-onboarding>.media-library-create>.media-form>button {background:var(--media-fill);color:var(--media-on-accent);min-height:44px}
|
||||
|
||||
/* Settings follows the prototype's grouped, flat navigation rows. */
|
||||
.media-screen:has(>.media-settings)>.media-heading {display:none}
|
||||
@@ -384,8 +378,8 @@ body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
.media-task-state[data-state=queued] {background:var(--surface);color:var(--muted)}
|
||||
.media-task-progress {grid-column:1/-1;display:block;width:100%;height:5px;border:0;border-radius:4px;accent-color:var(--media-accent);overflow:hidden;margin:6px 0}
|
||||
.media-task-progress::-webkit-progress-bar {background:var(--border);border-radius:4px}
|
||||
.media-task-progress::-webkit-progress-value {background:var(--media-accent);border-radius:4px}
|
||||
.media-task-progress:indeterminate {background:linear-gradient(90deg,var(--border),var(--media-accent),var(--border));background-size:200% 100%;animation:media-scan-progress 2s linear infinite;appearance:none}
|
||||
.media-task-progress::-webkit-progress-value {background:var(--media-fill);border-radius:4px}
|
||||
.media-task-progress:indeterminate {background:linear-gradient(90deg,var(--border),var(--media-fill),var(--border));background-size:200% 100%;animation:media-scan-progress 2s linear infinite;appearance:none}
|
||||
.media-task-progress:indeterminate::-webkit-progress-bar {background:transparent}
|
||||
@keyframes media-scan-progress {to{background-position:-200% 0}}
|
||||
@media(prefers-reduced-motion:reduce){.media-task-progress:indeterminate{animation:none}}
|
||||
@@ -745,10 +739,10 @@ body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
.media-video-stage {aspect-ratio:16/9;background:#060906;display:flex;align-items:center;justify-content:center}
|
||||
.media-video-stage video {display:block;width:100%;height:100%;max-height:none;object-fit:contain}
|
||||
.media-video-workspace .media-video-controls {display:block;max-width:none;max-height:none;padding:15px 20px 30px;margin:0;border:0;border-radius:0;background:transparent;overflow:visible}
|
||||
.media-video-workspace button,.media-video-workspace select,.media-video-sheet button,.media-video-sheet input,.media-video-sheet select {font:inherit;color:inherit;box-sizing:border-box;min-height:44px;border:0;border-radius:7px;padding:9px 12px;background:transparent}
|
||||
.media-video-workspace button,.media-video-sheet button {cursor:pointer}
|
||||
.media-video-workspace button:disabled,.media-video-sheet button:disabled {opacity:.45;cursor:default}
|
||||
.media-video-workspace button:hover:not(:disabled),.media-video-sheet button:hover:not(:disabled) {background:var(--surface)}
|
||||
.media-video-workspace button,.media-video-workspace select,.media-video-sheet .sheet-body button,.media-video-sheet .sheet-body input,.media-video-sheet .sheet-body select {font:inherit;color:inherit;box-sizing:border-box;min-height:44px;border:0;border-radius:7px;padding:9px 12px;background:transparent}
|
||||
.media-video-workspace button,.media-video-sheet .sheet-body button {cursor:pointer}
|
||||
.media-video-workspace button:disabled,.media-video-sheet .sheet-body button:disabled {opacity:.45;cursor:default}
|
||||
.media-video-workspace button:hover:not(:disabled),.media-video-sheet .sheet-body button:hover:not(:disabled) {background:var(--surface)}
|
||||
.media-video-workspace :focus-visible,.media-video-sheet :focus-visible {outline:2px solid var(--media-accent);outline-offset:2px}
|
||||
.media-video-toolbar,.media-video-toolbar>div {display:flex;align-items:center;gap:6px;min-width:0}
|
||||
.media-video-toolbar {justify-content:space-between;margin:5px 0}
|
||||
@@ -768,13 +762,10 @@ body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
.media-playback-problem>p {font-size:12px;line-height:1.8;margin:0;max-width:480px}
|
||||
.media-playback-problem>div {display:flex;justify-content:center;gap:10px;flex-wrap:wrap;margin-top:4px}
|
||||
.media-playback-problem button {font-size:12px}
|
||||
.media-video-workspace .media-playback-problem .media-primary {background:var(--media-accent);color:#182010}
|
||||
.media-video-workspace .media-playback-problem .media-primary {background:var(--media-fill);color:var(--media-on-accent)}
|
||||
.media-video-controls .media-screen-error {margin:18px 0;font-size:12px}
|
||||
.media-video-controls .media-screen-error-actions {display:flex;gap:8px}
|
||||
.media-video-sheet {position:fixed;inset:auto 0 0;margin:0 auto;padding:20px 22px 25px;border:1px solid var(--border);border-bottom:0;border-radius:15px 15px 0 0;width:540px;max-width:100%;max-height:80dvh;box-sizing:border-box;overflow:auto;background:var(--media-paper);color:var(--text);font:14px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif;box-shadow:0 -10px 50px #0004}
|
||||
.media-video-sheet::backdrop {background:#10160c88}
|
||||
.media-video-sheet header {display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}
|
||||
.media-video-sheet h2 {font-size:17px;margin:0;font-weight:600}
|
||||
.media-video-sheet .sheet-body {font-size:14px;line-height:1.6}
|
||||
.media-video-sheet p {font-size:12px;line-height:1.75;color:var(--muted);overflow-wrap:anywhere}
|
||||
.media-video-sheet .media-volume,.media-video-sleep {display:flex;align-items:center;justify-content:space-between;gap:16px}
|
||||
.media-video-sheet .media-volume input {flex:1;width:100%;min-width:0;accent-color:var(--media-accent);padding:0}
|
||||
@@ -798,7 +789,6 @@ body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
.media-player.media-player-expanded:not(:is(.media-player-audio,.media-player-native-video)) {inset:49px 0 0;width:100%}
|
||||
.media-video-stage {aspect-ratio:16/10}
|
||||
.media-video-workspace .media-video-controls {padding:13px 13px 25px}
|
||||
.media-video-sheet {padding:16px 16px calc(20px + env(safe-area-inset-bottom))}
|
||||
}
|
||||
@media(min-width:700px) and (max-height:500px){
|
||||
.media-video-workspace {display:grid;grid-template-columns:58% minmax(0,1fr);align-items:start}
|
||||
@@ -844,19 +834,7 @@ body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
.media-permission-pagination {font-size:12px;color:var(--muted);margin:0}
|
||||
.media-user-option>span {min-width:0;overflow-wrap:anywhere;line-height:1.6}
|
||||
.media-user-option>input {flex-shrink:0}
|
||||
.media-provider-settings {padding-top:24px}
|
||||
.media-provider-settings>h2 {margin:0 0 4px;font-size:16px}
|
||||
.media-provider-settings>h2:not(:first-child) {margin-top:28px}
|
||||
.media-screen .media-provider-row {display:flex;align-items:center;width:100%;gap:12px;min-height:70px;padding:13px 0;border-bottom:1px solid var(--border);border-radius:0;text-align:left}
|
||||
.media-provider-row>svg {flex-shrink:0;color:var(--muted)}
|
||||
.media-provider-row>span {flex:1;min-width:0;overflow-wrap:anywhere}
|
||||
.media-provider-row strong {font-size:14px;font-weight:500}
|
||||
.media-provider-row span>small {display:block;color:var(--muted);font-size:12px;margin-top:4px}
|
||||
.media-status-badge {display:inline-block;font-size:10px;background:var(--media-selection);color:var(--media-accent);padding:3px 6px;border-radius:4px;white-space:nowrap}
|
||||
.media-provider-note {background:var(--surface);border-radius:8px;padding:14px;font-size:12px;color:var(--muted);line-height:1.8;margin:12px 0}
|
||||
.media-provider-note h3 {margin:0;color:var(--text);font-size:14px}
|
||||
.media-provider-note code {overflow-wrap:anywhere;font-size:12px}
|
||||
.media-source-attribution {font-size:11px;color:var(--muted);line-height:1.8;margin-top:24px}
|
||||
.media-account-panel {max-width:640px;margin:32px auto}
|
||||
.media-account-avatar {display:grid;place-items:center;width:60px;height:60px;border-radius:50%;background:var(--media-selection);color:var(--media-accent);font-size:26px}
|
||||
.media-account-panel h2 {font-size:23px;margin:16px 0 4px;overflow-wrap:anywhere}
|
||||
@@ -907,7 +885,7 @@ body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
.media-channel-switcher>summary svg {color:var(--muted)}
|
||||
.media-channel-switcher nav {left:0}
|
||||
.media-mini .media-mini-toggle,.media-mini .media-mini-close {display:grid;place-items:center;width:40px;min-width:40px;height:40px;min-height:40px;padding:8px;border-radius:50%}
|
||||
.media-mini .media-mini-toggle {background:var(--media-accent);color:var(--media-background)}
|
||||
.media-mini .media-mini-toggle {background:var(--media-fill);color:var(--media-on-accent)}
|
||||
.media-mini .media-mini-close {color:var(--muted)}
|
||||
.media-mini .media-mini-close:hover {background:var(--border)}
|
||||
|
||||
@@ -915,7 +893,6 @@ body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
.media-screen .media-resource-panel>header h2 {margin:0;font-size:16px}
|
||||
.media-resource-panel>p {font-size:12px;line-height:1.8;color:var(--muted);margin:8px 0 20px}
|
||||
.media-resource-panel .media-actions nav button {display:flex;align-items:center;gap:8px}
|
||||
.media-resource-panel .source-modal {width:min(620px,calc(100vw - 28px));max-height:85dvh;padding:20px;color:var(--text);background:var(--media-paper);border:1px solid var(--border);border-radius:12px}
|
||||
.media-resource-dialog {overflow-wrap:anywhere;font-size:13px;line-height:1.8}
|
||||
.media-resource-expanded {padding:16px 0;border-bottom:1px solid var(--border)}
|
||||
.media-resource-expanded:last-child {border-bottom:0}
|
||||
@@ -925,50 +902,11 @@ body:has(>#app>.media-screen) { background: #faf9f5; }
|
||||
.media-search-count {font-size:12px;color:var(--muted);margin:12px 0}
|
||||
|
||||
/* Resource dialogs: one compact header, with a separately scrolling body. */
|
||||
.media-resource-panel .source-modal {padding:0;box-sizing:border-box}
|
||||
.media-resource-panel .source-modal-header {min-height:56px;box-sizing:border-box;padding:6px 12px 6px 18px;gap:12px;border-color:var(--border)}
|
||||
.media-resource-panel .source-modal-header h2 {margin:0;line-height:24px;font-size:16px}
|
||||
.media-resource-panel .source-modal-header button {display:grid;place-items:center;width:44px;height:44px;min-height:44px;padding:10px;flex:none;color:var(--text)}
|
||||
.media-resource-dialog {padding:4px 18px 16px;overflow-y:auto;min-height:0;overscroll-behavior:contain}
|
||||
.media-resource-dialog .media-edition-tools {margin:0}
|
||||
.media-resource-dialog .media-edition-admin {border:0;padding:0}
|
||||
|
||||
/* Media themes never set the reading surface's tokens. */
|
||||
body[data-media-theme] {background:var(--media-theme-background)}
|
||||
body[data-media-theme] :is(.media-screen,.media-player,.media-channel-entry) {
|
||||
--text:var(--media-theme-text);--muted:var(--media-theme-muted);--surface:var(--media-theme-surface);
|
||||
--border:var(--media-theme-border);--media-paper:var(--media-theme-paper);--media-background:var(--media-theme-background);
|
||||
--media-accent:var(--media-theme-accent);--media-on-accent:var(--media-theme-on-accent);--media-selection:var(--media-theme-selection);
|
||||
--media-error-ink:var(--media-theme-error-ink);--media-error-background:var(--media-theme-error-background);
|
||||
--reader-accent:var(--media-accent);--reader-ink:var(--text);--reader-muted:var(--muted);--reader-line:var(--border);--reader-paper:var(--media-paper);--reader-surface:var(--surface);
|
||||
color-scheme:var(--media-theme-scheme);
|
||||
}
|
||||
body[data-media-theme]:has(>#app>.media-screen) {background:var(--media-theme-background)}
|
||||
.media-mini .media-mini-toggle {color:var(--media-on-accent)}
|
||||
.media-theme-settings {max-width:760px;margin:20px auto}
|
||||
.media-theme-settings>p {font-size:12px;line-height:1.8;color:var(--muted);margin:0 0 20px}
|
||||
.media-screen .media-theme-system {display:flex;align-items:center;gap:12px;width:100%;border:1px solid var(--border);padding:14px 16px;text-align:left;background:var(--media-paper)}
|
||||
.media-theme-system>span {flex:1;min-width:0}
|
||||
.media-theme-system strong,.media-theme-system small {display:block}
|
||||
.media-theme-system strong {font-weight:500;font-size:14px}
|
||||
.media-theme-system small {font-size:12px;color:var(--muted);margin-top:3px}
|
||||
.media-screen .media-theme-settings h2 {font-size:14px;margin:24px 0 12px}
|
||||
.media-theme-grid {display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
|
||||
.media-screen .media-theme-card {display:block;text-align:left;border:1px solid var(--border);padding:8px 8px 12px;min-width:0;background:var(--media-paper);border-radius:10px}
|
||||
.media-screen :is(.media-theme-card,.media-theme-system)[aria-pressed=true] {border-color:var(--media-accent);box-shadow:0 0 0 1px var(--media-accent)}
|
||||
.media-theme-preview {display:block;border-radius:5px;padding:14px;aspect-ratio:1.7;box-sizing:border-box;border:1px solid #80808020}
|
||||
.media-theme-preview-heading {display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
|
||||
.media-theme-preview-heading i:first-child {width:30%;height:5px;border-radius:3px}
|
||||
.media-theme-preview-heading i:last-child {width:8px;height:8px;border-radius:50%}
|
||||
.media-theme-preview-covers {display:flex;gap:8px}
|
||||
.media-theme-preview-covers i {flex:1;aspect-ratio:1.1;border-radius:3px}
|
||||
.media-theme-preview-line {display:block;width:60%;height:4px;margin-top:10px;border-radius:3px}
|
||||
.media-theme-card-label {display:flex;align-items:center;justify-content:space-between;margin:10px 4px 3px;gap:4px}
|
||||
.media-theme-card-label strong {font-size:14px;font-weight:500}
|
||||
.media-theme-card-label svg {color:var(--media-accent)}
|
||||
.media-theme-card>small {display:block;margin:0 4px;color:var(--muted);font-size:11px}
|
||||
.media-theme-settings>p[role] {margin:16px 0 0}
|
||||
@media(max-width:700px){.media-theme-grid {grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.media-theme-settings {margin-top:16px}}
|
||||
@media(max-width:700px){
|
||||
.media-task-tabs{display:flex;width:100%;box-sizing:border-box;overflow-x:auto;margin-bottom:20px}
|
||||
.media-task-tabs button{flex:1 0 auto;padding-inline:12px}
|
||||
@@ -1007,12 +945,128 @@ body[data-media-theme]:has(>#app>.media-screen) {background:var(--media-theme-ba
|
||||
}
|
||||
|
||||
/* Media dialogs and player selects inherit the active media palette. */
|
||||
.media-modal,.confirmation-host[data-media-theme=true] {--text:var(--media-theme-text);--muted:var(--media-theme-muted);--surface:var(--media-theme-surface);--border:var(--media-theme-border);--media-paper:var(--media-theme-paper);--media-background:var(--media-theme-background);--media-accent:var(--media-theme-accent);--media-on-accent:var(--media-theme-on-accent);--media-selection:var(--media-theme-selection);--reader-accent:var(--media-accent);--reader-ink:var(--text);--reader-muted:var(--muted);--reader-line:var(--border);--reader-paper:var(--media-paper);--reader-surface:var(--surface);color:var(--text);color-scheme:var(--media-theme-scheme)}
|
||||
.media-modal {background:var(--media-paper);color:var(--text);border-color:var(--border)}
|
||||
.media-confirm-popup {border-color:var(--media-theme-border)!important;background:var(--media-theme-paper)!important;color:var(--media-theme-text)!important}
|
||||
.media-confirm-popup .swal2-html-container {color:var(--media-theme-muted)!important}
|
||||
.media-video-workspace select,.media-video-sheet select,.media-subtitle-controls select {appearance:none;-webkit-appearance:none;border:1px solid var(--border);background:var(--surface);color:var(--text);padding-right:30px;color-scheme:inherit}
|
||||
.media-video-workspace select,.media-video-sheet select,.media-subtitle-controls select {background-image:linear-gradient(45deg,transparent 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat}
|
||||
.media-native-controls select {appearance:none;-webkit-appearance:none;background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:7px 30px 7px 10px;background-image:linear-gradient(45deg,transparent 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat}
|
||||
.media-video-stage .dplayer-host{width:100%;height:100%}
|
||||
|
||||
|
||||
/* 外观与主题 (V3.1, prototype 23). */
|
||||
.theme-settings {max-width:640px;margin:16px auto}
|
||||
.theme-settings h2 {margin:22px 0 10px;font-size:13px;font-weight:600;color:var(--muted)}
|
||||
.theme-settings-card {overflow:hidden;border:1px solid var(--border);border-radius:18px;background:var(--media-paper)}
|
||||
.media-screen .theme-follow {display:flex;align-items:center;gap:12px;width:100%;min-height:64px;padding:14px;border-radius:0;text-align:left;color:var(--text)}
|
||||
.theme-follow-icon {display:grid;place-items:center;flex:none;width:36px;height:36px;border-radius:10px;background:var(--media-selection);color:var(--media-accent)}
|
||||
.theme-follow-text {flex:1;min-width:0}
|
||||
.theme-follow-text strong,.theme-follow-text small {display:block}
|
||||
.theme-follow-text strong {font-size:15px;font-weight:600}
|
||||
.theme-follow-text small {margin-top:2px;font-size:12px;color:var(--muted)}
|
||||
.theme-switch {position:relative;flex:none;width:44px;height:26px;border-radius:13px;background:var(--color-surface-2);box-shadow:inset 0 0 0 1px var(--border)}
|
||||
.theme-switch::after {content:'';position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;background:var(--color-surface);box-shadow:0 1px 2px color-mix(in srgb,var(--color-ink) 25%,transparent);transition:left var(--motion-duration) var(--motion-ease)}
|
||||
.theme-follow[aria-pressed=true] .theme-switch {background:var(--media-fill);box-shadow:none}
|
||||
.theme-follow[aria-pressed=true] .theme-switch::after {left:21px}
|
||||
.theme-settings-row {display:flex;align-items:center;flex-wrap:wrap;gap:8px 12px;min-height:48px;padding:8px 14px;border-top:1px solid var(--border);font-size:14px}
|
||||
.theme-settings-row>span {flex:1;color:var(--muted)}
|
||||
.theme-chips {display:flex;gap:2px;padding:3px;border-radius:12px;background:var(--surface)}
|
||||
.media-screen .theme-chips button {min-height:34px;padding:0 12px;border-radius:9px;color:var(--muted);font-size:13px}
|
||||
.media-screen .theme-chips button[aria-pressed=true] {background:var(--media-paper);color:var(--text);font-weight:600;box-shadow:0 1px 3px color-mix(in srgb,var(--color-ink) 18%,transparent)}
|
||||
.theme-grid {display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
|
||||
.media-screen .theme-card {display:block;min-width:0;padding:10px;border:1px solid var(--border);border-radius:18px;background:var(--media-paper);color:var(--text);text-align:left}
|
||||
.media-screen .theme-card[aria-pressed=true] {border-color:var(--media-accent);box-shadow:0 0 0 1px var(--media-accent)}
|
||||
.media-screen .theme-card:hover:not(:disabled) {background:var(--media-paper);border-color:var(--media-accent)}
|
||||
.theme-preview {display:flex;height:64px;overflow:hidden;border:1px solid var(--border);border-radius:12px}
|
||||
.theme-preview>span {flex:1;padding:10px 8px;box-sizing:border-box}
|
||||
.theme-preview-title {display:block;width:28px;height:6px;border-radius:3px}
|
||||
.theme-preview-chips {display:flex;gap:4px;margin-top:10px}
|
||||
.theme-preview-chips i {width:16px;height:16px;border-radius:5px}
|
||||
.theme-card-label {display:flex;align-items:center;justify-content:space-between;gap:4px;margin-top:8px;font-size:14px}
|
||||
.theme-card-label strong {font-weight:600}
|
||||
.theme-card-label svg {color:var(--media-accent)}
|
||||
.theme-card>small {display:block;font-size:12px;color:var(--muted)}
|
||||
.theme-settings-note {margin:12px 0 0;font-size:12px;line-height:1.6;color:var(--muted)}
|
||||
.theme-settings>p[role] {margin:12px 0 0;font-size:12px}
|
||||
.theme-papers {display:flex;flex-wrap:wrap;gap:12px 8px;justify-content:space-between}
|
||||
.media-screen .theme-papers>button {display:flex;flex-direction:column;align-items:center;gap:6px;min-height:0;padding:0;font-size:12px;color:var(--text)}
|
||||
.theme-papers>button>span {display:grid;place-items:center;width:48px;height:48px;box-sizing:border-box;border:1px solid var(--border);border-radius:50%;font-family:'Songti SC','Noto Serif SC','Source Han Serif SC',serif;font-size:18px;font-weight:700}
|
||||
.theme-papers>button[aria-pressed=true]>span {border:3px solid var(--media-accent)}
|
||||
.theme-paper-follow {margin-top:12px}
|
||||
|
||||
/* ---- 字幕与音轨 sheet (prototype 12) ---- */
|
||||
.media-video-tabs {display:flex;gap:2px;padding:4px;margin:0 0 14px;border-radius:14px;background:var(--surface)}
|
||||
.media-video-sheet .media-video-tabs button {flex:1;min-height:36px;border-radius:10px;color:var(--muted);font-size:14px}
|
||||
.media-video-sheet .media-video-tabs button[aria-selected=true] {background:var(--media-paper);color:var(--text);font-weight:600;box-shadow:0 1px 3px color-mix(in srgb,var(--color-ink) 18%,transparent)}
|
||||
.media-subtitle-panel {display:grid;gap:12px}
|
||||
.media-subtitle-list {display:grid;border:1px solid var(--border);border-radius:14px;overflow:hidden}
|
||||
.media-video-sheet .media-subtitle-list button {display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;min-height:52px;padding:8px 14px;border-radius:0;border-top:1px solid var(--border);text-align:left;color:var(--text)}
|
||||
.media-video-sheet .media-subtitle-list button:first-child {border-top:0}
|
||||
.media-subtitle-list strong {display:block;font-size:14px;font-weight:500}
|
||||
.media-subtitle-list small {display:block;margin-top:2px;font-size:12px;color:var(--muted)}
|
||||
.media-subtitle-list button[aria-checked=true] strong {font-weight:600}
|
||||
.media-subtitle-list svg {flex:none;color:var(--media-accent)}
|
||||
.media-video-sheet .media-subtitle-add {display:flex;align-items:center;justify-content:center;gap:8px;border:1px dashed var(--border);border-radius:12px;color:var(--media-accent)}
|
||||
.media-subtitle-row {display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:14px}
|
||||
.media-subtitle-steps,.media-subtitle-sizes,.media-video-speeds {display:flex;gap:2px;padding:3px;border-radius:12px;background:var(--surface)}
|
||||
.media-video-sheet :is(.media-subtitle-steps,.media-subtitle-sizes,.media-video-speeds) button {min-height:34px;min-width:52px;padding:0 10px;border-radius:9px;font-size:13px;font-variant-numeric:tabular-nums;color:var(--muted)}
|
||||
.media-video-sheet :is(.media-subtitle-sizes,.media-video-speeds) button[aria-pressed=true] {background:var(--media-paper);color:var(--text);font-weight:600;box-shadow:0 1px 3px color-mix(in srgb,var(--color-ink) 18%,transparent)}
|
||||
.media-video-speeds {flex-wrap:wrap}
|
||||
.media-subtitle-status,.media-subtitle-hint,.media-video-more-label {margin:0;font-size:12px;color:var(--muted)}
|
||||
/* 字号: relative to the picture's width (the stage is an inline-size container). */
|
||||
.media-video-stage {container-type:inline-size}
|
||||
.media-player[data-subtitle-size='small'] .media-video-stage video::cue {font-size:2.2cqw}
|
||||
.media-player[data-subtitle-size='large'] .media-video-stage video::cue {font-size:3.9cqw}
|
||||
|
||||
/* ---- landscape full screen (prototype 13): the picture fills the screen, tools float over it ---- */
|
||||
.media-player.media-player-expanded:fullscreen {background:#060906}
|
||||
.media-player:fullscreen .media-video-workspace {display:block!important;position:relative;max-width:none!important;width:100%;height:100%;margin:0;padding:0!important}
|
||||
.media-player:fullscreen .media-video-stage {height:100dvh!important;width:100%;aspect-ratio:auto;border-radius:0!important}
|
||||
.media-player:fullscreen .media-video-stage .dplayer-host video {max-height:none;height:100dvh}
|
||||
.media-player:fullscreen .media-video-controls {display:block!important;position:static}
|
||||
.media-player:fullscreen .media-video-controls>:not(.media-video-landscape):not(dialog) {display:none!important}
|
||||
.media-video-landscape {position:absolute;inset:0 0 auto;z-index:20;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:max(12px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) 28px max(16px,env(safe-area-inset-left));background:linear-gradient(rgb(0 0 0 / .62),transparent);color:#fff;pointer-events:none;transition:opacity var(--motion-duration) var(--motion-ease)}
|
||||
.media-video-landscape>* {pointer-events:auto}
|
||||
.media-video-landscape-title {display:flex;align-items:center;gap:8px;min-width:0}
|
||||
.media-video-landscape-title strong {overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:15px}
|
||||
.media-video-landscape-tools {display:flex;flex-wrap:wrap;justify-content:flex-end;gap:4px}
|
||||
.media-video-landscape button {display:inline-flex;align-items:center;gap:6px;min-height:40px;padding:0 12px;border:0;border-radius:20px;background:rgb(255 255 255 / .12);color:inherit;font:inherit;font-size:13px;cursor:pointer}
|
||||
.media-video-landscape button[aria-pressed=true] {background:rgb(255 255 255 / .28)}
|
||||
.media-video-landscape button:focus-visible {outline:2px solid #fff;outline-offset:2px}
|
||||
/* Hide with DPlayer's own controller after a few idle seconds. */
|
||||
.media-player:fullscreen:has(.dplayer-hide-controller) .media-video-landscape:not([data-locked]) {opacity:0}
|
||||
.media-player:fullscreen:has(.dplayer-hide-controller) .media-video-landscape:not([data-locked]):hover {opacity:1}
|
||||
/* 锁定: taps no longer reach the player; only the unlock button remains. */
|
||||
.media-video-landscape[data-locked] {inset:0 auto 0 0;background:none;padding:0 0 0 max(16px,env(safe-area-inset-left))}
|
||||
.media-video-landscape .media-video-unlock {width:48px;height:48px;padding:0;justify-content:center;border-radius:50%;background:rgb(0 0 0 / .45)}
|
||||
.media-player[data-locked] .dplayer-host {pointer-events:none}
|
||||
.media-player[data-locked] :is(.dplayer-controller,.dplayer-controller-mask,.dplayer-notice-list) {display:none!important}
|
||||
/* 适应屏幕: fill the screen, cropping the edges, instead of letterboxing. */
|
||||
.media-player[data-fit='cover'] .media-video-stage video,.media-player[data-fit='cover'] .media-video-stage .dplayer-host video {object-fit:cover}
|
||||
@media (prefers-reduced-motion: reduce) {.media-video-landscape {transition:none}}
|
||||
|
||||
/* ---- 媒体库管理 (prototype 20): scan state on each card, 扫描与元数据 beside the list ---- */
|
||||
.media-libraries-layout {display:grid;gap:20px;align-items:start}
|
||||
@media (min-width:1100px) {.media-libraries-layout {grid-template-columns:minmax(0,1fr) 320px}}
|
||||
.media-library-footer {flex-wrap:wrap;row-gap:8px}
|
||||
.media-library-scan {display:flex;align-items:center;flex-wrap:wrap;gap:8px 12px;margin-left:auto;font-size:12px;color:var(--muted)}
|
||||
.media-library-scan[data-state='running'] {flex:1 1 100%;color:var(--text)}
|
||||
.media-library-scan[data-state='running']>span {flex:none}
|
||||
.media-library-progress {flex:1;min-width:120px;height:6px;appearance:none;border:0;border-radius:3px;background:var(--surface);overflow:hidden}
|
||||
.media-library-progress::-webkit-progress-bar {background:var(--surface);border-radius:3px}
|
||||
.media-library-progress::-webkit-progress-value {background:var(--media-fill);border-radius:3px}
|
||||
.media-library-progress::-moz-progress-bar {background:var(--media-fill);border-radius:3px}
|
||||
.media-library-progress:indeterminate {background:linear-gradient(90deg,var(--surface),var(--media-fill),var(--surface));background-size:200% 100%;animation:media-scan-progress 2s linear infinite}
|
||||
.media-library-scan[data-state='failed'] {flex:1 1 100%}
|
||||
.media-library-scan-error {flex:1 1 14rem;color:var(--media-error-ink)}
|
||||
.media-library-scan-actions {display:flex;gap:8px;margin-left:auto}
|
||||
.media-screen .media-library-details {border:0;background:transparent;color:var(--media-accent);padding-inline:4px}
|
||||
.media-scan-settings {padding:16px;border:1px solid var(--border);border-radius:18px;background:var(--media-paper)}
|
||||
.media-screen .media-scan-settings h2 {margin:0 0 8px;font-size:15px}
|
||||
.media-scan-settings-list {display:grid}
|
||||
.media-screen .media-scan-settings-list button {display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;min-height:52px;padding:8px 0;border:0;border-top:1px solid var(--border);border-radius:0;background:transparent;text-align:left;color:var(--text)}
|
||||
.media-screen .media-scan-settings-list button:first-child {border-top:0}
|
||||
.media-scan-settings-list strong {display:block;font-size:14px;font-weight:500}
|
||||
.media-scan-settings-list small {display:block;margin-top:2px;font-size:11px;line-height:1.5;color:var(--muted)}
|
||||
.media-scan-switch {position:relative;flex:none;width:44px;height:26px;border-radius:13px;background:var(--surface);box-shadow:inset 0 0 0 1px var(--border)}
|
||||
.media-scan-switch::after {content:'';position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;background:var(--media-paper);box-shadow:0 1px 2px color-mix(in srgb,var(--color-ink) 25%,transparent);transition:left var(--motion-duration) var(--motion-ease)}
|
||||
[aria-checked=true]>.media-scan-switch {background:var(--media-fill);box-shadow:none}
|
||||
[aria-checked=true]>.media-scan-switch::after {left:21px}
|
||||
.media-scan-settings-note {margin:10px 0 0;font-size:12px;line-height:1.6;color:var(--muted)}
|
||||
@@ -11,7 +11,7 @@
|
||||
.media-select-error {display:block;margin-top:5px;font-size:12px;color:var(--media-error-ink,#943c32)}
|
||||
.media-select-control[data-variant=plain] {width:auto}
|
||||
.media-select-control[data-variant=plain]>.media-select-trigger {border:0;background:transparent;padding:0 2px;font-size:12px;gap:8px}
|
||||
.media-select-menu {position:fixed;inset:auto;box-sizing:border-box;margin:0;padding:5px;min-width:0;max-width:calc(100vw - 16px);overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:var(--border,#dfe5d9) transparent;border:1px solid var(--border,#dfe5d9);border-radius:10px;background:var(--media-paper,#fff);color:var(--text,#202b24);box-shadow:0 8px 30px #23332224;z-index:1000;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;color-scheme:var(--media-theme-scheme,light)}
|
||||
.media-select-menu {position:fixed;inset:auto;box-sizing:border-box;margin:0;padding:5px;min-width:0;max-width:calc(100vw - 16px);overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:var(--border,#dfe5d9) transparent;border:1px solid var(--border,#dfe5d9);border-radius:10px;background:var(--media-paper,#fff);color:var(--text,#202b24);box-shadow:0 8px 30px #23332224;z-index:1000;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;color-scheme:inherit}
|
||||
.media-select-menu::backdrop {background:transparent;pointer-events:none}
|
||||
.media-select-option {display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:44px;box-sizing:border-box;padding:10px 12px;border-radius:6px;cursor:pointer;overflow-wrap:anywhere}
|
||||
.media-select-option>span {min-width:0}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
DPlayer owns the video controls; this sheet only lays out its stage. */
|
||||
.media-screen {padding:68px 32px 96px;font-size:14px;scrollbar-width:thin}
|
||||
.media-channel-entry {height:68px;padding-inline:max(32px,calc((100vw - 1056px)/2))}
|
||||
.media-screen button {min-height:44px;border-radius:9px}
|
||||
.media-screen button:not(:where(.sheet-header *, .sheet-footer *, .sheet-list *, .action-menu *, .action-menu-trigger)) {min-height:44px;border-radius:9px}
|
||||
.media-screen :is(input:not([type=checkbox]):not([type=radio]):not([type=range]),select) {min-height:44px}
|
||||
.media-screen :is(.media-heading,.media-settings-heading,.media-narrator-heading) {min-height:56px}
|
||||
.media-screen :is(.media-page-heading h1,.media-page-heading h2,.media-heading>strong,.media-settings-heading h1) {font-size:16px;font-weight:650;line-height:1.4}
|
||||
@@ -93,16 +93,6 @@
|
||||
.media-screen .media-setting-row:last-child {border-radius:0 0 10px 10px}
|
||||
.media-screen .media-setting-row:nth-child(2):last-child {border-radius:10px}
|
||||
.media-setting-row small {font-size:11px}
|
||||
.media-screen .media-theme-grid {grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:18px 0}
|
||||
.media-screen .media-theme-card {padding:8px;border-radius:10px;background:var(--media-paper)}
|
||||
.media-theme-card>small {display:none}
|
||||
.media-theme-preview {height:88px;min-height:88px}
|
||||
.media-theme-card-label {font-size:12px;padding:10px 4px 4px}
|
||||
.media-theme-note {font-size:11px;color:var(--muted);line-height:1.8}
|
||||
.media-settings>.media-theme-settings {border:0;padding:0;margin:18px auto}
|
||||
.media-settings>.media-theme-settings .media-theme-system {margin-top:0}
|
||||
.media-screen .media-theme-system {margin-top:18px;padding:10px 12px;min-height:56px;background:var(--media-paper);border:1px solid var(--border);border-radius:10px}
|
||||
.media-theme-settings>h2 {display:none}
|
||||
.media-screen .media-settings-panel,.media-screen .media-form {max-width:760px;margin-inline:auto}
|
||||
.media-screen .media-setting-field,.media-screen .media-settings-check {min-height:56px;padding:12px 0;border-bottom:1px solid var(--border)}
|
||||
.media-screen .media-settings-check input {accent-color:var(--media-accent);width:20px;height:20px}
|
||||
@@ -218,35 +208,33 @@
|
||||
.media-screen .media-metadata-page .media-match-search>.media-primary {grid-column:1}
|
||||
}
|
||||
|
||||
/* Video playback is a themed surface around a deliberately dark video
|
||||
viewport. Keep the header, metadata and episode list on the active media
|
||||
palette even when the reader's global dark-mode selector is also present. */
|
||||
body[data-media-theme] .media-screen.media-video-page,
|
||||
body[data-media-theme] .media-player.media-player-expanded {
|
||||
background:var(--media-theme-background)!important;
|
||||
color:var(--media-theme-text)!important;
|
||||
/* Video playback is a themed surface around a deliberately dark video viewport. */
|
||||
:root body .media-screen.media-video-page,
|
||||
:root body .media-player.media-player-expanded {
|
||||
background:var(--color-bg)!important;
|
||||
color:var(--color-ink)!important;
|
||||
}
|
||||
body[data-media-theme] .media-screen.media-video-page>.media-heading {
|
||||
background:var(--media-theme-background)!important;
|
||||
color:var(--media-theme-text)!important;
|
||||
border-color:var(--media-theme-border)!important;
|
||||
:root body .media-screen.media-video-page>.media-heading {
|
||||
background:var(--color-bg)!important;
|
||||
color:var(--color-ink)!important;
|
||||
border-color:var(--color-line)!important;
|
||||
}
|
||||
body[data-media-theme] .media-video-workspace,
|
||||
body[data-media-theme] .media-video-controls,
|
||||
body[data-media-theme] .media-episode-rail {
|
||||
color:var(--media-theme-text);
|
||||
:root body .media-video-workspace,
|
||||
:root body .media-video-controls,
|
||||
:root body .media-episode-rail {
|
||||
color:var(--color-ink);
|
||||
}
|
||||
body[data-media-theme] .media-episode-rail {
|
||||
background:var(--media-theme-paper);
|
||||
border-color:var(--media-theme-border);
|
||||
box-shadow:0 8px 24px color-mix(in srgb,var(--media-theme-text) 12%,transparent);
|
||||
:root body .media-episode-rail {
|
||||
background:var(--color-surface);
|
||||
border-color:var(--color-line);
|
||||
box-shadow:0 8px 24px color-mix(in srgb,var(--color-ink) 12%,transparent);
|
||||
}
|
||||
body[data-media-theme] .media-episode-rail header {background:var(--media-theme-paper)}
|
||||
body[data-media-theme] .media-episode-rail .media-video-episodes button[aria-current=true] {
|
||||
background:var(--media-theme-selection);
|
||||
color:var(--media-theme-accent);
|
||||
:root body .media-episode-rail header {background:var(--color-surface)}
|
||||
:root body .media-episode-rail .media-video-episodes button[aria-current=true] {
|
||||
background:var(--color-brand-soft);
|
||||
color:var(--color-brand-text);
|
||||
}
|
||||
body[data-media-theme] .media-video-stage .dplayer-host { color-scheme:dark; }
|
||||
:root body .media-video-stage .dplayer-host { color-scheme:dark; }
|
||||
.searchable-select {position:relative;display:inline-flex;flex-direction:column;min-width:190px;max-width:100%}
|
||||
.searchable-select-label {display:block;margin:0 0 5px;color:var(--muted);font-size:12px;font-weight:600}
|
||||
.searchable-select-native {position:absolute!important;width:1px!important;height:1px!important;opacity:0!important;pointer-events:none!important}
|
||||
@@ -260,8 +248,6 @@ body[data-media-theme] .media-video-stage .dplayer-host { color-scheme:dark; }
|
||||
.searchable-select-options button {display:flex;align-items:center;justify-content:space-between;gap:10px;width:100%;min-height:38px;padding:8px 10px;border:0;border-radius:6px;background:transparent;color:var(--text);text-align:left}
|
||||
.searchable-select-options button:hover,.searchable-select-options button[aria-selected=true] {background:var(--media-selection);color:var(--media-accent)}
|
||||
.searchable-select-options p {padding:12px;color:var(--muted);font-size:12px}
|
||||
.media-audio-sheet,.media-video-sheet {max-height:85dvh}
|
||||
.media-audio-sheet>header,.media-video-sheet>header {height:56px;min-height:56px;position:sticky;top:-16px;background:inherit;z-index:2;margin-bottom:12px}
|
||||
@media(min-width:701px){
|
||||
.media-screen>.media-heading:has(.media-tabs:not([hidden])) {display:block;height:auto;min-height:0;margin:0;border:0;padding:0}
|
||||
.media-screen .media-mobile-title {display:none}
|
||||
@@ -277,7 +263,6 @@ body[data-media-theme] .media-video-stage .dplayer-host { color-scheme:dark; }
|
||||
.media-utility-workspace .media-settings-heading {left:max(24px,calc((100vw - 1060px)/2))}
|
||||
.media-screen .media-mobile-title {height:68px;left:max(145px,calc((100vw - 1056px)/2 + 110px))}
|
||||
.media-heading:has(.media-mobile-title) .media-heading-actions {top:12px}
|
||||
.media-audio-sheet,.media-video-sheet {inset:0;margin:auto;border:1px solid var(--border);border-radius:14px;height:fit-content}
|
||||
.media-screen .media-playback-status[data-panel=main] {display:grid;grid-template-columns:360px minmax(0,1fr);grid-template-rows:1fr auto auto auto 1fr;column-gap:64px;min-height:540px;align-content:center}
|
||||
.media-playback-status[data-panel=main] .media-playing-artwork {display:contents}
|
||||
.media-playback-status[data-panel=main] .media-playing-artwork>.media-cover {grid-column:1;grid-row:1/6;width:360px;margin:auto;align-self:center}
|
||||
@@ -366,25 +351,6 @@ body[data-media-theme] .media-video-stage .dplayer-host { color-scheme:dark; }
|
||||
min-width: 0;
|
||||
max-width: min(70%, 420px);
|
||||
}
|
||||
.media-screen.media-catalog-page .media-library-filter .mantine-Select-root {
|
||||
min-width: 0;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
.media-screen.media-catalog-page .media-library-filter .mantine-Input-wrapper { width: max-content; min-width: 0; }
|
||||
.media-screen.media-catalog-page .media-library-filter .mantine-Select-input {
|
||||
width: auto;
|
||||
max-width: min(180px,100%);
|
||||
min-height: 44px;
|
||||
height: 44px;
|
||||
padding: 0 21px 0 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.media-screen.media-catalog-page .media-library-filter :is(input,button,[role="button"]) {
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
@@ -406,19 +372,6 @@ body[data-media-theme] .media-video-stage .dplayer-host { color-scheme:dark; }
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.media-screen.media-catalog-page .media-sort-control .mantine-Select-root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
opacity: 0;
|
||||
}
|
||||
.media-screen.media-catalog-page .media-sort-control .mantine-Select-input {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.media-screen.media-catalog-page .media-browse-actions .media-icon-button,
|
||||
.media-screen.media-catalog-page .media-browse-actions .media-icon-button svg {
|
||||
color: var(--muted) !important;
|
||||
@@ -589,7 +542,6 @@ body[data-media-theme] .media-video-stage .dplayer-host { color-scheme:dark; }
|
||||
.media-video-workspace:has(.media-episode-rail) .media-episode-rail {position:absolute;right:0;top:16px;width:300px;box-sizing:border-box;margin:0}
|
||||
}
|
||||
@media(max-width:700px){
|
||||
.media-resource-panel .source-modal {inset:auto 0 0;width:100%;max-width:100%;margin:0;border-radius:14px 14px 0 0;max-height:85dvh}
|
||||
.media-screen {padding:0 16px calc(24px + env(safe-area-inset-bottom))}
|
||||
.media-screen.media-catalog-page .media-heading:has(.media-mobile-title) {display:flex;grid-template-rows:none;min-height:56px;height:56px}
|
||||
.media-screen .media-heading:has(.media-mobile-title) {grid-template-rows:56px 44px;min-height:100px}
|
||||
@@ -602,7 +554,6 @@ body[data-media-theme] .media-video-stage .dplayer-host { color-scheme:dark; }
|
||||
.media-person-detail .media-hero>.media-cover,.media-person-hero>.media-cover {width:92px;min-width:92px}
|
||||
.media-screen :is(.media-detail-actions,.media-album-actions)>.media-primary {flex:1;min-width:0}
|
||||
.media-detail-actions .media-edition-picker {flex-basis:100%;margin-top:8px}
|
||||
.media-screen .media-theme-grid {grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.media-screen .media-library-list {grid-template-columns:minmax(0,1fr)}
|
||||
.media-screen .media-playback-status[data-panel=main],.media-screen .media-playback-status[data-panel=lyrics] {display:flex;flex-direction:column;padding:24px 12px 14px;min-height:calc(100dvh - 80px);box-sizing:border-box}
|
||||
.media-playback-status[data-panel=main] .media-playing-artwork>.media-cover {width:min(100%,264px)}
|
||||
@@ -755,7 +706,7 @@ body[data-media-theme] .media-video-stage .dplayer-host { color-scheme:dark; }
|
||||
.media-task-icon-placeholder{width:36px;height:36px;display:block}.media-scrape-selection-toolbar{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin:16px 0 10px}.media-scrape-selection-toolbar>div{display:flex;gap:8px;flex-wrap:wrap}.media-scrape-search{display:grid;gap:6px;min-width:min(360px,100%);font-size:13px;color:var(--muted)}.media-scrape-search input{min-height:38px;border:1px solid var(--border);border-radius:8px;padding:0 12px;background:var(--surface)}.media-scrape-item-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 24px;max-height:460px;overflow:auto;padding:4px 8px;border:1px solid var(--border);border-radius:10px;background:var(--surface-muted,#f8f9f6)}.media-scrape-item-list .media-user-option{min-width:0;border-bottom:1px solid color-mix(in srgb,var(--border) 65%,transparent);padding:10px 4px}.media-scrape-item-list .media-user-option span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.media-scrape-selection-footer{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:12px 0}.media-scrape-selection-footer span{color:var(--muted);font-size:13px}
|
||||
|
||||
/* Shared media interaction tokens also cover layers outside the screen. */
|
||||
body[data-media-theme] {--surface-muted:var(--media-theme-surface)}
|
||||
:root body {--surface-muted:var(--color-surface-2)}
|
||||
.media-task-tabs button:hover {background:var(--media-selection)}
|
||||
.media-screen .media-task-tabs {
|
||||
display:flex;
|
||||
@@ -797,8 +748,8 @@ body[data-media-theme] {--surface-muted:var(--media-theme-surface)}
|
||||
.media-screen .media-browse-tools {height:auto;flex-wrap:wrap}
|
||||
.media-loading-status {color:var(--muted);font-size:13px;min-height:24px}
|
||||
.media-scan-job .media-task-retry {grid-column:1/-1;justify-self:start;min-height:44px}
|
||||
.media-player .media-playback-problem .media-primary {background:var(--media-accent);color:var(--media-on-accent)}
|
||||
.media-player .dplayer-host .dplayer-bar-wrap .dplayer-played,.media-player .dplayer-host .dplayer-volume-bar-inner {background:var(--media-accent)!important}
|
||||
.media-player .media-playback-problem .media-primary {background:var(--media-fill);color:var(--media-on-accent)}
|
||||
.media-player .dplayer-host .dplayer-bar-wrap .dplayer-played,.media-player .dplayer-host .dplayer-volume-bar-inner {background:var(--media-fill)!important}
|
||||
.media-player .dplayer-host .dplayer-setting-box,.media-player .dplayer-host .dplayer-setting-item {background:var(--media-paper);color:var(--text)}
|
||||
.media-player .dplayer-host .dplayer-setting-item:hover {background:var(--media-selection)}
|
||||
|
||||
@@ -809,7 +760,7 @@ body[data-media-theme] {--surface-muted:var(--media-theme-surface)}
|
||||
.media-scrape-manager>.media-task-create{margin-top:0}.media-scrape-manager>.media-task-history{margin-top:28px}
|
||||
@media(max-width:520px){.media-screen .media-folder-operation{align-items:stretch;flex-direction:column}.media-folder-operation>button{align-self:flex-start}}
|
||||
|
||||
.media-resource-hidden{display:none!important}.media-menu-submenu{position:relative}.media-menu-submenu>summary{list-style:none;cursor:pointer}.media-menu-submenu>summary::-webkit-details-marker{display:none}.media-menu-submenu>nav{display:none;position:absolute;right:100%;top:-6px;min-width:150px;padding:6px;border:1px solid var(--border);border-radius:10px;background:var(--surface);box-shadow:0 8px 24px rgba(0,0,0,.12)}.media-menu-submenu[open]>nav{display:grid;gap:3px}.media-menu-submenu>nav button{text-align:left}.media-folder-actions{display:flex;justify-content:flex-end;margin:8px 0}.media-folder-actions>nav{display:grid;gap:4px}.media-task-intro{display:flex;align-items:center;justify-content:space-between;gap:20px;margin:4px 0 20px;padding:18px 20px;border:1px solid var(--border);border-radius:14px;background:var(--surface-muted,#f3f4ef)}.media-task-intro h2{margin:0;font-size:20px;color:var(--text)}.media-task-intro p{margin:6px 0 0;color:var(--muted);font-size:13px}.media-task-intro .media-task-actions{display:flex;flex-wrap:wrap;gap:8px}.media-task-intro button{min-height:40px}.media-screen .media-task-tabs{border-radius:10px;background:var(--surface-muted,#f3f4ef);padding:5px}.media-screen .media-task-tabs button[aria-current=page]{background:var(--media-accent);color:#fff;box-shadow:none}.media-screen .media-task-tabs button{font-weight:650}.media-screen .media-folder-actions>summary{padding:9px 14px;border:1px solid var(--border);border-radius:9px;background:var(--surface);cursor:pointer}.media-screen .media-folder-actions[open]>summary{border-color:var(--media-accent)}
|
||||
.media-resource-hidden{display:none!important}.media-menu-submenu{position:relative}.media-menu-submenu>summary{list-style:none;cursor:pointer}.media-menu-submenu>summary::-webkit-details-marker{display:none}.media-menu-submenu>nav{display:none;position:absolute;right:100%;top:-6px;min-width:150px;padding:6px;border:1px solid var(--border);border-radius:10px;background:var(--surface);box-shadow:0 8px 24px rgba(0,0,0,.12)}.media-menu-submenu[open]>nav{display:grid;gap:3px}.media-menu-submenu>nav button{text-align:left}.media-folder-actions{display:flex;justify-content:flex-end;margin:8px 0}.media-folder-actions>nav{display:grid;gap:4px}.media-task-intro{display:flex;align-items:center;justify-content:space-between;gap:20px;margin:4px 0 20px;padding:18px 20px;border:1px solid var(--border);border-radius:14px;background:var(--surface-muted,#f3f4ef)}.media-task-intro h2{margin:0;font-size:20px;color:var(--text)}.media-task-intro p{margin:6px 0 0;color:var(--muted);font-size:13px}.media-task-intro .media-task-actions{display:flex;flex-wrap:wrap;gap:8px}.media-task-intro button{min-height:40px}.media-screen .media-task-tabs{border-radius:10px;background:var(--surface-muted,#f3f4ef);padding:5px}.media-screen .media-task-tabs button[aria-current=page]{background:var(--media-selection);color:var(--media-accent);box-shadow:none}.media-screen .media-task-tabs button{font-weight:650}.media-screen .media-folder-actions>summary{padding:9px 14px;border:1px solid var(--border);border-radius:9px;background:var(--surface);cursor:pointer}.media-screen .media-folder-actions[open]>summary{border-color:var(--media-accent)}
|
||||
@media(max-width:640px){.media-task-intro{align-items:stretch;flex-direction:column;gap:14px;margin-bottom:16px;padding:15px}.media-task-intro h2{font-size:18px}.media-task-intro .media-task-actions{display:grid;grid-template-columns:1fr}.media-task-intro .media-task-actions button,.media-task-intro>button{width:100%}.media-screen .media-task-tabs{display:grid;grid-template-columns:repeat(3,1fr);width:100%;box-sizing:border-box}.media-screen .media-task-tabs button{padding-inline:5px;font-size:13px}.media-menu-submenu>nav{right:0;top:100%}}
|
||||
|
||||
/* Keep task navigation quiet and aligned with the rest of the theme. */
|
||||
@@ -926,14 +877,9 @@ body[data-media-theme] {--surface-muted:var(--media-theme-surface)}
|
||||
.media-screen .media-actions nav :is(a,button) svg {flex:none;stroke:currentColor}
|
||||
.media-screen .media-library-management-actions>summary {color:var(--text)}
|
||||
.media-screen .media-library-management-actions[open]>summary {background:var(--media-selection);color:var(--media-accent)}
|
||||
.media-modal:has(.media-match-confirm){width:min(560px,calc(100vw - 32px));max-width:560px;padding:0;border-radius:16px;overflow:hidden}
|
||||
.media-modal:has(.media-match-confirm) .media-match-confirm{margin:0;padding:20px;border:0;border-radius:0;background:var(--media-paper)}
|
||||
.media-modal:has(.media-match-confirm) .media-match-confirm p{font-size:14px;line-height:1.75;margin:0 0 18px}
|
||||
.media-modal:has(.media-match-confirm) .media-match-confirm .dialog-actions{display:flex;align-items:center;justify-content:flex-end;gap:10px;flex-wrap:wrap}
|
||||
.media-modal:has(.media-match-confirm) .media-match-confirm .dialog-actions button{min-height:44px}
|
||||
.search-split{position:relative;isolation:isolate}
|
||||
.sources-search .search-menu{right:0;left:auto;min-width:260px;box-sizing:border-box}
|
||||
@media(max-width:640px){.media-screen .media-modal .media-match-confirm .dialog-actions{justify-content:stretch}.media-screen .media-modal .media-match-confirm .dialog-actions button{flex:1 1 140px}.sources-search .search-menu{right:0;min-width:min(290px,calc(100vw - 32px))}}
|
||||
@media(max-width:640px){.sources-search .search-menu{right:0;min-width:min(290px,calc(100vw - 32px))}}
|
||||
|
||||
/* Keep the embedded player's own controls independent from the reader theme. */
|
||||
.media-video-stage,.media-video-stage *{color-scheme:dark}
|
||||
@@ -991,3 +937,27 @@ body:has(.media-narrators:not([data-view=directory])) .media-channel-entry {
|
||||
|
||||
/* Folder rows are left-aligned text actions; only the chevron stays at the far edge. */
|
||||
.media-screen .media-folder-row>button { justify-content:flex-start; text-align:left; }
|
||||
|
||||
/* V3 theme page: which theme "跟随系统" uses on a light and on a dark device */
|
||||
|
||||
/* V3 播放队列 and 倍速与定时: how the queue advances, and stopping after the current entry */
|
||||
.media-screen .media-transport:has(.media-play-mode-cycle) {gap:12px}
|
||||
.media-screen .media-play-mode-cycle[data-mode=sequential] {color:var(--muted)}
|
||||
.media-screen .media-play-mode-cycle:not([data-mode=sequential]) {color:var(--media-accent)}
|
||||
.media-play-mode {display:flex;align-items:center;gap:12px;margin:0 0 10px}
|
||||
.media-play-mode>span {flex:none;font-size:13px;color:var(--muted)}
|
||||
.media-play-mode>div {display:flex;flex:1;gap:2px;padding:4px;border-radius:14px;background:var(--media-selection)}
|
||||
.media-screen .media-play-mode button {flex:1;min-height:40px;padding:0 6px;border:0;border-radius:10px;background:transparent;color:var(--muted);font-size:14px;white-space:nowrap}
|
||||
.media-screen .media-play-mode button[aria-pressed=true] {background:var(--media-paper);color:var(--text);font-weight:600;box-shadow:0 1px 3px #0003}
|
||||
.media-sleep-actions {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:14px}
|
||||
.media-screen .media-sleep-actions button {display:flex;align-items:center;justify-content:center;gap:8px;width:100%;min-height:48px;margin:0;padding:0 8px;border:1px solid var(--border);border-radius:9px;background:var(--surface);color:var(--text);font-size:14px}
|
||||
.media-screen .media-sleep-actions button[aria-pressed=true] {background:var(--media-selection);border-color:var(--media-accent);color:var(--media-accent);font-weight:600}
|
||||
.media-screen .media-sleep-actions button:disabled {opacity:.5}
|
||||
.media-screen .media-sleep-actions .media-sleep-off span {flex:none}
|
||||
.media-sleep-note {margin:10px 0 0;font-size:12px;color:var(--muted)}
|
||||
.media-task-panel{display:flex;flex-direction:column;gap:16px}
|
||||
.media-task-intro .media-task-actions{display:flex;flex-wrap:wrap}
|
||||
.media-text-link{color:var(--media-accent);text-decoration:none;display:inline-flex;align-items:center;min-height:44px}
|
||||
.media-icon-button>.icon{font-size:1.25rem}
|
||||
.media-manager-card{display:grid;gap:12px;padding:16px;border:1px solid var(--border);border-radius:10px;background:var(--surface)}
|
||||
.media-manager-card>:is(h3,h4){margin:0;font-size:15px}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Center, Loader, Stack, Text } from '@mantine/core';
|
||||
import { Button } from '../../../ui/toolkit.tsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
@@ -25,6 +25,7 @@ export function ReaderPage() {
|
||||
offline: runtime.offline,
|
||||
sync: runtime.sync,
|
||||
platform: runtime.platform,
|
||||
themeScope: runtime.mediaApi.preferenceScope(),
|
||||
settings: runtime.settings.current,
|
||||
...(runtime.pageHost ? { pageHost: runtime.pageHost } : {}),
|
||||
...(runtime.speechBridge ? { speechBridge: runtime.speechBridge } : {}),
|
||||
@@ -39,9 +40,9 @@ export function ReaderPage() {
|
||||
void screen.open(book);
|
||||
return () => { unregister(); screen.dispose(); host.current?.replaceChildren(); };
|
||||
}, [runtime, book, navigate]);
|
||||
if (error) return <Center mih="60vh"><Stack align="center"><Alert color="red">无法打开这本书:{error instanceof Error ? error.message : '请求失败'}</Alert></Stack></Center>;
|
||||
if (!book) return <Center mih="60vh"><Stack align="center"><Loader /><Text>正在打开书籍…</Text></Stack></Center>;
|
||||
return <div ref={host} className="reader-route-host" style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }} />;
|
||||
if (error) return <div className="page-state" role="alert"><h2>无法打开这本书</h2><p>{error instanceof Error ? error.message : '请求失败'}</p><Button onClick={() => navigate(-1)}>返回</Button></div>;
|
||||
if (!book) return <div className="page-state" role="status"><div className="spinner" aria-hidden="true" /><p>正在打开书籍…</p></div>;
|
||||
return <div ref={host} className="reader-route-host" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { ChevronRight, Search } from 'lucide-react';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useSettingsStore } from '../../../shared/stores/settings.store.ts';
|
||||
import { BookCard } from '../../shelf/pages/ShelfPage.tsx';
|
||||
import { MediaCover } from '../../media/components/cover.tsx';
|
||||
import { MediaPageFrame } from '../../media/components/MediaPageFrame.tsx';
|
||||
import type { MediaChannel } from '../../media/api/media-api.ts';
|
||||
import '../../home/styles/home.css';
|
||||
import '../styles/search.css';
|
||||
|
||||
const channelLabels: Record<MediaChannel, string> = { video: '影视', music: '音乐', audiobook: '有声书' };
|
||||
const BOOK_LIMIT = 12, MEDIA_LIMIT = 8;
|
||||
|
||||
/**
|
||||
* 搜索 (desktop shell, prototype 15/19/20): one query across the book library and
|
||||
* every media library the account can see. Each half shows its first results and
|
||||
* links to the page that owns the full list (书库 search, 影音 search).
|
||||
*/
|
||||
export function SearchPage() {
|
||||
const runtime = useRuntime();
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const query = (params.get('q') ?? '').trim();
|
||||
const [input, setInput] = useState(query);
|
||||
useEffect(() => setInput(query), [query]);
|
||||
const settings = useSettingsStore(state => state.settings);
|
||||
const user = runtime.api.currentSession()?.user.id;
|
||||
const books = useQuery({
|
||||
queryKey: ['search', 'books', runtime.api.baseUrl, user, query],
|
||||
queryFn: ({ signal }) => runtime.api.listBooks({ scope: 'library', search: query, page: 1, pageSize: BOOK_LIMIT }, { signal }),
|
||||
enabled: query.length > 0,
|
||||
});
|
||||
const media = useQuery({
|
||||
queryKey: ['search', 'media', runtime.api.baseUrl, user, query],
|
||||
queryFn: ({ signal }) => runtime.mediaApi.search(query, 'all', 0, signal),
|
||||
enabled: query.length > 0,
|
||||
});
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); const q = input.trim(); setParams(q ? { q } : {}); };
|
||||
const mediaItems = (media.data?.items ?? []).slice(0, MEDIA_LIMIT);
|
||||
return <MediaPageFrame className="media-secondary-page search-page" title="搜索" backTo="/" backLabel="返回首页">
|
||||
<form className="search-page-form" role="search" onSubmit={submit}>
|
||||
<Search size={18} aria-hidden="true" />
|
||||
<input type="search" name="q" aria-label="搜索全部内容" placeholder="搜索书名、片名、歌曲、有声书" value={input} maxLength={200} onInput={event => setInput(event.currentTarget.value)} />
|
||||
<button type="submit" className="media-primary" disabled={!input.trim()}>搜索</button>
|
||||
</form>
|
||||
{!query ? <p className="search-page-hint">输入名称,同时搜索书库和有权访问的影音库。</p> : <>
|
||||
<section className="search-page-section" aria-label="书籍搜索结果">
|
||||
<header><h2>书籍{books.data ? <small>{books.data.total}</small> : null}</h2>
|
||||
{books.data && books.data.total > BOOK_LIMIT && <Link to={`/library?q=${encodeURIComponent(query)}`}>在书库查看全部<ChevronRight size={16} aria-hidden="true" /></Link>}</header>
|
||||
{books.isPending ? <p role="status">正在搜索书籍…</p>
|
||||
: books.error ? <p className="media-error" role="alert">书籍搜索失败:{books.error.message} <button type="button" onClick={() => void books.refetch()}>重试</button></p>
|
||||
: books.data.items.length === 0 ? <p className="search-page-empty">没有找到书籍</p>
|
||||
: <div className="book-grid" data-density={settings.shelfDensity} data-show-author={String(settings.shelfShowAuthor)}>
|
||||
{books.data.items.map(book => <BookCard key={book.id} book={book} showAuthor={settings.shelfShowAuthor} showProgress={false} />)}
|
||||
</div>}
|
||||
</section>
|
||||
<section className="search-page-section" aria-label="影音搜索结果">
|
||||
<header><h2>影音{media.data ? <small>{media.data.total}</small> : null}</h2>
|
||||
{media.data && media.data.total > MEDIA_LIMIT && <Link to={`/media/search?q=${encodeURIComponent(query)}&scope=all`}>查看全部影音结果<ChevronRight size={16} aria-hidden="true" /></Link>}</header>
|
||||
{media.isPending ? <p role="status">正在搜索影音…</p>
|
||||
: media.error ? <p className="media-error" role="alert">影音搜索失败:{media.error.message} <button type="button" onClick={() => void media.refetch()}>重试</button></p>
|
||||
: mediaItems.length === 0 ? <p className="search-page-empty">没有找到影音</p>
|
||||
: <div className="search-page-media">{mediaItems.map(item =>
|
||||
<button type="button" key={item.id} className="media-search-result" onClick={() => navigate(`/media/${item.channel}/items/${encodeURIComponent(item.id)}`)}>
|
||||
<MediaCover api={runtime.mediaApi} item={item} square={item.channel === 'music'} />
|
||||
<span className="media-search-copy"><strong title={item.title}>{item.title}</strong><small>{channelLabels[item.channel]} · {item.libraryName}</small></span>
|
||||
<ChevronRight size={18} aria-hidden="true" />
|
||||
</button>)}</div>}
|
||||
</section>
|
||||
</>}
|
||||
</MediaPageFrame>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/* 搜索: one query, books and media. Uses the media page frame and its aliases. */
|
||||
.search-page { max-width: 1000px; }
|
||||
.search-page-form { display: flex; align-items: center; gap: 10px; margin: 8px 0 20px; padding: 0 6px 0 14px; border: 1px solid var(--border); border-radius: 14px; background: var(--media-paper); color: var(--muted); }
|
||||
.search-page-form input { flex: 1; min-width: 0; min-height: 46px; border: 0; background: transparent; color: var(--text); font: inherit; font-size: 15px; outline: none; }
|
||||
.search-page-form input::-webkit-search-cancel-button { display: none; }
|
||||
.search-page-form:focus-within { border-color: var(--media-accent); box-shadow: 0 0 0 1px var(--media-accent); }
|
||||
.search-page-hint, .search-page-empty { color: var(--muted); font-size: 14px; }
|
||||
.search-page-section { margin: 0 0 28px; }
|
||||
.search-page-section > header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
.search-page-section h2 { margin: 0; font-size: 17px; }
|
||||
.search-page-section h2 small { margin-left: 8px; font-size: 12px; font-weight: 400; color: var(--muted); }
|
||||
.search-page-section > header a { display: inline-flex; align-items: center; color: var(--media-accent); font-size: 13px; text-decoration: none; }
|
||||
.search-page-media { display: grid; gap: 4px; }
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Alert, Button, Card, Divider, Group, NumberInput, Select, SegmentedControl, Stack, Switch, Text, TextInput, Title } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useSettingsStore } from '../../../shared/stores/settings.store.ts';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
import { READOUT_FIELDS, READOUT_OPTIONS, type AppSettings, type ReadoutMode } from '../../../store/settings.ts';
|
||||
import { BusinessSettings } from '../../../ui/business-settings.tsx';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function SettingsPage() {
|
||||
const runtime = useRuntime();
|
||||
const settings = useSettingsStore(state => state.settings);
|
||||
const user = useAuthStore(state => state.verifiedUser);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [serverUrl, setServerUrl] = useState(runtime.api.baseUrl);
|
||||
const [passwordOpen, setPasswordOpen] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const patch = async (value: Partial<AppSettings>) => { setError(''); setMessage(''); try { await runtime.updateSettings(value); setMessage('设置已保存'); } catch (reason) { setError(reason instanceof Error ? reason.message : '保存失败'); } };
|
||||
const saveServerUrl = async () => { const value = serverUrl.trim().replace(/\/+$/, ''); if (!/^https?:\/\//i.test(value)) { setError('服务端地址必须以 http:// 或 https:// 开头'); return; } runtime.api.setBaseUrl(value); await runtime.settings.setServerUrl(value); setMessage('服务端地址已保存'); };
|
||||
const changePassword = async (event: React.FormEvent) => { event.preventDefault(); setSaving(true); setError(''); try { await runtime.api.changePassword(currentPassword, newPassword); setMessage('密码已修改,请重新登录'); setPasswordOpen(false); } catch (reason) { setError(reason instanceof Error ? reason.message : '密码修改失败'); } finally { setSaving(false); } };
|
||||
useEffect(() => { setServerUrl(runtime.api.baseUrl); }, [runtime.api.baseUrl]);
|
||||
const readout = (key: keyof AppSettings) => String(settings[key]);
|
||||
return <Stack p="md" maw={1000} mx="auto"><Group justify="space-between"><Title order={1}>设置</Title><Group><Button component={Link} to="/shelf" variant="subtle">书架</Button><Button component={Link} to="/media/video" variant="subtle">影音</Button></Group></Group>{message && <Alert color="green">{message}</Alert>}{error && <Alert color="red">{error}</Alert>}<Card withBorder><Stack><Title order={3}>外观与阅读</Title><Select label="主题" value={settings.theme} onChange={value => value && void patch({ theme: value as AppSettings['theme'] })} data={[{ value: 'light', label: '浅色' }, { value: 'sepia', label: '护眼' }, { value: 'green', label: '绿色' }, { value: 'dark', label: '深色' }]} /><SegmentedControl fullWidth value={settings.mode} onChange={value => void patch({ mode: value as AppSettings['mode'] })} data={[{ value: 'scroll', label: '滚动阅读' }, { value: 'paged', label: '分页阅读' }]} /><NumberInput label="字体缩放" min={0.75} max={2.5} step={0.05} value={settings.fontScale} onChange={value => { if (typeof value === 'number') void patch({ fontScale: value }); }} /><NumberInput label="页边距(rem)" min={0} max={6} step={0.25} value={settings.pageMargin} onChange={value => { if (typeof value === 'number') void patch({ pageMargin: value }); }} /><Switch label="显示阅读进度条" checked={settings.shelfShowProgress} onChange={event => void patch({ shelfShowProgress: event.currentTarget.checked })} /><Switch label="书架显示作者" checked={settings.shelfShowAuthor} onChange={event => void patch({ shelfShowAuthor: event.currentTarget.checked })} /></Stack></Card><Card withBorder><Stack><Title order={3}>书架</Title><Select label="默认排序" value={settings.shelfSort} onChange={value => value && void patch({ shelfSort: value as AppSettings['shelfSort'] })} data={[{ value: 'recent', label: '最近阅读' }, { value: 'added', label: '最近入库' }, { value: 'title', label: '书名' }, { value: 'author', label: '作者' }]} /><Select label="封面密度" value={settings.shelfDensity} onChange={value => value && void patch({ shelfDensity: value as AppSettings['shelfDensity'] })} data={[{ value: 'compact', label: '紧凑' }, { value: 'cozy', label: '适中' }, { value: 'comfortable', label: '宽松' }]} /></Stack></Card><Card withBorder><Stack><Title order={3}>状态栏显示</Title>{READOUT_FIELDS.map(({ key, label }) => <Select key={key} label={label} value={readout(key)} onChange={value => value && void patch({ [key]: value as ReadoutMode })} data={READOUT_OPTIONS} />)}</Stack></Card><Card withBorder><Stack><Title order={3}>服务端</Title><TextInput label="服务端地址" value={serverUrl} onChange={event => setServerUrl(event.currentTarget.value)} /><Button onClick={() => void saveServerUrl()}>保存地址</Button><Divider /><Text size="sm" c="dimmed">当前用户:{user?.displayName || user?.username || '未知'} · {user?.role === 'admin' ? '管理员' : '成员'}</Text>{!passwordOpen ? <Button variant="light" onClick={() => setPasswordOpen(true)}>修改密码</Button> : <form onSubmit={changePassword}><Stack><TextInput type="password" label="当前密码" value={currentPassword} onChange={event => setCurrentPassword(event.currentTarget.value)} required /><TextInput type="password" label="新密码(至少 8 位)" minLength={8} value={newPassword} onChange={event => setNewPassword(event.currentTarget.value)} required /><Group><Button type="submit" loading={saving}>确认修改</Button><Button type="button" variant="subtle" onClick={() => setPasswordOpen(false)}>取消</Button></Group></Stack></form>}</Stack></Card>{user?.role === 'admin' && <Card withBorder><Stack><Title order={3}>服务端业务设置</Title><Text size="sm" c="dimmed">管理员配置仅在服务端保存,不会写入本设备。</Text><BusinessSettings api={runtime.api} /></Stack></Card>}</Stack>;
|
||||
}
|
||||
@@ -1,21 +1,19 @@
|
||||
import { Alert } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { BookOpen, Headphones, LibraryBig, MoreHorizontal, Search, Settings, SlidersVertical } from 'lucide-react';
|
||||
import type { Book, ContinueReadingItem } from '../../../api/types.ts';
|
||||
import { BookOpen, LibraryBig, MoreHorizontal, Search } from 'lucide-react';
|
||||
import type { Book, ContinueReadingItem, ReadingState } from '../../../api/types.ts';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
import { useSettingsStore } from '../../../shared/stores/settings.store.ts';
|
||||
import { useShelf } from '../queries/shelf.queries.ts';
|
||||
import { SHELF_SORTS, ShelfSettingsPanel } from '../../../ui/shelf-settings.tsx';
|
||||
import { useShelf, useShelfGroups, type ShelfFilters } from '../queries/shelf.queries.ts';
|
||||
import { SHELF_SORTS, ShelfSettingsSheet } from '../../../ui/shelf-settings.tsx';
|
||||
import { IconButton, Callout } from '../../../ui/toolkit.tsx';
|
||||
import { OpdsAccess } from '../../../ui/opds-access.tsx';
|
||||
import { SystemSettings } from '../../../ui/system-settings.tsx';
|
||||
import { DialogView, type Dialog } from '../../../ui/dialog.tsx';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
|
||||
const coverCache = new WeakMap<object, Map<string, string>>();
|
||||
function useCover(api: ReturnType<typeof useRuntime>['api'], coverUrl: string | null | undefined): string | null {
|
||||
export function useCover(api: ReturnType<typeof useRuntime>['api'], coverUrl: string | null | undefined): string | null {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!coverUrl) { setSrc(null); return; }
|
||||
@@ -33,7 +31,7 @@ function useCover(api: ReturnType<typeof useRuntime>['api'], coverUrl: string |
|
||||
return src;
|
||||
}
|
||||
|
||||
function BookCard({ book, showAuthor, showProgress, onRemove }: { book: Book | ContinueReadingItem; showAuthor: boolean; showProgress: boolean; onRemove?: () => void }) {
|
||||
export function BookCard({ book, showAuthor, showProgress, onRemove }: { book: Book | ContinueReadingItem; showAuthor: boolean; showProgress: boolean; onRemove?: () => void }) {
|
||||
const runtime = useRuntime();
|
||||
const src = useCover(runtime.api, book.coverUrl);
|
||||
const progress = 'percentage' in book ? book.percentage : runtime.offline?.progressFor(book.id)?.percentage;
|
||||
@@ -47,15 +45,20 @@ function BookCard({ book, showAuthor, showProgress, onRemove }: { book: Book | C
|
||||
<div className="title">{book.title || '未命名'}</div>
|
||||
{showAuthor && <div className="author">{book.author || '未知作者'}</div>}
|
||||
</Link>
|
||||
{onRemove && <button type="button" className="book-more" aria-label={`${book.title} 的操作`} title="从书架拿掉" onClick={onRemove}><MoreHorizontal size={17} aria-hidden="true" /></button>}
|
||||
{'readingState' in book && book.readingState && <span className="book-state" data-state={book.readingState}>{readingLabels[book.readingState]}</span>}
|
||||
{onRemove && <button type="button" className="book-more" aria-label={`${book.title} 的操作`} title="书籍操作" onClick={onRemove}><MoreHorizontal size={17} aria-hidden="true" /></button>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
const readingLabels: Record<ReadingState, string> = { reading: '在读', want: '想读', finished: '已读' };
|
||||
const readingStates = ['reading', 'want', 'finished'] as const;
|
||||
const isReadingState = (value: string | null): value is ReadingState => readingStates.includes(value as ReadingState);
|
||||
type BookAction = ReadingState | 'clear' | 'remove';
|
||||
|
||||
export function ShelfPage() {
|
||||
const runtime = useRuntime();
|
||||
const queryClient = useQueryClient();
|
||||
const settings = useSettingsStore(state => state.settings);
|
||||
const user = useAuthStore(state => state.verifiedUser);
|
||||
const [params, setParams] = useSearchParams();
|
||||
const page = Math.max(1, Number(params.get('page')) || 1);
|
||||
const [sort, setSort] = useState<typeof settings.shelfSort>((params.get('sort') as typeof settings.shelfSort) || settings.shelfSort);
|
||||
@@ -63,25 +66,57 @@ export function ShelfPage() {
|
||||
const [queryText, setQueryText] = useState(params.get('q') || '');
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [opdsOpen, setOpdsOpen] = useState(false);
|
||||
const [systemSettingsOpen, setSystemSettingsOpen] = useState(false);
|
||||
const [removeTarget, setRemoveTarget] = useState<Book | ContinueReadingItem | null>(null);
|
||||
const query = useShelf(page, sort, queryText);
|
||||
const [menuTarget, setMenuTarget] = useState<Book | null>(null);
|
||||
const [groupsOpen, setGroupsOpen] = useState(false);
|
||||
const [actionError, setActionError] = useState('');
|
||||
// 在读 / 想读 / 已读 and one 分组 (a series or a tag), all in the URL like the page and sort.
|
||||
const status = isReadingState(params.get('status')) ? params.get('status') as ReadingState : undefined;
|
||||
const series = params.get('series') || undefined, tag = series ? undefined : params.get('tag') || undefined;
|
||||
const filters: ShelfFilters = { ...(status ? { status } : {}), ...(series ? { series } : {}), ...(tag ? { tag } : {}) };
|
||||
const filtered = !!(status || series || tag);
|
||||
const groups = useShelfGroups(groupsOpen);
|
||||
const query = useShelf(page, sort, queryText, filters);
|
||||
const pages = query.data ? Math.max(1, Math.ceil(query.data.total / query.data.pageSize)) : 1;
|
||||
const cards = useMemo(() => query.data?.items ?? [], [query.data]);
|
||||
useEffect(() => { void runtime.updateSettings({ shelfSort: sort }); }, [runtime, sort]);
|
||||
const onSort = (value: string) => { const next = value as typeof sort; setSort(next); setParams({ ...(queryText ? { q: queryText } : {}), sort: next, page: '1' }); };
|
||||
const onSearch = (event: FormEvent) => { event.preventDefault(); setQueryText(search.trim()); setParams({ ...(search.trim() ? { q: search.trim() } : {}), sort, page: '1' }); };
|
||||
const clearSearch = () => { setSearch(''); setQueryText(''); setParams({ sort, page: '1' }); };
|
||||
// Every navigation keeps what it does not change; a new filter or query starts at page 1.
|
||||
const go = (next: { q?: string; sort?: typeof sort; page?: number; status?: ReadingState | null; series?: string | null; tag?: string | null }) => {
|
||||
const q = next.q ?? queryText, nextStatus = next.status === undefined ? status : next.status ?? undefined;
|
||||
const nextSeries = next.series === undefined ? series : next.series ?? undefined, nextTag = next.tag === undefined ? tag : next.tag ?? undefined;
|
||||
setParams({ ...(q ? { q } : {}), sort: next.sort ?? sort, page: String(next.page ?? 1), ...(nextStatus ? { status: nextStatus } : {}), ...(nextSeries ? { series: nextSeries } : nextTag ? { tag: nextTag } : {}) });
|
||||
};
|
||||
const onSort = (value: string) => { const next = value as typeof sort; setSort(next); go({ sort: next }); };
|
||||
const onSearch = (event: FormEvent) => { event.preventDefault(); setQueryText(search.trim()); go({ q: search.trim() }); };
|
||||
const clearSearch = () => { setSearch(''); setQueryText(''); go({ q: '' }); };
|
||||
const clearFilters = () => go({ status: null, series: null, tag: null });
|
||||
const act = async (book: Book, action: BookAction) => {
|
||||
if (action === 'remove') { setRemoveTarget(book); return; }
|
||||
setActionError('');
|
||||
try {
|
||||
await runtime.api.setReadingStatus(book.id, action === 'clear' ? null : action);
|
||||
await queryClient.invalidateQueries({ queryKey: ['shelf'] });
|
||||
} catch (error) { setActionError(error instanceof Error ? error.message : '标记失败,请稍后重试。'); }
|
||||
};
|
||||
const menuOptions = (book: Book) => [
|
||||
...readingStates.filter(state => state !== book.readingState).map(state => ({ value: state, label: '标记为' + readingLabels[state] })),
|
||||
...(book.readingState ? [{ value: 'clear', label: '清除标记' }] : []),
|
||||
{ value: 'remove', label: '从书架拿掉' },
|
||||
];
|
||||
const groupOptions = [
|
||||
{ value: '', label: '全部书籍' },
|
||||
...(groups.data?.series ?? []).map(value => ({ value: 'series:' + value, label: '系列 · ' + value })),
|
||||
...(groups.data?.tags ?? []).map(value => ({ value: 'tag:' + value, label: '标签 · ' + value })),
|
||||
];
|
||||
const groupLabel = series ? '系列 · ' + series : tag ? '标签 · ' + tag : '';
|
||||
const remove = async (book: Book | ContinueReadingItem) => { await runtime.api.browseBatchShelf({ bookIds: [book.id] }, 'remove'); await queryClient.invalidateQueries({ queryKey: ['shelf'] }); };
|
||||
const subtitle = queryText ? (query.data?.total ? `「${queryText}」匹配 ${query.data.total} 本` : `没有匹配「${queryText}」的书`) : query.data?.total ? (sort === 'recent' ? `最近阅读 · 共 ${query.data.total} 本` : `按${SHELF_SORTS.find(item => item.value === sort)?.label ?? ''}排列 · ${query.data.total} 本`) : '收藏好书,随时接着读';
|
||||
const densityLabel = settings.shelfDensity === 'compact' ? '紧凑' : settings.shelfDensity === 'comfortable' ? '宽松' : '适中';
|
||||
return <div id="shelf-scroll" className="shelf shelf-screen">
|
||||
<header className="shelf-head">
|
||||
<div className="shelf-head-text"><h1 className="shelf-title">{queryText ? '搜索结果' : '我的书架'}</h1><p className="shelf-subtitle muted">{subtitle}</p></div>
|
||||
<div className="shelf-head-text"><h1 className="shelf-title">{queryText ? '搜索结果' : '书架'}</h1><p className="shelf-subtitle muted">{subtitle}</p></div>
|
||||
<div className="shelf-head-actions">
|
||||
<Link className="icon-button" aria-label="影音" title="影音" to="/media/video"><Headphones className="icon" size="1em" strokeWidth={1.8} /></Link>
|
||||
{user?.role === 'admin' && <button className="icon-button" aria-label="系统设置" title="系统设置" onClick={() => setSystemSettingsOpen(true)}><Settings className="icon" size="1em" strokeWidth={1.8} /><span className="media-visually-hidden">系统设置</span></button>}
|
||||
<button className="icon-button" aria-label={`书架设置 · ${densityLabel}`} title={`书架设置 · ${densityLabel}`} onClick={() => setSettingsOpen(true)}><SlidersVertical className="icon" size="1em" strokeWidth={1.8} /><span className="media-visually-hidden">设置</span></button>
|
||||
<IconButton icon="tune" label={`书架设置 · ${densityLabel}`} onClick={() => setSettingsOpen(true)} />
|
||||
</div>
|
||||
</header>
|
||||
<div className="shelf-discovery">
|
||||
@@ -96,16 +131,34 @@ export function ShelfPage() {
|
||||
</form>
|
||||
</div>
|
||||
<section className="shelf-section" aria-label={queryText ? '搜索结果' : '全部书籍'}>
|
||||
{(!query.isPending && (cards.length > 0 || queryText)) && <div className="shelf-toolbar"><span className="shelf-count muted">{queryText ? `找到 ${query.data?.total ?? 0} 本` : `共 ${query.data?.total ?? 0} 本`}</span><div className="shelf-sort" role="group" aria-label="排序方式">{SHELF_SORTS.map(option => <button key={option.value} type="button" className="chip" aria-pressed={option.value === sort} onClick={() => onSort(option.value)}>{option.label}</button>)}</div></div>}
|
||||
<div className="shelf-filters">
|
||||
<div className="shelf-status" role="group" aria-label="阅读状态">
|
||||
<button type="button" className="chip" aria-pressed={!status} onClick={() => go({ status: null })}>全部</button>
|
||||
{readingStates.map(state => <button key={state} type="button" className="chip" aria-pressed={status === state} onClick={() => go({ status: state })}>{readingLabels[state]}</button>)}
|
||||
</div>
|
||||
{groupLabel
|
||||
? <button type="button" className="chip shelf-group-active" aria-pressed="true" aria-label={`${groupLabel},点按取消分组`} onClick={() => go({ series: null, tag: null })}>{groupLabel}<span aria-hidden="true"> ×</span></button>
|
||||
: <button type="button" className="chip shelf-group" aria-haspopup="dialog" onClick={() => setGroupsOpen(true)}>分组</button>}
|
||||
</div>
|
||||
<FloatingNotice message={actionError} error />
|
||||
{(!query.isPending && (cards.length > 0 || queryText || filtered)) && <div className="shelf-toolbar"><span className="shelf-count muted">{queryText ? `找到 ${query.data?.total ?? 0} 本` : `共 ${query.data?.total ?? 0} 本`}</span><div className="shelf-sort" role="group" aria-label="排序方式">{SHELF_SORTS.map(option => <button key={option.value} type="button" className="chip" aria-pressed={option.value === sort} onClick={() => onSort(option.value)}>{option.label}</button>)}</div></div>}
|
||||
{query.isPending && <FloatingNotice message="正在加载书架…" busy />}
|
||||
{query.error && <Alert color="red" title="书架加载失败">{query.error.message}</Alert>}
|
||||
{query.data && cards.length === 0 && <div className="empty-state collection-empty"><BookOpen className="empty-glyph" aria-hidden="true" /><p>{queryText ? '没有匹配的书' : '书架还没有书'}</p><p className="muted">{queryText ? '换个关键词,或者检查一下作者名的写法' : '从书库挑选喜欢的书,或通过书源搜索,加入书架后就能开始阅读。'}</p><div className="empty-actions">{queryText ? <button type="button" className="button" onClick={clearSearch}>清除搜索</button> : <><Link className="button primary" to="/sources"><Search size={16} />去搜书</Link><Link className="button" to="/library"><LibraryBig size={16} />打开书库</Link></>}</div></div>}
|
||||
{cards.length > 0 && <div className="book-grid" data-density={settings.shelfDensity} data-show-author={String(settings.shelfShowAuthor)} data-show-progress={String(settings.shelfShowProgress)}>{cards.map(book => <BookCard key={book.id} book={book} showAuthor={settings.shelfShowAuthor} showProgress={settings.shelfShowProgress} onRemove={() => setRemoveTarget(book)} />)}</div>}
|
||||
{query.data && pages > 1 && <nav className="shelf-pager" aria-label="翻页"><button type="button" disabled={page <= 1} onClick={() => setParams({ ...(queryText ? { q: queryText } : {}), sort, page: String(page - 1) })}>上一页</button><span>{page} / {pages}</span><button type="button" disabled={page >= pages} onClick={() => setParams({ ...(queryText ? { q: queryText } : {}), sort, page: String(page + 1) })}>下一页</button></nav>}
|
||||
{query.error && <Callout tone="error" title="书架加载失败">{query.error.message}</Callout>}
|
||||
{query.data && cards.length === 0 && filtered && !queryText && <div className="empty-state collection-empty"><BookOpen className="empty-glyph" aria-hidden="true" /><p>{status ? `还没有${readingLabels[status]}的书` : '这个分组里没有书'}</p><p className="muted">{status === 'want' ? '在书籍的“⋯”菜单里可以标记想读。' : '换一个分类,或查看全部书籍。'}</p><div className="empty-actions"><button type="button" className="button" onClick={clearFilters}>查看全部书籍</button></div></div>}
|
||||
{query.data && cards.length === 0 && !(filtered && !queryText) && <div className="empty-state collection-empty"><BookOpen className="empty-glyph" aria-hidden="true" /><p>{queryText ? '没有匹配的书' : '书架还没有书'}</p><p className="muted">{queryText ? '换个关键词,或者检查一下作者名的写法' : '从书库挑选喜欢的书,或通过书源搜索,加入书架后就能开始阅读。'}</p><div className="empty-actions">{queryText ? <button type="button" className="button" onClick={clearSearch}>清除搜索</button> : <><Link className="button primary" to="/sources"><Search size={16} />去搜书</Link><Link className="button" to="/library"><LibraryBig size={16} />打开书库</Link></>}</div></div>}
|
||||
{cards.length > 0 && <div className="book-grid" data-density={settings.shelfDensity} data-show-author={String(settings.shelfShowAuthor)} data-show-progress={String(settings.shelfShowProgress)}>{cards.map(book => <BookCard key={book.id} book={book} showAuthor={settings.shelfShowAuthor} showProgress={settings.shelfShowProgress} onRemove={() => setMenuTarget(book)} />)}</div>}
|
||||
{query.data && pages > 1 && <nav className="shelf-pager" aria-label="翻页"><button type="button" disabled={page <= 1} onClick={() => go({ page: page - 1 })}>上一页</button><span>{page} / {pages}</span><button type="button" disabled={page >= pages} onClick={() => go({ page: page + 1 })}>下一页</button></nav>}
|
||||
</section>
|
||||
<ShelfSettingsPanel open={settingsOpen} settings={settings} onPatch={patch => { void runtime.updateSettings(patch); }} onClose={() => setSettingsOpen(false)} onOpenExternalReader={() => { setSettingsOpen(false); setOpdsOpen(true); }} />
|
||||
{settingsOpen && <ShelfSettingsSheet settings={settings} onPatch={patch => { void runtime.updateSettings(patch); }} onClose={() => setSettingsOpen(false)} onOpenExternalReader={() => { setSettingsOpen(false); setOpdsOpen(true); }} />}
|
||||
{opdsOpen && <OpdsAccess api={runtime.api} onSignedOut={() => { void runtime.api.signOut(); }} onClose={() => { setOpdsOpen(false); setSettingsOpen(true); }} />}
|
||||
{systemSettingsOpen && <SystemSettings api={runtime.api} onClose={() => setSystemSettingsOpen(false)} />}
|
||||
{menuTarget && <DialogView dialog={{ kind: 'pick', title: `《${menuTarget.title || '未命名'}》`, options: menuOptions(menuTarget), resolve: (_value: string | null) => undefined } satisfies Dialog} destructive="从书架拿掉" onClose={answer => { const target = menuTarget; setMenuTarget(null); if (typeof answer === 'string' && answer) void act(target, answer as BookAction); }} />}
|
||||
{groupsOpen && <DialogView dialog={{ kind: 'pick', title: groups.isPending ? '分组 · 正在读取…' : groups.error ? '分组 · 读取失败' : '分组', options: groupOptions, resolve: (_value: string | null) => undefined } satisfies Dialog} destructive="" onClose={answer => {
|
||||
setGroupsOpen(false);
|
||||
if (typeof answer !== 'string') return;
|
||||
if (answer === '') go({ series: null, tag: null });
|
||||
else if (answer.startsWith('series:')) go({ series: answer.slice(7), tag: null });
|
||||
else if (answer.startsWith('tag:')) go({ tag: answer.slice(4), series: null });
|
||||
}} />}
|
||||
{removeTarget && <DialogView dialog={{ kind: 'confirm', title: '移出书架', body: `确定要将《${removeTarget.title}》移出书架吗?`, resolve: (_answer: boolean) => undefined } satisfies Dialog} destructive="移出书架" onClose={answer => { const target = removeTarget; setRemoveTarget(null); if (answer === true) void remove(target); }} />}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,8 +1,35 @@
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { AppRuntime } from '../../../app/runtime.ts';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
export function shelfQuery(runtime: ReturnType<typeof useRuntime>, page = 1, sort: 'recent' | 'added' | 'title' | 'author' = 'recent', search = '') {
|
||||
const apiSort = sort === 'recent' ? 'updated' : sort;
|
||||
return queryOptions({ queryKey: ['shelf', runtime.api.baseUrl, runtime.api.currentSession()?.user.id, page, sort, search], queryFn: ({ signal }) => runtime.api.listBooks({ scope: 'shelf', page, pageSize: 60, sort: apiSort, order: sort === 'title' || sort === 'author' ? 'asc' : 'desc', search: search.trim() }, { signal }) });
|
||||
}
|
||||
export function useShelf(page = 1, sort: 'recent' | 'added' | 'title' | 'author' = 'recent', search = '') { return useQuery(shelfQuery(useRuntime(), page, sort, search)); }
|
||||
import type { ReadingState } from '../../../api/types.ts';
|
||||
|
||||
/**
|
||||
* What narrows the shelf besides the search: 在读 / 想读 / 已读, and one 分组 — a
|
||||
* series or a tag, the two groupings the books themselves carry.
|
||||
*/
|
||||
export interface ShelfFilters { status?: ReadingState; series?: string; tag?: string }
|
||||
|
||||
export function shelfQuery(runtime: AppRuntime, page = 1, sort: 'recent' | 'added' | 'title' | 'author' = 'recent', search = '', filters: ShelfFilters = {}) {
|
||||
const apiSort = sort === 'recent' ? 'updated' : sort;
|
||||
return queryOptions({
|
||||
queryKey: ['shelf', runtime.api.baseUrl, runtime.api.currentSession()?.user.id, page, sort, search, filters.status ?? '', filters.series ?? '', filters.tag ?? ''],
|
||||
queryFn: ({ signal }) => runtime.api.listBooks({
|
||||
scope: 'shelf', page, pageSize: 60, sort: apiSort, order: sort === 'title' || sort === 'author' ? 'asc' : 'desc', search: search.trim(),
|
||||
...(filters.status ? { status: filters.status } : {}), ...(filters.series ? { series: filters.series } : {}), ...(filters.tag ? { tag: filters.tag } : {}),
|
||||
}, { signal }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useShelf(page = 1, sort: 'recent' | 'added' | 'title' | 'author' = 'recent', search = '', filters: ShelfFilters = {}) {
|
||||
return useQuery(shelfQuery(useRuntime(), page, sort, search, filters));
|
||||
}
|
||||
|
||||
/** Series and tags on this account's shelf: the choices behind 分组. */
|
||||
export function useShelfGroups(enabled: boolean) {
|
||||
const runtime = useRuntime();
|
||||
return useQuery({
|
||||
queryKey: ['shelf-groups', runtime.api.baseUrl, runtime.api.currentSession()?.user.id],
|
||||
queryFn: ({ signal }) => runtime.api.facets({ signal }),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { SourceEntry } from '../../../api/sources.ts';
|
||||
import { Sheet } from '../../../ui/sheet.tsx';
|
||||
import { Button } from '../../../ui/toolkit.tsx';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
|
||||
/**
|
||||
* A source result's detail, shared by the source hub and a single source's catalog:
|
||||
* they show the same entry and offer the same 加入书架 choices.
|
||||
*/
|
||||
export function SourceBookDetailSheet({ detail, pending, error, acquiring, onAcquire, onClose }: {
|
||||
detail: SourceEntry;
|
||||
pending: boolean;
|
||||
error: Error | null;
|
||||
acquiring: boolean;
|
||||
onAcquire(entry: SourceEntry, option?: string): void;
|
||||
onClose(): void;
|
||||
}) {
|
||||
const options = detail.options ?? [];
|
||||
return <Sheet title={detail.title || '书籍详情'} onClose={onClose}
|
||||
footer={options.length
|
||||
? options.map(option => <Button key={option.id} className="primary" disabled={option.available === false || acquiring} onClick={() => onAcquire(detail, option.id)}>{option.label}</Button>)
|
||||
: <Button className="primary" disabled={acquiring} onClick={() => onAcquire(detail)}>加入书架</Button>}>
|
||||
<FloatingNotice message={pending ? '正在读取书籍详情…' : ''} busy />
|
||||
<div className="book-detail">
|
||||
<p className="book-detail-author">{detail.authors?.join('、') || '作者未知'}</p>
|
||||
{error && <p className="notice error" role="alert">{error.message}</p>}
|
||||
<p className="book-detail-description">{detail.description || '暂无简介'}</p>
|
||||
</div>
|
||||
</Sheet>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Icon } from '../../../ui/toolkit.tsx';
|
||||
|
||||
/** The heading every 书源 page uses: back, title, an optional subtitle and actions. */
|
||||
export function SourcesHeader({ title, subtitle, backTo = '/sources', actions }: { title: string; subtitle?: string; backTo?: string; actions?: ReactNode }) {
|
||||
return <header className="sources-header">
|
||||
<Link className="icon-button" to={backTo} aria-label="返回" title="返回"><Icon name="arrow-left" /></Link>
|
||||
<div className="sources-header-text"><h1>{title}</h1>{subtitle && <p className="muted">{subtitle}</p>}</div>
|
||||
{actions ?? <span aria-hidden="true" />}
|
||||
</header>;
|
||||
}
|
||||
@@ -1,53 +1,125 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Check, Package, Upload, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Package } from 'lucide-react';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
import { usePlugins, useSourceMutations } from '../hooks/sourceQueries.ts';
|
||||
import type { SourcePlugin } from '../../../api/sources.ts';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
import { ActionMenu } from '../../../ui/action-menu.tsx';
|
||||
import { ConfirmSheet, Sheet } from '../../../ui/sheet.tsx';
|
||||
import { Button, Callout, Icon, Segmented, SwitchRow } from '../../../ui/toolkit.tsx';
|
||||
import { notify } from '../../../ui/notifications.ts';
|
||||
import { SourcesHeader } from '../components/SourcesHeader.tsx';
|
||||
import '../styles/plugins.css';
|
||||
|
||||
function message(reason: unknown, fallback: string): string {
|
||||
return reason instanceof Error && reason.message ? reason.message : fallback;
|
||||
}
|
||||
|
||||
function status(plugin: SourcePlugin): { label: string; tone: 'on' | 'off' | 'error' | 'builtin' } {
|
||||
if (plugin.error) return { label: '加载失败', tone: 'error' };
|
||||
if (plugin.builtin) return { label: '内置', tone: 'builtin' };
|
||||
return plugin.enabled ? { label: '已启用', tone: 'on' } : { label: '已停用', tone: 'off' };
|
||||
}
|
||||
|
||||
/** 插件管理: the installed source plugins, and a sheet to install another. */
|
||||
export function PluginManagementPage() {
|
||||
const admin = useAuthStore(state => state.verifiedUser?.role === 'admin');
|
||||
const plugins = usePlugins(admin);
|
||||
const mutations = useSourceMutations();
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [removing, setRemoving] = useState<SourcePlugin | null>(null);
|
||||
if (!admin) return <div className="sources-screen plugin-management-screen">
|
||||
<SourcesHeader title="插件管理" />
|
||||
<main className="sources-body plugin-body"><Callout tone="warning">只有管理员可以管理插件</Callout></main>
|
||||
</div>;
|
||||
|
||||
const list = plugins.data ?? [];
|
||||
const toggle = (plugin: SourcePlugin) => mutations.enablePlugin.mutate({ id: plugin.pluginId, enabled: !plugin.enabled }, {
|
||||
onError: reason => notify(message(reason, plugin.enabled ? '停用失败' : '启用失败'), { kind: 'error' }),
|
||||
});
|
||||
const uninstall = (plugin: SourcePlugin) => mutations.uninstallPlugin.mutate(plugin.pluginId, {
|
||||
onSuccess: () => { setRemoving(null); notify(`已卸载 ${plugin.name || plugin.pluginId}`, { kind: 'success' }); },
|
||||
onError: reason => { setRemoving(null); notify(message(reason, '卸载失败'), { kind: 'error' }); },
|
||||
});
|
||||
|
||||
return <div className="sources-screen plugin-management-screen">
|
||||
<SourcesHeader title="插件管理" {...(plugins.data ? { subtitle: `${list.length} 个插件` } : {})}
|
||||
actions={<Button className="primary plugin-install-open" onClick={() => setInstalling(true)}><Icon name="upload" /><span>安装插件</span></Button>} />
|
||||
<main className="sources-body plugin-body">
|
||||
{plugins.isPending && <FloatingNotice message="正在读取插件列表…" busy />}
|
||||
{plugins.error && <Callout tone="error" title="读取失败" action={{ label: '重试', onClick: () => { void plugins.refetch(); } }}>{plugins.error.message}</Callout>}
|
||||
{plugins.data && !list.length && <section className="plugin-empty">
|
||||
<span className="plugin-empty-icon" aria-hidden="true"><Package size={28} /></span>
|
||||
<strong>还没有安装插件</strong>
|
||||
<p>插件可以接入新的书源类型</p>
|
||||
<Button className="primary" onClick={() => setInstalling(true)}>安装插件</Button>
|
||||
</section>}
|
||||
{list.length > 0 && <ul className="plugin-list" aria-label="已安装插件">
|
||||
{list.map(plugin => {
|
||||
const state = status(plugin);
|
||||
const name = plugin.name || plugin.pluginId;
|
||||
return <li className="plugin-row" key={plugin.pluginId} data-disabled={!plugin.enabled || undefined}>
|
||||
<span className="plugin-row-icon" aria-hidden="true"><Package size={20} /></span>
|
||||
<div className="plugin-row-text">
|
||||
<div className="plugin-row-title"><strong>{name}</strong><span className="plugin-tag" data-tone={state.tone}>{state.label}</span></div>
|
||||
<small>{[plugin.version ? `v${plugin.version}` : '未知版本', plugin.name ? plugin.pluginId : ''].filter(Boolean).join(' · ')}</small>
|
||||
{plugin.error && <small className="plugin-row-error">{plugin.error.message}</small>}
|
||||
</div>
|
||||
{!plugin.builtin && <ActionMenu className="plugin-row-more" label={`${name} 的操作`} items={[
|
||||
{ label: plugin.enabled ? '停用' : '启用', disabled: mutations.enablePlugin.isPending, onSelect: () => toggle(plugin) },
|
||||
{ label: '卸载…', danger: true, disabled: mutations.uninstallPlugin.isPending, onSelect: () => setRemoving(plugin) },
|
||||
]} />}
|
||||
</li>;
|
||||
})}
|
||||
</ul>}
|
||||
</main>
|
||||
{installing && <InstallPluginSheet onClose={() => setInstalling(false)} />}
|
||||
{removing && <ConfirmSheet danger title="卸载插件?" confirmText="卸载" busy={mutations.uninstallPlugin.isPending}
|
||||
text={`卸载「${removing.name || removing.pluginId}」后,使用它的书源将无法再搜索和更新`}
|
||||
onCancel={() => setRemoving(null)} onConfirm={() => uninstall(removing)} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function InstallPluginSheet({ onClose }: { onClose(): void }) {
|
||||
const mutations = useSourceMutations();
|
||||
const [mode, setMode] = useState<'npm' | 'file'>('npm');
|
||||
const [packageName, setPackageName] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [trusted, setTrusted] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const busy = mutations.installPlugin.isPending || mutations.uploadPlugin.isPending;
|
||||
if (!admin) return <div className="sources-screen plugin-management-screen"><FloatingNotice message="只有管理员可以管理插件。" error /></div>;
|
||||
const ready = trusted && (mode === 'npm' ? !!packageName.trim() : !!file);
|
||||
const install = async () => {
|
||||
if (!ready) return;
|
||||
setError('');
|
||||
if (!trusted) { setError('请确认插件来源可信。'); return; }
|
||||
try {
|
||||
if (file) await mutations.uploadPlugin.mutateAsync(file);
|
||||
else if (packageName.trim()) await mutations.installPlugin.mutateAsync(packageName.trim());
|
||||
else { setError('请输入 npm 包名或选择插件包。'); return; }
|
||||
setPackageName(''); setFile(null); setTrusted(false); if (fileInput.current) fileInput.current.value = '';
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : '插件安装失败'); }
|
||||
const plugin = mode === 'file' && file
|
||||
? await mutations.uploadPlugin.mutateAsync(file)
|
||||
: await mutations.installPlugin.mutateAsync(packageName.trim());
|
||||
notify(`已安装 ${plugin?.name || plugin?.pluginId || '插件'}`, { kind: 'success' });
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setError(message(reason, '插件安装失败'));
|
||||
}
|
||||
};
|
||||
return <div className="sources-screen plugin-management-screen">
|
||||
<header className="sources-header"><Link className="icon-button" to="/sources" aria-label="返回" title="返回"><ArrowLeft size={20} /></Link><h1>插件管理</h1><span /></header>
|
||||
<main className="sources-body plugin-management-body">
|
||||
{plugins.isPending && <FloatingNotice message="正在加载插件列表…" busy />}
|
||||
{error && <div className="notice error" role="alert">{error}</div>}
|
||||
<section className="sources-card plugin-install-card">
|
||||
<div className="plugin-card-heading"><Package size={20} aria-hidden="true" /><div><h2>安装插件</h2><p className="muted">从 npm 包名或本地压缩包安装来源插件。</p></div></div>
|
||||
<p className="notice">插件以服务端权限运行。安装前请确认来源可信;npm 安装需要服务端能够访问 npm 仓库。</p>
|
||||
<form className="plugin-install-form" onSubmit={event => { event.preventDefault(); void install(); }}>
|
||||
<div className="plugin-install-grid">
|
||||
<label>npm 包名<input type="text" placeholder="例如 reader-source-example" value={packageName} onChange={event => setPackageName(event.currentTarget.value)} disabled={busy || Boolean(file)} /></label>
|
||||
<label>上传插件包<input ref={fileInput} type="file" accept=".tgz,application/gzip,application/x-gzip" onChange={event => setFile(event.currentTarget.files?.[0] ?? null)} disabled={busy || Boolean(packageName.trim())} /></label>
|
||||
</div>
|
||||
<label className="plugin-trust"><input type="checkbox" checked={trusted} onChange={event => setTrusted(event.currentTarget.checked)} />我信任这个插件的代码</label>
|
||||
<button className="button primary plugin-install-submit" type="submit" disabled={busy || !trusted || (!packageName.trim() && !file)}><Upload size={16} aria-hidden="true" />{busy ? '安装中…' : '安装并启用'}</button>
|
||||
</form>
|
||||
</section>
|
||||
{plugins.error && <div className="notice error" role="alert">{plugins.error.message}</div>}
|
||||
<section className="sources-card plugin-list-card"><div className="plugin-list-heading"><h2>已安装插件</h2><span className="source-count">{plugins.data?.length ?? 0}</span></div>
|
||||
{!plugins.isPending && !plugins.data?.length && <p className="muted">暂无已安装插件。</p>}
|
||||
<div className="plugin-list">{(plugins.data ?? []).map(plugin => <article className="sources-row plugin-row" key={plugin.pluginId}><div className="plugin-row-info"><strong>{plugin.name || plugin.pluginId}</strong><small>{plugin.version || '未知版本'} · {plugin.builtin ? '内置' : plugin.enabled ? '已启用' : '已停用'}</small>{plugin.error && <small className="plugin-error">{plugin.error.message}</small>}</div>{!plugin.builtin && <div className="sources-actions plugin-row-actions"><button className="button" type="button" disabled={mutations.enablePlugin.isPending} onClick={() => mutations.enablePlugin.mutate({ id: plugin.pluginId, enabled: !plugin.enabled })}>{plugin.enabled ? <><X size={15} />停用</> : <><Check size={15} />启用</>}</button><button className="button danger" type="button" disabled={mutations.uninstallPlugin.isPending} onClick={() => mutations.uninstallPlugin.mutate(plugin.pluginId)}>卸载</button></div>}</article>)}</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>;
|
||||
return <Sheet title="安装插件" busy={busy} onClose={onClose}
|
||||
footer={<>
|
||||
<Button disabled={busy} onClick={onClose}>取消</Button>
|
||||
<Button className="primary" type="submit" form="plugin-install-form" disabled={busy || !ready}>{busy ? '安装中…' : '安装并启用'}</Button>
|
||||
</>}>
|
||||
<form id="plugin-install-form" className="sheet-form plugin-install-form" onSubmit={event => { event.preventDefault(); void install(); }}>
|
||||
<Segmented label="安装方式" value={mode} onChange={value => { setMode(value); setError(''); }}
|
||||
options={[{ value: 'npm', label: 'npm 包' }, { value: 'file', label: '本地文件' }]} />
|
||||
{mode === 'npm'
|
||||
? <label className="sheet-field">npm 包名
|
||||
<input type="text" placeholder="例如 reader-source-example" autoComplete="off" spellCheck={false} value={packageName} disabled={busy} onChange={event => setPackageName(event.currentTarget.value)} />
|
||||
<small>服务端需要能访问 npm 仓库</small>
|
||||
</label>
|
||||
: <label className="sheet-field">插件包(.tgz)
|
||||
<input type="file" accept=".tgz,application/gzip,application/x-gzip" disabled={busy} onChange={event => setFile(event.currentTarget.files?.[0] ?? null)} />
|
||||
</label>}
|
||||
<SwitchRow label="我信任这个插件" hint="插件以服务端权限运行,只安装来源可靠的插件" checked={trusted} onChange={setTrusted} />
|
||||
{error && <Callout tone="error">{error}</Callout>}
|
||||
</form>
|
||||
</Sheet>;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Alert, Button, Group, Modal, Select, Stack, Text } from '@mantine/core';
|
||||
import { Button, Callout } from '../../../ui/toolkit.tsx';
|
||||
import { SourcesHeader } from '../components/SourcesHeader.tsx';
|
||||
import { SourceBookDetailSheet } from '../components/SourceBookDetailSheet.tsx';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
@@ -53,34 +55,23 @@ export function SourceCatalogPage() {
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
if (sources.isPending) return <div className="sources-screen"><FloatingNotice message="正在加载书源…" busy /><main className="sources-body" /></div>;
|
||||
if (!source) return <div className="sources-screen source-catalog-screen"><header className="sources-header"><Button className="button icon-button" variant="subtle" onClick={() => navigate('/sources')} aria-label="返回">←</Button><h1>选择书源</h1></header><main className="sources-body"><section className="sources-card source-picker"><h2>选择来源</h2><p className="muted">选择一个已启用的书源开始搜索或浏览。</p><Select label="书源" placeholder="选择来源" data={sources.data?.filter(item => item.enabled && item.descriptor).map(item => ({ value: item.id, label: item.name })) ?? []} onChange={value => value && navigate(`/sources/${encodeURIComponent(value)}/browse`)} /></section></main></div>;
|
||||
if (!source) return <div className="sources-screen source-catalog-screen"><SourcesHeader title="选择书源" /><main className="sources-body"><section className="sources-card source-picker"><h2>选择来源</h2><p className="muted">选择一个已启用的书源开始搜索或浏览</p><label>书源<select defaultValue="" onChange={event => { const value = event.currentTarget.value; if (value) navigate(`/sources/${encodeURIComponent(value)}/browse`); }}><option value="" disabled>选择来源</option>{sources.data?.filter(item => item.enabled && item.descriptor).map(item => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label></section></main></div>;
|
||||
const supportsSearch = source.descriptor?.capabilities.includes('search');
|
||||
const supportsBrowse = source.descriptor?.capabilities.includes('browse');
|
||||
return <div className="sources-screen source-catalog-screen">
|
||||
<header className="sources-header"><Button className="button icon-button" variant="subtle" onClick={() => navigate('/sources')} aria-label="返回">←</Button><div><h1>{source.name}</h1><p className="muted">{source.descriptor?.label ?? '书源目录'}</p></div></header>
|
||||
<SourcesHeader title={source.name} subtitle={source.descriptor?.label ?? '书源目录'} />
|
||||
<main className="sources-body">
|
||||
{error && <Alert color="red" withCloseButton onClose={() => setError('')}>{error}</Alert>}
|
||||
{supportsSearch && <section className="sources-card source-catalog"><form className="sources-search" onSubmit={event => { event.preventDefault(); void search(false); }}><label className="source-keyword">搜索书籍<input type="search" placeholder="输入书名或作者" value={query} onChange={event => setQuery(event.currentTarget.value)} required /></label>{filterQuery.data?.map(field => field.type === 'select' ? <label key={field.key}>{field.label}<Select value={filters[field.key] ?? ''} data={field.options?.map(option => ({ value: option.value, label: option.label })) ?? []} onChange={value => setFilters(previous => ({ ...previous, [field.key]: value ?? '' }))} /></label> : null)}<Button type="submit" loading={busy}>搜索</Button>{page?.nextCursor && <Button type="button" variant="light" loading={busy} onClick={() => void search(true)}>继续搜索</Button>}</form></section>}
|
||||
<div className="source-catalog-actions">{supportsBrowse && <Button className="button" variant="light" loading={busy} onClick={() => void browse()}>浏览目录首页</Button>}</div>
|
||||
{page?.batch && <Text size="sm" c="dimmed">已检查 {page.batch.completed} / {page.batch.total} 个来源</Text>}
|
||||
{page?.navigation && <Group className="source-navigation">{page.navigation.map(item => <Button className="button" key={item.ref} variant="subtle" onClick={() => void browse(item.ref)}>{item.title}</Button>)}</Group>}
|
||||
{page?.errors?.map(item => <Alert key={`${item.source}-${item.code}`} color="yellow">{item.source}: {item.message}</Alert>)}
|
||||
{!page && !busy && <Alert>输入关键词搜索,或浏览书源目录。</Alert>}
|
||||
{error && <Callout tone="error" onDismiss={() => setError('')}>{error}</Callout>}
|
||||
{supportsSearch && <section className="sources-card source-catalog"><form className="sources-search" onSubmit={event => { event.preventDefault(); void search(false); }}><label className="source-keyword">搜索书籍<input type="search" placeholder="输入书名或作者" value={query} onChange={event => setQuery(event.currentTarget.value)} required /></label>{filterQuery.data?.map(field => field.type === 'select' ? <label key={field.key}>{field.label}<select value={filters[field.key] ?? ''} onChange={event => { const value = event.currentTarget.value; setFilters(previous => ({ ...previous, [field.key]: value })); }}><option value="">不限</option>{field.options?.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label> : null)}<Button type="submit" className="primary" disabled={busy}>搜索</Button>{page?.nextCursor && <Button type="button" disabled={busy} onClick={() => void search(true)}>继续搜索</Button>}</form></section>}
|
||||
<div className="source-catalog-actions">{supportsBrowse && <Button disabled={busy} onClick={() => void browse()}>浏览目录首页</Button>}</div>
|
||||
{page?.batch && <p className="muted">已检查 {page.batch.completed} / {page.batch.total} 个来源</p>}
|
||||
{page?.navigation && <nav className="source-navigation sources-actions" aria-label="目录">{page.navigation.map(item => <Button key={item.ref} onClick={() => void browse(item.ref)}>{item.title}</Button>)}</nav>}
|
||||
{page?.errors?.map(item => <Callout tone="warning" key={`${item.source}-${item.code}`}>{item.source}: {item.message}</Callout>)}
|
||||
{!page && !busy && <Callout>输入关键词搜索,或浏览书源目录</Callout>}
|
||||
{busy && !page && <FloatingNotice message="正在搜索…" busy />}
|
||||
<section className="sources-card"><div className="source-results">{page?.items.map(item => { const option = item.options?.[0]; return <article className="sources-row catalog-book" key={item.ref}><div><strong>{item.title}</strong>{item.latestChapter && <small>{item.latestChapter}</small>}{item.authors?.length ? <small>{item.authors.join('、')}</small> : null}{item.description && <p className="source-description">{item.description}</p>}</div><div className="sources-actions"><Button className="button" onClick={() => setDetail(item)}>详情</Button><Button className="button primary" disabled={option?.available === false} loading={acquire.isPending} onClick={() => acquireEntry(item, option?.id)}>{option?.label ?? '加入书架'}</Button></div></article>; })}</div></section>
|
||||
{page && !busy && page.items.length === 0 && <Alert>没有找到匹配书籍。</Alert>}
|
||||
<Modal opened={!!detail} onClose={() => setDetail(null)} title={detail?.title ?? '书籍详情'}>
|
||||
<Stack>
|
||||
{detailQuery.isPending && <FloatingNotice message="正在读取书籍详情…" busy />}
|
||||
{detailQuery.error && <Alert color="red">{detailQuery.error.message}</Alert>}
|
||||
{detail && <>
|
||||
<Text>{detail.description || '暂无简介'}</Text>
|
||||
<Text size="sm" c="dimmed">{detail.authors?.join('、') || '作者未知'}</Text>
|
||||
{detail.options?.map(option => <Button key={option.id} disabled={option.available === false} loading={acquire.isPending} onClick={() => acquireEntry(detail, option.id)}>{option.label}</Button>)}
|
||||
{(!detail.options || detail.options.length === 0) && <Button loading={acquire.isPending} onClick={() => acquireEntry(detail)}>加入书架</Button>}
|
||||
</>}
|
||||
</Stack>
|
||||
</Modal>
|
||||
<section className="sources-card"><div className="source-results">{page?.items.map(item => { const option = item.options?.[0]; return <article className="sources-row catalog-book" key={item.ref}><div><strong>{item.title}</strong>{item.latestChapter && <small>{item.latestChapter}</small>}{item.authors?.length ? <small>{item.authors.join('、')}</small> : null}{item.description && <p className="source-description">{item.description}</p>}</div><div className="sources-actions"><Button onClick={() => setDetail(item)}>详情</Button><Button className="primary" disabled={(option?.available === false) || (acquire.isPending)} onClick={() => acquireEntry(item, option?.id)}>{option?.label ?? '加入书架'}</Button></div></article>; })}</div></section>
|
||||
{page && !busy && page.items.length === 0 && <Callout>没有找到匹配的书籍</Callout>}
|
||||
{detail && <SourceBookDetailSheet detail={detail} pending={detailQuery.isPending} error={detailQuery.error} acquiring={acquire.isPending} onAcquire={acquireEntry} onClose={() => setDetail(null)} />}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,17 +1,57 @@
|
||||
import { Alert, Button, Card, Group, PasswordInput, Stack, Text, Title } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useSources, useSourceMutations } from '../hooks/sourceQueries.ts';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
import { Button, Callout } from '../../../ui/toolkit.tsx';
|
||||
import { SourcesHeader } from '../components/SourcesHeader.tsx';
|
||||
|
||||
const STATES = {
|
||||
unknown: '尚未验证访问',
|
||||
reachable: '最近访问成功',
|
||||
'auth-required': '需要登录或凭据已过期',
|
||||
'verification-required': '需要人工验证',
|
||||
} as const;
|
||||
|
||||
export function SourceCredentialsPage() {
|
||||
const { api } = useRuntime(); const navigate = useNavigate(); const { sourceId = '' } = useParams();
|
||||
const sources = useSources(); const source = sources.data?.find(item => item.id === sourceId); const mutations = useSourceMutations();
|
||||
const { api } = useRuntime();
|
||||
const navigate = useNavigate();
|
||||
const { sourceId = '' } = useParams();
|
||||
const sources = useSources();
|
||||
const source = sources.data?.find(item => item.id === sourceId);
|
||||
const mutations = useSourceMutations();
|
||||
const status = useQuery({ queryKey: ['source-credentials', api.baseUrl, sourceId], queryFn: () => api.credentialStatus(sourceId), enabled: !!source });
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
if (sources.isPending) return <FloatingNotice message="正在加载书源…" busy />; if (!source) return <Alert color="red">来源不存在。</Alert>;
|
||||
const save = async (event: React.FormEvent) => { event.preventDefault(); for (const field of source.descriptor?.credentialKeys ?? []) if (field.key in values) { const value = values[field.key] ?? ''; if (value) await mutations.credential.mutateAsync({ id: source.id, key: field.key, value }); else await api.deleteCredential(source.id, field.key); } navigate('/sources'); };
|
||||
return <Stack p="md" maw={700} mx="auto"><Group justify="space-between"><Title order={1}>{source.name} · 凭据</Title><Button variant="subtle" onClick={() => navigate('/sources')}>返回</Button></Group><Card withBorder><form onSubmit={save}><Stack>{status.isPending && <FloatingNotice message="正在读取凭据状态…" busy />}{status.data && <><Text>状态:{{ unknown: '尚未验证访问', reachable: '最近访问成功', 'auth-required': '需要登录或凭据已过期', 'verification-required': '需要人工验证' }[status.data.state]}</Text>{!status.data.available && <Alert color="yellow">来源当前不可用,请联系管理员检查插件。</Alert>}</>}{source.descriptor?.credentialKeys?.map(field => <PasswordInput key={field.key} label={`${field.label} · ${status.data?.fields.find(item => item.key === field.key)?.configured ? '已配置' : '未配置'}`} value={values[field.key] ?? ''} onChange={event => setValues(previous => ({ ...previous, [field.key]: event.currentTarget.value }))} description="留空并保存可删除此凭据" autoComplete="off" />)}<Button type="submit" loading={mutations.credential.isPending}>保存凭据</Button></Stack></form></Card></Stack>;
|
||||
if (sources.isPending) return <FloatingNotice message="正在加载书源…" busy />;
|
||||
const save = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!source) return;
|
||||
for (const field of source.descriptor?.credentialKeys ?? []) {
|
||||
if (!(field.key in values)) continue;
|
||||
const value = values[field.key] ?? '';
|
||||
if (value) await mutations.credential.mutateAsync({ id: source.id, key: field.key, value });
|
||||
else await api.deleteCredential(source.id, field.key);
|
||||
}
|
||||
navigate('/sources');
|
||||
};
|
||||
return <div className="sources-screen">
|
||||
<SourcesHeader title={source ? `${source.name} · 凭据` : '书源凭据'} />
|
||||
<main className="sources-body">
|
||||
{!source ? <Callout tone="error">来源不存在</Callout> : <form className="sources-card" onSubmit={save}>
|
||||
<FloatingNotice message={status.isPending ? '正在读取凭据状态…' : ''} busy />
|
||||
{status.data && <p>状态:{STATES[status.data.state]}</p>}
|
||||
{status.data && !status.data.available && <Callout tone="warning">来源当前不可用,请联系管理员检查插件</Callout>}
|
||||
{source.descriptor?.credentialKeys?.map(field => <label key={field.key}>
|
||||
{field.label} · {status.data?.fields.find(item => item.key === field.key)?.configured ? '已配置' : '未配置'}
|
||||
<input type="password" autoComplete="off" value={values[field.key] ?? ''} onChange={event => { const value = event.currentTarget.value; setValues(previous => ({ ...previous, [field.key]: value })); }} />
|
||||
<small className="muted">留空并保存可删除此凭据</small>
|
||||
</label>)}
|
||||
<div className="sources-actions">
|
||||
<Button type="submit" className="primary" disabled={mutations.credential.isPending}>保存凭据</Button>
|
||||
<Button onClick={() => navigate('/sources')}>取消</Button>
|
||||
</div>
|
||||
</form>}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,23 +1,75 @@
|
||||
import { Alert, Button, Card, Group, Select, Stack, Switch, Text, TextInput, Textarea, Title } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import type { SourceType } from '../../../api/sources.ts';
|
||||
import { useSources, useSourceMutations, useSourceTypes } from '../hooks/sourceQueries.ts';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
import { ConfirmSheet } from '../../../ui/sheet.tsx';
|
||||
import { Button, Callout } from '../../../ui/toolkit.tsx';
|
||||
import { SourcesHeader } from '../components/SourcesHeader.tsx';
|
||||
|
||||
export function SourceEditorPage() {
|
||||
const navigate = useNavigate();
|
||||
const { sourceId } = useParams();
|
||||
const sources = useSources(); const types = useSourceTypes(); const mutations = useSourceMutations();
|
||||
const sources = useSources();
|
||||
const types = useSourceTypes();
|
||||
const mutations = useSourceMutations();
|
||||
const source = sources.data?.find(item => item.id === sourceId);
|
||||
const [typeKey, setTypeKey] = useState(source ? `${source.pluginId}/${source.sourceType}` : '');
|
||||
const [name, setName] = useState(source?.name ?? '');
|
||||
const [config, setConfig] = useState<Record<string, unknown>>(source?.config ?? {});
|
||||
const [raw, setRaw] = useState(JSON.stringify(source?.config ?? {}, null, 2));
|
||||
useEffect(() => { if (!source) return; setTypeKey(`${source.pluginId}/${source.sourceType}`); setName(source.name); setConfig(source.config ?? {}); setRaw(JSON.stringify(source.config ?? {}, null, 2)); }, [source]);
|
||||
const type: SourceType | undefined = types.data?.find(item => `${item.pluginId}/${item.id}` === typeKey) ?? (source ? types.data?.find(item => item.pluginId === source.pluginId && item.id === source.sourceType) : undefined);
|
||||
const [jsonError, setJsonError] = useState('');
|
||||
const [removing, setRemoving] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!source) return;
|
||||
setTypeKey(`${source.pluginId}/${source.sourceType}`);
|
||||
setName(source.name);
|
||||
setConfig(source.config ?? {});
|
||||
setRaw(JSON.stringify(source.config ?? {}, null, 2));
|
||||
}, [source]);
|
||||
const type: SourceType | undefined = types.data?.find(item => `${item.pluginId}/${item.id}` === typeKey)
|
||||
?? (source ? types.data?.find(item => item.pluginId === source.pluginId && item.id === source.sourceType) : undefined);
|
||||
if (sources.isPending || types.isPending) return <FloatingNotice message="正在加载书源配置…" busy />;
|
||||
if (sourceId && !source) return <Alert color="red">来源不存在。</Alert>;
|
||||
const save = async (event: React.FormEvent) => { event.preventDefault(); let value = config; try { if (!type?.configSchema?.properties) value = JSON.parse(raw) as Record<string, unknown>; } catch { return; } await mutations.save.mutateAsync({ id: sourceId ?? null, input: { name: name.trim(), config: value, ...(sourceId ? {} : { pluginId: type?.pluginId, sourceType: type?.id }) } }); navigate('/sources'); };
|
||||
return <Stack p="md" maw={800} mx="auto"><Group justify="space-between"><Title order={1}>{sourceId ? '编辑书源' : '添加书源'}</Title><Button variant="subtle" onClick={() => navigate('/sources')}>返回</Button></Group><Card withBorder><form onSubmit={save}><Stack><Select label="来源类型" required disabled={!!sourceId} value={typeKey} data={types.data?.map(item => ({ value: `${item.pluginId}/${item.id}`, label: `${item.label} (${item.pluginId})` })) ?? []} onChange={value => { setTypeKey(value ?? ''); setConfig({}); setRaw('{}'); }} /><TextInput label="名称" required value={name} onChange={event => setName(event.currentTarget.value)} />{type?.configSchema?.properties ? Object.entries(type.configSchema.properties).map(([key, field]) => field.type === 'boolean' ? <Switch key={key} label={field.title ?? key} checked={config[key] === true} onChange={event => setConfig(previous => ({ ...previous, [key]: event.currentTarget.checked }))} /> : field.enum ? <Select key={key} label={field.title ?? key} value={String(config[key] ?? field.default ?? '')} data={field.enum.map((value, index) => ({ value, label: field.enumNames?.[index] ?? value }))} onChange={value => setConfig(previous => ({ ...previous, [key]: value ?? '' }))} /> : <TextInput key={key} label={field.title ?? key} type={field.type === 'number' || field.type === 'integer' ? 'number' : 'text'} value={String(config[key] ?? field.default ?? '')} onChange={event => setConfig(previous => ({ ...previous, [key]: field.type === 'number' || field.type === 'integer' ? Number(event.currentTarget.value) : event.currentTarget.value }))} />) : <Textarea label="配置 JSON" value={raw} onChange={event => setRaw(event.currentTarget.value)} minRows={8} />}{source?.descriptor?.credentialKeys?.length ? <Text size="sm" c="dimmed">凭据不会保存到配置中,请保存后单独设置。</Text> : null}<Group><Button type="submit" loading={mutations.save.isPending}>保存</Button><Button type="button" variant="light" onClick={() => navigate('/sources')}>取消</Button>{sourceId && <Button type="button" color="red" variant="subtle" loading={mutations.remove.isPending} onClick={async () => { await mutations.remove.mutateAsync(sourceId); navigate('/sources'); }}>删除来源</Button>}</Group></Stack></form></Card></Stack>;
|
||||
const save = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
let value = config;
|
||||
if (!type?.configSchema?.properties) {
|
||||
try { value = JSON.parse(raw) as Record<string, unknown>; setJsonError(''); }
|
||||
catch { setJsonError('配置 JSON 格式不正确'); return; }
|
||||
}
|
||||
await mutations.save.mutateAsync({ id: sourceId ?? null, input: { name: name.trim(), config: value, ...(sourceId ? {} : { pluginId: type?.pluginId, sourceType: type?.id }) } });
|
||||
navigate('/sources');
|
||||
};
|
||||
const setField = (key: string, value: unknown) => setConfig(previous => ({ ...previous, [key]: value }));
|
||||
return <div className="sources-screen">
|
||||
<SourcesHeader title={sourceId ? '编辑书源' : '添加书源'} />
|
||||
<main className="sources-body">
|
||||
{sourceId && !source ? <Callout tone="error">来源不存在</Callout> : <form className="sources-card" onSubmit={save}>
|
||||
<label>来源类型
|
||||
<select required disabled={!!sourceId} value={typeKey} onChange={event => { setTypeKey(event.currentTarget.value); setConfig({}); setRaw('{}'); }}>
|
||||
<option value="" disabled>选择来源类型</option>
|
||||
{types.data?.map(item => <option key={`${item.pluginId}/${item.id}`} value={`${item.pluginId}/${item.id}`}>{item.label} ({item.pluginId})</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>名称<input required value={name} onChange={event => setName(event.currentTarget.value)} /></label>
|
||||
{type?.configSchema?.properties
|
||||
? Object.entries(type.configSchema.properties).map(([key, field]) => field.type === 'boolean'
|
||||
? <label key={key} className="sources-consent"><input type="checkbox" checked={config[key] === true} onChange={event => setField(key, event.currentTarget.checked)} />{field.title ?? key}</label>
|
||||
: field.enum
|
||||
? <label key={key}>{field.title ?? key}<select value={String(config[key] ?? field.default ?? '')} onChange={event => setField(key, event.currentTarget.value)}>{field.enum.map((value, index) => <option key={value} value={value}>{field.enumNames?.[index] ?? value}</option>)}</select></label>
|
||||
: <label key={key}>{field.title ?? key}<input type={field.type === 'number' || field.type === 'integer' ? 'number' : 'text'} value={String(config[key] ?? field.default ?? '')} onChange={event => setField(key, field.type === 'number' || field.type === 'integer' ? Number(event.currentTarget.value) : event.currentTarget.value)} /></label>)
|
||||
: <label>配置 JSON<textarea rows={8} value={raw} onChange={event => setRaw(event.currentTarget.value)} /></label>}
|
||||
{jsonError && <Callout tone="error">{jsonError}</Callout>}
|
||||
{source?.descriptor?.credentialKeys?.length ? <p className="muted">凭据不会保存到配置中,请保存后单独设置</p> : null}
|
||||
<div className="sources-actions">
|
||||
<Button type="submit" className="primary" disabled={mutations.save.isPending}>保存</Button>
|
||||
<Button onClick={() => navigate('/sources')}>取消</Button>
|
||||
{sourceId && <Button className="danger" disabled={mutations.remove.isPending} onClick={() => setRemoving(true)}>删除来源</Button>}
|
||||
</div>
|
||||
</form>}
|
||||
</main>
|
||||
{removing && sourceId && <ConfirmSheet danger title="删除来源" text={`确定删除书源「${source?.name ?? sourceId}」?`} confirmText="删除"
|
||||
onCancel={() => setRemoving(false)}
|
||||
onConfirm={() => { setRemoving(false); void mutations.remove.mutateAsync(sourceId).then(() => navigate('/sources')); }} />}
|
||||
</div>;
|
||||
}
|
||||
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user