Appearance
架构
一个包可以包含多种 type,每种 type 均有独立的 protocol。widget 仅展示摘要并发出意图,不负责打开详情页或启动播放。
分工
| 侧 | 负责 |
|---|---|
| 扩展包(JavaScript) | 声明扩展、按 protocol 获取数据并映射为协议规定的 JSON |
| App | 负责安装、权限管理、模板渲染、意图解析、详情页、播放器、字幕层和弹幕层 |
JavaScript 扩展不能返回自定义 UI 树,也不能直接控制播放器。
包与扩展
Package conflux.bangumi
├── widget daily 首页小组件
├── widget ranking 另一块小组件
├── search main 搜索
├── detail main 详情数据
├── playback main 播放线路
├── subtitle main 字幕(可选)
└── danmu main 弹幕(可选)每个 ESM 模块导出一个包。包内的 extensions 数组可以包含多个扩展。每个扩展通过 type 指定类型,并实现该类型对应的 protocol(一组 JavaScript 方法)。
安装用于提供能力,添加用于启用能力。 安装脚本后,扩展不会自动显示在 Dashboard 中。用户添加 widget 时,需要根据该 widget 声明的 params 填写配置并创建实例。同一 widget type 可以创建多个实例,例如为两台 Emby 服务器分别创建一个实例。
同一 runtime 中,实例的 params 通过 input 传入。 多个实例共享该包的同一份 LoadedPackage(即同一份 JavaScript),不会按服务器重复加载。每次调用时,App 会将对应实例的 params 和 instanceId 写入 input。instanceId 位于 input 顶层,不属于 params。详情和播放扩展读取的是生成该条目时所用实例的输入,而不是包详情页中的 settings。
包级用户配置称为 settings,每个包仅有一份,通过 ctx.settings 读取。服务器和账号等实例配置应存放在 widget 实例的 params 中。请勿混用这两类配置。具体用法请参阅 开发者指南 · 设置和开发者指南 · 参数。
扩展的完全限定 ID 格式为 {packageId}/{type}/{id},例如 conflux.bangumi/widget/daily。实例 ID 由 App 生成且保持稳定,扩展可通过 input.instanceId 读取。
Type 一览
| type | 角色 | 用户能看见它吗 | Protocol |
|---|---|---|---|
widget | 首页 Dashboard 小组件能力 | 安装后由用户添加和排列实例 | load |
search | 搜索结果 | 是,搜索栏 | search |
detail | 详情数据 | 否,由详情页在后台调用 | load |
playback | 播放线路 | 否,在开始播放时调用 | sources |
subtitle | 字幕 | 安装后由用户启用实例 | match / search / file |
danmu | 弹幕 | 安装后由用户启用实例 | match / search / episodes? / comments |
widget 和 search 是展示型:产出 ListItem。detail / playback 是服务型:按 MediaRef 工作。subtitle / danmu 也是服务型,但播放器对全部已启用实例调用,ref 可选。
请勿在 widget protocol 中实现 loadDetail 或 sources。如果小组件同时承担详情和播放职责,搜索结果、其他包生成的条目及用户选择的播放源将无法使用统一规则解析。
点击解析
点击解析是 widget / search 与 detail / playback 之间的唯一连接机制。
条目仅携带身份信息和意图,不指定需要调用的包内函数:
ts
ListItem {
ref: MediaRef;
intent?: "detail" | "play"; // 默认 "detail"
episode?: { season: number; episode: number };
// 展示字段…
}App 会将点击操作转换为一次 Open:
ts
{
kind: "detail" | "play";
ref: MediaRef;
episode?: { season: number; episode: number };
origin: { packageId: string; extensionId: string; instanceId?: string };
}随后,App 按以下规则解析服务型扩展。扩展通过 accepts 声明可处理的数据范围,使 App 能在调用 JavaScript 之前确定是否显示播放按钮以及是否允许打开详情页。
ts
accepts: {
sources: Array<"tmdb" | "imdb" | "douban" | "url" | "custom">;
mediaTypes?: Array<"movie" | "tv">;
}解析 detail
按以下顺序解析,并在首次匹配后停止:
- 同源包:
origin.packageId对应的包中存在type: "detail"的扩展,且其accepts.sources包含ref.source。匹配后使用同一份 runtime,并将origin.instanceId和对应实例的params写入本次detail.load的input。 - 用户指定:用户已为该
source指定默认详情扩展 - 内置:
source为tmdb/imdb/douban时,走 App 内置详情 - 无匹配项:不打开详情页。如果默认
intent为detail,点击不会产生操作;如存在可用播放线路,则仍可播放。
source: "url" 和 "custom" 没有内置详情服务。通常只有生成条目的包能够识别对应 ID,因此此类条目应通过第一步解析,并在同一包中提供 detail 扩展。
解析 playback
- 同源包:同一包中存在
type: "playback"且accepts匹配的扩展。App 会将来源实例的instanceId和params写入sources的input。 - 用户指定:用户的默认播放扩展(可按 source 记)
- 无匹配项:隐藏播放按钮;如果用户从详情页发起播放,则提示没有可用线路。
用户何时看见播放按钮,见 用户说明 · 播放。
字幕和弹幕不使用上述解析流程。它们是播放器中的独立服务,由用户单独选择当前使用的 subtitle / danmu 扩展,与条目的来源 widget 无关。
小组件上的两种点击
| 手势 | 发出的 kind | 条件 |
|---|---|---|
| 点击卡片主体 | detail | 仅在能够解析到 detail(包括内置详情)时可用 |
| 点击模板中的播放按钮 | play | 能够解析到 playback,且已具备播放条件 |
满足以下任一条件即视为具备播放条件:
mediaType === "movie",或- 条目带了
episode,或 - 详情里用户已经选过一集(从详情页点播放)
剧集卡片未提供 episode 时,模板不显示播放按钮,避免在尚未选定剧集时调用 sources。用户需先打开详情页并选择季、集,然后再开始播放。
从小组件直接播放时,App 会跳过详情页,使用当前 ref 和可选的 episode 调用 playback。sources 默认不缓存(TTL 为 0)。