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