| 1 |
// Pure media-player logic (no DOM) — the virtual-timeline math for gapless |
| 2 |
// "segment" playback, extracted from static/media-player.js where it was |
| 3 |
// inlined seven times. Imported by the element and unit-tested directly. |
| 4 |
// |
| 5 |
// A track can be split into ordered segments: `main` segments (slices of the |
| 6 |
// creator's media file) interleaved with insertions (ads/intros). A single |
| 7 |
// main media URL may appear across several `main` segments (split around |
| 8 |
// insertions), so seeking within one needs its offset *inside that URL*. |
| 9 |
|
| 10 |
export interface Segment { |
| 11 |
url: string; |
| 12 |
duration_ms: number; |
| 13 |
/** 'main' for the creator's media; anything else is an insertion. */ |
| 14 |
segment_type: string; |
| 15 |
title?: string; |
| 16 |
} |
| 17 |
|
| 18 |
/** Format seconds as `m:ss` (or `h:mm:ss`). Ported from media-player.js:31. */ |
| 19 |
export function formatTime(seconds: number): string { |
| 20 |
if (Number.isNaN(seconds)) return '0:00'; |
| 21 |
const h = Math.floor(seconds / 3600); |
| 22 |
const m = Math.floor((seconds % 3600) / 60); |
| 23 |
const s = Math.floor(seconds % 60); |
| 24 |
const ss = s.toString().padStart(2, '0'); |
| 25 |
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${ss}`; |
| 26 |
return `${m}:${ss}`; |
| 27 |
} |
| 28 |
|
| 29 |
/** Cumulative virtual start (ms) of each segment, and the total duration. */ |
| 30 |
export function buildTimeline(segments: Segment[]): { segStarts: number[]; totalMs: number } { |
| 31 |
const segStarts: number[] = []; |
| 32 |
let totalMs = 0; |
| 33 |
for (const seg of segments) { |
| 34 |
segStarts.push(totalMs); |
| 35 |
totalMs += seg.duration_ms; |
| 36 |
} |
| 37 |
return { segStarts, totalMs }; |
| 38 |
} |
| 39 |
|
| 40 |
/** |
| 41 |
* Offset (ms) of segment `idx` within its own main media URL: the summed |
| 42 |
* duration of every earlier `main` segment sharing the same URL. Zero for |
| 43 |
* insertions or a URL's first appearance. This is the calculation that was |
| 44 |
* copy-pasted seven times in the original — the single source now. |
| 45 |
*/ |
| 46 |
export function mainOffsetMs(segments: Segment[], idx: number): number { |
| 47 |
const seg = segments[idx]; |
| 48 |
if (!seg || seg.segment_type !== 'main') return 0; |
| 49 |
let offset = 0; |
| 50 |
for (let j = 0; j < idx; j++) { |
| 51 |
if (segments[j].segment_type === 'main' && segments[j].url === seg.url) { |
| 52 |
offset += segments[j].duration_ms; |
| 53 |
} |
| 54 |
} |
| 55 |
return offset; |
| 56 |
} |
| 57 |
|
| 58 |
/** The segment index containing virtual time `targetMs`; clamps to the last |
| 59 |
* segment past the end. Ported from the three identical search loops. */ |
| 60 |
export function segmentIndexAtVirtualMs(segStarts: number[], segments: Segment[], targetMs: number): number { |
| 61 |
for (let k = 0; k < segments.length; k++) { |
| 62 |
if (targetMs < segStarts[k] + segments[k].duration_ms) return k; |
| 63 |
} |
| 64 |
return segments.length - 1; |
| 65 |
} |
| 66 |
|
| 67 |
/** |
| 68 |
* Map a chapter time (measured against the *main* media only, ignoring |
| 69 |
* insertions) to a virtual timeline position in ms. Ported from |
| 70 |
* media-player.js:381 — a chapter at main-time T lands after all insertions |
| 71 |
* that precede it. |
| 72 |
*/ |
| 73 |
export function chapterToVirtualMs(segments: Segment[], chapterMs: number): number { |
| 74 |
let virtualMs = 0; |
| 75 |
let mainElapsedMs = 0; |
| 76 |
for (const seg of segments) { |
| 77 |
if (seg.segment_type === 'main') { |
| 78 |
if (mainElapsedMs + seg.duration_ms > chapterMs) { |
| 79 |
virtualMs += chapterMs - mainElapsedMs; |
| 80 |
break; |
| 81 |
} |
| 82 |
mainElapsedMs += seg.duration_ms; |
| 83 |
virtualMs += seg.duration_ms; |
| 84 |
} else if (mainElapsedMs <= chapterMs) { |
| 85 |
virtualMs += seg.duration_ms; |
| 86 |
} |
| 87 |
} |
| 88 |
return virtualMs; |
| 89 |
} |
| 90 |
|