dsh-vidi 0.7.4 → 0.7.6
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 +338 -213
- package/lib/index.mjs +169 -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,79 @@ function upload(file, onProgress) {
|
|
|
35187
35187
|
xhr.send(file);
|
|
35188
35188
|
});
|
|
35189
35189
|
}
|
|
35190
|
-
/**
|
|
35190
|
+
/** Subtitle a camera (or the screen) and a microphone on this computer directly: pick both, press start. */
|
|
35191
|
+
function DeviceCard({ t, onStart }) {
|
|
35192
|
+
const [devices, setDevices] = (0, react.useState)(null);
|
|
35193
|
+
const [video, setVideoRaw] = (0, react.useState)(() => store.get("device.video", ""));
|
|
35194
|
+
const [audio, setAudioRaw] = (0, react.useState)(() => store.get("device.audio", ""));
|
|
35195
|
+
const setVideo = (v) => {
|
|
35196
|
+
setVideoRaw(v);
|
|
35197
|
+
store.set("device.video", v);
|
|
35198
|
+
};
|
|
35199
|
+
const setAudio = (v) => {
|
|
35200
|
+
setAudioRaw(v);
|
|
35201
|
+
store.set("device.audio", v);
|
|
35202
|
+
};
|
|
35203
|
+
(0, react.useEffect)(() => {
|
|
35204
|
+
fetch("/live/api/devices").then((r) => r.json()).then((d) => setDevices(d.devices ?? [])).catch(() => setDevices([]));
|
|
35205
|
+
}, []);
|
|
35206
|
+
if (!devices) return null;
|
|
35207
|
+
const cams = devices.filter((d) => d.kind !== "mic"), mics = devices.filter((d) => d.kind === "mic");
|
|
35208
|
+
if (!mics.length) return null;
|
|
35209
|
+
const v = video === "none" || cams.some((d) => d.id === video) ? video : cams.find((d) => d.kind === "camera")?.id ?? "none";
|
|
35210
|
+
const a = mics.some((d) => d.id === audio) ? audio : mics[0].id;
|
|
35211
|
+
const videoOptions = [...cams.map((d) => ({
|
|
35212
|
+
value: d.id,
|
|
35213
|
+
label: d.kind === "screen" ? d.name.replace(/^Screen (\d+)$/, (_, n) => t("dev.screen").replace("{n}", String(Number(n) + 1))) : d.name,
|
|
35214
|
+
group: t(d.kind === "screen" ? "dev.screens" : "dev.cameras")
|
|
35215
|
+
})), {
|
|
35216
|
+
value: "none",
|
|
35217
|
+
label: t("dev.noVideo"),
|
|
35218
|
+
group: t("dev.other")
|
|
35219
|
+
}];
|
|
35220
|
+
const title = v === "none" ? mics.find((d) => d.id === a)?.name ?? "" : videoOptions.find((o) => o.value === v)?.label ?? "";
|
|
35221
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
35222
|
+
className: "dshl-pushinfo dshl-devices",
|
|
35223
|
+
children: [
|
|
35224
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
35225
|
+
className: "title",
|
|
35226
|
+
children: [
|
|
35227
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.camera, {}),
|
|
35228
|
+
" ",
|
|
35229
|
+
t("dev.title"),
|
|
35230
|
+
" ",
|
|
35231
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("dev.hint") })
|
|
35232
|
+
]
|
|
35233
|
+
}),
|
|
35234
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("dl", { children: [
|
|
35235
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: t("dev.video") }),
|
|
35236
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
|
|
35237
|
+
value: v,
|
|
35238
|
+
options: videoOptions,
|
|
35239
|
+
onChange: setVideo
|
|
35240
|
+
}) }),
|
|
35241
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: t("dev.audio") }),
|
|
35242
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
|
|
35243
|
+
value: a,
|
|
35244
|
+
options: mics.map((d) => ({
|
|
35245
|
+
value: d.id,
|
|
35246
|
+
label: d.name
|
|
35247
|
+
})),
|
|
35248
|
+
onChange: setAudio
|
|
35249
|
+
}) })
|
|
35250
|
+
] }),
|
|
35251
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
35252
|
+
className: "dshl-btn primary",
|
|
35253
|
+
onClick: () => onStart({
|
|
35254
|
+
video: v === "none" ? void 0 : v,
|
|
35255
|
+
audio: a,
|
|
35256
|
+
title
|
|
35257
|
+
}),
|
|
35258
|
+
children: t("dev.start")
|
|
35259
|
+
})
|
|
35260
|
+
]
|
|
35261
|
+
});
|
|
35262
|
+
}
|
|
35191
35263
|
/** The push stream key: copy it, or change it (typed, or a new random one). */
|
|
35192
35264
|
function KeyRow({ value, t, onChanged }) {
|
|
35193
35265
|
const [edit, setEdit] = (0, react.useState)(null);
|
|
@@ -35517,7 +35589,7 @@ function LiveView(props) {
|
|
|
35517
35589
|
url: target
|
|
35518
35590
|
} : classify(target);
|
|
35519
35591
|
if (!c) return;
|
|
35520
|
-
if (props.onNewVideo && job && job.source !== "push" && job.url !== c.url && c.source !== "push" && await props.onNewVideo(target)) return;
|
|
35592
|
+
if (props.onNewVideo && job && job.source !== "push" && job.url !== c.url && c.source !== "push" && c.source !== "device" && await props.onNewVideo(target)) return;
|
|
35521
35593
|
try {
|
|
35522
35594
|
const j = await post("start", {
|
|
35523
35595
|
...options(),
|
|
@@ -35577,24 +35649,26 @@ function LiveView(props) {
|
|
|
35577
35649
|
props.onTitle(job.title);
|
|
35578
35650
|
}, [job?.id, job?.title]);
|
|
35579
35651
|
const rateLog = (0, react.useRef)([]);
|
|
35580
|
-
const [
|
|
35652
|
+
const [behindRec, setBehindRec] = (0, react.useState)(null);
|
|
35581
35653
|
(0, react.useEffect)(() => {
|
|
35582
|
-
if (!job || job.vod || job.status !== "running" || subs.state !== "running") {
|
|
35654
|
+
if (!job || job.vod || job.status !== "running" || subs.state !== "running" || job.recorded === void 0) {
|
|
35583
35655
|
rateLog.current = [];
|
|
35584
|
-
|
|
35656
|
+
setBehindRec(null);
|
|
35585
35657
|
return;
|
|
35586
35658
|
}
|
|
35587
35659
|
const log = rateLog.current;
|
|
35660
|
+
const gap = Math.max(0, job.recorded - subs.at);
|
|
35588
35661
|
log.push({
|
|
35589
35662
|
wall: Date.now(),
|
|
35590
|
-
|
|
35663
|
+
gap
|
|
35591
35664
|
});
|
|
35592
35665
|
while (log.length > 2 && log[0].wall < Date.now() - 12e4) log.shift();
|
|
35593
|
-
const first = log[0]
|
|
35594
|
-
|
|
35666
|
+
const first = log[0];
|
|
35667
|
+
setBehindRec(Date.now() - first.wall > 45e3 && gap > 20 && gap - first.gap > 8 ? gap : null);
|
|
35595
35668
|
}, [
|
|
35596
35669
|
subs.at,
|
|
35597
35670
|
subs.state,
|
|
35671
|
+
job?.recorded,
|
|
35598
35672
|
job?.status,
|
|
35599
35673
|
job?.id
|
|
35600
35674
|
]);
|
|
@@ -35839,6 +35913,10 @@ function LiveView(props) {
|
|
|
35839
35913
|
children: [
|
|
35840
35914
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", { children: t("empty.title") }),
|
|
35841
35915
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("empty.body") }),
|
|
35916
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DeviceCard, {
|
|
35917
|
+
t,
|
|
35918
|
+
onStart: (c) => void start(JSON.stringify(c), "device")
|
|
35919
|
+
}),
|
|
35842
35920
|
armed?.rtmp && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
35843
35921
|
className: "dshl-pushinfo",
|
|
35844
35922
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -36055,11 +36133,11 @@ function LiveView(props) {
|
|
|
36055
36133
|
onTerms: setTerms
|
|
36056
36134
|
})
|
|
36057
36135
|
] });
|
|
36058
|
-
const slow = !!job && job.status === "running" && !job.vod &&
|
|
36136
|
+
const slow = !!job && job.status === "running" && !job.vod && behindRec !== null;
|
|
36059
36137
|
const engineNow = providers.find((x) => x.id === usableEngine());
|
|
36060
36138
|
const liveNote = slow ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
36061
36139
|
className: "dshl-lag warn",
|
|
36062
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t(engineNow && !engineNow.online ? "lag.slowLocal" : "lag.slow").replace("{
|
|
36140
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t(engineNow && !engineNow.online ? "lag.slowLocal" : "lag.slow").replace("{s}", String(Math.round(behindRec))) }), engineNow && !engineNow.online && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
36063
36141
|
className: "dshl-link",
|
|
36064
36142
|
onClick: () => setSettingsOpen(true),
|
|
36065
36143
|
children: t("lag.settings")
|
|
@@ -36528,6 +36606,10 @@ function SubsStarter(props) {
|
|
|
36528
36606
|
});
|
|
36529
36607
|
}
|
|
36530
36608
|
const fmtTokens = (n, ui) => ui === "zh" && n >= 1e4 ? `${(n / 1e4).toFixed(n >= 1e5 ? 0 : 1)}万` : n >= 1e3 ? `${(n / 1e3).toFixed(n >= 1e5 ? 0 : 1)}k` : String(n);
|
|
36609
|
+
/** Engines billed by the minute of audio (no token counts come back): US$ per minute, from the provider's price list. */
|
|
36610
|
+
const PER_MINUTE = { "openai-translate": .034 };
|
|
36611
|
+
const audioCost = (audio) => audio.reduce((sum, [k, v]) => sum + (PER_MINUTE[k] ?? 0) * v / 60, 0);
|
|
36612
|
+
const usd = (v) => `$${v.toFixed(2)}`;
|
|
36531
36613
|
/** Total tokens for this video; hover or click for per-model input / output / cache. */
|
|
36532
36614
|
function UsagePill({ usage, t, ui }) {
|
|
36533
36615
|
const [open, setOpen] = (0, react.useState)(false);
|
|
@@ -36542,7 +36624,7 @@ function UsagePill({ usage, t, ui }) {
|
|
|
36542
36624
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
36543
36625
|
className: "dshl-pill",
|
|
36544
36626
|
onClick: () => setOpen((v) => !v),
|
|
36545
|
-
children: t("usage.tokens").replace("{n}", n(total))
|
|
36627
|
+
children: [total ? t("usage.tokens").replace("{n}", n(total)) : "", audioCost(audio) >= .01 ? usd(audioCost(audio)) : ""].filter(Boolean).join(" · ")
|
|
36546
36628
|
}), open && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
36547
36629
|
className: "dshl-pop right up usage",
|
|
36548
36630
|
children: [
|
|
@@ -36569,10 +36651,21 @@ function UsagePill({ usage, t, ui }) {
|
|
|
36569
36651
|
className: "dshl-muted",
|
|
36570
36652
|
children: t("usage.none")
|
|
36571
36653
|
}),
|
|
36572
|
-
audio.
|
|
36573
|
-
className: "
|
|
36574
|
-
children:
|
|
36575
|
-
|
|
36654
|
+
audio.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
|
|
36655
|
+
className: "audio",
|
|
36656
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
|
|
36657
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("usage.byAudio") }),
|
|
36658
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("usage.duration") }),
|
|
36659
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("usage.cost") })
|
|
36660
|
+
] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: audio.map(([k, v]) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
|
|
36661
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: engineLabel(t, k) }),
|
|
36662
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: CLOCK(v) }),
|
|
36663
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
|
|
36664
|
+
title: PER_MINUTE[k] ? t("usage.rate").replace("{p}", String(PER_MINUTE[k])) : void 0,
|
|
36665
|
+
children: PER_MINUTE[k] ? "≈ " + usd(PER_MINUTE[k] * v / 60) : "—"
|
|
36666
|
+
})
|
|
36667
|
+
] }, k)) })]
|
|
36668
|
+
})
|
|
36576
36669
|
]
|
|
36577
36670
|
})]
|
|
36578
36671
|
});
|
|
@@ -37012,7 +37105,10 @@ const en = {
|
|
|
37012
37105
|
"usage.cache": "Cache",
|
|
37013
37106
|
"usage.calls": "Calls",
|
|
37014
37107
|
"usage.reasoning": "incl. {n} thinking",
|
|
37015
|
-
"usage.
|
|
37108
|
+
"usage.byAudio": "Billed by audio time",
|
|
37109
|
+
"usage.duration": "Audio",
|
|
37110
|
+
"usage.cost": "Cost",
|
|
37111
|
+
"usage.rate": "US${p} per minute of audio (estimate from the price list)",
|
|
37016
37112
|
"usage.none": "Nothing used yet",
|
|
37017
37113
|
"set.mode": "Mode",
|
|
37018
37114
|
"st.loading": "Loading",
|
|
@@ -37039,8 +37135,8 @@ const en = {
|
|
|
37039
37135
|
"hold.polished": "Waiting for polished subtitles…",
|
|
37040
37136
|
"hold.skip": "Don’t wait",
|
|
37041
37137
|
"hold.behind": "(subtitles run {s}s behind the sound)",
|
|
37042
|
-
"lag.slow": "
|
|
37043
|
-
"lag.slowLocal": "
|
|
37138
|
+
"lag.slow": "Subtitles are {s} s behind the stream and falling further behind.",
|
|
37139
|
+
"lag.slowLocal": "Subtitles are {s} s behind the stream and falling further behind: this computer transcribes too slowly. An online service keeps up.",
|
|
37044
37140
|
"lag.settings": "Choose one",
|
|
37045
37141
|
"in.uploading": "Uploading {p}%",
|
|
37046
37142
|
"in.drop": "Drop to start",
|
|
@@ -37058,6 +37154,16 @@ const en = {
|
|
|
37058
37154
|
"note.noModel": "No model set up, so subtitles stay in the original language.",
|
|
37059
37155
|
"note.install": "Install first:",
|
|
37060
37156
|
"push.title": "Stream here from OBS",
|
|
37157
|
+
"dev.title": "Use a camera on this computer",
|
|
37158
|
+
"dev.hint": "Subtitles as you speak",
|
|
37159
|
+
"dev.video": "Picture",
|
|
37160
|
+
"dev.audio": "Microphone",
|
|
37161
|
+
"dev.cameras": "Cameras",
|
|
37162
|
+
"dev.screens": "Screen",
|
|
37163
|
+
"dev.screen": "Screen {n}",
|
|
37164
|
+
"dev.other": "Other",
|
|
37165
|
+
"dev.noVideo": "Sound only",
|
|
37166
|
+
"dev.start": "Start",
|
|
37061
37167
|
"push.hint": "Service: Custom",
|
|
37062
37168
|
"push.copy": "Copy",
|
|
37063
37169
|
"push.copied": "Copied",
|
|
@@ -37400,7 +37506,10 @@ const zh = {
|
|
|
37400
37506
|
"usage.cache": "缓存",
|
|
37401
37507
|
"usage.calls": "次数",
|
|
37402
37508
|
"usage.reasoning": "含思考 {n}",
|
|
37403
|
-
"usage.
|
|
37509
|
+
"usage.byAudio": "按音频时长计费",
|
|
37510
|
+
"usage.duration": "时长",
|
|
37511
|
+
"usage.cost": "费用",
|
|
37512
|
+
"usage.rate": "按每分钟音频 {p} 美元估算(官方价目)",
|
|
37404
37513
|
"usage.none": "还没有用量",
|
|
37405
37514
|
"set.mode": "模式",
|
|
37406
37515
|
"st.loading": "加载中",
|
|
@@ -37427,8 +37536,8 @@ const zh = {
|
|
|
37427
37536
|
"hold.polished": "等精修字幕…",
|
|
37428
37537
|
"hold.skip": "不等了",
|
|
37429
37538
|
"hold.behind": "(字幕比声音晚 {s} 秒)",
|
|
37430
|
-
"lag.slow": "
|
|
37431
|
-
"lag.slowLocal": "
|
|
37539
|
+
"lag.slow": "字幕比直播晚了 {s} 秒,还在越拉越远。",
|
|
37540
|
+
"lag.slowLocal": "字幕比直播晚了 {s} 秒,还在越拉越远:本机转写太慢。换成在线转写就能跟上。",
|
|
37432
37541
|
"lag.settings": "去选一个",
|
|
37433
37542
|
"in.uploading": "上传中 {p}%",
|
|
37434
37543
|
"in.drop": "松手开始",
|
|
@@ -37446,6 +37555,16 @@ const zh = {
|
|
|
37446
37555
|
"note.noModel": "还没配置模型,字幕只显示原文",
|
|
37447
37556
|
"note.install": "需要先安装:",
|
|
37448
37557
|
"push.title": "用 OBS 推流到这里",
|
|
37558
|
+
"dev.title": "用这台电脑的摄像头",
|
|
37559
|
+
"dev.hint": "边说边出字幕",
|
|
37560
|
+
"dev.video": "画面",
|
|
37561
|
+
"dev.audio": "麦克风",
|
|
37562
|
+
"dev.cameras": "摄像头",
|
|
37563
|
+
"dev.screens": "屏幕",
|
|
37564
|
+
"dev.screen": "屏幕 {n}",
|
|
37565
|
+
"dev.other": "其他",
|
|
37566
|
+
"dev.noVideo": "只要声音",
|
|
37567
|
+
"dev.start": "开始",
|
|
37449
37568
|
"push.hint": "服务选「自定义」",
|
|
37450
37569
|
"push.copy": "复制",
|
|
37451
37570
|
"push.copied": "已复制",
|
|
@@ -37848,6 +37967,10 @@ const CSS = `
|
|
|
37848
37967
|
.dshl-pushinfo dd .err { flex-basis: 100%; color: #d1434b; font-size: 12px; }
|
|
37849
37968
|
.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; }
|
|
37850
37969
|
.dshl-pushinfo .dshl-link { flex: none; font-size: 12.5px; }
|
|
37970
|
+
.dshl-devices { display: grid; width: fit-content; max-width: 100%; margin: 32px auto 0; }
|
|
37971
|
+
.dshl-devices + .dshl-pushinfo { margin-top: 12px; }
|
|
37972
|
+
.dshl-devices dd .dshl-select { width: 240px; max-width: 100%; height: 30px; }
|
|
37973
|
+
.dshl-devices > .dshl-btn { justify-self: end; height: 32px; padding: 0 18px; border-radius: 8px; font-size: 13px; }
|
|
37851
37974
|
|
|
37852
37975
|
/* ── Layout ── */
|
|
37853
37976
|
.dshl-main { display: grid; grid-template-columns: minmax(0, 1fr) clamp(300px, 28vw, 400px); align-items: start; gap: 18px; padding: 18px 20px 28px; }
|
|
@@ -38303,6 +38426,8 @@ body > .dshl-menu { z-index: 2147483002; }
|
|
|
38303
38426
|
.dshl-pop.usage td { padding: 4px 0 4px 10px; border-top: 1px solid var(--line-soft); text-align: right; }
|
|
38304
38427
|
.dshl-pop.usage th:first-child, .dshl-pop.usage td:first-child { max-width: 140px; padding-left: 0; overflow: hidden; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
|
|
38305
38428
|
.dshl-pop.usage p { margin: 8px 0 0; }
|
|
38429
|
+
.dshl-pop.usage table + table { margin-top: 12px; }
|
|
38430
|
+
.dshl-pop.usage table.audio td:first-child { max-width: 180px; }
|
|
38306
38431
|
@media (max-width: 600px) { .dshl-pop.settings.right { position: fixed; top: 58px; left: 12px; right: 12px; width: auto; max-width: none; } }
|
|
38307
38432
|
|
|
38308
38433
|
/* ── Login dialog ── */
|
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). */
|
|
@@ -1902,6 +2040,7 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
1902
2040
|
ingest: this.ingest,
|
|
1903
2041
|
vod: this.vod,
|
|
1904
2042
|
duration: this.duration,
|
|
2043
|
+
recorded: this.vod ? void 0 : this.recordedSec(),
|
|
1905
2044
|
loginBrowser: this.loginBrowser,
|
|
1906
2045
|
needsLogin: this.needsLogin,
|
|
1907
2046
|
hls: this.hlsName,
|
|
@@ -2054,6 +2193,14 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
2054
2193
|
vcodec: p.vcodec
|
|
2055
2194
|
};
|
|
2056
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
|
+
}
|
|
2057
2204
|
if (source === "push") {
|
|
2058
2205
|
const key = this.deps.ingest.key ?? this.id.slice(-4);
|
|
2059
2206
|
const { host, rtmpPort, srtPort } = this.deps.ingest;
|
|
@@ -2200,6 +2347,8 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
2200
2347
|
"23",
|
|
2201
2348
|
"-vf",
|
|
2202
2349
|
"scale=-2:min(720\\,ih)",
|
|
2350
|
+
"-pix_fmt",
|
|
2351
|
+
"yuv420p",
|
|
2203
2352
|
"-g",
|
|
2204
2353
|
"50"
|
|
2205
2354
|
]
|
|
@@ -2317,6 +2466,21 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
2317
2466
|
get m3u8() {
|
|
2318
2467
|
return join(this.deps.dir, this.hlsName, "index.m3u8");
|
|
2319
2468
|
}
|
|
2469
|
+
/** Seconds recorded so far (live), from the playlist; re-read at most once a second. */
|
|
2470
|
+
recCache = {
|
|
2471
|
+
at: 0,
|
|
2472
|
+
sec: 0
|
|
2473
|
+
};
|
|
2474
|
+
recordedSec() {
|
|
2475
|
+
if (Date.now() - this.recCache.at > 1e3) {
|
|
2476
|
+
const last = this.segments().at(-1);
|
|
2477
|
+
this.recCache = {
|
|
2478
|
+
at: Date.now(),
|
|
2479
|
+
sec: last ? last.start + last.dur : 0
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
return this.recCache.sec;
|
|
2483
|
+
}
|
|
2320
2484
|
/** Recorded segments so far: start time on the playback timeline and duration. */
|
|
2321
2485
|
segments() {
|
|
2322
2486
|
if (!existsSync(this.m3u8)) return [];
|
|
@@ -6106,7 +6270,7 @@ function apply(ctx, config) {
|
|
|
6106
6270
|
provider: typeof b.provider === "string" ? b.provider : "local",
|
|
6107
6271
|
mode: modeOf(b.mode)
|
|
6108
6272
|
} });
|
|
6109
|
-
const source = b.source === "push" || b.source === "file" ? b.source : "url";
|
|
6273
|
+
const source = b.source === "push" || b.source === "file" || b.source === "device" ? b.source : "url";
|
|
6110
6274
|
await disarm();
|
|
6111
6275
|
const raw = b.url.trim();
|
|
6112
6276
|
return send(res, 200, (await startJob({
|
|
@@ -6219,6 +6383,10 @@ function apply(ctx, config) {
|
|
|
6219
6383
|
}
|
|
6220
6384
|
return send(res, 200, job.summary());
|
|
6221
6385
|
}
|
|
6386
|
+
if (route === "devices") return send(res, 200, {
|
|
6387
|
+
devices: await listDevices(),
|
|
6388
|
+
platform: process.platform
|
|
6389
|
+
});
|
|
6222
6390
|
if (route === "pushkey" && req.method === "POST") {
|
|
6223
6391
|
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" });
|
|
6224
6392
|
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.6",
|
|
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",
|