movi-player 0.3.0 → 0.3.2

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.
Files changed (61) hide show
  1. package/AGENTS.md +2 -0
  2. package/README.md +54 -7
  3. package/dist/core/MoviPlayer.d.ts +77 -1
  4. package/dist/core/MoviPlayer.d.ts.map +1 -1
  5. package/dist/core/MoviPlayer.js +667 -121
  6. package/dist/core/MoviPlayer.js.map +1 -1
  7. package/dist/core/PlayerState.js +1 -1
  8. package/dist/core/PlayerState.js.map +1 -1
  9. package/dist/decode/VideoDecoder.d.ts.map +1 -1
  10. package/dist/decode/VideoDecoder.js +6 -2
  11. package/dist/decode/VideoDecoder.js.map +1 -1
  12. package/dist/demuxer.cjs +315 -129
  13. package/dist/demuxer.js +377 -191
  14. package/dist/element.cjs +58309 -16596
  15. package/dist/element.js +59520 -17807
  16. package/dist/index.cjs +58309 -16596
  17. package/dist/index.js +59520 -17807
  18. package/dist/player.cjs +56818 -15360
  19. package/dist/player.js +57818 -16360
  20. package/dist/render/DASHPlayerWrapper.d.ts +65 -0
  21. package/dist/render/DASHPlayerWrapper.d.ts.map +1 -0
  22. package/dist/render/DASHPlayerWrapper.js +540 -0
  23. package/dist/render/DASHPlayerWrapper.js.map +1 -0
  24. package/dist/render/HLSPlayerWrapper.d.ts.map +1 -1
  25. package/dist/render/HLSPlayerWrapper.js +47 -11
  26. package/dist/render/HLSPlayerWrapper.js.map +1 -1
  27. package/dist/render/MoviElement.d.ts +97 -0
  28. package/dist/render/MoviElement.d.ts.map +1 -1
  29. package/dist/render/MoviElement.js +852 -152
  30. package/dist/render/MoviElement.js.map +1 -1
  31. package/dist/render/ShakaPlayerWrapper.d.ts +111 -0
  32. package/dist/render/ShakaPlayerWrapper.d.ts.map +1 -0
  33. package/dist/render/ShakaPlayerWrapper.js +956 -0
  34. package/dist/render/ShakaPlayerWrapper.js.map +1 -0
  35. package/dist/source/DashFallback.d.ts +32 -0
  36. package/dist/source/DashFallback.d.ts.map +1 -0
  37. package/dist/source/DashFallback.js +142 -0
  38. package/dist/source/DashFallback.js.map +1 -0
  39. package/dist/source/DashManifest.d.ts +34 -0
  40. package/dist/source/DashManifest.d.ts.map +1 -0
  41. package/dist/source/DashManifest.js +194 -0
  42. package/dist/source/DashManifest.js.map +1 -0
  43. package/dist/source/DashSegmentSource.d.ts +32 -0
  44. package/dist/source/DashSegmentSource.d.ts.map +1 -0
  45. package/dist/source/DashSegmentSource.js +86 -0
  46. package/dist/source/DashSegmentSource.js.map +1 -0
  47. package/dist/source/HttpSource.d.ts +64 -0
  48. package/dist/source/HttpSource.d.ts.map +1 -1
  49. package/dist/source/HttpSource.js +444 -57
  50. package/dist/source/HttpSource.js.map +1 -1
  51. package/dist/source/ThumbnailHttpSource.d.ts +13 -0
  52. package/dist/source/ThumbnailHttpSource.d.ts.map +1 -1
  53. package/dist/source/ThumbnailHttpSource.js +70 -8
  54. package/dist/source/ThumbnailHttpSource.js.map +1 -1
  55. package/dist/source/index.d.ts +2 -0
  56. package/dist/source/index.d.ts.map +1 -1
  57. package/dist/source/index.js +1 -0
  58. package/dist/source/index.js.map +1 -1
  59. package/dist/types.d.ts +10 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/package.json +16 -6
