@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.
- package/README.md +28 -16
- package/client.js +353 -35
- package/index.js +39 -7
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
- 安全围栏会拒绝沙箱 iframe 里的子资源请求(它们带 `Sec-Fetch-Site: cross-site`),所以带样式、脚本、视频的 HTML 只能加载一半。
|
|
55
|
+
## 为什么自带 `/mp` 路由
|
|
43
56
|
|
|
44
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
5
|
-
* -
|
|
6
|
-
* - a
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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"
|
|
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 () {
|
|
450
|
-
onMouseLeave: function () {
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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.
|
|
663
|
-
id:
|
|
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.
|
|
668
|
-
|
|
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.
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "右侧栏媒体预览:直接预览视频、音频和 HTML(大文件可拖进度条,HTML
|
|
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
|
-
"
|
|
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
|
}
|