Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
292 changes: 174 additions & 118 deletions web/src/components/broadcast/Broadcast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import ProfileSettings from './ProfileSettings';
import Player from '../player/Player';
import { LocaleContext } from '../../providers/LocaleProvider';
import toBase64Utf8 from '../../utilities/base64';
import { useReconnectController } from '../../hooks/useReconnectController';

const mediaOptions = {
audio: true,
Expand Down Expand Up @@ -39,9 +40,21 @@ function BrowserBroadcaster() {
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
const localMediaStreamRef = useRef<MediaStream | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
const whipResourceUrlRef = useRef<string | null>(null)
const setupInProgressRef = useRef<boolean>(false)
const videoRef = useRef<HTMLVideoElement>(null)
const hasSignalRef = useRef<boolean>(false);
const badSignalCountRef = useRef<number>(10);
const shouldAutoReconnectRef = useRef<boolean>(false)
const {
scheduleReconnect,
reset: resetReconnect,
cancel: cancelReconnect,
} = useReconnectController({
baseDelayMs: 500,
maxDelayMs: 8_000,
maxAttempts: 8,
})

const endStream = () => navigate('/')
const requestMedia = (source: MediaSource) => {
Expand All @@ -64,107 +77,99 @@ function BrowserBroadcaster() {
.forEach((streamTrack: MediaStreamTrack) => streamTrack.stop())
}, [])

const getSenderByKind = useCallback((peerConnection: RTCPeerConnection, kind: "audio" | "video") => {
return peerConnection.getTransceivers().find(transceiver => transceiver.sender.track?.kind === kind)?.sender ??
peerConnection.getTransceivers().find(transceiver => transceiver.receiver.track.kind === kind)?.sender ??
null
const closeEventSource = useCallback(() => {
eventSourceRef.current?.close()
eventSourceRef.current = null
}, [])

const deleteWhipSession = useCallback(async () => {
const currentWhipResource = whipResourceUrlRef.current
if (!currentWhipResource) {
return
}

whipResourceUrlRef.current = null

await fetch(currentWhipResource, {
method: 'DELETE'
}).catch((err) => {
console.error("WHIP.DeleteSession", err)
})
}, [])

const closePeerConnectionAndSession = useCallback(async () => {
closeEventSource()
peerConnectionRef.current?.close()
peerConnectionRef.current = null
await deleteWhipSession()
}, [closeEventSource, deleteWhipSession])

const isFatalWhipStatus = useCallback((statusCode: number) => {
return statusCode === 400 || statusCode === 401 || statusCode === 403 || statusCode === 404
}, [])

const triggerReconnect = useCallback((setupPublisherSession: () => Promise<void>) => {
if (!shouldAutoReconnectRef.current) {
return
}

scheduleReconnect(() => {
void setupPublisherSession()
})
}, [scheduleReconnect])

useEffect(() => {
return () => {
eventSourceRef.current?.close()
cancelReconnect()
shouldAutoReconnectRef.current = false
void closePeerConnectionAndSession()
stopLocalMediaStream(localMediaStreamRef.current)
localMediaStreamRef.current = null
peerConnectionRef.current?.close()
peerConnectionRef.current = null
}
}, [stopLocalMediaStream])
}, [cancelReconnect, closePeerConnectionAndSession, stopLocalMediaStream])

useEffect(() => {
if (useDisplayMedia === "None") {
shouldAutoReconnectRef.current = false
cancelReconnect()
return;
}

let cancelled = false
let cancelled = false
shouldAutoReconnectRef.current = true

const mediaPromise = useDisplayMedia == "Screen" ?
navigator.mediaDevices.getDisplayMedia(mediaOptions) :
navigator.mediaDevices.getUserMedia(mediaOptions)

mediaPromise.then(async mediaStream => {
const nextLocalMediaStream = mediaStream
const setupPublisherSession = async () => {
if (setupInProgressRef.current || cancelled) {
return
}

if (cancelled) {
stopLocalMediaStream(nextLocalMediaStream)
return;
const mediaStream = localMediaStreamRef.current
if (!mediaStream) {
return
}

setupInProgressRef.current = true
setPeerConnectionDisconnected(() => false)
setConnectFailed(() => false)

const videoTrack = mediaStream.getVideoTracks()[0] ?? null
const audioTrack = mediaStream.getAudioTracks()[0] ?? null

const existingPeerConnection = peerConnectionRef.current
if (existingPeerConnection) {
const videoSender = getSenderByKind(existingPeerConnection, "video")
const audioSender = getSenderByKind(existingPeerConnection, "audio")

await Promise.all([
videoSender?.replaceTrack(videoTrack) ?? Promise.resolve(),
audioSender?.replaceTrack(audioTrack) ?? Promise.resolve(),
])

if (
cancelled ||
peerConnectionRef.current !== existingPeerConnection
) {
stopLocalMediaStream(nextLocalMediaStream)
return;
}
await closePeerConnectionAndSession()

videoRef.current!.srcObject = mediaStream
const previousLocalMediaStream = localMediaStreamRef.current
localMediaStreamRef.current = nextLocalMediaStream
stopLocalMediaStream(previousLocalMediaStream)
return
}

const peerConnection = new RTCPeerConnection();
const peerConnection = new RTCPeerConnection()
peerConnectionRef.current = peerConnection

if (
cancelled ||
peerConnectionRef.current !== peerConnection
) {
if (peerConnectionRef.current === peerConnection) {
peerConnectionRef.current = null
}
peerConnection.close()
stopLocalMediaStream(nextLocalMediaStream)
return
}

videoRef.current!.srcObject = mediaStream
const previousLocalMediaStream = localMediaStreamRef.current
localMediaStreamRef.current = nextLocalMediaStream
stopLocalMediaStream(previousLocalMediaStream)

peerConnection.addTransceiver(audioTrack ? audioTrack : "audio", { direction: 'sendonly' })

const isFirefox = navigator.userAgent.toLowerCase().includes('firefox')
const encodingPrefix = "Web"
peerConnection.addTransceiver(videoTrack ? videoTrack : "video", {
direction: 'sendonly',
sendEncodings: isFirefox ? undefined : [
{
rid: encodingPrefix + 'High',
},
{
rid: encodingPrefix + 'Mid',
scaleResolutionDownBy: 2.0
},
{
rid: encodingPrefix + 'Low',
scaleResolutionDownBy: 4.0
}
{ rid: encodingPrefix + 'High' },
{ rid: encodingPrefix + 'Mid', scaleResolutionDownBy: 2.0 },
{ rid: encodingPrefix + 'Low', scaleResolutionDownBy: 4.0 },
],
})

Expand All @@ -173,63 +178,114 @@ function BrowserBroadcaster() {
setPublishSuccess(() => true)
setMediaAccessError(() => null)
setPeerConnectionDisconnected(() => false)
} else if (peerConnection.iceConnectionState === 'disconnected' || peerConnection.iceConnectionState === 'failed') {
resetReconnect()
return
}

if (peerConnection.iceConnectionState === 'disconnected' || peerConnection.iceConnectionState === 'failed') {
setPublishSuccess(() => false)
setPeerConnectionDisconnected(() => true)
triggerReconnect(setupPublisherSession)
}
}

peerConnection
.createOffer()
.then(offer => {
peerConnection.setLocalDescription(offer)
.catch((err) => console.error("SetLocalDescription", err));

fetch(`/api/whip`, {
method: 'POST',
body: offer.sdp,
headers: {
Authorization: `Bearer ${toBase64Utf8(streamKey)}`,
'Content-Type': 'application/sdp'
}
}).then(r => {

if (r.status !== 201) {
setConnectFailed(() => true)
console.error("WHIP Endpoint did not return 201")
}
const parsedLinkHeader = parseLinkHeader(r.headers.get('Link'))

if (parsedLinkHeader === null || parsedLinkHeader === undefined) {
throw new DOMException("Missing link header");
}

eventSourceRef.current?.close()
const evtSource = new EventSource(`${parsedLinkHeader['urn:ietf:params:whep:ext:core:server-sent-events'].url}`)
eventSourceRef.current = evtSource

evtSource.onerror = () => evtSource.close();

// Receive current status of the stream
// evtSource.addEventListener("status", (event: MessageEvent) => setCurrentStreamStatus(JSON.parse(event.data)))

return r.text()
}).then(answer => {
peerConnection.setRemoteDescription({
sdp: answer,
type: 'answer'
}).catch((err) => console.error("SetRemoteDescription", err))
})
try {
const offer = await peerConnection.createOffer()
await peerConnection.setLocalDescription(offer)

const response = await fetch(`/api/whip`, {
method: 'POST',
body: offer.sdp,
headers: {
Authorization: `Bearer ${toBase64Utf8(streamKey)}`,
'Content-Type': 'application/sdp'
}
})
}, (reason: ErrorMessageEnum) => {
setMediaAccessError(() => reason)
setUseDisplayMedia("None");
})

if (response.status !== 201) {
setConnectFailed(() => true)
setPublishSuccess(() => false)
if (isFatalWhipStatus(response.status)) {
shouldAutoReconnectRef.current = false
cancelReconnect()
return
}

throw new DOMException("WHIP Endpoint did not return 201")
}

whipResourceUrlRef.current = response.headers.get('Location')

const parsedLinkHeader = parseLinkHeader(response.headers.get('Link'))
if (parsedLinkHeader === null || parsedLinkHeader === undefined) {
throw new DOMException("Missing link header")
}

closeEventSource()
const evtSource = new EventSource(`${parsedLinkHeader['urn:ietf:params:whep:ext:core:server-sent-events'].url}`)
eventSourceRef.current = evtSource

evtSource.onerror = () => {
closeEventSource()
setPublishSuccess(() => false)
setPeerConnectionDisconnected(() => true)
triggerReconnect(setupPublisherSession)
}

const answer = await response.text()
await peerConnection.setRemoteDescription({
sdp: answer,
type: 'answer'
})
} catch (err) {
console.error("Broadcast.SetupPublisherSession", err)
setPublishSuccess(() => false)
triggerReconnect(setupPublisherSession)
} finally {
setupInProgressRef.current = false
}
}

const requestAndStartSession = async () => {
const mediaPromise = useDisplayMedia == "Screen"
? navigator.mediaDevices.getDisplayMedia(mediaOptions)
: navigator.mediaDevices.getUserMedia(mediaOptions)

try {
const mediaStream = await mediaPromise
if (cancelled) {
stopLocalMediaStream(mediaStream)
return
}

videoRef.current!.srcObject = mediaStream
const previousLocalMediaStream = localMediaStreamRef.current
localMediaStreamRef.current = mediaStream
stopLocalMediaStream(previousLocalMediaStream)

await setupPublisherSession()
} catch (reason) {
const mediaError = reason as { name?: string }
if (mediaError.name === 'NotAllowedError') {
setMediaAccessError(() => ErrorMessageEnum.NotAllowedError)
} else if (mediaError.name === 'NotFoundError') {
setMediaAccessError(() => ErrorMessageEnum.NotFoundError)
} else {
setMediaAccessError(() => ErrorMessageEnum.NoMediaDevices)
}

shouldAutoReconnectRef.current = false
cancelReconnect()
setUseDisplayMedia("None")
}
}

void requestAndStartSession()

return () => {
cancelled = true
}
}, [getSenderByKind, mediaRequestCount, stopLocalMediaStream, streamKey, useDisplayMedia])
}, [cancelReconnect, closeEventSource, closePeerConnectionAndSession, isFatalWhipStatus, mediaRequestCount, resetReconnect, stopLocalMediaStream, streamKey, triggerReconnect, useDisplayMedia])

useEffect(() => {
hasSignalRef.current = hasSignal;
Expand Down
Loading