// frontend\src\pages\devices\CameraDetailsModal.tsx
import { useState } from 'react'
import type { ComponentProps } from 'react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import {
CheckIcon,
VideoCameraIcon,
Cog6ToothIcon,
PlayCircleIcon,
AdjustmentsHorizontalIcon,
FilmIcon,
} from '@heroicons/react/20/solid'
import Modal from '../../components/Modal'
import Button from '../../components/Button'
import Switch from '../../components/Switch'
import LoadingSpinner from '../../components/LoadingSpinner'
import Tabs, { type TabItem } from '../../components/Tabs'
import type {
Device,
MilestoneCameraDetail,
MilestoneApiStream,
MilestoneCameraSettings,
MilestoneStreamDetail,
} from '../../components/types'
// ── Option lists ──────────────────────────────────────────────────────────────
const CODEC_OPTIONS = ['h264', 'h265', 'mjpeg', 'mpeg4']
const STREAMING_MODE_OPTIONS = ['TCP', 'UDP', 'Multicast', 'HTTPS']
const ZIPSTREAM_STRENGTH_OPTIONS = ['aus', 'niedrig', 'mittel', 'hoch', 'Höher', 'Extrem']
function withCurrentValue(options: string[], current: string): string[] {
return Array.from(
new Set([current, ...options].map((option) => option.trim()).filter(Boolean)),
)
}
// ── Shared primitives ─────────────────────────────────────────────────────────
type CameraTabId =
| 'general'
| 'stream'
| 'ptz'
| 'image'
| 'osd'
| 'videostreams'
function cls() {
return [
'block w-full rounded-md px-3 py-1.5 text-sm outline-1 -outline-offset-1',
'bg-white text-gray-900 outline-gray-300',
'focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600',
'dark:bg-white/5 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500',
].join(' ')
}
function Label({ children }: { children: string }) {
return (
)
}
function SubHeading({ children }: { children: string }) {
return (
{children}
)
}
function Divider() {
return
}
function EmptyState({ message }: { message: string }) {
return {message}
}
// ── Props ─────────────────────────────────────────────────────────────────────
type CameraDetailsModalProps = {
open: boolean
setOpen: (open: boolean) => void
device: Device | null
childDevice: NonNullable[number] | null
camera: MilestoneCameraDetail | null
apiStreams?: MilestoneApiStream[]
cameraSettings?: MilestoneCameraSettings | null
isLoading: boolean
isSaving: boolean
error: string | null
onSubmit: NonNullable['onSubmit']>
}
// ── Component ─────────────────────────────────────────────────────────────────
export default function CameraDetailsModal({
open,
setOpen,
device,
childDevice,
camera,
apiStreams = [],
cameraSettings = null,
isLoading,
isSaving,
error,
onSubmit,
}: CameraDetailsModalProps) {
const [activeTab, setActiveTab] = useState('general')
const [activeStreamIndex, setActiveStreamIndex] = useState(0)
const title = childDevice?.displayName || childDevice?.name || 'Kamera bearbeiten'
// ── Derived data ────────────────────────────────────────────────────────────
const selectedStorageLabel = camera?.recordingStorage
? (camera.recordingStorage.displayName || camera.recordingStorage.name || camera.recordingStorage.id)
: ''
const storageOptions = (() => {
const map = new Map()
if (camera?.recordingStorage?.id) map.set(camera.recordingStorage.id, selectedStorageLabel)
for (const s of camera?.archiveStorages ?? []) map.set(s.id, s.displayName || s.name || s.id)
return Array.from(map.entries()).map(([value, label]) => ({ value, label }))
})()
const settingsStreams = cameraSettings?.stream ?? []
const gs = cameraSettings?.generalSettings?.[0]
const ptz = cameraSettings?.ptz
const allApiSettings = apiStreams.flatMap((g) => g.stream)
const cameraResolutionOptions = (camera?.streams ?? [])
.flatMap((stream) => [
stream.resolution,
...(stream.availableResolutions ?? []),
])
.filter(Boolean)
function resolutionOptionsForStream(stream: MilestoneStreamDetail, index: number) {
const activeStream = allApiSettings.find(
(setting) => setting.streamReferenceId === stream.streamReferenceId,
)
const cameraStream = camera?.streams?.[index]
const dynamicOptions = [
...(stream.availableResolutions ?? []),
...(activeStream?.availableResolutions ?? []),
...(cameraStream?.availableResolutions ?? []),
]
const uniqueDynamicOptions = withCurrentValue(dynamicOptions, stream.resolution)
return withCurrentValue(
uniqueDynamicOptions.length > 1 ? uniqueDynamicOptions : cameraResolutionOptions,
stream.resolution,
)
}
const streamOptions =
settingsStreams.length > 0
? settingsStreams.map((s) => ({
refId: s.streamReferenceId,
label: s.displayName || `Stream (${s.streamReferenceId.slice(0, 8)})`,
}))
: apiStreams.flatMap((g) =>
g.stream.map((s) => ({
refId: s.streamReferenceId,
label: g.displayName || `Stream (${s.streamReferenceId.slice(0, 8)})`,
})),
)
const liveDefaultRefId =
allApiSettings.find((s) => s.liveDefault)?.streamReferenceId ?? streamOptions[0]?.refId ?? ''
const defaultPlaybackRefId =
allApiSettings.find((s) => s.defaultPlayback)?.streamReferenceId ?? streamOptions[0]?.refId ?? ''
const currentLiveMode =
allApiSettings.find((s) => s.streamReferenceId === liveDefaultRefId)?.liveMode ?? 'WhenNeeded'
const visibleStreamIndex = settingsStreams[activeStreamIndex]
? activeStreamIndex
: 0
// ── Tabs ────────────────────────────────────────────────────────────────────
const tabItems: TabItem[] = [
{
name: 'Allgemein',
href: '#',
current: activeTab === 'general',
icon: Cog6ToothIcon,
onClick: () => setActiveTab('general'),
},
{
name: 'Stream-Auswahl',
href: '#',
current: activeTab === 'stream',
icon: PlayCircleIcon,
onClick: () => setActiveTab('stream'),
},
{
name: 'PTZ',
href: '#',
current: activeTab === 'ptz',
icon: AdjustmentsHorizontalIcon,
onClick: () => setActiveTab('ptz'),
},
{
name: 'Bildeinstellungen',
href: '#',
current: activeTab === 'image',
icon: Cog6ToothIcon,
onClick: () => setActiveTab('image'),
},
{
name: 'OSD',
href: '#',
current: activeTab === 'osd',
icon: VideoCameraIcon,
onClick: () => setActiveTab('osd'),
},
{
name: 'Video-Streams',
href: '#',
current: activeTab === 'videostreams',
icon: FilmIcon,
onClick: () => setActiveTab('videostreams'),
},
]
const videoStreamTabs: TabItem[] = settingsStreams.map((_stream, index) => ({
name: `Stream ${index + 1}`,
href: '#',
current: visibleStreamIndex === index,
onClick: () => setActiveStreamIndex(index),
}))
// ── Render ──────────────────────────────────────────────────────────────────
return (
)
}