dsh-vidi 0.1.0 → 0.2.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.
package/lib/client.js CHANGED
@@ -254,6 +254,13 @@ const Icon = {
254
254
  ...P,
255
255
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 5v14M5 12h14" })
256
256
  }),
257
+ pencil: () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
258
+ viewBox: "0 0 24 24",
259
+ width: "16",
260
+ height: "16",
261
+ ...P,
262
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 20h4L19 9l-4-4L4 16z" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M13.5 6.5l4 4" })]
263
+ }),
257
264
  close: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
258
265
  viewBox: "0 0 24 24",
259
266
  width: "16",
@@ -464,7 +471,7 @@ function Markdown({ text, fileUrl }) {
464
471
  }
465
472
 
466
473
  // src/client/ChatPanel.tsx
467
- const CLOCK$1 = (s) => {
474
+ const CLOCK$2 = (s) => {
468
475
  const h = Math.floor(s / 3600), m = Math.floor(s / 60) % 60, ss = Math.floor(s % 60);
469
476
  return h ? `${h}:${String(m).padStart(2, "0")}:${String(ss).padStart(2, "0")}` : `${m}:${String(ss).padStart(2, "0")}`;
470
477
  };
@@ -520,8 +527,8 @@ function ChatPanel(props) {
520
527
  setDraft(text);
521
528
  setTimeout(() => box.current?.focus(), 0);
522
529
  };
523
- const at = CLOCK$1(now);
524
- const around = `${CLOCK$1(Math.max(0, now - 30))}–${CLOCK$1(now + 30)}`;
530
+ const at = CLOCK$2(now);
531
+ const around = `${CLOCK$2(Math.max(0, now - 30))}–${CLOCK$2(now + 30)}`;
525
532
  const rows = nodes.flatMap((n, i) => {
526
533
  if (n.kind === "user") {
527
534
  const text = textOf(n.content);
@@ -33851,6 +33858,350 @@ const Player = (0, react.forwardRef)(function Player(props, ref) {
33851
33858
  });
33852
33859
  });
33853
33860
 
33861
+ // src/client/RestreamPanel.tsx
33862
+ /** Platforms, in the order people in China most often push to; YouTube and Twitch have one fixed ingest address. */
33863
+ const PLATFORMS = [
33864
+ "bilibili",
33865
+ "douyin",
33866
+ "kuaishou",
33867
+ "wechat",
33868
+ "douyu",
33869
+ "huya",
33870
+ "yy",
33871
+ "cc",
33872
+ "youtube",
33873
+ "twitch",
33874
+ "custom"
33875
+ ];
33876
+ const FIXED = {
33877
+ youtube: "rtmp://a.rtmp.youtube.com/live2",
33878
+ twitch: "rtmp://live.twitch.tv/app"
33879
+ };
33880
+ async function post$2(path, body) {
33881
+ const r = await fetch(`/live/api/${path}`, {
33882
+ method: "POST",
33883
+ headers: { "content-type": "application/json" },
33884
+ body: JSON.stringify(body)
33885
+ });
33886
+ const j = await r.json().catch(() => ({}));
33887
+ if (!r.ok) throw new Error(j.error ?? `HTTP ${r.status}`);
33888
+ return j;
33889
+ }
33890
+ const CLOCK$1 = (s) => {
33891
+ const h = Math.floor(s / 3600), m = Math.floor(s / 60) % 60, ss = Math.floor(s % 60);
33892
+ return `${h ? `${h}:` : ""}${String(m).padStart(h ? 2 : 1, "0")}:${String(ss).padStart(2, "0")}`;
33893
+ };
33894
+ const host = (s) => s.replace(/^[a-z]+:\/\//i, "").split("/")[0] ?? s;
33895
+ /**
33896
+ * The player's restream menu: saved destinations (tick the ones to push to),
33897
+ * adding one (platform, server address, stream key — copied from the
33898
+ * platform's own streaming tool), subtitles drawn in or not, picture size,
33899
+ * then start. While running, each destination shows its own state.
33900
+ */
33901
+ function RestreamPanel(props) {
33902
+ const { jobId, status, now, style, t } = props;
33903
+ const running = !!status?.running;
33904
+ const [targets, setTargets] = (0, react.useState)([]);
33905
+ const [picked, setPicked] = (0, react.useState)(() => {
33906
+ try {
33907
+ return JSON.parse(localStorage.getItem("dsh-live.rsPicked") ?? "[]");
33908
+ } catch {
33909
+ return [];
33910
+ }
33911
+ });
33912
+ const [editing, setEditing] = (0, react.useState)(null);
33913
+ const [burn, setBurn] = (0, react.useState)(true);
33914
+ const [height, setHeight] = (0, react.useState)(720);
33915
+ const [busy, setBusy] = (0, react.useState)(false);
33916
+ const [error, setError] = (0, react.useState)("");
33917
+ (0, react.useEffect)(() => {
33918
+ fetch("/live/api/restream/targets").then((r) => r.json()).then((j) => {
33919
+ const list = j.targets ?? [];
33920
+ setTargets(list);
33921
+ if (!list.length) setEditing({
33922
+ platform: "bilibili",
33923
+ server: "",
33924
+ key: ""
33925
+ });
33926
+ }).catch(() => {});
33927
+ }, []);
33928
+ (0, react.useEffect)(() => {
33929
+ try {
33930
+ localStorage.setItem("dsh-live.rsPicked", JSON.stringify(picked));
33931
+ } catch {}
33932
+ }, [picked]);
33933
+ const [, setTick] = (0, react.useState)(0);
33934
+ (0, react.useEffect)(() => {
33935
+ if (!running) return;
33936
+ const id = setInterval(() => setTick((n) => n + 1), 1e3);
33937
+ return () => clearInterval(id);
33938
+ }, [running]);
33939
+ /** Known notes from the host, in the page's language. */
33940
+ const note = (m) => m === "ended" ? t("rs.m.ended") : m === "no-burn" ? t("rs.m.noBurn") : m;
33941
+ const pname = (p) => t(`rs.p.${p}`);
33942
+ const save = async () => {
33943
+ if (!editing) return;
33944
+ setBusy(true);
33945
+ setError("");
33946
+ try {
33947
+ const list = (await post$2("restream/targets", { target: editing })).targets;
33948
+ setTargets(list);
33949
+ const added = editing.id ?? list.at(-1)?.id;
33950
+ if (added && !picked.includes(added)) setPicked((v) => [...v, added]);
33951
+ setEditing(null);
33952
+ } catch (e) {
33953
+ setError(e.message);
33954
+ } finally {
33955
+ setBusy(false);
33956
+ }
33957
+ };
33958
+ const remove = async (id) => {
33959
+ const j = await post$2("restream/targets", { remove: id }).catch(() => null);
33960
+ if (j) {
33961
+ setTargets(j.targets);
33962
+ setPicked((v) => v.filter((x) => x !== id));
33963
+ }
33964
+ };
33965
+ const start = async () => {
33966
+ setBusy(true);
33967
+ setError("");
33968
+ try {
33969
+ await post$2("restream", {
33970
+ jobId,
33971
+ action: "start",
33972
+ targets: picked.filter((id) => targets.some((x) => x.id === id)),
33973
+ from: now,
33974
+ burn,
33975
+ height,
33976
+ style
33977
+ });
33978
+ } catch (e) {
33979
+ setError(e.message);
33980
+ } finally {
33981
+ setBusy(false);
33982
+ }
33983
+ };
33984
+ const stop = async (targetId) => {
33985
+ setBusy(true);
33986
+ try {
33987
+ await post$2("restream", {
33988
+ jobId,
33989
+ action: "stop",
33990
+ targetId
33991
+ });
33992
+ } catch {} finally {
33993
+ setBusy(false);
33994
+ }
33995
+ };
33996
+ const add = async (id) => {
33997
+ await post$2("restream", {
33998
+ jobId,
33999
+ action: "start",
34000
+ targets: [id],
34001
+ from: now,
34002
+ burn,
34003
+ height,
34004
+ style
34005
+ }).catch((e) => setError(e.message));
34006
+ };
34007
+ const stateOf = (id) => running ? status.targets.find((x) => x.id === id) : void 0;
34008
+ const chosen = picked.filter((id) => targets.some((x) => x.id === id));
34009
+ if (editing) {
34010
+ const fixed = FIXED[editing.platform];
34011
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34012
+ className: "dshl-rs",
34013
+ children: [
34014
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
34015
+ className: "rs-head",
34016
+ children: editing.id ? t("rs.edit") : t("rs.add")
34017
+ }),
34018
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
34019
+ className: "rs-field",
34020
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("rs.platform") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
34021
+ value: editing.platform,
34022
+ onChange: (e) => setEditing({
34023
+ ...editing,
34024
+ platform: e.target.value,
34025
+ server: FIXED[e.target.value] ?? (FIXED[editing.platform] === editing.server ? "" : editing.server)
34026
+ }),
34027
+ children: PLATFORMS.map((p) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
34028
+ value: p,
34029
+ children: pname(p)
34030
+ }, p))
34031
+ })]
34032
+ }),
34033
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
34034
+ className: "rs-hint",
34035
+ children: t(`rs.h.${editing.platform}`)
34036
+ }),
34037
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
34038
+ className: "rs-field",
34039
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("rs.server") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
34040
+ type: "text",
34041
+ spellCheck: false,
34042
+ placeholder: fixed ?? "rtmp://…",
34043
+ value: editing.server,
34044
+ onChange: (e) => setEditing({
34045
+ ...editing,
34046
+ server: e.target.value
34047
+ })
34048
+ })]
34049
+ }),
34050
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
34051
+ className: "rs-field",
34052
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("rs.key") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
34053
+ type: "password",
34054
+ autoComplete: "off",
34055
+ spellCheck: false,
34056
+ placeholder: editing.id ? t("rs.keyKeep") : t("rs.keyPh"),
34057
+ value: editing.key,
34058
+ onChange: (e) => setEditing({
34059
+ ...editing,
34060
+ key: e.target.value
34061
+ })
34062
+ })]
34063
+ }),
34064
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
34065
+ className: "rs-err",
34066
+ children: error
34067
+ }),
34068
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34069
+ className: "rs-actions",
34070
+ children: [targets.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
34071
+ className: "dshl-btn small",
34072
+ onClick: () => {
34073
+ setEditing(null);
34074
+ setError("");
34075
+ },
34076
+ children: t("player.cancel")
34077
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
34078
+ className: "dshl-btn small primary",
34079
+ disabled: busy || !editing.server.trim(),
34080
+ onClick: () => void save(),
34081
+ children: t("rs.save")
34082
+ })]
34083
+ })
34084
+ ]
34085
+ });
34086
+ }
34087
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34088
+ className: "dshl-rs",
34089
+ children: [
34090
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34091
+ className: "rs-head",
34092
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: running ? t("rs.running").replace("{t}", CLOCK$1((Date.now() - status.startedAt) / 1e3)) : t("rs.title") }), !running && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
34093
+ className: "dshl-link",
34094
+ onClick: () => setEditing({
34095
+ platform: "bilibili",
34096
+ server: "",
34097
+ key: ""
34098
+ }),
34099
+ children: ["+ ", t("rs.add")]
34100
+ })]
34101
+ }),
34102
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
34103
+ className: "rs-list",
34104
+ children: targets.map((x) => {
34105
+ const st = stateOf(x.id);
34106
+ const on = running ? !!st : picked.includes(x.id);
34107
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34108
+ className: `rs-row ${on ? "on" : ""}`,
34109
+ children: [
34110
+ running ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: `rs-dot ${st?.state ?? "off"}` }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
34111
+ type: "checkbox",
34112
+ checked: on,
34113
+ onChange: (e) => setPicked((v) => e.target.checked ? [...v, x.id] : v.filter((id) => id !== x.id))
34114
+ }),
34115
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
34116
+ className: "rs-name",
34117
+ children: [pname(x.platform), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("small", { children: host(x.server) })]
34118
+ }),
34119
+ running ? st ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34120
+ className: `rs-state ${st.state}`,
34121
+ title: st.message,
34122
+ children: t(`rs.s.${st.state}`)
34123
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
34124
+ className: "dshl-link",
34125
+ onClick: () => void add(x.id),
34126
+ children: t("rs.join")
34127
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
34128
+ className: "rs-tools",
34129
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
34130
+ className: "ghost",
34131
+ title: t("rs.edit"),
34132
+ onClick: () => setEditing({
34133
+ id: x.id,
34134
+ platform: x.platform,
34135
+ server: x.server,
34136
+ key: ""
34137
+ }),
34138
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.pencil, {})
34139
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
34140
+ className: "ghost",
34141
+ title: t("rs.remove"),
34142
+ onClick: () => void remove(x.id),
34143
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.close, {})
34144
+ })]
34145
+ }),
34146
+ running && st && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
34147
+ className: "ghost",
34148
+ title: t("rs.stopOne"),
34149
+ onClick: () => void stop(x.id),
34150
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.close, {})
34151
+ })
34152
+ ]
34153
+ }, x.id);
34154
+ })
34155
+ }),
34156
+ running && status.targets.some((x) => x.state === "error" || x.state === "retrying") && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
34157
+ className: "rs-err",
34158
+ children: status.targets.filter((x) => x.message && x.state !== "live").map((x) => `${pname(x.platform)}:${x.message}`).join(";")
34159
+ }),
34160
+ !running && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
34161
+ className: "rs-opts",
34162
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
34163
+ className: "rs-check",
34164
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
34165
+ type: "checkbox",
34166
+ checked: burn,
34167
+ onChange: (e) => setBurn(e.target.checked)
34168
+ }), t("rs.burn")]
34169
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
34170
+ className: "seg",
34171
+ children: [720, 1080].map((h) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
34172
+ className: height === h ? "on" : "",
34173
+ onClick: () => setHeight(h),
34174
+ children: [h, "p"]
34175
+ }, h))
34176
+ })]
34177
+ }),
34178
+ status?.message && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
34179
+ className: "rs-hint",
34180
+ children: note(status.message)
34181
+ }),
34182
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
34183
+ className: "rs-err",
34184
+ children: error
34185
+ }),
34186
+ running ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
34187
+ className: "dshl-btn block rs-stop",
34188
+ disabled: busy,
34189
+ onClick: () => void stop(),
34190
+ children: t("rs.stop")
34191
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
34192
+ className: "dshl-btn block primary",
34193
+ disabled: busy || !chosen.length,
34194
+ onClick: () => void start(),
34195
+ children: chosen.length ? t("rs.start").replace("{n}", String(chosen.length)) : t("rs.pick")
34196
+ }),
34197
+ !running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
34198
+ className: "rs-hint",
34199
+ children: t("rs.note")
34200
+ })
34201
+ ]
34202
+ });
34203
+ }
34204
+
33854
34205
  // src/client/Settings.tsx
