2023-12-15 16:24:50 -07:00
import { baseUrl } from "@/api/baseUrl" ;
2026-09-16 09:12:06 -05:00
import {
LivePlayerError ,
PlayerStatsType ,
TwoWayTalkError ,
} from "@/types/live" ;
import { FrigateConfig } from "@/types/frigateConfig" ;
import { webRTCIceServers } from "@/utils/webrtcUtil" ;
2024-03-15 12:46:17 -06:00
import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
2026-09-16 09:12:06 -05:00
import useSWR from "swr" ;
2023-12-15 16:24:50 -07:00
type WebRtcPlayerProps = {
2024-02-10 05:30:53 -07:00
className? : string ;
2023-12-15 16:24:50 -07:00
camera : string ;
2024-02-14 17:19:55 -07:00
playbackEnabled? : boolean ;
2024-03-01 17:43:02 -07:00
audioEnabled? : boolean ;
2025-02-10 10:42:35 -06:00
volume? : number ;
2024-03-12 17:19:02 -06:00
microphoneEnabled? : boolean ;
2024-03-15 12:46:17 -06:00
iOSCompatFullScreen? : boolean ; // ios doesn't support fullscreen divs so we must support the video element
2024-04-02 06:45:16 -06:00
pip? : boolean ;
2025-02-10 10:42:35 -06:00
getStats? : boolean ;
setStats ?: ( stats : PlayerStatsType ) => void ;
2024-02-10 05:30:53 -07:00
onPlaying ?: () => void ;
2024-05-31 07:52:42 -06:00
onError ?: ( error : LivePlayerError ) => void ;
2026-09-16 09:12:06 -05:00
onMicrophoneError ?: ( error : TwoWayTalkError ) => void ;
2023-12-15 16:24:50 -07:00
};
export default function WebRtcPlayer ({
2024-02-10 05:30:53 -07:00
className ,
2023-12-15 16:24:50 -07:00
camera ,
2024-02-14 17:19:55 -07:00
playbackEnabled = true ,
2024-03-01 17:43:02 -07:00
audioEnabled = false ,
2025-02-10 10:42:35 -06:00
volume ,
2024-03-12 17:19:02 -06:00
microphoneEnabled = false ,
2024-03-15 12:46:17 -06:00
iOSCompatFullScreen = false ,
2024-04-02 06:45:16 -06:00
pip = false ,
2025-02-10 10:42:35 -06:00
getStats = false ,
setStats ,
2024-02-10 05:30:53 -07:00
onPlaying ,
2024-05-31 07:52:42 -06:00
onError ,
2026-09-16 09:12:06 -05:00
onMicrophoneError ,
2023-12-15 16:24:50 -07:00
} : WebRtcPlayerProps ) {
2024-03-13 08:04:11 -06:00
// metadata
2026-09-16 09:12:06 -05:00
const { data : config } = useSWR < FrigateConfig >( "config" );
// Keyed on the serialized list so an unrelated config update doesn't
// reconnect every WebRTC player.
const iceServersKey = JSON . stringify (
config ? . go2rtc ? . webrtc ? . ice_servers ?? [],
);
const iceServers = useMemo (
() => webRTCIceServers ( JSON . parse ( iceServersKey )),
[ iceServersKey ],
);
2024-03-13 08:04:11 -06:00
const wsURL = useMemo (() => {
return ` ${ baseUrl . replace ( /^http/ , "ws" ) } live/webrtc/api/ws?src= ${ camera } ` ;
}, [ camera ]);
2025-09-22 21:21:51 -05:00
// error handler
const handleError = useCallback (
( error : LivePlayerError , description : string = "Unknown error" ) => {
// eslint-disable-next-line no-console
console . error (
2025-11-23 09:40:25 -06:00
` ${ camera } - WebRTC error ' ${ error } ': ${ description } See the documentation: https://docs.frigate.video/configuration/live/#live-player-error-messages` ,
2025-09-22 21:21:51 -05:00
);
onError ? .( error );
},
[ camera , onError ],
);
2024-02-14 17:19:55 -07:00
// camera states
2026-03-05 08:42:38 -06:00
const pcRef = useRef < RTCPeerConnection | undefined >( undefined );
2026-03-16 20:47:07 +08:00
const wsRef = useRef < WebSocket | null >( null );
2023-12-15 16:24:50 -07:00
const videoRef = useRef < HTMLVideoElement | null >( null );
2026-09-16 09:12:06 -05:00
// Separate sendonly-audio connection for two-way talk: go2rtc only wires the
// backchannel from a connection's initial offer.
const micPcRef = useRef < RTCPeerConnection | undefined >( undefined );
const micWsRef = useRef < WebSocket | null >( null );
2024-05-31 07:52:42 -06:00
const [ bufferTimeout , setBufferTimeout ] = useState < NodeJS.Timeout >();
2026-03-05 08:42:38 -06:00
const videoLoadTimeoutRef = useRef < NodeJS.Timeout >( undefined );
2024-02-14 17:19:55 -07:00
2023-12-15 16:24:50 -07:00
const PeerConnection = useCallback (
async ( media : string ) => {
if ( ! videoRef . current ) {
return ;
}
const pc = new RTCPeerConnection ({
2024-05-17 07:30:22 -06:00
bundlePolicy : "max-bundle" ,
2026-09-16 09:12:06 -05:00
iceServers ,
2023-12-15 16:24:50 -07:00
});
const localTracks = [];
if ( /camera|microphone/ . test ( media )) {
const tracks = await getMediaTracks ( "user" , {
video : media.indexOf ( "camera" ) >= 0 ,
audio : media.indexOf ( "microphone" ) >= 0 ,
});
tracks . forEach (( track ) => {
pc . addTransceiver ( track , { direction : "sendonly" });
if ( track . kind === "video" ) localTracks . push ( track );
});
}
if ( media . indexOf ( "display" ) >= 0 ) {
const tracks = await getMediaTracks ( "display" , {
video : true ,
audio : media.indexOf ( "speaker" ) >= 0 ,
});
tracks . forEach (( track ) => {
pc . addTransceiver ( track , { direction : "sendonly" });
if ( track . kind === "video" ) localTracks . push ( track );
});
}
if ( /video|audio/ . test ( media )) {
const tracks = [ "video" , "audio" ]
. filter (( kind ) => media . indexOf ( kind ) >= 0 )
. map (
( kind ) =>
2024-02-28 15:23:56 -07:00
pc . addTransceiver ( kind , { direction : "recvonly" }). receiver . track ,
2023-12-15 16:24:50 -07:00
);
localTracks . push (... tracks );
}
videoRef . current . srcObject = new MediaStream ( localTracks );
return pc ;
},
2026-09-16 09:12:06 -05:00
[ videoRef , iceServers ],
2023-12-15 16:24:50 -07:00
);
async function getMediaTracks (
media : string ,
2024-02-28 15:23:56 -07:00
constraints : MediaStreamConstraints ,
2023-12-15 16:24:50 -07:00
) {
try {
const stream =
media === "user"
? await navigator . mediaDevices . getUserMedia ( constraints )
: await navigator . mediaDevices . getDisplayMedia ( constraints );
return stream . getTracks ();
2026-09-14 08:53:45 -05:00
} catch {
2023-12-15 16:24:50 -07:00
return [];
}
}
2026-09-16 09:12:06 -05:00
// Offer/answer/ICE exchange over the WebSocket; shared by both connections.
const startSignaling = useCallback (( pc : RTCPeerConnection , ws : WebSocket ) => {
ws . addEventListener ( "open" , () => {
pc . addEventListener ( "icecandidate" , ( ev ) => {
if ( ! ev . candidate ) return ;
ws . send (
JSON . stringify ({
type : "webrtc/candidate" ,
value : ev.candidate.candidate ,
}),
);
});
pc . createOffer ()
. then (( offer ) => pc . setLocalDescription ( offer ))
. then (() => {
ws . send (
JSON . stringify ({
type : "webrtc/offer" ,
value : pc.localDescription?.sdp ,
}),
);
});
});
ws . addEventListener ( "message" , ( ev ) => {
const msg = JSON . parse ( ev . data );
if ( msg . type === "webrtc/candidate" ) {
pc . addIceCandidate ({ candidate : msg.value , sdpMid : "0" });
} else if ( msg . type === "webrtc/answer" ) {
pc . setRemoteDescription ({ type : "answer" , sdp : msg.value });
}
});
}, []);
2023-12-15 16:24:50 -07:00
const connect = useCallback (
2024-03-13 08:04:11 -06:00
async ( aPc : Promise < RTCPeerConnection | undefined >) => {
2023-12-15 16:24:50 -07:00
if ( ! aPc ) {
return ;
}
2026-09-16 09:12:06 -05:00
const pc = await aPc ;
if ( ! pc ) {
return ;
}
pcRef . current = pc ;
2026-03-16 20:47:07 +08:00
wsRef . current = new WebSocket ( wsURL );
2026-09-16 09:12:06 -05:00
startSignaling ( pc , wsRef . current );
2023-12-15 16:24:50 -07:00
},
2026-09-16 09:12:06 -05:00
[ wsURL , startSignaling ],
2023-12-15 16:24:50 -07:00
);
useEffect (() => {
if ( ! videoRef . current ) {
return ;
}
2024-02-14 17:19:55 -07:00
if ( ! playbackEnabled ) {
return ;
}
2026-09-16 09:12:06 -05:00
// No mic here. It's a separate connection, so toggling talk never reloads.
const aPc = PeerConnection ( "video+audio" );
2024-03-13 08:04:11 -06:00
connect ( aPc );
2023-12-15 16:24:50 -07:00
return () => {
2026-03-16 20:47:07 +08:00
if ( wsRef . current ) {
wsRef . current . close ();
wsRef . current = null ;
}
2023-12-15 16:24:50 -07:00
if ( pcRef . current ) {
pcRef . current . close ();
pcRef . current = undefined ;
}
};
2026-09-16 09:12:06 -05:00
}, [ camera , connect , PeerConnection , pcRef , videoRef , playbackEnabled ]);
// Backchannel connection, alive only while the mic is on.
useEffect (() => {
if ( ! microphoneEnabled || ! playbackEnabled ) {
return ;
}
let cancelled = false ;
( async () => {
const tracks = await getMediaTracks ( "user" , {
video : false ,
audio : true ,
});
if ( cancelled ) {
tracks . forEach (( track ) => track . stop ());
return ;
}
if ( tracks . length === 0 ) {
onMicrophoneError ? .( "microphone" );
return ;
}
const pc = new RTCPeerConnection ({
bundlePolicy : "max-bundle" ,
iceServers ,
});
tracks . forEach (( track ) =>
pc . addTransceiver ( track , { direction : "sendonly" }),
);
micPcRef . current = pc ;
const ws = new WebSocket ( wsURL );
micWsRef . current = ws ;
startSignaling ( pc , ws );
// go2rtc sends an error instead of an answer when it can't attach the
// microphone to the camera's backchannel.
ws . addEventListener ( "message" , ( ev ) => {
const msg = JSON . parse ( ev . data );
if ( msg . type !== "error" || cancelled ) {
return ;
}
// eslint-disable-next-line no-console
console . error (
` ${ camera } - Two-way talk error: ${ msg . value } See the documentation: https://docs.frigate.video/configuration/live/#two-way-talk` ,
);
onMicrophoneError ? .( "refused" );
});
})();
return () => {
cancelled = true ;
micPcRef . current ? . getSenders (). forEach (( sender ) => sender . track ? . stop ());
if ( micWsRef . current ) {
micWsRef . current . close ();
micWsRef . current = null ;
}
if ( micPcRef . current ) {
micPcRef . current . close ();
micPcRef . current = undefined ;
}
};
2024-03-12 17:19:02 -06:00
}, [
microphoneEnabled ,
2026-09-16 09:12:06 -05:00
playbackEnabled ,
wsURL ,
startSignaling ,
iceServers ,
camera ,
onMicrophoneError ,
2024-03-12 17:19:02 -06:00
]);
2023-12-15 16:24:50 -07:00
2024-03-15 12:46:17 -06:00
// ios compat
2024-04-02 06:45:16 -06:00
2024-03-15 12:46:17 -06:00
const [ iOSCompatControls , setiOSCompatControls ] = useState ( false );
2024-04-02 06:45:16 -06:00
// control pip
useEffect (() => {
if ( ! videoRef . current || ! pip ) {
return ;
}
videoRef . current . requestPictureInPicture ();
}, [ pip , videoRef ]);
2025-02-10 10:42:35 -06:00
// control volume
useEffect (() => {
if ( ! videoRef . current || volume == undefined ) {
return ;
}
videoRef . current . volume = volume ;
}, [ volume , videoRef ]);
2024-06-04 10:11:32 -05:00
useEffect (() => {
videoLoadTimeoutRef . current = setTimeout (() => {
2025-09-22 21:21:51 -05:00
handleError ( "stalled" , "WebRTC connection timed out." );
2024-06-04 10:11:32 -05:00
}, 5000 );
return () => {
if ( videoLoadTimeoutRef . current ) {
clearTimeout ( videoLoadTimeoutRef . current );
}
};
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleLoadedData = () => {
if ( videoLoadTimeoutRef . current ) {
clearTimeout ( videoLoadTimeoutRef . current );
}
onPlaying ? .();
};
2025-02-10 10:42:35 -06:00
// stats
useEffect (() => {
if ( ! pcRef . current || ! getStats ) return ;
let lastBytesReceived = 0 ;
let lastTimestamp = 0 ;
const interval = setInterval ( async () => {
if ( pcRef . current && videoRef . current && ! videoRef . current . paused ) {
const report = await pcRef . current . getStats ();
let bytesReceived = 0 ;
let timestamp = 0 ;
let framesReceived = 0 ;
let framesDecoded = 0 ;
report . forEach (( stat ) => {
if ( stat . type === "inbound-rtp" && stat . kind === "video" ) {
bytesReceived = stat . bytesReceived ;
timestamp = stat . timestamp ;
framesReceived = stat . framesReceived ;
framesDecoded = stat . framesDecoded ;
}
});
const timeDiff = ( timestamp - lastTimestamp ) / 1000 ; // in seconds
const bitrate =
timeDiff > 0
? ( bytesReceived - lastBytesReceived ) / timeDiff / 1000
2025-09-01 19:23:44 -05:00
: 0 ; // in kBps
2025-02-10 10:42:35 -06:00
setStats ? .({
streamType : "WebRTC" ,
bandwidth : Math.round ( bitrate ),
totalFrames : framesReceived ,
2026-09-16 09:12:06 -05:00
droppedFrames : undefined ,
2025-02-10 10:42:35 -06:00
decodedFrames : framesDecoded ,
2026-09-16 09:12:06 -05:00
droppedFrameRate : undefined ,
2025-02-10 10:42:35 -06:00
});
lastBytesReceived = bytesReceived ;
lastTimestamp = timestamp ;
}
}, 1000 );
return () => {
clearInterval ( interval );
setStats ? .({
streamType : "-" ,
bandwidth : 0 ,
totalFrames : 0 ,
droppedFrames : undefined ,
decodedFrames : 0 ,
droppedFrameRate : 0 ,
});
};
// we need to listen on the value of the ref
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ pcRef , pcRef . current , getStats ]);
2023-12-15 16:24:50 -07:00
return (
2024-02-14 17:19:55 -07:00
< video
ref = { videoRef }
className = { className }
2024-03-15 12:46:17 -06:00
controls = { iOSCompatControls }
2024-02-14 17:19:55 -07:00
autoPlay
playsInline
2024-03-01 17:43:02 -07:00
muted = { ! audioEnabled }
2024-06-04 10:11:32 -05:00
onLoadedData = { handleLoadedData }
2024-05-31 07:52:42 -06:00
onProgress = {
onError != undefined
? () => {
if ( videoRef . current ? . paused ) {
return ;
}
if ( bufferTimeout ) {
clearTimeout ( bufferTimeout );
setBufferTimeout ( undefined );
}
setBufferTimeout (
setTimeout (() => {
2024-06-13 09:45:07 -05:00
if (
document . visibilityState === "visible" &&
pcRef . current != undefined
) {
2025-10-03 07:37:18 -05:00
handleError (
"stalled" ,
"Media playback has stalled after 3 seconds due to insufficient buffering or a network interruption." ,
);
2024-06-10 18:24:25 -05:00
}
2024-05-31 07:52:42 -06:00
}, 3000 ),
);
}
: undefined
}
2024-03-15 12:46:17 -06:00
onClick = {
iOSCompatFullScreen
? () => setiOSCompatControls ( ! iOSCompatControls )
: undefined
}
2024-05-31 07:52:42 -06:00
onError = {( e ) => {
if (
// @ts-expect-error code does exist
e . target . error . code == MediaError . MEDIA_ERR_NETWORK
) {
2025-09-22 21:21:51 -05:00
handleError ( "startup" , "Browser reported a network error." );
2024-05-31 07:52:42 -06:00
}
}}
2024-02-14 17:19:55 -07:00
/>
2023-12-15 16:24:50 -07:00
);
}