@vibedev-si/dsh-media-viewer 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CrisLIUning
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # @vibedev-si/dsh-media-viewer · 右侧栏媒体预览
2
+
3
+ > 在右侧栏**直接预览视频、音频和 HTML**,并提供**文件夹媒体画廊**。
4
+ > Inline video / audio / HTML preview and a folder media gallery for the DSH right sidebar.
5
+
6
+ 之前点开 `.mp4`、`.wav`、`.html` 只能看到"无法预览"或一个下载面板;装上它之后,这些文件在右侧栏里直接就能播放和渲染。
7
+
8
+ ## 功能
9
+
10
+ | 能力 | 说明 |
11
+ |---|---|
12
+ | 视频 | mp4 / webm / mov / mkv 等;可拖进度条;倍速、循环;**不限文件大小**(Range 流式传输) |
13
+ | 音频 | mp3 / wav / m4a / flac 等;实时频谱;倍速、循环 |
14
+ | HTML | 默认**允许脚本**;相对路径的 CSS / JS / 图片 / 视频 / `fetch` 都能加载;预览 / 源码切换;手机、平板、桌面宽度;脚本开关 |
15
+ | 媒体画廊 | 缩略图网格;按视频 / 音频 / 图片 / 网页筛选、搜索、排序;可包含子文件夹;点开是带上一个 / 下一个(← →)的灯箱 |
16
+
17
+ 画廊在右侧栏的 **+ 菜单**里叫「媒体画廊」;每个播放器工具栏里的「画廊」按钮会直接打开它所在的文件夹。
18
+
19
+ ## 安装
20
+
21
+ **前置依赖:[`dsh-better-sidebar`](https://www.npmjs.com/package/dsh-better-sidebar)**(≥ 0.24.0)。它接管了右侧栏的文件打开,本插件通过它的 `registerFileViewer` / `registerTab` 接入。没装它,本插件不会生效。
22
+
23
+ 在 VibeDev / DSH 的插件管理里按包名 `@vibedev-si/dsh-media-viewer` 安装即可,或用命令行:
24
+
25
+ ```sh
26
+ dsh plugin --profile <你的 profile 名> add @vibedev-si/dsh-media-viewer
27
+ ```
28
+
29
+ 安装后**刷新页面**。
30
+
31
+ > **从旧包名迁移**:本插件原先以无作用域的 `dsh-media-viewer` 发布(0.1.0),现已迁到官方作用域 `@vibedev-si/dsh-media-viewer`,旧包名已标记弃用。已装旧包的用户请卸载后改装新包名;功能与代码完全相同。
32
+
33
+ ## 为什么需要它
34
+
35
+ `dsh-better-sidebar` 内置的 `/sidebar/file`、`/sidebar/html` 路由:
36
+
37
+ - 有 **20MB 上限**,整文件读入内存,不支持 HTTP Range,所以大视频打不开、也不能拖进度条;
38
+ - 安全围栏会拒绝沙箱 iframe 里的子资源请求(它们带 `Sec-Fetch-Site: cross-site`),所以带样式、脚本、视频的 HTML 只能加载一半。
39
+
40
+ 本插件自带 `/mp/*` 路由解决这两点,并在沙箱里保持对 GUI 的隔离。
41
+
42
+ ## 安全
43
+
44
+ - Host 头必须是回环地址或受信任主机(防 DNS rebinding)。
45
+ - 所有路径必须落在**当前会话工作目录**内(符号链接解析之后也一样)。
46
+ - HTML 在**不透明来源**沙箱 iframe 中运行(无 `allow-same-origin`),无法访问 GUI 的 `parent` / `localStorage` / `cookie`。
47
+ - 文件路由只对"子资源类型"的请求放宽 cross-site,且需要有效的会话 id;列表接口始终严格同源。
48
+ - 每个响应都带 CSP `sandbox`,即使在新窗口里直接打开也保持不透明来源。
49
+
50
+ > HTML 默认允许脚本。预览**不可信**的 HTML 时,请点工具栏的「脚本 开」关掉脚本。
51
+
52
+ ## 已知限制
53
+
54
+ - HTML 里以 `/` 开头的根相对路径(如 `/logo.png`)会指向 GUI 服务器根,加载不到;请用相对路径。
55
+ - 浏览器本身不支持的编码(常见:H.265 / HEVC)无法播放,会提示并给出下载链接。
56
+ - 画廊单次最多列出 3000 个文件,递归最深 6 层,跳过 `node_modules` 与 `.git`。
57
+ - 目前主要在 Windows 上测试;路径处理按 Windows / POSIX 两种写法实现,但 macOS / Linux 尚未实机验证。
58
+
59
+ ## 开发
60
+
61
+ ```sh
62
+ npm test # host 路由:Range、越界、符号链接、Host 伪造、沙箱子资源
63
+ ```
64
+
65
+ client 是无需构建的纯 JS(`client.js`),host 是 `index.js`,没有构建步骤。
66
+
67
+ ## English
68
+
69
+ **@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):
70
+
71
+ - **Video** — mp4 / webm / mov / mkv…, seekable, playback speed, loop, no file-size cap (HTTP Range streaming).
72
+ - **Audio** — mp3 / wav / m4a / flac…, live spectrum, playback speed, loop.
73
+ - **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
+ - **Media gallery** — thumbnail grid for a folder with type filters, search, sort, optional recursion, and a lightbox with ← / → navigation.
75
+
76
+ 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
+
78
+ ## License
79
+
80
+ [MIT](./LICENSE) © CrisLIUning
package/client.js ADDED
@@ -0,0 +1,680 @@
1
+ /*!
2
+ * dsh-media-viewer — client half (browser bundle, no build step).
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
7
+ *
8
+ * Bundle format: the official DSH client-bundle shape, a CommonJS-style
9
+ * factory registered through window.__ModuleLoader__.load. `react` is resolved
10
+ * from the shell's module table; everything else is inlined.
11
+ */
12
+ window.__ModuleLoader__.load({
13
+ id: "@vibedev-si/dsh-media-viewer",
14
+ factory: (require) => {
15
+ var module = { exports: {} };
16
+ "use strict";
17
+
18
+ var R = require("react");
19
+ var h = R.createElement;
20
+ var useState = R.useState;
21
+ var useEffect = R.useEffect;
22
+ var useRef = R.useRef;
23
+ var useMemo = R.useMemo;
24
+ var useCallback = R.useCallback;
25
+
26
+ // ── file kinds ──────────────────────────────────────────────────────────
27
+
28
+ var VIDEO_EXTS = ["mp4", "m4v", "webm", "ogv", "mov", "qt", "mkv", "avi", "wmv", "flv", "mpeg", "mpg", "3gp", "m2ts"];
29
+ var AUDIO_EXTS = ["mp3", "wav", "m4a", "aac", "oga", "ogg", "opus", "flac", "weba", "aif", "aiff"];
30
+ var HTML_EXTS = ["html", "htm"];
31
+ var IMAGE_EXTS = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"];
32
+
33
+ function extOf(p) {
34
+ var m = /\.([^.\\/]+)$/.exec(p || "");
35
+ return m ? m[1].toLowerCase() : "";
36
+ }
37
+ function kindOf(p) {
38
+ var e = extOf(p);
39
+ if (VIDEO_EXTS.indexOf(e) >= 0) return "video";
40
+ if (AUDIO_EXTS.indexOf(e) >= 0) return "audio";
41
+ if (HTML_EXTS.indexOf(e) >= 0) return "html";
42
+ if (IMAGE_EXTS.indexOf(e) >= 0) return "image";
43
+ return "other";
44
+ }
45
+ function isAbs(p) { return /^([A-Za-z]:[\\/]|[\\/])/.test(p); }
46
+ function absOf(scope, p) {
47
+ if (isAbs(p)) return p;
48
+ var c = (scope && scope.cwd) || "";
49
+ return c ? c.replace(/[\\/]+$/, "") + "/" + p : p;
50
+ }
51
+ function baseName(p) { return (p || "").split(/[\\/]/).filter(Boolean).pop() || p; }
52
+ function dirName(p) { return (p || "").replace(/[\\/][^\\/]*$/, ""); }
53
+
54
+ /** Path-encoded route URL (relative assets of an HTML page resolve inside it). */
55
+ function fileUrl(scope, abs, query) {
56
+ var unc = /^[\\/]{2}[^\\/]/.test(abs);
57
+ var segs = abs.split(/[\\/]+/).filter(Boolean);
58
+ return "/mp/f/" + encodeURIComponent(scope.sessionId) + "/" + (unc ? "/" : "") +
59
+ segs.map(encodeURIComponent).join("/") + (query ? "?" + query : "");
60
+ }
61
+
62
+ function fmtSize(n) {
63
+ if (n == null) return "";
64
+ if (n < 1024) return n + " B";
65
+ if (n < 1048576) return (n / 1024).toFixed(0) + " KB";
66
+ if (n < 1073741824) return (n / 1048576).toFixed(1) + " MB";
67
+ return (n / 1073741824).toFixed(2) + " GB";
68
+ }
69
+ function fmtTime(ms) {
70
+ if (!ms) return "";
71
+ var d = new Date(ms);
72
+ function p(x) { return x < 10 ? "0" + x : "" + x; }
73
+ return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate()) + " " + p(d.getHours()) + ":" + p(d.getMinutes());
74
+ }
75
+ function fmtDur(s) {
76
+ if (!isFinite(s)) return "";
77
+ s = Math.round(s);
78
+ var m = Math.floor(s / 60), r = s % 60;
79
+ return m + ":" + (r < 10 ? "0" + r : r);
80
+ }
81
+
82
+ // ── styling helpers ─────────────────────────────────────────────────────
83
+
84
+ var C = {
85
+ muted: "var(--dsw-alias-label-secondary, #8b90a0)",
86
+ link: "var(--dsw-alias-link-normal, #4c8dff)",
87
+ line: "rgba(127,127,127,.28)",
88
+ soft: "rgba(127,127,127,.10)",
89
+ softer: "rgba(127,127,127,.06)",
90
+ accent: "#7c5cff",
91
+ };
92
+
93
+ var btnBase = {
94
+ display: "inline-flex", alignItems: "center", gap: "4px", padding: "3px 9px",
95
+ fontSize: "12px", lineHeight: "18px", borderRadius: "6px", cursor: "pointer",
96
+ border: "1px solid " + C.line, background: "transparent", color: "inherit",
97
+ textDecoration: "none", whiteSpace: "nowrap", flex: "none",
98
+ };
99
+ function btn(extra) {
100
+ var o = {};
101
+ for (var k in btnBase) o[k] = btnBase[k];
102
+ if (extra) for (var j in extra) o[j] = extra[j];
103
+ return o;
104
+ }
105
+ function Btn(p) {
106
+ return h("button", {
107
+ type: "button", title: p.title, onClick: p.onClick, disabled: p.disabled,
108
+ style: btn(p.active ? { background: C.accent, borderColor: C.accent, color: "#fff" } : p.disabled ? { opacity: 0.5, cursor: "default" } : null),
109
+ }, p.children);
110
+ }
111
+
112
+ function svg(size, children) {
113
+ return h("svg", { width: size, height: size, viewBox: "0 0 16 16", fill: "none", xmlns: "http://www.w3.org/2000/svg", style: { flex: "none" } }, children);
114
+ }
115
+ function pathEl(d, extra) {
116
+ var o = { d: d, stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
117
+ if (extra) for (var k in extra) o[k] = extra[k];
118
+ return h("path", o);
119
+ }
120
+ function IconVideo(size) {
121
+ return svg(size, [
122
+ h("rect", { key: "a", x: 1.5, y: 3, width: 13, height: 10, rx: 2, stroke: "currentColor", strokeWidth: 1.5 }),
123
+ h("path", { key: "b", d: "m6.5 5.9 3.9 2.1-3.9 2.1z", fill: "currentColor" }),
124
+ ]);
125
+ }
126
+ function IconAudio(size) {
127
+ return svg(size, [
128
+ pathEl("M6 11.5V3.5l7-1.5v8", { key: "a" }),
129
+ h("circle", { key: "b", cx: 4.5, cy: 11.5, r: 1.8, stroke: "currentColor", strokeWidth: 1.5 }),
130
+ h("circle", { key: "c", cx: 11.5, cy: 10, r: 1.8, stroke: "currentColor", strokeWidth: 1.5 }),
131
+ ]);
132
+ }
133
+ function IconHtml(size) {
134
+ return svg(size, [pathEl("m5.5 4.5-3.2 3.5 3.2 3.5M10.5 4.5l3.2 3.5-3.2 3.5M9 3l-2 10", { key: "a" })]);
135
+ }
136
+ function IconImage(size) {
137
+ return svg(size, [
138
+ h("rect", { key: "a", x: 1.5, y: 2.5, width: 13, height: 11, rx: 2, stroke: "currentColor", strokeWidth: 1.5 }),
139
+ h("circle", { key: "b", cx: 5.5, cy: 6, r: 1.2, fill: "currentColor" }),
140
+ pathEl("m2 12 4-3.5 3 2.5 2-1.5 3 2.5", { key: "c" }),
141
+ ]);
142
+ }
143
+ function IconFolder(size) {
144
+ return svg(size, [pathEl("M1.8 4.2c0-.7.5-1.2 1.2-1.2h3l1.5 1.7H13c.7 0 1.2.5 1.2 1.2v6.1c0 .7-.5 1.2-1.2 1.2H3c-.7 0-1.2-.5-1.2-1.2z", { key: "a" })]);
145
+ }
146
+ function IconGrid(size) {
147
+ return svg(size, [
148
+ h("rect", { key: "a", x: 2, y: 2, width: 5, height: 5, rx: 1, stroke: "currentColor", strokeWidth: 1.5 }),
149
+ h("rect", { key: "b", x: 9, y: 2, width: 5, height: 5, rx: 1, stroke: "currentColor", strokeWidth: 1.5 }),
150
+ h("rect", { key: "c", x: 2, y: 9, width: 5, height: 5, rx: 1, stroke: "currentColor", strokeWidth: 1.5 }),
151
+ h("rect", { key: "d", x: 9, y: 9, width: 5, height: 5, rx: 1, stroke: "currentColor", strokeWidth: 1.5 }),
152
+ ]);
153
+ }
154
+ function kindIcon(kind, size) {
155
+ return kind === "video" ? IconVideo(size) : kind === "audio" ? IconAudio(size) : kind === "html" ? IconHtml(size) : IconImage(size);
156
+ }
157
+
158
+ // ── shared pieces ───────────────────────────────────────────────────────
159
+
160
+ /** Header row: title (ellipsis) + actions. */
161
+ function Bar(p) {
162
+ return h("div", {
163
+ style: { display: "flex", alignItems: "center", gap: "6px", flex: "none", fontSize: "12px", minWidth: 0, flexWrap: "wrap" },
164
+ }, [
165
+ h("span", { key: "t", title: p.path, style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: "1 1 120px", minWidth: 0, color: C.muted } }, p.title),
166
+ p.children,
167
+ ]);
168
+ }
169
+
170
+ function openGallery(props, abs) {
171
+ try {
172
+ props.ctx.betterSidebar.openTab({ type: "mp:gallery", path: dirName(abs), title: baseName(dirName(abs)) || "媒体画廊" }, props.scope);
173
+ } catch (e) { console.error("[dsh-media-viewer] open gallery failed", e); }
174
+ }
175
+
176
+ function CommonActions(props, abs, url) {
177
+ var out = [
178
+ h("a", { key: "new", href: url, target: "_blank", rel: "noopener", style: btn(), title: "在新窗口打开" }, "新窗口"),
179
+ h("a", { key: "dl", href: fileUrl(props.scope, abs, "download=1"), download: baseName(abs), style: btn(), title: "下载原文件" }, "下载"),
180
+ ];
181
+ if (!props.hideGallery) {
182
+ out.push(h(Btn, { key: "gal", title: "浏览所在文件夹的全部媒体", onClick: function () { openGallery(props, abs); } }, [IconGrid(12), "画廊"]));
183
+ }
184
+ return out;
185
+ }
186
+
187
+ var RATES = [0.5, 1, 1.5, 2];
188
+
189
+ function RatePicker(p) {
190
+ return RATES.map(function (r) {
191
+ return h(Btn, { key: r, active: p.rate === r, title: "播放速度 " + r + "x", onClick: function () { p.set(r); } }, r + "x");
192
+ });
193
+ }
194
+
195
+ function Shell(p) {
196
+ return h("div", {
197
+ style: { display: "flex", flexDirection: "column", gap: "8px", padding: "10px", minWidth: 0, minHeight: 0, height: "100%", boxSizing: "border-box" },
198
+ }, p.children);
199
+ }
200
+
201
+ // ── video ───────────────────────────────────────────────────────────────
202
+
203
+ function VideoView(props) {
204
+ var abs = absOf(props.scope, props.path);
205
+ var url = fileUrl(props.scope, abs);
206
+ var vref = useRef(null);
207
+ var st = useState(1), rate = st[0], setRate = st[1];
208
+ var lp = useState(false), loop = lp[0], setLoop = lp[1];
209
+ var er = useState(null), err = er[0], setErr = er[1];
210
+ var du = useState(null), dur = du[0], setDur = du[1];
211
+ var dims = useState(null), dim = dims[0], setDim = dims[1];
212
+
213
+ useEffect(function () { setErr(null); setDur(null); setDim(null); }, [url]);
214
+ useEffect(function () { if (vref.current) vref.current.playbackRate = rate; }, [rate, url]);
215
+
216
+ var info = [dur != null ? fmtDur(dur) : null, dim ? dim : null, extOf(abs).toUpperCase()].filter(Boolean).join(" · ");
217
+
218
+ return h(Shell, null, [
219
+ h("div", { key: "stage", style: { flex: "1 1 auto", minHeight: 0, display: "flex", background: "#000", borderRadius: "8px", overflow: "hidden" } },
220
+ err
221
+ ? h("div", { style: { margin: "auto", padding: "20px", textAlign: "center", color: "#ddd", fontSize: "13px", lineHeight: 1.7 } }, [
222
+ h("div", { key: 1 }, "浏览器无法解码这个视频"),
223
+ h("div", { key: 2, style: { color: "#999", fontSize: "12px" } }, err),
224
+ h("div", { key: 3, style: { marginTop: "8px" } }, h("a", { href: fileUrl(props.scope, abs, "download=1"), download: baseName(abs), style: { color: C.link } }, "下载后用本地播放器打开")),
225
+ ])
226
+ : h("video", {
227
+ key: url, ref: vref, src: url, controls: true, loop: loop, preload: "metadata", playsInline: true,
228
+ style: { width: "100%", height: "100%", objectFit: "contain", background: "#000", outline: "none" },
229
+ onLoadedMetadata: function (e) {
230
+ setDur(e.target.duration);
231
+ if (e.target.videoWidth) setDim(e.target.videoWidth + "×" + e.target.videoHeight);
232
+ },
233
+ onError: function (e) {
234
+ var code = e.target && e.target.error ? e.target.error.code : 0;
235
+ setErr(code === 4 ? "格式或编码不受支持(常见于 H.265 / 非标准封装)" : "加载失败(错误码 " + code + ")");
236
+ },
237
+ })),
238
+ h(Bar, { key: "bar", title: baseName(abs) + (info ? " · " + info : ""), path: abs }, [
239
+ h(RatePicker, { key: "rate", rate: rate, set: setRate }),
240
+ h(Btn, { key: "loop", active: loop, title: "循环播放", onClick: function () { setLoop(!loop); } }, "循环"),
241
+ CommonActions(props, abs, url),
242
+ ]),
243
+ ]);
244
+ }
245
+
246
+ // ── audio ───────────────────────────────────────────────────────────────
247
+
248
+ function AudioView(props) {
249
+ var abs = absOf(props.scope, props.path);
250
+ var url = fileUrl(props.scope, abs);
251
+ var aref = useRef(null);
252
+ var cref = useRef(null);
253
+ var st = useState(1), rate = st[0], setRate = st[1];
254
+ var lp = useState(false), loop = lp[0], setLoop = lp[1];
255
+ var er = useState(null), err = er[0], setErr = er[1];
256
+ var du = useState(null), dur = du[0], setDur = du[1];
257
+
258
+ useEffect(function () { setErr(null); setDur(null); }, [url]);
259
+ useEffect(function () { if (aref.current) aref.current.playbackRate = rate; }, [rate, url]);
260
+
261
+ // Spectrum bars. Same-origin media, so the analyser is allowed to read it.
262
+ useEffect(function () {
263
+ var a = aref.current, cv = cref.current;
264
+ if (!a || !cv) return undefined;
265
+ var ac, an, data, raf = 0, dead = false;
266
+ function draw() {
267
+ if (dead) return;
268
+ raf = requestAnimationFrame(draw);
269
+ var g = cv.getContext("2d");
270
+ var w = cv.width, hh = cv.height;
271
+ g.clearRect(0, 0, w, hh);
272
+ var n = data.length, bw = w / n;
273
+ an.getByteFrequencyData(data);
274
+ var grad = g.createLinearGradient(0, hh, 0, 0);
275
+ grad.addColorStop(0, "#7c5cff");
276
+ grad.addColorStop(1, "#22d3ee");
277
+ g.fillStyle = grad;
278
+ for (var i = 0; i < n; i++) {
279
+ var bh = Math.max(2, (data[i] / 255) * hh);
280
+ g.fillRect(i * bw + 1, hh - bh, Math.max(1, bw - 2), bh);
281
+ }
282
+ }
283
+ function start() {
284
+ if (ac) { if (ac.state === "suspended") ac.resume(); return; }
285
+ try {
286
+ var AC = window.AudioContext || window.webkitAudioContext;
287
+ ac = new AC();
288
+ var src = ac.createMediaElementSource(a);
289
+ an = ac.createAnalyser();
290
+ an.fftSize = 128;
291
+ an.smoothingTimeConstant = 0.8;
292
+ src.connect(an);
293
+ an.connect(ac.destination);
294
+ data = new Uint8Array(an.frequencyBinCount);
295
+ draw();
296
+ } catch (e) { /* visualiser is decoration; playback must not depend on it */ }
297
+ }
298
+ a.addEventListener("play", start);
299
+ return function () {
300
+ dead = true;
301
+ cancelAnimationFrame(raf);
302
+ a.removeEventListener("play", start);
303
+ try { if (ac) ac.close(); } catch (e) { /* ignore */ }
304
+ };
305
+ }, [url]);
306
+
307
+ return h(Shell, null, [
308
+ h("div", {
309
+ key: "stage",
310
+ style: {
311
+ flex: "1 1 auto", minHeight: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
312
+ gap: "14px", padding: "16px", borderRadius: "10px", background: "linear-gradient(145deg, rgba(124,92,255,.14), rgba(34,211,238,.10))",
313
+ },
314
+ }, [
315
+ h("div", { key: "ic", style: { width: "72px", height: "72px", borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center", background: "linear-gradient(135deg,#7c5cff,#22d3ee)", color: "#fff" } }, IconAudio(34)),
316
+ h("div", { key: "nm", style: { fontSize: "14px", fontWeight: 600, textAlign: "center", wordBreak: "break-all" } }, baseName(abs)),
317
+ h("canvas", { key: "cv", ref: cref, width: 320, height: 64, style: { width: "100%", maxWidth: "320px", height: "64px" } }),
318
+ err
319
+ ? h("div", { key: "er", style: { color: C.muted, fontSize: "12px" } }, err)
320
+ : h("audio", {
321
+ key: url, ref: aref, src: url, controls: true, loop: loop, preload: "metadata", style: { width: "100%", maxWidth: "420px" },
322
+ onLoadedMetadata: function (e) { setDur(e.target.duration); },
323
+ onError: function () { setErr("这个音频浏览器无法解码"); },
324
+ }),
325
+ ]),
326
+ h(Bar, { key: "bar", title: baseName(abs) + " · " + [dur != null ? fmtDur(dur) : null, extOf(abs).toUpperCase()].filter(Boolean).join(" · "), path: abs }, [
327
+ h(RatePicker, { key: "rate", rate: rate, set: setRate }),
328
+ h(Btn, { key: "loop", active: loop, title: "循环播放", onClick: function () { setLoop(!loop); } }, "循环"),
329
+ CommonActions(props, abs, url),
330
+ ]),
331
+ ]);
332
+ }
333
+
334
+ // ── html ────────────────────────────────────────────────────────────────
335
+
336
+ var VIEWPORTS = [
337
+ { id: "fit", label: "自适应", w: null },
338
+ { id: "phone", label: "手机 390", w: 390 },
339
+ { id: "pad", label: "平板 768", w: 768 },
340
+ { id: "desk", label: "桌面 1280", w: 1280 },
341
+ ];
342
+
343
+ function HtmlView(props) {
344
+ var abs = absOf(props.scope, props.path);
345
+ var url = fileUrl(props.scope, abs);
346
+ var md = useState("render"), mode = md[0], setMode = md[1];
347
+ var sc = useState(true), scripts = sc[0], setScripts = sc[1];
348
+ var vp = useState("fit"), view = vp[0], setView = vp[1];
349
+ var rk = useState(0), reloadKey = rk[0], setReloadKey = rk[1];
350
+ var sr = useState({ state: "idle", text: "" }), source = sr[0], setSource = sr[1];
351
+
352
+ var frameRef = useRef(null);
353
+
354
+ useEffect(function () { setMode("render"); setSource({ state: "idle", text: "" }); }, [url]);
355
+
356
+ // Navigate the frame only after it exists WITH its sandbox attribute.
357
+ // The iframe is re-keyed whenever url / reloadKey / scripts change, so
358
+ // this runs against the fresh element each time.
359
+ useEffect(function () {
360
+ if (mode === "render" && frameRef.current) frameRef.current.src = url + "?v=" + reloadKey;
361
+ }, [mode, url, reloadKey, scripts]);
362
+
363
+ useEffect(function () {
364
+ if (mode !== "source") return undefined;
365
+ var ctl = new AbortController();
366
+ setSource({ state: "loading", text: "" });
367
+ fetch(url + "?v=" + reloadKey, { signal: ctl.signal })
368
+ .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); })
369
+ .then(function (t) { setSource({ state: "ok", text: t.length > 2000000 ? t.slice(0, 2000000) + "\n\n… (已截断,文件过大)" : t }); })
370
+ .catch(function (e) { if (e.name !== "AbortError") setSource({ state: "error", text: String(e.message || e) }); });
371
+ return function () { ctl.abort(); };
372
+ }, [mode, url, reloadKey]);
373
+
374
+ var sandbox = "allow-popups allow-downloads allow-modals allow-forms allow-pointer-lock" + (scripts ? " allow-scripts" : "");
375
+ var vw = VIEWPORTS.filter(function (v) { return v.id === view; })[0].w;
376
+
377
+ var stage;
378
+ if (mode === "source") {
379
+ stage = h("pre", {
380
+ style: { margin: 0, padding: "10px", flex: "1 1 auto", minHeight: 0, overflow: "auto", fontSize: "12px", lineHeight: 1.55, whiteSpace: "pre", tabSize: 2, fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", background: C.softer, borderRadius: "8px" },
381
+ }, source.state === "loading" ? "加载中…" : source.state === "error" ? "读取失败:" + source.text : source.text);
382
+ } else {
383
+ stage = h("div", {
384
+ style: { flex: "1 1 auto", minHeight: 0, overflow: "auto", display: "flex", justifyContent: "center", background: vw ? C.soft : "transparent", borderRadius: "8px" },
385
+ }, h("iframe", {
386
+ key: url + ":" + reloadKey + ":" + (scripts ? "s" : "n"),
387
+ ref: frameRef,
388
+ // NO src here: the browser snapshots the sandbox flags when the
389
+ // frame first navigates, so `src` must be assigned only after the
390
+ // element exists with its sandbox attribute (see the effect above).
391
+ title: baseName(abs),
392
+ sandbox: sandbox,
393
+ allow: "autoplay; fullscreen; clipboard-write",
394
+ allowFullScreen: true,
395
+ referrerPolicy: "no-referrer",
396
+ style: { width: vw ? vw + "px" : "100%", maxWidth: "none", height: "100%", border: "1px solid " + C.line, borderRadius: "8px", background: "#fff", flex: "none" },
397
+ }));
398
+ }
399
+
400
+ return h(Shell, null, [
401
+ stage,
402
+ h(Bar, { key: "bar", title: baseName(abs), path: abs }, [
403
+ h(Btn, { key: "m1", active: mode === "render", title: "渲染预览", onClick: function () { setMode("render"); } }, "预览"),
404
+ h(Btn, { key: "m2", active: mode === "source", title: "查看源码", onClick: function () { setMode("source"); } }, "源码"),
405
+ mode === "render" ? VIEWPORTS.map(function (v) {
406
+ return h(Btn, { key: v.id, active: view === v.id, title: "预览宽度:" + v.label, onClick: function () { setView(v.id); } }, v.label);
407
+ }) : null,
408
+ h(Btn, { key: "js", active: scripts, title: scripts ? "脚本已允许,点击关闭(页面将重新加载)" : "脚本已禁用,点击允许", onClick: function () { setScripts(!scripts); } }, scripts ? "脚本 开" : "脚本 关"),
409
+ h(Btn, { key: "rl", title: "重新加载", onClick: function () { setReloadKey(reloadKey + 1); } }, "刷新"),
410
+ CommonActions(props, abs, url),
411
+ ]),
412
+ ]);
413
+ }
414
+
415
+ // ── gallery ─────────────────────────────────────────────────────────────
416
+
417
+ function Thumb(p) {
418
+ var it = p.item, scope = p.scope;
419
+ var ref = useRef(null);
420
+ var vs = useState(false), vis = vs[0], setVis = vs[1];
421
+ var vid = useRef(null);
422
+ useEffect(function () {
423
+ var el = ref.current;
424
+ if (!el || typeof IntersectionObserver === "undefined") { setVis(true); return undefined; }
425
+ var io = new IntersectionObserver(function (es) { if (es[0].isIntersecting) { setVis(true); io.disconnect(); } }, { rootMargin: "240px" });
426
+ io.observe(el);
427
+ return function () { io.disconnect(); };
428
+ }, []);
429
+
430
+ var url = fileUrl(scope, it.abs);
431
+ var inner;
432
+ if (!vis) inner = null;
433
+ else if (it.kind === "image") inner = h("img", { src: url, loading: "lazy", draggable: false, style: { width: "100%", height: "100%", objectFit: "cover" } });
434
+ 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" } });
435
+ else inner = h("div", {
436
+ style: { width: "100%", height: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "6px",
437
+ 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))" },
438
+ }, [kindIcon(it.kind, 28), h("span", { key: "e", style: { fontSize: "11px", opacity: 0.8, fontWeight: 600 } }, it.ext.toUpperCase())]);
439
+
440
+ return h("div", {
441
+ ref: ref,
442
+ onMouseEnter: function () { var v = vid.current; if (v) { v.currentTime = 0; var pr = v.play(); if (pr && pr.catch) pr.catch(function () {}); } },
443
+ onMouseLeave: function () { var v = vid.current; if (v) { v.pause(); v.currentTime = 0.5; } },
444
+ style: { position: "relative", width: "100%", aspectRatio: "16 / 10", background: C.soft, overflow: "hidden", borderRadius: "8px 8px 0 0" },
445
+ }, [
446
+ inner,
447
+ h("span", { key: "badge", style: { position: "absolute", left: "6px", top: "6px", display: "inline-flex", alignItems: "center", gap: "3px", padding: "1px 6px", borderRadius: "10px", fontSize: "10px", background: "rgba(0,0,0,.55)", color: "#fff" } }, [kindIcon(it.kind, 10), it.ext.toUpperCase()]),
448
+ ]);
449
+ }
450
+
451
+ var FILTERS = [
452
+ { id: "all", label: "全部" },
453
+ { id: "video", label: "视频" },
454
+ { id: "audio", label: "音频" },
455
+ { id: "image", label: "图片" },
456
+ { id: "html", label: "网页" },
457
+ ];
458
+ var SIZES = [{ id: 150, label: "小" }, { id: 210, label: "中" }, { id: 300, label: "大" }];
459
+
460
+ function Lightbox(p) {
461
+ var it = p.item;
462
+ var vprops = { ctx: p.ctx, store: p.store, scope: p.scope, path: it.abs, title: it.name, hideGallery: true };
463
+ var body;
464
+ if (it.kind === "video") body = h(VideoView, vprops);
465
+ else if (it.kind === "audio") body = h(AudioView, vprops);
466
+ else if (it.kind === "html") body = h(HtmlView, vprops);
467
+ else body = h("div", { style: { height: "100%", display: "flex", padding: "10px", boxSizing: "border-box" } },
468
+ h("img", { src: fileUrl(p.scope, it.abs), style: { margin: "auto", maxWidth: "100%", maxHeight: "100%", objectFit: "contain" } }));
469
+
470
+ 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
+ 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))" },
473
+ }, [
474
+ h("div", { key: "top", style: { display: "flex", alignItems: "center", gap: "8px", padding: "8px 10px", borderBottom: "1px solid " + C.line, fontSize: "12px", flex: "none" } }, [
475
+ h(Btn, { key: "x", title: "返回画廊 (Esc)", onClick: p.onClose }, "← 返回"),
476
+ h("span", { key: "t", style: { flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: 600 } }, it.name),
477
+ h("span", { key: "n", style: { color: C.muted } }, (p.index + 1) + " / " + p.total + " · " + fmtSize(it.size)),
478
+ ]),
479
+ h("div", { key: "body", style: { position: "relative", flex: "1 1 auto", minHeight: 0 } }, [
480
+ p.index > 0 ? h("button", { key: "prev", type: "button", title: "上一个 (←)", onClick: p.onPrev, style: Object.assign({ left: "4px" }, nav) }, "‹") : null,
481
+ p.index < p.total - 1 ? h("button", { key: "next", type: "button", title: "下一个 (→)", onClick: p.onNext, style: Object.assign({ right: "4px" }, nav) }, "›") : null,
482
+ h("div", { key: "c", style: { position: "absolute", inset: 0 } }, body),
483
+ ]),
484
+ ]);
485
+ }
486
+
487
+ function GalleryTab(props) {
488
+ var scope = props.scope;
489
+ var seed = (props.tab && props.tab.path) || "";
490
+ var cu = useState(seed), cur = cu[0], setCur = cu[1];
491
+ var rc = useState(false), recursive = rc[0], setRecursive = rc[1];
492
+ var dt = useState(null), data = dt[0], setData = dt[1];
493
+ var ls = useState(true), loading = ls[0], setLoading = ls[1];
494
+ var er = useState(null), error = er[0], setError = er[1];
495
+ var fl = useState("all"), filter = fl[0], setFilter = fl[1];
496
+ var qs = useState(""), query = qs[0], setQuery = qs[1];
497
+ var so = useState("mtime"), sort = so[0], setSort = so[1];
498
+ var sz = useState(210), size = sz[0], setSize = sz[1];
499
+ var se = useState(-1), sel = se[0], setSel = se[1];
500
+ var tk = useState(0), tick = tk[0], setTick = tk[1];
501
+
502
+ // Follow a re-seeded tab (opened again for another folder).
503
+ useEffect(function () { setCur(seed); }, [seed]);
504
+
505
+ useEffect(function () {
506
+ var ctl = new AbortController();
507
+ setLoading(true);
508
+ setError(null);
509
+ var q = "sessionId=" + encodeURIComponent(scope.sessionId) + (cur ? "&dir=" + encodeURIComponent(cur) : "") + (recursive ? "&recursive=1" : "");
510
+ fetch("/mp/list?" + q, { signal: ctl.signal })
511
+ .then(function (r) { return r.json().then(function (j) { if (!r.ok || !j.ok) throw new Error((j && j.error && j.error.message) || "HTTP " + r.status); return j; }); })
512
+ .then(function (j) { setData(j); setLoading(false); })
513
+ .catch(function (e) { if (e.name !== "AbortError") { setError(String(e.message || e)); setLoading(false); } });
514
+ return function () { ctl.abort(); };
515
+ }, [scope.sessionId, cur, recursive, tick]);
516
+
517
+ var items = useMemo(function () {
518
+ if (!data) return [];
519
+ var q = query.trim().toLowerCase();
520
+ var list = data.files.filter(function (f) {
521
+ return (filter === "all" || f.kind === filter) && (q === "" || f.rel.toLowerCase().indexOf(q) >= 0);
522
+ });
523
+ list.sort(function (a, b) {
524
+ if (sort === "name") return a.rel.localeCompare(b.rel, "zh", { numeric: true });
525
+ if (sort === "size") return b.size - a.size;
526
+ return b.mtime - a.mtime;
527
+ });
528
+ return list;
529
+ }, [data, filter, query, sort]);
530
+
531
+ var counts = useMemo(function () {
532
+ var c = { all: 0, video: 0, audio: 0, image: 0, html: 0 };
533
+ if (data) data.files.forEach(function (f) { c.all++; c[f.kind]++; });
534
+ return c;
535
+ }, [data]);
536
+
537
+ // Keyboard: Esc / arrows while the lightbox is open.
538
+ useEffect(function () {
539
+ if (sel < 0 || props.visible === false) return undefined;
540
+ function onKey(e) {
541
+ if (e.key === "Escape") { setSel(-1); e.stopPropagation(); }
542
+ else if (e.key === "ArrowLeft") setSel(function (s) { return Math.max(0, s - 1); });
543
+ else if (e.key === "ArrowRight") setSel(function (s) { return Math.min(items.length - 1, s + 1); });
544
+ }
545
+ window.addEventListener("keydown", onKey, true);
546
+ return function () { window.removeEventListener("keydown", onKey, true); };
547
+ }, [sel, items.length, props.visible]);
548
+
549
+ useEffect(function () { setSel(-1); }, [cur, recursive, filter, query]);
550
+
551
+ var dir = data ? data.dir : cur;
552
+ var root = data ? data.cwd : "";
553
+ var crumbs = [];
554
+ if (data) {
555
+ var rel = data.dir.replace(/\\/g, "/").slice(data.cwd.replace(/\\/g, "/").length).split("/").filter(Boolean);
556
+ var acc = data.cwd;
557
+ crumbs.push({ name: baseName(data.cwd) || data.cwd, abs: data.cwd });
558
+ rel.forEach(function (seg) { acc = acc.replace(/[\\/]+$/, "") + "/" + seg; crumbs.push({ name: seg, abs: acc }); });
559
+ }
560
+
561
+ var chip = function (active) {
562
+ return btn(active ? { background: C.accent, borderColor: C.accent, color: "#fff" } : null);
563
+ };
564
+
565
+ var header = h("div", { key: "head", style: { flex: "none", display: "flex", flexDirection: "column", gap: "7px", padding: "9px 10px", borderBottom: "1px solid " + C.line } }, [
566
+ h("div", { key: "r1", style: { display: "flex", alignItems: "center", gap: "4px", fontSize: "12px", flexWrap: "wrap", minWidth: 0 } }, [
567
+ crumbs.map(function (c, i) {
568
+ return h(R.Fragment, { key: c.abs }, [
569
+ i > 0 ? h("span", { key: "s", style: { color: C.muted } }, "/") : null,
570
+ h("button", { key: "b", type: "button", onClick: function () { setCur(c.abs); }, title: c.abs, style: { border: "none", background: "transparent", color: i === crumbs.length - 1 ? "inherit" : C.link, cursor: "pointer", padding: "1px 3px", fontSize: "12px", fontWeight: i === crumbs.length - 1 ? 600 : 400 } }, c.name),
571
+ ]);
572
+ }),
573
+ h("span", { key: "sp", style: { flex: 1 } }),
574
+ h(Btn, { key: "rf", title: "重新扫描文件夹", onClick: function () { setTick(tick + 1); } }, "刷新"),
575
+ ]),
576
+ h("div", { key: "r2", style: { display: "flex", alignItems: "center", gap: "5px", flexWrap: "wrap" } }, [
577
+ FILTERS.map(function (f) {
578
+ return h("button", { key: f.id, type: "button", onClick: function () { setFilter(f.id); }, style: chip(filter === f.id) }, f.label + " " + (counts[f.id] || 0));
579
+ }),
580
+ h("input", { key: "q", type: "search", placeholder: "搜索文件名", value: query, onChange: function (e) { setQuery(e.target.value); }, style: { flex: "1 1 110px", minWidth: "90px", padding: "3px 8px", fontSize: "12px", borderRadius: "6px", border: "1px solid " + C.line, background: "transparent", color: "inherit" } }),
581
+ ]),
582
+ h("div", { key: "r3", style: { display: "flex", alignItems: "center", gap: "10px", fontSize: "12px", flexWrap: "wrap", color: C.muted } }, [
583
+ h("label", { key: "rec", style: { display: "inline-flex", alignItems: "center", gap: "4px", cursor: "pointer" } }, [
584
+ h("input", { key: "i", type: "checkbox", checked: recursive, onChange: function (e) { setRecursive(e.target.checked); } }), "包含子文件夹",
585
+ ]),
586
+ h("label", { key: "so", style: { display: "inline-flex", alignItems: "center", gap: "4px" } }, [
587
+ "排序",
588
+ h("select", { key: "s", value: sort, onChange: function (e) { setSort(e.target.value); }, style: { fontSize: "12px", background: "transparent", color: "inherit", border: "1px solid " + C.line, borderRadius: "5px", padding: "1px 4px" } }, [
589
+ h("option", { key: 1, value: "mtime" }, "最近修改"), h("option", { key: 2, value: "name" }, "名称"), h("option", { key: 3, value: "size" }, "大小"),
590
+ ]),
591
+ ]),
592
+ h("span", { key: "sz", style: { display: "inline-flex", alignItems: "center", gap: "4px" } }, ["缩略图", SIZES.map(function (s) {
593
+ return h("button", { key: s.id, type: "button", onClick: function () { setSize(s.id); }, style: chip(size === s.id) }, s.label);
594
+ })]),
595
+ h("span", { key: "ct", style: { marginLeft: "auto" } }, items.length + " 项" + (data && data.truncated ? "(已达上限,结果被截断)" : "")),
596
+ ]),
597
+ ]);
598
+
599
+ var body;
600
+ if (error) {
601
+ body = h("div", { key: "body", style: { margin: "auto", padding: "24px", textAlign: "center", color: C.muted, fontSize: "13px", lineHeight: 1.7 } }, ["读取文件夹失败", h("br", { key: "b" }), error]);
602
+ } else if (!data && loading) {
603
+ body = h("div", { key: "body", style: { margin: "auto", color: C.muted, fontSize: "13px" } }, "扫描中…");
604
+ } else {
605
+ var dirCards = recursive ? [] : data.dirs.map(function (d) {
606
+ return h("button", { key: "d:" + d.abs, type: "button", onClick: function () { setCur(d.abs); }, title: d.abs, style: { display: "flex", alignItems: "center", gap: "8px", padding: "10px", borderRadius: "8px", border: "1px dashed " + C.line, background: C.softer, color: "inherit", cursor: "pointer", textAlign: "left", fontSize: "12px", minWidth: 0 } }, [
607
+ IconFolder(16), h("span", { key: "n", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, d.name),
608
+ ]);
609
+ });
610
+ var cards = items.map(function (it, i) {
611
+ 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 } }, [
612
+ h(Thumb, { key: "t", item: it, scope: scope }),
613
+ h("div", { key: "m", style: { padding: "5px 8px 6px", fontSize: "12px", minWidth: 0 } }, [
614
+ h("div", { key: "n", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: 600 } }, recursive ? it.rel : it.name),
615
+ h("div", { key: "i", style: { color: C.muted, fontSize: "11px", marginTop: "1px" } }, fmtSize(it.size) + " · " + fmtTime(it.mtime)),
616
+ ]),
617
+ ]);
618
+ });
619
+ body = h("div", { key: "body", style: { flex: "1 1 auto", minHeight: 0, overflow: "auto", padding: "10px" } }, [
620
+ dirCards.length > 0 ? h("div", { key: "dirs", style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: "8px", marginBottom: "10px" } }, dirCards) : null,
621
+ cards.length > 0
622
+ ? h("div", { key: "grid", style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(" + size + "px, 1fr))", gap: "10px", opacity: loading ? 0.55 : 1 } }, cards)
623
+ : h("div", { key: "empty", style: { padding: "40px 10px", textAlign: "center", color: C.muted, fontSize: "13px" } }, data.files.length === 0 ? "这个文件夹里没有视频、音频、图片或网页文件" : "没有符合筛选条件的文件"),
624
+ ]);
625
+ }
626
+
627
+ return h("div", { style: { position: "relative", height: "100%", display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 } }, [
628
+ header,
629
+ body,
630
+ sel >= 0 && items[sel]
631
+ ? h(Lightbox, {
632
+ key: "lb", ctx: props.ctx, store: props.store, scope: scope, item: items[sel], index: sel, total: items.length,
633
+ onClose: function () { setSel(-1); },
634
+ onPrev: function () { setSel(Math.max(0, sel - 1)); },
635
+ onNext: function () { setSel(Math.min(items.length - 1, sel + 1)); },
636
+ })
637
+ : null,
638
+ ]);
639
+ }
640
+
641
+ // ── registration ────────────────────────────────────────────────────────
642
+
643
+ var inject = ["betterSidebar"];
644
+
645
+ function apply(ctx) {
646
+ // Each registration returns a disposer; wrapping in ctx.effect makes
647
+ // cordis call it on teardown (HMR / plugin disable) so re-activation
648
+ // does not throw "already registered".
649
+ ctx.effect(function () {
650
+ return ctx.betterSidebar.registerFileViewer({
651
+ id: "mp:video", title: "视频播放器", icon: IconVideo, exts: VIDEO_EXTS, priority: 10, fetchStrategy: "none", component: VideoView,
652
+ });
653
+ });
654
+ ctx.effect(function () {
655
+ return ctx.betterSidebar.registerFileViewer({
656
+ id: "mp:audio", title: "音频播放器", icon: IconAudio, exts: AUDIO_EXTS, priority: 10, fetchStrategy: "none", component: AudioView,
657
+ });
658
+ });
659
+ ctx.effect(function () {
660
+ return ctx.betterSidebar.registerFileViewer({
661
+ id: "mp:html", title: "网页预览(允许脚本)", icon: IconHtml, exts: HTML_EXTS, priority: 10, fetchStrategy: "none", component: HtmlView,
662
+ });
663
+ });
664
+ ctx.effect(function () {
665
+ return ctx.betterSidebar.registerTab({
666
+ id: "mp:gallery",
667
+ title: "媒体画廊",
668
+ description: "以缩略图浏览文件夹里的视频、音频、图片和网页",
669
+ icon: IconGrid,
670
+ order: 60,
671
+ dedupeKey: function (tab) { return tab.path || "mp:gallery"; },
672
+ component: GalleryTab,
673
+ });
674
+ });
675
+ }
676
+
677
+ module.exports = { inject: inject, apply: apply };
678
+ return module.exports;
679
+ },
680
+ });
@@ -0,0 +1,7 @@
1
+ # @vibedev-si/dsh-media-viewer bundle patch: one `insert` row mounting the plugin.
2
+ # The entry `name` must equal the package name so the client-modules scan
3
+ # resolves the package's ./client bundle and serves it at
4
+ # /plugins/@vibedev-si/dsh-media-viewer/client.js.
5
+ - insert:
6
+ - id: media-viewer
7
+ name: '@vibedev-si/dsh-media-viewer'
package/icon.svg ADDED
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2
+ <defs>
3
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#7C5CFF"/>
5
+ <stop offset="1" stop-color="#22D3EE"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="64" height="64" rx="14" fill="url(#g)"/>
9
+ <rect x="10" y="12" width="44" height="30" rx="6" fill="#FFFFFF"/>
10
+ <path d="M28 20v14l12-7z" fill="#7C5CFF"/>
11
+ <rect x="10" y="47" width="12" height="7" rx="2.5" fill="#FFFFFF" opacity="0.95"/>
12
+ <rect x="26" y="47" width="12" height="7" rx="2.5" fill="#FFFFFF" opacity="0.8"/>
13
+ <rect x="42" y="47" width="12" height="7" rx="2.5" fill="#FFFFFF" opacity="0.6"/>
14
+ </svg>
package/index.js ADDED
@@ -0,0 +1,407 @@
1
+ /**
2
+ * dsh-media-viewer — host half.
3
+ *
4
+ * Two routes, both under the single prefix `/mp`:
5
+ *
6
+ * GET|HEAD /mp/f/<sessionId>/<absolute path segments>
7
+ * Streams one file from the session working directory. Supports HTTP
8
+ * Range (206), so <video>/<audio> can seek, and has NO size cap (the
9
+ * built-in /sidebar/file route reads whole files into memory and refuses
10
+ * anything over 20MB).
11
+ * The path is encoded into the URL PATH (not the query) so that a
12
+ * previewed HTML page's relative assets (./style.css, clip.mp4, img/a.png)
13
+ * resolve back into this same route with the session intact.
14
+ *
15
+ * GET /mp/list?sessionId=<id>[&dir=<path>][&recursive=1]
16
+ * JSON listing of the media files (video/audio/image/html) in a folder,
17
+ * for the gallery tab.
18
+ *
19
+ * Security posture:
20
+ * - Host header must be loopback or a configured trusted authority (DNS
21
+ * rebinding defence), same as the /api gateway.
22
+ * - Every path must resolve (symlinks included) inside the session's working
23
+ * directory.
24
+ * - The listing route and top-level document loads keep the strict
25
+ * same-origin fence. The FILE route relaxes exactly one rule: a sandboxed
26
+ * preview iframe has an opaque origin, so its sub-resource requests carry
27
+ * `Sec-Fetch-Site: cross-site` and `Origin: null`. Those are accepted for
28
+ * sub-resource destinations only, and only for a live session id, which
29
+ * acts as the capability.
30
+ * - Every file response carries a CSP `sandbox` directive, so even a
31
+ * top-level load of an HTML file stays in an opaque origin.
32
+ */
33
+ import { createReadStream } from 'node:fs'
34
+ import { readdir, realpath, stat } from 'node:fs/promises'
35
+ import { basename, extname, resolve } from 'node:path'
36
+
37
+ export const name = '@vibedev-si/dsh-media-viewer'
38
+
39
+ /** Services required before mounting. */
40
+ export const inject = ['webServer', 'sessions', 'webRuntime']
41
+
42
+ // ── content types ───────────────────────────────────────────────────────────
43
+
44
+ const TYPES = {
45
+ // video
46
+ '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.webm': 'video/webm', '.ogv': 'video/ogg',
47
+ '.mov': 'video/quicktime', '.qt': 'video/quicktime', '.mkv': 'video/x-matroska',
48
+ '.avi': 'video/x-msvideo', '.wmv': 'video/x-ms-wmv', '.flv': 'video/x-flv',
49
+ '.mpeg': 'video/mpeg', '.mpg': 'video/mpeg', '.3gp': 'video/3gpp', '.m2ts': 'video/mp2t',
50
+ // audio
51
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.aac': 'audio/aac',
52
+ '.oga': 'audio/ogg', '.ogg': 'audio/ogg', '.opus': 'audio/ogg', '.flac': 'audio/flac',
53
+ '.weba': 'audio/webm', '.aif': 'audio/aiff', '.aiff': 'audio/aiff',
54
+ // image
55
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
56
+ '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.ico': 'image/x-icon',
57
+ '.avif': 'image/avif',
58
+ // web
59
+ '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8',
60
+ '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
61
+ '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8',
62
+ '.txt': 'text/plain; charset=utf-8', '.md': 'text/plain; charset=utf-8',
63
+ '.vtt': 'text/vtt; charset=utf-8', '.srt': 'text/plain; charset=utf-8',
64
+ '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf',
65
+ '.wasm': 'application/wasm',
66
+ }
67
+
68
+ const KINDS = {
69
+ video: new Set(['.mp4', '.m4v', '.webm', '.ogv', '.mov', '.qt', '.mkv', '.avi', '.wmv', '.flv', '.mpeg', '.mpg', '.3gp', '.m2ts']),
70
+ audio: new Set(['.mp3', '.wav', '.m4a', '.aac', '.oga', '.ogg', '.opus', '.flac', '.weba', '.aif', '.aiff']),
71
+ image: new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']),
72
+ html: new Set(['.html', '.htm']),
73
+ }
74
+
75
+ function kindOfExt(ext) {
76
+ for (const kind of Object.keys(KINDS)) if (KINDS[kind].has(ext)) return kind
77
+ return undefined
78
+ }
79
+
80
+ const SANDBOX_CSP = "sandbox allow-scripts allow-popups allow-downloads allow-modals allow-forms; object-src 'none'"
81
+
82
+ // ── browser-trust fence ─────────────────────────────────────────────────────
83
+
84
+ const header = (headers, key) => (typeof headers[key] === 'string' ? headers[key] : undefined)
85
+
86
+ function parseAuthority(authority) {
87
+ try { return new URL(`http://${authority}`) } catch { return undefined }
88
+ }
89
+
90
+ function isLoopbackHostname(hostname) {
91
+ if (hostname === 'localhost' || hostname === '[::1]') return true
92
+ const parts = hostname.split('.')
93
+ return parts.length === 4 && parts[0] === '127'
94
+ && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)
95
+ }
96
+
97
+ function isTrustedAuthority(hostUrl, trustedHosts) {
98
+ return trustedHosts.some((entry) => {
99
+ const entryUrl = parseAuthority(entry)
100
+ if (entryUrl === undefined) return false
101
+ return entryUrl.port === '' ? entryUrl.hostname === hostUrl.hostname : entryUrl.host === hostUrl.host
102
+ })
103
+ }
104
+
105
+ /** Sub-resource destinations a previewed page legitimately requests. */
106
+ const SUBRESOURCE_DESTS = new Set([
107
+ '', 'image', 'script', 'style', 'video', 'audio', 'track', 'font', 'manifest',
108
+ 'worker', 'sharedworker', 'empty', 'iframe', 'frame', 'object', 'embed',
109
+ ])
110
+
111
+ /**
112
+ * @param relaxed - true for the file route: also admit opaque-origin
113
+ * (sandboxed iframe) sub-resource requests.
114
+ */
115
+ function trusted(req, trustedHosts, relaxed) {
116
+ const host = header(req.headers, 'host')
117
+ if (host === undefined) return false
118
+ const hostUrl = parseAuthority(host)
119
+ if (hostUrl === undefined) return false
120
+ if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
121
+
122
+ const site = header(req.headers, 'sec-fetch-site')
123
+ const origin = header(req.headers, 'origin')
124
+ if (site === 'cross-site') {
125
+ if (!relaxed) return false
126
+ if (!SUBRESOURCE_DESTS.has(header(req.headers, 'sec-fetch-dest') ?? '')) return false
127
+ return origin === undefined || origin === 'null'
128
+ }
129
+ if (origin === undefined) return true
130
+ if (relaxed && origin === 'null') return true
131
+ try { return new URL(origin).hostname === hostUrl.hostname } catch { return false }
132
+ }
133
+
134
+ // ── paths ───────────────────────────────────────────────────────────────────
135
+
136
+ class HttpError extends Error {
137
+ constructor(status, message) {
138
+ super(message)
139
+ this.status = status
140
+ }
141
+ }
142
+
143
+ const norm = (v) => v.replace(/[\\/]+/g, '/').replace(/\/$/, '')
144
+
145
+ function isWithin(base, target) {
146
+ const b = norm(base)
147
+ const t = norm(target)
148
+ if (process.platform === 'win32') {
149
+ const lb = b.toLowerCase()
150
+ const lt = t.toLowerCase()
151
+ return lt === lb || lt.startsWith(`${lb}/`)
152
+ }
153
+ return t === b || t.startsWith(`${b}/`)
154
+ }
155
+
156
+ function requireAbsolute(path) {
157
+ if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
158
+ throw new HttpError(400, `"${path}" is not an absolute path`)
159
+ }
160
+ return resolve(path)
161
+ }
162
+
163
+ /** `/mp/f/<sid>/<segments>` -> { sessionId, path }. UNC marker and drive letter handled like better-sidebar. */
164
+ function decodeFilePath(pathname) {
165
+ const PREFIX = '/mp/f/'
166
+ if (!pathname.startsWith(PREFIX)) throw new HttpError(404, 'not a file route')
167
+ let segments
168
+ try {
169
+ segments = pathname.slice(PREFIX.length).split('/').map((s) => decodeURIComponent(s))
170
+ } catch {
171
+ throw new HttpError(400, 'malformed URL encoding')
172
+ }
173
+ const [sessionId, ...rest] = segments
174
+ if (!sessionId) throw new HttpError(400, 'sessionId is required')
175
+ const unc = rest[0] === ''
176
+ const tail = unc ? rest.slice(1) : rest
177
+ if (tail.length === 0 || tail.some((s) => s === '')) throw new HttpError(400, 'file path is required')
178
+ let path
179
+ if (unc) path = `//${tail.join('/')}`
180
+ else if (/^[A-Za-z]:$/.test(tail[0])) path = tail.join('/')
181
+ else path = `/${tail.join('/')}`
182
+ return { sessionId, path }
183
+ }
184
+
185
+ /** The live session's authoritative working directory. */
186
+ function liveCwd(ctx, sessionId) {
187
+ const cwd = ctx.sessions.get(sessionId)?.header?.cwd
188
+ if (typeof cwd !== 'string' || cwd === '') {
189
+ throw new HttpError(409, 'session is not attached; reload the page and reopen the file')
190
+ }
191
+ return cwd
192
+ }
193
+
194
+ /** Resolve `target` and prove (symlinks included) that it lives inside `cwd`. */
195
+ async function confine(cwd, target) {
196
+ const abs = requireAbsolute(target)
197
+ if (!isWithin(cwd, abs)) throw new HttpError(403, 'path is outside the session working directory')
198
+ let real
199
+ let realCwd
200
+ try {
201
+ real = await realpath(abs)
202
+ realCwd = await realpath(cwd)
203
+ } catch {
204
+ throw new HttpError(404, 'not found')
205
+ }
206
+ if (!isWithin(realCwd, real)) throw new HttpError(403, 'path resolves outside the session working directory')
207
+ return real
208
+ }
209
+
210
+ // ── responses ───────────────────────────────────────────────────────────────
211
+
212
+ function sendJson(res, status, body) {
213
+ const buf = Buffer.from(JSON.stringify(body))
214
+ res.writeHead(status, {
215
+ 'content-type': 'application/json; charset=utf-8',
216
+ 'content-length': String(buf.length),
217
+ 'cache-control': 'no-store',
218
+ })
219
+ res.end(buf)
220
+ }
221
+
222
+ function sendError(res, error) {
223
+ const status = error instanceof HttpError ? error.status : 500
224
+ if (res.headersSent) { res.destroy(); return }
225
+ sendJson(res, status, { ok: false, error: { message: error?.message ?? String(error) } })
226
+ }
227
+
228
+ /** Single `Range: bytes=` header -> {start,end} | null | {unsatisfiable:true}. */
229
+ function parseRange(raw, size) {
230
+ if (raw === undefined) return null
231
+ const m = /^bytes=(.+)$/i.exec(raw.trim())
232
+ if (!m) return null
233
+ const spec = m[1].split(',')[0].trim()
234
+ if (spec === '') return null
235
+ if (spec.startsWith('-')) {
236
+ const suffix = Number(spec.slice(1))
237
+ if (!Number.isFinite(suffix) || suffix <= 0) return null
238
+ return suffix >= size ? { start: 0, end: size - 1 } : { start: size - suffix, end: size - 1 }
239
+ }
240
+ const dash = spec.indexOf('-')
241
+ if (dash === -1) return null
242
+ const start = spec.slice(0, dash) === '' ? 0 : Number(spec.slice(0, dash))
243
+ const end = spec.slice(dash + 1) === '' ? size - 1 : Number(spec.slice(dash + 1))
244
+ if (!Number.isInteger(start) || start < 0 || !Number.isInteger(end)) return null
245
+ if (start >= size) return { unsatisfiable: true }
246
+ return { start, end: Math.min(end, size - 1) }
247
+ }
248
+
249
+ function serveFile(req, res, path, size, type, extra) {
250
+ const base = {
251
+ 'content-type': type,
252
+ 'accept-ranges': 'bytes',
253
+ 'cache-control': 'no-cache',
254
+ 'x-content-type-options': 'nosniff',
255
+ 'referrer-policy': 'no-referrer',
256
+ 'content-security-policy': SANDBOX_CSP,
257
+ 'cross-origin-resource-policy': 'cross-origin',
258
+ ...extra,
259
+ }
260
+ const range = parseRange(header(req.headers, 'range'), size)
261
+ if (range?.unsatisfiable) {
262
+ res.writeHead(416, { ...base, 'content-range': `bytes */${size}` })
263
+ res.end()
264
+ return
265
+ }
266
+ const stream = (opts) => {
267
+ const s = createReadStream(path, opts)
268
+ s.on('error', () => res.destroy())
269
+ res.on('close', () => s.destroy())
270
+ s.pipe(res)
271
+ }
272
+ if (range) {
273
+ res.writeHead(206, { ...base, 'content-range': `bytes ${range.start}-${range.end}/${size}`, 'content-length': String(range.end - range.start + 1) })
274
+ if (req.method === 'HEAD') { res.end(); return }
275
+ stream({ start: range.start, end: range.end })
276
+ return
277
+ }
278
+ res.writeHead(200, { ...base, 'content-length': String(size) })
279
+ if (req.method === 'HEAD') { res.end(); return }
280
+ stream({})
281
+ }
282
+
283
+ // ── listing ─────────────────────────────────────────────────────────────────
284
+
285
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.svn', '.hg'])
286
+ const MAX_ENTRIES = 3000
287
+ const MAX_DEPTH = 6
288
+
289
+ async function statMany(items) {
290
+ const out = []
291
+ for (let i = 0; i < items.length; i += 32) {
292
+ const chunk = items.slice(i, i + 32)
293
+ const rows = await Promise.all(chunk.map(async (it) => {
294
+ try {
295
+ const info = await stat(it.abs)
296
+ return { ...it, size: info.size, mtime: info.mtimeMs }
297
+ } catch {
298
+ return undefined
299
+ }
300
+ }))
301
+ for (const r of rows) if (r) out.push(r)
302
+ }
303
+ return out
304
+ }
305
+
306
+ async function listMedia(root, recursive) {
307
+ const files = []
308
+ const dirs = []
309
+ let truncated = false
310
+ const queue = [{ abs: root, depth: 0 }]
311
+ while (queue.length > 0 && !truncated) {
312
+ const { abs, depth } = queue.shift()
313
+ let names
314
+ try {
315
+ names = await readdir(abs, { withFileTypes: true })
316
+ } catch {
317
+ continue
318
+ }
319
+ for (const d of names) {
320
+ const child = resolve(abs, d.name)
321
+ if (d.isDirectory()) {
322
+ if (depth === 0) dirs.push({ name: d.name, abs: child })
323
+ if (recursive && depth < MAX_DEPTH && !SKIP_DIRS.has(d.name)) queue.push({ abs: child, depth: depth + 1 })
324
+ } else if (d.isFile()) {
325
+ const kind = kindOfExt(extname(d.name).toLowerCase())
326
+ if (kind === undefined) continue
327
+ if (files.length >= MAX_ENTRIES) { truncated = true; break }
328
+ files.push({ name: d.name, abs: child, kind })
329
+ }
330
+ }
331
+ }
332
+ const rows = await statMany(files)
333
+ const rootNorm = norm(root)
334
+ for (const r of rows) {
335
+ r.rel = norm(r.abs).slice(rootNorm.length).replace(/^\//, '')
336
+ r.ext = extname(r.name).slice(1).toLowerCase()
337
+ }
338
+ dirs.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
339
+ return { files: rows, dirs: dirs.filter((d) => !SKIP_DIRS.has(d.name)), truncated }
340
+ }
341
+
342
+ // ── plugin body ─────────────────────────────────────────────────────────────
343
+
344
+ export function apply(ctx) {
345
+ const trustedHosts = () => ctx.webRuntime.trustedHosts
346
+
347
+ ctx.effect(() => ctx.webServer.register({
348
+ kind: 'prefix',
349
+ path: '/mp',
350
+ handler: async (req, res) => {
351
+ try {
352
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
353
+ const isFile = url.pathname.startsWith('/mp/f/')
354
+
355
+ if (!trusted(req, trustedHosts(), isFile)) throw new HttpError(403, 'forbidden')
356
+
357
+ if (req.method === 'OPTIONS' && isFile) {
358
+ res.writeHead(204, {
359
+ 'access-control-allow-origin': '*',
360
+ 'access-control-allow-methods': 'GET, HEAD, OPTIONS',
361
+ 'access-control-allow-headers': 'range, if-range',
362
+ 'access-control-max-age': '600',
363
+ })
364
+ res.end()
365
+ return
366
+ }
367
+ if (req.method !== 'GET' && !(req.method === 'HEAD' && isFile)) throw new HttpError(405, 'method not allowed')
368
+
369
+ if (isFile) {
370
+ const { sessionId, path } = decodeFilePath(url.pathname)
371
+ const real = await confine(liveCwd(ctx, sessionId), path)
372
+ const info = await stat(real)
373
+ if (!info.isFile()) throw new HttpError(400, 'not a file')
374
+ const type = TYPES[extname(real).toLowerCase()] ?? 'application/octet-stream'
375
+ const extra = { 'access-control-allow-origin': '*', 'access-control-expose-headers': 'content-length, content-range, accept-ranges' }
376
+ if (url.searchParams.get('download') === '1') {
377
+ extra['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(real))}`
378
+ }
379
+ if (info.size === 0) {
380
+ res.writeHead(200, { 'content-type': type, 'content-length': '0', 'content-security-policy': SANDBOX_CSP, ...extra })
381
+ res.end()
382
+ return
383
+ }
384
+ serveFile(req, res, real, info.size, type, extra)
385
+ return
386
+ }
387
+
388
+ if (url.pathname === '/mp/list') {
389
+ const sessionId = url.searchParams.get('sessionId')
390
+ if (!sessionId) throw new HttpError(400, 'sessionId is required')
391
+ const cwd = liveCwd(ctx, sessionId)
392
+ const dirParam = url.searchParams.get('dir')
393
+ const dir = await confine(cwd, dirParam ? resolve(cwd, dirParam) : cwd)
394
+ const info = await stat(dir)
395
+ if (!info.isDirectory()) throw new HttpError(400, 'not a directory')
396
+ const { files, dirs, truncated } = await listMedia(dir, url.searchParams.get('recursive') === '1')
397
+ sendJson(res, 200, { ok: true, cwd: resolve(cwd), dir, files, dirs, truncated })
398
+ return
399
+ }
400
+
401
+ throw new HttpError(404, 'unknown route')
402
+ } catch (error) {
403
+ sendError(res, error)
404
+ }
405
+ },
406
+ }), 'dsh-media-viewer: /mp file + list routes')
407
+ }
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@vibedev-si/dsh-media-viewer",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "main": "index.js",
7
+ "icon": "./icon.svg",
8
+ "files": [
9
+ "index.js",
10
+ "client.js",
11
+ "icon.svg",
12
+ "cordis.patch.yml",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "exports": {
17
+ ".": "./index.js",
18
+ "./client": {
19
+ "default": "./client.js"
20
+ },
21
+ "./cordis.patch.yml": "./cordis.patch.yml",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "keywords": [
25
+ "dsh",
26
+ "dsh-plugin",
27
+ "deepseek-harness",
28
+ "vibedev",
29
+ "dsh-better-sidebar",
30
+ "better-sidebar",
31
+ "video",
32
+ "audio",
33
+ "html",
34
+ "preview",
35
+ "gallery",
36
+ "file-viewer"
37
+ ],
38
+ "author": "CrisLIUning",
39
+ "license": "MIT",
40
+ "homepage": "https://github.com/VibeDev-Si/dsh-media-viewer#readme",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/VibeDev-Si/dsh-media-viewer.git"
44
+ },
45
+ "bugs": {
46
+ "url": "https://github.com/VibeDev-Si/dsh-media-viewer/issues"
47
+ },
48
+ "engines": {
49
+ "node": ">=20"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public",
53
+ "registry": "https://registry.npmjs.org/"
54
+ },
55
+ "scripts": {
56
+ "test": "node test/route.test.mjs"
57
+ },
58
+ "peerDependencies": {
59
+ "dsh-better-sidebar": ">=0.24.0"
60
+ },
61
+ "dsh": {
62
+ "bundle": {
63
+ "patch": "./cordis.patch.yml"
64
+ },
65
+ "client": {
66
+ "inject": [
67
+ "@deepseek-ai/dsh-client-runtime"
68
+ ],
69
+ "platform": "web"
70
+ }
71
+ }
72
+ }