mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-14 09:33:00 +03:00
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:
@@ -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.",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user