@livepeer/core-web
Version:
Livepeer UI Kit's core web library, for adding reactive stores to video elements.
1 lines • 102 kB
Source Map (JSON)
{"version":3,"sources":["../../src/browser.ts","../../src/media/controls/controller.ts","../../src/hls/hls.ts","../../src/media/utils.ts","../../src/webrtc/shared.ts","../../src/webrtc/whep.ts","../../src/media/controls/fullscreen.ts","../../src/media/controls/pictureInPicture.ts","../../src/media/controls/volume.ts","../../src/media/controls/device.ts","../../src/media/metrics.ts"],"sourcesContent":["export {\n addEventListeners,\n getDeviceInfo,\n type HlsConfig,\n} from \"./media/controls\";\nexport { addMediaMetrics } from \"./media/metrics\";\nexport { canPlayMediaNatively } from \"./media/utils\";\n","import {\n ACCESS_CONTROL_ERROR_MESSAGE,\n BFRAMES_ERROR_MESSAGE,\n STREAM_OFFLINE_ERROR_MESSAGE,\n} from \"@livepeer/core/errors\";\nimport type { MediaControllerStore } from \"@livepeer/core/media\";\nimport { warn } from \"@livepeer/core/utils\";\nimport type { HlsConfig as HlsJsConfig } from \"hls.js\";\nimport { createNewHls, type HlsError } from \"../../hls/hls\";\nimport { createNewWHEP } from \"../../webrtc/whep\";\nimport {\n addFullscreenEventListener,\n enterFullscreen,\n exitFullscreen,\n isCurrentlyFullscreen,\n} from \"./fullscreen\";\nimport {\n addEnterPictureInPictureEventListener,\n addExitPictureInPictureEventListener,\n enterPictureInPicture,\n exitPictureInPicture,\n isCurrentlyPictureInPicture,\n} from \"./pictureInPicture\";\nimport { isVolumeChangeSupported } from \"./volume\";\n\nexport type HlsConfig = Partial<HlsJsConfig>;\n\nconst MEDIA_CONTROLLER_INITIALIZED_ATTRIBUTE =\n \"data-livepeer-controller-initialized\";\n\nconst allKeyTriggers = [\n \"KeyF\",\n \"KeyK\",\n \"KeyM\",\n \"KeyI\",\n \"KeyV\",\n \"KeyX\",\n \"Space\",\n \"ArrowRight\",\n \"ArrowLeft\",\n] as const;\ntype KeyTrigger = (typeof allKeyTriggers)[number];\n\nconst delay = (ms: number) => {\n return new Promise((resolve) => setTimeout(resolve, ms));\n};\n\nexport const addEventListeners = (\n element: HTMLMediaElement,\n store: MediaControllerStore,\n) => {\n const initializedState = store.getState();\n\n try {\n isVolumeChangeSupported(\n initializedState.currentSource?.type === \"audio\" ? \"audio\" : \"video\",\n ).then((result) => {\n store.setState(({ __device }) => ({\n __device: {\n ...__device,\n isVolumeChangeSupported: result,\n },\n }));\n });\n } catch (e) {\n console.error(e);\n }\n\n const onLoadedMetadata = () => {\n store.getState().__controlsFunctions.onCanPlay();\n store.getState().__controlsFunctions.requestMeasure();\n };\n\n const onLoadedData = () => {\n store.getState().__controlsFunctions.requestMeasure();\n };\n\n const onPlay = () => {\n store.getState().__controlsFunctions.onPlay();\n };\n const onPause = () => {\n store.getState().__controlsFunctions.onPause();\n };\n\n const onDurationChange = () =>\n store\n .getState()\n .__controlsFunctions.onDurationChange(element?.duration ?? 0);\n\n const onKeyUp = (e: KeyboardEvent) => {\n e.preventDefault();\n e.stopPropagation();\n\n const code = e.code as KeyTrigger;\n\n store.getState().__controlsFunctions.updateLastInteraction();\n\n const isNotBroadcast =\n store.getState().__initialProps.hotkeys !== \"broadcast\";\n\n if (allKeyTriggers.includes(code)) {\n if ((code === \"Space\" || code === \"KeyK\") && isNotBroadcast) {\n store.getState().__controlsFunctions.togglePlay();\n } else if (code === \"ArrowRight\" && isNotBroadcast) {\n store.getState().__controlsFunctions.requestSeekForward();\n } else if (code === \"ArrowLeft\" && isNotBroadcast) {\n store.getState().__controlsFunctions.requestSeekBack();\n } else if (code === \"KeyM\" && isNotBroadcast) {\n store.getState().__controlsFunctions.requestToggleMute();\n } else if (code === \"KeyX\" && isNotBroadcast) {\n store.getState().__controlsFunctions.requestClip();\n } else if (code === \"KeyF\") {\n store.getState().__controlsFunctions.requestToggleFullscreen();\n } else if (code === \"KeyI\") {\n store.getState().__controlsFunctions.requestTogglePictureInPicture();\n }\n }\n };\n\n const onMouseUpdate = () => {\n store.getState().__controlsFunctions.updateLastInteraction();\n };\n const onTouchUpdate = async () => {\n store.getState().__controlsFunctions.updateLastInteraction();\n };\n\n const onVolumeChange = () => {\n store\n .getState()\n .__controlsFunctions.setVolume(element.muted ? 0 : (element.volume ?? 0));\n };\n\n const onRateChange = () => {\n store.getState().__controlsFunctions.setPlaybackRate(element.playbackRate);\n };\n\n const onTimeUpdate = () => {\n store.getState().__controlsFunctions.onProgress(element?.currentTime ?? 0);\n\n if (element && (element?.duration ?? 0) > 0) {\n const currentTime = element.currentTime;\n\n const buffered = [...Array(element.buffered.length)].reduce(\n (prev, _curr, i) => {\n const start = element.buffered.start(element.buffered.length - 1 - i);\n const end = element.buffered.end(element.buffered.length - 1 - i);\n\n // if the TimeRange covers the current time, then use this value\n if (start <= currentTime && end >= currentTime) {\n return end;\n }\n\n return prev;\n },\n // default to no buffering\n 0,\n );\n\n store.getState().__controlsFunctions.updateBuffered(buffered);\n }\n };\n\n const onError = async (e: ErrorEvent) => {\n const source = store.getState().currentSource;\n\n if (source?.type === \"video\") {\n const sourceElement = e.target;\n const parentElement = (sourceElement as HTMLSourceElement)?.parentElement;\n const videoUrl =\n (parentElement as HTMLVideoElement)?.currentSrc ??\n (sourceElement as HTMLVideoElement)?.currentSrc;\n\n if (videoUrl) {\n try {\n const response = await fetch(videoUrl);\n if (response.status === 404) {\n console.warn(\"Video not found\");\n return store\n .getState()\n .__controlsFunctions?.onError?.(\n new Error(STREAM_OFFLINE_ERROR_MESSAGE),\n );\n }\n if (response.status === 401) {\n console.warn(\"Unauthorized to view video\");\n return store\n .getState()\n .__controlsFunctions?.onError?.(\n new Error(ACCESS_CONTROL_ERROR_MESSAGE),\n );\n }\n } catch (err) {\n console.warn(err);\n return store\n .getState()\n .__controlsFunctions?.onError?.(\n new Error(\"Error fetching video URL\"),\n );\n }\n }\n\n console.warn(\"Unknown error loading video\");\n return store\n .getState()\n .__controlsFunctions?.onError?.(\n new Error(\"Unknown error loading video\"),\n );\n }\n\n store.getState().__controlsFunctions.onError(new Error(e?.message));\n };\n\n const onWaiting = async () => {\n store.getState().__controlsFunctions.onWaiting();\n };\n\n const onStalled = async () => {\n store.getState().__controlsFunctions.onStalled();\n };\n\n const onLoadStart = async () => {\n store.getState().__controlsFunctions.onLoading();\n };\n\n const onEnded = async () => {\n store.getState().__controlsFunctions.onEnded();\n };\n\n const onResize = async () => {\n store.getState().__controlsFunctions.requestMeasure();\n };\n\n const parentElementOrElement = element?.parentElement ?? element;\n\n if (element) {\n element.addEventListener(\"volumechange\", onVolumeChange);\n element.addEventListener(\"ratechange\", onRateChange);\n\n element.addEventListener(\"loadedmetadata\", onLoadedMetadata);\n element.addEventListener(\"loadeddata\", onLoadedData);\n element.addEventListener(\"play\", onPlay);\n element.addEventListener(\"playing\", onPlay);\n element.addEventListener(\"pause\", onPause);\n element.addEventListener(\"durationchange\", onDurationChange);\n element.addEventListener(\"timeupdate\", onTimeUpdate);\n element.addEventListener(\"error\", onError);\n element.addEventListener(\"waiting\", onWaiting);\n element.addEventListener(\"stalled\", onStalled);\n element.addEventListener(\"loadstart\", onLoadStart);\n element.addEventListener(\"ended\", onEnded);\n\n parentElementOrElement?.addEventListener(\"mouseout\", onMouseUpdate);\n parentElementOrElement?.addEventListener(\"mousemove\", onMouseUpdate);\n\n parentElementOrElement?.addEventListener(\"touchstart\", onTouchUpdate);\n parentElementOrElement?.addEventListener(\"touchend\", onTouchUpdate);\n parentElementOrElement?.addEventListener(\"touchmove\", onTouchUpdate);\n\n if (typeof window !== \"undefined\") {\n window?.addEventListener?.(\"resize\", onResize);\n }\n\n parentElementOrElement?.addEventListener(\"keyup\", onKeyUp);\n parentElementOrElement?.setAttribute(\"tabindex\", \"0\");\n\n element.setAttribute(MEDIA_CONTROLLER_INITIALIZED_ATTRIBUTE, \"true\");\n }\n\n const onFullscreenChange = () => {\n store\n .getState()\n .__controlsFunctions.setFullscreen(isCurrentlyFullscreen(element));\n };\n\n const onEnterPictureInPicture = () => {\n store.getState().__controlsFunctions.setPictureInPicture(true);\n };\n const onExitPictureInPicture = () => {\n store.getState().__controlsFunctions.setPictureInPicture(false);\n };\n\n // add effects\n const removeEffectsFromStore = addEffectsToStore(element, store);\n\n // add fullscreen listener\n const removeFullscreenListener = addFullscreenEventListener(\n element,\n onFullscreenChange,\n );\n\n // add picture in picture listeners\n const removeEnterPictureInPictureListener =\n addEnterPictureInPictureEventListener(element, onEnterPictureInPicture);\n const removeExitPictureInPictureListener =\n addExitPictureInPictureEventListener(element, onExitPictureInPicture);\n\n return {\n destroy: () => {\n removeFullscreenListener?.();\n\n removeEnterPictureInPictureListener?.();\n removeExitPictureInPictureListener?.();\n\n element?.removeEventListener?.(\"ratechange\", onRateChange);\n element?.removeEventListener?.(\"volumechange\", onVolumeChange);\n element?.removeEventListener?.(\"loadedmetadata\", onLoadedMetadata);\n element?.removeEventListener?.(\"loadeddata\", onLoadedData);\n element?.removeEventListener?.(\"play\", onPlay);\n element?.removeEventListener?.(\"playing\", onPlay);\n element?.removeEventListener?.(\"pause\", onPause);\n element?.removeEventListener?.(\"durationchange\", onDurationChange);\n element?.removeEventListener?.(\"timeupdate\", onTimeUpdate);\n element?.removeEventListener?.(\"error\", onError);\n element?.removeEventListener?.(\"waiting\", onWaiting);\n element?.removeEventListener?.(\"stalled\", onStalled);\n element?.removeEventListener?.(\"loadstart\", onLoadStart);\n element?.removeEventListener?.(\"ended\", onEnded);\n\n if (typeof window !== \"undefined\") {\n window?.removeEventListener?.(\"resize\", onResize);\n }\n\n parentElementOrElement?.removeEventListener?.(\"mouseout\", onMouseUpdate);\n parentElementOrElement?.removeEventListener?.(\"mousemove\", onMouseUpdate);\n\n parentElementOrElement?.removeEventListener?.(\n \"touchstart\",\n onTouchUpdate,\n );\n parentElementOrElement?.removeEventListener?.(\"touchend\", onTouchUpdate);\n parentElementOrElement?.removeEventListener?.(\"touchmove\", onTouchUpdate);\n\n parentElementOrElement?.removeEventListener?.(\"keyup\", onKeyUp);\n\n removeEffectsFromStore?.();\n\n element?.removeAttribute?.(MEDIA_CONTROLLER_INITIALIZED_ATTRIBUTE);\n },\n };\n};\n\ntype Cleanup = () => void | Promise<void>;\n\n// Cleanup function for src side effects\nlet cleanupSource: Cleanup = () => {};\n// Cleanup function for poster image side effects\nlet cleanupPosterImage: Cleanup = () => {};\n\nconst addEffectsToStore = (\n element: HTMLMediaElement,\n store: MediaControllerStore,\n) => {\n // Subscribe to source changes (and trigger playback based on these)\n const destroySource = store.subscribe(\n ({\n __initialProps,\n __controls,\n currentSource,\n errorCount,\n progress,\n mounted,\n videoQuality,\n }) => ({\n aspectRatio: __initialProps.aspectRatio,\n autoPlay: __initialProps.autoPlay,\n backoff: __initialProps.backoff,\n backoffMax: __initialProps.backoffMax,\n calculateDelay: __initialProps.calculateDelay,\n errorCount,\n lastError: __controls.lastError,\n hlsConfig: __controls.hlsConfig,\n mounted,\n progress,\n source: currentSource,\n timeout: __initialProps.timeout,\n videoQuality,\n }),\n async ({\n aspectRatio,\n autoPlay,\n // biome-ignore lint/correctness/noUnusedFunctionParameters: ignored using `--suppress`\n backoff,\n // biome-ignore lint/correctness/noUnusedFunctionParameters: ignored using `--suppress`\n backoffMax,\n calculateDelay,\n errorCount,\n // biome-ignore lint/correctness/noUnusedFunctionParameters: ignored using `--suppress`\n lastError,\n hlsConfig,\n mounted,\n progress,\n source,\n timeout,\n videoQuality,\n }) => {\n if (!mounted) {\n return;\n }\n\n await cleanupSource?.();\n\n await delay(\n Math.max(calculateDelay(errorCount), errorCount === 0 ? 0 : 100),\n );\n\n let unmounted = false;\n\n if (!source) {\n return;\n }\n\n let jumped = false;\n\n const jumpToPreviousPosition = () => {\n const live = store.getState().live;\n\n if (!live && progress && !jumped) {\n element.currentTime = progress;\n\n jumped = true;\n }\n };\n\n const onErrorComposed = (err: Error) => {\n if (!unmounted) {\n cleanupSource?.();\n\n store.getState().__controlsFunctions?.onError?.(err);\n }\n };\n\n if (source.type === \"webrtc\") {\n const unsubscribeBframes = store.subscribe(\n (state) => state?.__metadata,\n (metadata) => {\n let webrtcIsPossibleForOneTrack = false;\n\n // Check if metadata and meta tracks are available\n if (metadata?.meta?.tracks) {\n // Iterate over each track in the metadata\n for (const trackId of Object.keys(metadata.meta.tracks)) {\n // Check if the track does not have bframes equal to 1\n if (metadata?.meta?.tracks[trackId]?.bframes !== 1) {\n webrtcIsPossibleForOneTrack = true;\n }\n }\n }\n\n // Determine if fallback to HLS is necessary\n const shouldNotFallBackToHLS =\n webrtcIsPossibleForOneTrack || metadata?.meta?.bframes === 0;\n\n // If fallback to HLS is needed and component is not unmounted, handle the error\n if (!shouldNotFallBackToHLS && !unmounted) {\n onErrorComposed(new Error(BFRAMES_ERROR_MESSAGE));\n }\n },\n );\n\n const { destroy } = createNewWHEP({\n source: source.src,\n element,\n callbacks: {\n onConnected: () => {\n store.getState().__controlsFunctions.setLive(true);\n jumpToPreviousPosition();\n },\n onError: onErrorComposed,\n onPlaybackOffsetUpdated:\n store.getState().__controlsFunctions.updatePlaybackOffsetMs,\n onRedirect: store.getState().__controlsFunctions.onFinalUrl,\n },\n accessControl: {\n jwt: store.getState().__initialProps.jwt,\n accessKey: store.getState().__initialProps.accessKey,\n },\n sdpTimeout: timeout,\n iceServers: store.getState().__initialProps.iceServers,\n });\n\n const id = setTimeout(() => {\n if (!store.getState().canPlay && !unmounted) {\n store.getState().__controlsFunctions.onWebRTCTimeout?.();\n\n onErrorComposed(\n new Error(\n \"Timeout reached for canPlay - triggering playback error.\",\n ),\n );\n }\n }, timeout);\n\n cleanupSource = () => {\n unmounted = true;\n\n clearTimeout(id);\n destroy?.();\n unsubscribeBframes?.();\n };\n\n return;\n }\n\n if (source.type === \"hls\") {\n const indexUrl = /\\/hls\\/[^/\\s]+\\/index\\.m3u8/;\n\n const onErrorCleaned = (error: HlsError) => {\n const cleanError = new Error(\n error?.response?.data?.toString?.() ??\n (error?.response?.code === 401\n ? ACCESS_CONTROL_ERROR_MESSAGE\n : \"Error with HLS.js\"),\n );\n\n onErrorComposed?.(cleanError);\n };\n\n const hlsConfigResolved = hlsConfig as Partial<HlsConfig> | null;\n\n const { destroy, setQuality } = createNewHls({\n source: source?.src,\n element,\n initialQuality: videoQuality,\n aspectRatio: aspectRatio ?? 16 / 9,\n callbacks: {\n onLive: store.getState().__controlsFunctions.setLive,\n onDuration: store.getState().__controlsFunctions.onDurationChange,\n onCanPlay: () => {\n store.getState().__controlsFunctions.onCanPlay();\n jumpToPreviousPosition();\n store.getState().__controlsFunctions.onError(null);\n },\n onError: onErrorCleaned,\n onPlaybackOffsetUpdated:\n store.getState().__controlsFunctions.updatePlaybackOffsetMs,\n onRedirect: store.getState().__controlsFunctions.onFinalUrl,\n },\n config: {\n ...(hlsConfigResolved ?? {}),\n async xhrSetup(xhr, url) {\n if (hlsConfigResolved?.xhrSetup) {\n await hlsConfigResolved?.xhrSetup?.(xhr, url);\n } else {\n const live = store.getState().live;\n\n if (!live || url.match(indexUrl)) {\n const jwt = store.getState().__initialProps.jwt;\n const accessKey = store.getState().__initialProps.accessKey;\n\n if (accessKey)\n xhr.setRequestHeader(\"Livepeer-Access-Key\", accessKey);\n else if (jwt) xhr.setRequestHeader(\"Livepeer-Jwt\", jwt);\n }\n }\n },\n autoPlay,\n },\n });\n\n const unsubscribeQualityUpdate = store.subscribe(\n (state) => state.videoQuality,\n (newQuality) => {\n setQuality(newQuality);\n },\n );\n\n cleanupSource = () => {\n unmounted = true;\n destroy?.();\n unsubscribeQualityUpdate?.();\n };\n\n return;\n }\n\n if (source?.type === \"video\") {\n store.getState().__controlsFunctions.onFinalUrl(source.src);\n\n element.addEventListener(\"canplay\", jumpToPreviousPosition);\n\n element.src = source.src;\n element.load();\n\n cleanupSource = () => {\n unmounted = true;\n\n element?.removeEventListener?.(\"canplay\", jumpToPreviousPosition);\n };\n\n return;\n }\n },\n {\n equalityFn: (a, b) => {\n const errorCountChanged =\n a.errorCount !== b.errorCount && b.errorCount !== 0;\n const lastErrorChanged = a.lastError !== b.lastError;\n\n const sourceChanged = a.source?.src !== b.source?.src;\n const mountedChanged = a.mounted !== b.mounted;\n\n const shouldReRender =\n errorCountChanged ||\n lastErrorChanged ||\n sourceChanged ||\n mountedChanged;\n\n return !shouldReRender;\n },\n },\n );\n\n // Subscribe to poster image changes\n const destroyPosterImage = store.subscribe(\n ({ __controls, live, __controlsFunctions, __initialProps }) => ({\n thumbnail: __controls.thumbnail?.src,\n live,\n setPoster: __controlsFunctions.setPoster,\n posterLiveUpdate: __initialProps.posterLiveUpdate,\n }),\n async ({ thumbnail, live, setPoster, posterLiveUpdate }) => {\n cleanupPosterImage?.();\n\n if (thumbnail && live && posterLiveUpdate > 0) {\n const interval = setInterval(() => {\n const thumbnailUrl = new URL(thumbnail);\n\n thumbnailUrl.searchParams.set(\"v\", Date.now().toFixed(0));\n\n setPoster(thumbnailUrl.toString());\n }, posterLiveUpdate);\n\n cleanupPosterImage = () => clearInterval(interval);\n }\n },\n {\n equalityFn: (a, b) => a.thumbnail === b.thumbnail && a.live === b.live,\n },\n );\n\n // Subscribe to play/pause changes\n const destroyPlayPause = store.subscribe(\n (state) => state.__controls.requestedPlayPauseLastTime,\n async () => {\n if (element.paused) {\n await element.play();\n } else {\n await element.pause();\n }\n },\n );\n\n // Subscribe to playback rate changes\n const destroyPlaybackRate = store.subscribe(\n (state) => state.playbackRate,\n (current) => {\n element.playbackRate = current === \"constant\" ? 1 : current;\n },\n );\n\n // Subscribe to volume changes\n const destroyVolume = store.subscribe(\n (state) => ({\n playing: state.playing,\n volume: state.volume,\n isVolumeChangeSupported: state.__device.isVolumeChangeSupported,\n }),\n (current) => {\n if (current.isVolumeChangeSupported) {\n element.volume = current.volume;\n }\n },\n {\n equalityFn: (a, b) =>\n a.volume === b.volume &&\n a.playing === b.playing &&\n a.isVolumeChangeSupported === b.isVolumeChangeSupported,\n },\n );\n\n // Subscribe to mute changes\n const destroyMute = store.subscribe(\n (state) => state.__controls.muted,\n (current, prev) => {\n if (current !== prev) {\n element.muted = current;\n }\n },\n );\n\n // Subscribe to seeking changes\n const destroySeeking = store.subscribe(\n (state) => state.__controls.requestedRangeToSeekTo,\n (current) => {\n if (typeof element.readyState === \"undefined\" || element.readyState > 0) {\n element.currentTime = current;\n }\n },\n );\n\n // Subscribe to fullscreen changes\n const destroyFullscreen = store.subscribe(\n (state) => state.__controls.requestedFullscreenLastTime,\n async () => {\n const isFullscreen = isCurrentlyFullscreen(element);\n if (isFullscreen) exitFullscreen(element);\n else enterFullscreen(element);\n },\n );\n\n // Subscribe to picture-in-picture changes\n const destroyPictureInPicture = store.subscribe(\n (state) => state.__controls.requestedPictureInPictureLastTime,\n async () => {\n try {\n const isPictureInPicture = await isCurrentlyPictureInPicture(element);\n if (isPictureInPicture) await exitPictureInPicture(element);\n else await enterPictureInPicture(element);\n } catch (e) {\n warn((e as Error)?.message ?? \"Picture in picture is not supported\");\n\n store.setState((state) => ({\n __device: {\n ...state.__device,\n isPictureInPictureSupported: false,\n },\n }));\n }\n },\n );\n\n // Subscribe to autohide interactions\n const destroyAutohide = store.subscribe(\n (state) => ({\n lastInteraction: state.__controls.lastInteraction,\n autohide: state.__controls.autohide,\n }),\n async ({ lastInteraction, autohide }) => {\n if (autohide && lastInteraction) {\n store.getState().__controlsFunctions.setHidden(false);\n\n await delay(autohide);\n\n const parentElementOrElement = element?.parentElement ?? element;\n\n // we check if any children of the parent element are in an \"open\" state, which is the radix\n // data attribute for popovers and other elements\n // this is the only way to reliably hide the controls while a popover is shown, and possibly\n // is missing some data attributes for other primitives\n const openElement = parentElementOrElement?.querySelector?.(\n '[data-state=\"open\"]',\n );\n\n if (\n !openElement &&\n !store.getState().hidden &&\n lastInteraction === store.getState().__controls.lastInteraction\n ) {\n store.getState().__controlsFunctions.setHidden(true);\n }\n }\n },\n {\n equalityFn: (a, b) =>\n a?.lastInteraction === b?.lastInteraction &&\n a?.autohide === b?.autohide,\n },\n );\n\n // Subscribe to sizing requests\n const destroyRequestSizing = store.subscribe(\n (state) => ({\n lastTime: state.__controls.requestedMeasureLastTime,\n fullscreen: state.fullscreen,\n }),\n async () => {\n store.getState().__controlsFunctions.setSize({\n ...((element as unknown as HTMLVideoElement)?.videoHeight &&\n (element as unknown as HTMLVideoElement)?.videoWidth\n ? {\n media: {\n height: (element as unknown as HTMLVideoElement).videoHeight,\n width: (element as unknown as HTMLVideoElement).videoWidth,\n },\n }\n : {}),\n ...(element?.clientHeight && element?.clientWidth\n ? {\n container: {\n height: element.clientHeight,\n width: element.clientWidth,\n },\n }\n : {}),\n ...(typeof window !== \"undefined\" &&\n window?.innerHeight &&\n window?.innerWidth\n ? {\n window: {\n height: window.innerHeight,\n width: window.innerWidth,\n },\n }\n : {}),\n });\n },\n {\n equalityFn: (a, b) =>\n a?.fullscreen === b?.fullscreen && a?.lastTime === b?.lastTime,\n },\n );\n\n // Subscribe to media sizing changes\n const destroyMediaSizing = store.subscribe(\n (state) => state.__controls.size?.media,\n async (media) => {\n const parentElementOrElement = element?.parentElement ?? element;\n\n if (parentElementOrElement) {\n if (media?.height && media?.width) {\n const elementStyle = parentElementOrElement.style;\n elementStyle.setProperty(\n \"--livepeer-media-height\",\n `${media.height}px`,\n );\n elementStyle.setProperty(\n \"--livepeer-media-width\",\n `${media.width}px`,\n );\n }\n }\n },\n {\n equalityFn: (a, b) => a?.height === b?.height && a?.width === b?.width,\n },\n );\n\n // Subscribe to container sizing changes\n const destroyContainerSizing = store.subscribe(\n (state) => state.__controls.size?.container,\n async (container) => {\n const parentElementOrElement = element?.parentElement ?? element;\n\n if (parentElementOrElement) {\n if (container?.height && container?.width) {\n const elementStyle = parentElementOrElement.style;\n elementStyle.setProperty(\n \"--livepeer-container-height\",\n `${container.height}px`,\n );\n elementStyle.setProperty(\n \"--livepeer-container-width\",\n `${container.width}px`,\n );\n }\n }\n },\n {\n equalityFn: (a, b) => a?.height === b?.height && a?.width === b?.width,\n },\n );\n\n return () => {\n destroyAutohide?.();\n destroyContainerSizing?.();\n destroyFullscreen?.();\n destroyMediaSizing?.();\n destroyMute?.();\n destroyPictureInPicture?.();\n destroyPlaybackRate?.();\n destroyPlayPause?.();\n destroyPosterImage?.();\n destroyRequestSizing?.();\n destroySeeking?.();\n destroyVolume?.();\n destroySource?.();\n\n cleanupPosterImage?.();\n cleanupSource?.();\n };\n};\n","import {\n calculateVideoQualityDimensions,\n type VideoQuality,\n} from \"@livepeer/core/media\";\nimport Hls, { type ErrorData, type HlsConfig } from \"hls.js\";\nimport { isClient } from \"../media/utils\";\n\nexport const VIDEO_HLS_INITIALIZED_ATTRIBUTE =\n \"data-livepeer-video-hls-initialized\";\n\nexport type HlsError = ErrorData;\n\nexport type VideoConfig = { autoplay?: boolean };\nexport type HlsVideoConfig = Partial<HlsConfig> & {\n autoPlay?: boolean;\n};\n\n/**\n * Checks if hls.js can play in the browser.\n */\nexport const isHlsSupported = () => (isClient() ? Hls.isSupported() : true);\n\n/**\n * Create an hls.js instance and attach to the provided media element.\n */\nexport const createNewHls = <TElement extends HTMLMediaElement>({\n source,\n element,\n callbacks,\n aspectRatio,\n config,\n initialQuality,\n}: {\n source: string;\n element: TElement;\n initialQuality: VideoQuality;\n aspectRatio: number;\n callbacks: {\n onLive?: (v: boolean) => void;\n onPlaybackOffsetUpdated?: (d: number) => void;\n onDuration?: (v: number) => void;\n onCanPlay?: () => void;\n onError?: (data: HlsError) => void;\n onRedirect?: (url: string | null) => void;\n };\n config: HlsVideoConfig;\n}): {\n setQuality: (quality: VideoQuality) => void;\n destroy: () => void;\n} => {\n // do not attach twice\n if (element.getAttribute(VIDEO_HLS_INITIALIZED_ATTRIBUTE) === \"true\") {\n return {\n setQuality: () => {\n //\n },\n destroy: () => {\n //\n },\n };\n }\n\n element.setAttribute(VIDEO_HLS_INITIALIZED_ATTRIBUTE, \"true\");\n\n const hls = new Hls({\n backBufferLength: 60 * 1.5,\n manifestLoadingMaxRetry: 0,\n fragLoadingMaxRetry: 0,\n levelLoadingMaxRetry: 0,\n appendErrorMaxRetry: 0,\n ...config,\n ...(config?.liveSyncDurationCount\n ? {\n liveSyncDurationCount: config.liveSyncDurationCount,\n }\n : {\n liveMaxLatencyDurationCount: 7,\n liveSyncDurationCount: 3,\n }),\n });\n\n const onDestroy = () => {\n hls?.destroy?.();\n element?.removeAttribute?.(VIDEO_HLS_INITIALIZED_ATTRIBUTE);\n };\n\n if (element) {\n hls.attachMedia(element);\n }\n\n let redirected = false;\n\n hls.on(Hls.Events.LEVEL_LOADED, async (_e, data) => {\n const { live, totalduration: duration, url } = data.details;\n\n if (!redirected) {\n callbacks?.onRedirect?.(url ?? null);\n redirected = true;\n }\n\n callbacks?.onLive?.(Boolean(live));\n callbacks?.onDuration?.(duration ?? 0);\n });\n\n hls.on(Hls.Events.MEDIA_ATTACHED, async () => {\n hls.loadSource(source);\n\n hls.on(Hls.Events.MANIFEST_PARSED, (_event, _data) => {\n setQuality({\n hls: hls ?? null,\n quality: initialQuality,\n aspectRatio,\n });\n\n callbacks?.onCanPlay?.();\n if (config.autoPlay) element?.play?.();\n });\n });\n\n hls.on(Hls.Events.ERROR, async (_event, data) => {\n const { details, fatal } = data;\n\n const isManifestParsingError = details === \"manifestParsingError\";\n\n if (!fatal && !isManifestParsingError) return;\n callbacks?.onError?.(data);\n\n if (fatal) {\n console.error(`Fatal error : ${data.details}`);\n switch (data.type) {\n case Hls.ErrorTypes.MEDIA_ERROR:\n hls.recoverMediaError();\n break;\n case Hls.ErrorTypes.NETWORK_ERROR:\n console.error(`A network error occurred: ${data.details}`);\n break;\n default:\n console.error(`An unrecoverable error occurred: ${data.details}`);\n hls.destroy();\n break;\n }\n }\n });\n\n function updateOffset() {\n const currentDate = Date.now();\n const newDate = hls.playingDate;\n\n if (newDate && currentDate) {\n callbacks?.onPlaybackOffsetUpdated?.(currentDate - newDate.getTime());\n }\n }\n\n const updateOffsetInterval = setInterval(updateOffset, 2000);\n\n return {\n destroy: () => {\n onDestroy?.();\n clearInterval?.(updateOffsetInterval);\n element?.removeAttribute?.(VIDEO_HLS_INITIALIZED_ATTRIBUTE);\n },\n setQuality: (videoQuality) => {\n setQuality({\n hls: hls ?? null,\n quality: videoQuality,\n aspectRatio,\n });\n },\n };\n};\n\nconst setQuality = ({\n hls,\n quality,\n aspectRatio,\n}: {\n hls: Hls | null;\n quality: VideoQuality;\n aspectRatio: number;\n}) => {\n if (hls) {\n const { width } = calculateVideoQualityDimensions(quality, aspectRatio);\n\n if (!width || quality === \"auto\") {\n hls.currentLevel = -1; // Auto level\n return;\n }\n\n if (hls.levels && hls.levels.length > 0) {\n // Sort levels by the absolute difference between their width and the desired width\n const sortedLevels = hls.levels\n .map((level, index) => ({ ...level, index }))\n .sort(\n (a, b) =>\n Math.abs((width ?? 0) - a.width) - Math.abs((width ?? 0) - b.width),\n );\n\n // Choose the level with the smallest difference in width\n const bestMatchLevel = sortedLevels?.[0];\n\n if ((bestMatchLevel?.index ?? -1) >= 0) {\n hls.currentLevel = bestMatchLevel.index;\n } else {\n hls.currentLevel = -1;\n }\n }\n }\n};\n","import type { Src } from \"@livepeer/core/media\";\nimport { noop } from \"@livepeer/core/utils\";\n\nexport const isClient = () => typeof window !== \"undefined\";\nexport const ua = () =>\n isClient() ? window?.navigator?.userAgent?.toLowerCase() : \"\";\nexport const isIos = () => /iphone|ipad|ipod|ios|CriOS|FxiOS/.test(ua());\nexport const isAndroid = () => /android/.test(ua());\nexport const isMobile = () => isClient() && (isIos() || isAndroid());\nexport const isIphone = () =>\n isClient() && /(iPhone|iPod)/gi.test(window?.navigator?.platform);\nexport const isFirefox = () => /firefox/.test(ua());\nexport const isChrome = () => isClient() && !!window?.chrome;\nexport const isSafari = () =>\n Boolean(\n isClient() &&\n !isChrome() &&\n (window?.safari || isIos() || /(apple|safari)/.test(ua())),\n );\n\n/**\n * To detect autoplay, we create a video element and call play on it, if it is `paused` after\n * a `play()` call, autoplay is supported. Although this unintuitive, it works across browsers\n * and is currently the lightest way to detect autoplay without using a data source.\n *\n * @see {@link https://github.com/ampproject/amphtml/blob/9bc8756536956780e249d895f3e1001acdee0bc0/src/utils/video.js#L25}\n */\nexport const canAutoplay = (\n muted = true,\n playsinline = true,\n): Promise<boolean> => {\n if (!isClient()) return Promise.resolve(false);\n\n const video = document.createElement(\"video\");\n\n if (muted) {\n video.setAttribute(\"muted\", \"\");\n video.muted = true;\n }\n\n if (playsinline) {\n video.setAttribute(\"playsinline\", \"\");\n video.setAttribute(\"webkit-playsinline\", \"\");\n }\n\n video.setAttribute(\"height\", \"0\");\n video.setAttribute(\"width\", \"0\");\n\n video.style.position = \"fixed\";\n video.style.top = \"0\";\n video.style.width = \"0\";\n video.style.height = \"0\";\n video.style.opacity = \"0\";\n\n // Promise wrapped this way to catch both sync throws and async rejections.\n // More info: https://github.com/tc39/proposal-promise-try\n new Promise((resolve) => resolve(video.play())).catch(noop);\n\n return Promise.resolve(!video.paused);\n};\n\n/**\n * Checks if the native HTML5 video player can play the mime type.\n */\nexport const canPlayMediaNatively = (src: Src): boolean => {\n if (isClient() && src?.mime) {\n if (src?.type?.includes(\"audio\")) {\n const audio = document.createElement(\"audio\");\n return audio.canPlayType(src.mime).length > 0;\n }\n\n const video = document.createElement(\"video\");\n return video.canPlayType(src.mime).length > 0;\n }\n\n return true;\n};\n","import { NOT_ACCEPTABLE_ERROR_MESSAGE } from \"@livepeer/core/errors\";\nimport type { AccessControlParams } from \"@livepeer/core/media\";\nimport { isClient } from \"../media/utils\";\n\n/**\n * Checks if WebRTC is supported and returns the appropriate RTCPeerConnection constructor.\n */\nexport const getRTCPeerConnectionConstructor = () => {\n // Check if the current environment is a client (browser)\n if (!isClient()) {\n return null; // If not a client, WebRTC is not supported\n }\n\n // Return the constructor for RTCPeerConnection with any vendor prefixes\n return (\n window.RTCPeerConnection ||\n window.webkitRTCPeerConnection ||\n window.mozRTCPeerConnection ||\n null // Return null if none of the constructors are available\n );\n};\n\n/**\n * Creates a new RTCPeerConnection instance with the given STUN and TURN servers.\n */\nexport function createPeerConnection(\n host: string | null,\n iceServers?: RTCIceServer | RTCIceServer[],\n): RTCPeerConnection | null {\n const RTCPeerConnectionConstructor = getRTCPeerConnectionConstructor();\n\n if (!RTCPeerConnectionConstructor) {\n throw new Error(\"No RTCPeerConnection constructor found in this browser.\");\n }\n\n // Defaults to Mist behavior\n const hostNoPort = host?.split(\":\")[0];\n const defaultIceServers = host\n ? [\n {\n urls: `stun:${hostNoPort}`,\n },\n {\n urls: `turn:${hostNoPort}`,\n username: \"livepeer\",\n credential: \"livepeer\",\n },\n ]\n : [];\n\n return new RTCPeerConnectionConstructor({\n iceServers: iceServers\n ? Array.isArray(iceServers)\n ? iceServers\n : [iceServers]\n : defaultIceServers,\n });\n}\n\nconst DEFAULT_TIMEOUT = 10000;\n\n/**\n * Performs the actual SDP exchange.\n *\n * 1. Sends the SDP offer to the server,\n * 2. Awaits the server's offer.\n *\n * SDP describes what kind of media we can send and how the server and client communicate.\n *\n * https://developer.mozilla.org/en-US/docs/Glossary/SDP\n * https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html#name-protocol-operation\n */\nexport async function negotiateConnectionWithClientOffer(\n peerConnection: RTCPeerConnection | null | undefined,\n endpoint: string | null | undefined,\n ofr: RTCSessionDescription | null,\n controller: AbortController,\n accessControl: AccessControlParams,\n sdpTimeout: number | null,\n): Promise<Date> {\n if (peerConnection && endpoint && ofr) {\n /**\n * This response contains the server's SDP offer.\n * This specifies how the client should communicate,\n * and what kind of media client and server have negotiated to exchange.\n */\n const response = await postSDPOffer(\n endpoint,\n ofr.sdp,\n controller,\n accessControl,\n sdpTimeout,\n );\n if (response.ok) {\n const answerSDP = await response.text();\n await peerConnection.setRemoteDescription(\n new RTCSessionDescription({ type: \"answer\", sdp: answerSDP }),\n );\n\n const playheadUtc = response.headers.get(\"Playhead-Utc\");\n\n return new Date(playheadUtc ?? new Date());\n }\n if (response.status === 406) {\n throw new Error(NOT_ACCEPTABLE_ERROR_MESSAGE);\n }\n\n const errorMessage = await response.text();\n throw new Error(errorMessage);\n }\n\n throw new Error(\"Peer connection not defined.\");\n}\n\n/**\n * Helper function to prefer H264 codec in SDP\n */\nfunction preferCodec(sdp: string, codec: string): string {\n const lines = sdp.split(\"\\r\\n\");\n const mLineIndex = lines.findIndex((line) => line.startsWith(\"m=video\"));\n\n if (mLineIndex === -1) return sdp;\n\n const codecRegex = new RegExp(`a=rtpmap:(\\\\d+) ${codec}(/\\\\d+)+`);\n const codecLine = lines.find((line) => codecRegex.test(line));\n\n if (!codecLine) return sdp;\n\n // biome-ignore lint/style/noNonNullAssertion: todo: fix this\n const codecPayload = codecRegex.exec(codecLine)![1];\n const mLineElements = lines[mLineIndex].split(\" \");\n\n const reorderedMLine = [\n ...mLineElements.slice(0, 3),\n codecPayload,\n ...mLineElements.slice(3).filter((payload) => payload !== codecPayload),\n ];\n\n lines[mLineIndex] = reorderedMLine.join(\" \");\n return lines.join(\"\\r\\n\");\n}\n\n/**\n * Constructs the client's SDP offer with H264 codec preference\n *\n * SDP describes what kind of media we can send and how the server and client communicate.\n *\n * https://developer.mozilla.org/en-US/docs/Glossary/SDP\n * https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html#name-protocol-operation\n */\nexport async function constructClientOffer(\n peerConnection: RTCPeerConnection | null | undefined,\n endpoint: string | null | undefined,\n noIceGathering?: boolean,\n) {\n if (peerConnection && endpoint) {\n // Override createOffer to include H264 codec preference\n const originalCreateOffer = peerConnection.createOffer.bind(peerConnection);\n // @ts-ignore (TODO: fix this)\n peerConnection.createOffer = async function (...args) {\n // @ts-ignore (TODO: fix this)\n const originalOffer = await originalCreateOffer.apply(this, args);\n return new RTCSessionDescription({\n // @ts-ignore (TODO: fix this)\n type: originalOffer.type,\n // @ts-ignore (TODO: fix this)\n sdp: preferCodec(originalOffer.sdp, \"H264\"),\n });\n };\n\n /** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createOffer */\n const offer = await peerConnection.createOffer();\n\n /** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/setLocalDescription */\n await peerConnection.setLocalDescription(offer);\n\n /** Wait for ICE gathering to complete */\n if (noIceGathering) {\n return peerConnection.localDescription;\n }\n const ofr = await waitToCompleteICEGathering(peerConnection);\n if (!ofr) {\n throw Error(\"failed to gather ICE candidates for offer\");\n }\n\n return ofr;\n }\n\n return null;\n}\n\n// Regular expression to match the playback ID at the end of the URL\n// It looks for a string that follows the last \"+\" or \"/\" and continues to the end of the pathname\nconst playbackIdPattern = /([/+])([^/+?]+)$/;\nconst REPLACE_PLACEHOLDER = \"PLAYBACK_ID\";\n\nconst MAX_REDIRECT_CACHE_SIZE = 10;\nconst redirectUrlCache = new Map<string, URL>();\n\nfunction getCachedTemplate(key: string): URL | undefined {\n const cachedItem = redirectUrlCache.get(key);\n\n if (cachedItem) {\n redirectUrlCache.delete(key);\n redirectUrlCache.set(key, cachedItem);\n }\n\n return cachedItem;\n}\n\nfunction setCachedTemplate(key: string, value: URL): void {\n if (redirectUrlCache.has(key)) {\n redirectUrlCache.delete(key);\n } else if (redirectUrlCache.size >= MAX_REDIRECT_CACHE_SIZE) {\n const oldestKey = redirectUrlCache.keys().next().value;\n if (oldestKey) {\n redirectUrlCache.delete(oldestKey);\n }\n }\n\n redirectUrlCache.set(key, value);\n}\n\nasync function postSDPOffer(\n endpoint: string,\n data: string,\n controller: AbortController,\n accessControl: AccessControlParams,\n sdpTimeout: number | null,\n) {\n const id = setTimeout(\n () => controller.abort(),\n sdpTimeout ?? DEFAULT_TIMEOUT,\n );\n\n const urlForPost = new URL(endpoint);\n const parsedMatches = urlForPost.pathname.match(playbackIdPattern);\n const currentPlaybackId = parsedMatches?.[2];\n\n const cachedTemplateUrl = getCachedTemplate(endpoint);\n\n // if we both have a cached redirect URL and a match for the playback ID,\n // use these to shortcut the typical webrtc redirect flow\n if (cachedTemplateUrl && currentPlaybackId) {\n urlForPost.host = cachedTemplateUrl.host;\n urlForPost.pathname = cachedTemplateUrl.pathname.replace(\n REPLACE_PLACEHOLDER,\n currentPlaybackId,\n );\n urlForPost.search = cachedTemplateUrl.search;\n }\n\n const response = await fetch(urlForPost.toString(), {\n method: \"POST\",\n mode: \"cors\",\n headers: {\n \"content-type\": \"application/sdp\",\n ...(accessControl?.accessKey\n ? {\n \"Livepeer-Access-Key\": accessControl.accessKey,\n }\n : {}),\n ...(accessControl?.jwt\n ? {\n \"Livepeer-Jwt\": accessControl.jwt,\n }\n : {}),\n },\n body: data,\n signal: controller.signal,\n });\n\n clearTimeout(id);\n\n return response;\n}\n\nexport async function getRedirectUrl(\n endpoint: string,\n abortController: AbortController,\n timeout: number | null,\n) {\n try {\n const cachedTemplateUrl = getCachedTemplate(endpoint);\n\n if (cachedTemplateUrl) {\n const currentIngestUrl = new URL(endpoint);\n const matches = currentIngestUrl.pathname.match(playbackIdPattern);\n const currentPlaybackId = matches?.[2];\n\n if (currentPlaybackId) {\n const finalRedirectUrl = new URL(cachedTemplateUrl);\n finalRedirectUrl.pathname = cachedTemplateUrl.pathname.replace(\n REPLACE_PLACEHOLDER,\n currentPlaybackId,\n );\n return finalRedirectUrl;\n }\n }\n\n const id = setTimeout(\n () => abortController.abort(),\n timeout ?? DEFAULT_TIMEOUT,\n );\n\n const response = await fetch(endpoint, {\n method: \"HEAD\",\n signal: abortController.signal,\n });\n\n // consume response body\n await response.text();\n\n clearTimeout(id);\n\n const actualRedirectedUrl = new URL(response.url);\n\n if (actualRedirectedUrl) {\n const templateForCache = new URL(actualRedirectedUrl);\n templateForCache.pathname = templateForCache.pathname.replace(\n playbackIdPattern,\n `$1${REPLACE_PLACEHOLDER}`,\n );\n\n if (\n !templateForCache.searchParams.has(\"ingestpb\") ||\n templateForCache.searchParams.get(\"ingestpb\") !== \"true\"\n ) {\n setCachedTemplate(endpoint, templateForCache);\n }\n }\n return actualRedirectedUrl;\n // biome-ignore lint/correctness/noUnusedVariables: ignored using `--suppress`\n } catch (e) {\n return null;\n }\n}\n\n/**\n * Receives an RTCPeerConnection and waits until\n * the connection is initialized or a timeout passes.\n *\n * https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html#section-4.1\n * https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/iceGatheringState\n * https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/icegatheringstatechange_event\n */\nasync function waitToCompleteICEGathering(peerConnection: RTCPeerConnection) {\n return new Promise<RTCSessionDescription | null>((resolve) => {\n /** Wait at most five seconds for ICE gathering. */\n setTimeout(() => {\n resolve(peerConnection.localDescription);\n }, 5000);\n peerConnection.onicegatheringstatechange = (_ev) => {\n if (peerConnection.iceGatheringState === \"complete\") {\n resolve(peerConnection.localDescription);\n }\n };\n });\n}\n\n/**\n * Parses the ICE servers from the `Link` headers returned during SDP negotiation.\n */\n// function parseIceServersFromLinkHeader(\n// iceString: string | null,\n// ): NonNullable<RTCConfiguration['iceServers']> | null {\n// try {\n// const servers = iceString\n// ?.split(', ')\n// .map((serverStr) => {\n// const parts = serverStr.split('; ');\n// const server: NonNullable<RTCConfiguration['iceServers']>[number] = {\n// urls: '',\n// };\n\n// for (const part of parts) {\n// if (part.startsWith('stun:') || part.startsWith('turn:')) {\n// server.urls = part;\n// } else if (part.startsWith('username=')) {\n// server.username = part.slice('username=\"'.length, -1);\n// } else if (part.startsWith('credential=')) {\n// server.credential = part.slice('credential=\"'.length, -1);\n// }\n// }\n\n// return server;\n// })\n// .filter((server) => server.urls);\n\n// return servers && (servers?.length ?? 0) > 0 ? servers : null;\n// } catch (e) {\n// console.error(e);\n// }\n\n// return null;\n// }\n","import type { AccessControlParams } from \"@livepeer/core/media\";\n\nimport {\n constructClientOffer,\n createPeerConnection,\n getRedirectUrl,\n negotiateConnectionWithClientOffer,\n} from \"./shared\";\n\nexport const VIDEO_WEBRTC_INITIALIZED_ATTRIBUTE =\n \"data-livepeer-video-whep-initialized\";\n\n/**\n * Client that uses WHEP to play back video over WebRTC.\n *\n * https://www.ietf.org/id/draft-murillo-whep-00.html\n */\nexport const createNewWHEP = <TElement extends HTMLMediaElement>({\n source,\n element,\n callbacks,\n accessControl,\n sdpTimeout,\n iceServers,\n}: {\n source: string;\n element: TElement;\n callbacks: {\n onConnected?: () => void;\n onPlaybackOffsetUpdated?: (d: number) => void;\n onError?: (data: Error) => void;\n onRedirect?: (url: string | null) => void;\n };\n accessControl: AccessControlParams;\n sdpTimeout: number | null;\n iceServers?: RTCIceServer | RTCIceServer[];\n}): {\n destroy: () => void;\n} => {\n // do not attach twice\n if (element.getAttribute(VID