This commit is contained in:
eric
2026-03-29 01:54:10 -05:00
parent 784a4468b8
commit c3ee09674f
13 changed files with 4347 additions and 70 deletions

View File

@@ -550,6 +550,7 @@ install_android_panel() {
fi
python3 - <<PY
from pathlib import Path
import os
path = Path(r"$ANDROID_PANEL_DIR/app.py")
text = path.read_text(encoding="utf-8")
if "import os" not in text:
@@ -558,6 +559,35 @@ text = text.replace(
"socketio.run(app, host='0.0.0.0', port=5000)",
"socketio.run(app, host='0.0.0.0', port=int(os.environ.get('ANDROID_PANEL_PORT', '5000')), allow_unsafe_werkzeug=True)",
)
mark = "# LIVE_ANDROID_PANEL_PATCH"
if mark not in text and "app = Flask(__name__)" in text:
text = text.replace(
"app = Flask(__name__)",
"app = Flask(__name__)\n\n"
+ mark
+ "\n@app.after_request\ndef _live_allow_embed(resp):\n resp.headers.pop('X-Frame-Options', None)\n return resp\n",
1,
)
old_block = (
" if scpy_ctx is not None:\n"
" print(f'reject connection, client {scpy_ctx} is already connected')\n"
" return False\n"
" else:\n"
" client_sid = request.sid\n"
)
new_block = (
" if scpy_ctx is not None:\n"
" print('live-android-panel: replacing previous scrcpy session (embed / multi-tab)')\n"
" try:\n"
" scpy_ctx.scrcpy_stop()\n"
" except Exception as _e:\n"
" print('live-android-panel: stop previous', _e)\n"
" scpy_ctx = None\n"
" client_sid = None\n"
" client_sid = request.sid\n"
)
if old_block in text:
text = text.replace(old_block, new_block, 1)
path.write_text(text, encoding="utf-8")
PY
chown -R live:live "$ANDROID_PANEL_DIR"

View File

@@ -585,7 +585,7 @@ service_neko_firefox_status() {
kv host "${HOSTNAME_ALIAS}.local"
kv port "${NEKO_FF_HTTP_PORT:-9201}"
kv url "http://${HOSTNAME_ALIAS}.local:${NEKO_FF_HTTP_PORT:-9201}"
kv detail "Neko Firefox (第二浏览器); 口令与 Chromium 实例相同(NEKO_USER_PASS / NEKO_ADMIN_PASS"
kv detail "Neko Firefox第二桌面可选Firefox arm64/x86 多架构ENABLE_NEKO_FF=1。登录策略与 Chromium 相同:昵称任意,密码须为 NEKO_USER_PASS/NEKO_ADMIN_PASS(勿用 live/live"
}
service_neko_firefox_start() {

View File

@@ -431,8 +431,17 @@ async def android_action(action: str, serial: str, payload: Optional[dict] = Non
return {"message": f"Swiped {direction}"}
if action == "wake":
# AOSP 常亮但 Web 端 scrcpy 仍黑屏时:唤醒 + 轻扫 + Home促使 Surface 刷新
await _shell(serial, "input keyevent KEYCODE_WAKEUP")
return {"message": "Wake command sent"}
await _shell(serial, "input keyevent 224")
try:
w, h = await _get_display_size(serial)
mx, y0, y1 = max(w // 2, 1), max(int(h * 0.72), 1), max(int(h * 0.28), 1)
await _shell(serial, f"input swipe {mx} {y0} {mx} {y1} 280")
except (OSError, ValueError, RuntimeError):
await _shell(serial, "input swipe 360 1200 360 400 280")
await _shell(serial, "input keyevent KEYCODE_HOME")
return {"message": "Wake + swipe + home sent (for web mirror refresh)"}
if action == "rotate":
target = str(payload.get("rotation", "0")).strip()

View File

@@ -673,7 +673,11 @@ def stack_summary() -> dict:
"user_password": env.get("NEKO_USER_PASS", "12345678"),
"admin_login": "admin",
"admin_password": env.get("NEKO_ADMIN_PASS", "12345678"),
"note_zh": "与 WebTTY、File Browser 等一致:用户名为 live / admin口令均为 NEKO_* 环境变量(默认 12345678。Firefox 若卡在加载:请确认 NEKO_WEBRTC_NAT1TO1 为本机局域网 IP 且 UDP 端口放行。",
"note_zh": (
"Neko 为 multiuser 模式:「显示昵称」可任意填写(如 live「密码」必须与 NEKO_USER_PASS / NEKO_ADMIN_PASS "
"完全一致(默认 12345678。不要把 live/12345678 填进同一格。密码错误时会一直卡在连接中。"
" WebRTC 需 NEKO_WEBRTC_NAT1TO1 为板子局域网 IP并放行 UDP 端口段。"
),
},
"neko_firefox_enabled": neko_ff_on,
"quick_links": ql,

View 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>
);
}

View File

@@ -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>

View File

@@ -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 桌面FirefoxENABLE_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}

View 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>
);
}

View File

@@ -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">

View File

@@ -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">

View File

@@ -13,12 +13,14 @@
"lucide-react": "^1.7.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18"
"react-dom": "^18",
"three": "0.170.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/three": "0.170.0",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"postcss": "^8",
@@ -522,6 +524,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -578,6 +587,35 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.170.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.170.0.tgz",
"integrity": "sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": "*",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~0.18.1"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
@@ -1136,6 +1174,13 @@
"win32"
]
},
"node_modules/@webgpu/types": {
"version": "0.1.69",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
"integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -2638,6 +2683,13 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"dev": true,
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -3893,6 +3945,13 @@
"node": ">= 8"
}
},
"node_modules/meshoptimizer": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
"dev": true,
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -5538,6 +5597,12 @@
"node": ">=0.8"
}
},
"node_modules/three": {
"version": "0.170.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
"integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View File

@@ -14,12 +14,14 @@
"lucide-react": "^1.7.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18"
"react-dom": "^18",
"three": "0.170.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/three": "0.170.0",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"postcss": "^8",

3824
web-console/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff