diff --git a/css/player-base.js b/css/player-base.js index c655dd50..3d6b356d 100644 --- a/css/player-base.js +++ b/css/player-base.js @@ -14,9 +14,8 @@ var versionclient = "youtube.player.web_20250917_22_RC00" * Available under Apache License Version 2.0 * */ - document.addEventListener("DOMContentLoaded", () => { - const video = videojs('video', { + const video = videojs('video', { controls: true, autoplay: false, preload: 'auto', @@ -37,7 +36,7 @@ var versionclient = "youtube.player.web_20250917_22_RC00" videoEl.setAttribute('webkit-playsinline', ''); } catch {} - video.ready(() => { + video.ready(() => { const metaTitle = document.querySelector('meta[name="title"]')?.content || ""; const metaDesc = document.querySelector('meta[name="twitter:description"]')?.content || ""; const metaAuthor = document.querySelector('meta[name="twitter:author"]')?.content || ""; @@ -53,15 +52,19 @@ var versionclient = "youtube.player.web_20250917_22_RC00" } const createTitleBar = () => { - const existing = video.getChild("TitleBar"); - if (!existing) { - const titleBar = video.addChild("TitleBar"); - titleBar.update({ title: metaTitle, description: stats }); - } + try { + const existing = video.getChild?.("TitleBar"); + if (!existing) { + const titleBar = video.addChild?.("TitleBar"); + titleBar?.update?.({ title: metaTitle, description: stats }); + } + } catch {} }; const removeTitleBar = () => { - const existing = video.getChild("TitleBar"); - if (existing) video.removeChild(existing); + try { + const existing = video.getChild?.("TitleBar"); + if (existing) video.removeChild?.(existing); + } catch {} }; const handleFullscreen = () => { const fs = document.fullscreenElement || document.webkitFullscreenElement; @@ -73,8 +76,7 @@ var versionclient = "youtube.player.web_20250917_22_RC00" handleFullscreen(); }); - // ----- state ----- - let syncing = false; + let syncing = false; let restarting = false; let firstSeekDone = false; @@ -103,12 +105,24 @@ var versionclient = "youtube.player.web_20250917_22_RC00" let startupPhase = true; let firstPlayCommitted = false; - // guard against element loop attrs - try { videoEl.loop = false; videoEl.removeAttribute?.('loop'); } catch {} + try { videoEl.loop = false; videoEl.removeAttribute?.('loop'); } catch {} try { audio.loop = false; audio.removeAttribute?.('loop'); } catch {} - // ----- helpers ----- - const clamp01 = v => Math.max(0, Math.min(1, Number(v))); + const abort = new AbortController(); + const on = (t, type, fn, opts) => t?.addEventListener(type, fn, { signal: abort.signal, ...(opts||{}) }); + const timeouts = new Set(); + const intervals = new Set(); + const rafs = new Set(); + const setT = (fn, ms) => { const id = setTimeout(fn, ms); timeouts.add(id); return id; }; + const setI = (fn, ms) => { const id = setInterval(fn, ms); intervals.add(id); return id; }; + const rAF = (fn) => { const id = requestAnimationFrame(fn); rafs.add(id); return id; }; + const clearAllTimers = () => { + intervals.forEach(clearInterval); intervals.clear(); + timeouts.forEach(clearTimeout); timeouts.clear(); + rafs.forEach(cancelAnimationFrame); rafs.clear(); + }; + + const clamp01 = v => Math.max(0, Math.min(1, Number(v))); const EPS = 0.15; const MICRO_DRIFT = 0.05; const BIG_DRIFT = 0.5; @@ -118,15 +132,17 @@ var versionclient = "youtube.player.web_20250917_22_RC00" const PROGRESS_KEY = `progress-${vidKey}`; const pickAudioSrc = () => { - const s = audio?.getAttribute?.('src'); - if (s) return s; - const child = audio?.querySelector?.('source'); - if (child?.getAttribute?.('src')) return child.getAttribute('src'); - if (audio?.currentSrc) return audio.currentSrc; + try { + const s = audio?.getAttribute?.('src'); + if (s) return s; + const child = audio?.querySelector?.('source'); + if (child?.getAttribute?.('src')) return child.getAttribute('src'); + if (audio?.currentSrc) return audio.currentSrc; + } catch {} return null; }; - const srcObj = video.src(); + const srcObj = video.src?.(); const videoSrc = Array.isArray(srcObj) ? (srcObj[0] && srcObj[0].src) : srcObj; const hasExternalAudio = !!audio && audio.tagName === 'AUDIO' && !!pickAudioSrc(); @@ -134,20 +150,23 @@ var versionclient = "youtube.player.web_20250917_22_RC00" let lastAT = 0, lastATts = 0; // audio watchdog let aligning = false; // prevents overlapping soft-aligns - // ------- clickless volume ramps (no WebAudio needed) ------- - let volAnim = null; + let volAnim = null; function setImmediateVolume(val) { - try { audio.volume = clamp01(val); } catch {} + try { if (audio) audio.volume = clamp01(val); } catch {} } function getBaseTargetVol() { - const vVol = clamp01(typeof video.volume === 'function' ? video.volume() : videoEl.volume ?? 1); - const vMuted = typeof video.muted === 'function' ? video.muted() : videoEl.muted; + // Prefer Video.js API if present; fall back to native + let vVol = 1, vMuted = false; + try { + vVol = clamp01(typeof video.volume === 'function' ? video.volume() : (videoEl.volume ?? 1)); + vMuted = typeof video.muted === 'function' ? video.muted() : !!videoEl.muted; + } catch {} const hardMuted = vMuted || userMutedVideo || userMutedAudio; return hardMuted ? 0 : vVol; } function rampVolumeTo(target, ms = 60) { target = clamp01(target); - const from = clamp01(audio.volume); + const from = clamp01(audio?.volume ?? 0); if (!isFinite(from)) { setImmediateVolume(target); return Promise.resolve(); } if (ms <= 0 || Math.abs(target - from) < 0.001) { setImmediateVolume(target); return Promise.resolve(); } @@ -162,10 +181,10 @@ var versionclient = "youtube.player.web_20250917_22_RC00" const t = Math.min(1, (performance.now() - start) / ms); const val = from + (target - from) * t; setImmediateVolume(val); - if (t < 1) requestAnimationFrame(step); + if (t < 1) rafs.add(requestAnimationFrame(step)); else resolve(); }; - requestAnimationFrame(step); + rAF(step); }); } async function softMuteAudio(ms = 40) { @@ -179,6 +198,7 @@ var versionclient = "youtube.player.web_20250917_22_RC00" } async function softAlignAudioTo(t, fadeDown = 40, fadeUp = 60) { + if (!audio) return; if (aligning) return; aligning = true; try { @@ -189,11 +209,11 @@ var versionclient = "youtube.player.web_20250917_22_RC00" aligning = false; } } - // ---------------------------------------------------------- + // function timeInBuffered(media, t) { try { - const br = media.buffered; + const br = media?.buffered; if (!br || br.length === 0 || !isFinite(t)) return false; for (let i = 0; i < br.length; i++) { const s = br.start(i) - EPS, e = br.end(i) + EPS; @@ -204,9 +224,9 @@ var versionclient = "youtube.player.web_20250917_22_RC00" } function canPlayAt(media, t) { try { - const rs = Number(media.readyState || 0); + const rs = Number(media?.readyState || 0); if (!isFinite(t)) return false; - if (rs >= 3) return true; + if (rs >= 3) return true; // HAVE_FUTURE_DATA return timeInBuffered(media, t); } catch { return false; } } @@ -214,26 +234,27 @@ var versionclient = "youtube.player.web_20250917_22_RC00" return canPlayAt(videoEl, t) && canPlayAt(audio, t); } function safeSetCT(media, t) { - try { if (isFinite(t) && t >= 0) media.currentTime = t; } catch {} + try { if (media && isFinite(t) && t >= 0) media.currentTime = t; } catch {} } function clearSyncLoop() { if (syncInterval) { clearInterval(syncInterval); + intervals.delete(syncInterval); syncInterval = null; } - try { audio.playbackRate = 1; } catch {} + try { if (audio) audio.playbackRate = 1; } catch {} } function setVideoMuted(val) { squelchMuteEvents++; - try { video.muted(val); } catch {} + try { video.muted?.(val); } catch {} queueMicrotask(() => { squelchMuteEvents = Math.max(0, squelchMuteEvents - 1); }); } function setAudioMuted(val) { squelchMuteEvents++; - try { audio.muted = val; } catch {} - queueMicrotask(() => { squelchMuteEvents = Math.max(0, squelchMuteEvents - 1); }); + try { if (audio) audio.muted = val; } catch {} + queueMicrotask(() => { squelchMuteEvents = Math.max(0, Math.min(1000, squelchMuteEvents - 1)); }); } async function ensureUnmutedIfNotUserMuted() { @@ -246,18 +267,20 @@ var versionclient = "youtube.player.web_20250917_22_RC00" function startSyncLoop() { clearSyncLoop(); - syncInterval = setInterval(() => { - const vt = Number(video.currentTime()); - const at = Number(audio.currentTime); + syncInterval = setI(() => { + const vt = Number(video.currentTime?.()); + const at = Number(audio?.currentTime); if (!isFinite(vt) || !isFinite(at)) return; // strict co-play enforcement if (intendedPlaying) { - if (video.paused() && !audio.paused) { try { video.play(); } catch {} } - if (!video.paused() && audio.paused) { try { audio.play().then(()=>softUnmuteAudio(80)).catch(()=>{}); } catch {} } + if (video.paused?.() && !audio?.paused) { try { video.play?.(); } catch {} } + if (!video.paused?.() && audio?.paused) { + try { audio.play?.().then(()=>softUnmuteAudio(80)).catch(()=>{}); } catch {} + } } else { - if (!video.paused()) { try { video.pause(); } catch {} } - if (!audio.paused) { try { audio.pause(); } catch {} } + if (!video.paused?.()) { try { video.pause?.(); } catch {} } + if (!audio?.paused) { try { audio.pause?.(); } catch {} } } const delta = vt - at; @@ -265,7 +288,7 @@ var versionclient = "youtube.player.web_20250917_22_RC00" if (Math.abs(delta) > RESYNC_DRIFT_LIMIT) { intendedPlaying = true; pauseHard(); - setTimeout(() => playTogether({ allowMutedRetry: true }), 120); + setT(() => playTogether({ allowMutedRetry: true }), 120); return; } @@ -275,16 +298,17 @@ var versionclient = "youtube.player.web_20250917_22_RC00" } else if (Math.abs(delta) > MICRO_DRIFT) { // gentle time-stretch const targetRate = 1 + (delta * 0.12); - try { audio.playbackRate = Math.max(0.9, Math.min(1.1, targetRate)); } catch {} + try { if (audio) audio.playbackRate = Math.max(0.9, Math.min(1.1, targetRate)); } catch {} } else { - try { audio.playbackRate = 1; } catch {} + try { if (audio) audio.playbackRate = 1; } catch {} } + // Media Session position state (guarded) try { - if ('mediaSession' in navigator && navigator.mediaSession.setPositionState) { + if ('mediaSession' in navigator && navigator.mediaSession?.setPositionState) { navigator.mediaSession.setPositionState({ - duration: Number(video.duration()) || 0, - playbackRate: Number(video.playbackRate()) || 1, + duration: Number(video.duration?.()) || 0, + playbackRate: Number(video.playbackRate?.()) || 1, position: vt }); } @@ -292,7 +316,7 @@ var versionclient = "youtube.player.web_20250917_22_RC00" // audio progress watchdog (kick without clicks) const now = performance.now(); - if (!audio.paused && intendedPlaying) { + if (audio && !audio.paused && intendedPlaying) { if (Math.abs(at - lastAT) < 0.001) { if (now - lastATts > 1500) { kickAudio(); @@ -310,31 +334,43 @@ var versionclient = "youtube.player.web_20250917_22_RC00" } async function kickAudio() { + if (!audio) return; try { const t = Number(audio.currentTime) || 0; await softMuteAudio(25); - audio.pause(); + audio.pause?.(); safeSetCT(audio, t + 0.001); - await audio.play().catch(()=>{}); + await audio.play?.().catch(()=>{}); await softUnmuteAudio(60); } catch {} } async function tryPlay(el) { + if (!el?.play) return false; try { const p = el.play(); if (p && p.then) await p; return true; - } catch { return false; } + } catch { + // Autoplay policy surfaces here; leave quiet. See MDN for play() promise. + return false; + } } async function playTogether({ allowMutedRetry = true } = {}) { + if (!audio) { // no audio element present + try { + const p = video.play?.(); + if (p && p.then) await p; + } catch {} + return; + } if (syncing || restarting) return; syncing = true; intendedPlaying = true; lastPlayKickTs = performance.now(); try { - const t = Number(video.currentTime()); + const t = Number(video.currentTime?.()); if (isFinite(t) && Math.abs(Number(audio.currentTime) - t) > 0.05) { await softAlignAudioTo(t, 25, 70); } @@ -344,21 +380,21 @@ var versionclient = "youtube.player.web_20250917_22_RC00" setAudioMuted(false); let vOk = true, aOk = true; - try { const p = video.play(); if (p && p.then) await p; } catch { vOk = false; } + try { const p = video.play?.(); if (p && p.then) await p; } catch { vOk = false; } aOk = await tryPlay(audio); if (allowMutedRetry && (!vOk || !aOk)) { - const prevVideoMuted = !!video.muted(); + const prevVideoMuted = !!video.muted?.(); const prevAudioMuted = !!audio.muted; await softMuteAudio(40); setVideoMuted(true); setAudioMuted(true); - try { const p = video.play(); if (p && p.then) await p; } catch {} + try { const p = video.play?.(); if (p && p.then) await p; } catch {} await tryPlay(audio); - setTimeout(async () => { + setT(async () => { if (!userMutedVideo) setVideoMuted(prevVideoMuted); if (!userMutedAudio) setAudioMuted(prevAudioMuted); await softUnmuteAudio(120); @@ -374,7 +410,7 @@ var versionclient = "youtube.player.web_20250917_22_RC00" if (!firstPlayCommitted) { firstPlayCommitted = true; // end startup phase shortly after first stable play - setTimeout(() => { startupPhase = false; }, 800); + setT(() => { startupPhase = false; }, 800); } } finally { syncing = false; @@ -382,8 +418,8 @@ var versionclient = "youtube.player.web_20250917_22_RC00" } function pauseHard() { - try { video.pause(); } catch {} - try { audio.pause(); } catch {} + try { video.pause?.(); } catch {} + try { audio?.pause?.(); } catch {} clearSyncLoop(); } @@ -410,20 +446,20 @@ var versionclient = "youtube.player.web_20250917_22_RC00" artwork: vidKey ? [{ src: `https://i.ytimg.com/vi/${vidKey}/maxresdefault.jpg`, sizes: "1280x720", type: "image/jpeg" }] : [] }); } catch {} - navigator.mediaSession.setActionHandler('play', () => playTogether()); - navigator.mediaSession.setActionHandler('pause', () => pauseTogether()); + try { navigator.mediaSession.setActionHandler('play', () => playTogether()); } catch {} + try { navigator.mediaSession.setActionHandler('pause', () => pauseTogether()); } catch {} try { navigator.mediaSession.setActionHandler('seekforward', (d) => { const inc = Number(d?.seekOffset) || 10; - video.currentTime(Math.min((video.currentTime() || 0) + inc, Number(video.duration()) || 0)); + video.currentTime?.(Math.min((video.currentTime?.() || 0) + inc, Number(video.duration?.()) || 0)); }); navigator.mediaSession.setActionHandler('seekbackward', (d) => { const dec = Number(d?.seekOffset) || 10; - video.currentTime(Math.max((video.currentTime() || 0) - dec, 0)); + video.currentTime?.(Math.max((video.currentTime?.() || 0) - dec, 0)); }); navigator.mediaSession.setActionHandler('seekto', (d) => { if (!d || typeof d.seekTime !== 'number') return; - video.currentTime(Math.max(0, Math.min(Number(video.duration()) || 0, d.seekTime))); + video.currentTime?.(Math.max(0, Math.min(Number(video.duration?.()) || 0, d.seekTime))); }); } catch {} } @@ -431,9 +467,9 @@ var versionclient = "youtube.player.web_20250917_22_RC00" function restoreProgress() { try { const saved = Number(localStorage.getItem(PROGRESS_KEY)); - const dur = Number(video.duration()) || 0; + const dur = Number(video.duration?.()) || 0; if (isFinite(saved) && saved > 3 && dur && saved < (dur - 10)) { - video.currentTime(saved); + video.currentTime?.(saved); safeSetCT(audio, saved); firstSeekDone = true; updateAudioGainImmediate(); @@ -442,7 +478,7 @@ var versionclient = "youtube.player.web_20250917_22_RC00" } function saveProgressThrottled() { try { - const t = Math.floor(Number(video.currentTime()) || 0); + const t = Math.floor(Number(video.currentTime?.()) || 0); if ((t & 1) === 0) localStorage.setItem(PROGRESS_KEY, String(t)); } catch {} } @@ -457,106 +493,127 @@ var versionclient = "youtube.player.web_20250917_22_RC00" showError(`${label} buffering…`); pauseHard(); }; - el.addEventListener('waiting', pauseIfRealStall); - el.addEventListener('stalled', pauseIfRealStall); - el.addEventListener('emptied', pauseIfRealStall); - el.addEventListener('error', pauseIfRealStall); + on(el, 'waiting', pauseIfRealStall); + on(el, 'stalled', pauseIfRealStall); + on(el, 'emptied', pauseIfRealStall); + on(el, 'error', pauseIfRealStall); const tryResume = async () => { hideError(); if (!intendedPlaying) return; - const t = Number(video.currentTime()); + const t = Number(video.currentTime?.()); if (bothPlayableAt(t)) { await ensureUnmutedIfNotUserMuted(); playTogether({ allowMutedRetry: true }); } }; - el.addEventListener('canplay', tryResume); - el.addEventListener('canplaythrough', tryResume); + on(el, 'canplay', tryResume); + on(el, 'canplaythrough', tryResume); } + // requestVideoFrameCallback alignment assist (micro-drift smoother) + const hasRVFC = typeof videoEl?.requestVideoFrameCallback === 'function'; + if (hasRVFC && hasExternalAudio) { + const rvfcLoop = (now, meta) => { + try { + if (!intendedPlaying || !audio || audio.paused) return; + const vt = Number(video.currentTime?.()); + const at = Number(audio.currentTime); + const delta = vt - at; + // Fine-grain trim, less than MICRO_DRIFT so we don't fight the main loop + if (Math.abs(delta) > 0.02 && Math.abs(delta) < BIG_DRIFT) { + const targetRate = 1 + (delta * 0.2); + audio.playbackRate = Math.max(0.9, Math.min(1.1, targetRate)); + } + } catch {} + if (!abort.signal.aborted) videoEl.requestVideoFrameCallback(rvfcLoop); + }; + videoEl.requestVideoFrameCallback(rvfcLoop); + } + + // ---------------- main path ---------------- if (qua !== "medium" && hasExternalAudio) { let audioReady = false, videoReady = false; const oneShotReady = (elm, markReady) => { let done = false; const onLoaded = () => { if (done) return; done = true; markReady(); maybeStart(); }; - elm.addEventListener('loadeddata', onLoaded, { once: true }); - elm.addEventListener('loadedmetadata', onLoaded, { once: true }); - elm.addEventListener('canplay', onLoaded, { once: true }); + on(elm, 'loadeddata', onLoaded, { once: true }); + on(elm, 'loadedmetadata', onLoaded, { once: true }); + on(elm, 'canplay', onLoaded, { once: true }); }; const maybeStart = () => { if (!audioReady || !videoReady || restarting) return; restoreProgress(); - const t = Number(video.currentTime()); + const t = Number(video.currentTime?.()); if (isFinite(t) && Math.abs(Number(audio.currentTime) - t) > 0.1) { safeSetCT(audio, t); } setupMediaSession(); updateAudioGainImmediate(); // end startup phase a bit after both are primed if play didn't already commit - setTimeout(() => { if (!firstPlayCommitted) startupPhase = false; }, 2500); + setT(() => { if (!firstPlayCommitted) startupPhase = false; }, 2500); }; oneShotReady(audio, () => { audioReady = true; }); oneShotReady(videoEl, () => { videoReady = true; }); // volume/mute coupling (no random mutes; fade around mute flips) - video.on('volumechange', () => { + video.on?.('volumechange', () => { if (squelchMuteEvents) return; - userMutedVideo = !!video.muted(); + userMutedVideo = !!video.muted?.(); if (performance.now() < suppressMirrorUntil || seekingActive || restarting) { // defer mirroring during squelch; just update gain target rampVolumeTo(getBaseTargetVol(), 80); return; } - if (video.muted()) { + if (video.muted?.()) { softMuteAudio(80).then(() => setAudioMuted(true)); } else { setAudioMuted(false); softUnmuteAudio(100); } // follow volume level smoothly - if (!video.muted()) rampVolumeTo(getBaseTargetVol(), 120); + if (!video.muted?.()) rampVolumeTo(getBaseTargetVol(), 120); }); - audio.addEventListener('volumechange', () => { + on(audio, 'volumechange', () => { if (squelchMuteEvents) return; userMutedAudio = !!audio.muted; // audio->video mute isn't mirrored; video UI is the authority }); // playback rate - video.on('ratechange', () => { try { audio.playbackRate = video.playbackRate(); } catch {} }); + video.on?.('ratechange', () => { try { if (audio) audio.playbackRate = video.playbackRate?.(); } catch {} }); // user play/pause drives both - video.on('play', () => { intendedPlaying = true; ensureUnmutedIfNotUserMuted().then(()=>playTogether()); }); - video.on('pause', () => { if (!restarting) pauseTogether(); }); + video.on?.('play', () => { intendedPlaying = true; ensureUnmutedIfNotUserMuted().then(()=>playTogether()); }); + video.on?.('pause', () => { if (!restarting) pauseTogether(); }); // seek auto-play policy with clickless alignment let wasPlayingBeforeSeek = false; let seekStartTime = 0; function computeSeekThresholds() { - const dur = Number(video.duration()) || 300; + const dur = Number(video.duration?.()) || 300; const small = Math.max(0.4, Math.min(5, dur * 0.01)); // 0.4s..5s const large = Math.max(5, Math.min(45, dur * 0.12)); // 5s..45s const huge = Math.max(10, Math.min(60, dur * 0.33)); // 10s..60s return { small, large, huge }; } - video.on('seeking', () => { + video.on?.('seeking', () => { if (restarting) return; seekingActive = true; - wasPlayingBeforeSeek = intendedPlaying && !video.paused(); - seekStartTime = Number(video.currentTime()); + wasPlayingBeforeSeek = intendedPlaying && !video.paused?.(); + seekStartTime = Number(video.currentTime?.()); suppressMirrorUntil = performance.now() + MUTE_SQUELCH_MS; }); - video.on('seeked', async () => { + video.on?.('seeked', async () => { if (restarting) return; - const newTime = Number(video.currentTime()); + const newTime = Number(video.currentTime?.()); const diff = Math.abs(newTime - seekStartTime); const { small, large, huge } = computeSeekThresholds(); @@ -587,7 +644,7 @@ var versionclient = "youtube.player.web_20250917_22_RC00" } else { intendedPlaying = false; pauseHard(); - setTimeout(async () => { + setT(async () => { if (wasPlayingBeforeSeek) { intendedPlaying = true; await ensureUnmutedIfNotUserMuted(); @@ -598,7 +655,7 @@ var versionclient = "youtube.player.web_20250917_22_RC00" seekingActive = false; }); - try { video.on('timeupdate', saveProgressThrottled); } catch {} + try { video.on?.('timeupdate', saveProgressThrottled); } catch {} // resilience wireResilience(videoEl, 'Video'); @@ -619,13 +676,13 @@ var versionclient = "youtube.player.web_20250917_22_RC00" } finally { restarting = false; } } - video.on('ended', () => { + video.on?.('ended', () => { if (restarting) return; if (performance.now() < suppressEndedUntil) return; if (desiredLoop) restartLoop(); else pauseTogether(); }); - audio.addEventListener('ended', () => { + on(audio, 'ended', () => { if (restarting) return; if (performance.now() < suppressEndedUntil) return; if (desiredLoop) restartLoop(); @@ -635,33 +692,54 @@ var versionclient = "youtube.player.web_20250917_22_RC00" // resume after either becomes ready again const tryAutoResume = async () => { if (!intendedPlaying) return; - const t = Number(video.currentTime()); + const t = Number(video.currentTime?.()); if (bothPlayableAt(t)) { await ensureUnmutedIfNotUserMuted(); playTogether({ allowMutedRetry: true }); } }; - videoEl.addEventListener('canplay', tryAutoResume); - audio.addEventListener('canplay', tryAutoResume); + on(videoEl, 'canplay', tryAutoResume); + on(audio, 'canplay', tryAutoResume); - // persist progress on unload + // persist progress on unload/visibility changes try { - window.addEventListener('pagehide', () => { clearSyncLoop(); }); - window.addEventListener('beforeunload', () => { + on(window, 'pagehide', () => { clearSyncLoop(); }); + on(window, 'beforeunload', () => { try { - localStorage.setItem(PROGRESS_KEY, String(Math.floor(Number(video.currentTime()) || 0))); + localStorage.setItem(PROGRESS_KEY, String(Math.floor(Number(video.currentTime?.()) || 0))); } catch {} }); + on(document, 'visibilitychange', () => { + if (document.visibilityState === 'hidden') { + // reduce CPU while hidden; resume smoothly when visible + clearSyncLoop(); + } else if (intendedPlaying && !syncInterval) { + startSyncLoop(); + // quick gentle re-sync on return + const t = Number(video.currentTime?.()); + if (isFinite(t)) softAlignAudioTo(t, 10, 80); + } + }); + on(window, 'offline', () => showError('Network offline…')); + on(window, 'online', () => hideError()); } catch {} } else { - // no external audio path + // no external audio path (kept) try { - video.on('timeupdate', () => { + video.on?.('timeupdate', () => { try { - localStorage.setItem(`progress-${vidKey}`, String(Math.floor(Number(video.currentTime()) || 0))); + localStorage.setItem(`progress-${vidKey}`, String(Math.floor(Number(video.currentTime?.()) || 0))); } catch {} }); } catch {} setupMediaSession(); } + + // clean up on navigation + on(window, 'pagehide', () => { + abort.abort(); + clearAllTimers(); + clearSyncLoop(); + }); }); + // https://codeberg.org/ashley/poke/src/branch/main/src/libpoketube/libpoketube-youtubei-objects.json