Merge pull request #13 from ibs0d/codex/apply-code-changes-from-diff

Live view & MSE playback tuning; add global stream-stats toggle
This commit is contained in:
ibs0d 2026-03-08 14:03:16 +11:00 committed by GitHub
commit 6f97f3e873
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 102 additions and 33 deletions

View File

@ -109,12 +109,41 @@ export default function LivePlayer({
offline, offline,
} = useCameraActivity(cameraConfig); } = useCameraActivity(cameraConfig);
const cameraActive = useMemo( const [stickyActive, setStickyActive] = useState(false);
() =>
!showStillWithoutActivity || useEffect(() => {
(windowVisible && (activeMotion || activeTracking)), if (showStillWithoutActivity) {
[activeMotion, activeTracking, showStillWithoutActivity, windowVisible], setStickyActive(true);
); }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (activeMotion || activeTracking) {
setStickyActive(true);
}
}, [activeMotion, activeTracking]);
const cameraActive = useMemo(() => {
// continuous: всегда активна
if (!showStillWithoutActivity) {
return true;
}
// smart, но уже "залипла" → ведём себя как continuous
if (stickyActive) {
return true;
}
// обычный smart до первого движения
return windowVisible && (activeMotion || activeTracking);
}, [
activeMotion,
activeTracking,
showStillWithoutActivity,
windowVisible,
stickyActive,
]);
// camera live state // camera live state

View File

