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

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 (3) hide show
  1. package/README.md +5 -0
  2. package/client.js +342 -13
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -16,6 +16,10 @@
16
16
 
17
17
  画廊在右侧栏的 **+ 菜单**里叫「媒体画廊」;每个播放器工具栏里的「画廊」按钮会直接打开它所在的文件夹。
18
18
 
19
+ ## 本版新增(0.1.2)
20
+
21
+ 新增「组件」入口:在预览工具栏里可直接打开 **VibeDev 插件中心**,了解或补齐影视工作台与 VibeDev 账号插件(打不开插件中心时会给出可复制的包名);已安装或应用内置的组件不会重复推荐。
22
+
19
23
  ## 安装
20
24
 
21
25
  **前置依赖:[`dsh-better-sidebar`](https://www.npmjs.com/package/dsh-better-sidebar)**(≥ 0.24.0)。它接管了右侧栏的文件打开,本插件通过它的 `registerFileViewer` / `registerTab` 接入。没装它,本插件不会生效。
@@ -72,6 +76,7 @@ client 是无需构建的纯 JS(`client.js`),host 是 `index.js`,没有
72
76
  - **Audio** — mp3 / wav / m4a / flac…, live spectrum, playback speed, loop.
73
77
  - **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.
74
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.
75
80
 
76
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.
77
82
 
package/client.js CHANGED
@@ -81,13 +81,20 @@ window.__ModuleLoader__.load({
81
81
 
82
82
  // ── styling helpers ─────────────────────────────────────────────────────
83
83
 
84
+ // Colours: ONLY variables the host really defines (--dsw-alias-bg-base, -bg-layer-1/2, -bg-overlay, -border-l1/l2,
85
+ // -brand-primary, -label-primary/secondary, -state-*). An earlier version used three names it had guessed
86
+ // (a "bg-primary", a "surface-primary" and a "link-normal"): the host has none of them, so the fallback (#fff)
87
+ // was always used and the gallery lightbox came out white, with light text on it, in the dark theme.
88
+ // test/tokens.test.mjs now rejects any --dsw-* name that is not on the real list.
84
89
  var C = {
85
- muted: "var(--dsw-alias-label-secondary, #8b90a0)",
86
- link: "var(--dsw-alias-link-normal, #4c8dff)",
90
+ bg: "var(--dsw-alias-bg-base, #fff)",
91
+ fg: "var(--dsw-alias-label-primary, #1b1d24)",
92
+ muted: "var(--dsw-alias-label-secondary, #6c7080)",
93
+ link: "var(--dsw-alias-brand-primary, #2f6fe0)",
87
94
  line: "rgba(127,127,127,.28)",
88
95
  soft: "rgba(127,127,127,.10)",
89
96
  softer: "rgba(127,127,127,.06)",
90
- accent: "#7c5cff",
97
+ accent: "#7254f5", // a white label on it is 4.88:1 (the old #7c5cff gave 4.35:1)
91
98
  };
92
99
 
93
100
  var btnBase = {
@@ -151,6 +158,12 @@ window.__ModuleLoader__.load({
151
158
  h("rect", { key: "d", x: 9, y: 9, width: 5, height: 5, rx: 1, stroke: "currentColor", strokeWidth: 1.5 }),
152
159
  ]);
153
160
  }
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
+ ]);
166
+ }
154
167
  function kindIcon(kind, size) {
155
168
  return kind === "video" ? IconVideo(size) : kind === "audio" ? IconAudio(size) : kind === "html" ? IconHtml(size) : IconImage(size);
156
169
  }
@@ -173,7 +186,7 @@ window.__ModuleLoader__.load({
173
186
  } catch (e) { console.error("[dsh-media-viewer] open gallery failed", e); }
174
187
  }
175
188
 
