Compare commits

...

14 Commits

Author SHA1 Message Date
MacBook bcbd46d658 fix: make shared file sync fail-fast in build script 2026-05-01 05:41:07 +02:00
MacBook 4d489ec992 chore: implement cross-browser build pipeline for extension
- Extract manifest.json to manifest.base.json
- Add Node.js build script to compile Chrome and Firefox artifacts
- Remove legacy sync-constants bat/sh scripts
- Update GitHub Actions workflow to use new build pipeline
2026-05-01 05:37:47 +02:00
Timo 65ad4b5c6b chore: Bump version to 1.2.1
Patch release for:
- fix: Seek relay filtering (HLS/DASH buffering micro-seeks no longer relayed)
- feat: Seek diagnostic logging in Dev tab (Filtered/Relayed with delta)
- feat: Log buffer increased from 50 to 200 entries
2026-04-25 17:47:59 +02:00
Timo c2857dbdda feat: Improve seek logging and increase log buffer to 200 entries
- Log [Seek] Filtered when delta < 3s threshold (warn level) showing exact delta
- Log [Seek] Relayed when a seek passes all filters (info level) showing target time + delta
- Programmatic seeks (force sync, peer commands) remain silent in logs
- Increase log ring buffer from 50 -> 200 entries in all three enforcement points
2026-04-25 17:45:06 +02:00
Timo d07bf745a3 fix: Add seek delta threshold and debounce to prevent HLS/DASH buffering micro-seeks from being relayed as user seeks
Streaming players (Emby, Jellyfin, etc.) perform frequent internal seeks
for buffering that are < 1s in magnitude. These were being relayed to
peers, causing brief video freezes every few minutes.

Fix:
- MIN_SEEK_DELTA = 3.0s: ignore seeks smaller than 3 seconds
- 800ms debounce: settle rapid-fire seeks (e.g. scrubbing) before relaying
- Programmatic seek suppression still takes priority via expectedEvents
- lastReportedSeekTime baseline updated on programmatic seeks too
2026-04-25 17:42:55 +02:00
Timo bd54e893b4 docs: Update root README for v1.2.0 features 2026-04-25 16:50:09 +02:00
Timo 50c9ba4ec8 docs: Update website for v1.2.0 release and add Episode Auto-Sync feature card 2026-04-25 16:49:52 +02:00
Timo 55c2d4ed0d feat: Auto-Sync Next Episode v1.2.0
Adds a new toggleable feature that detects episode transitions via
mediaTitle mutation (loadeddata/MutationObserver), pauses the video,
and waits for all room peers to load the same episode before
executing a coordinated Force Sync play at 0:00.

Protocol:
- Add EPISODE_LOBBY and EPISODE_READY events to shared/constants.js
- Add EPISODE_LOBBY_TIMEOUT (60s) constant
- Relay both new events in server/index.js

Content Script (content.js):
- Layered detection: loadeddata + MutationObserver src-change + heartbeat
- Debounced onEpisodeTransition() sends signal ONLY; no eager pause
- PAUSE_FOR_LOBBY handler pauses only after background confirms feature enabled
- startLobbyPoll() polls title match without premature pause for non-initiators
- checkAndReportLobbyReady() pauses and sends EPISODE_READY_LOCAL on match
- CONTENT_BOOT recovery for re-injection after hard navigation

Background (background.js):
- Episode lobby state persisted in chrome.storage.session with recovery
- EPISODE_CHANGED: checks setting, creates lobby, sends PAUSE_FOR_LOBBY to tab
- EPISODE_LOBBY/READY server event handlers with dedup logic
- 60s timeout cancels lobby (Option B) with Chrome failure notification
- Peer departure handled: removes from readyPeers, re-checks completion
- executeEpisodeLobby() reuses existing Force Sync pipeline at targetTime 0.0
- Lobby cleared on LEAVE_ROOM; status exposed in GET_STATUS

Popup:
- Auto-Sync Next Episode toggle in Settings tab (default: off, opt-in)
- Episode Lobby status card in Sync tab with peer readiness display
- LOBBY_UPDATE message handler for real-time UI updates

