fix(extension): resolve critical race conditions and infinite seek loop

- Fix infinite seek loop by transitioning to exact expectedSeekTime tracking
- Fix zombie connections by calling forceDisconnect on ping timeouts
- Fix popup Join/Leave race conditions using isProcessingConnection lock
- Fix cross-room join bugs by properly disconnecting old rooms and bypassing same-room joins
- Update onboarding UI to switch to Sync tab automatically
- Prepare CHANGELOG.md for v2.3.0 release
This commit is contained in:
KoalaDev
2026-06-13 10:50:46 +02:00
parent aa61a24351
commit 4fbf309e5a
18 changed files with 383 additions and 68 deletions
+10 -1
View File
@@ -4,9 +4,18 @@ All notable changes to the KoalaSync browser extension and relay server.
---
## [v2.2.5] — Unreleased
## [v2.3.0] — Unreleased
### Added
- **Extension: New Interactive Onboarding Tour**: A fully redesigned, interactive step-by-step onboarding experience.
- **Extension: Auto-Switch to Sync Tab**: The UI now intelligently switches to the Sync tab when you join a room to guide video selection.
### Fixed
- **Extension: Infinite Seek Loop Prevention**: Replaced the fragile time-based seek suppression with an exact target-time verification mechanism, entirely eliminating infinite seek loops on slow buffers.
- **Extension: Zombie Connections Resolved**: Implemented a forced disconnect upon ping timeouts, ensuring the extension reliably auto-reconnects when the WebSocket hangs in a half-open state.
- **Extension: Room Switching Architecture**: Joining a new room while already connected now explicitly severs the old connection first, preventing state cross-contamination.
- **Extension: Join/Leave Race Conditions**: Added UI locks to prevent users from accidentally sending conflicting connection commands via rapid double-clicking.
- **Extension: Same-Room Invite Bypass**: Clicking an invite link for the room you are currently in no longer triggers a redundant reconnect, instead instantly confirming the join.
- **Extension: Audio settings now propagate immediately to video tabs**: Changes made in the audio options page are now instantly applied to the active video tab. Previously, settings saved to `chrome.storage.local` were not picked up by the background listener, which only watched `chrome.storage.sync`.
- **Extension: Audio compressor now logs enable/disable state and resume failures**: The compressor reports when it is activated or bypassed, and warns if the `AudioContext` cannot be resumed (e.g. browser autoplay policy requires a user gesture on the page first).
- **Extension: Video heartbeat no longer sent when alone in a room**: The full media metadata `PEER_STATUS` is now only emitted when other peers are present. The session keepalive (background heartbeat) continues to run unaffected, preventing the server reaper from disconnecting idle peers.
+30 -7
View File
@@ -708,7 +708,9 @@ function sendPing() {
if (pingTimeout) clearTimeout(pingTimeout);
pingTimeout = setTimeout(() => {
if (pendingPingT === t) {
addLog('Ping timeout reached, force disconnecting to trigger reconnect', 'warn');
pendingPingT = null;
forceDisconnect();
}
pingTimeout = null;
}, 5000);
@@ -1333,9 +1335,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
function leaveOldRoomIfSwitching(newRoomId) {
if (currentRoom && currentRoom.roomId !== newRoomId) {
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
emit(EVENTS.LEAVE_ROOM, { peerId });
}
forceDisconnect();
currentRoom = null;
if (storageInitialized) chrome.storage.session.set({ currentRoom: null });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
@@ -1389,15 +1389,27 @@ async function handleAsyncMessage(message, sender, sendResponse) {
await ensureState();
if (message.type === 'CONNECT') {
const settings = await getSettings();
const desiredUrl = resolveServerUrl(settings);
if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected');
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
for (const tab of tabs) {
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
}
if (typeof sendResponse === 'function') sendResponse({ status: 'ok' });
return;
}
reconnectFailed = false;
reconnectStartTime = null;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
const settings = await getSettings();
if (settings.roomId) {
leaveOldRoomIfSwitching(settings.roomId);
}
const desiredUrl = resolveServerUrl(settings);
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
if (desiredUrl !== currentServerUrl) forceDisconnect();
connect();
@@ -1493,14 +1505,25 @@ async function handleAsyncMessage(message, sender, sendResponse) {
useCustomServer: !!useCustomServer,
serverUrl: serverUrl || ''
}, async () => {
const settings = await getSettings();
const desiredUrl = resolveServerUrl(settings);
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected');
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
for (const tab of tabs) {
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
}
return;
}
reconnectFailed = false;
reconnectStartTime = null;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
broadcastConnectionStatus('connecting');
leaveOldRoomIfSwitching(roomId);
const settings = await getSettings();
const desiredUrl = resolveServerUrl(settings);
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
if (desiredUrl !== currentServerUrl) forceDisconnect();
connect();
+16 -9
View File
@@ -55,6 +55,7 @@
const MIN_SEEK_DELTA = 2.0;
let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK
let seekDebounceTimer = null; // debounce timer for rapid seek events
let expectedSeekTime = null; // strictly track programmatic seeks
// --- Episode Auto-Sync State ---
let lastKnownMediaTitle = null;
@@ -438,7 +439,7 @@
ytButton.click();
}
if (action === EVENTS.SEEK) {
_setSuppress('seek');
expectedSeekTime = data.targetTime;
video.currentTime = data.targetTime;
}
return;
@@ -454,7 +455,7 @@
twitchButton.click();
}
if (action === EVENTS.SEEK) {
_setSuppress('seek');
expectedSeekTime = data.targetTime;
video.currentTime = data.targetTime;
}
return;
@@ -472,7 +473,7 @@
_setSuppress('paused');
video.pause();
} else if (action === EVENTS.SEEK) {
_setSuppress('seek');
expectedSeekTime = data.targetTime;
video.currentTime = data.targetTime;
}
} catch (e) {
@@ -575,7 +576,7 @@
return;
}
_setSuppress('paused');
_setSuppress('seek');
expectedSeekTime = payload.targetTime;
video.pause();
try {
video.currentTime = payload.targetTime;
@@ -865,11 +866,17 @@
const current = video.currentTime;
if (!Number.isFinite(current)) return;
// Step 1: Check _suppressTimers (programmatic seek from remote peer)
if (_suppressTimers['seek']) {
_clearSuppress('seek');
lastReportedSeekTime = current;
return;
// Step 1: Check expectedSeekTime (programmatic seek from remote peer)
if (expectedSeekTime !== null) {
if (Math.abs(current - expectedSeekTime) < 1.0) {
// Video arrived at expected time. Safely clear and ignore.
expectedSeekTime = null;
lastReportedSeekTime = current;
return;
} else {
// User manually scrubbed to a DIFFERENT time while we were buffering
expectedSeekTime = null;
}
}
// Step 2: Suppress during visibility grace period (tab re-focus ghost events)
+4 -1
View File
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizer",
"AUDIO_COMING_SOON": "Demnächst"
"AUDIO_COMING_SOON": "Demnächst",
"BTN_RESTART_TOUR": "",
"BTN_RESTART_TOUR_TOOLTIP": "",
"HINT_SELECT_VIDEO": ""
}
+4 -1
View File
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizer",
"AUDIO_COMING_SOON": "Coming soon"
"AUDIO_COMING_SOON": "Coming soon",
"BTN_RESTART_TOUR": "Restart Tutorial",
"BTN_RESTART_TOUR_TOOLTIP": "Restart the onboarding tutorial",
"HINT_SELECT_VIDEO": "Select your video here!"
}
+4 -1
View File
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Ecualizador",
"AUDIO_COMING_SOON": "Próximamente"
"AUDIO_COMING_SOON": "Próximamente",
"BTN_RESTART_TOUR": "",
"BTN_RESTART_TOUR_TOOLTIP": "",
"HINT_SELECT_VIDEO": ""
}
+4 -1
View File
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Égaliseur",
"AUDIO_COMING_SOON": "Bientôt disponible"
"AUDIO_COMING_SOON": "Bientôt disponible",
"BTN_RESTART_TOUR": "",
"BTN_RESTART_TOUR_TOOLTIP": "",
"HINT_SELECT_VIDEO": ""
}
+4 -1
View File
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizzatore",
"AUDIO_COMING_SOON": "Prossimamente"
"AUDIO_COMING_SOON": "Prossimamente",
"BTN_RESTART_TOUR": "",
"BTN_RESTART_TOUR_TOOLTIP": "",
"HINT_SELECT_VIDEO": ""
}
+4 -1
View File
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "イコライザー",
"AUDIO_COMING_SOON": "近日公開"
"AUDIO_COMING_SOON": "近日公開",
"BTN_RESTART_TOUR": "",
"BTN_RESTART_TOUR_TOOLTIP": "",
"HINT_SELECT_VIDEO": ""
}
+4 -1
View File
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "이퀄라이저",
"AUDIO_COMING_SOON": "출시 예정"
"AUDIO_COMING_SOON": "출시 예정",
"BTN_RESTART_TOUR": "",
"BTN_RESTART_TOUR_TOOLTIP": "",
"HINT_SELECT_VIDEO": ""
}
+4 -1
View File
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizer",
"AUDIO_COMING_SOON": "Binnenkort beschikbaar"
"AUDIO_COMING_SOON": "Binnenkort beschikbaar",
"BTN_RESTART_TOUR": "",
"BTN_RESTART_TOUR_TOOLTIP": "",
"HINT_SELECT_VIDEO": ""
}
+4 -1
View File
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Korektor",
"AUDIO_COMING_SOON": "Wkrótce"
"AUDIO_COMING_SOON": "Wkrótce",
"BTN_RESTART_TOUR": "",
"BTN_RESTART_TOUR_TOOLTIP": "",
"HINT_SELECT_VIDEO": ""
}
+4 -1
View File
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizador",
"AUDIO_COMING_SOON": "Em breve"
"AUDIO_COMING_SOON": "Em breve",
"BTN_RESTART_TOUR": "",
"BTN_RESTART_TOUR_TOOLTIP": "",
"HINT_SELECT_VIDEO": ""
}
+4 -1
View File
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizador",
"AUDIO_COMING_SOON": "Em breve"
"AUDIO_COMING_SOON": "Em breve",
"BTN_RESTART_TOUR": "",
"BTN_RESTART_TOUR_TOOLTIP": "",
"HINT_SELECT_VIDEO": ""
}
+4 -1
View File
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Эквалайзер",
"AUDIO_COMING_SOON": "Скоро"
"AUDIO_COMING_SOON": "Скоро",
"BTN_RESTART_TOUR": "",
"BTN_RESTART_TOUR_TOOLTIP": "",
"HINT_SELECT_VIDEO": ""
}
+4 -1
View File
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Ekolayzer",
"AUDIO_COMING_SOON": "Çok yakında"
"AUDIO_COMING_SOON": "Çok yakında",
"BTN_RESTART_TOUR": "",
"BTN_RESTART_TOUR_TOOLTIP": "",
"HINT_SELECT_VIDEO": ""
}
+39 -10
View File
@@ -364,6 +364,14 @@
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 1; transform: scale(1.3); }
}
@keyframes spotlightPulse {
0%, 100% { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.65), 0 0 0 0px var(--accent); }
50% { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.65), 0 0 0 6px var(--accent); }
}
@keyframes floatHint {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
</style>
</head>
<body>
@@ -457,11 +465,16 @@
<div id="tab-sync" class="tab-content">
<!-- SYNC ACTIVE: Visible when in a room -->
<div id="sync-active">
<div class="form-group">
<div class="form-group" style="position: relative;">
<label title="Choose the browser tab containing the video to sync" data-i18n="LABEL_SELECT_VIDEO" data-i18n-title="LABEL_SELECT_VIDEO_TOOLTIP">Select Video</label>
<select id="targetTab">
<option value="" data-i18n="OPTION_SELECT_TAB">-- Select a Tab --</option>
</select>
<!-- Hint Tooltip -->
<div id="targetTabHint" style="display: none; position: absolute; top: -25px; right: 0; background: var(--accent); color: white; padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: bold; pointer-events: none; animation: floatHint 2s ease-in-out infinite; box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); z-index: 10;" data-i18n="HINT_SELECT_VIDEO">
Select your video here!
<div style="position: absolute; bottom: -4px; right: 20px; width: 8px; height: 8px; background: var(--accent); transform: rotate(45deg);"></div>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
@@ -578,6 +591,7 @@
<div style="margin-top: 15px; padding: 8px; border-top: 1px solid var(--card);">
<label title="Tools for fixing connection issues" data-i18n="LABEL_TROUBLESHOOTING" data-i18n-title="LABEL_TROUBLESHOOTING_TOOLTIP">Troubleshooting</label>
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px;" title="Regenerate your internal ID and reconnect" data-i18n="BTN_REGEN_ID" data-i18n-title="BTN_REGEN_ID_TOOLTIP">Regenerate Peer ID</button>
<button id="restartTourBtn" class="secondary" style="width: 100%; font-size: 11px; margin-top: 8px;" title="Restart the onboarding tutorial" data-i18n="BTN_RESTART_TOUR" data-i18n-title="BTN_RESTART_TOUR_TOOLTIP">Restart Tutorial</button>
<p style="font-size: 9px; color: var(--text-muted); margin-top: 5px; text-align: center;" data-i18n="REGEN_ID_DESC">Use this if you see "Duplicate Identity" errors.</p>
<p style="font-size: 9px; text-align: center; margin-top: 6px;"><a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" style="color: var(--accent); text-decoration: none;" data-i18n="REGEN_ID_OTHER_ISSUE">Other issue? Open a GitHub Issue</a></p>
</div>
@@ -637,16 +651,31 @@
<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.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;">👋</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;" title="Skip the tutorial" data-i18n="BTN_ONBOARDING_SKIP" data-i18n-title="BTN_ONBOARDING_SKIP_TOOLTIP">Skip</button>
<button id="onboarding-next" class="primary" style="width:auto; padding:8px 16px;" title="Go to next step" data-i18n="BTN_ONBOARDING_NEXT" data-i18n-title="BTN_ONBOARDING_NEXT_TOOLTIP">Next</button>
<div id="onboarding-overlay" style="display:none; position:fixed; inset:0; background:transparent; z-index:1000; pointer-events:auto;">
<!-- Spotlight element -->
<div id="onboarding-spotlight" style="position:fixed; pointer-events:none; border:2px solid var(--accent); box-shadow:0 0 0 9999px rgba(15, 23, 42, 0.65); border-radius:8px; transition:all 0.3s cubic-bezier(0.4, 0, 0.2, 1); display:none; opacity:0; z-index:1001; animation: spotlightPulse 2s infinite ease-in-out;"></div>
<!-- Onboarding Card -->
<div id="onboarding-card" style="position:fixed; background:var(--card); padding:16px 20px; border-radius:12px; width:280px; box-shadow:0 8px 32px rgba(0,0,0,0.5); z-index:1002; transition:all 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-sizing:border-box; border:1px solid #334155; opacity:0; transform: scale(0.9); display:none;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span id="onboarding-icon" style="font-size:24px;">👋</span>
<span id="onboarding-step-indicator" style="font-size:10px; font-weight:700; color:var(--accent); text-transform:uppercase; letter-spacing:0.5px;">Step 1 of 3</span>
</div>
<div id="onboarding-dots" style="margin-top:12px; display:flex; gap:6px; justify-content:center;"></div>
<h2 id="onboarding-title" style="color:var(--accent); margin:0 0 6px; font-size:14px; font-weight:700; text-align:left;">Welcome to KoalaSync!</h2>
<p id="onboarding-text" style="color:var(--text-muted); font-size:12px; margin:0 0 16px; line-height:1.4; text-align:left;">Let's get you started.</p>
<!-- Progress Bar -->
<div id="onboarding-progress-container" style="height:4px; background:#334155; border-radius:2px; margin-bottom:16px; overflow:hidden;">
<div id="onboarding-progress-bar" style="height:100%; width:33%; background:var(--accent); transition:width 0.3s ease;"></div>
</div>
<div style="display:flex; gap:8px; justify-content:space-between; align-items:center;">
<button id="onboarding-skip" class="secondary" style="width:auto; padding:6px 12px; margin:0; font-size:11px;" title="Skip the tutorial" data-i18n="BTN_ONBOARDING_SKIP" data-i18n-title="BTN_ONBOARDING_SKIP_TOOLTIP">Skip</button>
<button id="onboarding-next" class="primary" style="width:auto; padding:6px 16px; margin:0; font-size:11px;" title="Go to next step" data-i18n="BTN_ONBOARDING_NEXT" data-i18n-title="BTN_ONBOARDING_NEXT_TOOLTIP">Next</button>
</div>
<!-- Tooltip pointer arrow -->
<div id="onboarding-arrow" style="position:absolute; width:0; height:0; border:6px solid transparent; transition:all 0.3s ease;"></div>
</div>
</div>
</body>
+236 -28
View File
@@ -29,6 +29,7 @@ const elements = {
inviteLink: document.getElementById('inviteLink'),
filterNoise: document.getElementById('filterNoise'),
regenId: document.getElementById('regenId'),
restartTourBtn: document.getElementById('restartTourBtn'),
lastActionCard: document.getElementById('lastActionCard'),
historyList: document.getElementById('historyList'),
copyLogs: document.getElementById('copyLogs'),
@@ -68,6 +69,7 @@ let lastPeersJson = null;
let lastKnownPeers = [];
let isDevTabVisible = false;
let reconnectSlowMode = false;
let isProcessingConnection = false;
let joinBtnTimeout = null;
let forceSyncResetTimer = null;
let popupIntervals = [];
@@ -173,7 +175,7 @@ function setRoomRefreshCooldown() {
// --- Initialization ---
async function init() {
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']);
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab']);
// Migrate preferences from sync → local for existing users
const oldSync = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']);
const toMigrate = {};
@@ -259,8 +261,21 @@ async function init() {
// Render lobby status if active
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
if (res.status === 'connected' && !res.targetTabId && localData.roomId) {
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
if (syncTabBtn) syncTabBtn.click();
showSelectVideoHint();
} else if (localData.activeTab) {
const btn = document.querySelector(`.tab-btn[data-tab="${localData.activeTab}"]`);
if (btn) btn.click();
}
} else {
await populateTabs();
if (localData.activeTab) {
const btn = document.querySelector(`.tab-btn[data-tab="${localData.activeTab}"]`);
if (btn) btn.click();
}
}
});
@@ -1121,6 +1136,8 @@ elements.tabs.forEach(btn => {
isDevTabVisible = btn.dataset.tab === 'tab-dev';
if (isDevTabVisible) refreshLogs();
if (btn.dataset.tab === 'tab-sync') refreshHistory();
chrome.storage.local.set({ activeTab: btn.dataset.tab });
});
});
@@ -1163,8 +1180,13 @@ elements.roomId.addEventListener('input', () => {
});
elements.joinBtn.addEventListener('click', async () => {
if (isProcessingConnection) return;
isProcessingConnection = true;
clearConnectionErrorTimer();
if (elements.joinBtn.disabled) return;
if (elements.joinBtn.disabled) {
isProcessingConnection = false;
return;
}
const roomIdInput = elements.roomId.value.trim();
const isCreating = !roomIdInput;
@@ -1176,6 +1198,7 @@ elements.joinBtn.addEventListener('click', async () => {
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
joinBtnTimeout = null;
isProcessingConnection = false;
showError(getMessage('ERR_CONN_TIMEOUT'));
}, 15000);
@@ -1197,6 +1220,7 @@ elements.joinBtn.addEventListener('click', async () => {
showError(getMessage('ERR_INVALID_SERVER_URL'));
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
isProcessingConnection = false;
return;
}
}
@@ -1223,6 +1247,8 @@ elements.joinBtn.addEventListener('click', async () => {
});
elements.leaveBtn.addEventListener('click', async () => {
if (isProcessingConnection) return;
isProcessingConnection = true;
clearConnectionErrorTimer();
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
await chrome.storage.local.set({ roomId: '', password: '' });
@@ -1230,6 +1256,7 @@ elements.leaveBtn.addEventListener('click', async () => {
elements.password.value = '';
lastKnownPeers = [];
updateUI(null, null);
isProcessingConnection = false;
});
function generateRoomId() {
@@ -1280,6 +1307,9 @@ elements.retryBtn.addEventListener('click', () => {
});
elements.targetTab.addEventListener('change', () => {
const hint = document.getElementById('targetTabHint');
if (hint) hint.style.display = 'none';
const val = elements.targetTab.value;
const tabId = val ? parseInt(val) : null;
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null;
@@ -1289,8 +1319,14 @@ elements.targetTab.addEventListener('change', () => {
elements.forceSyncBtn.addEventListener('click', async () => {
if (elements.forceSyncBtn.disabled) return;
const originalText = elements.forceSyncBtn.textContent;
elements.forceSyncBtn.disabled = true;
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
if (chrome.runtime.lastError || !status || !status.targetTabId) return;
if (chrome.runtime.lastError || !status || !status.targetTabId) {
elements.forceSyncBtn.disabled = false;
return;
}
const mode = elements.forceSyncMode.value;
let targetTime = null;
@@ -1298,6 +1334,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
if (mode === 'jump-to-others') {
if (!localPeerId) {
showError(getMessage('ERR_IDENTITY_NOT_LOADED'));
elements.forceSyncBtn.disabled = false;
return;
}
const peers = status.peers || [];
@@ -1307,6 +1344,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
if (otherTimes.length === 0) {
showError(getMessage('ERR_NO_PEERS_TIME'));
elements.forceSyncBtn.disabled = false;
return;
}
@@ -1315,8 +1353,6 @@ elements.forceSyncBtn.addEventListener('click', async () => {
targetTime = otherTimes.length % 2 !== 0 ? otherTimes[mid] : (otherTimes[mid - 1] + otherTimes[mid]) / 2;
}
const originalText = elements.forceSyncBtn.textContent;
elements.forceSyncBtn.disabled = true;
elements.forceSyncBtn.textContent = mode === 'jump-to-others' ? getMessage('BTN_STATE_SYNCING_GROUP', { time: formatTime(targetTime) }) : getMessage('BTN_STATE_SYNCING');
forceSyncDone = false;
const peerCount = (status.peers || []).filter(p => (typeof p === 'object' ? p.peerId : p) !== localPeerId).length;
@@ -1610,6 +1646,7 @@ chrome.runtime.onMessage.addListener((msg) => {
} else if (msg.type === 'ROOM_LIST') {
updateRoomList(msg.rooms);
} else if (msg.type === 'JOIN_STATUS') {
isProcessingConnection = false;
if (joinBtnTimeout) {
clearTimeout(joinBtnTimeout);
joinBtnTimeout = null;
@@ -1621,9 +1658,15 @@ chrome.runtime.onMessage.addListener((msg) => {
// Final confirmation of join from background
chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
if (syncTabBtn) syncTabBtn.click();
showSelectVideoHint();
});
} else {
// Join failed: reset UI state
chrome.storage.local.set({ roomId: '', password: '' });
elements.roomId.value = '';
elements.password.value = '';
updateUI(null, null);
}
} else if (msg.type === 'LOBBY_UPDATE') {
@@ -2049,36 +2092,174 @@ function updateLobbyUI(lobby, peers) {
// --- Onboarding Tour ---
const onboardingSteps = [
{ icon: '👋', get title() { return getMessage('ONBOARDING_1_TITLE'); }, get text() { return getMessage('ONBOARDING_1_TEXT'); }, targetTab: 'tab-room' },
{ icon: '🏠', get title() { return getMessage('ONBOARDING_2_TITLE'); }, get text() { return getMessage('ONBOARDING_2_TEXT'); }, targetTab: 'tab-room' },
{ icon: '🎬', get title() { return getMessage('ONBOARDING_3_TITLE'); }, get text() { return getMessage('ONBOARDING_3_TEXT'); }, targetTab: 'tab-sync' },
{ icon: '⚙️', get title() { return getMessage('ONBOARDING_4_TITLE'); }, get text() { return getMessage('ONBOARDING_4_TEXT'); }, targetTab: 'tab-settings' },
{ icon: '🎉', get title() { return getMessage('ONBOARDING_5_TITLE'); }, get text() { return getMessage('ONBOARDING_5_TEXT'); }, targetTab: 'tab-room' }
{
icon: '👋',
get title() { return getMessage('ONBOARDING_1_TITLE'); },
get text() { return getMessage('ONBOARDING_1_TEXT'); },
targetTab: 'tab-room',
targetSelector: '#createRoomBtn'
},
{
icon: '🎬',
get title() { return getMessage('ONBOARDING_3_TITLE'); },
get text() { return getMessage('ONBOARDING_3_TEXT'); },
targetTab: 'tab-sync',
targetSelector: '#targetTab'
},
{
icon: '⚙️',
get title() { return getMessage('ONBOARDING_4_TITLE'); },
get text() { return getMessage('ONBOARDING_4_TEXT'); },
targetTab: 'tab-settings',
targetSelector: '#username'
}
];
function showSelectVideoHint() {
const hint = document.getElementById('targetTabHint');
if (hint && !elements.targetTab.value) {
hint.style.display = 'block';
}
}
let onboardingStep = 0;
let onboardingTimeout = null;
function showOnboarding() {
const overlay = document.getElementById('onboarding-overlay');
if (!overlay) return;
document.body.style.minHeight = '400px';
overlay.style.display = 'flex';
document.body.style.minHeight = '420px';
overlay.style.display = 'block';
onboardingStep = 0;
renderOnboardingStep();
}
function positionSpotlightAndCard(targetEl) {
const spotlight = document.getElementById('onboarding-spotlight');
const card = document.getElementById('onboarding-card');
const arrow = document.getElementById('onboarding-arrow');
if (!spotlight || !card || !arrow) return;
spotlight.style.display = 'block';
card.style.display = 'block';
// Force a reflow
card.getBoundingClientRect();
spotlight.getBoundingClientRect();
const targetRect = targetEl.getBoundingClientRect();
const pad = 6;
const sLeft = targetRect.left - pad;
const sTop = targetRect.top - pad;
const sWidth = targetRect.width + (pad * 2);
const sHeight = targetRect.height + (pad * 2);
spotlight.style.left = `${sLeft}px`;
spotlight.style.top = `${sTop}px`;
spotlight.style.width = `${sWidth}px`;
spotlight.style.height = `${sHeight}px`;
spotlight.style.opacity = '1';
const cardWidth = 280;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
let cLeft = targetRect.left + (targetRect.width - cardWidth) / 2;
cLeft = Math.max(12, Math.min(320 - cardWidth - 12, cLeft));
const cardHeight = card.offsetHeight || 150;
let cTop = targetRect.bottom + 14;
let arrowDir = 'up';
if (cTop + cardHeight > viewportHeight - 12) {
cTop = targetRect.top - cardHeight - 14;
arrowDir = 'down';
}
if (cTop < 12) {
cTop = targetRect.bottom + 14;
arrowDir = 'up';
}
card.style.left = `${cLeft}px`;
card.style.top = `${cTop}px`;
card.style.opacity = '1';
card.style.transform = 'scale(1)';
arrow.style.left = '';
arrow.style.right = '';
arrow.style.top = '';
arrow.style.bottom = '';
arrow.style.borderColor = 'transparent';
const targetCenter = targetRect.left + (targetRect.width / 2);
const arrowLeft = targetCenter - cLeft - 6;
arrow.style.left = `${Math.max(12, Math.min(cardWidth - 24, arrowLeft))}px`;
if (arrowDir === 'up') {
arrow.style.top = '-12px';
arrow.style.borderBottomColor = 'var(--card)';
} else {
arrow.style.bottom = '-12px';
arrow.style.borderTopColor = 'var(--card)';
}
}
function centerCardFallback() {
const spotlight = document.getElementById('onboarding-spotlight');
const card = document.getElementById('onboarding-card');
const arrow = document.getElementById('onboarding-arrow');
if (!card) return;
if (spotlight) {
spotlight.style.display = 'none';
spotlight.style.opacity = '0';
}
if (arrow) {
arrow.style.borderColor = 'transparent';
}
card.style.display = 'block';
const cardWidth = 280;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const cLeft = (320 - cardWidth) / 2;
const cTop = (viewportHeight - (card.offsetHeight || 150)) / 2;
card.style.left = `${cLeft}px`;
card.style.top = `${cTop}px`;
card.style.opacity = '1';
card.style.transform = 'scale(1)';
}
function renderOnboardingStep() {
if (onboardingTimeout) clearTimeout(onboardingTimeout);
const step = onboardingSteps[onboardingStep];
const icon = document.getElementById('onboarding-icon');
const title = document.getElementById('onboarding-title');
const text = document.getElementById('onboarding-text');
const nextBtn = document.getElementById('onboarding-next');
const dots = document.getElementById('onboarding-dots');
if (!icon || !title || !text || !nextBtn || !dots) return;
const stepIndicator = document.getElementById('onboarding-step-indicator');
const progressBar = document.getElementById('onboarding-progress-bar');
if (!icon || !title || !text || !nextBtn) return;
icon.textContent = step.icon;
title.textContent = step.title;
text.textContent = step.text;
if (stepIndicator) {
stepIndicator.textContent = `Step ${onboardingStep + 1} of ${onboardingSteps.length}`;
}
if (progressBar) {
progressBar.style.width = `${((onboardingStep + 1) / onboardingSteps.length) * 100}%`;
}
nextBtn.textContent = onboardingStep === onboardingSteps.length - 1
? (getMessage('ONBOARDING_DONE') !== 'ONBOARDING_DONE' ? getMessage('ONBOARDING_DONE') : 'Done!')
: getMessage('BTN_ONBOARDING_NEXT');
if (step.targetTab) {
const tabBtn = document.querySelector(`.tab-btn[data-tab="${step.targetTab}"]`);
if (tabBtn) tabBtn.click();
@@ -2089,31 +2270,53 @@ function renderOnboardingStep() {
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');
dot.style.cssText = `width:8px; height:8px; border-radius:50%; background:${i === onboardingStep ? 'var(--accent)' : '#475569'};`;
dots.appendChild(dot);
});
const spotlight = document.getElementById('onboarding-spotlight');
const card = document.getElementById('onboarding-card');
if (spotlight) spotlight.style.opacity = '0';
if (card) {
card.style.opacity = '0';
card.style.transform = 'scale(0.95)';
}
nextBtn.textContent = onboardingStep === onboardingSteps.length - 1 ? (getMessage('ONBOARDING_DONE') !== 'ONBOARDING_DONE' ? getMessage('ONBOARDING_DONE') : 'Done!') : getMessage('BTN_ONBOARDING_NEXT');
onboardingTimeout = setTimeout(() => {
const targetEl = document.querySelector(step.targetSelector);
if (targetEl && targetEl.offsetParent !== null) {
positionSpotlightAndCard(targetEl);
} else {
centerCardFallback();
}
}, 180);
}
function completeOnboarding() {
const overlay = document.getElementById('onboarding-overlay');
if (overlay) overlay.style.display = 'none';
document.body.style.minHeight = '';
chrome.storage.sync.set({ onboardingComplete: true });
if (onboardingTimeout) clearTimeout(onboardingTimeout);
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
toggleUIState(inRoom);
const overlay = document.getElementById('onboarding-overlay');
const spotlight = document.getElementById('onboarding-spotlight');
const card = document.getElementById('onboarding-card');
if (card) {
card.style.opacity = '0';
card.style.transform = 'scale(0.9)';
}
if (spotlight) {
spotlight.style.opacity = '0';
}
setTimeout(() => {
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);
}, 300);
}
document.getElementById('onboarding-next')?.addEventListener('click', () => {
@@ -2130,3 +2333,8 @@ document.getElementById('onboarding-skip')?.addEventListener('click', completeOn
document.getElementById('onboarding-overlay')?.addEventListener('click', (e) => {
if (e.target.id === 'onboarding-overlay') completeOnboarding();
});
elements.restartTourBtn?.addEventListener('click', () => {
onboardingStep = 0;
showOnboarding();
});