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