dsh-vidi 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib/client.js +515 -142
  2. package/lib/index.mjs +38 -13
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -776,6 +776,194 @@ function langLabel(code, ui, fallback) {
776
776
  return NAMES[code]?.[ui] ?? fallback ?? code;
777
777
  }
778
778
 
779
+ // src/client/Pick.tsx
780
+ /** Theme tokens copied onto the list, so it looks like the place it opened from (light page, dark player menu). */
781
+ const TOKENS = [
782
+ "--bg",
783
+ "--l1",
784
+ "--l2",
785
+ "--ink",
786
+ "--muted",
787
+ "--faint",
788
+ "--line",
789
+ "--hover",
790
+ "--accent"
791
+ ];
792
+ /**
793
+ * A drop-down in the page's own style, in place of the browser's <select>
794
+ * (which draws a system menu as wide as the screen allows). The list is sized
795
+ * to its options, scrolls when long, opens upward when there is no room
796
+ * below, and works with the keyboard (arrows, Enter, Esc, typing a letter).
797
+ */
798
+ function Pick(props) {
799
+ const { value, options, onChange, placeholder, disabled, className, title, label } = props;
800
+ const [open, setOpen] = (0, react.useState)(false);
801
+ const [active, setActive] = (0, react.useState)(-1);
802
+ const [place, setPlace] = (0, react.useState)({});
803
+ const btn = (0, react.useRef)(null);
804
+ const list = (0, react.useRef)(null);
805
+ const chosen = options.find((o) => o.value === value);
806
+ const usable = options.map((o, i) => o.disabled ? -1 : i).filter((i) => i >= 0);
807
+ const close = (focus = true) => {
808
+ setOpen(false);
809
+ if (focus) btn.current?.focus();
810
+ };
811
+ const choose = (o) => {
812
+ if (o.disabled) return;
813
+ onChange(o.value);
814
+ close();
815
+ };
816
+ (0, react.useLayoutEffect)(() => {
817
+ if (!open || !btn.current) return;
818
+ const r = btn.current.getBoundingClientRect();
819
+ const cs = getComputedStyle(btn.current);
820
+ const vars = Object.fromEntries(TOKENS.map((k) => [k, cs.getPropertyValue(k).trim()]).filter(([, v]) => v));
821
+ const room = window.innerHeight - r.bottom - 8, above = r.top - 8;
822
+ const up = room < 220 && above > room;
823
+ const maxH = Math.max(120, Math.min(320, up ? above - 4 : room - 4));
824
+ const width = Math.min(Math.max(r.width, 180), window.innerWidth - 16);
825
+ const left = Math.min(Math.max(8, r.left), window.innerWidth - width - 8);
826
+ setPlace({
827
+ ...vars,
828
+ left,
829
+ width,
830
+ maxHeight: maxH,
831
+ ...up ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 },
832
+ fontFamily: cs.fontFamily
833
+ });
834
+ setActive(Math.max(0, options.findIndex((o) => o.value === value)));
835
+ }, [open]);
836
+ (0, react.useEffect)(() => {
837
+ list.current?.querySelector(`[data-i="${active}"]`)?.scrollIntoView({ block: "nearest" });
838
+ }, [active, open]);
839
+ (0, react.useEffect)(() => {
840
+ if (!open) return;
841
+ const away = (e) => {
842
+ if (!list.current?.contains(e.target) && !btn.current?.contains(e.target)) close(false);
843
+ };
844
+ const gone = (e) => {
845
+ if (!list.current?.contains(e.target)) close(false);
846
+ };
847
+ document.addEventListener("pointerdown", away, true);
848
+ window.addEventListener("resize", gone);
849
+ window.addEventListener("scroll", gone, true);
850
+ return () => {
851
+ document.removeEventListener("pointerdown", away, true);
852
+ window.removeEventListener("resize", gone);
853
+ window.removeEventListener("scroll", gone, true);
854
+ };
855
+ }, [open]);
856
+ const onKey = (e) => {
857
+ if (disabled) return;
858
+ const step = (d) => {
859
+ const at = usable.indexOf(active);
860
+ setActive(usable[Math.min(usable.length - 1, Math.max(0, (at < 0 ? d > 0 ? -1 : usable.length : at) + d))] ?? -1);
861
+ };
862
+ if (!open) {
863
+ if ([
864
+ "ArrowDown",
865
+ "ArrowUp",
866
+ "Enter",
867
+ " "
868
+ ].includes(e.key)) {
869
+ e.preventDefault();
870
+ setOpen(true);
871
+ }
872
+ return;
873
+ }
874
+ if (e.key === "Escape") {
875
+ e.preventDefault();
876
+ e.stopPropagation();
877
+ close();
878
+ } else if (e.key === "ArrowDown") {
879
+ e.preventDefault();
880
+ step(1);
881
+ } else if (e.key === "ArrowUp") {
882
+ e.preventDefault();
883
+ step(-1);
884
+ } else if (e.key === "Home") {
885
+ e.preventDefault();
886
+ setActive(usable[0] ?? -1);
887
+ } else if (e.key === "End") {
888
+ e.preventDefault();
889
+ setActive(usable.at(-1) ?? -1);
890
+ } else if (e.key === "Enter" || e.key === " ") {
891
+ e.preventDefault();
892
+ const o = options[active];
893
+ if (o) choose(o);
894
+ } else if (e.key === "Tab") close(false);
895
+ else if (e.key.length === 1) {
896
+ const k = e.key.toLowerCase();
897
+ const from = usable.indexOf(active);
898
+ const hit = [...usable.slice(from + 1), ...usable.slice(0, from + 1)].find((i) => options[i].label.toLowerCase().startsWith(k));
899
+ if (hit !== void 0) setActive(hit);
900
+ }
901
+ };
902
+ let lastGroup;
903
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
904
+ ref: btn,
905
+ type: "button",
906
+ className: `dshl-select ${open ? "open" : ""} ${className ?? ""}`,
907
+ disabled,
908
+ title,
909
+ "aria-haspopup": "listbox",
910
+ "aria-expanded": open,
911
+ onClick: () => setOpen((v) => !v),
912
+ onKeyDown: onKey,
913
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
914
+ className: "v",
915
+ children: label ?? chosen?.label ?? placeholder ?? ""
916
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {
917
+ className: "caret",
918
+ "aria-hidden": "true"
919
+ })]
920
+ }), open && (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
921
+ ref: list,
922
+ className: "dshl-options",
923
+ role: "listbox",
924
+ style: place,
925
+ onKeyDown: onKey,
926
+ children: options.map((o, i) => {
927
+ const head = o.group && o.group !== lastGroup ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
928
+ className: "grp",
929
+ children: o.group
930
+ }, `g:${o.group}`) : null;
931
+ lastGroup = o.group;
932
+ return [head, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
933
+ "data-i": i,
934
+ role: "option",
935
+ "aria-selected": o.value === value,
936
+ "aria-disabled": o.disabled || void 0,
937
+ className: `opt ${o.value === value ? "on" : ""} ${i === active ? "act" : ""} ${o.disabled ? "off" : ""}`,
938
+ onPointerEnter: () => !o.disabled && setActive(i),
939
+ onClick: () => choose(o),
940
+ children: [
941
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
942
+ className: "l",
943
+ children: o.label
944
+ }),
945
+ o.hint && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
946
+ className: "h",
947
+ children: o.hint
948
+ }),
949
+ o.value === value && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
950
+ className: "ok",
951
+ viewBox: "0 0 24 24",
952
+ width: "14",
953
+ height: "14",
954
+ fill: "none",
955
+ stroke: "currentColor",
956
+ strokeWidth: "2.4",
957
+ strokeLinecap: "round",
958
+ strokeLinejoin: "round",
959
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12.5l4.5 4.5L19 7.5" })
960
+ })
961
+ ]
962
+ }, o.value)];
963
+ })
964
+ }), document.fullscreenElement ?? document.body)] });
965
+ }
966
+
779
967
  // src/client/LineChips.tsx
780
968
  const MAX_TRANSLATIONS = 4;
