/** * KoalaSync Content Script * Injected into video tabs to control playback and detect events. */ (function() { if (window.koalaSyncInjected) return; window.koalaSyncInjected = true; let isProcessingCommand = false; // --- Helper: find the best video element on the page --- function findVideo() { const videos = document.querySelectorAll('video'); return videos.length > 0 ? videos[0] : null; } // --- Helper: YouTube/Twitch specific actions --- function tryMediaAction(action, data) { const video = findVideo(); if (!video) return; isProcessingCommand = true; try { const host = window.location.hostname.toLowerCase(); const isYouTube = host.includes('youtube.com'); const isTwitch = host.includes('twitch.tv'); if (isYouTube) { const ytButton = document.querySelector('.ytp-play-button'); if (ytButton) { const title = ytButton.getAttribute('aria-label') || ''; const isCurrentlyPlaying = title.toLowerCase().includes('pause'); if ((action === 'play' && !isCurrentlyPlaying) || (action === 'pause' && isCurrentlyPlaying)) { ytButton.click(); } } if (action === 'seek') video.currentTime = data.targetTime; return; } if (isTwitch) { const twitchButton = document.querySelector('[data-a-target="player-play-pause-button"]'); if (twitchButton) { const label = twitchButton.getAttribute('aria-label')?.toLowerCase() || ''; // Check for common localized labels (pause, stoppen, arrĂȘte) const isCurrentlyPlaying = label.includes('pause') || label.includes('stoppen') || label.includes('arrĂȘte'); if ((action === 'play' && !isCurrentlyPlaying) || (action === 'pause' && isCurrentlyPlaying)) { twitchButton.click(); } } if (action === 'seek') video.currentTime = data.targetTime; return; } // Fallback for native HTML5 if (action === 'play') { video.play().catch(() => {}); } else if (action === 'pause') { video.pause(); } else if (action === 'seek') { video.currentTime = data.targetTime; } } catch (e) { console.error('KoalaSync Media Action Error:', e); } finally { // Guarantee reset even on early returns in YouTube/Twitch blocks setTimeout(() => { isProcessingCommand = false; }, 1000); } } // --- Helper: Wait until video is ready for playback (buffered & seeked) --- function pollSeekReady(targetTime, timeoutMs = 8000) { return new Promise((resolve) => { const video = findVideo(); if (!video) { resolve(false); return; } const interval = 150; let elapsed = 0; const timer = setInterval(() => { elapsed += interval; const timeDiff = Math.abs(video.currentTime - targetTime); const ready = video.readyState >= 3 && timeDiff < 1.0; if (ready) { clearInterval(timer); resolve(true); } else if (elapsed >= timeoutMs) { clearInterval(timer); resolve(false); } }, interval); }); } // Listen for commands from background.js chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'SERVER_COMMAND') { const { action, payload } = message; if (action === 'play') { tryMediaAction('play'); } else if (action === 'pause') { tryMediaAction('pause'); } else if (action === 'seek') { tryMediaAction('seek', payload); } else if (action === 'force_sync_prepare') { const video = findVideo(); if (video) { video.pause(); video.currentTime = payload.targetTime; pollSeekReady(payload.targetTime).then(() => { chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }); }); } } else if (action === 'force_sync_execute') { tryMediaAction('play'); } } }); // Detect native events function reportEvent(action) { if (isProcessingCommand) return; const video = findVideo(); if (!video) return; chrome.runtime.sendMessage({ type: 'CONTENT_EVENT', action, payload: { currentTime: video.currentTime, timestamp: Date.now() } }); } function setupListeners() { const video = findVideo(); if (video && !video.dataset.koalaAttached) { video.addEventListener('play', () => reportEvent('play')); video.addEventListener('pause', () => reportEvent('pause')); video.addEventListener('seeked', () => reportEvent('seek')); video.dataset.koalaAttached = 'true'; } } // SPA Navigation Handler (MutationObserver) let mutationTimeout = null; const observer = new MutationObserver(() => { if (mutationTimeout) clearTimeout(mutationTimeout); mutationTimeout = setTimeout(() => { const video = findVideo(); if (video && !video.dataset.koalaAttached) { console.log('KoalaSync: New video detected via navigation.'); setupListeners(); } }, 1000); // 1s debounce }); observer.observe(document.body, { childList: true, subtree: true }); // Heartbeat let heartbeatErrorCount = 0; const heartbeatInterval = setInterval(() => { const video = findVideo(); if (video) { chrome.runtime.sendMessage({ type: 'HEARTBEAT', payload: { playbackState: video.paused ? 'paused' : 'playing', currentTime: video.currentTime } }).catch(err => { if (err.message.includes('Extension context invalidated')) { heartbeatErrorCount++; if (heartbeatErrorCount === 1) { console.warn('KoalaSync: Extension reloaded. Please refresh the page if sync stops working.'); } clearInterval(heartbeatInterval); observer.disconnect(); } }); } }, 15000); // Initial Setup setupListeners(); })();