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.
Files changed (3) hide show
  1. package/lib/client.js +338 -213
  2. package/lib/index.mjs +169 -1
  3. 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
- /** A value to copy: plain text (not an input look-alike) and a text button. */
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 [rate, setRate] = (0, react.useState)(null);
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
- setRate(null);
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
- at: subs.at
35663
+ gap
35591
35664
  });
35592
35665
  while (log.length > 2 && log[0].wall < Date.now() - 12e4) log.shift();
35593
- const first = log[0], last = log.at(-1);
35594
- setRate(last.wall - first.wall > 45e3 ? (last.at - first.at) / ((last.wall - first.wall) / 1e3) : null);
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 && rate !== null && rate < .95;
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("{x}", rate.toFixed(2)) }), engineNow && !engineNow.online && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
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.map(([k, v]) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
36573
- className: "dshl-muted small",
36574
- children: t("usage.audio").replace("{s}", engineLabel(t, k)).replace("{t}", CLOCK(v))
36575
- }, k))
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.audio": "Recognised by {s}: {t}",
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": "Transcription runs slower than the stream ({x}×), so subtitles fall further behind.",
37043
- "lag.slowLocal": "This computer transcribes slower than the stream ({x}×), so subtitles fall further behind. An online service keeps up.",
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.audio": "{s} 识别 {t}",
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": "转写比直播慢({x} 倍速),字幕会越落越远。",
37431
- "lag.slowLocal": "本机转写比直播慢({x} 倍速),字幕会越落越远。换成在线转写就能跟上。",
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.4",
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",