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
JavaScript
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
};