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;
// IDs and config
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;
// Utilities
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 };
}
// Parse placeholder
function parseVideosFromPlaceholder(placeholder) {
var list = [];
if (!placeholder) return list;
var includeRefs = placeholder.getAttribute('data-include-refs') === 'true';
// a) inline anchors
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() }));
}
});
// b) refs if requested
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() }));
});
});
}
// c) fallback to data-videos attribute
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'));
var groups = placeholders.map(function (ph) {
return { placeholder: ph, videos: parseVideosFromPlaceholder(ph) };
}).filter(function (g) { return g.videos && g.videos.length > 0; });
return groups;
}
// Create core container (fixed, minimal)
(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>';
// minimal style; skin admins can override
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
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);
}
// set callback once
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) {
// create player with this video as initial
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 document placeholders
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 || '') }));
});
});
var preserved = null;
if (currentIndex >= 0 && corePlaylist[currentIndex]) preserved = corePlaylist[currentIndex];
// Remove exact duplicates (videoId + start)
function dedupe(arr) {
var seen = {};
return arr.filter(function (x) {
var key = x.videoId + '::' + (x.start || 0);
if (seen[key]) return false;
seen[key] = true;
return true;
});
}
flattened = dedupe(flattened);
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;
}
// Bind clicks in placeholders to play in core player
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; // not a youtube link
// Find matching index
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;
}
// 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 wrapper with hash-safe behaviour ---
// Ensure initial history state stores base URL (no hash)
try {
if (!history.state || !history.state.url) {
history.replaceState({ url: location.href.split('#')[0] }, '', location.href);
} else if (history.state && history.state.url) {
// normalize to base (no hash)
history.replaceState({ url: (history.state.url || location.href).split('#')[0] }, '', location.href);
}
} catch (e) { /* ignore */ }
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;
// 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 -> allow native (hash-only)
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
if (a.search && (a.search.indexOf('action=edit') !== -1 || a.search.indexOf('action=history') !== -1)) return false;
// 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);
try { t = decodeURIComponent(t); } catch (e) { /* ignore */ }
t = t.replace(/\//g, '_');
return t;
}
function loadArticleByUrl(href, opts) {
opts = opts || {};
var baseNow = location.href.split('#')[0];
var baseTarget = href.split('#')[0];
if (baseNow === baseTarget) {
// prevent reload for hash-only or identical base
// But if opts.replaceState is true and we want to update title etc, we still avoid full parse.
return;
}
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&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; }
container.innerHTML = html;
try {
document.title = (data.parse.title || '') + ' - ' + (window.mw && mw.config && mw.config.get('wgSiteName') ? mw.config.get('wgSiteName') : document.title);
} catch (e) { /* ignore */ }
// history state uses base URL (no hash)
var base = baseTarget;
if (opts.pushState) history.pushState({ url: base }, '', href);
else if (opts.replaceState) history.replaceState({ url: base }, '', href);
// let other MW scripts run
if (window.mw && mw.hook) mw.hook('wikipage.content').fire(container);
// update playlist from new content
updatePlaylistFromDocument(container);
// scroll: if target has hash, scroll to that element; otherwise scroll top
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;
});
}
// Intercept clicks on 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: ignore hash-only navigation
window.addEventListener('popstate', function (ev) {
var baseNow = location.href.split('#')[0];
var baseState = (ev.state && ev.state.url) ? ev.state.url : (history.state && history.state.url ? history.state.url : null);
if (baseState === baseNow) {
// only hash change or same base -> let browser handle scroll
return;
}
loadArticleByUrl(location.href, { replaceState: true });
});
// Initial bootstrap
(function init() {
// seed playlist from current document
updatePlaylistFromDocument(document);
if (corePlaylist.length > 0) ensureYouTubeApiLoaded();
// ensure history normalized without hash
try { history.replaceState({ url: location.href.split('#')[0] }, '', location.href); } catch (e) {}
})();
// Expose debug helpers
window.CoreYouTubePersistentDebug = {
getPlaylist: function () { return corePlaylist.slice(); },
getCurrentIndex: function () { return currentIndex; },
playAt: playAt
};
})();