diff --git a/scripts/linux/lib/common.sh b/scripts/linux/lib/common.sh index ceb6e42..64237ec 100644 --- a/scripts/linux/lib/common.sh +++ b/scripts/linux/lib/common.sh @@ -550,6 +550,7 @@ install_android_panel() { fi python3 - < 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, diff --git a/web-console/components/LiveStudioScene3D.tsx b/web-console/components/LiveStudioScene3D.tsx new file mode 100644 index 0000000..b028545 --- /dev/null +++ b/web-console/components/LiveStudioScene3D.tsx @@ -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(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 ( +
+

+ {tr("直播工作室 · 3D", "Live studio · 3D")} +

+
+
+ ); +} diff --git a/web-console/components/live-product/LiveAndroidWorkstation.tsx b/web-console/components/live-product/LiveAndroidWorkstation.tsx index 11003f7..c213e6a 100644 --- a/web-console/components/live-product/LiveAndroidWorkstation.tsx +++ b/web-console/components/live-product/LiveAndroidWorkstation.tsx @@ -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({

{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.", )}

@@ -381,9 +393,11 @@ export function LiveAndroidWorkstation({