MagentaSport Player Keyboard Shortcuts

Add NBA-style shortcuts (J/L/U/O/Space + S/D) to the MagentaSport player. Works with Mozilla Firefox.

// ==UserScript==
// @name         MagentaSport Player Keyboard Shortcuts
// @namespace    https://github.com/dusanvin/MagentaSport-Videoplayer-Shortcuts
// @version      1.0
// @description  Add NBA-style shortcuts (J/L/U/O/Space + S/D) to the MagentaSport player. Works with Mozilla Firefox.
// @author       Vincent Dusanek
// @license      CC-BY-NC-SA-4.0
// @match        https://www.magentasport.de/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  // Anpassbare Schrittweiten (Sekunden)
  const STEP_SMALL = 10;   // J / L
  const STEP_LARGE = 60;   // U / O

  // Diskrete Geschwindigkeitsstufen (min 0.1x, max 16x)
  const RATE_STEPS = [0.1, 0.25, 0.5, 1, 2, 4, 8, 16];

  // Kleines Overlay für Feedback
  let toast;
  function ensureToast() {
    if (toast) return toast;
    toast = document.createElement('div');
    toast.id = 'ms-shortcuts-toast';
    Object.assign(toast.style, {
      position: 'fixed',
      left: '50%',
      bottom: '12%',
      transform: 'translateX(-50%)',
      padding: '8px 12px',
      borderRadius: '10px',
      background: 'rgba(0,0,0,0.65)',
      color: '#fff',
      fontSize: '14px',
      fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
      zIndex: 999999,
      opacity: '0',
      transition: 'opacity .15s ease',
      pointerEvents: 'none',
      userSelect: 'none'
    });
    document.documentElement.appendChild(toast);
    return toast;
  }
  let toastTimer;
  function showToast(text) {
    const el = ensureToast();
    el.textContent = text;
    el.style.opacity = '1';
    clearTimeout(toastTimer);
    toastTimer = setTimeout(() => (el.style.opacity = '0'), 650);
  }

  // Aktives Video finden (robust bei SPA/Reload des Players)
  function findActiveVideo() {
    // Bevorzugt das bekannte Magenta-Video
    const byId = document.querySelector('#sravvpl_video-element--0');
    if (byId && isUsableVideo(byId)) return byId;

    // Fallback: sichtbares, initialisiertes <video>
    const vids = Array.from(document.querySelectorAll('video'));
    const candidates = vids
      .filter(isUsableVideo)
      .sort((a, b) => (scoreVideo(b) - scoreVideo(a))); // priorisieren

    return candidates[0] || null;
  }
  function isUsableVideo(v) {
    if (!(v instanceof HTMLVideoElement)) return false;
    const rect = v.getBoundingClientRect();
    const visible = rect.width > 0 && rect.height > 0;
    return visible;
  }
  function scoreVideo(v) {
    let s = 0;
    if (v.currentSrc || v.src) s += 2;
    if (isFinite(v.duration) && v.duration > 0) s += 2;
    if (!v.paused) s += 1;
    return s;
  }

  // Zielzeit in erlaubten Bereich klemmen (auch für Live/DVR)
  function clampTime(video, target) {
    try {
      const seekable = video.seekable;
      if (seekable && seekable.length) {
        const start = seekable.start(0);
        const end = seekable.end(seekable.length - 1);
        const epsilon = 0.25; // kleine Marge am Ende
        return Math.min(Math.max(target, start), end - epsilon);
      }
    } catch (e) {}
    if (isFinite(video.duration) && video.duration > 0) {
      const epsilon = 0.25;
      return Math.min(Math.max(target, 0), video.duration - epsilon);
    }
    return Math.max(target, 0);
  }

  function seekBy(video, delta) {
    const target = clampTime(video, (video.currentTime || 0) + delta);
    video.currentTime = target;
    const sign = delta > 0 ? '+' : '–';
    const secs = Math.abs(delta);
    showToast(`${sign}${secs}s`);
  }

  function togglePlay(video) {
    if (video.paused) {
      const p = video.play();
      if (p && typeof p.catch === 'function') p.catch(() => {});
      showToast('▶︎');
    } else {
      video.pause();
      showToast('⏸');
    }
  }

  // Geschwindigkeit anpassen (dir: +1 schneller, -1 langsamer)
  function bumpRate(video, dir) {
    const cur = video.playbackRate || 1;
    const eps = 1e-6;
    let next = cur;

    if (dir > 0) {
      // nächstgrößere Stufe finden, sonst Maximum
      next = RATE_STEPS.find(r => r > cur + eps) ?? RATE_STEPS[RATE_STEPS.length - 1];
    } else {
      // nächstkleinere Stufe finden, sonst Minimum
      for (let i = RATE_STEPS.length - 1; i >= 0; i--) {
        if (RATE_STEPS[i] < cur - eps) {
          next = RATE_STEPS[i];
          break;
        }
      }
      if (next === cur) next = RATE_STEPS[0];
    }

    video.playbackRate = next;
    showToast(`${formatRate(next)}×`);
  }

  function formatRate(r) {
    // hübsche Ausgabe, max. 2 Nachkommastellen, ohne überflüssige Nullen
    const s = (Math.round(r * 100) / 100).toString();
    return s.replace(/(\.\d*[1-9])0+$|\.0+$/, '$1');
  }

  // Nur auslösen, wenn nicht gerade in einem Eingabefeld getippt wird
  function isTypingInField(e) {
    const t = e.target;
    return (
      t &&
      (t.isContentEditable ||
        /^(INPUT|TEXTAREA|SELECT)$/i.test(t.tagName))
    );
  }

  // Einmal registrieren – auch bei SPA-Navigation ausreichend
  window.addEventListener('keydown', (e) => {
    if (isTypingInField(e)) return;

    const key = e.key.toLowerCase();
    if (!['j', 'l', 'u', 'o', ' ', 's', 'd'].includes(key)) return;

    const video = findActiveVideo();
    if (!video) return;

    // optional: Wiederholungen (Taste gehalten) ignorieren
    if (e.repeat) return;

    if (key === ' ') {
      e.preventDefault(); // Scrollen der Seite verhindern
      togglePlay(video);
      return;
    }

    switch (key) {
      case 'j':
        seekBy(video, -STEP_SMALL);
        break;
      case 'l':
        seekBy(video, +STEP_SMALL);
        break;
      case 'u':
        seekBy(video, -STEP_LARGE);
        break;
      case 'o':
        seekBy(video, +STEP_LARGE);
        break;
      case 's': // langsamer
        bumpRate(video, -1);
        break;
      case 'd': // schneller
        bumpRate(video, +1);
        break;
    }
  });

  // Bonus: Reagiere auf Player-Wechsel (SPA) und räume altes Toast auf
  const mo = new MutationObserver(() => {
    if (toast && !document.documentElement.contains(toast)) {
      toast = null;
      ensureToast();
    }
  });
  mo.observe(document.documentElement, { childList: true, subtree: true });
})();