dsh-vidi 0.7.5 → 0.7.7
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/lib/client.js +409 -246
- package/lib/index.mjs +163 -1
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -73,6 +73,198 @@ function onOpenRequest(fn) {
|
|
|
73
73
|
/** Video links worth offering to open in the player. */
|
|
74
74
|
const VIDEO_LINK = /\bhttps?:\/\/(?:www\.|m\.|live\.)?(?:youtube\.com\/(?:watch|live|shorts)|youtu\.be\/|bilibili\.com\/(?:video|bangumi)|b23\.tv\/|live\.bilibili\.com\/|twitch\.tv\/|vimeo\.com\/|x\.com\/\S+\/status|twitter\.com\/\S+\/status|douyin\.com\/|v\.douyin\.com\/|ixigua\.com\/|weibo\.com\/tv|tiktok\.com\/|kick\.com\/|xiaoyuzhoufm\.com\/episode\/)\S*|\b\S+\.m3u8\b|\brtmps?:\/\/\S+|\bsrt:\/\/\S+/i;
|
|
75
75
|
|
|
76
|
+
// src/client/Pick.tsx
|
|
77
|
+
/** Theme tokens copied onto the list, so it looks like the place it opened from (light page, dark player menu). */
|
|
78
|
+
const TOKENS = [
|
|
79
|
+
"--bg",
|
|
80
|
+
"--l1",
|
|
81
|
+
"--l2",
|
|
82
|
+
"--ink",
|
|
83
|
+
"--muted",
|
|
84
|
+
"--faint",
|
|
85
|
+
"--line",
|
|
86
|
+
"--hover",
|
|
87
|
+
"--accent"
|
|
88
|
+
];
|
|
89
|
+
/**
|
|
90
|
+
* A drop-down in the page's own style, in place of the browser's <select>
|
|
91
|
+
* (which draws a system menu as wide as the screen allows). The list is sized
|
|
92
|
+
* to its options, scrolls when long, opens upward when there is no room
|
|
93
|
+
* below, and works with the keyboard (arrows, Enter, Esc, typing a letter).
|
|
94
|
+
*/
|
|
95
|
+
function Pick(props) {
|
|
96
|
+
const { value, options, onChange, placeholder, disabled, className, title, label } = props;
|
|
97
|
+
const [open, setOpen] = (0, react.useState)(false);
|
|
98
|
+
const [active, setActive] = (0, react.useState)(-1);
|
|
99
|
+
const [place, setPlace] = (0, react.useState)({});
|
|
100
|
+
const btn = (0, react.useRef)(null);
|
|
101
|
+
const list = (0, react.useRef)(null);
|
|
102
|
+
const chosen = options.find((o) => o.value === value);
|
|
103
|
+
const usable = options.map((o, i) => o.disabled ? -1 : i).filter((i) => i >= 0);
|
|
104
|
+
const close = (focus = true) => {
|
|
105
|
+
setOpen(false);
|
|
106
|
+
if (focus) btn.current?.focus();
|
|
107
|
+
};
|
|
108
|
+
const choose = (o) => {
|
|
109
|
+
if (o.disabled) return;
|
|
110
|
+
onChange(o.value);
|
|
111
|
+
close();
|
|
112
|
+
};
|
|
113
|
+
(0, react.useLayoutEffect)(() => {
|
|
114
|
+
if (!open || !btn.current) return;
|
|
115
|
+
const r = btn.current.getBoundingClientRect();
|
|
116
|
+
const cs = getComputedStyle(btn.current);
|
|
117
|
+
const vars = Object.fromEntries(TOKENS.map((k) => [k, cs.getPropertyValue(k).trim()]).filter(([, v]) => v));
|
|
118
|
+
const room = window.innerHeight - r.bottom - 8, above = r.top - 8;
|
|
119
|
+
const up = room < 220 && above > room;
|
|
120
|
+
const maxH = Math.max(120, Math.min(320, up ? above - 4 : room - 4));
|
|
121
|
+
const minWidth = Math.min(Math.max(r.width, 180), window.innerWidth - 16);
|
|
122
|
+
const maxWidth = Math.max(minWidth, Math.min(380, window.innerWidth - 16));
|
|
123
|
+
const side = r.left + maxWidth > window.innerWidth - 8 ? { right: Math.max(8, window.innerWidth - r.right) } : { left: Math.max(8, r.left) };
|
|
124
|
+
setPlace({
|
|
125
|
+
...vars,
|
|
126
|
+
...side,
|
|
127
|
+
minWidth,
|
|
128
|
+
maxWidth,
|
|
129
|
+
maxHeight: maxH,
|
|
130
|
+
...up ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 },
|
|
131
|
+
fontFamily: cs.fontFamily
|
|
132
|
+
});
|
|
133
|
+
setActive(Math.max(0, options.findIndex((o) => o.value === value)));
|
|
134
|
+
}, [open]);
|
|
135
|
+
(0, react.useEffect)(() => {
|
|
136
|
+
list.current?.querySelector(`[data-i="${active}"]`)?.scrollIntoView({ block: "nearest" });
|
|
137
|
+
}, [active, open]);
|
|
138
|
+
(0, react.useEffect)(() => {
|
|
139
|
+
if (!open) return;
|
|
140
|
+
const away = (e) => {
|
|
141
|
+
if (!list.current?.contains(e.target) && !btn.current?.contains(e.target)) close(false);
|
|
142
|
+
};
|
|
143
|
+
const gone = (e) => {
|
|
144
|
+
const t = e.target;
|
|
145
|
+
if (list.current?.contains(t)) return;
|
|
146
|
+
if (e.type === "resize" || t === document || t === document.documentElement || t instanceof Node && btn.current && t.contains(btn.current)) close(false);
|
|
147
|
+
};
|
|
148
|
+
document.addEventListener("pointerdown", away, true);
|
|
149
|
+
window.addEventListener("resize", gone);
|
|
150
|
+
window.addEventListener("scroll", gone, true);
|
|
151
|
+
return () => {
|
|
152
|
+
document.removeEventListener("pointerdown", away, true);
|
|
153
|
+
window.removeEventListener("resize", gone);
|
|
154
|
+
window.removeEventListener("scroll", gone, true);
|
|
155
|
+
};
|
|
156
|
+
}, [open]);
|
|
157
|
+
const onKey = (e) => {
|
|
158
|
+
if (disabled) return;
|
|
159
|
+
const step = (d) => {
|
|
160
|
+
const at = usable.indexOf(active);
|
|
161
|
+
setActive(usable[Math.min(usable.length - 1, Math.max(0, (at < 0 ? d > 0 ? -1 : usable.length : at) + d))] ?? -1);
|
|
162
|
+
};
|
|
163
|
+
if (!open) {
|
|
164
|
+
if ([
|
|
165
|
+
"ArrowDown",
|
|
166
|
+
"ArrowUp",
|
|
167
|
+
"Enter",
|
|
168
|
+
" "
|
|
169
|
+
].includes(e.key)) {
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
setOpen(true);
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (e.key === "Escape") {
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
e.stopPropagation();
|
|
178
|
+
close();
|
|
179
|
+
} else if (e.key === "ArrowDown") {
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
step(1);
|
|
182
|
+
} else if (e.key === "ArrowUp") {
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
step(-1);
|
|
185
|
+
} else if (e.key === "Home") {
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
setActive(usable[0] ?? -1);
|
|
188
|
+
} else if (e.key === "End") {
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
setActive(usable.at(-1) ?? -1);
|
|
191
|
+
} else if (e.key === "Enter" || e.key === " ") {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
const o = options[active];
|
|
194
|
+
if (o) choose(o);
|
|
195
|
+
} else if (e.key === "Tab") close(false);
|
|
196
|
+
else if (e.key.length === 1) {
|
|
197
|
+
const k = e.key.toLowerCase();
|
|
198
|
+
const from = usable.indexOf(active);
|
|
199
|
+
const hit = [...usable.slice(from + 1), ...usable.slice(0, from + 1)].find((i) => options[i].label.toLowerCase().startsWith(k));
|
|
200
|
+
if (hit !== void 0) setActive(hit);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
let lastGroup;
|
|
204
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
205
|
+
ref: btn,
|
|
206
|
+
type: "button",
|
|
207
|
+
className: `dshl-select ${open ? "open" : ""} ${className ?? ""}`,
|
|
208
|
+
disabled,
|
|
209
|
+
title,
|
|
210
|
+
"aria-haspopup": "listbox",
|
|
211
|
+
"aria-expanded": open,
|
|
212
|
+
onClick: () => setOpen((v) => !v),
|
|
213
|
+
onKeyDown: onKey,
|
|
214
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
215
|
+
className: "v",
|
|
216
|
+
children: label ?? chosen?.label ?? placeholder ?? ""
|
|
217
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {
|
|
218
|
+
className: "caret",
|
|
219
|
+
"aria-hidden": "true"
|
|
220
|
+
})]
|
|
221
|
+
}), open && (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
222
|
+
ref: list,
|
|
223
|
+
className: "dshl-options",
|
|
224
|
+
role: "listbox",
|
|
225
|
+
style: place,
|
|
226
|
+
onKeyDown: onKey,
|
|
227
|
+
children: options.map((o, i) => {
|
|
228
|
+
const head = o.group && o.group !== lastGroup ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
229
|
+
className: "grp",
|
|
230
|
+
children: o.group
|
|
231
|
+
}, `g:${o.group}`) : null;
|
|
232
|
+
lastGroup = o.group;
|
|
233
|
+
return [head, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
234
|
+
"data-i": i,
|
|
235
|
+
role: "option",
|
|
236
|
+
"aria-selected": o.value === value,
|
|
237
|
+
"aria-disabled": o.disabled || void 0,
|
|
238
|
+
className: `opt ${o.value === value ? "on" : ""} ${i === active ? "act" : ""} ${o.disabled ? "off" : ""}`,
|
|
239
|
+
onPointerEnter: () => !o.disabled && setActive(i),
|
|
240
|
+
onClick: () => choose(o),
|
|
241
|
+
children: [
|
|
242
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
243
|
+
className: "l",
|
|
244
|
+
children: o.label
|
|
245
|
+
}),
|
|
246
|
+
o.hint && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
247
|
+
className: "h",
|
|
248
|
+
children: o.hint
|
|
249
|
+
}),
|
|
250
|
+
o.value === value && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
251
|
+
className: "ok",
|
|
252
|
+
viewBox: "0 0 24 24",
|
|
253
|
+
width: "14",
|
|
254
|
+
height: "14",
|
|
255
|
+
fill: "none",
|
|
256
|
+
stroke: "currentColor",
|
|
257
|
+
strokeWidth: "2.4",
|
|
258
|
+
strokeLinecap: "round",
|
|
259
|
+
strokeLinejoin: "round",
|
|
260
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12.5l4.5 4.5L19 7.5" })
|
|
261
|
+
})
|
|
262
|
+
]
|
|
263
|
+
}, o.value)];
|
|
264
|
+
})
|
|
265
|
+
}), document.fullscreenElement ?? document.body)] });
|
|
266
|
+
}
|
|
267
|
+
|
|
76
268
|
// src/client/icons.tsx
|
|
77
269
|
/** Minimal stroke icons (24×24, currentColor). */
|
|
78
270
|
const P = {
|
|
@@ -793,198 +985,6 @@ function langLabel(code, ui, fallback) {
|
|
|
793
985
|
return NAMES[code]?.[ui] ?? fallback ?? code;
|
|
794
986
|
}
|
|
795
987
|
|
|
796
|
-
// src/client/Pick.tsx
|
|
797
|
-
/** Theme tokens copied onto the list, so it looks like the place it opened from (light page, dark player menu). */
|
|
798
|
-
const TOKENS = [
|
|
799
|
-
"--bg",
|
|
800
|
-
"--l1",
|
|
801
|
-
"--l2",
|
|
802
|
-
"--ink",
|
|
803
|
-
"--muted",
|
|
804
|
-
"--faint",
|
|
805
|
-
"--line",
|
|
806
|
-
"--hover",
|
|
807
|
-
"--accent"
|
|
808
|
-
];
|
|
809
|
-
/**
|
|
810
|
-
* A drop-down in the page's own style, in place of the browser's <select>
|
|
811
|
-
* (which draws a system menu as wide as the screen allows). The list is sized
|
|
812
|
-
* to its options, scrolls when long, opens upward when there is no room
|
|
813
|
-
* below, and works with the keyboard (arrows, Enter, Esc, typing a letter).
|
|
814
|
-
*/
|
|
815
|
-
function Pick(props) {
|
|
816
|
-
const { value, options, onChange, placeholder, disabled, className, title, label } = props;
|
|
817
|
-
const [open, setOpen] = (0, react.useState)(false);
|
|
818
|
-
const [active, setActive] = (0, react.useState)(-1);
|
|
819
|
-
const [place, setPlace] = (0, react.useState)({});
|
|
820
|
-
const btn = (0, react.useRef)(null);
|
|
821
|
-
const list = (0, react.useRef)(null);
|
|
822
|
-
const chosen = options.find((o) => o.value === value);
|
|
823
|
-
const usable = options.map((o, i) => o.disabled ? -1 : i).filter((i) => i >= 0);
|
|
824
|
-
const close = (focus = true) => {
|
|
825
|
-
setOpen(false);
|
|
826
|
-
if (focus) btn.current?.focus();
|
|
827
|
-
};
|
|
828
|
-
const choose = (o) => {
|
|
829
|
-
if (o.disabled) return;
|
|
830
|
-
onChange(o.value);
|
|
831
|
-
close();
|
|
832
|
-
};
|
|
833
|
-
(0, react.useLayoutEffect)(() => {
|
|
834
|
-
if (!open || !btn.current) return;
|
|
835
|
-
const r = btn.current.getBoundingClientRect();
|
|
836
|
-
const cs = getComputedStyle(btn.current);
|
|
837
|
-
const vars = Object.fromEntries(TOKENS.map((k) => [k, cs.getPropertyValue(k).trim()]).filter(([, v]) => v));
|
|
838
|
-
const room = window.innerHeight - r.bottom - 8, above = r.top - 8;
|
|
839
|
-
const up = room < 220 && above > room;
|
|
840
|
-
const maxH = Math.max(120, Math.min(320, up ? above - 4 : room - 4));
|
|
841
|
-
const minWidth = Math.min(Math.max(r.width, 180), window.innerWidth - 16);
|
|
842
|
-
const maxWidth = Math.max(minWidth, Math.min(380, window.innerWidth - 16));
|
|
843
|
-
const side = r.left + maxWidth > window.innerWidth - 8 ? { right: Math.max(8, window.innerWidth - r.right) } : { left: Math.max(8, r.left) };
|
|
844
|
-
setPlace({
|
|
845
|
-
...vars,
|
|
846
|
-
...side,
|
|
847
|
-
minWidth,
|
|
848
|
-
maxWidth,
|
|
849
|
-
maxHeight: maxH,
|
|
850
|
-
...up ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 },
|
|
851
|
-
fontFamily: cs.fontFamily
|
|
852
|
-
});
|
|
853
|
-
setActive(Math.max(0, options.findIndex((o) => o.value === value)));
|
|
854
|
-
}, [open]);
|
|
855
|
-
(0, react.useEffect)(() => {
|
|
856
|
-
list.current?.querySelector(`[data-i="${active}"]`)?.scrollIntoView({ block: "nearest" });
|
|
857
|
-
}, [active, open]);
|
|
858
|
-
(0, react.useEffect)(() => {
|
|
859
|
-
if (!open) return;
|
|
860
|
-
const away = (e) => {
|
|
861
|
-
if (!list.current?.contains(e.target) && !btn.current?.contains(e.target)) close(false);
|
|
862
|
-
};
|
|
863
|
-
const gone = (e) => {
|
|
864
|
-
const t = e.target;
|
|
865
|
-
if (list.current?.contains(t)) return;
|
|
866
|
-
if (e.type === "resize" || t === document || t === document.documentElement || t instanceof Node && btn.current && t.contains(btn.current)) close(false);
|
|
867
|
-
};
|
|
868
|
-
document.addEventListener("pointerdown", away, true);
|
|
869
|
-
window.addEventListener("resize", gone);
|
|
870
|
-
window.addEventListener("scroll", gone, true);
|
|
871
|
-
return () => {
|
|
872
|
-
document.removeEventListener("pointerdown", away, true);
|
|
873
|
-
window.removeEventListener("resize", gone);
|
|
874
|
-
window.removeEventListener("scroll", gone, true);
|
|
875
|
-
};
|
|
876
|
-
}, [open]);
|
|
877
|
-
const onKey = (e) => {
|
|
878
|
-
if (disabled) return;
|
|
879
|
-
const step = (d) => {
|
|
880
|
-
const at = usable.indexOf(active);
|
|
881
|
-
setActive(usable[Math.min(usable.length - 1, Math.max(0, (at < 0 ? d > 0 ? -1 : usable.length : at) + d))] ?? -1);
|
|
882
|
-
};
|
|
883
|
-
if (!open) {
|
|
884
|
-
if ([
|
|
885
|
-
"ArrowDown",
|
|
886
|
-
"ArrowUp",
|
|
887
|
-
"Enter",
|
|
888
|
-
" "
|
|
889
|
-
].includes(e.key)) {
|
|
890
|
-
e.preventDefault();
|
|
891
|
-
setOpen(true);
|
|
892
|
-
}
|
|
893
|
-
return;
|
|
894
|
-
}
|
|
895
|
-
if (e.key === "Escape") {
|
|
896
|
-
e.preventDefault();
|
|
897
|
-
e.stopPropagation();
|
|
898
|
-
close();
|
|
899
|
-
} else if (e.key === "ArrowDown") {
|
|
900
|
-
e.preventDefault();
|
|
901
|
-
step(1);
|
|
902
|
-
} else if (e.key === "ArrowUp") {
|
|
903
|
-
e.preventDefault();
|
|
904
|
-
step(-1);
|
|
905
|
-
} else if (e.key === "Home") {
|
|
906
|
-
e.preventDefault();
|
|
907
|
-
setActive(usable[0] ?? -1);
|
|
908
|
-
} else if (e.key === "End") {
|
|
909
|
-
e.preventDefault();
|
|
910
|
-
setActive(usable.at(-1) ?? -1);
|
|
911
|
-
} else if (e.key === "Enter" || e.key === " ") {
|
|
912
|
-
e.preventDefault();
|
|
913
|
-
const o = options[active];
|
|
914
|
-
if (o) choose(o);
|
|
915
|
-
} else if (e.key === "Tab") close(false);
|
|
916
|
-
else if (e.key.length === 1) {
|
|
917
|
-
const k = e.key.toLowerCase();
|
|
918
|
-
const from = usable.indexOf(active);
|
|
919
|
-
const hit = [...usable.slice(from + 1), ...usable.slice(0, from + 1)].find((i) => options[i].label.toLowerCase().startsWith(k));
|
|
920
|
-
if (hit !== void 0) setActive(hit);
|
|
921
|
-
}
|
|
922
|
-
};
|
|
923
|
-
let lastGroup;
|
|
924
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
925
|
-
ref: btn,
|
|
926
|
-
type: "button",
|
|
927
|
-
className: `dshl-select ${open ? "open" : ""} ${className ?? ""}`,
|
|
928
|
-
disabled,
|
|
929
|
-
title,
|
|
930
|
-
"aria-haspopup": "listbox",
|
|
931
|
-
"aria-expanded": open,
|
|
932
|
-
onClick: () => setOpen((v) => !v),
|
|
933
|
-
onKeyDown: onKey,
|
|
934
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
935
|
-
className: "v",
|
|
936
|
-
children: label ?? chosen?.label ?? placeholder ?? ""
|
|
937
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {
|
|
938
|
-
className: "caret",
|
|
939
|
-
"aria-hidden": "true"
|
|
940
|
-
})]
|
|
941
|
-
}), open && (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
942
|
-
ref: list,
|
|
943
|
-
className: "dshl-options",
|
|
944
|
-
role: "listbox",
|
|
945
|
-
style: place,
|
|
946
|
-
onKeyDown: onKey,
|
|
947
|
-
children: options.map((o, i) => {
|
|
948
|
-
const head = o.group && o.group !== lastGroup ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
949
|
-
className: "grp",
|
|
950
|
-
children: o.group
|
|
951
|
-
}, `g:${o.group}`) : null;
|
|
952
|
-
lastGroup = o.group;
|
|
953
|
-
return [head, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
954
|
-
"data-i": i,
|
|
955
|
-
role: "option",
|
|
956
|
-
"aria-selected": o.value === value,
|
|
957
|
-
"aria-disabled": o.disabled || void 0,
|
|
958
|
-
className: `opt ${o.value === value ? "on" : ""} ${i === active ? "act" : ""} ${o.disabled ? "off" : ""}`,
|
|
959
|
-
onPointerEnter: () => !o.disabled && setActive(i),
|
|
960
|
-
onClick: () => choose(o),
|
|
961
|
-
children: [
|
|
962
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
963
|
-
className: "l",
|
|
964
|
-
children: o.label
|
|
965
|
-
}),
|
|
966
|
-
o.hint && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
967
|
-
className: "h",
|
|
968
|
-
children: o.hint
|
|
969
|
-
}),
|
|
970
|
-
o.value === value && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
971
|
-
className: "ok",
|
|
972
|
-
viewBox: "0 0 24 24",
|
|
973
|
-
width: "14",
|
|
974
|
-
height: "14",
|
|
975
|
-
fill: "none",
|
|
976
|
-
stroke: "currentColor",
|
|
977
|
-
strokeWidth: "2.4",
|
|
978
|
-
strokeLinecap: "round",
|
|
979
|
-
strokeLinejoin: "round",
|
|
980
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12.5l4.5 4.5L19 7.5" })
|
|
981
|
-
})
|
|
982
|
-
]
|
|
983
|
-
}, o.value)];
|
|
984
|
-
})
|
|
985
|
-
}), document.fullscreenElement ?? document.body)] });
|
|
986
|
-
}
|
|
987
|
-
|
|
988
988
|
// src/client/LineChips.tsx
|
|
989
989
|
const MAX_TRANSLATIONS = 4;
|
|
990
990
|
/**
|
|
@@ -35187,7 +35187,28 @@ function upload(file, onProgress) {
|
|
|
35187
35187
|
xhr.send(file);
|
|
35188
35188
|
});
|
|
35189
35189
|
}
|
|
35190
|
-
/**
|
|
35190
|
+
/** Cameras, screens and microphones on the computer Vidi runs on (null while loading). */
|
|
35191
|
+
function useDevices(enabled) {
|
|
35192
|
+
const [devices, setDevices] = (0, react.useState)(null);
|
|
35193
|
+
(0, react.useEffect)(() => {
|
|
35194
|
+
if (!enabled || devices) return;
|
|
35195
|
+
fetch("/live/api/devices").then((r) => r.json()).then((d) => setDevices(d.devices ?? [])).catch(() => setDevices([]));
|
|
35196
|
+
}, [enabled, devices]);
|
|
35197
|
+
return devices;
|
|
35198
|
+
}
|
|
35199
|
+
/**
|
|
35200
|
+
* Ask for the camera / microphone from the page first when the page runs on the same computer: the system
|
|
35201
|
+
* then asks the user once for this app, and the capture that follows is allowed.
|
|
35202
|
+
*/
|
|
35203
|
+
async function askDeviceAccess(video) {
|
|
35204
|
+
if (!(location.protocol === "dsh-app:" || /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname)) || !navigator.mediaDevices?.getUserMedia) return;
|
|
35205
|
+
try {
|
|
35206
|
+
(await navigator.mediaDevices.getUserMedia({
|
|
35207
|
+
audio: true,
|
|
35208
|
+
video
|
|
35209
|
+
})).getTracks().forEach((tr) => tr.stop());
|
|
35210
|
+
} catch {}
|
|
35211
|
+
}
|
|
35191
35212
|
/** The push stream key: copy it, or change it (typed, or a new random one). */
|
|
35192
35213
|
function KeyRow({ value, t, onChanged }) {
|
|
35193
35214
|
const [edit, setEdit] = (0, react.useState)(null);
|
|
@@ -35368,6 +35389,23 @@ function LiveView(props) {
|
|
|
35368
35389
|
const linesBox = (0, react.useRef)(null);
|
|
35369
35390
|
const fileInput = (0, react.useRef)(null);
|
|
35370
35391
|
const active = !!job && (job.status === "resolving" || job.status === "running" || job.status === "stopping");
|
|
35392
|
+
const [srcTab, setSrcTabRaw] = (0, react.useState)(() => store.get("source", "link"));
|
|
35393
|
+
const setSrcTab = (v) => {
|
|
35394
|
+
setSrcTabRaw(v);
|
|
35395
|
+
store.set("source", v);
|
|
35396
|
+
setError("");
|
|
35397
|
+
};
|
|
35398
|
+
const devices = useDevices(srcTab === "camera");
|
|
35399
|
+
const [camVideo, setCamVideoRaw] = (0, react.useState)(() => store.get("device.video", ""));
|
|
35400
|
+
const [camAudio, setCamAudioRaw] = (0, react.useState)(() => store.get("device.audio", ""));
|
|
35401
|
+
const setCamVideo = (v) => {
|
|
35402
|
+
setCamVideoRaw(v);
|
|
35403
|
+
store.set("device.video", v);
|
|
35404
|
+
};
|
|
35405
|
+
const setCamAudio = (v) => {
|
|
35406
|
+
setCamAudioRaw(v);
|
|
35407
|
+
store.set("device.audio", v);
|
|
35408
|
+
};
|
|
35371
35409
|
const ui = t("view.live") === "Video" ? "en" : "zh";
|
|
35372
35410
|
const langOptions = (0, react.useMemo)(() => Object.entries(langs).filter(([k]) => k !== "auto").map(([k, v]) => [k, langLabel(k, ui, v)]), [langs, ui]);
|
|
35373
35411
|
const langName = (0, react.useCallback)((k) => k === "src" ? t("sub.source") : langLabel(k, ui, langs[k]), [
|
|
@@ -35517,7 +35555,7 @@ function LiveView(props) {
|
|
|
35517
35555
|
url: target
|
|
35518
35556
|
} : classify(target);
|
|
35519
35557
|
if (!c) return;
|
|
35520
|
-
if (props.onNewVideo && job && job.source !== "push" && job.url !== c.url && c.source !== "push" && await props.onNewVideo(target)) return;
|
|
35558
|
+
if (props.onNewVideo && job && job.source !== "push" && job.url !== c.url && c.source !== "push" && c.source !== "device" && await props.onNewVideo(target)) return;
|
|
35521
35559
|
try {
|
|
35522
35560
|
const j = await post("start", {
|
|
35523
35561
|
...options(),
|
|
@@ -35836,23 +35874,45 @@ function LiveView(props) {
|
|
|
35836
35874
|
const missing = deps.filter((d) => !d.ok && (d.name === "ffmpeg" || d.name === "yt-dlp" || (engine || "local") === "local" && d.name.startsWith("whisper")));
|
|
35837
35875
|
const needsModel = !hasTranslator && shownLines.some((l) => l !== "src");
|
|
35838
35876
|
const hasMain = !!job && !(job.status === "error" && !job.files.length);
|
|
35877
|
+
const cams = (devices ?? []).filter((d) => d.kind !== "mic"), mics = (devices ?? []).filter((d) => d.kind === "mic");
|
|
35878
|
+
const camV = camVideo === "none" || cams.some((d) => d.id === camVideo) ? camVideo : cams.find((d) => d.kind === "camera")?.id ?? "none";
|
|
35879
|
+
const camA = mics.some((d) => d.id === camAudio) ? camAudio : mics[0]?.id ?? "";
|
|
35880
|
+
const videoOptions = [...cams.map((d) => ({
|
|
35881
|
+
value: d.id,
|
|
35882
|
+
label: d.kind === "screen" ? t("dev.screen").replace("{n}", String(Number(/\d+/.exec(d.name)?.[0] ?? 0) + 1)) : d.name,
|
|
35883
|
+
group: t(d.kind === "screen" ? "dev.screens" : "dev.cameras")
|
|
35884
|
+
})), {
|
|
35885
|
+
value: "none",
|
|
35886
|
+
label: t("dev.noVideo"),
|
|
35887
|
+
group: t("dev.other")
|
|
35888
|
+
}];
|
|
35889
|
+
const startDevice = async () => {
|
|
35890
|
+
if (!camA) return;
|
|
35891
|
+
const title = camV === "none" ? mics.find((d) => d.id === camA)?.name ?? "" : videoOptions.find((o) => o.value === camV)?.label ?? "";
|
|
35892
|
+
await askDeviceAccess(camV !== "none" && cams.find((d) => d.id === camV)?.kind === "camera");
|
|
35893
|
+
start(JSON.stringify({
|
|
35894
|
+
video: camV === "none" ? void 0 : camV,
|
|
35895
|
+
audio: camA,
|
|
35896
|
+
title
|
|
35897
|
+
}), "device");
|
|
35898
|
+
};
|
|
35839
35899
|
const emptyBox = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
35840
35900
|
className: "dshl-drop",
|
|
35841
35901
|
children: [
|
|
35842
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", { children: t("empty.title") }),
|
|
35843
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("empty.body") }),
|
|
35844
|
-
|
|
35902
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", { children: t(srcTab === "file" ? "src.file.title" : srcTab === "camera" ? "src.cam.title" : srcTab === "push" ? "src.push.title" : "empty.title") }),
|
|
35903
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: srcTab === "camera" && devices && !mics.length ? t("src.cam.none") : t(srcTab === "file" ? "src.file.body" : srcTab === "camera" ? "src.cam.body" : srcTab === "push" ? "src.push.body" : "empty.body") }),
|
|
35904
|
+
srcTab === "file" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
35905
|
+
className: "dshl-btn",
|
|
35906
|
+
onClick: () => fileInput.current?.click(),
|
|
35907
|
+
children: [
|
|
35908
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.folder, {}),
|
|
35909
|
+
" ",
|
|
35910
|
+
t("src.file.pick")
|
|
35911
|
+
]
|
|
35912
|
+
}),
|
|
35913
|
+
srcTab === "push" && armed?.rtmp && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
35845
35914
|
className: "dshl-pushinfo",
|
|
35846
|
-
children:
|
|
35847
|
-
className: "title",
|
|
35848
|
-
children: [
|
|
35849
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.push, {}),
|
|
35850
|
-
" ",
|
|
35851
|
-
t("push.title"),
|
|
35852
|
-
" ",
|
|
35853
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("push.hint") })
|
|
35854
|
-
]
|
|
35855
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("dl", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyRow, {
|
|
35915
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("dl", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyRow, {
|
|
35856
35916
|
label: t("push.server"),
|
|
35857
35917
|
value: armed.rtmp,
|
|
35858
35918
|
copy: t("push.copy"),
|
|
@@ -35861,7 +35921,7 @@ function LiveView(props) {
|
|
|
35861
35921
|
value: armed.key,
|
|
35862
35922
|
t,
|
|
35863
35923
|
onChanged: () => void post("arm", options()).then((a) => setArmed(a.busy ? null : a)).catch(() => {})
|
|
35864
|
-
})] })
|
|
35924
|
+
})] })
|
|
35865
35925
|
})
|
|
35866
35926
|
]
|
|
35867
35927
|
});
|
|
@@ -36105,46 +36165,89 @@ function LiveView(props) {
|
|
|
36105
36165
|
title: t("page.back"),
|
|
36106
36166
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.arrow, {})
|
|
36107
36167
|
}),
|
|
36108
|
-
/* @__PURE__ */ (0, react_jsx_runtime.
|
|
36109
|
-
className: "
|
|
36110
|
-
|
|
36111
|
-
|
|
36112
|
-
|
|
36113
|
-
|
|
36114
|
-
|
|
36115
|
-
|
|
36116
|
-
|
|
36117
|
-
|
|
36118
|
-
|
|
36119
|
-
|
|
36120
|
-
})
|
|
36121
|
-
|
|
36122
|
-
className: "ghost",
|
|
36123
|
-
title: t("in.file.tip"),
|
|
36124
|
-
onClick: () => fileInput.current?.click(),
|
|
36125
|
-
children: uploading !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
36126
|
-
className: "pct",
|
|
36127
|
-
children: t("in.uploading").replace("{p}", String(uploading))
|
|
36128
|
-
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.folder, {})
|
|
36129
|
-
}),
|
|
36130
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
36131
|
-
ref: fileInput,
|
|
36132
|
-
type: "file",
|
|
36133
|
-
accept: "video/*,audio/*,.mkv,.ts,.flv",
|
|
36134
|
-
hidden: true,
|
|
36135
|
-
onChange: (e) => {
|
|
36136
|
-
const f = e.target.files?.[0];
|
|
36137
|
-
if (f) onFile(f);
|
|
36138
|
-
e.target.value = "";
|
|
36139
|
-
}
|
|
36140
|
-
})
|
|
36141
|
-
]
|
|
36168
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
|
|
36169
|
+
className: "srcpick",
|
|
36170
|
+
value: srcTab,
|
|
36171
|
+
onChange: (v) => setSrcTab(v),
|
|
36172
|
+
title: t("src.tip"),
|
|
36173
|
+
options: [
|
|
36174
|
+
"link",
|
|
36175
|
+
"file",
|
|
36176
|
+
"camera",
|
|
36177
|
+
"push"
|
|
36178
|
+
].map((k) => ({
|
|
36179
|
+
value: k,
|
|
36180
|
+
label: t(`src.${k}`)
|
|
36181
|
+
}))
|
|
36142
36182
|
}),
|
|
36143
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("
|
|
36183
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
36184
|
+
ref: fileInput,
|
|
36185
|
+
type: "file",
|
|
36186
|
+
accept: "video/*,audio/*,.mkv,.ts,.flv",
|
|
36187
|
+
hidden: true,
|
|
36188
|
+
onChange: (e) => {
|
|
36189
|
+
const f = e.target.files?.[0];
|
|
36190
|
+
if (f) onFile(f);
|
|
36191
|
+
e.target.value = "";
|
|
36192
|
+
}
|
|
36193
|
+
}),
|
|
36194
|
+
(srcTab === "link" || srcTab === "file") && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
36195
|
+
className: "dshl-field",
|
|
36196
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
36197
|
+
type: "text",
|
|
36198
|
+
value: input,
|
|
36199
|
+
placeholder: t(srcTab === "file" ? "src.file.placeholder" : "in.placeholder"),
|
|
36200
|
+
onChange: (e) => setInput(e.target.value),
|
|
36201
|
+
onKeyDown: (e) => {
|
|
36202
|
+
if (e.key === "Enter") start();
|
|
36203
|
+
}
|
|
36204
|
+
}), srcTab === "file" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
36205
|
+
className: "ghost",
|
|
36206
|
+
title: t("in.file.tip"),
|
|
36207
|
+
onClick: () => fileInput.current?.click(),
|
|
36208
|
+
children: uploading !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
36209
|
+
className: "pct",
|
|
36210
|
+
children: t("in.uploading").replace("{p}", String(uploading))
|
|
36211
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.folder, {})
|
|
36212
|
+
})]
|
|
36213
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
36144
36214
|
className: "dshl-btn primary",
|
|
36145
36215
|
onClick: () => start(),
|
|
36146
36216
|
disabled: !classify(input),
|
|
36147
36217
|
children: t("in.open")
|
|
36218
|
+
})] }),
|
|
36219
|
+
srcTab === "camera" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
36220
|
+
className: "dshl-devbar",
|
|
36221
|
+
children: devices === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
36222
|
+
className: "dshl-muted",
|
|
36223
|
+
children: t("src.cam.loading")
|
|
36224
|
+
}) : mics.length ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
|
|
36225
|
+
value: camV,
|
|
36226
|
+
options: videoOptions,
|
|
36227
|
+
onChange: setCamVideo,
|
|
36228
|
+
title: t("dev.video")
|
|
36229
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
|
|
36230
|
+
value: camA,
|
|
36231
|
+
options: mics.map((d) => ({
|
|
36232
|
+
value: d.id,
|
|
36233
|
+
label: d.name
|
|
36234
|
+
})),
|
|
36235
|
+
onChange: setCamAudio,
|
|
36236
|
+
title: t("dev.audio")
|
|
36237
|
+
})] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
36238
|
+
className: "dshl-muted",
|
|
36239
|
+
children: t("src.cam.none")
|
|
36240
|
+
})
|
|
36241
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
36242
|
+
className: "dshl-btn primary",
|
|
36243
|
+
onClick: () => void startDevice(),
|
|
36244
|
+
disabled: !camA,
|
|
36245
|
+
children: t("dev.start")
|
|
36246
|
+
})] }),
|
|
36247
|
+
srcTab === "push" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
36248
|
+
className: "dshl-field static",
|
|
36249
|
+
title: t("src.push.body"),
|
|
36250
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t(armed?.rtmp ? "src.push.bar" : active ? "src.push.busy" : "src.push.wait") })
|
|
36148
36251
|
}),
|
|
36149
36252
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Settings, {
|
|
36150
36253
|
engine: engine || "local",
|
|
@@ -36196,7 +36299,7 @@ function LiveView(props) {
|
|
|
36196
36299
|
}),
|
|
36197
36300
|
(error || job?.status === "error") && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
36198
36301
|
className: "dshl-note warn",
|
|
36199
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: error || job?.message }), job && !active && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
36302
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: error || (job?.message === "device-blocked" ? t("dev.blocked") : job?.message) }), job && !active && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
36200
36303
|
className: "dshl-link",
|
|
36201
36304
|
onClick: () => start(job.url, job.source),
|
|
36202
36305
|
children: t("st.retry")
|
|
@@ -37065,7 +37168,7 @@ const en = {
|
|
|
37065
37168
|
"in.uploading": "Uploading {p}%",
|
|
37066
37169
|
"in.drop": "Drop to start",
|
|
37067
37170
|
"empty.title": "Subtitles for any video",
|
|
37068
|
-
"empty.body": "YouTube, Bilibili and more —
|
|
37171
|
+
"empty.body": "Paste a link from YouTube, Bilibili and more — videos and live streams.",
|
|
37069
37172
|
"st.resolving": "Opening…",
|
|
37070
37173
|
"st.login": "Using your {b} login… if macOS asks, choose “Always Allow”.",
|
|
37071
37174
|
"st.live": "Live",
|
|
@@ -37078,6 +37181,33 @@ const en = {
|
|
|
37078
37181
|
"note.noModel": "No model set up, so subtitles stay in the original language.",
|
|
37079
37182
|
"note.install": "Install first:",
|
|
37080
37183
|
"push.title": "Stream here from OBS",
|
|
37184
|
+
"src.tip": "Where the video comes from",
|
|
37185
|
+
"src.link": "Link",
|
|
37186
|
+
"src.file": "Local file",
|
|
37187
|
+
"src.camera": "Camera",
|
|
37188
|
+
"src.push": "OBS stream",
|
|
37189
|
+
"src.file.placeholder": "Path of a video or audio file",
|
|
37190
|
+
"src.file.title": "Open a video on this computer",
|
|
37191
|
+
"src.file.body": "Drop a video or audio file here.",
|
|
37192
|
+
"src.file.pick": "Choose a file",
|
|
37193
|
+
"src.cam.title": "Talk to the camera",
|
|
37194
|
+
"src.cam.body": "Pick the picture and the microphone above and press Start: subtitles appear as you speak.",
|
|
37195
|
+
"src.cam.none": "No camera or microphone found on this computer.",
|
|
37196
|
+
"src.cam.loading": "Looking for cameras…",
|
|
37197
|
+
"src.push.title": "Stream from OBS",
|
|
37198
|
+
"src.push.body": "In OBS choose the Custom service and fill in the server and key below; subtitles start as soon as the picture arrives.",
|
|
37199
|
+
"src.push.bar": "Waiting for OBS: server and key below",
|
|
37200
|
+
"src.push.wait": "Getting the address ready…",
|
|
37201
|
+
"src.push.busy": "Close the current video to receive a stream",
|
|
37202
|
+
"dev.blocked": "No picture or sound from the device. Allow DeepSeek Harness under System Settings → Privacy & Security → Camera and Microphone (Screen Recording for the screen), then start again.",
|
|
37203
|
+
"dev.video": "Picture",
|
|
37204
|
+
"dev.audio": "Microphone",
|
|
37205
|
+
"dev.cameras": "Cameras",
|
|
37206
|
+
"dev.screens": "Screen",
|
|
37207
|
+
"dev.screen": "Screen {n}",
|
|
37208
|
+
"dev.other": "Other",
|
|
37209
|
+
"dev.noVideo": "Sound only",
|
|
37210
|
+
"dev.start": "Start",
|
|
37081
37211
|
"push.hint": "Service: Custom",
|
|
37082
37212
|
"push.copy": "Copy",
|
|
37083
37213
|
"push.copied": "Copied",
|
|
@@ -37456,7 +37586,7 @@ const zh = {
|
|
|
37456
37586
|
"in.uploading": "上传中 {p}%",
|
|
37457
37587
|
"in.drop": "松手开始",
|
|
37458
37588
|
"empty.title": "给任何视频配字幕",
|
|
37459
|
-
"empty.body": "
|
|
37589
|
+
"empty.body": "粘贴 YouTube、B 站等网站的视频或直播链接",
|
|
37460
37590
|
"st.resolving": "正在打开…",
|
|
37461
37591
|
"st.login": "正在用 {b} 的登录状态…如果系统弹窗询问,点「始终允许」",
|
|
37462
37592
|
"st.live": "直播中",
|
|
@@ -37469,6 +37599,33 @@ const zh = {
|
|
|
37469
37599
|
"note.noModel": "还没配置模型,字幕只显示原文",
|
|
37470
37600
|
"note.install": "需要先安装:",
|
|
37471
37601
|
"push.title": "用 OBS 推流到这里",
|
|
37602
|
+
"src.tip": "视频从哪里来",
|
|
37603
|
+
"src.link": "链接",
|
|
37604
|
+
"src.file": "本地文件",
|
|
37605
|
+
"src.camera": "摄像头",
|
|
37606
|
+
"src.push": "OBS 推流",
|
|
37607
|
+
"src.file.placeholder": "视频或音频文件的路径",
|
|
37608
|
+
"src.file.title": "打开这台电脑上的视频",
|
|
37609
|
+
"src.file.body": "把视频或音频文件拖到这里。",
|
|
37610
|
+
"src.file.pick": "选择文件",
|
|
37611
|
+
"src.cam.title": "对着摄像头说话",
|
|
37612
|
+
"src.cam.body": "在上面选好画面和麦克风,点开始,字幕边说边出。",
|
|
37613
|
+
"src.cam.none": "这台电脑上没有找到摄像头或麦克风。",
|
|
37614
|
+
"src.cam.loading": "正在找摄像头…",
|
|
37615
|
+
"src.push.title": "用 OBS 推流过来",
|
|
37616
|
+
"src.push.body": "OBS 里服务选「自定义」,填下面的服务器和密钥,画面一到字幕就开始。",
|
|
37617
|
+
"src.push.bar": "等 OBS 推流过来,服务器和密钥在下面",
|
|
37618
|
+
"src.push.wait": "正在准备推流地址…",
|
|
37619
|
+
"src.push.busy": "关掉当前视频后可以接收推流",
|
|
37620
|
+
"dev.blocked": "设备没有传来画面和声音。到 系统设置 → 隐私与安全性 → 摄像头、麦克风(用屏幕时是 屏幕录制)里打开 DeepSeek Harness,再点开始。",
|
|
37621
|
+
"dev.video": "画面",
|
|
37622
|
+
"dev.audio": "麦克风",
|
|
37623
|
+
"dev.cameras": "摄像头",
|
|
37624
|
+
"dev.screens": "屏幕",
|
|
37625
|
+
"dev.screen": "屏幕 {n}",
|
|
37626
|
+
"dev.other": "其他",
|
|
37627
|
+
"dev.noVideo": "只要声音",
|
|
37628
|
+
"dev.start": "开始",
|
|
37472
37629
|
"push.hint": "服务选「自定义」",
|
|
37473
37630
|
"push.copy": "复制",
|
|
37474
37631
|
"push.copied": "已复制",
|
|
@@ -37871,6 +38028,12 @@ const CSS = `
|
|
|
37871
38028
|
.dshl-pushinfo dd .err { flex-basis: 100%; color: #d1434b; font-size: 12px; }
|
|
37872
38029
|
.dshl-pushinfo .val { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; user-select: all; }
|
|
37873
38030
|
.dshl-pushinfo .dshl-link { flex: none; font-size: 12.5px; }
|
|
38031
|
+
.dshl-bar .dshl-select.srcpick { flex: none; width: auto; height: 40px; padding: 0 10px 0 14px; border-radius: 10px; font-weight: 550; }
|
|
38032
|
+
.dshl-devbar { display: flex; align-items: center; gap: 8px; flex: 1 1 260px; min-width: 0; }
|
|
38033
|
+
.dshl-devbar .dshl-select { flex: 1 1 0; min-width: 0; height: 40px; border-radius: 10px; }
|
|
38034
|
+
.dshl-field.static { color: var(--muted); cursor: default; }
|
|
38035
|
+
.dshl-field.static .mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; user-select: all; }
|
|
38036
|
+
.dshl-drop > .dshl-btn { margin-top: 20px; }
|
|
37874
38037
|
|
|
37875
38038
|
/* ── Layout ── */
|
|
37876
38039
|
.dshl-main { display: grid; grid-template-columns: minmax(0, 1fr) clamp(300px, 28vw, 400px); align-items: start; gap: 18px; padding: 18px 20px 28px; }
|
package/lib/index.mjs
CHANGED
|
@@ -1793,6 +1793,144 @@ var Restream = class extends EventEmitter {
|
|
|
1793
1793
|
this.changed();
|
|
1794
1794
|
}
|
|
1795
1795
|
};
|
|
1796
|
+
// src/devices.ts
|
|
1797
|
+
function ffmpegList(args) {
|
|
1798
|
+
return new Promise((resolve) => {
|
|
1799
|
+
execFile("ffmpeg", ["-hide_banner", ...args], { timeout: 8e3 }, (_err, _out, err) => resolve(String(err ?? "")));
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
async function listDevices() {
|
|
1803
|
+
if (process.platform === "darwin") {
|
|
1804
|
+
const text = await ffmpegList([
|
|
1805
|
+
"-f",
|
|
1806
|
+
"avfoundation",
|
|
1807
|
+
"-list_devices",
|
|
1808
|
+
"true",
|
|
1809
|
+
"-i",
|
|
1810
|
+
""
|
|
1811
|
+
]);
|
|
1812
|
+
const out = [];
|
|
1813
|
+
let section;
|
|
1814
|
+
for (const line of text.split("\n")) {
|
|
1815
|
+
if (/video devices:/i.test(line)) section = "video";
|
|
1816
|
+
else if (/audio devices:/i.test(line)) section = "audio";
|
|
1817
|
+
const m = /\]\s+\[(\d+)\]\s+(.+?)\s*$/.exec(line);
|
|
1818
|
+
if (!m || !section) continue;
|
|
1819
|
+
const [, index, name] = m;
|
|
1820
|
+
const id = name.includes(":") ? index : name;
|
|
1821
|
+
if (section === "audio") out.push({
|
|
1822
|
+
id,
|
|
1823
|
+
name,
|
|
1824
|
+
kind: "mic"
|
|
1825
|
+
});
|
|
1826
|
+
else out.push({
|
|
1827
|
+
id,
|
|
1828
|
+
name: name.replace(/^Capture screen (\d+)$/, "Screen $1"),
|
|
1829
|
+
kind: /^Capture screen/.test(name) ? "screen" : "camera"
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
return out.filter((d) => !(d.kind === "mic" && /speakers|audio device|blackhole|soundflower|loopback/i.test(d.name)));
|
|
1833
|
+
}
|
|
1834
|
+
if (process.platform === "win32") {
|
|
1835
|
+
const text = await ffmpegList([
|
|
1836
|
+
"-list_devices",
|
|
1837
|
+
"true",
|
|
1838
|
+
"-f",
|
|
1839
|
+
"dshow",
|
|
1840
|
+
"-i",
|
|
1841
|
+
"dummy"
|
|
1842
|
+
]);
|
|
1843
|
+
const out = [];
|
|
1844
|
+
let section;
|
|
1845
|
+
for (const line of text.split("\n")) {
|
|
1846
|
+
if (/DirectShow video devices/i.test(line)) section = "video";
|
|
1847
|
+
else if (/DirectShow audio devices/i.test(line)) section = "audio";
|
|
1848
|
+
if (/Alternative name/i.test(line)) continue;
|
|
1849
|
+
const m = /"([^"]+)"\s*(?:\((video|audio)\))?/.exec(line);
|
|
1850
|
+
if (!m) continue;
|
|
1851
|
+
const kind = m[2] ?? section;
|
|
1852
|
+
if (kind) out.push({
|
|
1853
|
+
id: m[1],
|
|
1854
|
+
name: m[1],
|
|
1855
|
+
kind: kind === "audio" ? "mic" : "camera"
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
return out;
|
|
1859
|
+
}
|
|
1860
|
+
return [...(existsSync("/dev") ? readdirSync("/dev").filter((f) => /^video\d+$/.test(f)) : []).map((f) => {
|
|
1861
|
+
let name = f;
|
|
1862
|
+
try {
|
|
1863
|
+
name = readFileSync(`/sys/class/video4linux/${f}/name`, "utf8").trim() || f;
|
|
1864
|
+
} catch {}
|
|
1865
|
+
return {
|
|
1866
|
+
id: `/dev/${f}`,
|
|
1867
|
+
name,
|
|
1868
|
+
kind: "camera"
|
|
1869
|
+
};
|
|
1870
|
+
}), {
|
|
1871
|
+
id: "default",
|
|
1872
|
+
name: "Default microphone",
|
|
1873
|
+
kind: "mic"
|
|
1874
|
+
}];
|
|
1875
|
+
}
|
|
1876
|
+
/** ffmpeg input for the chosen devices: arguments plus which streams carry the picture and the sound. */
|
|
1877
|
+
function deviceInput(c, q) {
|
|
1878
|
+
if (!c.audio) throw new Error("choose a microphone");
|
|
1879
|
+
if (process.platform === "darwin") return {
|
|
1880
|
+
args: [
|
|
1881
|
+
...q,
|
|
1882
|
+
"-f",
|
|
1883
|
+
"avfoundation",
|
|
1884
|
+
"-framerate",
|
|
1885
|
+
"30",
|
|
1886
|
+
...c.video ? ["-capture_cursor", "1"] : [],
|
|
1887
|
+
"-i",
|
|
1888
|
+
`${c.video ?? "none"}:${c.audio}`
|
|
1889
|
+
],
|
|
1890
|
+
audio: "0:a:0",
|
|
1891
|
+
video: c.video ? "0:v:0" : void 0
|
|
1892
|
+
};
|
|
1893
|
+
if (process.platform === "win32") return {
|
|
1894
|
+
args: [
|
|
1895
|
+
...q,
|
|
1896
|
+
"-f",
|
|
1897
|
+
"dshow",
|
|
1898
|
+
"-rtbufsize",
|
|
1899
|
+
"256M",
|
|
1900
|
+
"-i",
|
|
1901
|
+
[c.video ? `video=${c.video}` : "", `audio=${c.audio}`].filter(Boolean).join(":")
|
|
1902
|
+
],
|
|
1903
|
+
audio: "0:a:0",
|
|
1904
|
+
video: c.video ? "0:v:0" : void 0
|
|
1905
|
+
};
|
|
1906
|
+
return c.video ? {
|
|
1907
|
+
args: [
|
|
1908
|
+
...q,
|
|
1909
|
+
"-f",
|
|
1910
|
+
"v4l2",
|
|
1911
|
+
"-framerate",
|
|
1912
|
+
"30",
|
|
1913
|
+
"-i",
|
|
1914
|
+
c.video,
|
|
1915
|
+
...q,
|
|
1916
|
+
"-f",
|
|
1917
|
+
"pulse",
|
|
1918
|
+
"-i",
|
|
1919
|
+
c.audio
|
|
1920
|
+
],
|
|
1921
|
+
audio: "1:a:0",
|
|
1922
|
+
video: "0:v:0"
|
|
1923
|
+
} : {
|
|
1924
|
+
args: [
|
|
1925
|
+
...q,
|
|
1926
|
+
"-f",
|
|
1927
|
+
"pulse",
|
|
1928
|
+
"-i",
|
|
1929
|
+
c.audio
|
|
1930
|
+
],
|
|
1931
|
+
audio: "0:a:0"
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1796
1934
|
// src/job.ts
|
|
1797
1935
|
const run$1 = promisify(execFile);
|
|
1798
1936
|
/** URLs ffmpeg can open directly (no yt-dlp resolution). */
|
|
@@ -2055,6 +2193,14 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
2055
2193
|
vcodec: p.vcodec
|
|
2056
2194
|
};
|
|
2057
2195
|
}
|
|
2196
|
+
if (source === "device") {
|
|
2197
|
+
const c = JSON.parse(url);
|
|
2198
|
+
this.title = c.title || "Camera";
|
|
2199
|
+
return {
|
|
2200
|
+
...deviceInput(c, q),
|
|
2201
|
+
vcodec: "rawvideo"
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2058
2204
|
if (source === "push") {
|
|
2059
2205
|
const key = this.deps.ingest.key ?? this.id.slice(-4);
|
|
2060
2206
|
const { host, rtmpPort, srtPort } = this.deps.ingest;
|
|
@@ -2201,6 +2347,8 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
2201
2347
|
"23",
|
|
2202
2348
|
"-vf",
|
|
2203
2349
|
"scale=-2:min(720\\,ih)",
|
|
2350
|
+
"-pix_fmt",
|
|
2351
|
+
"yuv420p",
|
|
2204
2352
|
"-g",
|
|
2205
2353
|
"50"
|
|
2206
2354
|
]
|
|
@@ -2278,6 +2426,12 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
2278
2426
|
"pipe"
|
|
2279
2427
|
] });
|
|
2280
2428
|
this.ffmpeg = ff;
|
|
2429
|
+
if (this.opts.source === "device") setTimeout(() => {
|
|
2430
|
+
if (this.ffmpeg !== ff || this.segments().length) return;
|
|
2431
|
+
this.ffmpeg = void 0;
|
|
2432
|
+
ff.kill("SIGKILL");
|
|
2433
|
+
this.setStatus("error", "device-blocked");
|
|
2434
|
+
}, 12e3);
|
|
2281
2435
|
if (this.opts.source === "push") {
|
|
2282
2436
|
const wait = setInterval(() => {
|
|
2283
2437
|
if (this.ffmpeg !== ff) {
|
|
@@ -6122,7 +6276,7 @@ function apply(ctx, config) {
|
|
|
6122
6276
|
provider: typeof b.provider === "string" ? b.provider : "local",
|
|
6123
6277
|
mode: modeOf(b.mode)
|
|
6124
6278
|
} });
|
|
6125
|
-
const source = b.source === "push" || b.source === "file" ? b.source : "url";
|
|
6279
|
+
const source = b.source === "push" || b.source === "file" || b.source === "device" ? b.source : "url";
|
|
6126
6280
|
await disarm();
|
|
6127
6281
|
const raw = b.url.trim();
|
|
6128
6282
|
return send(res, 200, (await startJob({
|
|
@@ -6235,6 +6389,14 @@ function apply(ctx, config) {
|
|
|
6235
6389
|
}
|
|
6236
6390
|
return send(res, 200, job.summary());
|
|
6237
6391
|
}
|
|
6392
|
+
if (route === "devices") {
|
|
6393
|
+
const devices = await listDevices();
|
|
6394
|
+
log(`devices: ${devices.filter((d) => d.kind !== "mic").length} picture, ${devices.filter((d) => d.kind === "mic").length} microphone`);
|
|
6395
|
+
return send(res, 200, {
|
|
6396
|
+
devices,
|
|
6397
|
+
platform: process.platform
|
|
6398
|
+
});
|
|
6399
|
+
}
|
|
6238
6400
|
if (route === "pushkey" && req.method === "POST") {
|
|
6239
6401
|
if ([...jobs.values()].some((j) => j.opts.source === "push" && j.status === "running" && !j.message)) return send(res, 409, { error: "a stream is coming in; change the key after it ends" });
|
|
6240
6402
|
const b = await readJson(req);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vidi",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7",
|
|
4
4
|
"description": "A video tool for DeepSeek Harness: watch live streams, videos and podcasts with real-time subtitles in any language, then clip and download",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "PolyForm-Noncommercial-1.0.0",
|