import assert from 'node:assert/strict'; import { mediaMoment, momentTags, readMoment, waveformFromTags, waveformPeaks } from '../src/media-timeline.js'; import { buildZapRequest } from '../src/lnurl.js'; import puppeteer from './puppeteer.js'; const url = 'https://example.com/song.mp3'; const moment = mediaMoment(url, 65.9); assert.deepEqual(moment, { url, seconds: 65 }); for (const value of [-1, Infinity, NaN]) assert.equal(mediaMoment(url, value), null); assert.equal(mediaMoment('javascript:alert(1)', 0), null); assert.deepEqual(readMoment({ tags: momentTags(moment) }), moment); assert.equal(readMoment({ tags: [['media', url, '']] }), null); const request = buildZapRequest({ amountMsat: 21000, relays: [], recipientPk: 'p', eventId: 'e', moment, signFn: e => e }); assert.equal(request.tags.find(t => t[0] === 'e')[1], 'e'); assert.deepEqual(readMoment({ kind: 9735, tags: [['description', JSON.stringify(request)]] }), moment); assert.equal(readMoment(buildZapRequest({ amountMsat: 21000, relays: [], recipientPk: 'p', eventId: 'e', signFn: e => e })), null); assert.deepEqual(waveformFromTags({ tags: [['imeta', 'url ' + url, 'waveform 1 5 2']] }, url), [1, 5, 2]); assert.equal(waveformFromTags({ tags: [['imeta', 'url ' + url, 'waveform -1 2']] }, url), null); assert.deepEqual(waveformPeaks({ numberOfChannels: 2, getChannelData: n => n ? [0, 0, .8, 0] : [.2, -.5, .1, 0] }, 2), [.5, .8]); const wav = new Uint8Array(44 + 16000); const dv = new DataView(wav.buffer); const word = (at, text) => wav.set(new TextEncoder().encode(text), at); word(0, 'RIFF'); dv.setUint32(4, wav.length - 8, true); word(8, 'WAVE'); word(12, 'fmt '); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true); dv.setUint32(24, 8000, true); dv.setUint32(28, 16000, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); word(36, 'data'); dv.setUint32(40, 16000, true); for (let i = 0; i < 8000; i++) dv.setInt16(44 + i * 2, Math.sin(i * .3) * 20000, true); const server = Bun.serve({ port: 0, fetch(req) { const path = new URL(req.url).pathname; if (path === '/wave.wav') return new Response(wav, { headers: { 'content-type': 'audio/wav' } }); if (path === '/large.wav') return new Response('', { headers: { 'content-length': String(21 * 1024 * 1024) } }); if (path === '/module.js') return new Response(Bun.file('src/media-timeline.js'), { headers: { 'content-type': 'text/javascript' } }); if (path === '/style.css') return new Response(Bun.file('src/style.css')); return new Response('', { headers: { 'content-type': 'text/html' } }); } }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); try { const page = await browser.newPage(); await page.goto(server.url.href); const decoded = await page.evaluate(async () => { const { loadWaveform } = await import('/module.js'); const peaks = await loadWaveform('/wave.wav'); return { count: peaks?.length, nonzero: peaks?.every(x => x > .5), fallback: await loadWaveform('/large.wav') }; }); assert.equal(decoded.count, 96); assert.equal(decoded.nonzero, true); assert.equal(decoded.fallback, null); const result = await page.evaluate(async () => { const { mediaTimeline } = await import('/module.js'); const h = (tag, attrs = {}, ...children) => { const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k.startsWith('on')) el.addEventListener(k.slice(2).toLowerCase(), v); else if (v === true) el.setAttribute(k, ''); else if (v !== false) el.setAttribute(k, v); } el.append(...children); return el; }; const player = h('audio'); Object.defineProperty(player, 'duration', { value: 120 }); let at = 0; Object.defineProperty(player, 'currentTime', { get: () => at, set: x => { at = x; } }); const url = 'https://example.com/song.mp3'; const ev = { id: 'post', tags: [['imeta', 'url ' + url, 'waveform 1 10 4 8']] }; const calls = [], items = [{ id: 'a', moment: { seconds: 30 }, icon: '♡', text: 'Nice!' }]; const box = mediaTimeline(player, url, { h, event: () => ev, moments: () => items, action: (...a) => calls.push(a.slice(0, 3)), label: x => ({ mediaAtTime: 'at this time' }[x] || x) }); document.body.append(player, box); player.dispatchEvent(new Event('timeupdate')); const wave = box.querySelectorAll('.media-wave i').length; const range = box.querySelector('input'); range.value = 500; range.dispatchEvent(new Event('input')); const seek = at; for (const b of box.querySelectorAll('.media-moment-actions button')) b.click(); box.querySelector('.media-mark').click(); const marker = at; items.push({ id: 'b', moment: { seconds: 70 }, icon: '⚡', text: '' }); player.dispatchEvent(new Event('mediaactivity')); window.fixture = box; return { wave, seek, marker, calls, markers: box.querySelectorAll('.media-mark').length, aria: range.getAttribute('aria-valuetext') }; }); assert.equal(result.wave, 4); assert.equal(result.seek, 60); assert.equal(result.marker, 30); assert.equal(result.markers, 2); assert.deepEqual(result.calls.map(c => [c[0], c[1].id, c[2].seconds]), [['comment', 'post', 60], ['reaction', 'post', 60], ['zap', 'post', 60]]); await page.setViewport({ width: 360, height: 740 }); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); await page.screenshot({ path: '/tmp/media-timeline-mobile.png' }); console.log('✓ Timestamp validation, Lightning receipt round-trip, untimed zap isolation, waveform peaks, browser seeking, all three actions, marker updates and mobile layout'); } finally { await browser.close(); server.stop(); }