player.js (5328B)
1 // Track-list buttons and the persistent header control one audio element. 2 (() => { 3 const header = document.querySelector('#site-header'); 4 const player = header?.querySelector('audio'); 5 if (!player) return; 6 const heading = header.querySelector('#latest-track-heading'); 7 const title = header.querySelector('.track-title'); 8 const source = header.querySelector('.track-source a'); 9 let controls = []; 10 let playRequest = 0; 11 12 function timestamp(seconds) { 13 if (!Number.isFinite(seconds)) return '0:00'; 14 return `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, '0')}`; 15 } 16 17 function update() { 18 const playing = !player.paused && !player.ended; 19 for (const track of controls) { 20 const selected = player.src === track.url; 21 const active = selected && playing; 22 track.row.toggleAttribute('data-current-track', selected); 23 track.icon.textContent = active ? '⏸' : '▶'; 24 track.label.textContent = active ? 'Pause' : 'Play'; 25 track.button.setAttribute('aria-label', `${active ? 'Pause' : 'Play'} ${track.title}`); 26 track.state.textContent = selected 27 ? (player.error ? 'Error' : `${timestamp(player.currentTime)} / ${Number.isFinite(player.duration) ? timestamp(player.duration) : '–:––'}`) 28 : ''; 29 track.state.title = selected && player.error ? 'Unable to play. Try the player above.' : ''; 30 } 31 } 32 33 async function select(track) { 34 const request = ++playRequest; 35 if (player.src === track.url && !player.paused && !player.ended) { 36 player.pause(); 37 return; 38 } 39 if (player.src !== track.url) { 40 player.pause(); 41 title.textContent = track.title; 42 source.textContent = track.sourceTitle; 43 source.href = track.sourceURL; 44 player.setAttribute('aria-label', track.title); 45 player.src = track.url; 46 } 47 try { 48 await player.play(); 49 } catch (error) { 50 if (request !== playRequest || error.name === 'AbortError') return; 51 // Keep the native header controls available for retrying blocked playback. 52 track.state.textContent = 'Error'; 53 track.state.title = `Unable to play ${track.title}. Try the player above.`; 54 } 55 } 56 57 function preparePostTracks() { 58 document.querySelectorAll('#content [data-post-music]').forEach(post => { 59 post.querySelectorAll('audio[src]').forEach(audio => { 60 if (audio.closest('.music-track')) return; 61 const url = new URL(audio.src); 62 if (!url.pathname.toLowerCase().endsWith('.mp3')) return; 63 let name = url.pathname.split('/').pop().slice(0, -4); 64 try { name = decodeURIComponent(name); } catch { /* Keep malformed filenames readable. */ } 65 const row = document.createElement('div'); 66 row.className = 'music-track'; 67 const details = document.createElement('div'); 68 const trackTitle = document.createElement('p'); 69 trackTitle.className = 'track-title'; 70 trackTitle.textContent = name; 71 const trackSource = document.createElement('p'); 72 trackSource.className = 'track-source'; 73 const link = document.createElement('a'); 74 link.href = post.dataset.postUrl; 75 link.textContent = post.dataset.postTitle; 76 trackSource.append('From ', link); 77 details.append(trackTitle, trackSource); 78 audio.before(row); 79 row.append(audio, details); 80 }); 81 }); 82 } 83 84 function enhance() { 85 preparePostTracks(); 86 controls = []; 87 document.querySelectorAll('#content .music-track').forEach(row => { 88 const audio = row.querySelector('audio'); 89 const trackTitle = row.querySelector('.track-title'); 90 const trackSource = row.querySelector('.track-source a'); 91 if (!audio || !trackTitle || !trackSource) return; 92 const button = document.createElement('button'); 93 button.type = 'button'; 94 button.className = 'track-toggle'; 95 const icon = document.createElement('span'); 96 icon.className = 'track-toggle-icon'; 97 icon.setAttribute('aria-hidden', 'true'); 98 const label = document.createElement('span'); 99 button.append(icon, label); 100 const state = document.createElement('span'); 101 state.className = 'track-playback'; 102 const track = { row, button, icon, label, state, url: audio.src, title: trackTitle.textContent, 103 sourceTitle: trackSource.textContent, sourceURL: trackSource.href }; 104 button.addEventListener('click', () => select(track)); 105 audio.hidden = true; 106 audio.before(button); 107 const heading = document.createElement('div'); 108 heading.className = 'track-heading'; 109 trackTitle.before(heading); 110 heading.append(trackTitle, state); 111 controls.push(track); 112 }); 113 update(); 114 } 115 116 player.addEventListener('play', () => { 117 heading.textContent = 'Now playing'; 118 update(); 119 }); 120 for (const event of ['pause', 'ended', 'timeupdate', 'loadedmetadata', 'durationchange', 'emptied', 'error']) { 121 player.addEventListener(event, update); 122 } 123 document.addEventListener('play', event => { 124 if (!(event.target instanceof HTMLAudioElement)) return; 125 document.querySelectorAll('audio').forEach(audio => { 126 if (audio !== event.target) audio.pause(); 127 }); 128 }, true); 129 document.addEventListener('site:navigated', enhance); 130 enhance(); 131 })();