176
- function CommonActions(props, abs, url) {
189
+ function CommonActions(props, abs, url, toolsOpen, onTools) {
177
190
  var out = [
178
191
  h("a", { key: "new", href: url, target: "_blank", rel: "noopener", style: btn(), title: "在新窗口打开" }, "新窗口"),
179
192
  h("a", { key: "dl", href: fileUrl(props.scope, abs, "download=1"), download: baseName(abs), style: btn(), title: "下载原文件" }, "下载"),
@@ -181,6 +194,9 @@ window.__ModuleLoader__.load({
181
194
  if (!props.hideGallery) {
182
195
  out.push(h(Btn, { key: "gal", title: "浏览所在文件夹的全部媒体", onClick: function () { openGallery(props, abs); } }, [IconGrid(12), "画廊"]));
183
196
  }
197
+ if (typeof onTools === "function") {
198
+ out.push(h(Btn, { key: "tools", active: toolsOpen === true, title: "相关组件与插件中心", onClick: onTools }, [IconTools(12), "组件"]));
199
+ }
184
200
  return out;
185
201
  }
186
202
 
@@ -193,9 +209,305 @@ window.__ModuleLoader__.load({
193
209
  }
194
210
 
195
211
  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);
196
214
  return h("div", {
197
215
  style: { display: "flex", flexDirection: "column", gap: "8px", padding: "10px", minWidth: 0, minHeight: 0, height: "100%", boxSizing: "border-box" },
198
- }, p.children);
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);
199
511
  }
200
512
 
201
513
  // ── video ───────────────────────────────────────────────────────────────
@@ -209,13 +521,14 @@ window.__ModuleLoader__.load({
209
521
  var er = useState(null), err = er[0], setErr = er[1];
210
522
  var du = useState(null), dur = du[0], setDur = du[1];
211
523
  var dims = useState(null), dim = dims[0], setDim = dims[1];
524
+ var tl = useState(false), tools = tl[0], setTools = tl[1];
212
525
 
213
526
  useEffect(function () { setErr(null); setDur(null); setDim(null); }, [url]);
214
527
  useEffect(function () { if (vref.current) vref.current.playbackRate = rate; }, [rate, url]);
215
528
 
216
529
  var info = [dur != null ? fmtDur(dur) : null, dim ? dim : null, extOf(abs).toUpperCase()].filter(Boolean).join(" · ");
217
530
 
218
- return h(Shell, null, [
531
+ return h(Shell, { key: "root", tools: tools ? h(SuiteCard, { ctx: props.ctx }) : null }, [
219
532
  h("div", { key: "stage", style: { flex: "1 1 auto", minHeight: 0, display: "flex", background: "#000", borderRadius: "8px", overflow: "hidden" } },
220
533
  err
221
534
  ? h("div", { style: { margin: "auto", padding: "20px", textAlign: "center", color: "#ddd", fontSize: "13px", lineHeight: 1.7 } }, [
@@ -238,7 +551,7 @@ window.__ModuleLoader__.load({
238
551
  h(Bar, { key: "bar", title: baseName(abs) + (info ? " · " + info : ""), path: abs }, [
239
552
  h(RatePicker, { key: "rate", rate: rate, set: setRate }),
240
553
  h(Btn, { key: "loop", active: loop, title: "循环播放", onClick: function () { setLoop(!loop); } }, "循环"),
241
- CommonActions(props, abs, url),
554
+ CommonActions(props, abs, url, tools, function () { setTools(!tools); }),
242
555
  ]),
243
556
  ]);
244
557
  }
@@ -254,6 +567,7 @@ window.__ModuleLoader__.load({
254
567
  var lp = useState(false), loop = lp[0], setLoop = lp[1];
255
568
  var er = useState(null), err = er[0], setErr = er[1];
256
569
  var du = useState(null), dur = du[0], setDur = du[1];
570
+ var tl = useState(false), tools = tl[0], setTools = tl[1];
257
571
 
258
572
  useEffect(function () { setErr(null); setDur(null); }, [url]);
259
573
  useEffect(function () { if (aref.current) aref.current.playbackRate = rate; }, [rate, url]);
@@ -304,7 +618,7 @@ window.__ModuleLoader__.load({
304
618
  };
305
619
  }, [url]);
306
620
 
307
- return h(Shell, null, [
621
+ return h(Shell, { key: "root", tools: tools ? h(SuiteCard, { ctx: props.ctx }) : null }, [
308
622
  h("div", {
309
623
  key: "stage",
310
624
  style: {
@@ -326,7 +640,7 @@ window.__ModuleLoader__.load({
326
640
  h(Bar, { key: "bar", title: baseName(abs) + " · " + [dur != null ? fmtDur(dur) : null, extOf(abs).toUpperCase()].filter(Boolean).join(" · "), path: abs }, [
327
641
  h(RatePicker, { key: "rate", rate: rate, set: setRate }),
328
642
  h(Btn, { key: "loop", active: loop, title: "循环播放", onClick: function () { setLoop(!loop); } }, "循环"),
329
- CommonActions(props, abs, url),
643
+ CommonActions(props, abs, url, tools, function () { setTools(!tools); }),
330
644
  ]),
331
645
  ]);
332
646
  }
@@ -348,6 +662,7 @@ window.__ModuleLoader__.load({
348
662
  var vp = useState("fit"), view = vp[0], setView = vp[1];
349
663
  var rk = useState(0), reloadKey = rk[0], setReloadKey = rk[1];
350
664
  var sr = useState({ state: "idle", text: "" }), source = sr[0], setSource = sr[1];
665
+ var tl = useState(false), tools = tl[0], setTools = tl[1];
351
666
 
352
667
  var frameRef = useRef(null);
353
668
 
@@ -397,7 +712,7 @@ window.__ModuleLoader__.load({
397
712
  }));
398
713
  }
399
714
 
400
- return h(Shell, null, [
715
+ return h(Shell, { key: "root", tools: tools ? h(SuiteCard, { ctx: props.ctx }) : null }, [
401
716
  stage,
402
717
  h(Bar, { key: "bar", title: baseName(abs), path: abs }, [
403
718
  h(Btn, { key: "m1", active: mode === "render", title: "渲染预览", onClick: function () { setMode("render"); } }, "预览"),
@@ -407,7 +722,7 @@ window.__ModuleLoader__.load({
407
722
  }) : null,
408
723
  h(Btn, { key: "js", active: scripts, title: scripts ? "脚本已允许,点击关闭(页面将重新加载)" : "脚本已禁用,点击允许", onClick: function () { setScripts(!scripts); } }, scripts ? "脚本 开" : "脚本 关"),
409
724
  h(Btn, { key: "rl", title: "重新加载", onClick: function () { setReloadKey(reloadKey + 1); } }, "刷新"),
410
- CommonActions(props, abs, url),
725
+ CommonActions(props, abs, url, tools, function () { setTools(!tools); }),
411
726
  ]),
412
727
  ]);
413
728
  }
@@ -469,7 +784,7 @@ window.__ModuleLoader__.load({
469
784
 
470
785
  var nav = { position: "absolute", top: "50%", transform: "translateY(-50%)", width: "30px", height: "56px", border: "none", borderRadius: "6px", background: "rgba(127,127,127,.35)", color: "inherit", cursor: "pointer", fontSize: "18px", zIndex: 2 };
471
786
  return h("div", {
472
- style: { position: "absolute", inset: 0, zIndex: 20, display: "flex", flexDirection: "column", background: "var(--dsw-alias-bg-primary, var(--dsw-alias-surface-primary, #fff))" },
787
+ style: { position: "absolute", inset: 0, zIndex: 20, display: "flex", flexDirection: "column", background: C.bg, color: C.fg },
473
788
  }, [
474
789
  h("div", { key: "top", style: { display: "flex", alignItems: "center", gap: "8px", padding: "8px 10px", borderBottom: "1px solid " + C.line, fontSize: "12px", flex: "none" } }, [
475
790
  h(Btn, { key: "x", title: "返回画廊 (Esc)", onClick: p.onClose }, "← 返回"),
@@ -627,6 +942,8 @@ window.__ModuleLoader__.load({
627
942
  return h("div", { style: { position: "relative", height: "100%", display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 } }, [
628
943
  header,
629
944
  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 })),
630
947
  sel >= 0 && items[sel]
631
948
  ? h(Lightbox, {
632
949
  key: "lb", ctx: props.ctx, store: props.store, scope: scope, item: items[sel], index: sel, total: items.length,
@@ -674,7 +991,19 @@ window.__ModuleLoader__.load({
674
991
  });
675
992
  }
676
993
 
677
- module.exports = { inject: inject, apply: apply };
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
+ };
678
1007
  return module.exports;
679
1008
  },
680
1009
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibedev-si/dsh-media-viewer",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
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",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -53,7 +53,7 @@
53
53
  "registry": "https://registry.npmjs.org/"
54
54
  },
55
55
  "scripts": {
56
- "test": "node test/route.test.mjs"
56
+ "test": "node test/route.test.mjs && node test/tokens.test.mjs && node test/suite.test.mjs"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "dsh-better-sidebar": ">=0.24.0"