MediaWiki:Gadget-floatingYouTubePlayer.js

From Angelina Jordan Wiki
Revision as of 20:32, 28 September 2025 by Most2dot0 (talk | contribs) (another rewrite by ChatGPT, now supposed to keep playlists separate)

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.
/* Persistent core YouTube player + AJAX navigation for MediaWiki
   Drop into MediaWiki:Common.js or a gadget. Requires interface-admin to deploy.
*/
(function () {
  'use strict';

  /* CONFIG */
  var CONTENT_SELECTOR = '#mw-content-text';
  var CORE_PLAYER_ID = 'core-youtube-player';
  var API_PROP = 'text|displaytitle|title';
  var YT_SCRIPT_ID = 'youtube-iframe-api';

  /* Helper: safe API url */
  function getApiUrl() {
    if (window.mw && mw.config) {
      var path = mw.config.get('wgScriptPath') || '';
      return (path ? path + '/api.php' : '/api.php');
    }
    return '/api.php';
  }
  var API_URL = getApiUrl();

  /* Helper: strip HTML tags from a string (for displaytitle) */
  function stripTags(s) {
    var div = document.createElement('div');
    div.innerHTML = s || '';
    return div.textContent || div.innerText || '';
  }

  /* Helper: parse time parameters like "90" or "1m30s" or "90s" */
  function parseTimeParam(val) {
    if (!val) return 0;
    val = String(val);
    if (/^\d+$/.test(val)) return parseInt(val, 10);
    var m = val.match(/(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?/);
    if (m) {
      return (parseInt(m[1] || 0, 10) * 3600) +
             (parseInt(m[2] || 0, 10) * 60) +
             (parseInt(m[3] || 0, 10));
    }
    var digits = val.replace(/\D/g, '');
    return digits ? parseInt(digits, 10) : 0;
  }

  /* Helper: detect internal wiki link and extract title */
  function extractTitleFromUrl(href) {
    try {
      var url = new URL(href, location.href);
      if (url.origin !== location.origin) return null;
      // /wiki/Title
      var path = url.pathname;
      var m = path.match(/^\/wiki\/(.+)/);
      if (m) {
        return decodeURIComponent(m[1]).replace(/_/g, ' ').split(/[?#]/)[0];
      }
      // /w/index.php?title=Title...
      if (url.pathname.indexOf('/index.php') !== -1 || url.pathname.indexOf('/w/index.php') !== -1) {
        var t = url.searchParams.get('title');
        if (t) return decodeURIComponent(t).replace(/_/g, ' ').split(/[?#]/)[0];
      }
      return null;
    } catch (e) {
      return null;
    }
  }

  /* Helper: detect YouTube video ID and params from href */
  function parseYouTubeFromHref(href) {
    if (!href) return null;
    // Accept youtube.com/watch?v=ID, youtu.be/ID, youtube.com/embed/ID
    var idMatch = href.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([A-Za-z0-9_-]{5,})/);
    if (!idMatch) return null;
    var videoId = idMatch[1];
    // parse query params
    var q = '';
    try {
      q = (new URL(href, location.href)).search;
    } catch (e) {
      var parts = href.split('?');
      q = parts[1] ? '?' + parts[1] : '';
    }
    var params = new URLSearchParams(q);
    var start = parseTimeParam(params.get('t') || params.get('start'));
    var end = params.get('end') ? parseTimeParam(params.get('end')) : null;
    return { videoId: videoId, start: start, end: end };
  }

  /* Core player object */
  var CorePlayer = (function () {
    var playlists = []; // {placeholderEl, videos: [{videoId,start,end}], currentIndex, checkInterval}
    var player = null;
    var ytReady = false;
    var activePlaylist = null; // index in playlists
    var activeCheckInterval = null;

    /* create DOM container */
    function ensureContainer() {
      var c = document.getElementById(CORE_PLAYER_ID);
      if (!c) {
        c = document.createElement('div');
        c.id = CORE_PLAYER_ID;
        // Minimal styling. Adjust as needed in CSS on your wiki.
        c.style.position = 'fixed';
        c.style.bottom = '12px';
        c.style.right = '12px';
        c.style.zIndex = 9999;
        c.style.background = '#000';
        c.style.padding = '6px';
        c.style.borderRadius = '6px';
        c.style.boxShadow = '0 2px 8px rgba(0,0,0,0.35)';
        document.body.appendChild(c);
      }
      return c;
    }

    /* instantiate YT.Player when possible */
    function createPlayer(initialVideo) {
      var container = ensureContainer();
      container.innerHTML = '<div id="core-youtube-player-frame"></div>';
      // create player only if YT API ready
      if (!ytReady) return;
      if (player) return;
      var opts = {
        height: '270',
        width: '480',
        playerVars: { autoplay: 0, rel: 0 },
        events: {
          onReady: function () { /* nothing special */ },
          onStateChange: onPlayerStateChange
        }
      };
      if (initialVideo) opts.videoId = initialVideo.videoId;
      player = new YT.Player('core-youtube-player-frame', opts);
    }

    function onPlayerStateChange(ev) {
      // ev.data values from YT.PlayerState
      if (activePlaylist === null) return;
      clearActiveInterval();
      if (ev.data === YT.PlayerState.PLAYING) {
        // start end-time watch if set
        var cur = playlists[activePlaylist];
        var curVideo = cur && cur.videos[cur.currentIndex];
        if (curVideo && curVideo.end != null) {
          activeCheckInterval = setInterval(function () {
            try {
              var ct = player.getCurrentTime();
              if (ct >= curVideo.end) {
                clearActiveInterval();
                playNext(activePlaylist);
              }
            } catch (e) {
              clearActiveInterval();
            }
          }, 500);
        }
      } else if (ev.data === YT.PlayerState.ENDED) {
        playNext(activePlaylist);
      }
    }

    function clearActiveInterval() {
      if (activeCheckInterval) {
        clearInterval(activeCheckInterval);
        activeCheckInterval = null;
      }
    }

    /* registers a playlist for a placeholder element */
    function registerPlaylist(placeholderEl, videoArray) {
      var pid = playlists.length;
      playlists.push({
        placeholderEl: placeholderEl,
        videos: videoArray.slice(),
        currentIndex: 0
      });
      placeholderEl.setAttribute('data-core-playlist-id', pid);
      // If YT ready and no player created, create with this first video (but do not autoplay)
      if (ytReady && !player && playlists[pid].videos.length) {
        createPlayer(playlists[pid].videos[0]);
      }
      return pid;
    }

    function unregisterOrCleanup() {
      // remove playlists whose placeholder has been removed from DOM
      for (var i = playlists.length - 1; i >= 0; i--) {
        if (!playlists[i].placeholderEl || !document.body.contains(playlists[i].placeholderEl)) {
          if (i === activePlaylist) {
            // do not forcibly stop playback. Just reset mapping.
            activePlaylist = null;
            clearActiveInterval();
          }
          playlists.splice(i, 1);
        }
      }
      // reassign data-core-playlist-id attributes to match new indices
      playlists.forEach(function (pl, idx) {
        if (pl.placeholderEl && pl.placeholderEl.dataset) {
          pl.placeholderEl.setAttribute('data-core-playlist-id', idx);
        }
      });
    }

    function gotoVideo(playlistId, idx) {
      playlistId = Number(playlistId);
      if (!playlists[playlistId]) return;
      var pl = playlists[playlistId];
      if (idx < 0 || idx >= pl.videos.length) return;
      pl.currentIndex = idx;
      var v = pl.videos[idx];
      activePlaylist = playlistId;
      clearActiveInterval();
      if (!ytReady) {
        // create script if not present will trigger creation later
        loadYouTubeApi();
        // set a small retry to actually create when ready
        setTimeout(function () {
          if (!player && ytReady) createPlayer(v);
          if (player && player.loadVideoById) {
            try { player.loadVideoById({ videoId: v.videoId, startSeconds: v.start }); } catch (e) {}
          }
        }, 300);
        return;
      }
      if (!player) {
        createPlayer(v);
        // small timeout to ensure player exists
        setTimeout(function () {
          try { player.loadVideoById({ videoId: v.videoId, startSeconds: v.start }); } catch (e) {}
        }, 200);
        return;
      }
      try {
        player.loadVideoById({ videoId: v.videoId, startSeconds: v.start });
      } catch (e) {
        // fallback: cue then play
        try { player.cueVideoById({ videoId: v.videoId, startSeconds: v.start }); } catch (e2) {}
      }
    }

    function playNext(playlistId) {
      playlistId = Number(playlistId);
      var pl = playlists[playlistId];
      if (!pl) return;
      pl.currentIndex++;
      if (pl.currentIndex >= pl.videos.length) pl.currentIndex = 0;
      gotoVideo(playlistId, pl.currentIndex);
    }
    function playPrev(playlistId) {
      playlistId = Number(playlistId);
      var pl = playlists[playlistId];
      if (!pl) return;
      pl.currentIndex--;
      if (pl.currentIndex < 0) pl.currentIndex = pl.videos.length - 1;
      gotoVideo(playlistId, pl.currentIndex);
    }

    /* public API */
    return {
      setYTReady: function () { ytReady = true; },
      registerPlaylist: registerPlaylist,
      gotoVideo: gotoVideo,
      next: playNext,
      prev: playPrev,
      cleanup: unregisterOrCleanup,
      hasAnyPlaylist: function () { return playlists.length > 0; },
      // debug helper
      _debug: function () { return { playlists: playlists, active: activePlaylist, player: !!player }; }
    };
  })();

  /* Inject YouTube IFrame API if missing */
  function loadYouTubeApi() {
    if (document.getElementById(YT_SCRIPT_ID)) return;
    var tag = document.createElement('script');
    tag.id = YT_SCRIPT_ID;
    tag.src = 'https://www.youtube.com/iframe_api';
    var firstScriptTag = document.getElementsByTagName('script')[0];
    firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
  }

  /* onYouTubeIframeAPIReady should mark core ready */
  window.onYouTubeIframeAPIReady = function () {
    CorePlayer.setYTReady();
    // attempt to create a player if any playlist exists already
    // create player via CorePlayer by registering will do lazy creation
  };

  loadYouTubeApi();

  /* Parse a single placeholder element into video list - retains original logic */
  function parseVideosFromPlaceholder(placeholder) {
    var videoDataList = [];
    if (!placeholder) return videoDataList;
    var links = placeholder.querySelectorAll('a');
    var includeRefs = placeholder.getAttribute('data-include-refs') === 'true';

    function tryParseHref(href) {
      var info = parseYouTubeFromHref(href);
      if (info) videoDataList.push({ videoId: info.videoId, start: info.start || 0, end: (info.end != null ? info.end : null) });
    }

    // parse anchors
    links.forEach(function (link) {
      var href = link.getAttribute('href') || link.getAttribute('url') || '';
      tryParseHref(href);
    });

    // parse refs if requested (sup anchor style)
    if (includeRefs) {
      var refSupTags = placeholder.querySelectorAll('sup a[href^="#"]');
      refSupTags.forEach(function (supTag) {
        var refId = supTag.getAttribute('href').substring(1);
        var refListItem = document.getElementById(refId);
        if (refListItem) {
          var refLinks = refListItem.querySelectorAll('a');
          refLinks.forEach(function (link) {
            tryParseHref(link.getAttribute('href') || '');
          });
        }
      });
    }

    // fallback to data-videos
    if (videoDataList.length === 0) {
      var dataAttr = placeholder.getAttribute('data-videos') || '';
      if (dataAttr) {
        var dataVideos = dataAttr.split(',');
        dataVideos.forEach(function (videoData) {
          var parts = videoData.split('&');
          var videoId = parts[0].trim();
          var start = 0;
          var end = null;
          parts.forEach(function (part) {
            part = part.trim();
            if (part.startsWith('t=')) start = parseTimeParam(part.substring(2));
            else if (part.startsWith('start=')) start = parseTimeParam(part.substring(6));
            else if (part.startsWith('end=')) end = parseTimeParam(part.substring(4));
          });
          if (videoId) videoDataList.push({ videoId: videoId, start: start, end: end });
        });
      }
    }

    return videoDataList;
  }

  /* Attach placeholders in a root (document or a swapped fragment) */
  function attachPlaceholders(root) {
    root = root || document;
    var placeholders = root.querySelectorAll('.youtube-player-placeholder');
    placeholders.forEach(function (placeholder) {
      // if already processed on this placeholder, skip
      if (placeholder.getAttribute('data-core-processed') === 'true') return;
      var videos = parseVideosFromPlaceholder(placeholder);
      if (!videos || videos.length === 0) return;
      var pid = CorePlayer.registerPlaylist(placeholder, videos);

      // create controls in placeholder if missing
      if (!placeholder.querySelector('.youtube-player-controls')) {
        var controls = document.createElement('div');
        controls.className = 'youtube-player-controls';
        controls.innerHTML = '<button class="prev-button" data-core-playlist-id="' + pid + '">Previous</button>' +
                             '<button class="next-button" data-core-playlist-id="' + pid + '">Next</button>';
        placeholder.appendChild(controls);
      } else {
        // ensure control buttons carry playlist id
        var prev = placeholder.querySelector('.prev-button');
        var next = placeholder.querySelector('.next-button');
        if (prev) prev.setAttribute('data-core-playlist-id', pid);
        if (next) next.setAttribute('data-core-playlist-id', pid);
      }

      // bind play-on-link-click inside placeholder for YouTube links
      var links = placeholder.querySelectorAll('a');
      links.forEach(function (link) {
        var href = link.getAttribute('href') || link.getAttribute('url') || '';
        var info = parseYouTubeFromHref(href);
        if (!info) return;
        link.addEventListener('click', function (ev) {
          ev.preventDefault();
          ev.stopPropagation();
          // find index in playlist
          var pl = placeholder.getAttribute('data-core-playlist-id');
          var idx = -1;
          var pVideos = parseVideosFromPlaceholder(placeholder);
          for (var i = 0; i < pVideos.length; i++) {
            if (pVideos[i].videoId === info.videoId && pVideos[i].start === info.start && (pVideos[i].end === info.end)) {
              idx = i;
              break;
            }
          }
          if (idx === -1) idx = 0;
          CorePlayer.gotoVideo(pl, idx);
        }, { passive: false });
      });

      // bind prev/next
      placeholder.addEventListener('click', function (ev) {
        var el = ev.target;
        if (!el) return;
        if (el.classList.contains('next-button')) {
          var id = el.getAttribute('data-core-playlist-id');
          CorePlayer.next(id);
        } else if (el.classList.contains('prev-button')) {
          var id = el.getAttribute('data-core-playlist-id');
          CorePlayer.prev(id);
        }
      });

      placeholder.setAttribute('data-core-processed', 'true');
    });
  }

  /* Initial attach on load */
  document.addEventListener('DOMContentLoaded', function () {
    attachPlaceholders(document);
    // store initial page content in history.state so back/forward works
    try {
      var content = document.querySelector(CONTENT_SELECTOR);
      var currentHtml = content ? content.innerHTML : '';
      var docTitle = document.title;
      history.replaceState({ ajax: true, title: docTitle, html: currentHtml }, docTitle, location.href);
    } catch (e) { /* ignore */ }
  });

  /* Global click handler to intercept internal wiki navigation (AJAX) */
  document.addEventListener('click', function (ev) {
    // left click only, no modifier keys
    if (ev.defaultPrevented) return;
    if (ev.button !== 0) return;
    if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;

    var anchor = ev.target.closest && ev.target.closest('a');
    if (!anchor) return;
    var href = anchor.getAttribute('href') || '';
    if (!href || href.indexOf('javascript:') === 0) return;
    // external link — let default
    try {
      var url = new URL(href, location.href);
      if (url.origin !== location.origin) return;
    } catch (e) {
      return;
    }
    // don't intercept if anchor has target or download
    if (anchor.target && anchor.target !== '' && anchor.target !== '_self') return;
    if (anchor.hasAttribute('download')) return;

    // extract wiki title - if null, not an article link we intercept
    var title = extractTitleFromUrl(href);
    if (!title) return;

    // Allow anchors to same-page fragment to proceed normally if only fragment differs
    if (url && url.pathname === location.pathname && url.search === location.search && url.hash) {
      // Let browser handle fragment scroll
      return;
    }

    // Intercept navigation
    ev.preventDefault();
    ev.stopPropagation();
    ajaxNavigate(href);
  }, { capture: true });

  /* AJAX navigation function */
  function ajaxNavigate(href, isPop) {
    var title = extractTitleFromUrl(href);
    if (!title) {
      // fallback: full navigation
      location.href = href;
      return;
    }
    var fetchUrl = API_URL + '?action=parse&format=json&prop=' + encodeURIComponent(API_PROP) + '&page=' + encodeURIComponent(title);
    fetch(fetchUrl, { credentials: 'same-origin' }).then(function (res) {
      if (!res.ok) throw new Error('network error');
      return res.json();
    }).then(function (json) {
      if (json && json.parse && json.parse.text) {
        var html = json.parse.text['*'];
        var newTitle = (json.parse.displaytitle ? stripTags(json.parse.displaytitle) : (json.parse.title || title));
        replaceContent(html, newTitle, href, !isPop);
      } else {
        // fallback full navigation
        location.href = href;
      }
    }).catch(function () {
      location.href = href;
    });
  }

  /* Replace article content and re-attach placeholders */
  function replaceContent(html, newTitle, href, pushState) {
    var container = document.querySelector(CONTENT_SELECTOR);
    if (!container) {
      location.href = href; // can't handle
      return;
    }
    container.innerHTML = html;
    document.title = (window.mw && mw.config && mw.config.get('wgSiteName')) ? (newTitle + ' - ' + mw.config.get('wgSiteName')) : newTitle;
    // update history
    if (pushState) {
      try {
        history.pushState({ ajax: true, title: document.title, html: html }, document.title, href);
      } catch (e) { /* ignore */ }
    }
    // re-run placeholder parsing in newly inserted content
    attachPlaceholders(container);
    // clean up playlists for removed placeholders
    CorePlayer.cleanup();
    // scroll to top for new content
    window.scrollTo(0, 0);
  }

  /* popstate handling (back/forward) */
  window.addEventListener('popstate', function (ev) {
    var state = ev.state;
    if (state && state.ajax && state.html) {
      // restore stored HTML
      var container = document.querySelector(CONTENT_SELECTOR);
      if (!container) {
        // fallback: full reload
        location.reload();
        return;
      }
      container.innerHTML = state.html;
      document.title = state.title || document.title;
      attachPlaceholders(container);
      CorePlayer.cleanup();
    } else {
      // No stored html. Do an AJAX fetch of current location.
      ajaxNavigate(location.href, true);
    }
  });

  // Expose a tiny debug accessor for admins via console
  window.__CoreYouTubePersistent = {
    debug: function () { try { return CorePlayer._debug(); } catch (e) { return null; } }
  };

})();