DUNGEONS-34: implement Webka local streaming app

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-09-06 16:23:09 +05:00
parent 5540b9a1f4
commit 7c737c1011
36 changed files with 21596 additions and 0 deletions

View File

@@ -0,0 +1,321 @@
export function useStream(mode: "host" | "viewer") {
const media = shallowRef<MediaStream | null>(null);
const remote = shallowRef<MediaStream | null>(null);
const state = ref("offline");
const error = ref("");
const source = ref<"front" | "back" | "screen">("front");
const rearAvailable = ref(false);
const screenAvailable = ref(false);
const resolution = ref("");
const quality = ref("auto");
let socket: WebSocket | null = null;
const peers = new Map<string, RTCPeerConnection>();
const pending = new Map<string, RTCIceCandidateInit[]>();
let retry: ReturnType<typeof setTimeout> | undefined;
let stats: ReturnType<typeof setInterval> | undefined;
let reconnects = 0;
let desired = false;
let captureVersion = 0;
const apiError = useApiError();
const session = useSessionStore();
function send(type: string, target: string, data?: unknown) {
if (socket?.readyState === WebSocket.OPEN)
socket.send(JSON.stringify({ type, target, data }));
}
function clearPeers() {
for (const p of peers.values()) p.close();
peers.clear();
pending.clear();
remote.value = null;
resolution.value = "";
}
function removePeer(id: string) {
peers.get(id)?.close();
peers.delete(id);
pending.delete(id);
}
async function setQuality(value: string) {
quality.value = value;
for (const peer of peers.values())
for (const sender of peer.getSenders()) {
if (sender.track?.kind !== "video") continue;
const params = sender.getParameters();
if (!params.encodings?.length) params.encodings = [{}];
params.encodings[0]!.maxBitrate =
value === "low" ? 600_000 : value === "high" ? 4_000_000 : 2_000_000;
try {
await sender.setParameters(params);
} catch {
/* Browser may not expose encoding controls. */
}
}
}
function peer(id: string) {
const p = new RTCPeerConnection({ iceServers: [] }); // LAN-only: no external STUN/TURN services.
peers.set(id, p);
p.onicecandidate = (e) => {
if (e.candidate) send("ice", id, e.candidate.toJSON());
};
p.ontrack = (e) => {
remote.value = e.streams[0] ?? new MediaStream([e.track]);
state.value = "live";
};
p.onconnectionstatechange = () => {
if (p.connectionState === "connected") {
if (mode === "viewer") state.value = "live";
void setQuality(quality.value);
}
if (p.connectionState === "disconnected" && mode === "viewer")
state.value = "reconnecting";
if (p.connectionState === "failed") {
removePeer(id);
if (mode === "viewer") {
state.value = "reconnecting";
send("renegotiate", id);
}
}
};
if (mode === "host")
media.value
?.getTracks()
.forEach((track) => p.addTrack(track, media.value!));
return p;
}
async function offer(id: string) {
removePeer(id);
const p = peer(id);
await p.setLocalDescription(await p.createOffer());
send("offer", id, p.localDescription);
}
async function drain(id: string, p: RTCPeerConnection) {
for (const ice of pending.get(id) ?? []) await p.addIceCandidate(ice);
pending.delete(id);
}
async function message(v: any) {
if (v.type === "error") {
desired = false;
error.value = apiError({ data: { error: v.error } });
state.value = "offline";
socket?.close();
return;
}
if (v.type === "welcome") {
state.value =
mode === "host" ? "live" : v.live ? "connecting" : "waiting";
reconnects = 0;
}
if (v.type === "host-offline") {
clearPeers();
state.value = "waiting";
}
if (v.type === "host-online") state.value = "connecting";
if (v.type === "viewer-left") removePeer(v.peer);
if (
(v.type === "viewer-joined" || v.type === "renegotiate") &&
mode === "host"
)
await offer(v.peer);
if (v.type === "offer" && mode === "viewer") {
// ICE messages are serialized behind this handler.
const earlyIce = pending.get(v.peer) ?? [];
removePeer(v.peer);
pending.set(v.peer, earlyIce);
const p = peer(v.peer);
await p.setRemoteDescription(v.data);
await drain(v.peer, p);
await p.setLocalDescription(await p.createAnswer());
send("answer", v.peer, p.localDescription);
}
if (v.type === "answer") {
const p = peers.get(v.peer);
if (p) {
await p.setRemoteDescription(v.data);
await drain(v.peer, p);
}
}
if (v.type === "ice") {
const p = peers.get(v.peer);
if (p?.remoteDescription) await p.addIceCandidate(v.data);
else pending.set(v.peer, [...(pending.get(v.peer) ?? []), v.data]);
}
}
function connect() {
if (!desired) return;
state.value = reconnects ? "reconnecting" : "connecting";
const ws = new WebSocket(
`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/api/signal?mode=${mode}`,
);
socket = ws;
let queue = Promise.resolve();
ws.onmessage = (e) => {
queue = queue
.then(() => {
if (socket === ws && desired) return message(JSON.parse(e.data));
})
.catch((e) => {
error.value = apiError(e);
});
};
ws.onclose = async () => {
if (socket !== ws) return;
socket = null;
clearPeers();
if (!desired) return;
state.value = "reconnecting";
if (!(await session.refresh())) {
desired = false;
stop();
await navigateTo("/login");
return;
}
if (!desired) return;
retry = setTimeout(connect, Math.min(1000 * 2 ** reconnects++, 10000));
};
}
async function detectSources() {
screenAvailable.value = !!navigator.mediaDevices?.getDisplayMedia;
try {
const devices = await navigator.mediaDevices?.enumerateDevices();
rearAvailable.value =
!!devices?.some(
(d) =>
d.kind === "videoinput" && /back|rear|environment/i.test(d.label),
) || (devices?.filter((d) => d.kind === "videoinput").length ?? 0) > 1;
} catch {
rearAvailable.value = false;
}
}
async function capture(selected = source.value) {
const version = ++captureVersion;
error.value = "";
if (!window.isSecureContext || !navigator.mediaDevices) {
error.value = apiError({ data: { error: "secureContext" } });
return false;
}
let next: MediaStream | null = null;
try {
const acquire = () =>
selected === "screen"
? navigator.mediaDevices.getDisplayMedia({
video: { frameRate: 30 },
audio: false,
})
: navigator.mediaDevices.getUserMedia({
audio: false,
video: {
facingMode:
selected === "back" ? { exact: "environment" } : "user",
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 30, max: 30 },
},
});
try {
next = await acquire();
} catch (e: any) {
// Some mobile browsers require releasing the previous camera before opening another lens.
if (
e.name !== "NotReadableError" ||
!media.value ||
source.value === "screen" ||
selected === "screen"
)
throw e;
media.value.getTracks().forEach((t) => t.stop());
try {
next = await acquire();
} catch (retryError) {
stop();
throw retryError;
}
}
if (version !== captureVersion) {
next.getTracks().forEach((t) => t.stop());
return false;
}
const track = next.getVideoTracks()[0]!;
const renegotiate: string[] = [];
for (const [id, p] of peers) {
const sender = p.getSenders().find((s) => s.track?.kind === "video");
try {
await sender?.replaceTrack(track);
} catch {
renegotiate.push(id);
}
}
const previous = media.value;
media.value = next;
source.value = selected;
for (const id of renegotiate) await offer(id);
previous?.getTracks().forEach((t) => t.stop());
track.onended = () => {
if (media.value === next) stop();
};
await detectSources();
return true;
} catch (e) {
next?.getTracks().forEach((t) => t.stop());
error.value = apiError(e);
return false;
}
}
async function start() {
error.value = "";
if (desired) return;
if (mode === "host" && !media.value && !(await capture())) return;
desired = true;
reconnects = 0;
connect();
}
function stop() {
desired = false;
captureVersion++;
clearTimeout(retry);
const old = socket;
socket = null;
old?.close();
clearPeers();
media.value?.getTracks().forEach((t) => t.stop());
media.value = null;
state.value = "offline";
}
onMounted(() => {
void detectSources();
stats = setInterval(async () => {
if (mode !== "viewer") return;
for (const p of peers.values()) {
try {
(await p.getStats()).forEach((report) => {
if (
report.type === "inbound-rtp" &&
report.kind === "video" &&
report.frameWidth
)
resolution.value = `${report.frameWidth} × ${report.frameHeight}`;
});
} catch {
/* A closing peer has no stats. */
}
}
}, 3000);
});
onBeforeUnmount(() => {
stop();
clearInterval(stats);
});
return {
media,
remote,
state,
error,
source,
rearAvailable,
screenAvailable,
resolution,
quality,
capture,
start,
stop,
setQuality,
};
}