# 接口与 DOM 契约(冻结版 · v1) 本文件是队友之间唯一的对接依据。**任何一方要改这里,先改本文件再改代码。** ## 1. 目录与写入范围 ``` src/core/ 纯逻辑,无框架、无 DOM、无 Node API ← Lead(已冻结,勿改) src/host/ 宿主服务:目录、播放地址、now playing、代理、口味存储 src/client/ 浏览器端:状态、播放引擎、六种皮肤、页面 src/dsh/ DSH 适配:host 入口、client 入口、桥接 preview/ 独立预览服务器与入口页 scripts/ 构建脚本 styles/ CSS tests/ vitest ``` | 归属 | 可写文件 | | --- | --- | | radio-host (T1) | `src/host/**`(`taste.ts` 除外,已由 Lead 写好)、`tests/host.*.test.ts`、`tests/core.*.test.ts` | | radio-ui (T2) | `src/client/**`(`api.ts`、`transport.ts` 除外)、`styles/**`、`tests/ui.*.test.ts` | | Lead | `src/core/**`、`src/client/api.ts`、`src/client/transport.ts`、`src/host/taste.ts`、`src/host/contract.ts`、`src/dsh/**`、`preview/**`、`scripts/**`、`package.json`、`docs/**` | **禁止**跨范围写入。T1 需要 UI 契约变化 → 发消息给 Lead。 ## 2. 唯一 JSX 出口 整个仓库只有 `src/client/ui.ts` 使用 JSX: ```ts // src/client/ui.ts import { createElement } from "react"; import type { ReactNode } from "react"; /** 唯一 JSX 出口:JSX 在这里编译成 createElement,其余全部 .ts 文件直接调 h()。 */ export function h(type: unknown, props?: Record | null, ...children: ReactNode[]): ReactNode; ``` 所有 `.ts` 文件里写 DOM 一律 `h("section", { className: "radio-root" }, ...)`。 `h` 必须:过滤 `null`/`undefined`/`false` 子节点、把 `key` 原样透传、支持 `children` prop。 ## 3. 客户端 API(`src/client/api.ts`,已冻结) UI 只依赖 `RadioHost`,绝不 import DSH 或 Node: ```ts type RadioHost = { api: RadioApi; // 唯一数据/播放入口 storage: RadioStorage; // 口味与皮肤的本机持久化 baseUrl: string; // 本插件宿主路由的前缀 assetUrl?(relativePath: string): string | null; // 同源资源(hls.js 就靠它) now?: () => number; // 注入时钟,测试用 }; ``` - 关卡页(插件自己路由提供的独立播放器):`createRadioApi({ kind: "http", baseUrl })`, 对应 `GET/POST {baseUrl}/api/radio/catalog|resolve-play|now-playing`。 - DSH 动态浏览器半边(`fetch` 被沙箱隐藏):`createRadioApi({ kind: "rpc", call })`, 其中 `call` 就是注入的 `host.call(method, args)`,方法名 `radio/catalog`、`radio/resolvePlay`、`radio/nowPlaying`。 - 预览环境:同一个 http 适配器,`baseUrl` 为 `""`,见 `preview/server.mjs`。 - **UI 不要自己 `fetch`**,也不要依赖 `window.fetch`、`setTimeout` 之外的浏览器全局; HLS 运行时用 `host.assetUrl?.("hls.js")` 取地址后动态 `import()`。 ### 状态容器(T2 实现,Lead 依赖以下签名) ```ts // src/client/useRadio.ts export type RadioController = { // 状态 themeId: ThemeId; theme: RadioTheme; mood: MoodId; source: StationSource; stations: Station[]; queue: Station[]; current: Station | null; isPlaying: boolean; isLoading: boolean; error: string; notice: string; volume: number; liked: boolean; profile: TasteProfile; locale: Locale; // 动作(全部同步签名,返回 void;内部自行处理异步) toggle(): void; next(): void; previous(): void; play(station: Station): void; like(): void; dislike(): void; chooseMood(mood: MoodId): void; chooseTheme(themeId: ThemeId): void; chooseSource(source: StationSource): void; chooseRegion(code: string | null): void; search(query: string): void; setVolume(value: number): void; retry(): void; close(): void; }; export function useRadio(options: RadioMountOptions): RadioController; ``` `useRadio` 负责:目录请求与竞态取消、播放/失败切换(最多 5 次)、失败后记录 reliability、 15s 超时恢复、媒体键、键盘(Space 播放/暂停、N 下一台)、音量落到 `