This commit is contained in:
eric
2026-03-29 03:35:05 -05:00
parent 895dafc0e0
commit 22061fb3c8
14 changed files with 669 additions and 94 deletions

View File

@@ -275,6 +275,13 @@ export function LiveAndroidWorkstation({
) : 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(`状态: ${d.state}`, `State: ${d.state}`)}
</div>
) : null}
</button>
))}
</div>

View File

@@ -8,7 +8,6 @@ import {
CirclePlay,
Clock,
Cpu,
FolderOpen,
HardDrive,
Languages,
Loader2,
@@ -31,7 +30,6 @@ import { LiveStudioScene3D } from "@/components/LiveStudioScene3D";
import OverviewCharts from "@/components/OverviewCharts";
import { LiveConsoleWorkspace } from "@/components/console/LiveConsoleWorkspace";
import { LiveAndroidWorkstation } from "@/components/live-product/LiveAndroidWorkstation";
import { LiveFilebrowserPanel } from "@/components/live-product/LiveFilebrowserPanel";
import { LiveTiktokHdmiView } from "@/components/live-product/LiveTiktokHdmiView";
import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip";
import { LiveYoutubeUnmannedView } from "@/components/live-product/LiveYoutubeUnmannedView";
@@ -42,7 +40,6 @@ type PortalTheme = "dark" | "light";
type ViewKey =
| "dashboard"
| "services"
| "files"
| "live_youtube"
| "live_tiktok"
| "android"
@@ -218,7 +215,10 @@ export default function LiveControlApp() {
setDash(d);
setDashErr(null);
const devs = d.android?.devices || [];
if (devs.length && !androidSerial) setAndroidSerial(devs[0].serial);
if (devs.length && !androidSerial) {
const ready = devs.find((x) => (x.state || "").toLowerCase() === "device");
setAndroidSerial((ready ?? devs[0]).serial);
}
} catch (e) {
setDashErr((e as Error).message);
} finally {
@@ -276,7 +276,7 @@ export default function LiveControlApp() {
}, [currentProc, fetchJson]);
useEffect(() => {
if (!currentProc || (view !== "live_youtube" && view !== "live_tiktok")) return;
if (!currentProc || view !== "live_youtube") return;
void (async () => {
try {
const data = await fetchJson<{ content?: string }>(
@@ -395,7 +395,6 @@ export default function LiveControlApp() {
const navItems: { id: ViewKey; icon: typeof Activity; labelZh: string; labelEn: string }[] = [
{ id: "dashboard", icon: Activity, labelZh: "总览", labelEn: "Dashboard" },
{ id: "services", icon: Server, labelZh: "服务", labelEn: "Services" },
{ id: "files", icon: FolderOpen, labelZh: "文件File Browser", labelEn: "Files" },
{ id: "live_youtube", icon: CirclePlay, labelZh: "YouTube 无人直播", labelEn: "YouTube unmanned" },
{ id: "live_tiktok", icon: MonitorPlay, labelZh: "TikTok 无人直播", labelEn: "TikTok unmanned" },
{ id: "android", icon: Smartphone, labelZh: "安卓", labelEn: "Android" },
@@ -1047,14 +1046,6 @@ export default function LiveControlApp() {
</div>
) : null}
{view === "files" ? (
<LiveFilebrowserPanel
t={t}
portalTheme={portalTheme}
filebrowserUrl={ql.filebrowser || ""}
/>
) : null}
{view === "services" ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{(dash?.services || []).map((s, i) => (
@@ -1186,8 +1177,16 @@ export default function LiveControlApp() {
setUrlConfig={setUrlConfig}
onRunProcess={(a) => void runProcess(a)}
onSaveUrlConfig={(c) => saveUrlConfig(c)}
onReplayGoLive={async (replayUrl, label) => {
const safe = label.replace(/[,|]/g, "_");
await saveUrlConfig(`原画,${replayUrl},主播: ${safe}`);
await runProcess("start");
}}
onCopy={copyText}
fetchJson={fetchJson}
apiPrefix={base}
quickLinks={ql}
notify={notify}
onNavigate={(target: PortalNavTarget) => setView(target)}
/>
) : null}

View File

@@ -7,8 +7,7 @@ export type PortalNavTarget =
| "services"
| "live_youtube"
| "live_tiktok"
| "android"
| "files";
| "android";
type TFn = (zh: string, en: string) => string;

View File

@@ -1,7 +1,7 @@
"use client";
import { Cable, Copy, Monitor, Radio } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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";
@@ -46,6 +46,11 @@ const PRESETS: HdmiPreset[] = [
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";
export type TiktokSourceMode = "ffmpeg" | "replay" | "srs";
type ReplayFileRow = { name: string; size: number; replay_url: string };
type Props = {
t: TFn;
@@ -57,8 +62,13 @@ type Props = {
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>;
notify: (msg: string) => void;
onNavigate?: (target: PortalNavTarget) => void;
};
@@ -72,12 +82,20 @@ export function LiveTiktokHdmiView({
setUrlConfig,
onRunProcess,
onSaveUrlConfig,
onReplayGoLive,
onCopy,
fetchJson,
apiPrefix,
quickLinks,
notify,
onNavigate,
}: Props) {
const tkEntries = useMemo(() => entries.filter(isTiktokProcess), [entries]);
const processOptions = tkEntries.length ? tkEntries : entries;
const [sourceMode, setSourceMode] = useState<TiktokSourceMode>("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);
@@ -87,6 +105,27 @@ export function LiveTiktokHdmiView({
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") setSourceMode(r);
} catch {
/* ignore */
}
}, []);
useEffect(() => {
urlListDirtyRef.current = false;
}, [currentProc]);
@@ -104,13 +143,39 @@ export function LiveTiktokHdmiView({
}, [currentProc, fetchJson, setUrlConfig]);
useEffect(() => {
if (!currentProc || !urlAutoRefresh) return;
if (!currentProc || !urlAutoRefresh || sourceMode !== "ffmpeg") return;
const id = window.setInterval(() => {
if (urlListDirtyRef.current) return;
void pullUrlConfigFromServer();
}, 8000);
return () => clearInterval(id);
}, [currentProc, urlAutoRefresh, pullUrlConfigFromServer]);
}, [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 {
@@ -171,7 +236,48 @@ export function LiveTiktokHdmiView({
}
};
const lock = busy || urlReloading;
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 res = await fetch(
`${apiPrefix}/tiktok_replay/upload?process=${encodeURIComponent(currentProc)}`,
{ method: "POST", body: fd },
);
const data = (await res.json()) as { error?: string; message?: string };
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
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 checklist: { id: string; zh: string; en: string }[] = [
@@ -260,6 +366,26 @@ export function LiveTiktokHdmiView({
</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">FFmpeg / TikTok URL</option>
<option value="replay">{t("录播素材(本地上传)", "VOD upload")}</option>
<option value="srs">{t("SRSOBS 推流)", "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">
{(
[
@@ -287,6 +413,147 @@ export function LiveTiktokHdmiView({
</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" />
@@ -348,6 +615,7 @@ export function LiveTiktokHdmiView({
</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>
@@ -406,12 +674,13 @@ export function LiveTiktokHdmiView({
</button>
</div>
</div>
) : null}
<LiveProcessLiveLogCard
t={t}
currentProc={currentProc}
fetchJson={fetchJson}
urlListText={urlConfig}
urlListText={sourceMode === "ffmpeg" ? urlConfig : ""}
keySuffixUi=""
showKeyRow={false}
/>

View File

@@ -17,9 +17,9 @@ import { LivePipelineStrip } from "@/components/live-product/LivePipelineStrip";
import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard";
import {
defaultPm2NameForChannel,
fetchYoutubeProChannelsRemote,
loadYoutubeProChannels,
newChannelId,
parseYoutubeProChannelList,
pushYoutubeProChannelsRemote,
saveYoutubeProChannels,
type YoutubeProChannel,
@@ -271,8 +271,7 @@ export function LiveYoutubeUnmannedView({
let cancelled = false;
void (async () => {
try {
const data = await fetchJson<{ channels?: unknown }>("/hub/youtube_pro_channels");
const fromServer = parseYoutubeProChannelList(data.channels);
const fromServer = await fetchYoutubeProChannelsRemote(fetchJson);
if (fromServer.length && !cancelled) {
setChannels(fromServer);
saveYoutubeProChannels(fromServer);

View File

@@ -116,15 +116,13 @@ export function parseYoutubeIniFields(content: string): YoutubeIniFields {
return { key, rtmps, bitrate, fastAudio };
}
/** 与 web.py `managed_url_config_relpath` 一致(app-config 相对路径 */
/** 与 web.py `managed_url_config_relpath` 一致(每进程独立,避免 YouTube / TikTok 共写同一文件 */
export function managedUrlConfigRelPathForPm2(pm2: string): string {
if (!pm2.includes("__")) return "URL_config.ini";
return `URL_config.${pm2.replace(/[^a-zA-Z0-9_.-]/g, "_")}.ini`;
}
/** 与 web.py `managed_youtube_ini_relpath` 一致 */
export function managedYoutubeIniRelPathForPm2(pm2: string): string {
if (!pm2.includes("__")) return "youtube.ini";
return `youtube.${pm2.replace(/[^a-zA-Z0-9_.-]/g, "_")}.ini`;
}

View File

@@ -69,15 +69,42 @@ export function saveYoutubeProChannels(channels: YoutubeProChannel[]): void {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(channels));
}
const YOUTUBE_PRO_SYNC_PATHS = ["/hub/youtube_pro_channels", "/youtube_pro_channels"] as const;
/** 从服务器拉多频道列表(依次尝试 /hub 与根路径,兼容反代未转发 /hub 的情况) */
export async function fetchYoutubeProChannelsRemote(
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>,
): Promise<YoutubeProChannel[]> {
for (const path of YOUTUBE_PRO_SYNC_PATHS) {
try {
const data = await fetchJson<{ channels?: unknown }>(path);
const fromServer = parseYoutubeProChannelList(data.channels);
if (fromServer.length) return fromServer;
} catch {
/* try next */
}
}
return [];
}
export async function pushYoutubeProChannelsRemote(
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>,
channels: YoutubeProChannel[],
): Promise<void> {
await fetchJson("/hub/youtube_pro_channels", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ channels }),
});
let last: Error | null = null;
for (const path of YOUTUBE_PRO_SYNC_PATHS) {
try {
await fetchJson(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ channels }),
});
return;
} catch (e) {
last = e instanceof Error ? e : new Error(String(e));
}
}
throw last ?? new Error("youtube_pro_channels sync failed");
}
export function newChannelId(): string {

View File

@@ -28,6 +28,7 @@ const API_ROOTS = new Set([
"system_snapshot",
"android",
"hub",
"youtube_pro_channels",
]);
export function middleware(request: NextRequest) {
@@ -66,5 +67,6 @@ export const config = {
"/system_snapshot",
"/android/:path*",
"/hub/:path*",
"/youtube_pro_channels",
],
};