diff options
Diffstat (limited to 'src/media-timeline.js')
| -rw-r--r-- | src/media-timeline.js | 140 |
1 files changed, 140 insertions, 0 deletions
diff --git a/src/media-timeline.js b/src/media-timeline.js new file mode 100644 index 0000000..bc9f4c7 --- /dev/null +++ b/src/media-timeline.js @@ -0,0 +1,140 @@ +// 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; +} |
