Files
gitlab-instance-0a899031_do…/web-console/components/live-product/LiveAndroidWorkstation.tsx
2026-03-28 15:21:30 -05:00

761 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
ArrowUpRight,
ChevronDown,
ChevronUp,
Copy,
Layers,
Loader2,
Package,
Terminal,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
type DeviceRow = { serial: string; model?: string; state: string };
type TFn = (zh: string, en: string) => string;
type AndroidOverview = {
serial?: string;
model?: string;
brand?: string;
android_version?: string;
sdk?: string;
battery?: string;
focus?: string;
resolution?: string;
rotation?: string;
screen_state?: string;
};
type PkgRow = { package: string; label?: string };
type UiNode = {
label: string;
text?: string;
content_desc?: string;
resource_id?: string;
center_x: number;
center_y: number;
clickable?: boolean;
enabled?: boolean;
};
const SHELL_SNIPPETS: { label: string; cmd: string }[] = [
{ label: "getprop", cmd: "getprop ro.build.version.release" },
{ label: "top pkg", cmd: "dumpsys activity activities | grep -E 'mResumedActivity' | tail -n 1" },
{ label: "sdcard", cmd: "ls -la /sdcard | head -n 24" },
{ label: "net", cmd: "dumpsys connectivity | head -n 40" },
];
type Props = {
t: TFn;
notify: (msg: string) => void;
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
/** Parent busy (e.g. adb actions); combined with local pending for UI lock. */
busy: string | null;
androidSerial: string;
setAndroidSerial: (s: string) => void;
devices: DeviceRow[];
adbAvailable: boolean;
scrcpyUrl: string;
androidShotUrl: string | null;
androidShotErr: string | null;
apkPath: string;
setApkPath: (v: string) => void;
onAndroidAction: (action: string, payload?: Record<string, unknown>) => Promise<void>;
onCopy: (text: string) => void;
};
export function LiveAndroidWorkstation({
t,
notify,
fetchJson,
busy,
androidSerial,
setAndroidSerial,
devices,
adbAvailable,
scrcpyUrl,
androidShotUrl,
androidShotErr,
apkPath,
setApkPath,
onAndroidAction,
onCopy,
}: Props) {
const [overview, setOverview] = useState<AndroidOverview | null>(null);
const [overviewErr, setOverviewErr] = useState<string | null>(null);
const [pkgQuery, setPkgQuery] = useState("");
const [pkgList, setPkgList] = useState<PkgRow[]>([]);
const [shellCmd, setShellCmd] = useState("getprop ro.product.model");
const [shellOut, setShellOut] = useState("");
const [logcatLines, setLogcatLines] = useState("300");
const [logcatText, setLogcatText] = useState("");
const [logcatErr, setLogcatErr] = useState<string | null>(null);
const [uiNodes, setUiNodes] = useState<UiNode[]>([]);
const [uiErr, setUiErr] = useState<string | null>(null);
const [openUrl, setOpenUrl] = useState("https://");
const [adbInputText, setAdbInputText] = useState("");
const [embedScrcpy, setEmbedScrcpy] = useState(false);
const [pending, setPending] = useState<string | null>(null);
const lock = !!(busy || pending);
const loadOverview = useCallback(async () => {
if (!androidSerial || !adbAvailable) {
setOverview(null);
setOverviewErr(null);
return;
}
setPending("overview");
setOverviewErr(null);
try {
const o = await fetchJson<AndroidOverview>(
`/android/overview?serial=${encodeURIComponent(androidSerial)}`,
);
setOverview(o);
} catch (e) {
setOverview(null);
setOverviewErr((e as Error).message);
} finally {
setPending(null);
}
}, [adbAvailable, androidSerial, fetchJson]);
useEffect(() => {
void loadOverview();
}, [loadOverview]);
const searchPackages = async () => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
setPending("packages");
try {
const q = encodeURIComponent(pkgQuery.trim());
const data = await fetchJson<{ packages: PkgRow[] }>(
`/android/packages?serial=${encodeURIComponent(androidSerial)}&query=${q}&limit=80`,
);
setPkgList(data.packages || []);
} catch (e) {
notify((e as Error).message);
setPkgList([]);
} finally {
setPending(null);
}
};
const runShell = async () => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
const cmd = shellCmd.trim();
if (!cmd) return;
setPending("shell");
try {
const data = await fetchJson<{ output?: string; message?: string }>("/android/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "shell", serial: androidSerial, payload: { command: cmd } }),
});
setShellOut(data.output ?? data.message ?? "");
} catch (e) {
setShellOut((e as Error).message);
} finally {
setPending(null);
}
};
const refreshLogcat = async () => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
const n = Math.min(3000, Math.max(1, parseInt(logcatLines, 10) || 200));
setPending("logcat");
setLogcatErr(null);
try {
const data = await fetchJson<{ text?: string }>(
`/android/logcat?serial=${encodeURIComponent(androidSerial)}&lines=${n}`,
);
setLogcatText(data.text ?? "");
} catch (e) {
setLogcatText("");
setLogcatErr((e as Error).message);
} finally {
setPending(null);
}
};
const loadUiNodes = async () => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
setPending("ui");
setUiErr(null);
try {
const data = await fetchJson<{ nodes: UiNode[] }>(
`/android/ui?serial=${encodeURIComponent(androidSerial)}&limit=100`,
);
setUiNodes(data.nodes || []);
} catch (e) {
setUiNodes([]);
setUiErr((e as Error).message);
} finally {
setPending(null);
}
};
const tapNode = async (x: number, y: number) => {
await onAndroidAction("tap", { x, y });
};
const sendKey = async (keycode: string) => {
await onAndroidAction("key", { keycode });
};
const openUrlGo = async () => {
const u = openUrl.trim();
if (!u || u === "https://") {
notify(t("填写 URL", "Enter URL"));
return;
}
await onAndroidAction("open_url", { url: u });
};
const sendAdbText = async () => {
const raw = adbInputText.trim();
if (!raw) {
notify(t("填写要输入的文字", "Enter text"));
return;
}
await onAndroidAction("text", { text: raw });
};
return (
<div className="mx-auto max-w-5xl space-y-6">
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-white">{t("设备与工作台", "Device workstation")}</h3>
{!adbAvailable ? (
<span className="text-xs text-amber-200/90">{t("宿主机未检测到 adb", "adb not found on host")}</span>
) : (
<button
type="button"
disabled={lock || !androidSerial}
onClick={() => void loadOverview()}
className="rounded-lg border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs text-zinc-300 hover:bg-white/[0.08]"
>
{t("刷新设备信息", "Refresh device info")}
</button>
)}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{devices.map((d) => (
<button
key={d.serial}
type="button"
onClick={() => setAndroidSerial(d.serial)}
className={`rounded-xl border px-4 py-2 text-left text-xs ${
androidSerial === d.serial
? "border-violet-500/50 bg-violet-500/10 text-white"
: "border-white/10 bg-black/30 text-zinc-400"
}`}
>
<div className="font-medium text-zinc-200">{d.model || d.serial}</div>
<div className="text-[10px] text-zinc-600">{d.serial}</div>
</button>
))}
</div>
{adbAvailable && devices.length === 0 ? (
<p className="mt-4 rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 text-xs text-amber-200/90">
{t(
"未检测到已授权设备:请 USB 连接并开启「USB 调试」,手机上允许本计算机调试。",
"No authorized device: enable USB debugging and authorize this computer.",
)}
</p>
) : null}
</div>
{adbAvailable && androidSerial ? (
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-violet-500/15 bg-zinc-950/50 p-5">
<h4 className="text-xs font-semibold uppercase tracking-wider text-violet-300/90">
{t("设备信息", "Device info")}
</h4>
{overviewErr ? <p className="mt-2 text-xs text-rose-300">{overviewErr}</p> : null}
{overview ? (
<dl className="mt-3 space-y-1.5 font-mono text-[11px] text-zinc-400">
<div>
<dt className="inline text-zinc-600">model</dt>{" "}
<dd className="inline text-zinc-300">
{overview.brand} {overview.model}
</dd>
</div>
<div>
<dt className="inline text-zinc-600">Android</dt>{" "}
<dd className="inline text-zinc-300">
{overview.android_version} (sdk {overview.sdk})
</dd>
</div>
<div>
<dt className="inline text-zinc-600">display</dt>{" "}
<dd className="inline text-zinc-300">{overview.resolution}</dd>
</div>
<div>
<dt className="inline text-zinc-600">rotation</dt>{" "}
<dd className="inline text-zinc-300">{overview.rotation}</dd>
</div>
<div className="whitespace-pre-wrap text-[10px] leading-snug text-zinc-500">
{overview.battery}
</div>
<div className="line-clamp-3 text-[10px] text-zinc-500">{overview.focus}</div>
</dl>
) : !overviewErr ? (
<p className="mt-2 text-xs text-zinc-500">{t("加载中…", "Loading…")}</p>
) : null}
</div>
<div className="rounded-2xl border border-cyan-500/15 bg-zinc-950/50 p-5">
<h4 className="text-xs font-semibold uppercase tracking-wider text-cyan-300/90">
{t("快捷控制", "Quick controls")}
</h4>
<p className="mt-2 text-[11px] text-zinc-500">
{t("按键与常用应用;深层调试用下方 Shell / Logcat。", "Keys & apps; use Shell / Logcat for deep debug.")}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={lock}
onClick={() => void sendKey("3")}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
>
Home
</button>
<button
type="button"
disabled={lock}
onClick={() => void sendKey("4")}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
>
Back
</button>
<button
type="button"
disabled={lock}
onClick={() => void sendKey("187")}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
>
Recents
</button>
<button
type="button"
disabled={lock}
onClick={() => void onAndroidAction("wake")}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
>
Wake
</button>
<button
type="button"
disabled={lock}
onClick={() => void onAndroidAction("launch_package", { package: "com.zhiliaoapp.musically" })}
className="rounded-lg bg-gradient-to-r from-pink-500/25 to-violet-500/25 px-3 py-1.5 text-xs ring-1 ring-white/10"
>
TikTok
</button>
<button
type="button"
disabled={lock}
onClick={() => void sendKey("24")}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
>
Vol+
</button>
<button
type="button"
disabled={lock}
onClick={() => void sendKey("25")}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
>
Vol
</button>
<button
type="button"
disabled={lock}
onClick={() => void sendKey("66")}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
>
Enter
</button>
</div>
<p className="mt-3 text-[10px] text-zinc-600">{t("滑动手势(中心区域)", "Swipe (center)")}</p>
<div className="mt-1 flex flex-wrap gap-2">
{(["up", "down", "left", "right"] as const).map((dir) => (
<button
key={dir}
type="button"
disabled={lock}
onClick={() => void onAndroidAction("swipe", { direction: dir })}
className="rounded-lg border border-white/10 bg-black/30 px-2.5 py-1 font-mono text-[10px] uppercase text-zinc-300"
>
{dir}
</button>
))}
</div>
<div className="mt-4 rounded-lg border border-white/5 bg-black/20 p-3">
<p className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
{t("输入文字adb input text", "Type text (adb)")}
</p>
<p className="mt-1 text-[10px] text-zinc-600">
{t("仅适合英文与数字;中文需剪贴板或无障碍方案。", "ASCII-focused; CJK needs clipboard.")}
</p>
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
<input
className="min-w-0 flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-xs"
value={adbInputText}
onChange={(e) => setAdbInputText(e.target.value)}
placeholder="hello / 123"
onKeyDown={(e) => {
if (e.key === "Enter") void sendAdbText();
}}
/>
<button
type="button"
disabled={lock}
onClick={() => void sendAdbText()}
className="rounded-lg bg-emerald-500/20 px-3 py-2 text-xs text-emerald-100 ring-1 ring-emerald-500/30"
>
{t("发送", "Send")}
</button>
</div>
</div>
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<input
className="min-w-0 flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-xs"
value={openUrl}
onChange={(e) => setOpenUrl(e.target.value)}
placeholder="https://"
/>
<button
type="button"
disabled={lock}
onClick={() => void openUrlGo()}
className="rounded-lg bg-violet-500/20 px-3 py-2 text-xs text-violet-100 ring-1 ring-violet-500/30"
>
{t("打开链接", "Open URL")}
</button>
</div>
</div>
</div>
) : null}
{adbAvailable && androidSerial ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Package className="h-4 w-4 text-emerald-400" />
{t("包名检索与启动", "Packages")}
</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("过滤 pm list packages行内可启动或强停。", "Filter pm list packages; launch or force-stop.")}
</p>
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
<input
className="min-w-0 flex-1 rounded-xl border border-white/10 bg-black/40 px-4 py-2 text-sm"
value={pkgQuery}
onChange={(e) => setPkgQuery(e.target.value)}
placeholder="tiktok / musically / …"
onKeyDown={(e) => {
if (e.key === "Enter") void searchPackages();
}}
/>
<button
type="button"
disabled={lock}
onClick={() => void searchPackages()}
className="rounded-xl bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 ring-1 ring-emerald-500/30"
>
{t("搜索", "Search")}
</button>
</div>
<ul className="mt-4 max-h-48 space-y-1 overflow-y-auto rounded-lg border border-white/5 bg-black/30 p-2 font-mono text-[11px]">
{pkgList.map((p) => (
<li key={p.package} className="flex flex-wrap items-center justify-between gap-2 py-1 text-zinc-400">
<span className="min-w-0 truncate text-zinc-300">{p.package}</span>
<span className="flex shrink-0 gap-1">
<button
type="button"
disabled={lock}
onClick={() => void onAndroidAction("launch_package", { package: p.package })}
className="rounded bg-white/10 px-2 py-0.5 text-[10px] text-zinc-200"
>
{t("启动", "Launch")}
</button>
<button
type="button"
disabled={lock}
onClick={() => void onAndroidAction("force_stop", { package: p.package })}
className="rounded bg-rose-500/15 px-2 py-0.5 text-[10px] text-rose-200"
>
{t("强停", "Stop")}
</button>
</span>
</li>
))}
{!pkgList.length ? (
<li className="py-2 text-center text-zinc-600">{t("暂无结果,先搜索", "No results")}</li>
) : null}
</ul>
</div>
) : null}
{adbAvailable && androidSerial ? (
<div className="grid gap-6 xl:grid-cols-2">
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Terminal className="h-4 w-4 text-amber-400" />
{t("交互式 Shell", "ADB shell")}
</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("Ctrl+Enter 执行;勿执行不可逆命令。", "Ctrl+Enter to run.")}
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{SHELL_SNIPPETS.map((s) => (
<button
key={s.label}
type="button"
disabled={lock}
onClick={() => setShellCmd(s.cmd)}
className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] text-zinc-400 hover:text-zinc-200"
>
{s.label}
</button>
))}
</div>
<textarea
className="mt-3 min-h-[100px] w-full resize-y rounded-xl border border-white/10 bg-black/50 p-3 font-mono text-xs text-zinc-200"
value={shellCmd}
onChange={(e) => setShellCmd(e.target.value)}
spellCheck={false}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
void runShell();
}
}}
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
disabled={lock}
onClick={() => void runShell()}
className="rounded-xl bg-amber-500/20 px-4 py-2 text-sm text-amber-100 ring-1 ring-amber-500/30"
>
{pending === "shell" ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Run
</span>
) : (
"Run"
)}
</button>
{shellOut ? (
<button
type="button"
disabled={!shellOut}
onClick={() => onCopy(shellOut)}
className="inline-flex items-center gap-1 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200"
>
<Copy className="h-3.5 w-3.5" />
{t("复制输出", "Copy")}
</button>
) : null}
</div>
{shellOut ? (
<pre className="mt-3 max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-white/5 bg-black/40 p-3 font-mono text-[11px] text-zinc-400 xl:max-h-[min(24rem,50vh)]">
{shellOut}
</pre>
) : null}
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("Logcat最近行", "Logcat (recent)")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("logcat -d -t N大日志可能略慢。", "logcat -d -t N.")}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<input
className="w-24 rounded-lg border border-white/10 bg-black/40 px-3 py-2 font-mono text-xs"
value={logcatLines}
onChange={(e) => setLogcatLines(e.target.value)}
/>
<button
type="button"
disabled={lock}
onClick={() => void refreshLogcat()}
className="rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30"
>
{t("拉取日志", "Pull logs")}
</button>
{logcatText ? (
<button
type="button"
onClick={() => onCopy(logcatText)}
className="inline-flex items-center gap-1 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200"
>
<Copy className="h-3.5 w-3.5" />
{t("复制", "Copy")}
</button>
) : null}
</div>
{logcatErr ? <p className="mt-2 text-xs text-rose-300">{logcatErr}</p> : null}
{logcatText ? (
<pre className="mt-3 max-h-72 overflow-auto whitespace-pre-wrap rounded-lg border border-white/5 bg-black/50 p-3 font-mono text-[10px] leading-tight text-zinc-500 xl:max-h-[min(24rem,50vh)]">
{logcatText}
</pre>
) : null}
</div>
</div>
) : null}
{adbAvailable && androidSerial ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Layers className="h-4 w-4 text-fuchsia-400" />
{t("界面节点uiautomator dump", "UI nodes")}
</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("列出可交互节点,点击坐标模拟触控。", "Clickable nodes from UI dump; tap by coordinates.")}
</p>
<button
type="button"
disabled={lock}
onClick={() => void loadUiNodes()}
className="mt-3 rounded-xl border border-fuchsia-500/30 bg-fuchsia-500/10 px-4 py-2 text-sm text-fuchsia-100"
>
{t("刷新节点树", "Refresh nodes")}
</button>
{uiErr ? <p className="mt-2 text-xs text-rose-300">{uiErr}</p> : null}
<ul className="mt-3 max-h-64 space-y-1 overflow-y-auto rounded-lg border border-white/5 bg-black/30 p-2 text-[11px]">
{uiNodes.map((n, i) => (
<li
key={`${n.center_x}-${n.center_y}-${i}`}
className="flex flex-wrap items-center justify-between gap-2 border-b border-white/[0.04] py-1.5 text-zinc-400 last:border-0"
>
<div className="min-w-0 flex-1">
<span className="text-zinc-200">{n.label}</span>
{n.resource_id ? (
<span className="ml-2 font-mono text-[10px] text-zinc-600">{n.resource_id}</span>
) : null}
<div className="font-mono text-[10px] text-zinc-600">
({n.center_x}, {n.center_y}){n.clickable ? " · click" : ""}
</div>
</div>
<button
type="button"
disabled={lock}
onClick={() => void tapNode(n.center_x, n.center_y)}
className="shrink-0 rounded bg-white/10 px-2 py-1 text-[10px] text-zinc-200"
>
Tap
</button>
</li>
))}
{!uiNodes.length && !uiErr ? (
<li className="py-4 text-center text-zinc-600">{t("尚未加载", "Not loaded")}</li>
) : null}
</ul>
</div>
) : null}
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("安装 APK设备上的路径", "Install APK (path on device)")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("请先用 adb push 将 apk 推到 /sdcard/ 等路径。", "Push APK to /sdcard/ via adb first.")}
</p>
<input
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
value={apkPath}
onChange={(e) => setApkPath(e.target.value)}
/>
<button
type="button"
disabled={lock || !adbAvailable || !androidSerial}
onClick={() => void onAndroidAction("install_apk", { path: apkPath })}
className="mt-3 rounded-xl bg-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-100 ring-1 ring-emerald-500/30"
>
pm install
</button>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-white">{t("实时画面ADB 截图)", "Live screen (ADB)")}</h3>
{scrcpyUrl ? (
<a
href={scrcpyUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-violet-300 hover:text-violet-200"
>
ws-scrcpy <ArrowUpRight className="h-3 w-3" />
</a>
) : null}
</div>
<p className="mt-1 text-xs text-zinc-500">
{t(
"约每 2.5 秒刷新;全帧低延迟请用 ws-scrcpy 新窗口或本页内嵌。",
"~2.5s ADB refresh; use ws-scrcpy for low-latency stream.",
)}
</p>
{!adbAvailable ? (
<p className="mt-3 text-sm text-amber-200/90">{t("ADB 未就绪,无法截图。", "ADB unavailable.")}</p>
) : null}
{androidShotErr && adbAvailable ? <p className="mt-3 text-sm text-rose-300">{androidShotErr}</p> : null}
{androidShotUrl ? (
<img
alt="Android live"
className="mt-4 max-h-[min(60vh,520px)] w-full rounded-xl border border-white/10 bg-black object-contain"
src={androidShotUrl}
/>
) : null}
{adbAvailable && !androidShotUrl && !androidShotErr ? (
<p className="mt-6 text-center text-sm text-zinc-500">{t("加载截图…", "Loading screenshot…")}</p>
) : null}
</div>
{scrcpyUrl ? (
<div className="overflow-hidden rounded-2xl border border-white/[0.08] bg-black/40">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/5 px-4 py-3">
<button
type="button"
onClick={() => setEmbedScrcpy((v) => !v)}
className="flex items-center gap-2 text-xs font-medium text-zinc-300 hover:text-white"
>
ws-scrcpy
{embedScrcpy ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
<span className="font-normal text-zinc-600">
{embedScrcpy ? t("收起内嵌", "Collapse") : t("展开内嵌(省资源)", "Expand embed")}
</span>
</button>
<a href={scrcpyUrl} target="_blank" rel="noreferrer" className="text-xs text-violet-300">
{t("新窗口打开", "Open tab")}
</a>
</div>
{embedScrcpy ? (
<iframe title="scrcpy" src={scrcpyUrl} className="h-[min(56vh,420px)] w-full bg-black" />
) : (
<p className="px-4 py-3 text-center text-[11px] text-zinc-600">
{t("默认不加载 iframe避免占用带宽与解码需要时点「展开内嵌」。", "Iframe off by default to save resources.")}
</p>
)}
</div>
) : null}
</div>
);
}