1((Drupal) => { 2 const STORAGE_KEY = 'kalameh.miniPlayer.v1'; 3 const I_PLAY = '<polygon points="6 4 20 12 6 20 6 4"/>'; 4 const I_PAUSE = '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>'; 5 const RATES = [1, 1.25, 1.5, 2, 0.75]; 6 7 const fmt = (sec) => { 8 if (!Number.isFinite(sec) || sec < 0) { 9 return '0:00'; 10 } 11 const m = Math.floor(sec / 60); 12 const s = Math.floor(sec % 60); 13 return `${m}:${s.toString().padStart(2, '0')}`; 14 }; 15 16 const rateLabel = (r) => (r === 1 ? '1Ã' : `${String(r).replace(/\.0$/, '')}Ã`); 17 18 const loadState = () => { 19 try { 20 const raw = sessionStorage.getItem(STORAGE_KEY); 21 return raw ? JSON.parse(raw) : null; 22 } 23 catch (e) { 24 return null; 25 } 26 }; 27 28 const saveState = (partial) => { 29 try { 30 const prev = loadState() || {}; 31 const next = { ...prev, ...partial, updatedAt: Date.now() }; 32 if (!next.src) { 33 sessionStorage.removeItem(STORAGE_KEY); 34 return; 35 } 36 sessionStorage.setItem(STORAGE_KEY, JSON.stringify(next)); 37 } 38 catch (e) { 39 /* private mode / quota */ 40 } 41 }; 42 43 const clearState = () => { 44 try { 45 sessionStorage.removeItem(STORAGE_KEY); 46 } 47 catch (e) { 48 /* ignore */ 49 } 50 }; 51 52 const ensure = () => { 53 let root = document.getElementById('kalMiniPlayer'); 54 if (root) { 55 return root; 56 } 57 root = document.createElement('div'); 58 root.id = 'kalMiniPlayer'; 59 root.className = 'kal-mini-player'; 60 root.setAttribute('aria-hidden', 'true'); 61 root.innerHTML = ` 62 <button type="button" class="mp-cv" data-mp="cover" aria-label="Cover"><img alt=""/></button> 63 <div class="mp-info"><p class="mp-t" data-mp="title">â</p><p class="mp-a" data-mp="sub"></p></div> 64 <div class="mp-controls"> 65 <button type="button" data-mp="back10" aria-label="Back 10s"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L3 9"/><text x="12" y="16" font-family="system-ui,sans-serif" font-size="8" font-weight="700" text-anchor="middle" fill="currentColor" stroke="none">10</text></svg></button> 66 <button type="button" class="mp-play" data-mp="play" aria-label="Play/pause"><svg data-mp="play-icon" width="14" height="14" viewBox="0 0 24 24" fill="currentColor">${I_PLAY}</svg></button> 67 <button type="button" data-mp="fwd10" aria-label="Forward 10s"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 3v6h-6"/><path d="M20.49 15a9 9 0 1 1-2.13-9.36L21 9"/><text x="12" y="16" font-family="system-ui,sans-serif" font-size="8" font-weight="700" text-anchor="middle" fill="currentColor" stroke="none">10</text></svg></button> 68 <button type="button" class="mp-speed" data-mp="speed" aria-label="Speed">1Ã</button> 69 <button type="button" data-mp="close" aria-label="Close"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button> 70 </div> 71 <div class="mp-prog"><span class="mp-tm" data-mp="cur">0:00</span><div class="mp-bar" data-mp="bar"><div class="mp-fill" data-mp="fill"></div></div><span class="mp-tm" data-mp="dur">0:00</span></div> 72 <audio data-mp="audio" preload="metadata"></audio> 73 `; 74 document.body.appendChild(root); 75 76 const audio = root.querySelector('[data-mp="audio"]'); 77 const playIcon = root.querySelector('[data-mp="play-icon"]'); 78 let rateIdx = 0; 79 let persistTimer = null; 80 81 const renderPlay = () => { 82 playIcon.innerHTML = audio.paused ? I_PLAY : I_PAUSE; 83 }; 84 85 const persistNow = () => { 86 if (!audio.src) { 87 return; 88 } 89 saveState({ 90 src: audio.currentSrc || audio.src, 91 title: root.querySelector('[data-mp="title"]').textContent || '', 92 subtitle: root.querySelector('[data-mp="sub"]').textContent || '', 93 cover: root.querySelector('[data-mp="cover"] img')?.getAttribute('src') || '', 94 id: root.dataset.trackId || '', 95 shareUrl: root.dataset.shareUrl || '', 96 currentTime: audio.currentTime || 0, 97 playbackRate: audio.playbackRate || 1, 98 playing: !audio.paused, 99 }); 100 }; 101 102 const schedulePersist = () => { 103 clearTimeout(persistTimer); 104 persistTimer = setTimeout(persistNow, 400); 105 }; 106 107 root.querySelector('[data-mp="play"]').addEventListener('click', () => { 108 if (audio.paused) { 109 audio.play().catch(() => {}); 110 } 111 else { 112 audio.pause(); 113 } 114 }); 115 root.querySelector('[data-mp="back10"]').addEventListener('click', () => { 116 audio.currentTime = Math.max(0, audio.currentTime - 10); 117 persistNow(); 118 }); 119 root.querySelector('[data-mp="fwd10"]').addEventListener('click', () => { 120 audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + 10); 121 persistNow(); 122 }); 123 root.querySelector('[data-mp="speed"]').addEventListener('click', (e) => { 124 rateIdx = (rateIdx + 1) % RATES.length; 125 audio.playbackRate = RATES[rateIdx]; 126 e.currentTarget.textContent = rateLabel(RATES[rateIdx]); 127 persistNow(); 128 }); 129 root.querySelector('[data-mp="close"]').addEventListener('click', () => { 130 Drupal.kalamehMiniPlayer.close(); 131 }); 132 root.querySelector('[data-mp="bar"]').addEventListener('click', (e) => { 133 const rect = e.currentTarget.getBoundingClientRect(); 134 const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); 135 if (audio.duration) { 136 audio.currentTime = ratio * audio.duration; 137 persistNow(); 138 } 139 }); 140 141 audio.addEventListener('play', () => { 142 renderPlay(); 143 root.classList.add('on'); 144 root.setAttribute('aria-hidden', 'false'); 145 document.body.classList.add('kal-mini-player-open'); 146 persistNow(); 147 }); 148 audio.addEventListener('pause', () => { 149 renderPlay(); 150 persistNow(); 151 }); 152 audio.addEventListener('timeupdate', () => { 153 root.querySelector('[data-mp="cur"]').textContent = fmt(audio.currentTime); 154 root.querySelector('[data-mp="dur"]').textContent = fmt(audio.duration || 0); 155 const pct = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0; 156 root.querySelector('[data-mp="fill"]').style.width = `${pct}%`; 157 schedulePersist(); 158 }); 159 audio.addEventListener('ended', () => { 160 persistNow(); 161 }); 162 163 // Persist right before leaving the page. 164 window.addEventListener('pagehide', persistNow); 165 document.addEventListener('visibilitychange', () => { 166 if (document.visibilityState === 'hidden') { 167 persistNow(); 168 } 169 }); 170 171 root._audio = audio; 172 root._setRateIdx = (i) => { rateIdx = i; }; 173 root._rateIdx = () => rateIdx; 174 return root; 175 }; 176 177 const applyTrackMeta = (root, track) => { 178 root.querySelector('[data-mp="title"]').textContent = track.title || 'â'; 179 root.querySelector('[data-mp="sub"]').textContent = track.subtitle || ''; 180 const img = root.querySelector('[data-mp="cover"] img'); 181 if (track.cover) { 182 img.src = track.cover; 183 img.hidden = false;
184 } 185 else { 186 img.removeAttribute('src'); 187 img.hidden = true; 188 } 189 root.dataset.trackId = track.id || ''; 190 root.dataset.shareUrl = track.shareUrl || ''; 191 }; 192 193 Drupal.kalamehMiniPlayer = { 194 play(track) { 195 const root = ensure(); 196 const audio = root._audio; 197 applyTrackMeta(root, track); 198 const nextSrc = track.src; 199 const same = audio.src && (audio.currentSrc === nextSrc || audio.src.endsWith(nextSrc) || audio.src === nextSrc); 200 if (!same) { 201 audio.src = nextSrc; 202 } 203 root._setRateIdx(0); 204 audio.playbackRate = track.playbackRate || 1; 205 const ri = RATES.indexOf(audio.playbackRate); 206 root._setRateIdx(ri >= 0 ? ri : 0); 207 root.querySelector('[data-mp="speed"]').textContent = rateLabel(audio.playbackRate || 1); 208 if (typeof track.currentTime === 'number' && track.currentTime > 0) { 209 const seek = () => { 210 try { 211 audio.currentTime = track.currentTime; 212 } 213 catch (e) { 214 /* ignore */ 215 } 216 audio.removeEventListener('loadedmetadata', seek); 217 }; 218 if (audio.readyState >= 1) { 219 seek(); 220 } 221 else { 222 audio.addEventListener('loadedmetadata', seek); 223 } 224 } 225 audio.play().catch(() => {}); 226 saveState({ 227 src: nextSrc, 228 title: track.title || '', 229 subtitle: track.subtitle || '', 230 cover: track.cover || '', 231 id: track.id || '', 232 shareUrl: track.shareUrl || '', 233 currentTime: track.currentTime || 0, 234 playbackRate: audio.playbackRate || 1, 235 playing: true, 236 }); 237 }, 238 close() { 239 const root = document.getElementById('kalMiniPlayer'); 240 if (!root) { 241 return; 242 } 243 const audio = root._audio; 244 if (audio) { 245 audio.pause(); 246 audio.removeAttribute('src'); 247 audio.load(); 248 } 249 root.classList.remove('on'); 250 root.setAttribute('aria-hidden', 'true'); 251 document.body.classList.remove('kal-mini-player-open'); 252 clearState(); 253 }, 254 restore() { 255 const state = loadState(); 256 if (!state?.src) { 257 return; 258 } 259 const root = ensure(); 260 const audio = root._audio; 261 applyTrackMeta(root, state); 262 audio.src = state.src; 263 const rate = state.playbackRate || 1; 264 audio.playbackRate = rate; 265 const ri = RATES.indexOf(rate); 266 root._setRateIdx(ri >= 0 ? ri : 0); 267 root.querySelector('[data-mp="speed"]').textContent = rateLabel(rate); 268 root.classList.add('on'); 269 root.setAttribute('aria-hidden', 'false'); 270 document.body.classList.add('kal-mini-player-open'); 271 272 const seek = () => { 273 try { 274 if (state.currentTime > 0) { 275 audio.currentTime = state.currentTime; 276 } 277 } 278 catch (e) { 279 /* ignore */ 280 } 281 }; 282 if (audio.readyState >= 1) { 283 seek(); 284 } 285 else { 286 audio.addEventListener('loadedmetadata', seek, { once: true }); 287 } 288 289 if (state.playing) { 290 audio.play().catch(() => {}); 291 } 292 else { 293 audio.pause(); 294 root.querySelector('[data-mp="play-icon"]').innerHTML = I_PLAY; 295 } 296 }, 297 }; 298 299 // Restore across full page navigations (MPA). 300 const boot = () => { 301 ensure(); 302 Drupal.kalamehMiniPlayer.restore(); 303 }; 304 if (document.readyState === 'loading') { 305 document.addEventListener('DOMContentLoaded', boot); 306 } 307 else { 308 boot(); 309 } 310})(Drupal);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.