@vibedev-si/dsh-media-viewer 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +29 -17
  2. package/client.js +360 -364
  3. package/index.js +39 -7
  4. package/package.json +14 -7
package/README.md CHANGED
@@ -13,16 +13,34 @@
13
13
  | 音频 | mp3 / wav / m4a / flac 等;实时频谱;倍速、循环 |
14
14
  | HTML | 默认**允许脚本**;相对路径的 CSS / JS / 图片 / 视频 / `fetch` 都能加载;预览 / 源码切换;手机、平板、桌面宽度;脚本开关 |
15
15
  | 媒体画廊 | 缩略图网格;按视频 / 音频 / 图片 / 网页筛选、搜索、排序;可包含子文件夹;点开是带上一个 / 下一个(← →)的灯箱 |
16
+ | 加入画布 | 画廊里点「多选」,勾选图片 / 视频 / 音频(Shift 连选、全选),点「加入画布」放进**影视工作台**当前打开的画布;没打开时先在侧栏打开它 |
16
17
 
17
- 画廊在右侧栏的 **+ 菜单**里叫「媒体画廊」;每个播放器工具栏里的「画廊」按钮会直接打开它所在的文件夹。
18
+ **在哪里打开**
18
19
 
19
- ## 本版新增(0.1.2)
20
+ - 在文件树里点开视频、音频或网页文件,就用本插件的播放器打开(文件预览顶部的切换菜单里可以换回内置预览)。
21
+ - 媒体画廊:右侧栏「开始」页的「媒体画廊」、文件树顶部的「媒体画廊」按钮(打开当前文件夹),或文件预览顶部的「画廊」按钮(打开文件所在的文件夹)。
20
22
 
21
- 新增「组件」入口:在预览工具栏里可直接打开 **VibeDev 插件中心**,了解或补齐影视工作台与 VibeDev 账号插件(打不开插件中心时会给出可复制的包名);已安装或应用内置的组件不会重复推荐。
23
+ 「加入画布」需要同时启用影视工作台插件(`dsh-film-studio`):文件由工作台复制进项目的 `assets/`,在画布中央排成节点,整批一次撤销。两个插件只通过一个页面事件 `vibedev-film:add-media` 联系,没装工作台时画廊会直接提示。
24
+
25
+ ## 本版变更(0.2.0)
26
+
27
+ - **不再依赖 `dsh-better-sidebar`**:预览器、画廊和入口都直接用 DSH 自带的右侧栏(DSH 0.2.1-alpha.2 及以后),应用更新不会再因为第三方插件没跟上而失效。
28
+ - 视频、音频、HTML 注册为 DSH 的文件预览;画廊是右侧栏的一个页面。
29
+ - 装了 `dsh-better-sidebar` 时,播放器也会注册进它的编辑器(它会先接管所有文件),画廊仍是同一个。
30
+ - 宿主侧只需要 Web 服务和会话,受信主机从 DSH 新旧两种启动服务中按实际提供的读取(DSH 0.2.1-alpha.2 把 `webRuntime` 改名为 `webStartup` 后,0.1.x 无法加载)。
31
+ - **画廊多选与加入画布**(原 0.1.4 未单独发布,并入本版)。
32
+ - **更稳的播放**:
33
+ - 视频缩略图截取一帧后就释放播放器;
34
+ - 播放请求分段返回、不进浏览器缓存。
35
+ - 页面上视频多时,浏览器每个主机只有 6 个连接,不再出现后面的视频一直转圈、不发请求的情况。
36
+
37
+ ## 0.1.3
38
+
39
+ 撤回了 0.1.2 加进预览工具栏和画廊底部的「组件 / 插件中心」入口,它影响了侧栏观感,等重新设计后再考虑;预览、画廊与安全说明不受影响。
22
40
 
23
41
  ## 安装
24
42
 
