diff --git a/.changeset/pip-window-chrome-theme.md b/.changeset/pip-window-chrome-theme.md new file mode 100644 index 0000000..bfac5bd --- /dev/null +++ b/.changeset/pip-window-chrome-theme.md @@ -0,0 +1,5 @@ +--- +"@karnstack/kino": minor +--- + +`chromeTheme` now reaches the picture-in-picture window, not just the main tab. `createScenesProvider` takes a `chromeTheme` option and `ScenesProvider` gains `setChromeTheme`, which `ScenesPlayer` wires to its existing `chromeTheme` prop, so a theme flip mid-playback restyles an open pip window instead of leaving dark controls over a light stage. The overlay drawn on the pip window (which never loads `kino.css`) carries its own token sheet, dark by default with a light block keyed on `data-kino-theme`, and the pip window's backdrop follows `sceneTheme` rather than painting a hardcoded black behind a light stage. Omit both and nothing changes. diff --git a/README.md b/README.md index cfb2b07..2b29874 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,16 @@ For deeper control, every visual is driven by CSS custom properties on the `.kin } ``` +### Light chrome + +`chromeTheme` (`"light"` or `"dark"`, dark by default) flips the whole chrome to a light token set: control bar, captions, idle overlay, scrubber, menus, and the compact/touch UI. It is stamped as `data-kino-theme` on the `.kino` root, so your own overrides still win. + +```tsx + +``` + +On `ScenesPlayer` it also reaches the picture-in-picture surfaces, which kino draws itself: the placeholder left inline where the stage was, and the controls over the pip window (that window never loads `kino.css`, so it carries its own copy of the tokens). Later values flip both live, so the player can follow your site's theme toggle mid-playback without closing pip. `chromeTheme` is distinct from `sceneTheme`, which themes the stage inside the host document. + ## Keyboard shortcuts The player is keyboard-first. Shortcuts are ignored while a text input, textarea, select, or contenteditable element is focused, and modifier-key combinations (Ctrl/Cmd/Alt) are passed through. diff --git a/src/scenes/pip-surfaces.test.ts b/src/scenes/pip-surfaces.test.ts index ac5946b..9839f80 100644 --- a/src/scenes/pip-surfaces.test.ts +++ b/src/scenes/pip-surfaces.test.ts @@ -1,5 +1,9 @@ import { readFileSync } from "node:fs" -import { mountPipPlaceholder, mountPipOverlay } from "./pip-surfaces" +import { + mountPipPlaceholder, + mountPipOverlay, + pipStageBackdrop, +} from "./pip-surfaces" afterEach(() => { vi.useRealTimers() @@ -85,7 +89,7 @@ function overlayHarness(state: OverlayState) { test("overlay toggles play/pause off provider state and mirrors cue text", () => { const state = { paused: true, activeCueText: "", currentTime: 0, duration: 0 } const { deps, emit } = overlayHarness(state) - const cleanup = mountPipOverlay(window, deps) + const overlay = mountPipOverlay(window, deps) const root = document.body.querySelector( "[data-kino-pip-overlay]", ) as HTMLElement @@ -103,7 +107,7 @@ test("overlay toggles play/pause off provider state and mirrors cue text", () => expect(root.textContent).toContain("hello from the sequence") btn.click() expect(deps.pause).toHaveBeenCalledOnce() - cleanup() + overlay.destroy() expect(document.body.querySelector("[data-kino-pip-overlay]")).toBeNull() }) @@ -115,20 +119,20 @@ test("time readout pads seconds and progress line tracks position", () => { duration: 130, } const { deps } = overlayHarness(state) - const cleanup = mountPipOverlay(window, deps) + const overlay = mountPipOverlay(window, deps) const root = document.body.querySelector( "[data-kino-pip-overlay]", ) as HTMLElement expect(root.textContent).toContain("1:05 / 2:10") const progress = root.querySelector("[data-kino-pip-progress]") as HTMLElement expect(progress.style.width).toBe("50%") - cleanup() + overlay.destroy() }) test("cue pill hides entirely when empty and shows with text", () => { const state = { paused: true, activeCueText: "", currentTime: 0, duration: 0 } const { deps, emit } = overlayHarness(state) - const cleanup = mountPipOverlay(window, deps) + const overlay = mountPipOverlay(window, deps) const cue = document.body.querySelector("[data-kino-pip-cue]") as HTMLElement expect(cue.getAttribute("aria-live")).toBe("polite") expect(cue.style.display).toBe("none") @@ -139,7 +143,7 @@ test("cue pill hides entirely when empty and shows with text", () => { state.activeCueText = "" emit() expect(cue.style.display).toBe("none") - cleanup() + overlay.destroy() }) test("controls auto-hide while playing and reappear on pointer movement", () => { @@ -151,7 +155,7 @@ test("controls auto-hide while playing and reappear on pointer movement", () => duration: 10, } const { deps, emit } = overlayHarness(state) - const cleanup = mountPipOverlay(window, deps) + const overlay = mountPipOverlay(window, deps) const bar = document.body.querySelector("[data-kino-pip-bar]") as HTMLElement const cue = document.body.querySelector("[data-kino-pip-cue]") as HTMLElement expect(bar.style.opacity).not.toBe("0") @@ -168,7 +172,72 @@ test("controls auto-hide while playing and reappear on pointer movement", () => expect(bar.style.opacity).toBe("1") vi.advanceTimersByTime(5000) expect(bar.style.opacity).toBe("1") - cleanup() + overlay.destroy() +}) + +// The pip document never loads kino.css, so the overlay ships its own token +// sheet: dark on the root, light overriding it, mirroring the chrome tokens. +test("overlay stamps the chrome theme and flips it live without a remount", () => { + const { deps } = overlayHarness({ + paused: true, + activeCueText: "cue", + currentTime: 0, + duration: 10, + }) + const overlay = mountPipOverlay(window, deps, "light") + const root = document.body.querySelector( + "[data-kino-pip-overlay]", + ) as HTMLElement + expect(root.getAttribute("data-kino-theme")).toBe("light") + const sheet = document.head.querySelector( + "style[data-kino-pip-style]", + ) as HTMLStyleElement + expect(sheet.textContent).toContain('[data-kino-theme="light"]') + // Every color the overlay paints comes from a token, so the light block can + // reach all of it. + for (const token of [ + "--kino-pip-bar", + "--kino-pip-ctrl", + "--kino-pip-ctrl-dim", + "--kino-pip-ctrl-hover", + "--kino-pip-cue-fill", + "--kino-pip-cue-text", + "--kino-pip-progress", + ]) { + expect( + sheet.textContent!.split(`${token}:`).length - 1, + `${token} needs a dark default and a light override`, + ).toBe(2) + } + // Live flip: the pip window stays open, only the stamp changes. + overlay.setTheme("dark") + expect(root.getAttribute("data-kino-theme")).toBe("dark") + expect(document.body.querySelector("[data-kino-pip-overlay]")).toBe(root) + overlay.destroy() + expect(document.head.querySelector("style[data-kino-pip-style]")).toBeNull() +}) + +test("overlay defaults to dark chrome when no theme is passed", () => { + const { deps } = overlayHarness({ + paused: true, + activeCueText: "", + currentTime: 0, + duration: 0, + }) + const overlay = mountPipOverlay(window, deps) + const root = document.body.querySelector( + "[data-kino-pip-overlay]", + ) as HTMLElement + expect(root.getAttribute("data-kino-theme")).toBe("dark") + overlay.destroy() +}) + +// The backdrop is what shows before the mirrored stage paints, so it follows +// the stage theme rather than the chrome theme. +test("stage backdrop follows the theme and is opaque in both", () => { + expect(pipStageBackdrop("dark")).toBe("#000") + expect(pipStageBackdrop("light")).not.toBe("#000") + expect(pipStageBackdrop("light")).not.toContain("transparent") }) test("overlay cleanup unsubscribes and removes the pointermove listener", () => { @@ -180,9 +249,9 @@ test("overlay cleanup unsubscribes and removes the pointermove listener", () => currentTime: 0, duration: 0, }) - const cleanup = mountPipOverlay(window, deps) + const overlay = mountPipOverlay(window, deps) expect(listeners.size).toBe(1) - cleanup() + overlay.destroy() expect(listeners.size).toBe(0) const added = addSpy.mock.calls.filter(([type]) => type === "pointermove") const removed = removeSpy.mock.calls.filter( diff --git a/src/scenes/pip-surfaces.ts b/src/scenes/pip-surfaces.ts index 630435e..e04f097 100644 --- a/src/scenes/pip-surfaces.ts +++ b/src/scenes/pip-surfaces.ts @@ -1,12 +1,15 @@ // Parent-origin DOM for document picture-in-picture: the placeholder shown // inline where the stage was, and the compact controls overlaid on the pip -// window (the main tab's chrome is not visible from there). The overlay uses -// inline styles because kino.css is not loaded in the pip window; where a -// value mirrors a kino design token it is hardcoded with a comment naming -// the token. +// window (the main tab's chrome is not visible from there). kino.css is not +// loaded in the pip window, so layout stays inline and every color comes from +// the small token sheet this module injects there; the sheet mirrors the +// chrome tokens in src/styles/kino.css, dark by default with a light block +// keyed on data-kino-theme, exactly like the in-page chrome. import { formatTime } from "../util/format-time" +export type PipTheme = "light" | "dark" + export type PipOverlayDeps = { play(): void pause(): void @@ -19,6 +22,59 @@ export type PipOverlayDeps = { subscribe(listener: () => void): () => void } +// Handle on a mounted overlay: the chrome theme can flip while the window +// stays open (the embedding site's theme toggle), so this outlives mount. +export type PipOverlay = { + setTheme(theme: PipTheme): void + destroy(): void +} + +const theme = (t: PipTheme | undefined): PipTheme => + t === "light" ? "light" : "dark" + +/** + * Backdrop for the pip window body. It shows before the mirrored stage paints, + * so it follows the *stage* theme rather than the chrome theme, and it mirrors + * --kino-bg in src/styles/kino.css. Opaque in both themes: a transparent body + * would fall through to the browser's own window chrome. + */ +export function pipStageBackdrop(t: PipTheme): string { + return theme(t) === "light" ? "oklch(98% 0 0)" : "#000" +} + +// Token sheet injected into the pip document. Values mirror the chrome tokens +// in src/styles/kino.css (--kino-control-*, --kino-text-dim, --kino-caption-*, +// --kino-hover, --kino-thumb) so the two chromes read as one player. +const OVERLAY_CSS = ` +[data-kino-pip-overlay] { + --kino-pip-bar: linear-gradient(transparent, rgba(0, 0, 0, 0.85)); + --kino-pip-ctrl: #fff; + --kino-pip-ctrl-dim: rgba(255, 255, 255, 0.65); + --kino-pip-ctrl-hover: rgba(255, 255, 255, 0.12); + --kino-pip-cue-fill: rgba(0, 0, 0, 0.6); + --kino-pip-cue-text: #fff; + --kino-pip-progress: rgba(255, 255, 255, 0.9); +} +[data-kino-pip-overlay][data-kino-theme="light"] { + --kino-pip-bar: linear-gradient(transparent, rgba(255, 255, 255, 0.88)); + --kino-pip-ctrl: oklch(24% 0 0); + --kino-pip-ctrl-dim: rgba(0, 0, 0, 0.55); + --kino-pip-ctrl-hover: rgba(0, 0, 0, 0.08); + --kino-pip-cue-fill: rgba(255, 255, 255, 0.72); + --kino-pip-cue-text: oklch(28% 0 0); + --kino-pip-progress: rgba(0, 0, 0, 0.62); +} +[data-kino-pip-bar] { background: var(--kino-pip-bar); } +[data-kino-pip-overlay] button { color: var(--kino-pip-ctrl); } +[data-kino-pip-overlay] button:hover { background: var(--kino-pip-ctrl-hover); } +[data-kino-pip-time] { color: var(--kino-pip-ctrl-dim); } +[data-kino-pip-cue] { + color: var(--kino-pip-cue-text); + background: var(--kino-pip-cue-fill); +} +[data-kino-pip-progress] { background: var(--kino-pip-progress); } +` + // Exact path data from src/ui/icons.tsx (PlayIcon, PauseIcon, PipIcon) so the // pip surfaces visually match kino's buttons. const PLAY_PATH = @@ -62,38 +118,41 @@ export function mountPipPlaceholder( export function mountPipOverlay( pipWindow: Window, deps: PipOverlayDeps, -): () => void { + chromeTheme?: PipTheme, +): PipOverlay { const doc = pipWindow.document const root = doc.createElement("div") root.setAttribute("data-kino-pip-overlay", "") + root.setAttribute("data-kino-theme", theme(chromeTheme)) // pointer-events none on the root; the control bar re-enables clicks. root.style.cssText = "position:absolute;inset:0;pointer-events:none;font-family:ui-sans-serif,system-ui,sans-serif;" - // :hover cannot be expressed inline; mirrors .kino-ctrl:hover - // (color-mix(in oklab, white 12%, transparent)). + // Colors and :hover live in the sheet: neither a custom property lookup nor + // a pseudo-class can be expressed inline, and keeping every color here is + // what lets a theme flip be one attribute change. const style = doc.createElement("style") - style.textContent = - "[data-kino-pip-overlay] button:hover{background:rgba(255,255,255,0.12);}" + style.setAttribute("data-kino-pip-style", "") + style.textContent = OVERLAY_CSS doc.head.appendChild(style) const bar = doc.createElement("div") bar.setAttribute("data-kino-pip-bar", "") bar.style.cssText = "position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;gap:10px;padding:10px 12px;pointer-events:auto;" + - `background:linear-gradient(transparent,rgba(0,0,0,0.85));transition:opacity 250ms ${EASE};` + `transition:opacity 250ms ${EASE};` const btn = doc.createElement("button") btn.type = "button" btn.style.cssText = - "display:grid;place-items:center;width:34px;height:34px;padding:0;color:#fff;background:none;border:0;" + + "display:grid;place-items:center;width:34px;height:34px;padding:0;background:none;border:0;" + `border-radius:8px;cursor:pointer;transition:background 150ms ${EASE};` const time = doc.createElement("div") - // color mirrors --kino-text-dim (white at 65% alpha) + time.setAttribute("data-kino-pip-time", "") time.style.cssText = - "font-size:12px;color:rgba(255,255,255,0.65);font-variant-numeric:tabular-nums;white-space:nowrap;" + "font-size:12px;font-variant-numeric:tabular-nums;white-space:nowrap;" const cue = doc.createElement("div") cue.setAttribute("data-kino-pip-cue", "") @@ -102,7 +161,7 @@ export function mountPipOverlay( // on each side) with a small gap. cue.style.cssText = "position:absolute;left:50%;bottom:58px;transform:translateX(-50%);max-width:85%;padding:4px 10px;" + - "font-size:13px;line-height:1.35;text-align:center;color:#fff;background:rgba(0,0,0,0.6);" + + "font-size:13px;line-height:1.35;text-align:center;" + `border-radius:6px;transition:opacity 250ms ${EASE};` // Always-visible playback position along the very bottom edge; updates at @@ -110,7 +169,7 @@ export function mountPipOverlay( const progress = doc.createElement("div") progress.setAttribute("data-kino-pip-progress", "") progress.style.cssText = - "position:absolute;left:0;bottom:0;height:2px;width:0;background:rgba(255,255,255,0.9);" + "position:absolute;left:0;bottom:0;height:2px;width:0;" // Auto-hide: bar + cue stay up while paused; while playing they fade after // a stretch without pointer movement over the pip document. @@ -177,11 +236,16 @@ export function mountPipOverlay( bar.append(btn, time) root.append(cue, bar, progress) doc.body.appendChild(root) - return () => { - unsubscribe() - doc.removeEventListener("pointermove", onPointerMove) - clearHideTimer() - style.remove() - root.remove() + return { + setTheme(next) { + root.setAttribute("data-kino-theme", theme(next)) + }, + destroy() { + unsubscribe() + doc.removeEventListener("pointermove", onPointerMove) + clearHideTimer() + style.remove() + root.remove() + }, } } diff --git a/src/scenes/provider.test.ts b/src/scenes/provider.test.ts index 501a763..0cfdfb2 100644 --- a/src/scenes/provider.test.ts +++ b/src/scenes/provider.test.ts @@ -1,4 +1,5 @@ import { createScenesProvider } from "./provider" +import { pipStageBackdrop } from "./pip-surfaces" import type { HostEvent } from "./protocol" function mount(p: ReturnType) { @@ -521,6 +522,73 @@ test("setSceneTheme fans out to the mirror while in pip", async () => { uninstall() }) +// jsdom rewrites color syntax on assignment (oklch percentages become +// decimals), so compare against what the same value becomes in a style prop. +function asStyleValue(background: string): string { + const probe = document.createElement("div") + probe.style.background = background + return probe.style.background +} + +test("the pip window backdrop follows the stage theme, live", async () => { + const fake = new FakePipWindow() + const uninstall = installFakeDocumentPiP(fake) + const p = createScenesProvider({ src: SRC, theme: "light" }) + const { iframe } = mount(p) + fromHost(iframe, { type: "kino:ready", duration: 40.5 }) + p.actions.enterPiP() + await vi.waitFor(() => expect(p.getState().pip).toBe(true)) + // A hardcoded black body flashed black behind a light stage before the + // mirror painted, the same defect the in-page placeholder had. + expect(fake.document.body.style.background).toBe( + asStyleValue(pipStageBackdrop("light")), + ) + expect(fake.document.documentElement.style.colorScheme).toBe("light") + p.setSceneTheme("dark") + expect(fake.document.body.style.background).toBe( + asStyleValue(pipStageBackdrop("dark")), + ) + expect(fake.document.documentElement.style.colorScheme).toBe("dark") + p.destroy() + uninstall() +}) + +test("chromeTheme themes the pip overlay and setChromeTheme flips it live", async () => { + const fake = new FakePipWindow() + const uninstall = installFakeDocumentPiP(fake) + const p = createScenesProvider({ src: SRC, chromeTheme: "light" }) + 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 overlay = document.body.querySelector( + "[data-kino-pip-overlay]", + ) as HTMLElement + expect(overlay.getAttribute("data-kino-theme")).toBe("light") + p.setChromeTheme("dark") + expect(overlay.getAttribute("data-kino-theme")).toBe("dark") + p.destroy() + uninstall() +}) + +test("a chrome flip outside pip seeds the next pip window", 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.setChromeTheme("light") + p.actions.enterPiP() + await vi.waitFor(() => expect(p.getState().pip).toBe(true)) + expect( + document.body + .querySelector("[data-kino-pip-overlay]")! + .getAttribute("data-kino-theme"), + ).toBe("light") + p.destroy() + uninstall() +}) + test("mirror state feeds drift correction only, never MediaState", async () => { const fake = new FakePipWindow() const uninstall = installFakeDocumentPiP(fake) diff --git a/src/scenes/provider.ts b/src/scenes/provider.ts index 4050606..ed8ce44 100644 --- a/src/scenes/provider.ts +++ b/src/scenes/provider.ts @@ -1,6 +1,11 @@ import { defaultState } from "../core/fake-provider" import { enterPseudoFullscreen } from "../util/pseudo-fullscreen" -import { mountPipPlaceholder, mountPipOverlay } from "./pip-surfaces" +import { + mountPipPlaceholder, + mountPipOverlay, + pipStageBackdrop, + type PipOverlay, +} from "./pip-surfaces" import { parseVtt, cueTextAt, type VttCue } from "./vtt" import type { MediaState, PlayerActions, Provider } from "../core/types" import type { HostCommand, HostEvent } from "./protocol" @@ -25,12 +30,18 @@ export type ScenesProviderOptions = { muted?: boolean // Initial stage theme applied to the host document; defaults to dark. theme?: "light" | "dark" + // Initial chrome theme for the controls drawn over the pip window; defaults + // 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" } -// The Provider contract plus the scenes-only theme channel. The stage is a -// themed document, not chrome, so no other provider grows this. +// The Provider contract plus the scenes-only theme channels. The stage is a +// themed document and the pip window carries its own chrome, so no other +// provider grows these. export type ScenesProvider = Provider & { setSceneTheme(theme: "light" | "dark"): void + setChromeTheme(theme: "light" | "dark"): void } const TRACK_ID = "captions" @@ -80,6 +91,13 @@ export function createScenesProvider( // Stage theme forwarded to the host document. Dark is canonical; anything // but the two literals falls back to it. let theme: "light" | "dark" = opts.theme === "light" ? "light" : "dark" + // Chrome theme for the pip window's own controls. Same fallback rule, and + // it seeds every pip window opened from here, not just the first. + let chromeTheme: "light" | "dark" = + opts.chromeTheme === "light" ? "light" : "dark" + // Live overlay handle while pip is open, so a theme flip mid-session + // restyles it in place. Null outside pip. + let pipOverlay: PipOverlay | null = null // Rate held while a setRate command is in flight, so a stale host snapshot // taken before the command landed doesn't flicker the speed menu back. let pendingRate: number | null = null @@ -215,6 +233,13 @@ export function createScenesProvider( } } + // The pip body shows through until the mirrored stage paints, so it tracks + // the stage theme; a hardcoded black flashed black under a light stage. + const applyPipBackdrop = (win: Window) => { + win.document.body.style.background = pipStageBackdrop(theme) + win.document.documentElement.style.colorScheme = theme + } + const onFullscreenChange = () => patch({ fullscreen: document.fullscreenElement != null }) @@ -316,7 +341,7 @@ export function createScenesProvider( clearPseudoFullscreen() pipWindow = win as Window & { close(): void } win.document.body.style.margin = "0" - win.document.body.style.background = "#000" + applyPipBackdrop(win) // The pip document may be standards-mode with an auto-height body, // which would collapse the percentage-height mirror to 150px. win.document.documentElement.style.height = "100%" @@ -338,9 +363,9 @@ export function createScenesProvider( // The mirror host's parent is the pip window, so its events land // there, not on the main window. win.addEventListener("message", onMirrorMessage) - pipCleanups = [ - mountPipPlaceholder(mountContainer, actions.exitPiP), - mountPipOverlay(win, { + const overlay = mountPipOverlay( + win, + { play: actions.play, pause: actions.pause, getState: () => state, @@ -348,7 +373,16 @@ export function createScenesProvider( listeners.add(l) return () => listeners.delete(l) }, - }), + }, + chromeTheme, + ) + pipOverlay = overlay + pipCleanups = [ + mountPipPlaceholder(mountContainer, actions.exitPiP), + () => { + overlay.destroy() + pipOverlay = null + }, ] onPipPagehide = () => { if (!pipWindow) return @@ -430,6 +464,14 @@ export function createScenesProvider( theme = next === "light" ? "light" : "dark" send({ type: "kino:setTheme", theme }) sendMirror({ type: "kino:setTheme", theme }) + // The mirror repaints the stage; the window behind it repaints here. + if (pipWindow) applyPipBackdrop(pipWindow) + }, + // Themes the controls drawn over the pip window. A flip while pip is open + // restyles it in place; otherwise it just seeds the next window. + setChromeTheme(next) { + chromeTheme = next === "light" ? "light" : "dark" + pipOverlay?.setTheme(chromeTheme) }, destroy() { window.removeEventListener("message", onMessage) diff --git a/src/scenes/scenes-player.tsx b/src/scenes/scenes-player.tsx index 228cc86..974ab2f 100644 --- a/src/scenes/scenes-player.tsx +++ b/src/scenes/scenes-player.tsx @@ -9,13 +9,17 @@ import { type ScenesProviderOptions, } from "./provider" -export type ScenesPlayerProps = Omit & { +export type ScenesPlayerProps = Omit< + ScenesProviderOptions, + "theme" | "chromeTheme" +> & { accentColor?: string theme?: Record /** - * Chrome theme for the parent-document controls; defaults to dark. Stamped - * as `data-kino-theme` on the `.kino` root. Distinct from `sceneTheme`, - * which themes the iframe stage. + * Chrome theme; defaults to dark. Stamped as `data-kino-theme` on the + * `.kino` root, and carried into the picture-in-picture window, whose + * controls kino draws itself. Later values flip both without a remount. + * Distinct from `sceneTheme`, which themes the iframe stage. */ chromeTheme?: "light" | "dark" /** @@ -53,13 +57,21 @@ function ScenesPlayerInner({ }: ScenesPlayerProps) { const providerRef = useRef(null) if (providerRef.current === null) { - providerRef.current = createScenesProvider({ ...opts, theme: sceneTheme }) + providerRef.current = createScenesProvider({ + ...opts, + theme: sceneTheme, + chromeTheme, + }) } - // The initial value already rode the provider options; the extra mount-time - // setSceneTheme is idempotent, and later values flip the host live. + // The initial values already rode the provider options; the extra mount-time + // setters are idempotent, and later values flip the host and any open pip + // window live. useEffect(() => { if (sceneTheme != null) providerRef.current?.setSceneTheme(sceneTheme) }, [sceneTheme]) + useEffect(() => { + if (chromeTheme != null) providerRef.current?.setChromeTheme(chromeTheme) + }, [chromeTheme]) return (