// Pure media-player logic (no DOM) — the virtual-timeline math for gapless // "segment" playback, extracted from static/media-player.js where it was // inlined seven times. Imported by the element and unit-tested directly. // // A track can be split into ordered segments: `main` segments (slices of the // creator's media file) interleaved with insertions (ads/intros). A single // main media URL may appear across several `main` segments (split around // insertions), so seeking within one needs its offset *inside that URL*. export interface Segment { url: string; duration_ms: number; /** 'main' for the creator's media; anything else is an insertion. */ segment_type: string; title?: string; } /** Format seconds as `m:ss` (or `h:mm:ss`). Ported from media-player.js:31. */ export function formatTime(seconds: number): string { if (Number.isNaN(seconds)) return '0:00'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); const ss = s.toString().padStart(2, '0'); if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${ss}`; return `${m}:${ss}`; } /** Cumulative virtual start (ms) of each segment, and the total duration. */ export function buildTimeline(segments: Segment[]): { segStarts: number[]; totalMs: number } { const segStarts: number[] = []; let totalMs = 0; for (const seg of segments) { segStarts.push(totalMs); totalMs += seg.duration_ms; } return { segStarts, totalMs }; } /** * Offset (ms) of segment `idx` within its own main media URL: the summed * duration of every earlier `main` segment sharing the same URL. Zero for * insertions or a URL's first appearance. This is the calculation that was * copy-pasted seven times in the original — the single source now. */ export function mainOffsetMs(segments: Segment[], idx: number): number { const seg = segments[idx]; if (!seg || seg.segment_type !== 'main') return 0; let offset = 0; for (let j = 0; j < idx; j++) { if (segments[j].segment_type === 'main' && segments[j].url === seg.url) { offset += segments[j].duration_ms; } } return offset; } /** The segment index containing virtual time `targetMs`; clamps to the last * segment past the end. Ported from the three identical search loops. */ export function segmentIndexAtVirtualMs(segStarts: number[], segments: Segment[], targetMs: number): number { for (let k = 0; k < segments.length; k++) { if (targetMs < segStarts[k] + segments[k].duration_ms) return k; } return segments.length - 1; } /** * Map a chapter time (measured against the *main* media only, ignoring * insertions) to a virtual timeline position in ms. Ported from * media-player.js:381 — a chapter at main-time T lands after all insertions * that precede it. */ export function chapterToVirtualMs(segments: Segment[], chapterMs: number): number { let virtualMs = 0; let mainElapsedMs = 0; for (const seg of segments) { if (seg.segment_type === 'main') { if (mainElapsedMs + seg.duration_ms > chapterMs) { virtualMs += chapterMs - mainElapsedMs; break; } mainElapsedMs += seg.duration_ms; virtualMs += seg.duration_ms; } else if (mainElapsedMs <= chapterMs) { virtualMs += seg.duration_ms; } } return virtualMs; }