@signalwire/js
Version:
291 lines (256 loc) • 9.2 kB
text/typescript
import {
FabricLayoutChangedEventParams,
VideoLayoutChangedEventParams,
getLogger,
uuid,
} from '@signalwire/core'
import {
buildVideo,
cleanupElement,
createRootElementResizeObserver,
makeLayoutChangedHandler,
setVideoMediaTrack,
waitForVideoReady,
} from './utils/videoElement'
import { addSDKPrefix } from './utils/roomSession'
import { OverlayMap, LocalVideoOverlay } from './VideoOverlays'
import {
FabricRoomSession,
isFabricRoomSession,
} from './fabric/FabricRoomSession'
import { VideoRoomSession, isVideoRoomSession } from './video/VideoRoomSession'
export interface BuildVideoElementParams {
applyLocalVideoOverlay?: boolean
applyMemberOverlay?: boolean
mirrorLocalVideoOverlay?: boolean
room: FabricRoomSession | VideoRoomSession
rootElement?: HTMLElement
}
export interface BuildVideoElementReturnType {
element: HTMLElement
overlayMap: OverlayMap
localVideoOverlay: LocalVideoOverlay
unsubscribe(): void
}
export const buildVideoElement = async (
params: BuildVideoElementParams
): Promise<BuildVideoElementReturnType> => {
try {
const {
room,
rootElement: element,
applyLocalVideoOverlay = true,
applyMemberOverlay = true,
mirrorLocalVideoOverlay = true,
} = params
let hasVideoTrack = false
const overlayMap: OverlayMap = new Map()
const id = uuid()
let rootElement: HTMLElement
if (element) {
rootElement = element
} else {
rootElement = document.createElement('div')
rootElement.id = `rootElement-${id}`
}
/**
* We used this `LocalVideoOverlay` class to interact with the localVideo
* overlay DOM element in here and in the `makeLayoutChangedHandler`.
*/
const overlayId = addSDKPrefix(id)
const localVideoOverlay = new LocalVideoOverlay({
id: overlayId,
mirrorLocalVideoOverlay,
room,
})
if (applyLocalVideoOverlay) {
overlayMap.set(overlayId, localVideoOverlay)
}
const makeLayout = makeLayoutChangedHandler({
applyLocalVideoOverlay,
applyMemberOverlay,
overlayMap,
localVideoOverlay,
mirrorLocalVideoOverlay,
rootElement,
})
const processLayoutChanged = (params: any) => {
// @ts-expect-error
const hasVideoSender = room.peer?.hasVideoSender
if (hasVideoSender && room.localStream) {
makeLayout({
layout: params.layout,
localStream: room.localStream,
memberId: room.memberId,
})
} else {
getLogger().debug(
'No local video sender or local stream, hiding local video overlay',
hasVideoSender,
room.localStream
)
localVideoOverlay.hide()
}
}
const layoutChangedHandler = (
params: FabricLayoutChangedEventParams | VideoLayoutChangedEventParams
) => {
getLogger().debug('Received layout.changed - videoTrack', hasVideoTrack)
if (hasVideoTrack) {
processLayoutChanged(params)
return
}
}
const processVideoTrack = async (track: MediaStreamTrack) => {
hasVideoTrack = true
await videoElementSetup({
applyLocalVideoOverlay,
applyMemberOverlay,
rootElement,
track,
})
const roomCurrentLayoutEvent = room.currentLayoutEvent
// If the `layout.changed` has already been received, process the layout
if (roomCurrentLayoutEvent) {
processLayoutChanged(roomCurrentLayoutEvent)
}
}
// If the remote video already exist, inject the remote stream to the video element
// @ts-expect-error
const videoTrack = room.peer?.remoteVideoTrack as MediaStreamTrack | null
if (videoTrack) {
await processVideoTrack(videoTrack)
}
// Handle the RTCPeer `track` event
const trackHandler = async function (event: RTCTrackEvent) {
if (event.track.kind === 'video') {
await processVideoTrack(event.track)
}
}
const unsubscribe = () => {
cleanupElement(rootElement)
overlayMap.clear() // Use "delete" rather than "clear" if we want to update the reference
room.overlayMap = overlayMap
if (isFabricRoomSession(room)) {
room.off('track', trackHandler)
room.off('layout.changed', layoutChangedHandler)
room.off('destroy', unsubscribe)
} else if (isVideoRoomSession(room)) {
room.off('track', trackHandler)
room.off('layout.changed', layoutChangedHandler)
room.off('destroy', unsubscribe)
}
localVideoOverlay.detachListeners()
}
/**
* Using `on` instead of `once` (or `off` within trackHandler) because
* there are cases (promote/demote) where we need to handle multiple `track`
* events and update the videoEl with the new track.
*/
if (isFabricRoomSession(room)) {
room.on('track', trackHandler)
room.on('layout.changed', layoutChangedHandler)
room.once('destroy', unsubscribe)
} else if (isVideoRoomSession(room)) {
room.on('track', trackHandler)
room.on('layout.changed', layoutChangedHandler)
room.once('destroy', unsubscribe)
}
/**
* The room object is only being used to listen for events.
* The "buildVideoElement" function does not directly manipulate the room object in order to maintain immutability.
* Currently, we are overriding the following room properties in case the user calls "buildVideoElement" more than once.
* However, this can be moved out of here easily if we prefer not to override.
*/
room.overlayMap = overlayMap
room.localVideoOverlay = localVideoOverlay
return { element: rootElement, overlayMap, localVideoOverlay, unsubscribe }
} catch (error) {
getLogger().error('Unable to build the video element')
throw error
}
}
interface VideoElementSetupWorkerParams {
rootElement: HTMLElement
track: MediaStreamTrack
applyLocalVideoOverlay?: boolean
applyMemberOverlay?: boolean
}
const videoElementSetup = async (options: VideoElementSetupWorkerParams) => {
try {
const { applyLocalVideoOverlay, applyMemberOverlay, track, rootElement } =
options
// Create a video element
const videoElement = buildVideo()
setVideoMediaTrack({ element: videoElement, track })
videoElement.style.width = '100%'
videoElement.style.maxHeight = '100%'
if (rootElement.querySelector('.mcuContent')) {
getLogger().debug('MCU Content already there')
return
}
const mcuWrapper = document.createElement('div')
mcuWrapper.style.position = 'absolute'
mcuWrapper.style.top = '0'
mcuWrapper.style.left = '0'
mcuWrapper.style.right = '0'
mcuWrapper.style.bottom = '0'
mcuWrapper.appendChild(videoElement)
const paddingWrapper = document.createElement('div')
paddingWrapper.classList.add('paddingWrapper')
paddingWrapper.style.paddingBottom = '56.25%' // (9 / 16) * 100
paddingWrapper.style.position = 'relative'
paddingWrapper.style.width = '100%'
paddingWrapper.appendChild(mcuWrapper)
let layersWrapper: HTMLDivElement | null = null
// If the both flags are false, no need to create the MCU
if (applyLocalVideoOverlay || applyMemberOverlay) {
layersWrapper = document.createElement('div')
layersWrapper.classList.add('mcuLayers')
layersWrapper.style.display = 'none'
paddingWrapper.appendChild(layersWrapper)
}
const relativeWrapper = document.createElement('div')
relativeWrapper.classList.add('mcuContent')
relativeWrapper.style.position = 'relative'
relativeWrapper.style.width = '100%'
relativeWrapper.style.height = '100%'
relativeWrapper.style.margin = '0 auto'
relativeWrapper.style.display = 'flex'
relativeWrapper.style.alignItems = 'center'
relativeWrapper.style.justifyContent = 'center'
relativeWrapper.appendChild(paddingWrapper)
rootElement.style.width = '100%'
rootElement.style.height = '100%'
rootElement.appendChild(relativeWrapper)
getLogger().debug('MCU readyState 1 >>', videoElement.readyState)
if (videoElement.readyState === HTMLMediaElement.HAVE_NOTHING) {
getLogger().debug('Wait for the MCU to be ready')
await waitForVideoReady({ element: videoElement })
}
getLogger().debug('MCU is ready..')
/**
* Listen for the rootElement and the videoElement size changes and update the paddingWrapper.
* The ResizeObserver and the video "resize" event make sure:
* - The video should always maintain the aspect ratio.
* - The video should not overflow the user passed rootElement.
* - The video should not be cropped.
*/
const rootElementResizeObserver = createRootElementResizeObserver({
rootElement,
video: videoElement,
paddingWrapper,
})
rootElementResizeObserver.start()
track.addEventListener('ended', () => {
if (rootElementResizeObserver) {
rootElementResizeObserver.stop()
}
})
if (layersWrapper) {
layersWrapper.style.display = 'block'
}
} catch (error) {
getLogger().error('Handle video track error', error)
}
}