MediaWiki:Gadget-floatingYouTubePlayer.js

From Angelina Jordan Wiki
Revision as of 19:28, 28 September 2025 by Most2dot0 (talk | contribs) (another rewrite by ChatGPT)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
(function () {
  'use strict';
  if (window.CoreYouTubePersistent) return;
  window.CoreYouTubePersistent = true;

  // IDs and config
  var CORE_CONTAINER_ID = 'core-youtube-player-shell';
  var CORE_PLAYER_DIV_ID = 'core-yt-player';
  var CORE_PLAYLIST_DIV_ID = 'core-yt-playlist';
  var CHECK_INTERVAL_MS = 500;

  // State
  var corePlaylist = []; // {videoId, start, end, title, source}
  var currentIndex = -1;
  var ytPlayer = null;
  var ytApiReady = false;
  var endCheckInterval = null;
  var pendingCreate = false;

  // Utilities
  function log() { if (window.console) console.log.apply(console, arguments); }

  function parseTimeSpec(t) {
    if (!t) return 0;
    if (/^\d+$/.test(t)) return parseInt(t, 10);
    var total = 0;
    var m = /(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?/.exec(t);
    if (m) {
      total += (parseInt(m[1] || 0, 10) * 3600);
      total += (parseInt(m[2] || 0, 10) * 60);
      total += (parseInt(m[3] || 0, 10));
      if (total > 0) return total;
    }
    var n = parseInt(t, 10);
    return isNaN(n) ? 0 : n;
  }

  function extractYouTubeFromUrl(href) {
    if (!href) return null;
    try {
      var url = new URL(href, location.href);
    } catch (e) { return null; }
    var host = url.hostname.toLowerCase();
    var id = null;
    if (host === 'youtu.be') {
      id = url.pathname.replace(/^\//, '').split('/')[0];
    } else if (host.indexOf('youtube.com') !== -1) {
      if (url.pathname === '/watch') id = url.searchParams.get('v');
      if (!id) {
        var m = url.pathname.match(/\/embed\/([a-zA-Z0-9_-]{6,})/);
        if (m) id = m[1];
        m = url.pathname.match(/\/v\/([a-zA-Z0-9_-]{6,})/);
        if (!id && m) id = m[1];
      }
    }
    if (!id) return null;
    var start = 0, end = null;
    if (url.searchParams.has('t')) start = parseTimeSpec(url.searchParams.get('t'));
    if (url.searchParams.has('start')) start = parseTimeSpec(url.searchParams.get('start'));
    if (url.searchParams.has('end')) end = parseTimeSpec(url.searchParams.get('end'));
    return { videoId: id, start: start, end: end };
  }

  // Parse placeholder
  function parseVideosFromPlaceholder(placeholder) {
    var list = [];
    if (!placeholder) return list;
    var includeRefs = placeholder.getAttribute('data-include-refs') === 'true';

    // a) inline anchors
    var anchors = placeholder.querySelectorAll('a[href]');
    anchors.forEach(function (a) {
      var info = extractYouTubeFromUrl(a.href);
      if (info) {
        list.push(Object.assign({}, info, { title: (a.textContent || '').trim() }));
      }
    });

    // b) refs if requested
    if (includeRefs) {
      var supLinks = placeholder.querySelectorAll('sup a[href^="#"]');
      supLinks.forEach(function (sup) {
        var rid = sup.getAttribute('href').slice(1);
        var refElem = document.getElementById(rid);
        if (!refElem) return;
        var refAnch = refElem.querySelectorAll('a[href]');
        refAnch.forEach(function (ra) {
          var info = extractYouTubeFromUrl(ra.href);
          if (info) list.push(Object.assign({}, info, { title: (ra.textContent || '').trim() }));
        });
      });
    }

    // c) fallback to data-videos attribute
    if (list.length === 0) {
      var dv = (placeholder.getAttribute('data-videos') || '').trim();
      if (dv) {
        dv.split(',').forEach(function (entry) {
          entry = entry.trim();
          if (!entry) return;
          if (/^https?:\/\//.test(entry)) {
            var info = extractYouTubeFromUrl(entry);
            if (info) list.push(info);
            return;
          }
          var parts = entry.split('&').map(function (p) { return p.trim(); });
          var vid = parts[0];
          var start = 0, end = null;
          parts.slice(1).forEach(function (p) {
            if (p.startsWith('t=')) start = parseTimeSpec(p.substring(2));
            else if (p.startsWith('start=')) start = parseTimeSpec(p.substring(6));
            else if (p.startsWith('end=')) end = parseTimeSpec(p.substring(4));
          });
          if (vid) list.push({ videoId: vid, start: start, end: end });
        });
      }
    }

    return list;
  }

  function gatherPlaceholders(container) {
    container = container || document;
    var placeholders = Array.prototype.slice.call(container.querySelectorAll('.youtube-player-placeholder'));
    var groups = placeholders.map(function (ph) {
      return { placeholder: ph, videos: parseVideosFromPlaceholder(ph) };
    }).filter(function (g) { return g.videos && g.videos.length > 0; });
    return groups;
  }

  // Create core container (fixed, minimal)
  (function createShell() {
    if (document.getElementById(CORE_CONTAINER_ID)) return;
    var shell = document.createElement('div');
    shell.id = CORE_CONTAINER_ID;
    shell.innerHTML = '\
      <div id="' + CORE_PLAYER_DIV_ID + '"></div>\
      <div id="core-yt-controls" style="margin-top:6px;text-align:center">\
        <button id="core-yt-prev" title="Previous">◀</button>\
        <button id="core-yt-next" title="Next">▶</button>\
        <a id="core-yt-watch" href="#" target="_blank" rel="noopener noreferrer" style="margin-left:8px;display:none">Watch on YouTube</a>\
      </div>\
      <div id="' + CORE_PLAYLIST_DIV_ID + '" style="display:none;margin-top:8px;max-height:220px;overflow:auto"></div>';
    // minimal style; skin admins can override
    shell.style.position = 'fixed';
    shell.style.right = '12px';
    shell.style.bottom = '12px';
    shell.style.zIndex = 99999;
    shell.style.background = 'rgba(255,255,255,0.98)';
    shell.style.border = '1px solid #ccc';
    shell.style.padding = '8px';
    shell.style.borderRadius = '6px';
    shell.style.boxShadow = '0 2px 6px rgba(0,0,0,0.12)';
    shell.style.maxWidth = '520px';
    shell.style.fontFamily = 'sans-serif';
    shell.style.fontSize = '13px';
    document.body.appendChild(shell);

    document.getElementById('core-yt-prev').addEventListener('click', function () { playRelative(-1); });
    document.getElementById('core-yt-next').addEventListener('click', function () { playRelative(1); });
  })();

  function rebuildPlaylistUI() {
    var div = document.getElementById(CORE_PLAYLIST_DIV_ID);
    div.innerHTML = '';
    if (corePlaylist.length === 0) { div.style.display = 'none'; updateWatchLink(); return; }
    div.style.display = 'block';
    corePlaylist.forEach(function (v, idx) {
      var item = document.createElement('div');
      item.style.padding = '4px 6px';
      item.style.cursor = 'pointer';
      if (idx === currentIndex) item.style.fontWeight = 'bold';
      item.textContent = (v.title && v.title.length ? v.title : v.videoId) + (v.start ? ' (+' + v.start + 's)' : '');
      item.dataset.idx = idx;
      item.addEventListener('click', function () {
        playAt(parseInt(this.dataset.idx, 10));
      });
      div.appendChild(item);
    });
    updateWatchLink();
  }

  function updateWatchLink() {
    var watch = document.getElementById('core-yt-watch');
    if (currentIndex >= 0 && corePlaylist[currentIndex]) {
      watch.href = 'https://www.youtube.com/watch?v=' + corePlaylist[currentIndex].videoId;
      watch.style.display = '';
    } else {
      watch.href = '#';
      watch.style.display = 'none';
    }
  }

  // YouTube API
  function ensureYouTubeApiLoaded() {
    if (ytApiReady) return;
    if (!document.getElementById('youtube-iframe-api')) {
      var tag = document.createElement('script');
      tag.src = 'https://www.youtube.com/iframe_api';
      tag.id = 'youtube-iframe-api';
      var first = document.getElementsByTagName('script')[0];
      first.parentNode.insertBefore(tag, first);
    }
    // set callback once
    var existing = window.onYouTubeIframeAPIReady;
    window.onYouTubeIframeAPIReady = function () {
      ytApiReady = true;
      if (typeof existing === 'function') try { existing(); } catch (e) { log(e); }
      createPlayerIfNeeded();
    };
  }

  function createPlayerIfNeeded() {
    if (!ytApiReady) return;
    if (ytPlayer) return;
    if (corePlaylist.length === 0) { pendingCreate = true; return; }
    var initial = corePlaylist[Math.max(0, currentIndex >= 0 ? currentIndex : 0)];
    currentIndex = currentIndex >= 0 ? currentIndex : 0;
    var playerDiv = document.getElementById(CORE_PLAYER_DIV_ID);
    ytPlayer = new YT.Player(playerDiv, {
      height: '270',
      width: '480',
      videoId: initial.videoId,
      playerVars: { start: initial.start || 0, modestbranding: 1 },
      events: {
        onReady: function () { rebuildPlaylistUI(); },
        onStateChange: onPlayerStateChange
      }
    });
    pendingCreate = false;
  }

  function onPlayerStateChange(event) {
    clearEndInterval();
    if (event.data === YT.PlayerState.PLAYING) {
      var cur = corePlaylist[currentIndex];
      if (cur && cur.end) {
        endCheckInterval = setInterval(function () {
          try {
            var t = ytPlayer.getCurrentTime();
            if (t >= (cur.end - 0.25)) {
              clearEndInterval();
              playRelative(1);
            }
          } catch (e) { clearEndInterval(); }
        }, CHECK_INTERVAL_MS);
      }
    } else if (event.data === YT.PlayerState.ENDED) {
      playRelative(1);
    }
    rebuildPlaylistUI();
  }

  function clearEndInterval() {
    if (endCheckInterval) { clearInterval(endCheckInterval); endCheckInterval = null; }
  }

  function playAt(idx) {
    if (idx < 0 || idx >= corePlaylist.length) return;
    currentIndex = idx;
    var v = corePlaylist[currentIndex];
    if (!v) return;
    if (!ytApiReady) ensureYouTubeApiLoaded();
    if (!ytPlayer) {
      // create player with this video as initial
      pendingCreate = false;
      if (ytApiReady && typeof YT !== 'undefined' && YT.Player) {
        var playerDiv = document.getElementById(CORE_PLAYER_DIV_ID);
        ytPlayer = new YT.Player(playerDiv, {
          height: '270',
          width: '480',
          videoId: v.videoId,
          playerVars: { start: v.start || 0, modestbranding: 1 },
          events: { onReady: function () {}, onStateChange: onPlayerStateChange }
        });
      } else {
        ensureYouTubeApiLoaded();
        pendingCreate = true;
      }
    } else {
      try {
        ytPlayer.loadVideoById({ videoId: v.videoId, startSeconds: v.start || 0 });
      } catch (e) { log('YT load error', e); }
    }
    rebuildPlaylistUI();
  }

  function playRelative(delta) {
    if (corePlaylist.length === 0) return;
    if (currentIndex === -1) currentIndex = 0;
    var next = (currentIndex + delta) % corePlaylist.length;
    if (next < 0) next = corePlaylist.length - 1;
    playAt(next);
  }

  // Update playlist from document placeholders
  function updatePlaylistFromDocument(container) {
    var groups = gatherPlaceholders(container);
    var flattened = [];
    groups.forEach(function (g) {
      g.videos.forEach(function (v) {
        flattened.push(Object.assign({}, v, { source: (window.location.pathname || '') }));
      });
    });

    var preserved = null;
    if (currentIndex >= 0 && corePlaylist[currentIndex]) preserved = corePlaylist[currentIndex];

    // Remove exact duplicates (videoId + start)
    function dedupe(arr) {
      var seen = {};
      return arr.filter(function (x) {
        var key = x.videoId + '::' + (x.start || 0);
        if (seen[key]) return false;
        seen[key] = true;
        return true;
      });
    }
    flattened = dedupe(flattened);

    corePlaylist = flattened;

    if (preserved) {
      var found = corePlaylist.findIndex(function (x) { return x.videoId === preserved.videoId && (x.start || 0) === (preserved.start || 0); });
      if (found === -1) {
        corePlaylist.unshift(preserved);
        currentIndex = 0;
      } else {
        currentIndex = found;
      }
    } else {
      currentIndex = corePlaylist.length > 0 ? 0 : -1;
    }

    // Bind clicks in placeholders to play in core player
    groups.forEach(function (g) {
      var ph = g.placeholder;
      var anchors = ph.querySelectorAll('a[href]');
      anchors.forEach(function (a) {
        if (a.dataset.coreytBound) return;
        a.dataset.coreytBound = '1';
        a.addEventListener('click', function (ev) {
          var info = extractYouTubeFromUrl(a.href);
          if (!info) return; // not a youtube link
          // Find matching index
          var idx = corePlaylist.findIndex(function (x) { return x.videoId === info.videoId && (x.start || 0) === (info.start || 0); });
          if (idx !== -1) {
            ev.preventDefault(); ev.stopPropagation();
            playAt(idx);
            return;
          }
          // not found -> append and play
          corePlaylist.push(Object.assign({}, info, { title: (a.textContent || '').trim(), source: (window.location.pathname || '') }));
          playAt(corePlaylist.length - 1);
          ev.preventDefault(); ev.stopPropagation();
        }, false);
      });
    });

    rebuildPlaylistUI();

    if (ytApiReady && !ytPlayer && corePlaylist.length > 0) createPlayerIfNeeded();
    if (!ytApiReady && corePlaylist.length > 0) ensureYouTubeApiLoaded();
  }

  // --- Navigation wrapper with hash-safe behaviour ---

  // Ensure initial history state stores base URL (no hash)
  try {
    if (!history.state || !history.state.url) {
      history.replaceState({ url: location.href.split('#')[0] }, '', location.href);
    } else if (history.state && history.state.url) {
      // normalize to base (no hash)
      history.replaceState({ url: (history.state.url || location.href).split('#')[0] }, '', location.href);
    }
  } catch (e) { /* ignore */ }

  var articlePrefix = (window.mw && mw.config && mw.config.get('wgArticlePath')) ? mw.config.get('wgArticlePath').replace('$1', '') : '/wiki/';

  function shouldInterceptLink(a, ev) {
    if (!a || !a.href) return false;
    // only left click without modifiers
    if (ev && (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey)) return false;
    if (a.target && a.target !== '' && a.target !== '_self') return false;
    // same-origin
    if (a.origin !== location.origin) return false;
    // anchors on same page -> allow native (hash-only)
    if (a.pathname === location.pathname && a.hash && a.hash !== '') return false;
    // only article path
    if (!a.pathname.startsWith(articlePrefix)) return false;
    // skip edit/history/special
    if (a.search && (a.search.indexOf('action=edit') !== -1 || a.search.indexOf('action=history') !== -1)) return false;
    // opt-out
    if (a.classList.contains('no-ajax') || a.dataset.noAjax === 'true') return false;
    return true;
  }

  function titleFromPath(pathname) {
    var t = pathname.substring(articlePrefix.length);
    try { t = decodeURIComponent(t); } catch (e) { /* ignore */ }
    t = t.replace(/\//g, '_');
    return t;
  }

  function loadArticleByUrl(href, opts) {
    opts = opts || {};
    var baseNow = location.href.split('#')[0];
    var baseTarget = href.split('#')[0];
    if (baseNow === baseTarget) {
      // prevent reload for hash-only or identical base
      // But if opts.replaceState is true and we want to update title etc, we still avoid full parse.
      return;
    }
    var urlObj;
    try { urlObj = new URL(href, location.href); } catch (e) { location.href = href; return; }
    var title = titleFromPath(urlObj.pathname);
    if (!title) { location.href = href; return; }

    var api = mw.util.wikiScript('api') + '?action=parse&page=' + encodeURIComponent(title) + '&prop=text|displaytitle&format=json';
    fetch(api, { credentials: 'same-origin' }).then(function (r) {
      if (!r.ok) throw new Error('fetch failed ' + r.status);
      return r.json();
    }).then(function (data) {
      if (!data || !data.parse || !data.parse.text) throw new Error('no parse text');
      var html = data.parse.text['*'];
      var container = document.getElementById('mw-content-text');
      if (!container) { location.href = href; return; }
      container.innerHTML = html;
      try {
        document.title = (data.parse.title || '') + ' - ' + (window.mw && mw.config && mw.config.get('wgSiteName') ? mw.config.get('wgSiteName') : document.title);
      } catch (e) { /* ignore */ }

      // history state uses base URL (no hash)
      var base = baseTarget;
      if (opts.pushState) history.pushState({ url: base }, '', href);
      else if (opts.replaceState) history.replaceState({ url: base }, '', href);

      // let other MW scripts run
      if (window.mw && mw.hook) mw.hook('wikipage.content').fire(container);

      // update playlist from new content
      updatePlaylistFromDocument(container);

      // scroll: if target has hash, scroll to that element; otherwise scroll top
      if (urlObj.hash) {
        var id = decodeURIComponent(urlObj.hash.slice(1));
        var el = document.getElementById(id) || document.getElementsByName(id)[0];
        if (el) el.scrollIntoView();
        else window.scrollTo(0, 0);
      } else {
        window.scrollTo(0, 0);
      }
    }).catch(function (err) {
      log('AJAX navigation failed, falling back to full reload', err);
      location.href = href;
    });
  }

  // Intercept clicks on internal links
  document.addEventListener('click', function (ev) {
    var a = ev.target.closest ? ev.target.closest('a[href]') : null;
    if (!a) return;
    if (!shouldInterceptLink(a, ev)) return;
    ev.preventDefault();
    loadArticleByUrl(a.href, { pushState: true });
  }, true);

  // popstate: ignore hash-only navigation
  window.addEventListener('popstate', function (ev) {
    var baseNow = location.href.split('#')[0];
    var baseState = (ev.state && ev.state.url) ? ev.state.url : (history.state && history.state.url ? history.state.url : null);
    if (baseState === baseNow) {
      // only hash change or same base -> let browser handle scroll
      return;
    }
    loadArticleByUrl(location.href, { replaceState: true });
  });

  // Initial bootstrap
  (function init() {
    // seed playlist from current document
    updatePlaylistFromDocument(document);
    if (corePlaylist.length > 0) ensureYouTubeApiLoaded();
    // ensure history normalized without hash
    try { history.replaceState({ url: location.href.split('#')[0] }, '', location.href); } catch (e) {}
  })();

  // Expose debug helpers
  window.CoreYouTubePersistentDebug = {
    getPlaylist: function () { return corePlaylist.slice(); },
    getCurrentIndex: function () { return currentIndex; },
    playAt: playAt
  };

})();