's'
This commit is contained in:
176
web-console/components/LiveStudioScene3D.tsx
Normal file
176
web-console/components/LiveStudioScene3D.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type Tr = (zh: string, en: string) => string;
|
||||
|
||||
export function LiveStudioScene3D({ tr }: { tr: Tr }) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = mountRef.current;
|
||||
if (!el) return;
|
||||
|
||||
let disposed = false;
|
||||
let animationId = 0;
|
||||
let teardown: (() => void) | undefined;
|
||||
|
||||
void import("three").then((THREE) => {
|
||||
if (disposed || !mountRef.current) return;
|
||||
|
||||
const mount = mountRef.current;
|
||||
const width = Math.max(mount.clientWidth, 280);
|
||||
const height = Math.max(mount.clientHeight, 200);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(48, width / height, 0.1, 80);
|
||||
camera.position.set(0, 1.1, 7.2);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(typeof window !== "undefined" ? window.devicePixelRatio : 1, 2));
|
||||
renderer.setSize(width, height);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
mount.appendChild(renderer.domElement);
|
||||
|
||||
const hemi = new THREE.HemisphereLight(0xffc4ec, 0x4466ff, 0.85);
|
||||
scene.add(hemi);
|
||||
const dir = new THREE.DirectionalLight(0xffffff, 0.9);
|
||||
dir.position.set(4, 8, 6);
|
||||
scene.add(dir);
|
||||
const back = new THREE.PointLight(0x7cf5ff, 0.6, 20);
|
||||
back.position.set(-3, 2, -2);
|
||||
scene.add(back);
|
||||
|
||||
const group = new THREE.Group();
|
||||
|
||||
const studio = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(3.2, 0.35, 2.2),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x2a2540,
|
||||
metalness: 0.15,
|
||||
roughness: 0.55,
|
||||
}),
|
||||
);
|
||||
studio.position.y = -0.85;
|
||||
group.add(studio);
|
||||
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(0.72, 0.09, 12, 48),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xff3355,
|
||||
emissive: 0x440011,
|
||||
metalness: 0.35,
|
||||
roughness: 0.25,
|
||||
}),
|
||||
);
|
||||
ring.rotation.x = Math.PI / 2;
|
||||
ring.position.set(-0.85, 0.15, 0.2);
|
||||
group.add(ring);
|
||||
|
||||
const play = new THREE.Mesh(
|
||||
new THREE.ConeGeometry(0.35, 0.5, 3),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xffffff,
|
||||
emissive: 0x222222,
|
||||
metalness: 0.2,
|
||||
roughness: 0.3,
|
||||
}),
|
||||
);
|
||||
play.rotation.z = -Math.PI / 2;
|
||||
play.rotation.y = Math.PI;
|
||||
play.position.set(-0.55, 0.18, 0.2);
|
||||
group.add(play);
|
||||
|
||||
const tk = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.22, 0.28, 0.95, 20),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x25f4ee,
|
||||
emissive: 0x053038,
|
||||
metalness: 0.25,
|
||||
roughness: 0.35,
|
||||
}),
|
||||
);
|
||||
tk.position.set(1.05, 0.35, -0.15);
|
||||
tk.rotation.z = 0.12;
|
||||
group.add(tk);
|
||||
|
||||
const tkCap = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.26, 16, 16),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xfe2c55,
|
||||
emissive: 0x220510,
|
||||
metalness: 0.2,
|
||||
roughness: 0.4,
|
||||
}),
|
||||
);
|
||||
tkCap.position.set(1.05, 0.92, -0.15);
|
||||
group.add(tkCap);
|
||||
|
||||
const starGeo = new THREE.BufferGeometry();
|
||||
const starCount = 120;
|
||||
const positions = new Float32Array(starCount * 3);
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
positions[i * 3] = (Math.random() - 0.5) * 14;
|
||||
positions[i * 3 + 1] = (Math.random() - 0.3) * 8;
|
||||
positions[i * 3 + 2] = (Math.random() - 0.5) * 8 - 2;
|
||||
}
|
||||
starGeo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
const stars = new THREE.Points(
|
||||
starGeo,
|
||||
new THREE.PointsMaterial({ color: 0xffffff, size: 0.06, transparent: true, opacity: 0.7 }),
|
||||
);
|
||||
scene.add(stars);
|
||||
|
||||
scene.add(group);
|
||||
|
||||
const t0 = performance.now();
|
||||
const tick = () => {
|
||||
if (disposed) return;
|
||||
animationId = requestAnimationFrame(tick);
|
||||
const t = (performance.now() - t0) / 1000;
|
||||
group.rotation.y = Math.sin(t * 0.35) * 0.25 + t * 0.12;
|
||||
group.position.y = Math.sin(t * 0.9) * 0.06;
|
||||
ring.rotation.z = t * 0.4;
|
||||
stars.rotation.y = t * 0.02;
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
tick();
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!mountRef.current || disposed) return;
|
||||
const w = Math.max(mountRef.current.clientWidth, 280);
|
||||
const h = Math.max(mountRef.current.clientHeight, 200);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
});
|
||||
ro.observe(mount);
|
||||
|
||||
teardown = () => {
|
||||
cancelAnimationFrame(animationId);
|
||||
ro.disconnect();
|
||||
renderer.dispose();
|
||||
starGeo.dispose();
|
||||
if (renderer.domElement.parentNode === mount) {
|
||||
mount.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cancelAnimationFrame(animationId);
|
||||
teardown?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[14rem] flex-col">
|
||||
<p className="mb-2 px-1 text-[10px] font-medium uppercase tracking-wider text-fuchsia-200/80">
|
||||
{tr("直播工作室 · 3D", "Live studio · 3D")}
|
||||
</p>
|
||||
<div ref={mountRef} className="relative min-h-[12rem] flex-1 overflow-hidden rounded-xl bg-gradient-to-b from-fuchsia-950/40 to-cyan-950/20 ring-1 ring-white/10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Copy, ExternalLink, Layers, Loader2, Package, RefreshCw, Terminal } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
type DeviceRow = { serial: string; model?: string; state: string };
|
||||
type TFn = (zh: string, en: string) => string;
|
||||
@@ -95,6 +95,18 @@ export function LiveAndroidWorkstation({
|
||||
|
||||
const lock = !!(busy || pending);
|
||||
|
||||
const hostMirrorSrc = useMemo(() => {
|
||||
if (!scrcpyUrl) return "";
|
||||
try {
|
||||
const u = new URL(scrcpyUrl);
|
||||
u.searchParams.set("_lh", String(mirrorReloadKey));
|
||||
return u.toString();
|
||||
} catch {
|
||||
const sep = scrcpyUrl.includes("?") ? "&" : "?";
|
||||
return `${scrcpyUrl}${sep}_lh=${mirrorReloadKey}`;
|
||||
}
|
||||
}, [scrcpyUrl, mirrorReloadKey]);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
if (!androidSerial || !adbAvailable) {
|
||||
setOverview(null);
|
||||
@@ -340,8 +352,8 @@ export function LiveAndroidWorkstation({
|
||||
<div className="space-y-2 border-b border-white/5 px-4 py-3">
|
||||
<p className="text-[11px] leading-relaxed text-zinc-400">
|
||||
{t(
|
||||
"板载 USB:投屏协议来自开源 scrcpy(见上方仓库链接);浏览器内常用 ws-scrcpy。黑屏时先 Wake,再点「重载投屏」或新窗口打开面板。",
|
||||
"Board USB: scrcpy protocol; browser uses ws-scrcpy. Black screen: Wake, reload mirror, or open panel in new tab.",
|
||||
"板载 ws-scrcpy 同时只允许一个浏览器会话:多标签/总览与安卓页各嵌一个 iframe 会互相抢连接。请只保留一个投屏页,或反复点「重载投屏」。已打补丁的安装会踢掉旧连接以便嵌入。黑屏请先 Wake(会连发唤醒+滑动+Home)。",
|
||||
"Only one ws-scrcpy browser session: multiple embedded iframes fight for the stream—keep one tab or use Reload. Patched installs replace the old session. Black screen: try Wake (wake+swipe+home), then Reload or New tab.",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -381,9 +393,11 @@ export function LiveAndroidWorkstation({
|
||||
<iframe
|
||||
key={`ws-scrcpy-${mirrorReloadKey}`}
|
||||
title="ws-scrcpy"
|
||||
src={scrcpyUrl}
|
||||
src={hostMirrorSrc}
|
||||
className="h-[min(72vh,640px)] w-full bg-black"
|
||||
allow="fullscreen; autoplay; clipboard-read; clipboard-write"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
loading="eager"
|
||||
/>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-sm text-zinc-500">{t("未配置 ws-scrcpy 地址", "ws-scrcpy URL missing")}</p>
|
||||
|
||||
@@ -26,10 +26,12 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { LiveStudioScene3D } from "@/components/LiveStudioScene3D";
|
||||
import OverviewCharts from "@/components/OverviewCharts";
|
||||
import { LiveConsoleWorkspace } from "@/components/console/LiveConsoleWorkspace";
|
||||
import { LiveAndroidWorkstation } from "@/components/live-product/LiveAndroidWorkstation";
|
||||
import { LiveTiktokHdmiView } from "@/components/live-product/LiveTiktokHdmiView";
|
||||
import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip";
|
||||
import { LiveYoutubeUnmannedView } from "@/components/live-product/LiveYoutubeUnmannedView";
|
||||
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
|
||||
|
||||
@@ -665,6 +667,23 @@ 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">
|
||||
@@ -797,6 +816,12 @@ export default function LiveControlApp() {
|
||||
{dash.stack.neko_login.note_zh ||
|
||||
t("Chromium :9200 与 Firefox :9201(若启用)共用下列账号策略。", "Chromium :9200 and Firefox :9201 share the same policy.")}
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] text-amber-200/85">
|
||||
{t(
|
||||
"重要:两栏分开填——昵称随意,密码必须是环境变量里的口令(默认 12345678)。用错密码会像一直加载;勿把「用户名/密码」写在一行。",
|
||||
"Use two fields: display name is free-form; password must match NEKO_* (default 12345678). Wrong password looks like infinite loading.",
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 font-mono text-sm">
|
||||
<div className="rounded-xl border border-white/10 bg-black/40 px-4 py-3">
|
||||
<p className="text-[10px] uppercase text-zinc-500">{t("用户", "User")}</p>
|
||||
@@ -880,6 +905,55 @@ export default function LiveControlApp() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-2xl border border-violet-500/20 bg-gradient-to-br from-violet-500/[0.12] via-zinc-950/50 to-fuchsia-500/[0.06] p-5 shadow-lg shadow-violet-500/5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">{t("直播进程", "Live processes")}</h3>
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-medium text-zinc-300">
|
||||
{dash?.live?.backend_mode || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-4 space-y-2.5">
|
||||
{(dash?.live?.processes || []).slice(0, 10).map((p) => (
|
||||
<li
|
||||
key={p.pm2}
|
||||
className="group flex items-center gap-3 rounded-xl border border-white/[0.08] bg-black/35 px-3 py-2.5 transition hover:border-violet-400/25 hover:bg-black/45"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-[10px] font-bold text-violet-100 ring-1 ring-white/10">
|
||||
LIVE
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-zinc-100">{p.label || p.pm2}</p>
|
||||
<p className="truncate font-mono text-[10px] text-zinc-600">{p.script || p.pm2}</p>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded-lg px-2.5 py-1 text-[10px] font-semibold ${statusPill(p.process_status || "")}`}>
|
||||
{p.process_status || "—"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{!dash?.live?.processes?.length ? (
|
||||
<li className="rounded-xl border border-dashed border-white/10 py-8 text-center text-xs text-zinc-600">
|
||||
{t("暂无直播进程", "No live processes")}
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
<div className="mt-5 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("live_youtube")}
|
||||
className="rounded-xl border border-violet-400/35 bg-violet-500/15 py-2.5 text-[11px] font-semibold text-violet-100 shadow-inner shadow-black/20 transition hover:bg-violet-500/25"
|
||||
>
|
||||
YouTube
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("live_tiktok")}
|
||||
className="rounded-xl border border-cyan-400/35 bg-cyan-500/15 py-2.5 text-[11px] font-semibold text-cyan-100 shadow-inner shadow-black/20 transition hover:bg-cyan-500/25"
|
||||
>
|
||||
TikTok
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dash?.probes && showStreamProbes ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{dash.probes.srs ? (
|
||||
@@ -997,67 +1071,6 @@ export default function LiveControlApp() {
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="xl:col-span-2 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>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-violet-500/20 bg-gradient-to-br from-violet-500/[0.12] via-zinc-950/50 to-fuchsia-500/[0.06] p-5 shadow-lg shadow-violet-500/5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">{t("直播进程", "Live processes")}</h3>
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-medium text-zinc-300">
|
||||
{dash?.live?.backend_mode || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-4 space-y-2.5">
|
||||
{(dash?.live?.processes || []).slice(0, 10).map((p) => (
|
||||
<li
|
||||
key={p.pm2}
|
||||
className="group flex items-center gap-3 rounded-xl border border-white/[0.08] bg-black/35 px-3 py-2.5 transition hover:border-violet-400/25 hover:bg-black/45"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-[10px] font-bold text-violet-100 ring-1 ring-white/10">
|
||||
LIVE
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-zinc-100">{p.label || p.pm2}</p>
|
||||
<p className="truncate font-mono text-[10px] text-zinc-600">{p.script || p.pm2}</p>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded-lg px-2.5 py-1 text-[10px] font-semibold ${statusPill(p.process_status || "")}`}>
|
||||
{p.process_status || "—"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{!dash?.live?.processes?.length ? (
|
||||
<li className="rounded-xl border border-dashed border-white/10 py-8 text-center text-xs text-zinc-600">
|
||||
{t("暂无直播进程", "No live processes")}
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
<div className="mt-5 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("live_youtube")}
|
||||
className="rounded-xl border border-violet-400/35 bg-violet-500/15 py-2.5 text-[11px] font-semibold text-violet-100 shadow-inner shadow-black/20 transition hover:bg-violet-500/25"
|
||||
>
|
||||
YouTube
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("live_tiktok")}
|
||||
className="rounded-xl border border-cyan-400/35 bg-cyan-500/15 py-2.5 text-[11px] font-semibold text-cyan-100 shadow-inner shadow-black/20 transition hover:bg-cyan-500/25"
|
||||
>
|
||||
TikTok
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1098,6 +1111,14 @@ export default function LiveControlApp() {
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{s.service_id === "neko_firefox" ? (
|
||||
<p className="mt-2 text-[11px] leading-snug text-fuchsia-200/80">
|
||||
{t(
|
||||
"第二套 Neko 桌面(Firefox);ENABLE_NEKO_FF=1。登录:昵称任意,密码 = NEKO_USER_PASS / NEKO_ADMIN_PASS(与 Chromium 相同,默认 12345678)。",
|
||||
"Second Neko desktop (Firefox). Login: any display name; password = NEKO_USER_PASS / NEKO_ADMIN_PASS (same as Chromium).",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{s.url ? (
|
||||
<a
|
||||
@@ -1177,6 +1198,7 @@ export default function LiveControlApp() {
|
||||
fetchJson={fetchJson}
|
||||
liveProcesses={dash?.live?.processes ?? []}
|
||||
notify={notify}
|
||||
onNavigate={(target: PortalNavTarget) => setView(target)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1193,6 +1215,7 @@ export default function LiveControlApp() {
|
||||
onSaveUrlConfig={(c) => saveUrlConfig(c)}
|
||||
onCopy={copyText}
|
||||
fetchJson={fetchJson}
|
||||
onNavigate={(target: PortalNavTarget) => setView(target)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
56
web-console/components/live-product/LivePipelineStrip.tsx
Normal file
56
web-console/components/live-product/LivePipelineStrip.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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.target && onNavigate ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import { Cable, Copy, Monitor, Radio } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip";
|
||||
import { LivePipelineStrip } from "@/components/live-product/LivePipelineStrip";
|
||||
import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard";
|
||||
|
||||
type ProcessEntry = { pm2: string; script: string; label: string };
|
||||
@@ -57,6 +59,7 @@ type Props = {
|
||||
onSaveUrlConfig: (content?: string) => void | Promise<void>;
|
||||
onCopy: (text: string) => void;
|
||||
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||||
onNavigate?: (target: PortalNavTarget) => void;
|
||||
};
|
||||
|
||||
export function LiveTiktokHdmiView({
|
||||
@@ -71,6 +74,7 @@ export function LiveTiktokHdmiView({
|
||||
onSaveUrlConfig,
|
||||
onCopy,
|
||||
fetchJson,
|
||||
onNavigate,
|
||||
}: Props) {
|
||||
const tkEntries = useMemo(() => entries.filter(isTiktokProcess), [entries]);
|
||||
const processOptions = tkEntries.length ? tkEntries : entries;
|
||||
@@ -179,6 +183,41 @@ export function LiveTiktokHdmiView({
|
||||
|
||||
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(
|
||||
"从板端编码到采集卡再到安卓;与「网络」页顶卡相同的箭头风格。",
|
||||
"Encode → HDMI → capture → Android; 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: t("采集卡", "Capture card"),
|
||||
sub: "USB / PCIe",
|
||||
gradient: "from-emerald-400 to-teal-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">
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
parseYoutubeStreamKeySuffix,
|
||||
type YoutubeIniFields,
|
||||
} from "@/lib/youtubeConfigUtils";
|
||||
import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip";
|
||||
import { LivePipelineStrip } from "@/components/live-product/LivePipelineStrip";
|
||||
import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard";
|
||||
import {
|
||||
defaultPm2NameForChannel,
|
||||
@@ -143,6 +145,7 @@ type Props = {
|
||||
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||||
liveProcesses: LiveProcRow[];
|
||||
notify: (msg: string) => void;
|
||||
onNavigate?: (target: PortalNavTarget) => void;
|
||||
};
|
||||
|
||||
export function LiveYoutubeUnmannedView({
|
||||
@@ -158,6 +161,7 @@ export function LiveYoutubeUnmannedView({
|
||||
fetchJson,
|
||||
liveProcesses,
|
||||
notify,
|
||||
onNavigate,
|
||||
}: Props) {
|
||||
const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]);
|
||||
const processOptions = ytEntries.length ? ytEntries : entries;
|
||||
@@ -549,6 +553,37 @@ export function LiveYoutubeUnmannedView({
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
{onNavigate ? (
|
||||
<LivePipelineStrip
|
||||
t={t}
|
||||
title={t("YouTube 转播链路", "YouTube relay chain")}
|
||||
subtitle={t(
|
||||
"与「网络」页相同风格的链路示意:代理 → 编码推流 → 浏览器侧操作工作室。",
|
||||
"Same visual language as Network: proxy → encode → browser studio.",
|
||||
)}
|
||||
onNavigate={onNavigate}
|
||||
steps={[
|
||||
{
|
||||
label: "ShellCrash",
|
||||
sub: t("透明代理", "Proxy"),
|
||||
gradient: "from-violet-500 to-fuchsia-500",
|
||||
target: "network",
|
||||
},
|
||||
{
|
||||
label: "FFmpeg",
|
||||
sub: t("推流 / 转码", "Stream"),
|
||||
gradient: "from-cyan-400 to-blue-500",
|
||||
target: "live_youtube",
|
||||
},
|
||||
{
|
||||
label: "Neko",
|
||||
sub: t("Chromium 工作室", "Chromium"),
|
||||
gradient: "from-emerald-400 to-teal-500",
|
||||
target: "services",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
<div className="rounded-2xl border border-violet-500/25 bg-gradient-to-br from-violet-500/[0.12] via-zinc-950/80 to-cyan-500/5 p-6 shadow-xl shadow-violet-900/10">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
Reference in New Issue
Block a user