show live streams as a one-tap grid in the mobile drawer

Switching streams on mobile meant scrolling past the camera toggles, opening a dropdown, and picking a stream. The drawer now opens with the stream section, and streams are a wrapping grid of toggles sized to their names, so a switch is two taps. A single stream shows as plain text. The audio and two-way talk lines show a spinner while the stream's go2rtc metadata loads instead of reporting audio as unavailable, which showed on the first switch to a transcoded stream.
This commit is contained in:
Josh Hawkins
2026-11-13 10:31:36 -06:00
parent c1ecdd80cc
commit 3575049cd7
3 changed files with 171 additions and 115 deletions
+3
View File
@@ -173,6 +173,9 @@
"label": "Play in background",
"tips": "Enable this option to continue streaming when the player is hidden."
},
"metadata": {
"loading": "Fetching stream metadata…"
},
"mode": "Streaming Technology",
"technology": {
"description": "Choose your preferred streaming technology. Frigate may still fall back to low bandwidth mode on playback or network errors.",
+74 -38
View File
@@ -12,11 +12,15 @@ import { useTranslation } from "react-i18next";
import { LuInfo } from "react-icons/lu";
import { MdOutlineRestartAlt } from "react-icons/md";
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
// Radix Select needs a non-empty value. Auto itself is stored as the
// absence of a pinned stream
const AUTO_VALUE = "__frigate_auto__";
const GRID_ITEM =
"h-auto min-h-10 min-w-[calc((100%-1rem)/3)] grow whitespace-normal bg-secondary [overflow-wrap:anywhere] px-2 py-2 text-secondary-foreground hover:bg-secondary hover:text-secondary-foreground data-[state=on]:bg-selected data-[state=on]:text-selected-foreground data-[state=on]:hover:bg-selected data-[state=on]:hover:text-selected-foreground";
type LiveStreamSelectProps = {
// friendly name to go2rtc stream name, in ladder order
streams: Record<string, string>;
@@ -29,6 +33,7 @@ type LiveStreamSelectProps = {
// sends auto back to the top of the ladder
onRetry: () => void;
disabled: boolean;
variant?: "select" | "grid";
};
export default function LiveStreamSelect({
@@ -40,6 +45,7 @@ export default function LiveStreamSelect({
onSelect,
onRetry,
disabled,
variant = "select",
}: LiveStreamSelectProps) {
const { t } = useTranslation(["views/live"]);
@@ -52,48 +58,78 @@ export default function LiveStreamSelect({
const auto = autoAvailable && pinnedStream == undefined;
const value = auto ? AUTO_VALUE : (pinnedStream ?? playingStream);
const autoLabel = auto
? t("stream.auto.selected", {
stream:
autoReason === "floor"
? t("stream.auto.floorLabel")
: labelFor(playingStream),
})
: t("stream.auto.label");
return (
<>
<Select
value={value}
disabled={disabled}
onValueChange={(next) =>
onSelect(next === AUTO_VALUE ? undefined : next)
}
>
<SelectTrigger className="w-full">
<SelectValue>
{auto
? t("stream.auto.selected", {
stream:
autoReason === "floor"
? t("stream.auto.floorLabel")
: labelFor(playingStream),
})
: labelFor(value)}
</SelectValue>
</SelectTrigger>
{variant === "grid" &&
!autoAvailable &&
Object.keys(streams).length <= 1 ? (
<div className="text-sm">{labelFor(playingStream)}</div>
) : variant === "grid" ? (
<ToggleGroup
type="single"
className="flex flex-wrap items-stretch gap-2"
value={value}
disabled={disabled}
onValueChange={(next) => {
// radix sends an empty value when the active item is tapped again
if (next) {
onSelect(next === AUTO_VALUE ? undefined : next);
}
}}
>
{autoAvailable && (
<ToggleGroupItem value={AUTO_VALUE} className={GRID_ITEM}>
{autoLabel}
</ToggleGroupItem>
)}
{Object.entries(streams).map(([label, name]) => (
<ToggleGroupItem key={label} value={name} className={GRID_ITEM}>
{label}
</ToggleGroupItem>
))}
</ToggleGroup>
) : (
<Select
value={value}
disabled={disabled}
onValueChange={(next) =>
onSelect(next === AUTO_VALUE ? undefined : next)
}
>
<SelectTrigger className="w-full">
<SelectValue>{auto ? autoLabel : labelFor(value)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{autoAvailable && (
<SelectItem className="cursor-pointer" value={AUTO_VALUE}>
<span className="flex flex-col gap-0.5 whitespace-normal">
<span>{t("stream.auto.label")}</span>
<span className="text-xs text-muted-foreground">
{t("stream.auto.tips")}
<SelectContent>
<SelectGroup>
{autoAvailable && (
<SelectItem className="cursor-pointer" value={AUTO_VALUE}>
<span className="flex flex-col gap-0.5 whitespace-normal">
<span>{t("stream.auto.label")}</span>
<span className="text-xs text-muted-foreground">
{t("stream.auto.tips")}
</span>
</span>
</span>
</SelectItem>
)}
{Object.entries(streams).map(([label, name]) => (
<SelectItem key={label} className="cursor-pointer" value={name}>
{label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SelectItem>
)}
{Object.entries(streams).map(([label, name]) => (
<SelectItem key={label} className="cursor-pointer" value={name}>
{label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
{auto && autoReason && !disabled && (
<div className="flex flex-col items-center gap-3">
+94 -77
View File
@@ -230,15 +230,16 @@ export default function LiveCameraView({
}
}, [streamNameLoaded, liveStreams, pinnedStream, clearPinnedStream]);
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
isRestreamed ? `go2rtc/streams/${streamName}` : null,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 60000,
},
);
const { data: cameraMetadata, isLoading: cameraMetadataLoading } =
useSWR<LiveStreamMetadata>(
isRestreamed ? `go2rtc/streams/${streamName}` : null,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 60000,
},
);
const webRTCAvailability = useWebRTCAvailableForStream(
cameraMetadata,
@@ -847,6 +848,7 @@ export default function LiveCameraView({
onResetStream={resetStream}
supportsAudioOutput={supportsAudioOutput}
supports2WayTalk={supports2WayTalk}
streamMetadataLoading={cameraMetadataLoading}
cameraEnabled={cameraEnabled}
debug={debug}
setDebug={setDebug}
@@ -1013,6 +1015,7 @@ type FrigateCameraFeaturesProps = {
onResetStream: () => void;
supportsAudioOutput: boolean;
supports2WayTalk: boolean;
streamMetadataLoading: boolean;
cameraEnabled: boolean;
debug: boolean;
setDebug: (debug: boolean) => void;
@@ -1044,6 +1047,7 @@ function FrigateCameraFeatures({
onResetStream,
supportsAudioOutput,
supports2WayTalk,
streamMetadataLoading,
cameraEnabled,
debug,
setDebug,
@@ -1440,7 +1444,12 @@ function FrigateCameraFeatures({
!debug &&
isRestreamed && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? (
{streamMetadataLoading ? (
<>
<ActivityIndicator className="size-4" />
<div>{t("stream.metadata.loading")}</div>
</>
) : supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>{t("stream.audio.available")}</div>
@@ -1699,72 +1708,7 @@ function FrigateCameraFeatures({
<DrawerContent className="max-h-[75dvh] overflow-hidden rounded-2xl">
<div className="scrollbar-container mt-2 flex h-auto flex-col gap-2 overflow-y-auto px-2 py-4">
<>
{isAdmin && (
<>
<FilterSwitch
label={t("cameraSettings.camera")}
isChecked={enabledState == "ON"}
onCheckedChange={() =>
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
}
/>
<FilterSwitch
label={t("cameraSettings.objectDetection")}
isChecked={detectState == "ON"}
onCheckedChange={() =>
sendDetect(detectState == "ON" ? "OFF" : "ON")
}
/>
{recordingEnabled && (
<FilterSwitch
label={t("cameraSettings.recording")}
isChecked={recordState == "ON"}
onCheckedChange={() =>
sendRecord(recordState == "ON" ? "OFF" : "ON")
}
/>
)}
<FilterSwitch
label={t("cameraSettings.snapshots")}
isChecked={snapshotState == "ON"}
onCheckedChange={() =>
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
}
/>
{audioDetectEnabled && (
<FilterSwitch
label={t("cameraSettings.audioDetection")}
isChecked={audioState == "ON"}
onCheckedChange={() =>
sendAudio(audioState == "ON" ? "OFF" : "ON")
}
/>
)}
{audioDetectEnabled && transcriptionEnabled && (
<FilterSwitch
label={t("cameraSettings.transcription")}
disabled={audioState == "OFF"}
isChecked={transcriptionState == "ON"}
onCheckedChange={() =>
sendTranscription(
transcriptionState == "ON" ? "OFF" : "ON",
)
}
/>
)}
{autotrackingEnabled && (
<FilterSwitch
label={t("cameraSettings.autotracking")}
isChecked={autotrackingState == "ON"}
onCheckedChange={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
/>
)}
</>
)}
<div className="mt-3 flex flex-col gap-5">
<div className="mb-3 flex flex-col gap-5">
{!isRestreamed && (
<div className="flex flex-col gap-2 p-2">
<Label>{t("stream.title")}</Label>
@@ -1817,6 +1761,7 @@ function FrigateCameraFeatures({
onSelect={onSelectStream}
onRetry={onResetStream}
disabled={debug || forceLowBandwidth}
variant="grid"
/>
{debug && (
@@ -1832,7 +1777,12 @@ function FrigateCameraFeatures({
!debug &&
isRestreamed && (
<div className="mt-1 flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? (
{streamMetadataLoading ? (
<>
<ActivityIndicator className="size-4" size={14} />
<div>{t("stream.metadata.loading")}</div>
</>
) : supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>{t("stream.audio.available")}</div>
@@ -1961,6 +1911,73 @@ function FrigateCameraFeatures({
</p>
</div>
)}
</div>
{isAdmin && (
<>
<FilterSwitch
label={t("cameraSettings.camera")}
isChecked={enabledState == "ON"}
onCheckedChange={() =>
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
}
/>
<FilterSwitch
label={t("cameraSettings.objectDetection")}
isChecked={detectState == "ON"}
onCheckedChange={() =>
sendDetect(detectState == "ON" ? "OFF" : "ON")
}
/>
{recordingEnabled && (
<FilterSwitch
label={t("cameraSettings.recording")}
isChecked={recordState == "ON"}
onCheckedChange={() =>
sendRecord(recordState == "ON" ? "OFF" : "ON")
}
/>
)}
<FilterSwitch
label={t("cameraSettings.snapshots")}
isChecked={snapshotState == "ON"}
onCheckedChange={() =>
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
}
/>
{audioDetectEnabled && (
<FilterSwitch
label={t("cameraSettings.audioDetection")}
isChecked={audioState == "ON"}
onCheckedChange={() =>
sendAudio(audioState == "ON" ? "OFF" : "ON")
}
/>
)}
{audioDetectEnabled && transcriptionEnabled && (
<FilterSwitch
label={t("cameraSettings.transcription")}
disabled={audioState == "OFF"}
isChecked={transcriptionState == "ON"}
onCheckedChange={() =>
sendTranscription(
transcriptionState == "ON" ? "OFF" : "ON",
)
}
/>
)}
{autotrackingEnabled && (
<FilterSwitch
label={t("cameraSettings.autotracking")}
isChecked={autotrackingState == "ON"}
onCheckedChange={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
/>
)}
</>
)}
<div className="mt-3 flex flex-col gap-5">
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="flex flex-col gap-2">