@@ -0,0 +1,956 @@
1
+ import shaka from "shaka-player/dist/shaka-player.compiled";
2
+ import { EventEmitter } from "../events/EventEmitter";
3
+ import { CanvasRenderer } from "./CanvasRenderer";
4
+ import { TrackManager } from "../core/TrackManager";
5
+ import { Logger } from "../utils/Logger";
6
+ const TAG = "ShakaPlayerWrapper";
7
+ export class ShakaPlayerWrapper extends EventEmitter {
8
+ config;
9
+ player = null; // shaka.Player
10
+ videoElement;
11
+ canvasRenderer = null;
12
+ state = "idle";
13
+ trackManager;
14
+ frameCallbackId = null;
15
+ _framesRendered = 0;
16
+ // All Shaka variant tracks for the loaded manifest, plus the per-type
17
+ // representative arrays whose array index === the Track.id we hand the
18
+ // TrackManager (video id -1 is Auto/ABR, subtitle id null is "off").
19
+ variants = [];
20
+ videoRenditions = []; // distinct video qualities
21
+ audioRenditions = []; // distinct audio tracks
22
+ textTracks = [];
23
+ // Image/thumbnail track for seek-bar previews (DASH-IF tiled thumbnails or
24
+ // HLS image playlists). null when the manifest carries no thumbnail track.
25
+ imageTrackId = null;
26
+ // Decoded sprite sheets keyed by URI — adjacent hover positions usually live
27
+ // in the same sheet, so this avoids re-fetching/decoding it on every move.
28
+ spriteCache = new Map();
29
+ // Overlay element Shaka renders subtitle/caption cues into (canvas mode),
30
+ // plus the injected <style> that positions Shaka's inner text container.
31
+ textContainer = null;
32
+ textStyle = null;
33
+ // True once load() has succeeded. While false, a Shaka error is NOT surfaced
34
+ // to the player — load() rejects instead, letting the caller try a fallback
35
+ // engine (hls.js/dash.js) without flashing an error overlay first.
36
+ _loaded = false;
37
+ constructor(config) {
38
+ super();
39
+ this.config = config;
40
+ this.trackManager = new TrackManager();
41
+ this.videoElement = document.createElement("video");
42
+ this.videoElement.crossOrigin = "anonymous";
43
+ this.videoElement.playsInline = true;
44
+ this.videoElement.style.display = "none"; // Hidden; canvas renderer draws frames
45
+ // Preserve pitch when changing playback speed
46
+ this.videoElement.preservesPitch = true;
47
+ this.videoElement.mozPreservesPitch = true; // Firefox
48
+ this.videoElement.webkitPreservesPitch = true; // Safari/older Chrome
49
+ // DRM mode: use native video element directly (no canvas) — canvas can't
50
+ // access DRM-protected frames (browser blocks VideoFrame copy).
51
+ // LCEVC mode: Shaka composites the enhanced frames onto the canvas itself
52
+ // (via attachCanvas), so our rVFC→CanvasRenderer path stays out of the way.
53
+ if (!config.drm && !config.lcevc && config.renderer === "canvas" && config.canvas) {
54
+ this.canvasRenderer = new CanvasRenderer(config.canvas);
55
+ }
56
+ this.setupEventHandlers();
57
+ this.setupTrackSwitching();
58
+ }
59
+ // --- Track switching: mirror selections from the TrackManager onto Shaka. ---
60
+ setupTrackSwitching() {
61
+ this.trackManager.on("videoTrackChange", (track) => {
62
+ if (!this.player)
63
+ return;
64
+ const id = track ? track.id : -1;
65
+ // Auto (-1) → enable ABR.
66
+ if (id === -1) {
67
+ this.player.configure({ abr: { enabled: true } });
68
+ Logger.info(TAG, "Switched to Auto Quality (ABR)");
69
+ return;
70
+ }
71
+ const target = this.videoRenditions[id];
72
+ if (!target)
73
+ return;
74
+ // Manual selection: pin ABR off, then switch to a variant carrying the
75
+ // chosen video rendition while keeping the active audio. clearBuffer on
76
+ // (paused) gives an immediate switch; smooth (next-segment) while playing.
77
+ this.player.configure({ abr: { enabled: false } });
78
+ const active = this.activeVariant() ?? target;
79
+ const pick = this.variants.find((v) => v.videoId === target.videoId && v.audioId === active.audioId) || target;
80
+ this.player.selectVariantTrack(pick, this.state !== "playing");
81
+ Logger.info(TAG, `Requesting video rendition ${target.height}p`);
82
+ });
83
+ this.trackManager.on("audioTrackChange", (track) => {
84
+ if (!this.player || !track)
85
+ return;
86
+ const target = this.audioRenditions[track.id];
87
+ if (!target)
88
+ return;
89
+ // selectAudioLanguage swaps audio while leaving video ABR intact;
90
+ // selectVariantTrack would pin the whole variant and fight the ABR
91
+ // manager (console warning + possible override). Disambiguate same-
92
+ // language tracks by channel count / label where available.
93
+ this.player.selectAudioLanguage(target.language || "", target.roles?.[0], target.channelsCount || undefined, undefined, undefined, undefined, target.label || undefined);
94
+ Logger.info(TAG, `Selected audio track ${track.id} (${target.language || target.label || ""})`);
95
+ });
96
+ this.trackManager.on("subtitleTrackChange", (track) => {
97
+ if (!this.player)
98
+ return;
99
+ if (!track) {
100
+ this.player.setTextTrackVisibility(false);
101
+ Logger.info(TAG, "Subtitles disabled");
102
+ return;
103
+ }
104
+ const t = this.textTracks[track.id];
105
+ if (!t) {
106
+ this.player.setTextTrackVisibility(false);
107
+ return;
108
+ }
109
+ this.player.selectTextTrack(t);
110
+ this.player.setTextTrackVisibility(true);
111
+ Logger.info(TAG, `Selected subtitle track ${track.id} (${track.language || track.label || ""})`);
112
+ });
113
+ }
114
+ activeVariant() {
115
+ return this.variants.find((v) => v.active) ?? null;
116
+ }
117
+ /**
118
+ * Map a Shaka error to a plain, user-facing message. The technical code and
119
+ * category are logged for developers but never surfaced in the UI — viewers
120
+ * see why playback failed in everyday language, not "Shaka … (code 6012)".
121
+ * Categories follow shaka.util.Error.Category (thousands digit of the code).
122
+ */
123
+ friendlyMessage(e) {
124
+ const category = typeof e?.category === "number"
125
+ ? e.category
126
+ : typeof e?.code === "number"
127
+ ? Math.floor(e.code / 1000)
128
+ : 0;
129
+ switch (category) {
130
+ case 1: { // NETWORK
131
+ // Not every network failure is the viewer's connection. Shaka splits
132
+ // this category by code: BAD_HTTP_STATUS (1001) means the server DID
133
+ // answer, with an error status (403/404/5xx) — blaming the user's wifi
134
+ // is wrong and unactionable. HTTP_ERROR (1002) / TIMEOUT (1003) are the
135
+ // genuine "request never landed" cases. Surface the real reason.
136
+ const code = typeof e?.code === "number" ? e.code : 0;
137
+ // BAD_HTTP_STATUS data: [uri, httpStatus, responseText, headers, type]
138
+ const status = code === 1001 && Array.isArray(e?.data) ? Number(e.data[1]) || 0 : 0;
139
+ if (status === 401 || status === 403)
140
+ return `Access to this video was denied (HTTP ${status}). The link may have expired or be restricted.`;
141
+ if (status === 404 || status === 410)
142
+ return "This video could not be found. The link may be broken or removed.";
143
+ if (status === 429)
144
+ return "The video server is rate-limiting requests. Please wait a moment and try again.";
145
+ if (status >= 500)
146
+ return `The video server had a problem (HTTP ${status}). Please try again later.`;
147
+ if (status > 0)
148
+ return `The video server rejected the request (HTTP ${status}).`;
149
+ if (code === 1003) // TIMEOUT
150
+ return "The video took too long to load. Check your connection and try again.";
151
+ // HTTP_ERROR / CORS / offline — the request never reached the server.
152
+ return "Couldn't load the video. Check your internet connection and try again.";
153
+ }
154
+ case 3: // MEDIA
155
+ return "This video can't be played on this device.";
156
+ case 4: // MANIFEST
157
+ return "This video is unavailable or in an unsupported format.";
158
+ case 5: // STREAMING
159
+ return "Playback was interrupted. Please try again.";
160
+ case 6: // DRM
161
+ return "This video is protected and can't be played here.";
162
+ case 7: // PLAYER
163
+ return "Couldn't start the video. Please try again.";
164
+ default:
165
+ return "Something went wrong while loading the video.";
166
+ }
167
+ }
168
+ setupEventHandlers() {
169
+ this.videoElement.addEventListener("play", () => this.setState("playing"));
170
+ this.videoElement.addEventListener("playing", () => this.setState("playing"));
171
+ this.videoElement.addEventListener("pause", () => {
172
+ if (this.state !== "ended")
173
+ this.setState("paused");
174
+ });
175
+ this.videoElement.addEventListener("ended", () => this.setState("ended"));
176
+ this.videoElement.addEventListener("seeking", () => this.setState("seeking"));
177
+ this.videoElement.addEventListener("seeked", () => {
178
+ if (this.videoElement.paused)
179
+ this.setState("paused");
180
+ else
181
+ this.setState("playing");
182
+ });
183
+ this.videoElement.addEventListener("waiting", () => this.setState("buffering"));
184
+ this.videoElement.addEventListener("timeupdate", () => {
185
+ this.emit("timeUpdate", this.videoElement.currentTime);
186
+ });
187
+ this.videoElement.addEventListener("durationchange", () => {
188
+ this.emit("durationChange", this.videoElement.duration);
189
+ });
190
+ this.videoElement.addEventListener("error", (_e) => {
191
+ const error = this.videoElement.error;
192
+ this.emit("error", new Error(error?.message || "Video element error"));
193
+ this.setState("error");
194
+ });
195
+ }
196
+ setState(newState) {
197
+ if (this.state !== newState) {
198
+ this.state = newState;
199
+ this.emit("stateChange", newState);
200
+ if (newState === "playing" && this.canvasRenderer) {
201
+ this.startFrameLoop();
202
+ }
203
+ else if (newState !== "seeking" && newState !== "buffering") {
204
+ if (newState === "paused" ||
205
+ newState === "ended" ||
206
+ newState === "error" ||
207
+ newState === "idle") {
208
+ this.stopFrameLoop();
209
+ }
210
+ }
211
+ }
212
+ }
213
+ startFrameLoop() {
214
+ if (this.frameCallbackId !== null)
215
+ return;
216
+ this.frameCallbackId = this.videoElement.requestVideoFrameCallback((_now, _metadata) => {
217
+ this.renderFrame();
218
+ this.frameCallbackId = null;
219
+ if (this.state === "playing" ||
220
+ this.state === "seeking" ||
221
+ this.state === "buffering") {
222
+ this.startFrameLoop();
223
+ }
224
+ });
225
+ }
226
+ stopFrameLoop() {
227
+ if (this.frameCallbackId !== null) {
228
+ this.videoElement.cancelVideoFrameCallback(this.frameCallbackId);
229
+ this.frameCallbackId = null;
230
+ }
231
+ }
232
+ renderFrame() {
233
+ if (!this.canvasRenderer)
234
+ return;
235
+ try {
236
+ const frame = new VideoFrame(this.videoElement);
237
+ this.canvasRenderer.render(frame);
238
+ frame.close();
239
+ this._framesRendered++;
240
+ }
241
+ catch (e) {
242
+ Logger.warn(TAG, "Failed to create VideoFrame", e);
243
+ }
244
+ }
245
+ /**
246
+ * Ensure the external LCEVC decoder library (lcevc_dec.js) is present. Returns
247
+ * true if the global `LCEVCdec` is available — lazy-loading it from `url` when
248
+ * given. The library is proprietary (V-Nova) and not bundled, so LCEVC is
249
+ * opt-in and degrades gracefully to base-layer playback when it's missing.
250
+ */
251
+ async ensureLcevcLib(url) {
252
+ const present = () => !!(window.LCEVCdec || window.libDPIModule);
253
+ if (present())
254
+ return true;
255
+ if (!url)
256
+ return false;
257
+ try {
258
+ await new Promise((resolve, reject) => {
259
+ const s = document.createElement("script");
260
+ s.src = url;
261
+ s.async = true;
262
+ s.onload = () => resolve();
263
+ s.onerror = () => reject(new Error(`Failed to load ${url}`));
264
+ document.head.appendChild(s);
265
+ });
266
+ }
267
+ catch (e) {
268
+ Logger.warn(TAG, "LCEVC decoder library load failed", e);
269
+ return false;
270
+ }
271
+ return present();
272
+ }
273
+ async load() {
274
+ this.setState("loading");
275
+ this.emit("loadStart", undefined);
276
+ const source = this.config.source;
277
+ const url = source && source.type === "url" ? source.url : null;
278
+ if (!url) {
279
+ throw new Error("Shaka source must be a URL");
280
+ }
281
+ if (!shaka.Player.isBrowserSupported()) {
282
+ // Throw only — let the caller try the hls.js/dash.js fallback before any
283
+ // error surfaces.
284
+ throw new Error("Adaptive streaming not supported in this browser");
285
+ }
286
+ shaka.polyfill.installAll();
287
+ this.player = new shaka.Player();
288
+ await this.player.attach(this.videoElement);
289
+ // Lenient manifest parsing to match dash.js / real-world streams: skip
290
+ // empty AdaptationSets instead of failing the whole load (Shaka error
291
+ // 4003 DASH_EMPTY_ADAPTATION_SET), and don't choke on quirky timing
292
+ // attributes. dash.js ignores these; Shaka is strict by default.
293
+ this.player.configure({
294
+ manifest: {
295
+ dash: {
296
+ ignoreEmptyAdaptationSet: true,
297
+ ignoreMinBufferTime: true,
298
+ ignoreSuggestedPresentationDelay: true,
299
+ },
300
+ },
301
+ });
302
+ // Render subtitle/caption (and IMSC image) cues over the canvas. In canvas
303
+ // mode the <video> is hidden and the canvas is a direct shadow-root child
304
+ // (so canvas.parentElement is null), so we create a dedicated absolutely-
305
+ // positioned overlay as a sibling of the canvas. NOTE: setVideoContainer()
306
+ // alone does NOT wire a displayer in the lib build — an explicit
307
+ // textDisplayFactory → UITextDisplayer is required for cues to actually
308
+ // paint into the overlay (verified empirically).
309
+ try {
310
+ const sibling = this.config.canvas instanceof HTMLCanvasElement
311
+ ? this.config.canvas
312
+ : this.videoElement;
313
+ const root = sibling.parentNode; // ShadowRoot or element — appendable
314
+ if (root) {
315
+ const tc = document.createElement("div");
316
+ tc.className = "movi-shaka-text-container";
317
+ tc.style.position = "absolute";
318
+ tc.style.inset = "0";
319
+ tc.style.pointerEvents = "none";
320
+ tc.style.zIndex = "2"; // above the canvas, below the controls bar
321
+ root.appendChild(tc);
322
+ this.textContainer = tc;
323
+ // The lib build ships NO Shaka UI CSS, so Shaka's `.shaka-text-container`
324
+ // would collapse to the top of the overlay (static, content-height) and
325
+ // the cues render up by the title bar — looking like "no subtitles".
326
+ // Inject minimal positioning so it fills the overlay and sits at the
327
+ // bottom, with room to clear the controls bar.
328
+ const style = document.createElement("style");
329
+ style.textContent =
330
+ ".movi-shaka-text-container .shaka-text-container{" +
331
+ "position:absolute!important;inset:0!important;" +
332
+ "box-sizing:border-box!important;padding-bottom:max(48px,8%)!important;}" +
333
+ // Match the player's own subtitle size: same formula as
334
+ // .movi-subtitle-line, so cues track the size setting
335
+ // (--movi-sub-size-mult) and scale with the player width.
336
+ // !important overrides Shaka's small inline default.
337
+ ".movi-shaka-text-container .shaka-text-container div," +
338
+ ".movi-shaka-text-container .shaka-text-container span{" +
339
+ "font-size:calc(clamp(20px,calc(var(--movi-player-width,100vw)*0.032),40px)" +
340
+ "*var(--movi-sub-size-mult,1))!important;line-height:1.3!important;}";
341
+ root.appendChild(style);
342
+ this.textStyle = style;
343
+ const video = this.videoElement;
344
+ this.player.configure("textDisplayFactory", () => new shaka.text.UITextDisplayer(video, tc));
345
+ }
346
+ }
347
+ catch (e) {
348
+ Logger.warn(TAG, "Could not set up Shaka text display", e);
349
+ }
350
+ // DRM: Shaka manages EME natively — point it at the license server(s).
351
+ if (this.config.drm && this.config.licenseUrl) {
352
+ Logger.info(TAG, "DRM mode enabled — using native video element (no canvas)");
353
+ this.player.configure({
354
+ drm: {
355
+ servers: {
356
+ "com.widevine.alpha": this.config.licenseUrl,
357
+ "com.microsoft.playready": this.config.licenseUrl,
358
+ "com.apple.fps": this.config.licenseUrl,
359
+ },
360
+ },
361
+ });
362
+ if (this.config.licenseHeaders) {
363
+ const headers = this.config.licenseHeaders;
364
+ this.player
365
+ .getNetworkingEngine()
366
+ ?.registerRequestFilter((type, request) => {
367
+ if (type === shaka.net.NetworkingEngine.RequestType.LICENSE) {
368
+ Object.assign(request.headers, headers);
369
+ }
370
+ });
371
+ }
372
+ }
373
+ // Custom media headers: applied to EVERY outbound request Shaka makes —
374
+ // manifest, segments, init segments, timing, etc. (the "network media flow"
375
+ // the caller asked about). Registered unconditionally (independent of DRM)
376
+ // so auth tokens / signed headers reach the .mpd/.m3u8 and its segments.
377
+ // License headers above stack on top for LICENSE requests.
378
+ if (this.config.headers) {
379
+ const mediaHeaders = this.config.headers;
380
+ this.player
381
+ .getNetworkingEngine()
382
+ ?.registerRequestFilter((_type, request) => {
383
+ Object.assign(request.headers, mediaHeaders);
384
+ });
385
+ }
386
+ // Surface Shaka errors. load() rejects on a fatal load error; later
387
+ // (post-load) errors arrive via this event without rejecting.
388
+ this.player.addEventListener("error", (event) => {
389
+ const detail = event?.detail;
390
+ // Log the technical detail for developers; surface plain text to viewers.
391
+ Logger.error(TAG, `Shaka runtime error (code ${detail?.code}, category ${detail?.category})`, detail?.data);
392
+ // Pre-load errors are handled by load()'s rejection + the caller's
393
+ // fallback — don't surface them (would flash an error before recovery).
394
+ if (!this._loaded)
395
+ return;
396
+ this.emit("error", new Error(this.friendlyMessage(detail)));
397
+ this.setState("error");
398
+ });
399
+ // MPEG-5 Part 2 LCEVC: enable Shaka's enhancement-layer decoding. The
400
+ // actual decoder is the external lcevc_dec.js library (proprietary, not
401
+ // bundled); Shaka composites the enhanced output onto the attached canvas.
402
+ if (this.config.lcevc) {
403
+ const ready = await this.ensureLcevcLib(this.config.lcevcUrl);
404
+ if (ready) {
405
+ const c = this.config.canvas;
406
+ if (c instanceof HTMLCanvasElement)
407
+ this.player.attachCanvas(c);
408
+ this.player.configure({ lcevc: { enabled: true } });
409
+ Logger.info(TAG, "LCEVC decoding enabled");
410
+ }
411
+ else {
412
+ Logger.warn(TAG, "LCEVC requested but the lcevc_dec.js decoder library is unavailable — playing the base layer only");
413
+ }
414
+ }
415
+ // ABR adaptation / manual variant change swaps the active rendition without
416
+ // changing the track list — re-fire tracksChange so the gear-badge UI in
417
+ // MoviElement repaints against the new height.
418
+ const repaint = () => this.trackManager.emit("tracksChange", this.trackManager.getTracks());
419
+ this.player.addEventListener("adaptation", repaint);
420
+ this.player.addEventListener("variantchanged", repaint);
421
+ try {
422
+ await this.player.load(url);
423
+ }
424
+ catch (e) {
425
+ // Log for developers, but DON'T emit/setState here — just throw. The
426
+ // caller (MoviPlayer) tries hls.js/dash.js/FFmpeg fallbacks and only
427
+ // surfaces an error if they all fail, so a recoverable Shaka failure
428
+ // never flashes an error overlay. The friendly message rides on the
429
+ // thrown error for the no-fallback case.
430
+ Logger.error(TAG, `Shaka load failed${typeof e?.code === "number" ? ` (code ${e.code}, category ${e.category})` : ""}`, e);
431
+ throw new Error(this.friendlyMessage(e));
432
+ }
433
+ this._loaded = true;
434
+ const count = this.updateTracks();
435
+ Logger.info(TAG, `Manifest parsed (${url.includes(".m3u8") ? "HLS" : "DASH"}). ${count} video renditions, ${this.audioRenditions.length} audio, ${this.textTracks.length} text`);
436
+ // Apply audio-only data-saver if requested at load time.
437
+ if (this.config.audioOnly)
438
+ this.setAudioOnly(true);
439
+ this.setState("ready");
440
+ this.emit("loadEnd", undefined);
441
+ }
442
+ /**
443
+ * Audio-only (data-saver) mode. Picks a true audio-only variant when the
444
+ * manifest exposes one (zero video bandwidth); otherwise pins the SMALLEST
445
+ * video rendition (most of the saving without breaking a stream whose audio
446
+ * is only muxed with video). ABR is disabled so it doesn't climb back up.
447
+ * Toggling off re-enables ABR. NOT done via manifest.disableVideo — that
448
+ * fails (Shaka 4032) on HLS streams with no separate audio-only variant.
449
+ */
450
+ setAudioOnly(enabled) {
451
+ if (!this.player)
452
+ return;
453
+ try {
454
+ if (!enabled) {
455
+ this.player.configure({ abr: { enabled: true } });
456
+ Logger.info(TAG, "Audio-only off — ABR re-enabled");
457
+ return;
458
+ }
459
+ const variants = this.player.getVariantTracks() ?? [];
460
+ if (!variants.length)
461
+ return;
462
+ this.player.configure({ abr: { enabled: false } });
463
+ const audioOnly = variants.filter((v) => !v.videoCodec && !v.height);
464
+ const pick = audioOnly.length
465
+ ? audioOnly[0]
466
+ : variants
467
+ .slice()
468
+ .sort((a, b) => (a.bandwidth || 0) - (b.bandwidth || 0))[0];
469
+ if (pick) {
470
+ this.player.selectVariantTrack(pick, true);
471
+ Logger.info(TAG, audioOnly.length
472
+ ? "Audio-only mode — selected audio-only variant"
473
+ : `Audio-only mode — no audio-only variant; pinned smallest video (${pick.height || "?"}p)`);
474
+ }
475
+ }
476
+ catch (e) {
477
+ Logger.warn(TAG, "setAudioOnly failed", e);
478
+ }
479
+ }
480
+ /** Build the video/audio/subtitle track lists from Shaka's manifest. */
481
+ updateTracks() {
482
+ if (!this.player)
483
+ return 0;
484
+ this.variants = this.player.getVariantTracks() ?? [];
485
+ this.textTracks = this.player.getTextTracks() ?? [];
486
+ // Thumbnail track (highest-res image AdaptationSet / image playlist).
487
+ const imageTracks = this.player.getImageTracks?.() ?? [];
488
+ this.imageTrackId = imageTracks.length
489
+ ? imageTracks.reduce((a, b) => (b.height || 0) > (a.height || 0) ? b : a).id
490
+ : null;
491
+ // --- Distinct video renditions (by videoId; fall back to dimensions). ---
492
+ // Skip audio-only variants (no video component) so an audio-only stream
493
+ // doesn't surface a phantom video rendition — that would make the player
494
+ // think it has video and suppress the audio-strip layout.
495
+ const seenVideo = new Map();
496
+ for (const v of this.variants) {
497
+ const hasVideo = v.videoId != null || (v.width || 0) > 0;
498
+ if (!hasVideo)
499
+ continue;
500
+ const key = v.videoId != null
501
+ ? `v${v.videoId}`
502
+ : `${v.width}x${v.height}@${v.videoBandwidth ?? v.bandwidth}`;
503
+ if (!seenVideo.has(key))
504
+ seenVideo.set(key, v);
505
+ }
506
+ this.videoRenditions = [...seenVideo.values()].sort((a, b) => (a.height || 0) - (b.height || 0) ||
507
+ (a.bandwidth || 0) - (b.bandwidth || 0));
508
+ // --- Distinct audio tracks (by audioId; fall back to language/label). ---
509
+ const seenAudio = new Map();
510
+ for (const v of this.variants) {
511
+ const key = v.audioId != null
512
+ ? `a${v.audioId}`
513
+ : `${v.language || ""}/${v.label || ""}/${v.channelsCount || ""}`;
514
+ if (!seenAudio.has(key))
515
+ seenAudio.set(key, v);
516
+ }
517
+ this.audioRenditions = [...seenAudio.values()];
518
+ const tracks = [];
519
+ // Video tracks — only for streams that actually carry video. Audio-only
520
+ // streams push no video track (not even "Auto"), so the player reports no
521
+ // active video track and switches to the audio-strip layout.
522
+ if (this.videoRenditions.length > 0) {
523
+ // Auto / ABR video track.
524
+ tracks.push({
525
+ id: -1,
526
+ type: "video",
527
+ codec: "auto",
528
+ width: 0,
529
+ height: 0,
530
+ frameRate: 0,
531
+ label: "Auto",
532
+ });
533
+ // Disambiguate same-resolution renditions with their bitrate.
534
+ const heightCount = new Map();
535
+ this.videoRenditions.forEach((r) => heightCount.set(r.height, (heightCount.get(r.height) || 0) + 1));
536
+ this.videoRenditions.forEach((r, index) => {
537
+ const bitrate = r.videoBandwidth ?? r.bandwidth ?? 0;
538
+ const hasDuplicates = (heightCount.get(r.height) || 0) > 1;
539
+ const label = hasDuplicates
540
+ ? `${r.height}p · ${(bitrate / 1000).toFixed(0)} kbps`
541
+ : `${r.height}p`;
542
+ tracks.push({
543
+ id: index,
544
+ type: "video",
545
+ codec: r.videoCodec ?? "",
546
+ bitRate: bitrate,
547
+ width: r.width,
548
+ height: r.height,
549
+ frameRate: r.frameRate || 0,
550
+ label,
551
+ });
552
+ });
553
+ }
554
+ // Audio tracks (only expose the menu if there's a real choice).
555
+ if (this.audioRenditions.length > 1) {
556
+ this.audioRenditions.forEach((a, index) => {
557
+ const lang = a.language && a.language !== "und" ? a.language : "";
558
+ const label = a.label ||
559
+ [lang, a.channelsCount ? `${a.channelsCount}ch` : ""]
560
+ .filter(Boolean)
561
+ .join(" ") ||
562
+ `Audio ${index + 1}`;
563
+ tracks.push({
564
+ id: index,
565
+ type: "audio",
566
+ codec: a.audioCodec ?? "",
567
+ language: lang,
568
+ label,
569
+ channels: a.channelsCount || 0,
570
+ sampleRate: a.audioSamplingRate || 0,
571
+ bitRate: a.audioBandwidth ?? 0,
572
+ });
573
+ });
574
+ }
575
+ // Subtitle / text tracks. IMSC-1 image profile (codecs "…im1i") and other
576
+ // bitmap captions are image subtitles; everything else (WebVTT, TTML text
577
+ // profile "…im1t", CEA-608/708) is text.
578
+ this.textTracks.forEach((t, index) => {
579
+ const lang = t.language && t.language !== "und" ? t.language : "";
580
+ const label = t.label || lang || `Subtitle ${index + 1}`;
581
+ const codecs = (t.codecs || "").toLowerCase();
582
+ const isImage = codecs.includes("im1i") ||
583
+ codecs.includes("image") ||
584
+ /image/i.test((t.roles || []).join(" "));
585
+ tracks.push({
586
+ id: index,
587
+ type: "subtitle",
588
+ codec: t.codecs ?? "",
589
+ language: lang,
590
+ label,
591
+ subtitleType: isImage ? "image" : "text",
592
+ });
593
+ });
594
+ this.trackManager.setTracks(tracks);
595
+ if (this.videoRenditions.length > 0) {
596
+ this.trackManager.selectVideoTrack(-1); // default Auto (video streams only)
597
+ }
598
+ this.sizeCanvasToTopRendition();
599
+ return this.videoRenditions.length;
600
+ }
601
+ /** True for a live (dynamic) stream — drives the LIVE indicator UI. */
602
+ isLive() {
603
+ try {
604
+ return !!this.player?.isLive?.();
605
+ }
606
+ catch {
607
+ return false;
608
+ }
609
+ }
610
+ /** True when the stream carries audio but no video (→ audio-strip layout). */
611
+ isAudioOnly() {
612
+ return this.videoRenditions.length === 0 && this.variants.length > 0;
613
+ }
614
+ /** Live edge time (seekable range end) — where "go live" jumps to. */
615
+ getLiveEdge() {
616
+ try {
617
+ const range = this.player?.seekRange?.();
618
+ return range ? range.end : this.videoElement.duration;
619
+ }
620
+ catch {
621
+ return this.videoElement.duration;
622
+ }
623
+ }
624
+ /** Start of the seekable (DVR) window — used to scale the live progress bar. */
625
+ getSeekRangeStart() {
626
+ try {
627
+ const range = this.player?.seekRange?.();
628
+ return range ? range.start : 0;
629
+ }
630
+ catch {
631
+ return 0;
632
+ }
633
+ }
634
+ /** Size the canvas from the highest rendition (or the <video> dims). */
635
+ sizeCanvasToTopRendition() {
636
+ if (!this.canvasRenderer || this.videoRenditions.length === 0)
637
+ return;
638
+ const top = this.videoRenditions.reduce((a, b) => ((b.height || 0) > (a.height || 0) ? b : a), this.videoRenditions[0]);
639
+ const applyDims = (w, h) => {
640
+ if (!this.canvasRenderer || w <= 0 || h <= 0)
641
+ return;
642
+ this.canvasRenderer.configure(w, h);
643
+ const canvas = this.canvasRenderer.getCanvas();
644
+ const parent = canvas instanceof HTMLCanvasElement ? canvas.parentElement : null;
645
+ const cw = parent?.clientWidth || w;
646
+ const ch = parent?.clientHeight || h;
647
+ if (cw > 0 && ch > 0)
648
+ this.canvasRenderer.resize(cw, ch);
649
+ };
650
+ if (top.width > 0 && top.height > 0) {
651
+ applyDims(top.width, top.height);
652
+ }
653
+ else {
654
+ const onMeta = () => {
655
+ this.videoElement.removeEventListener("loadedmetadata", onMeta);
656
+ applyDims(this.videoElement.videoWidth, this.videoElement.videoHeight);
657
+ };
658
+ if (this.videoElement.videoWidth > 0 && this.videoElement.videoHeight > 0) {
659
+ applyDims(this.videoElement.videoWidth, this.videoElement.videoHeight);
660
+ }
661
+ else {
662
+ this.videoElement.addEventListener("loadedmetadata", onMeta);
663
+ }
664
+ }
665
+ }
666
+ async play() {
667
+ await this.videoElement.play();
668
+ }
669
+ pause() {
670
+ this.videoElement.pause();
671
+ }
672
+ async seek(time) {
673
+ this.videoElement.currentTime = time;
674
+ }
675
+ getState() {
676
+ return this.state;
677
+ }
678
+ getDuration() {
679
+ return this.videoElement.duration;
680
+ }
681
+ getCurrentTime() {
682
+ return this.videoElement.currentTime;
683
+ }
684
+ setVolume(volume) {
685
+ this.videoElement.volume = volume;
686
+ }
687
+ setMuted(muted) {
688
+ this.videoElement.muted = muted;
689
+ }
690
+ setPlaybackRate(rate) {
691
+ this.videoElement.playbackRate = rate;
692
+ }
693
+ getVolume() {
694
+ return this.videoElement.volume;
695
+ }
696
+ isMuted() {
697
+ return this.videoElement.muted;
698
+ }
699
+ getPlaybackRate() {
700
+ return this.videoElement.playbackRate;
701
+ }
702
+ setSubtitleOverlay(_element) {
703
+ // No-op: Shaka renders cues itself into the text container created in
704
+ // load() (see setVideoContainer), so the player's external overlay isn't
705
+ // used for adaptive-stream subtitles.
706
+ }
707
+ setHDREnabled(enabled) {
708
+ if (this.canvasRenderer) {
709
+ this.canvasRenderer.setHDREnabled(enabled);
710
+ }
711
+ }
712
+ getVideoElement() {
713
+ return this.videoElement;
714
+ }
715
+ getBufferEndTime() {
716
+ if (this.videoElement.buffered.length) {
717
+ return this.videoElement.buffered.end(this.videoElement.buffered.length - 1);
718
+ }
719
+ return 0;
720
+ }
721
+ resizeCanvas(width, height) {
722
+ if (this.canvasRenderer) {
723
+ this.canvasRenderer.resize(width, height);
724
+ }
725
+ }
726
+ /** Active rendition dimensions for the gear-badge UI (used in Auto mode). */
727
+ getActiveResolution() {
728
+ // Use LIVE stats (the currently-playing rendition), not the cached variant
729
+ // snapshot. In Auto/ABR the active rendition changes after load, so the
730
+ // snapshot's `.active` flag goes stale and would keep reporting the wrong
731
+ // (often top) height — making the gear badge show "HD" while a lower
732
+ // rendition is actually playing.
733
+ try {
734
+ const s = this.player?.getStats?.();
735
+ if (s?.width && s?.height)
736
+ return { width: s.width, height: s.height };
737
+ }
738
+ catch { }
739
+ // The hidden <video>'s decoded dimensions also reflect the current rendition.
740
+ return {
741
+ width: this.videoElement.videoWidth || 0,
742
+ height: this.videoElement.videoHeight || 0,
743
+ };
744
+ }
745
+ /** True when the manifest carries a thumbnail/image track for seek previews. */
746
+ hasThumbnails() {
747
+ return this.imageTrackId !== null;
748
+ }
749
+ /** Fetch + decode a sprite sheet once, cached by URI. */
750
+ loadSprite(uri) {
751
+ let p = this.spriteCache.get(uri);
752
+ if (!p) {
753
+ p = (async () => {
754
+ try {
755
+ const res = await fetch(uri, { mode: "cors" });
756
+ if (!res.ok)
757
+ return null;
758
+ return await createImageBitmap(await res.blob());
759
+ }
760
+ catch (e) {
761
+ Logger.warn(TAG, "Thumbnail sprite fetch failed", e);
762
+ return null;
763
+ }
764
+ })();
765
+ this.spriteCache.set(uri, p);
766
+ }
767
+ return p;
768
+ }
769
+ /**
770
+ * Seek-preview thumbnail for `time` as a JPEG Blob, or null if the manifest
771
+ * has no thumbnail track. Shaka resolves which tile in the sprite sheet maps
772
+ * to the time; we crop that tile out and hand back a Blob the existing
773
+ * MoviElement preview <img> can show — same contract as getPreviewFrame.
774
+ */
775
+ async getThumbnailBlob(time) {
776
+ if (!this.player || this.imageTrackId == null)
777
+ return null;
778
+ let thumb;
779
+ try {
780
+ thumb = await this.player.getThumbnails(this.imageTrackId, time);
781
+ }
782
+ catch (e) {
783
+ Logger.warn(TAG, "getThumbnails failed", e);
784
+ return null;
785
+ }
786
+ if (!thumb || !thumb.uris?.length)
787
+ return null;
788
+ const bitmap = await this.loadSprite(thumb.uris[0]);
789
+ if (!bitmap)
790
+ return null;
791
+ const w = thumb.width || bitmap.width;
792
+ const h = thumb.height || bitmap.height;
793
+ const canvas = document.createElement("canvas");
794
+ canvas.width = w;
795
+ canvas.height = h;
796
+ const ctx = canvas.getContext("2d");
797
+ if (!ctx)
798
+ return null;
799
+ // Crop the single tile (positionX/Y, width/height) out of the sheet.
800
+ ctx.drawImage(bitmap, thumb.positionX || 0, thumb.positionY || 0, w, h, 0, 0, w, h);
801
+ return new Promise((resolve) => canvas.toBlob((b) => resolve(b), "image/jpeg", 0.85));
802
+ }
803
+ getVideoTracks() {
804
+ return this.trackManager
805
+ .getTracks()
806
+ .filter((t) => t.type === "video");
807
+ }
808
+ selectVideoTrack(id) {
809
+ if (!this.player)
810
+ return;
811
+ // The trackManager event handler performs the Shaka switch.
812
+ this.trackManager.selectVideoTrack(id);
813
+ }
814
+ getAudioTracks() {
815
+ return this.trackManager
816
+ .getTracks()
817
+ .filter((t) => t.type === "audio");
818
+ }
819
+ selectAudioTrack(id) {
820
+ if (!this.player)
821
+ return false;
822
+ return this.trackManager.selectAudioTrack(id);
823
+ }
824
+ getSubtitleTracks() {
825
+ return this.trackManager
826
+ .getTracks()
827
+ .filter((t) => t.type === "subtitle");
828
+ }
829
+ async selectSubtitleTrack(id) {
830
+ if (!this.player)
831
+ return false;
832
+ return this.trackManager.selectSubtitleTrack(id);
833
+ }
834
+ setFitMode(mode) {
835
+ if (this.canvasRenderer) {
836
+ this.canvasRenderer.setFitMode(mode);
837
+ }
838
+ else {
839
+ if (mode === "contain")
840
+ this.videoElement.style.objectFit = "contain";
841
+ else if (mode === "cover")
842
+ this.videoElement.style.objectFit = "cover";
843
+ else if (mode === "fill")
844
+ this.videoElement.style.objectFit = "fill";
845
+ }
846
+ }
847
+ getStats() {
848
+ const stats = {};
849
+ const s = this.player?.getStats?.();
850
+ const active = this.activeVariant();
851
+ const w = s?.width || active?.width || this.videoElement.videoWidth || 0;
852
+ const h = s?.height || active?.height || this.videoElement.videoHeight || 0;
853
+ // --- Video ---
854
+ if (w && h) {
855
+ stats["Video Codec"] = active?.videoCodec ?? "N/A";
856
+ stats["Resolution"] = `${w}x${h}`;
857
+ const eff = Math.max(h, Math.round((w * 9) / 16));
858
+ stats["Quality"] =
859
+ eff >= 8640 ? "16K" : eff >= 4320 ? "8K" : eff >= 2160 ? "4K" : eff >= 1440 ? "2K" : eff >= 1080 ? "1080p" : eff >= 720 ? "720p" : eff >= 480 ? "480p" : "SD";
860
+ if (active?.frameRate)
861
+ stats["Frame Rate"] = `${active.frameRate} fps`;
862
+ const bitrate = active?.videoBandwidth ?? active?.bandwidth;
863
+ stats["Video Bitrate"] = bitrate ? `${(bitrate / 1000).toFixed(0)} kbps` : "N/A";
864
+ }
865
+ if (active?.audioCodec)
866
+ stats["Audio Codec"] = active.audioCodec;
867
+ // --- Decoder / Renderer ---
868
+ if (this.canvasRenderer) {
869
+ const rStats = this.canvasRenderer.getStats();
870
+ stats["Video Decoder"] = "Hardware (Native)";
871
+ stats["Renderer"] = "Canvas";
872
+ stats["Color Space"] = rStats.colorSpace || "N/A";
873
+ }
874
+ else {
875
+ stats["Video Decoder"] = "Hardware (Native)";
876
+ stats["Renderer"] = "HTML5 Video";
877
+ }
878
+ // --- Playback ---
879
+ stats["Playback State"] = this.state;
880
+ stats["Playback Rate"] = `${this.videoElement.playbackRate}x`;
881
+ // --- Frames ---
882
+ if (s) {
883
+ if (typeof s.decodedFrames === "number")
884
+ stats["Frames Decoded"] = s.decodedFrames;
885
+ if (typeof s.droppedFrames === "number")
886
+ stats["Frames Dropped"] = s.droppedFrames;
887
+ }
888
+ if (this.canvasRenderer)
889
+ stats["Frames Rendered"] = this._framesRendered;
890
+ // --- Buffer ---
891
+ if (this.videoElement.buffered.length > 0) {
892
+ const buffEnd = this.videoElement.buffered.end(this.videoElement.buffered.length - 1);
893
+ stats["Buffer Ahead"] = `${(buffEnd - this.videoElement.currentTime).toFixed(1)}s`;
894
+ }
895
+ // --- Stream specific ---
896
+ if (this.videoRenditions.length > 1) {
897
+ const abrOn = this.player?.getConfiguration?.()?.abr?.enabled !== false;
898
+ const activeLabel = active ? `${active.height}p` : "N/A";
899
+ stats["Quality Level"] = abrOn ? `Auto (${activeLabel})` : activeLabel;
900
+ const heights = this.videoRenditions.map((r) => r.height);
901
+ stats["Available Levels"] = `${this.videoRenditions.length} (${Math.min(...heights)}p–${Math.max(...heights)}p)`;
902
+ }
903
+ if (s?.estimatedBandwidth > 0) {
904
+ stats["Bandwidth Estimate"] = `${(s.estimatedBandwidth / 1000).toFixed(0)} kbps`;
905
+ }
906
+ try {
907
+ stats["Stream Type"] = this.player?.isLive?.() ? "Live" : "VOD";
908
+ }
909
+ catch { }
910
+ // Memory usage (Chrome only)
911
+ const mem = performance.memory;
912
+ if (mem) {
913
+ stats["Memory Used"] = `${(mem.usedJSHeapSize / 1048576).toFixed(0)} MB`;
914
+ }
915
+ return stats;
916
+ }
917
+ getNetworkSpeed() {
918
+ // Shaka estimatedBandwidth is in bits/s → bytes/s.
919
+ const tp = this.player?.getStats?.()?.estimatedBandwidth;
920
+ return tp && tp > 0 ? tp / 8 : 0;
921
+ }
922
+ isFileSource() {
923
+ return false;
924
+ }
925
+ destroy() {
926
+ this.stopFrameLoop();
927
+ // Release cached thumbnail sprite sheets.
928
+ for (const p of this.spriteCache.values()) {
929
+ p.then((b) => b?.close()).catch(() => { });
930
+ }
931
+ this.spriteCache.clear();
932
+ this.imageTrackId = null;
933
+ if (this.player) {
934
+ // shaka destroy is async; fire-and-forget (we drop the reference below).
935
+ this.player.destroy().catch(() => {
936
+ /* Shaka can throw if already torn down */
937
+ });
938
+ this.player = null;
939
+ }
940
+ if (this.textContainer?.parentNode) {
941
+ this.textContainer.parentNode.removeChild(this.textContainer);
942
+ }
943
+ this.textContainer = null;
944
+ if (this.textStyle?.parentNode) {
945
+ this.textStyle.parentNode.removeChild(this.textStyle);
946
+ }
947
+ this.textStyle = null;
948
+ this.videoElement.removeAttribute("src");
949
+ this.videoElement.load();
950
+ if (this.videoElement.parentNode) {
951
+ this.videoElement.parentNode.removeChild(this.videoElement);
952
+ }
953
+ this.removeAllListeners();
954
+ }
955
+ }
956
+ //# sourceMappingURL=ShakaPlayerWrapper.js.map