|
|
|
@@ -28,7 +28,82 @@ export interface RecordedMediaArchive {
|
|
|
|
|
export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | "error";
|
|
|
|
|
|
|
|
|
|
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
|
|
|
|
|
const RECORDED_MEDIA_SOURCE_OPEN_TIMEOUT_MS = 10_000;
|
|
|
|
|
const RECORDED_MEDIA_TARGET_TIMEOUT_MS = 10_000;
|
|
|
|
|
const RECORDED_MEDIA_FRAGMENT_TIMEOUT_MS = 15_000;
|
|
|
|
|
const RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS = 12;
|
|
|
|
|
const RECORDED_MEDIA_SEGMENTS_AHEAD = 36;
|
|
|
|
|
const RECORDED_MEDIA_MAX_RESIDENT_SEGMENTS = 160;
|
|
|
|
|
const RECORDED_MEDIA_RETAIN_BEHIND_SEGMENTS = 60;
|
|
|
|
|
let recordedMediaWorkerGeneration = 0;
|
|
|
|
|
const recordedMediaSourceOwners = new WeakMap<HTMLVideoElement, object>();
|
|
|
|
|
|
|
|
|
|
function claimRecordedMediaSource(video: HTMLVideoElement): object {
|
|
|
|
|
const owner = {};
|
|
|
|
|
recordedMediaSourceOwners.set(video, owner);
|
|
|
|
|
return owner;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function releaseRecordedMediaSource(video: HTMLVideoElement, owner: object): boolean {
|
|
|
|
|
if (recordedMediaSourceOwners.get(video) !== owner) return false;
|
|
|
|
|
recordedMediaSourceOwners.delete(video);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function recordedMediaDecodeStartSequence(
|
|
|
|
|
randomAccessSequences: readonly number[],
|
|
|
|
|
targetSequence: number,
|
|
|
|
|
): number | null {
|
|
|
|
|
if (!Number.isInteger(targetSequence) || targetSequence < 1) return null;
|
|
|
|
|
let left = 0;
|
|
|
|
|
let right = randomAccessSequences.length - 1;
|
|
|
|
|
let selected: number | null = null;
|
|
|
|
|
while (left <= right) {
|
|
|
|
|
const middle = Math.floor((left + right) / 2);
|
|
|
|
|
const sequence = randomAccessSequences[middle];
|
|
|
|
|
if (!Number.isInteger(sequence) || sequence < 1) return null;
|
|
|
|
|
if (sequence <= targetSequence) {
|
|
|
|
|
selected = sequence;
|
|
|
|
|
left = middle + 1;
|
|
|
|
|
} else {
|
|
|
|
|
right = middle - 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return selected;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function recordedMediaSegmentAppendOrder(
|
|
|
|
|
appended: ReadonlySet<number>,
|
|
|
|
|
decodeStartSequence: number,
|
|
|
|
|
desiredEndSequence: number,
|
|
|
|
|
): number[] {
|
|
|
|
|
if (
|
|
|
|
|
!Number.isInteger(decodeStartSequence)
|
|
|
|
|
|| !Number.isInteger(desiredEndSequence)
|
|
|
|
|
|| decodeStartSequence < 1
|
|
|
|
|
|| desiredEndSequence < decodeStartSequence
|
|
|
|
|
) return [];
|
|
|
|
|
const missing: number[] = [];
|
|
|
|
|
for (let sequence = decodeStartSequence; sequence <= desiredEndSequence; sequence += 1) {
|
|
|
|
|
if (!appended.has(sequence)) missing.push(sequence);
|
|
|
|
|
}
|
|
|
|
|
return missing;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function recordedMediaTimeRangesContain(
|
|
|
|
|
ranges: TimeRanges,
|
|
|
|
|
targetSeconds: number,
|
|
|
|
|
toleranceSeconds = 0.15,
|
|
|
|
|
): boolean {
|
|
|
|
|
if (!Number.isFinite(targetSeconds)) return false;
|
|
|
|
|
for (let index = 0; index < ranges.length; index += 1) {
|
|
|
|
|
if (
|
|
|
|
|
ranges.start(index) - toleranceSeconds <= targetSeconds
|
|
|
|
|
&& ranges.end(index) + toleranceSeconds >= targetSeconds
|
|
|
|
|
) return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function recordedMediaPresentationState(
|
|
|
|
|
state: "loading" | "ready" | "error",
|
|
|
|
@@ -211,13 +286,15 @@ async function mountRecordedEpochStream(
|
|
|
|
|
// The generation token binds the native media request to the exact manifest.
|
|
|
|
|
// The browser range-streams the virtual init+fragment file from the server;
|
|
|
|
|
// no complete camera archive is copied into JavaScript memory.
|
|
|
|
|
video.preload = "auto";
|
|
|
|
|
const sourceOwner = claimRecordedMediaSource(video);
|
|
|
|
|
video.src = descriptor.streamUrl;
|
|
|
|
|
const cleanup = () => {
|
|
|
|
|
if (!releaseRecordedMediaSource(video, sourceOwner)) return;
|
|
|
|
|
video.pause();
|
|
|
|
|
video.removeAttribute("src");
|
|
|
|
|
video.load();
|
|
|
|
|
};
|
|
|
|
|
video.preload = "auto";
|
|
|
|
|
video.src = descriptor.streamUrl;
|
|
|
|
|
video.load();
|
|
|
|
|
try {
|
|
|
|
|
await waitForSeekableArchive(
|
|
|
|
@@ -232,22 +309,36 @@ async function mountRecordedEpochStream(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface RecordedSegmentTarget {
|
|
|
|
|
readonly revision: number;
|
|
|
|
|
readonly sequence: number;
|
|
|
|
|
readonly decodeStart: number;
|
|
|
|
|
readonly readyEnd: number;
|
|
|
|
|
readonly desiredEnd: number;
|
|
|
|
|
readonly targetSeconds: number;
|
|
|
|
|
forceReset: boolean;
|
|
|
|
|
resetAttempts: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface RecordedSegmentStreamRuntime {
|
|
|
|
|
readonly generation: string;
|
|
|
|
|
readonly mediaSource: MediaSource;
|
|
|
|
|
readonly sourceBuffer: SourceBuffer;
|
|
|
|
|
readonly video: HTMLVideoElement;
|
|
|
|
|
readonly objectUrl: string;
|
|
|
|
|
readonly epoch: ObservationRecordedMediaEpoch;
|
|
|
|
|
readonly contractGeneration: string;
|
|
|
|
|
readonly segmentCount: number;
|
|
|
|
|
readonly randomAccessSequences: readonly number[];
|
|
|
|
|
readonly segmentEndTimesSeconds: readonly number[];
|
|
|
|
|
readonly appended: Set<number>;
|
|
|
|
|
readonly abort: AbortController;
|
|
|
|
|
desiredStart: number;
|
|
|
|
|
desiredEnd: number;
|
|
|
|
|
target: number;
|
|
|
|
|
target: RecordedSegmentTarget | null;
|
|
|
|
|
fetchAbort: AbortController | null;
|
|
|
|
|
notifiedRevision: number;
|
|
|
|
|
pumping: boolean;
|
|
|
|
|
disposed: boolean;
|
|
|
|
|
onTargetReady: ((sequence: number) => void) | null;
|
|
|
|
|
onTargetBuffered: ((target: RecordedSegmentTarget) => void) | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function recordedMediaFragmentUrl(
|
|
|
|
@@ -269,7 +360,12 @@ function waitForMediaSourceOpen(mediaSource: MediaSource, signal: AbortSignal):
|
|
|
|
|
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
|
|
|
if (mediaSource.readyState === "open") return Promise.resolve();
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const timeout = globalThis.setTimeout(() => {
|
|
|
|
|
cleanup();
|
|
|
|
|
reject(new Error("Recorded fragment MediaSource did not open"));
|
|
|
|
|
}, RECORDED_MEDIA_SOURCE_OPEN_TIMEOUT_MS);
|
|
|
|
|
const cleanup = () => {
|
|
|
|
|
globalThis.clearTimeout(timeout);
|
|
|
|
|
mediaSource.removeEventListener("sourceopen", onOpen);
|
|
|
|
|
mediaSource.removeEventListener("sourceclose", onClose);
|
|
|
|
|
signal.removeEventListener("abort", onAbort);
|
|
|
|
@@ -328,15 +424,170 @@ function appendRecordedFragment(
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchRecordedFragment(url: string, signal: AbortSignal): Promise<ArrayBuffer> {
|
|
|
|
|
const response = await fetch(url, {
|
|
|
|
|
method: "GET",
|
|
|
|
|
cache: "force-cache",
|
|
|
|
|
headers: { Accept: "video/mp4" },
|
|
|
|
|
signal,
|
|
|
|
|
function removeRecordedMediaRange(
|
|
|
|
|
sourceBuffer: SourceBuffer,
|
|
|
|
|
startSeconds: number,
|
|
|
|
|
endSeconds: number,
|
|
|
|
|
signal: AbortSignal,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
|
|
|
if (!Number.isFinite(startSeconds) || !Number.isFinite(endSeconds) || endSeconds <= startSeconds) {
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
}
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const cleanup = () => {
|
|
|
|
|
sourceBuffer.removeEventListener("updateend", onUpdateEnd);
|
|
|
|
|
sourceBuffer.removeEventListener("error", onError);
|
|
|
|
|
signal.removeEventListener("abort", onAbort);
|
|
|
|
|
};
|
|
|
|
|
const onUpdateEnd = () => {
|
|
|
|
|
cleanup();
|
|
|
|
|
resolve();
|
|
|
|
|
};
|
|
|
|
|
const onError = () => {
|
|
|
|
|
cleanup();
|
|
|
|
|
reject(new Error("Recorded fragment eviction failed"));
|
|
|
|
|
};
|
|
|
|
|
const onAbort = () => {
|
|
|
|
|
cleanup();
|
|
|
|
|
reject(new DOMException("Aborted", "AbortError"));
|
|
|
|
|
};
|
|
|
|
|
sourceBuffer.addEventListener("updateend", onUpdateEnd, { once: true });
|
|
|
|
|
sourceBuffer.addEventListener("error", onError, { once: true });
|
|
|
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
|
|
|
try {
|
|
|
|
|
sourceBuffer.remove(startSeconds, endSeconds);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
cleanup();
|
|
|
|
|
reject(error);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) throw new Error(`Recorded fragment returned HTTP ${response.status}`);
|
|
|
|
|
return response.arrayBuffer();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchRecordedFragment(url: string, signal: AbortSignal): Promise<ArrayBuffer> {
|
|
|
|
|
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
const abort = () => controller.abort();
|
|
|
|
|
const timeout = globalThis.setTimeout(
|
|
|
|
|
() => controller.abort(),
|
|
|
|
|
RECORDED_MEDIA_FRAGMENT_TIMEOUT_MS,
|
|
|
|
|
);
|
|
|
|
|
signal.addEventListener("abort", abort, { once: true });
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(url, {
|
|
|
|
|
method: "GET",
|
|
|
|
|
cache: "force-cache",
|
|
|
|
|
headers: { Accept: "video/mp4" },
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) throw new Error(`Recorded fragment returned HTTP ${response.status}`);
|
|
|
|
|
return await response.arrayBuffer();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (controller.signal.aborted && !signal.aborted) {
|
|
|
|
|
throw new Error("Recorded fragment request timed out");
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
} finally {
|
|
|
|
|
globalThis.clearTimeout(timeout);
|
|
|
|
|
signal.removeEventListener("abort", abort);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function recordedSegmentChainComplete(
|
|
|
|
|
appended: ReadonlySet<number>,
|
|
|
|
|
target: RecordedSegmentTarget,
|
|
|
|
|
): boolean {
|
|
|
|
|
for (let sequence = target.decodeStart; sequence <= target.readyEnd; sequence += 1) {
|
|
|
|
|
if (!appended.has(sequence)) return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function recordedSegmentStartSeconds(
|
|
|
|
|
segmentEndTimesSeconds: readonly number[],
|
|
|
|
|
sequence: number,
|
|
|
|
|
): number | null {
|
|
|
|
|
if (!Number.isInteger(sequence) || sequence < 1 || sequence > segmentEndTimesSeconds.length) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return sequence === 1 ? 0 : segmentEndTimesSeconds[sequence - 2] ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function recordedSegmentTargetBuffered(
|
|
|
|
|
runtime: RecordedSegmentStreamRuntime,
|
|
|
|
|
target: RecordedSegmentTarget,
|
|
|
|
|
): boolean {
|
|
|
|
|
const readyEndSeconds = runtime.segmentEndTimesSeconds[target.readyEnd - 1];
|
|
|
|
|
if (!Number.isFinite(readyEndSeconds)) return false;
|
|
|
|
|
const readyProbeSeconds = Math.max(target.targetSeconds, readyEndSeconds - 0.001);
|
|
|
|
|
return (
|
|
|
|
|
recordedSegmentChainComplete(runtime.appended, target)
|
|
|
|
|
&& recordedMediaTimeRangesContain(runtime.sourceBuffer.buffered, target.targetSeconds, 0.02)
|
|
|
|
|
&& recordedMediaTimeRangesContain(runtime.sourceBuffer.buffered, readyProbeSeconds, 0.02)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resetRecordedSegmentBuffer(runtime: RecordedSegmentStreamRuntime): Promise<void> {
|
|
|
|
|
const duration = runtime.mediaSource.duration;
|
|
|
|
|
if (runtime.sourceBuffer.buffered.length && Number.isFinite(duration) && duration > 0) {
|
|
|
|
|
await removeRecordedMediaRange(runtime.sourceBuffer, 0, duration, runtime.abort.signal);
|
|
|
|
|
}
|
|
|
|
|
runtime.appended.clear();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function evictRecordedSegmentHistory(
|
|
|
|
|
runtime: RecordedSegmentStreamRuntime,
|
|
|
|
|
target: RecordedSegmentTarget,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
if (runtime.appended.size <= RECORDED_MEDIA_MAX_RESIDENT_SEGMENTS) return;
|
|
|
|
|
const retainedTarget = Math.max(1, target.sequence - RECORDED_MEDIA_RETAIN_BEHIND_SEGMENTS);
|
|
|
|
|
const retainedStart = recordedMediaDecodeStartSequence(
|
|
|
|
|
runtime.randomAccessSequences,
|
|
|
|
|
retainedTarget,
|
|
|
|
|
) ?? 1;
|
|
|
|
|
const removeEnd = recordedSegmentStartSeconds(
|
|
|
|
|
runtime.segmentEndTimesSeconds,
|
|
|
|
|
retainedStart,
|
|
|
|
|
) ?? 0;
|
|
|
|
|
if (removeEnd > 0) {
|
|
|
|
|
await removeRecordedMediaRange(runtime.sourceBuffer, 0, removeEnd, runtime.abort.signal);
|
|
|
|
|
}
|
|
|
|
|
for (const sequence of runtime.appended) {
|
|
|
|
|
if (sequence < retainedStart) runtime.appended.delete(sequence);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchRecordedRuntimeFragment(
|
|
|
|
|
runtime: RecordedSegmentStreamRuntime,
|
|
|
|
|
targetRevision: number,
|
|
|
|
|
sequence: number,
|
|
|
|
|
): Promise<ArrayBuffer | null> {
|
|
|
|
|
runtime.fetchAbort?.abort();
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
runtime.fetchAbort = controller;
|
|
|
|
|
const abortFetch = () => controller.abort();
|
|
|
|
|
runtime.abort.signal.addEventListener("abort", abortFetch, { once: true });
|
|
|
|
|
try {
|
|
|
|
|
const url = recordedMediaFragmentUrl(runtime.epoch, runtime.contractGeneration, sequence);
|
|
|
|
|
const payload = await fetchRecordedFragment(url, controller.signal);
|
|
|
|
|
if (
|
|
|
|
|
runtime.disposed
|
|
|
|
|
|| runtime.abort.signal.aborted
|
|
|
|
|
|| runtime.target?.revision !== targetRevision
|
|
|
|
|
) return null;
|
|
|
|
|
return payload;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (
|
|
|
|
|
controller.signal.aborted
|
|
|
|
|
&& !runtime.disposed
|
|
|
|
|
&& !runtime.abort.signal.aborted
|
|
|
|
|
&& runtime.target?.revision !== targetRevision
|
|
|
|
|
) return null;
|
|
|
|
|
throw error;
|
|
|
|
|
} finally {
|
|
|
|
|
runtime.abort.signal.removeEventListener("abort", abortFetch);
|
|
|
|
|
if (runtime.fetchAbort === controller) runtime.fetchAbort = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function pumpRecordedSegmentWindow(runtime: RecordedSegmentStreamRuntime): Promise<void> {
|
|
|
|
@@ -344,29 +595,98 @@ async function pumpRecordedSegmentWindow(runtime: RecordedSegmentStreamRuntime):
|
|
|
|
|
runtime.pumping = true;
|
|
|
|
|
try {
|
|
|
|
|
while (!runtime.disposed && !runtime.abort.signal.aborted) {
|
|
|
|
|
const targetMissing = !runtime.appended.has(runtime.target) ? runtime.target : null;
|
|
|
|
|
let next = targetMissing;
|
|
|
|
|
if (next === null) {
|
|
|
|
|
for (let sequence = runtime.desiredStart; sequence <= runtime.desiredEnd; sequence += 1) {
|
|
|
|
|
if (!runtime.appended.has(sequence)) {
|
|
|
|
|
next = sequence;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const target = runtime.target;
|
|
|
|
|
if (!target) break;
|
|
|
|
|
if (target.forceReset) {
|
|
|
|
|
await resetRecordedSegmentBuffer(runtime);
|
|
|
|
|
const latest = runtime.target;
|
|
|
|
|
if (!latest) break;
|
|
|
|
|
latest.forceReset = false;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (next === null) break;
|
|
|
|
|
const url = recordedMediaFragmentUrl(runtime.epoch, runtime.contractGeneration, next);
|
|
|
|
|
const payload = await fetchRecordedFragment(url, runtime.abort.signal);
|
|
|
|
|
if (runtime.disposed || runtime.abort.signal.aborted) return;
|
|
|
|
|
await appendRecordedFragment(runtime.sourceBuffer, payload, runtime.abort.signal);
|
|
|
|
|
runtime.appended.add(next);
|
|
|
|
|
if (runtime.appended.has(runtime.target)) runtime.onTargetReady?.(runtime.target);
|
|
|
|
|
const targetDecoderReady = recordedSegmentTargetBuffered(runtime, target);
|
|
|
|
|
if (
|
|
|
|
|
targetDecoderReady
|
|
|
|
|
&& runtime.notifiedRevision !== target.revision
|
|
|
|
|
&& runtime.onTargetBuffered !== null
|
|
|
|
|
) {
|
|
|
|
|
runtime.notifiedRevision = target.revision;
|
|
|
|
|
runtime.onTargetBuffered(target);
|
|
|
|
|
}
|
|
|
|
|
const next = recordedMediaSegmentAppendOrder(
|
|
|
|
|
runtime.appended,
|
|
|
|
|
target.decodeStart,
|
|
|
|
|
target.desiredEnd,
|
|
|
|
|
)[0] ?? null;
|
|
|
|
|
if (next !== null) {
|
|
|
|
|
const payload = await fetchRecordedRuntimeFragment(runtime, target.revision, next);
|
|
|
|
|
if (payload === null) continue;
|
|
|
|
|
await appendRecordedFragment(runtime.sourceBuffer, payload, runtime.abort.signal);
|
|
|
|
|
runtime.appended.add(next);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (targetDecoderReady) {
|
|
|
|
|
await evictRecordedSegmentHistory(runtime, target);
|
|
|
|
|
if (runtime.target === target) break;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (target.resetAttempts < 1) {
|
|
|
|
|
target.resetAttempts += 1;
|
|
|
|
|
target.forceReset = true;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
throw new Error("Recorded fragment target is not decoder-ready");
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
runtime.pumping = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function waitForRecordedVideoTarget(
|
|
|
|
|
video: HTMLVideoElement,
|
|
|
|
|
targetSeconds: number,
|
|
|
|
|
signal: AbortSignal,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const targetReady = () => (
|
|
|
|
|
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
|
|
|
|
|
&& !video.seeking
|
|
|
|
|
&& Math.abs(video.currentTime - targetSeconds) <= 0.25
|
|
|
|
|
&& recordedMediaTimeRangesContain(video.buffered, targetSeconds)
|
|
|
|
|
);
|
|
|
|
|
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
|
|
|
if (targetReady()) return Promise.resolve();
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const events = ["loadeddata", "canplay", "seeked", "timeupdate"] as const;
|
|
|
|
|
const timeout = globalThis.setTimeout(() => {
|
|
|
|
|
cleanup();
|
|
|
|
|
reject(new Error("Recorded fragment target did not become decoder-ready"));
|
|
|
|
|
}, RECORDED_MEDIA_TARGET_TIMEOUT_MS);
|
|
|
|
|
const cleanup = () => {
|
|
|
|
|
globalThis.clearTimeout(timeout);
|
|
|
|
|
for (const event of events) video.removeEventListener(event, onProgress);
|
|
|
|
|
video.removeEventListener("error", onError);
|
|
|
|
|
signal.removeEventListener("abort", onAbort);
|
|
|
|
|
};
|
|
|
|
|
const onProgress = () => {
|
|
|
|
|
if (!targetReady()) return;
|
|
|
|
|
cleanup();
|
|
|
|
|
resolve();
|
|
|
|
|
};
|
|
|
|
|
const onError = () => {
|
|
|
|
|
cleanup();
|
|
|
|
|
reject(new Error("Recorded fragment target decode failed"));
|
|
|
|
|
};
|
|
|
|
|
const onAbort = () => {
|
|
|
|
|
cleanup();
|
|
|
|
|
reject(new DOMException("Aborted", "AbortError"));
|
|
|
|
|
};
|
|
|
|
|
for (const event of events) video.addEventListener(event, onProgress);
|
|
|
|
|
video.addEventListener("error", onError, { once: true });
|
|
|
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
|
|
|
onProgress();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function RecordedFmp4Player({
|
|
|
|
|
source,
|
|
|
|
|
playback,
|
|
|
|
@@ -378,6 +698,7 @@ export function RecordedFmp4Player({
|
|
|
|
|
admissionKey = null,
|
|
|
|
|
onAdmissionChange,
|
|
|
|
|
onPlaybackChange,
|
|
|
|
|
onPlayingRejected,
|
|
|
|
|
}: {
|
|
|
|
|
source: ObservationSourceDescriptor;
|
|
|
|
|
playback?: RecordedObservationPlayback | null;
|
|
|
|
@@ -389,12 +710,15 @@ export function RecordedFmp4Player({
|
|
|
|
|
admissionKey?: string | null;
|
|
|
|
|
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
|
|
|
|
|
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
|
|
|
|
onPlayingRejected?: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
|
|
|
const onAdmissionChangeRef = useRef(onAdmissionChange);
|
|
|
|
|
onAdmissionChangeRef.current = onAdmissionChange;
|
|
|
|
|
const onPlaybackChangeRef = useRef(onPlaybackChange);
|
|
|
|
|
onPlaybackChangeRef.current = onPlaybackChange;
|
|
|
|
|
const onPlayingRejectedRef = useRef(onPlayingRejected);
|
|
|
|
|
onPlayingRejectedRef.current = onPlayingRejected;
|
|
|
|
|
const workerRef = useRef<{ admissionKey: string | null; generation: number } | null>(null);
|
|
|
|
|
if (!workerRef.current || workerRef.current.admissionKey !== admissionKey) {
|
|
|
|
|
recordedMediaWorkerGeneration += 1;
|
|
|
|
@@ -431,7 +755,12 @@ export function RecordedFmp4Player({
|
|
|
|
|
const [bufferRevision, setBufferRevision] = useState(0);
|
|
|
|
|
const segmentedRuntimeRef = useRef<RecordedSegmentStreamRuntime | null>(null);
|
|
|
|
|
const [segmentedRuntimeGeneration, setSegmentedRuntimeGeneration] = useState<string | null>(null);
|
|
|
|
|
const targetRevisionRef = useRef(0);
|
|
|
|
|
const targetReadyAbortRef = useRef<AbortController | null>(null);
|
|
|
|
|
const playAttemptRevisionRef = useRef(0);
|
|
|
|
|
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
|
|
|
|
|
const playbackPlayingRef = useRef(Boolean(playback?.playing));
|
|
|
|
|
playbackPlayingRef.current = Boolean(playback?.playing);
|
|
|
|
|
const playbackRate = playback?.rate && Number.isFinite(playback.rate)
|
|
|
|
|
? Math.min(4, Math.max(0.25, playback.rate))
|
|
|
|
|
: 1;
|
|
|
|
@@ -440,16 +769,17 @@ export function RecordedFmp4Player({
|
|
|
|
|
[archive?.manifest.epochs, currentSeconds],
|
|
|
|
|
);
|
|
|
|
|
const segmented = Boolean(
|
|
|
|
|
segmentSequence !== null
|
|
|
|
|
&& Number.isInteger(segmentSequence)
|
|
|
|
|
&& segmentSequence >= 1
|
|
|
|
|
&& segmentCount !== null
|
|
|
|
|
segmentCount !== null
|
|
|
|
|
&& Number.isInteger(segmentCount)
|
|
|
|
|
&& segmentCount >= segmentSequence
|
|
|
|
|
&& segmentCount >= 1
|
|
|
|
|
&& typeof MediaSource !== "undefined"
|
|
|
|
|
&& epoch
|
|
|
|
|
&& epoch.segmentCount === segmentCount
|
|
|
|
|
&& epoch.randomAccessSequences.length > 0
|
|
|
|
|
&& epoch.segmentEndTimesSeconds.length === segmentCount
|
|
|
|
|
&& MediaSource.isTypeSupported(epoch.mediaType),
|
|
|
|
|
);
|
|
|
|
|
const directPlaybackSeconds = segmented ? null : currentSeconds;
|
|
|
|
|
const waitingForEpoch = Boolean(archive && !epoch);
|
|
|
|
|
const selectedGeneration = contract && epoch
|
|
|
|
|
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}`
|
|
|
|
@@ -546,7 +876,15 @@ export function RecordedFmp4Player({
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const video = videoRef.current;
|
|
|
|
|
if (!video || !archive || !contract || !epoch || !segmented || segmentCount === null) return;
|
|
|
|
|
const epochSegmentCount = epoch?.segmentCount ?? null;
|
|
|
|
|
if (
|
|
|
|
|
!video
|
|
|
|
|
|| !archive
|
|
|
|
|
|| !contract
|
|
|
|
|
|| !epoch
|
|
|
|
|
|| !segmented
|
|
|
|
|
|| epochSegmentCount === null
|
|
|
|
|
) return;
|
|
|
|
|
const abort = new AbortController();
|
|
|
|
|
const mediaSource = new MediaSource();
|
|
|
|
|
const objectUrl = URL.createObjectURL(mediaSource);
|
|
|
|
@@ -557,11 +895,13 @@ export function RecordedFmp4Player({
|
|
|
|
|
setSegmentedRuntimeGeneration(null);
|
|
|
|
|
setState("loading");
|
|
|
|
|
video.pause();
|
|
|
|
|
const sourceOpened = waitForMediaSourceOpen(mediaSource, abort.signal);
|
|
|
|
|
const sourceOwner = claimRecordedMediaSource(video);
|
|
|
|
|
video.src = objectUrl;
|
|
|
|
|
video.load();
|
|
|
|
|
|
|
|
|
|
void (async () => {
|
|
|
|
|
await waitForMediaSourceOpen(mediaSource, abort.signal);
|
|
|
|
|
await sourceOpened;
|
|
|
|
|
const sourceBuffer = mediaSource.addSourceBuffer(epoch.mediaType);
|
|
|
|
|
const initUrl = recordedMediaFragmentUrl(
|
|
|
|
|
epoch,
|
|
|
|
@@ -576,18 +916,21 @@ export function RecordedFmp4Player({
|
|
|
|
|
generation,
|
|
|
|
|
mediaSource,
|
|
|
|
|
sourceBuffer,
|
|
|
|
|
video,
|
|
|
|
|
objectUrl,
|
|
|
|
|
epoch,
|
|
|
|
|
contractGeneration: contract.manifestGenerationSha256,
|
|
|
|
|
segmentCount,
|
|
|
|
|
segmentCount: epochSegmentCount,
|
|
|
|
|
randomAccessSequences: epoch.randomAccessSequences,
|
|
|
|
|
segmentEndTimesSeconds: epoch.segmentEndTimesSeconds,
|
|
|
|
|
appended: new Set(),
|
|
|
|
|
abort,
|
|
|
|
|
desiredStart: 1,
|
|
|
|
|
desiredEnd: 1,
|
|
|
|
|
target: 1,
|
|
|
|
|
target: null,
|
|
|
|
|
fetchAbort: null,
|
|
|
|
|
notifiedRevision: 0,
|
|
|
|
|
pumping: false,
|
|
|
|
|
disposed: false,
|
|
|
|
|
onTargetReady: null,
|
|
|
|
|
onTargetBuffered: null,
|
|
|
|
|
};
|
|
|
|
|
segmentedRuntimeRef.current = runtime;
|
|
|
|
|
setSegmentedRuntimeGeneration(generation);
|
|
|
|
@@ -605,15 +948,17 @@ export function RecordedFmp4Player({
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
abort.abort();
|
|
|
|
|
if (runtime) {
|
|
|
|
|
runtime.disposed = true;
|
|
|
|
|
runtime.onTargetReady = null;
|
|
|
|
|
runtime.fetchAbort?.abort();
|
|
|
|
|
runtime.onTargetBuffered = null;
|
|
|
|
|
}
|
|
|
|
|
abort.abort();
|
|
|
|
|
targetReadyAbortRef.current?.abort();
|
|
|
|
|
if (segmentedRuntimeRef.current === runtime) segmentedRuntimeRef.current = null;
|
|
|
|
|
setSegmentedRuntimeGeneration(null);
|
|
|
|
|
video.pause();
|
|
|
|
|
if (video.src === objectUrl) {
|
|
|
|
|
if (releaseRecordedMediaSource(video, sourceOwner)) {
|
|
|
|
|
video.pause();
|
|
|
|
|
video.removeAttribute("src");
|
|
|
|
|
video.load();
|
|
|
|
|
}
|
|
|
|
@@ -631,45 +976,65 @@ export function RecordedFmp4Player({
|
|
|
|
|
|| !segmented
|
|
|
|
|
|| segmentedRuntimeGeneration !== runtime.generation
|
|
|
|
|
|| segmentSequence === null
|
|
|
|
|
|| !Number.isInteger(segmentSequence)
|
|
|
|
|
|| segmentSequence < 1
|
|
|
|
|
|| segmentSequence > runtime.segmentCount
|
|
|
|
|
) return;
|
|
|
|
|
const archiveByteLength = archive.byteLength;
|
|
|
|
|
runtime.target = segmentSequence;
|
|
|
|
|
runtime.desiredStart = Math.max(1, segmentSequence - 2);
|
|
|
|
|
runtime.desiredEnd = Math.min(runtime.segmentCount, segmentSequence + 36);
|
|
|
|
|
const targetSeconds = recordedMediaLocalTime(
|
|
|
|
|
runtime.epoch.timelineStartSeconds,
|
|
|
|
|
currentSeconds,
|
|
|
|
|
runtime.epoch.timelineEndSeconds - runtime.epoch.timelineStartSeconds,
|
|
|
|
|
const decodeStart = recordedMediaDecodeStartSequence(
|
|
|
|
|
runtime.randomAccessSequences,
|
|
|
|
|
segmentSequence,
|
|
|
|
|
);
|
|
|
|
|
const markReady = (sequence: number) => {
|
|
|
|
|
if (
|
|
|
|
|
runtime.disposed
|
|
|
|
|
|| segmentedRuntimeRef.current !== runtime
|
|
|
|
|
|| runtime.target !== sequence
|
|
|
|
|
) return;
|
|
|
|
|
try {
|
|
|
|
|
if (Math.abs(video.currentTime - targetSeconds) > 0.2) video.currentTime = targetSeconds;
|
|
|
|
|
} catch {
|
|
|
|
|
setState("error");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setBufferRevision((revision) => revision + 1);
|
|
|
|
|
setReadyGeneration(runtime.generation);
|
|
|
|
|
setState("ready");
|
|
|
|
|
reportAdmission({
|
|
|
|
|
phase: "ready",
|
|
|
|
|
byteLength: archiveByteLength,
|
|
|
|
|
message: null,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
runtime.onTargetReady = markReady;
|
|
|
|
|
if (runtime.appended.has(segmentSequence)) {
|
|
|
|
|
markReady(segmentSequence);
|
|
|
|
|
} else {
|
|
|
|
|
if (decodeStart === null) {
|
|
|
|
|
setReadyGeneration(null);
|
|
|
|
|
setState("loading");
|
|
|
|
|
setState("error");
|
|
|
|
|
reportAdmission({
|
|
|
|
|
phase: "error",
|
|
|
|
|
byteLength: archiveByteLength,
|
|
|
|
|
message: "Для кадра записанной камеры нет random-access фрагмента.",
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
void pumpRecordedSegmentWindow(runtime).catch((error: unknown) => {
|
|
|
|
|
const targetSeconds = recordedSegmentStartSeconds(
|
|
|
|
|
runtime.segmentEndTimesSeconds,
|
|
|
|
|
segmentSequence,
|
|
|
|
|
);
|
|
|
|
|
if (targetSeconds === null) {
|
|
|
|
|
setReadyGeneration(null);
|
|
|
|
|
setState("error");
|
|
|
|
|
reportAdmission({
|
|
|
|
|
phase: "error",
|
|
|
|
|
byteLength: archiveByteLength,
|
|
|
|
|
message: "Для кадра записанной камеры нет точной media timestamp.",
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const previousTarget = runtime.target;
|
|
|
|
|
const readyEnd = Math.min(
|
|
|
|
|
runtime.segmentCount,
|
|
|
|
|
segmentSequence + RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS,
|
|
|
|
|
);
|
|
|
|
|
const desiredEnd = Math.min(
|
|
|
|
|
runtime.segmentCount,
|
|
|
|
|
segmentSequence + RECORDED_MEDIA_SEGMENTS_AHEAD,
|
|
|
|
|
);
|
|
|
|
|
const candidateTarget: RecordedSegmentTarget = {
|
|
|
|
|
revision: previousTarget?.revision ?? 0,
|
|
|
|
|
sequence: segmentSequence,
|
|
|
|
|
decodeStart,
|
|
|
|
|
readyEnd,
|
|
|
|
|
desiredEnd,
|
|
|
|
|
targetSeconds,
|
|
|
|
|
forceReset: false,
|
|
|
|
|
resetAttempts: 0,
|
|
|
|
|
};
|
|
|
|
|
const rollingTarget = Boolean(
|
|
|
|
|
playbackPlayingRef.current
|
|
|
|
|
&& previousTarget
|
|
|
|
|
&& runtime.notifiedRevision === previousTarget.revision
|
|
|
|
|
&& recordedSegmentTargetBuffered(runtime, candidateTarget),
|
|
|
|
|
);
|
|
|
|
|
const reportPumpError = (error: unknown) => {
|
|
|
|
|
if (
|
|
|
|
|
runtime.disposed
|
|
|
|
|
|| runtime.abort.signal.aborted
|
|
|
|
@@ -682,11 +1047,95 @@ export function RecordedFmp4Player({
|
|
|
|
|
byteLength: archiveByteLength,
|
|
|
|
|
message: "Покадровый фрагмент записанной камеры недоступен.",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
return () => {
|
|
|
|
|
if (runtime.onTargetReady === markReady) runtime.onTargetReady = null;
|
|
|
|
|
};
|
|
|
|
|
}, [archive?.byteLength, currentSeconds, segmentSequence, segmented, segmentedRuntimeGeneration]);
|
|
|
|
|
if (rollingTarget && previousTarget) {
|
|
|
|
|
runtime.target = {
|
|
|
|
|
...candidateTarget,
|
|
|
|
|
revision: previousTarget.revision,
|
|
|
|
|
};
|
|
|
|
|
runtime.onTargetBuffered = null;
|
|
|
|
|
void pumpRecordedSegmentWindow(runtime).catch(reportPumpError);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
targetRevisionRef.current += 1;
|
|
|
|
|
const target: RecordedSegmentTarget = {
|
|
|
|
|
...candidateTarget,
|
|
|
|
|
revision: targetRevisionRef.current,
|
|
|
|
|
forceReset: Boolean(
|
|
|
|
|
runtime.appended.size
|
|
|
|
|
&& !recordedMediaTimeRangesContain(video.buffered, targetSeconds, 0.02)
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
const targetReadyAbort = new AbortController();
|
|
|
|
|
targetReadyAbortRef.current?.abort();
|
|
|
|
|
targetReadyAbortRef.current = targetReadyAbort;
|
|
|
|
|
runtime.fetchAbort?.abort();
|
|
|
|
|
runtime.target = target;
|
|
|
|
|
const alreadyBuffered = recordedSegmentTargetBuffered(runtime, target);
|
|
|
|
|
video.pause();
|
|
|
|
|
if (!alreadyBuffered) {
|
|
|
|
|
setReadyGeneration(null);
|
|
|
|
|
setState("loading");
|
|
|
|
|
}
|
|
|
|
|
const markBuffered = (bufferedTarget: RecordedSegmentTarget) => {
|
|
|
|
|
if (
|
|
|
|
|
runtime.disposed
|
|
|
|
|
|| segmentedRuntimeRef.current !== runtime
|
|
|
|
|
|| runtime.target?.revision !== bufferedTarget.revision
|
|
|
|
|
|| targetReadyAbort.signal.aborted
|
|
|
|
|
) return;
|
|
|
|
|
void (async () => {
|
|
|
|
|
try {
|
|
|
|
|
if (Math.abs(video.currentTime - bufferedTarget.targetSeconds) > 0.05) {
|
|
|
|
|
video.currentTime = bufferedTarget.targetSeconds;
|
|
|
|
|
}
|
|
|
|
|
await waitForRecordedVideoTarget(
|
|
|
|
|
video,
|
|
|
|
|
bufferedTarget.targetSeconds,
|
|
|
|
|
targetReadyAbort.signal,
|
|
|
|
|
);
|
|
|
|
|
if (
|
|
|
|
|
targetReadyAbort.signal.aborted
|
|
|
|
|
|| runtime.disposed
|
|
|
|
|
|| segmentedRuntimeRef.current !== runtime
|
|
|
|
|
|| runtime.target?.revision !== bufferedTarget.revision
|
|
|
|
|
) return;
|
|
|
|
|
setBufferRevision((revision) => revision + 1);
|
|
|
|
|
setReadyGeneration(runtime.generation);
|
|
|
|
|
setState("ready");
|
|
|
|
|
reportAdmission({
|
|
|
|
|
phase: "ready",
|
|
|
|
|
byteLength: archiveByteLength,
|
|
|
|
|
message: null,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (
|
|
|
|
|
targetReadyAbort.signal.aborted
|
|
|
|
|
|| (error instanceof DOMException && error.name === "AbortError")
|
|
|
|
|
) return;
|
|
|
|
|
setReadyGeneration(null);
|
|
|
|
|
setState("error");
|
|
|
|
|
reportAdmission({
|
|
|
|
|
phase: "error",
|
|
|
|
|
byteLength: archiveByteLength,
|
|
|
|
|
message: "Кадр записанной камеры не стал decoder-ready.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
};
|
|
|
|
|
runtime.onTargetBuffered = markBuffered;
|
|
|
|
|
if (alreadyBuffered) {
|
|
|
|
|
runtime.notifiedRevision = target.revision;
|
|
|
|
|
markBuffered(target);
|
|
|
|
|
}
|
|
|
|
|
void pumpRecordedSegmentWindow(runtime).catch(reportPumpError);
|
|
|
|
|
return () => {
|
|
|
|
|
targetReadyAbort.abort();
|
|
|
|
|
if (targetReadyAbortRef.current === targetReadyAbort) targetReadyAbortRef.current = null;
|
|
|
|
|
if (runtime.onTargetBuffered === markBuffered) runtime.onTargetBuffered = null;
|
|
|
|
|
};
|
|
|
|
|
}, [archive?.byteLength, segmentSequence, segmented, segmentedRuntimeGeneration]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const video = videoRef.current;
|
|
|
|
@@ -739,14 +1188,23 @@ export function RecordedFmp4Player({
|
|
|
|
|
}, [archive?.byteLength, contract, epoch, segmented]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
playAttemptRevisionRef.current += 1;
|
|
|
|
|
const playAttemptRevision = playAttemptRevisionRef.current;
|
|
|
|
|
const video = videoRef.current;
|
|
|
|
|
if (!video || !epoch || visualState !== "ready") return;
|
|
|
|
|
const target = recordedMediaLocalTime(
|
|
|
|
|
epoch.timelineStartSeconds,
|
|
|
|
|
currentSeconds,
|
|
|
|
|
video.duration,
|
|
|
|
|
);
|
|
|
|
|
if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.35) {
|
|
|
|
|
const target = segmented
|
|
|
|
|
? null
|
|
|
|
|
: recordedMediaLocalTime(
|
|
|
|
|
epoch.timelineStartSeconds,
|
|
|
|
|
directPlaybackSeconds ?? epoch.timelineStartSeconds,
|
|
|
|
|
video.duration,
|
|
|
|
|
);
|
|
|
|
|
if (
|
|
|
|
|
!segmented
|
|
|
|
|
&& target !== null
|
|
|
|
|
&& Number.isFinite(target)
|
|
|
|
|
&& Math.abs(video.currentTime - target) > 0.35
|
|
|
|
|
) {
|
|
|
|
|
try {
|
|
|
|
|
video.currentTime = target;
|
|
|
|
|
} catch {
|
|
|
|
@@ -762,17 +1220,28 @@ export function RecordedFmp4Player({
|
|
|
|
|
}
|
|
|
|
|
video.playbackRate = playbackRate;
|
|
|
|
|
if (playback?.playing) {
|
|
|
|
|
void video.play().catch(() => undefined);
|
|
|
|
|
void video.play().catch(() => {
|
|
|
|
|
if (playAttemptRevisionRef.current !== playAttemptRevision) return;
|
|
|
|
|
onPlayingRejectedRef.current?.();
|
|
|
|
|
setReadyGeneration(null);
|
|
|
|
|
setState("error");
|
|
|
|
|
reportAdmission({
|
|
|
|
|
phase: "error",
|
|
|
|
|
byteLength: archive?.byteLength ?? null,
|
|
|
|
|
message: "Запуск записанной камеры отклонён браузером.",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
video.pause();
|
|
|
|
|
}
|
|
|
|
|
}, [
|
|
|
|
|
archive?.byteLength,
|
|
|
|
|
bufferRevision,
|
|
|
|
|
currentSeconds,
|
|
|
|
|
directPlaybackSeconds,
|
|
|
|
|
epoch,
|
|
|
|
|
playback?.playing,
|
|
|
|
|
playbackRate,
|
|
|
|
|
segmented,
|
|
|
|
|
visualState,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
@@ -781,15 +1250,6 @@ export function RecordedFmp4Player({
|
|
|
|
|
if ((!interactive && onPlaybackChange === undefined) || !video || !epoch || visualState !== "ready") return;
|
|
|
|
|
let videoFrameRequest: number | null = null;
|
|
|
|
|
const emitPlayback = () => {
|
|
|
|
|
const expectedLocalSeconds = recordedMediaLocalTime(
|
|
|
|
|
epoch.timelineStartSeconds,
|
|
|
|
|
currentSeconds,
|
|
|
|
|
video.duration,
|
|
|
|
|
);
|
|
|
|
|
// A controlled seek first moves the shared LAB clock and only then fetches
|
|
|
|
|
// its exact fMP4 fragment. Do not let the previously displayed native
|
|
|
|
|
// frame race that pending seek and rewind the shared clock.
|
|
|
|
|
if (Math.abs(video.currentTime - expectedLocalSeconds) > 0.35) return;
|
|
|
|
|
onPlaybackChangeRef.current?.({
|
|
|
|
|
currentSeconds: epoch.timelineStartSeconds + video.currentTime,
|
|
|
|
|
playing: !video.paused && !video.ended,
|
|
|
|
@@ -816,7 +1276,7 @@ export function RecordedFmp4Player({
|
|
|
|
|
video.cancelVideoFrameCallback(videoFrameRequest);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, [currentSeconds, epoch, interactive, onPlaybackChange, visualState]);
|
|
|
|
|
}, [epoch, interactive, onPlaybackChange, visualState]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|