bygone-yt

youtube time-machine. pick a date, see videos from that era. filters out all videos made after that date. V3 VORAPIS REQUIRED!

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         bygone-yt
// @namespace    http://tampermonkey.net/
// @license      MIT
// @version      429
// @description  youtube time-machine. pick a date, see videos from that era. filters out all videos made after that date. V3 VORAPIS REQUIRED!
// @author       relicofatime
// @match        https://www.youtube.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_addStyle
// @grant        GM_info
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @connect      youtube.com
// @connect      greatest.deepsurf.us
// @connect      update.greatest.deepsurf.us
// @run-at       document-start
// ==/UserScript==

(function () {
    'use strict';

    const SCRIPT_VERSION = 429;

    // ============================================================
    //  bygone-yt v2 — V3-only rewrite
    //  Modules (in declaration order):
    //    Interceptor (IIFE, document-start)   — patches Response.json/.text;
    //                                            owns videoPool, mapVideo, sweep,
    //                                            sidebar sweep, click hijack.
    //    Config + VERSION
    //    Store                                — GM_* persistence + profiles + clock.
    //    DateHelper                           — relative-text ↔ Date.
    //    InterestModel                        — watch-history scoring.
    //    YouTubeAPI                           — InnerTube client (auth-trick).
    //    FeedEngine                           — merge sources, dedup, weight.
    //    UI                                   — panel + FAB + styles.
    //    App                                  — wire everything.
    // ============================================================

    // ============================================================
    //  INTERCEPTOR (runs at document-start; before V3)
    //  Owns: videoPool, mapVideo, sweep, sidebar sweep, click hijack.
    //  Exposes setVideos / appendVideos / setLazyFetcher / mapVideo / sweep.
    //  References to Store, DateHelper, etc. resolve at CALL time, not
    //  install time — those classes are declared later in this file but
    //  exist before any YouTube response arrives.
    // ============================================================

    let _v3Detected = false;
    function _checkV3() {
        if (_v3Detected) return true;
        // V3/VORAPIS runs in the PAGE context (@grant none). When bygone runs in a
        // userscript-manager SANDBOX (e.g. the Android kiosk), page-context globals
        // live on unsafeWindow, NOT the sandbox `window`, and PeakyTube's mobile
        // layout drops the desktop .lohp-* shelves the old DOM check relied on — so
        // a single hard-coded marker misses V3 entirely. Scan every cross-context
        // signal instead and cache the first positive.
        const mark = () => { _v3Detected = true; return true; };
        const RX = /vlturbo|vorapis|turbopipe/i;
        const de = document.documentElement;
        // 1) documentElement attribute / class markers (DOM = cross-context).
        try {
            if (de.hasAttribute('v3') || de.hasAttribute('vorapis')) return mark();
            for (const a of de.attributes) {
                if (a.name === 'v3' || RX.test(a.name) || RX.test(a.value || '')) return mark();
            }
            if (RX.test(de.className || '')) return mark();
        } catch (_) {}
        // 2) localStorage keys V3 writes (same-origin → visible from any context).
        try {
            for (let i = 0; i < localStorage.length; i++) {
                const k = localStorage.key(i);
                if (k && (RX.test(k) || /^v3[_-]/i.test(k))) return mark();
            }
        } catch (_) {}
        // 3) page-context globals — on unsafeWindow when bygone is sandboxed.
        try {
            const pw = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window;
            for (const w of [pw, window]) {
                if (w && (w.PatcherJSC_TURBOPIPE || w.VLTURBO || w.VORAPIS)) return mark();
            }
        } catch (_) {}
        // 4) DOM fallback for both desktop and mobile (PeakyTube) layouts.
        try {
            if (document.querySelector('.lohp-large-shelf-container, .lohp-medium-shelf, ' +
                'ytm-browse, [class*="vorapis" i], [id*="vorapis" i], [class*="vlturbo" i]')) return mark();
        } catch (_) {}
        return false;
    }

    function _checkStarTube() {
        try {
            // StarTube runs in the PAGE context — when bygone is sandboxed its
            // globals live on unsafeWindow, not the sandbox window (same issue
            // _checkV3 handles for V3's globals).
            const pw = (typeof unsafeWindow !== 'undefined' && unsafeWindow) || window;
            if (pw.globalDataPoints || window.globalDataPoints || document.globalDataPoints) return true;
            if (localStorage.getItem('ST_STABLE_SETTINGS')) return true;
            if (localStorage.getItem('starTubeConfigCreated')) return true;
        } catch (_) {}
        return !!document.querySelector(
            '#startube-settings-window-entity, #startube-settings-window, ' +
            '#st-watch-below, #st-actions-info-row, [id^="st-"], [class^="st-"], [class*=" st-"]'
        );
    }

    function _isKioskOrMobileLayout() {
        // Mobile UA, or the kiosk APK's own marker. Capability heuristics
        // (maxTouchPoints, pointer:coarse, width <= 1280) used to live here
        // and misfired on touchscreen desktops and small desktop windows:
        // the kiosk watch CSS then DELETED the comment section on desktop
        // and its 100vw layout fought V3's watch page (V3 kept re-aligning
        // the player, yanking the user's scroll back up every few seconds).
        // UA alone is NOT enough either: the kiosk runs GeckoView with
        // USER_AGENT_MODE_DESKTOP (no "Android" in the UA), so we also
        // check for the bottom nav that only the APK's kiosk-page.js
        // content script creates. A desktop browser never has it.
        // Latched: V3 periodically rebuilds <body>, briefly destroying the
        // nav marker — without the latch the gate flickers off for a beat
        // and the kiosk attributes get stripped mid-session.
        if (_isKioskOrMobileLayout._latched) return true;
        try {
            const ua = (navigator && navigator.userAgent) || '';
            if (/Android|Mobile|Tablet|iPhone|iPad|iPod/i.test(ua)) {
                _isKioskOrMobileLayout._latched = true;
                return true;
            }
        } catch (_) {}
        try {
            if (document.getElementById('bygone-kiosk-bottom-nav')) {
                _isKioskOrMobileLayout._latched = true;
                return true;
            }
        } catch (_) {}
        return false;
    }

    function _kioskEffectiveWidth() {
        const vals = [];
        const add = (v) => {
            const n = Number(v);
            if (Number.isFinite(n) && n > 0) vals.push(n);
        };
        try { add(window.innerWidth); } catch (_) {}
        try { add(document.documentElement && document.documentElement.clientWidth); } catch (_) {}
        try { add(window.visualViewport && window.visualViewport.width); } catch (_) {}
        try {
            if (window.screen) {
                add(window.screen.width);
                add(window.screen.height);
                add(window.screen.availWidth);
                add(window.screen.availHeight);
            }
        } catch (_) {}
        return vals.length ? Math.min.apply(null, vals) : 0;
    }

    function _kioskVisibleWidth() {
        const vals = [];
        const add = (v) => {
            const n = Number(v);
            if (Number.isFinite(n) && n > 0) vals.push(n);
        };
        try { add(window.visualViewport && window.visualViewport.width); } catch (_) {}
        try { add(document.documentElement && document.documentElement.clientWidth); } catch (_) {}
        try { add(window.innerWidth); } catch (_) {}
        try { if (!vals.length && window.screen) add(window.screen.width); } catch (_) {}
        return vals.length ? Math.min.apply(null, vals) : 0;
    }

    const Interceptor = (() => {
        const POOL_LS_KEY = 'bygone_v3_pool';
        try {
            const lastVersion = (typeof GM_getValue === 'function') ? Number(GM_getValue('bygone_last_version', 0)) : 0;
            if (!Number.isFinite(lastVersion) || lastVersion < SCRIPT_VERSION) {
                localStorage.removeItem(POOL_LS_KEY);
                localStorage.removeItem('wbt_v3_pool');
            }
        } catch (_) {}
        // Migrate old localStorage key
        try {
            const old = localStorage.getItem('wbt_v3_pool');
            if (old && !localStorage.getItem(POOL_LS_KEY)) localStorage.setItem(POOL_LS_KEY, old);
        } catch (_) {}
        let videoPool = [];
        let active = false;
        // User-level pause (panel "Active" toggle, persisted as Store.isActive()).
        // Paused: hide-CSS is disabled (native cards show through instead of
        // blank slots), waitForPool resolves immediately (no 8s stall on every
        // InnerTube response), and the sweeps stand down.
        let _userPaused = false;
        let _poolReadyCbs = [];
        function _onPoolReady(fn) { if (active && videoPool.length) fn(); else _poolReadyCbs.push(fn); }
        function _firePoolReady() { for (const fn of _poolReadyCbs) try { fn(); } catch (_) {} _poolReadyCbs = []; }

        // _idMap (origId → video) gives STABLE mapping across responses.
        // _responseSeen (video.id set) gives DEDUP WITHIN ONE response.
        // _usedReplacements tracks all-time usage (for fresh-pick preference).
        // _poolIdsSet is the set of pool video IDs (fast membership test).
        const _idMap = new Map();
        const _usedReplacements = new Set();
        let _sweepStats = {};   // per-run branch counters, surfaced by __bygoneDiag
        let _lastHomeChannelPromoPruned = 0;
        let _lastHomeChannelPromoLeft = 0;
        let _responseSeen = new Set();
        let _displayedIdsCache = null;
        let _poolIdsSet = new Set();
        const _keptNaturalIds = new Set();
        let _poolCursor = 0;

        function startResponseScope() {
            _responseSeen = new Set();
            _displayedIdsCache = null;
        }

        // Build a set of every videoId currently visible in the page DOM.
        // Cached for the lifetime of one response/sweep scope.
        function _getDisplayedIds() {
            if (_displayedIdsCache) return _displayedIdsCache;
            const ids = new Set();
            document.querySelectorAll('a[href*="/watch"]').forEach(a => {
                const h = a.getAttribute('href') || '';
                const m = h.match(/[?&]v=([A-Za-z0-9_-]+)/);
                if (m) ids.add(m[1]);
            });
            _displayedIdsCache = ids;
            return ids;
        }

        // ---- mapVideo: the heart of the system ---------------------
        //   opts.dedupInResponse:
        //     true  → enforce per-response uniqueness via _responseSeen.
        //   opts.avoidDisplayedOnFreshPick:
        //     true  → fresh picks avoid videos already visible.
        //     (Stable mappings are NEVER rejected for being on-screen.
        //      Returning the same answer for the same origId is what keeps
        //      cards stable across sweep ticks — rejecting it on screen
        //      grounds reintroduces the v189-era once-per-second rotation.)
        function mapVideo(origId, opts) {
            if (!videoPool.length) return null;
            const dedupInResponse = !!(opts && opts.dedupInResponse);
            const avoidDisplayed = !!(opts && opts.avoidDisplayedOnFreshPick);
            const displayed = (dedupInResponse || avoidDisplayed) ? _getDisplayedIds() : null;
            const stableClash = (id) => dedupInResponse && _responseSeen.has(id);
            const freshClash = (id) =>
                (dedupInResponse && _responseSeen.has(id)) ||
                (displayed && displayed.has(id));

            // origId is already a pool video → return as-is.
            if (origId && _poolIdsSet.has(origId)) {
                const found = videoPool.find(p => p.id === origId);
                if (found) {
                    _usedReplacements.add(found.id);
                    if (dedupInResponse) _responseSeen.add(found.id);
                    return found;
                }
            }

            // Stable mapping for this origId — never rejected for on-screen.
            if (origId && _idMap.has(origId)) {
                const stable = _idMap.get(origId);
                if (!stableClash(stable.id)) {
                    _usedReplacements.add(stable.id);
                    if (dedupInResponse) _responseSeen.add(stable.id);
                    return stable;
                }
            }

            // Fresh pick. Avoid videos already mapped to some other origId,
            // otherwise two origIds end up with the same replacement and we
            // see the same video twice in the grid (v203 fix).
            const alreadyMapped = new Set();
            for (const v of _idMap.values()) if (v && v.id) alreadyMapped.add(v.id);

            //   1) not-mapped AND not-clashing
            //   2) not-mapped (may clash)
            //   3) not-clashing (pool saturated — allow remap, avoid on-screen/response dup)
            //   4) anything (fully saturated — round-robin repeat)
            // Tiers 3 & 4 are what stop a modern video leaking through: once
            // every pool video is already mapped to some other slot (pool
            // smaller than the number of card slots — e.g. 109 pool vs 360
            // cards), tiers 1-2 fail. Returning null there left the ORIGINAL
            // present-day video on the card. A repeated era video is always
            // preferable to a modern hole, so never return null while the pool
            // is non-empty.
            let v = _findVideo(c => !alreadyMapped.has(c.id) && !freshClash(c.id));
            if (!v) v = _findVideo(c => !alreadyMapped.has(c.id));
            if (!v) v = _findVideo(c => !freshClash(c.id));
            if (!v) v = _findVideo(() => true);
            if (!v) return null;

            if (origId) _idMap.set(origId, v);
            _usedReplacements.add(v.id);
            if (dedupInResponse) _responseSeen.add(v.id);
            _maybeFetchMore();
            return v;
        }

        function _findVideo(pred) {
            for (let i = 0; i < videoPool.length; i++) {
                const cand = videoPool[(_poolCursor + i) % videoPool.length];
                if (pred(cand)) {
                    _poolCursor = (_poolCursor + i + 1) % videoPool.length;
                    return cand;
                }
            }
            return null;
        }

        // ---- Pool management --------------------------------------

        function hydrateFromLocalStorage() {
            try {
                const raw = localStorage.getItem(POOL_LS_KEY);
                if (!raw) return false;
                const parsed = JSON.parse(raw);
                if (parsed && Array.isArray(parsed.videos) && parsed.videos.length) {
                    videoPool = parsed.videos;
                    _poolIdsSet = new Set(videoPool.map(v => v.id));
                    active = true;
                    _firePoolReady();
                    return true;
                }
            } catch (_) {}
            return false;
        }

        function _persistPool() {
            try {
                localStorage.setItem(POOL_LS_KEY, JSON.stringify({
                    videos: videoPool,
                    savedAt: Date.now(),
                }));
            } catch (_) {}
        }

        const _VALID_VID = /^[A-Za-z0-9_-]{11}$/;

        function _pruneMappingStateForPool() {
            for (const [origId, mapped] of _idMap) {
                if (!mapped || !mapped.id || !_poolIdsSet.has(mapped.id)) _idMap.delete(origId);
            }
            for (const id of Array.from(_usedReplacements)) {
                if (!_poolIdsSet.has(id)) _usedReplacements.delete(id);
            }
            _displayedIdsCache = null;
        }

        function setVideos(videos) {
            if (!Array.isArray(videos) || !videos.length) return 0;
            const _seen = new Set();
            videoPool = videos.filter(v => {
                if (!v || !v.id || !_VALID_VID.test(v.id) || _seen.has(v.id)) return false;
                _seen.add(v.id);
                return true;
            });
            if (!videoPool.length) return 0;
            _poolIdsSet = new Set(videoPool.map(v => v.id));
            _poolCursor = 0;
            _pruneMappingStateForPool();
            if (_keptNaturalIds.size > 500) _keptNaturalIds.clear();
            active = true;
            _persistPool();
            _firePoolReady();
            return videoPool.length;
        }

        function appendVideos(videos) {
            if (!Array.isArray(videos) || !videos.length) return 0;
            let added = 0;
            for (const v of videos) {
                if (!v || !v.id || !_VALID_VID.test(v.id)) continue;
                if (_poolIdsSet.has(v.id)) continue;
                videoPool.push(v);
                _poolIdsSet.add(v.id);
                added++;
            }
            if (added > 0) _persistPool();
            return added;
        }

        function clearPool() {
            videoPool = [];
            active = false;
            _poolIdsSet = new Set();
            _idMap.clear();
            _usedReplacements.clear();
            _responseSeen = new Set();
            _displayedIdsCache = null;
            _keptNaturalIds.clear();
            _poolCursor = 0;
            try { localStorage.removeItem(POOL_LS_KEY); } catch (_) {}
        }

        // Evict specific ids from the pool — used when exact-date enrichment
        // discovers a slack-admitted video was actually published after the
        // set date. Cards already painted with an evicted video keep it until
        // the next build/resweep; eviction only stops further use.
        function removeVideos(ids) {
            if (!Array.isArray(ids) || !ids.length) return 0;
            const drop = new Set(ids);
            const before = videoPool.length;
            videoPool = videoPool.filter(v => !drop.has(v.id));
            const removed = before - videoPool.length;
            if (!removed) return 0;
            _poolIdsSet = new Set(videoPool.map(v => v.id));
            _poolCursor = 0;
            _pruneMappingStateForPool();
            if (!videoPool.length) active = false;
            _persistPool();
            return removed;
        }

        // Repaint already-rendered cards from the CURRENT pool. The per-tick
        // sweep pins a card forever once it carries data-bygone-swept/ok/keep,
        // so after a manual "Reload feed" the fresh pool never reaches cards
        // that were already replaced — the feed looks frozen. This strips those
        // marks, drops the stable id-map + cursor so every slot re-rolls against
        // the new pool, then runs one sweep pass. It is a one-shot, user-driven
        // re-roll, NOT the per-tick loop, so it does not reintroduce rotation.
        function forceResweep() {
            try {
                document.querySelectorAll('[data-bygone-swept],[data-bygone-ok],[data-bygone-keep]')
                    .forEach(el => {
                        el.removeAttribute('data-bygone-swept');
                        el.removeAttribute('data-bygone-ok');
                        el.removeAttribute('data-bygone-keep');
                    });
            } catch (_) {}
            _idMap.clear();
            _usedReplacements.clear();
            _responseSeen = new Set();
            _displayedIdsCache = null;
            _poolCursor = 0;
            try { _sweep(); } catch (_) {}
            try { _sidebarSweep(); } catch (_) {}
        }

        // Lazy infinite-scroll wiring. App.init calls setLazyFetcher with a
        // function that returns the next page of videos. When the pool hits
        // 70% used, _maybeFetchMore fires the fetcher in the background.
        let _lazyFetcher = null;
        let _fetchingMore = false;
        let _morePage = 2;
        function setLazyFetcher(fn) { _lazyFetcher = fn; }

        function _maybeFetchMore() {
            if (_fetchingMore || !_lazyFetcher || !videoPool.length) return;
            if (_usedReplacements.size / videoPool.length < 0.7) return;
            _fetchingMore = true;
            const page = _morePage++;
            Promise.resolve()
                .then(() => _lazyFetcher(page))
                .then(more => { if (more && more.length) appendVideos(more); })
                .catch(() => { _morePage--; })
                .then(() => { _fetchingMore = false; });
        }

        function isInnerTubeUrl(url) {
            return !!url && url.indexOf('/youtubei/v1/') !== -1;
        }

        // ---- Renderer mutation -------------------------------------

        const RENDERER_KEYS = [
            'videoRenderer',
            'gridVideoRenderer',
            'compactVideoRenderer',
            'lockupViewModel',
        ];

        // ---- "Keep naturally-old videos" --------------------------
        // If YouTube's own algorithm surfaces a video that was uploaded
        // at or before the set date, leave it ALONE — YouTube's recs for
        // genuinely old content are good and worth keeping. Only videos
        // newer than the set date get replaced.
        const _REL_RE = /(?:Streamed\s+)*(\d+)\s*(year|month|week|day|hour|minute|second)s?\s+ago/i;
        const _VIDEO_ABS_DATE_RE = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}/i;

        function _relTextAtOrBeforeSet(relText) {
            if (!relText) return false;
            let setDateStr;
            try { setDateStr = Store.getCurrentDate(); } catch { return false; }
            if (!setDateStr) return false;
            const setDate = new Date(setDateStr);
            if (isNaN(setDate.getTime())) return false;
            const approx = DateHelper.approxPublishDate(relText);
            if (!approx) return false;
            // Year-granular text locates the date only to ±1 unit; without
            // slack this gate flips on the real-world month/day (the same
            // calendar cliff as the feed filters) and stops recognizing
            // genuinely-old cards as natural once the date drifts past the
            // anchor's. A straddle card published just after the set date
            // passing as natural is the lesser evil — it was already being
            // kept before the drift.
            return approx.getTime() <= setDate.getTime() + DateHelper.approxSlackMs(relText);
        }

        function _rendererRelText(r) {
            const p = r.publishedTimeText;
            if (!p) return '';
            return p.simpleText || (p.runs && p.runs[0] && p.runs[0].text) || '';
        }

        function _lockupRelText(r) {
            try {
                const rows = r.metadata?.lockupMetadataViewModel?.metadata
                    ?.contentMetadataViewModel?.metadataRows || [];
                for (const row of rows) {
                    for (const part of (row.metadataParts || [])) {
                        const txt = (part && part.text && part.text.content) || '';
                        if (_REL_RE.test(txt)) return txt;
                    }
                }
            } catch {}
            return '';
        }

        // Overwrite the date metadataPart of a lockup in place.
        function _rewriteLockupDate(r, newText) {
            try {
                const rows = r.metadata?.lockupMetadataViewModel?.metadata
                    ?.contentMetadataViewModel?.metadataRows || [];
                for (const row of rows) {
                    for (const part of (row.metadataParts || [])) {
                        const txt = (part && part.text && part.text.content) || '';
                        if (_REL_RE.test(txt)) { part.text.content = newText; return; }
                    }
                }
            } catch {}
        }

        let _replaceCount = 0;
        function replaceRenderer(r) {
            if (!r || typeof r !== 'object') return;
            const origId = r.videoId || '';
            if (!origId) return;
            if (_poolIdsSet.has(origId)) return;
            const _keepRel = _rendererRelText(r);
            if (_relTextAtOrBeforeSet(_keepRel)) {
                _keptNaturalIds.add(origId);
                try {
                    const sd = Store.getCurrentDate();
                    if (sd && r.publishedTimeText) {
                        const nd = DateHelper.recalcForFeed(_keepRel, sd, origId);
                        if (nd) {
                            if (r.publishedTimeText.simpleText !== undefined) r.publishedTimeText.simpleText = nd;
                            else if (r.publishedTimeText.runs) r.publishedTimeText.runs = [{ text: nd }];
                        }
                    }
                } catch (_) {}
                return;
            }
            const v = mapVideo(origId, { dedupInResponse: true, avoidDisplayedOnFreshPick: true });
            if (!v) return;
            const vid = v.id;
            _replaceCount++;

            r.videoId = vid;
            r.title = { simpleText: v.title || '', runs: [{ text: v.title || '' }] };
            r.thumbnail = { thumbnails: [
                { url: 'https://i.ytimg.com/vi/' + vid + '/mqdefault.jpg', width: 320, height: 180 },
            ] };
            if (v.viewCountFormatted || v.viewCount) {
                const viewsText = v.viewCountFormatted || (v.viewCount + ' views');
                r.viewCountText = { simpleText: viewsText };
                r.shortViewCountText = { simpleText: viewsText };
            }
            // Re-relativize date so "11 years ago" doesn't appear for a video
            // that's RECENT relative to the simulated time-machine date.
            let dateStr = v.relativeDate || '';
            try {
                const setDate = Store.getCurrentDate();
                if (setDate && dateStr) {
                    dateStr = DateHelper.recalcForFeed(dateStr, setDate, vid) || dateStr;
                }
            } catch (_) {}
            if (dateStr) r.publishedTimeText = { simpleText: dateStr };

            const dur = v.duration || '';
            r.lengthText = { simpleText: dur, accessibility: { accessibilityData: { label: dur } } };
            r.lengthSeconds = v.lengthSeconds || 0;

            const chan = v.channel || '';
            const cid = v.channelId || '';
            const byline = { runs: [{
                text: chan,
                navigationEndpoint: cid ? {
                    browseEndpoint: { browseId: cid, canonicalBaseUrl: '/channel/' + cid },
                    commandMetadata: { webCommandMetadata: { url: '/channel/' + cid, webPageType: 'WEB_PAGE_TYPE_CHANNEL' } },
                } : undefined,
            }] };
            if (chan) {
                r.shortBylineText = byline;
                r.longBylineText = byline;
                r.ownerText = byline;
            }

            r.navigationEndpoint = {
                watchEndpoint: { videoId: vid },
                commandMetadata: { webCommandMetadata: { url: '/watch?v=' + vid, webPageType: 'WEB_PAGE_TYPE_WATCH' } },
            };

            r.thumbnailOverlays = dur ? [{
                thumbnailOverlayTimeStatusRenderer: {
                    text: { simpleText: dur, accessibility: { accessibilityData: { label: dur } } },
                    style: 'DEFAULT',
                },
            }] : [];

            // Strip fields V3 might use to re-derive originals.
            delete r.descriptionSnippet;
            delete r.detailedMetadataSnippets;
            delete r.richThumbnail;
            delete r.menu;
            delete r.badges;
            delete r.ownerBadges;
        }

        // Modern InnerTube viewModel format (V3 home feed uses this).
        function replaceLockupViewModel(r) {
            if (!r || typeof r !== 'object') return;
            const origId = r.contentId || '';
            if (!origId) return;
            if (_poolIdsSet.has(origId)) return;
            const _keepRel = _lockupRelText(r);
            if (_relTextAtOrBeforeSet(_keepRel)) {
                _keptNaturalIds.add(origId);
                try {
                    const sd = Store.getCurrentDate();
                    if (sd) {
                        const nd = DateHelper.recalcForFeed(_keepRel, sd, origId);
                        if (nd) _rewriteLockupDate(r, nd);
                    }
                } catch (_) {}
                return;
            }
            const v = mapVideo(origId, { dedupInResponse: true, avoidDisplayedOnFreshPick: true });
            if (!v) return;
            const vid = v.id;
            _replaceCount++;

            const thumbUrl = 'https://i.ytimg.com/vi/' + vid + '/mqdefault.jpg';
            r.contentId = vid;
            r.contentType = 'LOCKUP_CONTENT_TYPE_VIDEO';

            if (!r.metadata) r.metadata = {};
            if (!r.metadata.lockupMetadataViewModel) r.metadata.lockupMetadataViewModel = {};
            const meta = r.metadata.lockupMetadataViewModel;
            meta.title = { content: v.title || '', styleRuns: [] };

            let dateStr = v.relativeDate || '';
            try {
                const setDate = Store.getCurrentDate();
                if (setDate && dateStr) dateStr = DateHelper.recalcForFeed(dateStr, setDate, vid) || dateStr;
            } catch (_) {}

            const viewsText = v.viewCountFormatted || (v.viewCount ? v.viewCount + ' views' : '');
            meta.metadata = { contentMetadataViewModel: { metadataRows: [
                { metadataParts: [{
                    text: {
                        content: v.channel || '',
                        commandRuns: v.channelId ? [{
                            startIndex: 0,
                            length: (v.channel || '').length,
                            onTap: { innertubeCommand: {
                                browseEndpoint: { browseId: v.channelId, canonicalBaseUrl: '/channel/' + v.channelId },
                                commandMetadata: { webCommandMetadata: { url: '/channel/' + v.channelId, webPageType: 'WEB_PAGE_TYPE_CHANNEL' } },
                            } },
                        }] : [],
                    },
                }] },
                { metadataParts: [
                    viewsText ? { text: { content: viewsText } } : null,
                    dateStr ? { text: { content: dateStr } } : null,
                ].filter(Boolean) },
            ] } };

            r.contentImage = { thumbnailViewModel: {
                image: { sources: [{ url: thumbUrl, width: 480, height: 360 }] },
                overlays: v.duration ? [{
                    thumbnailOverlayBadgeViewModel: { thumbnailBadges: [{
                        thumbnailBadgeViewModel: { text: v.duration, badgeStyle: 'THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT' },
                    }] },
                }] : [],
            } };

            r.rendererContext = { commandContext: { onTap: { innertubeCommand: {
                watchEndpoint: { videoId: vid },
                commandMetadata: { webCommandMetadata: { url: '/watch?v=' + vid, webPageType: 'WEB_PAGE_TYPE_WATCH' } },
            } } } };
        }

        // Walk a parsed JSON response and replace every renderer in place.
        function walkAndReplace(node, depth) {
            if (!node || depth > 40 || typeof node !== 'object') return;
            if (Array.isArray(node)) {
                for (let i = 0; i < node.length; i++) walkAndReplace(node[i], depth + 1);
                return;
            }
            for (const k in node) {
                if (RENDERER_KEYS.indexOf(k) !== -1) {
                    const r = node[k];
                    if (r) {
                        if (k === 'lockupViewModel') { if (r.contentId) replaceLockupViewModel(r); }
                        else if (r.videoId) replaceRenderer(r);
                    }
                }
                walkAndReplace(node[k], depth + 1);
            }
        }

        // ---- Watch-page video date re-relativization --------------
        // Data-level: rewrites the big "X ago" date under the player so
        // it reads relative to the sim date. COMMENT filtering is NOT
        // done here — comments lazy-load in continuation responses V3
        // re-renders from caches the interceptor never sees, so the
        // comment filter is a DOM sweep instead (see _commentSweep).

        function _relTextOf(obj) {
            if (!obj) return '';
            if (typeof obj === 'string') return obj;
            if (obj.simpleText) return obj.simpleText;
            if (obj.content) return obj.content;
            if (Array.isArray(obj.runs)) return obj.runs.map(r => (r && r.text) || '').join('');
            return '';
        }

        function _rewriteWatchDate(vpir, setDateStr) {
            const rd = vpir.relativeDateText;
            const raw = _relTextOf(rd);
            if (!raw) return;
            const approx = DateHelper.approxPublishDate(raw);
            if (!approx) return;
            const newText = DateHelper.recalcRelative(raw, setDateStr);
            if (rd.simpleText !== undefined) rd.simpleText = newText;
            else if (Array.isArray(rd.runs)) rd.runs = [{ text: newText }];
        }

        function _walkWatchDate(node, depth, setDateStr) {
            if (!node || depth > 45 || typeof node !== 'object') return;
            if (Array.isArray(node)) {
                for (let i = 0; i < node.length; i++) _walkWatchDate(node[i], depth + 1, setDateStr);
                return;
            }
            if (node.videoPrimaryInfoRenderer) {
                try { _rewriteWatchDate(node.videoPrimaryInfoRenderer, setDateStr); } catch (_) {}
            }
            for (const k in node) _walkWatchDate(node[k], depth + 1, setDateStr);
        }

        function _processCommentsAndDates(json) {
            if (!json || typeof json !== 'object') return;
            // Browse responses carry no watch date and are the biggest payloads.
            if (json.contents && json.contents.twoColumnBrowseResultsRenderer) return;
            let setDateStr = null;
            try { setDateStr = Store.getCurrentDate(); } catch (_) {}
            if (!setDateStr) return;
            if (isNaN(new Date(setDateStr).getTime())) return;
            _walkWatchDate(json, 0, setDateStr);
        }

        // ---- Response patching -------------------------------------
        // Patch Response.prototype.json + .text. Patches that fail to
        // produce a usable response MUST NOT reject — that crashes V3's
        // render. Always return either the modified or original body.

        function waitForPool(timeoutMs) {
            if (_userPaused) return Promise.resolve(false);
            if (active && videoPool.length) return Promise.resolve(true);
            return new Promise(resolve => {
                const start = Date.now();
                const tick = () => {
                    if (_userPaused) return resolve(false);
                    if (active && videoPool.length) return resolve(true);
                    if (Date.now() - start > timeoutMs) return resolve(false);
                    setTimeout(tick, 30);
                };
                tick();
            });
        }
        // (moved to top — see _poolReadyCbs near pool declarations)

        function rewriteJsonText(text) {
            if (!active || !videoPool.length || !text || text.length < 20) return text;
            try {
                const json = JSON.parse(text);
                _replaceCount = 0;
                startResponseScope();
                walkAndReplace(json, 0);
                try { _processCommentsAndDates(json); } catch (_) {}
                return JSON.stringify(json);
            } catch (_) { return text; }
        }

        // Search responses must NOT be touched. The search hijack puts
        // `before:YYYY-MM-DD` in the search URL — YouTube itself does the
        // date filter and returns genuine, query-relevant videos from
        // before the set date. If we walkAndReplace those, the user sees
        // OUR pool videos (random replacements) instead of actual search
        // results matching their query. Skip /search; keep /browse and
        // /next (those are the home feed + watch-page sidebar — both
        // need replacement to keep modern videos off the page).
        function _isChannelPage() {
            return /^\/(channel\/|@|c\/|user\/)/.test(location.pathname);
        }

        function _shouldReplace(url) {
            if (!isInnerTubeUrl(url)) return false;
            if (url.indexOf('/youtubei/v1/search') !== -1) return false;
            if (url.indexOf('/youtubei/v1/player') !== -1) return false;
            if (url.indexOf('/youtubei/v1/notification') !== -1) return false;
            if (url.indexOf('/youtubei/v1/reel') !== -1) return false;
            if (_isChannelPage() && url.indexOf('/youtubei/v1/browse') !== -1) return false;
            return true;
        }

        function install() {
            const origRespText = Response.prototype.text;
            try {
                const origRespJson = Response.prototype.json;

                Response.prototype.json = async function () {
                    const url = this.url || '';
                    if (!_shouldReplace(url)) return origRespJson.call(this);
                    const ready = await waitForPool(8000);
                    if (!ready || !active || !videoPool.length) return origRespJson.call(this);
                    // Read body ONCE. If we throw here, V3's render dies — wrap
                    // the mutation in try/catch and always return the parsed body.
                    const json = await origRespJson.call(this);
                    try {
                        _replaceCount = 0;
                        startResponseScope();
                        walkAndReplace(json, 0);
                        try { _processCommentsAndDates(json); } catch (_) {}
                        // /next responses populate the watch-page sidebar; the
                        // initial paint can race ahead of our walk landing in
                        // the DOM, so kick the sidebar sweep a few times.
                        if (url.indexOf('/youtubei/v1/next') !== -1) {
                            setTimeout(() => { try { _sidebarSweep(); } catch (_) {} }, 100);
                            setTimeout(() => { try { _sidebarSweep(); } catch (_) {} }, 500);
                            setTimeout(() => { try { _sidebarSweep(); } catch (_) {} }, 1500);
                        }
                    } catch (_) {}
                    return json;
                };

                Response.prototype.text = async function () {
                    const url = this.url || '';
                    if (!_shouldReplace(url)) return origRespText.call(this);
                    const ready = await waitForPool(8000);
                    if (!ready || !active || !videoPool.length) return origRespText.call(this);
                    const text = await origRespText.call(this);
                    return rewriteJsonText(text);
                };
            } catch (e) { console.warn('[bygone] Response patch failed', e); }

            // Belt-and-suspenders fetch patch: if anything reads the body via
            // a Response we hand back (rather than .json()/.text() on the
            // network Response), this still rewrites it.
            const origFetch = window.fetch ? window.fetch.bind(window) : null;
            if (origFetch) {
                window.fetch = function (input, init) {
                    const url = typeof input === 'string' ? input : (input && input.url) || '';
                    const p = origFetch(input, init);
                    if (!_shouldReplace(url)) return p;
                    return p.then(response => {
                        if (!response || !response.ok) return response;
                        return waitForPool(8000).then(ready => {
                            if (!ready) return response;
                            return origRespText.call(response.clone()).then(text => {
                                const out = rewriteJsonText(text);
                                if (out === text) return response;
                                return new Response(out, {
                                    status: response.status,
                                    statusText: response.statusText,
                                    headers: new Headers(response.headers),
                                });
                            }).catch(() => response);
                        });
                    });
                };
            }
        }

        // Store the original fetch so YouTubeAPI can use it without our patch
        // re-entering (would create a feedback loop where our own InnerTube
        // calls get their videos populated back into our pool).
        let _origFetch = null;
        try {
            _origFetch = window.fetch ? window.fetch.bind(window) : null;
            install();
        } catch (e) { console.warn('[bygone] install failed', e); }
        hydrateFromLocalStorage();

        // ----------------------------------------------------------
        //  HIDE UN-REPLACED CARDS
        // ----------------------------------------------------------
        // Cards default to `visibility: hidden`. The sweep marks each card
        // with `data-bygone-ok="1"` once it confirms the card shows a pool
        // video, and `_rewriteCard` sets `data-bygone-swept="<id>"` on cards
        // it freshly rewrites. CSS shows only those two attribute states.
        //
        // Effect: until our sweep verifies a card OR our interceptor's
        // _rewriteCard touches it, you see empty space rather than a
        // modern (un-replaced) YouTube video.
        function _injectHideCss() {
            try {
                if (document.getElementById('bygone-hide-css')) return;
                const s = document.createElement('style');
                s.id = 'bygone-hide-css';
                s.textContent = `
                    ytd-rich-item-renderer,
                    ytd-grid-video-renderer,
                    ytd-video-renderer,
                    ytd-compact-video-renderer,
                    yt-lockup-view-model,
                    .yt-lockup-view-model,
                    .lohp-large-shelf-container,
                    .lohp-medium-shelf {
                        visibility: hidden !important;
                    }
                    [data-bygone-ok="1"],
                    [data-bygone-swept] {
                        visibility: visible !important;
                    }
                    [data-bygone-ok="1"] .yt-lockup,
                    [data-bygone-swept] .yt-lockup,
                    [data-bygone-ok="1"] .context-data-item.yt-lockup,
                    [data-bygone-swept] .context-data-item.yt-lockup,
                    [data-bygone-ok="1"] .yt-lockup-content,
                    [data-bygone-swept] .yt-lockup-content,
                    [data-bygone-ok="1"] .yt-lockup-title,
                    [data-bygone-swept] .yt-lockup-title,
                    [data-bygone-ok="1"] .yt-lockup-title a,
                    [data-bygone-swept] .yt-lockup-title a,
                    [data-bygone-ok="1"] .lohp-media-object-content,
                    [data-bygone-swept] .lohp-media-object-content,
                    [data-bygone-ok="1"] .lohp-video-link,
                    [data-bygone-swept] .lohp-video-link {
                        visibility: visible !important;
                    }
                    /* Kill the load-time flash of YouTube's logged-in
                       "What to Watch" feed (multirow shelves + shelf-grid).
                       Gated with :has() on the genuine 2013 LOHP grid so it
                       only fires when the LOHP is present — the same condition
                       as the JS removal in _cleanupHomeSpaArtifacts. display:none
                       (not visibility) so no blank space is reserved while the
                       sweep removes them from the DOM. The :has() wrapper rules
                       collapse the whole feed row; the bare-shelf rules are the
                       fallback if the row wrapper class differs. */
                    html:has(.lohp-newspaper-shelf, .lohp-large-shelf-container) .feed-item-container:has(.multirow-shelf),
                    html:has(.lohp-newspaper-shelf, .lohp-large-shelf-container) .feed-item-container:has(.yt-shelf-grid),
                    html:has(.lohp-newspaper-shelf, .lohp-large-shelf-container) .multirow-shelf,
                    html:has(.lohp-newspaper-shelf, .lohp-large-shelf-container) .yt-shelf-grid {
                        display: none !important;
                    }
                    ytd-watch-flexy #primary,
                    ytd-watch-flexy #primary-inner,
                    ytd-watch-flexy #above-the-fold,
                    ytd-watch-flexy #owner,
                    ytd-watch-flexy #meta,
                    ytd-watch-flexy #info,
                    ytd-watch-flexy #info-contents,
                    ytd-watch-flexy #upload-info,
                    ytd-watch-flexy #menu,
                    ytd-watch-flexy #menu-container,
                    ytd-watch-flexy #top-level-buttons-computed,
                    ytd-watch-flexy ytd-watch-metadata,
                    ytd-watch-flexy ytd-video-owner-renderer,
                    #watch7-main-container,
                    #watch7-content,
                    #watch-header,
                    #watch-description,
                    .watch-main-col,
                    .watch-info,
                    .watch-actions,
                    .watch-action-buttons,
                    .watch-extras-section {
                        visibility: visible !important;
                    }
                    [data-bygone-swept] img.bygone-thumb,
                    [data-bygone-ok] img.bygone-thumb {
                        display: inline-block !important;
                        visibility: visible !important;
                        /* Size to the card's own slot, never a fixed 196x110.
                           A fixed width overflowed the 185px grid/shelfslider
                           slots (card pushed offscreen) and the fixed height
                           grew the row (empty space below). width:100% fits the
                           slot and max-width caps it in wide containers. */
                        width: 100% !important;
                        max-width: 196px !important;
                        height: auto !important;
                        aspect-ratio: 16 / 9 !important;
                        object-fit: cover;
                        vertical-align: top;
                        margin-right: 8px;
                        margin-bottom: 4px;
                    }
                    .html5-video-player [data-bygone-player-card-blocked="1"],
                    #movie_player [data-bygone-player-card-blocked="1"],
                    .ytp-autonav-endscreen-countdown-container,
                    .ytp-autonav-endscreen-countdown-overlay {
                        display: none !important;
                    }
                    [data-bygone-comment-hidden] {
                        display: none !important;
                        height: 0 !important;
                        max-height: 0 !important;
                        overflow: hidden !important;
                        margin: 0 !important;
                        padding: 0 !important;
                        border: 0 !important;
                        min-height: 0 !important;
                        visibility: hidden !important;
                    }
                    .bygone-meta {
                        display: block !important;
                        visibility: visible !important;
                        color: #aaa;
                        font-size: 11px;
                        margin-top: 2px;
                    }
                    .bygone-meta .yt-user-name {
                        color: #4e7ab5;
                    }
                    .bygone-meta .view-count,
                    .bygone-meta .content-item-time-created {
                        color: #999;
                    }
                `;
                if (_userPaused) s.media = 'not all';
                (document.head || document.documentElement).appendChild(s);
            } catch (_) {}
        }
        _injectHideCss();
        // Re-inject if V3 strips our <style> element.
        setInterval(() => { if (!document.getElementById('bygone-hide-css')) _injectHideCss(); }, 3000);

        // Toggle the user-level pause. Flipping the hide-CSS off via its media
        // attribute (instead of removing it) keeps the re-inject interval from
        // fighting the toggle.
        function setPaused(p) {
            _userPaused = !!p;
            try {
                const s = document.getElementById('bygone-hide-css');
                if (s) s.media = _userPaused ? 'not all' : 'all';
            } catch (_) {}
        }

        // ============================================================
        //  SWEEP — for cards V3 paints from cached renderer data
        //  (LOHP featured block, show-more continuations, sidebar fragments
        //   V3 reads from its own in-memory cache rather than the response).
        // ============================================================

        const _poolIdSet = () => _poolIdsSet;

        const _HOME_ROOT_SEL = [
            '.lohp-newspaper-shelf',
            '#c3-content-items',
            '#browse-items-primary',
            '#feed',
            '#feed-list',
            '.feed-list',
            '.channels-browse-content-grid',
            '.expanded-shelf-content-list',
            '.yt-shelf-grid',
            '.yt-rich-grid',
            'ytd-browse[page-subtype="home"] #contents',
            'ytd-rich-grid-renderer #contents'
        ].join(',');

        const _WATCH_CHROME_SEL = [
            'ytd-watch-flexy',
            '#watch7-container',
            '#watch7-main-container',
            '#watch7-content',
            '#watch7-sidebar',
            '#watch7-sidebar-contents',
            '#watch7-sidebar-modules',
            '#watch-header',
            '#watch-description',
            '#watch-discussion',
            '.watch-main-col',
            '.watch-sidebar',
            '.watch-card',
            '#above-the-fold',
            '#owner',
            '#meta'
        ].join(',');

        const _STALE_HOME_CARD_SEL = [
            'ytd-rich-item-renderer',
            'ytd-video-renderer',
            'ytd-compact-video-renderer',
            'yt-lockup-view-model',
            '.yt-lockup-view-model',
            '.video-list-item',
            '.feed-item-container .yt-lockup',
            '.context-data-item.yt-lockup',
            '.yt-shelf-grid-item',
            '.yt-uix-shelfslider-item',
            '.expanded-shelf-content-item',
            '.channels-content-item'
        ].join(',');

        const _HOME_CHANNEL_PROMO_SEL = [
            '.channels-content-item',
            '.yt-lockup-channel',
            '.yt-lockup.yt-lockup-channel',
            '.context-data-item.yt-lockup',
            'yt-lockup-view-model',
            'ytd-channel-renderer'
        ].join(',');

        const _CHANNEL_LINK_SEL = [
            'a[href^="/channel/"]',
            'a[href^="/user/"]',
            'a[href^="/c/"]',
            'a[href^="/@"]'
        ].join(',');

        function _isHomeLikePath() {
            const p = location.pathname;
            return p === '/' || p === '' || p === '/feed/trending';
        }

        function _homeRoots(root) {
            const scope = root || document;
            const out = [];
            const add = el => { if (el && out.indexOf(el) === -1) out.push(el); };
            if (scope.matches && scope.matches(_HOME_ROOT_SEL)) add(scope);
            if (scope.querySelectorAll) scope.querySelectorAll(_HOME_ROOT_SEL).forEach(add);
            return out.filter(el => !(el.closest && el.closest(_WATCH_CHROME_SEL)));
        }

        function _insideAnyRoot(el, roots) {
            for (const root of roots || []) {
                if (root === el || (root.contains && root.contains(el))) return true;
            }
            return false;
        }

        function _insideWatchChrome(el) {
            return !!(el && el.closest && el.closest(_WATCH_CHROME_SEL));
        }

        function _cleanupHomeSpaArtifacts() {
            if (_userPaused || !_isHomeLikePath()) return;

            // LOGGED-IN "WHAT TO WATCH" FEED REMOVAL.
            // For a signed-in user, YouTube/V3 render the modern logged-in home
            // feed (`.multirow-shelf` recommendation shelves + `.yt-shelf-grid`
            // + shelfslider lockups) ON TOP of the genuine 2013 LOHP newspaper
            // grid. Those shelves are not part of the time-machine homepage:
            // `.yt-shelf-grid` is a home root, so the sweep fills each lockup
            // with a pool video and reveals it, and they multiply as YouTube
            // lazy-loads more shelves (1 -> 30 -> ...). When the real LOHP grid
            // is present, strip the competing feed shelves wholesale (header and
            // all) BEFORE the sweep runs, so they are never swept/revealed.
            // Guarded so a container holding any LOHP markup is never removed,
            // and runs every sweep tick to catch lazy-loaded shelves.
            const LOHP_SEL = '.lohp-newspaper-shelf, .lohp-large-shelf-container, ' +
                '.lohp-medium-shelf, .lohp-media-object';
            if (document.querySelector(LOHP_SEL)) {
                const kill = new Set();
                // Climb to the OUTERMOST feed-row wrapper so removal takes the
                // whole row — leaving the empty `li.feed-item-container` (which
                // keeps its margin) behind is what produced blank gaps between
                // the LOHP shelves. Stop at the shared feed list and never climb
                // into a wrapper that holds LOHP markup.
                const WRAP_RE = /feed-item|shelf-wrapper|multirow-shelf|yt-shelf-grid|expander/i;
                document.querySelectorAll('.multirow-shelf, .yt-shelf-grid').forEach(shelf => {
                    if (shelf.querySelector(LOHP_SEL)) return;
                    if (shelf.closest('#masthead, #guide, #guide-container, #wbt-panel')) return;
                    let target = shelf;
                    let p = shelf.parentElement;
                    while (p && p !== document.body) {
                        const cls = (p.className && p.className.toString) ? p.className.toString() : '';
                        if (!WRAP_RE.test(cls)) break;
                        if (p.querySelector(LOHP_SEL)) break;
                        target = p;
                        p = p.parentElement;
                    }
                    kill.add(target);
                });
                kill.forEach(el => {
                    try { el.remove(); } catch (_) {
                        try { el.style.setProperty('display', 'none', 'important'); } catch (__) {}
                    }
                });
            }

            const roots = _homeRoots(document);
            let channelPromoPruned = 0;
            let channelPromoLeft = 0;
            document.querySelectorAll(_WATCH_CHROME_SEL).forEach(el => {
                if (_insideAnyRoot(el, roots)) return;
                try { el.remove(); } catch (_) {
                    try { el.style.setProperty('display', 'none', 'important'); } catch (__) {}
                }
            });
            if (roots.length) {
                document.querySelectorAll(_STALE_HOME_CARD_SEL).forEach(el => {
                    if (_insideAnyRoot(el, roots)) return;
                    if (!el.querySelector || !el.querySelector('a[href*="/watch"]')) return;
                    if (el.closest && el.closest('#masthead, #guide, #guide-container, #wbt-panel')) return;
                    try { el.remove(); } catch (_) {
                        try { el.style.setProperty('display', 'none', 'important'); } catch (__) {}
                    }
                });
                document.querySelectorAll(_HOME_CHANNEL_PROMO_SEL).forEach(el => {
                    if (!_insideAnyRoot(el, roots)) return;
                    if (_insideWatchChrome(el)) return;
                    if (el.closest && el.closest('#masthead, #guide, #guide-container, #wbt-panel')) return;
                    if (el.closest && el.closest('[data-bygone-ok], [data-bygone-swept], [data-bygone-keep]')) return;
                    if (!el.querySelector || el.querySelector('a[href*="/watch"]')) return;
                    if (!el.querySelector(_CHANNEL_LINK_SEL)) return;

                    // Home-page channel recommendation/promo modules (for example
                    // brand intro cards) are not video cards, so they cannot be
                    // rewritten into pool videos. Remove them instead of revealing
                    // one native modern recommendation.
                    try { el.remove(); channelPromoPruned++; } catch (_) {
                        try { el.style.setProperty('display', 'none', 'important'); channelPromoPruned++; } catch (__) {}
                    }
                });
                document.querySelectorAll(_HOME_CHANNEL_PROMO_SEL).forEach(el => {
                    if (!_insideAnyRoot(el, roots)) return;
                    if (_insideWatchChrome(el)) return;
                    if (el.closest && el.closest('#masthead, #guide, #guide-container, #wbt-panel')) return;
                    if (el.closest && el.closest('[data-bygone-ok], [data-bygone-swept], [data-bygone-keep]')) return;
                    if (!el.querySelector || el.querySelector('a[href*="/watch"]')) return;
                    if (!el.querySelector(_CHANNEL_LINK_SEL)) return;
                    const cs = getComputedStyle(el);
                    if (cs.display !== 'none' && cs.visibility !== 'hidden') channelPromoLeft++;
                });
            }
            _lastHomeChannelPromoPruned = channelPromoPruned;
            _lastHomeChannelPromoLeft = channelPromoLeft;
        }

        let _homeSpaFixTimer = null;
        function _burstHomeSpaFix() {
            if (_homeSpaFixTimer) { clearInterval(_homeSpaFixTimer); _homeSpaFixTimer = null; }
            let elapsed = 0;
            const run = () => {
                try { _cleanupHomeSpaArtifacts(); } catch (_) {}
                try { _sweep(); } catch (_) {}
            };
            run();
            _homeSpaFixTimer = setInterval(() => {
                run();
                elapsed += 150;
                if (elapsed >= 4500) {
                    clearInterval(_homeSpaFixTimer);
                    _homeSpaFixTimer = null;
                }
            }, 150);
        }

        function _nextDomVideoFor(origId) {
            // dedupInResponse: false — the sweep uses a LOCAL `seenOnPage`
            // Set (built fresh from the live DOM each sweep) for within-
            // pass dedup. avoidDisplayedOnFreshPick uses our displayed
            // cache so the FIRST mapping for any origId picks something
            // not already on screen. Future ticks for the same origId hit
            // the stable path and never touch displayed state.
            return mapVideo(origId, {
                dedupInResponse: false,
                avoidDisplayedOnFreshPick: true,
            });
        }

        function _freshDomVideoAvoiding(avoidIds) {
            return _findVideo(v => v && v.id && !avoidIds.has(v.id));
        }

        function _findCards(root) {
            // Outer shelf containers AND inner cards are BOTH kept (no
            // innermost-only filter). V3's featured-top shelf has the
            // BIG promoted card's img/title/link directly inside
            // `.lohp-large-shelf-container` with no per-card wrapper —
            // dropping the outer would leave the big card unmatched and
            // its assets get partially rewritten by stray sub-element
            // matches (title from one video, thumb from another, click
            // from a third). The sweep handles the nesting by sorting
            // INNERMOST FIRST and scoping each card's rewrite to its
            // OWN subtree (excluding descendants that are themselves
            // matched cards) — so the big card's own assets get one
            // video and the 3 sidekick cards each get their own.
            const sels = [
                'ytd-rich-item-renderer',
                'ytd-video-renderer',
                'ytd-grid-video-renderer',
                'ytd-compact-video-renderer',
                'yt-lockup-view-model',
                '.lohp-large-shelf-container',
                '.lohp-medium-shelf',
                '.lohp-media-object',
                '.video-list-item',
                '.feed-item-container .yt-lockup',
                '.yt-shelf-grid-item',
                '.yt-uix-shelfslider-item',
                '.expanded-shelf-content-item',
                '.channels-content-item',
            ];
            const set = new Set();
            for (const s of sels) root.querySelectorAll(s).forEach(el => set.add(el));
            // Card must have at least one /watch link to be a video card.
            let arr = Array.from(set).filter(c => c.querySelector('a[href*="/watch"]'));
            if (_isHomeLikePath()) {
                const roots = _homeRoots(root);
                if (roots.length) {
                    arr = arr.filter(c => _insideAnyRoot(c, roots) && !_insideWatchChrome(c));
                } else {
                    arr = arr.filter(c =>
                        !_insideWatchChrome(c) &&
                        !(c.closest && c.closest('#masthead, #guide, #guide-container, #wbt-panel'))
                    );
                }
            }

            // THUMB-ONLY INNER MERGE. V3's big featured card has its
            // thumbnail wrapped in its own `.lohp-media-object` (re-used
            // per-card class) separate from the title/click area. That
            // inner wrapper has NO body text — just the thumb img.
            // Normal sidekick cards have title + channel + views + date
            // text (20+ chars). Dropping ONLY thumb-only inners lets the
            // outer's scoped rewrite reach the big card's thumb without
            // disturbing normal shelves where every inner is a real
            // self-contained card.
            arr = arr.filter(c => {
                const cText = (c.textContent || '').trim();
                if (cText.length >= 20) return true;
                for (const outer of arr) {
                    if (outer === c || !outer.contains(c)) continue;
                    return false;
                }
                return true;
            });

            // INNERMOST FIRST: descendant cards sort before their ancestors.
            // The sweep marks each card it processes with `data-bygone-swept`,
            // and uses scoped-link/scoped-rewrite helpers so outer cards
            // only touch elements not inside any inner card.
            arr.sort((a, b) => a.contains(b) ? 1 : b.contains(a) ? -1 : 0);
            return arr;
        }

        // Inner cards (matched cards that this card contains). Used by the
        // sweep to scope an outer card's operations to its OWN subtree.
        function _innerCardsOf(card, allCards) {
            if (!allCards) return [];
            const out = [];
            for (const c of allCards) if (c !== card && card.contains(c)) out.push(c);
            return out;
        }

        // Owned: not inside any inner card.
        function _ownedBy(el, innerCards) {
            for (const inner of innerCards) if (inner.contains(el)) return false;
            return true;
        }

        function _primaryWatchLink(card, innerCards) {
            const inner = innerCards || [];
            const links = Array.from(card.querySelectorAll('a[href*="/watch"]'))
                .filter(a => _ownedBy(a, inner));
            for (const a of links) if (a.querySelector('img')) return a;
            return links[0] || null;
        }

        // Debug logger. AUTO-logs a BYGONE-DIAG line every 3s for ~90s after
        // load (so the transient "blank for ~30s then fills in" is captured
        // without any console call — avoids the Tampermonkey sandbox boundary).
        // Also exposes __bygoneDiag() / __bygoneDiag('watch') on the page window
        // for manual checks. Reports matched cards HIDDEN (blank holes) vs
        // visible, how many hidden ones still carry a valid /watch id, and the
        // live pool/usage — distinguishing pool/mapping exhaustion (HIDDEN high,
        // POOL < cards) from a thumbnail-only problem (visNoImg high).
        const _diagT0 = Date.now();
        function _bygoneDiagRun() {
            try {
                const cards = _findCards(document);
                const idOf = function (el) {
                    const inner = _innerCardsOf(el, cards);
                    const a = _primaryWatchLink(el, inner) || el.querySelector('a[href*="/watch?v="]');
                    const m = a && (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]{11})/);
                    return m ? m[1] : null;
                };
                let vis = 0, hid = 0, hidId = 0, noImg = 0;
                const allIds = new Set(), visIds = new Set(), hiddenIds = [];
                const visCounts = {};
                const samp = [];
                for (const c of cards) {
                    const id = idOf(c);
                    if (id) allIds.add(id);
                    if (getComputedStyle(c).visibility === 'hidden') {
                        hid++;
                        if (id) { hidId++; hiddenIds.push(id); }
                        if (samp.length < 6) samp.push((c.className || '').toString().trim().slice(0, 36));
                    } else {
                        vis++;
                        if (id) visIds.add(id);
                        if (id) visCounts[id] = (visCounts[id] || 0) + 1;
                        const hasImg = Array.from(c.querySelectorAll('img')).some(function (im) {
                            return (im.getAttribute('src') || '').length > 10 && im.getBoundingClientRect().width > 10;
                        });
                        if (!hasImg) noImg++;
                    }
                }
                let dupHidden = 0;
                let visDupes = 0, visDupeGroups = 0;
                for (const count of Object.values(visCounts)) {
                    if (count > 1) {
                        visDupeGroups++;
                        visDupes += count - 1;
                    }
                }
                const distinctHidden = new Set();
                for (const id of hiddenIds) { distinctHidden.add(id); if (visIds.has(id)) dupHidden++; }
                const msg = 'BYGONE-DIAG t=' + Math.round((Date.now() - _diagT0) / 1000) + 's' +
                    ' cards=' + cards.length + ' vis=' + vis + ' HIDDEN=' + hid +
                    ' hiddenWithId=' + hidId + ' visNoImg=' + noImg +
                    ' visDupes=' + visDupes + ' visDupeGroups=' + visDupeGroups +
                    ' chanPromoPruned=' + _lastHomeChannelPromoPruned +
                    ' chanPromoLeft=' + _lastHomeChannelPromoLeft +
                    ' POOL=' + videoPool.length + ' used=' + _usedReplacements.size +
                    ' idMap=' + _idMap.size + ' active=' + active;
                const msg2 = '  distinctAll=' + allIds.size + ' distinctVisible=' + visIds.size +
                    ' distinctHidden=' + distinctHidden.size + ' dupHidden=' + dupHidden +
                    ' (hidden cards whose id is ALSO on a visible card)';
                console.log(msg);
                console.log(msg2);
                console.log('  sweepBranches: ' + JSON.stringify(_sweepStats));
                if (samp.length) console.log('  hidden: ' + samp.join(' / '));
                return msg;
            } catch (e) { console.log('BYGONE-DIAG err ' + e.message); return 'err: ' + e.message; }
        }
        function _bygoneDiag(mode) {
            if (mode === 'watch') {
                let n = 0;
                const id = setInterval(function () { _bygoneDiagRun(); if (++n >= 20) clearInterval(id); }, 3000);
                return 'BYGONE-DIAG watching for 60s…';
            }
            return _bygoneDiagRun();
        }
        try { window.__bygoneDiag = _bygoneDiag; } catch (_) {}
        try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow) unsafeWindow.__bygoneDiag = _bygoneDiag; } catch (_) {}

        // Read the "X ago" relative-date text shown on a DOM card.
        function _cardRelText(card) {
            const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT, null);
            let node;
            while ((node = walker.nextNode())) {
                const m = (node.nodeValue || '').match(_REL_RE);
                if (m) return m[0];
            }
            return '';
        }

        function _cardIsNaturallyOld(card, innerCards) {
            const src = card.getAttribute && card.getAttribute('data-bygone-date-source');
            if (src && _relTextAtOrBeforeSet(src)) return true;
            // Use ownership-aware lookup so we read THIS card's date,
            // not a rewritten date from an already-swept inner card.
            const info = _cardDateTextInfo(card, innerCards || []);
            if (info && info.kind === 'relative' && _relTextAtOrBeforeSet(info.raw)) return true;
            if (info && info.kind === 'absolute') {
                let setDateStr;
                try { setDateStr = Store.getCurrentDate(); } catch { return false; }
                if (setDateStr) {
                    const iso = DateHelper.parseExactDateText(info.raw);
                    if (iso && new Date(iso).getTime() <= new Date(setDateStr).getTime()) return true;
                }
            }
            // Fallback for cards where _cardDateTextInfo found nothing
            // (e.g. no inner cards to exclude — flat card structure).
            if (!info && _relTextAtOrBeforeSet(_cardRelText(card))) return true;
            return false;
        }

        function _cardVideoId(card, innerCards) {
            const a = _primaryWatchLink(card, innerCards);
            if (!a) return '';
            const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
            return m ? m[1] : '';
        }

        function _cardDateTextInfo(card, innerCards) {
            if (!card) return null;
            const owns = (el) => _ownedBy(el, innerCards || []);
            const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT, {
                acceptNode: (node) => owns(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
            });
            let node;
            while ((node = walker.nextNode())) {
                const v = node.nodeValue || '';
                let m = v.match(_REL_RE);
                if (m) return { node, raw: m[0], full: v, kind: 'relative' };
                m = v.match(_VIDEO_ABS_DATE_RE);
                if (m) return { node, raw: m[0], full: v, kind: 'absolute' };
            }
            return null;
        }

        function _displayDateForVideo(video, fallbackText, videoId) {
            let setDate;
            try { setDate = Store.getCurrentDate(); } catch { return fallbackText || ''; }
            if (!setDate) return fallbackText || '';
            const id = videoId || (video && video.id) || '';
            const base = (video && video.relativeDate) || fallbackText || '';
            try {
                if (_VIDEO_ABS_DATE_RE.test(base)) {
                    const iso = DateHelper.parseExactDateText(base);
                    if (iso) return DateHelper.relativeToDate(new Date(iso), setDate, id);
                }
                const out = DateHelper.recalcForFeed(base, setDate, id);
                return out || base || '';
            } catch (_) {
                return base || '';
            }
        }

        function _writeCardDate(card, video, innerCards, fallbackText) {
            const id = (video && video.id) || _cardVideoId(card, innerCards);
            const info = _cardDateTextInfo(card, innerCards);
            const raw = (video && video.relativeDate) || fallbackText || (info && info.raw) || '';
            const display = _displayDateForVideo(video, raw, id);
            if (!display) return false;
            try {
                if (raw) card.setAttribute('data-bygone-date-source', raw);
                card.setAttribute('data-bygone-date-display', display);
            } catch (_) {}
            if (!info || !info.node) return false;
            const full = info.full || info.node.nodeValue || '';
            const next = full.indexOf(info.raw) !== -1 ? full.replace(info.raw, display) : display;
            if (info.node.nodeValue !== next) info.node.nodeValue = next;
            return true;
        }

        // Re-relativize a kept card's "X ago" date to the set date so the
        // feed stays date-consistent. Called ONCE when the card is first
        // marked `data-bygone-keep` — after that the displayed date reads
        // as "modern", so the sweep must skip-guard kept cards (otherwise
        // they'd be re-evaluated as new and erroneously replaced).
        // The actual one-shot guard is `data-bygone-redated`; if a kept
        // card had no date text yet, later sweeps may try again.
        function _redateKeptCard(card, vid, innerCards) {
            if (!card || !card.getAttribute) return false;
            if (card.getAttribute('data-bygone-redated') === '1') return true;
            const inner = innerCards || [];
            const info = _cardDateTextInfo(card, inner);
            const stored = card.getAttribute('data-bygone-date-source') || '';
            const source = stored || (info && info.raw) || '';
            if (!source) return false;
            if (!stored && !_relTextAtOrBeforeSet(source) && !_VIDEO_ABS_DATE_RE.test(source)) {
                card.setAttribute('data-bygone-redated', '1');
                return true;
            }
            const ok = _writeCardDate(card, { id: vid, relativeDate: source }, inner, source);
            if (ok) card.setAttribute('data-bygone-redated', '1');
            return ok;
        }

        // Idempotent channel-name update. Hard guard against writing into
        // title elements (the v189 mistake where titles became channel
        // names). Writes only when value differs — calling on a correct
        // card produces zero mutations, so it cannot cause a sweep loop.
        function _setCardChannel(card, video, innerCards) {
            if (!card || !video || !video.channel) return;
            const want = video.channel;
            const href = video.channelId ? '/channel/' + video.channelId : null;
            const inner = innerCards || [];
            const owns = (el) => _ownedBy(el, inner);
            const ownedFirst = (sel) => {
                for (const e of card.querySelectorAll(sel)) if (owns(e)) return e;
                return null;
            };

            // 1. Real channel anchors. Skip those wrapping an <img> (avatar).
            let hitAnchor = false;
            card.querySelectorAll(
                'a[href^="/channel/"], a[href^="/user/"], a[href^="/c/"], a[href^="/@"]'
            ).forEach(link => {
                if (!owns(link)) return;
                if (link.querySelector('img')) return;
                if ((link.textContent || '').trim() !== want) link.textContent = want;
                if (href && link.getAttribute('href') !== href) link.setAttribute('href', href);
                hitAnchor = true;
            });
            if (hitAnchor) return;

            // 2. V3's 2013 sidebar/card markup: `.attribution > .g-hovercard`.
            //    The video title is a SEPARATE `.title` element.
            let el = ownedFirst('.attribution .g-hovercard')
                || ownedFirst('.attribution .yt-user-name')
                || ownedFirst('.attribution');
            if (!el) {
                el = ownedFirst(
                    '.chan-name, .yt-user-name, .video-user-name, ' +
                    '#channel-name, .ytd-channel-name'
                );
            }
            if (!el) return;
            // Hard guard: never write into a title element.
            if (el.matches && el.matches('[class*="title"], #video-title, h3, h3 *')) return;

            const cur = el.textContent || '';
            const byMatch = cur.match(/^(\s*by\s+)/i);
            const desired = (byMatch ? byMatch[1] : '') + want;
            if (cur.trim() !== desired.trim()) el.textContent = desired;
        }

        function _displayThumbUrl(video, videoId) {
            const id = videoId || (video && video.id) || '';
            const fallback = id ? ('https://i.ytimg.com/vi/' + id + '/mqdefault.jpg') : '';
            const raw = (video && video.thumbnail) || fallback;
            if (!raw) return fallback;
            return String(raw).replace(/\/(default|hqdefault|sddefault)\.(jpg|webp)(\?.*)?$/i, '/mqdefault.$2$3');
        }

        function _rewriteCard(card, video, innerCards) {
            const vid = video.id;
            const watchUrl = '/watch?v=' + vid;
            const thumbUrl = _displayThumbUrl(video, vid);
            const inner = innerCards || [];
            const owns = (el) => _ownedBy(el, inner);
            // querySelectorAll filtered to this card's OWN subtree.
            const ownedQS = (sel) =>
                Array.from(card.querySelectorAll(sel)).filter(owns);

            // Rewrite all /watch hrefs in this card's own subtree.
            ownedQS('a').forEach(a => {
                const href = a.getAttribute('href') || '';
                if (href.includes('/watch')) a.setAttribute('href', watchUrl);
            });

            // Primary thumbnail. V3 sidebar cards have multiple imgs (avatar,
            // badges) — target the one INSIDE the primary watch link first.
            const primaryLink = _primaryWatchLink(card, inner);
            const targetImgs = new Set();
            if (primaryLink) primaryLink.querySelectorAll('img').forEach(im => {
                if (owns(im)) targetImgs.add(im);
            });
            ownedQS('img').forEach(im => {
                const cls = (im.className || '') + ' ' + (im.parentElement && im.parentElement.className || '');
                if (/thumb|preview|video/i.test(cls)) targetImgs.add(im);
            });
            if (!targetImgs.size) {
                const fi = ownedQS('img')[0];
                if (fi) targetImgs.add(fi);
            }
            if (!targetImgs.size) {
                const thumbImg = document.createElement('img');
                thumbImg.src = thumbUrl;
                thumbImg.alt = video.title || '';
                thumbImg.className = 'bygone-thumb';
                const link = ownedQS('a[href*="/watch"]')[0];
                if (link) link.insertBefore(thumbImg, link.firstChild);
                else card.insertBefore(thumbImg, card.firstChild);
            } else {
                targetImgs.forEach(img => {
                    img.setAttribute('src', thumbUrl);
                    img.removeAttribute('srcset');
                    if (img.hasAttribute('data-src')) img.setAttribute('data-src', thumbUrl);
                    if (img.hasAttribute('data-thumb')) img.setAttribute('data-thumb', thumbUrl);
                    img.alt = video.title || '';
                    img.style.visibility = 'visible';
                    if (img.style.display === 'none') img.style.display = '';
                });
            }
            ownedQS('source').forEach(s => {
                if (s.hasAttribute('srcset')) s.setAttribute('srcset', thumbUrl);
                if (s.hasAttribute('src')) s.setAttribute('src', thumbUrl);
            });
            ownedQS('[style*="ytimg"], [style*="background-image"]').forEach(el => {
                try { el.style.backgroundImage = 'url(' + thumbUrl + ')'; } catch (_) {}
            });
            ownedQS('[data-thumb], [data-thumbnail], [data-thumb-url]').forEach(el => {
                if (el.hasAttribute('data-thumb')) el.setAttribute('data-thumb', thumbUrl);
                if (el.hasAttribute('data-thumbnail')) el.setAttribute('data-thumbnail', thumbUrl);
                if (el.hasAttribute('data-thumb-url')) el.setAttribute('data-thumb-url', thumbUrl);
            });

            // Title: only use existing StarTube/YouTube title nodes. Do not
            // manufacture title markup; that breaks V3's layouts.
            const titleSels = [
                '.yt-lockup-title a[href*="/watch"]',
                'h3.yt-lockup-title a[href*="/watch"]',
                'a.yt-uix-tile-link[href*="/watch"]',
                'a.yt-uix-sessionlink[href*="/watch"]',
                '.lohp-video-link[href*="/watch"]',
                '#video-title-link',
                'a#video-title',
                'span#video-title',
                '#video-title',
                'a[href*="/watch"] .yt-ui-ellipsis-wrapper',
                'a[href*="/watch"] .title',
                'a.related-video span.title',
                '.related-video .title',
                'span.title',
                '.title',
            ];
            let titleEl = null;
            for (const sel of titleSels) {
                const candidates = ownedQS(sel).filter(e => {
                    if (!e || (e.querySelector && e.querySelector('img'))) return false;
                    if (e.closest && e.closest('.yt-lockup-meta, .lohp-video-metadata, .attribution')) return false;
                    const a = e.matches && e.matches('a') ? e : (e.closest && e.closest('a'));
                    return !a || ((a.getAttribute('href') || '').indexOf('/watch') !== -1);
                });
                titleEl = candidates.find(e => (e.textContent || '').trim()) || candidates[0] || null;
                if (titleEl) break;
            }
            if (titleEl && video.title) {
                titleEl.textContent = video.title;
                if (titleEl.hasAttribute('title')) titleEl.setAttribute('title', video.title);
                const titleAnchor = titleEl.matches && titleEl.matches('a') ? titleEl : (titleEl.closest && titleEl.closest('a'));
                if (titleAnchor) {
                    titleAnchor.setAttribute('href', watchUrl);
                    titleAnchor.setAttribute('title', video.title);
                }
            }

            _setCardChannel(card, video, inner);

            // Views + date via text-node regex (works across V3/modern markup).
            // Skip text nodes that live inside an inner matched card — those
            // belong to that card, not this one.
            const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT, {
                acceptNode: (node) => owns(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
            });
            let node, viewsNode = null;
            while ((node = walker.nextNode())) {
                const v = node.nodeValue || '';
                if (!viewsNode && /\b\d[\d,.]*\s*[KkMmBb]?\s*views?\b/.test(v)) viewsNode = node;
            }
            if (viewsNode && (video.viewCountFormatted || video.viewCount)) {
                viewsNode.nodeValue = video.viewCountFormatted || (video.viewCount + ' views');
            }
            _writeCardDate(card, video, inner, video.relativeDate || '');

            const durEl = ownedQS('.video-time, .ytd-thumbnail-overlay-time-status-renderer, .badge-shape-wiz__text')[0];
            if (durEl) durEl.textContent = video.duration || '';

            card.setAttribute('data-bygone-swept', vid);
        }

        // Re-relativize the "X ago" date text node on a card that already
        // shows a pool video. Used after exact dates arrive so the displayed
        // date corrects itself in place (no rebuild / reshuffle). Idempotent:
        // recalcForFeed is deterministic, so it only writes when the value
        // actually changes.
        function _refreshCardDate(card, video, inner) {
            if (!card || !video || !video.id) return;
            const setDate = Store.getCurrentDate();
            if (!setDate) return;
            const stored = card.getAttribute && card.getAttribute('data-bygone-date-source');
            _writeCardDate(card, video, inner, stored || video.relativeDate || '');
        }

        // Walk every on-screen card and re-relativize its date. Called when a
        // batch of exact dates has just been cached.
        function refreshAllDates() {
            try {
                if (!Store.getCurrentDate()) return;
                const cards = _findCards(document);
                for (const card of cards) {
                    const inner = _innerCardsOf(card, cards);
                    const vid = _cardVideoId(card, inner);
                    if (!vid) continue;
                    const video = videoPool.find(v => v.id === vid) || { id: vid };
                    _refreshCardDate(card, video, inner);
                }
            } catch (_) {}
        }

        function _imgHasAnySource(img) {
            if (!img) return false;
            if ((img.currentSrc || '').length > 10) return true;
            const attrs = ['src', 'srcset', 'data-src', 'data-thumb', 'data-thumbnail', 'data-thumb-url'];
            for (const attr of attrs) {
                if ((img.getAttribute(attr) || '').length > 10) return true;
            }
            return false;
        }

        function _cleanupBygoneThumbs(card, inner) {
            const owns = (el) => _ownedBy(el, inner || []);
            const thumbs = Array.from(card.querySelectorAll('img.bygone-thumb')).filter(owns);
            if (!thumbs.length) return false;
            const nativeImgs = Array.from(card.querySelectorAll('img'))
                .filter(img => owns(img) && !img.classList.contains('bygone-thumb'));
            if (!nativeImgs.some(_imgHasAnySource)) return false;
            for (const thumb of thumbs) {
                try { thumb.remove(); } catch (_) {}
            }
            return true;
        }

        function _hasNativeThumbScaffold(card, inner) {
            const owns = (el) => _ownedBy(el, inner || []);
            const primary = _primaryWatchLink(card, inner || []);
            const imgRoot = primary || card;
            const imgs = Array.from(imgRoot.querySelectorAll('img'))
                .filter(img => owns(img) && !img.classList.contains('bygone-thumb'));
            if (imgs.length) return true;
            const scaffolds = Array.from(card.querySelectorAll(
                'picture, source, [data-thumb], [data-thumbnail], [data-thumb-url], ' +
                '[style*="ytimg"], [style*="background-image"], .yt-thumb, .video-thumb, ' +
                '.thumb, .thumbnail, ytd-thumbnail'
            )).filter(owns);
            return scaffolds.length > 0;
        }

        function _fillExistingThumbImg(card, videoId, inner) {
            const owns = (el) => _ownedBy(el, inner || []);
            const primary = _primaryWatchLink(card, inner || []);
            const imgRoot = primary || card;
            const imgs = Array.from(imgRoot.querySelectorAll('img'))
                .filter(img => owns(img) && !img.classList.contains('bygone-thumb'));
            if (!imgs.length) return false;
            if (imgs.some(_imgHasAnySource)) return true;
            const target = imgs.find(img => {
                const cls = ((img.className || '') + ' ' +
                    ((img.parentElement && img.parentElement.className) || '')).toString();
                return /thumb|preview|video/i.test(cls);
            }) || imgs[0];
            const thumbUrl = _displayThumbUrl(null, videoId);
            try {
                target.setAttribute('src', thumbUrl);
                target.removeAttribute('srcset');
                if (target.hasAttribute('data-src')) target.setAttribute('data-src', thumbUrl);
                if (target.hasAttribute('data-thumb')) target.setAttribute('data-thumb', thumbUrl);
                if (target.hasAttribute('data-thumbnail')) target.setAttribute('data-thumbnail', thumbUrl);
                if (target.hasAttribute('data-thumb-url')) target.setAttribute('data-thumb-url', thumbUrl);
                target.style.visibility = 'visible';
                if (target.style.display === 'none') target.style.display = '';
                return true;
            } catch (_) {
                return false;
            }
        }

        function _ensureThumbOnCard(card, videoId, inner) {
            if (_cleanupBygoneThumbs(card, inner)) return;
            const owns = (el) => _ownedBy(el, inner || []);
            if (Array.from(card.querySelectorAll('img.bygone-thumb')).some(owns)) return;
            if (_fillExistingThumbImg(card, videoId, inner)) return;
            if (_hasNativeThumbScaffold(card, inner)) return;
            const thumbUrl = _displayThumbUrl(null, videoId);
            const thumbImg = document.createElement('img');
            thumbImg.src = thumbUrl;
            thumbImg.className = 'bygone-thumb';
            const link = Array.from(card.querySelectorAll('a[href*="/watch"]')).filter(owns)[0];
            if (link) link.insertBefore(thumbImg, link.firstChild);
            else card.insertBefore(thumbImg, card.firstChild);
        }

        function _ensureMetadata(card, videoId, inner) {
            const owns = (el) => _ownedBy(el, inner || []);
            const contentArea = Array.from(card.querySelectorAll(
                '.lohp-media-object-content, .yt-lockup-content'
            )).filter(owns)[0];
            if (!contentArea) return;
            const seenGeneratedMeta = new Set();
            Array.from(contentArea.querySelectorAll('.bygone-meta')).filter(owns).forEach(el => {
                const sig = (el.textContent || '').replace(/\s+/g, ' ').trim().toLowerCase();
                if (!sig || seenGeneratedMeta.has(sig)) {
                    try { el.remove(); } catch (_) {}
                    return;
                }
                seenGeneratedMeta.add(sig);
            });
            const hasChannel = contentArea.querySelector('.yt-user-name, a[href*="/channel/"]');
            let hasViews = false;
            for (const s of contentArea.querySelectorAll('span, li')) {
                if (/\d[\d,.]*\s*(views?|[KkMmBb]\s*views?)/i.test(s.textContent || '')) { hasViews = true; break; }
            }
            const hasDate = !!_cardDateTextInfo(card, inner || []);
            if (card.getAttribute('data-bygone-meta') === '1' && hasChannel && hasViews && hasDate) return;
            if (hasChannel && hasViews && hasDate) { card.setAttribute('data-bygone-meta', '1'); return; }
            const video = videoPool.find(v => v.id === videoId);
            if (!video) return;
            if (!hasChannel && video.channel) {
                const d = document.createElement('div');
                d.className = 'lohp-video-metadata bygone-meta';
                const chanHref = video.channelId ? '/channel/' + video.channelId : '#';
                d.innerHTML = '<span class="run run-text ">by </span>' +
                    '<a class="yt-user-name" href="' + chanHref + '">' +
                    video.channel.replace(/</g, '&lt;') + '</a>';
                contentArea.appendChild(d);
            }
            const viewsText = video.viewCountFormatted || ((video.viewCount || '0') + ' views');
            if (!hasViews || !hasDate) {
                const d = document.createElement('div');
                d.className = 'lohp-video-metadata bygone-meta';
                const dateStr = _displayDateForVideo(video, video.relativeDate || '', videoId);
                const parts = [];
                if (!hasViews) parts.push('<span class="view-count">' + viewsText.replace(/</g, '&lt;') + '</span>');
                if (!hasDate && dateStr) parts.push('<span class="content-item-time-created">' + dateStr.replace(/</g, '&lt;') + '</span>');
                d.innerHTML = '<span>' + parts.join(' ') + '</span>';
                contentArea.appendChild(d);
                if (dateStr) {
                    try {
                        card.setAttribute('data-bygone-date-source', video.relativeDate || dateStr);
                        card.setAttribute('data-bygone-date-display', dateStr);
                    } catch (_) {}
                }
            }
            card.setAttribute('data-bygone-meta', '1');
        }

        // ---- Grid sweep --------------------------------------------
        // RULE: only rewrite a card that currently shows a non-pool video.
        // A card already showing one of our videos is revealed and never
        // re-rolled. If V3 creates more slots than the pool can uniquely
        // populate in this render wave, repeated era videos are acceptable;
        // blank slots and rotation are not.

        function _hasRevealedInnerCard(innerCards) {
            for (const inner of innerCards || []) {
                if (!inner || !inner.getAttribute) continue;
                if (inner.getAttribute('data-bygone-ok') === '1') return true;
                if (inner.getAttribute('data-bygone-swept')) return true;
                if (inner.getAttribute('data-bygone-keep')) return true;
            }
            return false;
        }

        function _tryRewriteDuplicatePoolCard(card, currentVid, inner, seenOnPage, keptPoolThisSweep, st) {
            if (!currentVid || !keptPoolThisSweep.has(currentVid)) return false;
            const alt = _freshDomVideoAvoiding(seenOnPage);
            if (!alt) {
                if (st) st.dupSkip++;
                return false;
            }
            try {
                _rewriteCard(card, alt, inner);
                seenOnPage.add(alt.id);
                keptPoolThisSweep.add(alt.id);
                if (st) {
                    st.dupReuse++;
                    st.dupFixed++;
                    st.rewritten++;
                }
                return true;
            } catch (_) {
                if (st) st.rewriteThrew++;
                return false;
            }
        }

        function _searchSweep() {
            if (!active || _userPaused) return;
            const _p = location.pathname;
            if (_p !== '/results' && _p !== '/results/') return;
            const cards = _findCards(document);
            for (const card of cards) {
                if (card.getAttribute && card.getAttribute('data-bygone-search-dated')) continue;
                const inner = _innerCardsOf(card, cards);
                const vid = _cardVideoId(card, inner);
                if (!vid) continue;
                card.setAttribute('data-bygone-ok', '1');
                const stored = card.getAttribute('data-bygone-date-source');
                if (_writeCardDate(card, { id: vid }, inner, stored || '')) {
                    card.setAttribute('data-bygone-search-dated', '1');
                }
            }
        }

        function _sweep() {
            if (!active || _userPaused || !videoPool.length) return;
            // Search-results page: real query results from YouTube (already
            // date-bounded via `before:` in the URL). Sweeping would replace
            // those genuine matches with pool videos. Leave them alone.
            const _p = location.pathname;
            if (_p === '/results' || _p === '/results/' || _isChannelPage()) return;
            if (_isHomeLikePath()) _cleanupHomeSpaArtifacts();
            startResponseScope();                         // refresh _displayedIdsCache
            const poolIds = _poolIdSet();
            const cards = _findCards(document);
            const st = {
                total: cards.length, alreadySwept: 0, keep: 0, noOwnedLink: 0,
                badHref: 0, isPool: 0, natural: 0, mapNull: 0, dupSkip: 0,
                dupReuse: 0, dupFixed: 0, wrapperReveal: 0, rewritten: 0, rewriteThrew: 0
            };
            _sweepStats = st;
            if (!cards.length) return;
            let swept = 0;
            const poolById = new Map();
            for (const v of videoPool) if (v && v.id) poolById.set(v.id, v);

            // First pass: every pool video already visible on the page.
            // The next-pass fresh picks will avoid duplicating these.
            const seenOnPage = new Set();
            for (const card of cards) {
                const inner = _innerCardsOf(card, cards);
                const a = _primaryWatchLink(card, inner);
                if (!a) continue;
                const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
                if (m && poolIds.has(m[1])) seenOnPage.add(m[1]);
            }
            const keptPoolThisSweep = new Set();

            for (const card of cards) {
                const inner = _innerCardsOf(card, cards);
                if (card.getAttribute && card.getAttribute('data-bygone-swept')) {
                    const sweptVid = card.getAttribute('data-bygone-swept');
                    const visibleVid = _cardVideoId(card, inner) || sweptVid;
                    if (poolIds.has(visibleVid) && _tryRewriteDuplicatePoolCard(card, visibleVid, inner, seenOnPage, keptPoolThisSweep, st)) {
                        continue;
                    }
                    if (poolIds.has(visibleVid)) {
                        seenOnPage.add(visibleVid);
                        keptPoolThisSweep.add(visibleVid);
                    }
                    const sweptVideo = poolById.get(sweptVid) || poolById.get(visibleVid);
                    if (sweptVideo) {
                        try { _setCardChannel(card, sweptVideo, inner); } catch (_) {}
                        try { _ensureMetadata(card, sweptVideo.id, inner); } catch (_) {}
                        try { _refreshCardDate(card, sweptVideo, inner); } catch (_) {}
                    }
                    _cleanupBygoneThumbs(card, inner);
                    st.alreadySwept++;
                    continue;
                }
                // Kept card from an earlier sweep — its date was already
                // redated, so re-evaluating it would read as "modern".
                // Skip it; just keep it revealed.
                if (card.getAttribute && card.getAttribute('data-bygone-keep')) {
                    const keepVid = _cardVideoId(card, inner);
                    if (keepVid) _redateKeptCard(card, keepVid, inner);
                    card.setAttribute('data-bygone-ok', '1');
                    st.keep++;
                    continue;
                }
                const a = _primaryWatchLink(card, inner);
                if (!a) {
                    st.noOwnedLink++;
                    if (_hasRevealedInnerCard(inner)) {
                        card.setAttribute('data-bygone-ok', '1');
                        st.wrapperReveal++;
                    }
                    continue;
                }
                const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
                if (!m) { card.setAttribute('data-bygone-ok', '1'); st.badHref++; continue; }
                const currentVid = m[1];

                // ANTI-ROTATION (v206 rule, do not change): NEVER touch a
                // card already showing one of our videos — not even to
                // dedup. Rewriting an already-replaced card means fighting
                // V3's re-render, and that tug-of-war is the once-per-
                // second rotation. Visible duplicates are accepted; the
                // rotation is not.
                if (poolIds.has(currentVid)) {
                    if (_tryRewriteDuplicatePoolCard(card, currentVid, inner, seenOnPage, keptPoolThisSweep, st)) continue;
                    seenOnPage.add(currentVid);
                    keptPoolThisSweep.add(currentVid);
                    card.setAttribute('data-bygone-ok', '1');
                    const currentVideo = poolById.get(currentVid);
                    if (currentVideo) {
                        try { _setCardChannel(card, currentVideo, inner); } catch (_) {}
                        try { _refreshCardDate(card, currentVideo, inner); } catch (_) {}
                    }
                    _ensureThumbOnCard(card, currentVid, inner);
                    _ensureMetadata(card, currentVid, inner);
                    st.isPool++;
                    continue;
                }

                // Naturally-old recommendation — kept by the interceptor at
                // the JSON level OR detected here from the DOM date text.
                // Mark `keep` so the click hijack navigates to the REAL video.
                if (_keptNaturalIds.has(currentVid) || _cardIsNaturallyOld(card, inner)) {
                    _keptNaturalIds.add(currentVid);
                    _redateKeptCard(card, currentVid, inner);
                    card.setAttribute('data-bygone-ok', '1');
                    card.setAttribute('data-bygone-keep', '1');
                    _ensureThumbOnCard(card, currentVid, inner);
                    _ensureMetadata(card, currentVid, inner);
                    st.natural++;
                    continue;
                }

                let next = _nextDomVideoFor(currentVid);
                if (!next) { st.mapNull++; continue; }
                if (seenOnPage.has(next.id)) {
                    const alt = _freshDomVideoAvoiding(seenOnPage);
                    if (alt) { next = alt; st.dupReuse++; }
                    else st.dupSkip++;
                }
                seenOnPage.add(next.id);
                keptPoolThisSweep.add(next.id);
                try { _rewriteCard(card, next, inner); swept++; st.rewritten++; } catch (_) { st.rewriteThrew++; }
            }
        }

        // ---- Sidebar sweep -----------------------------------------
        // CRITICAL: disconnect the MutationObserver during our own DOM
        // writes. Otherwise our writes refire the observer → schedule
        // another sweep → write again → tight feedback loop.

        let _sidebarObs = null;
        let _sidebarObsTarget = null;

        function _sidebarSweep() {
            let resumeTarget = null;
            if (_sidebarObs && _sidebarObsTarget) {
                try { _sidebarObs.disconnect(); resumeTarget = _sidebarObsTarget; } catch (_) {}
            }
            try { _sidebarSweepCore(); }
            finally {
                if (_sidebarObs && resumeTarget) {
                    try { _sidebarObs.observe(resumeTarget, { childList: true, subtree: true }); } catch (_) {}
                }
            }
        }

        function _sidebarSweepCore() {
            if (!active || _userPaused || !videoPool.length) return;
            if (!location.pathname.startsWith('/watch')) return;
            startResponseScope();
            const poolIds = _poolIdSet();
            const poolById = new Map();
            for (const v of videoPool) poolById.set(v.id, v);

            const rewriteIfNeeded = (card) => {
                if (!card) return;
                if (card.getAttribute && card.getAttribute('data-bygone-swept')) {
                    const sweptVid = card.getAttribute('data-bygone-swept');
                    const visibleVid = _cardVideoId(card) || sweptVid;
                    const vobj = poolById.get(sweptVid) || poolById.get(visibleVid);
                    if (vobj) {
                        try { _setCardChannel(card, vobj); } catch (_) {}
                        try { _ensureThumbOnCard(card, vobj.id); } catch (_) {}
                        try { _ensureMetadata(card, vobj.id); } catch (_) {}
                        try { _refreshCardDate(card, vobj); } catch (_) {}
                    }
                    return;
                }
                // Kept card from an earlier sweep — already redated; skip.
                if (card.getAttribute && card.getAttribute('data-bygone-keep')) {
                    const keepVid = _cardVideoId(card);
                    if (keepVid) _redateKeptCard(card, keepVid);
                    card.setAttribute('data-bygone-ok', '1');
                    return;
                }
                const a = _primaryWatchLink(card);
                if (!a) return;
                const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
                if (!m) return;
                const cur = m[1];
                // ANTI-ROTATION (v206 rule): card already shows one of our
                // videos → only fix a stale channel name (idempotent) and
                // skip. Re-mapping "duplicates" here is what produces the
                // every-tick rotation, because V3 immediately re-renders
                // the card back to its cached state and we re-replace it.
                if (poolIds.has(cur)) {
                    card.setAttribute('data-bygone-ok', '1');
                    const vobj = poolById.get(cur);
                    if (vobj) {
                        try { _setCardChannel(card, vobj); } catch (_) {}
                        try { _ensureThumbOnCard(card, cur); } catch (_) {}
                        try { _ensureMetadata(card, cur); } catch (_) {}
                        try { _refreshCardDate(card, vobj); } catch (_) {}
                    }
                    return;
                }
                if (_keptNaturalIds.has(cur) || _cardIsNaturallyOld(card)) {
                    _keptNaturalIds.add(cur);
                    _redateKeptCard(card, cur);
                    card.setAttribute('data-bygone-ok', '1');
                    card.setAttribute('data-bygone-keep', '1');
                    try { _ensureThumbOnCard(card, cur); } catch (_) {}
                    try { _ensureMetadata(card, cur); } catch (_) {}
                    return;
                }
                const next = _nextDomVideoFor(cur);
                if (!next) {
                    const stored = card.getAttribute('data-bygone-date-source');
                    _writeCardDate(card, { id: cur }, [], stored || '');
                    card.setAttribute('data-bygone-ok', '1');
                    return;
                }
                try { _rewriteCard(card, next); } catch (_) {}
            };

            // V3 exact path: every <li> in the sidebar <ol> is a card.
            document.querySelectorAll(
                '#watch7-sidebar-contents ol > li, #watch7-sidebar ol > li, ' +
                '#watch7-sidebar-modules li.video-list-item, .watch-sidebar-body li'
            ).forEach(rewriteIfNeeded);

            // Named containers only (the old [id*="sidebar"] catch-all scanned
            // the whole document on every sweep — huge CPU sink).
            const containers = [
                document.getElementById('watch7-sidebar-contents'),
                document.getElementById('watch7-sidebar'),
                document.getElementById('watch7-sidebar-modules'),
                document.getElementById('related'),
                document.getElementById('secondary'),
                document.getElementById('secondary-inner'),
            ].filter(Boolean);
            const seenCards = new Set();
            for (const c of containers) {
                const classicMatches = c.querySelectorAll(
                    '.video-list-item, .related-list-item, ytd-compact-video-renderer, ' +
                    'yt-lockup-view-model, yt-collection-thumbnail-view-model, ' +
                    '.ytd-watch-next-secondary-results-renderer'
                );
                const cards = new Set(Array.from(classicMatches));
                c.querySelectorAll('a[href*="/watch?v="]').forEach(a => {
                    let p = a;
                    for (let i = 0; i < 8 && p && p !== c; i++) {
                        if (p.querySelector && p.querySelector('img')) { cards.add(p); break; }
                        p = p.parentElement;
                    }
                });
                cards.forEach(card => {
                    if (seenCards.has(card)) return;
                    seenCards.add(card);
                    rewriteIfNeeded(card);
                });
            }
        }

        // ---- Sidebar infinite scroll -------------------------------
        // V3's Up Next sidebar is a fixed list from the initial /next
        // response — it doesn't natively load more. We extend it by
        // cloning an existing <li> (inherits V3's exact markup + CSS)
        // and rewriting it with a fresh pool video whenever the user
        // scrolls near the bottom.

        let _lastSidebarExtend = 0;
        function _maybeExtendSidebar() {
            if (!active || _userPaused || !videoPool.length) return;
            if (!location.pathname.startsWith('/watch')) return;
            // Find the sidebar <ol> (V3) or any container with sidebar list items.
            const ol = document.querySelector(
                '#watch7-sidebar-contents ol, #watch7-sidebar ol, ' +
                '#watch7-sidebar-modules ol, .watch-sidebar-body ol'
            );
            if (!ol) return;
            const items = ol.querySelectorAll('li');
            if (!items.length) return;
            // Are we near the bottom of the sidebar list?
            const olBottom = ol.getBoundingClientRect().bottom;
            if (olBottom - window.innerHeight > 800) return;
            // Throttle: at most one extend every 500ms.
            if (Date.now() - _lastSidebarExtend < 500) return;
            _lastSidebarExtend = Date.now();

            // Collect videoIds already shown so we don't duplicate.
            const shown = new Set();
            items.forEach(li => {
                const a = _primaryWatchLink(li);
                if (!a) return;
                const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
                if (m) shown.add(m[1]);
            });

            // Pick N unused pool videos.
            const N = 10;
            const picks = [];
            for (const v of videoPool) {
                if (shown.has(v.id)) continue;
                picks.push(v);
                if (picks.length >= N) break;
            }
            if (!picks.length) {
                // Pool dry — trigger lazy fetch and try again next scroll.
                _maybeFetchMore();
                return;
            }

            // Clone the FIRST existing <li> as a template (inherits V3's
            // exact markup + CSS) and rewrite each clone with a fresh video.
            const template = items[0];
            for (const v of picks) {
                try {
                    const clone = template.cloneNode(true);
                    clone.removeAttribute('data-bygone-swept');
                    clone.removeAttribute('data-bygone-ok');
                    _rewriteCard(clone, v);
                    clone.setAttribute('data-bygone-ok', '1');
                    ol.appendChild(clone);
                } catch (_) {}
            }
        }

        // Hook scroll on multiple potential containers — V3's scroll
        // container varies by layout. window scroll is the catch-all.
        window.addEventListener('scroll', _maybeExtendSidebar, { passive: true });
        document.addEventListener('scroll', _maybeExtendSidebar, { passive: true, capture: true });

        // ---- Player recommendations / autoplay ---------------------
        // The watch sidebar is normal DOM, but the YouTube player owns its
        // own end-screen cards, "up next" overlay, and next-button/autoplay
        // navigation. Those paths can fire synthetic clicks or internal
        // player navigation, bypassing the normal card click hijack. Keep
        // player rec UI pool-only, and block native autoplay entirely.

        function _currentWatchVideoId() {
            try {
                const u = new URL(location.href);
                const v = u.searchParams.get('v') || '';
                return _VALID_VID.test(v) ? v : '';
            } catch (_) {
                const m = (location.href || '').match(/[?&]v=([A-Za-z0-9_-]{11})/);
                return m ? m[1] : '';
            }
        }

        function _poolVideoById(id) {
            if (!id) return null;
            for (const v of videoPool) if (v && v.id === id) return v;
            return null;
        }

        function _extractVideoIdFromText(text) {
            if (!text) return '';
            const m = String(text).match(/(?:[?&]v=|\/(?:vi|embed|shorts)\/)([A-Za-z0-9_-]{11})/);
            return m ? m[1] : '';
        }

        function _playerRecVideoId(el) {
            if (!el) return '';
            const attrs = ['data-video-id', 'data-vid', 'data-videoid', 'video-id', 'videoid'];
            for (const a of attrs) {
                try {
                    const v = el.getAttribute && el.getAttribute(a);
                    if (v && _VALID_VID.test(v)) return v;
                } catch (_) {}
            }
            try {
                const link = el.matches && el.matches('a[href]') ? el : el.querySelector('a[href]');
                const id = link && _extractVideoIdFromText(link.getAttribute('href') || '');
                if (id) return id;
            } catch (_) {}
            try {
                const nodes = [el].concat(Array.from(el.querySelectorAll('[style], img, source')));
                for (const n of nodes) {
                    const id = _extractVideoIdFromText(
                        (n.getAttribute && (
                            n.getAttribute('src') || n.getAttribute('srcset') ||
                            n.getAttribute('style') || n.getAttribute('data-thumb') ||
                            n.getAttribute('data-thumbnail') || ''
                        )) || ''
                    );
                    if (id) return id;
                }
            } catch (_) {}
            return '';
        }

        function _pickPlayerPoolVideo(origId) {
            const current = _currentWatchVideoId();
            if (origId && _poolIdsSet.has(origId) && origId !== current) {
                const alreadyPool = _poolVideoById(origId);
                if (alreadyPool) return alreadyPool;
            }
            let mapped = null;
            if (origId && !_poolIdsSet.has(origId)) {
                try { mapped = _nextDomVideoFor(origId); } catch (_) {}
                if (mapped && mapped.id && mapped.id !== current) return mapped;
            }
            const avoid = new Set();
            if (current) avoid.add(current);
            if (origId) avoid.add(origId);
            try {
                const fresh = _freshDomVideoAvoiding(avoid);
                if (fresh) return fresh;
            } catch (_) {}
            for (const v of videoPool) {
                if (v && v.id && !avoid.has(v.id)) return v;
            }
            return videoPool[0] || null;
        }

        function _setFirstText(root, selectors, text) {
            if (!text) return false;
            for (const sel of selectors) {
                const els = root.querySelectorAll ? root.querySelectorAll(sel) : [];
                for (const el of els) {
                    if (!el || el.querySelector && el.querySelector('img')) continue;
                    try {
                        el.textContent = text;
                        if (el.hasAttribute && el.hasAttribute('title')) el.setAttribute('title', text);
                        return true;
                    } catch (_) {}
                }
            }
            return false;
        }

        function _rewritePlayerRecCard(card, video) {
            if (!card || !video || !video.id) return false;
            const vid = video.id;
            const watchUrl = '/watch?v=' + vid;
            const thumbUrl = _displayThumbUrl(video, vid);
            try {
                if (card.matches && card.matches('a[href]')) card.setAttribute('href', watchUrl);
                card.querySelectorAll('a[href]').forEach(a => {
                    const href = a.getAttribute('href') || '';
                    if (href.indexOf('/watch') !== -1 || href.indexOf('/shorts') !== -1 || href.indexOf('/embed') !== -1) {
                        a.setAttribute('href', watchUrl);
                    }
                });
            } catch (_) {}
            try {
                const nodes = [card].concat(Array.from(card.querySelectorAll('*')));
                for (const n of nodes) {
                    ['data-video-id', 'data-vid', 'data-videoid', 'video-id', 'videoid'].forEach(a => {
                        try { if (n.hasAttribute && n.hasAttribute(a)) n.setAttribute(a, vid); } catch (_) {}
                    });
                }
            } catch (_) {}
            try {
                card.querySelectorAll('img').forEach(img => {
                    img.setAttribute('src', thumbUrl);
                    img.removeAttribute('srcset');
                    if (img.hasAttribute('data-src')) img.setAttribute('data-src', thumbUrl);
                    if (img.hasAttribute('data-thumb')) img.setAttribute('data-thumb', thumbUrl);
                    if (img.hasAttribute('data-thumbnail')) img.setAttribute('data-thumbnail', thumbUrl);
                    img.alt = video.title || '';
                });
                card.querySelectorAll('source').forEach(s => {
                    if (s.hasAttribute('src')) s.setAttribute('src', thumbUrl);
                    if (s.hasAttribute('srcset')) s.setAttribute('srcset', thumbUrl);
                });
                [card].concat(Array.from(card.querySelectorAll('[style]'))).forEach(n => {
                    const style = n.getAttribute && (n.getAttribute('style') || '');
                    if (/ytimg|background-image/i.test(style)) n.style.backgroundImage = 'url("' + thumbUrl + '")';
                });
            } catch (_) {}
            _setFirstText(card, [
                '.ytp-ce-video-title',
                '.ytp-videowall-still-info-title',
                '.ytp-autonav-endscreen-upnext-title',
                '.ytp-suggestion-title',
                '.ytp-ce-expanding-overlay-title',
                '[class*="title"]'
            ], video.title || '');
            _setFirstText(card, [
                '.ytp-ce-video-metadata',
                '.ytp-videowall-still-info-author',
                '.ytp-autonav-endscreen-upnext-author',
                '.ytp-suggestion-author',
                '[class*="metadata"]'
            ], video.channel || '');
            try {
                card.setAttribute('data-bygone-player-card-ok', '1');
                card.setAttribute('data-bygone-player-video', vid);
                card.removeAttribute('data-bygone-player-card-blocked');
            } catch (_) {}
            return true;
        }

        function _looksLikePlayerRecCard(el) {
            if (!el || !el.closest || !el.closest('.html5-video-player, #movie_player')) return false;
            const cls = (el.className || '').toString();
            if (/ytp-(ce-video|videowall-still|autonav-endscreen-upnext|suggestion)/i.test(cls)) return true;
            return !!_playerRecVideoId(el);
        }

        function _playerRecCards() {
            const roots = document.querySelectorAll('.html5-video-player, #movie_player');
            const out = new Set();
            roots.forEach(root => {
                root.querySelectorAll([
                    '.ytp-ce-video',
                    '.ytp-ce-element',
                    '.ytp-videowall-still',
                    '.ytp-autonav-endscreen-upnext-container',
                    '.ytp-suggestion-link',
                    'a.ytp-suggestion-link'
                ].join(',')).forEach(el => {
                    if (_looksLikePlayerRecCard(el)) out.add(el);
                });
            });
            return Array.from(out);
        }

        let _lastAutoplayDisableAttempt = 0;
        let _autoplayDisableKey = '';
        let _autoplayDisableTries = 0;
        function _tryDisableAutoplayControl(el) {
            if (!el) return;
            const now = Date.now();
            if (now - _lastAutoplayDisableAttempt < 8000) return;
            // V3's toggle handler refocuses the player and scrolls it back
            // into view, so every click here yanks the user's scroll. If the
            // toggle still reads "on" after a few attempts, the state
            // detection is wrong for this V3 build — give up for this video
            // instead of yanking the viewport every 8 s forever.
            const key = location.pathname + location.search;
            if (_autoplayDisableKey !== key) {
                _autoplayDisableKey = key;
                _autoplayDisableTries = 0;
            }
            if (_autoplayDisableTries >= 3) return;
            _autoplayDisableTries++;
            _lastAutoplayDisableAttempt = now;
            try { el.setAttribute('data-bygone-autoplay-disable-attempt', String(now)); } catch (_) {}
            try { el.click(); } catch (_) {}
        }
        function _disableNativeAutoplay() {
            if (!location.pathname.startsWith('/watch')) return;
            try {
                document.querySelectorAll('.ytp-autonav-toggle-button, .ytp-autonav-toggle-button-container button').forEach(btn => {
                    const state = (btn.getAttribute('aria-checked') || btn.getAttribute('aria-pressed') || '').toLowerCase();
                    const label = (btn.getAttribute('aria-label') || btn.getAttribute('title') || '').toLowerCase();
                    if (!(state === 'true' || /autoplay.*on|on.*autoplay/.test(label))) return;
                    _tryDisableAutoplayControl(btn);
                });
                document.querySelectorAll('.autoplay-bar input[type="checkbox"]:checked').forEach(_tryDisableAutoplayControl);
                document.querySelectorAll('.ytp-autonav-endscreen-countdown-container, .ytp-autonav-endscreen-countdown-overlay')
                    .forEach(el => el.setAttribute('data-bygone-player-card-blocked', '1'));
            } catch (_) {}
        }

        function _sweepPlayerRecommendations() {
            if (!active || _userPaused || !videoPool.length) return;
            if (!location.pathname.startsWith('/watch')) return;
            _disableNativeAutoplay();
            for (const card of _playerRecCards()) {
                const orig = _playerRecVideoId(card);
                const v = _pickPlayerPoolVideo(orig);
                if (!v) {
                    try { card.setAttribute('data-bygone-player-card-blocked', '1'); } catch (_) {}
                    continue;
                }
                _rewritePlayerRecCard(card, v);
            }
        }

        function _playerRecClickTarget(target) {
            if (!target || !target.closest) return null;
            return target.closest(
                '.ytp-next-button, .ytp-ce-video, .ytp-videowall-still, ' +
                '.ytp-autonav-endscreen-upnext-container, .ytp-suggestion-link'
            );
        }

        function _navigateToPoolVideo(video) {
            if (!video || !video.id) return false;
            try {
                Store.addClickEvent({
                    videoId: video.id, channelId: video.channelId || null,
                    channel: video.channel || '', title: video.title || '',
                    source: video.source || 'unknown', ts: Date.now(),
                });
                Store.markFeedClicked(video.id);
                Store.recordSourceClick(video.source || 'unknown');
            } catch (_) {}
            location.href = location.origin + '/watch?v=' + video.id;
            return true;
        }

        setInterval(_sweepPlayerRecommendations, 500);

        // ---- Home page infinite scroll --------------------------------
        // V3's LOHP continuation fires once then dies. We inject more
        // pool videos into the grid when the user scrolls near bottom.

        let _lastHomeExtend = 0;
        let _homeExtendCount = 0;
        let _homeExtendPausedUntil = 0;
        const _MAX_HOME_EXTENDS = 20;
        const _HOME_FEATURE_SEL = '.lohp-large-shelf-container, .lohp-medium-shelf';
        const _HOME_EXTEND_CONTAINER_SEL = [
            '#c3-content-items',
            '#browse-items-primary',
            '#feed',
            '#feed-list',
            '.feed-list',
            '.channels-browse-content-grid',
            '.expanded-shelf-content-list',
            '.yt-shelf-grid',
            '.yt-rich-grid',
            'ytd-rich-grid-renderer #contents'
        ].join(',');

        function _isHomeFeature(el) {
            return !!(el && el.matches && el.matches(_HOME_FEATURE_SEL));
        }

        function _isInsideHomeFeature(el) {
            return !!(el && el.closest && el.closest(_HOME_FEATURE_SEL));
        }

        function _containsHomeFeature(el) {
            return !!(el && el.querySelector && el.querySelector(_HOME_FEATURE_SEL));
        }

        function _countDirectVideoChildren(container) {
            if (!container || !container.children) return 0;
            let n = 0;
            for (const child of container.children) {
                if (_isHomeFeature(child) || _isInsideHomeFeature(child) || _containsHomeFeature(child)) continue;
                if (child.querySelector && child.querySelector('a[href*="/watch"]')) n++;
            }
            return n;
        }

        function _watchLinkCount(el) {
            if (!el || !el.querySelectorAll) return 0;
            const ids = new Set();
            el.querySelectorAll('a[href*="/watch"]').forEach(a => {
                const h = a.getAttribute('href') || '';
                const m = h.match(/[?&]v=([A-Za-z0-9_-]+)/);
                ids.add(m ? m[1] : h);
            });
            return ids.size;
        }

        function _watchAnchorCount(el) {
            return el && el.querySelectorAll ? el.querySelectorAll('a[href*="/watch"]').length : 0;
        }

        function _isSingleVideoTemplate(el) {
            if (!el || _isHomeFeature(el) || _isInsideHomeFeature(el) || _containsHomeFeature(el)) return false;
            return _watchLinkCount(el) === 1 && _watchAnchorCount(el) <= 3;
        }

        function _templateFromHomeContainer(container, cards) {
            if (!container || _isHomeFeature(container) || _isInsideHomeFeature(container)) return null;
            const direct = [];
            for (const child of Array.from(container.children || [])) {
                if (_isSingleVideoTemplate(child)) direct.push(child);
            }
            if (direct.length) return direct[direct.length - 1];

            for (let i = cards.length - 1; i >= 0; i--) {
                let node = cards[i];
                if (!_isSingleVideoTemplate(node)) continue;
                while (node && node.parentElement && node.parentElement !== container) node = node.parentElement;
                if (node && node.parentElement === container && _isSingleVideoTemplate(node)) {
                    return node;
                }
            }
            return null;
        }

        function _findHomeExtendTarget(cards) {
            const sidebarAncestorSel = [
                '#watch7-sidebar-contents',
                '#watch7-sidebar',
                '#watch7-sidebar-modules',
                '#related',
                '#secondary',
                '#secondary-inner'
            ].join(',');
            const badAncestorSel = [
                _HOME_FEATURE_SEL,
                sidebarAncestorSel
            ].join(',');

            const knownContainers = [
                '#c3-content-items',
                '#browse-items-primary',
                '#feed',
                '#feed-list',
                '.feed-list',
                '.channels-browse-content-grid',
                '.expanded-shelf-content-list',
                '.yt-shelf-grid',
                '.yt-rich-grid',
                'ytd-rich-grid-renderer #contents'
            ];
            for (const sel of knownContainers) {
                for (const container of document.querySelectorAll(sel)) {
                    if (!container || (container.closest && container.closest(sidebarAncestorSel))) continue;
                    const template = _templateFromHomeContainer(container, cards);
                    if (template) return { container, template };
                }
            }

            for (let i = cards.length - 1; i >= 0; i--) {
                const template = cards[i];
                if (!template || !template.parentElement) continue;
                if (_isHomeFeature(template) || _isInsideHomeFeature(template) || _containsHomeFeature(template)) continue;
                if (template.closest && template.closest(badAncestorSel)) continue;

                let container = template.parentElement;
                for (let depth = 0; depth < 5 && container; depth++, container = container.parentElement) {
                    if (_isHomeFeature(container) || _isInsideHomeFeature(container) || _containsHomeFeature(container)) break;
                    if (container.closest && container.closest('#watch7-sidebar-contents,#watch7-sidebar,#watch7-sidebar-modules,#related,#secondary,#secondary-inner')) break;

                    const tag = (container.tagName || '').toLowerCase();
                    const cls = (container.className || '').toString();
                    const directVideoChildren = _countDirectVideoChildren(container);
                    const listLike = tag === 'ol' || tag === 'ul' || /items|grid|list|feed|shelf|browse|content/i.test(cls);

                    // The top LOHP feature area has a big card + side cards but
                    // is not a repeatable feed list. Requiring repeated direct
                    // card children keeps appended batches out of that gap.
                    if (listLike && directVideoChildren >= 2) {
                        return { container, template };
                    }
                }
            }
            return null;
        }

        function _maybeExtendHome() {
            if (!active || _userPaused || !videoPool.length) return;
            const path = location.pathname;
            if (path !== '/' && path !== '' && path !== '/feed/trending') return;
            if (_homeExtendCount >= _MAX_HOME_EXTENDS) return;
            if (Date.now() - _lastHomeExtend < 600) return;

            const docBottom = document.documentElement.scrollHeight;
            if (docBottom - window.scrollY - window.innerHeight > 1200) return;
            _lastHomeExtend = Date.now();

            const cards = _findCards(document);
            if (!cards.length) return;

            const shown = new Set();
            for (const c of cards) {
                const a = _primaryWatchLink(c);
                if (!a) continue;
                const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
                if (m) shown.add(m[1]);
            }

            const N = 12;
            const picks = [];
            for (const v of videoPool) {
                if (shown.has(v.id)) continue;
                picks.push(v);
                if (picks.length >= N) break;
            }
            if (!picks.length) { _maybeFetchMore(); return; }

            const target = _findHomeExtendTarget(cards);
            if (!target) return;
            const { container, template } = target;

            for (const v of picks) {
                try {
                    const clone = template.cloneNode(true);
                    clone.removeAttribute('data-bygone-swept');
                    clone.removeAttribute('data-bygone-ok');
                    clone.removeAttribute('data-bygone-keep');
                    clone.removeAttribute('data-bygone-redated');
                    _rewriteCard(clone, v);
                    clone.setAttribute('data-bygone-ok', '1');
                    clone.setAttribute('data-bygone-home-extend', '1');
                    container.appendChild(clone);
                    shown.add(v.id);
                } catch (_) {}
            }
            _homeExtendCount++;
            _maybeFetchMore();
        }

        // Reset extend count on navigation (new page = fresh feed).
        function _resetHomeExtend() { _homeExtendCount = 0; }

        window.addEventListener('scroll', _maybeExtendHome, { passive: true });
        document.addEventListener('scroll', _maybeExtendHome, { passive: true, capture: true });
        window.addEventListener('yt-navigate-finish', _resetHomeExtend);
        window.addEventListener('popstate', _resetHomeExtend);

        // ---- Comment filter (DOM sweep) ----------------------------
        // Comments lazy-load in batches and V3 re-renders them from its
        // own caches, so a data-level filter can't reliably catch them.
        // Instead we sweep the rendered comment DOM on a heartbeat:
        //   - Hide any comment thread dated AFTER the cutoff
        //     (set date + 2 years).
        //   - Re-relativize surviving comment timestamps to the set date.
        //   - Auto-drain the V3 "show/load more comments" continuation,
        //     sweeping each loaded batch before it has a chance to linger.
        const _C_DATE_RE = /(?:Streamed\s+)?(\d+)\s+(year|month|week|day|hour|minute|second)s?\s+ago/i;
        // V3's 2013 layout can render an ABSOLUTE date ("May 15, 2013")
        // instead of a relative "X years ago" string. Detect both.
        const _C_ABS_DATE_RE = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}/i;
        let _cDiag = { found: 0, hidden: 0, kept: 0, notime: 0 };

        let _commentDrain = {
            key: '',
            clicks: 0,
            done: false,
            capped: false,
            stall: 0,
            lastCount: 0,
            lastClickAt: 0,
            lastClickCount: -1,
            lastLogAt: 0,
        };

        // Comment subsystem (v382 model). V3 uses explicit "load more comments"
        // batches, so the active model is: remove bad comment slots from the DOM,
        // collapse their orphan reply loaders/spacers, then keep draining V3's
        // paginator until the visible kept-comment list is replenished or exhausted.

        const _COMMENT_V2_TARGET_VISIBLE = 35;
        const _COMMENT_V2_MAX_CLICKS = 260;
        const _COMMENT_V2_MIN_CLICK_MS = 260;
        const _COMMENT_V2_WAIT_SAME_COUNT_MS = 900;
        const _COMMENT_V2_STALL_TICKS = 36;

        function _commentPageKey() {
            const m = location.search.match(/[?&]v=([A-Za-z0-9_-]+)/);
            return location.pathname + '|' + (m ? m[1] : location.search);
        }

        function _commentRoots() {
            const selectors = [
                'ytd-comments',
                '#comments',
                '#watch-comments',
                '#watch-comments-section',
                '#watch-discussion',
                '#watch-discussion-section',
                '#watch7-discussion',
                '#watch7-discussion-contents',
                '#comment-section-renderer',
                '#comment-section-renderer-items',
                '.comment-section',
                '.comments-section',
                '.comment-list',
                '.comments-list',
                '.comments'
            ];
            const out = [];
            const seen = new Set();
            for (const sel of selectors) {
                try {
                    document.querySelectorAll(sel).forEach(el => {
                        if (!seen.has(el)) { seen.add(el); out.push(el); }
                    });
                } catch (_) {}
            }
            return out;
        }

        function _commentRoot() {
            const roots = _commentRoots();
            return roots[0] || null;
        }

        function _isCommentishArea(el) {
            if (!el || !el.closest) return false;
            return !!el.closest(
                'ytd-comments, #comments, #watch-comments, #watch-comments-section, ' +
                '#watch-discussion, #watch-discussion-section, #watch7-discussion, ' +
                '#comment-section-renderer, .comment-section, .comments-section, ' +
                '.comment-list, .comments-list, .comments'
            );
        }

        function _commentThreadCount(el) {
            if (!el || !el.querySelectorAll) return 0;
            return el.querySelectorAll(
                'div.comment[data-id], ytd-comment-thread-renderer, ytd-comment-view-model, ' +
                '.comment-thread-renderer, .comment-item'
            ).length;
        }

        function _addCommentThread(out, seen, node) {
            if (!node || seen.has(node) || !node.isConnected) return;
            if (node.getAttribute && node.getAttribute('data-bygone-comment-hidden') === '1') return;
            for (const old of Array.from(seen)) {
                if (old.contains && old.contains(node)) return;
                if (node.contains && node.contains(old)) {
                    seen.delete(old);
                    const idx = out.indexOf(old);
                    if (idx !== -1) out.splice(idx, 1);
                }
            }
            seen.add(node);
            out.push(node);
        }

        function _collectCommentThreads() {
            const out = [];
            const seen = new Set();
            const roots = _commentRoots();
            const scopes = roots.length ? roots : [document];
            const selectors = [
                'ytd-comment-thread-renderer',
                'ytd-comment-view-model',
                '.comment-thread-renderer',
                '.comment-item',
                'div.comment[data-id]'
            ].join(',');
            for (const root of scopes) {
                try {
                    root.querySelectorAll(selectors).forEach(node => {
                        if (node.matches && node.matches('ytd-comment-view-model') &&
                            node.closest('ytd-comment-thread-renderer')) return;
                        _addCommentThread(out, seen, node);
                    });
                } catch (_) {}
            }
            return out;
        }

        function _commentTimeElement(thread) {
            if (!thread || !thread.querySelectorAll) return null;
            const direct = thread.querySelector(
                '#published-time-text a, #published-time-text yt-formatted-string, ' +
                '#published-time-text yt-core-attributed-string, ' +
                '.published-time-text a, .published-time-text yt-core-attributed-string, ' +
                'span.metadata span.detail a.detail_link:not(.detail_link_full), ' +
                '.metadata a.detail_link:not(.detail_link_full), ' +
                'a.detail_link:not(.detail_link_full), .metadata .time, ' +
                '.comment .time, .time a, .time, .comment-time, .comment-date, ' +
                'a.comment-author-time, time'
            );
            if (direct) return direct;
            const scan = thread.querySelectorAll('a, span, div, time, yt-core-attributed-string, yt-formatted-string');
            for (const el of scan) {
                if (el.children && el.children.length > 2) continue;
                const t = (el.textContent || '').trim();
                if (!t || t.length > 80) continue;
                if (_C_DATE_RE.test(t) || _C_ABS_DATE_RE.test(t)) return el;
            }
            return null;
        }

        function _commentApproxDate(raw) {
            raw = (raw || '').trim();
            const m = raw.match(_C_DATE_RE);
            if (m) return DateHelper.approxPublishDate(m[0]);
            const am = raw.match(_C_ABS_DATE_RE);
            if (am) {
                const d = new Date(am[0]);
                if (!isNaN(d.getTime())) return d;
            }
            return null;
        }

        function _commentSlotFor(thread) {
            if (!thread) return null;
            if (thread.closest) {
                const post = thread.closest('.post');
                if (post) return post;
                const ytd = thread.closest('ytd-comment-thread-renderer');
                if (ytd) return ytd;
                const item = thread.closest('.comment-thread-renderer, .comment-item');
                if (item && _commentThreadCount(item) <= 1) return item;
                const li = thread.closest('li');
                if (li && _commentThreadCount(li) <= 1 && _isCommentishArea(li)) return li;
            }
            const root = _commentRoot();
            let best = thread;
            for (let el = thread; el && el !== document.body && el !== root; el = el.parentElement) {
                const cls = (el.className || '').toString();
                const tag = (el.tagName || '').toLowerCase();
                const isSlot = tag === 'li' ||
                    /^(ytd-comment-thread-renderer|ytd-comment-view-model)$/i.test(tag) ||
                    /(^|\s)(post|comment-thread|comment-item|comment-entry|comment-container|comment-renderer)(\s|$)/i.test(cls);
                if (isSlot && _commentThreadCount(el) <= 1) best = el;
                if (el.parentElement === root) break;
            }
            return best;
        }

        function _hardCollapseNode(el) {
            if (!el || !el.setAttribute) return;
            try {
                el.setAttribute('data-bygone-comment-hidden', '1');
                el.setAttribute('aria-hidden', 'true');
                el.style.setProperty('display', 'none', 'important');
                el.style.setProperty('height', '0', 'important');
                el.style.setProperty('min-height', '0', 'important');
                el.style.setProperty('max-height', '0', 'important');
                el.style.setProperty('overflow', 'hidden', 'important');
                el.style.setProperty('margin', '0', 'important');
                el.style.setProperty('padding', '0', 'important');
                el.style.setProperty('border', '0', 'important');
                el.style.setProperty('visibility', 'hidden', 'important');
            } catch (_) {}
        }

        function _removeNode(el) {
            if (!el) return false;
            try { el.remove(); return true; } catch (_) {}
            _hardCollapseNode(el);
            return false;
        }

        function _isReplyLoaderNode(el) {
            if (!el || !el.textContent) return false;
            const cls = ((el.className || '') + ' ' + (el.id || '')).toString().toLowerCase();
            const text = (el.textContent || '').replace(/\s+/g, ' ').trim().toLowerCase();
            if (/more comments|load more comments|show more comments/.test(text)) return false;
            if (/view all\s+\d*\s*repl|show all\s+\d*\s*repl|load\s+\d*\s*repl/.test(text)) return true;
            return /comment-repl|reply|replies|\brepl\b/.test(cls) && /reply|replies|\brepl\b/.test(text);
        }

        function _isReplySidecarNode(el) {
            if (!el) return false;
            const cls = ((el.className || '') + ' ' + (el.id || '')).toString().toLowerCase();
            const text = (el.textContent || '').replace(/\s+/g, ' ').trim().toLowerCase();
            if (/more comments|load more comments|show more comments/.test(text)) return false;
            if (/comment-repl|comment_reply|comment-reply|replies|reply-list|reply-list|reply-thread|reply-section/.test(cls)) return true;
            if (/view all\s+\d*\s*repl|show all\s+\d*\s*repl|load\s+\d*\s*repl|view\s+\d+\s*repl/.test(text)) return true;
            return false;
        }

        function _removeAdjacentCommentJunk(slot) {
            if (!slot) return;
            try {
                slot.querySelectorAll(
                    '.comment-replies, .comment-replies-renderer, .comment-reply, ' +
                    '.comment-repl, [class*="reply"], [class*="repl"]'
                ).forEach(el => {
                    if (_isReplySidecarNode(el) || _isReplyLoaderNode(el)) _removeNode(el);
                });
            } catch (_) {}

            const scan = (dir) => {
                let sib = dir === 'next' ? slot.nextElementSibling : slot.previousElementSibling;
                let guard = 0;
                while (sib && guard++ < 14) {
                    const next = dir === 'next' ? sib.nextElementSibling : sib.previousElementSibling;
                    const topLevelThread = sib.matches && sib.matches('.post, ytd-comment-thread-renderer, .comment-thread-renderer, .comment-item');
                    const text = (sib.textContent || '').replace(/\s+/g, ' ').trim();
                    const cls = ((sib.className || '') + ' ' + (sib.id || '')).toString();
                    if (_isReplySidecarNode(sib) || _isReplyLoaderNode(sib) || (!text && /spacer|separator|reply|repl|loader/i.test(cls))) {
                        _removeNode(sib);
                        sib = next;
                        continue;
                    }
                    if (_commentThreadCount(sib) > 0 || topLevelThread) break;
                    break;
                }
            };
            scan('next');
            scan('prev');
        }

        function _collapseEmptyCommentAncestors(start, root) {
            let el = start;
            let guard = 0;
            while (el && el !== document.body && el !== root && guard++ < 8) {
                const next = el.parentElement;
                const sig = ((el.className || '') + ' ' + (el.id || '')).toString();
                const tag = (el.tagName || '').toLowerCase();
                const slotish = tag === 'li' || /post|comment|thread|item|entry|container/i.test(sig);
                if (!slotish) break;
                const text = (el.textContent || '').replace(/\s+/g, '').trim();
                if (_commentThreadCount(el) === 0 && text.length < 8) _removeNode(el);
                else break;
                el = next;
            }
        }

        function _nukeCommentThread(thread) {
            const slot = _commentSlotFor(thread);
            if (!slot) return;
            const root = _commentRoot();
            const parent = slot.parentElement;
            _removeAdjacentCommentJunk(slot);
            _removeNode(slot);
            _collapseEmptyCommentAncestors(parent, root);
        }

        function _pruneOldCommentShells() {
            const roots = _commentRoots();
            for (const root of roots) {
                try {
                    root.querySelectorAll('[data-bygone-comment-hidden="1"]').forEach(_removeNode);
                    root.querySelectorAll('.comment-replies, .comment-replies-renderer, [class*="comment-repl"]').forEach(el => {
                        const prev = el.previousElementSibling;
                        const next = el.nextElementSibling;
                        const nearThread = (prev && _commentThreadCount(prev) > 0) || (next && _commentThreadCount(next) > 0);
                        const nearHidden = (prev && prev.getAttribute && prev.getAttribute('data-bygone-comment-hidden') === '1') ||
                            (next && next.getAttribute && next.getAttribute('data-bygone-comment-hidden') === '1');
                        if (!nearThread || nearHidden) _removeNode(el);
                    });
                    root.querySelectorAll('.post, .comment-thread-renderer, .comment-item, li').forEach(el => {
                        const text = (el.textContent || '').replace(/\s+/g, '').trim();
                        if (_commentThreadCount(el) === 0 && text.length < 8) _removeNode(el);
                    });
                } catch (_) {}
            }
        }

        function _visibleCommentCount() {
            let n = 0;
            for (const thread of _collectCommentThreads()) {
                const slot = _commentSlotFor(thread) || thread;
                if (!slot || !slot.isConnected) continue;
                if (slot.getAttribute && slot.getAttribute('data-bygone-comment-hidden') === '1') continue;
                try {
                    const cs = getComputedStyle(slot);
                    if (cs.display === 'none' || cs.visibility === 'hidden') continue;
                } catch (_) {}
                n++;
            }
            return n;
        }

        function _commentSweep() {
            if (!active || _userPaused || !location.pathname.startsWith('/watch')) return;
            let setDateStr;
            try { setDateStr = Store.getCurrentDate(); } catch { return; }
            const setDate = setDateStr ? new Date(setDateStr) : null;
            if (!setDate || isNaN(setDate.getTime())) return;
            const cutoff = new Date(setDate);
            cutoff.setFullYear(cutoff.getFullYear() + 2);

            const batch = { found: 0, removed: 0, kept: 0, notime: 0 };
            for (const thread of _collectCommentThreads()) {
                if (!thread || !thread.isConnected) continue;
                if (thread.getAttribute && thread.getAttribute('data-bygone-comment-status') === 'kept') continue;
                batch.found++;
                const timeEl = _commentTimeElement(thread);
                if (!timeEl) { batch.notime++; continue; }
                const raw = (timeEl.textContent || '').trim();
                const approx = _commentApproxDate(raw);
                if (!approx || isNaN(approx.getTime())) { batch.notime++; continue; }

                if (approx.getTime() > cutoff.getTime()) {
                    batch.removed++;
                    _cDiag.hidden++;
                    try { thread.setAttribute('data-bygone-comment-status', 'removed'); } catch (_) {}
                    _nukeCommentThread(thread);
                    continue;
                }

                batch.kept++;
                _cDiag.kept++;
                try {
                    thread.setAttribute('data-bygone-comment-status', 'kept');
                    const edited = /\(edited\)/i.test(raw) ? ' (edited)' : '';
                    timeEl.textContent = DateHelper.relativeToDate(approx, setDate) + edited;
                } catch (_) {}
            }
            _cDiag.found += batch.found;
            _cDiag.notime += batch.notime;
            _pruneOldCommentShells();

            if ((batch.removed || batch.kept || batch.notime) && !_commentSweep._lastReport) {
                _commentSweep._lastReport = true;
                console.log('[bygone] comments v382:',
                    'batch=' + batch.found,
                    'removed=' + batch.removed,
                    'kept=' + batch.kept,
                    'notime=' + batch.notime,
                    'visible=' + _visibleCommentCount(),
                    'cutoff=' + cutoff.toISOString().slice(0, 10));
                setTimeout(() => { _commentSweep._lastReport = false; }, 3500);
            }
        }

        function _resetCommentDrain() {
            _cDiag = { found: 0, hidden: 0, kept: 0, notime: 0 };
            _commentSweep._lastReport = false;
            _commentDrain = {
                key: _commentPageKey(),
                clicks: 0,
                done: false,
                capped: false,
                stall: 0,
                lastCount: 0,
                lastClickAt: 0,
                lastClickCount: -1,
                lastLogAt: 0,
            };
        }

        function _commentButtonText(el) {
            if (!el) return '';
            return [
                el.getAttribute && el.getAttribute('aria-label'),
                el.getAttribute && el.getAttribute('title'),
                el.value,
                el.textContent,
            ].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
        }

        function _commentClickable(el) {
            if (!el) return null;
            // V3's distiller comment paginator is a bare DIV
            // (.jfk-button.load-more-button) with no role="button", so the
            // class selectors here are load-bearing — without them the drain
            // never finds anything to click and comments stop at one batch.
            const sel = 'button, a, [role="button"], input[type="button"], input[type="submit"], ' +
                '.load-more-button, .yt-uix-load-more, .jfk-button';
            if (el.matches && el.matches(sel)) return el;
            return el.querySelector && el.querySelector(sel);
        }

        function _isUsableCommentButton(el) {
            const clickEl = _commentClickable(el);
            if (!clickEl) return false;
            if (clickEl.disabled || clickEl.getAttribute('aria-disabled') === 'true') return false;
            if (clickEl.closest && clickEl.closest(
                'div.comment[data-id], ytd-comment-thread-renderer, ytd-comment-view-model, ' +
                '.comment-thread-renderer, .comment-item, .post'
            )) return false;
            // "Comments are turned off" panels carry a "Learn more" link to
            // support.google.com — clicking it navigates the whole tab away
            // from the video. Never click external links from the drain.
            try {
                if (clickEl.tagName === 'A' && clickEl.href) {
                    const u = new URL(clickEl.href, location.href);
                    if (!/(^|\.)youtube\.com$/i.test(u.hostname)) return false;
                }
            } catch (_) {}
            const text = _commentButtonText(el).toLowerCase();
            const sig = (text + ' ' + ((el.className || '') + ' ' + (el.id || '') + ' ' +
                ((clickEl.className || '') + ' ' + (clickEl.id || '')))).toLowerCase();
            if (/reply|replies|\brepl\b|transcript|description|playlist|share|sort|newest|top comments|learn more/.test(sig)) return false;
            const positive = /load more comments|show more comments|view more comments|more comments|load comments|show comments/.test(sig) ||
                (_isCommentishArea(el) && /(load more|show more|view more|\bmore\b|continuation|paginator|load-more)/.test(sig));
            if (!positive) return false;
            try {
                const cs = getComputedStyle(clickEl);
                if (cs.display === 'none' || cs.visibility === 'hidden' || cs.pointerEvents === 'none') return false;
                // Computed style misses ancestor display:none (V3 nests a
                // duplicate paginator inside hidden reply teasers).
                if (clickEl.offsetParent === null && cs.position !== 'fixed') return false;
            } catch (_) {}
            // V3's idle distiller button always CONTAINS a hidden
            // "Loading..." span, so a text match on "loading" would reject
            // it permanently. Only treat it as busy when the indicator is
            // actually visible.
            try {
                const li = clickEl.querySelector && clickEl.querySelector('[class*="loading"]');
                if (li) {
                    const lcs = getComputedStyle(li);
                    if (lcs.display !== 'none' && lcs.visibility !== 'hidden') return false;
                }
            } catch (_) {}
            return true;
        }

        function _findCommentLoadButton() {
            const selectors = [
                'button',
                'a',
                '[role="button"]',
                'input[type="button"]',
                'input[type="submit"]',
                '.yt-uix-load-more',
                '.yt-uix-button',
                '.load-more-button',
                '.load-more',
                '.comment-section-renderer-paginator',
                '.comments-pagination',
                '.comment-pager',
                '[class*="continuation"]',
                '[class*="paginator"]',
                '[class*="load-more"]'
            ].join(',');
            const scopes = _commentRoots();
            if (!scopes.length) scopes.push(document);
            const seen = new Set();
            for (const scope of scopes) {
                let candidates = [];
                try { candidates = Array.from(scope.querySelectorAll(selectors)); } catch (_) {}
                for (const el of candidates) {
                    if (seen.has(el)) continue;
                    seen.add(el);
                    if (_isUsableCommentButton(el)) return _commentClickable(el);
                }
            }
            return null;
        }

        function _clickCommentLoadButton(btn) {
            if (!btn) return false;
            // No scrollIntoView here: the drain clicks up to 260 times per
            // page, and scrolling the paginator into view each time yanked
            // the user's viewport while they were reading.
            try {
                ['pointerover', 'mouseover', 'pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'].forEach(type => {
                    btn.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
                });
                return true;
            } catch (_) {
                try { btn.click(); return true; } catch (__) {}
            }
            return false;
        }

        function _nearCommentBottom() {
            const root = _commentRoot();
            if (!root) return true;
            try {
                const r = root.getBoundingClientRect();
                return r.bottom < window.innerHeight + 1100 ||
                    window.scrollY + window.innerHeight > document.documentElement.scrollHeight - 1400;
            } catch (_) {
                return true;
            }
        }

        function _commentShouldLoadMore(visible) {
            if (visible < _COMMENT_V2_TARGET_VISIBLE) return true;
            return _nearCommentBottom();
        }

        function _commentDrainTick() {
            if (!active || _userPaused || !location.pathname.startsWith('/watch')) return;
            const key = _commentPageKey();
            if (_commentDrain.key !== key) _resetCommentDrain();
            if (_commentDrain.capped) return;

            try { _commentSweep(); } catch (_) {}

            const total = _collectCommentThreads().length;
            const visible = _visibleCommentCount();
            const btn = _findCommentLoadButton();
            if (!btn) {
                if (total !== _commentDrain.lastCount) {
                    _commentDrain.lastCount = total;
                    _commentDrain.stall = 0;
                    _commentDrain.done = false;
                } else if (_commentDrain.clicks) {
                    _commentDrain.stall++;
                }
                if (_commentDrain.clicks && !_commentDrain.done && _commentDrain.stall >= _COMMENT_V2_STALL_TICKS) {
                    _commentDrain.done = true;
                    console.log('[bygone] comment drain exhausted:',
                        'clicks=' + _commentDrain.clicks,
                        'visible=' + visible,
                        'threads=' + total,
                        'removed=' + _cDiag.hidden,
                        'kept=' + _cDiag.kept);
                }
                return;
            }

            _commentDrain.done = false;
            if (!_commentShouldLoadMore(visible)) return;
            if (_commentDrain.clicks >= _COMMENT_V2_MAX_CLICKS) {
                _commentDrain.done = true;
                _commentDrain.capped = true;
                console.log('[bygone] comment drain capped:',
                    'clicks=' + _commentDrain.clicks,
                    'visible=' + visible,
                    'threads=' + total);
                return;
            }

            const now = Date.now();
            if (now - _commentDrain.lastClickAt < _COMMENT_V2_MIN_CLICK_MS) return;
            if (total === _commentDrain.lastClickCount &&
                now - _commentDrain.lastClickAt < _COMMENT_V2_WAIT_SAME_COUNT_MS) return;

            if (_clickCommentLoadButton(btn)) {
                _commentDrain.clicks++;
                _commentDrain.lastClickAt = now;
                _commentDrain.lastClickCount = total;
                _commentDrain.lastCount = total;
                _commentDrain.stall = 0;
                [60, 180, 420, 900].forEach(ms => setTimeout(() => {
                    try { _commentSweep(); } catch (_) {}
                }, ms));
                if (_commentDrain.clicks === 1 || now - _commentDrain.lastLogAt > 3000) {
                    _commentDrain.lastLogAt = now;
                    console.log('[bygone] comment drain loading:',
                        'clicks=' + _commentDrain.clicks,
                        'visible=' + visible,
                        'threads=' + total,
                        'button="' + _commentButtonText(btn).slice(0, 80) + '"');
                }
            }
        }

        let _commentObs = null;
        let _commentObsRoot = null;
        let _commentSweepQueued = false;
        function _queueCommentSweep() {
            if (_commentSweepQueued) return;
            _commentSweepQueued = true;
            setTimeout(() => {
                _commentSweepQueued = false;
                try { _commentSweep(); _commentDrainTick(); } catch (_) {}
            }, 25);
        }

        function _installCommentObserver() {
            if (!location.pathname.startsWith('/watch')) return;
            const root = _commentRoot();
            if (!root) { setTimeout(_installCommentObserver, 500); return; }
            if (_commentObs && _commentObsRoot === root) return;
            if (_commentObs) { try { _commentObs.disconnect(); } catch (_) {} }
            _commentObsRoot = root;
            _commentObs = new MutationObserver(_queueCommentSweep);
            try { _commentObs.observe(root, { childList: true, subtree: true }); } catch (_) {}
            _queueCommentSweep();
        }

        // ---- Heartbeats + nav burst --------------------------------

        // Steady heartbeat. A persistent MutationObserver on the home grid
        // was tried (v310) but it fought V3's own re-render of the feed —
        // every fix we made triggered a V3 re-render which the observer
        // caught and re-fixed, an unbounded loop. Sweeping is therefore
        // POLL-based only: a steady heartbeat plus a time-BOUNDED burst
        // after load / nav (see _burstGridSweep). Bounded = cannot loop.
        setInterval(_sweep, 800);
        setInterval(_searchSweep, 800);
        setInterval(_sidebarSweep, 1000);
        // Comment sweep — frequent so newly lazy-loaded / V3-re-rendered
        // comment batches get filtered before the user reads them.
        setInterval(_commentSweep, 300);
        setInterval(_commentDrainTick, 80);
        if (document.readyState !== 'loading') _installCommentObserver();
        else document.addEventListener('DOMContentLoaded', _installCommentObserver);
        window.addEventListener('yt-navigate-finish', (e) => {
            if (e.detail && e.detail._bygonePoke) return;
            _resetCommentDrain();
            if (location.pathname.startsWith('/watch')) {
                setTimeout(_installCommentObserver, 200);
            } else if (_commentObs) {
                try { _commentObs.disconnect(); } catch (_) {}
                _commentObs = null;
                _commentObsRoot = null;
            }
            [300, 800, 1600, 3000].forEach(ms => setTimeout(() => {
                try { _commentSweep(); _commentDrainTick(); } catch (_) {}
            }, ms));
        });
        // Heartbeat extender as a safety net when scroll events miss.
        setInterval(() => { try { _maybeExtendSidebar(); } catch (_) {} }, 1500);
        // Time-bounded grid burst: sweep every 120 ms for the first ~4 s
        // after load / nav, then stop. This catches V3's load-time feed
        // re-paints fast (so the "3-4 cycles" settle quickly and barely
        // register) WITHOUT a persistent observer that could loop forever.
        // Self-terminating + single-flight: it always ends.
        let _gridBurstTimer = null;
        function _burstGridSweep() {
            if (_gridBurstTimer) { clearInterval(_gridBurstTimer); _gridBurstTimer = null; }
            let elapsed = 0;
            try { _sweep(); } catch (_) {}
            _gridBurstTimer = setInterval(() => {
                try { _sweep(); } catch (_) {}
                elapsed += 120;
                if (elapsed >= 4000) { clearInterval(_gridBurstTimer); _gridBurstTimer = null; }
            }, 120);
        }
        if (document.readyState !== 'loading') _burstGridSweep();
        else document.addEventListener('DOMContentLoaded', _burstGridSweep);
        _onPoolReady(() => { _burstGridSweep(); _sidebarSweep(); _sweepPlayerRecommendations(); });

        // SPA navigation re-renders without a reload — restart the bounded
        // burst so replacements land fast after the new page paints.
        let _lastV3Poke = 0;
        let _poking = false;
        let _pokeTimeout1 = null;
        let _pokeTimeout2 = null;
        function _navSweepBurst() {
            if (_isHomeLikePath()) _burstHomeSpaFix();
            else _burstGridSweep();
            _pokeRetries = 0;
            if (_pokeTimeout1) clearTimeout(_pokeTimeout1);
            if (_pokeTimeout2) clearTimeout(_pokeTimeout2);
            _pokeTimeout1 = setTimeout(_maybePokeV3, 1200);
            _pokeTimeout2 = setTimeout(_maybePokeV3, 3000);
        }

        // Watch → home: V3 sometimes fails to render the top featured block;
        // the card SLOTS are missing so our sweep can't do anything (nothing
        // to sweep). Poke V3 by re-dispatching its own nav events. NOT a
        // page reload, the URL doesn't change. Guarded so it can't loop:
        // only on home, once per 8 s, retries up to 3 times.
        let _pokeRetries = 0;
        function _maybePokeV3() {
            try {
                if (!_checkV3()) return;
                const path = location.pathname;
                if (path !== '/' && path !== '') return;
                if (!active || !videoPool.length) return;
                if (Date.now() - _lastV3Poke < 8000) return;

                const hasFeatured = !!document.querySelector('.lohp-large-shelf-container, .lohp-medium-shelf');
                const cardCount = _findCards(document).length;
                if (hasFeatured && cardCount >= 5) { _pokeRetries = 0; return; }

                _lastV3Poke = Date.now();
                _poking = true;
                try { window.dispatchEvent(new CustomEvent('yt-navigate-start',  { detail: { pageType: 'home', url: location.href, _bygonePoke: true } })); } catch (_) {}
                try { window.dispatchEvent(new CustomEvent('yt-navigate-finish', { detail: { pageType: 'home', url: location.href, response: {}, _bygonePoke: true } })); } catch (_) {}
                try { window.dispatchEvent(new PopStateEvent('popstate', { state: history.state })); } catch (_) {}
                _poking = false;
                [400, 900, 1600, 2600].forEach(ms => setTimeout(() => { try { _sweep(); } catch (_) {} }, ms));

                _pokeRetries++;
                if (_pokeRetries < 3) {
                    setTimeout(_maybePokeV3, 3000);
                } else {
                    _pokeRetries = 0;
                }
            } catch (_) {}
        }
        window.addEventListener('yt-navigate-finish', (e) => {
            if (_poking || (e.detail && e.detail._bygonePoke)) return;
            startResponseScope();
            _navSweepBurst();
        });
        window.addEventListener('popstate', () => {
            if (_poking) return;
            startResponseScope();
            _resetCommentDrain();
            if (location.pathname.startsWith('/watch')) {
                setTimeout(_installCommentObserver, 200);
            } else if (_commentObs) {
                try { _commentObs.disconnect(); } catch (_) {}
                _commentObs = null;
                _commentObsRoot = null;
            }
            _navSweepBurst();
        });

        // Watch page poke — V3's SPA nav often leaves the watch page
        // metadata (channel, likes, description) empty. Detect this and
        // re-dispatch nav events to kick V3 into rendering. Guarded:
        // only on /watch, max 4 retries spaced 2s apart.
        let _watchPokeRetries = 0;
        let _lastWatchPoke = 0;
        function _visibleEnough(el) {
            if (!el) return false;
            try {
                const cs = getComputedStyle(el);
                if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
                const r = el.getBoundingClientRect();
                if (r.width <= 1 || r.height <= 1) return false;
            } catch (_) {}
            return true;
        }
        function _watchMetaState() {
            const ownerSelectors = [
                '#owner ytd-channel-name',
                '#owner #channel-name',
                '#upload-info ytd-channel-name',
                '#upload-info #channel-name',
                '#watch7-content .yt-user-name',
                '#watch-header .yt-user-name',
                '.watch-main-col .yt-user-name',
                '.watch-user-name',
                '.yt-user-info',
                'a[href^="/channel/UC"]'
            ];
            const actionSelectors = [
                '#top-level-buttons-computed',
                '#menu-container',
                '#menu ytd-toggle-button-renderer',
                'ytd-menu-renderer',
                '.watch-actions',
                '.watch-action-buttons',
                '#watch8-sentiment-actions',
                '.like-button-renderer',
                '.yt-uix-button-content'
            ];
            const firstVisible = (selectors) => {
                for (const sel of selectors) {
                    const els = document.querySelectorAll(sel);
                    for (const el of els) {
                        if (!_visibleEnough(el)) continue;
                        const text = (el.textContent || '').trim();
                        if (text || el.querySelector('button, a, img, yt-icon')) return el;
                    }
                }
                return null;
            };
            const owner = firstVisible(ownerSelectors);
            const actions = firstVisible(actionSelectors);
            return {
                owner: !!owner,
                actions: !!actions,
                ownerSel: owner ? (owner.tagName.toLowerCase() + (owner.id ? '#' + owner.id : '') + (owner.className ? '.' + String(owner.className).split(/\s+/)[0] : '')) : '',
                actionsSel: actions ? (actions.tagName.toLowerCase() + (actions.id ? '#' + actions.id : '') + (actions.className ? '.' + String(actions.className).split(/\s+/)[0] : '')) : ''
            };
        }
        try { window.__bygoneWatchDiag = _watchMetaState; } catch (_) {}
        try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow) unsafeWindow.__bygoneWatchDiag = _watchMetaState; } catch (_) {}
        function _maybePokeWatch() {
            try {
                if (!_checkV3()) return;
                if (!location.pathname.startsWith('/watch')) return;
                if (!active || !videoPool.length) return;
                if (Date.now() - _lastWatchPoke < 4000) return;

                const metaState = _watchMetaState();
                const hasMeta = metaState.owner && metaState.actions;
                if (hasMeta) { _watchPokeRetries = 0; return; }

                _lastWatchPoke = Date.now();
                try { console.log('[bygone] watch metadata missing; poking V3', metaState); } catch (_) {}
                _poking = true;
                try { window.dispatchEvent(new CustomEvent('yt-navigate-start',  { detail: { pageType: 'watch', url: location.href, _bygonePoke: true } })); } catch (_) {}
                try { window.dispatchEvent(new CustomEvent('yt-navigate-finish', { detail: { pageType: 'watch', url: location.href, response: {}, _bygonePoke: true } })); } catch (_) {}
                try { window.dispatchEvent(new PopStateEvent('popstate', { state: history.state })); } catch (_) {}
                _poking = false;

                _watchPokeRetries++;
                if (_watchPokeRetries < 4) {
                    setTimeout(_maybePokeWatch, 2000);
                } else {
                    _watchPokeRetries = 0;
                }
            } catch (_) {}
        }
        window.addEventListener('yt-navigate-finish', (e) => {
            if (e.detail && e.detail._bygonePoke) return;
            if (location.pathname.startsWith('/watch')) {
                _watchPokeRetries = 0;
                setTimeout(_maybePokeWatch, 1500);
                setTimeout(_maybePokeWatch, 3500);
            }
        });
        window.addEventListener('popstate', () => {
            if (_poking) return;
            if (location.pathname.startsWith('/watch')) {
                _watchPokeRetries = 0;
                setTimeout(_maybePokeWatch, 1500);
            }
        });

        // Sidebar MutationObserver — coalesced via rAF so V3's hundred-per-
        // second sidebar mutations collapse to one sweep per animation frame.
        let _sidebarSweepScheduled = false;
        function _scheduleSidebarSweep() {
            if (_sidebarSweepScheduled) return;
            _sidebarSweepScheduled = true;
            requestAnimationFrame(() => {
                _sidebarSweepScheduled = false;
                try { _sidebarSweep(); } catch (_) {}
            });
        }
        function _installSidebarObserver() {
            const target = document.getElementById('watch7-sidebar-contents')
                || document.getElementById('watch7-sidebar')
                || document.querySelector('#secondary');
            if (!target) { setTimeout(_installSidebarObserver, 1000); return; }
            if (_sidebarObs) { try { _sidebarObs.disconnect(); } catch (_) {} }
            _sidebarObsTarget = target;
            _sidebarObs = new MutationObserver(_scheduleSidebarSweep);
            _sidebarObs.observe(target, { childList: true, subtree: true });
        }
        if (document.readyState !== 'loading') _installSidebarObserver();
        else document.addEventListener('DOMContentLoaded', _installSidebarObserver);
        window.addEventListener('yt-navigate-finish', () => setTimeout(_installSidebarObserver, 500));
        window.addEventListener('popstate', () => setTimeout(_installSidebarObserver, 500));

        // Aggressive post-nav polling for the watch sidebar (V3 may re-render
        // it multiple times in the first few seconds). Single-flight.
        let _burstTimer = null;
        function _burstSidebarSweep() {
            if (_burstTimer) { clearInterval(_burstTimer); _burstTimer = null; }
            let elapsed = 0;
            _burstTimer = setInterval(() => {
                try { _sidebarSweep(); } catch (_) {}
                try { _sweepPlayerRecommendations(); } catch (_) {}
                elapsed += 200;
                if (elapsed >= 10000) { clearInterval(_burstTimer); _burstTimer = null; }
            }, 200);
        }
        window.addEventListener('yt-navigate-finish', _burstSidebarSweep);
        window.addEventListener('popstate', _burstSidebarSweep);
        if (location.pathname.startsWith('/watch')) {
            if (document.readyState !== 'loading') _burstSidebarSweep();
            else document.addEventListener('DOMContentLoaded', _burstSidebarSweep);
        }

        // ---- Click hijack ------------------------------------------
        // V3 binds click handlers in closures over the ORIGINAL videoId at
        // render time. Even if we rewrite href + the renderer's videoId
        // afterwards, V3's bubble-phase handler navigates to the original.
        // We capture BEFORE V3 sees the click and force navigation.

        function _findClickTarget(target) {
            let el = target, link = null, sweptCard = null, keepCard = null;
            for (let i = 0; i < 14 && el && el !== document.body; i++) {
                if (!link && el.tagName === 'A') {
                    const h = el.getAttribute('href') || '';
                    if (h.indexOf('/watch') !== -1 && h.indexOf('v=') !== -1) link = el;
                }
                if (!sweptCard && el.hasAttribute && el.hasAttribute('data-bygone-swept')) sweptCard = el;
                if (!keepCard && el.hasAttribute && el.hasAttribute('data-bygone-keep')) keepCard = el;
                el = el.parentElement;
            }
            return { link, sweptCard, keepCard };
        }

        function _resolveTarget(link, sweptCard, keepCard) {
            if (sweptCard) {
                const id = sweptCard.getAttribute('data-bygone-swept');
                if (id) return id;
            }
            if (!link) return null;
            const href = link.getAttribute('href') || '';
            const m = href.match(/[?&]v=([A-Za-z0-9_-]+)/);
            if (!m) return null;
            const origId = m[1];
            if (_poolIdsSet.has(origId)) return origId;
            // Kept naturally-old recommendation — navigate to the REAL
            // video, don't map it to a pool replacement.
            if (keepCard) return origId;
            const v = mapVideo(origId);
            return v ? v.id : null;
        }

        // True when the user is on the search-results page. Click hijack
        // must NOT rewrite link targets there — the results are genuine
        // YouTube search hits already date-bounded via `before:` in the
        // URL, so clicks should go to those real videos, not be remapped
        // into our pool.
        const _onResultsPage = () => {
            const p = location.pathname;
            return p === '/results' || p === '/results/';
        };

        function _isHomeNavClick(target) {
            if (!target || !target.closest) return false;
            const a = target.closest('a');
            const href = a && (a.getAttribute('href') || '');
            let homeHref = false;
            if (href) {
                try {
                    const u = new URL(href, location.origin);
                    homeHref = u.origin === location.origin && u.pathname === '/';
                } catch (_) {
                    homeHref = href === '/' || href.indexOf('/?') === 0;
                }
            }
            const logoHit = target.closest(
                '#logo, #logo-container, #masthead-logo-link, #logo-icon, ' +
                '.v3-logo, .yt-masthead-logo, .appbar-logo, ytd-topbar-logo-renderer, ' +
                'a[title="YouTube"], a[aria-label="YouTube"], a[aria-label="Home"]'
            );
            return homeHref || !!logoHit;
        }

        function _scheduleHomeSpaFixFromClick() {
            [80, 220, 500, 1000, 1800].forEach(ms => setTimeout(() => {
                if (_isHomeLikePath()) _burstHomeSpaFix();
            }, ms));
        }

        document.addEventListener('click', function (e) {
            if (e.defaultPrevented || _userPaused) return;
            const playerRecTarget = _playerRecClickTarget(e.target);
            if (playerRecTarget && active && videoPool.length) {
                e.preventDefault();
                e.stopImmediatePropagation();
                // Synthetic player clicks are autoplay/internal navigation.
                // Blocking them removes native autoplay instead of letting a
                // modern YouTube recommendation leak through.
                if (!e.isTrusted) return;
                const orig = _playerRecVideoId(playerRecTarget);
                const video = _pickPlayerPoolVideo(orig);
                if (video) _navigateToPoolVideo(video);
                return;
            }
            // Only act on REAL user clicks. YouTube/V3 dispatch SYNTHETIC clicks
            // for autoplay advance, end-screen cards and SPA prefetch; turning
            // those into a full `location.href` load can cause a reload loop on
            // the watch page. Untrusted clicks fall through to YouTube's own
            // handling — and since the sweep already rewrites link hrefs to the
            // pool video, navigation still lands on the right video. No feature
            // is lost; only the synthetic-click → forced-reload path is cut.
            if (!e.isTrusted) return;
            if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
            if (_isHomeNavClick(e.target)) {
                _scheduleHomeSpaFixFromClick();
                return;
            }
            if (!active || !videoPool.length) return;

            // On search results: don't remap video IDs, but DO force a
            // full page load so V3's broken SPA nav doesn't eat the watch
            // page metadata (channel, likes, description).
            if (_onResultsPage()) {
                const { link } = _findClickTarget(e.target);
                if (!link) return;
                const href = link.getAttribute('href') || '';
                if (href.indexOf('/watch') === -1) return;
                e.preventDefault();
                e.stopImmediatePropagation();
                location.href = location.origin + href;
                return;
            }

            const { link, sweptCard, keepCard } = _findClickTarget(e.target);
            if (!sweptCard && !link) return;
            const targetId = _resolveTarget(link, sweptCard, keepCard);
            if (!targetId) return;
            e.preventDefault();
            e.stopImmediatePropagation();
            try {
                const pv = videoPool.find(v => v.id === targetId);
                if (pv) {
                    Store.addClickEvent({
                        videoId: targetId, channelId: pv.channelId || null,
                        channel: pv.channel || '', title: pv.title || '',
                        source: pv.source || 'unknown', ts: Date.now(),
                    });
                    Store.markFeedClicked(targetId);
                    Store.recordSourceClick(pv.source || 'unknown');
                }
            } catch (_) {}
            location.href = location.origin + '/watch?v=' + targetId;
        }, true);

        // Middle-click / ctrl-click: rewrite href so the browser-native
        // new-tab open lands on the correct video.
        document.addEventListener('auxclick', function (e) {
            if (!active || _userPaused || !videoPool.length) return;
            if (_onResultsPage()) return;
            if (e.button !== 1) return;
            const { link, sweptCard, keepCard } = _findClickTarget(e.target);
            const targetId = _resolveTarget(link, sweptCard, keepCard);
            if (!targetId || !link) return;
            const href = link.getAttribute('href') || '';
            link.setAttribute('href', href.replace(/([?&]v=)[A-Za-z0-9_-]+/, '$1' + targetId));
        }, true);

        return {
            setVideos,
            appendVideos,
            clearPool,
            removeVideos,
            forceResweep,
            setPaused,
            isPaused: () => _userPaused,
            setLazyFetcher,
            isActive: () => active,
            poolSize: () => videoPool.length,
            usedCount: () => _usedReplacements.size,
            origFetch: _origFetch,
            sweep: _sweep,
            mapVideo,
            getPoolVideo: (id) => videoPool.find(v => v.id === id) || null,
            getPoolIds: () => new Set(_poolIdsSet),
            getPool: () => videoPool,
            isKeptNatural: (id) => _keptNaturalIds.has(id),
            rewriteCard: _rewriteCard,
            findCards: _findCards,
            refreshAllDates,
            isChannelPage: _isChannelPage,
        };
    })();

    // ============================================================
    //  CONFIG + VERSION
    // ============================================================

    const VERSION = SCRIPT_VERSION;

    const CONFIG = {
        // maxSearchPages: how many continuation pages to walk per search query.
        // The date filter drops a large share of each ~20-result page, so
        // paging is what lets one era query yield well beyond a single page.
        api: { maxResults: 25, cooldownMs: 250, maxSearchPages: 5 },
        feed: {
            dateWindowDays: 7,
            // No forward grace on the video feed — only past-or-on-set-date
            // uploads. (Comments have a separate 2-year cutoff applied in
            // _commentCutoff; that one is intentional and stays.)
            futureGraceDays: 0,
            maxHomepageVideos: 300,
            weights: {
                subscriptions: 0.27,
                searchTerms:   0.15,
                categories:    0.17,
                topics:        0.09,
                similar:       0.15,
                trending:      0.17,
            },
        },
        cache: {
            subscriptions: 14400000,    // 4h
            searchTerms:    7200000,    // 2h
            categories:     7200000,
            topics:         7200000,
            similar:        3600000,
            trending:       1800000,
        },
        defaultGlobalNegatives: ['for kids', 'nursery rhymes', 'cocomelon', 'baby shark'],
        installUrls: {
            v3: 'https://vorapis.pages.dev/#/home/download',
            starTube: 'https://greatest.deepsurf.us/scripts/485622-startube',
        },
        update: {
            scriptPage: 'https://greatest.deepsurf.us/en/scripts/580843-bygone-yt',
            metaUrl: 'https://update.greatest.deepsurf.us/scripts/580843/bygone-yt.meta.js',
            userUrl: 'https://update.greatest.deepsurf.us/scripts/580843/bygone-yt.user.js',
            checkIntervalMs: 6 * 60 * 60 * 1000,
            alertRepeatMs: 24 * 60 * 60 * 1000,
        },
        categories: {
            1: 'Film & Animation', 2: 'Autos & Vehicles', 10: 'Music',
            15: 'Pets & Animals', 17: 'Sports', 19: 'Travel & Events',
            20: 'Gaming', 22: 'People & Blogs', 23: 'Comedy',
            24: 'Entertainment', 25: 'News & Politics', 26: 'How-to & Style',
            27: 'Education', 28: 'Science & Technology',
        },
        discoveryQueries: [
            '', 'music video', 'trailer', 'funny', 'review', 'highlights',
            'how to', 'compilation', 'reaction', 'vlog', 'tutorial', 'news',
            'challenge', 'unboxing', 'animation', 'top 10', 'best of', 'cover',
            'remix', 'documentary', 'interview', 'gameplay', 'montage',
        ],
    };

    // ============================================================
    //  STORE — GM_* persistence + profiles + rolling clock
    // ============================================================

    class Store {
        static _migrated = false;
        static _migrateFromWbt() {
            if (this._migrated) return;
            this._migrated = true;
            try {
                if (GM_getValue('bygone_migrated', false)) return;
                const allKeys = GM_listValues();
                let found = 0;
                for (const k of allKeys) {
                    if (!k.startsWith('wbt_')) continue;
                    const newKey = 'bygone_' + k.slice(4);
                    if (GM_getValue(newKey, undefined) !== undefined) continue;
                    const val = GM_getValue(k, undefined);
                    if (val !== undefined) { GM_setValue(newKey, val); found++; }
                }
                GM_setValue('bygone_migrated', true);
                if (found) console.log('[bygone] migrated', found, 'keys from wbt_ → bygone_');
            } catch (e) { console.warn('[bygone] migration error', e); }
        }

        static _get(k, d) {
            this._migrateFromWbt();
            try { const r = GM_getValue(k, undefined); if (r === undefined) return d; return typeof r === 'string' ? JSON.parse(r) : r; }
            catch { return d; }
        }
        static _set(k, v) { GM_setValue(k, JSON.stringify(v)); }
        static _del(k) { GM_deleteValue(k); }
        static _has(o, k) { return Object.prototype.hasOwnProperty.call(o || {}, k); }
        static _parseImportJson(json) {
            const text = String(json == null ? '' : json).replace(/^\uFEFF/, '');
            const data = JSON.parse(text);
            if (!data || typeof data !== 'object') throw new Error('Import file is not a JSON object');
            return data;
        }
        static defaultEraDate() {
            const now = new Date();
            const month = now.getMonth();
            let day = now.getDate();
            if (month === 1 && day === 29) day = 28; // 2014 is not a leap year.
            return this._formatLocalDate(new Date(2014, month, day));
        }
        static _legacyFiveYearDefaultDate() {
            const d = new Date();
            d.setFullYear(d.getFullYear() - 5);
            return this._formatLocalDate(d);
        }
        static shouldUpgradeLegacyDefaultDate(dateStr) {
            if (!dateStr) return true;
            if (dateStr === this._legacyFiveYearDefaultDate()) return true;
            const m = String(dateStr).match(/^(\d{4})-(\d{2})-(\d{2})$/);
            if (!m) return false;
            const now = new Date();
            if (+m[1] !== now.getFullYear() - 5) return false;
            const oldDefault = this._parseLocalDate(this._legacyFiveYearDefaultDate());
            const candidate = this._parseLocalDate(dateStr);
            return Math.abs(candidate.getTime() - oldDefault.getTime()) <= 10 * 86400000;
        }

        // Selected date
        static getDate()           { return this._get('bygone_date', null); }
        static setDate(d)          { this._set('bygone_date', d); }

        // Sources
        static getSubscriptions()  { return this._get('bygone_subscriptions', []); }
        static setSubscriptions(s) { this._set('bygone_subscriptions', s); }
        static getSearchTerms()    { return this._get('bygone_search_terms', []); }
        static setSearchTerms(t)   { this._set('bygone_search_terms', t); }
        static getCategories()     { return this._get('bygone_categories', [20, 10, 24]); }
        static setCategories(c)    { this._set('bygone_categories', c); }
        static getTopics()         { return this._get('bygone_topics', []); }
        static setTopics(t)        { this._set('bygone_topics', t); }
        static getBlockedChannels(){ return this._get('bygone_blocked_channels', []); }
        static setBlockedChannels(b){ this._set('bygone_blocked_channels', b); }

        // Exact publish dates (ISO YYYY-MM-DD) keyed by video id, fetched from
        // /next so relative dates can be computed precisely against the set
        // date instead of guessed from year-granular strings. Persistent +
        // in-memory cached because recalcForFeed reads it per card per sweep.
        static _exactCache = null;
        static getExactDates()      { return this._get('bygone_exact_dates', {}); }
        static getExactDate(id) {
            if (!id) return null;
            if (!this._exactCache) this._exactCache = this.getExactDates();
            return this._exactCache[id] || null;
        }
        static addExactDates(map) {
            if (!map) return;
            if (!this._exactCache) this._exactCache = this.getExactDates();
            Object.assign(this._exactCache, map);
            this._set('bygone_exact_dates', this._exactCache);
        }

        // State
        static isActive()          { return this._get('bygone_active', true); }
        static setActive(v)        { this._set('bygone_active', v); }
        static hasSeenDependencyPrompt(){ return this._get('bygone_dependency_prompt_seen', false); }
        static markDependencyPromptSeen(){ this._set('bygone_dependency_prompt_seen', true); }
        static isDiscoveryEnabled(){ return this._get('bygone_discovery', true); }
        static setDiscoveryEnabled(v){ this._set('bygone_discovery', v); }
        static isSimilarEnabled()  { return this._get('bygone_similar_enabled', true); }
        static setSimilarEnabled(v){ this._set('bygone_similar_enabled', v); }
        static isLearningEnabled() { return this._get('bygone_learning', true); }
        static setLearningEnabled(v){ this._set('bygone_learning', v); }

        // Auto-sync bygone subscriptions to YouTube account
        static isAutoSyncSubs()    { return this._get('bygone_auto_sync_subs', true); }
        static setAutoSyncSubs(v)  { this._set('bygone_auto_sync_subs', v); }
        // Track which channel IDs we've already synced to YouTube so we
        // don't re-call subscribe on every panel render / page load.
        static getSyncedSubIds()   { return this._get('bygone_synced_sub_ids', []); }
        static setSyncedSubIds(ids){ this._set('bygone_synced_sub_ids', ids); }
        static markSubSynced(id) {
            if (!id) return;
            const ids = this.getSyncedSubIds();
            if (!ids.includes(id)) { ids.push(id); this.setSyncedSubIds(ids); }
        }

        // Global negatives
        static getGlobalNegatives()  { return this._get('bygone_global_negatives', CONFIG.defaultGlobalNegatives.slice()); }
        static setGlobalNegatives(v) { this._set('bygone_global_negatives', v); }

        // Hidden videos
        static getHiddenIds()      { return this._get('bygone_hidden_ids', []); }
        static setHiddenIds(ids)   { this._set('bygone_hidden_ids', ids); }
        static hideVideoId(id) {
            const ids = this.getHiddenIds();
            if (!ids.includes(id)) {
                ids.push(id);
                if (ids.length > 2000) ids.splice(0, ids.length - 2000);
                this.setHiddenIds(ids);
            }
        }

        // Profiles
        static getProfiles()       { return this._get('bygone_profiles', {}); }
        static setProfiles(p)      { this._set('bygone_profiles', p); }
        static saveProfile(name) {
            const profiles = this.getProfiles();
            profiles[name] = {
                date: this.getDate(),
                subscriptions: this.getSubscriptions(),
                searchTerms: this.getSearchTerms(),
                categories: this.getCategories(),
                topics: this.getTopics(),
                blockedChannels: this.getBlockedChannels(),
                customLogo: this.getCustomLogo(),
                discovery: this.isDiscoveryEnabled(),
                similar: this.isSimilarEnabled(),
                learning: this.isLearningEnabled(),
                globalNegatives: this.getGlobalNegatives(),
                savedAt: Date.now(),
            };
            this.setProfiles(profiles);
        }
        // Create a fresh, empty profile (clean-install defaults) rather than
        // snapshotting the current state. Keeps the current era date so the
        // blank profile is immediately usable; everything else starts empty.
        static createBlankProfile(name) {
            const profiles = this.getProfiles();
            profiles[name] = {
                date: this.getDate() || '',
                subscriptions: [],
                searchTerms: [],
                categories: [20, 10, 24],
                topics: [],
                blockedChannels: [],
                customLogo: '',
                discovery: true,
                similar: true,
                learning: true,
                globalNegatives: CONFIG.defaultGlobalNegatives.slice(),
                savedAt: Date.now(),
            };
            this.setProfiles(profiles);
        }
        static loadProfile(name) {
            const p = this.getProfiles()[name];
            if (!p) return false;
            if (p.date)             this.setDate(p.date);
            if (p.subscriptions)    this.setSubscriptions(p.subscriptions);
            if (p.searchTerms)      this.setSearchTerms(p.searchTerms);
            if (p.categories)       this.setCategories(p.categories);
            if (p.topics)           this.setTopics(p.topics);
            if (p.blockedChannels)  this.setBlockedChannels(p.blockedChannels);
            if (p.discovery !== undefined) this.setDiscoveryEnabled(p.discovery);
            if (p.similar   !== undefined) this.setSimilarEnabled(p.similar);
            if (p.learning  !== undefined) this.setLearningEnabled(p.learning);
            if (p.globalNegatives)  this.setGlobalNegatives(p.globalNegatives);
            if (p.customLogo)       this.setCustomLogo(p.customLogo);
            else                    this.clearCustomLogo();
            this.stopClock();
            return true;
        }
        static deleteProfile(name) {
            const profiles = this.getProfiles();
            delete profiles[name];
            this.setProfiles(profiles);
        }
        static exportProfile(name) {
            const p = this.getProfiles()[name];
            return p ? JSON.stringify({ name, ...p }, null, 2) : null;
        }
        static importProfile(json) {
            const data = this._parseImportJson(json);
            const name = data.name;
            if (!name) throw new Error('Profile has no name');
            delete data.name;
            const profiles = this.getProfiles();
            profiles[name] = data;
            this.setProfiles(profiles);
            return name;
        }

        static exportAll() {
            return JSON.stringify({
                _bygone_export: true,
                _version: VERSION,
                _exportedAt: Date.now(),
                date: this.getDate(),
                active: this.isActive(),
                subscriptions: this.getSubscriptions(),
                searchTerms: this.getSearchTerms(),
                categories: this.getCategories(),
                topics: this.getTopics(),
                blockedChannels: this.getBlockedChannels(),
                exactDates: this.getExactDates(),
                globalNegatives: this.getGlobalNegatives(),
                hiddenIds: this.getHiddenIds(),
                customLogo: this.getCustomLogo(),
                kioskDarkMode: this.getKioskDarkMode(),
                kioskZoom: this.getKioskZoom(),
                discovery: this.isDiscoveryEnabled(),
                similar: this.isSimilarEnabled(),
                learning: this.isLearningEnabled(),
                autoSyncSubs: this.isAutoSyncSubs(),
                syncedSubIds: this.getSyncedSubIds(),
                profiles: this.getProfiles(),
                clockActive: this.isClockActive(),
                clockRealStart: this.getClockRealStart(),
                clockSimStart: this.getClockSimStart(),
                timeOffset: this.getTimeOffset(),
                watchHistory: this.getWatchHistory(),
                cachedInterests: this._get('bygone_cached_interests', null),
                loadCount: this.getLoadCount(),
                dislikes: this.getDislikes(),
                impressions: this.getImpressions(),
                seenIds: this.getSeenIds(),
                clickEvents: this.getClickEvents(),
                feedImpressions: this.getFeedImpressions(),
                searchHistory: this.getSearchHistory(),
                sourceStats: this._get('bygone_source_stats', {}),
                sourceOrder: this.getSourceOrder(),
            }, null, 2);
        }
        static importAll(json) {
            const d = this._parseImportJson(json);
            if (!d._bygone_export) throw new Error('Not a bygone-yt full export');
            const arr = v => Array.isArray(v) ? v : [];
            const obj = v => (v && typeof v === 'object' && !Array.isArray(v)) ? v : {};
            if (this._has(d, 'date'))            this.setDate(d.date || null);
            if (this._has(d, 'active'))          this.setActive(!!d.active);
            if (this._has(d, 'subscriptions'))   this.setSubscriptions(arr(d.subscriptions));
            if (this._has(d, 'searchTerms'))     this.setSearchTerms(arr(d.searchTerms));
            if (this._has(d, 'categories'))      this.setCategories(arr(d.categories));
            if (this._has(d, 'topics'))          this.setTopics(arr(d.topics));
            if (this._has(d, 'blockedChannels')) this.setBlockedChannels(arr(d.blockedChannels));
            if (this._has(d, 'exactDates'))      { this._set('bygone_exact_dates', obj(d.exactDates)); this._exactCache = null; }
            if (this._has(d, 'globalNegatives')) this.setGlobalNegatives(arr(d.globalNegatives));
            if (this._has(d, 'hiddenIds'))       this.setHiddenIds(arr(d.hiddenIds));
            if (this._has(d, 'customLogo'))      d.customLogo ? this.setCustomLogo(d.customLogo) : this.clearCustomLogo();
            if (this._has(d, 'kioskDarkMode'))   this.setKioskDarkMode(!!d.kioskDarkMode);
            if (this._has(d, 'kioskZoom'))       this.setKioskZoom(d.kioskZoom);
            if (this._has(d, 'discovery'))       this.setDiscoveryEnabled(!!d.discovery);
            if (this._has(d, 'similar'))         this.setSimilarEnabled(!!d.similar);
            if (this._has(d, 'learning'))        this.setLearningEnabled(!!d.learning);
            if (this._has(d, 'autoSyncSubs'))    this.setAutoSyncSubs(!!d.autoSyncSubs);
            if (this._has(d, 'syncedSubIds'))    this.setSyncedSubIds(arr(d.syncedSubIds));
            if (this._has(d, 'profiles'))        this.setProfiles(obj(d.profiles));
            if (this._has(d, 'clockActive'))     this.setClockActive(!!d.clockActive);
            if (this._has(d, 'clockRealStart'))  this.setClockRealStart(Number(d.clockRealStart) || 0);
            if (this._has(d, 'clockSimStart'))   this.setClockSimStart(Number(d.clockSimStart) || 0);
            if (this._has(d, 'timeOffset'))      this.setTimeOffset(Number(d.timeOffset) || 0);
            if (this._has(d, 'watchHistory'))    this.setWatchHistory(arr(d.watchHistory));
            if (this._has(d, 'cachedInterests')) {
                if (d.cachedInterests == null) this._del('bygone_cached_interests');
                else this._set('bygone_cached_interests', d.cachedInterests);
            }
            if (this._has(d, 'loadCount'))       this._set('bygone_load_count', Number(d.loadCount) || 0);
            if (this._has(d, 'dislikes'))        this.setDislikes(obj(d.dislikes));
            if (this._has(d, 'impressions'))     this.setImpressions(obj(d.impressions));
            if (this._has(d, 'seenIds'))         this.setSeenIds(arr(d.seenIds));
            if (this._has(d, 'clickEvents'))     this.setClickEvents(arr(d.clickEvents));
            if (this._has(d, 'feedImpressions')) this.setFeedImpressions(obj(d.feedImpressions));
            if (this._has(d, 'searchHistory'))   this.setSearchHistory(arr(d.searchHistory));
            if (this._has(d, 'sourceStats'))     this.setSourceStats(obj(d.sourceStats));
            if (this._has(d, 'sourceOrder'))     this.setSourceOrder(arr(d.sourceOrder));
            if (!this._has(d, 'exactDates')) this._exactCache = null;
            return { version: d._version || null };
        }

        // Custom logo
        static getCustomLogo()     { return this._get('bygone_custom_logo', null); }
        static setCustomLogo(d)    { this._set('bygone_custom_logo', d); }
        static clearCustomLogo()   { this._del('bygone_custom_logo'); }

        // APK page look controls. These use page localStorage so the APK's
        // built-in WebExtension and this userscript share one setting source.
        static _pageLocalStorage() {
            try {
                if (typeof unsafeWindow !== 'undefined' && unsafeWindow.localStorage) return unsafeWindow.localStorage;
            } catch {}
            try { return localStorage; } catch { return null; }
        }
        static _lsGet(k, d) {
            const ls = this._pageLocalStorage();
            if (!ls) return d;
            try { const v = ls.getItem(k); return v === null ? d : v; } catch { return d; }
        }
        static _lsSet(k, v) {
            const ls = this._pageLocalStorage();
            if (!ls) return;
            try { ls.setItem(k, String(v)); } catch {}
        }
        static getHideFab()  { return this._get('bygone_hide_fab', false); }
        static setHideFab(v) { this._set('bygone_hide_fab', !!v); }

        static getKioskDarkMode() { return this._lsGet('bygone_kiosk_dark', '0') !== '0'; }
        static setKioskDarkMode(v){ this._lsSet('bygone_kiosk_dark', v ? '1' : '0'); }
        static getKioskZoom() {
            const n = Number(this._lsGet('bygone_kiosk_zoom', '1.35'));
            if (!Number.isFinite(n)) return 1.35;
            return Math.max(0.9, Math.min(1.65, n));
        }
        static setKioskZoom(v) {
            const n = Number(v);
            this._lsSet('bygone_kiosk_zoom', Math.max(0.9, Math.min(1.65, Number.isFinite(n) ? n : 1.35)));
        }

        // Rolling clock — sim time = simStart + (Date.now() - realStart), i.e.
        // it advances exactly 1:1 with real elapsed wall-time from a single
        // saved anchor (arm at 2014-05-02, reopen a week later → 2014-05-09).
        // Date.now() is RAW (no offset) so the world-time sync can't skew the
        // progression. Default ON and still toggleable; the anchor persists
        // across reloads and is NEVER silently re-set, so the rate can't drift.
        static isClockActive()     { return this._get('bygone_clock_active', true); }
        static setClockActive(v)   { this._set('bygone_clock_active', v); }
        static getClockRealStart() { return this._get('bygone_clock_real_start', 0); }
        static setClockRealStart(t){ this._set('bygone_clock_real_start', t); }
        static getClockSimStart()  { return this._get('bygone_clock_sim_start', 0); }
        static setClockSimStart(t) { this._set('bygone_clock_sim_start', t); }
        static getTimeOffset()     { return this._get('bygone_time_offset', 0); }
        static setTimeOffset(v)    { this._set('bygone_time_offset', v); }

        // Parse YYYY-MM-DD as LOCAL midnight (not UTC — `new Date('2010-01-15')`
        // is UTC midnight, the wrong day for non-UTC users).
        static _parseLocalDate(s) {
            if (!s) return new Date();
            const m = String(s).match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
            return m ? new Date(+m[1], +m[2] - 1, +m[3]) : new Date(s);
        }
        static _formatLocalDate(d) {
            const y = d.getFullYear();
            const m = String(d.getMonth() + 1).padStart(2, '0');
            const dy = String(d.getDate()).padStart(2, '0');
            return `${y}-${m}-${dy}`;
        }

        // Arm the rolling clock if it's active but has no anchor yet — fresh
        // install, default-on, or after a stop cleared the anchors. Anchors to
        // the current base date at the real wall-clock moment, so from here it
        // tracks real elapsed time 1:1 and never silently re-anchors.
        static _armClockIfNeeded() {
            if (!this.isClockActive()) return;
            if (this.getClockRealStart() && this.getClockSimStart()) return;
            const base = this.getDate();
            if (!base) return;
            this.setClockSimStart(this._parseLocalDate(base).getTime());
            this.setClockRealStart(Date.now());
        }
        static getCurrentDate() {
            if (this.isClockActive()) {
                this._armClockIfNeeded();
                const rs = this.getClockRealStart();
                const ss = this.getClockSimStart();
                if (rs && ss) return this._formatLocalDate(new Date(ss + (Date.now() - rs)));
            }
            return this.getDate();
        }
        static getCurrentDateTime() {
            if (this.isClockActive()) {
                this._armClockIfNeeded();
                const rs = this.getClockRealStart();
                const ss = this.getClockSimStart();
                if (rs && ss) return new Date(ss + (Date.now() - rs));
            }
            const d = this.getDate();
            return d ? this._parseLocalDate(d) : new Date();
        }
        // Arm/re-arm at an explicit date: sim time = that date as of right now,
        // then it rolls forward 1:1 with real elapsed wall-time.
        static startClock(dateStr) {
            const base = dateStr || this.getDate();
            if (base) this.setDate(base);
            this.setClockActive(true);
            this.setClockSimStart(this._parseLocalDate(base).getTime());
            this.setClockRealStart(Date.now());
        }
        // Freeze at the current advanced date and clear the anchors, so a later
        // re-arm starts cleanly from the frozen date with no stale offset.
        static stopClock() {
            const cur = this.getCurrentDate();
            this.setClockActive(false);
            this.setDate(cur);
            this.setClockRealStart(0);
            this.setClockSimStart(0);
        }

        // Watch-history learning
        static getWatchHistory()   { return this._get('bygone_watch_history', []); }
        static setWatchHistory(h)  { this._set('bygone_watch_history', h); }
        static addWatchEvent(ev) {
            const h = this.getWatchHistory();
            if (h.some(e => e.videoId === ev.videoId && (ev.ts - e.ts) < 300000)) return;
            h.push(ev);
            const cutoff = Date.now() - (60 * 86400000);
            const pruned = h.filter(e => e.ts > cutoff);
            if (pruned.length > 200) pruned.splice(0, pruned.length - 200);
            this.setWatchHistory(pruned);
            this._del('bygone_cached_interests');
        }
        static getCachedInterests() {
            const cached = this._get('bygone_cached_interests', null);
            if (cached) return cached;
            const i = InterestModel.compute();
            this._set('bygone_cached_interests', i);
            return i;
        }
        static clearLearningData() {
            this._del('bygone_watch_history');
            this._del('bygone_cached_interests');
            this._del('bygone_load_count');
        }
        static getLoadCount()      { return this._get('bygone_load_count', 0); }
        static incrementLoadCount(){ const c = this.getLoadCount() + 1; this._set('bygone_load_count', c); return c; }

        // Dislike signal — blocks a channel and pushes its keywords down.
        static getDislikes()       { return this._get('bygone_dislikes', { channels: {}, keywords: {} }); }
        static setDislikes(d)      { this._set('bygone_dislikes', d); }
        static recordDislike({ channelId, title }) {
            const d = this.getDislikes();
            if (channelId) d.channels[channelId] = (d.channels[channelId] || 0) + 2;
            if (title) {
                const stop = new Set(['the','a','an','in','on','at','to','for','of','and','or','is','it','my','we','i','you','this','that','with','from','by']);
                const words = title.replace(/[^\w\s]/g, '').split(/\s+/)
                    .filter(w => w.length > 2 && !stop.has(w.toLowerCase()));
                for (const w of words.slice(0, 5)) {
                    const k = w.toLowerCase();
                    d.keywords[k] = (d.keywords[k] || 0) + 1;
                }
            }
            this.setDislikes(d);
            this._del('bygone_cached_interests');
        }

        static getImpressions()    { return this._get('bygone_impressions', {}); }
        static setImpressions(i)   { this._set('bygone_impressions', i); }
        static recordImpressions(videoIds) {
            const store = this.getImpressions();
            const fi = this.getFeedImpressions();
            const clicks = this.getClickEvents();
            const now = Date.now();
            const PARK_MS_COLD      = 2 * 86400000;
            const PARK_MS_CLICKED   = 5 * 86400000;
            const THRESHOLD_COLD    = 5;
            const THRESHOLD_CLICKED = 15;
            const clickCounts = {};
            for (const ev of clicks) {
                clickCounts[ev.videoId] = (clickCounts[ev.videoId] || 0) + 1;
            }
            for (const id of videoIds) {
                if (!store[id]) store[id] = { count: 0, hiddenUntil: 0, clicks: 0 };
                const row = store[id];
                if (row.hiddenUntil && row.hiddenUntil <= now) { row.count = 0; row.hiddenUntil = 0; }
                row.count++;
                const userClicks = clickCounts[id] || 0;
                const fiClicked = fi[id] && fi[id].clicked;
                row.clicks = userClicks;
                if (userClicks >= 3 || fiClicked) {
                    if (row.count >= THRESHOLD_CLICKED) {
                        row.hiddenUntil = now + PARK_MS_CLICKED;
                        row.count = 0;
                    }
                } else {
                    if (row.count >= THRESHOLD_COLD) {
                        row.hiddenUntil = now + PARK_MS_COLD;
                        row.count = 0;
                    }
                }
            }
            const keys = Object.keys(store);
            if (keys.length > 5000) {
                const dormant = keys.filter(k => !store[k].hiddenUntil && store[k].count === 0);
                for (const k of dormant.slice(0, keys.length - 4000)) delete store[k];
            }
            this.setImpressions(store);
        }
        static isImpressionHidden(id) {
            const row = this.getImpressions()[id];
            if (!row || !row.hiddenUntil) return false;
            return row.hiddenUntil > Date.now();
        }

        // Seen videos (push to back of feed across refreshes)
        static getSeenIds()        { return this._get('bygone_seen_ids', []); }
        static setSeenIds(ids)     { this._set('bygone_seen_ids', ids); }
        static addSeenIds(newIds) {
            const ids = this.getSeenIds();
            for (const id of newIds) if (!ids.includes(id)) ids.push(id);
            if (ids.length > 300) ids.splice(0, ids.length - 300);
            this.setSeenIds(ids);
        }

        // Click events (source-attributed engagement signal)
        static getClickEvents()    { return this._get('bygone_click_events', []); }
        static setClickEvents(e)   { this._set('bygone_click_events', e); }
        static addClickEvent(ev) {
            const events = this.getClickEvents();
            if (events.some(e => e.videoId === ev.videoId && (ev.ts - e.ts) < 300000)) return;
            events.push(ev);
            const cutoff = Date.now() - (90 * 86400000);
            const pruned = events.filter(e => e.ts > cutoff);
            if (pruned.length > 500) pruned.splice(0, pruned.length - 500);
            this.setClickEvents(pruned);
        }

        // Feed impressions (tracks shown-but-not-clicked for negative signals)
        static getFeedImpressions()    { return this._get('bygone_feed_impressions', {}); }
        static setFeedImpressions(fi)  { this._set('bygone_feed_impressions', fi); }
        static recordFeedImpressions(videos) {
            const store = this.getFeedImpressions();
            const now = Date.now();
            for (const v of videos) {
                if (!v || !v.id) continue;
                if (!store[v.id]) {
                    store[v.id] = {
                        impressions: 0, clicked: false,
                        channelId: v.channelId || null, channel: v.channel || '',
                        title: v.title || '', source: v.source || '',
                        firstSeen: now, lastSeen: now,
                    };
                }
                store[v.id].impressions++;
                store[v.id].lastSeen = now;
            }
            const keys = Object.keys(store);
            if (keys.length > 3000) {
                const sorted = keys.filter(k => !store[k].clicked)
                    .sort((a, b) => store[a].lastSeen - store[b].lastSeen);
                for (const k of sorted.slice(0, keys.length - 2500)) delete store[k];
            }
            this.setFeedImpressions(store);
        }
        static markFeedClicked(videoId) {
            const store = this.getFeedImpressions();
            if (store[videoId]) { store[videoId].clicked = true; this.setFeedImpressions(store); }
        }

        // Search history (auto-learned search queries)
        static getSearchHistory()    { return this._get('bygone_search_history', []); }
        static setSearchHistory(h)   { this._set('bygone_search_history', h); }
        static addSearchQuery(query) {
            if (!query || query.length < 3) return;
            const clean = query.replace(/\s*before:\d{4}-\d{2}-\d{2}/g, '').trim();
            if (!clean) return;
            const h = this.getSearchHistory();
            if (h.some(e => e.query.toLowerCase() === clean.toLowerCase() && (Date.now() - e.ts) < 3600000)) return;
            h.push({ query: clean, ts: Date.now() });
            const cutoff = Date.now() - (180 * 86400000);
            const pruned = h.filter(e => e.ts > cutoff);
            if (pruned.length > 200) pruned.splice(0, pruned.length - 200);
            this.setSearchHistory(pruned);
        }

        // Source CTR stats (per-source impression/click counts)
        static getSourceStats() {
            const stats = this._get('bygone_source_stats', {});
            const now = Date.now();
            const DECAY_INTERVAL = 7 * 86400000;
            let needsWrite = false;
            for (const key of Object.keys(stats)) {
                const s = stats[key];
                if (s.lastUpdated && (now - s.lastUpdated) > DECAY_INTERVAL) {
                    s.impressions = Math.floor(s.impressions * 0.7);
                    s.clicks = Math.floor(s.clicks * 0.7);
                    s.lastUpdated = now;
                    needsWrite = true;
                    if (s.impressions < 5) { delete stats[key]; continue; }
                }
            }
            if (needsWrite) this._set('bygone_source_stats', stats);
            return stats;
        }
        static setSourceStats(s)   { this._set('bygone_source_stats', s); }
        static recordSourceImpression(source) {
            if (!source) return;
            const stats = this.getSourceStats();
            if (!stats[source]) stats[source] = { impressions: 0, clicks: 0, lastUpdated: Date.now() };
            stats[source].impressions++;
            stats[source].lastUpdated = Date.now();
            this.setSourceStats(stats);
        }
        static recordSourceClick(source) {
            if (!source) return;
            const stats = this.getSourceStats();
            if (!stats[source]) stats[source] = { impressions: 0, clicks: 0, lastUpdated: Date.now() };
            stats[source].clicks++;
            stats[source].lastUpdated = Date.now();
            this.setSourceStats(stats);
        }

        // Cache helpers
        static getCacheEntry(key, ttlMs) {
            const e = this._get(`bygone_cache_${key}`, null);
            if (!e) return null;
            if (Date.now() - e.ts > ttlMs) { this._del(`bygone_cache_${key}`); return null; }
            return e.data;
        }
        static setCacheEntry(key, data) { this._set(`bygone_cache_${key}`, { ts: Date.now(), data }); }

        // Source-source order (for drag-reorder support in UI)
        static getSourceOrder() {
            return this._get('bygone_source_order', ['subscriptions', 'searchTerms', 'topics', 'categories']);
        }
        static setSourceOrder(o) { this._set('bygone_source_order', o); }
    }

    // ============================================================
    //  DATE HELPER — relative-text ↔ Date
    // ============================================================

    class DateHelper {
        static _msMap = {
            year:   365.25 * 86400000,
            month:  30.44  * 86400000,
            week:   7      * 86400000,
            day:             86400000,
            hour:            3600000,
            minute:             60000,
            second:              1000,
        };

        static _stripStreamPrefix(text) {
            return String(text || '').replace(/^(?:Streamed\s+)+/i, '').trim();
        }

        static _streamPrefix(text) {
            return /^(?:Streamed\s+)+/i.test(text || '') ? 'Streamed ' : '';
        }

        static approxPublishDate(relativeText) {
            if (!relativeText) return null;
            const clean = this._stripStreamPrefix(relativeText);
            const m = clean.match(/(\d+)\s*(year|month|week|day|hour|minute|second)/i);
            if (!m) return null;
            return new Date(Date.now() - parseInt(m[1], 10) * (this._msMap[m[2].toLowerCase()] || 0));
        }

        // Precision slack for a relative-date string: "12 years ago" only
        // locates the publish date to within ±1 year, so any window check on
        // approxPublishDate must tolerate one full unit either side. Without
        // this the feed dies by calendar drift: once the real-world month/day
        // passes the anchor's, every era video's year-granular approx lands
        // just outside the 90-180d source windows and the client-side filters
        // drop 100% of results (all-zero sources, empty pool, no errors).
        static approxSlackMs(relativeText) {
            const clean = this._stripStreamPrefix(relativeText);
            const m = String(clean).match(/year|month|week|day|hour|minute|second/i);
            return m ? (this._msMap[m[0].toLowerCase()] || 0) : 0;
        }

        // Window check for an interval-valued relative date, in ONE parse
        // (the filter loops run this per video per page). The parsed point
        // is the NEWEST date the string supports (YouTube rounds the count
        // down), so the true date lies in [point - unit, point].
        // opts.dropUndated: unparseable text fails the check (callers that
        //   treat undated as likely-modern).
        // opts.strictBefore: no slack at the max edge — for sources with no
        //   server-side before: operator (/next related), where slack would
        //   admit the straddling bucket's post-set-date uploads.
        static inApproxWindow(relativeText, after, before, opts) {
            const o = opts || {};
            const clean = this._stripStreamPrefix(relativeText);
            const m = String(clean).match(/(\d+)\s*(year|month|week|day|hour|minute|second)/i);
            if (!m) return !o.dropUndated;
            const unitMs = this._msMap[m[2].toLowerCase()] || 0;
            const t = Date.now() - parseInt(m[1], 10) * unitMs;
            if (after && t < new Date(after).getTime() - unitMs) return false;
            if (before && t > new Date(before).getTime() + (o.strictBefore ? 0 : unitMs)) return false;
            return true;
        }

        static relativeToDate(publishDate, referenceDate, videoId) {
            const diffMs = new Date(referenceDate).getTime() - new Date(publishDate).getTime();
            if (diffMs < 0) {
                const h = videoId ? this._hash(videoId) : this._hash(String(publishDate));
                const d = (h % 13) + 1;
                return d === 1 ? '1 day ago' : `${d} days ago`;
            }
            const days = Math.floor(diffMs / 86400000);
            const years = Math.floor(days / 365.25);
            const months = Math.floor(days / 30.44);
            const weeks = Math.floor(days / 7);
            const hours = Math.floor(diffMs / 3600000);
            if (years  >= 1) return years  === 1 ? '1 year ago'  : `${years} years ago`;
            if (months >= 1) return months === 1 ? '1 month ago' : `${months} months ago`;
            if (weeks  >= 1) return weeks  === 1 ? '1 week ago'  : `${weeks} weeks ago`;
            if (days   >= 1) return days   === 1 ? '1 day ago'   : `${days} days ago`;
            if (hours  >= 1) return hours  === 1 ? '1 hour ago'  : `${hours} hours ago`;
            return '1 day ago';
        }

        // Parse YouTube's exact watch-page date text ("Mar 18, 2008",
        // "Premiered Mar 18, 2008", "Streamed live on Apr 5, 2007") to ISO.
        static parseExactDateText(text) {
            if (!text) return null;
            const t = text.replace(/^(Premiered|Streamed live on|Started streaming on|Uploaded on|Published on)\s+/i, '').trim();
            const d = new Date(t);
            if (isNaN(d.getTime())) return null;
            return d.toISOString().slice(0, 10);
        }

        static recalcRelative(innertubeText, setDateStr, videoId) {
            if (!setDateStr) return innertubeText || '';
            const prefix = this._streamPrefix(innertubeText);
            const exact = videoId ? Store.getExactDate(videoId) : null;
            if (exact) return prefix + this.relativeToDate(new Date(exact), setDateStr, videoId);
            if (!innertubeText) return '';
            const pub = this.approxPublishDate(innertubeText);
            if (!pub) return innertubeText;
            return prefix + this.relativeToDate(pub, setDateStr, videoId);
        }

        static _hash(str) {
            let h = 0;
            for (let i = 0; i < str.length; i++) { h = ((h << 5) - h) + str.charCodeAt(i); h |= 0; }
            return Math.abs(h);
        }

        static relativeToDateIfPast(publishDate, referenceDate) {
            const diffMs = new Date(referenceDate).getTime() - new Date(publishDate).getTime();
            if (diffMs < 0) return null;
            return this.relativeToDate(publishDate, referenceDate);
        }

        static _plausibleRecentFeedAge(innertubeText, setDateStr, videoId) {
            const clean = this._stripStreamPrefix(innertubeText);
            const m = clean.match(/^(\d+)\s*years?\s+ago/i);
            if (!m || !setDateStr) return '';
            const approx = this.approxPublishDate(innertubeText);
            const anchor = new Date(setDateStr).getTime();
            if (!approx || isNaN(anchor)) return '';
            if (Math.abs(anchor - approx.getTime()) > 455 * 86400000) return '';
            const h = this._hash((videoId || '') + '|' + clean + '|' + setDateStr);
            const bucket = h % 12;
            if (bucket < 2) {
                const d = (h % 24) + 3;
                return `${d} days ago`;
            }
            if (bucket < 5) {
                const w = (h % 6) + 2;
                return `${w} weeks ago`;
            }
            const mo = (h % 9) + 2;
            return `${mo} months ago`;
        }

        // Recalculate only when the source date supports it. Exact dates win;
        // ambiguous year-rounded feed text gets a temporary plausible recency
        // instead of leaking present-day text like "12 years ago".
        static recalcForFeed(innertubeText, setDateStr, videoId) {
            if (!setDateStr) return innertubeText || '';
            const prefix = this._streamPrefix(innertubeText);
            // Exact date known → compute the precise relative-to-set-date.
            const exact = videoId ? Store.getExactDate(videoId) : null;
            if (exact) return prefix + this.relativeToDate(new Date(exact), setDateStr, videoId);
            if (!innertubeText) return '';
            const pub = this.approxPublishDate(innertubeText);
            if (!pub) return innertubeText;
            const real = this.relativeToDateIfPast(pub, setDateStr);
            const plausible = real || this._plausibleRecentFeedAge(innertubeText, setDateStr, videoId);
            return prefix + (plausible || this._stripStreamPrefix(innertubeText) || innertubeText);
        }

        /*
        static recalcForFeedLegacyVariety(innertubeText, setDateStr, videoId) {
            const prefix = this._streamPrefix(innertubeText);
            // The relative string is year-granular, so a video within ~a year
            // of the set date can't be relativized precisely and otherwise
            // collapses to a fake "1 day ago" (negative diff from rounding).
            // The pool is date-bounded to roughly the ~6 months before the set
            // date, so spread these across a realistic recency window
            // (days → weeks → months) keyed off the id for stable variety —
            // instead of making every near-set-date video look brand new.
            return prefix + this._stripStreamPrefix(innertubeText);
        }
        */
    }

    // ============================================================
    //  INTEREST MODEL — channel/keyword scoring from watch history
    // ============================================================

    class InterestModel {
        static _YT_STOP = new Set([
            'official','video','full','new','part','episode','ep',
            'hd','4k','live','stream','clip','trailer','season',
            'ft','feat','vs','vol','remix','edit','reupload',
            'deleted','original','extended','version','subtitles',
        ]);
        static _STOP = new Set([
            'the','a','an','in','on','at','to','for','of','and','or','is','it',
            'my','we','i','you','this','that','with','from','by','be','as','are',
            'was','were','been','has','have','had','do','does','did','but','not',
            'so','if','no','yes',
        ]);

        static compute() {
            const watches = Store.getWatchHistory();
            const dislikes = Store.getDislikes();
            const now = Date.now();
            const channels = {};
            const keywords = {};
            for (const w of watches) {
                const ageDays = (now - w.ts) / 86400000;
                const decay = Math.pow(0.5, ageDays / 7);
                if (w.channelId) {
                    if (!channels[w.channelId]) channels[w.channelId] = { name: w.channel, score: 0 };
                    channels[w.channelId].score += decay;
                }
                if (w.title) {
                    const kws = w.title.replace(/[^\w\s]/g, '').split(/\s+/)
                        .filter(x => x.length > 2 && !this._STOP.has(x.toLowerCase()) && !this._YT_STOP.has(x.toLowerCase()));
                    for (const kw of kws.slice(0, 5)) {
                        const lower = kw.toLowerCase();
                        if (!keywords[lower]) keywords[lower] = { score: 0 };
                        keywords[lower].score += decay;
                    }
                }
            }
            for (const [id, p] of Object.entries(dislikes.channels || {})) if (channels[id]) channels[id].score -= p;
            for (const [kw, p] of Object.entries(dislikes.keywords || {})) if (keywords[kw]) keywords[kw].score -= p;
            try {
                const neg = InterestModel.computeNegativeSignals();
                for (const cc of neg.coldChannels) {
                    if (channels[cc.channelId]) channels[cc.channelId].score -= Math.min(cc.skipScore * 0.5, 3);
                }
                for (const kw of neg.autoNegKeywords) {
                    if (keywords[kw]) keywords[kw].score -= 1;
                }
            } catch (_) {}
            return { channels, keywords };
        }

        static getLearnedChannels(i) {
            return Object.entries(i.channels)
                .filter(([_, c]) => c.score >= 2)
                .sort((a, b) => b[1].score - a[1].score)
                .slice(0, 25)
                .map(([id, c]) => ({ channelId: id, name: c.name, score: c.score }));
        }
        static getLearnedKeywords(i) {
            return Object.entries(i.keywords)
                .filter(([_, k]) => k.score >= 3)
                .sort((a, b) => b[1].score - a[1].score)
                .slice(0, 15)
                .map(([kw, k]) => ({ keyword: kw, score: k.score }));
        }

        static computeNegativeSignals() {
            const fi = Store.getFeedImpressions();
            const channelSkips = {};
            const keywordSkips = {};
            for (const [_, data] of Object.entries(fi)) {
                if (data.impressions < 4) continue;
                const isSkip = !data.clicked;
                if (data.channelId) {
                    if (!channelSkips[data.channelId]) channelSkips[data.channelId] = { name: data.channel, shown: 0, clicked: 0 };
                    channelSkips[data.channelId].shown++;
                    if (!isSkip) channelSkips[data.channelId].clicked++;
                }
                if (isSkip && data.title) {
                    const words = data.title.replace(/[^\w\s]/g, '').split(/\s+/)
                        .filter(w => w.length > 2 && !this._STOP.has(w.toLowerCase()) && !this._YT_STOP.has(w.toLowerCase()));
                    for (const w of words.slice(0, 5)) {
                        const k = w.toLowerCase();
                        if (!keywordSkips[k]) keywordSkips[k] = { shown: 0 };
                        keywordSkips[k].shown++;
                    }
                }
            }
            const coldChannels = Object.entries(channelSkips)
                .filter(([_, c]) => c.shown >= 3 && c.clicked === 0)
                .map(([id, c]) => ({ channelId: id, name: c.name, skipScore: c.shown }));
            const autoNegKeywords = Object.entries(keywordSkips)
                .filter(([_, k]) => k.shown >= 3)
                .sort((a, b) => b[1].shown - a[1].shown)
                .slice(0, 20)
                .map(([kw]) => kw);
            return { coldChannels, autoNegKeywords };
        }

        static inferLanguageHints() {
            const watches = Store.getWatchHistory();
            const clicks = Store.getClickEvents();
            const allTitles = [...watches, ...clicks].map(e => e.title || '').filter(Boolean);
            let latin = 0, total = 0;
            for (const title of allTitles) {
                for (const ch of title) {
                    const cp = ch.codePointAt(0);
                    total++;
                    if (cp >= 0x0041 && cp <= 0x024F) latin++;
                }
            }
            const englishMarkers = /\b(the|and|for|with|this|that|from|have|will|your|about)\b/i;
            let englishScore = 0;
            for (const t of allTitles) if (englishMarkers.test(t)) englishScore++;
            const likelyEnglish = allTitles.length > 5 && (englishScore / allTitles.length) > 0.5;
            const latinDominant = total > 100 && (latin / total) > 0.8;
            return { latinDominant, likelyEnglish };
        }
    }

    // ============================================================
    //  YOUTUBE API — InnerTube (no API keys, uses page's auth cookies
    //  via the auth-trick: SAPISIDHASH header on GM_xmlhttpRequest).
    //  V3-only: always use GM_xmlhttpRequest. The page-fetch path is
    //  dropped because V3's Response patch would otherwise re-enter
    //  our interceptor on our own outbound calls.
    // ============================================================

    class YouTubeAPI {
        constructor() {
            this._lastRequest = 0;
            this._configCache = null;
            this._configCacheTs = 0;
        }

        _getCookie(name) {
            try {
                const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
                const m = win.document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
                return m ? decodeURIComponent(m[1]) : null;
            } catch { return null; }
        }

        async _getSapisidHash(origin) {
            const sapisid = this._getCookie('SAPISID') || this._getCookie('__Secure-3PAPISID');
            if (!sapisid) return null;
            const ts = Math.floor(Date.now() / 1000);
            try {
                const data = new TextEncoder().encode(`${ts} ${sapisid} ${origin}`);
                const buf = await crypto.subtle.digest('SHA-1', data);
                const hex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
                return `SAPISIDHASH ${ts}_${hex}`;
            } catch { return null; }
        }

        _getConfig() {
            if (this._configCache && Date.now() - this._configCacheTs < 30000) return this._configCache;
            let cfg = null;
            try {
                const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
                cfg = win.ytcfg && win.ytcfg.data_;
            } catch {}
            let fullContext = null;
            if (cfg && cfg.INNERTUBE_CONTEXT) {
                try { fullContext = JSON.parse(JSON.stringify(cfg.INNERTUBE_CONTEXT)); } catch {}
            }
            this._configCache = {
                apiKey: (cfg && cfg.INNERTUBE_API_KEY) || 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
                clientVersion: (cfg && cfg.INNERTUBE_CLIENT_VERSION) || '2.20260301.00.00',
                fullContext,
            };
            this._configCacheTs = Date.now();
            return this._configCache;
        }

        _buildContext(cfg) {
            return cfg.fullContext || { client: { clientName: 'WEB', clientVersion: cfg.clientVersion, hl: 'en', gl: 'US' } };
        }

        _postViaGM(url, body, headers) {
            return new Promise((resolve, reject) => {
                // Every feed source funnels through here, and callers swallow
                // rejections into empty arrays — so log each failure with the
                // endpoint + a response snippet, or transport breakage looks
                // identical to "no era videos found" (sources all zero).
                const ep = (url.split('?')[0].match(/youtubei\/v1\/(.+)$/) || [])[1] || url;
                const t0 = Date.now();
                const fail = (err, detail) => {
                    console.warn('[bygone] innertube ' + ep + ' failed after ' + (Date.now() - t0) + 'ms: ' +
                        err.message + (detail ? ' — ' + String(detail).slice(0, 160) : ''));
                    reject(err);
                };
                try {
                    GM_xmlhttpRequest({
                        method: 'POST', url, headers, data: JSON.stringify(body), timeout: 15000,
                        onload(res) {
                            if (res.status >= 200 && res.status < 300) {
                                try { resolve(JSON.parse(res.responseText)); }
                                catch { fail(new Error('Invalid JSON'), res.responseText); }
                            } else {
                                const err = new Error(`InnerTube HTTP ${res.status}`);
                                err.status = res.status;
                                fail(err, res.responseText);
                            }
                        },
                        onerror(res) { fail(new Error('Network error'), res && res.error); },
                        ontimeout() { fail(new Error('Timed out')); },
                    });
                } catch (e) { fail(e); } // GM_xmlhttpRequest itself missing/blocked
            });
        }

        async _rateLimit() {
            const wait = CONFIG.api.cooldownMs - (Date.now() - this._lastRequest);
            if (wait > 0) await new Promise(r => setTimeout(r, wait));
            this._lastRequest = Date.now();
        }

        async _post(endpoint, body) {
            await this._rateLimit();
            const cfg = this._getConfig();
            const url = `https://www.youtube.com/youtubei/v1/${endpoint}?key=${cfg.apiKey}&prettyPrint=false`;
            const fullBody = { context: this._buildContext(cfg), ...body };
            const headers = {
                'Content-Type': 'application/json',
                'X-YouTube-Client-Name': '1',
                'X-YouTube-Client-Version': cfg.clientVersion,
                'X-Origin': 'https://www.youtube.com',
                'Origin':   'https://www.youtube.com',
                'Referer':  'https://www.youtube.com/',
            };
            const auth = await this._getSapisidHash('https://www.youtube.com');
            if (auth) { headers['Authorization'] = auth; headers['X-Goog-AuthUser'] = '0'; }
            try { return await this._postViaGM(url, fullBody, headers); }
            catch (err) {
                if (err.status === 403 || (err.status >= 500 && err.status < 600)) {
                    this._configCache = null;
                    await new Promise(r => setTimeout(r, 1000));
                    const cfg2 = this._getConfig();
                    headers['X-YouTube-Client-Version'] = cfg2.clientVersion;
                    const auth2 = await this._getSapisidHash('https://www.youtube.com');
                    if (auth2) headers['Authorization'] = auth2;
                    fullBody.context = this._buildContext(cfg2);
                    return await this._postViaGM(`https://www.youtube.com/youtubei/v1/${endpoint}?key=${cfg2.apiKey}&prettyPrint=false`, fullBody, headers);
                }
                throw err;
            }
        }

        // ---- Parsers -----------------------------------------------

        _parseViewCount(t) {
            if (!t) return 0;
            const m = t.replace(/,/g, '').toLowerCase().match(/([\d.]+)\s*([kmb])?/);
            if (!m) return 0;
            const n = parseFloat(m[1]);
            return m[2] === 'b' ? n * 1e9 : m[2] === 'm' ? n * 1e6 : m[2] === 'k' ? n * 1e3 : n;
        }

        _parseSearchResults(data) {
            const out = [];
            const pushVideo = (v) => {
                if (!v || !v.videoId || !/^[A-Za-z0-9_-]{11}$/.test(v.videoId)) return;
                const viewText = v.viewCountText?.simpleText || v.viewCountText?.runs?.[0]?.text || '';
                out.push({
                    id: v.videoId,
                    title: v.title?.runs?.[0]?.text || '',
                    channel: v.ownerText?.runs?.[0]?.text || v.longBylineText?.runs?.[0]?.text || '',
                    channelId: v.ownerText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId || '',
                    thumbnail: v.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
                    viewCount: this._parseViewCount(viewText),
                    viewCountFormatted: viewText || '0 views',
                    relativeDate: v.publishedTimeText?.simpleText || '',
                    duration: v.lengthText?.simpleText
                        || v.lengthText?.accessibility?.accessibilityData?.label
                        || (v.thumbnailOverlays || []).map(o => o?.thumbnailOverlayTimeStatusRenderer?.text?.simpleText).filter(Boolean)[0]
                        || '',
                });
            };
            const scanItems = (items) => {
                for (const item of items || []) pushVideo(item.videoRenderer);
            };
            try {
                // Initial search response.
                const sections = data?.contents?.twoColumnSearchResultsRenderer
                    ?.primaryContents?.sectionListRenderer?.contents || [];
                for (const section of sections) scanItems(section?.itemSectionRenderer?.contents);
                // Continuation response (page 2+).
                for (const c of data?.onResponseReceivedCommands || []) {
                    for (const cont of c?.appendContinuationItemsAction?.continuationItems || []) {
                        scanItems(cont?.itemSectionRenderer?.contents);
                    }
                }
            } catch (e) { console.warn('[bygone] parse error', e.message); }
            return out;
        }

        _parsePlaylistResults(data) {
            const out = [];
            try {
                const tabs = data?.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
                let items = [];
                for (const tab of tabs) {
                    const contents = tab?.tabRenderer?.content?.sectionListRenderer?.contents
                        || tab?.tabRenderer?.content?.richGridRenderer?.contents || [];
                    for (const section of contents) {
                        const sectionItems = section?.itemSectionRenderer?.contents?.[0]
                            ?.playlistVideoListRenderer?.contents || [];
                        items.push(...sectionItems);
                    }
                }
                for (const item of items) {
                    const v = item.playlistVideoRenderer;
                    if (!v || !v.videoId || !/^[A-Za-z0-9_-]{11}$/.test(v.videoId)) continue;
                    const viewText = v.videoInfo?.runs?.[0]?.text || '';
                    out.push({
                        id: v.videoId,
                        title: v.title?.runs?.[0]?.text || v.title?.simpleText || '',
                        channel: v.shortBylineText?.runs?.[0]?.text || '',
                        channelId: v.shortBylineText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId || '',
                        thumbnail: v.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
                        viewCount: this._parseViewCount(viewText),
                        viewCountFormatted: viewText || '0 views',
                        relativeDate: v.videoInfo?.runs?.[2]?.text || '',
                        duration: v.lengthText?.simpleText || '',
                    });
                }
            } catch (e) { console.warn('[bygone] playlist parse error', e.message); }
            return out;
        }

        _parseRelatedResults(data) {
            const out = [];
            try {
                const items = data?.contents?.twoColumnWatchNextResults?.secondaryResults?.secondaryResults?.results || [];
                for (const item of items) {
                    const v = item?.compactVideoRenderer;
                    if (!v || !v.videoId) continue;
                    const viewText = v.viewCountText?.simpleText || v.viewCountText?.runs?.[0]?.text || v.shortViewCountText?.simpleText || '';
                    out.push({
                        id: v.videoId,
                        title: v.title?.simpleText || v.title?.runs?.[0]?.text || '',
                        channel: v.longBylineText?.runs?.[0]?.text || v.shortBylineText?.runs?.[0]?.text || '',
                        channelId: v.longBylineText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId
                            || v.shortBylineText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId || '',
                        thumbnail: v.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
                        viewCount: this._parseViewCount(viewText),
                        viewCountFormatted: viewText || '0 views',
                        relativeDate: v.publishedTimeText?.simpleText || '',
                        duration: v.lengthText?.simpleText || '',
                    });
                }
            } catch (e) { console.warn('[bygone] related parse error', e.message); }
            return out;
        }

        _parseChannelResults(data) {
            try {
                const sections = data?.contents?.twoColumnSearchResultsRenderer
                    ?.primaryContents?.sectionListRenderer?.contents || [];
                for (const section of sections) {
                    for (const item of section?.itemSectionRenderer?.contents || []) {
                        if (item.channelRenderer) {
                            return {
                                id: item.channelRenderer.channelId,
                                name: item.channelRenderer.title?.simpleText
                                    || item.channelRenderer.title?.runs?.[0]?.text || '',
                            };
                        }
                    }
                }
            } catch {}
            return null;
        }

        // ---- Search query builder ----------------------------------

        _buildDateQuery(query, after, before, negatives) {
            let q = query || '';
            if (after)  q += ` after:${(after instanceof Date ? after : new Date(after)).toISOString().split('T')[0]}`;
            if (before) q += ` before:${(before instanceof Date ? before : new Date(before)).toISOString().split('T')[0]}`;
            if (Array.isArray(negatives)) {
                for (const n of negatives) {
                    const t = String(n || '').trim();
                    if (!t) continue;
                    q += /\s/.test(t) ? ` -"${t}"` : ` -${t}`;
                }
            }
            return q.trim();
        }

        // ---- Public methods ----------------------------------------

        _allNegatives(extra) {
            const neg = [...(Array.isArray(extra) ? extra : []), ...Store.getGlobalNegatives()];
            try {
                const lang = InterestModel.inferLanguageHints();
                if (lang.likelyEnglish) neg.push('ITA', 'dublado', 'doblado', 'español', 'en español', 'hindi', 'tamil', 'telugu', 'bhojpuri', 'bollywood', 'русский', 'arabic', 'legendado', 'sottotitoli');
            } catch (_) {}
            return neg;
        }

        async searchVideos(query, { publishedAfter, publishedBefore, maxResults, order = 'relevance', categoryId, negatives, strictMatch, maxPages } = {}) {
            const allNeg = this._allNegatives(negatives);
            let q = this._buildDateQuery(query, publishedAfter, publishedBefore, allNeg);
            if (categoryId && CONFIG.categories[categoryId]) q = `${CONFIG.categories[categoryId]} ${q}`.trim();
            const params = order === 'viewCount' ? 'CAMSAhAB' : order === 'date' ? 'CAISAhAB' : 'EgIQAQ==';
            const want = maxResults || CONFIG.api.maxResults;
            const pageCap = Math.max(1, maxPages || CONFIG.api.maxSearchPages || 1);

            const strictRe = strictMatch
                ? new RegExp(`\\b${strictMatch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i')
                : null;
            // CLIENT-SIDE DATE BOUNDING. YouTube applies the `before:`/`after:`
            // search operators loosely on broad/popular queries and returns
            // modern videos anyway, so each page is filtered here. Undated
            // results are kept (rare; matches the channel path) — modern
            // pollution carries a recent relative date and is dropped.
            const aMin = publishedAfter ? new Date(publishedAfter) : null;
            const aMax = publishedBefore ? new Date(publishedBefore) : null;
            // Window check with the string's own precision as slack. Modern
            // pollution still drops: its strings are fine-grained ("2 weeks
            // ago"), or at year granularity land a decade+ past aMax even
            // after the slack. The query's own before:/after: operators are
            // what keep post-set-date uploads out of this source.
            const inWindow = (v) => DateHelper.inApproxWindow(v.relativeDate, aMin, aMax);

            // PAGINATE via continuation tokens. The first page of a search is
            // only ~20 results; the response ends with a continuation token
            // that fetches the next ~20 (still inside the date window), and so
            // on. The date filter drops a lot per page, so we keep paging until
            // we have `want` valid results or run out of pages/token. This is
            // what lets a single era query surface far more than one page.
            const collected = [];
            const seen = new Set();
            let token = null;
            for (let page = 0; page < pageCap; page++) {
                let data;
                try { data = await this._post('search', token ? { continuation: token } : { query: q, params }); }
                catch (_) { break; }
                const results = this._parseSearchResults(data);
                for (const v of results) {
                    if (!v || !v.id || seen.has(v.id)) continue;
                    if (strictRe && !strictRe.test(v.title || '')) continue;
                    if (!inWindow(v)) continue;
                    seen.add(v.id);
                    collected.push(v);
                }
                if (collected.length >= want) break;
                token = this._extractSearchContinuation(data);
                if (!token) break;
            }
            return collected.slice(0, want);
        }

        // Pull the search continuation token from either an initial search
        // response (continuationItemRenderer inside the sectionList) or a
        // continuation response (appendContinuationItemsAction).
        _extractSearchContinuation(data) {
            const fromItems = (items) => {
                for (const it of items || []) {
                    const t = it?.continuationItemRenderer?.continuationEndpoint
                        ?.continuationCommand?.token;
                    if (t) return t;
                }
                return null;
            };
            try {
                const sects = data?.contents?.twoColumnSearchResultsRenderer
                    ?.primaryContents?.sectionListRenderer?.contents || [];
                const t1 = fromItems(sects);
                if (t1) return t1;
                for (const s of sects) {
                    const t = fromItems(s?.itemSectionRenderer?.contents);
                    if (t) return t;
                }
                for (const c of data?.onResponseReceivedCommands || []) {
                    const t = fromItems(c?.appendContinuationItemsAction?.continuationItems);
                    if (t) return t;
                }
            } catch (_) {}
            return null;
        }

        async getChannelVideos(channelName, { publishedAfter, publishedBefore, maxResults, channelId } = {}) {
            if (channelId && channelId.startsWith('UC')) {
                try {
                    const data = await this._post('browse', { browseId: `VL${'UU' + channelId.slice(2)}` });
                    const results = this._parsePlaylistResults(data);
                    const filtered = results.filter(v =>
                        DateHelper.inApproxWindow(v.relativeDate, publishedAfter, publishedBefore));
                    if (filtered.length) return filtered.slice(0, maxResults || CONFIG.api.maxResults);
                } catch (e) { /* fall through to search */ }
            }
            const q = this._buildDateQuery(channelName, publishedAfter, publishedBefore, this._allNegatives());
            const data = await this._post('search', { query: q, params: 'EgIQAQ==' });
            let results = this._parseSearchResults(data);
            if (channelId) {
                results = results.filter(v => v.channelId === channelId);
            } else {
                const lc = channelName.toLowerCase();
                results = results.filter(v => (v.channel || '').toLowerCase() === lc);
            }
            return results.slice(0, maxResults || CONFIG.api.maxResults);
        }

        async getPopularByCategory(categoryId, opts) {
            return this.searchVideos('', { ...opts, categoryId, order: 'relevance' });
        }

        async getRelatedVideos(videoId) {
            try { return this._parseRelatedResults(await this._post('next', { videoId })); }
            catch { return []; }
        }

        // Exact publish date (ISO) for a video from its watch page's primary
        // info date text. /next is already used for related videos and is less
        // bot-gated than /player.
        async fetchExactDate(videoId) {
            if (!videoId) return null;
            try {
                const data = await this._post('next', { videoId });
                const conts = data?.contents?.twoColumnWatchNextResults
                    ?.results?.results?.contents || [];
                for (const c of conts) {
                    const dt = c?.videoPrimaryInfoRenderer?.dateText;
                    const text = dt?.simpleText || (dt?.runs || []).map(r => r.text).join('');
                    const iso = DateHelper.parseExactDateText(text);
                    if (iso) return iso;
                }
            } catch (_) {}
            return null;
        }

        async resolveChannel(input) {
            const q = input.startsWith('@') ? input : `"${input}"`;
            const data = await this._post('search', { query: q, params: 'EgIQAg==' });
            return this._parseChannelResults(data);
        }

        // Subscribe (or unsubscribe) the logged-in user to one or more
        // channels via InnerTube. Uses the same auth-trick as everything
        // else, so the user must already be logged in to YouTube in this
        // browser session.
        async subscribeToChannel(channelId) {
            if (!channelId) return false;
            try {
                await this._post('subscription/subscribe', { channelIds: [channelId] });
                return true;
            } catch (e) {
                console.warn('[bygone] subscribe failed for', channelId, '—', e.message);
                return false;
            }
        }
        async unsubscribeFromChannel(channelId) {
            if (!channelId) return false;
            try {
                await this._post('subscription/unsubscribe', { channelIds: [channelId] });
                return true;
            } catch (e) {
                console.warn('[bygone] unsubscribe failed for', channelId, '—', e.message);
                return false;
            }
        }
    }

    // ============================================================
    //  FEED ENGINE — merge sources (subs / search / categories /
    //  topics / similar / trending); dedup; weight; date-window
    //  ending at the set date (no forward grace).
    // ============================================================

    class FeedEngine {
        constructor(api) { this.api = api; }

        static _tag(videos, source, detail) {
            for (const v of videos) if (v) { v.source = source; v.sourceDetail = detail; }
            return videos;
        }

        _dateWindow(selectedDate) {
            const d = new Date(selectedDate);
            const days = CONFIG.feed.dateWindowDays;
            const after = new Date(d);  after.setDate(after.getDate() - days);
            const before = new Date(d); before.setDate(before.getDate() + days);
            return { after, before, center: d };
        }

        _interleave(batches) {
            const out = [];
            const longest = Math.max(0, ...batches.map(b => b.length));
            for (let i = 0; i < longest; i++) for (const b of batches) if (i < b.length) out.push(b[i]);
            return out;
        }

        _dedupe(videos) {
            const seen = new Set();
            const blockedNames = new Set(Store.getBlockedChannels().map(b => b.name.toLowerCase()));
            const blockedIds = new Set(Store.getBlockedChannels().map(b => b.id).filter(Boolean));
            const hidden = new Set(Store.getHiddenIds());
            let coldIds = new Set();
            try {
                const neg = InterestModel.computeNegativeSignals();
                coldIds = new Set(neg.coldChannels.filter(c => c.skipScore >= 5).map(c => c.channelId));
            } catch (_) {}
            return videos.filter(v => {
                if (!v || seen.has(v.id)) return false;
                // Auto-generated "Foo - Topic" channels: comments are always
                // off on their uploads and half the watch page is dead, so
                // keep them out of the pool entirely.
                if (v.channel && /\s+-\s+topic$/i.test(v.channel.trim())) return false;
                if (v.channel && blockedNames.has(v.channel.toLowerCase())) return false;
                if (v.channelId && blockedIds.has(v.channelId)) return false;
                if (v.channelId && coldIds.has(v.channelId)) return false;
                if (hidden.has(v.id)) return false;
                seen.add(v.id);
                return true;
            });
        }

        // Soft bias toward videos near the chosen date. Flatter falloff
        // (^0.3) so older material still surfaces.
        _weightedShuffle(videos, centerDate) {
            const center = new Date(centerDate).getTime();
            const weighted = videos.map(v => {
                let pub = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
                let slackDays = 0;
                if (!pub || isNaN(pub)) {
                    const d = DateHelper.approxPublishDate(v.relativeDate);
                    pub = d ? d.getTime() : center;
                    // Coarse strings locate pub only to ±1 unit; bias with the
                    // closest distance the video could be, so year-granular
                    // dates aren't penalized as farther than they may be.
                    if (d) slackDays = DateHelper.approxSlackMs(v.relativeDate) / 86400000;
                }
                const daysDiff = Math.max(1, Math.abs(center - pub) / 86400000 - slackDays);
                return { v, sort: Math.random() / Math.pow(daysDiff, 0.3), source: v.source || '' };
            });
            weighted.sort((a, b) => b.sort - a.sort);
            const result = [];
            const deferred = [];
            let lastSrc = '', consecutive = 0;
            for (const w of weighted) {
                if (w.source === lastSrc) { consecutive++; if (consecutive > 2) { deferred.push(w); continue; } }
                else { lastSrc = w.source; consecutive = 1; }
                result.push(w);
            }
            let insertIdx = 1;
            for (const d of deferred) { result.splice(Math.min(insertIdx, result.length), 0, d); insertIdx += 3; }
            return result.map(w => w.v);
        }

        // ---- Source collection ------------------------------------

        _collectSubscriptions() {
            const subs = Store.getSubscriptions();
            const explicitIds = new Set(subs.map(s => s.id).filter(Boolean));
            const explicitNames = new Set(subs.map(s => s.name.toLowerCase()));
            const result = [...subs];
            if (Store.isLearningEnabled()) {
                const interests = Store.getCachedInterests();
                if (interests) for (const lc of InterestModel.getLearnedChannels(interests)) {
                    if (!explicitIds.has(lc.channelId)) {
                        result.push({ id: lc.channelId, name: lc.name, weight: Math.min(3, Math.round(lc.score)), _learned: true });
                        explicitIds.add(lc.channelId);
                        explicitNames.add(lc.name.toLowerCase());
                    }
                }
            }
            return result;
        }

        _normalizeTerm(raw) {
            if (typeof raw === 'string') return { term: raw, weight: 3 };
            return {
                term: raw.term || '',
                weight: raw.weight || 3,
                negatives: Array.isArray(raw.negatives) ? raw.negatives : [],
                strict: !!raw.strict,
                categoryBias: raw.categoryBias || null,
            };
        }

        _normalizeTopic(raw) {
            if (typeof raw === 'string') return { name: raw, weight: 3 };
            return {
                name: raw.name || '',
                weight: raw.weight || 3,
                negatives: Array.isArray(raw.negatives) ? raw.negatives : [],
                strict: !!raw.strict,
                categoryBias: raw.categoryBias || null,
            };
        }

        _collectSearchTerms() {
            const terms = Store.getSearchTerms().map(t => this._normalizeTerm(t));
            if (Store.isLearningEnabled()) {
                const interests = Store.getCachedInterests();
                if (interests) {
                    const existing = new Set(terms.map(t => t.term.toLowerCase()));
                    for (const lk of InterestModel.getLearnedKeywords(interests)) {
                        if (!existing.has(lk.keyword)) {
                            terms.push({ term: lk.keyword, weight: 2, _learned: true, negatives: [], strict: false, categoryBias: null });
                        }
                    }
                }
            }
            const base = terms.filter(t => t.term && t.term.length > 1);
            const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
            const _WILD = [
                'giant', 'weird', 'homemade', 'forgotten', 'strange', 'amateur',
                'rare', 'lost', 'secret', 'hidden', 'backyard', 'handmade',
                'local', 'unknown', 'original', 'crazy', 'funny', 'real',
                'epic', 'tiny', 'cursed', 'broken', 'ancient', 'haunted',
                'mysterious', 'banned', 'stolen', 'worst', 'ultimate', 'illegal',
            ];
            const dc = { weight: 2, negatives: [], strict: false, categoryBias: null, _deepCut: true };
            if (base.length >= 2) {
                const a = pick(base);
                let b = pick(base), tries = 0;
                while (b.term === a.term && tries++ < 5) b = pick(base);
                if (a.term !== b.term) terms.push({ ...dc, term: a.term + ' ' + b.term });
            }
            if (base.length > 0) {
                terms.push({ ...dc, term: pick(base).term + ' ' + pick(_WILD) });
            }
            terms.push({ ...dc, weight: 1, term: pick(_WILD) });
            const existing = new Set(terms.map(t => t.term.toLowerCase()));
            const subs = Store.getSubscriptions();
            if (subs.length > 0) {
                const pool = subs.filter(s => s.name && !existing.has(s.name.toLowerCase()));
                for (let i = 0; i < Math.min(5, pool.length); i++) {
                    const s = pool.splice(Math.floor(Math.random() * pool.length), 1)[0];
                    terms.push({ ...dc, term: s.name, _chanName: true });
                }
            }
            return terms;
        }

        // Unified source fetcher: caches optional. Each fetcher returns an array.
        async _fetchSubs(dw, count, useCache) {
            const subs = this._collectSubscriptions();
            if (!subs.length) return [];
            const key = `subs_${dw.center.toDateString()}`;
            if (useCache) {
                const c = Store.getCacheEntry(key, CONFIG.cache.subscriptions);
                if (c) return c;
            }
            const totalW = subs.reduce((s, x) => s + (x.weight || 3), 0);
            const batches = await Promise.allSettled(subs.map(async sub => {
                if (!sub.id && !sub._learned) {
                    try {
                        const ch = await this.api.resolveChannel(sub.name);
                        if (ch && ch.id) {
                            sub.id = ch.id;
                            sub.name = ch.name || sub.name;
                            const stored = Store.getSubscriptions();
                            const match = stored.find(s => s.name.toLowerCase() === sub.name.toLowerCase() || (s.id && s.id === ch.id));
                            if (match && !match.id) { match.id = ch.id; match.name = ch.name || match.name; Store.setSubscriptions(stored); }
                        }
                    } catch (_) {}
                }
                const w = sub.weight || 3;
                const per = Math.max(3, Math.ceil(count * w / totalW));
                const videos = await this.api.getChannelVideos(sub.name, {
                    publishedAfter: dw.after, publishedBefore: dw.before,
                    maxResults: per, channelId: sub.id,
                });
                const detail = sub._learned ? `Learned: ${sub.name}` : `Subscription: ${sub.name}`;
                return FeedEngine._tag(videos, 'subscriptions', detail);
            }));
            const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value));
            if (useCache && out.length) Store.setCacheEntry(key, out);
            return out;
        }

        async _fetchSearch(dw, count, useCache) {
            const terms = this._collectSearchTerms();
            if (!terms.length) return [];
            const key = `search_${dw.center.toDateString()}`;
            if (useCache) {
                const c = Store.getCacheEntry(key, CONFIG.cache.searchTerms);
                if (c) return c;
            }
            const totalW = terms.reduce((s, x) => s + (x.weight || 3), 0);
            const batches = await Promise.allSettled(terms.map(async t => {
                const w = t.weight || 3;
                const per = Math.max(3, Math.ceil(count * w / totalW));
                const q = `"${t.term}"`;
                const videos = await this.api.searchVideos(q, {
                    publishedAfter: dw.after, publishedBefore: dw.before, maxResults: per,
                    negatives: t.negatives, strictMatch: t.strict ? t.term : null, categoryId: t.categoryBias,
                    order: t._deepCut ? 'date' : 'relevance',
                });
                const label = t._chanName ? `Channel hunt: "${t.term}"` : t._deepCut ? `Deep cut: "${t.term}"` : `Search: "${t.term}"`;
                return FeedEngine._tag(videos, 'searchTerms', label);
            }));
            const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value));
            if (useCache && out.length) Store.setCacheEntry(key, out);
            return out;
        }

        async _fetchCategories(dw, count, useCache) {
            const cats = Store.getCategories();
            if (!cats.length) return [];
            const key = `cats_${cats.join('_')}_${dw.center.toDateString()}`;
            if (useCache) {
                const c = Store.getCacheEntry(key, CONFIG.cache.categories);
                if (c) return c;
            }
            const per = Math.max(5, Math.ceil(count / cats.length));
            const batches = await Promise.allSettled(cats.map(async id => {
                const videos = await this.api.getPopularByCategory(id, {
                    publishedAfter: dw.after, publishedBefore: dw.before, maxResults: per,
                });
                return FeedEngine._tag(videos, 'categories', `Category: ${CONFIG.categories[id] || id}`);
            }));
            const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value));
            if (useCache && out.length) Store.setCacheEntry(key, out);
            return out;
        }

        async _fetchTopics(dw, count, useCache) {
            const topics = Store.getTopics().map(t => this._normalizeTopic(t));
            if (!topics.length) return [];
            const key = `topics_${dw.center.toDateString()}`;
            if (useCache) {
                const c = Store.getCacheEntry(key, CONFIG.cache.topics);
                if (c) return c;
            }
            const totalW = topics.reduce((s, x) => s + (x.weight || 3), 0);
            const batches = await Promise.allSettled(topics.map(async topic => {
                const w = topic.weight || 3;
                const per = Math.max(3, Math.ceil(count * w / totalW));
                const videos = await this.api.searchVideos(topic.name, {
                    publishedAfter: dw.after, publishedBefore: dw.before, maxResults: per,
                    negatives: topic.negatives, strictMatch: topic.strict ? topic.name : null, categoryId: topic.categoryBias,
                });
                return FeedEngine._tag(videos, 'topics', `Topic: "${topic.name}"`);
            }));
            const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value));
            if (useCache && out.length) Store.setCacheEntry(key, out);
            return out;
        }

        // Trending: random discovery queries sorted by view count.
        _buildDiscoveryQueries() {
            const queries = new Set();
            const interests = Store.getCachedInterests();
            if (interests) {
                const keywords = Object.entries(interests.keywords)
                    .filter(([_, k]) => k.score >= 1)
                    .sort((a, b) => b[1].score - a[1].score)
                    .slice(0, 15)
                    .map(([kw]) => kw);
                for (let i = 0; i < keywords.length; i++) {
                    queries.add(keywords[i]);
                    if (i + 1 < keywords.length) queries.add(keywords[i] + ' ' + keywords[i + 1]);
                }
            }
            const searchTerms = Store.getSearchTerms();
            for (const raw of searchTerms) {
                const term = typeof raw === 'string' ? raw : raw.term;
                if (term) { queries.add(term); queries.add(term + ' review'); }
            }
            const subs = Store.getSubscriptions();
            for (const sub of subs.slice(0, 5)) { if (sub.name) queries.add(sub.name); }
            const searchHistory = Store.getSearchHistory();
            for (const entry of searchHistory.slice(-10)) { if (entry.query) queries.add(entry.query); }
            if (queries.size < 4) {
                for (const f of ['review', 'tutorial', 'documentary', 'explained', 'analysis']) queries.add(f);
            }
            return Array.from(queries);
        }

        async _fetchTrending(dw, count, useCache) {
            if (!Store.isDiscoveryEnabled()) return [];
            const key = `trending_${dw.center.toDateString()}`;
            if (useCache) {
                const c = Store.getCacheEntry(key, CONFIG.cache.trending);
                if (c) return c;
            }
            const pool = this._buildDiscoveryQueries();
            const picked = [];
            for (let i = 0; i < 6 && pool.length; i++) {
                picked.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]);
            }
            if (!picked.length) picked.push('');
            let autoNeg = [];
            try { autoNeg = InterestModel.computeNegativeSignals().autoNegKeywords.slice(0, 10); } catch (_) {}
            const per = Math.max(5, Math.ceil(count / picked.length));
            const batches = await Promise.allSettled(picked.map(async q => {
                const videos = await this.api.searchVideos(q, {
                    negatives: autoNeg,
                    publishedAfter: dw.after, publishedBefore: dw.before, maxResults: per, order: 'relevance',
                });
                return FeedEngine._tag(videos, 'trending', q ? `Discover: "${q}"` : 'Discover');
            }));
            const out = batches.filter(r => r.status === 'fulfilled').flatMap(r => r.value);
            if (useCache && out.length) Store.setCacheEntry(key, out);
            return out;
        }

        // CF "Similar": harvest /next sidebar for recent watch seeds.
        async _fetchSimilar(dw, count, useCache) {
            if (!Store.isSimilarEnabled()) return [];
            const key = `similar_${dw.center.toDateString()}`;
            if (useCache) {
                const c = Store.getCacheEntry(key, CONFIG.cache.similar);
                if (c) return c;
            }
            const seeds = await this._pickSimilarSeeds();
            if (!seeds.length) return [];
            const batches = await Promise.allSettled(seeds.map(async seed => {
                const related = await this.api.getRelatedVideos(seed.videoId);
                // strictBefore: /next has no before: operator, so max-edge
                // slack here would admit the straddling year-bucket's
                // post-set-date uploads with nothing upstream to stop them.
                const filtered = related.filter(v =>
                    DateHelper.inApproxWindow(v.relativeDate, dw.after, dw.before, { strictBefore: true }));
                return FeedEngine._tag(filtered, 'similar', `Similar to: ${seed.label}`);
            }));
            const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value)).slice(0, count);
            if (useCache && out.length) Store.setCacheEntry(key, out);
            return out;
        }

        async _pickSimilarSeeds() {
            const recent = Store.getWatchHistory().slice().reverse().filter(w => w.videoId).slice(0, 8);
            const seeds = [];
            const usedChannels = new Set();
            for (const w of recent) {
                if (w.channelId && usedChannels.has(w.channelId)) continue;
                seeds.push({ videoId: w.videoId, label: w.title || w.channel || 'recent watch' });
                if (w.channelId) usedChannels.add(w.channelId);
                if (seeds.length >= 3) break;
            }
            if (seeds.length >= 3) return seeds;
            const interests = Store.getCachedInterests();
            if (interests) for (const lc of InterestModel.getLearnedChannels(interests)) {
                if (seeds.length >= 3) break;
                if (usedChannels.has(lc.channelId)) continue;
                try {
                    const v = await this.api.getChannelVideos(lc.name, { maxResults: 1, channelId: lc.channelId });
                    if (v.length) { seeds.push({ videoId: v[0].id, label: lc.name }); usedChannels.add(lc.channelId); }
                } catch {}
            }
            return seeds;
        }

        // RELATED FAN-OUT. Harvest the related-videos sidebar (/next) of the
        // era videos we already pooled. Neighbours of an era video are mostly
        // era videos, so this deepens the pool from billions of candidates
        // without inventing search terms. The /next endpoint takes no date
        // operator, so results are STRICTLY date-filtered and undated ones are
        // dropped (related lists are full of present-day recommendations).
        async _fetchRelatedExpansion(seedVideos, dw, count) {
            const seeds = [];
            const usedCh = new Set();
            for (const v of seedVideos) {
                if (!v || !v.id) continue;
                if (v.channelId && usedCh.has(v.channelId)) continue;  // spread seeds across channels
                seeds.push(v);
                if (v.channelId) usedCh.add(v.channelId);
                if (seeds.length >= 8) break;
            }
            if (!seeds.length) return [];
            const batches = await Promise.allSettled(seeds.map(async s => {
                const related = await this.api.getRelatedVideos(s.id);
                // dropUndated: likely modern. strictBefore: /next has no
                // before: operator (same reasoning as _fetchSimilar).
                const filtered = related.filter(r =>
                    DateHelper.inApproxWindow(r.relativeDate, dw.after, dw.before,
                        { dropUndated: true, strictBefore: true }));
                return FeedEngine._tag(filtered, 'related', `Related to: ${s.title || s.id}`);
            }));
            const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value));
            return out.slice(0, count);
        }

        // Boost subs+search when learning has signal; drain from trending+similar.
        _effectiveWeights() {
            if (!Store.isLearningEnabled()) return CONFIG.feed.weights;
            const i = Store.getCachedInterests();
            if (!i) return CONFIG.feed.weights;
            const lc = InterestModel.getLearnedChannels(i).length;
            const lk = InterestModel.getLearnedKeywords(i).length;
            const w = { ...CONFIG.feed.weights };
            const subBoost = Math.min(0.10, lc * 0.02);
            const termBoost = Math.min(0.05, lk * 0.01);
            w.subscriptions += subBoost;
            w.searchTerms += termBoost;
            const drain = (subBoost + termBoost) / 2;
            w.trending = Math.max(0.05, w.trending - drain);
            w.similar  = Math.max(0.05, (w.similar || 0) - drain);

            const stats = Store.getSourceStats();
            const sourceKeys = Object.keys(w);
            const ctrs = {};
            let hasEnoughData = false;
            for (const key of sourceKeys) {
                const s = stats[key];
                if (s && s.impressions >= 20) { ctrs[key] = s.clicks / s.impressions; hasEnoughData = true; }
                else ctrs[key] = null;
            }
            if (hasEnoughData) {
                const validCtrs = Object.values(ctrs).filter(v => v !== null);
                const avgCtr = validCtrs.reduce((s, v) => s + v, 0) / validCtrs.length;
                for (const key of sourceKeys) {
                    if (ctrs[key] === null) continue;
                    const adj = Math.max(-0.08, Math.min(0.08, (ctrs[key] - avgCtr) * 2));
                    w[key] = Math.max(0.03, w[key] + adj);
                }
            }
            const total = Object.values(w).reduce((s, v) => s + v, 0);
            for (const key of sourceKeys) w[key] /= total;

            return w;
        }

        _mixSources(sources, weights) {
            const w = weights || CONFIG.feed.weights;
            const total = CONFIG.feed.maxHomepageVideos;
            const take = (arr, n) => [...arr].sort(() => Math.random() - 0.5).slice(0, n);
            const counts = {
                subscriptions: Math.round(total * (w.subscriptions || 0)),
                searchTerms:   Math.round(total * (w.searchTerms   || 0)),
                categories:    Math.round(total * (w.categories    || 0)),
                topics:        Math.round(total * (w.topics        || 0)),
                similar:       Math.round(total * (w.similar       || 0)),
                trending:      Math.round(total * (w.trending      || 0)),
            };
            const mixed = [];
            for (const [k, n] of Object.entries(counts)) mixed.push(...take(sources[k] || [], n));
            if (mixed.length < total) {
                const ids = new Set(mixed.map(v => v.id));
                const extras = Object.values(sources).flat().filter(v => v && !ids.has(v.id));
                mixed.push(...take(extras, total - mixed.length));
            }
            return mixed;
        }

        // Internal: race the build against a timeout so loading can't hang.
        // Raised to 45s as a pure backstop — the per-source budget below means
        // _build resolves with partial data long before this fires; it should
        // only ever trigger if the whole engine wedges.
        async _buildWithTimeout(promise) {
            return Promise.race([promise, new Promise((_, rej) => setTimeout(() => rej(new Error('Feed build timed out')), 45000))]);
        }

        // Resolve to a source's videos, or `fallback` if it rejects or runs
        // past `ms`. This is what stops one slow source from sinking the whole
        // build: previously a single fetcher that ran past the 30s wall left
        // Promise.allSettled pending, so EVERY source's results were thrown
        // away and the pool came back empty (POOL=0). Now each source is
        // individually bounded, so allSettled always settles fast with whatever
        // the fast sources returned.
        _settleWithin(promise, ms, fallback, label) {
            const safe = Promise.resolve(promise).then(
                v => (Array.isArray(v) ? v : fallback),
                (e) => {
                    // Swallowing rejections is what keeps one dead source from
                    // sinking the build — but NEVER silently: an all-zero
                    // `sources:` line with no error context is undebuggable.
                    try { console.warn('[bygone] source rejected:', label || '', (e && e.message) || e); } catch (_) {}
                    return fallback;
                }
            );
            // The timeout path must log too, or a hung source is again
            // indistinguishable from genuine no-results. The timer is
            // cancelled when the source settles first, so the late firing
            // can't emit a false "timed out".
            let timer = null;
            const timeout = new Promise(resolve => {
                timer = setTimeout(() => {
                    try { console.warn('[bygone] source timed out after ' + ms + 'ms:', label || 'unlabeled'); } catch (_) {}
                    resolve(fallback);
                }, ms);
            });
            return Promise.race([
                safe.then(v => { if (timer) clearTimeout(timer); return v; }),
                timeout,
            ]);
        }

        // useCache:false — InnerTube fetches are free, so pull a fresh
        // feed from the live API on every page load instead of serving
        // a stale cached batch. The random shuffle in _mixSources/
        // _weightedShuffle plus the impression park (recordImpressions)
        // mean every refresh surfaces a different set of pool videos.
        async buildHomeFeed(selectedDate, onPartial) { return this._buildWithTimeout(this._build(selectedDate, false, onPartial)); }

        async buildHomeFeedMore(selectedDate, page, excludeIds) {
            const d = new Date(selectedDate);
            d.setDate(d.getDate() - CONFIG.feed.dateWindowDays * 2 * (page - 1));
            const out = await this._build(d.toISOString().split('T')[0], false);
            const excl = excludeIds instanceof Set ? excludeIds : new Set(excludeIds || []);
            return out.filter(v => !excl.has(v.id));
        }

        _enforceDiversity(videos) {
            const MAX_PER_CHANNEL = 3;
            const MAX_PER_SOURCE = Math.ceil(videos.length * 0.35);
            const channelCounts = {};
            const sourceCounts = {};
            const kept = [];
            const deferred = [];
            for (const v of videos) {
                const chKey = v.channelId || v.channel || 'unknown';
                const srcKey = v.source || 'unknown';
                const chCount = channelCounts[chKey] || 0;
                const srcCount = sourceCounts[srcKey] || 0;
                if (chCount >= MAX_PER_CHANNEL || srcCount >= MAX_PER_SOURCE) {
                    deferred.push(v);
                    continue;
                }
                channelCounts[chKey] = chCount + 1;
                sourceCounts[srcKey] = srcCount + 1;
                kept.push(v);
            }
            if (kept.length < 100) for (const v of deferred) { kept.push(v); if (kept.length >= videos.length) break; }
            return kept;
        }

        async _build(selectedDate, useCache, onPartial) {
            const total = CONFIG.feed.maxHomepageVideos;
            const anchor = new Date(selectedDate);
            // No future-grace on videos. The small +7d edge on each window
            // is just smoothing for YouTube's approximate relative-date
            // strings ("1 day ago" can fall a couple days either side of
            // the actual upload), not a deliberate grace period.
            const smoothMs = 7 * 86400000;
            const subsWindow = this._dateWindow(selectedDate);
            const queryWindow    = { after: new Date(anchor - 90  * 86400000), before: new Date(anchor.getTime() + smoothMs), center: anchor };
            const catWindow      = { after: new Date(anchor - 180 * 86400000), before: new Date(anchor.getTime() + smoothMs), center: anchor };
            const trendingWindow = { after: new Date(anchor - 365 * 86400000), before: new Date(anchor.getTime() + smoothMs), center: anchor };

            const loadNum = Store.incrementLoadCount();
            const explore = loadNum % 10 === 0;
            const weights = explore ? CONFIG.feed.weights : this._effectiveWeights();

            // Each source is bounded to SOURCE_BUDGET so a slow one resolves
            // empty instead of stalling the whole build. Budget sits above the
            // 15s per-request HTTP timeout so a single-request source still has
            // time to answer; multi-page sources that can't finish in time just
            // contribute what the fast sources already gave us.
            const SOURCE_BUDGET = 22000;
            // Emit each source's videos to onPartial the moment it resolves, so
            // the pool can go live (firing pool-ready → home grid + watch "Up
            // Next" sidebar sweep) after the FIRST source returns instead of
            // waiting for all of them. The final weighted pool still replaces
            // this below, so the end state is unchanged.
            const emit = (typeof onPartial === 'function') ? onPartial : null;
            const bound = (label, p) => {
                const s = this._settleWithin(p, SOURCE_BUDGET, [], label);
                if (!emit) return s;
                return s.then(vids => { if (vids && vids.length) { try { emit(vids); } catch (_) {} } return vids; });
            };
            const results = await Promise.allSettled([
                bound('subs',     this._fetchSubs      (subsWindow,     Math.round(total * weights.subscriptions * 2), useCache)),
                bound('search',   this._fetchSearch    (queryWindow,    Math.round(total * weights.searchTerms   * 2), useCache)),
                bound('cats',     this._fetchCategories(catWindow,      Math.round(total * weights.categories    * 2), useCache)),
                bound('topics',   this._fetchTopics    (queryWindow,    Math.round(total * weights.topics        * 2), useCache)),
                bound('similar',  this._fetchSimilar   (subsWindow,     Math.round(total * weights.similar       * 2), useCache)),
                bound('trending', this._fetchTrending  (trendingWindow, Math.round(total * weights.trending      * 2), useCache)),
            ]);
            const val = (i) => results[i].status === 'fulfilled' ? results[i].value : [];
            const err = (i) => results[i].status === 'rejected' ? (results[i].reason && results[i].reason.message) || 'rejected' : '';
            // Per-source diagnostic: tells us at a glance which fetchers
            // are returning videos and which are silently empty/failing.
            console.log('[bygone] sources:',
                'subs=' + val(0).length + (err(0) ? '(err:'+err(0)+')' : ''),
                'search=' + val(1).length + (err(1) ? '(err:'+err(1)+')' : ''),
                'cats=' + val(2).length + (err(2) ? '(err:'+err(2)+')' : ''),
                'topics=' + val(3).length + (err(3) ? '(err:'+err(3)+')' : ''),
                'similar=' + val(4).length + (err(4) ? '(err:'+err(4)+')' : ''),
                'trending=' + val(5).length + (err(5) ? '(err:'+err(5)+')' : ''));
            const mixed = this._mixSources({
                subscriptions: val(0), searchTerms: val(1), categories: val(2),
                topics:        val(3), similar:     val(4), trending:    val(5),
            }, weights);

            let deduped = this._dedupe(mixed);
            // RELATED FAN-OUT. If the keyword/category/trending pool came back
            // below target (deep/sparse eras, or a near-sourceless profile),
            // expand it by harvesting the related sidebar of the era videos we
            // already have. Seeds come from the pool itself, so it works on any
            // date and needs no watch history (unlike _fetchSimilar).
            // Only fan out if the primary sources left us short AND actually
            // returned seeds to expand from — fanning out from an empty pool is
            // pointless and just burns the remaining time budget. Bounded too,
            // so a slow related-fetch can't push the build past the backstop.
            // Only fan out when the pool came back genuinely short (sparse era /
            // near-sourceless profile). When it's already healthy (e.g. 279/300)
            // the ~10s fan-out pass adds a handful of videos for a big latency
            // cost, so skip it and let the pool go live sooner.
            if (deduped.length < total * 0.6 && deduped.length > 0) {
                try {
                    const related = await this._settleWithin(
                        this._fetchRelatedExpansion(deduped, trendingWindow, total - deduped.length),
                        10000, [], 'related fan-out');
                    if (related.length) {
                        deduped = this._dedupe([...deduped, ...related]);
                        console.log('[bygone] related fan-out added', related.length, '→ pool', deduped.length);
                    }
                } catch (_) {}
            }
            const diverse = this._enforceDiversity(deduped);
            let visible = diverse.filter(v => !Store.isImpressionHidden(v.id));
            console.log('[bygone] feed build: mixed=' + mixed.length + ' deduped=' + deduped.length + ' visible=' + visible.length);
            // Safety valve: if the impression-park filter has whittled
            // the visible pool below a usable threshold, fall back to
            // the unfiltered pool. Better to show seen videos than to
            // show the same 3-4 cards everywhere.
            if (visible.length < 30 && diverse.length > visible.length) visible = diverse;

            // Push recently seen videos to the back so refreshes feel fresh.
            const seen = new Set(Store.getSeenIds());
            const unseen = visible.filter(v => !seen.has(v.id));
            const seenVids = visible.filter(v => seen.has(v.id));
            const ordered = [
                ...this._weightedShuffle(unseen,   anchor),
                ...this._weightedShuffle(seenVids, anchor),
            ];
            // Always record impressions (not gated on useCache) so the
            // park logic keeps running — that's what stops the same
            // videos reappearing across refreshes. Slice is small (20)
            // so that only videos actually likely to be SHOWN this load
            // get counted; recording the whole 150-video pool would mean
            // a couple of reloads parks everything the API returns.
            const top20 = ordered.slice(0, 20);
            Store.recordImpressions(top20.map(v => v.id));
            try {
                Store.recordFeedImpressions(top20);
                for (const v of top20) Store.recordSourceImpression(v.source || 'unknown');
            } catch (_) {}
            return ordered;
        }
    }

    // ============================================================
    //  UI — floating panel + FAB. Panel-only CSS (no YouTube-layout
    //  overrides; V3 already provides the 2013 look).
    // ============================================================

    // CSS uses !important throughout because V3 / YouTube CSS targets
    // a lot of selectors aggressively (e.g. `body button { display: none }`
    // in some V3 layouts) and would otherwise hide our FAB.
    const CSS = `
        #wbt-fab {
            position: fixed !important;
            bottom: calc(82px + env(safe-area-inset-bottom, 0px)) !important;
            right: calc(16px + env(safe-area-inset-right, 0px)) !important;
            z-index: 2147483646 !important;
            width: var(--bygone-fab-size, 58px) !important; height: var(--bygone-fab-size, 58px) !important;
            border-radius: 50% !important;
            background: #c00 !important; color: #fff !important;
            border: 1px solid #800 !important;
            font: bold var(--bygone-fab-font, 23px) sans-serif !important;
            line-height: var(--bygone-fab-size, 58px) !important;
            cursor: pointer !important;
            box-shadow: 0 2px 8px rgba(0,0,0,.4) !important;
            display: block !important; visibility: visible !important; opacity: 1 !important;
            padding: 0 !important; margin: 0 !important;
            transform: none !important; zoom: 1 !important;
        }
        #wbt-fab:hover { background: #e00 !important; }
        #wbt-panel {
            position: fixed !important;
            bottom: calc(140px + env(safe-area-inset-bottom, 0px)) !important;
            right: 6px !important;
            z-index: 2147483647 !important;
            max-width: calc(100vw - 12px) !important;
            width: min(var(--bygone-panel-width, 430px), calc(100vw - 12px)) !important;
            max-height: calc(100vh - 158px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)) !important;
            overflow-y: auto !important;
            background: #f5f5f5 !important; color: #222 !important;
            border: 1px solid #888 !important; border-radius: 4px !important;
            box-shadow: 0 2px 12px rgba(0,0,0,.4) !important;
            font: var(--bygone-control-size, 16px) sans-serif !important;
            visibility: visible !important; opacity: 1 !important;
            transform: none !important; zoom: 1 !important;
        }
        #wbt-panel.wbt-hidden { display: none !important; }
        .wbt-h { background: linear-gradient(#e8e8e8, #d4d4d4); padding: 10px 12px;
            font: bold var(--bygone-control-title-size, 17px) sans-serif; border-bottom: 1px solid #aaa; cursor: move; }
        .wbt-close { float: right; cursor: pointer; color: #666; font-weight: normal; }
        .wbt-close:hover { color: #c00; }
        .wbt-body { padding: 10px 12px; }
        .wbt-sec { margin-bottom: 14px; }
        .wbt-sec h4 { margin: 0 0 8px; font: bold var(--bygone-control-size, 16px) sans-serif; color: #333;
            border-bottom: 1px dotted #aaa; padding-bottom: 3px; }
        .wbt-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 6px 0; }
        .wbt-row input[type="text"], .wbt-row input[type="date"], .wbt-row input[type="number"],
        .wbt-row select { flex: 1 1 150px; min-height: var(--bygone-control-height, 42px); padding: 7px 9px; border: 1px solid #aaa;
            border-radius: 2px; font: var(--bygone-control-size, 16px) sans-serif; min-width: 0; box-sizing: border-box; }
        .wbt-file { flex: 1 1 190px; max-width: 100%; min-height: var(--bygone-control-height, 42px); font: var(--bygone-control-size, 16px) sans-serif; color: #222; }
        .wbt-btn { min-height: var(--bygone-control-height, 42px); padding: 7px 12px; border: 1px solid #888; background: #ddd;
            border-radius: 2px; cursor: pointer; font: var(--bygone-control-size, 16px) sans-serif; white-space: nowrap; box-sizing: border-box; }
        .wbt-btn:hover { background: #e8e8e8; }
        .wbt-btn-primary { background: #c00; color: #fff; border-color: #800; }
        .wbt-btn-primary:hover { background: #e00; }
        .wbt-btn-x { padding: 0 6px; font-weight: bold; color: #c00; }
        .wbt-list { background: #fff; border: 1px solid #aaa; border-radius: 2px;
            min-height: var(--bygone-control-height, 42px); max-height: 180px; overflow-y: auto; }
        .wbt-item { min-height: var(--bygone-control-height, 42px); padding: 7px 9px; border-bottom: 1px dotted #ddd;
            display: flex; align-items: center; gap: 8px; cursor: grab; }
        .wbt-item:last-child { border-bottom: 0; }
        .wbt-item.dragging { opacity: .4; }
        .wbt-item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
        .wbt-item-weight { width: 40px; }
        .wbt-toggle { min-height: var(--bygone-control-height, 42px); display: flex; align-items: center; gap: 8px; margin: 4px 0; cursor: pointer; }
        .wbt-tabs { display: flex; gap: 0; border-bottom: 1px solid #aaa; overflow-x: auto; }
        .wbt-tab { flex: 1 0 auto; text-align: center; padding: 10px 11px; cursor: pointer; border: 1px solid transparent;
            border-bottom: 0; font-size: var(--bygone-control-size, 16px); }
        .wbt-tab.active { background: #f5f5f5; border-color: #aaa; }
        .wbt-tabbody { display: none; }
        .wbt-tabbody.active { display: block; }
        .wbt-mute { color: #666; font-size: var(--bygone-control-small, 14px); line-height: 1.4; }
        .wbt-pill { display: inline-block; background: #ddd; padding: 1px 5px; border-radius: 8px;
            font-size: var(--bygone-control-small, 14px); margin-right: 3px; }
        .wbt-stats td { padding: 1px 6px 1px 0; }
        #wbt-dep-modal {
            position: fixed !important;
            inset: 0 !important;
            z-index: 2147483647 !important;
            background: rgba(0,0,0,.55) !important;
            display: flex !important;
            align-items: center !important;
            justify-content: center !important;
            visibility: visible !important;
            opacity: 1 !important;
            font: 12px sans-serif !important;
            color: #222 !important;
        }
        #wbt-dep-modal-box {
            width: min(420px, calc(100vw - 28px)) !important;
            background: #f5f5f5 !important;
            border: 1px solid #777 !important;
            box-shadow: 0 4px 22px rgba(0,0,0,.45) !important;
            padding: 12px !important;
            border-radius: 3px !important;
        }
        .wbt-dep-actions {
            display: flex !important;
            flex-wrap: wrap !important;
            gap: 6px !important;
            margin-top: 10px !important;
        }
    `;

    class UI {
        constructor(api, feedEngine) {
            this.api = api;
            this.feedEngine = feedEngine;
            this._dragSrc = null;
            this._feedStatus = '';
            this._feedStatusTimer = null;
            this._activeTab = _checkV3() ? 'feed' : 'setup';
        }

        init() {
            // Try GM_addStyle (polish). ALSO inject a manual <style> element
            // (so the styles survive even if GM_addStyle is blocked). The
            // layout-critical styles are duplicated inline on each container
            // below so the panel works even if the <style> is stripped by V3.
            try { GM_addStyle(CSS); } catch {}
            try {
                if (!document.getElementById('wbt-style')) {
                    const s = document.createElement('style');
                    s.id = 'wbt-style';
                    s.textContent = CSS;
                    (document.head || document.documentElement).appendChild(s);
                }
            } catch {}
            if (!document.body) {
                document.addEventListener('DOMContentLoaded', () => this.init(), { once: true });
                return;
            }
            this._mountFab();
            this._mountPanel();
        }

        // Apply a style object to an element using setProperty + 'important'
        // so no other stylesheet can override us.
        _style(el, props) {
            for (const k in props) {
                try { el.style.setProperty(k, props[k], 'important'); } catch {}
            }
        }

        _mountFab() {
            if (document.getElementById('wbt-fab')) return;
            const fab = this._el('button', 'wbt-fab', '⏲');
            fab.id = 'wbt-fab';
            fab.title = 'bygone-yt — open panel';
            // Inline critical styles so the FAB shows even if CSS was stripped.
            this._style(fab, {
                position: 'fixed',
                bottom: 'calc(82px + env(safe-area-inset-bottom, 0px))',
                right: 'calc(16px + env(safe-area-inset-right, 0px))',
                'z-index': '2147483646',
                width: '56px', height: '56px',
                'border-radius': '50%',
                background: '#c00', color: '#fff',
                border: '1px solid #800',
                font: 'bold 22px sans-serif',
                'line-height': '56px',
                cursor: 'pointer',
                'box-shadow': '0 2px 8px rgba(0,0,0,.4)',
                display: 'block', visibility: 'visible', opacity: '1',
                transform: 'none', zoom: '1',
                padding: '0', margin: '0',
            });
            fab.onclick = (e) => {
                e.preventDefault();
                e.stopPropagation();
                const panel = document.getElementById('wbt-panel');
                if (panel) {
                    const wasHidden = panel.classList.contains('wbt-hidden');
                    panel.classList.toggle('wbt-hidden');
                    // Belt-and-suspenders: toggle inline display too.
                    panel.style.setProperty('display', wasHidden ? 'block' : 'none', 'important');
                }
            };
            // Stays in the DOM even when hidden: the kiosk bottom nav opens
            // the panel by click()ing this element, and the 5s remount
            // watchdog checks for its existence.
            if (Store.getHideFab()) fab.style.setProperty('display', 'none', 'important');
            (document.body || document.documentElement).appendChild(fab);
            this._wireFabShortcut();
        }

        _wireFabShortcut() {
            if (UI._fabShortcutWired) return;
            UI._fabShortcutWired = true;
            // Ctrl+Shift+B brings the button back (or hides it again) —
            // without this a hidden FAB on desktop is unrecoverable.
            document.addEventListener('keydown', (e) => {
                if (!e.ctrlKey || !e.shiftKey || (e.key !== 'B' && e.key !== 'b')) return;
                e.preventDefault();
                const next = !Store.getHideFab();
                Store.setHideFab(next);
                const fab = document.getElementById('wbt-fab');
                if (fab) fab.style.setProperty('display', next ? 'none' : 'block', 'important');
            }, true);
        }

        _mountPanel() {
            const old = document.getElementById('wbt-panel');
            if (old) old.remove();
            const p = this._el('div');
            p.id = 'wbt-panel';
            p.classList.add('wbt-hidden');
            // Inline critical layout styles. CSS classes provide polish on
            // top; if CSS is stripped, the panel still renders correctly.
            this._style(p, {
                position: 'fixed',
                bottom: 'calc(140px + env(safe-area-inset-bottom, 0px))',
                right: '6px',
                'z-index': '2147483647',
                width: 'min(var(--bygone-panel-width, 430px), calc(100vw - 12px))',
                'max-width': 'calc(100vw - 12px)',
                'max-height': 'calc(100vh - 158px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))',
                'overflow-y': 'auto',
                background: '#f5f5f5', color: '#222',
                border: '1px solid #888', 'border-radius': '4px',
                'box-shadow': '0 2px 12px rgba(0,0,0,.4)',
                font: 'var(--bygone-control-size, 16px) sans-serif',
                display: 'none',                      // start hidden
                'pointer-events': 'auto',             // defeat any global pe:none
                transform: 'none', zoom: '1',
                padding: '0', margin: '0',
            });
            this._renderPanel(p);
            (document.body || document.documentElement).appendChild(p);
        }

        _renderPanel(p) {
            p.innerHTML = '';

            const header = this._el('div', 'wbt-h', 'bygone-yt v' + VERSION);
            this._style(header, {
                background: 'linear-gradient(#e8e8e8, #d4d4d4)',
                padding: '10px 12px', 'font-weight': 'bold',
                'border-bottom': '1px solid #aaa', cursor: 'move',
                color: '#222', font: 'bold var(--bygone-control-title-size, 17px) sans-serif',
            });
            const close = this._el('span', 'wbt-close', '✕');
            this._style(close, { float: 'right', cursor: 'pointer', color: '#666', 'font-weight': 'normal' });
            close.onclick = () => {
                p.classList.add('wbt-hidden');
                p.style.setProperty('display', 'none', 'important');
            };
            header.appendChild(close);
            this._enableDrag(p, header);
            p.appendChild(header);

            const tabs = this._el('div', 'wbt-tabs');
            this._style(tabs, { display: 'flex', gap: '0', 'border-bottom': '1px solid #aaa', background: '#eee', 'overflow-x': 'auto' });
            const body = this._el('div', 'wbt-body');
            this._style(body, { padding: '10px 12px' });

            const sections = [
                ['feed',     'Feed',     () => this._renderFeed()],
                ['sources',  'Sources',  () => this._renderSources()],
                ['profiles', 'Profiles', () => this._renderProfiles()],
                ['look',     'Look',     () => this._renderLook()],
                ['stats',    'Stats',    () => this._renderStats()],
                ['setup',    'Setup',    () => this._renderSetup()],
            ];
            for (const [id, label, render] of sections) {
                const tab = this._el('div', 'wbt-tab', label);
                this._style(tab, {
                    flex: '1 0 auto', padding: '10px 11px', cursor: 'pointer',
                    border: '1px solid transparent', 'border-bottom': '0',
                    'font-size': 'var(--bygone-control-size, 16px)', 'text-align': 'center',
                    background: id === this._activeTab ? '#f5f5f5' : 'transparent',
                    'border-top-color': id === this._activeTab ? '#aaa' : 'transparent',
                    'border-left-color': id === this._activeTab ? '#aaa' : 'transparent',
                    'border-right-color': id === this._activeTab ? '#aaa' : 'transparent',
                });
                if (id === this._activeTab) tab.classList.add('active');
                tab.onclick = () => {
                    this._activeTab = id;
                    this._renderPanel(p);
                };
                tabs.appendChild(tab);
            }
            const section = sections.find(s => s[0] === this._activeTab);
            const content = section ? section[2]() : this._renderFeed();
            body.appendChild(content);
            p.appendChild(tabs);
            p.appendChild(body);
        }

        // Style helpers used by section renderers below.
        _styleBtn(b, primary) {
            this._style(b, {
                'min-height': 'var(--bygone-control-height, 42px)', padding: '7px 12px',
                border: '1px solid ' + (primary ? '#800' : '#888'),
                background: primary ? '#c00' : '#ddd',
                color: primary ? '#fff' : '#222',
                'border-radius': '2px', cursor: 'pointer',
                font: 'var(--bygone-control-size, 16px) sans-serif', 'white-space': 'nowrap',
                'box-sizing': 'border-box',
            });
        }
        _styleInput(i) {
            this._style(i, {
                flex: '1 1 150px', 'min-height': 'var(--bygone-control-height, 42px)', padding: '7px 9px',
                border: '1px solid #aaa', 'border-radius': '2px',
                font: 'var(--bygone-control-size, 16px) sans-serif', 'min-width': '0',
                background: '#fff', color: '#222',
                'box-sizing': 'border-box',
            });
        }
        _styleRow(r) {
            this._style(r, { display: 'flex', gap: '8px', 'align-items': 'center', 'flex-wrap': 'wrap', margin: '6px 0' });
        }
        _styleSec(s) {
            this._style(s, { 'margin-bottom': '14px' });
        }
        _styleH4(h) {
            this._style(h, {
                margin: '0 0 8px', 'font-size': 'var(--bygone-control-size, 16px)', color: '#333',
                'border-bottom': '1px dotted #aaa', 'padding-bottom': '3px',
                font: 'bold var(--bygone-control-size, 16px) sans-serif',
            });
        }
        _styleList(l) {
            this._style(l, {
                background: '#fff', border: '1px solid #aaa',
                'border-radius': '2px', 'min-height': 'var(--bygone-control-height, 42px)',
                'max-height': '180px', 'overflow-y': 'auto',
            });
        }
        _styleItem(it) {
            this._style(it, {
                'min-height': 'var(--bygone-control-height, 42px)', padding: '7px 9px', 'border-bottom': '1px dotted #ddd',
                display: 'flex', 'align-items': 'center', gap: '6px', cursor: 'grab',
            });
        }
        _styleMute(m) {
            this._style(m, { color: '#666', 'font-size': 'var(--bygone-control-small, 14px)', 'line-height': '1.4' });
        }
        _styleToggle(l) {
            this._style(l, { 'min-height': 'var(--bygone-control-height, 42px)', display: 'flex', 'align-items': 'center', gap: '8px', margin: '4px 0', cursor: 'pointer' });
        }

        // ---- Tabs --------------------------------------------------

        _renderFeed() {
            const wrap = this._el('div');
            // Active toggle — pausing stands the whole replacement machinery
            // down (pool cleared, hide-CSS off, sweeps idle) so YouTube shows
            // its own videos; re-enabling rebuilds the era pool.
            wrap.appendChild(this._toggle('Active', Store.isActive(), v => {
                Store.setActive(v);
                if (v) {
                    this._reloadFeed();
                } else {
                    try { Interceptor.clearPool(); } catch (_) {}
                    try { Interceptor.setPaused(true); } catch (_) {}
                    this._setFeedStatus('Paused — YouTube is showing its own videos. Reload the page for a clean reset.', 8000);
                }
            }));

            // Date picker
            const dateSec = this._el('div', 'wbt-sec');
            dateSec.appendChild(this._el('h4', null, 'Date'));
            const dateRow = this._el('div', 'wbt-row');
            const date = Store.getDate() || '';
            const dateInput = this._el('input');
            dateInput.type = 'date';
            dateInput.value = date;
            const applyDate = (val) => {
                if (!/^\d{4}-\d{2}-\d{2}$/.test(val) || isNaN(new Date(val).getTime())) return;
                // Re-anchor the rolling clock at the newly chosen date instead
                // of stopping it, so picking an era keeps it rolling from there.
                if (Store.isClockActive()) Store.startClock(val);
                else Store.setDate(val);
                if (dateInput.value !== val) dateInput.value = val;
                if (dateText.value !== val) dateText.value = val;
                this._clearFeedCaches();
                this._reloadFeed();
            };
            dateInput.onchange = () => applyDate(dateInput.value);
            dateRow.appendChild(dateInput);
            // Typeable fallback: on mobile (e.g. the Android kiosk) the native
            // <input type=date> picker often won't open a keyboard, so a plain
            // text box lets the user type YYYY-MM-DD. Kept in sync with the picker.
            const dateText = this._el('input');
            dateText.type = 'text';
            dateText.placeholder = 'YYYY-MM-DD';
            dateText.value = date;
            dateText.setAttribute('inputmode', 'numeric');
            dateText.setAttribute('autocomplete', 'off');
            dateText.maxLength = 10;
            this._style(dateText, { 'max-width': '120px' });
            dateText.onchange = () => applyDate(dateText.value.trim());
            dateText.addEventListener('keydown', (e) => {
                if (e.key === 'Enter') { e.preventDefault(); applyDate(dateText.value.trim()); }
            });
            dateRow.appendChild(dateText);
            dateSec.appendChild(dateRow);

            // Era quick-jump: one tap sets the year, keeping today's month/day
            // (same shape as the default-era logic). Mainly for the kiosk,
            // where typing a date is a chore.
            const presetRow = this._el('div', 'wbt-row');
            for (const y of [2008, 2011, 2014, 2017]) {
                const b = this._el('button', 'wbt-btn', String(y));
                b.title = 'Jump to ' + y;
                b.onclick = () => {
                    const now = new Date();
                    let day = now.getDate();
                    const month = now.getMonth();
                    const isLeap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
                    if (month === 1 && day === 29 && !isLeap) day = 28;
                    applyDate(Store._formatLocalDate(new Date(y, month, day)));
                };
                presetRow.appendChild(b);
            }
            dateSec.appendChild(presetRow);

            // Clock
            const clockRow = this._el('div', 'wbt-row');
            const clockBtn = this._el('button', 'wbt-btn', Store.isClockActive() ? 'Stop clock' : 'Start rolling clock');
            clockBtn.onclick = () => {
                if (Store.isClockActive()) Store.stopClock();
                else if (dateInput.value) Store.startClock(dateInput.value);
                this._renderPanel(document.getElementById('wbt-panel'));
                this._reloadFeed();
            };
            clockRow.appendChild(clockBtn);
            dateSec.appendChild(clockRow);
            if (Store.isClockActive()) {
                const now = Store.getCurrentDate();
                dateSec.appendChild(this._el('div', 'wbt-mute', 'Sim time: ' + now));
            }
            wrap.appendChild(dateSec);

            // Source toggles
            const togSec = this._el('div', 'wbt-sec');
            togSec.appendChild(this._el('h4', null, 'Sources'));
            togSec.appendChild(this._toggle('"Similar" (CF) source', Store.isSimilarEnabled(), v => Store.setSimilarEnabled(v)));
            togSec.appendChild(this._toggle('Discovery', Store.isDiscoveryEnabled(), v => Store.setDiscoveryEnabled(v)));
            togSec.appendChild(this._toggle('Watch-history learning', Store.isLearningEnabled(), v => Store.setLearningEnabled(v)));
            togSec.appendChild(this._toggle('Auto-subscribe on YouTube to bygone subs', Store.isAutoSyncSubs(), v => {
                Store.setAutoSyncSubs(v);
                if (v) App._scheduleSubSync(500);
            }));
            wrap.appendChild(togSec);

            // Reload + clear caches
            const actions = this._el('div', 'wbt-sec');
            const row = this._el('div', 'wbt-row');
            const reload = this._el('button', 'wbt-btn wbt-btn-primary', 'Reload feed');
            reload.onclick = () => this._reloadFeed(reload);
            const clear = this._el('button', 'wbt-btn', 'Clear cache');
            clear.onclick = async () => {
                const n = this._clearFeedCaches();
                this._setFeedStatus('Cleared ' + n + ' cached feed ' + (n === 1 ? 'entry' : 'entries') + ' and reset the pool. Reloading...');
                await this._reloadFeed(clear, 'Cache cleared. ');
            };
            row.appendChild(reload); row.appendChild(clear);
            actions.appendChild(row);
            const status = this._el('div', 'wbt-mute', this._feedStatus || '');
            status.id = 'wbt-feed-status';
            this._style(status, { 'min-height': '14px', 'margin-top': '4px' });
            actions.appendChild(status);
            wrap.appendChild(actions);

            return wrap;
        }

        _renderSources() {
            const wrap = this._el('div');

            // Subscriptions
            const subSec = this._listSection('Subscriptions',
                Store.getSubscriptions(),
                (s) => s.name + (s.weight ? ` (w${s.weight})` : ''),
                'Channel name…',
                async (name) => {
                    if (!name) return;
                    const ch = await this.api.resolveChannel(name).catch(() => null);
                    const subs = Store.getSubscriptions();
                    subs.push({ id: ch ? ch.id : null, name: ch ? ch.name : name, weight: 3 });
                    Store.setSubscriptions(subs);
                    // Newly added → push to YouTube account.
                    App._scheduleSubSync(800);
                },
                (newOrder) => Store.setSubscriptions(newOrder));
            wrap.appendChild(subSec);

            // Search terms
            wrap.appendChild(this._listSection('Search terms',
                Store.getSearchTerms(),
                (t) => (typeof t === 'string' ? t : t.term) + (t.weight ? ` (w${t.weight})` : ''),
                'Search query…',
                (q) => { if (!q) return; const t = Store.getSearchTerms(); t.push({ term: q, weight: 3 }); Store.setSearchTerms(t); },
                (newOrder) => Store.setSearchTerms(newOrder)));

            // Topics
            wrap.appendChild(this._listSection('Custom topics',
                Store.getTopics(),
                (t) => (typeof t === 'string' ? t : t.name) + (t.weight ? ` (w${t.weight})` : ''),
                'Topic name…',
                (n) => { if (!n) return; const t = Store.getTopics(); t.push({ name: n, weight: 3 }); Store.setTopics(t); },
                (newOrder) => Store.setTopics(newOrder)));

            // Categories
            const catSec = this._el('div', 'wbt-sec');
            catSec.appendChild(this._el('h4', null, 'Categories'));
            const selected = new Set(Store.getCategories());
            for (const [id, name] of Object.entries(CONFIG.categories)) {
                const row = this._el('label', 'wbt-toggle');
                const cb = this._el('input');
                cb.type = 'checkbox';
                cb.checked = selected.has(Number(id));
                cb.onchange = () => {
                    if (cb.checked) selected.add(Number(id));
                    else selected.delete(Number(id));
                    Store.setCategories(Array.from(selected));
                };
                row.appendChild(cb);
                row.appendChild(document.createTextNode(' ' + name));
                catSec.appendChild(row);
            }
            wrap.appendChild(catSec);

            // Blocked channels
            wrap.appendChild(this._listSection('Blocked channels',
                Store.getBlockedChannels(),
                (b) => b.name,
                'Channel name to block…',
                (n) => { if (!n) return; const b = Store.getBlockedChannels(); b.push({ name: n, id: null }); Store.setBlockedChannels(b); },
                (newOrder) => Store.setBlockedChannels(newOrder)));

            // Global negatives
            wrap.appendChild(this._listSection('Global negative keywords',
                Store.getGlobalNegatives().map(n => ({ name: n })),
                (n) => n.name,
                'Negative keyword…',
                (n) => { if (!n) return; const list = Store.getGlobalNegatives(); list.push(n); Store.setGlobalNegatives(list); },
                (newOrder) => Store.setGlobalNegatives(newOrder.map(n => n.name))));

            return wrap;
        }

        // Generic list section with add + remove + drag-reorder.
        _listSection(title, items, formatItem, placeholder, onAdd, onReorder) {
            const sec = this._el('div', 'wbt-sec');
            sec.appendChild(this._el('h4', null, title));
            const list = this._el('div', 'wbt-list');
            items.forEach((item, idx) => {
                const row = this._el('div', 'wbt-item');
                row.draggable = true;
                row.dataset.idx = String(idx);
                row.ondragstart = (e) => { this._dragSrc = idx; row.classList.add('dragging'); try { e.dataTransfer.effectAllowed = 'move'; } catch {} };
                row.ondragend = () => row.classList.remove('dragging');
                row.ondragover = (e) => { e.preventDefault(); };
                row.ondrop = (e) => {
                    e.preventDefault();
                    const src = this._dragSrc; this._dragSrc = null;
                    if (src === null || src === idx) return;
                    const reordered = items.slice();
                    const [moved] = reordered.splice(src, 1);
                    reordered.splice(idx, 0, moved);
                    onReorder(reordered);
                    this._renderPanel(document.getElementById('wbt-panel'));
                };
                row.appendChild(this._el('span', 'wbt-item-name', formatItem(item)));
                const rm = this._el('button', 'wbt-btn wbt-btn-x', '×');
                rm.onclick = () => {
                    const next = items.slice();
                    next.splice(idx, 1);
                    onReorder(next);
                    this._renderPanel(document.getElementById('wbt-panel'));
                };
                row.appendChild(rm);
                list.appendChild(row);
            });
            sec.appendChild(list);

            const addRow = this._el('div', 'wbt-row');
            const input = this._el('input');
            input.type = 'text';
            input.placeholder = placeholder;
            const addBtn = this._el('button', 'wbt-btn', '+');
            const doAdd = async () => {
                const val = input.value.trim();
                input.value = '';
                await onAdd(val);
                this._renderPanel(document.getElementById('wbt-panel'));
            };
            addBtn.onclick = doAdd;
            input.onkeydown = (e) => { if (e.key === 'Enter') doAdd(); };
            addRow.appendChild(input);
            addRow.appendChild(addBtn);
            sec.appendChild(addRow);

            return sec;
        }

        _renderProfiles() {
            const wrap = this._el('div');
            wrap.appendChild(this._el('h4', null, 'Profiles'));
            const profiles = Store.getProfiles();
            const names = Object.keys(profiles);

            if (!names.length) wrap.appendChild(this._el('div', 'wbt-mute', 'No saved profiles yet.'));
            for (const name of names) {
                const row = this._el('div', 'wbt-item');
                row.appendChild(this._el('span', 'wbt-item-name', name));
                const load = this._el('button', 'wbt-btn', 'Load');
                load.onclick = () => { Store.loadProfile(name); this._renderPanel(document.getElementById('wbt-panel')); this._reloadFeed(); App._scheduleSubSync(800); };
                const exp  = this._el('button', 'wbt-btn', 'Export');
                exp.onclick = () => {
                    const blob = new Blob([Store.exportProfile(name)], { type: 'application/json' });
                    const url = URL.createObjectURL(blob);
                    const a = this._el('a');
                    a.href = url; a.download = `bygone-profile-${name}.json`;
                    a.click();
                    setTimeout(() => URL.revokeObjectURL(url), 1000);
                };
                const del  = this._el('button', 'wbt-btn wbt-btn-x', '×');
                del.onclick = () => { if (confirm(`Delete profile "${name}"?`)) { Store.deleteProfile(name); this._renderPanel(document.getElementById('wbt-panel')); } };
                row.appendChild(load); row.appendChild(exp); row.appendChild(del);
                wrap.appendChild(row);
            }

            const addRow = this._el('div', 'wbt-row');
            const input = this._el('input');
            input.type = 'text';
            input.placeholder = 'New profile name…';
            const saveBtn = this._el('button', 'wbt-btn', 'Save current as…');
            saveBtn.onclick = () => {
                const n = input.value.trim();
                if (!n) return;
                Store.saveProfile(n);
                input.value = '';
                this._renderPanel(document.getElementById('wbt-panel'));
            };
            const blankBtn = this._el('button', 'wbt-btn', 'New blank');
            blankBtn.onclick = () => {
                const n = input.value.trim();
                if (!n) { input.placeholder = 'enter a name first…'; input.focus(); return; }
                if (Store.getProfiles()[n] && !confirm(`Overwrite "${n}" with a blank profile?`)) return;
                Store.createBlankProfile(n);
                input.value = '';
                this._renderPanel(document.getElementById('wbt-panel'));
            };
            addRow.appendChild(input); addRow.appendChild(saveBtn); addRow.appendChild(blankBtn);
            wrap.appendChild(addRow);

            // Import
            const impRow = this._el('div', 'wbt-row');
            const imp = this._el('input', 'wbt-file');
            imp.type = 'file';
            imp.accept = '.json,application/json,text/json,text/plain';
            imp.onchange = () => {
                const f = imp.files && imp.files[0];
                if (!f) return;
                const r = new FileReader();
                r.onload = () => {
                    try { Store.importProfile(r.result); alert('Profile imported.'); this._renderPanel(document.getElementById('wbt-panel')); }
                    catch (e) { alert('Import failed: ' + e.message); }
                    finally { imp.value = ''; }
                };
                r.readAsText(f);
            };
            impRow.appendChild(imp);
            wrap.appendChild(impRow);

            // Full export/import (ALL data)
            wrap.appendChild(this._el('h4', null, 'Full Backup'));
            const expAllRow = this._el('div', 'wbt-row');
            const expAllBtn = this._el('button', 'wbt-btn wbt-btn-primary', 'Export ALL data');
            expAllBtn.onclick = () => {
                const blob = new Blob([Store.exportAll()], { type: 'application/json' });
                const url = URL.createObjectURL(blob);
                const a = this._el('a');
                a.href = url; a.download = `bygone-yt-full-backup.json`;
                a.click();
                setTimeout(() => URL.revokeObjectURL(url), 1000);
            };
            expAllRow.appendChild(expAllBtn);
            wrap.appendChild(expAllRow);

            const impAllRow = this._el('div', 'wbt-row');
            const impAll = this._el('input', 'wbt-file');
            impAll.type = 'file';
            impAll.accept = '.json,application/json,text/json,text/plain';
            impAll.onchange = () => {
                const f = impAll.files && impAll.files[0];
                if (!f) return;
                const r = new FileReader();
                r.onload = async () => {
                    try {
                        Store.importAll(r.result);
                        const cleared = this._clearFeedCaches();
                        this._broadcastKioskLookPrefs();
                        this._renderPanel(document.getElementById('wbt-panel'));
                        App._scheduleSubSync(800);
                        await this._reloadFeed(null, 'Full backup restored. ');
                        alert('Full backup restored. Cleared ' + cleared + ' old feed cache ' + (cleared === 1 ? 'entry' : 'entries') + ' and reloaded.');
                    }
                    catch (e) { alert('Import failed: ' + e.message); }
                    finally { impAll.value = ''; }
                };
                r.readAsText(f);
            };
            impAllRow.appendChild(impAll);
            wrap.appendChild(impAllRow);

            return wrap;
        }

        _renderLook() {
            const wrap = this._el('div');
            wrap.appendChild(this._el('h4', null, 'APK page look'));

            const dark = this._el('input');
            dark.type = 'checkbox';
            dark.checked = Store.getKioskDarkMode();
            const darkRow = this._el('label', 'wbt-toggle');
            darkRow.appendChild(dark);
            darkRow.appendChild(document.createTextNode('Dark mode'));
            dark.onchange = () => {
                Store.setKioskDarkMode(dark.checked);
                this._broadcastKioskLookPrefs();
            };
            wrap.appendChild(darkRow);

            const hideFab = this._el('input');
            hideFab.type = 'checkbox';
            hideFab.checked = Store.getHideFab();
            const hideFabRow = this._el('label', 'wbt-toggle');
            hideFabRow.appendChild(hideFab);
            hideFabRow.appendChild(document.createTextNode('Hide the floating ⏲ button (Ctrl+Shift+B toggles it back; the kiosk bottom nav still opens this panel)'));
            hideFab.onchange = () => {
                Store.setHideFab(hideFab.checked);
                const fab = document.getElementById('wbt-fab');
                if (fab) fab.style.setProperty('display', hideFab.checked ? 'none' : 'block', 'important');
            };
            wrap.appendChild(hideFabRow);

            const zoomRow = this._el('div', 'wbt-row');
            const zoomLabel = this._el('span', null, 'UI size');
            this._style(zoomLabel, { width: '68px', 'font-size': 'var(--bygone-control-small, 14px)', color: '#333' });
            const zoom = this._el('input');
            zoom.type = 'range';
            zoom.min = '0.9';
            zoom.max = '1.65';
            zoom.step = '0.05';
            zoom.value = String(Store.getKioskZoom());
            this._style(zoom, { flex: '1' });
            const zoomValue = this._el('span', null, Math.round(Store.getKioskZoom() * 100) + '%');
            this._style(zoomValue, { width: '48px', 'font-size': 'var(--bygone-control-small, 14px)', color: '#333', 'text-align': 'right' });
            const resetZoom = this._el('button', 'wbt-btn', 'Reset');
            const applyZoom = () => {
                Store.setKioskZoom(zoom.value);
                zoomValue.textContent = Math.round(Store.getKioskZoom() * 100) + '%';
                this._broadcastKioskLookPrefs();
            };
            zoom.oninput = applyZoom;
            resetZoom.onclick = () => {
                zoom.value = '1.35';
                applyZoom();
            };
            zoomRow.appendChild(zoomLabel);
            zoomRow.appendChild(zoom);
            zoomRow.appendChild(zoomValue);
            zoomRow.appendChild(resetZoom);
            wrap.appendChild(zoomRow);

            const hint = this._el('div', 'wbt-mute', 'These controls are read by the APK page layer. UI size enlarges cards, thumbnails, titles, and touch targets without moving the floating button.');
            wrap.appendChild(hint);

            wrap.appendChild(this._el('h4', null, 'Custom logo'));

            const cur = Store.getCustomLogo();
            if (cur) {
                const img = this._el('img');
                img.src = cur;
                img.style.maxWidth = '100%';
                img.style.maxHeight = '60px';
                wrap.appendChild(img);
            }
            const row = this._el('div', 'wbt-row');
            const file = this._el('input', 'wbt-file');
            file.type = 'file';
            file.accept = 'image/*';
            file.onchange = () => {
                const f = file.files && file.files[0];
                if (!f) return;
                const r = new FileReader();
                r.onload = () => {
                    Store.setCustomLogo(r.result);
                    this._renderPanel(document.getElementById('wbt-panel'));
                    this._applyCustomLogo();
                };
                r.readAsDataURL(f);
            };
            const clear = this._el('button', 'wbt-btn', 'Clear');
            clear.onclick = () => { Store.clearCustomLogo(); this._renderPanel(document.getElementById('wbt-panel')); };
            row.appendChild(file); row.appendChild(clear);
            wrap.appendChild(row);

            return wrap;
        }

        _renderStats() {
            const wrap = this._el('div');
            wrap.appendChild(this._el('h4', null, 'Pool'));
            const pool = this._el('table', 'wbt-stats');
            const row = (k, v) => { const tr = this._el('tr'); tr.appendChild(this._el('td', null, k)); tr.appendChild(this._el('td', null, String(v))); pool.appendChild(tr); };
            row('Pool size',   Interceptor.poolSize());
            row('Used',        Interceptor.usedCount());
            row('Active',      Interceptor.isActive());
            wrap.appendChild(pool);

            wrap.appendChild(this._el('h4', null, 'Learning'));
            const interests = Store.getCachedInterests();
            const lc = interests ? InterestModel.getLearnedChannels(interests) : [];
            const lk = interests ? InterestModel.getLearnedKeywords(interests) : [];
            if (!lc.length && !lk.length) {
                wrap.appendChild(this._el('div', 'wbt-mute', 'No learning data yet — watch some videos to build a profile.'));
            } else {
                wrap.appendChild(this._el('div', 'wbt-mute', 'Top channels:'));
                for (const c of lc.slice(0, 6)) wrap.appendChild(this._el('div', null, `• ${c.name} (${c.score.toFixed(1)})`));
                wrap.appendChild(this._el('div', 'wbt-mute', 'Top keywords:'));
                for (const k of lk.slice(0, 6)) wrap.appendChild(this._el('div', null, `• ${k.keyword}`));
            }

            const clearBtn = this._el('button', 'wbt-btn', 'Clear learning data');
            clearBtn.onclick = () => { if (confirm('Clear all watch history + learning?')) { Store.clearLearningData(); this._renderPanel(document.getElementById('wbt-panel')); } };
            wrap.appendChild(clearBtn);

            return wrap;
        }

        _renderSetup() {
            const wrap = this._el('div');
            wrap.appendChild(this._el('h4', null, 'Required Extensions'));

            const v3Ok = _checkV3();

            const v3Row = this._el('div', 'wbt-row');
            const v3Status = this._el('span', null, v3Ok ? '✅ V3 detected' : '❌ V3 not detected');
            this._style(v3Status, { color: v3Ok ? '#080' : '#c00', 'font-weight': 'bold', 'font-size': 'var(--bygone-control-small, 14px)' });
            v3Row.appendChild(v3Status);
            wrap.appendChild(v3Row);

            if (!v3Ok) {
                const warn = this._el('div');
                this._style(warn, {
                    background: '#fff3cd', border: '1px solid #e0c36a',
                    'border-radius': '4px', padding: '8px 10px', margin: '8px 0',
                    color: '#664d03', 'font-size': 'var(--bygone-control-small, 14px)', 'line-height': '1.5',
                });
                warn.innerHTML = '<b>bygone-yt requires V3 and StarTube to work.</b><br>' +
                    'V3 ("Get Old YouTube Layout") provides the 2013 YouTube layout.<br>' +
                    'StarTube is the companion userscript that V3 depends on.<br><br>' +
                    'Without these, bygone-yt cannot function — the page will not render correctly ' +
                    'and you may experience refresh loops or broken layouts.<br><br>' +
                    '<b>Install both V3 and StarTube first, then reload the page.</b>';
                wrap.appendChild(warn);
            } else {
                const ok = this._el('div', 'wbt-mute', 'V3 is installed and active. bygone-yt is ready to use.');
                wrap.appendChild(ok);
            }

            wrap.appendChild(this._el('h4', null, 'About'));
            const about = this._el('div', 'wbt-mute');
            about.textContent = 'bygone-yt v' + VERSION + ' — YouTube time machine for V3/StarTube. ' +
                'Set a date and browse YouTube as it was back then.';
            wrap.appendChild(about);

            return wrap;
        }

        // ---- Helpers -----------------------------------------------

        // Style map keyed off class name. _el applies these inline whenever
        // an element is created with a known class. This is what makes the
        // UI work even when GM_addStyle is blocked or V3 strips our <style>.
        static _STYLE_MAP = {
            'wbt-sec':   { 'margin-bottom': '14px' },
            'wbt-row':   { display: 'flex', gap: '8px', 'align-items': 'center', 'flex-wrap': 'wrap', margin: '6px 0' },
            'wbt-btn':   {
                'min-height': 'var(--bygone-control-height, 42px)', padding: '7px 12px', border: '1px solid #888', background: '#ddd',
                color: '#222', 'border-radius': '2px', cursor: 'pointer',
                font: 'var(--bygone-control-size, 16px) sans-serif', 'white-space': 'nowrap', 'box-sizing': 'border-box',
            },
            'wbt-btn-primary': {
                'min-height': 'var(--bygone-control-height, 42px)', padding: '7px 12px', border: '1px solid #800', background: '#c00',
                color: '#fff', 'border-radius': '2px', cursor: 'pointer',
                font: 'var(--bygone-control-size, 16px) sans-serif', 'white-space': 'nowrap', 'box-sizing': 'border-box',
            },
            'wbt-btn-x': { 'min-height': 'var(--bygone-control-height, 42px)', padding: '0 8px', 'font-weight': 'bold', color: '#c00', border: '1px solid #888', background: '#ddd', cursor: 'pointer', 'border-radius': '2px', font: 'var(--bygone-control-size, 16px) sans-serif' },
            'wbt-list':  {
                background: '#fff', border: '1px solid #aaa',
                'border-radius': '2px', 'min-height': 'var(--bygone-control-height, 42px)',
                'max-height': '180px', 'overflow-y': 'auto',
            },
            'wbt-item':  {
                'min-height': 'var(--bygone-control-height, 42px)', padding: '7px 9px', 'border-bottom': '1px dotted #ddd',
                display: 'flex', 'align-items': 'center', gap: '8px', cursor: 'grab',
            },
            'wbt-item-name': { flex: '1', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
            'wbt-toggle': { 'min-height': 'var(--bygone-control-height, 42px)', display: 'flex', 'align-items': 'center', gap: '8px', margin: '4px 0', cursor: 'pointer' },
            'wbt-mute':  { color: '#666', 'font-size': 'var(--bygone-control-small, 14px)', 'line-height': '1.4' },
            'wbt-file':  { flex: '1 1 190px', 'max-width': '100%', 'min-height': 'var(--bygone-control-height, 42px)', font: 'var(--bygone-control-size, 16px) sans-serif', color: '#222' },
            'wbt-stats': { 'border-collapse': 'collapse' },
        };

        _el(tag, cls, text) {
            const el = document.createElement(tag);
            if (cls) {
                el.className = cls;
                // Apply inline styles for every class on the element.
                for (const c of cls.split(/\s+/)) {
                    const sty = UI._STYLE_MAP[c];
                    if (sty) for (const k in sty) {
                        try { el.style.setProperty(k, sty[k], 'important'); } catch {}
                    }
                }
            }
            if (text !== undefined && text !== null) el.textContent = text;
            // <h4> headings — pure tag-based default style (used inside panels).
            if (tag === 'h4') {
                try {
                    el.style.setProperty('margin', '0 0 8px', 'important');
                    el.style.setProperty('font', 'bold var(--bygone-control-size, 16px) sans-serif', 'important');
                    el.style.setProperty('color', '#333', 'important');
                    el.style.setProperty('border-bottom', '1px dotted #aaa', 'important');
                    el.style.setProperty('padding-bottom', '3px', 'important');
                } catch {}
            }
            // <input type="text"|"date"|"number">, <select> — style at use site
            // since type isn't known until later. Done with a microtask defer.
            if (tag === 'input' || tag === 'select') {
                queueMicrotask(() => {
                    if (el.type === 'checkbox' || el.type === 'radio' || el.type === 'file') return;
                    try {
                        el.style.setProperty('flex', '1 1 150px', 'important');
                        el.style.setProperty('min-height', 'var(--bygone-control-height, 42px)', 'important');
                        el.style.setProperty('padding', '7px 9px', 'important');
                        el.style.setProperty('border', '1px solid #aaa', 'important');
                        el.style.setProperty('border-radius', '2px', 'important');
                        el.style.setProperty('font', 'var(--bygone-control-size, 16px) sans-serif', 'important');
                        el.style.setProperty('min-width', '0', 'important');
                        el.style.setProperty('box-sizing', 'border-box', 'important');
                        el.style.setProperty('background', '#fff', 'important');
                        el.style.setProperty('color', '#222', 'important');
                    } catch {}
                });
            }
            // Plain buttons (without wbt-btn class) — style anyway.
            if (tag === 'button' && (!cls || !/wbt-btn/.test(cls))) {
                try {
                    el.style.setProperty('min-height', 'var(--bygone-control-height, 42px)', 'important');
                    el.style.setProperty('padding', '7px 12px', 'important');
                    el.style.setProperty('border', '1px solid #888', 'important');
                    el.style.setProperty('background', '#ddd', 'important');
                    el.style.setProperty('color', '#222', 'important');
                    el.style.setProperty('border-radius', '2px', 'important');
                    el.style.setProperty('cursor', 'pointer', 'important');
                    el.style.setProperty('font', 'var(--bygone-control-size, 16px) sans-serif', 'important');
                } catch {}
            }
            return el;
        }

        _toggle(label, value, onChange) {
            const lab = this._el('label', 'wbt-toggle');
            const cb = this._el('input');
            cb.type = 'checkbox';
            cb.checked = !!value;
            cb.onchange = () => onChange(cb.checked);
            lab.appendChild(cb);
            lab.appendChild(document.createTextNode(' ' + label));
            return lab;
        }

        // Called on every panel re-render (each tab switch), so the document-
        // level move/up listeners are bound exactly once and read the current
        // drag state from the instance; only the per-render header gets a
        // fresh mousedown.
        _enableDrag(panel, handle) {
            handle.addEventListener('mousedown', (e) => {
                if (e.target.tagName === 'SPAN' || e.target.tagName === 'BUTTON') return;
                const r = panel.getBoundingClientRect();
                this._dragState = { panel, ox: e.clientX - r.left, oy: e.clientY - r.top };
                e.preventDefault();
            });
            if (this._dragDocBound) return;
            this._dragDocBound = true;
            document.addEventListener('mousemove', (e) => {
                const d = this._dragState;
                if (!d) return;
                d.panel.style.left = (e.clientX - d.ox) + 'px';
                d.panel.style.top  = (e.clientY - d.oy) + 'px';
                d.panel.style.right = 'auto';
                d.panel.style.bottom = 'auto';
            });
            document.addEventListener('mouseup', () => { this._dragState = null; });
        }

        _setFeedStatus(text, ttlMs) {
            this._feedStatus = text || '';
            const el = document.getElementById('wbt-feed-status');
            if (el) el.textContent = this._feedStatus;
            if (this._feedStatusTimer) clearTimeout(this._feedStatusTimer);
            if (ttlMs) {
                this._feedStatusTimer = setTimeout(() => {
                    this._feedStatus = '';
                    const cur = document.getElementById('wbt-feed-status');
                    if (cur) cur.textContent = '';
                }, ttlMs);
            }
        }

        _clearFeedCaches() {
            let n = 0;
            try {
                for (const k of GM_listValues()) {
                    if (!k.startsWith('bygone_cache_')) continue;
                    GM_deleteValue(k);
                    n++;
                }
            } catch (_) {}
            try { Interceptor.clearPool(); } catch (_) {}
            return n;
        }

        async _reloadFeed(button, successPrefix) {
            const label = button && button.textContent;
            if (button) {
                button.disabled = true;
                button.textContent = 'Reloading...';
            }
            this._setFeedStatus((successPrefix || '') + 'Reloading feed...');
            try {
                const result = await App.primeInterceptor();
                if (result && result.ok) {
                    // Re-roll the cards already on screen against the fresh pool;
                    // without this the pool reloads but the visible feed is frozen.
                    try { Interceptor.forceResweep(); } catch (_) {}
                    this._setFeedStatus((successPrefix || '') + 'Loaded ' + result.count + ' era videos.', 5000);
                } else {
                    const msg = result && result.message ? result.message : 'No era videos loaded.';
                    this._setFeedStatus((successPrefix || '') + msg, 8000);
                }
                return result;
            } catch (e) {
                console.warn('[bygone] reload failed:', e);
                this._setFeedStatus('Reload failed: ' + (e && e.message ? e.message : e), 8000);
                return { ok: false, count: 0, error: e };
            } finally {
                if (button) {
                    button.disabled = false;
                    button.textContent = label;
                }
            }
        }

        _applyCustomLogo() {
            const url = Store.getCustomLogo();
            if (!url) return;
            const apply = () => {
                document.querySelectorAll('#logo img, #logo-icon img, .logo-icon img, a#logo img, .v3-logo img, #masthead-logo-link img')
                    .forEach(img => { img.src = url; });
            };
            apply();
            setTimeout(apply, 800);
            setTimeout(apply, 2500);
        }

        _ensureKioskLookStyle() {
            const css = [
                'html[data-bygone-kiosk-dark="1"],html[data-bygone-kiosk-dark="1"] body{background:#0f0f0f!important;color:#e8eaed!important;}',
                'html[data-bygone-kiosk-dark="1"] #page,html[data-bygone-kiosk-dark="1"] #content,html[data-bygone-kiosk-dark="1"] #body-container,html[data-bygone-kiosk-dark="1"] #masthead,html[data-bygone-kiosk-dark="1"] #watch7-container,html[data-bygone-kiosk-dark="1"] #watch7-main-container,html[data-bygone-kiosk-dark="1"] #watch7-sidebar,html[data-bygone-kiosk-dark="1"] #guide,html[data-bygone-kiosk-dark="1"] .yt-card,html[data-bygone-kiosk-dark="1"] .feed-item-container,html[data-bygone-kiosk-dark="1"] .yt-lockup,html[data-bygone-kiosk-dark="1"] .comment,html[data-bygone-kiosk-dark="1"] .post{background:#0f0f0f!important;color:#e8eaed!important;border-color:#303134!important;}',
                'html[data-bygone-kiosk-dark="1"] .metadata,html[data-bygone-kiosk-dark="1"] .yt-lockup-meta,html[data-bygone-kiosk-dark="1"] .yt-lockup-byline{color:#aeb3b8!important;}',
                'html[data-bygone-kiosk-dark="1"] a{color:#8ab4f8!important;}',
                'html,body{touch-action:pan-y!important;overscroll-behavior:none!important;overscroll-behavior-x:none!important;overscroll-behavior-y:none!important;}',
                'html[data-bygone-kiosk-zoom="1"]{--bygone-kiosk-zoom:1.35;--bygone-feed-columns:1;--bygone-feed-gap:6px;--bygone-feed-pad:0px;--bygone-feed-max:100vw;--bygone-watch-pad:0px;--bygone-watch-width:100vw;--bygone-home-top-gap:10px;--bygone-body-size:16px;--bygone-title-size:21px;--bygone-meta-size:15px;--bygone-control-size:16px;--bygone-control-small:14px;--bygone-control-title-size:17px;--bygone-control-height:42px;--bygone-panel-width:430px;--bygone-fab-size:58px;--bygone-fab-font:23px;width:100%!important;max-width:100vw!important;min-width:0!important;overflow-x:hidden!important;overscroll-behavior:none!important;-webkit-text-size-adjust:100%!important;text-size-adjust:100%!important;}',
                '@media (min-width:600px){html[data-bygone-kiosk-zoom="1"]{--bygone-feed-columns:2;--bygone-feed-gap:8px;--bygone-feed-pad:4px;--bygone-feed-max:100vw;--bygone-watch-width:100vw;}}',
                'html[data-bygone-kiosk-zoom="1"],html[data-bygone-kiosk-zoom="1"] body{width:100%!important;max-width:100vw!important;min-width:0!important;overflow-x:hidden!important;}',
                '@supports (overflow:clip){html[data-bygone-kiosk-zoom="1"],html[data-bygone-kiosk-zoom="1"] body{overflow-x:clip!important;}}',
                'html[data-bygone-kiosk-zoom="1"] body{font-size:var(--bygone-body-size)!important;line-height:1.35!important;transform:none!important;zoom:1!important;margin:0!important;padding-bottom:calc(88px + env(safe-area-inset-bottom,0px))!important;overscroll-behavior:none!important;}',
                'html[data-bygone-kiosk-zoom="1"] #wbt-fab{position:fixed!important;right:calc(14px + env(safe-area-inset-right,0px))!important;bottom:calc(78px + env(safe-area-inset-bottom,0px))!important;width:var(--bygone-fab-size)!important;height:var(--bygone-fab-size)!important;font-size:var(--bygone-fab-font)!important;line-height:var(--bygone-fab-size)!important;transform:none!important;zoom:1!important;}',
                'html[data-bygone-kiosk-zoom="1"] #wbt-panel{position:fixed!important;right:6px!important;bottom:calc(140px + env(safe-area-inset-bottom,0px))!important;width:min(var(--bygone-panel-width),calc(100vw - 12px))!important;max-width:calc(100vw - 12px)!important;max-height:calc(100vh - 158px - env(safe-area-inset-top,0px) - env(safe-area-inset-bottom,0px))!important;font-size:var(--bygone-control-size)!important;line-height:1.35!important;transform:none!important;zoom:1!important;box-sizing:border-box!important;}',
                'html[data-bygone-kiosk-zoom="1"] #wbt-panel,html[data-bygone-kiosk-zoom="1"] #wbt-panel *{font-size:var(--bygone-control-size)!important;line-height:1.35!important;box-sizing:border-box!important;}',
                'html[data-bygone-kiosk-zoom="1"] #wbt-panel .wbt-h{font-size:var(--bygone-control-title-size)!important;}',
                'html[data-bygone-kiosk-zoom="1"] #wbt-panel .wbt-mute,html[data-bygone-kiosk-zoom="1"] #wbt-panel .wbt-pill{font-size:var(--bygone-control-small)!important;}',
                'html[data-bygone-kiosk-zoom="1"] #wbt-panel button,html[data-bygone-kiosk-zoom="1"] #wbt-panel input,html[data-bygone-kiosk-zoom="1"] #wbt-panel select{font-size:var(--bygone-control-size)!important;min-height:var(--bygone-control-height)!important;}',
                'html[data-bygone-kiosk-zoom="1"] #masthead-positioner,html[data-bygone-kiosk-zoom="1"] #yt-masthead-container,html[data-bygone-kiosk-zoom="1"] #masthead,html[data-bygone-kiosk-zoom="1"] #masthead-container{left:0!important;right:auto!important;width:100vw!important;max-width:100vw!important;min-width:0!important;margin-left:0!important;margin-right:0!important;box-sizing:border-box!important;overflow:hidden!important;transform:none!important;}',
                'html[data-bygone-kiosk-zoom="1"] #yt-masthead-container,html[data-bygone-kiosk-zoom="1"] #masthead-container,html[data-bygone-kiosk-zoom="1"] #masthead{display:flex!important;align-items:center!important;gap:6px!important;padding:4px 6px!important;}',
                'html[data-bygone-kiosk-zoom="1"] #masthead-search,html[data-bygone-kiosk-zoom="1"] #masthead-search-form,html[data-bygone-kiosk-zoom="1"] form[action="/results"],html[data-bygone-kiosk-zoom="1"] #search-form{position:static!important;float:none!important;display:flex!important;flex:1 1 auto!important;width:auto!important;max-width:none!important;min-width:0!important;margin:0!important;box-sizing:border-box!important;}',
                'html[data-bygone-kiosk-zoom="1"] #masthead-search-terms,html[data-bygone-kiosk-zoom="1"] input[name="search_query"],html[data-bygone-kiosk-zoom="1"] input#search,html[data-bygone-kiosk-zoom="1"] input#masthead-search-term{display:block!important;visibility:visible!important;opacity:1!important;width:100%!important;max-width:100%!important;min-width:0!important;height:40px!important;box-sizing:border-box!important;}',
                'html[data-bygone-kiosk-zoom="1"] #masthead-search-terms{flex:1 1 auto!important;margin:0!important;padding:0!important;overflow:hidden!important;background:#fff!important;border:1px solid #8a8a8a!important;border-radius:2px 0 0 2px!important;}',
                'html[data-bygone-kiosk-zoom="1"] input[name="search_query"],html[data-bygone-kiosk-zoom="1"] input#search,html[data-bygone-kiosk-zoom="1"] input#masthead-search-term{font-size:18px!important;line-height:40px!important;padding:0 8px!important;border:0!important;background:#fff!important;color:#111!important;}',
                'html[data-bygone-kiosk-zoom="1"] #masthead-search button,html[data-bygone-kiosk-zoom="1"] #masthead-search-form button,html[data-bygone-kiosk-zoom="1"] #search-form button,html[data-bygone-kiosk-zoom="1"] .search-btn-component{flex:0 0 48px!important;width:48px!important;min-width:48px!important;height:40px!important;margin:0!important;padding:0!important;}',
                'html[data-bygone-kiosk-zoom="1"] #masthead-upload-button-group,html[data-bygone-kiosk-zoom="1"] #masthead-user,html[data-bygone-kiosk-zoom="1"] #yt-masthead-user,html[data-bygone-kiosk-zoom="1"] #masthead-signin,html[data-bygone-kiosk-zoom="1"] #yt-masthead-signin{display:none!important;}',
                'html[data-bygone-kiosk-zoom="1"] #guide,html[data-bygone-kiosk-zoom="1"] #guide-container{display:none!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) #content{padding-top:var(--bygone-home-top-gap)!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) #page,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) #content,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) #body-container,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-card,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .feed-item-container,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .feed-item-main,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .feed-item-main-content{width:100%!important;max-width:var(--bygone-feed-max)!important;min-width:0!important;max-height:none!important;height:auto!important;box-sizing:border-box!important;margin-left:auto!important;margin-right:auto!important;padding-left:var(--bygone-feed-pad)!important;padding-right:var(--bygone-feed-pad)!important;overflow:visible!important;overflow-x:clip!important;touch-action:pan-y!important;-webkit-overflow-scrolling:auto!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) #page,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) #content,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) #body-container,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) #content-container,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) #main,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) #feed,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .feed-list{width:100vw!important;max-width:100vw!important;min-width:0!important;margin-left:0!important;margin-right:0!important;padding-left:var(--bygone-feed-pad)!important;padding-right:var(--bygone-feed-pad)!important;left:0!important;right:auto!important;transform:none!important;box-sizing:border-box!important;overflow-x:hidden!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .shelf-wrapper,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .multirow-shelf,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-uix-expander,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-uix-shelfslider-body{display:block!important;width:100%!important;max-width:none!important;max-height:none!important;height:auto!important;overflow:visible!important;overflow-y:visible!important;touch-action:pan-y!important;-webkit-overflow-scrolling:auto!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .shelf-content,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-uix-shelfslider-list,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-shelf-grid,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .channels-browse-content-grid,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .lohp-medium-shelves-container{display:grid!important;grid-template-columns:repeat(var(--bygone-feed-columns),minmax(0,1fr))!important;gap:var(--bygone-feed-gap)!important;width:100%!important;max-width:none!important;max-height:none!important;height:auto!important;overflow:visible!important;overflow-y:visible!important;padding:0!important;margin:0!important;touch-action:pan-y!important;-webkit-overflow-scrolling:auto!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-uix-shelfslider-prev,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-uix-shelfslider-next,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-uix-slider-prev,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-uix-slider-next{display:none!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-uix-shelfslider-item,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-lockup,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .context-data-item.yt-lockup,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .lohp-medium-shelf,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .lohp-large-shelf-container{display:block!important;width:100%!important;max-width:none!important;max-height:none!important;height:auto!important;min-width:0!important;margin:0!important;padding:6px 0 14px!important;box-sizing:border-box!important;border-bottom:1px solid rgba(128,128,128,.28)!important;float:none!important;clear:both!important;overflow:visible!important;overflow-y:visible!important;touch-action:pan-y!important;-webkit-overflow-scrolling:auto!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) li.yt-uix-shelfslider-item > .yt-lockup{padding:0!important;border:0!important;min-height:0!important;background:transparent!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) a[href*="/watch"],html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-lockup a,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .lohp-video-link{touch-action:pan-y!important;-webkit-user-drag:none!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-lockup-title,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-lockup-title a,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .lohp-video-link{font-size:var(--bygone-title-size)!important;line-height:1.18!important;font-weight:700!important;white-space:normal!important;overflow-wrap:anywhere!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-lockup-title .yt-ui-ellipsis,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-lockup-title .yt-ui-ellipsis-wrapper,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .lohp-video-link .yt-ui-ellipsis,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .lohp-video-link .yt-ui-ellipsis-wrapper{max-height:none!important;white-space:normal!important;overflow:visible!important;text-overflow:clip!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-lockup-meta,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-lockup-byline,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .lohp-video-metadata,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .bygone-meta{font-size:var(--bygone-meta-size)!important;line-height:1.35!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-thumb,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .ux-thumb-wrap,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .video-thumb,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-lockup-thumbnail,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .lohp-media-object{display:block!important;width:100%!important;max-width:none!important;min-width:0!important;aspect-ratio:16/9!important;height:auto!important;margin:0 0 8px!important;float:none!important;overflow:hidden!important;background:transparent!important;touch-action:pan-y!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-thumb-clip,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-thumb-clip-inner,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-thumb-square,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-thumb-default,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-thumb-simple,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-thumb-fluid{display:block!important;width:100%!important;max-width:none!important;height:100%!important;max-height:none!important;min-height:0!important;position:static!important;top:auto!important;left:auto!important;right:auto!important;bottom:auto!important;overflow:hidden!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-lockup-content,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .lohp-media-object-content{display:block!important;min-width:0!important;width:100%!important;max-width:none!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-thumb img,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .ux-thumb-wrap img,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .video-thumb img,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .yt-lockup-thumbnail img,html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) .lohp-media-object img{display:block!important;width:100%!important;max-width:none!important;height:100%!important;max-height:none!important;object-fit:cover!important;object-position:center center!important;background:transparent!important;margin:0!important;position:static!important;top:auto!important;left:auto!important;right:auto!important;bottom:auto!important;transform:none!important;vertical-align:top!important;pointer-events:none!important;-webkit-user-drag:none!important;touch-action:pan-y!important;}',
                'html[data-bygone-kiosk-zoom="1"]:not([data-bygone-kiosk-watch="1"]) img.bygone-thumb{display:block!important;width:100%!important;max-width:none!important;aspect-ratio:16/9!important;height:auto!important;max-height:none!important;object-fit:cover!important;object-position:center center!important;background:transparent!important;margin:0!important;position:static!important;top:auto!important;left:auto!important;right:auto!important;bottom:auto!important;transform:none!important;vertical-align:top!important;pointer-events:none!important;-webkit-user-drag:none!important;touch-action:pan-y!important;}',
                'html[data-bygone-kiosk-watch="1"],html[data-bygone-kiosk-watch="1"] body{width:100%!important;max-width:100vw!important;min-width:0!important;overflow-x:hidden!important;overscroll-behavior:none!important;}',
                'html[data-bygone-kiosk-watch="1"] #page,html[data-bygone-kiosk-watch="1"] #content,html[data-bygone-kiosk-watch="1"] #body-container,html[data-bygone-kiosk-watch="1"] #watch7-container,html[data-bygone-kiosk-watch="1"] #watch7-main-container,html[data-bygone-kiosk-watch="1"] #watch7-content,html[data-bygone-kiosk-watch="1"] .watch-main-col{width:100vw!important;max-width:100vw!important;min-width:0!important;margin-left:0!important;margin-right:0!important;padding-left:0!important;padding-right:0!important;left:0!important;right:auto!important;transform:none!important;box-sizing:border-box!important;overflow-x:hidden!important;}',
                'html[data-bygone-kiosk-watch="1"] #player,html[data-bygone-kiosk-watch="1"] #player-api,html[data-bygone-kiosk-watch="1"] #watch7-player,html[data-bygone-kiosk-watch="1"] #watch-player,html[data-bygone-kiosk-watch="1"] .player-width,html[data-bygone-kiosk-watch="1"] .html5-video-player{width:100vw!important;max-width:100vw!important;min-width:0!important;margin:0 0 10px!important;left:0!important;right:auto!important;transform:none!important;box-sizing:border-box!important;}',
                'html[data-bygone-kiosk-watch="1"] #watch7-sidebar,html[data-bygone-kiosk-watch="1"] .watch-sidebar,html[data-bygone-kiosk-watch="1"] #secondary,html[data-bygone-kiosk-watch="1"] #secondary-inner{width:100vw!important;max-width:100vw!important;margin:12px 0 0!important;padding:0 4px calc(84px + env(safe-area-inset-bottom,0px))!important;left:0!important;right:auto!important;transform:none!important;box-sizing:border-box!important;overflow:hidden!important;}',
                // Comments are SHOWN on the kiosk (the v382 sweep removes
                // modern ones and redates the rest, same as desktop) — they
                // just need phone-width layout instead of the old nuke.
                'html[data-bygone-kiosk-watch="1"] #watch-discussion,html[data-bygone-kiosk-watch="1"] #watch7-discussion,html[data-bygone-kiosk-watch="1"] #comment-section-renderer,html[data-bygone-kiosk-watch="1"] .comment-section,html[data-bygone-kiosk-watch="1"] .comments{display:block!important;width:100%!important;max-width:100%!important;min-width:0!important;margin:0!important;padding:0 8px!important;box-sizing:border-box!important;overflow:hidden!important;overflow-wrap:anywhere!important;}',
                'html[data-bygone-kiosk-watch="1"] div.comment[data-id],html[data-bygone-kiosk-watch="1"] .comment-thread-renderer,html[data-bygone-kiosk-watch="1"] .comment-item,html[data-bygone-kiosk-watch="1"] #watch-discussion .post{display:block!important;width:100%!important;max-width:100%!important;min-width:0!important;margin:0!important;box-sizing:border-box!important;float:none!important;overflow:hidden!important;overflow-wrap:anywhere!important;font-size:var(--bygone-body-size)!important;line-height:1.4!important;}',
                'html[data-bygone-kiosk-watch="1"] div.comment[data-id] .metadata,html[data-bygone-kiosk-watch="1"] div.comment[data-id] .comment-header,html[data-bygone-kiosk-watch="1"] div.comment[data-id] .comment-footer,html[data-bygone-kiosk-watch="1"] div.comment[data-id] .detail_link{font-size:var(--bygone-meta-size)!important;}',
                'html[data-bygone-kiosk-watch="1"] #watch-discussion .load-more-button,html[data-bygone-kiosk-watch="1"] #watch-discussion .yt-uix-load-more{display:block!important;width:100%!important;min-height:44px!important;font-size:var(--bygone-control-size)!important;box-sizing:border-box!important;}',
                // Watch-page thumbnails (sidebar + any card): V3 crops thumbs
                // with inline negative margins / absolute offsets on the img;
                // without this reset they hang off to the right at phone width.
                'html[data-bygone-kiosk-watch="1"] .yt-thumb,html[data-bygone-kiosk-watch="1"] .ux-thumb-wrap,html[data-bygone-kiosk-watch="1"] .video-thumb,html[data-bygone-kiosk-watch="1"] .yt-lockup-thumbnail{display:block!important;width:100%!important;max-width:100%!important;min-width:0!important;aspect-ratio:16/9!important;height:auto!important;margin:0!important;float:none!important;overflow:hidden!important;box-sizing:border-box!important;}',
                'html[data-bygone-kiosk-watch="1"] .yt-thumb-clip,html[data-bygone-kiosk-watch="1"] .yt-thumb-clip-inner,html[data-bygone-kiosk-watch="1"] .yt-thumb-square,html[data-bygone-kiosk-watch="1"] .yt-thumb-default,html[data-bygone-kiosk-watch="1"] .yt-thumb-simple,html[data-bygone-kiosk-watch="1"] .yt-thumb-fluid{display:block!important;width:100%!important;max-width:none!important;height:100%!important;max-height:none!important;min-height:0!important;position:static!important;top:auto!important;left:auto!important;right:auto!important;bottom:auto!important;overflow:hidden!important;}',
                'html[data-bygone-kiosk-watch="1"] .yt-thumb img,html[data-bygone-kiosk-watch="1"] .ux-thumb-wrap img,html[data-bygone-kiosk-watch="1"] .video-thumb img,html[data-bygone-kiosk-watch="1"] .yt-lockup-thumbnail img{display:block!important;width:100%!important;max-width:none!important;height:100%!important;max-height:none!important;object-fit:cover!important;object-position:center center!important;margin:0!important;position:static!important;top:auto!important;left:auto!important;right:auto!important;bottom:auto!important;transform:none!important;vertical-align:top!important;}',
                'html[data-bygone-kiosk-watch="1"] img.bygone-thumb{display:block!important;width:100%!important;max-width:none!important;aspect-ratio:16/9!important;height:auto!important;max-height:none!important;object-fit:cover!important;object-position:center center!important;margin:0!important;position:static!important;transform:none!important;vertical-align:top!important;}',
                // Hard overrides: V3 pins the desktop grid with its own
                // !important rules (masthead/page min-width 1003px, watch7
                // columns, the old Google bar, the fixed guide rail). The
                // body-prefixed selectors outrank them; the dead chrome is
                // hidden outright on the kiosk.
                'html[data-bygone-kiosk-zoom="1"] #gb,html[data-bygone-kiosk-zoom="1"] #gbw,html[data-bygone-kiosk-zoom="1"] #gbz,html[data-bygone-kiosk-zoom="1"] #gbx3,html[data-bygone-kiosk-zoom="1"] #footer-container,html[data-bygone-kiosk-zoom="1"] #footer,html[data-bygone-kiosk-zoom="1"] #guide-main,html[data-bygone-kiosk-zoom="1"] .guide-module,html[data-bygone-kiosk-zoom="1"] #appbar-guide-menu{display:none!important;}',
                'html[data-bygone-kiosk-zoom="1"] body #yt-masthead-container,html[data-bygone-kiosk-zoom="1"] body #masthead-positioner,html[data-bygone-kiosk-zoom="1"] body #masthead,html[data-bygone-kiosk-zoom="1"] body #page,html[data-bygone-kiosk-zoom="1"] body #content,html[data-bygone-kiosk-zoom="1"] body #body-container,html[data-bygone-kiosk-zoom="1"] body #appbar-content,html[data-bygone-kiosk-zoom="1"] body .yt-grid-box{width:100vw!important;max-width:100vw!important;min-width:0!important;margin-left:0!important;margin-right:0!important;left:0!important;box-sizing:border-box!important;}',
                'html[data-bygone-kiosk-watch="1"] body #watch7-main,html[data-bygone-kiosk-watch="1"] body #watch7-main-container,html[data-bygone-kiosk-watch="1"] body #watch7-video,html[data-bygone-kiosk-watch="1"] body #watch7-content,html[data-bygone-kiosk-watch="1"] body #player-api,html[data-bygone-kiosk-watch="1"] body .player-width{width:100vw!important;max-width:100vw!important;min-width:0!important;margin:0!important;padding-left:0!important;padding-right:0!important;float:none!important;left:0!important;box-sizing:border-box!important;}',
            ].join('\n');
            // Primary path: adopted stylesheets. V3 continuously rebuilds
            // <head> and eats injected <style> elements within seconds, so a
            // DOM style node is unreliable; adopted sheets live outside the
            // DOM where V3's cleanup can't reach.
            try {
                if (typeof CSSStyleSheet !== 'undefined' && document.adoptedStyleSheets !== undefined) {
                    if (!this._kioskSheet) {
                        this._kioskSheet = new CSSStyleSheet();
                        this._kioskSheetCss = null;
                    }
                    if (this._kioskSheetCss !== css) {
                        this._kioskSheet.replaceSync(css);
                        this._kioskSheetCss = css;
                    }
                    if (document.adoptedStyleSheets.indexOf(this._kioskSheet) === -1) {
                        document.adoptedStyleSheets = document.adoptedStyleSheets.concat([this._kioskSheet]);
                    }
                    return;
                }
            } catch (_) {}
            // Fallback: classic <style>, re-created whenever V3 removes it.
            // (Unique id — the APK extension uses its own; they used to share
            // one id and whichever ran first blocked the other's CSS.)
            const host = document.head || document.documentElement;
            if (!host) return;
            let style = document.getElementById('bygone-userscript-kiosk-style');
            if (style) {
                if (style.parentNode && style !== style.parentNode.lastElementChild) {
                    try { style.parentNode.appendChild(style); } catch (_) {}
                }
                if (style.textContent !== css) style.textContent = css;
                return;
            }
            style = document.createElement('style');
            style.id = 'bygone-userscript-kiosk-style';
            style.textContent = css;
            host.appendChild(style);
        }

        _clampKioskViewportSlack() {
            try {
                const root = document.documentElement;
                const body = document.body;
                const scroller = document.scrollingElement || root;
                if (root) root.scrollLeft = 0;
                if (body) body.scrollLeft = 0;
                if (scroller) scroller.scrollLeft = 0;
            } catch {}
        }

        _applyKioskLookPrefs() {
            const root = document.documentElement;
            if (!root) return;
            this._ensureKioskLookStyle();
            const sizeVars = [
                '--bygone-kiosk-zoom',
                '--bygone-body-size',
                '--bygone-title-size',
                '--bygone-meta-size',
                '--bygone-control-size',
                '--bygone-control-small',
                '--bygone-control-title-size',
                '--bygone-control-height',
                '--bygone-panel-width',
                '--bygone-fab-size',
                '--bygone-fab-font',
                '--bygone-home-top-gap',
            ];
            const feedVars = [
                '--bygone-feed-columns',
                '--bygone-feed-gap',
                '--bygone-feed-pad',
                '--bygone-feed-max',
                '--bygone-watch-pad',
                '--bygone-watch-width',
            ];
            if (!_isKioskOrMobileLayout()) {
                root.removeAttribute('data-bygone-kiosk-dark');
                root.removeAttribute('data-bygone-kiosk-zoom');
                root.removeAttribute('data-bygone-kiosk-watch');
                root.removeAttribute('data-bygone-kiosk-columns');
                sizeVars.concat(feedVars).forEach(v => root.style.removeProperty(v));
                return;
            }
            if (Store.getKioskDarkMode()) root.setAttribute('data-bygone-kiosk-dark', '1');
            else root.removeAttribute('data-bygone-kiosk-dark');
            root.setAttribute('data-bygone-kiosk-zoom', '1');
            if (location.pathname.indexOf('/watch') === 0) {
                root.setAttribute('data-bygone-kiosk-watch', '1');
                // V3's "#player.watch-small" rule pads the player column
                // 60px for the old guide rail and outranks stylesheet
                // selectors; inline !important is what reliably beats it.
                for (const id of ['player', 'watch7-main-container']) {
                    const el = document.getElementById(id);
                    if (!el) continue;
                    try {
                        el.style.setProperty('padding-left', '0', 'important');
                        el.style.setProperty('padding-right', '0', 'important');
                    } catch (_) {}
                }
                // V3 keeps the 480x360 desktop player height, leaving a
                // black slab under the video. Pin the box to 16:9-of-
                // viewport (skip while fullscreen — the player owns its
                // size there).
                try {
                    if (!document.fullscreenElement) {
                        const playerHeight = Math.round(Math.min(
                            window.innerWidth * 9 / 16,
                            window.innerHeight - 112
                        ));
                        if (playerHeight > 150) {
                            // V3 renames the api node ("player-api_VORAPI_
                            // ELEMENT_ID"), so match the id by prefix.
                            document.querySelectorAll('#movie_player, #player-api, [id^="player-api"]').forEach(box => {
                                box.style.setProperty('height', playerHeight + 'px', 'important');
                            });
                            // The #player wrapper keeps V3's taller desktop
                            // height (black slab under the controls) — let
                            // it hug the pinned player box.
                            const wrap = document.getElementById('player');
                            if (wrap) wrap.style.setProperty('height', 'auto', 'important');
                        }
                    }
                } catch (_) {}
                // Same story for the sidebar's own guide-rail margin.
                const sidebar = document.getElementById('watch7-sidebar');
                if (sidebar) {
                    try {
                        sidebar.style.setProperty('margin-left', '0', 'important');
                        sidebar.style.setProperty('margin-right', '0', 'important');
                        sidebar.style.setProperty('float', 'none', 'important');
                    } catch (_) {}
                }
            } else {
                root.removeAttribute('data-bygone-kiosk-watch');
            }
            const z = Store.getKioskZoom();
            const px = n => Math.round(n) + 'px';
            const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
            const visibleWidth = _kioskVisibleWidth() || _kioskEffectiveWidth() || 390;
            const layoutWidth = Math.max(
                Number(window.innerWidth) || 0,
                Number(document.documentElement && document.documentElement.clientWidth) || 0,
                Number(window.visualViewport && window.visualViewport.width) || 0
            );
            const columnWidth = visibleWidth || layoutWidth;
            const feedColumns = columnWidth >= 600 ? 2 : 1;
            const widthT = clamp((columnWidth - 340) / 440, 0, 1);
            const userScale = clamp(0.95 + (z - 1.15) * 0.14, 0.92, 1.08);
            const titleBase = feedColumns === 2
                ? clamp(18 + widthT * 3, 18, 21)
                : clamp(20 + widthT * 3, 20, 23);
            const metaBase = feedColumns === 2
                ? clamp(14 + widthT, 14, 15)
                : clamp(15 + widthT * 1.5, 15, 17);
            const bodyBase = clamp(16 + widthT * 1.5, 16, 18);
            const controlBase = clamp(16 + widthT * 1.5, 16, 18);
            root.setAttribute('data-bygone-kiosk-columns', String(feedColumns));
            root.style.setProperty('--bygone-kiosk-zoom', String(z));
            root.style.setProperty('--bygone-feed-columns', String(feedColumns));
            root.style.setProperty('--bygone-feed-gap', feedColumns === 2 ? px(clamp(7 + widthT, 7, 8)) : px(clamp(4 + widthT * 2, 4, 6)));
            root.style.setProperty('--bygone-feed-pad', feedColumns === 2 ? px(clamp(2 + widthT * 2, 2, 4)) : '0px');
            root.style.setProperty('--bygone-feed-max', '100vw');
            root.style.setProperty('--bygone-watch-pad', '0px');
            root.style.setProperty('--bygone-watch-width', '100vw');
            root.style.setProperty('--bygone-body-size', px(bodyBase * userScale));
            root.style.setProperty('--bygone-title-size', px(titleBase * userScale));
            root.style.setProperty('--bygone-meta-size', px(metaBase * userScale));
            root.style.setProperty('--bygone-control-size', px(controlBase * userScale));
            root.style.setProperty('--bygone-control-small', px(clamp(controlBase - 2, 14, 16) * userScale));
            root.style.setProperty('--bygone-control-title-size', px(clamp(controlBase + 1, 17, 19) * userScale));
            root.style.setProperty('--bygone-control-height', px(clamp(40 + widthT * 6, 40, 46) * userScale));
            root.style.setProperty('--bygone-panel-width', px(clamp(columnWidth - 12, 320, 430)));
            root.style.setProperty('--bygone-fab-size', px(clamp(54 + widthT * 6, 54, 60)));
            root.style.setProperty('--bygone-fab-font', px(clamp(21 + widthT * 3, 21, 24)));
            root.style.setProperty('--bygone-home-top-gap', px(clamp(8 + widthT * 6, 8, 14)));
            this._clampKioskViewportSlack();
        }

        _broadcastKioskLookPrefs() {
            this._applyKioskLookPrefs();
            try {
                const target = (typeof unsafeWindow !== 'undefined' && unsafeWindow) || window;
                const Ctor = target.CustomEvent || CustomEvent;
                target.dispatchEvent(new Ctor('bygone-kiosk-look-changed', {
                    detail: { dark: Store.getKioskDarkMode(), zoom: Store.getKioskZoom() }
                }));
            } catch {}
        }
    }

    // ============================================================
    //  APP — wire everything
    // ============================================================

    class App {
        static async init() {
            // Validate stored time offset (max 24h drift; reset garbage values).
            const offset = Store.getTimeOffset();
            if (Math.abs(offset) > 86400000) Store.setTimeOffset(0);

            // On version bump, clear cached source results (they may be stale or
            // use shapes the new code doesn't understand). ALSO wipe the
            // impression park + seen-id list: with `recordImpressions`
            // logging 60 ids per load and parking after 3 impressions for
            // 7 days, repeated reloads (testing, dev cycles, or even
            // ordinary use) shrink the visible pool to a handful of
            // videos. A version bump is the safe time to reset.
            const lastVersion = Store._get('bygone_last_version', 0);
            if (lastVersion < VERSION) {
                try { for (const k of GM_listValues()) if (k.startsWith('bygone_cache_')) GM_deleteValue(k); } catch {}
                try { Store.setImpressions({}); } catch {}
                try { Store.setSeenIds([]); } catch {}
                try { Interceptor.clearPool(); } catch {}
                try {
                    const oldZoom = Number(Store._lsGet('bygone_kiosk_zoom', '1.35'));
                    if (!Number.isFinite(oldZoom) || oldZoom > 1.65) Store.setKioskZoom(1.35);
                } catch {}
                Store._set('bygone_last_version', VERSION);
            }

            // Wait for body (V3 strips the YT shell so we don't wait for ytd-app).
            await App._waitForBody();

            const api = new YouTubeAPI();
            const feedEngine = new FeedEngine(api);
            App._api = api;
            App._feedEngine = feedEngine;
            App._ui = new UI(api, feedEngine);

            // __bygoneNetDiag(): one InnerTube search through the same
            // GM_xmlhttpRequest path every feed source uses. Page-level fetch
            // can succeed while this path fails (sandbox/transport issues), so
            // the probe has to go through _post, not window.fetch.
            const netDiag = () => api._post('search', { query: 'minecraft before:2014-05-23' }).then(
                j => {
                    const s = JSON.stringify(j);
                    console.log('[bygone] NET-DIAG GM path OK — bytes:', s.length,
                        'videoRenderer:', (s.match(/"videoRenderer"/g) || []).length);
                    return 'ok';
                },
                e => { console.warn('[bygone] NET-DIAG GM path FAILED:', (e && e.message) || e); return 'fail'; });
            try { window.__bygoneNetDiag = netDiag; } catch (_) {}
            try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow) unsafeWindow.__bygoneNetDiag = netDiag; } catch (_) {}

            // Default era = 2014 with the current real month/day. Older
            // builds defaulted to "five years ago" (2021 in 2026), which made
            // relative redating look modern until the user manually corrected it.
            // The legacy-default upgrade is ONE-SHOT: without the flag it re-ran
            // on every load, so a user deliberately picking a date ~5 years back
            // had their choice silently reset to 2014 on the next reload.
            const storedDate = Store.getDate();
            const defaultMigrated = Store._get('bygone_default_date_migrated', false);
            if (!storedDate || (!defaultMigrated && Store.shouldUpgradeLegacyDefaultDate(storedDate))) {
                const defaultDate = Store.defaultEraDate();
                if (Store.isClockActive()) Store.startClock(defaultDate);
                else Store.setDate(defaultDate);
            }
            if (!defaultMigrated) Store._set('bygone_default_date_migrated', true);

            // MOUNT UI FIRST — before priming the pool. The feed build can
            // take up to 30 s (or fail if no sources are configured), and
            // awaiting it before showing the panel meant a fresh install
            // saw no UI at all. The user needs the panel to even add
            // sources, so it has to come up immediately.
            try { App._ui.init(); } catch (e) { console.error('[bygone] UI mount failed:', e); }
            setTimeout(() => App._maybeShowDependencyPrompt(), 800);
            setInterval(() => {
                if (!document.getElementById('wbt-panel') && !document.getElementById('wbt-fab')) {
                    try { App._ui.init(); } catch {}
                }
            }, 5000);

            // Prime the interceptor pool IN THE BACKGROUND so V3 gets its
            // videos as soon as they're ready, but the UI stays responsive.
            App.primeInterceptor().catch(e => console.error('[bygone] prime failed:', e));

            // Apply custom logo on every nav.
            const applyLogo = () => { try { App._ui._applyCustomLogo(); } catch {} };
            applyLogo();
            window.addEventListener('yt-navigate-finish', applyLogo);
            window.addEventListener('popstate', applyLogo);

            // Apply APK page look prefs on load/nav. The APK extension also
            // watches these same localStorage keys and applies them earlier.
            const applyLook = () => { try { App._ui._applyKioskLookPrefs(); } catch {} };
            applyLook();
            window.addEventListener('yt-navigate-finish', applyLook);
            window.addEventListener('popstate', applyLook);
            let applyLookResizeTimer = null;
            const applyLookSoon = () => {
                if (applyLookResizeTimer) clearTimeout(applyLookResizeTimer);
                applyLookResizeTimer = setTimeout(applyLook, 120);
            };
            window.addEventListener('resize', applyLookSoon);
            window.addEventListener('orientationchange', applyLookSoon);
            try {
                if (window.visualViewport) {
                    window.visualViewport.addEventListener('resize', applyLookSoon);
                    window.visualViewport.addEventListener('scroll', applyLookSoon);
                }
            } catch {}
            try {
                if (window.ResizeObserver) {
                    const ro = new ResizeObserver(applyLookSoon);
                    ro.observe(document.documentElement);
                    if (document.body) ro.observe(document.body);
                }
            } catch {}

            // Watch-history tracking: on watch page, after ≥ 15 s of watching,
            // record the watch event.
            setInterval(applyLook, 1000);
            App._wireWatchTracking();

            // Subscribe hijack + auto-sync bygone subs to YouTube account.
            App._installSubscribeHijack();
            App._scheduleSubSync(2000); // initial sync after 2 s

            // Search-date-filter: append before:YYYY-MM-DD to search_query
            // (in the URL) but keep the visible search input clean.
            App._installSearchHijack();

            // Warn when GreasyFork has a newer release. Throttled and delayed so
            // it never blocks YouTube startup or feed replacement.
            setTimeout(() => {
                App._checkGreasyForkVersion().catch(e => console.warn('[bygone] update check failed:', e));
            }, 3500);

            // Channel page handler — fetch that channel's videos by ID.
            window.addEventListener('yt-navigate-finish', () => {
                if (Interceptor.isChannelPage()) {
                    setTimeout(() => App._handleChannelPage().catch(e => console.warn('[bygone] channel page error:', e)), 800);
                } else {
                    App._channelPageActive = null;
                }
            });
            if (Interceptor.isChannelPage()) {
                setTimeout(() => App._handleChannelPage().catch(e => console.warn('[bygone] channel page error:', e)), 1500);
            }

            console.log(`[bygone] v2 (${VERSION}) ready. Date: ${Store.getCurrentDate()}`);
        }

        static _depsReady() {
            return { v3: _checkV3(), starTube: _checkStarTube() };
        }

        static _maybeShowDependencyPrompt() {
            try {
                if (Store.hasSeenDependencyPrompt()) return;
                const deps = App._depsReady();
                if (deps.v3 && deps.starTube) return;
                Store.markDependencyPromptSeen();
                App._renderDependencyPrompt(deps);
            } catch (e) {
                console.warn('[bygone] dependency prompt failed:', e);
            }
        }

        static _renderDependencyPrompt(deps) {
            if (!document.body || document.getElementById('wbt-dep-modal')) return;
            const modal = document.createElement('div');
            modal.id = 'wbt-dep-modal';

            const box = document.createElement('div');
            box.id = 'wbt-dep-modal-box';

            const title = document.createElement('div');
            title.textContent = 'bygone-yt needs V3 and StarTube';
            title.style.cssText = 'font:bold 14px sans-serif;margin-bottom:8px;color:#222;';
            box.appendChild(title);

            const missing = [];
            if (!deps.v3) missing.push('V3 / VORAPIS');
            if (!deps.starTube) missing.push('StarTube');
            const body = document.createElement('div');
            body.style.cssText = 'line-height:1.45;color:#333;margin-bottom:8px;';
            body.textContent = 'Missing: ' + missing.join(', ') + '. Install the missing scripts, then reload YouTube.';
            box.appendChild(body);

            const note = document.createElement('div');
            note.textContent = 'This message only appears once. The Setup tab will still show dependency status later.';
            note.style.cssText = 'font-size:11px;color:#666;margin-bottom:10px;';
            box.appendChild(note);

            const actions = document.createElement('div');
            actions.className = 'wbt-dep-actions';

            const mkBtn = (label, primary, onClick) => {
                const b = document.createElement('button');
                b.textContent = label;
                b.className = primary ? 'wbt-btn wbt-btn-primary' : 'wbt-btn';
                b.style.cssText = 'padding:4px 9px;border:1px solid ' + (primary ? '#800' : '#888') +
                    ';background:' + (primary ? '#c00' : '#ddd') +
                    ';color:' + (primary ? '#fff' : '#222') +
                    ';border-radius:2px;cursor:pointer;font:12px sans-serif;';
                b.addEventListener('click', onClick);
                return b;
            };
            const close = () => { try { modal.remove(); } catch (_) {} };

            if (!deps.v3) {
                actions.appendChild(mkBtn('Install V3', true, () => {
                    window.open(CONFIG.installUrls.v3, '_blank', 'noopener,noreferrer');
                    close();
                }));
            }
            if (!deps.starTube) {
                actions.appendChild(mkBtn('Install StarTube', true, () => {
                    window.open(CONFIG.installUrls.starTube, '_blank', 'noopener,noreferrer');
                    close();
                }));
            }
            actions.appendChild(mkBtn('Open setup', false, () => {
                close();
                try {
                    if (App._ui) App._ui._activeTab = 'setup';
                    App._ui.init();
                    const panel = document.getElementById('wbt-panel');
                    if (panel) {
                        panel.classList.remove('wbt-hidden');
                        panel.style.setProperty('display', 'block', 'important');
                    }
                } catch (_) {}
            }));
            actions.appendChild(mkBtn('Close', false, close));

            box.appendChild(actions);
            modal.appendChild(box);
            document.body.appendChild(modal);
        }

        // ---- Search-date-filter hijack ---------------------------
        // Goal: every search the user runs should be limited to videos
        // published before the configured time-machine date, but the
        // search bar itself should NEVER show the `before:YYYY-MM-DD`
        // tag. The tag lives only in the URL / link target.
        //
        // Two paths cover this:
        //   1. Submit-time:   intercept the search form's submit and
        //      temporarily mutate the input.value to append the tag.
        //      YT reads input.value to build the navigation URL, so
        //      the resulting /results URL has `before:` in it. We
        //      restore the visible value on the next tick so the user
        //      never sees the tag.
        //   2. URL-fixup:     if a /results page is reached without
        //      the tag (back-button, deep link, programmatic nav),
        //      redirect to the same URL with the tag appended.
        // After landing on /results, a low-frequency interval keeps
        // the visible input scrubbed (YT re-renders it on nav).
        static _installSearchHijack() {
            const TAG_RE = /\s*before:\d{4}-\d{2}-\d{2}/g;
            const cleanOf = (s) => (s || '').replace(TAG_RE, '').trim();
            const hasTag = (s) => /before:\d{4}-\d{2}-\d{2}/.test(s || '');

            const findSearchInput = () => {
                return document.querySelector(
                    'input#search, input[name="search_query"], input#masthead-search-term'
                );
            };

            const scrubVisibleInput = () => {
                const inp = findSearchInput();
                if (!inp) return;
                if (hasTag(inp.value)) inp.value = cleanOf(inp.value);
            };

            // Path 2: URL-level fixup on /results.
            const applyUrlFixup = () => {
                if (!Store.isActive()) return;
                const p = location.pathname;
                if (p !== '/results' && p !== '/results/') return;
                const dateStr = Store.getCurrentDate();
                if (!dateStr) return;
                const params = new URLSearchParams(location.search);
                const query = params.get('search_query') || '';
                if (!query) return;
                if (hasTag(query)) { scrubVisibleInput(); return; }
                params.set('search_query', `${query} before:${dateStr}`.trim());
                window.location.replace(`/results?${params.toString()}`);
            };

            // Path 1: capture-phase submit hook. Runs before YT's own
            // handlers, so by the time YT reads input.value to build
            // the URL, the tag is already there.
            document.addEventListener('submit', (e) => {
                if (!Store.isActive()) return;
                const form = e.target;
                if (!form || form.tagName !== 'FORM') return;
                const input = form.querySelector(
                    'input[name="search_query"], input#search, input#masthead-search-term'
                );
                if (!input || !input.value.trim()) return;
                if (hasTag(input.value)) return;
                const dateStr = Store.getCurrentDate();
                if (!dateStr) return;
                const original = input.value;
                try { Store.addSearchQuery(original); } catch (_) {}
                input.value = `${original.trim()} before:${dateStr}`;
                // Restore the visible value after YT has read it to
                // build the navigation URL. Microtask is too early
                // (sometimes runs before YT's submit handler); a 0ms
                // timeout is safe.
                setTimeout(() => {
                    const live = findSearchInput();
                    if (live) live.value = original;
                }, 0);
            }, true);

            // Same idea for Enter keydown — some YT layouts fire nav
            // directly off the keypress without a form submit event.
            document.addEventListener('keydown', (e) => {
                if (e.key !== 'Enter' || e.isComposing) return;
                if (!Store.isActive()) return;
                const t = e.target;
                if (!t || !t.matches) return;
                if (!t.matches('input[name="search_query"], input#search, input#masthead-search-term')) return;
                if (!t.value.trim() || hasTag(t.value)) return;
                const dateStr = Store.getCurrentDate();
                if (!dateStr) return;
                const original = t.value;
                try { Store.addSearchQuery(original); } catch (_) {}
                t.value = `${original.trim()} before:${dateStr}`;
                setTimeout(() => {
                    const live = findSearchInput();
                    if (live) live.value = original;
                }, 0);
            }, true);

            // Run URL fixup on initial load + every nav.
            applyUrlFixup();
            window.addEventListener('yt-navigate-finish', () => {
                applyUrlFixup();
                setTimeout(scrubVisibleInput, 100);
                setTimeout(scrubVisibleInput, 500);
            });
            window.addEventListener('popstate', () => {
                applyUrlFixup();
                setTimeout(scrubVisibleInput, 100);
            });
            // Low-frequency scrubber for YT re-renders of the input.
            setInterval(() => {
                const p = location.pathname;
                if (p === '/results' || p === '/results/') scrubVisibleInput();
            }, 1000);
        }

        // ---- Subscribe button click hijack -----------------------
        // When the user clicks any "Subscribe" button anywhere on
        // YouTube (V3 2013 markup OR modern), add the channel to the
        // bygone subscriptions list. Doesn't BLOCK YouTube's own
        // subscribe flow — just records the channel for us.
        static _installSubscribeHijack() {
            const isSubButton = (el) => {
                for (let i = 0; i < 8 && el && el !== document.body; i++) {
                    const cls = (el.className && el.className.toString && el.className.toString()) || '';
                    if (/yt-uix-button-subscribe-branded|yt-uix-subscription-button|subscribe-button-renderer|ytd-subscribe-button/i.test(cls)) return el;
                    const aria = el.getAttribute && (el.getAttribute('aria-label') || '');
                    if (/^subscribe/i.test(aria) || /^subscribed/i.test(aria)) return el;
                    const txt = (el.textContent || '').trim().toLowerCase();
                    if (txt === 'subscribe' || txt === 'subscribed') return el;
                    el = el.parentElement;
                }
                return null;
            };

            document.addEventListener('click', async (e) => {
                if (!isSubButton(e.target)) return;
                // Detect whether the action was a SUBSCRIBE or an
                // UNSUBSCRIBE by reading state RIGHT before the click
                // takes effect. If the button currently reads
                // "Subscribed", the click will unsubscribe.
                let wasSubscribed = false;
                try {
                    let el = e.target;
                    for (let i = 0; i < 8 && el; i++) {
                        const txt = (el.textContent || '').trim().toLowerCase();
                        if (txt === 'subscribed') { wasSubscribed = true; break; }
                        if (txt === 'subscribe') { wasSubscribed = false; break; }
                        el = el.parentElement;
                    }
                } catch {}

                // Give YouTube's own handler a moment to fire so the page
                // state settles, then extract channel info from the DOM.
                setTimeout(async () => {
                    try {
                        const info = await App._extractChannelInfo();
                        if (!info || !info.id) return;
                        const subs = Store.getSubscriptions();
                        const i = subs.findIndex(s => s.id === info.id);
                        if (wasSubscribed) {
                            // User just unsubscribed → remove from bygone.
                            if (i >= 0) {
                                subs.splice(i, 1);
                                Store.setSubscriptions(subs);
                                console.log('[bygone] removed subscription:', info.name);
                            }
                        } else {
                            // User just subscribed → add to bygone.
                            if (i < 0) {
                                subs.push({ id: info.id, name: info.name, weight: 3 });
                                Store.setSubscriptions(subs);
                                Store.markSubSynced(info.id); // already on YT
                                console.log('[bygone] added subscription:', info.name);
                            }
                        }
                    } catch (err) {
                        console.warn('[bygone] sub hijack error:', err);
                    }
                }, 200);
            }, true);
        }

        // Pull channel ID + name from the current page (works on
        // channel pages, watch pages, and anywhere a channel link is
        // visible near the top of the page).
        static async _extractChannelInfo() {
            const path = location.pathname;
            // Direct /channel/UC...
            let m = path.match(/^\/channel\/(UC[A-Za-z0-9_-]+)/);
            if (m) {
                const id = m[1];
                const name = App._scrapeChannelName() || id;
                return { id, name };
            }
            // /@handle, /c/, /user/ — find a channel link on the page
            // whose href is /channel/UC...
            const link = document.querySelector('a[href^="/channel/UC"]');
            if (link) {
                const href = link.getAttribute('href') || '';
                const m2 = href.match(/^\/channel\/(UC[A-Za-z0-9_-]+)/);
                if (m2) {
                    const id = m2[1];
                    const name = (link.textContent || '').trim() || App._scrapeChannelName() || id;
                    return { id, name };
                }
            }
            // Fall back: try to resolve via the name shown on the page
            // (slower; one API hit).
            const name = App._scrapeChannelName();
            if (!name) return null;
            try {
                const ch = await App._api.resolveChannel(name);
                if (ch && ch.id) return { id: ch.id, name: ch.name || name };
            } catch {}
            return null;
        }

        static _scrapeChannelName() {
            const sels = [
                '.qualified-channel-title-text',
                '.channel-header-profile-image-container .channel-title',
                '#channel-header-container .channel-name',
                '#channel-name',
                'ytd-channel-name',
                '.ytd-channel-name',
                '.attribution .g-hovercard',
                '.attribution .yt-user-name',
                '.attribution',
            ];
            for (const sel of sels) {
                const el = document.querySelector(sel);
                if (el && el.textContent && el.textContent.trim()) {
                    return el.textContent.trim().replace(/^by\s+/i, '');
                }
            }
            return null;
        }

        // Auto-sync bygone subscriptions to the user's YouTube account.
        // For every bygone sub with a channel ID that hasn't been synced
        // yet, fire a subscribe API call. Rate-limited via the YouTubeAPI
        // internal cooldown.
        static async _syncSubsToYouTube() {
            if (!Store.isAutoSyncSubs()) return;
            const subs = Store.getSubscriptions();
            const synced = new Set(Store.getSyncedSubIds());
            const pending = subs.filter(s => s && s.id && !synced.has(s.id));
            if (!pending.length) return;
            console.log(`[bygone] syncing ${pending.length} subscription(s) to YouTube…`);
            for (const sub of pending) {
                try {
                    const ok = await App._api.subscribeToChannel(sub.id);
                    if (ok) {
                        Store.markSubSynced(sub.id);
                        console.log('[bygone] subscribed on YouTube:', sub.name);
                    }
                } catch (e) {
                    console.warn('[bygone] sync error for', sub.name, e.message);
                }
            }
        }

        // Debounced trigger — coalesces multiple changes into one batch
        // (e.g. when the user adds three subs in a row in the panel).
        static _scheduleSubSync(delay) {
            if (App._syncTimer) clearTimeout(App._syncTimer);
            App._syncTimer = setTimeout(() => {
                App._syncTimer = null;
                App._syncSubsToYouTube().catch(e => console.warn('[bygone] sync failed:', e));
            }, delay || 1500);
        }

        // Build the feed and feed it into the interceptor pool. Also wires
        // the lazy fetcher for infinite scroll.
        static async primeInterceptor() {
            try {
                if (!Store.isActive()) {
                    Interceptor.setPaused(true);
                    return { ok: false, count: 0, message: 'bygone is paused — turn Active on to rebuild the feed.' };
                }
                Interceptor.setPaused(false);
                const date = Store.getCurrentDate();
                if (!date) return { ok: false, count: 0, message: 'No date is set.' };
                // Progressive prime: the build streams each source's videos to
                // onPartial as it resolves. The FIRST batch activates the pool
                // (fires pool-ready → home grid + watch "Up Next" sidebar sweep),
                // so replacement starts in a few seconds on a cold load instead of
                // waiting ~25-30s for every source. The final weighted pool below
                // then replaces this partial set.
                let earlyPrimed = false;
                const onPartial = (vids) => {
                    try {
                        if (!vids || !vids.length) return;
                        if (Interceptor.isActive()) {
                            Interceptor.appendVideos(vids);
                        } else if (Interceptor.setVideos(vids)) {
                            earlyPrimed = true;
                            console.log('[bygone] early-primed', Interceptor.poolSize(), 'videos (partial) — pool live');
                        }
                    } catch (_) {}
                };
                const videos = await App._feedEngine.buildHomeFeed(date, onPartial);
                let accepted = 0;
                if (videos && videos.length) {
                    if (earlyPrimed) {
                        // Pool already live from progressive priming — append
                        // any new videos without resetting pool/cursor/idMap.
                        // setVideos would replace the pool with the final
                        // weighted selection, prune idMap entries for partial-
                        // pool videos, and cause V3 re-renders to map cards to
                        // different replacement videos (date flash + re-roll).
                        const added = Interceptor.appendVideos(videos);
                        accepted = Interceptor.poolSize();
                        console.log('[bygone] primed with', accepted, 'videos (appended', added, 'new)');
                    } else {
                        accepted = Interceptor.setVideos(videos);
                        if (accepted) console.log('[bygone] primed with', accepted, 'videos');
                    }
                    if (accepted) App._enrichExactDates(videos, date);
                } else if (earlyPrimed) {
                    accepted = Interceptor.poolSize();
                }
                Interceptor.setLazyFetcher(async (page) => {
                    const cur = Store.getCurrentDate();
                    if (!cur) return [];
                    const more = await App._feedEngine.buildHomeFeedMore(cur, page, Interceptor.getPoolIds());
                    App._enrichExactDates(more, cur);
                    return more;
                });
                if (!accepted) {
                    return { ok: false, count: 0, message: 'No era videos loaded. Check sources or try Clear cache.' };
                }
                return { ok: true, count: accepted };
            } catch (e) {
                console.error('[bygone] prime failed', e);
                return { ok: false, count: 0, error: e, message: 'Reload failed: ' + (e && e.message ? e.message : e) };
            }
        }

        // Fetch EXACT publish dates for the videos most likely to be shown, so
        // their "X ago" reads precisely against the set date instead of being
        // guessed from year-granular strings. Only the ambiguous ones (coarse
        // approx within ~400 days of the set date, where year-granularity
        // fails) are fetched; clearly-older videos relativize fine already.
        // Results cache persistently per id, so this cost is paid once across
        // loads. Runs in the background and refreshes displayed dates per
        // chunk as they arrive. Guarded so only one run happens at a time.
        static _enrichRunning = false;
        static async _enrichExactDates(videos, setDateStr) {
            if (App._enrichRunning || !videos || !videos.length) return;
            // Home-feed only. The watch page doesn't show the home feed, and
            // firing dozens of /next calls there floods the session with API
            // traffic (on top of the page's own player/next/comment requests),
            // which can get the session rate-limited and the player reloading.
            const _p = location.pathname;
            if (_p !== '/' && _p !== '' && _p !== '/feed/trending') return;
            const anchor = new Date(setDateStr).getTime();
            if (isNaN(anchor)) return;
            const AMBIG_MS = 400 * 86400000;
            const need = [];
            for (const v of videos) {
                if (!v || !v.id || Store.getExactDate(v.id)) continue;
                const approx = DateHelper.approxPublishDate(v.relativeDate);
                // The string's granularity is noise on top of the ambiguity
                // threshold: a "14 years ago" video can sit a full bucket
                // closer to the anchor than its point estimate says.
                const ambig = AMBIG_MS + DateHelper.approxSlackMs(v.relativeDate);
                if (approx && Math.abs(anchor - approx.getTime()) > ambig) continue;
                need.push(v.id);
                if (need.length >= 80) break;   // prioritise the soonest-shown
            }
            if (!need.length) return;
            App._enrichRunning = true;
            try {
                const CHUNK = 10;
                for (let i = 0; i < need.length; i += CHUNK) {
                    const slice = need.slice(i, i + CHUNK);
                    const map = {};
                    await Promise.all(slice.map(async id => {
                        const iso = await App._api.fetchExactDate(id);
                        if (iso) map[id] = iso;
                    }));
                    if (Object.keys(map).length) {
                        Store.addExactDates(map);
                        // Slack admits the straddling year-bucket, which can
                        // contain post-set-date uploads; now that exact dates
                        // are known, evict those instead of showing them with
                        // fabricated "N days ago" ages.
                        const future = Object.keys(map).filter(id => new Date(map[id]) > new Date(setDateStr));
                        if (future.length) {
                            const n = Interceptor.removeVideos(future);
                            if (n) console.log('[bygone] evicted', n, 'post-era videos from pool');
                        }
                        try { Interceptor.refreshAllDates(); } catch (_) {}
                    }
                    // Bail if the user changed the era mid-fetch.
                    if (Store.getCurrentDate() !== setDateStr) break;
                }
                console.log('[bygone] exact-date enrichment done for', need.length, 'videos');
            } catch (_) {
            } finally {
                App._enrichRunning = false;
            }
        }

        static _channelPageActive = null;

        static async _handleChannelPage() {
            if (!Interceptor.isChannelPage()) return;
            const date = Store.getCurrentDate();
            if (!date) return;

            const info = await App._extractChannelInfo();
            if (!info || !info.id) {
                console.warn('[bygone] channel page: could not extract channelId');
                return;
            }

            if (App._channelPageActive === info.id) return;
            App._channelPageActive = info.id;

            console.log('[bygone] channel page: fetching videos for', info.id, info.name);

            try {
                const videos = await App._api.getChannelVideos(info.name, {
                    channelId: info.id,
                    publishedBefore: date,
                    maxResults: 50,
                });

                if (!videos || !videos.length) {
                    console.log('[bygone] channel page: no videos found before', date);
                    return;
                }

                videos.sort((a, b) => {
                    const da = DateHelper.approxPublishDate(a.relativeDate);
                    const db = DateHelper.approxPublishDate(b.relativeDate);
                    if (!da && !db) return 0;
                    if (!da) return 1;
                    if (!db) return -1;
                    return db.getTime() - da.getTime();
                });

                for (const v of videos) {
                    if (v.relativeDate) {
                        try {
                            v.relativeDate = DateHelper.recalcForFeed(v.relativeDate, date, v.id) || v.relativeDate;
                        } catch (_) {}
                    }
                }

                console.log('[bygone] channel page: fetched', videos.length, 'videos for', info.id);

                const tryRewrite = () => {
                    const cards = Interceptor.findCards(document);
                    if (!cards.length) return false;
                    const channelCards = cards.filter(c => {
                        const a = c.querySelector('a[href*="/watch"]');
                        return !!a;
                    });
                    const inner = [];
                    let wrote = 0;
                    for (let i = 0; i < channelCards.length; i++) {
                        if (i < videos.length) {
                            try {
                                Interceptor.rewriteCard(channelCards[i], videos[i], inner);
                                channelCards[i].setAttribute('data-bygone-ok', '1');
                                wrote++;
                            } catch (_) {}
                        } else {
                            try { channelCards[i].style.setProperty('display', 'none', 'important'); } catch (_) {}
                        }
                    }
                    return wrote > 0;
                };

                if (!tryRewrite()) {
                    setTimeout(tryRewrite, 500);
                    setTimeout(tryRewrite, 1500);
                    setTimeout(tryRewrite, 3000);
                }
            } catch (e) {
                console.error('[bygone] channel page fetch failed:', e);
            }
        }

        static _cacheBustUrl(url) {
            const sep = url.indexOf('?') === -1 ? '?' : '&';
            return url + sep + 'bygone_update_check=' + VERSION + '_' + Date.now();
        }

        static _requestText(url, timeoutMs) {
            return new Promise((resolve, reject) => {
                try {
                    GM_xmlhttpRequest({
                        method: 'GET',
                        url: App._cacheBustUrl(url),
                        timeout: timeoutMs || 10000,
                        headers: { 'Accept': 'text/plain,text/html,*/*' },
                        onload(res) {
                            if (res.status >= 200 && res.status < 300) resolve(res.responseText || '');
                            else reject(new Error('HTTP ' + res.status));
                        },
                        onerror() { reject(new Error('Network error')); },
                        ontimeout() { reject(new Error('Timed out')); },
                    });
                } catch (e) {
                    reject(e);
                }
            });
        }

        static _parseUserscriptVersion(text) {
            const m = String(text || '').match(/^\s*\/\/\s*@version\s+(.+?)\s*$/m);
            return m ? m[1].trim() : '';
        }

        static _compareVersions(a, b) {
            const pa = String(a || '').match(/\d+/g);
            const pb = String(b || '').match(/\d+/g);
            if (!pa || !pb) return String(a || '').localeCompare(String(b || ''), undefined, { numeric: true, sensitivity: 'base' });
            const len = Math.max(pa.length, pb.length);
            for (let i = 0; i < len; i++) {
                const na = Number(pa[i] || 0);
                const nb = Number(pb[i] || 0);
                if (na !== nb) return na < nb ? -1 : 1;
            }
            return 0;
        }

        static _greasyForkVersionUrls() {
            const urls = [];
            const add = (url) => {
                if (!url || !/greasyfork\.org/i.test(url)) return;
                if (urls.indexOf(url) === -1) urls.push(url);
            };
            add(CONFIG.update.metaUrl);
            add(CONFIG.update.userUrl);
            try {
                const info = (typeof GM_info !== 'undefined' && GM_info) ? GM_info : null;
                const script = info && info.script ? info.script : {};
                add(script.updateURL || script.updateUrl);
                add(script.downloadURL || script.downloadUrl);
            } catch (_) {}
            return urls;
        }

        static async _fetchLatestGreasyForkVersion() {
            for (const url of App._greasyForkVersionUrls()) {
                try {
                    const text = await App._requestText(url, 10000);
                    const version = App._parseUserscriptVersion(text);
                    if (version) return { version, url };
                } catch (e) {
                    console.warn('[bygone] update check source failed:', url, e && e.message ? e.message : e);
                }
            }
            return null;
        }

        static async _checkGreasyForkVersion(force) {
            const now = Date.now();
            const interval = CONFIG.update.checkIntervalMs || 21600000;
            const lastCheck = Number(Store._get('bygone_update_last_check', 0)) || 0;
            if (!force && now - lastCheck < interval) return;
            Store._set('bygone_update_last_check', now);

            const latest = await App._fetchLatestGreasyForkVersion();
            if (!latest || !latest.version) return;
            Store._set('bygone_update_latest_version', latest.version);
            if (App._compareVersions(VERSION, latest.version) >= 0) return;

            const repeatMs = CONFIG.update.alertRepeatMs || 86400000;
            const lastAlertVersion = Store._get('bygone_update_last_alert_version', '');
            const lastAlertAt = Number(Store._get('bygone_update_last_alert_at', 0)) || 0;
            if (!force && lastAlertVersion === latest.version && now - lastAlertAt < repeatMs) return;

            Store._set('bygone_update_last_alert_version', latest.version);
            Store._set('bygone_update_last_alert_at', now);
            alert(
                'out of date; please update!\n\n' +
                'Installed: v' + VERSION + '\n' +
                'Latest: v' + latest.version + '\n\n' +
                CONFIG.update.scriptPage
            );
        }

        static _waitForBody() {
            return new Promise(resolve => {
                let waited = 0;
                const check = () => {
                    if (document.body) return resolve();
                    waited += 200;
                    if (waited >= 10000) return resolve();
                    setTimeout(check, 200);
                };
                if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', check, { once: true });
                else check();
            });
        }

        // On watch page, count a "watch" after 15s of being on it. Cheap,
        // doesn't try to read the video element (which V3's player wraps).
        static _wireWatchTracking() {
            let timer = null;
            const tick = () => {
                if (timer) { clearTimeout(timer); timer = null; }
                if (!location.pathname.startsWith('/watch')) return;
                const m = location.search.match(/[?&]v=([A-Za-z0-9_-]+)/);
                if (!m) return;
                const videoId = m[1];
                timer = setTimeout(() => {
                    if (!location.pathname.startsWith('/watch')) return;
                    if (location.search.indexOf(videoId) === -1) return;
                    const titleEl = document.querySelector('.watch-title, #eow-title, h1.title, .watch-page-title') || document.querySelector('title');
                    const chanEl = document.querySelector('.yt-user-name, .watch-user-name, .attribution .g-hovercard, .attribution');
                    let channelId = null;
                    const chanLink = document.querySelector(
                        'a[href*="/channel/UC"], .yt-user-name[href*="/channel/"], .attribution a[href*="/channel/"]'
                    );
                    if (chanLink) {
                        const cm = (chanLink.getAttribute('href') || '').match(/\/channel\/(UC[A-Za-z0-9_-]+)/);
                        if (cm) channelId = cm[1];
                    }
                    if (!channelId) {
                        const pv = Interceptor.getPoolVideo(videoId);
                        if (pv && pv.channelId) channelId = pv.channelId;
                    }
                    Store.addWatchEvent({
                        videoId,
                        title: titleEl ? titleEl.textContent.trim().slice(0, 200) : '',
                        channel: chanEl ? chanEl.textContent.trim().slice(0, 80) : '',
                        channelId,
                        ts: Date.now(),
                    });
                }, 15000);
            };
            window.addEventListener('yt-navigate-finish', tick);
            window.addEventListener('popstate', tick);
            tick();
        }
    }

    // ============================================================
    //  ENTRY
    // ============================================================

    App.init();

})();