's'
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
MousePointer2,
|
||||
Pause,
|
||||
Play,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
@@ -16,6 +17,9 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { androidScrcpyWsUrl } from "@/lib/androidScrcpyWs";
|
||||
import { ScrcpyH264Decoder, webCodecsH264Supported } from "@/lib/scrcpyH264Decoder";
|
||||
|
||||
type AndroidOverview = {
|
||||
model?: string;
|
||||
brand?: string;
|
||||
@@ -35,6 +39,41 @@ type TFn = (zh: string, en: string) => string;
|
||||
|
||||
const BOOKMARKS_KEY = "live-hub-adb-bookmarks-v1";
|
||||
|
||||
function clientPointToCanvasPixel(
|
||||
canvas: HTMLCanvasElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): { x: number; y: number } | null {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const nw = canvas.width;
|
||||
const nh = canvas.height;
|
||||
if (!nw || !nh) return null;
|
||||
const elAR = rect.width / rect.height;
|
||||
const imAR = nw / nh;
|
||||
let drawW: number;
|
||||
let drawH: number;
|
||||
let offX: number;
|
||||
let offY: number;
|
||||
if (imAR > elAR) {
|
||||
drawW = rect.width;
|
||||
drawH = rect.width / imAR;
|
||||
offX = 0;
|
||||
offY = (rect.height - drawH) / 2;
|
||||
} else {
|
||||
drawH = rect.height;
|
||||
drawW = rect.height * imAR;
|
||||
offX = (rect.width - drawW) / 2;
|
||||
offY = 0;
|
||||
}
|
||||
const lx = clientX - rect.left - offX;
|
||||
const ly = clientY - rect.top - offY;
|
||||
if (lx < 0 || ly < 0 || lx > drawW || ly > drawH) return null;
|
||||
return {
|
||||
x: Math.max(0, Math.min(nw - 1, Math.round((lx / drawW) * nw))),
|
||||
y: Math.max(0, Math.min(nh - 1, Math.round((ly / drawH) * nh))),
|
||||
};
|
||||
}
|
||||
|
||||
function clientPointToImagePixel(
|
||||
img: HTMLImageElement,
|
||||
clientX: number,
|
||||
@@ -101,6 +140,7 @@ type Props = {
|
||||
};
|
||||
|
||||
const SCRCPY_URL = "https://github.com/Genymobile/scrcpy";
|
||||
const ESCRCPY_URL = "https://github.com/viarotel-org/escrcpy";
|
||||
|
||||
export function LiveAndroidStreamConsole({
|
||||
t,
|
||||
@@ -128,8 +168,20 @@ export function LiveAndroidStreamConsole({
|
||||
const [pairCode, setPairCode] = useState("");
|
||||
const [connBusy, setConnBusy] = useState(false);
|
||||
const [bookmarks, setBookmarks] = useState<string[]>([]);
|
||||
const [scrcpyStream, setScrcpyStream] = useState(false);
|
||||
const [scrcpyConnected, setScrcpyConnected] = useState(false);
|
||||
const [scrcpyErr, setScrcpyErr] = useState<string | null>(null);
|
||||
const [scrcpyInfo, setScrcpyInfo] = useState<{
|
||||
server_jar?: boolean;
|
||||
adb_ok?: boolean;
|
||||
server_version?: string;
|
||||
hint_zh?: string;
|
||||
} | null>(null);
|
||||
const lastTapRef = useRef<{ t: number; x: number; y: number } | null>(null);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const decoderRef = useRef<ScrcpyH264Decoder | null>(null);
|
||||
const mirrorFocusRef = useRef<HTMLDivElement | null>(null);
|
||||
const shotFailRef = useRef(0);
|
||||
|
||||
@@ -194,10 +246,97 @@ export function LiveAndroidStreamConsole({
|
||||
}, [apiBase, adbAvailable, serial]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adbAvailable || !serial || liveMs <= 0) return;
|
||||
if (!adbAvailable || !serial || liveMs <= 0 || scrcpyStream) return;
|
||||
const id = window.setInterval(() => setFrameTs((n) => n + 1), liveMs);
|
||||
return () => clearInterval(id);
|
||||
}, [adbAvailable, liveMs, serial]);
|
||||
}, [adbAvailable, liveMs, serial, scrcpyStream]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adbAvailable) {
|
||||
setScrcpyInfo(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/android/scrcpy/info`);
|
||||
const data = (await res.json()) as {
|
||||
server_jar?: boolean;
|
||||
adb_ok?: boolean;
|
||||
server_version?: string;
|
||||
hint_zh?: string;
|
||||
};
|
||||
if (!cancelled && res.ok) setScrcpyInfo(data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [apiBase, adbAvailable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrcpyStream) setScrcpyErr(null);
|
||||
}, [scrcpyStream]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrcpyStream || !serial || !adbAvailable) {
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
decoderRef.current?.reset();
|
||||
decoderRef.current = null;
|
||||
setScrcpyConnected(false);
|
||||
return;
|
||||
}
|
||||
if (!webCodecsH264Supported()) {
|
||||
setScrcpyErr(t("当前浏览器不支持 WebCodecs H.264", "WebCodecs H.264 not supported in this browser"));
|
||||
setScrcpyStream(false);
|
||||
return;
|
||||
}
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const dec = new ScrcpyH264Decoder(canvas, (msg) => setScrcpyErr(msg));
|
||||
decoderRef.current = dec;
|
||||
|
||||
const wsUrl = androidScrcpyWsUrl(apiBase, serial);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = () => {
|
||||
setScrcpyConnected(true);
|
||||
setScrcpyErr(null);
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
if (typeof ev.data === "string") {
|
||||
try {
|
||||
const j = JSON.parse(ev.data) as { error?: string };
|
||||
if (j.error) setScrcpyErr(j.error);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
decoderRef.current?.push(ev.data as ArrayBuffer);
|
||||
};
|
||||
ws.onerror = () => {
|
||||
setScrcpyErr(t("WebSocket 异常", "WebSocket error"));
|
||||
};
|
||||
ws.onclose = () => {
|
||||
setScrcpyConnected(false);
|
||||
decoderRef.current?.reset();
|
||||
decoderRef.current = null;
|
||||
};
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
wsRef.current = null;
|
||||
dec.reset();
|
||||
if (decoderRef.current === dec) decoderRef.current = null;
|
||||
};
|
||||
}, [scrcpyStream, serial, adbAvailable, apiBase, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serial && adbAvailable) {
|
||||
@@ -318,10 +457,41 @@ export function LiveAndroidStreamConsole({
|
||||
e.preventDefault();
|
||||
void (async () => {
|
||||
await onAndroidAction(hit[0], hit[1]);
|
||||
bumpFrame();
|
||||
if (!scrcpyStream) bumpFrame();
|
||||
})();
|
||||
};
|
||||
|
||||
const onCanvasClick = async (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!tapMode || lock || !adbAvailable || !serial) return;
|
||||
const c = canvasRef.current;
|
||||
if (!c) return;
|
||||
const p = clientPointToCanvasPixel(c, e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
const now = Date.now();
|
||||
const prev = lastTapRef.current;
|
||||
if (prev && now - prev.t < 320 && Math.abs(prev.x - p.x) < 28 && Math.abs(prev.y - p.y) < 28) {
|
||||
lastTapRef.current = null;
|
||||
await onAndroidAction("double_tap", { x: p.x, y: p.y });
|
||||
notify(t("双击", "Double tap"));
|
||||
} else {
|
||||
lastTapRef.current = { t: now, x: p.x, y: p.y };
|
||||
await onAndroidAction("tap", { x: p.x, y: p.y });
|
||||
}
|
||||
if (!scrcpyStream) bumpFrame();
|
||||
};
|
||||
|
||||
const onCanvasContextMenu = async (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
if (lock || !adbAvailable || !serial) return;
|
||||
const c = canvasRef.current;
|
||||
if (!c) return;
|
||||
const p = clientPointToCanvasPixel(c, e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
await onAndroidAction("long_press", { x: p.x, y: p.y, duration: 720 });
|
||||
notify(t("长按", "Long press"));
|
||||
if (!scrcpyStream) bumpFrame();
|
||||
};
|
||||
|
||||
const onImgError = () => {
|
||||
setImgBusy(false);
|
||||
shotFailRef.current += 1;
|
||||
@@ -488,25 +658,40 @@ export function LiveAndroidStreamConsole({
|
||||
</h3>
|
||||
<p className="mt-1 max-w-xl text-[11px] leading-relaxed text-zinc-500">
|
||||
{t(
|
||||
"统一适配 USB 板子、Redroid 容器与无线 adb:截图轮询 + 输入注入。极致低延迟请用桌面",
|
||||
"USB boards, Redroid, wireless adb: polled screencap + input. For lowest latency use desktop ",
|
||||
"统一适配 USB / Redroid / 无线 adb:可选官方 scrcpy-server 经 WebSocket 的 H.264 真流(与桌面 ",
|
||||
"USB / Redroid / wireless adb: optional real H.264 from official scrcpy-server over WebSocket (same stack as ",
|
||||
)}
|
||||
<a href={ESCRCPY_URL} target="_blank" rel="noreferrer" className="text-emerald-300/90 underline-offset-2 hover:underline">
|
||||
escrcpy
|
||||
</a>
|
||||
{t(" / ", " / ")}
|
||||
<a href={SCRCPY_URL} target="_blank" rel="noreferrer" className="text-emerald-300/90 underline-offset-2 hover:underline">
|
||||
scrcpy
|
||||
</a>
|
||||
{t("。", ".")}
|
||||
{t(");截图轮询作备用。", "); polled PNG as fallback.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={SCRCPY_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1.5 self-start rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-[11px] font-medium text-zinc-300 hover:bg-white/[0.07]"
|
||||
>
|
||||
scrcpy
|
||||
<ExternalLink className="h-3.5 w-3.5 opacity-70" />
|
||||
</a>
|
||||
<div className="flex shrink-0 flex-col gap-1.5 self-start sm:flex-row">
|
||||
<a
|
||||
href={ESCRCPY_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-[11px] font-medium text-zinc-300 hover:bg-white/[0.07]"
|
||||
>
|
||||
escrcpy
|
||||
<ExternalLink className="h-3.5 w-3.5 opacity-70" />
|
||||
</a>
|
||||
<a
|
||||
href={SCRCPY_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-[11px] font-medium text-zinc-300 hover:bg-white/[0.07]"
|
||||
>
|
||||
scrcpy
|
||||
<ExternalLink className="h-3.5 w-3.5 opacity-70" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{devices.length > 0 ? (
|
||||
@@ -614,7 +799,41 @@ export function LiveAndroidStreamConsole({
|
||||
<MousePointer2 className="h-3 w-3" />
|
||||
{t("点按穿透", "Tap-through")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock || !serial || !scrcpyInfo?.server_jar}
|
||||
title={
|
||||
scrcpyInfo && !scrcpyInfo.server_jar && scrcpyInfo.hint_zh
|
||||
? scrcpyInfo.hint_zh
|
||||
: t("官方 scrcpy-server + WebSocket H.264", "Official scrcpy-server + WebSocket H.264")
|
||||
}
|
||||
onClick={() => {
|
||||
setScrcpyStream((v) => {
|
||||
const next = !v;
|
||||
notify(
|
||||
next
|
||||
? t("已请求 Scrcpy 真流(WebSocket)", "Scrcpy WebSocket stream starting")
|
||||
: t("已关闭 Scrcpy 真流", "Scrcpy stream stopped"),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className={`inline-flex items-center gap-1 rounded-lg border px-2.5 py-1 text-[10px] font-semibold transition ${
|
||||
scrcpyStream
|
||||
? "border-cyan-400/50 bg-cyan-500/20 text-cyan-100 ring-1 ring-cyan-400/35"
|
||||
: "border-white/10 bg-black/30 text-zinc-400 hover:text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
<Radio className="h-3 w-3" />
|
||||
{scrcpyStream ? t("关闭真流", "Stop WS") : t("Scrcpy 真流", "Scrcpy WS")}
|
||||
</button>
|
||||
</div>
|
||||
{scrcpyInfo && !scrcpyInfo.server_jar ? (
|
||||
<p className="mt-2 text-[10px] text-amber-200/80">
|
||||
{t("未检测到 scrcpy-server:请放置 vendor/scrcpy-server.jar 或设置 SCRCPY_SERVER_JAR。", "No scrcpy-server jar: add vendor/scrcpy-server.jar or SCRCPY_SERVER_JAR.")}
|
||||
{scrcpyInfo.server_version ? ` (${t("期望版本", "version")} ${scrcpyInfo.server_version})` : null}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-[10px] text-zinc-600">
|
||||
{t(
|
||||
"快捷键(镜像区聚焦时):H Home · B Back · R 多任务 · W 唤醒 · M 菜单",
|
||||
@@ -637,38 +856,56 @@ export function LiveAndroidStreamConsole({
|
||||
style={{ maxHeight: "min(72vh, 680px)" }}
|
||||
>
|
||||
<div className="absolute left-1/2 top-0 z-10 h-5 w-24 -translate-x-1/2 rounded-b-xl bg-black/80" />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={shotUrl || undefined}
|
||||
alt="device"
|
||||
className={`max-h-[min(72vh,680px)] w-auto max-w-full rounded-[1.35rem] object-contain ${
|
||||
tapMode ? "cursor-crosshair" : "cursor-default"
|
||||
}`}
|
||||
draggable={false}
|
||||
onLoadStart={() => {
|
||||
setImgBusy(true);
|
||||
}}
|
||||
onLoad={onImgLoad}
|
||||
onError={onImgError}
|
||||
onClick={onImageClick}
|
||||
onContextMenu={onImageContextMenu}
|
||||
/>
|
||||
{scrcpyStream ? (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`max-h-[min(72vh,680px)] w-auto max-w-full rounded-[1.35rem] object-contain ${
|
||||
tapMode ? "cursor-crosshair" : "cursor-default"
|
||||
}`}
|
||||
draggable={false}
|
||||
onClick={onCanvasClick}
|
||||
onContextMenu={onCanvasContextMenu}
|
||||
/>
|
||||
) : (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={shotUrl || undefined}
|
||||
alt="device"
|
||||
className={`max-h-[min(72vh,680px)] w-auto max-w-full rounded-[1.35rem] object-contain ${
|
||||
tapMode ? "cursor-crosshair" : "cursor-default"
|
||||
}`}
|
||||
draggable={false}
|
||||
onLoadStart={() => {
|
||||
setImgBusy(true);
|
||||
}}
|
||||
onLoad={onImgLoad}
|
||||
onError={onImgError}
|
||||
onClick={onImageClick}
|
||||
onContextMenu={onImageContextMenu}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{liveMs > 0 ? (
|
||||
{scrcpyConnected ? (
|
||||
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-1 rounded-full bg-cyan-500/90 px-2 py-0.5 text-[9px] font-bold text-cyan-950 shadow-lg">
|
||||
<Radio className="h-3 w-3" />
|
||||
SCRCPY·WS
|
||||
</div>
|
||||
) : liveMs > 0 && !scrcpyStream ? (
|
||||
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-1 rounded-full bg-emerald-500/90 px-2 py-0.5 text-[9px] font-bold text-emerald-950 shadow-lg">
|
||||
<Play className="h-3 w-3" />
|
||||
LIVE
|
||||
</div>
|
||||
) : (
|
||||
) : !scrcpyStream ? (
|
||||
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-1 rounded-full bg-zinc-700/90 px-2 py-0.5 text-[9px] font-bold text-zinc-200">
|
||||
<Pause className="h-3 w-3" />
|
||||
{t("单帧", "Still")}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{imgErr ? <p className="mt-3 text-center text-xs text-rose-300">{imgErr}</p> : null}
|
||||
{scrcpyErr ? <p className="mt-3 text-center text-xs text-rose-300">{scrcpyErr}</p> : null}
|
||||
{imgErr && !scrcpyStream ? <p className="mt-3 text-center text-xs text-rose-300">{imgErr}</p> : null}
|
||||
<p className="mt-3 text-center text-[10px] text-zinc-600">
|
||||
{t("左键单击 / 双击 · 右键长按 · 坐标对齐 PNG 像素", "Left tap/double · right long-press · PNG pixel coords")}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user