Bumps APP_VERSION and manifest to 1.2.0
2026-04-25 16:43:10 +02:00
Timo 02afc193c6 fix: extend input sanitization to relay path and remove whitespace artefact 2026-04-25 16:17:16 +02:00
Timo 7fc156977a feat: add graceful shutdown, input validation, and peer data factory (Phase 3) 2026-04-25 16:15:11 +02:00
Timo 99cb07bc2a refactor: centralize room cleanup logic to fix DRY violation (H-1) 2026-04-25 16:12:51 +02:00
Timo 77ffda3e42 fix: address phase 1 audit findings (xss, cors, dead code) 2026-04-25 16:10:20 +02:00
Timo 01bc95e176 docs: add github link and version info to dev tab (v1.1.4) 2026-04-25 03:49:42 +02:00
Timo 7417c21217 feat: enhance peer list with volume icons, play/pause status, and time interpolation (v1.1.3) 2026-04-25 03:47:57 +02:00
16 changed files with 1010 additions and 184 deletions
+6 -8
View File
@@ -52,19 +52,17 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Sync Protocol Constants
- name: Build Extensions
run: |
chmod +x ./scripts/sync-constants.sh
./scripts/sync-constants.sh
- name: Create Extension Zip
run: |
zip -r koala-sync-extension.zip extension/ -x "*.DS_Store*"
npm install
npm run build:extension
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
files: koala-sync-extension.zip
files: |
dist/koalasync-chrome.zip
dist/koalasync-firefox.zip
name: Release ${{ github.ref_name }}
generate_release_notes: true
draft: false
+4
View File
@@ -2,6 +2,8 @@
KoalaSync is a premium, lightweight Chrome Extension and Relay Server for synchronized video playback across any website (YouTube, Twitch, Netflix, and custom HTML5 players).
**Latest Version**: `v1.2.0` (Episode Auto-Sync)
> [!TIP]
> **New Developers & AI Agents**: Please read [AI_INIT.md](AI_INIT.md) before starting work.
@@ -17,6 +19,7 @@ KoalaSync is a premium, lightweight Chrome Extension and Relay Server for synchr
## Key Features
- **Global Synchronization**: Synchronize Play, Pause, and Seeking on any website with a `<video>` tag.
- **Episode Auto-Sync**: Perfectly sync series binges. All peers wait until everyone has loaded the next episode before starting together (v1.2.0+).
- **Smart Matching**: Automatically highlights and sorts tabs containing matching video titles.
- **Noise Filtering**: Built-in domain blacklist to hide non-video sites from selection.
- **Smart Identity**: Customizable usernames combined with unique hexadecimal peer IDs.
@@ -25,6 +28,7 @@ KoalaSync is a premium, lightweight Chrome Extension and Relay Server for synchr
- **Integrated Diagnostics**: A dedicated "Dev" tab for real-time video state debugging.
- **Seamless Invitations**: Smart invitation links that automatically configure the server and room credentials for your friends.
## Setup Instructions
### 1. Relay Server (Docker)
+299 -54
View File
@@ -1,4 +1,4 @@
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION } from './shared/constants.js';
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION, EPISODE_LOBBY_TIMEOUT } from './shared/constants.js';
// --- State Management ---
let socket = null;
@@ -8,7 +8,6 @@ let isConnecting = false;
let peerId = null; // initialized via getPeerId()
let currentRoom = null;
let lastPeersJson = null;
let heartbeatInterval = null;
let currentTabId = null;
let currentTabTitle = null; // New: for Smart Matching
let logs = [];
@@ -30,13 +29,14 @@ function ensureState() {
chrome.storage.session.get([
'logs', 'history', 'currentRoom', 'lastActionState',
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle'
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle',
'episodeLobby'
], (data) => {
if (data.currentTabId !== undefined) currentTabId = data.currentTabId;
if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle;
// Merge data from storage with any early-arriving state
// New entries (added during boot) must stay at the top (index 0)
if (data.logs) logs = [...logs, ...data.logs].slice(0, 50);
if (data.logs) logs = [...logs, ...data.logs].slice(0, 200);
if (data.history) history = [...history, ...data.history].slice(0, 20);
if (data.currentRoom) currentRoom = data.currentRoom;
if (data.lastActionState) lastActionState = data.lastActionState;
@@ -67,11 +67,22 @@ function ensureState() {
}
}
// Recover Episode Lobby
if (data.episodeLobby && !episodeLobby) {
episodeLobby = data.episodeLobby;
const lobbyRemaining = (episodeLobby.createdAt + EPISODE_LOBBY_TIMEOUT) - Date.now();
if (lobbyRemaining > 0) {
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), lobbyRemaining);
} else {
cancelEpisodeLobby('Timeout (recovered)');
}
}
storageInitialized = true;
// Process any early logs/history that weren't captured in the spread
if (pendingLogs.length > 0) {
logs = [...pendingLogs, ...logs].slice(0, 50);
logs = [...pendingLogs, ...logs].slice(0, 200);
chrome.storage.session.set({ logs });
pendingLogs = [];
}
@@ -100,17 +111,30 @@ let isForceSyncInitiator = false;
let forceSyncAcks = new Set();
let forceSyncTimeout = null;
// --- Storage Utils ---
function startHeartbeat() {
// Session heartbeats are now handled by the chrome.alarms 'keepAlive' listener
// to ensure they survive Service Worker suspension in MV3.
}
// Episode Auto-Sync Lobby
let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt }
let episodeLobbyTimeout = null;
function stopHeartbeat() {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
// --- Storage Utils ---
/**
* Canonical peer data factory. All peer object construction must go through
* here to guarantee a consistent shape with predictable null defaults.
* @param {object} raw - Raw data from server event or heartbeat payload.
* @returns {object} Normalized peer data object.
*/
function createPeerData(raw) {
return {
peerId: raw.peerId || null,
username: raw.username || null,
tabTitle: raw.tabTitle || null,
mediaTitle: raw.mediaTitle || null,
playbackState: raw.playbackState || null,
currentTime: raw.currentTime != null ? raw.currentTime : null,
volume: raw.volume != null ? raw.volume : null,
muted: raw.muted != null ? raw.muted : null,
lastHeartbeat: Date.now()
};
}
async function getPeerId() {
@@ -145,7 +169,7 @@ function addLog(message, type = 'info') {
pendingLogs.unshift(log);
} else {
logs.unshift(log);
if (logs.length > 50) logs.pop();
if (logs.length > 200) logs.pop();
chrome.storage.session.set({ logs });
}
chrome.runtime.sendMessage({ type: 'LOG_UPDATE', log }).catch(() => {});
@@ -418,17 +442,13 @@ function addToHistory(action, senderId) {
// --- Event Handlers ---
function handleServerEvent(event, data) {
switch (event) {
case EVENTS.ROOM_DATA:
currentRoom = data;
if (storageInitialized) chrome.storage.session.set({ currentRoom });
addLog(`Joined Room: ${data.roomId}`, 'success');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: data.peers }).catch(() => {});
// Start background heartbeat
startHeartbeat();
// Inform Website Bridge & Popup
const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' };
chrome.runtime.sendMessage(joinStatusMsg).catch(() => {});
@@ -519,12 +539,7 @@ function handleServerEvent(event, data) {
if (!Array.isArray(currentRoom.peers)) currentRoom.peers = [];
if (data.status === 'joined') {
if (!currentRoom.peers.find(p => (p.peerId || p) === data.peerId)) {
currentRoom.peers.push({
peerId: data.peerId,
username: data.username,
tabTitle: data.tabTitle,
mediaTitle: data.mediaTitle || null
});
currentRoom.peers.push(createPeerData(data));
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
@@ -532,6 +547,11 @@ function handleServerEvent(event, data) {
currentRoom.peers = currentRoom.peers.filter(p => (p.peerId || p) !== data.peerId);
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
// Episode Lobby: Handle peer departure
if (episodeLobby) {
checkEpisodeLobbyPeerDeparture();
}
} else {
// Heartbeat/Update: Update tabTitle for matching
const peer = currentRoom.peers.find(p => (p.peerId || p) === data.peerId);
@@ -540,15 +560,15 @@ 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();
} else {
// Migration: replace string with object
// Migration: replace string peer with normalized object
const idx = currentRoom.peers.indexOf(peer);
currentRoom.peers[idx] = {
peerId: data.peerId,
username: data.username,
tabTitle: data.tabTitle,
mediaTitle: data.mediaTitle || null
};
currentRoom.peers[idx] = createPeerData(data);
}
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
@@ -556,6 +576,51 @@ function handleServerEvent(event, data) {
}
}
break;
case EVENTS.EPISODE_LOBBY:
if (data.senderId && data.expectedTitle) {
addLog(`Episode lobby from ${data.senderId}: "${data.expectedTitle}"`, 'info');
// If we already have a lobby for this same title, treat as dedup
if (episodeLobby && episodeLobby.expectedTitle === data.expectedTitle) {
break; // Already tracking this lobby
}
// Cancel any existing lobby before starting a new one
if (episodeLobby) clearEpisodeLobbyState();
episodeLobby = {
expectedTitle: data.expectedTitle,
initiatorPeerId: data.senderId,
readyPeers: [],
createdAt: Date.now()
};
persistEpisodeLobby();
broadcastLobbyUpdate();
// Start timeout
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), EPISODE_LOBBY_TIMEOUT);
// Forward to content script to start polling
if (currentTabId) {
const tabId = parseInt(currentTabId);
if (!isNaN(tabId)) {
chrome.tabs.sendMessage(tabId, {
type: 'EPISODE_LOBBY',
expectedTitle: data.expectedTitle
}).catch(() => {});
}
}
}
break;
case EVENTS.EPISODE_READY:
if (episodeLobby && data.senderId) {
if (!episodeLobby.readyPeers.includes(data.senderId)) {
episodeLobby.readyPeers.push(data.senderId);
persistEpisodeLobby();
broadcastLobbyUpdate();
addLog(`Episode ready from ${data.senderId} (${episodeLobby.readyPeers.length})`, 'info');
checkEpisodeLobbyCompletion();
}
}
break;
default:
addLog(`Received unknown event from server: ${event}`, 'warn');
break;
@@ -576,6 +641,102 @@ function executeForceSync() {
addLog('Force Sync Executed', 'success');
}
// --- Episode Auto-Sync Lobby Functions ---
function persistEpisodeLobby() {
if (storageInitialized) chrome.storage.session.set({ episodeLobby });
}
function broadcastLobbyUpdate() {
chrome.runtime.sendMessage({ type: 'LOBBY_UPDATE', lobby: episodeLobby }).catch(() => {});
}
function clearEpisodeLobbyState() {
if (episodeLobbyTimeout) clearTimeout(episodeLobbyTimeout);
episodeLobbyTimeout = null;
episodeLobby = null;
if (storageInitialized) chrome.storage.session.set({ episodeLobby: null });
broadcastLobbyUpdate();
// Notify content script to stop polling
if (currentTabId) {
const tabId = parseInt(currentTabId);
if (!isNaN(tabId)) {
chrome.tabs.sendMessage(tabId, { type: 'EPISODE_LOBBY_CANCEL' }).catch(() => {});
}
}
}
function cancelEpisodeLobby(reason) {
if (!episodeLobby) return;
const title = episodeLobby.expectedTitle;
clearEpisodeLobbyState();
addLog(`Episode lobby cancelled: ${reason} for "${title}"`, 'warn');
// Chrome notification on failure (per Q2: only notify on failure)
chrome.notifications.create(`episode_${Date.now()}`, {
type: 'basic',
iconUrl: 'icons/icon128.png',
title: 'KoalaSync — Episode Sync Failed',
message: `Auto-sync cancelled: ${reason}. You may need to manually sync.`,
priority: 1
});
}
function executeEpisodeLobby() {
if (!episodeLobby) return;
const title = episodeLobby.expectedTitle;
clearEpisodeLobbyState();
addLog(`Episode lobby complete: Starting "${title}" via Force Sync`, 'success');
// Trigger a standard Force Sync at targetTime 0.0
isForceSyncInitiator = true;
forceSyncAcks.clear();
const deadline = Date.now() + 5000;
chrome.storage.session.set({
isForceSyncInitiator: true,
forceSyncAcks: [],
forceSyncDeadline: deadline
});
const syncPayload = { targetTime: 0.0 };
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId });
routeToContent(EVENTS.FORCE_SYNC_PREPARE, syncPayload);
forceSyncTimeout = setTimeout(() => {
if (isForceSyncInitiator) {
addLog('Force Sync (Episode): Timeout waiting for ACKs, executing anyway...', 'warn');
executeForceSync();
}
}, 5000);
}
function checkEpisodeLobbyCompletion() {
if (!episodeLobby || !currentRoom) return;
const peerCount = currentRoom.peers ? currentRoom.peers.length : 1;
if (episodeLobby.readyPeers.length >= peerCount) {
executeEpisodeLobby();
}
}
function checkEpisodeLobbyPeerDeparture() {
if (!episodeLobby || !currentRoom) return;
const remainingPeerIds = currentRoom.peers.map(p => typeof p === 'object' ? p.peerId : p);
// If only we remain, cancel the lobby
if (remainingPeerIds.length <= 1) {
cancelEpisodeLobby('All other peers left');
return;
}
// Filter readyPeers to only include peers still in the room
episodeLobby.readyPeers = episodeLobby.readyPeers.filter(id => remainingPeerIds.includes(id));
persistEpisodeLobby();
broadcastLobbyUpdate();
// Re-check if all remaining peers are now ready
checkEpisodeLobbyCompletion();
}
function updateLastAction(action, senderId, timestamp = Date.now()) {
lastActionState = {
action,
@@ -641,23 +802,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
}
});
setInterval(async () => {
await ensureState();
// Calling a chrome API keeps the SW alive in MV3 (Chrome 110+)
chrome.storage.session.get('keepAlive', () => {});
if (!socket || socket.readyState !== WebSocket.OPEN) {
connect();
} else if (currentRoom) {
// Redundant heartbeat for active SW state
const settings = await getSettings();
emit(EVENTS.PEER_STATUS, {
peerId,
status: 'heartbeat',
username: settings.username,
tabTitle: currentTabTitle
});
}
}, 30000); // every 30s
// --- Extension Message Listeners ---
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
@@ -703,24 +848,29 @@ async function handleAsyncMessage(message, sender, sendResponse) {
peerId,
peers: currentRoom ? currentRoom.peers : [],
lastActionState,
targetTabId: currentTabId
targetTabId: currentTabId,
episodeLobby: episodeLobby
});
} else if (message.type === 'LEAVE_ROOM') {
emit(EVENTS.LEAVE_ROOM, { peerId });
currentRoom = null;
currentTabId = null;
stopHeartbeat();
updateBadgeStatus();
isForceSyncInitiator = false;
forceSyncAcks.clear();
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
// Cancel any active episode lobby
clearEpisodeLobbyState();
chrome.storage.session.set({
currentRoom: null,
isForceSyncInitiator: false,
forceSyncAcks: [],
forceSyncDeadline: null
forceSyncDeadline: null,
episodeLobby: null
});
addLog('Left Room', 'info');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
@@ -878,6 +1028,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
me.tabTitle = currentTabTitle;
me.username = settings.username;
me.mediaTitle = message.payload.mediaTitle;
me.playbackState = message.payload.playbackState;
me.currentTime = message.payload.currentTime;
me.volume = message.payload.volume;
me.muted = message.payload.muted;
me.lastHeartbeat = Date.now();
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
}
@@ -902,6 +1057,96 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else if (message.type === 'LOG') {
addLog(`[Content] ${message.message}`, message.level || 'info');
sendResponse({ status: 'ok' });
} else if (message.type === 'EPISODE_CHANGED') {
// Content script detected an episode transition
if (sender.tab) {
const senderTabId = sender.tab.id;
if (!currentTabId || currentTabId !== senderTabId) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
}
const newTitle = message.payload && message.payload.newTitle;
if (!newTitle) {
sendResponse({ status: 'no_title' });
return;
}
// Check setting
const epSettings = await chrome.storage.sync.get(['autoSyncNextEpisode']);
if (!epSettings.autoSyncNextEpisode) {
addLog(`Episode change detected ("${newTitle}") but Auto-Sync is disabled.`, 'info');
sendResponse({ status: 'disabled' });
return;
}
// If lobby already exists for this title, just mark self ready
if (episodeLobby && episodeLobby.expectedTitle === newTitle) {
if (!episodeLobby.readyPeers.includes(peerId)) {
episodeLobby.readyPeers.push(peerId);
persistEpisodeLobby();
broadcastLobbyUpdate();
emit(EVENTS.EPISODE_READY, { peerId, title: newTitle });
checkEpisodeLobbyCompletion();
}
sendResponse({ status: 'ready_sent' });
return;
}
// Cancel any existing lobby for a different episode
if (episodeLobby) clearEpisodeLobbyState();
// Create new lobby
episodeLobby = {
expectedTitle: newTitle,
initiatorPeerId: peerId,
readyPeers: [peerId], // We are already ready
createdAt: Date.now()
};
persistEpisodeLobby();
broadcastLobbyUpdate();
addLog(`Episode lobby created: "${newTitle}"`, 'info');
// Tell content script to pause the video and start polling
// (This is the only place we pause — after confirming the feature is enabled)
if (sender.tab && sender.tab.id) {
chrome.tabs.sendMessage(sender.tab.id, {
type: 'PAUSE_FOR_LOBBY',
expectedTitle: newTitle
}).catch(() => {});
}
// Broadcast to room
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: newTitle });
// Start timeout (Q1: Option B — cancel on timeout)
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout — not all peers loaded the episode'), EPISODE_LOBBY_TIMEOUT);
// Immediate check — maybe we're the only one in the room
checkEpisodeLobbyCompletion();
sendResponse({ status: 'lobby_created' });
} else if (message.type === 'EPISODE_READY_LOCAL') {
// Content script confirmed it loaded the lobby episode
if (episodeLobby && message.payload && message.payload.title === episodeLobby.expectedTitle) {
if (!episodeLobby.readyPeers.includes(peerId)) {
episodeLobby.readyPeers.push(peerId);
persistEpisodeLobby();
broadcastLobbyUpdate();
emit(EVENTS.EPISODE_READY, { peerId, title: message.payload.title });
addLog(`Local episode ready: "${message.payload.title}"`, 'success');
checkEpisodeLobbyCompletion();
}
}
sendResponse({ status: 'ok' });
} else if (message.type === 'CONTENT_BOOT') {
// Content script re-injected, check if there's an active lobby
if (episodeLobby) {
sendResponse({ lobbyActive: true, expectedTitle: episodeLobby.expectedTitle });
} else {
sendResponse({ lobbyActive: false });
}
} else {
// Final fallback to prevent channel hanging
sendResponse({ error: 'unhandled_message' });
+214 -3
View File
@@ -22,12 +22,27 @@
FORCE_SYNC_PREPARE: "force_sync_prepare",
FORCE_SYNC_ACK: "force_sync_ack",
FORCE_SYNC_EXECUTE: "force_sync_execute",
PEER_STATUS: "peer_status"
PEER_STATUS: "peer_status",
EPISODE_LOBBY: "episode_lobby",
EPISODE_READY: "episode_ready"
};
let expectedEvents = new Set();
let expectedTimeouts = {};
// --- Seek Relay Filtering ---
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
// from being relayed to peers as user-initiated seeks.
const MIN_SEEK_DELTA = 3.0;
let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK
let seekDebounceTimer = null; // debounce timer for rapid seek events
// --- Episode Auto-Sync State ---
let lastKnownMediaTitle = null;
let episodeTransitionDebounce = null;
let pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
let lobbyPollTimer = null;
function expectEvent(state) {
expectedEvents.add(state);
if (expectedTimeouts[state]) clearTimeout(expectedTimeouts[state]);
@@ -47,6 +62,100 @@
return videos.length > 0 ? videos[0] : null;
}
// --- Episode Auto-Sync: Detection ---
function getMediaTitle() {
return (navigator.mediaSession && navigator.mediaSession.metadata)
? navigator.mediaSession.metadata.title
: null;
}
function checkEpisodeTransition() {
const currentTitle = getMediaTitle();
const video = findVideo();
// Only trigger if: we had a previous title, the title changed,
// a video exists, and we're near the start of new content.
if (lastKnownMediaTitle && currentTitle
&& currentTitle !== lastKnownMediaTitle
&& video
&& video.currentTime < 5
&& video.readyState >= 1) {
onEpisodeTransition(currentTitle);
}
// Always track the latest known title
if (currentTitle) lastKnownMediaTitle = currentTitle;
}
function onEpisodeTransition(newTitle) {
// Debounce: prevent duplicate fires from multiple signals
if (episodeTransitionDebounce) return;
episodeTransitionDebounce = setTimeout(() => {
episodeTransitionDebounce = null;
}, 2000);
reportLog(`Episode transition detected: "${newTitle}"`, 'info');
// Do NOT pause here. We notify background.js first.
// Background checks the setting; if enabled it creates a lobby
// and sends back PAUSE_FOR_LOBBY so we only freeze if the feature is on.
chrome.runtime.sendMessage({
type: 'EPISODE_CHANGED',
payload: { newTitle }
}).catch(() => {});
}
function checkAndReportLobbyReady(expectedTitle) {
const video = findVideo();
const currentTitle = getMediaTitle();
if (video && currentTitle && currentTitle === expectedTitle
&& video.currentTime < 5 && video.readyState >= 1) {
// Match! Pause at start and report ready.
if (!video.paused) {
expectEvent('paused');
video.pause();
}
stopLobbyPoll();
chrome.runtime.sendMessage({
type: 'EPISODE_READY_LOCAL',
payload: { title: currentTitle }
}).catch(() => {});
reportLog(`Episode lobby: Ready for "${currentTitle}"`, 'success');
return true;
}
return false;
}
function startLobbyPoll(expectedTitle) {
stopLobbyPoll();
pendingLobbyTitle = expectedTitle;
// NOTE: Do NOT pause here. Three callers reach this function:
// 1. PAUSE_FOR_LOBBY (initiator): already paused by that handler before calling us.
// 2. EPISODE_LOBBY (non-initiator): peer may still be on the PREVIOUS episode — pausing
// would freeze them mid-episode. The pause happens inside checkAndReportLobbyReady()
// only once their title actually matches.
// 3. CONTENT_BOOT recovery: same reasoning as (2).
// Check immediately
if (checkAndReportLobbyReady(expectedTitle)) return;
// Poll every 2 seconds — no log spam, internal only
lobbyPollTimer = setInterval(() => {
checkAndReportLobbyReady(expectedTitle);
}, 2000);
}
function stopLobbyPoll() {
pendingLobbyTitle = null;
if (lobbyPollTimer) {
clearInterval(lobbyPollTimer);
lobbyPollTimer = null;
}
}
// --- Helper: YouTube/Twitch specific actions ---
function tryMediaAction(action, data) {
const video = findVideo();
@@ -181,11 +290,45 @@
});
}
} 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 });
}
}
// Episode Auto-Sync: Lobby notification from background
if (message.type === 'EPISODE_LOBBY') {
const expectedTitle = message.expectedTitle;
if (expectedTitle) {
reportLog(`Episode lobby received: waiting for "${expectedTitle}"`, 'info');
startLobbyPoll(expectedTitle);
}
sendResponse({ status: 'ok' });
return true;
}
// Episode Auto-Sync: Lobby cancelled by background
if (message.type === 'EPISODE_LOBBY_CANCEL') {
stopLobbyPoll();
sendResponse({ status: 'ok' });
return true;
}
// Episode Auto-Sync: Background confirmed lobby created, pause the video
if (message.type === 'PAUSE_FOR_LOBBY') {
const video = findVideo();
if (video && !video.paused) {
expectEvent('paused');
video.pause();
}
// Start lobby poll now that we know the feature is enabled
if (message.expectedTitle) {
startLobbyPoll(message.expectedTitle);
}
sendResponse({ status: 'ok' });
return true;
}
if (message.type === 'GET_VIDEO_STATE') {
const video = findVideo();
if (video) {
@@ -211,6 +354,7 @@
duration: video.duration || 0,
readyState: video.readyState,
muted: video.muted,
volume: video.volume,
playbackRate: video.playbackRate,
url: window.location.href,
id: video.id || 'none',
@@ -257,22 +401,75 @@
const handlePlay = () => reportEvent(EVENTS.PLAY);
const handlePause = () => reportEvent(EVENTS.PAUSE);
const handleSeeked = () => reportEvent(EVENTS.SEEK);
// Seek filtering: ignore HLS/DASH buffering micro-seeks.
// Only relay if delta >= MIN_SEEK_DELTA AND not already debouncing.
const handleSeeked = () => {
const video = findVideo();
if (!video) return;
const current = video.currentTime;
if (!Number.isFinite(current)) return;
// Step 1: Check expectedEvents (programmatic seek suppression)
if (expectedEvents.has('seek')) {
expectedEvents.delete('seek');
lastReportedSeekTime = current; // Update baseline so next user seek is relative to here
// No log — this is routine programmatic behavior (Force Sync, lobby, peer command)
return;
}
const delta = lastReportedSeekTime !== null ? Math.abs(current - lastReportedSeekTime) : null;
const deltaStr = delta !== null ? `Δ${delta.toFixed(2)}s` : 'Δ?';
// Step 2: Delta check — skip micro-seeks (buffering, chapter markers, etc.)
if (lastReportedSeekTime !== null && delta < MIN_SEEK_DELTA) {
reportLog(`[Seek] Filtered (${deltaStr} < ${MIN_SEEK_DELTA}s threshold) @ ${current.toFixed(2)}s — not relayed`, 'warn');
return;
}
// Step 3: Debounce rapid consecutive seeks (e.g. scrubbing)
// — wait 800ms for the user to settle before relaying
if (seekDebounceTimer) clearTimeout(seekDebounceTimer);
seekDebounceTimer = setTimeout(() => {
seekDebounceTimer = null;
const v = findVideo();
if (!v) return;
const settled = v.currentTime;
const finalDelta = lastReportedSeekTime !== null ? Math.abs(settled - lastReportedSeekTime) : null;
const finalDeltaStr = finalDelta !== null ? `Δ${finalDelta.toFixed(2)}s` : 'Δ?';
lastReportedSeekTime = settled;
reportLog(`[Seek] Relayed @ ${settled.toFixed(2)}s (${finalDeltaStr})`, 'info');
reportEvent(EVENTS.SEEK);
}, 800);
};
let lastVideoSrc = null;
// Episode detection handler for loadeddata event
const handleLoadedData = () => {
checkEpisodeTransition();
};
function setupListeners() {
const video = findVideo();
if (video) {
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('seeked', handleSeeked);
video.removeEventListener('loadeddata', handleLoadedData);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('seeked', handleSeeked);
video.addEventListener('loadeddata', handleLoadedData);
video.dataset.koalaAttached = 'true';
lastVideoSrc = video.currentSrc || video.src;
// Initialize episode tracking title on first attach
if (!lastKnownMediaTitle) {
lastKnownMediaTitle = getMediaTitle();
}
}
}
@@ -288,6 +485,10 @@
const currentSrc = video.currentSrc || video.src;
if (!video.dataset.koalaAttached || (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc)) {
// If src changed, also check for episode transition
if (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc) {
checkEpisodeTransition();
}
setupListeners();
}
}
@@ -314,7 +515,9 @@
payload: {
playbackState: video.paused ? 'paused' : 'playing',
currentTime: video.currentTime,
mediaTitle: mediaTitle
mediaTitle: mediaTitle,
volume: video.volume,
muted: video.muted
}
}).catch(err => {
if (err.message.includes('Extension context invalidated')) {
@@ -332,4 +535,12 @@
// Initial Setup
setupListeners();
// Episode Auto-Sync: Boot recovery — check if background has an active lobby
chrome.runtime.sendMessage({ type: 'CONTENT_BOOT' }, (res) => {
if (res && res.lobbyActive && res.expectedTitle) {
reportLog(`Boot: Active lobby detected for "${res.expectedTitle}"`, 'info');
startLobbyPoll(res.expectedTitle);
}
});
})();
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "KoalaSync",
"version": "1.1.2",
"version": "1.2.1",
"description": "Synchronize video playback across different tabs and users.",
"permissions": [
"storage",
@@ -22,10 +22,6 @@
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://koalasync.shik3i.net/*"],
+21 -3
View File
@@ -148,9 +148,6 @@
}
.peer-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #334155;
}
@@ -290,6 +287,16 @@
<div style="text-align:center; color: var(--text-muted); font-size: 11px; padding-top: 20px;">No recent commands</div>
</div>
<!-- Episode Auto-Sync Lobby Status -->
<div id="episodeLobbyCard" class="info-card" style="display:none; margin-bottom: 15px; border-left: 4px solid var(--star); animation: fadeIn 0.3s ease-out;">
<div style="display:flex; align-items:center; gap: 6px; margin-bottom: 6px;">
<span style="font-size: 16px;"></span>
<span style="font-weight: 700; color: var(--star); font-size: 12px;">EPISODE LOBBY</span>
</div>
<div id="lobbyTitle" style="font-size: 11px; color: var(--text); margin-bottom: 6px; font-weight: 600;"></div>
<div id="lobbyPeerStatus" style="font-size: 10px; color: var(--text-muted);"></div>
</div>
<div id="peerListSync" class="info-card" style="display:none;"></div>
</div>
@@ -304,10 +311,16 @@
<label style="margin-bottom: 0;">Filter Noise Tabs</label>
<input type="checkbox" id="filterNoise" style="width: auto;" checked>
</div>
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
<label style="margin-bottom: 0;">Auto-Sync Next Episode</label>
<input type="checkbox" id="autoSyncNextEpisode" style="width: auto;">
</div>
<div style="font-size: 11px; color: var(--text-muted); padding: 8px;">
<p>• Username helps others identify you.</p>
<p>• Noise filtering uses a blacklist to hide common non-video sites (e.g. Search, Social Media) from the Target Tab selector.</p>
<p>• Auto-Sync will pause and wait for all peers when an episode changes, then sync-start together.</p>
</div>
<div style="margin-top: 15px; padding: 8px; border-top: 1px solid var(--card);">
@@ -344,6 +357,11 @@
<button id="clearLogs" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;">CLEAR</button>
</div>
<div id="logList"></div>
<div style="margin-top: 20px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px;">
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
<div id="appVersion" style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;">v0.0.0</div>
</div>
</div>
<script src="popup.js" type="module"></script>
+168 -13
View File
@@ -40,7 +40,11 @@ const elements = {
peerListSync: document.getElementById('peerListSync'),
videoDebug: document.getElementById('videoDebug'),
playBtn: document.getElementById('playBtn'),
pauseBtn: document.getElementById('pauseBtn')
pauseBtn: document.getElementById('pauseBtn'),
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
lobbyTitle: document.getElementById('lobbyTitle'),
lobbyPeerStatus: document.getElementById('lobbyPeerStatus')
};
let localPeerId = null;
@@ -49,12 +53,19 @@ let lastPeersJson = null;
// --- Initialization ---
async function init() {
// Load Settings
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username']);
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode']);
elements.serverUrl.value = data.serverUrl || '';
elements.roomId.value = data.roomId || '';
elements.password.value = data.password || '';
elements.username.value = data.username || '';
elements.filterNoise.checked = data.filterNoise !== false;
elements.autoSyncNextEpisode.checked = !!data.autoSyncNextEpisode;
// Set Version Info
const versionEl = document.getElementById('appVersion');
if (versionEl) {
versionEl.textContent = `v${chrome.runtime.getManifest().version}`;
}
if (data.useCustomServer) {
setServerMode(true);
@@ -77,6 +88,9 @@ async function init() {
// Populate Tabs using the background's targetTabId
await populateTabs(res.peers, res.targetTabId);
// Render lobby status if active
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
} else {
await populateTabs();
}
@@ -188,11 +202,56 @@ function updateLastActionUI(state, peers) {
elements.lastActionCard.appendChild(grid);
}
function formatTime(seconds) {
if (seconds === null || seconds === undefined || isNaN(seconds)) return '--:--';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
function getVolumeIcon(volume, muted) {
if (muted || volume === 0) return '🔇';
if (volume < 0.33) return '🔈';
if (volume < 0.66) return '🔉';
return '🔊';
}
let activePeers = [];
let interpolationInterval = null;
function startInterpolation() {
if (interpolationInterval) return;
interpolationInterval = setInterval(() => {
const timeElements = document.querySelectorAll('.peer-time-display');
timeElements.forEach(el => {
const peerId = el.dataset.peerId;
const peer = activePeers.find(p => p.peerId === peerId);
if (peer && peer.playbackState === 'playing' && peer.currentTime != null && peer.lastHeartbeat) {
const elapsed = (Date.now() - peer.lastHeartbeat) / 1000;
el.textContent = formatTime(peer.currentTime + elapsed);
}
});
}, 1000);
}
function updatePeerList(peers) {
if (!peers) return;
activePeers = peers;
if (!interpolationInterval) startInterpolation();
// UI Throttle: Only re-render if the peer state actually changed
const currentPeersJson = JSON.stringify(peers);
// UI Throttle: Only re-render if the peer state actually changed (excluding time interpolation)
const stateToHash = peers.map(p => ({
id: p.peerId,
user: p.username,
tab: p.tabTitle,
media: p.mediaTitle,
state: p.playbackState,
vol: p.volume,
muted: p.muted
}));
const currentPeersJson = JSON.stringify(stateToHash);
if (currentPeersJson === lastPeersJson) return;
lastPeersJson = currentPeersJson;
@@ -213,10 +272,10 @@ function updatePeerList(peers) {
const peerItem = document.createElement('div');
peerItem.className = 'peer-item';
peerItem.style.cssText = 'display:block; padding: 6px 0;';
peerItem.style.cssText = 'position:relative; display:block; padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.05);';
const header = document.createElement('div');
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center;';
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding-right: 24px;';
const nameSpan = document.createElement('span');
if (pUsername) {
@@ -235,29 +294,72 @@ function updatePeerList(peers) {
header.appendChild(nameSpan);
// Volume Icon (Top Right)
if (p.volume !== undefined && p.volume !== null) {
const volIcon = document.createElement('div');
volIcon.style.cssText = 'position:absolute; top:8px; right:0; cursor:help; font-size:14px;';
volIcon.textContent = getVolumeIcon(p.volume, p.muted);
volIcon.title = p.muted ? 'Muted' : `Volume: ${Math.round(p.volume * 100)}%`;
peerItem.appendChild(volIcon);
}
if (pId === localPeerId) {
const you = document.createElement('span');
you.style.cssText = 'font-size:10px; color:var(--accent)';
you.style.cssText = 'font-size:10px; color:var(--accent); font-weight:bold;';
you.textContent = 'YOU';
header.appendChild(you);
}
peerItem.appendChild(header);
// Media Info
if (p.mediaTitle) {
const mediaDiv = document.createElement('div');
mediaDiv.style.cssText = 'font-size:11px; color:var(--star); font-weight: 600; margin-top: 2px;';
mediaDiv.style.cssText = 'font-size:11px; color:var(--star); font-weight: 600; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px;';
mediaDiv.textContent = `🎬 ${p.mediaTitle}`;
peerItem.appendChild(mediaDiv);
}
if (pTabTitle) {
const titleDiv = document.createElement('div');
titleDiv.style.cssText = 'font-size:10px; color:var(--text-muted); opacity: 0.8;';
titleDiv.textContent = p.mediaTitle ? `via ${pTabTitle}` : pTabTitle;
peerItem.appendChild(titleDiv);
// Status Line (Play/Pause + Time)
const statusLine = document.createElement('div');
statusLine.style.cssText = 'display:flex; align-items:center; gap:8px; margin-top:4px;';
if (p.playbackState) {
const stateIcon = document.createElement('span');
stateIcon.style.fontSize = '10px';
if (p.playbackState === 'playing') {
stateIcon.textContent = '▶';
stateIcon.style.color = 'var(--success)';
} else {
stateIcon.textContent = '⏸';
stateIcon.style.color = 'var(--error)';
}
statusLine.appendChild(stateIcon);
}
if (p.currentTime !== undefined && p.currentTime !== null) {
const timeSpan = document.createElement('span');
timeSpan.className = 'peer-time-display';
timeSpan.dataset.peerId = pId;
timeSpan.style.cssText = 'font-size:11px; font-family:monospace; color:var(--text-muted);';
let displayTime = p.currentTime;
if (p.playbackState === 'playing' && p.lastHeartbeat && p.currentTime != null) {
const elapsed = (Date.now() - p.lastHeartbeat) / 1000;
displayTime += elapsed;
}
timeSpan.textContent = formatTime(displayTime);
statusLine.appendChild(timeSpan);
}
if (pTabTitle) {
const titleDiv = document.createElement('span');
titleDiv.style.cssText = 'font-size:10px; color:var(--text-muted); opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: right;';
titleDiv.textContent = pTabTitle;
statusLine.appendChild(titleDiv);
}
peerItem.appendChild(statusLine);
container.appendChild(peerItem);
});
};
@@ -536,6 +638,10 @@ elements.filterNoise.addEventListener('change', () => {
});
});
elements.autoSyncNextEpisode.addEventListener('change', () => {
chrome.storage.sync.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
});
elements.serverUrl.addEventListener('input', () => {
chrome.storage.sync.set({ serverUrl: elements.serverUrl.value });
});
@@ -789,6 +895,15 @@ chrome.runtime.onMessage.addListener((msg) => {
// Join failed: reset UI state
updateUI(null, null);
}
} else if (msg.type === 'LOBBY_UPDATE') {
// Episode lobby state changed
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
if (res && res.peers) {
updateLobbyUI(msg.lobby, res.peers);
} else {
updateLobbyUI(msg.lobby, []);
}
});
}
});
@@ -883,3 +998,43 @@ function refreshDebugInfo() {
init();
setInterval(refreshLogs, 5000);
// --- Episode Lobby UI ---
function updateLobbyUI(lobby, peers) {
if (!elements.episodeLobbyCard) return;
if (!lobby) {
elements.episodeLobbyCard.style.display = 'none';
return;
}
elements.episodeLobbyCard.style.display = 'block';
elements.lobbyTitle.textContent = `\u{1F3AC} Waiting for: "${lobby.expectedTitle}"`;
// Build peer readiness list
const readySet = new Set(lobby.readyPeers || []);
const peerLines = [];
if (peers && peers.length > 0) {
peers.forEach(p => {
const pId = typeof p === 'object' ? p.peerId : p;
const pName = (typeof p === 'object' && p.username) ? p.username : pId;
const isReady = readySet.has(pId);
const icon = isReady ? '\u2705' : '\u23f3';
const label = isReady ? 'Ready' : 'Loading...';
peerLines.push(`${icon} ${pName} \u2014 ${label}`);
});
}
if (peerLines.length > 0) {
elements.lobbyPeerStatus.textContent = peerLines.join(' | ');
} else {
elements.lobbyPeerStatus.textContent = 'Waiting for peers...';
}
// Show elapsed time
if (lobby.createdAt) {
const elapsed = Math.floor((Date.now() - lobby.createdAt) / 1000);
elements.lobbyPeerStatus.textContent += ` (${elapsed}s)`;
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "koalasync",
"version": "1.2.1",
"description": "KoalaSync Build Scripts",
"private": true,
"scripts": {
"build:extension": "node scripts/build-extension.js"
},
"devDependencies": {
"archiver": "^7.0.1",
"fs-extra": "^11.2.0"
}
}
+123
View File
@@ -0,0 +1,123 @@
const fs = require('fs');
const path = require('path');
const archiver = require('archiver');
const rootDir = path.join(__dirname, '..');
const extDir = path.join(rootDir, 'extension');
const distDir = path.join(rootDir, 'dist');
const baseManifestPath = path.join(extDir, 'manifest.base.json');
// Ensure dist directory exists
if (fs.existsSync(distDir)) {
fs.rmSync(distDir, { recursive: true, force: true });
}
fs.mkdirSync(distDir, { recursive: true });
// Sync shared constants from root /shared to /extension/shared
console.log('Syncing protocol constants...');
const masterSharedDir = path.join(rootDir, 'shared');
const extSharedDir = path.join(extDir, 'shared');
if (!fs.existsSync(extSharedDir)) {
fs.mkdirSync(extSharedDir, { recursive: true });
}
const sharedFiles = ['constants.js', 'blacklist.js'];
for (const file of sharedFiles) {
const src = path.join(masterSharedDir, file);
const dest = path.join(extSharedDir, file);
if (!fs.existsSync(src)) {
throw new Error(`CRITICAL: Source shared file missing: ${src}. Aborting build to prevent broken artifacts.`);
}
fs.copyFileSync(src, dest);
}
console.log('✓ constants.js and blacklist.js synced to extension/shared/');
// Read the base manifest
const baseManifest = JSON.parse(fs.readFileSync(baseManifestPath, 'utf8'));
// Helper to copy files, ignoring manifest.json and manifest.base.json
function copyExtensionFiles(targetDir) {
fs.mkdirSync(targetDir, { recursive: true });
const items = fs.readdirSync(extDir);
for (const item of items) {
if (item === 'manifest.json' || item === 'manifest.base.json') continue;
const srcPath = path.join(extDir, item);
const destPath = path.join(targetDir, item);
if (fs.lstatSync(srcPath).isDirectory()) {
fs.cpSync(srcPath, destPath, { recursive: true });
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
// Helper to zip a directory
function zipDirectory(sourceDir, outPath) {
return new Promise((resolve, reject) => {
const archive = archiver('zip', { zlib: { level: 9 } });
const stream = fs.createWriteStream(outPath);
archive
.directory(sourceDir, false)
.on('error', err => reject(err))
.pipe(stream);
stream.on('close', () => resolve());
archive.finalize();
});
}
async function buildBrowser(browserName, manifestModifier) {
console.log(`Building for ${browserName}...`);
const browserDistDir = path.join(distDir, browserName);
// 1. Copy files
copyExtensionFiles(browserDistDir);
// 2. Modify and write manifest
const browserManifest = manifestModifier(JSON.parse(JSON.stringify(baseManifest)));
fs.writeFileSync(
path.join(browserDistDir, 'manifest.json'),
JSON.stringify(browserManifest, null, 2)
);
// 3. Zip it
const zipPath = path.join(distDir, `koalasync-${browserName}.zip`);
await zipDirectory(browserDistDir, zipPath);
console.log(`Successfully built and zipped ${browserName} -> ${zipPath}`);
}
async function run() {
try {
// Build Chrome
await buildBrowser('chrome', (manifest) => {
manifest.background = {
service_worker: "background.js",
type: "module"
};
return manifest;
});
// Build Firefox
await buildBrowser('firefox', (manifest) => {
manifest.background = {
scripts: ["background.js"],
type: "module"
};
manifest.browser_specific_settings = {
gecko: {
id: "koalasync@shik3i.net"
}
};
return manifest;
});
console.log('Build complete!');
} catch (error) {
console.error('Build failed:', error);
process.exit(1);
}
}
run();
-12
View File
@@ -1,12 +0,0 @@
@echo off
REM KoalaSync - Protocol Synchronization Script (Windows)
REM
REM This script copies the master constants.js file from the shared directory
REM to the extension directory. Since Chrome Extensions cannot load files
REM outside their root, this manual sync is required after any changes to
REM the shared protocol.
if not exist extension\shared mkdir extension\shared
copy /y shared\constants.js extension\shared\constants.js
copy /y shared\blacklist.js extension\shared\blacklist.js
echo ✓ constants.js and blacklist.js synced to extension\shared\
-12
View File
@@ -1,12 +0,0 @@
#!/bin/sh
# KoalaSync - Protocol Synchronization Script (Linux/macOS)
#
# This script copies the master constants.js file from the shared directory
# to the extension directory. Since Chrome Extensions cannot load files
# outside their root, this manual sync is required after any changes to
# the shared protocol.
mkdir -p extension/shared
cp shared/constants.js extension/shared/constants.js
cp shared/blacklist.js extension/shared/blacklist.js
echo "✓ constants.js and blacklist.js synced to extension/shared/"
+107 -65
View File
@@ -23,7 +23,7 @@ const httpServer = createServer(app);
// Socket.IO setup with security constraints
const io = new Server(httpServer, {
cors: {
origin: "*",
origin: ["https://koalasync.shik3i.net"],
methods: ["GET", "POST"]
},
maxHttpBufferSize: 1024, // 1KB max per message
@@ -113,6 +113,51 @@ function checkEventRate(socketId) {
return entry.count <= 30;
}
/**
* Central peer teardown. Removes a socket from all room state and notifies
* remaining peers. Call this from every disconnect/leave/reaper/dedupe path.
*
* @param {string} socketId - The socket.id being removed.
* @param {string} roomId - The room it belongs to.
* @param {string} reason - Log label ('disconnect', 'leave', 'reaper', 'dedupe', 'room-switch').
* @param {boolean} [emitLeave=true] - Set false when the socket.io room leave
* is handled by the caller (e.g. reaper calls
* socket.leave() before us, or dedupe calls
* oldSocket.leave() before disconnecting).
*/
function removePeerFromRoom(socketId, roomId, reason, emitLeave = true) {
const room = rooms.get(roomId);
if (!room) return;
const peerData = room.peerData.get(socketId);
if (!peerData) return; // Already cleaned up
const { peerId } = peerData;
// 1. Remove from room data structures
room.peers.delete(socketId);
room.peerIds.delete(socketId);
room.peerData.delete(socketId);
// 2. Remove from global maps
socketToRoom.delete(socketId);
if (peerToSocket.get(peerId) === socketId) {
peerToSocket.delete(peerId);
}
// 3. Notify remaining peers (use io.to so the removed socket itself
// doesn't receive it — it has already left or is disconnecting)
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
// 4. Delete empty room
if (room.peers.size === 0) {
rooms.delete(roomId);
log('ROOM', `Deleted empty room after ${reason}: ${roomId.substring(0, 3)}***`);
}
log('ROOM', `Peer ${peerId} removed (${reason}) from room ${roomId.substring(0, 3)}***`);
}
io.on('connection', (socket) => {
const clientIp = socket.handshake.address;
@@ -155,7 +200,16 @@ io.on('connection', (socket) => {
return;
}
if (!payload || typeof payload.roomId !== 'string') return;
const { roomId, password, peerId, username, tabTitle, mediaTitle, protocolVersion } = payload;
const { password, peerId, protocolVersion } = payload;
// --- M-2: Sanitize and clamp all string fields ---
const roomId = String(payload.roomId || '').substring(0, 64);
const username = typeof payload.username === 'string' ? payload.username.substring(0, 30) : null;
const tabTitle = typeof payload.tabTitle === 'string' ? payload.tabTitle.substring(0, 100) : null;
const mediaTitle = typeof payload.mediaTitle === 'string' ? payload.mediaTitle.substring(0, 100) : null;
if (!roomId) return; // Guard: empty after sanitization
try {
// Protocol check
if (protocolVersion !== '1.0.0') {
@@ -171,14 +225,7 @@ io.on('connection', (socket) => {
}
if (oldMapping && oldMapping.roomId !== roomId) {
socket.leave(oldMapping.roomId);
const oldRoom = rooms.get(oldMapping.roomId);
if (oldRoom) {
oldRoom.peers.delete(socket.id);
oldRoom.peerIds.delete(socket.id);
oldRoom.peerData.delete(socket.id);
socket.to(oldMapping.roomId).emit(EVENTS.PEER_STATUS, { peerId: oldMapping.peerId, status: 'left' });
if (oldRoom.peers.size === 0) rooms.delete(oldMapping.roomId);
}
removePeerFromRoom(socket.id, oldMapping.roomId, 'room-switch');
}
const ip = socket.handshake.address;
@@ -228,11 +275,7 @@ io.on('connection', (socket) => {
oldSocket.disconnect(true);
log('DEDUPE', `Kicked old session for peer ${peerId}`);
}
room.peers.delete(sid);
room.peerIds.delete(sid);
room.peerData.delete(sid);
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId: data.peerId, status: 'left' });
log('ROOM', `Deduplicated peer ${peerId} from room ${roomId}`);
removePeerFromRoom(sid, roomId, 'dedupe');
}
}
}
@@ -266,7 +309,8 @@ io.on('connection', (socket) => {
const relayEvents = [
EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
EVENTS.PEER_STATUS, EVENTS.FORCE_SYNC_PREPARE,
EVENTS.FORCE_SYNC_ACK, EVENTS.FORCE_SYNC_EXECUTE
EVENTS.FORCE_SYNC_ACK, EVENTS.FORCE_SYNC_EXECUTE,
EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY
];
relayEvents.forEach(eventName => {
@@ -286,12 +330,19 @@ io.on('connection', (socket) => {
room.lastActivity = Date.now();
// Update peer metadata and lastSeen
// Sanitize mutable string fields to enforce the same length
// limits as JOIN_ROOM — the relay path is otherwise unbounded.
const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : val;
const existing = room.peerData.get(socket.id) || { peerId: mapping.peerId };
room.peerData.set(socket.id, {
...existing,
username: data.username !== undefined ? data.username : existing.username,
tabTitle: data.tabTitle !== undefined ? data.tabTitle : existing.tabTitle,
mediaTitle: data.mediaTitle !== undefined ? data.mediaTitle : existing.mediaTitle,
username: data.username !== undefined ? clamp(data.username, 30) : existing.username,
tabTitle: data.tabTitle !== undefined ? clamp(data.tabTitle, 100) : existing.tabTitle,
mediaTitle: data.mediaTitle !== undefined ? clamp(data.mediaTitle, 100) : existing.mediaTitle,
playbackState: data.playbackState !== undefined ? data.playbackState : existing.playbackState,
currentTime: data.currentTime !== undefined ? data.currentTime : existing.currentTime,
volume: data.volume !== undefined ? data.volume : existing.volume,
muted: data.muted !== undefined ? data.muted : existing.muted,
lastSeen: Date.now()
});
@@ -313,23 +364,8 @@ io.on('connection', (socket) => {
socket.on(EVENTS.LEAVE_ROOM, () => {
const mapping = socketToRoom.get(socket.id);
if (mapping) {
const { roomId, peerId } = mapping;
socket.leave(roomId);
const room = rooms.get(roomId);
if (room) {
room.peers.delete(socket.id);
room.peerIds.delete(socket.id);
room.peerData.delete(socket.id);
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
if (room.peers.size === 0) {
rooms.delete(roomId);
log('ROOM', `Deleted empty room: ${roomId.substring(0, 3)}***`);
}
}
socketToRoom.delete(socket.id);
if (peerToSocket.get(peerId) === socket.id) {
peerToSocket.delete(peerId);
}
socket.leave(mapping.roomId);
removePeerFromRoom(socket.id, mapping.roomId, 'leave');
}
});
@@ -355,22 +391,10 @@ io.on('connection', (socket) => {
eventCounts.delete(socket.id);
const mapping = socketToRoom.get(socket.id);
if (mapping) {
const { roomId, peerId } = mapping;
const room = rooms.get(roomId);
if (room) {
room.peers.delete(socket.id);
room.peerIds.delete(socket.id);
room.peerData.delete(socket.id);
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
if (room.peers.size === 0) {
rooms.delete(roomId);
log('ROOM', `Deleted empty room (after disconnect): ${roomId.substring(0, 3)}***`);
}
}
socketToRoom.delete(socket.id);
if (peerToSocket.get(peerId) === socket.id) {
peerToSocket.delete(peerId);
}
// Socket is already disconnected — no need to call socket.leave().
// removePeerFromRoom uses io.to() for notifications, which correctly
// excludes this dead socket since it has already left all rooms.
removePeerFromRoom(socket.id, mapping.roomId, 'disconnect');
}
});
});
@@ -383,23 +407,21 @@ setInterval(() => {
for (const [roomId, room] of rooms) {
// 1. Prune dead peers
// Snapshot keys first — we must not mutate peerData while iterating it.
const staleSids = [];
for (const [sid, data] of room.peerData.entries()) {
if (data.lastSeen && data.lastSeen < peerCutoff) {
const socket = io.sockets.sockets.get(sid);
if (socket) socket.leave(roomId);
room.peers.delete(sid);
room.peerIds.delete(sid);
room.peerData.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' });
log('CLEANUP', `Pruned dead peer ${data.peerId} from room ${roomId}`);
staleSids.push(sid);
}
}
for (const sid of staleSids) {
// Gracefully evict the socket from the Socket.IO room if it is
// still technically connected (zombie with no heartbeat).
const deadSocket = io.sockets.sockets.get(sid);
if (deadSocket) deadSocket.leave(roomId);
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
removePeerFromRoom(sid, roomId, 'reaper');
}
// 2. Prune empty or inactive rooms
if (room.peers.size === 0 || room.lastActivity < roomCutoff) {
@@ -413,3 +435,23 @@ setInterval(() => {
httpServer.listen(PORT, () => {
log('SERVER', `KoalaSync Relay running on port ${PORT}`);
});
// --- M-4: Graceful Shutdown ---
function gracefulShutdown(signal) {
log('SERVER', `${signal} received — starting graceful shutdown...`);
// 1. Notify all connected clients so they can display a meaningful message
io.emit(EVENTS.ERROR, { message: 'Server is restarting. Please reconnect in a moment.' });
// 2. Stop accepting new HTTP connections
httpServer.close(() => {
log('SERVER', 'HTTP server closed. Exiting.');
process.exit(0);
});
// 3. Safety net: force-exit after 5s if connections don't drain
setTimeout(() => {
log('SERVER', 'Force-exit after timeout.');
process.exit(1);
}, 5000);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
+7 -2
View File
@@ -7,7 +7,7 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "1.1.2";
export const APP_VERSION = "1.2.1";
export const OFFICIAL_SERVER_URL = 'wss://sync.shik3i.net';
export const OFFICIAL_LANDING_PAGE_URL = 'https://koalasync.shik3i.net';
@@ -32,8 +32,13 @@ export const EVENTS = {
FORCE_SYNC_EXECUTE: "force_sync_execute",
EVENT_ACK: "event_ack",
GET_ROOMS: "get_rooms",
ROOM_LIST: "room_list"
ROOM_LIST: "room_list",
// Episode Auto-Sync
EPISODE_LOBBY: "episode_lobby", // Broadcast: waiting for everyone on this episode
EPISODE_READY: "episode_ready" // Response: loaded the episode and paused at 0:00
};
export const HEARTBEAT_INTERVAL = 15000; // 15s
export const FORCE_SYNC_TIMEOUT = 5000; // 5s timeout for ACKs
export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby
+25 -7
View File
@@ -104,12 +104,26 @@ document.addEventListener('DOMContentLoaded', () => {
const banner = document.createElement('div');
banner.className = 'invite-banner';
banner.id = 'koala-banner';
banner.innerHTML = `
<div class="container" style="display:flex; justify-content:space-between; align-items:center;">
<span>🎫 Invitation for <b>${roomId}</b> detected!</span>
<a href="join.html${window.location.hash}" class="btn-banner">OPEN JOIN PAGE</a>
</div>
`;
const container = document.createElement('div');
container.className = 'container';
container.style.cssText = 'display:flex; justify-content:space-between; align-items:center;';
const inviteSpan = document.createElement('span');
inviteSpan.appendChild(document.createTextNode('🎫 Invitation for '));
const boldRoom = document.createElement('b');
boldRoom.textContent = roomId;
inviteSpan.appendChild(boldRoom);
inviteSpan.appendChild(document.createTextNode(' detected!'));
const joinLink = document.createElement('a');
joinLink.href = 'join.html' + window.location.hash;
joinLink.className = 'btn-banner';
joinLink.textContent = 'OPEN JOIN PAGE';
container.appendChild(inviteSpan);
container.appendChild(joinLink);
banner.appendChild(container);
document.body.prepend(banner);
}
}
@@ -178,7 +192,11 @@ document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => window.close(), 3000);
} else {
banner.style.background = 'var(--error)';
banner.innerHTML = `<div class="container">❌ Error: ${message}</div>`;
banner.innerHTML = '';
const errDiv = document.createElement('div');
errDiv.className = 'container';
errDiv.textContent = '❌ Error: ' + message;
banner.appendChild(errDiv);
}
}
}
+7
View File
@@ -38,6 +38,7 @@
<header class="hero">
<div class="container hero-grid">
<div class="hero-text">
<div class="version-badge" data-reveal>v1.2.0 OUT NOW</div>
<h1 data-reveal>Watch Together.<br>Sync Perfectly.</h1>
<p data-reveal>KoalaSync brings friends closer through synchronized video playback. No lag, no tracking, just shared moments.</p>
<div class="cta-group" data-reveal>
@@ -64,6 +65,11 @@
<h3>Real-time Sync</h3>
<p>Proprietary two-phase synchronization protocol ensures sub-millisecond precision across all peers.</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">🎬</div>
<h3>Episode Auto-Sync</h3>
<p>New in v1.2.0: Perfectly sync series binges. All peers wait until everyone has loaded the next episode.</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">🛡️</div>
<h3>Privacy First</h3>
@@ -75,6 +81,7 @@
<p>Find the right tab instantly. KoalaSync highlights and sorts matching video tabs for you.</p>
</div>
</div>
</div>
</section>
+15
View File
@@ -223,6 +223,21 @@ nav {
transform: translateY(-2px);
}
.version-badge {
display: inline-block;
background: rgba(99, 102, 241, 0.1);
color: var(--accent);
padding: 0.5rem 1.25rem;
border-radius: 99px;
font-size: 0.85rem;
font-weight: 700;
margin-bottom: 1.5rem;
border: 1px solid rgba(99, 102, 241, 0.3);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.hero-image {
display: none;
}