comparison mrjunejune/src/tools/hls_player/hls-player.js @ 242:543df0fe7168

[tools] Add full HLS player support
author MrJuneJune <me@mrjunejune.com>
date Mon, 03 Aug 2026 13:14:41 -0700
parents
children
comparison
equal deleted inserted replaced
241:9c2eec61a152 242:543df0fe7168
1 (function hlsPlayerModule(global) {
2 "use strict";
3
4 const DEFAULT_CODECS = "vp09.00.10.08,opus";
5 const HLS_MIME_TYPES = [
6 "application/vnd.apple.mpegurl",
7 "application/x-mpegURL",
8 ];
9
10 function meaningfulLines(text) {
11 return text
12 .split(/\r?\n/)
13 .map(line => line.trim())
14 .filter(Boolean);
15 }
16
17 function parseAttributeList(value) {
18 const attributes = {};
19 const expression = /([A-Z0-9-]+)=("[^"]*"|[^,]*)/g;
20 let match;
21 while ((match = expression.exec(value)) !== null) {
22 const rawValue = match[2];
23 attributes[match[1]] = rawValue.startsWith('"')
24 ? rawValue.slice(1, -1)
25 : rawValue;
26 }
27 return attributes;
28 }
29
30 function resolveUri(value, baseUrl) {
31 return new URL(value, baseUrl).href;
32 }
33
34 function normalizePlaylistUrl(value, baseUrl) {
35 const url = new URL(value, baseUrl);
36 if (url.protocol !== "http:" &&
37 url.protocol !== "https:" &&
38 url.protocol !== "blob:") {
39 throw new Error("HLS playlist URL must use HTTP, HTTPS, or a local file.");
40 }
41 return url.href;
42 }
43
44 function normalizeLocalPath(path) {
45 const output = [];
46 for (const part of path.replace(/\\/g, "/").split("/")) {
47 if (!part || part === ".") continue;
48 if (part === "..") {
49 if (!output.length) throw new Error("Local playlist path escapes its selected folder.");
50 output.pop();
51 } else {
52 output.push(part);
53 }
54 }
55 return output.join("/");
56 }
57
58 function localRelativePath(currentPath, reference) {
59 const cleanReference = reference.split(/[?#]/)[0];
60 if (/^https?:\/\//i.test(cleanReference) ||
61 cleanReference.startsWith("blob:")) {
62 return null;
63 }
64 const slash = currentPath.lastIndexOf("/");
65 const rootRelative = cleanReference.startsWith("/");
66 const directory = !rootRelative && slash >= 0
67 ? currentPath.slice(0, slash + 1)
68 : "";
69 return normalizeLocalPath(
70 `${directory}${decodeURIComponent(cleanReference).replace(/^\/+/, "")}`,
71 );
72 }
73
74 async function rewritePlaylistUris(text, resolveReference) {
75 const output = [];
76 for (const originalLine of text.split(/\r?\n/)) {
77 const line = originalLine.trim();
78 if (line.startsWith("#")) {
79 let rewrittenLine = originalLine;
80 const references = [
81 ...originalLine.matchAll(/URI="([^"]+)"/g),
82 ].map(match => match[1]);
83 for (const reference of references) {
84 rewrittenLine = rewrittenLine.replace(
85 `URI="${reference}"`,
86 `URI="${await resolveReference(reference)}"`,
87 );
88 }
89 output.push(rewrittenLine);
90 } else if (line && !line.startsWith("#")) {
91 output.push(await resolveReference(line));
92 } else {
93 output.push(originalLine);
94 }
95 }
96 return output.join("\n");
97 }
98
99 async function createLocalHlsUrl(fileList) {
100 const files = Array.from(fileList || []);
101 if (!files.length) throw new Error("Choose an HLS playlist and its media files.");
102
103 const byPath = new Map();
104 const byName = new Map();
105 for (const file of files) {
106 const relativePath = normalizeLocalPath(file.webkitRelativePath || file.name);
107 byPath.set(relativePath, file);
108 const basename = relativePath.split("/").pop();
109 if (!byName.has(basename)) byName.set(basename, file);
110 else byName.set(basename, null);
111 }
112
113 const playlists = [...byPath.entries()].filter(([filePath]) =>
114 filePath.toLowerCase().endsWith(".m3u8")
115 );
116 if (!playlists.length) throw new Error("No .m3u8 playlist was selected.");
117 const master =
118 playlists.find(([filePath]) => /(^|\/)master\.m3u8$/i.test(filePath)) ||
119 playlists.find(([filePath]) => /stream\.m3u8$/i.test(filePath)) ||
120 playlists[0];
121
122 const objectUrls = [];
123 const assetUrls = new Map();
124 const playlistUrls = new Map();
125 const resolving = new Set();
126
127 const rememberUrl = blob => {
128 const url = URL.createObjectURL(blob);
129 objectUrls.push(url);
130 return url;
131 };
132
133 const findFile = (reference, currentPath) => {
134 const relativePath = localRelativePath(currentPath, reference);
135 if (relativePath === null) return null;
136 const direct = byPath.get(relativePath);
137 if (direct) return [relativePath, direct];
138 const basename = relativePath.split("/").pop();
139 const unique = byName.get(basename);
140 if (unique) {
141 const uniquePath = [...byPath.entries()].find(([, file]) => file === unique)[0];
142 return [uniquePath, unique];
143 }
144 throw new Error(`Local HLS file is missing: ${reference}`);
145 };
146
147 const materializePlaylist = async (filePath, file) => {
148 if (playlistUrls.has(filePath)) return playlistUrls.get(filePath);
149 if (resolving.has(filePath)) throw new Error("Local HLS playlists contain a cycle.");
150 resolving.add(filePath);
151 try {
152 const rewritten = await rewritePlaylistUris(
153 await file.text(),
154 async reference => {
155 if (/^https?:\/\//i.test(reference) ||
156 reference.startsWith("blob:")) return reference;
157 const [resolvedPath, resolvedFile] = findFile(reference, filePath);
158 if (resolvedPath.toLowerCase().endsWith(".m3u8")) {
159 return materializePlaylist(resolvedPath, resolvedFile);
160 }
161 if (!assetUrls.has(resolvedPath)) {
162 assetUrls.set(resolvedPath, rememberUrl(resolvedFile));
163 }
164 return assetUrls.get(resolvedPath);
165 },
166 );
167 const url = rememberUrl(new Blob(
168 [rewritten],
169 { type: "application/vnd.apple.mpegurl" },
170 ));
171 playlistUrls.set(filePath, url);
172 return url;
173 } finally {
174 resolving.delete(filePath);
175 }
176 };
177
178 try {
179 const url = await materializePlaylist(master[0], master[1]);
180 return {
181 url,
182 playlistName: master[0],
183 revoke() {
184 for (const objectUrl of objectUrls) URL.revokeObjectURL(objectUrl);
185 objectUrls.length = 0;
186 },
187 };
188 } catch (error) {
189 for (const objectUrl of objectUrls) URL.revokeObjectURL(objectUrl);
190 throw error;
191 }
192 }
193
194 function parseMasterPlaylist(text, baseUrl) {
195 const lines = meaningfulLines(text);
196 const variants = [];
197 for (let index = 0; index < lines.length; index++) {
198 if (!lines[index].startsWith("#EXT-X-STREAM-INF:")) continue;
199 const attributes = parseAttributeList(
200 lines[index].slice("#EXT-X-STREAM-INF:".length),
201 );
202 const uri = lines[index + 1];
203 if (!uri || uri.startsWith("#")) {
204 throw new Error("HLS variant is missing its playlist URL.");
205 }
206 variants.push({
207 url: resolveUri(uri, baseUrl),
208 bandwidth: Number(attributes.BANDWIDTH || 0),
209 codecs: attributes.CODECS || "",
210 resolution: attributes.RESOLUTION || "",
211 });
212 index++;
213 }
214 return variants;
215 }
216
217 function parseMediaPlaylist(text, baseUrl) {
218 const lines = meaningfulLines(text);
219 const segments = [];
220 let initSegment = null;
221 let duration = null;
222 let totalDuration = 0;
223 let endList = false;
224
225 for (const line of lines) {
226 if (line.startsWith("#EXT-X-KEY:")) {
227 const attributes = parseAttributeList(line.slice("#EXT-X-KEY:".length));
228 if ((attributes.METHOD || "NONE") !== "NONE") {
229 throw new Error("Encrypted HLS playlists are not supported by the JavaScript fallback.");
230 }
231 } else if (line.startsWith("#EXT-X-BYTERANGE")) {
232 throw new Error("Byte-range HLS playlists are not supported by the JavaScript fallback.");
233 } else if (line.startsWith("#EXT-X-MAP:")) {
234 const attributes = parseAttributeList(line.slice("#EXT-X-MAP:".length));
235 if (!attributes.URI) throw new Error("HLS initialization segment URL is missing.");
236 initSegment = resolveUri(attributes.URI, baseUrl);
237 } else if (line.startsWith("#EXTINF:")) {
238 duration = Number(line.slice("#EXTINF:".length).split(",")[0]);
239 if (!Number.isFinite(duration)) throw new Error("Invalid HLS segment duration.");
240 } else if (line === "#EXT-X-ENDLIST") {
241 endList = true;
242 } else if (!line.startsWith("#")) {
243 if (duration === null) continue;
244 segments.push({
245 url: resolveUri(line, baseUrl),
246 duration,
247 });
248 totalDuration += duration;
249 duration = null;
250 }
251 }
252
253 return { initSegment, segments, totalDuration, endList };
254 }
255
256 function chooseVariant(variants, isSupported = () => true) {
257 const supported = variants.filter(isSupported);
258 if (!supported.length) return null;
259 return supported.reduce((best, current) =>
260 current.bandwidth > best.bandwidth ? current : best
261 );
262 }
263
264 function once(target, eventName, errorName) {
265 return new Promise((resolve, reject) => {
266 const cleanup = () => {
267 target.removeEventListener(eventName, onEvent);
268 if (errorName) target.removeEventListener(errorName, onError);
269 };
270 const onEvent = event => {
271 cleanup();
272 resolve(event);
273 };
274 const onError = () => {
275 cleanup();
276 reject(new Error(`Media event failed: ${errorName}`));
277 };
278 target.addEventListener(eventName, onEvent, { once: true });
279 if (errorName) target.addEventListener(errorName, onError, { once: true });
280 });
281 }
282
283 async function appendBuffer(sourceBuffer, bytes) {
284 sourceBuffer.appendBuffer(bytes);
285 await once(sourceBuffer, "updateend", "error");
286 }
287
288 class HlsPlayer {
289 constructor(video, options = {}) {
290 if (!video) throw new Error("A video element is required.");
291 this.video = video;
292 this.statusElement = options.statusElement || null;
293 this.detailsElement = options.detailsElement || null;
294 this.abortController = null;
295 this.mediaSource = null;
296 this.objectUrl = null;
297 this.hls = null;
298 this.generation = 0;
299 }
300
301 setStatus(message, state = "loading") {
302 if (!this.statusElement) return;
303 this.statusElement.textContent = message;
304 this.statusElement.dataset.state = state;
305 }
306
307 setDetails(details) {
308 if (!this.detailsElement) return;
309 for (const [key, value] of Object.entries(details)) {
310 const target = this.detailsElement.querySelector(`[data-detail="${key}"]`);
311 if (target) target.textContent = String(value);
312 }
313 this.detailsElement.hidden = false;
314 }
315
316 destroy() {
317 this.generation++;
318 if (this.abortController) this.abortController.abort();
319 this.abortController = null;
320 if (this.hls) this.hls.destroy();
321 this.hls = null;
322 this.video.pause();
323 this.video.removeAttribute("src");
324 this.video.load();
325 if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
326 this.objectUrl = null;
327 this.mediaSource = null;
328 }
329
330 async fetchText(url, signal) {
331 const response = await fetch(url, { signal, cache: "no-store" });
332 if (!response.ok) {
333 throw new Error(`Playlist request failed (${response.status}).`);
334 }
335 return response.text();
336 }
337
338 async fetchBytes(url, signal) {
339 const response = await fetch(url, { signal, cache: "no-store" });
340 if (!response.ok) {
341 throw new Error(`Media request failed (${response.status}): ${url}`);
342 }
343 return response.arrayBuffer();
344 }
345
346 nativeHlsSupported() {
347 return HLS_MIME_TYPES.some(type => this.video.canPlayType(type) !== "");
348 }
349
350 hlsJsSupported() {
351 return Boolean(global.Hls && global.Hls.isSupported());
352 }
353
354 async loadWithHlsJs(playlistUrl, generation, signal) {
355 const Hls = global.Hls;
356 const hls = new Hls({ enableWorker: false });
357 this.hls = hls;
358 this.setStatus("Loading HLS manifest...");
359
360 const manifest = await new Promise((resolve, reject) => {
361 let manifestData = null;
362 let mediaReady = this.video.readyState >= 2;
363 let settled = false;
364
365 const cleanup = () => {
366 hls.off(Hls.Events.MEDIA_ATTACHED, onMediaAttached);
367 hls.off(Hls.Events.MANIFEST_PARSED, onManifestParsed);
368 this.video.removeEventListener("loadeddata", onLoadedData);
369 this.video.removeEventListener("error", onMediaError);
370 signal.removeEventListener("abort", onAbort);
371 };
372 const finish = (callback, value) => {
373 if (settled) return;
374 settled = true;
375 cleanup();
376 callback(value);
377 };
378 const maybeResolve = () => {
379 if (manifestData && mediaReady) finish(resolve, manifestData);
380 };
381 const onMediaAttached = () => hls.loadSource(playlistUrl);
382 const onManifestParsed = (_event, data) => {
383 manifestData = data;
384 maybeResolve();
385 };
386 const onLoadedData = () => {
387 mediaReady = true;
388 maybeResolve();
389 };
390 const onMediaError = () => {
391 finish(reject, new Error("The browser could not decode this HLS stream."));
392 };
393 const onHlsError = (_event, data) => {
394 if (!data.fatal) return;
395 const reason = data.error?.message ||
396 data.reason ||
397 data.details ||
398 data.type ||
399 "unknown error";
400 const error = new Error(`HLS playback failed: ${reason}`);
401 if (!settled) {
402 finish(reject, error);
403 return;
404 }
405 if (generation === this.generation && this.hls === hls) {
406 hls.destroy();
407 this.hls = null;
408 this.setStatus(error.message, "error");
409 }
410 };
411 const onAbort = () => {
412 const error = new Error("HLS loading was cancelled.");
413 error.name = "AbortError";
414 finish(reject, error);
415 };
416
417 hls.on(Hls.Events.MEDIA_ATTACHED, onMediaAttached);
418 hls.on(Hls.Events.MANIFEST_PARSED, onManifestParsed);
419 hls.on(Hls.Events.ERROR, onHlsError);
420 this.video.addEventListener("loadeddata", onLoadedData);
421 this.video.addEventListener("error", onMediaError);
422 signal.addEventListener("abort", onAbort, { once: true });
423 hls.attachMedia(this.video);
424 });
425
426 if (generation !== this.generation || signal.aborted) return;
427 const selectedLevelIndex = hls.currentLevel >= 0
428 ? hls.currentLevel
429 : hls.loadLevel;
430 const selectedLevel = hls.levels[selectedLevelIndex] || hls.levels[0] || {};
431 const codecs = [
432 selectedLevel.videoCodec,
433 selectedLevel.audioCodec,
434 ].filter(Boolean).join(", ") || "Detected from stream";
435 const live = !Number.isFinite(this.video.duration);
436
437 if (!live && this.video.duration > 0 && this.video.seekable.length) {
438 this.video.currentTime = Math.min(0.05, this.video.duration);
439 await once(this.video, "seeked", "error");
440 }
441 if (generation !== this.generation || signal.aborted) return;
442
443 this.setDetails({
444 mode: "hls.js",
445 segments: manifest.levels?.length > 1
446 ? `${manifest.levels.length} adaptive levels`
447 : "Managed by hls.js",
448 duration: live ? "Live" : `${this.video.duration.toFixed(1)} seconds`,
449 codecs,
450 });
451 this.setStatus(
452 live ? "Live stream ready. Press play." : "Stream ready. Press play.",
453 "ready",
454 );
455 }
456
457 async load(inputUrl, options = {}) {
458 this.destroy();
459 const generation = this.generation;
460 this.abortController = new AbortController();
461 const signal = this.abortController.signal;
462 try {
463 const playlistUrl = normalizePlaylistUrl(
464 inputUrl,
465 global.location?.href || "http://localhost/",
466 );
467 this.setStatus("Loading playlist...");
468 if (this.nativeHlsSupported() && !options.forceMediaSource) {
469 this.video.src = playlistUrl;
470 await once(this.video, "loadedmetadata", "error");
471 if (generation !== this.generation || signal.aborted) return;
472 this.setDetails({
473 mode: "Native HLS",
474 segments: "Managed by browser",
475 duration: Number.isFinite(this.video.duration)
476 ? `${this.video.duration.toFixed(1)} seconds`
477 : "Live",
478 codecs: "Managed by browser",
479 });
480 this.setStatus("Stream ready.", "ready");
481 return;
482 }
483
484 if (this.hlsJsSupported()) {
485 await this.loadWithHlsJs(playlistUrl, generation, signal);
486 return;
487 }
488
489 if (!global.MediaSource) {
490 throw new Error("This browser does not support native HLS or MediaSource playback.");
491 }
492
493 let mediaPlaylistUrl = playlistUrl;
494 let codecs = "";
495 let playlistText = await this.fetchText(mediaPlaylistUrl, signal);
496 const variants = parseMasterPlaylist(playlistText, mediaPlaylistUrl);
497 if (variants.length) {
498 const variant = chooseVariant(variants, candidate => {
499 const candidateCodecs = candidate.codecs || DEFAULT_CODECS;
500 return global.MediaSource.isTypeSupported(
501 `video/mp4; codecs="${candidateCodecs}"`,
502 );
503 });
504 if (!variant) {
505 throw new Error("No HLS variant uses a codec supported by this browser.");
506 }
507 mediaPlaylistUrl = variant.url;
508 codecs = variant.codecs;
509 this.setStatus(`Loading ${variant.bandwidth || "selected"} bps variant...`);
510 playlistText = await this.fetchText(mediaPlaylistUrl, signal);
511 }
512
513 const playlist = parseMediaPlaylist(playlistText, mediaPlaylistUrl);
514 if (!playlist.endList) {
515 throw new Error("The JavaScript fallback currently supports VOD playlists only.");
516 }
517 if (!playlist.initSegment || !playlist.segments.length) {
518 throw new Error("The JavaScript fallback requires an fMP4 playlist with EXT-X-MAP.");
519 }
520
521 codecs = codecs || DEFAULT_CODECS;
522 const mimeType = `video/mp4; codecs="${codecs}"`;
523 if (!global.MediaSource.isTypeSupported(mimeType)) {
524 throw new Error(`Browser does not support ${mimeType}.`);
525 }
526
527 this.mediaSource = new global.MediaSource();
528 this.objectUrl = URL.createObjectURL(this.mediaSource);
529 this.video.src = this.objectUrl;
530 await once(this.mediaSource, "sourceopen");
531 if (generation !== this.generation) return;
532
533 const sourceBuffer = this.mediaSource.addSourceBuffer(mimeType);
534 sourceBuffer.mode = "segments";
535 const initBytes = await this.fetchBytes(playlist.initSegment, signal);
536 if (generation !== this.generation || signal.aborted) return;
537 await appendBuffer(sourceBuffer, initBytes);
538
539 for (let index = 0; index < playlist.segments.length; index++) {
540 this.setStatus(`Loading segment ${index + 1} of ${playlist.segments.length}...`);
541 const segmentBytes = await this.fetchBytes(
542 playlist.segments[index].url,
543 signal,
544 );
545 if (generation !== this.generation || signal.aborted) return;
546 await appendBuffer(sourceBuffer, segmentBytes);
547 }
548
549 if (generation !== this.generation) return;
550 this.mediaSource.endOfStream();
551 this.video.currentTime = Math.min(0.05, playlist.totalDuration);
552 await once(this.video, "seeked", "error");
553 if (generation !== this.generation || signal.aborted) return;
554 this.setDetails({
555 mode: "JavaScript MediaSource",
556 segments: playlist.segments.length,
557 duration: `${playlist.totalDuration.toFixed(1)} seconds`,
558 codecs,
559 });
560 this.setStatus("Stream ready. Press play.", "ready");
561 } catch (error) {
562 if (error.name === "AbortError" || generation !== this.generation) return;
563 if (this.hls) {
564 this.hls.destroy();
565 this.hls = null;
566 }
567 this.setStatus(error.message || "Unable to load HLS stream.", "error");
568 throw error;
569 }
570 }
571 }
572
573 const exportsObject = {
574 HlsPlayer,
575 chooseVariant,
576 parseAttributeList,
577 parseMasterPlaylist,
578 parseMediaPlaylist,
579 normalizePlaylistUrl,
580 createLocalHlsUrl,
581 localRelativePath,
582 rewritePlaylistUris,
583 };
584
585 if (typeof module !== "undefined" && module.exports) {
586 module.exports = exportsObject;
587 }
588 global.HlsPlayerModule = exportsObject;
589
590 if (global.document) {
591 global.addEventListener("DOMContentLoaded", () => {
592 const form = document.querySelector("#hlsForm");
593 const input = document.querySelector("#hlsUrl");
594 const video = document.querySelector("#hlsVideo");
595 const status = document.querySelector("#hlsStatus");
596 const details = document.querySelector("#hlsDetails");
597 const sampleButton = document.querySelector("#sampleButton");
598 const fileInputs = [
599 document.querySelector("#hlsFiles"),
600 document.querySelector("#hlsFolder"),
601 ].filter(Boolean);
602 if (!form || !input || !video) return;
603
604 const player = new HlsPlayer(video, {
605 statusElement: status,
606 detailsElement: details,
607 });
608 global.hlsPlayer = player;
609 let localSelection = null;
610 let localLoadGeneration = 0;
611
612 const load = () => {
613 localLoadGeneration++;
614 if (localSelection) {
615 localSelection.revoke();
616 localSelection = null;
617 }
618 const value = input.value.trim();
619 if (!value) return;
620 const url = new URL(global.location.href);
621 url.searchParams.set("url", value);
622 global.history.replaceState({}, "", url);
623 player.load(value).catch(() => {});
624 };
625
626 const loadLocal = async files => {
627 const generation = ++localLoadGeneration;
628 if (localSelection) {
629 localSelection.revoke();
630 localSelection = null;
631 }
632 let selection = null;
633 try {
634 selection = await createLocalHlsUrl(files);
635 if (generation !== localLoadGeneration) {
636 selection.revoke();
637 return;
638 }
639 localSelection = selection;
640 input.value = `Local: ${selection.playlistName}`;
641 player.setStatus(`Loading local playlist ${selection.playlistName}...`);
642 await player.load(selection.url, { forceMediaSource: true });
643 if (generation !== localLoadGeneration) return;
644 } catch (error) {
645 if (selection) selection.revoke();
646 if (localSelection === selection) localSelection = null;
647 if (generation !== localLoadGeneration) return;
648 player.setStatus(error.message || "Unable to load local HLS files.", "error");
649 }
650 };
651
652 form.addEventListener("submit", event => {
653 event.preventDefault();
654 load();
655 });
656 sampleButton.addEventListener("click", () => {
657 input.value = "/public/hls-sample/h264-ts-stream.m3u8";
658 load();
659 });
660 for (const fileInput of fileInputs) {
661 fileInput.addEventListener("change", () => {
662 if (fileInput.files?.length) loadLocal(fileInput.files);
663 fileInput.value = "";
664 });
665 }
666
667 const requestedUrl = new URL(global.location.href).searchParams.get("url");
668 if (requestedUrl) input.value = requestedUrl;
669 load();
670 });
671 }
672 })(typeof window !== "undefined" ? window : globalThis);