@karnstack/kino 0.1.2 → 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 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, file, and Vimeo
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 };
@@ -807,6 +807,7 @@ function Scrubber() {
807
807
  className: "kino-scrubber",
808
808
  onPointerMove,
809
809
  onPointerLeave: () => setHover(null),
810
+ onPointerDown,
810
811
  children: [hover && /* @__PURE__ */ jsxs("div", {
811
812
  ref: previewRef,
812
813
  className: "kino-preview kino-glass",
@@ -834,7 +835,6 @@ function Scrubber() {
834
835
  "aria-valuemax": Math.floor(duration) || 0,
835
836
  "aria-valuenow": Math.floor(currentTime),
836
837
  "aria-valuetext": formatTime(currentTime),
837
- onPointerDown,
838
838
  children: [
839
839
  buffered.map(([s, e], i) => /* @__PURE__ */ jsx("div", {
840
840
  className: "kino-buffered",
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { _ as usePlayer, a as SkipBackButton, c as Captions, d as Player, f as useControlsVisible, g as useMediaSelector, h as PlayerContext, i as PlayPauseButton, l as IdleOverlay, m as useWrapperRef, n as FullscreenButton, o as SkipForwardButton, p as useIsCompact, r as PipButton, s as VolumeControl, t as ControlBar, u as Scrubber, v as usePlayerActions, y as formatTime } from "./control-bar-DrAeqaap.js";
1
+ import { _ as usePlayer, a as SkipBackButton, c as Captions, d as Player, f as useControlsVisible, g as useMediaSelector, h as PlayerContext, i as PlayPauseButton, l as IdleOverlay, m as useWrapperRef, n as FullscreenButton, o as SkipForwardButton, p as useIsCompact, r as PipButton, s as VolumeControl, t as ControlBar, u as Scrubber, v as usePlayerActions, y as formatTime } from "./control-bar-B-p1rMgm.js";
2
2
  export { Captions, ControlBar, FullscreenButton, IdleOverlay, PipButton, PlayPauseButton, Player, PlayerContext, Scrubber, SkipBackButton, SkipForwardButton, VolumeControl, formatTime, useControlsVisible, useIsCompact, useMediaSelector, usePlayer, usePlayerActions, useWrapperRef };
package/dist/mux.js CHANGED
@@ -1,88 +1,18 @@
1
- import { c as Captions, d as Player, l as IdleOverlay, t as ControlBar } from "./control-bar-DrAeqaap.js";
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;
@@ -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.1.2",
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",