diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce5cd75..7788b8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,6 +79,8 @@ const pluginManifest = { - 发布到市场的外部依赖应固定版本,并填写 64 位 SHA-256;不要引用会变化的分支或 `latest` 文件。 - `pluginDanmakuRenderers` 必须同时申请 `danmaku.renderer` 和 `script.external`。 - 渲染器当前只支持 `apiVersion: 1` 和 `platforms: ['android', 'ios']` 的子集;请勿提前声明 Windows。 +- 声明 `supportsRealtimeAdd: true` 的渲染器必须处理 `add` 消息,并立即显示 `message.item`,不能通过清空整表来实现。 +- 读取 `settings.value.rendererSettings` 时必须容忍对象或字段缺失,并忽略未知字段;渲染器专属设置不得改变其他渲染器的默认设置行为。 - 渲染器引用的每个依赖 ID 必须存在于 `pluginManifest.requires`。 - 外部代码的来源、许可和再分发条件由投稿者负责确认,并应在插件 README 中说明。 diff --git a/js-plugin-api.md b/js-plugin-api.md index 39f95e7..83657d1 100644 --- a/js-plugin-api.md +++ b/js-plugin-api.md @@ -206,6 +206,7 @@ const pluginDanmakuRenderers = [ description: '示例 DOM 弹幕引擎', apiVersion: 1, platforms: ['android', 'ios'], + supportsRealtimeAdd: true, requires: ['engine'], bootstrap: String.raw` const root = document.getElementById('nipa-danmaku-root'); @@ -217,6 +218,9 @@ const pluginDanmakuRenderers = [ case 'load': engine.load(message.items || []); break; + case 'add': + engine.addRealtime(message.item); + break; case 'settings': engine.setOptions(message.value || {}); break; @@ -241,6 +245,7 @@ const pluginDanmakuRenderers = [ - `description`:选填,渲染器说明。 - `apiVersion`:选填,默认 `1`;当前宿主只支持 `1`。 - `platforms`:必填,当前只接受 `android`、`ios`,至少填写一个。Windows 尚无 WebView 渲染宿主,声明 `windows` 也不会生效。 +- `supportsRealtimeAdd`:选填,默认 `false`。设为 `true` 后,本地发送成功导致的单条列表更新会改发 `add`,适配层必须立即渲染该条弹幕;未声明时仍通过整表 `load` 保持兼容。 - `requires`:选填,引用 `pluginManifest.requires` 的依赖 ID。省略时加载清单中的全部依赖;填写后仍按清单顺序加载选中的依赖。 - `bootstrap`:必填,外部脚本加载完成后执行的适配代码。必须安装 `window.NipaDanmakuRenderer.handle(message)`。 @@ -250,6 +255,7 @@ const pluginDanmakuRenderers = [ |---|---|---| | `initialize` | `apiVersion`、`pluginId`、`rendererId` | 初始化协议与身份信息 | | `load` | `version`、`items` | 弹幕列表变化时推送;`version` 是宿主列表版本 | +| `add` | `item` | 仅向声明 `supportsRealtimeAdd: true` 的渲染器发送本地新弹幕;不会紧接着发送同版本整表 `load` | | `settings` | `value` | 弹幕显示设置变化时推送 | | `clock` | `positionSeconds`、`durationSeconds`、`playing`、`playbackRate`、`seekRevision` | 播放时钟,正常播放时最多每 100 ms 推送一次 | | `dispose` | 无 | 释放引擎、DOM、Observer 和监听器 | @@ -260,7 +266,9 @@ const pluginDanmakuRenderers = [ - `color`(CSS `rgb(...)`)、`isMe`; - 可选的 `senderId`、`danmakuId`、`timestamp`、`source`、`fontSize`、`pool`、`weight`,以及数据源扩展字段。 -`settings.value` 当前包含 `visible`、`opacity`、`fontSize`、`fontFamily`、`displayArea`、`scrollDurationSeconds`、`stacking`、`merge`、`blockTop`、`blockBottom`、`blockScroll`、`blockWords`、`timeOffsetSeconds`。 +`settings.value` 当前包含 `visible`、`opacity`、`fontSize`、`fontFamily`、`displayArea`、`scrollDurationSeconds`、`stacking`、`merge`、`blockTop`、`blockBottom`、`blockScroll`、`blockWords`、`timeOffsetSeconds`。可选的 `rendererSettings` 是当前渲染器的专属配置对象;目前宿主仅在选择 Titan 时发送,其中包含 `opacity`、`fontSize`、`bold`、`fontBorder`、`fontFamily`、`speedPlus`、`density`、`duration`、`limit`、`preventShade`、`offsetTop`、`offsetBottom`、`maxLength`、`isRecyclingDom`、`isRecyclingModel`、`forbidShrinkState`。适配器应忽略不认识的字段,并为缺失字段保留自身默认值。Titan 的 `speedSync` 固定为 `true`,不作为可持久化设置发送。 + +Titan 专属设置在宿主中独立持久化,仅在 Titan 被选中时显示;其他渲染器继续使用原有通用设置入口和值域。 适配层需要自行把这些通用消息映射到第三方引擎。可通过以下通道向宿主写日志或报告运行错误: diff --git a/plugins.json b/plugins.json index e2b3455..e3f2903 100644 --- a/plugins.json +++ b/plugins.json @@ -54,7 +54,7 @@ { "id": "titan_danmaku_renderer", "name": "JavaScript弹幕引擎", - "version": "1.0.2", + "version": "1.0.5", "minHostVersion": "1.11.4", "description": "注意:仅支持 iOS、Android 端;使用实验性 JavaScript 弹幕引擎渲染 NipaPlay 弹幕", "author": "Retr0", diff --git a/plugins/.DS_Store b/plugins/.DS_Store index dcc9c78..b92896f 100644 Binary files a/plugins/.DS_Store and b/plugins/.DS_Store differ diff --git a/plugins/titan_danmaku_renderer/README.md b/plugins/titan_danmaku_renderer/README.md index 7dffd47..936d95c 100644 --- a/plugins/titan_danmaku_renderer/README.md +++ b/plugins/titan_danmaku_renderer/README.md @@ -58,6 +58,19 @@ requires: ['titan-bundle'], shasum -a 256 dist/titan-bundle.js ``` +## Titan 专属设置 + +当 NipaPlay 选择 Titan 渲染器时,软件会显示独立的 Titan 设置入口,并通过 +`settings.value.rendererSettings` 传入适配层。当前支持: + +- 不透明度、字号倍率、加粗、描边类型(重墨 / 描边 / 45° 投影)和字体族; +- 滚动速度、密度、基准时长与同屏上限; +- 防挡字幕、顶部/底部偏移、最大长度; +- DOM 回收、模型回收与禁止缩小。 + +这些设置单独持久化,只在 Titan 被选中时显示和生效,不会覆盖其他弹幕引擎的字号、字体、描边与速度配置。 +播放倍速同步固定开启,不提供关闭入口。 + ## 与播放器的通信 插件适配层创建 `window.NipaDanmakuRenderer.handle(message)`,接收 NipaPlay 推送的消息: @@ -66,11 +79,14 @@ shasum -a 256 dist/titan-bundle.js | --- | --- | --- | | `initialize` | API 版本、插件 ID、渲染器 ID | 预留协议消息,当前适配层无需额外处理 | | `load` | 标准化弹幕列表和列表版本 | 转换字段后执行 `clear/reset/addList/seek` | -| `settings` | 可见性、透明度、字号、显示区域、字体、屏蔽和时间偏移 | 当前映射可见性、透明度、显示区域、字体和时间偏移;屏蔽已在 App 侧完成 | +| `add` | 用户刚发送成功的单条标准化弹幕 | 不传 `stime`,调用 `engine.add()` 立即渲染,不清空现有弹幕 | +| `settings` | 通用设置及 `rendererSettings` | 映射通用可见性、透明度、显示区域、时间偏移和全部 Titan 专属设置;屏蔽已在 App 侧完成 | | `clock` | 播放位置、总时长、播放态、倍速和 seek 版本 | 调用 `play/pause/seek/setSetting` 对齐播放器 | | `dispose` | 渲染器退出 | 断开 `ResizeObserver` 并释放 Engine | -播放时钟最多每 100 ms 推送一次。弹幕列表只在 `danmakuListVersion` 改变时重新发送,避免每帧跨 WebView 传输全部数据。 +播放时钟最多每 100 ms 推送一次。历史弹幕或轨道发生普通变化时,宿主按 `danmakuListVersion` 重新发送整表;用户发送成功时,因为本渲染器声明了 `supportsRealtimeAdd: true`,宿主改发单条 `add`,避免 `clear/reset` 清空正在显示的弹幕。 + +实时 `add` 不显式传递 `stime`,由 Titan 读取 `timelineSync()` 的当前秒数并标记为实时弹幕。历史 `load` 仍通过 `addList()` 载入,并把 NipaPlay 的秒转换成 Titan 所需的毫秒 `stime`。 ## 弹幕字段转换 diff --git a/plugins/titan_danmaku_renderer/titan_danmaku_renderer.js b/plugins/titan_danmaku_renderer/titan_danmaku_renderer.js index a3d6130..5717c06 100644 --- a/plugins/titan_danmaku_renderer/titan_danmaku_renderer.js +++ b/plugins/titan_danmaku_renderer/titan_danmaku_renderer.js @@ -1,7 +1,7 @@ const pluginManifest = { id: 'titan_danmaku_renderer', name: 'JavaScript弹幕引擎', - version: '1.0.2', + version: '1.0.5', description: '注意:仅支持 iOS、Android 端;使用实验性 JavaScript 弹幕引擎渲染 NipaPlay 弹幕', author: 'Retr0', minHostVersion: '1.11.4', @@ -22,10 +22,12 @@ const pluginDanmakuRenderers = [ description: 'Titan 弹幕引擎,提取自某知名弹幕视频网。原汁原味,一行未动', apiVersion: 1, platforms: ['android', 'ios'], + supportsRealtimeAdd: true, requires: ['titan-bundle'], bootstrap: String.raw` const root = document.getElementById('nipa-danmaku-root'); const simplifiedChineseFontFamily = "-apple-system, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Noto Sans CJK SC', 'Source Han Sans SC', sans-serif"; + const defaultTitanFontFamily = "SimHei, 'Microsoft JhengHei', Arial, Helvetica, sans-serif"; document.documentElement.lang = 'zh-CN'; document.documentElement.style.webkitLocale = "'zh-CN'"; root.lang = 'zh-CN'; @@ -64,7 +66,7 @@ const pluginDanmakuRenderers = [ setting: { visible: true, opacity: 0.85, - fontFamily: simplifiedChineseFontFamily, + fontFamily: defaultTitanFontFamily, bold: true, preventShade: false, speedPlus: 1, @@ -93,15 +95,19 @@ const pluginDanmakuRenderers = [ const match = /rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i.exec(value || ''); return match ? ((+match[1] << 16) | (+match[2] << 8) | +match[3]) : 0xffffff; } - function load(items) { - const titanItems = items.map((item, index) => ({ + function toTitanItem(item, index, realtime) { + const titanItem = { text: item.content || '', - stime: (+item.time || 0) * 1000, mode: typeToMode[item.type] || +item.originalType || 1, size: +item.fontSize || 25, color: colorToInt(item.color), - dmid: item.danmakuId || ('nipaplay-' + index), - })); + dmid: item.danmakuId || (realtime ? 'local-' + Date.now() : 'nipaplay-' + index), + }; + if (!realtime) titanItem.stime = (+item.time || 0) * 1000; + return titanItem; + } + function load(items) { + const titanItems = items.map((item, index) => toTitanItem(item, index, false)); rollLayer.textContent = ''; engine.clear(); engine.reset(); @@ -110,16 +116,30 @@ const pluginDanmakuRenderers = [ } engine.seek(clock.positionSeconds + clock.offsetSeconds); } + function addRealtime(item) { + engine.add(toTitanItem(item || {}, 0, true)); + } function applySettings(value) { + const rendererSettings = value.rendererSettings || {}; engine.setSetting('visible', value.visible !== false); - engine.setSetting('opacity', value.opacity == null ? 1 : value.opacity); + engine.setSetting('opacity', rendererSettings.opacity == null ? 0.85 : rendererSettings.opacity); engine.setSetting('area', Math.round((value.displayArea || 1) * 100)); - engine.setSetting( - 'fontFamily', - value.fontFamily - ? value.fontFamily + ', ' + simplifiedChineseFontFamily - : simplifiedChineseFontFamily, - ); + engine.setSetting('fontSize', rendererSettings.fontSize == null ? 1 : rendererSettings.fontSize); + engine.setSetting('bold', rendererSettings.bold !== false); + engine.setSetting('fontBorder', rendererSettings.fontBorder == null ? 0 : rendererSettings.fontBorder); + engine.setSetting('fontFamily', rendererSettings.fontFamily || defaultTitanFontFamily); + engine.setSetting('speedPlus', rendererSettings.speedPlus == null ? 1 : rendererSettings.speedPlus); + engine.setSetting('density', rendererSettings.density == null ? 1 : rendererSettings.density); + engine.setSetting('duration', rendererSettings.duration == null ? 4.5 : rendererSettings.duration); + engine.setSetting('limit', rendererSettings.limit == null ? 300 : rendererSettings.limit); + engine.setSetting('speedSync', true); + engine.setSetting('preventShade', rendererSettings.preventShade === true); + engine.setSetting('offsetTop', rendererSettings.offsetTop || 0); + engine.setSetting('offsetBottom', rendererSettings.offsetBottom || 0); + engine.setSetting('maxLength', rendererSettings.maxLength == null ? 50 : rendererSettings.maxLength); + engine.setSetting('isRecyclingDom', rendererSettings.isRecyclingDom !== false); + engine.setSetting('isRecyclingModel', rendererSettings.isRecyclingModel === true); + engine.setSetting('forbidShrinkState', rendererSettings.forbidShrinkState !== false); const nextOffset = +value.timeOffsetSeconds || 0; if (clock.offsetSeconds !== nextOffset) { clock.offsetSeconds = nextOffset; @@ -146,6 +166,7 @@ const pluginDanmakuRenderers = [ handle(message) { switch (message.type) { case 'load': load(message.items || []); break; + case 'add': addRealtime(message.item); break; case 'settings': applySettings(message.value || {}); break; case 'clock': syncClock(message); break; case 'dispose': diff --git a/scripts/sync_plugins_index.py b/scripts/sync_plugins_index.py index b6f4d45..6f3dfce 100644 --- a/scripts/sync_plugins_index.py +++ b/scripts/sync_plugins_index.py @@ -273,6 +273,16 @@ def _read_int_property(object_source, name, default=None): return int(match.group(0)) +def _read_bool_property(object_source, name, default=None): + start = _find_property(object_source, name) + if start is None: + return default + match = re.match(r"(?:true|false)\b", object_source[start:]) + if not match: + raise ValueError(f"'{name}' must be a boolean") + return match.group(0) == "true" + + def _read_string_array_property(object_source, name, default=None): start = _find_property(object_source, name) if start is None: @@ -332,6 +342,9 @@ def parse_danmaku_renderers(filepath): "platforms": _read_string_array_property( object_source, "platforms", [] ), + "supportsRealtimeAdd": _read_bool_property( + object_source, "supportsRealtimeAdd", False + ), "requires": _read_string_array_property( object_source, "requires", None ),