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.
(function () {
'use strict';
if (window.CoreYouTubePersistent) return;
window.CoreYouTubePersistent = true;
// --- Config / IDs ---
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;
// --- Utils ---
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 };
}
// --- Placeholder parsing ---
function parseVideosFromPlaceholder(placeholder) {
var list = [];
if (!placeholder) return list;
var includeRefs = placeholder.getAttribute('data-include-refs') === 'true';
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() }));
});
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() }));
});
});
}
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'));
return placeholders.map(function (ph) { return { placeholder: ph, videos: parseVideosFromPlaceholder(ph) }; })
.filter(function (g) { return g.videos && g.videos.length > 0; });
}
// --- Core UI shell ---
(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>';
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 & player lifecycle ---
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);
}
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) {
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 placeholders and bind links ---
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 || '') }));
});
});
// dedupe
var seen = {};
flattened = flattened.filter(function (x) {
var key = x.videoId + '::' + (x.start || 0);
if (seen[key]) return false;
seen[key] = true;
return true;
});
var preserved = null;
if (currentIndex >= 0 && corePlaylist[currentIndex]) preserved = corePlaylist[currentIndex];
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;
}
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;
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;
}
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 robust popstate restore ---
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;
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;
if (a.origin !== location.origin) return false;
if (a.pathname === location.pathname && a.hash && a.hash !== '') return false; // hash-only -> let browser
if (!a.pathname.startsWith(articlePrefix)) return false;
if (a.search && (a.search.indexOf('action=edit') !== -1 || a.search.indexOf('action=history') !== -1)) return false;
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) {}
return t.replace(/\//g, '_');
}
// loadArticleByUrl: fetch via API parse, replace content, update heading/title, update history state with contentHtml
function loadArticleByUrl(href, opts) {
opts = opts || {};
var baseNow = location.href.split('#')[0];
var baseTarget = href.split('#')[0];
if (baseNow === baseTarget && !opts.force) return; // avoid reload unless forced
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|categorieshtml&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; }
// Replace content
container.innerHTML = html;
// Update heading/title
var rawTitle = (data.parse.displaytitle || data.parse.title || '');
var headingEl = document.getElementById('firstHeading');
if (headingEl) headingEl.innerHTML = rawTitle;
var siteName = (window.mw && mw.config && mw.config.get('wgSiteName')) ? mw.config.get('wgSiteName') : '';
var fullTitle = rawTitle ? (rawTitle + (siteName ? ' - ' + siteName : '')) : document.title;
try { document.title = fullTitle; } catch (e) {}
// Create history state including contentHtml so popstate can restore without re-fetch
var base = baseTarget;
var stateObj = { url: base, full: href, title: rawTitle, titleFull: fullTitle, contentHtml: html };
if (opts.pushState) history.pushState(stateObj, '', href);
else if (opts.replaceState) history.replaceState(stateObj, '', href);
else history.replaceState(stateObj, '', href);
// Fire MediaWiki content hook
if (window.mw && mw.hook) mw.hook('wikipage.content').fire(container);
// Refresh playlist bindings
updatePlaylistFromDocument(container);
// scroll to fragment if present
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;
});
}
// click interception for 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: restore from history.state.contentHtml if present; otherwise fetch
window.addEventListener('popstate', function (ev) {
var state = ev.state;
if (state && state.url) {
var baseNow = location.href.split('#')[0];
var baseState = state.url.split('#')[0];
if (baseNow === baseState) {
// hash-only or no base change -> let browser handle scrolling
return;
}
// if we have content in state, restore without network
if (state.contentHtml) {
var container = document.getElementById('mw-content-text');
if (container) {
container.innerHTML = state.contentHtml;
var headingEl = document.getElementById('firstHeading');
if (headingEl) headingEl.innerHTML = (state.title || '');
try { document.title = state.titleFull || document.title; } catch (e) {}
if (window.mw && mw.hook) mw.hook('wikipage.content').fire(container);
updatePlaylistFromDocument(container);
// scroll to hash if present
if (location.hash) {
var id = decodeURIComponent(location.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);
}
return;
}
}
// no cached content -> fetch (force)
var href = state.full || state.url;
loadArticleByUrl(href, { replaceState: true, force: true });
} else {
// no state: fetch current location (force)
loadArticleByUrl(location.href, { replaceState: true, force: true });
}
});
// --- Initial bootstrap: seed playlist and history with current contentHtml ---
(function init() {
var container = document.getElementById('mw-content-text');
if (container) {
updatePlaylistFromDocument(document);
// normalize initial history state and include current contentHtml
try {
var base = location.href.split('#')[0];
var heading = document.getElementById('firstHeading');
var rawTitle = heading ? heading.textContent.trim() : document.title;
var siteName = (window.mw && mw.config && mw.config.get('wgSiteName')) ? mw.config.get('wgSiteName') : '';
var fullTitle = rawTitle ? (rawTitle + (siteName ? ' - ' + siteName : '')) : document.title;
var stateObj = { url: base, full: location.href, title: rawTitle, titleFull: fullTitle, contentHtml: container.innerHTML };
history.replaceState(stateObj, '', location.href);
} catch (e) { /* ignore */ }
}
if (corePlaylist.length > 0) ensureYouTubeApiLoaded();
})();
// --- Expose debug helpers ---
window.CoreYouTubePersistentDebug = {
getPlaylist: function () { return corePlaylist.slice(); },
getCurrentIndex: function () { return currentIndex; },
playAt: function (i) { playAt(i); }
};
})();