diff options
Diffstat (limited to 'src/media-timeline.js')
| -rw-r--r-- | src/media-timeline.js | 42 |
1 files changed, 21 insertions, 21 deletions
diff --git a/src/media-timeline.js b/src/media-timeline.js index bc9f4c7..d8ae008 100644 --- a/src/media-timeline.js +++ b/src/media-timeline.js @@ -76,28 +76,26 @@ export function loadWaveform(url) { 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. -export function mediaTimeline(player, url, { h, event, moments, action, label }) { +// 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, onMarks = null }) { 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 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' }); - 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); + box.append(seek, time, detail); let waveStarted = false, signature = ''; const draw = values => { if (!values) return; @@ -112,29 +110,31 @@ export function mediaTimeline(player, url, { h, event, moments, action, label }) 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(','); + const items = ev && finite ? moments(ev.id, url) : []; + const next = items.map(x => x.id).join(',') + '@' + (finite ? player.duration : 0); + // a video has its own seek bar: this one shows under it only to carry + // what was said at a moment (two bars read as a glitch on a phone) + box.classList.toggle('has-marks', items.length > 0); + if (onMarks) onMarks(items.length > 0); 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))); + 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); - actions.hidden = true; return box; } |
