MediaWiki:Gadget-floatingYouTubePlayer.js
From Angelina Jordan Wiki
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; } }
};
})();