781
969
  /**
@@ -810,20 +998,18 @@ function LineChips(props) {
810
998
  children: [i >= 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", { children: i + 1 }), label(k)]
811
999
  }, k);
812
1000
  }),
813
- translations.length < MAX_TRANSLATIONS && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1001
+ translations.length < MAX_TRANSLATIONS && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
814
1002
  className: "chip add",
815
1003
  value: "",
816
- onChange: (e) => {
817
- if (e.target.value) onChange([...lines, e.target.value]);
818
- },
1004
+ label: t("sub.addLang"),
819
1005
  title: t("in.addLang"),
820
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
821
- value: "",
822
- children: t("sub.addLang")
823
- }), options.filter(([k]) => !lines.includes(k)).map(([k, v]) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1006
+ onChange: (v) => {
1007
+ if (v) onChange([...lines, v]);
1008
+ },
1009
+ options: options.filter(([k]) => !lines.includes(k)).map(([k, v]) => ({
824
1010
  value: k,
825
- children: v
826
- }, k))]
1011
+ label: v
1012
+ }))
827
1013
  }),
828
1014
  lines.length > 1 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
829
1015
  className: "chip ghost",
@@ -860,30 +1046,26 @@ function SubtitleMenu(props) {
860
1046
  const second = lines[1] ?? "";
861
1047
  const label = (k) => site.find((s) => s.key === k)?.label ?? name(k);
862
1048
  const inUse = lines.filter((l) => l !== "src" && !l.startsWith("site:"));
863
- const choices = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
864
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1049
+ const choices = [
1050
+ {
865
1051
  value: "src",
866
- children: label("src")
867
- }),
868
- inUse.map((k) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1052
+ label: label("src")
1053
+ },
1054
+ ...inUse.map((k) => ({
869
1055
  value: k,
870
- children: label(k)
871
- }, k)),
872
- site.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("optgroup", {
873
- label: t("sub.siteGroup"),
874
- children: site.map((s) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
875
- value: s.key,
876
- children: s.label
877
- }, s.key))
878
- }),
879
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("optgroup", {
880
- label: t("sub.translateTo"),
881
- children: options.filter(([k]) => !inUse.includes(k)).map(([k, v]) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
882
- value: k,
883
- children: v
884
- }, k))
885
- })
886
- ] });
1056
+ label: label(k)
1057
+ })),
1058
+ ...site.map((s) => ({
1059
+ value: s.key,
1060
+ label: s.label,
1061
+ group: t("sub.siteGroup")
1062
+ })),
1063
+ ...options.filter(([k]) => !inUse.includes(k)).map(([k, v]) => ({
1064
+ value: k,
1065
+ label: v,
1066
+ group: t("sub.translateTo")
1067
+ }))
1068
+ ];
887
1069
  const setMain = (v) => onLines(v === second ? [v, main] : second ? [v, second] : [v]);
888
1070
  const setSecond = (v) => onLines(!v ? [main] : v === main ? [main] : [main, v]);
889
1071
  const first = style.lines.find((l) => l.show)?.size ?? SIZES.m[0];
@@ -903,21 +1085,21 @@ function SubtitleMenu(props) {
903
1085
  children: [
904
1086
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
905
1087
  className: "slot",
906
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sub.main") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1088
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sub.main") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
907
1089
  value: main,
908
- onChange: (e) => setMain(e.target.value),
909
- children: choices
1090
+ onChange: setMain,
1091
+ options: choices
910
1092
  })]
911
1093
  }),
912
1094
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
913
1095
  className: "slot",
914
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sub.second") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1096
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sub.second") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
915
1097
  value: second,
916
- onChange: (e) => setSecond(e.target.value),
917
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1098
+ onChange: setSecond,
1099
+ options: [{
918
1100
  value: "",
919
- children: t("sub.none")
920
- }), choices]
1101
+ label: t("sub.none")
1102
+ }, ...choices]
921
1103
  })]
922
1104
  }),
923
1105
  second && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -1016,13 +1198,13 @@ function LoginDialog(props) {
1016
1198
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("login.body") }),
1017
1199
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1018
1200
  className: "srow",
1019
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("login.from") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1201
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("login.from") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
1020
1202
  value: browser,
1021
- onChange: (e) => onBrowser(e.target.value),
1022
- children: browsers.map((b) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1203
+ onChange: onBrowser,
1204
+ options: browsers.map((b) => ({
1023
1205
  value: b.id,
1024
- children: b.label
1025
- }, b.id))
1206
+ label: b.label
1207
+ }))
1026
1208
  })]
1027
1209
  }),
1028
1210
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -33366,8 +33548,7 @@ const Player = (0, react.forwardRef)(function Player(props, ref) {
33366
33548
  backBufferLength: Infinity
33367
33549
  } : {
33368
33550
  liveSyncDuration: delay,
33369
- liveMaxLatencyDuration: delay + 60,
33370
- backBufferLength: 1800
33551
+ backBufferLength: Infinity
33371
33552
  });
33372
33553
  hls.current = h;
33373
33554
  h.loadSource(src);
@@ -33445,7 +33626,7 @@ const Player = (0, react.forwardRef)(function Player(props, ref) {
33445
33626
  (0, react.useEffect)(() => {
33446
33627
  if (!menu) return;
33447
33628
  const close = (e) => {
33448
- if (!e.target.closest?.(".dshl-menu, .dshl-menubtn")) setMenu(null);
33629
+ if (!e.target.closest?.(".dshl-menu, .dshl-menubtn, .dshl-options")) setMenu(null);
33449
33630
  };
33450
33631
  window.addEventListener("mousedown", close);
33451
33632
  return () => window.removeEventListener("mousedown", close);
@@ -33490,7 +33671,6 @@ const Player = (0, react.forwardRef)(function Player(props, ref) {
33490
33671
  const h = hls.current, el = video.current;
33491
33672
  if (h && el) {
33492
33673
  h.config.liveSyncDuration = v;
33493
- h.config.liveMaxLatencyDuration = v + 60;
33494
33674
  const pos = h.liveSyncPosition;
33495
33675
  if (pos != null) el.currentTime = pos;
33496
33676
  }
@@ -34107,17 +34287,17 @@ function RestreamPanel(props) {
34107
34287
  }),
34108
34288
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
34109
34289
  className: "rs-field",
34110
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("rs.platform") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
34290
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("rs.platform") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
34111
34291
  value: editing.platform,
34112
- onChange: (e) => setEditing({
34292
+ onChange: (v) => setEditing({
34113
34293
  ...editing,
34114
- platform: e.target.value,
34115
- server: FIXED[e.target.value] ?? (FIXED[editing.platform] === editing.server ? "" : editing.server)
34294
+ platform: v,
34295
+ server: FIXED[v] ?? (FIXED[editing.platform] === editing.server ? "" : editing.server)
34116
34296
  }),
34117
- children: PLATFORMS.map((p) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
34297
+ options: PLATFORMS.map((p) => ({
34118
34298
  value: p,
34119
- children: pname(p)
34120
- }, p))
34299
+ label: pname(p)
34300
+ }))
34121
34301
  })]
34122
34302
  }),
34123
34303
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -34319,7 +34499,7 @@ function Settings(props) {
34319
34499
  (0, react.useEffect)(() => {
34320
34500
  if (!open) return;
34321
34501
  const close = (e) => {
34322
- if (!box.current?.contains(e.target)) onOpen(false);
34502
+ if (!box.current?.contains(e.target) && !e.target.closest?.(".dshl-options")) onOpen(false);
34323
34503
  };
34324
34504
  window.addEventListener("mousedown", close);
34325
34505
  return () => window.removeEventListener("mousedown", close);
@@ -34394,14 +34574,15 @@ function Settings(props) {
34394
34574
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34395
34575
  title: t("set.engine.tip"),
34396
34576
  children: t("set.engine")
34397
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
34577
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
34398
34578
  value: engine,
34399
34579
  disabled,
34400
- onChange: (e) => onEngine(e.target.value),
34401
- children: providers.map((p) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
34580
+ onChange: onEngine,
34581
+ options: providers.map((p) => ({
34402
34582
  value: p.id,
34403
- children: [name(p.id), p.ready ? "" : ` · ${t(p.online ? "set.engine.noKey" : "set.engine.notInstalled")}`]
34404
- }, p.id))
34583
+ label: name(p.id),
34584
+ hint: p.ready ? void 0 : t(p.online ? "set.engine.noKey" : "set.engine.notInstalled")
34585
+ }))
34405
34586
  })]
34406
34587
  }),
34407
34588
  cur?.online && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -34475,19 +34656,18 @@ function Settings(props) {
34475
34656
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34476
34657
  title: t("set.model.tip"),
34477
34658
  children: t("set.model")
34478
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
34659
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
34479
34660
  value: models?.chosen ? modelKey(models.chosen) : "",
34480
- onChange: (e) => chooseModel(e.target.value),
34481
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
34661
+ onChange: chooseModel,
34662
+ options: [{
34482
34663
  value: "",
34483
- children: models?.fallback ? t("set.model.default").replace("{m}", modelName(models.fallback)) : t("set.model.dsh")
34484
- }), providersOf.map((pn) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("optgroup", {
34485
- label: pn,
34486
- children: models.models.filter((m) => m.providerName === pn).map((m) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
34487
- value: modelKey(m),
34488
- children: [m.name, m.image ? ` · ${t("set.model.image")}` : ""]
34489
- }, modelKey(m)))
34490
- }, pn))]
34664
+ label: models?.fallback ? t("set.model.default").replace("{m}", modelName(models.fallback)) : t("set.model.dsh")
34665
+ }, ...providersOf.flatMap((pn) => models.models.filter((m) => m.providerName === pn).map((m) => ({
34666
+ value: modelKey(m),
34667
+ label: m.name,
34668
+ hint: m.image ? t("set.model.image") : void 0,
34669
+ group: pn
34670
+ })))]
34491
34671
  })]
34492
34672
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34493
34673
  className: "hint",
@@ -34495,56 +34675,61 @@ function Settings(props) {
34495
34675
  })]
34496
34676
  }),
34497
34677
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34498
- className: "srow",
34499
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("set.mode") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
34500
- className: "dshl-seg",
34501
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
34502
- className: mode === "realtime" ? "on" : "",
34503
- title: t("mode.realtime.tip"),
34504
- onClick: () => onMode("realtime"),
34505
- children: t("mode.realtime")
34506
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
34507
- className: mode === "polished" ? "on" : "",
34508
- title: t("mode.polished.tip"),
34509
- onClick: () => onMode("polished"),
34510
- children: t("mode.polished")
34678
+ className: "srow col",
34679
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
34680
+ className: "pair",
34681
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("set.mode") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34682
+ className: "dshl-seg",
34683
+ children: [
34684
+ "site",
34685
+ "realtime",
34686
+ "polished"
34687
+ ].map((m) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
34688
+ className: mode === m ? "on" : "",
34689
+ title: t(`mode.${m}.tip`),
34690
+ onClick: () => onMode(m),
34691
+ children: t(`mode.${m}`)
34692
+ }, m))
34511
34693
  })]
34694
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34695
+ className: "hint",
34696
+ children: t(`mode.${mode}.tip`)
34512
34697
  })]
34513
34698
  }),
34514
34699
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34515
34700
  className: "srow",
34516
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("set.spoken") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
34701
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("set.spoken") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
34517
34702
  value: spoken,
34518
34703
  disabled,
34519
- onChange: (e) => onSpoken(e.target.value),
34520
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
34704
+ onChange: onSpoken,
34705
+ options: [{
34521
34706
  value: "auto",
34522
- children: t("set.auto")
34523
- }), langOptions.map(([k, v]) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
34707
+ label: t("set.auto")
34708
+ }, ...langOptions.map(([k, v]) => ({
34524
34709
  value: k,
34525
- children: v
34526
- }, k))]
34710
+ label: v
34711
+ }))]
34527
34712
  })]
34528
34713
  }),
34529
34714
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34530
34715
  className: "srow",
34531
34716
  title: t("set.login.tip"),
34532
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("set.login") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
34717
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("set.login") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
34533
34718
  value: login,
34534
- onChange: (e) => onLogin(e.target.value),
34535
- children: [
34536
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
34719
+ onChange: onLogin,
34720
+ options: [
34721
+ {
34537
34722
  value: "auto",
34538
- children: t("set.auto")
34539
- }),
34540
- browsers.map((b) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
34723
+ label: t("set.auto")
34724
+ },
34725
+ ...browsers.map((b) => ({
34541
34726
  value: b.id,
34542
- children: b.label
34543
- }, b.id)),
34544
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
34727
+ label: b.label
34728
+ })),
34729
+ {
34545
34730
  value: "none",
34546
- children: t("set.login.none")
34547
- })
34731
+ label: t("set.login.none")
34732
+ }
34548
34733
  ]
34549
34734
  })]
34550
34735
  })
@@ -34649,7 +34834,7 @@ function LiveView(props) {
34649
34834
  setLinesRaw(l);
34650
34835
  store.set("lines", l);
34651
34836
  };
34652
- const [mode, setModeRaw] = (0, react.useState)(() => store.get("mode", "realtime"));
34837
+ const [mode, setModeRaw] = (0, react.useState)(() => store.get("mode", "polished"));
34653
34838
  const setMode = (m) => {
34654
34839
  setModeRaw(m);
34655
34840
  store.set("mode", m);
@@ -34702,6 +34887,13 @@ function LiveView(props) {
34702
34887
  t,
34703
34888
  ui
34704
34889
  ]);
34890
+ const siteBest = (0, react.useMemo)(() => {
34891
+ const list = job?.site ?? [];
34892
+ const base = (l) => l.replace(/-orig$/, "").split("-")[0];
34893
+ return (list.find((x) => !x.auto && base(x.lang) === ui) ?? list.find((x) => !x.auto) ?? list.find((x) => base(x.lang) === ui) ?? list[0])?.key;
34894
+ }, [job?.site, ui]);
34895
+ const siteOnly = mode === "site" && !!job;
34896
+ const screenLines = siteOnly && siteBest ? lines.some((l) => l.startsWith("site:")) ? lines.filter((l) => l.startsWith("site:")) : [siteBest] : lines;
34705
34897
  const shownLines = lines;
34706
34898
  /** The chosen recognition, or — when it can't run here (no key, not installed) — the first one that can. */
