Skip to content

Commit 3d48236

Browse files
committed
add placeholder (blur-up) prop and fix rate persistence across swaps
placeholder: pass a low-res data URI or URL and kino paints it behind the video while the poster and first frame load, then the sharp poster covers it. Rendered as a .kino-placeholder <img> at the video host's z-level but earlier in the DOM, so the video paints over it. Plumbed through both the headless Player and the MuxPlayer wrapper. rate: loading a new source resets the media element's playbackRate to defaultPlaybackRate, so the chosen speed dropped to 1x on every swapSource. Seed defaultPlaybackRate at mount and keep it in lockstep in setRate so the rate survives source changes. Also widen the eslint ignore to **/dist/** so a built demo/dist no longer poisons `pnpm lint`.
1 parent 4af055d commit 3d48236

8 files changed

Lines changed: 80 additions & 2 deletions

File tree

.changeset/placeholder-and-rate.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@karnstack/kino": minor
3+
---
4+
5+
Add a `placeholder` prop (blur-up): pass a low-res data URI or URL and kino paints it behind the video while the poster and first frame load, then the sharp poster covers it. Works on `MuxPlayer` and the headless `Player`.
6+
7+
Fix playback-rate persistence across source swaps. Loading a new source resets the media element's `playbackRate` to `defaultPlaybackRate`, so the chosen speed dropped to 1x on every `swapSource`. kino now keeps `defaultPlaybackRate` in lockstep with the rate (seeded at mount, updated in `setRate`), so the rate survives lesson/source changes.

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ Give the player a sized container. It fills `100%` width and height of its paren
5757

5858
kino is auth-agnostic. For signed playback you mint the `playback`, `thumbnail`, and `storyboard` tokens server-side and hand them to the player through the `tokens` prop. The player never holds a signing key and never talks to your auth layer; it only appends the tokens you give it to the media, thumbnail, and storyboard URLs. For public playback you can omit `tokens` entirely.
5959

60+
### Blur-up placeholder
61+
62+
Before the poster and first frame load, the video box is empty. Pass a small `placeholder` (a base64 data URI or a URL) and kino paints it behind the video as a blur-up; the sharp poster covers it once decoded, and it reappears briefly across source swaps.
63+
64+
```tsx
65+
<MuxPlayer playbackId="..." placeholder={blurDataUrl} />
66+
```
67+
68+
The poster itself stays the signed Mux thumbnail (kino derives it from `playbackId` + the `thumbnail` token), so `placeholder` is purely the instant low-res layer underneath.
69+
6070
## Theming
6171

6272
The quickest knob is the `accentColor` prop, which drives the scrubber fill, active menu items, and range controls.

eslint.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@ export default [
1111
},
1212
},
1313
{
14-
ignores: ["dist/**", "node_modules/**"],
14+
ignores: ["**/dist/**", "**/node_modules/**"],
1515
},
1616
]

src/mux/mux-player.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ export type MuxPlayerProps = MuxProviderOptions & {
1010
accentColor?: string
1111
theme?: Record<string, string>
1212
className?: string
13+
/** Blur-up still painted behind the video until the poster/first frame loads. */
14+
placeholder?: string
1315
children?: ReactNode
1416
}
1517

