User:Magent/Common.js proposal: Difference between revisions

From PickiPedia: A knowledge base of bluegrass, old time psychedelic jams, and other public domain music
Jump to navigationJump to search
Magent (talk | contribs)
Exact source of the proposed MediaWiki:Common.js (pickipedia#111), meant to be copied from ?action=raw (via create-page on MediaWiki MCP Server)
 
Magent (talk | contribs)
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);


         if (video.canPlayType('application/vnd.apple.mpegurl')) {
         // hls.js first, native HLS second.
            // Safari native HLS — try HLS first, fall back on error.
        //
            // If the browser has no AV1 decoder there is no point chasing the
        // The order used to be the other way round, on the assumption that
            // CID as a raw file: it is an HLS directory, that request fails
        // only Safari claims to play HLS natively. Chrome answers "maybe" to
            // too, and the reader is told the content is missing when the
        // that question too and does play these streams — so Chrome took the
            // real answer is "this browser cannot decode it".
        // native path, where the renditions are invisible to us: no quality
            video.src = hlsSrc;
        // selector, and an error we can only guess at. Through hls.js we can
            video.addEventListener('error', function() {
        // see the ladder, offer a choice, and tell a decoder failure apart
                if (!canDecodeAV1()) {
        // from a missing playlist.
                    showPlaybackError(container, cid);
        //
                    return;
        // Native remains the fallback for anything hls.js cannot drive
                }
        // (no Media Source Extensions), which includes iOS Safari.
                fallbackToDirectVideo(container, cid, width, maxWidth, startSeconds);
         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);
             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 =

'

' + '
' + kickerHtml + '
' + '
' + (lastMod ? '
' + escapeHtml(lastMod) + '
' : ) + '

' + '

' + '

' + escapeHtml(subpage) + '

' + (dek ? '

' + dek + '

' : ) + '

';

   } else {
     // Letter variant: title first, then kicker, then optional dek/meta.
     hero.innerHTML =

'

' + escapeHtml(subpage) + '

' + '

' + kickerHtml + '

' + (dek ? '

' + dek + '

' : ) + (lastMod ? '

Last edited ' + escapeHtml(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, '"');
   }

}());