// ==UserScript== // @name YouTube A/B Loop // @version 1.0.0 // @description A/B loop — native YouTube player integration // @author Black0S // @match https://www.youtube.com/watch* // @run-at document-idle // @grant GM_xmlhttpRequest // @connect raw.githubusercontent.com // ==/UserScript== (function () { 'use strict'; // ───────────────────────────────────────────────────────────────────────────── // CONSTANTS // ───────────────────────────────────────────────────────────────────────────── const RETRY_DELAY_MS = 600; // ms between player-ready polling attempts const SPA_NAVIGATION_DELAY = 1500; // ms to wait after YouTube SPA navigation const EMPTY_TIME = '–:––'; // ───────────────────────────────────────────────────────────────────────────── // CSS (injected once into , persists across SPA navigations) // ───────────────────────────────────────────────────────────────────────────── const CSS = ` /* ── Toolbar button ── */ .abl-yt-btn { border: none; background: transparent; cursor: pointer; padding: 0; margin: 0; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; position: relative; vertical-align: top; opacity: .9; transition: opacity .15s; } .abl-yt-btn:hover { opacity: 1; } /* Active dot — visible when loop is running */ .abl-yt-btn .abl-dot { position: absolute; top: 9px; right: 9px; width: 5px; height: 5px; border-radius: 50%; background: #f00; opacity: 0; transform: scale(0); transition: opacity .2s, transform .25s cubic-bezier(.34,1.56,.64,1); } .abl-yt-btn.active .abl-dot { opacity: 1; transform: scale(1); } /* ── Floating panel ── */ .abl-panel { position: absolute; bottom: 54px; right: 4px; width: 340px; background: rgba(30,30,30,.72); backdrop-filter: blur(28px) saturate(1.6) brightness(.85); -webkit-backdrop-filter: blur(28px) saturate(1.6) brightness(.85); border-radius: 12px; border: 1px solid rgba(255,255,255,.08); box-shadow: 0 8px 32px rgba(0,0,0,.5); font-family: 'Roboto','YouTube Sans',Arial,sans-serif; color: #fff; z-index: 99999; overflow: hidden; opacity: 0; transform: translateY(8px) scale(.97); transform-origin: bottom right; transition: opacity .18s, transform .18s cubic-bezier(.4,0,.2,1); pointer-events: none; user-select: none; } .abl-panel.open { opacity: 1; transform: none; pointer-events: all; } /* ── Panel header ── */ .abl-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px 8px; border-bottom: 1px solid rgba(255,255,255,.08); } .abl-panel-title { font-size: 12px; font-weight: 500; letter-spacing: .06em; text-transform: uppercase; color: rgba(255,255,255,.45); } /* ── Loop toggle (pill switch) ── */ .abl-loop-toggle { display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 4px 8px; border-radius: 20px; transition: background .15s; } .abl-loop-toggle:hover { background: rgba(255,255,255,.08); } .abl-toggle-pill { width: 30px; height: 17px; border-radius: 9px; background: rgba(255,255,255,.18); position: relative; transition: background .2s; flex-shrink: 0; } .abl-toggle-pill::after { content: ''; position: absolute; top: 2.5px; left: 2.5px; width: 12px; height: 12px; border-radius: 50%; background: rgba(255,255,255,.5); transition: transform .22s cubic-bezier(.34,1.56,.64,1), background .2s; } .abl-loop-toggle.on .abl-toggle-pill { background: #f00; } .abl-loop-toggle.on .abl-toggle-pill::after { transform: translateX(13px); background: #fff; } .abl-toggle-label { font-size: 13px; font-weight: 500; color: rgba(255,255,255,.6); transition: color .15s; } .abl-loop-toggle.on .abl-toggle-label { color: #fff; } /* ── Mode selector ── */ .abl-mode-row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 10px 16px; border-bottom: 1px solid rgba(255,255,255,.08); } .abl-mode-btn { height: 32px; border-radius: 6px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.05); font-family: inherit; font-size: 12px; font-weight: 500; color: rgba(255,255,255,.45); cursor: pointer; transition: background .15s, color .15s, border-color .15s; display: flex; align-items: center; justify-content: center; gap: 6px; } .abl-mode-btn:hover { background: rgba(255,255,255,.1); color: rgba(255,255,255,.8); } .abl-mode-btn.sel { background: rgba(255,255,255,.12); border-color: rgba(255,255,255,.3); color: #fff; } /* ── A/B section (collapsed in "full" mode) ── */ .abl-ab-section { padding: 10px 16px; border-bottom: 1px solid rgba(255,255,255,.08); overflow: hidden; max-height: 200px; transition: opacity .2s, max-height .2s, padding .2s; } .abl-ab-section.hidden { opacity: 0; max-height: 0; padding: 0 16px; pointer-events: none; } /* ── Footer ── */ .abl-panel-footer { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px 12px; overflow: hidden; max-height: 80px; transition: opacity .2s, max-height .2s, padding .2s; } .abl-panel-footer.hidden { opacity: 0; max-height: 0; padding: 0; pointer-events: none; } /* ── A/B point cards ── */ .abl-points-row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 10px; } .abl-point-card { background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.08); border-radius: 8px; padding: 8px 10px; display: flex; align-items: center; justify-content: space-between; transition: border-color .2s, background .2s; } .abl-point-card.set { border-color: rgba(255,255,255,.2); background: rgba(255,255,255,.08); } .abl-point-left { display: flex; align-items: center; gap: 8px; } .abl-point-badge { width: 18px; height: 18px; border-radius: 50%; background: rgba(255,255,255,.08); display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 700; color: rgba(255,255,255,.3); transition: background .2s, color .2s; } .abl-point-card.set .abl-point-badge { background: rgba(255,0,0,.25); color: #f66; } .abl-point-time { font-size: 13px; font-weight: 500; font-variant-numeric: tabular-nums; color: rgba(255,255,255,.3); transition: color .2s; } .abl-point-card.set .abl-point-time { color: #fff; } .abl-point-actions { display: flex; gap: 2px; } .abl-pt-set-btn { font-family: inherit; font-size: 10px; font-weight: 600; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; color: rgba(255,255,255,.5); padding: 3px 7px; cursor: pointer; transition: background .15s, color .15s; } .abl-pt-set-btn:hover { background: rgba(255,255,255,.15); color: #fff; } .abl-pt-clr-btn { background: none; border: none; color: rgba(255,255,255,.2); font-size: 11px; cursor: pointer; padding: 3px 4px; transition: color .15s; } .abl-pt-clr-btn:hover { color: #f44; } /* ── Mini timeline ── */ .abl-track-wrap { position: relative; height: 20px; display: flex; align-items: center; cursor: pointer; margin-bottom: 3px; } .abl-rail { position: absolute; left: 0; right: 0; height: 3px; background: rgba(255,255,255,.12); border-radius: 2px; } .abl-rail-prog { position: absolute; left: 0; height: 100%; background: rgba(255,255,255,.3); border-radius: 2px; pointer-events: none; width: 0%; } .abl-rail-range { position: absolute; height: 100%; background: #f00; border-radius: 2px; pointer-events: none; opacity: 0; transition: opacity .3s; } .abl-rail-range.on { opacity: .55; } .abl-th { position: absolute; top: 50%; transform: translate(-50%,-50%); border-radius: 50%; cursor: grab; z-index: 2; display: none; transition: transform .1s; } .abl-th.vis { display: block; } .abl-th:hover { transform: translate(-50%,-50%) scale(1.3); } #abl-th-a, #abl-th-b { width: 11px; height: 11px; background: #fff; box-shadow: 0 0 0 2px rgba(255,255,255,.25); } #abl-th-play { width: 11px; height: 11px; background: #fff; box-shadow: 0 1px 5px rgba(0,0,0,.6); display: block; z-index: 3; left: 0%; } .abl-times-row { display: flex; justify-content: space-between; font-size: 10px; color: rgba(255,255,255,.25); font-variant-numeric: tabular-nums; } /* ── Footer: keyboard hints & reset ── */ .abl-hint-row { display: flex; gap: 12px; } .abl-hint { display: flex; align-items: center; gap: 4px; font-size: 10px; color: rgba(255,255,255,.2); } .abl-kbd { background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.1); border-radius: 3px; padding: 1px 5px; font-size: 10px; font-family: inherit; color: rgba(255,255,255,.3); } .abl-reset-btn { font-family: inherit; font-size: 11px; font-weight: 500; background: transparent; border: 1px solid rgba(255,255,255,.1); border-radius: 6px; color: rgba(255,255,255,.3); padding: 5px 12px; cursor: pointer; letter-spacing: .04em; transition: border-color .15s, color .15s, background .15s; } .abl-reset-btn:hover { border-color: rgba(255,60,60,.5); color: #f66; background: rgba(255,0,0,.08); } /* ── Update banner (shown when a newer version is available) ── */ .abl-update-bar { display: flex; align-items: center; justify-content: space-between; padding: 7px 16px; gap: 10px; background: rgba(255, 180, 0, .10); border-top: 1px solid rgba(255, 180, 0, .18); /* Hidden by default */ display: none; } .abl-update-bar.visible { display: flex; } .abl-update-text { font-size: 11px; color: rgba(255, 210, 80, .9); flex: 1; } .abl-update-text strong { color: #ffd050; } .abl-update-link { font-size: 11px; font-weight: 600; color: #ffd050; text-decoration: none; white-space: nowrap; padding: 3px 9px; border-radius: 5px; border: 1px solid rgba(255, 208, 80, .35); background: rgba(255, 208, 80, .08); transition: background .15s, border-color .15s; } .abl-update-link:hover { background: rgba(255, 208, 80, .18); border-color: rgba(255, 208, 80, .6); } `; // ───────────────────────────────────────────────────────────────────────────── // SVG ICON (toolbar button) // ───────────────────────────────────────────────────────────────────────────── const ICON_SVG = ` `; // ───────────────────────────────────────────────────────────────────────────── // HTML TEMPLATE (panel inner content — static string, parsed once per page) // ───────────────────────────────────────────────────────────────────────────── const PANEL_HTML = `
A / B Loop
Loop off
A
${EMPTY_TIME}
B
${EMPTY_TIME}
0:00 ${EMPTY_TIME}
Update available: Install
`; // ───────────────────────────────────────────────────────────────────────────── // TIME FORMATTER // ───────────────────────────────────────────────────────────────────────────── /** * Formats a seconds value as "mm:ss" or "h:mm:ss". * Returns EMPTY_TIME for null / NaN inputs. */ function fmt(seconds) { if (seconds == null || isNaN(seconds)) return EMPTY_TIME; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); const mm = String(m).padStart(2, '0'); const ss = String(s).padStart(2, '0'); return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; } // ───────────────────────────────────────────────────────────────────────────── // SESSION (one isolated object per page load) // // All state lives here instead of in free-floating module-level variables. // On SPA navigation we simply tear down the old session and create a new one — // no risk of stale state leaking between pages. // ───────────────────────────────────────────────────────────────────────────── function createSession(video, btn, panel, ui) { return { // DOM references video, btn, panel, ui, // Loop state pointA : null, // loop start (seconds) — null = not set pointB : null, // loop end (seconds) — null = not set loopOn : false, loopMode : 'ab', // 'ab' | 'full' // UI state panelOpen : false, dragTarget : null, // callback invoked on mousemove while dragging rafId : null, // Per-frame cache — avoids redundant DOM writes on every RAF tick _lastPct : -1, _lastDuration : -1, _lastLoStart : -1, _lastLoEnd : -1, }; } // ───────────────────────────────────────────────────────────────────────────── // POINT HELPERS // ───────────────────────────────────────────────────────────────────────────── /** Sets loop point A or B to the video's current time and updates the UI. */ function setPoint(sess, which) { const time = sess.video.currentTime; const isA = which === 'a'; if (isA) { sess.pointA = time; sess.ui.valA.textContent = fmt(time); sess.ui.ptA.classList.add('set'); sess.ui.thA.classList.add('vis'); } else { sess.pointB = time; sess.ui.valB.textContent = fmt(time); sess.ui.ptB.classList.add('set'); sess.ui.thB.classList.add('vis'); } // Invalidate the range cache so renderFrame redraws the highlighted region sess._lastLoStart = sess._lastLoEnd = -1; } /** Clears loop point A or B and updates the UI. */ function clearPoint(sess, which) { const isA = which === 'a'; if (isA) { sess.pointA = null; sess.ui.valA.textContent = EMPTY_TIME; sess.ui.ptA.classList.remove('set'); sess.ui.thA.classList.remove('vis'); } else { sess.pointB = null; sess.ui.valB.textContent = EMPTY_TIME; sess.ui.ptB.classList.remove('set'); sess.ui.thB.classList.remove('vis'); } sess._lastLoStart = sess._lastLoEnd = -1; } // ───────────────────────────────────────────────────────────────────────────── // RENDER LOOP // // Runs on every animation frame. // // Optimisations applied vs. the original: // • Per-frame value cache → DOM writes only happen when something changed. // • Range highlight position is recomputed only when A or B move. // • Duration label is written once (or on seek to a new video). // ───────────────────────────────────────────────────────────────────────────── function renderFrame(sess) { const { video, ui } = sess; const duration = video.duration || 0; // ── Duration label (written once per video, not every frame) ───────────── if (duration !== sess._lastDuration) { ui.tEnd.textContent = fmt(duration); sess._lastDuration = duration; } // ── Playhead + progress bar ─────────────────────────────────────────────── const pct = duration ? (video.currentTime / duration) * 100 : 0; if (pct !== sess._lastPct) { ui.prog.style.width = pct + '%'; ui.thPlay.style.left = pct + '%'; sess._lastPct = pct; } // ── A/B thumb positions (only written when points exist) ───────────────── if (sess.pointA !== null) ui.thA.style.left = (sess.pointA / duration * 100) + '%'; if (sess.pointB !== null) ui.thB.style.left = (sess.pointB / duration * 100) + '%'; // ── A→B highlighted range (redrawn only when boundaries change) ────────── const bothSet = sess.pointA !== null && sess.pointB !== null; if (sess.loopMode === 'ab' && bothSet && duration) { const lo = Math.min(sess.pointA, sess.pointB); const hi = Math.max(sess.pointA, sess.pointB); if (lo !== sess._lastLoStart || hi !== sess._lastLoEnd) { ui.range.style.left = (lo / duration * 100) + '%'; ui.range.style.width = ((hi - lo) / duration * 100) + '%'; ui.range.classList.add('on'); sess._lastLoStart = lo; sess._lastLoEnd = hi; } } else if (ui.range.classList.contains('on')) { ui.range.classList.remove('on'); sess._lastLoStart = sess._lastLoEnd = -1; } // ── Loop enforcement ────────────────────────────────────────────────────── if (sess.loopOn) { if (sess.loopMode === 'ab' && bothSet) { // A→B mode: jump back to A when playback passes B const lo = Math.min(sess.pointA, sess.pointB); const hi = Math.max(sess.pointA, sess.pointB); if (video.currentTime >= hi || video.currentTime < lo) { video.currentTime = lo; } } else if (sess.loopMode === 'full' && duration > 0) { // Full-video mode: detected via RAF instead of the 'ended' event. // YouTube suppresses 'ended' after the first replay — it fires only once — // so we poll currentTime directly, which is reliable on every loop. const hasEnded = video.ended || (video.paused && video.currentTime >= duration - 0.1); const nearEnd = video.currentTime >= duration - 0.3; if (hasEnded || nearEnd) { video.currentTime = 0; video.play(); } } } sess.rafId = requestAnimationFrame(() => renderFrame(sess)); } function startRenderLoop(sess) { sess.rafId = requestAnimationFrame(() => renderFrame(sess)); } // ───────────────────────────────────────────────────────────────────────────── // SETUP FUNCTIONS (each wires exactly one UI concern) // ───────────────────────────────────────────────────────────────────────────── /** Opens / closes the floating panel on toolbar-button click. */ function setupPanelToggle(sess) { const { btn, panel } = sess; btn.addEventListener('click', (e) => { e.stopPropagation(); sess.panelOpen = !sess.panelOpen; panel.classList.toggle('open', sess.panelOpen); btn.classList.toggle('active', sess.panelOpen || sess.loopOn); }); // Close when the user clicks anywhere outside the panel document.addEventListener('click', (e) => { if (sess.panelOpen && !panel.contains(e.target) && e.target !== btn) { sess.panelOpen = false; panel.classList.remove('open'); btn.classList.toggle('active', sess.loopOn); } }); } /** Wires the loop on/off pill toggle in the panel header. */ function setupLoopToggle(sess) { const { ui, btn, video } = sess; ui.toggle.addEventListener('click', () => { sess.loopOn = !sess.loopOn; ui.toggle.classList.toggle('on', sess.loopOn); ui.toggleLabel.textContent = sess.loopOn ? 'Loop on' : 'Loop off'; btn.classList.toggle('active', sess.loopOn || sess.panelOpen); // Disable YouTube's native loop so ours takes full control if (sess.loopOn) video.loop = false; }); } /** * Wires the Full-video / A→B mode buttons. * Returns `setMode` so setupPointButtons (Reset) can reuse it. */ function setupModeSelector(sess) { const { ui } = sess; function setMode(mode) { sess.loopMode = mode; const isAB = mode === 'ab'; ui.modeFull.classList.toggle('sel', !isAB); ui.modeAB.classList.toggle('sel', isAB); ui.abSection.classList.toggle('hidden', !isAB); ui.footer.classList.toggle('hidden', !isAB); } ui.modeFull.addEventListener('click', () => setMode('full')); ui.modeAB.addEventListener('click', () => setMode('ab')); return setMode; } /** Wires the Set A, Set B, clear (✕), and Reset buttons. */ function setupPointButtons(sess, setMode) { const { ui, btn } = sess; ui.setABtn.addEventListener('click', () => setPoint(sess, 'a')); ui.setBBtn.addEventListener('click', () => setPoint(sess, 'b')); ui.clrA.addEventListener('click', () => clearPoint(sess, 'a')); ui.clrB.addEventListener('click', () => clearPoint(sess, 'b')); ui.resetBtn.addEventListener('click', () => { clearPoint(sess, 'a'); clearPoint(sess, 'b'); sess.loopOn = false; ui.toggle.classList.remove('on'); ui.toggleLabel.textContent = 'Loop off'; btn.classList.remove('active'); setMode('ab'); }); } /** Wires the mini-timeline: drag thumbs A, B, playhead; click-to-seek on rail. */ function setupTimeline(sess) { const { ui, video } = sess; /** Maps a clientX pixel position to a 0–1 fraction along the rail. */ function toFraction(clientX) { const rect = ui.rail.getBoundingClientRect(); return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); } /** Makes a thumb draggable; calls onDrag(clientX) on every mousemove. */ function makeDraggable(thumb, onDrag) { thumb.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); sess.dragTarget = onDrag; }); } makeDraggable(ui.thA, (x) => { sess.pointA = toFraction(x) * video.duration; ui.valA.textContent = fmt(sess.pointA); ui.ptA.classList.add('set'); ui.thA.classList.add('vis'); sess._lastLoStart = sess._lastLoEnd = -1; // force range redraw }); makeDraggable(ui.thB, (x) => { sess.pointB = toFraction(x) * video.duration; ui.valB.textContent = fmt(sess.pointB); ui.ptB.classList.add('set'); ui.thB.classList.add('vis'); sess._lastLoStart = sess._lastLoEnd = -1; }); makeDraggable(ui.thPlay, (x) => { video.currentTime = toFraction(x) * video.duration; }); document.addEventListener('mousemove', (e) => { if (sess.dragTarget) sess.dragTarget(e.clientX); }); document.addEventListener('mouseup', () => { sess.dragTarget = null; }); // Click directly on the rail to seek (only when not already mid-drag) ui.rail.addEventListener('click', (e) => { if (!sess.dragTarget) video.currentTime = toFraction(e.clientX) * video.duration; }); } /** * Wires keyboard shortcuts A and B. * Ignored while the user is typing in any text input. */ function setupKeyboard(sess) { document.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return; if (e.key === 'a' || e.key === 'A') setPoint(sess, 'a'); if (e.key === 'b' || e.key === 'B') setPoint(sess, 'b'); }); } // ───────────────────────────────────────────────────────────────────────────── // DOM BUILDER // ───────────────────────────────────────────────────────────────────────────── function buildDOM() { const btn = document.createElement('button'); btn.className = 'abl-yt-btn ytp-button'; btn.title = 'A/B Loop'; btn.innerHTML = ICON_SVG + ''; const panel = document.createElement('div'); panel.className = 'abl-panel'; panel.innerHTML = PANEL_HTML; return { btn, panel }; } /** * Queries all interactive panel elements into a single flat object. * Using a helper keeps mountUI() clean and makes the element map explicit. */ function queryUI(panel) { const q = (sel) => panel.querySelector(sel); return { toggle : q('#abl-toggle'), toggleLabel : q('.abl-toggle-label'), modeFull : q('#abl-mode-full'), modeAB : q('#abl-mode-ab'), abSection : q('#abl-ab-section'), footer : q('.abl-panel-footer'), ptA : q('#abl-pt-a'), ptB : q('#abl-pt-b'), valA : q('#abl-val-a'), valB : q('#abl-val-b'), setABtn : q('#abl-set-a'), setBBtn : q('#abl-set-b'), clrA : q('#abl-clr-a'), clrB : q('#abl-clr-b'), resetBtn : q('#abl-reset'), rail : q('#abl-rail'), prog : q('#abl-prog'), range : q('#abl-range'), thA : q('#abl-th-a'), thB : q('#abl-th-b'), thPlay : q('#abl-th-play'), tEnd : q('#abl-t-end'), updateBar : q('#abl-update-bar'), updateVersion: q('#abl-update-version'), }; } // ───────────────────────────────────────────────────────────────────────────── // UPDATE CHECK // Fetches the raw script from GitHub, extracts the @version from the header, // and shows the update banner in the panel if a newer version is available. // Uses GM_xmlhttpRequest to bypass cross-origin restrictions. // ───────────────────────────────────────────────────────────────────────────── const CURRENT_VERSION = '1.0.0'; const RAW_SCRIPT_URL = 'https://raw.githubusercontent.com/Black0S/Youtube-Loop-UserScript/refs/heads/main/youtube-loop.js'; /** * Compares two semver strings (e.g. "1.0.0" vs "1.1.0"). * Returns true if `remote` is strictly newer than `local`. */ function isNewer(local, remote) { const toInt = (v) => v.split('.').map(Number); const [la, lb, lc] = toInt(local); const [ra, rb, rc] = toInt(remote); return ra > la || (ra === la && rb > lb) || (ra === la && rb === lb && rc > lc); } /** * Fetches the remote script, parses the @version line, and reveals the * update banner inside the panel if a newer version is found. * Called once per session, non-blocking. */ function checkForUpdate(ui) { GM_xmlhttpRequest({ method : 'GET', url : RAW_SCRIPT_URL, // Only download the first 512 bytes — the @version is always in the header headers : { Range: 'bytes=0-511' }, onload : (res) => { const match = res.responseText.match(/@version\s+([\d.]+)/); if (!match) return; const remoteVersion = match[1]; if (isNewer(CURRENT_VERSION, remoteVersion)) { ui.updateVersion.textContent = `v${remoteVersion}`; ui.updateBar.classList.add('visible'); } }, onerror : () => { /* silently ignore network errors */ }, }); } // ───────────────────────────────────────────────────────────────────────────── // INJECTION (orchestrates a single page session) // ───────────────────────────────────────────────────────────────────────────── let activeSession = null; // null between page navigations function inject() { if (activeSession) return; const video = document.querySelector('video'); const player = document.querySelector('#movie_player'); const rightControls = player?.querySelector('.ytp-right-controls'); if (!video?.duration || !player || !rightControls) return; // Inject the stylesheet only once — it survives SPA navigations harmlessly if (!document.querySelector('#abl-style')) { const style = document.createElement('style'); style.id = 'abl-style'; style.textContent = CSS; document.head.appendChild(style); } const { btn, panel } = buildDOM(); rightControls.insertBefore(btn, rightControls.firstChild); player.style.position = 'relative'; player.appendChild(panel); const ui = queryUI(panel); const sess = createSession(video, btn, panel, ui); activeSession = sess; // Wire all UI concerns, then start the render loop const setMode = setupModeSelector(sess); setupPanelToggle(sess); setupLoopToggle(sess); setupPointButtons(sess, setMode); setupTimeline(sess); setupKeyboard(sess); startRenderLoop(sess); checkForUpdate(ui); } // ───────────────────────────────────────────────────────────────────────────── // PLAYER-READY POLLING // YouTube loads its player asynchronously; we retry until it's ready. // ───────────────────────────────────────────────────────────────────────────── function tryInject() { const video = document.querySelector('video'); const player = document.querySelector('#movie_player'); const ready = video?.duration > 0 && player?.querySelector('.ytp-right-controls'); if (ready) inject(); else setTimeout(tryInject, RETRY_DELAY_MS); } // ───────────────────────────────────────────────────────────────────────────── // SPA NAVIGATION HANDLER // // YouTube never does full page reloads between videos. // We watch mutations (lightweight — only fires on nav events, // not on every DOM change like observing document.body). // ───────────────────────────────────────────────────────────────────────────── /** Destroys the current session cleanly: stops RAF, removes DOM nodes. */ function teardown() { if (!activeSession) return; cancelAnimationFrame(activeSession.rafId); activeSession.btn.remove(); activeSession.panel.remove(); activeSession = null; } let lastUrl = location.href; new MutationObserver(() => { if (location.href === lastUrl) return; // guard: <title> can change without navigation lastUrl = location.href; teardown(); setTimeout(tryInject, SPA_NAVIGATION_DELAY); }).observe(document.querySelector('title'), { childList: true }); // ───────────────────────────────────────────────────────────────────────────── // ENTRY POINT // ───────────────────────────────────────────────────────────────────────────── tryInject(); })();