Skip Intros, Credits, After Credits & Advanced Fullscreen on Crunchyroll
// ==UserScript==
// @name CrunchySkip
// @namespace http://tampermonkey.net/
// @version 3.1
// @description Skip Intros, Credits, After Credits & Advanced Fullscreen on Crunchyroll
// @author Kriimaar
// @match https://www.crunchyroll.com/*/watch/*
// @match https://www.crunchyroll.com/watch/*
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const CURRENT_VERSION = '3.5';
const GREASYFORK_META_URL = 'https://update.greasyfork.org/scripts/561075/CrunchySkip.meta.js';
const GREASYFORK_PAGE_URL = 'https://greasyfork.org/scripts/561075';
const log = (...a) => console.log('[CrunchySkip]', ...a);
// ─── SEMVER ───────────────────────────────────────────────────────────────────
function semverGt(a, b) {
const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const na = pa[i] || 0, nb = pb[i] || 0;
if (na > nb) return true;
if (na < nb) return false;
}
return false;
}
// ─── VERSION CHECK ────────────────────────────────────────────────────────────
function checkVersion() {
fetch(GREASYFORK_META_URL)
.then(r => r.text())
.then(text => {
const m = text.match(/@version\s+([\d.]+)/);
if (m && semverGt(m[1], CURRENT_VERSION)) showUpdatePopup(m[1]);
})
.catch(() => {});
}
function showUpdatePopup(latest) {
if (document.getElementById('cs-update-popup')) return;
const d = document.createElement('div');
d.id = 'cs-update-popup';
d.style.cssText = 'position:fixed;inset:0;z-index:99999999;background:rgba(0,0,0,.72);display:flex;align-items:center;justify-content:center;font-family:system-ui,sans-serif;';
d.innerHTML = `<div style="background:#0e0e16;border-radius:16px;padding:26px 30px;max-width:360px;width:92%;color:#f0f0f5;border:1px solid rgba(232,144,10,.45);box-shadow:0 24px 60px rgba(0,0,0,.9);">
<div style="display:flex;align-items:center;gap:9px;margin-bottom:14px;"><div style="width:10px;height:10px;border-radius:999px;background:#e8900a;"></div><span style="font-size:15px;font-weight:700;">Update verfügbar</span></div>
<p style="font-size:13px;color:#b0b0c4;line-height:1.55;margin-bottom:18px;">CrunchySkip <strong style="color:#e8900a;">${latest}</strong> ist auf Greasyfork verfügbar.<br>Du nutzt aktuell <strong>v${CURRENT_VERSION}</strong>.</p>
<div style="display:flex;gap:8px;">
<button id="cs-upd-go" style="flex:1;padding:9px;border:none;border-radius:999px;background:#e8900a;color:#fff;font-size:13px;font-weight:600;cursor:pointer;">Jetzt updaten</button>
<button id="cs-upd-no" style="padding:9px 16px;border:none;border-radius:999px;background:#1e1e2a;color:#888;font-size:13px;cursor:pointer;">Später</button>
</div></div>`;
document.body.appendChild(d);
d.querySelector('#cs-upd-go').onclick = () => { window.open(GREASYFORK_PAGE_URL, '_blank'); d.remove(); };
d.querySelector('#cs-upd-no').onclick = () => d.remove();
}
// ─── I18N ─────────────────────────────────────────────────────────────────────
const LANG = {
de: {
langName:'Deutsch', proTitle:'CrunchySkip',
autoSkipIntro:'Intro automatisch überspringen',
autoSkipCredits:'Credits automatisch überspringen',
skipAfterCredits:'Nach Credits → nächste Folge',
forceFullscreen:'Vollbildmodus erzwingen',
autoFullscreen:'Auto Vollbild', uiLanguage:'UI-Sprache',
ok:'OK', footer:'Created by @Kriimaar', skipDelay:'Skip-Delay',
fsNone:'Aus', fsAlways:'Immer', fsVideoPlayerExit:'Nur nach VideoPlayer-Exit',
shortcutMenu:'Menü-Shortcut', shortcutSkip:'Skip-Shortcut',
exportSettings:'Exportieren', importSettings:'Importieren',
},
en: {
langName:'English', proTitle:'CrunchySkip',
autoSkipIntro:'Auto Skip Intro', autoSkipCredits:'Auto Skip Credits',
skipAfterCredits:'After Credits → Next Episode', forceFullscreen:'Force Fullscreen',
autoFullscreen:'Auto Fullscreen', uiLanguage:'UI Language',
ok:'OK', footer:'Created by @Kriimaar', skipDelay:'Skip Delay',
fsNone:'None', fsAlways:'Always', fsVideoPlayerExit:'VideoPlayer Exit Only',
shortcutMenu:'Menu Shortcut', shortcutSkip:'Skip Shortcut',
exportSettings:'Export', importSettings:'Import',
},
};
const LANG_KEYS = Object.keys(LANG);
// ─── STATE ────────────────────────────────────────────────────────────────────
const state = {
uiLang: GM_getValue('cr_uiLang', 'de'),
autoSkipIntro: GM_getValue('cr_autoSkipIntro', false),
autoSkipOutro: GM_getValue('cr_autoSkipOutro', false),
autoSkipAfterCredits: GM_getValue('cr_autoSkipAfterCredits', false),
forceFullscreen: GM_getValue('cr_forceFullscreen', false),
skipDelaySec: GM_getValue('cr_skipDelaySec', 0),
autoFullscreenMode: GM_getValue('cr_autoFullscreenMode', 'none'),
shortcutMenu: GM_getValue('cr_shortcutMenu', 'Alt+S'),
shortcutSkip: GM_getValue('cr_shortcutSkip', 'Alt+X'),
onboardingDone: GM_getValue('cr_onboardingDone', false),
};
const t = k => LANG[state.uiLang]?.[k] || LANG.de[k] || k;
const save = (k, v) => { state[k] = v; GM_setValue('cr_' + k, v); log('save', k, '=', v); };
// ─── SELECTOR FALLBACK-CHAIN ──────────────────────────────────────────────────
const _selHit = {};
function qs(selectors, ctx = document) {
for (const sel of selectors) {
try {
const el = ctx.querySelector(sel);
if (el) {
if (!_selHit[sel]) { log('[selector hit]', sel); _selHit[sel] = true; }
return el;
}
} catch (e) { log('[selector error]', sel, e.message); }
}
return null;
}
// ─── PROFILE KEYS ─────────────────────────────────────────────────────────────
const PROFILE_KEYS = ['autoSkipIntro','autoSkipOutro','autoSkipAfterCredits','forceFullscreen','skipDelaySec','autoFullscreenMode','shortcutMenu','shortcutSkip'];
// ─── PROFILES ─────────────────────────────────────────────────────────────────
function saveProfile(name) {
if (!name) return;
const p = GM_getValue('cr_profiles', {});
p[name] = {};
PROFILE_KEYS.forEach(k => p[name][k] = state[k]);
GM_setValue('cr_profiles', p);
log('Profil gespeichert:', name);
renderProfileList();
}
function loadProfile(name) {
const p = GM_getValue('cr_profiles', {});
if (!p[name]) return;
PROFILE_KEYS.forEach(k => { if (p[name][k] !== undefined) save(k, p[name][k]); });
log('Profil geladen:', name);
updateMenu();
}
function deleteProfile(name) {
const p = GM_getValue('cr_profiles', {});
delete p[name];
GM_setValue('cr_profiles', p);
renderProfileList();
log('Profil gelöscht:', name);
}
// ─── EXPORT / IMPORT ──────────────────────────────────────────────────────────
function exportSettings() {
const data = { uiLang: state.uiLang, profiles: GM_getValue('cr_profiles', {}) };
PROFILE_KEYS.forEach(k => data[k] = state[k]);
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }));
a.download = 'CrunchySkip-settings.json';
document.body.appendChild(a); a.click(); a.remove();
log('Einstellungen exportiert');
}
function importSettings(file) {
const reader = new FileReader();
reader.onload = e => {
try {
const data = JSON.parse(e.target.result);
PROFILE_KEYS.forEach(k => { if (data[k] !== undefined) save(k, data[k]); });
if (data.uiLang) save('uiLang', data.uiLang);
if (data.profiles) GM_setValue('cr_profiles', data.profiles);
log('Einstellungen importiert');
updateMenu();
} catch (_) { log('Import fehlgeschlagen: ungültiges JSON'); }
};
reader.readAsText(file);
}
// ─── SKIP STATE ───────────────────────────────────────────────────────────────
// Alle Timeouts und Flags pro Episode hier zentralisiert.
let episodeId = null;
let creditsSkippedThisEpisode = false; // true nachdem Credits geskippt wurden → After-Credits-Trigger aktiv
let afterCreditsDone = false; // verhindert mehrfaches Next-Episode in einer Folge
let pendingSkipTimer = null; // aktiver Delay-Timer
// Cooldown: verhindert dass checkSkip innerhalb von N ms nochmal skipped
// Wird erst NACH dem Klick gesetzt.
const SKIP_COOLDOWN_MS = 7000;
let lastSkipAt = 0;
function skipOnCooldown() { return Date.now() - lastSkipAt < SKIP_COOLDOWN_MS; }
function markSkipped() { lastSkipAt = Date.now(); log('markSkipped, Cooldown bis', new Date(lastSkipAt + SKIP_COOLDOWN_MS).toLocaleTimeString()); }
function resetEpisodeState() {
log('Episode reset');
creditsSkippedThisEpisode = false;
afterCreditsDone = false;
lastSkipAt = 0;
if (pendingSkipTimer !== null) { clearTimeout(pendingSkipTimer); pendingSkipTimer = null; }
}
function checkEpisodeChange() {
const id = location.pathname;
if (id !== episodeId) {
log('Episode gewechselt:', episodeId, '=>', id);
episodeId = id;
resetEpisodeState();
}
}
// ─── FULLSCREEN ───────────────────────────────────────────────────────────────
let guiBtn = null;
let menu = null;
let scriptRequestedFullscreen = false;
let userExitedFullscreen = false;
let lastFsExit = 0;
let fsArmedUntil = 0;
const isFullscreen = () => !!(document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement);
const getFullscreenRoot = () => document.fullscreenElement || document.body;
const disarmFs = () => { fsArmedUntil = 0; };
function shouldAutoEnterFs() {
if (!state.forceFullscreen || isFullscreen()) return false;
if (state.autoFullscreenMode === 'none') return false;
if (state.autoFullscreenMode === 'always') return true;
if (state.autoFullscreenMode === 'userexit') return userExitedFullscreen && (Date.now() - lastFsExit < 5000);
return false;
}
function getPlayerRoot() {
return qs([
'[data-testid="player-controls-root"]',
'[data-testid="vilos-player"]',
'#player-container',
'.bitmovin-player',
]) || document.querySelector('video')?.closest('div') || null;
}
function enterFullscreenViaButton() {
if (isFullscreen()) return true;
const btn = qs([
'[data-testid="fullscreen-button"]',
'[data-testid="vilos-fullscreen_button"]',
'[aria-label*="ollbild"]',
'[aria-label*="ullscreen"]',
]);
if (!btn) { log('Fullscreen-Button nicht gefunden'); return false; }
scriptRequestedFullscreen = true;
btn.click();
return true;
}
document.addEventListener('fullscreenchange', () => {
if (isFullscreen()) {
scriptRequestedFullscreen = false;
ensureMenuInCorrectRoot();
ensureGuiButtonInCorrectRoot();
return;
}
lastFsExit = Date.now();
userExitedFullscreen = !scriptRequestedFullscreen;
scriptRequestedFullscreen = false;
});
document.addEventListener('pointerdown', e => {
const pr = getPlayerRoot();
if (!pr || !pr.contains(e.target)) return;
try { pr.focus?.(); } catch (_) {}
if (!state.forceFullscreen || isFullscreen()) return;
if (state.autoFullscreenMode === 'always') { enterFullscreenViaButton(); return; }
if (Date.now() < fsArmedUntil) { disarmFs(); enterFullscreenViaButton(); }
}, true);
// ─── KEYBOARD SHORTCUTS ───────────────────────────────────────────────────────
function parseShortcut(str) {
if (!str) return null;
const parts = str.trim().toUpperCase().split('+');
const key = parts[parts.length - 1];
if (!key) return null;
return { ctrl: parts.includes('CTRL') || parts.includes('CONTROL'), alt: parts.includes('ALT'), shift: parts.includes('SHIFT'), key };
}
function matchesShortcut(e, str) {
const sc = parseShortcut(str);
if (!sc) return false;
return e.ctrlKey === sc.ctrl && e.altKey === sc.alt && e.shiftKey === sc.shift && e.key.toUpperCase() === sc.key;
}
document.addEventListener('keydown', e => {
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
if (matchesShortcut(e, state.shortcutMenu)) {
e.preventDefault();
if (!menu) return;
menu.style.display === 'block' ? hideMenu() : showMenu();
return;
}
if (matchesShortcut(e, state.shortcutSkip)) {
e.preventDefault();
const btn = findSkipButton();
if (btn) { btn.click(); markSkipped(); log('Manuell geskippt via Shortcut'); }
else log('Shortcut: kein Skip-Button gefunden');
}
});
// ─── SPA NAVIGATION ───────────────────────────────────────────────────────────
(function () {
const _push = history.pushState.bind(history);
const _replace = history.replaceState.bind(history);
history.pushState = (...a) => { _push(...a); window.dispatchEvent(new Event('cr:navigate')); };
history.replaceState = (...a) => { _replace(...a); window.dispatchEvent(new Event('cr:navigate')); };
})();
window.addEventListener('popstate', () => window.dispatchEvent(new Event('cr:navigate')));
window.addEventListener('cr:navigate', () => {
log('SPA navigate:', location.pathname);
checkEpisodeChange();
setTimeout(() => {
const old = document.getElementById('cs-btn');
if (old) old.remove();
guiBtn = null;
ensureGuiButton();
}, 800);
});
// ─── NEXT EPISODE ─────────────────────────────────────────────────────────────
function triggerNextEpisode() {
// Guard: nur einmal pro Episode
if (afterCreditsDone) return;
if (skipOnCooldown()) return;
afterCreditsDone = true;
markSkipped();
const btn = qs([
'[data-testid="next-episode-button"]',
'[data-testid="vilos-next_button"]',
'[data-testid="vilos-nextepisode_button"]',
'[aria-label*="Next Episode"]',
'[aria-label*="Nächste Episode"]',
]);
if (btn) {
log('After-Credits: Next-Episode-Button geklickt');
btn.click();
return;
}
log('After-Credits: Next-Episode-Button nicht gefunden, Shift+N Fallback');
document.dispatchEvent(new KeyboardEvent('keydown', {
key:'N', code:'KeyN', keyCode:78, which:78, shiftKey:true, bubbles:true,
}));
}
// ─── SKIP BUTTON ERKENNUNG ────────────────────────────────────────────────────
// Crunchyroll blendet den Skip-Button via opacity ein/aus wenn die Maus über den
// Player geht. Der Button ist trotzdem im DOM klickbar. Wir prüfen daher NUR
// display:none und disabled — NICHT opacity/visibility.
function isButtonPresent(btn) {
if (!btn || btn.disabled) return false;
if (window.getComputedStyle(btn).display === 'none') return false;
const r = btn.getBoundingClientRect();
return r.width >= 4 && r.height >= 4;
}
const SKIP_BTN_SELECTORS = [
'button[data-testid="skip-intro-button"]',
'button[data-testid="skip-ending-button"]',
'button[data-testid="skip-outro-button"]',
'button[data-testid="skipButton"]',
'[data-testid="skip-intro-icon"]',
'[data-testid="skip-ending-icon"]',
'[data-testid="skip-outro-icon"]',
];
const SKIP_ARIA_PATTERNS = [
/skip\s*(opening|intro)/i,
/skip\s*(ending|credits?|outro)/i,
/(opening|intro)\s*überspringen/i,
/(ending|credits?|outro|abspann)\s*überspringen/i,
/^skip$/i,
/^überspringen$/i,
];
function findSkipButton() {
// Methode 1: bekannte testids
for (const sel of SKIP_BTN_SELECTORS) {
let el;
try { el = document.querySelector(sel); } catch (_) { continue; }
if (!el) continue;
const btn = el.tagName === 'BUTTON' ? el : el.closest('button');
if (btn && isButtonPresent(btn)) {
if (!_selHit['skip:' + sel]) { log('[skip selector hit]', sel); _selHit['skip:' + sel] = true; }
return btn;
}
}
// Methode 2: aria-label / textContent Whitelist
for (const btn of document.querySelectorAll('button')) {
const label = (btn.getAttribute('aria-label') || btn.textContent || '').trim();
if (!SKIP_ARIA_PATTERNS.some(p => p.test(label))) continue;
if (!isButtonPresent(btn)) continue;
if (!_selHit['skip:aria:' + label]) { log('[skip aria hit]', JSON.stringify(label)); _selHit['skip:aria:' + label] = true; }
return btn;
}
return null;
}
function getSkipType(btn) {
const testids = [...btn.querySelectorAll('[data-testid]')].map(el => el.getAttribute('data-testid').toLowerCase());
const ownTestid = (btn.getAttribute('data-testid') || '').toLowerCase();
const label = (btn.getAttribute('aria-label') || '').toLowerCase();
const text = (btn.textContent || '').toLowerCase();
const combined = [ownTestid, ...testids, label, text].join(' ');
if (/ending|outro|credits?|abspann/.test(combined)) return 'outro';
if (/intro|opening/.test(combined)) return 'intro';
// Zeitposition als Fallback
const video = document.querySelector('video');
if (video && video.duration > 0) {
const ratio = video.currentTime / video.duration;
log('getSkipType Fallback via Zeitposition, ratio:', ratio.toFixed(2));
return ratio > 0.45 ? 'outro' : 'intro';
}
log('getSkipType unbekannt, defaulte auf intro');
return 'intro';
}
// ─── SKIP AUSFÜHRUNG ──────────────────────────────────────────────────────────
// markSkipped() wird immer NACH dem Klick gesetzt.
// pendingSkipTimer guard verhindert mehrfaches Queuen.
function scheduleSkip(clickFn) {
if (pendingSkipTimer !== null) return;
const delayMs = Math.max(0, state.skipDelaySec || 0) * 1000;
if (!delayMs) {
clickFn();
markSkipped();
return;
}
pendingSkipTimer = setTimeout(() => {
pendingSkipTimer = null;
clickFn();
markSkipped();
}, delayMs);
}
// ─── HAUPT-SKIP-LOGIK ─────────────────────────────────────────────────────────
function checkSkip() {
checkEpisodeChange();
const video = document.querySelector('video');
if (!video || !video.duration || video.paused) return;
// Skip-Button Logik (Intro / Credits)
if (!skipOnCooldown() && pendingSkipTimer === null) {
const skipBtn = findSkipButton();
if (skipBtn) {
const type = getSkipType(skipBtn);
log('Skip-Button erkannt:', type);
if (type === 'intro' && state.autoSkipIntro) {
scheduleSkip(() => {
if (!isButtonPresent(skipBtn)) { log('Skip-Button vor Ausführung verschwunden'); return; }
skipBtn.click();
log('Intro geskippt');
});
} else if (type === 'outro' && state.autoSkipOutro) {
scheduleSkip(() => {
if (!isButtonPresent(skipBtn)) { log('Skip-Button vor Ausführung verschwunden'); return; }
skipBtn.click();
creditsSkippedThisEpisode = true;
log('Credits geskippt, After-Credits-Trigger aktiv');
});
}
}
}
// After-Credits → nächste Episode
// Bedingungen:
// 1. Toggle aktiviert
// 2. Credits wurden in dieser Episode geskippt
// 3. Noch nicht für diese Episode ausgelöst
// 4. Kein aktiver Cooldown / Delay-Timer
// 5. Video ist in den letzten 60 Sekunden und zu > 96% abgespielt
if (
state.autoSkipAfterCredits &&
creditsSkippedThisEpisode &&
!afterCreditsDone &&
!skipOnCooldown() &&
pendingSkipTimer === null
) {
const remaining = video.duration - video.currentTime;
const progress = video.currentTime / video.duration;
if (progress > 0.96 && remaining > 0.5 && remaining < 60) {
log('After-Credits Trigger: progress=' + progress.toFixed(3) + ' remaining=' + remaining.toFixed(1) + 's');
triggerNextEpisode();
}
}
}
// ─── ONBOARDING ───────────────────────────────────────────────────────────────
function showOnboarding() {
if (state.onboardingDone || document.getElementById('cs-onboarding')) return;
const d = document.createElement('div');
d.id = 'cs-onboarding';
d.style.cssText = 'position:fixed;inset:0;z-index:99999998;background:rgba(0,0,0,.78);display:flex;align-items:center;justify-content:center;font-family:system-ui,sans-serif;';
d.innerHTML = `
<div style="background:#0e0e16;border-radius:16px;padding:28px 32px;max-width:390px;width:92%;
color:#f0f0f5;border:1px solid rgba(158,7,255,.3);box-shadow:0 24px 60px rgba(0,0,0,.85);">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:18px;">
<div style="width:10px;height:10px;border-radius:999px;background:#00d489;"></div>
<span style="font-size:15px;font-weight:700;">CrunchySkip — Schnellstart</span>
</div>
<div style="display:flex;flex-direction:column;gap:11px;font-size:13px;color:#c0c0cc;line-height:1.55;">
<div><span style="color:#9e07ff;font-weight:600;">Intro / Credits überspringen</span><br>Toggles im CS-Menü aktivieren — wird beim nächsten Vorkommen automatisch geskippt.</div>
<div><span style="color:#9e07ff;font-weight:600;">Nach Credits → nächste Folge</span><br>Sobald Credits geskippt wurden und das Video fast endet, startet die nächste Episode automatisch.</div>
<div><span style="color:#9e07ff;font-weight:600;">Tastenkürzel</span><br>
<kbd style="background:#1a1a28;padding:1px 6px;border-radius:4px;font-size:11px;">Alt+S</kbd> öffnet das Menü ·
<kbd style="background:#1a1a28;padding:1px 6px;border-radius:4px;font-size:11px;">Alt+X</kbd> skippt manuell. Beide frei anpassbar.
</div>
<div><span style="color:#9e07ff;font-weight:600;">CS Button</span><br>Der lila CS-Button sitzt direkt links neben dem Vollbild-Button.</div>
</div>
<button id="cs-ob-ok" style="margin-top:22px;width:100%;padding:10px;border:none;border-radius:999px;background:#9e07ff;color:#fff;font-size:13px;font-weight:600;cursor:pointer;">Verstanden, loslegen!</button>
</div>`;
document.body.appendChild(d);
d.querySelector('#cs-ob-ok').onclick = () => { d.remove(); save('onboardingDone', true); };
}
// ─── MENU HELPERS ─────────────────────────────────────────────────────────────
function setSwitch(row, on) {
const p = row?.querySelector('.cs-switch');
if (!p) return;
p.textContent = on ? 'AN' : 'AUS';
p.style.background = on ? 'rgba(0,176,120,.9)' : 'rgba(90,90,102,.95)';
}
function toggleFlag(key, rowId) {
save(key, !state[key]);
setSwitch(menu?.querySelector('#' + rowId), state[key]);
log(key, '=', state[key]);
if (key === 'forceFullscreen') {
if (state.forceFullscreen && !isFullscreen()) enterFullscreenViaButton();
else disarmFs();
updateMenu();
}
}
function makeToggleRow(id, label, on) {
return `<div id="${id}" style="padding:7px 8px;border-radius:10px;background:#101019;display:flex;
justify-content:space-between;align-items:center;cursor:pointer;margin-top:4px;user-select:none;">
<span class="cs-label" style="font-size:12px;">${label}</span>
<span class="cs-switch" style="min-width:32px;text-align:center;font-size:11px;padding:2px 7px;
border-radius:999px;background:${on ? 'rgba(0,176,120,.9)' : 'rgba(90,90,102,.95)'};">
${on ? 'AN' : 'AUS'}</span></div>`;
}
function makeSection(title, content) {
return `<div style="border-top:1px solid rgba(255,255,255,.06);margin-top:7px;padding-top:8px;">
<div style="font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#5a5a6a;margin-bottom:5px;">${title}</div>
${content}</div>`;
}
function renderProfileList() {
const c = document.getElementById('cs-profile-list');
if (!c) return;
const profiles = GM_getValue('cr_profiles', {});
const names = Object.keys(profiles);
c.innerHTML = '';
if (!names.length) {
c.innerHTML = '<span style="color:#5a5a6a;font-size:11px;">Keine Profile gespeichert</span>';
return;
}
names.forEach(name => {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:5px;margin-bottom:3px;';
row.innerHTML = `
<span style="font-size:11px;color:#c8c8d8;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${name}">${name}</span>
<button data-load style="font-size:10px;background:#9e07ff22;color:#c078ff;border:none;border-radius:4px;padding:2px 7px;cursor:pointer;">Laden</button>
<button data-del style="font-size:10px;background:#ff073322;color:#ff7070;border:none;border-radius:4px;padding:2px 7px;cursor:pointer;">✕</button>`;
row.querySelector('[data-load]').onclick = e => { e.stopPropagation(); loadProfile(name); };
row.querySelector('[data-del]').onclick = e => { e.stopPropagation(); deleteProfile(name); };
c.appendChild(row);
});
}
function updateMenu() {
if (!menu) return;
const $ = id => menu.querySelector('#' + id);
if ($('cs-title')) $('cs-title').textContent = t('proTitle');
if ($('cs-footer')) $('cs-footer').textContent = t('footer');
if ($('cs-ok')) $('cs-ok').textContent = t('ok');
[
['cs-intro', 'autoSkipIntro', 'autoSkipIntro'],
['cs-outro', 'autoSkipOutro', 'autoSkipCredits'],
['cs-after', 'autoSkipAfterCredits', 'skipAfterCredits'],
['cs-forcefs', 'forceFullscreen', 'forceFullscreen'],
].forEach(([id, sk, lk]) => {
const row = $(id);
if (!row) return;
row.querySelector('.cs-label').textContent = t(lk);
setSwitch(row, state[sk]);
});
if ($('cs-delay-label')) $('cs-delay-label').textContent = state.skipDelaySec.toFixed(1) + ' s';
if ($('cs-delay-range')) $('cs-delay-range').value = state.skipDelaySec;
if ($('cs-delay-input')) $('cs-delay-input').value = state.skipDelaySec;
if ($('cs-lang-current')) $('cs-lang-current').textContent = `${t('uiLanguage')}: ${LANG[state.uiLang]?.langName}`;
if ($('cs-shortcut-menu')) $('cs-shortcut-menu').value = state.shortcutMenu;
if ($('cs-shortcut-skip')) $('cs-shortcut-skip').value = state.shortcutSkip;
const autofsRow = $('cs-autofs'), afSel = $('cs-autofs-select');
if (autofsRow) autofsRow.style.display = state.forceFullscreen ? 'flex' : 'none';
if (afSel) {
afSel.value = state.autoFullscreenMode;
const opts = afSel.querySelectorAll('option');
if (opts[0]) opts[0].textContent = t('fsNone');
if (opts[1]) opts[1].textContent = t('fsAlways');
if (opts[2]) opts[2].textContent = t('fsVideoPlayerExit');
}
renderProfileList();
}
// ─── MENU BUILD ───────────────────────────────────────────────────────────────
function buildMenu() {
if (document.getElementById('cs-menu')) { menu = document.getElementById('cs-menu'); updateMenu(); return menu; }
menu = document.createElement('div');
menu.id = 'cs-menu';
menu.style.cssText = 'position:fixed;display:none;opacity:0;transition:opacity .15s ease;z-index:9999999;pointer-events:auto;';
menu.innerHTML = `
<div style="background:#08090d;border-radius:14px;padding:11px 13px 9px;min-width:282px;max-width:314px;
box-shadow:0 20px 48px rgba(0,0,0,.92);font-family:system-ui,sans-serif;font-size:13px;color:#f4f4f8;
border:1px solid rgba(255,255,255,.07);max-height:88vh;overflow-y:auto;scrollbar-width:thin;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:9px;">
<div style="display:flex;align-items:center;gap:6px;">
<div style="width:8px;height:8px;border-radius:999px;background:#00d489;flex-shrink:0;"></div>
<span id="cs-title" style="font-weight:700;font-size:14px;">${t('proTitle')}</span>
<span style="font-size:9px;color:#5a5a6a;background:#14141e;padding:1px 5px;border-radius:4px;">v${CURRENT_VERSION}</span>
</div>
<div style="position:relative;">
<div id="cs-lang-current" style="font-size:11px;color:#c8c8d8;cursor:pointer;padding:3px 8px;
border-radius:999px;background:#111119;border:1px solid rgba(158,7,255,.55);white-space:nowrap;">
${t('uiLanguage')}: ${LANG[state.uiLang]?.langName}
</div>
<div id="cs-lang-list" style="display:none;position:absolute;top:24px;right:0;background:#0a0a14;
border-radius:8px;padding:4px;min-width:130px;border:1px solid rgba(158,7,255,.55);
box-shadow:0 14px 28px rgba(0,0,0,.92);z-index:2;"></div>
</div>
</div>
${makeToggleRow('cs-intro', t('autoSkipIntro'), state.autoSkipIntro)}
${makeToggleRow('cs-outro', t('autoSkipCredits'), state.autoSkipOutro)}
${makeToggleRow('cs-after', t('skipAfterCredits'), state.autoSkipAfterCredits)}
${makeToggleRow('cs-forcefs', t('forceFullscreen'), state.forceFullscreen)}
<div id="cs-autofs" style="padding:7px 8px;border-radius:10px;background:#101019;
justify-content:space-between;align-items:center;gap:8px;margin-top:4px;
display:${state.forceFullscreen ? 'flex' : 'none'};">
<span class="cs-label" style="font-size:12px;">${t('autoFullscreen')}</span>
<select id="cs-autofs-select" style="background:#0d0d17;color:#f4f4f8;
border:1px solid rgba(255,255,255,.11);border-radius:6px;padding:2px 8px;font-size:11px;outline:none;">
<option value="none">${t('fsNone')}</option>
<option value="always">${t('fsAlways')}</option>
<option value="userexit">${t('fsVideoPlayerExit')}</option>
</select>
</div>
${makeSection('Skip-Delay', `
<div style="display:flex;justify-content:space-between;margin-bottom:4px;">
<span style="font-size:11px;color:#a0a0b4;">${t('skipDelay')}</span>
<span id="cs-delay-label" style="font-size:11px;color:#a0a0b4;">${state.skipDelaySec.toFixed(1)} s</span>
</div>
<div style="display:flex;align-items:center;gap:7px;">
<input id="cs-delay-range" type="range" min="0" max="10" step="0.5" value="${state.skipDelaySec}"
style="flex:1;appearance:none;-webkit-appearance:none;height:4px;border-radius:4px;background:#1c1c2a;outline:none;cursor:pointer;">
<input id="cs-delay-input" type="number" min="0" max="10" step="0.5" value="${state.skipDelaySec}"
style="width:50px;background:#111119;border:1px solid rgba(255,255,255,.11);
border-radius:6px;color:#f4f4f8;font-size:11px;padding:3px 5px;">
</div>`)}
${makeSection('Tastenkürzel', `
<div style="display:flex;flex-direction:column;gap:5px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:6px;">
<span style="font-size:11px;color:#a0a0b4;white-space:nowrap;">${t('shortcutMenu')}</span>
<input id="cs-shortcut-menu" type="text" value="${state.shortcutMenu}" placeholder="Alt+S"
style="width:88px;background:#111119;border:1px solid rgba(255,255,255,.11);
border-radius:6px;color:#f4f4f8;font-size:11px;padding:3px 6px;text-align:right;">
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:6px;">
<span style="font-size:11px;color:#a0a0b4;white-space:nowrap;">${t('shortcutSkip')}</span>
<input id="cs-shortcut-skip" type="text" value="${state.shortcutSkip}" placeholder="Alt+X"
style="width:88px;background:#111119;border:1px solid rgba(255,255,255,.11);
border-radius:6px;color:#f4f4f8;font-size:11px;padding:3px 6px;text-align:right;">
</div>
</div>`)}
${makeSection('Profile', `
<div style="display:flex;gap:5px;margin-bottom:6px;">
<input id="cs-profile-name" type="text" placeholder="Profilname"
style="flex:1;background:#111119;border:1px solid rgba(255,255,255,.11);
border-radius:6px;color:#f4f4f8;font-size:11px;padding:4px 8px;">
<button id="cs-profile-save" style="background:#9e07ff;color:#fff;border:none;
border-radius:6px;font-size:11px;padding:3px 11px;cursor:pointer;flex-shrink:0;">Speichern</button>
</div>
<div id="cs-profile-list" style="display:flex;flex-direction:column;gap:2px;"></div>`)}
${makeSection('Export / Import', `
<div style="display:flex;gap:6px;">
<button id="cs-export" style="flex:1;background:#111119;color:#c8c8d8;
border:1px solid rgba(255,255,255,.09);border-radius:7px;font-size:11px;
padding:6px 4px;cursor:pointer;">${t('exportSettings')}</button>
<label style="flex:1;display:flex;align-items:center;justify-content:center;
background:#111119;color:#c8c8d8;border:1px solid rgba(255,255,255,.09);
border-radius:7px;font-size:11px;padding:6px 4px;cursor:pointer;">
${t('importSettings')}
<input id="cs-import-input" type="file" accept=".json" style="display:none;">
</label>
</div>`)}
<div style="display:flex;justify-content:space-between;align-items:center;
margin-top:9px;padding-top:7px;border-top:1px solid rgba(255,255,255,.05);">
<span id="cs-footer" style="font-size:10px;color:#44445a;">${t('footer')}</span>
<button id="cs-ok" style="cursor:pointer;padding:4px 15px;border-radius:999px;border:none;
font-size:12px;font-weight:600;background:#9e07ff;color:#fff;">${t('ok')}</button>
</div>
</div>`;
[['cs-intro','autoSkipIntro'],['cs-outro','autoSkipOutro'],['cs-after','autoSkipAfterCredits'],['cs-forcefs','forceFullscreen']]
.forEach(([id, key]) => menu.querySelector('#'+id)?.addEventListener('click', e => { e.stopPropagation(); toggleFlag(key, id); }));
menu.querySelector('#cs-ok').onclick = e => { e.stopPropagation(); hideMenu(); };
const afSel = menu.querySelector('#cs-autofs-select');
afSel.value = state.autoFullscreenMode;
afSel.onchange = e => { save('autoFullscreenMode', e.target.value); updateMenu(); };
const langCur = menu.querySelector('#cs-lang-current');
const langList = menu.querySelector('#cs-lang-list');
langCur.onclick = e => {
e.stopPropagation();
if (!langList.dataset.built) {
LANG_KEYS.forEach(key => {
const div = document.createElement('div');
div.textContent = LANG[key].langName;
div.style.cssText = 'padding:5px 9px;border-radius:6px;font-size:12px;cursor:pointer;color:#f3f3f7;';
div.onmouseenter = () => div.style.background = 'rgba(158,7,255,.2)';
div.onmouseleave = () => div.style.background = 'transparent';
div.onclick = ev => { ev.stopPropagation(); save('uiLang', key); langList.style.display = 'none'; updateMenu(); };
langList.appendChild(div);
});
langList.dataset.built = '1';
}
langList.style.display = langList.style.display === 'block' ? 'none' : 'block';
};
const dRange = menu.querySelector('#cs-delay-range');
const dInput = menu.querySelector('#cs-delay-input');
const dLabel = menu.querySelector('#cs-delay-label');
const applyDelay = v => {
v = isNaN(v) ? 0 : Math.max(0, Math.min(10, Math.round(parseFloat(v) * 10) / 10));
save('skipDelaySec', v);
if (dRange) dRange.value = v;
if (dInput) dInput.value = v;
if (dLabel) dLabel.textContent = v.toFixed(1) + ' s';
};
dRange.oninput = e => applyDelay(e.target.value);
dInput.onblur = () => applyDelay(dInput.value);
dInput.onkeydown = e => { if (e.key === 'Enter') dInput.blur(); };
const bindShortcut = (inputId, stateKey) => {
const inp = menu.querySelector('#' + inputId);
inp.onblur = () => save(stateKey, inp.value.trim());
inp.onkeydown = e => { if (e.key === 'Enter') inp.blur(); };
};
bindShortcut('cs-shortcut-menu', 'shortcutMenu');
bindShortcut('cs-shortcut-skip', 'shortcutSkip');
menu.querySelector('#cs-profile-save').onclick = e => {
e.stopPropagation();
const inp = menu.querySelector('#cs-profile-name');
const name = inp?.value.trim();
if (name) { saveProfile(name); inp.value = ''; }
else log('Profilname leer');
};
menu.querySelector('#cs-export').onclick = e => { e.stopPropagation(); exportSettings(); };
const impInput = menu.querySelector('#cs-import-input');
impInput.onchange = e => {
const f = e.target.files[0];
if (f) { importSettings(f); impInput.value = ''; }
};
updateMenu();
return menu;
}
// ─── MENU POSITIONING ─────────────────────────────────────────────────────────
function ensureMenuInCorrectRoot() {
if (!menu) return;
const root = getFullscreenRoot();
if (menu.parentElement !== root) root.appendChild(menu);
}
function positionMenu() {
if (!guiBtn || !menu || menu.style.display !== 'block') return;
const btnR = guiBtn.getBoundingClientRect();
const boxR = menu.firstElementChild.getBoundingClientRect();
const m = 10;
let top = btnR.top - boxR.height - m;
let left = btnR.left + btnR.width / 2 - boxR.width / 2;
if (top < m) top = btnR.bottom + m;
left = Math.max(m, Math.min(left, window.innerWidth - boxR.width - m));
menu.style.top = top + 'px';
menu.style.left = left + 'px';
}
function showMenu() {
if (!menu) return;
ensureMenuInCorrectRoot();
menu.style.display = 'block';
requestAnimationFrame(() => { menu.style.opacity = '1'; positionMenu(); });
renderProfileList();
document.addEventListener('click', outsideClickHandler);
}
function hideMenu() {
if (!menu) return;
menu.style.opacity = '0';
setTimeout(() => { if (menu) menu.style.display = 'none'; }, 160);
document.removeEventListener('click', outsideClickHandler);
}
function outsideClickHandler(e) {
if (!menu || menu.contains(e.target) || guiBtn?.contains(e.target)) return;
hideMenu();
}
// ─── GUI BUTTON ───────────────────────────────────────────────────────────────
function ensureGuiButtonInCorrectRoot() {
if (!guiBtn) return;
const fsRoot = getFullscreenRoot();
if (guiBtn.parentElement !== fsRoot) fsRoot.appendChild(guiBtn);
}
function ensureGuiButton() {
if (document.getElementById('cs-btn')) return;
const fsBtn = qs([
'[data-testid="fullscreen-button"]',
'[data-testid="vilos-fullscreen_button"]',
'[aria-label*="ollbild"]',
'[aria-label*="ullscreen"]',
]);
if (!fsBtn) return;
guiBtn = document.createElement('button');
guiBtn.id = 'cs-btn';
guiBtn.title = `CrunchySkip Menü (${state.shortcutMenu})`;
guiBtn.innerHTML = `
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" style="flex-shrink:0;">
<rect y="2" width="14" height="1.8" rx="0.9" fill="currentColor"/>
<rect y="6.1" width="14" height="1.8" rx="0.9" fill="currentColor"/>
<rect y="10.2" width="14" height="1.8" rx="0.9" fill="currentColor"/>
</svg>
<span style="font-size:11px;font-weight:700;letter-spacing:.03em;">CS</span>`;
guiBtn.style.cssText = `
display:inline-flex;align-items:center;gap:4px;padding:0 10px;height:32px;
border-radius:6px;border:none;cursor:pointer;
background:rgba(158,7,255,.22);color:#d89eff;
font-family:system-ui,sans-serif;
transition:background .15s ease,color .15s ease;`;
guiBtn.onmouseenter = () => { guiBtn.style.background = 'rgba(158,7,255,.42)'; guiBtn.style.color = '#fff'; };
guiBtn.onmouseleave = () => { guiBtn.style.background = 'rgba(158,7,255,.22)'; guiBtn.style.color = '#d89eff'; };
guiBtn.onclick = e => { e.stopPropagation(); menu.style.display === 'block' ? hideMenu() : showMenu(); };
buildMenu();
fsBtn.parentElement?.insertBefore(guiBtn, fsBtn);
const fsRoot = getFullscreenRoot();
if (menu.parentElement !== fsRoot) fsRoot.appendChild(menu);
}
// ─── MUTATIONOBSERVER (debounced) ─────────────────────────────────────────────
let obDebounce = null;
const observer = new MutationObserver(() => {
clearTimeout(obDebounce);
obDebounce = setTimeout(() => {
ensureGuiButton();
if (shouldAutoEnterFs()) enterFullscreenViaButton();
}, 50);
});
// ─── INIT ─────────────────────────────────────────────────────────────────────
function init() {
log(`geladen v${CURRENT_VERSION}`);
checkVersion();
observer.observe(document.body, { childList: true, subtree: true });
setInterval(checkSkip, 800);
setTimeout(() => { ensureGuiButton(); showOnboarding(); }, 1500);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();