UNPKG

hls-video-element

Version:

Custom element (web component) for playing video using the HTTP Live Streaming (HLS) format. Uses HLS.js.

237 lines (236 loc) 8.91 kB
import { CustomVideoElement } from "custom-media-element"; import { MediaTracksMixin } from "media-tracks"; import Hls from "hls.js/dist/hls.mjs"; const HlsVideoMixin = (superclass) => { return class HlsVideo extends superclass { static shadowRootOptions = { ...superclass.shadowRootOptions }; static getTemplateHTML = (attrs, props = {}) => { const { src, ...rest } = attrs; return ` <script type="application/json" id="config"> ${JSON.stringify(props.config || {})} </script> ${superclass.getTemplateHTML(rest)} `; }; #airplaySourceEl = null; #config = null; constructor() { super(); this.#upgradeProperty("config"); } get config() { return this.#config; } set config(value) { this.#config = value; } attributeChangedCallback(attrName, oldValue, newValue) { if (attrName !== "src") { super.attributeChangedCallback(attrName, oldValue, newValue); } if (attrName === "src" && oldValue != newValue) { this.load(); } } #destroy() { var _a, _b; (_a = this.#airplaySourceEl) == null ? void 0 : _a.remove(); (_b = this.nativeEl) == null ? void 0 : _b.removeEventListener( "webkitcurrentplaybacktargetiswirelesschanged", this.#toggleHlsLoad ); if (this.api) { this.api.detachMedia(); this.api.destroy(); this.api = null; } } async load() { var _a, _b; const isFirstLoad = !this.api; this.#destroy(); if (!this.src) { return; } if (isFirstLoad && !this.#config) { this.#config = JSON.parse(((_a = this.shadowRoot.getElementById("config")) == null ? void 0 : _a.textContent) || "{}"); } if (Hls.isSupported()) { await Promise.resolve(); this.api = new Hls({ // Mimic the media element with an Infinity duration for live streams. liveDurationInfinity: true, // Disable auto quality level/fragment loading. autoStartLoad: false, // Custom configuration for hls.js. ...this.config }); this.api.loadSource(this.src); this.api.attachMedia(this.nativeEl); switch (this.nativeEl.preload) { case "none": { const loadSourceOnPlay = () => this.api.startLoad(); this.nativeEl.addEventListener("play", loadSourceOnPlay, { once: true }); this.api.on(Hls.Events.DESTROYING, () => { this.nativeEl.removeEventListener("play", loadSourceOnPlay); }); break; } case "metadata": { const originalLength = this.api.config.maxBufferLength; const originalSize = this.api.config.maxBufferSize; this.api.config.maxBufferLength = 1; this.api.config.maxBufferSize = 1; const increaseBufferOnPlay = () => { this.api.config.maxBufferLength = originalLength; this.api.config.maxBufferSize = originalSize; }; this.nativeEl.addEventListener("play", increaseBufferOnPlay, { once: true }); this.api.on(Hls.Events.DESTROYING, () => { this.nativeEl.removeEventListener("play", increaseBufferOnPlay); }); this.api.startLoad(); break; } default: this.api.startLoad(); } if (this.nativeEl.webkitCurrentPlaybackTargetIsWireless) { this.api.stopLoad(); } this.nativeEl.addEventListener( "webkitcurrentplaybacktargetiswirelesschanged", this.#toggleHlsLoad ); this.#airplaySourceEl = document.createElement("source"); this.#airplaySourceEl.setAttribute("type", "application/x-mpegURL"); this.#airplaySourceEl.setAttribute("src", this.src); this.nativeEl.disableRemotePlayback = false; this.nativeEl.append(this.#airplaySourceEl); const levelIdMap = /* @__PURE__ */ new WeakMap(); this.api.on(Hls.Events.MANIFEST_PARSED, (event, data) => { if (this.nativeEl.autoplay && this.nativeEl.paused) { this.nativeEl.play().catch((err) => { console.warn("Autoplay failed:", err); }); } removeAllMediaTracks(); let videoTrack = this.videoTracks.getTrackById("main"); if (!videoTrack) { videoTrack = this.addVideoTrack("main"); videoTrack.id = "main"; videoTrack.selected = true; } for (const [id, level] of data.levels.entries()) { const videoRendition = videoTrack.addRendition( level.url[0], level.width, level.height, level.videoCodec, level.bitrate ); levelIdMap.set(level, `${id}`); videoRendition.id = `${id}`; } for (let [id, a] of data.audioTracks.entries()) { const kind = a.default ? "main" : "alternative"; const audioTrack = this.addAudioTrack(kind, a.name, a.lang); audioTrack.id = `${id}`; if (a.default) { audioTrack.enabled = true; } } }); this.audioTracks.addEventListener("change", () => { var _a2; const audioTrackId = +((_a2 = [...this.audioTracks].find((t) => t.enabled)) == null ? void 0 : _a2.id); const availableIds = this.api.audioTracks.map((t) => t.id); if (audioTrackId != this.api.audioTrack && availableIds.includes(audioTrackId)) { this.api.audioTrack = audioTrackId; } }); this.api.on(Hls.Events.LEVELS_UPDATED, (event, data) => { const videoTrack = this.videoTracks[this.videoTracks.selectedIndex ?? 0]; if (!videoTrack) return; const levelIds = data.levels.map((l) => levelIdMap.get(l)); for (const rendition of this.videoRenditions) { if (rendition.id && !levelIds.includes(rendition.id)) { videoTrack.removeRendition(rendition); } } }); let lastFailedLevel = null; this.api.on(Hls.Events.ERROR, (event, data) => { if (data.type === Hls.ErrorTypes.NETWORK_ERROR && data.details === Hls.ErrorDetails.FRAG_LOAD_ERROR) { lastFailedLevel = data.frag.level; } }); this.api.on(Hls.Events.LEVEL_SWITCHED, (event, data) => { const newLevel = data.level; if (lastFailedLevel !== null && newLevel < lastFailedLevel) { console.warn( `\u26A0\uFE0F hls.js downgraded quality from level ${lastFailedLevel} to ${newLevel} due to fragment load failure.` ); this.videoRenditions.selectedIndex = newLevel; lastFailedLevel = null; } }); const switchRendition = (event) => { const level = event.target.selectedIndex; if (level != this.api.nextLevel) { this.api.nextLevel = level; } }; (_b = this.videoRenditions) == null ? void 0 : _b.addEventListener("change", switchRendition); const removeAllMediaTracks = () => { for (const videoTrack of this.videoTracks) { this.removeVideoTrack(videoTrack); } for (const audioTrack of this.audioTracks) { this.removeAudioTrack(audioTrack); } }; this.api.once(Hls.Events.DESTROYING, removeAllMediaTracks); return; } await Promise.resolve(); if (this.nativeEl.canPlayType("application/vnd.apple.mpegurl")) { this.nativeEl.src = this.src; } } #toggleHlsLoad = () => { var _a, _b, _c; if ((_a = this.nativeEl) == null ? void 0 : _a.webkitCurrentPlaybackTargetIsWireless) { (_b = this.api) == null ? void 0 : _b.stopLoad(); } else { (_c = this.api) == null ? void 0 : _c.startLoad(); } }; // This is a pattern to update property values that are set before // the custom element is upgraded. // https://web.dev/custom-elements-best-practices/#make-properties-lazy #upgradeProperty(prop) { if (Object.prototype.hasOwnProperty.call(this, prop)) { const value = this[prop]; delete this[prop]; this[prop] = value; } } }; }; const HlsVideoElement = HlsVideoMixin(MediaTracksMixin(CustomVideoElement)); if (globalThis.customElements && !globalThis.customElements.get("hls-video")) { globalThis.customElements.define("hls-video", HlsVideoElement); } var hls_video_element_default = HlsVideoElement; export { Hls, HlsVideoElement, HlsVideoMixin, hls_video_element_default as default };