From 56955027f9ad032cac8c4c0e77f9d5109e6d9666 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Sat, 30 May 2026 00:02:19 +0200 Subject: [PATCH] Fix critical sync lockouts, zombie disconnects, play-seek debounces, active lobbies, SW keep-alives, branding matches and ESLint warnings --- eslint.config.js => eslint.config.mjs | 0 extension/background.js | 52 +++++++++++++++++++++++++++ extension/bridge.js | 1 + extension/content.js | 43 +++++++++++++++++++++- extension/popup.js | 29 ++++++++++----- server/index.js | 31 ++++++++++++++-- 6 files changed, 144 insertions(+), 12 deletions(-) rename eslint.config.js => eslint.config.mjs (100%) diff --git a/eslint.config.js b/eslint.config.mjs similarity index 100% rename from eslint.config.js rename to eslint.config.mjs diff --git a/extension/background.js b/extension/background.js index a379eec..ecf6c94 100644 --- a/extension/background.js +++ b/extension/background.js @@ -18,6 +18,17 @@ let isNamespaceJoined = false; let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] }; let localSeq = 0; // Monotonically increasing command sequence for this peer const lastSeqBySender = {}; // senderId → last received seq (stale command guard) +const activePorts = new Set(); // New: track active content ports for keep-alive + +// --- Keep-Alive Port Listener --- +chrome.runtime.onConnect.addListener((port) => { + if (port.name === 'keepAlive') { + activePorts.add(port); + port.onDisconnect.addListener(() => { + activePorts.delete(port); + }); + } +}); function _persistLastSeq() { if (storageInitialized) chrome.storage.session.set({ lastSeqBySender }); @@ -562,9 +573,47 @@ function handleServerEvent(event, data) { currentRoom = data; if (currentRoom && Array.isArray(currentRoom.peers)) { currentRoom.peers = currentRoom.peers.map(p => typeof p === 'object' ? createPeerData(p) : { peerId: p, username: null, tabTitle: null, mediaTitle: null, playbackState: null, currentTime: null, volume: null, muted: null, lastHeartbeat: Date.now() }); + + // Clear sequence tracking for peers that are no longer in the room + const activePeerIds = new Set(currentRoom.peers.map(p => typeof p === 'object' ? p.peerId : p)); + Object.keys(lastSeqBySender).forEach(pId => { + if (!activePeerIds.has(pId)) { + delete lastSeqBySender[pId]; + } + }); + _persistLastSeq(); } else if (currentRoom) { currentRoom.peers = []; } + + // Recover server-tracked active Episode Lobby if present + if (data && data.activeLobby && !episodeLobby) { + episodeLobby = { + expectedTitle: data.activeLobby.expectedTitle, + initiatorPeerId: data.activeLobby.initiatorPeerId, + readyPeers: data.activeLobby.readyPeers, + createdAt: Date.now() + }; + persistEpisodeLobby(); + broadcastLobbyUpdate(); + addLog(`Recovered active episode lobby from server: "${episodeLobby.expectedTitle}"`, 'info'); + + // Notify content script to start polling + if (currentTabId) { + const tabId = parseInt(currentTabId); + if (!isNaN(tabId)) { + chrome.tabs.sendMessage(tabId, { + type: 'EPISODE_LOBBY', + expectedTitle: episodeLobby.expectedTitle + }).catch(() => {}); + } + } + + // Schedule timeout if we don't already have one + if (!episodeLobbyTimeout) { + episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), EPISODE_LOBBY_TIMEOUT); + } + } if (storageInitialized) chrome.storage.session.set({ currentRoom }); addLog(`Joined Room: ${data?.roomId || 'unknown'}`, 'success'); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: data.peers }).catch(() => {}); @@ -716,6 +765,9 @@ function handleServerEvent(event, data) { if (!Array.isArray(currentRoom.peers)) currentRoom.peers = []; if (data.status === 'joined') { if (!currentRoom.peers.find(p => (p.peerId || p) === data.peerId)) { + delete lastSeqBySender[data.peerId]; + _persistLastSeq(); + currentRoom.peers.push(createPeerData(data)); if (storageInitialized) chrome.storage.session.set({ currentRoom }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); diff --git a/extension/bridge.js b/extension/bridge.js index 94045e6..cbc56f0 100644 --- a/extension/bridge.js +++ b/extension/bridge.js @@ -1,3 +1,4 @@ +/* global cloneInto */ /** * KoalaSync Bridge Script * Injected into sync.koalastuff.net to facilitate communication between diff --git a/extension/content.js b/extension/content.js index 42b2083..72103ee 100644 --- a/extension/content.js +++ b/extension/content.js @@ -383,6 +383,18 @@ } } else if (action === EVENTS.FORCE_SYNC_EXECUTE) { stopLobbyPoll(); + + // Network Latency Compensation: pre-seek video by network transit latency + const video = findVideo(); + if (video && message.actionTimestamp) { + const latency = (Date.now() - message.actionTimestamp) / 1000; + if (latency > 0.05 && latency < 5.0) { + _setSuppress('seek'); + video.currentTime += latency; + reportLog(`Force Sync: Compensated ${Math.round(latency * 1000)}ms network latency`, 'info'); + } + } + tryMediaAction(EVENTS.PLAY); chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId }); actionCompleted = true; @@ -469,6 +481,17 @@ // Detect native events function reportEvent(action) { + if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) { + clearTimeout(seekDebounceTimer); + seekDebounceTimer = null; + const v = findVideo(); + if (v && Number.isFinite(v.currentTime)) { + lastReportedSeekTime = v.currentTime; + reportLog(`[Seek] Debounce flushed immediately due to ${action.toUpperCase()}`, 'info'); + reportEvent(EVENTS.SEEK); + } + } + const video = findVideo(); if (!video) return; @@ -512,7 +535,7 @@ // pause immediately after switching back. let pageVisible = !document.hidden; let visibilityGraceUntil = 0; - const VISIBILITY_GRACE_MS = 1000; + const VISIBILITY_GRACE_MS = 300; document.addEventListener('visibilitychange', () => { if (document.hidden) { @@ -705,6 +728,24 @@ // Initial Setup setupListeners(); + + // Maintain a persistent keep-alive port connection to prevent background SW suspension + let keepAlivePort = null; + function connectKeepAlivePort() { + try { + if (chrome.runtime.id) { + keepAlivePort = chrome.runtime.connect({ name: 'keepAlive' }); + keepAlivePort.onDisconnect.addListener(() => { + keepAlivePort = null; + setTimeout(connectKeepAlivePort, 1000); + }); + } + } catch (_e) { + // Extension context invalidated or disabled + } + } + connectKeepAlivePort(); + schedulePeriodicHeartbeat(); // Immediate heartbeat on injection — populate peer data without waiting 15s diff --git a/extension/popup.js b/extension/popup.js index 5eaa412..d7a13f1 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -563,7 +563,9 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) { const hostname = new URL(tab.url).hostname.toLowerCase(); if (domain.endsWith('.')) return hostname.startsWith(domain) || hostname.includes('.' + domain); if (domain.includes('.')) return hostname === domain || hostname.endsWith('.' + domain); - } catch {} + } catch { + /* ignore invalid URLs */ + } return urlStr.includes(domain); })) return false; } @@ -571,23 +573,32 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) { }); // Smart Matching Logic — exclude own tabTitle to prevent self-match (computed once) + const cleanTitle = (rawTitle) => { + if (!rawTitle) return ''; + return rawTitle + .replace(/(?:\s*[-\|•]\s*(?:YouTube|Twitch|Jellyfin|Emby|Netflix|Vimeo|Dailymotion).*)$/i, '') + .replace(/^(?:Netflix|Twitch|YouTube|Emby|Jellyfin)\s*[-\|•]\s*/i, '') + .trim(); + }; + const peerTitles = peerIds .filter(p => (typeof p === 'object' ? p.peerId : p) !== localPeerId) .map(p => (typeof p === 'object' ? p.tabTitle : null)) - .filter(t => t && t.length > 3); + .filter(t => t && t.length > 3) + .map(t => cleanTitle(t).toLowerCase()) + .filter(t => t.length > 3); filteredTabs.forEach(tab => { const option = document.createElement('option'); option.value = tab.id; - const title = (tab.title || 'Loading...'); + const rawTitle = (tab.title || 'Loading...'); + const title = cleanTitle(rawTitle).toLowerCase(); - const isMatch = peerTitles.some(pt => { - const t1 = title.toLowerCase(); - const t2 = pt.toLowerCase(); - return t1.includes(t2) || t2.includes(t1); + const isMatch = title.length > 3 && peerTitles.some(pt => { + return title.includes(pt) || pt.includes(title); }); - let label = title.substring(0, 45) + (title.length > 45 ? '...' : ''); + let label = rawTitle.substring(0, 45) + (rawTitle.length > 45 ? '...' : ''); if (isMatch) { label = `⭐ MATCH: ${label}`; option.style.fontWeight = 'bold'; @@ -992,7 +1003,7 @@ function handleCreateRoom() { // Auto-connect elements.joinBtn.click(); -}; +} elements.createRoomBtn.addEventListener('click', handleCreateRoom); const syncTabCreateRoomBtn = document.getElementById('syncTabCreateRoomBtn'); diff --git a/server/index.js b/server/index.js index ba4c217..e64ffa5 100644 --- a/server/index.js +++ b/server/index.js @@ -196,7 +196,18 @@ function removePeerFromRoom(socketId, roomId, reason) { // 3. Notify remaining peers (use io.to so the removed socket itself // doesn't receive it — it has already left or is disconnecting) - io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' }); + const isPeerStillConnected = Array.from(room.peerData.values()).some(data => data.peerId === peerId); + if (!isPeerStillConnected) { + io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' }); + } + + // 3.5. Clean up active lobby if a peer leaves + if (room.activeLobby) { + room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId); + if (room.activeLobby.readyPeers.length <= 1 || room.activeLobby.initiatorPeerId === peerId) { + room.activeLobby = null; // Dissolve lobby + } + } // 4. Delete empty room if (room.peers.size === 0) { @@ -401,7 +412,8 @@ io.on('connection', (socket) => { socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, mediaTitle: mediaTitle || null, status: 'joined' }); socket.emit(EVENTS.ROOM_DATA, { roomId, - peers: Array.from(room.peers).map(sid => room.peerData.get(sid)) + peers: Array.from(room.peers).map(sid => room.peerData.get(sid)), + activeLobby: room.activeLobby || null }); log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`); } catch (err) { @@ -477,6 +489,21 @@ io.on('connection', (socket) => { // Strip undefined keys for clean wire format Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]); socket.to(mapping.roomId).emit(eventName, relayPayload); + + // --- Side-effects: Server-side Episode Lobby Tracking --- + if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle) { + room.activeLobby = { + expectedTitle: relayPayload.expectedTitle, + initiatorPeerId: mapping.peerId, + readyPeers: [mapping.peerId] + }; + } else if (eventName === EVENTS.EPISODE_READY && room.activeLobby) { + if (!room.activeLobby.readyPeers.includes(mapping.peerId)) { + room.activeLobby.readyPeers.push(mapping.peerId); + } + } else if ((eventName === EVENTS.FORCE_SYNC_PREPARE || eventName === EVENTS.FORCE_SYNC_EXECUTE) && room.activeLobby) { + room.activeLobby = null; + } } } } catch (err) {