From 68ca82fedd3414c49be50cd47c6bf0091f9294d8 Mon Sep 17 00:00:00 2001 From: Karn Date: Sat, 25 Jul 2026 00:54:05 +0530 Subject: [PATCH] feat(scenes): carry chromeTheme into the picture-in-picture window The pip window is a separate document that never loads kino.css, so its controls kept a hardcoded dark palette and its body a hardcoded black, which read as a dark player bolted onto a light one the moment a lesson ran in light mode. The overlay now injects a small token sheet into the pip document, dark on the root with a light block keyed on data-kino-theme, so every color it paints is themeable and a flip is one attribute change on a live window. mountPipOverlay returns a handle ({ setTheme, destroy }) instead of a bare cleanup so the theme can change while the window stays open. createScenesProvider takes a chromeTheme option, ScenesProvider gains setChromeTheme, and ScenesPlayer wires both to the chromeTheme prop it already had, matching how sceneTheme rides the wire to the host. The pip window backdrop follows sceneTheme rather than chromeTheme: it is what shows before the mirrored stage paints, so it belongs to the stage. colorScheme on the pip document follows it too. --- .changeset/pip-window-chrome-theme.md | 5 ++ README.md | 10 +++ src/scenes/pip-surfaces.test.ts | 91 +++++++++++++++++++--- src/scenes/pip-surfaces.ts | 106 +++++++++++++++++++++----- src/scenes/provider.test.ts | 68 +++++++++++++++++ src/scenes/provider.ts | 58 ++++++++++++-- src/scenes/scenes-player.tsx | 26 +++++-- 7 files changed, 317 insertions(+), 47 deletions(-) create mode 100644 .changeset/pip-window-chrome-theme.md 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 (