33855
34206
  const engineLabel = (t, id) => {
33856
34207
  const v = t(`engine.${id}`);
@@ -33935,7 +34286,7 @@ function Settings(props) {
33935
34286
  setBusy(false);
33936
34287
  }
33937
34288
  };
33938
- const from = key.source === "dsh" ? t("set.key.fromDsh") : key.source === "env" ? t("set.key.fromEnv") : "";
34289
+ const from = key.source === "dsh" ? key.provider ? t("set.key.fromDshP").replace("{p}", key.provider) : t("set.key.fromDsh") : key.source === "env" ? t("set.key.fromEnv") : "";
33939
34290
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
33940
34291
  className: "dshl-pick",
33941
34292
  ref: box,
@@ -35026,36 +35377,53 @@ function LiveView(props) {
35026
35377
  onResume: () => startSubs(),
35027
35378
  t
35028
35379
  }),
35029
- menus: [{
35030
- id: "cc",
35031
- icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.cc, {}),
35032
- title: t("menu.cc"),
35033
- label: `${t("player.cc")} · ${lines.filter((l) => !l.startsWith("site:") || siteChips.some((c) => c.key === l)).map((k) => siteChips.find((c) => c.key === k)?.label ?? langName(k)).join(" · ")}`,
35034
- content: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SubtitleMenu, {
35035
- lines,
35036
- onLines: (l) => void changeLines(l),
35037
- options: translatable,
35038
- site: siteChips,
35039
- name: nameWithSpoken,
35040
- style: subStyle,
35041
- onStyle: setSubStyle,
35042
- t
35043
- }) })
35044
- }, {
35045
- id: "download",
35046
- icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.download, {}),
35047
- title: t("menu.download"),
35048
- content: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadPanel, {
35049
- job,
35050
- lines: shownLines,
35051
- tasks: tasks.filter((x) => x.dirId === job.id),
35052
- sessionId,
35053
- onTask: refreshTasks,
35054
- langName,
35055
- siteChips,
35056
- t
35057
- })
35058
- }]
35380
+ menus: [
35381
+ {
35382
+ id: "cc",
35383
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.cc, {}),
35384
+ title: t("menu.cc"),
35385
+ label: `${t("player.cc")} · ${lines.filter((l) => !l.startsWith("site:") || siteChips.some((c) => c.key === l)).map((k) => siteChips.find((c) => c.key === k)?.label ?? langName(k)).join(" · ")}`,
35386
+ content: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SubtitleMenu, {
35387
+ lines,
35388
+ onLines: (l) => void changeLines(l),
35389
+ options: translatable,
35390
+ site: siteChips,
35391
+ name: nameWithSpoken,
35392
+ style: subStyle,
35393
+ onStyle: setSubStyle,
35394
+ t
35395
+ }) })
35396
+ },
35397
+ {
35398
+ id: "restream",
35399
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.push, {}),
35400
+ title: t("menu.restream"),
35401
+ label: job.restream?.running ? t("rs.live") : void 0,
35402
+ on: !!job.restream?.running,
35403
+ content: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RestreamPanel, {
35404
+ jobId: job.id,
35405
+ status: job.restream,
35406
+ now,
35407
+ style: subStyle,
35408
+ t
35409
+ })
35410
+ },
35411
+ {
35412
+ id: "download",
35413
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.download, {}),
35414
+ title: t("menu.download"),
35415
+ content: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadPanel, {
35416
+ job,
35417
+ lines: shownLines,
35418
+ tasks: tasks.filter((x) => x.dirId === job.id),
35419
+ sessionId,
35420
+ onTask: refreshTasks,
35421
+ langName,
35422
+ siteChips,
35423
+ t
35424
+ })
35425
+ }
35426
+ ]
35059
35427
  }),
