From 3756463ee16a953b1c2522b235e962190bce07f6 Mon Sep 17 00:00:00 2001 From: Karn Date: Sun, 26 Jul 2026 03:14:05 +0530 Subject: [PATCH] feat(scenes): ambient first-load preview A scene sequence paused at time 0 renders every scene in its pre-entrance state, so the default first-load frame is a blank stage behind a play button. Fill it with the lesson itself instead of a poster asset: `createScenesProvider` and `ScenesPlayer` take an optional `preview: { endSeconds, cycles? }`, and with it the stage plays its opening window muted on load, loops it (twice by default), then holds the opening scene's settled final frame. Throughout the preview MediaState keeps reporting a paused player at time 0, carrying the viewer's own volume and muted values. Every chrome surface and every consumer already keys off that idle shape, so the idle overlay, control bar and captions behave exactly as they do without a preview and nothing downstream mistakes the loop for playback. Only buffered and readyState flow through, since loading progress is not clock state and cross-lesson autoplay arms on the readyState edge. The first transport action hands the clock back: the host is paused, seeked to where the viewer wants it, and given back their audio settings and rate, after which snapshots flow normally and nothing preview-related runs again. play() hands back at the top, so the preview is a tease of the opening rather than progress through it. setRate is the one exception, since the idle speed chips set a rate and then play. The loop always runs at 1x whatever speed the viewer watches at: their rate is how fast they want to learn, not how fast a teaser should move, and a saved 2x or 2.5x turns the loop frantic. Two ordering guards, both against snapshots the host queued before a seek landed: one stops a boundary snapshot burning a second loop cycle, the other drops a stale snapshot that would flash the preview clock into the scrubber on hand-back. endSeconds comes from the caller, which is the side that has the manifest; the opening scene's boundary is the natural window, since one that ends mid-motion also restarts mid-motion. The preview is skipped for prefers-reduced-motion, for saveData, and when autoPlay is set. It waits for the player to intersect the viewport before starting so a lesson below the fold does not spend its loops unwatched, and if muted autoplay is refused (iOS low power mode) it settles on the held frame, which is still a real frame of the lesson. No protocol change and no host change, so this needs no runtime republish. Omit `preview` and nothing changes. --- .changeset/silly-donuts-shave.md | 9 + src/scenes.ts | 6 +- src/scenes/provider.test.ts | 342 +++++++++++++++++++++++++++++++ src/scenes/provider.ts | 255 ++++++++++++++++++++++- 4 files changed, 609 insertions(+), 3 deletions(-) create mode 100644 .changeset/silly-donuts-shave.md diff --git a/.changeset/silly-donuts-shave.md b/.changeset/silly-donuts-shave.md new file mode 100644 index 0000000..c98e7b0 --- /dev/null +++ b/.changeset/silly-donuts-shave.md @@ -0,0 +1,9 @@ +--- +"@karnstack/kino": minor +--- + +Scenes: an ambient first-load preview. `createScenesProvider` and `ScenesPlayer` take an optional `preview: { endSeconds, cycles? }`; with it, the stage plays its opening window muted on load, loops it (twice by default), then holds the opening scene's settled final frame. A scene sequence paused at time 0 renders its scenes in their pre-entrance state, so the default first-load frame is a blank stage behind a play button; this fills it with the lesson itself rather than a poster asset. + +Throughout the preview `MediaState` keeps reporting a paused player at time 0, carrying the viewer's own volume and muted values. Every chrome surface and every consumer already keys off that idle shape, so the idle overlay, control bar and captions behave exactly as they do without a preview, and nothing downstream mistakes the loop for playback. Only `buffered` and `readyState` flow through, since loading progress is not clock state. The first transport action hands the clock back: the host is paused, seeked to where the viewer wants it, and given back their audio settings and rate, after which snapshots flow normally and nothing preview-related runs again. `play()` hands back at the top, so the preview is a tease of the opening rather than progress through it. + +`endSeconds` comes from the caller, which is the side that has the manifest; the opening scene's boundary is the natural window, since one that ends mid-motion also restarts mid-motion. The preview is skipped for `prefers-reduced-motion: reduce`, for `navigator.connection.saveData`, and when `autoPlay` is set. It waits for the player to intersect the viewport before starting, so a lesson below the fold does not spend its loops unwatched, and if muted autoplay is refused (iOS low power mode) it settles on the held frame instead, which is still a real frame of the lesson. Omit `preview` and nothing changes. diff --git a/src/scenes.ts b/src/scenes.ts index 20feb41..a2e047c 100644 --- a/src/scenes.ts +++ b/src/scenes.ts @@ -11,7 +11,11 @@ export { createSceneClock } from "./scenes/cues" export type { SceneClock } from "./scenes/cues" export { sceneAt, localTime } from "./scenes/sequence-timeline" export { createScenesProvider } from "./scenes/provider" -export type { ScenesProviderOptions, ScenesProvider } from "./scenes/provider" +export type { + ScenesProviderOptions, + ScenesProvider, + ScenesPreviewOptions, +} from "./scenes/provider" export { ScenesPlayer } from "./scenes/scenes-player" export type { ScenesPlayerProps } from "./scenes/scenes-player" export { createSceneHost } from "./scenes/host" diff --git a/src/scenes/provider.test.ts b/src/scenes/provider.test.ts index ea68f2e..4ce630c 100644 --- a/src/scenes/provider.test.ts +++ b/src/scenes/provider.test.ts @@ -848,3 +848,345 @@ test("enterFullscreen is a no-op while in pip", async () => { p.destroy() uninstall() }) + +// --------------------------------------------------------------------------- +// Ambient first-load preview +// --------------------------------------------------------------------------- + +const PREVIEW = { endSeconds: 20, cycles: 2 } + +// A host snapshot for the preview loop: playing, at `currentTime`. +function playingAt(currentTime: number): HostEvent { + return { + type: "kino:state", + state: { + currentTime, + duration: 40.5, + paused: false, + buffered: [[0, 20]], + seeking: false, + ended: false, + rate: 1, + volume: 1, + muted: true, + readyState: 4, + }, + } +} + +// Bring a provider up to the point where the muted loop is running, and hand +// back the command log with the handshake traffic already dropped. +function mountPreviewing( + options: Partial[0]> = {}, +) { + const p = createScenesProvider({ src: SRC, preview: PREVIEW, ...options }) + const { iframe } = mount(p) + const posted: unknown[] = [] + iframe.contentWindow!.postMessage = (msg: unknown) => posted.push(msg) + fromHost(iframe, { type: "kino:ready", duration: 40.5 }) + return { p, iframe, posted } +} + +test("without the preview option the handshake starts nothing", () => { + const p = createScenesProvider({ src: SRC }) + const { iframe } = mount(p) + const posted: unknown[] = [] + iframe.contentWindow!.postMessage = (msg: unknown) => posted.push(msg) + fromHost(iframe, { type: "kino:ready", duration: 40.5 }) + expect(posted).toEqual([ + expect.objectContaining({ type: "kino:init", autoPlay: false }), + ]) + p.destroy() +}) + +test("preview starts the loop muted, and mutes before it plays", () => { + const { p, posted } = mountPreviewing() + expect(posted).toContainEqual({ type: "kino:setMuted", muted: true }) + expect(posted).toContainEqual({ type: "kino:seek", time: 0 }) + expect(posted).toContainEqual({ type: "kino:play" }) + // Muted-autoplay policy only exempts a player that is already muted. + const muteAt = posted.findIndex( + (m) => (m as { type: string }).type === "kino:setMuted", + ) + const playAt = posted.findIndex( + (m) => (m as { type: string }).type === "kino:play", + ) + expect(muteAt).toBeLessThan(playAt) + p.destroy() +}) + +test("the loop never reaches MediaState: the player keeps reading idle", () => { + const { p, iframe } = mountPreviewing() + fromHost(iframe, playingAt(7.5)) + const s = p.getState() + // Exactly the shape IdleOverlay, ControlBar and Captions gate on. + expect(s.paused).toBe(true) + expect(s.currentTime).toBe(0) + expect(s.ended).toBe(false) + // The viewer's own audio settings, not the loop's. + expect(s.muted).toBe(false) + // Loading progress is not clock state, so it still flows: cross-lesson + // autoplay arms on the readyState edge. + expect(s.readyState).toBe(4) + expect(s.buffered).toEqual([[0, 20]]) + p.destroy() +}) + +test("no caption fires while the preview runs", () => { + const { p, iframe } = mountPreviewing({ + captions: { + src: "https://scenes.example.com/c.vtt", + label: "en", + srclang: "en", + }, + }) + p.actions.setTextTrack("captions") + fromHost(iframe, playingAt(7.5)) + expect(p.getState().activeCueText).toBe("") + p.destroy() +}) + +test("the window loops `cycles` times, then settles on the held frame", () => { + const { p, iframe, posted } = mountPreviewing() + const seeks = () => + posted.filter((m) => (m as { type: string }).type === "kino:seek") + + fromHost(iframe, playingAt(19.9)) + expect(seeks()).toHaveLength(1) // just the start-of-loop seek + + fromHost(iframe, playingAt(20.1)) + expect(seeks()).toEqual([ + { type: "kino:seek", time: 0 }, + { type: "kino:seek", time: 0 }, + ]) + + fromHost(iframe, playingAt(3)) + fromHost(iframe, playingAt(20.2)) + // Last cycle spent: pause on the opening scene's settled final frame. + expect(posted).toContainEqual({ type: "kino:pause" }) + expect(seeks().at(-1)).toEqual({ type: "kino:seek", time: 20 - 0.15 }) + // Still idle to everyone watching. + expect(p.getState().paused).toBe(true) + expect(p.getState().currentTime).toBe(0) + p.destroy() +}) + +test("a snapshot queued before the loop seek cannot burn a second cycle", () => { + const { p, iframe, posted } = mountPreviewing() + fromHost(iframe, playingAt(20.1)) + // Same boundary again: the host had this in flight before our seek landed. + fromHost(iframe, playingAt(20.15)) + expect(posted).not.toContainEqual({ type: "kino:pause" }) + fromHost(iframe, playingAt(1)) + fromHost(iframe, playingAt(20.2)) + expect(posted).toContainEqual({ type: "kino:pause" }) + p.destroy() +}) + +test("play() hands the clock back at the top with the viewer's audio", () => { + const { p, iframe, posted } = mountPreviewing() + fromHost(iframe, playingAt(12)) + posted.length = 0 + p.actions.play() + expect(posted).toEqual([ + { type: "kino:pause" }, + { type: "kino:seek", time: 0 }, + { type: "kino:setMuted", muted: false }, + { type: "kino:setVolume", volume: 1 }, + { type: "kino:setRate", rate: 1 }, + { type: "kino:play" }, + ]) + // And the host is authoritative again. + fromHost(iframe, snapshot(1, 0.4)) + expect(p.getState().currentTime).toBe(0.4) + expect(p.getState().paused).toBe(false) + p.destroy() +}) + +test("play() after the preview settled still starts from the top", () => { + const { p, iframe, posted } = mountPreviewing({ + preview: { endSeconds: 20, cycles: 1 }, + }) + fromHost(iframe, playingAt(20.1)) + posted.length = 0 + p.actions.play() + expect(posted).toContainEqual({ type: "kino:seek", time: 0 }) + p.destroy() +}) + +test("a stale snapshot after the hand-back is dropped, not flashed", () => { + const { p, iframe } = mountPreviewing() + fromHost(iframe, playingAt(12)) + p.actions.play() + // Queued by the host before our seek landed; it still carries the loop clock. + fromHost(iframe, snapshot(1, 12.1)) + expect(p.getState().currentTime).toBe(0) + // The host agreeing releases the guard. + fromHost(iframe, snapshot(1, 0.2)) + expect(p.getState().currentTime).toBe(0.2) + p.destroy() +}) + +test("seek() hands the clock back at the requested time", () => { + const { p, iframe, posted } = mountPreviewing() + fromHost(iframe, playingAt(12)) + posted.length = 0 + p.actions.seek(300) + expect(posted).toContainEqual({ type: "kino:setMuted", muted: false }) + expect( + posted.filter((m) => (m as { type: string }).type === "kino:seek"), + ).toEqual([ + { type: "kino:seek", time: 300 }, + { type: "kino:seek", time: 300 }, + ]) + expect(p.getState().currentTime).toBe(300) + p.destroy() +}) + +test("setRate holds the preview: the chips set a speed, then play", () => { + const { p, iframe, posted } = mountPreviewing() + fromHost(iframe, playingAt(5)) + posted.length = 0 + p.actions.setRate(2.5) + // Nothing posted: speeding the muted loop under the viewer only looks broken. + expect(posted).toEqual([]) + expect(p.getState().rate).toBe(2.5) + // The chosen rate rides the hand-back instead. + p.actions.play() + expect(posted).toContainEqual({ type: "kino:setRate", rate: 2.5 }) + p.destroy() +}) + +test("refused muted autoplay settles on the held frame after the grace", () => { + vi.useFakeTimers() + const p = createScenesProvider({ src: SRC, preview: PREVIEW }) + const { iframe } = mount(p) + const posted: unknown[] = [] + iframe.contentWindow!.postMessage = (msg: unknown) => posted.push(msg) + fromHost(iframe, { type: "kino:ready", duration: 40.5 }) + // iOS low power mode: play() rejected, so the clock never moves. + vi.advanceTimersByTime(900) + expect(posted).toContainEqual({ type: "kino:pause" }) + expect(posted).toContainEqual({ type: "kino:seek", time: 20 - 0.15 }) + expect(p.getState().paused).toBe(true) + p.destroy() + vi.useRealTimers() +}) + +test("a rolling loop is never settled by the grace timer", () => { + vi.useFakeTimers() + const p = createScenesProvider({ src: SRC, preview: PREVIEW }) + const { iframe } = mount(p) + const posted: unknown[] = [] + iframe.contentWindow!.postMessage = (msg: unknown) => posted.push(msg) + fromHost(iframe, { type: "kino:ready", duration: 40.5 }) + fromHost(iframe, playingAt(0.3)) + vi.advanceTimersByTime(900) + expect(posted).not.toContainEqual({ type: "kino:pause" }) + p.destroy() + vi.useRealTimers() +}) + +test("autoPlay wins over preview: nothing is teased", () => { + const p = createScenesProvider({ src: SRC, preview: PREVIEW, autoPlay: true }) + const { iframe } = mount(p) + const posted: unknown[] = [] + iframe.contentWindow!.postMessage = (msg: unknown) => posted.push(msg) + fromHost(iframe, { type: "kino:ready", duration: 40.5 }) + expect(posted).toEqual([ + expect.objectContaining({ + type: "kino:init", + autoPlay: true, + muted: false, + }), + ]) + p.destroy() +}) + +test("a window too short to settle inside is no preview at all", () => { + const p = createScenesProvider({ src: SRC, preview: { endSeconds: 0.1 } }) + const { iframe } = mount(p) + const posted: unknown[] = [] + iframe.contentWindow!.postMessage = (msg: unknown) => posted.push(msg) + fromHost(iframe, { type: "kino:ready", duration: 40.5 }) + expect(posted).toHaveLength(1) + p.destroy() +}) + +test("prefers-reduced-motion suppresses the preview", () => { + const original = window.matchMedia + window.matchMedia = ((q: string) => ({ + matches: q.includes("prefers-reduced-motion"), + })) as typeof window.matchMedia + const { p, posted } = mountPreviewing() + expect(posted).toHaveLength(1) + p.destroy() + window.matchMedia = original +}) + +test("save-data suppresses the preview", () => { + Object.defineProperty(navigator, "connection", { + value: { saveData: true }, + configurable: true, + }) + const { p, posted } = mountPreviewing() + expect(posted).toHaveLength(1) + p.destroy() + Reflect.deleteProperty(navigator, "connection") +}) + +test("an offscreen player defers the preview until it scrolls into view", () => { + let fire: ((entries: Array<{ isIntersecting: boolean }>) => void) | null = + null + const disconnect = vi.fn() + class FakeObserver { + constructor(cb: (entries: Array<{ isIntersecting: boolean }>) => void) { + fire = cb + } + observe() {} + disconnect = disconnect + } + vi.stubGlobal("IntersectionObserver", FakeObserver) + const { p, posted } = mountPreviewing() + // Below the fold: both loops would be spent unwatched. + expect(posted).toHaveLength(1) + fire!([{ isIntersecting: true }]) + expect(posted).toContainEqual({ type: "kino:play" }) + expect(disconnect).toHaveBeenCalled() + p.destroy() + vi.unstubAllGlobals() +}) + +test("destroy tears down a preview that never started", () => { + let fire: ((entries: Array<{ isIntersecting: boolean }>) => void) | null = + null + const disconnect = vi.fn() + class FakeObserver { + constructor(cb: (entries: Array<{ isIntersecting: boolean }>) => void) { + fire = cb + } + observe() {} + disconnect = disconnect + } + vi.stubGlobal("IntersectionObserver", FakeObserver) + const { p, posted } = mountPreviewing() + p.destroy() + expect(disconnect).toHaveBeenCalled() + fire!([{ isIntersecting: true }]) + expect(posted).toHaveLength(1) + vi.unstubAllGlobals() +}) + +test("the loop always runs at 1x, whatever speed the viewer watches at", () => { + const { p, iframe, posted } = mountPreviewing({ defaultRate: 2.5 }) + // The viewer's speed is how fast they want to learn, not how fast a teaser + // should move. + expect(posted).toContainEqual({ type: "kino:setRate", rate: 1 }) + fromHost(iframe, playingAt(5)) + posted.length = 0 + // It comes back on the hand-back, not before. + p.actions.play() + expect(posted).toContainEqual({ type: "kino:setRate", rate: 2.5 }) + expect(p.getState().rate).toBe(2.5) + p.destroy() +}) diff --git a/src/scenes/provider.ts b/src/scenes/provider.ts index a0ee851..884789f 100644 --- a/src/scenes/provider.ts +++ b/src/scenes/provider.ts @@ -8,7 +8,7 @@ import { } from "./pip-surfaces" import { parseVtt, cueTextAt, type VttCue } from "./vtt" import type { MediaState, PlayerActions, Provider } from "../core/types" -import type { HostCommand, HostEvent } from "./protocol" +import type { HostCommand, HostEvent, HostMediaState } from "./protocol" type DocumentPiPHost = Window & { documentPictureInPicture?: { @@ -16,6 +16,34 @@ type DocumentPiPHost = Window & { } } +/** + * Ambient preview: on first load the stage plays its opening window muted and + * loops it, so the player reads as something worth pressing play on instead of + * a blank frame behind a play button. + * + * Throughout, `MediaState` keeps reporting a paused player at time 0 (and the + * viewer's own volume/muted). Every chrome surface and every consumer already + * keys off that idle shape, so none of them need to know a preview is running; + * the real clock lives only inside the host iframe. The first transport action + * hands the clock back and nothing preview-related runs again. + */ +export type ScenesPreviewOptions = { + /** + * End of the looped window, in sequence seconds. The caller derives this + * from the manifest (the provider never sees one) — the opening scene's + * boundary is the natural choice, since a window that ends mid-motion + * restarts mid-motion. Values at or below the settle backoff disable the + * preview. + */ + endSeconds: number + /** + * Loops before settling; defaults to 2. After the last one the host holds + * the opening scene's settled final frame, paused, so the player is lively + * on arrival and calm afterwards rather than animating forever. + */ + cycles?: number +} + export type ScenesProviderOptions = { // Full URL of the host page, token and sequence already encoded by the caller. src: string @@ -34,6 +62,9 @@ export type ScenesProviderOptions = { // to dark. The main tab's chrome is themed by the Player's chromeTheme prop // instead, which cannot reach across into the pip document. chromeTheme?: "light" | "dark" + // Ambient first-load preview. Omit for a still, idle player. Ignored when + // autoPlay is set, which already starts the real thing. + preview?: ScenesPreviewOptions } // The Provider contract plus the scenes-only theme channels. The stage is a @@ -46,6 +77,54 @@ export type ScenesProvider = Provider & { const TRACK_ID = "captions" +const PREVIEW_DEFAULT_CYCLES = 2 +// How far back from the window's end the settled frame sits. Far enough inside +// the opening scene that the clock cannot tip into the next one, close enough +// that the scene is holding its final state rather than still animating. +const PREVIEW_SETTLE_BACKOFF_S = 0.15 +// Muted autoplay is allowed by policy everywhere that matters, but iOS low +// power mode refuses it outright. If the host has not reported itself playing +// by now, treat it as refused and settle instead of waiting forever. +const PREVIEW_START_GRACE_MS = 800 +// A snapshot the host queued before a seek landed can report the old clock. +// Anything inside this window of the seek target counts as the host agreeing. +const PREVIEW_RESUME_EPSILON_S = 0.5 + +type NormalizedPreview = { end: number; cycles: number; settleAt: number } + +function normalizePreview( + opts: ScenesProviderOptions, +): NormalizedPreview | null { + const preview = opts.preview + // autoPlay is the viewer starting the real thing; there is nothing to tease. + if (!preview || opts.autoPlay) return null + const end = preview.endSeconds + // Too short to settle inside means too short to be worth looping. + if (!Number.isFinite(end) || end <= PREVIEW_SETTLE_BACKOFF_S) return null + const cycles = Math.max( + 1, + Math.floor(preview.cycles ?? PREVIEW_DEFAULT_CYCLES), + ) + return { end, cycles, settleAt: end - PREVIEW_SETTLE_BACKOFF_S } +} + +type NavigatorWithConnection = Navigator & { + connection?: { saveData?: boolean } +} + +// Reasons never to run the preview, checked once at the handshake. +function previewSuppressed(): boolean { + if (typeof window === "undefined") return true + // Someone who asked for less motion did not ask for a looping animation. + if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true) + return true + // On a metered connection the opening window would be fetched for nothing + // whenever the viewer never presses play. + if ((navigator as NavigatorWithConnection).connection?.saveData === true) + return true + return false +} + // Plays an audio-driven React scene sequence hosted in an iframe. The iframe // owns the audio element and the scene DOM; this side only speaks the wire // protocol and adapts it to kino's Provider contract. @@ -105,6 +184,32 @@ export function createScenesProvider( // iPhone-class WebKit) is active. Null otherwise. let pseudoRestore: (() => void) | null = null + // Ambient preview. Null when not configured, suppressed, or superseded by + // autoPlay; the phase below then never leaves "off". + const preview = normalizePreview(opts) + // "off" never configured, or the viewer has taken the clock back + // "waiting" configured; the host is untouched, still idle at its start + // "running" muted loop in flight + // "settled" loop finished (or was refused); host paused on the opening + // scene's final frame + // "running" and "settled" both mean the host clock is somewhere the viewer + // did not put it, so both suppress state snapshots and both must be released + // with an explicit seek before real playback. + let previewPhase: "off" | "waiting" | "running" | "settled" = "off" + let previewCyclesLeft = 0 + let previewGrace: ReturnType | null = null + let previewObserver: IntersectionObserver | null = null + // Seek target from the hand-back, held until the host's own clock agrees, so + // a snapshot queued before that seek cannot flash the preview clock into the + // scrubber. Null outside the hand-back. + let previewResumeAt: number | null = null + // True from a loop seek until the host's clock has actually come back, so a + // snapshot queued at the window's end before that seek landed cannot burn a + // second cycle. + let previewRewinding = false + const previewHoldsClock = () => + previewPhase === "running" || previewPhase === "settled" + let state: MediaState = { ...defaultState(), rate: desiredRate, @@ -163,6 +268,110 @@ export function createScenesProvider( const readCueText = (t: number): string => state.activeTextTrackId === TRACK_ID ? cueTextAt(vttCues, t) : "" + const clearPreviewGrace = () => { + if (previewGrace === null) return + clearTimeout(previewGrace) + previewGrace = null + } + + const stopPreviewObserver = () => { + previewObserver?.disconnect() + previewObserver = null + } + + // End of the loop: hold the opening scene's settled final frame. Also the + // landing spot when muted autoplay was refused, which is why it is a still + // frame of real content and not a blank stage. + const settlePreview = () => { + if (previewPhase !== "running") return + previewPhase = "settled" + clearPreviewGrace() + send({ type: "kino:pause" }) + send({ type: "kino:seek", time: preview?.settleAt ?? 0 }) + } + + // Start the muted loop. Separate from the kino:init reply so the deferred + // (offscreen) start goes through exactly the same path as the immediate one. + const startPreview = () => { + if (!preview || previewPhase !== "waiting") return + stopPreviewObserver() + previewPhase = "running" + previewCyclesLeft = preview.cycles + // Muted before playing: the muted-autoplay exemption is what lets this + // start without any user activation. + send({ type: "kino:setMuted", muted: true }) + // Always 1x, whatever speed the viewer watches at. Their rate is how fast + // they want to learn, not how fast a teaser should move; a saved 2x or + // 2.5x turns the loop frantic. The real rate rides the hand-back. + send({ type: "kino:setRate", rate: 1 }) + send({ type: "kino:seek", time: 0 }) + send({ type: "kino:play" }) + previewGrace = setTimeout(settlePreview, PREVIEW_START_GRACE_MS) + } + + // Hand the clock back to the viewer, who wants it at `time`. Leaves the host + // paused there with their own audio settings restored, and retires the + // preview for the life of this provider. A no-op once the preview is off, so + // every transport action can call it unconditionally. + const releasePreview = (time: number) => { + if (previewPhase === "off") return + const disturbed = previewHoldsClock() + previewPhase = "off" + stopPreviewObserver() + clearPreviewGrace() + // "waiting" never touched the host, so there is nothing to undo — and + // rewinding here would clobber a resume seek that arrived first. + if (!disturbed) return + send({ type: "kino:pause" }) + send({ type: "kino:seek", time }) + send({ type: "kino:setMuted", muted: state.muted }) + send({ type: "kino:setVolume", volume: state.volume }) + send({ type: "kino:setRate", rate: desiredRate }) + previewResumeAt = time + patch({ currentTime: time, activeCueText: readCueText(time) }) + } + + // Drive the loop off the host's state ticks. Coarse by design: at the host's + // 10Hz the loop point lands within ~100ms of the window's end, which is + // under the threshold of noticing on an ambient loop. + const advancePreview = (snapshot: HostMediaState) => { + if (previewPhase !== "running" || !preview) return + // Playing means muted autoplay was allowed after all. + if (!snapshot.paused) clearPreviewGrace() + if (previewRewinding) { + if (snapshot.currentTime < preview.end) previewRewinding = false + return + } + if (snapshot.currentTime < preview.end) return + previewCyclesLeft -= 1 + if (previewCyclesLeft > 0) { + previewRewinding = true + send({ type: "kino:seek", time: 0 }) + return + } + settlePreview() + } + + // Arm the preview once the host is listening. Deferred until the player is + // actually on screen: a lesson opened below the fold would otherwise spend + // both its loops unwatched and be settled by the time anyone scrolled to it. + const armPreview = () => { + if (!preview || previewPhase !== "off") return + if (previewSuppressed()) return + previewPhase = "waiting" + if (typeof IntersectionObserver === "undefined" || !mountContainer) { + startPreview() + return + } + previewObserver = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) startPreview() + }, + { threshold: 0.25 }, + ) + previewObserver.observe(mountContainer) + } + const onMessage = (ev: MessageEvent) => { if (!iframe) return if (ev.origin !== origin || ev.source !== iframe.contentWindow) return @@ -181,8 +390,33 @@ export function createScenesProvider( autoPlay: opts.autoPlay ?? false, theme, }) + armPreview() break case "kino:state": + // While the ambient preview owns the host clock, MediaState keeps + // reporting the idle player the chrome and its consumers expect: the + // loop must not move the scrubber, fire captions, or read as playback + // to anything watching. Loading progress is not clock state, so + // buffered/readyState still flow (cross-lesson autoplay arms on the + // readyState edge, and would otherwise wait for a preview to finish). + if (previewHoldsClock()) { + patch({ + buffered: msg.state.buffered, + readyState: msg.state.readyState, + }) + advancePreview(msg.state) + break + } + // A snapshot the host queued before the hand-back seek landed still + // carries the preview clock; drop it rather than flash it. + if (previewResumeAt !== null) { + if ( + Math.abs(msg.state.currentTime - previewResumeAt) > + PREVIEW_RESUME_EPSILON_S + ) + break + previewResumeAt = null + } // The host's snapshot is authoritative once it arrives, except while // a setRate is in flight: hold the optimistic rate until the host // echoes it back, then let snapshots flow through wholly again. @@ -264,14 +498,19 @@ export function createScenesProvider( // never fan out: the mirror stays muted forever. const actions: PlayerActions = { play: () => { + // Taking over from the ambient preview starts the lesson at the top: the + // preview is a tease of the opening, not progress through it. + releasePreview(0) send({ type: "kino:play" }) sendMirror({ type: "kino:play" }) }, pause: () => { + releasePreview(0) send({ type: "kino:pause" }) sendMirror({ type: "kino:pause" }) }, seek: (t) => { + releasePreview(t) send({ type: "kino:seek", time: t }) sendMirror({ type: "kino:seek", time: t }) // Optimistic time so the scrubber tracks the pointer between state ticks. @@ -280,9 +519,14 @@ export function createScenesProvider( setRate: (r) => { pendingRate = r desiredRate = r + patch({ rate: r }) + // Deliberately not a hand-back: the idle speed chips set a rate and then + // play, so a rate change alone must leave the preview alone. Speeding up + // the muted loop under the viewer would only look like a glitch; the + // chosen rate rides the hand-back instead. + if (previewHoldsClock()) return send({ type: "kino:setRate", rate: r }) sendMirror({ type: "kino:setRate", rate: r }) - patch({ rate: r }) }, setVolume: (v) => { const vol = Math.min(1, Math.max(0, v)) @@ -331,6 +575,10 @@ export function createScenesProvider( const dpp = (window as DocumentPiPHost).documentPictureInPicture if (!dpp || pipWindow || pipEntering || !iframe || !mountContainer) return + // The mirror comes up on MediaState's clock, so the preview has to be + // off it before the window opens or the mirror would sync to 0 while + // the master kept looping behind the inline placeholder. + releasePreview(0) clearPseudoFullscreen() pipEntering = true let win: Window @@ -496,6 +744,9 @@ export function createScenesProvider( destroy() { window.removeEventListener("message", onMessage) document.removeEventListener("fullscreenchange", onFullscreenChange) + stopPreviewObserver() + clearPreviewGrace() + previewPhase = "off" pseudoRestore?.() pseudoRestore = null // Fires the pagehide handler, which removes the mirror and clears pip