Greasy Fork is available in English.
ETA HUD on player • end-time badges on thumbnails • auto highest quality • playlist autoplay
// ==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); })();