MediaWiki talk:Common.js: Difference between revisions
From PickiPedia: A knowledge base of bluegrass, old time psychedelic jams, and other public domain music
Jump to navigationJump to search
Proposed Common.js update for cryptograss/pickipedia#111: poster frames and an honest AV1 playback error (via create-page on MediaWiki MCP Server) |
Escape spaces before ! ? : so MediaWiki's French spacing doesn't turn them into non-breaking spaces in the copyable code; fix block height (via update-page on MediaWiki MCP Server) |
||
| Line 10: | Line 10: | ||
* '''No pointless fallback:''' on a decoder failure the player no longer retries the CID as a raw video file. That retry can't work for an HLS directory, and it ended in the misleading IPFS message. | * '''No pointless fallback:''' on a decoder failure the player no longer retries the CID as a raw video file. That retry can't work for an HLS directory, and it ended in the misleading IPFS message. | ||
'''To apply:''' | '''To apply:''' select the full file from the '''rendered page''' (not this page's edit box, where some characters are escaped), paste it over the whole of [[MediaWiki:Common.js]], and save. Alternatively, make the four edits shown in the diff. | ||
=== Diff against revision 5430 === | === Diff against revision 5430 === | ||
| Line 31: | Line 31: | ||
+ return window.MediaSource.isTypeSupported(type); | + return window.MediaSource.isTypeSupported(type); | ||
+ } | + } | ||
+ return document.createElement('video').canPlayType(type) !== ''; | + return document.createElement('video').canPlayType(type) !== ''; | ||
+ } catch (e) { | + } catch (e) { | ||
+ return false; | + return false; | ||
| Line 112: | Line 112: | ||
fallbackToDirectVideo(container, cid, width, maxWidth, startSeconds); | fallbackToDirectVideo(container, cid, width, maxWidth, startSeconds); | ||
}); | }); | ||
} else if (typeof Hls !== 'undefined' && Hls.isSupported()) { | } else if (typeof Hls !== 'undefined' && Hls.isSupported()) { | ||
@@ -326,8 +392,17 @@ | @@ -326,8 +392,17 @@ | ||
hls.attachMedia(video); | hls.attachMedia(video); | ||
| Line 339: | Line 339: | ||
function loadHls() { | function loadHls() { | ||
if (typeof Hls !== 'undefined') { | if (typeof Hls !== 'undefined') { | ||
return Promise.resolve(); | return Promise.resolve(); | ||
} | } | ||
| Line 382: | Line 382: | ||
// rather than being ignored outright. | // rather than being ignored outright. | ||
video.currentTime = isFinite(video.duration) | video.currentTime = isFinite(video.duration) | ||
 ? Math.min(seconds, video.duration) | |||
 : seconds; | |||
}); | }); | ||
} | } | ||
| Line 392: | Line 392: | ||
function timecodeFromHash() { | function timecodeFromHash() { | ||
var m = /[#&]t=([0-9:]+)/.exec(window.location.hash || ''); | var m = /[#&]t=([0-9:]+)/.exec(window.location.hash || ''); | ||
return m ? parseTimecode(m[1]) : 0; | return m ? parseTimecode(m[1]) : 0; | ||
} | } | ||
| Line 432: | Line 432: | ||
return window.MediaSource.isTypeSupported(type); | return window.MediaSource.isTypeSupported(type); | ||
} | } | ||
return document.createElement('video').canPlayType(type) !== ''; | return document.createElement('video').canPlayType(type) !== ''; | ||
} catch (e) { | } catch (e) { | ||
return false; | return false; | ||
| Line 524: | Line 524: | ||
fallbackToDirectVideo(container, cid, width, maxWidth, startSeconds); | fallbackToDirectVideo(container, cid, width, maxWidth, startSeconds); | ||
}); | }); | ||
} else if (typeof Hls !== 'undefined' && Hls.isSupported()) { | } else if (typeof Hls !== 'undefined' && Hls.isSupported()) { | ||
var hls = new Hls(); | var hls = new Hls(); | ||
hls.loadSource(hlsSrc); | hls.loadSource(hlsSrc); | ||
| Line 556: | Line 556: | ||
} | } | ||
if (typeof mw !== 'undefined' && mw.hook) { | if (typeof mw !== 'undefined' && mw.hook) { | ||
mw.hook('wikipage.content').add(function($content) { | mw.hook('wikipage.content').add(function($content) { | ||
initPlayers(); | initPlayers(); | ||
| Line 578: | Line 578: | ||
var nickVal = nickname.value.trim(); | var nickVal = nickname.value.trim(); | ||
var makeVal = make ? make.value.trim() : ''; | var makeVal = make ? make.value.trim() : ''; | ||
var modelVal = model ? model.value.trim() : ''; | var modelVal = model ? model.value.trim() : ''; | ||
var serialVal = serial ? serial.value.trim() : ''; | var serialVal = serial ? serial.value.trim() : ''; | ||
var hasNickname = nickVal !== ''; | var hasNickname = nickVal !== ''; | ||
var hasMakerComplete = makeVal !== '' && modelVal !== '' && serialVal !== ''; | var hasMakerComplete = makeVal !== '' && modelVal !== '' && serialVal !== ''; | ||
if (!hasNickname && !hasMakerComplete) { | if (!hasNickname && !hasMakerComplete) { | ||
args.numErrors += 1; | args.numErrors += 1; | ||
if (makeVal !== '' || modelVal !== '' || serialVal !== '') { | if (makeVal !== '' || modelVal !== '' || serialVal !== '') { | ||
alert('Maker identification requires all three: make, model, and serial number. Or provide a nickname instead.'); | alert('Maker identification requires all three: make, model, and serial number. Or provide a nickname instead.'); | ||
} else { | } else { | ||
| Line 627: | Line 627: | ||
var action = mw.config.get('wgAction') || 'view'; | var action = mw.config.get('wgAction') || 'view'; | ||
if (nsNumber !== 2) return; | if (nsNumber !== 2) return; | ||
if (action !== 'view') return; | if (action !== 'view') return; | ||
if (title.indexOf('/') === -1) return; | if (title.indexOf('/') === -1) return; | ||
| Line 646: | Line 646: | ||
} | } | ||
var cats = mw.config.get('wgCategories') || []; | var cats = mw.config.get('wgCategories') || []; | ||
if (cats.indexOf('Masthead style') !== -1) variant = 'masthead'; | if (cats.indexOf('Masthead style') !== -1) variant = 'masthead'; | ||
if (cats.indexOf('Letter style') !== -1) variant = 'letter'; | if (cats.indexOf('Letter style') !== -1) variant = 'letter'; | ||
body.classList.add('pickipedia-variant-' + variant); | body.classList.add('pickipedia-variant-' + variant); | ||
| Line 705: | Line 705: | ||
'<div class="pp-kicker">' + kickerHtml + '</div>' + | '<div class="pp-kicker">' + kickerHtml + '</div>' + | ||
'<div class="pp-topbar-rule"></div>' + | '<div class="pp-topbar-rule"></div>' + | ||
(lastMod ? '<div class="pp-topbar-vol">' + escapeHtml(lastMod) + '</div>' : '') + | (lastMod ? '<div class="pp-topbar-vol">' + escapeHtml(lastMod) + '</div>' : '') + | ||
'</div>' + | '</div>' + | ||
'<div class="pp-titlewrap">' + | '<div class="pp-titlewrap">' + | ||
'<h1 class="pp-title">' + escapeHtml(subpage) + '</h1>' + | '<h1 class="pp-title">' + escapeHtml(subpage) + '</h1>' + | ||
(dek ? '<p class="pp-dek">' + dek + '</p>' : '') + | (dek ? '<p class="pp-dek">' + dek + '</p>' : '') + | ||
'</div>'; | '</div>'; | ||
} else { | } else { | ||
| Line 716: | Line 716: | ||
'<h1 class="pp-title">' + escapeHtml(subpage) + '</h1>' + | '<h1 class="pp-title">' + escapeHtml(subpage) + '</h1>' + | ||
'<div class="pp-kicker">' + kickerHtml + '</div>' + | '<div class="pp-kicker">' + kickerHtml + '</div>' + | ||
(dek ? '<p class="pp-dek">' + dek + '</p>' : '') + | (dek ? '<p class="pp-dek">' + dek + '</p>' : '') + | ||
(lastMod ? '<div class="pp-meta">Last edited ' + escapeHtml(lastMod) + '</div>' : ''); | (lastMod ? '<div class="pp-meta">Last edited ' + escapeHtml(lastMod) + '</div>' : ''); | ||
} | } | ||
content.parentNode.insertBefore(hero, content); | content.parentNode.insertBefore(hero, content); | ||
| Line 726: | Line 726: | ||
toggle.className = 'pickipedia-mode'; | toggle.className = 'pickipedia-mode'; | ||
toggle.type = 'button'; | toggle.type = 'button'; | ||
toggle.textContent = body.classList.contains('pickipedia-dark') ? '☼ Light' : '☾ Dark'; | toggle.textContent = body.classList.contains('pickipedia-dark') ? '☼ Light' : '☾ Dark'; | ||
toggle.addEventListener('click', function () { | toggle.addEventListener('click', function () { | ||
var dark = body.classList.toggle('pickipedia-dark'); | var dark = body.classList.toggle('pickipedia-dark'); | ||
toggle.textContent = dark ? '☼ Light' : '☾ Dark'; | toggle.textContent = dark ? '☼ Light' : '☾ Dark'; | ||
try { localStorage.setItem(STORAGE_KEY, dark ? 'dark' : 'light'); } catch (e) {} | try { localStorage.setItem(STORAGE_KEY, dark ? 'dark' : 'light'); } catch (e) {} | ||
}); | }); | ||
document.body.appendChild(toggle); | document.body.appendChild(toggle); | ||
function escapeHtml(s) { | function escapeHtml(s) { | ||
return String(s == null ? '' : s) | return String(s == null ? '' : s) | ||
.replace(/&/g, '&amp;') | .replace(/&/g, '&amp;') | ||
.replace(/</g, '&lt;') | .replace(/</g, '&lt;') | ||
| Line 743: | Line 743: | ||
}()); | }()); | ||
</pre> | </pre> | ||
—[[User:Magent|Magent]], on behalf of Justin and Sky, at block ~25, | —[[User:Magent|Magent]], on behalf of Justin and Sky, at block ~25,979,300 | ||
Revision as of 00:40, 15 September 2026
Proposed update: poster frames and an honest "can't play AV1" message
For cryptograss/pickipedia#111. Posted here at Justin's request, because the Magent account has no editsitejs right and so cannot edit MediaWiki:Common.js itself.
Base revision: 5430 (still the live revision as of posting). Full file: 606 lines, md5 314b9b9e1d1d9716c2bfb7755bb69949.
What changes (only in the HLS video player block; the other four modules are untouched):
- Poster frame: the player sets
video.posterto<CID>/poster.jpg. delivery-kid has written this file into every encode since #120, so the player shows a still instead of a black box. Older releases have no poster.jpg; a video whose poster 404s just shows its normal empty state. canDecodeAV1(): checks whether the browser can actually decode AV1.showPlaybackError(): replaces "Error loading video. The IPFS content may not be available." That text blamed IPFS every time, even when the file was being served fine. Without an AV1 decoder (Safari on Intel Macs, for example), readers now get told to use Firefox or Chrome, or download and use VLC. With a decoder, they get a direct link to the stream so they can tell whether it's a fetch problem.- No pointless fallback: on a decoder failure the player no longer retries the CID as a raw video file. That retry can't work for an HLS directory, and it ended in the misleading IPFS message.
To apply: select the full file from the rendered page (not this page's edit box, where some characters are escaped), paste it over the whole of MediaWiki:Common.js, and save. Alternatively, make the four edits shown in the diff.
Diff against revision 5430
--- MediaWiki:Common.js (rev 5430)
+++ MediaWiki:Common.js (proposed)
@@ -283,6 +283,52 @@
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;
+ }
+ }
+
+ // 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 = '<p style="padding:1em; border:1px solid #ac6600; background:#fef6e7;">'
+ + '<strong>This browser cannot play this video.</strong><br>'
+ + '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.<br><br>'
+ + '<strong>Firefox and Chrome play it on any desktop</strong>, including older Intel Macs. '
+ + 'Or download it and play it in VLC.<br>'
+ + '<a href="' + url + '" rel="nofollow">' + url + '</a>'
+ + '</p>';
+ } else {
+ html = '<p style="padding:1em; border:1px solid #b32424; background:#fee;">'
+ + '<strong>Could not load this video.</strong><br>'
+ + '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:<br>'
+ + '<a href="' + url + '" rel="nofollow">' + url + '</a>'
+ + '</p>';
+ }
+ 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);
@@ -290,7 +336,7 @@
var video = createVideoElement(width, maxWidth);
video.src = directUrl;
video.addEventListener('error', function() {
- container.innerHTML = '<p style="color: red; padding: 1em;">Error loading video. The IPFS content may not be available.</p>';
+ showPlaybackError(container, cid);
});
seekWhenReady(video, startSeconds);
container.appendChild(video);
@@ -311,13 +357,33 @@
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);
if (video.canPlayType('application/vnd.apple.mpegurl')) {
- // Safari native HLS — try HLS first, fall back on error
+ // Safari native HLS — try HLS first, fall back on error.
+ // 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 if (typeof Hls !== 'undefined' && Hls.isSupported()) {
@@ -326,8 +392,17 @@
hls.attachMedia(video);
hls.on(Hls.Events.ERROR, function(event, data) {
if (data.fatal) {
- // HLS failed (likely no master.m3u8) — try direct video
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);
}
});
Full replacement file
/**
* 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>
<span id="br-status" style="margin-left: 8px; font-size: 0.9em; color: #666;"></span>
`;
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:
* <span class="timeago" data-lastmod-page="Template:NewsShorts"></span>
*
* 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):
* <div class="hls-video-player" data-cid="QmXet6..."></div>
*
* 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
// {{Src|video|...|t=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;
}
}
// 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 = '<p style="padding:1em; border:1px solid #ac6600; background:#fef6e7;">'
+ '<strong>This browser cannot play this video.</strong><br>'
+ '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.<br><br>'
+ '<strong>Firefox and Chrome play it on any desktop</strong>, including older Intel Macs. '
+ 'Or download it and play it in VLC.<br>'
+ '<a href="' + url + '" rel="nofollow">' + url + '</a>'
+ '</p>';
} else {
html = '<p style="padding:1em; border:1px solid #b32424; background:#fee;">'
+ '<strong>Could not load this video.</strong><br>'
+ '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:<br>'
+ '<a href="' + url + '" rel="nofollow">' + url + '</a>'
+ '</p>';
}
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);
if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari native HLS — try HLS first, fall back on error.
// 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 if (typeof Hls !== 'undefined' && Hls.isSupported()) {
var hls = new Hls();
hls.loadSource(hlsSrc);
hls.attachMedia(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 {
// 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 =
'<div class="pp-topbar">' +
'<div class="pp-kicker">' + kickerHtml + '</div>' +
'<div class="pp-topbar-rule"></div>' +
(lastMod ? '<div class="pp-topbar-vol">' + escapeHtml(lastMod) + '</div>' : '') +
'</div>' +
'<div class="pp-titlewrap">' +
'<h1 class="pp-title">' + escapeHtml(subpage) + '</h1>' +
(dek ? '<p class="pp-dek">' + dek + '</p>' : '') +
'</div>';
} else {
// Letter variant: title first, then kicker, then optional dek/meta.
hero.innerHTML =
'<h1 class="pp-title">' + escapeHtml(subpage) + '</h1>' +
'<div class="pp-kicker">' + kickerHtml + '</div>' +
(dek ? '<p class="pp-dek">' + dek + '</p>' : '') +
(lastMod ? '<div class="pp-meta">Last edited ' + escapeHtml(lastMod) + '</div>' : '');
}
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, '"');
}
}());
—Magent, on behalf of Justin and Sky, at block ~25,979,300