Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/scenes-post-before-host-ready.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@karnstack/kino": patch
---

Scenes: hold every outgoing host command until the `kino:ready` handshake. A freshly created iframe holds an about:blank document that inherits the embedding page's origin until the host document loads, so a command posted at the host origin before then is refused by the browser ("The target origin provided ... does not match the recipient window's origin") and surfaces as a console error. `ScenesPlayer`'s mount-time `setSceneTheme` hit this on every cross-origin embed. Nothing is lost: the pre-ready rate, volume, muted and theme all ride the `kino:init` reply. The picture-in-picture mirror follows the same rule, gated on its own ready handshake.
64 changes: 64 additions & 0 deletions src/scenes/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,41 @@ test("init carries the theme option; anything but light falls back to dark", ()
p.destroy()
})

// Until the host announces kino:ready the frame is still on its initial
// about:blank document, which inherits the embedding page's origin. A command
// targeted at the host origin is refused there ("The target origin provided
// does not match the recipient window's origin"), so nothing may go out early.
test("no command reaches the host frame before the ready handshake", () => {
const p = createScenesProvider({ src: SRC })
const { iframe } = mount(p)
const posted: unknown[] = []
iframe.contentWindow!.postMessage = (msg: unknown) => posted.push(msg)
p.setSceneTheme("light")
p.actions.play()
p.actions.seek(4)
p.actions.setRate(1.5)
p.actions.setVolume(0.5)
p.actions.setMuted(true)
expect(posted).toEqual([])
// The dropped pre-ready settings are not lost: they ride the init reply.
fromHost(iframe, { type: "kino:ready", duration: 40.5 })
expect(posted).toContainEqual({
type: "kino:init",
rate: 1.5,
volume: 0.5,
muted: true,
autoPlay: false,
theme: "light",
})
p.destroy()
})

