This commit is contained in:
eric
2026-03-29 02:14:17 -05:00
parent c3ee09674f
commit 16dc9abbbb
8 changed files with 355 additions and 36 deletions

View File

@@ -8,6 +8,7 @@ import {
CirclePlay,
Clock,
Cpu,
FolderOpen,
HardDrive,
Languages,
Loader2,
@@ -30,6 +31,7 @@ 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";
@@ -40,6 +42,7 @@ type PortalTheme = "dark" | "light";
type ViewKey =
| "dashboard"
| "services"
| "files"
| "live_youtube"
| "live_tiktok"
| "android"
@@ -400,6 +403,7 @@ 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" },
@@ -617,6 +621,32 @@ export default function LiveControlApp() {
{dashErr}
</div>
) : null}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.02 }}
className={`rounded-2xl border p-2 backdrop-blur-xl ${
portalTheme === "light"
? "border-slate-200/90 bg-white/80 shadow-sm shadow-slate-200/30"
: "border-white/[0.07] bg-zinc-950/30"
}`}
>
<OverviewCharts snapshot={dash?.snapshot ?? null} theme={portalTheme} tr={t} />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className={`rounded-2xl border p-3 shadow-lg ${
portalTheme === "light"
? "border-fuchsia-200/80 bg-gradient-to-br from-fuchsia-50/90 via-white to-cyan-50/70 shadow-fuchsia-100/40"
: "border-fuchsia-500/20 bg-gradient-to-br from-fuchsia-500/10 via-zinc-950/60 to-cyan-500/10 shadow-fuchsia-900/10"
}`}
>
<LiveStudioScene3D tr={t} />
</motion.div>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
{[
{
@@ -667,23 +697,6 @@ export default function LiveControlApp() {
))}
</div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className="rounded-2xl border border-white/[0.07] bg-zinc-950/30 p-2 backdrop-blur-xl"
>
<OverviewCharts snapshot={dash?.snapshot ?? null} theme="dark" tr={t} />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.09 }}
className="rounded-2xl border border-fuchsia-500/20 bg-gradient-to-br from-fuchsia-500/10 via-zinc-950/60 to-cyan-500/10 p-3 shadow-lg shadow-fuchsia-900/10"
>
<LiveStudioScene3D tr={t} />
</motion.div>
{dash?.hardware && typeof dash.hardware === "object" ? (
<div className="rounded-2xl border border-indigo-500/25 bg-gradient-to-br from-indigo-500/[0.1] via-zinc-950/70 to-violet-500/[0.08] p-6 shadow-2xl shadow-indigo-500/5">
<h3 className="text-base font-semibold text-white">
@@ -1074,6 +1087,14 @@ 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) => (

View File

@@ -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("内置 UIiframe", "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/loginBody 为 JSONusername / 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>
);
}

View File

@@ -2,7 +2,13 @@
import { ChevronRight } from "lucide-react";
export type PortalNavTarget = "network" | "services" | "live_youtube" | "live_tiktok" | "android";
export type PortalNavTarget =
| "network"
| "services"
| "live_youtube"
| "live_tiktok"
| "android"
| "files";
type TFn = (zh: string, en: string) => string;

View File

@@ -199,6 +199,12 @@ export function LiveTiktokHdmiView({
gradient: "from-cyan-400 to-blue-500",
target: "live_tiktok",
},
{
label: "File Browser",
sub: t("本地文件", "Local files"),
gradient: "from-amber-400 to-orange-500",
target: "files",
},
{
label: "HDMI",
sub: t("板载输出", "SoC out"),
@@ -239,6 +245,15 @@ export function LiveTiktokHdmiView({
</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*.shSRS 拉流上屏)时,会自动停止其它正在占用 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-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" />

View File

@@ -259,22 +259,6 @@ export function LiveYoutubeUnmannedView({
}
}, []);
const setModePersist = (m: "single" | "pro") => {
setMode(m);
if (m === "pro") {
const ch = channels.find((c) => c.id === activeCh) ?? channels[0];
if (ch) {
const u = (ch.urlLines ?? "").trim();
setProUrlDraft(u || urlConfig);
}
}
try {
window.localStorage.setItem(MODE_KEY, m);
} catch {
/* ignore */
}
};
useEffect(() => {
const list = loadYoutubeProChannels();
const initial =
@@ -308,6 +292,52 @@ export function LiveYoutubeUnmannedView({
const active = channels.find((c) => c.id === activeCh) || channels[0];
const setModePersist = useCallback(
(m: "single" | "pro") => {
if (m === "single" && mode === "pro" && active) {
persistChannels(channels.map((x) => (x.id === active.id ? { ...x, urlLines: proUrlDraft } : x)));
}
if (m === "pro" && channels.length) {
const first = channels[0];
const youtubePm2Ok = ytEntries.some((e) => e.pm2 === currentProc);
const pm2Name =
youtubePm2Ok && currentProc.trim()
? currentProc.trim()
: (first.pm2Name ?? "").trim() || defaultPm2NameForChannel(first.id);
const next: YoutubeProChannel[] = [
{
...first,
streamKey: ytFields.key.trim() || first.streamKey || "",
urlLines: urlConfig.trim() || first.urlLines || "",
pm2Name,
},
...channels.slice(1),
];
persistChannels(next);
const ch = next.find((c) => c.id === activeCh) ?? next[0];
setProUrlDraft((ch?.urlLines ?? "").trim() || urlConfig.trim());
}
setMode(m);
try {
window.localStorage.setItem(MODE_KEY, m);
} catch {
/* ignore */
}
},
[
mode,
active,
channels,
proUrlDraft,
ytFields.key,
urlConfig,
currentProc,
ytEntries,
activeCh,
persistChannels,
],
);
const selectProChannel = useCallback(
(c: YoutubeProChannel) => {
if (active && mode === "pro") {