@signalwire/js
Version:
343 lines (302 loc) • 10.2 kB
text/typescript
import {
getLogger,
InternalVideoLayoutLayer,
InternalVideoLayout,
debounce,
uuid,
} from '@signalwire/core'
import { OverlayMap, LocalVideoOverlay, UserOverlay } from '../VideoOverlays'
import { addOverlayPrefix } from './roomSession'
const buildVideo = () => {
const video = document.createElement('video')
video.muted = true
video.autoplay = true
video.playsInline = true
/**
* Local and Remote video elements should never be paused
* and Safari/Firefox pause the video (ie: enabling PiP, switch cameras etc)
* We try to force it to keep playing.
*/
video.addEventListener('pause', () => {
video.play().catch((error) => {
getLogger().error('Video Element Paused', video, error)
})
})
return video
}
const waitForVideoReady = ({ element }: { element: HTMLVideoElement }) => {
return new Promise<void>((resolve) => {
element.addEventListener('canplay', function listener() {
element.removeEventListener('canplay', listener)
resolve()
})
element.addEventListener('resize', function listener() {
element.removeEventListener('resize', listener)
resolve()
})
})
}
const _getLocationStyles = ({
x,
y,
width,
height,
}: InternalVideoLayoutLayer) => {
return {
top: `${y}%`,
left: `${x}%`,
width: `${width}%`,
height: `${height}%`,
}
}
const _buildLayer = ({ location }: { location: InternalVideoLayoutLayer }) => {
const { top, left, width, height } = _getLocationStyles(location)
const layer = document.createElement('div')
layer.style.position = 'absolute'
layer.style.overflow = 'hidden'
layer.style.top = top
layer.style.left = left
layer.style.width = width
layer.style.height = height
return layer
}
const _updateLayer = ({
location,
element,
}: {
location: InternalVideoLayoutLayer
element: HTMLElement
}) => {
const { top, left, width, height } = _getLocationStyles(location)
element.style.top = top
element.style.left = left
element.style.width = width
element.style.height = height
return element
}
interface MakeLayoutChangedHandlerParams {
applyLocalVideoOverlay?: boolean
applyMemberOverlay?: boolean
overlayMap: OverlayMap
localVideoOverlay: LocalVideoOverlay
mirrorLocalVideoOverlay?: boolean
rootElement: HTMLElement
}
interface LayoutChangedHandlerParams {
layout: InternalVideoLayout
memberId: string
localStream: MediaStream
}
const makeLayoutChangedHandler = (params: MakeLayoutChangedHandlerParams) => {
const {
applyLocalVideoOverlay,
applyMemberOverlay,
overlayMap,
localVideoOverlay,
mirrorLocalVideoOverlay,
rootElement,
} = params
return async (params: LayoutChangedHandlerParams) => {
getLogger().debug('Process layout.changed')
try {
const { layout, memberId, localStream } = params
const { layers = [] } = layout
const mcuLayers = rootElement.querySelector('.mcuLayers')
// To handle the DOM updates in batch
const fragment = document.createDocumentFragment()
const currentOverlayIds = new Set()
// Make local video overlay for the self member
if (applyLocalVideoOverlay) {
const location = layers.find(({ member_id }) => member_id === memberId)
const overlayId = localVideoOverlay.id // LocalVideoOverlay ID is already unique
let myLayerEl = localVideoOverlay.domElement
currentOverlayIds.add(overlayId)
if (!location) {
getLogger().warn('Local video overlay location not found', memberId)
localVideoOverlay.status = 'hidden'
if (myLayerEl) {
// Should we remove it from the DOM and the OverlayMap?
localVideoOverlay.hide()
}
} else {
if (myLayerEl) {
getLogger().debug('Update local video overlay')
_updateLayer({ location, element: myLayerEl })
} else {
getLogger().debug('Build local video overlay')
myLayerEl = _buildLayer({ location })
myLayerEl.id = overlayId
const localVideo = buildVideo()
localVideo.srcObject = localStream
localVideo.disablePictureInPicture = true
localVideo.style.width = '100%'
localVideo.style.height = '100%'
localVideo.style.pointerEvents = 'none'
localVideo.style.objectFit = 'cover'
myLayerEl.appendChild(localVideo)
localVideoOverlay.domElement = myLayerEl
// Mirror the local video if user has requested it
if (mirrorLocalVideoOverlay) {
localVideoOverlay.setMirror()
}
}
// Show local overlay element only if the localStream has a valid video track
const hasVideo =
localStream
.getVideoTracks()
.filter((t) => t.enabled && t.readyState === 'live').length > 0
if (hasVideo && location.visible) {
localVideoOverlay.setMediaStream(localStream)
localVideoOverlay.show()
} else {
localVideoOverlay.hide()
}
// Append the local video overlay to the fragment
fragment.appendChild(myLayerEl)
}
}
// Make overlay for all members (including a self member)
if (applyMemberOverlay) {
layers.forEach((location) => {
const memberIdInLocation = location.member_id
if (!memberIdInLocation) return
const overlayId = addOverlayPrefix(memberIdInLocation)
currentOverlayIds.add(overlayId)
let overlay = overlayMap.get(overlayId)
if (overlay && overlay.domElement) {
// If the overlay already exists, modify its styles
getLogger().debug('Update an overlay for', memberIdInLocation)
_updateLayer({ location, element: overlay.domElement })
} else {
// If the overlay doesn't exist, create a new overlay
getLogger().debug('Build an overlay for', memberIdInLocation)
overlay = new UserOverlay({ id: overlayId })
overlayMap.set(overlayId, overlay)
const newLayer = _buildLayer({ location })
newLayer.id = `${overlayId}-${uuid()}` // Unique DOM ID since user is allowed to build multiple video elements
overlay.domElement = newLayer
}
if (!location.visible) {
overlay.hide()
} else {
overlay.show()
}
// Append the overlay element to the fragment
fragment.appendChild(overlay.domElement)
})
}
// Remove overlays that are no longer present
overlayMap.forEach((overlay, overlayId) => {
if (!currentOverlayIds.has(overlayId)) {
if (overlay.domElement && overlay.domElement.parentNode) {
overlay.domElement.parentNode.removeChild(overlay.domElement)
}
overlayMap.delete(overlayId)
}
})
// Replace mcuLayers content in batch with the fragment
if (mcuLayers) {
mcuLayers.innerHTML = ''
mcuLayers.appendChild(fragment)
}
} catch (error) {
getLogger().error('Layout Changed Error', error)
}
}
}
const cleanupElement = (rootElement: HTMLElement) => {
while (rootElement.firstChild) {
rootElement.removeChild(rootElement.firstChild)
}
}
const setVideoMediaTrack = ({
track,
element,
}: {
track: MediaStreamTrack
element: HTMLVideoElement
}) => {
element.srcObject = new MediaStream([track])
track.addEventListener('ended', () => {
element.srcObject = null
element.remove()
})
}
const createRootElementResizeObserver = ({
video,
rootElement,
paddingWrapper,
}: {
video: HTMLVideoElement
rootElement: HTMLElement
paddingWrapper: HTMLDivElement
}) => {
const computePaddingWrapperWidth = (width: number, height: number) => {
const nativeVideoRatio = video.videoWidth / video.videoHeight
const rootElementRatio = width / height
if (nativeVideoRatio > rootElementRatio) {
return '100%'
} else {
return `${height * nativeVideoRatio}px`
}
}
// Debounce to avoid multiple calls
const update = debounce(
({ width, height }: { width: number; height: number }) => {
const maxPaddingBottom = (video.videoHeight / video.videoWidth) * 100
if (paddingWrapper) {
const pb = (height / width) * 100
paddingWrapper.style.paddingBottom = `${
pb > maxPaddingBottom ? maxPaddingBottom : pb
}%`
paddingWrapper.style.width = computePaddingWrapperWidth(width, height)
}
},
100
)
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
if (entry.contentBoxSize) {
// Firefox implements `contentBoxSize` as a single content rect, rather than an array
const { inlineSize, blockSize } = Array.isArray(entry.contentBoxSize)
? entry.contentBoxSize[0]
: entry.contentBoxSize
update({ width: inlineSize, height: blockSize })
} else if (entry.contentRect) {
update({
width: entry.contentRect.width,
height: entry.contentRect.height,
})
}
})
})
/**
* When the intrinsic dimensions of the video changes, the root element resize may or may not trigger.
* For example; remote stream from the server changes dimensions from 16/9 (Landscape) to 9/16 (Portrait) mode.
* For this reason we need to listen for the 'resize' event on the video element.
*/
const onVideoResize = () => {
const width = rootElement.clientWidth
const height = rootElement.clientHeight
update({ width, height })
}
return {
start: () => {
observer.observe(rootElement)
video.addEventListener('resize', onVideoResize)
},
stop: () => {
observer.disconnect()
video.removeEventListener('resize', onVideoResize)
},
}
}
export {
buildVideo,
cleanupElement,
makeLayoutChangedHandler,
setVideoMediaTrack,
waitForVideoReady,
createRootElementResizeObserver,
LocalVideoOverlay,
}