UNPKG

@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
// 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