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.
- package/AGENTS.md +2 -0
- package/README.md +54 -7
- package/dist/core/MoviPlayer.d.ts +77 -1
- package/dist/core/MoviPlayer.d.ts.map +1 -1
- package/dist/core/MoviPlayer.js +667 -121
- package/dist/core/MoviPlayer.js.map +1 -1
- package/dist/core/PlayerState.js +1 -1
- package/dist/core/PlayerState.js.map +1 -1
- package/dist/decode/VideoDecoder.d.ts.map +1 -1
- package/dist/decode/VideoDecoder.js +6 -2
- package/dist/decode/VideoDecoder.js.map +1 -1
- package/dist/demuxer.cjs +315 -129
- package/dist/demuxer.js +377 -191
- package/dist/element.cjs +58309 -16596
- package/dist/element.js +59520 -17807
- package/dist/index.cjs +58309 -16596
- package/dist/index.js +59520 -17807
- package/dist/player.cjs +56818 -15360
- package/dist/player.js +57818 -16360
- package/dist/render/DASHPlayerWrapper.d.ts +65 -0
- package/dist/render/DASHPlayerWrapper.d.ts.map +1 -0
- package/dist/render/DASHPlayerWrapper.js +540 -0
- package/dist/render/DASHPlayerWrapper.js.map +1 -0
- package/dist/render/HLSPlayerWrapper.d.ts.map +1 -1
- package/dist/render/HLSPlayerWrapper.js +47 -11
- package/dist/render/HLSPlayerWrapper.js.map +1 -1
- package/dist/render/MoviElement.d.ts +97 -0
- package/dist/render/MoviElement.d.ts.map +1 -1
- package/dist/render/MoviElement.js +852 -152
- package/dist/render/MoviElement.js.map +1 -1
- package/dist/render/ShakaPlayerWrapper.d.ts +111 -0
- package/dist/render/ShakaPlayerWrapper.d.ts.map +1 -0
- package/dist/render/ShakaPlayerWrapper.js +956 -0
- package/dist/render/ShakaPlayerWrapper.js.map +1 -0
- package/dist/source/DashFallback.d.ts +32 -0
- package/dist/source/DashFallback.d.ts.map +1 -0
- package/dist/source/DashFallback.js +142 -0
- package/dist/source/DashFallback.js.map +1 -0
- package/dist/source/DashManifest.d.ts +34 -0
- package/dist/source/DashManifest.d.ts.map +1 -0
- package/dist/source/DashManifest.js +194 -0
- package/dist/source/DashManifest.js.map +1 -0
- package/dist/source/DashSegmentSource.d.ts +32 -0
- package/dist/source/DashSegmentSource.d.ts.map +1 -0
- package/dist/source/DashSegmentSource.js +86 -0
- package/dist/source/DashSegmentSource.js.map +1 -0
- package/dist/source/HttpSource.d.ts +64 -0
- package/dist/source/HttpSource.d.ts.map +1 -1
- package/dist/source/HttpSource.js +444 -57
- package/dist/source/HttpSource.js.map +1 -1
- package/dist/source/ThumbnailHttpSource.d.ts +13 -0
- package/dist/source/ThumbnailHttpSource.d.ts.map +1 -1
- package/dist/source/ThumbnailHttpSource.js +70 -8
- package/dist/source/ThumbnailHttpSource.js.map +1 -1
- package/dist/source/index.d.ts +2 -0
- package/dist/source/index.d.ts.map +1 -1
- package/dist/source/index.js +1 -0
- package/dist/source/index.js.map +1 -1
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -1
- 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
|