Compare commits

...

7 Commits

Author SHA1 Message Date
Koala 98b4fc5fb4 release: v1.9.0 — command sequencing, episode-aware sync, echo suppression
- Add per-peer monotonically increasing seq numbers (localSeq + lastSeqBySender)
- Server relays seq for stale command detection (backward compatible)
- Replace expectedEvents (1500ms timeout) with _suppressTimers (per-type, 300ms)
- Fix FORCE_SYNC_ACK missing seq (stale-ACK guard)
- Fix episode lobby readyPeers asymmetry (initiator now included)
- Add extractEpisodeId() parsing S01E01, Season 1 Ep 1, Folge 5, Ep. 3, #42
- Add isDifferentEpisode() guard blocking cross-episode sync commands
- Add sameEpisode() for format-tolerant lobby title matching
- Guard controlled via autoSyncNextEpisode setting
- Reduce reactive lock 1000ms → 300ms
- Fix routeToContent unbounded retry (max 3 attempts)
- Fix server bcrypt.hash failure crashing join flow
- Fix sameEpisode(null, title) returning true
- Fix forceSyncTimeout leak on rapid force sync clicks
- Persist lastSeqBySender across service worker restarts
- Bump version: 1.8.10 → 1.9.0
2026-05-28 04:54:54 +02:00
Koala 762d6425be fix: audit corrections for sameEpisode, CMD_ACK, lastSeqBySender, forceSyncTimeout
- sameEpisode: return false when one title is null (prevent false lobby completions)
- Episode guard: skip CMD_ACK for FORCE_SYNC_EXECUTE on mismatch (H1)
- Persist lastSeqBySender in chrome.storage.session (M3, survives SW restart)
- Clear forceSyncTimeout before overwriting (H3, pre-existing timer leak)
2026-05-28 04:45:52 +02:00
Koala 1fba2fb69c perf: add command sequence numbers, episode-aware sync guard, fix echo suppression
- Add monotonically increasing seq per peer (localSeq + lastSeqBySender)
- Server relays seq field for stale command detection (backward compatible)
- Replace expectedEvents (1500ms timeout) with _suppressTimers (per-type, 300ms)
- Fix FORCE_SYNC_ACK missing seq (stale-ACK could trigger premature exec)
- Fix episode lobby readyPeers asymmetry (initiator never broadcasted readiness)
- Add extractEpisodeId() parsing S01E01/Season 1 Episode 1/Folge 5 patterns
- Add isDifferentEpisode() guard blocking sync commands across different episodes
- add sameEpisode() for format-tolerant lobby title matching
- Guard controlled by autoSyncNextEpisode setting with disable hint in warning
- Reduce reactive lock from 1000ms to 300ms (seq numbers handle ordering)
2026-05-28 04:30:32 +02:00
Koala eca259281a feat: expand emoji/name system with comprehensive animal mapping
- Create shared/names.js as single source of truth for emoji maps, noun
  lists, adjectives, and username generation (215 animal emoji entries)
- Remove duplicated inline lists from popup.js and background.js; both
  now import getAvatarForName / generateUsername from shared/names.js
- Add names.js to the build script sync list so it stays DRY across
  extension builds
- Fix missing cat emoji: CoolCat now correctly resolves to 🐱
- Sort emoji map keys by length at lookup time to prevent substring
  false-matches (e.g. 'caterpillar' before 'cat')
