// Timestamp annotations retain the usual Nostr event target. `media` is a // client extension; the r tag is a portable media-fragment link. export function mediaMoment(url, seconds) { try { const u = new URL(url); if (!/^https?:$/.test(u.protocol) || !Number.isFinite(seconds) || seconds < 0 || seconds > Number.MAX_SAFE_INTEGER) return null; u.hash = ''; return { url: u.href, seconds: Math.floor(seconds) }; } catch { return null; } } export function momentTags(moment) { const m = moment && mediaMoment(moment.url, moment.seconds); return m ? [['media', m.url, String(m.seconds)], ['r', m.url + '#t=' + m.seconds]] : []; } export function readMoment(event, depth = 0) { if (depth > 1) return null; if (event?.kind === 9735) { try { return readMoment(JSON.parse(event.tags.find(t => t[0] === 'description')?.[1]), depth + 1); } catch { return null; } } const tag = event?.tags?.find(t => t[0] === 'media'); return tag && /^\d+$/.test(tag[2] || '') ? mediaMoment(tag[1], Number(tag[2])) : null; } export function mediaClock(seconds) { const s = Math.max(0, Math.floor(seconds || 0)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); } export function waveformFromTags(event, url) { const tag = event?.tags?.find(t => t[0] === 'imeta' && t.includes('url ' + url)); const raw = tag?.find(t => t.startsWith('waveform '))?.slice(9); if (!raw) return null; const values = raw.trim().split(/\s+/).map(Number); return values.length > 1 && values.length <= 4096 && values.every(n => Number.isFinite(n) && n >= 0) ? values : null; } export function waveformPeaks(buffer, count = 96) { const peaks = new Array(count).fill(0); for (let ch = 0; ch < buffer.numberOfChannels; ch++) { const data = buffer.getChannelData(ch); for (let i = 0; i < count; i++) { const end = Math.floor((i + 1) * data.length / count); for (let j = Math.floor(i * data.length / count); j < end; j++) peaks[i] = Math.max(peaks[i], Math.abs(data[j])); } } return peaks; } const waves = new Map(); let decoding = Promise.resolve(); export function loadWaveform(url) { if (waves.has(url)) return waves.get(url); const job = decoding.then(async () => { const limit = 20 * 1024 * 1024; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 30000); let ctx; try { const response = await fetch(url, { signal: controller.signal, credentials: 'omit' }); if (!response.ok || Number(response.headers.get('content-length')) > limit) throw new Error('size'); const reader = response.body.getReader(), parts = []; let size = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > limit) { await reader.cancel(); throw new Error('size'); } parts.push(value); } const Audio = globalThis.AudioContext || globalThis.webkitAudioContext; ctx = new Audio({ sampleRate: 8000 }); const buffer = await ctx.decodeAudioData(await new Blob(parts).arrayBuffer()); return waveformPeaks(buffer); } catch { return null; } finally { clearTimeout(timer); controller.abort(); if (ctx) await ctx.close().catch(() => {}); } }); decoding = job.then(() => {}); waves.set(url, job); if (waves.size > 24) waves.delete(waves.keys().next().value); return job; } // The live player owns this subtree, so morphing cannot replace its handlers // with closures pointing at a detached player. export function mediaTimeline(player, url, { h, event, moments, action, label }) { const audio = player.tagName === 'AUDIO'; const box = h('div', { class: 'media-timeline', onClick: e => e.stopPropagation(), onPointerdown: e => e.stopPropagation() }); box._skipMorph = true; const bars = h('div', { class: 'media-wave', 'aria-hidden': 'true' }); const range = h('input', { type: 'range', min: 0, max: 1000, value: 0, step: 1, 'aria-label': label('mediaSeek'), disabled: true }); const seek = h('div', { class: 'media-seek' }, bars, range); const time = h('span', { class: 'small muted' }, '0:00'); const marks = h('div', { class: 'media-marks' }); const detail = h('div', { class: 'small media-moment-detail', 'aria-live': 'polite' }); const actions = h('div', { class: 'row media-moment-actions' }, time); for (const [kind, icon] of [['comment', '↩'], ['reaction', '♡'], ['zap', '⚡']]) { actions.append(h('button', { type: 'button', class: 'btn-sm', 'aria-label': label(kind === 'comment' ? 'msgReply' : kind === 'reaction' ? 'postLike' : 'zapTitle') + ' ' + label('mediaAtTime'), onClick: e => { e.stopPropagation(); const ev = event(); const m = mediaMoment(url, player.currentTime); if (ev && m) action(kind, ev, m, e.currentTarget); } }, icon + ' ' + label('mediaAtTime'))); } box.append(seek, actions, marks, detail); let waveStarted = false, signature = ''; const draw = values => { if (!values) return; const max = Math.max(...values) || 1; const stride = Math.max(1, Math.ceil(values.length / 96)); bars.replaceChildren(); for (let i = 0; i < values.length; i += stride) bars.append(h('i', { style: 'height:' + Math.max(3, 100 * Math.max(...values.slice(i, i + stride)) / max) + '%' })); seek.classList.add('has-wave'); }; const jump = seconds => { if (Number.isFinite(player.duration)) player.currentTime = Math.min(seconds, player.duration); }; range.addEventListener('input', () => jump(Number(range.value) * player.duration / 1000)); const update = () => { const ev = event(); const finite = Number.isFinite(player.duration) && player.duration > 0; actions.hidden = !ev || !finite; range.disabled = !finite; range.value = finite ? player.currentTime / player.duration * 1000 : 0; range.setAttribute('aria-valuetext', mediaClock(player.currentTime) + ' / ' + mediaClock(finite ? player.duration : 0)); seek.style.setProperty('--played', (finite ? player.currentTime / player.duration * 100 : 0) + '%'); time.textContent = mediaClock(player.currentTime) + ' / ' + mediaClock(finite ? player.duration : 0); for (const b of actions.querySelectorAll('button')) b.title = b.textContent + ' ' + mediaClock(player.currentTime); if (audio && !waveStarted && finite) { waveStarted = true; const published = waveformFromTags(ev, url); if (published) draw(published); else if (player.duration <= 1200 && !navigator.connection?.saveData) loadWaveform(url).then(draw); } const items = ev ? moments(ev.id, url) : []; const next = items.map(x => x.id).join(','); if (signature !== next) { signature = next; marks.replaceChildren(...items.map(x => h('button', { type: 'button', class: 'media-mark btn-sm', title: x.text, onClick: () => { jump(x.moment.seconds); detail.textContent = x.text || x.icon; } }, mediaClock(x.moment.seconds) + ' ' + x.icon))); } }; // Fetching/decoding starts only once the user plays (or explicitly seeks). for (const type of ['timeupdate', 'playing', 'seeked', 'durationchange', 'mediaactivity']) player.addEventListener(type, update); actions.hidden = true; return box; }