@karnstack/kino 0.1.3 → 0.2.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 +40 -1
- package/dist/captions-BBk_DvTt.js +75 -0
- package/dist/mux.js +2 -72
- package/dist/native.d.ts +56 -0
- package/dist/native.js +358 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -67,6 +67,45 @@ 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
|
+
|
|
70
109
|
## Theming
|
|
71
110
|
|
|
72
111
|
The quickest knob is the `accentColor` prop, which drives the scrubber fill, active menu items, and range controls.
|
|
@@ -136,7 +175,7 @@ pnpm lint # eslint
|
|
|
136
175
|
|
|
137
176
|
## Roadmap
|
|
138
177
|
|
|
139
|
-
- More providers: YouTube
|
|
178
|
+
- More providers: YouTube and Vimeo
|
|
140
179
|
- AirPlay support
|
|
141
180
|
- Chapters
|
|
142
181
|
- Documented headless primitives for fully custom chrome
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
//#region src/util/platform.ts
|
|
37
|
+
function detectIOS(ua, maxTouchPoints = 0) {
|
|
38
|
+
if (/iPhone|iPad|iPod/.test(ua)) return true;
|
|
39
|
+
return /Macintosh/.test(ua) && maxTouchPoints > 1;
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/util/captions.ts
|
|
43
|
+
function joinCues(cues) {
|
|
44
|
+
if (!cues || cues.length === 0) return "";
|
|
45
|
+
const parts = [];
|
|
46
|
+
for (let i = 0; i < cues.length; i++) {
|
|
47
|
+
const c = cues[i];
|
|
48
|
+
if (c && typeof c.text === "string") parts.push(c.text);
|
|
49
|
+
}
|
|
50
|
+
return parts.join("\n").replace(/<[^>]+>/g, "").replace(/[^\S\n]+/g, " ").replace(/ *\n */g, "\n").replace(/\n{2,}/g, "\n").trim();
|
|
51
|
+
}
|
|
52
|
+
function activeCueText(tracks, now) {
|
|
53
|
+
if (!tracks) return "";
|
|
54
|
+
for (let i = 0; i < tracks.length; i++) {
|
|
55
|
+
const t = tracks[i];
|
|
56
|
+
if (!t) continue;
|
|
57
|
+
if (t.kind !== "subtitles" && t.kind !== "captions") continue;
|
|
58
|
+
if (t.mode === "disabled") continue;
|
|
59
|
+
const active = joinCues(t.activeCues);
|
|
60
|
+
if (active) return active;
|
|
61
|
+
const all = t.cues;
|
|
62
|
+
if (all && all.length) {
|
|
63
|
+
const hits = [];
|
|
64
|
+
for (let j = 0; j < all.length; j++) {
|
|
65
|
+
const c = all[j];
|
|
66
|
+
if (c && c.startTime <= now && now < c.endTime) hits.push(c);
|
|
67
|
+
}
|
|
68
|
+
const text = joinCues(hits);
|
|
69
|
+
if (text) return text;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
export { detectIOS as n, defaultState as r, activeCueText as t };
|
package/dist/mux.js
CHANGED
|
@@ -1,88 +1,18 @@
|
|
|
1
1
|
import { c as Captions, d as Player, l as IdleOverlay, t as ControlBar } from "./control-bar-B-p1rMgm.js";
|
|
2
|
+
import { n as detectIOS, r as defaultState, t as activeCueText } from "./captions-BBk_DvTt.js";
|
|
2
3
|
import { useEffect, useRef } from "react";
|
|
3
4
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
5
|
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
6
|
//#region src/mux/urls.ts
|
|
41
7
|
const IMAGE_HOST = "https://image.mux.com";
|
|
42
8
|
function buildImageUrl(playbackId, kind, token, ext = kind === "storyboard" ? "vtt" : "webp") {
|
|
43
9
|
const base = `${IMAGE_HOST}/${playbackId}/${kind}.${ext}`;
|
|
44
10
|
return token ? `${base}?token=${token}` : base;
|
|
45
11
|
}
|
|
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
12
|
//#endregion
|
|
83
13
|
//#region src/mux/provider.ts
|
|
84
14
|
function createMuxProvider(opts) {
|
|
85
|
-
const ios = typeof navigator !== "undefined" && detectIOS(navigator.userAgent);
|
|
15
|
+
const ios = typeof navigator !== "undefined" && detectIOS(navigator.userAgent, navigator.maxTouchPoints);
|
|
86
16
|
let el = null;
|
|
87
17
|
let renditions = null;
|
|
88
18
|
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,358 @@
|
|
|
1
|
+
import { c as Captions, d as Player, l as IdleOverlay, t as ControlBar } from "./control-bar-B-p1rMgm.js";
|
|
2
|
+
import { n as detectIOS, r as defaultState, t as activeCueText } from "./captions-BBk_DvTt.js";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
//#region src/native/provider.ts
|
|
6
|
+
function createNativeProvider(opts) {
|
|
7
|
+
const ios = typeof navigator !== "undefined" && detectIOS(navigator.userAgent, navigator.maxTouchPoints);
|
|
8
|
+
const tracks = opts.tracks ?? [];
|
|
9
|
+
let el = null;
|
|
10
|
+
let state = {
|
|
11
|
+
...defaultState(),
|
|
12
|
+
rate: opts.defaultRate ?? 1,
|
|
13
|
+
muted: opts.muted ?? false,
|
|
14
|
+
activeTextTrackId: null,
|
|
15
|
+
capabilities: {
|
|
16
|
+
canSetQuality: false,
|
|
17
|
+
hasStoryboard: false,
|
|
18
|
+
canPiP: typeof document !== "undefined" && "pictureInPictureEnabled" in document,
|
|
19
|
+
canFullscreen: true,
|
|
20
|
+
canSetRate: true,
|
|
21
|
+
hasTextTracks: tracks.length > 0
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
let desiredRate = opts.defaultRate ?? 1;
|
|
25
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
26
|
+
const emit = () => listeners.forEach((l) => l());
|
|
27
|
+
const patch = (p) => {
|
|
28
|
+
state = {
|
|
29
|
+
...state,
|
|
30
|
+
...p
|
|
31
|
+
};
|
|
32
|
+
emit();
|
|
33
|
+
};
|
|
34
|
+
const reload = () => {
|
|
35
|
+
try {
|
|
36
|
+
el?.load();
|
|
37
|
+
} catch {}
|
|
38
|
+
};
|
|
39
|
+
const onFullscreenChange = () => patch({ fullscreen: document.fullscreenElement != null });
|
|
40
|
+
const onWebkitBeginFullscreen = () => patch({ fullscreen: true });
|
|
41
|
+
const onWebkitEndFullscreen = () => patch({ fullscreen: false });
|
|
42
|
+
const onEnterPip = () => patch({ pip: true });
|
|
43
|
+
const onLeavePip = () => patch({ pip: false });
|
|
44
|
+
let activeTrack = null;
|
|
45
|
+
const readCueText = () => ios ? "" : activeCueText(el?.textTracks, el?.currentTime ?? 0);
|
|
46
|
+
const modeForActiveTrack = () => ios ? "showing" : "hidden";
|
|
47
|
+
const onCueChange = () => patch({ activeCueText: readCueText() });
|
|
48
|
+
const bindActiveTrack = (track) => {
|
|
49
|
+
if (activeTrack) activeTrack.removeEventListener("cuechange", onCueChange);
|
|
50
|
+
activeTrack = track;
|
|
51
|
+
if (activeTrack) activeTrack.addEventListener("cuechange", onCueChange);
|
|
52
|
+
patch({ activeCueText: readCueText() });
|
|
53
|
+
};
|
|
54
|
+
const applyTextTrackModes = () => {
|
|
55
|
+
const tt = el?.textTracks;
|
|
56
|
+
if (!tt) return;
|
|
57
|
+
const id = state.activeTextTrackId;
|
|
58
|
+
let next = null;
|
|
59
|
+
for (let i = 0; i < tt.length; i++) {
|
|
60
|
+
const t = tt[i];
|
|
61
|
+
if (!t) continue;
|
|
62
|
+
if (t.kind !== "subtitles" && t.kind !== "captions") continue;
|
|
63
|
+
if (id != null && (t.id || String(i)) === id) {
|
|
64
|
+
const mode = modeForActiveTrack();
|
|
65
|
+
if (t.mode !== mode) t.mode = mode;
|
|
66
|
+
next = t;
|
|
67
|
+
} else if (t.mode !== "disabled") t.mode = "disabled";
|
|
68
|
+
}
|
|
69
|
+
if (next !== activeTrack) bindActiveTrack(next);
|
|
70
|
+
};
|
|
71
|
+
const readTextTracks = () => {
|
|
72
|
+
const tt = el?.textTracks;
|
|
73
|
+
if (!tt) return [];
|
|
74
|
+
const out = [];
|
|
75
|
+
for (let i = 0; i < tt.length; i++) {
|
|
76
|
+
const t = tt[i];
|
|
77
|
+
if (!t) continue;
|
|
78
|
+
if (t.kind !== "subtitles" && t.kind !== "captions") continue;
|
|
79
|
+
out.push({
|
|
80
|
+
id: t.id || String(i),
|
|
81
|
+
kind: t.kind,
|
|
82
|
+
label: t.label,
|
|
83
|
+
lang: t.language,
|
|
84
|
+
mode: t.mode
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
};
|
|
89
|
+
const syncFromEl = () => {
|
|
90
|
+
if (!el) return;
|
|
91
|
+
if (el.playbackRate !== desiredRate) el.playbackRate = desiredRate;
|
|
92
|
+
applyTextTrackModes();
|
|
93
|
+
const ranges = [];
|
|
94
|
+
for (let i = 0; i < el.buffered.length; i++) ranges.push([el.buffered.start(i), el.buffered.end(i)]);
|
|
95
|
+
patch({
|
|
96
|
+
paused: el.paused,
|
|
97
|
+
currentTime: el.currentTime,
|
|
98
|
+
duration: el.duration || 0,
|
|
99
|
+
buffered: ranges,
|
|
100
|
+
rate: desiredRate,
|
|
101
|
+
volume: el.volume,
|
|
102
|
+
muted: el.muted,
|
|
103
|
+
readyState: el.readyState,
|
|
104
|
+
seeking: el.seeking,
|
|
105
|
+
ended: el.ended,
|
|
106
|
+
error: el.error ? {
|
|
107
|
+
code: el.error.code,
|
|
108
|
+
message: el.error.message
|
|
109
|
+
} : null,
|
|
110
|
+
videoHeight: el.videoHeight || 0,
|
|
111
|
+
textTracks: readTextTracks(),
|
|
112
|
+
activeCueText: readCueText()
|
|
113
|
+
});
|
|
114
|
+
if (typeof navigator !== "undefined" && "mediaSession" in navigator) navigator.mediaSession.playbackState = el.paused ? "paused" : "playing";
|
|
115
|
+
};
|
|
116
|
+
const MEDIA_SESSION_ACTIONS = [
|
|
117
|
+
"play",
|
|
118
|
+
"pause",
|
|
119
|
+
"seekbackward",
|
|
120
|
+
"seekforward",
|
|
121
|
+
"seekto"
|
|
122
|
+
];
|
|
123
|
+
const setupMediaSession = () => {
|
|
124
|
+
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
|
|
125
|
+
const ms = navigator.mediaSession;
|
|
126
|
+
const set = (a, h) => {
|
|
127
|
+
try {
|
|
128
|
+
ms.setActionHandler(a, h);
|
|
129
|
+
} catch {}
|
|
130
|
+
};
|
|
131
|
+
set("play", () => actions.play());
|
|
132
|
+
set("pause", () => actions.pause());
|
|
133
|
+
set("seekbackward", (d) => actions.seek(Math.max(0, state.currentTime - (d.seekOffset || 10))));
|
|
134
|
+
set("seekforward", (d) => actions.seek(state.currentTime + (d.seekOffset || 10)));
|
|
135
|
+
set("seekto", (d) => {
|
|
136
|
+
if (typeof d.seekTime === "number") actions.seek(d.seekTime);
|
|
137
|
+
});
|
|
138
|
+
setSessionMetadata(opts.metadata?.videoTitle ?? "Video");
|
|
139
|
+
};
|
|
140
|
+
const setSessionMetadata = (title) => {
|
|
141
|
+
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
|
|
142
|
+
if (typeof MediaMetadata === "undefined") return;
|
|
143
|
+
try {
|
|
144
|
+
navigator.mediaSession.metadata = new MediaMetadata({ title });
|
|
145
|
+
} catch {}
|
|
146
|
+
};
|
|
147
|
+
const teardownMediaSession = () => {
|
|
148
|
+
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
|
|
149
|
+
for (const a of MEDIA_SESSION_ACTIONS) try {
|
|
150
|
+
navigator.mediaSession.setActionHandler(a, null);
|
|
151
|
+
} catch {}
|
|
152
|
+
};
|
|
153
|
+
const MEDIA_EVENTS = [
|
|
154
|
+
"play",
|
|
155
|
+
"pause",
|
|
156
|
+
"timeupdate",
|
|
157
|
+
"durationchange",
|
|
158
|
+
"progress",
|
|
159
|
+
"volumechange",
|
|
160
|
+
"ratechange",
|
|
161
|
+
"seeking",
|
|
162
|
+
"seeked",
|
|
163
|
+
"ended",
|
|
164
|
+
"loadedmetadata",
|
|
165
|
+
"canplay",
|
|
166
|
+
"waiting",
|
|
167
|
+
"error"
|
|
168
|
+
];
|
|
169
|
+
const actions = {
|
|
170
|
+
play: () => {
|
|
171
|
+
el?.play?.();
|
|
172
|
+
},
|
|
173
|
+
pause: () => el?.pause(),
|
|
174
|
+
seek: (t) => {
|
|
175
|
+
if (el) el.currentTime = t;
|
|
176
|
+
},
|
|
177
|
+
setRate: (r) => {
|
|
178
|
+
desiredRate = r;
|
|
179
|
+
if (el) el.playbackRate = r;
|
|
180
|
+
patch({ rate: r });
|
|
181
|
+
},
|
|
182
|
+
setVolume: (v) => {
|
|
183
|
+
if (el) el.volume = Math.min(1, Math.max(0, v));
|
|
184
|
+
},
|
|
185
|
+
setMuted: (m) => {
|
|
186
|
+
if (el) el.muted = m;
|
|
187
|
+
},
|
|
188
|
+
setQuality: () => {},
|
|
189
|
+
setTextTrack: (id) => {
|
|
190
|
+
if (!el?.textTracks) return;
|
|
191
|
+
patch({ activeTextTrackId: id });
|
|
192
|
+
applyTextTrackModes();
|
|
193
|
+
},
|
|
194
|
+
enterFullscreen: (wrapper) => {
|
|
195
|
+
if (wrapper.requestFullscreen) wrapper.requestFullscreen();
|
|
196
|
+
else el?.webkitEnterFullscreen?.();
|
|
197
|
+
},
|
|
198
|
+
exitFullscreen: () => {
|
|
199
|
+
if (document.fullscreenElement) document.exitFullscreen?.();
|
|
200
|
+
else el?.webkitExitFullscreen?.();
|
|
201
|
+
},
|
|
202
|
+
enterPiP: () => {
|
|
203
|
+
el?.requestPictureInPicture?.();
|
|
204
|
+
},
|
|
205
|
+
exitPiP: () => {
|
|
206
|
+
document.exitPictureInPicture?.();
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const appendTracks = () => {
|
|
210
|
+
if (!el) return;
|
|
211
|
+
let activeId = null;
|
|
212
|
+
tracks.forEach((t, i) => {
|
|
213
|
+
const node = document.createElement("track");
|
|
214
|
+
node.kind = t.kind ?? "subtitles";
|
|
215
|
+
node.src = t.src;
|
|
216
|
+
node.srclang = t.srclang;
|
|
217
|
+
node.label = t.label;
|
|
218
|
+
node.id = `kino-track-${i}`;
|
|
219
|
+
el.appendChild(node);
|
|
220
|
+
if (t.default && activeId == null) activeId = node.id;
|
|
221
|
+
});
|
|
222
|
+
if (activeId != null) state = {
|
|
223
|
+
...state,
|
|
224
|
+
activeTextTrackId: activeId
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
return {
|
|
228
|
+
mount(container) {
|
|
229
|
+
el = document.createElement("video");
|
|
230
|
+
el.src = opts.src;
|
|
231
|
+
el.setAttribute("playsinline", "");
|
|
232
|
+
el.playsInline = true;
|
|
233
|
+
el.preload = "metadata";
|
|
234
|
+
if (opts.poster) el.poster = opts.poster;
|
|
235
|
+
if (opts.crossOrigin) el.crossOrigin = opts.crossOrigin;
|
|
236
|
+
if (opts.muted) el.muted = true;
|
|
237
|
+
if (opts.loop) el.loop = true;
|
|
238
|
+
if (opts.autoPlay) el.autoplay = true;
|
|
239
|
+
el.playbackRate = desiredRate;
|
|
240
|
+
for (const ev of MEDIA_EVENTS) el.addEventListener(ev, syncFromEl);
|
|
241
|
+
document.addEventListener("fullscreenchange", onFullscreenChange);
|
|
242
|
+
el.addEventListener("webkitbeginfullscreen", onWebkitBeginFullscreen);
|
|
243
|
+
el.addEventListener("webkitendfullscreen", onWebkitEndFullscreen);
|
|
244
|
+
el.addEventListener("enterpictureinpicture", onEnterPip);
|
|
245
|
+
el.addEventListener("leavepictureinpicture", onLeavePip);
|
|
246
|
+
appendTracks();
|
|
247
|
+
container.appendChild(el);
|
|
248
|
+
const tt = el.textTracks;
|
|
249
|
+
if (tt && typeof tt.addEventListener === "function") {
|
|
250
|
+
tt.addEventListener("addtrack", onTextTracksChanged);
|
|
251
|
+
tt.addEventListener("removetrack", onTextTracksChanged);
|
|
252
|
+
tt.addEventListener("change", onTextTracksChanged);
|
|
253
|
+
}
|
|
254
|
+
applyTextTrackModes();
|
|
255
|
+
setupMediaSession();
|
|
256
|
+
},
|
|
257
|
+
swapSource(next) {
|
|
258
|
+
if (!el || next.src == null) return;
|
|
259
|
+
el.src = next.src;
|
|
260
|
+
if (next.poster != null) el.poster = next.poster;
|
|
261
|
+
if (next.metadata?.videoTitle != null) setSessionMetadata(next.metadata.videoTitle);
|
|
262
|
+
reload();
|
|
263
|
+
el.playbackRate = desiredRate;
|
|
264
|
+
patch({
|
|
265
|
+
currentTime: 0,
|
|
266
|
+
duration: 0,
|
|
267
|
+
ended: false,
|
|
268
|
+
seeking: false,
|
|
269
|
+
error: null
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
getState: () => state,
|
|
273
|
+
subscribe: (l) => {
|
|
274
|
+
listeners.add(l);
|
|
275
|
+
return () => listeners.delete(l);
|
|
276
|
+
},
|
|
277
|
+
actions,
|
|
278
|
+
destroy() {
|
|
279
|
+
document.removeEventListener("fullscreenchange", onFullscreenChange);
|
|
280
|
+
teardownMediaSession();
|
|
281
|
+
if (activeTrack) activeTrack.removeEventListener("cuechange", onCueChange);
|
|
282
|
+
activeTrack = null;
|
|
283
|
+
if (el) {
|
|
284
|
+
for (const ev of MEDIA_EVENTS) el.removeEventListener(ev, syncFromEl);
|
|
285
|
+
el.removeEventListener("webkitbeginfullscreen", onWebkitBeginFullscreen);
|
|
286
|
+
el.removeEventListener("webkitendfullscreen", onWebkitEndFullscreen);
|
|
287
|
+
el.removeEventListener("enterpictureinpicture", onEnterPip);
|
|
288
|
+
el.removeEventListener("leavepictureinpicture", onLeavePip);
|
|
289
|
+
const tt = el.textTracks;
|
|
290
|
+
if (tt && typeof tt.removeEventListener === "function") {
|
|
291
|
+
tt.removeEventListener("addtrack", onTextTracksChanged);
|
|
292
|
+
tt.removeEventListener("removetrack", onTextTracksChanged);
|
|
293
|
+
tt.removeEventListener("change", onTextTracksChanged);
|
|
294
|
+
}
|
|
295
|
+
el.removeAttribute("src");
|
|
296
|
+
reload();
|
|
297
|
+
el.remove();
|
|
298
|
+
}
|
|
299
|
+
el = null;
|
|
300
|
+
listeners.clear();
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
function onTextTracksChanged() {
|
|
304
|
+
applyTextTrackModes();
|
|
305
|
+
patch({
|
|
306
|
+
textTracks: readTextTracks(),
|
|
307
|
+
activeCueText: readCueText()
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/native/native-player.tsx
|
|
313
|
+
/**
|
|
314
|
+
* kino's glass chrome over a plain HTML <video> element, playing a raw media
|
|
315
|
+
* URL (mp4, webm, ogg, …) directly. Use this when you have a file URL rather
|
|
316
|
+
* than a Mux playback id.
|
|
317
|
+
*
|
|
318
|
+
* Only `src`, `poster`, and `metadata.videoTitle` are reactive (they flow
|
|
319
|
+
* through `swapSource`). `tracks`, `crossOrigin`, `muted`, `loop`, and
|
|
320
|
+
* `defaultRate` are read once when the provider is created — changing them later
|
|
321
|
+
* is a no-op. Remount (e.g. via `key`) if you need them to change.
|
|
322
|
+
*/
|
|
323
|
+
function NativePlayer({ accentColor, theme, className, placeholder, children, ...opts }) {
|
|
324
|
+
const providerRef = useRef(null);
|
|
325
|
+
if (providerRef.current === null) providerRef.current = createNativeProvider(opts);
|
|
326
|
+
const provider = providerRef.current;
|
|
327
|
+
const mountedRef = useRef(false);
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
if (!mountedRef.current) {
|
|
330
|
+
mountedRef.current = true;
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
provider.swapSource?.({
|
|
334
|
+
src: opts.src,
|
|
335
|
+
poster: opts.poster,
|
|
336
|
+
metadata: opts.metadata
|
|
337
|
+
});
|
|
338
|
+
}, [
|
|
339
|
+
opts.src,
|
|
340
|
+
opts.poster,
|
|
341
|
+
opts.metadata?.videoTitle
|
|
342
|
+
]);
|
|
343
|
+
return /* @__PURE__ */ jsxs(Player, {
|
|
344
|
+
provider,
|
|
345
|
+
accentColor,
|
|
346
|
+
theme,
|
|
347
|
+
className,
|
|
348
|
+
placeholder,
|
|
349
|
+
children: [
|
|
350
|
+
/* @__PURE__ */ jsx(IdleOverlay, {}),
|
|
351
|
+
/* @__PURE__ */ jsx(Captions, {}),
|
|
352
|
+
/* @__PURE__ */ jsx(ControlBar, {}),
|
|
353
|
+
children
|
|
354
|
+
]
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
//#endregion
|
|
358
|
+
export { NativePlayer, createNativeProvider };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@karnstack/kino",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Themeable React video player with pluggable providers.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Karn",
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
"types": "./dist/mux.d.ts",
|
|
41
41
|
"import": "./dist/mux.js"
|
|
42
42
|
},
|
|
43
|
+
"./native": {
|
|
44
|
+
"types": "./dist/native.d.ts",
|
|
45
|
+
"import": "./dist/native.js"
|
|
46
|
+
},
|
|
43
47
|
"./styles.css": "./dist/styles.css"
|
|
44
48
|
},
|
|
45
49
|
"peerDependencies": {
|
|
@@ -64,6 +68,7 @@
|
|
|
64
68
|
"prettier": "^3.3.0",
|
|
65
69
|
"react": "^19.2.0",
|
|
66
70
|
"react-dom": "^19.2.0",
|
|
71
|
+
"shiki": "^4.3.0",
|
|
67
72
|
"tailwindcss": "^4.3.1",
|
|
68
73
|
"tsdown": "^0.22.3",
|
|
69
74
|
"typescript": "^5.7.0",
|