UNPKG

@livepeer/core-web

Version:

Livepeer UI Kit's core web library, for adding reactive stores to video elements.

519 lines (515 loc) 16.2 kB
// src/webrtc/shared.ts import { NOT_ACCEPTABLE_ERROR_MESSAGE } from "@livepeer/core/errors"; // src/media/utils.ts import { noop } from "@livepeer/core/utils"; var isClient = () => typeof window !== "undefined"; // src/webrtc/shared.ts 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/webrtc/whip.ts import { warn } from "@livepeer/core/utils"; var VIDEO_WEBRTC_INITIALIZED_ATTRIBUTE2 = "data-livepeer-video-whip-initialized"; var createNewWHIP = ({ ingestUrl, element, callbacks, sdpTimeout, noIceGathering, iceServers }) => { if (element.getAttribute(VIDEO_WEBRTC_INITIALIZED_ATTRIBUTE2) === "true") { return { destroy: () => { } }; } element.setAttribute(VIDEO_WEBRTC_INITIALIZED_ATTRIBUTE2, "true"); let destroyed = false; const abortController = new AbortController(); let peerConnection = null; getRedirectUrl(ingestUrl, abortController, sdpTimeout).then((redirectUrl) => { if (destroyed || !redirectUrl) { return; } const redirectUrlString = redirectUrl.toString().replace("video+", ""); peerConnection = createPeerConnection(redirectUrl.host, iceServers); if (peerConnection) { peerConnection.addEventListener("negotiationneeded", async (_ev) => { try { const ofr = await constructClientOffer( peerConnection, redirectUrlString, noIceGathering ); await negotiateConnectionWithClientOffer( peerConnection, ingestUrl, ofr, abortController, {}, sdpTimeout ); } catch (e) { callbacks?.onError?.(e); } }); peerConnection.addEventListener( "connectionstatechange", async (_ev) => { try { if (peerConnection?.connectionState === "failed") { callbacks?.onError?.(new Error("Failed to connect to peer.")); } if (peerConnection?.connectionState === "connected") { callbacks?.onConnected?.(); } } catch (e) { callbacks?.onError?.(e); } } ); callbacks?.onRTCPeerConnection?.(peerConnection); } else { warn("Could not create peer connection."); } }).catch((e) => callbacks?.onError?.(e)); return { destroy: () => { destroyed = true; abortController?.abort?.(); peerConnection?.close?.(); element?.removeAttribute?.(VIDEO_WEBRTC_INITIALIZED_ATTRIBUTE2); } }; }; var attachMediaStreamToPeerConnection = async ({ mediaStream, peerConnection }) => { const newVideoTrack = mediaStream?.getVideoTracks?.()?.[0] ?? null; const newAudioTrack = mediaStream?.getAudioTracks?.()?.[0] ?? null; const transceivers = peerConnection.getTransceivers(); let videoTransceiver = transceivers.find( (t) => t.receiver.track.kind === "video" ); let audioTransceiver = transceivers.find( (t) => t.receiver.track.kind === "audio" ); if (newVideoTrack) { if (videoTransceiver) { await videoTransceiver.sender.replaceTrack(newVideoTrack); } else { videoTransceiver = await peerConnection.addTransceiver(newVideoTrack, { direction: "sendonly" }); } } if (newAudioTrack) { if (audioTransceiver) { await audioTransceiver.sender.replaceTrack(newAudioTrack); } else { audioTransceiver = await peerConnection.addTransceiver(newAudioTrack, { direction: "sendonly" }); } } }; var getUserMedia = (constraints) => { if (typeof navigator === "undefined") { return null; } if (navigator?.mediaDevices?.getUserMedia) { return navigator.mediaDevices.getUserMedia(constraints); } if (navigator?.getUserMedia) { return navigator.getUserMedia(constraints); } if (navigator?.webkitGetUserMedia) { return navigator.webkitGetUserMedia(constraints); } if (navigator?.mozGetUserMedia) { return navigator.mozGetUserMedia(constraints); } if (navigator?.msGetUserMedia) { return navigator.msGetUserMedia(constraints); } warn( "getUserMedia is not supported in this environment. Check if you are in a secure (HTTPS) context - https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia" ); return null; }; var getMediaDevices = () => { if (typeof navigator === "undefined") { return null; } if (!navigator.mediaDevices) { warn( "mediaDevices was not found in this environment. Check if you are in a secure (HTTPS) context - https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia" ); return null; } return navigator.mediaDevices; }; var getDisplayMedia = (options) => { if (typeof navigator === "undefined") { warn("getDisplayMedia does not exist in this environment."); return null; } if (!navigator?.mediaDevices?.getDisplayMedia) { warn("getDisplayMedia does not exist in this environment."); return null; } return navigator.mediaDevices.getDisplayMedia(options); }; export { attachMediaStreamToPeerConnection, createNewWHEP, createNewWHIP, getDisplayMedia, getMediaDevices, getUserMedia }; //# sourceMappingURL=index.js.map