's'
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
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">
|
||||
|
||||
67
web-console/package-lock.json
generated
67
web-console/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
3824
web-console/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user