mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-27 04:20:25 +00:00
bd60a14754
When no other peer is present, heartbeats, force-sync, and episode auto-sync are now fully suppressed (previously the keepAlive heartbeat, force-sync, and episode lobby were still broadcast to an empty room): - keepAlive heartbeat gated on a live peer count - force-sync is a no-op when solo (no pause-then-timeout freeze, no traffic) - episode auto-sync skips the lobby entirely and just plays the next episode The solo state is recomputed live from the current peer list on every event (never cached), so the instant another peer joins syncing resumes. On that solo->not-solo transition the background also requests an immediate heartbeat from the content script so the newcomer sees the current position without waiting for the next interval. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2050 lines
85 KiB
JavaScript
2050 lines
85 KiB
JavaScript
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
|
|
import { generateUsername } from './shared/names.js';
|
|
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
|
import { sameEpisode } from './episode-utils.js';
|
|
import { initTabManager } from './modules/tab-manager.js';
|
|
|
|
// --- Uninstall URL Initialization ---
|
|
let uninstallURLInitPromise = null;
|
|
|
|
async function initUninstallURL() {
|
|
if (uninstallURLInitPromise) {
|
|
return uninstallURLInitPromise;
|
|
}
|
|
|
|
uninstallURLInitPromise = (async () => {
|
|
// --- UNINSTALL_URL_INJECT_START ---
|
|
const UNINSTALL_URL = ""; // Populated during build
|
|
const BROWSER_TYPE = "unknown";
|
|
// --- UNINSTALL_URL_INJECT_END ---
|
|
|
|
if (UNINSTALL_URL && UNINSTALL_URL.trim() !== '') {
|
|
try {
|
|
const url = new URL(UNINSTALL_URL);
|
|
url.searchParams.set("browser", BROWSER_TYPE);
|
|
|
|
const runtimeAPI = typeof browser !== 'undefined' ? browser.runtime : chrome.runtime;
|
|
if (runtimeAPI && runtimeAPI.setUninstallURL) {
|
|
const result = runtimeAPI.setUninstallURL(url.href);
|
|
// browser.runtime.setUninstallURL returns a Promise, handle rejection silently
|
|
if (result && typeof result.catch === 'function') {
|
|
result.catch(err => console.warn('Failed to set uninstall URL:', err));
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to initialize uninstall URL:", err);
|
|
}
|
|
}
|
|
})();
|
|
|
|
return uninstallURLInitPromise;
|
|
}
|
|
|
|
chrome.runtime.onInstalled.addListener((details) => {
|
|
if (details.reason === 'install' || details.reason === 'update') {
|
|
initUninstallURL();
|
|
purgeLegacySyncKeys();
|
|
}
|
|
});
|
|
|
|
chrome.runtime.onStartup.addListener(() => {
|
|
initUninstallURL();
|
|
purgeLegacySyncKeys();
|
|
});
|
|
|
|
// --- State Management ---
|
|
let socket = null;
|
|
let isConnecting = false;
|
|
let peerId = null; // initialized via getPeerId()
|
|
let currentRoom = null;
|
|
let currentTabId = null;
|
|
let currentTabTitle = null; // New: for Smart Matching
|
|
let logs = [];
|
|
let history = []; // New: for Action History
|
|
let storageInitialized = false;
|
|
let pendingLogs = [];
|
|
let pendingHistory = [];
|
|
let eventQueue = [];
|
|
let flushTimer = null; // paces draining of eventQueue after (re)connect
|
|
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
|
|
let expectedAcksCount = 0; // Snapshot of peerCount when initiating Force Sync
|
|
|
|
// --- Ping / Latency ---
|
|
let pingInterval = null;
|
|
let pingTimeout = null;
|
|
let pendingPingT = null;
|
|
let currentPingMs = null;
|
|
let missedPongs = 0;
|
|
|
|
// --- 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 });
|
|
}
|
|
|
|
// --- Boot Sequence Lock ---
|
|
let restorationTask = null;
|
|
|
|
function ensureState() {
|
|
if (!restorationTask) {
|
|
restorationTask = new Promise(resolve => {
|
|
let resolved = false;
|
|
const done = () => { if (!resolved) { resolved = true; resolve(); } };
|
|
|
|
const storageTimeout = setTimeout(() => {
|
|
addLog('Storage restoration timed out, continuing with defaults', 'warn');
|
|
storageInitialized = true;
|
|
done();
|
|
}, 10000);
|
|
|
|
chrome.storage.session.get([
|
|
'logs', 'history', 'currentRoom', 'lastActionState',
|
|
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
|
|
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle',
|
|
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt'
|
|
], (data) => {
|
|
clearTimeout(storageTimeout);
|
|
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
|
|
if (data.currentTabId !== undefined) currentTabId = data.currentTabId;
|
|
if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle;
|
|
// Merge data from storage with any early-arriving state
|
|
// New entries (added during boot) must stay at the top (index 0)
|
|
if (data.logs) logs = [...logs, ...data.logs].slice(0, 200);
|
|
if (data.history) history = [...history, ...data.history].slice(0, 20);
|
|
if (data.currentRoom) currentRoom = data.currentRoom;
|
|
if (data.lastActionState) lastActionState = data.lastActionState;
|
|
|
|
if (data.eventQueue) eventQueue = [...eventQueue, ...data.eventQueue].slice(0, 50);
|
|
if (data.isForceSyncInitiator !== undefined && isForceSyncInitiator === false) {
|
|
isForceSyncInitiator = data.isForceSyncInitiator;
|
|
}
|
|
if (data.forceSyncAcks) {
|
|
const mergedAcks = new Set([...forceSyncAcks, ...data.forceSyncAcks]);
|
|
forceSyncAcks = mergedAcks;
|
|
}
|
|
if (data.reconnectFailed !== undefined) reconnectFailed = data.reconnectFailed;
|
|
if (data.reconnectStartTime) reconnectStartTime = data.reconnectStartTime;
|
|
if (data.reconnectAttempts !== undefined) reconnectAttempts = data.reconnectAttempts;
|
|
if (data.roomIdleSince !== undefined) roomIdleSince = data.roomIdleSince;
|
|
if (data.lastContentHeartbeatAt !== undefined) lastContentHeartbeatAt = data.lastContentHeartbeatAt;
|
|
|
|
// Recover Force Sync Timeout
|
|
if (data.forceSyncDeadline) {
|
|
const remaining = data.forceSyncDeadline - Date.now();
|
|
if (remaining > 0 && isForceSyncInitiator) {
|
|
forceSyncTimeout = setTimeout(() => {
|
|
if (isForceSyncInitiator) {
|
|
addLog('Force Sync: Recovered timeout triggered, executing...', 'warn');
|
|
executeForceSync();
|
|
}
|
|
}, remaining);
|
|
} else if (remaining <= 0 && isForceSyncInitiator) {
|
|
executeForceSync();
|
|
}
|
|
}
|
|
|
|
// 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)');
|
|
}
|
|
}
|
|
|
|
if (data.localSeq !== undefined && !isNaN(data.localSeq)) localSeq = data.localSeq;
|
|
if (data.lastSeqBySender && typeof data.lastSeqBySender === 'object') Object.assign(lastSeqBySender, data.lastSeqBySender);
|
|
|
|
storageInitialized = true;
|
|
|
|
// Process any early logs/history that weren't captured in the spread
|
|
if (pendingLogs.length > 0) {
|
|
logs = [...pendingLogs, ...logs].slice(0, 200);
|
|
chrome.storage.session.set({ logs });
|
|
pendingLogs = [];
|
|
}
|
|
if (pendingHistory.length > 0) {
|
|
history = [...pendingHistory, ...history].slice(0, 20);
|
|
chrome.storage.session.set({ history });
|
|
pendingHistory = [];
|
|
}
|
|
|
|
done();
|
|
});
|
|
});
|
|
}
|
|
return restorationTask;
|
|
}
|
|
|
|
// Start restoration immediately
|
|
ensureState();
|
|
|
|
let reconnectTimer = null;
|
|
let reconnectStartTime = null;
|
|
let reconnectFailed = false;
|
|
let reconnectAttempts = 0;
|
|
let currentServerUrl = null;
|
|
let roomIdleSince = null;
|
|
let lastContentHeartbeatAt = null;
|
|
let connectIntent = false;
|
|
const MAX_RECONNECT_ATTEMPTS = 20;
|
|
// Backoff tuned so that at most ~8 connection attempts land in any 60s window,
|
|
// keeping a single client comfortably under the server's per-IP connection
|
|
// budget (10/min) even before jitter. Cumulative (no jitter): 1, 2.8, 6, 11.9,
|
|
// 22.4, 34.4, 46.4, 58.4s → 8th attempt at ~58s.
|
|
const _RECONNECT_BASE_DELAY = 1000;
|
|
const _RECONNECT_MAX_DELAY = 12000;
|
|
const _RECONNECT_FACTOR = 1.8;
|
|
const _RECONNECT_GIVEUP_MS = 300000; // switch to slow mode after 5 min of fast retries
|
|
const _RECONNECT_SLOW_DELAY = 300000; // slow-mode interval: every 5 min
|
|
const _RECONNECT_JITTER = 0.2; // ±20% randomization to de-synchronize reconnect herds
|
|
// Paced queue flush: after a (re)connect we drain the offline event backlog in
|
|
// small batches instead of one synchronous burst, so we stay well under the
|
|
// server's per-socket event budget (50 / 10s) and leave headroom for the
|
|
// heartbeats/pings/commands that also count toward it. 10 per 3s ≈ 33/10s.
|
|
const FLUSH_BATCH_SIZE = 10;
|
|
const FLUSH_BATCH_INTERVAL_MS = 3000;
|
|
// Ping liveness: a single unanswered ping is tolerated (transient network
|
|
// blip); only MAX_MISSED_PONGS consecutive misses force a reconnect. With a
|
|
// 15s interval and 5s timeout that means ~20s to detect a genuinely dead link.
|
|
const PING_INTERVAL_MS = 15000;
|
|
const PING_TIMEOUT_MS = 5000;
|
|
const MAX_MISSED_PONGS = 2;
|
|
const ROOM_IDLE_AUTO_LEAVE_MS = 2 * 60 * 60 * 1000;
|
|
|
|
// Force Sync Coordination
|
|
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 ---
|
|
|
|
/**
|
|
* Canonical peer data factory. All peer object construction must go through
|
|
* here to guarantee a consistent shape with predictable null defaults.
|
|
* @param {object} raw - Raw data from server event or heartbeat payload.
|
|
* @returns {object} Normalized peer data object.
|
|
*/
|
|
function createPeerData(raw) {
|
|
return {
|
|
peerId: raw.peerId || null,
|
|
username: raw.username || null,
|
|
tabTitle: raw.tabTitle || null,
|
|
mediaTitle: raw.mediaTitle || null,
|
|
playbackState: raw.playbackState || null,
|
|
currentTime: raw.currentTime != null ? raw.currentTime : null,
|
|
volume: raw.volume != null ? raw.volume : null,
|
|
muted: raw.muted != null ? raw.muted : null,
|
|
lastHeartbeat: Date.now()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Updates properties of a peer in the room and instantly broadcasts the changes to the popup UI.
|
|
* Also tracks lastReactiveUpdate to guard against older heartbeats in transit overwriting state.
|
|
*/
|
|
function updateLocalPeerState(targetPeerId, updates) {
|
|
if (!currentRoom || !Array.isArray(currentRoom.peers)) return;
|
|
const peer = currentRoom.peers.find(p => typeof p === 'object' ? p.peerId === targetPeerId : p === targetPeerId);
|
|
if (peer && typeof peer === 'object') {
|
|
Object.keys(updates).forEach(key => {
|
|
if (updates[key] !== undefined && updates[key] !== null) {
|
|
peer[key] = updates[key];
|
|
}
|
|
});
|
|
peer.lastReactiveUpdate = Date.now(); // Race condition guard lock
|
|
if (updates.currentTime !== undefined && updates.currentTime !== null) {
|
|
peer.lastHeartbeat = Date.now(); // reset time interpolation baseline
|
|
}
|
|
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function getPeerId() {
|
|
const data = await chrome.storage.local.get(['peerId']);
|
|
if (data.peerId) return data.peerId;
|
|
const newId = self.crypto.randomUUID().substring(0, 8);
|
|
await chrome.storage.local.set({ peerId: newId });
|
|
return newId;
|
|
}
|
|
|
|
async function getSettings() {
|
|
// Local-only by design. Room credentials (roomId/password) and identity
|
|
// (username) must NEVER come from storage.sync — syncing them across devices
|
|
// both leaks them and resurrects dead rooms on reinstall (a fresh install
|
|
// has empty local storage but sync survives in the user's Google account).
|
|
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username']);
|
|
let username = data.username;
|
|
if (!username) {
|
|
username = generateUsername();
|
|
await chrome.storage.local.set({ username });
|
|
}
|
|
return {
|
|
serverUrl: data.serverUrl || '',
|
|
useCustomServer: data.useCustomServer || false,
|
|
roomId: data.roomId || '',
|
|
password: data.password || '',
|
|
username
|
|
};
|
|
}
|
|
|
|
// Privacy + correctness: only onboardingComplete and dismissedHints belong in
|
|
// storage.sync. Everything else is per-device local storage. This actively
|
|
// removes legacy keys that older versions wrote to sync (and that would
|
|
// otherwise be redistributed across devices and resurrected on reinstall).
|
|
const LEGACY_SYNC_KEYS = [
|
|
'serverUrl', 'useCustomServer', 'roomId', 'password', 'username',
|
|
'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
|
|
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings'
|
|
];
|
|
function purgeLegacySyncKeys() {
|
|
chrome.storage.sync.remove(LEGACY_SYNC_KEYS).catch(() => {});
|
|
}
|
|
|
|
function addLog(message, type = 'info') {
|
|
const log = {
|
|
timestamp: new Date().toISOString(),
|
|
message,
|
|
type
|
|
};
|
|
if (!storageInitialized) {
|
|
pendingLogs.unshift(log);
|
|
} else {
|
|
logs.unshift(log);
|
|
if (logs.length > 200) logs.pop();
|
|
chrome.storage.session.set({ logs });
|
|
}
|
|
chrome.runtime.sendMessage({ type: 'LOG_UPDATE', log }).catch(() => {});
|
|
}
|
|
|
|
// --- WebSocket Client ---
|
|
function resolveServerUrl(settings) {
|
|
return (settings.serverUrl && settings.useCustomServer) ? settings.serverUrl : OFFICIAL_SERVER_URL;
|
|
}
|
|
|
|
function forceDisconnect() {
|
|
if (reconnectTimer) {
|
|
clearTimeout(reconnectTimer);
|
|
reconnectTimer = null;
|
|
}
|
|
if (episodeLobbyTimeout) {
|
|
clearTimeout(episodeLobbyTimeout);
|
|
episodeLobbyTimeout = null;
|
|
}
|
|
episodeLobby = null;
|
|
if (forceSyncTimeout) {
|
|
clearTimeout(forceSyncTimeout);
|
|
forceSyncTimeout = null;
|
|
}
|
|
stopPing();
|
|
if (socket) {
|
|
socket.onopen = null;
|
|
socket.onmessage = null;
|
|
socket.onclose = null;
|
|
socket.onerror = null;
|
|
socket.close();
|
|
socket = null;
|
|
}
|
|
currentServerUrl = null;
|
|
isConnecting = false;
|
|
isNamespaceJoined = false;
|
|
isForceSyncInitiator = false;
|
|
expectedAcksCount = 0;
|
|
roomIdleSince = null;
|
|
lastContentHeartbeatAt = null;
|
|
forceSyncAcks.clear();
|
|
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
|
eventQueue = [];
|
|
chrome.storage.session.set({
|
|
isForceSyncInitiator: false,
|
|
forceSyncAcks: [],
|
|
forceSyncDeadline: null,
|
|
expectedAcksCount: 0,
|
|
eventQueue: [],
|
|
episodeLobby: null,
|
|
roomIdleSince: null,
|
|
lastContentHeartbeatAt: null
|
|
}).catch(() => {});
|
|
if (currentRoom) {
|
|
currentRoom.peers = [];
|
|
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
|
}
|
|
broadcastConnectionStatus('disconnected');
|
|
}
|
|
|
|
function persistRoomIdleState() {
|
|
chrome.storage.session.set({ roomIdleSince, lastContentHeartbeatAt }).catch(() => {});
|
|
}
|
|
|
|
function markRoomUseful() {
|
|
roomIdleSince = null;
|
|
lastContentHeartbeatAt = Date.now();
|
|
persistRoomIdleState();
|
|
}
|
|
|
|
function markRoomPotentiallyIdle() {
|
|
if (!currentRoom) {
|
|
roomIdleSince = null;
|
|
lastContentHeartbeatAt = null;
|
|
persistRoomIdleState();
|
|
return;
|
|
}
|
|
if (!roomIdleSince) {
|
|
roomIdleSince = Date.now();
|
|
persistRoomIdleState();
|
|
}
|
|
}
|
|
|
|
function clearTargetTabForIdle() {
|
|
currentTabId = null;
|
|
currentTabTitle = null;
|
|
lastContentHeartbeatAt = null;
|
|
if (currentRoom) {
|
|
roomIdleSince = Date.now();
|
|
}
|
|
chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt }).catch(() => {});
|
|
updateBadgeStatus();
|
|
}
|
|
|
|
async function leaveRoomAfterIdleGrace(reason) {
|
|
if (!currentRoom) return;
|
|
connectIntent = false;
|
|
reconnectFailed = false;
|
|
reconnectAttempts = 0;
|
|
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
|
emit(EVENTS.LEAVE_ROOM, { peerId });
|
|
forceDisconnect();
|
|
currentRoom = null;
|
|
currentTabId = null;
|
|
currentTabTitle = null;
|
|
roomIdleSince = null;
|
|
lastContentHeartbeatAt = null;
|
|
clearEpisodeLobbyState();
|
|
await chrome.storage.session.set({
|
|
currentRoom: null,
|
|
currentTabId: null,
|
|
currentTabTitle: null,
|
|
roomIdleSince: null,
|
|
lastContentHeartbeatAt: null,
|
|
episodeLobby: null
|
|
}).catch(() => {});
|
|
await chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
|
|
addLog(reason, 'info');
|
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
|
updateBadgeStatus();
|
|
}
|
|
|
|
async function connect() {
|
|
if (isConnecting) return;
|
|
isConnecting = true;
|
|
|
|
let finalUrl = '';
|
|
try {
|
|
// --- Phase 1: Storage ---
|
|
let settings;
|
|
try {
|
|
if (!peerId) peerId = await getPeerId();
|
|
settings = await getSettings();
|
|
} catch (e) {
|
|
throw new Error(`[Storage Error] ${e.message}`);
|
|
}
|
|
|
|
// --- Phase 2: Connection Guard ---
|
|
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
|
if (isNamespaceJoined) {
|
|
isConnecting = false;
|
|
return;
|
|
}
|
|
socket.onopen = null;
|
|
socket.onmessage = null;
|
|
socket.onclose = null;
|
|
socket.onerror = null;
|
|
socket.close();
|
|
}
|
|
|
|
if (!navigator.onLine) {
|
|
addLog('Browser is offline. Waiting...', 'warn');
|
|
broadcastConnectionStatus('offline');
|
|
isConnecting = false;
|
|
if (currentRoom || connectIntent) {
|
|
scheduleReconnect();
|
|
}
|
|
return;
|
|
}
|
|
|
|
broadcastConnectionStatus('reconnecting');
|
|
const isCustomServer = settings.serverUrl && settings.useCustomServer;
|
|
finalUrl = isCustomServer ? settings.serverUrl : OFFICIAL_SERVER_URL;
|
|
|
|
// --- Phase 3: URL Validation ---
|
|
try {
|
|
if (isCustomServer) {
|
|
finalUrl = finalUrl.trim();
|
|
if (!finalUrl.includes('://')) {
|
|
finalUrl = 'ws://' + finalUrl;
|
|
}
|
|
const urlObj = new URL(finalUrl);
|
|
const isLocal = urlObj.hostname === 'localhost' || urlObj.hostname === '127.0.0.1';
|
|
if (urlObj.protocol !== 'wss:' && !isLocal) {
|
|
urlObj.protocol = 'wss:';
|
|
finalUrl = urlObj.toString();
|
|
addLog('Security: Upgraded to wss:// for remote host.', 'warn');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
throw new Error(`[URL Error] ${e.message}`);
|
|
}
|
|
|
|
addLog(`Connecting to ${isCustomServer ? finalUrl : 'Official Server'}... (attempt ${reconnectAttempts + 1})`, 'info');
|
|
|
|
currentServerUrl = finalUrl;
|
|
|
|
// --- Phase 4: WebSocket Init ---
|
|
try {
|
|
const url = new URL(finalUrl);
|
|
url.pathname = '/socket.io/';
|
|
url.searchParams.set('EIO', '4');
|
|
url.searchParams.set('transport', 'websocket');
|
|
url.searchParams.set('version', chrome.runtime.getManifest().version);
|
|
url.searchParams.set('token', OFFICIAL_SERVER_TOKEN);
|
|
|
|
socket = new WebSocket(url.toString());
|
|
} catch (e) {
|
|
throw new Error(`[Connection Error] ${e.message}`);
|
|
}
|
|
|
|
// --- Phase 5: Event Listeners ---
|
|
socket.onopen = () => {
|
|
reconnectAttempts = 0;
|
|
reconnectStartTime = null;
|
|
reconnectFailed = false;
|
|
addLog('WebSocket Connection Opened', 'success');
|
|
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }).catch(() => {});
|
|
isNamespaceJoined = false;
|
|
socket.send('40');
|
|
};
|
|
|
|
socket.onmessage = async (event) => {
|
|
await ensureState();
|
|
const msg = event.data;
|
|
if (msg === '2') {
|
|
socket.send('3');
|
|
return;
|
|
}
|
|
if (msg.startsWith('0')) {
|
|
addLog(`Socket.IO Handshake: ${msg}`, 'info');
|
|
} else if (msg.startsWith('40')) {
|
|
isConnecting = false;
|
|
isNamespaceJoined = true;
|
|
broadcastConnectionStatus('connected');
|
|
startPing();
|
|
addLog('Joined Namespace /', 'success');
|
|
const settings = await getSettings();
|
|
if (settings.roomId) {
|
|
emit(EVENTS.JOIN_ROOM, {
|
|
roomId: settings.roomId,
|
|
password: settings.password,
|
|
peerId,
|
|
username: settings.username,
|
|
tabTitle: currentTabTitle,
|
|
protocolVersion: PROTOCOL_VERSION
|
|
});
|
|
}
|
|
flushEventQueue();
|
|
} else if (msg.startsWith('42')) {
|
|
try {
|
|
const payload = JSON.parse(msg.substring(2));
|
|
try {
|
|
handleServerEvent(payload[0], payload[1]);
|
|
} catch (handlerErr) {
|
|
addLog(`Handler error for ${payload[0]}: ${handlerErr.message}`, 'error');
|
|
}
|
|
} catch (_e) {
|
|
addLog(`Failed to parse message: ${msg}`, 'error');
|
|
}
|
|
}
|
|
};
|
|
|
|
socket.onclose = () => {
|
|
isConnecting = false;
|
|
isNamespaceJoined = false;
|
|
stopPing();
|
|
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
|
|
|
if (!connectIntent && !currentRoom) {
|
|
isForceSyncInitiator = false;
|
|
forceSyncAcks.clear();
|
|
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
|
chrome.storage.session.set({
|
|
isForceSyncInitiator: false,
|
|
forceSyncAcks: [],
|
|
forceSyncDeadline: null
|
|
}).catch(() => {});
|
|
}
|
|
|
|
|
|
if (currentRoom && !connectIntent) {
|
|
currentRoom.peers = [];
|
|
if (storageInitialized) chrome.storage.session.set({ currentRoom }).catch(() => {});
|
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
|
}
|
|
broadcastConnectionStatus('disconnected');
|
|
if (currentRoom || connectIntent) {
|
|
addLog('Disconnected. Scheduling reconnect...', 'warn');
|
|
socket = null;
|
|
scheduleReconnect();
|
|
} else {
|
|
addLog('Disconnected. No active session — staying disconnected.', 'info');
|
|
socket = null;
|
|
}
|
|
};
|
|
|
|
socket.onerror = () => {
|
|
broadcastConnectionStatus('disconnected');
|
|
const logType = reconnectAttempts > 1 ? 'error' : 'warn';
|
|
addLog('WebSocket Error: Connection failed', logType);
|
|
};
|
|
|
|
} catch (e) {
|
|
isConnecting = false;
|
|
const logType = reconnectAttempts > 1 ? 'error' : 'warn';
|
|
const errMsg = (e && e.message) ? e.message : String(e || 'Unknown connection error');
|
|
addLog(errMsg, logType);
|
|
broadcastConnectionStatus('disconnected');
|
|
if (currentRoom || connectIntent) {
|
|
scheduleReconnect();
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function broadcastConnectionStatus(status) {
|
|
chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {});
|
|
updateBadgeStatus();
|
|
}
|
|
|
|
function updateBadgeStatus() {
|
|
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
|
|
const isReconnecting = !isConnected && reconnectAttempts > 0;
|
|
const status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
|
|
|
|
if (status === 'reconnecting') {
|
|
chrome.action.setBadgeText({ text: '...' });
|
|
chrome.action.setBadgeBackgroundColor({ color: '#f59e0b' });
|
|
} else if (status === 'connecting') {
|
|
chrome.action.setBadgeText({ text: '...' });
|
|
chrome.action.setBadgeBackgroundColor({ color: '#fbbf24' });
|
|
} else if (status === 'connected' && currentRoom && currentTabId) {
|
|
chrome.action.setBadgeText({ text: 'ON' });
|
|
chrome.action.setBadgeBackgroundColor({ color: '#22c55e' });
|
|
} else {
|
|
chrome.action.setBadgeText({ text: '' });
|
|
}
|
|
}
|
|
|
|
function showNotification(senderName, action) {
|
|
chrome.storage.local.get(['browserNotifications', 'locale'], async (settings) => {
|
|
if (!settings.browserNotifications) return;
|
|
|
|
const lang = settings.locale || getSystemLanguage();
|
|
await loadLocale(lang);
|
|
|
|
let labelKey = '';
|
|
if (action === 'play') labelKey = 'NOTIF_PLAY';
|
|
else if (action === 'pause') labelKey = 'NOTIF_PAUSE';
|
|
else if (action === 'seek') labelKey = 'NOTIF_SEEK';
|
|
else if (action === 'force_sync_prepare') labelKey = 'NOTIF_FORCE_PREPARE';
|
|
else if (action === 'force_sync_execute') labelKey = 'NOTIF_FORCE_EXECUTE';
|
|
|
|
const label = labelKey ? getMessage(labelKey) : action;
|
|
|
|
let displayName = senderName || 'A peer';
|
|
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
|
const peer = currentRoom.peers.find(p => (p.peerId || p) === senderName);
|
|
if (peer && peer.username) displayName = peer.username;
|
|
}
|
|
|
|
if (displayName === 'You' || displayName === 'YOU') {
|
|
displayName = getMessage('LABEL_YOU') || 'YOU';
|
|
}
|
|
|
|
const message = getMessage('TOAST_PEER_ACTION', { name: displayName, action: label }) + '.';
|
|
|
|
chrome.notifications.create(`sync_${Date.now()}`, {
|
|
type: 'basic',
|
|
iconUrl: 'icons/icon128.png',
|
|
title: 'KoalaSync',
|
|
message: message,
|
|
priority: 1
|
|
});
|
|
});
|
|
}
|
|
|
|
function scheduleReconnect() {
|
|
if (reconnectTimer) return;
|
|
|
|
if (!reconnectStartTime) reconnectStartTime = Date.now();
|
|
|
|
const elapsed = Date.now() - reconnectStartTime;
|
|
reconnectAttempts++;
|
|
|
|
if (!reconnectFailed && (elapsed > _RECONNECT_GIVEUP_MS || reconnectAttempts > MAX_RECONNECT_ATTEMPTS)) {
|
|
reconnectFailed = true;
|
|
addLog('Switching to slow reconnect mode (every 5 minutes)', 'warn');
|
|
}
|
|
|
|
const baseDelay = reconnectFailed
|
|
? _RECONNECT_SLOW_DELAY
|
|
: Math.min(_RECONNECT_BASE_DELAY * Math.pow(_RECONNECT_FACTOR, reconnectAttempts - 1), _RECONNECT_MAX_DELAY);
|
|
// Jitter de-synchronizes herds: many clients dropped by the same server
|
|
// blip won't all reconnect on the same tick and exhaust the connection
|
|
// budget in lockstep. Applied in both fast and slow mode.
|
|
const jitterFactor = 1 - _RECONNECT_JITTER + Math.random() * 2 * _RECONNECT_JITTER;
|
|
const delay = Math.round(baseDelay * jitterFactor);
|
|
|
|
if (reconnectFailed) {
|
|
addLog(`Slow reconnect in ~5min (attempt ${reconnectAttempts})`, 'info');
|
|
} else {
|
|
addLog(`Reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`, 'warn');
|
|
}
|
|
|
|
chrome.storage.session.set({ reconnectFailed, reconnectAttempts, reconnectStartTime }).catch(() => {});
|
|
|
|
reconnectTimer = setTimeout(() => {
|
|
reconnectTimer = null;
|
|
connect();
|
|
}, delay);
|
|
}
|
|
|
|
// Slow reconnect logic is now handled in the keepAlive alarm
|
|
|
|
function emit(event, data) {
|
|
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
|
const msg = `42${JSON.stringify([event, data])}`;
|
|
try {
|
|
socket.send(msg);
|
|
} catch (e) {
|
|
// The socket can close between the readyState check and send()
|
|
// (race with a server-side disconnect). Re-queue so the event is
|
|
// retried on the next successful (re)connect instead of being lost.
|
|
addLog(`Send failed, re-queueing ${event}: ${e.message}`, 'warn');
|
|
queueEvent(event, data);
|
|
}
|
|
} else {
|
|
queueEvent(event, data);
|
|
}
|
|
}
|
|
|
|
function queueEvent(event, data) {
|
|
eventQueue.push({ event, data });
|
|
if (eventQueue.length > 50) {
|
|
eventQueue.shift();
|
|
addLog('Event queue cap reached, dropping oldest event', 'warn');
|
|
}
|
|
chrome.storage.session.set({ eventQueue });
|
|
}
|
|
|
|
/**
|
|
* Drain the offline event queue in paced batches. A reconnect after a long
|
|
* outage can leave up to 50 queued events; dumping them in one tick would
|
|
* exceed the server's per-socket event budget and get us disconnected right
|
|
* after rejoining. We send FLUSH_BATCH_SIZE events, then wait
|
|
* FLUSH_BATCH_INTERVAL_MS before the next batch. Remaining events drain across
|
|
* subsequent batches; if the connection drops mid-drain, the rest stay queued.
|
|
*/
|
|
function flushEventQueue() {
|
|
if (flushTimer) return; // a drain is already in progress
|
|
const drainBatch = () => {
|
|
flushTimer = null;
|
|
if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
|
return; // lost the connection — leave the rest queued for next connect
|
|
}
|
|
let sent = 0;
|
|
while (eventQueue.length > 0 && sent < FLUSH_BATCH_SIZE) {
|
|
const queuedMsg = eventQueue.shift();
|
|
emit(queuedMsg.event, queuedMsg.data);
|
|
sent++;
|
|
}
|
|
chrome.storage.session.set({ eventQueue }).catch(() => {});
|
|
if (eventQueue.length > 0) {
|
|
flushTimer = setTimeout(drainBatch, FLUSH_BATCH_INTERVAL_MS);
|
|
}
|
|
};
|
|
drainBatch();
|
|
}
|
|
|
|
function addToHistory(action, senderId) {
|
|
const historyEntry = {
|
|
action,
|
|
senderId: senderId || 'You',
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
if (!storageInitialized) {
|
|
pendingHistory.unshift(historyEntry);
|
|
} else {
|
|
history.unshift(historyEntry);
|
|
if (history.length > 20) history.pop();
|
|
chrome.storage.session.set({ history });
|
|
}
|
|
chrome.runtime.sendMessage({ type: 'HISTORY_UPDATE', history }).catch(() => {});
|
|
}
|
|
|
|
// --- Ping / Latency ---
|
|
function sendPing() {
|
|
const t = Date.now();
|
|
pendingPingT = t;
|
|
emit(EVENTS.PING, { t });
|
|
if (pingTimeout) clearTimeout(pingTimeout);
|
|
pingTimeout = setTimeout(() => {
|
|
pingTimeout = null;
|
|
if (pendingPingT !== t) return; // a PONG arrived in time
|
|
// This ping went unanswered. Tolerate transient blips: only force a
|
|
// reconnect after MAX_MISSED_PONGS consecutive misses, not the first.
|
|
pendingPingT = null;
|
|
missedPongs++;
|
|
if (missedPongs >= MAX_MISSED_PONGS) {
|
|
addLog(`${missedPongs} consecutive pings unanswered — force disconnecting to trigger reconnect`, 'warn');
|
|
missedPongs = 0;
|
|
forceDisconnect();
|
|
if (currentRoom || connectIntent) {
|
|
scheduleReconnect();
|
|
}
|
|
} else {
|
|
addLog(`Ping unanswered (${missedPongs}/${MAX_MISSED_PONGS}) — retrying next interval`, 'warn');
|
|
}
|
|
}, PING_TIMEOUT_MS);
|
|
}
|
|
|
|
function startPing() {
|
|
if (pingInterval) clearInterval(pingInterval);
|
|
if (pingTimeout) { clearTimeout(pingTimeout); pingTimeout = null; }
|
|
currentPingMs = null;
|
|
pendingPingT = null;
|
|
missedPongs = 0;
|
|
pingInterval = setInterval(sendPing, PING_INTERVAL_MS);
|
|
sendPing();
|
|
}
|
|
|
|
function stopPing() {
|
|
if (pingInterval) {
|
|
clearInterval(pingInterval);
|
|
pingInterval = null;
|
|
}
|
|
if (pingTimeout) {
|
|
clearTimeout(pingTimeout);
|
|
pingTimeout = null;
|
|
}
|
|
currentPingMs = null;
|
|
pendingPingT = null;
|
|
missedPongs = 0;
|
|
}
|
|
|
|
// --- Event Handlers ---
|
|
function handleServerEvent(event, data) {
|
|
if (!data) {
|
|
addLog(`Ignored server event ${event} due to empty payload`, 'warn');
|
|
return;
|
|
}
|
|
switch (event) {
|
|
case EVENTS.ROOM_DATA:
|
|
currentRoom = data;
|
|
markRoomPotentiallyIdle();
|
|
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(() => {});
|
|
|
|
// Inform Website Bridge & Popup
|
|
const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' };
|
|
chrome.runtime.sendMessage(joinStatusMsg).catch(() => {});
|
|
chrome.tabs.query({}, (tabs) => {
|
|
tabs.forEach(tab => {
|
|
chrome.tabs.sendMessage(tab.id, joinStatusMsg).catch(() => {});
|
|
});
|
|
});
|
|
break;
|
|
case EVENTS.ROOM_LIST:
|
|
chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {});
|
|
break;
|
|
case EVENTS.ERROR:
|
|
isConnecting = false;
|
|
// If we get a server error before successfully joining a room,
|
|
// clear connectIntent to prevent an infinite reconnect loop.
|
|
if (!currentRoom) {
|
|
connectIntent = false;
|
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
reconnectAttempts = 0;
|
|
reconnectFailed = false;
|
|
}
|
|
broadcastConnectionStatus('disconnected');
|
|
addLog(`Server Error: ${data.message}`, 'error');
|
|
chrome.storage.local.get(['browserNotifications', 'locale'], async (settings) => {
|
|
if (!settings.browserNotifications) return;
|
|
const lang = settings.locale || getSystemLanguage();
|
|
await loadLocale(lang);
|
|
chrome.notifications.create(`error_${Date.now()}`, {
|
|
type: 'basic',
|
|
iconUrl: 'icons/icon128.png',
|
|
title: getMessage('NOTIF_ERROR_TITLE') || 'KoalaSync Error',
|
|
message: data.message
|
|
});
|
|
});
|
|
// Inform Website Bridge & Popup
|
|
const errStatusMsg = { type: 'JOIN_STATUS', success: false, message: data.message };
|
|
chrome.runtime.sendMessage(errStatusMsg).catch(() => {});
|
|
chrome.tabs.query({}, (tabs) => {
|
|
tabs.forEach(tab => {
|
|
chrome.tabs.sendMessage(tab.id, errStatusMsg).catch(() => {});
|
|
});
|
|
});
|
|
break;
|
|
case EVENTS.PLAY:
|
|
case EVENTS.PAUSE:
|
|
case EVENTS.SEEK:
|
|
case EVENTS.FORCE_SYNC_PREPARE:
|
|
if (data.senderId && typeof data.seq === 'number') {
|
|
const lastSeq = lastSeqBySender[data.senderId];
|
|
if (lastSeq !== undefined && data.seq <= lastSeq) {
|
|
addLog(`Ignored stale ${event} from ${data.senderId} (seq ${data.seq} <= ${lastSeq})`, 'warn');
|
|
break;
|
|
}
|
|
lastSeqBySender[data.senderId] = data.seq;
|
|
_persistLastSeq();
|
|
}
|
|
if (data.senderId) {
|
|
addToHistory(event, data.senderId);
|
|
showNotification(data.senderId, event);
|
|
updateLastAction(event, data.senderId);
|
|
lastActionState.targetTime = data.targetTime !== undefined ? data.targetTime : data.currentTime;
|
|
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
|
|
|
// Remote Reactive Update
|
|
updateLocalPeerState(data.senderId, {
|
|
playbackState: event === EVENTS.PLAY ? 'playing' : (event === EVENTS.PAUSE ? 'paused' : undefined),
|
|
currentTime: data.currentTime !== undefined ? data.currentTime : (data.targetTime !== undefined ? data.targetTime : undefined)
|
|
});
|
|
}
|
|
routeToContent(event, data);
|
|
break;
|
|
case EVENTS.FORCE_SYNC_ACK:
|
|
if (data.senderId && typeof data.seq === 'number') {
|
|
const lastSeq = lastSeqBySender[data.senderId];
|
|
if (lastSeq !== undefined && data.seq <= lastSeq) break;
|
|
lastSeqBySender[data.senderId] = data.seq;
|
|
_persistLastSeq();
|
|
}
|
|
if (isForceSyncInitiator) {
|
|
forceSyncAcks.add(data.senderId);
|
|
chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) });
|
|
addLog(`Received ACK from ${data.senderId} (${forceSyncAcks.size})`, 'info');
|
|
|
|
// Update UI state for buffering progress
|
|
if (lastActionState && lastActionState.action === EVENTS.FORCE_SYNC_PREPARE) {
|
|
if (!Array.isArray(lastActionState.acks)) lastActionState.acks = [];
|
|
if (!lastActionState.acks.includes(data.senderId)) {
|
|
lastActionState.acks.push(data.senderId);
|
|
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
|
chrome.runtime.sendMessage({ type: 'ACTION_UPDATE', state: lastActionState }).catch(() => {});
|
|
}
|
|
|
|
// Force Sync ACK Reactive Update
|
|
updateLocalPeerState(data.senderId, {
|
|
playbackState: 'paused', // Preparing for force sync always pauses the player
|
|
currentTime: lastActionState.targetTime
|
|
});
|
|
}
|
|
|
|
// Check if all peers responded using the snapshot count
|
|
const targetCount = expectedAcksCount > 0 ? expectedAcksCount : (currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1);
|
|
if (forceSyncAcks.size >= targetCount) {
|
|
executeForceSync();
|
|
}
|
|
}
|
|
break;
|
|
case EVENTS.FORCE_SYNC_EXECUTE:
|
|
if (data?.senderId && typeof data.seq === 'number') {
|
|
const lastSeq = lastSeqBySender[data.senderId];
|
|
if (lastSeq !== undefined && data.seq <= lastSeq) break;
|
|
lastSeqBySender[data.senderId] = data.seq;
|
|
_persistLastSeq();
|
|
}
|
|
if (data?.senderId) {
|
|
addToHistory(event, data.senderId);
|
|
showNotification(data.senderId, event);
|
|
|
|
// (The sender's state is updated below with everyone else)
|
|
}
|
|
|
|
// Force Sync Execute Remote Reactive Update:
|
|
// Set all peers to playing and apply a reactive lock to block stale heartbeats
|
|
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
|
currentRoom.peers.forEach(peer => {
|
|
if (peer && typeof peer === 'object') {
|
|
peer.playbackState = 'playing';
|
|
peer.lastReactiveUpdate = Date.now();
|
|
}
|
|
});
|
|
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
|
}
|
|
|
|
routeToContent(event, data);
|
|
break;
|
|
case EVENTS.PING:
|
|
if (data && typeof data.t === 'number' && Number.isFinite(data.t) && data.sender) {
|
|
emit(EVENTS.PONG, { t: data.t, target: data.sender });
|
|
}
|
|
break;
|
|
case EVENTS.EVENT_ACK:
|
|
if (lastActionState && lastActionState.action && data?.senderId) {
|
|
// Correlation Check: Only accept ACK if it matches our current action's timestamp
|
|
if (data.actionTimestamp === lastActionState.timestamp) {
|
|
if (!Array.isArray(lastActionState.acks)) lastActionState.acks = [];
|
|
if (!lastActionState.acks.includes(data.senderId)) {
|
|
lastActionState.acks.push(data.senderId);
|
|
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
|
chrome.runtime.sendMessage({ type: 'ACTION_UPDATE', state: lastActionState }).catch(() => {});
|
|
|
|
// ACK Reactive Update
|
|
updateLocalPeerState(data.senderId, {
|
|
playbackState: lastActionState.action === EVENTS.PLAY ? 'playing' : (lastActionState.action === EVENTS.PAUSE ? 'paused' : undefined),
|
|
currentTime: (lastActionState.action === EVENTS.SEEK || lastActionState.action === EVENTS.FORCE_SYNC_PREPARE) ? lastActionState.targetTime : undefined
|
|
});
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
case EVENTS.PEER_STATUS:
|
|
if (currentRoom) {
|
|
if (!Array.isArray(currentRoom.peers)) currentRoom.peers = [];
|
|
if (data.status === 'joined') {
|
|
if (!currentRoom.peers.find(p => (p.peerId || p) === data.peerId)) {
|
|
const wasSolo = currentRoom.peers.filter(p => (p.peerId || p) !== peerId).length === 0;
|
|
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(() => {});
|
|
|
|
// We were alone and now we're not — proactively push our
|
|
// current playback state so the newcomer syncs immediately
|
|
// instead of waiting up to a full heartbeat interval.
|
|
if (wasSolo && currentTabId) {
|
|
chrome.tabs.sendMessage(currentTabId, { type: 'REQUEST_HEARTBEAT' }).catch(() => {});
|
|
}
|
|
|
|
if (episodeLobby && episodeLobby.initiatorPeerId === peerId) {
|
|
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: episodeLobby.expectedTitle });
|
|
}
|
|
}
|
|
} else if (data.status === 'left') {
|
|
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(() => {});
|
|
|
|
if (episodeLobby) {
|
|
checkEpisodeLobbyPeerDeparture();
|
|
}
|
|
|
|
if (isForceSyncInitiator) {
|
|
forceSyncAcks.delete(data.peerId);
|
|
chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) });
|
|
expectedAcksCount = Math.max(1, currentRoom.peers ? currentRoom.peers.length : 1);
|
|
chrome.storage.session.set({ expectedAcksCount });
|
|
if (forceSyncAcks.size >= expectedAcksCount) {
|
|
executeForceSync();
|
|
}
|
|
}
|
|
} else {
|
|
const peer = currentRoom.peers.find(p => (typeof p === 'object' ? p.peerId : p) === data.peerId);
|
|
if (peer) {
|
|
if (typeof peer === 'object') {
|
|
peer.tabTitle = data.tabTitle;
|
|
peer.username = data.username;
|
|
peer.mediaTitle = data.mediaTitle !== undefined ? data.mediaTitle : peer.mediaTitle;
|
|
peer.volume = data.volume !== undefined ? data.volume : peer.volume;
|
|
peer.muted = data.muted !== undefined ? data.muted : peer.muted;
|
|
|
|
const timeSinceReactive = peer.lastReactiveUpdate ? (Date.now() - peer.lastReactiveUpdate) : Infinity;
|
|
const ignoreStatus = timeSinceReactive < 300;
|
|
|
|
if (!ignoreStatus) {
|
|
peer.playbackState = data.playbackState !== undefined ? data.playbackState : peer.playbackState;
|
|
peer.currentTime = data.currentTime !== undefined ? data.currentTime : peer.currentTime;
|
|
if (data.playbackState !== undefined || data.currentTime !== undefined) {
|
|
peer.lastHeartbeat = Date.now();
|
|
}
|
|
}
|
|
} else {
|
|
// Migration: replace string peer with normalized object
|
|
const idx = currentRoom.peers.indexOf(peer);
|
|
currentRoom.peers[idx] = createPeerData(data);
|
|
}
|
|
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
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 && sameEpisode(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: [data.senderId], // Initiator is already ready
|
|
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;
|
|
case EVENTS.EPISODE_LOBBY_CANCEL:
|
|
if (episodeLobby) {
|
|
const title = episodeLobby.expectedTitle;
|
|
clearEpisodeLobbyState();
|
|
addLog(`Episode lobby for "${title}" cancelled by ${data.senderId || 'peer'}`, 'warn');
|
|
}
|
|
break;
|
|
case EVENTS.PONG:
|
|
if (data && typeof data.t === 'number' && Number.isFinite(data.t)) {
|
|
if (pendingPingT === data.t) {
|
|
pendingPingT = null;
|
|
missedPongs = 0;
|
|
if (pingTimeout) {
|
|
clearTimeout(pingTimeout);
|
|
pingTimeout = null;
|
|
}
|
|
const rtt = Date.now() - data.t;
|
|
currentPingMs = (rtt >= 0 && rtt < 30000) ? rtt : null;
|
|
chrome.runtime.sendMessage({ type: 'PING_UPDATE', ping: currentPingMs }).catch(() => {});
|
|
}
|
|
}
|
|
break;
|
|
default:
|
|
addLog(`Received unknown event from server: ${event}`, 'warn');
|
|
break;
|
|
}
|
|
}
|
|
|
|
function executeForceSync() {
|
|
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
|
isForceSyncInitiator = false;
|
|
forceSyncAcks.clear();
|
|
expectedAcksCount = 0;
|
|
chrome.storage.session.set({
|
|
isForceSyncInitiator: false,
|
|
forceSyncAcks: [],
|
|
forceSyncDeadline: null,
|
|
expectedAcksCount: 0
|
|
});
|
|
|
|
// Set all peers to playing and apply a reactive lock to block stale heartbeats
|
|
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
|
currentRoom.peers.forEach(peer => {
|
|
if (peer && typeof peer === 'object') {
|
|
peer.playbackState = 'playing';
|
|
peer.lastReactiveUpdate = Date.now();
|
|
}
|
|
});
|
|
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
|
}
|
|
|
|
const executionTimestamp = Date.now();
|
|
updateLastAction(EVENTS.FORCE_SYNC_EXECUTE, 'You', executionTimestamp);
|
|
|
|
localSeq++;
|
|
chrome.storage.session.set({ localSeq });
|
|
|
|
emit(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp, seq: localSeq });
|
|
routeToContent(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp, seq: localSeq });
|
|
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;
|
|
|
|
// Broadcast cancellation to room
|
|
emit(EVENTS.EPISODE_LOBBY_CANCEL, { peerId });
|
|
|
|
clearEpisodeLobbyState();
|
|
addLog(`Episode lobby cancelled: ${reason} for "${title}"`, 'warn');
|
|
|
|
const reasonKeys = {
|
|
'Timeout': 'LOBBY_CANCEL_TIMEOUT',
|
|
'Timeout (recovered)': 'LOBBY_CANCEL_TIMEOUT_RECOVERED',
|
|
'All other peers left': 'LOBBY_CANCEL_PEERS_LEFT',
|
|
'Timeout — not all peers loaded the episode': 'LOBBY_CANCEL_TIMEOUT_PEERS_LOAD',
|
|
'Cancelled by user': 'LOBBY_CANCEL_USER'
|
|
};
|
|
|
|
// Chrome notification on failure (per Q2: only notify on failure)
|
|
chrome.storage.local.get(['browserNotifications', 'locale'], async (settings) => {
|
|
if (!settings.browserNotifications) return;
|
|
|
|
const lang = settings.locale || getSystemLanguage();
|
|
await loadLocale(lang);
|
|
|
|
const reasonKey = reasonKeys[reason];
|
|
const localizedReason = reasonKey ? getMessage(reasonKey) : reason;
|
|
|
|
const titleText = getMessage('NOTIF_LOBBY_CANCEL_TITLE') || 'KoalaSync — Episode Sync Failed';
|
|
const messageText = getMessage('NOTIF_LOBBY_CANCEL_MSG', { reason: localizedReason }) || `Auto-sync cancelled: ${localizedReason}. You may need to manually sync.`;
|
|
|
|
chrome.notifications.create(`episode_${Date.now()}`, {
|
|
type: 'basic',
|
|
iconUrl: 'icons/icon128.png',
|
|
title: titleText,
|
|
message: messageText,
|
|
priority: 1
|
|
});
|
|
});
|
|
}
|
|
|
|
function executeEpisodeLobby() {
|
|
if (!episodeLobby) return;
|
|
const title = episodeLobby.expectedTitle;
|
|
clearEpisodeLobbyState();
|
|
addLog(`Episode lobby complete: Starting "${title}" via Force Sync`, 'success');
|
|
|
|
isForceSyncInitiator = true;
|
|
forceSyncAcks.clear();
|
|
expectedAcksCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
|
|
const deadline = Date.now() + FORCE_SYNC_TIMEOUT;
|
|
const timestamp = Date.now();
|
|
updateLastAction(EVENTS.FORCE_SYNC_PREPARE, 'You', timestamp);
|
|
lastActionState.targetTime = 0.0;
|
|
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
|
chrome.storage.session.set({
|
|
isForceSyncInitiator: true,
|
|
forceSyncAcks: [],
|
|
forceSyncDeadline: deadline,
|
|
expectedAcksCount: expectedAcksCount
|
|
});
|
|
|
|
const syncPayload = { targetTime: 0.0 };
|
|
localSeq++;
|
|
chrome.storage.session.set({ localSeq });
|
|
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId, actionTimestamp: timestamp, seq: localSeq });
|
|
routeToContent(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, actionTimestamp: timestamp, seq: localSeq });
|
|
|
|
forceSyncTimeout = setTimeout(() => {
|
|
if (isForceSyncInitiator) {
|
|
addLog('Force Sync (Episode): Timeout waiting for ACKs, executing anyway...', 'warn');
|
|
executeForceSync();
|
|
}
|
|
}, FORCE_SYNC_TIMEOUT);
|
|
}
|
|
|
|
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;
|
|
if (!Array.isArray(currentRoom.peers)) 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,
|
|
senderId,
|
|
timestamp,
|
|
acks: []
|
|
};
|
|
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
|
chrome.runtime.sendMessage({ type: 'ACTION_UPDATE', state: lastActionState }).catch(() => {});
|
|
}
|
|
|
|
async function routeToContent(action, payload) {
|
|
if (!currentTabId) return;
|
|
|
|
const tabId = parseInt(currentTabId);
|
|
if (isNaN(tabId)) return;
|
|
|
|
const actionTimestamp = payload?.actionTimestamp || Date.now();
|
|
const commandSenderId = payload?.senderId || null;
|
|
|
|
_routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, 0);
|
|
}
|
|
|
|
function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries) {
|
|
chrome.tabs.sendMessage(tabId, {
|
|
type: 'SERVER_COMMAND',
|
|
action,
|
|
payload,
|
|
actionTimestamp,
|
|
commandSenderId
|
|
}).catch(err => {
|
|
if (retries >= 3) {
|
|
addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn');
|
|
clearTargetTabForIdle();
|
|
return;
|
|
}
|
|
if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) {
|
|
chrome.scripting.executeScript({
|
|
target: { tabId },
|
|
files: ['content.js']
|
|
}).then(() => {
|
|
setTimeout(() => _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries + 1), 500);
|
|
}).catch(_err => {
|
|
addLog(`Auto-reinject failed for tab ${tabId}`, 'warn');
|
|
});
|
|
} else {
|
|
addLog(`Content Script not responding in tab ${tabId}`, 'warn');
|
|
clearTargetTabForIdle();
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Keep-Alive Mechanism ---
|
|
chrome.alarms.create('keepAlive', { periodInMinutes: 0.5 });
|
|
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
|
await ensureState();
|
|
if (alarm.name === 'keepAlive') {
|
|
chrome.storage.session.get('keepAlive', () => {});
|
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
if (!reconnectFailed && (currentRoom || connectIntent)) {
|
|
connect();
|
|
}
|
|
} else if (currentRoom) {
|
|
const now = Date.now();
|
|
const heartbeatAge = lastContentHeartbeatAt ? (now - lastContentHeartbeatAt) : Infinity;
|
|
if (!currentTabId || heartbeatAge > 45000) {
|
|
markRoomPotentiallyIdle();
|
|
}
|
|
if (roomIdleSince && Date.now() - roomIdleSince >= ROOM_IDLE_AUTO_LEAVE_MS) {
|
|
await leaveRoomAfterIdleGrace('Left room after 2 hours without a selected video heartbeat.');
|
|
return;
|
|
}
|
|
// Heartbeat — only broadcast when someone else is in the room.
|
|
// Recomputed live so a freshly joined peer is picked up immediately.
|
|
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
|
if (otherCount > 0) {
|
|
const settings = await getSettings();
|
|
emit(EVENTS.PEER_STATUS, {
|
|
peerId,
|
|
status: 'heartbeat',
|
|
username: settings.username,
|
|
tabTitle: currentTabTitle
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
function leaveOldRoomIfSwitching(newRoomId) {
|
|
if (currentRoom && currentRoom.roomId !== newRoomId) {
|
|
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
|
|
forceDisconnect();
|
|
currentRoom = null;
|
|
if (storageInitialized) chrome.storage.session.set({ currentRoom: null });
|
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
|
|
|
// Reset force sync states
|
|
isForceSyncInitiator = false;
|
|
forceSyncAcks.clear();
|
|
expectedAcksCount = 0;
|
|
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
|
chrome.storage.session.set({
|
|
isForceSyncInitiator: false,
|
|
forceSyncAcks: [],
|
|
forceSyncDeadline: null,
|
|
expectedAcksCount: 0
|
|
});
|
|
|
|
// Cancel any active episode lobby
|
|
clearEpisodeLobbyState();
|
|
}
|
|
}
|
|
|
|
function resetAudioProcessingInTab(tabId) {
|
|
if (!tabId) return;
|
|
chrome.tabs.sendMessage(tabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
|
|
}
|
|
|
|
async function applyAudioSettingsToTab(tabId) {
|
|
if (!tabId) return;
|
|
// Local-only: audioSettings are never read from storage.sync.
|
|
const data = await chrome.storage.local.get(['audioSettings']);
|
|
chrome.tabs.sendMessage(tabId, {
|
|
action: 'APPLY_AUDIO_SETTINGS',
|
|
settings: data.audioSettings
|
|
}).catch(() => {});
|
|
}
|
|
|
|
// --- Extension Message Listeners ---
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
handleAsyncMessage(message, sender, sendResponse);
|
|
return true; // Keep channel open for async responses
|
|
});
|
|
|
|
async function handleAsyncMessage(message, sender, sendResponse) {
|
|
if (!message) return;
|
|
await ensureState();
|
|
|
|
if (message.type === 'CONNECT') {
|
|
const settings = await getSettings();
|
|
connectIntent = !!settings.roomId;
|
|
const desiredUrl = resolveServerUrl(settings);
|
|
|
|
if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
|
|
broadcastConnectionStatus('connected');
|
|
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
|
|
for (const tab of tabs) {
|
|
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
|
|
}
|
|
if (typeof sendResponse === 'function') sendResponse({ status: 'ok' });
|
|
return;
|
|
}
|
|
|
|
reconnectFailed = false;
|
|
reconnectStartTime = null;
|
|
reconnectAttempts = 0;
|
|
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
|
|
|
if (settings.roomId) {
|
|
leaveOldRoomIfSwitching(settings.roomId);
|
|
}
|
|
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
|
if (desiredUrl !== currentServerUrl) forceDisconnect();
|
|
if (settings.roomId) connect();
|
|
} else if (settings.roomId) {
|
|
emit(EVENTS.JOIN_ROOM, {
|
|
roomId: settings.roomId,
|
|
password: settings.password,
|
|
peerId,
|
|
username: settings.username,
|
|
tabTitle: currentTabTitle,
|
|
protocolVersion: PROTOCOL_VERSION
|
|
});
|
|
}
|
|
sendResponse({ status: 'ok' });
|
|
} else if (message.type === 'RETRY_CONNECT') {
|
|
connectIntent = true;
|
|
reconnectFailed = false;
|
|
reconnectStartTime = null;
|
|
reconnectAttempts = 0;
|
|
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
|
forceDisconnect();
|
|
connect();
|
|
sendResponse({ status: 'ok' });
|
|
} else if (message.type === 'GET_STATUS') {
|
|
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
|
|
const isReconnecting = !isConnected && reconnectAttempts > 0;
|
|
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
|
|
sendResponse({
|
|
status,
|
|
peerId,
|
|
peers: currentRoom ? currentRoom.peers : [],
|
|
lastActionState,
|
|
targetTabId: currentTabId,
|
|
episodeLobby: episodeLobby,
|
|
reconnectAttempts,
|
|
reconnectSlowMode: reconnectFailed,
|
|
roomId: currentRoom ? currentRoom.roomId : null,
|
|
serverUrl: currentServerUrl,
|
|
version: chrome.runtime.getManifest().version,
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
roomPassword: currentRoom ? currentRoom.password : null,
|
|
ping: currentPingMs
|
|
});
|
|
} else if (message.type === 'LEAVE_ROOM') {
|
|
connectIntent = false;
|
|
reconnectFailed = false;
|
|
reconnectAttempts = 0;
|
|
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
|
resetAudioProcessingInTab(currentTabId);
|
|
emit(EVENTS.LEAVE_ROOM, { peerId });
|
|
currentRoom = null;
|
|
currentTabId = null;
|
|
currentTabTitle = null;
|
|
roomIdleSince = null;
|
|
lastContentHeartbeatAt = null;
|
|
|
|
updateBadgeStatus();
|
|
|
|
isForceSyncInitiator = false;
|
|
forceSyncAcks.clear();
|
|
expectedAcksCount = 0;
|
|
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
|
|
|
// Cancel any active episode lobby
|
|
clearEpisodeLobbyState();
|
|
|
|
chrome.storage.session.set({
|
|
currentRoom: null,
|
|
currentTabId: null,
|
|
currentTabTitle: null,
|
|
roomIdleSince: null,
|
|
lastContentHeartbeatAt: null,
|
|
isForceSyncInitiator: false,
|
|
forceSyncAcks: [],
|
|
forceSyncDeadline: null,
|
|
episodeLobby: null,
|
|
expectedAcksCount: 0
|
|
});
|
|
chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
|
|
addLog('Left Room', 'info');
|
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
|
forceDisconnect();
|
|
sendResponse({ status: 'ok' });
|
|
} else if (message.type === 'CLEAR_LOGS') {
|
|
logs = [];
|
|
sendResponse({ status: 'ok' });
|
|
} else if (message.type === 'GET_LOGS') {
|
|
sendResponse(logs);
|
|
} else if (message.type === 'GET_HISTORY') {
|
|
sendResponse(history);
|
|
} else if (message.type === 'GET_ROOM_LIST') {
|
|
emit(EVENTS.GET_ROOMS, {});
|
|
sendResponse({ status: 'ok' });
|
|
} else if (message.type === 'WEB_JOIN_REQUEST') {
|
|
const { roomId: rawRoomId, password, useCustomServer, serverUrl } = message;
|
|
const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : '';
|
|
if (!roomId) {
|
|
const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' };
|
|
chrome.runtime.sendMessage(errMsg).catch(() => {});
|
|
chrome.tabs.query({}, (tabs) => {
|
|
tabs.forEach(tab => chrome.tabs.sendMessage(tab.id, errMsg).catch(() => {}));
|
|
});
|
|
sendResponse({ status: 'invalid_room_id' });
|
|
return;
|
|
}
|
|
connectIntent = true;
|
|
chrome.storage.local.set({
|
|
roomId,
|
|
password,
|
|
useCustomServer: !!useCustomServer,
|
|
serverUrl: serverUrl || ''
|
|
}, async () => {
|
|
const settings = await getSettings();
|
|
const desiredUrl = resolveServerUrl(settings);
|
|
|
|
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
|
|
broadcastConnectionStatus('connected');
|
|
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
|
|
for (const tab of tabs) {
|
|
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
|
|
}
|
|
sendResponse({ status: 'already_joined' });
|
|
return;
|
|
}
|
|
|
|
reconnectFailed = false;
|
|
reconnectStartTime = null;
|
|
reconnectAttempts = 0;
|
|
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
|
broadcastConnectionStatus('connecting');
|
|
leaveOldRoomIfSwitching(roomId);
|
|
|
|
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
|
if (desiredUrl !== currentServerUrl) forceDisconnect();
|
|
connect();
|
|
} else if (roomId) {
|
|
emit(EVENTS.JOIN_ROOM, {
|
|
roomId,
|
|
password,
|
|
peerId,
|
|
username: settings.username,
|
|
tabTitle: currentTabTitle,
|
|
protocolVersion: PROTOCOL_VERSION
|
|
});
|
|
}
|
|
addLog(`Joining room via link: ${roomId}`, 'info');
|
|
sendResponse({ status: 'ok' });
|
|
});
|
|
} else if (message.type === 'REGENERATE_ID') {
|
|
const newId = self.crypto.randomUUID().substring(0, 8);
|
|
chrome.storage.local.set({ peerId: newId }, () => {
|
|
peerId = newId;
|
|
addLog(`Identity regenerated: ${newId}`, 'success');
|
|
if (socket) socket.close(); // Force reconnect with new ID
|
|
sendResponse({ peerId: newId });
|
|
});
|
|
} else if (message.type === 'GET_VIDEO_STATE') {
|
|
const { tabId } = message;
|
|
if (!tabId) {
|
|
sendResponse({ error: 'No tabId provided' });
|
|
return;
|
|
}
|
|
chrome.tabs.sendMessage(tabId, { type: 'GET_VIDEO_STATE' }, (res) => {
|
|
if (chrome.runtime.lastError) {
|
|
sendResponse({ error: chrome.runtime.lastError.message });
|
|
} else {
|
|
sendResponse(res);
|
|
}
|
|
});
|
|
} else if (message.type === 'CONTENT_EVENT') {
|
|
const processEvent = () => {
|
|
// Live solo check — recomputed from the current peer list on every
|
|
// event (the list is updated synchronously on PEER_STATUS join/leave),
|
|
// never cached, so the instant a peer joins we resume sending.
|
|
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
|
const hasOtherPeers = otherCount > 0;
|
|
|
|
// Force Sync only makes sense with other peers. Solo it is a no-op:
|
|
// skip the pause/seek + ACK-wait entirely (no freeze, no server traffic).
|
|
if (message.action === EVENTS.FORCE_SYNC_PREPARE && !hasOtherPeers) {
|
|
sendResponse({ status: 'ok_solo' });
|
|
return;
|
|
}
|
|
|
|
const timestamp = Date.now();
|
|
localSeq++;
|
|
chrome.storage.session.set({ localSeq });
|
|
updateLastAction(message.action, 'You', timestamp);
|
|
|
|
const payload = message.payload || {};
|
|
lastActionState.targetTime = payload.targetTime !== undefined ? payload.targetTime : payload.currentTime;
|
|
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
|
|
|
payload.actionTimestamp = timestamp;
|
|
payload.seq = localSeq;
|
|
message.payload = payload;
|
|
|
|
// Local Reactive Update
|
|
updateLocalPeerState(peerId, {
|
|
playbackState: message.action === EVENTS.PLAY ? 'playing' : (message.action === EVENTS.PAUSE ? 'paused' : undefined),
|
|
currentTime: payload.currentTime !== undefined ? payload.currentTime : (payload.targetTime !== undefined ? payload.targetTime : undefined)
|
|
});
|
|
|
|
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
|
|
isForceSyncInitiator = true;
|
|
forceSyncAcks.clear();
|
|
expectedAcksCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
|
|
const deadline = Date.now() + FORCE_SYNC_TIMEOUT;
|
|
chrome.storage.session.set({
|
|
isForceSyncInitiator: true,
|
|
forceSyncAcks: [],
|
|
forceSyncDeadline: deadline,
|
|
expectedAcksCount: expectedAcksCount
|
|
});
|
|
addLog('Initiating Force Sync...', 'info');
|
|
|
|
routeToContent(EVENTS.FORCE_SYNC_PREPARE, message.payload);
|
|
|
|
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
|
forceSyncTimeout = setTimeout(() => {
|
|
if (isForceSyncInitiator) {
|
|
addLog('Force Sync: Timeout waiting for ACKs, executing anyway...', 'warn');
|
|
executeForceSync();
|
|
}
|
|
}, FORCE_SYNC_TIMEOUT);
|
|
}
|
|
addToHistory(message.action, 'You');
|
|
|
|
const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK;
|
|
if (isNonEssentialEvent && !hasOtherPeers) {
|
|
sendResponse({ status: 'ok_solo' });
|
|
return;
|
|
}
|
|
|
|
emit(message.action, { ...message.payload, peerId });
|
|
sendResponse({ status: 'ok' });
|
|
};
|
|
|
|
if (sender.tab) {
|
|
const senderTabId = sender.tab.id;
|
|
|
|
if (!currentTabId || currentTabId !== senderTabId) {
|
|
sendResponse({ status: 'ignored_unselected_tab' });
|
|
return;
|
|
}
|
|
|
|
currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null;
|
|
chrome.storage.session.set({ currentTabTitle });
|
|
updateBadgeStatus();
|
|
processEvent();
|
|
} else {
|
|
routeToContent(message.action, message.payload);
|
|
processEvent();
|
|
}
|
|
} else if (message.type === 'FORCE_SYNC_ACK') {
|
|
if (isForceSyncInitiator) {
|
|
forceSyncAcks.add(peerId);
|
|
chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) });
|
|
addLog(`Local ACK received (${forceSyncAcks.size})`, 'info');
|
|
|
|
// Local Force Sync ACK Reactive Update
|
|
if (lastActionState && lastActionState.action === EVENTS.FORCE_SYNC_PREPARE) {
|
|
updateLocalPeerState(peerId, {
|
|
playbackState: 'paused',
|
|
currentTime: lastActionState.targetTime
|
|
});
|
|
}
|
|
|
|
const peerCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
|
|
if (forceSyncAcks.size >= peerCount) {
|
|
executeForceSync();
|
|
}
|
|
} else {
|
|
localSeq++;
|
|
chrome.storage.session.set({ localSeq });
|
|
emit(EVENTS.FORCE_SYNC_ACK, { peerId, seq: localSeq });
|
|
}
|
|
sendResponse({ status: 'ok' });
|
|
} else if (message.type === 'CMD_ACK') {
|
|
const commandSenderId = message.commandSenderId;
|
|
// Only ACK if the command sender is still a known peer in our room.
|
|
// If we've already seen their PEER_STATUS 'left', skip the ACK — it would
|
|
// only be dropped server-side as an absent-peer ACK anyway.
|
|
const senderStillPresent = currentRoom && Array.isArray(currentRoom.peers) &&
|
|
currentRoom.peers.some(p => (typeof p === 'object' ? p.peerId : p) === commandSenderId);
|
|
if (commandSenderId && commandSenderId !== peerId && senderStillPresent) {
|
|
emit(EVENTS.EVENT_ACK, {
|
|
senderId: peerId,
|
|
targetId: commandSenderId,
|
|
actionTimestamp: message.actionTimestamp
|
|
});
|
|
}
|
|
sendResponse({ status: 'ok' });
|
|
} else if (message.type === 'HEARTBEAT') {
|
|
if (sender.tab) {
|
|
const senderTabId = sender.tab.id;
|
|
|
|
if (!currentTabId || currentTabId !== senderTabId) {
|
|
sendResponse({ status: 'ignored_unselected_tab' });
|
|
return;
|
|
}
|
|
|
|
currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null;
|
|
chrome.storage.session.set({ currentTabTitle });
|
|
updateBadgeStatus();
|
|
}
|
|
|
|
markRoomUseful();
|
|
getSettings().then(settings => {
|
|
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle };
|
|
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
|
if (otherCount > 0) emit(EVENTS.PEER_STATUS, statusPayload);
|
|
|
|
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
|
const me = currentRoom.peers.find(p => (p.peerId || p) === peerId);
|
|
if (me && typeof me === 'object') {
|
|
me.tabTitle = currentTabTitle;
|
|
me.username = settings.username;
|
|
me.mediaTitle = message.payload?.mediaTitle;
|
|
me.playbackState = message.payload?.playbackState;
|
|
me.currentTime = message.payload?.currentTime;
|
|
me.volume = message.payload?.volume;
|
|
me.muted = message.payload?.muted;
|
|
me.lastHeartbeat = Date.now();
|
|
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
|
}
|
|
}
|
|
sendResponse({ status: 'ok' });
|
|
}).catch(err => {
|
|
addLog('Heartbeat settings error: ' + err.message, 'error');
|
|
sendResponse({ status: 'ok' });
|
|
});
|
|
} else if (message.type === 'SET_TARGET_TAB') {
|
|
const previousTabId = currentTabId;
|
|
currentTabId = message.tabId;
|
|
currentTabTitle = message.tabTitle;
|
|
lastContentHeartbeatAt = null;
|
|
if (currentRoom) {
|
|
roomIdleSince = Date.now();
|
|
}
|
|
chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt });
|
|
updateBadgeStatus();
|
|
|
|
if (previousTabId && previousTabId !== currentTabId) {
|
|
resetAudioProcessingInTab(previousTabId);
|
|
}
|
|
|
|
if (currentTabId) {
|
|
const selectedTabId = currentTabId;
|
|
chrome.scripting.executeScript({
|
|
target: { tabId: selectedTabId },
|
|
files: ['content.js']
|
|
})
|
|
.then(() => applyAudioSettingsToTab(selectedTabId))
|
|
.catch(err => {
|
|
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
|
|
});
|
|
}
|
|
|
|
sendResponse({ status: 'ok' });
|
|
} 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.local.get(['autoSyncNextEpisode']);
|
|
if (epSettings.autoSyncNextEpisode === false) {
|
|
addLog(`Episode change detected ("${newTitle}") but Auto-Sync is disabled.`, 'info');
|
|
sendResponse({ status: 'disabled' });
|
|
return;
|
|
}
|
|
|
|
// Variant A: alone in the room → no one to wait for. Skip the lobby
|
|
// entirely so the next episode just plays through (no pause, no traffic).
|
|
// Live peer check, so the moment someone joins the next transition syncs.
|
|
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
|
if (otherCount === 0) {
|
|
addLog(`Episode change ("${newTitle}") — alone in room, playing through without a lobby.`, 'info');
|
|
sendResponse({ status: 'solo_no_lobby' });
|
|
return;
|
|
}
|
|
|
|
// If lobby already exists for this title, just mark self ready
|
|
if (episodeLobby && sameEpisode(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 && sameEpisode(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 if (message.type === 'CANCEL_EPISODE_LOBBY') {
|
|
if (episodeLobby) {
|
|
cancelEpisodeLobby('Cancelled by user');
|
|
sendResponse({ status: 'ok' });
|
|
} else {
|
|
sendResponse({ error: 'No active lobby' });
|
|
}
|
|
} else {
|
|
// Final fallback to prevent channel hanging
|
|
sendResponse({ error: 'unhandled_message' });
|
|
}
|
|
}
|
|
|
|
initTabManager({
|
|
getCurrentTabId: () => currentTabId,
|
|
setCurrentTabId: (val) => { currentTabId = val; },
|
|
setCurrentTabTitle: (val) => { currentTabTitle = val; },
|
|
setLastContentHeartbeatAt: (val) => { lastContentHeartbeatAt = val; },
|
|
setRoomIdleSince: (val) => { roomIdleSince = val; },
|
|
getCurrentRoom: () => currentRoom,
|
|
getPeerId: () => peerId,
|
|
getStorageInitialized: () => storageInitialized,
|
|
updateBadgeStatus,
|
|
addLog,
|
|
getSettings,
|
|
emit,
|
|
applyAudioSettingsToTab,
|
|
ensureState,
|
|
EVENTS
|
|
});
|
|
|
|
// Initial Connect — only if user has an active room configuration
|
|
getSettings().then(settings => {
|
|
connectIntent = !!settings.roomId;
|
|
if (connectIntent) connect();
|
|
}).catch(() => connectIntent = false);
|