Mercurial
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); |