DUNGEONS-34: implement Webka local streaming app
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
321
frontend/composables/useStream.ts
Normal file
321
frontend/composables/useStream.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user