MediaWiki:Gadget-floatingYouTubePlayer.js

From Angelina Jordan Wiki
Revision as of 18:15, 28 September 2025 by Most2dot0 (talk | contribs) (1st try to implement alternate, floating YouTube player, meant to survive inter page jumps)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

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.
/* Core persistent YouTube player for MediaWiki
   - Install as gadget or in MediaWiki:Common.js by an interface admin.
   - Keeps the iframe alive across /wiki/ navigations.
   - Feeds playlist from .youtube-player-placeholder elements (data-videos or inline links).
   - Preserves currently playing video if it's not present on the newly loaded page.
*/

(function () {
  'use strict';
  if (window.CoreYouTubePlayerGadget) return;
  window.CoreYouTubePlayerGadget = true;

  // --- Config / IDs ---
  var CORE_CONTAINER_ID = 'core-youtube-player-shell';
  var CORE_PLAYER_DIV_ID = 'core-yt-player';
  var CHECK_INTERVAL_MS = 500;

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

  // Utility: safe console
  function log() { if (window.console) console.log.apply(console, arguments); }

  // --- Insert core container (minimal styling) ---
  (function insertContainer() {
    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:4px;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">Watch on YouTube</a>\
      </div>\
      <div id="core-yt-playlist" style="display:none;margin-top:6px;max-height:200px;overflow:auto"></div>';
    // Minimal non-intrusive style. Admin can override in CSS.
    shell.style.position = 'fixed';
    shell.style.right = '12px';
    shell.style.bottom = '12px';
    shell.style.zIndex = 99999;
    shell.style.background = 'rgba(255,255,255,0.96)';
    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.15)';
    shell.style.maxWidth = '520px';
    shell.style.fontFamily = 'sans-serif';
    shell.style.fontSize = '13px';
    document.body.appendChild(shell);

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

  // --- YouTube API loader ---
  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);
    }
    // onYouTubeIframeAPIReady will call createPlayerIfNeeded
    window.onYouTubeIframeAPIReady = function () {
      ytApiReady = true;
      createPlayerIfNeeded();
    };
  }

  // --- Time parsing ---
  function parseTimeSpec(t) {
    if (!t) return 0;
    if (typeof t === 'number') return t;
    // formats: 90, 1m30s, 1h2m3s
    if (/^[0-9]+$/.test(t)) return parseInt(t, 10);
    var total = 0;
    var regex = /(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?/;
    var m = regex.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;
    }
    // fallback: try parseInt
    var n = parseInt(t, 10);
    return isNaN(n) ? 0 : n;
  }

  // --- Extract YouTube info from href or data string ---
  function extractYouTubeFromUrl(href) {
    if (!href) return null;
    try {
      var url = new URL(href, location.href);
    } catch (e) {
      return null;
    }
    var hostname = url.hostname.toLowerCase();
    var videoId = null;
    if (hostname === 'youtu.be') {
      var path = url.pathname.replace(/^\//, '');
      if (path) videoId = path.split('/')[0];
    } else if (hostname.endsWith('youtube.com')) {
      if (url.pathname === '/watch') {
        videoId = url.searchParams.get('v');
      } else {
        // embed links or /v/VIDEOID
        var m = url.pathname.match(/\/embed\/([a-zA-Z0-9_-]{6,})/);
        if (m) videoId = m[1];
        m = url.pathname.match(/\/v\/([a-zA-Z0-9_-]{6,})/);
        if (!videoId && m) videoId = m[1];
      }
    }
    if (!videoId) return null;
    var start = 0;
    var end = null;
    // Accept parameters: t, start, end
    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: videoId, start: start, end: end };
  }

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

    // 1) Inline links
    var links = placeholder.querySelectorAll('a[href]');
    links.forEach(function (link) {
      var info = extractYouTubeFromUrl(link.href);
      if (info) {
        videoDataList.push(Object.assign({ title: (link.textContent || '').trim() }, info));
      }
    });

    // 2) refs if requested: find <sup><a href="#cite_ref-..."></a></sup>
    if (includeRefs) {
      var supLinks = placeholder.querySelectorAll('sup a[href^="#"]');
      supLinks.forEach(function (sup) {
        var refId = sup.getAttribute('href').substring(1);
        var refElem = document.getElementById(refId);
        if (refElem) {
          var refAnchors = refElem.querySelectorAll('a[href]');
          refAnchors.forEach(function (ra) {
            var info = extractYouTubeFromUrl(ra.href);
            if (info) videoDataList.push(Object.assign({ title: (ra.textContent || '').trim() }, info));
          });
        }
      });
    }

    // 3) fallback to data-videos attribute
    if (videoDataList.length === 0) {
      var dv = placeholder.getAttribute('data-videos');
      if (dv) {
        dv.split(',').forEach(function (entry) {
          entry = entry.trim();
          if (!entry) return;
          // entry like VIDEOID&t=45&end=135  or full url
          if (/^https?:\/\//.test(entry)) {
            var info = extractYouTubeFromUrl(entry);
            if (info) videoDataList.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) videoDataList.push({ videoId: vid, start: start, end: end });
        });
      }
    }

    return videoDataList;
  }

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

  // --- Playlist / UI management ---
  function rebuildPlaylistUI() {
    var listDiv = document.getElementById('core-yt-playlist');
    listDiv.innerHTML = '';
    if (corePlaylist.length === 0) { listDiv.style.display = 'none'; return; }
    listDiv.style.display = 'block';
    corePlaylist.forEach(function (v, idx) {
      var item = document.createElement('div');
      item.style.padding = '3px 4px';
      item.style.cursor = 'pointer';
      if (idx === currentIndex) item.style.fontWeight = 'bold';
      item.textContent = (v.title && v.title.length > 0 ? v.title : v.videoId) + (v.start ? ' (+' + v.start + 's)' : '');
      item.setAttribute('data-idx', idx);
      item.addEventListener('click', function () {
        var i = parseInt(this.getAttribute('data-idx'), 10);
        playAt(i);
      });
      listDiv.appendChild(item);
    });
    // update watch on youtube link
    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';
    }
  }

  function clearCheckInterval() {
    if (checkInterval) { clearInterval(checkInterval); checkInterval = null; }
  }

  function onPlayerStateChangeHandler(event) {
    // 1 = playing, 0 = ended
    if (event.data === YT.PlayerState.PLAYING) {
      clearCheckInterval();
      var cur = corePlaylist[currentIndex];
      if (cur && cur.end) {
        checkInterval = setInterval(function () {
          try {
            var t = ytPlayer.getCurrentTime();
            if (t >= cur.end - 0.25) { // small tolerance
              clearCheckInterval();
              playRelative(1);
            }
          } catch (e) { clearCheckInterval(); }
        }, CHECK_INTERVAL_MS);
      }
      // pause any other YT players in page (unlikely since we keep a single core player)
      // Try to pause other players created by other scripts (best-effort)
      var players = document.querySelectorAll('iframe[src*="youtube.com/embed"], iframe[src*="youtube-nocookie.com/embed"]');
      players.forEach(function (frame) {
        if (frame.id && frame.id !== CORE_PLAYER_DIV_ID && frame.contentWindow) {
          try { /* no reliable cross-frame pause without postMessage; skip */ } catch (e) { }
        }
      });
    } else if (event.data === YT.PlayerState.ENDED) {
      clearCheckInterval();
      playRelative(1);
    } else {
      clearCheckInterval();
    }
    rebuildPlaylistUI(); // update highlight
  }

  function createPlayerIfNeeded() {
    if (!ytApiReady) return;
    if (ytPlayer) return;
    var firstVideo = corePlaylist.length > 0 ? corePlaylist[0] : null;
    if (!firstVideo) {
      // nothing to play yet. wait until playlist populated.
      pendingCreatePlayer = true;
      return;
    }
    var playerDiv = document.getElementById(CORE_PLAYER_DIV_ID);
    ytPlayer = new YT.Player(playerDiv, {
      height: '270',
      width: '480',
      videoId: firstVideo.videoId,
      playerVars: { start: firstVideo.start || 0, modestbranding: 1 },
      events: {
        onReady: function (e) {
          // ensure UI state
          currentIndex = 0;
          rebuildPlaylistUI();
        },
        onStateChange: onPlayerStateChangeHandler
      }
    });
    pendingCreatePlayer = false;
  }

  // playAt: set currentIndex and load into player
  function playAt(idx) {
    if (idx < 0 || idx >= corePlaylist.length) return;
    var video = corePlaylist[idx];
    currentIndex = idx;
    rebuildPlaylistUI();
    if (!ytApiReady) ensureYouTubeApiLoaded();
    if (!ytPlayer) {
      // create player with this video as initial
      ytPlayer = null;
      pendingCreatePlayer = false;
      // create a placeholder player synchronously if API ready
      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: video.videoId,
          playerVars: { start: video.start || 0, modestbranding: 1 },
          events: { onReady: function () {}, onStateChange: onPlayerStateChangeHandler }
        });
      } else {
        // ensure API and wait; createPlayerIfNeeded will run after API ready
        ensureYouTubeApiLoaded();
        pendingCreatePlayer = true;
      }
    } else {
      try {
        // load video and start at specified second
        ytPlayer.loadVideoById({ videoId: video.videoId, startSeconds: video.start || 0 });
      } catch (e) {
        log('YT loadVideoById error', e);
      }
    }
    // update watch link
    var watch = document.getElementById('core-yt-watch');
    watch.href = 'https://www.youtube.com/watch?v=' + video.videoId;
  }

  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);
  }

  // --- When new page content is loaded: update playlist ---
  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 || '') }));
      });
    });

    // Preserve currently playing video if it's not in flattened; else map index
    var preserved = null;
    if (currentIndex >= 0 && corePlaylist[currentIndex]) {
      preserved = corePlaylist[currentIndex];
    }

    // Replace playlist
    corePlaylist = flattened;

    if (preserved) {
      var found = corePlaylist.findIndex(function (x) { return x.videoId === preserved.videoId && (x.start || 0) === (preserved.start || 0); });
      if (found === -1) {
        // keep preserved as first element so playback can continue unchanged
        corePlaylist.unshift(preserved);
        currentIndex = 0;
      } else {
        currentIndex = found;
      }
    } else {
      if (corePlaylist.length > 0) currentIndex = 0;
      else currentIndex = -1;
    }

    // Attach click handlers inside placeholders so clicking a link plays that video in core
    groups.forEach(function (g) {
      var ph = g.placeholder;
      var anchors = ph.querySelectorAll('a[href]');
      anchors.forEach(function (a) {
        // avoid double-binding; use a dataset flag
        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 in corePlaylist
          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);
          } else {
            // if 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 interception and content replacement ---
  var articlePrefix = (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 -> let default
    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 queries
    if (a.search && (a.search.indexOf('action=edit') !== -1 || a.search.indexOf('action=history') !== -1)) return false;
    // skip files / downloads where download attribute present
    if (a.hasAttribute('download')) return false;
    // explicit 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);
    // decode URI and replace slashes by underscores
    try { t = decodeURIComponent(t); } catch (e) { /* ignore */ }
    t = t.replace(/\//g, '_');
    return t;
  }

  function loadArticleByUrl(href, options) {
    options = options || {};
    var urlObj = new URL(href, location.href);
    var title = titleFromPath(urlObj.pathname);
    if (!title) {
      // fallback to full reload
      location.href = href;
      return;
    }
    var apiUrl = mw.util.wikiScript('api') + '?action=parse&page=' + encodeURIComponent(title) + '&prop=text|displaytitle&format=json';
    fetch(apiUrl, { 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) {
        // fallback: full navigation
        location.href = href;
        return;
      }
      // Replace content. parse.text likely contains a wrapper .mw-parser-output
      container.innerHTML = html;
      // update title
      try {
        document.title = (data.parse.title || '') + ' - ' + (mw && mw.config && mw.config.get('wgSiteName') ? mw.config.get('wgSiteName') : document.title);
      } catch (e) { /* ignore */ }
      // update history
      if (!options.replaceState) {
        history.pushState({ title: data.parse.title || '', url: href }, '', href);
      } else {
        history.replaceState({ title: data.parse.title || '', url: href }, '', href);
      }
      // notify MediaWiki hooks that content changed
      if (mw && mw.hook && mw.hook('wikipage.content')) {
        mw.hook('wikipage.content').fire(container);
      }
      // re-run placeholder parsing on new content
      updatePlaylistFromDocument(container);
      // scroll to top
      window.scrollTo(0, 0);
    }).catch(function (err) {
      log('AJAX navigation failed, falling back to full reload', err);
      location.href = href;
    });
  }

  // Attach global click handler to intercept 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)) {
      ev.preventDefault();
      loadArticleByUrl(a.href);
    }
  }, true);

  // Handle back/forward
  window.addEventListener('popstate', function (ev) {
    var state = ev.state;
    if (state && state.url) {
      loadArticleByUrl(state.url, { replaceState: true });
    } else {
      // no state, reload current URL
      loadArticleByUrl(location.href, { replaceState: true });
    }
  });

  // --- Initial run on first load ---
  (function initOnLoad() {
    // Seed playlist from current document
    updatePlaylistFromDocument(document);
    // Ensure API loaded if we have items
    if (corePlaylist.length > 0) ensureYouTubeApiLoaded();
    // If API already loaded earlier on page, onYouTubeIframeAPIReady will be called automatically.
    // Push initial state so popstate has data
    try {
      history.replaceState({ title: document.title, url: location.href }, '', location.href);
    } catch (e) { /* ignore */ }
  })();

  // Public debug access (optional)
  window.CoreYouTubePlayerGadgetDebug = {
    getPlaylist: function () { return corePlaylist.slice(); },
    getCurrentIndex: function () { return currentIndex; },
    playAt: playAt
  };
})();