@livepeer/core-web
Version:
Livepeer UI Kit's core web library, for adding reactive stores to video elements.
1,497 lines (1,486 loc) • 50.8 kB
JavaScript
// src/media/controls/controller.ts
import {
ACCESS_CONTROL_ERROR_MESSAGE,
BFRAMES_ERROR_MESSAGE,
STREAM_OFFLINE_ERROR_MESSAGE
} from "@livepeer/core/errors";
import { warn } from "@livepeer/core/utils";
// src/hls/hls.ts
import {
calculateVideoQualityDimensions
} from "@livepeer/core/media";
import Hls from "hls.js";
// src/media/utils.ts
import { noop } from "@livepeer/core/utils";
var isClient = () => typeof window !== "undefined";
var ua = () => isClient() ? window?.navigator?.userAgent?.toLowerCase() : "";
var isIos = () => /iphone|ipad|ipod|ios|CriOS|FxiOS/.test(ua());
var isAndroid = () => /android/.test(ua());
var isMobile = () => isClient() && (isIos() || isAndroid());
var canPlayMediaNatively = (src) => {
if (isClient() && src?.mime) {
if (src?.type?.includes("audio")) {
const audio = document.createElement("audio");
return audio.canPlayType(src.mime).length > 0;
}
const video = document.createElement("video");
return video.canPlayType(src.mime).length > 0;
}
return true;
};
// src/hls/hls.ts
var VIDEO_HLS_INITIALIZED_ATTRIBUTE = "data-livepeer-video-hls-initialized";
var isHlsSupported = () => isClient() ? Hls.isSupported() : true;
var createNewHls = ({
source,
element,
callbacks,
aspectRatio,
config,
initialQuality
}) => {
if (element.getAttribute(VIDEO_HLS_INITIALIZED_ATTRIBUTE) === "true") {
return {
setQuality: () => {
},
destroy: () => {
}
};
}
element.setAttribute(VIDEO_HLS_INITIALIZED_ATTRIBUTE, "true");
const hls = new Hls({
backBufferLength: 60 * 1.5,
manifestLoadingMaxRetry: 0,
fragLoadingMaxRetry: 0,
levelLoadingMaxRetry: 0,
appendErrorMaxRetry: 0,
...config,
...config?.liveSyncDurationCount ? {
liveSyncDurationCount: config.liveSyncDurationCount
} : {
liveMaxLatencyDurationCount: 7,
liveSyncDurationCount: 3
}
});
const onDestroy = () => {
hls?.destroy?.();
element?.removeAttribute?.(VIDEO_HLS_INITIALIZED_ATTRIBUTE);
};
if (element) {
hls.attachMedia(element);
}
let redirected = false;
hls.on(Hls.Events.LEVEL_LOADED, async (_e, data) => {
const { live, totalduration: duration, url } = data.details;
if (!redirected) {
callbacks?.onRedirect?.(url ?? null);
redirected = true;
}
callbacks?.onLive?.(Boolean(live));
callbacks?.onDuration?.(duration ?? 0);
});
hls.on(Hls.Events.MEDIA_ATTACHED, async () => {
hls.loadSource(source);
hls.on(Hls.Events.MANIFEST_PARSED, (_event, _data) => {
setQuality({
hls: hls ?? null,
quality: initialQuality,
aspectRatio
});
callbacks?.onCanPlay?.();
if (config.autoPlay) element?.play?.();
});
});
hls.on(Hls.Events.ERROR, async (_event, data) => {
const { details, fatal } = data;
const isManifestParsingError = details === "manifestParsingError";
if (!fatal && !isManifestParsingError) return;
callbacks?.onError?.(data);
if (fatal) {
console.error(`Fatal error : ${data.details}`);
switch (data.type) {
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
break;
case Hls.ErrorTypes.NETWORK_ERROR:
console.error(`A network error occurred: ${data.details}`);
break;
default:
console.error(`An unrecoverable error occurred: ${data.details}`);
hls.destroy();
break;
}
}
});
function updateOffset() {
const currentDate = Date.now();
const newDate = hls.playingDate;
if (newDate && currentDate) {
callbacks?.onPlaybackOffsetUpdated?.(currentDate - newDate.getTime());
}
}
const updateOffsetInterval = setInterval(updateOffset, 2e3);
return {
destroy: () => {
onDestroy?.();
clearInterval?.(updateOffsetInterval);
element?.removeAttribute?.(VIDEO_HLS_INITIALIZED_ATTRIBUTE);
},
setQuality: (videoQuality) => {
setQuality({
hls: hls ?? null,
quality: videoQuality,
aspectRatio
});
}
};
};
var setQuality = ({
hls,
quality,
aspectRatio
}) => {
if (hls) {
const { width } = calculateVideoQualityDimensions(quality, aspectRatio);
if (!width || quality === "auto") {
hls.currentLevel = -1;
return;
}
if (hls.levels && hls.levels.length > 0) {
const sortedLevels = hls.levels.map((level, index) => ({ ...level, index })).sort(
(a, b) => Math.abs((width ?? 0) - a.width) - Math.abs((width ?? 0) - b.width)
);
const bestMatchLevel = sortedLevels?.[0];
if ((bestMatchLevel?.index ?? -1) >= 0) {
hls.currentLevel = bestMatchLevel.index;
} else {
hls.currentLevel = -1;
}
}
}
};
// src/webrtc/shared.ts
import { NOT_ACCEPTABLE_ERROR_MESSAGE } from "@livepeer/core/errors";
var getRTCPeerConnectionConstructor = () => {
if (!isClient()) {
return null;
}
return window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection || null;
};
function createPeerConnection(host, iceServers) {
const RTCPeerConnectionConstructor = getRTCPeerConnectionConstructor();
if (!RTCPeerConnectionConstructor) {
throw new Error("No RTCPeerConnection constructor found in this browser.");
}
const hostNoPort = host?.split(":")[0];
const defaultIceServers = host ? [
{
urls: `stun:${hostNoPort}`
},
{
urls: `turn:${hostNoPort}`,
username: "livepeer",
credential: "livepeer"
}
] : [];
return new RTCPeerConnectionConstructor({
iceServers: iceServers ? Array.isArray(iceServers) ? iceServers : [iceServers] : defaultIceServers
});
}
var DEFAULT_TIMEOUT = 1e4;
async function negotiateConnectionWithClientOffer(peerConnection, endpoint, ofr, controller, accessControl, sdpTimeout) {
if (peerConnection && endpoint && ofr) {
const response = await postSDPOffer(
endpoint,
ofr.sdp,
controller,
accessControl,
sdpTimeout
);
if (response.ok) {
const answerSDP = await response.text();
await peerConnection.setRemoteDescription(
new RTCSessionDescription({ type: "answer", sdp: answerSDP })
);
const playheadUtc = response.headers.get("Playhead-Utc");
return new Date(playheadUtc ?? /* @__PURE__ */ new Date());
}
if (response.status === 406) {
throw new Error(NOT_ACCEPTABLE_ERROR_MESSAGE);
}
const errorMessage = await response.text();
throw new Error(errorMessage);
}
throw new Error("Peer connection not defined.");
}
function preferCodec(sdp, codec) {
const lines = sdp.split("\r\n");
const mLineIndex = lines.findIndex((line) => line.startsWith("m=video"));
if (mLineIndex === -1) return sdp;
const codecRegex = new RegExp(`a=rtpmap:(\\d+) ${codec}(/\\d+)+`);
const codecLine = lines.find((line) => codecRegex.test(line));
if (!codecLine) return sdp;
const codecPayload = codecRegex.exec(codecLine)[1];
const mLineElements = lines[mLineIndex].split(" ");
const reorderedMLine = [
...mLineElements.slice(0, 3),
codecPayload,
...mLineElements.slice(3).filter((payload) => payload !== codecPayload)
];
lines[mLineIndex] = reorderedMLine.join(" ");
return lines.join("\r\n");
}
async function constructClientOffer(peerConnection, endpoint, noIceGathering) {
if (peerConnection && endpoint) {
const originalCreateOffer = peerConnection.createOffer.bind(peerConnection);
peerConnection.createOffer = async function(...args) {
const originalOffer = await originalCreateOffer.apply(this, args);
return new RTCSessionDescription({
// @ts-ignore (TODO: fix this)
type: originalOffer.type,
// @ts-ignore (TODO: fix this)
sdp: preferCodec(originalOffer.sdp, "H264")
});
};
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
if (noIceGathering) {
return peerConnection.localDescription;
}
const ofr = await waitToCompleteICEGathering(peerConnection);
if (!ofr) {
throw Error("failed to gather ICE candidates for offer");
}
return ofr;
}
return null;
}
var playbackIdPattern = /([/+])([^/+?]+)$/;
var REPLACE_PLACEHOLDER = "PLAYBACK_ID";
var MAX_REDIRECT_CACHE_SIZE = 10;
var redirectUrlCache = /* @__PURE__ */ new Map();
function getCachedTemplate(key) {
const cachedItem = redirectUrlCache.get(key);
if (cachedItem) {
redirectUrlCache.delete(key);
redirectUrlCache.set(key, cachedItem);
}
return cachedItem;
}
function setCachedTemplate(key, value) {
if (redirectUrlCache.has(key)) {
redirectUrlCache.delete(key);
} else if (redirectUrlCache.size >= MAX_REDIRECT_CACHE_SIZE) {
const oldestKey = redirectUrlCache.keys().next().value;
if (oldestKey) {
redirectUrlCache.delete(oldestKey);
}
}
redirectUrlCache.set(key, value);
}
async function postSDPOffer(endpoint, data, controller, accessControl, sdpTimeout) {
const id = setTimeout(
() => controller.abort(),
sdpTimeout ?? DEFAULT_TIMEOUT
);
const urlForPost = new URL(endpoint);
const parsedMatches = urlForPost.pathname.match(playbackIdPattern);
const currentPlaybackId = parsedMatches?.[2];
const cachedTemplateUrl = getCachedTemplate(endpoint);
if (cachedTemplateUrl && currentPlaybackId) {
urlForPost.host = cachedTemplateUrl.host;
urlForPost.pathname = cachedTemplateUrl.pathname.replace(
REPLACE_PLACEHOLDER,
currentPlaybackId
);
urlForPost.search = cachedTemplateUrl.search;
}
const response = await fetch(urlForPost.toString(), {
method: "POST",
mode: "cors",
headers: {
"content-type": "application/sdp",
...accessControl?.accessKey ? {
"Livepeer-Access-Key": accessControl.accessKey
} : {},
...accessControl?.jwt ? {
"Livepeer-Jwt": accessControl.jwt
} : {}
},
body: data,
signal: controller.signal
});
clearTimeout(id);
return response;
}
async function getRedirectUrl(endpoint, abortController, timeout) {
try {
const cachedTemplateUrl = getCachedTemplate(endpoint);
if (cachedTemplateUrl) {
const currentIngestUrl = new URL(endpoint);
const matches = currentIngestUrl.pathname.match(playbackIdPattern);
const currentPlaybackId = matches?.[2];
if (currentPlaybackId) {
const finalRedirectUrl = new URL(cachedTemplateUrl);
finalRedirectUrl.pathname = cachedTemplateUrl.pathname.replace(
REPLACE_PLACEHOLDER,
currentPlaybackId
);
return finalRedirectUrl;
}
}
const id = setTimeout(
() => abortController.abort(),
timeout ?? DEFAULT_TIMEOUT
);
const response = await fetch(endpoint, {
method: "HEAD",
signal: abortController.signal
});
await response.text();
clearTimeout(id);
const actualRedirectedUrl = new URL(response.url);
if (actualRedirectedUrl) {
const templateForCache = new URL(actualRedirectedUrl);
templateForCache.pathname = templateForCache.pathname.replace(
playbackIdPattern,
`$1${REPLACE_PLACEHOLDER}`
);
if (!templateForCache.searchParams.has("ingestpb") || templateForCache.searchParams.get("ingestpb") !== "true") {
setCachedTemplate(endpoint, templateForCache);
}
}
return actualRedirectedUrl;
} catch (e) {
return null;
}
}
async function waitToCompleteICEGathering(peerConnection) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(peerConnection.localDescription);
}, 5e3);
peerConnection.onicegatheringstatechange = (_ev) => {
if (peerConnection.iceGatheringState === "complete") {
resolve(peerConnection.localDescription);
}
};
});
}
// src/webrtc/whep.ts
var VIDEO_WEBRTC_INITIALIZED_ATTRIBUTE = "data-livepeer-video-whep-initialized";
var createNewWHEP = ({
source,
element,
callbacks,
accessControl,
sdpTimeout,
iceServers
}) => {
if (element.getAttribute(VIDEO_WEBRTC_INITIALIZED_ATTRIBUTE) === "true") {
return {
destroy: () => {
}
};
}
element.setAttribute(VIDEO_WEBRTC_INITIALIZED_ATTRIBUTE, "true");
let destroyed = false;
const abortController = new AbortController();
let peerConnection = null;
const stream = new MediaStream();
const errorComposed = (e) => {
callbacks?.onError?.(e);
if (element) {
element.srcObject = null;
}
};
getRedirectUrl(source, abortController, sdpTimeout).then((redirectUrl) => {
if (destroyed || !redirectUrl) {
return;
}
const redirectUrlString = redirectUrl.toString();
callbacks?.onRedirect?.(redirectUrlString ?? null);
peerConnection = createPeerConnection(redirectUrl.host, iceServers);
if (peerConnection) {
peerConnection.addTransceiver("video", {
direction: "recvonly"
});
peerConnection.addTransceiver("audio", {
direction: "recvonly"
});
peerConnection.ontrack = (event) => {
if (destroyed) {
return;
}
try {
if (stream) {
const track = event.track;
const currentTracks = stream.getTracks();
const streamAlreadyHasVideoTrack = currentTracks.some(
(track2) => track2.kind === "video"
);
const streamAlreadyHasAudioTrack = currentTracks.some(
(track2) => track2.kind === "audio"
);
switch (track.kind) {
case "video":
if (streamAlreadyHasVideoTrack) {
break;
}
stream.addTrack(track);
break;
case "audio":
if (streamAlreadyHasAudioTrack) {
break;
}
stream.addTrack(track);
break;
default:
console.log(`received unknown track ${track}`);
}
}
} catch (e) {
errorComposed(e);
}
};
peerConnection.addEventListener("connectionstatechange", (_ev) => {
if (destroyed) {
return;
}
try {
if (peerConnection?.connectionState === "failed") {
callbacks?.onError?.(new Error("Failed to connect to peer."));
}
if (peerConnection?.connectionState === "connected" && !element.srcObject) {
element.srcObject = stream;
callbacks?.onConnected?.();
}
} catch (e) {
errorComposed(e);
}
});
peerConnection.addEventListener("negotiationneeded", async (_ev) => {
if (destroyed) {
return;
}
try {
const ofr = await constructClientOffer(
peerConnection,
redirectUrlString
);
if (destroyed) {
return;
}
const response = await negotiateConnectionWithClientOffer(
peerConnection,
source,
ofr,
abortController,
accessControl,
sdpTimeout
);
if (destroyed) {
return;
}
const currentDate = Date.now();
if (response && currentDate) {
callbacks?.onPlaybackOffsetUpdated?.(
currentDate - response.getTime()
);
}
} catch (e) {
errorComposed(e);
}
});
}
}).catch((e) => errorComposed(e));
return {
destroy: () => {
destroyed = true;
abortController?.abort?.();
peerConnection?.close?.();
if (element) {
element.srcObject = null;
}
element?.removeAttribute?.(VIDEO_WEBRTC_INITIALIZED_ATTRIBUTE);
}
};
};
// src/media/controls/fullscreen.ts
var methodsList = [
// modern browsers
{
requestFullscreen: "requestFullscreen",
exitFullscreen: "exitFullscreen",
fullscreenElement: "fullscreenElement",
fullscreenEnabled: "fullscreenEnabled",
fullscreenchange: "fullscreenchange",
fullscreenerror: "fullscreenerror"
},
// new WebKit
{
requestFullscreen: "webkitRequestFullscreen",
exitFullscreen: "webkitExitFullscreen",
fullscreenElement: "webkitFullscreenElement",
fullscreenEnabled: "webkitFullscreenEnabled",
fullscreenchange: "webkitfullscreenchange",
fullscreenerror: "webkitfullscreenerror"
},
// old WebKit
{
requestFullscreen: "webkitRequestFullScreen",
exitFullscreen: "webkitCancelFullScreen",
fullscreenElement: "webkitCurrentFullScreenElement",
fullscreenEnabled: "webkitCancelFullScreen",
fullscreenchange: "webkitfullscreenchange",
fullscreenerror: "webkitfullscreenerror"
},
// old firefox
{
requestFullscreen: "mozRequestFullScreen",
exitFullscreen: "mozCancelFullScreen",
fullscreenElement: "mozFullScreenElement",
fullscreenEnabled: "mozFullScreenEnabled",
fullscreenchange: "mozfullscreenchange",
fullscreenerror: "mozfullscreenerror"
},
// old IE
{
requestFullscreen: "msRequestFullscreen",
exitFullscreen: "msExitFullscreen",
fullscreenElement: "msFullscreenElement",
fullscreenEnabled: "msFullscreenEnabled",
fullscreenchange: "MSFullscreenChange",
fullscreenerror: "MSFullscreenError"
}
];
var iosMethods = {
requestFullscreen: "webkitEnterFullscreen",
exitFullscreen: "webkitExitFullscreen",
fullscreenElement: null,
fullscreenEnabled: "webkitDisplayingFullscreen",
fullscreenchange: "fullscreenchange",
fullscreenerror: "fullscreenerror"
};
var isFullscreenSupported = () => {
if (typeof document === "undefined") {
return true;
}
const videoElement = document.createElement("video");
const result = Boolean(getFullscreenMethods(videoElement));
videoElement.remove();
return result;
};
var isCurrentlyFullscreen = (inputElement) => {
const { methods, element } = getFullscreenMethods(inputElement);
if (methods?.fullscreenElement) {
return Boolean(document[methods.fullscreenElement]);
}
return Boolean(element?.webkitPresentationMode === "fullscreen");
};
var enterFullscreen = (inputElement) => {
const { methods, element } = getFullscreenMethods(inputElement);
if (methods) {
return new Promise((resolve, reject) => {
const fullscreenMethod = methods.requestFullscreen;
const onFullScreen = () => {
removeFullscreenEventListener(inputElement, onFullScreen);
resolve();
};
addFullscreenEventListener(inputElement, onFullScreen);
const returnPromise = methods.fullscreenElement ? element?.parentElement?.[fullscreenMethod]?.() : element?.[fullscreenMethod]?.() ?? null;
if (returnPromise === null) {
return resolve();
}
if (returnPromise instanceof Promise) {
returnPromise.then(onFullScreen).catch(reject);
}
});
}
return false;
};
var exitFullscreen = (inputElement) => {
const { methods, element } = getFullscreenMethods(inputElement);
if (methods) {
return new Promise((resolve, reject) => {
if (!isCurrentlyFullscreen(inputElement)) {
resolve();
return;
}
const onFullScreenExit = () => {
removeFullscreenEventListener(inputElement, onFullScreenExit);
resolve();
};
addFullscreenEventListener(inputElement, onFullScreenExit);
const returnPromise = methods.fullscreenElement ? document?.[methods.exitFullscreen]?.() : element?.[methods.exitFullscreen]?.() ?? null;
if (returnPromise instanceof Promise) {
returnPromise.then(onFullScreenExit).catch(reject);
}
});
}
return false;
};
var addFullscreenEventListener = (inputElement, callback) => {
const { methods, element } = getFullscreenMethods(inputElement);
if (methods && element) {
const parentElementOrElement = element?.parentElement ?? element;
parentElementOrElement?.addEventListener(
methods.fullscreenchange,
callback,
false
);
return () => {
removeFullscreenEventListener(inputElement, callback);
};
}
return null;
};
var removeFullscreenEventListener = (inputElement, callback) => {
const { methods, element } = getFullscreenMethods(inputElement);
if (methods && element) {
const parentElementOrElement = element?.parentElement ?? element;
parentElementOrElement?.removeEventListener(
methods.fullscreenchange,
callback,
false
);
return true;
}
return false;
};
var getFullscreenMethods = (element) => {
if (isClient()) {
for (const methods of methodsList) {
const exitFullscreenMethod = methods.exitFullscreen;
if (exitFullscreenMethod in document) {
return { methods, element };
}
}
if (element && iosMethods.requestFullscreen in element) {
return { methods: iosMethods, element };
}
}
return { methods: null };
};
// src/media/controls/pictureInPicture.ts
var isPictureInPictureSupported = (element) => {
if (typeof document === "undefined") {
return true;
}
const videoElement = element ?? document.createElement("video");
const isPiPDisabled = Boolean(
videoElement.disablePictureInPicture
);
const { apiType } = getPictureInPictureMode(videoElement);
return Boolean(apiType) && !isPiPDisabled;
};
var isCurrentlyPictureInPicture = (inputElement) => {
const { apiType, element } = getPictureInPictureMode(inputElement);
if (apiType === "w3c") {
return Boolean(document?.pictureInPictureElement);
}
if (apiType === "webkit") {
return element?.webkitPresentationMode === "picture-in-picture";
}
return false;
};
var enterPictureInPicture = async (inputElement) => {
const { apiType, element } = getPictureInPictureMode(inputElement);
if (apiType === "w3c") {
await element?.requestPictureInPicture?.();
} else if (apiType === "webkit") {
await element?.webkitSetPresentationMode?.("picture-in-picture");
}
return null;
};
var exitPictureInPicture = (inputElement) => {
const { apiType, element } = getPictureInPictureMode(inputElement);
if (apiType === "w3c") {
return document?.exitPictureInPicture?.() ?? null;
}
if (apiType === "webkit") {
return element?.webkitSetPresentationMode?.("inline") ?? null;
}
return null;
};
var addEnterPictureInPictureEventListener = (inputElement, callback) => {
const { apiType, element } = getPictureInPictureMode(inputElement);
if (apiType === "w3c" && element) {
element.addEventListener("enterpictureinpicture", callback, false);
return () => {
element.removeEventListener("enterpictureinpicture", callback, false);
};
}
if (apiType === "webkit" && element) {
const callbackComposed = (e) => {
if (element?.webkitPresentationMode === "picture-in-picture") {
callback?.(e);
}
};
document.addEventListener(
"webkitpresentationmodechanged",
callbackComposed,
false
);
return () => {
document.removeEventListener(
"webkitpresentationmodechanged",
callbackComposed,
false
);
};
}
return null;
};
var addExitPictureInPictureEventListener = (inputElement, callback) => {
const { apiType, element } = getPictureInPictureMode(inputElement);
if (apiType === "w3c" && element) {
element.addEventListener("leavepictureinpicture", callback, false);
return () => {
element.removeEventListener("leavepictureinpicture", callback, false);
};
}
if (apiType === "webkit" && element) {
const callbackComposed = (e) => {
if (element?.webkitPresentationMode === "inline") {
callback?.(e);
}
};
document.addEventListener(
"webkitpresentationmodechanged",
callbackComposed,
false
);
return () => {
document.removeEventListener(
"webkitpresentationmodechanged",
callbackComposed,
false
);
};
}
return null;
};
var getPictureInPictureMode = (element) => {
if (isClient() && element instanceof HTMLVideoElement) {
if (document?.pictureInPictureEnabled) {
return { apiType: "w3c", element };
}
if (element?.webkitSupportsPresentationMode?.("picture-in-picture")) {
return { apiType: "webkit", element };
}
}
return { apiType: null };
};
// src/media/controls/volume.ts
var isVolumeChangeSupported = (type) => {
return new Promise((resolve) => {
if (typeof window === "undefined") {
return false;
}
const testElement = document.createElement(type);
const newVolume = 0.342;
testElement.volume = newVolume;
setTimeout(() => {
const isSupported = testElement.volume !== 1;
testElement.remove();
resolve(isSupported);
});
});
};
// src/media/controls/controller.ts
var MEDIA_CONTROLLER_INITIALIZED_ATTRIBUTE = "data-livepeer-controller-initialized";
var allKeyTriggers = [
"KeyF",
"KeyK",
"KeyM",
"KeyI",
"KeyV",
"KeyX",
"Space",
"ArrowRight",
"ArrowLeft"
];
var delay = (ms) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
var addEventListeners = (element, store) => {
const initializedState = store.getState();
try {
isVolumeChangeSupported(
initializedState.currentSource?.type === "audio" ? "audio" : "video"
).then((result) => {
store.setState(({ __device }) => ({
__device: {
...__device,
isVolumeChangeSupported: result
}
}));
});
} catch (e) {
console.error(e);
}
const onLoadedMetadata = () => {
store.getState().__controlsFunctions.onCanPlay();
store.getState().__controlsFunctions.requestMeasure();
};
const onLoadedData = () => {
store.getState().__controlsFunctions.requestMeasure();
};
const onPlay = () => {
store.getState().__controlsFunctions.onPlay();
};
const onPause = () => {
store.getState().__controlsFunctions.onPause();
};
const onDurationChange = () => store.getState().__controlsFunctions.onDurationChange(element?.duration ?? 0);
const onKeyUp = (e) => {
e.preventDefault();
e.stopPropagation();
const code = e.code;
store.getState().__controlsFunctions.updateLastInteraction();
const isNotBroadcast = store.getState().__initialProps.hotkeys !== "broadcast";
if (allKeyTriggers.includes(code)) {
if ((code === "Space" || code === "KeyK") && isNotBroadcast) {
store.getState().__controlsFunctions.togglePlay();
} else if (code === "ArrowRight" && isNotBroadcast) {
store.getState().__controlsFunctions.requestSeekForward();
} else if (code === "ArrowLeft" && isNotBroadcast) {
store.getState().__controlsFunctions.requestSeekBack();
} else if (code === "KeyM" && isNotBroadcast) {
store.getState().__controlsFunctions.requestToggleMute();
} else if (code === "KeyX" && isNotBroadcast) {
store.getState().__controlsFunctions.requestClip();
} else if (code === "KeyF") {
store.getState().__controlsFunctions.requestToggleFullscreen();
} else if (code === "KeyI") {
store.getState().__controlsFunctions.requestTogglePictureInPicture();
}
}
};
const onMouseUpdate = () => {
store.getState().__controlsFunctions.updateLastInteraction();
};
const onTouchUpdate = async () => {
store.getState().__controlsFunctions.updateLastInteraction();
};
const onVolumeChange = () => {
store.getState().__controlsFunctions.setVolume(element.muted ? 0 : element.volume ?? 0);
};
const onRateChange = () => {
store.getState().__controlsFunctions.setPlaybackRate(element.playbackRate);
};
const onTimeUpdate = () => {
store.getState().__controlsFunctions.onProgress(element?.currentTime ?? 0);
if (element && (element?.duration ?? 0) > 0) {
const currentTime = element.currentTime;
const buffered = [...Array(element.buffered.length)].reduce(
(prev, _curr, i) => {
const start = element.buffered.start(element.buffered.length - 1 - i);
const end = element.buffered.end(element.buffered.length - 1 - i);
if (start <= currentTime && end >= currentTime) {
return end;
}
return prev;
},
// default to no buffering
0
);
store.getState().__controlsFunctions.updateBuffered(buffered);
}
};
const onError = async (e) => {
const source = store.getState().currentSource;
if (source?.type === "video") {
const sourceElement = e.target;
const parentElement = sourceElement?.parentElement;
const videoUrl = parentElement?.currentSrc ?? sourceElement?.currentSrc;
if (videoUrl) {
try {
const response = await fetch(videoUrl);
if (response.status === 404) {
console.warn("Video not found");
return store.getState().__controlsFunctions?.onError?.(
new Error(STREAM_OFFLINE_ERROR_MESSAGE)
);
}
if (response.status === 401) {
console.warn("Unauthorized to view video");
return store.getState().__controlsFunctions?.onError?.(
new Error(ACCESS_CONTROL_ERROR_MESSAGE)
);
}
} catch (err) {
console.warn(err);
return store.getState().__controlsFunctions?.onError?.(
new Error("Error fetching video URL")
);
}
}
console.warn("Unknown error loading video");
return store.getState().__controlsFunctions?.onError?.(
new Error("Unknown error loading video")
);
}
store.getState().__controlsFunctions.onError(new Error(e?.message));
};
const onWaiting = async () => {
store.getState().__controlsFunctions.onWaiting();
};
const onStalled = async () => {
store.getState().__controlsFunctions.onStalled();
};
const onLoadStart = async () => {
store.getState().__controlsFunctions.onLoading();
};
const onEnded = async () => {
store.getState().__controlsFunctions.onEnded();
};
const onResize = async () => {
store.getState().__controlsFunctions.requestMeasure();
};
const parentElementOrElement = element?.parentElement ?? element;
if (element) {
element.addEventListener("volumechange", onVolumeChange);
element.addEventListener("ratechange", onRateChange);
element.addEventListener("loadedmetadata", onLoadedMetadata);
element.addEventListener("loadeddata", onLoadedData);
element.addEventListener("play", onPlay);
element.addEventListener("playing", onPlay);
element.addEventListener("pause", onPause);
element.addEventListener("durationchange", onDurationChange);
element.addEventListener("timeupdate", onTimeUpdate);
element.addEventListener("error", onError);
element.addEventListener("waiting", onWaiting);
element.addEventListener("stalled", onStalled);
element.addEventListener("loadstart", onLoadStart);
element.addEventListener("ended", onEnded);
parentElementOrElement?.addEventListener("mouseout", onMouseUpdate);
parentElementOrElement?.addEventListener("mousemove", onMouseUpdate);
parentElementOrElement?.addEventListener("touchstart", onTouchUpdate);
parentElementOrElement?.addEventListener("touchend", onTouchUpdate);
parentElementOrElement?.addEventListener("touchmove", onTouchUpdate);
if (typeof window !== "undefined") {
window?.addEventListener?.("resize", onResize);
}
parentElementOrElement?.addEventListener("keyup", onKeyUp);
parentElementOrElement?.setAttribute("tabindex", "0");
element.setAttribute(MEDIA_CONTROLLER_INITIALIZED_ATTRIBUTE, "true");
}
const onFullscreenChange = () => {
store.getState().__controlsFunctions.setFullscreen(isCurrentlyFullscreen(element));
};
const onEnterPictureInPicture = () => {
store.getState().__controlsFunctions.setPictureInPicture(true);
};
const onExitPictureInPicture = () => {
store.getState().__controlsFunctions.setPictureInPicture(false);
};
const removeEffectsFromStore = addEffectsToStore(element, store);
const removeFullscreenListener = addFullscreenEventListener(
element,
onFullscreenChange
);
const removeEnterPictureInPictureListener = addEnterPictureInPictureEventListener(element, onEnterPictureInPicture);
const removeExitPictureInPictureListener = addExitPictureInPictureEventListener(element, onExitPictureInPicture);
return {
destroy: () => {
removeFullscreenListener?.();
removeEnterPictureInPictureListener?.();
removeExitPictureInPictureListener?.();
element?.removeEventListener?.("ratechange", onRateChange);
element?.removeEventListener?.("volumechange", onVolumeChange);
element?.removeEventListener?.("loadedmetadata", onLoadedMetadata);
element?.removeEventListener?.("loadeddata", onLoadedData);
element?.removeEventListener?.("play", onPlay);
element?.removeEventListener?.("playing", onPlay);
element?.removeEventListener?.("pause", onPause);
element?.removeEventListener?.("durationchange", onDurationChange);
element?.removeEventListener?.("timeupdate", onTimeUpdate);
element?.removeEventListener?.("error", onError);
element?.removeEventListener?.("waiting", onWaiting);
element?.removeEventListener?.("stalled", onStalled);
element?.removeEventListener?.("loadstart", onLoadStart);
element?.removeEventListener?.("ended", onEnded);
if (typeof window !== "undefined") {
window?.removeEventListener?.("resize", onResize);
}
parentElementOrElement?.removeEventListener?.("mouseout", onMouseUpdate);
parentElementOrElement?.removeEventListener?.("mousemove", onMouseUpdate);
parentElementOrElement?.removeEventListener?.(
"touchstart",
onTouchUpdate
);
parentElementOrElement?.removeEventListener?.("touchend", onTouchUpdate);
parentElementOrElement?.removeEventListener?.("touchmove", onTouchUpdate);
parentElementOrElement?.removeEventListener?.("keyup", onKeyUp);
removeEffectsFromStore?.();
element?.removeAttribute?.(MEDIA_CONTROLLER_INITIALIZED_ATTRIBUTE);
}
};
};
var cleanupSource = () => {
};
var cleanupPosterImage = () => {
};
var addEffectsToStore = (element, store) => {
const destroySource = store.subscribe(
({
__initialProps,
__controls,
currentSource,
errorCount,
progress,
mounted,
videoQuality
}) => ({
aspectRatio: __initialProps.aspectRatio,
autoPlay: __initialProps.autoPlay,
backoff: __initialProps.backoff,
backoffMax: __initialProps.backoffMax,
calculateDelay: __initialProps.calculateDelay,
errorCount,
lastError: __controls.lastError,
hlsConfig: __controls.hlsConfig,
mounted,
progress,
source: currentSource,
timeout: __initialProps.timeout,
videoQuality
}),
async ({
aspectRatio,
autoPlay,
// biome-ignore lint/correctness/noUnusedFunctionParameters: ignored using `--suppress`
backoff,
// biome-ignore lint/correctness/noUnusedFunctionParameters: ignored using `--suppress`
backoffMax,
calculateDelay,
errorCount,
// biome-ignore lint/correctness/noUnusedFunctionParameters: ignored using `--suppress`
lastError,
hlsConfig,
mounted,
progress,
source,
timeout,
videoQuality
}) => {
if (!mounted) {
return;
}
await cleanupSource?.();
await delay(
Math.max(calculateDelay(errorCount), errorCount === 0 ? 0 : 100)
);
let unmounted = false;
if (!source) {
return;
}
let jumped = false;
const jumpToPreviousPosition = () => {
const live = store.getState().live;
if (!live && progress && !jumped) {
element.currentTime = progress;
jumped = true;
}
};
const onErrorComposed = (err) => {
if (!unmounted) {
cleanupSource?.();
store.getState().__controlsFunctions?.onError?.(err);
}
};
if (source.type === "webrtc") {
const unsubscribeBframes = store.subscribe(
(state) => state?.__metadata,
(metadata) => {
let webrtcIsPossibleForOneTrack = false;
if (metadata?.meta?.tracks) {
for (const trackId of Object.keys(metadata.meta.tracks)) {
if (metadata?.meta?.tracks[trackId]?.bframes !== 1) {
webrtcIsPossibleForOneTrack = true;
}
}
}
const shouldNotFallBackToHLS = webrtcIsPossibleForOneTrack || metadata?.meta?.bframes === 0;
if (!shouldNotFallBackToHLS && !unmounted) {
onErrorComposed(new Error(BFRAMES_ERROR_MESSAGE));
}
}
);
const { destroy } = createNewWHEP({
source: source.src,
element,
callbacks: {
onConnected: () => {
store.getState().__controlsFunctions.setLive(true);
jumpToPreviousPosition();
},
onError: onErrorComposed,
onPlaybackOffsetUpdated: store.getState().__controlsFunctions.updatePlaybackOffsetMs,
onRedirect: store.getState().__controlsFunctions.onFinalUrl
},
accessControl: {
jwt: store.getState().__initialProps.jwt,
accessKey: store.getState().__initialProps.accessKey
},
sdpTimeout: timeout,
iceServers: store.getState().__initialProps.iceServers
});
const id = setTimeout(() => {
if (!store.getState().canPlay && !unmounted) {
store.getState().__controlsFunctions.onWebRTCTimeout?.();
onErrorComposed(
new Error(
"Timeout reached for canPlay - triggering playback error."
)
);
}
}, timeout);
cleanupSource = () => {
unmounted = true;
clearTimeout(id);
destroy?.();
unsubscribeBframes?.();
};
return;
}
if (source.type === "hls") {
const indexUrl = /\/hls\/[^/\s]+\/index\.m3u8/;
const onErrorCleaned = (error) => {
const cleanError = new Error(
error?.response?.data?.toString?.() ?? (error?.response?.code === 401 ? ACCESS_CONTROL_ERROR_MESSAGE : "Error with HLS.js")
);
onErrorComposed?.(cleanError);
};
const hlsConfigResolved = hlsConfig;
const { destroy, setQuality: setQuality2 } = createNewHls({
source: source?.src,
element,
initialQuality: videoQuality,
aspectRatio: aspectRatio ?? 16 / 9,
callbacks: {
onLive: store.getState().__controlsFunctions.setLive,
onDuration: store.getState().__controlsFunctions.onDurationChange,
onCanPlay: () => {
store.getState().__controlsFunctions.onCanPlay();
jumpToPreviousPosition();
store.getState().__controlsFunctions.onError(null);
},
onError: onErrorCleaned,
onPlaybackOffsetUpdated: store.getState().__controlsFunctions.updatePlaybackOffsetMs,
onRedirect: store.getState().__controlsFunctions.onFinalUrl
},
config: {
...hlsConfigResolved ?? {},
async xhrSetup(xhr, url) {
if (hlsConfigResolved?.xhrSetup) {
await hlsConfigResolved?.xhrSetup?.(xhr, url);
} else {
const live = store.getState().live;
if (!live || url.match(indexUrl)) {
const jwt = store.getState().__initialProps.jwt;
const accessKey = store.getState().__initialProps.accessKey;
if (accessKey)
xhr.setRequestHeader("Livepeer-Access-Key", accessKey);
else if (jwt) xhr.setRequestHeader("Livepeer-Jwt", jwt);
}
}
},
autoPlay
}
});
const unsubscribeQualityUpdate = store.subscribe(
(state) => state.videoQuality,
(newQuality) => {
setQuality2(newQuality);
}
);
cleanupSource = () => {
unmounted = true;
destroy?.();
unsubscribeQualityUpdate?.();
};
return;
}
if (source?.type === "video") {
store.getState().__controlsFunctions.onFinalUrl(source.src);
element.addEventListener("canplay", jumpToPreviousPosition);
element.src = source.src;
element.load();
cleanupSource = () => {
unmounted = true;
element?.removeEventListener?.("canplay", jumpToPreviousPosition);
};
return;
}
},
{
equalityFn: (a, b) => {
const errorCountChanged = a.errorCount !== b.errorCount && b.errorCount !== 0;
const lastErrorChanged = a.lastError !== b.lastError;
const sourceChanged = a.source?.src !== b.source?.src;
const mountedChanged = a.mounted !== b.mounted;
const shouldReRender = errorCountChanged || lastErrorChanged || sourceChanged || mountedChanged;
return !shouldReRender;
}
}
);
const destroyPosterImage = store.subscribe(
({ __controls, live, __controlsFunctions, __initialProps }) => ({
thumbnail: __controls.thumbnail?.src,
live,
setPoster: __controlsFunctions.setPoster,
posterLiveUpdate: __initialProps.posterLiveUpdate
}),
async ({ thumbnail, live, setPoster, posterLiveUpdate }) => {
cleanupPosterImage?.();
if (thumbnail && live && posterLiveUpdate > 0) {
const interval = setInterval(() => {
const thumbnailUrl = new URL(thumbnail);
thumbnailUrl.searchParams.set("v", Date.now().toFixed(0));
setPoster(thumbnailUrl.toString());
}, posterLiveUpdate);
cleanupPosterImage = () => clearInterval(interval);
}
},
{
equalityFn: (a, b) => a.thumbnail === b.thumbnail && a.live === b.live
}
);
const destroyPlayPause = store.subscribe(
(state) => state.__controls.requestedPlayPauseLastTime,
async () => {
if (element.paused) {
await element.play();
} else {
await element.pause();
}
}
);
const destroyPlaybackRate = store.subscribe(
(state) => state.playbackRate,
(current) => {
element.playbackRate = current === "constant" ? 1 : current;
}
);
const destroyVolume = store.subscribe(
(state) => ({
playing: state.playing,
volume: state.volume,
isVolumeChangeSupported: state.__device.isVolumeChangeSupported
}),
(current) => {
if (current.isVolumeChangeSupported) {
element.volume = current.volume;
}
},
{
equalityFn: (a, b) => a.volume === b.volume && a.playing === b.playing && a.isVolumeChangeSupported === b.isVolumeChangeSupported
}
);
const destroyMute = store.subscribe(
(state) => state.__controls.muted,
(current, prev) => {
if (current !== prev) {
element.muted = current;
}
}
);
const destroySeeking = store.subscribe(
(state) => state.__controls.requestedRangeToSeekTo,
(current) => {
if (typeof element.readyState === "undefined" || element.readyState > 0) {
element.currentTime = current;
}
}
);
const destroyFullscreen = store.subscribe(
(state) => state.__controls.requestedFullscreenLastTime,
async () => {
const isFullscreen = isCurrentlyFullscreen(element);
if (isFullscreen) exitFullscreen(element);
else enterFullscreen(element);
}
);
const destroyPictureInPicture = store.subscribe(
(state) => state.__controls.requestedPictureInPictureLastTime,
async () => {
try {
const isPictureInPicture = await isCurrentlyPictureInPicture(element);
if (isPictureInPicture) await exitPictureInPicture(element);
else await enterPictureInPicture(element);
} catch (e) {
warn(e?.message ?? "Picture in picture is not supported");
store.setState((state) => ({
__device: {
...state.__device,
isPictureInPictureSupported: false
}
}));
}
}
);
const destroyAutohide = store.subscribe(
(state) => ({
lastInteraction: state.__controls.lastInteraction,
autohide: state.__controls.autohide
}),
async ({ lastInteraction, autohide }) => {
if (autohide && lastInteraction) {
store.getState().__controlsFunctions.setHidden(false);
await delay(autohide);
const parentElementOrElement = element?.parentElement ?? element;
const openElement = parentElementOrElement?.querySelector?.(
'[data-state="open"]'
);
if (!openElement && !store.getState().hidden && lastInteraction === store.getState().__controls.lastInteraction) {
store.getState().__controlsFunctions.setHidden(true);
}
}
},
{
equalityFn: (a, b) => a?.lastInteraction === b?.lastInteraction && a?.autohide === b?.autohide
}
);
const destroyRequestSizing = store.subscribe(
(state) => ({
lastTime: state.__controls.requestedMeasureLastTime,
fullscreen: state.fullscreen
}),
async () => {
store.getState().__controlsFunctions.setSize({
...element?.videoHeight && element?.videoWidth ? {
media: {
height: element.videoHeight,
width: element.videoWidth
}
} : {},
...element?.clientHeight && element?.clientWidth ? {
container: {
height: element.clientHeight,
width: element.clientWidth
}
} : {},
...typeof window !== "undefined" && window?.innerHeight && window?.innerWidth ? {
window: {
height: window.innerHeight,
width: window.innerWidth
}
} : {}
});
},
{
equalityFn: (a, b) => a?.fullscreen === b?.fullscreen && a?.lastTime === b?.lastTime
}
);
const destroyMediaSizing = store.subscribe(
(state) => state.__controls.size?.media,
async (media) => {
const parentElementOrElement = element?.parentElement ?? element;
if (parentElementOrElement) {
if (media?.height && media?.width) {
const elementStyle = parentElementOrElement.style;
elementStyle.setProperty(
"--livepeer-media-height",
`${media.height}px`
);
elementStyle.setProperty(
"--livepeer-media-width",
`${media.width}px`
);
}
}
},
{
equalityFn: (a, b) => a?.height === b?.height && a?.width === b?.width
}
);
const destroyContainerSizing = store.subscribe(
(state) => state.__controls.size?.container,
async (container) => {
const parentElementOrElement = element?.parentElement ?? element;
if (parentElementOrElement) {
if (container?.height && container?.width) {
const elementStyle = parentElementOrElement.style;
elementStyle.setProperty(
"--livepeer-container-height",
`${container.height}px`
);
elementStyle.setProperty(
"--livepeer-container-width",
`${container.width}px`
);
}
}
},
{
equalityFn: (a, b) => a?.height === b?.height && a?.width === b?.width
}
);
return () => {
destroyAutohide?.();
destroyContainerSizing?.();
destroyFullscreen?.();
destroyMediaSizing?.();
destroyMute?.();
destroyPictureInPicture?.();
destroyPlaybackRate?.();
destroyPlayPause?.();
destroyPosterImage?.();
destroyRequestSizing?.();
destroySeeking?.();
destroyVolume?.();
destroySource?.();
cleanupPosterImage?.();
cleanupSource?.();
};
};
// src/media/controls/device.ts
var getDeviceInfo = (version2) => ({
version: version2,
isAndroid: isAndroid(),
isIos: isIos(),
isMobile: isMobile(),
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : "Node.js or unknown",
screenWidth: typeof window !== "undefined" && window?.screen ? window?.screen?.width ?? null : null,
isFullscreenSupported: isFullscreenSupported(),
isWebRTCSupported: Boolean(getRTCPeerConnectionConstructor()),
isPictureInPictureSupported: isPictureInPictureSupported(),
isHlsSupported: isHlsSupported(),
isVolumeChangeSupported: true
});
// src/media/metrics.ts
import {
addLegacyMediaMetricsToStore,
addMetricsToStore,
createControllerStore
} from "@livepeer/core/media";
import { createStorage, noopStorage } from "@livepeer/core/storage";
import { version } from "@livepeer/core/version";
function addMediaMetrics(element, opts = {}) {
if (element) {
const source = opts?.src ?? element?.src ?? null;
const { store, destroy } = createControllerStore({
src: source,
playbackId: opts?.playbackId,
device: getDeviceInfo(version.core),
storage: createStorage({ storage: noopStorage }),
initialProps: {
autoPlay: Boolean(element?.autoplay),
volume: element?.muted ? 0 : element?.volume,
preload: element?.preload === "" ? "auto" : element?.preload,
playbackRate: element?.playbackRate,
hotkeys: false,
posterLiveUpdate: 0,
...opts,
onError(error) {
if (error) {
opts?.onError?.(error);
}
}
}
});
const { destroy: destroyListeners } = addEventListeners(element, store);
const { destroy: destroyMetrics } = ad