Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
514532c
docs: Vimeo provider design spec
karngyan Jun 28, 2026
26c021c
docs: Vimeo provider implementation plan
karngyan Jun 28, 2026
fed53d2
feat(vimeo): source parsing + embed-url helper
karngyan Jun 28, 2026
07f5642
feat(vimeo): SDK loader, player lifecycle, test fake
karngyan Jun 28, 2026
d28529f
feat(vimeo): event-driven state sync
karngyan Jun 28, 2026
2edccc4
feat(vimeo): loaded handler — duration, qualities, tracks, capabilities
karngyan Jun 28, 2026
2d5fca5
feat(vimeo): player actions
karngyan Jun 28, 2026
93fb021
feat(vimeo): captions — overlay cues + track selection
karngyan Jun 28, 2026
49f86a8
feat(vimeo): swapSource with hash channel
karngyan Jun 28, 2026
7fd2d5c
feat(vimeo): VimeoPlayer React wrapper + entry
karngyan Jun 28, 2026
fbb334f
build(vimeo): wire ./vimeo entry point
karngyan Jun 28, 2026
ff8ac8c
docs(vimeo): README, demo, changeset
karngyan Jun 28, 2026
435155c
fix(vimeo): drop auto pseudo-quality + sync rate on setPlaybackRate r…
karngyan Jun 28, 2026
57205e7
docs(vimeo): reorder studio providers + show Source only for Mux
karngyan Jun 28, 2026
765f380
fix(vimeo): advance currentTime on seeking event; guard bufferstart d…
karngyan Jun 28, 2026
f5bb39a
docs: design spec — demo copy-as-markdown + llms.txt
karngyan Jun 28, 2026
95befab
feat(demo): copy-as-markdown button per page + /llms.txt
karngyan Jun 28, 2026
1f9de13
fix(vimeo): grant the SDK iframe picture-in-picture permission
karngyan Jun 28, 2026
ba298cb
fix(vimeo): disable PiP — it can't be driven from the parent frame
karngyan Jun 28, 2026
98b7b05
docs(vimeo): drop picture-in-picture from the Vimeo feature copy
karngyan Jun 28, 2026
889c5e1
style: apply prettier formatting
karngyan Jun 28, 2026
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
8 changes: 8 additions & 0 deletions .changeset/vimeo-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@karnstack/kino": minor
---

Add a Vimeo provider (`@karnstack/kino/vimeo`) — the Vimeo Player SDK under
kino's chrome with quality selection, styled captions, and playback rate.
Supports unlisted videos via a `hash`. Import `VimeoPlayer` or the lower-level
`createVimeoProvider`.
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

