153 lines
4.8 KiB
TypeScript
153 lines
4.8 KiB
TypeScript
import type { TimelineBounds } from "./types";
|
|
|
|
import { formatTime } from "./controls";
|
|
import { state } from "./state";
|
|
|
|
// cached to avoid recalc on every timeupdate
|
|
let cachedBounds: TimelineBounds = { start: 0, end: 0, duration: 0 };
|
|
let cachedDuration = 0;
|
|
let cachedSeekableEnd = 0;
|
|
|
|
const getDuration = (): number =>
|
|
Number.isFinite(state.elements.video.duration) && state.elements.video.duration > 0
|
|
? state.elements.video.duration
|
|
: 0;
|
|
|
|
const getSeekableEnd = (): number => {
|
|
const ranges = state.elements.video.seekable;
|
|
if (!ranges || ranges.length <= 0) {
|
|
return 0;
|
|
}
|
|
const end = ranges.end(ranges.length - 1);
|
|
return Number.isFinite(end) && end > 0 ? end : 0;
|
|
};
|
|
|
|
/**
|
|
* Computes timeline bounds from video. Uses the full duration for VOD, and seekable ranges only
|
|
* when duration is unavailable.
|
|
*/
|
|
const timelineBounds = (): TimelineBounds => {
|
|
const duration = getDuration();
|
|
if (duration > 0) {
|
|
return { start: 0, end: duration, duration };
|
|
}
|
|
|
|
if (state.elements.video.seekable.length > 0) {
|
|
const seekableStart = state.elements.video.seekable.start(0);
|
|
const seekableEnd = state.elements.video.seekable.end(state.elements.video.seekable.length - 1);
|
|
if (
|
|
Number.isFinite(seekableStart) &&
|
|
Number.isFinite(seekableEnd) &&
|
|
seekableEnd > seekableStart
|
|
) {
|
|
return { start: seekableStart, end: seekableEnd, duration: seekableEnd - seekableStart };
|
|
}
|
|
}
|
|
|
|
return { start: 0, end: duration, duration };
|
|
};
|
|
|
|
export const invalidateBounds = (): void => {
|
|
cachedBounds = timelineBounds();
|
|
cachedDuration = getDuration();
|
|
cachedSeekableEnd = getSeekableEnd();
|
|
};
|
|
|
|
export const getBounds = (): TimelineBounds => {
|
|
// lazy init + refresh: some streams expand seekable range over time (often tracks buffered end).
|
|
// If we keep stale bounds, seeking past "watched/buffered" can map to only a fraction of the real timeline.
|
|
const duration = getDuration();
|
|
const seekableEnd = getSeekableEnd();
|
|
if (
|
|
cachedBounds.duration <= 0 ||
|
|
duration !== cachedDuration ||
|
|
seekableEnd !== cachedSeekableEnd
|
|
) {
|
|
cachedBounds = timelineBounds();
|
|
cachedDuration = duration;
|
|
cachedSeekableEnd = seekableEnd;
|
|
}
|
|
return cachedBounds;
|
|
};
|
|
|
|
// converts video.currentTime to timeline-relative time (0-based for UI display)
|
|
export const displayTimeFromAbsolute = (absoluteTime: number): number => {
|
|
const b = getBounds();
|
|
if (!Number.isFinite(absoluteTime) || b.duration <= 0) {
|
|
return 0;
|
|
}
|
|
return Math.max(b.start, Math.min(b.end, absoluteTime)) - b.start;
|
|
};
|
|
|
|
// converts timeline-relative time back to video time
|
|
export const absoluteTimeFromDisplay = (displayTime: number): number => {
|
|
const b = getBounds();
|
|
if (!Number.isFinite(displayTime) || b.duration <= 0) {
|
|
return 0;
|
|
}
|
|
return b.start + Math.max(0, Math.min(b.duration, displayTime));
|
|
};
|
|
|
|
// converts 0-1 ratio to absolute video time
|
|
export const absoluteTimeFromRatio = (ratio: number): number => {
|
|
const b = getBounds();
|
|
if (!Number.isFinite(ratio) || b.duration <= 0) {
|
|
return 0;
|
|
}
|
|
return b.start + Math.max(0, Math.min(1, ratio)) * b.duration;
|
|
};
|
|
|
|
// finds the end of the buffered region containing currentTime
|
|
const getBufferedEnd = (): number => {
|
|
const { currentTime } = state.elements.video;
|
|
let end = 0;
|
|
// first: find buffered range that contains current time
|
|
for (let i = 0; i < state.elements.video.buffered.length; i++) {
|
|
if (
|
|
state.elements.video.buffered.start(i) <= currentTime &&
|
|
state.elements.video.buffered.end(i) >= currentTime
|
|
) {
|
|
end = state.elements.video.buffered.end(i);
|
|
break;
|
|
}
|
|
}
|
|
// fallback: next buffered range after current time
|
|
if (end === 0) {
|
|
for (let i = 0; i < state.elements.video.buffered.length; i++) {
|
|
if (state.elements.video.buffered.end(i) > currentTime) {
|
|
end = Math.max(end, state.elements.video.buffered.end(i));
|
|
}
|
|
}
|
|
}
|
|
return end;
|
|
};
|
|
|
|
/**
|
|
* Updates progress bar, scrubber position, and time displays. Called on timeupdate, progress
|
|
* events, and seek.
|
|
*/
|
|
export const updateTimeline = (currentTime: number): void => {
|
|
const { progress, scrubber, timeDisplay, durationDisplay, buffered } = state.elements;
|
|
const b = getBounds();
|
|
|
|
if (b.duration <= 0) {
|
|
progress.style.width = "0%";
|
|
buffered.style.width = "0%";
|
|
scrubber.style.left = "0%";
|
|
timeDisplay.textContent = "00:00";
|
|
durationDisplay.textContent = "00:00";
|
|
return;
|
|
}
|
|
|
|
const pct = (displayTimeFromAbsolute(currentTime) / b.duration) * 100;
|
|
progress.style.width = `${pct}%`;
|
|
scrubber.style.left = `${pct}%`;
|
|
timeDisplay.textContent = formatTime(displayTimeFromAbsolute(currentTime));
|
|
durationDisplay.textContent = formatTime(b.duration);
|
|
|
|
// buffered region
|
|
const bufferedEnd = getBufferedEnd();
|
|
const bufferedPct = (displayTimeFromAbsolute(bufferedEnd) / b.duration) * 100;
|
|
buffered.style.width = `${bufferedPct}%`;
|
|
};
|