25
- **前置依赖:[`dsh-better-sidebar`](https://www.npmjs.com/package/dsh-better-sidebar)**(≥ 0.24.0)。它接管了右侧栏的文件打开,本插件通过它的 `registerFileViewer` / `registerTab` 接入。没装它,本插件不会生效。
43
+ 需要 DSH 0.2.1-alpha.2 或更新版本(VibeDev Next 已满足)。不需要其他插件;`dsh-better-sidebar` 可装可不装。
26
44
 
27
45
  在 VibeDev / DSH 的插件管理里按包名 `@vibedev-si/dsh-media-viewer` 安装即可,或用命令行:
28
46
 
@@ -32,16 +50,11 @@ dsh plugin --profile <你的 profile 名> add @vibedev-si/dsh-media-viewer
32
50
 
33
51
  安装后**刷新页面**。
34
52
 
35
- > **从旧包名迁移**:本插件原先以无作用域的 `dsh-media-viewer` 发布(0.1.0),现已迁到官方作用域 `@vibedev-si/dsh-media-viewer`,旧包名已标记弃用。已装旧包的用户请卸载后改装新包名;功能与代码完全相同。
36
-
37
- ## 为什么需要它
38
-
39
- `dsh-better-sidebar` 内置的 `/sidebar/file`、`/sidebar/html` 路由:
53
+ > **从旧包名迁移**:本插件原先以无作用域的 `dsh-media-viewer` 发布(0.1.0),现已迁到官方作用域 `@vibedev-si/dsh-media-viewer`,旧包名已标记弃用。已装旧包的用户请卸载后改装新包名。
40
54
 
41
- - 有 **20MB 上限**,整文件读入内存,不支持 HTTP Range,所以大视频打不开、也不能拖进度条;
42
- - 安全围栏会拒绝沙箱 iframe 里的子资源请求(它们带 `Sec-Fetch-Site: cross-site`),所以带样式、脚本、视频的 HTML 只能加载一半。
55
+ ## 为什么自带 `/mp` 路由
43
56
 
44
- 本插件自带 `/mp/*` 路由解决这两点,并在沙箱里保持对 GUI 的隔离。
57
+ DSH 自带预览读取整个文件;`dsh-better-sidebar` 的 `/sidebar/file`、`/sidebar/html` 有 20MB 上限、不支持 HTTP Range,安全围栏还会拒绝沙箱 iframe 里的子资源请求。本插件自带 `/mp/*` 路由:大视频可以流式播放、拖进度条,带样式、脚本、视频的 HTML 能完整加载,并在沙箱里保持对 GUI 的隔离。
45
58
 
46
59
  ## 安全
47
60
 
@@ -63,22 +76,21 @@ dsh plugin --profile <你的 profile 名> add @vibedev-si/dsh-media-viewer
63
76
  ## 开发
64
77
 
65
78
  ```sh
66
- npm test # host 路由:Range、越界、符号链接、Host 伪造、沙箱子资源
79
+ npm test # host 路由(Range、分段、越界、符号链接、Host 伪造、沙箱子资源)与宿主接口登记
67
80
  ```
68
81
 
69
82
  client 是无需构建的纯 JS(`client.js`),host 是 `index.js`,没有构建步骤。
70
83
 
71
84
  ## English
72
85
 
73
- **@vibedev-si/dsh-media-viewer** adds inline previews to the [`dsh-better-sidebar`](https://www.npmjs.com/package/dsh-better-sidebar) right column, which requires it (≥ 0.24.0):
86
+ **@vibedev-si/dsh-media-viewer** adds inline previews to the DSH right sidebar (DSH 0.2.1-alpha.2 or later) and needs no other plugin:
74
87
 
75
88
  - **Video** — mp4 / webm / mov / mkv…, seekable, playback speed, loop, no file-size cap (HTTP Range streaming).
76
89
  - **Audio** — mp3 / wav / m4a / flac…, live spectrum, playback speed, loop.
77
90
  - **HTML** — scripts allowed by default; relative CSS / JS / images / video / `fetch()` all load; preview ⇄ source, phone / tablet / desktop widths, script toggle. Runs in an opaque-origin sandboxed iframe, so a previewed page cannot reach the GUI.
78
- - **Media gallery** — thumbnail grid for a folder with type filters, search, sort, optional recursion, and a lightbox with ← / → navigation.
79
- - **Related components (0.1.2)** — a 「组件」 entry in the preview toolbar opens the **VibeDev plugin centre** from the sidebar to learn about or add the film workbench and the VibeDev account plugin (and falls back to a copyable package name); components already installed or shipped by the app are never recommended again.
91
+ - **Media gallery** — a right-sidebar page: thumbnail grid for a folder with type filters, search, sort, optional recursion, a lightbox with ← / → navigation, and multi-select to add media to the Film Studio canvas.
80
92
 
81
- Why it exists: the built-in `/sidebar/file` and `/sidebar/html` routes cap files at 20 MB, read them fully into memory, have no Range support, and their fence rejects the `cross-site` sub-resource requests a sandboxed iframe makes. This plugin ships its own `/mp/*` routes instead. Install it by package name from your DSH plugin manager, or `dsh plugin --profile <name> add @vibedev-si/dsh-media-viewer`, then reload the page.
93
+ 0.2.0 stands on the host's own surfaces (document previews, the right sidebar's page registry, the file tree and document toolbars) instead of `dsh-better-sidebar`; when that plugin is installed the players also register inside its editor, which claims files first. Install it by package name from your DSH plugin manager, or `dsh plugin --profile <name> add @vibedev-si/dsh-media-viewer`, then reload the page.
82
94
 
83
95
  ## License
84
96
 
package/client.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /*!
2
2
  * dsh-media-viewer — client half (browser bundle, no build step).
3
3
  *
4
- * Registers with dsh-better-sidebar through `ctx.betterSidebar`:
5
- * - file viewers: video (mp4/webm/mov/...), audio (mp3/wav/...), html
6
- * - a tab: "媒体画廊", a thumbnail grid of the media in a folder
4
+ * Registers on the host's own right sidebar (DSH 0.2.1-alpha.2 and later):
5
+ * - document previews: video (mp4/webm/mov/...), audio (mp3/wav/...), html
6
+ * - a page: "媒体画廊", a thumbnail grid of the media in a folder
7
+ * and, when dsh-better-sidebar is installed, the same viewers and tab inside it.
7
8
  *
8
9
  * Bundle format: the official DSH client-bundle shape, a CommonJS-style
9
10
  * factory registered through window.__ModuleLoader__.load. `react` is resolved
@@ -59,6 +60,15 @@ window.__ModuleLoader__.load({
59
60
  segs.map(encodeURIComponent).join("/") + (query ? "?" + query : "");
60
61
  }
61
62
 
63
+ /**
64
+ * The URL a player streams. A document preview passes the file's version as `nonce`: the browser shares one media
65
+ * resource per URL within a page, and one held by a gallery thumbnail or the lightbox can leave a second player of
66
+ * the same file waiting with no request at all.
67
+ */
68
+ function playUrl(props, abs) {
69
+ return fileUrl(props.scope, abs, props.nonce ? "v=" + encodeURIComponent(props.nonce) : "");
70
+ }
71
+
62
72
  function fmtSize(n) {
63
73
  if (n == null) return "";
64
74
  if (n < 1024) return n + " B";
@@ -158,11 +168,8 @@ window.__ModuleLoader__.load({
158
168
  h("rect", { key: "d", x: 9, y: 9, width: 5, height: 5, rx: 1, stroke: "currentColor", strokeWidth: 1.5 }),
159
169
  ]);
160
170
  }
161
- function IconTools(size) {
162
- return svg(size, [
163
- h("rect", { key: "a", x: 2, y: 2, width: 12, height: 12, rx: 3, stroke: "currentColor", strokeWidth: 1.5 }),
164
- pathEl("M8 5.4v5.2M5.4 8h5.2", { key: "b" }),
165
- ]);
171
+ function IconCheck(size) {
172
+ return svg(size, [pathEl("m3.5 8.3 2.9 2.9 6.1-6.4", { key: "a" })]);
166
173
  }
167
174
  function kindIcon(kind, size) {
168
175
  return kind === "video" ? IconVideo(size) : kind === "audio" ? IconAudio(size) : kind === "html" ? IconHtml(size) : IconImage(size);
@@ -180,13 +187,18 @@ window.__ModuleLoader__.load({
180
187
  ]);
181
188
  }
182
189
 
190
+ /**
191
+ * Open the gallery on a file's folder. The host's own right sidebar does it (a native page tab); a view hosted by
192
+ * dsh-better-sidebar keeps opening its tab there, as before 0.2.0.
193
+ */
183
194
  function openGallery(props, abs) {
184
195
  try {
185
- props.ctx.betterSidebar.openTab({ type: "mp:gallery", path: dirName(abs), title: baseName(dirName(abs)) || "媒体画廊" }, props.scope);
196
+ if (typeof props.openGallery === "function") props.openGallery(dirName(abs));
197
+ else props.ctx.betterSidebar.openTab({ type: "mp:gallery", path: dirName(abs), title: baseName(dirName(abs)) || "媒体画廊" }, props.scope);
186
198
  } catch (e) { console.error("[dsh-media-viewer] open gallery failed", e); }
187
199
  }
188
200
 
189
- function CommonActions(props, abs, url, toolsOpen, onTools) {
201
+ function CommonActions(props, abs, url) {
190
202
  var out = [
191
203
  h("a", { key: "new", href: url, target: "_blank", rel: "noopener", style: btn(), title: "在新窗口打开" }, "新窗口"),
192
204
  h("a", { key: "dl", href: fileUrl(props.scope, abs, "download=1"), download: baseName(abs), style: btn(), title: "下载原文件" }, "下载"),
@@ -194,9 +206,6 @@ window.__ModuleLoader__.load({
194
206
  if (!props.hideGallery) {
195
207
  out.push(h(Btn, { key: "gal", title: "浏览所在文件夹的全部媒体", onClick: function () { openGallery(props, abs); } }, [IconGrid(12), "画廊"]));
196
208
  }
197
- if (typeof onTools === "function") {
198
- out.push(h(Btn, { key: "tools", active: toolsOpen === true, title: "相关组件与插件中心", onClick: onTools }, [IconTools(12), "组件"]));
199
- }
200
209
  return out;
201
210
  }
202
211
 
@@ -209,326 +218,30 @@ window.__ModuleLoader__.load({
209
218
  }
210
219
 
211
220
  function Shell(p) {
212
- var kids = p.children ? (Array.isArray(p.children) ? p.children.slice() : [p.children]) : [];
213
- if (p.tools) kids.push(p.tools);
214
221
  return h("div", {
215
- style: { display: "flex", flexDirection: "column", gap: "8px", padding: "10px", minWidth: 0, minHeight: 0, height: "100%", boxSizing: "border-box" },
216
- }, kids);
217
- }
218
-
219
- // ── companion components (the plugin centre is the way to the rest) ─────
220
- //
221
- // What the Host actually has comes from its plugin inventory — a bundle the profile
222
- // installed, or one the application ships itself. Every service below is read
223
- // optionally and strictly, so a Host without a plugin manager, a panel layout, or the
224
- // plugin navigation contract keeps this viewer working and simply shows the manual
225
- // spec. Nothing here installs anything: the centre and the Plugins page own every
226
- // install, approval and progress step, and nothing is read before a surface is opened.
227
-
228
- /** The VibeDev components by package name; the centre is the way to all of them. */
229
- var SUITE = { account: "@vibedev-si/dsh-vibedev", center: "@vibedev-si/dsh-ecosystem", film: "dsh-film" };
230
- /** The main panel the VibeDev plugin centre registers its page under. */
231
- var CENTER_PANEL = "vibedev-center";
232
- /** The package a person can add on the Plugins page when nothing can open the centre. */
233
- var CENTER_SPEC = SUITE.center;
234
- /**
235
- * The components this viewer's own work leans on, in the order the card lists them.
236
- * A bundle already installed or shipped by the app is never named (see `bundlePresent`),
237
- * and the viewer never recommends itself.
238
- */
239
- var COMPANIONS = [
240
- { package: SUITE.account, line: "VibeDev 账号:用余额生成图片、视频和音频,模型请求走 VibeDev 网关。" },
241
- { package: SUITE.film, line: "影视工作台:剧本、分镜画布和导演台,把这个文件夹里的媒体排成片子。" },
242
- ];
243
- /** The card's own copy, in the one language this bundle's UI uses. */
244
- var SUITE_COPY = {
245
- missingTitle: "相关组件",
246
- missingIntro: "这些组件和右侧栏预览配合使用,装好后出现在它们各自的页面里。",
247
- toolsTitle: "插件中心",
248
- toolsIntro: "插件中心里可以浏览其余组件。",
249
- keep: "这里只做提示:不会自动安装,也不会改动已装的东西;安装与授权都在插件中心里完成。",
250
- open: "打开插件中心",
251
- manual: "打不开插件中心时,可在「插件」页按包名安装:",
252
- copy: "复制包名",
253
- copied: "已复制",
254
- tools: "组件",
255
- };
256
-
257
- /** A value read as a record, without trusting its declared type. */
258
- function asRecord(v) { return typeof v === "object" && v !== null ? v : undefined; }
259
-
260
- /** One property of a service, read without letting a hostile getter throw into the viewer. */
261
- function safeGet(target, name) {
262
- var record = asRecord(target);
263
- if (record === undefined) return undefined;
264
- try { return record[name]; } catch (e) { return undefined; }
265
- }
266
-
267
- /** One optional service, read strictly: absent (never throwing) on a Host that does not have it. */
268
- function serviceOf(ctx, name) {
269
- if (!ctx || typeof ctx.get !== "function") return undefined;
270
- try { return ctx.get(name); } catch (e) { return undefined; }
271
- }
272
-
273
- /** The optional services the card uses, from the strict store first and the property proxy second. */
274
- function suiteServices(ctx) {
275
- var remote = asRecord(serviceOf(ctx, "remote") || safeGet(ctx, "remote"));
276
- return {
277
- manager: asRecord(serviceOf(ctx, "remote.pluginManager") || safeGet(remote, "pluginManager")),
278
- slots: asRecord(serviceOf(ctx, "slots") || safeGet(ctx, "slots")),
279
- layout: asRecord(serviceOf(ctx, "layout") || safeGet(ctx, "layout")),
280
- navigation: asRecord(serviceOf(ctx, "pluginNavigation") || safeGet(ctx, "pluginNavigation")),
281
- remote: remote,
282
- };
283
- }
284
-
285
- /** Whether the Host already has a bundle: the profile installed it, or the application provides it itself. */
286
- function bundlePresent(bundle) {
287
- return bundle.installed === true || (bundle.removable === false && !bundle.source);
288
- }
289
-
290
- /**
291
- * The present package names of one inventory answer, or undefined when the answer is not a
292
- * readable list. An answer this cannot read is never read as "nothing is present": a refusal
293
- * envelope, another shape, or a missing value means the caller knows nothing, not that every
294
- * component is missing.
295
- */
296
- function presentFrom(answer) {
297
- var record = asRecord(answer);
298
- if (record === undefined || record.ok === false) return undefined;
299
- if (!Array.isArray(record.value)) return undefined;
300
- var names = [];
301
- for (var i = 0; i < record.value.length; i++) {
302
- var bundle = asRecord(record.value[i]);
303
- if (!bundle || !bundlePresent(bundle)) continue;
304
- if (typeof bundle.name === "string" && bundle.name !== "" && names.indexOf(bundle.name) < 0) names.push(bundle.name);
305
- }
306
- return names;
307
- }
308
-
309
- /**
310
- * Read the Host's plugin inventory once. A manager that cannot answer — absent, throwing,
311
- * rejecting, or answering an envelope that is not a readable list — is not an inventory of
312
- * nothing: `ok: false` makes the card offer the centre and claim no component is missing.
313
- */
314
- function readPresence(ctx) {
315
- var manager = suiteServices(ctx).manager;
316
- var list = safeGet(manager, "listBundles");
317
- if (typeof list !== "function") return Promise.resolve({ ok: false, present: [] });
318
- try {
319
- return Promise.resolve(list.call(manager)).then(
320
- function (answer) {
321
- var present = presentFrom(answer);
322
- return present === undefined ? { ok: false, present: [] } : { ok: true, present: present };
323
- },
324
- function () { return { ok: false, present: [] }; });
325
- } catch (e) {
326
- return Promise.resolve({ ok: false, present: [] });
327
- }
328
- }
329
-
330
- /** Hear about installs, removals and activations; a Host without the event bus returns a no-op. */
331
- function observePresence(ctx, listener) {
332
- var remote = suiteServices(ctx).remote;
333
- var on = safeGet(remote, "$on");
334
- if (typeof on !== "function") return function () {};
335
- try {
336
- var off = on.call(remote, "plugin-manager/changed", listener);
337
- return typeof off === "function" ? function () { off(); } : function () {};
338
- } catch (e) {
339
- return function () {};
340
- }
341
- }
342
-
343
- /**
344
- * The centre's main panel, where this Host registered one. A slots entry keeps its
345
- * identity in `options` (`options.key` is the panel id the layout selects); older
346
- * shapes kept `key`/`id` at the top level, which is still read as a fallback.
347
- */
348
- function centerPanel(ctx) {
349
- var slots = suiteServices(ctx).slots;
350
- var entries = safeGet(slots, "entries");
351
- if (typeof entries !== "function") return undefined;
352
- var listed;
353
- try { listed = entries.call(slots, "main"); } catch (e) { return undefined; }
354
- if (!Array.isArray(listed)) return undefined;
355
- for (var i = 0; i < listed.length; i++) {
356
- var record = asRecord(listed[i]);
357
- var options = asRecord(safeGet(record, "options"));
358
- var key = options ? (safeGet(options, "key") !== undefined ? safeGet(options, "key") : safeGet(options, "id"))
359
- : (record ? (safeGet(record, "key") !== undefined ? safeGet(record, "key") : safeGet(record, "id")) : undefined);
360
- if (key === CENTER_PANEL) return CENTER_PANEL;
361
- }
362
- return undefined;
363
- }
364
-
365
- /**
366
- * Send the person to the centre, or to the Host's own way of installing it. A panel that
367
- * is gone, a layout that refuses, and a missing navigation contract each fall through to
368
- * the next, and the last answer is the spec a person can paste into the Plugins page.
369
- */
370
- function openCenter(ctx) {
371
- var services = suiteServices(ctx);
372
- var panel = centerPanel(ctx);
373
- var selectPanel = safeGet(services.layout, "selectPanel");
374
- if (panel !== undefined && typeof selectPanel === "function") {
375
- try { selectPanel.call(services.layout, panel); return { kind: "center" }; } catch (e) { /* the panel is gone */ }
376
- }
377
- var navigation = services.navigation;
378
- var openBundle = safeGet(navigation, "openBundle");
379
- if (typeof openBundle === "function") {
380
- try { openBundle.call(navigation, SUITE.center); return { kind: "plugins" }; } catch (e) { /* no Plugins panel here */ }
381
- }
382
- var openInstall = safeGet(navigation, "openInstall");
383
- if (typeof openInstall === "function") {
384
- try { openInstall.call(navigation, { spec: CENTER_SPEC }); return { kind: "plugins" }; } catch (e) { /* nothing could open */ }
385
- }
386
- return { kind: "manual", spec: CENTER_SPEC };
387
- }
388
-
389
- /** Which of `wanted` this Host does not have yet, in the order the surface lists them. */
390
- function missingFrom(present, wanted) {
391
- return wanted.filter(function (want) { return present.indexOf(want.package) < 0; });
392
- }
393
-
394
- /** What the card says: the absent components, or the one way to the rest. An unreadable inventory claims nothing. */
395
- function suiteHint(read, wanted) {
396
- if (!read.ok) return { kind: "tools", missing: [] };
397
- var missing = missingFrom(read.present, wanted);
398
- if (missing.length === 0) return { kind: "tools", missing: [] };
399
- return {
400
- kind: "missing",
401
- missing: missing.map(function (want) { return { package: want.package, line: want.line }; }),
402
- };
403
- }
404
-
405
- /** Write text through the browser's clipboard, where the page has one. */
406
- function writeClipboard(text) {
407
- var clipboard = typeof navigator !== "undefined" && navigator ? safeGet(navigator, "clipboard") : undefined;
408
- var write = safeGet(clipboard, "writeText");
409
- if (typeof write !== "function") return Promise.resolve(false);
410
- try {
411
- return Promise.resolve(write.call(clipboard, text)).then(function () { return true; }, function () { return false; });
412
- } catch (e) {
413
- return Promise.resolve(false);
414
- }
415
- }
416
-
417
- /** One read of what this Host has, shared by every card this bundle renders. */
418
- function createSuiteStore(ctx, wanted) {
419
- var snapshot = { read: false, found: { ok: false, present: [] }, hint: { kind: "tools", missing: [] } };
420
- var listeners = [];
421
- function publish(next) {
422
- snapshot = next;
423
- var copy = listeners.slice();
424
- for (var i = 0; i < copy.length; i++) copy[i]();
425
- }
426
- function read() {
427
- return readPresence(ctx).then(function (found) {
428
- publish({ read: true, found: found, hint: suiteHint(found, wanted) });
429
- });
430
- }
431
- return {
432
- subscribe: function (listener) {
433
- listeners.push(listener);
434
- return function () {
435
- var at = listeners.indexOf(listener);
436
- if (at >= 0) listeners.splice(at, 1);
437
- };
438
- },
439
- getSnapshot: function () { return snapshot; },
440
- open: function () { return openCenter(ctx); },
441
- copy: writeClipboard,
442
- /** Start the first read and every later refresh; the returned disposer releases both. */
443
- start: function () {
444
- void read();
445
- return observePresence(ctx, function () { void read(); });
446
- },
447
- };
448
- }
449
-
450
- /**
451
- * The companion components this Host lacks, and the one quiet way into the plugin centre.
452
- * It renders nothing until its own read settled, installs nothing, and opens nothing by
453
- * itself: every line here is a statement about the Host, and the button is the only action.
454
- */
455
- function SuiteCard(props) {
456
- var store = useMemo(function () {
457
- return props.store || createSuiteStore(props.ctx, COMPANIONS);
458
- }, [props.ctx, props.store]);
459
- var st = useState(store.getSnapshot()), snap = st[0], setSnap = st[1];
460
- var mn = useState(null), manual = mn[0], setManual = mn[1];
461
- var cp = useState(false), copied = cp[0], setCopied = cp[1];
462
-
463
- useEffect(function () {
464
- // Subscribe before the first read: a change published during it must still reach this card.
465
- var off = store.subscribe(function () { setSnap(store.getSnapshot()); });
466
- var stop = store.start();
467
- return function () { off(); stop(); };
468
- }, [store]);
469
-
470
- if (!snap.read) return null;
471
- var missing = snap.hint.kind === "missing";
472
- var kids = [
473
- h("div", { key: "t", style: { fontSize: "12px", fontWeight: 600 } }, missing ? SUITE_COPY.missingTitle : SUITE_COPY.toolsTitle),
474
- h("div", { key: "i", style: { fontSize: "11px", color: C.muted, lineHeight: 1.6 } }, missing ? SUITE_COPY.missingIntro : SUITE_COPY.toolsIntro),
475
- ];
476
- for (var i = 0; i < snap.hint.missing.length; i++) {
477
- var line = snap.hint.missing[i];
478
- kids.push(h("div", { key: "m:" + line.package, style: { fontSize: "12px", lineHeight: 1.6 } }, line.line));
479
- }
480
- kids.push(h("div", { key: "k", style: { fontSize: "11px", color: C.muted, lineHeight: 1.6 } }, SUITE_COPY.keep));
481
- kids.push(h("div", { key: "a", style: { display: "flex", flexWrap: "wrap", gap: "6px", paddingTop: "2px" } }, [
482
- h(Btn, {
483
- key: "open", active: missing, title: "打开插件中心",
484
- onClick: function () {
485
- var target = store.open();
486
- setCopied(false);
487
- setManual(target.kind === "manual" ? { spec: target.spec } : null);
488
- },
489
- }, SUITE_COPY.open),
490
- ]));
491
- if (manual) {
492
- kids.push(h("div", { key: "mn", style: { fontSize: "11px", color: C.muted, lineHeight: 1.6 } }, SUITE_COPY.manual));
493
- kids.push(h("code", {
494
- key: "spec",
495
- style: { fontSize: "12px", padding: "2px 6px", borderRadius: "6px", background: C.softer, border: "1px solid " + C.line, userSelect: "text", alignSelf: "flex-start" },
496
- }, manual.spec));
497
- kids.push(h("div", { key: "cp", style: { display: "flex", flexWrap: "wrap", gap: "6px" } }, [
498
- h(Btn, {
499
- key: "copy", title: "复制包名",
500
- onClick: function () { void store.copy(manual.spec).then(function (written) { setCopied(written); }); },
501
- }, copied ? SUITE_COPY.copied : SUITE_COPY.copy),
502
- ]));
503
- }
504
- return h("div", {
505
- "data-mp-companions": "1",
506
- style: {
507
- display: "flex", flexDirection: "column", gap: "5px", flex: "none", boxSizing: "border-box",
508
- padding: "9px 10px", borderRadius: "8px", border: "1px dashed " + C.line, background: C.softer, color: C.fg,
509
- },
510
- }, kids);
222
+ // The host's document pane sets a monospace face for code; the player's labels are ordinary UI text.
223
+ style: { display: "flex", flexDirection: "column", gap: "8px", padding: "10px", minWidth: 0, minHeight: 0, height: "100%", boxSizing: "border-box", fontFamily: "system-ui, -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif" },
224
+ }, p.children);
511
225
  }
512
226
 
513
227
  // ── video ───────────────────────────────────────────────────────────────
514
228
 
515
229
  function VideoView(props) {
516
230
  var abs = absOf(props.scope, props.path);
517
- var url = fileUrl(props.scope, abs);
231
+ var url = playUrl(props, abs);
518
232
  var vref = useRef(null);
519
233
  var st = useState(1), rate = st[0], setRate = st[1];
520
234
  var lp = useState(false), loop = lp[0], setLoop = lp[1];
521
235
  var er = useState(null), err = er[0], setErr = er[1];
522
236
  var du = useState(null), dur = du[0], setDur = du[1];
523
237
  var dims = useState(null), dim = dims[0], setDim = dims[1];
524
- var tl = useState(false), tools = tl[0], setTools = tl[1];
525
238
 
526
239
  useEffect(function () { setErr(null); setDur(null); setDim(null); }, [url]);
527
240
  useEffect(function () { if (vref.current) vref.current.playbackRate = rate; }, [rate, url]);
528
241
 
529
242
  var info = [dur != null ? fmtDur(dur) : null, dim ? dim : null, extOf(abs).toUpperCase()].filter(Boolean).join(" · ");
530
243
 
531
- return h(Shell, { key: "root", tools: tools ? h(SuiteCard, { ctx: props.ctx }) : null }, [
244
+ return h(Shell, null, [
532
245
  h("div", { key: "stage", style: { flex: "1 1 auto", minHeight: 0, display: "flex", background: "#000", borderRadius: "8px", overflow: "hidden" } },
533
246
  err
534
247
  ? h("div", { style: { margin: "auto", padding: "20px", textAlign: "center", color: "#ddd", fontSize: "13px", lineHeight: 1.7 } }, [
@@ -551,7 +264,7 @@ window.__ModuleLoader__.load({
551
264
  h(Bar, { key: "bar", title: baseName(abs) + (info ? " · " + info : ""), path: abs }, [
552
265
  h(RatePicker, { key: "rate", rate: rate, set: setRate }),
553
266
  h(Btn, { key: "loop", active: loop, title: "循环播放", onClick: function () { setLoop(!loop); } }, "循环"),
554
- CommonActions(props, abs, url, tools, function () { setTools(!tools); }),
267
+ CommonActions(props, abs, url),
555
268
  ]),
556
269
  ]);
557
270
  }
@@ -560,14 +273,13 @@ window.__ModuleLoader__.load({
560
273
 
561
274
  function AudioView(props) {
562
275
  var abs = absOf(props.scope, props.path);
563
- var url = fileUrl(props.scope, abs);
276
+ var url = playUrl(props, abs);
564
277
  var aref = useRef(null);
565
278
  var cref = useRef(null);
566
279
  var st = useState(1), rate = st[0], setRate = st[1];
567
280
  var lp = useState(false), loop = lp[0], setLoop = lp[1];
568
281
  var er = useState(null), err = er[0], setErr = er[1];
569
282
  var du = useState(null), dur = du[0], setDur = du[1];
570
- var tl = useState(false), tools = tl[0], setTools = tl[1];
571
283
 
572
284
  useEffect(function () { setErr(null); setDur(null); }, [url]);
573
285
  useEffect(function () { if (aref.current) aref.current.playbackRate = rate; }, [rate, url]);
@@ -618,7 +330,7 @@ window.__ModuleLoader__.load({
618
330
  };
619
331
  }, [url]);
620
332
 
621
- return h(Shell, { key: "root", tools: tools ? h(SuiteCard, { ctx: props.ctx }) : null }, [
333
+ return h(Shell, null, [
622
334
  h("div", {
623
335
  key: "stage",
624
336
  style: {
@@ -640,7 +352,7 @@ window.__ModuleLoader__.load({
640
352
  h(Bar, { key: "bar", title: baseName(abs) + " · " + [dur != null ? fmtDur(dur) : null, extOf(abs).toUpperCase()].filter(Boolean).join(" · "), path: abs }, [
641
353
  h(RatePicker, { key: "rate", rate: rate, set: setRate }),
642
354
  h(Btn, { key: "loop", active: loop, title: "循环播放", onClick: function () { setLoop(!loop); } }, "循环"),
643
- CommonActions(props, abs, url, tools, function () { setTools(!tools); }),
355
+ CommonActions(props, abs, url),
644
356
  ]),
645
357
  ]);
646
358
  }
@@ -662,7 +374,6 @@ window.__ModuleLoader__.load({
662
374
  var vp = useState("fit"), view = vp[0], setView = vp[1];
663
375
  var rk = useState(0), reloadKey = rk[0], setReloadKey = rk[1];
664
376
  var sr = useState({ state: "idle", text: "" }), source = sr[0], setSource = sr[1];
665
- var tl = useState(false), tools = tl[0], setTools = tl[1];
666
377
 
667
378
  var frameRef = useRef(null);
668
379
 
@@ -712,7 +423,7 @@ window.__ModuleLoader__.load({
712
423
  }));
713
424
  }
714
425
 
715
- return h(Shell, { key: "root", tools: tools ? h(SuiteCard, { ctx: props.ctx }) : null }, [
426
+ return h(Shell, null, [
716
427
  stage,
717
428
  h(Bar, { key: "bar", title: baseName(abs), path: abs }, [
718
429
  h(Btn, { key: "m1", active: mode === "render", title: "渲染预览", onClick: function () { setMode("render"); } }, "预览"),
@@ -722,11 +433,33 @@ window.__ModuleLoader__.load({
722
433
  }) : null,
723
434
  h(Btn, { key: "js", active: scripts, title: scripts ? "脚本已允许,点击关闭(页面将重新加载)" : "脚本已禁用,点击允许", onClick: function () { setScripts(!scripts); } }, scripts ? "脚本 开" : "脚本 关"),
724
435
  h(Btn, { key: "rl", title: "重新加载", onClick: function () { setReloadKey(reloadKey + 1); } }, "刷新"),
725
- CommonActions(props, abs, url, tools, function () { setTools(!tools); }),
436
+ CommonActions(props, abs, url),
726
437
  ]),
727
438
  ]);
728
439
  }
729
440
 
441
+ // ── add to the film canvas ──────────────────────────────────────────────
442
+
443
+ /** What the 影视工作台 (dsh-film-studio) canvas takes: images, videos and audio. */
444
+ function canAddToCanvas(it) { return it.kind === "image" || it.kind === "video" || it.kind === "audio"; }
445
+
446
+ /**
447
+ * Hand files to the open 影视工作台 canvas. The two plugins only share a DOM
448
+ * event: the workbench sets `detail.accepted` while handling it, so "not
449
+ * accepted" means the workbench plugin is not installed or not enabled.
450
+ * @param paths - absolute file paths.
451
+ * @param sessionId - the gallery's session (the workbench opens there if needed).
452
+ * @returns a promise of `{ ok, added?, failed?, message? }`.
453
+ */
454
+ function addToCanvas(paths, sessionId) {
455
+ return new Promise(function (resolve) {
456
+ var detail = { paths: paths, sessionId: sessionId, accepted: false, respond: resolve };
457
+ try { window.dispatchEvent(new CustomEvent("vibedev-film:add-media", { detail: detail })); }
458
+ catch (e) { resolve({ ok: false, message: String(e && e.message || e) }); return; }
459
+ if (!detail.accepted) resolve({ ok: false, message: "没有找到影视工作台:请先安装并启用「影视工作台」插件(dsh-film-studio)" });
460
+ });
461
+ }
462
+
730
463
  // ── gallery ─────────────────────────────────────────────────────────────
731
464
 
732
465
  function Thumb(p) {
@@ -742,11 +475,56 @@ window.__ModuleLoader__.load({
742
475
  return function () { io.disconnect(); };
743
476
  }, []);
744
477
 
478
+ // A video thumbnail keeps its <video> only until one frame is captured, then shows that frame as an image: a
479
+ // player left on the page holds a connection, and a grid of them would leave none for anything else. Hovering
480
+ // brings a muted player back for the preview.
481
+ var ps = useState(null), poster = ps[0], setPoster = ps[1];
482
+ var hv = useState(false), hover = hv[0], setHover = hv[1];
483
+ // Native listeners, plus a look at the state the element is already in: a frame can be ready before React's
484
+ // media handlers see it, and a missed event left the player on the page for good.
485
+ useEffect(function () {
486
+ var v = vid.current;
487
+ if (!vis || hover || poster || it.kind !== "video" || !v) return undefined;
488
+ var done = false;
489
+ function capture() {
490
+ if (done) return;
491
+ done = true;
492
+ try {
493
+ var w = Math.min(480, v.videoWidth || 480), hh = Math.round(w * ((v.videoHeight || 270) / (v.videoWidth || 480)));
494
+ var cv = document.createElement("canvas");
495
+ cv.width = w; cv.height = hh;
496
+ cv.getContext("2d").drawImage(v, 0, 0, w, hh);
497
+ setPoster(cv.toDataURL("image/jpeg", 0.72));
498
+ } catch (err) { setPoster("none"); }
499
+ }
500
+ // The frame at 0.5 s: the media fragment usually lands there already; otherwise seek, and take it once there.
501
+ function settle() {
502
+ if (v.readyState < 2) return;
503
+ if (v.currentTime > 0.05 || !(v.duration > 0.6)) capture();
504
+ else if (!v.seeking) { try { v.currentTime = 0.5; } catch (err) { capture(); } }
505
+ }
506
+ v.addEventListener("loadeddata", settle);
507
+ v.addEventListener("canplay", settle);
508
+ v.addEventListener("seeked", settle);
509
+ settle();
510
+ return function () {
511
+ done = true;
512
+ v.removeEventListener("loadeddata", settle);
513
+ v.removeEventListener("canplay", settle);
514
+ v.removeEventListener("seeked", settle);
515
+ };
516
+ }, [vis, hover, poster]);
517
+
745
518
  var url = fileUrl(scope, it.abs);
746
519
  var inner;
520
+ var fill = { width: "100%", height: "100%", objectFit: "cover", background: "#000" };
747
521
  if (!vis) inner = null;
748
522
  else if (it.kind === "image") inner = h("img", { src: url, loading: "lazy", draggable: false, style: { width: "100%", height: "100%", objectFit: "cover" } });
749
- else if (it.kind === "video") inner = h("video", { ref: vid, src: url + "#t=0.5", muted: true, preload: "metadata", playsInline: true, style: { width: "100%", height: "100%", objectFit: "cover", background: "#000" } });
523
+ else if (it.kind === "video" && poster && poster !== "none" && !hover) inner = h("img", { src: poster, draggable: false, alt: "", style: fill });
524
+ else if (it.kind === "video") inner = h("video", {
525
+ key: hover ? "play" : "frame", ref: vid, src: url + (hover ? "" : "#t=0.5"), muted: true, preload: "metadata", playsInline: true,
526
+ autoPlay: hover, loop: hover, style: fill,
527
+ });
750
528
  else inner = h("div", {
751
529
  style: { width: "100%", height: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "6px",
752
530
  background: it.kind === "audio" ? "linear-gradient(145deg, rgba(124,92,255,.35), rgba(34,211,238,.25))" : "linear-gradient(145deg, rgba(255,140,60,.28), rgba(255,80,120,.2))" },
@@ -754,8 +532,8 @@ window.__ModuleLoader__.load({
754
532
 
755
533
  return h("div", {
756
534
  ref: ref,
757
- onMouseEnter: function () { var v = vid.current; if (v) { v.currentTime = 0; var pr = v.play(); if (pr && pr.catch) pr.catch(function () {}); } },
758
- onMouseLeave: function () { var v = vid.current; if (v) { v.pause(); v.currentTime = 0.5; } },
535
+ onMouseEnter: function () { if (it.kind === "video") setHover(true); },
536
+ onMouseLeave: function () { if (it.kind === "video") setHover(false); },
759
537
  style: { position: "relative", width: "100%", aspectRatio: "16 / 10", background: C.soft, overflow: "hidden", borderRadius: "8px 8px 0 0" },
760
538
  }, [
761
539
  inner,
@@ -813,6 +591,18 @@ window.__ModuleLoader__.load({
813
591
  var sz = useState(210), size = sz[0], setSize = sz[1];
814
592
  var se = useState(-1), sel = se[0], setSel = se[1];
815
593
  var tk = useState(0), tick = tk[0], setTick = tk[1];
594
+ // Multi-select for "加入画布": picked files are keyed by absolute path, so a
595
+ // pick survives filtering, searching and re-sorting.
596
+ var pk = useState(false), picking = pk[0], setPicking = pk[1];
597
+ var pd = useState({}), picked = pd[0], setPicked = pd[1];
598
+ var bz = useState(false), adding = bz[0], setAdding = bz[1];
599
+ var nt = useState(null), notice = nt[0], setNotice = nt[1];
600
+ var lastPick = useRef(-1);
601
+ useEffect(function () {
602
+ if (!notice) return undefined;
603
+ var timer = setTimeout(function () { setNotice(null); }, notice.kind === "error" ? 8000 : 4000);
604
+ return function () { clearTimeout(timer); };
605
+ }, [notice]);
816
606
 
817
607
  // Follow a re-seeded tab (opened again for another folder).
818
608
  useEffect(function () { setCur(seed); }, [seed]);
@@ -863,6 +653,41 @@ window.__ModuleLoader__.load({
863
653
 
864
654
  useEffect(function () { setSel(-1); }, [cur, recursive, filter, query]);
865
655
 
656
+ var pickedPaths = Object.keys(picked);
657
+ function togglePick(it, index, range) {
658
+ if (!canAddToCanvas(it)) return;
659
+ setPicked(function (prev) {
660
+ var next = Object.assign({}, prev);
661
+ if (range && lastPick.current >= 0) {
662
+ var from = Math.min(lastPick.current, index), to = Math.max(lastPick.current, index);
663
+ for (var i = from; i <= to; i++) if (items[i] && canAddToCanvas(items[i])) next[items[i].abs] = true;
664
+ } else if (next[it.abs]) delete next[it.abs];
665
+ else next[it.abs] = true;
666
+ return next;
667
+ });
668
+ lastPick.current = index;
669
+ }
670
+ function pickAll() {
671
+ var next = Object.assign({}, picked);
672
+ items.forEach(function (it) { if (canAddToCanvas(it)) next[it.abs] = true; });
673
+ setPicked(next);
674
+ }
675
+ function stopPicking() { setPicking(false); setPicked({}); lastPick.current = -1; }
676
+ function addPicked() {
677
+ if (adding || pickedPaths.length === 0) return;
678
+ setAdding(true);
679
+ addToCanvas(pickedPaths, scope.sessionId).then(function (result) {
680
+ setAdding(false);
681
+ if (result.ok) {
682
+ var failed = (result.failed || []).length;
683
+ setNotice({ kind: failed ? "warn" : "ok", text: "已加入画布 " + (result.added || 0) + " 个" + (failed ? "," + failed + " 个未加入:" + result.failed.map(function (f) { return baseName(f.path) + "(" + f.reason + ")"; }).join("、") : "") });
684
+ stopPicking();
685
+ } else {
686
+ setNotice({ kind: "error", text: result.message || "加入画布失败" });
687
+ }
688
+ });
689
+ }
690
+
866
691
  var dir = data ? data.dir : cur;
867
692
  var root = data ? data.cwd : "";
868
693
  var crumbs = [];
@@ -886,8 +711,25 @@ window.__ModuleLoader__.load({
886
711
  ]);
887
712
  }),
888
713
  h("span", { key: "sp", style: { flex: 1 } }),
714
+ h(Btn, { key: "pick", active: picking, title: picking ? "退出多选" : "多选文件,加入影视工作台的画布", onClick: function () { if (picking) stopPicking(); else setPicking(true); } }, [IconCheck(12), picking ? "退出多选" : "多选"]),
889
715
  h(Btn, { key: "rf", title: "重新扫描文件夹", onClick: function () { setTick(tick + 1); } }, "刷新"),
890
716
  ]),
717
+ picking ? h("div", { key: "pickbar", style: { display: "flex", alignItems: "center", gap: "6px", fontSize: "12px", flexWrap: "wrap" } }, [
718
+ h("span", { key: "n", style: { color: C.muted } }, "已选 " + pickedPaths.length + " 个(Shift 连选)"),
719
+ h(Btn, { key: "all", title: "选中当前列表里能加入画布的全部文件", onClick: pickAll }, "全选"),
720
+ h(Btn, { key: "none", title: "清空选择", disabled: pickedPaths.length === 0, onClick: function () { setPicked({}); lastPick.current = -1; } }, "清空"),
721
+ h("span", { key: "sp", style: { flex: 1 } }),
722
+ h("button", {
723
+ key: "add", type: "button", disabled: adding || pickedPaths.length === 0, onClick: addPicked,
724
+ title: "加入当前打开着的影视工作台画布(没有打开时会先在侧栏打开它)",
725
+ style: btn({ background: C.accent, borderColor: C.accent, color: "#fff", fontWeight: 600, opacity: adding || pickedPaths.length === 0 ? 0.5 : 1, cursor: adding || pickedPaths.length === 0 ? "default" : "pointer" }),
726
+ }, adding ? "正在加入…" : "加入画布(" + pickedPaths.length + ")"),
727
+ ]) : null,
728
+ notice ? h("div", {
729
+ key: "notice", role: notice.kind === "error" ? "alert" : "status",
730
+ style: { fontSize: "12px", lineHeight: 1.5, padding: "5px 8px", borderRadius: "6px",
731
+ background: notice.kind === "error" ? "rgba(229,72,77,.14)" : notice.kind === "warn" ? "rgba(245,166,35,.16)" : "rgba(48,164,108,.14)" },
732
+ }, notice.text) : null,
891
733
  h("div", { key: "r2", style: { display: "flex", alignItems: "center", gap: "5px", flexWrap: "wrap" } }, [
892
734
  FILTERS.map(function (f) {
893
735
  return h("button", { key: f.id, type: "button", onClick: function () { setFilter(f.id); }, style: chip(filter === f.id) }, f.label + " " + (counts[f.id] || 0));
@@ -923,7 +765,16 @@ window.__ModuleLoader__.load({
923
765
  ]);
924
766
  });
925
767
  var cards = items.map(function (it, i) {
926
- return h("div", { key: it.abs, onClick: function () { setSel(i); }, title: it.rel, style: { cursor: "pointer", borderRadius: "8px", border: "1px solid " + C.line, overflow: "hidden", display: "flex", flexDirection: "column", minWidth: 0 } }, [
768
+ var addable = canAddToCanvas(it);
769
+ var chosen = !!picked[it.abs];
770
+ return h("div", {
771
+ key: it.abs,
772
+ onClick: function (e) { if (picking) togglePick(it, i, e.shiftKey); else setSel(i); },
773
+ title: picking && !addable ? "网页不能加入画布" : it.rel,
774
+ "aria-pressed": picking ? chosen : undefined,
775
+ style: { position: "relative", cursor: picking && !addable ? "not-allowed" : "pointer", borderRadius: "8px", border: chosen ? "2px solid " + C.accent : "1px solid " + C.line, margin: chosen ? "-1px" : 0, overflow: "hidden", display: "flex", flexDirection: "column", minWidth: 0, opacity: picking && !addable ? 0.45 : 1 },
776
+ }, [
777
+ picking && addable ? h("span", { key: "chk", "aria-hidden": "true", style: { position: "absolute", right: "6px", top: "6px", zIndex: 1, width: "18px", height: "18px", borderRadius: "5px", display: "flex", alignItems: "center", justifyContent: "center", background: chosen ? C.accent : "rgba(0,0,0,.45)", border: "1.5px solid #fff", color: "#fff" } }, chosen ? IconCheck(11) : null) : null,
927
778
  h(Thumb, { key: "t", item: it, scope: scope }),
928
779
  h("div", { key: "m", style: { padding: "5px 8px 6px", fontSize: "12px", minWidth: 0 } }, [
929
780
  h("div", { key: "n", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: 600 } }, recursive ? it.rel : it.name),
@@ -942,8 +793,6 @@ window.__ModuleLoader__.load({
942
793
  return h("div", { style: { position: "relative", height: "100%", display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 } }, [
943
794
  header,
944
795
  body,
945
- // The card sits at the foot of the gallery: a statement about the Host, never a launch ad.
946
- h("div", { key: "tools", style: { flex: "none", padding: "0 10px 10px" } }, h(SuiteCard, { ctx: props.ctx })),
947
796
  sel >= 0 && items[sel]
948
797
  ? h(Lightbox, {
949
798
  key: "lb", ctx: props.ctx, store: props.store, scope: scope, item: items[sel], index: sel, total: items.length,
@@ -955,55 +804,202 @@ window.__ModuleLoader__.load({
955
804
  ]);
956
805
  }
957
806
 
958
- // ── registration ────────────────────────────────────────────────────────
807
+ // ── the host's own right sidebar (DSH 0.2.1-alpha.2 and later) ──────────
808
+ //
809
+ // Since 0.2.0 the viewer stands on the host's public surfaces only, so an app update never strands it behind a
810
+ // third-party plugin that has not caught up:
811
+ // - video / audio / HTML bodies: `ctx.documentPreviews` + the keyed `sidebar.right.tab.document` seat
812
+ // (content loading "renderer": the body streams the file from our own /mp route);
813
+ // - the gallery: a page type in `ctx.sidebarRightTabs` + the keyed `sidebar.right.pane.tab` seat;
814
+ // - "画廊" buttons: the document toolbar (`sidebar.right.tab.document.actions`) and the file tree
815
+ // (`sidebar.right.tab.files.actions`).
816
+
817
+ var PACKAGE = "@vibedev-si/dsh-media-viewer";
818
+ var GALLERY_ID = PACKAGE + "/gallery";
819
+ var GALLERY_KIND = "vibedev-media-gallery";
820
+ var PREVIEWS = [
821
+ { id: PACKAGE + "/video", title: "视频播放器", exts: VIDEO_EXTS, binary: true, view: VideoView },
822
+ { id: PACKAGE + "/audio", title: "音频播放器", exts: AUDIO_EXTS, binary: true, view: AudioView },
823
+ { id: PACKAGE + "/html", title: "网页预览(允许脚本)", exts: HTML_EXTS, binary: false, view: HtmlView },
824
+ ];
959
825
 
960
- var inject = ["betterSidebar"];
826
+ /** `dsh-resource://file/session/<id>/<path>` → `{ sessionId, path }` (the host's address grammar). */
827
+ function parseSessionAddress(address) {
828
+ var prefix = "dsh-resource://file/session/";
829
+ if (typeof address !== "string" || address.indexOf(prefix) !== 0) return null;
830
+ var rest = address.slice(prefix.length).split(/[?#]/)[0].split("/");
831
+ if (rest.length < 2 || !rest[0]) return null;
832
+ try {
833
+ return { sessionId: decodeURIComponent(rest[0]), path: rest.slice(1).map(decodeURIComponent).join("/") };
834
+ } catch (e) { return null; }
835
+ }
961
836
 
962
- function apply(ctx) {
963
- // Each registration returns a disposer; wrapping in ctx.effect makes
964
- // cordis call it on teardown (HMR / plugin disable) so re-activation
965
- // does not throw "already registered".
966
- ctx.effect(function () {
967
- return ctx.betterSidebar.registerFileViewer({
968
- id: "mp:video", title: "视频播放器", icon: IconVideo, exts: VIDEO_EXTS, priority: 10, fetchStrategy: "none", component: VideoView,
837
+ /** The host's right sidebar, when it is there (read lazily: it may mount after this plugin). */
838
+ function sidebarOf(ctx) {
839
+ try { return ctx.get ? ctx.get("sidebarRight") : undefined; } catch (e) { return undefined; }
840
+ }
841
+
842
+ /** Open (or re-aim) the gallery page in the session on screen. */
843
+ function openGalleryPage(ctx, dir) {
844
+ var sidebar = sidebarOf(ctx);
845
+ if (!sidebar) return;
846
+ sidebar.openTab(GALLERY_KIND, { params: { path: dir || "" }, revealIfOpened: true });
847
+ }
848
+
849
+ function Notice(text) {
850
+ return h("div", { style: { margin: "auto", padding: "24px", textAlign: "center", color: C.muted, fontSize: "13px", lineHeight: 1.7 } }, text);
851
+ }
852
+
853
+ /**
854
+ * One document body: the file's host path and version come from the standard `useResource`; the view streams the
855
+ * file itself, and reports the version it shows so the host's "file changed" bar works as for its own previews.
856
+ */
857
+ function documentBody(View) {
858
+ return function MediaDocumentBody(props) {
859
+ var info = props.useTabInfo ? props.useTabInfo() : null;
860
+ var meta = props.useResource ? props.useResource(props.resourceAddress) : { status: "none" };
861
+ var file = parseSessionAddress(props.resourceAddress);
862
+ var content = props.content;
863
+ var abs = meta && meta.value ? meta.value.absolutePath : undefined;
864
+ var version = meta && meta.value ? meta.value.version : undefined;
865
+ var revision = content && content.kind === "renderer" ? content.revision : 0;
866
+ useEffect(function () {
867
+ if (content && content.kind === "renderer" && abs) content.loaded(version === undefined ? "" : String(version));
868
+ }, [revision, abs, version]);
869
+ if (!file) return Notice("这个文件不在会话的工作区里,无法预览");
870
+ if (!abs) return Notice(meta && meta.status === "failed" ? "读取文件信息失败" : "正在读取文件…");
871
+ return h(View, {
872
+ key: String(revision),
873
+ scope: { sessionId: file.sessionId, cwd: "" },
874
+ path: abs,
875
+ nonce: (version === undefined ? "" : String(version)) + "." + revision,
876
+ // The document toolbar already carries "画廊" (sidebar.right.tab.document.actions).
877
+ hideGallery: true,
878
+ openGallery: function (dir) {
879
+ var actions = info && info.tab && info.tab.actions;
880
+ if (actions && actions.openTab) actions.openTab(GALLERY_KIND, { params: { path: dir || "" }, revealIfOpened: true });
881
+ },
969
882
  });
883
+ };
884
+ }
885
+
886
+ /** The gallery as a host page: its folder arrives as the navigation's `params.path`. */
887
+ function GalleryPage(props) {
888
+ var info = props.useTabInfo ? props.useTabInfo() : null;
889
+ var tab = info && info.tab;
890
+ var params = (tab && tab.navigation && tab.navigation.params) || {};
891
+ var sessionId = props.sessionId || (tab && tab.sessionId) || "";
892
+ if (!sessionId) return Notice("请先打开一个会话");
893
+ return h(GalleryTab, {
894
+ ctx: props.ctx,
895
+ scope: { sessionId: sessionId, cwd: "" },
896
+ tab: { path: typeof params.path === "string" ? params.path : "" },
897
+ visible: tab ? tab.visible !== false : true,
970
898
  });
899
+ }
900
+
901
+ function GuideIcon(p) { return IconGrid((p && p.size) || 16); }
902
+
903
+ /** "画廊" in the document toolbar: only for files the gallery shows. */
904
+ function documentAction(ctx) {
905
+ return function GalleryDocumentAction(props) {
906
+ var abs = props.absolutePath || "";
907
+ if (kindOf(abs) === "other") return null;
908
+ return h(Btn, { title: "浏览所在文件夹的全部媒体", onClick: function () { openGalleryPage(ctx, dirName(abs)); } }, [IconGrid(12), "画廊"]);
909
+ };
910
+ }
911
+
912
+ /** "媒体画廊" beside the file tree's reload control: the folder the tree shows. */
913
+ function filesAction(ctx) {
914
+ return function GalleryFilesAction(props) {
915
+ return h(Btn, { title: "以缩略图浏览这个文件夹里的视频、音频、图片和网页", onClick: function () { openGalleryPage(ctx, props.absolutePath || ""); } }, [IconGrid(12), "媒体画廊"]);
916
+ };
917
+ }
918
+
919
+ function registerNative(ctx) {
971
920
  ctx.effect(function () {
972
- return ctx.betterSidebar.registerFileViewer({
973
- id: "mp:audio", title: "音频播放器", icon: IconAudio, exts: AUDIO_EXTS, priority: 10, fetchStrategy: "none", component: AudioView,
921
+ return ctx.sidebarRightTabs.register({
922
+ id: GALLERY_ID,
923
+ kind: GALLERY_KIND,
924
+ title: function () { return "媒体画廊"; },
925
+ guide: [{ id: "gallery", order: 60, title: function () { return "媒体画廊"; }, description: function () { return "以缩略图浏览文件夹里的视频、音频、图片和网页"; }, icon: GuideIcon }],
974
926
  });
975
- });
927
+ }, "dsh-media-viewer: gallery type");
976
928
  ctx.effect(function () {
977
- return ctx.betterSidebar.registerFileViewer({
978
- id: "mp:html", title: "网页预览(允许脚本)", icon: IconHtml, exts: HTML_EXTS, priority: 10, fetchStrategy: "none", component: HtmlView,
929
+ return ctx.slots.inject("sidebar.right.pane.tab", function () {
930
+ return ctx.slots.register({ name: "sidebar.right.pane.tab", key: GALLERY_ID, inject: function (sessionId) { return { ctx: ctx, sessionId: sessionId }; } }, GalleryPage);
979
931
  });
980
- });
932
+ }, "dsh-media-viewer: gallery body");
981
933
  ctx.effect(function () {
982
- return ctx.betterSidebar.registerTab({
983
- id: "mp:gallery",
984
- title: "媒体画廊",
985
- description: "以缩略图浏览文件夹里的视频、音频、图片和网页",
986
- icon: IconGrid,
987
- order: 60,
988
- dedupeKey: function (tab) { return tab.path || "mp:gallery"; },
989
- component: GalleryTab,
934
+ return ctx.slots.inject("sidebar.right.tab.files.actions", function () {
935
+ return ctx.slots.register({ name: "sidebar.right.tab.files.actions", id: GALLERY_ID }, filesAction(ctx));
936
+ });
937
+ }, "dsh-media-viewer: file tree action");
938
+ ctx.effect(function () {
939
+ return ctx.slots.inject("sidebar.right.tab.document.actions", function () {
940
+ return ctx.slots.register({ name: "sidebar.right.tab.document.actions", id: GALLERY_ID }, documentAction(ctx));
941
+ });
942
+ }, "dsh-media-viewer: document action");
943
+ // Document previews are optional: without the host's preview package there is nothing to register them with.
944
+ ctx.inject(["documentPreviews"], function (scope) {
945
+ PREVIEWS.forEach(function (preview) {
946
+ scope.effect(function () {
947
+ return scope.documentPreviews.register({
948
+ id: preview.id, extensions: preview.exts, binaryExtensions: preview.binary ? preview.exts : [],
949
+ priority: "extension", title: function () { return preview.title; }, loading: "renderer", wrap: false,
950
+ });
951
+ }, "dsh-media-viewer: " + preview.id + " metadata");
952
+ scope.effect(function () {
953
+ return scope.slots.inject("sidebar.right.tab.document", function () {
954
+ return scope.slots.register({ name: "sidebar.right.tab.document", key: preview.id }, documentBody(preview.view));
955
+ });
956
+ }, "dsh-media-viewer: " + preview.id + " body");
990
957
  });
991
958
  });
992
959
  }
993
960
 
994
- // The loader reads `inject` and `apply`; `__suite` is the companion-components core, kept
995
- // exported so test/suite.test.mjs can drive it and the card through a fake loader and React.
996
- module.exports = {
997
- inject: inject,
998
- apply: apply,
999
- __suite: {
1000
- SUITE: SUITE, CENTER_PANEL: CENTER_PANEL, CENTER_SPEC: CENTER_SPEC, COMPANIONS: COMPANIONS,
1001
- suiteServices: suiteServices, bundlePresent: bundlePresent, presentFrom: presentFrom,
1002
- readPresence: readPresence, observePresence: observePresence, centerPanel: centerPanel,
1003
- openCenter: openCenter, missingFrom: missingFrom, suiteHint: suiteHint,
1004
- createSuiteStore: createSuiteStore, SuiteCard: SuiteCard,
1005
- },
1006
- };
961
+ // ── dsh-better-sidebar, when it is installed ────────────────────────────
962
+ //
963
+ // Better Sidebar's editor claims every file address ahead of the host's previews, so with it installed the media
964
+ // views must also live inside it (as they did before 0.2.0). Optional: nothing waits for it.
965
+
966
+ function registerBetterSidebar(ctx) {
967
+ // The gallery stays the host's own page (one 媒体画廊, not two); the viewers' "画廊" button opens it.
968
+ function withNativeGallery(View) {
969
+ return function (props) {
970
+ return h(View, Object.assign({}, props, { openGallery: function (dir) { openGalleryPage(ctx, dir); } }));
971
+ };
972
+ }
973
+ ctx.inject(["betterSidebar"], function (scope) {
974
+ // Each registration returns a disposer; wrapping it in scope.effect lets cordis dispose it on teardown.
975
+ scope.effect(function () {
976
+ return scope.betterSidebar.registerFileViewer({
977
+ id: "mp:video", title: "视频播放器", icon: IconVideo, exts: VIDEO_EXTS, priority: 10, fetchStrategy: "none", component: withNativeGallery(VideoView),
978
+ });
979
+ });
980
+ scope.effect(function () {
981
+ return scope.betterSidebar.registerFileViewer({
982
+ id: "mp:audio", title: "音频播放器", icon: IconAudio, exts: AUDIO_EXTS, priority: 10, fetchStrategy: "none", component: withNativeGallery(AudioView),
983
+ });
984
+ });
985
+ scope.effect(function () {
986
+ return scope.betterSidebar.registerFileViewer({
987
+ id: "mp:html", title: "网页预览(允许脚本)", icon: IconHtml, exts: HTML_EXTS, priority: 10, fetchStrategy: "none", component: withNativeGallery(HtmlView),
988
+ });
989
+ });
990
+ });
991
+ }
992
+
993
+ // ── registration ────────────────────────────────────────────────────────
994
+
995
+ var inject = ["slots", "sidebarRightTabs"];
996
+
997
+ function apply(ctx) {
998
+ registerNative(ctx);
999
+ registerBetterSidebar(ctx);
1000
+ }
1001
+
1002
+ module.exports = { inject: inject, apply: apply, internals: { parseSessionAddress: parseSessionAddress, GALLERY_KIND: GALLERY_KIND, PREVIEWS: PREVIEWS } };
1007
1003
  return module.exports;
1008
1004
  },
1009
1005
  });
package/index.js CHANGED
@@ -36,8 +36,22 @@ import { basename, extname, resolve } from 'node:path'
36
36
 
37
37
  export const name = '@vibedev-si/dsh-media-viewer'
38
38
 
39
- /** Services required before mounting. */
40
- export const inject = ['webServer', 'sessions', 'webRuntime']
39
+ /**
40
+ * Services required before mounting. The extra trusted hosts come from `webStartup` (DSH 0.2.1-alpha.2 and later)
41
+ * or `webRuntime` (earlier releases), read when present: requiring either one left the plugin pending on the other.
42
+ */
43
+ export const inject = ['webServer', 'sessions']
44
+
45
+ /** The deployment's extra trusted hosts (loopback is always trusted), from whichever startup service this host has. */
46
+ export function trustedHostsOf(ctx) {
47
+ for (const service of ['webStartup', 'webRuntime']) {
48
+ try {
49
+ const value = typeof ctx.get === 'function' ? ctx.get(service) : ctx[service]
50
+ if (Array.isArray(value?.trustedHosts)) return value.trustedHosts
51
+ } catch { /* not provided by this host */ }
52
+ }
53
+ return []
54
+ }
41
55
 
42
56
  // ── content types ───────────────────────────────────────────────────────────
43
57
 
@@ -246,18 +260,35 @@ function parseRange(raw, size) {
246
260
  return { start, end: Math.min(end, size - 1) }
247
261
  }
248
262
 
249
- function serveFile(req, res, path, size, type, extra) {
263
+ /**
264
+ * The most an open-ended media range (`bytes=N-`) gets in one response. A player asks for the rest of the file, reads
265
+ * what it needs (just the metadata for preload=metadata) and then stops reading, which leaves the response unfinished
266
+ * and its connection taken. A page that also keeps its own streams open runs out of the browser's six connections per
267
+ * host, and the next video waits with no request at all. A small capped 206 is read to its end, frees the connection,
268
+ * and the player asks again from where it stopped.
269
+ */
270
+ export const MEDIA_CHUNK_BYTES = 256 * 1024
271
+
272
+ /** @param chunk - cap for an open-ended range, for media playback; undefined serves what was asked. */
273
+ function serveFile(req, res, path, size, type, extra, chunk) {
250
274
  const base = {
251
275
  'content-type': type,
252
276
  'accept-ranges': 'bytes',
253
- 'cache-control': 'no-cache',
277
+ // no-store, not no-cache: a stored response keeps the browser cache's single-writer lock on the URL while a
278
+ // player holds its stream (a gallery thumbnail, the lightbox), and every other player of the same file then waits
279
+ // forever. Local files need no cache.
280
+ 'cache-control': 'no-store',
254
281
  'x-content-type-options': 'nosniff',
255
282
  'referrer-policy': 'no-referrer',
256
283
  'content-security-policy': SANDBOX_CSP,
257
284
  'cross-origin-resource-policy': 'cross-origin',
258
285
  ...extra,
259
286
  }
260
- const range = parseRange(header(req.headers, 'range'), size)
287
+ const asked = header(req.headers, 'range')
288
+ const range = parseRange(asked, size)
289
+ if (range && !range.unsatisfiable && chunk !== undefined && /^bytes=\s*\d*-\s*$/i.test(asked ?? '')) {
290
+ range.end = Math.min(range.end, range.start + chunk - 1)
291
+ }
261
292
  if (range?.unsatisfiable) {
262
293
  res.writeHead(416, { ...base, 'content-range': `bytes */${size}` })
263
294
  res.end()
@@ -342,7 +373,7 @@ async function listMedia(root, recursive) {
342
373
  // ── plugin body ─────────────────────────────────────────────────────────────
343
374
 
344
375
  export function apply(ctx) {
345
- const trustedHosts = () => ctx.webRuntime.trustedHosts
376
+ const trustedHosts = () => trustedHostsOf(ctx)
346
377
 
347
378
  ctx.effect(() => ctx.webServer.register({
348
379
  kind: 'prefix',
@@ -381,7 +412,8 @@ export function apply(ctx) {
381
412
  res.end()
382
413
  return
383
414
  }
384
- serveFile(req, res, real, info.size, type, extra)
415
+ const playback = url.searchParams.get('download') !== '1' && /^(video|audio)\//.test(type)
416
+ serveFile(req, res, real, info.size, type, extra, playback ? MEDIA_CHUNK_BYTES : undefined)
385
417
  return
386
418
  }
387
419
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vibedev-si/dsh-media-viewer",
3
- "version": "0.1.2",
4
- "description": "右侧栏媒体预览:直接预览视频、音频和 HTML(大文件可拖进度条,HTML 默认允许脚本),并提供文件夹媒体画廊,需配合 dsh-better-sidebar · Inline video, audio and HTML preview plus a folder media gallery in the dsh-better-sidebar right column — range-streamed large files, sandboxed HTML with working relative assets",
3
+ "version": "0.2.0",
4
+ "description": "右侧栏媒体预览:直接预览视频、音频和 HTML(大文件可拖进度条,HTML 默认允许脚本),文件夹媒体画廊(多选后加入影视工作台画布);用 DSH 自带的右侧栏,装了 dsh-better-sidebar 时也在其中可用 · Inline video, audio and HTML preview plus a folder media gallery in the host's own right sidebar (and inside dsh-better-sidebar when installed) — range-streamed large files, sandboxed HTML with working relative assets",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "icon": "./icon.svg",
@@ -26,14 +26,14 @@
26
26
  "dsh-plugin",
27
27
  "deepseek-harness",
28
28
  "vibedev",
29
- "dsh-better-sidebar",
30
- "better-sidebar",
29
+ "right-sidebar",
31
30
  "video",
32
31
  "audio",
33
32
  "html",
34
33
  "preview",
35
34
  "gallery",
36
- "file-viewer"
35
+ "file-viewer",
36
+ "dsh-better-sidebar"
37
37
  ],
38
38
  "author": "CrisLIUning",
39
39
  "license": "MIT",
@@ -53,7 +53,7 @@
53
53
  "registry": "https://registry.npmjs.org/"
54
54
  },
55
55
  "scripts": {
56
- "test": "node test/route.test.mjs && node test/tokens.test.mjs && node test/suite.test.mjs"
56
+ "test": "node test/route.test.mjs && node test/tokens.test.mjs && node test/add-to-canvas.test.mjs && node test/host-surface.test.mjs"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "dsh-better-sidebar": ">=0.24.0"
@@ -64,9 +64,16 @@
64
64
  },
65
65
  "client": {
66
66
  "inject": [
67
- "@deepseek-ai/dsh-client-runtime"
67
+ "@deepseek-ai/dsh-client-runtime",
68
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
69
+ "@deepseek-ai/dsh-client-ui-sidebar-documentpreview"
68
70
  ],
69
71
  "platform": "web"
70
72
  }
73
+ },
74
+ "peerDependenciesMeta": {
75
+ "dsh-better-sidebar": {
76
+ "optional": true
77
+ }
71
78
  }
72
79
  }