This commit is contained in:
eric
2026-03-28 17:16:38 -05:00
parent 37ab25911a
commit 46fb194a02
20 changed files with 962 additions and 331 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { Copy, Layers, Loader2, Package, Terminal } from "lucide-react";
import { Copy, ExternalLink, Layers, Loader2, Package, RefreshCw, Terminal } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
type DeviceRow = { serial: string; model?: string; state: string };
@@ -84,9 +84,14 @@ export function LiveAndroidWorkstation({
const [openUrl, setOpenUrl] = useState("https://");
const [adbInputText, setAdbInputText] = useState("");
const [mirrorMode, setMirrorMode] = useState<"host" | "webusb">("host");
const [mirrorReloadKey, setMirrorReloadKey] = useState(0);
const [logcatLive, setLogcatLive] = useState(false);
const [pending, setPending] = useState<string | null>(null);
const PANDA_WEB_SCRCPY = "https://pandatestgrid.github.io/panda-web-scrcpy/";
const LINK_SCRCPY = "https://github.com/Genymobile/scrcpy";
const LINK_WS_SCRCPY = "https://github.com/NetrisTV/ws-scrcpy";
const LINK_PANDA_GH = "https://github.com/PandaTestGrid/panda-web-scrcpy";
const lock = !!(busy || pending);
@@ -157,26 +162,36 @@ export function LiveAndroidWorkstation({
}
};
const refreshLogcat = async () => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
const n = Math.min(3000, Math.max(1, parseInt(logcatLines, 10) || 200));
setPending("logcat");
setLogcatErr(null);
try {
const data = await fetchJson<{ text?: string }>(
`/android/logcat?serial=${encodeURIComponent(androidSerial)}&lines=${n}`,
);
setLogcatText(data.text ?? "");
} catch (e) {
setLogcatText("");
setLogcatErr((e as Error).message);
} finally {
setPending(null);
}
};
const refreshLogcat = useCallback(
async (opts?: { silent?: boolean }) => {
if (!androidSerial) {
return;
}
const n = Math.min(3000, Math.max(1, parseInt(logcatLines, 10) || 200));
const silent = !!opts?.silent;
if (!silent) setPending("logcat");
setLogcatErr(null);
try {
const data = await fetchJson<{ text?: string }>(
`/android/logcat?serial=${encodeURIComponent(androidSerial)}&lines=${n}`,
);
setLogcatText(data.text ?? "");
} catch (e) {
setLogcatText("");
setLogcatErr((e as Error).message);
} finally {
if (!silent) setPending(null);
}
},
[androidSerial, fetchJson, logcatLines],
);
useEffect(() => {
if (!logcatLive || !androidSerial || !adbAvailable) return;
const id = window.setInterval(() => void refreshLogcat({ silent: true }), 3000);
void refreshLogcat({ silent: true });
return () => clearInterval(id);
}, [logcatLive, androidSerial, adbAvailable, refreshLogcat]);
const loadUiNodes = async () => {
if (!androidSerial) {
@@ -260,10 +275,10 @@ export function LiveAndroidWorkstation({
))}
</div>
{adbAvailable && devices.length === 0 ? (
<p className="mt-4 rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 text-xs text-amber-200/90">
<p className="mt-4 rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 text-xs leading-relaxed text-amber-200/90">
{t(
"未检测到已授权设备: USB 连接并开启「USB 调试」,手机上允许本计算机调试。",
"No authorized device: enable USB debugging and authorize this computer.",
"未检测到已授权设备:双头 USB 接板子时请开「USB 调试」并授权Redroid / 容器安卓可先 `adb connect IP:5555` 再刷新本页。",
"No device: enable USB debugging for dual-USB to the board; for Redroid use adb connect IP:5555 then refresh.",
)}
</p>
) : null}
@@ -293,22 +308,40 @@ export function LiveAndroidWorkstation({
>
WebUSB · Panda
</button>
<a
href="https://github.com/PandaTestGrid/panda-web-scrcpy"
target="_blank"
rel="noreferrer"
className="ml-auto self-center text-[11px] text-zinc-500 underline-offset-2 hover:text-violet-300 hover:underline"
>
GitHub
</a>
<div className="ml-auto flex flex-wrap items-center gap-2 self-center">
<a
href={LINK_SCRCPY}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-0.5 text-[10px] text-zinc-500 hover:text-emerald-300"
>
scrcpy <ExternalLink className="h-3 w-3" />
</a>
<a
href={LINK_WS_SCRCPY}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-0.5 text-[10px] text-zinc-500 hover:text-violet-300"
>
ws-scrcpy <ExternalLink className="h-3 w-3" />
</a>
<a
href={LINK_PANDA_GH}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-0.5 text-[10px] text-zinc-500 hover:text-cyan-300"
>
panda-web-scrcpy <ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
{mirrorMode === "host" ? (
<div>
<div className="space-y-2 border-b border-white/5 px-4 py-3">
<p className="text-[11px] text-zinc-400">
<p className="text-[11px] leading-relaxed text-zinc-400">
{t(
"手机接在 live 板子 USB 时使用本页:先在 ws-scrcpy 里选设备并连接;黑屏多为未连 WebSocket / 屏幕休眠——可先 Wake 再刷新或新窗口打开面板。",
"USB to the board: pick device in ws-scrcpy; black screen often means WS down or screen off—Wake, refresh, or open panel in a new tab.",
"板载 USB投屏协议来自开源 scrcpy见上方仓库链接浏览器内常用 ws-scrcpy。黑屏时先 Wake再点「重载投屏」或新窗口打开面板。",
"Board USB: scrcpy protocol; browser uses ws-scrcpy. Black screen: Wake, reload mirror, or open panel in new tab.",
)}
</p>
<div className="flex flex-wrap items-center gap-2">
@@ -323,19 +356,30 @@ export function LiveAndroidWorkstation({
</button>
) : null}
{scrcpyUrl ? (
<a
href={scrcpyUrl}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] text-violet-300"
>
{t("新窗口打开 :5000", "Open :5000 tab")}
</a>
<>
<button
type="button"
onClick={() => setMirrorReloadKey((k) => k + 1)}
className="inline-flex items-center gap-1 rounded-lg border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] text-zinc-200"
>
<RefreshCw className="h-3.5 w-3.5" />
{t("重载投屏", "Reload mirror")}
</button>
<a
href={scrcpyUrl}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] text-violet-300"
>
{t("新窗口打开", "New tab")}
</a>
</>
) : null}
</div>
</div>
{scrcpyUrl ? (
<iframe
key={`ws-scrcpy-${mirrorReloadKey}`}
title="ws-scrcpy"
src={scrcpyUrl}
className="h-[min(72vh,640px)] w-full bg-black"
@@ -349,15 +393,15 @@ export function LiveAndroidWorkstation({
<div>
<p className="border-b border-white/5 px-4 py-3 text-[11px] leading-relaxed text-zinc-400">
{t(
"Panda / WebUSB」在浏览器里直连手机:手机必须插在您正在用浏览器看页面的这台电脑 USB 上(走 WebUSB不是板子上的 ADB。官方演示见 ",
"Panda uses WebUSB: phone must plug into the PC running this browser, not the board. Demo: ",
"Panda / WebUSB:手机必须插在您正在打开本页的电脑 USB 上(与板载 ADB 无关)。官方 scrcpy 协议生态同源,适合本地调试。仓库:",
"Panda WebUSB: phone plugs into the PC running this browser. Same scrcpy-protocol family. Repo: ",
)}
<a className="text-cyan-300 hover:underline" href={PANDA_WEB_SCRCPY} target="_blank" rel="noreferrer">
panda-web-scrcpy
<a className="text-cyan-300 hover:underline" href={LINK_PANDA_GH} target="_blank" rel="noreferrer">
PandaTestGrid/panda-web-scrcpy
</a>
</p>
<iframe
key={`panda-${mirrorReloadKey}`}
title="panda-web-scrcpy"
src={PANDA_WEB_SCRCPY}
className="h-[min(72vh,640px)] w-full bg-black"
@@ -680,10 +724,25 @@ export function LiveAndroidWorkstation({
value={logcatLines}
onChange={(e) => setLogcatLines(e.target.value)}
/>
<label className="flex cursor-pointer items-center gap-2 text-[11px] text-zinc-400">
<input
type="checkbox"
className="rounded border-white/20 bg-black/40"
checked={logcatLive}
onChange={(e) => setLogcatLive(e.target.checked)}
/>
{t("每 3 秒自动刷新", "Auto every 3s")}
</label>
<button
type="button"
disabled={lock}
onClick={() => void refreshLogcat()}
onClick={() => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
void refreshLogcat();
}}
className="rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30"
>
{t("拉取日志", "Pull logs")}

View File

@@ -176,6 +176,8 @@ export default function LiveControlApp() {
const [currentProc, setCurrentProc] = useState("");
const [urlConfig, setUrlConfig] = useState("");
const [douyinUrl, setDouyinUrl] = useState("");
const [businessYoutubeKey, setBusinessYoutubeKey] = useState("");
const [syncYoutubeIni, setSyncYoutubeIni] = useState(false);
const [apkPath, setApkPath] = useState("/sdcard/app.apk");
const [androidSerial, setAndroidSerial] = useState("");
const [hubCfgSnapshot, setHubCfgSnapshot] = useState<Record<string, unknown> | null>(null);
@@ -268,9 +270,13 @@ export default function LiveControlApp() {
useEffect(() => {
void (async () => {
try {
const b = await fetchJson<{ streams?: { douyin_input_url?: string } }>("/hub/business/douyinyoutube");
const b = await fetchJson<{
streams?: { douyin_input_url?: string; youtube_rtmp_key?: string };
}>("/hub/business/douyinyoutube");
const u = b.streams?.douyin_input_url;
if (u) setDouyinUrl(u);
const k = b.streams?.youtube_rtmp_key;
if (k !== undefined && k !== null) setBusinessYoutubeKey(k);
} catch {
/* ignore */
}
@@ -366,12 +372,23 @@ export default function LiveControlApp() {
const saveBusinessMeta = async () => {
setBusy("save:meta");
try {
await fetchJson("/hub/business/douyinyoutube", {
const data = await fetchJson<{ message?: string }>("/hub/business/douyinyoutube", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ streams: { douyin_input_url: douyinUrl } }),
body: JSON.stringify({
streams: {
douyin_input_url: douyinUrl,
youtube_rtmp_key: businessYoutubeKey,
},
sync_youtube_ini: syncYoutubeIni,
}),
});
notify(t("业务元数据已同步", "Business metadata saved"));
const detail = (data.message || "").trim();
notify(
detail.length > 0 && detail.length < 280
? detail
: t("业务元数据已同步", "Business metadata saved"),
);
} catch (e) {
notify((e as Error).message);
} finally {
@@ -1104,6 +1121,10 @@ export default function LiveControlApp() {
setUrlConfig={setUrlConfig}
douyinUrl={douyinUrl}
setDouyinUrl={setDouyinUrl}
businessYoutubeKey={businessYoutubeKey}
setBusinessYoutubeKey={setBusinessYoutubeKey}
syncYoutubeIni={syncYoutubeIni}
setSyncYoutubeIni={setSyncYoutubeIni}
onRunProcess={(a) => void runProcess(a)}
onSaveUrlConfig={(c) => void saveUrlConfig(c)}
onSaveBusinessMeta={() => void saveBusinessMeta()}
@@ -1122,6 +1143,10 @@ export default function LiveControlApp() {
setUrlConfig={setUrlConfig}
douyinUrl={douyinUrl}
setDouyinUrl={setDouyinUrl}
businessYoutubeKey={businessYoutubeKey}
setBusinessYoutubeKey={setBusinessYoutubeKey}
syncYoutubeIni={syncYoutubeIni}
setSyncYoutubeIni={setSyncYoutubeIni}
onRunProcess={(a) => void runProcess(a)}
onSaveUrlConfig={(c) => void saveUrlConfig(c)}
onSaveBusinessMeta={() => void saveBusinessMeta()}

View File

@@ -0,0 +1,241 @@
"use client";
import { Pause, Play, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { extractDouyinRoomHints } from "@/lib/youtubeConfigUtils";
type TFn = (zh: string, en: string) => string;
type StatusPayload = {
process_status?: string;
recent_log?: string;
recent_error?: string;
};
function fmtDuration(sec: number): string {
if (sec < 60) return `${sec}s`;
const m = Math.floor(sec / 60);
const s = sec % 60;
if (m < 60) return `${m}m ${s}s`;
const h = Math.floor(m / 60);
const mm = m % 60;
return `${h}h ${mm}m`;
}
function statusLine(t: TFn, raw: string | undefined): string {
const s = (raw || "unknown").toLowerCase();
if (s === "online" || s === "running") return t("运行中PM2/进程在线)", "Running (process online)");
if (s === "stopped" || s === "stopping") return t("已停止", "Stopped");
if (s === "not_found" || s === "errored") return t("未运行或未找到", "Not running / not found");
if (s === "invalid") return t("无效进程名", "Invalid process");
return raw || "—";
}
type Props = {
t: TFn;
currentProc: string;
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
/** 用于源站房间线索(与 Electron 一致) */
urlListText: string;
/** YouTube 密钥尾码;非 YouTube 进程可传空并关 showKeyRow */
keySuffixUi: string;
showKeyRow?: boolean;
pollMs?: number;
};
export function LiveProcessLiveLogCard({
t,
currentProc,
fetchJson,
urlListText,
keySuffixUi,
showKeyRow = true,
pollMs = 1000,
}: Props) {
const [processStatus, setProcessStatus] = useState<string>("");
const [recentLog, setRecentLog] = useState("");
const [recentError, setRecentError] = useState("");
const [paused, setPaused] = useState(false);
const [liveSince, setLiveSince] = useState<number | null>(null);
const [nowTick, setNowTick] = useState(0);
const outRef = useRef<HTMLPreElement>(null);
const errRef = useRef<HTMLPreElement>(null);
const prevOnlineRef = useRef(false);
const douyinHints = extractDouyinRoomHints(urlListText);
const pull = useCallback(async () => {
if (!currentProc) return;
try {
const data = await fetchJson<StatusPayload>(
`/status?process=${encodeURIComponent(currentProc)}`,
);
const ps = data.process_status || "unknown";
setProcessStatus(ps);
setRecentLog(data.recent_log || "");
setRecentError(data.recent_error || "");
const online = /^(online|running)$/i.test(ps);
if (online && !prevOnlineRef.current) {
setLiveSince(Date.now());
}
if (!online) {
setLiveSince(null);
}
prevOnlineRef.current = online;
} catch {
setProcessStatus("error");
}
}, [currentProc, fetchJson]);
useEffect(() => {
void pull();
}, [pull]);
useEffect(() => {
if (paused || !currentProc) return;
const id = window.setInterval(() => void pull(), pollMs);
return () => clearInterval(id);
}, [paused, currentProc, pull, pollMs]);
useEffect(() => {
const onVis = () => {
if (document.visibilityState === "visible") void pull();
};
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, [pull]);
useEffect(() => {
if (!liveSince) return;
const id = window.setInterval(() => setNowTick((n) => n + 1), 1000);
return () => clearInterval(id);
}, [liveSince]);
useEffect(() => {
if (paused) return;
const el = outRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [recentLog, paused]);
useEffect(() => {
if (paused) return;
const el = errRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [recentError, paused]);
const durationLabel =
liveSince == null
? t("—(未检测到在线)", "— (not online)")
: fmtDuration(Math.max(0, Math.floor((Date.now() - liveSince) / 1000)));
return (
<section className="rounded-2xl border border-emerald-500/20 bg-gradient-to-b from-emerald-500/[0.07] to-zinc-950/80 p-6 ring-1 ring-white/[0.04]">
<span className="sr-only" aria-hidden>
{nowTick}
</span>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-white">{t("当前直播日志", "Live process log")}</h3>
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-200/90 ring-1 ring-emerald-500/25">
{paused ? t("已暂停刷新", "Paused") : t("每秒刷新", "~1s refresh")}
</span>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void pull()}
className="inline-flex items-center gap-1 rounded-lg border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs text-zinc-300 hover:bg-white/[0.08]"
>
<RefreshCw className="h-3.5 w-3.5" />
{t("立即拉取", "Refresh")}
</button>
<button
type="button"
onClick={() => setPaused((p) => !p)}
className={`inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs ring-1 ${
paused
? "bg-amber-500/20 text-amber-100 ring-amber-500/35"
: "border border-white/10 bg-black/30 text-zinc-300"
}`}
>
{paused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
{paused ? t("继续自动刷新", "Resume") : t("暂停自动刷新", "Pause")}
</button>
</div>
</div>
<div className="mt-4 space-y-3 rounded-xl border border-white/[0.06] bg-black/25 p-4">
<div className="grid gap-2 text-xs sm:grid-cols-2">
<div className="flex flex-wrap gap-2">
<span className="text-zinc-500">{t("进程状态", "Status")}</span>
<span className="font-medium text-zinc-200">{statusLine(t, processStatus)}</span>
</div>
<div className="flex flex-wrap gap-2">
<span className="text-zinc-500">{t("本次在线时长(估算)", "Online duration (est.)")}</span>
<span className="font-mono text-cyan-200/90">{durationLabel}</span>
</div>
</div>
{showKeyRow ? (
<div className="flex flex-wrap gap-2 text-xs">
<span className="text-zinc-500">{t("YouTube key 尾码(参考)", "YT key suffix")}</span>
<span className="font-mono text-zinc-300">{keySuffixUi || "—"}</span>
</div>
) : null}
{douyinHints.length > 0 ? (
<div className="text-xs">
<span className="text-zinc-500">{t("源站房间线索", "Douyin room hints")}</span>
<span className="ml-2 inline-flex flex-wrap gap-1">
{douyinHints.map((id, i) => (
<a
key={`${id}-${i}`}
href={`https://live.douyin.com/${id}`}
target="_blank"
rel="noreferrer"
className="text-violet-300 underline-offset-2 hover:underline"
>
{id}
</a>
))}
</span>
</div>
) : (
<p className="text-[11px] text-zinc-600">
{t("(地址列表中未解析到抖音房间线索)", "(No Douyin room id in URL list)")}
</p>
)}
</div>
{processStatus !== "not_found" && processStatus !== "invalid" ? (
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-emerald-400/90">
{t("进程输出stdout / 合并日志)", "Process output")}
</div>
<pre
ref={outRef}
className="log-live-scroll mt-2 max-h-[min(22rem,42vh)] min-h-[12rem] overflow-auto whitespace-pre-wrap rounded-xl border border-emerald-500/20 bg-black/55 p-3 font-mono text-[10px] leading-relaxed text-emerald-100/95"
>
{recentLog.trim() || t("(暂无)", "(empty)")}
</pre>
</div>
<div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-rose-400/90">
{t("异常信息stderr", "Stderr")}
</div>
<pre
ref={errRef}
className="log-live-scroll mt-2 max-h-[min(22rem,42vh)] min-h-[12rem] overflow-auto whitespace-pre-wrap rounded-xl border border-rose-500/20 bg-black/55 p-3 font-mono text-[10px] leading-relaxed text-rose-100/95"
>
{recentError.trim() || t("(暂无)", "(empty)")}
</pre>
</div>
</div>
) : (
<p className="mt-4 text-xs text-zinc-500">
{t("进程未在 PM2 / 本地注册表中运行,日志区域已隐藏。", "Process not running — log panes hidden.")}
</p>
)}
</section>
);
}

View File

@@ -3,6 +3,8 @@
import { Cable, Copy, Monitor, Radio } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard";
type ProcessEntry = { pm2: string; script: string; label: string };
type TFn = (zh: string, en: string) => string;
@@ -53,6 +55,10 @@ type Props = {
setUrlConfig: (v: string) => void;
douyinUrl: string;
setDouyinUrl: (v: string) => void;
businessYoutubeKey: string;
setBusinessYoutubeKey: (v: string) => void;
syncYoutubeIni: boolean;
setSyncYoutubeIni: (v: boolean) => void;
onRunProcess: (a: "start" | "stop" | "restart") => void;
onSaveUrlConfig: (content?: string) => void;
onSaveBusinessMeta: () => void;
@@ -70,6 +76,10 @@ export function LiveTiktokHdmiView({
setUrlConfig,
douyinUrl,
setDouyinUrl,
businessYoutubeKey,
setBusinessYoutubeKey,
syncYoutubeIni,
setSyncYoutubeIni,
onRunProcess,
onSaveUrlConfig,
onSaveBusinessMeta,
@@ -272,14 +282,37 @@ export function LiveTiktokHdmiView({
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("抖音源站(业务元数据)", "Douyin source (hub metadata)")}</h3>
<p className="mt-1 text-xs text-zinc-500">{t("写入 business JSON。", "Writes business JSON.")}</p>
<h3 className="text-sm font-semibold text-white">{t("业务元数据(配置中心)", "Hub business metadata")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("与 YouTube 页共用一份 business JSONHDMI/TikTok 场景可只填抖音 URL。", "Shared business JSON with YouTube page.")}
</p>
<label className="mt-4 block text-xs text-zinc-400">{t("抖音直播间 URL", "Douyin live URL")}</label>
<input
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
value={douyinUrl}
onChange={(e) => setDouyinUrl(e.target.value)}
placeholder="https://live.douyin.com/..."
/>
<label className="mt-3 block text-xs text-zinc-400">
{t("YouTube 串流密钥(声明式,可选)", "YouTube key (optional declarative)")}
</label>
<input
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 font-mono text-sm text-zinc-200"
spellCheck={false}
autoComplete="off"
value={businessYoutubeKey}
onChange={(e) => setBusinessYoutubeKey(e.target.value)}
placeholder="xxxx-xxxx…"
/>
<label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-zinc-300">
<input
type="checkbox"
className="mt-0.5 rounded border-white/20 bg-black/40"
checked={syncYoutubeIni}
onChange={(e) => setSyncYoutubeIni(e.target.checked)}
/>
<span>{t("同时写入 config/youtube.ini 的 key=", "Also sync key= to config/youtube.ini")}</span>
</label>
<button
type="button"
disabled={lock}
@@ -328,6 +361,15 @@ export function LiveTiktokHdmiView({
</button>
</div>
</div>
<LiveProcessLiveLogCard
t={t}
currentProc={currentProc}
fetchJson={fetchJson}
urlListText={urlConfig}
keySuffixUi=""
showKeyRow={false}
/>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import {
parseYoutubeStreamKeySuffix,
type YoutubeIniFields,
} from "@/lib/youtubeConfigUtils";
import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard";
import {
defaultPm2NameForChannel,
loadYoutubeProChannels,
@@ -37,6 +38,10 @@ type Props = {
setUrlConfig: (v: string) => void;
douyinUrl: string;
setDouyinUrl: (v: string) => void;
businessYoutubeKey: string;
setBusinessYoutubeKey: (v: string) => void;
syncYoutubeIni: boolean;
setSyncYoutubeIni: (v: boolean) => void;
onRunProcess: (a: "start" | "stop" | "restart") => void;
onSaveUrlConfig: (content?: string) => void;
onSaveBusinessMeta: () => void;
@@ -53,6 +58,10 @@ export function LiveYoutubeUnmannedView({
setUrlConfig,
douyinUrl,
setDouyinUrl,
businessYoutubeKey,
setBusinessYoutubeKey,
syncYoutubeIni,
setSyncYoutubeIni,
onRunProcess,
onSaveUrlConfig,
onSaveBusinessMeta,
@@ -169,7 +178,8 @@ export function LiveYoutubeUnmannedView({
setYtIniLoading(true);
setYtIniStatus(null);
try {
await fetchJson("/save_config", {
const params = new URLSearchParams({ process: currentProc });
await fetchJson(`/save_config?${params}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
@@ -236,8 +246,8 @@ export function LiveYoutubeUnmannedView({
</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{t(
"对齐桌面客户端「直播」页:单路配置与多路 Pro 编排,无账号登录。",
"Desktop-style live control: single or Pro lanes, no account login.",
"对齐 d2y 桌面懒人包「直播」页:单路 / Pro、无 Google 登录Linux 推荐 PM2 或 systemd 托管下方所选进程。底部为实时日志(轮询 PM2/本地日志文件)。",
"Like d2y desktop: single/Pro, no Google login; on Linux use PM2/systemd for the selected process. Bottom: live logs from PM2/local log files.",
)}
</p>
</div>
@@ -497,12 +507,20 @@ export function LiveYoutubeUnmannedView({
{t("将本线路密钥写入服务器 youtube.ini", "Push key → server youtube.ini")}
</button>
</div>
<p className="text-[11px] text-amber-200/70">
{t(
"服务器仅一份 youtube.ini推送会覆盖多路并行请在板上为每路配置独立 PM2 与独立 config 目录,或轮流推送。",
"Server has one youtube.ini; overwrite. For true parallel lanes use separate PM2 + config dirs on the host.",
)}
</p>
<div
role="alert"
className="rounded-xl border border-rose-500/45 bg-rose-950/35 p-3 ring-1 ring-rose-500/25"
>
<p className="text-xs font-semibold text-rose-100/95">
{t("覆盖警告", "Overwrite warning")}
</p>
<p className="mt-1 text-[11px] leading-relaxed text-rose-100/80">
{t(
"服务器上通常只有一份 youtube.ini点此推送会立刻覆盖其中 key=;多路并行请在板上为每路使用独立 PM2 与独立 config 目录,或轮流推送后再开播。",
"Hosts usually have a single youtube.ini: this overwrites key=. For parallel lanes use separate PM2 + config dirs per lane, or push then start one at a time.",
)}
</p>
</div>
<textarea
className="h-16 w-full resize-y rounded-xl border border-white/10 bg-black/40 p-3 text-xs text-zinc-300"
value={active.notes ?? ""}
@@ -561,14 +579,45 @@ export function LiveYoutubeUnmannedView({
)}
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("抖音源站(业务元数据)", "Douyin source (metadata)")}</h3>
<p className="mt-1 text-xs text-zinc-500">{t("写入 business JSON供运维脚本读取。", "business.json for automation.")}</p>
<h3 className="text-sm font-semibold text-white">{t("业务元数据(配置中心)", "Hub business metadata")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t(
"写入 business/douyinyoutube.json可与下方选项同步到仓库 config/youtube.ini。",
"Writes business/douyinyoutube.json; optional sync to config/youtube.ini.",
)}
</p>
<label className="mt-4 block text-xs text-zinc-400">{t("抖音直播间 URL", "Douyin live URL")}</label>
<input
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
value={douyinUrl}
onChange={(e) => setDouyinUrl(e.target.value)}
placeholder="https://live.douyin.com/..."
/>
<label className="mt-3 block text-xs text-zinc-400">
{t("YouTube 串流密钥(声明式 youtube_rtmp_key", "YouTube stream key (declarative)")}
</label>
<input
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 font-mono text-sm text-zinc-200"
spellCheck={false}
autoComplete="off"
value={businessYoutubeKey}
onChange={(e) => setBusinessYoutubeKey(e.target.value)}
placeholder="xxxx-xxxx… 或完整 rtmp(s) URL"
/>
<label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-zinc-300">
<input
type="checkbox"
className="mt-0.5 rounded border-white/20 bg-black/40"
checked={syncYoutubeIni}
onChange={(e) => setSyncYoutubeIni(e.target.checked)}
/>
<span>
{t(
"同时写入本仓库(进程工作目录)下的 config/youtube.ini 中的 key=(需 Web 已配置 repo 根目录)",
"Also write key= to repo config/youtube.ini (FastAPI repo root must match this deployment).",
)}
</span>
</label>
<button
type="button"
disabled={lock}
@@ -607,6 +656,15 @@ export function LiveYoutubeUnmannedView({
<p className="mt-4 text-xs text-zinc-600">{t("Pro 模式:请使用「当前线路」中的地址列表。", "Use lane URL list in Pro mode.")}</p>
)}
</div>
<LiveProcessLiveLogCard
t={t}
currentProc={currentProc}
fetchJson={fetchJson}
urlListText={mode === "single" ? urlConfig : proUrlDraft}
keySuffixUi={keySuffixUi}
showKeyRow
/>
</div>
);
}