's'
This commit is contained in:
28
web-console/lib/androidScrcpyWs.ts
Normal file
28
web-console/lib/androidScrcpyWs.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 将 HTTP API 根地址转为 WebSocket 根(用于 /android/scrcpy/ws)。
|
||||
* next dev 下若未设置 NEXT_PUBLIC_API_URL,浏览器会连到 :3000,WebSocket 升级通常无法被中间件代理;
|
||||
* 真流调试请设 NEXT_PUBLIC_API_URL 指向 uvicorn(例如 http://127.0.0.1:8001)。
|
||||
*/
|
||||
|
||||
export function httpApiBaseToWsBase(apiBase: string): string {
|
||||
if (typeof window === "undefined") return "ws://127.0.0.1:8001";
|
||||
const base = (apiBase || "").trim();
|
||||
if (base) {
|
||||
try {
|
||||
const u = new URL(base, window.location.href);
|
||||
const proto = u.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${proto}//${u.host}`;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const { protocol, host } = window.location;
|
||||
const proto = protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${proto}//${host}`;
|
||||
}
|
||||
|
||||
export function androidScrcpyWsUrl(apiBase: string, serial: string, maxSize = 1920): string {
|
||||
const root = httpApiBaseToWsBase(apiBase).replace(/\/$/, "");
|
||||
const q = new URLSearchParams({ serial, max_size: String(maxSize) });
|
||||
return `${root}/android/scrcpy/ws?${q.toString()}`;
|
||||
}
|
||||
169
web-console/lib/scrcpyH264Decoder.ts
Normal file
169
web-console/lib/scrcpyH264Decoder.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 解析 scrcpy raw_stream(AVCC:4 字节大端长度 + NAL)并用 WebCodecs 画到 Canvas。
|
||||
* 与官方 scrcpy / escrcpy 设备端编码一致;浏览器需 Chromium 系且支持 VideoDecoder(H.264)。
|
||||
*/
|
||||
|
||||
function concatBuffers(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
const o = new Uint8Array(a.length + b.length);
|
||||
o.set(a, 0);
|
||||
o.set(b, a.length);
|
||||
return o;
|
||||
}
|
||||
|
||||
function readU32be(b: Uint8Array, o: number): number {
|
||||
return (b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3];
|
||||
}
|
||||
|
||||
function buildAvcC(sps: Uint8Array, pps: Uint8Array): Uint8Array {
|
||||
const spsBody = sps.subarray(1);
|
||||
const ppsBody = pps.subarray(1);
|
||||
const out = new Uint8Array(11 + spsBody.length + 3 + ppsBody.length);
|
||||
let p = 0;
|
||||
out[p++] = 1;
|
||||
out[p++] = sps[1];
|
||||
out[p++] = sps[2];
|
||||
out[p++] = sps[3];
|
||||
out[p++] = 0xfc | 3;
|
||||
out[p++] = 0xe0 | 1;
|
||||
out[p++] = (spsBody.length >> 8) & 0xff;
|
||||
out[p++] = spsBody.length & 0xff;
|
||||
out.set(spsBody, p);
|
||||
p += spsBody.length;
|
||||
out[p++] = 1;
|
||||
out[p++] = (ppsBody.length >> 8) & 0xff;
|
||||
out[p++] = ppsBody.length & 0xff;
|
||||
out.set(ppsBody, p);
|
||||
return out;
|
||||
}
|
||||
|
||||
function codecStringFromSps(sps: Uint8Array): string {
|
||||
const h = (n: number) => n.toString(16).padStart(2, "0").toUpperCase();
|
||||
return `avc1.${h(sps[1])}${h(sps[2])}${h(sps[3])}`;
|
||||
}
|
||||
|
||||
export class ScrcpyH264Decoder {
|
||||
private buf: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
|
||||
private decoder: VideoDecoder | null = null;
|
||||
private configured = false;
|
||||
private configuring = false;
|
||||
private ts = 0;
|
||||
private sps: Uint8Array | null = null;
|
||||
private pps: Uint8Array | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly canvas: HTMLCanvasElement,
|
||||
private readonly onDecodeError: (msg: string) => void,
|
||||
) {}
|
||||
|
||||
reset(): void {
|
||||
this.buf = new Uint8Array(0);
|
||||
this.sps = null;
|
||||
this.pps = null;
|
||||
this.configured = false;
|
||||
this.configuring = false;
|
||||
this.ts = 0;
|
||||
if (this.decoder) {
|
||||
try {
|
||||
this.decoder.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.decoder = null;
|
||||
}
|
||||
}
|
||||
|
||||
push(chunk: ArrayBuffer): void {
|
||||
const add = new Uint8Array(chunk.byteLength);
|
||||
add.set(new Uint8Array(chunk));
|
||||
this.buf = concatBuffers(this.buf, add);
|
||||
this.drainAvcc();
|
||||
}
|
||||
|
||||
private drainAvcc(): void {
|
||||
const b = this.buf;
|
||||
let o = 0;
|
||||
while (o + 4 <= b.length) {
|
||||
const len = readU32be(b, o);
|
||||
if (len <= 0 || len > 6 * 1024 * 1024) {
|
||||
this.buf = new Uint8Array(b.subarray(o + 1));
|
||||
o = 0;
|
||||
continue;
|
||||
}
|
||||
if (o + 4 + len > b.length) break;
|
||||
const nal = b.subarray(o + 4, o + 4 + len);
|
||||
o += 4 + len;
|
||||
this.handleNal(nal);
|
||||
}
|
||||
if (o > 0) this.buf = new Uint8Array(b.subarray(o));
|
||||
}
|
||||
|
||||
private handleNal(nal: Uint8Array): void {
|
||||
if (nal.length < 2) return;
|
||||
const t = nal[0] & 0x1f;
|
||||
if (t === 7) this.sps = new Uint8Array(nal);
|
||||
else if (t === 8) this.pps = new Uint8Array(nal);
|
||||
|
||||
if (this.sps && this.pps && !this.configured && !this.configuring) {
|
||||
void this.configure();
|
||||
}
|
||||
if (!this.configured || !this.decoder) return;
|
||||
if (t !== 1 && t !== 5 && t !== 7 && t !== 8) return;
|
||||
if (t === 7 || t === 8) return;
|
||||
|
||||
const key = t === 5;
|
||||
const data = new Uint8Array(4 + nal.length);
|
||||
new DataView(data.buffer).setUint32(0, nal.length, false);
|
||||
data.set(nal, 4);
|
||||
try {
|
||||
const chunk = new EncodedVideoChunk({
|
||||
type: key ? "key" : "delta",
|
||||
timestamp: this.ts,
|
||||
data,
|
||||
});
|
||||
this.ts += 33_333;
|
||||
this.decoder.decode(chunk);
|
||||
} catch (e) {
|
||||
this.onDecodeError((e as Error).message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async configure(): Promise<void> {
|
||||
if (!this.sps || !this.pps || this.configured || this.configuring) return;
|
||||
this.configuring = true;
|
||||
try {
|
||||
const avcc = buildAvcC(this.sps, this.pps);
|
||||
const codec = codecStringFromSps(this.sps);
|
||||
const dec = new VideoDecoder({
|
||||
output: (frame) => {
|
||||
const ctx = this.canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
frame.close();
|
||||
return;
|
||||
}
|
||||
if (this.canvas.width !== frame.displayWidth || this.canvas.height !== frame.displayHeight) {
|
||||
this.canvas.width = frame.displayWidth;
|
||||
this.canvas.height = frame.displayHeight;
|
||||
}
|
||||
ctx.drawImage(frame, 0, 0);
|
||||
frame.close();
|
||||
},
|
||||
error: (e) => this.onDecodeError(String((e as DOMException).message || e)),
|
||||
});
|
||||
await dec.configure({
|
||||
codec,
|
||||
description: avcc,
|
||||
optimizeForLatency: true,
|
||||
} as VideoDecoderConfig);
|
||||
this.decoder = dec;
|
||||
this.configured = true;
|
||||
} catch (e) {
|
||||
this.onDecodeError((e as Error).message || String(e));
|
||||
} finally {
|
||||
this.configuring = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function webCodecsH264Supported(): boolean {
|
||||
return typeof VideoDecoder !== "undefined" && typeof VideoFrame !== "undefined";
|
||||
}
|
||||
@@ -24,8 +24,8 @@ export function parseYoutubeProChannelList(raw: unknown): YoutubeProChannel[] {
|
||||
function migrateRow(x: Record<string, unknown>): YoutubeProChannel | null {
|
||||
if (!x || typeof x !== "object") return null;
|
||||
const id = String(x.id || "").trim();
|
||||
const name = String(x.name || "").trim();
|
||||
if (!id || !name) return null;
|
||||
if (!id) return null;
|
||||
const name = String(x.name || "").trim() || id;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
|
||||
Reference in New Issue
Block a user