Last active
April 24, 2026 01:22
-
-
Save affan2021shaikh/efa8b9f8c31b794d2d3b2a1b99928e0f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name YouTube Background Play Plus | |
| // @namespace https://example.invalid/ | |
| // @version 2.0.0 | |
| // @description Best-effort background play for YouTube desktop and mobile web using only standard userscript features. | |
| // @match https://www.youtube.com/* | |
| // @match https://m.youtube.com/* | |
| // @run-at document-start | |
| // @grant none | |
| // @noframes | |
| // ==/UserScript== | |
| (() => { | |
| 'use strict'; | |
| const CFG = { | |
| manualGraceMs: 1000, | |
| hiddenRetryMs: 250, | |
| visibleRetryMs: 500, | |
| maxRetryMs: 5000, | |
| hiddenRetryWindowMs: 30000, | |
| debug: false, | |
| }; | |
| const log = (...args) => { | |
| if (CFG.debug) console.log('[YT BG Play Plus]', ...args); | |
| }; | |
| const SEL = [ | |
| '#movie_player video', | |
| 'ytd-player video', | |
| 'video.html5-main-video', | |
| 'video', | |
| ]; | |
| const CONTROL_SEL = [ | |
| 'button', | |
| '[role="button"]', | |
| 'tp-yt-paper-icon-button', | |
| 'yt-icon-button', | |
| '.ytp-button', | |
| '.ytp-play-button', | |
| '.ytp-volume-panel', | |
| '.ytp-fullscreen-button', | |
| ].join(','); | |
| let currentVideo = null; | |
| let desiredPlaying = false; | |
| let lastGestureAt = 0; | |
| let lastExplicitControlAt = 0; | |
| let lastManualPauseAt = 0; | |
| let hiddenSince = 0; | |
| let observerStarted = false; | |
| let retryTimer = 0; | |
| let retryDelay = CFG.visibleRetryMs; | |
| let playInFlight = false; | |
| let booted = false; | |
| const now = () => Date.now(); | |
| const max2 = Math.max; | |
| function touchGesture(event) { | |
| const t = now(); | |
| lastGestureAt = t; | |
| const target = event && event.target; | |
| if (target instanceof Element && target.closest(CONTROL_SEL)) { | |
| lastExplicitControlAt = t; | |
| } | |
| } | |
| function manualGraceActive() { | |
| const t = now(); | |
| const recent = max2(lastGestureAt, lastExplicitControlAt, lastManualPauseAt); | |
| return (t - recent) <= CFG.manualGraceMs; | |
| } | |
| function setMediaSessionState() { | |
| try { | |
| if (!('mediaSession' in navigator)) return; | |
| navigator.mediaSession.playbackState = desiredPlaying ? 'playing' : 'paused'; | |
| } catch (_) {} | |
| } | |
| function installMediaSessionHandlers() { | |
| try { | |
| if (!('mediaSession' in navigator)) return; | |
| if (typeof navigator.mediaSession.setActionHandler !== 'function') return; | |
| navigator.mediaSession.setActionHandler('play', () => { | |
| desiredPlaying = true; | |
| setMediaSessionState(); | |
| scheduleRetry('mediasession-play', true); | |
| }); | |
| navigator.mediaSession.setActionHandler('pause', () => { | |
| desiredPlaying = false; | |
| lastManualPauseAt = now(); | |
| setMediaSessionState(); | |
| try { | |
| if (currentVideo && !currentVideo.paused) currentVideo.pause(); | |
| } catch (_) {} | |
| }); | |
| } catch (_) {} | |
| } | |
| function scoreVideo(v) { | |
| if (!(v instanceof HTMLVideoElement) || !v.isConnected) return -1e18; | |
| const r = v.getBoundingClientRect(); | |
| const area = max2(0, r.width) * max2(0, r.height); | |
| let score = area; | |
| if (r.width > 0 && r.height > 0) score += 1_000_000; | |
| if (document.visibilityState === 'visible') score += 20_000; | |
| if (v.readyState >= 2) score += 10_000; | |
| if (!v.paused) score += 50_000; | |
| if (v.currentSrc) score += 2_000; | |
| if (v.matches('.html5-main-video')) score += 200_000; | |
| if (v.closest('#movie_player')) score += 150_000; | |
| if (v.closest('ytd-player')) score += 100_000; | |
| if (v.closest('.html5-video-container')) score += 50_000; | |
| return score; | |
| } | |
| function pickVideo() { | |
| const seen = new Set(); | |
| const candidates = []; | |
| for (const sel of SEL) { | |
| for (const v of document.querySelectorAll(sel)) { | |
| if (!seen.has(v)) { | |
| seen.add(v); | |
| candidates.push(v); | |
| } | |
| } | |
| } | |
| let best = null; | |
| let bestScore = -1e18; | |
| for (const v of candidates) { | |
| const s = scoreVideo(v); | |
| if (s > bestScore) { | |
| bestScore = s; | |
| best = v; | |
| } | |
| } | |
| return best; | |
| } | |
| function attachVideo(v) { | |
| if (!(v instanceof HTMLVideoElement)) return; | |
| if (v === currentVideo) return; | |
| if (currentVideo) { | |
| currentVideo.removeEventListener('play', onPlay, true); | |
| currentVideo.removeEventListener('playing', onPlaying, true); | |
| currentVideo.removeEventListener('pause', onPause, true); | |
| currentVideo.removeEventListener('ended', onEnded, true); | |
| currentVideo.removeEventListener('emptied', onChangedSource, true); | |
| currentVideo.removeEventListener('loadstart', onChangedSource, true); | |
| currentVideo.removeEventListener('loadedmetadata', onChangedSource, true); | |
| currentVideo.removeEventListener('stalled', onNeedRetry, true); | |
| currentVideo.removeEventListener('waiting', onNeedRetry, true); | |
| currentVideo.removeEventListener('suspend', onNeedRetry, true); | |
| currentVideo.removeEventListener('canplay', onNeedRetry, true); | |
| } | |
| currentVideo = v; | |
| try { | |
| currentVideo.setAttribute('playsinline', ''); | |
| currentVideo.setAttribute('webkit-playsinline', ''); | |
| } catch (_) {} | |
| currentVideo.addEventListener('play', onPlay, true); | |
| currentVideo.addEventListener('playing', onPlaying, true); | |
| currentVideo.addEventListener('pause', onPause, true); | |
| currentVideo.addEventListener('ended', onEnded, true); | |
| currentVideo.addEventListener('emptied', onChangedSource, true); | |
| currentVideo.addEventListener('loadstart', onChangedSource, true); | |
| currentVideo.addEventListener('loadedmetadata', onChangedSource, true); | |
| currentVideo.addEventListener('stalled', onNeedRetry, true); | |
| currentVideo.addEventListener('waiting', onNeedRetry, true); | |
| currentVideo.addEventListener('suspend', onNeedRetry, true); | |
| currentVideo.addEventListener('canplay', onNeedRetry, true); | |
| log('attached video'); | |
| } | |
| function syncVideo() { | |
| const best = pickVideo(); | |
| if (best) attachVideo(best); | |
| } | |
| function resetRetryDelay() { | |
| retryDelay = document.hidden ? CFG.hiddenRetryMs : CFG.visibleRetryMs; | |
| } | |
| function bumpRetryDelay() { | |
| retryDelay = Math.min(Math.max(retryDelay * 1.5, 250), CFG.maxRetryMs); | |
| } | |
| function safePlay(reason) { | |
| if (!currentVideo || !desiredPlaying) return Promise.resolve(false); | |
| if (playInFlight) return Promise.resolve(true); | |
| playInFlight = true; | |
| let p; | |
| try { | |
| p = currentVideo.play(); | |
| } catch (err) { | |
| playInFlight = false; | |
| log('play threw', reason, err && err.name ? err.name : err); | |
| return Promise.resolve(false); | |
| } | |
| if (p && typeof p.then === 'function') { | |
| return p.then(() => { | |
| playInFlight = false; | |
| resetRetryDelay(); | |
| setMediaSessionState(); | |
| log('play ok', reason); | |
| return true; | |
| }).catch((err) => { | |
| playInFlight = false; | |
| bumpRetryDelay(); | |
| log('play failed', reason, err && err.name ? err.name : err); | |
| return false; | |
| }); | |
| } | |
| playInFlight = false; | |
| resetRetryDelay(); | |
| setMediaSessionState(); | |
| return Promise.resolve(true); | |
| } | |
| function scheduleRetry(reason, immediate = false) { | |
| if (!desiredPlaying) return; | |
| clearTimeout(retryTimer); | |
| const delay = immediate ? 0 : (document.hidden ? CFG.hiddenRetryMs : retryDelay); | |
| retryTimer = setTimeout(async () => { | |
| if (!desiredPlaying) return; | |
| if (document.hidden && hiddenSince && (now() - hiddenSince) > CFG.hiddenRetryWindowMs) { | |
| return; | |
| } | |
| syncVideo(); | |
| if (!currentVideo) { | |
| bumpRetryDelay(); | |
| scheduleRetry(reason); | |
| return; | |
| } | |
| if (!currentVideo.paused && currentVideo.readyState >= 2) { | |
| resetRetryDelay(); | |
| return; | |
| } | |
| const ok = await safePlay(reason); | |
| if (!ok && desiredPlaying) scheduleRetry(reason); | |
| }, delay); | |
| } | |
| function onPlay() { | |
| desiredPlaying = true; | |
| resetRetryDelay(); | |
| setMediaSessionState(); | |
| } | |
| function onPlaying() { | |
| desiredPlaying = true; | |
| resetRetryDelay(); | |
| setMediaSessionState(); | |
| } | |
| function onPause() { | |
| if (manualGraceActive()) { | |
| desiredPlaying = false; | |
| lastManualPauseAt = now(); | |
| setMediaSessionState(); | |
| return; | |
| } | |
| if (desiredPlaying) { | |
| scheduleRetry('pause'); | |
| } | |
| } | |
| function onEnded() { | |
| desiredPlaying = false; | |
| resetRetryDelay(); | |
| setMediaSessionState(); | |
| } | |
| function onChangedSource() { | |
| setTimeout(() => { | |
| syncVideo(); | |
| if (desiredPlaying) scheduleRetry('source-change', true); | |
| }, 0); | |
| } | |
| function onNeedRetry() { | |
| if (desiredPlaying) scheduleRetry('buffering'); | |
| } | |
| function onVisibilityChange() { | |
| if (document.hidden) { | |
| hiddenSince = now(); | |
| if (desiredPlaying) scheduleRetry('hidden', true); | |
| } else { | |
| hiddenSince = 0; | |
| if (desiredPlaying) scheduleRetry('visible', true); | |
| } | |
| } | |
| function onPageHide() { | |
| if (desiredPlaying) scheduleRetry('pagehide', true); | |
| } | |
| function onPageShow() { | |
| if (desiredPlaying) scheduleRetry('pageshow', true); | |
| } | |
| function onFocusChange() { | |
| if (desiredPlaying) scheduleRetry('focus-change', true); | |
| } | |
| function onUserIntent(event) { | |
| touchGesture(event); | |
| } | |
| function patchPause() { | |
| const proto = HTMLMediaElement && HTMLMediaElement.prototype; | |
| if (!proto || typeof proto.pause !== 'function') return; | |
| const nativePause = proto.pause; | |
| proto.pause = function patchedPause() { | |
| try { | |
| if ( | |
| this === currentVideo && | |
| desiredPlaying && | |
| document.hidden && | |
| !manualGraceActive() | |
| ) { | |
| log('blocked hidden pause'); | |
| return; | |
| } | |
| } catch (_) {} | |
| return nativePause.apply(this, arguments); | |
| }; | |
| } | |
| function patchHistory() { | |
| const dispatch = () => window.dispatchEvent(new Event('ytbg-locationchange')); | |
| try { | |
| const pushState = history.pushState; | |
| if (typeof pushState === 'function') { | |
| history.pushState = function () { | |
| const ret = pushState.apply(this, arguments); | |
| dispatch(); | |
| return ret; | |
| }; | |
| } | |
| } catch (_) {} | |
| try { | |
| const replaceState = history.replaceState; | |
| if (typeof replaceState === 'function') { | |
| history.replaceState = function () { | |
| const ret = replaceState.apply(this, arguments); | |
| dispatch(); | |
| return ret; | |
| }; | |
| } | |
| } catch (_) {} | |
| window.addEventListener('popstate', dispatch, true); | |
| window.addEventListener('yt-navigate-finish', dispatch, true); | |
| window.addEventListener('yt-page-data-updated', dispatch, true); | |
| window.addEventListener('ytbg-locationchange', () => { | |
| setTimeout(() => { | |
| syncVideo(); | |
| if (desiredPlaying) scheduleRetry('navigation', true); | |
| }, 0); | |
| }, true); | |
| } | |
| function startObserver() { | |
| if (observerStarted) return; | |
| const root = document.documentElement || document; | |
| observerStarted = true; | |
| const mo = new MutationObserver(() => { | |
| syncVideo(); | |
| }); | |
| mo.observe(root, { childList: true, subtree: true }); | |
| syncVideo(); | |
| log('observer started'); | |
| } | |
| function installGlobalListeners() { | |
| document.addEventListener('pointerdown', onUserIntent, true); | |
| document.addEventListener('touchstart', onUserIntent, true); | |
| document.addEventListener('mousedown', onUserIntent, true); | |
| document.addEventListener('click', onUserIntent, true); | |
| document.addEventListener('keydown', onUserIntent, true); | |
| document.addEventListener('visibilitychange', onVisibilityChange, true); | |
| window.addEventListener('pagehide', onPageHide, true); | |
| window.addEventListener('pageshow', onPageShow, true); | |
| window.addEventListener('focus', onFocusChange, true); | |
| window.addEventListener('blur', onFocusChange, true); | |
| } | |
| function boot() { | |
| if (booted) return; | |
| booted = true; | |
| patchPause(); | |
| patchHistory(); | |
| installGlobalListeners(); | |
| installMediaSessionHandlers(); | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', () => { | |
| startObserver(); | |
| }, { once: true }); | |
| } else { | |
| startObserver(); | |
| } | |
| setInterval(() => { | |
| if (!desiredPlaying) return; | |
| syncVideo(); | |
| if (!currentVideo) return; | |
| if (document.hidden) { | |
| if (currentVideo.paused) scheduleRetry('watchdog-hidden'); | |
| return; | |
| } | |
| if (currentVideo.paused) { | |
| scheduleRetry('watchdog-visible'); | |
| } | |
| }, 300); | |
| } | |
| boot(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment