mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 20:18:14 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65ad4b5c6b | |||
| c2857dbdda | |||
| d07bf745a3 | |||
| bd54e893b4 | |||
| 50c9ba4ec8 | |||
| 55c2d4ed0d |
@@ -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)
|
||||
|
||||
+264
-7
@@ -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;
|
||||
@@ -29,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;
|
||||
@@ -66,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 = [];
|
||||
}
|
||||
@@ -99,6 +111,10 @@ let isForceSyncInitiator = false;
|
||||
let forceSyncAcks = new Set();
|
||||
let forceSyncTimeout = null;
|
||||
|
||||
// Episode Auto-Sync Lobby
|
||||
let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt }
|
||||
let episodeLobbyTimeout = null;
|
||||
|
||||
// --- Storage Utils ---
|
||||
|
||||
/**
|
||||
@@ -153,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(() => {});
|
||||
@@ -531,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);
|
||||
@@ -555,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;
|
||||
@@ -575,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,
|
||||
@@ -686,7 +848,8 @@ 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 });
|
||||
@@ -699,11 +862,15 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
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(() => {});
|
||||
@@ -890,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' });
|
||||
|
||||
+210
-2
@@ -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) {
|
||||
@@ -258,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,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();
|
||||
}
|
||||
}
|
||||
@@ -335,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.4",
|
||||
"version": "1.2.1",
|
||||
"description": "Synchronize video playback across different tabs and users.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
@@ -287,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>
|
||||
|
||||
@@ -301,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);">
|
||||
|
||||
+63
-2
@@ -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,13 @@ 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');
|
||||
@@ -83,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();
|
||||
}
|
||||
@@ -630,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 });
|
||||
});
|
||||
@@ -883,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, []);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -977,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)`;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -309,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 => {
|
||||
|
||||
+7
-2
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "1.1.4";
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user