mirror of
https://github.com/giongto35/cloud-game.git
synced 2026-07-18 00:55:40 +00:00
Add an option for initiating WebRTC offer on the client side
WebRTC negotiation will start in the browser with an offer. Added because a reversed negotiation (server's offer) doesn't work in Firefox-based browsers.
This commit is contained in:
parent
97a0d0a4c5
commit
8c4b4bf96f
8 changed files with 252 additions and 147 deletions
|
|
@ -29,13 +29,6 @@ import {
|
|||
RECORDING_TOGGLED,
|
||||
REFRESH_INPUT,
|
||||
SETTINGS_CHANGED,
|
||||
WEBRTC_CONNECTION_CLOSED,
|
||||
WEBRTC_CONNECTION_READY,
|
||||
WEBRTC_ICE_CANDIDATE_FOUND,
|
||||
WEBRTC_ICE_CANDIDATE_RECEIVED,
|
||||
WEBRTC_NEW_CONNECTION,
|
||||
WEBRTC_SDP_LOCAL,
|
||||
WEBRTC_SDP_REMOTE,
|
||||
WORKER_LIST_FETCHED,
|
||||
pub,
|
||||
sub,
|
||||
|
|
@ -148,8 +141,10 @@ const showMenuScreen = () => {
|
|||
setState(app.state.menu);
|
||||
};
|
||||
|
||||
const isConnected = () => webrtc.isConnected();
|
||||
|
||||
const startGame = () => {
|
||||
if (!webrtc.isConnected()) {
|
||||
if (!isConnected()) {
|
||||
message.show("Game cannot load. Please refresh");
|
||||
return;
|
||||
}
|
||||
|
|
@ -183,16 +178,13 @@ const onMessage = (m) => {
|
|||
log.debug(`[msg] ${api.endpointName[t] || t}`);
|
||||
switch (t) {
|
||||
case api.endpoint.INIT:
|
||||
pub(WEBRTC_NEW_CONNECTION, payload);
|
||||
handleWebrtcStart({ data: payload, initiator: true });
|
||||
break;
|
||||
case api.endpoint.OFFER:
|
||||
pub(WEBRTC_SDP_REMOTE, api.fromBase64(payload));
|
||||
webrtc.setRemoteDescription(api.fromBase64(payload));
|
||||
break;
|
||||
case api.endpoint.ICE_CANDIDATE:
|
||||
pub(
|
||||
WEBRTC_ICE_CANDIDATE_RECEIVED,
|
||||
payload ? api.fromBase64(payload) : "",
|
||||
);
|
||||
webrtc.addCandidate(payload ? api.fromBase64(payload) : "");
|
||||
break;
|
||||
case api.endpoint.GAME_START:
|
||||
if (payload.av) pub(APP_VIDEO_CHANGED, payload.av);
|
||||
|
|
@ -486,6 +478,56 @@ document.onfullscreenchange = () =>
|
|||
// subscriptions
|
||||
sub(MESSAGE, onMessage);
|
||||
|
||||
// webrtc
|
||||
function handleWebrtcStart({ data, initiator }) {
|
||||
workerManager.whoami(data.wid);
|
||||
|
||||
let makingOffer = false;
|
||||
|
||||
const negotiate = () => {
|
||||
if (makingOffer) return;
|
||||
makingOffer = true;
|
||||
webrtc
|
||||
.offerSdp()
|
||||
.then((offer) => {
|
||||
if (!offer) return;
|
||||
log.debug("> offer", offer);
|
||||
api.server.initWebrtcStream({ initiator, sdpOffer: offer });
|
||||
})
|
||||
.finally(() => (makingOffer = false));
|
||||
};
|
||||
|
||||
const datachannel = (ch) => {
|
||||
log.debug("> datachannel", ch.label);
|
||||
if (ch.label === "data") {
|
||||
// we'll handle ws and webrtc server messages in one place
|
||||
ch.onmessage = (x) => onMessage(api.fromBytes(x.data));
|
||||
}
|
||||
return ch;
|
||||
};
|
||||
|
||||
webrtc.start({
|
||||
initiator,
|
||||
iceServers: data.ice,
|
||||
media: stream.video.el,
|
||||
onNegotiationNeeded: negotiate,
|
||||
onDataChannel: datachannel,
|
||||
onConnect: onConnectionReady,
|
||||
onDisconnect: () => input.retropad.toggle(false),
|
||||
onIceCandidate: api.server.sendIceCandidate,
|
||||
onSdpAnswer: api.server.sendSdp,
|
||||
});
|
||||
|
||||
if (initiator) {
|
||||
negotiate();
|
||||
webrtc.createDataChannel({ onChannel: datachannel });
|
||||
} else {
|
||||
api.server.initWebrtcStream();
|
||||
}
|
||||
|
||||
gameList.set(data.games);
|
||||
}
|
||||
|
||||
sub(GAME_ROOM_AVAILABLE, stream.play, 2);
|
||||
sub(GAME_SAVED, () => message.show("Saved"));
|
||||
sub(GAME_PLAYER_IDX, (data) => {
|
||||
|
|
@ -496,30 +538,6 @@ sub(GAME_PLAYER_IDX_SET, (idx) => {
|
|||
});
|
||||
sub(GAME_ERROR_NO_FREE_SLOTS, () => message.show("No free slots :(", 2500));
|
||||
|
||||
// WebRTC connection handling
|
||||
sub(WEBRTC_NEW_CONNECTION, (data) => {
|
||||
workerManager.whoami(data.wid);
|
||||
webrtc.start({ iceServers: data.ice, media: stream.video.el });
|
||||
webrtc.modDataChannel = (ch) => {
|
||||
ch.binaryType = "arraybuffer";
|
||||
if (ch.label === "data") {
|
||||
ch.onmessage = (x) => onMessage(api.fromBytes(x.data));
|
||||
}
|
||||
return ch;
|
||||
};
|
||||
api.server.initWebrtcStream();
|
||||
gameList.set(data.games);
|
||||
});
|
||||
sub(WEBRTC_SDP_REMOTE, webrtc.setRemoteDescription);
|
||||
sub(WEBRTC_SDP_LOCAL, api.server.sendSdp);
|
||||
sub(WEBRTC_ICE_CANDIDATE_FOUND, api.server.sendIceCandidate);
|
||||
sub(WEBRTC_ICE_CANDIDATE_RECEIVED, webrtc.addCandidate);
|
||||
sub(WEBRTC_CONNECTION_READY, onConnectionReady);
|
||||
sub(WEBRTC_CONNECTION_CLOSED, () => {
|
||||
input.retropad.toggle(false);
|
||||
webrtc.stop();
|
||||
});
|
||||
|
||||
sub(LATENCY_CHECK_REQUESTED, onLatencyCheck);
|
||||
sub(GAMEPAD_CONNECTED, () => message.show("Gamepad connected"));
|
||||
sub(GAMEPAD_DISCONNECTED, () => message.show("Gamepad disconnected"));
|
||||
|
|
|
|||
|
|
@ -60,14 +60,6 @@ export const GAME_PLAYER_IDX = "gamePlayerIndex";
|
|||
export const GAME_PLAYER_IDX_SET = "gamePlayerIndexSet";
|
||||
export const GAME_ERROR_NO_FREE_SLOTS = "gameNoFreeSlots";
|
||||
|
||||
export const WEBRTC_CONNECTION_CLOSED = "webrtcConnectionClosed";
|
||||
export const WEBRTC_CONNECTION_READY = "webrtcConnectionReady";
|
||||
export const WEBRTC_ICE_CANDIDATE_FOUND = "webrtcIceCandidateFound";
|
||||
export const WEBRTC_ICE_CANDIDATE_RECEIVED = "webrtcIceCandidateReceived";
|
||||
export const WEBRTC_NEW_CONNECTION = "webrtcNewConnection";
|
||||
export const WEBRTC_SDP_LOCAL = "webrtcSdpLocal";
|
||||
export const WEBRTC_SDP_REMOTE = "webrtcSdpRemote";
|
||||
|
||||
export const MESSAGE = "message";
|
||||
|
||||
export const GAMEPAD_CONNECTED = "gamepadConnected";
|
||||
|
|
|
|||
|
|
@ -1,26 +1,19 @@
|
|||
import {
|
||||
pub,
|
||||
WEBRTC_CONNECTION_READY,
|
||||
WEBRTC_CONNECTION_CLOSED,
|
||||
WEBRTC_ICE_CANDIDATE_FOUND,
|
||||
WEBRTC_SDP_LOCAL,
|
||||
} from "event";
|
||||
import { log } from "log";
|
||||
|
||||
let /** @type {RTCPeerConnection} */ pc;
|
||||
let /** @type {Map<string, RTCDataChannel>} */ channels = new Map();
|
||||
let /** @type {MediaStream} */ stream;
|
||||
let /** @type {RTCLocalIceCandidateInit[]} */ candidateBuf = [];
|
||||
let /** @type {(channel: RTCDataChannel) => RTCDataChannel} */ modDataChannel;
|
||||
let handleSdpAnswer;
|
||||
let _initiator = false;
|
||||
|
||||
const ice = ((timeout = 3000) => {
|
||||
let timeoutId;
|
||||
const ice = (() => {
|
||||
let handleIceCandidate;
|
||||
|
||||
const onIceCandidate = (/** @type {RTCPeerConnectionIceEvent} */ ev) => {
|
||||
if (!ev.candidate) return;
|
||||
log.debug(`[rtc] [ice] local: ${ev.candidate.candidate}`);
|
||||
pub(WEBRTC_ICE_CANDIDATE_FOUND, ev.candidate);
|
||||
log.debug(`[rtc] [ice] local`, ev.candidate);
|
||||
if (handleIceCandidate) handleIceCandidate(ev.candidate);
|
||||
};
|
||||
|
||||
const onIceCandidateError = (
|
||||
|
|
@ -40,17 +33,6 @@ const ice = ((timeout = 3000) => {
|
|||
const onIceGatheringStateChange = (event) => {
|
||||
const /** @type {RTCPeerConnection} */ t = event.target;
|
||||
log.debug(`[rtc] [ice] state: ${t.iceGatheringState}`);
|
||||
|
||||
switch (t.iceGatheringState) {
|
||||
case "gathering":
|
||||
timeoutId = setTimeout(() => {
|
||||
log.warn(`[rtc] [ice] stopped due to timeout ${timeout}ms`);
|
||||
}, timeout);
|
||||
break;
|
||||
case "complete":
|
||||
clearTimeout(timeoutId);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onIceConnectionStateChange = () => {
|
||||
|
|
@ -68,20 +50,26 @@ const ice = ((timeout = 3000) => {
|
|||
onIceCandidateError,
|
||||
onIceGatheringStateChange,
|
||||
onIceConnectionStateChange,
|
||||
set handleIceCandidate(cb) {
|
||||
handleIceCandidate = cb;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
const isConnected = () => pc?.connectionState === "connected";
|
||||
const hasRemoteDescription = () => pc?.remoteDescription !== null;
|
||||
|
||||
const addRemoteCandidate = (data) => {
|
||||
if (!data) return;
|
||||
pc.addIceCandidate(new RTCIceCandidate(data)).catch((e) => {
|
||||
const candidate = new RTCIceCandidate(data);
|
||||
pc.addIceCandidate(candidate).catch((e) => {
|
||||
log.error("[rtc] [ice] remote candidate add failed", e.name);
|
||||
});
|
||||
log.debug(`[rtc] [ice] added remote: ${data.candidate}`);
|
||||
log.debug(`[rtc] [ice] added remote`, candidate);
|
||||
};
|
||||
|
||||
const flushRemoteCandidates = () => {
|
||||
// this will work only when the remote description is set
|
||||
if (!pc.remoteDescription || candidateBuf.length === 0) return;
|
||||
if (!hasRemoteDescription() || candidateBuf.length === 0) return;
|
||||
|
||||
log.debug(`[rtc] [ice] remote candidate buf (${candidateBuf.length})`);
|
||||
let data = undefined;
|
||||
|
|
@ -90,21 +78,65 @@ const flushRemoteCandidates = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const isConnected = () => pc?.connectionState === "connected";
|
||||
|
||||
// hacks
|
||||
// Chrome bug https://bugs.chromium.org/p/chromium/issues/detail?id=818180 workaround
|
||||
// force stereo params for Opus tracks (a=fmtp:111 ...)
|
||||
const enableOpusStereo = (sdp) =>
|
||||
sdp.replace(/(a=fmtp:111 .*)/g, "$1;stereo=1");
|
||||
|
||||
const pushChannel = (chan) => {
|
||||
channels.set(chan.label, chan);
|
||||
log.debug(`[rtc] [data-ch] push: ${chan.label}`);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (stream) {
|
||||
while (stream.getTracks().length > 0) {
|
||||
const t = stream.getTracks()[0];
|
||||
t.stop();
|
||||
stream.removeTrack(t);
|
||||
}
|
||||
stream = null;
|
||||
}
|
||||
if (pc) {
|
||||
ice.handleIceCandidate = null;
|
||||
handleSdpAnswer = null;
|
||||
pc.oniceconnectionstatechange = null;
|
||||
pc.onicegatheringstatechange = null;
|
||||
pc.onicecandidate = null;
|
||||
pc.onicecandidateerror = null;
|
||||
pc.onconnectionstatechange = null;
|
||||
pc.ondatachannel = null;
|
||||
pc.ontrack = null;
|
||||
pc.close();
|
||||
pc = null;
|
||||
}
|
||||
|
||||
for (const [, channel] of channels) {
|
||||
channel.close();
|
||||
}
|
||||
channels.clear();
|
||||
candidateBuf = [];
|
||||
log.debug("[rtc] WebRTC has been closed");
|
||||
};
|
||||
|
||||
/**
|
||||
* WebRTC connection module.
|
||||
*/
|
||||
export const webrtc = {
|
||||
start: ({ iceServers = [], media, initiator = false } = {}) => {
|
||||
start: ({
|
||||
iceServers = [],
|
||||
media,
|
||||
initiator = false,
|
||||
onNegotiationNeeded,
|
||||
onDataChannel,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onIceCandidate,
|
||||
onSdpAnswer,
|
||||
} = {}) => {
|
||||
log.debug("[rtc] got remote ICE servers", iceServers);
|
||||
pc = new RTCPeerConnection({ iceCandidatePoolSize: 16, iceServers });
|
||||
pc = new RTCPeerConnection({ iceCandidatePoolSize: 1, iceServers });
|
||||
stream = new MediaStream();
|
||||
|
||||
_initiator = initiator;
|
||||
|
|
@ -118,14 +150,17 @@ export const webrtc = {
|
|||
);
|
||||
}
|
||||
|
||||
if (onIceCandidate) ice.handleIceCandidate = onIceCandidate;
|
||||
if (onSdpAnswer) handleSdpAnswer = onSdpAnswer;
|
||||
|
||||
pc.addTransceiver("video", { direction: "recvonly" });
|
||||
pc.addTransceiver("audio", { direction: "recvonly" });
|
||||
|
||||
pc.ondatachannel = (ev) => {
|
||||
let chan = modDataChannel ? modDataChannel(ev.channel) : ev.channel;
|
||||
channels.set(chan.label, chan);
|
||||
log.debug(`[rtc] [data-ch] push: ${chan.label}`);
|
||||
let chan = onDataChannel ? onDataChannel(ev.channel) : ev.channel;
|
||||
pushChannel(chan);
|
||||
};
|
||||
|
||||
pc.oniceconnectionstatechange = ice.onIceConnectionStateChange;
|
||||
pc.onicegatheringstatechange = ice.onIceGatheringStateChange;
|
||||
pc.onicecandidate = ice.onIceCandidate;
|
||||
|
|
@ -135,31 +170,38 @@ export const webrtc = {
|
|||
|
||||
switch (pc.connectionState) {
|
||||
case "connected":
|
||||
pub(WEBRTC_CONNECTION_READY);
|
||||
if (onConnect) onConnect();
|
||||
break;
|
||||
case "failed":
|
||||
case "closed":
|
||||
pub(WEBRTC_CONNECTION_CLOSED);
|
||||
if (onDisconnect) onDisconnect();
|
||||
stop();
|
||||
break;
|
||||
}
|
||||
};
|
||||
pc.onnegotiationneeded = () => {
|
||||
log.debug("[rtc] negotiation needed");
|
||||
// todo implement
|
||||
// pc.createOffer()
|
||||
// .then((description) =>
|
||||
// pc.setLocalDescription(description).catch(log.error),
|
||||
// )
|
||||
// .catch(log.error);
|
||||
};
|
||||
pc.ontrack = (event) => {
|
||||
stream.addTrack(event.track);
|
||||
if (onNegotiationNeeded) onNegotiationNeeded();
|
||||
};
|
||||
pc.ontrack = (event) => stream.addTrack(event.track);
|
||||
},
|
||||
offerSdp: async () => {
|
||||
if (!pc || !_initiator) return;
|
||||
|
||||
try {
|
||||
const offer = await pc.createOffer();
|
||||
offer.sdp = enableOpusStereo(offer.sdp);
|
||||
await pc.setLocalDescription(offer);
|
||||
log.debug("[rtc] [sdp] local offer", offer);
|
||||
return offer;
|
||||
} catch (e) {
|
||||
log.error(`[rtc] [sdp] local offer error: ${e}`);
|
||||
}
|
||||
},
|
||||
setRemoteDescription: async (
|
||||
/** @type {RTCSessionDescriptionInit} */ sdp,
|
||||
) => {
|
||||
log.debug("[rtc] [sdp] remote offer", sdp);
|
||||
log.debug("[rtc] [sdp] remote SDP", sdp);
|
||||
|
||||
try {
|
||||
const offer = new RTCSessionDescription(sdp);
|
||||
|
|
@ -172,22 +214,23 @@ export const webrtc = {
|
|||
|
||||
flushRemoteCandidates();
|
||||
|
||||
if (_initiator) return;
|
||||
|
||||
try {
|
||||
const answer = await pc.createAnswer();
|
||||
answer.sdp = enableOpusStereo(answer.sdp);
|
||||
await pc.setLocalDescription(answer);
|
||||
log.debug("[rtc] [sdp] local answer", answer);
|
||||
pub(WEBRTC_SDP_LOCAL, answer);
|
||||
handleSdpAnswer(answer);
|
||||
} catch (e) {
|
||||
log.error(`[rtc] [sdp] local answer error: ${e}`);
|
||||
}
|
||||
},
|
||||
pushChannel,
|
||||
addCandidate: (
|
||||
/** @type {RTCLocalIceCandidateInit | string} */ candidate,
|
||||
) => {
|
||||
const allowed = pc.remoteDescription !== null;
|
||||
|
||||
if (allowed) {
|
||||
if (hasRemoteDescription()) {
|
||||
addRemoteCandidate(candidate);
|
||||
} else {
|
||||
candidateBuf.push(candidate);
|
||||
|
|
@ -206,35 +249,18 @@ export const webrtc = {
|
|||
if (!isConnected()) return Promise.resolve();
|
||||
return await pc.getStats();
|
||||
},
|
||||
stop: () => {
|
||||
if (stream) {
|
||||
while (stream.getTracks().length > 0) {
|
||||
const t = stream.getTracks()[0];
|
||||
t.stop();
|
||||
stream.removeTrack(t);
|
||||
}
|
||||
stream = null;
|
||||
createDataChannel: ({ onChannel }) => {
|
||||
try {
|
||||
let ch = pc.createDataChannel("data", {
|
||||
ordered: false,
|
||||
maxRetransmits: 0,
|
||||
});
|
||||
ch = onChannel ? onChannel(ch) : ch;
|
||||
if (!ch) throw new Error("null channel");
|
||||
channels.set(ch.label, ch);
|
||||
} catch (e) {
|
||||
log.error("[rtc] failed to create data channel", e);
|
||||
}
|
||||
if (pc) {
|
||||
pc.oniceconnectionstatechange = null;
|
||||
pc.onicegatheringstatechange = null;
|
||||
pc.onicecandidate = null;
|
||||
pc.onicecandidateerror = null;
|
||||
pc.onconnectionstatechange = null;
|
||||
pc.ondatachannel = null;
|
||||
pc.ontrack = null;
|
||||
pc.close();
|
||||
pc = null;
|
||||
}
|
||||
|
||||
for (const [, channel] of channels) {
|
||||
channel.close();
|
||||
}
|
||||
channels.clear();
|
||||
candidateBuf = [];
|
||||
log.debug("[rtc] WebRTC has been closed");
|
||||
},
|
||||
set modDataChannel(fn) {
|
||||
modDataChannel = fn;
|
||||
},
|
||||
stop,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue