# 交付说明 > 目标:把 `radio.qiaomu.ai` 的完整功能与界面复刻成 DeepSeek Harness 插件。 > 交付物:`@qiaomu/dsh-radio`(宿主侧 DSH 插件 + 独立播放器页面)。 > 本文只记录**可复核**的证据:命令、原始输出、截图路径、构建指纹。 --- ## 1. 交付形态与一个必须说清的边界 插件是**宿主侧插件**:宿主半边用 `ctx.webServer` 注册自己的 HTTP 路由, 并在 `/qiaomu-radio/` 上提供**完整的独立播放器页面**。界面代码 (`src/client/**` + `styles/**`)与宿主服务(`src/host/**`)都在同一个包里,预览与 生产走同一份源码、同一个 `createRadioService`、同一个 HLS 代理。 **边界(不夸大)**:本包**没有**把六套皮肤做成 harness 页面里的 UI 插槽贡献(slot)。 界面是插件自己同源路由上的独立播放器页面;harness 页面本身不出现电台面板。原因不是省事, 而是现阶段不可行: 1. harness 的 web 服务**不发 CORS 头**,浏览器侧必须同源,而 `host.call` 的 Remote 命名空间(`ctx.remote.`)需要官方 tsdown/Typert 代码生成,第三方包没有公开契约; 2. 因此界面走插件自己的同源路由:功能完整、可独立打开,并**在真实 harness 里验证通过** (见 §2.4)。 `lib/client.js`(2,457 B)**确实被真实 harness 加载并激活**:它按官方格式注册成 `window.__ModuleLoader__.load({id, factory})`,导出 cordis 的 `apply`/`inject`。 它保持极小且不依赖 `react-dom` 是**故意的**——理由见 §2.4 的两条实测踩坑记录。 --- ## 2. 验收命令与真实输出 ### 2.1 `npm run check`(typecheck + test + build) ``` $ npm run typecheck # tsc --noEmit → 0 错误 $ npm run test # vitest run ✓ tests/core.recommendation.test.ts (16 tests) ✓ tests/host.catalog.test.ts (21 tests) ✓ tests/host.proxy.test.ts (12 tests) ✓ tests/dsh.routes.test.ts (12 tests) ✓ tests/dsh.plugin.test.ts (9 tests) ✓ tests/build.client-bundle.test.ts (4 tests) ✓ tests/ui.skins.test.ts (20 tests) ✓ tests/ui.navigation.test.ts (5 tests) Test Files 10 passed (10) Tests 106 passed (106) $ npm run build # node scripts/build.mjs built release artifacts into lib/ ``` 宿主测试**零网络访问**:全部注入假 `fetch` 与假 `now()`;`tests/dsh.plugin.test.ts` 另有一条断言,确保 `apply()` 激活期间**不发起任何 fetch**。 `tests/ui.*` 用 happy-dom + 假 host,`fetch` 被 stub 成 reject(`// @vitest-environment happy-dom` 逐文件声明,因为 `vitest.config.ts` 的 `environmentMatchGlobs` 在 vitest 4.1.11 下对这两个文件不生效)。 UI 两个测试文件覆盖:六皮肤契约 DOM(`radio-stage`/`theme-picker`/`skin-`/`skin-body`/ `skin-faceplate`/`skin-screen[data-page]`/`skin-controls`/`skin-dock`)、无障碍(交互元素必须有 可访问名、图标 `aria-hidden` + `stroke=currentColor`、无 emoji、`lang` 属六语言之一)、 窄容器走 `.skin-flat-screen`、样式契约(无 `!important`、选择器必须含 `.radio-root`、 **样式表里不许内联 WebGL 渲染器**、CSS 3D 回退必须留全、存在 `reduced-motion` 块),以及 `menu → channels → stations → now` 全链路与键盘 roving(ArrowDown/ArrowUp/Enter/Escape)。 ### 2.2 产物清单 ``` lib/index.js 40.1 KB 宿主半边(ESM, Node 20+) lib/client.js 2.4 KB harness 客户端半边:__ModuleLoader__ 注册信封 + apply/inject(无 react-dom,见 §2.5) lib/player/app.js 921.7 KB 播放器页面脚本(React + three.js 已内联) lib/player/styles.css 69.4 KB styles/*.css 顺序拼接 lib/player/hls.js 345.0 KB 懒加载的 hls.js lib/player/models/qiaomu-fantasy-radio-hyper3d-v2.glb 2,680.6 KB 魔兽世界款的 GLB (sha256 bd88187c…31da6,与参考**同一个文件**) lib/player/images/warcraft-loading-rune.png 59.8 KB 该款的加载符文 lib/types/** 宿主半边类型声明 ``` `npm pack --dry-run` → **32 个文件**,无源码、无测试、无 `preview-dist` 混入。 `lib/client.js` 里 `THREE`/`three` 出现 **0 次**(实测 `grep -c`)——three.js 只进宿主提供的播放器页, **不进 harness 客户端半边**,客户端捆绑包"只能依赖 React"的约束没有被破坏。 ### 2.3 真实安装 + 真实启动(不是"应该能跑") ```bash # 装进一个干净的临时工程 $ node /runtime/dependencies/pnpm/bin/pnpm.mjs add /绝对路径/qiaomu-radio-dsh + @qiaomu/dsh-radio 0.1.0 $ node -e "import('@qiaomu/dsh-radio').then(m => console.log(m.name, m.inject, typeof m.apply))" @qiaomu/dsh-radio [ 'webServer' ] function # 入口、注入声明、apply 都正确 ``` `tools/serve-built.mjs` 用**生产产物**(`lib/index.js`)在一个最小 web 载体上真实启动: ``` $ node tools/serve-built.mjs 4190 [qiaomu-radio] player served at http://127.0.0.1:4190/qiaomu-radio/ @qiaomu/dsh-radio serving the built plugin at http://127.0.0.1:4190/qiaomu-radio/ 302 /qiaomu-radio # 裸前缀跳转 200 /qiaomu-radio/ 1062 B # 播放器页面 200 /qiaomu-radio/app.js 226616 B 200 /qiaomu-radio/styles.css 32017 B 200 /qiaomu-radio/hls.js 353241 B 200 /qiaomu-radio/api/radio/catalog?mood=focus&source=china-curated 2473 B ``` ### 2.4 真实电台链路(Radio Browser 实时数据 + HLS 全链路) 目录:真实拉取成功(`Classic Vinyl HD`,320 kbps,30 万投票);中国精选 7 家: ``` 央广中国之声 / 央广经济之声 / 北京音乐广播 FM97.4 / 北京交通广播 FM103.9 … ``` HLS 播放链路(生产产物 + 真实 Chrome 网络面板): ``` 200 api/radio/catalog 200 api/radio/resolve-play 200 .../hls.js ← 动态注入的运行时 200 .../stream/...rthk.../master.m3u8 ← 主播放列表 200 .../stream/...rthk.../index_64_a.m3u8 ← 变体播放列表(URI 已重写回代理前缀) 200 .../stream/...rthk.../index_64_a...ts ← 媒体分片 ``` 分片代理另验:`Range: bytes=0-2047` → `206`、`content-type: video/mp2t`、2 KB, TS 同步字节 `0x47` 正确。**China 电台的 CNR HLS 也验证通过**(央广中国之声 `index.m3u8` + 分片)。 `tools/cdp-nav-probe.mjs` 的完整步进(最终构建、清空 localStorage 后从零走): ``` initial page=now after-menu page=menu 行=[乔木电台|正在播放|频道|地区电台|电台列表|搜索电台|喜欢的电台|最近收听|语言] after-channels page=channels 行=[频道|全球精选|松一口气|安静做事|爵士时刻|古典留白|需要能量|去远方|中国电台] after-china page=stations 行=[电台列表|央广中国之声 01 · 中国 · HLS|央广经济之声 02 …|香港电台第三台 07 …] 点“央广中国之声” page=now state=loading→error(自动播放被拦) hls=function hlsSrc=…/hls.js 屏显=“正在播放 中国 HLS 央广中国之声 …” network 200 hls.js / 200 stream/...zgzs/index.m3u8 / 200 stream/...zgzs/16066927.ts ``` **踩过并修掉的根因(值得记档)**:hls.js 最初用 `await import(url)` 加载,但 `hls.light.min.js` 是 UMD/IIFE,动态 `import()` 不报错也拿不到构造函数,于是**静默放弃 HLS**。 现改为注入 `