mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-31 13:08:15 +00:00
style: restore classic remote control UI and sync architectural fixes
This commit is contained in:
+10
-2
@@ -23,7 +23,9 @@ KoalaSync is a specialized tool for **synchronized video playback** across multi
|
|||||||
- `docker-compose.yml`: Root-level orchestration for the relay server.
|
- `docker-compose.yml`: Root-level orchestration for the relay server.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> `shared/constants.js` and `shared/blacklist.js` must be synchronized to the `extension/shared/` directory after every modification by running `./scripts/sync-constants.sh` or `.\scripts\sync-constants.bat`.
|
> **Single Source of Truth**: `shared/constants.js` and `shared/blacklist.js` are the master files. They must be synchronized to the `extension/shared/` directory using `.\scripts\sync-constants.bat` or `./scripts/sync-constants.sh`.
|
||||||
|
> - **Extension Modules** (`background.js`, `popup.js`) import directly from `./shared/constants.js`.
|
||||||
|
> - **Content Scripts** (`content.js`) use a **manual synchronous mirror** to prevent race conditions during page load. Always verify parity after sync.
|
||||||
|
|
||||||
## 3. Mandatory Reading
|
## 3. Mandatory Reading
|
||||||
Before touching any code, you MUST read the following documents in order:
|
Before touching any code, you MUST read the following documents in order:
|
||||||
@@ -31,7 +33,13 @@ Before touching any code, you MUST read the following documents in order:
|
|||||||
2. [extension/README.md](extension/README.md) – Extension components, tab structure, and loading process.
|
2. [extension/README.md](extension/README.md) – Extension components, tab structure, and loading process.
|
||||||
3. [SYNC_GUIDE.md](SYNC_GUIDE.md) – Protocol constants and synchronization requirements.
|
3. [SYNC_GUIDE.md](SYNC_GUIDE.md) – Protocol constants and synchronization requirements.
|
||||||
|
|
||||||
## 4. Design Guidelines
|
## 4. The "Vanilla JS Mirror" Pattern
|
||||||
|
To avoid boot-time race conditions in Manifest V3 without a bundler, the following architectural trade-off is enforced:
|
||||||
|
- **Synchronous Execution**: `content.js` MUST execute synchronously to catch early media events.
|
||||||
|
- **Manual Mirroring**: `content.js` maintains a manual mirror of the `EVENTS` constants from `shared/constants.js`.
|
||||||
|
- **Maintenance**: Developers must ensure that any changes to `shared/constants.js` are manually reflected in `content.js` after running the sync scripts.
|
||||||
|
|
||||||
|
## 5. Design Guidelines
|
||||||
The popup UI follows a strict design system. Do not modify these variables or the layout structure without explicit approval.
|
The popup UI follows a strict design system. Do not modify these variables or the layout structure without explicit approval.
|
||||||
- **Font**: System font stack. **MANDATORY**: No external CDNs or Google Fonts to ensure 100% privacy.
|
- **Font**: System font stack. **MANDATORY**: No external CDNs or Google Fonts to ensure 100% privacy.
|
||||||
- **Popup Width**: Fixed at `320px`.
|
- **Popup Width**: Fixed at `320px`.
|
||||||
|
|||||||
@@ -40,3 +40,10 @@ To maintain a clean room state and eliminate "Ghost Peers":
|
|||||||
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or DoS.
|
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or DoS.
|
||||||
- **Noise Filtering**: Uses a curated blacklist of domains (Search Engines, Social Media) to declutter the "Target Tab" selector in the popup.
|
- **Noise Filtering**: Uses a curated blacklist of domains (Search Engines, Social Media) to declutter the "Target Tab" selector in the popup.
|
||||||
- **Diagnostics**: A "Dev" tab provides real-time access to the underlying `<video>` state (`readyState`, `paused`, `currentTime`) for easier troubleshooting.
|
- **Diagnostics**: A "Dev" tab provides real-time access to the underlying `<video>` state (`readyState`, `paused`, `currentTime`) for easier troubleshooting.
|
||||||
|
|
||||||
|
## 6. Constant Synchronization & Consistency
|
||||||
|
To maintain a "Single Source of Truth" across the server and extension without using a bundler:
|
||||||
|
- **Relay Server & Extension Modules**: `background.js` and `popup.js` import constants directly from `shared/constants.js`.
|
||||||
|
- **Content Scripts**: To ensure zero-latency execution, `content.js` uses a manual mirror of `EVENTS`.
|
||||||
|
- **Synchronization**: The `./scripts/sync-constants.sh` script ensures that the `shared/` folder within the `extension/` directory is kept up-to-date with the root `shared/` source.
|
||||||
|
- **Verification**: Any protocol change requires a manual verification sweep across all three constant locations (Shared, Server, and Content Script Mirror).
|
||||||
|
|||||||
+308
-175
@@ -21,27 +21,73 @@ let isNamespaceJoined = false;
|
|||||||
let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] };
|
let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] };
|
||||||
let currentCommandSenderId = null; // Track who sent the last command we are executing
|
let currentCommandSenderId = null; // Track who sent the last command we are executing
|
||||||
|
|
||||||
// Restore state from session storage
|
// --- Boot Sequence Lock ---
|
||||||
chrome.storage.session.get(['logs', 'history', 'currentRoom', 'lastActionState'], (data) => {
|
let restorationTask = null;
|
||||||
if (data.logs) logs = data.logs;
|
|
||||||
if (data.history) history = data.history;
|
|
||||||
if (data.currentRoom) currentRoom = data.currentRoom;
|
|
||||||
if (data.lastActionState) lastActionState = data.lastActionState;
|
|
||||||
storageInitialized = true;
|
|
||||||
|
|
||||||
if (pendingLogs.length > 0) {
|
function ensureState() {
|
||||||
logs.unshift(...pendingLogs);
|
if (!restorationTask) {
|
||||||
if (logs.length > 50) logs = logs.slice(0, 50);
|
restorationTask = new Promise(resolve => {
|
||||||
chrome.storage.session.set({ logs });
|
chrome.storage.session.get([
|
||||||
pendingLogs = [];
|
'logs', 'history', 'currentRoom', 'lastActionState',
|
||||||
|
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
|
||||||
|
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime'
|
||||||
|
], (data) => {
|
||||||
|
// 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, 50);
|
||||||
|
if (data.history) history = [...history, ...data.history].slice(0, 20);
|
||||||
|
if (data.currentRoom) currentRoom = data.currentRoom;
|
||||||
|
if (data.lastActionState) lastActionState = data.lastActionState;
|
||||||
|
|
||||||
|
if (data.eventQueue) eventQueue = [...eventQueue, ...data.eventQueue].slice(0, 50);
|
||||||
|
if (data.isForceSyncInitiator !== undefined && isForceSyncInitiator === false) {
|
||||||
|
isForceSyncInitiator = data.isForceSyncInitiator;
|
||||||
|
}
|
||||||
|
if (data.forceSyncAcks) {
|
||||||
|
const mergedAcks = new Set([...forceSyncAcks, ...data.forceSyncAcks]);
|
||||||
|
forceSyncAcks = mergedAcks;
|
||||||
|
}
|
||||||
|
if (data.reconnectFailed !== undefined) reconnectFailed = data.reconnectFailed;
|
||||||
|
if (data.reconnectStartTime) reconnectStartTime = data.reconnectStartTime;
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
storageInitialized = true;
|
||||||
|
|
||||||
|
// Process any early logs/history that weren't captured in the spread
|
||||||
|
if (pendingLogs.length > 0) {
|
||||||
|
logs = [...pendingLogs, ...logs].slice(0, 50);
|
||||||
|
chrome.storage.session.set({ logs });
|
||||||
|
pendingLogs = [];
|
||||||
|
}
|
||||||
|
if (pendingHistory.length > 0) {
|
||||||
|
history = [...pendingHistory, ...history].slice(0, 20);
|
||||||
|
chrome.storage.session.set({ history });
|
||||||
|
pendingHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (pendingHistory.length > 0) {
|
return restorationTask;
|
||||||
history.unshift(...pendingHistory);
|
}
|
||||||
if (history.length > 20) history = history.slice(0, 20);
|
|
||||||
chrome.storage.session.set({ history });
|
// Start restoration immediately
|
||||||
pendingHistory = [];
|
ensureState();
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
let reconnectStartTime = null; // New: track when reconnection started
|
let reconnectStartTime = null; // New: track when reconnection started
|
||||||
@@ -54,14 +100,8 @@ let forceSyncTimeout = null;
|
|||||||
|
|
||||||
// --- Storage Utils ---
|
// --- Storage Utils ---
|
||||||
function startHeartbeat() {
|
function startHeartbeat() {
|
||||||
stopHeartbeat();
|
// Session heartbeats are now handled by the chrome.alarms 'keepAlive' listener
|
||||||
heartbeatInterval = setInterval(() => {
|
// to ensure they survive Service Worker suspension in MV3.
|
||||||
if (currentRoom) {
|
|
||||||
emit(EVENTS.PEER_STATUS, { peerId, status: 'heartbeat' });
|
|
||||||
} else {
|
|
||||||
stopHeartbeat();
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopHeartbeat() {
|
function stopHeartbeat() {
|
||||||
@@ -113,141 +153,172 @@ function addLog(message, type = 'info') {
|
|||||||
// --- WebSocket Client ---
|
// --- WebSocket Client ---
|
||||||
async function connect() {
|
async function connect() {
|
||||||
if (isConnecting) return;
|
if (isConnecting) return;
|
||||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return;
|
|
||||||
if (!navigator.onLine) {
|
|
||||||
addLog('Browser is offline. Waiting...', 'warn');
|
|
||||||
broadcastConnectionStatus('offline');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reconnectFailed) return; // Wait for manual retry
|
|
||||||
|
|
||||||
if (!peerId) peerId = await getPeerId();
|
|
||||||
const settings = await getSettings();
|
|
||||||
|
|
||||||
isConnecting = true;
|
isConnecting = true;
|
||||||
broadcastConnectionStatus('connecting');
|
|
||||||
const isCustomServer = settings.serverUrl && settings.useCustomServer;
|
|
||||||
let finalUrl = isCustomServer ? settings.serverUrl : OFFICIAL_SERVER_URL;
|
|
||||||
|
|
||||||
// Robustness: Ensure finalUrl is not empty and has a protocol
|
|
||||||
if (isCustomServer) {
|
|
||||||
finalUrl = finalUrl.trim();
|
|
||||||
if (!finalUrl.includes('://')) {
|
|
||||||
finalUrl = 'ws://' + finalUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strict WSS Enforcement
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addLog(`Connecting to ${isCustomServer ? finalUrl : 'Official Server'}...`, 'info');
|
|
||||||
|
|
||||||
|
let finalUrl = '';
|
||||||
try {
|
try {
|
||||||
const url = new URL(finalUrl);
|
// --- Phase 1: Storage ---
|
||||||
url.pathname = '/socket.io/';
|
let settings;
|
||||||
url.searchParams.set('EIO', '4');
|
try {
|
||||||
url.searchParams.set('transport', 'websocket');
|
if (!peerId) peerId = await getPeerId();
|
||||||
url.searchParams.set('version', APP_VERSION);
|
settings = await getSettings();
|
||||||
|
} catch (e) {
|
||||||
if (!isCustomServer) {
|
throw new Error(`[Storage Error] ${e.message}`);
|
||||||
url.searchParams.set('token', OFFICIAL_SERVER_TOKEN);
|
|
||||||
} else {
|
|
||||||
// Self-hosted servers use the same official token by design.
|
|
||||||
// This allows users to run their own relay while still using
|
|
||||||
// the official extension — the token is public and not a secret.
|
|
||||||
url.searchParams.set('token', OFFICIAL_SERVER_TOKEN);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
socket = new WebSocket(url.toString());
|
// --- 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();
|
||||||
|
}
|
||||||
|
|
||||||
socket.onopen = () => {
|
if (!navigator.onLine) {
|
||||||
|
addLog('Browser is offline. Waiting...', 'warn');
|
||||||
|
broadcastConnectionStatus('offline');
|
||||||
isConnecting = false;
|
isConnecting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reconnectFailed) {
|
||||||
|
isConnecting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastConnectionStatus('connecting');
|
||||||
|
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'}...`, 'info');
|
||||||
|
|
||||||
|
// --- 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', APP_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 = () => {
|
||||||
reconnectDelay = 1000;
|
reconnectDelay = 1000;
|
||||||
addLog('WebSocket Connection Opened', 'success');
|
addLog('WebSocket Connection Opened', 'success');
|
||||||
reconnectStartTime = null;
|
reconnectStartTime = null;
|
||||||
reconnectFailed = false;
|
reconnectFailed = false;
|
||||||
isNamespaceJoined = false;
|
isNamespaceJoined = false;
|
||||||
|
|
||||||
// Socket.IO Handshake: Send "40" to join default namespace
|
|
||||||
socket.send('40');
|
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');
|
||||||
|
addLog('Joined Namespace /', 'success');
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (settings.roomId) {
|
||||||
|
emit(EVENTS.JOIN_ROOM, {
|
||||||
|
roomId: settings.roomId,
|
||||||
|
password: settings.password,
|
||||||
|
peerId,
|
||||||
|
username: settings.username,
|
||||||
|
tabTitle: currentTabTitle,
|
||||||
|
protocolVersion: PROTOCOL_VERSION
|
||||||
|
});
|
||||||
|
}
|
||||||
|
while (eventQueue.length > 0) {
|
||||||
|
const queuedMsg = eventQueue.shift();
|
||||||
|
emit(queuedMsg.event, queuedMsg.data);
|
||||||
|
}
|
||||||
|
eventQueue = [];
|
||||||
|
chrome.storage.session.set({ eventQueue: [] });
|
||||||
|
} else if (msg.startsWith('42')) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(msg.substring(2));
|
||||||
|
handleServerEvent(payload[0], payload[1]);
|
||||||
|
} catch (e) {
|
||||||
|
addLog(`Failed to parse message: ${msg}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
isConnecting = false;
|
||||||
|
isNamespaceJoined = false;
|
||||||
|
|
||||||
|
// Clear Force Sync state
|
||||||
|
isForceSyncInitiator = false;
|
||||||
|
forceSyncAcks.clear();
|
||||||
|
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||||
|
chrome.storage.session.set({
|
||||||
|
isForceSyncInitiator: false,
|
||||||
|
forceSyncAcks: [],
|
||||||
|
forceSyncDeadline: null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentRoom) {
|
||||||
|
currentRoom.peers = [];
|
||||||
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||||
|
}
|
||||||
|
broadcastConnectionStatus('disconnected');
|
||||||
|
addLog(`Disconnected. Retrying in ${reconnectDelay / 1000}s...`, 'warn');
|
||||||
|
scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = (err) => {
|
||||||
|
broadcastConnectionStatus('disconnected');
|
||||||
|
addLog(`WebSocket Error: ${err.message || 'Handshake failed or server unreachable'}`, 'error');
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
isConnecting = false;
|
isConnecting = false;
|
||||||
addLog(`Invalid Server URL: ${finalUrl}`, 'error');
|
addLog(e.message, 'error');
|
||||||
broadcastConnectionStatus('disconnected');
|
broadcastConnectionStatus('disconnected');
|
||||||
scheduleReconnect();
|
scheduleReconnect();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
|
||||||
const msg = event.data;
|
|
||||||
|
|
||||||
// Engine.IO Ping/Pong
|
|
||||||
if (msg === '2') {
|
|
||||||
socket.send('3'); // Pong
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Socket.IO Handshake / Packet parsing
|
|
||||||
if (msg.startsWith('0')) {
|
|
||||||
addLog(`Socket.IO Handshake: ${msg}`, 'info');
|
|
||||||
} else if (msg.startsWith('40')) {
|
|
||||||
isNamespaceJoined = true;
|
|
||||||
broadcastConnectionStatus('connected');
|
|
||||||
addLog('Joined Namespace /', 'success');
|
|
||||||
// Auto-rejoin room if we have one in settings
|
|
||||||
if (settings.roomId) {
|
|
||||||
emit(EVENTS.JOIN_ROOM, {
|
|
||||||
roomId: settings.roomId,
|
|
||||||
password: settings.password,
|
|
||||||
peerId,
|
|
||||||
username: settings.username,
|
|
||||||
tabTitle: currentTabTitle,
|
|
||||||
protocolVersion: PROTOCOL_VERSION
|
|
||||||
});
|
|
||||||
}
|
|
||||||
while (eventQueue.length > 0) {
|
|
||||||
const queuedMsg = eventQueue.shift();
|
|
||||||
emit(queuedMsg.event, queuedMsg.data);
|
|
||||||
}
|
|
||||||
eventQueue = []; // Explicitly reset to avoid memory leaks
|
|
||||||
} else if (msg.startsWith('42')) {
|
|
||||||
// Event: 42["event", data]
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(msg.substring(2));
|
|
||||||
handleServerEvent(payload[0], payload[1]);
|
|
||||||
} catch (e) {
|
|
||||||
addLog(`Failed to parse message: ${msg}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onclose = () => {
|
|
||||||
isConnecting = false;
|
|
||||||
isNamespaceJoined = false;
|
|
||||||
if (currentRoom) {
|
|
||||||
currentRoom.peers = [];
|
|
||||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
|
||||||
}
|
|
||||||
broadcastConnectionStatus('disconnected');
|
|
||||||
addLog(`Disconnected. Retrying in ${reconnectDelay / 1000}s...`, 'warn');
|
|
||||||
scheduleReconnect();
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onerror = (err) => {
|
|
||||||
broadcastConnectionStatus('disconnected');
|
|
||||||
addLog('WebSocket Error', 'error');
|
|
||||||
socket.close();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function broadcastConnectionStatus(status) {
|
function broadcastConnectionStatus(status) {
|
||||||
chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {});
|
chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {});
|
||||||
updateBadgeStatus();
|
updateBadgeStatus();
|
||||||
@@ -301,6 +372,7 @@ function scheduleReconnect() {
|
|||||||
// Check 5 minute cap (300,000ms)
|
// Check 5 minute cap (300,000ms)
|
||||||
if (Date.now() - reconnectStartTime > 300000) {
|
if (Date.now() - reconnectStartTime > 300000) {
|
||||||
reconnectFailed = true;
|
reconnectFailed = true;
|
||||||
|
chrome.storage.session.set({ reconnectFailed: true });
|
||||||
addLog('Reconnection failed after 5 minutes. Please try again manually.', 'error');
|
addLog('Reconnection failed after 5 minutes. Please try again manually.', 'error');
|
||||||
broadcastConnectionStatus('reconnect_failed');
|
broadcastConnectionStatus('reconnect_failed');
|
||||||
return;
|
return;
|
||||||
@@ -323,6 +395,7 @@ function emit(event, data) {
|
|||||||
eventQueue.shift();
|
eventQueue.shift();
|
||||||
addLog('Event queue cap reached, dropping oldest event', 'warn');
|
addLog('Event queue cap reached, dropping oldest event', 'warn');
|
||||||
}
|
}
|
||||||
|
chrome.storage.session.set({ eventQueue });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,10 +428,12 @@ function handleServerEvent(event, data) {
|
|||||||
// Start background heartbeat
|
// Start background heartbeat
|
||||||
startHeartbeat();
|
startHeartbeat();
|
||||||
|
|
||||||
// Inform Website Bridge
|
// Inform Website Bridge & Popup
|
||||||
|
const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' };
|
||||||
|
chrome.runtime.sendMessage(joinStatusMsg).catch(() => {});
|
||||||
chrome.tabs.query({}, (tabs) => {
|
chrome.tabs.query({}, (tabs) => {
|
||||||
tabs.forEach(tab => {
|
tabs.forEach(tab => {
|
||||||
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Joined' }).catch(() => {});
|
chrome.tabs.sendMessage(tab.id, joinStatusMsg).catch(() => {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -366,6 +441,8 @@ function handleServerEvent(event, data) {
|
|||||||
chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {});
|
chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {});
|
||||||
break;
|
break;
|
||||||
case EVENTS.ERROR:
|
case EVENTS.ERROR:
|
||||||
|
isConnecting = false;
|
||||||
|
broadcastConnectionStatus('disconnected');
|
||||||
addLog(`Server Error: ${data.message}`, 'error');
|
addLog(`Server Error: ${data.message}`, 'error');
|
||||||
chrome.notifications.create(`error_${Date.now()}`, {
|
chrome.notifications.create(`error_${Date.now()}`, {
|
||||||
type: 'basic',
|
type: 'basic',
|
||||||
@@ -373,10 +450,12 @@ function handleServerEvent(event, data) {
|
|||||||
title: 'KoalaSync Error',
|
title: 'KoalaSync Error',
|
||||||
message: data.message
|
message: data.message
|
||||||
});
|
});
|
||||||
// Inform Website Bridge
|
// Inform Website Bridge & Popup
|
||||||
|
const errStatusMsg = { type: 'JOIN_STATUS', success: false, message: data.message };
|
||||||
|
chrome.runtime.sendMessage(errStatusMsg).catch(() => {});
|
||||||
chrome.tabs.query({}, (tabs) => {
|
chrome.tabs.query({}, (tabs) => {
|
||||||
tabs.forEach(tab => {
|
tabs.forEach(tab => {
|
||||||
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: false, message: data.message }).catch(() => {});
|
chrome.tabs.sendMessage(tab.id, errStatusMsg).catch(() => {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -386,7 +465,7 @@ function handleServerEvent(event, data) {
|
|||||||
case EVENTS.FORCE_SYNC_PREPARE:
|
case EVENTS.FORCE_SYNC_PREPARE:
|
||||||
if (data.senderId) {
|
if (data.senderId) {
|
||||||
addToHistory(event, data.senderId);
|
addToHistory(event, data.senderId);
|
||||||
showNotification(event, data.senderId);
|
showNotification(data.senderId, event);
|
||||||
updateLastAction(event, data.senderId);
|
updateLastAction(event, data.senderId);
|
||||||
}
|
}
|
||||||
routeToContent(event, data);
|
routeToContent(event, data);
|
||||||
@@ -394,7 +473,19 @@ function handleServerEvent(event, data) {
|
|||||||
case EVENTS.FORCE_SYNC_ACK:
|
case EVENTS.FORCE_SYNC_ACK:
|
||||||
if (isForceSyncInitiator) {
|
if (isForceSyncInitiator) {
|
||||||
forceSyncAcks.add(data.senderId);
|
forceSyncAcks.add(data.senderId);
|
||||||
|
chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) });
|
||||||
addLog(`Received ACK from ${data.senderId} (${forceSyncAcks.size})`, 'info');
|
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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if all peers responded
|
// Check if all peers responded
|
||||||
const peerCount = currentRoom ? currentRoom.peers.length : 1;
|
const peerCount = currentRoom ? currentRoom.peers.length : 1;
|
||||||
if (forceSyncAcks.size >= peerCount) {
|
if (forceSyncAcks.size >= peerCount) {
|
||||||
@@ -413,6 +504,7 @@ function handleServerEvent(event, data) {
|
|||||||
if (lastActionState && lastActionState.action && data.senderId) {
|
if (lastActionState && lastActionState.action && data.senderId) {
|
||||||
// Correlation Check: Only accept ACK if it matches our current action's timestamp
|
// Correlation Check: Only accept ACK if it matches our current action's timestamp
|
||||||
if (data.actionTimestamp === lastActionState.timestamp) {
|
if (data.actionTimestamp === lastActionState.timestamp) {
|
||||||
|
if (!Array.isArray(lastActionState.acks)) lastActionState.acks = [];
|
||||||
if (!lastActionState.acks.includes(data.senderId)) {
|
if (!lastActionState.acks.includes(data.senderId)) {
|
||||||
lastActionState.acks.push(data.senderId);
|
lastActionState.acks.push(data.senderId);
|
||||||
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
||||||
@@ -423,6 +515,7 @@ function handleServerEvent(event, data) {
|
|||||||
break;
|
break;
|
||||||
case EVENTS.PEER_STATUS:
|
case EVENTS.PEER_STATUS:
|
||||||
if (currentRoom) {
|
if (currentRoom) {
|
||||||
|
if (!Array.isArray(currentRoom.peers)) currentRoom.peers = [];
|
||||||
if (data.status === 'joined') {
|
if (data.status === 'joined') {
|
||||||
if (!currentRoom.peers.find(p => (p.peerId || p) === data.peerId)) {
|
if (!currentRoom.peers.find(p => (p.peerId || p) === data.peerId)) {
|
||||||
currentRoom.peers.push({ peerId: data.peerId, username: data.username, tabTitle: data.tabTitle });
|
currentRoom.peers.push({ peerId: data.peerId, username: data.username, tabTitle: data.tabTitle });
|
||||||
@@ -460,6 +553,12 @@ function handleServerEvent(event, data) {
|
|||||||
function executeForceSync() {
|
function executeForceSync() {
|
||||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||||
isForceSyncInitiator = false;
|
isForceSyncInitiator = false;
|
||||||
|
forceSyncAcks.clear();
|
||||||
|
chrome.storage.session.set({
|
||||||
|
isForceSyncInitiator: false,
|
||||||
|
forceSyncAcks: [],
|
||||||
|
forceSyncDeadline: null
|
||||||
|
});
|
||||||
emit(EVENTS.FORCE_SYNC_EXECUTE, {});
|
emit(EVENTS.FORCE_SYNC_EXECUTE, {});
|
||||||
routeToContent(EVENTS.FORCE_SYNC_EXECUTE, {});
|
routeToContent(EVENTS.FORCE_SYNC_EXECUTE, {});
|
||||||
addLog('Force Sync Executed', 'success');
|
addLog('Force Sync Executed', 'success');
|
||||||
@@ -515,40 +614,56 @@ async function routeToContent(action, payload) {
|
|||||||
|
|
||||||
// --- Keep-Alive Mechanism ---
|
// --- Keep-Alive Mechanism ---
|
||||||
chrome.alarms.create('keepAlive', { periodInMinutes: 1 });
|
chrome.alarms.create('keepAlive', { periodInMinutes: 1 });
|
||||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||||
|
await ensureState();
|
||||||
if (alarm.name === 'keepAlive') {
|
if (alarm.name === 'keepAlive') {
|
||||||
chrome.storage.session.get('keepAlive', () => {});
|
chrome.storage.session.get('keepAlive', () => {});
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
connect();
|
connect();
|
||||||
|
} else if (currentRoom) {
|
||||||
|
// Heartbeat Logic migrated from setInterval
|
||||||
|
emit(EVENTS.PEER_STATUS, { peerId, status: 'heartbeat' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(async () => {
|
||||||
|
await ensureState();
|
||||||
// Calling a chrome API keeps the SW alive in MV3 (Chrome 110+)
|
// Calling a chrome API keeps the SW alive in MV3 (Chrome 110+)
|
||||||
chrome.storage.session.get('keepAlive', () => {});
|
chrome.storage.session.get('keepAlive', () => {});
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
connect();
|
connect();
|
||||||
|
} else if (currentRoom) {
|
||||||
|
// Redundant heartbeat for active SW state
|
||||||
|
emit(EVENTS.PEER_STATUS, { peerId, status: 'heartbeat' });
|
||||||
}
|
}
|
||||||
}, 20000); // every 20s
|
}, 30000); // every 30s
|
||||||
|
|
||||||
// --- Extension Message Listeners ---
|
// --- Extension Message Listeners ---
|
||||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
handleAsyncMessage(message, sender, sendResponse);
|
||||||
|
return true; // Keep channel open for async responses
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleAsyncMessage(message, sender, sendResponse) {
|
||||||
|
await ensureState();
|
||||||
|
|
||||||
if (message.type === 'CONNECT') {
|
if (message.type === 'CONNECT') {
|
||||||
reconnectFailed = false;
|
reconnectFailed = false;
|
||||||
reconnectStartTime = null;
|
reconnectStartTime = null;
|
||||||
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
||||||
// Already connected, but maybe room changed or we need to refresh room state
|
// Already connected, but maybe room changed or we need to refresh room state
|
||||||
getSettings().then(settings => {
|
const settings = await getSettings();
|
||||||
if (settings.roomId) {
|
if (settings.roomId) {
|
||||||
emit(EVENTS.JOIN_ROOM, {
|
emit(EVENTS.JOIN_ROOM, {
|
||||||
roomId: settings.roomId,
|
roomId: settings.roomId,
|
||||||
password: settings.password,
|
password: settings.password,
|
||||||
peerId,
|
peerId,
|
||||||
protocolVersion: PROTOCOL_VERSION
|
username: settings.username,
|
||||||
});
|
tabTitle: currentTabTitle,
|
||||||
}
|
protocolVersion: PROTOCOL_VERSION
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
connect();
|
connect();
|
||||||
}
|
}
|
||||||
@@ -565,25 +680,35 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
status,
|
status,
|
||||||
peerId,
|
peerId,
|
||||||
peers: currentRoom ? currentRoom.peers : [],
|
peers: currentRoom ? currentRoom.peers : [],
|
||||||
lastActionState
|
lastActionState,
|
||||||
|
targetTabId: currentTabId
|
||||||
});
|
});
|
||||||
// Global return true at the end handles this
|
|
||||||
} else if (message.type === 'LEAVE_ROOM') {
|
} else if (message.type === 'LEAVE_ROOM') {
|
||||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||||
currentRoom = null;
|
currentRoom = null;
|
||||||
currentTabId = null;
|
currentTabId = null;
|
||||||
stopHeartbeat();
|
stopHeartbeat();
|
||||||
updateBadgeStatus();
|
updateBadgeStatus();
|
||||||
if (storageInitialized) chrome.storage.session.set({ currentRoom: null });
|
|
||||||
|
isForceSyncInitiator = false;
|
||||||
|
forceSyncAcks.clear();
|
||||||
|
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||||
|
|
||||||
|
chrome.storage.session.set({
|
||||||
|
currentRoom: null,
|
||||||
|
isForceSyncInitiator: false,
|
||||||
|
forceSyncAcks: [],
|
||||||
|
forceSyncDeadline: null
|
||||||
|
});
|
||||||
addLog('Left Room', 'info');
|
addLog('Left Room', 'info');
|
||||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||||
} else if (message.type === 'CLEAR_LOGS') {
|
} else if (message.type === 'CLEAR_LOGS') {
|
||||||
logs = [];
|
logs = [];
|
||||||
sendResponse({ status: 'ok' });
|
sendResponse({ status: 'ok' });
|
||||||
} else if (message.type === 'GET_LOGS') {
|
} else if (message.type === 'GET_LOGS') {
|
||||||
sendResponse(storageInitialized ? logs : pendingLogs);
|
sendResponse(logs);
|
||||||
} else if (message.type === 'GET_HISTORY') {
|
} else if (message.type === 'GET_HISTORY') {
|
||||||
sendResponse(storageInitialized ? history : pendingHistory);
|
sendResponse(history);
|
||||||
} else if (message.type === 'GET_ROOM_LIST') {
|
} else if (message.type === 'GET_ROOM_LIST') {
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
socket.send(`42${JSON.stringify([EVENTS.GET_ROOMS])}`);
|
socket.send(`42${JSON.stringify([EVENTS.GET_ROOMS])}`);
|
||||||
@@ -595,21 +720,20 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
password,
|
password,
|
||||||
useCustomServer: !!useCustomServer,
|
useCustomServer: !!useCustomServer,
|
||||||
serverUrl: serverUrl || ''
|
serverUrl: serverUrl || ''
|
||||||
}, () => {
|
}, async () => {
|
||||||
broadcastConnectionStatus('connecting');
|
broadcastConnectionStatus('connecting');
|
||||||
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
||||||
// FORCE TRANSITION: Emit Join Room directly if already connected
|
// FORCE TRANSITION: Emit Join Room directly if already connected
|
||||||
getSettings().then(settings => {
|
const settings = await getSettings();
|
||||||
emit(EVENTS.JOIN_ROOM, {
|
emit(EVENTS.JOIN_ROOM, {
|
||||||
roomId,
|
roomId,
|
||||||
password,
|
password,
|
||||||
peerId,
|
peerId,
|
||||||
username: settings.username, // Use local settings, not bridge
|
username: settings.username,
|
||||||
tabTitle: currentTabTitle,
|
tabTitle: currentTabTitle,
|
||||||
protocolVersion: PROTOCOL_VERSION
|
protocolVersion: PROTOCOL_VERSION
|
||||||
});
|
|
||||||
addLog(`Joining room via link: ${roomId}`, 'info');
|
|
||||||
});
|
});
|
||||||
|
addLog(`Joining room via link: ${roomId}`, 'info');
|
||||||
} else {
|
} else {
|
||||||
connect();
|
connect();
|
||||||
}
|
}
|
||||||
@@ -658,6 +782,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
|
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||||
isForceSyncInitiator = true;
|
isForceSyncInitiator = true;
|
||||||
forceSyncAcks.clear();
|
forceSyncAcks.clear();
|
||||||
|
const deadline = Date.now() + 5000;
|
||||||
|
chrome.storage.session.set({
|
||||||
|
isForceSyncInitiator: true,
|
||||||
|
forceSyncAcks: [],
|
||||||
|
forceSyncDeadline: deadline
|
||||||
|
});
|
||||||
addLog('Initiating Force Sync...', 'info');
|
addLog('Initiating Force Sync...', 'info');
|
||||||
|
|
||||||
// Route to our own content script so we pause and seek
|
// Route to our own content script so we pause and seek
|
||||||
@@ -676,6 +806,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
} else if (message.type === 'FORCE_SYNC_ACK') {
|
} else if (message.type === 'FORCE_SYNC_ACK') {
|
||||||
if (isForceSyncInitiator) {
|
if (isForceSyncInitiator) {
|
||||||
forceSyncAcks.add(peerId);
|
forceSyncAcks.add(peerId);
|
||||||
|
chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) });
|
||||||
addLog(`Local ACK received (${forceSyncAcks.size})`, 'info');
|
addLog(`Local ACK received (${forceSyncAcks.size})`, 'info');
|
||||||
const peerCount = currentRoom ? currentRoom.peers.length : 1;
|
const peerCount = currentRoom ? currentRoom.peers.length : 1;
|
||||||
if (forceSyncAcks.size >= peerCount) {
|
if (forceSyncAcks.size >= peerCount) {
|
||||||
@@ -702,6 +833,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
getSettings().then(settings => {
|
getSettings().then(settings => {
|
||||||
emit(EVENTS.PEER_STATUS, { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle });
|
emit(EVENTS.PEER_STATUS, { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle });
|
||||||
});
|
});
|
||||||
|
} else if (message.type === 'LOG') {
|
||||||
|
addLog(`[Content] ${message.message}`, message.level || 'info');
|
||||||
}
|
}
|
||||||
return true; // Keep channel open for async responses
|
return true; // Keep channel open for async responses
|
||||||
});
|
});
|
||||||
|
|||||||
+37
-14
@@ -4,7 +4,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
if (window.koalaSyncInjected) return;
|
// Injection Guard: Check if already injected AND context is valid
|
||||||
|
try {
|
||||||
|
if (window.koalaSyncInjected && chrome.runtime.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Context invalidated, proceed with re-injection
|
||||||
|
}
|
||||||
window.koalaSyncInjected = true;
|
window.koalaSyncInjected = true;
|
||||||
|
|
||||||
// Local Protocol Constants (Mirroring shared/constants.js)
|
// Local Protocol Constants (Mirroring shared/constants.js)
|
||||||
@@ -25,12 +32,18 @@
|
|||||||
lastTargetState = state;
|
lastTargetState = state;
|
||||||
if (targetStateTimeout) clearTimeout(targetStateTimeout);
|
if (targetStateTimeout) clearTimeout(targetStateTimeout);
|
||||||
if (state !== null) {
|
if (state !== null) {
|
||||||
|
// Seek events might take longer than play/pause, using 2s for safety
|
||||||
|
const timeout = state === 'seek' ? 2000 : 1500;
|
||||||
targetStateTimeout = setTimeout(() => {
|
targetStateTimeout = setTimeout(() => {
|
||||||
lastTargetState = null;
|
lastTargetState = null;
|
||||||
}, 1500);
|
}, timeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reportLog(message, level = 'info') {
|
||||||
|
chrome.runtime.sendMessage({ type: 'LOG', message, level }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helper: find the best video element on the page ---
|
// --- Helper: find the best video element on the page ---
|
||||||
function findVideo() {
|
function findVideo() {
|
||||||
const videos = document.querySelectorAll('video');
|
const videos = document.querySelectorAll('video');
|
||||||
@@ -55,7 +68,10 @@
|
|||||||
setTargetState(action === EVENTS.PLAY ? 'playing' : 'paused');
|
setTargetState(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||||
ytButton.click();
|
ytButton.click();
|
||||||
}
|
}
|
||||||
if (action === EVENTS.SEEK) video.currentTime = data.targetTime;
|
if (action === EVENTS.SEEK) {
|
||||||
|
setTargetState('seek');
|
||||||
|
video.currentTime = data.targetTime;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,7 +84,10 @@
|
|||||||
setTargetState(action === EVENTS.PLAY ? 'playing' : 'paused');
|
setTargetState(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||||
twitchButton.click();
|
twitchButton.click();
|
||||||
}
|
}
|
||||||
if (action === EVENTS.SEEK) video.currentTime = data.targetTime;
|
if (action === EVENTS.SEEK) {
|
||||||
|
setTargetState('seek');
|
||||||
|
video.currentTime = data.targetTime;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,29 +96,34 @@
|
|||||||
if (action === EVENTS.PLAY) {
|
if (action === EVENTS.PLAY) {
|
||||||
setTargetState('playing');
|
setTargetState('playing');
|
||||||
video.play().catch((e) => {
|
video.play().catch((e) => {
|
||||||
console.warn('KoalaSync playback prevented:', e);
|
reportLog(`Playback prevented: ${e.message}`, 'warn');
|
||||||
setTargetState(null);
|
setTargetState(null);
|
||||||
});
|
});
|
||||||
} else if (action === EVENTS.PAUSE) {
|
} else if (action === EVENTS.PAUSE) {
|
||||||
setTargetState('paused');
|
setTargetState('paused');
|
||||||
video.pause();
|
video.pause();
|
||||||
} else if (action === EVENTS.SEEK) {
|
} else if (action === EVENTS.SEEK) {
|
||||||
|
setTargetState('seek');
|
||||||
video.currentTime = data.targetTime;
|
video.currentTime = data.targetTime;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('KoalaSync Media Action Error:', e);
|
reportLog(`Media Action Error: ${e.message}`, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helper: Wait until video is ready for playback (buffered & seeked) ---
|
// --- Helper: Wait until video is ready for playback (buffered & seeked) ---
|
||||||
function pollSeekReady(targetTime, timeoutMs = 8000) {
|
function pollSeekReady(targetTime, timeoutMs = 8000) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const video = findVideo();
|
|
||||||
if (!video) { resolve(false); return; }
|
|
||||||
|
|
||||||
const interval = 150;
|
const interval = 150;
|
||||||
let elapsed = 0;
|
let elapsed = 0;
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
|
const video = findVideo(); // Re-query DOM on every iteration
|
||||||
|
if (!video) {
|
||||||
|
clearInterval(timer);
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
elapsed += interval;
|
elapsed += interval;
|
||||||
const timeDiff = Math.abs(video.currentTime - targetTime);
|
const timeDiff = Math.abs(video.currentTime - targetTime);
|
||||||
const ready = video.readyState >= 3 && timeDiff < 1.0;
|
const ready = video.readyState >= 3 && timeDiff < 1.0;
|
||||||
@@ -175,15 +199,14 @@
|
|||||||
const video = findVideo();
|
const video = findVideo();
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : null);
|
const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null));
|
||||||
|
|
||||||
if (eventState && lastTargetState === eventState) {
|
if (eventState && lastTargetState === eventState) {
|
||||||
setTargetState(null); // Consume the match
|
setTargetState(null); // Consume the match
|
||||||
return; // Ignore event caused by our programmatic action
|
return; // Ignore event caused by our programmatic action
|
||||||
}
|
}
|
||||||
if (action !== 'seek') {
|
|
||||||
setTargetState(null); // Reset on mismatch
|
setTargetState(null); // Reset on mismatch or unhandled event
|
||||||
}
|
|
||||||
|
|
||||||
chrome.runtime.sendMessage({
|
chrome.runtime.sendMessage({
|
||||||
type: 'CONTENT_EVENT',
|
type: 'CONTENT_EVENT',
|
||||||
@@ -258,7 +281,7 @@
|
|||||||
if (err.message.includes('Extension context invalidated')) {
|
if (err.message.includes('Extension context invalidated')) {
|
||||||
heartbeatErrorCount++;
|
heartbeatErrorCount++;
|
||||||
if (heartbeatErrorCount === 1) {
|
if (heartbeatErrorCount === 1) {
|
||||||
console.warn('KoalaSync: Extension reloaded. Please refresh the page if sync stops working.');
|
reportLog('Extension reloaded. Please refresh the page if sync stops working.', 'warn');
|
||||||
}
|
}
|
||||||
clearInterval(heartbeatInterval);
|
clearInterval(heartbeatInterval);
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
|
|||||||
@@ -278,11 +278,12 @@
|
|||||||
|
|
||||||
<label>Remote Control</label>
|
<label>Remote Control</label>
|
||||||
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
|
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
|
||||||
<button id="playBtn" class="primary" style="flex:1;">Play</button>
|
<button id="playBtn" class="primary" style="flex:1; background: var(--success);">▶ Play</button>
|
||||||
<button id="pauseBtn" class="primary" style="flex:1;">Pause</button>
|
<button id="pauseBtn" class="primary" style="flex:1; background: var(--error);">⏸ Pause</button>
|
||||||
<button id="forceSyncBtn" class="secondary" style="flex:1; border-color:var(--accent); color:var(--accent);">Force Sync</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button id="forceSyncBtn" class="primary" style="background: linear-gradient(135deg, #6366f1, #a855f7); width: 100%; margin-bottom: 15px;">⚡ Force Sync Everyone</button>
|
||||||
|
|
||||||
<!-- NEW: Last Action Status Card -->
|
<!-- NEW: Last Action Status Card -->
|
||||||
<label>Last Activity Status</label>
|
<label>Last Activity Status</label>
|
||||||
<div id="lastActionCard" class="info-card" style="margin-bottom: 15px; min-height: 70px;">
|
<div id="lastActionCard" class="info-card" style="margin-bottom: 15px; min-height: 70px;">
|
||||||
|
|||||||
+242
-95
@@ -1,13 +1,6 @@
|
|||||||
import { EVENTS, OFFICIAL_LANDING_PAGE_URL } from './shared/constants.js';
|
import { EVENTS, OFFICIAL_LANDING_PAGE_URL } from './shared/constants.js';
|
||||||
import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
|
import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
const elements = {
|
const elements = {
|
||||||
tabs: document.querySelectorAll('.tabs .tab-btn'),
|
tabs: document.querySelectorAll('.tabs .tab-btn'),
|
||||||
@@ -141,15 +134,29 @@ function updateLastActionUI(state, peers) {
|
|||||||
|
|
||||||
const timeStr = new Date(state.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
const timeStr = new Date(state.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
|
||||||
let html = `
|
// Clear previous content
|
||||||
<div style="display:flex; justify-content:space-between; margin-bottom:10px; align-items:baseline;">
|
elements.lastActionCard.innerHTML = '';
|
||||||
<span style="font-weight:700; color:var(--accent); font-size:13px;">${actionNames[state.action] || state.action.toUpperCase()}</span>
|
|
||||||
<span style="font-size:10px; color:var(--text-muted);">${senderName} @ ${timeStr}</span>
|
// Create Header
|
||||||
</div>
|
const header = document.createElement('div');
|
||||||
<div style="display:grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 6px;">
|
header.style.cssText = 'display:flex; justify-content:space-between; margin-bottom:10px; align-items:baseline;';
|
||||||
`;
|
|
||||||
|
const actionSpan = document.createElement('span');
|
||||||
|
actionSpan.style.cssText = 'font-weight:700; color:var(--accent); font-size:13px;';
|
||||||
|
actionSpan.textContent = actionNames[state.action] || state.action.toUpperCase();
|
||||||
|
|
||||||
|
const infoSpan = document.createElement('span');
|
||||||
|
infoSpan.style.cssText = 'font-size:10px; color:var(--text-muted);';
|
||||||
|
infoSpan.textContent = `${senderName} @ ${timeStr}`;
|
||||||
|
|
||||||
|
header.appendChild(actionSpan);
|
||||||
|
header.appendChild(infoSpan);
|
||||||
|
elements.lastActionCard.appendChild(header);
|
||||||
|
|
||||||
|
// Create Grid
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.style.cssText = 'display:grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 6px;';
|
||||||
|
|
||||||
// Filter out "You" if we are the sender, but show status of other peers
|
|
||||||
peers.forEach(peer => {
|
peers.forEach(peer => {
|
||||||
const pId = typeof peer === 'object' ? peer.peerId : peer;
|
const pId = typeof peer === 'object' ? peer.peerId : peer;
|
||||||
const pName = (typeof peer === 'object' && peer.username) ? peer.username : pId.substring(0, 4);
|
const pName = (typeof peer === 'object' && peer.username) ? peer.username : pId.substring(0, 4);
|
||||||
@@ -157,18 +164,24 @@ function updateLastActionUI(state, peers) {
|
|||||||
const color = isAcked ? 'var(--success)' : '#475569';
|
const color = isAcked ? 'var(--success)' : '#475569';
|
||||||
const icon = isAcked ? '✓' : '...';
|
const icon = isAcked ? '✓' : '...';
|
||||||
|
|
||||||
html += `
|
const peerItem = document.createElement('div');
|
||||||
<div title="${pName}" style="display:flex; flex-direction:column; align-items:center; opacity: ${isAcked ? 1 : 0.6};">
|
peerItem.title = pName;
|
||||||
<div style="width:20px; height:20px; border-radius:50%; background:${color}; color:white; display:flex; align-items:center; justify-content:center; font-size:10px; font-weight:bold; margin-bottom:2px;">
|
peerItem.style.cssText = `display:flex; flex-direction:column; align-items:center; opacity: ${isAcked ? 1 : 0.6};`;
|
||||||
${icon}
|
|
||||||
</div>
|
const dot = document.createElement('div');
|
||||||
<span style="font-size:8px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40px;">${pName}</span>
|
dot.style.cssText = `width:20px; height:20px; border-radius:50%; background:${color}; color:white; display:flex; align-items:center; justify-content:center; font-size:10px; font-weight:bold; margin-bottom:2px;`;
|
||||||
</div>
|
dot.textContent = icon;
|
||||||
`;
|
|
||||||
|
const nameSpan = document.createElement('span');
|
||||||
|
nameSpan.style.cssText = 'font-size:8px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40px;';
|
||||||
|
nameSpan.textContent = pName;
|
||||||
|
|
||||||
|
peerItem.appendChild(dot);
|
||||||
|
peerItem.appendChild(nameSpan);
|
||||||
|
grid.appendChild(peerItem);
|
||||||
});
|
});
|
||||||
|
|
||||||
html += `</div>`;
|
elements.lastActionCard.appendChild(grid);
|
||||||
elements.lastActionCard.innerHTML = html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePeerList(peers) {
|
function updatePeerList(peers) {
|
||||||
@@ -179,29 +192,67 @@ function updatePeerList(peers) {
|
|||||||
if (currentPeersJson === lastPeersJson) return;
|
if (currentPeersJson === lastPeersJson) return;
|
||||||
lastPeersJson = currentPeersJson;
|
lastPeersJson = currentPeersJson;
|
||||||
|
|
||||||
const html = peers.map(p => {
|
const renderPeers = (container) => {
|
||||||
const id = escapeHtml(typeof p === 'object' ? p.peerId : p);
|
container.innerHTML = '';
|
||||||
const username = (typeof p === 'object' && p.username) ? escapeHtml(p.username) : '';
|
if (peers.length === 0) {
|
||||||
const titleText = (typeof p === 'object' && p.tabTitle) ? escapeHtml(p.tabTitle) : '';
|
const empty = document.createElement('div');
|
||||||
|
empty.style.cssText = 'text-align:center; color: var(--text-muted); font-size: 12px;';
|
||||||
|
empty.textContent = 'No peers connected';
|
||||||
|
container.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const nameLabel = username ? `<span style="font-weight:600; color:white;">${username}</span> <span style="font-size:10px; opacity:0.6; font-style:italic;">(${id})</span>` : `<span style="font-weight:600;">👤 ${id}</span>`;
|
peers.forEach(p => {
|
||||||
const title = titleText ? `<div style="font-size:10px; color:var(--text-muted);">${titleText}</div>` : '';
|
const pId = typeof p === 'object' ? p.peerId : p;
|
||||||
|
const pUsername = (typeof p === 'object' && p.username) ? p.username : '';
|
||||||
|
const pTabTitle = (typeof p === 'object' && p.tabTitle) ? p.tabTitle : '';
|
||||||
|
|
||||||
return `
|
const peerItem = document.createElement('div');
|
||||||
<div class="peer-item" style="display:block; padding: 6px 0;">
|
peerItem.className = 'peer-item';
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
peerItem.style.cssText = 'display:block; padding: 6px 0;';
|
||||||
<span>${nameLabel}</span>
|
|
||||||
${id === escapeHtml(localPeerId) ? '<span style="font-size:10px; color:var(--accent)">YOU</span>' : ''}
|
|
||||||
</div>
|
|
||||||
${title}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
const emptyHtml = '<div style="text-align:center; color: var(--text-muted); font-size: 12px;">No peers connected</div>';
|
const header = document.createElement('div');
|
||||||
|
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center;';
|
||||||
|
|
||||||
if (elements.peerList) elements.peerList.innerHTML = html || emptyHtml;
|
const nameSpan = document.createElement('span');
|
||||||
if (elements.peerListSync) elements.peerListSync.innerHTML = html || emptyHtml;
|
if (pUsername) {
|
||||||
|
const u = document.createElement('span');
|
||||||
|
u.style.cssText = 'font-weight:600; color:white;';
|
||||||
|
u.textContent = pUsername;
|
||||||
|
const i = document.createElement('span');
|
||||||
|
i.style.cssText = 'font-size:10px; opacity:0.6; font-style:italic;';
|
||||||
|
i.textContent = ` (${pId})`;
|
||||||
|
nameSpan.appendChild(u);
|
||||||
|
nameSpan.appendChild(i);
|
||||||
|
} else {
|
||||||
|
nameSpan.style.fontWeight = '600';
|
||||||
|
nameSpan.textContent = `👤 ${pId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
header.appendChild(nameSpan);
|
||||||
|
|
||||||
|
if (pId === localPeerId) {
|
||||||
|
const you = document.createElement('span');
|
||||||
|
you.style.cssText = 'font-size:10px; color:var(--accent)';
|
||||||
|
you.textContent = 'YOU';
|
||||||
|
header.appendChild(you);
|
||||||
|
}
|
||||||
|
|
||||||
|
peerItem.appendChild(header);
|
||||||
|
|
||||||
|
if (pTabTitle) {
|
||||||
|
const titleDiv = document.createElement('div');
|
||||||
|
titleDiv.style.cssText = 'font-size:10px; color:var(--text-muted);';
|
||||||
|
titleDiv.textContent = pTabTitle;
|
||||||
|
peerItem.appendChild(titleDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(peerItem);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (elements.peerList) renderPeers(elements.peerList);
|
||||||
|
if (elements.peerListSync) renderPeers(elements.peerListSync);
|
||||||
|
|
||||||
// Re-populate tabs to update Star Matching when peers change
|
// Re-populate tabs to update Star Matching when peers change
|
||||||
populateTabs(peers);
|
populateTabs(peers);
|
||||||
@@ -279,10 +330,16 @@ function applyConnectionStatus(status) {
|
|||||||
|
|
||||||
elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : (failed ? 'status-offline' : (connecting ? 'status-online' : 'status-offline')));
|
elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : (failed ? 'status-offline' : (connecting ? 'status-online' : 'status-offline')));
|
||||||
|
|
||||||
// Custom colors for states
|
if (connecting) {
|
||||||
if (connecting) elements.connDot.style.background = '#fbbf24';
|
elements.connDot.style.background = '#fbbf24';
|
||||||
else if (failed) elements.connDot.style.background = '#ef4444';
|
elements.connDot.style.boxShadow = '0 0 8px #fbbf24';
|
||||||
else elements.connDot.style.background = '';
|
} else if (failed) {
|
||||||
|
elements.connDot.style.background = '#ef4444';
|
||||||
|
elements.connDot.style.boxShadow = 'none';
|
||||||
|
} else {
|
||||||
|
elements.connDot.style.background = '';
|
||||||
|
elements.connDot.style.boxShadow = '';
|
||||||
|
}
|
||||||
|
|
||||||
elements.connText.textContent = connected ? 'Connected' : (connecting ? 'Connecting...' : (failed ? 'Failed' : 'Disconnected'));
|
elements.connText.textContent = connected ? 'Connected' : (connecting ? 'Connecting...' : (failed ? 'Failed' : 'Disconnected'));
|
||||||
elements.retryBtn.style.display = failed ? 'block' : 'none';
|
elements.retryBtn.style.display = failed ? 'block' : 'none';
|
||||||
@@ -291,7 +348,7 @@ function applyConnectionStatus(status) {
|
|||||||
if (connecting) {
|
if (connecting) {
|
||||||
elements.joinBtn.disabled = true;
|
elements.joinBtn.disabled = true;
|
||||||
elements.joinBtn.textContent = 'Connecting...';
|
elements.joinBtn.textContent = 'Connecting...';
|
||||||
} else if (!connected) {
|
} else {
|
||||||
elements.joinBtn.disabled = false;
|
elements.joinBtn.disabled = false;
|
||||||
elements.joinBtn.textContent = 'Join Room';
|
elements.joinBtn.textContent = 'Join Room';
|
||||||
}
|
}
|
||||||
@@ -299,19 +356,47 @@ function applyConnectionStatus(status) {
|
|||||||
|
|
||||||
function updateHistory(history) {
|
function updateHistory(history) {
|
||||||
if (!history || !elements.historyList) return;
|
if (!history || !elements.historyList) return;
|
||||||
|
elements.historyList.innerHTML = '';
|
||||||
|
|
||||||
if (history.length === 0) {
|
if (history.length === 0) {
|
||||||
elements.historyList.innerHTML = '<div style="text-align:center; padding: 10px;">No activity yet</div>';
|
const empty = document.createElement('div');
|
||||||
|
empty.style.cssText = 'text-align:center; padding: 10px;';
|
||||||
|
empty.textContent = 'No activity yet';
|
||||||
|
elements.historyList.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
elements.historyList.innerHTML = history.map(item => {
|
|
||||||
|
history.forEach(item => {
|
||||||
const time = new Date(item.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
const time = new Date(item.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
const actionLabel = escapeHtml(item.action.toUpperCase().replace('FORCE_SYNC_', ''));
|
const actionLabel = item.action.toUpperCase().replace('FORCE_SYNC_', '');
|
||||||
const senderIdEscaped = escapeHtml(item.senderId);
|
|
||||||
const sender = item.senderId === 'You' ? '<span style="color:var(--accent)">You</span>' : senderIdEscaped;
|
const entry = document.createElement('div');
|
||||||
return `<div style="margin-bottom: 4px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 2px;">
|
entry.style.cssText = 'margin-bottom: 4px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 2px;';
|
||||||
<span style="color:#64748b">[${time}]</span> <b>${actionLabel}</b> by ${sender}
|
|
||||||
</div>`;
|
const timeSpan = document.createElement('span');
|
||||||
}).join('');
|
timeSpan.style.color = '#64748b';
|
||||||
|
timeSpan.textContent = `[${time}] `;
|
||||||
|
|
||||||
|
const actionBold = document.createElement('b');
|
||||||
|
actionBold.textContent = actionLabel;
|
||||||
|
|
||||||
|
const textNode1 = document.createTextNode(' by ');
|
||||||
|
|
||||||
|
const senderSpan = document.createElement('span');
|
||||||
|
if (item.senderId === 'You') {
|
||||||
|
senderSpan.style.color = 'var(--accent)';
|
||||||
|
senderSpan.textContent = 'You';
|
||||||
|
} else {
|
||||||
|
senderSpan.textContent = item.senderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.appendChild(timeSpan);
|
||||||
|
entry.appendChild(actionBold);
|
||||||
|
entry.appendChild(textNode1);
|
||||||
|
entry.appendChild(senderSpan);
|
||||||
|
|
||||||
|
elements.historyList.appendChild(entry);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshHistory() {
|
function refreshHistory() {
|
||||||
@@ -321,26 +406,53 @@ function refreshHistory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateRoomList(rooms) {
|
function updateRoomList(rooms) {
|
||||||
|
if (!elements.publicRooms) return;
|
||||||
|
elements.publicRooms.innerHTML = '';
|
||||||
|
|
||||||
if (!rooms || rooms.length === 0) {
|
if (!rooms || rooms.length === 0) {
|
||||||
elements.publicRooms.innerHTML = '<div style="text-align:center; padding: 10px; color:var(--text-muted);">No active rooms</div>';
|
const empty = document.createElement('div');
|
||||||
|
empty.style.cssText = 'text-align:center; padding: 10px; color:var(--text-muted);';
|
||||||
|
empty.textContent = 'No active rooms';
|
||||||
|
elements.publicRooms.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
elements.publicRooms.innerHTML = rooms.map(r => `
|
|
||||||
<div class="room-item" style="display:flex; justify-content:space-between; align-items:center; padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor:pointer;" data-id="${escapeHtml(r.id)}">
|
|
||||||
<div style="display:flex; align-items:center; gap: 6px;">
|
|
||||||
<span style="font-weight:600;">${escapeHtml(r.id)}</span>
|
|
||||||
${r.hasPassword ? '<span title="Password Protected">🔒</span>' : ''}
|
|
||||||
</div>
|
|
||||||
<span style="font-size:11px; color:var(--accent)">${parseInt(r.peerCount)} peers</span>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
elements.publicRooms.querySelectorAll('.room-item').forEach(item => {
|
rooms.forEach(r => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'room-item';
|
||||||
|
item.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor:pointer;';
|
||||||
|
item.dataset.id = r.id;
|
||||||
|
|
||||||
|
const leftSide = document.createElement('div');
|
||||||
|
leftSide.style.cssText = 'display:flex; align-items:center; gap: 6px;';
|
||||||
|
|
||||||
|
const idSpan = document.createElement('span');
|
||||||
|
idSpan.style.fontWeight = '600';
|
||||||
|
idSpan.textContent = r.id;
|
||||||
|
|
||||||
|
leftSide.appendChild(idSpan);
|
||||||
|
|
||||||
|
if (r.hasPassword) {
|
||||||
|
const lock = document.createElement('span');
|
||||||
|
lock.title = 'Password Protected';
|
||||||
|
lock.textContent = '🔒';
|
||||||
|
leftSide.appendChild(lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
const peerCount = document.createElement('span');
|
||||||
|
peerCount.style.cssText = 'font-size:11px; color:var(--accent)';
|
||||||
|
peerCount.textContent = `${parseInt(r.peerCount)} peers`;
|
||||||
|
|
||||||
|
item.appendChild(leftSide);
|
||||||
|
item.appendChild(peerCount);
|
||||||
|
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
elements.roomId.value = item.dataset.id;
|
elements.roomId.value = r.id;
|
||||||
elements.password.value = '';
|
elements.password.value = '';
|
||||||
elements.password.focus();
|
elements.password.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
elements.publicRooms.appendChild(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,16 +569,29 @@ function showError(msg) {
|
|||||||
// --- Action Handlers ---
|
// --- Action Handlers ---
|
||||||
elements.joinBtn.addEventListener('click', async () => {
|
elements.joinBtn.addEventListener('click', async () => {
|
||||||
if (elements.joinBtn.disabled) return;
|
if (elements.joinBtn.disabled) return;
|
||||||
elements.joinBtn.disabled = true;
|
const roomIdInput = elements.roomId.value.trim();
|
||||||
const originalText = elements.joinBtn.textContent;
|
const isCreating = !roomIdInput;
|
||||||
elements.joinBtn.textContent = 'Joining...';
|
|
||||||
setTimeout(() => {
|
|
||||||
elements.joinBtn.disabled = false;
|
|
||||||
elements.joinBtn.textContent = originalText;
|
|
||||||
}, 1500);
|
|
||||||
|
|
||||||
const serverUrl = elements.serverUrl.value;
|
elements.joinBtn.disabled = true;
|
||||||
const roomId = elements.roomId.value || Math.random().toString(36).substring(2, 8).toUpperCase();
|
elements.joinBtn.textContent = isCreating ? 'Creating Room...' : 'Joining...';
|
||||||
|
|
||||||
|
const serverUrl = elements.serverUrl.value.trim();
|
||||||
|
const useCustom = elements.serverCustom.classList.contains('active');
|
||||||
|
|
||||||
|
// Proactive URL Validation
|
||||||
|
if (useCustom && serverUrl) {
|
||||||
|
try {
|
||||||
|
const urlToCheck = serverUrl.includes('://') ? serverUrl : 'ws://' + serverUrl;
|
||||||
|
new URL(urlToCheck);
|
||||||
|
} catch (e) {
|
||||||
|
showError('Invalid Server URL format.');
|
||||||
|
elements.joinBtn.disabled = false;
|
||||||
|
elements.joinBtn.textContent = 'Join Room';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = roomIdInput || Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||||
const password = elements.password.value;
|
const password = elements.password.value;
|
||||||
|
|
||||||
await chrome.storage.sync.set({ serverUrl, roomId, password });
|
await chrome.storage.sync.set({ serverUrl, roomId, password });
|
||||||
@@ -476,8 +601,7 @@ elements.joinBtn.addEventListener('click', async () => {
|
|||||||
chrome.runtime.sendMessage({ type: 'CONNECT' });
|
chrome.runtime.sendMessage({ type: 'CONNECT' });
|
||||||
|
|
||||||
// UI Feedback: Immediately switch state for better responsiveness
|
// UI Feedback: Immediately switch state for better responsiveness
|
||||||
const data = await chrome.storage.sync.get(['useCustomServer']);
|
updateUI(roomId, password, useCustom, serverUrl);
|
||||||
updateUI(roomId, password, data.useCustomServer, serverUrl);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.leaveBtn.addEventListener('click', async () => {
|
elements.leaveBtn.addEventListener('click', async () => {
|
||||||
@@ -590,12 +714,15 @@ elements.copyInvite.addEventListener('click', () => {
|
|||||||
// --- Logs & Status ---
|
// --- Logs & Status ---
|
||||||
async function refreshLogs() {
|
async function refreshLogs() {
|
||||||
chrome.runtime.sendMessage({ type: 'GET_LOGS' }, (logs) => {
|
chrome.runtime.sendMessage({ type: 'GET_LOGS' }, (logs) => {
|
||||||
if (logs) {
|
if (logs && elements.logList) {
|
||||||
elements.logList.innerHTML = logs.map(log => `
|
elements.logList.innerHTML = '';
|
||||||
<div class="log-entry log-${log.type}">
|
logs.forEach(log => {
|
||||||
[${log.timestamp.split('T')[1].split('.')[0]}] ${escapeHtml(log.message)}
|
const entry = document.createElement('div');
|
||||||
</div>
|
entry.className = `log-entry log-${log.type}`;
|
||||||
`).join('');
|
const timeStr = log.timestamp.split('T')[1].split('.')[0];
|
||||||
|
entry.textContent = `[${timeStr}] ${log.message}`;
|
||||||
|
elements.logList.appendChild(entry);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -619,7 +746,7 @@ chrome.runtime.onMessage.addListener((msg) => {
|
|||||||
}
|
}
|
||||||
if (msg.status === 'disconnected' || msg.status === 'reconnect_failed') {
|
if (msg.status === 'disconnected' || msg.status === 'reconnect_failed') {
|
||||||
elements.joinBtn.disabled = false;
|
elements.joinBtn.disabled = false;
|
||||||
elements.joinBtn.textContent = 'Join / Create Room';
|
elements.joinBtn.textContent = 'Join Room';
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'HISTORY_UPDATE') {
|
} else if (msg.type === 'HISTORY_UPDATE') {
|
||||||
updateHistory(msg.history);
|
updateHistory(msg.history);
|
||||||
@@ -671,13 +798,33 @@ function refreshDebugInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (elements.videoDebug) {
|
if (elements.videoDebug) {
|
||||||
elements.videoDebug.innerHTML = `
|
elements.videoDebug.innerHTML = '';
|
||||||
<div style="color:var(--accent); margin-bottom:4px;">VIDEO STATE: ${state.paused ? 'PAUSED' : 'PLAYING'}</div>
|
|
||||||
<div style="font-size: 11px;">Time: ${state.currentTime.toFixed(2)}s / ${state.duration.toFixed(2)}s</div>
|
const status = document.createElement('div');
|
||||||
<div style="font-size: 11px;">ReadyState: ${state.readyState}</div>
|
status.style.cssText = 'color:var(--accent); margin-bottom:4px;';
|
||||||
<div style="font-size: 11px;">Muted: ${state.muted} | PlaybackRate: ${state.playbackRate}</div>
|
status.textContent = `VIDEO STATE: ${state.paused ? 'PAUSED' : 'PLAYING'}`;
|
||||||
<div style="font-size:9px; margin-top:4px; opacity:0.7;">URL: ${state.url.substring(0, 40)}...</div>
|
|
||||||
`;
|
const time = document.createElement('div');
|
||||||
|
time.style.fontSize = '11px';
|
||||||
|
time.textContent = `Time: ${state.currentTime.toFixed(2)}s / ${state.duration.toFixed(2)}s`;
|
||||||
|
|
||||||
|
const readyState = document.createElement('div');
|
||||||
|
readyState.style.fontSize = '11px';
|
||||||
|
readyState.textContent = `ReadyState: ${state.readyState}`;
|
||||||
|
|
||||||
|
const misc = document.createElement('div');
|
||||||
|
misc.style.fontSize = '11px';
|
||||||
|
misc.textContent = `Muted: ${state.muted} | PlaybackRate: ${state.playbackRate}`;
|
||||||
|
|
||||||
|
const url = document.createElement('div');
|
||||||
|
url.style.cssText = 'font-size:9px; margin-top:4px; opacity:0.7;';
|
||||||
|
url.textContent = `URL: ${state.url.substring(0, 40)}...`;
|
||||||
|
|
||||||
|
elements.videoDebug.appendChild(status);
|
||||||
|
elements.videoDebug.appendChild(time);
|
||||||
|
elements.videoDebug.appendChild(readyState);
|
||||||
|
elements.videoDebug.appendChild(misc);
|
||||||
|
elements.videoDebug.appendChild(url);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
+21
-6
@@ -36,6 +36,7 @@ const io = new Server(httpServer, {
|
|||||||
*/
|
*/
|
||||||
const rooms = new Map();
|
const rooms = new Map();
|
||||||
const socketToRoom = new Map();
|
const socketToRoom = new Map();
|
||||||
|
const peerToSocket = new Map(); // peerId -> socketId (Global lookup)
|
||||||
|
|
||||||
function log(type, message, details = '') {
|
function log(type, message, details = '') {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
@@ -246,6 +247,7 @@ io.on('connection', (socket) => {
|
|||||||
lastSeen: Date.now()
|
lastSeen: Date.now()
|
||||||
});
|
});
|
||||||
socketToRoom.set(socket.id, { roomId, peerId });
|
socketToRoom.set(socket.id, { roomId, peerId });
|
||||||
|
peerToSocket.set(peerId, socket.id);
|
||||||
|
|
||||||
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, status: 'joined' });
|
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, status: 'joined' });
|
||||||
socket.emit(EVENTS.ROOM_DATA, {
|
socket.emit(EVENTS.ROOM_DATA, {
|
||||||
@@ -323,20 +325,27 @@ io.on('connection', (socket) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
socketToRoom.delete(socket.id);
|
socketToRoom.delete(socket.id);
|
||||||
|
if (peerToSocket.get(peerId) === socket.id) {
|
||||||
|
peerToSocket.delete(peerId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on(EVENTS.EVENT_ACK, (data) => {
|
socket.on(EVENTS.EVENT_ACK, (data) => {
|
||||||
if (!data.targetId) return;
|
if (!data.targetId) return;
|
||||||
const targetSocket = Array.from(io.sockets.sockets.values()).find(s => {
|
|
||||||
const roomData = socketToRoom.get(s.id);
|
const senderMapping = socketToRoom.get(socket.id);
|
||||||
return roomData && roomData.peerId === data.targetId;
|
const targetSocketId = peerToSocket.get(data.targetId);
|
||||||
});
|
const targetMapping = targetSocketId ? socketToRoom.get(targetSocketId) : null;
|
||||||
if (targetSocket) {
|
|
||||||
targetSocket.emit(EVENTS.EVENT_ACK, {
|
// Security: Only relay ACK if both peers are in the same room
|
||||||
|
if (senderMapping && targetMapping && senderMapping.roomId === targetMapping.roomId) {
|
||||||
|
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
|
||||||
senderId: data.senderId,
|
senderId: data.senderId,
|
||||||
actionTimestamp: data.actionTimestamp
|
actionTimestamp: data.actionTimestamp
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
log('SECURITY', `Blocked cross-room ACK attempt from ${socket.id} to ${data.targetId}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -357,6 +366,9 @@ io.on('connection', (socket) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
socketToRoom.delete(socket.id);
|
socketToRoom.delete(socket.id);
|
||||||
|
if (peerToSocket.get(peerId) === socket.id) {
|
||||||
|
peerToSocket.delete(peerId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -378,6 +390,9 @@ setInterval(() => {
|
|||||||
room.peerIds.delete(sid);
|
room.peerIds.delete(sid);
|
||||||
room.peerData.delete(sid);
|
room.peerData.delete(sid);
|
||||||
socketToRoom.delete(sid);
|
socketToRoom.delete(sid);
|
||||||
|
if (peerToSocket.get(data.peerId) === sid) {
|
||||||
|
peerToSocket.delete(data.peerId);
|
||||||
|
}
|
||||||
|
|
||||||
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId: data.peerId, status: 'left' });
|
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId: data.peerId, status: 'left' });
|
||||||
log('CLEANUP', `Pruned dead peer ${data.peerId} from room ${roomId}`);
|
log('CLEANUP', `Pruned dead peer ${data.peerId} from room ${roomId}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user