> **[Try it live → kino.karnstack.com](https://kino.karnstack.com)** — drop in any public Mux playback ID, pick an accent, and play with the real glass UI.

kino ships the player UI and a provider contract. Each provider adapts a streaming engine to that contract, so the same glass chrome can sit on top of different backends. Three providers ship today: **Mux** (adaptive HLS via `@mux/mux-video`), **Native** (a plain `<video>` over any raw file URL), and **YouTube** (the IFrame Player API wrapped in the same chrome). Each lives behind its own entry point, so you only pull in the engine you use.
kino ships the player UI and a provider contract. Each provider adapts a streaming engine to that contract, so the same glass chrome can sit on top of different backends. Four providers ship today: **Mux** (adaptive HLS via `@mux/mux-video`), **Native** (a plain `<video>` over any raw file URL), **YouTube** (the IFrame Player API wrapped in the same chrome), and **Vimeo** (the Vimeo Player SDK under the same chrome). Each lives behind its own entry point, so you only pull in the engine you use.

## Install

Expand Down Expand Up @@ -132,6 +132,27 @@ Speed, fullscreen, and captions work. The captions menu lists the video's own su

kino plays YouTube through the official IFrame Player API and, per YouTube's terms, **doesn't obscure the player**: before playback and while paused, YouTube shows its own thumbnail, play button, title, and logo, and kino's controls sit alongside them. A few things the API simply doesn't expose, so kino hides those controls: **manual quality** (YouTube dropped it — playback is always automatic), **picture-in-picture**, and **scrub-preview thumbnails** (storyboards aren't available to embeds).

## Playing a Vimeo video

For a Vimeo source, use the Vimeo provider. It drives the Vimeo Player SDK under the same glass chrome, with kino owning the controls and keyboard map.

```tsx
import { VimeoPlayer } from "@karnstack/kino/vimeo"
import "@karnstack/kino/styles.css"

export default function Watch() {
return <VimeoPlayer videoId="291235566" accentColor="#00adef" />
}
```

For an unlisted video, pass the hash (or a share URL that contains it):

```tsx
<VimeoPlayer videoId="123456789" hash="abcdef0123" />
```

Chromeless playback (kino owning the controls) requires a paid Vimeo plan.

## Theming

The quickest knob is the `accentColor` prop, which drives the scrubber fill, active menu items, and range controls.
Expand Down Expand Up @@ -201,7 +222,6 @@ pnpm lint # eslint

## Roadmap

- More providers: Vimeo
- AirPlay support
- Chapters
- Documented headless primitives for fully custom chrome
Expand Down
144 changes: 144 additions & 0 deletions demo/pages/install.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ export function Clip() {
)
}`

const VIMEO_SNIPPET = `import { VimeoPlayer } from "@karnstack/kino/vimeo"
import "@karnstack/kino/styles.css"

export function Clip() {
return (
<div style={{ aspectRatio: "16 / 9" }}>
<VimeoPlayer videoId="291235566" />
</div>
)
}`

type Prop = [string, string, string]

const SHARED_PROPS: Prop[] = [
Expand Down Expand Up @@ -116,6 +127,125 @@ const YOUTUBE_PROPS: Prop[] = [
...SHARED_PROPS,
]

const VIMEO_PROPS: Prop[] = [
["videoId", "string", "Vimeo video id or any Vimeo share URL. Required."],
["hash", "string", "Privacy hash for unlisted videos."],
["autoPlay", "boolean", "Start playback on mount."],
["muted", "boolean", "Start muted."],
["loop", "boolean", "Loop playback."],
["defaultRate", "number", "Initial playback rate."],
[
"metadata",
"{ videoId?, videoTitle?, viewerUserId? }",
"OS media-session metadata.",
],
...SHARED_PROPS,
]

const markdown = `# Up and running.

kino is a single package with per-provider entry points. React 19 is a peer dependency; the Mux engine is pulled in transitively, while the native and YouTube providers need nothing extra — YouTube loads the IFrame API at runtime.

## Add the package

\`\`\`bash
pnpm add @karnstack/kino
\`\`\`

Import the stylesheet once with \`import "@karnstack/kino/styles.css"\`, then give the player a sized container — it fills the full width and height of its parent.

## Quick start

### Mux

\`\`\`tsx
${MUX_SNIPPET}
\`\`\`

### Native

\`\`\`tsx
${NATIVE_SNIPPET}
\`\`\`

### YouTube

\`\`\`tsx
${YOUTUBE_SNIPPET}
\`\`\`

### Vimeo

\`\`\`tsx
${VIMEO_SNIPPET}
\`\`\`

## API

### MuxPlayer

| Prop | Type | Description |
|---|---|---|
| playbackId | string | Mux playback id. Required. |
| tokens | { playback?, thumbnail?, storyboard? } | Signed-playback tokens, minted server-side. |
| poster | string | Override the derived Mux thumbnail. |
| metadata | { videoId?, videoTitle?, viewerUserId? } | Mux Data metadata. |
| autoPlay | boolean | Start playback on mount. |
| defaultRate | number | Initial playback rate. |
| accentColor | string | Accent color — any CSS color. |
| theme | Record<string, string> | Inline CSS custom properties on the root. |
| placeholder | string | Blur-up still painted until the poster loads. |
| className | string | Extra class on the .kino root. |

### NativePlayer

| Prop | Type | Description |
|---|---|---|
| src | string | Raw media URL — mp4, webm, ogg. Required. |
| poster | string | Poster image URL. |
| tracks | NativeTextTrack[] | Sidecar subtitle / caption tracks. |
| autoPlay | boolean | Start playback on mount. |
| muted | boolean | Start muted. |
| loop | boolean | Loop playback. |
| defaultRate | number | Initial playback rate. |
| crossOrigin | "anonymous" \\| "use-credentials" | CORS mode for cross-origin media and tracks. |
| metadata | { videoId?, videoTitle?, viewerUserId? } | OS media-session metadata. |
| accentColor | string | Accent color — any CSS color. |
| theme | Record<string, string> | Inline CSS custom properties on the root. |
| placeholder | string | Blur-up still painted until the poster loads. |
| className | string | Extra class on the .kino root. |

### YouTubePlayer

| Prop | Type | Description |
|---|---|---|
| videoId | string | Video id or watch / youtu.be / embed / shorts URL. Required. |
| autoPlay | boolean | Start playback on mount. |
| muted | boolean | Start muted. |
| loop | boolean | Loop playback. |
| defaultRate | number | Initial playback rate. |
| metadata | { videoId?, videoTitle?, viewerUserId? } | OS media-session metadata. |
| accentColor | string | Accent color — any CSS color. |
| theme | Record<string, string> | Inline CSS custom properties on the root. |
| placeholder | string | Blur-up still painted until the poster loads. |
| className | string | Extra class on the .kino root. |

### VimeoPlayer

| Prop | Type | Description |
|---|---|---|
| videoId | string | Vimeo video id or any Vimeo share URL. Required. |
| hash | string | Privacy hash for unlisted videos. |
| autoPlay | boolean | Start playback on mount. |
| muted | boolean | Start muted. |
| loop | boolean | Loop playback. |
| defaultRate | number | Initial playback rate. |
| metadata | { videoId?, videoTitle?, viewerUserId? } | OS media-session metadata. |
| accentColor | string | Accent color — any CSS color. |
| theme | Record<string, string> | Inline CSS custom properties on the root. |
| placeholder | string | Blur-up still painted until the poster loads. |
| className | string | Extra class on the .kino root. |`

const propRows = (props: Prop[]) =>
props.map(([name, type, desc]) => ({
key: name,
Expand All @@ -129,6 +259,7 @@ export function InstallPage() {
eyebrow="Install"
title="Up and running."
lead="kino is a single package with per-provider entry points. React 19 is a peer dependency; the Mux engine is pulled in transitively, while the native and YouTube providers need nothing extra — YouTube loads the IFrame API at runtime."
markdown={markdown}
/>

<section className="flex flex-col gap-6">
Expand Down Expand Up @@ -162,6 +293,10 @@ export function InstallPage() {
<h3 className="text-lg font-medium text-paper">YouTube</h3>
<CodeBlock code={YOUTUBE_SNIPPET} label="youtube.tsx" />
</div>
<div className="flex flex-col gap-3">
<h3 className="text-lg font-medium text-paper">Vimeo</h3>
<CodeBlock code={VIMEO_SNIPPET} label="vimeo.tsx" />
</div>
</div>
</section>

Expand Down Expand Up @@ -199,6 +334,15 @@ export function InstallPage() {
rows={propRows(YOUTUBE_PROPS)}
/>
</div>
<div className="flex flex-col gap-4">
<h3 className="font-mono text-sm tracking-wide text-paper-faint uppercase">
VimeoPlayer
</h3>
<Table
head={["Prop", "Type", "Description"]}
rows={propRows(VIMEO_PROPS)}
/>
</div>
</section>
</div>
)
Expand Down
28 changes: 25 additions & 3 deletions demo/pages/overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,38 @@ import {
btnPrimary,
btnSecondary,
CopyButton,
CopyMarkdownButton,
Eyebrow,
FrameNumber,
} from "../ui"
import { ArrowRightIcon } from "../icons"

const INSTALL = "pnpm add @karnstack/kino"

const markdown = `# Glass chrome for every video.

kino is a themeable React video player with a pluggable-provider architecture. The same translucent, keyboard-first UI sits over Mux, raw files, YouTube, and Vimeo — behind a small typed surface.

\`\`\`bash
${INSTALL}
\`\`\`

## Why kino

**01 — Pluggable providers.** One UI contract, many engines. Mux HLS, raw files, YouTube, and Vimeo all ship today.

**02 — Keyboard-first.** Play, seek, speed, captions, and fullscreen are all driven from the keyboard out of the box.

**03 — Themeable.** Set the accent with a single prop, or repaint every surface through CSS custom properties.

**04 — Capability-aware.** Controls hide themselves when the active engine or platform can't support them — never a dead button.`

const HIGHLIGHTS = [
{
n: "01",
term: "Pluggable providers",
detail:
"One UI contract, many engines. Mux HLS, raw files, and YouTube ship today; Vimeo is next.",
"One UI contract, many engines. Mux HLS, raw files, YouTube, and Vimeo all ship today.",
},
{
n: "02",
Expand All @@ -42,15 +61,18 @@ export function OverviewPage() {
return (
<div className="flex flex-col gap-20 lg:gap-28">
<section className="flex flex-col gap-7 pt-2">
<Eyebrow>React video player</Eyebrow>
<div className="flex items-start justify-between gap-4">
<Eyebrow>React video player</Eyebrow>
<CopyMarkdownButton markdown={markdown} />
</div>
<div>
<h1 className="max-w-[18ch] font-display text-5xl font-semibold tracking-tight text-balance text-paper sm:text-6xl">
Glass chrome for every video.
</h1>
<p className="mt-6 max-w-[56ch] text-lg/8 text-pretty text-paper-dim">
kino is a themeable React video player with a pluggable-provider
architecture. The same translucent, keyboard-first UI sits over Mux,
raw files, and YouTube — behind a small typed surface.
raw files, YouTube, and Vimeo — behind a small typed surface.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
Expand Down
45 changes: 43 additions & 2 deletions demo/pages/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ const PROVIDERS: ProviderCard[] = [
},
{
name: "Vimeo",
status: "planned",
status: "shipped",
entry: "@karnstack/kino/vimeo",
detail:
"The Vimeo player SDK adapted to the kino provider contract, sharing the glass UI and keyboard map.",
"The Vimeo Player SDK under the same kino chrome — quality, styled captions, and rate. Chromeless playback needs a paid Vimeo plan.",
importLine: 'import { VimeoPlayer } from "@karnstack/kino/vimeo"',
},
]

Expand All @@ -52,13 +54,52 @@ const PROVIDER_CONTRACT = `export interface Provider {
swapSource?(opts: SourceOptions): void
}`

const markdown = `# One UI, many engines.

kino ships the player UI and a provider contract. Each provider adapts a streaming engine to that contract, so the same glass chrome can sit on top of any backend.

## Providers

**Mux** (\`@karnstack/kino/mux\`) — Adaptive HLS through the @mux/mux-video element — renditions, storyboard scrub previews, captions, and signed playback.

\`\`\`ts
import { MuxPlayer } from "@karnstack/kino/mux"
\`\`\`

**Native** (\`@karnstack/kino/native\`) — A native <video> element over any raw mp4, webm, or ogg URL. No streaming engine, no account, nothing to register.

\`\`\`ts
import { NativePlayer } from "@karnstack/kino/native"
\`\`\`

**YouTube** (\`@karnstack/kino/youtube\`) — The YouTube IFrame Player API wrapped in the same kino chrome — kino owns the controls, keyboard map, and captions menu. Quality and PiP follow YouTube's API limits.

\`\`\`ts
import { YouTubePlayer } from "@karnstack/kino/youtube"
\`\`\`

**Vimeo** (\`@karnstack/kino/vimeo\`) — The Vimeo Player SDK under the same kino chrome — quality, styled captions, and rate. Chromeless playback needs a paid Vimeo plan.

\`\`\`ts
import { VimeoPlayer } from "@karnstack/kino/vimeo"
\`\`\`

## The contract

A provider is a handful of methods. Implement \`mount\`, a \`getState\` / \`subscribe\` pair, an \`actions\` object, and \`destroy\`. The UI reads everything through this surface — it never talks to an engine directly.

\`\`\`ts
${PROVIDER_CONTRACT}
\`\`\``

export function ProvidersPage() {
return (
<div className="flex flex-col gap-16">
<PageHeader
eyebrow="Architecture"
title="One UI, many engines."
lead="kino ships the player UI and a provider contract. Each provider adapts a streaming engine to that contract, so the same glass chrome can sit on top of any backend."
markdown={markdown}
/>

<section className="flex flex-col gap-6">
Expand Down
Loading
Loading