YouTube Ultimate

ETA HUD • thumbnail end-time badges • auto highest quality • playlist autoplay • session stats • viewing history • 5-column grid

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         YouTube Ultimate
// @namespace    dispatch330.youtube.ultimate
// @version      2.9
// @description  ETA HUD • thumbnail end-time badges • auto highest quality • playlist autoplay • session stats • viewing history • 5-column grid
// @author       dispatch330 ([email protected])
// @license      MIT
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @match        *://www.youtube.com/*
// @match        *://m.youtube.com/*
// @exclude      *://www.youtube.com/live_chat*
// @exclude      *://www.youtube.com/shorts/*
// @run-at       document-idle
// @noframes
// @grant        none
// @compatible   chrome
// @compatible   firefox
// @compatible   opera
// @compatible   safari
// @compatible   edge
// @compatible   brave
// @credits      Quality module based on ElectroKnight22's YouTube HD Premium (greatest.deepsurf.us/en/scripts/498145)
// ==/UserScript==

(function () {
    'use strict';

    const getPlayerEl = () => document.querySelector('#movie_player');
    const getVideoEl  = () => document.querySelector('video');
    const isMobile    = window.location.hostname === 'm.youtube.com';

    // ─── QUALITY ─────────────────────────────────────────────────────────────────
    // Based on ElectroKnight22's YouTube HD Premium (greatest.deepsurf.us/en/scripts/498145)

    const QUALITY_HEIGHT = Object.freeze({
        highres: 4320, hd2160: 2160, hd1440: 1440, hd1080: 1080,
        hd720: 720, large: 480, medium: 360, small: 240, tiny: 144,
    });

    let qualityRetryTimer = null;

    function applyQuality() {
        const p = getPlayerEl();
        if (!p) return 'no_data';

        const qualityData = typeof p.getAvailableQualityData === 'function'
            ? p.getAvailableQualityData() : null;

        if (Array.isArray(qualityData) && qualityData.length) {
            const playable = qualityData.filter(q => q.isPlayable);
            if (!playable.length) return 'no_playable';

            playable.sort((a, b) => {
                const diff = (QUALITY_HEIGHT[b.quality] ?? 0) - (QUALITY_HEIGHT[a.quality] ?? 0);
                if (diff !== 0) return diff;
                return (b.paygatedQualityDetails ? 1 : 0) - (a.paygatedQualityDetails ? 1 : 0);
            });

            const best = playable[0];
            try { p.setPlaybackQualityRange(best.quality, best.quality, best.formatId ?? null); } catch (_) {}
            return 'applied';
        }

        const levels = typeof p.getAvailableQualityLevels === 'function'
            ? p.getAvailableQualityLevels() : null;
        if (Array.isArray(levels) && levels.length) {
            try { p.setPlaybackQualityRange(levels[0], levels[0], null); } catch (_) {}
            return 'applied';
        }

        return 'no_data';
    }

    function initQuality() {
        clearInterval(qualityRetryTimer);
        if (!settings.quality) return;
        const result = applyQuality();
        if (result !== 'no_data') return;

        let attempts = 0;
        qualityRetryTimer = setInterval(() => {
            attempts++;
            const r = applyQuality();
            if (r !== 'no_data' || attempts >= 33) clearInterval(qualityRetryTimer);
        }, 300);
    }

    // ─── AUTOPLAY ────────────────────────────────────────────────────────────────

    let autoplayObserver = null;

    function isVideoLoopOn() {
        const path = document.querySelector('ytd-playlist-loop-button-renderer button path');
        return path?.getAttribute('d')?.startsWith('M13') ?? false;
    }

    function getNextItem() {
        return document.querySelector(
            'ytd-playlist-panel-video-renderer[selected] + ytd-playlist-panel-video-renderer > a'
        );
    }

    function tryAdvancePlaylist(playerEl) {
        const isEnded   = playerEl.classList.contains('ended-mode');
        const isBlocked = !isEnded && !!playerEl.querySelector('.html5-ypc-title')?.innerText;
        if (!isEnded && !isBlocked) return;

        const next = getNextItem();
        if (!next) return;

        if (isEnded) {
            if (!isVideoLoopOn()) next.click();
        } else {
            next.click();
        }
    }

    function initAutoplay() {
        if (!settings.autoplay) { autoplayObserver?.disconnect(); return; }
        const playerEl = getPlayerEl();
        if (!playerEl) return;

        autoplayObserver?.disconnect();
        autoplayObserver = new MutationObserver(mutations => {
            for (const m of mutations) {
                tryAdvancePlaylist(m.target);
                if (m.target.classList.contains('ended-mode') && !isInPlaylist()) { showStatsOverlay(); commitSession(); }
            }
        });
        autoplayObserver.observe(playerEl, { attributes: true, attributeFilter: ['class'] });
        tryAdvancePlaylist(playerEl);
    }

    // ─── HUD ─────────────────────────────────────────────────────────────────────

    let hudVideoEl  = null;
    let hudListener = null;
    let hudBadge    = null;

    function formatClock(date) {
        return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
    }

    function getOrCreateHudBadge() {
        const container = document.querySelector('.ytp-time-duration');
        if (!container) return null;

        let badge = container.querySelector('.yt-ult-eta');
        if (!badge) {
            badge = document.createElement('span');
            badge.className = 'yt-ult-eta';
            Object.assign(badge.style, {
                marginLeft: '0', opacity: '0.6', fontSize: 'inherit',
                fontFamily: 'inherit', color: 'inherit',
                pointerEvents: 'none', userSelect: 'none',
            });
            container.appendChild(badge);
        }
        return badge;
    }

    function hudTick() {
        const v = hudVideoEl;
        if (!v || !v.duration || v.duration === Infinity) return;

        if (!hudBadge) hudBadge = getOrCreateHudBadge();
        if (!hudBadge) return;

        const remaining = (v.duration - v.currentTime) / (v.playbackRate || 1);
        const end = new Date(Date.now() + remaining * 1000);
        hudBadge.textContent = ` • ${formatClock(end)}`;
    }

    function initHud() {
        if (hudVideoEl && hudListener) hudVideoEl.removeEventListener('timeupdate', hudListener);
        hudBadge?.remove();
        hudBadge = null;
        if (!settings.hud) return;

        const v = getVideoEl();
        if (!v) return;

        hudVideoEl  = v;
        hudListener = hudTick;
        v.addEventListener('timeupdate', hudListener);

        setTimeout(() => {
            const tc = document.querySelector('.ytp-time-contents') || document.querySelector('.ytp-time-display');
            if (tc) tc.style.cursor = 'pointer';
        }, 800);
    }

    document.addEventListener('click', (e) => {
        const timeEl = e.target.closest('.ytp-time-contents, .ytp-time-duration, .yt-ult-eta');
        if (!timeEl) return;
        if (document.getElementById('yt-ult-stats')) {
            document.getElementById('yt-ult-stats')?.remove();
            statsDismissed = true;
        } else {
            statsDismissed = false;
            showStatsOverlay();
        }
    }, true);

    // ─── STATS ───────────────────────────────────────────────────────────────────

    let stats = null;
    let statsDismissed = false;
    let sessionCommitted = false;


    function resetStats() {
        stats = {
            startTime:  null,
            pauses:     0,
            seeks:      0,
            watchedMs:  0,
            wallMs:     0,
            lastPlayAt: null,
        };
        statsDismissed = false;
        sessionCommitted = false;
        saveStats();
    }

    function isInPlaylist() {
        return new URLSearchParams(location.search).has('list');
    }

    function formatDuration(ms) {
        const totalSec = Math.floor(ms / 1000);
        const h = Math.floor(totalSec / 3600);
        const m = Math.floor((totalSec % 3600) / 60);
        const s = totalSec % 60;
        if (h > 0) return `${h}h ${m}m ${s}s`;
        if (m > 0) return `${m}m ${s}s`;
        return `${s}s`;
    }

    function showStatsOverlay() {
        if (isInPlaylist()) return;
        if (!stats) return;
        if (statsDismissed) return;
        if (document.getElementById('yt-ult-stats')) return;

        if (stats.lastPlayAt) {
            stats.watchedMs += Date.now() - stats.lastPlayAt;
            stats.lastPlayAt = null;
        }

        const wallMs = stats.wallMs || 0;
        const startStr = stats.startTime
            ? new Date(stats.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
            : '—';

        document.getElementById('yt-ult-stats')?.remove();

        const overlay = document.createElement('div');
        overlay.id = 'yt-ult-stats';
        Object.assign(overlay.style, {
            position:      'absolute',
            transform:     'translate(-50%, -50%)',
            background:    'rgba(15, 15, 15, 0.95)',
            borderRadius:  '12px',
            padding:       '28px 32px',
            color:         '#fff',
            fontFamily:    'Roboto, Arial, sans-serif',
            fontSize:      '14px',
            lineHeight:    '1.7',
            zIndex:        '9999',
            minWidth:      '280px',
            boxShadow:     '0 8px 32px rgba(0,0,0,0.6)',
            pointerEvents: 'auto',
        });

        const header = document.createElement('div');
        Object.assign(header.style, { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' });

        const title = document.createElement('span');
        Object.assign(title.style, { fontSize: '15px', fontWeight: '500', opacity: '0.9' });
        title.textContent = 'Session stats';

        const closeBtn = document.createElement('button');
        Object.assign(closeBtn.style, { background: 'none', border: 'none', color: '#fff', fontSize: '20px', cursor: 'pointer', opacity: '0.6', padding: '0', lineHeight: '1' });
        closeBtn.textContent = '✕';
        closeBtn.addEventListener('click', () => { statsDismissed = true; overlay.remove(); });

        header.appendChild(title);
        header.appendChild(closeBtn);
        overlay.appendChild(header);

        const grid = document.createElement('div');
        Object.assign(grid.style, { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px 24px' });

        const endStr = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });

        const items = [
            ['Started at', startStr],
            ['Ended at',   endStr],
            ['Wall time',  formatDuration(wallMs)],
            ['Watched',    formatDuration(stats.watchedMs)],
            ['Pauses',     stats.pauses],
            ['Seeks',      stats.seeks],
        ];

        for (const [label, value] of items) {
            const cell = document.createElement('div');
            const lbl = document.createElement('div');
            Object.assign(lbl.style, { opacity: '0.5', fontSize: '12px', marginBottom: '2px' });
            lbl.textContent = label;
            const val = document.createElement('div');
            val.style.fontSize = '15px';
            val.textContent = value;
            cell.appendChild(lbl);
            cell.appendChild(val);
            grid.appendChild(cell);
        }

        overlay.appendChild(grid);

        const player = getPlayerEl();
        if (!player) return;
        player.style.position = 'relative';
        player.appendChild(overlay);
        overlay.style.top  = '50%';
        overlay.style.left = '50%';
    }

    let statsVideoEl   = null;
    let statsListeners = null;
    let wallPlayAt     = null;

    function startWall() {
        if (!wallPlayAt) wallPlayAt = Date.now();
    }

    function stopWall() {
        if (wallPlayAt) {
            stats.wallMs = (stats.wallMs || 0) + (Date.now() - wallPlayAt);
            wallPlayAt = null;
            saveStats();
        }
    }

    function initStats(videoEl) {
        if (!videoEl || !settings.stats) return;

        if (statsVideoEl && statsListeners) {
            statsVideoEl.removeEventListener('play',    statsListeners.play);
            statsVideoEl.removeEventListener('pause',   statsListeners.pause);
            statsVideoEl.removeEventListener('seeked',  statsListeners.seeked);
            document.removeEventListener('visibilitychange', statsListeners.visibility);
        }

        if (!document.getElementById('yt-ult-stats')) {
            wallPlayAt = null;
            resetStats();
        }

        statsVideoEl = videoEl;

        statsListeners = {
            play() {
                if (!stats.startTime) stats.startTime = Date.now();
                stats.lastPlayAt = Date.now();
                startWall();
            },
            pause() {
                if (!videoEl.ended) {
                    stats.pauses++;
                    if (stats.lastPlayAt) {
                        stats.watchedMs += Date.now() - stats.lastPlayAt;
                        stats.lastPlayAt = null;
                    }
                    stopWall();
                }
            },
            seeked() {
                if (stats.startTime) { stats.seeks++; saveStats(); }
            },
            visibility() {
                if (document.hidden) {
                    stopWall();
                } else if (!videoEl.paused) {
                    startWall();
                }
            },
        };

        videoEl.addEventListener('play',   statsListeners.play);
        videoEl.addEventListener('pause',  statsListeners.pause);
        videoEl.addEventListener('seeked', statsListeners.seeked);
        document.addEventListener('visibilitychange', statsListeners.visibility);
    }

    const DB_NAME    = 'yt-ult';
    const DB_VERSION = 1;
    const DB_STORE   = 'sessions';

    let db = null;

    function openDb() {
        return new Promise((resolve, reject) => {
            if (db) return resolve(db);
            const req = indexedDB.open(DB_NAME, DB_VERSION);
            req.onupgradeneeded = e => {
                const store = e.target.result.createObjectStore(DB_STORE, { keyPath: 'id', autoIncrement: true });
                store.createIndex('date', 'date');
            };
            req.onsuccess = e => { db = e.target.result; resolve(db); };
            req.onerror   = () => reject(req.error);
        });
    }

    function dbAdd(record) {
        return openDb().then(d => new Promise((resolve, reject) => {
            const tx  = d.transaction(DB_STORE, 'readwrite');
            const req = tx.objectStore(DB_STORE).add(record);
            req.onsuccess = () => resolve(req.result);
            req.onerror   = () => reject(req.error);
        })).catch(() => {});
    }

    function dbGetAll() {
        return openDb().then(d => new Promise((resolve, reject) => {
            const req = d.transaction(DB_STORE, 'readonly').objectStore(DB_STORE).getAll();
            req.onsuccess = () => resolve(req.result);
            req.onerror   = () => reject(req.error);
        })).catch(() => []);
    }

    function commitSession() {
        if (!stats?.startTime || !stats.watchedMs) return;
        if (sessionCommitted) return;
        const videoId = new URLSearchParams(location.search).get('v');
        if (!videoId) return;
        sessionCommitted = true;
        if (wallPlayAt) {
            stats.wallMs = (stats.wallMs || 0) + (Date.now() - wallPlayAt);
            wallPlayAt = null;
        }
        dbAdd({
            videoId,
            date:      stats.startTime,
            watchedMs: stats.watchedMs,
            wallMs:    Math.min(stats.wallMs || 0, stats.watchedMs * 2),
            pauses:    stats.pauses,
            seeks:     stats.seeks,
        });
    }

    // ─── SETTINGS ────────────────────────────────────────────────────────────────

    const SETTINGS_KEY = 'yt-ult-settings';

    const DEFAULT_SETTINGS = {
        hud:       true,
        thumbEta:  true,
        quality:   true,
        autoplay:  true,
        grid:      true,
        stats:     true,
    };

    const MENU_ITEMS = [
        { key: 'hud',      label: 'ETA HUD' },
        { key: 'thumbEta', label: 'Thumbnail end-time badges' },
        { key: 'quality',  label: 'Auto highest quality' },
        { key: 'autoplay', label: 'Playlist autoplay' },
        { key: 'grid',     label: '5-column grid' },
        { key: 'stats',    label: 'Session stats' },
    ];

    let settings = (() => {
        try {
            const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
            return Object.assign({}, DEFAULT_SETTINGS, saved);
        } catch (_) { return { ...DEFAULT_SETTINGS }; }
    })();

    function saveSetting(key, value) {
        settings[key] = value;
        try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch (_) {}
    }

    function applySettings() {
        const gridStyle = document.getElementById('yt-ult-grid');
        if (gridStyle) gridStyle.disabled = !settings.grid;

        const hudBadgeEl = document.querySelector('.yt-ult-eta');
        if (hudBadgeEl) hudBadgeEl.style.display = settings.hud ? '' : 'none';

        document.querySelectorAll('.yt-ult-thumb-eta').forEach(e => {
            e.style.display = settings.thumbEta ? '' : 'none';
        });
    }

    // ─── DASHBOARD ───────────────────────────────────────────────────────────────

    function el(tag, styles, text) {
        const e = document.createElement(tag);
        if (styles) Object.assign(e.style, styles);
        if (text !== undefined) e.textContent = text;
        return e;
    }

    function fmtMs(ms) {
        const s = Math.floor(ms / 1000);
        const h = Math.floor(s / 3600);
        const m = Math.floor((s % 3600) / 60);
        if (h > 0) return `${h}h ${m}m`;
        if (m > 0) return `${m}m`;
        return `${s}s`;
    }

    function startOfDay(ts)   { const d = new Date(ts); d.setHours(0,0,0,0); return d.getTime(); }
    function startOfWeek(ts)  { const d = new Date(startOfDay(ts)); d.setDate(d.getDate() - d.getDay()); return d.getTime(); }
    function startOfMonth(ts) { const d = new Date(ts); d.setDate(1); d.setHours(0,0,0,0); return d.getTime(); }
    function startOfYear(ts)  { const d = new Date(ts); d.setMonth(0,1); d.setHours(0,0,0,0); return d.getTime(); }

    function aggregateSessions(sessions, getBucket) {
        const map = new Map();
        for (const s of sessions) {
            const key = getBucket(s.date);
            const cur = map.get(key) || { watchedMs: 0, count: 0, pauses: 0, seeks: 0 };
            cur.watchedMs += s.watchedMs;
            cur.count++;
            cur.pauses += s.pauses;
            cur.seeks  += s.seeks;
            map.set(key, cur);
        }
        return map;
    }

    function buildBarChart(data, labelFn, color) {
        const wrap = el('div', { display: 'flex', alignItems: 'flex-end', gap: '6px', height: '80px', marginTop: '8px' });
        const max  = Math.max(...data.map(d => d.value), 1);
        for (const { label, value } of data) {
            const col = el('div', { display: 'flex', flexDirection: 'column', alignItems: 'center', flex: '1', gap: '4px' });
            const bar = el('div', {
                width: '100%', background: color,
                height: `${Math.round((value / max) * 64)}px`,
                borderRadius: '3px 3px 0 0', minHeight: '2px',
                transition: 'height 0.3s',
            });
            const lbl = el('div', { fontSize: '10px', opacity: '0.5', whiteSpace: 'nowrap' }, labelFn(label));
            col.appendChild(bar);
            col.appendChild(lbl);
            wrap.appendChild(col);
        }
        return wrap;
    }

    function statCard(label, value) {
        const card = el('div', { background: 'rgba(255,255,255,0.05)', borderRadius: '8px', padding: '12px 16px' });
        const lbl  = el('div', { fontSize: '11px', opacity: '0.5', marginBottom: '4px' }, label);
        const val  = el('div', { fontSize: '18px', fontWeight: '500' }, value);
        card.appendChild(lbl);
        card.appendChild(val);
        return card;
    }

    function buildSection(title, rows) {
        const sec = el('div', { marginBottom: '24px' });
        const hdr = el('div', { fontSize: '12px', opacity: '0.4', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '12px' }, title);
        sec.appendChild(hdr);
        const grid = el('div', { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' });
        for (const [label, value] of rows) grid.appendChild(statCard(label, value));
        sec.appendChild(grid);
        return sec;
    }

    function buildPeriodTab(sessions, period) {
        const now = Date.now();
        const ranges = {
            week:  { ms: 7  * 86400000, buckets: 7,  getBucket: startOfDay,   labelFn: ts => new Date(ts).toLocaleDateString([], { weekday: 'short' }) },
            month: { ms: 30 * 86400000, buckets: 4,  getBucket: startOfWeek,  labelFn: ts => new Date(ts).toLocaleDateString([], { month: 'short', day: 'numeric' }) },
            year:  { ms: 365* 86400000, buckets: 12, getBucket: startOfMonth, labelFn: ts => new Date(ts).toLocaleDateString([], { month: 'short' }) },
        }[period];

        const filtered  = sessions.filter(s => s.date >= now - ranges.ms);
        const totalMs   = filtered.reduce((a, s) => a + s.watchedMs, 0);
        const totalVids = filtered.length;
        const avgMs     = totalVids ? Math.round(totalMs / totalVids) : 0;
        const pauses    = filtered.reduce((a, s) => a + s.pauses, 0);
        const seeks     = filtered.reduce((a, s) => a + s.seeks, 0);

        const wrap = el('div');
        wrap.appendChild(buildSection('Overview', [
            ['Watch time', fmtMs(totalMs)],
            ['Videos',     totalVids],
            ['Avg per video', fmtMs(avgMs)],
            ['Pauses', pauses],
            ['Seeks',  seeks],
            ['Avg pauses/video', totalVids ? (pauses / totalVids).toFixed(1) : '—'],
        ]));

        const agg = aggregateSessions(filtered, ranges.getBucket);
        const buckets = [];
        for (let i = ranges.buckets - 1; i >= 0; i--) {
            const key = ranges.getBucket(now - i * (ranges.ms / ranges.buckets));
            buckets.push({ label: key, value: (agg.get(key)?.watchedMs ?? 0) });
        }
        const chartHdr = el('div', { fontSize: '12px', opacity: '0.4', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '4px' }, 'Watch time');
        wrap.appendChild(chartHdr);
        wrap.appendChild(buildBarChart(buckets, ranges.labelFn, 'rgba(255,255,255,0.15)'));

        return wrap;
    }

    function buildTodayTab(sessions) {
        const now      = Date.now();
        const todayStart = (() => { const d = new Date(now); d.setHours(0,0,0,0); return d.getTime(); })();
        const filtered = sessions.filter(s => s.date >= todayStart);

        const totalMs   = filtered.reduce((a, s) => a + s.watchedMs, 0);
        const totalVids = filtered.length;
        const avgMs     = totalVids ? Math.round(totalMs / totalVids) : 0;
        const pauses    = filtered.reduce((a, s) => a + s.pauses, 0);
        const seeks     = filtered.reduce((a, s) => a + s.seeks, 0);

        const wrap = el('div');

        const hdr = el('div', { fontSize: '12px', opacity: '0.4', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '12px' }, 'Overview');
        wrap.appendChild(hdr);

        const grid = el('div', { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '24px' });
        const items = [
            ['Watch time',       fmtMs(totalMs)],
            ['Videos',           totalVids],
            ['Avg per video',    fmtMs(avgMs)],
            ['Pauses',           pauses],
            ['Seeks',            seeks],
            ['Avg pauses/video', totalVids ? (pauses / totalVids).toFixed(1) : '—'],
        ];
        for (const [label, value] of items) grid.appendChild(statCard(label, value));
        wrap.appendChild(grid);

        const hourlyData = [];
        for (let h = 0; h < 24; h++) {
            const hStart = todayStart + h * 3600000;
            const hEnd   = hStart + 3600000;
            const ms     = filtered.filter(s => s.date >= hStart && s.date < hEnd).reduce((a, s) => a + s.watchedMs, 0);
            hourlyData.push({ label: h, value: ms });
        }

        const barHdr = el('div', { fontSize: '12px', opacity: '0.4', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '4px' }, 'Watch time');
        wrap.appendChild(barHdr);
        wrap.appendChild(buildBarChart(hourlyData, h => `${h}h`, 'rgba(255,255,255,0.15)'));



        return wrap;
    }


    async function showDashboard() {
        document.getElementById('yt-ult-dashboard')?.remove();

        const sessions = await dbGetAll();

        const overlay = el('div');
        overlay.id = 'yt-ult-dashboard';
        Object.assign(overlay.style, {
            position: 'fixed', top: '0', left: '0', width: '100vw', height: '100vh',
            background: 'rgba(0,0,0,0.75)', zIndex: '99999',
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            fontFamily: 'Roboto, Arial, sans-serif',
        });

        const panel = el('div', {
            background: '#0f0f0f', borderRadius: '16px', padding: '32px',
            width: '480px', maxHeight: '80vh', overflowY: 'auto',
            color: '#fff', fontSize: '14px', lineHeight: '1.6',
            boxShadow: '0 16px 64px rgba(0,0,0,0.8)',
        });

        const hdr = el('div', { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' });
        const ttl = el('span', { fontSize: '18px', fontWeight: '500' }, 'Viewing history');
        const cls = el('button', { background: 'none', border: 'none', color: '#fff', fontSize: '22px', cursor: 'pointer', opacity: '0.5', padding: '0' }, '✕');
        cls.addEventListener('click', () => overlay.remove());
        hdr.appendChild(ttl);
        hdr.appendChild(cls);
        panel.appendChild(hdr);

        const tabs     = ['today', 'week', 'month', 'year', 'settings'];
        const tabLabels = { today: 'Today', week: 'Week', month: 'Month', year: 'Year', settings: 'Settings' };
        let activeTab  = 'today';

        const tabBar  = el('div', { display: 'flex', gap: '8px', marginBottom: '24px' });
        const content = el('div');

        function buildSettingsTab() {
            const wrap = el('div');
            const hdr  = el('div', { fontSize: '12px', opacity: '0.4', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '12px' }, 'Settings');
            wrap.appendChild(hdr);

            for (const item of MENU_ITEMS) {
                const on = settings[item.key];
                const isLast = item === MENU_ITEMS[MENU_ITEMS.length - 1];
                const row = el('div', {
                    display: 'flex', alignItems: 'center', justifyContent: 'space-between',
                    padding: '10px 0',
                    borderBottom: isLast ? 'none' : '1px solid rgba(255,255,255,0.06)',
                    cursor: 'pointer',
                });

                const lbl = el('span', { fontSize: '14px' }, item.label);

                const track = el('div', {
                    width: '40px', height: '24px', borderRadius: '12px',
                    background: on ? '#3ea6ff' : 'rgba(255,255,255,0.2)',
                    position: 'relative', transition: 'background 0.2s', flexShrink: '0',
                    boxSizing: 'border-box',
                });
                const thumb = el('div', {
                    width: '18px', height: '18px', borderRadius: '50%',
                    background: '#fff',
                    position: 'absolute', top: '3px',
                    left: on ? '19px' : '3px',
                    transition: 'left 0.2s',
                    boxShadow: '0 1px 4px rgba(0,0,0,0.3)',
                });
                track.appendChild(thumb);
                row.appendChild(lbl);
                row.appendChild(track);

                row.addEventListener('click', () => {
                    const newVal = !settings[item.key];
                    saveSetting(item.key, newVal);
                    track.style.background = newVal ? '#3ea6ff' : 'rgba(255,255,255,0.2)';
                    thumb.style.left = newVal ? '19px' : '3px';
                    applySettings();
                });

                wrap.appendChild(row);
            }
            return wrap;
        }

        function renderTab(period) {
            content.replaceChildren();
            if (period === 'settings') {
                content.appendChild(buildSettingsTab());
            } else if (period === 'today') {
                content.appendChild(buildTodayTab(sessions));
            } else {
                content.appendChild(buildPeriodTab(sessions, period));
            }
            for (const btn of tabBar.children) {
                btn.style.opacity    = btn.dataset.tab === period ? '1' : '0.4';
                btn.style.background = btn.dataset.tab === period ? 'rgba(255,255,255,0.1)' : 'none';
            }
        }

        for (const tab of tabs) {
            const btn = el('button', {
                background: 'none', border: '1px solid rgba(255,255,255,0.15)',
                color: '#fff', borderRadius: '6px', padding: '6px 14px',
                cursor: 'pointer', fontSize: '13px', opacity: '0.4',
            }, tabLabels[tab]);
            btn.dataset.tab = tab;
            btn.addEventListener('click', () => { activeTab = tab; renderTab(tab); });
            tabBar.appendChild(btn);
        }

        panel.appendChild(tabBar);
        panel.appendChild(content);
        renderTab(activeTab);

        overlay.appendChild(panel);
        overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
        document.body.appendChild(overlay);
    }


    // ─── MASTHEAD BUTTON ─────────────────────────────────────────────────────────

    function initMastheadButton() {
        const masthead = document.querySelector('ytd-masthead #end');
        if (!masthead || document.getElementById('yt-ult-btn')) return;

        const wrap = document.createElement('yt-icon-button');
        wrap.id = 'yt-ult-btn';
        wrap.className = 'style-scope ytd-masthead';
        wrap.title = 'YouTube Ultimate — viewing history';
        wrap.style.cursor = 'pointer';

        const btn = document.createElement('button');
        btn.id = 'yt-ult-btn-inner';
        btn.className = 'style-scope yt-icon-button';
        Object.assign(btn.style, {
            display: 'flex', alignItems: 'center', justifyContent: 'center',
        });
        btn.setAttribute('aria-label', 'YouTube Ultimate — viewing history');

        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('width', '24');
        svg.setAttribute('height', '24');
        svg.setAttribute('viewBox', '0 0 24 24');
        svg.setAttribute('fill', 'currentColor');
        svg.style.fill = 'var(--yt-spec-text-primary, #fff)';

        const paths = [
            'M3 13h2v7H3v-7z',
            'M8 9h2v11H8V9z',
            'M13 5h2v15h-2V5z',
            'M18 11h2v9h-2v-9z',
        ];
        for (const d of paths) {
            const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
            p.setAttribute('d', d);
            svg.appendChild(p);
        }

        btn.appendChild(svg);
        wrap.appendChild(btn);
        wrap.addEventListener('click', (e) => { e.stopPropagation(); showDashboard(); });
        const firstChild = masthead.firstElementChild;
        if (firstChild) {
            masthead.insertBefore(wrap, firstChild);
        } else {
            masthead.prepend(wrap);
        }
    }

    function waitForMasthead() {
        const tryInject = () => {
            const end = document.querySelector('ytd-masthead #end');
            if (end && !document.getElementById('yt-ult-btn')) {
                initMastheadButton();
            }
        };

        let attempts = 0;
        const timer = setInterval(() => {
            attempts++;
            tryInject();
            if (document.getElementById('yt-ult-btn') || attempts >= 30) clearInterval(timer);
        }, 300);

        document.addEventListener('yt-navigate-finish', () => setTimeout(tryInject, 400));
    }

    // ─── THUMBNAIL ETA ───────────────────────────────────────────────────────────

    function parseDurationToSeconds(text) {
        const clean = text.trim().replace(/[^\d:]/g, '');
        const parts = clean.split(':').map(Number);
        if (parts.some(isNaN)) return null;

        if (parts.length === 2) {
            const [m, s] = parts;
            if (s >= 60) return null;
            return m * 60 + s;
        }
        if (parts.length === 3) {
            const [h, m, s] = parts;
            if (m >= 60 || s >= 60) return null;
            return h * 3600 + m * 60 + s;
        }
        return null;
    }

    function updateThumbEtaText(badgeEl) {
        const seconds = parseInt(badgeEl.dataset.ultSeconds, 10);
        if (!seconds) return;
        const eta = badgeEl.querySelector('.yt-ult-thumb-eta');
        if (!eta) return;
        eta.textContent = `• ${formatClock(new Date(Date.now() + seconds * 1000))}`;
    }

    function injectThumbnailEta(badgeEl) {
        if (badgeEl.dataset.ultEta) return;
        badgeEl.dataset.ultEta = '1';

        const text = Array.from(badgeEl.childNodes)
            .filter(n => n.nodeType === Node.TEXT_NODE)
            .map(n => n.textContent)
            .join('').trim();
        if (!text.includes(':')) return;

        const seconds = parseDurationToSeconds(text);
        if (!seconds) return;

        badgeEl.dataset.ultSeconds = seconds;

        const eta = document.createElement('span');
        eta.className = 'yt-ult-thumb-eta';
        Object.assign(eta.style, {
            marginLeft: '4px', opacity: '0.6', fontSize: 'inherit',
            fontFamily: 'inherit', color: 'inherit',
            pointerEvents: 'none', userSelect: 'none',
        });
        badgeEl.appendChild(eta);

        updateThumbEtaText(badgeEl);
        badgeEl.addEventListener('mouseenter', () => updateThumbEtaText(badgeEl));
    }

    function processAllThumbnailBadges() {
        document.querySelectorAll('.ytBadgeShapeText:not([data-ult-eta])').forEach(injectThumbnailEta);
    }

    function refreshAllThumbEtas() {
        document.querySelectorAll('.ytBadgeShapeText[data-ult-seconds]').forEach(updateThumbEtaText);
    }

    function resetThumbnailEta() {
        document.querySelectorAll('.ytBadgeShapeText[data-ult-eta]').forEach(badgeEl => {
            delete badgeEl.dataset.ultEta;
            delete badgeEl.dataset.ultSeconds;
            badgeEl.querySelector('.yt-ult-thumb-eta')?.remove();
        });
    }

    const scheduleIdle = typeof requestIdleCallback === 'function'
        ? cb => requestIdleCallback(cb, { timeout: 1000 })
        : cb => setTimeout(cb, 100);

    function initThumbnailEta() {
        processAllThumbnailBadges();

        new MutationObserver(mutations => {
            const nodes = [];
            for (const m of mutations) {
                for (const node of m.addedNodes) {
                    if (node.nodeType !== 1) continue;
                    const tag = node.tagName?.toLowerCase() ?? '';
                    if (!tag.startsWith('ytd-') && !tag.startsWith('ytm-') && !tag.startsWith('div')) continue;
                    nodes.push(node);
                }
            }
            if (!nodes.length) return;

            scheduleIdle(() => {
                for (const node of nodes) {
                    if (node.classList?.contains('ytBadgeShapeText')) injectThumbnailEta(node);
                    node.querySelectorAll?.('.ytBadgeShapeText:not([data-ult-eta])').forEach(injectThumbnailEta);
                }
            });
        }).observe(document.body, { childList: true, subtree: true });
    }

    // ─── GRID ────────────────────────────────────────────────────────────────────

    function applyGrid() {
        if (!settings.grid) return;
        const style = document.getElementById('yt-ult-grid');
        if (style) return;
        const styleEl = document.createElement('style');
        styleEl.id = 'yt-ult-grid';
        styleEl.textContent = `
            ytd-rich-grid-renderer {
                --ytd-rich-grid-items-per-row: 5 !important;
                --ytd-rich-grid-slim-items-per-row: 5 !important;
                --ytd-rich-grid-posts-per-row: 5 !important;
            }
            ytd-rich-grid-row > #contents {
                display: grid !important;
                grid-template-columns: repeat(5, 1fr) !important;
                gap: 8px 16px !important;
            }
            ytd-rich-grid-row > #contents > ytd-rich-item-renderer {
                width: 100% !important;
                max-width: 100% !important;
                margin: 0 !important;
            }
        `;
        document.head.appendChild(styleEl);
    }

    // ─── KEYBOARD SHORTCUTS ──────────────────────────────────────────────────────

    document.addEventListener('keydown', (e) => {
        const tag = e.target.tagName;
        if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return;
        if (e.key === 's' || e.key === 'S') {
            if (document.getElementById('yt-ult-stats')) {
                document.getElementById('yt-ult-stats')?.remove();
                statsDismissed = true;
            } else {
                statsDismissed = false;
                showStatsOverlay();
            }
        }
        if (e.key === '`' || e.key === '~') {
            const overlay = document.getElementById('yt-ult-stats');
            if (!overlay) return;
            const dvdAnim = overlay._dvdAnim;
            if (dvdAnim) {
                cancelAnimationFrame(dvdAnim);
                overlay._dvdAnim = null;
                overlay.style.transform = 'translate(-50%, -50%)';
                overlay.style.top       = '50%';
                overlay.style.left      = '50%';
                overlay.style.boxShadow = '0 8px 32px rgba(0,0,0,0.6)';
            } else {
                overlay.style.transform = 'none';
                overlay.style.position  = 'absolute';
                const player = getPlayerEl();
                if (!player) return;
                const ow = overlay.offsetWidth;
                const oh = overlay.offsetHeight;
                let x = (player.offsetWidth  - ow) / 2;
                let y = (player.offsetHeight - oh) / 2;
                let vx = 2, vy = 1.5;
                let lastTime = null;
                overlay.style.left = x + 'px';
                overlay.style.top  = y + 'px';

                const colors = ['#ff4444','#44aaff','#44ff88','#ffaa44','#ff44aa','#aaff44'];
                let colorIdx = 0;

                function tick(ts) {
                    if (!document.getElementById('yt-ult-stats')) return;
                    const p = getPlayerEl();
                    if (!p) return;
                    if (lastTime === null) lastTime = ts;
                    const dt = Math.min((ts - lastTime) / 16.67, 3);
                    lastTime = ts;

                    const cpw = p.offsetWidth;
                    const cph = p.offsetHeight;
                    x += vx * dt;
                    y += vy * dt;
                    let bounced = false;
                    if (x <= 0)             { x = 0;        vx =  Math.abs(vx); bounced = true; }
                    if (x + ow >= cpw)      { x = cpw - ow; vx = -Math.abs(vx); bounced = true; }
                    if (y <= 0)             { y = 0;        vy =  Math.abs(vy); bounced = true; }
                    if (y + oh >= cph)      { y = cph - oh; vy = -Math.abs(vy); bounced = true; }
                    if (bounced) {
                        colorIdx = (colorIdx + 1) % colors.length;
                        overlay.style.boxShadow = `0 0 0 3px ${colors[colorIdx]}, 0 8px 32px rgba(0,0,0,0.6)`;
                    }
                    overlay.style.left = x + 'px';
                    overlay.style.top  = y + 'px';
                    overlay._dvdAnim = requestAnimationFrame(tick);
                }
                overlay._dvdAnim = requestAnimationFrame(tick);
            }
        }
    });

    // ─── NAVIGATION (SPA) ────────────────────────────────────────────────────────

    function onVideoLoad() {
        initQuality();
        initAutoplay();
        initHud();
        initStats(getVideoEl());

    }

    function onPageChange() {
        commitSession();
        resetThumbnailEta();
    }

    const NAV_EVENT = isMobile ? 'video-data-change' : 'yt-player-updated';
    document.addEventListener(NAV_EVENT, onVideoLoad);
    document.addEventListener('yt-navigate-finish', onPageChange);

    setTimeout(() => { if (getPlayerEl()) onVideoLoad(); }, 800);
    waitForMasthead();
    applyGrid();
    initThumbnailEta();
    setInterval(refreshAllThumbEtas, 60_000);

})();