mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-31 13:08:15 +00:00
feat: implement Strategy C (zero-latency hybrid synchronization) and fix room switching duplicate matches
This commit is contained in:
+104
-6
@@ -137,6 +137,28 @@ function createPeerData(raw) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 => (p.peerId || 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() {
|
async function getPeerId() {
|
||||||
const data = await chrome.storage.local.get(['peerId']);
|
const data = await chrome.storage.local.get(['peerId']);
|
||||||
if (data.peerId) return data.peerId;
|
if (data.peerId) return data.peerId;
|
||||||
@@ -488,6 +510,14 @@ function handleServerEvent(event, data) {
|
|||||||
addToHistory(event, data.senderId);
|
addToHistory(event, data.senderId);
|
||||||
showNotification(data.senderId, event);
|
showNotification(data.senderId, event);
|
||||||
updateLastAction(event, data.senderId);
|
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);
|
routeToContent(event, data);
|
||||||
break;
|
break;
|
||||||
@@ -505,6 +535,12 @@ function handleServerEvent(event, data) {
|
|||||||
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
||||||
chrome.runtime.sendMessage({ type: 'ACTION_UPDATE', state: lastActionState }).catch(() => {});
|
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
|
// Check if all peers responded
|
||||||
@@ -518,6 +554,11 @@ function handleServerEvent(event, data) {
|
|||||||
if (data.senderId) {
|
if (data.senderId) {
|
||||||
addToHistory(event, data.senderId);
|
addToHistory(event, data.senderId);
|
||||||
showNotification(data.senderId, event);
|
showNotification(data.senderId, event);
|
||||||
|
|
||||||
|
// Force Sync Execute Remote Reactive Update
|
||||||
|
updateLocalPeerState(data.senderId, {
|
||||||
|
playbackState: 'playing'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
routeToContent(event, data);
|
routeToContent(event, data);
|
||||||
break;
|
break;
|
||||||
@@ -530,6 +571,12 @@ function handleServerEvent(event, data) {
|
|||||||
lastActionState.acks.push(data.senderId);
|
lastActionState.acks.push(data.senderId);
|
||||||
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
||||||
chrome.runtime.sendMessage({ type: 'ACTION_UPDATE', state: lastActionState }).catch(() => {});
|
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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -571,11 +618,19 @@ function handleServerEvent(event, data) {
|
|||||||
peer.tabTitle = data.tabTitle;
|
peer.tabTitle = data.tabTitle;
|
||||||
peer.username = data.username;
|
peer.username = data.username;
|
||||||
peer.mediaTitle = data.mediaTitle !== undefined ? data.mediaTitle : peer.mediaTitle;
|
peer.mediaTitle = data.mediaTitle !== undefined ? data.mediaTitle : peer.mediaTitle;
|
||||||
peer.playbackState = data.playbackState !== undefined ? data.playbackState : peer.playbackState;
|
|
||||||
peer.currentTime = data.currentTime !== undefined ? data.currentTime : peer.currentTime;
|
|
||||||
peer.volume = data.volume !== undefined ? data.volume : peer.volume;
|
peer.volume = data.volume !== undefined ? data.volume : peer.volume;
|
||||||
peer.muted = data.muted !== undefined ? data.muted : peer.muted;
|
peer.muted = data.muted !== undefined ? data.muted : peer.muted;
|
||||||
peer.lastHeartbeat = Date.now();
|
|
||||||
|
// Race condition guard: ignore heartbeat playbackState/currentTime
|
||||||
|
// if we applied a reactive user action in the last 1.5 seconds.
|
||||||
|
const timeSinceReactive = peer.lastReactiveUpdate ? (Date.now() - peer.lastReactiveUpdate) : Infinity;
|
||||||
|
const ignoreStatus = timeSinceReactive < 1500;
|
||||||
|
|
||||||
|
if (!ignoreStatus) {
|
||||||
|
peer.playbackState = data.playbackState !== undefined ? data.playbackState : peer.playbackState;
|
||||||
|
peer.currentTime = data.currentTime !== undefined ? data.currentTime : peer.currentTime;
|
||||||
|
peer.lastHeartbeat = Date.now();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Migration: replace string peer with normalized object
|
// Migration: replace string peer with normalized object
|
||||||
const idx = currentRoom.peers.indexOf(peer);
|
const idx = currentRoom.peers.indexOf(peer);
|
||||||
@@ -811,9 +866,31 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
function leaveOldRoomIfSwitching(newRoomId) {
|
||||||
|
if (currentRoom && currentRoom.roomId !== newRoomId) {
|
||||||
|
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
||||||
|
try {
|
||||||
|
socket.send(`42${JSON.stringify([EVENTS.LEAVE_ROOM, { peerId }])}`);
|
||||||
|
} catch (e) {
|
||||||
|
addLog('Failed to send leave room packet during transition', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentRoom = null;
|
||||||
|
if (storageInitialized) chrome.storage.session.set({ currentRoom: null });
|
||||||
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||||
|
|
||||||
|
// Reset force sync states
|
||||||
|
isForceSyncInitiator = false;
|
||||||
|
forceSyncAcks.clear();
|
||||||
|
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||||
|
chrome.storage.session.set({
|
||||||
|
isForceSyncInitiator: false,
|
||||||
|
forceSyncAcks: [],
|
||||||
|
forceSyncDeadline: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Extension Message Listeners ---
|
// --- Extension Message Listeners ---
|
||||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
@@ -827,9 +904,12 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
if (message.type === 'CONNECT') {
|
if (message.type === 'CONNECT') {
|
||||||
reconnectFailed = false;
|
reconnectFailed = false;
|
||||||
reconnectStartTime = null;
|
reconnectStartTime = null;
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (settings.roomId) {
|
||||||
|
leaveOldRoomIfSwitching(settings.roomId);
|
||||||
|
}
|
||||||
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
|
||||||
const settings = await getSettings();
|
|
||||||
if (settings.roomId) {
|
if (settings.roomId) {
|
||||||
emit(EVENTS.JOIN_ROOM, {
|
emit(EVENTS.JOIN_ROOM, {
|
||||||
roomId: settings.roomId,
|
roomId: settings.roomId,
|
||||||
@@ -907,6 +987,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
serverUrl: serverUrl || ''
|
serverUrl: serverUrl || ''
|
||||||
}, async () => {
|
}, async () => {
|
||||||
broadcastConnectionStatus('connecting');
|
broadcastConnectionStatus('connecting');
|
||||||
|
leaveOldRoomIfSwitching(roomId);
|
||||||
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
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
@@ -949,8 +1030,16 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
const processEvent = () => {
|
const processEvent = () => {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
updateLastAction(message.action, 'You', timestamp);
|
updateLastAction(message.action, 'You', timestamp);
|
||||||
|
lastActionState.targetTime = message.payload.targetTime !== undefined ? message.payload.targetTime : message.payload.currentTime;
|
||||||
|
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
||||||
message.payload.actionTimestamp = timestamp;
|
message.payload.actionTimestamp = timestamp;
|
||||||
|
|
||||||
|
// Local Reactive Update
|
||||||
|
updateLocalPeerState(peerId, {
|
||||||
|
playbackState: message.action === EVENTS.PLAY ? 'playing' : (message.action === EVENTS.PAUSE ? 'paused' : undefined),
|
||||||
|
currentTime: message.payload.currentTime !== undefined ? message.payload.currentTime : (message.payload.targetTime !== undefined ? message.payload.targetTime : undefined)
|
||||||
|
});
|
||||||
|
|
||||||
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
|
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||||
isForceSyncInitiator = true;
|
isForceSyncInitiator = true;
|
||||||
forceSyncAcks.clear();
|
forceSyncAcks.clear();
|
||||||
@@ -997,6 +1086,15 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
forceSyncAcks.add(peerId);
|
forceSyncAcks.add(peerId);
|
||||||
chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) });
|
chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) });
|
||||||
addLog(`Local ACK received (${forceSyncAcks.size})`, 'info');
|
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 ? currentRoom.peers.length : 1;
|
const peerCount = currentRoom ? currentRoom.peers.length : 1;
|
||||||
if (forceSyncAcks.size >= peerCount) {
|
if (forceSyncAcks.size >= peerCount) {
|
||||||
executeForceSync();
|
executeForceSync();
|
||||||
|
|||||||
+61
-25
@@ -265,16 +265,20 @@
|
|||||||
|
|
||||||
if (message.type === 'SERVER_COMMAND') {
|
if (message.type === 'SERVER_COMMAND') {
|
||||||
const { action, payload } = message;
|
const { action, payload } = message;
|
||||||
|
let actionCompleted = false;
|
||||||
|
|
||||||
if (action === EVENTS.PLAY) {
|
if (action === EVENTS.PLAY) {
|
||||||
tryMediaAction(EVENTS.PLAY);
|
tryMediaAction(EVENTS.PLAY);
|
||||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
||||||
|
actionCompleted = true;
|
||||||
} else if (action === EVENTS.PAUSE) {
|
} else if (action === EVENTS.PAUSE) {
|
||||||
tryMediaAction(EVENTS.PAUSE);
|
tryMediaAction(EVENTS.PAUSE);
|
||||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
||||||
|
actionCompleted = true;
|
||||||
} else if (action === EVENTS.SEEK) {
|
} else if (action === EVENTS.SEEK) {
|
||||||
tryMediaAction(EVENTS.SEEK, payload);
|
tryMediaAction(EVENTS.SEEK, payload);
|
||||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
||||||
|
actionCompleted = true;
|
||||||
} else if (action === EVENTS.FORCE_SYNC_PREPARE) {
|
} else if (action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||||
if (!payload || payload.targetTime === undefined) return;
|
if (!payload || payload.targetTime === undefined) return;
|
||||||
const video = findVideo();
|
const video = findVideo();
|
||||||
@@ -288,13 +292,21 @@
|
|||||||
video.pause();
|
video.pause();
|
||||||
video.currentTime = payload.targetTime;
|
video.currentTime = payload.targetTime;
|
||||||
pollSeekReady(payload.targetTime).then((ready) => {
|
pollSeekReady(payload.targetTime).then((ready) => {
|
||||||
if (ready) chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' });
|
if (ready) {
|
||||||
|
chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' });
|
||||||
|
scheduleProactiveHeartbeat();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (action === EVENTS.FORCE_SYNC_EXECUTE) {
|
} else if (action === EVENTS.FORCE_SYNC_EXECUTE) {
|
||||||
stopLobbyPoll(); // Clear any pending lobby on force sync
|
stopLobbyPoll(); // Clear any pending lobby on force sync
|
||||||
tryMediaAction(EVENTS.PLAY);
|
tryMediaAction(EVENTS.PLAY);
|
||||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
||||||
|
actionCompleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionCompleted) {
|
||||||
|
scheduleProactiveHeartbeat();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +411,9 @@
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Trigger proactive heartbeat to push stabilized state
|
||||||
|
scheduleProactiveHeartbeat();
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePlay = () => reportEvent(EVENTS.PLAY);
|
const handlePlay = () => reportEvent(EVENTS.PLAY);
|
||||||
@@ -510,36 +525,57 @@
|
|||||||
const HEARTBEAT_INTERVAL_VAL = 15000;
|
const HEARTBEAT_INTERVAL_VAL = 15000;
|
||||||
// --- SHARED_HEARTBEAT_INJECT_END ---
|
// --- SHARED_HEARTBEAT_INJECT_END ---
|
||||||
|
|
||||||
// Heartbeat
|
// Heartbeat Refactoring (Self-scheduling setTimeout with proactive heartbeat scheduling)
|
||||||
|
let heartbeatTimeout = null;
|
||||||
|
let proactiveHeartbeatTimeout = null;
|
||||||
let heartbeatErrorCount = 0;
|
let heartbeatErrorCount = 0;
|
||||||
const heartbeatInterval = setInterval(() => {
|
|
||||||
|
function sendHeartbeat() {
|
||||||
const video = findVideo();
|
const video = findVideo();
|
||||||
if (video) {
|
if (!video) return;
|
||||||
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
|
|
||||||
chrome.runtime.sendMessage({
|
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
|
||||||
type: 'HEARTBEAT',
|
chrome.runtime.sendMessage({
|
||||||
payload: {
|
type: 'HEARTBEAT',
|
||||||
playbackState: video.paused ? 'paused' : 'playing',
|
payload: {
|
||||||
currentTime: video.currentTime,
|
playbackState: video.paused ? 'paused' : 'playing',
|
||||||
mediaTitle: mediaTitle,
|
currentTime: video.currentTime,
|
||||||
volume: video.volume,
|
mediaTitle: mediaTitle,
|
||||||
muted: video.muted
|
volume: video.volume,
|
||||||
|
muted: video.muted
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
if (err.message.includes('Extension context invalidated')) {
|
||||||
|
heartbeatErrorCount++;
|
||||||
|
if (heartbeatErrorCount === 1) {
|
||||||
|
reportLog('Extension reloaded. Please refresh the page if sync stops working.', 'warn');
|
||||||
}
|
}
|
||||||
}).catch(err => {
|
if (heartbeatTimeout) clearTimeout(heartbeatTimeout);
|
||||||
if (err.message.includes('Extension context invalidated')) {
|
if (proactiveHeartbeatTimeout) clearTimeout(proactiveHeartbeatTimeout);
|
||||||
heartbeatErrorCount++;
|
observer.disconnect();
|
||||||
if (heartbeatErrorCount === 1) {
|
}
|
||||||
reportLog('Extension reloaded. Please refresh the page if sync stops working.', 'warn');
|
});
|
||||||
}
|
}
|
||||||
clearInterval(heartbeatInterval);
|
|
||||||
observer.disconnect();
|
function schedulePeriodicHeartbeat() {
|
||||||
}
|
if (heartbeatTimeout) clearTimeout(heartbeatTimeout);
|
||||||
});
|
heartbeatTimeout = setTimeout(() => {
|
||||||
}
|
sendHeartbeat();
|
||||||
}, HEARTBEAT_INTERVAL_VAL);
|
schedulePeriodicHeartbeat();
|
||||||
|
}, HEARTBEAT_INTERVAL_VAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleProactiveHeartbeat() {
|
||||||
|
if (proactiveHeartbeatTimeout) clearTimeout(proactiveHeartbeatTimeout);
|
||||||
|
proactiveHeartbeatTimeout = setTimeout(() => {
|
||||||
|
sendHeartbeat();
|
||||||
|
schedulePeriodicHeartbeat(); // Reschedules the next periodic check to be exactly 15s from now
|
||||||
|
}, 500); // 500ms stabilization delay
|
||||||
|
}
|
||||||
|
|
||||||
// Initial Setup
|
// Initial Setup
|
||||||
setupListeners();
|
setupListeners();
|
||||||
|
schedulePeriodicHeartbeat();
|
||||||
|
|
||||||
// Episode Auto-Sync: Boot recovery — check if background has an active lobby
|
// Episode Auto-Sync: Boot recovery — check if background has an active lobby
|
||||||
chrome.runtime.sendMessage({ type: 'CONTENT_BOOT' }, (res) => {
|
chrome.runtime.sendMessage({ type: 'CONTENT_BOOT' }, (res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user