From 55c2d4ed0df1b334d8a942405203fb2c2cd9a747 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:43:10 +0200 Subject: [PATCH] feat: Auto-Sync Next Episode v1.2.0 Adds a new toggleable feature that detects episode transitions via mediaTitle mutation (loadeddata/MutationObserver), pauses the video, and waits for all room peers to load the same episode before executing a coordinated Force Sync play at 0:00. Protocol: - Add EPISODE_LOBBY and EPISODE_READY events to shared/constants.js - Add EPISODE_LOBBY_TIMEOUT (60s) constant - Relay both new events in server/index.js Content Script (content.js): - Layered detection: loadeddata + MutationObserver src-change + heartbeat - Debounced onEpisodeTransition() sends signal ONLY; no eager pause - PAUSE_FOR_LOBBY handler pauses only after background confirms feature enabled - startLobbyPoll() polls title match without premature pause for non-initiators - checkAndReportLobbyReady() pauses and sends EPISODE_READY_LOCAL on match - CONTENT_BOOT recovery for re-injection after hard navigation Background (background.js): - Episode lobby state persisted in chrome.storage.session with recovery - EPISODE_CHANGED: checks setting, creates lobby, sends PAUSE_FOR_LOBBY to tab - EPISODE_LOBBY/READY server event handlers with dedup logic - 60s timeout cancels lobby (Option B) with Chrome failure notification - Peer departure handled: removes from readyPeers, re-checks completion - executeEpisodeLobby() reuses existing Force Sync pipeline at targetTime 0.0 - Lobby cleared on LEAVE_ROOM; status exposed in GET_STATUS Popup: - Auto-Sync Next Episode toggle in Settings tab (default: off, opt-in) - Episode Lobby status card in Sync tab with peer readiness display - LOBBY_UPDATE message handler for real-time UI updates Bumps APP_VERSION and manifest to 1.2.0 --- extension/background.js | 265 +++++++++++++++++++++++++++++++++++++++- extension/content.js | 162 +++++++++++++++++++++++- extension/manifest.json | 2 +- extension/popup.html | 16 +++ extension/popup.js | 65 +++++++++- server/index.js | 3 +- shared/constants.js | 9 +- 7 files changed, 511 insertions(+), 11 deletions(-) diff --git a/extension/background.js b/extension/background.js index bc27dc3..2ce8651 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,4 +1,4 @@ -import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION } from './shared/constants.js'; +import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION, EPISODE_LOBBY_TIMEOUT } from './shared/constants.js'; // --- State Management --- let socket = null; @@ -29,7 +29,8 @@ function ensureState() { chrome.storage.session.get([ 'logs', 'history', 'currentRoom', 'lastActionState', 'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks', - 'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle' + 'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle', + 'episodeLobby' ], (data) => { if (data.currentTabId !== undefined) currentTabId = data.currentTabId; if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle; @@ -66,6 +67,17 @@ function ensureState() { } } + // Recover Episode Lobby + if (data.episodeLobby && !episodeLobby) { + episodeLobby = data.episodeLobby; + const lobbyRemaining = (episodeLobby.createdAt + EPISODE_LOBBY_TIMEOUT) - Date.now(); + if (lobbyRemaining > 0) { + episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), lobbyRemaining); + } else { + cancelEpisodeLobby('Timeout (recovered)'); + } + } + storageInitialized = true; // Process any early logs/history that weren't captured in the spread @@ -99,6 +111,10 @@ let isForceSyncInitiator = false; let forceSyncAcks = new Set(); let forceSyncTimeout = null; +// Episode Auto-Sync Lobby +let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt } +let episodeLobbyTimeout = null; + // --- Storage Utils --- /** @@ -531,6 +547,11 @@ function handleServerEvent(event, data) { currentRoom.peers = currentRoom.peers.filter(p => (p.peerId || p) !== data.peerId); if (storageInitialized) chrome.storage.session.set({ currentRoom }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); + + // Episode Lobby: Handle peer departure + if (episodeLobby) { + checkEpisodeLobbyPeerDeparture(); + } } else { // Heartbeat/Update: Update tabTitle for matching const peer = currentRoom.peers.find(p => (p.peerId || p) === data.peerId); @@ -555,6 +576,51 @@ function handleServerEvent(event, data) { } } break; + case EVENTS.EPISODE_LOBBY: + if (data.senderId && data.expectedTitle) { + addLog(`Episode lobby from ${data.senderId}: "${data.expectedTitle}"`, 'info'); + // If we already have a lobby for this same title, treat as dedup + if (episodeLobby && episodeLobby.expectedTitle === data.expectedTitle) { + break; // Already tracking this lobby + } + // Cancel any existing lobby before starting a new one + if (episodeLobby) clearEpisodeLobbyState(); + + episodeLobby = { + expectedTitle: data.expectedTitle, + initiatorPeerId: data.senderId, + readyPeers: [], + createdAt: Date.now() + }; + persistEpisodeLobby(); + broadcastLobbyUpdate(); + + // Start timeout + episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), EPISODE_LOBBY_TIMEOUT); + + // Forward to content script to start polling + if (currentTabId) { + const tabId = parseInt(currentTabId); + if (!isNaN(tabId)) { + chrome.tabs.sendMessage(tabId, { + type: 'EPISODE_LOBBY', + expectedTitle: data.expectedTitle + }).catch(() => {}); + } + } + } + break; + case EVENTS.EPISODE_READY: + if (episodeLobby && data.senderId) { + if (!episodeLobby.readyPeers.includes(data.senderId)) { + episodeLobby.readyPeers.push(data.senderId); + persistEpisodeLobby(); + broadcastLobbyUpdate(); + addLog(`Episode ready from ${data.senderId} (${episodeLobby.readyPeers.length})`, 'info'); + checkEpisodeLobbyCompletion(); + } + } + break; default: addLog(`Received unknown event from server: ${event}`, 'warn'); break; @@ -575,6 +641,102 @@ function executeForceSync() { addLog('Force Sync Executed', 'success'); } +// --- Episode Auto-Sync Lobby Functions --- +function persistEpisodeLobby() { + if (storageInitialized) chrome.storage.session.set({ episodeLobby }); +} + +function broadcastLobbyUpdate() { + chrome.runtime.sendMessage({ type: 'LOBBY_UPDATE', lobby: episodeLobby }).catch(() => {}); +} + +function clearEpisodeLobbyState() { + if (episodeLobbyTimeout) clearTimeout(episodeLobbyTimeout); + episodeLobbyTimeout = null; + episodeLobby = null; + if (storageInitialized) chrome.storage.session.set({ episodeLobby: null }); + broadcastLobbyUpdate(); + + // Notify content script to stop polling + if (currentTabId) { + const tabId = parseInt(currentTabId); + if (!isNaN(tabId)) { + chrome.tabs.sendMessage(tabId, { type: 'EPISODE_LOBBY_CANCEL' }).catch(() => {}); + } + } +} + +function cancelEpisodeLobby(reason) { + if (!episodeLobby) return; + const title = episodeLobby.expectedTitle; + clearEpisodeLobbyState(); + addLog(`Episode lobby cancelled: ${reason} for "${title}"`, 'warn'); + + // Chrome notification on failure (per Q2: only notify on failure) + chrome.notifications.create(`episode_${Date.now()}`, { + type: 'basic', + iconUrl: 'icons/icon128.png', + title: 'KoalaSync — Episode Sync Failed', + message: `Auto-sync cancelled: ${reason}. You may need to manually sync.`, + priority: 1 + }); +} + +function executeEpisodeLobby() { + if (!episodeLobby) return; + const title = episodeLobby.expectedTitle; + clearEpisodeLobbyState(); + addLog(`Episode lobby complete: Starting "${title}" via Force Sync`, 'success'); + + // Trigger a standard Force Sync at targetTime 0.0 + isForceSyncInitiator = true; + forceSyncAcks.clear(); + const deadline = Date.now() + 5000; + chrome.storage.session.set({ + isForceSyncInitiator: true, + forceSyncAcks: [], + forceSyncDeadline: deadline + }); + + const syncPayload = { targetTime: 0.0 }; + emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId }); + routeToContent(EVENTS.FORCE_SYNC_PREPARE, syncPayload); + + forceSyncTimeout = setTimeout(() => { + if (isForceSyncInitiator) { + addLog('Force Sync (Episode): Timeout waiting for ACKs, executing anyway...', 'warn'); + executeForceSync(); + } + }, 5000); +} + +function checkEpisodeLobbyCompletion() { + if (!episodeLobby || !currentRoom) return; + const peerCount = currentRoom.peers ? currentRoom.peers.length : 1; + if (episodeLobby.readyPeers.length >= peerCount) { + executeEpisodeLobby(); + } +} + +function checkEpisodeLobbyPeerDeparture() { + if (!episodeLobby || !currentRoom) return; + const remainingPeerIds = currentRoom.peers.map(p => typeof p === 'object' ? p.peerId : p); + + // If only we remain, cancel the lobby + if (remainingPeerIds.length <= 1) { + cancelEpisodeLobby('All other peers left'); + return; + } + + // Filter readyPeers to only include peers still in the room + episodeLobby.readyPeers = episodeLobby.readyPeers.filter(id => remainingPeerIds.includes(id)); + persistEpisodeLobby(); + broadcastLobbyUpdate(); + + // Re-check if all remaining peers are now ready + checkEpisodeLobbyCompletion(); +} + function updateLastAction(action, senderId, timestamp = Date.now()) { lastActionState = { action, @@ -686,7 +848,8 @@ async function handleAsyncMessage(message, sender, sendResponse) { peerId, peers: currentRoom ? currentRoom.peers : [], lastActionState, - targetTabId: currentTabId + targetTabId: currentTabId, + episodeLobby: episodeLobby }); } else if (message.type === 'LEAVE_ROOM') { emit(EVENTS.LEAVE_ROOM, { peerId }); @@ -699,11 +862,15 @@ async function handleAsyncMessage(message, sender, sendResponse) { forceSyncAcks.clear(); if (forceSyncTimeout) clearTimeout(forceSyncTimeout); + // Cancel any active episode lobby + clearEpisodeLobbyState(); + chrome.storage.session.set({ currentRoom: null, isForceSyncInitiator: false, forceSyncAcks: [], - forceSyncDeadline: null + forceSyncDeadline: null, + episodeLobby: null }); addLog('Left Room', 'info'); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); @@ -890,6 +1057,96 @@ async function handleAsyncMessage(message, sender, sendResponse) { } else if (message.type === 'LOG') { addLog(`[Content] ${message.message}`, message.level || 'info'); sendResponse({ status: 'ok' }); + } else if (message.type === 'EPISODE_CHANGED') { + // Content script detected an episode transition + if (sender.tab) { + const senderTabId = sender.tab.id; + if (!currentTabId || currentTabId !== senderTabId) { + sendResponse({ status: 'ignored_unselected_tab' }); + return; + } + } + + const newTitle = message.payload && message.payload.newTitle; + if (!newTitle) { + sendResponse({ status: 'no_title' }); + return; + } + + // Check setting + const epSettings = await chrome.storage.sync.get(['autoSyncNextEpisode']); + if (!epSettings.autoSyncNextEpisode) { + addLog(`Episode change detected ("${newTitle}") but Auto-Sync is disabled.`, 'info'); + sendResponse({ status: 'disabled' }); + return; + } + + // If lobby already exists for this title, just mark self ready + if (episodeLobby && episodeLobby.expectedTitle === newTitle) { + if (!episodeLobby.readyPeers.includes(peerId)) { + episodeLobby.readyPeers.push(peerId); + persistEpisodeLobby(); + broadcastLobbyUpdate(); + emit(EVENTS.EPISODE_READY, { peerId, title: newTitle }); + checkEpisodeLobbyCompletion(); + } + sendResponse({ status: 'ready_sent' }); + return; + } + + // Cancel any existing lobby for a different episode + if (episodeLobby) clearEpisodeLobbyState(); + + // Create new lobby + episodeLobby = { + expectedTitle: newTitle, + initiatorPeerId: peerId, + readyPeers: [peerId], // We are already ready + createdAt: Date.now() + }; + persistEpisodeLobby(); + broadcastLobbyUpdate(); + addLog(`Episode lobby created: "${newTitle}"`, 'info'); + + // Tell content script to pause the video and start polling + // (This is the only place we pause — after confirming the feature is enabled) + if (sender.tab && sender.tab.id) { + chrome.tabs.sendMessage(sender.tab.id, { + type: 'PAUSE_FOR_LOBBY', + expectedTitle: newTitle + }).catch(() => {}); + } + + // Broadcast to room + emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: newTitle }); + + // Start timeout (Q1: Option B — cancel on timeout) + episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout — not all peers loaded the episode'), EPISODE_LOBBY_TIMEOUT); + + // Immediate check — maybe we're the only one in the room + checkEpisodeLobbyCompletion(); + + sendResponse({ status: 'lobby_created' }); + } else if (message.type === 'EPISODE_READY_LOCAL') { + // Content script confirmed it loaded the lobby episode + if (episodeLobby && message.payload && message.payload.title === episodeLobby.expectedTitle) { + if (!episodeLobby.readyPeers.includes(peerId)) { + episodeLobby.readyPeers.push(peerId); + persistEpisodeLobby(); + broadcastLobbyUpdate(); + emit(EVENTS.EPISODE_READY, { peerId, title: message.payload.title }); + addLog(`Local episode ready: "${message.payload.title}"`, 'success'); + checkEpisodeLobbyCompletion(); + } + } + sendResponse({ status: 'ok' }); + } else if (message.type === 'CONTENT_BOOT') { + // Content script re-injected, check if there's an active lobby + if (episodeLobby) { + sendResponse({ lobbyActive: true, expectedTitle: episodeLobby.expectedTitle }); + } else { + sendResponse({ lobbyActive: false }); + } } else { // Final fallback to prevent channel hanging sendResponse({ error: 'unhandled_message' }); diff --git a/extension/content.js b/extension/content.js index 7a074bf..db9f6de 100644 --- a/extension/content.js +++ b/extension/content.js @@ -22,12 +22,20 @@ FORCE_SYNC_PREPARE: "force_sync_prepare", FORCE_SYNC_ACK: "force_sync_ack", FORCE_SYNC_EXECUTE: "force_sync_execute", - PEER_STATUS: "peer_status" + PEER_STATUS: "peer_status", + EPISODE_LOBBY: "episode_lobby", + EPISODE_READY: "episode_ready" }; let expectedEvents = new Set(); let expectedTimeouts = {}; + // --- Episode Auto-Sync State --- + let lastKnownMediaTitle = null; + let episodeTransitionDebounce = null; + let pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby) + let lobbyPollTimer = null; + function expectEvent(state) { expectedEvents.add(state); if (expectedTimeouts[state]) clearTimeout(expectedTimeouts[state]); @@ -47,6 +55,100 @@ return videos.length > 0 ? videos[0] : null; } + // --- Episode Auto-Sync: Detection --- + function getMediaTitle() { + return (navigator.mediaSession && navigator.mediaSession.metadata) + ? navigator.mediaSession.metadata.title + : null; + } + + function checkEpisodeTransition() { + const currentTitle = getMediaTitle(); + const video = findVideo(); + + // Only trigger if: we had a previous title, the title changed, + // a video exists, and we're near the start of new content. + if (lastKnownMediaTitle && currentTitle + && currentTitle !== lastKnownMediaTitle + && video + && video.currentTime < 5 + && video.readyState >= 1) { + onEpisodeTransition(currentTitle); + } + + // Always track the latest known title + if (currentTitle) lastKnownMediaTitle = currentTitle; + } + + function onEpisodeTransition(newTitle) { + // Debounce: prevent duplicate fires from multiple signals + if (episodeTransitionDebounce) return; + episodeTransitionDebounce = setTimeout(() => { + episodeTransitionDebounce = null; + }, 2000); + + reportLog(`Episode transition detected: "${newTitle}"`, 'info'); + + // Do NOT pause here. We notify background.js first. + // Background checks the setting; if enabled it creates a lobby + // and sends back PAUSE_FOR_LOBBY so we only freeze if the feature is on. + chrome.runtime.sendMessage({ + type: 'EPISODE_CHANGED', + payload: { newTitle } + }).catch(() => {}); + } + + function checkAndReportLobbyReady(expectedTitle) { + const video = findVideo(); + const currentTitle = getMediaTitle(); + + if (video && currentTitle && currentTitle === expectedTitle + && video.currentTime < 5 && video.readyState >= 1) { + // Match! Pause at start and report ready. + if (!video.paused) { + expectEvent('paused'); + video.pause(); + } + stopLobbyPoll(); + chrome.runtime.sendMessage({ + type: 'EPISODE_READY_LOCAL', + payload: { title: currentTitle } + }).catch(() => {}); + reportLog(`Episode lobby: Ready for "${currentTitle}"`, 'success'); + return true; + } + return false; + } + + function startLobbyPoll(expectedTitle) { + stopLobbyPoll(); + pendingLobbyTitle = expectedTitle; + + // NOTE: Do NOT pause here. Three callers reach this function: + // 1. PAUSE_FOR_LOBBY (initiator): already paused by that handler before calling us. + // 2. EPISODE_LOBBY (non-initiator): peer may still be on the PREVIOUS episode — pausing + // would freeze them mid-episode. The pause happens inside checkAndReportLobbyReady() + // only once their title actually matches. + // 3. CONTENT_BOOT recovery: same reasoning as (2). + + // Check immediately + if (checkAndReportLobbyReady(expectedTitle)) return; + + // Poll every 2 seconds — no log spam, internal only + lobbyPollTimer = setInterval(() => { + checkAndReportLobbyReady(expectedTitle); + }, 2000); + } + + + function stopLobbyPoll() { + pendingLobbyTitle = null; + if (lobbyPollTimer) { + clearInterval(lobbyPollTimer); + lobbyPollTimer = null; + } + } + // --- Helper: YouTube/Twitch specific actions --- function tryMediaAction(action, data) { const video = findVideo(); @@ -181,11 +283,45 @@ }); } } else if (action === EVENTS.FORCE_SYNC_EXECUTE) { + stopLobbyPoll(); // Clear any pending lobby on force sync tryMediaAction(EVENTS.PLAY); chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp }); } } + // Episode Auto-Sync: Lobby notification from background + if (message.type === 'EPISODE_LOBBY') { + const expectedTitle = message.expectedTitle; + if (expectedTitle) { + reportLog(`Episode lobby received: waiting for "${expectedTitle}"`, 'info'); + startLobbyPoll(expectedTitle); + } + sendResponse({ status: 'ok' }); + return true; + } + + // Episode Auto-Sync: Lobby cancelled by background + if (message.type === 'EPISODE_LOBBY_CANCEL') { + stopLobbyPoll(); + sendResponse({ status: 'ok' }); + return true; + } + + // Episode Auto-Sync: Background confirmed lobby created, pause the video + if (message.type === 'PAUSE_FOR_LOBBY') { + const video = findVideo(); + if (video && !video.paused) { + expectEvent('paused'); + video.pause(); + } + // Start lobby poll now that we know the feature is enabled + if (message.expectedTitle) { + startLobbyPoll(message.expectedTitle); + } + sendResponse({ status: 'ok' }); + return true; + } + if (message.type === 'GET_VIDEO_STATE') { const video = findVideo(); if (video) { @@ -262,18 +398,30 @@ let lastVideoSrc = null; + // Episode detection handler for loadeddata event + const handleLoadedData = () => { + checkEpisodeTransition(); + }; + function setupListeners() { const video = findVideo(); if (video) { video.removeEventListener('play', handlePlay); video.removeEventListener('pause', handlePause); video.removeEventListener('seeked', handleSeeked); + video.removeEventListener('loadeddata', handleLoadedData); video.addEventListener('play', handlePlay); video.addEventListener('pause', handlePause); video.addEventListener('seeked', handleSeeked); + video.addEventListener('loadeddata', handleLoadedData); video.dataset.koalaAttached = 'true'; lastVideoSrc = video.currentSrc || video.src; + + // Initialize episode tracking title on first attach + if (!lastKnownMediaTitle) { + lastKnownMediaTitle = getMediaTitle(); + } } } @@ -289,6 +437,10 @@ const currentSrc = video.currentSrc || video.src; if (!video.dataset.koalaAttached || (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc)) { + // If src changed, also check for episode transition + if (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc) { + checkEpisodeTransition(); + } setupListeners(); } } @@ -335,4 +487,12 @@ // Initial Setup setupListeners(); + // Episode Auto-Sync: Boot recovery — check if background has an active lobby + chrome.runtime.sendMessage({ type: 'CONTENT_BOOT' }, (res) => { + if (res && res.lobbyActive && res.expectedTitle) { + reportLog(`Boot: Active lobby detected for "${res.expectedTitle}"`, 'info'); + startLobbyPoll(res.expectedTitle); + } + }); + })(); diff --git a/extension/manifest.json b/extension/manifest.json index cb4330b..df902e0 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "KoalaSync", - "version": "1.1.4", + "version": "1.2.0", "description": "Synchronize video playback across different tabs and users.", "permissions": [ "storage", diff --git a/extension/popup.html b/extension/popup.html index a57edd6..29a4c0e 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -287,6 +287,16 @@
No recent commands
+ + + @@ -301,10 +311,16 @@ + +
+ + +

