Compare commits

...

7 Commits

6 changed files with 198 additions and 37 deletions
+5 -4
View File
@@ -98,10 +98,11 @@ The following features are critical and must not be removed or fundamentally alt
> [!CAUTION]
> **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.**
> The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json`. You do NOT need to manually bump version numbers.
1. Commit all code changes and push to `main`.
2. Create and push a new tag. **MANDATORY**: Tags MUST start with a `v` (e.g., `v1.4.0`). The GitHub Actions release workflow is strictly configured to ignore any tags without the `v` prefix.
3. The CI will extract the version from the tag (e.g., `v1.4.0``1.4.0`), inject it into all source files, build the extension artifacts, publish the Docker image, and create a GitHub Release.
4. Verify the release builds on GitHub Actions.
1. **MANDATORY SYNTAX CHECK**: Before staging, committing, or pushing any changes, you **MUST** run a syntax validation check using `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check.
2. Commit all verified code changes and push to `main`.
3. Create and push a new tag. **MANDATORY**: Tags MUST start with a `v` (e.g., `v1.4.0`). The GitHub Actions release workflow is strictly configured to ignore any tags without the `v` prefix.
4. The CI will extract the version from the tag (e.g., `v1.4.0``1.4.0`), inject it into all source files, build the extension artifacts, publish the Docker image, and create a GitHub Release.
5. Verify the release builds on GitHub Actions.
### Adding a Protocol Event
1. Add the event name to `shared/constants.js`.
+128 -4
View File
@@ -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() {
const data = await chrome.storage.local.get(['peerId']);
if (data.peerId) return data.peerId;
@@ -488,6 +510,14 @@ function handleServerEvent(event, data) {
addToHistory(event, data.senderId);
showNotification(data.senderId, event);
updateLastAction(event, data.senderId);
lastActionState.targetTime = data.targetTime !== undefined ? data.targetTime : data.currentTime;
if (storageInitialized) chrome.storage.session.set({ lastActionState });
// Remote Reactive Update
updateLocalPeerState(data.senderId, {
playbackState: event === EVENTS.PLAY ? 'playing' : (event === EVENTS.PAUSE ? 'paused' : undefined),
currentTime: data.currentTime !== undefined ? data.currentTime : (data.targetTime !== undefined ? data.targetTime : undefined)
});
}
routeToContent(event, data);
break;
@@ -505,6 +535,12 @@ function handleServerEvent(event, data) {
if (storageInitialized) chrome.storage.session.set({ lastActionState });
chrome.runtime.sendMessage({ type: 'ACTION_UPDATE', state: lastActionState }).catch(() => {});
}
// Force Sync ACK Reactive Update
updateLocalPeerState(data.senderId, {
playbackState: 'paused', // Preparing for force sync always pauses the player
currentTime: lastActionState.targetTime
});
}
// Check if all peers responded
@@ -518,7 +554,24 @@ function handleServerEvent(event, data) {
if (data.senderId) {
addToHistory(event, data.senderId);
showNotification(data.senderId, event);
// Force Sync Execute Remote Reactive Update
updateLocalPeerState(data.senderId, {
playbackState: 'playing'
});
}
// Reset reactive update locks for all peers so the next playing heartbeat is accepted immediately
if (currentRoom && Array.isArray(currentRoom.peers)) {
currentRoom.peers.forEach(peer => {
if (peer && typeof peer === 'object') {
peer.lastReactiveUpdate = 0;
}
});
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
routeToContent(event, data);
break;
case EVENTS.EVENT_ACK:
@@ -530,6 +583,12 @@ function handleServerEvent(event, data) {
lastActionState.acks.push(data.senderId);
if (storageInitialized) chrome.storage.session.set({ lastActionState });
chrome.runtime.sendMessage({ type: 'ACTION_UPDATE', state: lastActionState }).catch(() => {});
// ACK Reactive Update
updateLocalPeerState(data.senderId, {
playbackState: lastActionState.action === EVENTS.PLAY ? 'playing' : (lastActionState.action === EVENTS.PAUSE ? 'paused' : undefined),
currentTime: (lastActionState.action === EVENTS.SEEK || lastActionState.action === EVENTS.FORCE_SYNC_PREPARE) ? lastActionState.targetTime : undefined
});
}
}
}
@@ -571,11 +630,19 @@ function handleServerEvent(event, data) {
peer.tabTitle = data.tabTitle;
peer.username = data.username;
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.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 {
// Migration: replace string peer with normalized object
const idx = currentRoom.peers.indexOf(peer);
@@ -647,6 +714,18 @@ function executeForceSync() {
forceSyncAcks: [],
forceSyncDeadline: null
});
// Reset reactive update locks for all peers so the next playing heartbeat is accepted immediately
if (currentRoom && Array.isArray(currentRoom.peers)) {
currentRoom.peers.forEach(peer => {
if (peer && typeof peer === 'object') {
peer.lastReactiveUpdate = 0;
}
});
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
emit(EVENTS.FORCE_SYNC_EXECUTE, {});
routeToContent(EVENTS.FORCE_SYNC_EXECUTE, {});
addLog('Force Sync Executed', 'success');
@@ -813,7 +892,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 ---
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
@@ -827,9 +930,12 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (message.type === 'CONNECT') {
reconnectFailed = false;
reconnectStartTime = null;
const settings = await getSettings();
if (settings.roomId) {
leaveOldRoomIfSwitching(settings.roomId);
}
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
// Already connected, but maybe room changed or we need to refresh room state
const settings = await getSettings();
if (settings.roomId) {
emit(EVENTS.JOIN_ROOM, {
roomId: settings.roomId,
@@ -907,6 +1013,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
serverUrl: serverUrl || ''
}, async () => {
broadcastConnectionStatus('connecting');
leaveOldRoomIfSwitching(roomId);
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
// FORCE TRANSITION: Emit Join Room directly if already connected
const settings = await getSettings();
@@ -949,8 +1056,16 @@ async function handleAsyncMessage(message, sender, sendResponse) {
const processEvent = () => {
const timestamp = Date.now();
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;
// 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) {
isForceSyncInitiator = true;
forceSyncAcks.clear();
@@ -997,6 +1112,15 @@ async function handleAsyncMessage(message, sender, sendResponse) {
forceSyncAcks.add(peerId);
chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) });
addLog(`Local ACK received (${forceSyncAcks.size})`, 'info');
// Local Force Sync ACK Reactive Update
if (lastActionState && lastActionState.action === EVENTS.FORCE_SYNC_PREPARE) {
updateLocalPeerState(peerId, {
playbackState: 'paused',
currentTime: lastActionState.targetTime
});
}
const peerCount = currentRoom ? currentRoom.peers.length : 1;
if (forceSyncAcks.size >= peerCount) {
executeForceSync();
+61 -25
View File
@@ -265,16 +265,20 @@
if (message.type === 'SERVER_COMMAND') {
const { action, payload } = message;
let actionCompleted = false;
if (action === EVENTS.PLAY) {
tryMediaAction(EVENTS.PLAY);
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
actionCompleted = true;
} else if (action === EVENTS.PAUSE) {
tryMediaAction(EVENTS.PAUSE);
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
actionCompleted = true;
} else if (action === EVENTS.SEEK) {
tryMediaAction(EVENTS.SEEK, payload);
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
actionCompleted = true;
} else if (action === EVENTS.FORCE_SYNC_PREPARE) {
if (!payload || payload.targetTime === undefined) return;
const video = findVideo();
@@ -288,13 +292,21 @@
video.pause();
video.currentTime = payload.targetTime;
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) {
stopLobbyPoll(); // Clear any pending lobby on force sync
tryMediaAction(EVENTS.PLAY);
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
actionCompleted = true;
}
if (actionCompleted) {
scheduleProactiveHeartbeat();
}
}
@@ -399,6 +411,9 @@
timestamp: Date.now()
}
});
// Trigger proactive heartbeat to push stabilized state
scheduleProactiveHeartbeat();
}
const handlePlay = () => reportEvent(EVENTS.PLAY);
@@ -510,36 +525,57 @@
const HEARTBEAT_INTERVAL_VAL = 15000;
// --- SHARED_HEARTBEAT_INJECT_END ---
// Heartbeat
// Heartbeat Refactoring (Self-scheduling setTimeout with proactive heartbeat scheduling)
let heartbeatTimeout = null;
let proactiveHeartbeatTimeout = null;
let heartbeatErrorCount = 0;
const heartbeatInterval = setInterval(() => {
function sendHeartbeat() {
const video = findVideo();
if (video) {
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
chrome.runtime.sendMessage({
type: 'HEARTBEAT',
payload: {
playbackState: video.paused ? 'paused' : 'playing',
currentTime: video.currentTime,
mediaTitle: mediaTitle,
volume: video.volume,
muted: video.muted
if (!video) return;
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
chrome.runtime.sendMessage({
type: 'HEARTBEAT',
payload: {
playbackState: video.paused ? 'paused' : 'playing',
currentTime: video.currentTime,
mediaTitle: mediaTitle,
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 (err.message.includes('Extension context invalidated')) {
heartbeatErrorCount++;
if (heartbeatErrorCount === 1) {
reportLog('Extension reloaded. Please refresh the page if sync stops working.', 'warn');
}
clearInterval(heartbeatInterval);
observer.disconnect();
}
});
}
}, HEARTBEAT_INTERVAL_VAL);
if (heartbeatTimeout) clearTimeout(heartbeatTimeout);
if (proactiveHeartbeatTimeout) clearTimeout(proactiveHeartbeatTimeout);
observer.disconnect();
}
});
}
function schedulePeriodicHeartbeat() {
if (heartbeatTimeout) clearTimeout(heartbeatTimeout);
heartbeatTimeout = setTimeout(() => {
sendHeartbeat();
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
setupListeners();
schedulePeriodicHeartbeat();
// Episode Auto-Sync: Boot recovery — check if background has an active lobby
chrome.runtime.sendMessage({ type: 'CONTENT_BOOT' }, (res) => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "KoalaSync",
"version": "1.4.4",
"version": "1.5.1",
"description": "Watch party extension to synchronize video playback on YouTube, Twitch, Netflix, and HTML5 sites in real-time with friends.",
"permissions": [
"storage",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "koalasync",
"version": "1.4.4",
"version": "1.5.1",
"description": "KoalaSync Build Scripts",
"private": true,
"scripts": {
+2 -2
View File
@@ -1,4 +1,4 @@
{
"version": "1.4.4",
"date": "2026-05-18T13:14:48Z"
"version": "1.5.1",
"date": "2026-05-18T17:44:37Z"
}