@karnstack/kino 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -3
- package/dist/captions-CGVAzrJq.js +40 -0
- package/dist/fake-provider-Do3fX4-T.js +36 -0
- package/dist/mux.js +3 -72
- package/dist/native.d.ts +56 -0
- package/dist/native.js +359 -0
- package/dist/styles.css +11 -1
- package/dist/youtube.d.ts +54 -0
- package/dist/youtube.js +293 -0
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<p align="center">
|
|
6
6
|
A themeable React video player with a pluggable-provider architecture —
|
|
7
7
|
translucent glass chrome, keyboard-first controls, and a small typed surface.
|
|
8
|
-
Mux
|
|
8
|
+
Mux, raw files, and YouTube are built in.
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
> **[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.
|
|
26
26
|
|
|
27
|
-
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.
|
|
27
|
+
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.
|
|
28
28
|
|
|
29
29
|
## Install
|
|
30
30
|
|
|
@@ -67,6 +67,71 @@ Before the poster and first frame load, the video box is empty. Pass a small `pl
|
|
|
67
67
|
|
|
68
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
69
|
|
|
70
|
+
## Playing a raw URL
|
|
71
|
+
|
|
72
|
+
For a plain media URL (mp4, webm, ogg, …) — no Mux account or HLS engine — use the native provider. It puts the same glass chrome over a native `<video>` element, so this entry pulls in none of the Mux engine.
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
import { NativePlayer } from "@karnstack/kino/native"
|
|
76
|
+
import "@karnstack/kino/styles.css"
|
|
77
|
+
|
|
78
|
+
export function Clip() {
|
|
79
|
+
return (
|
|
80
|
+
<div style={{ aspectRatio: "16 / 9" }}>
|
|
81
|
+
<NativePlayer
|
|
82
|
+
src="https://example.com/clip.mp4"
|
|
83
|
+
poster="https://example.com/clip.jpg"
|
|
84
|
+
accentColor="oklch(50.8% 0.118 165.612)"
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Pass sidecar subtitles/captions via `tracks`, and kino renders the cues in its own styled overlay:
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
<NativePlayer
|
|
95
|
+
src="https://example.com/clip.mp4"
|
|
96
|
+
tracks={[
|
|
97
|
+
{
|
|
98
|
+
src: "https://example.com/en.vtt",
|
|
99
|
+
srclang: "en",
|
|
100
|
+
label: "English",
|
|
101
|
+
default: true,
|
|
102
|
+
},
|
|
103
|
+
]}
|
|
104
|
+
/>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`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.
|
|
108
|
+
|
|
109
|
+
## Playing a YouTube video
|
|
110
|
+
|
|
111
|
+
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).
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
import { YouTubePlayer } from "@karnstack/kino/youtube"
|
|
115
|
+
import "@karnstack/kino/styles.css"
|
|
116
|
+
|
|
117
|
+
export function Clip() {
|
|
118
|
+
return (
|
|
119
|
+
<div style={{ aspectRatio: "16 / 9" }}>
|
|
120
|
+
<YouTubePlayer
|
|
121
|
+
videoId="dQw4w9WgXcQ"
|
|
122
|
+
accentColor="oklch(50.8% 0.118 165.612)"
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`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`.
|
|
130
|
+
|
|
131
|
+
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.
|
|
132
|
+
|
|
133
|
+
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).
|
|
134
|
+
|
|
70
135
|
## Theming
|
|
71
136
|
|
|
72
137
|
The quickest knob is the `accentColor` prop, which drives the scrubber fill, active menu items, and range controls.
|
|
@@ -136,7 +201,7 @@ pnpm lint # eslint
|
|
|
136
201
|
|
|
137
202
|
## Roadmap
|
|
138
203
|
|
|
139
|
-
- More providers:
|
|
204
|
+
- More providers: Vimeo
|
|
140
205
|
- AirPlay support
|
|
141
206
|
- Chapters
|
|
142
207
|
- Documented headless primitives for fully custom chrome
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//#region src/util/platform.ts
|
|
2
|
+
function detectIOS(ua, maxTouchPoints = 0) {
|
|
3
|
+
if (/iPhone|iPad|iPod/.test(ua)) return true;
|
|
4
|
+
return /Macintosh/.test(ua) && maxTouchPoints > 1;
|
|
5
|
+
}
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/util/captions.ts
|
|
8
|
+
function joinCues(cues) {
|
|
9
|
+
if (!cues || cues.length === 0) return "";
|
|
10
|
+
const parts = [];
|
|
11
|
+
for (let i = 0; i < cues.length; i++) {
|
|
12
|
+
const c = cues[i];
|
|
13
|
+
if (c && typeof c.text === "string") parts.push(c.text);
|
|
14
|
+
}
|
|
15
|
+
return parts.join("\n").replace(/<[^>]+>/g, "").replace(/[^\S\n]+/g, " ").replace(/ *\n */g, "\n").replace(/\n{2,}/g, "\n").trim();
|
|
16
|
+
}
|
|
17
|
+
function activeCueText(tracks, now) {
|
|
18
|
+
if (!tracks) return "";
|
|
19
|
+
for (let i = 0; i < tracks.length; i++) {
|
|
20
|
+
const t = tracks[i];
|
|
21
|
+
if (!t) continue;
|
|
22
|
+
if (t.kind !== "subtitles" && t.kind !== "captions") continue;
|
|
23
|
+
if (t.mode === "disabled") continue;
|
|
24
|
+
const active = joinCues(t.activeCues);
|
|
25
|
+
if (active) return active;
|
|
26
|
+
const all = t.cues;
|
|
27
|
+
if (all && all.length) {
|
|
28
|
+
const hits = [];
|
|
29
|
+
for (let j = 0; j < all.length; j++) {
|
|
30
|
+
const c = all[j];
|
|
31
|
+
if (c && c.startTime <= now && now < c.endTime) hits.push(c);
|
|
32
|
+
}
|
|
33
|
+
const text = joinCues(hits);
|
|
34
|
+
if (text) return text;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
export { detectIOS as n, activeCueText as t };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//#region src/core/fake-provider.ts
|
|
2
|
+
const DEFAULT_CAPS = {
|
|
3
|
+
canSetQuality: true,
|
|
4
|
+
hasStoryboard: false,
|
|
5
|
+
canPiP: true,
|
|
6
|
+
canFullscreen: true,
|
|
7
|
+
canSetRate: true,
|
|
8
|
+
hasTextTracks: false
|
|
9
|
+
};
|
|
10
|
+
function defaultState() {
|
|
11
|
+
return {
|
|
12
|
+
paused: true,
|
|
13
|
+
currentTime: 0,
|
|
14
|
+
duration: 0,
|
|
15
|
+
buffered: [],
|
|
16
|
+
rate: 1,
|
|
17
|
+
volume: 1,
|
|
18
|
+
muted: false,
|
|
19
|
+
readyState: 0,
|
|
20
|
+
seeking: false,
|
|
21
|
+
ended: false,
|
|
22
|
+
error: null,
|
|
23
|
+
qualities: [],
|
|
24
|
+
activeQualityId: "auto",
|
|
25
|
+
videoHeight: 0,
|
|
26
|
+
textTracks: [],
|
|
27
|
+
activeTextTrackId: null,
|
|
28
|
+
activeCueText: "",
|
|
29
|
+
fullscreen: false,
|
|
30
|
+
pip: false,
|
|
31
|
+
storyboard: null,
|
|
32
|
+
capabilities: { ...DEFAULT_CAPS }
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
export { defaultState as t };
|
package/dist/mux.js
CHANGED
|
@@ -1,88 +1,19 @@
|
|
|
1
1
|
import { c as Captions, d as Player, l as IdleOverlay, t as ControlBar } from "./control-bar-B-p1rMgm.js";
|
|
2
|
+
import { t as defaultState } from "./fake-provider-Do3fX4-T.js";
|
|
3
|
+
import { n as detectIOS, t as activeCueText } from "./captions-CGVAzrJq.js";
|
|
2
4
|
import { useEffect, useRef } from "react";
|
|
3
5
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
6
|
import "@mux/mux-video";
|
|
5
|
-
//#region src/core/fake-provider.ts
|
|
6
|
-
const DEFAULT_CAPS = {
|
|
7
|
-
canSetQuality: true,
|
|
8
|
-
hasStoryboard: false,
|
|
9
|
-
canPiP: true,
|
|
10
|
-
canFullscreen: true,
|
|
11
|
-
canSetRate: true,
|
|
12
|
-
hasTextTracks: false
|
|
13
|
-
};
|
|
14
|
-
function defaultState() {
|
|
15
|
-
return {
|
|
16
|
-
paused: true,
|
|
17
|
-
currentTime: 0,
|
|
18
|
-
duration: 0,
|
|
19
|
-
buffered: [],
|
|
20
|
-
rate: 1,
|
|
21
|
-
volume: 1,
|
|
22
|
-
muted: false,
|
|
23
|
-
readyState: 0,
|
|
24
|
-
seeking: false,
|
|
25
|
-
ended: false,
|
|
26
|
-
error: null,
|
|
27
|
-
qualities: [],
|
|
28
|
-
activeQualityId: "auto",
|
|
29
|
-
videoHeight: 0,
|
|
30
|
-
textTracks: [],
|
|
31
|
-
activeTextTrackId: null,
|
|
32
|
-
activeCueText: "",
|
|
33
|
-
fullscreen: false,
|
|
34
|
-
pip: false,
|
|
35
|
-
storyboard: null,
|
|
36
|
-
capabilities: { ...DEFAULT_CAPS }
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
//#endregion
|
|
40
7
|
//#region src/mux/urls.ts
|
|
41
8
|
const IMAGE_HOST = "https://image.mux.com";
|
|
42
9
|
function buildImageUrl(playbackId, kind, token, ext = kind === "storyboard" ? "vtt" : "webp") {
|
|
43
10
|
const base = `${IMAGE_HOST}/${playbackId}/${kind}.${ext}`;
|
|
44
11
|
return token ? `${base}?token=${token}` : base;
|
|
45
12
|
}
|
|
46
|
-
function detectIOS(ua) {
|
|
47
|
-
return /iPhone|iPad|iPod/.test(ua);
|
|
48
|
-
}
|
|
49
|
-
//#endregion
|
|
50
|
-
//#region src/util/captions.ts
|
|
51
|
-
function joinCues(cues) {
|
|
52
|
-
if (!cues || cues.length === 0) return "";
|
|
53
|
-
const parts = [];
|
|
54
|
-
for (let i = 0; i < cues.length; i++) {
|
|
55
|
-
const c = cues[i];
|
|
56
|
-
if (c && typeof c.text === "string") parts.push(c.text);
|
|
57
|
-
}
|
|
58
|
-
return parts.join("\n").replace(/<[^>]+>/g, "").replace(/[^\S\n]+/g, " ").replace(/ *\n */g, "\n").replace(/\n{2,}/g, "\n").trim();
|
|
59
|
-
}
|
|
60
|
-
function activeCueText(tracks, now) {
|
|
61
|
-
if (!tracks) return "";
|
|
62
|
-
for (let i = 0; i < tracks.length; i++) {
|
|
63
|
-
const t = tracks[i];
|
|
64
|
-
if (!t) continue;
|
|
65
|
-
if (t.kind !== "subtitles" && t.kind !== "captions") continue;
|
|
66
|
-
if (t.mode === "disabled") continue;
|
|
67
|
-
const active = joinCues(t.activeCues);
|
|
68
|
-
if (active) return active;
|
|
69
|
-
const all = t.cues;
|
|
70
|
-
if (all && all.length) {
|
|
71
|
-
const hits = [];
|
|
72
|
-
for (let j = 0; j < all.length; j++) {
|
|
73
|
-
const c = all[j];
|
|
74
|
-
if (c && c.startTime <= now && now < c.endTime) hits.push(c);
|
|
75
|
-
}
|
|
76
|
-
const text = joinCues(hits);
|
|
77
|
-
if (text) return text;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
return "";
|
|
81
|
-
}
|
|
82
13
|
//#endregion
|
|
83
14
|
//#region src/mux/provider.ts
|
|
84
15
|
function createMuxProvider(opts) {
|
|
85
|
-
const ios = typeof navigator !== "undefined" && detectIOS(navigator.userAgent);
|
|
16
|
+
const ios = typeof navigator !== "undefined" && detectIOS(navigator.userAgent, navigator.maxTouchPoints);
|
|
86
17
|
let el = null;
|
|
87
18
|
let renditions = null;
|
|
88
19
|
let renditionsBound = false;
|
package/dist/native.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { a as Provider } from "./types-D0bLitH2.js";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
//#region src/native/provider.d.ts
|
|
5
|
+
type NativeTextTrack = {
|
|
6
|
+
src: string;
|
|
7
|
+
srclang: string;
|
|
8
|
+
label: string;
|
|
9
|
+
kind?: "subtitles" | "captions";
|
|
10
|
+
default?: boolean;
|
|
11
|
+
};
|
|
12
|
+
type NativeProviderOptions = {
|
|
13
|
+
src: string;
|
|
14
|
+
poster?: string;
|
|
15
|
+
metadata?: {
|
|
16
|
+
videoId?: string;
|
|
17
|
+
videoTitle?: string;
|
|
18
|
+
viewerUserId?: string;
|
|
19
|
+
};
|
|
20
|
+
autoPlay?: boolean;
|
|
21
|
+
defaultRate?: number;
|
|
22
|
+
muted?: boolean;
|
|
23
|
+
loop?: boolean;
|
|
24
|
+
crossOrigin?: "anonymous" | "use-credentials";
|
|
25
|
+
tracks?: NativeTextTrack[];
|
|
26
|
+
};
|
|
27
|
+
declare function createNativeProvider(opts: NativeProviderOptions): Provider;
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/native/native-player.d.ts
|
|
30
|
+
type NativePlayerProps = NativeProviderOptions & {
|
|
31
|
+
accentColor?: string;
|
|
32
|
+
theme?: Record<string, string>;
|
|
33
|
+
className?: string; /** Blur-up still painted behind the video until the poster/first frame loads. */
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
children?: ReactNode;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* kino's glass chrome over a plain HTML <video> element, playing a raw media
|
|
39
|
+
* URL (mp4, webm, ogg, …) directly. Use this when you have a file URL rather
|
|
40
|
+
* than a Mux playback id.
|
|
41
|
+
*
|
|
42
|
+
* Only `src`, `poster`, and `metadata.videoTitle` are reactive (they flow
|
|
43
|
+
* through `swapSource`). `tracks`, `crossOrigin`, `muted`, `loop`, and
|
|
44
|
+
* `defaultRate` are read once when the provider is created — changing them later
|
|
45
|
+
* is a no-op. Remount (e.g. via `key`) if you need them to change.
|
|
46
|
+
*/
|
|
47
|
+
declare function NativePlayer({
|
|
48
|
+
accentColor,
|
|
49
|
+
theme,
|
|
50
|
+
className,
|
|
51
|
+
placeholder,
|
|
52
|
+
children,
|
|
53
|
+
...opts
|
|
54
|
+
}: NativePlayerProps): import("react").JSX.Element;
|
|
55
|
+
//#endregion
|
|
56
|
+
export { NativePlayer, type NativePlayerProps, type NativeProviderOptions, type NativeTextTrack, createNativeProvider };
|
package/dist/native.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { c as Captions, d as Player, l as IdleOverlay, t as ControlBar } from "./control-bar-B-p1rMgm.js";
|
|
2
|
+
import { t as defaultState } from "./fake-provider-Do3fX4-T.js";
|
|
3
|
+
import { n as detectIOS, t as activeCueText } from "./captions-CGVAzrJq.js";
|
|
4
|
+
import { useEffect, useRef } from "react";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
//#region src/native/provider.ts
|
|
7
|
+
function createNativeProvider(opts) {
|
|
8
|
+
const ios = typeof navigator !== "undefined" && detectIOS(navigator.userAgent, navigator.maxTouchPoints);
|
|
9
|
+
const tracks = opts.tracks ?? [];
|
|
10
|
+
let el = null;
|
|
11
|
+
let state = {
|
|
12
|
+
...defaultState(),
|
|
13
|
+
rate: opts.defaultRate ?? 1,
|
|
14
|
+
muted: opts.muted ?? false,
|
|
15
|
+
activeTextTrackId: null,
|
|
16
|
+
capabilities: {
|
|
17
|
+
canSetQuality: false,
|
|
18
|
+
hasStoryboard: false,
|
|
19
|
+
canPiP: typeof document !== "undefined" && "pictureInPictureEnabled" in document,
|
|
20
|
+
canFullscreen: true,
|
|
21
|
+
canSetRate: true,
|
|
22
|
+
hasTextTracks: tracks.length > 0
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
let desiredRate = opts.defaultRate ?? 1;
|
|
26
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
27
|
+
const emit = () => listeners.forEach((l) => l());
|
|
28
|
+
const patch = (p) => {
|
|
29
|
+
state = {
|
|
30
|
+
...state,
|
|
31
|
+
...p
|
|
32
|
+
};
|
|
33
|
+
emit();
|
|
34
|
+
};
|
|
35
|
+
const reload = () => {
|
|
36
|
+
try {
|
|
37
|
+
el?.load();
|
|
38
|
+
} catch {}
|
|
39
|
+
};
|
|
40
|
+
const onFullscreenChange = () => patch({ fullscreen: document.fullscreenElement != null });
|
|
41
|
+
const onWebkitBeginFullscreen = () => patch({ fullscreen: true });
|
|
42
|
+
const onWebkitEndFullscreen = () => patch({ fullscreen: false });
|
|
43
|
+
const onEnterPip = () => patch({ pip: true });
|
|
44
|
+
const onLeavePip = () => patch({ pip: false });
|
|
45
|
+
let activeTrack = null;
|
|
46
|
+
const readCueText = () => ios ? "" : activeCueText(el?.textTracks, el?.currentTime ?? 0);
|
|
47
|
+
const modeForActiveTrack = () => ios ? "showing" : "hidden";
|
|
48
|
+
const onCueChange = () => patch({ activeCueText: readCueText() });
|
|
49
|
+
const bindActiveTrack = (track) => {
|
|
50
|
+
if (activeTrack) activeTrack.removeEventListener("cuechange", onCueChange);
|
|
51
|
+
activeTrack = track;
|
|
52
|
+
if (activeTrack) activeTrack.addEventListener("cuechange", onCueChange);
|
|
53
|
+
patch({ activeCueText: readCueText() });
|
|
54
|
+
};
|
|
55
|
+
const applyTextTrackModes = () => {
|
|
56
|
+
const tt = el?.textTracks;
|
|
57
|
+
if (!tt) return;
|
|
58
|
+
const id = state.activeTextTrackId;
|
|
59
|
+
let next = null;
|
|
60
|
+
for (let i = 0; i < tt.length; i++) {
|
|
61
|
+
const t = tt[i];
|
|
62
|
+
if (!t) continue;
|
|
63
|
+
if (t.kind !== "subtitles" && t.kind !== "captions") continue;
|
|
64
|
+
if (id != null && (t.id || String(i)) === id) {
|
|
65
|
+
const mode = modeForActiveTrack();
|
|
66
|
+
if (t.mode !== mode) t.mode = mode;
|
|
67
|
+
next = t;
|
|
68
|
+
} else if (t.mode !== "disabled") t.mode = "disabled";
|
|
69
|
+
}
|
|
70
|
+
if (next !== activeTrack) bindActiveTrack(next);
|
|
71
|
+
};
|
|
72
|
+
const readTextTracks = () => {
|
|
73
|
+
const tt = el?.textTracks;
|
|
74
|
+
if (!tt) return [];
|
|
75
|
+
const out = [];
|
|
76
|
+
for (let i = 0; i < tt.length; i++) {
|
|
77
|
+
const t = tt[i];
|
|
78
|
+
if (!t) continue;
|
|
79
|
+
if (t.kind !== "subtitles" && t.kind !== "captions") continue;
|
|
80
|
+
out.push({
|
|
81
|
+
id: t.id || String(i),
|
|
82
|
+
kind: t.kind,
|
|
83
|
+
label: t.label,
|
|
84
|
+
lang: t.language,
|
|
85
|
+
mode: t.mode
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
};
|
|
90
|
+
const syncFromEl = () => {
|
|
91
|
+
if (!el) return;
|
|
92
|
+
if (el.playbackRate !== desiredRate) el.playbackRate = desiredRate;
|
|
93
|
+
applyTextTrackModes();
|
|
94
|
+
const ranges = [];
|
|
95
|
+
for (let i = 0; i < el.buffered.length; i++) ranges.push([el.buffered.start(i), el.buffered.end(i)]);
|
|
96
|
+
patch({
|
|
97
|
+
paused: el.paused,
|
|
98
|
+
currentTime: el.currentTime,
|
|
99
|
+
duration: el.duration || 0,
|
|
100
|
+
buffered: ranges,
|
|
101
|
+
rate: desiredRate,
|
|
102
|
+
volume: el.volume,
|
|
103
|
+
muted: el.muted,
|
|
104
|
+
readyState: el.readyState,
|
|
105
|
+
seeking: el.seeking,
|
|
106
|
+
ended: el.ended,
|
|
107
|
+
error: el.error ? {
|
|
108
|
+
code: el.error.code,
|
|
109
|
+
message: el.error.message
|
|
110
|
+
} : null,
|
|
111
|
+
videoHeight: el.videoHeight || 0,
|
|
112
|
+
textTracks: readTextTracks(),
|
|
113
|
+
activeCueText: readCueText()
|
|
114
|
+
});
|
|
115
|
+
if (typeof navigator !== "undefined" && "mediaSession" in navigator) navigator.mediaSession.playbackState = el.paused ? "paused" : "playing";
|
|
116
|
+
};
|
|
117
|
+
const MEDIA_SESSION_ACTIONS = [
|
|
118
|
+
"play",
|
|
119
|
+
"pause",
|
|
120
|
+
"seekbackward",
|
|
121
|
+
"seekforward",
|
|
122
|
+
"seekto"
|
|
123
|
+
];
|
|
124
|
+
const setupMediaSession = () => {
|
|
125
|
+
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
|
|
126
|
+
const ms = navigator.mediaSession;
|
|
127
|
+
const set = (a, h) => {
|
|
128
|
+
try {
|
|
129
|
+
ms.setActionHandler(a, h);
|
|
130
|
+
} catch {}
|
|
131
|
+
};
|
|
132
|
+
set("play", () => actions.play());
|
|
133
|
+
set("pause", () => actions.pause());
|
|
134
|
+
set("seekbackward", (d) => actions.seek(Math.max(0, state.currentTime - (d.seekOffset || 10))));
|
|
135
|
+
set("seekforward", (d) => actions.seek(state.currentTime + (d.seekOffset || 10)));
|
|
136
|
+
set("seekto", (d) => {
|
|
137
|
+
if (typeof d.seekTime === "number") actions.seek(d.seekTime);
|
|
138
|
+
});
|
|
139
|
+
setSessionMetadata(opts.metadata?.videoTitle ?? "Video");
|
|
140
|
+
};
|
|
141
|
+
const setSessionMetadata = (title) => {
|
|
142
|
+
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
|
|
143
|
+
if (typeof MediaMetadata === "undefined") return;
|
|
144
|
+
try {
|
|
145
|
+
navigator.mediaSession.metadata = new MediaMetadata({ title });
|
|
146
|
+
} catch {}
|
|
147
|
+
};
|
|
148
|
+
const teardownMediaSession = () => {
|
|
149
|
+
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
|
|
150
|
+
for (const a of MEDIA_SESSION_ACTIONS) try {
|
|
151
|
+
navigator.mediaSession.setActionHandler(a, null);
|
|
152
|
+
} catch {}
|
|
153
|
+
};
|
|
154
|
+
const MEDIA_EVENTS = [
|
|
155
|
+
"play",
|
|
156
|
+
"pause",
|
|
157
|
+
"timeupdate",
|
|
158
|
+
"durationchange",
|
|
159
|
+
"progress",
|
|
160
|
+
"volumechange",
|
|
161
|
+
"ratechange",
|
|
162
|
+
"seeking",
|
|
163
|
+
"seeked",
|
|
164
|
+
"ended",
|
|
165
|
+
"loadedmetadata",
|
|
166
|
+
"canplay",
|
|
167
|
+
"waiting",
|
|
168
|
+
"error"
|
|
169
|
+
];
|
|
170
|
+
const actions = {
|
|
171
|
+
play: () => {
|
|
172
|
+
el?.play?.();
|
|
173
|
+
},
|
|
174
|
+
pause: () => el?.pause(),
|
|
175
|
+
seek: (t) => {
|
|
176
|
+
if (el) el.currentTime = t;
|
|
177
|
+
},
|
|
178
|
+
setRate: (r) => {
|
|
179
|
+
desiredRate = r;
|
|
180
|
+
if (el) el.playbackRate = r;
|
|
181
|
+
patch({ rate: r });
|
|
182
|
+
},
|
|
183
|
+
setVolume: (v) => {
|
|
184
|
+
if (el) el.volume = Math.min(1, Math.max(0, v));
|
|
185
|
+
},
|
|
186
|
+
setMuted: (m) => {
|
|
187
|
+
if (el) el.muted = m;
|
|
188
|
+
},
|
|
189
|
+
setQuality: () => {},
|
|
190
|
+
setTextTrack: (id) => {
|
|
191
|
+
if (!el?.textTracks) return;
|
|
192
|
+
patch({ activeTextTrackId: id });
|
|
193
|
+
applyTextTrackModes();
|
|
194
|
+
},
|
|
195
|
+
enterFullscreen: (wrapper) => {
|
|
196
|
+
if (wrapper.requestFullscreen) wrapper.requestFullscreen();
|
|
197
|
+
else el?.webkitEnterFullscreen?.();
|
|
198
|
+
},
|
|
199
|
+
exitFullscreen: () => {
|
|
200
|
+
if (document.fullscreenElement) document.exitFullscreen?.();
|
|
201
|
+
else el?.webkitExitFullscreen?.();
|
|
202
|
+
},
|
|
203
|
+
enterPiP: () => {
|
|
204
|
+
el?.requestPictureInPicture?.();
|
|
205
|
+
},
|
|
206
|
+
exitPiP: () => {
|
|
207
|
+
document.exitPictureInPicture?.();
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
const appendTracks = () => {
|
|
211
|
+
if (!el) return;
|
|
212
|
+
let activeId = null;
|
|
213
|
+
tracks.forEach((t, i) => {
|
|
214
|
+
const node = document.createElement("track");
|
|
215
|
+
node.kind = t.kind ?? "subtitles";
|
|
216
|
+
node.src = t.src;
|
|
217
|
+
node.srclang = t.srclang;
|
|
218
|
+
node.label = t.label;
|
|
219
|
+
node.id = `kino-track-${i}`;
|
|
220
|
+
el.appendChild(node);
|
|
221
|
+
if (t.default && activeId == null) activeId = node.id;
|
|
222
|
+
});
|
|
223
|
+
if (activeId != null) state = {
|
|
224
|
+
...state,
|
|
225
|
+
activeTextTrackId: activeId
|
|
226
|
+
};
|
|
227
|
+
};
|
|
228
|
+
return {
|
|
229
|
+
mount(container) {
|
|
230
|
+
el = document.createElement("video");
|
|
231
|
+
el.src = opts.src;
|
|
232
|
+
el.setAttribute("playsinline", "");
|
|
233
|
+
el.playsInline = true;
|
|
234
|
+
el.preload = "metadata";
|
|
235
|
+
if (opts.poster) el.poster = opts.poster;
|
|
236
|
+
if (opts.crossOrigin) el.crossOrigin = opts.crossOrigin;
|
|
237
|
+
if (opts.muted) el.muted = true;
|
|
238
|
+
if (opts.loop) el.loop = true;
|
|
239
|
+
if (opts.autoPlay) el.autoplay = true;
|
|
240
|
+
el.playbackRate = desiredRate;
|
|
241
|
+
for (const ev of MEDIA_EVENTS) el.addEventListener(ev, syncFromEl);
|
|
242
|
+
document.addEventListener("fullscreenchange", onFullscreenChange);
|
|
243
|
+
el.addEventListener("webkitbeginfullscreen", onWebkitBeginFullscreen);
|
|
244
|
+
el.addEventListener("webkitendfullscreen", onWebkitEndFullscreen);
|
|
245
|
+
el.addEventListener("enterpictureinpicture", onEnterPip);
|
|
246
|
+
el.addEventListener("leavepictureinpicture", onLeavePip);
|
|
247
|
+
appendTracks();
|
|
248
|
+
container.appendChild(el);
|
|
249
|
+
const tt = el.textTracks;
|
|
250
|
+
if (tt && typeof tt.addEventListener === "function") {
|
|
251
|
+
tt.addEventListener("addtrack", onTextTracksChanged);
|
|
252
|
+
tt.addEventListener("removetrack", onTextTracksChanged);
|
|
253
|
+
tt.addEventListener("change", onTextTracksChanged);
|
|
254
|
+
}
|
|
255
|
+
applyTextTrackModes();
|
|
256
|
+
setupMediaSession();
|
|
257
|
+
},
|
|
258
|
+
swapSource(next) {
|
|
259
|
+
if (!el || next.src == null) return;
|
|
260
|
+
el.src = next.src;
|
|
261
|
+
if (next.poster != null) el.poster = next.poster;
|
|
262
|
+
if (next.metadata?.videoTitle != null) setSessionMetadata(next.metadata.videoTitle);
|
|
263
|
+
reload();
|
|
264
|
+
el.playbackRate = desiredRate;
|
|
265
|
+
patch({
|
|
266
|
+
currentTime: 0,
|
|
267
|
+
duration: 0,
|
|
268
|
+
ended: false,
|
|
269
|
+
seeking: false,
|
|
270
|
+
error: null
|
|
271
|
+
});
|
|
272
|
+
},
|
|
273
|
+
getState: () => state,
|
|
274
|
+
subscribe: (l) => {
|
|
275
|
+
listeners.add(l);
|
|
276
|
+
return () => listeners.delete(l);
|
|
277
|
+
},
|
|
278
|
+
actions,
|
|
279
|
+
destroy() {
|
|
280
|
+
document.removeEventListener("fullscreenchange", onFullscreenChange);
|
|
281
|
+
teardownMediaSession();
|
|
282
|
+
if (activeTrack) activeTrack.removeEventListener("cuechange", onCueChange);
|
|
283
|
+
activeTrack = null;
|
|
284
|
+
if (el) {
|
|
285
|
+
for (const ev of MEDIA_EVENTS) el.removeEventListener(ev, syncFromEl);
|
|
286
|
+
el.removeEventListener("webkitbeginfullscreen", onWebkitBeginFullscreen);
|
|
287
|
+
el.removeEventListener("webkitendfullscreen", onWebkitEndFullscreen);
|
|
288
|
+
el.removeEventListener("enterpictureinpicture", onEnterPip);
|
|
289
|
+
el.removeEventListener("leavepictureinpicture", onLeavePip);
|
|
290
|
+
const tt = el.textTracks;
|
|
291
|
+
if (tt && typeof tt.removeEventListener === "function") {
|
|
292
|
+
tt.removeEventListener("addtrack", onTextTracksChanged);
|
|
293
|
+
tt.removeEventListener("removetrack", onTextTracksChanged);
|
|
294
|
+
tt.removeEventListener("change", onTextTracksChanged);
|
|
295
|
+
}
|
|
296
|
+
el.removeAttribute("src");
|
|
297
|
+
reload();
|
|
298
|
+
el.remove();
|
|
299
|
+
}
|
|
300
|
+
el = null;
|
|
301
|
+
listeners.clear();
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
function onTextTracksChanged() {
|
|
305
|
+
applyTextTrackModes();
|
|
306
|
+
patch({
|
|
307
|
+
textTracks: readTextTracks(),
|
|
308
|
+
activeCueText: readCueText()
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
//#endregion
|
|
313
|
+
//#region src/native/native-player.tsx
|
|
314
|
+
/**
|
|
315
|
+
* kino's glass chrome over a plain HTML <video> element, playing a raw media
|
|
316
|
+
* URL (mp4, webm, ogg, …) directly. Use this when you have a file URL rather
|
|
317
|
+
* than a Mux playback id.
|
|
318
|
+
*
|
|
319
|
+
* Only `src`, `poster`, and `metadata.videoTitle` are reactive (they flow
|
|
320
|
+
* through `swapSource`). `tracks`, `crossOrigin`, `muted`, `loop`, and
|
|
321
|
+
* `defaultRate` are read once when the provider is created — changing them later
|
|
322
|
+
* is a no-op. Remount (e.g. via `key`) if you need them to change.
|
|
323
|
+
*/
|
|
324
|
+
function NativePlayer({ accentColor, theme, className, placeholder, children, ...opts }) {
|
|
325
|
+
const providerRef = useRef(null);
|
|
326
|
+
if (providerRef.current === null) providerRef.current = createNativeProvider(opts);
|
|
327
|
+
const provider = providerRef.current;
|
|
328
|
+
const mountedRef = useRef(false);
|
|
329
|
+
useEffect(() => {
|
|
330
|
+
if (!mountedRef.current) {
|
|
331
|
+
mountedRef.current = true;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
provider.swapSource?.({
|
|
335
|
+
src: opts.src,
|
|
336
|
+
poster: opts.poster,
|
|
337
|
+
metadata: opts.metadata
|
|
338
|
+
});
|
|
339
|
+
}, [
|
|
340
|
+
opts.src,
|
|
341
|
+
opts.poster,
|
|
342
|
+
opts.metadata?.videoTitle
|
|
343
|
+
]);
|
|
344
|
+
return /* @__PURE__ */ jsxs(Player, {
|
|
345
|
+
provider,
|
|
346
|
+
accentColor,
|
|
347
|
+
theme,
|
|
348
|
+
className,
|
|
349
|
+
placeholder,
|
|
350
|
+
children: [
|
|
351
|
+
/* @__PURE__ */ jsx(IdleOverlay, {}),
|
|
352
|
+
/* @__PURE__ */ jsx(Captions, {}),
|
|
353
|
+
/* @__PURE__ */ jsx(ControlBar, {}),
|
|
354
|
+
children
|
|
355
|
+
]
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
//#endregion
|
|
359
|
+
export { NativePlayer, createNativeProvider };
|
package/dist/styles.css
CHANGED
|
@@ -28,11 +28,21 @@
|
|
|
28
28
|
box-sizing: border-box;
|
|
29
29
|
}
|
|
30
30
|
.kino mux-video,
|
|
31
|
-
.kino video
|
|
31
|
+
.kino video,
|
|
32
|
+
/* The YouTube provider mounts an <iframe> here; size it like the video. */
|
|
33
|
+
.kino .kino-video-host iframe {
|
|
32
34
|
position: absolute;
|
|
33
35
|
inset: 0;
|
|
34
36
|
width: 100%;
|
|
35
37
|
height: 100%;
|
|
38
|
+
border: 0;
|
|
39
|
+
}
|
|
40
|
+
/* Let kino's gesture layer own every pointer interaction. Without this the
|
|
41
|
+
YouTube iframe surfaces its own hover chrome — title, "More videos", a second
|
|
42
|
+
progress bar — on top of kino's controls. Killing pointer events on the
|
|
43
|
+
iframe stops YouTube from ever seeing a hover, so only kino's UI shows. */
|
|
44
|
+
.kino .kino-video-host iframe {
|
|
45
|
+
pointer-events: none;
|
|
36
46
|
}
|
|
37
47
|
.kino .kino-placeholder {
|
|
38
48
|
position: absolute;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { a as Provider } from "./types-D0bLitH2.js";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
//#region src/youtube/provider.d.ts
|
|
5
|
+
type YouTubeProviderOptions = {
|
|
6
|
+
videoId: string;
|
|
7
|
+
metadata?: {
|
|
8
|
+
videoId?: string;
|
|
9
|
+
videoTitle?: string;
|
|
10
|
+
viewerUserId?: string;
|
|
11
|
+
};
|
|
12
|
+
autoPlay?: boolean;
|
|
13
|
+
muted?: boolean;
|
|
14
|
+
loop?: boolean;
|
|
15
|
+
defaultRate?: number;
|
|
16
|
+
};
|
|
17
|
+
declare function parseYouTubeId(input: string): string;
|
|
18
|
+
declare function createYouTubeProvider(opts: YouTubeProviderOptions): Provider;
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/youtube/youtube-player.d.ts
|
|
21
|
+
type YouTubePlayerProps = YouTubeProviderOptions & {
|
|
22
|
+
accentColor?: string;
|
|
23
|
+
theme?: Record<string, string>;
|
|
24
|
+
className?: string; /** Blur-up still painted behind the video until the first frame loads. */
|
|
25
|
+
placeholder?: string;
|
|
26
|
+
children?: ReactNode;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* kino's glass chrome over a YouTube video, backed by the YouTube IFrame Player
|
|
30
|
+
* API. Pass a video id or any watch / youtu.be / embed / shorts URL.
|
|
31
|
+
*
|
|
32
|
+
* Only `videoId` and `metadata.videoTitle` are reactive (they flow through
|
|
33
|
+
* `swapSource`). `autoPlay`, `muted`, `loop`, and `defaultRate` are read once
|
|
34
|
+
* when the provider is created — changing them later is a no-op. Remount (e.g.
|
|
35
|
+
* via `key`) if you need them to change.
|
|
36
|
+
*
|
|
37
|
+
* The IFrame API can't expose quality or picture-in-picture, so those controls
|
|
38
|
+
* hide themselves automatically. Captions work through the API and are rendered
|
|
39
|
+
* by YouTube inside the embed.
|
|
40
|
+
*
|
|
41
|
+
* Per YouTube's API terms, kino doesn't obscure the player: YouTube shows its
|
|
42
|
+
* own thumbnail, play button, title, and logo before playback and while paused.
|
|
43
|
+
* kino's controls sit alongside them.
|
|
44
|
+
*/
|
|
45
|
+
declare function YouTubePlayer({
|
|
46
|
+
accentColor,
|
|
47
|
+
theme,
|
|
48
|
+
className,
|
|
49
|
+
placeholder,
|
|
50
|
+
children,
|
|
51
|
+
...opts
|
|
52
|
+
}: YouTubePlayerProps): import("react").JSX.Element;
|
|
53
|
+
//#endregion
|
|
54
|
+
export { YouTubePlayer, type YouTubePlayerProps, type YouTubeProviderOptions, createYouTubeProvider, parseYouTubeId };
|
package/dist/youtube.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { c as Captions, d as Player, l as IdleOverlay, t as ControlBar } from "./control-bar-B-p1rMgm.js";
|
|
2
|
+
import { t as defaultState } from "./fake-provider-Do3fX4-T.js";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
//#region src/youtube/provider.ts
|
|
6
|
+
const PLAYING = 1;
|
|
7
|
+
const ENDED = 0;
|
|
8
|
+
const BUFFERING = 3;
|
|
9
|
+
const CAPTION_MODULES = ["captions", "cc"];
|
|
10
|
+
const IFRAME_API_SRC = "https://www.youtube.com/iframe_api";
|
|
11
|
+
function parseYouTubeId(input) {
|
|
12
|
+
const trimmed = input.trim();
|
|
13
|
+
if (/^[\w-]{11}$/.test(trimmed)) return trimmed;
|
|
14
|
+
const m = trimmed.match(/(?:youtu\.be\/|\/embed\/|\/shorts\/|[?&]v=)([\w-]{11})/);
|
|
15
|
+
return m ? m[1] : trimmed;
|
|
16
|
+
}
|
|
17
|
+
let apiPromise = null;
|
|
18
|
+
function loadYouTubeAPI() {
|
|
19
|
+
const w = window;
|
|
20
|
+
if (w.YT?.Player) return Promise.resolve(w.YT);
|
|
21
|
+
if (apiPromise) return apiPromise;
|
|
22
|
+
apiPromise = new Promise((resolve) => {
|
|
23
|
+
const prev = w.onYouTubeIframeAPIReady;
|
|
24
|
+
w.onYouTubeIframeAPIReady = () => {
|
|
25
|
+
prev?.();
|
|
26
|
+
if (w.YT) resolve(w.YT);
|
|
27
|
+
};
|
|
28
|
+
if (!document.querySelector(`script[src="${IFRAME_API_SRC}"]`)) {
|
|
29
|
+
const script = document.createElement("script");
|
|
30
|
+
script.src = IFRAME_API_SRC;
|
|
31
|
+
script.async = true;
|
|
32
|
+
document.head.appendChild(script);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
return apiPromise;
|
|
36
|
+
}
|
|
37
|
+
function readyYT() {
|
|
38
|
+
if (typeof window === "undefined") return null;
|
|
39
|
+
const yt = window.YT;
|
|
40
|
+
return yt && typeof yt.Player === "function" ? yt : null;
|
|
41
|
+
}
|
|
42
|
+
function createYouTubeProvider(opts) {
|
|
43
|
+
const initialId = parseYouTubeId(opts.videoId);
|
|
44
|
+
let player = null;
|
|
45
|
+
let destroyed = false;
|
|
46
|
+
let ready = false;
|
|
47
|
+
let ticker;
|
|
48
|
+
let desiredRate = opts.defaultRate ?? 1;
|
|
49
|
+
let captionModule = CAPTION_MODULES[0];
|
|
50
|
+
let captionsSig = "";
|
|
51
|
+
let state = {
|
|
52
|
+
...defaultState(),
|
|
53
|
+
rate: desiredRate,
|
|
54
|
+
muted: opts.muted ?? false,
|
|
55
|
+
capabilities: {
|
|
56
|
+
canSetQuality: false,
|
|
57
|
+
hasStoryboard: false,
|
|
58
|
+
canPiP: false,
|
|
59
|
+
canFullscreen: true,
|
|
60
|
+
canSetRate: true,
|
|
61
|
+
hasTextTracks: false
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
65
|
+
const emit = () => listeners.forEach((l) => l());
|
|
66
|
+
const patch = (p) => {
|
|
67
|
+
state = {
|
|
68
|
+
...state,
|
|
69
|
+
...p
|
|
70
|
+
};
|
|
71
|
+
emit();
|
|
72
|
+
};
|
|
73
|
+
const onFullscreenChange = () => patch({ fullscreen: document.fullscreenElement != null });
|
|
74
|
+
const syncFromPlayer = () => {
|
|
75
|
+
if (!player || !ready) return;
|
|
76
|
+
if (player.getPlaybackRate() !== desiredRate) player.setPlaybackRate(desiredRate);
|
|
77
|
+
const ps = player.getPlayerState();
|
|
78
|
+
const duration = player.getDuration() || 0;
|
|
79
|
+
const loaded = player.getVideoLoadedFraction() || 0;
|
|
80
|
+
patch({
|
|
81
|
+
paused: ps !== PLAYING && ps !== BUFFERING,
|
|
82
|
+
ended: ps === ENDED,
|
|
83
|
+
currentTime: player.getCurrentTime() || 0,
|
|
84
|
+
duration,
|
|
85
|
+
buffered: duration > 0 ? [[0, loaded * duration]] : [],
|
|
86
|
+
rate: desiredRate,
|
|
87
|
+
volume: (player.getVolume() || 0) / 100,
|
|
88
|
+
muted: player.isMuted(),
|
|
89
|
+
readyState: 4
|
|
90
|
+
});
|
|
91
|
+
syncCaptions();
|
|
92
|
+
};
|
|
93
|
+
const syncCaptions = () => {
|
|
94
|
+
if (!player) return;
|
|
95
|
+
let list = [];
|
|
96
|
+
for (const mod of CAPTION_MODULES) try {
|
|
97
|
+
const got = player.getOption(mod, "tracklist");
|
|
98
|
+
if (Array.isArray(got)) {
|
|
99
|
+
list = got;
|
|
100
|
+
captionModule = mod;
|
|
101
|
+
if (got.length) break;
|
|
102
|
+
}
|
|
103
|
+
} catch {}
|
|
104
|
+
const tracks = list.map((t) => ({
|
|
105
|
+
id: t.languageCode,
|
|
106
|
+
kind: "captions",
|
|
107
|
+
label: t.displayName || t.languageCode,
|
|
108
|
+
lang: t.languageCode,
|
|
109
|
+
mode: state.activeTextTrackId === t.languageCode ? "showing" : "disabled"
|
|
110
|
+
}));
|
|
111
|
+
const sig = tracks.map((t) => `${t.id}:${t.mode}`).join("|");
|
|
112
|
+
if (sig === captionsSig) return;
|
|
113
|
+
captionsSig = sig;
|
|
114
|
+
patch({
|
|
115
|
+
textTracks: tracks,
|
|
116
|
+
capabilities: {
|
|
117
|
+
...state.capabilities,
|
|
118
|
+
hasTextTracks: tracks.length > 0
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
const setSessionMetadata = (title) => {
|
|
123
|
+
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
|
|
124
|
+
if (typeof MediaMetadata === "undefined") return;
|
|
125
|
+
try {
|
|
126
|
+
navigator.mediaSession.metadata = new MediaMetadata({ title });
|
|
127
|
+
} catch {}
|
|
128
|
+
};
|
|
129
|
+
const startTicker = () => {
|
|
130
|
+
if (ticker == null) ticker = setInterval(syncFromPlayer, 250);
|
|
131
|
+
};
|
|
132
|
+
const actions = {
|
|
133
|
+
play: () => player?.playVideo(),
|
|
134
|
+
pause: () => player?.pauseVideo(),
|
|
135
|
+
seek: (t) => player?.seekTo(t, true),
|
|
136
|
+
setRate: (r) => {
|
|
137
|
+
desiredRate = r;
|
|
138
|
+
player?.setPlaybackRate(r);
|
|
139
|
+
patch({ rate: r });
|
|
140
|
+
},
|
|
141
|
+
setVolume: (v) => player?.setVolume(Math.min(100, Math.max(0, v * 100))),
|
|
142
|
+
setMuted: (m) => {
|
|
143
|
+
if (m) player?.mute();
|
|
144
|
+
else player?.unMute();
|
|
145
|
+
patch({ muted: m });
|
|
146
|
+
},
|
|
147
|
+
setQuality: () => {},
|
|
148
|
+
setTextTrack: (id) => {
|
|
149
|
+
patch({ activeTextTrackId: id });
|
|
150
|
+
try {
|
|
151
|
+
player?.setOption(captionModule, "track", id == null ? {} : { languageCode: id });
|
|
152
|
+
} catch {}
|
|
153
|
+
syncCaptions();
|
|
154
|
+
},
|
|
155
|
+
enterFullscreen: (wrapper) => {
|
|
156
|
+
if (wrapper.requestFullscreen) wrapper.requestFullscreen();
|
|
157
|
+
},
|
|
158
|
+
exitFullscreen: () => {
|
|
159
|
+
if (document.fullscreenElement) document.exitFullscreen?.();
|
|
160
|
+
},
|
|
161
|
+
enterPiP: () => {},
|
|
162
|
+
exitPiP: () => {}
|
|
163
|
+
};
|
|
164
|
+
const createPlayer = (yt, host) => {
|
|
165
|
+
player = new yt.Player(host, {
|
|
166
|
+
videoId: initialId,
|
|
167
|
+
playerVars: {
|
|
168
|
+
controls: 0,
|
|
169
|
+
playsinline: 1,
|
|
170
|
+
rel: 0,
|
|
171
|
+
modestbranding: 1,
|
|
172
|
+
disablekb: 1,
|
|
173
|
+
autoplay: opts.autoPlay ? 1 : 0,
|
|
174
|
+
mute: opts.muted ? 1 : 0,
|
|
175
|
+
loop: opts.loop ? 1 : 0,
|
|
176
|
+
...opts.loop ? { playlist: initialId } : {}
|
|
177
|
+
},
|
|
178
|
+
events: {
|
|
179
|
+
onReady: (e) => {
|
|
180
|
+
player = e.target;
|
|
181
|
+
ready = true;
|
|
182
|
+
player.setPlaybackRate(desiredRate);
|
|
183
|
+
if (opts.muted) player.mute();
|
|
184
|
+
for (const mod of CAPTION_MODULES) try {
|
|
185
|
+
player.loadModule(mod);
|
|
186
|
+
} catch {}
|
|
187
|
+
setSessionMetadata(opts.metadata?.videoTitle ?? "Video");
|
|
188
|
+
syncFromPlayer();
|
|
189
|
+
startTicker();
|
|
190
|
+
},
|
|
191
|
+
onStateChange: syncFromPlayer,
|
|
192
|
+
onError: (e) => patch({ error: {
|
|
193
|
+
code: e.data ?? 0,
|
|
194
|
+
message: "YouTube playback error"
|
|
195
|
+
} })
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
return {
|
|
200
|
+
mount(container) {
|
|
201
|
+
const host = document.createElement("div");
|
|
202
|
+
container.appendChild(host);
|
|
203
|
+
document.addEventListener("fullscreenchange", onFullscreenChange);
|
|
204
|
+
const yt = readyYT();
|
|
205
|
+
if (yt) createPlayer(yt, host);
|
|
206
|
+
else loadYouTubeAPI().then((loaded) => {
|
|
207
|
+
if (destroyed) return;
|
|
208
|
+
if (host.isConnected) createPlayer(loaded, host);
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
swapSource(next) {
|
|
212
|
+
if (!player || !ready || next.src == null) return;
|
|
213
|
+
player.loadVideoById(parseYouTubeId(next.src));
|
|
214
|
+
if (next.metadata?.videoTitle != null) setSessionMetadata(next.metadata.videoTitle);
|
|
215
|
+
player.setPlaybackRate(desiredRate);
|
|
216
|
+
patch({
|
|
217
|
+
currentTime: 0,
|
|
218
|
+
duration: 0,
|
|
219
|
+
ended: false,
|
|
220
|
+
seeking: false,
|
|
221
|
+
error: null
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
getState: () => state,
|
|
225
|
+
subscribe: (l) => {
|
|
226
|
+
listeners.add(l);
|
|
227
|
+
return () => listeners.delete(l);
|
|
228
|
+
},
|
|
229
|
+
actions,
|
|
230
|
+
destroy() {
|
|
231
|
+
destroyed = true;
|
|
232
|
+
ready = false;
|
|
233
|
+
document.removeEventListener("fullscreenchange", onFullscreenChange);
|
|
234
|
+
if (ticker != null) clearInterval(ticker);
|
|
235
|
+
ticker = void 0;
|
|
236
|
+
try {
|
|
237
|
+
player?.destroy();
|
|
238
|
+
} catch {}
|
|
239
|
+
player = null;
|
|
240
|
+
listeners.clear();
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/youtube/youtube-player.tsx
|
|
246
|
+
/**
|
|
247
|
+
* kino's glass chrome over a YouTube video, backed by the YouTube IFrame Player
|
|
248
|
+
* API. Pass a video id or any watch / youtu.be / embed / shorts URL.
|
|
249
|
+
*
|
|
250
|
+
* Only `videoId` and `metadata.videoTitle` are reactive (they flow through
|
|
251
|
+
* `swapSource`). `autoPlay`, `muted`, `loop`, and `defaultRate` are read once
|
|
252
|
+
* when the provider is created — changing them later is a no-op. Remount (e.g.
|
|
253
|
+
* via `key`) if you need them to change.
|
|
254
|
+
*
|
|
255
|
+
* The IFrame API can't expose quality or picture-in-picture, so those controls
|
|
256
|
+
* hide themselves automatically. Captions work through the API and are rendered
|
|
257
|
+
* by YouTube inside the embed.
|
|
258
|
+
*
|
|
259
|
+
* Per YouTube's API terms, kino doesn't obscure the player: YouTube shows its
|
|
260
|
+
* own thumbnail, play button, title, and logo before playback and while paused.
|
|
261
|
+
* kino's controls sit alongside them.
|
|
262
|
+
*/
|
|
263
|
+
function YouTubePlayer({ accentColor, theme, className, placeholder, children, ...opts }) {
|
|
264
|
+
const providerRef = useRef(null);
|
|
265
|
+
if (providerRef.current === null) providerRef.current = createYouTubeProvider(opts);
|
|
266
|
+
const provider = providerRef.current;
|
|
267
|
+
const mountedRef = useRef(false);
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
if (!mountedRef.current) {
|
|
270
|
+
mountedRef.current = true;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
provider.swapSource?.({
|
|
274
|
+
src: opts.videoId,
|
|
275
|
+
metadata: opts.metadata
|
|
276
|
+
});
|
|
277
|
+
}, [opts.videoId, opts.metadata?.videoTitle]);
|
|
278
|
+
return /* @__PURE__ */ jsxs(Player, {
|
|
279
|
+
provider,
|
|
280
|
+
accentColor,
|
|
281
|
+
theme,
|
|
282
|
+
className,
|
|
283
|
+
placeholder,
|
|
284
|
+
children: [
|
|
285
|
+
/* @__PURE__ */ jsx(IdleOverlay, {}),
|
|
286
|
+
/* @__PURE__ */ jsx(Captions, {}),
|
|
287
|
+
/* @__PURE__ */ jsx(ControlBar, {}),
|
|
288
|
+
children
|
|
289
|
+
]
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
//#endregion
|
|
293
|
+
export { YouTubePlayer, createYouTubeProvider, parseYouTubeId };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@karnstack/kino",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Themeable React video player with pluggable providers.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Karn",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"player",
|
|
18
18
|
"react",
|
|
19
19
|
"mux",
|
|
20
|
+
"youtube",
|
|
20
21
|
"hls",
|
|
21
22
|
"video-player"
|
|
22
23
|
],
|
|
@@ -40,6 +41,14 @@
|
|
|
40
41
|
"types": "./dist/mux.d.ts",
|
|
41
42
|
"import": "./dist/mux.js"
|
|
42
43
|
},
|
|
44
|
+
"./native": {
|
|
45
|
+
"types": "./dist/native.d.ts",
|
|
46
|
+
"import": "./dist/native.js"
|
|
47
|
+
},
|
|
48
|
+
"./youtube": {
|
|
49
|
+
"types": "./dist/youtube.d.ts",
|
|
50
|
+
"import": "./dist/youtube.js"
|
|
51
|
+
},
|
|
43
52
|
"./styles.css": "./dist/styles.css"
|
|
44
53
|
},
|
|
45
54
|
"peerDependencies": {
|
|
@@ -64,6 +73,7 @@
|
|
|
64
73
|
"prettier": "^3.3.0",
|
|
65
74
|
"react": "^19.2.0",
|
|
66
75
|
"react-dom": "^19.2.0",
|
|
76
|
+
"shiki": "^4.3.0",
|
|
67
77
|
"tailwindcss": "^4.3.1",
|
|
68
78
|
"tsdown": "^0.22.3",
|
|
69
79
|
"typescript": "^5.7.0",
|