Files
Webka/frontend/composables/useStream.ts
2026-09-06 16:23:09 +05:00

322 lines
9.7 KiB
TypeScript
Raw Permalink 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.
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,
};
}