34707
34899
  const usableEngine = () => {
@@ -34738,8 +34930,8 @@ function LiveView(props) {
34738
34930
  }).catch(() => {});
34739
34931
  }, [sessionId]);
34740
34932
  (0, react.useEffect)(() => {
34741
- setSubStyle((s) => reconcile(s, shownLines));
34742
- }, [shownLines.join(",")]);
34933
+ setSubStyle((s) => reconcile(s, screenLines));
34934
+ }, [screenLines.join(",")]);
34743
34935
  (0, react.useEffect)(() => {
34744
34936
  saveStyle(subStyle);
34745
34937
  }, [subStyle]);
@@ -34838,6 +35030,7 @@ function LiveView(props) {
34838
35030
  url: target
34839
35031
  } : classify(target);
34840
35032
  if (!c) return;
35033
+ if (props.onNewVideo && job && job.source !== "push" && job.url !== c.url && c.source !== "push" && await props.onNewVideo(target)) return;
34841
35034
  try {
34842
35035
  const j = await post("start", {
34843
35036
  ...options(),
@@ -34862,7 +35055,39 @@ function LiveView(props) {
34862
35055
  state: "off",
34863
35056
  at: 0
34864
35057
  };
34865
- const startSubs = async (from) => {
35058
+ (0, react.useEffect)(() => {
35059
+ if (!job?.title || !props.onTitle) return;
35060
+ const key = `dsh-live.named.${sessionId}`;
35061
+ try {
35062
+ if (localStorage.getItem(key) === job.id) return;
35063
+ localStorage.setItem(key, job.id);
35064
+ } catch {}
35065
+ props.onTitle(job.title);
35066
+ }, [job?.id, job?.title]);
35067
+ const rateLog = (0, react.useRef)([]);
35068
+ const [rate, setRate] = (0, react.useState)(null);
35069
+ (0, react.useEffect)(() => {
35070
+ if (!job || job.vod || job.status !== "running" || subs.state !== "running") {
35071
+ rateLog.current = [];
35072
+ setRate(null);
35073
+ return;
35074
+ }
35075
+ const log = rateLog.current;
35076
+ log.push({
35077
+ wall: Date.now(),
35078
+ at: subs.at
35079
+ });
35080
+ while (log.length > 2 && log[0].wall < Date.now() - 12e4) log.shift();
35081
+ const first = log[0], last = log.at(-1);
35082
+ setRate(last.wall - first.wall > 45e3 ? (last.at - first.at) / ((last.wall - first.wall) / 1e3) : null);
35083
+ }, [
35084
+ subs.at,
35085
+ subs.state,
35086
+ job?.status,
35087
+ job?.id
35088
+ ]);
35089
+ const emptyLines = subs.state === "running" && cues.length === 0 && subs.at > 15 ? t("tr.listening").replace("{t}", CLOCK(subs.at)) : t("tr.empty");
35090
+ const startSubs = async (from, withMode) => {
34866
35091
  if (!job) return;
34867
35092
  setError("");
34868
35093
  const p = providers.find((x) => x.id === usableEngine());
@@ -34874,6 +35099,7 @@ function LiveView(props) {
34874
35099
  try {
34875
35100
  setJob(await post("subs", {
34876
35101
  ...options(),
35102
+ ...withMode ? { mode: withMode } : {},
34877
35103
  jobId: job.id,
34878
35104
  action: "start",
34879
35105
  from
@@ -34888,6 +35114,17 @@ function LiveView(props) {
34888
35114
  action: "pause"
34889
35115
  }).catch(() => job));
34890
35116
  };
35117
+ /** Switching modes while a video is open takes effect right away: no delay stops transcribing, the others (re)start it. */
35118
+ const changeMode = (m) => {
35119
+ if (m === mode) return;
35120
+ setMode(m);
35121
+ if (!job || job.status === "error") return;
35122
+ if (m === "site") {
35123
+ if (subs.state === "running") pauseSubs();
35124
+ return;
35125
+ }
35126
+ startSubs(subs.state === "off" ? now : void 0, m);
35127
+ };
34891
35128
  const changeLines = async (next) => {
34892
35129
  const before = translationsOf(lines).join(","), after = translationsOf(next).join(",");
34893
35130
  setLines(next);
@@ -34901,6 +35138,16 @@ function LiveView(props) {
34901
35138
  const stop = async () => {
34902
35139
  if (job) await post("stop", { jobId: job.id }).catch(() => {});
34903
35140
  };
35141
+ /** Leave this video: everything for it stops, the page is empty again (its files stay in the video folder). */
35142
+ const closeVideo = async () => {
35143
+ if (!job) return;
35144
+ await post("close", { jobId: job.id }).catch(() => {});
35145
+ setJob(null);
35146
+ setCues([]);
35147
+ setTerms([]);
35148
+ setInput("");
35149
+ setBench(null);
35150
+ };
34904
35151
  const onFile = async (file) => {
34905
35152
  setError("");
34906
35153
  setUploading(0);
@@ -34932,7 +35179,7 @@ function LiveView(props) {
34932
35179
  const translatable = langOptions.filter(([k]) => k !== spokenBase);
34933
35180
  const nameWithSpoken = (k) => k === "src" && spokenBase ? `${t("sub.source")} · ${langName(spokenBase)}` : langName(k);
34934
35181
  const [siteCues, setSiteCues] = (0, react.useState)({});
34935
- const siteKeys = lines.filter((l) => l.startsWith("site:") && job?.site?.some((s) => s.key === l));
35182
+ const siteKeys = screenLines.filter((l) => l.startsWith("site:") && job?.site?.some((s) => s.key === l));
34936
35183
  (0, react.useEffect)(() => {
34937
35184
  if (!job) return;
34938
35185
  for (const k of siteKeys) {
@@ -35231,7 +35478,7 @@ function LiveView(props) {
35231
35478
  },
35232
35479
  children: [shown.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
35233
35480
  className: "dshl-muted pad",
35234
- children: t("tr.empty")
35481
+ children: emptyLines
35235
35482
  }), shown.map((c) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
35236
35483
  className: `dshl-line ${c.id === activeId ? "active" : ""} ${c.polished ? "ok" : "raw"}`,
35237
35484
  onClick: () => player.current?.seek(c.start),
@@ -35263,7 +35510,7 @@ function LiveView(props) {
35263
35510
  children: [
35264
35511
  paragraphs.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
35265
35512
  className: "dshl-muted pad",
35266
- children: t("tr.empty")
35513
+ children: emptyLines
35267
35514
  }),
35268
35515
  paragraphs.some((p) => p.speaker) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
35269
35516
  className: "dshl-muted small",
@@ -35301,6 +35548,32 @@ function LiveView(props) {
35301
35548
  onTerms: setTerms
35302
35549
  })
35303
35550
  ] });
35551
+ const live = !!job && job.status === "running" && !job.vod;
35552
+ const slow = live && mode !== "site" && rate !== null && rate < .95;
35553
+ const behindBy = live && mode !== "site" && subs.state === "running" && cues.length > 0 ? now - subs.at : 0;
35554
+ const engineNow = providers.find((x) => x.id === usableEngine());
35555
+ const liveNote = siteOnly && job && job.status !== "resolving" && !job.site?.length ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35556
+ className: "dshl-lag",
35557
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("site.none") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35558
+ className: "dshl-link",
35559
+ onClick: () => changeMode("polished"),
35560
+ children: t("site.switch")
35561
+ })]
35562
+ }) : slow ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35563
+ className: "dshl-lag warn",
35564
+ 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", {
35565
+ className: "dshl-link",
35566
+ onClick: () => setSettingsOpen(true),
35567
+ children: t("lag.settings")
35568
+ })]
35569
+ }) : behindBy > 6 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35570
+ className: "dshl-lag",
35571
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("lag.behind").replace("{t}", CLOCK(behindBy)) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35572
+ className: "dshl-link",
35573
+ onClick: () => player.current?.seek(Math.max(0, subs.at - 2)),
35574
+ children: t("lag.wait")
35575
+ })]
35576
+ }) : null;
35304
35577
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35305
35578
  className: `dshl ${theater && !activeBench ? "theater" : ""} ${pageMode ? "page" : ""} ${activeBench ? "bench" : ""}`,
