dsh-vidi 0.7.5 → 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 +294 -194
- package/lib/index.mjs +153 -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(),
|
|
@@ -35841,6 +35913,10 @@ function LiveView(props) {
|
|
|
35841
35913
|
children: [
|
|
35842
35914
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", { children: t("empty.title") }),
|
|
35843
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
|
+
}),
|
|
35844
35920
|
armed?.rtmp && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
35845
35921
|
className: "dshl-pushinfo",
|
|
35846
35922
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -37078,6 +37154,16 @@ const en = {
|
|
|
37078
37154
|
"note.noModel": "No model set up, so subtitles stay in the original language.",
|
|
37079
37155
|
"note.install": "Install first:",
|
|
37080
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",
|
|
37081
37167
|
"push.hint": "Service: Custom",
|
|
37082
37168
|
"push.copy": "Copy",
|
|
37083
37169
|
"push.copied": "Copied",
|
|
@@ -37469,6 +37555,16 @@ const zh = {
|
|
|
37469
37555
|
"note.noModel": "还没配置模型,字幕只显示原文",
|
|
37470
37556
|
"note.install": "需要先安装:",
|
|
37471
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": "开始",
|
|
37472
37568
|
"push.hint": "服务选「自定义」",
|
|
37473
37569
|
"push.copy": "复制",
|
|
37474
37570
|
"push.copied": "已复制",
|
|
@@ -37871,6 +37967,10 @@ const CSS = `
|
|
|
37871
37967
|
.dshl-pushinfo dd .err { flex-basis: 100%; color: #d1434b; font-size: 12px; }
|
|
37872
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; }
|
|
37873
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; }
|
|
37874
37974
|
|
|
37875
37975
|
/* ── Layout ── */
|
|
37876
37976
|
.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
|
]
|
|
@@ -6122,7 +6270,7 @@ function apply(ctx, config) {
|
|
|
6122
6270
|
provider: typeof b.provider === "string" ? b.provider : "local",
|
|
6123
6271
|
mode: modeOf(b.mode)
|
|
6124
6272
|
} });
|
|
6125
|
-
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";
|
|
6126
6274
|
await disarm();
|
|
6127
6275
|
const raw = b.url.trim();
|
|
6128
6276
|
return send(res, 200, (await startJob({
|
|
@@ -6235,6 +6383,10 @@ function apply(ctx, config) {
|
|
|
6235
6383
|
}
|
|
6236
6384
|
return send(res, 200, job.summary());
|
|
6237
6385
|
}
|
|
6386
|
+
if (route === "devices") return send(res, 200, {
|
|
6387
|
+
devices: await listDevices(),
|
|
6388
|
+
platform: process.platform
|
|
6389
|
+
});
|
|
6238
6390
|
if (route === "pushkey" && req.method === "POST") {
|
|
6239
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" });
|
|
6240
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",
|