summaryrefslogtreecommitdiff
path: root/priv/static/room.js
diff options
context:
space:
mode:
authorArjun Satarkar <me@arjunsatarkar.net>2024-07-17 13:41:06 +0000
committerArjun Satarkar <me@arjunsatarkar.net>2024-07-17 13:41:06 +0000
commit06f7696c0976c75c13435f84a2101c1203c18b95 (patch)
tree9ea52c784f4bc5013c8e8af32e82c0a497889b32 /priv/static/room.js
downloadmediasync-06f7696c0976c75c13435f84a2101c1203c18b95.tar
mediasync-06f7696c0976c75c13435f84a2101c1203c18b95.tar.gz
mediasync-06f7696c0976c75c13435f84a2101c1203c18b95.zip
Initial commit
Diffstat (limited to 'priv/static/room.js')
-rw-r--r--priv/static/room.js148
1 files changed, 148 insertions, 0 deletions
diff --git a/priv/static/room.js b/priv/static/room.js
new file mode 100644
index 0000000..85ef9d3
--- /dev/null
+++ b/priv/static/room.js
@@ -0,0 +1,148 @@
+"use strict";
+
+(() => {
+ const randomBackoffMilliseconds = (lowest, highest) => {
+ return Math.round(Math.random() * (highest - lowest) + lowest);
+ };
+
+ const prepareInitialInfoMessage = () => {
+ const dataView = new DataView(new ArrayBuffer(1));
+ dataView.setUint8(0, "i".charCodeAt(0));
+ return dataView;
+ };
+
+ const prepareStateUpdateMessage = (positionMilliseconds, paused) => {
+ const dataView = new DataView(new ArrayBuffer(10));
+ dataView.setUint8(0, "s".charCodeAt(0));
+ dataView.setUint8(1, +paused);
+ dataView.setBigUint64(2, positionMilliseconds);
+ return dataView;
+ };
+
+ const player = videojs("player", {
+ controls: true,
+ fill: true,
+ playsinline: true,
+ preload: "auto",
+ });
+
+ const updatePlaybackState = (latestReceivedState, nowMilliseconds) => {
+ if (nowMilliseconds - latestReceivedState.receivedAtMilliseconds > 2000) {
+ player.pause();
+ return;
+ }
+
+ const idealPositionMilliseconds =
+ latestReceivedState.positionMilliseconds +
+ (nowMilliseconds - latestReceivedState.receivedAtMilliseconds);
+ const currentPositionMilliseconds = player.currentTime() * 1000;
+ const positionDiffMilliseconds = currentPositionMilliseconds - idealPositionMilliseconds;
+ const absPositionDiffMilliseconds = Math.abs(positionDiffMilliseconds);
+
+ if (absPositionDiffMilliseconds > 1250) {
+ player.currentTime(idealPositionMilliseconds / 1000);
+ player.playbackRate(1);
+ } else if (
+ absPositionDiffMilliseconds > 200 ||
+ (player.playbackRate() != 1 && absPositionDiffMilliseconds > 100)
+ ) {
+ player.playbackRate(1 - 0.02 * Math.sign(positionDiffMilliseconds));
+ } else {
+ player.playbackRate(1);
+ }
+
+ if (latestReceivedState.paused) {
+ player.pause();
+ player.currentTime(idealPositionMilliseconds / 1000);
+ } else {
+ player.play().then(null, () => {
+ // Failed to play - try muting in case it's because the browser is blocking autoplay
+ player.muted(true);
+ player.play().then(null, () => console.error("Failed to play video."));
+ });
+ }
+ };
+
+ const latestReceivedState = {
+ paused: true,
+ positionMilliseconds: 0,
+ receivedAtMilliseconds: null,
+ };
+
+ const manageWebsocket = () => {
+ let websocket = new WebSocket(location.href.replace(/^http/, "ws") + "/websocket");
+ websocket.binaryType = "arraybuffer";
+
+ let initialized = false;
+ let host;
+
+ // Interval to check video state for non-hosts, and to send state for host
+ let intervalId;
+
+ websocket.addEventListener("open", (_) => {
+ console.debug("Created WebSocket connection successfully.");
+ websocket.send(prepareInitialInfoMessage());
+ });
+
+ websocket.addEventListener("message", (event) => {
+ const messageDataView = new DataView(event.data);
+ switch (String.fromCharCode(messageDataView.getUint8(0))) {
+ case "i":
+ if (initialized) {
+ websocket.close(); // Error condition: we're already initialized
+ } else {
+ initialized = true;
+ host = !!messageDataView.getUint8(1);
+
+ // How often host sends state - unused on non-host clients
+ const SEND_STATE_INTERVAL_MILLISECONDS = 250;
+ // How often client checks if its state matches what the server sent - unused on hosts
+ const CHECK_STATE_INTERVAL_MILLISECONDS = 20;
+
+ if (host) {
+ intervalId = setInterval(() => {
+ websocket.send(
+ prepareStateUpdateMessage(
+ BigInt(Math.round(player.currentTime() * 1000)),
+ player.paused(),
+ ),
+ );
+ }, SEND_STATE_INTERVAL_MILLISECONDS);
+ } else {
+ intervalId = setInterval(() => {
+ updatePlaybackState(latestReceivedState, performance.now());
+ }, CHECK_STATE_INTERVAL_MILLISECONDS);
+ }
+ }
+ break;
+ case "s":
+ if (host || !initialized) {
+ /* Error conditions: host should send state updates, not receive
+ them, and the server should not send us state updates until
+ we're initialized. */
+ websocket.close();
+ } else {
+ latestReceivedState.paused = messageDataView.getUint8(1);
+ latestReceivedState.positionMilliseconds = Number(messageDataView.getBigUint64(2));
+ latestReceivedState.receivedAtMilliseconds = performance.now();
+ }
+ break;
+ default:
+ websocket.close(); // Error condition: unrecognized message type
+ }
+ });
+
+ websocket.addEventListener("close", (_) => {
+ clearInterval(intervalId);
+ player.pause();
+ const recreateAfter = randomBackoffMilliseconds(50, 3000);
+ console.debug(
+ `WebSocket connection closed; will attempt to recreate it in ${recreateAfter} ms.`,
+ );
+ websocket = null;
+ setTimeout(manageWebsocket, recreateAfter);
+ });
+ };
+
+ manageWebsocket();
+})();