1618
export function MuxPlayer({
1719
accentColor,
1820
theme,
1921
className,
22+
placeholder,
2023
children,
2124
...opts
2225
}: MuxPlayerProps) {
@@ -57,6 +60,7 @@ export function MuxPlayer({
5760
accentColor={accentColor}
5861
theme={theme}
5962
className={className}
63+
placeholder={placeholder}
6064
>
6165
<IdleOverlay />
6266
<Captions />

src/mux/provider.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,11 @@ export function createMuxProvider(opts: MuxProviderOptions): Provider {
298298
if (el) el.currentTime = t
299299
},
300300
setRate: (r) => {
301-
if (el) el.playbackRate = r
301+
if (!el) return
302+
el.playbackRate = r
303+
// Loading a new source resets playbackRate to defaultPlaybackRate, so keep
304+
// them in lockstep — otherwise the chosen rate drops to 1x on every swap.
305+
el.defaultPlaybackRate = r
302306
},
303307
setVolume: (v) => {
304308
if (el) el.volume = Math.min(1, Math.max(0, v))
@@ -367,6 +371,9 @@ export function createMuxProvider(opts: MuxProviderOptions): Provider {
367371
buildImageUrl(opts.playbackId, "thumbnail", opts.tokens?.thumbnail)
368372
if (opts.autoPlay) el.autoplay = true
369373
el.playbackRate = state.rate
374+
// defaultPlaybackRate is what the element reverts to when a new source
375+
// loads; seed it so the initial rate survives the first (and every) load.
376+
el.defaultPlaybackRate = state.rate
370377
if (opts.envKey) el.envKey = opts.envKey
371378
if (opts.metadata) {
372379
el.metadata = {

src/styles/kino.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@
3434
width: 100%;
3535
height: 100%;
3636
}
37+
.kino .kino-placeholder {
38+
position: absolute;
39+
inset: 0;
40+
width: 100%;
41+
height: 100%;
42+
object-fit: cover;
43+
/* Same level as the video host but earlier in the DOM, so the video element
44+
(and its sharp poster, once decoded) paints over this blur-up. */
45+
z-index: 0;
46+
pointer-events: none;
47+
user-select: none;
48+
}
3749
.kino .kino-video-host {
3850
position: absolute;
3951
inset: 0;

src/ui/player.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,28 @@ test("renders children overlay and applies accent var", () => {
1616
expect(root.style.getPropertyValue("--kino-accent")).toBe("rgb(1,2,3)")
1717
})
1818

19+
test("renders the placeholder behind the video host when given", () => {
20+
const provider = createFakeProvider()
21+
const { container } = render(
22+
<Player provider={provider} placeholder="data:image/png;base64,AAAA" />,
23+
)
24+
const img = container.querySelector(".kino-placeholder") as HTMLImageElement
25+
expect(img).toBeInTheDocument()
26+
expect(img.getAttribute("src")).toBe("data:image/png;base64,AAAA")
27+
// Must paint behind the video, so it precedes the host in the DOM.
28+
const host = container.querySelector(".kino-video-host")
29+
expect(
30+
img.compareDocumentPosition(host as Node) &
31+
Node.DOCUMENT_POSITION_FOLLOWING,
32+
).toBeTruthy()
33+
})
34+
35+
test("omits the placeholder when not given", () => {
36+
const provider = createFakeProvider()
37+
const { container } = render(<Player provider={provider} />)
38+
expect(container.querySelector(".kino-placeholder")).toBeNull()
39+
})
40+
1941
test("space toggles playback via keyboard", () => {
2042
const provider = createFakeProvider({ paused: true })
2143
const { container } = render(<Player provider={provider} />)

src/ui/player.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ type PlayerProps = {
5050
accentColor?: string
5151
theme?: Record<string, string>
5252
className?: string
53+
/**
54+
* Low-res still (data URI or URL) painted behind the video while the poster
55+
* and first frame load — a blur-up. The sharp poster covers it once decoded,
56+
* so it only shows during the initial load and across source swaps.
57+
*/
58+
placeholder?: string
5359
children?: ReactNode
5460
}
5561

@@ -58,6 +64,7 @@ export function Player({
5864
accentColor,
5965
theme,
6066
className,
67+
placeholder,
6168
children,
6269
}: PlayerProps) {
6370
const wrapperRef = useRef<HTMLDivElement | null>(null)
@@ -170,6 +177,15 @@ export function Player({
170177
style={style as CSSProperties}
171178
tabIndex={0}
172179
>
180+
{placeholder && (
181+
<img
182+
className="kino-placeholder"
183+
src={placeholder}
184+
alt=""
185+
aria-hidden="true"
186+
draggable={false}
187+
/>
188+
)}
173189
<div ref={videoHostRef} className="kino-video-host" />
174190
<PlayerChrome compact={compact}>{children}</PlayerChrome>
175191
</div>

0 commit comments

Comments
 (0)