frigate/web/src/components/player/MsePlayer.tsx
Josh Hawkins 2754f788cc
Add Auto live view option with transcoding options (#24519)
* implement auto mode for single camera live view

* add transcoded live streams and stream ordering for auto mode

Cameras can now add lower quality live streams that go2rtc transcodes to H.264 on demand. `live.transcode` takes a source stream and a list of heights and bitrates, and each quality becomes a `{camera}_transcode_{height}p` stream. The config validator adds them to `live.streams` without moving any the user already placed, and drops them when transcoding is disabled or a height changes. `create_config.py` writes them into go2rtc's generated config at startup, and saving the config or deleting a camera syncs them through go2rtc's API, so no restart is needed. They use `#hardware`, so go2rtc picks a hardware encoder and falls back to the CPU when there isn't one.

The Live playback settings stream list can be reordered by drag, since its order is the auto ladder. Auto order sorts it by bitrate, measuring native streams through a new admin-only `/go2rtc/streams/{name}/bitrate` endpoint and using the configured bitrate for transcoded ones. A pure reorder wasn't saved before because RJSF, the settings form, and `update_yaml` all ignore map key order. Sections can now mark a map with `orderedMaps`, which sends the whole map with `replace_paths` so `config_set` rewrites it in order.

Transcoded streams aren't in `go2rtc.streams`, which only lists yaml streams, so the frontend treated them as not restreamed and fell back to jsmpeg. Every restream check now goes through `isRestreamedStream`.

Auto treated any stall with no bytes in the last 2 seconds as a dead camera and handed it to the error fallback, which went straight to jsmpeg. Heavy congestion can stop delivery completely, so congested viewers skipped every lower stream. Auto now declines only when stats show the camera offline, and a stall on a live camera steps down. The stream picker also has a Try highest quality button that sends auto back to the top stream.

* fixes
2026-09-30 15:10:49 -06:00

939 lines
27 KiB
TypeScript

import { baseUrl } from "@/api/baseUrl";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import {
LiveHealthSample,
LivePlayerError,
PlayerStatsType,
VideoResolutionType,
} from "@/types/live";
import {
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isIOS, isSafari } from "react-device-detect";
type MSEPlayerProps = {
camera: string;
className?: string;
playbackEnabled?: boolean;
audioEnabled?: boolean;
volume?: number;
playInBackground?: boolean;
pip?: boolean;
getStats?: boolean;
setStats?: (stats: PlayerStatsType) => void;
onPlaying?: () => void;
setFullResolution?: React.Dispatch<SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void;
onHealthSample?: (sample: LiveHealthSample) => void;
};
function MSEPlayer({
camera,
className,
playbackEnabled = true,
audioEnabled = false,
volume,
playInBackground = false,
pip = false,
getStats = false,
setStats,
onPlaying,
setFullResolution,
onError,
onHealthSample,
}: MSEPlayerProps) {
const RECONNECT_TIMEOUT: number = 10000;
const BUFFERING_COOLDOWN_TIMEOUT: number = 5000;
const CODECS: string[] = [
"avc1.640029", // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
"avc1.64002A", // H.264 high 4.2 (Chromecast 3rd Gen)
"avc1.640033", // H.264 high 5.1 (Chromecast with Google TV)
"hvc1.1.6.L153.B0", // H.265 main 5.1 (Chromecast Ultra)
"mp4a.40.2", // AAC LC
"mp4a.40.5", // AAC HE
"flac", // FLAC (PCM compatible)
"opus", // OPUS Chrome, Firefox
];
const visibilityCheck: boolean = !pip;
const [isPlaying, setIsPlaying] = useState(false);
const lastJumpTimeRef = useRef(0);
const MAX_BUFFER_ENTRIES = 10; // Size of the rolling window of buffered times
const bufferTimes = useRef<number[]>([]);
const bufferIndex = useRef(0);
const [wsState, setWsState] = useState<number>(WebSocket.CLOSED);
const [connectTS, setConnectTS] = useState<number>(0);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const [errorCount, setErrorCount] = useState<number>(0);
const totalBytesLoaded = useRef(0);
const appendingRef = useRef(false);
const [fallbackTimeout] = useUserPersistence<number>(
"liveFallbackTimeout",
3,
);
const videoRef = useRef<HTMLVideoElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTIDRef = useRef<number | null>(null);
const intentionalDisconnectRef = useRef<boolean>(false);
const onCloseRef = useRef<(() => void) | null>(null);
const ondataRef = useRef<((data: ArrayBufferLike) => void) | null>(null);
const onmessageRef = useRef<{
[key: string]: (msg: { value: string; type: string }) => void;
}>({});
const msRef = useRef<MediaSource | null>(null);
const mseCodecRef = useRef<string | null>(null);
const mseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mseResponseReceivedRef = useRef<boolean>(false);
const wsURL = useMemo(() => {
return `${baseUrl.replace(/^http/, "ws")}live/mse/api/ws?src=${camera}`;
}, [camera]);
// socket handlers are bound once per connection, so they reach the
// current onError through a ref
const onErrorRef = useRef(onError);
useEffect(() => {
onErrorRef.current = onError;
}, [onError]);
const handleError = useCallback(
(error: LivePlayerError, description: string = "Unknown error") => {
// eslint-disable-next-line no-console
console.error(
`${camera} - MSE error '${error}': ${description} See the documentation: https://docs.frigate.video/configuration/live/#live-player-error-messages`,
);
if (mseCodecRef.current) {
// eslint-disable-next-line no-console
console.error(
`${camera} - Browser negotiated codecs: ${mseCodecRef.current}`,
);
// eslint-disable-next-line no-console
console.error(`${camera} - Supported codecs: ${CODECS.join(", ")}`);
}
onErrorRef.current?.(error);
},
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[camera],
);
const handleLoadedMetadata = useCallback(() => {
if (videoRef.current && setFullResolution) {
setFullResolution({
width: videoRef.current.videoWidth,
height: videoRef.current.videoHeight,
});
}
}, [setFullResolution]);
const play = () => {
const currentVideo = videoRef.current;
if (currentVideo) {
currentVideo.play().catch((er: { name: string }) => {
if (er.name === "NotAllowedError" && !currentVideo.muted) {
currentVideo.muted = true;
currentVideo.play().catch(() => {});
}
});
}
};
const send = useCallback(
(value: object) => {
if (wsRef.current) wsRef.current.send(JSON.stringify(value));
},
[wsRef],
);
const codecs = useCallback((isSupported: (type: string) => boolean) => {
return CODECS.filter((codec) =>
isSupported(`video/mp4; codecs="${codec}"`),
).join();
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onConnect = useCallback(() => {
if (!videoRef.current?.isConnected || !wsURL || wsRef.current) {
return false;
}
intentionalDisconnectRef.current = false;
setWsState(WebSocket.CONNECTING);
setConnectTS(Date.now());
wsRef.current = new WebSocket(wsURL);
wsRef.current.binaryType = "arraybuffer";
wsRef.current.addEventListener("open", onOpen);
// Capture current onClose identity so removeEventListener can find it later
onCloseRef.current = onClose;
wsRef.current.addEventListener("close", onClose);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wsURL]);
const onDisconnect = useCallback(() => {
if (bufferTimeout) {
clearTimeout(bufferTimeout);
setBufferTimeout(undefined);
}
// Clear any pending MSE timeout
if (mseTimeoutRef.current !== null) {
clearTimeout(mseTimeoutRef.current);
mseTimeoutRef.current = null;
}
// Clear any pending reconnect attempts
if (reconnectTIDRef.current !== null) {
clearTimeout(reconnectTIDRef.current);
reconnectTIDRef.current = null;
}
setIsPlaying(false);
if (wsRef.current) {
const ws = wsRef.current;
wsRef.current = null;
const currentReadyState = ws.readyState;
intentionalDisconnectRef.current = true;
setWsState(WebSocket.CLOSED);
// Remove event listeners to prevent them firing during close.
// Use onCloseRef to remove the exact function that was attached in onConnect,
// since onClose may have been recreated by React since then.
try {
ws.removeEventListener("open", onOpen);
if (onCloseRef.current) {
ws.removeEventListener("close", onCloseRef.current);
onCloseRef.current = null;
}
} catch {
// Ignore errors removing listeners
}
// Close the socket in any non-CLOSED state, including CONNECTING.
// A CONNECTING socket that is not closed will complete its handshake
// and remain open, leaking a browser connection.
if (currentReadyState !== WebSocket.CLOSED) {
try {
ws.close();
} catch {
// Ignore close errors
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bufferTimeout]);
const handlePause = useCallback(() => {
// don't let the user pause the live stream
if (isPlaying && playbackEnabled) {
videoRef.current?.play();
}
}, [isPlaying, playbackEnabled]);
const onOpen = useCallback(() => {
// If we were marked for intentional disconnect while connecting, close immediately
if (intentionalDisconnectRef.current) {
wsRef.current?.close();
wsRef.current = null;
return;
}
setWsState(WebSocket.OPEN);
wsRef.current?.addEventListener("message", (ev) => {
if (typeof ev.data === "string") {
const msg = JSON.parse(ev.data);
for (const mode in onmessageRef.current) {
onmessageRef.current[mode](msg);
}
} else {
ondataRef.current?.(ev.data);
}
});
ondataRef.current = null;
onmessageRef.current = {};
// Reset the MSE response flag for this new connection
mseResponseReceivedRef.current = false;
// Create a fresh MediaSource for this connection to avoid stale sourceopen events
// from previous connections interfering with this one
const MediaSourceConstructor =
"ManagedMediaSource" in window ? window.ManagedMediaSource : MediaSource;
// @ts-expect-error for typing
msRef.current = new MediaSourceConstructor();
onMse();
// onMse is defined below and stable
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const reconnect = (timeout?: number) => {
// Don't reconnect if intentional disconnect was flagged
if (intentionalDisconnectRef.current) {
return;
}
setWsState(WebSocket.CONNECTING);
wsRef.current = null;
const delay =
timeout ?? Math.max(RECONNECT_TIMEOUT - (Date.now() - connectTS), 0);
reconnectTIDRef.current = window.setTimeout(() => {
reconnectTIDRef.current = null;
onConnect();
}, delay);
};
const onClose = useCallback(() => {
// Don't reconnect if this was an intentional disconnect
if (intentionalDisconnectRef.current) {
// Reset the flag so future connects are allowed
intentionalDisconnectRef.current = false;
return;
}
if (wsState === WebSocket.CLOSED) return;
reconnect();
// reconnect is defined below and stable
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wsState]);
const sendWithTimeout = (value: object, timeout: number) => {
return new Promise<void>((resolve, reject) => {
// Don't start timeout if WS isn't connected - this can happen when
// sourceopen fires from a previous connection after we've already disconnected
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
// Reject so caller knows this didn't work
reject(new Error("WebSocket not connected"));
return;
}
// If we've already received an MSE response for this connection, don't start another timeout
if (mseResponseReceivedRef.current) {
resolve();
return;
}
// Clear any existing MSE timeout from a previous attempt
if (mseTimeoutRef.current !== null) {
clearTimeout(mseTimeoutRef.current);
mseTimeoutRef.current = null;
}
const timeoutId = setTimeout(() => {
// Only reject if we haven't received a response yet
if (!mseResponseReceivedRef.current) {
mseTimeoutRef.current = null;
reject(new Error("Timeout waiting for response"));
}
}, timeout);
mseTimeoutRef.current = timeoutId;
// Override the onmessageRef handler for mse type to resolve the promise on response
const originalHandler = onmessageRef.current["mse"];
onmessageRef.current["mse"] = (msg) => {
if (msg.type === "mse") {
// Mark that we've received the response
mseResponseReceivedRef.current = true;
// Clear the timeout (use ref to clear the current one, not closure)
if (mseTimeoutRef.current !== null) {
clearTimeout(mseTimeoutRef.current);
mseTimeoutRef.current = null;
}
// Call original handler in try-catch so errors don't prevent promise resolution
if (originalHandler) {
try {
originalHandler(msg);
} catch {
// Don't reject - we got the response, just let the error bubble
}
}
resolve();
}
};
send(value);
});
};
const onMse = () => {
if ("ManagedMediaSource" in window) {
// safari
const MediaSource = window.ManagedMediaSource;
msRef.current?.addEventListener(
"sourceopen",
() => {
sendWithTimeout(
{
type: "mse",
// @ts-expect-error for typing
value: codecs(MediaSource.isTypeSupported),
},
(fallbackTimeout ?? 3) * 1000,
).catch(() => {
if (wsRef.current) {
onDisconnect();
}
if (isIOS || isSafari) {
handleError("mse-decode", "Safari cannot open MediaSource.");
} else {
handleError("startup", "Error opening MediaSource.");
}
});
},
{ once: true },
);
if (videoRef.current) {
videoRef.current.disableRemotePlayback = true;
videoRef.current.srcObject = msRef.current;
}
} else {
// non safari
msRef.current?.addEventListener(
"sourceopen",
() => {
URL.revokeObjectURL(videoRef.current?.src || "");
sendWithTimeout(
{
type: "mse",
value: codecs(MediaSource.isTypeSupported),
},
(fallbackTimeout ?? 3) * 1000,
).catch(() => {
// Only report errors if we actually had a connection that failed
// If WS wasn't connected, this is a stale sourceopen event from a previous connection
if (wsRef.current) {
onDisconnect();
if (isIOS || isSafari) {
handleError("mse-decode", "Safari cannot open MediaSource.");
} else {
handleError("startup", "Error opening MediaSource.");
}
}
});
},
{ once: true },
);
if (videoRef.current && msRef.current) {
videoRef.current.src = URL.createObjectURL(msRef.current);
videoRef.current.srcObject = null;
}
}
play();
onmessageRef.current["mse"] = (msg) => {
if (msg.type !== "mse") return;
// Store the codec value for error logging
mseCodecRef.current = msg.value;
let sb: SourceBuffer | undefined;
try {
sb = msRef.current?.addSourceBuffer(msg.value);
if (sb?.mode) {
sb.mode = "segments";
}
} catch (e) {
// Safari sometimes throws this error
if (e instanceof DOMException && e.name === "InvalidStateError") {
if (wsRef.current) {
onDisconnect();
}
handleError("mse-decode", "Safari reported InvalidStateError.");
return;
} else {
throw e; // Re-throw if it's not the error we're handling
}
}
sb?.addEventListener("updateend", () => {
if (sb.updating) return;
try {
if (bufLen > 0) {
const data = buf.slice(0, bufLen);
bufLen = 0;
sb.appendBuffer(data);
return;
}
appendingRef.current = false;
if (sb.buffered && sb.buffered.length) {
const end = sb.buffered.end(sb.buffered.length - 1) - 15;
const start = sb.buffered.start(0);
if (end > start) {
sb.remove(start, end);
msRef.current?.setLiveSeekableRange(end, end + 15);
}
}
} catch {
// no-op
}
});
const buf = new Uint8Array(2 * 1024 * 1024);
let bufLen = 0;
ondataRef.current = (data) => {
totalBytesLoaded.current += data.byteLength;
appendingRef.current = true;
if (sb?.updating || bufLen > 0) {
const b = new Uint8Array(data);
buf.set(b, bufLen);
bufLen += b.byteLength;
// console.debug("VideoRTC.buffer", b.byteLength, bufLen);
} else {
try {
sb?.appendBuffer(data as ArrayBuffer);
} catch {
// no-op
}
}
};
};
// go2rtc answers a codec it cannot provide with an error message
// instead of a negotiation reply
onmessageRef.current["error"] = (msg) => {
if (msg.type !== "error" || !msg.value?.includes("codecs not matched")) {
return;
}
if (wsRef.current) {
onDisconnect();
}
handleError("mse-codec", msg.value);
};
};
const getBufferedTime = (video: HTMLVideoElement | null) => {
if (!video || video.buffered.length === 0) return 0;
return video.buffered.end(video.buffered.length - 1) - video.currentTime;
};
const jumpToLive = () => {
if (!videoRef.current) return;
const buffered = videoRef.current.buffered;
if (buffered.length > 0) {
const liveEdge = buffered.end(buffered.length - 1);
// Jump to the live edge
videoRef.current.currentTime = liveEdge - 0.75;
lastJumpTimeRef.current = Date.now();
}
};
const calculateAdaptiveBufferThreshold = () => {
const filledEntries = bufferTimes.current.length;
const sum = bufferTimes.current.reduce((a, b) => a + b, 0);
const averageBufferTime = filledEntries ? sum / filledEntries : 0;
return averageBufferTime * (isSafari || isIOS ? 3 : 1.5);
};
const calculateAdaptivePlaybackRate = (
bufferTime: number,
bufferThreshold: number,
) => {
const alpha = 0.2; // aggressiveness of playback rate increase
const beta = 0.5; // steepness of exponential growth
// don't adjust playback rate if we're close enough to live
// or if we just started streaming
if (
((bufferTime <= bufferThreshold && bufferThreshold < 3) ||
bufferTime < 3) &&
bufferTimes.current.length <= MAX_BUFFER_ENTRIES
) {
return 1;
}
const rate = 1 + alpha * Math.exp(beta * bufferTime - bufferThreshold);
return Math.min(rate, 2);
};
const onProgress = useCallback(() => {
const bufferTime = getBufferedTime(videoRef.current);
if (
videoRef.current &&
(videoRef.current.playbackRate === 1 || bufferTime < 3)
) {
if (bufferTimes.current.length < MAX_BUFFER_ENTRIES) {
bufferTimes.current.push(bufferTime);
} else {
bufferTimes.current[bufferIndex.current] = bufferTime;
bufferIndex.current = (bufferIndex.current + 1) % MAX_BUFFER_ENTRIES;
}
}
const bufferThreshold = calculateAdaptiveBufferThreshold();
// if we have > 3 seconds of buffered data and we're still not playing,
// something might be wrong - maybe codec issue, no audio, etc
// so mark the player as playing so that error handlers will fire
if (!isPlaying && playbackEnabled && bufferTime > 3) {
setIsPlaying(true);
lastJumpTimeRef.current = Date.now();
onPlaying?.();
}
// if we have more than 10 seconds of buffer, something's wrong so error out
if (
isPlaying &&
playbackEnabled &&
(bufferThreshold > 10 || bufferTime > 10)
) {
onDisconnect();
handleError(
"stalled",
"Buffer time (10 seconds) exceeded, browser may not be playing media correctly.",
);
}
const playbackRate = calculateAdaptivePlaybackRate(
bufferTime,
bufferThreshold,
);
// if we're above our rolling average threshold or have > 3 seconds of
// buffered data and we're playing, we may have drifted from actual live
// time
if (videoRef.current && isPlaying && playbackEnabled) {
if (
(isSafari || isIOS) &&
bufferTime > 3 &&
Date.now() - lastJumpTimeRef.current > BUFFERING_COOLDOWN_TIMEOUT
) {
// Jump to live on Safari/iOS due to a change of playback rate causing re-buffering
jumpToLive();
} else {
// increase/decrease playback rate to compensate - non Safari/iOS only
if (videoRef.current.playbackRate !== playbackRate) {
videoRef.current.playbackRate = playbackRate;
}
}
}
if (onError != undefined) {
if (videoRef.current?.paused) {
return;
}
if (bufferTimeout) {
clearTimeout(bufferTimeout);
setBufferTimeout(undefined);
}
const timeoutDuration =
bufferTime == 0
? (fallbackTimeout ?? 3) * 2 * 1000
: (fallbackTimeout ?? 3) * 1000;
setBufferTimeout(
setTimeout(() => {
if (
document.visibilityState === "visible" &&
wsRef.current != null &&
videoRef.current
) {
onDisconnect();
handleError(
"stalled",
`Media playback has stalled after ${timeoutDuration / 1000} seconds due to insufficient buffering or a network interruption.`,
);
}
}, timeoutDuration),
);
}
}, [
bufferTimeout,
isPlaying,
onDisconnect,
handleError,
onError,
onPlaying,
playbackEnabled,
fallbackTimeout,
]);
useEffect(() => {
if (!playbackEnabled) {
return;
}
onConnect();
return () => {
onDisconnect();
};
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playbackEnabled]);
// check visibility
useEffect(() => {
if (!playbackEnabled || !visibilityCheck) {
return;
}
if (!("hidden" in document)) {
return;
}
const listener = () => {
if (document.hidden) {
onDisconnect();
} else if (videoRef.current?.isConnected) {
onConnect();
}
};
if (!playInBackground) {
document.addEventListener("visibilitychange", listener);
}
return () => {
if (!playInBackground) {
document.removeEventListener("visibilitychange", listener);
}
};
}, [
playbackEnabled,
visibilityCheck,
playInBackground,
onConnect,
onDisconnect,
]);
// control pip
useEffect(() => {
if (!videoRef.current || !pip) {
return;
}
videoRef.current.requestPictureInPicture();
}, [pip, videoRef]);
// control volume
useEffect(() => {
if (!videoRef.current || volume == undefined) {
return;
}
videoRef.current.volume = volume;
}, [volume, videoRef]);
// ensure we disconnect for slower connections
useEffect(() => {
if (wsRef.current?.readyState === WebSocket.OPEN && !playbackEnabled) {
if (bufferTimeout) {
clearTimeout(bufferTimeout);
setBufferTimeout(undefined);
}
setTimeout(() => {
if (!playbackEnabled) onDisconnect();
}, 10000);
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playbackEnabled]);
// stats and health samples
const onHealthSampleRef = useRef(onHealthSample);
useEffect(() => {
onHealthSampleRef.current = onHealthSample;
}, [onHealthSample]);
const sampleHealth = onHealthSample !== undefined;
useEffect(() => {
const video = videoRef.current;
let lastLoadedBytes = totalBytesLoaded.current;
let lastTimestamp = Date.now();
let healthBytes = lastLoadedBytes;
let healthTimestamp = lastTimestamp;
// a restart mid-stream must not count the existing buffer as new media
let lastBufferedEnd =
video && video.buffered.length > 0
? video.buffered.end(video.buffered.length - 1)
: 0;
let deferred = false;
if (!getStats && !sampleHealth) return;
const update = () => {
if (!video) {
return;
}
const now = Date.now();
const bytesLoaded = totalBytesLoaded.current;
if (getStats) {
const timeElapsed = (now - lastTimestamp) / 1000; // seconds
const bandwidth = (bytesLoaded - lastLoadedBytes) / timeElapsed / 1000; // kBps
lastLoadedBytes = bytesLoaded;
lastTimestamp = now;
const videoQuality = video.getVideoPlaybackQuality();
const { totalVideoFrames, droppedVideoFrames } = videoQuality;
const droppedFrameRate = totalVideoFrames
? (droppedVideoFrames / totalVideoFrames) * 100
: 0;
setStats?.({
streamType: "MSE",
bandwidth,
totalFrames: totalVideoFrames,
droppedFrames: droppedVideoFrames || undefined,
decodedFrames: totalVideoFrames - droppedVideoFrames,
droppedFrameRate,
});
}
if (!sampleHealth) {
return;
}
// a sample taken mid-append would count the bytes without their
// media, so it waits one tick for the append to land
if (appendingRef.current && !deferred) {
deferred = true;
return;
}
deferred = false;
// the buffered end tracks delivered media, so playback-rate
// catch-up does not skew it
const bufferedEnd =
video.buffered.length > 0
? video.buffered.end(video.buffered.length - 1)
: 0;
const mediaSeconds = bufferedEnd - lastBufferedEnd;
const bytes = bytesLoaded - healthBytes;
const wallSeconds = (now - healthTimestamp) / 1000;
lastBufferedEnd = bufferedEnd;
healthBytes = bytesLoaded;
healthTimestamp = now;
// a reconnect starts a new MediaSource timeline
if (mediaSeconds < 0) {
return;
}
if (
document.visibilityState !== "visible" ||
(video.paused && bufferedEnd > 0)
) {
return;
}
onHealthSampleRef.current?.({ bytes, mediaSeconds, wallSeconds });
};
const interval = setInterval(update, 1000);
return () => {
clearInterval(interval);
if (getStats) {
setStats?.({
streamType: "-",
bandwidth: 0,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
droppedFrameRate: 0,
});
}
};
}, [setStats, getStats, sampleHealth]);
return (
<video
ref={videoRef}
className={className}
playsInline
preload="auto"
onLoadedData={() => {
handleLoadedMetadata?.();
onPlaying?.();
setIsPlaying(true);
lastJumpTimeRef.current = Date.now();
}}
muted={!audioEnabled}
onPause={handlePause}
onProgress={onProgress}
onError={(e) => {
if (
// @ts-expect-error code does exist
e.target.error.code == MediaError.MEDIA_ERR_NETWORK
) {
if (wsRef.current) {
onDisconnect();
}
handleError("startup", "Browser reported a network error.");
}
if (
// @ts-expect-error code does exist
e.target.error.code == MediaError.MEDIA_ERR_DECODE &&
(isSafari || isIOS)
) {
if (wsRef.current) {
onDisconnect();
}
handleError("mse-decode", "Safari reported decoding errors.");
}
setErrorCount((prevCount) => prevCount + 1);
if (wsRef.current) {
onDisconnect();
if (errorCount >= 3) {
// too many mse errors, try jsmpeg
handleError("startup", `Max error count ${errorCount} exceeded.`);
} else {
reconnect(5000);
}
}
}}
/>
);
}
export default MSEPlayer;