CrunchySkip

Skip Intros, Credits, After Credits & Advanced Fullscreen on Crunchyroll

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==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ü &nbsp;·&nbsp;
            <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();

})();