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 ### 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 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: 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. - **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); if (pingTimeout) clearTimeout(pingTimeout);
pingTimeout = setTimeout(() => { pingTimeout = setTimeout(() => {
if (pendingPingT === t) { if (pendingPingT === t) {
addLog('Ping timeout reached, force disconnecting to trigger reconnect', 'warn');
pendingPingT = null; pendingPingT = null;
forceDisconnect();
} }
pingTimeout = null; pingTimeout = null;
}, 5000); }, 5000);
@@ -1333,9 +1335,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
function leaveOldRoomIfSwitching(newRoomId) { function leaveOldRoomIfSwitching(newRoomId) {
if (currentRoom && currentRoom.roomId !== newRoomId) { if (currentRoom && currentRoom.roomId !== newRoomId) {
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info'); addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) { forceDisconnect();
emit(EVENTS.LEAVE_ROOM, { peerId });
}
currentRoom = null; currentRoom = null;
if (storageInitialized) chrome.storage.session.set({ currentRoom: null }); if (storageInitialized) chrome.storage.session.set({ currentRoom: null });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
@@ -1389,15 +1389,27 @@ async function handleAsyncMessage(message, sender, sendResponse) {
await ensureState(); await ensureState();
if (message.type === 'CONNECT') { 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; reconnectFailed = false;
reconnectStartTime = null; reconnectStartTime = null;
reconnectAttempts = 0; reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }); chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
const settings = await getSettings();
if (settings.roomId) { if (settings.roomId) {
leaveOldRoomIfSwitching(settings.roomId); leaveOldRoomIfSwitching(settings.roomId);
} }
const desiredUrl = resolveServerUrl(settings);
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) { if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
if (desiredUrl !== currentServerUrl) forceDisconnect(); if (desiredUrl !== currentServerUrl) forceDisconnect();
connect(); connect();
@@ -1493,14 +1505,25 @@ async function handleAsyncMessage(message, sender, sendResponse) {
useCustomServer: !!useCustomServer, useCustomServer: !!useCustomServer,
serverUrl: serverUrl || '' serverUrl: serverUrl || ''
}, async () => { }, 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; reconnectFailed = false;
reconnectStartTime = null; reconnectStartTime = null;
reconnectAttempts = 0; reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }); chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
broadcastConnectionStatus('connecting'); broadcastConnectionStatus('connecting');
leaveOldRoomIfSwitching(roomId); leaveOldRoomIfSwitching(roomId);
const settings = await getSettings();
const desiredUrl = resolveServerUrl(settings);
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) { if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
if (desiredUrl !== currentServerUrl) forceDisconnect(); if (desiredUrl !== currentServerUrl) forceDisconnect();
connect(); connect();
+16 -9
View File
@@ -55,6 +55,7 @@
const MIN_SEEK_DELTA = 2.0; const MIN_SEEK_DELTA = 2.0;
let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK
let seekDebounceTimer = null; // debounce timer for rapid seek events let seekDebounceTimer = null; // debounce timer for rapid seek events
let expectedSeekTime = null; // strictly track programmatic seeks
// --- Episode Auto-Sync State --- // --- Episode Auto-Sync State ---
let lastKnownMediaTitle = null; let lastKnownMediaTitle = null;
@@ -438,7 +439,7 @@
ytButton.click(); ytButton.click();
} }
if (action === EVENTS.SEEK) { if (action === EVENTS.SEEK) {
_setSuppress('seek'); expectedSeekTime = data.targetTime;
video.currentTime = data.targetTime; video.currentTime = data.targetTime;
} }
return; return;
@@ -454,7 +455,7 @@
twitchButton.click(); twitchButton.click();
} }
if (action === EVENTS.SEEK) { if (action === EVENTS.SEEK) {
_setSuppress('seek'); expectedSeekTime = data.targetTime;
video.currentTime = data.targetTime; video.currentTime = data.targetTime;
} }
return; return;
@@ -472,7 +473,7 @@
_setSuppress('paused'); _setSuppress('paused');
video.pause(); video.pause();
} else if (action === EVENTS.SEEK) { } else if (action === EVENTS.SEEK) {
_setSuppress('seek'); expectedSeekTime = data.targetTime;
video.currentTime = data.targetTime; video.currentTime = data.targetTime;
} }
} catch (e) { } catch (e) {
@@ -575,7 +576,7 @@
return; return;
} }
_setSuppress('paused'); _setSuppress('paused');
_setSuppress('seek'); expectedSeekTime = payload.targetTime;
video.pause(); video.pause();
try { try {
video.currentTime = payload.targetTime; video.currentTime = payload.targetTime;
@@ -865,11 +866,17 @@
const current = video.currentTime; const current = video.currentTime;
if (!Number.isFinite(current)) return; if (!Number.isFinite(current)) return;
// Step 1: Check _suppressTimers (programmatic seek from remote peer) // Step 1: Check expectedSeekTime (programmatic seek from remote peer)
if (_suppressTimers['seek']) { if (expectedSeekTime !== null) {
_clearSuppress('seek'); if (Math.abs(current - expectedSeekTime) < 1.0) {
lastReportedSeekTime = current; // Video arrived at expected time. Safely clear and ignore.
return; 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) // 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_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release", "AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizer", "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_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release", "AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizer", "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_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release", "AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Ecualizador", "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_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release", "AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Égaliseur", "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_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release", "AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizzatore", "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_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release", "AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "イコライザー", "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_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release", "AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "이퀄라이저", "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_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release", "AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizer", "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_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release", "AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Korektor", "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_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release", "AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizador", "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_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release", "AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizador", "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_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release", "AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Эквалайзер", "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_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release", "AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Ekolayzer", "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); } 0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 1; transform: scale(1.3); } 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> </style>
</head> </head>
<body> <body>
@@ -457,11 +465,16 @@
<div id="tab-sync" class="tab-content"> <div id="tab-sync" class="tab-content">
<!-- SYNC ACTIVE: Visible when in a room --> <!-- SYNC ACTIVE: Visible when in a room -->
<div id="sync-active"> <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> <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"> <select id="targetTab">
<option value="" data-i18n="OPTION_SELECT_TAB">-- Select a Tab --</option> <option value="" data-i18n="OPTION_SELECT_TAB">-- Select a Tab --</option>
</select> </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>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;"> <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);"> <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> <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="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; 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> <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> </div>
@@ -637,16 +651,31 @@
<script src="popup.js" type="module"></script> <script src="popup.js" type="module"></script>
<!-- Onboarding Overlay --> <!-- 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-overlay" style="display:none; position:fixed; inset:0; background:transparent; z-index:1000; pointer-events:auto;">
<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); "> <!-- Spotlight element -->
<div id="onboarding-icon" style="font-size:48px; margin-bottom:12px;">👋</div> <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>
<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> <!-- Onboarding Card -->
<div style="display:flex; gap:8px; justify-content:center;"> <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;">
<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> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<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> <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>
<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>
</div> </div>
</body> </body>
+236 -28
View File
@@ -29,6 +29,7 @@ const elements = {
inviteLink: document.getElementById('inviteLink'), inviteLink: document.getElementById('inviteLink'),
filterNoise: document.getElementById('filterNoise'), filterNoise: document.getElementById('filterNoise'),
regenId: document.getElementById('regenId'), regenId: document.getElementById('regenId'),
restartTourBtn: document.getElementById('restartTourBtn'),
lastActionCard: document.getElementById('lastActionCard'), lastActionCard: document.getElementById('lastActionCard'),
historyList: document.getElementById('historyList'), historyList: document.getElementById('historyList'),
copyLogs: document.getElementById('copyLogs'), copyLogs: document.getElementById('copyLogs'),
@@ -68,6 +69,7 @@ let lastPeersJson = null;
let lastKnownPeers = []; let lastKnownPeers = [];
let isDevTabVisible = false; let isDevTabVisible = false;
let reconnectSlowMode = false; let reconnectSlowMode = false;
let isProcessingConnection = false;
let joinBtnTimeout = null; let joinBtnTimeout = null;
let forceSyncResetTimer = null; let forceSyncResetTimer = null;
let popupIntervals = []; let popupIntervals = [];
@@ -173,7 +175,7 @@ function setRoomRefreshCooldown() {
// --- Initialization --- // --- Initialization ---
async function init() { 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 // 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 oldSync = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']);
const toMigrate = {}; const toMigrate = {};
@@ -259,8 +261,21 @@ async function init() {
// Render lobby status if active // Render lobby status if active
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers); 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 { } else {
await populateTabs(); 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'; isDevTabVisible = btn.dataset.tab === 'tab-dev';
if (isDevTabVisible) refreshLogs(); if (isDevTabVisible) refreshLogs();
if (btn.dataset.tab === 'tab-sync') refreshHistory(); 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 () => { elements.joinBtn.addEventListener('click', async () => {
if (isProcessingConnection) return;
isProcessingConnection = true;
clearConnectionErrorTimer(); clearConnectionErrorTimer();
if (elements.joinBtn.disabled) return; if (elements.joinBtn.disabled) {
isProcessingConnection = false;
return;
}
const roomIdInput = elements.roomId.value.trim(); const roomIdInput = elements.roomId.value.trim();
const isCreating = !roomIdInput; const isCreating = !roomIdInput;
@@ -1176,6 +1198,7 @@ elements.joinBtn.addEventListener('click', async () => {
elements.joinBtn.disabled = false; elements.joinBtn.disabled = false;
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM'); elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
joinBtnTimeout = null; joinBtnTimeout = null;
isProcessingConnection = false;
showError(getMessage('ERR_CONN_TIMEOUT')); showError(getMessage('ERR_CONN_TIMEOUT'));
}, 15000); }, 15000);
@@ -1197,6 +1220,7 @@ elements.joinBtn.addEventListener('click', async () => {
showError(getMessage('ERR_INVALID_SERVER_URL')); showError(getMessage('ERR_INVALID_SERVER_URL'));
elements.joinBtn.disabled = false; elements.joinBtn.disabled = false;
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM'); elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
isProcessingConnection = false;
return; return;
} }
} }
@@ -1223,6 +1247,8 @@ elements.joinBtn.addEventListener('click', async () => {
}); });
elements.leaveBtn.addEventListener('click', async () => { elements.leaveBtn.addEventListener('click', async () => {
if (isProcessingConnection) return;
isProcessingConnection = true;
clearConnectionErrorTimer(); clearConnectionErrorTimer();
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' }); chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
await chrome.storage.local.set({ roomId: '', password: '' }); await chrome.storage.local.set({ roomId: '', password: '' });
@@ -1230,6 +1256,7 @@ elements.leaveBtn.addEventListener('click', async () => {
elements.password.value = ''; elements.password.value = '';
lastKnownPeers = []; lastKnownPeers = [];
updateUI(null, null); updateUI(null, null);
isProcessingConnection = false;
}); });
function generateRoomId() { function generateRoomId() {
@@ -1280,6 +1307,9 @@ elements.retryBtn.addEventListener('click', () => {
}); });
elements.targetTab.addEventListener('change', () => { elements.targetTab.addEventListener('change', () => {
const hint = document.getElementById('targetTabHint');
if (hint) hint.style.display = 'none';
const val = elements.targetTab.value; const val = elements.targetTab.value;
const tabId = val ? parseInt(val) : null; const tabId = val ? parseInt(val) : null;
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || 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 () => { elements.forceSyncBtn.addEventListener('click', async () => {
if (elements.forceSyncBtn.disabled) return; 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)); 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; const mode = elements.forceSyncMode.value;
let targetTime = null; let targetTime = null;
@@ -1298,6 +1334,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
if (mode === 'jump-to-others') { if (mode === 'jump-to-others') {
if (!localPeerId) { if (!localPeerId) {
showError(getMessage('ERR_IDENTITY_NOT_LOADED')); showError(getMessage('ERR_IDENTITY_NOT_LOADED'));
elements.forceSyncBtn.disabled = false;
return; return;
} }
const peers = status.peers || []; const peers = status.peers || [];
@@ -1307,6 +1344,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
if (otherTimes.length === 0) { if (otherTimes.length === 0) {
showError(getMessage('ERR_NO_PEERS_TIME')); showError(getMessage('ERR_NO_PEERS_TIME'));
elements.forceSyncBtn.disabled = false;
return; return;
} }
@@ -1315,8 +1353,6 @@ elements.forceSyncBtn.addEventListener('click', async () => {
targetTime = otherTimes.length % 2 !== 0 ? otherTimes[mid] : (otherTimes[mid - 1] + otherTimes[mid]) / 2; 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'); elements.forceSyncBtn.textContent = mode === 'jump-to-others' ? getMessage('BTN_STATE_SYNCING_GROUP', { time: formatTime(targetTime) }) : getMessage('BTN_STATE_SYNCING');
forceSyncDone = false; forceSyncDone = false;
const peerCount = (status.peers || []).filter(p => (typeof p === 'object' ? p.peerId : p) !== localPeerId).length; 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') { } else if (msg.type === 'ROOM_LIST') {
updateRoomList(msg.rooms); updateRoomList(msg.rooms);
} else if (msg.type === 'JOIN_STATUS') { } else if (msg.type === 'JOIN_STATUS') {
isProcessingConnection = false;
if (joinBtnTimeout) { if (joinBtnTimeout) {
clearTimeout(joinBtnTimeout); clearTimeout(joinBtnTimeout);
joinBtnTimeout = null; joinBtnTimeout = null;
@@ -1621,9 +1658,15 @@ chrome.runtime.onMessage.addListener((msg) => {
// Final confirmation of join from background // Final confirmation of join from background
chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => { chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl); 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 { } else {
// Join failed: reset UI state // Join failed: reset UI state
chrome.storage.local.set({ roomId: '', password: '' });
elements.roomId.value = '';
elements.password.value = '';
updateUI(null, null); updateUI(null, null);
} }
} else if (msg.type === 'LOBBY_UPDATE') { } else if (msg.type === 'LOBBY_UPDATE') {
@@ -2049,36 +2092,174 @@ function updateLobbyUI(lobby, peers) {
// --- Onboarding Tour --- // --- Onboarding Tour ---
const onboardingSteps = [ 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: '👋',
{ icon: '🎬', get title() { return getMessage('ONBOARDING_3_TITLE'); }, get text() { return getMessage('ONBOARDING_3_TEXT'); }, targetTab: 'tab-sync' }, get title() { return getMessage('ONBOARDING_1_TITLE'); },
{ icon: '⚙️', get title() { return getMessage('ONBOARDING_4_TITLE'); }, get text() { return getMessage('ONBOARDING_4_TEXT'); }, targetTab: 'tab-settings' }, get text() { return getMessage('ONBOARDING_1_TEXT'); },
{ icon: '🎉', get title() { return getMessage('ONBOARDING_5_TITLE'); }, get text() { return getMessage('ONBOARDING_5_TEXT'); }, targetTab: 'tab-room' } 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 onboardingStep = 0;
let onboardingTimeout = null;
function showOnboarding() { function showOnboarding() {
const overlay = document.getElementById('onboarding-overlay'); const overlay = document.getElementById('onboarding-overlay');
if (!overlay) return; if (!overlay) return;
document.body.style.minHeight = '400px'; document.body.style.minHeight = '420px';
overlay.style.display = 'flex'; overlay.style.display = 'block';
onboardingStep = 0;
renderOnboardingStep(); 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() { function renderOnboardingStep() {
if (onboardingTimeout) clearTimeout(onboardingTimeout);
const step = onboardingSteps[onboardingStep]; const step = onboardingSteps[onboardingStep];
const icon = document.getElementById('onboarding-icon'); const icon = document.getElementById('onboarding-icon');
const title = document.getElementById('onboarding-title'); const title = document.getElementById('onboarding-title');
const text = document.getElementById('onboarding-text'); const text = document.getElementById('onboarding-text');
const nextBtn = document.getElementById('onboarding-next'); const nextBtn = document.getElementById('onboarding-next');
const dots = document.getElementById('onboarding-dots'); const stepIndicator = document.getElementById('onboarding-step-indicator');
if (!icon || !title || !text || !nextBtn || !dots) return; const progressBar = document.getElementById('onboarding-progress-bar');
if (!icon || !title || !text || !nextBtn) return;
icon.textContent = step.icon; icon.textContent = step.icon;
title.textContent = step.title; title.textContent = step.title;
text.textContent = step.text; 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) { if (step.targetTab) {
const tabBtn = document.querySelector(`.tab-btn[data-tab="${step.targetTab}"]`); const tabBtn = document.querySelector(`.tab-btn[data-tab="${step.targetTab}"]`);
if (tabBtn) tabBtn.click(); if (tabBtn) tabBtn.click();
@@ -2089,31 +2270,53 @@ function renderOnboardingStep() {
if (syncActive) syncActive.style.display = 'block'; if (syncActive) syncActive.style.display = 'block';
if (syncInactive) syncInactive.style.display = 'none'; if (syncInactive) syncInactive.style.display = 'none';
} else { } 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'; const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
if (syncActive) syncActive.style.display = inRoom ? 'block' : 'none'; if (syncActive) syncActive.style.display = inRoom ? 'block' : 'none';
if (syncInactive) syncInactive.style.display = inRoom ? 'none' : 'block'; if (syncInactive) syncInactive.style.display = inRoom ? 'none' : 'block';
} }
} }
dots.replaceChildren(); const spotlight = document.getElementById('onboarding-spotlight');
onboardingSteps.forEach((_, i) => { const card = document.getElementById('onboarding-card');
const dot = document.createElement('div'); if (spotlight) spotlight.style.opacity = '0';
dot.style.cssText = `width:8px; height:8px; border-radius:50%; background:${i === onboardingStep ? 'var(--accent)' : '#475569'};`; if (card) {
dots.appendChild(dot); 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() { function completeOnboarding() {
const overlay = document.getElementById('onboarding-overlay'); if (onboardingTimeout) clearTimeout(onboardingTimeout);
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'; const overlay = document.getElementById('onboarding-overlay');
toggleUIState(inRoom); 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', () => { document.getElementById('onboarding-next')?.addEventListener('click', () => {
@@ -2130,3 +2333,8 @@ document.getElementById('onboarding-skip')?.addEventListener('click', completeOn
document.getElementById('onboarding-overlay')?.addEventListener('click', (e) => { document.getElementById('onboarding-overlay')?.addEventListener('click', (e) => {
if (e.target.id === 'onboarding-overlay') completeOnboarding(); if (e.target.id === 'onboarding-overlay') completeOnboarding();
}); });
elements.restartTourBtn?.addEventListener('click', () => {
onboardingStep = 0;
showOnboarding();
});