1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
|
// 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;
}
// Where a moment sits along a bar: a percentage of the clip, or null.
export function markAt(seconds, duration) {
return Number.isFinite(duration) && duration > 0 ? Math.min(100, Math.max(0, seconds / duration * 100)) : null;
}
// The live player owns this subtree, so morphing cannot replace its handlers
// with closures pointing at a detached player. What was said, reacted or
// zapped at a moment sits on the bar at that moment; replies, reactions and
// zaps made while a clip plays carry its time on their own (see momentOf).
export function mediaTimeline(player, url, { h, event, moments, glyph, 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 marks = h('div', { class: 'media-marks' });
const seek = h('div', { class: 'media-seek' }, bars, range, marks);
const time = h('span', { class: 'small muted media-time' }, '0:00');
const detail = h('div', { class: 'small media-moment-detail', 'aria-live': 'polite' });
box.append(seek, time, 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;
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);
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 && finite ? moments(ev.id, url) : [];
const next = items.map(x => x.id).join(',') + '@' + (finite ? player.duration : 0);
if (signature !== next) {
signature = next;
marks.replaceChildren(...items.map(x => h('button', { type: 'button', class: 'media-mark', title: mediaClock(x.moment.seconds) + ' ' + x.text,
style: 'left:' + markAt(x.moment.seconds, player.duration) + '%',
onClick: e => { e.stopPropagation(); jump(x.moment.seconds); detail.textContent = mediaClock(x.moment.seconds) + ' ' + x.text; } }, glyph(x))));
}
};
// Fetching/decoding starts only once the user plays (or explicitly seeks).
for (const type of ['timeupdate', 'playing', 'seeked', 'durationchange', 'mediaactivity']) player.addEventListener(type, update);
return box;
}
|