YouTube Ultimate

ETA HUD on player • end-time badges on thumbnails • auto highest quality • playlist autoplay

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         YouTube Ultimate
// @namespace    dispatch330.youtube.ultimate
// @version      2.7
// @description  ETA HUD on player • end-time badges on thumbnails • auto highest quality • playlist autoplay
// @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 (greasyfork.org/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 (greasyfork.org/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);
    }

    function initHudClick() {
        const container = document.querySelector('.ytp-time-duration');
        if (!container || container._ytUltClick) return;
        container._ytUltClick = true;
        container.style.cursor = 'pointer';
        container.addEventListener('click', () => {
            if (document.getElementById('yt-ult-stats')) {
                document.getElementById('yt-ult-stats')?.remove();
                statsDismissed = true;
            } else {
                statsDismissed = false;
                showStatsOverlay();
            }
        });
    }

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

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

    const STATS_KEY = 'yt-ult-stats';

    function saveStats() {
        try { localStorage.setItem(STATS_KEY, JSON.stringify(stats)); } catch (_) {}
    }

    function loadStats() {
        try {
            const raw = localStorage.getItem(STATS_KEY);
            return raw ? JSON.parse(raw) : null;
        } catch (_) { return null; }
    }

    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;

    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);
        }

        const saved = loadStats();
        const oneDayMs = 24 * 60 * 60 * 1000;
        if (saved && saved.startTime && (Date.now() - saved.startTime) < oneDayMs) {
            stats = saved;
            statsDismissed = false;
        } else {
            resetStats();
        }
        document.getElementById('yt-ult-stats')?.remove();

        statsVideoEl = videoEl;

        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();
            }
        }

        statsListeners = {
            play() {
                if (!stats.startTime) stats.startTime = Date.now();
                stats.lastPlayAt = Date.now();
                startWall();
                saveStats();
            },
            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;
        sessionCommitted = true;
        const wallMs = stats.wallMs || 0;
        const videoId = new URLSearchParams(location.search).get('v') || location.pathname;
        dbAdd({
            videoId,
            date:      stats.startTime,
            watchedMs: stats.watchedMs,
            wallMs,
            pauses:    stats.pauses,
            seeks:     stats.seeks,
        });
    }

    // ─── 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' }, 'Today');
        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 clockHdr = el('div', { fontSize: '12px', opacity: '0.4', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '8px' }, 'Activity clock');
        wrap.appendChild(clockHdr);

        let clockStyle = 'bar';
        const clockWrap = el('div');
        wrap.appendChild(clockWrap);

        const styleToggle = el('div', { display: 'flex', gap: '8px', marginBottom: '12px' });
        for (const [key, label] of [['bar', 'Bar'], ['ring', 'Ring'], ['polar', 'Polar']]) {
            const btn = el('button', {
                background: 'none',
                border: '1px solid rgba(255,255,255,0.15)',
                color: '#fff', borderRadius: '6px', padding: '4px 12px',
                cursor: 'pointer', fontSize: '12px',
                opacity: key === clockStyle ? '1' : '0.4',
            }, label);
            btn.addEventListener('click', () => {
                clockStyle = key;
                for (const b of styleToggle.children) b.style.opacity = '0.4';
                btn.style.opacity = '1';
                if (clockStyle === 'bar') {
                    clockWrap.replaceChildren(buildBarChart(hourlyData, h => `${h}h`, 'rgba(255,255,255,0.15)'));
                } else {
                    clockWrap.replaceChildren(buildClockChart(hourlyData, clockStyle));
                }
            });
            styleToggle.appendChild(btn);
        }
        wrap.insertBefore(styleToggle, clockWrap);
        clockWrap.appendChild(buildBarChart(hourlyData, h => `${h}h`, 'rgba(255,255,255,0.15)'));

        return wrap;
    }

    function buildClockChart(hourlyData, style = 'ring') {
        const size   = 220;
        const cx     = size / 2;
        const cy     = size / 2;
        const max    = Math.max(...hourlyData.map(d => d.value), 1);

        if (style === 'polar') {
            const pad  = 24;
            const rMax = (size / 2) - pad;
            const svgSize = size + pad * 2;
            const scx  = svgSize / 2;
            const scy  = svgSize / 2;

            const svg  = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
            svg.setAttribute('width', svgSize);
            svg.setAttribute('height', svgSize);
            svg.setAttribute('viewBox', `0 0 ${svgSize} ${svgSize}`);
            svg.style.display = 'block';
            svg.style.margin  = '0 auto 16px';

            for (const frac of [0.25, 0.5, 0.75, 1]) {
                const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
                circle.setAttribute('cx', scx);
                circle.setAttribute('cy', scy);
                circle.setAttribute('r', rMax * frac);
                circle.setAttribute('fill', 'none');
                circle.setAttribute('stroke', 'rgba(255,255,255,0.06)');
                circle.setAttribute('stroke-width', '1');
                svg.appendChild(circle);
            }

            for (const { label: h, value } of hourlyData) {
                const angleStart = (h / 24) * Math.PI * 2 - Math.PI / 2;
                const angleEnd   = ((h + 1) / 24) * Math.PI * 2 - Math.PI / 2;
                const r          = (value / max) * rMax;
                const gap        = 0.04;
                if (r < 1) continue;

                const x1 = scx + r * Math.cos(angleStart + gap);
                const y1 = scy + r * Math.sin(angleStart + gap);
                const x2 = scx + r * Math.cos(angleEnd - gap);
                const y2 = scy + r * Math.sin(angleEnd - gap);

                const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
                path.setAttribute('d', `M ${scx} ${scy} L ${x1} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y2} Z`);
                path.setAttribute('fill', `rgba(255,255,255,${0.15 + 0.7 * (value / max)})`);
                const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
                title.textContent = `${h}:00 — ${fmtMs(value)}`;
                path.appendChild(title);
                svg.appendChild(path);
            }

            for (const h of [0, 6, 12, 18]) {
                const angle = (h / 24) * Math.PI * 2 - Math.PI / 2;
                const r     = rMax + 14;
                const txt   = document.createElementNS('http://www.w3.org/2000/svg', 'text');
                txt.setAttribute('x', scx + r * Math.cos(angle));
                txt.setAttribute('y', scy + r * Math.sin(angle));
                txt.setAttribute('text-anchor', 'middle');
                txt.setAttribute('dominant-baseline', 'middle');
                txt.setAttribute('fill', 'rgba(255,255,255,0.4)');
                txt.setAttribute('font-size', '11');
                txt.setAttribute('font-family', 'Roboto, Arial, sans-serif');
                txt.textContent = `${h}h`;
                svg.appendChild(txt);
            }
            return svg;
        }

        const rMin   = 32;
        const rMax   = 88;

        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('width', size);
        svg.setAttribute('height', size);
        svg.setAttribute('viewBox', `0 0 ${size} ${size}`);
        svg.style.display = 'block';
        svg.style.margin  = '0 auto 16px';

        for (const { label: h, value } of hourlyData) {
            const angleStart = (h / 24) * Math.PI * 2 - Math.PI / 2;
            const angleEnd   = ((h + 1) / 24) * Math.PI * 2 - Math.PI / 2;
            const r          = rMin + (value / max) * (rMax - rMin);
            const gap        = 0.03;

            const x1 = cx + rMin * Math.cos(angleStart + gap);
            const y1 = cy + rMin * Math.sin(angleStart + gap);
            const x2 = cx + r    * Math.cos(angleStart + gap);
            const y2 = cy + r    * Math.sin(angleStart + gap);
            const x3 = cx + r    * Math.cos(angleEnd   - gap);
            const y3 = cy + r    * Math.sin(angleEnd   - gap);
            const x4 = cx + rMin * Math.cos(angleEnd   - gap);
            const y4 = cy + rMin * Math.sin(angleEnd   - gap);

            const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
            const d = `M ${x1} ${y1} L ${x2} ${y2} A ${r} ${r} 0 0 1 ${x3} ${y3} L ${x4} ${y4} A ${rMin} ${rMin} 0 0 0 ${x1} ${y1} Z`;
            path.setAttribute('d', d);
            path.setAttribute('fill', value > 0 ? `rgba(255,255,255,${0.1 + 0.7 * (value / max)})` : 'rgba(255,255,255,0.05)');

            const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
            title.textContent = `${h}:00 — ${fmtMs(value)}`;
            path.appendChild(title);
            svg.appendChild(path);
        }

        for (const h of [0, 6, 12, 18]) {
            const angle = (h / 24) * Math.PI * 2 - Math.PI / 2;
            const r     = rMax + 12;
            const x     = cx + r * Math.cos(angle);
            const y     = cy + r * Math.sin(angle);
            const txt   = document.createElementNS('http://www.w3.org/2000/svg', 'text');
            txt.setAttribute('x', x);
            txt.setAttribute('y', y);
            txt.setAttribute('text-anchor', 'middle');
            txt.setAttribute('dominant-baseline', 'middle');
            txt.setAttribute('fill', 'rgba(255,255,255,0.35)');
            txt.setAttribute('font-size', '10');
            txt.setAttribute('font-family', 'Roboto, Arial, sans-serif');
            txt.textContent = `${h}h`;
            svg.appendChild(txt);
        }

        return svg;
    }

    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 row = el('div', {
                    display: 'flex', alignItems: 'center', justifyContent: 'space-between',
                    padding: '10px 0', borderBottom: '1px solid rgba(255,255,255,0.06)',
                    cursor: 'pointer',
                });

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

                const track = el('div', {
                    width: '34px', height: '14px', borderRadius: '7px',
                    background: on ? '#3ea6ff' : 'rgba(255,255,255,0.3)',
                    position: 'relative', transition: 'background 0.2s', flexShrink: '0',
                });
                const thumb = el('div', {
                    width: '20px', height: '20px', borderRadius: '50%',
                    background: on ? '#3ea6ff' : 'rgba(255,255,255,0.8)',
                    position: 'absolute', top: '-3px',
                    left: on ? '16px' : '-2px',
                    transition: 'left 0.2s, background 0.2s',
                    boxShadow: '0 1px 3px rgba(0,0,0,0.4)',
                });
                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.3)';
                    thumb.style.background = newVal ? '#3ea6ff' : 'rgba(255,255,255,0.8)';
                    thumb.style.left = newVal ? '16px' : '-2px';
                    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);
    }


    // ─── 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';
        });
    }

    // ─── 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);
        btn.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) => {
        if (e.target.tagName === 'INPUT' || e.target.tagName === '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();
            }
        }
    });

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

    function onVideoLoad() {
        initQuality();
        initAutoplay();
        initHud();
        initStats(getVideoEl());
        setTimeout(initHudClick, 1000);
    }

    function onPageChange() {
        commitSession();
        resetThumbnailEta();
        document.getElementById('yt-ult-stats')?.remove();
    }

    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);

})();