• Username helps others identify you.

• Noise filtering uses a blacklist to hide common non-video sites (e.g. Search, Social Media) from the Target Tab selector.

+

• Auto-Sync will pause and wait for all peers when an episode changes, then sync-start together.

diff --git a/extension/popup.js b/extension/popup.js index 6215fd2..8f09cbc 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -40,7 +40,11 @@ const elements = { peerListSync: document.getElementById('peerListSync'), videoDebug: document.getElementById('videoDebug'), playBtn: document.getElementById('playBtn'), - pauseBtn: document.getElementById('pauseBtn') + pauseBtn: document.getElementById('pauseBtn'), + autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'), + episodeLobbyCard: document.getElementById('episodeLobbyCard'), + lobbyTitle: document.getElementById('lobbyTitle'), + lobbyPeerStatus: document.getElementById('lobbyPeerStatus') }; let localPeerId = null; @@ -49,12 +53,13 @@ let lastPeersJson = null; // --- Initialization --- async function init() { // Load Settings - const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username']); + const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode']); elements.serverUrl.value = data.serverUrl || ''; elements.roomId.value = data.roomId || ''; elements.password.value = data.password || ''; elements.username.value = data.username || ''; elements.filterNoise.checked = data.filterNoise !== false; + elements.autoSyncNextEpisode.checked = !!data.autoSyncNextEpisode; // Set Version Info const versionEl = document.getElementById('appVersion'); @@ -83,6 +88,9 @@ async function init() { // Populate Tabs using the background's targetTabId await populateTabs(res.peers, res.targetTabId); + + // Render lobby status if active + if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers); } else { await populateTabs(); } @@ -630,6 +638,10 @@ elements.filterNoise.addEventListener('change', () => { }); }); +elements.autoSyncNextEpisode.addEventListener('change', () => { + chrome.storage.sync.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked }); +}); + elements.serverUrl.addEventListener('input', () => { chrome.storage.sync.set({ serverUrl: elements.serverUrl.value }); }); @@ -883,6 +895,15 @@ chrome.runtime.onMessage.addListener((msg) => { // Join failed: reset UI state updateUI(null, null); } + } else if (msg.type === 'LOBBY_UPDATE') { + // Episode lobby state changed + chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => { + if (res && res.peers) { + updateLobbyUI(msg.lobby, res.peers); + } else { + updateLobbyUI(msg.lobby, []); + } + }); } }); @@ -977,3 +998,43 @@ function refreshDebugInfo() { init(); setInterval(refreshLogs, 5000); + +// --- Episode Lobby UI --- +function updateLobbyUI(lobby, peers) { + if (!elements.episodeLobbyCard) return; + + if (!lobby) { + elements.episodeLobbyCard.style.display = 'none'; + return; + } + + elements.episodeLobbyCard.style.display = 'block'; + elements.lobbyTitle.textContent = `\u{1F3AC} Waiting for: "${lobby.expectedTitle}"`; + + // Build peer readiness list + const readySet = new Set(lobby.readyPeers || []); + const peerLines = []; + + if (peers && peers.length > 0) { + peers.forEach(p => { + const pId = typeof p === 'object' ? p.peerId : p; + const pName = (typeof p === 'object' && p.username) ? p.username : pId; + const isReady = readySet.has(pId); + const icon = isReady ? '\u2705' : '\u23f3'; + const label = isReady ? 'Ready' : 'Loading...'; + peerLines.push(`${icon} ${pName} \u2014 ${label}`); + }); + } + + if (peerLines.length > 0) { + elements.lobbyPeerStatus.textContent = peerLines.join(' | '); + } else { + elements.lobbyPeerStatus.textContent = 'Waiting for peers...'; + } + + // Show elapsed time + if (lobby.createdAt) { + const elapsed = Math.floor((Date.now() - lobby.createdAt) / 1000); + elements.lobbyPeerStatus.textContent += ` (${elapsed}s)`; + } +} diff --git a/server/index.js b/server/index.js index c7195c0..e4f5210 100644 --- a/server/index.js +++ b/server/index.js @@ -309,7 +309,8 @@ io.on('connection', (socket) => { const relayEvents = [ EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.PEER_STATUS, EVENTS.FORCE_SYNC_PREPARE, - EVENTS.FORCE_SYNC_ACK, EVENTS.FORCE_SYNC_EXECUTE + EVENTS.FORCE_SYNC_ACK, EVENTS.FORCE_SYNC_EXECUTE, + EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY ]; relayEvents.forEach(eventName => { diff --git a/shared/constants.js b/shared/constants.js index 7158f0e..2c128c3 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -7,7 +7,7 @@ */ export const PROTOCOL_VERSION = "1.0.0"; -export const APP_VERSION = "1.1.4"; +export const APP_VERSION = "1.2.0"; export const OFFICIAL_SERVER_URL = 'wss://sync.shik3i.net'; export const OFFICIAL_LANDING_PAGE_URL = 'https://koalasync.shik3i.net'; @@ -32,8 +32,13 @@ export const EVENTS = { FORCE_SYNC_EXECUTE: "force_sync_execute", EVENT_ACK: "event_ack", GET_ROOMS: "get_rooms", - ROOM_LIST: "room_list" + ROOM_LIST: "room_list", + + // Episode Auto-Sync + EPISODE_LOBBY: "episode_lobby", // Broadcast: waiting for everyone on this episode + EPISODE_READY: "episode_ready" // Response: loaded the episode and paused at 0:00 }; export const HEARTBEAT_INTERVAL = 15000; // 15s export const FORCE_SYNC_TIMEOUT = 5000; // 5s timeout for ACKs +export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby