169 lines
4.8 KiB
JavaScript
169 lines
4.8 KiB
JavaScript
const startButton = document.querySelector("#start");
|
|
const stopButton = document.querySelector("#stop");
|
|
const preview = document.querySelector("#preview");
|
|
const state = document.querySelector("#state");
|
|
const message = document.querySelector("#message");
|
|
|
|
const peerConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };
|
|
|
|
let stream;
|
|
let signal;
|
|
const peers = new Map();
|
|
|
|
startButton.addEventListener("click", startSharing);
|
|
stopButton.addEventListener("click", stopSharing);
|
|
|
|
async function startSharing() {
|
|
console.info("[host] startSharing clicked");
|
|
startButton.disabled = true;
|
|
setState("connecting", "准备中");
|
|
message.textContent = "请选择要分享的桌面、窗口或应用。";
|
|
|
|
try {
|
|
stream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: { frameRate: 30 },
|
|
audio: {
|
|
echoCancellation: false,
|
|
noiseSuppression: false,
|
|
sampleRate: 48000,
|
|
},
|
|
});
|
|
|
|
console.info("[host] getDisplayMedia resolved", describeStream(stream));
|
|
preview.srcObject = stream;
|
|
signal = openSignalSocket();
|
|
|
|
stream.getTracks().forEach((track) => {
|
|
track.addEventListener("ended", () => {
|
|
console.info("[host] track ended", describeTrack(track));
|
|
stopSharing();
|
|
});
|
|
});
|
|
|
|
stopButton.disabled = false;
|
|
setState("live", "推流中");
|
|
message.textContent = `观看地址:${location.origin}/viewer`;
|
|
} catch (error) {
|
|
console.error("[host] startSharing failed", error);
|
|
stopSharing();
|
|
message.textContent = error instanceof Error ? error.message : "启动分享失败。";
|
|
}
|
|
}
|
|
|
|
function openSignalSocket() {
|
|
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
|
const nextSocket = new WebSocket(`${protocol}://${location.host}/signal?role=host`);
|
|
|
|
nextSocket.addEventListener("open", () => console.info("[host] signal open"));
|
|
nextSocket.addEventListener("message", (event) => void handleSignalMessage(JSON.parse(event.data)));
|
|
nextSocket.addEventListener("close", (event) => console.info("[host] signal close", { code: event.code, reason: event.reason }));
|
|
nextSocket.addEventListener("error", (event) => console.error("[host] signal error", event));
|
|
|
|
return nextSocket;
|
|
}
|
|
|
|
async function handleSignalMessage(data) {
|
|
console.info("[host] signal", data);
|
|
|
|
if (data.type === "viewer-join" && typeof data.viewerId === "string") {
|
|
await createPeer(data.viewerId);
|
|
return;
|
|
}
|
|
|
|
if (data.type === "answer" && typeof data.viewerId === "string") {
|
|
const peer = peers.get(data.viewerId);
|
|
if (peer) {
|
|
await peer.setRemoteDescription(data.sdp);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (data.type === "candidate" && typeof data.viewerId === "string") {
|
|
const peer = peers.get(data.viewerId);
|
|
if (peer && data.candidate) {
|
|
await peer.addIceCandidate(data.candidate);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (data.type === "viewer-left" && typeof data.viewerId === "string") {
|
|
closePeer(data.viewerId);
|
|
}
|
|
}
|
|
|
|
async function createPeer(viewerId) {
|
|
closePeer(viewerId);
|
|
|
|
const peer = new RTCPeerConnection(peerConfig);
|
|
peers.set(viewerId, peer);
|
|
|
|
stream.getTracks().forEach((track) => peer.addTrack(track, stream));
|
|
peer.addEventListener("icecandidate", (event) => {
|
|
if (event.candidate) {
|
|
sendSignal({ type: "candidate", to: viewerId, candidate: event.candidate });
|
|
}
|
|
});
|
|
peer.addEventListener("connectionstatechange", () => {
|
|
console.info("[host] peer state", { viewerId, state: peer.connectionState });
|
|
if (["failed", "closed", "disconnected"].includes(peer.connectionState)) {
|
|
closePeer(viewerId);
|
|
}
|
|
});
|
|
|
|
const offer = await peer.createOffer();
|
|
await peer.setLocalDescription(offer);
|
|
sendSignal({ type: "offer", to: viewerId, sdp: peer.localDescription });
|
|
}
|
|
|
|
function sendSignal(data) {
|
|
if (signal?.readyState === WebSocket.OPEN) {
|
|
signal.send(JSON.stringify(data));
|
|
}
|
|
}
|
|
|
|
function closePeer(viewerId) {
|
|
const peer = peers.get(viewerId);
|
|
if (peer) {
|
|
peer.close();
|
|
peers.delete(viewerId);
|
|
}
|
|
}
|
|
|
|
function stopSharing() {
|
|
console.info("[host] stopSharing", { peers: peers.size, signalState: signal?.readyState });
|
|
for (const viewerId of peers.keys()) {
|
|
closePeer(viewerId);
|
|
}
|
|
signal?.close();
|
|
stream?.getTracks().forEach((track) => track.stop());
|
|
|
|
signal = undefined;
|
|
stream = undefined;
|
|
preview.srcObject = null;
|
|
startButton.disabled = false;
|
|
stopButton.disabled = true;
|
|
setState("idle", "未推流");
|
|
}
|
|
|
|
function describeStream(nextStream) {
|
|
return {
|
|
tracks: nextStream.getTracks().map(describeTrack),
|
|
};
|
|
}
|
|
|
|
function describeTrack(track) {
|
|
return {
|
|
kind: track.kind,
|
|
label: track.label,
|
|
enabled: track.enabled,
|
|
muted: track.muted,
|
|
readyState: track.readyState,
|
|
settings: track.getSettings(),
|
|
};
|
|
}
|
|
|
|
function setState(kind, text) {
|
|
state.className = `badge ${kind}`;
|
|
state.textContent = text;
|
|
}
|