aboutsummaryrefslogtreecommitdiffstats
path: root/src/media-timeline.js
blob: bc9f4c78af43c31e5cb952557a9d287f269a0f67 (plain) (blame)
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
137
138
139
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;
}