1 2import * as store from './store.js'; 3import { $, $$, t, fmtTime, announce, toast, url, cfg } from './core.js'; 4import { addHistory, toggleFav, syncFavButtons } from './library.js'; 5import * as analytics from './analytics.js'; 6const dock = document.getElementById('dock'); 7const audio = dock ? $('[data-p="audio"]', dock) : null; 8const ui = {}; 9if (dock) for (const n of $$('[data-p]', dock)) ui[n.dataset.p] = n; 10let queue = []; 11let index = -1; 12let queueId = ''; 13let state = 'idle'; 14let repeat = 'off'; 15let rate = 1; 16let sleepAt = 0; 17let played = 0; 18let lastTick = 0; 19let beaconSent = false; 20let seeking = false; 21let lastSave = 0; 22let lastPosState = 0; 23let errorTimer = 0; 24let pendingResume = 0; 25const encodePath = (f) => String(f || '').split('/').map(encodeURIComponent).join('/'); 26export function current() { 27 return index >= 0 ? queue[index] : null; 28} 29export function isActive() { 30 return !!audio && (state === 'playing' || state === 'loading'); 31} 32function readRow(row, list) { 33 const d = row.dataset; 34 const L = (list && list.dataset) || {}; 35 const kind = d.kind || L.kind || 'r'; 36 const src = d.src || (L.base || '') + encodePath(d.f); 37 const link = row.querySelector('a[href]'); 38 let title; 39 let sub = d.sub || L.sub || '';
40 if (row.classList.contains('track--reciter')) { 41 title = L.coll || ''; 42 } else { 43 const pick = (sel) => row.querySelector(sel)?.textContent.trim() || ''; 44 title = pick('.track__lat') || pick('.track__title') || pick('.track__ar') || pick('.track__link') || L.coll || ''; 45 } 46 return { 47 k: kind, 48 id: Number(d.id), 49 src, 50 t: title, 51 s: sub, 52 a: row.classList.contains('track--reciter') ? '' : (L.coll || ''), 53 img: d.img !== undefined ? d.img : (L.img || ''), 54 u: link && !link.hasAttribute('data-a') ? link.getAttribute('href') : location.pathname, 55 d: Number(d.d) || 0, 56 n: Number(d.n) || 0, 57 }; 58} 59function visibleRows(container) { 60 return $$('.track[data-id]', container).filter((r) => !r.hidden && !r.closest('[hidden]')); 61} 62export function playFromRow(row) { 63 const list = row.closest('.tracklist'); 64 const it = readRow(row, list); 65 const cur = current(); 66 if (cur && cur.k === it.k && cur.id === it.id && audio.src) { 67 toggle(); 68 return; 69 } 70 const rows = visibleRows(list); 71 setQueue(rows.map((r) => readRow(r, r.closest('.tracklist'))), (list && list.dataset.queue) || ''); 72 start(Math.max(0, rows.indexOf(row))); 73 if (list && list.dataset.queueSrc) extendQueue(list.dataset.queueSrc, it); 74} 75export function playList(target) { 76 const root = typeof target === 'string' ? $(target) : target; 77 if (!root) return; 78 const rows = visibleRows(root); 79 if (!rows.length) return; 80 setQueue(rows.map((r) => readRow(r, r.closest('.tracklist'))), root.id || 'list'); 81 start(0); 82} 83function setQueue(items, id) { 84 queue = items.slice(0, 400); 85 queueId = id; 86} 87async function extendQueue(src, playing) { 88 try { 89 const res = await fetch(src, { headers: { Accept: 'application/json' } }); 90 if (!res.ok) return; 91 const data = await res.json(); 92 const items = data.items.map((x) => ({ 93 k: 'r', id: x.id, src: data.base + encodePath(x.f), t: x.t, s: data.sub, a: data.title, img: data.img || '', u: x.u, d: x.d, n: x.n, 94 })); 95 const cur = current(); 96 const i = items.findIndex((x) => cur && x.id === cur.id); 97 if (i >= 0 && cur && cur.id === playing.id) { 98 queue = items; 99 index = i; 100 queueId = data.queue; 101 updateButtons(); 102 saveNow(true); 103 } 104 } catch {} 105} 106function start(i, from = null) { 107 const it = queue[i]; 108 if (!it || !audio) return; 109 index = i; 110 clearTimeout(errorTimer); 111 hideMessage(); 112 audio.src = it.src; 113 audio.defaultPlaybackRate = rate; 114 audio.playbackRate = rate; 115 const resume = from !== null ? from : resumePosition(it); 116 pendingResume = resume > 0 ? resume : 0; 117 if (pendingResume) { 118 try { audio.currentTime = pendingResume; } catch {} 119 } 120 setState('loading'); 121 const p = audio.play(); 122 if (p && p.catch) { 123 p.catch((err) => { 124 if (err && err.name === 'NotAllowedError') setState('paused'); 125 else if (err && err.name !== 'AbortError') onError(); 126 }); 127 } 128 played = 0; 129 lastTick = 0; 130 beaconSent = false; 131 showDock(); 132 render(); 133 mediaSession(it); 134 saveNow(true); 135 addHistory({ k: it.k, id: it.id, title: it.t, sub: it.s, url: it.u, img: it.img }); 136 analytics.event('Audio', 'play', `${it.t} â ${it.s}`); 137 announce(t('now_playing', { title: `${it.t} â ${it.s}` })); 138} 139export function toggle() { 140 if (!audio) return; 141 if (!audio.src || audio.src === location.href) { 142 if (index >= 0) start(index, pendingResume || null); 143 return; 144 } 145 if (audio.paused) { 146 const p = audio.play(); 147 if (p && p.catch) p.catch(() => {}); 148 } else { 149 audio.pause(); 150 } 151} 152export function next(auto = false) { 153 if (index < queue.length - 1) start(index + 1, 0); 154 else if (repeat === 'all' && queue.length) start(0, 0); 155 else if (auto) setState('paused'); 156} 157export function prev() { 158 if (audio && audio.currentTime > 3) { 159 audio.currentTime = 0; 160 return; 161 } 162 if (index > 0) start(index - 1, 0); 163} 164export function jump(sec) { 165 if (!audio || !audio.src) return; 166 const d = audio.duration || Infinity; 167 audio.currentTime = Math.max(0, Math.min(d - 0.5, audio.currentTime + sec)); 168} 169export function seekPct(pct) { 170 if (audio && audio.duration) audio.currentTime = audio.duration * pct; 171} 172export function mute() { 173 if (audio) audio.muted = !audio.muted; 174} 175export function cycleRepeat() { 176 repeat = repeat === 'off' ? 'all' : repeat === 'all' ? 'one' : 'off'; 177 savePrefs(); 178 updateButtons(); 179 toast(t('repeat_' + repeat)); 180} 181export function changeRate(delta) { 182 const rates = [0.75, 1, 1.25, 1.5, 1.75, 2]; 183 const i = Math.max(0, Math.min(rates.length - 1, rates.indexOf(rate) + delta)); 184 setRate(rates[i]); 185} 186function setRate(r) { 187 rate = r; 188 if (audio) { 189 audio.playbackRate = r; 190 audio.defaultPlaybackRate = r; 191 }
192 if (ui.rate) ui.rate.value = String(r); 193 savePrefs(); 194} 195function setState(s) { 196 state = s; 197 if (dock) dock.dataset.state = s; 198 if (ui.toggle) ui.toggle.setAttribute('aria-label', s === 'playing' || s === 'loading' ? t('pause') : t('play')); 199 if ('mediaSession' in navigator) navigator.mediaSession.playbackState = s === 'playing' ? 'playing' : 'paused'; 200 syncRows(document); 201} 202export function syncRows(root) { 203 const cur = current(); 204 for (const row of $$('.track[data-id]', root)) { 205 const kind = row.dataset.kind || row.closest('.tracklist')?.dataset.kind || 'r'; 206 const on = !!cur && kind === cur.k && Number(row.dataset.id) === cur.id; 207 if (on) { 208 row.setAttribute('aria-current', 'true'); 209 row.dataset.state = state; 210 const b = row.querySelector('[data-a="play"]'); 211 if (b) b.setAttribute('aria-label', state === 'playing' ? t('pause') : t('play')); 212 } else if (row.hasAttribute('aria-current') || row.dataset.state) { 213 row.removeAttribute('aria-current'); 214 delete row.dataset.state; 215 } 216 } 217 for (const b of $$('.play--xl', root)) { 218 const row = b.closest('.track'); 219 b.dataset.state = row && row.dataset.state ? row.dataset.state : ''; 220 } 221} 222function showDock() { 223 if (!dock || !dock.hidden) return; 224 dock.hidden = false; 225 document.documentElement.dataset.dock = ''; 226 const skip = $('[data-skip-dock]'); 227 if (skip) skip.hidden = false; 228} 229function hideDock() { 230 if (!dock) return; 231 dock.hidden = true; 232 delete document.documentElement.dataset.dock; 233 const skip = $('[data-skip-dock]'); 234 if (skip) skip.hidden = true; 235} 236function render() { 237 const it = current(); 238 if (!it || !dock) return; 239 ui.title.textContent = it.t; 240 ui.sub.textContent = it.a ? `${it.s} · ${it.a}` : it.s; 241 ui.link.href = it.u || '#'; 242 ui.art.style.backgroundImage = it.img ? `url("${it.img}-96.webp")` : ''; 243 ui.dur.textContent = it.d ? fmtTime(it.d) : '0:00'; 244 ui.download.href = url(`/download/${it.k}${it.id}`); 245 ui.fav.dataset.entity = `${it.k === 's' ? 's' : 'r'}:${it.id}`; 246 ui.fav.dataset.name = `${it.t} â ${it.s}`; 247 ui.fav.dataset.url = it.u || ''; 248 ui.fav.dataset.img = it.img || ''; 249 syncFavButtons(dock); 250 updateButtons(); 251 progress(); 252} 253function updateButtons() { 254 if (!dock) return; 255 ui.prev.disabled = index <= 0 && !(audio && audio.currentTime > 3); 256 ui.next.disabled = index >= queue.length - 1 && repeat !== 'all'; 257 ui.repeat.dataset.mode = repeat; 258 ui.repeat.setAttribute('aria-label', t('repeat_' + repeat)); 259 ui.repeat.setAttribute('aria-pressed', repeat === 'off' ? 'false' : 'true'); 260} 261function progress() { 262 if (!audio || seeking) return; 263 const it = current(); 264 const d = audio.duration && isFinite(audio.duration) ? audio.duration : (it ? it.d : 0); 265 const cur = audio.currentTime || pendingResume || 0; 266 const pct = d ? Math.min(100, (cur / d) * 100) : 0; 267 ui.seek.value = String(Math.round(pct * 10)); 268 ui.seek.style.setProperty('--p', pct + '%'); 269 ui.seek.setAttribute('aria-valuetext', `${fmtTime(cur)} / ${fmtTime(d)}`); 270 ui.cur.textContent = fmtTime(cur); 271 if (d) ui.dur.textContent = fmtTime(d); 272 for (const row of $$('.track--single[aria-current="true"]')) { 273 const range = row.querySelector('[data-a="seek-inline"]'); 274 const time = row.querySelector('[data-inline="cur"]'); 275 if (range) {
276 range.value = String(Math.round(pct * 10)); 277 range.style.setProperty('--p', pct + '%'); 278 } 279 if (time) time.textContent = fmtTime(cur); 280 } 281} 282function mediaSession(it) { 283 if (!('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return; 284 const art = it.img 285 ? [96, 192, 384].map((w) => ({ src: new URL(`${it.img}-${w}.jpg`, location.href).href, sizes: `${w}x${Math.round(w * 1.2)}`, type: 'image/jpeg' })) 286 : [{ src: new URL(url('/assets/img/icon-512.png'), location.href).href, sizes: '512x512', type: 'image/png' }]; 287 navigator.mediaSession.metadata = new MediaMetadata({ title: it.t, artist: it.s, album: it.a || 'Assajda', artwork: art }); 288 const ms = navigator.mediaSession; 289 const set = (a, h) => { try { ms.setActionHandler(a, h); } catch {} }; 290 set('play', () => toggle()); 291 set('pause', () => audio.pause()); 292 set('stop', () => audio.pause()); 293 set('previoustrack', () => prev()); 294 set('nexttrack', () => next()); 295 set('seekbackward', (e) => jump(-(e.seekOffset || 10))); 296 set('seekforward', (e) => jump(e.seekOffset || 30)); 297 set('seekto', (e) => { if (e.seekTime !== undefined) audio.currentTime = e.seekTime; }); 298} 299function resumePosition(it) { 300 if (!it || it.d < 300) return 0; 301 const pos = store.get('resume', {})[it.k + it.id] || 0; 302 return pos > 30 && pos < it.d - 30 ? pos : 0; 303} 304function saveResume(it, pos) { 305 if (!it || it.d < 300) return; 306 const map = store.get('resume', {}); 307 const key = it.k + it.id; 308 if (pos > 30 && pos < it.d - 30) map[key] = Math.round(pos); 309 else delete map[key]; 310 const keys = Object.keys(map); 311 if (keys.length > 300) for (const k of keys.slice(0, keys.length - 300)) delete map[k]; 312 store.set('resume', map); 313} 314function saveNow(force = false) { 315 const now = Date.now(); 316 if (!force && now - lastSave < 5000) return; 317 lastSave = now; 318 const it = current(); 319 if (!it) return; 320 const pos = audio ? audio.currentTime || 0 : 0; 321 saveResume(it, pos); 322 store.set('now', { q: queue.slice(0, 150), i: index, id: queueId, pos: Math.round(pos), ts: now }); 323} 324function savePrefs() { 325 store.set('prefs', { rate, repeat }); 326} 327function beacon(it) { 328 beaconSent = true; 329 const key = 'as:l:' + it.k + it.id; 330 try { 331 if (sessionStorage.getItem(key)) return; 332 sessionStorage.setItem(key, '1'); 333 } catch {} 334 const body = new URLSearchParams({ t: it.k, id: String(it.id) }); 335 if (navigator.sendBeacon) navigator.sendBeacon(url('/api/listen'), body); 336 else fetch(url('/api/listen'), { method: 'POST', body, keepalive: true }).catch(() => {}); 337} 338function showMessage(msg) { 339 if (!ui.msg) return; 340 ui.msg.textContent = msg; 341 ui.msg.hidden = false; 342} 343function hideMessage() { 344 if (ui.msg) ui.msg.hidden = true; 345} 346function onError() { 347 const it = current(); 348 setState('error'); 349 showMessage(t('error_file')); 350 for (const row of $$('.track[data-id]')) if (it && Number(row.dataset.id) === it.id) row.dataset.state = 'error'; 351 if (it) analytics.event('Audio', 'error', `${it.k}${it.id}`); 352 if (index < queue.length - 1) errorTimer = setTimeout(() => next(true), 3000); 353} 354export function init() { 355 if (!dock || !audio) return; 356 const prefs = store.get('prefs', {}); 357 repeat = ['off', 'all', 'one'].includes(prefs.repeat) ? prefs.repeat : 'off'; 358 setRate(Number(prefs.rate) || 1); 359 audio.addEventListener('play', () => setState('playing')); 360 audio.addEventListener('playing', () => { setState('playing'); hideMessage(); }); 361 audio.addEventListener('pause', () => { if (state !== 'error') setState('paused'); saveNow(true); }); 362 audio.addEventListener('waiting', () => setState('loading')); 363 audio.addEventListener('stalled', () => { if (state === 'loading') showMessage(t('slow')); }); 364 audio.addEventListener('loadedmetadata', () => { 365 if (pendingResume && Math.abs(audio.currentTime - pendingResume) > 2) { 366 try { audio.currentTime = pendingResume; } catch {} 367 } 368 pendingResume = 0; 369 progress(); 370 }); 371 audio.addEventListener('error', () => { if (audio.src && audio.src !== location.href) onError(); }); 372 audio.addEventListener('ended', () => { 373 const it = current(); 374 if (it && !beaconSent) beacon(it); 375 if (it) { 376 saveResume(it, 0); 377 analytics.event('Audio', 'complete', `${it.t} â ${it.s}`); 378 } 379 if (sleepAt === -1) { 380 sleepAt = 0; 381 ui.sleep.value = '0'; 382 setState('paused'); 383 toast(t('sleep_done')); 384 return; 385 } 386 if (repeat === 'one') { 387 audio.currentTime = 0; 388 audio.play().catch(() => {}); 389 } else { 390 next(true); 391 } 392 }); 393 let raf = 0; 394 audio.addEventListener('timeupdate', () => { 395 const now = performance.now(); 396 if (!audio.paused && lastTick) played += Math.min(1.5, (now - lastTick) / 1000); 397 lastTick = audio.paused ? 0 : now; 398 const it = current(); 399 if (it && !beaconSent && played >= Math.min(30, (it.d || 60) * 0.5)) beacon(it); 400 if (sleepAt > 0 && Date.now() >= sleepAt) { 401 audio.pause(); 402 sleepAt = 0; 403 ui.sleep.value = '0'; 404 toast(t('sleep_done')); 405 } 406 saveNow(); 407 if (!raf) raf = requestAnimationFrame(() => { raf = 0; progress(); }); 408 if ('mediaSession' in navigator && navigator.mediaSession.setPositionState && now - lastPosState > 1000 && isFinite(audio.duration)) { 409 lastPosState = now;
410 try { navigator.mediaSession.setPositionState({ duration: audio.duration, playbackRate: audio.playbackRate, position: Math.min(audio.currentTime, audio.duration) }); } catch {} 411 } 412 }); 413 ui.toggle.addEventListener('click', () => toggle()); 414 ui.prev.addEventListener('click', () => prev()); 415 ui.next.addEventListener('click', () => next()); 416 ui.back.addEventListener('click', () => jump(-10)); 417 ui.fwd.addEventListener('click', () => jump(30)); 418 ui.repeat.addEventListener('click', () => cycleRepeat()); 419 ui.rate.addEventListener('change', () => setRate(Number(ui.rate.value) || 1)); 420 ui.sleep.addEventListener('change', () => { 421 const v = ui.sleep.value; 422 sleepAt = v === 'end' ? -1 : v === '0' ? 0 : Date.now() + Number(v) * 60000; 423 if (sleepAt) toast(v === 'end' ? t('sleep_end_set') : t('sleep_set', { n: v })); 424 }); 425 ui.fav.addEventListener('click', () => toggleFav(ui.fav)); 426 ui.close.addEventListener('click', () => { 427 audio.pause(); 428 saveNow(true); 429 hideDock(); 430 store.del('now'); 431 index = -1; 432 queue = []; 433 syncRows(document); 434 }); 435 ui.share.addEventListener('click', () => { 436 const it = current(); 437 if (it) document.dispatchEvent(new CustomEvent('as:share', { detail: { url: new URL(it.u || location.pathname, location.href).href, title: `${it.t} â ${it.s}` } })); 438 }); 439 ui.seek.addEventListener('input', () => { 440 seeking = true; 441 const pct = Number(ui.seek.value) / 1000; 442 ui.seek.style.setProperty('--p', pct * 100 + '%'); 443 if (audio.duration) ui.cur.textContent = fmtTime(audio.duration * pct); 444 }); 445 ui.seek.addEventListener('change', () => { 446 seeking = false; 447 seekPct(Number(ui.seek.value) / 1000); 448 }); 449 ui.seek.addEventListener('keydown', (e) => { 450 if (e.key === 'PageUp' || e.key === 'PageDown') { 451 e.preventDefault(); 452 jump(e.key === 'PageUp' ? 60 : -60); 453 } 454 }); 455 addEventListener('pagehide', () => saveNow(true)); 456 document.addEventListener('visibilitychange', () => { if (document.hidden) saveNow(true); }); 457 if ('BroadcastChannel' in window) { 458 const bc = new BroadcastChannel('assajda-player'); 459 audio.addEventListener('play', () => bc.postMessage('play')); 460 bc.onmessage = (e) => { if (e.data === 'play' && !audio.paused) audio.pause(); }; 461 } 462 const now = store.get('now'); 463 if (now && Array.isArray(now.q) && now.q[now.i]) { 464 for (const it of now.q) if (it && typeof it.t === 'string') it.t = it.t.replace(/^[0-9\u0660-\u0669]+\s*/, ''); 465 queue = now.q; 466 index = now.i; 467 queueId = now.id || ''; 468 pendingResume = now.pos || 0; 469 showDock(); 470 setState('paused'); 471 render(); 472 } 473 updateButtons(); 474} 475export function resume() { 476 if (index >= 0) start(index, pendingResume || null); 477} 478export function seekInline(range) { 479 const row = range.closest('.track'); 480 const cur = current(); 481 if (!row || !cur || Number(row.dataset.id) !== cur.id) return; 482 seekPct(Number(range.value) / 1000); 483}
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.