mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 20:18:14 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93acd0b44c | |||
| ce7b5c47f2 | |||
| 92fb8e2d00 | |||
| 43cde9bbef | |||
| 56955027f9 | |||
| 5ef059a94f | |||
| 98b4fc5fb4 | |||
| 762d6425be | |||
| 1fba2fb69c | |||
| eca259281a | |||
| afd28be2e6 | |||
| cc0265c836 | |||
| 6ebff9ab4c | |||
| 35e779c1ff | |||
| 09f0e04891 | |||
| 9eff53ba46 | |||
| bde2f7ea55 |
+217
-29
@@ -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,24 @@ 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
|
||||
let expectedAcksCount = 0; // Snapshot of peerCount when initiating Force Sync
|
||||
|
||||
// --- 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,9 +54,10 @@ function ensureState() {
|
||||
'logs', 'history', 'currentRoom', 'lastActionState',
|
||||
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
|
||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle',
|
||||
'episodeLobby'
|
||||
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount'
|
||||
], (data) => {
|
||||
clearTimeout(storageTimeout);
|
||||
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
|
||||
if (data.currentTabId !== undefined) currentTabId = data.currentTabId;
|
||||
if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle;
|
||||
// Merge data from storage with any early-arriving state
|
||||
@@ -84,6 +104,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 +148,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 +229,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 +575,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 +655,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 +680,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) });
|
||||
@@ -613,14 +707,20 @@ function handleServerEvent(event, data) {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if all peers responded
|
||||
const peerCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
|
||||
if (forceSyncAcks.size >= peerCount) {
|
||||
// Check if all peers responded using the snapshot count
|
||||
const targetCount = expectedAcksCount > 0 ? expectedAcksCount : (currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1);
|
||||
if (forceSyncAcks.size >= targetCount) {
|
||||
executeForceSync();
|
||||
}
|
||||
}
|
||||
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 +767,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(() => {});
|
||||
@@ -685,8 +788,9 @@ function handleServerEvent(event, data) {
|
||||
}
|
||||
|
||||
if (isForceSyncInitiator) {
|
||||
const peerCount = Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
|
||||
if (forceSyncAcks.size >= peerCount) {
|
||||
expectedAcksCount = Math.max(1, currentRoom.peers ? currentRoom.peers.length : 1);
|
||||
chrome.storage.session.set({ expectedAcksCount });
|
||||
if (forceSyncAcks.size >= expectedAcksCount) {
|
||||
executeForceSync();
|
||||
}
|
||||
}
|
||||
@@ -701,12 +805,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 +829,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 +838,7 @@ function handleServerEvent(event, data) {
|
||||
episodeLobby = {
|
||||
expectedTitle: data.expectedTitle,
|
||||
initiatorPeerId: data.senderId,
|
||||
readyPeers: [],
|
||||
readyPeers: [data.senderId], // Initiator is already ready
|
||||
createdAt: Date.now()
|
||||
};
|
||||
persistEpisodeLobby();
|
||||
@@ -764,6 +870,13 @@ function handleServerEvent(event, data) {
|
||||
}
|
||||
}
|
||||
break;
|
||||
case EVENTS.EPISODE_LOBBY_CANCEL:
|
||||
if (episodeLobby) {
|
||||
const title = episodeLobby.expectedTitle;
|
||||
clearEpisodeLobbyState();
|
||||
addLog(`Episode lobby for "${title}" cancelled by ${data.senderId || 'peer'}`, 'warn');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
addLog(`Received unknown event from server: ${event}`, 'warn');
|
||||
break;
|
||||
@@ -774,10 +887,12 @@ function executeForceSync() {
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
expectedAcksCount = 0;
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null
|
||||
forceSyncDeadline: null,
|
||||
expectedAcksCount: 0
|
||||
});
|
||||
|
||||
// Set all peers to playing and apply a reactive lock to block stale heartbeats
|
||||
@@ -795,8 +910,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');
|
||||
}
|
||||
|
||||
@@ -828,6 +946,10 @@ function clearEpisodeLobbyState() {
|
||||
function cancelEpisodeLobby(reason) {
|
||||
if (!episodeLobby) return;
|
||||
const title = episodeLobby.expectedTitle;
|
||||
|
||||
// Broadcast cancellation to room
|
||||
emit(EVENTS.EPISODE_LOBBY_CANCEL, { peerId });
|
||||
|
||||
clearEpisodeLobbyState();
|
||||
addLog(`Episode lobby cancelled: ${reason} for "${title}"`, 'warn');
|
||||
|
||||
@@ -849,6 +971,7 @@ function executeEpisodeLobby() {
|
||||
|
||||
isForceSyncInitiator = true;
|
||||
forceSyncAcks.clear();
|
||||
expectedAcksCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
|
||||
const deadline = Date.now() + FORCE_SYNC_TIMEOUT;
|
||||
const timestamp = Date.now();
|
||||
updateLastAction(EVENTS.FORCE_SYNC_PREPARE, 'You', timestamp);
|
||||
@@ -857,12 +980,15 @@ function executeEpisodeLobby() {
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: true,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: deadline
|
||||
forceSyncDeadline: deadline,
|
||||
expectedAcksCount: expectedAcksCount
|
||||
});
|
||||
|
||||
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 +1046,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 +1057,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');
|
||||
});
|
||||
@@ -980,11 +1116,13 @@ function leaveOldRoomIfSwitching(newRoomId) {
|
||||
// Reset force sync states
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
expectedAcksCount = 0;
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null
|
||||
forceSyncDeadline: null,
|
||||
expectedAcksCount: 0
|
||||
});
|
||||
|
||||
// Cancel any active episode lobby
|
||||
@@ -1059,6 +1197,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
expectedAcksCount = 0;
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
|
||||
// Cancel any active episode lobby
|
||||
@@ -1069,7 +1208,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null,
|
||||
episodeLobby: null
|
||||
episodeLobby: null,
|
||||
expectedAcksCount: 0
|
||||
});
|
||||
addLog('Left Room', 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
@@ -1135,10 +1275,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, {
|
||||
@@ -1149,16 +1292,19 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
isForceSyncInitiator = true;
|
||||
forceSyncAcks.clear();
|
||||
expectedAcksCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
|
||||
const deadline = Date.now() + FORCE_SYNC_TIMEOUT;
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: true,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: deadline
|
||||
forceSyncDeadline: deadline,
|
||||
expectedAcksCount: expectedAcksCount
|
||||
});
|
||||
addLog('Initiating Force Sync...', 'info');
|
||||
|
||||
routeToContent(EVENTS.FORCE_SYNC_PREPARE, message.payload);
|
||||
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
forceSyncTimeout = setTimeout(() => {
|
||||
if (isForceSyncInitiator) {
|
||||
addLog('Force Sync: Timeout waiting for ACKs, executing anyway...', 'warn');
|
||||
@@ -1169,7 +1315,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
addToHistory(message.action, 'You');
|
||||
|
||||
const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK;
|
||||
const hasOtherPeers = currentRoom && Array.isArray(currentRoom.peers) && currentRoom.peers.length > 0;
|
||||
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
||||
const hasOtherPeers = otherCount > 0;
|
||||
|
||||
if (isNonEssentialEvent && !hasOtherPeers) {
|
||||
sendResponse({ status: 'ok_solo' });
|
||||
@@ -1215,7 +1362,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
executeForceSync();
|
||||
}
|
||||
} else {
|
||||
emit(EVENTS.FORCE_SYNC_ACK, { peerId });
|
||||
localSeq++;
|
||||
chrome.storage.session.set({ localSeq });
|
||||
emit(EVENTS.FORCE_SYNC_ACK, { peerId, seq: localSeq });
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CMD_ACK') {
|
||||
@@ -1310,7 +1459,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
|
||||
// If lobby already exists for this title, just mark self ready
|
||||
if (episodeLobby && episodeLobby.expectedTitle === newTitle) {
|
||||
if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, newTitle)) {
|
||||
if (!episodeLobby.readyPeers.includes(peerId)) {
|
||||
episodeLobby.readyPeers.push(peerId);
|
||||
persistEpisodeLobby();
|
||||
@@ -1357,7 +1506,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,6 +1524,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else {
|
||||
sendResponse({ lobbyActive: false });
|
||||
}
|
||||
} else if (message.type === 'CANCEL_EPISODE_LOBBY') {
|
||||
if (episodeLobby) {
|
||||
cancelEpisodeLobby('Cancelled by user');
|
||||
sendResponse({ status: 'ok' });
|
||||
} else {
|
||||
sendResponse({ error: 'No active lobby' });
|
||||
}
|
||||
} else {
|
||||
// Final fallback to prevent channel hanging
|
||||
sendResponse({ error: 'unhandled_message' });
|
||||
@@ -1382,18 +1538,50 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
|
||||
// Tab removal listener
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
||||
await ensureState();
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Re-inject on full page refresh
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, _tab) => {
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
|
||||
await ensureState();
|
||||
if (currentTabId && tabId === parseInt(currentTabId) && changeInfo.status === 'complete') {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* global cloneInto */
|
||||
/**
|
||||
* KoalaSync Bridge Script
|
||||
* Injected into sync.koalastuff.net to facilitate communication between
|
||||
|
||||
+138
-42
@@ -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(() => {});
|
||||
@@ -66,7 +81,12 @@
|
||||
function findVideo(root = document) {
|
||||
const video = root.querySelector('video');
|
||||
if (video) return video;
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
|
||||
// Optimize: scan only potential player, video, media, and stream hosts by matching typical keywords (case-insensitive)
|
||||
// or common custom element tags. This prevents recursive scanning of thousands of standard DOM nodes (div, span, a, etc.)
|
||||
// while guaranteeing 100% airtight compatibility with all video web components in the wild.
|
||||
const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player');
|
||||
for (const el of potentialHosts) {
|
||||
if (el.shadowRoot) {
|
||||
const found = findVideo(el.shadowRoot);
|
||||
if (found) return found;
|
||||
@@ -82,6 +102,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 +146,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 +179,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 +250,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 +266,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 +279,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 +334,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 +373,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 +420,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 +474,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,12 +495,8 @@
|
||||
|
||||
const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null));
|
||||
|
||||
if (eventState && expectedEvents.has(eventState)) {
|
||||
expectedEvents.delete(eventState);
|
||||
if (expectedTimeouts[eventState]) {
|
||||
clearTimeout(expectedTimeouts[eventState]);
|
||||
delete expectedTimeouts[eventState];
|
||||
}
|
||||
if (_suppressTimers[eventState]) {
|
||||
_clearSuppress(eventState);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -446,7 +528,7 @@
|
||||
// pause immediately after switching back.
|
||||
let pageVisible = !document.hidden;
|
||||
let visibilityGraceUntil = 0;
|
||||
const VISIBILITY_GRACE_MS = 1000;
|
||||
const VISIBILITY_GRACE_MS = 300;
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
@@ -480,13 +562,9 @@
|
||||
const current = video.currentTime;
|
||||
if (!Number.isFinite(current)) return;
|
||||
|
||||
// Step 1: Check expectedEvents (programmatic seek from remote peer — ALWAYS process)
|
||||
if (expectedEvents.has('seek')) {
|
||||
expectedEvents.delete('seek');
|
||||
if (expectedTimeouts['seek']) {
|
||||
clearTimeout(expectedTimeouts['seek']);
|
||||
delete expectedTimeouts['seek'];
|
||||
}
|
||||
// Step 1: Check _suppressTimers (programmatic seek from remote peer)
|
||||
if (_suppressTimers['seek']) {
|
||||
_clearSuppress('seek');
|
||||
lastReportedSeekTime = current;
|
||||
return;
|
||||
}
|
||||
@@ -643,6 +721,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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KoalaSync",
|
||||
"version": "1.8.7",
|
||||
"description": "Watch party extension to synchronize video playback on YouTube, Twitch, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"version": "1.9.1",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"tabs",
|
||||
|
||||
+40
-5
@@ -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;
|
||||
@@ -288,7 +314,15 @@
|
||||
</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" title="Room settings and connection">Room</button>
|
||||
@@ -406,7 +440,8 @@
|
||||
<span style="font-weight: 700; color: var(--star); font-size: 12px;">EPISODE LOBBY</span>
|
||||
</div>
|
||||
<div id="lobbyTitle" style="font-size: 11px; color: var(--text); margin-bottom: 6px; font-weight: 600;"></div>
|
||||
<div id="lobbyPeerStatus" style="font-size: 10px; color: var(--text-muted);"></div>
|
||||
<div id="lobbyPeerStatus" style="font-size: 10px; color: var(--text-muted); margin-bottom: 8px;"></div>
|
||||
<button id="cancelLobbyBtn" class="secondary" style="margin-top: 4px; padding: 6px 10px; font-size: 11px; width: auto; display: block;" title="Cancel lobby and play anyway">Skip & Play anyway</button>
|
||||
</div>
|
||||
|
||||
<div id="peerListSync" class="info-card" style="display:none;"></div>
|
||||
|
||||
+71
-41
@@ -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 = {
|
||||
@@ -48,7 +49,8 @@ const elements = {
|
||||
lobbyPeerStatus: document.getElementById('lobbyPeerStatus'),
|
||||
browserNotifications: document.getElementById('browserNotifications'),
|
||||
autoCopyInvite: document.getElementById('autoCopyInvite'),
|
||||
syncTabCopyInvite: document.getElementById('syncTabCopyInvite')
|
||||
syncTabCopyInvite: document.getElementById('syncTabCopyInvite'),
|
||||
cancelLobbyBtn: document.getElementById('cancelLobbyBtn')
|
||||
};
|
||||
|
||||
let localPeerId = null;
|
||||
@@ -63,24 +65,6 @@ let errorToken = 0;
|
||||
let forceSyncDone = false;
|
||||
|
||||
// --- Helpers ---
|
||||
function getAvatarForName(username) {
|
||||
if (!username) return '👤';
|
||||
const lower = username.toLowerCase();
|
||||
const map = {
|
||||
'koala': '🐨', 'panda': '🐼', 'tiger': '🐯', 'eagle': '🦅',
|
||||
'fox': '🦊', 'bear': '🐻', 'wolf': '🐺', 'lion': '🦁',
|
||||
'hawk': '🦅', 'seal': '🦭', 'owl': '🦉', 'shark': '🦈',
|
||||
'dragon': '🐉', 'phoenix': '🐦', 'falcon': '🦅', 'panther': '🐆',
|
||||
'raven': '🐦⬛', 'cobra': '🐍', 'lynx': '🐈', 'jaguar': '🐆',
|
||||
'orca': '🐋', 'mantis': '🦗', 'viper': '🐍', 'condor': '🦅',
|
||||
'badger': '🦡', 'otter': '🦦', 'rhino': '🦏', 'crane': '🦩',
|
||||
'mongoose': '🦦', 'specter': '👻'
|
||||
};
|
||||
for (const [key, emoji] of Object.entries(map)) {
|
||||
if (lower.includes(key)) return emoji;
|
||||
}
|
||||
return '👤';
|
||||
}
|
||||
|
||||
// --- Initialization ---
|
||||
async function init() {
|
||||
@@ -88,9 +72,7 @@ async function init() {
|
||||
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite']);
|
||||
let username = data.username;
|
||||
if (!username) {
|
||||
const adjs = ['Happy', 'Cool', 'Fast', 'Smart', 'Brave', 'Calm', 'Sneaky', 'Lazy', 'Wild', 'Chill', 'Lucky', 'Epic', 'Swift', 'Bold', 'Mighty', 'Cosmic', 'Neon', 'Shadow', 'Crystal', 'Thunder', 'Silent', 'Golden', 'Fierce', 'Noble', 'Mystic', 'Frozen', 'Blazing', 'Sapphire', 'Iron', 'Crimson'];
|
||||
const nouns = ['Koala', 'Panda', 'Tiger', 'Eagle', 'Fox', 'Bear', 'Wolf', 'Lion', 'Hawk', 'Seal', 'Owl', 'Shark', 'Dragon', 'Phoenix', 'Falcon', 'Panther', 'Raven', 'Cobra', 'Lynx', 'Jaguar', 'Orca', 'Mantis', 'Viper', 'Condor', 'Badger', 'Otter', 'Rhino', 'Crane', 'Mongoose', 'Specter'];
|
||||
username = `${adjs[Math.floor(Math.random() * adjs.length)]}${nouns[Math.floor(Math.random() * nouns.length)]}`;
|
||||
username = generateUsername();
|
||||
chrome.storage.sync.set({ username });
|
||||
}
|
||||
|
||||
@@ -105,10 +87,11 @@ async function init() {
|
||||
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);
|
||||
@@ -333,7 +316,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);
|
||||
@@ -407,18 +392,20 @@ function updatePeerList(peers) {
|
||||
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding-right: 24px;';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.style.cssText = 'display: inline-flex; align-items: center; max-width: 200px; overflow: hidden; white-space: nowrap;';
|
||||
const avatar = getAvatarForName(pUsername || pId);
|
||||
if (pUsername) {
|
||||
const u = document.createElement('span');
|
||||
u.style.cssText = 'font-weight:600; color:white;';
|
||||
u.style.cssText = 'font-weight:600; color:white; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; display: inline-block;';
|
||||
u.textContent = `${avatar} ${pUsername}`;
|
||||
const i = document.createElement('span');
|
||||
i.style.cssText = 'font-size:10px; opacity:0.6; font-style:italic;';
|
||||
i.style.cssText = 'font-size:10px; opacity:0.6; font-style:italic; white-space: nowrap; flex-shrink: 0;';
|
||||
i.textContent = ` (${pId})`;
|
||||
nameSpan.appendChild(u);
|
||||
nameSpan.appendChild(i);
|
||||
} else {
|
||||
nameSpan.style.fontWeight = '600';
|
||||
nameSpan.style.cssText = 'white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 170px;';
|
||||
nameSpan.textContent = `${avatar} ${pId}`;
|
||||
}
|
||||
|
||||
@@ -476,7 +463,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);
|
||||
@@ -577,7 +566,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;
|
||||
}
|
||||
@@ -585,23 +576,32 @@ 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';
|
||||
@@ -997,16 +997,21 @@ elements.leaveBtn.addEventListener('click', async () => {
|
||||
});
|
||||
|
||||
function handleCreateRoom() {
|
||||
const generateId = () => Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
const roomId = generateId();
|
||||
const password = generateId();
|
||||
const secureGenerateId = (length = 6) => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const array = new Uint8Array(length);
|
||||
self.crypto.getRandomValues(array);
|
||||
return Array.from(array, byte => chars[byte % chars.length]).join('');
|
||||
};
|
||||
const roomId = secureGenerateId();
|
||||
const password = secureGenerateId();
|
||||
elements.roomId.value = roomId;
|
||||
elements.password.value = password;
|
||||
window.justCreatedRoom = true;
|
||||
|
||||
// Auto-connect
|
||||
elements.joinBtn.click();
|
||||
};
|
||||
}
|
||||
|
||||
elements.createRoomBtn.addEventListener('click', handleCreateRoom);
|
||||
const syncTabCreateRoomBtn = document.getElementById('syncTabCreateRoomBtn');
|
||||
@@ -1128,6 +1133,11 @@ elements.playBtn.addEventListener('click', () => {
|
||||
type: 'CONTENT_EVENT',
|
||||
action: EVENTS.PLAY,
|
||||
payload: {}
|
||||
}, (response) => {
|
||||
if (response && response.status === 'ok_solo') {
|
||||
elements.playBtn.textContent = '▶ Play';
|
||||
elements.playBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
// Safety reset: restore button after 2.5s in case no peers respond
|
||||
setTimeout(() => {
|
||||
@@ -1149,6 +1159,11 @@ elements.pauseBtn.addEventListener('click', () => {
|
||||
type: 'CONTENT_EVENT',
|
||||
action: EVENTS.PAUSE,
|
||||
payload: {}
|
||||
}, (response) => {
|
||||
if (response && response.status === 'ok_solo') {
|
||||
elements.pauseBtn.textContent = '⏸ Pause';
|
||||
elements.pauseBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
// Safety reset: restore button after 2.5s in case no peers respond
|
||||
setTimeout(() => {
|
||||
@@ -1197,6 +1212,21 @@ if (elements.syncTabCopyInvite) {
|
||||
});
|
||||
}
|
||||
|
||||
if (elements.cancelLobbyBtn) {
|
||||
elements.cancelLobbyBtn.addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ type: 'CANCEL_EPISODE_LOBBY' }, (response) => {
|
||||
if (response && response.status === 'ok') {
|
||||
showToast('Episode Lobby skipped.', 'info');
|
||||
if (elements.episodeLobbyCard) {
|
||||
elements.episodeLobbyCard.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
showToast('Failed to skip lobby.', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Logs & Status ---
|
||||
async function refreshLogs() {
|
||||
chrome.runtime.sendMessage({ type: 'GET_LOGS' }, (logs) => {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "1.8.8",
|
||||
"version": "1.9.1",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
+55
-5
@@ -95,8 +95,24 @@ function checkAuthRate(ip, roomId) {
|
||||
|
||||
function recordAuthFailure(ip, roomId) {
|
||||
if (failedAuthAttempts.size > 50000) {
|
||||
failedAuthAttempts.clear();
|
||||
log('SECURITY', 'Cleared failedAuthAttempts map to prevent memory leak');
|
||||
const now = Date.now();
|
||||
// 1. Clear expired entries (> 15 mins)
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
failedAuthAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If still over 50k, perform LRU-style eviction on the oldest 10,000 entries
|
||||
if (failedAuthAttempts.size > 50000) {
|
||||
log('SECURITY', 'failedAuthAttempts size exceeded 50000. Performing LRU-style eviction.');
|
||||
const sortedEntries = Array.from(failedAuthAttempts.entries())
|
||||
.sort((a, b) => a[1].lastAttempt - b[1].lastAttempt);
|
||||
|
||||
for (let i = 0; i < 10000 && i < sortedEntries.length; i++) {
|
||||
failedAuthAttempts.delete(sortedEntries[i][0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
const key = `${ip}:${roomId}`;
|
||||
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
|
||||
@@ -196,7 +212,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) {
|
||||
@@ -342,6 +369,11 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!room) {
|
||||
socket.emit(EVENTS.ERROR, { message: "Join error" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!createdByMe) {
|
||||
if (room.passwordHash) {
|
||||
if (!password || !(await bcrypt.compare(password, room.passwordHash))) {
|
||||
@@ -396,7 +428,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) {
|
||||
@@ -412,7 +445,8 @@ io.on('connection', (socket) => {
|
||||
EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
|
||||
EVENTS.PEER_STATUS, EVENTS.FORCE_SYNC_PREPARE,
|
||||
EVENTS.FORCE_SYNC_ACK, EVENTS.FORCE_SYNC_EXECUTE,
|
||||
EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY
|
||||
EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY,
|
||||
EVENTS.EPISODE_LOBBY_CANCEL
|
||||
];
|
||||
|
||||
relayEvents.forEach(eventName => {
|
||||
@@ -454,6 +488,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),
|
||||
@@ -471,6 +506,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 || eventName === EVENTS.EPISODE_LOBBY_CANCEL) && room.activeLobby) {
|
||||
room.activeLobby = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
+3
-2
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "1.8.6";
|
||||
export const APP_VERSION = "1.9.0";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
|
||||
@@ -36,7 +36,8 @@ export const EVENTS = {
|
||||
|
||||
// Episode Auto-Sync
|
||||
EPISODE_LOBBY: "episode_lobby", // Broadcast: waiting for everyone on this episode
|
||||
EPISODE_READY: "episode_ready" // Response: loaded the episode and paused at 0:00
|
||||
EPISODE_READY: "episode_ready", // Response: loaded the episode and paused at 0:00
|
||||
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel" // Broadcast: cancel active lobby and resume
|
||||
};
|
||||
|
||||
export const HEARTBEAT_INTERVAL = 15000; // 15s
|
||||
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* KoalaSync Shared Name Generation & Emoji Mapping
|
||||
*
|
||||
* ⚠️ WARNING: This is the SINGLE SOURCE OF TRUTH.
|
||||
* If you edit this file, you MUST run: node scripts/build-extension.js
|
||||
* to propagate changes to the extension.
|
||||
*
|
||||
* The emoji map covers every animal/creature that has a Unicode emoji.
|
||||
* Entries are sorted by key length (longest first) at lookup time to
|
||||
* prevent substring false-matches (e.g. "caterpillar" must be checked
|
||||
* before "cat").
|
||||
*
|
||||
* If you add a new animal noun to USERNAME_NOUNS, ensure it has a
|
||||
* corresponding entry in ANIMAL_EMOJI_MAP (or a substring that already
|
||||
* maps to a suitable emoji).
|
||||
*/
|
||||
|
||||
export const USERNAME_ADJECTIVES = [
|
||||
'Happy', 'Cool', 'Fast', 'Smart', 'Brave', 'Calm', 'Sneaky', 'Lazy',
|
||||
'Wild', 'Chill', 'Lucky', 'Epic', 'Swift', 'Bold', 'Mighty',
|
||||
'Cosmic', 'Neon', 'Shadow', 'Crystal', 'Thunder', 'Silent', 'Golden',
|
||||
'Fierce', 'Noble', 'Mystic', 'Frozen', 'Blazing', 'Sapphire', 'Iron', 'Crimson'
|
||||
];
|
||||
|
||||
export const USERNAME_NOUNS = [
|
||||
'Koala', 'Panda', 'Tiger', 'Eagle', 'Fox', 'Bear', 'Wolf', 'Lion',
|
||||
'Hawk', 'Seal', 'Owl', 'Shark', 'Dragon', 'Phoenix', 'Falcon',
|
||||
'Panther', 'Raven', 'Cobra', 'Lynx', 'Jaguar', 'Orca', 'Mantis',
|
||||
'Viper', 'Condor', 'Badger', 'Otter', 'Rhino', 'Crane', 'Mongoose',
|
||||
'Specter',
|
||||
'Cat', 'Dog', 'Deer', 'Bat', 'Gorilla', 'Monkey', 'Rabbit',
|
||||
'Horse', 'Unicorn', 'Zebra', 'Leopard', 'Cheetah', 'Puma',
|
||||
'Ram', 'Goat', 'Bull', 'Donkey', 'Moose',
|
||||
'Elephant', 'Giraffe', 'Hippo', 'Sloth', 'Kangaroo',
|
||||
'Raccoon', 'Hamster', 'Hedgehog', 'Skunk', 'Beaver', 'Bison',
|
||||
'Camel', 'Llama', 'Hyena', 'Coyote',
|
||||
'Mouse', 'Pig', 'Boar', 'Polar', 'Orangutan', 'Mammoth',
|
||||
'Crow', 'Duck', 'Swan', 'Penguin', 'Parrot', 'Peacock',
|
||||
'Dove', 'Dodo', 'Turkey', 'Flamingo', 'Chicken', 'Rooster', 'Goose',
|
||||
'Dolphin', 'Whale', 'Crab', 'Lobster', 'Octopus', 'Squid',
|
||||
'Jellyfish', 'Turtle',
|
||||
'Crocodile', 'Lizard', 'Snake', 'Frog', 'Toad', 'Gecko',
|
||||
'Bee', 'Ant', 'Spider', 'Scorpion', 'Butterfly', 'Ladybug',
|
||||
'Beetle', 'Snail', 'Dragonfly', 'Caterpillar',
|
||||
'Alien', 'Robot', 'Mermaid', 'Ghoul', 'Sprite', 'Cyborg',
|
||||
'Dinosaur', 'Reaper', 'Wraith', 'Sphinx',
|
||||
];
|
||||
|
||||
export const ANIMAL_EMOJI_MAP = {
|
||||
'hippopotamus': '🦛',
|
||||
'rhinoceros': '🦏',
|
||||
'caterpillar': '🐛',
|
||||
'chimpanzee': '🐵',
|
||||
'orangutan': '🦧',
|
||||
'blackbird': '🐦⬛',
|
||||
'bumblebee': '🐝',
|
||||
'cockatoo': '🦜',
|
||||
'cockroach': '🪳',
|
||||
'dragonfly': '🐉',
|
||||
'grasshopper': '🦗',
|
||||
'hedgehog': '🦔',
|
||||
'jellyfish': '🪼',
|
||||
'kangaroo': '🦘',
|
||||
'ladybird': '🐞',
|
||||
'ladybug': '🐞',
|
||||
'porcupine': '🦔',
|
||||
'scorpion': '🦂',
|
||||
'tarantula': '🕷️',
|
||||
'alligator': '🐊',
|
||||
'anaconda': '🐍',
|
||||
'antelope': '🦌',
|
||||
'blowfish': '🐡',
|
||||
'butterfly': '🦋',
|
||||
'chameleon': '🦎',
|
||||
'chipmunk': '🐿️',
|
||||
'crocodile': '🐊',
|
||||
'dinosaur': '🦖',
|
||||
'elephant': '🐘',
|
||||
'flamingo': '🦩',
|
||||
'giraffe': '🦒',
|
||||
'hamster': '🐹',
|
||||
'leopard': '🐆',
|
||||
'lobster': '🦞',
|
||||
'mermaid': '🧜♀️',
|
||||
'mongoose': '🦦',
|
||||
'mosquito': '🦟',
|
||||
'pangolin': '🦔',
|
||||
'peacock': '🦚',
|
||||
'penguin': '🐧',
|
||||
'phoenix': '🐦🔥',
|
||||
'raccoon': '🦝',
|
||||
'seahorse': '🐴',
|
||||
'sealion': '🦭',
|
||||
'unicorn': '🦄',
|
||||
'vampire': '🦇',
|
||||
'warthog': '🐗',
|
||||
'wolverine': '🦡',
|
||||
'mammoth': '🦣',
|
||||
'meerkat': '🦦',
|
||||
'octopus': '🐙',
|
||||
'opposum': '🐭',
|
||||
'ostrich': '🐦',
|
||||
'panther': '🐆',
|
||||
'pelican': '🦩',
|
||||
'rooster': '🐓',
|
||||
'serpent': '🐍',
|
||||
'specter': '👻',
|
||||
'spectre': '👻',
|
||||
'sparrow': '🐦',
|
||||
'spider': '🕷️',
|
||||
'sphinx': '🦁',
|
||||
'squirrel': '🐿️',
|
||||
'stingray': '🦈',
|
||||
'termite': '🐜',
|
||||
'tortoise': '🐢',
|
||||
'turkey': '🦃',
|
||||
'walrus': '🦭',
|
||||
'wombat': '🦡',
|
||||
'woodpecker': '🐦',
|
||||
'alien': '👾',
|
||||
'badger': '🦡',
|
||||
'beaver': '🦫',
|
||||
'beetle': '🪲',
|
||||
'beluga': '🐋',
|
||||
'bison': '🦬',
|
||||
'bobcat': '🐱',
|
||||
'buffalo': '🦬',
|
||||
'bunny': '🐰',
|
||||
'camel': '🐪',
|
||||
'cheetah': '🐆',
|
||||
'chicken': '🐔',
|
||||
'cobra': '🐍',
|
||||
'condor': '🦅',
|
||||
'cougar': '🐆',
|
||||
'coyote': '🐺',
|
||||
'crane': '🦩',
|
||||
'cricket': '🦗',
|
||||
'crow': '🐦⬛',
|
||||
'cyborg': '🤖',
|
||||
'dolphin': '🐬',
|
||||
'donkey': '🫏',
|
||||
'dragon': '🐉',
|
||||
'drake': '🐉',
|
||||
'eagle': '🦅',
|
||||
'falcon': '🦅',
|
||||
'ferret': '🦦',
|
||||
'gazelle': '🦌',
|
||||
'gecko': '🦎',
|
||||
'gerbil': '🐹',
|
||||
'ghost': '👻',
|
||||
'ghoul': '👻',
|
||||
'goose': '🪿',
|
||||
'gopher': '🐹',
|
||||
'gorilla': '🦍',
|
||||
'grizzly': '🐻',
|
||||
'heron': '🦩',
|
||||
'hippo': '🦛',
|
||||
'hornet': '🐝',
|
||||
'hyena': '🐺',
|
||||
'iguana': '🦎',
|
||||
'jackal': '🐺',
|
||||
'jaguar': '🐆',
|
||||
'kitten': '🐱',
|
||||
'koala': '🐨',
|
||||
'lemur': '🐒',
|
||||
'lizard': '🦎',
|
||||
'llama': '🦙',
|
||||
'locust': '🦗',
|
||||
'lynx': '🐱',
|
||||
'macaw': '🦜',
|
||||
'mantis': '🦗',
|
||||
'mink': '🦦',
|
||||
'monkey': '🐵',
|
||||
'moose': '🦌',
|
||||
'mouse': '🐭',
|
||||
'orca': '🐋',
|
||||
'otter': '🦦',
|
||||
'oyster': '🦪',
|
||||
'panda': '🐼',
|
||||
'parrot': '🦜',
|
||||
'pigeon': '🕊️',
|
||||
'polar': '🐻❄️',
|
||||
'poodle': '🐩',
|
||||
'puffin': '🐧',
|
||||
'puma': '🐆',
|
||||
'rabbit': '🐰',
|
||||
'raptor': '🦖',
|
||||
'raven': '🐦⬛',
|
||||
'reaper': '👻',
|
||||
'rhino': '🦏',
|
||||
'robin': '🐦',
|
||||
'robot': '🤖',
|
||||
'salmon': '🐟',
|
||||
'shrimp': '🦐',
|
||||
'skunk': '🦨',
|
||||
'sloth': '🦥',
|
||||
'snail': '🐌',
|
||||
'snake': '🐍',
|
||||
'sprite': '🧚',
|
||||
'squid': '🦑',
|
||||
'swan': '🦢',
|
||||
'tapir': '🐗',
|
||||
'tiger': '🐯',
|
||||
'toad': '🐸',
|
||||
'trout': '🐟',
|
||||
'tuna': '🐟',
|
||||
'turtle': '🐢',
|
||||
'viper': '🐍',
|
||||
'vulture': '🦅',
|
||||
'weasel': '🦦',
|
||||
'whale': '🐋',
|
||||
'wolf': '🐺',
|
||||
'wraith': '👻',
|
||||
'zebra': '🦓',
|
||||
'ape': '🦍',
|
||||
'ant': '🐜',
|
||||
'bat': '🦇',
|
||||
'bee': '🐝',
|
||||
'bug': '🐛',
|
||||
'cat': '🐱',
|
||||
'cow': '🐮',
|
||||
'crab': '🦀',
|
||||
'dog': '🐶',
|
||||
'duck': '🦆',
|
||||
'elk': '🦌',
|
||||
'fly': '🪰',
|
||||
'fox': '🦊',
|
||||
'frog': '🐸',
|
||||
'goat': '🐐',
|
||||
'hawk': '🦅',
|
||||
'hen': '🐔',
|
||||
'hog': '🐷',
|
||||
'lion': '🦁',
|
||||
'mole': '🐭',
|
||||
'moth': '🦋',
|
||||
'mule': '🫏',
|
||||
'owl': '🦉',
|
||||
'pig': '🐷',
|
||||
'ram': '🐏',
|
||||
'rat': '🐀',
|
||||
'seal': '🦭',
|
||||
'shark': '🦈',
|
||||
'wasp': '🐝',
|
||||
'yak': '🐂',
|
||||
'doe': '🦌',
|
||||
'ewe': '🐑',
|
||||
'buck': '🦌',
|
||||
'ox': '🐂',
|
||||
'bull': '🐂',
|
||||
'dodo': '🦤',
|
||||
'boar': '🐗',
|
||||
'bear': '🐻',
|
||||
'deer': '🦌',
|
||||
'dove': '🕊️',
|
||||
'fish': '🐟',
|
||||
'hare': '🐰',
|
||||
'horse': '🐴',
|
||||
'lamb': '🐑',
|
||||
'mare': '🐴',
|
||||
'pony': '🐴',
|
||||
'pup': '🐶',
|
||||
'croc': '🐊',
|
||||
'gnat': '🦟',
|
||||
'gnu': '🦬',
|
||||
};
|
||||
|
||||
export function getAvatarForName(username) {
|
||||
if (!username) return '\u{1F464}';
|
||||
const lower = username.toLowerCase();
|
||||
const sorted = Object.entries(ANIMAL_EMOJI_MAP).sort((a, b) => b[0].length - a[0].length);
|
||||
for (const [key, emoji] of sorted) {
|
||||
if (lower.includes(key)) return emoji;
|
||||
}
|
||||
return '\u{1F464}';
|
||||
}
|
||||
|
||||
export function generateUsername() {
|
||||
const adj = USERNAME_ADJECTIVES[Math.floor(Math.random() * USERNAME_ADJECTIVES.length)];
|
||||
const noun = USERNAME_NOUNS[Math.floor(Math.random() * USERNAME_NOUNS.length)];
|
||||
return `${adj}${noun}`;
|
||||
}
|
||||
+2
-2
@@ -103,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;">
|
||||
@@ -457,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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "1.8.7",
|
||||
"date": "2026-05-26T00:06:38Z"
|
||||
"version": "1.9.1",
|
||||
"date": "2026-05-29T22:08:39Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user