@vibedev-si/dsh-media-viewer 0.1.3 → 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 +28 -16
  2. package/client.js +353 -35
  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.3)
20
+ - 在文件树里点开视频、音频或网页文件,就用本插件的播放器打开(文件预览顶部的切换菜单里可以换回内置预览)。
21
+ - 媒体画廊:右侧栏「开始」页的「媒体画廊」、文件树顶部的「媒体画廊」按钮(打开当前文件夹),或文件预览顶部的「画廊」按钮(打开文件所在的文件夹)。
22
+
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
20
38
 
21
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
- - **Withdrawn in 0.1.3** — the 「组件」 / plugin-centre entry 0.1.2 added to the preview toolbar and the gallery footer is gone, because it changed how the sidebar looked; it will be reconsidered after a redesign, and previewing, the gallery and the security notes are unaffected.
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,6 +168,9 @@ 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
  }
171
+ function IconCheck(size) {
172
+ return svg(size, [pathEl("m3.5 8.3 2.9 2.9 6.1-6.4", { key: "a" })]);
173
+ }
161
174
  function kindIcon(kind, size) {
162
175
  return kind === "video" ? IconVideo(size) : kind === "audio" ? IconAudio(size) : kind === "html" ? IconHtml(size) : IconImage(size);
163
176
  }
@@ -174,9 +187,14 @@ window.__ModuleLoader__.load({
174
187
  ]);
175
188
  }
176
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
+ */
177
194
  function openGallery(props, abs) {
178
195
  try {
179
- 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);
180
198
  } catch (e) { console.error("[dsh-media-viewer] open gallery failed", e); }
181
199
  }
182
200
 
