Volume memory, frame stepping, hotkeys, download, screenshot, thumbnail, loop, and playlist/related tools for Hypnotube
// ==UserScript==
// @name Hypnotube Fixer
// @namespace https://github.com/Thunderhawkk
// @version 1.0
// @description Volume memory, frame stepping, hotkeys, download, screenshot, thumbnail, loop, and playlist/related tools for Hypnotube
// @author Thunderhawkk
// @license MIT
// @match https://hypnotube.com/*
// @match https://www.hypnotube.com/*
// @icon https://cdn.hypnotube.com/templates/hypnotube/images/touch/fav.png
// @run-at document-idle
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @grant GM_download
// @connect hypnotube.com
// @connect media.hypnotube.com
// @connect ht-media.hypnotube.com
// @connect cdn.hypnotube.com
// @connect *
// ==/UserScript==
(function () {
'use strict';
console.log('[Hypnotube Fixer v1.0] Loaded');
// =====================================================================
// Defaults + GM-storage backed settings
// Each setting is read through a getter (validated) and written on save.
// =====================================================================
const DEFAULTS = {
defaultVol: 20,
videoFps: 30,
framesToSkip: 1,
ssFolder: 'Hypnotube',
ssMode: 'auto',
hideBigPlay: true,
hideAds: false,
showVol: true,
playlistFullscreen: false
};
const SS_MODES = ['auto', 'preview', 'hide'];
function clamp(n, lo, hi) {
n = Number(n);
if (isNaN(n)) n = lo;
return Math.min(hi, Math.max(lo, n));
}
function intVal(v, fallback, lo, hi) {
const n = parseInt(v, 10);
return clamp(isNaN(n) ? fallback : n, lo, hi);
}
function boolVal(v, fallback) {
if (v === undefined || v === null) return fallback;
return v === true || v === 'true' || v === 1 || v === '1';
}
function clamp01(n) {
n = Number(n);
if (isNaN(n)) return 0;
return Math.min(1, Math.max(0, n));
}
const SETTINGS = {
get defaultVol() { return intVal(GM_getValue('ht_defaultVol', DEFAULTS.defaultVol), DEFAULTS.defaultVol, 0, 100); },
set defaultVol(v) { GM_setValue('ht_defaultVol', intVal(v, DEFAULTS.defaultVol, 0, 100)); },
get videoFps() { return intVal(GM_getValue('ht_videoFps', DEFAULTS.videoFps), DEFAULTS.videoFps, 1, 120); },
set videoFps(v) { GM_setValue('ht_videoFps', intVal(v, DEFAULTS.videoFps, 1, 120)); },
get framesToSkip() { return intVal(GM_getValue('ht_framesToSkip', DEFAULTS.framesToSkip), DEFAULTS.framesToSkip, 1, 120); },
set framesToSkip(v) { GM_setValue('ht_framesToSkip', intVal(v, DEFAULTS.framesToSkip, 1, 120)); },
get ssFolder() {
const v = GM_getValue('ht_ssFolder', DEFAULTS.ssFolder);
return (typeof v === 'string' && v.trim()) ? v.trim() : DEFAULTS.ssFolder;
},
set ssFolder(v) { GM_setValue('ht_ssFolder', (typeof v === 'string' && v.trim()) ? v.trim() : DEFAULTS.ssFolder); },
get ssMode() {
const v = GM_getValue('ht_ssMode', DEFAULTS.ssMode);
return SS_MODES.indexOf(v) >= 0 ? v : DEFAULTS.ssMode;
},
set ssMode(v) { GM_setValue('ht_ssMode', SS_MODES.indexOf(v) >= 0 ? v : DEFAULTS.ssMode); },
get hideBigPlay() { return boolVal(GM_getValue('ht_hideBigPlay', DEFAULTS.hideBigPlay), DEFAULTS.hideBigPlay); },
set hideBigPlay(v) { GM_setValue('ht_hideBigPlay', !!v); },
get hideAds() { return boolVal(GM_getValue('ht_hideAds', DEFAULTS.hideAds), DEFAULTS.hideAds); },
set hideAds(v) { GM_setValue('ht_hideAds', !!v); },
get showVol() { return boolVal(GM_getValue('ht_showVol', DEFAULTS.showVol), DEFAULTS.showVol); },
set showVol(v) { GM_setValue('ht_showVol', !!v); },
get playlistFullscreen() { return boolVal(GM_getValue('ht_playlistFullscreen', DEFAULTS.playlistFullscreen), DEFAULTS.playlistFullscreen); },
set playlistFullscreen(v) { GM_setValue('ht_playlistFullscreen', !!v); }
};
// =====================================================================
// Module state
// =====================================================================
let manualVolChange = false;
let volSaveTimer = null;
let toastTimer = null;
let styleEl = null;
let lastVideoEl = null;
let activeDownloadRequest = null;
let isDownloading = false;
let screenshotHidden = false;
let playlistFullscreenActive = false;
let playlistFullscreenDismissed = false;
const ICONS = {
download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
camera: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>',
image: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>',
loop: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>'
};
// =====================================================================
// Utilities
// =====================================================================
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function toast(msg, type, duration) {
type = type || 'info';
duration = duration || 3000;
const existing = document.querySelector('.ht-toast');
if (existing) existing.remove();
if (toastTimer) clearTimeout(toastTimer);
const el = document.createElement('div');
el.className = 'ht-toast';
el.textContent = msg;
el.style.cssText = `
position: fixed; bottom: 80px; right: 20px; z-index: 100000;
padding: 12px 20px; border-radius: 10px; font: 14px/1.4 sans-serif;
color: #fff; max-width: 320px; transform: translateY(20px); opacity: 0;
transition: all 0.3s ease; pointer-events: none;
background: ${type === 'success' ? 'rgba(34,197,94,0.95)' : type === 'error' ? 'rgba(239,68,68,0.95)' : 'rgba(30,30,30,0.95)'};
border: 1px solid ${type === 'success' ? 'rgba(34,197,94,0.3)' : type === 'error' ? 'rgba(239,68,68,0.3)' : 'rgba(255,255,255,0.1)'};
backdrop-filter: blur(12px); box-shadow: 0 8px 32px rgba(0,0,0,0.4);`;
document.body.appendChild(el);
requestAnimationFrame(function () { el.style.transform = 'translateY(0)'; el.style.opacity = '1'; });
toastTimer = setTimeout(function () {
el.style.transform = 'translateY(20px)'; el.style.opacity = '0';
setTimeout(function () { el.remove(); }, 300);
}, duration);
}
function getVideoEl() {
return document.querySelector('video#plyr_player') || document.querySelector('video');
}
function isWatchPage() {
return /\/video\//.test(window.location.pathname) || !!document.querySelector('video#plyr_player');
}
function isPlaylistPage() {
if (document.body && document.body.classList.contains('page-index-playlist')) return true;
return window.location.pathname.indexOf('/playlist/') === 0;
}
function getVideoId() {
const m = window.location.pathname.match(/\/video\/(?:.*-)?(\d+)\.html/);
return m ? m[1] : null;
}
function getVideoTitle() {
let el = document.querySelector('.item-tr-col h1');
if (!el) el = document.querySelector('.box-container h1');
if (!el) {
const h1s = Array.prototype.slice.call(document.querySelectorAll('h1'));
for (let i = 0; i < h1s.length; i++) {
if ((h1s[i].textContent || '').trim() !== 'Related Content') { el = h1s[i]; break; }
}
}
let title = el ? (el.textContent || '').trim() : '';
if (!title) title = document.title || '';
title = title.replace(/[\\/:*?"<>|]/g, '_').trim();
if (title.length > 150) title = title.slice(0, 150).trim();
return title || 'Hypnotube';
}
function formatTime(seconds) {
if (!seconds) return '00-00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return h > 0 ? `${h}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}` : `${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
}
function isHiddenInInput() {
const el = document.activeElement;
if (!el) return false;
const tag = (el.tagName || '').toUpperCase();
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
if (el.isContentEditable) return true;
if (el.closest && el.closest('.ht-settings-overlay')) return true;
return false;
}
// =====================================================================
// Injected CSS (single block, regenerated when settings change)
// =====================================================================
function buildCss(s) {
const parts = [];
parts.push(`
.ht-ctl { position: relative; }
.ht-ctl:hover { color: #fff; }
.ht-ctl.active { color: #22c55e !important; }
.ht-ctl.ht-active-download { color: #f97316 !important; }
.ht-ctl svg { width: 18px; height: 18px; display: block; pointer-events: none; }
.ht-ctl-label {
position: absolute; top: -15px; left: 50%; transform: translateX(-50%);
font-size: 10px; font-weight: 700; color: #fff; background: rgba(0,0,0,0.85);
padding: 1px 4px; border-radius: 3px; white-space: nowrap; pointer-events: none;
}
#ht-vol-display {
color: #fff; font-size: 12px; font-weight: 700; margin-left: 6px;
background: rgba(0,0,0,0.6); padding: 2px 5px; border-radius: 4px;
}
.ht-filter-wrap { display: flex; align-items: center; gap: 8px; margin: 10px 0; }
.ht-filter-bar {
flex: 1; box-sizing: border-box; padding: 8px 12px; background: #1b1b1b;
border: 1px solid #3a3a3a; color: #fff; border-radius: 6px; font-size: 13px; outline: none;
}
.ht-filter-bar:focus { border-color: #f97316; }
.ht-filter-bar::placeholder { color: #777; }
.ht-filter-count { font-size: 12px; color: #aaa; white-space: nowrap; }
#ht-settings-gear {
position: fixed; bottom: 20px; right: 20px; z-index: 99999;
width: 42px; height: 42px; border-radius: 50%; border: 1px solid #444;
background: #222; color: #fff; font-size: 20px; line-height: 1; cursor: pointer;
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
#ht-settings-gear:hover { background: #333; border-color: #f97316; }
.ht-fs-exit {
position: fixed; top: 10px; right: 10px; z-index: 10001;
width: 36px; height: 36px; border-radius: 50%; border: 1px solid #444;
background: rgba(20,20,20,0.85); color: #fff; font-size: 18px; cursor: pointer;
}
.ht-playlist-fullscreen {
position: fixed !important; inset: 0 !important;
width: 100vw !important; height: 100vh !important;
max-width: none !important; max-height: none !important;
margin: 0 !important; z-index: 9999 !important; background: #000 !important;
}
.ht-settings-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.75); z-index: 100000;
display: flex; align-items: center; justify-content: center;
}
.ht-settings-modal {
width: 360px; max-width: calc(100vw - 30px); max-height: 88vh; overflow-y: auto;
background: #1c1c1e; border: 1px solid rgba(255,255,255,0.1); border-radius: 12px;
color: #eee; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
}
.ht-settings-head {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; border-bottom: 1px solid rgba(255,255,255,0.08);
}
.ht-settings-head h3 { margin: 0; font-size: 16px; }
.ht-settings-close { background: transparent; border: none; color: #aaa; font-size: 18px; cursor: pointer; }
.ht-settings-close:hover { color: #fff; }
.ht-settings-body { padding: 14px 18px; }
.ht-row { margin-bottom: 12px; }
.ht-row > label { display: block; font-size: 12px; font-weight: 600; color: #ddd; margin-bottom: 5px; }
.ht-row small { display: block; color: #888; font-size: 11px; font-weight: 400; margin-top: 3px; }
.ht-row input[type="number"], .ht-row input[type="text"], .ht-row select {
width: 100%; box-sizing: border-box; background: #2a2a2a; color: #fff;
border: 1px solid #444; border-radius: 6px; padding: 7px 9px; font-size: 13px; outline: none;
}
.ht-row input:focus, .ht-row select:focus { border-color: #f97316; }
.ht-row > label.ht-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #ddd; margin-bottom: 0; }
.ht-check input { width: 16px; height: 16px; flex: 0 0 auto; }
.ht-settings-foot { padding: 14px 18px; border-top: 1px solid rgba(255,255,255,0.08); }
.ht-save-btn {
width: 100%; background: #f97316; color: #fff; border: none; border-radius: 8px;
padding: 10px; font-size: 14px; font-weight: 700; cursor: pointer;
}
.ht-save-btn:hover { background: #ea580c; }
`);
// Toggleable rules
if (s.hideBigPlay) parts.push('.plyr__control--overlaid { display: none !important; }');
if (s.hideAds) parts.push('#playerOverlay, .top-ban-item { display: none !important; }');
if (!s.showVol) parts.push('#ht-vol-display { display: none !important; }');
return parts.join('\n');
}
function applyStyles() {
const css = buildCss(SETTINGS);
if (styleEl && styleEl.parentNode) {
styleEl.textContent = css;
return;
}
if (typeof GM_addStyle === 'function') {
styleEl = GM_addStyle(css) || document.getElementById('ht-style');
}
if (!styleEl) {
styleEl = document.getElementById('ht-style');
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'ht-style';
document.head.appendChild(styleEl);
}
styleEl.textContent = css;
} else if (!styleEl.id) {
styleEl.id = 'ht-style';
}
}
// =====================================================================
// Volume module
// =====================================================================
function getTargetVolume() {
const stored = GM_getValue('ht_volume', null);
if (stored !== null && stored !== undefined && stored !== '') {
const n = parseFloat(stored);
if (!isNaN(n)) return clamp01(n);
}
return clamp01(SETTINGS.defaultVol / 100);
}
function onVideoVolumeChange() {
updateVolDisplay();
const video = getVideoEl();
if (!video) return;
const started = parseInt(video.dataset.htVolStarted || '0', 10);
if (!manualVolChange && started && (Date.now() - started > 500)) {
if (Math.abs(video.volume - getTargetVolume()) > 0.01) manualVolChange = true;
}
if (manualVolChange) {
clearTimeout(volSaveTimer);
volSaveTimer = setTimeout(function () {
try {
GM_setValue('ht_volume', video.volume);
} catch (err) {
console.warn('[Hypnotube Fixer] Failed to persist volume', err);
}
}, 300);
}
}
function syncVolume() {
const video = getVideoEl();
if (!video) return;
// Reset per-video state when the player swaps to a new <video> element.
if (video !== lastVideoEl) {
lastVideoEl = video;
manualVolChange = false;
}
if (!video.dataset.htVolListener) {
video.dataset.htVolListener = '1';
video.addEventListener('volumechange', onVideoVolumeChange);
}
// Haven-style retry: apply the target volume for ~3s after load,
// and stop as soon as the user changes it manually.
if (!video.dataset.htVolStarted) {
video.dataset.htVolStarted = String(Date.now());
if (!manualVolChange) {
// Only set volume; never force-unmute (a deliberate mute must survive reloads).
try { video.volume = getTargetVolume(); }
catch (err) { console.warn('[Hypnotube Fixer] Volume apply failed', err); }
}
}
if (manualVolChange) return;
if (Date.now() - parseInt(video.dataset.htVolStarted, 10) < 3000) {
const target = getTargetVolume();
if (Math.abs(video.volume - target) > 0.01) {
try { video.volume = target; } catch (err) { console.warn('[Hypnotube Fixer] Volume retry failed', err); }
}
}
}
function updateVolDisplay() {
const video = getVideoEl();
const volArea = document.querySelector('.plyr__volume');
let label = document.getElementById('ht-vol-display');
if (!SETTINGS.showVol || !video || !volArea) {
if (label) label.remove();
return;
}
if (!label) {
label = document.createElement('span');
label.id = 'ht-vol-display';
label.className = 'ht-vol-display';
if (volArea.parentNode) volArea.parentNode.insertBefore(label, volArea.nextSibling);
else volArea.appendChild(label);
}
label.textContent = Math.round(video.volume * 100) + '%';
}
// =====================================================================
// Screenshot module
// NOTE: media.hypnotube.com (BunnyCDN) sends no Access-Control-Allow-Origin,
// so we NEVER set video.crossOrigin (it would break playback). Direct canvas
// capture therefore taints the canvas and toDataURL() can throw SecurityError,
// which is why the VTT preview fallback exists.
// =====================================================================
function triggerAnchorDownload(url, filename) {
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
function saveDataUrl(dataUrl, filename) {
const path = 'Script Screenshots/' + SETTINGS.ssFolder + '/' + filename;
if (typeof GM_download === 'function') {
try {
GM_download({
url: dataUrl, name: path, saveAs: false,
onerror: function (err) {
console.warn('[Hypnotube Fixer] GM_download failed, using anchor fallback', err);
triggerAnchorDownload(dataUrl, filename);
}
});
return;
} catch (err) {
console.warn('[Hypnotube Fixer] GM_download threw, using anchor fallback', err);
}
}
triggerAnchorDownload(dataUrl, filename);
}
function saveRemoteUrl(url, filename) {
const path = 'Script Screenshots/' + SETTINGS.ssFolder + '/' + filename;
if (typeof GM_download === 'function') {
try {
GM_download({
url: url, name: path, saveAs: false,
onerror: function (err) {
console.warn('[Hypnotube Fixer] GM_download failed, using anchor fallback', err);
triggerAnchorDownload(url, filename);
}
});
return;
} catch (err) {
console.warn('[Hypnotube Fixer] GM_download threw, using anchor fallback', err);
}
}
triggerAnchorDownload(url, filename);
}
function captureDirectFrame(video, title) {
if (!video.videoWidth || !video.videoHeight) return false;
let dataUrl = null;
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
dataUrl = canvas.toDataURL('image/png');
} catch (err) {
console.warn('[Hypnotube Fixer] Direct canvas capture failed (tainted/CORS):', err && err.name ? err.name : err);
return false;
}
if (!dataUrl || dataUrl === 'data:,') {
console.warn('[Hypnotube Fixer] Direct canvas capture returned an empty data URL');
return false;
}
saveDataUrl(dataUrl, title + ' - ' + formatTime(video.currentTime) + '.png');
toast('📸 Saved exact frame', 'success');
return true;
}
function parseVttCues(text) {
const cues = [];
const lines = String(text).replace(/\r/g, '').split('\n');
const longRe = /(\d{1,2}):(\d{2}):(\d{2})[.,](\d{3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[.,](\d{3})/;
const shortRe = /(\d{1,2}):(\d{2})[.,](\d{3})\s*-->\s*(\d{1,2}):(\d{2})[.,](\d{3})/;
const urlRe = /https?:\/\/\S+?\.(?:jpg|jpeg|png|webp|avif)\b/i;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
let start = null;
let m = line.match(longRe);
if (m) {
start = parseInt(m[1], 10) * 3600 + parseInt(m[2], 10) * 60 + parseInt(m[3], 10) + parseInt(m[4], 10) / 1000;
} else {
m = line.match(shortRe);
if (m) start = parseInt(m[1], 10) * 60 + parseInt(m[2], 10) + parseInt(m[3], 10) / 1000;
}
if (start === null) continue;
let urlM = line.match(urlRe);
if (!urlM && lines[i + 1]) urlM = lines[i + 1].trim().match(urlRe);
if (urlM) cues.push({ start: start, url: urlM[0] });
}
return cues;
}
function fetchPreviewImage(imgUrl, cueStart, title, onFail) {
GM_xmlhttpRequest({
method: 'GET',
url: imgUrl,
responseType: 'blob',
onload: function (res) {
if (res.status !== 200 || !res.response) {
console.warn('[Hypnotube Fixer] Preview image fetch failed:', res.status);
if (onFail) onFail(); else toast('❌ Preview image unavailable', 'error');
return;
}
const objectUrl = URL.createObjectURL(res.response);
const img = new Image();
img.onload = function () {
try {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
canvas.getContext('2d').drawImage(img, 0, 0);
let dataUrl = null;
try {
dataUrl = canvas.toDataURL('image/png');
} catch (err) {
console.warn('[Hypnotube Fixer] Preview canvas export failed', err);
}
if (dataUrl && dataUrl !== 'data:,') {
saveDataUrl(dataUrl, title + ' - ' + formatTime(cueStart) + ' (preview).png');
toast('📸 Saved nearest preview frame (5s granularity, lower res)', 'info');
} else if (onFail) {
onFail();
} else {
toast('❌ Preview capture failed', 'error');
}
} finally {
URL.revokeObjectURL(objectUrl);
}
};
img.onerror = function () {
URL.revokeObjectURL(objectUrl);
console.warn('[Hypnotube Fixer] Preview image could not be decoded');
if (onFail) onFail(); else toast('❌ Preview image could not be decoded', 'error');
};
img.src = objectUrl;
},
onerror: function () {
console.warn('[Hypnotube Fixer] Preview image request error');
if (onFail) onFail(); else toast('❌ Preview image download failed', 'error');
}
});
}
function capturePreviewFrame(video, title, onFail) {
const id = getVideoId();
if (!id) {
console.warn('[Hypnotube Fixer] No video id found for VTT preview');
if (onFail) onFail(); else toast('❌ Could not determine video id', 'error');
return;
}
const vttUrl = 'https://hypnotube.com/webvtt/' + id + '.vtt';
GM_xmlhttpRequest({
method: 'GET',
url: vttUrl,
onload: function (res) {
if (res.status !== 200 || !res.responseText) {
console.warn('[Hypnotube Fixer] VTT fetch failed:', res.status);
if (onFail) onFail(); else toast('❌ Preview track unavailable', 'error');
return;
}
const cues = parseVttCues(res.responseText);
if (!cues.length) {
console.warn('[Hypnotube Fixer] No cues parsed from VTT');
if (onFail) onFail(); else toast('❌ No preview frames found', 'error');
return;
}
const now = video.currentTime;
let best = cues[0];
let bestDist = Math.abs(cues[0].start - now);
for (let i = 1; i < cues.length; i++) {
const d = Math.abs(cues[i].start - now);
if (d < bestDist) { bestDist = d; best = cues[i]; }
}
fetchPreviewImage(best.url, best.start, title, onFail);
},
onerror: function () {
console.warn('[Hypnotube Fixer] VTT request error');
if (onFail) onFail(); else toast('❌ Preview request failed', 'error');
}
});
}
function toggleOverlaysForScreenshot() {
const nodes = document.querySelectorAll('.plyr__controls, .plyr__control--overlaid, #playerOverlay');
if (!screenshotHidden) {
nodes.forEach(function (el) {
if (el.dataset.htHidden) return;
el.dataset.htHidden = '1';
el.style.setProperty('display', 'none', 'important');
});
screenshotHidden = true;
toast('🖼️ Overlays hidden — take your OS screenshot; press S again to restore', 'info', 5000);
} else {
nodes.forEach(function (el) {
if (!el.dataset.htHidden) return;
delete el.dataset.htHidden;
el.style.removeProperty('display');
});
screenshotHidden = false;
toast('🔄 Overlays restored', 'info', 1500);
}
}
function takeScreenshot() {
const video = getVideoEl();
if (!video) { toast('❌ No video found', 'error'); return; }
// A hidden overlay state always takes priority: next invocation restores it.
if (screenshotHidden) { toggleOverlaysForScreenshot(); return; }
const mode = SETTINGS.ssMode;
if (mode === 'hide') { toggleOverlaysForScreenshot(); return; }
const title = getVideoTitle();
const failToHide = function () { toggleOverlaysForScreenshot(); };
if (mode === 'preview') {
capturePreviewFrame(video, title, failToHide);
return;
}
// "auto": exact frame first, then VTT preview, then hide overlays.
if (captureDirectFrame(video, title)) return;
capturePreviewFrame(video, title, failToHide);
}
// =====================================================================
// Thumbnail download
// =====================================================================
function getThumbUrl() {
const og = document.querySelector('meta[property="og:image"]');
if (og && og.content) return og.content;
const v = getVideoEl();
if (v && v.poster) return v.poster;
const img = document.querySelector('.item-col img');
if (img && img.src) return img.src;
return null;
}
function downloadThumbnail() {
const url = getThumbUrl();
if (!url) { toast('❌ Thumbnail not found', 'error'); return; }
saveRemoteUrl(url, getVideoTitle() + ' - thumb.jpg');
toast('🖼️ Thumbnail downloading…', 'info', 2000);
}
// =====================================================================
// MP4 download module
// =====================================================================
function qualityScore(v) {
if (v === null || v === undefined) return 0;
const s = String(v).trim().toUpperCase();
if (s === 'HD') return 100000; // HD is always higher than SD
if (s === 'SD') return -100000;
const n = parseInt(s, 10);
return isNaN(n) ? 0 : n;
}
function findBestSource() {
const video = getVideoEl();
if (!video) return null;
const sources = Array.prototype.slice.call(video.querySelectorAll('source[src]'));
// Quality logic: if several <source> elements exist, pick the highest "sizes".
if (sources.length > 1) {
let best = null;
let bestScore = -Infinity;
sources.forEach(function (s) {
const score = qualityScore(s.getAttribute('sizes'));
if (score > bestScore) { bestScore = score; best = s; }
});
if (best) return best.src || best.getAttribute('src');
}
if (video.currentSrc) return video.currentSrc;
if (sources.length === 1) return sources[0].src || sources[0].getAttribute('src');
if (video.src) return video.src;
return null;
}
function setBtnLabel(btn, text) {
let label = btn.querySelector('.ht-ctl-label');
if (!label) {
label = document.createElement('span');
label.className = 'ht-ctl-label';
btn.appendChild(label);
}
label.textContent = text;
}
function resetDownloadBtn(btn) {
if (!btn) return;
btn.classList.remove('ht-active-download');
const label = btn.querySelector('.ht-ctl-label');
if (label) label.remove();
}
function doDownload(url, title, btn) {
isDownloading = true;
btn.classList.add('ht-active-download');
setBtnLabel(btn, '0% (×)');
activeDownloadRequest = GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'blob',
// No Referer header is required for media.hypnotube.com (verified: plain GET -> 206).
// Omitting it avoids managers rejecting forbidden headers.
onprogress: function (e) {
if (e.lengthComputable) {
const p = Math.floor((e.loaded / e.total) * 100);
setBtnLabel(btn, p + '% (×)');
} else {
setBtnLabel(btn, '…(×)');
}
},
onload: function (r) {
isDownloading = false;
activeDownloadRequest = null;
resetDownloadBtn(btn);
if (r.status === 200 && r.response) {
const u = URL.createObjectURL(r.response);
const a = document.createElement('a');
a.href = u;
a.download = title + ' - Hypnotube.mp4';
document.body.appendChild(a);
a.click();
setTimeout(function () { a.remove(); URL.revokeObjectURL(u); }, 1000);
toast('✅ Download complete', 'success');
} else {
toast('❌ Download failed (HTTP ' + r.status + ')', 'error');
}
},
onerror: function () {
isDownloading = false;
activeDownloadRequest = null;
resetDownloadBtn(btn);
toast('❌ Network error', 'error');
},
onabort: function () {
isDownloading = false;
activeDownloadRequest = null;
resetDownloadBtn(btn);
}
});
}
function handleDownloadClick(btn) {
// Second click aborts an in-flight download.
if (isDownloading) {
if (activeDownloadRequest && typeof activeDownloadRequest.abort === 'function') {
activeDownloadRequest.abort();
}
isDownloading = false;
activeDownloadRequest = null;
resetDownloadBtn(btn);
toast('⬇️ Download cancelled', 'info');
return;
}
const src = findBestSource();
if (!src) {
toast('❌ No video source found', 'error');
resetDownloadBtn(btn);
return;
}
doDownload(src, getVideoTitle(), btn);
}
// =====================================================================
// Loop toggle
// =====================================================================
function syncLoopButton(btn) {
if (!btn) btn = document.getElementById('ht-loop-btn');
if (!btn) return;
const video = getVideoEl();
btn.classList.toggle('active', !!(video && video.loop));
}
function toggleLoop(btn) {
const video = getVideoEl();
if (!video) { toast('❌ No video found', 'error'); return; }
video.loop = !video.loop;
syncLoopButton(btn || document.getElementById('ht-loop-btn'));
toast(video.loop ? '🔁 Loop on' : '🔁 Loop off', 'info', 1500);
}
// =====================================================================
// Player control buttons
// =====================================================================
function createControl(id, svg, title, onClick) {
const b = document.createElement('button');
b.type = 'button';
b.id = id;
b.className = 'plyr__controls__item plyr__control ht-ctl';
b.title = title;
b.setAttribute('aria-label', title);
b.innerHTML = svg; // static, trusted inline SVG
b.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
onClick();
});
return b;
}
function injectButtons() {
const controls = document.querySelector('.plyr__controls');
if (!controls) return;
if (!document.getElementById('ht-dl-btn')) {
const dl = createControl('ht-dl-btn', ICONS.download, 'Download MP4', function () { handleDownloadClick(dl); });
controls.appendChild(dl);
}
if (!document.getElementById('ht-ss-btn')) {
const ss = createControl('ht-ss-btn', ICONS.camera, 'Screenshot (S)', function () { takeScreenshot(); });
controls.appendChild(ss);
}
if (!document.getElementById('ht-thumb-btn')) {
const th = createControl('ht-thumb-btn', ICONS.image, 'Download thumbnail', function () { downloadThumbnail(); });
controls.appendChild(th);
}
if (!document.getElementById('ht-loop-btn')) {
const lp = createControl('ht-loop-btn', ICONS.loop, 'Toggle loop (L)', function () { toggleLoop(lp); });
controls.appendChild(lp);
syncLoopButton(lp);
}
}
// =====================================================================
// Filtering helpers + rails
// =====================================================================
function getItemMatchText(item) {
let text = '';
const titleEl = item.querySelector('.title');
if (titleEl) text = titleEl.textContent || '';
if (!text) {
const a = item.querySelector('a[title]');
if (a) text = a.getAttribute('title') || '';
}
if (!text) {
const a = item.querySelector('a[href*="/video/"]');
if (a) text = a.textContent || '';
}
if (!text) text = item.textContent || '';
// Cap matching text length to avoid perf issues on large grids.
return text.toLowerCase().slice(0, 300);
}
function applyFilter(items, rawQuery, countEl) {
const q = (rawQuery || '').trim().toLowerCase();
let shown = 0;
const total = items.length;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (!item || !item.isConnected) continue;
const match = !q || getItemMatchText(item).indexOf(q) >= 0;
item.style.display = match ? '' : 'none';
if (match) shown++;
}
if (countEl) countEl.textContent = q ? (shown + ' of ' + total) : '';
}
function makeFilterBar(placeholder, onInput) {
const wrap = document.createElement('div');
wrap.className = 'ht-filter-wrap';
const input = document.createElement('input');
input.type = 'text';
input.className = 'ht-filter-bar';
input.placeholder = placeholder;
input.autocomplete = 'off';
input.spellcheck = false;
const count = document.createElement('span');
count.className = 'ht-filter-count';
wrap.appendChild(input);
wrap.appendChild(count);
input.addEventListener('input', function () { onInput(input.value, count); });
return { wrap: wrap, input: input, count: count };
}
function initRelatedFilter() {
if (!isWatchPage()) return;
// Note the site's misspelling: "reltated-sec".
const section = document.querySelector('section.reltated-sec');
if (!section || section.querySelector('.ht-filter-bar')) return;
const items = Array.prototype.slice.call(section.querySelectorAll('.item-col'));
if (!items.length) return;
const bar = makeFilterBar('Search related content…', function (q, count) { applyFilter(items, q, count); });
section.insertBefore(bar.wrap, section.firstChild);
}
function initPlaylistWidgetFilter() {
if (!isWatchPage()) return;
const widget = document.querySelector('.playlist-widget');
if (!widget || widget.querySelector('.ht-filter-bar')) return;
// The widget's internal markup is unverified, so gather every plausible
// clickable/text-bearing descendant, then keep only the deepest matches
// (so filtering does not hide an ancestor and its child together).
const all = Array.prototype.slice.call(widget.querySelectorAll(
'a, [role="button"], li, button, div[class*="item"], div[class*="video"], div[class*="entry"]'
));
const candidates = all.filter(function (el) {
if (el.closest && el.closest('.ht-filter-wrap')) return false;
const t = (el.textContent || '').trim();
return t.length > 1 && t.length <= 300;
});
const leaves = candidates.filter(function (el) {
return !candidates.some(function (other) { return other !== el && el.contains(other); });
});
if (!leaves.length) return;
const bar = makeFilterBar('Search playlist queue…', function (q, count) { applyFilter(leaves, q, count); });
widget.insertBefore(bar.wrap, widget.firstChild);
}
function initPlaylistPageFilter() {
if (!isPlaylistPage()) return;
// Only filter the main listing grid, never the filter aside.
const main = document.querySelector('.content-col') || document.querySelector('.main-col');
if (!main || main.querySelector('.ht-filter-bar')) return;
const items = Array.prototype.slice.call(main.querySelectorAll('.item-col')).filter(function (it) {
return !it.closest('.aside-main-col');
});
if (!items.length) return;
const bar = makeFilterBar('Search this playlist…', function (q, count) { applyFilter(items, q, count); });
main.insertBefore(bar.wrap, main.firstChild);
}
// =====================================================================
// Optional auto-maximize for in-player playlist
// =====================================================================
function exitPlaylistFullscreen() {
const player = document.querySelector('.plyr--video.ht-playlist-fullscreen');
if (player) player.classList.remove('ht-playlist-fullscreen');
const btn = document.getElementById('ht-fs-exit');
if (btn) btn.remove();
playlistFullscreenActive = false;
playlistFullscreenDismissed = true;
}
function applyPlaylistFullscreen() {
const enable = SETTINGS.playlistFullscreen;
const widget = document.querySelector('.playlist-widget');
const eligible = enable && !playlistFullscreenDismissed && isWatchPage() && !!widget;
if (!eligible) {
if (playlistFullscreenActive) {
const player = document.querySelector('.plyr--video.ht-playlist-fullscreen');
if (player) player.classList.remove('ht-playlist-fullscreen');
const btn = document.getElementById('ht-fs-exit');
if (btn) btn.remove();
playlistFullscreenActive = false;
}
return;
}
const player = document.querySelector('.plyr--video');
if (!player) return;
if (!playlistFullscreenActive) {
player.classList.add('ht-playlist-fullscreen');
playlistFullscreenActive = true;
}
if (!document.getElementById('ht-fs-exit')) {
const b = document.createElement('button');
b.id = 'ht-fs-exit';
b.type = 'button';
b.className = 'ht-fs-exit';
b.textContent = '✕';
b.title = 'Exit playlist fullscreen';
b.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
exitPlaylistFullscreen();
});
document.body.appendChild(b);
}
}
// =====================================================================
// Hotkeys
// =====================================================================
function stepVideo(delta) {
const video = getVideoEl();
if (!video) return;
try {
video.currentTime = Math.max(0, video.currentTime + delta);
} catch (err) {
console.warn('[Hypnotube Fixer] Seek failed', err);
}
}
function handleKeydown(e) {
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (isHiddenInInput()) return;
const key = e.key;
const step = SETTINGS.framesToSkip / SETTINGS.videoFps;
// Frame stepping: step = framesToSkip / videoFps seconds.
if (key === ',' || key === '<') { stepVideo(-step); e.preventDefault(); return; }
if (key === '.' || key === '>') { stepVideo(step); e.preventDefault(); return; }
// Shift+O opens settings.
if (e.shiftKey && key && key.toLowerCase() === 'o') { e.preventDefault(); createSettingsUI(); return; }
// S = screenshot, L = loop (plain keys only).
if (!e.shiftKey && (key === 's' || key === 'S')) { e.preventDefault(); takeScreenshot(); return; }
if (!e.shiftKey && (key === 'l' || key === 'L')) { e.preventDefault(); toggleLoop(); return; }
// NOTE: We intentionally do NOT bind or override space, k, m, f, or the
// arrow keys. Plyr already handles those globally, and hijacking them
// breaks normal playback controls (play/pause, mute, fullscreen, seek).
}
// =====================================================================
// Settings panel
// =====================================================================
function createSettingsUI() {
if (document.getElementById('ht-settings-overlay')) return;
const s = SETTINGS;
const selected = function (v) { return s.ssMode === v ? ' selected' : ''; };
const overlay = document.createElement('div');
overlay.id = 'ht-settings-overlay';
overlay.className = 'ht-settings-overlay';
overlay.innerHTML = `
<div class="ht-settings-modal">
<div class="ht-settings-head">
<h3>⚙️ Hypnotube Fixer</h3>
<button class="ht-settings-close" id="ht-settings-close" title="Close">✕</button>
</div>
<div class="ht-settings-body">
<div class="ht-row">
<label>Default Volume % <small>Starting volume, 0–100 (0 = mute)</small></label>
<input type="number" id="ht-set-vol" min="0" max="100" value="${escapeHtml(String(s.defaultVol))}">
</div>
<div class="ht-row">
<label>Assumed FPS <small>Frame rate used for frame stepping</small></label>
<input type="number" id="ht-set-fps" min="1" max="120" value="${escapeHtml(String(s.videoFps))}">
</div>
<div class="ht-row">
<label>Frames to Skip <small>Per , / . keypress</small></label>
<input type="number" id="ht-set-skip" min="1" max="120" value="${escapeHtml(String(s.framesToSkip))}">
</div>
<div class="ht-row">
<label>Screenshot Subfolder <small>Inside "Script Screenshots"</small></label>
<input type="text" id="ht-set-folder" value="${escapeHtml(s.ssFolder)}">
</div>
<div class="ht-row">
<label>Screenshot Mode <small>Auto = exact frame, else VTT preview, else hide overlays</small></label>
<select id="ht-set-ssmode">
<option value="auto"${selected('auto')}>Auto</option>
<option value="preview"${selected('preview')}>Preview frame (VTT)</option>
<option value="hide"${selected('hide')}>Hide overlays (manual)</option>
</select>
</div>
<div class="ht-row">
<label class="ht-check"><input type="checkbox" id="ht-set-bigplay"${s.hideBigPlay ? ' checked' : ''}> Hide Plyr big play button</label>
</div>
<div class="ht-row">
<label class="ht-check"><input type="checkbox" id="ht-set-ads"${s.hideAds ? ' checked' : ''}> Hide ad / overlay elements</label>
</div>
<div class="ht-row">
<label class="ht-check"><input type="checkbox" id="ht-set-showvol"${s.showVol ? ' checked' : ''}> Show on-screen volume %</label>
</div>
<div class="ht-row">
<label class="ht-check"><input type="checkbox" id="ht-set-plfs"${s.playlistFullscreen ? ' checked' : ''}> Auto-maximize player when an in-player playlist exists</label>
<small>Expands the player to fill the window; use the ✕ button to exit. Off by default.</small>
</div>
</div>
<div class="ht-settings-foot">
<button class="ht-save-btn" id="ht-set-save">Save & Close</button>
</div>
</div>`;
document.body.appendChild(overlay);
function close() {
document.removeEventListener('keydown', onKey, true);
overlay.remove();
}
function onKey(ev) {
if (ev.key === 'Escape') { ev.preventDefault(); close(); }
}
overlay.querySelector('#ht-settings-close').addEventListener('click', close);
overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); });
document.addEventListener('keydown', onKey, true);
overlay.querySelector('#ht-set-save').addEventListener('click', function () {
const volEl = overlay.querySelector('#ht-set-vol');
const fpsEl = overlay.querySelector('#ht-set-fps');
const skipEl = overlay.querySelector('#ht-set-skip');
const folderEl = overlay.querySelector('#ht-set-folder');
const modeEl = overlay.querySelector('#ht-set-ssmode');
SETTINGS.defaultVol = intVal(volEl.value, DEFAULTS.defaultVol, 0, 100);
SETTINGS.videoFps = intVal(fpsEl.value, DEFAULTS.videoFps, 1, 120);
SETTINGS.framesToSkip = intVal(skipEl.value, DEFAULTS.framesToSkip, 1, 120);
SETTINGS.ssFolder = folderEl.value;
SETTINGS.ssMode = modeEl.value;
SETTINGS.hideBigPlay = overlay.querySelector('#ht-set-bigplay').checked;
SETTINGS.hideAds = overlay.querySelector('#ht-set-ads').checked;
SETTINGS.showVol = overlay.querySelector('#ht-set-showvol').checked;
SETTINGS.playlistFullscreen = overlay.querySelector('#ht-set-plfs').checked;
// Treat the newly saved default as the current volume too, so the
// change is audible immediately and survives the ht_volume override.
try { GM_setValue('ht_volume', SETTINGS.defaultVol / 100); } catch (err) {
console.warn('[Hypnotube Fixer] Could not persist volume on save', err);
}
// Re-apply everything immediately.
manualVolChange = false;
const video = getVideoEl();
if (video) delete video.dataset.htVolStarted;
playlistFullscreenDismissed = false;
applyStyles();
syncVolume();
updateVolDisplay();
applyPlaylistFullscreen();
toast('✅ Settings saved', 'success');
close();
});
}
function ensureGear() {
if (document.getElementById('ht-settings-gear')) return;
const btn = document.createElement('button');
btn.id = 'ht-settings-gear';
btn.type = 'button';
btn.title = 'Hypnotube Fixer settings (Shift+O)';
btn.textContent = '⚙️';
btn.addEventListener('click', function (e) {
e.preventDefault();
createSettingsUI();
});
document.body.appendChild(btn);
}
// =====================================================================
// Poll / init
// =====================================================================
function poll() {
ensureGear();
injectButtons();
syncVolume();
if (isWatchPage()) {
updateVolDisplay();
initRelatedFilter();
initPlaylistWidgetFilter();
applyPlaylistFullscreen();
}
initPlaylistPageFilter();
}
function init() {
applyStyles();
ensureGear();
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('Hypnotube Fixer: Settings', createSettingsUI);
}
document.addEventListener('keydown', handleKeydown, false);
poll();
setInterval(poll, 1000);
}
if (document.body) init(); else window.addEventListener('load', init);
})();