@f8team/reel-core 1.0.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.
@@ -0,0 +1,846 @@
1
+ /**
2
+ * Read-only view of the reactive store, handed to plugins via `PluginHost`.
3
+ * Plugins observe state but never mutate it directly.
4
+ */
5
+ interface ReadableStore<T> {
6
+ /** Snapshot read. */
7
+ getState(): T;
8
+ /** Subscribe to a slice. The listener fires only when the slice changes (===). */
9
+ subscribe<U>(selector: (state: T) => U, listener: (value: U) => void): () => void;
10
+ }
11
+ /**
12
+ * Internal mutable store. Adapters never see this; only `createPlayer` does.
13
+ *
14
+ * @internal
15
+ */
16
+ interface MutableStore<T> extends ReadableStore<T> {
17
+ /**
18
+ * Apply a partial update. Updates are coalesced inside one microtask, so
19
+ * multiple `setState` calls within the same tick fire one round of
20
+ * subscribers.
21
+ */
22
+ setState(patch: Partial<T> | ((current: T) => Partial<T>)): void;
23
+ /** Drop every subscriber. */
24
+ dispose(): void;
25
+ }
26
+
27
+ /**
28
+ * Reactive store with microtask-batched updates and selector subscriptions.
29
+ *
30
+ * Design goals:
31
+ * - **Tiny**: ~30 lines of logic, no external runtime.
32
+ * - **Predictable**: subscribers fire synchronously after the microtask flush.
33
+ * Multiple `setState` calls in the same tick fire one round of subscribers.
34
+ * - **Selector-aware**: a subscriber registered with a selector only re-runs
35
+ * when its selected slice changes (`Object.is` equality by default).
36
+ */
37
+
38
+ /**
39
+ * Optional knobs handed to `createStore`.
40
+ *
41
+ * The generic `T` is unused at the type level today, but it locks the option
42
+ * shape to a specific store so future per-state hooks (e.g. `selectorOf`)
43
+ * stay backwards-compatible.
44
+ */
45
+ interface CreateStoreOptions<_T> {
46
+ /**
47
+ * Override the global microtask scheduler (used by tests so the store
48
+ * can flush synchronously). Default: `queueMicrotask`.
49
+ */
50
+ scheduleFlush?: (run: () => void) => void;
51
+ /** Custom equality at the store level, used by `subscribe` if none is passed. */
52
+ isEqual?: <U>(a: U, b: U) => boolean;
53
+ }
54
+
55
+ /**
56
+ * Player error contract. Surfaced through the `error` event and `state.error`.
57
+ *
58
+ * Golden cases: G13 (unauthorized HLS), every error path in G1..G16.
59
+ */
60
+ /** Stable error codes. The string literal IS the public contract. */
61
+ type PlayerErrorCode = "source" | "network" | "decode" | "unauthorized" | "unsupported" | "internal";
62
+ /**
63
+ * Structured player error. Adapters render the message; plugins can inspect
64
+ * `code`, `status`, and `retryable` to decide on retry/UI strategy.
65
+ */
66
+ interface PlayerError {
67
+ /** Stable code. */
68
+ code: PlayerErrorCode;
69
+ /** Human-readable message. Adapters MAY localize. */
70
+ message: string;
71
+ /** Optional underlying cause (`Error`, `MediaError`, `Hls.errors.*`). */
72
+ cause?: unknown;
73
+ /** HTTP status, when the error came from an XHR (G13). */
74
+ status?: number;
75
+ /** Failing URL, when known. */
76
+ url?: string;
77
+ /** Whether the consumer can usefully retry (e.g. transient network). */
78
+ retryable: boolean;
79
+ }
80
+
81
+ /**
82
+ * Media source descriptors and related contracts.
83
+ *
84
+ * Every adapter and plugin compiles against the public types declared here.
85
+ * @see {@link ../../docs/spec/api-contract.md} for the frozen contract.
86
+ */
87
+ /**
88
+ * Subtitle / captions track attached to the underlying `<video>`.
89
+ *
90
+ * Maps to a `<track kind="subtitles">` child. Default-language priority is
91
+ * implemented in the `subtitles` plugin (see G1, G15).
92
+ *
93
+ * Golden cases: G1 (course learning), G15 (VTT cross-origin).
94
+ */
95
+ interface SubtitleTrack {
96
+ /** VTT URL. The BE must respond with `Access-Control-Allow-Origin: *` (G15). */
97
+ src: string;
98
+ /** BCP-47 language code (e.g. `"vi"`, `"en-US"`). */
99
+ srcLang: string;
100
+ /** Human-readable label (e.g. `"Tiếng Việt"`). */
101
+ label: string;
102
+ /** When `true`, this track wins the default-selection vote unconditionally. */
103
+ default?: boolean;
104
+ }
105
+ /**
106
+ * Sprite thumbnails (timeline hover preview) descriptor.
107
+ *
108
+ * The `src` URL must point to a WebVTT file whose cues map a time range to a
109
+ * sprite tile, e.g.:
110
+ *
111
+ * ```
112
+ * WEBVTT
113
+ *
114
+ * 00:00:00.000 --> 00:00:10.000
115
+ * sprites/00001.jpg#xywh=0,0,160,90
116
+ * ```
117
+ *
118
+ * Image URLs in the cue body may be absolute or relative; relative paths are
119
+ * resolved against the VTT URL. The `@f8team/reel-plugin-thumbnails` plugin
120
+ * fetches and parses the VTT, then exposes a `thumbnails:getAt(time)` command
121
+ * consumed by the React/Lit `SeekBar` hover preview.
122
+ */
123
+ interface ThumbnailsDescriptor {
124
+ /** Absolute VTT URL describing sprite cues. */
125
+ src: string;
126
+ /**
127
+ * Send credentials when fetching the VTT (and resolved sprite images
128
+ * when the browser permits). Defaults to `false`.
129
+ */
130
+ withCredentials?: boolean;
131
+ }
132
+ /**
133
+ * The kind of source. `auto` lets the registry pick.
134
+ *
135
+ * - `hls` — HLS m3u8 manifest, dispatched to `hls.js` (or native on Safari).
136
+ * - `mp4` — Progressive MP4/WebM, dispatched to the native engine.
137
+ * - `youtube` — YouTube IFrame API.
138
+ * - `dash` — MPEG-DASH (reserved for a future dash provider).
139
+ * - `native` — Force the native engine even if the URL looks like HLS (G4 Safari escape).
140
+ * - `auto` — Detect from the URL via `looksLikeHls` / `detectSourceType`.
141
+ */
142
+ type SourceType = "auto" | "hls" | "mp4" | "youtube" | "dash" | "native";
143
+ /**
144
+ * Single playable source. The registry picks the right provider based on
145
+ * `type` and the URL.
146
+ *
147
+ * Golden cases: G1, G2, G3, G4, G5, G7, G10, G11, G14.
148
+ */
149
+ interface SourceDescriptor {
150
+ /** Absolute or relative URL. May be a `blob:`, `data:`, or `http(s):` URL. */
151
+ src: string;
152
+ /** Optional explicit type hint. Defaults to `"auto"`. */
153
+ type?: SourceType;
154
+ /**
155
+ * Cookie / credentials policy for HLS XHRs.
156
+ *
157
+ * - `boolean` — sent for every request.
158
+ * - `(url) => boolean` — predicate (e.g. allowlist `https://api-gateway*`, see G7, G13).
159
+ */
160
+ withCredentials?: boolean | ((url: string) => boolean);
161
+ /** Subtitle tracks to attach (G1, G15). */
162
+ tracks?: SubtitleTrack[];
163
+ /**
164
+ * Sprite-thumbnails VTT descriptor for timeline hover previews. When set,
165
+ * the `@f8team/reel-plugin-thumbnails` plugin fetches and parses the VTT;
166
+ * the `Controls.SeekBar` (React) and the Lit default chrome render a tile
167
+ * tooltip during pointer hover.
168
+ */
169
+ thumbnails?: ThumbnailsDescriptor;
170
+ }
171
+ /**
172
+ * One quality variant exposed by the engine (HLS levels, DASH representations).
173
+ *
174
+ * Golden case: G7 (course video lesson editor).
175
+ */
176
+ interface QualityLevel {
177
+ /** Stable identifier produced by the loader (e.g. HLS level index). */
178
+ id: string;
179
+ /** Vertical resolution in pixels (e.g. `720`). */
180
+ height: number;
181
+ /** Average bitrate in bits per second. */
182
+ bitrate: number;
183
+ /** Display label (e.g. `"720p"`, `"Auto"`). */
184
+ label: string;
185
+ }
186
+ /**
187
+ * Imperative loader returned by a `SourceProvider`. The core attaches a single
188
+ * loader at a time; switching sources detaches the previous loader before
189
+ * attaching the new one.
190
+ */
191
+ interface SourceLoader {
192
+ /** Attach the source to the underlying `<video>`. Resolves on `loadedmetadata`. */
193
+ attach(video: HTMLVideoElement, source: SourceDescriptor): Promise<void>;
194
+ /** Detach the source. Called before the next `attach` and on `dispose`. */
195
+ detach(): void;
196
+ /**
197
+ * Optional: cancel any in-flight network work without tearing down the
198
+ * already-attached media element. The core calls this on rapid
199
+ * `setSource(A) → setSource(B)` transitions so the pending A loader can
200
+ * stop firing callbacks before `detach()` executes.
201
+ *
202
+ * Implementations should:
203
+ * - Abort outstanding XHRs / `fetch` requests.
204
+ * - Reject the pending `attach()` Promise with an `AbortError`.
205
+ * - Remove any listeners whose only purpose is to resolve that Promise.
206
+ *
207
+ * `abort()` MUST be safe to call before `detach()`, after `detach()`, and
208
+ * multiple times. Leave actual media-element / runtime cleanup to `detach()`.
209
+ */
210
+ abort?(): void;
211
+ /** Optional: enumerate available quality levels (HLS, DASH). */
212
+ getQualities?(): QualityLevel[];
213
+ /** Optional: switch quality. `"auto"` re-enables ABR. */
214
+ setQuality?(level: QualityLevel | "auto"): void;
215
+ }
216
+ /**
217
+ * Provider that knows how to handle a class of sources (HLS, native, YouTube).
218
+ */
219
+ interface SourceProvider {
220
+ /** Identifier used by `unregisterSource` and debug output. */
221
+ name: string;
222
+ /**
223
+ * Decide whether this provider can handle the given source.
224
+ *
225
+ * - `true` — definite match.
226
+ * - `"maybe"` — handle if no `true` match exists (used by the native fallback).
227
+ * - `false` — skip.
228
+ */
229
+ canHandle(source: SourceDescriptor): boolean | "maybe";
230
+ /** Construct a fresh loader. Called once per source attach. */
231
+ createLoader(): SourceLoader;
232
+ }
233
+
234
+ /**
235
+ * `unauthorized` event payload. Surfaced once per `source.src` for HLS XHRs
236
+ * that end with HTTP 401 or 403 (G13).
237
+ */
238
+ interface UnauthorizedEvent {
239
+ /** Failing URL (manifest or segment). */
240
+ url: string;
241
+ /** Always 401 or 403. */
242
+ status: 401 | 403;
243
+ /** The source that triggered the failure. */
244
+ source: SourceDescriptor;
245
+ }
246
+ /**
247
+ * Built-in player events. Plugin events use the `${string}:${string}` form
248
+ * (e.g. `subtitles:change`, `markers:hover`); they pass through the bus
249
+ * untyped because plugins are loaded dynamically.
250
+ *
251
+ * Golden cases:
252
+ * - G1: `timeupdate` powers the 500 ms `onProgress` aggregation.
253
+ * - G2: `play`, `pause`, `ended` drive story progress segments.
254
+ * - G7: `qualitychange` fires when the lesson editor flips levels.
255
+ * - G13: `unauthorized` triggers the auth callback.
256
+ */
257
+ interface PlayerEvents {
258
+ /** Source loaded; metadata and duration are now valid. */
259
+ ready: {
260
+ duration: number;
261
+ };
262
+ /** Playback started (or resumed). */
263
+ play: void;
264
+ /** Playback paused (user, programmatic, or background). */
265
+ pause: void;
266
+ /** Media ended. */
267
+ ended: void;
268
+ /**
269
+ * Time progress. Fires on every native `timeupdate`; plugins can throttle.
270
+ *
271
+ * - `currentTime` — wall-clock playback position.
272
+ * - `playedSeconds` — alias kept for parity with the legacy F8 player.
273
+ * - `duration` — current duration (matches `state.duration`).
274
+ */
275
+ timeupdate: {
276
+ currentTime: number;
277
+ playedSeconds: number;
278
+ duration: number;
279
+ };
280
+ /** Duration changed (live HLS, source switch). */
281
+ durationchange: {
282
+ duration: number;
283
+ };
284
+ /** Playback rate changed (G7). */
285
+ ratechange: {
286
+ playbackRate: number;
287
+ };
288
+ /** Volume or mute flipped. */
289
+ volumechange: {
290
+ volume: number;
291
+ muted: boolean;
292
+ };
293
+ /** Seek started (user or programmatic). */
294
+ seeking: {
295
+ time: number;
296
+ };
297
+ /** Seek finished. */
298
+ seeked: {
299
+ time: number;
300
+ };
301
+ /** Buffering toggled. `isBuffering=true` means stalled (`waiting`), including after a seek scrub. */
302
+ buffering: {
303
+ isBuffering: boolean;
304
+ };
305
+ /** Quality level changed. `auto=true` means ABR picked it. */
306
+ qualitychange: {
307
+ quality: QualityLevel | null;
308
+ auto: boolean;
309
+ };
310
+ /**
311
+ * User explicitly chose a rendition via `hls:setQuality` (manual height or
312
+ * auto when `qualityHeight` is `null`). Does **not** fire on source attach or
313
+ * ABR-driven switches — safe for persisting preferences without clobbering.
314
+ */
315
+ qualitypreference: {
316
+ qualityHeight: number | null;
317
+ };
318
+ /**
319
+ * Manual HLS rendition switch in flight (manual level or AUTO). Clears once
320
+ * hls.js reports `LEVEL_SWITCHED` for the awaited target / auto step, or after
321
+ * a safety timeout. Use for a transient center spinner.
322
+ */
323
+ qualityswitch: {
324
+ active: boolean;
325
+ };
326
+ /** Any error (transport, decode, source). */
327
+ error: PlayerError;
328
+ /** Authentication failure (G13). Fires at most once per source.src. */
329
+ unauthorized: UnauthorizedEvent;
330
+ /** Fullscreen toggled. */
331
+ fullscreenchange: {
332
+ fullscreen: boolean;
333
+ };
334
+ /** Picture-in-Picture toggled. */
335
+ pipchange: {
336
+ pip: boolean;
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Lifecycle status of the player. The state machine guarantees these
342
+ * transitions; see `docs/spec/architecture.md` for the transition diagram.
343
+ */
344
+ type PlayerStatus = "idle" | "loading" | "ready" | "playing" | "paused" | "ended" | "error";
345
+ /** Buffered range (start/end in seconds). */
346
+ interface BufferedRange {
347
+ start: number;
348
+ end: number;
349
+ }
350
+ /**
351
+ * Snapshot of the player's reactive state. Every adapter renders against
352
+ * either the snapshot (`getState()`) or a memoized selector via `subscribe`.
353
+ *
354
+ * Golden cases: G7 (qualities, playback rate), G8 (currentTime/duration drives
355
+ * seek bar), G13 (error.code === "unauthorized"), G14 (videoWidth/Height
356
+ * confirm playback started inline on iOS).
357
+ */
358
+ interface PlayerState {
359
+ /** Lifecycle status; never overlapping. */
360
+ status: PlayerStatus;
361
+ /** Active source. `null` until `setSource` is called. */
362
+ source: SourceDescriptor | null;
363
+ /** Current playback position in seconds. */
364
+ currentTime: number;
365
+ /** Media duration in seconds. `0` while loading; `Infinity` for live HLS. */
366
+ duration: number;
367
+ /** Buffered ranges in seconds. */
368
+ buffered: BufferedRange[];
369
+ /** Playback rate (`1` is normal speed). G7 lists [0.25..1.75]. */
370
+ playbackRate: number;
371
+ /** Volume on the `[0, 1]` scale. */
372
+ volume: number;
373
+ /** Whether the underlying `<video>` is muted. */
374
+ muted: boolean;
375
+ /** Native video width (after `loadedmetadata`). */
376
+ videoWidth: number;
377
+ /** Native video height (after `loadedmetadata`). */
378
+ videoHeight: number;
379
+ /** Whether Picture-in-Picture is currently active. */
380
+ pip: boolean;
381
+ /** Whether the document/element is in fullscreen. */
382
+ fullscreen: boolean;
383
+ /** Quality levels exposed by the loader (G7). Empty for native engines. */
384
+ qualities: QualityLevel[];
385
+ /** Active quality level. `null` while ABR is auto / loader does not expose levels. */
386
+ activeQuality: QualityLevel | null;
387
+ /** Latest error, if any. Cleared on `setSource` or successful retry. */
388
+ error: PlayerError | null;
389
+ }
390
+
391
+ /**
392
+ * Disposer returned by every subscription / listener helper. Idempotent.
393
+ */
394
+ type Disposer = () => void;
395
+ /**
396
+ * Public Player surface. Returned by `createPlayer(options)`.
397
+ *
398
+ * Lifecycle: `createPlayer` → `attach(<video>)` → use → `detach` (optional)
399
+ * → `dispose`. After `dispose`, every subsequent call is a no-op (or throws,
400
+ * for clearly invalid use).
401
+ *
402
+ * Golden cases:
403
+ * - G12: `play / pause / paused / seekTo` shape-conform with `VideoPlayerHandle`.
404
+ * - G7: `setPlaybackRate`, `setVolume`, `setMuted`.
405
+ * - G8: `seekTo` accepts seconds; the React adapter additionally accepts `"00:01:23"`.
406
+ */
407
+ interface Player {
408
+ /** Replace (or clear) the active source. */
409
+ setSource(source: SourceDescriptor | null): void;
410
+ /** Snapshot of the active source. */
411
+ getSource(): SourceDescriptor | null;
412
+ /**
413
+ * Re-attempt loading the current source. No-op if no source is set.
414
+ *
415
+ * Internally equivalent to `setSource(getSource())`: the state machine
416
+ * detaches the failed loader, clears the error, and starts a fresh
417
+ * `loading → ready` cycle. Use this from a "Try again" button on an
418
+ * error overlay.
419
+ *
420
+ * @returns `true` when retry was dispatched, `false` when there was
421
+ * nothing to retry.
422
+ */
423
+ retry(): boolean;
424
+ /** Start playback. May reject (autoplay policy, no source, dispose). */
425
+ play(): Promise<void>;
426
+ /** Pause playback. */
427
+ pause(): void;
428
+ /** Whether the player is paused. */
429
+ paused(): boolean;
430
+ /** Seek to a time in seconds. Clamped to `[0, duration]`. */
431
+ seekTo(seconds: number): void;
432
+ /** Set playback rate. */
433
+ setPlaybackRate(rate: number): void;
434
+ /** Set volume on `[0, 1]`. */
435
+ setVolume(volume: number): void;
436
+ /** Set muted state. */
437
+ setMuted(muted: boolean): void;
438
+ /** Snapshot read of the full state. */
439
+ getState(): PlayerState;
440
+ /** Current playback position in seconds. */
441
+ getCurrentTime(): number;
442
+ /** Current duration in seconds. */
443
+ getDuration(): number;
444
+ /** Buffered ranges. */
445
+ getBuffered(): BufferedRange[];
446
+ /**
447
+ * Subscribe to a built-in event. Returns a disposer.
448
+ *
449
+ * Plugin events (`subtitles:change`, etc.) ride the same bus but lose
450
+ * type-safety because they are loaded dynamically. Plugins augment via
451
+ * declaration-merging if they need typed events.
452
+ */
453
+ on<K extends keyof PlayerEvents>(event: K, handler: (payload: PlayerEvents[K]) => void): Disposer;
454
+ /** Remove an event handler. */
455
+ off<K extends keyof PlayerEvents>(event: K, handler: (payload: PlayerEvents[K]) => void): void;
456
+ /** Subscribe to a slice of state. The listener fires only when the slice changes (===). */
457
+ subscribe<T>(selector: (state: PlayerState) => T, listener: (value: T) => void): Disposer;
458
+ /** Register a plugin after construction. */
459
+ use(plugin: PluginInstance): void;
460
+ /** Remove a plugin by name. Calls its teardown. */
461
+ removePlugin(name: string): void;
462
+ /** Shared command registry. */
463
+ commands: CommandRegistry;
464
+ /** Attach to a `<video>` element. Resolves once `loadedmetadata` fires. */
465
+ attach(el: HTMLVideoElement): Promise<void>;
466
+ /** Detach from the current `<video>` without disposing the player. */
467
+ detach(): void;
468
+ /** Dispose every resource. After this, the player is dead. */
469
+ dispose(): void;
470
+ }
471
+
472
+ /**
473
+ * Command registry shared between the player and plugins. Commands are the
474
+ * extension point for keyboard handlers, plugin contributions, and UI buttons.
475
+ */
476
+ interface CommandRegistry {
477
+ /**
478
+ * Register a command. Returns a disposer.
479
+ *
480
+ * Commands MUST be namespaced with a `<plugin>:<verb>` form
481
+ * (e.g. `markers:seek`). Built-in commands use the `core:` prefix.
482
+ *
483
+ * Re-registering an identical name throws.
484
+ */
485
+ add<P = void>(name: string, handler: (payload: P) => void): () => void;
486
+ /** Run a command. No-op if the command does not exist (idempotent). */
487
+ run<P = void>(name: string, payload?: P): void;
488
+ /** Whether the command exists. */
489
+ has(name: string): boolean;
490
+ }
491
+ /**
492
+ * Plugin host handed to each plugin's `setup` function. Provides the only
493
+ * sanctioned ways to extend the player (controls, commands, store, events).
494
+ */
495
+ interface PluginHost {
496
+ controls: {
497
+ /**
498
+ * Contribute a UI fragment to a slot owned by an adapter (e.g. `seekbar.overlay`,
499
+ * `menu.settings`). Adapters render the returned `Element` directly.
500
+ *
501
+ * Returns a disposer. Plugins SHOULD store the disposer and call it on
502
+ * cleanup; the player calls every disposer on `dispose`.
503
+ */
504
+ contribute(slot: string, render: () => Element): () => void;
505
+ };
506
+ /** Shared command registry. Plugins MUST namespace their commands. */
507
+ commands: CommandRegistry;
508
+ /** Read-only state. */
509
+ store: ReadableStore<PlayerState>;
510
+ /** Emit a custom plugin event. Use the `${plugin}:${verb}` namespace. */
511
+ emit<K extends string>(event: K, payload?: unknown): void;
512
+ }
513
+ /**
514
+ * Plugin instance. The factory pattern (`subtitles({ defaultLang })`) returns
515
+ * an instance.
516
+ */
517
+ interface PluginInstance {
518
+ /** Unique name. Re-registering the same name throws. */
519
+ readonly name: string;
520
+ /**
521
+ * Setup hook. Called once when the plugin is registered (either via
522
+ * `PlayerOptions.plugins` or `player.use(...)`).
523
+ *
524
+ * Optional return value: a teardown function called on `removePlugin` or
525
+ * `dispose`.
526
+ */
527
+ setup(player: Player, host: PluginHost): void | (() => void);
528
+ }
529
+
530
+ /**
531
+ * Theme tokens. Every visible token is a CSS custom property that adapters
532
+ * inject via `style` or scoped CSS. Phase 1 ships the type only; Phase 1.K
533
+ * defines the runtime token defaults; Phase 3 ships the four shipping themes
534
+ * (`classroom`, `story`, `admin`, `minimal`).
535
+ */
536
+ type ThemeName = "classroom" | "story" | "admin" | "minimal";
537
+ /**
538
+ * Subset of overridable design tokens. Adapters merge a partial set into the
539
+ * defaults at runtime.
540
+ */
541
+ interface ThemeTokens {
542
+ "color-bg"?: string;
543
+ "color-fg"?: string;
544
+ "color-accent"?: string;
545
+ "color-overlay"?: string;
546
+ "control-size"?: string;
547
+ "control-radius"?: string;
548
+ "control-gap"?: string;
549
+ "control-padding"?: string;
550
+ "focus-ring-color"?: string;
551
+ "focus-ring-width"?: string;
552
+ "seekbar-height"?: string;
553
+ "seekbar-buffered"?: string;
554
+ "seekbar-played"?: string;
555
+ "seekbar-knob-size"?: string;
556
+ "font-family"?: string;
557
+ "font-size-sm"?: string;
558
+ "font-size-md"?: string;
559
+ "motion-fast"?: string;
560
+ "motion-base"?: string;
561
+ }
562
+
563
+ /**
564
+ * Autoplay policy.
565
+ *
566
+ * - `off` (default) — never auto-play.
567
+ * - `muted` — `play()` after `attach`, with `muted = true` (browser-friendly).
568
+ * - `on` — attempt to play with the current `muted` state. May reject.
569
+ */
570
+ type AutoplayMode = "off" | "muted" | "on";
571
+ /**
572
+ * Keyboard scope.
573
+ *
574
+ * - `global` — listen on `document.body` (default — G1, G3).
575
+ * - `container` — listen on the player root only (G16 — embedded surfaces).
576
+ * - `off` — no built-in handlers (G2 story owns its own keys).
577
+ */
578
+ type KeyboardScope = "global" | "container" | "off";
579
+ /**
580
+ * Hooks called once per source / event. Hooks are sticky listeners — they
581
+ * survive across renders / state updates because they live on the player
582
+ * instance.
583
+ */
584
+ interface PlayerHooks {
585
+ /**
586
+ * Authenticated HLS / DASH XHR ended with 401 or 403 (G13). The legacy
587
+ * F8 alias `onStreamUnauthorized` maps to this hook in `f8-ui`.
588
+ */
589
+ onUnauthorized?: (event: UnauthorizedEvent) => void;
590
+ /** Any error (transport, decode, source). */
591
+ onError?: (error: PlayerError) => void;
592
+ }
593
+ /**
594
+ * Options accepted by `createPlayer(options)`.
595
+ *
596
+ * Every field is optional. The player attaches with no source until
597
+ * `setSource` is called or `options.source` is provided.
598
+ */
599
+ interface PlayerOptions {
600
+ /** Initial source. Omit to attach without playable media. */
601
+ source?: SourceDescriptor;
602
+ /** Autoplay policy. Defaults to `"off"`. */
603
+ autoplay?: AutoplayMode;
604
+ /** Seconds to seek to on first ready. */
605
+ startTime?: number;
606
+ /** Whether to loop on `ended`. */
607
+ loop?: boolean;
608
+ /** Initial muted state. Defaults to `false` (or `true` if autoplay is `muted`). */
609
+ muted?: boolean;
610
+ /** Initial volume on `[0, 1]`. */
611
+ volume?: number;
612
+ /** Initial playback rate. */
613
+ playbackRate?: number;
614
+ /** Poster URL — passed to `<video poster>`. */
615
+ poster?: string;
616
+ /** `<video preload>`. */
617
+ preload?: "auto" | "metadata" | "none";
618
+ /** iOS inline playback (G2, G14). Defaults to `true`. */
619
+ playsInline?: boolean;
620
+ /**
621
+ * `<video crossorigin>`. Defaults to `"anonymous"` (T3.5) so WebVTT tracks
622
+ * load with valid CORS, HLS segments can be fetched with `withCredentials`,
623
+ * and `<canvas drawImage(video)>` does not taint the canvas.
624
+ *
625
+ * Pass `null` explicitly to omit the attribute (rare — only for legacy
626
+ * embeds where the response lacks `Access-Control-Allow-Origin`).
627
+ */
628
+ crossOrigin?: "anonymous" | "use-credentials" | null;
629
+ /** Plugins to register synchronously at `createPlayer` time. */
630
+ plugins?: PluginInstance[];
631
+ /** Theme name or runtime token overrides. */
632
+ theme?: ThemeName | ThemeTokens;
633
+ /** Lifecycle hooks. */
634
+ hooks?: PlayerHooks;
635
+ /** Keyboard scoping. Adapters may override per-mount (G16). */
636
+ keyboard?: {
637
+ scope?: KeyboardScope;
638
+ };
639
+ }
640
+
641
+ /**
642
+ * `createPlayer(options)` — wires every piece of `@f8team/reel-core` into a
643
+ * single `Player`. The factory is the one and only public entry point.
644
+ *
645
+ * Responsibilities:
646
+ * 1. Build the reactive store seeded with the initial PlayerOptions.
647
+ * 2. Build the event bus, command registry, controls registry, plugin bus.
648
+ * 3. Register the always-on native source provider (and HLS / YouTube
649
+ * providers with callbacks bound to this player's store and bus).
650
+ * 4. Bridge `<video>` events ⇄ the state-machine reducer.
651
+ * 5. Apply machine effects (attach/detach source, mediaPlay/Pause/Seek,
652
+ * clearError/setError) against the live `<video>` element.
653
+ * 6. Expose the `Player` interface defined in
654
+ * `docs/spec/api-contract.md`.
655
+ *
656
+ * Tests live next to this file and validate the external behavior; lower-level
657
+ * pieces (store, machine, sources, plugins, etc.) are covered by their own
658
+ * unit tests.
659
+ */
660
+
661
+ /** @internal — extra knobs surfaced for tests and adapters. */
662
+ interface CreatePlayerInternalOptions {
663
+ /** Override the microtask scheduler for the store. Tests use this. */
664
+ storeOptions?: CreateStoreOptions<PlayerState>;
665
+ /** Inject extra source providers (e.g. dash, in-house DRM). */
666
+ extraProviders?: SourceProvider[];
667
+ }
668
+ /**
669
+ * Build a fresh `Player`. Safe to construct in the browser, jsdom, or
670
+ * server-rendering tests (only `attach()` touches the DOM).
671
+ */
672
+ declare function createPlayer(options?: PlayerOptions, internal?: CreatePlayerInternalOptions): Player;
673
+
674
+ /**
675
+ * Identity helper used by plugin authors so editors get type narrowing
676
+ * on `setup` arguments without typing `as PluginInstance` at every call site.
677
+ *
678
+ * @example
679
+ * const subtitles = definePlugin({
680
+ * name: "subtitles",
681
+ * setup: (player, host) => { ... },
682
+ * });
683
+ */
684
+
685
+ declare function definePlugin(spec: PluginInstance): PluginInstance;
686
+
687
+ /**
688
+ * Apply theme tokens to an element's `style`.
689
+ *
690
+ * Adapters call this once per mount and again whenever the theme prop changes.
691
+ * Plugins can overlay extra tokens through the same helper.
692
+ */
693
+
694
+ interface ApplyThemeArgs {
695
+ /** Theme preset name. `undefined` skips preset application. */
696
+ preset?: ThemeName;
697
+ /** Extra token overrides. Wins over the preset. */
698
+ overrides?: ThemeTokens;
699
+ /** Whether to apply the neutral defaults first. Default: `true`. */
700
+ applyDefaults?: boolean;
701
+ }
702
+ /**
703
+ * Resolve a final token map by layering defaults → preset → overrides.
704
+ */
705
+ declare function resolveTokens(args?: ApplyThemeArgs): Partial<ThemeTokens>;
706
+ /**
707
+ * Apply tokens to `element.style`. Returns a disposer that resets the
708
+ * variables to their previous values (useful for adapters that need to
709
+ * roll back on theme change).
710
+ */
711
+ declare function applyTheme(element: HTMLElement, args?: ApplyThemeArgs): () => void;
712
+
713
+ /**
714
+ * Default token values. Phase 3 ships theme CSS files (`classroom.css`,
715
+ * `story.css`, `admin.css`, `minimal.css`) that override these via class
716
+ * selectors. The defaults below are intentionally neutral so an unstyled
717
+ * player still looks reasonable.
718
+ */
719
+
720
+ /**
721
+ * Token name → CSS custom property name. Adapters write to the variable
722
+ * directly so consumers can also use plain CSS to override.
723
+ */
724
+ declare const TOKEN_TO_CSS_VAR: Record<keyof ThemeTokens, string>;
725
+ /**
726
+ * Neutral default tokens. Adapters apply these on the player root before any
727
+ * theme class wins. Values use `1rem = 10px` (F8 frontends use the 62.5%
728
+ * font-size convention).
729
+ */
730
+ declare const DEFAULT_TOKENS: Required<ThemeTokens>;
731
+ /** Built-in theme presets. Phase 3 packages the CSS counterpart. */
732
+ declare const THEME_PRESETS: Record<ThemeName, ThemeTokens>;
733
+
734
+ /**
735
+ * Single shared off-screen `aria-live` region. Adapters / plugins call
736
+ * `announce("Subtitles on")` to push messages to assistive tech without
737
+ * jumping focus.
738
+ *
739
+ * The region is created lazily (so SSR-only consumers pay nothing) and
740
+ * polite by default. Screen readers respect rate-limit conventions, so
741
+ * announcements queue up and read sequentially.
742
+ */
743
+ /**
744
+ * Announce a message to assistive tech. No-op outside the browser.
745
+ *
746
+ * Subsequent calls with the same string force a fresh DOM mutation (we
747
+ * blank the region for one frame) so screen readers re-read identical
748
+ * messages — useful for repeated state toggles like Mute on/off.
749
+ */
750
+ declare function announce(message: string): void;
751
+
752
+ /**
753
+ * Minimal focus trap helper.
754
+ *
755
+ * Used by adapters in fullscreen so Tab / Shift+Tab stays inside the player.
756
+ * Not a replacement for full focus-management libraries (focus-trap-react)
757
+ * — but enough for the player's small, well-known interactive surface.
758
+ */
759
+ interface FocusTrap {
760
+ /** Activate the trap. Optionally focuses the first focusable. */
761
+ activate(opts?: {
762
+ focusFirst?: boolean;
763
+ }): void;
764
+ /** Deactivate. Optionally restores focus to the previously focused element. */
765
+ deactivate(opts?: {
766
+ restoreFocus?: boolean;
767
+ }): void;
768
+ }
769
+ /**
770
+ * Build a focus trap rooted at `container`.
771
+ *
772
+ * The trap installs a `keydown` listener and rotates focus among the
773
+ * descendants matching `FOCUSABLE_SELECTOR` when Tab/Shift+Tab fires near
774
+ * the boundary.
775
+ */
776
+ declare function createFocusTrap(container: HTMLElement): FocusTrap;
777
+
778
+ /**
779
+ * Time helpers shared by the seek bar, transcript markers (G8), and the
780
+ * keyboard / commands layer.
781
+ */
782
+ /**
783
+ * Parse a time string like `"00:01:23"`, `"1:23"`, `"00:01:23.456"` to seconds.
784
+ * Returns `NaN` for invalid input — callers should guard.
785
+ *
786
+ * Accepted forms:
787
+ * - `"H:MM:SS"` / `"H:MM:SS.fff"`
788
+ * - `"M:SS"` / `"MM:SS"` / `"MM:SS.fff"`
789
+ *
790
+ * Anything else (including bare numbers, negative values, NaN strings) is
791
+ * `NaN`. Callers that want to accept numbers SHOULD test `typeof === "number"`
792
+ * first.
793
+ */
794
+ declare function parseTime(input: string): number;
795
+ /**
796
+ * Format seconds to `H:MM:SS` (when ≥ 1h) or `MM:SS` (otherwise). NaN /
797
+ * negative inputs are coerced to `0:00`.
798
+ *
799
+ * The ARIA-friendly verbose form (`"1 hour 2 minutes"`) is the adapter's job.
800
+ */
801
+ declare function formatTime(seconds: number): string;
802
+ /**
803
+ * Coerce a time-like input to seconds. Accepts numbers, formatted strings,
804
+ * and clamps negatives to 0.
805
+ *
806
+ * Returns `NaN` if the input cannot be coerced (so callers can refuse to
807
+ * call `seekTo` rather than seeking to 0 silently).
808
+ */
809
+ declare function toSeconds(input: number | string): number;
810
+
811
+ /**
812
+ * URL helpers used by the source registry to dispatch a source descriptor to
813
+ * the right provider. Pure string predicates — no DOM, no I/O.
814
+ */
815
+
816
+ /**
817
+ * Heuristic for HLS m3u8 URLs.
818
+ *
819
+ * Accepts:
820
+ * - `*.m3u8` (case-insensitive) at any path/query position
821
+ * - `application/vnd.apple.mpegurl` MIME hints inside `?type=`
822
+ *
823
+ * Rejects:
824
+ * - YouTube-hosted m3u8 (handled by the YouTube provider).
825
+ * - Empty / non-string inputs.
826
+ */
827
+ declare function looksLikeHls(input: string | undefined | null): boolean;
828
+ /**
829
+ * YouTube URL recognition. Matches `youtube.com/watch?v=`, `youtu.be/<id>`,
830
+ * `youtube.com/embed/<id>`, and `youtube.com/shorts/<id>`.
831
+ */
832
+ declare function looksLikeYouTube(input: string | undefined | null): boolean;
833
+ /**
834
+ * MP4 / WebM / Ogg recognition by extension or `blob:` / `data:` schemes.
835
+ */
836
+ declare function looksLikeProgressive(input: string | undefined | null): boolean;
837
+ /**
838
+ * Resolve the effective `SourceType` for a descriptor.
839
+ *
840
+ * Honors an explicit non-`auto` type. For `auto`, dispatch by URL: HLS first,
841
+ * then YouTube, then progressive, falling back to `native` (which dispatches
842
+ * to the always-on native loader).
843
+ */
844
+ declare function detectSourceType(source: SourceDescriptor): SourceType;
845
+
846
+ export { type AutoplayMode, type BufferedRange, type CommandRegistry, DEFAULT_TOKENS, type Disposer, type KeyboardScope, type MutableStore, type Player, type PlayerError, type PlayerErrorCode, type PlayerEvents, type PlayerHooks, type PlayerOptions, type PlayerState, type PlayerStatus, type PluginHost, type PluginInstance, type QualityLevel, type ReadableStore, type SourceDescriptor, type SourceLoader, type SourceProvider, type SourceType, type SubtitleTrack, THEME_PRESETS, TOKEN_TO_CSS_VAR, type ThemeName, type ThemeTokens, type ThumbnailsDescriptor, type UnauthorizedEvent, announce, applyTheme, createFocusTrap, createPlayer, definePlugin, detectSourceType, formatTime, looksLikeHls, looksLikeProgressive, looksLikeYouTube, parseTime, resolveTokens, toSeconds };