@@ -201,7 +219,8 @@ window.__ModuleLoader__.load({
201
219
 
202
220
  function Shell(p) {
203
221
  return h("div", {
204
- style: { display: "flex", flexDirection: "column", gap: "8px", padding: "10px", minWidth: 0, minHeight: 0, height: "100%", boxSizing: "border-box" },
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" },
205
224
  }, p.children);
206
225
  }
207
226
 
@@ -209,7 +228,7 @@ window.__ModuleLoader__.load({
209
228
 
210
229
  function VideoView(props) {
211
230
  var abs = absOf(props.scope, props.path);
212
- var url = fileUrl(props.scope, abs);
231
+ var url = playUrl(props, abs);
213
232
  var vref = useRef(null);
214
233
  var st = useState(1), rate = st[0], setRate = st[1];
215
234
  var lp = useState(false), loop = lp[0], setLoop = lp[1];
@@ -254,7 +273,7 @@ window.__ModuleLoader__.load({
254
273
 
255
274
  function AudioView(props) {
256
275
  var abs = absOf(props.scope, props.path);
257
- var url = fileUrl(props.scope, abs);
276
+ var url = playUrl(props, abs);
258
277
  var aref = useRef(null);
259
278
  var cref = useRef(null);
260
279
  var st = useState(1), rate = st[0], setRate = st[1];
@@ -419,6 +438,28 @@ window.__ModuleLoader__.load({
419
438
  ]);
420
439
  }
421
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
+
422
463
  // ── gallery ─────────────────────────────────────────────────────────────
423
464
 
424
465
  function Thumb(p) {
@@ -434,11 +475,56 @@ window.__ModuleLoader__.load({
434
475
  return function () { io.disconnect(); };
435
476
  }, []);
436
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
+
437
518
  var url = fileUrl(scope, it.abs);
438
519
  var inner;
520
+ var fill = { width: "100%", height: "100%", objectFit: "cover", background: "#000" };
439
521
  if (!vis) inner = null;
440
522
  else if (it.kind === "image") inner = h("img", { src: url, loading: "lazy", draggable: false, style: { width: "100%", height: "100%", objectFit: "cover" } });
441
- 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
+ });
442
528
  else inner = h("div", {
443
529
  style: { width: "100%", height: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "6px",
444
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))" },
@@ -446,8 +532,8 @@ window.__ModuleLoader__.load({
446
532
 
447
533
  return h("div", {
448
534
  ref: ref,
449
- onMouseEnter: function () { var v = vid.current; if (v) { v.currentTime = 0; var pr = v.play(); if (pr && pr.catch) pr.catch(function () {}); } },
450
- 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); },
451
537
  style: { position: "relative", width: "100%", aspectRatio: "16 / 10", background: C.soft, overflow: "hidden", borderRadius: "8px 8px 0 0" },
452
538
  }, [
453
539
  inner,
@@ -505,6 +591,18 @@ window.__ModuleLoader__.load({
505
591
  var sz = useState(210), size = sz[0], setSize = sz[1];
506
592
  var se = useState(-1), sel = se[0], setSel = se[1];
507
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]);
508
606
 
509
607
  // Follow a re-seeded tab (opened again for another folder).
510
608
  useEffect(function () { setCur(seed); }, [seed]);
@@ -555,6 +653,41 @@ window.__ModuleLoader__.load({
555
653
 
556
654
  useEffect(function () { setSel(-1); }, [cur, recursive, filter, query]);
557
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
+
558
691
  var dir = data ? data.dir : cur;
559
692
  var root = data ? data.cwd : "";
560
693
  var crumbs = [];
@@ -578,8 +711,25 @@ window.__ModuleLoader__.load({
578
711
  ]);
579
712
  }),
580
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 ? "退出多选" : "多选"]),
581
715
  h(Btn, { key: "rf", title: "重新扫描文件夹", onClick: function () { setTick(tick + 1); } }, "刷新"),
582
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,
583
733
  h("div", { key: "r2", style: { display: "flex", alignItems: "center", gap: "5px", flexWrap: "wrap" } }, [
584
734
  FILTERS.map(function (f) {
585
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));
@@ -615,7 +765,16 @@ window.__ModuleLoader__.load({
615
765
  ]);
616
766
  });
617
767
  var cards = items.map(function (it, i) {
618
- 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,
619
778
  h(Thumb, { key: "t", item: it, scope: scope }),
620
779
  h("div", { key: "m", style: { padding: "5px 8px 6px", fontSize: "12px", minWidth: 0 } }, [
621
780
  h("div", { key: "n", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: 600 } }, recursive ? it.rel : it.name),
@@ -645,43 +804,202 @@ window.__ModuleLoader__.load({
645
804
  ]);
646
805
  }
647
806
 
648
- // ── 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
+ ];
649
825
 
650
- 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
+ }
651
836
 
652
- function apply(ctx) {
653
- // Each registration returns a disposer; wrapping in ctx.effect makes
654
- // cordis call it on teardown (HMR / plugin disable) so re-activation
655
- // does not throw "already registered".
656
- ctx.effect(function () {
657
- return ctx.betterSidebar.registerFileViewer({
658
- 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
+ },
659
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,
660
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) {
661
920
  ctx.effect(function () {
662
- return ctx.betterSidebar.registerFileViewer({
663
- 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 }],
664
926
  });
665
- });
927
+ }, "dsh-media-viewer: gallery type");
666
928
  ctx.effect(function () {
667
- return ctx.betterSidebar.registerFileViewer({
668
- 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);
669
931
  });
670
- });
932
+ }, "dsh-media-viewer: gallery body");
671
933
  ctx.effect(function () {
672
- return ctx.betterSidebar.registerTab({
673
- id: "mp:gallery",
674
- title: "媒体画廊",
675
- description: "以缩略图浏览文件夹里的视频、音频、图片和网页",
676
- icon: IconGrid,
677
- order: 60,
678
- dedupeKey: function (tab) { return tab.path || "mp:gallery"; },
679
- 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");
680
957
  });
681
958
  });
682
959
  }
683
960
 
684
- module.exports = { inject: inject, apply: apply };
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 } };
685
1003
  return module.exports;
686
1004
  },
687
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.3",
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"
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
  }