751 lines
36 KiB
TypeScript
751 lines
36 KiB
TypeScript
// 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 (
|
||
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||
{children}
|
||
</label>
|
||
)
|
||
}
|
||
|
||
function SubHeading({ children }: { children: string }) {
|
||
return (
|
||
<h5 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||
{children}
|
||
</h5>
|
||
)
|
||
}
|
||
|
||
function Divider() {
|
||
return <hr className="border-gray-200 dark:border-white/10" />
|
||
}
|
||
|
||
function EmptyState({ message }: { message: string }) {
|
||
return <p className="text-sm text-gray-500 dark:text-gray-400">{message}</p>
|
||
}
|
||
|
||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||
|
||
type CameraDetailsModalProps = {
|
||
open: boolean
|
||
setOpen: (open: boolean) => void
|
||
device: Device | null
|
||
childDevice: NonNullable<Device['milestoneChildDevices']>[number] | null
|
||
camera: MilestoneCameraDetail | null
|
||
apiStreams?: MilestoneApiStream[]
|
||
cameraSettings?: MilestoneCameraSettings | null
|
||
isLoading: boolean
|
||
isSaving: boolean
|
||
error: string | null
|
||
onSubmit: NonNullable<ComponentProps<'form'>['onSubmit']>
|
||
}
|
||
|
||
// ── Component ─────────────────────────────────────────────────────────────────
|
||
|
||
export default function CameraDetailsModal({
|
||
open,
|
||
setOpen,
|
||
device,
|
||
childDevice,
|
||
camera,
|
||
apiStreams = [],
|
||
cameraSettings = null,
|
||
isLoading,
|
||
isSaving,
|
||
error,
|
||
onSubmit,
|
||
}: CameraDetailsModalProps) {
|
||
const [activeTab, setActiveTab] = useState<CameraTabId>('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<string, string>()
|
||
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 (
|
||
<Modal
|
||
open={open}
|
||
setOpen={setOpen}
|
||
zIndexClassName="z-[10000]"
|
||
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl bg-white dark:bg-gray-900"
|
||
>
|
||
<form onSubmit={onSubmit} className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col">
|
||
|
||
{/* Header */}
|
||
<div className="flex shrink-0 items-start justify-between border-b border-gray-200 bg-gray-50 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
|
||
<div className="flex items-center gap-3">
|
||
<VideoCameraIcon aria-hidden="true" className="size-5 text-indigo-600 dark:text-indigo-400" />
|
||
<div>
|
||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||
{device?.milestoneDisplayName || device?.inventoryNumber || 'Milestone-Kamera'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Button type="button" variant="secondary" color="gray" size="sm" disabled={isSaving} onClick={() => setOpen(false)}>
|
||
<span className="sr-only">Schließen</span>
|
||
<XMarkIcon aria-hidden="true" className="size-5" />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Tabs nav */}
|
||
<div className="shrink-0 bg-gray-50 dark:bg-gray-800/60">
|
||
<Tabs
|
||
fullWidth
|
||
tabs={tabItems}
|
||
variant="underline-icons"
|
||
ariaLabel="Kamera-Einstellungen"
|
||
className="overflow-x-auto px-4 sm:px-0"
|
||
desktopNavClassName="sm:px-6"
|
||
/>
|
||
</div>
|
||
|
||
{/* Tab content – all panels stay in DOM so FormData is complete */}
|
||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||
|
||
{isLoading && (
|
||
<div className="flex min-h-48 items-center justify-center p-6">
|
||
<LoadingSpinner size="md" label="Kamera-Details werden geladen…" className="text-indigo-600 dark:text-indigo-400" />
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="m-4 rounded-md bg-red-50 p-4 text-sm text-red-700 sm:mx-6 dark:bg-red-900/30 dark:text-red-300">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{!isLoading && camera && (
|
||
<>
|
||
{/* ── Tab 1: Allgemein ─────────────────────────────────── */}
|
||
<div className={activeTab === 'general' ? 'space-y-5 px-4 py-6 sm:px-6' : 'hidden'}>
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
|
||
|
||
<div className="sm:col-span-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
||
<Switch id="cameraEnabled" name="enabled" label="Kamera aktiviert" defaultChecked={camera.enabled} />
|
||
</div>
|
||
<div className="sm:col-span-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
||
<Switch id="cameraRecordingEnabled" name="recordingEnabled" label="Aufzeichnung aktiviert" defaultChecked={camera.recordingEnabled} />
|
||
</div>
|
||
<div className="sm:col-span-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
||
<Switch id="cameraRecordKeyframesOnly" name="recordKeyframesOnly" label="Nur Keyframes aufzeichnen" defaultChecked={camera.recordKeyframesOnly} />
|
||
</div>
|
||
</div>
|
||
|
||
<Divider />
|
||
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
|
||
<div className="sm:col-span-6">
|
||
<Label>Recording-Storage</Label>
|
||
{camera.recordingStorage?.id && (
|
||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
Aktuell: {selectedStorageLabel}
|
||
{camera.recordingStorage.diskPath ? ` · ${camera.recordingStorage.diskPath}` : ''}
|
||
{camera.recordingStorage.mounted === false ? ' · nicht gemountet' : ''}
|
||
</p>
|
||
)}
|
||
<div className="mt-2">
|
||
<select name="recordingStorageId" defaultValue={camera.recordingStorage?.id ?? ''} className={cls()}>
|
||
{storageOptions.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Tab 2: Stream-Auswahl ────────────────────────────── */}
|
||
<div className={activeTab === 'stream' ? 'space-y-5 px-4 py-6 sm:px-6' : 'hidden'}>
|
||
{streamOptions.length === 0 ? (
|
||
<EmptyState message="Keine Streams verfügbar." />
|
||
) : (
|
||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-3">
|
||
<div>
|
||
<Label>Live-Standard</Label>
|
||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Stream für Live-Ansicht</p>
|
||
<div className="mt-2">
|
||
<select name="active-liveDefault-refId" defaultValue={liveDefaultRefId} className={cls()}>
|
||
{streamOptions.map((o) => <option key={o.refId} value={o.refId}>{o.label}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<Label>Standard-Wiedergabe</Label>
|
||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Stream für Aufzeichnungswiedergabe</p>
|
||
<div className="mt-2">
|
||
<select name="active-defaultPlayback-refId" defaultValue={defaultPlaybackRefId} className={cls()}>
|
||
{streamOptions.map((o) => <option key={o.refId} value={o.refId}>{o.label}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<Label>Live-Modus</Label>
|
||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Wann der Stream gesendet wird</p>
|
||
<div className="mt-2">
|
||
<select name="active-liveMode" defaultValue={currentLiveMode} className={cls()}>
|
||
<option value="WhenNeeded">Wenn benötigt</option>
|
||
<option value="Always">Immer</option>
|
||
<option value="Never">Nie</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* PTZ */}
|
||
<div className={activeTab === 'ptz' ? 'space-y-4 px-4 py-6 sm:px-6' : 'hidden'}>
|
||
{!ptz ? (
|
||
<EmptyState message="Keine PTZ-Einstellungen verfügbar." />
|
||
) : (
|
||
<>
|
||
{/* PTZ */}
|
||
<div className="space-y-4">
|
||
<SubHeading>PTZ</SubHeading>
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
|
||
<div className="sm:col-span-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
||
<Switch id="ptz-ptzEnabled" name="ptz-ptzEnabled" label="PTZ aktiviert" defaultChecked={ptz.ptzEnabled} />
|
||
</div>
|
||
<div className="sm:col-span-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
||
<Switch id="ptz-ptzHomeSupport" name="ptz-ptzHomeSupport" label="Home-Position" defaultChecked={ptz.ptzHomeSupport} />
|
||
</div>
|
||
<div className="sm:col-span-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
||
<Switch id="ptz-ptzCenterOnPosition" name="ptz-ptzCenterOnPosition" label="Zentrierung auf Position" defaultChecked={ptz.ptzCenterOnPositionInView} />
|
||
</div>
|
||
<div className="sm:col-span-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
||
<Switch id="ptz-ptzCenterAndZoom" name="ptz-ptzCenterAndZoom" label="Zoom auf Rechteck" defaultChecked={ptz.ptzCenterAndZoomToRectangle} />
|
||
</div>
|
||
<div className="sm:col-span-3">
|
||
<Label>Protokoll</Label>
|
||
<div className="mt-2">
|
||
<input name="ptz-ptzProtocol" type="text" defaultValue={ptz.ptzProtocol} className={cls()} />
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-3">
|
||
<Label>COM-Port</Label>
|
||
<div className="mt-2">
|
||
<input name="ptz-ptzCOMPort" type="number" min="0" step="1" defaultValue={ptz.ptzCOMPort} className={cls()} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Bildeinstellungen */}
|
||
<div className={activeTab === 'image' ? 'space-y-4 px-4 py-6 sm:px-6' : 'hidden'}>
|
||
{!gs ? (
|
||
<EmptyState message="Keine Bildeinstellungen verfügbar." />
|
||
) : (
|
||
<>
|
||
{/* Bild */}
|
||
<div className="space-y-4">
|
||
<SubHeading>Bildeinstellungen</SubHeading>
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
|
||
<div className="sm:col-span-3">
|
||
<Label>Kameraname</Label>
|
||
<div className="mt-2">
|
||
<input name="gs-cameraName" type="text" defaultValue={gs.cameraName} className={cls()} />
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-3">
|
||
<Label>Weißabgleich</Label>
|
||
<div className="mt-2">
|
||
<input name="gs-whiteBalance" type="text" defaultValue={gs.whiteBalance} className={cls()} />
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<Label>Helligkeit</Label>
|
||
<div className="mt-2">
|
||
<input name="gs-brightness" type="number" min="0" max="100" defaultValue={gs.brightness} className={cls()} />
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<Label>Kontrast</Label>
|
||
<div className="mt-2">
|
||
<input name="gs-contrast" type="number" min="0" max="100" defaultValue={gs.contrast} className={cls()} />
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<Label>Sättigung</Label>
|
||
<div className="mt-2">
|
||
<input name="gs-saturation" type="number" min="0" max="100" defaultValue={gs.saturation} className={cls()} />
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<Label>Schärfe</Label>
|
||
<div className="mt-2">
|
||
<input name="gs-sharpness" type="number" min="0" max="100" defaultValue={gs.sharpness} className={cls()} />
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<Label>Bild spiegeln</Label>
|
||
<div className="mt-2">
|
||
<select name="gs-mirrorImage" defaultValue={gs.mirrorImage} className={cls()}>
|
||
<option value="No">Nein</option>
|
||
<option value="Yes">Ja</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<Label>Rotation</Label>
|
||
<div className="mt-2">
|
||
<select name="gs-rotation" defaultValue={gs.rotation} className={cls()}>
|
||
<option value="0">0°</option>
|
||
<option value="90">90°</option>
|
||
<option value="180">180°</option>
|
||
<option value="270">270°</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
||
<Switch id="gs-blackAndWhite" name="gs-blackAndWhite" label="Schwarz/Weiß" defaultChecked={gs.blackAndWhiteMode === 'Yes'} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* OSD */}
|
||
<div className={activeTab === 'osd' ? 'space-y-6 px-4 py-6 sm:px-6' : 'hidden'}>
|
||
{!gs ? (
|
||
<EmptyState message="Keine OSD-Einstellungen verfügbar." />
|
||
) : (
|
||
<>
|
||
{/* OSD & Multicast */}
|
||
<div className="space-y-4">
|
||
<SubHeading>OSD & Multicast</SubHeading>
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
|
||
<div className="sm:col-span-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
||
<Switch id="gs-oSDTextEnabled" name="gs-oSDTextEnabled" label="OSD-Text anzeigen" defaultChecked={gs.oSDTextEnabled === 'Yes'} />
|
||
</div>
|
||
<div className="sm:col-span-3">
|
||
<Label>OSD-Position</Label>
|
||
<div className="mt-2">
|
||
<select name="gs-oSDPosition" defaultValue={gs.oSDPosition} className={cls()}>
|
||
<option value="Top">Oben</option>
|
||
<option value="Bottom">Unten</option>
|
||
<option value="Left">Links</option>
|
||
<option value="Right">Rechts</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-3">
|
||
<Label>OSD-Text</Label>
|
||
<div className="mt-2">
|
||
<input name="gs-oSDText" type="text" defaultValue={gs.oSDText} className={cls()} />
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-3">
|
||
<Label>Multicast-Adresse</Label>
|
||
<div className="mt-2">
|
||
<input name="gs-multicastAddress" type="text" defaultValue={gs.multicastAddress} className={cls()} />
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-3">
|
||
<Label>Multicast-Port</Label>
|
||
<div className="mt-2">
|
||
<input name="gs-multicastVideoPort" type="number" min="0" defaultValue={gs.multicastVideoPort} className={cls()} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Tab 4: Video-Streams ─────────────────────────────── */}
|
||
<div className={activeTab === 'videostreams' ? 'space-y-4 px-4 py-6 sm:px-6' : 'hidden'}>
|
||
{settingsStreams.length === 0 ? (
|
||
<EmptyState message="Keine Stream-Einstellungen verfügbar." />
|
||
) : (
|
||
<>
|
||
<Tabs
|
||
tabs={videoStreamTabs}
|
||
variant="pills-brand"
|
||
background="subtle"
|
||
fullWidth
|
||
ariaLabel="Video-Streams"
|
||
className="overflow-x-auto"
|
||
/>
|
||
|
||
{settingsStreams.map((stream, index) => (
|
||
<div
|
||
key={stream.streamReferenceId || index}
|
||
className={
|
||
index === visibleStreamIndex
|
||
? 'rounded-lg border border-gray-200 dark:border-white/10'
|
||
: 'hidden'
|
||
}
|
||
>
|
||
|
||
{/* Stream header */}
|
||
<div className="flex items-center gap-2 border-b border-gray-200 bg-gray-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
|
||
<FilmIcon aria-hidden="true" className="size-4 text-indigo-500 dark:text-indigo-400" />
|
||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||
{stream.displayName || `Stream ${index + 1}`}
|
||
</span>
|
||
{stream.streamReferenceId && (
|
||
<span className="font-mono text-xs text-gray-400 dark:text-gray-500">
|
||
{stream.streamReferenceId.slice(0, 8)}…
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="p-4">
|
||
<input type="hidden" name={`stream-${index}-streamReferenceId`} value={stream.streamReferenceId} />
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
|
||
|
||
{/* Codec / FPS / Auflösung */}
|
||
<div className="sm:col-span-2">
|
||
<Label>Codec</Label>
|
||
<div className="mt-2">
|
||
<select name={`stream-${index}-codec`} defaultValue={stream.codec} className={cls()}>
|
||
{withCurrentValue(CODEC_OPTIONS, stream.codec).map((c) => (
|
||
<option key={c} value={c}>{c.toUpperCase()}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-1">
|
||
<Label>FPS</Label>
|
||
<div className="mt-2">
|
||
<input name={`stream-${index}-FPS`} type="number" min="0" step="1" defaultValue={stream.FPS ?? ''} className={cls()} />
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-3">
|
||
<Label>Auflösung</Label>
|
||
<div className="mt-2">
|
||
<select name={`stream-${index}-resolution`} defaultValue={stream.resolution} className={cls()}>
|
||
{resolutionOptionsForStream(stream, index).map((r) => (
|
||
<option key={r} value={r}>{r}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bitrate / Komprimierung / GOP */}
|
||
<div className="sm:col-span-2">
|
||
<Label>Ziel-Bitrate (kbps)</Label>
|
||
<div className="mt-2">
|
||
<input name={`stream-${index}-targetBitrate`} type="number" min="0" step="1" defaultValue={stream.targetBitrate ?? ''} className={cls()} />
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<Label>Komprimierung</Label>
|
||
<div className="mt-2">
|
||
<input name={`stream-${index}-compression`} type="number" min="0" max="100" defaultValue={stream.compression ?? ''} className={cls()} />
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<Label>Max. Bilder zwischen Schlüsselbilder</Label>
|
||
<div className="mt-2">
|
||
<input name={`stream-${index}-maxGOPSize`} type="number" min="0" step="1" defaultValue={stream.maxGOPSize ?? ''} className={cls()} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Streaming-Modus / Steuerungsmodus */}
|
||
<div className="sm:col-span-3">
|
||
<Label>Streaming-Modus</Label>
|
||
<div className="mt-2">
|
||
<select name={`stream-${index}-streamingMode`} defaultValue={stream.streamingMode} className={cls()}>
|
||
{withCurrentValue(STREAMING_MODE_OPTIONS, stream.streamingMode).map((m) => (
|
||
<option key={m} value={m}>{m}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-3">
|
||
<Label>Steuerungsmodus</Label>
|
||
<div className="mt-2">
|
||
<select name={`stream-${index}-controlMode`} defaultValue={stream.controlMode} className={cls()}>
|
||
<option value="VariableBitRate">Variable Bitrate</option>
|
||
<option value="ConstantBitRate">Konstante Bitrate</option>
|
||
{stream.controlMode && !['VariableBitRate', 'ConstantBitRate'].includes(stream.controlMode) && (
|
||
<option value={stream.controlMode}>{stream.controlMode}</option>
|
||
)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Datum / Uhrzeit */}
|
||
<div className="sm:col-span-3">
|
||
<Label>Datum einfügen</Label>
|
||
<div className="mt-2">
|
||
<select name={`stream-${index}-includeDate`} defaultValue={stream.includeDate || 'No'} className={cls()}>
|
||
<option value="No">Nein</option>
|
||
<option value="Yes">Ja</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-3">
|
||
<Label>Uhrzeit einfügen</Label>
|
||
<div className="mt-2">
|
||
<select name={`stream-${index}-includeTime`} defaultValue={stream.includeTime || 'No'} className={cls()}>
|
||
<option value="No">Nein</option>
|
||
<option value="Yes">Ja</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Zipstream */}
|
||
<div className="sm:col-span-6">
|
||
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 dark:border-white/5 dark:bg-white/[0.02]">
|
||
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||
Zipstream
|
||
</p>
|
||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||
<div>
|
||
<Label>Kompression</Label>
|
||
<div className="mt-2">
|
||
<select name={`stream-${index}-zStrength`} defaultValue={stream.zStrength || ''} className={cls()}>
|
||
<option value="">–</option>
|
||
{withCurrentValue(ZIPSTREAM_STRENGTH_OPTIONS, stream.zStrength).filter(Boolean).map((z) => (
|
||
<option key={z} value={z}>{z}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<Label>GOP-Modus</Label>
|
||
<div className="mt-2">
|
||
<select name={`stream-${index}-zGopMode`} defaultValue={stream.zGopMode || ''} className={cls()}>
|
||
<option value="">–</option>
|
||
<option value="Fixed">Fixed</option>
|
||
<option value="DynamicGOP">Dynamic GOP</option>
|
||
{stream.zGopMode && !['', 'Fixed', 'DynamicGOP'].includes(stream.zGopMode) && (
|
||
<option value={stream.zGopMode}>{stream.zGopMode}</option>
|
||
)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<Label>FPS-Modus</Label>
|
||
<div className="mt-2">
|
||
<select name={`stream-${index}-zFpsMode`} defaultValue={stream.zFpsMode || ''} className={cls()}>
|
||
<option value="">–</option>
|
||
<option value="Fixed">Fixed</option>
|
||
<option value="DynamicFPS">Dynamic FPS</option>
|
||
{stream.zFpsMode && !['', 'Fixed', 'DynamicFPS'].includes(stream.zFpsMode) && (
|
||
<option value={stream.zFpsMode}>{stream.zFpsMode}</option>
|
||
)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<Label>GOP-Länge</Label>
|
||
<div className="mt-2">
|
||
<input name={`stream-${index}-zGopLength`} type="number" min="0" step="1" defaultValue={stream.zGopLength ?? ''} className={cls()} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="flex shrink-0 justify-end gap-x-3 border-t border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
|
||
<Button type="button" variant="secondary" color="gray" disabled={isSaving} onClick={() => setOpen(false)}>
|
||
Abbrechen
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
color="indigo"
|
||
isLoading={isSaving}
|
||
disabled={isLoading || !camera}
|
||
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
|
||
>
|
||
Speichern
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
)
|
||
}
|