@ -465,11 +465,11 @@ function MSEPlayer({
bufLen = 0; bufLen = 0;
sb.appendBuffer(data); sb.appendBuffer(data);
} else if (sb.buffered && sb.buffered.length) { } else if (sb.buffered && sb.buffered.length) {
const end = sb.buffered.end(sb.buffered.length - 1) - 15; const end = sb.buffered.end(sb.buffered.length - 1) - 10;
const start = sb.buffered.start(0); const start = sb.buffered.start(0);
if (end > start) { if (end > start) {
sb.remove(start, end); sb.remove(start, end);
msRef.current?.setLiveSeekableRange(end, end + 15); msRef.current?.setLiveSeekableRange(end, end + 10);
} }
} }
} catch (e) { } catch (e) {
@ -511,7 +511,7 @@ function MSEPlayer({
if (buffered.length > 0) { if (buffered.length > 0) {
const liveEdge = buffered.end(buffered.length - 1); const liveEdge = buffered.end(buffered.length - 1);
// Jump to the live edge // Jump to the live edge
videoRef.current.currentTime = liveEdge - 0.75; videoRef.current.currentTime = liveEdge - 0.45;
lastJumpTimeRef.current = Date.now(); lastJumpTimeRef.current = Date.now();
} }
}; };
@ -520,21 +520,21 @@ function MSEPlayer({
const filledEntries = bufferTimes.current.length; const filledEntries = bufferTimes.current.length;
const sum = bufferTimes.current.reduce((a, b) => a + b, 0); const sum = bufferTimes.current.reduce((a, b) => a + b, 0);
const averageBufferTime = filledEntries ? sum / filledEntries : 0; const averageBufferTime = filledEntries ? sum / filledEntries : 0;
return averageBufferTime * (isSafari || isIOS ? 3 : 1.5); return averageBufferTime * (isSafari || isIOS ? 3 : 0.6);
}; };
const calculateAdaptivePlaybackRate = ( const calculateAdaptivePlaybackRate = (
bufferTime: number, bufferTime: number,
bufferThreshold: number, bufferThreshold: number,
) => { ) => {
const alpha = 0.2; // aggressiveness of playback rate increase const alpha = 0.1; // aggressiveness of playback rate increase
const beta = 0.5; // steepness of exponential growth const beta = 0.3; // steepness of exponential growth
// don't adjust playback rate if we're close enough to live // don't adjust playback rate if we're close enough to live
// or if we just started streaming // or if we just started streaming
if ( if (
((bufferTime <= bufferThreshold && bufferThreshold < 3) || ((bufferTime <= bufferThreshold && bufferThreshold < 1) ||
bufferTime < 3) && bufferTime < 1) &&
bufferTimes.current.length <= MAX_BUFFER_ENTRIES bufferTimes.current.length <= MAX_BUFFER_ENTRIES
) { ) {
return 1; return 1;
@ -548,7 +548,7 @@ function MSEPlayer({
if ( if (
videoRef.current && videoRef.current &&
(videoRef.current.playbackRate === 1 || bufferTime < 3) (videoRef.current.playbackRate === 1 || bufferTime < 1)
) { ) {
if (bufferTimes.current.length < MAX_BUFFER_ENTRIES) { if (bufferTimes.current.length < MAX_BUFFER_ENTRIES) {
bufferTimes.current.push(bufferTime); bufferTimes.current.push(bufferTime);
@ -563,7 +563,7 @@ function MSEPlayer({
// if we have > 3 seconds of buffered data and we're still not playing, // if we have > 3 seconds of buffered data and we're still not playing,
// something might be wrong - maybe codec issue, no audio, etc // something might be wrong - maybe codec issue, no audio, etc
// so mark the player as playing so that error handlers will fire // so mark the player as playing so that error handlers will fire
if (!isPlaying && playbackEnabled && bufferTime > 3) { if (!isPlaying && playbackEnabled && bufferTime > 1) {
setIsPlaying(true); setIsPlaying(true);
lastJumpTimeRef.current = Date.now(); lastJumpTimeRef.current = Date.now();
onPlaying?.(); onPlaying?.();
@ -592,8 +592,7 @@ function MSEPlayer({
// time // time
if (videoRef.current && isPlaying && playbackEnabled) { if (videoRef.current && isPlaying && playbackEnabled) {
if ( if (
(isSafari || isIOS) && bufferTime > 0.4 &&
bufferTime > 3 &&
Date.now() - lastJumpTimeRef.current > BUFFERING_COOLDOWN_TIMEOUT Date.now() - lastJumpTimeRef.current > BUFFERING_COOLDOWN_TIMEOUT
) { ) {
// Jump to live on Safari/iOS due to a change of playback rate causing re-buffering // Jump to live on Safari/iOS due to a change of playback rate causing re-buffering

View File

@ -35,7 +35,7 @@ import useSWR from "swr";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
import LivePlayer from "@/components/player/LivePlayer"; import LivePlayer from "@/components/player/LivePlayer";
import { IoClose } from "react-icons/io5"; import { IoClose, IoStatsChart } from "react-icons/io5";
import { LuLayoutDashboard, LuPencil } from "react-icons/lu"; import { LuLayoutDashboard, LuPencil } from "react-icons/lu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { EditGroupDialog } from "@/components/filter/CameraGroupSelector"; import { EditGroupDialog } from "@/components/filter/CameraGroupSelector";
@ -404,16 +404,44 @@ export default function DraggableGridLayout({
}; };
// audio and stats states // audio and stats states
const [globalStreamStatsEnabled, setGlobalStreamStatsEnabled] =
useState(false);
const getStreamStatsFromStorage = (): boolean => {
const storedValue = localStorage.getItem("globalStreamStatsEnabled");
return storedValue === "true";
};
const setStreamStatsToStorage = (value: boolean): void => {
localStorage.setItem("globalStreamStatsEnabled", value.toString());
};
const toggleGlobalStreamStats = () => {
setGlobalStreamStatsEnabled((prevState) => {
const newState = !prevState;
setStreamStatsToStorage(newState);
return newState;
});
};
const [audioStates, setAudioStates] = useState<AudioState>({}); const [audioStates, setAudioStates] = useState<AudioState>({});
const [volumeStates, setVolumeStates] = useState<VolumeState>({}); const [volumeStates, setVolumeStates] = useState<VolumeState>({});
const [statsStates, setStatsStates] = useState<StatsState>(() => { const [statsStates, setStatsStates] = useState<StatsState>({});
const initialStates: StatsState = {};
useEffect(() => {
const initialStreamStatsState = getStreamStatsFromStorage();
setGlobalStreamStatsEnabled(initialStreamStatsState);
}, []);
useEffect(() => {
const updatedStatsState: StatsState = {};
cameras.forEach((camera) => { cameras.forEach((camera) => {
initialStates[camera.name] = false; updatedStatsState[camera.name] = globalStreamStatsEnabled;
}); });
return initialStates;
}); setStatsStates(updatedStatsState);
}, [globalStreamStatsEnabled, cameras]);
const toggleStats = (cameraName: string): void => { const toggleStats = (cameraName: string): void => {
setStatsStates((prev) => ({ setStatsStates((prev) => ({
@ -628,7 +656,7 @@ export default function DraggableGridLayout({
} }
audioState={audioStates[camera.name]} audioState={audioStates[camera.name]}
toggleAudio={() => toggleAudio(camera.name)} toggleAudio={() => toggleAudio(camera.name)}
statsState={statsStates[camera.name]} statsState={statsStates[camera.name] ?? true}
toggleStats={() => toggleStats(camera.name)} toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name]} volumeState={volumeStates[camera.name]}
setVolumeState={(value) => setVolumeState={(value) =>
@ -665,7 +693,7 @@ export default function DraggableGridLayout({
cameraConfig={camera} cameraConfig={camera}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"} preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
playInBackground={false} playInBackground={false}
showStats={statsStates[camera.name]} showStats={statsStates[camera.name] ?? true}
onClick={() => { onClick={() => {
!isEditMode && onSelectCamera(camera.name); !isEditMode && onSelectCamera(camera.name);
}} }}
@ -673,9 +701,7 @@ export default function DraggableGridLayout({
setPreferredLiveModes((prevModes) => { setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes }; const newModes = { ...prevModes };
if (e === "mse-decode") { if (e === "mse-decode") {
newModes[camera.name] = "webrtc"; delete newModes[camera.name];
} else {
newModes[camera.name] = "jsmpeg";
} }
return newModes; return newModes;
}); });
@ -699,6 +725,21 @@ export default function DraggableGridLayout({
"z-50 flex flex-row gap-2", "z-50 flex flex-row gap-2",
)} )}
> >
<Tooltip>
<TooltipTrigger asChild>
<div
className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-60 transition-all duration-300 hover:bg-muted hover:opacity-100"
onClick={toggleGlobalStreamStats}
>
<IoStatsChart className="size-5 md:m-[6px]" />
</div>
</TooltipTrigger>
<TooltipContent>
{globalStreamStatsEnabled
? t("streamStats.disable")
: t("streamStats.enable")}
</TooltipContent>
</Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <div

View File

@ -150,7 +150,7 @@ export default function LiveCameraView({
const [streamName, setStreamName, streamNameLoaded] = const [streamName, setStreamName, streamNameLoaded] =
useUserPersistence<string>( useUserPersistence<string>(
`${camera.name}-stream`, `${camera.name}-stream`,
Object.values(camera.live.streams)[0], Object.values(camera.live.streams)[1],
); );
const isRestreamed = useMemo( const isRestreamed = useMemo(
@ -273,7 +273,7 @@ export default function LiveCameraView({
false, false,
); );
const [showStats, setShowStats] = useState(false); const [showStats, setShowStats] = useState(true);
const [debug, setDebug] = useState(false); const [debug, setDebug] = useState(false);
useSearchEffect("debug", (value: string) => { useSearchEffect("debug", (value: string) => {

View File

@ -566,7 +566,7 @@ export default function LiveDashboardView({
} }
audioState={audioStates[camera.name]} audioState={audioStates[camera.name]}
toggleAudio={() => toggleAudio(camera.name)} toggleAudio={() => toggleAudio(camera.name)}
statsState={statsStates[camera.name]} statsState={statsStates[camera.name] ?? true}
toggleStats={() => toggleStats(camera.name)} toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name] ?? 1} volumeState={volumeStates[camera.name] ?? 1}
setVolumeState={(value) => setVolumeState={(value) =>
@ -599,7 +599,7 @@ export default function LiveDashboardView({
alwaysShowCameraName={displayCameraNames} alwaysShowCameraName={displayCameraNames}
useWebGL={useWebGL} useWebGL={useWebGL}
playInBackground={false} playInBackground={false}
showStats={statsStates[camera.name]} showStats={statsStates[camera.name] ?? true}
streamName={streamName} streamName={streamName}
onClick={() => onSelectCamera(camera.name)} onClick={() => onSelectCamera(camera.name)}
onError={(e) => handleError(camera.name, e)} onError={(e) => handleError(camera.name, e)}