import io, { Socket } from "socket.io-client"; import { Config, Room, RoomOptions, Timeline } from "../types/types"; import { Sub, useSub } from "../util/sub"; class Connection { url = "/"; socket: Socket; isConnected = new Sub(); config = new Sub(); room = new Sub(); timeline = new Sub(); timeSyncIntervals = [500, 1000, 3000, 5000, 10000, 30000]; timeSyncs: { [requestId: number]: TimeSyncRequest } = {}; timeSyncTimeoutIds: number[] = []; timeSyncTooOld = 120000; roomTime = new Sub(); constructor() { this.isConnected.set(false); this.socket = io(this.url, { autoConnect: false, transports: ["websocket"], }); this.setupSocketListeners(); this.connect(); this.roomTime.set(0); } connect() { this.socket.connect(); } setupSocketListeners() { this.socket.on("connect", async () => { this.isConnected.set(true); await this.onConnect(); }); this.socket.on("disconnect", () => { this.isConnected.set(false); this.onDisconnect(); }); this.socket.on("config", (data: any) => { this.config.set(data.config); }); this.socket.on("time_sync", (data: any) => { this.timeSyncResponse(data.requestId, data.clientDiff, data.serverTime); }); this.socket.on("room", (data: any) => { if (data.room) { this.setQueryLobbyId(data.room.id); } this.room.set(data.room); }); this.socket.on("timeline", (data: any) => { if (data.timeline) { this.timeline.set(new Timeline(data.timeline)); } else { this.timeline.set(null); } }); } async onConnect() { this.startTimeSync(); const lobbyId = this.getQueryLobbyId(); if (lobbyId) { const exists = await this.requestJoin(lobbyId); if (!exists) { this.setQueryLobbyId(); this.requestJoinRandom(); } } else { this.requestJoinRandom(); } } onDisconnect() { this.stopTimeSync(); } private getQueryLobbyId(): number | null { const query = new URLSearchParams(window.location.search); const lobby = query.get("lobby"); if (!lobby) { return null; } const lobbyId = Number.parseInt(query.get("lobby")!.toString()); if (!Number.isSafeInteger(lobbyId) || lobbyId < 1) { return null; } return lobbyId; } private setQueryLobbyId(lobbyId?: number) { const newUrl = new URL(window.location.href); if (lobbyId) { newUrl.searchParams.set("lobby", String(lobbyId)); } else { newUrl.searchParams.delete("lobby"); } window.history.pushState({}, "", newUrl.toString()); } setRoomOptions(roomOptions: RoomOptions) { this.socket.emit("room_options", roomOptions); } requestStart() { this.socket.emit("request_start"); } submitTickerMessage(message: string) { return new Promise((resolve, reject) => { this.socket.emit( "submit_ticker_message", message, (_: null, err?: string) => { if (err) { reject(err); } else { resolve(); } } ); }); } async requestJoin(roomId: number): Promise { return new Promise((resolve, reject) => { this.socket.emit( "request_join", roomId, (err?: string, didJoinRoom?: boolean) => { if (err) { return reject(err); } return resolve(!!didJoinRoom); } ); }); } requestSetReady() { this.socket.emit("request_set_ready"); } requestJoinRandom() { this.socket.emit("request_join_random"); } startTimeSync() { for (let i = 0; i < this.timeSyncIntervals.length; i++) { const timeoutId = setTimeout(() => { // Only reschedule the last sync interval (i.e. every 30 seconds) const shouldReschedule = i === this.timeSyncIntervals.length - 1; this.sendTimeSync(shouldReschedule); }, this.timeSyncIntervals[i]); this.timeSyncTimeoutIds.push(timeoutId); } } stopTimeSync() { for (let i = 0; i < this.timeSyncTimeoutIds.length; i++) { clearTimeout(this.timeSyncTimeoutIds[i]); } } sendTimeSync(alsoSchedule: boolean) { const sync = new TimeSyncRequest(); this.socket.emit("time_sync", sync.requestId, Date.now()); this.timeSyncs[sync.requestId] = sync; if (alsoSchedule) { setTimeout(() => { this.sendTimeSync(true); }, this.timeSyncIntervals[this.timeSyncIntervals.length - 1]); } } timeSyncResponse(requestId: number, clientDiff: number, serverTime: number) { const syncReq = this.timeSyncs[requestId]; if (!syncReq) { return; } syncReq.response(clientDiff, serverTime); for (const i in this.timeSyncs) { if (this.timeSyncs[i].start < Date.now() - this.timeSyncTooOld) { delete this.timeSyncs[i]; break; } } this.roomTime.set(calculateRoomTime()); } serverTime(): number { return Date.now() + this.serverTimeOffset(); } serverTimeOffset(): number { let num = 0; let sum = 0; for (const i in this.timeSyncs) { const sync = this.timeSyncs[i]; if (!sync.ready) continue; sum += sync.offset; num += 1; } if (num === 0) { return 0; } return Math.round(sum / num); } } let _timeSyncId = 0; class TimeSyncRequest { requestId: number; start: number; offset = 0; ready = false; constructor() { this.requestId = _timeSyncId++; this.start = Date.now(); } response(clientDiff: number, serverTime: number) { this.ready = true; const now = Date.now(); const lag = now - this.start; this.offset = serverTime - now + lag / 2; // console.log('TIME SYNC', 'cdiff:', clientDiff, 'lag:', // lag, 'diff:', serverTime - now, 'offset:', this.offset); } } const connection: Connection = new Connection(); export default connection; export function useRoom(): Room | null { return useSub(connection.room); } export function useConfig(): Config | null { return useSub(connection.config); } export function useRoomRunningAndReadyChanged(): Room | null { return useSub(connection.room, (v) => [v, v?.running, v?.readyToParticipate]); } export function useTimeline(): Timeline | null { return useSub(connection.timeline); } export function useTimelineSongFileChanged(): Timeline | null { return useSub(connection.timeline, (v) => [v?.songFile]); } export function useRoomTime(): number { return useSub(connection.roomTime) || 0; } /** * Calculates the current room time, adjusted for any possible server time * offset and lag. */ export function calculateRoomTime(): number { const room = connection.room.get(); if (!room || typeof room.startTime === "undefined") { return 0; } return (connection.serverTime() - room.startTime) * room.speedFactor; } export function useIsConnected(): boolean { return useSub(connection.isConnected) || false; }