Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/packages/player.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ import "@hyperframes/player";
Set `autoplay muted` only when playback should start without a user gesture.

Add or remove `controls` at any time. When shown, the controls reflect the current
playback state, position and speed.
playback state, position and speed. Clicking or dragging the scrubber keeps a
playing film playing from the new spot, as a `<video>`'s own controls do, and
leaves a paused film paused.

## Attributes

Expand Down Expand Up @@ -93,6 +95,7 @@ const player = document.querySelector("hyperframes-player");
player.play();
player.pause();
player.seek(2.5);
player.seek(2.5, { keepPlaying: true });

player.currentTime = 5;
player.playbackRate = 1.5;
Expand All @@ -102,6 +105,10 @@ console.log(player.duration, player.paused, player.ready);
console.log(player.compositionWidth, player.compositionHeight);
```

`seek()` and setting `currentTime` pause at the new time. Pass `{ keepPlaying: true }`
to keep a playing film playing from there, as setting a `<video>`'s `currentTime`
does; a paused film stays paused.

In Studio previews, scrubbing keeps the last complete picture visible while video
layers decode at the requested time. Rapid seeks keep only the latest queued
destination, and the captions and video layers appear together once it is ready.
Expand Down
7 changes: 4 additions & 3 deletions packages/player/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ If you need a classic `<script>` tag instead of ESM, use the explicit global bui

The player loads the composition in a sandboxed iframe, auto-detects its dimensions and duration, and scales it responsively to fit the container.

Add or remove `controls` at any time. When shown, the controls reflect the current playback state, position and speed.
Add or remove `controls` at any time. When shown, the controls reflect the current playback state, position and speed. Clicking or dragging the scrubber keeps a playing film playing from the new spot, as a `<video>`'s own controls do, and leaves a paused film paused.

### With a framework

Expand Down Expand Up @@ -144,10 +144,11 @@ const player = document.querySelector("hyperframes-player");
// Playback
player.play();
player.pause();
player.seek(2.5); // jump to 2.5 seconds
player.seek(2.5); // jump to 2.5 seconds and pause there
player.seek(2.5, { keepPlaying: true }); // jump, and keep playing if it was playing

// Properties
player.currentTime; // number (read/write)
player.currentTime; // number (read/write); setting it jumps and pauses, like seek()
player.duration; // number (read-only)
player.paused; // boolean (read-only)
player.ready; // boolean (read-only)
Expand Down
82 changes: 82 additions & 0 deletions packages/player/src/controls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,85 @@ describe("createControls idle hide", () => {
vi.useRealTimers();
});
});

describe("createControls scrubbing", () => {
function setup() {
const host = document.createElement("div");
document.body.appendChild(host);
const calls: string[] = [];
const api = createControls(host, {
...noopCallbacks(),
onPlay: () => {
calls.push("play");
api.updatePlaying(true);
},
onPause: () => {
calls.push("pause");
api.updatePlaying(false);
},
onSeek: (fraction) => calls.push(`seek ${fraction}`),
onScrubStart: () => calls.push("scrub start"),
onScrubEnd: () => calls.push("scrub end"),
});
const scrubber = host.querySelector<HTMLElement>(".hfp-scrubber")!;
scrubber.getBoundingClientRect = () => ({ left: 0, width: 100 }) as DOMRect;
const playButton = host.querySelector<HTMLElement>(".hfp-play-btn")!;
const teardown = () => {
api.destroy();
host.remove();
};
return { api, calls, scrubber, playButton, teardown };
}

it("holds a playing film while the scrubber is pressed and plays on at release", () => {
const { api, calls, scrubber, playButton, teardown } = setup();
api.updatePlaying(true);

scrubber.dispatchEvent(new MouseEvent("mousedown", { clientX: 20 }));
document.dispatchEvent(new MouseEvent("mousemove", { clientX: 70 }));

expect(calls).toEqual(["pause", "scrub start", "seek 0.2", "seek 0.7"]);
expect(playButton.classList.contains("hfp-playing")).toBe(true);

document.dispatchEvent(new MouseEvent("mouseup"));

expect(calls).toEqual(["pause", "scrub start", "seek 0.2", "seek 0.7", "scrub end", "play"]);
expect(playButton.classList.contains("hfp-playing")).toBe(true);
teardown();
});

it("leaves a paused film paused", () => {
const { calls, scrubber, playButton, teardown } = setup();

scrubber.dispatchEvent(new MouseEvent("mousedown", { clientX: 30 }));
document.dispatchEvent(new MouseEvent("mouseup"));

expect(calls).toEqual(["scrub start", "seek 0.3", "scrub end"]);
expect(playButton.classList.contains("hfp-playing")).toBe(false);
teardown();
});

it("plays on once after a touch scrub, without the tap replaying as mouse events", () => {
const { api, calls, scrubber, teardown } = setup();
api.updatePlaying(true);

const touch = new Touch({ identifier: 0, target: scrubber, clientX: 40 });
scrubber.dispatchEvent(new TouchEvent("touchstart", { touches: [touch] }));
const touchEnd = new TouchEvent("touchend", { cancelable: true });
document.dispatchEvent(touchEnd);

expect(calls).toEqual(["pause", "scrub start", "seek 0.4", "scrub end", "play"]);
expect(touchEnd.defaultPrevented).toBe(true);
teardown();
});

it("leaves taps elsewhere on the page alone", () => {
const { teardown } = setup();

const touchEnd = new TouchEvent("touchend", { cancelable: true });
document.dispatchEvent(touchEnd);

expect(touchEnd.defaultPrevented).toBe(false);
teardown();
});
});
34 changes: 21 additions & 13 deletions packages/player/src/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,30 +294,38 @@ export function createControls(
};

let scrubbing = false;
let playAfterScrub = false;

scrubber.addEventListener("mousedown", (e) => {
e.stopPropagation();
const startScrub = () => {
scrubbing = true;
playAfterScrub = isPlaying;
if (playAfterScrub) callbacks.onPause();
callbacks.onScrubStart?.();
};
const endScrub = () => {
scrubbing = false;
callbacks.onScrubEnd?.();
if (playAfterScrub) callbacks.onPlay();
};

scrubber.addEventListener("mousedown", (e) => {
e.stopPropagation();
startScrub();
handleScrubAt(e.clientX);
});
const onMouseMove = (e: MouseEvent) => {
if (scrubbing) handleScrubAt(e.clientX);
};
const onMouseUp = () => {
if (scrubbing) {
scrubbing = false;
callbacks.onScrubEnd?.();
}
if (scrubbing) endScrub();
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);

scrubber.addEventListener(
"touchstart",
(e) => {
scrubbing = true;
callbacks.onScrubStart?.();
startScrub();
const touch = e.touches[0];
if (touch) handleScrubAt(touch.clientX);
},
Expand All @@ -329,11 +337,10 @@ export function createControls(
if (touch) handleScrubAt(touch.clientX);
}
};
const onTouchEnd = () => {
if (scrubbing) {
scrubbing = false;
callbacks.onScrubEnd?.();
}
const onTouchEnd = (e: TouchEvent) => {
if (!scrubbing) return;
if (e.cancelable) e.preventDefault();
endScrub();
};
document.addEventListener("touchmove", onTouchMove, { passive: true });
document.addEventListener("touchend", onTouchEnd);
Expand Down Expand Up @@ -368,6 +375,7 @@ export function createControls(
time.textContent = `${formatTime(clampedCurrent)} / ${formatTime(duration)}`;
},
updatePlaying(playing: boolean) {
if (scrubbing && playAfterScrub) return;
const changed = playing !== isPlaying;
isPlaying = playing;
playBtn.classList.toggle("hfp-playing", playing);
Expand Down
132 changes: 130 additions & 2 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -812,12 +812,16 @@ describe("HyperframesPlayer seek() sync path", () => {
postMessage?: ReturnType<typeof vi.fn>;
};
type PlayerInternal = HTMLElement & {
seek: (t: number) => void;
seek: (t: number, options?: { keepPlaying?: boolean }) => void;
play: () => void;
pause: () => void;
stopMedia: () => void;
iframe: HTMLIFrameElement;
_currentTime: number;
_ready: boolean;
_assetsReady: boolean;
currentTime: number;
paused: boolean;
duration: number;
_parentMedia: Array<{
el: { pause: ReturnType<typeof vi.fn>; src: string };
Expand Down Expand Up @@ -1169,6 +1173,107 @@ describe("HyperframesPlayer seek() sync path", () => {
player.seek(11);
expect(player._currentTime).toBe(11);
});

function fakeTimeline(duration: number) {
const state = { time: 0, playing: false };
const timeline: TimelineStub = {
duration: () => duration,
time: () => state.time,
seek: (t) => void (state.time = t),
play: () => void (state.playing = true),
pause: () => void (state.playing = false),
};
return { timeline, state };
}

function recordPlayAndPause(): string[] {
const events: string[] = [];
for (const type of ["play", "pause"]) player.addEventListener(type, () => events.push(type));
return events;
}

it("keeps playing from the new time when seek() is asked to keep playing", () => {
const { timeline, state } = fakeTimeline(10);
stubContentWindow({ __timelines: { main: timeline }, postMessage: vi.fn() });
player.play();
const events = recordPlayAndPause();

player.seek(3, { keepPlaying: true });

expect(player.paused).toBe(false);
expect(player.currentTime).toBe(3);
expect(state).toEqual({ time: 3, playing: true });
expect(events).toEqual([]);
});

it("stays paused when seek() is asked to keep playing a paused film", () => {
const { timeline, state } = fakeTimeline(10);
stubContentWindow({ __timelines: { main: timeline }, postMessage: vi.fn() });
const events = recordPlayAndPause();

player.seek(3, { keepPlaying: true });

expect(player.paused).toBe(true);
expect(state).toEqual({ time: 3, playing: false });
expect(events).toEqual([]);
});

it("asks a cross-origin runtime to play on after a seek that keeps playing", () => {
const post = vi.fn();
stubContentWindow({ postMessage: post });
player._ready = true;
player._assetsReady = true;
player.play();
post.mockClear();

player.seek(3, { keepPlaying: true });

const actions = post.mock.calls
.map(([message]) => message.action)
.filter((action) => action === "seek" || action === "play");
expect(actions).toEqual(["seek", "play"]);
expect(post).toHaveBeenCalledWith(
expect.objectContaining({ action: "seek", timeSeconds: 3 }),
"*",
);
expect(player.paused).toBe(false);
});

it("keeps a playing film playing from where the viewer releases the scrubber", () => {
const { timeline, state } = fakeTimeline(10);
stubContentWindow({ __timelines: { main: timeline }, postMessage: vi.fn() });
player.setAttribute("controls", "");
player.play();
const events = recordPlayAndPause();
const scrubber = player.shadowRoot!.querySelector<HTMLElement>(".hfp-scrubber")!;
scrubber.getBoundingClientRect = () => ({ left: 0, width: 100 }) as DOMRect;

scrubber.dispatchEvent(new MouseEvent("mousedown", { clientX: 20 }));
document.dispatchEvent(new MouseEvent("mousemove", { clientX: 70 }));
document.dispatchEvent(new MouseEvent("mouseup"));

expect(player.paused).toBe(false);
expect(player.currentTime).toBe(7);
expect(state).toEqual({ time: 7, playing: true });
expect(events).toEqual(["pause", "play"]);
});

it("leaves a paused film paused when the viewer clicks the scrubber", () => {
const { timeline, state } = fakeTimeline(10);
stubContentWindow({ __timelines: { main: timeline }, postMessage: vi.fn() });
player.setAttribute("controls", "");
player.seek(0);
const events = recordPlayAndPause();
const scrubber = player.shadowRoot!.querySelector<HTMLElement>(".hfp-scrubber")!;
scrubber.getBoundingClientRect = () => ({ left: 0, width: 100 }) as DOMRect;

scrubber.dispatchEvent(new MouseEvent("mousedown", { clientX: 70 }));
document.dispatchEvent(new MouseEvent("mouseup"));

expect(player.paused).toBe(true);
expect(state).toEqual({ time: 7, playing: false });
expect(events).toEqual([]);
});
});

describe("HyperframesPlayer loop end-state handling", () => {
Expand Down Expand Up @@ -3807,7 +3912,7 @@ describe("HyperframesPlayer asset-ready gate", () => {
_onIframeLoad(): void;
play(): void;
pause(): void;
seek(timeInSeconds: number): void;
seek(timeInSeconds: number, options?: { keepPlaying?: boolean }): void;
shaderLoader: {
showAssetsLoading(): void;
hide(): void;
Expand Down Expand Up @@ -4386,6 +4491,29 @@ describe("HyperframesPlayer asset-ready gate", () => {
player.remove();
});

it("keeps a queued play if a seek asks to keep playing while assets are still buffering", async () => {
const player = await createConnectedPlayer();

const { doc, video } = createStalledVideoDoc();
stubIframeContentDocument(player.iframe, doc);

const playSpy = vi.fn();
player.addEventListener("play", playSpy);

player._waitForAssetsReady(doc);
player.play();
player.seek(1.5, { keepPlaying: true });

video.dispatchEvent(new Event("canplay"));
await new Promise((resolve) => setTimeout(resolve, 0));

expect(player.assetsReady).toBe(true);
expect(player._paused).toBe(false);
expect(playSpy).toHaveBeenCalledTimes(1);

player.remove();
});

it("settles a play() called twice while buffering into exactly one playback start (pre-existing idempotency, not the pause/seek cancel)", async () => {
const player = await createConnectedPlayer();

Expand Down
10 changes: 6 additions & 4 deletions packages/player/src/hyperframes-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,10 +487,11 @@ class HyperframesPlayer extends HTMLElement {
this._media.stopAdoptedMedia();
}

seek(timeInSeconds: number) {
// seek()'s own contract is that it lands paused, so a play still queued
// from the asset-buffering window is cancelled here too — same reason as
// pause() above.
seek(timeInSeconds: number, options?: { keepPlaying?: boolean }) {
const playOn = options?.keepPlaying === true && (!this._paused || this._pendingPlay);
// A plain seek() lands paused, so a play still queued from the
// asset-buffering window is cancelled here too — same reason as pause()
// above. keepPlaying plays on from the new time, as <video>'s currentTime does.
this._pendingPlay = false;
if (!this._trySyncSeek(timeInSeconds) && !this._tryDirectTimelineSeek(timeInSeconds)) {
this._sendControl("seek", {
Expand Down Expand Up @@ -523,6 +524,7 @@ class HyperframesPlayer extends HTMLElement {
this._paused = true;
this.controlsApi?.updatePlaying(false);
this.controlsApi?.updateTime(this._currentTime, this._duration);
if (playOn) this._play(false);
}

setColorGrading(target: ColorGradingTarget, grading: unknown) {
Expand Down
Loading
Loading