MediaWiki:Common.js: Difference between revisions
Appearance
No edit summary Tag: Reverted |
No edit summary Tag: Reverted |
||
| Line 543: | Line 543: | ||
el.removeEventListener( 'animationend', done ); | el.removeEventListener( 'animationend', done ); | ||
} ); | } ); | ||
Revision as of 21:07, 4 October 2026
/* =========================================================
1. MINECRAFT SERVER STATUS (was missing: nothing ever
replaced "Checking…", so it stayed stuck forever)
========================================================= */
mw.hook('wikipage.content').add(function ($content) {
$content.find('.mc-status').each(function () {
var box = this;
if (box.dataset.mcInit === '1') return;
box.dataset.mcInit = '1';
var host = box.dataset.host || 'smp.fedoverse.com';
var stateEl = box.querySelector('.mc-state');
var playersEl = box.querySelector('.mc-players');
var iconEl = box.querySelector('.mc-icon');
var motdEl = box.querySelector('.mc-motd');
var addrEl = box.querySelector('.mc-addr');
var mainEl = box.querySelector('.mc-main');
function getJson(url, ms) {
var ctrl = window.AbortController ? new AbortController() : null;
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, ms || 8000) : null;
return fetch(url, ctrl ? { signal: ctrl.signal } : undefined)
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(
function (v) { clearTimeout(timer); return v; },
function (e) { clearTimeout(timer); throw e; }
);
}
// Normalise the two APIs into one shape
function fromMcStatusIo(j) {
if (!j || typeof j.online !== 'boolean') throw new Error('bad response');
return {
online: j.online,
players: j.players ? j.players.online : 0,
max: j.players ? j.players.max : 0,
motd: j.motd && j.motd.clean ? String(j.motd.clean) : '',
icon: j.icon || ''
};
}
function fromMcSrvStat(j) {
if (!j || typeof j.online !== 'boolean') throw new Error('bad response');
return {
online: j.online,
players: j.players ? j.players.online : 0,
max: j.players ? j.players.max : 0,
motd: j.motd && j.motd.clean ? [].concat(j.motd.clean).join('\n') : '',
icon: j.icon || ''
};
}
function fetchStatus() {
return getJson('https://api.mcstatus.io/v2/status/java/' + encodeURIComponent(host))
.then(fromMcStatusIo)
.catch(function () {
return getJson('https://api.mcsrvstat.us/3/' + encodeURIComponent(host))
.then(fromMcSrvStat);
});
}
function setState(name) {
box.dataset.state = name;
box.classList.toggle('is-online', name === 'online');
box.classList.toggle('is-offline', name === 'offline');
box.classList.toggle('is-error', name === 'error');
}
function show(s) {
setState(s.online ? 'online' : 'offline');
if (stateEl) stateEl.textContent = s.online ? 'Online' : 'Offline';
if (playersEl) playersEl.textContent = s.online ? s.players + ' / ' + s.max : '– / –';
if (motdEl) {
motdEl.textContent = '';
if (s.online) {
s.motd.split('\n').forEach(function (line) {
line = line.trim();
if (line) {
var d = document.createElement('div');
d.textContent = line;
motdEl.appendChild(d);
}
});
}
}
if (iconEl) {
iconEl.textContent = '';
if (/^data:image\/png;base64,/i.test(s.icon)) {
var img = document.createElement('img');
img.alt = '';
img.src = s.icon;
iconEl.appendChild(img);
}
}
}
function showError() {
setState('error');
if (stateEl) stateEl.textContent = 'Status unavailable';
if (playersEl) playersEl.textContent = '– / –';
}
function refresh() {
if (!document.body.contains(box)) return; // page content was replaced
fetchStatus().then(show, showError);
}
// Click / Enter on the address copies the IP
function copyAddr() {
if (!navigator.clipboard || !navigator.clipboard.writeText || !addrEl) return;
navigator.clipboard.writeText(host).then(function () {
addrEl.textContent = 'Copied!';
setTimeout(function () { addrEl.textContent = host; }, 1200);
}, function () { /* clipboard blocked, ignore */ });
}
if (mainEl) {
mainEl.addEventListener('click', copyAddr);
mainEl.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
copyAddr();
}
});
}
refresh();
setInterval(refresh, 60000);
});
});
/* =========================================================
2. PODCAST DECK
Changes: runs inside mw.hook (DOM guaranteed), tries several
sources in order, falls through when a source returns an
empty list, and shows a link instead of staying blank.
========================================================= */
mw.hook('wikipage.content').add(function ($content) {
var root = $content.find('#pc-podcast')[0];
if (!root || root.dataset.pcInit === '1') return;
root.dataset.pcInit = '1';
var feed = root.dataset.feed, page = root.dataset.page;
var deck = root.querySelector('.pc-deck'), nav = root.querySelector('.pc-nav');
var cards = [], dots = [], active = 0;
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text) e.textContent = text;
return e;
}
function safeUrl(u) { return /^https?:\/\//i.test(u || '') ? u : ''; }
function txt(node, tag) { var n = node.getElementsByTagName(tag)[0]; return n ? n.textContent.trim() : ''; }
function plain(html) {
return new DOMParser().parseFromString(html || '', 'text/html').body.textContent.replace(/\s+/g, ' ').trim();
}
function parseDate(s) {
var d = new Date(s);
if (isNaN(d)) d = new Date(String(s).replace(' ', 'T') + 'Z');
return d;
}
function fmtDate(s) {
var d = parseDate(s);
return isNaN(d) ? '' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function fmtDur(v) {
if (!v) return '';
if (v.indexOf(':') > -1) return v.replace(/^00:/, '').replace(/^0(\d:)/, '$1');
var s = parseInt(v, 10);
if (!s) return '';
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
return (h ? h + ':' + ('0' + m).slice(-2) : m) + ':' + ('0' + sec).slice(-2);
}
function fromXml(xml) {
var doc = new DOMParser().parseFromString(xml, 'application/xml');
if (doc.getElementsByTagName('parsererror').length) throw new Error('bad xml');
var chImg = doc.getElementsByTagName('itunes:image')[0];
var chCover = chImg ? chImg.getAttribute('href') : '';
return Array.prototype.map.call(doc.getElementsByTagName('item'), function (it) {
var img = it.getElementsByTagName('itunes:image')[0];
var s = txt(it, 'itunes:season'), e = txt(it, 'itunes:episode');
return {
title: txt(it, 'title'),
link: txt(it, 'link'),
date: txt(it, 'pubDate'),
dur: fmtDur(txt(it, 'itunes:duration')),
tag: e ? (s ? 'S' + s + ' · ' : '') + 'E' + e : '',
desc: plain(txt(it, 'description')),
cover: (img && img.getAttribute('href')) || chCover
};
});
}
function fromJson(j) {
var chCover = j.feed && j.feed.image;
return (j.items || []).map(function (it) {
return {
title: it.title,
link: it.link,
date: it.pubDate,
dur: '',
tag: '',
desc: plain(it.description),
cover: it.thumbnail || chCover
};
});
}
function okText(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); }
function okJson(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }
function loadEpisodes() {
var enc = encodeURIComponent(feed);
var sources = [
function () { return fetch(feed).then(okText).then(fromXml); },
function () { return fetch('https://api.allorigins.win/raw?url=' + enc).then(okText).then(fromXml); },
function () { return fetch('https://api.rss2json.com/v1/api.json?rss_url=' + enc).then(okJson).then(fromJson); },
function () { return fetch('https://corsproxy.io/?url=' + enc).then(okText).then(fromXml); }
];
// Try each source in turn; an error OR an empty list moves on to the next
return sources.reduce(function (p, src) {
return p.catch(function () {
return src().then(function (list) {
list = list.filter(function (e) { return e.title; });
if (!list.length) throw new Error('empty');
return list;
});
});
}, Promise.reject(new Error('start')));
}
function buildCard(ep) {
var c = el('article', 'pc-card');
var cover = el('div', 'pc-cover');
if (safeUrl(ep.cover)) {
var img = el('img');
img.alt = '';
img.loading = 'lazy';
img.onerror = function () { cover.textContent = '🎙️'; };
img.src = ep.cover;
cover.appendChild(img);
} else {
cover.textContent = '🎙️';
}
var body = el('div', 'pc-body');
var meta = el('div', 'pc-meta');
if (ep.tag) meta.appendChild(el('span', 'pc-pill pc-pill-strong', ep.tag));
var ds = fmtDate(ep.date);
if (ds) meta.appendChild(el('span', 'pc-pill', ds));
if (ep.dur) meta.appendChild(el('span', 'pc-pill', ep.dur));
if (meta.children.length) body.appendChild(meta);
var href = safeUrl(ep.link) || safeUrl(page);
var title = el('div', 'pc-title');
title.setAttribute('role', 'heading');
title.setAttribute('aria-level', '3');
var a = el('a', '', ep.title || 'Untitled episode');
if (href) {
a.href = href;
a.target = '_blank';
a.rel = 'noopener';
}
title.appendChild(a);
body.appendChild(title);
body.appendChild(el('p', 'pc-desc', ep.desc));
c.appendChild(cover);
c.appendChild(body);
if (href) {
var o = el('div', 'mc-open');
var oa = el('a');
oa.href = href;
oa.target = '_blank';
oa.rel = 'noopener';
oa.title = 'Open episode';
oa.setAttribute('aria-label', 'Open episode');
oa.appendChild(el('span', 'mc-open-icon'));
o.appendChild(oa);
c.appendChild(o);
}
return c;
}
function layout() {
var n = cards.length;
if (!n) return;
cards.forEach(function (c, i) {
var pos = (i - active + n) % n;
c.dataset.pos = pos;
c.inert = pos !== 0;
c.setAttribute('aria-hidden', pos ? 'true' : 'false');
});
dots.forEach(function (d, i) {
d.setAttribute('aria-current', i === active ? 'true' : 'false');
});
}
function go(delta) {
active = (active + delta + cards.length) % cards.length;
layout();
}
function arrow(label, path, delta) {
var b = el('button', 'pc-btn');
b.type = 'button';
b.setAttribute('aria-label', label);
b.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="' + path + '"/></svg>';
b.addEventListener('click', function () { go(delta); });
return b;
}
function render(list) {
list = list
.sort(function (a, b) { return parseDate(b.date) - parseDate(a.date); })
.slice(0, 3);
deck.innerHTML = '';
nav.innerHTML = '';
cards = [];
dots = [];
active = 0;
list.forEach(function (ep) {
var card = buildCard(ep);
cards.push(card);
deck.appendChild(card);
});
for (var k = cards.length; k < 3; k++) {
var g = el('div', 'pc-card pc-ghost');
g.dataset.pos = k;
g.setAttribute('aria-hidden', 'true');
deck.appendChild(g);
}
root.classList.add('is-loaded');
if (cards.length > 1) {
var wrap = el('div', 'pc-dots');
cards.forEach(function (_, i) {
var d = el('button', 'pc-dot');
d.type = 'button';
d.setAttribute('aria-label', 'Episode ' + (i + 1));
d.addEventListener('click', function () {
active = i;
layout();
});
dots.push(d);
wrap.appendChild(d);
});
nav.appendChild(arrow('Previous episode', 'm15 18-6-6 6-6', -1));
nav.appendChild(wrap);
nav.appendChild(arrow('Next episode', 'm9 18 6-6-6-6', 1));
var sx = null;
deck.addEventListener('pointerdown', function (e) { sx = e.clientX; });
deck.addEventListener('pointerup', function (e) {
if (sx === null) return;
var dx = e.clientX - sx;
sx = null;
if (Math.abs(dx) > 45) go(dx < 0 ? 1 : -1);
});
}
layout();
}
function renderError() {
root.classList.add('is-error');
deck.innerHTML = '';
var p = el('p', 'pc-error', 'Couldn\u2019t load episodes right now. ');
var href = safeUrl(page);
if (href) {
var a = el('a', '', 'Listen on RSS.com');
a.href = href;
a.target = '_blank';
a.rel = 'noopener';
p.appendChild(a);
}
deck.appendChild(p);
}
loadEpisodes().then(render, renderError);
});
/* =========================================================
3. NATIVE AUDIO PLAYER (unchanged)
========================================================= */
mw.hook('wikipage.content').add(function ($content) {
$content.find('.infobox-native-audio').each(function () {
if (this.dataset.audioInitialized === '1') {
return;
}
const src = this.dataset.src;
if (!src) {
return;
}
const audio = document.createElement('audio');
audio.preload = 'metadata';
audio.src = src;
const player = document.createElement('div');
player.className = 'custom-audio-player';
player.innerHTML = `
<button class="audio-play" type="button" aria-label="Play">▶</button>
<div class="audio-progress-container">
<input class="audio-progress" type="range" min="0" max="100" value="0">
</div>
<span class="audio-time">0:00 / 0:00</span>
<button class="audio-volume" type="button" aria-label="Mute">🔊</button>
`;
const playButton = player.querySelector('.audio-play');
const progress = player.querySelector('.audio-progress');
const time = player.querySelector('.audio-time');
const volumeButton = player.querySelector('.audio-volume');
function formatTime(seconds) {
if (!isFinite(seconds)) {
return '0:00';
}
const minutes = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60)
.toString()
.padStart(2, '0');
return `${minutes}:${secs}`;
}
function updateTime() {
progress.value = audio.duration
? (audio.currentTime / audio.duration) * 100
: 0;
time.textContent =
`${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
}
playButton.addEventListener('click', function () {
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
});
audio.addEventListener('play', function () {
playButton.textContent = '❚❚';
playButton.setAttribute('aria-label', 'Pause');
});
audio.addEventListener('pause', function () {
playButton.textContent = '▶';
playButton.setAttribute('aria-label', 'Play');
});
audio.addEventListener('timeupdate', updateTime);
audio.addEventListener('loadedmetadata', updateTime);
progress.addEventListener('input', function () {
if (audio.duration) {
audio.currentTime =
(progress.value / 100) * audio.duration;
}
});
volumeButton.addEventListener('click', function () {
audio.muted = !audio.muted;
volumeButton.textContent = audio.muted ? '🔇' : '🔊';
});
audio.addEventListener('ended', function () {
playButton.textContent = '▶';
progress.value = 0;
});
this.replaceChildren(audio, player);
this.dataset.audioInitialized = '1';
});
});
document.addEventListener( 'pointermove', function ( e ) {
if ( e.pointerType !== 'mouse' || reduce.matches ) { return; }
var el = e.target.closest && e.target.closest( SEL );
if ( !el ) { return; }
var r = el.getBoundingClientRect();
var x = ( e.clientX - r.left ) / r.width - 0.5; // -0.5 .. 0.5
var y = ( e.clientY - r.top ) / r.height - 0.5;
el.classList.add( 'is-hovering' );
el.style.setProperty( '--ry', ( x * MAX_TILT * 2 ) + 'deg' );
el.style.setProperty( '--rx', ( -y * MAX_TILT * 2 ) + 'deg' );
} );
document.addEventListener( 'pointerout', function ( e ) {
var el = e.target.closest && e.target.closest( SEL );
if ( !el || el.contains( e.relatedTarget ) ) { return; }
el.classList.remove( 'is-hovering' );
el.style.setProperty( '--rx', '0deg' );
el.style.setProperty( '--ry', '0deg' );
} );
document.addEventListener( 'click', function ( e ) {
var el = e.target.closest && e.target.closest( SEL );
if ( !el || reduce.matches ) { return; }
// restart the animation even on rapid repeat clicks
el.classList.remove( 'is-pressed' );
void el.offsetWidth;
el.classList.add( 'is-pressed' );
el.addEventListener( 'animationend', function done() {
el.classList.remove( 'is-pressed' );
el.removeEventListener( 'animationend', done );
} );