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