35060
35428
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35061
35429
  className: "dshl-done",
@@ -35635,6 +36003,55 @@ const en = {
35635
36003
  "sub.bg.none": "None",
35636
36004
  "sub.bg.light": "Light",
35637
36005
  "sub.bg.dark": "Dark",
36006
+ "menu.restream": "Restream",
36007
+ "rs.live": "Live",
36008
+ "rs.title": "Push this video, with subtitles, to live platforms",
36009
+ "rs.running": "Restreaming · {t}",
36010
+ "rs.add": "Add a platform",
36011
+ "rs.edit": "Edit",
36012
+ "rs.remove": "Remove",
36013
+ "rs.platform": "Platform",
36014
+ "rs.server": "Server",
36015
+ "rs.key": "Stream key",
36016
+ "rs.keyPh": "Paste the stream key",
36017
+ "rs.keyKeep": "Leave empty to keep the saved key",
36018
+ "rs.save": "Save",
36019
+ "rs.burn": "Subtitles in the picture",
36020
+ "rs.start": "Start restreaming ({n})",
36021
+ "rs.pick": "Tick where to stream",
36022
+ "rs.stop": "Stop restreaming",
36023
+ "rs.stopOne": "Stop this one",
36024
+ "rs.join": "Add",
36025
+ "rs.m.ended": "The video ended, so restreaming stopped.",
36026
+ "rs.m.noBurn": "Subtitles could not be drawn on this computer; streaming without them.",
36027
+ "rs.note": "Starts from where you are watching, with the subtitles you see. Keys stay on this computer.",
36028
+ "rs.s.connecting": "Connecting",
36029
+ "rs.s.live": "Live",
36030
+ "rs.s.retrying": "Reconnecting",
36031
+ "rs.s.error": "Failed",
36032
+ "rs.s.stopped": "Stopped",
36033
+ "rs.p.bilibili": "Bilibili",
36034
+ "rs.p.douyin": "Douyin",
36035
+ "rs.p.kuaishou": "Kuaishou",
36036
+ "rs.p.wechat": "WeChat Channels",
36037
+ "rs.p.douyu": "Douyu",
36038
+ "rs.p.huya": "Huya",
36039
+ "rs.p.yy": "YY",
36040
+ "rs.p.cc": "NetEase CC",
36041
+ "rs.p.youtube": "YouTube",
36042
+ "rs.p.twitch": "Twitch",
36043
+ "rs.p.custom": "Other (RTMP)",
36044
+ "rs.h.bilibili": "In the Bilibili live tool (直播姬) or the web live page, open the streaming settings and copy the server address and stream key.",
36045
+ "rs.h.douyin": "In Douyin Live Companion (直播伴侣), copy the push address; your account needs streaming rights.",
36046
+ "rs.h.kuaishou": "In Kuaishou Live Companion, open streaming settings and copy the address and key.",
36047
+ "rs.h.wechat": "In WeChat Channels Assistant (视频号助手), create a live session and copy its push address and key.",
36048
+ "rs.h.douyu": "Douyu streamer centre → live settings: copy the push address and code.",
36049
+ "rs.h.huya": "Huya streamer back office → push settings (OBS): copy the address and key.",
36050
+ "rs.h.yy": "Copy the push address and key from the YY live tool.",
36051
+ "rs.h.cc": "Copy the push address and key from the NetEase CC live tool.",
36052
+ "rs.h.youtube": "YouTube Studio → Go live → Stream: copy the stream key (the server is filled in).",
36053
+ "rs.h.twitch": "Twitch dashboard → Settings → Stream: copy the primary stream key (the server is filled in).",
36054
+ "rs.h.custom": "Any RTMP, RTMPS or SRT address. If it already contains the key, leave the key empty.",
35638
36055
  "menu.download": "Download",
35639
36056
  "usage.tokens": "{n} tokens",
35640
36057
  "usage.title": "Usage for this video",
@@ -35774,7 +36191,7 @@ const en = {
35774
36191
  "chat.p.summary": "Summarise the key points of this video so far from the subtitles.",
35775
36192
  "chat.p.download": "Download the original of this video.",
35776
36193
  "page.back": "Back to chats",
35777
- "page.open": "Video",
36194
+ "page.open": "Vidi video",
35778
36195
  "side.subs": "Subtitles {p}%",
35779
36196
  "side.subsLive": "Subtitling",
35780
36197
  "side.recording": "Recording",
@@ -35851,6 +36268,7 @@ const en = {
35851
36268
  "set.login.none": "Off",
35852
36269
  "set.key": "{s} key",
35853
36270
  "set.key.fromDsh": "from DSH",
36271
+ "set.key.fromDshP": "from DSH · {p}",
35854
36272
  "set.key.fromEnv": "from environment",
35855
36273
  "set.key.replace": "Paste a new key",
35856
36274
  "set.key.paste": "Paste key",
@@ -35899,6 +36317,55 @@ const zh = {
35899
36317
  "sub.bg.none": "无",
35900
36318
  "sub.bg.light": "浅",
35901
36319
  "sub.bg.dark": "深",
36320
+ "menu.restream": "转播",
36321
+ "rs.live": "转播中",
36322
+ "rs.title": "把这个视频连同字幕推到直播平台",
36323
+ "rs.running": "转播中 · {t}",
36324
+ "rs.add": "添加平台",
36325
+ "rs.edit": "修改",
36326
+ "rs.remove": "删除",
36327
+ "rs.platform": "平台",
36328
+ "rs.server": "服务器地址",
36329
+ "rs.key": "推流码",
36330
+ "rs.keyPh": "粘贴推流码",
36331
+ "rs.keyKeep": "留空则沿用已保存的推流码",
36332
+ "rs.save": "保存",
36333
+ "rs.burn": "字幕显示在画面里",
36334
+ "rs.start": "开始转播({n} 个平台)",
36335
+ "rs.pick": "先勾选要推到哪里",
36336
+ "rs.stop": "停止转播",
36337
+ "rs.stopOne": "停止这一路",
36338
+ "rs.join": "加入",
36339
+ "rs.m.ended": "视频播完了,转播已停止。",
36340
+ "rs.m.noBurn": "这台电脑画不了字幕,先不带字幕推流。",
36341
+ "rs.note": "从你正在看的位置开始,带着你看到的字幕。推流码只存在这台电脑上。",
36342
+ "rs.s.connecting": "连接中",
36343
+ "rs.s.live": "推流中",
36344
+ "rs.s.retrying": "重连中",
36345
+ "rs.s.error": "失败",
36346
+ "rs.s.stopped": "已停止",
36347
+ "rs.p.bilibili": "B站",
36348
+ "rs.p.douyin": "抖音",
36349
+ "rs.p.kuaishou": "快手",
36350
+ "rs.p.wechat": "视频号",
36351
+ "rs.p.douyu": "斗鱼",
36352
+ "rs.p.huya": "虎牙",
36353
+ "rs.p.yy": "YY",
36354
+ "rs.p.cc": "网易CC",
36355
+ "rs.p.youtube": "YouTube",
36356
+ "rs.p.twitch": "Twitch",
36357
+ "rs.p.custom": "其他(RTMP)",
36358
+ "rs.h.bilibili": "在「B站直播姬」或网页端「我的直播间 → 开播设置」里,复制服务器地址和串流密钥。",
36359
+ "rs.h.douyin": "在「抖音直播伴侣」里复制推流地址;账号需要先开通推流权限。",
36360
+ "rs.h.kuaishou": "在「快手直播伴侣 → 推流设置」里复制地址和推流码。",
36361
+ "rs.h.wechat": "在「视频号助手」里创建直播,复制推流地址和推流密钥。",
36362
+ "rs.h.douyu": "在「斗鱼主播中心 → 直播设置」里复制推流地址和推流码。",
36363
+ "rs.h.huya": "在「虎牙主播后台 → 推流设置(OBS 推流)」里复制地址和密钥。",
36364
+ "rs.h.yy": "在 YY 开播工具里复制推流地址和推流码。",
36365
+ "rs.h.cc": "在网易CC开播工具里复制推流地址和推流码。",
36366
+ "rs.h.youtube": "在 YouTube Studio「开始直播 → 直播」里复制串流密钥,服务器地址已经填好。",
36367
+ "rs.h.twitch": "在 Twitch 创作者后台「设置 → 直播」里复制主串流密钥,服务器地址已经填好。",
36368
+ "rs.h.custom": "任意 RTMP、RTMPS 或 SRT 地址。地址里已经带了推流码的话,推流码留空。",
35902
36369
  "menu.download": "下载",
35903
36370
  "usage.tokens": "{n} tokens",
35904
36371
  "usage.title": "这个视频的用量",
@@ -36038,7 +36505,7 @@ const zh = {
36038
36505
  "chat.p.summary": "根据字幕,总结这个视频到目前为止的要点。",
36039
36506
  "chat.p.download": "下载这个视频的原片。",
36040
36507
  "page.back": "返回对话",
36041
- "page.open": "视频",
36508
+ "page.open": "Vidi 视频",
36042
36509
  "side.subs": "字幕 {p}%",
36043
36510
  "side.subsLive": "生成字幕中",
36044
36511
  "side.recording": "录制中",
@@ -36115,6 +36582,7 @@ const zh = {
36115
36582
  "set.login.none": "不用",
36116
36583
  "set.key": "{s} 密钥",
36117
36584
  "set.key.fromDsh": "来自 DSH",
36585
+ "set.key.fromDshP": "来自 DSH · {p}",
36118
36586
  "set.key.fromEnv": "来自环境变量",
36119
36587
  "set.key.replace": "粘贴新密钥替换",
36120
36588
  "set.key.paste": "粘贴密钥",
@@ -36465,6 +36933,44 @@ const CSS = `
36465
36933
  .dshl-submenu .seg button.on { background: rgba(255,255,255,.92); color: #111; font-weight: 600; }
36466
36934
 
36467
36935
  /* Download (inside the dark menu) */
36936
+ /* Restream (player menu) */
36937
+ .dshl-rs { display: grid; gap: 10px; padding: 0 6px; }
36938
+ .dshl-rs .rs-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--muted); font-size: 12.5px; }
36939
+ .dshl-rs .rs-head .dshl-link { flex: none; font-size: 12.5px; }
36940
+ .dshl-rs .rs-list { display: grid; gap: 2px; margin: 0 -6px; }
36941
+ .dshl-rs .rs-row { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 4px 6px 4px 10px; border-radius: 8px; }
36942
+ .dshl-rs .rs-row:hover { background: var(--hover); }
36943
+ .dshl-rs .rs-row input[type=checkbox], .dshl-rs .rs-check input { width: 16px; height: 16px; margin: 0; flex: none; accent-color: #3d6df2; cursor: pointer; }
36944
+ .dshl-rs .rs-name { display: grid; flex: 1; min-width: 0; font-size: 13.5px; font-weight: 550; }
36945
+ .dshl-rs .rs-name small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--faint); font-size: 11.5px; font-weight: 400; }
36946
+ .dshl-rs .rs-tools { display: flex; gap: 2px; opacity: .65; }
36947
+ .dshl-rs .rs-row:hover .rs-tools { opacity: 1; }
36948
+ .dshl-rs button.ghost { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 6px; background: none; color: var(--muted); cursor: pointer; }
36949
+ .dshl-rs button.ghost:hover { background: var(--l2); color: var(--ink); }
36950
+ .dshl-rs .rs-dot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--faint); }
36951
+ .dshl-rs .rs-dot.live { background: #2fbf71; box-shadow: 0 0 0 3px rgba(47,191,113,.2); }
36952
+ .dshl-rs .rs-dot.connecting, .dshl-rs .rs-dot.retrying { background: #f5a524; animation: dshl-pulse 1.4s ease-in-out infinite; }
36953
+ .dshl-rs .rs-dot.error { background: #ff5a5f; }
36954
+ .dshl-rs .rs-state { flex: none; color: var(--muted); font-size: 12px; }
36955
+ .dshl-rs .rs-state.live { color: #5fd897; }
36956
+ .dshl-rs .rs-state.error { color: #ff7a7e; }
36957
+ .dshl-rs .rs-opts { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 34px; }
36958
+ .dshl-rs .rs-check { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; }
36959
+ .dshl-rs .seg { display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: var(--l2); }
36960
+ .dshl-rs .seg button { height: 26px; padding: 0 12px; border: 0; border-radius: 6px; background: none; color: var(--muted); font-size: 12.5px; cursor: pointer; }
36961
+ .dshl-rs .seg button.on { background: rgba(255,255,255,.92); color: #111; font-weight: 600; }
36962
+ .dshl-rs .dshl-btn.block { height: 38px; justify-content: center; }
36963
+ .dshl-rs .dshl-btn.rs-stop { border-color: transparent; background: #e5484d; color: #fff; }
36964
+ .dshl-rs .rs-hint { margin: 0; color: var(--faint); font-size: 12px; line-height: 1.55; }
36965
+ .dshl-rs .rs-err { margin: 0; color: #ff8f92; font-size: 12px; line-height: 1.55; }
36966
+ .dshl-rs .rs-field { display: grid; grid-template-columns: 76px 1fr; align-items: center; gap: 10px; font-size: 13px; color: var(--muted); }
36967
+ .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; }
36968
+ .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; }
36969
+ .dshl-rs .rs-field input:focus, .dshl-rs .rs-field select:focus { outline: none; border-color: var(--accent); }
36970
+ .dshl-rs .rs-actions { display: flex; justify-content: flex-end; gap: 8px; }
36971
+ .dshl-menu.sheet .dshl-rs .rs-row { min-height: 50px; }
36972
+ .dshl-menu.sheet .dshl-rs .rs-field input, .dshl-menu.sheet .dshl-rs .rs-field select { height: 42px; font-size: 15px; }
36973
+ .dshl-menu.sheet .dshl-rs .dshl-btn.block { height: 46px; font-size: 15px; }
36468
36974
  .dshl-dl { display: grid; gap: 14px; padding: 0 6px; }
36469
36975
  .dshl-dl .grp { display: grid; gap: 8px; }
36470
36976
  .dshl-dl .cap { color: var(--muted); font-size: 12px; font-weight: 550; }