35306
35579
  "data-dsh-own-drop": "dsh-vidi",
@@ -35383,7 +35656,7 @@ function LiveView(props) {
35383
35656
  engine: engine || "local",
35384
35657
  onEngine: setEngine,
35385
35658
  mode,
35386
- onMode: setMode,
35659
+ onMode: changeMode,
35387
35660
  spoken,
35388
35661
  onSpoken: setSpoken,
35389
35662
  langOptions,
@@ -35456,7 +35729,7 @@ function LiveView(props) {
35456
35729
  siteCues: shownSite,
35457
35730
  spoken: spokenBase,
35458
35731
  style: subStyle,
35459
- delay: job.mode === "polished" && !job.vod ? Math.max(delay, 75) : delay,
35732
+ delay: job.vod ? delay : mode === "polished" ? Math.max(delay, 75) : mode === "site" ? 3 : delay,
35460
35733
  onDelay: setDelay,
35461
35734
  showDelay: job.status === "running" && !job.vod,
35462
35735
  onSnapshot: saveSnapshot,
@@ -35567,9 +35840,17 @@ function LiveView(props) {
35567
35840
  className: "dshl-btn small",
35568
35841
  onClick: stop,
35569
35842
  children: t("in.stop")
35843
+ }),
35844
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35845
+ className: "dshl-btn small icon quiet",
35846
+ title: t("in.close"),
35847
+ "aria-label": t("in.close"),
35848
+ onClick: () => void closeVideo(),
35849
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.close, {})
35570
35850
  })
35571
35851
  ]
35572
35852
  }),
35853
+ liveNote,
35573
35854
  pageMode && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
35574
35855
  className: "dshl-under",
35575
35856
  children: transcriptBox
