Files
hyperframes/packages/player/src/video-source.ts
Miguel Ángel 840f657028 feat(player): play and loop a range of a film with range-start and range-end (#4688)
* feat(player): play and loop a range of the film with range-start and range-end

The runtime takes a set-play-range control and stops on the range end frame itself.
The player wraps to range-start with loop, fires ended without it, and reports a
range cut to the film with rangeclamped. Video and __timelines players check it per tick.

* fix(player): hold a range on its last frame and let the runtime own range jumps

A runtime with the play-range capability jumps to range-start itself when a new range leaves
the playhead outside, so a stale state no longer ends the film; older runtimes keep the
player fallback, guarded until a state lands inside. Every range stop shows the last frame
before range-end; a paused playhead re-parks only when it falls outside the new range.

* fix(player): stop a range a check early and enforce its end on a video in a background tab

* test(player): share the range video setup

* fix(player): a slow frame no longer cuts a range short; a video without a range is unchanged

* fix(player): a range still stops early where video time moves once per frame

* refactor(player): cut the range look-ahead comment to one line

* fix(player): a per-frame video time rounded down still stops a range early

* test(player): pin no rounding tolerance at a film's own end

* test(studio): expect the play-range capability on control messages
2026-09-28 23:38:32 +00:00

79 lines
2.7 KiB
TypeScript

// Video mode: a `<video playsinline>` behind the direct-timeline adapter a same-origin
// `__timelines` composition uses, so play, seek, loop and `ended` share one code path.
import type { DirectTimelineAdapter } from "./timeline-adapters.js";
export function isVideoType(type: string | null): boolean {
return type?.trim().toLowerCase().startsWith("video/") ?? false;
}
export interface VideoSourceCallbacks {
onMetadata: (video: HTMLVideoElement) => void;
onDurationChange: (video: HTMLVideoElement) => void;
onResize: (video: HTMLVideoElement) => void;
onPlay: (video: HTMLVideoElement) => void;
onPause: (video: HTMLVideoElement) => void;
onTimeUpdate: () => void;
onError: (message: string, code: number | null) => void;
onPlayRejected: (error: unknown) => void;
}
export interface VideoSource {
video: HTMLVideoElement;
adapter: DirectTimelineAdapter;
destroy: () => void;
}
export function createVideoSource(callbacks: VideoSourceCallbacks): VideoSource {
const video = document.createElement("video");
video.className = "hfp-video";
video.playsInline = true;
video.preload = "metadata";
const listening = new AbortController();
const { signal } = listening;
video.addEventListener("loadedmetadata", () => callbacks.onMetadata(video), { signal });
video.addEventListener("durationchange", () => callbacks.onDurationChange(video), { signal });
video.addEventListener("resize", () => callbacks.onResize(video), { signal });
video.addEventListener("play", () => callbacks.onPlay(video), { signal });
video.addEventListener("pause", () => callbacks.onPause(video), { signal });
video.addEventListener("timeupdate", () => callbacks.onTimeUpdate(), { signal });
video.addEventListener(
"error",
() =>
callbacks.onError(video.error?.message || "Video failed to load", video.error?.code ?? null),
{ signal },
);
const adapter: DirectTimelineAdapter = {
duration: () => video.duration,
time: () => video.currentTime,
seek: (timeInSeconds) => {
video.currentTime = timeInSeconds;
},
// A rejection that lands after destroy() belongs to a source that is gone.
play: () =>
video.play().catch((error: unknown) => {
if (!signal.aborted) callbacks.onPlayRejected(error);
}),
pause: () => video.pause(),
// The default rate survives a new src; the current rate alone would reset to it.
timeScale: (rate) => {
video.defaultPlaybackRate = rate;
video.playbackRate = rate;
},
};
return {
video,
adapter,
destroy: () => {
listening.abort();
video.pause();
video.removeAttribute("src");
video.load();
video.remove();
},
};
}