fix(media): sync prototype routes and review tooling

This commit is contained in:
hectorqin committed 2026-10-08 09:51:42 +08:00
1 parent 075a49f361
commit 875fec84dd
21 files changed
+81 -87

No files matched your search

+3 -3
View File
@@ -14,7 +14,7 @@ A self-hosted reading and media library for browsers and Android, with EPUB typo
- **Built for reading**: preserve EPUB publisher styles, detect TXT encodings and chapters, and enjoy comics, paged or scrolling reading, themes, fonts, and text-to-speech.
- **Media browsing**: browse movies and series, artists and albums, or audiobooks and narrators. Filter by library, search, save favorites, and manage playback history and queues.
- **Metadata and organization**: read local tags, NFO, artwork, lyrics, and subtitles. Configure TMDB and MusicBrainz for online matching, review candidates, or edit metadata and editions manually.
- **Continue across devices**: accounts have independent shelves, notes, reading and playback progress. Web video uses Plyr; audio includes lyrics, speed controls, and a sleep timer. Android supports native background playback.
- **Continue across devices**: accounts have independent shelves, notes, reading and playback progress. Web video uses DPlayer; audio includes lyrics, speed controls, and a sleep timer. Android supports native background playback.
- **Simple hosting**: one Docker container serves the backend and Web UI. Media scanning only reads source files, with separate databases and service threads for reading and media.
## Supported content
@@ -34,9 +34,9 @@ A self-hosted reading and media library for browsers and Android, with EPUB typo
Prepare Docker, Docker Compose, a book directory, and a separate data directory. Open `http://<your-host>:5888` after starting the service. The first registered account becomes the administrator. Follow the [getting started guide](docs/getting-started.en.md).
Reader opens on the bookshelf by default. Use the media entry to browse your media; on mobile, tap the channel title to switch between video, music, audiobooks, and reading. Media settings include multiple themes; reading controls and bookshelf settings remain separate.
Reader opens on the video page (`#/media/video`) by default. Use the channel navigation to switch between video, music, audiobooks, and reading; the media entry on the shelf also opens the video page. Media settings include multiple themes; reading controls and bookshelf settings remain separate.
In media settings, administrators can add a server directory or OpenList library. Mount local media into Docker first and enter its container path. Library creation starts a scan. See the [media guide (中文)](docs/media-user-guide.zh-CN.md) and [OpenList setup (中文)](docs/media-openlist.md).
From the video page, open More actions → Media settings → Media libraries to add a server directory or OpenList library. Mount local media into Docker first and enter its container path. Library creation starts a scan. See the [media guide (中文)](docs/media-user-guide.zh-CN.md) and [OpenList setup (中文)](docs/media-openlist.md).
## Documentation
+3 -3
View File
@@ -14,7 +14,7 @@
- **专注阅读**:EPUB 保留出版方样式,TXT 支持编码识别与章节切分;支持漫画、分页、滚动、主题、字体和朗读。
- **完整的影音浏览**:电影与剧集、歌手与专辑、有声书与演播者分别组织,支持媒体库筛选、搜索、收藏、历史记录和播放队列。
- **资料与刮削**:读取本地标签、NFO、封面、歌词和字幕;可配置 TMDB 与 MusicBrainz 补全资料,确认匹配后保存,也可手工整理作品与版本。
- **跨设备继续**:账号独立保存书架、阅读进度、笔记及播放进度。Web 视频采用 Plyr;音频支持歌词、倍速和睡眠定时,Android 支持原生后台播放。
- **跨设备继续**:账号独立保存书架、阅读进度、笔记及播放进度。Web 视频使用 DPlayer;音频支持歌词、倍速和睡眠定时,Android 支持原生后台播放。
- **适合自部署**:一个 Docker 容器提供服务端与 Web 界面;影音源文件只读扫描,阅读与影音使用独立数据库及服务线程。
## 支持内容
@@ -34,9 +34,9 @@
准备 Docker、Docker Compose、书籍目录和独立数据目录。启动后访问 `http://<你的主机>:5888`,首个注册账号成为管理员。部署步骤见[入门指南](docs/getting-started.zh-CN.md)。
默认进入书架,从「影音」入口打开媒体模块。手机点击顶部频道名称切换影视、音乐、有声书,选择「阅读」返回书架;影音设置内置多种主题,阅读界面与书架设置保持独立。
打开站点默认进入影视页(`#/media/video`)。可从频道导航切换影视、音乐、有声书,选择「阅读」返回书架;书架右上角的「影音」入口也会打开影视页。影音设置内置多种主题,阅读界面与书架设置保持独立。
管理员在「影音设置 → 媒体库管理」添加服务器目录或 OpenList。Docker 本地媒体目录需先挂载,再填写容器内路径;创建后自动扫描。详细步骤见[影音手册](docs/media-user-guide.zh-CN.md)和 [OpenList 接入](docs/media-openlist.md)。
管理员打开影视页右上角「更多操作 → 影音设置 → 媒体库管理」,添加服务器目录或 OpenList。Docker 本地媒体目录需先挂载,再填写容器内路径;创建后自动扫描。详细步骤见[影音手册](docs/media-user-guide.zh-CN.md)和 [OpenList 接入](docs/media-openlist.md)。
## 文档
+1 -1
View File
@@ -1,6 +1,6 @@
# 业务配置
管理员在「系统设置 → 服务配置」编辑实例业务配置;「影音设置 → 元数据刮削」同时提供 TMDB、MusicBrainz 的直接入口。普通用户无法读取或修改服务凭据。
管理员在「系统设置 → 服务配置」编辑实例业务配置,其中包含 TMDB、MusicBrainz 等服务分类。普通用户无法读取或修改服务凭据。
| 分类 | 可修改内容 |
| --- | --- |
+2 -2
View File
@@ -15,7 +15,7 @@ Read-only books do not prevent account or progress updates in the data directory
## Business settings in the UI
Administrators configure HTTP speech, scanning, login lifetimes, public URL, browser origins and WebDAV under System settings → Service configuration. TMDB and MusicBrainz are also editable in Media settings → Metadata providers. Registration remains under Registration and invitations. Values persist in reader.db and apply without restarting.
Administrators configure HTTP speech, scanning, login lifetimes, public URL, browser origins, TMDB, MusicBrainz and WebDAV under System settings → Service configuration. Registration remains under Registration and invitations. Values persist in reader.db and apply without restarting.
On first upgrade, legacy business environment variables are imported once. Existing database values, including cleared credentials, always win. See [business settings (中文)](business-settings.zh-CN.md).
@@ -88,7 +88,7 @@ Mount local movie, series, music, and audiobook directories separately (read-onl
The official image includes ffprobe and ffmpeg. For native runs, put them on PATH or set MEDIA_FFPROBE_PATH / MEDIA_FFMPEG_PATH to executable paths. They probe metadata and extract embedded resources; they do not enable transcoding.
Configure TMDB credentials and MusicBrainz contact information in Media settings → Metadata providers. Changes apply without restarting. Local tags and NFO work without online credentials.
Configure TMDB credentials and MusicBrainz contact information under System settings → Service configuration. Changes apply without restarting. Local tags and NFO work without online credentials.
Configure OpenList in the library creation form; no local mount is needed. Reader must reach OpenList and its download upstreams. Remote embedded metadata is not read, and some drivers require OpenList Web proxy. See [OpenList setup (中文)](media-openlist.md).
+2 -2
View File
@@ -15,7 +15,7 @@
## 页面业务配置
管理员在「系统设置 → 服务配置」中管理 HTTP 朗读、扫描、登录有效期、对外地址、浏览器来源及 WebDAV 备份目标。TMDB 与 MusicBrainz 也可在「影音设置 → 元数据刮削」编辑,注册策略沿用「注册与邀请」。配置写入 reader.db,保存后无需重启。详见[业务配置](business-settings.zh-CN.md)。
管理员在「系统设置 → 服务配置」中管理 HTTP 朗读、扫描、登录有效期、对外地址、浏览器来源、TMDB、MusicBrainz 及 WebDAV 备份目标,注册策略沿用「注册与邀请」。配置写入 reader.db,保存后无需重启。详见[业务配置](business-settings.zh-CN.md)。
升级首次启动会导入旧业务环境变量;数据库存在配置后不再读取这些环境变量,清空字段也不会恢复旧值。
@@ -86,7 +86,7 @@ reader 代理语音请求,不内置合成模型。上游接口需兼容项目
官方镜像包含 ffprobe 和 ffmpeg。本机启动时将二者加入 PATH,或设置 MEDIA_FFPROBE_PATH / MEDIA_FFMPEG_PATH 为可执行文件路径。ffprobe 用于时长、编码、标签和章节,ffmpeg 用于内嵌资源提取,均不提供转码。
在线匹配在「影音设置 → 元数据刮削」配置 TMDB 令牌或 API Key,以及 MusicBrainz 应用标识与联系地址。可测试连接、调整资料语言并保存,无需重启。本地 NFO 和标签读取不依赖在线凭据。
在线匹配在书架右上角「系统设置 → 服务配置」的 TMDB 和 MusicBrainz 分类中配置令牌、API Key、应用标识与联系地址。可测试连接、调整资料语言并保存,无需重启。本地 NFO 和标签读取不依赖在线凭据。
OpenList 连接信息在建库界面设置,无需本地挂载;服务端必须能访问 OpenList 和其下载源。远端内嵌资料暂不读取,某些驱动需要 OpenList Web 代理。配置与限制见 [OpenList 接入](media-openlist.md)。
+2 -2
View File
@@ -96,7 +96,7 @@ The shelf contains readable books; the library lets you browse the disk director
### OPDS
As an administrator, add an OPDS source under Sources (书源) and configure its server URL. If authentication is required, set credentials for your current account. Open the source to browse or search its catalog and acquire books for your shelf.
As an administrator, open Sources (书源) from the shelf, add an OPDS source, and configure its server URL. If authentication is required, set credentials for your current account. Open the source to browse or search its catalog and acquire books for your shelf.
## 5. Add media libraries
@@ -108,7 +108,7 @@ Mount local media separately by adding the directories you need to Compose volum
- /path/to/your/audiobooks:/media/audiobook:ro
```
Run `docker compose up -d`. Open media from the bookshelf, then Settings → Media libraries. Choose video, music, or audiobooks, set a container path such as `/media/video`, and choose access permissions. Creation starts a scan; inspect scan tasks in settings if it fails.
Run `docker compose up -d`, open the video page (`#/media/video`), and use More actions → Media settings → Media libraries. Choose video, music, or audiobooks, set a container path such as `/media/video`, and choose access permissions. Creation starts a scan; inspect Scan and scrape if it fails.
When running directly on Windows, use an absolute local path such as `D:\Media\Music`. With Docker Desktop, mount the directory and enter its container path instead.
+2 -2
View File
@@ -96,7 +96,7 @@ Windows 推荐使用上面的 Compose 配置,避免不同 shell 的路径和
### OPDS
管理员在「书源」添加 OPDS 来源并设置服务地址。需要认证时填写当前账号的来源凭据。打开来源后可浏览或搜索目录,获取书籍并加入书架。
管理员从书架的「书源」入口进入书源中心,添加 OPDS 来源并设置服务地址。需要认证时填写当前账号的来源凭据。打开来源后可浏览或搜索目录,获取书籍并加入书架。
## 5. 添加影音媒体库
@@ -108,7 +108,7 @@ Windows 推荐使用上面的 Compose 配置,避免不同 shell 的路径和
- /path/to/your/audiobooks:/media/audiobook:ro
```
执行 `docker compose up -d` 后,从书架「影音」进入「设置 → 媒体库管理」,选择影视、音乐或有声书,填写容器内目录(例如 `/media/video`)及访问范围。创建后自动扫描;扫描任务和失败原因可在设置中查看。
执行 `docker compose up -d` 后,打开影视页(`#/media/video`),从右上角「更多操作」进入「影音设置 → 媒体库管理」,选择影视、音乐或有声书,填写容器内目录(例如 `/media/video`)及访问范围。创建后自动扫描;扫描任务和失败原因可在「扫描与刮削」中查看。
Windows 直接运行服务时填本机绝对路径,例如 `D:\Media\Music`;Docker Desktop 则挂载该目录并填写容器内路径。
+1 -1
View File
@@ -33,4 +33,4 @@ channel 表示 video、music 或 audiobook;itemId、part、libraryId 均为真
路由表之外保留少量旧深链接兼容:`/media`、`/media/music` 和 `/media/audiobook` 会进入对应频道默认页;未知媒体路径回到 `/media/video`。书源与系统旧入口的兼容规则见[React 架构说明](frontend-react-migration.md)。返回按实际浏览器历史和保存的来源状态处理;不为每个页面生成 return 查询参数。播放器的 item、part 查询参数保存作品及播放部分,可刷新后重新校验授权并恢复页面。
协议实现以[React 页面路由](../web/src/app/router/routes.tsx)及[影音 feature](../web/src/features/media/)为准。冻结 v2 原型内的示例路由只用于设计演示。
协议实现以[React 页面路由](../web/src/app/router/routes.tsx)及[影音 feature](../web/src/features/media/)为准。冻结 v2 原型同步这些页面路径;其中作品 ID、查询参数和状态参数仍只用于设计演示。
+4 -4
View File
@@ -1,10 +1,10 @@
# 影音使用说明
影音支持电影、剧集、音乐和有声书。默认启动仍进入书架;从书架的「影音」入口进入,也可直接访问影音链接。手机点击顶部频道名称切换,桌面使用频道导航;「阅读」返回书架,阅读界面与书架设置保持原位。
影音支持电影、剧集、音乐和有声书。打开站点默认进入影视页(`#/media/video`);也可从书架右上角的「影音」入口进入或直接访问影音链接。通过频道导航切换频道,「阅读」返回书架,阅读界面与书架设置保持原位。
## 新建与管理媒体库
管理员进入「影音设置 → 媒体库管理 → 新建媒体库」,选择内容类型、接入方式和访问范围。普通用户只看到已获授权的媒体库。
管理员从影视页的「更多操作」进入「影音设置 → 媒体库管理 → 新建媒体库」,选择内容类型、接入方式和访问范围。普通用户只看到已获授权的媒体库。
- **服务器目录**:填写 Reader 服务端能读取的路径。Docker 部署填写容器内路径,例如将宿主目录只读挂载到 /media/music 后,填 /media/music。
- **OpenList**:填写服务地址、远端绝对目录,以及可选访问令牌和目录密码。创建前验证连接。编辑已有库可更新或清除凭据;不改服务地址和根目录。见[OpenList 接入说明](media-openlist.md)。
@@ -68,7 +68,7 @@
本地库优先读取文件标签、NFO 和同目录封面;ffprobe 用于编码、时长及章节探测,ffmpeg 用于部分内嵌资源提取,两者不等于转码功能。OpenList 支持外置 NFO、封面、歌词和字幕,暂不读取远端内嵌标签、封面及章节。
在「影音设置 → 元数据刮削」中配置 TMDB 令牌或 API Key(同时填写时优先令牌),以及 MusicBrainz 应用标识与联系地址,保存后生效。管理员在作品菜单中匹配资料,审阅候选后确认;也可在「扫描与刮削」中发起批量任务并重试未完成项。来源网络、凭据或封面下载失败会单独提示。
管理员从频道导航切换到「阅读」进入书架,打开右上角「系统设置 → 服务配置」,在 TMDB 和 MusicBrainz 分类中配置令牌、API Key 或应用标识,保存后生效。管理员在作品菜单中匹配资料,审阅候选后确认;也可从「影音设置 → 扫描与刮削」发起批量任务并重试未完成项。来源网络、凭据或封面下载失败会单独提示。
详情的资源信息菜单可查看文件与技术信息、管理版本。手工资料、作品归属、版本拆分或合并会在重扫时保留,不修改原文件。移动版本不转移收藏,也不复制原作品的人工资料;更改剧集、季和单集关系后需重新确认受影响的在线匹配。
@@ -80,7 +80,7 @@
## 播放与恢复
视频由 Plyr 提供内置控件;音频页面提供封面、进度、队列、歌词、倍速和睡眠定时。关闭迷你播放器停止当前播放。章节和曲目按当前版本的真实顺序处理,失效资源不会悄悄切到其他版本。
视频由 DPlayer 提供内置控件;音频页面提供封面、进度、队列、歌词、倍速和睡眠定时。关闭迷你播放器停止当前播放。章节和曲目按当前版本的真实顺序处理,失效资源不会悄悄切到其他版本。
播放器地址包含作品 ID 和播放部分 ID,刷新后重新读取会话与授权。服务器保存播放进度;权限被撤销、资源失效或会话冲突时需要按页面提示处理。浏览器自动播放限制可能要求再次点击播放。
+1 -1
View File
@@ -2,6 +2,6 @@
本目录只保留当前仍使用的设计基准。早期原型、反复覆盖的截图、失效的构建哈希和逐轮进度记录已移出仓库。
- [冻结 v2 高保真原型](v2-review/index.html):已采用的布局基准,包含桌面和手机、主题及状态。示例内容和旧示例路由不代表生产数据或当前路由协议。
- [冻结 v2 高保真原型](v2-review/index.html):已采用的布局基准,包含桌面和手机、主题及状态。原型中的作品 ID、查询参数和状态参数是演示数据;页面路径按当前 React 路由与 DPlayer 播放器同步。
后续验收结果生成到被 Git 忽略的 `artifacts/media`,需要长期保留的截图应人工挑选;不要提交整批重复截图。运行方式见[验证说明](../../media-validation.md)。
+2 -2
View File
@@ -1,7 +1,7 @@
# 影音 v2 设计基准
[打开高保真交互原型](index.html)。这是已采用的布局参考,不再是待确认提案。单文件包含 42 个页面和状态、手机/桌面尺寸与六种主题,可直接在浏览器打开。
[打开高保真交互原型](index.html)。这是已采用的布局参考,不再是待确认提案。单文件包含 42 个页面和状态、手机/桌面尺寸与六种主题,可直接在浏览器打开;页面路径按当前 React 路由同步。
作品、封面、进度、候选和账号均为示例;不调用真实服务,不播放媒体,不执行文件操作。冻结文件保留评审时的示例路由;当前收藏、播放器等地址以[导航说明](../../../media-navigation.md)为准。视频内置控件由 Plyr 提供,不要求按示例重画。
作品、封面、进度、候选和账号均为示例;不调用真实服务,不播放媒体,不执行文件操作。冻结文件保留评审时的示例内容;作品详情使用 `/media/:channel/items/:itemId`,播放器使用 `/media/:channel/player` 及其歌词、队列、章节子面板,收藏等公共入口以[导航说明](../../../media-navigation.md)为准。视频控件由 DPlayer 提供,原型只表达布局和交互层级。
从仓库根目录运行 node docs/prototypes/media/v2-review/verify.cjs 可验证原型布局与基本交互;使用 PROTOTYPE_CHROMIUM 指定浏览器。输出进入 artifacts/media/prototype-v2,不会覆盖已冻结的设计源文件。原型验证不替代生产页面的视觉与功能验收。
+9 -9
View File
@@ -3,15 +3,15 @@
<style>
*{box-sizing:border-box}body{margin:0;background:#e9ece7;color:#243129;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}button,input,select,textarea{font:inherit}button,a,input,select{ -webkit-tap-highlight-color:transparent}button{cursor:pointer}a{color:inherit}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid #598450;outline-offset:3px}svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}button{color:inherit}.review-head{height:76px;padding:14px 28px;background:#f9faf7;border-bottom:1px solid #d6dcd2;display:flex;align-items:center;justify-content:space-between;gap:16px}.review-head strong{font-size:17px}.review-head small{display:block;color:#637064;font-size:12px;margin-top:3px}.review-actions{display:flex;align-items:center;gap:10px}.review-actions select,.review-actions button{height:36px;border:1px solid #cbd3c7;background:#fff;border-radius:7px;padding:0 10px}.workspace{display:grid;grid-template-columns:220px minmax(0,1fr)}.directory{padding:24px 16px;border-right:1px solid #d5dbd0;background:#f4f6f0;height:calc(100vh - 76px);overflow:auto;position:sticky;top:0}.directory h2{font-size:11px;font-weight:600;letter-spacing:.08em;color:#717b6d;margin:20px 10px 6px}.directory h2:first-child{margin-top:0}.directory a{display:flex;align-items:center;gap:8px;min-height:35px;padding:7px 10px;text-decoration:none;border-radius:6px;font-size:12px;color:#55604f}.directory a.current{background:#e0e8d9;color:#294c27;font-weight:600}.directory .dot{width:5px;height:5px;border-radius:50%;background:currentColor}.stage{min-width:0;padding:24px;overflow:auto}.stage-info{max-width:1120px;margin:0 auto 15px;display:flex;align-items:start;justify-content:space-between;gap:20px}.stage-info h1{font-size:15px;margin:0 0 4px}.stage-info p{margin:0;color:#596754;font-size:12px;max-width:740px}.status{font-size:11px;background:#dce7d4;border:1px solid #bdcdb0;padding:4px 8px;border-radius:5px;white-space:nowrap}.frame{width:390px;max-width:100%;height:844px;margin:auto;border-radius:14px;box-shadow:0 14px 50px #263a2015,0 0 0 1px #00000014;overflow:hidden;container-type:inline-size;position:relative;background:#f8f9f5}.app{--bg:#f8f9f5;--paper:#fff;--soft:#edf0e8;--line:#dfe5d9;--ink:#202b24;--muted:#66715f;--accent:#466638;--on:#fff;--tint:#e6eddf;--error:#a84032;--shadow:0 8px 30px #23332215;height:100%;background:var(--bg);color:var(--ink);display:flex;flex-direction:column;font-size:14px;position:relative;color-scheme:light}.app[data-theme=dark]{--bg:#141b19;--paper:#1d2722;--soft:#25312a;--line:#354439;--ink:#eef2e9;--muted:#a3b49f;--accent:#b6ce9d;--on:#23311d;--tint:#30402b;--error:#f0a494;color-scheme:dark}.app[data-theme=sand]{--bg:#fbf7ee;--paper:#fffdf8;--soft:#f0e8dc;--line:#e3d6c4;--ink:#382b21;--muted:#796751;--accent:#865333;--tint:#efe0cf}.app[data-theme=blue]{--bg:#f3f7fa;--paper:#fff;--soft:#e6eff4;--line:#d4e1e8;--ink:#20343d;--muted:#5c7380;--accent:#2b637c;--tint:#dbeaf2}.app[data-theme=rose]{--bg:#faf5f7;--paper:#fff;--soft:#f1e5eb;--line:#e4d5dc;--ink:#3b2c34;--muted:#7d6470;--accent:#894d6a;--tint:#efdeea}.app[data-theme=graphite]{--bg:#191c20;--paper:#23272c;--soft:#2e333a;--line:#40464f;--ink:#f1f3f5;--muted:#b0b7c1;--accent:#c2cedc;--on:#1f2832;--tint:#343f4c;--error:#f0a494;color-scheme:dark}
.app button{border:0;background:transparent;padding:0;line-height:1.4}.app button:disabled{opacity:.4;cursor:default}.app a{text-decoration:none}.app h1,.app h2,.app h3,.app p{margin:0}.app h1{font-size:24px;line-height:1.3;letter-spacing:-.025em}.app h2{font-size:16px;font-weight:650}.app h3{font-size:14px}.app small{font-size:12px;color:var(--muted)}.app .muted{color:var(--muted)}.app .tiny{font-size:11px}.app .icon-button{width:44px;height:44px;display:inline-flex;align-items:center;justify-content:center;flex:none;border-radius:50%;color:inherit}.app .icon-button:hover{background:var(--soft)}.app .primary,.app .secondary{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:10px 18px;border-radius:9px;font-weight:600}.app .primary{background:var(--accent);color:var(--on)}.app .secondary{background:var(--soft);color:var(--ink)}.app .text-link{color:var(--accent);min-height:44px;display:inline-flex;gap:5px;align-items:center}.app .topbar{height:56px;min-height:56px;padding:0 8px 0 16px;display:flex;align-items:center;gap:4px;background:var(--bg);position:relative;z-index:5}.app .topbar.secondary-bar{padding-left:4px;border-bottom:1px solid var(--line)}.app .topbar .page-title{font-size:16px;font-weight:650;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.app .topbar .channel-title{display:flex;align-items:center;gap:7px;font-size:19px;font-weight:700;min-height:44px}.app .topbar .channel-title svg{width:15px;color:var(--muted)}.spacer{flex:1}.app .brand,.app .desktop-channels{display:none}.app .main{overflow:auto;scrollbar-width:none;min-height:0;flex:1;padding:0 16px 24px;overscroll-behavior:contain}.app .main::-webkit-scrollbar,.app .lyric-lines::-webkit-scrollbar{display:none}.app .tabs{height:44px;display:flex;align-items:stretch;gap:24px;border-bottom:1px solid var(--line);margin-bottom:16px;position:sticky;top:0;background:var(--bg);z-index:2}.app .tabs a,.app .tabs button{position:relative;display:flex;align-items:center;min-height:44px;color:var(--muted);font-size:14px;border-bottom:2px solid transparent}.app .tabs .active{color:var(--ink);font-weight:650;border-color:var(--accent)}.app .browse-tools{display:flex;align-items:center;gap:4px;height:44px;margin:6px -4px 12px}.app .scope-button{display:flex;align-items:center;gap:4px;min-height:44px;font-size:12px;padding:0 4px}.app .scope-button svg{width:14px}.app .count{color:var(--muted);font-size:11px}.app .grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px 12px}.app .tile{display:block;min-width:0}.app .tile-title{display:block;font-size:13px;font-weight:550;margin-top:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.5}.app .tile-meta{display:block;font-size:11px;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.app .cover{aspect-ratio:2/3;background:var(--cover,#52674e);border-radius:7px;overflow:hidden;position:relative;color:#fff;display:flex;flex-direction:column;justify-content:space-between;padding:12%;box-shadow:0 3px 8px #15211714}.app .cover:before{content:"";position:absolute;width:160%;height:85%;left:-35%;bottom:-30%;border-radius:50% 50% 0 0;transform:rotate(-26deg);background:linear-gradient(150deg,#dfe1b73b,#0c202688)}.app .cover:after{content:"";position:absolute;width:90%;aspect-ratio:1;border:1px solid #eee5c033;border-radius:50%;right:-35%;top:18%}.app .cover .art-title{position:relative;font-family:Georgia,"Songti SC",serif;font-size:clamp(16px,5cqw,23px);line-height:1.5;letter-spacing:.12em;font-weight:400;writing-mode:vertical-rl;z-index:1}.app .cover .art-mark{font-size:6px;letter-spacing:.15em;opacity:.7;position:relative;z-index:1}.app .cover.square{aspect-ratio:1}.app .square .art-title{writing-mode:horizontal-tb;font-size:clamp(16px,4.6cqw,25px);letter-spacing:.05em}.app .cover.scene{aspect-ratio:16/9}.app .scene .art-title{writing-mode:horizontal-tb;font-size:30px}.app .avatar{border-radius:50%;aspect-ratio:1;background:linear-gradient(140deg,#887a68,#554b40);color:#f4ecdd;display:grid;place-items:center;font:32px Georgia,serif}.app .resume{display:flex;align-items:center;gap:12px;border:1px solid var(--line);background:var(--paper);border-radius:11px;padding:10px;margin:0 0 16px}.app .resume>.cover{width:48px;flex:none;padding:5px;aspect-ratio:1}.app .resume .art-title{font-size:10px;writing-mode:horizontal-tb}.app .resume .art-mark{display:none}.app .resume strong{font-size:13px;font-weight:550}.app .resume small{display:block;font-size:11px;margin-top:3px}.app .resume .icon-button{background:var(--tint);color:var(--accent);width:40px;height:40px}.app .section-head{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:44px;margin:16px 0 8px}.app .section-head small{font-size:11px}.app .section-head>a{color:var(--muted);font-size:12px}.app .detail{padding-top:20px}.app .hero{display:flex;align-items:center;gap:20px}.app .hero>.cover{width:106pLine truncated
.app .player-main{display:flex;flex-direction:column;min-height:100%;padding:24px 12px 0;max-width:600px;margin:auto}.app .player-art{width:min(74%,264px);margin:auto auto 24px;padding:24px}.app .player-art .art-title{font-size:33px}.app .player-heading{display:flex;align-items:center;gap:8px;margin:0 0 16px}.app .player-heading>div{flex:1;min-width:0}.app .player-heading h1{font-size:21px}.app .player-heading p{font-size:12px;color:var(--muted);margin-top:6px}.app .player-bottom{margin-top:auto;padding-top:18px;padding-bottom:14px}.app .seek{width:100%;accent-color:var(--accent);height:24px;margin:0;display:block;-webkit-appearance:none;appearance:none;background:transparent;border:0;padding:0;cursor:pointer}.app .time-row{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums;margin-top:3px}.app .transport{display:flex;align-items:center;justify-content:center;gap:35px;margin:20px 0 18px}.app .transport .main-play{width:64px;height:64px;background:var(--accent);color:var(--on);border-radius:50%}.app .transport .main-play svg{width:28px;height:28px}.app .transport .icon-button svg{width:25px;height:25px}.app .player-options{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.app .player-options button{min-height:48px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;color:var(--muted);font-size:11px}.app .player-options .active{color:var(--accent)}.app .lyric-top{text-align:center;margin:6px 0 18px}.app .lyric-top h1{font-size:18px}.app .lyric-top p{font-size:12px;color:var(--muted);margin-top:6px}.app .lyric-lines{flex:1;min-height:160px;overflow:auto;scrollbar-width:none;display:flex;flex-direction:column;justify-content:center;gap:8px;text-align:center;padding:22px 4px;mask-image:linear-gradient(transparent,#000 10%,#000 90%,transparent)}.app .lyric-lines button{color:var(--muted);font-size:15px;min-height:44px}.app .lyric-lines .active{color:var(--ink);font-size:19px;font-weight:600}.app .audio-book-art{width:135px;flex:none;margin:10px auto 22px;padding:20px}.app .chapter-heading{text-align:center;font-size:12px;color:var(--muted);margin-bottom:12px}.app .video-main{padding:0;background:#131715;color:#f0f3ed}.app .video-layout{display:grid}.app .video-surface{aspect-ratio:16/9;position:relative;overflow:hidden;background:#202e2a}.app .video-surface:before{content:"";position:absolute;inset:0;background:radial-gradient(ellipse at 72% 22%,#d7d5af40,transparent 36%),linear-gradient(165deg,#6a877e 0 40%,#3e625b 41% 63%,#284d47 64% 76%,#192e2c 77%)}.app .video-surface:after{content:"";position:absolute;bottom:20%;left:-10%;width:130%;height:23%;border-radius:50%;border-top:1px solid #dfebd550;transform:rotate(-9deg)}.app .video-word{position:absolute;top:22%;left:7%;font:30px Georgia,serif;letter-spacing:.3em;color:#f1ecce}.app .video-word small{display:block;font:8px/3 sans-serif;letter-spacing:.25em;color:#d4decf}.app .plyr-demo{position:absolute;z-index:1;bottom:0;left:0;right:0;padding:24px 10px 3px;background:linear-gradient(transparent,#000b);display:flex;align-items:center;gap:8px}.app .plyr-demo .icon-button{width:36px;height:44px}.app .plyr-demo .seek{flex:1;min-width:20px;accent-color:#c6d7af}.app .plyr-demo time{font-size:10px;white-space:nowrap}.app .video-info{padding:16px}.app .video-info h1{font-size:18px;margin:0 0 5px}.app .video-info p{color:#a4b1a3;font-size:12px}.app .video-tool-row{display:grid;grid-template-columns:repeat(4,1fr);border-block:1px solid #344138;margin:16px 0}.app .video-tool-row button{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;min-height:62px;font-size:11px;color:#bac6b7}.app .episode-rail{padding:0 16px 20px;--line:#344138;--paper:#1d2822;--muted:#b4c1af;--tint:#31432c;--accent:#c0d4a6;color:#eef2e8}.app .episode-rail .section-head{margin-top:0}.app .video-description{font-size:12px;color:#a9b8a4;margin-top:15px;line-height:1.8}.app .video-description summary{min-height:44px;cursor:pointer;display:flex;align-items:center;justify-content:space-between}
.app .player-main{display:flex;flex-direction:column;min-height:100%;padding:24px 12px 0;max-width:600px;margin:auto}.app .player-art{width:min(74%,264px);margin:auto auto 24px;padding:24px}.app .player-art .art-title{font-size:33px}.app .player-heading{display:flex;align-items:center;gap:8px;margin:0 0 16px}.app .player-heading>div{flex:1;min-width:0}.app .player-heading h1{font-size:21px}.app .player-heading p{font-size:12px;color:var(--muted);margin-top:6px}.app .player-bottom{margin-top:auto;padding-top:18px;padding-bottom:14px}.app .seek{width:100%;accent-color:var(--accent);height:24px;margin:0;display:block;-webkit-appearance:none;appearance:none;background:transparent;border:0;padding:0;cursor:pointer}.app .time-row{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums;margin-top:3px}.app .transport{display:flex;align-items:center;justify-content:center;gap:35px;margin:20px 0 18px}.app .transport .main-play{width:64px;height:64px;background:var(--accent);color:var(--on);border-radius:50%}.app .transport .main-play svg{width:28px;height:28px}.app .transport .icon-button svg{width:25px;height:25px}.app .player-options{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.app .player-options button{min-height:48px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;color:var(--muted);font-size:11px}.app .player-options .active{color:var(--accent)}.app .lyric-top{text-align:center;margin:6px 0 18px}.app .lyric-top h1{font-size:18px}.app .lyric-top p{font-size:12px;color:var(--muted);margin-top:6px}.app .lyric-lines{flex:1;min-height:160px;overflow:auto;scrollbar-width:none;display:flex;flex-direction:column;justify-content:center;gap:8px;text-align:center;padding:22px 4px;mask-image:linear-gradient(transparent,#000 10%,#000 90%,transparent)}.app .lyric-lines button{color:var(--muted);font-size:15px;min-height:44px}.app .lyric-lines .active{color:var(--ink);font-size:19px;font-weight:600}.app .audio-book-art{width:135px;flex:none;margin:10px auto 22px;padding:20px}.app .chapter-heading{text-align:center;font-size:12px;color:var(--muted);margin-bottom:12px}.app .video-main{padding:0;background:#131715;color:#f0f3ed}.app .video-layout{display:grid}.app .video-surface{aspect-ratio:16/9;position:relative;overflow:hidden;background:#202e2a}.app .video-surface:before{content:"";position:absolute;inset:0;background:radial-gradient(ellipse at 72% 22%,#d7d5af40,transparent 36%),linear-gradient(165deg,#6a877e 0 40%,#3e625b 41% 63%,#284d47 64% 76%,#192e2c 77%)}.app .video-surface:after{content:"";position:absolute;bottom:20%;left:-10%;width:130%;height:23%;border-radius:50%;border-top:1px solid #dfebd550;transform:rotate(-9deg)}.app .video-word{position:absolute;top:22%;left:7%;font:30px Georgia,serif;letter-spacing:.3em;color:#f1ecce}.app .video-word small{display:block;font:8px/3 sans-serif;letter-spacing:.25em;color:#d4decf}.app .dplayer-demo{position:absolute;z-index:1;bottom:0;left:0;right:0;padding:24px 10px 3px;background:linear-gradient(transparent,#000b);display:flex;align-items:center;gap:8px}.app .dplayer-demo .icon-button{width:36px;height:44px}.app .dplayer-demo .seek{flex:1;min-width:20px;accent-color:#c6d7af}.app .dplayer-demo time{font-size:10px;white-space:nowrap}.app .video-info{padding:16px}.app .video-info h1{font-size:18px;margin:0 0 5px}.app .video-info p{color:#a4b1a3;font-size:12px}.app .video-tool-row{display:grid;grid-template-columns:repeat(4,1fr);border-block:1px solid #344138;margin:16px 0}.app .video-tool-row button{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;min-height:62px;font-size:11px;color:#bac6b7}.app .episode-rail{padding:0 16px 20px;--line:#344138;--paper:#1d2822;--muted:#b4c1af;--tint:#31432c;--accent:#c0d4a6;color:#eef2e8}.app .episode-rail .section-head{margin-top:0}.app .video-description{font-size:12px;color:#a9b8a4;margin-top:15px;line-height:1.8}.app .video-description summary{min-height:44px;cursor:pointer;display:flex;align-items:center;justify-content:space-between}
.app .settings-intro{display:flex;align-items:center;gap:12px;background:var(--paper);border:1px solid var(--line);border-radius:11px;padding:16px;margin-top:18px}.app .settings-intro .avatar{width:42px;font-size:18px}.app .settings-intro strong{display:block;font-size:14px}.app .settings-intro small{font-size:11px}.app .setting-group{margin-top:23px}.app .setting-group>h2{font-size:11px;color:var(--muted);font-weight:500;margin:0 0 8px 2px}.app .setting-list{border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--paper)}.app .setting-row{display:flex;align-items:center;gap:12px;text-align:left;width:100%;padding:10px 12px;min-height:56px;border-bottom:1px solid var(--line)}.app .setting-row:last-child{border:0}.app .setting-row>span{flex:1;font-size:13px;min-width:0}.app .setting-row small{font-size:11px}.app .setting-row>svg:first-child{color:var(--muted);width:18px}.app .setting-row>svg:last-child{width:15px;color:var(--muted)}.app .theme-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:18px 0}.app .theme-card{text-align:left;border:1px solid var(--line);border-radius:10px;background:var(--paper);padding:8px}.app .theme-card[aria-pressed=true]{outline:2px solid var(--accent);outline-offset:-2px}.app .theme-art{height:88px;border-radius:6px;background:var(--swatch);padding:12px;display:flex;gap:8px;align-items:end}.app .theme-art span{background:var(--swatch-accent);opacity:.8;width:30%;height:75%;border-radius:3px}.app .theme-art span:nth-child(2){opacity:.35;height:55%}.app .theme-art span:nth-child(3){opacity:.14;height:65%}.app .theme-card strong{display:flex;align-items:center;justify-content:space-between;font-size:12px;padding:10px 4px 4px}.app .theme-card svg{width:15px}.app .search-box{display:flex;align-items:center;background:var(--paper);border:1px solid var(--line);border-radius:9px;margin:16px 0 8px;padding-left:12px;gap:8px;height:44px}.app .search-box>svg{color:var(--muted);width:18px}.app input,.app select,.app textarea{background:var(--paper);border:1px solid var(--line);border-radius:7px;color:var(--ink);min-width:0;padding:10px 12px;min-height:44px;font-size:14px}.app .search-box input{flex:1;border:0;background:transparent;outline:none;padding:8px 0;width:100%;min-height:40px}.app .search-box button{width:44px;height:44px}.app .search-scopes{display:flex;gap:8px;margin:12px 0 16px}.app .search-scopes button{font-size:12px;min-height:44px;padding:7px 12px;border-radius:7px;color:var(--muted)}.app .search-scopes .active{background:var(--tint);color:var(--accent)}.app .page-tools{display:flex;align-items:center;justify-content:space-between;margin:14px 0 6px;gap:8px}.app .page-tools p{font-size:12px;color:var(--muted)}.app .library-card{background:var(--paper);border:1px solid var(--line);border-radius:11px;padding:16px;margin:14px 0}.app .library-title{display:flex;align-items:center;gap:10px}.app .library-title>svg{color:var(--accent)}.app .library-title>div{flex:1}.app .library-title strong{font-size:14px;display:block}.app .library-title small{font-size:11px;display:block;margin-top:4px}.app .library-card code{display:block;font-size:11px;overflow-wrap:anywhere;color:var(--muted);margin:12px 0}.app .library-footer{display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--line);padding-top:12px;margin-top:8px;font-size:11px;color:var(--muted)}.app .library-footer button{color:var(--accent);font-size:12px;min-height:32px}.app .form{padding-top:20px;max-width:660px;margin:auto}.app .field{display:block;font-size:12px;margin:0 0 18px}.app .field>input,.app .field>select,.app .field>textarea{display:block;width:100%;margin:7px 0}.app .field small{font-size:11px}.app textarea{resize:vertical;min-height:100px;line-height:1.7}.app .form-footer{position:sticky;bottom:-24px;background:var(--bg);border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:10px;padding:14px 0 20px;margin-top:20px}.app .notice{background:var(--soft);border:1px solid var(--line);border-radius:8px;padding:12px;font-size:12px;color:var(--muted);line-height:1.8;margin:15px 0}.app .task{border:1px solid var(--line);border-radius:10px;padding:15px;background:var(--paper);margin-bottom:12px}.app .task-top{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:13px}.app .task p{font-size:11px;color:var(--muted);margin-top:8px}.app .state-screen{min-height:65%;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;max-width:360px;margin:auto;padding:34px 18px}.app .state-icon{width:72px;height:72px;border:1px solid var(--line);background:var(--soft);border-radius:20px;display:grid;place-items:center;color:var(--accent);margin-bottom:22px}.app .state-icon svg{width:30px;height:30px}.app .state-screen h1{font-size:21px;margin-bottom:10px}.app .state-screen p{font-size:13px;color:var(--muted);line-height:1.9;margin-bottom:24px}.app .state-screen .text-link{fontLine truncated
.app .overlay{position:absolute;inset:0;background:#10201466;z-index:10;display:flex;align-items:flex-end;justify-content:center}.app .sheet{background:var(--paper);color:var(--ink);border:1px solid var(--line);width:100%;max-width:520px;max-height:82%;border-radius:16px 16px 0 0;display:flex;flex-direction:column;box-shadow:var(--shadow);animation:rise .16s ease-out}.app .sheet-head{display:flex;align-items:center;gap:8px;min-height:56px;padding:5px 8px 5px 20px;border-bottom:1px solid var(--line)}.app .sheet-head h2{font-size:15px;flex:1}.app .sheet-body{padding:18px 20px 24px;overflow:auto;font-size:13px}.app .sheet-body>p{color:var(--muted);font-size:12px;margin-bottom:16px;line-height:1.8}.app .timer-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:16px 0}.app .timer-grid button{background:var(--soft);border:1px solid var(--line);border-radius:9px;min-height:64px;font-size:21px;font-variant-numeric:tabular-nums}.app .timer-grid small{margin-left:4px;font-size:11px}.app .timer-grid button[aria-pressed=true]{border-color:var(--accent);background:var(--tint);color:var(--accent)}.app .sheet-option{display:flex;align-items:center;gap:10px;width:100%;min-height:50px;border-bottom:1px solid var(--line);text-align:left;font-size:13px}.app .sheet-option>span{flex:1}.app .sheet-option small{display:block;margin-top:3px}.app .sheet-option:last-child{border:0}.app .specs{display:grid;grid-template-columns:88px 1fr;gap:13px 18px;font-size:12px;margin:16px 0 20px}.app .specs dt{color:var(--muted)}.app .specs dd{margin:0;overflow-wrap:anywhere}.app .popover{position:absolute;top:49px;left:12px;width:192px;border:1px solid var(--line);background:var(--paper);box-shadow:var(--shadow);padding:6px;border-radius:10px;z-index:12}.app .popover.right{left:auto;right:8px}.app .popover button,.app .popover a{display:flex;align-items:center;gap:10px;width:100%;min-height:44px;padding:10px;border-radius:7px;font-size:13px;text-align:left}.app .popover .active{background:var(--tint);color:var(--accent)}.app .popover .active>svg:last-child{margin-left:auto;width:15px}.app .popover button:hover,.app .popover a:hover{background:var(--soft)}.app .toast{position:absolute;bottom:26px;left:50%;transform:translateX(-50%);background:var(--ink);color:var(--bg);border-radius:9px;padding:11px 16px;font-size:12px;z-index:25;width:max-content;max-width:90%;box-shadow:var(--shadow)}@keyframes rise{from{transform:translateY(12px);opacity:.4}}
.route-note{font:11px ui-monospace,Consolas,monospace;color:#677360;max-width:1120px;margin:14px auto 0;overflow-wrap:anywhere}.review-foot{max-width:1120px;margin:10px auto 0;font-size:11px;color:#687562}.review-foot a{margin-right:14px}.review-only{font-size:10px;border:1px solid #bbcbb3;color:#426033;padding:2px 5px;border-radius:4px}.app .pill{font-size:11px;border-radius:5px;background:var(--tint);color:var(--accent);padding:3px 6px}.app .empty-collection{border:1px dashed var(--line);border-radius:11px;padding:28px 20px;text-align:center;margin-top:20px;color:var(--muted)}
@container(min-width:700px){.app .brand{display:block;font:700 24px Georgia,serif;color:var(--accent);margin-right:24px}.app .topbar{height:68px;min-height:68px;padding:0 32px;border-bottom:1px solid var(--line)}.app .topbar.secondary-bar{padding-left:32px}.app .topbar>.channel-title{display:none}.app .desktop-channels{display:flex;gap:24px;align-items:center;height:100%;font-size:14px}.app .desktop-channels a{height:100%;display:flex;align-items:center;position:relative;color:var(--muted)}.app .desktop-channels .active{color:var(--ink);font-weight:650}.app .desktop-channels .active:after{content:"";position:absolute;bottom:0;left:0;right:0;height:2px;background:var(--accent)}.app .main{padding:0 32px 28px}.app .grid{grid-template-columns:repeat(6,minmax(0,1fr));gap:26px 20px}.app .cover .art-title{font-size:28px}.app .cover .art-mark{font-size:7px}.app .tabs{margin-top:5px;gap:30px}.app .resume{max-width:520px;margin-bottom:18px}.app .browse-tools{margin-bottom:16px}.app .detail{max-width:1000px;margin:auto;padding-top:28px}.app .hero{gap:28px}.app .hero>.cover{width:158px;padding:23px}.app .hero>.avatar{width:126px;font-size:48px}.app .hero-copy h1{font-size:32px}.app .hero-copy p{font-size:13px}.app .hero-actions{max-width:420px;margin:24px 0}.app .detail-layout{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:40px;align-items:start}.app .detail-layout aside{padding-top:6px}.app .detail-layout .resource{margin-top:16px}.app .detail .grid{grid-template-columns:repeat(4,minmax(0,1fr))}.app .episode-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.app .player-main{max-width:940px;display:grid;grid-template-columns:minmax(280px,1fr) minmax(300px,1fr);column-gap:64px;align-items:center;padding:45px 20px}.app .player-art{grid-column:1;grid-row:1/4;width:100%;max-width:360px;margin:auto}.app .player-art .art-title{font-size:42px}.app .player-heading{grid-column:2;align-self:end;margin-top:30px}.app .player-bottom{grid-column:2;width:100%;align-self:start;margin:0}.app .lyrics-player{display:flex;max-width:620px;padding:20px 35px}.app .lyric-lines{min-height:300px}.app .audio-book-art{grid-column:1;grid-row:1/5;width:230px;margin:auto}.app .chapter-heading{grid-column:2;align-self:end}.app .video-main{padding:0}.app .video-layout{grid-template-columns:minmax(0,1fr) 300px}.app .video-surface{aspect-ratio:16/9}.app .video-word{font-size:44px}.app .episode-rail{padding:22px 20px;border-left:1px solid #344138}.app .episode-rail .episode-grid{grid-template-columns:1fr}.app .video-info{padding:22px 26px}.app .video-tool-row{max-width:440px}.app .video-tool-row button{flex-direction:row;gap:8px;min-height:48px}.app .settings-wrap{max-width:700px;margin:auto}.app .settings-intro{margin-top:24px}.app .theme-grid{grid-template-columns:repeat(3,1fr)}.app .theme-art{height:112px}.app .sheet{border-radius:14px;margin:auto;max-height:85%}.app .overlay{align-items:center}.app .mini{padding:9px 24px;gap:16px}.app .mini-name{max-width:440px}.app .mini .spacer{display:block}.app .search-wrap{max-width:800px;margin:auto}.app .library-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:18px}.app .library-card{margin:0}.app .state-screen{min-height:80%}.app .wide-list{max-width:850px;margin:auto}.app .popover{left:24px}.app .popover.right{right:24px}}
@media(max-width:850px){.workspace{grid-template-columns:1fr}.directory{display:none;position:static;height:auto}.directory.open{display:block}.review-head{height:auto;min-height:76px;padding:12px 14px;flex-wrap:wrap}.review-head small{max-width:95vw}.stage{padding:16px 8px}.stage-info{padding:0 8px}.stage-info p{font-size:11px}.review-actions{flex-wrap:wrap;gap:6px}.stage-info .status{display:none}.review-actions select{max-width:120px}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;scroll-behavior:auto!important}}
.app .seek::-webkit-slider-runnable-track{height:4px;border-radius:4px;background:linear-gradient(to right,var(--accent) 38%,var(--line) 38%)}.app .seek::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--accent);margin-top:-4px;border:0}.app .seek::-moz-range-track{height:4px;border:0;border-radius:4px;background:var(--line)}.app .seek::-moz-range-progress{height:4px;background:var(--accent)}.app .seek::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:var(--accent);border:0}.app .plyr-demo{--accent:#c0d4a6;--line:#778279}.app .video-layout.film-layout{display:block;max-width:960px;margin:auto}@container(min-width:700px){.app .film-layout .video-info{max-width:900px;margin:auto}.app .film-layout .video-tool-row{max-width:440px}}
.app .seek::-webkit-slider-runnable-track{height:4px;border-radius:4px;background:linear-gradient(to right,var(--accent) 38%,var(--line) 38%)}.app .seek::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--accent);margin-top:-4px;border:0}.app .seek::-moz-range-track{height:4px;border:0;border-radius:4px;background:var(--line)}.app .seek::-moz-range-progress{height:4px;background:var(--accent)}.app .seek::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:var(--accent);border:0}.app .dplayer-demo{--accent:#c0d4a6;--line:#778279}.app .video-layout.film-layout{display:block;max-width:960px;margin:auto}@container(min-width:700px){.app .film-layout .video-info{max-width:900px;margin:auto}.app .film-layout .video-tool-row{max-width:440px}}
</style></head><body>
<header class="review-head"><div><strong>reader. <span style="font-weight:400;color:#718067">影音设计评审 / v2</span></strong><small>待确认方案 · 全部为示例内容与模拟交互 · 未修改业务页面</small></div><div class="review-actions"><button id="directory-toggle">页面目录</button><label>设备 <select id="device"><option value="390">手机 · 390</option><option value="360">窄屏 · 360</option><option value="535">大屏手机 · 535</option><option value="1120">桌面 · 1120</option></select></label><label>主题 <select id="review-theme"><option value="forest">原野</option><option value="sand">暖砂</option><option value="blue">雾蓝</option><option value="rose">蔷薇</option><option value="dark">深海</option><option value="graphite">石墨</option></select></label></div></header>
<header class="review-head"><div><strong>reader. <span style="font-weight:400;color:#718067">影音设计评审 / v2</span></strong><small>冻结设计基准 · 全部为示例内容与模拟交互 · 未修改业务页面</small></div><div class="review-actions"><button id="directory-toggle">页面目录</button><label>设备 <select id="device"><option value="390">手机 · 390</option><option value="360">窄屏 · 360</option><option value="535">大屏手机 · 535</option><option value="1120">桌面 · 1120</option></select></label><label>主题 <select id="review-theme"><option value="forest">原野</option><option value="sand">暖砂</option><option value="blue">雾蓝</option><option value="rose">蔷薇</option><option value="dark">深海</option><option value="graphite">石墨</option></select></label></div></header>
<div class="workspace"><aside class="directory" id="directory"><nav id="page-list" aria-label="原型页面目录"></nav></aside><main class="stage"><div class="stage-info"><div><h1 id="review-title"></h1><p id="review-note"></p></div><span class="status">冻结设计基准</span></div><div class="frame" id="frame"><div class="app" id="app" data-theme="forest"></div></div><p class="route-note" id="route-note"></p><p class="review-foot"><a href="../../../media-validation.md">验证方式</a>原型不连接服务、不读取账号、不播放真实媒体。阅读及书架设置沿用现有界面。</p></main></div>
<script>
(()=>{'use strict';
@@ -19,12 +19,12 @@ const app=document.querySelector('#app'),frame=document.querySelector('#frame');
const paths={back:'<path d="m14 5-7 7 7 7"/>',chevron:'<path d="m9 5 7 7-7 7"/>',down:'<path d="m7 10 5 5 5-5"/>',switch:'<path d="m8 9 4-4 4 4m-8 6 4 4 4-4"/>',search:'<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',more:'<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',play:'<path d="m8 5 11 7-11 7z"/>',pause:'<path d="M8 5v14M16 5v14"/>',next:'<path d="m5 5 10 7-10 7zM19 5v14"/>',prev:'<path d="m19 5-10 7 10 7zM5 5v14"/>',heart:'<path d="M20 5a5 5 0 0 0-8 1 5 5 0 0 0-8-1c-5 5 2 11 8 15 6-4 13-10 8-15z"/>',close:'<path d="m6 6 12 12M6 18 18 6"/>',timer:'<circle cx="12" cy="13" r="8"/><path d="M9 2h6m-3 7v5l3 2m3-12 2 2"/>',queue:'<path d="M3 5h13M3 11h10M3 17h8m6-5v8m0-8 5-1"/><circle cx="15" cy="20" r="2"/>',lyrics:'<path d="M4 5h16M7 12h10M4 19h16"/>',cover:'<rect x="4" y="4" width="16" height="16" rx="2"/><circle cx="12" cy="12" r="4"/>',settings:'<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="3"/><circle cx="16" cy="17" r="3"/>',folder:'<path d="M3 6h7l2 3h9v11H3z"/>',film:'<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 4v16M17 4v16M3 9h4m-4 6h4m10-6h4m-4 6h4"/>',music:'<path d="M9 18V5l11-2v13M9 8l11-2"/><ellipse cx="6" cy="18" rx="3" ry="3"/><ellipse cx="17" cy="16" rx="3" ry="3"/>',book:'<path d="M3 4c4-1 6 0 9 2 3-2 5-3 9-2v15c-4-1-6 0-9 2-3-2-5-3-9-2zM12 6v15"/>',headphones:'<path d="M3 14v-3a9 9 0 0 1 18 0v3"/><rect x="3" y="12" width="4" height="9" rx="2"/><rect x="17" y="12" width="4" height="9" rx="2"/>',clock:'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l4 2"/>',check:'<path d="m4 12 5 5L20 6"/>',refresh:'<path d="M20 8a8 8 0 1 0 0 8M20 3v5h-5"/>',add:'<path d="M12 4v16M4 12h16"/>',theme:'<circle cx="12" cy="12" r="9"/><path d="M12 3v18"/>',volume:'<path d="m11 4-6 5H2v6h3l6 5zM15 8a6 6 0 0 1 0 8"/>',fullscreen:'<path d="M3 9V3h6M15 3h6v6M21 15v6h-6M9 21H3v-6"/>',subtitle:'<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M6 10h5m2 0h5M6 14h3m3 0h6"/>',speed:'<circle cx="12" cy="12" r="9"/><path d="m12 12 4-4"/>',layers:'<path d="m12 3 10 5-10 5L2 8zm-9 10 9 5 9-5M3 18l9 5 9-5"/>',warning:'<path d="M12 3 2 21h20zM12 9v5m0 3v1"/>',lock:'<rect x="5" y="10" width="14" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',user:'<circle cx="12" cy="7" r="4"/><path d="M4 22v-3a8 8 0 0 1 16 0v3"/>',rewind:'<path d="M3 10a9 9 0 1 1 1 9M3 4v6h6"/><text x="8" y="16" font-size="8" stroke="none" fill="currentColor">15</text>',forward:'<path d="M21 10a9 9 0 1 0-1 9M21 4v6h-6"/><text x="8" y="16" font-size="8" stroke="none" fill="currentColor">30</text>'};
const ic=n=>`<svg viewBox="0 0 24 24" aria-hidden="true">${paths[n]||paths.more}</svg>`,esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const pages=[
['video','影视','影视首页','/media/video','频道首页减少重复信息:续播一行、列表三列,搜索只保留图标。'],['movies','影视','电影列表','/media/video/movies','电影与剧集共用卡片规则,只保留标题与一行关键资料。'],['series','影视','剧集列表','/media/video/series','集数作为副信息,避免媒体库名称重复占用每张卡片。'],['movie','影视','电影详情','/media/video/movie/coast','作品、播放、简介、资源逐级展开;技术规格收进资源信息。'],['show','影视','剧集详情','/media/video/show/mountains','选季紧邻选集,当前播放集使用进度与边框双重标记。'],['video-player','影视','电影播放','/media/video/player/coast/original','电影画面居中并限制最大宽度,辅助工具靠近画面;不增加推荐模块。'],['episode-player','影视','剧集播放','/media/video/player/mountains-3/original','保留 Plyr;移动端下方工具定宽排布,桌面右侧承载选季和选集。'],
['music','音乐','专辑列表','/media/music/albums','专辑封面、标题、歌手三层结构;媒体库只在列表工具栏出现。'],['artists','音乐','歌手列表','/media/music/artists','头像与姓名优先,点击进入独立歌手详情。'],['artist','音乐','歌手详情','/media/music/artist/north','简介、专辑、曲目分组;没有资料时不凭空补造内容。'],['tracks','音乐','曲目列表','/media/music/tracks','行高 60px,标题和歌手对齐,次要操作留在行末。'],['album','音乐','专辑详情','/media/music/album/quiet','播放全部为唯一主按钮,曲目区不重复专辑名称。'],['music-player','音乐','音乐播放','/media/music/player/forest/original','以封面和曲名为视觉中心,保留四个低频工具入口。'],['lyrics','音乐','歌词','/media/music/player/forest/original/lyrics','歌词与控制区分层,当前行增强字重,边缘渐隐,滚动条隐藏。'],['queue','音乐','播放队列','/media/music/player/forest/original/queue','当前播放行清晰高亮,保留队列上下文与单行操作。'],
['audiobooks','有声书','作品列表','/media/audiobook/books','封面密度与影视一致;演播者为副信息,没有独立搜索框。'],['narrators','有声书','演播者','/media/audiobook/narrators','只保留姓名、头像、作品数,按实际演播者进入详情。'],['narrator','有声书','演播者详情','/media/audiobook/narrators/zhou','演播者详情有自己的返回路径与作品列表。'],['book','有声书','有声书详情','/media/audiobook/book/letters','把继续收听与当前章节放在首屏,版本入口收拢至资源区。'],['chapters','有声书','章节列表','/media/audiobook/items/letters/chapters','章节列表独立地址,当前章进度就近显示。'],['audio-player','有声书','有声书播放','/media/audiobook/player/letters/chapter-6','章节标题与剩余时长优先,快退快进替代音乐切曲语义。'],
['search','通用','搜索','/media/search?q=山','标题只出现一次,单搜索框、单范围筛选、分组结果。'],['favorites','通用','我的收藏','/media/video/favorites','移除收藏内 tab;全部内容一个列表,类型筛选放入按钮面板。'],['history','通用','播放历史','/media/video/history','按日期分组,显示观看或收听进度,行点击直接恢复。'],['folders','通用','文件夹浏览','/media/video/folders','保持只读浏览语义,路径与文件操作分层。'],
['video','影视','影视首页','/media/video','频道首页减少重复信息:续播一行、列表三列,搜索只保留图标。'],['movies','影视','电影列表','/media/video/movies','电影与剧集共用卡片规则,只保留标题与一行关键资料。'],['series','影视','剧集列表','/media/video/series','集数作为副信息,避免媒体库名称重复占用每张卡片。'],['movie','影视','电影详情','/media/video/items/coast','作品、播放、简介、资源逐级展开;技术规格收进资源信息。'],['show','影视','剧集详情','/media/video/items/mountains','选季紧邻选集,当前播放集使用进度与边框双重标记。'],['video-player','影视','电影播放','/media/video/player?item=coast&part=coast-main','视频播放器画面居中并限制最大宽度,辅助工具靠近画面;不增加推荐模块。'],['episode-player','影视','剧集播放','/media/video/player?item=mountains&part=mountains-3','按 DPlayer 控件语义校准布局;移动端下方工具定宽排布,桌面右侧承载选季和选集。'],
['music','音乐','专辑列表','/media/music/albums','专辑封面、标题、歌手三层结构;媒体库只在列表工具栏出现。'],['artists','音乐','歌手列表','/media/music/artists','头像与姓名优先,点击进入独立歌手详情。'],['artist','音乐','歌手详情','/media/music/items/north','简介、专辑、曲目分组;没有资料时不凭空补造内容。'],['tracks','音乐','曲目列表','/media/music/tracks','行高 60px,标题和歌手对齐,次要操作留在行末。'],['album','音乐','专辑详情','/media/music/items/quiet','播放全部为唯一主按钮,曲目区不重复专辑名称。'],['music-player','音乐','音乐播放','/media/music/player?item=quiet&part=quiet-1','以封面和曲名为视觉中心,播放器使用 `/media/music/player`,保留四个低频工具入口。'],['lyrics','音乐','歌词','/media/music/player/lyrics?item=quiet&part=quiet-1','歌词与控制区分层,当前行增强字重,边缘渐隐,滚动条隐藏。'],['queue','音乐','播放队列','/media/music/player/queue?item=quiet&part=quiet-1','当前播放行清晰高亮,保留队列上下文与单行操作。'],
['audiobooks','有声书','作品列表','/media/audiobook/books','封面密度与影视一致;演播者为副信息,没有独立搜索框。'],['narrators','有声书','演播者','/media/audiobook/narrators','只保留姓名、头像、作品数,按实际演播者进入详情。'],['narrator','有声书','演播者详情','/media/audiobook/narrators/zhou','演播者详情有自己的返回路径与作品列表。'],['book','有声书','有声书详情','/media/audiobook/items/letters','把继续收听与当前章节放在首屏,版本入口收拢至资源区。'],['chapters','有声书','章节列表','/media/audiobook/items/letters/chapters','章节列表使用 `/media/audiobook/items/:itemId/chapters`,当前章进度就近显示。'],['audio-player','有声书','有声书播放','/media/audiobook/player?item=letters&part=chapter-6','播放器使用 `/media/audiobook/player`,章节标题与剩余时长优先,快退快进替代音乐切曲语义。'],
['search','通用','搜索','/media/search?q=山','标题只出现一次,单搜索框、单范围筛选、分组结果;结果进入 `/media/:channel/items/:itemId`。'],['favorites','通用','我的收藏','/media/favorites','收藏使用全局 `/media/favorites`,类型筛选放入按钮面板。'],['history','通用','播放历史','/media/video/history','按日期分组,显示观看或收听进度,行点击直接恢复。'],['folders','通用','文件夹浏览','/media/video/folders','保持只读浏览语义,路径与文件操作分层。'],
['account','设置与管理','账号与连接','/media/video/settings/account','账号使用独立路由,显示身份、服务连接与账号切换。'],['settings','设置与管理','影音设置','/media/video/settings','个人内容、偏好、管理员、账号四组;每项设置进入独立页面。'],['themes','设置与管理','主题外观','/media/video/settings/theme','统一六套语义主题,预览更紧凑,跟随系统单独设置。'],['browse-settings','设置与管理','浏览偏好','/media/video/settings/browse','密度和排序留在独立设置页。'],['playback-settings','设置与管理','播放设置','/media/video/settings/playback','设置与即时播放器操作分开:这里调整默认行为。'],['libraries','设置与管理','媒体库管理','/media/video/settings/libraries','卡片只突出名称、内容和状态,路径降级,扫描与更多分工明确。'],['library-edit','设置与管理','新建媒体库','/media/video/settings/libraries/new','标签和说明紧贴输入,保存区固定在表单末尾。'],['permissions','设置与管理','访问权限','/media/video/settings/libraries/home/permissions','清晰区分所有用户和指定用户,管理员始终拥有访问权限。'],['tasks','设置与管理','扫描与刮削','/media/video/settings/tasks','任务状态、进度、异常与重试入口保持相邻。'],['match','设置与管理','匹配资料','/media/video/items/coast/match','候选来源和关键资料对照,明确选中后才确认匹配。'],['metadata','设置与管理','编辑资料','/media/video/items/coast/metadata','展示当前值与来源;修改为模拟,不写回任何真实数据。'],['plugins','设置与管理','来源与刮削','/media/video/settings/plugins','首期只显示本地资源及刮削配置,不提前暴露 TVBox、网盘或转码入口。'],
['empty','状态','空媒体库','/media/video?review=empty','管理员一个添加入口;普通用户显示联系管理员,不放无效卡片。'],['loading','状态','加载中','/media/video?review=loading','骨架占位与真实卡片一致,加载时不闪现零条结果。'],['unavailable','状态','服务不可用','/media/video?review=unavailable','说明连接失败,保留重试与返回;不误报为空库。'],['missing','状态','播放资源失效','/media/video/player/coast/missing','保留作品上下文,提示检查文件或选择版本,不继续播放旧资源。'],['denied','状态','无访问权限','/media/video/movie/private','给出明确返回出口,普通用户不显示管理员操作。']
['empty','状态','空媒体库','/media/video?review=empty','管理员一个添加入口;普通用户显示联系管理员,不放无效卡片。'],['loading','状态','加载中','/media/video?review=loading','骨架占位与真实卡片一致,加载时不闪现零条结果。'],['unavailable','状态','服务不可用','/media/video?review=unavailable','说明连接失败,保留重试与返回;不误报为空库。'],['missing','状态','播放资源失效','/media/video/player?item=coast&part=missing','保留作品上下文,提示检查文件或选择版本,不继续播放旧资源。'],['denied','状态','无访问权限','/media/video/items/private','给出明确返回出口,普通用户不显示管理员操作。']
];
const byId=Object.fromEntries(pages.map(p=>[p[0],p]));const colors=['#4f6a60','#547483','#947756','#786b76','#63705b','#506779'];
const movies=[['海岸线','2024 · 剧情',0],['城市之间','2023 · 剧情',1],['午后邮局','2024 · 生活',2],['长夜微光','2023 · 悬疑',3],['山谷回声','2022 · 自然',4],['蓝色星期天','2024 · 纪录',5]];
@@ -55,7 +55,7 @@ let book=p==='book',show=p==='show';return `<div class="detail">${hero(book?'山
function episodes(){return `<div class="episode-grid">${['出发','重逢','山间的清晨','旧桥','回声','归途'].map((t,i)=>`<button class="episode ${i===2?'current':''}" data-action="start:episode"><strong>第 ${i+1} 集 · ${t}</strong><small>${i===2?'继续 · 12:48 / 42:20':'42 分钟'}</small>${i===2?'<div class="progress"><span></span></div>':''}</button>`).join('')}</div>`;}
function playerBottom(book=false,lyric=false){return `<div class="player-bottom"><input class="seek" type="range" min="0" max="100" value="38" aria-label="播放进度"><div class="time-row"><span>${book?'12:48':'01:36'}</span><span>${book?'-11:48':'04:12'}</span></div><div class="transport">${ib(book?'rewind':'prev',book?'skip:-15':'previous',book?'快退15秒':'上一首')}${button(ic(state.playing?'pause':'play'),'toggle','icon-button main-play')}${ib(book?'forward':'next',book?'skip:30':'next',book?'快进30秒':'下一首')}</div><div class="player-options">${button(ic(book?'speed':'heart')+(book?'1.0×':'收藏'),book?'sheet:speed':'favorite')}${button(ic('timer')+(state.timer?state.timer+' 分钟':'定时'),'sheet:timer',state.timer?'active':'')}${button(ic(book?'book':'queue')+(book?'章节':'队列'),book?'go:chapters':'go:queue')}${button(ic(book?'layers':lyric?'cover':'lyrics')+(book?'版本':lyric?'封面':'歌词'),book?'sheet:versions':lyric?'go:music-player':'go:lyrics',lyric?'active':'')}</div></div>`;}
function audio(){let book=state.page==='audio-player',lyric=state.page==='lyrics';if(lyric)return `<div class="player-main lyrics-player"><div class="lyric-top"><h1>${state.queue[state.current]||'森林来信'}</h1><p>北岸 · 静谧时刻</p></div><div class="lyric-lines" tabindex="0" aria-label="歌词">${['风经过无声的山林','晨光在树叶之间停留','把远方写成一封信','寄给慢慢醒来的你','溪流记得回家的路','我们沿着暮色前行'].map((x,i)=>button(x,'lyric:'+i,i===state.lyric?'active':'')).join('')}</div>${playerBottom(false,true)}</div>`;return `<div class="player-main">${cover(book?'山间来信':'静谧时刻',book?4:0,book?'audio-book-art':'square player-art')}${book?'<p class="chapter-heading">第 6 / 18 章 · 山间来信</p>':''}<div class="player-heading"><div><h1>${book?'山间的清晨':state.queue[state.current]||'森林来信'}</h1><p>${book?'周宁 演播 · 林间 著':'北岸 · 静谧时刻'}</p></div>${ib('more','sheet:track-more','当前内容操作')}</div>${playerBottom(book)}</div>`;}
function video(){const episodic=state.page==='episode-player';return `<div class="video-layout ${episodic?'with-episodes':'film-layout'}"><div><div class="video-surface"><div class="video-word">${episodic?'远山之路':'海岸线'}<small>${episodic?'山间的清晨':'沿着海岸,回到日常'}</small></div><div class="plyr-demo">${ib(state.playing?'pause':'play','toggle','播放或暂停')}<input class="seek" type="range" min="0" max="100" value="38" aria-label="视频进度"><time>38:12</time>${ib('volume','sheet:volume','音量')}${ib('settings','sheet:speed','倍速')}${ib('fullscreen','fullscreen','全屏')}</div></div><div class="video-info"><h1>${episodic?'远山之路 · 山间的清晨':'海岸线'}</h1><p>${episodic?'第 1 季 · 第 3 集 · 1080p':'原版 · 1080p · 剩余 1 小时 10 分'}</p><div class="video-tool-row">${button(ic('subtitle')+'字幕','sheet:subtitles')}${button(ic('volume')+'音轨','sheet:tracks')}${button(ic('layers')+'版本','sheet:versions')}${button(ic('more')+'更多','sheet:player-more')}</div><details class="video-description"><summary>作品信息 ${ic('down')}</summary><p>一位久别故乡的年轻人沿着海岸返回,重新寻找家的方向。</p></details></div></div>${episodic?`<aside class="episode-rail">${section('选集',button('第 1 季'+ic('down'),'sheet:seasons','scope-button'))}${episodes()}</aside>`:''}</div>`;}
function video(){const episodic=state.page==='episode-player';return `<div class="video-layout ${episodic?'with-episodes':'film-layout'}"><div><div class="video-surface"><div class="video-word">${episodic?'远山之路':'海岸线'}<small>${episodic?'山间的清晨':'沿着海岸,回到日常'}</small></div><div class="dplayer-demo">${ib(state.playing?'pause':'play','toggle','播放或暂停')}<input class="seek" type="range" min="0" max="100" value="38" aria-label="视频进度"><time>38:12</time>${ib('volume','sheet:volume','音量')}${ib('settings','sheet:speed','倍速')}${ib('fullscreen','fullscreen','全屏')}</div></div><div class="video-info"><h1>${episodic?'远山之路 · 山间的清晨':'海岸线'}</h1><p>${episodic?'第 1 季 · 第 3 集 · 1080p':'原版 · 1080p · 剩余 1 小时 10 分'}</p><div class="video-tool-row">${button(ic('subtitle')+'字幕','sheet:subtitles')}${button(ic('volume')+'音轨','sheet:tracks')}${button(ic('layers')+'版本','sheet:versions')}${button(ic('more')+'更多','sheet:player-more')}</div><details class="video-description"><summary>作品信息 ${ic('down')}</summary><p>一位久别故乡的年轻人沿着海岸返回,重新寻找家的方向。</p></details></div></div>${episodic?`<aside class="episode-rail">${section('选集',button('第 1 季'+ic('down'),'sheet:seasons','scope-button'))}${episodes()}</aside>`:''}</div>`;}
function settingRow(icon,title,sub,id){return link(id,ic(icon)+`<span>${title}</span><small>${sub}</small>`+ic('chevron'),'setting-row');}
function settingGroup(title,body){return `<section class="setting-group"><h2>${title}</h2><div class="setting-list">${body}</div></section>`;}
function settings(){return `<div class="settings-wrap"><div class="settings-intro"><div class="avatar">H</div><div><strong>Hector</strong><small>管理员 · 当前连接本机服务</small></div></div>${settingGroup('我的内容',settingRow('heart','我的收藏','12 项','favorites')+settingRow('clock','播放历史','','history'))}${settingGroup('偏好设置',settingRow('theme','主题外观',themeName(),'themes')+settingRow('settings','浏览偏好','紧凑','browse-settings')+settingRow('headphones','播放设置','','playback-settings'))}${settingGroup('管理员',settingRow('folder','媒体库管理','3 个','libraries')+settingRow('refresh','扫描与刮削','1 项待处理','tasks')+settingRow('layers','来源与刮削','','plugins'))}${settingGroup('账号',settingRow('user','账号与连接','','account'))}</div>`;}
+3 -3
View File
@@ -26,10 +26,10 @@ const assert=require('node:assert/strict');
await page.evaluate(()=>{window.prototypeReview.setWidth(390);window.prototypeReview.setTheme('forest');window.prototypeReview.go('video',true);});
await page.locator('.channel-title').click();await page.getByRole('menu',{name:'切换频道'}).waitFor();await page.locator('#review-title').click();assert.equal(await page.getByRole('menu').count(),0);
await page.locator('.channel-title').click();await page.locator('.popover [data-action="go:music"]').click();assert.ok(page.url().endsWith('/media/music/albums'));
await page.locator('.tile[data-go="album"]').first().click();assert.equal(await page.locator('.channel-title').count(),0);assert.ok(page.url().endsWith('/media/music/album/quiet'));
await page.locator('[data-action="start:music"]').click();await page.locator('[data-action="go:lyrics"]').click();assert.ok(page.url().endsWith('/lyrics'));
await page.locator('.tile[data-go="album"]').first().click();assert.equal(await page.locator('.channel-title').count(),0);assert.ok(page.url().endsWith('/media/music/items/quiet'));
await page.locator('[data-action="start:music"]').click();await page.locator('[data-action="go:lyrics"]').click();assert.ok(page.url().includes('/media/music/player/lyrics?item=quiet&part=quiet-1'));
await page.locator('[data-action="sheet:timer"]').click();await page.getByRole('dialog',{name:'睡眠定时'}).waitFor();await page.locator('[data-action="timer:30"]').click();assert.equal(await page.getByRole('dialog').count(),0);assert.match(await page.locator('[data-action="sheet:timer"]').innerText(),/30/);assert.equal(await page.locator('[data-action="sheet:timer"]').evaluate(e=>document.activeElement===e),true);
await page.locator('[data-action="go:queue"]').click();assert.ok(page.url().endsWith('/queue'));await page.locator('[data-action="back"]').click();await page.waitForURL(u=>u.hash.endsWith('/lyrics'));await page.reload();await page.locator('.lyric-lines').waitFor();assert.equal(await page.locator('.lyric-lines').evaluate(n=>getComputedStyle(n).scrollbarWidth),'none');
await page.locator('[data-action="go:queue"]').click();assert.ok(page.url().includes('/media/music/player/queue?item=quiet&part=quiet-1'));await page.locator('[data-action="back"]').click();await page.waitForURL(u=>u.hash.includes('/media/music/player/lyrics?item=quiet&part=quiet-1'));await page.reload();await page.locator('.lyric-lines').waitFor();assert.equal(await page.locator('.lyric-lines').evaluate(n=>getComputedStyle(n).scrollbarWidth),'none');
await page.evaluate(()=>window.prototypeReview.go('movie',true));await page.locator('[data-action="popover:resource"]').click();await page.locator('[data-action="sheet:files"]').click();await page.getByRole('dialog',{name:'文件与技术信息'}).waitFor();const headerHeight=await page.locator('.sheet-head').evaluate(e=>e.getBoundingClientRect().height);assert.ok(headerHeight<=58);await page.keyboard.press('Escape');assert.equal(await page.getByRole('dialog').count(),0);
await page.evaluate(()=>window.prototypeReview.go('themes',true));await page.locator('[data-action="theme:dark"]').click();assert.equal(await page.locator('#app').getAttribute('data-theme'),'dark');
assert.deepEqual(errors,[]);await fs.writeFile(join(output,'checks.json'),JSON.stringify({pages:ids.length,layoutCases:layout.length,screenshots:shots.length,errors,checks:['360/390/1120 no horizontal overflow','primary channel dropdown and outside dismissal','secondary page has no channel dropdown','album and player independent hashes','lyrics queue back and reload','timer preset selection','resource dialog compact header and Escape','theme preview'],shots},null,2));
+2 -2
View File
@@ -70,11 +70,11 @@ EPUB 章节按需加载,长书的目录与传输窗口分开处理,跨窗口
## OPDS 书库
管理员在「书源」中添加 OPDS 服务地址;需要认证时配置当前账号的凭据。打开来源可浏览、搜索并获取书籍,下载完成后加入书架。也可以通过[只读 OPDS 服务](opds-webdav.zh-CN.md)把 Reader 中的文件书提供给外部阅读器。
从书架的「书源」入口进入书源中心;管理员可在「书源管理」中添加 OPDS 服务地址。需要认证时配置当前账号的凭据。打开来源可浏览、搜索并获取书籍,下载完成后加入书架。也可以通过[只读 OPDS 服务](opds-webdav.zh-CN.md)把 Reader 中的文件书提供给外部阅读器。
## 影音入口
从书架「影音」进入电影、剧集、音乐和有声书。手机点击顶部频道名称切换,选择「阅读」返回书架。影音拥有独立设置、主题、收藏与播放页面,不改变阅读器和书架设置。
打开站点默认进入影视页(`#/media/video`),也可从书架右上角「影音」入口进入。通过频道导航切换电影、剧集、音乐和有声书,选择「阅读」返回书架。影音拥有独立设置、主题、收藏与播放页面,不改变阅读器和书架设置。
管理员可添加本地目录或 OpenList 媒体库,配置权限、扫描与刮削;用户浏览已授权的内容并播放。建库、歌词、字幕、队列、倍速及睡眠定时见[影音使用说明](media-user-guide.zh-CN.md)。
@@ -7,7 +7,7 @@ import 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', 'account'];
const panels: readonly SettingsPanel[] = ['home', 'theme', 'browse', 'playback', 'plugins', 'account'];
const titles: Record<SettingsPanel, string> = {
home: '影音设置', theme: '主题外观', browse: '浏览偏好', playback: '播放设置',
plugins: '来源与刮削', account: '账号与连接',
@@ -997,16 +997,13 @@ body[data-media-theme]:has(>#app>.media-screen) {background:var(--media-theme-ba
.media-sleep-presets small {font-size:11px;color:var(--muted)}
.media-screen .media-sleep-off,.media-video-sheet .media-sleep-off {display:flex;align-items:center;gap:10px;width:100%;margin-top:14px;padding:12px 4px;text-align:left;min-height:44px;color:var(--text)}
.media-sleep-off span {flex:1}
.media-video-stage .plyr {width:100%;--plyr-color-main:var(--media-accent);--plyr-video-background:#080b0e;--plyr-font-family:inherit;--plyr-control-icon-size:18px;--plyr-control-spacing:10px;--plyr-menu-background:#263022;--plyr-menu-color:#f2f2e9}
.media-video-stage .plyr video {width:100%;max-height:min(66dvh,650px)}
.media-video-stage .plyr:is(:fullscreen,.plyr--fullscreen-fallback) video {max-height:none;height:100%}
.media-video-stage .plyr__control {border:0}
.media-video-stage .plyr__controls button {min-width:36px;min-height:36px}
.media-video-stage .plyr__control--overlaid {background:var(--media-accent);color:#182010;border-radius:50%;padding:18px}
.media-video-stage .dplayer-host {width:100%;background:#080b0e}
.media-video-stage .dplayer-host video {width:100%;max-height:min(66dvh,650px);object-fit:contain}
.media-video-stage .dplayer-host:fullscreen video,.media-video-stage .dplayer-host:-webkit-full-screen video {max-height:none;height:100%}
.media-video-stage .dplayer-controller button {border:0;min-width:36px;min-height:36px}
@media(max-width:600px){
.media-video-stage .plyr {--plyr-control-icon-size:16px;--plyr-control-spacing:7px}
.media-video-stage .plyr .plyr__volume input[type=range],.media-video-stage .plyr .plyr__time--duration,.media-video-stage .plyr [data-plyr=pip] {display:none}
.media-video-stage .plyr .plyr__volume {flex:0 0 auto;min-width:0;width:auto}
.media-video-stage .dplayer-host .dplayer-time .dplayer-dtime {display:none}
.media-video-stage .dplayer-host .dplayer-volume {flex:0 0 auto;min-width:0;width:auto}
}
/* Media dialogs and player selects inherit the active media palette. */
+4 -8
View File
@@ -1,5 +1,5 @@
/* Approved v2 visual contract. Media-only selectors keep reading isolated.
Plyr owns all controls inside .plyr; this sheet only lays out its stage. */
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}
@@ -246,11 +246,7 @@ body[data-media-theme] .media-episode-rail .media-video-episodes button[aria-cur
background:var(--media-theme-selection);
color:var(--media-theme-accent);
}
body[data-media-theme] .media-video-stage .plyr {
--plyr-color-main:var(--media-theme-accent);
--plyr-menu-background:var(--media-theme-paper);
--plyr-menu-color:var(--media-theme-text);
}
body[data-media-theme] .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}
@@ -941,8 +937,8 @@ body[data-media-theme] {--surface-muted:var(--media-theme-surface)}
/* Keep the embedded player's own controls independent from the reader theme. */
.media-video-stage,.media-video-stage *{color-scheme:dark}
.media-video-stage .plyr{--plyr-color-main:#b9d98a;--plyr-video-background:#080b0e;--plyr-font-family:inherit;--plyr-control-icon-size:18px;--plyr-control-spacing:10px;--plyr-menu-background:#263022;--plyr-menu-color:#f2f2e9}
.media-video-stage .plyr__controls button,.media-video-stage .plyr__control{background:transparent;color:#f2f2e9}
.media-video-stage .dplayer-host{background:#080b0e}
.media-video-stage .dplayer-controller button{background:transparent;color:#f2f2e9}
/* Folder actions use the same compact icon button as other page menus. */
.media-screen .media-folder-actions>summary { width:44px; min-width:44px; height:44px; box-sizing:border-box; padding:10px; border:1px solid var(--border); border-radius:50%; background:var(--surface); cursor:pointer; display:inline-flex; align-items:center; justify-content:center; list-style:none; }
+1
View File
@@ -17,6 +17,7 @@ describe('React Router route tree', () => {
['/media/queue', 'MediaQueuePage'],
['/media/music/queue', 'MediaQueuePage'],
['/media/video/settings', 'MediaSettingsPage'],
['/media/video/settings/plugins', 'MediaSettingsPage'],
['/media/video/settings/libraries', 'MediaLibrariesPage'],
['/media/video/settings/tasks', 'MediaTasksPage'],
['/media/video/folders/library-1', 'MediaFoldersPage'],
+18 -18
View File
@@ -3,16 +3,16 @@ const {chromium}=require('playwright'),{spawn}=require('node:child_process'),{jo
const repo=resolve(__dirname,'../..'),out=process.env.MEDIA_REVIEW_OUTPUT||join(repo,'artifacts/media/playback-review');await fs.mkdir(out,{recursive:true});const server=spawn(process.execPath,['--import',pathToFileURL(join(repo,'server/node_modules/tsx/dist/loader.mjs')).href,join(repo,'server/tools/media-review-fixture.ts')],{cwd:repo,windowsHide:true,stdio:['pipe','pipe','pipe']});let browser,logs='';server.stderr.on('data',d=>logs+=d);
try{
const {baseUrl,sampleItems}=await new Promise((ok,no)=>{let output='';server.stdout.on('data',d=>{output+=d;for(const line of output.split('\n'))try{const v=JSON.parse(line);if(v.baseUrl)ok(v);}catch{}});server.once('exit',()=>no(Error(logs)));});
browser=await chromium.launch({headless:true,executablePath:process.env.PROTOTYPE_CHROMIUM});const context=await browser.newContext({viewport:{width:535,height:834}}),page=await context.newPage(),errors=[],shots=[],external=[];let sessions=0;
page.on('pageerror',error=>errors.push(error.message));page.on('request',request=>{if(request.method()==='POST'&&request.url().endsWith('/media/playback'))sessions++;if(request.url().includes('cdn.plyr.io'))external.push(request.url());});
const shot=async name=>{if(name.startsWith('video')){await page.locator('.plyr').hover();await page.waitForFunction(()=>getComputedStyle(document.querySelector('.plyr__controls')).opacity==='1');await page.waitForFunction(()=>document.querySelector('.media-player video').readyState>=2);}else await page.mouse.move(0,0);await page.screenshot({path:join(out,name+'.png')});shots.push(name);};
browser=await chromium.launch({headless:true,executablePath:process.env.PROTOTYPE_CHROMIUM});const context=await browser.newContext({viewport:{width:535,height:834}}),page=await context.newPage(),errors=[],shots=[];let sessions=0;
page.on('pageerror',error=>errors.push(error.message));page.on('request',request=>{if(request.method()==='POST'&&request.url().endsWith('/media/playback'))sessions++;});
const shot=async name=>{if(name.startsWith('video')){await page.locator('.dplayer-host').hover();await page.waitForFunction(()=>getComputedStyle(document.querySelector('.dplayer-controller')).opacity==='1');await page.waitForFunction(()=>document.querySelector('.media-player video').readyState>=2);}else await page.mouse.move(0,0);await page.screenshot({path:join(out,name+'.png')});shots.push(name);};
const go=hash=>page.evaluate(hash=>location.hash=hash,hash);
const ready=kind=>page.waitForFunction(kind=>{const media=document.querySelector('.media-player '+kind);return media?.readyState>=1;},kind);
const saved=()=>page.waitForResponse(response=>response.request().method()==='PUT'&&response.url().includes('/progress')&&response.ok());
const album=sampleItems.find(item=>item.kind==='album'&&item.title==='静谧时刻'),movie=sampleItems.find(item=>item.kind==='movie'&&item.title==='海岸线');assert.ok(album&&movie);
await page.goto(baseUrl+'/#/media/music/album/'+album.id);await page.locator('input[autocomplete="username"]').fill('reviewer');await page.locator('input[type="password"]').fill('review-test-pass');await page.locator('form button[type=submit]').click();await page.getByRole('button',{name:'播放全部',exact:true}).click();
await page.goto(baseUrl+'/#/media/music/items/'+album.id);await page.locator('input[autocomplete="username"]').fill('reviewer');await page.locator('input[type="password"]').fill('review-test-pass');await page.locator('form button[type=submit]').click();await page.getByRole('button',{name:'播放全部',exact:true}).click();
await page.waitForFunction(()=>{const audio=document.querySelector('.media-player audio');return audio?.readyState>=2&&!audio.paused;});await page.getByRole('button',{name:'打开播放控制',exact:true}).click();await page.locator('.media-audio-heading strong').waitFor();
assert.match(new URL(page.url()).hash,/^#\/media\/music\/player\/[^/]+\/[^/?]+$/);assert.ok(!page.url().includes('return='));
const playerLocation=new URL('http://route'+new URL(page.url()).hash.slice(1));assert.equal(playerLocation.pathname,'/media/music/player');assert.ok(playerLocation.searchParams.get('item'));assert.ok(playerLocation.searchParams.get('part'));assert.ok(!page.url().includes('return='));
const source=await page.locator('.media-player audio').getAttribute('src');
await page.getByRole('button',{name:'歌词',exact:true}).click();await page.locator('.media-lyrics-lines button').first().waitFor();assert.ok(page.url().includes('/lyrics'));
assert.equal(await page.locator('.media-lyrics-lines').evaluate(node=>getComputedStyle(node).scrollbarWidth),'none');
@@ -25,21 +25,21 @@ const {chromium}=require('playwright'),{spawn}=require('node:child_process'),{jo
const beforeNext=page.url();await page.getByRole('button',{name:'下一首 / 章',exact:true}).click();await page.waitForURL(url=>url.href!==beforeNext);await ready('audio');
const audioUrl=page.url(),saveNext=saved();await page.locator('.media-player audio').evaluate(audio=>{audio.currentTime=24;audio.pause();});await saveNext;
await page.reload();await page.locator('.media-audio-heading strong').waitFor({timeout:8000});await ready('audio');assert.equal(page.url(),audioUrl);assert.ok(await page.locator('.media-player audio').evaluate(audio=>audio.currentTime>=23&&audio.paused));await shot('lyrics-restored-mobile');
const direct=await page.context().newPage();await direct.goto(audioUrl);await direct.locator('.media-audio-heading strong').waitFor();await direct.waitForFunction(()=>document.querySelector('.media-player audio')?.readyState>=1);await direct.getByRole('button',{name:'← 返回播放',exact:true}).click();await direct.waitForURL(url=>/\/player\/[^/]+\/[^/?]+$/.test(url.hash));await direct.close();
await go('#/media/video/movie/'+movie.id);await page.getByRole('button',{name:'播放电影',exact:true}).click();await page.locator('.plyr--video').waitFor();await ready('video');await page.waitForURL(url=>/^#\/media\/video\/player\/[^/]+\/[^/?]+$/.test(url.hash));
assert.equal(await page.locator('.plyr__controls').count(),1);assert.equal(await page.locator('.media-video-timeline').count(),0);await shot('video-mobile');
await page.setViewportSize({width:390,height:844});await shot('video-mobile-narrow');assert.ok(await page.locator('.plyr').evaluate(node=>node.scrollWidth<=node.clientWidth+1));assert.equal(await page.locator('.plyr__volume input').isVisible(),false);assert.ok((await page.locator('.plyr__progress input').boundingBox()).width>=80);await page.setViewportSize({width:535,height:834});
const direct=await page.context().newPage();await direct.goto(audioUrl);await direct.locator('.media-audio-heading strong').waitFor();await direct.waitForFunction(()=>document.querySelector('.media-player audio')?.readyState>=1);await direct.getByRole('button',{name:'← 返回播放',exact:true}).click();await direct.waitForURL(url=>{const routed=new URL('http://route'+url.hash.slice(1));return routed.pathname==='/media/music/player'&&routed.searchParams.get('item')&&routed.searchParams.get('part');});await direct.close();
await go('#/media/video/items/'+movie.id);await page.getByRole('button',{name:'播放电影',exact:true}).click();await page.locator('.dplayer-host').waitFor();await ready('video');await page.waitForURL(url=>{const routed=new URL('http://route'+url.hash.slice(1));return routed.pathname==='/media/video/player'&&routed.searchParams.get('item')&&routed.searchParams.get('part');});
assert.equal(await page.locator('.dplayer-controller').count(),1);assert.equal(await page.locator('.media-video-timeline').count(),0);await shot('video-mobile');
await page.setViewportSize({width:390,height:844});await shot('video-mobile-narrow');assert.ok(await page.locator('.dplayer-host').evaluate(node=>node.scrollWidth<=node.clientWidth+1));assert.equal(await page.locator('.dplayer-volume-bar-wrap').isVisible(),false);assert.ok((await page.locator('.dplayer-bar-wrap').boundingBox()).width>=80);await page.setViewportSize({width:535,height:834});
const videoUrl=page.url(),videoSource=await page.locator('.media-player video').getAttribute('src');await page.getByRole('button',{name:'字幕',exact:true}).click();await page.getByRole('dialog',{name:'字幕'}).waitFor();await page.getByRole('button',{name:'关闭播放菜单',exact:true}).click();assert.equal(await page.locator('.media-player video').getAttribute('src'),videoSource);
await page.setViewportSize({width:1120,height:900});await shot('video-desktop');
await page.locator('.plyr').hover();await page.locator('.plyr [data-plyr="fullscreen"]').click();await page.waitForFunction(()=>!!document.fullscreenElement);await shot('video-fullscreen');await page.locator('.plyr').hover();await page.locator('.plyr [data-plyr="fullscreen"]').click();await page.waitForFunction(()=>!document.fullscreenElement);
const saveVideo=saved();await page.locator('.media-player video').evaluate(video=>{video.currentTime=20;video.pause();});await saveVideo;await page.reload();await page.locator('.plyr--video').waitFor();await ready('video');assert.equal(page.url(),videoUrl);assert.ok(await page.locator('.media-player video').evaluate(video=>video.currentTime>=19&&video.paused));await shot('video-restored-desktop');
await page.locator('.plyr__controls [data-plyr="play"]').click();await page.waitForFunction(()=>!document.querySelector('.media-player video').paused);
await page.locator('.plyr__controls [data-plyr="play"]').press('k');await page.waitForFunction(()=>document.querySelector('.media-player video').paused);
await page.locator('.plyr__controls [data-plyr="play"]').press('k');await page.waitForFunction(()=>!document.querySelector('.media-player video').paused);
await go('#/media/video/player/'+movie.id+'/missing');await page.getByRole('alert').filter({hasText:'播放资源已变化'}).waitFor();assert.equal(await page.locator('.media-player').isVisible(),false);
assert.deepEqual(external,[]);assert.deepEqual(errors,[]);
await fs.writeFile(join(out,'checks.json'),JSON.stringify({shots,flows:['resource IDs in clean playback URLs','lyrics seek and hidden scrollbar','timer presets','continuous playback across panels','next track updates route','audio reload and direct-link restore','Plyr local assets and fullscreen','video subtitle menu retains source','video reload restores progress paused','invalid resource shows error'],sessions,errors},null,2));
await fs.writeFile(join(out,'index.html'),'<!doctype html><meta charset="utf-8"><title>播放页验收</title><style>body{font:15px system-ui;background:#f4f4f1;color:#273323;padding:20px}img{max-width:100%;border:1px solid #ccc}figure{margin:24px 0}</style><h1>播放页实际截图</h1><p>真实媒体、临时数据库。Plyr 控件、歌词、定时和刷新恢复。</p>'+shots.map(name=>`<figure><figcaption>${name}</figcaption><img src="${name}.png"></figure>`).join(''));
await page.locator('.dplayer-host').hover();await page.locator('.dplayer-full-icon').click();await page.waitForFunction(()=>!!document.fullscreenElement);await shot('video-fullscreen');await page.locator('.dplayer-host').hover();await page.locator('.dplayer-full-icon').click();await page.waitForFunction(()=>!document.fullscreenElement);
const saveVideo=saved();await page.locator('.media-player video').evaluate(video=>{video.currentTime=20;video.pause();});await saveVideo;await page.reload();await page.locator('.dplayer-host').waitFor();await ready('video');assert.equal(page.url(),videoUrl);assert.ok(await page.locator('.media-player video').evaluate(video=>video.currentTime>=19&&video.paused));await shot('video-restored-desktop');
await page.locator('.dplayer-play-icon').click();await page.waitForFunction(()=>!document.querySelector('.media-player video').paused);
await page.locator('.dplayer-play-icon').press('k');await page.waitForFunction(()=>document.querySelector('.media-player video').paused);
await page.locator('.dplayer-play-icon').press('k');await page.waitForFunction(()=>!document.querySelector('.media-player video').paused);
await go('#/media/video/player?item='+encodeURIComponent(movie.id)+'&part=missing');await page.getByRole('alert').filter({hasText:'播放资源已变化'}).waitFor();assert.equal(await page.locator('.media-player').isVisible(),false);
assert.deepEqual(errors,[]);
await fs.writeFile(join(out,'checks.json'),JSON.stringify({shots,flows:['resource IDs in clean playback URLs','lyrics seek and hidden scrollbar','timer presets','continuous playback across panels','next track updates route','audio reload and direct-link restore','DPlayer local assets and fullscreen','video subtitle menu retains source','video reload restores progress paused','invalid resource shows error'],sessions,errors},null,2));
await fs.writeFile(join(out,'index.html'),'<!doctype html><meta charset="utf-8"><title>播放页验收</title><style>body{font:15px system-ui;background:#f4f4f1;color:#273323;padding:20px}img{max-width:100%;border:1px solid #ccc}figure{margin:24px 0}</style><h1>播放页实际截图</h1><p>真实媒体、临时数据库。DPlayer 控件、歌词、定时和刷新恢复。</p>'+shots.map(name=>`<figure><figcaption>${name}</figcaption><img src="${name}.png"></figure>`).join(''));
console.log(JSON.stringify({screenshots:shots.length,sessions,errors}));
}finally{if(browser)await browser.close();server.stdin.end();await new Promise(ok=>server.exitCode!==null?ok():server.once('exit',ok));}
})().catch(error=>{console.error(error);process.exitCode=1;});
+2 -2
View File
@@ -23,14 +23,14 @@ const {chromium}=require('playwright'),{spawn}=require('node:child_process'),{jo
await page.getByRole('button',{name:'深海',exact:true}).click();await page.reload();await page.getByRole('button',{name:'深海',exact:true}).waitFor();assert.equal(await page.getByRole('button',{name:'深海',exact:true}).getAttribute('aria-pressed'),'true');
await shot('themes-dark-mobile');await page.setViewportSize({width:1120,height:900});await shot('themes-desktop');await page.setViewportSize({width:427,height:900});
const album=sampleItems.find(item=>item.kind==='album'&&item.title==='静谧时刻'),movie=sampleItems.find(item=>item.kind==='movie'&&item.title==='海岸线');assert.ok(album&&movie,'Use the real acceptance sample pack');
await go('#/media/music/album/'+album.id);await page.getByRole('button',{name:'播放全部',exact:true}).click();await page.waitForFunction(()=>{const audio=document.querySelector('.media-player audio');return audio?.readyState>=2&&!audio.paused;});
await go('#/media/music/items/'+album.id);await page.getByRole('button',{name:'播放全部',exact:true}).click();await page.waitForFunction(()=>{const audio=document.querySelector('.media-player audio');return audio?.readyState>=2&&!audio.paused;});
const source=await page.locator('.media-player audio').evaluate(audio=>audio.currentSrc);assert.deepEqual(await palette('.media-screen'),await palette('.media-player'));await page.getByRole('button',{name:'播放全部',exact:true}).hover();assert.equal(await page.getByRole('button',{name:'播放全部',exact:true}).evaluate(button=>getComputedStyle(button).backgroundColor),'rgb(142, 201, 238)');await page.mouse.move(0,0);await shot('player-dark');
await go('#/media/video/settings/theme');await page.getByRole('button',{name:'深海',exact:true}).waitFor();assert.equal(await page.getByRole('button',{name:'深海',exact:true}).getAttribute('aria-pressed'),'true');
await page.getByRole('button',{name:'暖砂',exact:true}).click();assert.deepEqual(await palette('.media-screen'),await palette('.media-player'));assert.equal(await page.locator('.media-player audio').evaluate(audio=>audio.currentSrc),source);assert.equal(await page.locator('.media-player audio').evaluate(audio=>audio.paused),false);
await page.getByRole('button',{name:'关闭播放器',exact:true}).click();
for(const [id,name] of [['sand','暖砂'],['midnight','深海']]){
await go('#/media/video/settings/theme');await page.getByRole('button',{name,exact:true}).click();
await go('#/media/video/movie/'+movie.id);await page.locator('summary[aria-label="资源信息操作"]').click();await page.getByRole('button',{name:'版本管理',exact:true}).click();
await go('#/media/video/items/'+movie.id);await page.locator('summary[aria-label="作品操作"]').click();await page.getByRole('button',{name:'版本管理',exact:true}).click();
const dialog=page.getByRole('dialog',{name:'版本管理'});await dialog.waitFor();
for(const width of [390,535,1120]){
await page.setViewportSize({width,height:834});const header=await dialog.locator('.source-modal-header').boundingBox();assert.ok(header.height<=60,'resource header must be compact');heights.push({theme:id,width,height:header.height});
+12 -12
View File
@@ -49,46 +49,46 @@ async function saveShot(capture){for(let attempt=0;;attempt++){try{return await
}
const routes=[
['video',`video?library=${movie.libraryId}`,'.media-tile'],['movies',`video/movies?library=${movie.libraryId}`,'.media-tile'],['series',`video/series?library=${movie.libraryId}`,'.media-tile'],
['movie',`video/movie/${movie.id}`,'.media-detail-heading'],['show',`video/show/${series.id}`,'.media-detail-heading'],
['movie',`video/items/${movie.id}`,'.media-detail-heading'],['show',`video/items/${series.id}`,'.media-detail-heading'],
['music',`music/albums?library=${album.libraryId}`,'.media-tile'],['artists',`music/artists?library=${album.libraryId}`,'.media-tile'],['tracks',`music/tracks?library=${album.libraryId}`,'.media-track-entry'],
['album',`music/album/${album.id}`,'.media-album-track'],['artist',`music/artist/${artist.id}`,'.media-detail-heading'],
['audiobooks',`audiobook/books?library=${book.libraryId}`,'.media-tile'],['narrators',`audiobook/narrators?library=${book.libraryId}`,'.media-person-tile'],['book',`audiobook/book/${book.id}`,'.media-detail-heading'],
['album',`music/items/${album.id}`,'.media-album-track'],['artist',`music/items/${artist.id}`,'.media-detail-heading'],
['audiobooks',`audiobook/books?library=${book.libraryId}`,'.media-tile'],['narrators',`audiobook/narrators?library=${book.libraryId}`,'.media-person-tile'],['book',`audiobook/items/${book.id}`,'.media-detail-heading'],
['chapters',`audiobook/items/${book.id}/chapters`,'.media-audiobook-chapters'],['search','search?q=海&scope=all','.media-search-result'],
['favorites','video/favorites','.media-favorite-list'],['history','video/history','.media-history-row'],['folders',`music/folders?library=${album.libraryId}`,'.media-folder-row'],
['favorites','favorites','.media-favorite-list'],['history','video/history','.media-history-row'],['folders',`music/folders?library=${album.libraryId}`,'.media-folder-row'],
['settings','music/settings','.media-settings-intro'],['themes','music/settings/theme','.media-theme-card'],['browse-settings','music/settings/browse','.media-settings-panel'],['playback-settings','music/settings/playback','.media-settings-panel'],
['libraries','music/settings/libraries','.media-library-row'],['library-edit','video/settings/libraries/new','.media-library-create'],['permissions',`video/settings/libraries/${movie.libraryId}/permissions`,'.media-permissions-form'],
['tasks','video/settings/tasks','.media-manager'],['metadata',`video/items/${movie.id}/metadata`,'.media-metadata-host'],['match',`video/items/${movie.id}/match`,'.media-metadata-host'],['plugins','video/settings/plugins','.media-settings'],['account','music/settings/account','.media-account-panel'],
];
for(const [channel,item] of [['music',album],['audiobook',book]]){await go('#/media/'+channel+'/'+(channel==='music'?'album/':'book/')+item.id);await page.locator('.media-detail-favorite').waitFor();await page.locator('.media-detail-favorite').click();await page.waitForFunction(()=>document.querySelector('.media-detail-favorite')?.getAttribute('aria-pressed')==='true');}
for(const [channel,item] of [['music',album],['audiobook',book]]){await go('#/media/'+channel+'/items/'+item.id);await page.locator('.media-detail-favorite').waitFor();await page.locator('.media-detail-favorite').click();await page.waitForFunction(()=>document.querySelector('.media-detail-favorite')?.getAttribute('aria-pressed')==='true');}
const wanted=process.env.MEDIA_V2_ONLY?.split(',');
for(const [id,route,ready] of routes){if(wanted&&!wanted.includes(id))continue;await go('#/media/'+route);await page.locator(ready).first().waitFor();if(id==='match'){await page.getByRole('button',{name:'搜索候选',exact:true}).click();await page.getByRole('button',{name:'预览匹配',exact:true}).first().waitFor();}for(const width of [390,1120])await shot(id,width);console.log('reviewed '+id);}
if(!wanted||wanted.includes('players')){
await go('#/media/audiobook/narrators?library='+book.libraryId);await page.locator('.media-person-tile').first().click();await page.locator('.media-person-hero').waitFor();await shot('narrator');await shot('narrator',1120);
await page.route('**/api/v1/media/libraries',route=>route.fulfill({contentType:'application/json',body:JSON.stringify({items:[]})}));
await page.goto(baseUrl+'/#/media/video');await page.locator('.media-library-empty').waitFor();await shot('empty');await page.unroute('**/api/v1/media/libraries');await page.reload();await page.locator('.media-tabs:not([hidden])').waitFor();
await go('#/media/video/movie/favorite-001');await page.locator('.media-missing-edition').waitFor();await shot('missing');
await go('#/media/video/items/favorite-001');await page.locator('.media-missing-edition').waitFor();await shot('missing');
for(const [id,status,code] of [['denied',403,'MEDIA_FORBIDDEN'],['unavailable',503,'MEDIA_UNAVAILABLE']]){
const pattern=id==='unavailable'?/\/api\/v1\/media\/(browse|libraries\/[^/]+\/items)\?/:'**/api/v1/media/items/review-'+id;
await page.route(pattern,route=>route.fulfill({status,contentType:'application/json',body:JSON.stringify({error:{code,message:id==='denied'?'当前账号没有此媒体库的访问权限':'暂时无法读取影音服务'}})}));
await go(id==='unavailable'?'#/media/video':'#/media/video/movie/review-'+id);if(id==='unavailable')await page.reload();await page.locator('.media-screen-error.is-page').waitFor();await shot(id);await page.unroute(pattern);
await go(id==='unavailable'?'#/media/video':'#/media/video/items/review-'+id);if(id==='unavailable')await page.reload();await page.locator('.media-screen-error.is-page').waitFor();await shot(id);await page.unroute(pattern);
}
const pending=[];await page.route('**/api/v1/media/browse?**',route=>pending.push(route));
await go('#/media/music/albums');await page.locator('.media-read-loading').waitFor();await shot('loading');
for(const route of pending)await route.continue().catch(()=>{});await page.unroute('**/api/v1/media/browse?**');
await go('#/media/music/album/'+album.id);await page.getByRole('button',{name:'播放全部',exact:true}).click();await page.waitForFunction(()=>document.querySelector('.media-player audio')?.readyState>=2);await page.getByRole('button',{name:'打开播放控制',exact:true}).click();await page.locator('.media-playing-artwork').waitFor();
await go('#/media/music/items/'+album.id);await page.getByRole('button',{name:'播放全部',exact:true}).click();await page.waitForFunction(()=>document.querySelector('.media-player audio')?.readyState>=2);await page.getByRole('button',{name:'打开播放控制',exact:true}).click();await page.locator('.media-playing-artwork').waitFor();
for(const width of [360,390,1120])await shot('music-player',width);
await page.getByRole('button',{name:'歌词',exact:true}).click();await page.locator('.media-lyrics-lines').waitFor();await shot('lyrics');assert.equal(await page.locator('.media-lyrics-lines').evaluate(e=>getComputedStyle(e).scrollbarWidth),'none');
await page.getByRole('button',{name:'睡眠定时',exact:true}).click();await page.getByRole('dialog').waitFor();await page.screenshot({path:join(out,'timer-actual.png')});await page.keyboard.press('Escape');
await page.getByRole('button',{name:'播放队列',exact:true}).click();await page.locator('.media-current-playlist').waitFor();await shot('queue');
await go('#/media/audiobook/book/'+book.id);await page.locator('.media-detail-actions .media-primary').click();await page.waitForFunction(()=>document.querySelector('.media-player audio')?.readyState>=2);await page.getByRole('button',{name:'打开播放控制',exact:true}).click();await page.locator('.media-playing-artwork').waitFor();await shot('audio-player');await shot('audio-player',1120);
for(const [id,item] of [['video-player',movie],['episode-player',episode]]){await go('#/media/video/'+(item===movie?'movie/':'episode/')+item.id);await page.locator('.media-detail-actions .media-primary').click();await page.locator('.dplayer-video').waitFor();await page.waitForFunction(()=>document.querySelector('.media-player video')?.readyState>=2);for(const width of [390,1120])await shot(id,width);}
await go('#/media/audiobook/items/'+book.id);await page.locator('.media-detail-actions .media-primary').click();await page.waitForFunction(()=>document.querySelector('.media-player audio')?.readyState>=2);await page.getByRole('button',{name:'打开播放控制',exact:true}).click();await page.locator('.media-playing-artwork').waitFor();await shot('audio-player');await shot('audio-player',1120);
for(const [id,item] of [['video-player',movie],['episode-player',episode]]){await go('#/media/video/items/'+item.id);await page.locator('.media-detail-actions .media-primary').click();await page.locator('.dplayer-video').waitFor();await page.waitForFunction(()=>document.querySelector('.media-player video')?.readyState>=2);for(const width of [390,1120])await shot(id,width);}
}
if(!wanted||wanted.includes('players')){
await go('#/media/music/settings/theme');await page.getByRole('button',{name:'深海',exact:true}).click();await go('#/media/music/albums?library='+album.libraryId);await page.locator('.media-tile').first().waitFor();await shot('music',390,'midnight');
await go('#/media/music/settings/theme');await page.getByRole('button',{name:'原野',exact:true}).click();
}
assert.deepEqual(errors,[]);await fs.writeFile(join(out,'checks.json'),JSON.stringify({complete:true,fixture:'Real local acceptance media and disposable server; reference content differs where the library lacks prototype metadata. Video internals use unchanged Plyr.',checks,errors},null,2));
await fs.writeFile(join(out,'index.html'),`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>影音 v2 实施验收</title><style>body{margin:24px;background:#f8f9f5;color:#202b24;font:14px/1.6 system-ui}h1{font-size:24px}section{margin:32px 0}.pair{display:grid;grid-template-columns:1fr 1fr;gap:16px;max-width:1600px}figure{margin:0;min-width:0}img{width:100%;border:1px solid #dfe5d9}figcaption{margin:8px 0}nav{display:flex;flex-wrap:wrap;gap:12px}a{color:#466638}@media(max-width:600px){body{margin:12px}.pair{gap:8px}}</style><h1>影音 v2 · 原型与实际页面</h1><p>左侧为已确认的 v2 原型,右侧为生产构建。尺寸与主题一致;实际页面使用本地验收资源,条目数量、封面及元数据可能不同。Plyr 自带控件按用户要求保留。</p><nav>${checks.map(r=>`<a href="#${r.key}">${r.id} ${r.width}</a>`).join('')}</nav>${checks.map(r=>`<section id="${r.key}"><h2>${r.id} · ${r.width}px</h2><div class="pair"><figure><figcaption>v2 原型</figcaption><img loading="lazy" src="${r.key}-reference.png"></figure><figure><figcaption>实际页面</figcaption><img loading="lazy" src="${r.key}-actual.png"></figure></div></section>`).join('')}</html>`);
assert.deepEqual(errors,[]);await fs.writeFile(join(out,'checks.json'),JSON.stringify({complete:true,fixture:'Real local acceptance media and disposable server; reference content differs where the library lacks prototype metadata. Video internals use DPlayer.',checks,errors},null,2));
await fs.writeFile(join(out,'index.html'),`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>影音 v2 实施验收</title><style>body{margin:24px;background:#f8f9f5;color:#202b24;font:14px/1.6 system-ui}h1{font-size:24px}section{margin:32px 0}.pair{display:grid;grid-template-columns:1fr 1fr;gap:16px;max-width:1600px}figure{margin:0;min-width:0}img{width:100%;border:1px solid #dfe5d9}figcaption{margin:8px 0}nav{display:flex;flex-wrap:wrap;gap:12px}a{color:#466638}@media(max-width:600px){body{margin:12px}.pair{gap:8px}}</style><h1>影音 v2 · 原型与实际页面</h1><p>左侧为已确认的 v2 原型,右侧为生产构建。尺寸与主题一致;实际页面使用本地验收资源,条目数量、封面及元数据可能不同。视频控件由 DPlayer 提供。</p><nav>${checks.map(r=>`<a href="#${r.key}">${r.id} ${r.width}</a>`).join('')}</nav>${checks.map(r=>`<section id="${r.key}"><h2>${r.id} · ${r.width}px</h2><div class="pair"><figure><figcaption>v2 原型</figcaption><img loading="lazy" src="${r.key}-reference.png"></figure><figure><figcaption>实际页面</figcaption><img loading="lazy" src="${r.key}-actual.png"></figure></div></section>`).join('')}</html>`);
console.log(JSON.stringify({comparisons:checks.length,errors}));
}finally{if(browser)await browser.close();server.stdin.end();await new Promise(r=>server.exitCode!==null?r():server.once('exit',r));}
})().catch(e=>{console.error(e);process.exitCode=1;});