@@ -35669,7 +35950,7 @@ function SubsStarter(props) {
35669
35950
  (0, react.useEffect)(() => {
35670
35951
  if (!open) return;
35671
35952
  const close = (e) => {
35672
- if (!box.current?.contains(e.target) && !pop.current?.contains(e.target)) setOpen(false);
35953
+ if (!box.current?.contains(e.target) && !pop.current?.contains(e.target) && !e.target.closest?.(".dshl-options")) setOpen(false);
35673
35954
  };
35674
35955
  window.addEventListener("mousedown", close);
35675
35956
  return () => window.removeEventListener("mousedown", close);
@@ -36225,8 +36506,17 @@ const en = {
36225
36506
  "in.remove": "Remove",
36226
36507
  "mode.realtime": "Real-time",
36227
36508
  "mode.polished": "Polished",
36228
- "mode.realtime.tip": "Subtitles right away",
36229
- "mode.polished.tip": "1–2 min behind, more accurate subtitles",
36509
+ "mode.site": "No delay",
36510
+ "mode.site.tip": "The site’s own subtitles only: nothing is transcribed or translated, the picture is not held back",
36511
+ "mode.realtime.tip": "A few seconds behind: transcribed and translated as it is heard",
36512
+ "mode.polished.tip": "1–2 minutes behind: misheard words fixed with context and the picture — the most accurate (default)",
36513
+ "site.none": "This video has no subtitles of its own.",
36514
+ "site.switch": "Switch to Polished",
36515
+ "lag.behind": "Subtitles are {t} behind the picture.",
36516
+ "lag.wait": "Wait for them",
36517
+ "lag.slow": "Transcription runs slower than the stream ({x}×), so subtitles fall further behind.",
36518
+ "lag.slowLocal": "This computer transcribes slower than the stream ({x}×), so subtitles fall further behind. An online service keeps up.",
36519
+ "lag.settings": "Choose one",
36230
36520
  "in.uploading": "Uploading {p}%",
36231
36521
  "in.drop": "Drop to start",
36232
36522
  "empty.title": "Subtitles for any video",
@@ -36363,6 +36653,8 @@ const en = {
36363
36653
  "tr.copyShort": "Copy",
36364
36654
  "tr.copied": "Copied",
36365
36655
  "tr.empty": "Subtitles show up here.",
36656
+ "in.close": "Close this video (its files stay in the video folder)",
36657
+ "tr.listening": "Listening — no speech yet (music or a break so far). Heard up to {t}.",
36366
36658
  "gl.new": "New term",
36367
36659
  "gl.add": "Add",
36368
36660
  "gl.auto": "auto",
@@ -36549,8 +36841,17 @@ const zh = {
36549
36841
  "in.remove": "移除",
36550
36842
  "mode.realtime": "实时",
36551
36843
  "mode.polished": "精修",
36552
- "mode.realtime.tip": "字幕最快出现",
36553
- "mode.polished.tip": "画面晚 1–2 分钟,字幕更准",
36844
+ "mode.site": "无延迟",
36845
+ "mode.site.tip": "只看网站自带的字幕:不转写、不翻译,画面也不往后拖",
36846
+ "mode.realtime.tip": "晚几秒:听到就转写、翻译",
36847
+ "mode.polished.tip": "晚 1–2 分钟:结合上下文和画面改正听错的词,字幕最准(默认)",
36848
+ "site.none": "这个视频没有网站自带的字幕。",
36849
+ "site.switch": "换成精修",
36850
+ "lag.behind": "字幕比画面慢了 {t}。",
36851
+ "lag.wait": "等等字幕",
36852
+ "lag.slow": "转写比直播慢({x} 倍速),字幕会越落越远。",
36853
+ "lag.slowLocal": "本机转写比直播慢({x} 倍速),字幕会越落越远。换成在线转写就能跟上。",
36854
+ "lag.settings": "去选一个",
36554
36855
  "in.uploading": "上传中 {p}%",
36555
36856
  "in.drop": "松手开始",
36556
36857
  "empty.title": "给任何视频配字幕",
@@ -36687,6 +36988,8 @@ const zh = {
36687
36988
  "tr.copyShort": "复制",
36688
36989
  "tr.copied": "已复制",
36689
36990
  "tr.empty": "字幕会出现在这里",
36991
+ "in.close": "关闭这个视频(文件还留在视频目录里)",
36992
+ "tr.listening": "正在听,还没有人说话(目前是音乐或插播)。已听到 {t}。",
36690
36993
  "gl.new": "新词条",
36691
36994
  "gl.add": "添加",
36692
36995
  "gl.auto": "自动",
@@ -36820,6 +37123,7 @@ const CSS = `
36820
37123
  .dshl-btn.primary:hover:not(:disabled) { background: var(--accent); filter: brightness(1.06); }
36821
37124
  .dshl-btn.stop { background: var(--live); border-color: var(--live); color: #fff; }
36822
37125
  .dshl-btn.icon { width: 40px; padding: 0; }
37126
+ .dshl-btn.small.icon { width: 30px; }
36823
37127
  .dshl-btn.icon.back { color: var(--muted); }
36824
37128
  .dshl-btn.icon.back svg { transform: rotate(180deg); }
36825
37129
  .dshl-btn.block { display: flex; width: 100%; }
@@ -36847,10 +37151,25 @@ const CSS = `
36847
37151
  .dshl-switch.on { background: var(--accent); }
36848
37152
  .dshl-switch.on i { left: 16px; }
36849
37153
 
36850
- .dshl select { height: 32px; padding: 0 28px 0 10px; border: 1px solid var(--line); border-radius: 8px; color: var(--ink); font: inherit; font-size: 13px; cursor: pointer;
36851
- appearance: none; -webkit-appearance: none;
36852
- background: var(--bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%239a9993' stroke-width='1.5'/%3E%3C/svg%3E") no-repeat right 10px center; }
36853
- .dshl select:hover { border-color: color-mix(in srgb, var(--ink) 25%, var(--line)); }
37154
+ .dshl-select { display: inline-flex; align-items: center; gap: 8px; min-width: 0; max-width: 100%; height: 32px; padding: 0 10px; border: 1px solid var(--line); border-radius: 8px;
37155
+ background: var(--bg); color: var(--ink); font: inherit; font-size: 13px; text-align: left; cursor: pointer; }
37156
+ .dshl-select .v { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
37157
+ .dshl-select .caret { flex: none; width: 6px; height: 6px; margin: -3px 1px 0 0; border-right: 1.5px solid var(--faint); border-bottom: 1.5px solid var(--faint); transform: rotate(45deg); transition: transform .15s; }
37158
+ .dshl-select.open .caret { margin-top: 3px; transform: rotate(-135deg); }
37159
+ .dshl-select:hover:not(:disabled), .dshl-select.open { border-color: color-mix(in srgb, var(--ink) 25%, var(--line)); }
37160
+ .dshl-select:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 1px; }
37161
+ .dshl-select:disabled { opacity: .5; cursor: default; }
37162
+ /* The list: drawn at the page's top level (so no panel clips it), in the colours of where it opened. */
37163
+ .dshl-options { position: fixed; z-index: 2147483000; overflow: auto; overscroll-behavior: contain; padding: 4px; border: 1px solid var(--line, #e4e3de); border-radius: 10px;
37164
+ background: var(--bg, #fff); color: var(--ink, #1b1b19); box-shadow: 0 10px 30px rgba(0,0,0,.18), 0 2px 6px rgba(0,0,0,.08); font-size: 13px; line-height: 1.4; -webkit-font-smoothing: antialiased; }
37165
+ .dshl-options .grp { padding: 8px 10px 4px; color: var(--faint, #9a9993); font-size: 11.5px; font-weight: 600; }
37166
+ .dshl-options .opt { display: flex; align-items: center; gap: 8px; min-height: 32px; padding: 6px 10px; border-radius: 6px; cursor: pointer; }
37167
+ .dshl-options .opt.act { background: var(--hover, rgba(0,0,0,.05)); }
37168
+ .dshl-options .opt.on { font-weight: 550; }
37169
+ .dshl-options .opt.off { opacity: .45; cursor: default; }
37170
+ .dshl-options .opt .l { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
37171
+ .dshl-options .opt .h { flex: none; color: var(--faint, #9a9993); font-size: 12px; }
37172
+ .dshl-options .opt .ok { flex: none; margin-left: auto; color: var(--accent, #3b6ef5); }
36854
37173
 
36855
37174
  /* Toggle chips (several can be on): languages to show or download */
36856
37175
  .dshl-chips .row, .dshl-checks2 { display: flex; flex-wrap: wrap; gap: 6px; }
@@ -36861,7 +37180,8 @@ const CSS = `
36861
37180
  .dshl-chips .chip i { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; margin-left: -3px; border-radius: 50%;
36862
37181
  background: var(--accent); color: #fff; font-size: 10.5px; font-style: normal; font-weight: 600; }
36863
37182
  .dshl-chips .chip.site:not(.on) { border-style: dashed; }
36864
- .dshl-chips select.chip.add { appearance: none; -webkit-appearance: none; height: 28px; border-style: dashed; padding: 0 10px; }
37183
+ .dshl-chips .dshl-select.chip.add { height: 28px; border-style: dashed; padding: 0 10px; background: transparent; }
37184
+ .dshl-chips .dshl-select.chip.add .caret { display: none; }
36865
37185
  .dshl-chips .chip.ghost { width: 28px; padding: 0; justify-content: center; }
36866
37186
  .dshl-checks2 { margin: 0 0 10px; }
36867
37187
  .dshl-checks2 button i { width: 14px; height: 14px; margin-left: -2px; border: 1.5px solid currentColor; border-radius: 4px; opacity: .55; flex: none; }
@@ -36945,7 +37265,7 @@ html:not([data-platform]) [data-sidebar-drawer] .dshl-panelwrap .dshl.page .dshl
36945
37265
  every state, full screen included; the bar's 40px row is centred on the same line. */
36946
37266
  html[data-platform='darwin'] .dshl-panelwrap .dshl.page .dshl-top { padding-top: 5px; padding-bottom: 8px; }
36947
37267
  html[data-platform='darwin'] .dshl.page .dshl-top { -webkit-app-region: drag; app-region: drag; }
36948
- html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, select, .dshl-pick) { -webkit-app-region: no-drag; app-region: no-drag; }
37268
+ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .dshl-select, .dshl-pick) { -webkit-app-region: no-drag; app-region: no-drag; }
36949
37269
  .dshl.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
36950
37270
  .dshl.page .dshl-main { flex: 1; min-height: 0; grid-template-columns: minmax(0, 1fr) clamp(320px, 30vw, 420px); align-items: stretch; padding-bottom: 16px; }
36951
37271
  /* Video page: player on top, then the status row, then subtitles/glossary filling what is left; chat on the right. */
@@ -36955,6 +37275,8 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, s
36955
37275
  .dshl.page .dshl-side { position: static; height: 100%; max-height: none; }
36956
37276
  /* Workbench open: the video and its subtitles keep a column on the left; the bench takes the rest. */
36957
37277
  .dshl.page.bench .dshl-main { grid-template-columns: clamp(340px, 32vw, 520px) minmax(0, 1fr); }
37278
+ .dshl-lag { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: -2px 0 8px; padding: 7px 12px; border-radius: 8px; background: var(--l1, rgba(0,0,0,.04)); color: var(--muted); font-size: 12.5px; }
37279
+ .dshl-lag.warn { background: color-mix(in srgb, #f5a524 14%, transparent); color: var(--ink); }
36958
37280
  .dshl-guard { display: grid; gap: 8px; margin: 12px; padding: 12px 14px; border: 1px solid color-mix(in srgb, #d1434b 35%, var(--line)); border-radius: 10px; color: var(--ink); font-size: 13px; }
36959
37281
  .dshl-guard p { margin: 0; line-height: 1.6; }
36960
37282
  .dshl-guard code { color: var(--muted); font-size: 12px; white-space: pre-wrap; word-break: break-word; }
@@ -37129,11 +37451,7 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, s
37129
37451
  .dshl-submenu { display: grid; gap: 4px; }
37130
37452
  .dshl-submenu .slots { display: grid; gap: 8px; padding: 4px 6px 10px; }
37131
37453
  .dshl-submenu .slot { display: grid; grid-template-columns: 56px 1fr; align-items: center; gap: 10px; color: var(--muted); }
37132
- .dshl-submenu .slot select { width: 100%; min-width: 0; height: 34px; padding: 0 30px 0 10px; border: 1px solid var(--line); border-radius: 8px; color: var(--ink); font: inherit; font-size: 13px; cursor: pointer;
37133
- appearance: none; -webkit-appearance: none;
37134
- background: var(--l1) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23b4b4ba' stroke-width='1.5'/%3E%3C/svg%3E") no-repeat right 10px center; }
37135
- .dshl-submenu .slot select:hover { border-color: rgba(255,255,255,.28); }
37136
- .dshl-submenu .slot select option, .dshl-submenu .slot select optgroup { background: #fff; color: #111; }
37454
+ .dshl-submenu .slot .dshl-select { width: 100%; height: 34px; background: var(--l1); }
37137
37455
  .dshl-submenu .swap { justify-self: start; height: 26px; margin-left: 66px; padding: 0 10px; border: 0; border-radius: 6px; background: var(--l2); color: var(--muted); font-size: 12px; cursor: pointer; }
37138
37456
  .dshl-submenu .swap:hover { color: var(--ink); }
37139
37457
  .dshl-submenu .opts { position: sticky; bottom: -10px; display: grid; gap: 2px; margin: 0 -8px -10px; padding: 6px 8px 10px; border-top: 1px solid var(--line-soft); background: rgb(28,28,31); }
@@ -37173,12 +37491,12 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, s
37173
37491
  .dshl-rs .rs-hint { margin: 0; color: var(--faint); font-size: 12px; line-height: 1.55; }
37174
37492
  .dshl-rs .rs-err { margin: 0; color: #ff8f92; font-size: 12px; line-height: 1.55; }
37175
37493
  .dshl-rs .rs-field { display: grid; grid-template-columns: 76px 1fr; align-items: center; gap: 10px; font-size: 13px; color: var(--muted); }
37176
- .dshl-rs .rs-field input, .dshl-rs .rs-field select { width: 100%; height: 34px; padding: 0 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--l1); color: var(--ink); font: inherit; font-size: 13px; }
37177
- .dshl-rs .rs-field select { padding-right: 28px; background-color: var(--l1); background-image: url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2710%27 height=%276%27%3E%3Cpath d=%27M1 1l4 4 4-4%27 fill=%27none%27 stroke=%27%23aaa%27 stroke-width=%271.5%27/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; appearance: none; }
37178
- .dshl-rs .rs-field input:focus, .dshl-rs .rs-field select:focus { outline: none; border-color: var(--accent); }
37494
+ .dshl-rs .rs-field .dshl-select { width: 100%; height: 34px; background: var(--l1); }
37495
+ .dshl-rs .rs-field input { width: 100%; height: 34px; padding: 0 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--l1); color: var(--ink); font: inherit; font-size: 13px; }
37496
+ .dshl-rs .rs-field input:focus { outline: none; border-color: var(--accent); }
37179
37497
  .dshl-rs .rs-actions { display: flex; justify-content: flex-end; gap: 8px; }
37180
37498
  .dshl-menu.sheet .dshl-rs .rs-row { min-height: 50px; }
37181
- .dshl-menu.sheet .dshl-rs .rs-field input, .dshl-menu.sheet .dshl-rs .rs-field select { height: 42px; font-size: 15px; }
37499
+ .dshl-menu.sheet .dshl-rs .rs-field input, .dshl-menu.sheet .dshl-rs .rs-field .dshl-select { height: 42px; font-size: 15px; }
37182
37500
  .dshl-menu.sheet .dshl-rs .dshl-btn.block { height: 46px; font-size: 15px; }
37183
37501
  .dshl-dl { display: grid; gap: 14px; padding: 0 6px; }
37184
37502
  .dshl-dl .grp { display: grid; gap: 8px; }
@@ -37208,7 +37526,7 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, s
37208
37526
  .dshl-menu.sheet .dshl-seg button, .dshl-menu.sheet .dshl-submenu .seg button { min-width: 52px; height: 34px; font-size: 14px; }
37209
37527
  .dshl-menu.sheet .dshl-submenu .seg { width: 210px; }
37210
37528
  .dshl-menu.sheet .dshl-submenu .slot { grid-template-columns: 64px 1fr; font-size: 15px; }
37211
- .dshl-menu.sheet .dshl-submenu .slot select { height: 44px; font-size: 15px; }
37529
+ .dshl-menu.sheet .dshl-submenu .slot .dshl-select { height: 44px; font-size: 15px; }
37212
37530
  .dshl-menu.sheet .dshl-submenu .swap { height: 34px; margin-left: 74px; font-size: 14px; }
37213
37531
  .dshl-menu.sheet .dshl-switch { width: 44px; height: 26px; }
37214
37532
  .dshl-menu.sheet .dshl-switch i { width: 22px; height: 22px; }
@@ -37328,7 +37646,7 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, s
37328
37646
  .dshl .srow { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 46px; font-size: 13.5px; }
37329
37647
  .dshl-pop.settings { width: 320px; padding: 6px 12px; }
37330
37648
  .dshl-pop.settings .srow + .srow { border-top: 1px solid var(--line-soft); }
37331
- .dshl-pop.settings .srow > select, .dshl-pop.settings .srow > .dshl-seg { width: 168px; }
37649
+ .dshl-pop.settings .srow > .dshl-select, .dshl-pop.settings .srow > .dshl-seg { width: 168px; }
37332
37650
  .dshl-pop.settings .srow > .dshl-seg button { flex: 1; padding: 0 6px; }
37333
37651
  .dshl .srow.col { display: grid; justify-content: stretch; gap: 8px; padding: 10px 0; }
37334
37652
  .dshl .srow .keyline { display: flex; align-items: center; gap: 6px; }
@@ -37343,7 +37661,7 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, s
37343
37661
  .dshl .srow .keyfoot { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; color: var(--faint); font-size: 12px; line-height: 1.5; }
37344
37662
  .dshl .srow .hint { color: var(--muted); font-size: 12.5px; line-height: 1.6; }
37345
37663
  .dshl .srow .pair { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
37346
- .dshl .srow .pair > select { width: 168px; }
37664
+ .dshl .srow .pair > .dshl-select { width: 168px; }
37347
37665
  .dshl-pop.usage { min-width: 320px; padding: 12px 14px; }
37348
37666
  .dshl-pop.usage h3 { margin: 0 0 8px; color: var(--muted); font-size: 12.5px; font-weight: 600; }
37349
37667
  .dshl-pop.usage table { width: 100%; border-collapse: collapse; font-size: 12.5px; font-variant-numeric: tabular-nums; }
@@ -37439,12 +37757,12 @@ const samePath = (a, b) => !!a && !!b && a.replace(/\/+$/, "") === b.replace(/\/
37439
37757
  * It lives in a "Video" workspace on the video folder, so DeepSeek's own file
37440
37758
  * and shell tools can work on the recordings, clips and frames directly.
37441
37759
  */
37442
- async function pageSession(sessions, workspaces, title) {
37760
+ async function pageSession(sessions, workspaces, title, fresh = false) {
37443
37761
  let saved = null;
37444
37762
  try {
37445
37763
  saved = localStorage.getItem(PAGE_SESSION);
37446
37764
  } catch {}
37447
- if (saved && sessions.has(saved)) return saved;
37765
+ if (!fresh && saved && sessions.has(saved)) return saved;
37448
37766
  const deps = await fetch("/live/api/deps").then((r) => r.json());
37449
37767
  if (!deps?.dataDir) throw new Error("the video plugin is not running yet");
37450
37768
  const known = () => workspaces.list?.getSnapshot().items?.find((w) => samePath(w.path, deps.dataDir));
@@ -37508,9 +37826,48 @@ function closePage(sessions) {
37508
37826
  });
37509
37827
  if (returnTo && sessions.has(returnTo)) sessions.open(returnTo);
37510
37828
  }
37829
+ /** The video folder (the "Video" workspace), asked once. */
37830
+ let dataDirP;
37831
+ const videoFolder = () => dataDirP ??= fetch("/live/api/deps").then((r) => r.json()).then((d) => d?.dataDir).catch(() => {
37832
+ dataDirP = void 0;
37833
+ });
37834
+ /**
37835
+ * Each video gets a conversation of its own, named after it ("▶ title"), so
37836
+ * the history lists videos rather than a row of "Untitled". This applies to
37837
+ * the conversations in the video folder; a chat elsewhere that opened a video
37838
+ * keeps its own name and switches video in place.
37839
+ */
37840
+ function videoHandlers(sessions, workspaces, sessionId, fallbackTitle) {
37841
+ const own = async () => {
37842
+ const cwd = sessions.info?.(sessionId)?.cwd;
37843
+ const dir = await videoFolder();
37844
+ return !!cwd && !!dir && samePath(cwd, dir);
37845
+ };
37846
+ return {
37847
+ onNewVideo: async (link) => {
37848
+ if (!workspaces || !await own()) return false;
37849
+ try {
37850
+ await openPage(sessions, {
37851
+ sessionId: await pageSession(sessions, workspaces, fallbackTitle, true),
37852
+ pending: link,
37853
+ workspaces
37854
+ });
37855
+ return true;
37856
+ } catch {
37857
+ return false;
37858
+ }
37859
+ },
37860
+ onTitle: (title) => {
37861
+ own().then((yes) => {
37862
+ const now = sessions.info?.(sessionId)?.title;
37863
+ if (yes && sessions.rename && (!now || now.startsWith("▶ "))) sessions.rename(sessionId, `▶ ${title}`.slice(0, 80));
37864
+ });
37865
+ }
37866
+ };
37867
+ }
37511
37868
  /** The video page as a main panel (newer DS-Harness): the app's sidebar and its toggle stay. */
37512
37869
  function VideoPanel(props) {
37513
- const { sessions, t } = props;
37870
+ const { sessions, workspaces, t } = props;
37514
37871
  const page = (0, react.useSyncExternalStore)(pageStore.subscribe, pageStore.get);
37515
37872
  const face = (0, react.useSyncExternalStore)(sessions.subscribe, () => page.sessionId ? sessions.face(page.sessionId) : void 0);
37516
37873
  const chat = (0, react.useSyncExternalStore)(sessions.subscribe, () => page.sessionId ? sessions.chat(page.sessionId) : void 0);
@@ -37523,6 +37880,7 @@ function VideoPanel(props) {
37523
37880
  page: true,
37524
37881
  chatFace: face,
37525
37882
  chatSource: chat,
37883
+ ...videoHandlers(sessions, workspaces, page.sessionId, t("view.live")),
37526
37884
  pending: page.pending,
37527
37885
  onPendingDone: () => pageStore.set({ pending: void 0 })
37528
37886
  }, page.sessionId)
@@ -37530,7 +37888,7 @@ function VideoPanel(props) {
37530
37888
  }
37531
37889
  /** Full-window video page (shell.overlay, older DS-Harness): player, subtitles, terms and the chat with DeepSeek. */
37532
37890
  function VideoPage(props) {
37533
- const { sessions, t } = props;
37891
+ const { sessions, workspaces, t } = props;
37534
37892
  const page = (0, react.useSyncExternalStore)(pageStore.subscribe, pageStore.get);
37535
37893
  const face = (0, react.useSyncExternalStore)(sessions.subscribe, () => page.sessionId ? sessions.face(page.sessionId) : void 0);
37536
37894
  const chat = (0, react.useSyncExternalStore)(sessions.subscribe, () => page.sessionId ? sessions.chat(page.sessionId) : void 0);
@@ -37547,6 +37905,7 @@ function VideoPage(props) {
37547
37905
  t,
37548
37906
  chatFace: face,
37549
37907
  chatSource: chat,
37908
+ ...videoHandlers(sessions, workspaces, page.sessionId, t("view.live")),
37550
37909
  onClose: () => closePage(sessions),
37551
37910
  pending: page.pending,
37552
37911
  onPendingDone: () => pageStore.set({ pending: void 0 })
@@ -37679,7 +38038,19 @@ function sessionsOf(ctx, extra) {
37679
38038
  return;
37680
38039
  }
37681
38040
  },
37682
- subscribe: (fn) => s.list.subscribe(fn)
38041
+ subscribe: (fn) => s.list.subscribe(fn),
38042
+ rename: (id, title) => {
38043
+ try {
38044
+ s.binding(id)?.session?.rename?.(title);
38045
+ } catch {}
38046
+ },
38047
+ info: (id) => {
38048
+ const x = list().byId?.[id];
38049
+ return x ? {
38050
+ title: x.title,
38051
+ cwd: x.cwd
38052
+ } : void 0;
38053
+ }
37683
38054
  };
37684
38055
  }
37685
38056
  function apply(ctx) {
@@ -37738,6 +38109,7 @@ function apply(ctx) {
37738
38109
  label: () => t("page.open"),
37739
38110
  inject: () => ({
37740
38111
  sessions: sessions(),
38112
+ workspaces: ctx.workspaces,
37741
38113
  t
37742
38114
  })
37743
38115
  }, VideoPage));
@@ -37755,6 +38127,7 @@ function apply(ctx) {
37755
38127
  locale: NS,
37756
38128
  inject: () => ({
37757
38129
  sessions: sessions(),
38130
+ workspaces: ctx.workspaces,
37758
38131
  t
37759
38132
  })
37760
38133
  }, VideoPanel));
package/lib/index.mjs CHANGED
@@ -2356,12 +2356,14 @@ var LiveJob = class LiveJob extends EventEmitter {
2356
2356
  const p = this.provider;
2357
2357
  this.provider = void 0;
2358
2358
  await p?.stop().catch(() => {});
2359
- await this.translating;
2360
- await this.polisher.flush();
2361
2359
  this.subs = {
2362
2360
  state,
2363
2361
  at: this.subs.at
2364
2362
  };
2363
+ this.persist();
2364
+ this.emit("state", this.summary());
2365
+ await this.translating;
2366
+ await this.polisher.flush();
2365
2367
  this.save();
2366
2368
  this.persist();
2367
2369
  this.emit("state", this.summary());
@@ -2395,7 +2397,7 @@ var LiveJob = class LiveJob extends EventEmitter {
2395
2397
  this.emit("cue", cue);
2396
2398
  if (cue.final) {
2397
2399
  this.deps.glossary.notice(cue.src);
2398
- if (this.opts.mode === "realtime" && !this.provider?.translates && cue.tr === void 0 && this.targets().length) this.translate(cue);
2400
+ if (this.opts.mode !== "polished" && !this.provider?.translates && cue.tr === void 0 && this.targets().length) this.translate(cue);
2399
2401
  else {
2400
2402
  this.record(cue);
2401
2403
  this.polisher.add(cue);
@@ -3419,13 +3421,21 @@ const ISO = {
3419
3421
  var WhisperServer = class {
3420
3422
  model;
3421
3423
  log;
3424
+ audioCtx;
3422
3425
  proc;
3423
3426
  port;
3424
3427
  starting;
3425
3428
  users = 0;
3426
- constructor(model, log) {
3429
+ /**
3430
+ * @param audioCtx Whisper's encoder window in frames (1500 = 30 s). Chunks
3431
+ * here are a few seconds long, and the encoder costs the same for 5 s as for
3432
+ * 30 s unless the window is cut down: a window just over the longest chunk
3433
+ * made recognition about 2.5× faster (Apple M5, large-v3-turbo).
3434
+ */
3435
+ constructor(model, log, audioCtx = 0) {
3427
3436
  this.model = model;
3428
3437
  this.log = log;
3438
+ this.audioCtx = audioCtx;
3429
3439
  }
3430
3440
  async acquire() {
3431
3441
  this.users++;
@@ -3448,7 +3458,8 @@ var WhisperServer = class {
3448
3458
  String(port),
3449
3459
  "-t",
3450
3460
  "8",
3451
- "-nt"
3461
+ "-nt",
3462
+ ...this.audioCtx ? ["-ac", String(this.audioCtx)] : []
3452
3463
  ], { stdio: [
3453
3464
  "ignore",
3454
3465
  "ignore",
@@ -4615,7 +4626,7 @@ function apply(ctx, config) {
4615
4626
  appendFileSync(logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}\n`);
4616
4627
  } catch {}
4617
4628
  };
4618
- const whisper = new WhisperServer(config.whisperModel, log);
4629
+ const whisper = new WhisperServer(config.whisperModel, log, Math.min(1500, Math.max(256, Math.ceil((config.maxChunkSec + 5) * 50 / 64) * 64)));
4619
4630
  const jobs = /* @__PURE__ */ new Map();
4620
4631
  const byConversation = /* @__PURE__ */ new Map();
4621
4632
  let llm;
@@ -4772,7 +4783,7 @@ function apply(ctx, config) {
4772
4783
  tgtLangs: [config.defaultTargetLang],
4773
4784
  srcLang: "auto",
4774
4785
  provider: defaultEngine(),
4775
- mode: "realtime"
4786
+ mode: "polished"
4776
4787
  };
4777
4788
  /** Browser notifications: "show this video" for a session (tools, /video). */
4778
4789
  const bus = new EventEmitter();
@@ -4910,13 +4921,14 @@ function apply(ctx, config) {
4910
4921
  /** The conversation's video: in memory, or the latest one on disk (after a restart). */
4911
4922
  function jobForConversation(conversationId) {
4912
4923
  const id = byConversation.get(conversationId);
4924
+ if (id === "") return void 0;
4913
4925
  if (id && jobs.has(id)) return jobs.get(id);
4914
4926
  const root = join(config.dataDir, "jobs");
4915
4927
  if (!existsSync(root)) return void 0;
4916
4928
  const dirs = readdirSync(root).filter((n) => /^[\w-]+$/.test(n)).sort().reverse();
4917
4929
  for (const name of dirs) {
4918
4930
  const file = join(root, name, "job.json");
4919
- if (!existsSync(file)) continue;
4931
+ if (!existsSync(file) || existsSync(join(root, name, "closed"))) continue;
4920
4932
  try {
4921
4933
  if (JSON.parse(readFileSync(file, "utf8")).opts?.conversationId !== conversationId) continue;
4922
4934
  } catch {
@@ -4931,6 +4943,7 @@ function apply(ctx, config) {
4931
4943
  return job;
4932
4944
  }
4933
4945
  }
4946
+ const modeOf = (m) => m === "polished" || m === "site" ? m : "realtime";
4934
4947
  async function startJob(opts, waiting = false) {
4935
4948
  const prev = waiting ? void 0 : byConversation.get(opts.conversationId);
4936
4949
  if (prev) await jobs.get(prev)?.close("replaced");
@@ -4941,7 +4954,7 @@ function apply(ctx, config) {
4941
4954
  job = new LiveJob(id, opts, jobDeps(dir, opts, () => job, waiting));
4942
4955
  jobs.set(id, job);
4943
4956
  if (!waiting) byConversation.set(opts.conversationId, id);
4944
- job.autoSubs = true;
4957
+ job.autoSubs = opts.mode !== "site";
4945
4958
  bus.emit("job", job);
4946
4959
  job.start();
4947
4960
  return job;
@@ -5005,7 +5018,7 @@ function apply(ctx, config) {
5005
5018
  tgtLangs: b.tgtLangs.map(String).slice(0, 4),
5006
5019
  srcLang: typeof b.srcLang === "string" ? b.srcLang : "auto",
5007
5020
  provider: typeof b.provider === "string" ? b.provider : "local",
5008
- mode: b.mode === "polished" ? "polished" : "realtime"
5021
+ mode: modeOf(b.mode)
5009
5022
  } });
5010
5023
  const source = b.source === "push" || b.source === "file" ? b.source : "url";
5011
5024
  await disarm();
@@ -5013,13 +5026,25 @@ function apply(ctx, config) {
5013
5026
  return send(res, 200, (await startJob({
5014
5027
  source,
5015
5028
  url: source === "file" && raw.startsWith("~") ? join(homedir(), raw.slice(1)) : raw,
5016
- mode: b.mode === "polished" ? "polished" : "realtime",
5029
+ mode: modeOf(b.mode),
5017
5030
  srcLang: typeof b.srcLang === "string" ? b.srcLang : "auto",
5018
5031
  tgtLangs: Array.isArray(b.tgtLangs) ? b.tgtLangs.filter((x) => typeof x === "string").slice(0, 4) : [config.defaultTargetLang],
5019
5032
  provider: typeof b.provider === "string" ? b.provider : "local",
5020
5033
  conversationId: String(b.conversationId ?? "default")
5021
5034
  })).summary());
5022
5035
  }
5036
+ if (route === "close" && req.method === "POST") {
5037
+ const b = await readJson(req);
5038
+ const job = jobs.get(String(b.jobId));
5039
+ if (!job) return send(res, 404, { error: "no such job" });
5040
+ await job.close("closed");
5041
+ const conv = job.summary().conversationId;
5042
+ if (byConversation.get(conv) === job.id) byConversation.set(conv, "");
5043
+ try {
5044
+ writeFileSync(join(job.summary().dir, "closed"), (/* @__PURE__ */ new Date()).toISOString());
5045
+ } catch {}
5046
+ return send(res, 200, { ok: true });
5047
+ }
5023
5048
  if (route === "stop" && req.method === "POST") {
5024
5049
  const b = await readJson(req);
5025
5050
  const job = jobs.get(String(b.jobId));
@@ -5094,7 +5119,7 @@ function apply(ctx, config) {
5094
5119
  if (Array.isArray(b.tgtLangs)) opts.tgtLangs = b.tgtLangs.map(String).slice(0, 4);
5095
5120
  if (typeof b.srcLang === "string") opts.srcLang = b.srcLang;
5096
5121
  if (typeof b.provider === "string") opts.provider = b.provider;
5097
- if (b.mode === "realtime" || b.mode === "polished") opts.mode = b.mode;
5122
+ if (b.mode === "realtime" || b.mode === "polished" || b.mode === "site") opts.mode = b.mode;
5098
5123
  const svc = opts.provider ? service(opts.provider) : void 0;
5099
5124
  if (svc) {
5100
5125
  await keys.refresh();
@@ -5113,7 +5138,7 @@ function apply(ctx, config) {
5113
5138
  return send(res, 200, await arm({
5114
5139
  source: "push",
5115
5140
  url: "rtmp",
5116
- mode: b.mode === "polished" ? "polished" : "realtime",
5141
+ mode: modeOf(b.mode),
5117
5142
  srcLang: typeof b.srcLang === "string" ? b.srcLang : "auto",
5118
5143
  tgtLangs: Array.isArray(b.tgtLangs) ? b.tgtLangs.map(String).slice(0, 4) : [config.defaultTargetLang],
5119
5144
  provider: typeof b.provider === "string" ? b.provider : "local",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vidi",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
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",