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.
/* 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
};
})();