diff --git a/.changeset/youtube-provider.md b/.changeset/youtube-provider.md new file mode 100644 index 0000000..39cba8b --- /dev/null +++ b/.changeset/youtube-provider.md @@ -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. `` +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. diff --git a/README.md b/README.md index 5e54d27..d728160 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

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.

@@ -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 `

+ +
+ ) +} +``` + +`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. @@ -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 diff --git a/demo/pages/install.tsx b/demo/pages/install.tsx index 08ea8fc..cf002d8 100644 --- a/demo/pages/install.tsx +++ b/demo/pages/install.tsx @@ -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 ( +
+ +
+ ) +}` + type Prop = [string, string, string] const SHARED_PROPS: Prop[] = [ @@ -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, @@ -99,7 +128,7 @@ export function InstallPage() {
@@ -129,6 +158,10 @@ export function InstallPage() {

Native

+
+

YouTube

+ +
@@ -157,6 +190,15 @@ export function InstallPage() { rows={propRows(NATIVE_PROPS)} /> +
+

+ YouTubePlayer +

+ + ) diff --git a/demo/pages/overview.tsx b/demo/pages/overview.tsx index be3c7b7..21ed22c 100644 --- a/demo/pages/overview.tsx +++ b/demo/pages/overview.tsx @@ -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", @@ -50,7 +50,7 @@ export function OverviewPage() {

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.

diff --git a/demo/pages/providers.tsx b/demo/pages/providers.tsx index e95aece..556bb46 100644 --- a/demo/pages/providers.tsx +++ b/demo/pages/providers.tsx @@ -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", diff --git a/demo/player-studio.tsx b/demo/player-studio.tsx index 305cd77..97572a8 100644 --- a/demo/player-studio.tsx +++ b/demo/player-studio.tsx @@ -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. @@ -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 }, @@ -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 (
@@ -71,6 +89,13 @@ export function PlayerStudio({ accentColor={accent} theme={{ "--kino-radius": `${radius}px` }} /> + ) : mode === "youtube" ? ( + ) : ( { const active = mode === p.id diff --git a/docs/superpowers/specs/2026-06-28-youtube-provider-design.md b/docs/superpowers/specs/2026-06-28-youtube-provider-design.md new file mode 100644 index 0000000..43a53d0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-youtube-provider-design.md @@ -0,0 +1,153 @@ +# YouTube provider — design + +## Goal + +Add a YouTube provider to kino so the same glass chrome plays YouTube videos, +mirroring the existing `mux/` and `native/` providers. Then update the README, +demo site, and overview copy so the docs advertise **three** shipped providers +(Mux, Native, YouTube) instead of framing Mux as the only/first one. + +## Architecture + +kino's contract is `Provider` (`src/core/types.ts`): `mount(container)`, +`getState()`, `subscribe(listener)`, `actions`, `destroy()`, optional +`swapSource(opts)`. A thin React wrapper creates the provider once +(`useRef`) and routes reactive prop changes through `swapSource`. UI controls +gate themselves on the `capabilities` set the provider reports. + +The YouTube provider follows the `native/` shape exactly. + +### New files + +- `src/youtube/provider.ts` — `createYouTubeProvider(opts): Provider` + + `YouTubeProviderOptions` type + `parseYouTubeId(input): string` helper. +- `src/youtube/youtube-player.tsx` — `` (props = + `YouTubeProviderOptions` + `accentColor/theme/className/placeholder/children`). +- `src/youtube/provider.test.ts` — vitest, mirroring `native/provider.test.ts`. +- `src/youtube.ts` — entry re-exporting the provider, component, types, helper. + +### Wiring + +- `package.json` → add `exports["./youtube"]`. +- `tsdown.config.ts` → add `youtube: "src/youtube.ts"` entry. +- `package.json` `devDependencies` → `@types/youtube` (global `YT` typings only; + no runtime dependency — the IFrame API script is loaded at runtime). +- `src/styles/kino.css` → add `.kino iframe` to the existing + `.kino mux-video, .kino video { inset:0; width/height:100% }` rule. + +## Engine integration + +YouTube IFrame Player API. A module-level singleton promise lazy-loads +`https://www.youtube.com/iframe_api` and resolves when `window.YT.Player` is +ready (chaining the existing global `onYouTubeIframeAPIReady` callback so +multiple players coexist). Already-loaded `window.YT` short-circuits. + +`mount(container)` appends a host `
` and, once the API resolves, constructs +`new YT.Player(hostDiv, { videoId, playerVars: { controls: 0, playsinline: 1, +rel: 0, modestbranding: 1, autoplay, mute, loop, playlist }, events })`. The API +replaces the div with an `