mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
6276ee134c
Bump version to 2.4.6
2058 lines
86 KiB
JavaScript
2058 lines
86 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) {
|
|
// No room and no intent to connect → this isn't a failure, it's the normal
|
|
// resting state. Surface a distinct 'idle' status so the UI can say
|
|
// "ready to connect" instead of a misleading red "Disconnected".
|
|
if (status === 'disconnected' && !currentRoom && !connectIntent) {
|
|
status = 'idle';
|
|
}
|
|
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'));
|
|
// Distinguish the normal "not in a room" resting state from a real drop.
|
|
if (status === 'disconnected' && !currentRoom && !connectIntent) status = 'idle';
|
|
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);
|