mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-19 15:46:15 +00:00
f450584562
getReadyTabVideoState() treated "no video found" as a broken injection and forced a full reactivation. On a page that legitimately has no video yet — an anime or Drive page before playback starts — that fired on every call, and the dev panel polls it on a timer. The result was an endless teardown and reinjection cycle: the target never settled, the popup showed "activating" forever, and the panel reported "Target tab changed before content script recovery completed" because each read raced the reactivation it had triggered. Only an unreachable content script justifies recovery now, and that recovery no longer reinjects unless the selected frame actually moved. Audited against v3.1.2, which worked on these pages. The only unjustified deviation left was the retry budget, which had been cut from eight passes to three and shortened the window for a late-loading player; it is back at eight, now bounded by a wall-clock deadline instead of being unbounded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4522 lines
193 KiB
JavaScript
4522 lines
193 KiB
JavaScript
import { EVENTS, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
|
|
import { generateUsername } from './shared/names.js';
|
|
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
|
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
|
|
import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js';
|
|
import { initTabManager } from './modules/tab-manager.js';
|
|
import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js';
|
|
import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js';
|
|
import { createChatEchoTracker, createChatSendLimiter, createLatestTaskQueue, normalizeRoomId, shouldShowChatNotification } from './chat-session.js';
|
|
import { createChatActivityStore } from './chat-activity.js';
|
|
import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js';
|
|
import {
|
|
MEDIA_FRAME_ACCESS_REQUIRED,
|
|
listMediaFrameScriptTargets,
|
|
resolveMediaContentTarget
|
|
} from './media-frame-target.js';
|
|
import './page-api-seek-overrides.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
|
|
// The tab the user picked, kept separately from the tab we managed to inject
|
|
// into. A failed or refused activation is a state of the selection, not a
|
|
// reason to silently discard it: dropping it here is what made the popup show
|
|
// no target again as soon as it was reopened.
|
|
let userSelectedTabId = null;
|
|
let userSelectedTabTitle = null;
|
|
let userSelectionErrorTabId = null;
|
|
let userSelectionErrorMessage = null;
|
|
let currentTargetFrameId = 0;
|
|
let currentTargetDocumentId = null;
|
|
let currentTargetHasVideo = false;
|
|
let targetActivationGeneration = 0;
|
|
let activeTargetActivation = null;
|
|
let mediaTargetRefreshTask = null;
|
|
let mediaTargetRefreshTabId = null;
|
|
let mediaTargetRefreshDirty = false;
|
|
let mediaTargetRefreshFollowupTimer = null;
|
|
let contentCommandQueue = Promise.resolve();
|
|
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)
|
|
|
|
// --- Host Control Mode ---
|
|
let controlMode = CONTROL_MODES.EVERYONE; // 'everyone' | 'host-only'
|
|
let hostPeerId = null; // peerId of the room host (creator / fallback)
|
|
// Features the connected relay advertises in ROOM_DATA. Empty against an older
|
|
// relay (no capabilities field) → host-control UI/behavior stays unavailable.
|
|
let serverCapabilities = [];
|
|
let chatSecretGuard = '';
|
|
let chatSessionGeneration = 0;
|
|
let chatReceiveQueue = Promise.resolve();
|
|
const chatSendLimiter = createChatSendLimiter();
|
|
const chatEchoTracker = createChatEchoTracker();
|
|
const chatActivityStore = createChatActivityStore();
|
|
const webJoinCoordinator = createLatestTaskQueue();
|
|
function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); }
|
|
function serverSupportsChat() {
|
|
return serverSupports(CAPABILITIES.CHAT_V1) || serverSupports(CAPABILITIES.CHAT);
|
|
}
|
|
const CLIENT_CAPABILITIES = Object.freeze([CAPABILITIES.CHAT_V1]);
|
|
|
|
function invalidateChatSession() {
|
|
chatSessionGeneration++;
|
|
chatReceiveQueue = Promise.resolve();
|
|
chatSendLimiter.reset();
|
|
chatEchoTracker.reset();
|
|
clearChatKeyCache();
|
|
}
|
|
|
|
function clearChatActivity() {
|
|
chatActivityStore.clear();
|
|
if (storageInitialized) chrome.storage.session.set({ chatActivityTimeline: [] }).catch(() => {});
|
|
}
|
|
|
|
async function clearFailedJoinCredentials() {
|
|
webJoinCoordinator.invalidate();
|
|
connectIntent = false;
|
|
chatSecretGuard = '';
|
|
invalidateChatSession();
|
|
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
|
|
}
|
|
// Local peer's desync state (content.js reports it via HCM_DESYNC_STATE). Relayed
|
|
// in heartbeats so the host's popup UI can show "Solo" instead of silently
|
|
// appearing un-ACK'd.
|
|
let hcmDesynced = false;
|
|
// Co-Host: peerIds allowed to drive in host-only (always includes the owner).
|
|
let controllers = [];
|
|
function amHost() { return !!peerId && hostPeerId === peerId; } // owner: can toggle mode / promote
|
|
function amController() { return amHost() || (!!peerId && controllers.includes(peerId)); } // can drive the room
|
|
// Room-moving actions a guest may not initiate while in host-only mode.
|
|
const HOST_ONLY_GATED_ACTIONS = [
|
|
EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
|
|
EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE,
|
|
EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_LOBBY_CANCEL
|
|
];
|
|
// Best-effort estimate of where the room (host) is right now, for guest snap-back.
|
|
// Extrapolates from the host peer's last known state. Used by content.js.
|
|
function getHostSyncTarget() {
|
|
if (!currentRoom || !Array.isArray(currentRoom.peers)) return null;
|
|
const host = currentRoom.peers.find(p => (typeof p === 'object' ? p.peerId : p) === hostPeerId);
|
|
if (!host || typeof host !== 'object') return null;
|
|
let targetTime = typeof host.currentTime === 'number' ? host.currentTime : null;
|
|
if (targetTime !== null && host.playbackState === 'playing' && host.lastHeartbeat) {
|
|
// M-4: clamp extrapolation. lastHeartbeat is the *arrival* time of the host's
|
|
// last heartbeat — beyond ~2 heartbeat intervals the host's true state is too
|
|
// stale (they may have paused without the next heartbeat landing yet) and the
|
|
// linear extrapolation would overshoot by tens of seconds. Cap it so the
|
|
// guest snaps to a position within plausibility; the next heartbeat corrects.
|
|
const elapsedSec = (Date.now() - host.lastHeartbeat) / 1000;
|
|
if (elapsedSec > 0 && elapsedSec <= 2 * HEARTBEAT_INTERVAL / 1000) {
|
|
targetTime += elapsedSec;
|
|
}
|
|
}
|
|
return { playbackState: host.playbackState || null, targetTime };
|
|
}
|
|
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);
|
|
});
|
|
}
|
|
});
|
|
|
|
let _persistLastSeqTimer = null;
|
|
function _persistLastSeq() {
|
|
if (!storageInitialized) return;
|
|
if (_persistLastSeqTimer) clearTimeout(_persistLastSeqTimer);
|
|
_persistLastSeqTimer = setTimeout(() => {
|
|
_persistLastSeqTimer = null;
|
|
chrome.storage.session.set({ lastSeqBySender });
|
|
}, 500);
|
|
}
|
|
|
|
// --- 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',
|
|
'currentTargetFrameId', 'currentTargetDocumentId', 'currentTargetHasVideo',
|
|
'selectedTabId', 'selectedTabTitle', 'selectionErrorTabId', 'selectionErrorMessage',
|
|
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt',
|
|
'hcmDesynced', 'chatActivityTimeline'
|
|
], (data) => {
|
|
clearTimeout(storageTimeout);
|
|
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
|
|
if (data.currentTabId !== undefined) currentTabId = normalizeTabId(data.currentTabId);
|
|
userSelectedTabId = normalizeTabId(data.selectedTabId);
|
|
userSelectedTabTitle = userSelectedTabId !== null && typeof data.selectedTabTitle === 'string'
|
|
? data.selectedTabTitle
|
|
: null;
|
|
userSelectionErrorTabId = normalizeTabId(data.selectionErrorTabId);
|
|
userSelectionErrorMessage = userSelectionErrorTabId !== null
|
|
&& typeof data.selectionErrorMessage === 'string'
|
|
? data.selectionErrorMessage
|
|
: null;
|
|
currentTargetFrameId = currentTabId !== null
|
|
&& Number.isInteger(data.currentTargetFrameId)
|
|
&& data.currentTargetFrameId >= 0
|
|
? data.currentTargetFrameId
|
|
: 0;
|
|
currentTargetDocumentId = currentTabId !== null
|
|
&& typeof data.currentTargetDocumentId === 'string'
|
|
&& data.currentTargetDocumentId
|
|
? data.currentTargetDocumentId
|
|
: null;
|
|
currentTargetHasVideo = currentTabId !== null && data.currentTargetHasVideo === true;
|
|
if (data.currentTabTitle !== undefined) {
|
|
currentTabTitle = currentTabId !== null && typeof data.currentTabTitle === 'string'
|
|
? data.currentTabTitle
|
|
: null;
|
|
}
|
|
// 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;
|
|
// Host Control Mode: restore role/mode/capabilities from persisted room.
|
|
controlMode = currentRoom.controlMode || CONTROL_MODES.EVERYONE;
|
|
hostPeerId = currentRoom.hostPeerId || null;
|
|
controllers = Array.isArray(currentRoom.controllers) ? currentRoom.controllers : [];
|
|
serverCapabilities = Array.isArray(currentRoom.capabilities) ? currentRoom.capabilities : [];
|
|
chatActivityStore.restore(data.chatActivityTimeline);
|
|
}
|
|
if (data.hcmDesynced !== undefined) hcmDesynced = data.hcmDesynced;
|
|
// L-2: enforce the desync invariant on restore — a persisted hcmDesynced=true
|
|
// is stale if our restored role is no longer "gated guest" (e.g. we became
|
|
// the host, or the room is in 'everyone'). Without this, the first heartbeat
|
|
// after SW restart would broadcast a bogus Solo flag for up to 15s.
|
|
hcmEnforceDesyncInvariant();
|
|
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,
|
|
desynced: raw.desynced === true, // HCM: peer is watching on their own
|
|
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;
|
|
// 16 hex chars = 64 bits. At a busy relay (25k concurrent peers) the 32-bit
|
|
// (8-hex) generation would hit ~7% collision probability per snapshot —
|
|
// and a same-room collision triggers our dedup path, kicking the older
|
|
// session with a confusing error. 16 hex chars drops the probability to
|
|
// ~1e-10 even at a million peers, and the server already clamps peerId to
|
|
// 16 chars (server/index.js JOIN_ROOM sanitizer). Existing persisted 8-char
|
|
// IDs continue to work — this only affects newly-generated IDs.
|
|
const newId = self.crypto.randomUUID().replace(/-/g, '').substring(0, 16);
|
|
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', 'chatKey', 'chatEnabled', 'chatNotifications', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']);
|
|
let username = data.username;
|
|
if (!username) {
|
|
username = generateUsername();
|
|
await chrome.storage.local.set({ username });
|
|
}
|
|
const legacyTitlePrivacyMode = normalizeTitlePrivacyMode(data.titlePrivacyMode);
|
|
const mediaTitlePrivacyMode = normalizeTitlePrivacyMode(data.mediaTitlePrivacyMode || legacyTitlePrivacyMode);
|
|
const chatKey = validateChatSecret(data.chatKey);
|
|
chatSecretGuard = chatKey;
|
|
const roomId = normalizeRoomId(data.roomId);
|
|
if (data.roomId && data.roomId !== roomId) chrome.storage.local.set({ roomId }).catch(() => {});
|
|
return {
|
|
serverUrl: data.serverUrl || '',
|
|
useCustomServer: data.useCustomServer || false,
|
|
roomId,
|
|
password: data.password || '',
|
|
chatKey,
|
|
chatEnabled: data.chatEnabled === true,
|
|
chatNotifications: data.chatNotifications !== false,
|
|
username,
|
|
sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode),
|
|
mediaTitlePrivacyMode
|
|
};
|
|
}
|
|
|
|
function getSharedTitleFields(settings, mediaTitle = null) {
|
|
return {
|
|
tabTitle: sanitizeTabTitle(currentTabTitle, settings?.sendTabTitle),
|
|
mediaTitle: sanitizeSharedTitle(mediaTitle, settings?.mediaTitlePrivacyMode)
|
|
};
|
|
}
|
|
|
|
function withTitlePrivacy(payload, settings, keys) {
|
|
return applyTitlePrivacyToPayload(payload, settings?.mediaTitlePrivacyMode, keys);
|
|
}
|
|
|
|
function emitEpisodeLobbyForCurrentPrivacy() {
|
|
if (!episodeLobby || episodeLobby.initiatorPeerId !== peerId) return;
|
|
getSettings().then(settings => {
|
|
if (!episodeLobby || episodeLobby.initiatorPeerId !== peerId) return;
|
|
const expectedTitle = sanitizeSharedTitle(episodeLobby.expectedTitle, settings.mediaTitlePrivacyMode);
|
|
if (expectedTitle) {
|
|
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle });
|
|
}
|
|
}).catch(err => {
|
|
addLog('Episode lobby privacy error: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
// 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', 'chatKey',
|
|
'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay', 'username',
|
|
'filterNoise', 'customBlacklistDomains', 'blacklistOverrides', 'autoSyncNextEpisode', 'forceSyncMode',
|
|
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings',
|
|
'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode'
|
|
];
|
|
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;
|
|
invalidateChatSession();
|
|
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 invalidateTargetActivations() {
|
|
targetActivationGeneration++;
|
|
activeTargetActivation = null;
|
|
mediaTargetRefreshDirty = false;
|
|
if (mediaTargetRefreshFollowupTimer !== null) {
|
|
clearTimeout(mediaTargetRefreshFollowupTimer);
|
|
mediaTargetRefreshFollowupTimer = null;
|
|
}
|
|
return targetActivationGeneration;
|
|
}
|
|
|
|
function isCurrentTargetIdentity(tabId, generation) {
|
|
return normalizeTabId(currentTabId) === normalizeTabId(tabId)
|
|
&& targetActivationGeneration === generation;
|
|
}
|
|
|
|
function normalizeFrameId(value) {
|
|
return Number.isInteger(value) && value >= 0 ? value : 0;
|
|
}
|
|
|
|
function currentContentTarget() {
|
|
return {
|
|
frameId: normalizeFrameId(currentTargetFrameId),
|
|
documentId: typeof currentTargetDocumentId === 'string' && currentTargetDocumentId
|
|
? currentTargetDocumentId
|
|
: null,
|
|
hasVideo: currentTargetHasVideo
|
|
};
|
|
}
|
|
|
|
function targetMessageOptions(frameId, documentId = null) {
|
|
return typeof documentId === 'string' && documentId
|
|
? { documentId }
|
|
: { frameId: normalizeFrameId(frameId) };
|
|
}
|
|
|
|
function sendMessageToFrame(tabId, frameId, message, callback = null, documentId = null) {
|
|
const options = targetMessageOptions(frameId, documentId);
|
|
if (typeof callback === 'function') {
|
|
return chrome.tabs.sendMessage(tabId, message, options, callback);
|
|
}
|
|
return chrome.tabs.sendMessage(tabId, message, options);
|
|
}
|
|
|
|
function sendMessageToCurrentContent(message, callback = null) {
|
|
const tabId = normalizeTabId(currentTabId);
|
|
if (tabId === null) {
|
|
return typeof callback === 'function' ? undefined : Promise.reject(new Error('No target tab selected'));
|
|
}
|
|
return sendMessageToFrame(
|
|
tabId,
|
|
currentTargetFrameId,
|
|
message,
|
|
callback,
|
|
currentTargetDocumentId
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Chat is page UI, not player UI. The controlled video can live in a nested
|
|
* cross-origin frame (Drive, YummyAnime), but the overlay always belongs to the
|
|
* tab's top document: inside the player frame it renders on top of the video,
|
|
* and closing or minimizing it only affects that frame.
|
|
*/
|
|
function sendMessageToChatOverlay(message) {
|
|
const tabId = normalizeTabId(currentTabId);
|
|
if (tabId === null) return Promise.reject(new Error('No target tab selected'));
|
|
return sendMessageToFrame(tabId, 0, message);
|
|
}
|
|
|
|
function sendMessageToContentTab(tabId, message, callback = null) {
|
|
if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) {
|
|
return sendMessageToCurrentContent(message, callback);
|
|
}
|
|
if (typeof callback === 'function') {
|
|
return chrome.tabs.sendMessage(tabId, message, callback);
|
|
}
|
|
return chrome.tabs.sendMessage(tabId, message);
|
|
}
|
|
|
|
function isCurrentContentSender(sender) {
|
|
if (!sender?.tab) return false;
|
|
const senderTabId = normalizeTabId(sender.tab.id);
|
|
const senderFrameId = normalizeFrameId(sender.frameId);
|
|
const matchesActivation = senderTabId === normalizeTabId(activeTargetActivation?.tabId)
|
|
&& senderFrameId === normalizeFrameId(activeTargetActivation?.frameId)
|
|
&& (!activeTargetActivation?.documentId
|
|
|| sender.documentId === activeTargetActivation.documentId);
|
|
if (Number.isInteger(activeTargetActivation?.frameId)) return matchesActivation;
|
|
return senderTabId === normalizeTabId(currentTabId)
|
|
&& senderFrameId === normalizeFrameId(currentTargetFrameId)
|
|
&& (!currentTargetDocumentId || sender.documentId === currentTargetDocumentId);
|
|
}
|
|
|
|
function isExtensionPageSender(sender) {
|
|
const extensionRoot = chrome.runtime.getURL('');
|
|
return typeof sender?.url === 'string' && sender.url.startsWith(extensionRoot);
|
|
}
|
|
|
|
function sameContentTarget(left, right) {
|
|
return normalizeFrameId(left?.frameId) === normalizeFrameId(right?.frameId)
|
|
&& (!left?.documentId || !right?.documentId || left.documentId === right.documentId);
|
|
}
|
|
|
|
function clearCurrentContentTarget() {
|
|
currentTargetFrameId = 0;
|
|
currentTargetDocumentId = null;
|
|
currentTargetHasVideo = false;
|
|
}
|
|
|
|
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
|
|
if (expectedTabId !== null && normalizeTabId(currentTabId) !== normalizeTabId(expectedTabId)) {
|
|
return false;
|
|
}
|
|
if (expectedGeneration !== null && targetActivationGeneration !== expectedGeneration) {
|
|
return false;
|
|
}
|
|
|
|
completeForceSyncBeforeTargetChange(null);
|
|
invalidateTargetActivations();
|
|
clearPendingTarget().catch(() => {});
|
|
if (currentTabId) deactivateTargetTab(currentTabId).catch(() => {});
|
|
currentTabId = null;
|
|
currentTabTitle = null;
|
|
clearCurrentContentTarget();
|
|
lastContentHeartbeatAt = null;
|
|
if (currentRoom) {
|
|
roomIdleSince = Date.now();
|
|
}
|
|
chrome.storage.session.set({
|
|
currentTabId,
|
|
currentTabTitle,
|
|
currentTargetFrameId,
|
|
currentTargetDocumentId,
|
|
currentTargetHasVideo,
|
|
roomIdleSince,
|
|
lastContentHeartbeatAt
|
|
}).catch(() => {});
|
|
updateBadgeStatus();
|
|
return true;
|
|
}
|
|
|
|
async function leaveRoomAfterIdleGrace(reason) {
|
|
if (!currentRoom) return;
|
|
connectIntent = false;
|
|
reconnectFailed = false;
|
|
reconnectAttempts = 0;
|
|
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
|
completeForceSyncBeforeTargetChange(null);
|
|
emit(EVENTS.LEAVE_ROOM, { peerId });
|
|
forceDisconnect();
|
|
currentRoom = null;
|
|
clearChatActivity();
|
|
controlMode = CONTROL_MODES.EVERYONE;
|
|
hostPeerId = null;
|
|
controllers = [];
|
|
serverCapabilities = [];
|
|
hcmDesynced = false;
|
|
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
|
|
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
|
|
broadcastControlMode();
|
|
if (currentTabId) await deactivateTargetTab(currentTabId);
|
|
invalidateTargetActivations();
|
|
currentTabId = null;
|
|
currentTabTitle = null;
|
|
clearCurrentContentTarget();
|
|
roomIdleSince = null;
|
|
lastContentHeartbeatAt = null;
|
|
clearEpisodeLobbyState();
|
|
await clearPendingTarget();
|
|
await chrome.storage.session.set({
|
|
currentRoom: null,
|
|
chatActivityTimeline: [],
|
|
currentTabId: null,
|
|
currentTabTitle: null,
|
|
currentTargetFrameId: 0,
|
|
currentTargetDocumentId: null,
|
|
currentTargetHasVideo: false,
|
|
roomIdleSince: null,
|
|
lastContentHeartbeatAt: null,
|
|
episodeLobby: null,
|
|
hcmDesynced: false
|
|
}).catch(() => {});
|
|
chatSecretGuard = '';
|
|
invalidateChatSession();
|
|
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).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) {
|
|
const sharedTitles = getSharedTitleFields(settings);
|
|
emit(EVENTS.JOIN_ROOM, {
|
|
roomId: settings.roomId,
|
|
password: settings.password,
|
|
peerId,
|
|
username: settings.username,
|
|
tabTitle: sharedTitles.tabTitle,
|
|
clientCapabilities: CLIENT_CAPABILITIES,
|
|
protocolVersion: PROTOCOL_VERSION
|
|
});
|
|
}
|
|
flushEventQueue();
|
|
} else if (msg.startsWith('42')) {
|
|
try {
|
|
const payload = JSON.parse(msg.substring(2));
|
|
try {
|
|
await 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;
|
|
invalidateChatSession();
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// Invariant: only a gated guest (host-only room AND not the host) can be
|
|
// "desynced". Any role/mode change that makes us the host, or switches the room
|
|
// to 'everyone', must clear the persisted flag — otherwise a stale value would
|
|
// mislabel us as "Solo" to peers and (in content) keep us ignoring host commands
|
|
// after the reason to is gone. Call after any controlMode/hostPeerId change.
|
|
function hcmEnforceDesyncInvariant() {
|
|
if (hcmDesynced && !(controlMode === CONTROL_MODES.HOST_ONLY && !amController())) {
|
|
hcmDesynced = false;
|
|
if (storageInitialized) chrome.storage.session.set({ hcmDesynced: false });
|
|
}
|
|
}
|
|
|
|
function broadcastControlMode() {
|
|
// Notify popup (role badge / host toggle) and the active content tab
|
|
// (so it can enable/disable the host-only guest gate).
|
|
const payload = { type: 'CONTROL_MODE', controlMode, hostPeerId, controllers, amHost: amHost(), amController: amController(), hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL), coHostSupported: serverSupports(CAPABILITIES.CO_HOST) };
|
|
chrome.runtime.sendMessage(payload).catch(() => {});
|
|
if (currentTabId) {
|
|
const tabId = parseInt(currentTabId);
|
|
if (!isNaN(tabId)) sendMessageToCurrentContent(payload).catch(() => {});
|
|
}
|
|
}
|
|
|
|
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(() => {});
|
|
if (currentTabId) sendMessageToCurrentContent({ type: 'CONNECTION_STATUS', status }).catch(() => {});
|
|
updateBadgeStatus();
|
|
}
|
|
|
|
async function broadcastJoinStatus(message, shouldSend = () => true) {
|
|
let websiteTabs = [];
|
|
try {
|
|
websiteTabs = await chrome.tabs.query({ url: 'https://sync.koalastuff.net/*' });
|
|
} catch (_) {
|
|
// The website bridge is optional and may be unavailable.
|
|
}
|
|
if (!shouldSend()) return false;
|
|
chrome.runtime.sendMessage(message).catch(() => {});
|
|
await Promise.all(websiteTabs.map(tab =>
|
|
chrome.tabs.sendMessage(tab.id, message).catch(() => {})
|
|
));
|
|
return true;
|
|
}
|
|
|
|
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: '#c96736' });
|
|
} else if (status === 'connecting') {
|
|
chrome.action.setBadgeText({ text: '...' });
|
|
chrome.action.setBadgeBackgroundColor({ color: '#de7949' });
|
|
} else if (status === 'connected' && currentRoom && currentTabId) {
|
|
chrome.action.setBadgeText({ text: 'ON' });
|
|
chrome.action.setBadgeBackgroundColor({ color: '#56ae6c' });
|
|
} 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 = action === 'joined'
|
|
? getMessage('TOAST_PEER_JOINED', { name: displayName })
|
|
: action === 'left'
|
|
? getMessage('TOAST_PEER_LEFT', { name: displayName })
|
|
: 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 getTabForNotification(tabId) {
|
|
return new Promise(resolve => {
|
|
try {
|
|
chrome.tabs.get(tabId, tab => {
|
|
if (chrome.runtime.lastError) resolve(null);
|
|
else resolve(tab || null);
|
|
});
|
|
} catch (_) {
|
|
resolve(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
function getWindowForNotification(windowId) {
|
|
return new Promise(resolve => {
|
|
try {
|
|
chrome.windows.get(windowId, windowInfo => {
|
|
if (chrome.runtime.lastError) resolve(null);
|
|
else resolve(windowInfo || null);
|
|
});
|
|
} catch (_) {
|
|
resolve(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
function showChatNotification(displayName, text) {
|
|
chrome.storage.local.get(['chatNotifications', 'locale'], async (settings) => {
|
|
const enabled = settings.chatNotifications !== false;
|
|
if (!enabled) return;
|
|
const targetTabId = normalizeTabId(currentTabId);
|
|
const tab = targetTabId === null ? null : await getTabForNotification(targetTabId);
|
|
const windowInfo = Number.isInteger(tab?.windowId)
|
|
? await getWindowForNotification(tab.windowId)
|
|
: null;
|
|
if (!shouldShowChatNotification({ enabled, targetTabId, tab, windowInfo })) return;
|
|
|
|
await loadLocale(settings.locale || getSystemLanguage());
|
|
chrome.notifications.create(`chat_${Date.now()}`, {
|
|
type: 'basic',
|
|
iconUrl: 'icons/icon128.png',
|
|
title: getMessage('CHAT_TITLE') || 'KoalaSync',
|
|
message: `${displayName || getMessage('CHAT_TITLE') || 'Room Chat'}: ${text}`,
|
|
priority: 1
|
|
});
|
|
});
|
|
}
|
|
|
|
function chatActivityDisplayName(senderId) {
|
|
if (senderId === peerId) return '';
|
|
const peer = currentRoom?.peers?.find(candidate =>
|
|
(typeof candidate === 'object' ? candidate.peerId : candidate) === senderId
|
|
);
|
|
return typeof peer === 'object' ? peer.username || senderId : senderId;
|
|
}
|
|
|
|
function sendChatActivity(action, senderId, timestamp = Date.now()) {
|
|
if (!currentRoom || !serverSupportsChat()) return;
|
|
if (![EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE, 'joined', 'left'].includes(action)) return;
|
|
const entry = chatActivityStore.add({
|
|
action,
|
|
senderId,
|
|
username: chatActivityDisplayName(senderId),
|
|
timestamp: Number.isFinite(timestamp) ? timestamp : Date.now()
|
|
});
|
|
if (!entry) return;
|
|
if (storageInitialized) chrome.storage.session.set({ chatActivityTimeline: chatActivityStore.snapshot() }).catch(() => {});
|
|
if (!currentTabId) return;
|
|
sendMessageToChatOverlay({
|
|
type: 'CHAT_EVENT',
|
|
event: entry
|
|
}).catch(error => addLog(`Chat activity delivery failed: ${error.message}`, 'warn'));
|
|
}
|
|
|
|
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) {
|
|
try {
|
|
const msg = encodeSocketEvent(event, data, chatSecretGuard);
|
|
socket.send(msg);
|
|
} catch (e) {
|
|
if (e.message === 'Refusing to send chat secret to relay') {
|
|
addLog(e.message, 'error');
|
|
return;
|
|
}
|
|
// 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 emitLive(event, data) {
|
|
if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) return false;
|
|
try {
|
|
socket.send(encodeSocketEvent(event, data, chatSecretGuard));
|
|
return true;
|
|
} catch (e) {
|
|
addLog(e.message === 'Refusing to send chat secret to relay' ? e.message : `Live send failed for ${event}: ${e.message}`, 'error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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 ---
|
|
async function handleServerEvent(event, data) {
|
|
if (!data) {
|
|
addLog(`Ignored server event ${event} due to empty payload`, 'warn');
|
|
return;
|
|
}
|
|
// Host Control Mode (receiver-side backstop): in host-only mode, ignore
|
|
// room-moving events from any non-controller. The server already drops these,
|
|
// so this covers old/buggy/modified clients that slipped through.
|
|
// Defensive: require a known hostPeerId — if the server ever sends host-only
|
|
// without a host (state inconsistency), gate-everyone would lock the owner
|
|
// out of their own room (L-6).
|
|
if (controlMode === CONTROL_MODES.HOST_ONLY &&
|
|
hostPeerId &&
|
|
HOST_ONLY_GATED_ACTIONS.includes(event) &&
|
|
data.senderId && data.senderId !== hostPeerId && !controllers.includes(data.senderId)) {
|
|
addLog(`Ignored ${event} from non-controller ${data.senderId} (host-only)`, 'warn');
|
|
return;
|
|
}
|
|
switch (event) {
|
|
case EVENTS.ROOM_DATA:
|
|
if (currentRoom?.roomId !== data.roomId) {
|
|
invalidateChatSession();
|
|
clearChatActivity();
|
|
}
|
|
currentRoom = data;
|
|
// Host Control Mode: adopt room role/mode on (re)join.
|
|
controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
|
|
hostPeerId = data.hostPeerId || null;
|
|
controllers = Array.isArray(data.controllers) ? data.controllers : [];
|
|
serverCapabilities = Array.isArray(data.capabilities) ? data.capabilities : [];
|
|
if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
|
hcmEnforceDesyncInvariant();
|
|
broadcastControlMode();
|
|
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)) {
|
|
sendMessageToCurrentContent({
|
|
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' };
|
|
await broadcastJoinStatus(joinStatusMsg);
|
|
break;
|
|
case EVENTS.CONTROL_MODE:
|
|
// Host Control Mode changed (toggle or host-leave fallback).
|
|
controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
|
|
hostPeerId = data.hostPeerId || null;
|
|
controllers = Array.isArray(data.controllers) ? data.controllers : [];
|
|
hcmEnforceDesyncInvariant();
|
|
if (currentRoom) {
|
|
currentRoom.controlMode = controlMode;
|
|
currentRoom.hostPeerId = hostPeerId;
|
|
currentRoom.controllers = controllers;
|
|
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
|
}
|
|
addLog(`Control mode: ${controlMode}${amHost() ? ' (you are owner)' : (amController() ? ' (you are controller)' : '')}`, 'info');
|
|
broadcastControlMode();
|
|
break;
|
|
case EVENTS.ROOM_LIST:
|
|
chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {});
|
|
break;
|
|
case EVENTS.CHAT_MESSAGE: {
|
|
if (!currentRoom || !serverSupportsChat() || !currentTabId) break;
|
|
const generation = chatSessionGeneration;
|
|
const roomId = currentRoom.roomId;
|
|
const tabId = Number(currentTabId);
|
|
const received = { ...data };
|
|
const isCurrentSession = () => generation === chatSessionGeneration &&
|
|
currentRoom?.roomId === roomId && Number(currentTabId) === tabId;
|
|
chatReceiveQueue = chatReceiveQueue.catch(() => {}).then(async () => {
|
|
if (!isCurrentSession()) return;
|
|
const settings = await getSettings();
|
|
if (!settings.chatEnabled || !settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) return;
|
|
const chatKey = settings.chatKey;
|
|
try {
|
|
const text = await decryptChatMessage({
|
|
ciphertext: received.ciphertext,
|
|
roomId,
|
|
senderId: received.senderId,
|
|
secret: chatKey
|
|
});
|
|
if (!isCurrentSession() || chatSecretGuard !== chatKey) return;
|
|
if (received.senderId === peerId) chatEchoTracker.acknowledge(received.ciphertext);
|
|
const senderPeer = currentRoom.peers?.find(candidate =>
|
|
(typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId
|
|
);
|
|
if (Number.isInteger(tabId)) {
|
|
sendMessageToChatOverlay({
|
|
type: 'CHAT_MESSAGE',
|
|
message: {
|
|
id: received.id,
|
|
senderId: received.senderId,
|
|
username: typeof senderPeer === 'object' ? senderPeer.username : null,
|
|
timestamp: received.timestamp,
|
|
text
|
|
}
|
|
}).catch(() => {});
|
|
}
|
|
if (received.senderId !== peerId) {
|
|
showChatNotification(
|
|
typeof senderPeer === 'object' ? senderPeer.username : received.senderId,
|
|
text
|
|
);
|
|
}
|
|
} catch (_) {
|
|
if (isCurrentSession()) addLog('Discarded chat message that failed authentication', 'warn');
|
|
}
|
|
});
|
|
await chatReceiveQueue;
|
|
break;
|
|
}
|
|
case EVENTS.ERROR:
|
|
isConnecting = false;
|
|
// If we get a server error before successfully joining a room,
|
|
// clear persisted credentials as well, otherwise service-worker
|
|
// restart would immediately retry the rejected room.
|
|
if (!currentRoom && connectIntent) {
|
|
await clearFailedJoinCredentials();
|
|
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 };
|
|
await broadcastJoinStatus(errStatusMsg);
|
|
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);
|
|
sendChatActivity(event, data.senderId, data.actionTimestamp);
|
|
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);
|
|
sendChatActivity(event, data.senderId, data.actionTimestamp);
|
|
|
|
// (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(() => {});
|
|
sendChatActivity('joined', data.peerId, Date.now());
|
|
showNotification(data.username || data.peerId, 'joined');
|
|
|
|
// 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) {
|
|
sendMessageToCurrentContent({ type: 'REQUEST_HEARTBEAT' }).catch(() => {});
|
|
}
|
|
|
|
if (episodeLobby && episodeLobby.initiatorPeerId === peerId) {
|
|
emitEpisodeLobbyForCurrentPrivacy();
|
|
}
|
|
}
|
|
} else if (data.status === 'left') {
|
|
const departedDisplayName = chatActivityDisplayName(data.peerId);
|
|
sendChatActivity('left', data.peerId, Date.now());
|
|
showNotification(departedDisplayName, '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;
|
|
// Only update when present. Our own heartbeats now carry
|
|
// 'desynced', but other PEER_STATUS variants (server join
|
|
// broadcast, future/old clients) omit it — and clobbering it
|
|
// to false there would flicker the host's "Solo" badge.
|
|
if (data.desynced !== undefined) peer.desynced = data.desynced === true;
|
|
|
|
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(() => {});
|
|
if (episodeLobby) {
|
|
checkEpisodeLobbyCompletion();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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)) {
|
|
sendMessageToCurrentContent({
|
|
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 });
|
|
sendChatActivity(EVENTS.FORCE_SYNC_EXECUTE, peerId, executionTimestamp);
|
|
addLog('Force Sync Executed', 'success');
|
|
}
|
|
|
|
function completeForceSyncBeforeTargetChange(nextTabId) {
|
|
if (!isForceSyncInitiator) return;
|
|
const selectedTabId = normalizeTabId(currentTabId);
|
|
const normalizedNextTabId = normalizeTabId(nextTabId);
|
|
if (selectedTabId !== null && selectedTabId === normalizedNextTabId) return;
|
|
|
|
addLog('Finishing Force Sync before target change', 'info');
|
|
executeForceSync();
|
|
}
|
|
|
|
// --- 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)) {
|
|
sendMessageToCurrentContent({ 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 peers = Array.isArray(currentRoom.peers) ? currentRoom.peers : [];
|
|
// M-3: desynced peers (watching on their own) sit out the lobby — their content
|
|
// script ignores EPISODE_LOBBY and never reports ready. Don't let them block
|
|
// completion: count only peers who actually participate.
|
|
const participatingCount = peers.filter(p => !(typeof p === 'object' && p.desynced)).length;
|
|
if (episodeLobby.readyPeers.length >= participatingCount) {
|
|
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(() => {});
|
|
}
|
|
|
|
function routeToContent(action, payload) {
|
|
const tabId = normalizeTabId(currentTabId);
|
|
if (tabId === null) return Promise.resolve();
|
|
const actionTimestamp = payload?.actionTimestamp || Date.now();
|
|
const commandSenderId = payload?.senderId || null;
|
|
const deliver = () => _routeToContentInternal(
|
|
tabId,
|
|
action,
|
|
payload,
|
|
actionTimestamp,
|
|
commandSenderId,
|
|
0
|
|
);
|
|
const queued = contentCommandQueue.catch(() => {}).then(deliver);
|
|
contentCommandQueue = queued;
|
|
return queued;
|
|
}
|
|
|
|
function getTabVideoState(tabId) {
|
|
return new Promise((resolve) => {
|
|
sendMessageToContentTab(tabId, { type: 'GET_VIDEO_STATE' }, (res) => {
|
|
if (chrome.runtime.lastError) {
|
|
resolve({ error: chrome.runtime.lastError.message });
|
|
return;
|
|
}
|
|
resolve(res);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function decorateVideoState(tabId, state) {
|
|
if (!state || state.error) return state;
|
|
try {
|
|
const tab = await chrome.tabs.get(tabId);
|
|
const frameUrl = state.url;
|
|
let frameOrigin = null;
|
|
try { frameOrigin = new URL(frameUrl).origin; } catch { /* unavailable */ }
|
|
const topUrl = tab?.url || state.url;
|
|
let platform = state.platform;
|
|
try {
|
|
if (new URL(topUrl).hostname.toLowerCase() === 'drive.google.com') platform = 'Google Drive';
|
|
} catch { /* keep the content-reported platform */ }
|
|
return {
|
|
...state,
|
|
url: topUrl,
|
|
pageTitle: tab?.title || state.pageTitle,
|
|
frameOrigin,
|
|
frameId: normalizeFrameId(currentTargetFrameId),
|
|
inIframe: normalizeFrameId(currentTargetFrameId) !== 0 || state.inIframe === true,
|
|
platform
|
|
};
|
|
} catch {
|
|
return state;
|
|
}
|
|
}
|
|
|
|
async function getReadyTabVideoState(tabId, expectedGeneration = targetActivationGeneration) {
|
|
if (!isCurrentTargetIdentity(tabId, expectedGeneration)) {
|
|
return { error: 'Target tab changed before video state could be read' };
|
|
}
|
|
let state = await getTabVideoState(tabId);
|
|
// "No video" is a legitimate answer, not a broken injection: an anime or
|
|
// Drive page has no video element until the viewer starts playback. Forcing
|
|
// a reactivation for it made every poll of this function tear the content
|
|
// script down and reinject it, which kept the target permanently activating.
|
|
// Only an unreachable content script justifies recovery.
|
|
if (!state || state.error) {
|
|
const activation = await refreshCurrentMediaTarget(tabId, { onlyIfTargetMoved: true });
|
|
if (activation?.status !== 'ok' && activation?.status !== 'unchanged') {
|
|
return { error: 'Target tab changed before content script recovery completed' };
|
|
}
|
|
// An unchanged target reports no generation of its own.
|
|
const generation = Number.isInteger(activation.generation)
|
|
? activation.generation
|
|
: targetActivationGeneration;
|
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
if (!isCurrentTargetIdentity(tabId, generation)) {
|
|
return { error: 'Target tab changed before video state could be read' };
|
|
}
|
|
state = await getTabVideoState(tabId);
|
|
if (!isCurrentTargetIdentity(tabId, generation)) {
|
|
return { error: 'Target tab changed while video state was being read' };
|
|
}
|
|
}
|
|
return decorateVideoState(tabId, state);
|
|
}
|
|
|
|
async function simulateRemoteSeek(delta, explicitTargetTime = null) {
|
|
if (!currentTabId) return { status: 'no_tab' };
|
|
const tabId = parseInt(currentTabId);
|
|
if (isNaN(tabId)) return { status: 'no_tab' };
|
|
|
|
const state = await getReadyTabVideoState(tabId);
|
|
if (!state || state.error) return { status: 'error', message: state?.error || 'No video state' };
|
|
if (!state.found || !Number.isFinite(state.currentTime)) return { status: 'no_video' };
|
|
|
|
let targetTime = explicitTargetTime !== null ? explicitTargetTime : Math.max(0, state.currentTime + (delta || 0));
|
|
if (Number.isFinite(state.duration) && state.duration > 0) {
|
|
targetTime = Math.min(targetTime, Math.max(0, state.duration - 0.1));
|
|
}
|
|
|
|
const senderId = 'KoalaDev';
|
|
const timestamp = Date.now();
|
|
const payload = {
|
|
senderId,
|
|
actionTimestamp: timestamp,
|
|
currentTime: targetTime,
|
|
targetTime
|
|
};
|
|
|
|
addToHistory(EVENTS.SEEK, senderId);
|
|
showNotification(senderId, EVENTS.SEEK);
|
|
updateLastAction(EVENTS.SEEK, senderId, timestamp);
|
|
lastActionState.targetTime = targetTime;
|
|
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
|
updateLocalPeerState(senderId, { currentTime: targetTime });
|
|
routeToContent(EVENTS.SEEK, payload);
|
|
|
|
return { status: 'ok', targetTime };
|
|
}
|
|
|
|
async function devRemoteToolsAllowed() {
|
|
const data = await chrome.storage.local.get(['username']);
|
|
return data.username === 'KoalaDev';
|
|
}
|
|
|
|
function shouldUsePageApiSeek(url) {
|
|
return typeof globalThis.koalaFindPageApiSeekProvider === 'function' &&
|
|
!!globalThis.koalaFindPageApiSeekProvider(url);
|
|
}
|
|
|
|
function installPageApiSeekBridge() {
|
|
if (window.__koalaPageApiSeekBridge?.activate) {
|
|
window.__koalaPageApiSeekBridge.activate();
|
|
return;
|
|
}
|
|
|
|
let active = true;
|
|
|
|
function currentMatch() {
|
|
return typeof window.koalaFindPageApiSeekProvider === 'function'
|
|
? window.koalaFindPageApiSeekProvider(window.location.hostname)
|
|
: null;
|
|
}
|
|
|
|
// Disney+ ("hive"/BAM) player: the real media player hangs off the
|
|
// <disney-web-player> custom element as `.mediaPlayer`, exposing precise
|
|
// seek(ms) and timeline.info (playhead/duration in ms).
|
|
function disneyMediaPlayer() {
|
|
const el = document.querySelector('disney-web-player');
|
|
return el && el.mediaPlayer ? el.mediaPlayer : null;
|
|
}
|
|
|
|
function seekWithPageApi(time) {
|
|
const match = currentMatch();
|
|
if (!match) return;
|
|
|
|
try {
|
|
if (match.provider === 'netflix') {
|
|
const videoPlayer = window.netflix?.appContext?.state?.playerApp?.getAPI?.().videoPlayer;
|
|
const ids = videoPlayer?.getAllPlayerSessionIds?.();
|
|
const sessionId = ids ? ids[0] : null;
|
|
const player = sessionId ? videoPlayer.getVideoPlayerBySessionId(sessionId) : null;
|
|
player?.seek(Math.round(time * 1000));
|
|
} else if (match.provider === 'disney') {
|
|
const mp = disneyMediaPlayer();
|
|
if (mp && typeof mp.seek === 'function') mp.seek(Math.round(time * 1000));
|
|
}
|
|
} catch (_e) {
|
|
// Player not ready or private API changed; the next sync tick can retry.
|
|
}
|
|
}
|
|
|
|
function handleBridgeMessage(event) {
|
|
if (event.source !== window) return;
|
|
const data = event.data;
|
|
if (!data || data.__koalaPageApiSeek !== 1) return;
|
|
if (data.kind === 'destroy') {
|
|
destroy();
|
|
return;
|
|
}
|
|
if (!active || data.kind !== 'seek' || typeof data.time !== 'number') return;
|
|
seekWithPageApi(data.time);
|
|
}
|
|
|
|
// Disney+'s <video> currentTime is blob-relative and its scrubber lags, so
|
|
// the isolated-world content script can't read an accurate position. Push
|
|
// the real playhead/duration (seconds) from the page's media player.
|
|
const timelineInterval = setInterval(() => {
|
|
if (!active) return;
|
|
try {
|
|
const match = currentMatch();
|
|
if (!match || match.provider !== 'disney') return;
|
|
const mp = disneyMediaPlayer();
|
|
const info = mp && mp.timeline && mp.timeline.info;
|
|
if (!info || typeof info.playheadPositionMs !== 'number' || typeof info.programDurationMs !== 'number') return;
|
|
if (info.programDurationMs <= 0) return;
|
|
window.postMessage({
|
|
__koalaPlayerTime: 1,
|
|
provider: 'disney',
|
|
position: info.playheadPositionMs / 1000,
|
|
duration: info.programDurationMs / 1000
|
|
}, '*');
|
|
} catch (_e) {
|
|
// Ignore transient errors (player teardown / element swap).
|
|
}
|
|
}, 250);
|
|
|
|
function destroy() {
|
|
if (!active) return;
|
|
active = false;
|
|
clearInterval(timelineInterval);
|
|
window.removeEventListener('message', handleBridgeMessage);
|
|
delete window.__koalaPageApiSeekBridge;
|
|
}
|
|
|
|
window.addEventListener('message', handleBridgeMessage);
|
|
window.__koalaPageApiSeekBridge = {
|
|
activate() {
|
|
active = true;
|
|
},
|
|
destroy
|
|
};
|
|
}
|
|
|
|
function setPageApiSeekEnabled(enabled) {
|
|
window.KOALA_PAGE_API_SEEK_ENABLED = enabled === true;
|
|
}
|
|
|
|
async function deactivateMediaFrameMonitors(tabId) {
|
|
const targets = listMediaFrameScriptTargets(tabId);
|
|
await Promise.all(targets.map(async target => {
|
|
const documentId = target.documentIds?.[0];
|
|
const frameId = target.frameIds?.[0];
|
|
try {
|
|
if (typeof documentId === 'string') {
|
|
await chrome.tabs.sendMessage(
|
|
tabId,
|
|
{ type: 'MEDIA_MONITOR_DEACTIVATE' },
|
|
{ documentId }
|
|
);
|
|
} else if (Number.isInteger(frameId)) {
|
|
await chrome.tabs.sendMessage(
|
|
tabId,
|
|
{ type: 'MEDIA_MONITOR_DEACTIVATE' },
|
|
{ frameId }
|
|
);
|
|
} else {
|
|
await chrome.tabs.sendMessage(tabId, { type: 'MEDIA_MONITOR_DEACTIVATE' });
|
|
}
|
|
} catch {
|
|
// Denied or already-navigated frames have no installed monitor.
|
|
}
|
|
}));
|
|
}
|
|
|
|
async function deactivateTargetTab(tabId, contentTarget = null, { deactivateMonitor = true } = {}) {
|
|
const normalizedTabId = normalizeTabId(tabId);
|
|
if (normalizedTabId === null) return;
|
|
if (deactivateMonitor) {
|
|
await deactivateMediaFrameMonitors(normalizedTabId);
|
|
}
|
|
const target = contentTarget
|
|
|| (normalizedTabId === normalizeTabId(currentTabId) ? currentContentTarget() : null)
|
|
|| (normalizedTabId === normalizeTabId(activeTargetActivation?.tabId)
|
|
? {
|
|
frameId: activeTargetActivation.frameId,
|
|
documentId: activeTargetActivation.documentId
|
|
}
|
|
: null)
|
|
|| { frameId: 0, documentId: null };
|
|
resetAudioProcessingInTab(normalizedTabId, target);
|
|
await sendMessageToFrame(
|
|
normalizedTabId,
|
|
target.frameId,
|
|
{ type: 'TARGET_DEACTIVATE' },
|
|
null,
|
|
target.documentId
|
|
).catch(() => {});
|
|
await sendMessageToFrame(
|
|
normalizedTabId,
|
|
target.frameId,
|
|
{ type: 'CHAT_DESTROY' },
|
|
null,
|
|
target.documentId
|
|
).catch(() => {});
|
|
// The overlay lives in the top document whenever the player is nested, so
|
|
// clearing only the media frame would leave a stale chat behind on Drive.
|
|
if (normalizeFrameId(target.frameId) !== 0) {
|
|
await sendMessageToFrame(
|
|
normalizedTabId,
|
|
0,
|
|
{ type: 'CHAT_DESTROY' }
|
|
).catch(() => {});
|
|
}
|
|
}
|
|
|
|
function createHostAccessRequiredError(access, requestAdded, cause) {
|
|
const error = new Error(`Host access required for ${access.host || 'this website'}`);
|
|
error.code = HOST_ACCESS_REQUIRED_STATUS;
|
|
error.tabId = access.tab?.id || null;
|
|
error.host = access.host || null;
|
|
error.originPattern = access.originPattern || null;
|
|
error.requestAdded = requestAdded === true;
|
|
error.cause = cause;
|
|
return error;
|
|
}
|
|
|
|
function injectionFailureResponse(error) {
|
|
if (error?.code === HOST_ACCESS_REQUIRED_STATUS) {
|
|
return {
|
|
status: HOST_ACCESS_REQUIRED_STATUS,
|
|
tabId: error.tabId,
|
|
host: error.host,
|
|
originPattern: error.originPattern,
|
|
requestAdded: error.requestAdded === true
|
|
};
|
|
}
|
|
return { status: 'error', message: error?.message || 'Script injection failed' };
|
|
}
|
|
|
|
function isMediaTargetNavigationError(error) {
|
|
const message = String(error?.message || '');
|
|
return error?.code === 'media_target_navigated'
|
|
|| message.includes('No document with id')
|
|
|| message.includes('No document with ID');
|
|
}
|
|
|
|
function isTargetActivationSuperseded(tabId, activationGeneration) {
|
|
if (!Number.isInteger(activationGeneration)) return false;
|
|
return targetActivationGeneration !== activationGeneration
|
|
|| activeTargetActivation?.generation !== activationGeneration
|
|
|| normalizeTabId(activeTargetActivation?.tabId) !== normalizeTabId(tabId);
|
|
}
|
|
|
|
function createTargetActivationSupersededError() {
|
|
const error = new Error('Target activation was superseded');
|
|
error.code = 'target_activation_superseded';
|
|
return error;
|
|
}
|
|
|
|
const SCRIPT_INJECTION_TIMEOUT_MS = 5000;
|
|
|
|
/**
|
|
* chrome.scripting.executeScript can stay pending indefinitely when a target
|
|
* frame is busy or navigating — an embedded player or ad frame is enough, and
|
|
* an allFrames call only needs one of them. An activation that never settles
|
|
* leaves the popup on "activating" forever, so every injection is bounded.
|
|
*/
|
|
function executeScriptWithTimeout(options, timeoutMs = SCRIPT_INJECTION_TIMEOUT_MS) {
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
return chrome.scripting.executeScript(options);
|
|
}
|
|
let timeoutId = null;
|
|
const label = Array.isArray(options?.files) && options.files.length > 0
|
|
? options.files.join(', ')
|
|
: 'function injection';
|
|
const timeout = new Promise((_, reject) => {
|
|
timeoutId = setTimeout(() => {
|
|
const error = new Error(`Script injection timed out after ${timeoutMs}ms (${label})`);
|
|
error.code = 'script_injection_timeout';
|
|
reject(error);
|
|
}, timeoutMs);
|
|
});
|
|
return Promise.race([
|
|
chrome.scripting.executeScript(options),
|
|
timeout
|
|
]).finally(() => {
|
|
if (timeoutId !== null) clearTimeout(timeoutId);
|
|
});
|
|
}
|
|
|
|
async function injectMediaFrameMonitors(tabId, contentTarget) {
|
|
const targets = listMediaFrameScriptTargets(tabId);
|
|
let injectedCount = 0;
|
|
await Promise.all(targets.map(async target => {
|
|
try {
|
|
await executeScriptWithTimeout({
|
|
target,
|
|
files: ['media-frame-monitor.js']
|
|
}, 2000);
|
|
injectedCount++;
|
|
} catch {
|
|
// One denied widget frame must not block the selected player.
|
|
}
|
|
}));
|
|
if (injectedCount > 0) return;
|
|
|
|
const fallbackTargets = [{ tabId }, contentTarget.scriptTarget];
|
|
const seen = new Set();
|
|
for (const target of fallbackTargets) {
|
|
const key = JSON.stringify(target);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
try {
|
|
await executeScriptWithTimeout({
|
|
target,
|
|
files: ['media-frame-monitor.js']
|
|
}, 2000);
|
|
injectedCount++;
|
|
} catch {
|
|
// Main injection below reports a real selected-target failure.
|
|
}
|
|
}
|
|
}
|
|
|
|
async function injectContentScript(tabId, {
|
|
requestHostAccess = true,
|
|
navigationRetries = 2,
|
|
activationGeneration = null
|
|
} = {}) {
|
|
const normalizedTabId = normalizeTabId(tabId);
|
|
if (normalizedTabId === null) throw new Error('Invalid tab ID');
|
|
tabId = normalizedTabId;
|
|
let needsPageApiSeek = false;
|
|
let pageApiSeekReady = false;
|
|
let access = null;
|
|
let contentTarget = {
|
|
frameId: 0,
|
|
documentId: null,
|
|
frameUrl: null,
|
|
hasVideo: false,
|
|
scriptTarget: { tabId }
|
|
};
|
|
try {
|
|
access = await inspectTabHostAccess(chrome, tabId);
|
|
const url = access.url || '';
|
|
needsPageApiSeek = shouldUsePageApiSeek(url);
|
|
contentTarget = await resolveMediaContentTarget(chrome, tabId);
|
|
if (!isTargetActivationSuperseded(tabId, activationGeneration)
|
|
&& activeTargetActivation?.tabId === tabId) {
|
|
activeTargetActivation.frameId = contentTarget.frameId;
|
|
activeTargetActivation.documentId = contentTarget.documentId;
|
|
}
|
|
} catch (error) {
|
|
if (error?.code === MEDIA_FRAME_ACCESS_REQUIRED) {
|
|
// addHostAccessRequest() only grants the tab's top origin. Embedded
|
|
// player origins must be requested explicitly by the popup.
|
|
const requestAdded = false;
|
|
throw createHostAccessRequiredError({
|
|
tab: { id: tabId },
|
|
host: error.host,
|
|
originPattern: error.originPattern
|
|
}, requestAdded, error);
|
|
}
|
|
// MEDIA_FRAME_AMBIGUOUS is no longer fatal: the resolver falls back to
|
|
// the top frame and the monitor promotes the player that starts playing.
|
|
addLog(`Media frame probe fell back to the top frame: ${error.message}`, 'warn');
|
|
}
|
|
|
|
const scriptTarget = contentTarget.scriptTarget;
|
|
const selectedDocumentId = contentTarget.documentId;
|
|
|
|
try {
|
|
if (isTargetActivationSuperseded(tabId, activationGeneration)) {
|
|
throw createTargetActivationSupersededError();
|
|
}
|
|
await injectMediaFrameMonitors(tabId, contentTarget);
|
|
if (isTargetActivationSuperseded(tabId, activationGeneration)) {
|
|
const replacementTabId = normalizeTabId(activeTargetActivation?.tabId);
|
|
if (replacementTabId !== tabId) {
|
|
await deactivateMediaFrameMonitors(tabId);
|
|
}
|
|
throw createTargetActivationSupersededError();
|
|
}
|
|
if (needsPageApiSeek) {
|
|
try {
|
|
await executeScriptWithTimeout({
|
|
target: scriptTarget,
|
|
world: 'MAIN',
|
|
files: ['page-api-seek-overrides.js']
|
|
});
|
|
await executeScriptWithTimeout({
|
|
target: scriptTarget,
|
|
world: 'MAIN',
|
|
func: installPageApiSeekBridge
|
|
});
|
|
pageApiSeekReady = true;
|
|
} catch (err) {
|
|
addLog(`Page API seek bridge injection failed: ${err.message}`, 'warn');
|
|
}
|
|
}
|
|
|
|
await executeScriptWithTimeout({
|
|
target: scriptTarget,
|
|
files: ['page-api-seek-overrides.js']
|
|
});
|
|
await executeScriptWithTimeout({
|
|
target: scriptTarget,
|
|
func: setPageApiSeekEnabled,
|
|
args: [pageApiSeekReady]
|
|
});
|
|
// The chat overlay is standalone page UI and carries its own runtime
|
|
// message listener, so it is installed in the top document regardless of
|
|
// where the player lives. Only the playback controller goes into the
|
|
// selected media frame.
|
|
let injectionResults;
|
|
if (contentTarget.frameId === 0) {
|
|
injectionResults = await executeScriptWithTimeout({
|
|
target: scriptTarget,
|
|
files: ['chat-format.js', 'chat-overlay.js', 'content.js']
|
|
});
|
|
} else {
|
|
try {
|
|
await executeScriptWithTimeout({
|
|
target: { tabId, frameIds: [0] },
|
|
files: ['chat-format.js', 'chat-overlay.js']
|
|
});
|
|
} catch (err) {
|
|
addLog(`Chat overlay injection failed in the top frame: ${err.message}`, 'warn');
|
|
}
|
|
// A pre-3.1.3 build may have left an overlay inside the player.
|
|
await sendMessageToFrame(
|
|
tabId,
|
|
contentTarget.frameId,
|
|
{ type: 'CHAT_DESTROY' },
|
|
null,
|
|
contentTarget.documentId
|
|
).catch(() => {});
|
|
injectionResults = await executeScriptWithTimeout({
|
|
target: scriptTarget,
|
|
files: ['content.js']
|
|
});
|
|
}
|
|
const frameResult = Array.isArray(injectionResults)
|
|
? injectionResults.find(result => normalizeFrameId(result?.frameId) === contentTarget.frameId)
|
|
: null;
|
|
if (selectedDocumentId && frameResult?.documentId !== selectedDocumentId) {
|
|
const navigationError = new Error('Selected media document navigated during injection');
|
|
navigationError.code = 'media_target_navigated';
|
|
throw navigationError;
|
|
}
|
|
contentTarget.documentId = typeof frameResult?.documentId === 'string'
|
|
? frameResult.documentId
|
|
: contentTarget.documentId;
|
|
if (!isTargetActivationSuperseded(tabId, activationGeneration)
|
|
&& activeTargetActivation?.tabId === tabId) {
|
|
activeTargetActivation.frameId = contentTarget.frameId;
|
|
activeTargetActivation.documentId = contentTarget.documentId;
|
|
}
|
|
return contentTarget;
|
|
} catch (error) {
|
|
if (error?.code === 'target_activation_superseded') {
|
|
try { error.contentTarget = contentTarget; } catch { /* immutable browser error */ }
|
|
throw error;
|
|
}
|
|
// Name the frame the injection was aimed at. Without it every failure
|
|
// reads the same in the log and there is no way to tell a denied player
|
|
// frame from a document that navigated mid-injection.
|
|
addLog(
|
|
`Content injection failed in frame ${contentTarget.frameId}`
|
|
+ `${contentTarget.frameUrl ? ` (${contentTarget.frameUrl})` : ''}: ${error?.message}`,
|
|
'warn'
|
|
);
|
|
if (navigationRetries > 0 && isMediaTargetNavigationError(error)) {
|
|
return injectContentScript(tabId, {
|
|
requestHostAccess,
|
|
navigationRetries: navigationRetries - 1,
|
|
activationGeneration
|
|
});
|
|
}
|
|
try { error.contentTarget = contentTarget; } catch { /* immutable browser error */ }
|
|
// A temporary activeTab grant is intentionally allowed to win: even if
|
|
// permissions.contains() reports false, a successful injection above is
|
|
// valid. Only convert an actual injection failure into a host-access UX.
|
|
try {
|
|
// The tab may have crossed origins between the initial permission
|
|
// check and executeScript(). Always report/request the current URL.
|
|
access = await inspectTabHostAccess(chrome, tabId);
|
|
} catch (_inspectionError) {
|
|
access = null;
|
|
}
|
|
const accessIsMissing = access?.granted === false
|
|
|| (access?.granted === null && isHostAccessError(error));
|
|
if (access?.originPattern && accessIsMissing) {
|
|
const requestAdded = requestHostAccess
|
|
? await addTabHostAccessRequest(chrome, tabId, access.originPattern)
|
|
: false;
|
|
throw createHostAccessRequiredError(access, requestAdded, error);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
let pendingTargetMutation = Promise.resolve();
|
|
|
|
const PENDING_TARGET_KEYS = [
|
|
'pendingTargetTabId',
|
|
'pendingTargetTabTitle',
|
|
'pendingTargetHost',
|
|
'pendingTargetOriginPattern',
|
|
'pendingTargetRequestId'
|
|
];
|
|
|
|
function createPendingTargetRequestId() {
|
|
return globalThis.crypto?.randomUUID?.()
|
|
|| `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
|
|
function emptyPendingTargetState() {
|
|
return {
|
|
pendingTargetTabId: null,
|
|
pendingTargetTabTitle: null,
|
|
pendingTargetHost: null,
|
|
pendingTargetOriginPattern: null,
|
|
pendingTargetRequestId: null
|
|
};
|
|
}
|
|
|
|
function mutatePendingTarget(operation) {
|
|
const result = pendingTargetMutation.catch(() => {}).then(operation);
|
|
pendingTargetMutation = result.catch(() => {});
|
|
return result;
|
|
}
|
|
|
|
async function readPendingTarget() {
|
|
return mutatePendingTarget(async () => {
|
|
const stored = await chrome.storage.session.get(PENDING_TARGET_KEYS);
|
|
const tabId = normalizeTabId(stored.pendingTargetTabId);
|
|
const originPattern = typeof stored.pendingTargetOriginPattern === 'string'
|
|
&& stored.pendingTargetOriginPattern.length > 0
|
|
? stored.pendingTargetOriginPattern
|
|
: null;
|
|
|
|
if (tabId === null || originPattern === null) {
|
|
if (Object.values(stored).some(value => value !== null && value !== undefined)) {
|
|
if (tabId !== null) {
|
|
await removeTabHostAccessRequest(chrome, tabId, originPattern);
|
|
}
|
|
await chrome.storage.session.set(emptyPendingTargetState());
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const requestId = typeof stored.pendingTargetRequestId === 'string'
|
|
&& stored.pendingTargetRequestId.length > 0
|
|
? stored.pendingTargetRequestId
|
|
: createPendingTargetRequestId();
|
|
if (stored.pendingTargetRequestId !== requestId) {
|
|
await chrome.storage.session.set({ pendingTargetRequestId: requestId });
|
|
}
|
|
|
|
return {
|
|
tabId,
|
|
tabTitle: typeof stored.pendingTargetTabTitle === 'string'
|
|
? stored.pendingTargetTabTitle
|
|
: null,
|
|
host: typeof stored.pendingTargetHost === 'string'
|
|
? stored.pendingTargetHost
|
|
: null,
|
|
originPattern,
|
|
requestId
|
|
};
|
|
});
|
|
}
|
|
|
|
async function rememberPendingTarget(tabId, tabTitle, error, expectedGeneration) {
|
|
return mutatePendingTarget(async () => {
|
|
if (targetActivationGeneration !== expectedGeneration) return null;
|
|
const previous = await chrome.storage.session.get(PENDING_TARGET_KEYS);
|
|
const previousTabId = normalizeTabId(previous.pendingTargetTabId);
|
|
const nextOriginPattern = error?.originPattern || null;
|
|
if (previousTabId !== null && (
|
|
previousTabId !== tabId
|
|
|| previous.pendingTargetOriginPattern !== nextOriginPattern
|
|
)) {
|
|
await removeTabHostAccessRequest(
|
|
chrome,
|
|
previousTabId,
|
|
previous.pendingTargetOriginPattern || null
|
|
);
|
|
}
|
|
if (targetActivationGeneration !== expectedGeneration) return null;
|
|
const requestId = previousTabId === tabId
|
|
&& previous.pendingTargetOriginPattern === nextOriginPattern
|
|
&& typeof previous.pendingTargetRequestId === 'string'
|
|
&& previous.pendingTargetRequestId.length > 0
|
|
? previous.pendingTargetRequestId
|
|
: createPendingTargetRequestId();
|
|
await chrome.storage.session.set({
|
|
pendingTargetTabId: tabId,
|
|
pendingTargetTabTitle: typeof tabTitle === 'string' ? tabTitle : null,
|
|
pendingTargetHost: error?.host || null,
|
|
pendingTargetOriginPattern: nextOriginPattern,
|
|
pendingTargetRequestId: requestId
|
|
});
|
|
if (targetActivationGeneration !== expectedGeneration) {
|
|
const current = await chrome.storage.session.get(PENDING_TARGET_KEYS);
|
|
if (current.pendingTargetRequestId === requestId) {
|
|
await removeTabHostAccessRequest(chrome, tabId, nextOriginPattern);
|
|
await chrome.storage.session.set(emptyPendingTargetState());
|
|
}
|
|
return null;
|
|
}
|
|
return {
|
|
tabId,
|
|
tabTitle: typeof tabTitle === 'string' ? tabTitle : null,
|
|
host: error?.host || null,
|
|
originPattern: nextOriginPattern,
|
|
requestId
|
|
};
|
|
});
|
|
}
|
|
|
|
async function clearPendingTarget({ expectedRequestId = null, expectedTabId = null } = {}) {
|
|
return mutatePendingTarget(async () => {
|
|
const pending = await chrome.storage.session.get(PENDING_TARGET_KEYS);
|
|
const pendingTabId = normalizeTabId(pending.pendingTargetTabId);
|
|
if (expectedRequestId !== null && pending.pendingTargetRequestId !== expectedRequestId) {
|
|
return false;
|
|
}
|
|
if (expectedTabId !== null && pendingTabId !== normalizeTabId(expectedTabId)) {
|
|
return false;
|
|
}
|
|
if (pendingTabId !== null) {
|
|
await removeTabHostAccessRequest(
|
|
chrome,
|
|
pendingTabId,
|
|
pending.pendingTargetOriginPattern || null
|
|
);
|
|
}
|
|
await chrome.storage.session.set(emptyPendingTargetState());
|
|
return true;
|
|
});
|
|
}
|
|
|
|
const ACTIVATION_DEADLINE_MS = 30000;
|
|
|
|
/**
|
|
* Last line of defence for the "activating" state.
|
|
*
|
|
* Every known way an activation can stall is bounded by now, but a browser call
|
|
* that never settles would still pin activeTargetActivation and leave the popup
|
|
* spinning with nothing in the log. Past the deadline the attempt is declared
|
|
* dead so the selection can report a real error and be retried deliberately.
|
|
*/
|
|
function expireStuckActivation() {
|
|
const startedAt = activeTargetActivation?.startedAt;
|
|
if (!Number.isFinite(startedAt) || Date.now() - startedAt < ACTIVATION_DEADLINE_MS) {
|
|
return false;
|
|
}
|
|
const stalledTabId = normalizeTabId(activeTargetActivation.tabId);
|
|
addLog(`Target activation for tab ${stalledTabId} exceeded ${ACTIVATION_DEADLINE_MS}ms; abandoning it`, 'warn');
|
|
activeTargetActivation = null;
|
|
if (stalledTabId !== null && normalizeTabId(userSelectedTabId) === stalledTabId) {
|
|
userSelectionErrorTabId = stalledTabId;
|
|
userSelectionErrorMessage = 'The page never finished responding to script injection';
|
|
chrome.storage.session.set({
|
|
selectionErrorTabId: userSelectionErrorTabId,
|
|
selectionErrorMessage: userSelectionErrorMessage
|
|
}).catch(() => {});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function rememberUserSelection(tabId, tabTitle) {
|
|
const normalizedTabId = normalizeTabId(tabId);
|
|
if (normalizedTabId === null) return false;
|
|
userSelectedTabId = normalizedTabId;
|
|
userSelectedTabTitle = typeof tabTitle === 'string' ? tabTitle : null;
|
|
userSelectionErrorTabId = null;
|
|
userSelectionErrorMessage = null;
|
|
await chrome.storage.session.set({
|
|
selectedTabId: userSelectedTabId,
|
|
selectedTabTitle: userSelectedTabTitle,
|
|
selectionErrorTabId: null,
|
|
selectionErrorMessage: null
|
|
});
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Records why a selection could not be activated, without discarding it. The
|
|
* popup keeps showing the chosen tab and can explain the problem or offer the
|
|
* host-access grant; nothing retries on its own.
|
|
*/
|
|
async function recordUserSelectionFailure(tabId, error) {
|
|
const normalizedTabId = normalizeTabId(tabId);
|
|
if (normalizedTabId === null || normalizeTabId(userSelectedTabId) !== normalizedTabId) {
|
|
return false;
|
|
}
|
|
userSelectionErrorTabId = normalizedTabId;
|
|
userSelectionErrorMessage = error?.code === HOST_ACCESS_REQUIRED_STATUS
|
|
? null
|
|
: (error?.message || 'Script injection failed');
|
|
await chrome.storage.session.set({
|
|
selectionErrorTabId: userSelectionErrorTabId,
|
|
selectionErrorMessage: userSelectionErrorMessage
|
|
});
|
|
return true;
|
|
}
|
|
|
|
async function clearUserSelection(expectedTabId = null) {
|
|
if (expectedTabId !== null
|
|
&& normalizeTabId(userSelectedTabId) !== normalizeTabId(expectedTabId)) {
|
|
return false;
|
|
}
|
|
userSelectedTabId = null;
|
|
userSelectedTabTitle = null;
|
|
userSelectionErrorTabId = null;
|
|
userSelectionErrorMessage = null;
|
|
await chrome.storage.session.set({
|
|
selectedTabId: null,
|
|
selectedTabTitle: null,
|
|
selectionErrorTabId: null,
|
|
selectionErrorMessage: null
|
|
});
|
|
return true;
|
|
}
|
|
|
|
async function activateTargetTab(tabId, tabTitle, {
|
|
requestHostAccess = true,
|
|
expectedGeneration = null,
|
|
expectedCurrentTabId = null
|
|
} = {}) {
|
|
const selectedTabId = normalizeTabId(tabId);
|
|
if (selectedTabId === null) {
|
|
return { status: 'invalid_tab' };
|
|
}
|
|
if (expectedGeneration !== null && targetActivationGeneration !== expectedGeneration) {
|
|
return { status: 'superseded' };
|
|
}
|
|
if (expectedCurrentTabId !== null
|
|
&& normalizeTabId(currentTabId) !== normalizeTabId(expectedCurrentTabId)) {
|
|
return { status: 'superseded' };
|
|
}
|
|
|
|
completeForceSyncBeforeTargetChange(selectedTabId);
|
|
const activationGeneration = ++targetActivationGeneration;
|
|
activeTargetActivation = {
|
|
generation: activationGeneration,
|
|
tabId: selectedTabId,
|
|
startedAt: Date.now()
|
|
};
|
|
const previousTabId = normalizeTabId(currentTabId);
|
|
const previousContentTarget = currentContentTarget();
|
|
let injectedContentTarget = { frameId: 0, documentId: null, hasVideo: false };
|
|
|
|
try {
|
|
if (previousTabId && previousTabId !== selectedTabId) {
|
|
await deactivateTargetTab(previousTabId);
|
|
}
|
|
if (activationGeneration !== targetActivationGeneration) {
|
|
return { status: 'superseded' };
|
|
}
|
|
try {
|
|
injectedContentTarget = await injectContentScript(selectedTabId, {
|
|
requestHostAccess,
|
|
activationGeneration
|
|
});
|
|
} catch (error) {
|
|
if (activationGeneration !== targetActivationGeneration) {
|
|
if (normalizeTabId(currentTabId) !== selectedTabId) {
|
|
await deactivateTargetTab(selectedTabId, error?.contentTarget || injectedContentTarget);
|
|
}
|
|
if (error?.code === HOST_ACCESS_REQUIRED_STATUS
|
|
&& error.requestAdded === true
|
|
&& activeTargetActivation?.tabId !== selectedTabId) {
|
|
await removeTabHostAccessRequest(
|
|
chrome,
|
|
selectedTabId,
|
|
error.originPattern || null
|
|
);
|
|
}
|
|
return { status: 'superseded' };
|
|
}
|
|
if (previousTabId === selectedTabId
|
|
&& expectedCurrentTabId === selectedTabId
|
|
&& isMediaTargetNavigationError(error)) {
|
|
addLog('Media document changed during refresh; keeping the previous target until navigation completes', 'warn');
|
|
throw error;
|
|
}
|
|
currentTabId = null;
|
|
currentTabTitle = null;
|
|
clearCurrentContentTarget();
|
|
lastContentHeartbeatAt = null;
|
|
if (currentRoom) roomIdleSince = Date.now();
|
|
const failedContentTarget = error?.contentTarget || injectedContentTarget;
|
|
await deactivateTargetTab(selectedTabId, failedContentTarget);
|
|
if (previousTabId && (previousTabId !== selectedTabId
|
|
|| !sameContentTarget(previousContentTarget, failedContentTarget))) {
|
|
await deactivateTargetTab(previousTabId, previousContentTarget);
|
|
}
|
|
await chrome.storage.session.set({
|
|
currentTabId: null,
|
|
currentTabTitle: null,
|
|
currentTargetFrameId: 0,
|
|
currentTargetDocumentId: null,
|
|
currentTargetHasVideo: false,
|
|
roomIdleSince,
|
|
lastContentHeartbeatAt: null
|
|
});
|
|
if (activationGeneration !== targetActivationGeneration) {
|
|
return { status: 'superseded' };
|
|
}
|
|
updateBadgeStatus();
|
|
|
|
if (error?.code === HOST_ACCESS_REQUIRED_STATUS) {
|
|
const pending = await rememberPendingTarget(
|
|
selectedTabId,
|
|
tabTitle,
|
|
error,
|
|
activationGeneration
|
|
);
|
|
if (!pending || activationGeneration !== targetActivationGeneration) {
|
|
return { status: 'superseded' };
|
|
}
|
|
chrome.runtime.sendMessage({
|
|
type: 'TARGET_TAB_ACCESS_REQUIRED',
|
|
requestId: pending.requestId,
|
|
...injectionFailureResponse(error)
|
|
}).catch(() => {});
|
|
} else {
|
|
await clearPendingTarget();
|
|
if (activationGeneration !== targetActivationGeneration) {
|
|
return { status: 'superseded' };
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (activationGeneration !== targetActivationGeneration) {
|
|
if (currentTabId !== selectedTabId) await deactivateTargetTab(selectedTabId, injectedContentTarget);
|
|
return { status: 'superseded' };
|
|
}
|
|
|
|
await applyAudioSettingsToTab(selectedTabId, injectedContentTarget);
|
|
if (activationGeneration !== targetActivationGeneration) {
|
|
if (currentTabId !== selectedTabId) await deactivateTargetTab(selectedTabId, injectedContentTarget);
|
|
return { status: 'superseded' };
|
|
}
|
|
await removeTabHostAccessRequest(chrome, selectedTabId);
|
|
if (activationGeneration !== targetActivationGeneration) {
|
|
if (currentTabId !== selectedTabId) await deactivateTargetTab(selectedTabId, injectedContentTarget);
|
|
return { status: 'superseded' };
|
|
}
|
|
await clearPendingTarget();
|
|
if (activationGeneration !== targetActivationGeneration) {
|
|
if (currentTabId !== selectedTabId) await deactivateTargetTab(selectedTabId, injectedContentTarget);
|
|
return { status: 'superseded' };
|
|
}
|
|
if (previousTabId === selectedTabId
|
|
&& !sameContentTarget(previousContentTarget, injectedContentTarget)) {
|
|
await deactivateTargetTab(previousTabId, previousContentTarget, { deactivateMonitor: false });
|
|
}
|
|
currentTabId = selectedTabId;
|
|
currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null;
|
|
// Activation can also start from the pending host-access flow, so keep
|
|
// the user-facing selection in step with what actually got injected.
|
|
await rememberUserSelection(selectedTabId, currentTabTitle);
|
|
currentTargetFrameId = normalizeFrameId(injectedContentTarget.frameId);
|
|
currentTargetDocumentId = typeof injectedContentTarget.documentId === 'string'
|
|
? injectedContentTarget.documentId
|
|
: null;
|
|
currentTargetHasVideo = injectedContentTarget.hasVideo === true;
|
|
lastContentHeartbeatAt = null;
|
|
if (currentRoom) roomIdleSince = Date.now();
|
|
await chrome.storage.session.set({
|
|
currentTabId,
|
|
currentTabTitle,
|
|
currentTargetFrameId,
|
|
currentTargetDocumentId,
|
|
currentTargetHasVideo,
|
|
roomIdleSince,
|
|
lastContentHeartbeatAt
|
|
});
|
|
if (activationGeneration !== targetActivationGeneration) {
|
|
return { status: 'superseded' };
|
|
}
|
|
updateBadgeStatus();
|
|
return {
|
|
status: 'ok',
|
|
tabId: selectedTabId,
|
|
frameId: currentTargetFrameId,
|
|
documentId: currentTargetDocumentId,
|
|
hasVideo: currentTargetHasVideo,
|
|
generation: activationGeneration
|
|
};
|
|
} finally {
|
|
if (activeTargetActivation?.generation === activationGeneration) {
|
|
activeTargetActivation = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function reactivateCurrentTarget(tabId, { expectedGeneration = targetActivationGeneration } = {}) {
|
|
const selectedTabId = normalizeTabId(tabId);
|
|
if (selectedTabId === null || !isCurrentTargetIdentity(selectedTabId, expectedGeneration)) {
|
|
return { status: 'superseded' };
|
|
}
|
|
if (activeTargetActivation && activeTargetActivation.tabId !== selectedTabId) {
|
|
return { status: 'superseded' };
|
|
}
|
|
return activateTargetTab(selectedTabId, currentTabTitle, {
|
|
expectedGeneration,
|
|
expectedCurrentTabId: selectedTabId
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cheap pre-check for lifecycle-driven refreshes.
|
|
*
|
|
* Reactivation tears down and re-injects the content script, which interrupts
|
|
* playback and audio routing. That price is only worth paying when the selected
|
|
* frame or document actually moved — not for the constant DOM churn that pages
|
|
* like Drive and YouTube produce while simply playing.
|
|
*/
|
|
async function selectedMediaTargetMoved(tabId) {
|
|
let resolved;
|
|
try {
|
|
resolved = await resolveMediaContentTarget(chrome, tabId, { attempts: 1 });
|
|
} catch {
|
|
// An access-required error must reach the full activation path so the
|
|
// popup can surface it.
|
|
return true;
|
|
}
|
|
if (normalizeTabId(currentTabId) !== normalizeTabId(tabId)) return false;
|
|
// An inconclusive probe is not a reason to move. A page whose players are
|
|
// still loading, or that offers several equally-ranked mirrors, resolves
|
|
// differently from one moment to the next; acting on that flips the target
|
|
// back and forth and leaves activation running forever.
|
|
if (resolved.hasVideo !== true) return false;
|
|
if (currentTargetHasVideo !== true) return true;
|
|
return normalizeFrameId(resolved.frameId) !== normalizeFrameId(currentTargetFrameId)
|
|
|| (typeof resolved.documentId === 'string'
|
|
&& typeof currentTargetDocumentId === 'string'
|
|
&& resolved.documentId !== currentTargetDocumentId);
|
|
}
|
|
|
|
function refreshCurrentMediaTarget(tabId, { queueIfRunning = false, onlyIfTargetMoved = false } = {}) {
|
|
const selectedTabId = normalizeTabId(tabId);
|
|
if (selectedTabId === null || normalizeTabId(currentTabId) !== selectedTabId) {
|
|
return Promise.resolve({ status: 'superseded' });
|
|
}
|
|
if (mediaTargetRefreshTask && mediaTargetRefreshTabId === selectedTabId) {
|
|
if (queueIfRunning) mediaTargetRefreshDirty = true;
|
|
return mediaTargetRefreshTask;
|
|
}
|
|
if (activeTargetActivation?.tabId === selectedTabId) {
|
|
return Promise.resolve({ status: 'activation_in_progress' });
|
|
}
|
|
|
|
const task = (async () => {
|
|
let result;
|
|
let pass = 0;
|
|
do {
|
|
pass++;
|
|
mediaTargetRefreshDirty = false;
|
|
if (onlyIfTargetMoved && !(await selectedMediaTargetMoved(selectedTabId))) {
|
|
result = { status: 'unchanged' };
|
|
break;
|
|
}
|
|
const expectedGeneration = targetActivationGeneration;
|
|
result = await reactivateCurrentTarget(selectedTabId, { expectedGeneration });
|
|
// Let lifecycle messages queued during the final probe/injection
|
|
// mark the refresh dirty before deciding whether a trailing pass is needed.
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
} while (mediaTargetRefreshDirty
|
|
&& pass < 2
|
|
&& normalizeTabId(currentTabId) === selectedTabId);
|
|
return result;
|
|
})();
|
|
let wrappedTask;
|
|
mediaTargetRefreshTabId = selectedTabId;
|
|
wrappedTask = task.finally(() => {
|
|
if (mediaTargetRefreshTask !== wrappedTask) return;
|
|
const needsFollowup = mediaTargetRefreshDirty
|
|
&& normalizeTabId(currentTabId) === selectedTabId;
|
|
mediaTargetRefreshTask = null;
|
|
mediaTargetRefreshTabId = null;
|
|
mediaTargetRefreshDirty = false;
|
|
if (needsFollowup && mediaTargetRefreshFollowupTimer === null) {
|
|
mediaTargetRefreshFollowupTimer = setTimeout(() => {
|
|
mediaTargetRefreshFollowupTimer = null;
|
|
refreshCurrentMediaTarget(selectedTabId, { queueIfRunning: true }).catch(() => {});
|
|
}, 250);
|
|
}
|
|
});
|
|
mediaTargetRefreshTask = wrappedTask;
|
|
return wrappedTask;
|
|
}
|
|
|
|
async function waitForMediaTargetRefresh(sender) {
|
|
const senderTabId = normalizeTabId(sender?.tab?.id);
|
|
if (senderTabId === null
|
|
|| mediaTargetRefreshTabId !== senderTabId
|
|
|| !mediaTargetRefreshTask) {
|
|
return;
|
|
}
|
|
await mediaTargetRefreshTask.catch(() => {});
|
|
}
|
|
|
|
async function retryPendingTarget({ expectedRequestId = null, requireGrantedAccess = false } = {}) {
|
|
let pending = await readPendingTarget();
|
|
if (!pending || (expectedRequestId !== null && pending.requestId !== expectedRequestId)) {
|
|
return null;
|
|
}
|
|
if (activeTargetActivation && activeTargetActivation.tabId !== pending.tabId) {
|
|
return { status: 'superseded' };
|
|
}
|
|
|
|
if (requireGrantedAccess) {
|
|
let granted;
|
|
try {
|
|
granted = await chrome.permissions.contains({
|
|
origins: [pending.originPattern]
|
|
});
|
|
} catch {
|
|
return { status: 'permission_not_granted' };
|
|
}
|
|
if (granted !== true) {
|
|
return { status: 'permission_not_granted' };
|
|
}
|
|
pending = await readPendingTarget();
|
|
if (!pending || pending.requestId !== expectedRequestId) {
|
|
return { status: 'superseded' };
|
|
}
|
|
}
|
|
|
|
try {
|
|
const expectedGeneration = targetActivationGeneration;
|
|
const response = await activateTargetTab(pending.tabId, pending.tabTitle, {
|
|
requestHostAccess: false,
|
|
expectedGeneration
|
|
});
|
|
if (response.status === 'ok') {
|
|
addLog(`Website access granted; selected tab ${pending.tabId}`, 'success');
|
|
chrome.runtime.sendMessage({
|
|
type: 'TARGET_TAB_READY',
|
|
tabId: pending.tabId,
|
|
requestId: pending.requestId
|
|
}).catch(() => {});
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
if (error?.code !== HOST_ACCESS_REQUIRED_STATUS) {
|
|
await clearPendingTarget({
|
|
expectedRequestId: pending.requestId,
|
|
expectedTabId: pending.tabId
|
|
});
|
|
addLog(`Pending tab activation failed: ${error.message}`, 'warn');
|
|
}
|
|
return injectionFailureResponse(error);
|
|
}
|
|
}
|
|
|
|
if (chrome.permissions?.onAdded?.addListener) {
|
|
chrome.permissions.onAdded.addListener((addedPermissions) => {
|
|
ensureState().then(async () => {
|
|
const pending = await readPendingTarget();
|
|
if (!pending) return;
|
|
const addedOrigins = Array.isArray(addedPermissions?.origins)
|
|
? addedPermissions.origins
|
|
: [];
|
|
if (!addedOrigins.includes(pending.originPattern) && !addedOrigins.includes('<all_urls>')) {
|
|
return;
|
|
}
|
|
await retryPendingTarget({
|
|
expectedRequestId: pending.requestId,
|
|
requireGrantedAccess: true
|
|
});
|
|
}).catch(error => addLog(`Website access retry failed: ${error.message}`, 'warn'));
|
|
});
|
|
}
|
|
|
|
if (chrome.tabs?.onRemoved?.addListener) {
|
|
chrome.tabs.onRemoved.addListener((removedTabId) => {
|
|
ensureState().then(async () => {
|
|
const tabId = normalizeTabId(removedTabId);
|
|
if (tabId === null) return;
|
|
const pending = await readPendingTarget();
|
|
const isCurrent = normalizeTabId(currentTabId) === tabId;
|
|
const isPending = pending?.tabId === tabId;
|
|
const isActivating = activeTargetActivation?.tabId === tabId;
|
|
const isSelected = normalizeTabId(userSelectedTabId) === tabId;
|
|
if (isSelected) await clearUserSelection(tabId);
|
|
if (!isCurrent && !isPending && !isActivating) return;
|
|
|
|
const hasReplacementActivation = activeTargetActivation
|
|
&& activeTargetActivation.tabId !== tabId;
|
|
if (isCurrent) completeForceSyncBeforeTargetChange(null);
|
|
if (isActivating || (isCurrent && !hasReplacementActivation)) {
|
|
invalidateTargetActivations();
|
|
}
|
|
if (isCurrent) {
|
|
currentTabId = null;
|
|
currentTabTitle = null;
|
|
clearCurrentContentTarget();
|
|
lastContentHeartbeatAt = null;
|
|
if (currentRoom) roomIdleSince = Date.now();
|
|
}
|
|
if (isPending) {
|
|
await clearPendingTarget({
|
|
expectedRequestId: pending.requestId,
|
|
expectedTabId: tabId
|
|
});
|
|
}
|
|
await chrome.storage.session.set({
|
|
currentTabId,
|
|
currentTabTitle,
|
|
currentTargetFrameId,
|
|
currentTargetDocumentId,
|
|
currentTargetHasVideo,
|
|
roomIdleSince,
|
|
lastContentHeartbeatAt
|
|
});
|
|
updateBadgeStatus();
|
|
chrome.runtime.sendMessage({ type: 'TARGET_TAB_CLEARED', tabId }).catch(() => {});
|
|
if (isCurrent) {
|
|
addLog('Target tab closed.', 'warn');
|
|
if (currentRoom) {
|
|
const roomAtClose = currentRoom;
|
|
getSettings().then(settings => {
|
|
if (currentRoom !== roomAtClose) return;
|
|
emit(EVENTS.PEER_STATUS, {
|
|
peerId,
|
|
playbackState: 'paused',
|
|
currentTime: null,
|
|
mediaTitle: null,
|
|
username: settings.username,
|
|
tabTitle: null
|
|
});
|
|
const me = currentRoom?.peers?.find(p => (p.peerId || p) === peerId);
|
|
if (me && typeof me === 'object') {
|
|
me.playbackState = 'paused';
|
|
me.currentTime = null;
|
|
me.mediaTitle = null;
|
|
me.tabTitle = null;
|
|
me.lastHeartbeat = Date.now();
|
|
if (storageInitialized) {
|
|
chrome.storage.session.set({ currentRoom });
|
|
}
|
|
chrome.runtime.sendMessage({
|
|
type: 'PEER_UPDATE',
|
|
peers: currentRoom.peers
|
|
}).catch(() => {});
|
|
}
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
}).catch(error => addLog(`Closed target-tab cleanup failed: ${error.message}`, 'warn'));
|
|
});
|
|
}
|
|
|
|
async function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries) {
|
|
if (normalizeTabId(currentTabId) !== normalizeTabId(tabId)) return;
|
|
if (mediaTargetRefreshTask && mediaTargetRefreshTabId === normalizeTabId(tabId)) {
|
|
await mediaTargetRefreshTask.catch(() => {});
|
|
if (normalizeTabId(currentTabId) !== normalizeTabId(tabId)) return;
|
|
}
|
|
|
|
const targetGeneration = targetActivationGeneration;
|
|
try {
|
|
await sendMessageToContentTab(tabId, {
|
|
type: 'SERVER_COMMAND',
|
|
action,
|
|
payload,
|
|
actionTimestamp,
|
|
commandSenderId
|
|
});
|
|
} catch (error) {
|
|
if (!isCurrentTargetIdentity(tabId, targetGeneration)) {
|
|
if (normalizeTabId(currentTabId) === normalizeTabId(tabId) && retries < 3) {
|
|
await _routeToContentInternal(
|
|
tabId,
|
|
action,
|
|
payload,
|
|
actionTimestamp,
|
|
commandSenderId,
|
|
retries + 1
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (retries >= 3) {
|
|
addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn');
|
|
clearTargetTabForIdle(tabId, targetGeneration);
|
|
return;
|
|
}
|
|
|
|
const message = String(error?.message || '');
|
|
if (message.includes('Receiving end does not exist')
|
|
|| message.includes('Extension context invalidated')
|
|
|| message.includes('No document with id')
|
|
|| message.includes('No document with ID')) {
|
|
try {
|
|
const response = await refreshCurrentMediaTarget(tabId);
|
|
if (response?.status !== 'ok' && response?.status !== 'activation_in_progress') return;
|
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
await _routeToContentInternal(
|
|
tabId,
|
|
action,
|
|
payload,
|
|
actionTimestamp,
|
|
commandSenderId,
|
|
retries + 1
|
|
);
|
|
} catch (refreshError) {
|
|
addLog(`Auto-reinject failed for tab ${tabId}: ${refreshError.message}`, 'warn');
|
|
}
|
|
return;
|
|
}
|
|
|
|
addLog(`Content Script not responding in tab ${tabId}`, 'warn');
|
|
clearTargetTabForIdle(tabId, targetGeneration);
|
|
}
|
|
}
|
|
|
|
// --- 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();
|
|
const sharedTitles = getSharedTitleFields(settings);
|
|
emit(EVENTS.PEER_STATUS, {
|
|
peerId,
|
|
status: 'heartbeat',
|
|
username: settings.username,
|
|
tabTitle: sharedTitles.tabTitle,
|
|
desynced: hcmDesynced
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
function leaveOldRoomIfSwitching(newRoomId) {
|
|
if (currentRoom && currentRoom.roomId !== newRoomId) {
|
|
if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_RESET' }).catch(() => {});
|
|
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
|
|
forceDisconnect();
|
|
currentRoom = null;
|
|
clearChatActivity();
|
|
controlMode = CONTROL_MODES.EVERYONE;
|
|
hostPeerId = null;
|
|
controllers = [];
|
|
serverCapabilities = [];
|
|
invalidateChatSession();
|
|
hcmDesynced = false;
|
|
// Notify content.js/popup so they drop any guest-side HCM state from the
|
|
// previous room (badge/dialog/desync) — H-2/H-3.
|
|
broadcastControlMode();
|
|
if (storageInitialized) chrome.storage.session.set({ currentRoom: null, hcmDesynced: false });
|
|
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, contentTarget = null) {
|
|
if (!tabId) return;
|
|
if (contentTarget) {
|
|
sendMessageToFrame(
|
|
tabId,
|
|
contentTarget.frameId,
|
|
{ action: 'RESET_AUDIO_PROCESSING' },
|
|
null,
|
|
contentTarget.documentId
|
|
).catch(() => {});
|
|
return;
|
|
}
|
|
if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) {
|
|
sendMessageToCurrentContent({ action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
|
|
return;
|
|
}
|
|
chrome.tabs.sendMessage(tabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
|
|
}
|
|
|
|
async function applyAudioSettingsToTab(tabId, contentTarget = null) {
|
|
if (!tabId) return;
|
|
// Local-only: audioSettings are never read from storage.sync.
|
|
const data = await chrome.storage.local.get(['audioSettings']);
|
|
const message = {
|
|
action: 'APPLY_AUDIO_SETTINGS',
|
|
settings: data.audioSettings
|
|
};
|
|
if (contentTarget) {
|
|
sendMessageToFrame(
|
|
tabId,
|
|
contentTarget.frameId,
|
|
message,
|
|
null,
|
|
contentTarget.documentId
|
|
).catch(() => {});
|
|
return;
|
|
}
|
|
if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) {
|
|
sendMessageToCurrentContent(message).catch(() => {});
|
|
return;
|
|
}
|
|
chrome.tabs.sendMessage(tabId, message).catch(() => {});
|
|
}
|
|
|
|
// --- Extension Message Listeners ---
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
handleAsyncMessage(message, sender, sendResponse).catch(error => {
|
|
addLog(`Message handler failed for ${message?.type || 'unknown'}: ${error.message}`, 'error');
|
|
try { sendResponse({ status: 'error' }); } catch (_) { /* channel already closed */ }
|
|
});
|
|
return true; // Keep channel open for async responses
|
|
});
|
|
|
|
chrome.storage.onChanged.addListener((changes, area) => {
|
|
if (area !== 'local') return;
|
|
if (changes.browserNotifications && currentTabId) {
|
|
sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
|
}
|
|
if (!changes.roomId && !changes.chatKey && !changes.chatEnabled) return;
|
|
if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue);
|
|
invalidateChatSession();
|
|
if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
|
});
|
|
|
|
async function handleAsyncMessage(message, sender, sendResponse) {
|
|
if (!message) return;
|
|
await ensureState();
|
|
|
|
const senderTabId = normalizeTabId(sender?.tab?.id);
|
|
const mediaLifecycleMessage = message.type === 'MEDIA_FRAME_CANDIDATE_CHANGED'
|
|
|| message.type === 'MEDIA_FRAME_VISIBILITY'
|
|
|| message.type === 'MEDIA_TARGET_REFRESH';
|
|
if (!mediaLifecycleMessage) await waitForMediaTargetRefresh(sender);
|
|
|
|
const mustRevalidateEmbeddedSender = senderTabId !== null
|
|
&& normalizeFrameId(currentTargetFrameId) !== 0
|
|
&& isCurrentContentSender(sender)
|
|
&& (message.type === 'CONTENT_EVENT' || message.type === 'HEARTBEAT');
|
|
if (mustRevalidateEmbeddedSender) {
|
|
// Heartbeats and content events arrive continuously. Revalidating a
|
|
// nested target is only about confirming the frame still holds the
|
|
// player, so it must not reinject the content script every time: that
|
|
// put Drive- and anime-style targets into a permanent activation loop.
|
|
await refreshCurrentMediaTarget(senderTabId, { onlyIfTargetMoved: true }).catch(() => {});
|
|
}
|
|
|
|
if (message.type === 'CONNECT') {
|
|
webJoinCoordinator.invalidate();
|
|
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');
|
|
if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
|
await broadcastJoinStatus({ type: 'JOIN_STATUS', success: true, message: 'Already in room' });
|
|
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) {
|
|
const sharedTitles = getSharedTitleFields(settings);
|
|
emit(EVENTS.JOIN_ROOM, {
|
|
roomId: settings.roomId,
|
|
password: settings.password,
|
|
peerId,
|
|
username: settings.username,
|
|
tabTitle: sharedTitles.tabTitle,
|
|
clientCapabilities: CLIENT_CAPABILITIES,
|
|
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') {
|
|
if (message.retryPendingTarget === true) {
|
|
await retryPendingTarget();
|
|
}
|
|
expireStuckActivation();
|
|
const pendingTarget = await readPendingTarget();
|
|
const settings = await getSettings();
|
|
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';
|
|
// One public selection, one derived state. The selection is whatever the
|
|
// user picked; readiness is whether we managed to inject into it. The
|
|
// state is terminal — nothing here retries on its own.
|
|
const publicTargetTabId = normalizeTabId(userSelectedTabId) ?? normalizeTabId(currentTabId);
|
|
const targetReady = publicTargetTabId !== null
|
|
&& normalizeTabId(currentTabId) === publicTargetTabId
|
|
&& !activeTargetActivation;
|
|
const targetActivationState = publicTargetTabId === null
|
|
? 'none'
|
|
: targetReady
|
|
? 'ready'
|
|
: activeTargetActivation
|
|
? 'activating'
|
|
: pendingTarget?.tabId === publicTargetTabId
|
|
? 'access_required'
|
|
// Nothing is in flight and the target is not live, so
|
|
// this is a settled failure. Reporting it as
|
|
// "activating" is what left the popup spinning forever
|
|
// with no way to tell that it had already given up.
|
|
: 'error';
|
|
sendResponse({
|
|
status,
|
|
peerId,
|
|
peers: currentRoom ? currentRoom.peers : [],
|
|
lastActionState,
|
|
targetTabId: publicTargetTabId,
|
|
targetTabTitle: userSelectedTabTitle ?? currentTabTitle,
|
|
targetReady,
|
|
targetActivationState,
|
|
targetActivationError: normalizeTabId(userSelectionErrorTabId) === publicTargetTabId
|
|
? userSelectionErrorMessage
|
|
: null,
|
|
targetFrameId: currentTargetFrameId,
|
|
targetDocumentId: currentTargetDocumentId,
|
|
targetHasVideo: currentTargetHasVideo,
|
|
pendingTargetTabId: pendingTarget?.tabId ?? null,
|
|
pendingTargetHost: pendingTarget?.host ?? null,
|
|
pendingTargetOriginPattern: pendingTarget?.originPattern ?? null,
|
|
pendingTargetRequestId: pendingTarget?.requestId ?? null,
|
|
episodeLobby: episodeLobby,
|
|
reconnectAttempts,
|
|
reconnectSlowMode: reconnectFailed,
|
|
roomId: currentRoom ? currentRoom.roomId : null,
|
|
serverUrl: currentServerUrl,
|
|
version: chrome.runtime.getManifest().version,
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
ping: currentPingMs,
|
|
controlMode,
|
|
hostPeerId,
|
|
controllers,
|
|
amHost: amHost(),
|
|
amController: amController(),
|
|
hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL),
|
|
coHostSupported: serverSupports(CAPABILITIES.CO_HOST),
|
|
chatSupported: serverSupportsChat(),
|
|
hasChatKey: !!settings.chatKey,
|
|
chatEnabled: settings.chatEnabled
|
|
});
|
|
} else if (message.type === 'GET_CHAT_CONTEXT') {
|
|
if (!currentRoom || !currentTabId || !isCurrentContentSender(sender)) {
|
|
sendResponse({ supported: false, hasKey: false });
|
|
return;
|
|
}
|
|
const settings = await getSettings();
|
|
const localeData = await chrome.storage.local.get(['locale', 'browserNotifications']);
|
|
await loadLocale(localeData.locale || getSystemLanguage());
|
|
const translated = key => {
|
|
const value = getMessage(key);
|
|
return value === key ? '' : value;
|
|
};
|
|
sendResponse({
|
|
supported: serverSupportsChat(),
|
|
enabled: settings.chatEnabled,
|
|
hasKey: !!settings.chatKey,
|
|
connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined),
|
|
eventNotifications: localeData.browserNotifications === true,
|
|
peerId,
|
|
roomId: currentRoom.roomId,
|
|
activity: chatActivityStore.snapshot(),
|
|
strings: {
|
|
title: translated('CHAT_TITLE'),
|
|
liveOnly: translated('CHAT_LIVE_ONLY'),
|
|
open: translated('CHAT_OPEN'),
|
|
close: translated('CHAT_CLOSE'),
|
|
dockLeft: translated('CHAT_DOCK_LEFT'),
|
|
dockRight: translated('CHAT_DOCK_RIGHT'),
|
|
detached: translated('CHAT_DETACHED'),
|
|
placeholder: translated('CHAT_PLACEHOLDER'),
|
|
send: translated('CHAT_SEND'),
|
|
missingKey: translated('CHAT_MISSING_KEY'),
|
|
tooLong: translated('CHAT_TOO_LONG'),
|
|
sendFailed: translated('CHAT_SEND_FAILED'),
|
|
empty: translated('CHAT_EMPTY'),
|
|
you: translated('LABEL_YOU'),
|
|
eventPlay: translated('NOTIF_PLAY'),
|
|
eventPause: translated('NOTIF_PAUSE'),
|
|
eventSeek: translated('NOTIF_SEEK'),
|
|
eventForcePrepare: translated('NOTIF_FORCE_PREPARE'),
|
|
eventForceExecute: translated('NOTIF_FORCE_EXECUTE'),
|
|
eventAction: translated('TOAST_PEER_ACTION'),
|
|
eventJoined: translated('TOAST_PEER_JOINED'),
|
|
eventLeft: translated('TOAST_PEER_LEFT'),
|
|
quickReactions: translated('CHAT_QUICK_REACTIONS')
|
|
}
|
|
});
|
|
} else if (message.type === 'CHAT_SEND') {
|
|
if (!currentRoom || !currentTabId || !isCurrentContentSender(sender)) {
|
|
sendResponse({ status: 'invalid_tab' });
|
|
return;
|
|
}
|
|
if (!serverSupportsChat()) {
|
|
sendResponse({ status: 'unsupported' });
|
|
return;
|
|
}
|
|
if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
|
sendResponse({ status: 'disconnected' });
|
|
return;
|
|
}
|
|
const generation = chatSessionGeneration;
|
|
const roomId = currentRoom.roomId;
|
|
const tabId = Number(currentTabId);
|
|
const socketSnapshot = socket;
|
|
const isCurrentSession = () => generation === chatSessionGeneration &&
|
|
currentRoom?.roomId === roomId && Number(currentTabId) === tabId &&
|
|
socket === socketSnapshot && socketSnapshot.readyState === WebSocket.OPEN && isNamespaceJoined;
|
|
const settings = await getSettings();
|
|
if (!settings.chatEnabled) {
|
|
sendResponse({ status: 'disabled' });
|
|
return;
|
|
}
|
|
if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) {
|
|
sendResponse({ status: settings.chatKey ? 'session_changed' : 'missing_key' });
|
|
return;
|
|
}
|
|
const chatKey = settings.chatKey;
|
|
const rateLimit = chatSendLimiter.take();
|
|
if (!rateLimit.allowed) {
|
|
sendResponse({ status: 'rate_limited', retryAfterMs: rateLimit.retryAfterMs });
|
|
return;
|
|
}
|
|
try {
|
|
const ciphertext = await encryptChatMessage({
|
|
text: message.text,
|
|
roomId,
|
|
senderId: peerId,
|
|
secret: chatKey
|
|
});
|
|
if (!isCurrentSession() || chatSecretGuard !== chatKey) {
|
|
sendResponse({ status: 'session_changed' });
|
|
return;
|
|
}
|
|
const echoPromise = chatEchoTracker.waitFor(ciphertext);
|
|
const sent = emitLive(EVENTS.CHAT_MESSAGE, buildChatRelayPayload(ciphertext));
|
|
if (!sent) {
|
|
chatEchoTracker.cancel(ciphertext);
|
|
sendResponse({ status: 'disconnected' });
|
|
return;
|
|
}
|
|
const acknowledged = await echoPromise;
|
|
if (!isCurrentSession() || chatSecretGuard !== chatKey) {
|
|
sendResponse({ status: 'session_changed' });
|
|
return;
|
|
}
|
|
sendResponse({ status: acknowledged ? 'ok' : 'unconfirmed' });
|
|
} catch (err) {
|
|
sendResponse({ status: err instanceof RangeError ? 'too_long' : 'invalid_message' });
|
|
}
|
|
} else if (message.type === 'CREATE_CHAT_KEY') {
|
|
const chatKey = generateChatSecret();
|
|
chatSecretGuard = chatKey;
|
|
invalidateChatSession();
|
|
await chrome.storage.local.set({ chatKey });
|
|
sendResponse({ status: 'ok', chatKey });
|
|
} else if (message.type === 'SET_CONTROL_MODE') {
|
|
// Popup (host) toggles the room control mode. Server validates host authority
|
|
// and broadcasts CONTROL_MODE back, which updates our local state + UI.
|
|
const mode = message.controlMode;
|
|
if (mode !== CONTROL_MODES.EVERYONE && mode !== CONTROL_MODES.HOST_ONLY) {
|
|
sendResponse({ status: 'invalid' });
|
|
return;
|
|
}
|
|
if (!amHost()) {
|
|
sendResponse({ status: 'not_host' });
|
|
return;
|
|
}
|
|
emit(EVENTS.SET_CONTROL_MODE, { controlMode: mode });
|
|
sendResponse({ status: 'ok' });
|
|
} else if (message.type === 'SET_PEER_ROLE') {
|
|
// Popup (owner) promotes/demotes a peer to/from controller. Server validates
|
|
// owner authority and broadcasts CONTROL_MODE back, refreshing all clients.
|
|
const targetPeerId = typeof message.peerId === 'string' ? message.peerId : null;
|
|
if (!targetPeerId) {
|
|
sendResponse({ status: 'invalid' });
|
|
return;
|
|
}
|
|
if (!amHost()) {
|
|
sendResponse({ status: 'not_owner' });
|
|
return;
|
|
}
|
|
emit(EVENTS.SET_PEER_ROLE, { peerId: targetPeerId, controller: message.controller === true });
|
|
sendResponse({ status: 'ok' });
|
|
} else if (message.type === 'GET_CONTROL_MODE') {
|
|
// content.js asks for current mode/role on (re)injection. Include the
|
|
// persisted desync state so a page reload re-adopts it — otherwise a fresh
|
|
// content script would start synced while background keeps relaying us as
|
|
// "Solo" to the host (stale-badge split-brain).
|
|
sendResponse({ controlMode, hostPeerId, controllers, amHost: amHost(), amController: amController(), desynced: hcmDesynced, hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL), coHostSupported: serverSupports(CAPABILITIES.CO_HOST) });
|
|
} else if (message.type === 'REQUEST_HOST_SYNC') {
|
|
if (sender.tab && !isCurrentContentSender(sender)) {
|
|
sendResponse({ status: 'ignored_unselected_tab', target: null });
|
|
return;
|
|
}
|
|
// content.js resync: hand back the host's extrapolated current position.
|
|
sendResponse({ target: getHostSyncTarget() });
|
|
} else if (message.type === 'GET_HCM_STRINGS') {
|
|
// Localized strings for the in-page host-control dialog/badge. content.js
|
|
// has no i18n loader of its own, so background resolves them here.
|
|
const settings = await chrome.storage.local.get(['locale']);
|
|
const lang = settings.locale || getSystemLanguage();
|
|
await loadLocale(lang);
|
|
// getMessage returns the key name itself if the dictionary failed to load.
|
|
// Return undefined in that case so content keeps its English fallback rather
|
|
// than rendering a raw key like "HCM_DIALOG_TITLE".
|
|
const m = (k) => { const v = getMessage(k); return v === k ? undefined : v; };
|
|
sendResponse({
|
|
title: m('HCM_DIALOG_TITLE'),
|
|
body: m('HCM_DIALOG_BODY'),
|
|
stay: m('HCM_DIALOG_STAY'),
|
|
solo: m('HCM_DIALOG_SOLO'),
|
|
badge: m('HCM_BADGE_SOLO'),
|
|
resync: m('HCM_BADGE_RESYNC')
|
|
});
|
|
} else if (message.type === 'HCM_DESYNC_STATE') {
|
|
// content.js tells us whether the local user chose to watch on their own.
|
|
// Only accept from the currently selected tab.
|
|
if (sender.tab && !isCurrentContentSender(sender)) {
|
|
sendResponse({ status: 'ignored_unselected_tab' });
|
|
return;
|
|
}
|
|
// Mirrored into heartbeats so the host's UI can show "Solo" instead of
|
|
// silently waiting for ACKs that will never come. Persisted so the
|
|
// heartbeat survives SW restarts (idle timeout, crash).
|
|
hcmDesynced = !!message.desynced;
|
|
if (storageInitialized) chrome.storage.session.set({ hcmDesynced });
|
|
sendResponse({ status: 'ok' });
|
|
} else if (message.type === 'LEAVE_ROOM') {
|
|
webJoinCoordinator.invalidate();
|
|
completeForceSyncBeforeTargetChange(null);
|
|
connectIntent = false;
|
|
reconnectFailed = false;
|
|
reconnectAttempts = 0;
|
|
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
|
emit(EVENTS.LEAVE_ROOM, { peerId });
|
|
currentRoom = null;
|
|
clearChatActivity();
|
|
controlMode = CONTROL_MODES.EVERYONE;
|
|
hostPeerId = null;
|
|
controllers = [];
|
|
serverCapabilities = [];
|
|
hcmDesynced = false;
|
|
// Notify content.js/popup BEFORE currentTabId is cleared so they drop any
|
|
// stale guest-side HCM state (dialog/badge/desync) — H-2/H-3.
|
|
broadcastControlMode();
|
|
if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget());
|
|
invalidateTargetActivations();
|
|
currentTabId = null;
|
|
currentTabTitle = null;
|
|
clearCurrentContentTarget();
|
|
roomIdleSince = null;
|
|
lastContentHeartbeatAt = null;
|
|
|
|
updateBadgeStatus();
|
|
|
|
isForceSyncInitiator = false;
|
|
forceSyncAcks.clear();
|
|
expectedAcksCount = 0;
|
|
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
|
|
|
// Cancel any active episode lobby
|
|
clearEpisodeLobbyState();
|
|
await clearPendingTarget();
|
|
|
|
chrome.storage.session.set({
|
|
currentRoom: null,
|
|
chatActivityTimeline: [],
|
|
currentTabId: null,
|
|
currentTabTitle: null,
|
|
currentTargetFrameId: 0,
|
|
currentTargetDocumentId: null,
|
|
currentTargetHasVideo: false,
|
|
roomIdleSince: null,
|
|
lastContentHeartbeatAt: null,
|
|
isForceSyncInitiator: false,
|
|
forceSyncAcks: [],
|
|
forceSyncDeadline: null,
|
|
episodeLobby: null,
|
|
expectedAcksCount: 0,
|
|
hcmDesynced: false
|
|
});
|
|
chatSecretGuard = '';
|
|
invalidateChatSession();
|
|
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).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, chatKey: rawChatKey, useCustomServer, serverUrl } = message;
|
|
const roomId = normalizeRoomId(rawRoomId);
|
|
const chatKey = validateChatSecret(rawChatKey);
|
|
if (!roomId) {
|
|
const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' };
|
|
await broadcastJoinStatus(errMsg);
|
|
sendResponse({ status: 'invalid_room_id' });
|
|
return;
|
|
}
|
|
await webJoinCoordinator.run(async isCurrentJoin => {
|
|
if (!isCurrentJoin()) {
|
|
sendResponse({ status: 'superseded' });
|
|
return { status: 'superseded' };
|
|
}
|
|
try {
|
|
connectIntent = true;
|
|
await chrome.storage.local.set({
|
|
roomId,
|
|
password: typeof password === 'string' ? password : '',
|
|
chatKey,
|
|
useCustomServer: !!useCustomServer,
|
|
serverUrl: typeof serverUrl === 'string' ? serverUrl : ''
|
|
});
|
|
if (!isCurrentJoin()) {
|
|
sendResponse({ status: 'superseded' });
|
|
return { status: 'superseded' };
|
|
}
|
|
chatSecretGuard = chatKey;
|
|
invalidateChatSession();
|
|
const settings = await getSettings();
|
|
if (!isCurrentJoin()) {
|
|
sendResponse({ status: 'superseded' });
|
|
return { status: 'superseded' };
|
|
}
|
|
const desiredUrl = resolveServerUrl(settings);
|
|
|
|
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
|
|
broadcastConnectionStatus('connected');
|
|
if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
|
const statusSent = await broadcastJoinStatus(
|
|
{ type: 'JOIN_STATUS', success: true, message: 'Already in room' },
|
|
isCurrentJoin
|
|
);
|
|
if (!statusSent || !isCurrentJoin()) {
|
|
sendResponse({ status: 'superseded' });
|
|
return { status: 'superseded' };
|
|
}
|
|
sendResponse({ status: 'already_joined' });
|
|
return { status: 'already_joined' };
|
|
}
|
|
|
|
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) {
|
|
const sharedTitles = getSharedTitleFields(settings);
|
|
emit(EVENTS.JOIN_ROOM, {
|
|
roomId,
|
|
password,
|
|
peerId,
|
|
username: settings.username,
|
|
tabTitle: sharedTitles.tabTitle,
|
|
clientCapabilities: CLIENT_CAPABILITIES,
|
|
protocolVersion: PROTOCOL_VERSION
|
|
});
|
|
}
|
|
addLog(`Joining room via link: ${roomId}`, 'info');
|
|
sendResponse({ status: 'ok' });
|
|
return { status: 'ok' };
|
|
} catch (_) {
|
|
if (isCurrentJoin()) await clearFailedJoinCredentials();
|
|
sendResponse({ status: 'storage_error' });
|
|
return { status: 'storage_error' };
|
|
}
|
|
});
|
|
} else if (message.type === 'REGENERATE_ID') {
|
|
// Match getPeerId()'s 16-hex-char generation — see comment there.
|
|
const newId = self.crypto.randomUUID().replace(/-/g, '').substring(0, 16);
|
|
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 = normalizeTabId(message.tabId);
|
|
if (tabId === null) {
|
|
sendResponse({ error: 'No tabId provided' });
|
|
return;
|
|
}
|
|
getReadyTabVideoState(tabId).then(state => {
|
|
sendResponse(state);
|
|
}).catch(error => {
|
|
sendResponse({ error: error.message });
|
|
});
|
|
} else if (message.type === 'DEV_SIMULATE_REMOTE_SEEK') {
|
|
if (!(await devRemoteToolsAllowed())) {
|
|
sendResponse({ status: 'forbidden' });
|
|
return;
|
|
}
|
|
const delta = message.delta !== null && message.delta !== undefined ? Number(message.delta) : null;
|
|
const targetTime = message.targetTime !== null && message.targetTime !== undefined ? Number(message.targetTime) : null;
|
|
|
|
if (delta === null && targetTime === null) {
|
|
sendResponse({ status: 'invalid_params' });
|
|
return;
|
|
}
|
|
simulateRemoteSeek(delta, targetTime).then(sendResponse).catch(err => {
|
|
addLog(`Remote seek simulation failed: ${err.message}`, 'warn');
|
|
sendResponse({ status: 'error', message: err.message });
|
|
});
|
|
} else if (message.type === 'CONTENT_EVENT') {
|
|
const senderIsContent = !!sender?.tab && !isExtensionPageSender(sender);
|
|
if (!senderIsContent && message.expectedTabId !== undefined) {
|
|
const expectedTabId = normalizeTabId(message.expectedTabId);
|
|
if (expectedTabId === null || normalizeTabId(currentTabId) !== expectedTabId) {
|
|
sendResponse({ status: 'stale_target' });
|
|
return;
|
|
}
|
|
}
|
|
const processEvent = async () => {
|
|
// Host Control Mode (sender-side): a non-controller in host-only mode must
|
|
// not drive the room. Don't broadcast; hand the action back to content.js so
|
|
// it can snap the local player back / offer desync.
|
|
// Defensive: require a known hostPeerId (L-6) — otherwise the actual
|
|
// owner would gate themselves if state ever becomes inconsistent.
|
|
if (controlMode === CONTROL_MODES.HOST_ONLY && hostPeerId && !amController() &&
|
|
HOST_ONLY_GATED_ACTIONS.includes(message.action)) {
|
|
addLog(`Host-only: blocked local ${message.action} (you are a guest)`, 'warn');
|
|
if (senderIsContent && sender.tab.id) {
|
|
sendMessageToFrame(sender.tab.id, sender.frameId, {
|
|
type: 'HOST_BLOCKED',
|
|
action: message.action,
|
|
target: getHostSyncTarget()
|
|
}, null, sender.documentId).catch(() => {});
|
|
}
|
|
sendResponse({ status: 'blocked_host_only' });
|
|
return;
|
|
}
|
|
|
|
// 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 payload = message.payload && typeof message.payload === 'object' ? message.payload : {};
|
|
const payloadNumber = (value) => value !== undefined && value !== null && value !== '' ? Number(value) : NaN;
|
|
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
|
|
const targetTime = payloadNumber(payload.targetTime);
|
|
if (!Number.isFinite(targetTime)) {
|
|
sendResponse({ status: 'invalid_params' });
|
|
return;
|
|
}
|
|
payload.targetTime = targetTime;
|
|
} else if (message.action === EVENTS.SEEK) {
|
|
const targetTime = payloadNumber(payload.targetTime !== undefined ? payload.targetTime : payload.currentTime);
|
|
if (!Number.isFinite(targetTime)) {
|
|
sendResponse({ status: 'invalid_params' });
|
|
return;
|
|
}
|
|
payload.currentTime = targetTime;
|
|
payload.targetTime = targetTime;
|
|
}
|
|
|
|
const timestamp = Date.now();
|
|
localSeq++;
|
|
chrome.storage.session.set({ localSeq });
|
|
updateLastAction(message.action, 'You', timestamp);
|
|
|
|
const hasPlaybackTime = Number.isFinite(payload.currentTime) || Number.isFinite(payload.targetTime);
|
|
if (!senderIsContent && (message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE) && !hasPlaybackTime) {
|
|
const tabId = currentTabId ? parseInt(currentTabId) : NaN;
|
|
if (!isNaN(tabId)) {
|
|
const state = await getReadyTabVideoState(tabId);
|
|
if (state && !state.error && state.found && Number.isFinite(state.currentTime)) {
|
|
payload.currentTime = state.currentTime;
|
|
}
|
|
}
|
|
}
|
|
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 (!senderIsContent && (message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK)) {
|
|
routeToContent(message.action, message.payload);
|
|
}
|
|
|
|
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');
|
|
sendChatActivity(message.action, peerId, timestamp);
|
|
|
|
const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK;
|
|
if (isNonEssentialEvent && !hasOtherPeers) {
|
|
sendResponse({ status: 'ok_solo' });
|
|
return;
|
|
}
|
|
|
|
const settings = await getSettings();
|
|
const outboundPayload = withTitlePrivacy(message.payload, settings, ['mediaTitle']);
|
|
emit(message.action, { ...outboundPayload, peerId });
|
|
sendResponse({ status: 'ok' });
|
|
};
|
|
|
|
if (senderIsContent) {
|
|
if (!isCurrentContentSender(sender)) {
|
|
sendResponse({ status: 'ignored_unselected_tab' });
|
|
return;
|
|
}
|
|
|
|
currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null;
|
|
chrome.storage.session.set({ currentTabTitle });
|
|
updateBadgeStatus();
|
|
processEvent().catch(err => {
|
|
addLog('Content event privacy error: ' + err.message, 'error');
|
|
sendResponse({ status: 'error' });
|
|
});
|
|
} else {
|
|
processEvent().catch(err => {
|
|
addLog('Content event privacy error: ' + err.message, 'error');
|
|
sendResponse({ status: 'error' });
|
|
});
|
|
}
|
|
} else if (message.type === 'FORCE_SYNC_ACK') {
|
|
if (sender.tab && !isCurrentContentSender(sender)) {
|
|
sendResponse({ status: 'ignored_unselected_tab' });
|
|
return;
|
|
}
|
|
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') {
|
|
if (sender.tab && !isCurrentContentSender(sender)) {
|
|
sendResponse({ status: 'ignored_unselected_tab' });
|
|
return;
|
|
}
|
|
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) {
|
|
if (!isCurrentContentSender(sender)) {
|
|
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 sharedTitles = getSharedTitleFields(settings, message.payload?.mediaTitle);
|
|
const statusPayload = {
|
|
...message.payload,
|
|
peerId,
|
|
username: settings.username,
|
|
tabTitle: sharedTitles.tabTitle,
|
|
mediaTitle: sharedTitles.mediaTitle,
|
|
desynced: hcmDesynced
|
|
};
|
|
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 = sharedTitles.tabTitle;
|
|
me.username = settings.username;
|
|
me.mediaTitle = sharedTitles.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 === 'INJECT_CONTENT_SCRIPT') {
|
|
const tabId = normalizeTabId(message.tabId);
|
|
if (tabId === null) {
|
|
sendResponse({ status: 'invalid_tab' });
|
|
return true;
|
|
}
|
|
|
|
const expectedCurrentTabId = normalizeTabId(message.expectedCurrentTabId);
|
|
if (expectedCurrentTabId === null
|
|
|| tabId !== expectedCurrentTabId
|
|
|| normalizeTabId(currentTabId) !== expectedCurrentTabId) {
|
|
sendResponse({ status: 'stale_target' });
|
|
return true;
|
|
}
|
|
|
|
refreshCurrentMediaTarget(tabId).then(response => {
|
|
sendResponse(response);
|
|
}).catch(err => {
|
|
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
|
|
sendResponse(injectionFailureResponse(err));
|
|
});
|
|
return true;
|
|
} else if (message.type === 'SET_TARGET_TAB') {
|
|
if (message.tabId === null || message.tabId === undefined || message.tabId === '') {
|
|
const previousTabId = currentTabId;
|
|
const previousContentTarget = currentContentTarget();
|
|
completeForceSyncBeforeTargetChange(null);
|
|
invalidateTargetActivations();
|
|
currentTabId = null;
|
|
currentTabTitle = null;
|
|
clearCurrentContentTarget();
|
|
lastContentHeartbeatAt = null;
|
|
if (currentRoom) roomIdleSince = Date.now();
|
|
if (previousTabId) {
|
|
await deactivateTargetTab(previousTabId, previousContentTarget);
|
|
}
|
|
await clearUserSelection();
|
|
await clearPendingTarget();
|
|
await chrome.storage.session.set({
|
|
currentTabId: null,
|
|
currentTabTitle: null,
|
|
currentTargetFrameId: 0,
|
|
currentTargetDocumentId: null,
|
|
currentTargetHasVideo: false,
|
|
roomIdleSince,
|
|
lastContentHeartbeatAt: null
|
|
});
|
|
updateBadgeStatus();
|
|
sendResponse({ status: 'ok', tabId: null });
|
|
return;
|
|
}
|
|
|
|
// Persist the choice before activating. Injection can fail for reasons
|
|
// the user can act on (a player frame needing host access, a page still
|
|
// loading), and none of them mean they stopped wanting this tab.
|
|
await rememberUserSelection(message.tabId, message.tabTitle);
|
|
try {
|
|
const response = await activateTargetTab(message.tabId, message.tabTitle);
|
|
if (response?.status === 'ok') {
|
|
chrome.runtime.sendMessage({
|
|
type: 'TARGET_TAB_READY',
|
|
tabId: response.tabId
|
|
}).catch(() => {});
|
|
} else if (response?.status !== 'superseded') {
|
|
await recordUserSelectionFailure(message.tabId, {
|
|
message: `Activation returned ${response?.status || 'no result'}`
|
|
});
|
|
}
|
|
sendResponse(response);
|
|
} catch (error) {
|
|
addLog(`Failed to select tab: ${error.message}`, 'warn');
|
|
await recordUserSelectionFailure(message.tabId, error);
|
|
sendResponse(injectionFailureResponse(error));
|
|
}
|
|
} 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) {
|
|
if (!isCurrentContentSender(sender)) {
|
|
sendResponse({ status: 'ignored_unselected_tab' });
|
|
return;
|
|
}
|
|
}
|
|
|
|
const newTitle = message.payload && message.payload.newTitle;
|
|
if (newTitle && extractEpisodeId(newTitle) === null) {
|
|
addLog(`Episode change detected ("${newTitle}") but no episode ID was found; ignoring.`, 'info');
|
|
sendResponse({ status: 'not_an_episode' });
|
|
return;
|
|
}
|
|
if (!newTitle) {
|
|
sendResponse({ status: 'no_title' });
|
|
return;
|
|
}
|
|
|
|
const settings = await getSettings();
|
|
const lobbyTitle = sanitizeSharedTitle(newTitle, settings.mediaTitlePrivacyMode);
|
|
if (!lobbyTitle) {
|
|
addLog(`Episode change detected but media title sharing is ${settings.mediaTitlePrivacyMode}; not creating a lobby.`, 'info');
|
|
sendResponse({ status: 'title_privacy_no_lobby' });
|
|
return;
|
|
}
|
|
|
|
// Check setting
|
|
const epSettings = await chrome.storage.local.get(['autoSyncNextEpisode']);
|
|
if (epSettings.autoSyncNextEpisode === false) {
|
|
addLog(`Episode change detected ("${lobbyTitle}") but Auto-Sync is disabled.`, 'info');
|
|
sendResponse({ status: 'disabled' });
|
|
return;
|
|
}
|
|
|
|
// Host Control Mode: a gated guest must NOT initiate an episode lobby — the
|
|
// server drops the guest's EPISODE_LOBBY, so the lobby would never complete
|
|
// and the guest would self-pause (PAUSE_FOR_LOBBY) into a 60s freeze. In
|
|
// host-only the controllers (owner + co-hosts) drive episode sync; a plain
|
|
// guest just follows / snaps back. Use amController() for parity with the
|
|
// CONTENT_EVENT gate and the server's controllers-based check.
|
|
if (controlMode === CONTROL_MODES.HOST_ONLY && !amController()) {
|
|
addLog(`Episode change ("${lobbyTitle}") — host-only guest, not creating a lobby (controller drives).`, 'info');
|
|
sendResponse({ status: 'host_only_guest_skip' });
|
|
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 ("${lobbyTitle}") — 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, lobbyTitle)) {
|
|
if (!episodeLobby.readyPeers.includes(peerId)) {
|
|
episodeLobby.readyPeers.push(peerId);
|
|
persistEpisodeLobby();
|
|
broadcastLobbyUpdate();
|
|
emit(EVENTS.EPISODE_READY, { peerId, title: lobbyTitle });
|
|
checkEpisodeLobbyCompletion();
|
|
}
|
|
sendResponse({ status: 'ready_sent' });
|
|
return;
|
|
}
|
|
|
|
// Cancel any existing lobby for a different episode
|
|
if (episodeLobby) clearEpisodeLobbyState();
|
|
|
|
// Create new lobby
|
|
episodeLobby = {
|
|
expectedTitle: lobbyTitle,
|
|
initiatorPeerId: peerId,
|
|
readyPeers: [peerId], // We are already ready
|
|
createdAt: Date.now()
|
|
};
|
|
persistEpisodeLobby();
|
|
broadcastLobbyUpdate();
|
|
addLog(`Episode lobby created: "${lobbyTitle}"`, '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) {
|
|
sendMessageToFrame(sender.tab.id, sender.frameId, {
|
|
type: 'PAUSE_FOR_LOBBY',
|
|
expectedTitle: lobbyTitle
|
|
}, null, sender.documentId).catch(() => {});
|
|
}
|
|
|
|
// Broadcast to room
|
|
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: lobbyTitle });
|
|
|
|
// 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') {
|
|
if (sender.tab) {
|
|
if (!isCurrentContentSender(sender)) {
|
|
sendResponse({ status: 'ignored_unselected_tab' });
|
|
return;
|
|
}
|
|
}
|
|
// Content script confirmed it loaded the lobby episode
|
|
if (episodeLobby && message.payload && sameEpisode(message.payload.title, episodeLobby.expectedTitle)) {
|
|
if (!episodeLobby.readyPeers.includes(peerId)) {
|
|
const settings = await getSettings();
|
|
const readyTitle = sanitizeSharedTitle(message.payload.title, settings.mediaTitlePrivacyMode);
|
|
episodeLobby.readyPeers.push(peerId);
|
|
persistEpisodeLobby();
|
|
broadcastLobbyUpdate();
|
|
emit(EVENTS.EPISODE_READY, { peerId, title: readyTitle });
|
|
addLog(`Local episode ready: "${readyTitle || episodeLobby.expectedTitle}"`, 'success');
|
|
checkEpisodeLobbyCompletion();
|
|
}
|
|
}
|
|
sendResponse({ status: 'ok' });
|
|
} else if (message.type === 'TITLE_PRIVACY_CHANGED') {
|
|
const settings = await getSettings();
|
|
if (episodeLobby && episodeLobby.initiatorPeerId === peerId) {
|
|
const nextLobbyTitle = sanitizeSharedTitle(episodeLobby.expectedTitle, settings.mediaTitlePrivacyMode);
|
|
if (!nextLobbyTitle || nextLobbyTitle !== episodeLobby.expectedTitle) {
|
|
cancelEpisodeLobby('Title privacy changed');
|
|
}
|
|
}
|
|
if (currentRoom) {
|
|
const sharedTitles = getSharedTitleFields(settings);
|
|
emit(EVENTS.PEER_STATUS, {
|
|
peerId,
|
|
status: 'heartbeat',
|
|
username: settings.username,
|
|
tabTitle: sharedTitles.tabTitle,
|
|
mediaTitle: sharedTitles.mediaTitle,
|
|
desynced: hcmDesynced
|
|
});
|
|
}
|
|
if (currentRoom && currentTabId) {
|
|
sendMessageToCurrentContent({ type: 'REQUEST_HEARTBEAT' }).catch(() => {});
|
|
}
|
|
sendResponse({ status: 'ok' });
|
|
} else if (message.type === 'MEDIA_FRAME_CANDIDATE_CHANGED') {
|
|
const tabId = normalizeTabId(sender.tab?.id);
|
|
if (tabId === null || tabId !== normalizeTabId(currentTabId)) {
|
|
sendResponse({ status: 'ignored_stale_tab' });
|
|
return;
|
|
}
|
|
// A page-driven notification must never surface as a handler failure.
|
|
// Reporting it that way turned one unreachable player frame into an
|
|
// endless error cascade in the popup.
|
|
const activation = await refreshCurrentMediaTarget(tabId, {
|
|
queueIfRunning: true,
|
|
onlyIfTargetMoved: true
|
|
}).catch(error => {
|
|
addLog(`Media frame candidate refresh failed: ${error.message}`, 'warn');
|
|
return { status: 'error', message: error.message };
|
|
});
|
|
sendResponse(activation || { status: 'invalid_tab' });
|
|
} else if (message.type === 'MEDIA_FRAME_VISIBILITY') {
|
|
if (!isCurrentContentSender(sender)) {
|
|
sendResponse({ status: 'ignored_stale_frame' });
|
|
return;
|
|
}
|
|
if (message.visible !== false) {
|
|
sendResponse({ status: 'ok' });
|
|
return;
|
|
}
|
|
const tabId = normalizeTabId(sender.tab?.id);
|
|
const activation = tabId === null
|
|
? null
|
|
: await refreshCurrentMediaTarget(tabId, {
|
|
queueIfRunning: true,
|
|
onlyIfTargetMoved: true
|
|
}).catch(error => {
|
|
addLog(`Media frame visibility refresh failed: ${error.message}`, 'warn');
|
|
return { status: 'error', message: error.message };
|
|
});
|
|
sendResponse(activation || { status: 'invalid_tab' });
|
|
} else if (message.type === 'MEDIA_TARGET_REFRESH') {
|
|
if (!isCurrentContentSender(sender)) {
|
|
sendResponse({ status: 'ignored_stale_frame' });
|
|
return;
|
|
}
|
|
const tabId = normalizeTabId(sender.tab?.id);
|
|
const activation = tabId === null
|
|
? null
|
|
: await refreshCurrentMediaTarget(tabId, {
|
|
queueIfRunning: true,
|
|
onlyIfTargetMoved: true
|
|
}).catch(error => {
|
|
addLog(`Media target refresh failed: ${error.message}`, 'warn');
|
|
return { status: 'error', message: error.message };
|
|
});
|
|
sendResponse(activation || { status: 'invalid_tab' });
|
|
} else if (message.type === 'CONTENT_BOOT') {
|
|
if (sender.tab) {
|
|
if (!isCurrentContentSender(sender)) {
|
|
sendResponse({ status: 'ignored_unselected_tab' });
|
|
return;
|
|
}
|
|
}
|
|
// 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,
|
|
reactivateCurrentTarget: tabId => refreshCurrentMediaTarget(tabId, { queueIfRunning: true }),
|
|
ensureState,
|
|
sendToCurrentContent: sendMessageToCurrentContent
|
|
});
|
|
|
|
// Initial Connect — only if user has an active room configuration
|
|
getSettings().then(settings => {
|
|
connectIntent = !!settings.roomId;
|
|
if (connectIntent) connect();
|
|
}).catch(() => connectIntent = false);
|