test("setSceneTheme posts kino:setTheme to the master", () => {
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 })
p.setSceneTheme("light")
expect(posted).toContainEqual({ type: "kino:setTheme", theme: "light" })
p.setSceneTheme("dark")
Expand Down Expand Up @@ -186,6 +216,7 @@ test("actions post protocol commands to the host", () => {
const { iframe } = mount(p)
const posted: unknown[] = []
iframe.contentWindow!.postMessage = (msg: unknown) => posted.push(msg)
fromHost(iframe, { type: "kino:ready", duration: 40.5 })
p.actions.play()
p.actions.seek(21)
p.actions.setRate(2)
Expand Down Expand Up @@ -498,6 +529,36 @@ test("mirror init carries the current theme, not the mount-time one", async () =
uninstall()
})

// Same about:blank rule as the master: a mirror created in the pip window is
// on the pip document's origin until it loads, so nothing may be posted at the
// host origin before its own ready handshake.
test("no command reaches the mirror before its ready handshake", async () => {
const fake = new FakePipWindow()
const uninstall = installFakeDocumentPiP(fake)
const p = createScenesProvider({ src: SRC })
const { iframe } = mount(p)
fromHost(iframe, { type: "kino:ready", duration: 40.5 })
p.actions.enterPiP()
await vi.waitFor(() => expect(p.getState().pip).toBe(true))
const mirror = findMirror()!
const mirrorPost = vi.spyOn(mirror.contentWindow!, "postMessage")
p.actions.play()
p.actions.seek(9)
p.setSceneTheme("light")
expect(mirrorPost).not.toHaveBeenCalled()
// The mirror comes up on the master's clock and theme regardless.
fromMirror(fake, mirror, { type: "kino:ready", duration: 40.5 })
expect(mirrorPost.mock.calls.map((c) => c[0])).toContainEqual(
expect.objectContaining({
type: "kino:init",
startTime: 9,
theme: "light",
}),
)
p.destroy()
uninstall()
})

test("setSceneTheme fans out to the mirror while in pip", async () => {
const fake = new FakePipWindow()
const uninstall = installFakeDocumentPiP(fake)
Expand All @@ -507,6 +568,7 @@ test("setSceneTheme fans out to the mirror while in pip", async () => {
p.actions.enterPiP()
await vi.waitFor(() => expect(p.getState().pip).toBe(true))
const mirror = findMirror()!
fromMirror(fake, mirror, { type: "kino:ready", duration: 40.5 })
const mirrorPost = vi.spyOn(mirror.contentWindow!, "postMessage")
const masterPost = vi.spyOn(iframe.contentWindow!, "postMessage")
p.setSceneTheme("light")
Expand Down Expand Up @@ -599,6 +661,7 @@ test("mirror state feeds drift correction only, never MediaState", async () => {
p.actions.enterPiP()
await vi.waitFor(() => expect(p.getState().pip).toBe(true))
const mirror = findMirror()!
fromMirror(fake, mirror, { type: "kino:ready", duration: 40.5 })
const mirrorPost = vi.spyOn(mirror.contentWindow!, "postMessage")
// The mirror reports its own clock; MediaState stays on the master's.
fromMirror(fake, mirror, snapshot(1, 5))
Expand Down Expand Up @@ -627,6 +690,7 @@ test("transport commands fan out to the mirror while in pip, volume commands nev
p.actions.enterPiP()
await vi.waitFor(() => expect(p.getState().pip).toBe(true))
const mirror = findMirror()!
fromMirror(fake, mirror, { type: "kino:ready", duration: 40.5 })
const mirrorPost = vi.spyOn(mirror.contentWindow!, "postMessage")
p.actions.play()
p.actions.pause()
Expand Down
23 changes: 22 additions & 1 deletion src/scenes/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,24 @@ export function createScenesProvider(
patch({ fullscreen: false })
}

// A freshly created iframe holds an about:blank document that inherits THIS
// page's origin until the real host document loads, so a command targeted at
// the host origin is refused outright ("The target origin provided does not
// match the recipient window's origin") and lands as a console error. The
// handshake is the earliest point the host is both on its own origin and
// listening, so nothing goes out before it. Nothing is lost: the pre-ready
// rate, volume, muted and theme all ride the kino:init reply.
let ready = false
const send = (cmd: HostCommand) => {
if (!ready) return
iframe?.contentWindow?.postMessage(cmd, origin)
}

// Non-null mirrorIframe implies pip is active; outside pip this is a no-op.
// Same rule for the mirror, which is about:blank on the pip window's origin
// until it loads. False outside pip too, so this is a no-op there.
let mirrorReady = false
const sendMirror = (cmd: HostCommand) => {
if (!mirrorReady) return
mirrorIframe?.contentWindow?.postMessage(cmd, origin)
}

Expand All @@ -158,6 +170,8 @@ export function createScenesProvider(
if (msg == null || typeof msg !== "object") return
switch (msg.type) {
case "kino:ready":
// Set before the reply: send() itself is gated on this.
ready = true
patch({ duration: msg.duration })
send({
type: "kino:init",
Expand Down Expand Up @@ -209,6 +223,8 @@ export function createScenesProvider(
if (msg == null || typeof msg !== "object") return
switch (msg.type) {
case "kino:ready": {
// Set before the reply: sendMirror() itself is gated on this.
mirrorReady = true
// A non-finite currentTime would flow through init startTime straight
// into audio.currentTime in the mirror; fall back to the start.
const t = state.currentTime
Expand Down Expand Up @@ -359,6 +375,9 @@ export function createScenesProvider(
mirror.style.display = "block"
mirrorIframe = mirror
mirrorTime = null
// This window's mirror has not announced itself yet, whatever a
// previous pip session left behind.
mirrorReady = false
win.document.body.appendChild(mirror)
// The mirror host's parent is the pip window, so its events land
// there, not on the main window.
Expand Down Expand Up @@ -395,6 +414,7 @@ export function createScenesProvider(
mirrorIframe?.remove()
mirrorIframe = null
mirrorTime = null
mirrorReady = false
pipCleanups.forEach((c) => c())
pipCleanups = []
// Nothing to resume: the master never stopped.
Expand Down Expand Up @@ -483,6 +503,7 @@ export function createScenesProvider(
pipWindow?.close()
iframe?.remove()
iframe = null
ready = false
mountContainer = null
listeners.clear()
},
Expand Down
Loading