2026-05-28 03:35:17 +02:00
GitHub Action afd28be2e6 chore(release): update versions to v1.8.10 [skip ci] 2026-05-26 15:42:24 +00:00
Koala cc0265c836 v1.8.10: fix description length, solo peer detection, tab-close peer notify, lastHeartbeat guard, time interpolation stale guard 2026-05-26 17:42:11 +02:00
GitHub Action 6ebff9ab4c chore(release): update versions to v1.8.9 [skip ci] 2026-05-26 01:07:16 +00:00
10 changed files with 535 additions and 88 deletions
+120 -17
View File
@@ -1,4 +1,5 @@
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
import { generateUsername } from './shared/names.js';
// --- State Management ---
let socket = null;
@@ -15,6 +16,12 @@ let pendingHistory = [];
let eventQueue = [];
let isNamespaceJoined = false;
let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] };
let localSeq = 0; // Monotonically increasing command sequence for this peer
const lastSeqBySender = {}; // senderId → last received seq (stale command guard)
function _persistLastSeq() {
if (storageInitialized) chrome.storage.session.set({ lastSeqBySender });
}
// --- Boot Sequence Lock ---
let restorationTask = null;
@@ -35,7 +42,7 @@ function ensureState() {
'logs', 'history', 'currentRoom', 'lastActionState',
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle',
'episodeLobby'
'episodeLobby', 'localSeq', 'lastSeqBySender'
], (data) => {
clearTimeout(storageTimeout);
if (data.currentTabId !== undefined) currentTabId = data.currentTabId;
@@ -84,6 +91,9 @@ function ensureState() {
}
}
if (data.localSeq !== undefined && !isNaN(data.localSeq)) localSeq = data.localSeq;
if (data.lastSeqBySender && typeof data.lastSeqBySender === 'object') Object.assign(lastSeqBySender, data.lastSeqBySender);
storageInitialized = true;
// Process any early logs/history that weren't captured in the spread
@@ -125,6 +135,26 @@ let forceSyncTimeout = null;
let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt }
let episodeLobbyTimeout = null;
// --- Episode Title Extraction (synced with content.js) ---
function extractEpisodeId(title) {
if (!title || typeof title !== 'string') return null;
const se = title.match(/S(?:eason\s*)?(\d+)[\s\-\.]*E(?:pisode\s*)?(\d+)/i);
if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`;
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
return null;
}
function sameEpisode(titleA, titleB) {
if (!titleA && !titleB) return true; // Both unknown → assume same (backward compat)
if (!titleA || !titleB) return false; // One unknown, one known → different
const idA = extractEpisodeId(titleA);
const idB = extractEpisodeId(titleB);
if (idA && idB) return idA === idB; // Both have parseable IDs → compare IDs
if (idA || idB) return false; // One has ID, other doesn't → different
return titleA === titleB; // Neither has ID → exact string match
}
// --- Storage Utils ---
/**
@@ -186,9 +216,7 @@ async function getSettings() {
}
let username = data.username;
if (!username) {
const adjs = ['Happy', 'Cool', 'Fast', 'Smart', 'Brave', 'Calm', 'Sneaky', 'Lazy', 'Wild', 'Chill', 'Lucky', 'Epic', 'Swift', 'Bold', 'Mighty', 'Cosmic', 'Neon', 'Shadow', 'Crystal', 'Thunder', 'Silent', 'Golden', 'Fierce', 'Noble', 'Mystic', 'Frozen', 'Blazing', 'Sapphire', 'Iron', 'Crimson'];
const nouns = ['Koala', 'Panda', 'Tiger', 'Eagle', 'Fox', 'Bear', 'Wolf', 'Lion', 'Hawk', 'Seal', 'Owl', 'Shark', 'Dragon', 'Phoenix', 'Falcon', 'Panther', 'Raven', 'Cobra', 'Lynx', 'Jaguar', 'Orca', 'Mantis', 'Viper', 'Condor', 'Badger', 'Otter', 'Rhino', 'Crane', 'Mongoose', 'Specter'];
username = `${adjs[Math.floor(Math.random() * adjs.length)]}${nouns[Math.floor(Math.random() * nouns.length)]}`;
username = generateUsername();
chrome.storage.sync.set({ username }, () => {
resolve({
serverUrl: data.serverUrl || '',
@@ -576,6 +604,15 @@ function handleServerEvent(event, data) {
case EVENTS.PAUSE:
case EVENTS.SEEK:
case EVENTS.FORCE_SYNC_PREPARE:
if (data.senderId && typeof data.seq === 'number') {
const lastSeq = lastSeqBySender[data.senderId];
if (lastSeq !== undefined && data.seq <= lastSeq) {
addLog(`Ignored stale ${event} from ${data.senderId} (seq ${data.seq} <= ${lastSeq})`, 'warn');
break;
}
lastSeqBySender[data.senderId] = data.seq;
_persistLastSeq();
}
if (data.senderId) {
addToHistory(event, data.senderId);
showNotification(data.senderId, event);
@@ -592,6 +629,12 @@ function handleServerEvent(event, data) {
routeToContent(event, data);
break;
case EVENTS.FORCE_SYNC_ACK:
if (data.senderId && typeof data.seq === 'number') {
const lastSeq = lastSeqBySender[data.senderId];
if (lastSeq !== undefined && data.seq <= lastSeq) break;
lastSeqBySender[data.senderId] = data.seq;
_persistLastSeq();
}
if (isForceSyncInitiator) {
forceSyncAcks.add(data.senderId);
chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) });
@@ -621,6 +664,12 @@ function handleServerEvent(event, data) {
}
break;
case EVENTS.FORCE_SYNC_EXECUTE:
if (data?.senderId && typeof data.seq === 'number') {
const lastSeq = lastSeqBySender[data.senderId];
if (lastSeq !== undefined && data.seq <= lastSeq) break;
lastSeqBySender[data.senderId] = data.seq;
_persistLastSeq();
}
if (data?.senderId) {
addToHistory(event, data.senderId);
showNotification(data.senderId, event);
@@ -701,12 +750,14 @@ function handleServerEvent(event, data) {
peer.muted = data.muted !== undefined ? data.muted : peer.muted;
const timeSinceReactive = peer.lastReactiveUpdate ? (Date.now() - peer.lastReactiveUpdate) : Infinity;
const ignoreStatus = timeSinceReactive < 1000;
const ignoreStatus = timeSinceReactive < 300;
if (!ignoreStatus) {
peer.playbackState = data.playbackState !== undefined ? data.playbackState : peer.playbackState;
peer.currentTime = data.currentTime !== undefined ? data.currentTime : peer.currentTime;
peer.lastHeartbeat = Date.now();
if (data.playbackState !== undefined || data.currentTime !== undefined) {
peer.lastHeartbeat = Date.now();
}
}
} else {
// Migration: replace string peer with normalized object
@@ -723,7 +774,7 @@ function handleServerEvent(event, data) {
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) {
if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, data.expectedTitle)) {
break; // Already tracking this lobby
}
// Cancel any existing lobby before starting a new one
@@ -732,7 +783,7 @@ function handleServerEvent(event, data) {
episodeLobby = {
expectedTitle: data.expectedTitle,
initiatorPeerId: data.senderId,
readyPeers: [],
readyPeers: [data.senderId], // Initiator is already ready
createdAt: Date.now()
};
persistEpisodeLobby();
@@ -795,8 +846,11 @@ function executeForceSync() {
const executionTimestamp = Date.now();
updateLastAction(EVENTS.FORCE_SYNC_EXECUTE, 'You', executionTimestamp);
emit(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp });
routeToContent(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp });
localSeq++;
chrome.storage.session.set({ localSeq });
emit(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp, seq: localSeq });
routeToContent(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp, seq: localSeq });
addLog('Force Sync Executed', 'success');
}
@@ -861,8 +915,10 @@ function executeEpisodeLobby() {
});
const syncPayload = { targetTime: 0.0 };
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId, actionTimestamp: timestamp });
routeToContent(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, actionTimestamp: timestamp });
localSeq++;
chrome.storage.session.set({ localSeq });
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId, actionTimestamp: timestamp, seq: localSeq });
routeToContent(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, actionTimestamp: timestamp, seq: localSeq });
forceSyncTimeout = setTimeout(() => {
if (isForceSyncInitiator) {
@@ -920,6 +976,10 @@ async function routeToContent(action, payload) {
const actionTimestamp = payload?.actionTimestamp || Date.now();
const commandSenderId = payload?.senderId || null;
_routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, 0);
}
function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries) {
chrome.tabs.sendMessage(tabId, {
type: 'SERVER_COMMAND',
action,
@@ -927,12 +987,18 @@ async function routeToContent(action, payload) {
actionTimestamp,
commandSenderId
}).catch(err => {
if (retries >= 3) {
addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn');
currentTabId = null;
updateBadgeStatus();
return;
}
if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) {
chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
}).then(() => {
setTimeout(() => routeToContent(action, payload), 500);
setTimeout(() => _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries + 1), 500);
}).catch(_err => {
addLog(`Auto-reinject failed for tab ${tabId}`, 'warn');
});
@@ -1135,10 +1201,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else if (message.type === 'CONTENT_EVENT') {
const processEvent = () => {
const timestamp = Date.now();
localSeq++;
chrome.storage.session.set({ localSeq });
updateLastAction(message.action, 'You', timestamp);
lastActionState.targetTime = message.payload?.targetTime !== undefined ? message.payload.targetTime : message.payload?.currentTime;
if (storageInitialized) chrome.storage.session.set({ lastActionState });
message.payload.actionTimestamp = timestamp;
message.payload.seq = localSeq;
// Local Reactive Update
updateLocalPeerState(peerId, {
@@ -1159,6 +1228,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
routeToContent(EVENTS.FORCE_SYNC_PREPARE, message.payload);
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
forceSyncTimeout = setTimeout(() => {
if (isForceSyncInitiator) {
addLog('Force Sync: Timeout waiting for ACKs, executing anyway...', 'warn');
@@ -1169,7 +1239,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
addToHistory(message.action, 'You');
const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK;
const hasOtherPeers = currentRoom && Array.isArray(currentRoom.peers) && currentRoom.peers.length > 0;
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
const hasOtherPeers = otherCount > 0;
if (isNonEssentialEvent && !hasOtherPeers) {
sendResponse({ status: 'ok_solo' });
@@ -1215,7 +1286,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
executeForceSync();
}
} else {
emit(EVENTS.FORCE_SYNC_ACK, { peerId });
localSeq++;
chrome.storage.session.set({ localSeq });
emit(EVENTS.FORCE_SYNC_ACK, { peerId, seq: localSeq });
}
sendResponse({ status: 'ok' });
} else if (message.type === 'CMD_ACK') {
@@ -1310,7 +1383,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
// If lobby already exists for this title, just mark self ready
if (episodeLobby && episodeLobby.expectedTitle === newTitle) {
if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, newTitle)) {
if (!episodeLobby.readyPeers.includes(peerId)) {
episodeLobby.readyPeers.push(peerId);
persistEpisodeLobby();
@@ -1357,7 +1430,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
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 && message.payload && sameEpisode(message.payload.title, episodeLobby.expectedTitle)) {
if (!episodeLobby.readyPeers.includes(peerId)) {
episodeLobby.readyPeers.push(peerId);
persistEpisodeLobby();
@@ -1384,11 +1457,41 @@ async function handleAsyncMessage(message, sender, sendResponse) {
// Tab removal listener
chrome.tabs.onRemoved.addListener((tabId) => {
if (tabId === currentTabId) {
const wasInRoom = !!currentRoom;
currentTabId = null;
currentTabTitle = null;
chrome.storage.session.set({ currentTabId: null, currentTabTitle: null });
updateBadgeStatus();
addLog('Target tab closed.', 'warn');
if (wasInRoom) {
const roomAtClose = currentRoom;
getSettings().then(settings => {
if (currentRoom !== roomAtClose) return;
emit(EVENTS.PEER_STATUS, {
peerId,
playbackState: 'paused',
currentTime: null,
mediaTitle: null,
username: settings.username,
tabTitle: null
});
if (currentRoom && Array.isArray(currentRoom.peers)) {
const me = currentRoom.peers.find(p => (p.peerId || p) === peerId);
if (me && typeof me === 'object') {
me.playbackState = 'paused';
me.currentTime = null;
me.mediaTitle = null;
me.tabTitle = null;
me.lastHeartbeat = Date.now();
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
}
}).catch(() => {});
}
}
});
+102 -40
View File
@@ -29,8 +29,25 @@
};
// --- SHARED_EVENTS_INJECT_END ---
let expectedEvents = new Set();
let expectedTimeouts = {};
// Suppresses native event reporting after a programmatic action.
// Each entry is a per-type timer (key = 'playing'|'paused'|'seek').
// While a timer exists, matching native events are consumed and not relayed.
// Timers self-clean after 300ms if the native event never fires.
let _suppressTimers = {};
function _setSuppress(state) {
if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]);
_suppressTimers[state] = setTimeout(() => {
delete _suppressTimers[state];
}, 300);
}
function _clearSuppress(state) {
if (_suppressTimers[state]) {
clearTimeout(_suppressTimers[state]);
delete _suppressTimers[state];
}
}
// --- Seek Relay Filtering ---
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
@@ -44,19 +61,17 @@
let episodeTransitionDebounce = null;
let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
let lobbyPollTimer = null;
let _autoSyncEnabled = true; // Cached setting, updated via storage.onChanged
function expectEvent(state) {
expectedEvents.add(state);
if (expectedTimeouts[state]) {
clearTimeout(expectedTimeouts[state]);
delete expectedTimeouts[state];
// Cache the autoSyncNextEpisode setting
chrome.storage.sync.get(['autoSyncNextEpisode'], (data) => {
_autoSyncEnabled = data.autoSyncNextEpisode !== false; // default: enabled
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'sync' && changes.autoSyncNextEpisode) {
_autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false;
}
const timeout = state === 'seek' ? 10000 : 1500;
expectedTimeouts[state] = setTimeout(() => {
expectedEvents.delete(state);
delete expectedTimeouts[state];
}, timeout);
}
});
function reportLog(message, level = 'info') {
chrome.runtime.sendMessage({ type: 'LOG', message, level }).catch(() => {});
@@ -82,6 +97,43 @@
: null;
}
// Extract a canonical episode identifier from a title string.
// Handles: S01E01, S1E1, S01 - E01, Season 1 Episode 1, "Folge 5", "Episode 5", "Ep. 5", "#5"
// Returns null if no episode pattern found.
function extractEpisodeId(title) {
if (!title || typeof title !== 'string') return null;
// S01E01 patterns (with optional spaces, dashes, dots between S and E)
const se = title.match(/S(?:eason\s*)?(\d+)[\s\-\.]*E(?:pisode\s*)?(\d+)/i);
if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`;
// "Episode X", "Folge X", "Ep. X", "#X"
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
return null;
}
// Returns true if two titles likely refer to the same episode.
// Strict: both must have IDs and match, OR neither has IDs and exact match.
function sameEpisode(titleA, titleB) {
if (!titleA && !titleB) return true; // Both unknown → assume same (backward compat)
if (!titleA || !titleB) return false; // One unknown, one known → different
const idA = extractEpisodeId(titleA);
const idB = extractEpisodeId(titleB);
if (idA && idB) return idA === idB; // Both have parseable IDs → compare IDs
if (idA || idB) return false; // One has ID, other doesn't → different
return titleA === titleB; // Neither has ID → exact string match
}
// Returns true only when we are CERTAIN the episodes differ.
// Permissive: only blocks if BOTH titles have parseable IDs AND they differ.
// Films, music, unparseable titles always pass through.
function isDifferentEpisode(titleA, titleB) {
if (!titleA || !titleB) return false; // Unknown → allow
const idA = extractEpisodeId(titleA);
const idB = extractEpisodeId(titleB);
if (!idA || !idB) return false; // At least one unparseable → allow
return idA !== idB; // Both parseable → only block if different
}
function checkEpisodeTransition() {
const currentTitle = getMediaTitle();
const video = findVideo();
@@ -89,7 +141,7 @@
// 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
&& !sameEpisode(currentTitle, lastKnownMediaTitle)
&& video
&& video.currentTime < 5
&& video.readyState >= 1) {
@@ -122,11 +174,11 @@
const video = findVideo();
const currentTitle = getMediaTitle();
if (video && currentTitle && currentTitle === expectedTitle
if (video && currentTitle && sameEpisode(currentTitle, expectedTitle)
&& video.currentTime < 5 && video.readyState >= 1) {
// Match! Pause at start and report ready.
if (!video.paused) {
expectEvent('paused');
_setSuppress('paused');
video.pause();
}
stopLobbyPoll();
@@ -193,11 +245,11 @@
if (ytButton) {
const isCurrentlyPlaying = !video.paused;
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
expectEvent(action === EVENTS.PLAY ? 'playing' : 'paused');
_setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused');
ytButton.click();
}
if (action === EVENTS.SEEK) {
expectEvent('seek');
_setSuppress('seek');
video.currentTime = data.targetTime;
}
return;
@@ -209,11 +261,11 @@
if (twitchButton) {
const isCurrentlyPlaying = !video.paused;
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
expectEvent(action === EVENTS.PLAY ? 'playing' : 'paused');
_setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused');
twitchButton.click();
}
if (action === EVENTS.SEEK) {
expectEvent('seek');
_setSuppress('seek');
video.currentTime = data.targetTime;
}
return;
@@ -222,16 +274,16 @@
// Fallback for native HTML5
if (action === EVENTS.PLAY) {
expectEvent('playing');
_setSuppress('playing');
video.play().catch((e) => {
reportLog(`Playback prevented: ${e.message}`, 'warn');
expectedEvents.delete('playing');
_clearSuppress('playing');
});
} else if (action === EVENTS.PAUSE) {
expectEvent('paused');
_setSuppress('paused');
video.pause();
} else if (action === EVENTS.SEEK) {
expectEvent('seek');
_setSuppress('seek');
video.currentTime = data.targetTime;
}
} catch (e) {
@@ -277,6 +329,24 @@
if (message.type === 'SERVER_COMMAND') {
const { action, payload } = message;
let actionCompleted = false;
// Guard: Don't execute sync commands if peers are on different episodes.
// Only active when autoSyncNextEpisode setting is enabled (default: on).
// Only blocks when BOTH sides have parseable S01E01-style IDs that differ.
// Films and unparseable titles always pass through.
const syncActions = [EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE];
if (_autoSyncEnabled && syncActions.includes(action)) {
const senderTitle = payload?.mediaTitle;
const myTitle = getMediaTitle();
if (isDifferentEpisode(senderTitle, myTitle)) {
reportLog(`Episode mismatch: sender="${senderTitle || '?'}" vs mine="${myTitle || '?'}" — skipping ${action}. Disable "Auto-Sync next Episode" in settings if this causes issues.`, 'warn');
if (action !== EVENTS.FORCE_SYNC_PREPARE && action !== EVENTS.FORCE_SYNC_EXECUTE) {
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
}
return;
}
}
if (action === EVENTS.PLAY) {
tryMediaAction(EVENTS.PLAY);
@@ -298,8 +368,8 @@
reportLog(`Media Action Error: Invalid force sync payload - ${JSON.stringify(payload)}`, 'error');
return;
}
expectEvent('paused');
expectEvent('seek');
_setSuppress('paused');
_setSuppress('seek');
video.pause();
video.currentTime = payload.targetTime;
pollSeekReady(payload.targetTime).then((ready) => {
@@ -345,7 +415,7 @@
if (message.type === 'PAUSE_FOR_LOBBY') {
const video = findVideo();
if (video && !video.paused) {
expectEvent('paused');
_setSuppress('paused');
video.pause();
}
// Start lobby poll now that we know the feature is enabled
@@ -409,12 +479,8 @@
const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null));
if (eventState && expectedEvents.has(eventState)) {
expectedEvents.delete(eventState);
if (expectedTimeouts[eventState]) {
clearTimeout(expectedTimeouts[eventState]);
delete expectedTimeouts[eventState];
}
if (_suppressTimers[eventState]) {
_clearSuppress(eventState);
return;
}
@@ -480,13 +546,9 @@
const current = video.currentTime;
if (!Number.isFinite(current)) return;
// Step 1: Check expectedEvents (programmatic seek from remote peer — ALWAYS process)
if (expectedEvents.has('seek')) {
expectedEvents.delete('seek');
if (expectedTimeouts['seek']) {
clearTimeout(expectedTimeouts['seek']);
delete expectedTimeouts['seek'];
}
// Step 1: Check _suppressTimers (programmatic seek from remote peer)
if (_suppressTimers['seek']) {
_clearSuppress('seek');
lastReportedSeekTime = current;
return;
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "KoalaSync",
"version": "1.8.8",
"description": "Watch party extension to synchronize video playback on YouTube, Twitch, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
"version": "1.9.0",
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
"permissions": [
"storage",
"tabs",
+18 -23
View File
@@ -1,5 +1,6 @@
import { EVENTS, OFFICIAL_LANDING_PAGE_URL } from './shared/constants.js';
import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
import { getAvatarForName, generateUsername } from './shared/names.js';
const elements = {
@@ -63,24 +64,6 @@ let errorToken = 0;
let forceSyncDone = false;
// --- Helpers ---
function getAvatarForName(username) {
if (!username) return '👤';
const lower = username.toLowerCase();
const map = {
'koala': '🐨', 'panda': '🐼', 'tiger': '🐯', 'eagle': '🦅',
'fox': '🦊', 'bear': '🐻', 'wolf': '🐺', 'lion': '🦁',
'hawk': '🦅', 'seal': '🦭', 'owl': '🦉', 'shark': '🦈',
'dragon': '🐉', 'phoenix': '🐦', 'falcon': '🦅', 'panther': '🐆',
'raven': '🐦‍⬛', 'cobra': '🐍', 'lynx': '🐈', 'jaguar': '🐆',
'orca': '🐋', 'mantis': '🦗', 'viper': '🐍', 'condor': '🦅',
'badger': '🦡', 'otter': '🦦', 'rhino': '🦏', 'crane': '🦩',
'mongoose': '🦦', 'specter': '👻'
};
for (const [key, emoji] of Object.entries(map)) {
if (lower.includes(key)) return emoji;
}
return '👤';
}
// --- Initialization ---
async function init() {
@@ -88,9 +71,7 @@ async function init() {
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite']);
let username = data.username;
if (!username) {
const adjs = ['Happy', 'Cool', 'Fast', 'Smart', 'Brave', 'Calm', 'Sneaky', 'Lazy', 'Wild', 'Chill', 'Lucky', 'Epic', 'Swift', 'Bold', 'Mighty', 'Cosmic', 'Neon', 'Shadow', 'Crystal', 'Thunder', 'Silent', 'Golden', 'Fierce', 'Noble', 'Mystic', 'Frozen', 'Blazing', 'Sapphire', 'Iron', 'Crimson'];
const nouns = ['Koala', 'Panda', 'Tiger', 'Eagle', 'Fox', 'Bear', 'Wolf', 'Lion', 'Hawk', 'Seal', 'Owl', 'Shark', 'Dragon', 'Phoenix', 'Falcon', 'Panther', 'Raven', 'Cobra', 'Lynx', 'Jaguar', 'Orca', 'Mantis', 'Viper', 'Condor', 'Badger', 'Otter', 'Rhino', 'Crane', 'Mongoose', 'Specter'];
username = `${adjs[Math.floor(Math.random() * adjs.length)]}${nouns[Math.floor(Math.random() * nouns.length)]}`;
username = generateUsername();
chrome.storage.sync.set({ username });
}
@@ -334,7 +315,9 @@ function startInterpolation() {
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);
if (elapsed < 45) {
el.textContent = formatTime(peer.currentTime + elapsed);
}
}
});
}, 1000);
@@ -477,7 +460,9 @@ function updatePeerList(peers) {
let displayTime = p.currentTime;
if (p.playbackState === 'playing' && p.lastHeartbeat && p.currentTime != null) {
const elapsed = (Date.now() - p.lastHeartbeat) / 1000;
displayTime += elapsed;
if (elapsed < 45) {
displayTime += elapsed;
}
}
timeSpan.textContent = formatTime(displayTime);
statusLine.appendChild(timeSpan);
@@ -1129,6 +1114,11 @@ elements.playBtn.addEventListener('click', () => {
type: 'CONTENT_EVENT',
action: EVENTS.PLAY,
payload: {}
}, (response) => {
if (response && response.status === 'ok_solo') {
elements.playBtn.textContent = '▶ Play';
elements.playBtn.disabled = false;
}
});
// Safety reset: restore button after 2.5s in case no peers respond
setTimeout(() => {
@@ -1150,6 +1140,11 @@ elements.pauseBtn.addEventListener('click', () => {
type: 'CONTENT_EVENT',
action: EVENTS.PAUSE,
payload: {}
}, (response) => {
if (response && response.status === 'ok_solo') {
elements.pauseBtn.textContent = '⏸ Pause';
elements.pauseBtn.disabled = false;
}
});
// Safety reset: restore button after 2.5s in case no peers respond
setTimeout(() => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "koalasync",
"version": "1.8.8",
"version": "1.9.0",
"description": "KoalaSync Build Scripts",
"private": true,
"scripts": {
+2 -2
View File
@@ -22,7 +22,7 @@ if (!fs.existsSync(extSharedDir)) {
fs.mkdirSync(extSharedDir, { recursive: true });
}
const sharedFiles = ['constants.js', 'blacklist.js', 'README.md'];
const sharedFiles = ['constants.js', 'blacklist.js', 'names.js', 'README.md'];
for (const file of sharedFiles) {
const src = path.join(masterSharedDir, file);
const dest = path.join(extSharedDir, file);
@@ -31,7 +31,7 @@ for (const file of sharedFiles) {
}
fs.copyFileSync(src, dest);
}
console.log('✓ constants.js, blacklist.js, and README.md synced to extension/shared/');
console.log('✓ constants.js, blacklist.js, names.js, and README.md synced to extension/shared/');
// Read the base manifest
const baseManifest = JSON.parse(fs.readFileSync(baseManifestPath, 'utf8'));
+6
View File
@@ -342,6 +342,11 @@ io.on('connection', (socket) => {
}
}
if (!room) {
socket.emit(EVENTS.ERROR, { message: "Join error" });
return;
}
if (!createdByMe) {
if (room.passwordHash) {
if (!password || !(await bcrypt.compare(password, room.passwordHash))) {
@@ -454,6 +459,7 @@ io.on('connection', (socket) => {
// --- S-3: Construct clean relay payload — never forward raw client data ---
const relayPayload = {
senderId: mapping.peerId,
seq: clampNum(data.seq, 0, Number.MAX_SAFE_INTEGER),
currentTime: clampNum(data.currentTime, 0, 86400),
targetTime: clampNum(data.targetTime, 0, 86400),
playbackState: validState(data.playbackState),
+1 -1
View File
@@ -7,7 +7,7 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "1.8.6";
export const APP_VERSION = "1.9.0";
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
+281
View File
@@ -0,0 +1,281 @@
/**
* KoalaSync Shared Name Generation & Emoji Mapping
*
* ⚠️ WARNING: This is the SINGLE SOURCE OF TRUTH.
* If you edit this file, you MUST run: node scripts/build-extension.js
* to propagate changes to the extension.
*
* The emoji map covers every animal/creature that has a Unicode emoji.
* Entries are sorted by key length (longest first) at lookup time to
* prevent substring false-matches (e.g. "caterpillar" must be checked
* before "cat").
*
* If you add a new animal noun to USERNAME_NOUNS, ensure it has a
* corresponding entry in ANIMAL_EMOJI_MAP (or a substring that already
* maps to a suitable emoji).
*/
export const USERNAME_ADJECTIVES = [
'Happy', 'Cool', 'Fast', 'Smart', 'Brave', 'Calm', 'Sneaky', 'Lazy',
'Wild', 'Chill', 'Lucky', 'Epic', 'Swift', 'Bold', 'Mighty',
'Cosmic', 'Neon', 'Shadow', 'Crystal', 'Thunder', 'Silent', 'Golden',
'Fierce', 'Noble', 'Mystic', 'Frozen', 'Blazing', 'Sapphire', 'Iron', 'Crimson'
];
export const USERNAME_NOUNS = [
'Koala', 'Panda', 'Tiger', 'Eagle', 'Fox', 'Bear', 'Wolf', 'Lion',
'Hawk', 'Seal', 'Owl', 'Shark', 'Dragon', 'Phoenix', 'Falcon',
'Panther', 'Raven', 'Cobra', 'Lynx', 'Jaguar', 'Orca', 'Mantis',
'Viper', 'Condor', 'Badger', 'Otter', 'Rhino', 'Crane', 'Mongoose',
'Specter',
'Cat', 'Dog', 'Deer', 'Bat', 'Gorilla', 'Monkey', 'Rabbit',
'Horse', 'Unicorn', 'Zebra', 'Leopard', 'Cheetah', 'Puma',
'Ram', 'Goat', 'Bull', 'Donkey', 'Moose',
'Elephant', 'Giraffe', 'Hippo', 'Sloth', 'Kangaroo',
'Raccoon', 'Hamster', 'Hedgehog', 'Skunk', 'Beaver', 'Bison',
'Camel', 'Llama', 'Hyena', 'Coyote',
'Mouse', 'Pig', 'Boar', 'Polar', 'Orangutan', 'Mammoth',
'Crow', 'Duck', 'Swan', 'Penguin', 'Parrot', 'Peacock',
'Dove', 'Dodo', 'Turkey', 'Flamingo', 'Chicken', 'Rooster', 'Goose',
'Dolphin', 'Whale', 'Crab', 'Lobster', 'Octopus', 'Squid',
'Jellyfish', 'Turtle',
'Crocodile', 'Lizard', 'Snake', 'Frog', 'Toad', 'Gecko',
'Bee', 'Ant', 'Spider', 'Scorpion', 'Butterfly', 'Ladybug',
'Beetle', 'Snail', 'Dragonfly', 'Caterpillar',
'Alien', 'Robot', 'Mermaid', 'Ghoul', 'Sprite', 'Cyborg',
'Dinosaur', 'Reaper', 'Wraith', 'Sphinx',
];
export const ANIMAL_EMOJI_MAP = {
'hippopotamus': '🦛',
'rhinoceros': '🦏',
'caterpillar': '🐛',
'chimpanzee': '🐵',
'orangutan': '🦧',
'blackbird': '🐦‍⬛',
'bumblebee': '🐝',
'cockatoo': '🦜',
'cockroach': '🪳',
'dragonfly': '🐉',
'grasshopper': '🦗',
'hedgehog': '🦔',
'jellyfish': '🪼',
'kangaroo': '🦘',
'ladybird': '🐞',
'ladybug': '🐞',
'porcupine': '🦔',
'scorpion': '🦂',
'tarantula': '🕷️',
'alligator': '🐊',
'anaconda': '🐍',
'antelope': '🦌',
'blowfish': '🐡',
'butterfly': '🦋',
'chameleon': '🦎',
'chipmunk': '🐿️',
'crocodile': '🐊',
'dinosaur': '🦖',
'elephant': '🐘',
'flamingo': '🦩',
'giraffe': '🦒',
'hamster': '🐹',
'leopard': '🐆',
'lobster': '🦞',
'mermaid': '🧜‍♀️',
'mongoose': '🦦',
'mosquito': '🦟',
'pangolin': '🦔',
'peacock': '🦚',
'penguin': '🐧',
'phoenix': '🐦‍🔥',
'raccoon': '🦝',
'seahorse': '🐴',
'sealion': '🦭',
'unicorn': '🦄',
'vampire': '🦇',
'warthog': '🐗',
'wolverine': '🦡',
'mammoth': '🦣',
'meerkat': '🦦',
'octopus': '🐙',
'opposum': '🐭',
'ostrich': '🐦',
'panther': '🐆',
'pelican': '🦩',
'rooster': '🐓',
'serpent': '🐍',
'specter': '👻',
'spectre': '👻',
'sparrow': '🐦',
'spider': '🕷️',
'sphinx': '🦁',
'squirrel': '🐿️',
'stingray': '🦈',
'termite': '🐜',
'tortoise': '🐢',
'turkey': '🦃',
'walrus': '🦭',
'wombat': '🦡',
'woodpecker': '🐦',
'alien': '👾',
'badger': '🦡',
'beaver': '🦫',
'beetle': '🪲',
'beluga': '🐋',
'bison': '🦬',
'bobcat': '🐱',
'buffalo': '🦬',
'bunny': '🐰',
'camel': '🐪',
'cheetah': '🐆',
'chicken': '🐔',
'cobra': '🐍',
'condor': '🦅',
'cougar': '🐆',
'coyote': '🐺',
'crane': '🦩',
'cricket': '🦗',
'crow': '🐦‍⬛',
'cyborg': '🤖',
'dolphin': '🐬',
'donkey': '🫏',
'dragon': '🐉',
'drake': '🐉',
'eagle': '🦅',
'falcon': '🦅',
'ferret': '🦦',
'gazelle': '🦌',
'gecko': '🦎',
'gerbil': '🐹',
'ghost': '👻',
'ghoul': '👻',
'goose': '🪿',
'gopher': '🐹',
'gorilla': '🦍',
'grizzly': '🐻',
'heron': '🦩',
'hippo': '🦛',
'hornet': '🐝',
'hyena': '🐺',
'iguana': '🦎',
'jackal': '🐺',
'jaguar': '🐆',
'kitten': '🐱',
'koala': '🐨',
'lemur': '🐒',
'lizard': '🦎',
'llama': '🦙',
'locust': '🦗',
'lynx': '🐱',
'macaw': '🦜',
'mantis': '🦗',
'mink': '🦦',
'monkey': '🐵',
'moose': '🦌',
'mouse': '🐭',
'orca': '🐋',
'otter': '🦦',
'oyster': '🦪',
'panda': '🐼',
'parrot': '🦜',
'pigeon': '🕊️',
'polar': '🐻‍❄️',
'poodle': '🐩',
'puffin': '🐧',
'puma': '🐆',
'rabbit': '🐰',
'raptor': '🦖',
'raven': '🐦‍⬛',
'reaper': '👻',
'rhino': '🦏',
'robin': '🐦',
'robot': '🤖',
'salmon': '🐟',
'shrimp': '🦐',
'skunk': '🦨',
'sloth': '🦥',
'snail': '🐌',
'snake': '🐍',
'sprite': '🧚',
'squid': '🦑',
'swan': '🦢',
'tapir': '🐗',
'tiger': '🐯',
'toad': '🐸',
'trout': '🐟',
'tuna': '🐟',
'turtle': '🐢',
'viper': '🐍',
'vulture': '🦅',
'weasel': '🦦',
'whale': '🐋',
'wolf': '🐺',
'wraith': '👻',
'zebra': '🦓',
'ape': '🦍',
'ant': '🐜',
'bat': '🦇',
'bee': '🐝',
'bug': '🐛',
'cat': '🐱',
'cow': '🐮',
'crab': '🦀',
'dog': '🐶',
'duck': '🦆',
'elk': '🦌',
'fly': '🪰',
'fox': '🦊',
'frog': '🐸',
'goat': '🐐',
'hawk': '🦅',
'hen': '🐔',
'hog': '🐷',
'lion': '🦁',
'mole': '🐭',
'moth': '🦋',
'mule': '🫏',
'owl': '🦉',
'pig': '🐷',
'ram': '🐏',
'rat': '🐀',
'seal': '🦭',
'shark': '🦈',
'wasp': '🐝',
'yak': '🐂',
'doe': '🦌',
'ewe': '🐑',
'buck': '🦌',
'ox': '🐂',
'bull': '🐂',
'dodo': '🦤',
'boar': '🐗',
'bear': '🐻',
'deer': '🦌',
'dove': '🕊️',
'fish': '🐟',
'hare': '🐰',
'horse': '🐴',
'lamb': '🐑',
'mare': '🐴',
'pony': '🐴',
'pup': '🐶',
'croc': '🐊',
'gnat': '🦟',
'gnu': '🦬',
};
export function getAvatarForName(username) {
if (!username) return '\u{1F464}';
const lower = username.toLowerCase();
const sorted = Object.entries(ANIMAL_EMOJI_MAP).sort((a, b) => b[0].length - a[0].length);
for (const [key, emoji] of sorted) {
if (lower.includes(key)) return emoji;
}
return '\u{1F464}';
}
export function generateUsername() {
const adj = USERNAME_ADJECTIVES[Math.floor(Math.random() * USERNAME_ADJECTIVES.length)];
const noun = USERNAME_NOUNS[Math.floor(Math.random() * USERNAME_NOUNS.length)];
return `${adj}${noun}`;
}
+2 -2
View File
@@ -1,4 +1,4 @@
{
"version": "1.8.8",
"date": "2026-05-26T00:34:04Z"
"version": "1.8.10",
"date": "2026-05-26T15:42:24Z"
}