's'
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,902 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Copy,
|
||||
Layers,
|
||||
Loader2,
|
||||
Package,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { LiveAndroidStreamConsole } from "@/components/live-product/LiveAndroidStreamConsole";
|
||||
|
||||
type DeviceRow = {
|
||||
serial: string;
|
||||
model?: string;
|
||||
state: string;
|
||||
transport?: string;
|
||||
kind?: string;
|
||||
};
|
||||
|
||||
type TFn = (zh: string, en: string) => string;
|
||||
|
||||
type AndroidDiagnostics = {
|
||||
kind?: string;
|
||||
transport?: string;
|
||||
boot_completed?: string;
|
||||
hardware?: string;
|
||||
hardware_boot?: string;
|
||||
fingerprint?: string;
|
||||
abi?: string;
|
||||
device_provisioned?: string;
|
||||
user_setup_complete?: string;
|
||||
google_play?: boolean;
|
||||
google_services?: boolean;
|
||||
setupwizard?: boolean;
|
||||
magisk?: boolean;
|
||||
root?: boolean;
|
||||
ready?: boolean;
|
||||
};
|
||||
|
||||
type AndroidOverview = {
|
||||
serial?: string;
|
||||
model?: string;
|
||||
brand?: string;
|
||||
android_version?: string;
|
||||
sdk?: string;
|
||||
battery?: string;
|
||||
focus?: string;
|
||||
resolution?: string;
|
||||
rotation?: string;
|
||||
screen_state?: string;
|
||||
transport?: string;
|
||||
kind?: string;
|
||||
diagnostics?: AndroidDiagnostics;
|
||||
};
|
||||
|
||||
type PkgRow = { package: string; label?: string };
|
||||
type UiNode = {
|
||||
label: string;
|
||||
text?: string;
|
||||
content_desc?: string;
|
||||
resource_id?: string;
|
||||
center_x: number;
|
||||
center_y: number;
|
||||
clickable?: boolean;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
type RedroidInstance = {
|
||||
name: string;
|
||||
image?: string;
|
||||
status_text?: string;
|
||||
port?: number;
|
||||
running?: boolean;
|
||||
data_dir?: string;
|
||||
adb_serial?: string;
|
||||
diagnostics?: AndroidDiagnostics & {
|
||||
model?: string;
|
||||
android_version?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type RedroidSummary = {
|
||||
enabled?: boolean;
|
||||
arch?: string;
|
||||
image?: string;
|
||||
device_profile?: string;
|
||||
gpu_mode?: string;
|
||||
default_port?: number;
|
||||
instance_prefix?: string;
|
||||
running?: boolean;
|
||||
ready_count?: number;
|
||||
requirements?: {
|
||||
docker_available?: boolean;
|
||||
adb_available?: boolean;
|
||||
kvm?: boolean;
|
||||
binderfs?: boolean;
|
||||
binder_nodes?: string[];
|
||||
ashmem?: boolean;
|
||||
};
|
||||
instances?: RedroidInstance[];
|
||||
};
|
||||
|
||||
const SHELL_SNIPPETS: { label: string; cmd: string }[] = [
|
||||
{ label: "getprop", cmd: "getprop ro.build.version.release" },
|
||||
{ label: "top pkg", cmd: "dumpsys activity activities | grep -E 'mResumedActivity' | tail -n 1" },
|
||||
{ label: "usb", cmd: "dumpsys usb | head -n 60" },
|
||||
{ label: "surface", cmd: "dumpsys SurfaceFlinger | head -n 40" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
t: TFn;
|
||||
notify: (msg: string) => void;
|
||||
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||||
apiBase: string;
|
||||
busy: string | null;
|
||||
androidSerial: string;
|
||||
setAndroidSerial: (s: string) => void;
|
||||
devices: DeviceRow[];
|
||||
adbAvailable: boolean;
|
||||
apkPath: string;
|
||||
setApkPath: (v: string) => void;
|
||||
onAndroidAction: (action: string, payload?: Record<string, unknown>) => Promise<void>;
|
||||
onCopy: (text: string) => void;
|
||||
onRefreshDevices: () => void;
|
||||
};
|
||||
|
||||
function boolBadge(v: boolean | undefined, ok: string, bad: string) {
|
||||
if (v) return <span className="rounded bg-emerald-500/15 px-2 py-0.5 text-[10px] text-emerald-200">{ok}</span>;
|
||||
return <span className="rounded bg-zinc-500/15 px-2 py-0.5 text-[10px] text-zinc-400">{bad}</span>;
|
||||
}
|
||||
|
||||
export function LiveAndroidWorkstation({
|
||||
t,
|
||||
notify,
|
||||
fetchJson,
|
||||
apiBase,
|
||||
busy,
|
||||
androidSerial,
|
||||
setAndroidSerial,
|
||||
devices,
|
||||
adbAvailable,
|
||||
apkPath,
|
||||
setApkPath,
|
||||
onAndroidAction,
|
||||
onCopy,
|
||||
onRefreshDevices,
|
||||
}: Props) {
|
||||
const [overview, setOverview] = useState<AndroidOverview | null>(null);
|
||||
const [overviewErr, setOverviewErr] = useState<string | null>(null);
|
||||
const [redroid, setRedroid] = useState<RedroidSummary | null>(null);
|
||||
const [redroidErr, setRedroidErr] = useState<string | null>(null);
|
||||
const [pkgQuery, setPkgQuery] = useState("");
|
||||
const [pkgList, setPkgList] = useState<PkgRow[]>([]);
|
||||
const [shellCmd, setShellCmd] = useState("getprop ro.product.model");
|
||||
const [shellOut, setShellOut] = useState("");
|
||||
const [logcatLines, setLogcatLines] = useState("300");
|
||||
const [logcatText, setLogcatText] = useState("");
|
||||
const [logcatErr, setLogcatErr] = useState<string | null>(null);
|
||||
const [uiNodes, setUiNodes] = useState<UiNode[]>([]);
|
||||
const [uiErr, setUiErr] = useState<string | null>(null);
|
||||
const [openUrl, setOpenUrl] = useState("https://");
|
||||
const [adbInputText, setAdbInputText] = useState("");
|
||||
const [logcatLive, setLogcatLive] = useState(false);
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
|
||||
const lock = !!(busy || pending);
|
||||
const currentDevice = devices.find((d) => d.serial === androidSerial) || null;
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
if (!androidSerial || !adbAvailable) {
|
||||
setOverview(null);
|
||||
setOverviewErr(null);
|
||||
return;
|
||||
}
|
||||
setPending("overview");
|
||||
setOverviewErr(null);
|
||||
try {
|
||||
const data = await fetchJson<AndroidOverview>(`/android/overview?serial=${encodeURIComponent(androidSerial)}`);
|
||||
setOverview(data);
|
||||
} catch (e) {
|
||||
setOverview(null);
|
||||
setOverviewErr((e as Error).message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}, [adbAvailable, androidSerial, fetchJson]);
|
||||
|
||||
const loadRedroid = useCallback(async () => {
|
||||
setRedroidErr(null);
|
||||
try {
|
||||
const data = await fetchJson<RedroidSummary>("/redroid/summary");
|
||||
setRedroid(data);
|
||||
} catch (e) {
|
||||
setRedroid(null);
|
||||
setRedroidErr((e as Error).message);
|
||||
}
|
||||
}, [fetchJson]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
}, [loadOverview]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRedroid();
|
||||
}, [loadRedroid]);
|
||||
|
||||
const searchPackages = async () => {
|
||||
if (!androidSerial) {
|
||||
notify(t("请选择设备", "Pick a device"));
|
||||
return;
|
||||
}
|
||||
setPending("packages");
|
||||
try {
|
||||
const q = encodeURIComponent(pkgQuery.trim());
|
||||
const data = await fetchJson<{ packages: PkgRow[] }>(
|
||||
`/android/packages?serial=${encodeURIComponent(androidSerial)}&query=${q}&limit=80`,
|
||||
);
|
||||
setPkgList(data.packages || []);
|
||||
} catch (e) {
|
||||
notify((e as Error).message);
|
||||
setPkgList([]);
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runShell = async () => {
|
||||
if (!androidSerial) {
|
||||
notify(t("请选择设备", "Pick a device"));
|
||||
return;
|
||||
}
|
||||
const cmd = shellCmd.trim();
|
||||
if (!cmd) return;
|
||||
setPending("shell");
|
||||
try {
|
||||
const data = await fetchJson<{ output?: string; message?: string }>("/android/action", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "shell", serial: androidSerial, payload: { command: cmd } }),
|
||||
});
|
||||
setShellOut(data.output ?? data.message ?? "");
|
||||
} catch (e) {
|
||||
setShellOut((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);
|
||||
}, [adbAvailable, androidSerial, logcatLive, refreshLogcat]);
|
||||
|
||||
const loadUiNodes = async () => {
|
||||
if (!androidSerial) {
|
||||
notify(t("请选择设备", "Pick a device"));
|
||||
return;
|
||||
}
|
||||
setPending("ui");
|
||||
setUiErr(null);
|
||||
try {
|
||||
const data = await fetchJson<{ nodes: UiNode[] }>(
|
||||
`/android/ui?serial=${encodeURIComponent(androidSerial)}&limit=100`,
|
||||
);
|
||||
setUiNodes(data.nodes || []);
|
||||
} catch (e) {
|
||||
setUiNodes([]);
|
||||
setUiErr((e as Error).message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runRedroidAction = async (action: string, name?: string) => {
|
||||
setPending(`redroid:${action}`);
|
||||
try {
|
||||
const res = await fetchJson<{ message?: string; summary?: RedroidSummary }>("/redroid/action", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, name: name || "" }),
|
||||
});
|
||||
if (res.summary) setRedroid(res.summary);
|
||||
notify(res.message || "OK");
|
||||
await loadRedroid();
|
||||
onRefreshDevices();
|
||||
} catch (e) {
|
||||
notify((e as Error).message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const sendKey = async (keycode: string) => {
|
||||
await onAndroidAction("key", { keycode });
|
||||
};
|
||||
|
||||
const sendAdbText = async () => {
|
||||
const raw = adbInputText.trim();
|
||||
if (!raw) {
|
||||
notify(t("请输入文字", "Enter text"));
|
||||
return;
|
||||
}
|
||||
await onAndroidAction("text", { text: raw });
|
||||
};
|
||||
|
||||
const openUrlGo = async () => {
|
||||
const u = openUrl.trim();
|
||||
if (!u || u === "https://") {
|
||||
notify(t("请输入 URL", "Enter URL"));
|
||||
return;
|
||||
}
|
||||
await onAndroidAction("open_url", { url: u });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">{t("安卓设备工作台", "Android workstation")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t("USB 真机与 Redroid 共用一套控制面,优先保证 USB 真机稳定。", "USB phone and Redroid share one control surface.")}
|
||||
</p>
|
||||
</div>
|
||||
{!adbAvailable ? (
|
||||
<span className="text-xs text-amber-200/90">{t("宿主机未检测到 adb", "adb not found on host")}</span>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock || !androidSerial}
|
||||
onClick={() => void loadOverview()}
|
||||
className="rounded-lg border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs text-zinc-300"
|
||||
>
|
||||
{t("刷新设备", "Refresh device")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => {
|
||||
void loadRedroid();
|
||||
onRefreshDevices();
|
||||
}}
|
||||
className="rounded-lg border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs text-zinc-300"
|
||||
>
|
||||
{t("刷新 Redroid", "Refresh Redroid")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{devices.map((d) => (
|
||||
<button
|
||||
key={d.serial}
|
||||
type="button"
|
||||
onClick={() => setAndroidSerial(d.serial)}
|
||||
className={`rounded-xl border px-4 py-2 text-left text-xs ${
|
||||
androidSerial === d.serial
|
||||
? "border-violet-500/50 bg-violet-500/10 text-white"
|
||||
: "border-white/10 bg-black/30 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-zinc-200">{d.model || d.serial}</span>
|
||||
{d.transport ? (
|
||||
<span className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-[9px] uppercase text-zinc-500">
|
||||
{d.transport}
|
||||
</span>
|
||||
) : null}
|
||||
{d.kind ? (
|
||||
<span className="rounded bg-cyan-500/10 px-1.5 py-0.5 font-mono text-[9px] uppercase text-cyan-200">
|
||||
{d.kind}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-600">{d.serial}</div>
|
||||
{(d.state || "").toLowerCase() !== "device" ? (
|
||||
<div className="mt-1 text-[10px] font-medium text-amber-200/90">
|
||||
{d.state === "unauthorized"
|
||||
? t("未授权,请在手机上允许 USB 调试。", "Unauthorized, allow USB debugging on device.")
|
||||
: `${t("状态", "State")}: ${d.state}`}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{adbAvailable && devices.length === 0 ? (
|
||||
<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 真机请检查调试授权;Redroid 请先启动实例,再 adb connect 127.0.0.1:5555。",
|
||||
"No authorized devices. For USB phones, confirm USB debugging. For Redroid, start an instance then adb connect 127.0.0.1:5555.",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<div className="rounded-2xl border border-violet-500/15 bg-zinc-950/50 p-5">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-violet-300/90">
|
||||
{t("真机诊断", "Phone diagnostics")}
|
||||
</h4>
|
||||
{overviewErr ? <p className="mt-2 text-xs text-rose-300">{overviewErr}</p> : null}
|
||||
{overview ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
<dl className="space-y-1.5 font-mono text-[11px] text-zinc-400">
|
||||
<div>
|
||||
<dt className="inline text-zinc-600">model</dt>{" "}
|
||||
<dd className="inline text-zinc-300">
|
||||
{overview.brand} {overview.model}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline text-zinc-600">android</dt>{" "}
|
||||
<dd className="inline text-zinc-300">
|
||||
{overview.android_version} (sdk {overview.sdk})
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline text-zinc-600">kind</dt>{" "}
|
||||
<dd className="inline text-zinc-300">
|
||||
{overview.kind || currentDevice?.kind || "unknown"} / {overview.transport || currentDevice?.transport || "unknown"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline text-zinc-600">display</dt>{" "}
|
||||
<dd className="inline text-zinc-300">{overview.resolution}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline text-zinc-600">rotation</dt>{" "}
|
||||
<dd className="inline text-zinc-300">{overview.rotation}</dd>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-[10px] leading-snug text-zinc-500">{overview.battery}</div>
|
||||
<div className="line-clamp-3 text-[10px] text-zinc-500">{overview.focus}</div>
|
||||
</dl>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{boolBadge(overview.diagnostics?.ready, t("已启动", "Boot ready"), t("未完成启动", "Boot pending"))}
|
||||
{boolBadge(overview.diagnostics?.google_play, "Google Play", "No Play")}
|
||||
{boolBadge(overview.diagnostics?.google_services, "GMS", "No GMS")}
|
||||
{boolBadge(overview.diagnostics?.magisk, "Magisk", "No Magisk")}
|
||||
{boolBadge(overview.diagnostics?.root, "Root", "No root")}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/5 bg-black/20 p-3 text-[11px] text-zinc-400">
|
||||
<div>boot_completed: {overview.diagnostics?.boot_completed || "-"}</div>
|
||||
<div>provisioned: {overview.diagnostics?.device_provisioned || "-"}</div>
|
||||
<div>setup_complete: {overview.diagnostics?.user_setup_complete || "-"}</div>
|
||||
<div>abi: {overview.diagnostics?.abi || "-"}</div>
|
||||
<div className="truncate">hardware: {overview.diagnostics?.hardware || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !overviewErr ? (
|
||||
<p className="mt-2 text-xs text-zinc-500">{t("加载中", "Loading")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-cyan-500/15 bg-zinc-950/50 p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-cyan-300/90">
|
||||
{t("Redroid 板块", "Redroid panel")}
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void runRedroidAction("start")}
|
||||
className="rounded-lg bg-emerald-500/20 px-3 py-1.5 text-xs text-emerald-100 ring-1 ring-emerald-500/30"
|
||||
>
|
||||
{t("启动默认实例", "Start default")}
|
||||
</button>
|
||||
</div>
|
||||
{redroidErr ? <p className="mt-2 text-xs text-rose-300">{redroidErr}</p> : null}
|
||||
{redroid ? (
|
||||
<div className="mt-3 space-y-3 text-[11px] text-zinc-400">
|
||||
<div className="rounded-xl border border-white/5 bg-black/20 p-3">
|
||||
<div>arch: {redroid.arch || "-"}</div>
|
||||
<div className="truncate">image: {redroid.image || "-"}</div>
|
||||
<div>profile: {redroid.device_profile || "-"}</div>
|
||||
<div>gpu: {redroid.gpu_mode || "-"}</div>
|
||||
<div>adb: 127.0.0.1:{redroid.default_port || 5555}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{boolBadge(redroid.requirements?.docker_available, "Docker", "No Docker")}
|
||||
{boolBadge(redroid.requirements?.adb_available, "ADB", "No ADB")}
|
||||
{boolBadge(redroid.requirements?.kvm, "KVM", "No KVM")}
|
||||
{boolBadge(redroid.requirements?.binderfs, "binderfs", "No binderfs")}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(redroid.instances || []).map((item) => (
|
||||
<div key={item.name} className="rounded-xl border border-white/10 bg-black/25 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-100">{item.name}</div>
|
||||
<div className="font-mono text-[10px] text-zinc-500">
|
||||
{item.port ? `127.0.0.1:${item.port}` : "-"} / {item.status_text || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void runRedroidAction("start", item.name)}
|
||||
className="rounded bg-emerald-500/15 px-2 py-1 text-[10px] text-emerald-200"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void runRedroidAction("restart", item.name)}
|
||||
className="rounded bg-amber-500/15 px-2 py-1 text-[10px] text-amber-200"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void runRedroidAction("adb_connect", item.name)}
|
||||
className="rounded bg-cyan-500/15 px-2 py-1 text-[10px] text-cyan-200"
|
||||
>
|
||||
ADB
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void runRedroidAction("stop", item.name)}
|
||||
className="rounded bg-rose-500/15 px-2 py-1 text-[10px] text-rose-200"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{boolBadge(item.diagnostics?.ready, t("系统就绪", "System ready"), t("启动中", "Booting"))}
|
||||
{boolBadge(item.diagnostics?.google_play, "Play", "No Play")}
|
||||
{boolBadge(item.diagnostics?.magisk, "Magisk", "No Magisk")}
|
||||
{boolBadge(item.diagnostics?.root, "Root", "No root")}
|
||||
</div>
|
||||
{item.data_dir ? (
|
||||
<div className="mt-2 truncate font-mono text-[10px] text-zinc-500">{item.data_dir}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{!redroid.instances?.length ? (
|
||||
<p className="rounded-xl border border-white/5 bg-black/20 p-3 text-xs text-zinc-500">
|
||||
{t("当前没有 Redroid 实例。默认动作会创建 redroid13-1。", "No Redroid instances yet. Default actions create redroid13-1.")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-zinc-500">{t("读取 Redroid 状态中", "Loading Redroid status")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LiveAndroidStreamConsole
|
||||
t={t}
|
||||
apiBase={apiBase}
|
||||
fetchJson={fetchJson}
|
||||
serial={androidSerial}
|
||||
devices={devices}
|
||||
setSerial={setAndroidSerial}
|
||||
adbAvailable={adbAvailable}
|
||||
lock={lock}
|
||||
overview={overview}
|
||||
onAndroidAction={onAndroidAction}
|
||||
notify={notify}
|
||||
onRefreshDevices={onRefreshDevices}
|
||||
/>
|
||||
|
||||
{adbAvailable && androidSerial ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-5">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-emerald-300/90">
|
||||
{t("快捷控制", "Quick controls")}
|
||||
</h4>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button type="button" disabled={lock} onClick={() => void sendKey("3")} className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs">Home</button>
|
||||
<button type="button" disabled={lock} onClick={() => void sendKey("4")} className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs">Back</button>
|
||||
<button type="button" disabled={lock} onClick={() => void sendKey("187")} className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs">Recents</button>
|
||||
<button type="button" disabled={lock} onClick={() => void onAndroidAction("wake")} className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs">Wake</button>
|
||||
<button type="button" disabled={lock} onClick={() => void onAndroidAction("launch_package", { package: "com.zhiliaoapp.musically" })} className="rounded-lg bg-gradient-to-r from-pink-500/25 to-violet-500/25 px-3 py-1.5 text-xs ring-1 ring-white/10">TikTok</button>
|
||||
<button type="button" disabled={lock} onClick={() => void sendKey("24")} className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs">Vol+</button>
|
||||
<button type="button" disabled={lock} onClick={() => void sendKey("25")} className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs">Vol-</button>
|
||||
<button type="button" disabled={lock} onClick={() => void sendKey("66")} className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs">Enter</button>
|
||||
</div>
|
||||
<p className="mt-3 text-[10px] text-zinc-600">{t("滑动手势", "Swipe gestures")}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{(["up", "down", "left", "right"] as const).map((dir) => (
|
||||
<button
|
||||
key={dir}
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void onAndroidAction("swipe", { direction: dir })}
|
||||
className="rounded-lg border border-white/10 bg-black/30 px-2.5 py-1 font-mono text-[10px] uppercase text-zinc-300"
|
||||
>
|
||||
{dir}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg border border-white/5 bg-black/20 p-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">{t("ADB 文本输入", "ADB text input")}</p>
|
||||
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-xs"
|
||||
value={adbInputText}
|
||||
onChange={(e) => setAdbInputText(e.target.value)}
|
||||
placeholder="hello / 123"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void sendAdbText();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void sendAdbText()}
|
||||
className="rounded-lg bg-emerald-500/20 px-3 py-2 text-xs text-emerald-100 ring-1 ring-emerald-500/30"
|
||||
>
|
||||
{t("发送", "Send")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-xs"
|
||||
value={openUrl}
|
||||
onChange={(e) => setOpenUrl(e.target.value)}
|
||||
placeholder="https://"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void openUrlGo()}
|
||||
className="rounded-lg bg-violet-500/20 px-3 py-2 text-xs text-violet-100 ring-1 ring-violet-500/30"
|
||||
>
|
||||
{t("打开链接", "Open URL")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-5">
|
||||
<h4 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-cyan-300/90">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
{t("USB 真机优化建议", "USB optimization")}
|
||||
</h4>
|
||||
<ul className="mt-3 space-y-2 text-xs leading-relaxed text-zinc-400">
|
||||
<li>{t("优先使用主板直连 USB,不要经过不稳的 HUB。", "Prefer direct motherboard USB, not unstable hubs.")}</li>
|
||||
<li>{t("开发板端固定 adb server,避免频繁 kill-server。", "Keep adb server stable and avoid frequent kill-server.")}</li>
|
||||
<li>{t("镜像页默认支持 scrcpy H.264 真流,截图模式作为回退。", "Use scrcpy H.264 live stream first, screenshot mode as fallback.")}</li>
|
||||
<li>{t("如果 TikTok / YouTube App 直播,要先确认屏幕常亮、分辨率稳定、USB 供电足够。", "For TikTok / YouTube app live, keep screen on, resolution stable, and USB power sufficient.")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{adbAvailable && androidSerial ? (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Package className="h-4 w-4 text-emerald-400" />
|
||||
{t("包名搜索与启动", "Packages")}
|
||||
</h3>
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-xl border border-white/10 bg-black/40 px-4 py-2 text-sm"
|
||||
value={pkgQuery}
|
||||
onChange={(e) => setPkgQuery(e.target.value)}
|
||||
placeholder="tiktok / musically / youtube"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void searchPackages();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void searchPackages()}
|
||||
className="rounded-xl bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 ring-1 ring-emerald-500/30"
|
||||
>
|
||||
{t("搜索", "Search")}
|
||||
</button>
|
||||
</div>
|
||||
<ul className="mt-4 max-h-48 space-y-1 overflow-y-auto rounded-lg border border-white/5 bg-black/30 p-2 font-mono text-[11px]">
|
||||
{pkgList.map((p) => (
|
||||
<li key={p.package} className="flex flex-wrap items-center justify-between gap-2 py-1 text-zinc-400">
|
||||
<span className="min-w-0 truncate text-zinc-300">{p.package}</span>
|
||||
<span className="flex shrink-0 gap-1">
|
||||
<button type="button" disabled={lock} onClick={() => void onAndroidAction("launch_package", { package: p.package })} className="rounded bg-white/10 px-2 py-0.5 text-[10px] text-zinc-200">
|
||||
{t("启动", "Launch")}
|
||||
</button>
|
||||
<button type="button" disabled={lock} onClick={() => void onAndroidAction("force_stop", { package: p.package })} className="rounded bg-rose-500/15 px-2 py-0.5 text-[10px] text-rose-200">
|
||||
{t("停止", "Stop")}
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{!pkgList.length ? <li className="py-2 text-center text-zinc-600">{t("暂无结果", "No results")}</li> : null}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{adbAvailable && androidSerial ? (
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Terminal className="h-4 w-4 text-amber-400" />
|
||||
ADB shell
|
||||
</h3>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{SHELL_SNIPPETS.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => setShellCmd(s.cmd)}
|
||||
className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className="mt-3 min-h-[100px] w-full resize-y rounded-xl border border-white/10 bg-black/50 p-3 font-mono text-xs text-zinc-200"
|
||||
value={shellCmd}
|
||||
onChange={(e) => setShellCmd(e.target.value)}
|
||||
spellCheck={false}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
void runShell();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void runShell()}
|
||||
className="rounded-xl bg-amber-500/20 px-4 py-2 text-sm text-amber-100 ring-1 ring-amber-500/30"
|
||||
>
|
||||
{pending === "shell" ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Run
|
||||
</span>
|
||||
) : (
|
||||
"Run"
|
||||
)}
|
||||
</button>
|
||||
{shellOut ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!shellOut}
|
||||
onClick={() => onCopy(shellOut)}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("复制输出", "Copy")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{shellOut ? (
|
||||
<pre className="mt-3 max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-white/5 bg-black/40 p-3 font-mono text-[11px] text-zinc-400 xl:max-h-[min(24rem,50vh)]">
|
||||
{shellOut}
|
||||
</pre>
|
||||
) : null}
|
||||
</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("Logcat", "Logcat")}</h3>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="w-24 rounded-lg border border-white/10 bg-black/40 px-3 py-2 font-mono text-xs"
|
||||
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()}
|
||||
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")}
|
||||
</button>
|
||||
{logcatText ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(logcatText)}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("复制", "Copy")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{logcatErr ? <p className="mt-2 text-xs text-rose-300">{logcatErr}</p> : null}
|
||||
{logcatText ? (
|
||||
<pre className="mt-3 max-h-72 overflow-auto whitespace-pre-wrap rounded-lg border border-white/5 bg-black/50 p-3 font-mono text-[10px] leading-tight text-zinc-500 xl:max-h-[min(24rem,50vh)]">
|
||||
{logcatText}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{adbAvailable && androidSerial ? (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Layers className="h-4 w-4 text-fuchsia-400" />
|
||||
{t("界面节点", "UI nodes")}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void loadUiNodes()}
|
||||
className="mt-3 rounded-xl border border-fuchsia-500/30 bg-fuchsia-500/10 px-4 py-2 text-sm text-fuchsia-100"
|
||||
>
|
||||
{t("刷新节点", "Refresh nodes")}
|
||||
</button>
|
||||
{uiErr ? <p className="mt-2 text-xs text-rose-300">{uiErr}</p> : null}
|
||||
<ul className="mt-3 max-h-64 space-y-1 overflow-y-auto rounded-lg border border-white/5 bg-black/30 p-2 text-[11px]">
|
||||
{uiNodes.map((n, i) => (
|
||||
<li
|
||||
key={`${n.center_x}-${n.center_y}-${i}`}
|
||||
className="flex flex-wrap items-center justify-between gap-2 border-b border-white/[0.04] py-1.5 text-zinc-400 last:border-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-zinc-200">{n.label}</span>
|
||||
{n.resource_id ? <span className="ml-2 font-mono text-[10px] text-zinc-600">{n.resource_id}</span> : null}
|
||||
<div className="font-mono text-[10px] text-zinc-600">
|
||||
({n.center_x}, {n.center_y}){n.clickable ? " click" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void onAndroidAction("tap", { x: n.center_x, y: n.center_y })}
|
||||
className="shrink-0 rounded bg-white/10 px-2 py-1 text-[10px] text-zinc-200"
|
||||
>
|
||||
Tap
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{!uiNodes.length && !uiErr ? <li className="py-4 text-center text-zinc-600">{t("尚未加载", "Not loaded")}</li> : null}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{adbAvailable && androidSerial ? (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="text-sm font-semibold text-white">{t("安装 APK", "Install APK")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">{t("先 adb push 到 /sdcard/ 再执行安装。", "Push the APK to /sdcard/ first.")}</p>
|
||||
<input
|
||||
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
|
||||
value={apkPath}
|
||||
onChange={(e) => setApkPath(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock || !adbAvailable || !androidSerial}
|
||||
onClick={() => void onAndroidAction("install_apk", { path: apkPath })}
|
||||
className="mt-3 rounded-xl bg-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-100 ring-1 ring-emerald-500/30"
|
||||
>
|
||||
pm install
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1486
d2ypp2/web-console/components/live-product/LiveControlApp.tsx
Normal file
1486
d2ypp2/web-console/components/live-product/LiveControlApp.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, FolderOpen } from "lucide-react";
|
||||
|
||||
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
|
||||
|
||||
type TFn = (zh: string, en: string) => string;
|
||||
type PortalTheme = "dark" | "light";
|
||||
|
||||
type Props = {
|
||||
t: TFn;
|
||||
portalTheme: PortalTheme;
|
||||
filebrowserUrl: string;
|
||||
};
|
||||
|
||||
export function LiveFilebrowserPanel({ t, portalTheme, filebrowserUrl }: Props) {
|
||||
const url = filebrowserUrl ? normalizeBrowserReachableHttpUrl(filebrowserUrl) : "";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div
|
||||
className={`rounded-2xl border p-6 shadow-xl ${
|
||||
portalTheme === "light"
|
||||
? "border-slate-200/90 bg-gradient-to-br from-amber-50/90 via-white to-slate-50"
|
||||
: "border-amber-500/25 bg-gradient-to-br from-amber-500/[0.12] via-zinc-950/80 to-violet-500/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500/35 to-violet-600/25 ring-1 ring-white/10">
|
||||
<FolderOpen className="h-5 w-5 text-amber-200" />
|
||||
</div>
|
||||
<div>
|
||||
<h2
|
||||
className={`text-lg font-semibold tracking-tight ${portalTheme === "light" ? "text-slate-900" : "text-white"}`}
|
||||
>
|
||||
{t("文件(File Browser)", "Files (File Browser)")}
|
||||
</h2>
|
||||
<p className={`mt-1 text-xs ${portalTheme === "light" ? "text-slate-600" : "text-zinc-500"}`}>
|
||||
{t(
|
||||
"与 Docker 部署的 File Browser 分离:此处仅嵌入面板并说明 API,便于你自建「选本地视频 → 配合 TikTok HDMI 测试」等前端逻辑。",
|
||||
"Separate from File Browser itself: embed + API notes so you can build “pick local video → TikTok HDMI test” flows.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-2xl border p-5 ${
|
||||
portalTheme === "light" ? "border-slate-200/90 bg-white/90" : "border-white/[0.08] bg-zinc-950/50"
|
||||
}`}
|
||||
>
|
||||
<p className={`text-sm font-medium ${portalTheme === "light" ? "text-slate-800" : "text-white"}`}>
|
||||
{t("内置 UI(iframe)", "Built-in UI (iframe)")}
|
||||
</p>
|
||||
<p className={`mt-1 text-xs ${portalTheme === "light" ? "text-slate-600" : "text-zinc-500"}`}>
|
||||
{t(
|
||||
"若服务未启动或端口不通,请先在「服务」页启动 filebrowser 容器。",
|
||||
"Start the filebrowser service from Services if the frame is blank.",
|
||||
)}
|
||||
</p>
|
||||
{url ? (
|
||||
<div
|
||||
className={`mt-4 overflow-hidden rounded-xl border ${
|
||||
portalTheme === "light" ? "border-slate-200 bg-slate-50" : "border-white/10 bg-black/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-white/5 px-3 py-2">
|
||||
<span className="truncate font-mono text-[11px] text-zinc-500">{url}</span>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`inline-flex shrink-0 items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium ${
|
||||
portalTheme === "light"
|
||||
? "bg-violet-100 text-violet-800"
|
||||
: "bg-violet-500/20 text-violet-200"
|
||||
}`}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t("新窗口", "New tab")}
|
||||
</a>
|
||||
</div>
|
||||
<iframe title="File Browser" src={url} className="h-[min(72vh,40rem)] w-full bg-black/20" />
|
||||
</div>
|
||||
) : (
|
||||
<p className={`mt-4 text-sm ${portalTheme === "light" ? "text-amber-800" : "text-amber-200/90"}`}>
|
||||
{t("未从总览获取到 filebrowser 链接;请确认 stack 配置与 hub 仪表盘。", "No filebrowser URL from dashboard.")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-2xl border p-5 ${
|
||||
portalTheme === "light" ? "border-slate-200/90 bg-white/90" : "border-white/[0.08] bg-zinc-950/50"
|
||||
}`}
|
||||
>
|
||||
<h3 className={`text-sm font-semibold ${portalTheme === "light" ? "text-slate-900" : "text-white"}`}>
|
||||
{t("REST API(自定义前端)", "REST API (custom UI)")}
|
||||
</h3>
|
||||
<ul className={`mt-3 space-y-2 text-xs ${portalTheme === "light" ? "text-slate-600" : "text-zinc-400"}`}>
|
||||
<li>
|
||||
{t(
|
||||
"登录:POST /api/login,Body 为 JSON:username / password(与 File Browser 账户一致),返回 Set-Cookie 或 token(视版本而定)。",
|
||||
"Login: POST /api/login with JSON username/password; follow cookie/token per your File Browser version.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"列目录:GET /api/resources(常需携带 Cookie)。具体字段以你部署的版本文档为准。",
|
||||
"List: GET /api/resources (often cookie-authenticated). See your image version docs.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"与 TikTok HDMI 页配合:在自定义脚本里拉取文件路径后,可写独立 ffmpeg 推流;控制台会在启动 TikTok / obs*.sh 时自动互斥其它 HDMI 链路进程。",
|
||||
"With TikTok HDMI: your script can feed ffmpeg paths; the hub stops other TikTok/OBS HDMI sinks when you start one.",
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
|
||||
|
||||
export type PortalNavTarget =
|
||||
| "network"
|
||||
| "services"
|
||||
| "live_youtube"
|
||||
| "live_tiktok"
|
||||
| "android";
|
||||
|
||||
type TFn = (zh: string, en: string) => string;
|
||||
|
||||
export type PipelineStep = {
|
||||
label: string;
|
||||
sub?: string;
|
||||
gradient: string;
|
||||
target?: PortalNavTarget;
|
||||
/** 若设置,点击时在新标签打开(优先于 target) */
|
||||
openUrl?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
t: TFn;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
steps: PipelineStep[];
|
||||
onNavigate?: (target: PortalNavTarget) => void;
|
||||
};
|
||||
|
||||
export function LivePipelineStrip({ t, title, subtitle, steps, onNavigate }: Props) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-violet-500/25 bg-gradient-to-br from-violet-500/10 via-zinc-950/40 to-fuchsia-500/5 p-6 shadow-lg shadow-violet-900/10">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">{title}</h3>
|
||||
<p className="mt-2 text-xs text-zinc-400">{subtitle}</p>
|
||||
<div className="mt-6 flex flex-col items-center gap-4 sm:flex-row sm:flex-wrap sm:justify-center">
|
||||
{steps.map((step, i) => (
|
||||
<div key={step.label} className="flex items-center gap-4">
|
||||
{step.openUrl || (step.target && onNavigate) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (step.openUrl) {
|
||||
window.open(normalizeBrowserReachableHttpUrl(step.openUrl), "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
if (step.target && onNavigate) onNavigate(step.target);
|
||||
}}
|
||||
className={`flex min-h-[4rem] min-w-[7rem] flex-col items-center justify-center rounded-2xl bg-gradient-to-br px-4 py-2 text-center text-xs font-bold text-white shadow-lg ring-2 ring-white/10 transition hover:ring-violet-400/50 hover:brightness-110 ${step.gradient}`}
|
||||
>
|
||||
<span>{step.label}</span>
|
||||
{step.sub ? <span className="mt-1 text-[10px] font-normal text-white/80">{step.sub}</span> : null}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className={`flex min-h-[4rem] min-w-[7rem] flex-col items-center justify-center rounded-2xl bg-gradient-to-br px-4 py-2 text-center text-xs font-bold text-white shadow-lg ${step.gradient}`}
|
||||
>
|
||||
<span>{step.label}</span>
|
||||
{step.sub ? <span className="mt-1 text-[10px] font-normal text-white/80">{step.sub}</span> : null}
|
||||
</div>
|
||||
)}
|
||||
{i < steps.length - 1 ? <ChevronRight className="hidden h-5 w-5 text-zinc-600 sm:block" aria-hidden /> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 text-center text-[10px] text-zinc-600">{t("点击高亮块可跳转到对应功能页", "Tap a highlighted step to open its page")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, Pause, Play, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
extractDouyinCurrentRoomFromLog,
|
||||
extractDouyinLiveUrls,
|
||||
extractDouyinRoomHints,
|
||||
} from "@/lib/youtubeConfigUtils";
|
||||
|
||||
type TFn = (zh: string, en: string) => string;
|
||||
|
||||
type StatusPayload = {
|
||||
process_status?: string;
|
||||
recent_log?: string;
|
||||
recent_error?: string;
|
||||
business_status?: string;
|
||||
business_note?: string;
|
||||
ffmpeg_pids?: number[];
|
||||
log_age_seconds?: number | null;
|
||||
is_pushing?: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
function businessLine(t: TFn, raw: string | undefined, note: string | undefined): string {
|
||||
const s = (raw || "").toLowerCase();
|
||||
if (s === "streaming") return t("Pushing to YouTube", "Pushing to YouTube");
|
||||
if (s === "waiting_source") return t("Waiting for the upstream live source", "Waiting for the upstream live source");
|
||||
if (s === "source_live")
|
||||
return t("Source live detected, but no active FFmpeg relay worker", "Source live detected, but no active FFmpeg relay worker");
|
||||
if (s === "monitoring") return t("Process online and monitoring the source", "Process online and monitoring the source");
|
||||
if (s === "misconfigured") return t("YouTube key missing or invalid", "YouTube key missing or invalid");
|
||||
if (s === "stale") return t("Process online but the log heartbeat is stale", "Process online but the log heartbeat is stale");
|
||||
if (s === "error") return t("Recent process error detected", "Recent process error detected");
|
||||
if (note?.trim()) return note.trim();
|
||||
return t("No business-state hint yet", "No business-state hint yet");
|
||||
}
|
||||
|
||||
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 [businessStatus, setBusinessStatus] = useState("");
|
||||
const [businessNote, setBusinessNote] = useState("");
|
||||
const [ffmpegPids, setFfmpegPids] = useState<number[]>([]);
|
||||
const [logAgeSeconds, setLogAgeSeconds] = useState<number | null>(null);
|
||||
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 onlineNow = /^(online|running)$/i.test(processStatus);
|
||||
const roomFromLog = extractDouyinCurrentRoomFromLog(recentLog);
|
||||
const fallbackUrl = extractDouyinLiveUrls(urlListText)[0] ?? "";
|
||||
const primaryRoomId = roomFromLog || (fallbackUrl.match(/live\.douyin\.com\/([a-zA-Z0-9_-]+)/i)?.[1] ?? null);
|
||||
const primaryUrl = primaryRoomId ? `https://live.douyin.com/${primaryRoomId}` : fallbackUrl || null;
|
||||
|
||||
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 || "");
|
||||
setBusinessStatus(data.business_status || "");
|
||||
setBusinessNote(data.business_note || "");
|
||||
setFfmpegPids(Array.isArray(data.ffmpeg_pids) ? data.ffmpeg_pids : []);
|
||||
setLogAgeSeconds(typeof data.log_age_seconds === "number" ? data.log_age_seconds : null);
|
||||
const online = /^(online|running)$/i.test(ps);
|
||||
if (online && !prevOnlineRef.current) {
|
||||
setLiveSince(Date.now());
|
||||
}
|
||||
if (!online) {
|
||||
setLiveSince(null);
|
||||
}
|
||||
prevOnlineRef.current = online;
|
||||
} catch {
|
||||
setProcessStatus("error");
|
||||
setBusinessStatus("error");
|
||||
setBusinessNote("");
|
||||
setFfmpegPids([]);
|
||||
setLogAgeSeconds(null);
|
||||
}
|
||||
}, [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("Business state", "Business state")}</span>
|
||||
<span className="font-medium text-cyan-200/90">{businessLine(t, businessStatus, businessNote)}</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 className="flex flex-wrap gap-2">
|
||||
<span className="text-zinc-500">{t("FFmpeg workers", "FFmpeg workers")}</span>
|
||||
<span className="font-mono text-zinc-300">
|
||||
{ffmpegPids.length ? ffmpegPids.join(", ") : "none"}
|
||||
{logAgeSeconds != null ? ` · log ${fmtDuration(Math.max(0, Math.floor(logAgeSeconds)))}` : ""}
|
||||
</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}
|
||||
{primaryUrl ? (
|
||||
<div className="rounded-lg border border-violet-500/20 bg-violet-500/[0.06] px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-[11px] font-medium text-zinc-400">
|
||||
{roomFromLog
|
||||
? t("当前直播源站(日志)", "Current source (from log)")
|
||||
: onlineNow
|
||||
? t("配置中的源站地址", "Configured source URL")
|
||||
: t("源站快捷入口", "Source quick link")}
|
||||
</span>
|
||||
<a
|
||||
href={primaryUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-violet-500/20 px-3 py-1.5 text-xs font-medium text-violet-100 ring-1 ring-violet-500/35 transition hover:bg-violet-500/30"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0 opacity-90" />
|
||||
{primaryRoomId || t("打开抖音直播页", "Open Douyin live")}
|
||||
</a>
|
||||
</div>
|
||||
{douyinHints.length > 1 && roomFromLog ? (
|
||||
<p className="mt-2 text-[10px] text-zinc-600">
|
||||
{t("配置中还有", "Also in config:")}{" "}
|
||||
{douyinHints
|
||||
.filter((id) => id !== primaryRoomId)
|
||||
.slice(0, 3)
|
||||
.map((id) => (
|
||||
<a
|
||||
key={id}
|
||||
href={`https://live.douyin.com/${id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-1 text-violet-400/90 underline-offset-2 hover:underline"
|
||||
>
|
||||
{id}
|
||||
</a>
|
||||
))}
|
||||
{douyinHints.length > 4 ? "…" : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-zinc-600">
|
||||
{t("(日志与地址列表中暂无抖音房间)", "(No Douyin room in log or 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,783 @@
|
||||
"use client";
|
||||
|
||||
import { Cable, Copy, Film, Monitor, Radio, Trash2, Upload } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
|
||||
import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip";
|
||||
import { LivePipelineStrip } from "@/components/live-product/LivePipelineStrip";
|
||||
import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard";
|
||||
import { fetchJsonResponse } from "@/lib/fetchJson";
|
||||
|
||||
type ProcessEntry = { pm2: string; script: string; label: string };
|
||||
|
||||
type TFn = (zh: string, en: string) => string;
|
||||
|
||||
function isTiktokProcess(e: ProcessEntry) {
|
||||
return /tiktok/i.test(e.script) || /tiktok/i.test(e.pm2);
|
||||
}
|
||||
|
||||
type HdmiPreset = { id: string; labelZh: string; labelEn: string; spec: string; hintZh: string; hintEn: string };
|
||||
|
||||
const PRESETS: HdmiPreset[] = [
|
||||
{
|
||||
id: "720p60",
|
||||
labelZh: "720p @ 60Hz",
|
||||
labelEn: "720p @ 60Hz",
|
||||
spec: "1280x720, 59.94/60Hz, RGB/YUV 有限",
|
||||
hintZh: "板端 HDMI 输出时序与显示端一致;下游画布建议 1280×720。",
|
||||
hintEn: "Match SoC HDMI timing to the display; downstream canvas e.g. 1280×720.",
|
||||
},
|
||||
{
|
||||
id: "1080p30",
|
||||
labelZh: "1080p @ 30Hz",
|
||||
labelEn: "1080p @ 30Hz",
|
||||
spec: "1920x1080, 30Hz",
|
||||
hintZh: "带宽紧张时可优先 1080p30;检查线材与接口规格。",
|
||||
hintEn: "Prefer 1080p30 when bandwidth is tight; check cable and port specs.",
|
||||
},
|
||||
{
|
||||
id: "1080p60",
|
||||
labelZh: "1080p @ 60Hz",
|
||||
labelEn: "1080p @ 60Hz",
|
||||
spec: "1920x1080, 60Hz, 高刷转播",
|
||||
hintZh: "两端均需稳定支持 1080p60;长时间运行注意散热。",
|
||||
hintEn: "Both ends need stable 1080p60; mind thermals for long runs.",
|
||||
},
|
||||
];
|
||||
|
||||
const CHECK_KEY = "live-hub-tiktok-hdmi-check-v1";
|
||||
const NOTES_KEY = "live-hub-tiktok-hdmi-notes-v1";
|
||||
const SOURCE_MODE_KEY = "live-hub-tiktok-source-mode-v1";
|
||||
|
||||
/** UI 仅展示 SRS 选项(ffmpeg/replay 的 option 保留 hidden,改 false 即可恢复) */
|
||||
const TIKTOK_SOURCE_UI_SRS_ONLY = true;
|
||||
|
||||
export type TiktokSourceMode = "ffmpeg" | "replay" | "srs";
|
||||
|
||||
type ReplayFileRow = { name: string; size: number; replay_url: string };
|
||||
|
||||
type HdmiStatus = {
|
||||
status?: string;
|
||||
can_stream_hdmi?: boolean;
|
||||
preferred_device?: string;
|
||||
video_devices?: string[];
|
||||
usb_capture_matches?: string[];
|
||||
issues?: string[];
|
||||
suggested_ffmpeg_cmd?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
t: TFn;
|
||||
busy: boolean;
|
||||
entries: ProcessEntry[];
|
||||
currentProc: string;
|
||||
setCurrentProc: (v: string) => void;
|
||||
urlConfig: string;
|
||||
setUrlConfig: (v: string) => void;
|
||||
onRunProcess: (a: "start" | "stop" | "restart") => void;
|
||||
onSaveUrlConfig: (content?: string) => void | Promise<void>;
|
||||
/** 录播:写入 URL 配置为单条 replayfile 行并启动进程 */
|
||||
onReplayGoLive: (replayUrl: string, displayLabel: string) => void | Promise<void>;
|
||||
onCopy: (text: string) => void;
|
||||
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||||
apiPrefix: string;
|
||||
quickLinks: Record<string, string>;
|
||||
hdmiStatus?: HdmiStatus;
|
||||
notify: (msg: string) => void;
|
||||
onNavigate?: (target: PortalNavTarget) => void;
|
||||
};
|
||||
|
||||
export function LiveTiktokHdmiView({
|
||||
t,
|
||||
busy,
|
||||
entries,
|
||||
currentProc,
|
||||
setCurrentProc,
|
||||
urlConfig,
|
||||
setUrlConfig,
|
||||
onRunProcess,
|
||||
onSaveUrlConfig,
|
||||
onReplayGoLive,
|
||||
onCopy,
|
||||
fetchJson,
|
||||
apiPrefix,
|
||||
quickLinks,
|
||||
hdmiStatus,
|
||||
notify,
|
||||
onNavigate,
|
||||
}: Props) {
|
||||
const tkEntries = useMemo(() => entries.filter(isTiktokProcess), [entries]);
|
||||
const processOptions = tkEntries.length ? tkEntries : entries;
|
||||
const [sourceMode, setSourceMode] = useState<TiktokSourceMode>(
|
||||
TIKTOK_SOURCE_UI_SRS_ONLY ? "srs" : "ffmpeg",
|
||||
);
|
||||
const [replayFiles, setReplayFiles] = useState<ReplayFileRow[]>([]);
|
||||
const [replayLoading, setReplayLoading] = useState(false);
|
||||
const [uploadBusy, setUploadBusy] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string | null>("1080p30");
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>("1080p30");
|
||||
const [urlReloading, setUrlReloading] = useState(false);
|
||||
const [urlAutoRefresh, setUrlAutoRefresh] = useState(true);
|
||||
const [urlFetchNote, setUrlFetchNote] = useState<string | null>(null);
|
||||
const urlListDirtyRef = useRef(false);
|
||||
const [hdmiNotes, setHdmiNotes] = useState("");
|
||||
const [checks, setChecks] = useState<Record<string, boolean>>({});
|
||||
|
||||
const setSourceModePersist = useCallback(
|
||||
(m: TiktokSourceMode) => {
|
||||
setSourceMode(m);
|
||||
try {
|
||||
window.localStorage.setItem(SOURCE_MODE_KEY, m);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const r = window.localStorage.getItem(SOURCE_MODE_KEY);
|
||||
if (r === "ffmpeg" || r === "replay" || r === "srs") {
|
||||
if (TIKTOK_SOURCE_UI_SRS_ONLY && (r === "ffmpeg" || r === "replay")) setSourceMode("srs");
|
||||
else setSourceMode(r);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
urlListDirtyRef.current = false;
|
||||
}, [currentProc]);
|
||||
|
||||
const pullUrlConfigFromServer = useCallback(async () => {
|
||||
if (!currentProc) return;
|
||||
try {
|
||||
const data = await fetchJson<{ content?: string }>(
|
||||
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
|
||||
);
|
||||
setUrlConfig(data.content ?? "");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [currentProc, fetchJson, setUrlConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProc || !urlAutoRefresh || sourceMode !== "ffmpeg") return;
|
||||
const id = window.setInterval(() => {
|
||||
if (urlListDirtyRef.current) return;
|
||||
void pullUrlConfigFromServer();
|
||||
}, 8000);
|
||||
return () => clearInterval(id);
|
||||
}, [currentProc, urlAutoRefresh, pullUrlConfigFromServer, sourceMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceMode !== "ffmpeg" || !currentProc) return;
|
||||
void pullUrlConfigFromServer();
|
||||
}, [sourceMode, currentProc, pullUrlConfigFromServer]);
|
||||
|
||||
const loadReplayList = useCallback(async () => {
|
||||
if (!currentProc) return;
|
||||
setReplayLoading(true);
|
||||
try {
|
||||
const data = await fetchJson<{ files?: ReplayFileRow[] }>(
|
||||
`/tiktok_replay/list?process=${encodeURIComponent(currentProc)}`,
|
||||
);
|
||||
setReplayFiles(data.files ?? []);
|
||||
} catch (e) {
|
||||
notify((e as Error).message);
|
||||
setReplayFiles([]);
|
||||
} finally {
|
||||
setReplayLoading(false);
|
||||
}
|
||||
}, [currentProc, fetchJson, notify]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceMode !== "replay" || !currentProc) return;
|
||||
void loadReplayList();
|
||||
}, [sourceMode, currentProc, loadReplayList]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CHECK_KEY);
|
||||
if (raw) setChecks(JSON.parse(raw) as Record<string, boolean>);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
setHdmiNotes(window.localStorage.getItem(NOTES_KEY) || "");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistChecks = useCallback((next: Record<string, boolean>) => {
|
||||
setChecks(next);
|
||||
try {
|
||||
window.localStorage.setItem(CHECK_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistNotes = (v: string) => {
|
||||
setHdmiNotes(v);
|
||||
try {
|
||||
window.localStorage.setItem(NOTES_KEY, v);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const reloadUrlConfig = async () => {
|
||||
if (!currentProc) return;
|
||||
setUrlReloading(true);
|
||||
setUrlFetchNote(null);
|
||||
urlListDirtyRef.current = false;
|
||||
try {
|
||||
const data = await fetchJson<{ content?: string }>(
|
||||
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
|
||||
);
|
||||
setUrlConfig(data.content ?? "");
|
||||
setUrlFetchNote(t("已从服务器读取 URL_config", "Reloaded URL_config from server"));
|
||||
} catch (e) {
|
||||
setUrlFetchNote((e as Error).message);
|
||||
} finally {
|
||||
setUrlReloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveUrlAndClearDirty = async () => {
|
||||
try {
|
||||
await Promise.resolve(onSaveUrlConfig());
|
||||
urlListDirtyRef.current = false;
|
||||
} catch {
|
||||
/* parent 已 toast */
|
||||
}
|
||||
};
|
||||
|
||||
const deleteReplayFile = async (name: string) => {
|
||||
if (!currentProc) return;
|
||||
setUploadBusy(true);
|
||||
try {
|
||||
await fetchJson(`/tiktok_replay/delete`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ process: currentProc, filename: name }),
|
||||
});
|
||||
notify(t("已删除文件", "File removed"));
|
||||
await loadReplayList();
|
||||
} catch (e) {
|
||||
notify((e as Error).message);
|
||||
} finally {
|
||||
setUploadBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPickUpload = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!f || !currentProc) return;
|
||||
setUploadBusy(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", f);
|
||||
const data = await fetchJsonResponse<{ error?: string; message?: string }>(
|
||||
`${apiPrefix}/tiktok_replay/upload?process=${encodeURIComponent(currentProc)}`,
|
||||
{ method: "POST", body: fd },
|
||||
);
|
||||
notify(data.message || t("上传成功", "Uploaded"));
|
||||
await loadReplayList();
|
||||
} catch (e) {
|
||||
notify((e as Error).message);
|
||||
} finally {
|
||||
setUploadBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const lock = busy || urlReloading || uploadBusy;
|
||||
const presetLabel = PRESETS.find((p) => p.id === selectedPreset);
|
||||
const hdmiReady = !!hdmiStatus?.can_stream_hdmi;
|
||||
const hdmiDevice = hdmiStatus?.preferred_device || hdmiStatus?.video_devices?.[0] || "";
|
||||
|
||||
const checklist: { id: string; zh: string; en: string }[] = [
|
||||
{ id: "cable", zh: "HDMI 线材插紧、方向正确(如需转接头确认规格)", en: "HDMI cable seated; adapter specs OK" },
|
||||
{ id: "thermal", zh: "长时间推流注意散热,过热会掉帧黑屏", en: "Thermal headroom for long runs" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{onNavigate ? (
|
||||
<LivePipelineStrip
|
||||
t={t}
|
||||
title={t("TikTok HDMI 硬件链路", "TikTok HDMI hardware chain")}
|
||||
subtitle={t(
|
||||
"板端编码经 HDMI 到显示/下游设备;与「网络」页顶卡相同的箭头风格。",
|
||||
"Encode on SoC → HDMI → display/downstream; same arrow style as Network header.",
|
||||
)}
|
||||
onNavigate={onNavigate}
|
||||
steps={[
|
||||
{
|
||||
label: "FFmpeg",
|
||||
sub: t("采集 / 推流", "Capture"),
|
||||
gradient: "from-cyan-400 to-blue-500",
|
||||
target: "live_tiktok",
|
||||
},
|
||||
{
|
||||
label: "HDMI",
|
||||
sub: t("板载输出", "SoC out"),
|
||||
gradient: "from-violet-500 to-fuchsia-500",
|
||||
},
|
||||
{
|
||||
label: "Android",
|
||||
sub: t("AOSP / 手机", "AOSP"),
|
||||
gradient: "from-amber-400 to-orange-500",
|
||||
target: "android",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
<div className="rounded-2xl border border-cyan-500/25 bg-gradient-to-br from-cyan-500/[0.14] via-zinc-950/80 to-fuchsia-500/10 p-6 shadow-xl shadow-cyan-900/10">
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-cyan-500/35 to-fuchsia-600/25 ring-1 ring-white/10">
|
||||
<Cable className="h-5 w-5 text-cyan-200" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight text-white">
|
||||
{t("TikTok 无人直播(单路)", "TikTok unmanned (single lane)")}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">
|
||||
{t(
|
||||
"布局对齐 YouTube 单频道:直播开关 + 源站 + URL_config;无 stream key,输出走 HDMI 硬件链路。",
|
||||
"Same layout as YouTube single: controls + URLs; no stream key — HDMI hardware path.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-cyan-500/20 bg-cyan-500/[0.06] p-4 ring-1 ring-white/[0.04]">
|
||||
<p className="text-xs leading-relaxed text-cyan-100/90">
|
||||
{t(
|
||||
"HDMI 互斥:后端在「开始 / 重新开始」TikTok 或 obs*.sh(SRS 拉流上屏)时,会自动停止其它正在占用 HDMI 链路的同类进程;与 YouTube 推流互不干扰。",
|
||||
"HDMI mutex: starting TikTok or obs*.sh stops other TikTok/OBS HDMI sinks; YouTube RTMP is unaffected.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-zinc-950/60 p-6 ring-1 ring-white/[0.04]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Monitor className="h-4 w-4 text-cyan-300" />
|
||||
{t("X86 HDMI 采集状态", "X86 HDMI capture status")}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t(
|
||||
"这里直接显示主机是否识别到采集卡和 /dev/video 设备,可用于验证 ARM HDMI 进 X86 的链路。",
|
||||
"Shows whether the host sees the capture card and /dev/video nodes for ARM-to-X86 HDMI ingest.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-[11px] font-semibold ring-1 ${
|
||||
hdmiReady
|
||||
? "bg-emerald-500/20 text-emerald-200 ring-emerald-500/30"
|
||||
: "bg-rose-500/15 text-rose-200 ring-rose-500/25"
|
||||
}`}
|
||||
>
|
||||
{hdmiReady ? t("可推流", "Ready") : t("未就绪", "Not ready")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-white/10 bg-black/25 p-4 text-[11px] text-zinc-400">
|
||||
<p>
|
||||
<span className="text-zinc-600">device: </span>
|
||||
<span className="font-mono text-zinc-200">{hdmiDevice || "—"}</span>
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<span className="text-zinc-600">video nodes: </span>
|
||||
<span className="font-mono text-zinc-200">
|
||||
{(hdmiStatus?.video_devices || []).join(", ") || "—"}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<span className="text-zinc-600">usb matches: </span>
|
||||
<span className="text-zinc-300">
|
||||
{(hdmiStatus?.usb_capture_matches || []).length || 0}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/25 p-4 text-[11px] text-zinc-400">
|
||||
<p className="text-zinc-600">{t("建议 FFmpeg", "Suggested FFmpeg")}</p>
|
||||
<p className="mt-2 break-all font-mono text-zinc-200">
|
||||
{hdmiStatus?.suggested_ffmpeg_cmd || "—"}
|
||||
</p>
|
||||
{hdmiStatus?.suggested_ffmpeg_cmd ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(hdmiStatus.suggested_ffmpeg_cmd || "")}
|
||||
className="mt-3 inline-flex items-center gap-2 rounded-lg bg-cyan-500/15 px-3 py-2 text-xs font-medium text-cyan-100 ring-1 ring-cyan-500/25"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("复制命令", "Copy command")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{(hdmiStatus?.issues || []).length ? (
|
||||
<ul className="mt-4 list-inside list-disc space-y-1 text-[11px] text-amber-200/85">
|
||||
{(hdmiStatus?.issues || []).map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-amber-500/20 bg-zinc-950/60 p-6 ring-1 ring-white/[0.04]">
|
||||
<div className="flex items-center gap-2 text-amber-200/90">
|
||||
<Radio className="h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold text-white">{t("直播开关", "Live control")}</h3>
|
||||
</div>
|
||||
<label className="mt-4 block text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||||
{t("TikTok 进程(PM2)", "TikTok PM2 process")}
|
||||
</label>
|
||||
<select
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"
|
||||
value={currentProc}
|
||||
onChange={(e) => setCurrentProc(e.target.value)}
|
||||
>
|
||||
{processOptions.map((e) => (
|
||||
<option key={e.pm2} value={e.pm2}>
|
||||
{e.label} ({e.pm2})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="mt-4 block text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||||
{t("直播源", "Video source")}
|
||||
</label>
|
||||
<select
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"
|
||||
value={sourceMode}
|
||||
onChange={(e) => setSourceModePersist(e.target.value as TiktokSourceMode)}
|
||||
>
|
||||
<option value="ffmpeg" hidden={TIKTOK_SOURCE_UI_SRS_ONLY}>
|
||||
FFmpeg(抖音 / TikTok 等直播间 URL)
|
||||
</option>
|
||||
<option value="replay" hidden={TIKTOK_SOURCE_UI_SRS_ONLY}>
|
||||
{t("录播素材(本地上传)", "VOD upload")}
|
||||
</option>
|
||||
<option value="srs">{t("SRS(OBS 推流)", "SRS (OBS)")}</option>
|
||||
</select>
|
||||
{sourceMode === "srs" ? (
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-amber-200/85">
|
||||
{t(
|
||||
"此模式不显示 URL_config:请在 OBS 中填写下方 RTMP 推流地址;板端 SRS 拉流上屏仍可能占用 HDMI,与 TikTok 进程互斥规则不变。",
|
||||
"URL_config is hidden: set the RTMP URL in OBS. SRS pull-to-HDMI may still mutex with TikTok per server rules.",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{(
|
||||
[
|
||||
["start", t("开始直播", "Start"), "bg-emerald-500/20 text-emerald-200 ring-emerald-500/30"],
|
||||
["stop", t("停止直播", "Stop"), "bg-rose-500/15 text-rose-200 ring-rose-500/25"],
|
||||
["restart", t("重新开始", "Restart"), "bg-amber-500/15 text-amber-200 ring-amber-500/25"],
|
||||
] as const
|
||||
).map(([a, label, cls]) => (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => onRunProcess(a)}
|
||||
className={`rounded-xl px-4 py-3 text-sm font-medium ring-1 ${cls}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-center text-[11px] text-zinc-500">
|
||||
{t("当前 HDMI 预设(备忘)", "HDMI preset (memo)")}:{" "}
|
||||
<span className="font-mono text-cyan-300/90">
|
||||
{presetLabel ? t(presetLabel.labelZh, presetLabel.labelEn) : selectedPreset}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sourceMode === "replay" ? (
|
||||
<div className="rounded-2xl border border-violet-500/25 bg-zinc-950/60 p-6 ring-1 ring-white/[0.04]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Film className="h-4 w-4 text-violet-300" />
|
||||
{t("录播素材", "VOD library")}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-xl bg-violet-500/15 px-3 py-2 text-xs font-medium text-violet-100 ring-1 ring-violet-500/30">
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<span>{t("上传视频", "Upload")}</span>
|
||||
<input type="file" accept=".mp4,.mkv,.ts,.flv,.mov,.m4v,.webm" className="hidden" onChange={onPickUpload} />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock || !currentProc}
|
||||
onClick={() => void loadReplayList()}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-xs text-zinc-300"
|
||||
>
|
||||
{t("刷新列表", "Refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-zinc-500">
|
||||
{t(
|
||||
"文件保存在服务器 config/tiktok_replay/对应进程目录;点「开始直播」会写入 replayfile 行并启动 PM2 进程。停播请用上方「停止直播」。",
|
||||
"Files under config/tiktok_replay/<process>. Go live writes replayfile + starts PM2. Use Stop above to stop.",
|
||||
)}
|
||||
</p>
|
||||
{replayLoading ? (
|
||||
<p className="mt-4 text-xs text-zinc-500">{t("加载中…", "Loading…")}</p>
|
||||
) : replayFiles.length === 0 ? (
|
||||
<p className="mt-4 text-xs text-zinc-500">{t("暂无文件,请先上传。", "No files yet.")}</p>
|
||||
) : (
|
||||
<ul className="mt-4 space-y-2">
|
||||
{replayFiles.map((row) => (
|
||||
<li
|
||||
key={row.name}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-xs text-zinc-300"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono">{row.name}</span>
|
||||
<span className="shrink-0 text-zinc-500">{(row.size / (1024 * 1024)).toFixed(1)} MB</span>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
try {
|
||||
await Promise.resolve(onReplayGoLive(row.replay_url, row.name));
|
||||
} catch {
|
||||
/* parent toasts */
|
||||
}
|
||||
})()
|
||||
}
|
||||
className="rounded-lg bg-emerald-500/20 px-2 py-1 text-[11px] text-emerald-100 ring-1 ring-emerald-500/30"
|
||||
>
|
||||
{t("开始直播", "Go live")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => onRunProcess("stop")}
|
||||
className="rounded-lg bg-rose-500/15 px-2 py-1 text-[11px] text-rose-100 ring-1 ring-rose-500/25"
|
||||
>
|
||||
{t("停播", "Stop")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void deleteReplayFile(row.name)}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-zinc-700/40 px-2 py-1 text-[11px] text-zinc-200"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t("删除", "Del")}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sourceMode === "srs" ? (
|
||||
<div className="rounded-2xl border border-emerald-500/25 bg-zinc-950/60 p-6 ring-1 ring-white/[0.04]">
|
||||
<h3 className="text-sm font-semibold text-white">{t("SRS / OBS 推流", "SRS / OBS ingest")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t(
|
||||
"OBS:设置 → 推流 → 服务选「自定义」,服务器填 RTMP 地址;串流密钥可与 SRS 应用配置一致(默认 live/livestream)。",
|
||||
"OBS → Stream → Custom: RTMP URL as server; stream key per SRS app (default live/livestream).",
|
||||
)}
|
||||
</p>
|
||||
<dl className="mt-4 space-y-3 text-xs">
|
||||
<div>
|
||||
<dt className="text-zinc-500">{t("RTMP 推流地址(OBS 服务器)", "RTMP publish URL")}</dt>
|
||||
<dd className="mt-1 flex flex-wrap items-center gap-2 font-mono text-emerald-200/90">
|
||||
<span className="break-all">{quickLinks.srs_rtmp_publish || "—"}</span>
|
||||
{quickLinks.srs_rtmp_publish ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-cyan-300 hover:text-cyan-200"
|
||||
onClick={() => onCopy(quickLinks.srs_rtmp_publish)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">{t("HTTP-FLV 预览", "HTTP-FLV preview")}</dt>
|
||||
<dd className="mt-1 flex flex-wrap items-center gap-2 font-mono text-zinc-400">
|
||||
<span className="break-all">{quickLinks.srs_play_flv || quickLinks.srs_http || "—"}</span>
|
||||
{quickLinks.srs_play_flv || quickLinks.srs_http ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-cyan-300 hover:text-cyan-200"
|
||||
onClick={() => onCopy(quickLinks.srs_play_flv || quickLinks.srs_http)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">{t("SRS 控制台", "SRS web UI")}</dt>
|
||||
<dd className="mt-1 font-mono text-zinc-400">
|
||||
<a
|
||||
href={quickLinks.srs_http || "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-cyan-300 hover:underline"
|
||||
>
|
||||
{quickLinks.srs_http || "—"}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Monitor className="h-4 w-4 text-cyan-400" />
|
||||
{t("HDMI 硬件与时序", "HDMI timing")}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t("点选一路作为现场备忘;与板端 ffmpeg/OBS 采集参数对齐。", "Pick a preset as field memo; match capture settings.")}
|
||||
</p>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
{PRESETS.map((p) => {
|
||||
const open = expanded === p.id;
|
||||
const sel = selectedPreset === p.id;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpanded(open ? null : p.id);
|
||||
setSelectedPreset(p.id);
|
||||
}}
|
||||
className={`rounded-2xl border p-4 text-left transition ${
|
||||
sel ? "border-cyan-500/50 ring-1 ring-cyan-500/20" : ""
|
||||
} ${open ? "border-cyan-500/40 bg-cyan-500/10" : "border-white/[0.08] bg-zinc-950/50 hover:border-white/15"}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Monitor className="h-4 w-4 text-cyan-400" />
|
||||
{t(p.labelZh, p.labelEn)}
|
||||
</div>
|
||||
<p className="mt-2 font-mono text-[11px] text-zinc-400">{p.spec}</p>
|
||||
{open ? <p className="mt-2 text-xs text-zinc-500">{t(p.hintZh, p.hintEn)}</p> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-xl border border-white/5 bg-black/25 p-4">
|
||||
<p className="text-xs font-semibold text-zinc-400">{t("上线前检查清单(本地保存)", "Pre-flight checklist (local)")}</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{checklist.map((c) => (
|
||||
<li key={c.id} className="flex items-start gap-2 text-xs text-zinc-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 rounded border-white/20 bg-black/40"
|
||||
checked={!!checks[c.id]}
|
||||
onChange={(e) => persistChecks({ ...checks, [c.id]: e.target.checked })}
|
||||
/>
|
||||
<span>{t(c.zh, c.en)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<label className="mt-4 block text-[11px] text-zinc-500">{t("HDMI / 走线备忘", "HDMI / wiring notes")}</label>
|
||||
<textarea
|
||||
className="mt-1 min-h-[72px] w-full resize-y rounded-lg border border-white/10 bg-black/40 p-3 text-xs text-zinc-300"
|
||||
value={hdmiNotes}
|
||||
onChange={(e) => persistNotes(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={t("接口、线长、显示器型号…", "Ports, cable length, display model…")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sourceMode === "ffmpeg" ? (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">{t("直播地址列表 · URL_config.ini", "URL_config.ini")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onClick={() => onCopy(urlConfig)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("复制全文", "Copy all")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t(
|
||||
"一行一个地址;可勾选自动从服务器刷新,或点「重新读取」手动同步。",
|
||||
"One URL per line; enable auto-refresh or reload from server.",
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-zinc-400">
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-white/20 bg-black/40"
|
||||
checked={urlAutoRefresh}
|
||||
onChange={(e) => setUrlAutoRefresh(e.target.checked)}
|
||||
/>
|
||||
{t("每 8 秒自动从服务器刷新", "Auto-refresh from server every 8s")}
|
||||
</label>
|
||||
</div>
|
||||
{urlFetchNote ? <p className="mt-2 text-xs text-zinc-500">{urlFetchNote}</p> : null}
|
||||
<textarea
|
||||
className="mt-4 min-h-[200px] w-full resize-y rounded-xl border border-white/10 bg-black/50 p-4 font-mono text-xs text-zinc-300"
|
||||
value={urlConfig}
|
||||
onChange={(e) => {
|
||||
urlListDirtyRef.current = true;
|
||||
setUrlConfig(e.target.value);
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void reloadUrlConfig()}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-zinc-300"
|
||||
>
|
||||
{t("重新读取", "Reload from server")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void saveUrlAndClearDirty()}
|
||||
className="rounded-xl bg-cyan-500/20 px-4 py-2 text-sm font-medium text-cyan-100 ring-1 ring-cyan-500/30"
|
||||
>
|
||||
{t("保存 URL 配置", "Save URL config")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LiveProcessLiveLogCard
|
||||
t={t}
|
||||
currentProc={currentProc}
|
||||
fetchJson={fetchJson}
|
||||
urlListText={sourceMode === "ffmpeg" ? urlConfig : ""}
|
||||
keySuffixUi=""
|
||||
showKeyRow={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user