User:Magent/Common.js proposal: Difference between revisions
Exact source of the proposed MediaWiki:Common.js (pickipedia#111), meant to be copied from ?action=raw (via create-page on MediaWiki MCP Server) |
Quality selector (Auto / 1080p / 720p / 360p / Audio only), and prefer hls.js over native HLS so the ladder is visible |
||
| Line 299: | Line 299: | ||
return false; | return false; | ||
} | } | ||
} | |||
// Name a rendition by the short side of its picture: an upright phone | |||
// video published at 1080x1920 is "1080p" exactly as a landscape one is at | |||
// 1920x1080. Levels carrying no picture at all are the audio-only stream. | |||
function levelLabel(level) { | |||
var w = level && level.width, h = level && level.height; | |||
if (!w || !h) { | |||
return 'Audio only'; | |||
} | |||
return Math.min(w, h) + 'p'; | |||
} | |||
// Remember the viewer's choice across videos and visits. Someone on a | |||
// phone tether picks "Audio only" once and means it for the evening, not | |||
// for one embed. Storage can throw (private windows), and a remembered | |||
// rendition may not exist on the next video, so every read is guarded. | |||
var QUALITY_KEY = 'pickipedia-video-quality'; | |||
function rememberedQuality() { | |||
try { | |||
return window.localStorage.getItem(QUALITY_KEY) || 'auto'; | |||
} catch (e) { | |||
return 'auto'; | |||
} | |||
} | |||
function rememberQuality(value) { | |||
try { | |||
window.localStorage.setItem(QUALITY_KEY, value); | |||
} catch (e) { | |||
// Nothing to do — the selector still works for this page view. | |||
} | |||
} | |||
// A row of buttons under the video: Auto, then each rendition largest | |||
// first, then Audio only. hls.js chooses by bandwidth on its own; this is | |||
// for the times a person wants to overrule it — to save data deliberately, | |||
// or to stop it switching around mid-tune. | |||
function addQualitySelector(container, video, hls) { | |||
var levels = hls.levels || []; | |||
if (levels.length < 2) { | |||
return; // nothing to choose between | |||
} | |||
var bar = document.createElement('div'); | |||
bar.className = 'hls-quality-bar'; | |||
bar.style.cssText = 'display:flex; flex-wrap:wrap; gap:4px; align-items:center;' | |||
+ 'margin-top:6px; font-size:0.85em;'; | |||
var label = document.createElement('span'); | |||
label.textContent = 'Quality:'; | |||
label.style.cssText = 'opacity:0.7; margin-right:2px;'; | |||
bar.appendChild(label); | |||
// Largest first, so the order reads the way people expect. | |||
var order = levels.map(function(level, index) { | |||
return { index: index, level: level }; | |||
}).sort(function(a, b) { | |||
return ((b.level.height || 0) * (b.level.width || 0)) | |||
- ((a.level.height || 0) * (a.level.width || 0)); | |||
}); | |||
var choices = [{ value: 'auto', text: 'Auto', index: -1 }]; | |||
order.forEach(function(entry) { | |||
choices.push({ | |||
value: levelLabel(entry.level), | |||
text: levelLabel(entry.level), | |||
index: entry.index | |||
}); | |||
}); | |||
var buttons = []; | |||
function paint(activeValue) { | |||
buttons.forEach(function(button) { | |||
var on = button.getAttribute('data-value') === activeValue; | |||
button.style.fontWeight = on ? '700' : '400'; | |||
button.style.background = on ? '#eaf3ff' : '#fff'; | |||
button.style.borderColor = on ? '#36c' : '#a2a9b1'; | |||
button.setAttribute('aria-pressed', on ? 'true' : 'false'); | |||
}); | |||
} | |||
choices.forEach(function(choice) { | |||
var button = document.createElement('button'); | |||
button.type = 'button'; | |||
button.textContent = choice.text; | |||
button.setAttribute('data-value', choice.value); | |||
button.style.cssText = 'padding:2px 8px; cursor:pointer; border:1px solid #a2a9b1;' | |||
+ 'border-radius:2px; background:#fff; font-size:inherit;'; | |||
button.addEventListener('click', function() { | |||
// -1 is hls.js's "pick for me". Setting currentLevel switches | |||
// at the next segment rather than reloading the video. | |||
hls.currentLevel = choice.index; | |||
rememberQuality(choice.value); | |||
paint(choice.value); | |||
}); | |||
buttons.push(button); | |||
bar.appendChild(button); | |||
}); | |||
container.appendChild(bar); | |||
// Apply what this viewer chose last time, if this video has it. | |||
var wanted = rememberedQuality(); | |||
var match = choices.filter(function(choice) { | |||
return choice.value === wanted; | |||
})[0]; | |||
if (match && match.index !== -1) { | |||
hls.currentLevel = match.index; | |||
} | |||
paint(match ? match.value : 'auto'); | |||
} | } | ||
| Line 373: | Line 486: | ||
container.appendChild(video); | container.appendChild(video); | ||
// hls.js first, native HLS second. | |||
// | |||
// The order used to be the other way round, on the assumption that | |||
// only Safari claims to play HLS natively. Chrome answers "maybe" to | |||
// that question too and does play these streams — so Chrome took the | |||
// native path, where the renditions are invisible to us: no quality | |||
// selector, and an error we can only guess at. Through hls.js we can | |||
// see the ladder, offer a choice, and tell a decoder failure apart | |||
// from a missing playlist. | |||
// | |||
// Native remains the fallback for anything hls.js cannot drive | |||
// (no Media Source Extensions), which includes iOS Safari. | |||
if (typeof Hls !== 'undefined' && Hls.isSupported()) { | |||
var hls = new Hls(); | var hls = new Hls(); | ||
hls.loadSource(hlsSrc); | hls.loadSource(hlsSrc); | ||
hls.attachMedia(video); | hls.attachMedia(video); | ||
// The renditions are only known once the master playlist is | |||
// parsed, so the selector is built then rather than up front. | |||
hls.on(Hls.Events.MANIFEST_PARSED, function() { | |||
try { | |||
addQualitySelector(container, video, hls); | |||
} catch (e) { | |||
// A broken selector must never cost anyone the video. | |||
} | |||
}); | |||
hls.on(Hls.Events.ERROR, function(event, data) { | hls.on(Hls.Events.ERROR, function(event, data) { | ||
if (data.fatal) { | if (data.fatal) { | ||
| Line 406: | Line 526: | ||
fallbackToDirectVideo(container, cid, width, maxWidth, startSeconds); | fallbackToDirectVideo(container, cid, width, maxWidth, startSeconds); | ||
} | } | ||
}); | |||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { | |||
// Native HLS. The browser picks the rendition itself, so there is | |||
// nothing to offer a selector over. | |||
// | |||
// If the browser has no AV1 decoder there is no point chasing the | |||
// CID as a raw file: it is an HLS directory, that request fails | |||
// too, and the reader is told the content is missing when the | |||
// real answer is "this browser cannot decode it". | |||
video.src = hlsSrc; | |||
video.addEventListener('error', function() { | |||
if (!canDecodeAV1()) { | |||
showPlaybackError(container, cid); | |||
return; | |||
} | |||
fallbackToDirectVideo(container, cid, width, maxWidth, startSeconds); | |||
}); | }); | ||
} else { | } else { | ||
Revision as of 17:39, 17 September 2026
/**
- Blue Railroad Submission - Date to Block Height Converter
- Adds a datepicker that converts dates to Ethereum block heights
- Also shows time ago for news templates
- /
(function() {
'use strict';
// Only run on the Blue Railroad Submission form
if (!document.querySelector('input[name="Blue Railroad Submission[block_height]"]')) {
return;
}
// Reference point: known block and timestamp
// Post-merge average block time is ~12.12 seconds
const AVG_BLOCK_TIME = 12.12;
// Get current block from footer (format: "24,328,442")
function getCurrentBlockFromFooter() {
const footerLink = document.querySelector('a[href*="etherscan.io/block/"]');
if (footerLink) {
const match = footerLink.href.match(/block\/(\d+)/);
if (match) return parseInt(match[1]);
}
return null;
}
// Calculate block height from date
function dateToBlockHeight(targetDate, refBlock, refTimestamp) {
const targetTimestamp = targetDate.getTime() / 1000;
const secondsDiff = refTimestamp - targetTimestamp;
const blocksDiff = Math.round(secondsDiff / AVG_BLOCK_TIME);
return refBlock - blocksDiff;
}
// Add the datepicker UI
function addDatePicker() {
const blockInput = document.querySelector('input[name="Blue Railroad Submission[block_height]"]');
if (!blockInput) return;
const container = document.createElement('div');
container.style.marginTop = '8px';
container.innerHTML = `
<label style="display: block; margin-bottom: 4px; font-size: 0.9em;">
Or pick a date/time:
</label>
<input type="datetime-local" id="br-datepicker" style="padding: 4px; margin-right: 8px;">
<button type="button" id="br-convert-btn" style="padding: 4px 12px; cursor: pointer;">
Convert to Block
</button>
`;
blockInput.parentNode.appendChild(container);
const datePicker = document.getElementById('br-datepicker');
const convertBtn = document.getElementById('br-convert-btn');
const status = document.getElementById('br-status');
// Set default to now
const now = new Date();
datePicker.value = now.toISOString().slice(0, 16);
convertBtn.addEventListener('click', function() {
const selectedDate = new Date(datePicker.value);
if (isNaN(selectedDate.getTime())) {
status.textContent = 'Invalid date';
status.style.color = 'red';
return;
}
const currentBlock = getCurrentBlockFromFooter();
if (!currentBlock) {
status.textContent = 'Could not find reference block';
status.style.color = 'red';
return;
}
const currentTimestamp = Date.now() / 1000;
const estimatedBlock = dateToBlockHeight(selectedDate, currentBlock, currentTimestamp);
if (estimatedBlock > currentBlock) {
status.textContent = 'Date is in the future!';
status.style.color = 'orange';
} else {
status.textContent = '~estimated';
status.style.color = 'green';
}
blockInput.value = estimatedBlock;
});
}
// Run when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', addDatePicker);
} else {
addDatePicker();
}
})();
/**
* TimeAgo — shows "X blocks ago" for transcluded templates. * * Usage in wikitext: * * * Fetches the last revision timestamp from the API and renders * a block-based relative time string using Ethereum block heights. */
(function() {
'use strict';
// Post-merge average block time is ~12.12 seconds var AVG_BLOCK_TIME = 12.12;
var spans = document.querySelectorAll('.timeago[data-lastmod-page]');
if (!spans.length) return;
// Format number with commas (e.g., 1234567 -> "1,234,567")
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
// Collect unique page titles
var titles = [];
spans.forEach(function(span) {
var t = span.getAttribute('data-lastmod-page');
if (t && titles.indexOf(t) === -1) titles.push(t);
});
// Batch query the API (up to 50 titles per request)
var api = mw.config.get('wgScriptPath') + '/api.php';
var url = api + '?action=query&prop=revisions&rvprop=timestamp&format=json&titles=' +
encodeURIComponent(titles.join('|'));
fetch(url).then(function(r) { return r.json(); }).then(function(data) {
var pages = data.query && data.query.pages || {};
var timestamps = {};
Object.keys(pages).forEach(function(id) {
var page = pages[id];
if (page.revisions && page.revisions[0]) {
timestamps[page.title] = new Date(page.revisions[0].timestamp);
}
});
spans.forEach(function(span) {
var title = span.getAttribute('data-lastmod-page');
var ts = timestamps[title];
if (!ts) {
span.textContent = ;
return;
}
span.textContent = formatBlocksAgo(ts);
span.title = ts.toLocaleString();
});
});
function formatBlocksAgo(date) {
var secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
var blocksAgo = Math.round(secondsAgo / AVG_BLOCK_TIME);
if (blocksAgo < 1) {
return 'this block';
} else if (blocksAgo === 1) {
return '1 block ago';
} else {
return formatNumber(blocksAgo) + ' blocks ago';
}
}
})();
/**
* HLS Video Player - Initializes HLS.js for IPFS-hosted videos * * Usage in wikitext (via Template:HLSVideo):
*
* * The gadget loads hls.js and initializes players for any element * with the hls-video-player class and a data-cid attribute. * * Supports both HLS streams (CID/master.m3u8) and raw video files. * Tries HLS first; if the manifest 404s, falls back to direct playback. */
(function() {
'use strict';
var IPFS_GATEWAY = 'https://ipfs.delivery-kid.cryptograss.live/ipfs'; var HLS_JS_URL = 'https://cdn.jsdelivr.net/npm/hls.js@latest'; var hlsLoadPromise = null;
// CIDv1 (bafy...) is Base32 lowercase, but MediaWiki capitalizes page titles.
// CIDv0 (Qm...) is Base58 case-sensitive — must not be lowercased.
function normalizeCid(cid) {
if (cid && cid.substring(0, 4) === 'Bafy') {
return cid.toLowerCase();
}
return cid;
}
function loadHls() {
if (typeof Hls !== 'undefined') {
return Promise.resolve();
}
if (hlsLoadPromise) {
return hlsLoadPromise;
}
hlsLoadPromise = new Promise(function(resolve) {
var script = document.createElement('script');
script.src = HLS_JS_URL;
script.onload = resolve;
document.head.appendChild(script);
});
return hlsLoadPromise;
}
// Accepts raw seconds (872), "14:32", or "1:02:03". Anything unparseable
// means "start at the beginning" rather than throwing — a typo in a
// citation should still play the video.
function parseTimecode(raw) {
if (!raw) { return 0; }
var parts = String(raw).trim().split(':');
for (var i = 0; i < parts.length; i++) {
if (parts[i] === || isNaN(Number(parts[i]))) { return 0; }
}
return parts.reduce(function (acc, p) { return acc * 60 + Number(p); }, 0);
}
// Seek once the browser knows how long the video is.
//
// All three playback paths below — Safari's native HLS, hls.js, and the
// direct-file fallback — end up attached to a real <video>, so hanging one
// listener on the element covers every case. Seeking earlier than
// loadedmetadata is unreliable: with hls.js the manifest may not be parsed
// yet and the seek is silently dropped.
function seekWhenReady(video, seconds) {
if (!seconds) { return; }
video.addEventListener('loadedmetadata', function onMeta() {
video.removeEventListener('loadedmetadata', onMeta);
// A citation pointing past the end should land on the last frame
// rather than being ignored outright.
video.currentTime = isFinite(video.duration)
? Math.min(seconds, video.duration)
: seconds;
});
}
// A #t= fragment in the URL seeks the player too, which is what makes a // ▶ 14:32 citation land the reader on the assertion // rather than at 0:00 of a forty-minute set. function timecodeFromHash() { var m = /[#&]t=([0-9:]+)/.exec(window.location.hash || ); return m ? parseTimecode(m[1]) : 0; }
function initPlayers() {
var containers = document.querySelectorAll('.hls-video-player[data-cid]:not([data-initialized])');
if (!containers.length) return;
// Mark ALL containers immediately to prevent race conditions
containers.forEach(function(c) {
c.setAttribute('data-initialized', 'true');
});
// Then load hls.js and initialize
loadHls().then(function() {
containers.forEach(initPlayer);
});
}
function createVideoElement(width, maxWidth) {
var video = document.createElement('video');
video.controls = true;
video.playsInline = true;
video.style.width = width;
video.style.maxWidth = maxWidth;
video.style.backgroundColor = '#000';
video.style.display = 'block';
return video;
}
// Can this browser actually decode AV1? Apple ships no software AV1
// decoder, so Safari plays it only on M3/A17-and-later hardware; Chrome
// and Firefox bundle dav1d and manage it anywhere. Everything published
// through delivery-kid since Sept 2026 is AV1, so "can't play" is far
// more often a decoder gap than a missing file.
function canDecodeAV1() {
var type = 'video/mp4; codecs="av01.0.05M.08"';
try {
if (window.MediaSource && window.MediaSource.isTypeSupported) {
return window.MediaSource.isTypeSupported(type);
}
return document.createElement('video').canPlayType(type) !== ;
} catch (e) {
return false;
}
}
// Name a rendition by the short side of its picture: an upright phone
// video published at 1080x1920 is "1080p" exactly as a landscape one is at
// 1920x1080. Levels carrying no picture at all are the audio-only stream.
function levelLabel(level) {
var w = level && level.width, h = level && level.height;
if (!w || !h) {
return 'Audio only';
}
return Math.min(w, h) + 'p';
}
// Remember the viewer's choice across videos and visits. Someone on a // phone tether picks "Audio only" once and means it for the evening, not // for one embed. Storage can throw (private windows), and a remembered // rendition may not exist on the next video, so every read is guarded. var QUALITY_KEY = 'pickipedia-video-quality';
function rememberedQuality() {
try {
return window.localStorage.getItem(QUALITY_KEY) || 'auto';
} catch (e) {
return 'auto';
}
}
function rememberQuality(value) {
try {
window.localStorage.setItem(QUALITY_KEY, value);
} catch (e) {
// Nothing to do — the selector still works for this page view.
}
}
// A row of buttons under the video: Auto, then each rendition largest
// first, then Audio only. hls.js chooses by bandwidth on its own; this is
// for the times a person wants to overrule it — to save data deliberately,
// or to stop it switching around mid-tune.
function addQualitySelector(container, video, hls) {
var levels = hls.levels || [];
if (levels.length < 2) {
return; // nothing to choose between
}
var bar = document.createElement('div');
bar.className = 'hls-quality-bar';
bar.style.cssText = 'display:flex; flex-wrap:wrap; gap:4px; align-items:center;'
+ 'margin-top:6px; font-size:0.85em;';
var label = document.createElement('span');
label.textContent = 'Quality:';
label.style.cssText = 'opacity:0.7; margin-right:2px;';
bar.appendChild(label);
// Largest first, so the order reads the way people expect.
var order = levels.map(function(level, index) {
return { index: index, level: level };
}).sort(function(a, b) {
return ((b.level.height || 0) * (b.level.width || 0))
- ((a.level.height || 0) * (a.level.width || 0));
});
var choices = [{ value: 'auto', text: 'Auto', index: -1 }];
order.forEach(function(entry) {
choices.push({
value: levelLabel(entry.level),
text: levelLabel(entry.level),
index: entry.index
});
});
var buttons = [];
function paint(activeValue) {
buttons.forEach(function(button) {
var on = button.getAttribute('data-value') === activeValue;
button.style.fontWeight = on ? '700' : '400';
button.style.background = on ? '#eaf3ff' : '#fff';
button.style.borderColor = on ? '#36c' : '#a2a9b1';
button.setAttribute('aria-pressed', on ? 'true' : 'false');
});
}
choices.forEach(function(choice) {
var button = document.createElement('button');
button.type = 'button';
button.textContent = choice.text;
button.setAttribute('data-value', choice.value);
button.style.cssText = 'padding:2px 8px; cursor:pointer; border:1px solid #a2a9b1;'
+ 'border-radius:2px; background:#fff; font-size:inherit;';
button.addEventListener('click', function() {
// -1 is hls.js's "pick for me". Setting currentLevel switches
// at the next segment rather than reloading the video.
hls.currentLevel = choice.index;
rememberQuality(choice.value);
paint(choice.value);
});
buttons.push(button);
bar.appendChild(button);
});
container.appendChild(bar);
// Apply what this viewer chose last time, if this video has it.
var wanted = rememberedQuality();
var match = choices.filter(function(choice) {
return choice.value === wanted;
})[0];
if (match && match.index !== -1) {
hls.currentLevel = match.index;
}
paint(match ? match.value : 'auto');
}
// One honest failure message. The old text blamed IPFS unconditionally,
// which sent people debugging storage for what was usually a codec
// problem — and said "may not be available" about content that was
// demonstrably being served.
function showPlaybackError(container, cid) {
var url = IPFS_GATEWAY + '/' + normalizeCid(cid) + '/master.m3u8';
var html;
if (!canDecodeAV1()) {
html = '
'
+ 'This browser cannot play this video.
'
+ 'It is encoded in AV1 — a royalty-free format, which is why we use it. '
+ 'Apple ships no software AV1 decoder, so Safari plays it only on M3 or newer Macs '
+ 'and iPhone 15 Pro or newer.
'
+ 'Firefox and Chrome play it on any desktop, including older Intel Macs. '
+ 'Or download it and play it in VLC.
'
+ '<a href="' + url + '" rel="nofollow">' + url + '</a>'
+ '
';
} else {
html = '
'
+ 'Could not load this video.
'
+ 'Your browser can decode AV1, so this is more likely a fetch or gateway problem '
+ 'than a codec one. Try the stream directly — if this link works, the content is '
+ 'present and the fault is in playback rather than availability:
'
+ '<a href="' + url + '" rel="nofollow">' + url + '</a>'
+ '
';
}
container.innerHTML = html;
}
function fallbackToDirectVideo(container, cid, width, maxWidth, startSeconds) {
// CID is a raw video file, not an HLS stream — play directly
var directUrl = IPFS_GATEWAY + '/' + normalizeCid(cid);
container.innerHTML = ;
var video = createVideoElement(width, maxWidth);
video.src = directUrl;
video.addEventListener('error', function() {
showPlaybackError(container, cid);
});
seekWhenReady(video, startSeconds);
container.appendChild(video);
}
function initPlayer(container) {
var cid = container.getAttribute('data-cid');
var normalCid = normalizeCid(cid);
var width = container.getAttribute('data-width') || '100%';
var maxWidth = container.getAttribute('data-max-width') || '800px';
// data-start wins over the URL fragment: an embed that explicitly asks
// for a moment means it, whereas the fragment is a property of how the
// reader arrived and should not override the page's own intent.
var startSeconds = parseTimecode(container.getAttribute('data-start'))
|| timecodeFromHash();
var hlsSrc = IPFS_GATEWAY + '/' + normalCid + '/master.m3u8';
var video = createVideoElement(width, maxWidth);
// A still from the video, pinned alongside it under the same CID, so
// the player shows the thing rather than a black rectangle before
// anyone presses play. It is a JPEG, which means it also renders on
// browsers that cannot decode the AV1 behind it — a Safari user
// without hardware AV1 at least sees what they are being offered.
//
// Releases pinned before posters existed simply have no poster.jpg.
// A video element whose poster 404s falls back to its normal empty
// state rather than showing a broken image, so this is safe to set
// unconditionally.
video.poster = IPFS_GATEWAY + '/' + normalCid + '/poster.jpg';
seekWhenReady(video, startSeconds);
container.appendChild(video);
// hls.js first, native HLS second.
//
// The order used to be the other way round, on the assumption that
// only Safari claims to play HLS natively. Chrome answers "maybe" to
// that question too and does play these streams — so Chrome took the
// native path, where the renditions are invisible to us: no quality
// selector, and an error we can only guess at. Through hls.js we can
// see the ladder, offer a choice, and tell a decoder failure apart
// from a missing playlist.
//
// Native remains the fallback for anything hls.js cannot drive
// (no Media Source Extensions), which includes iOS Safari.
if (typeof Hls !== 'undefined' && Hls.isSupported()) {
var hls = new Hls();
hls.loadSource(hlsSrc);
hls.attachMedia(video);
// The renditions are only known once the master playlist is
// parsed, so the selector is built then rather than up front.
hls.on(Hls.Events.MANIFEST_PARSED, function() {
try {
addQualitySelector(container, video, hls);
} catch (e) {
// A broken selector must never cost anyone the video.
}
});
hls.on(Hls.Events.ERROR, function(event, data) {
if (data.fatal) {
hls.destroy();
// A media-type fatal on a browser without AV1 is a decoder
// gap, not a missing playlist. Say so rather than falling
// through to a direct-video attempt that cannot succeed.
var mediaFatal = data.type === Hls.ErrorTypes.MEDIA_ERROR;
if (mediaFatal && !canDecodeAV1()) {
showPlaybackError(container, cid);
return;
}
// Otherwise the usual cause is that this CID is a plain
// video file rather than an HLS directory.
fallbackToDirectVideo(container, cid, width, maxWidth, startSeconds);
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS. The browser picks the rendition itself, so there is
// nothing to offer a selector over.
//
// If the browser has no AV1 decoder there is no point chasing the
// CID as a raw file: it is an HLS directory, that request fails
// too, and the reader is told the content is missing when the
// real answer is "this browser cannot decode it".
video.src = hlsSrc;
video.addEventListener('error', function() {
if (!canDecodeAV1()) {
showPlaybackError(container, cid);
return;
}
fallbackToDirectVideo(container, cid, width, maxWidth, startSeconds);
});
} else {
// No HLS support — try direct video
fallbackToDirectVideo(container, cid, width, maxWidth, startSeconds);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPlayers);
} else {
initPlayers();
}
if (typeof mw !== 'undefined' && mw.hook) {
mw.hook('wikipage.content').add(function($content) {
initPlayers();
});
}
})(); /**
* Instrument form — validation + page name computation (v24772700) */
(function() {
'use strict';
console.log('PickiPedia Common.js instrument module v24772700 loaded');
mw.hook('pf.formValidation').add(function(args) {
var nickname = document.querySelector('input[name="Instrument[nickname]"]');
if (!nickname) return;
var make = document.querySelector('input[name="Instrument[make]"]');
var model = document.querySelector('input[name="Instrument[model]"]');
var serial = document.querySelector('input[name="Instrument[serial]"]');
var nickVal = nickname.value.trim();
var makeVal = make ? make.value.trim() : ;
var modelVal = model ? model.value.trim() : ;
var serialVal = serial ? serial.value.trim() : ;
var hasNickname = nickVal !== ;
var hasMakerComplete = makeVal !== && modelVal !== && serialVal !== ;
if (!hasNickname && !hasMakerComplete) {
args.numErrors += 1;
if (makeVal !== || modelVal !== || serialVal !== ) {
alert('Maker identification requires all three: make, model, and serial number. Or provide a nickname instead.');
} else {
alert('Please provide either a nickname or maker identification (make, model, and serial number).');
}
return;
}
// If no nickname, fill it from make/model/serial for page naming
if (!hasNickname) {
nickname.value = makeVal + ' ' + modelVal + ' ' + serialVal;
}
});
})();
/* ============================================================
* Pickipedia — User-subpage zine/Substack styling * * Layout: * - corner logo (absolute-positioned upper-left of viewport, * OUTSIDE all wiki wrappers — does not push content down) * - non-sticky topbar (just nav) at top of body * - hero block with title (top), then USER:Username kicker below, * optional dek + meta * - light/dark mode toggle (persisted in localStorage) * * Variant from HTML comment marker `pickipedia:variant=letter|masthead` * or category membership; default letter. * ============================================================ */
(function () {
'use strict';
if (typeof mw === 'undefined' || !mw.config) return;
var nsNumber = mw.config.get('wgNamespaceNumber');
var pageName = mw.config.get('wgPageName') || ;
var title = mw.config.get('wgTitle') || ;
var action = mw.config.get('wgAction') || 'view';
if (nsNumber !== 2) return;
if (action !== 'view') return;
if (title.indexOf('/') === -1) return;
var slash = title.indexOf('/');
var username = title.slice(0, slash).replace(/_/g, ' ');
var subpage = title.slice(slash + 1).replace(/_/g, ' ');
var body = document.body;
body.classList.add('pickipedia-userpost');
var variant = 'letter';
var content = document.querySelector('.mw-parser-output');
if (content) {
var html = content.innerHTML || ;
var m = html.match(/pickipedia:variant\s*=\s*(letter|masthead)/i);
if (m) variant = m[1].toLowerCase();
}
var cats = mw.config.get('wgCategories') || [];
if (cats.indexOf('Masthead style') !== -1) variant = 'masthead';
if (cats.indexOf('Letter style') !== -1) variant = 'letter';
body.classList.add('pickipedia-variant-' + variant);
var STORAGE_KEY = 'pickipedia-mode';
var saved = null;
try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) {}
if (saved === 'dark') body.classList.add('pickipedia-dark');
var LOGO_URL = '/images/thumb/8/80/Pickipedia-quarter-transparent.png/400px-Pickipedia-quarter-transparent.png';
// ---------- Corner logo: stand-alone body child, absolute-positioned. ----------
var cornerLogo = document.createElement('a');
cornerLogo.className = 'pp-corner-logo';
cornerLogo.href = '/wiki/Main_Page';
cornerLogo.title = 'PickiPedia home';
cornerLogo.innerHTML = '<img src="' + LOGO_URL + '" alt="PickiPedia">';
document.body.insertBefore(cornerLogo, document.body.firstChild);
// ---------- Topbar (nav only) ----------
var topbar = document.createElement('header');
topbar.className = 'pickipedia-topbar';
topbar.innerHTML =
'<nav class="pickipedia-topbar-nav">' +
'<a href="' + mw.util.getUrl(pageName, { action: 'edit' }) + '">edit</a>' +
'<a href="' + mw.util.getUrl(pageName, { action: 'history' }) + '">history</a>' +
'<a href="' + mw.util.getUrl('Talk:' + pageName) + '">talk</a>' +
'</nav>';
document.body.insertBefore(topbar, cornerLogo.nextSibling);
// ---------- Hero ----------
if (content) {
var dek = ;
var dekEl = content.querySelector('.pp-dek-source');
if (dekEl) {
dek = dekEl.innerHTML;
dekEl.remove();
}
var kickerHtml = '<a href="' + mw.util.getUrl('User:' + username.replace(/ /g, '_')) +
'">User:' + escapeHtml(username) + '</a>';
var kickerEl = content.querySelector('.pp-kicker-source');
if (kickerEl) {
kickerHtml = escapeHtml(kickerEl.textContent.trim());
kickerEl.remove();
}
var lastMod = ;
var lastModEl = document.getElementById('footer-info-lastmod');
if (lastModEl) lastMod = lastModEl.textContent.replace(/^\s*This page was last (edited|modified) on\s*/i, ).trim();
var hero = document.createElement('header');
hero.className = 'pickipedia-hero';
if (variant === 'masthead') {
hero.innerHTML =
'
' + '
' + escapeHtml(subpage) + '
' + (dek ? '' + dek + '
' : ) + '';
} else {
// Letter variant: title first, then kicker, then optional dek/meta.
hero.innerHTML =
'
' + escapeHtml(subpage) + '
' + '
' + (dek ? '
' + dek + '
' : ) + (lastMod ? '
' : );
} content.parentNode.insertBefore(hero, content); }
// ---------- Light/dark toggle ----------
var toggle = document.createElement('button');
toggle.className = 'pickipedia-mode';
toggle.type = 'button';
toggle.textContent = body.classList.contains('pickipedia-dark') ? '☼ Light' : '☾ Dark';
toggle.addEventListener('click', function () {
var dark = body.classList.toggle('pickipedia-dark');
toggle.textContent = dark ? '☼ Light' : '☾ Dark';
try { localStorage.setItem(STORAGE_KEY, dark ? 'dark' : 'light'); } catch (e) {}
});
document.body.appendChild(toggle);
function escapeHtml(s) {
return String(s == null ? : s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
}());