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 (