Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions .changeset/youtube-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@karnstack/kino": minor
---

feat: YouTube provider. A new `@karnstack/kino/youtube` entry puts the same kino
glass chrome over the YouTube IFrame Player API. `<YouTubePlayer videoId="…" />`
accepts a bare id or any watch / youtu.be / embed / shorts URL (resolved via the
exported `parseYouTubeId` helper).

Play/pause, seek, speed, fullscreen, volume, and a captions menu (driven by the
video's own subtitle tracks, rendered by YouTube inside the embed) all work
through kino's controls. The provider follows YouTube's API terms — it plays
through the official IFrame API and doesn't obscure the player, so YouTube's own
thumbnail, play button, title, and logo show before playback and while paused.
Quality, picture-in-picture, and scrub-preview storyboards are hidden because
the IFrame API doesn't expose them. No runtime dependency — the API is loaded on
demand.
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<p align="center">
A themeable React video player with a pluggable-provider architecture —
translucent glass chrome, keyboard-first controls, and a small typed surface.
Mux is the first provider.
Mux, raw files, and YouTube are built in.
</p>

<p align="center">
Expand All @@ -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. The Mux provider is built on the `@mux/mux-video` custom element.
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.

## Install

Expand Down Expand Up @@ -106,6 +106,32 @@ Pass sidecar subtitles/captions via `tracks`, and kino renders the cues in its o

`NativePlayer` also takes `autoPlay`, `muted`, `loop`, `defaultRate`, and `crossOrigin` (set the last when the media or a caption track is cross-origin). Quality switching hides itself since a raw file carries no rendition ladder.

## Playing a YouTube video

For a YouTube source, use the YouTube provider. It drives the YouTube IFrame Player API under the same glass chrome, with kino owning the controls and keyboard map (the native YouTube UI is hidden).

```tsx
import { YouTubePlayer } from "@karnstack/kino/youtube"
import "@karnstack/kino/styles.css"

export function Clip() {
return (
<div style={{ aspectRatio: "16 / 9" }}>
<YouTubePlayer
videoId="dQw4w9WgXcQ"
accentColor="oklch(50.8% 0.118 165.612)"
/>
</div>
)
}
```

`videoId` accepts a bare id or any `watch`, `youtu.be`, `embed`, or `shorts` URL — kino resolves it (the `parseYouTubeId` helper is exported if you want it directly). It also takes `autoPlay`, `muted`, `loop`, `defaultRate`, and `metadata`.

Speed, fullscreen, and captions work. The captions menu lists the video's own subtitle tracks; YouTube renders the cues itself inside the embed, so they appear in YouTube's style rather than kino's caption overlay.

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).

## Theming

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

## Roadmap

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

const YOUTUBE_SNIPPET = `import { YouTubePlayer } from "@karnstack/kino/youtube"
import "@karnstack/kino/styles.css"

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

type Prop = [string, string, string]

const SHARED_PROPS: Prop[] = [
Expand Down Expand Up @@ -87,6 +98,24 @@ const NATIVE_PROPS: Prop[] = [
...SHARED_PROPS,
]

const YOUTUBE_PROPS: Prop[] = [
[
"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.",
],
...SHARED_PROPS,
]

const propRows = (props: Prop[]) =>
props.map(([name, type, desc]) => ({
key: name,
Expand All @@ -99,7 +128,7 @@ export function InstallPage() {
<PageHeader
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, and the file provider needs nothing extra."
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."
/>

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

Expand Down Expand Up @@ -157,6 +190,15 @@ export function InstallPage() {
rows={propRows(NATIVE_PROPS)}
/>
</div>
<div className="flex flex-col gap-4">
<h3 className="font-mono text-sm tracking-wide text-paper-faint uppercase">
YouTubePlayer
</h3>
<Table
head={["Prop", "Type", "Description"]}
rows={propRows(YOUTUBE_PROPS)}
/>
</div>
</section>
</div>
)
Expand Down
4 changes: 2 additions & 2 deletions demo/pages/overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const HIGHLIGHTS = [
n: "01",
term: "Pluggable providers",
detail:
"One UI contract, many engines. Mux HLS and raw files ship today; YouTube and Vimeo are next.",
"One UI contract, many engines. Mux HLS, raw files, and YouTube ship today; Vimeo is next.",
},
{
n: "02",
Expand Down Expand Up @@ -50,7 +50,7 @@ export function OverviewPage() {
<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 more — behind a small typed surface.
raw files, and YouTube — behind a small typed surface.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
Expand Down
6 changes: 4 additions & 2 deletions demo/pages/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ const PROVIDERS: ProviderCard[] = [
},
{
name: "YouTube",
status: "planned",
status: "shipped",
entry: "@karnstack/kino/youtube",
detail:
"Embed-backed playback wrapped in the same kino chrome, so a YouTube source feels native to the player.",
"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.",
importLine: 'import { YouTubePlayer } from "@karnstack/kino/youtube"',
},
{
name: "Vimeo",
Expand Down
32 changes: 29 additions & 3 deletions demo/player-studio.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useState, type CSSProperties } from "react"
import { MuxPlayer } from "../src/mux/mux-player"
import { NativePlayer } from "../src/native/native-player"
import { YouTubePlayer } from "../src/youtube/youtube-player"
import { CheckIcon } from "./icons"
import { TouchTarget } from "./ui"

export type Mode = "mux" | "native"
export type Mode = "mux" | "native" | "youtube"

// Public sample assets so the studio plays real media with no account or signed
// tokens — anyone who clones the repo gets the full UI.
Expand All @@ -18,6 +19,13 @@ const NATIVE_SAMPLE = {
label: "accrobra · mp4",
} as const

// A public, embeddable Creative Commons video so the YouTube tab plays for
// anyone who clones the repo.
const YOUTUBE_SAMPLE = {
id: "aqz-KE-bpKQ",
label: "Big Buck Bunny · YouTube",
} as const

export const DEFAULT_ACCENT = "#f4b942"
const ACCENTS = [
{ name: "Leader", value: DEFAULT_ACCENT },
Expand Down Expand Up @@ -48,8 +56,18 @@ export function PlayerStudio({
const [radius, setRadius] = useState(DEFAULT_RADIUS)

const activeSample = SAMPLES.find((s) => s.id === source)
const label = mode === "native" ? NATIVE_SAMPLE.label : activeSample?.label
const code = mode === "native" ? NATIVE_SAMPLE.src : source
const label =
mode === "native"
? NATIVE_SAMPLE.label
: mode === "youtube"
? YOUTUBE_SAMPLE.label
: activeSample?.label
const code =
mode === "native"
? NATIVE_SAMPLE.src
: mode === "youtube"
? YOUTUBE_SAMPLE.id
: source

return (
<div className="flex flex-col gap-5">
Expand All @@ -71,6 +89,13 @@ export function PlayerStudio({
accentColor={accent}
theme={{ "--kino-radius": `${radius}px` }}
/>
) : mode === "youtube" ? (
<YouTubePlayer
key="youtube"
videoId={YOUTUBE_SAMPLE.id}
accentColor={accent}
theme={{ "--kino-radius": `${radius}px` }}
/>
) : (
<MuxPlayer
key="mux"
Expand Down Expand Up @@ -105,6 +130,7 @@ export function PlayerStudio({
[
{ id: "mux", label: "Mux · HLS" },
{ id: "native", label: "Native · mp4" },
{ id: "youtube", label: "YouTube · Embed" },
] as const
).map((p) => {
const active = mode === p.id
Expand Down
Loading
Loading