Compare commits

...

43 Commits

Author SHA1 Message Date
Timo 92fb8e2d00 Roll back network latency compensation to maximize simplicity and clock skew safety 2026-05-30 00:06:52 +02:00
Timo 43cde9bbef Optimize network latency compensation cap to protect against system clock skew 2026-05-30 00:03:56 +02:00
Timo 56955027f9 Fix critical sync lockouts, zombie disconnects, play-seek debounces, active lobbies, SW keep-alives, branding matches and ESLint warnings 2026-05-30 00:02:19 +02:00
GitHub Action 5ef059a94f chore(release): update versions to v1.9.0 [skip ci] 2026-05-28 02:55:04 +00:00
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
Koala 35e779c1ff move version link outside h1 to avoid inherited heading styles 2026-05-26 03:00:29 +02:00
Koala 09f0e04891 add clickable version with GitHub icon to popup header 2026-05-26 02:58:36 +02:00
Koala 9eff53ba46 chore: remove CSP-blocked inline handlers from landing page 2026-05-26 02:47:45 +02:00
GitHub Action bde2f7ea55 chore(release): update versions to v1.8.8 [skip ci] 2026-05-26 00:34:04 +00:00
Koala 901861269a fix: Firefox invite — cloneInto() for CustomEvent detail (XrayWrapper) 2026-05-26 02:33:54 +02:00
GitHub Action ba91e2744c chore(release): update versions to v1.8.7 [skip ci] 2026-05-26 00:06:39 +00:00
Koala b7a44024ab fix: suppress ghost seek events on tab re-focus (Firefox tab throttling) 2026-05-26 02:06:23 +02:00
Koala 3c49bfe54c feat: SEO — add Emby/Jellyfin to descriptions, optimize meta tags, fix APP_VERSION 2026-05-26 01:51:34 +02:00
GitHub Action d2ea7c7423 chore(release): update versions to v1.8.6 [skip ci] 2026-05-25 23:07:55 +00:00
Koala db11812bd6 fix: suppress seek events when solo, add moz-extension CORS, add server logging 2026-05-26 01:06:04 +02:00
GitHub Action 6db8fdbf75 chore(release): update versions to v1.8.5 [skip ci] 2026-05-25 21:56:58 +00:00
Koala d23c37f87f chore(release): v1.8.5 2026-05-25 23:56:50 +02:00
Koala acd428d4f7 fix: audit fixes — auto-match title corruption, duplicate HTML attrs, stale ℹ️ icons, missing tooltip, remove fix_ui.js 2026-05-25 23:56:14 +02:00
Koala c0a6f0adc2 ui: clipboard emoji for invite button, movie emoji for audible tab indicator 2026-05-25 23:56:14 +02:00
GitHub Action 42b73bb97f chore(release): update versions to v1.8.3 [skip ci] 2026-05-25 21:43:51 +00:00
Koala dc36bfdded chore(release): v1.8.3 2026-05-25 23:43:41 +02:00
Koala 06db850387 fix(ui): add comprehensive tooltips to all labels, buttons and inputs 2026-05-25 23:43:09 +02:00
Koala 0de92b5b61 fix(ui): tooltips, full emoji map, and onboarding layout 2026-05-25 23:37:21 +02:00
GitHub Action 8ff8e7beb6 chore(release): update versions to v1.8.1 [skip ci] 2026-05-25 21:32:16 +00:00
Koala 8317099072 chore(release): prep for v1.8.1 2026-05-25 23:32:07 +02:00
Koala 04c63dcf68 fix(ui): add missing avatar emoji in episode lobby UI 2026-05-25 23:30:26 +02:00
Koala fd23ccc23a feat(ui): v1.8.1 visual upgrades (emojis, animations, audio indicator) 2026-05-25 23:29:06 +02:00
Koala 4d5caeda9e style(ui): redesign sync button layout and rename to SYNC 2026-05-25 23:20:37 +02:00
Koala f1f41e5cac fix(ui): apply tooltip to full label text for settings 2026-05-25 23:19:23 +02:00
GitHub Action 473eacda22 chore(release): update versions to v1.8.0 [skip ci] 2026-05-25 21:09:07 +00:00
Koala 42029f86bf chore(release): prep for v1.8.0 2026-05-25 23:08:55 +02:00
Koala 4c4a2638d7 docs: rearrange README to prioritize user quick start 2026-05-25 23:06:58 +02:00
Koala 5b57970c4c docs: use for-the-badge style for download links 2026-05-25 23:05:22 +02:00
Koala 61492f953b docs: Update README quick start for users 2026-05-25 23:04:14 +02:00
Koala da6a1cc643 Server updates 2026-05-25 22:59:54 +02:00
Koala 62fdffa5ee UX Improvements: Onboarding, UI Feedback, Auto-Copy Invite 2026-05-25 22:59:54 +02:00
GitHub Action fb13978c9d chore(release): update versions to v1.7.5 [skip ci] 2026-05-25 11:26:16 +00:00
18 changed files with 1147 additions and 219 deletions
+24 -12
View File
@@ -8,7 +8,7 @@
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
</p>
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback across any website—YouTube, Twitch, Netflix, and custom HTML5 players. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
### 🌟 Why KoalaSync?
@@ -30,7 +30,28 @@
---
### 📂 Repository Structure
### 🚀 Quick Start
#### For Users (Installation & Usage)
The easiest and safest way to install KoalaSync is directly through the official browser stores:
<p>
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white&style=for-the-badge" alt="Chrome Extension"></a>
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white&style=for-the-badge" alt="Firefox Add-on"></a>
</p>
*(For manual offline installation: Download the latest `.zip` from the [Releases](https://github.com/Shik3i/KoalaSync/releases) page and load it as an "Unpacked Extension" in Developer Mode).*
**How to use:**
1. **Create a Room:** Click the Koala icon in your browser and hit `+ Create New Room`.
2. **Invite Friends:** Share the auto-copied invite link. Once they click it, they automatically join.
3. **Pick a Video:** Navigate to the Sync Tab, select the tab playing your video, and grab some popcorn! 🍿
---
### 🛠️ For Developers & Self-Hosters
#### 📂 Repository Structure
- `extension/`: Browser Extension (Chrome & Firefox).
- `server/`: Node.js + Socket.IO Relay Server (Containerized).
@@ -39,16 +60,7 @@
- `scripts/`: Automated build and synchronization utilities.
- `docs/`: Technical deep-dives ([Architecture](docs/ARCHITECTURE.md), [Sync Guide](docs/SYNC_GUIDE.md)).
---
### 🚀 Quick Start
#### For Users (Installation)
The easiest way to install KoalaSync is to download the pre-compiled version from the [Releases](https://github.com/Shik3i/KoalaSync/releases) page.
1. Download the latest `koalasync-chrome.zip` or `koalasync-firefox.zip`.
2. Extract the file and load it as an "Unpacked Extension" in your browser's Developer Mode.
#### For Developers (Building)
#### Building from Source
To build the extension from source and synchronize protocol constants:
```bash
npm install
+180 -16
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,23 @@ 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)
const activePorts = new Set(); // New: track active content ports for keep-alive
// --- Keep-Alive Port Listener ---
chrome.runtime.onConnect.addListener((port) => {
if (port.name === 'keepAlive') {
activePorts.add(port);
port.onDisconnect.addListener(() => {
activePorts.delete(port);
});
}
});
function _persistLastSeq() {
if (storageInitialized) chrome.storage.session.set({ lastSeqBySender });
}
// --- Boot Sequence Lock ---
let restorationTask = null;
@@ -35,7 +53,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 +102,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 +146,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 +227,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 || '',
@@ -534,9 +573,47 @@ function handleServerEvent(event, data) {
currentRoom = data;
if (currentRoom && Array.isArray(currentRoom.peers)) {
currentRoom.peers = currentRoom.peers.map(p => typeof p === 'object' ? createPeerData(p) : { peerId: p, username: null, tabTitle: null, mediaTitle: null, playbackState: null, currentTime: null, volume: null, muted: null, lastHeartbeat: Date.now() });
// Clear sequence tracking for peers that are no longer in the room
const activePeerIds = new Set(currentRoom.peers.map(p => typeof p === 'object' ? p.peerId : p));
Object.keys(lastSeqBySender).forEach(pId => {
if (!activePeerIds.has(pId)) {
delete lastSeqBySender[pId];
}
});
_persistLastSeq();
} else if (currentRoom) {
currentRoom.peers = [];
}
// Recover server-tracked active Episode Lobby if present
if (data && data.activeLobby && !episodeLobby) {
episodeLobby = {
expectedTitle: data.activeLobby.expectedTitle,
initiatorPeerId: data.activeLobby.initiatorPeerId,
readyPeers: data.activeLobby.readyPeers,
createdAt: Date.now()
};
persistEpisodeLobby();
broadcastLobbyUpdate();
addLog(`Recovered active episode lobby from server: "${episodeLobby.expectedTitle}"`, 'info');
// Notify content script to start polling
if (currentTabId) {
const tabId = parseInt(currentTabId);
if (!isNaN(tabId)) {
chrome.tabs.sendMessage(tabId, {
type: 'EPISODE_LOBBY',
expectedTitle: episodeLobby.expectedTitle
}).catch(() => {});
}
}
// Schedule timeout if we don't already have one
if (!episodeLobbyTimeout) {
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), EPISODE_LOBBY_TIMEOUT);
}
}
if (storageInitialized) chrome.storage.session.set({ currentRoom });
addLog(`Joined Room: ${data?.roomId || 'unknown'}`, 'success');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: data.peers }).catch(() => {});
@@ -576,6 +653,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 +678,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 +713,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);
@@ -667,6 +765,9 @@ 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)) {
delete lastSeqBySender[data.peerId];
_persistLastSeq();
currentRoom.peers.push(createPeerData(data));
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
@@ -701,12 +802,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 +826,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 +835,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 +898,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 +967,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 +1028,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 +1039,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 +1253,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 +1280,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');
@@ -1167,6 +1289,16 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}, FORCE_SYNC_TIMEOUT);
}
addToHistory(message.action, 'You');
const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK;
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' });
return;
}
emit(message.action, { ...message.payload, peerId });
sendResponse({ status: 'ok' });
};
@@ -1206,7 +1338,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') {
@@ -1301,7 +1435,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();
@@ -1348,7 +1482,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();
@@ -1375,11 +1509,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(() => {});
}
}
});
+11 -7
View File
@@ -1,3 +1,4 @@
/* global cloneInto */
/**
* KoalaSync Bridge Script
* Injected into sync.koalastuff.net to facilitate communication between
@@ -22,12 +23,15 @@ window.addEventListener('KOALASYNC_JOIN_REQUEST', (e) => {
// 3. Listen for Status Updates from the Extension and relay to Website
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'JOIN_STATUS') {
const event = new CustomEvent('KOALASYNC_STATUS', {
detail: {
success: msg.success,
message: msg.message
}
});
window.dispatchEvent(event);
const detail = { success: msg.success, message: msg.message };
// Firefox MV3 content scripts run in an isolated world. When dispatching
// a CustomEvent with a detail object, Firefox wraps it in an XrayWrapper
// that the page's JavaScript cannot destructure (Permission denied).
// cloneInto() exposes the object to the page's context correctly.
// Chrome doesn't have this issue — cloneInto() is undefined there.
const safeDetail = typeof cloneInto === 'function'
? cloneInto(detail, document.defaultView)
: detail;
window.dispatchEvent(new CustomEvent('KOALASYNC_STATUS', { detail: safeDetail }));
}
});
+172 -43
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
@@ -399,6 +469,17 @@
// Detect native events
function reportEvent(action) {
if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) {
clearTimeout(seekDebounceTimer);
seekDebounceTimer = null;
const v = findVideo();
if (v && Number.isFinite(v.currentTime)) {
lastReportedSeekTime = v.currentTime;
reportLog(`[Seek] Debounce flushed immediately due to ${action.toUpperCase()}`, 'info');
reportEvent(EVENTS.SEEK);
}
}
const video = findVideo();
if (!video) return;
@@ -409,14 +490,14 @@
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;
}
// Suppress only SEEK during visibility grace period (tab re-focus ghost jump).
// Play/Pause pass through — user may want to immediately pause after tabbing back.
if (Date.now() < visibilityGraceUntil && action === EVENTS.SEEK) return;
chrome.runtime.sendMessage({
type: 'CONTENT_EVENT',
@@ -433,6 +514,38 @@
scheduleProactiveHeartbeat();
}
// --- Tab Visibility Handling ---
// Browsers (especially Firefox) aggressively throttle background tabs.
// When the user returns to a video tab, the video element may have lost
// time-sync and fires spurious seek events as it recovers (jumping back).
// We suppress only SEEK for a short grace period after tab re-focus.
// Play/Pause are NOT suppressed — the user may legitimately want to
// pause immediately after switching back.
let pageVisible = !document.hidden;
let visibilityGraceUntil = 0;
const VISIBILITY_GRACE_MS = 300;
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
pageVisible = false;
} else if (!pageVisible) {
pageVisible = true;
visibilityGraceUntil = Date.now() + VISIBILITY_GRACE_MS;
reportLog(`Tab re-focused — suppressing seeks for ${VISIBILITY_GRACE_MS / 1000}s to prevent ghost relay`, 'warn');
}
});
// Reset on page hide/show (bfcache, tab discard)
window.addEventListener('pagehide', () => { pageVisible = false; });
window.addEventListener('pageshow', (event) => {
// event.persisted is true ONLY when restored from bfcache, not on initial load
if (event.persisted && !pageVisible) {
pageVisible = true;
visibilityGraceUntil = Date.now() + VISIBILITY_GRACE_MS;
reportLog(`Page restored from cache — suppressing seeks for ${VISIBILITY_GRACE_MS / 1000}s`, 'warn');
}
});
const handlePlay = () => reportEvent(EVENTS.PLAY);
const handlePause = () => reportEvent(EVENTS.PAUSE);
@@ -444,28 +557,26 @@
const current = video.currentTime;
if (!Number.isFinite(current)) return;
// Step 1: Check expectedEvents (programmatic seek suppression)
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;
// No log — this is routine programmatic behavior (Force Sync, lobby, peer command)
return;
}
// Step 2: Suppress during visibility grace period (tab re-focus ghost events)
if (Date.now() < visibilityGraceUntil) 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.)
// Step 3: 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)
// Step 4: Debounce rapid consecutive seeks (e.g. scrubbing)
// — wait 800ms for the user to settle before relaying
if (seekDebounceTimer) clearTimeout(seekDebounceTimer);
seekDebounceTimer = setTimeout(() => {
@@ -605,6 +716,24 @@
// Initial Setup
setupListeners();
// Maintain a persistent keep-alive port connection to prevent background SW suspension
let keepAlivePort = null;
function connectKeepAlivePort() {
try {
if (chrome.runtime.id) {
keepAlivePort = chrome.runtime.connect({ name: 'keepAlive' });
keepAlivePort.onDisconnect.addListener(() => {
keepAlivePort = null;
setTimeout(connectKeepAlivePort, 1000);
});
}
} catch (_e) {
// Extension context invalidated or disabled
}
}
connectKeepAlivePort();
schedulePeriodicHeartbeat();
// Immediate heartbeat on injection — populate peer data without waiting 15s
+2 -2
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "KoalaSync",
"version": "1.7.5",
"description": "Watch party extension to synchronize video playback on YouTube, Twitch, Netflix, and HTML5 sites 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",
+169 -56
View File
@@ -27,15 +27,20 @@
font-size: 14px;
}
.header-row {
display: flex;
align-items: center;
margin-bottom: 16px;
}
h1 {
font-size: 18px;
margin: 0 0 16px 0;
margin: 0;
color: var(--accent);
letter-spacing: 1px;
text-transform: uppercase;
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
@@ -46,6 +51,27 @@
border-radius: 4px;
}
.popup-version {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: var(--text-muted);
text-decoration: none;
opacity: 0.5;
transition: opacity 0.2s, color 0.2s;
}
.popup-version:hover {
opacity: 1;
color: var(--accent);
}
.popup-version svg {
width: 12px;
height: 12px;
display: block;
}
/* Tabs */
.tabs {
display: flex;
@@ -237,17 +263,72 @@
from { opacity: 1; }
to { opacity: 0; transform: translateY(-10px); }
}
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateX(5px); }
to { opacity: 1; transform: translateX(0); }
}
.tab-active-animate {
animation: fadeSlideIn 0.15s ease-out forwards;
}
/* Toggle Switch CSS */
.toggle-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: #334155;
transition: .3s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: #94a3b8;
transition: .3s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--accent);
}
input:checked + .slider:before {
transform: translateX(16px);
background-color: white;
}
</style>
</head>
<body>
<div id="toast-container"></div>
<h1><img src="icons/icon128.png" alt="KoalaSync Logo">KoalaSync</h1>
<div class="header-row">
<h1><img src="icons/icon128.png" alt="KoalaSync Logo">KoalaSync</h1>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="popup-version">
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
<span id="popupVersion">v0.0.0</span>
</a>
</div>
<div class="tabs">
<button class="tab-btn active" data-tab="tab-room">Room</button>
<button class="tab-btn" data-tab="tab-sync">Sync</button>
<button class="tab-btn" data-tab="tab-settings">Settings</button>
<button class="tab-btn" data-tab="tab-dev">Dev</button>
<button class="tab-btn active" data-tab="tab-room" title="Room settings and connection">Room</button>
<button class="tab-btn" data-tab="tab-sync" title="Video sync controls and remote actions">Sync</button>
<button class="tab-btn" data-tab="tab-settings" title="Extension preferences">Settings</button>
<button class="tab-btn" data-tab="tab-dev" title="Advanced Diagnostics & Logs">Status</button>
</div>
<!-- Room Tab -->
@@ -255,47 +336,52 @@
<!-- JOIN SECTION: Visible when not in a room -->
<div id="section-join">
<button id="createRoomBtn" class="primary" style="margin-bottom: 16px; padding: 14px; font-size: 15px; background: linear-gradient(135deg, #6366f1, #a855f7); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);">+ Create New Room</button>
<details style="margin-bottom: 12px; background: var(--card); border: 1px solid #334155; border-radius: 8px; padding: 8px;">
<summary style="font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; cursor: pointer; outline: none;">Manual Connect / Advanced</summary>
<div style="margin-top: 12px;">
<div class="form-group">
<label>Server</label>
<label title="Select which KoalaSync server to use">Server</label>
<div style="display:flex; gap:4px; margin-bottom:8px;">
<button id="serverOfficial" class="tab-btn active" style="flex:1; padding:6px; font-size:11px;">Official</button>
<button id="serverCustom" class="tab-btn" style="flex:1; padding:6px; font-size:11px;">Custom</button>
<button id="serverOfficial" class="tab-btn active" style="flex:1; padding:6px; font-size:11px;" title="Use the official reliable server">Official</button>
<button id="serverCustom" class="tab-btn" style="flex:1; padding:6px; font-size:11px;" title="Connect to your own self-hosted server">Custom</button>
</div>
<input type="text" id="serverUrl" placeholder="wss://your-server:3000" style="display:none;">
</div>
<div class="form-group">
<label>Room ID</label>
<input type="text" id="roomId" placeholder="Leave empty to create">
<label title="The unique identifier for your sync room">Room ID</label>
<input type="text" id="roomId" placeholder="Enter Room ID" title="The unique ID of the room you want to join">
</div>
<div class="form-group">
<label>Password (Optional)</label>
<input type="password" id="password" placeholder="Room password">
<label title="Optional password to restrict room access">Password (Optional)</label>
<input type="password" id="password" placeholder="Room Password (optional)" title="Password for the room (leave empty if none)">
</div>
<div id="roomError" style="display:none; color:var(--error); font-size:11px; margin-bottom:8px; text-align:center;"></div>
<button id="joinBtn" class="primary">Join Room</button>
<button id="createRoomBtn" class="secondary" style="border: 1px solid var(--accent); color: var(--accent);">Create Random Room</button>
<button id="joinBtn" class="primary" title="Connect to the room">Join Room</button>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1.5rem; margin-bottom: 8px;">
<label style="margin:0;">Public Rooms</label>
<button id="refreshRooms" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;">REFRESH</button>
<label style="margin:0;" title="List of publicly available rooms on this server">Public Rooms</label>
<button id="refreshRooms" class="secondary" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;" title="Refresh the list of public rooms">REFRESH</button>
</div>
<div id="publicRooms" class="info-card" style="max-height: 120px; overflow-y: auto; padding: 4px;">
<div style="text-align:center; color: var(--text-muted); font-size: 11px; padding: 10px;">Refreshing...</div>
</div>
</div>
</details>
</div>
<!-- ACTIVE SECTION: Visible when in a room -->
<div id="section-active" style="display:none;">
<div class="info-card" style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; border-left: 4px solid var(--accent);">
<div>
<label style="margin-bottom: 0;">Active Room</label>
<label style="margin-bottom: 0;" title="The room you are currently connected to">Active Room</label>
<div id="activeRoomId" style="font-weight: 700; color: var(--accent); font-size: 16px; letter-spacing: 1px;">NONE</div>
</div>
<div id="activeServer" style="font-size: 10px; color: var(--text-muted); text-align: right; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">Official Server</div>
</div>
<div class="info-card" style="margin-bottom: 20px;">
<label>Invite Link</label>
<label title="Share this link with friends so they can join">Invite Link</label>
<div class="invite-box">
<input type="text" id="inviteLink" readonly>
<button id="copyInvite" class="secondary">📋</button>
@@ -303,7 +389,7 @@
</div>
<div style="margin-bottom: 20px;">
<label>Peers in Room</label>
<label title="Other users currently connected to this room">Peers in Room</label>
<div id="peerList" class="info-card">
<div style="text-align:center; color: var(--text-muted); font-size: 12px;">No peers connected</div>
</div>
@@ -315,29 +401,34 @@
<!-- Sync Tab -->
<div id="tab-sync" class="tab-content">
<!-- SYNC ACTIVE: Visible when in a room -->
<div id="sync-active">
<div class="form-group">
<label>Target Tab (Video Source)</label>
<label title="Choose the browser tab containing the video to sync">Select Video</label>
<select id="targetTab">
<option value="">-- Select a Tab --</option>
</select>
</div>
<label>Remote Control</label>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<label style="margin: 0;">Remote Control</label>
<button id="syncTabCopyInvite" title="Copy Invite Link" style="background:transparent; border: 1px solid #334155; border-radius: 6px; padding: 4px 8px; font-size: 11px; cursor:pointer; opacity:0.8; transition: opacity 0.2s; color: var(--text-muted); display: flex; align-items: center; gap: 4px; white-space: nowrap;">📋 Invite Link</button>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
<button id="playBtn" class="primary" style="flex:1; background: var(--success);">▶ Play</button>
<button id="pauseBtn" class="primary" style="flex:1; background: var(--error);">⏸ Pause</button>
<button id="playBtn" class="primary" style="flex:1; background: var(--success);" title="Send a Play command to everyone">▶ Play</button>
<button id="pauseBtn" class="primary" style="flex:1; background: var(--error);" title="Send a Pause command to everyone">⏸ Pause</button>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 15px; align-items: stretch;">
<button id="forceSyncBtn" class="primary" style="background: linear-gradient(135deg, #6366f1, #a855f7); flex: 1;">⚡ Force Sync</button>
<select id="forceSyncMode" style="width: auto; min-width: 130px; background: var(--card); border: 1px solid #334155; color: white; padding: 10px 8px; border-radius: 8px; font-size: 11px; font-family: inherit; cursor: pointer; align-self: stretch;" title="Choose sync target">
<button id="forceSyncBtn" class="primary" style="background: linear-gradient(135deg, #6366f1, #a855f7); flex: 1;" title="Force all users to sync up">⚡ SYNC</button>
<select id="forceSyncMode" style="flex: 1; background: var(--card); border: 1px solid #334155; color: white; padding: 10px 8px; border-radius: 8px; font-size: 11px; font-family: inherit; cursor: pointer; align-self: stretch;" title="Choose sync target">
<option value="jump-to-others">Jump to Others</option>
<option value="jump-to-me">Jump to Me</option>
</select>
</div>
<!-- NEW: Last Action Status Card -->
<label>Last Activity Status</label>
<label title="Shows the most recent play, pause, or seek command">Last Activity Status</label>
<div id="lastActionCard" class="info-card" style="margin-bottom: 15px; max-height: 120px; overflow-y: auto;">
<div style="text-align:center; color: var(--text-muted); font-size: 10px;">No recent commands</div>
</div>
@@ -353,68 +444,90 @@
</div>
<div id="peerListSync" class="info-card" style="display:none;"></div>
</div>
<!-- SYNC INACTIVE: Visible when not in a room -->
<div id="sync-inactive" style="display:none; text-align: center; padding: 20px 10px;">
<div style="font-size: 32px; margin-bottom: 12px;">🔒</div>
<h3 style="margin: 0 0 8px 0; color: var(--accent); font-size: 15px;">Connect to a room first</h3>
<p style="color: var(--text-muted); font-size: 12px; margin-bottom: 20px;">You need to join a room via an invite link or create a new one to sync videos.</p>
<button id="syncTabCreateRoomBtn" class="primary" style="padding: 12px; font-size: 14px; background: linear-gradient(135deg, #6366f1, #a855f7); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);" title="Create a new random room and join it">Create New Room</button>
</div>
</div>
<!-- Settings Tab -->
<div id="tab-settings" class="tab-content">
<div class="form-group">
<label>Your Username</label>
<input type="text" id="username" placeholder="Anonymous Koala" maxlength="20">
<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;" title="Username helps others identify you.">Your Username</label>
<input type="text" id="username" placeholder="Anonymous Koala" maxlength="20" style="width: 150px;">
</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;">Filter Noise Tabs</label>
<input type="checkbox" id="filterNoise" style="width: auto;" checked>
<label style="margin-bottom: 0; cursor: help;" title="Filters out non-video tabs and unrelated domains to keep the list clean">Hide Clutter Tabs</label>
<label class="toggle-switch">
<input type="checkbox" id="filterNoise" checked>
<span class="slider"></span>
</label>
</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;">
<label style="margin-bottom: 0; cursor: help;" title="Pauses automatically and waits for all peers when an episode changes, then sync-starts together.">Auto-Sync Next Episode</label>
<label class="toggle-switch">
<input type="checkbox" id="autoSyncNextEpisode">
<span class="slider"></span>
</label>
</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;">Browser Notifications</label>
<input type="checkbox" id="browserNotifications" style="width: auto;">
<label style="margin-bottom: 0; cursor: help;" title="Automatically copies the invite link to your clipboard when creating a new room.">Auto-Copy Invite Link</label>
<label class="toggle-switch">
<input type="checkbox" id="autoCopyInvite" checked>
<span class="slider"></span>
</label>
</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; cursor: help;" title="Shows native system notifications when someone joins/leaves or plays/pauses.">Browser Notifications</label>
<label class="toggle-switch">
<input type="checkbox" id="browserNotifications">
<span class="slider"></span>
</label>
</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);">
<label>Troubleshooting</label>
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px;">Regenerate Peer ID</button>
<label title="Tools for fixing connection issues">Troubleshooting</label>
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px;" title="Regenerate your internal ID and reconnect">Regenerate Peer ID</button>
<p style="font-size: 9px; color: var(--text-muted); margin-top: 5px; text-align: center;">Use this if you see "Duplicate Identity" errors.</p>
</div>
</div>
<!-- Dev Tab -->
<div id="tab-dev" class="tab-content">
<label>Connection Status</label>
<label title="Current WebSocket connection state">Connection Status</label>
<div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;">
<span id="connDot" class="status-dot status-offline"></span>
<span id="connText" style="flex:1;">Disconnected</span>
<button id="retryBtn" class="secondary" style="display:none; width: auto; padding: 4px 8px; font-size: 10px; margin: 0;">RETRY</button>
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px;">Copy Logs</button>
<button id="retryBtn" class="secondary" style="display:none; width: auto; padding: 4px 8px; font-size: 10px; margin: 0;" title="Attempt to reconnect to the server">RETRY</button>
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px;" title="Copy logs to clipboard for sharing">Copy Logs</button>
</div>
<label>Video Debug Info</label>
<label title="Technical details about the currently selected video element">Video Debug Info</label>
<div id="videoDebug" class="info-card" style="font-size: 10px; font-family: monospace; color: var(--text-muted); max-height: 250px; overflow-y: auto; line-height: 1.4;">
No tab selected or video detected.
</div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
<label>Full Action History</label>
<label title="Chronological log of all sync commands in the room">Full Action History</label>
</div>
<div id="historyList" class="info-card" style="max-height: 120px; overflow-y: auto; font-size: 10px; margin-bottom: 15px;">
<div style="text-align:center; color: var(--text-muted); font-size: 11px;">No activity yet</div>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
<label>Logs (Last 50)</label>
<button id="clearLogs" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;">CLEAR</button>
<label title="Technical connection logs for debugging">Logs (Last 50)</label>
<button id="clearLogs" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;" title="Clear log output">CLEAR</button>
</div>
<div id="logList"></div>
@@ -427,14 +540,14 @@
<script src="popup.js" type="module"></script>
<!-- Onboarding Overlay -->
<div id="onboarding-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:1000; align-items:center; justify-content:center;">
<div id="onboarding-card" style="background:var(--card); padding:24px; border-radius:16px; max-width:280px; width:90%; text-align:center; box-shadow:0 8px 32px rgba(0,0,0,0.5);">
<div id="onboarding-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.5); backdrop-filter: blur(2px); z-index:1000; align-items:flex-end; justify-content:center; padding-bottom: 20px;">
<div id="onboarding-card" style="background:var(--card); padding:24px; border-radius:16px; max-width:280px; width:90%; text-align:center; box-shadow:0 8px 32px rgba(0,0,0,0.5); ">
<div id="onboarding-icon" style="font-size:48px; margin-bottom:12px;">\u{1F44B}</div>
<h2 id="onboarding-title" style="color:var(--accent); margin:0 0 8px; font-size:16px;">Welcome to KoalaSync!</h2>
<p id="onboarding-text" style="color:var(--text-muted); font-size:13px; margin:0 0 16px; line-height:1.4;">Let's get you started.</p>
<div style="display:flex; gap:8px; justify-content:center;">
<button id="onboarding-skip" class="secondary" style="width:auto; padding:8px 16px;">Skip</button>
<button id="onboarding-next" class="primary" style="width:auto; padding:8px 16px;">Next</button>
<button id="onboarding-skip" class="secondary" style="width:auto; padding:8px 16px;" title="Skip the tutorial">Skip</button>
<button id="onboarding-next" class="primary" style="width:auto; padding:8px 16px;" title="Go to next step">Next</button>
</div>
<div id="onboarding-dots" style="margin-top:12px; display:flex; gap:6px; justify-content:center;"></div>
</div>
+214 -50
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 = {
@@ -46,7 +47,9 @@ const elements = {
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
lobbyTitle: document.getElementById('lobbyTitle'),
lobbyPeerStatus: document.getElementById('lobbyPeerStatus'),
browserNotifications: document.getElementById('browserNotifications')
browserNotifications: document.getElementById('browserNotifications'),
autoCopyInvite: document.getElementById('autoCopyInvite'),
syncTabCopyInvite: document.getElementById('syncTabCopyInvite')
};
let localPeerId = null;
@@ -60,15 +63,15 @@ let populateTabsToken = null;
let errorToken = 0;
let forceSyncDone = false;
// --- Helpers ---
// --- Initialization ---
async function init() {
// Load Settings
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications']);
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 });
}
@@ -76,16 +79,18 @@ async function init() {
elements.roomId.value = data.roomId || '';
elements.password.value = data.password || '';
elements.username.value = username;
elements.filterNoise.checked = data.filterNoise !== false;
elements.autoSyncNextEpisode.checked = data.autoSyncNextEpisode !== false;
elements.forceSyncMode.value = data.forceSyncMode || 'jump-to-others';
elements.browserNotifications.checked = data.browserNotifications === true;
if (elements.filterNoise) elements.filterNoise.checked = data.filterNoise !== false;
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = data.autoSyncNextEpisode !== false;
if (elements.forceSyncMode) elements.forceSyncMode.value = data.forceSyncMode || 'jump-to-others';
if (elements.browserNotifications) elements.browserNotifications.checked = data.browserNotifications === true;
if (elements.autoCopyInvite) elements.autoCopyInvite.checked = data.autoCopyInvite !== false;
// Set Version Info
const versionTxt = `v${chrome.runtime.getManifest().version}`;
const versionEl = document.getElementById('appVersion');
if (versionEl) {
versionEl.textContent = `v${chrome.runtime.getManifest().version}`;
}
if (versionEl) versionEl.textContent = versionTxt;
const popupVerEl = document.getElementById('popupVersion');
if (popupVerEl) popupVerEl.textContent = versionTxt;
if (data.useCustomServer) {
setServerMode(true);
@@ -142,16 +147,31 @@ function toggleUIState(inRoom) {
if (elements.sectionJoin) elements.sectionJoin.style.display = inRoom ? 'none' : 'block';
if (elements.sectionActive) elements.sectionActive.style.display = inRoom ? 'block' : 'none';
if (elements.peerListSync) elements.peerListSync.style.display = inRoom ? 'block' : 'none';
const syncActive = document.getElementById('sync-active');
const syncInactive = document.getElementById('sync-inactive');
if (syncActive) syncActive.style.display = inRoom ? 'block' : 'none';
if (syncInactive) syncInactive.style.display = inRoom ? 'none' : 'block';
}
function updateUI(roomId, password, useCustomServer = false, serverUrl = '') {
const inRoom = !!roomId;
toggleUIState(inRoom);
if (inRoom) {
const serverFlag = useCustomServer ? '1' : '0';
const encodedUrl = encodeURIComponent(serverUrl || '');
const invite = `${OFFICIAL_LANDING_PAGE_URL}/join.html#join:${roomId}:${password}:${serverFlag}:${encodedUrl}`;
let invite = `${OFFICIAL_LANDING_PAGE_URL}/join.html#join:${roomId}:${password}`;
if (useCustomServer) {
const encodedUrl = encodeURIComponent(serverUrl || '');
invite += `:1:${encodedUrl}`;
}
elements.inviteLink.value = invite;
if (window.justCreatedRoom) {
window.justCreatedRoom = false;
if (elements.autoCopyInvite && elements.autoCopyInvite.checked && elements.copyInvite) {
elements.copyInvite.click();
}
}
if (elements.activeRoomId) elements.activeRoomId.textContent = roomId;
if (elements.activeServer) {
elements.activeServer.textContent = useCustomServer ? (serverUrl || 'Custom Server') : 'Official Server';
@@ -238,6 +258,7 @@ function updateLastActionUI(state, peers) {
const isAcked = safeAcks.includes(pId) || pId === state.senderId;
const color = isAcked ? 'var(--success)' : '#475569';
const icon = isAcked ? '✓' : '...';
const avatar = getAvatarForName(pName);
const peerItem = document.createElement('div');
peerItem.title = pName;
@@ -249,7 +270,7 @@ function updateLastActionUI(state, peers) {
const nameSpan = document.createElement('span');
nameSpan.style.cssText = 'font-size:7px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:36px;';
nameSpan.textContent = pName;
nameSpan.textContent = `${avatar} ${pName}`;
peerItem.appendChild(dot);
peerItem.appendChild(nameSpan);
@@ -294,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);
@@ -368,10 +391,11 @@ function updatePeerList(peers) {
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding-right: 24px;';
const nameSpan = document.createElement('span');
const avatar = getAvatarForName(pUsername || pId);
if (pUsername) {
const u = document.createElement('span');
u.style.cssText = 'font-weight:600; color:white;';
u.textContent = pUsername;
u.textContent = `${avatar} ${pUsername}`;
const i = document.createElement('span');
i.style.cssText = 'font-size:10px; opacity:0.6; font-style:italic;';
i.textContent = ` (${pId})`;
@@ -379,7 +403,7 @@ function updatePeerList(peers) {
nameSpan.appendChild(i);
} else {
nameSpan.style.fontWeight = '600';
nameSpan.textContent = `👤 ${pId}`;
nameSpan.textContent = `${avatar} ${pId}`;
}
header.appendChild(nameSpan);
@@ -436,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);
@@ -537,7 +563,9 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
const hostname = new URL(tab.url).hostname.toLowerCase();
if (domain.endsWith('.')) return hostname.startsWith(domain) || hostname.includes('.' + domain);
if (domain.includes('.')) return hostname === domain || hostname.endsWith('.' + domain);
} catch {}
} catch {
/* ignore invalid URLs */
}
return urlStr.includes(domain);
})) return false;
}
@@ -545,30 +573,44 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
});
// Smart Matching Logic — exclude own tabTitle to prevent self-match (computed once)
const cleanTitle = (rawTitle) => {
if (!rawTitle) return '';
return rawTitle
.replace(/(?:\s*[-\|•]\s*(?:YouTube|Twitch|Jellyfin|Emby|Netflix|Vimeo|Dailymotion).*)$/i, '')
.replace(/^(?:Netflix|Twitch|YouTube|Emby|Jellyfin)\s*[-\|•]\s*/i, '')
.trim();
};
const peerTitles = peerIds
.filter(p => (typeof p === 'object' ? p.peerId : p) !== localPeerId)
.map(p => (typeof p === 'object' ? p.tabTitle : null))
.filter(t => t && t.length > 3);
.filter(t => t && t.length > 3)
.map(t => cleanTitle(t).toLowerCase())
.filter(t => t.length > 3);
filteredTabs.forEach(tab => {
const option = document.createElement('option');
option.value = tab.id;
const title = (tab.title || 'Loading...');
const rawTitle = (tab.title || 'Loading...');
const title = cleanTitle(rawTitle).toLowerCase();
const isMatch = peerTitles.some(pt => {
const t1 = title.toLowerCase();
const t2 = pt.toLowerCase();
return t1.includes(t2) || t2.includes(t1);
const isMatch = title.length > 3 && peerTitles.some(pt => {
return title.includes(pt) || pt.includes(title);
});
let label = title.substring(0, 45) + (title.length > 45 ? '...' : '');
let label = rawTitle.substring(0, 45) + (rawTitle.length > 45 ? '...' : '');
if (isMatch) {
label = `⭐ MATCH: ${label}`;
option.style.fontWeight = 'bold';
option.style.color = 'var(--star)';
}
if (tab.audible) {
label = `[🎬] ${label}`;
}
option.textContent = label;
option.dataset.originalTitle = tab.title;
elements.targetTab.appendChild(option);
});
@@ -597,6 +639,13 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
if (currentTargetTabId) {
elements.targetTab.value = currentTargetTabId;
} else {
const matchOpt = options.find(o => o.textContent.includes('⭐ MATCH:'));
if (matchOpt && elements.targetTab.options.length > 1) {
elements.targetTab.value = matchOpt.value;
const tabTitle = matchOpt.dataset.originalTitle || null;
chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId: parseInt(matchOpt.value), tabTitle });
}
}
}
@@ -643,7 +692,7 @@ function applyConnectionStatus(status) {
if (elements.playBtn) elements.playBtn.textContent = '▶ Play';
if (elements.pauseBtn) elements.pauseBtn.textContent = '⏸ Pause';
if (elements.forceSyncBtn) elements.forceSyncBtn.textContent = '⚡ Force Sync';
if (elements.forceSyncBtn) elements.forceSyncBtn.textContent = '⚡ SYNC';
}
function updateHistory(history) {
@@ -815,6 +864,12 @@ elements.browserNotifications.addEventListener('change', () => {
chrome.storage.sync.set({ browserNotifications: elements.browserNotifications.checked });
});
if (elements.autoCopyInvite) {
elements.autoCopyInvite.addEventListener('change', () => {
chrome.storage.sync.set({ autoCopyInvite: elements.autoCopyInvite.checked });
});
}
elements.forceSyncMode.addEventListener('change', () => {
chrome.storage.sync.set({ forceSyncMode: elements.forceSyncMode.value });
});
@@ -841,7 +896,14 @@ elements.tabs.forEach(btn => {
elements.tabs.forEach(b => b.classList.remove('active'));
elements.contents.forEach(c => c.classList.remove('active'));
btn.classList.add('active');
document.getElementById(btn.dataset.tab).classList.add('active');
const targetContent = document.getElementById(btn.dataset.tab);
targetContent.classList.add('active');
targetContent.classList.remove('tab-active-animate');
void targetContent.offsetWidth; // Force reflow to restart animation
targetContent.classList.add('tab-active-animate');
isDevTabVisible = btn.dataset.tab === 'tab-dev';
if (isDevTabVisible) refreshLogs();
if (btn.dataset.tab === 'tab-sync') refreshHistory();
@@ -931,17 +993,23 @@ elements.leaveBtn.addEventListener('click', async () => {
updateUI(null, null);
});
elements.createRoomBtn.addEventListener('click', () => {
const animals = ['koala', 'panda', 'tiger', 'eagle', 'fox', 'bear'];
const adj = ['happy', 'cool', 'fast', 'smart', 'brave', 'calm'];
const id = `${adj[Math.floor(Math.random() * adj.length)]}-${animals[Math.floor(Math.random() * animals.length)]}-${Math.floor(Math.random() * 100)}`;
const array = new Uint32Array(1);
window.crypto.getRandomValues(array);
const pass = array[0].toString(36).substring(0, 6);
function handleCreateRoom() {
const generateId = () => Math.random().toString(36).substring(2, 8).toUpperCase();
const roomId = generateId();
const password = generateId();
elements.roomId.value = roomId;
elements.password.value = password;
window.justCreatedRoom = true;
elements.roomId.value = id;
elements.password.value = pass;
// Auto-connect
elements.joinBtn.click();
}
elements.createRoomBtn.addEventListener('click', handleCreateRoom);
const syncTabCreateRoomBtn = document.getElementById('syncTabCreateRoomBtn');
if (syncTabCreateRoomBtn) syncTabCreateRoomBtn.addEventListener('click', () => {
document.querySelector('.tab-btn[data-tab="tab-room"]').click();
handleCreateRoom();
});
elements.refreshRooms.addEventListener('click', () => {
@@ -960,7 +1028,7 @@ elements.retryBtn.addEventListener('click', () => {
elements.targetTab.addEventListener('change', () => {
const val = elements.targetTab.value;
const tabId = val ? parseInt(val) : null;
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.text.replace('⭐ MATCH: ', '') || null;
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null;
chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle });
});
@@ -997,13 +1065,15 @@ elements.forceSyncBtn.addEventListener('click', async () => {
elements.forceSyncBtn.disabled = true;
elements.forceSyncBtn.textContent = mode === 'jump-to-others' ? `Syncing to group (${formatTime(targetTime)})...` : 'Syncing...';
forceSyncDone = false;
const peerCount = (status.peers || []).filter(p => (typeof p === 'object' ? p.peerId : p) !== localPeerId).length;
const syncTimeoutMs = peerCount === 0 ? 3000 : 12000;
const forceSyncReset = () => {
if (!forceSyncDone) {
elements.forceSyncBtn.disabled = false;
elements.forceSyncBtn.textContent = originalText;
}
};
forceSyncResetTimer = setTimeout(forceSyncReset, 12000);
forceSyncResetTimer = setTimeout(forceSyncReset, syncTimeoutMs);
const tabId = parseInt(status.targetTabId);
const sendForceSync = (time) => {
@@ -1045,19 +1115,55 @@ elements.forceSyncBtn.addEventListener('click', async () => {
});
elements.playBtn.addEventListener('click', () => {
if (!elements.targetTab.value) {
showToast('Please select a video first!', 'warning');
return;
}
elements.playBtn.textContent = '▶ Playing...';
elements.playBtn.disabled = true;
chrome.runtime.sendMessage({
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(() => {
if (elements.playBtn.disabled) {
elements.playBtn.textContent = '▶ Play';
elements.playBtn.disabled = false;
}
}, 2500);
});
elements.pauseBtn.addEventListener('click', () => {
if (!elements.targetTab.value) {
showToast('Please select a video first!', 'warning');
return;
}
elements.pauseBtn.textContent = '⏸ Pausing...';
elements.pauseBtn.disabled = true;
chrome.runtime.sendMessage({
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(() => {
if (elements.pauseBtn.disabled) {
elements.pauseBtn.textContent = '⏸ Pause';
elements.pauseBtn.disabled = false;
}
}, 2500);
});
elements.clearLogs.addEventListener('click', () => {
@@ -1083,6 +1189,21 @@ elements.copyInvite.addEventListener('click', () => {
});
});
if (elements.syncTabCopyInvite) {
elements.syncTabCopyInvite.addEventListener('click', () => {
navigator.clipboard.writeText(elements.inviteLink.value).then(() => {
const original = elements.syncTabCopyInvite.textContent;
elements.syncTabCopyInvite.textContent = '✓';
elements.syncTabCopyInvite.style.color = 'var(--success)';
showToast('Invite link copied!', 'success', 2000);
setTimeout(() => {
elements.syncTabCopyInvite.textContent = original;
elements.syncTabCopyInvite.style.color = '';
}, 2000);
});
});
}
// --- Logs & Status ---
async function refreshLogs() {
chrome.runtime.sendMessage({ type: 'GET_LOGS' }, (logs) => {
@@ -1116,12 +1237,29 @@ chrome.runtime.onMessage.addListener((msg) => {
'play': '▶ Play',
'pause': '⏸ Pause',
'seek': '⏩ Seek',
'force_sync_prepare': '⚡ Force Sync',
'force_sync_execute': '⚡ Force Play'
'force_sync_prepare': '⚡ Sync',
'force_sync_execute': '⚡ Sync Play'
};
const action = actionNames[state.action] || state.action;
showToast(`${state.senderId} ${action}`, 'info', 2000);
}
if (state && (state.action === 'play' || state.action === 'pause')) {
const btn = state.action === 'play' ? elements.playBtn : elements.pauseBtn;
if (btn && btn.disabled) {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
const peerCount = res && res.peers ? res.peers.length : 1;
if (state.acks && state.acks.length >= peerCount) {
btn.textContent = '✅ Synced!';
setTimeout(() => {
btn.disabled = false;
btn.textContent = state.action === 'play' ? '▶ Play' : '⏸ Pause';
}, 2000);
}
});
}
}
if (state && state.action === 'force_sync_execute') {
forceSyncDone = true;
if (forceSyncResetTimer) {
@@ -1130,7 +1268,10 @@ chrome.runtime.onMessage.addListener((msg) => {
}
if (elements.forceSyncBtn) {
elements.forceSyncBtn.disabled = false;
elements.forceSyncBtn.textContent = '⚡ Force Sync';
elements.forceSyncBtn.textContent = ' Synced!';
setTimeout(() => {
elements.forceSyncBtn.textContent = '⚡ SYNC';
}, 2000);
}
}
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
@@ -1317,10 +1458,11 @@ function updateLobbyUI(lobby, peers) {
peers.forEach(p => {
const pId = typeof p === 'object' ? p.peerId : p;
const pName = (typeof p === 'object' && p.username) ? p.username : pId;
const avatar = getAvatarForName(pName);
const isReady = readySet.has(pId);
const icon = isReady ? '\u2705' : '\u23f3';
const label = isReady ? 'Ready' : 'Loading...';
peerLines.push(`${icon} ${pName} \u2014 ${label}`);
peerLines.push(`${icon} ${avatar} ${pName} \u2014 ${label}`);
});
}
@@ -1339,11 +1481,11 @@ function updateLobbyUI(lobby, peers) {
// --- Onboarding Tour ---
const onboardingSteps = [
{ icon: '\u{1F44B}', title: 'Welcome to KoalaSync!', text: 'Watch videos together in perfect sync — no matter where you are. Let\'s take a quick tour!' },
{ icon: '\u{1F3E0}', title: 'Room Tab', text: 'Create a room and share the invite link with friends. Anyone with the link can join instantly.' },
{ icon: '\u{1F3AC}', title: 'Sync Tab', text: 'Pick the tab with your video. Play, pause, and seek — everyone stays in sync. Drift? Just hit Force Sync.' },
{ icon: '\u2699\uFE0F', title: 'Settings', text: 'Pick a fun username, hide distracting tabs, and enable notifications so you never miss a moment.' },
{ icon: '\u{1F389}', title: 'You\'re all set!', text: 'Open a video, create a room, and start watching together. Enjoy!' }
{ icon: '👋', title: 'Welcome to KoalaSync!', text: 'Watch videos together in perfect sync — no matter where you are. Let\'s take a quick tour!', targetTab: 'tab-room' },
{ icon: '🏠', title: '1. Create a Room', text: 'Start here. Create a room and share the invite link with your friends.', targetTab: 'tab-room' },
{ icon: '🎬', title: '2. Select Video', text: 'Navigate here to select the video you want to sync. Play, pause, and seek — everyone stays in sync.', targetTab: 'tab-sync' },
{ icon: '⚙️', title: '3. Personalize', text: 'Pick a fun username so your friends know who you are.', targetTab: 'tab-settings' },
{ icon: '🎉', title: 'You\'re all set!', text: 'Time to grab some popcorn. Enjoy watching together!', targetTab: 'tab-room' }
];
let onboardingStep = 0;
@@ -1351,6 +1493,7 @@ let onboardingStep = 0;
function showOnboarding() {
const overlay = document.getElementById('onboarding-overlay');
if (!overlay) return;
document.body.style.minHeight = '400px';
overlay.style.display = 'flex';
renderOnboardingStep();
}
@@ -1368,6 +1511,23 @@ function renderOnboardingStep() {
title.textContent = step.title;
text.textContent = step.text;
if (step.targetTab) {
const tabBtn = document.querySelector(`.tab-btn[data-tab="${step.targetTab}"]`);
if (tabBtn) tabBtn.click();
const syncActive = document.getElementById('sync-active');
const syncInactive = document.getElementById('sync-inactive');
if (step.targetTab === 'tab-sync') {
if (syncActive) syncActive.style.display = 'block';
if (syncInactive) syncInactive.style.display = 'none';
} else {
// Restore actual lock state when on other tabs so we don't leave it unlocked
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
if (syncActive) syncActive.style.display = inRoom ? 'block' : 'none';
if (syncInactive) syncInactive.style.display = inRoom ? 'none' : 'block';
}
}
dots.replaceChildren();
onboardingSteps.forEach((_, i) => {
const dot = document.createElement('div');
@@ -1381,7 +1541,11 @@ function renderOnboardingStep() {
function completeOnboarding() {
const overlay = document.getElementById('onboarding-overlay');
if (overlay) overlay.style.display = 'none';
document.body.style.minHeight = '';
chrome.storage.sync.set({ onboardingComplete: true });
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
toggleUIState(inRoom);
}
document.getElementById('onboarding-next')?.addEventListener('click', () => {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "koalasync",
"version": "1.6.1",
"version": "1.8.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "koalasync",
"version": "1.6.1",
"version": "1.8.5",
"devDependencies": {
"archiver": "^7.0.1",
"eslint": "^10.4.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "koalasync",
"version": "1.7.5",
"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'));
+63 -7
View File
@@ -44,9 +44,10 @@ const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: (origin, callback) => {
if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://')) {
if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://') || origin.startsWith('moz-extension://')) {
callback(null, true);
} else {
log('CORS', `Rejected origin: ${origin}`);
callback(new Error('Not allowed by CORS'));
}
},
@@ -93,6 +94,10 @@ function checkAuthRate(ip, roomId) {
}
function recordAuthFailure(ip, roomId) {
if (failedAuthAttempts.size > 50000) {
failedAuthAttempts.clear();
log('SECURITY', 'Cleared failedAuthAttempts map to prevent memory leak');
}
const key = `${ip}:${roomId}`;
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
record.count++;
@@ -191,7 +196,18 @@ function removePeerFromRoom(socketId, roomId, reason) {
// 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' });
const isPeerStillConnected = Array.from(room.peerData.values()).some(data => data.peerId === peerId);
if (!isPeerStillConnected) {
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
}
// 3.5. Clean up active lobby if a peer leaves
if (room.activeLobby) {
room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId);
if (room.activeLobby.readyPeers.length <= 1 || room.activeLobby.initiatorPeerId === peerId) {
room.activeLobby = null; // Dissolve lobby
}
}
// 4. Delete empty room
if (room.peers.size === 0) {
@@ -227,6 +243,12 @@ io.on('connection', (socket) => {
}
if (clientVersion) {
if (typeof clientVersion !== 'string') {
log('AUTH', `Invalid version type from ${clientIp}`);
socket.emit(EVENTS.ERROR, { message: 'Invalid version format' });
socket.disconnect(true);
return;
}
const parts = clientVersion.split('.').map(Number);
const cMaj = parts[0], cMin = parts[1], cPatch = parts[2] || 0;
const [mMaj, mMin, mPatch] = MIN_VERSION.split('.').map(Number);
@@ -286,11 +308,13 @@ io.on('connection', (socket) => {
const ip = socket._clientIp || socket.handshake.address;
if (!checkAuthRate(ip, roomId)) {
log('AUTH', `Auth rate limit blocked ${ip} from room ${roomId.substring(0, 3)}***`);
socket.emit(EVENTS.ERROR, { message: "Too many failed attempts. Try again later." });
return;
}
let room = rooms.get(roomId);
let createdByMe = false;
if (!room) {
// Acquire per-room creation lock to prevent race conditions
@@ -298,9 +322,6 @@ io.on('connection', (socket) => {
if (lockPromise) {
await lockPromise;
room = rooms.get(roomId);
if (room) {
// Another concurrent request created it, fall through to password check
}
}
if (!room) {
// Create and store lock before async boundary
@@ -309,6 +330,7 @@ io.on('connection', (socket) => {
roomCreationLocks.set(roomId, lockPromise);
try {
if (rooms.size >= MAX_ROOMS) {
log('ROOM', `Server at capacity: ${rooms.size}/${MAX_ROOMS} rooms — rejecting join`);
socket.emit(EVENTS.ERROR, { message: "Server capacity reached" });
return;
}
@@ -322,21 +344,31 @@ io.on('connection', (socket) => {
lastActivity: Date.now()
};
rooms.set(roomId, room);
createdByMe = true;
log('ROOM', `Created room: ${roomId.substring(0, 3)}***`);
} finally {
roomCreationLocks.delete(roomId);
resolveLock();
}
}
} else {
}
if (!room) {
socket.emit(EVENTS.ERROR, { message: "Join error" });
return;
}
if (!createdByMe) {
if (room.passwordHash) {
if (!password || !(await bcrypt.compare(password, room.passwordHash))) {
recordAuthFailure(ip, roomId);
log('AUTH', `Invalid password from ${ip} for room ${roomId.substring(0, 3)}***`);
socket.emit(EVENTS.ERROR, { message: "Invalid password" });
return;
}
}
if (room.peers.size >= MAX_PEERS_PER_ROOM) {
log('ROOM', `Room full (${room.peers.size}/${MAX_PEERS_PER_ROOM}): ${roomId.substring(0, 3)}***`);
socket.emit(EVENTS.ERROR, { message: "Room full" });
return;
}
@@ -380,7 +412,8 @@ io.on('connection', (socket) => {
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, mediaTitle: mediaTitle || null, status: 'joined' });
socket.emit(EVENTS.ROOM_DATA, {
roomId,
peers: Array.from(room.peers).map(sid => room.peerData.get(sid))
peers: Array.from(room.peers).map(sid => room.peerData.get(sid)),
activeLobby: room.activeLobby || null
});
log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`);
} catch (err) {
@@ -438,6 +471,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),
@@ -455,6 +489,21 @@ io.on('connection', (socket) => {
// Strip undefined keys for clean wire format
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
socket.to(mapping.roomId).emit(eventName, relayPayload);
// --- Side-effects: Server-side Episode Lobby Tracking ---
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle) {
room.activeLobby = {
expectedTitle: relayPayload.expectedTitle,
initiatorPeerId: mapping.peerId,
readyPeers: [mapping.peerId]
};
} else if (eventName === EVENTS.EPISODE_READY && room.activeLobby) {
if (!room.activeLobby.readyPeers.includes(mapping.peerId)) {
room.activeLobby.readyPeers.push(mapping.peerId);
}
} else if ((eventName === EVENTS.FORCE_SYNC_PREPARE || eventName === EVENTS.FORCE_SYNC_EXECUTE) && room.activeLobby) {
room.activeLobby = null;
}
}
}
} catch (err) {
@@ -464,6 +513,11 @@ io.on('connection', (socket) => {
});
socket.on(EVENTS.GET_ROOMS, () => {
if (!checkEventRate(socket.id)) {
log('SECURITY', `Event rate limit exceeded for socket (GET_ROOMS): ${socket.id}`);
socket.disconnect(true);
return;
}
const list = Array.from(rooms.entries()).map(([id, r]) => ({
id,
peerCount: r.peers.size,
@@ -581,8 +635,10 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('uncaughtException', (err) => {
log('ERROR', `Uncaught exception: ${err.message}`, err.stack);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
log('ERROR', `Unhandled rejection: ${reason}`);
process.exit(1);
});
+1 -1
View File
@@ -7,7 +7,7 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "1.3.1";
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}`;
}
+3 -3
View File
@@ -195,7 +195,7 @@ document.addEventListener('DOMContentLoaded', () => {
const isDE = document.documentElement.classList.contains('lang-de');
title.textContent = isDE ? 'Erfolgreich!' : 'Success!';
let count = 3;
let count = 2;
const updateCountdown = () => {
const closingMsg = isDE
? `Du bist dem Raum beigetreten. <br><span style="color:var(--accent); font-weight:bold;">Dieser Tab schließt sich in ${count} Sekunden...</span>`
@@ -226,8 +226,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (banner) {
if (success) {
banner.style.background = 'var(--success)';
banner.innerHTML = '<div class="container">✅ Joined! This tab will close in 3s...</div>';
setTimeout(() => window.close(), 3000);
banner.innerHTML = '<div class="container">✅ Joined! This tab will close in 2s...</div>';
setTimeout(() => window.close(), 2000);
} else {
banner.style.background = 'var(--error)';
banner.innerHTML = '';
+18 -15
View File
@@ -3,15 +3,18 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KoalaSync | Real-time Video Synchronization for Friends</title>
<meta name="description" content="Watch YouTube, Twitch, and HTML5 videos in sync with friends. KoalaSync is a privacy-first, open-source browser extension for Chrome and Firefox.">
<title>KoalaSync | Sync Netflix, Emby, Jellyfin & Any Video with Friends Browser Extension</title>
<meta name="description" content="Watch Netflix, Emby, Jellyfin, YouTube, Twitch and any HTML5 video in perfect sync with friends. KoalaSync is a privacy-first, open-source browser extension for Chrome and Firefox. Works on almost any site with a video element.">
<link rel="stylesheet" href="style.css">
<link rel="icon" type="image/png" href="assets/logo.png">
<meta property="og:title" content="KoalaSync | Sync your videos">
<meta property="og:description" content="Watch together, stay in sync. Privacy-first video synchronization.">
<link rel="canonical" href="https://sync.koalastuff.net/">
<meta name="robots" content="index, follow">
<meta property="og:title" content="KoalaSync | Sync Netflix, Emby, Jellyfin & Any Video with Friends">
<meta property="og:description" content="Watch Netflix, Emby, Jellyfin, YouTube, Twitch and any HTML5 video in perfect sync. Privacy-first, open-source browser extension for Chrome & Firefox.">
<meta property="og:image" content="https://sync.koalastuff.net/assets/logo.png">
<meta property="og:type" content="website">
<meta property="og:url" content="https://sync.koalastuff.net/">
<script src="lang-init.js"></script>
</head>
@@ -54,8 +57,8 @@
<span lang="de">Gemeinsam schauen.<br>Perfekt synchron.</span>
</h1>
<h2 class="hero-subtitle" data-reveal>
<span lang="en">A free, open-source watch party extension for YouTube, Twitch, Netflix, and almost any website with a video player. Built for reliable synchronization and data sovereignty.</span>
<span lang="de">Eine kostenlose, quelloffene Watch-Party-Erweiterung für YouTube, Twitch, Netflix und fast jede Webseite mit einem Videoplayer. Entwickelt für zuverlässige Synchronisation und Datenhoheit.</span>
<span lang="en">Your remote movie night without lags. No registration, no data collection. Just share a link and watch together.</span>
<span lang="de">Dein Kino-Abend auf Distanz. Keine Lags, keine Anmeldung. Einfach Link teilen und zusammen schauen.</span>
</h2>
<div class="cta-group" data-reveal>
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc" class="btn btn-primary">
@@ -100,7 +103,7 @@
<div class="mock-label"><span lang="en">Invite Link</span><span lang="de">Einladungs-Link</span></div>
<div class="mock-invite-box">
<input type="text" class="mock-input" value="https://sync.koalastuff.net/join.html#join:brave-eagle-80:pass" readonly>
<button class="mock-btn" style="padding: 0.35rem 0.5rem;" onclick="navigator.clipboard.writeText('https://sync.koalastuff.net/join.html#join:brave-eagle-80:pass')"><span lang="en">Copy</span><span lang="de">Kopieren</span></button>
<button class="mock-btn" style="padding: 0.35rem 0.5rem;"><span lang="en">Copy</span><span lang="de">Kopieren</span></button>
</div>
</div>
<div class="mock-card" style="margin-bottom: 12px;">
@@ -131,7 +134,7 @@
<div id="mock-sync" class="mock-screen">
<div class="mock-form-group" style="margin-bottom: 12px;">
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
<span lang="en">Target Tab (Video Source)</span><span lang="de">Ziel-Tab (Video-Quelle)</span>
<span lang="en">Select Video</span><span lang="de">Video auswählen</span>
</label>
<select class="mock-input" style="width: 100%; box-sizing: border-box; background: var(--card); border: 1px solid #334155; color: white; padding: 8px; border-radius: 8px; font-family: inherit; font-size: 0.75rem; outline: none; cursor: default;">
<option>🎬 Germany's Next Flopmodel - S21E12</option>
@@ -146,7 +149,7 @@
<button class="mock-btn mock-btn-pause" style="flex: 1; background: #ef4444; color: white; padding: 8px; border-radius: 8px; font-weight: 700; border: none; cursor: pointer;"><span lang="en">⏸ Pause</span><span lang="de">⏸ Pause</span></button>
</div>
<button class="mock-btn mock-btn-force" style="background: linear-gradient(135deg, #6366f1, #a855f7); width: 100%; padding: 10px; color: white; border: none; font-weight: 700; border-radius: 8px; margin-bottom: 15px; cursor: pointer;">
<span lang="en">Force Sync Everyone</span><span lang="de">Synchronisation erzwingen</span>
<span lang="en">Sync All Viewers</span><span lang="de">Alle synchronisieren</span>
</button>
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
@@ -174,7 +177,7 @@
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 8px; border: 1px solid #334155;">
<label style="margin: 0; font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 700; cursor: pointer;" for="chk-noise">
<span lang="en">Filter Noise Tabs</span><span lang="de">Noise-Tabs filtern</span>
<span lang="en">Show Video Tabs Only</span><span lang="de">Nur Video-Tabs anzeigen</span>
</label>
<input type="checkbox" id="chk-noise" checked disabled style="width: auto; cursor: not-allowed;">
</div>
@@ -277,8 +280,8 @@
<span lang="en">Zero Accounts / Pure Privacy</span>
<span lang="de">Keine Accounts / Datenschutz</span>
</h3>
<p lang="en">No registration, no tracking, and no database persistence. The server runs entirely in RAM, collects no telemetry or persistent logs, and purges your room when you leave.</p>
<p lang="de">Keine Registrierung, kein Tracking und keine Datenspeicherung. Der Server läuft flüchtig im RAM, speichert keine dauerhaften Logs und löscht deinen Raum nach dem Verlassen.</p>
<p lang="en">No registration, no tracking, no bullshit. Just share a link and watch. The server runs entirely in RAM and purges your room when you leave.</p>
<p lang="de">Kein Login. Keine Daten. Einfach Link teilen und schauen. Der Server läuft flüchtig im RAM und löscht deinen Raum komplett nach dem Verlassen.</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">🌐</div>
@@ -295,8 +298,8 @@
<span lang="en">Self-Hostable & Docker-Ready</span>
<span lang="de">Self-Hostable & Docker-Ready</span>
</h3>
<p lang="en">Take full ownership of your sync relay. We offer ready-to-use Docker images and lightweight Caddy configurations, enabling you to launch your private server in seconds.</p>
<p lang="de">Übernimm die volle Kontrolle. Mit unseren fertigen Docker-Images und einfachen Caddy-Konfigurationen startest du deinen eigenen, komplett privaten Relay-Server in wenigen Sekunden.</p>
<p lang="en">Our official servers are free and fast, but you <strong>can</strong> take full ownership if you want to. Launch your own private relay server in seconds via Docker.</p>
<p lang="de">Unsere Server sind kostenlos und schnell, aber du <strong>kannst</strong> auch die volle Kontrolle übernehmen, wenn du willst. Starte dein eigenes privates Relay in Sekunden via Docker.</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">🔗</div>
@@ -454,7 +457,7 @@
<span class="t-key">-</span> <span class="t-str">MAX_PEERS_PER_ROOM=50</span>
<span class="t-key">pids_limit:</span> <span class="t-val">2048</span></code></pre>
<div style="margin-top: 1rem; font-size: 0.75rem; text-align: right; padding-right: 0.5rem;">
<a href="https://github.com/Shik3i/KoalaSync/pkgs/container/koalasync" target="_blank" style="color: var(--accent); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; font-weight: 600; transition: opacity 0.2s; opacity: 0.85;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.85'">
<a href="https://github.com/Shik3i/KoalaSync/pkgs/container/koalasync" target="_blank" style="color: var(--accent); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; font-weight: 600; transition: opacity 0.2s; opacity: 0.85;">
📦 <span lang="en">View all image tags on GitHub Packages</span><span lang="de">Alle Image-Tags auf GitHub Packages ansehen</span>
</a>
</div>
+2
View File
@@ -1,4 +1,6 @@
# KoalaSync Website — Allow all crawlers, full indexing
User-agent: *
Allow: /
# Sitemap for search engines
Sitemap: https://sync.koalastuff.net/sitemap.xml
+2 -2
View File
@@ -1,4 +1,4 @@
{
"version": "1.7.5",
"date": "2026-05-25T14:00:00Z"
"version": "1.9.0",
"date": "2026-05-28T02:55:04Z"
}