{t('Cameras.Title')}
{can('Manage') && (
- setEditing(null)}>
- {t('Cameras.Add')}
-
+ <>
+ {/* Finding cameras on the network is a way to add one, so it sits
+ next to Add rather than in its own nav entry. */}
+
+ {t('Nav.Discovery')}
+
+ setEditing(null)}>
+ {t('Cameras.Add')}
+
+ >
)}
@@ -78,7 +86,7 @@ export function Cameras() {
- {t('Cameras.Live')}{' '}
+ {t('Cameras.Live')}{' '}
{can('Manage') && (
<>
setEditing(c)}>{t('Cameras.Edit')} {' '}
diff --git a/src/OpenIPC.Viewer.Web.Client/src/pages/Discovery.tsx b/src/OpenIPC.Viewer.Web.Client/src/pages/Discovery.tsx
index d858ea7..b7f911b 100644
--- a/src/OpenIPC.Viewer.Web.Client/src/pages/Discovery.tsx
+++ b/src/OpenIPC.Viewer.Web.Client/src/pages/Discovery.tsx
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { api, ApiError, type CameraDraftDto, type DiscoveredDeviceDto, type ScanDto } from '../api'
+import { Link } from 'react-router-dom'
import { useI18n } from '../i18n'
+import { Icon } from '../components/Icon'
// Find cameras on the LAN and add them without leaving the browser.
//
@@ -74,6 +76,9 @@ export function Discovery() {
return (
+
+ {t('Nav.Cameras')}
+
{t('Discovery.Title')}
(key: string, fallback: T): T {
+ try {
+ const raw = localStorage.getItem('openipc.grid.' + key)
+ return raw === null ? fallback : (JSON.parse(raw) as T)
+ } catch {
+ return fallback
+ }
+}
+function savePref(key: string, value: unknown) {
+ try {
+ localStorage.setItem('openipc.grid.' + key, JSON.stringify(value))
+ } catch { /* private mode / quota — the preference just won't stick */ }
+}
+
// The Live grid, driven by saved layouts (name + grid size + ordered camera
// tiles). Switching between layouts keeps shared cameras playing. An edit mode
@@ -31,6 +56,14 @@ export function Grid() {
const [busy, setBusy] = useState(false)
// Which dialog is open, if any (replaces native prompt/confirm).
const [dialog, setDialog] = useState<'create' | 'rename' | 'delete' | null>(null)
+ // Wall modes: periodic stills instead of live streams, and a chrome-free
+ // fullscreen wall that can cycle through the layout's pages on its own.
+ const [stills, setStills] = useState(() => loadPref('stills', false))
+ const [stillInterval, setStillInterval] = useState(() => loadPref('stillInterval', 5))
+ const [rotate, setRotate] = useState(() => loadPref('rotate', false))
+ const [rotateInterval, setRotateInterval] = useState(() => loadPref('rotateInterval', 30))
+ const [kiosk, setKiosk] = useState(false)
+ const [stage, setStage] = useState(null)
const camById = useMemo(() => {
const m = new Map()
@@ -52,6 +85,48 @@ export function Grid() {
}
}, [])
+ useEffect(() => savePref('stills', stills), [stills])
+ useEffect(() => savePref('stillInterval', stillInterval), [stillInterval])
+ useEffect(() => savePref('rotate', rotate), [rotate])
+ useEffect(() => savePref('rotateInterval', rotateInterval), [rotateInterval])
+
+ // Kiosk is a CSS mode first and a fullscreen request second. The Fullscreen
+ // API is refused outright in an embedded/iframed page, and a wall shown that
+ // way (a kiosk browser, a dashboard frame) still wants the chrome gone — so
+ // the mode never depends on the request being granted.
+ const enterKiosk = () => {
+ setKiosk(true)
+ // Refusal comes back either way depending on the browser — a rejected
+ // promise, or a synchronous throw that would take the click handler (and
+ // the mode with it) down. Both mean the same thing: CSS-only kiosk.
+ try {
+ void stage?.requestFullscreen?.()?.catch(() => { /* CSS-only kiosk, then */ })
+ } catch { /* CSS-only kiosk, then */ }
+ }
+ const exitKiosk = useCallback(() => {
+ setKiosk(false)
+ if (document.fullscreenElement) void document.exitFullscreen()
+ }, [])
+
+ // Leaving fullscreen by the browser's own means (Esc, F11, the tile that went
+ // fullscreen on its own) must drop the mode too, and Esc has to work in the
+ // CSS-only case where the browser has no fullscreen to leave.
+ useEffect(() => {
+ if (!kiosk) return
+ const onFullscreenChange = () => {
+ if (!document.fullscreenElement) setKiosk(false)
+ }
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && !document.fullscreenElement) setKiosk(false)
+ }
+ document.addEventListener('fullscreenchange', onFullscreenChange)
+ document.addEventListener('keydown', onKey)
+ return () => {
+ document.removeEventListener('fullscreenchange', onFullscreenChange)
+ document.removeEventListener('keydown', onKey)
+ }
+ }, [kiosk])
+
const active = layouts?.find((l) => l.id === activeId) ?? null
// A layout is global, but a restricted user may only see part of it. Drop the
@@ -71,6 +146,17 @@ export function Grid() {
const safePage = Math.min(page, pageCount - 1)
const pageStart = safePage * pageSize
+ // Auto-flip through the pages of a kiosk wall. Only in kiosk: someone working
+ // in the console wants the page they chose to stay put.
+ useEffect(() => {
+ if (!kiosk || !rotate || pageCount < 2 || editing) return
+ const timer = window.setInterval(
+ () => setPage((p) => (p + 1) % pageCount),
+ Math.max(5, rotateInterval) * 1000,
+ )
+ return () => window.clearInterval(timer)
+ }, [kiosk, rotate, rotateInterval, pageCount, editing])
+
async function reloadLayouts(): Promise {
const ls = await api.layouts()
ls.sort((a, b) => a.sortOrder - b.sortOrder)
@@ -163,9 +249,38 @@ export function Grid() {
))}
{!editing && can('Manage') && !restricted && (
- setDialog('create')}>{t('Grid.NewLayout')}
+ setDialog('create')}>
+ {t('Grid.NewLayout')}
+
)}
+ {active && !editing && (
+
+ setStills(!stills)}
+ >
+ {t('Grid.Stills')}
+
+ {stills && (
+ setStillInterval(Number(e.target.value))}
+ >
+ {STILL_INTERVALS.map((s) => (
+
+ {s} {t('Common.Seconds')}
+
+ ))}
+
+ )}
+
+ {t('Grid.Kiosk')}
+
+
+ )}
{active && !editing && can('Manage') && !restricted && (
{t('Grid.Edit')}
)}
@@ -224,18 +339,45 @@ export function Grid() {
>
) : (
- <>
+
+ {kiosk && (
+
+ {pageCount > 1 && (
+ <>
+
+ setRotate(e.target.checked)} />
+ {t('Grid.Rotate')}
+
+ {rotate && (
+ setRotateInterval(Number(e.target.value))}>
+ {ROTATE_INTERVALS.map((s) => (
+
+ {s} {t('Common.Seconds')}
+
+ ))}
+
+ )}
+ >
+ )}
+ {t('Grid.KioskExit')}
+
+ )}
{Array.from({ length: pageSize }, (_, i) => {
const id = tiles[pageStart + i]
const cam = id ? camById.get(id) : undefined
- return cam ? :
+ if (!cam) return
+ return stills ? (
+
+ ) : (
+
+ )
})}
{pageCount > 1 && (
setPage(safePage - 1)}>
- ‹
+
{Array.from({ length: pageCount }, (_, i) => (
setPage(safePage + 1)}
>
- ›
+
)}
- >
+
)}
{dialog === 'create' && (
diff --git a/src/OpenIPC.Viewer.Web.Client/src/pages/Groups.tsx b/src/OpenIPC.Viewer.Web.Client/src/pages/Groups.tsx
index 6842f2c..3280d94 100644
--- a/src/OpenIPC.Viewer.Web.Client/src/pages/Groups.tsx
+++ b/src/OpenIPC.Viewer.Web.Client/src/pages/Groups.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { api, ApiError, type GroupDto } from '../api'
import { useI18n } from '../i18n'
+import { Icon } from '../components/Icon'
import { useAuth } from '../auth'
import { ConfirmModal, TextPromptModal } from '../components/Modals'
@@ -66,8 +67,8 @@ export function Groups() {
style={{ flex: 1 }}
/>
{can('Manage') && (
-
- {t('Groups.Add')}
+
+ {t('Groups.Add')}
)}
diff --git a/src/OpenIPC.Viewer.Web.Client/src/pages/Recordings.tsx b/src/OpenIPC.Viewer.Web.Client/src/pages/Recordings.tsx
new file mode 100644
index 0000000..a07d41a
--- /dev/null
+++ b/src/OpenIPC.Viewer.Web.Client/src/pages/Recordings.tsx
@@ -0,0 +1,267 @@
+import { useEffect, useRef, useState } from 'react'
+import { api, type CalendarPointDto, type CameraDto, type RecordingDto } from '../api'
+import { useAuth } from '../auth'
+import { useI18n } from '../i18n'
+import { Icon } from '../components/Icon'
+import { ConfirmModal } from '../components/Modals'
+import { ArchiveCalendar, dayRange, monthRange } from '../components/ArchiveCalendar'
+import { ArchiveTabs } from '../components/ArchiveTabs'
+
+// The recorded archive: what the desktop head wrote, listed newest-first and
+// played in place. Playback is a plain against a ranged endpoint, so
+// seeking is the browser's own — no player to maintain.
+export function Recordings() {
+ const { t } = useI18n()
+ const { can } = useAuth()
+ const [items, setItems] = useState(null)
+ const [cameras, setCameras] = useState([])
+ const [cameraId, setCameraId] = useState('')
+ const [month, setMonth] = useState(() => new Date())
+ const [day, setDay] = useState(null)
+ const [points, setPoints] = useState([])
+ const [page, setPage] = useState(0)
+ const [total, setTotal] = useState(0)
+ const [playing, setPlaying] = useState(null)
+ const [playhead, setPlayhead] = useState(0)
+ const [clipStart, setClipStart] = useState(0)
+ const [clipEnd, setClipEnd] = useState(null)
+ const [precise, setPrecise] = useState(false)
+ const videoRef = useRef(null)
+ const [deleting, setDeleting] = useState(null)
+
+ // The list shows the selected day (or everything when none is picked); the
+ // calendar always shows the whole displayed month, so the dots stay put while
+ // you click around inside it.
+ const load = async (pageIndex = page) => {
+ const range = day ? dayRange(day) : {}
+ const [result, dots, cams] = await Promise.all([
+ api.recordings({
+ cameraId: cameraId || undefined,
+ ...range,
+ offset: pageIndex * PAGE_SIZE,
+ limit: PAGE_SIZE,
+ }),
+ api.recordingCalendar({ cameraId: cameraId || undefined, ...monthRange(month) }),
+ cameras.length ? Promise.resolve(cameras) : api.cameras(),
+ ])
+ setItems(result.items)
+ setTotal(result.total)
+ setPoints(dots)
+ setCameras(cams)
+ }
+
+ // Changing WHAT is being looked at starts over at the first page; paging
+ // through the same selection does not.
+ useEffect(() => {
+ setPage(0)
+ void load(0)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [cameraId, day, month])
+
+ useEffect(() => {
+ void load(page)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [page])
+
+ if (items === null) return {t('Common.Loading')}
+
+ return (
+
+
{t('Nav.Recordings')}
+
+
+
+ setCameraId(e.target.value)}>
+ {t('Recordings.AllCameras')}
+ {cameras.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+
+
{
+ setMonth(next)
+ setDay(null) // a day from the old month would filter to nothing
+ }}
+ onPickDay={setDay}
+ />
+
+ {playing && (
+
+
setPlayhead(e.currentTarget.currentTime)}
+ />
+
+
+ {can('Export') && (
+
+ )}
+
+ )}
+
+ {items.length === 0 ? (
+ {day ? t('Archive.EmptyDay') : t('Recordings.Empty')}
+ ) : (
+
+
+
+ {t('Recordings.Th.Camera')}
+ {t('Recordings.Th.Started')}
+ {t('Recordings.Th.Duration')}
+ {t('Recordings.Th.Size')}
+
+
+
+
+ {items.map((r) => (
+
+
+ {r.cameraName ?? '—'}
+ {r.hasMotion && {t('Recordings.Motion')} }
+ {!r.playable && (
+
+ {r.codec ?? 'codec'}
+
+ )}
+
+ {formatStart(r.startedAt)}
+ {formatDuration(r.durationSeconds)}
+ {formatSize(r.sizeBytes)}
+
+ {r.playable ? (
+ {
+ setPlaying(r)
+ setPlayhead(0)
+ setClipStart(0)
+ setClipEnd(null)
+ }}
+ >
+ {t('Recordings.Play')}
+
+ ) : (
+
+ {t('Recordings.Download')}
+
+ )}{' '}
+ {can('Manage') && (
+ setDeleting(r)}>
+ {t('Cameras.Delete')}
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {total > PAGE_SIZE && (
+
+ setPage(page - 1)}>
+
+
+
+ {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} / {total}
+
+ = total}
+ title={t('Grid.NextPage')}
+ onClick={() => setPage(page + 1)}
+ >
+
+
+
+ )}
+
+ {deleting && (
+ {
+ const target = deleting
+ setDeleting(null)
+ if (playing?.id === target.id) setPlaying(null)
+ await api.deleteRecording(target.id)
+ await load()
+ }}
+ onCancel={() => setDeleting(null)}
+ />
+ )}
+
+ )
+}
+
+const PAGE_SIZE = 50
+
+// mm:ss for the in/out marks: a clip offset is seconds into one file, not a date.
+function formatClock(seconds: number): string {
+ const m = Math.floor(seconds / 60)
+ const s = Math.floor(seconds % 60)
+ return `${m}:${String(s).padStart(2, '0')}`
+}
+
+function formatStart(iso: string): string {
+ return new Date(iso).toLocaleString()
+}
+
+// Null duration = the recording never got an end time (the app died mid-write).
+function formatDuration(seconds: number | null): string {
+ if (seconds === null) return '—'
+ const h = Math.floor(seconds / 3600)
+ const m = Math.floor((seconds % 3600) / 60)
+ const s = seconds % 60
+ const pad = (n: number) => String(n).padStart(2, '0')
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
+}
+
+function formatSize(bytes: number): string {
+ if (bytes <= 0) return '—'
+ const mb = bytes / (1024 * 1024)
+ return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb.toFixed(1)} MB`
+}
diff --git a/src/OpenIPC.Viewer.Web.Client/src/pages/Settings.tsx b/src/OpenIPC.Viewer.Web.Client/src/pages/Settings.tsx
new file mode 100644
index 0000000..7cd1e80
--- /dev/null
+++ b/src/OpenIPC.Viewer.Web.Client/src/pages/Settings.tsx
@@ -0,0 +1,49 @@
+import type { ReactNode } from 'react'
+import { NavLink, Navigate, Outlet } from 'react-router-dom'
+import { useAuth } from '../auth'
+import { useI18n } from '../i18n'
+
+// Everything you configure rather than watch, behind one nav entry.
+//
+// The sidebar had grown to seven items — too many for the mobile tab strip, and
+// it mixed daily viewing (grid, cameras, archive) with things touched once a
+// month. Groups, users and server status live here now; each tab keeps its own
+// URL, so links and the back button still work.
+export function Settings() {
+ const { t } = useI18n()
+ const { can } = useAuth()
+
+ return (
+
+
{t('Nav.Settings')}
+
+ (isActive ? 'active' : '')}>
+ {t('Nav.Groups')}
+
+ {can('Manage') && (
+ (isActive ? 'active' : '')}>
+ {t('Nav.Users')}
+
+ )}
+ (isActive ? 'active' : '')}>
+ {t('Nav.System')}
+
+
+
+
+ )
+}
+
+// Landing on /settings alone should show something rather than an empty frame.
+export function SettingsIndex() {
+ return
+}
+
+// A hidden tab is not a closed door: typing the URL used to load a page whose
+// first request 403s, leaving it stuck on "Loading…". Send those callers to a
+// tab they can actually use. (The API refuses them regardless — this is about
+// not showing a broken screen.)
+export function RequireManage({ children }: { children: ReactNode }) {
+ const { can } = useAuth()
+ return can('Manage') ? <>{children}> :
+}
diff --git a/src/OpenIPC.Viewer.Web.Client/src/pages/Snapshots.tsx b/src/OpenIPC.Viewer.Web.Client/src/pages/Snapshots.tsx
new file mode 100644
index 0000000..6b5b241
--- /dev/null
+++ b/src/OpenIPC.Viewer.Web.Client/src/pages/Snapshots.tsx
@@ -0,0 +1,149 @@
+import { useEffect, useState } from 'react'
+import { api, type CameraDto, type SnapshotDto } from '../api'
+import { useAuth } from '../auth'
+import { useI18n } from '../i18n'
+import { Icon } from '../components/Icon'
+import { ConfirmModal } from '../components/Modals'
+import { ArchiveTabs } from '../components/ArchiveTabs'
+
+const PAGE_SIZE = 60
+
+// The snapshot library: stills kept on the server, in the same folder and the
+// same table the desktop head writes — so a still taken from a phone shows up in
+// the desktop browser, and the desktop's show up here.
+//
+// Thumbnails are requested for the grid and the full image only when one is
+// opened: a page of sixty 1080p JPEGs is several tens of megabytes, and on a
+// phone over Wi-Fi that is the difference between a gallery and a stall.
+export function Snapshots() {
+ const { t } = useI18n()
+ const { can } = useAuth()
+ const [items, setItems] = useState(null)
+ const [cameras, setCameras] = useState([])
+ const [cameraId, setCameraId] = useState('')
+ const [page, setPage] = useState(0)
+ const [total, setTotal] = useState(0)
+ const [open, setOpen] = useState(null)
+ const [deleting, setDeleting] = useState(null)
+
+ const load = async (pageIndex = page) => {
+ const [result, cams] = await Promise.all([
+ api.snapshots({
+ cameraId: cameraId || undefined,
+ offset: pageIndex * PAGE_SIZE,
+ limit: PAGE_SIZE,
+ }),
+ api.cameras(),
+ ])
+ setItems(result.items)
+ setTotal(result.total)
+ setCameras(cams)
+ }
+
+ useEffect(() => {
+ setPage(0)
+ void load(0)
+ // Changing the camera filter restarts at the first page; paging keeps it.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [cameraId])
+
+ const remove = async () => {
+ if (!deleting) return
+ await api.deleteSnapshot(deleting.id)
+ setDeleting(null)
+ if (open?.id === deleting.id) setOpen(null)
+ await load()
+ }
+
+ const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE))
+ const flip = async (next: number) => {
+ setPage(next)
+ await load(next)
+ }
+
+ return (
+
+
{t('Nav.Recordings')}
+
+
+
+
+ {total > 0 && `${page * PAGE_SIZE + 1}–${Math.min((page + 1) * PAGE_SIZE, total)} / ${total}`}
+
+ setCameraId(e.target.value)}>
+ {t('Recordings.AllCameras')}
+ {cameras.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+
+ {items === null ? (
+
{t('Common.Loading')}
+ ) : items.length === 0 ? (
+
{t('Snapshots.Empty')}
+ ) : (
+
+ {items.map((s) => (
+
setOpen(s)}>
+
+
+ {new Date(s.takenAt).toLocaleString()}
+ {s.cameraName ?? '—'}
+
+
+ ))}
+
+ )}
+
+ {pageCount > 1 && (
+
+ void flip(page - 1)}>
+
+
+ {page + 1} / {pageCount}
+ = pageCount} onClick={() => void flip(page + 1)}>
+
+
+
+ )}
+
+ {open && (
+
setOpen(null)}>
+
e.stopPropagation()}>
+
{open.cameraName ?? '—'}
+
+
+ {new Date(open.takenAt).toLocaleString()}
+ {open.width > 0 && ` · ${open.width}×${open.height}`}
+
+
+
setOpen(null)}>{t('Common.Close')}
+ {can('Manage') && (
+
setDeleting(open)}>
+ {t('Cameras.Delete')}
+
+ )}
+
+ {t('Snapshot.Download')}
+
+
+
+
+ )}
+
+ {deleting && (
+
setDeleting(null)}
+ />
+ )}
+
+ )
+}
diff --git a/src/OpenIPC.Viewer.Web.Client/src/pages/System.tsx b/src/OpenIPC.Viewer.Web.Client/src/pages/System.tsx
index 6e7c9d0..2e1058b 100644
--- a/src/OpenIPC.Viewer.Web.Client/src/pages/System.tsx
+++ b/src/OpenIPC.Viewer.Web.Client/src/pages/System.tsx
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../auth'
import { useI18n } from '../i18n'
+import { Icon } from '../components/Icon'
import { ConfirmModal } from '../components/Modals'
type Status = { version: string; cameras: number; groups: number; sessions: number; streams: number }
@@ -113,7 +114,7 @@ export function System() {
color: 'var(--text-1)',
}}
>
- {t('System.Export')}
+ {t('System.Export')}
{t('Users.Title')}
- setEditing('new')}>
- {t('Users.Add')}
+ setEditing('new')}>
+ {t('Users.Add')}
diff --git a/src/OpenIPC.Viewer.Web.Client/src/strings.ts b/src/OpenIPC.Viewer.Web.Client/src/strings.ts
index bd6f8e5..8f2e10b 100644
--- a/src/OpenIPC.Viewer.Web.Client/src/strings.ts
+++ b/src/OpenIPC.Viewer.Web.Client/src/strings.ts
@@ -14,7 +14,9 @@ export const EN: Dict = {
'Nav.Grid': 'Grid',
'Nav.Groups': 'Groups',
'Nav.Discovery': 'Discovery',
+ 'Nav.Recordings': 'Archive',
'Nav.Users': 'Users',
+ 'Nav.Settings': 'Settings',
'Nav.System': 'System',
'Nav.SignOut': 'Sign out',
@@ -25,7 +27,7 @@ export const EN: Dict = {
'System.Sessions': 'Active sessions',
'System.Streams': 'Live streams',
'System.Backup': 'Backup & restore',
- 'System.Export': '⭳ Export config',
+ 'System.Export': 'Export config',
'System.ImportBtn': 'Import',
'System.BackupNote':
'Export writes a JSON backup of cameras, groups and layouts. Camera passwords are never included.',
@@ -36,17 +38,18 @@ export const EN: Dict = {
'System.RevokeConfirm': 'Sign out every session, including this one?',
'Cameras.Title': 'Cameras',
- 'Cameras.Add': '+ Add camera',
+ 'Cameras.Add': 'Add camera',
'Cameras.Empty': 'No cameras yet.',
'Common.Loading': 'Loading…',
'Common.Save': 'Save',
'Common.Cancel': 'Cancel',
'Common.Close': 'Close',
+ 'Common.Seconds': 's',
'Cameras.Th.Name': 'Name',
'Cameras.Th.Host': 'Host',
'Cameras.Th.Group': 'Group',
'Cameras.Th.Grid': 'Grid',
- 'Cameras.Live': '▶ Live',
+ 'Cameras.Live': 'Live',
'Cameras.Edit': 'Edit',
'Cameras.Delete': 'Delete',
'Cameras.DeleteConfirm': 'Delete this camera?',
@@ -76,7 +79,7 @@ export const EN: Dict = {
'Grid.Title': 'Grid',
'Grid.Empty': 'No cameras to show. Add cameras or include them in the grid.',
- 'Grid.NewLayout': '+ Layout',
+ 'Grid.NewLayout': 'Layout',
'Grid.NewLayoutTitle': 'New layout',
'Grid.NewLayoutName': 'New layout name',
'Grid.Edit': 'Edit layout',
@@ -90,9 +93,17 @@ export const EN: Dict = {
'Grid.PrevPage': 'Previous page',
'Grid.NextPage': 'Next page',
'Grid.EditingPage': 'Editing page',
+ 'Grid.Stills': 'Stills',
+ 'Grid.StillsHint': 'Periodic snapshots instead of live streams — much lighter for a big wall',
+ 'Grid.StillsInterval': 'Refresh interval',
+ 'Grid.StillFailed': 'no frame',
+ 'Grid.Kiosk': 'Kiosk',
+ 'Grid.KioskHint': 'Full-screen wall without the interface (Esc to leave)',
+ 'Grid.KioskExit': 'Leave kiosk',
+ 'Grid.Rotate': 'Cycle pages',
'Groups.Title': 'Groups',
- 'Groups.Add': '+ Add group',
+ 'Groups.Add': 'Add group',
'Groups.Name': 'Name',
'Groups.NewName': 'Group name',
'Groups.Empty': 'No groups yet.',
@@ -101,7 +112,7 @@ export const EN: Dict = {
'Groups.InUse': 'Group is still used by cameras.',
'System.Account': 'Account',
- 'Live.Back': '← Cameras',
+ 'Live.Back': 'Cameras',
'Camera.Details': 'Details',
'Camera.Credentials': 'Credentials set',
'Camera.Capabilities': 'Capabilities',
@@ -156,7 +167,7 @@ export const EN: Dict = {
'Discovery.AddFailed': 'Could not add the camera.',
'Users.Title': 'Users',
- 'Users.Add': '+ Add user',
+ 'Users.Add': 'Add user',
'Users.AddTitle': 'Add user',
'Users.EditTitle': 'Edit user',
'Users.Empty': 'No users yet — only the built-in administrator can sign in.',
@@ -175,6 +186,89 @@ export const EN: Dict = {
'Users.Perm.Ptz': 'PTZ',
'Users.Perm.Export': 'export',
'Users.Perm.Manage': 'manage',
+
+ 'Tile.Listen': 'Listen',
+ 'Tile.Mute': 'Mute',
+ 'Majestic.Title': 'Camera settings',
+ 'Majestic.Reload': 'Reload',
+ 'Majestic.NightMode': 'Day/night',
+ 'Majestic.Night.day': 'Day',
+ 'Majestic.Night.night': 'Night',
+ 'Majestic.Night.auto': 'Auto',
+ 'Majestic.Isp': 'Image (live)',
+ 'Majestic.IspHint': 'Applied on the fly — the stream does not restart',
+ 'Majestic.ApplyIsp': 'Apply',
+ 'Majestic.Config': 'Configuration',
+ 'Majestic.Filter': 'Filter…',
+ 'Majestic.Changed': 'Changed',
+ 'Majestic.Revert': 'Revert',
+ 'Majestic.NoMatches': 'Nothing matches the filter.',
+ 'Majestic.NoChanges': 'Nothing changed.',
+ 'Majestic.Applied': 'Applied.',
+ 'Majestic.AppliedRestart': 'Applied — the camera restarts the stream, the tile will blink.',
+ 'Majestic.ApplyFailed': 'The camera rejected the change.',
+ 'Majestic.RestartHint': 'Changing this restarts the video stream',
+ 'Majestic.Metrics': 'Metrics',
+ 'Majestic.NoMetrics': 'This firmware has no /metrics endpoint.',
+ 'Majestic.Raw': 'Raw config.json',
+ 'Majestic.RawHint': 'The whole file, as the camera holds it. No validation beyond "is it JSON" — same as editing it over SSH.',
+ 'Majestic.BadJson': 'Not valid JSON',
+ 'Majestic.NotMajestic': 'This camera does not run Majestic.',
+ 'Majestic.Unreachable': 'The camera did not answer.',
+ 'Recordings.Tab': 'Recordings',
+ 'Snapshots.Title': 'Snapshots',
+ 'Snapshots.Empty': 'No saved snapshots yet.',
+ 'Snapshots.Delete': 'Delete snapshot',
+ 'Snapshots.DeleteConfirm': 'Delete this snapshot from the library?',
+ 'Snapshot.Save': 'Save to library',
+ 'Snapshot.Saved': 'Saved to the library.',
+ 'Snapshot.SaveFailed': 'Could not save the snapshot.',
+ 'Talk.Title': 'Talk',
+ 'Talk.Hold': 'Hold to talk',
+ 'Talk.Talking': 'On air…',
+ 'Talk.Unsupported': 'This camera has no speaker.',
+ 'Talk.Busy': 'Someone else is talking to this camera.',
+ 'Talk.Failed': 'The camera did not accept the audio.',
+ 'Talk.NoMic': 'No microphone, or access was denied.',
+ 'Talk.NeedsHttps': 'Talking needs a microphone, which browsers only allow over HTTPS (or on localhost).',
+ 'Users.Perm.Talk': 'talk',
+ 'Tile.Fullscreen': 'Fullscreen',
+ 'Tile.ZoomIn': 'Zoom in (mouse wheel)',
+ 'Tile.ZoomOut': 'Zoom out',
+ 'Tile.ZoomReset': 'Reset zoom',
+ 'Snapshot.Take': 'Snapshot',
+ 'Snapshot.Taking': 'Asking the camera for a still…',
+ 'Snapshot.Download': 'Download',
+ 'Snapshot.Failed': 'The camera did not return a still.',
+
+ 'Recording.Start': 'Record',
+ 'Recording.Stop': 'Stop recording',
+ 'Recording.Active': 'recording',
+ 'Recording.Nothing': 'The camera sent nothing — no clip was saved.',
+ 'Recording.Failed': 'Could not start recording.',
+
+ 'Export.MarkIn': 'Mark in',
+ 'Export.MarkOut': 'Mark out',
+ 'Export.Save': 'Export clip',
+ 'Export.Precise': 'exact frame (re-encodes)',
+
+ 'Archive.PrevMonth': 'Previous month',
+ 'Archive.NextMonth': 'Next month',
+ 'Archive.ClearDay': 'Whole archive',
+ 'Archive.EmptyDay': 'Nothing recorded on this day.',
+
+ 'Recordings.Title': 'Archive',
+ 'Recordings.AllCameras': 'All cameras',
+ 'Recordings.Empty': 'Nothing recorded yet. Recording is started from the desktop app.',
+ 'Recordings.Th.Camera': 'Camera',
+ 'Recordings.Th.Started': 'Started',
+ 'Recordings.Th.Duration': 'Length',
+ 'Recordings.Th.Size': 'Size',
+ 'Recordings.Play': 'Play',
+ 'Recordings.Download': 'Download',
+ 'Recordings.Motion': 'motion',
+ 'Recordings.NotPlayableHint': 'This codec does not play in most browsers — download the file instead.',
+ 'Recordings.DeleteConfirm': 'Delete this recording? The file is removed from disk.',
}
export const RU: Dict = {
@@ -188,7 +282,9 @@ export const RU: Dict = {
'Nav.Grid': 'Сетка',
'Nav.Groups': 'Группы',
'Nav.Discovery': 'Поиск',
+ 'Nav.Recordings': 'Архив',
'Nav.Users': 'Пользователи',
+ 'Nav.Settings': 'Настройки',
'Nav.System': 'Система',
'Nav.SignOut': 'Выход',
@@ -199,7 +295,7 @@ export const RU: Dict = {
'System.Sessions': 'Активные сессии',
'System.Streams': 'Живые потоки',
'System.Backup': 'Бэкап и восстановление',
- 'System.Export': '⭳ Экспорт конфига',
+ 'System.Export': 'Экспорт конфига',
'System.ImportBtn': 'Импорт',
'System.BackupNote':
'Экспорт пишет JSON-бэкап камер, групп и раскладок. Пароли камер не включаются.',
@@ -210,9 +306,10 @@ export const RU: Dict = {
'System.RevokeConfirm': 'Завершить все сессии, включая текущую?',
'Cameras.Title': 'Камеры',
- 'Cameras.Add': '+ Добавить камеру',
+ 'Cameras.Add': 'Добавить камеру',
'Cameras.Empty': 'Камер пока нет.',
'Common.Loading': 'Загрузка…',
+ 'Common.Seconds': 'с',
'Common.Save': 'Сохранить',
'Common.Cancel': 'Отмена',
'Common.Close': 'Закрыть',
@@ -220,7 +317,7 @@ export const RU: Dict = {
'Cameras.Th.Host': 'Хост',
'Cameras.Th.Group': 'Группа',
'Cameras.Th.Grid': 'Сетка',
- 'Cameras.Live': '▶ Эфир',
+ 'Cameras.Live': 'Эфир',
'Cameras.Edit': 'Изменить',
'Cameras.Delete': 'Удалить',
'Cameras.DeleteConfirm': 'Удалить камеру?',
@@ -249,7 +346,7 @@ export const RU: Dict = {
'Grid.Title': 'Сетка',
'Grid.Empty': 'Нет камер для показа. Добавьте камеры или включите их в сетку.',
- 'Grid.NewLayout': '+ Раскладка',
+ 'Grid.NewLayout': 'Раскладка',
'Grid.NewLayoutTitle': 'Новая раскладка',
'Grid.NewLayoutName': 'Название новой раскладки',
'Grid.Edit': 'Изменить раскладку',
@@ -263,9 +360,17 @@ export const RU: Dict = {
'Grid.PrevPage': 'Предыдущая страница',
'Grid.NextPage': 'Следующая страница',
'Grid.EditingPage': 'Редактируется страница',
+ 'Grid.Stills': 'Стоп-кадры',
+ 'Grid.StillsHint': 'Периодические снимки вместо живых потоков — намного легче для большой стены',
+ 'Grid.StillsInterval': 'Интервал обновления',
+ 'Grid.StillFailed': 'нет кадра',
+ 'Grid.Kiosk': 'Киоск',
+ 'Grid.KioskHint': 'Полноэкранная стена без интерфейса (выход — Esc)',
+ 'Grid.KioskExit': 'Выйти из киоска',
+ 'Grid.Rotate': 'Листать страницы',
'Groups.Title': 'Группы',
- 'Groups.Add': '+ Добавить группу',
+ 'Groups.Add': 'Добавить группу',
'Groups.Name': 'Имя',
'Groups.NewName': 'Название группы',
'Groups.Empty': 'Групп пока нет.',
@@ -274,7 +379,7 @@ export const RU: Dict = {
'Groups.InUse': 'Группа ещё используется камерами.',
'System.Account': 'Учётная запись',
- 'Live.Back': '← Камеры',
+ 'Live.Back': 'Камеры',
'Camera.Details': 'Детали',
'Camera.Credentials': 'Учётные данные заданы',
'Camera.Capabilities': 'Возможности',
@@ -329,7 +434,7 @@ export const RU: Dict = {
'Discovery.AddFailed': 'Не удалось добавить камеру.',
'Users.Title': 'Пользователи',
- 'Users.Add': '+ Добавить пользователя',
+ 'Users.Add': 'Добавить пользователя',
'Users.AddTitle': 'Добавить пользователя',
'Users.EditTitle': 'Изменить пользователя',
'Users.Empty': 'Пользователей пока нет — войти может только встроенный администратор.',
@@ -348,4 +453,87 @@ export const RU: Dict = {
'Users.Perm.Ptz': 'PTZ',
'Users.Perm.Export': 'экспорт',
'Users.Perm.Manage': 'управление',
+
+ 'Tile.Listen': 'Слушать',
+ 'Tile.Mute': 'Выключить звук',
+ 'Majestic.Title': 'Настройки камеры',
+ 'Majestic.Reload': 'Обновить',
+ 'Majestic.NightMode': 'День/ночь',
+ 'Majestic.Night.day': 'День',
+ 'Majestic.Night.night': 'Ночь',
+ 'Majestic.Night.auto': 'Авто',
+ 'Majestic.Isp': 'Изображение (на лету)',
+ 'Majestic.IspHint': 'Применяется на лету — поток не перезапускается',
+ 'Majestic.ApplyIsp': 'Применить',
+ 'Majestic.Config': 'Конфигурация',
+ 'Majestic.Filter': 'Фильтр…',
+ 'Majestic.Changed': 'Изменено',
+ 'Majestic.Revert': 'Вернуть',
+ 'Majestic.NoMatches': 'Ничего не найдено.',
+ 'Majestic.NoChanges': 'Изменений нет.',
+ 'Majestic.Applied': 'Применено.',
+ 'Majestic.AppliedRestart': 'Применено — камера перезапускает поток, тайл моргнёт.',
+ 'Majestic.ApplyFailed': 'Камера не приняла изменение.',
+ 'Majestic.RestartHint': 'Изменение перезапускает видеопоток',
+ 'Majestic.Metrics': 'Метрики',
+ 'Majestic.NoMetrics': 'В этой прошивке нет эндпоинта /metrics.',
+ 'Majestic.Raw': 'Сырой config.json',
+ 'Majestic.RawHint': 'Файл целиком, как его хранит камера. Проверяется только то, что это JSON — как при правке по SSH.',
+ 'Majestic.BadJson': 'Некорректный JSON',
+ 'Majestic.NotMajestic': 'На этой камере нет Majestic.',
+ 'Majestic.Unreachable': 'Камера не ответила.',
+ 'Recordings.Tab': 'Записи',
+ 'Snapshots.Title': 'Снимки',
+ 'Snapshots.Empty': 'Сохранённых снимков пока нет.',
+ 'Snapshots.Delete': 'Удалить снимок',
+ 'Snapshots.DeleteConfirm': 'Удалить этот снимок из библиотеки?',
+ 'Snapshot.Save': 'Сохранить в архив',
+ 'Snapshot.Saved': 'Сохранено в архив.',
+ 'Snapshot.SaveFailed': 'Не удалось сохранить снимок.',
+ 'Talk.Title': 'Говорить',
+ 'Talk.Hold': 'Удерживайте, чтобы говорить',
+ 'Talk.Talking': 'В эфире…',
+ 'Talk.Unsupported': 'У этой камеры нет динамика.',
+ 'Talk.Busy': 'С этой камерой уже кто-то говорит.',
+ 'Talk.Failed': 'Камера не приняла звук.',
+ 'Talk.NoMic': 'Микрофона нет, или доступ к нему запрещён.',
+ 'Talk.NeedsHttps': 'Для разговора нужен микрофон, а браузер даёт его только по HTTPS (или на localhost).',
+ 'Users.Perm.Talk': 'говорить',
+ 'Tile.Fullscreen': 'Во весь экран',
+ 'Tile.ZoomIn': 'Приблизить (колесо мыши)',
+ 'Tile.ZoomOut': 'Отдалить',
+ 'Tile.ZoomReset': 'Сбросить зум',
+ 'Snapshot.Take': 'Снимок',
+ 'Snapshot.Taking': 'Запрашиваем кадр у камеры…',
+ 'Snapshot.Download': 'Скачать',
+ 'Snapshot.Failed': 'Камера не отдала кадр.',
+
+ 'Recording.Start': 'Записать',
+ 'Recording.Stop': 'Остановить запись',
+ 'Recording.Active': 'идёт запись',
+ 'Recording.Nothing': 'Камера ничего не передала — запись не сохранена.',
+ 'Recording.Failed': 'Не удалось начать запись.',
+
+ 'Export.MarkIn': 'Начало',
+ 'Export.MarkOut': 'Конец',
+ 'Export.Save': 'Сохранить фрагмент',
+ 'Export.Precise': 'точный кадр (перекодирование)',
+
+ 'Archive.PrevMonth': 'Предыдущий месяц',
+ 'Archive.NextMonth': 'Следующий месяц',
+ 'Archive.ClearDay': 'Весь архив',
+ 'Archive.EmptyDay': 'В этот день ничего не записано.',
+
+ 'Recordings.Title': 'Архив',
+ 'Recordings.AllCameras': 'Все камеры',
+ 'Recordings.Empty': 'Записей пока нет. Запись запускается из десктоп-приложения.',
+ 'Recordings.Th.Camera': 'Камера',
+ 'Recordings.Th.Started': 'Начало',
+ 'Recordings.Th.Duration': 'Длительность',
+ 'Recordings.Th.Size': 'Размер',
+ 'Recordings.Play': 'Смотреть',
+ 'Recordings.Download': 'Скачать',
+ 'Recordings.Motion': 'движение',
+ 'Recordings.NotPlayableHint': 'Этот кодек не играет в большинстве браузеров — скачайте файл.',
+ 'Recordings.DeleteConfirm': 'Удалить запись? Файл будет удалён с диска.',
}
diff --git a/src/OpenIPC.Viewer.Web.Client/src/styles.css b/src/OpenIPC.Viewer.Web.Client/src/styles.css
index b42fbd5..d105624 100644
--- a/src/OpenIPC.Viewer.Web.Client/src/styles.css
+++ b/src/OpenIPC.Viewer.Web.Client/src/styles.css
@@ -140,6 +140,48 @@ label input, label select { color:var(--text-1); }
.cell:hover .expand { opacity:1; }
.cell .expand:hover { background:var(--accent); }
+/* Digital zoom: the slot is scaled and dragged, the cell clips it (it already
+ has overflow:hidden). No transition — panning must track the pointer. */
+.cell .video-slot { transform-origin:center center; }
+.cell.zoomed { cursor:grab; }
+.cell.zoomed:active { cursor:grabbing; }
+.cell .zoom-pad { position:absolute; right:8px; top:8px; display:flex; gap:4px;
+ opacity:0; transition:opacity .15s; }
+.cell:hover .zoom-pad, .cell.zoomed .zoom-pad { opacity:1; }
+.cell .zoom-pad button { width:28px; height:28px; padding:0; display:flex;
+ align-items:center; justify-content:center; background:rgba(0,0,0,.55);
+ border:none; border-radius:var(--r-sm); color:#fff; line-height:1; }
+.cell .zoom-pad button:hover:not(:disabled) { background:var(--accent); }
+.cell .zoom-pad button:disabled { opacity:.35; }
+.cell .zoom-level { font-size:11px; font-variant-numeric:tabular-nums; }
+.cell .zoom-pad button:has(.zoom-level) { width:auto; padding:0 8px; }
+
+/* Stills mode: a JPEG in place of the
, framed the same way. */
+.cell img.still { position:absolute; inset:0; width:100%; height:100%;
+ object-fit:contain; background:#000; display:block; }
+
+/* Kiosk: the wall fills the screen, everything else gets out of the way. The
+ grid gets explicit rows so the cells share the height instead of keeping
+ their 16:9 box and leaving a band of background under the wall. */
+/* Fixed rather than sized to the viewport: the mode has to cover the page even
+ when the fullscreen request was refused (embedded/iframed pages), and being
+ fixed makes the fullscreen case identical. */
+.stage.kiosk { position:fixed; inset:0; z-index:20; background:var(--bg0);
+ display:flex; flex-direction:column; padding:8px; box-sizing:border-box; }
+.stage.kiosk .videos { margin-top:0; flex:1; min-height:0; }
+.stage.kiosk .cell { aspect-ratio:auto; height:100%; }
+.stage.kiosk .videos.n4 { grid-template-rows:repeat(2,1fr); }
+.stage.kiosk .videos.n9 { grid-template-rows:repeat(3,1fr); }
+.stage.kiosk .videos.n16 { grid-template-rows:repeat(4,1fr); }
+.stage.kiosk .videos.n25 { grid-template-rows:repeat(5,1fr); }
+.stage.kiosk .pager { justify-content:center; margin-top:8px; }
+/* The kiosk's own controls: dim until pointed at, so a wall left running shows
+ only cameras. */
+.kiosk-bar { display:flex; align-items:center; gap:10px; justify-content:flex-end;
+ padding:0 0 8px; opacity:.15; transition:opacity .15s; }
+.kiosk-bar:hover { opacity:1; }
+.kiosk-bar label { display:flex; align-items:center; gap:6px; font-size:12px; }
+
/* Login: centered card, no sidebar. */
.login { max-width:340px; margin:12vh auto 0; padding:24px; }
.login .card { background:var(--bg2); border:1px solid var(--border-weak);
@@ -190,7 +232,8 @@ label input, label select { color:var(--text-1); }
of postage stamps is useless. Tiles keep their 16:9 box. */
.videos.n9, .videos.n16, .videos.n25 { grid-template-columns:repeat(2,1fr); }
.videos { gap:6px; }
- .cell .expand { opacity:1; } /* no hover on touch — always show */
+ .cell .expand, .cell .snap, .cell .listen, .cell .zoom-pad { opacity:1; } /* no hover on touch — always show */
+ .kiosk-bar { opacity:1; }
/* Wide list tables (the camera list) scroll sideways rather than squashing;
narrow key/value tables (.details) stack instead — see below. */
@@ -241,3 +284,103 @@ label input, label select { color:var(--text-1); }
.camera-picker { max-height:200px; overflow-y:auto; margin-top:8px; padding:8px;
border:1px solid var(--border-weak); border-radius:var(--r-sm); background:var(--bg2);
display:flex; flex-direction:column; gap:6px; }
+
+/* Snapshot: a second tile affordance next to fullscreen, and the viewer modal. */
+.cell .snap { position:absolute; right:42px; bottom:8px; opacity:0; transition:opacity .15s;
+ width:28px; height:28px; padding:0; display:flex; align-items:center; justify-content:center;
+ background:rgba(0,0,0,.55); border:none; border-radius:var(--r-sm); color:#fff; font-size:14px; line-height:1; }
+.cell:hover .snap { opacity:1; }
+.cell .snap:hover { background:var(--accent); }
+.snapshot-modal { max-width:900px; }
+.snapshot-modal img { width:100%; height:auto; border-radius:var(--r-sm); background:#000; display:block; }
+.button-link { display:inline-block; padding:8px 10px; border:1px solid var(--accent);
+ border-radius:var(--r-sm); background:var(--accent); color:#fff; }
+.button-link:hover { background:var(--accent-hover); border-color:var(--accent-hover); text-decoration:none; }
+
+/* Archive player: the selected recording, above the list. */
+.player { margin-bottom:18px; }
+.player video { width:100%; max-height:60vh; background:#000; border-radius:var(--r-md); display:block; }
+
+/* Listen toggle — same affordance row as snapshot/fullscreen, shown only when
+ the stream actually carries an audio track. */
+.cell .listen { position:absolute; right:76px; bottom:8px; opacity:0; transition:opacity .15s;
+ width:28px; height:28px; padding:0; display:flex; align-items:center; justify-content:center;
+ background:rgba(0,0,0,.55); border:none; border-radius:var(--r-sm); color:#fff; font-size:13px; line-height:1; }
+.cell:hover .listen { opacity:1; }
+.cell .listen:hover { background:var(--accent); }
+
+/* Icons sit on the text baseline and take the surrounding colour; a button or
+ link that pairs one with a label gets the gap from .row. */
+.icon { flex:none; }
+button.row, a.row { display:inline-flex; align-items:center; gap:6px; }
+
+/* Archive calendar: a month of days, shaded by how much was recorded. */
+.calendar { margin-bottom:18px; max-width:340px; }
+.calendar-head { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
+.calendar-head button { padding:5px 8px; }
+.calendar-month { flex:1; font-weight:500; text-transform:capitalize; }
+.calendar-grid { display:grid; grid-template-columns:repeat(7,1fr); gap:3px; }
+.calendar-weekday { font-size:11px; color:var(--text-3); text-align:center; padding-bottom:2px; }
+.calendar-day { position:relative; aspect-ratio:1; padding:0; font-size:12px;
+ background:var(--bg2); border:1px solid transparent; border-radius:var(--r-sm); color:var(--text-3); }
+.calendar-day:disabled { opacity:.35; cursor:default; }
+.calendar-day.has-activity { background:var(--accent-tint); color:var(--text-1); cursor:pointer; }
+.calendar-day.has-activity:hover { border-color:var(--accent); }
+.calendar-day.today { border-color:var(--border-med); }
+.calendar-day.selected { background:var(--accent); border-color:var(--accent); color:#fff; opacity:1 !important; }
+.calendar-dot { position:absolute; left:50%; bottom:4px; transform:translateX(-50%);
+ width:4px; height:4px; border-radius:50%; background:currentColor; opacity:.7; }
+
+/* Clip export controls under the archive player. */
+.export { flex-wrap:wrap; gap:10px; margin-top:10px; padding-top:10px; border-top:1px solid var(--border-weak); }
+.button-link.disabled { opacity:.5; pointer-events:none; }
+
+/* Settings sub-tabs sit under the page title, not in the sidebar. */
+.settings-tabs { margin-bottom:18px; }
+.button-link.ghost { background:var(--bg2); border-color:var(--border-med); color:var(--text-1); }
+.button-link.ghost:hover { background:var(--bg3); border-color:var(--text-3); }
+
+/* Camera settings (Majestic). Dense two-column rows: the panel routinely lists
+ a hundred knobs, so a form-grid layout would be a mile of scrolling. */
+.majestic { border:1px solid var(--border-weak); border-radius:var(--r-md);
+ padding:16px; background:var(--bg1); }
+.majestic h3 { margin:0; font-size:13px; }
+.majestic h4 { margin:12px 0 6px; font-size:12px; color:var(--text-3);
+ text-transform:uppercase; letter-spacing:.04em; }
+.majestic .toolbar { margin-bottom:8px; }
+.majestic-block { border-top:1px solid var(--border-weak); padding-top:12px; margin-top:12px; }
+.majestic .filter { width:180px; }
+.fields { display:grid; grid-template-columns:repeat(auto-fill, minmax(260px, 1fr)); gap:6px 16px; }
+/* flex-direction is explicit: the global `label` rule stacks its children, and a
+ knob row wants label and control side by side. */
+.field { display:flex; flex-direction:row; align-items:center; gap:8px; font-size:12px; min-height:28px; }
+.field .k { flex:1; color:var(--text-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.field input[type=text], .field input[type=number], .field select { width:120px; }
+.field input[type=checkbox] { margin:0; }
+/* An edited-but-unsaved row, so "what am I about to send" is visible at a glance. */
+.field.modified .k { color:var(--accent); font-weight:600; }
+.field .restart { margin-left:4px; color:var(--warning); cursor:help; }
+.metrics td { font-size:12px; font-variant-numeric:tabular-nums; }
+.raw-json { width:100%; font-family:var(--font-mono, monospace); font-size:12px;
+ background:var(--bg0); color:var(--text-1); border:1px solid var(--border-weak);
+ border-radius:var(--r-sm); padding:8px; resize:vertical; }
+@media (max-width:700px) {
+ .fields { grid-template-columns:1fr; }
+ .majestic .filter { width:120px; }
+}
+
+/* Snapshot library: a thumbnail grid that keeps its rows even, and a caption
+ that never pushes the image around. */
+.gallery { display:grid; grid-template-columns:repeat(auto-fill, minmax(180px, 1fr)); gap:10px; }
+.shot { padding:0; border:1px solid var(--border-weak); border-radius:var(--r-md);
+ background:var(--bg2); overflow:hidden; display:flex; flex-direction:column; text-align:left; }
+.shot:hover { border-color:var(--accent); }
+.shot img { width:100%; aspect-ratio:16/9; object-fit:cover; background:#000; display:block; }
+.shot .cap { display:flex; flex-direction:column; gap:2px; padding:6px 8px; font-size:11px; }
+.shot .when { color:var(--text-1); font-variant-numeric:tabular-nums; }
+.shot .who { color:var(--text-3); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+
+/* Push-to-talk: unmistakable while live, since it is an open microphone into
+ someone else's room. */
+.talk.on { background:var(--danger); border-color:var(--danger); color:#fff; }
+.talk.on:hover { border-color:var(--danger); }
diff --git a/src/OpenIPC.Viewer.Web/Api/LiveApi.cs b/src/OpenIPC.Viewer.Web/Api/LiveApi.cs
index ff70bdc..58463cd 100644
--- a/src/OpenIPC.Viewer.Web/Api/LiveApi.cs
+++ b/src/OpenIPC.Viewer.Web/Api/LiveApi.cs
@@ -74,19 +74,31 @@ public static void MapLiveEndpoints(this WebApplication app)
private static async Task PumpAsync(WebSocket socket, LiveSubscription subscription, CancellationToken ct)
{
+ // A viewer leaving closes the socket, which arrives as a close *frame* —
+ // and a frame is only seen by someone receiving. Without this loop the
+ // send side keeps writing into a socket the browser has already given
+ // up on, so the shared ffmpeg process outlives its last viewer (a grid
+ // switched to stills mode, or a tab navigated away, would leave one
+ // running per camera). We never expect data from the client, so the
+ // first receive to complete means "gone" either way.
+ using var leaving = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ var watchClose = WatchForCloseAsync(socket, leaving);
+
try
{
- await foreach (var chunk in subscription.Reader.ReadAllAsync(ct).ConfigureAwait(false))
+ await foreach (var chunk in subscription.Reader.ReadAllAsync(leaving.Token).ConfigureAwait(false))
{
if (socket.State != WebSocketState.Open)
break;
- await socket.SendAsync(chunk, WebSocketMessageType.Binary, endOfMessage: true, ct).ConfigureAwait(false);
+ await socket.SendAsync(chunk, WebSocketMessageType.Binary, endOfMessage: true, leaving.Token).ConfigureAwait(false);
}
}
catch (OperationCanceledException) { }
catch (Exception) { /* socket closed by the viewer — normal */ }
finally
{
+ leaving.Cancel();
+ try { await watchClose.ConfigureAwait(false); } catch { /* best effort */ }
if (socket.State == WebSocketState.Open)
{
try { await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "stream ended", CancellationToken.None); }
@@ -95,6 +107,28 @@ private static async Task PumpAsync(WebSocket socket, LiveSubscription subscript
}
}
+ // Reads until the client closes (or errors), then trips the token so the
+ // send loop stops and the subscription is disposed.
+ private static async Task WatchForCloseAsync(WebSocket socket, CancellationTokenSource leaving)
+ {
+ var scratch = new byte[256];
+ try
+ {
+ while (socket.State == WebSocketState.Open)
+ {
+ var result = await socket.ReceiveAsync(scratch, leaving.Token).ConfigureAwait(false);
+ if (result.MessageType == WebSocketMessageType.Close)
+ break;
+ }
+ }
+ catch (OperationCanceledException) { /* we're the ones shutting down */ }
+ catch (Exception) { /* connection gone — same outcome */ }
+ finally
+ {
+ try { leaving.Cancel(); } catch (ObjectDisposedException) { /* already torn down */ }
+ }
+ }
+
// Injects credentials into the RTSP URL for ffmpeg (they live in the secrets
// store, not the entity's URI). Never surfaced back to any client.
private static string BuildRtspUrl(Uri baseUri, CameraCredentials? credentials)
diff --git a/src/OpenIPC.Viewer.Web/Api/LiveFfmpeg.cs b/src/OpenIPC.Viewer.Web/Api/LiveFfmpeg.cs
index e5b2109..cdc47bf 100644
--- a/src/OpenIPC.Viewer.Web/Api/LiveFfmpeg.cs
+++ b/src/OpenIPC.Viewer.Web/Api/LiveFfmpeg.cs
@@ -21,7 +21,18 @@ public static Process Start(string rtspUrl, bool transcode)
CreateNoWindow = true,
};
- var args = new List { "-rtsp_transport", "tcp", "-fflags", "nobuffer", "-i", rtspUrl, "-an" };
+ var args = new List { "-rtsp_transport", "tcp", "-fflags", "nobuffer", "-i", rtspUrl };
+
+ // Audio rides along in the same stream instead of getting its own ffmpeg
+ // session: AAC at 64 kbps is around a percent of the video bitrate, so
+ // carrying it for every viewer is cheaper than a second process for the
+ // one who unmutes — and nobody's picture restarts when they do. Tiles
+ // start muted; the browser needs that for autoplay anyway.
+ //
+ // Always re-encoded rather than copied: cameras emit G.711/PCM/Opus more
+ // often than AAC, and only AAC plays in MSE. With no audio track on the
+ // input ffmpeg simply ignores these.
+ args.AddRange(new[] { "-c:a", "aac", "-b:a", "64k", "-ac", "1" });
if (transcode)
{
// Software H.264 (libopenh264 — the LGPL build has no libx264). Output
@@ -63,6 +74,9 @@ public static void Kill(Process? proc)
}
// Same bundled-then-PATH resolution as FfmpegSubprocessRecorder.
+ // Also used by the snapshot endpoint, which spawns its own one-shot ffmpeg.
+ public static string ResolveExecutable() => Resolve();
+
private static string Resolve()
{
string? rid = null;
diff --git a/src/OpenIPC.Viewer.Web/Api/MajesticApi.cs b/src/OpenIPC.Viewer.Web/Api/MajesticApi.cs
new file mode 100644
index 0000000..57e1a50
--- /dev/null
+++ b/src/OpenIPC.Viewer.Web/Api/MajesticApi.cs
@@ -0,0 +1,301 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using OpenIPC.Viewer.Core.Entities;
+using OpenIPC.Viewer.Core.Majestic;
+using OpenIPC.Viewer.Core.Services;
+using OpenIPC.Viewer.Web.Auth;
+using static OpenIPC.Viewer.Web.Api.ApiHelpers;
+
+namespace OpenIPC.Viewer.Web.Api;
+
+// The camera's own settings (Majestic config.json), over HTTP.
+//
+// Everything here is gated on Manage, including the reads — config.json is not
+// a view of the picture, it is the installation: it carries the RTSP/ONVIF
+// logins, and on many builds the Wi-Fi PSK. ViewLive means "may watch this
+// camera", which is not the same permission at all.
+//
+// Writes are read-modify-write against a FRESH copy of the camera's config, not
+// against whatever the browser last saw: the client sends only (path, value)
+// pairs, so a stale tab can't silently revert a field someone else changed, and
+// a malicious one can't post a wholesale payload through the field editor. The
+// raw-JSON endpoint is the deliberate exception — that one is "I know what I'm
+// doing", and it says so in the UI.
+public static class MajesticApi
+{
+ private static readonly TimeSpan CallTimeout = TimeSpan.FromSeconds(8);
+
+ public static void MapMajesticEndpoints(this WebApplication app)
+ {
+ // Everything the panel needs in one round-trip: identity, the parsed
+ // config and the current night mode. Individual failures degrade to
+ // nulls rather than failing the whole read — an older firmware without
+ // /api/v1/info.json still has an editable config.
+ app.MapGet("/api/v1/cameras/{id}/majestic", async (string id, HttpContext ctx, CancellationToken ct) =>
+ {
+ var (target, error) = await TryResolveAsync(ctx, id, ct);
+ if (error is not null)
+ return error;
+
+ return await InvokeAsync(ctx, "read", ct, async () =>
+ {
+ using var timeout = Timeout(ct);
+ var config = await target!.Value.Client.GetConfigAsync(target.Value.Endpoint, timeout.Token);
+ MajesticInfo? info = null;
+ try
+ {
+ info = await target.Value.Client.GetInfoAsync(target.Value.Endpoint, timeout.Token);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ Log(ctx).LogInformation(ex, "Majestic info unavailable for {Host}", target.Value.Endpoint.Host);
+ }
+
+ var model = target.Value.Schema.Parse(config.RawJson);
+ return Results.Json(new
+ {
+ info = info is null ? null : new
+ {
+ model = info.Model,
+ firmware = info.FirmwareVersion,
+ chip = info.ChipModel,
+ uptime = info.Uptime,
+ },
+ nightMode = config.NightMode.ToString().ToLowerInvariant(),
+ sections = model.Sections.Select(s => new
+ {
+ name = s.Name,
+ fields = s.Fields.Select(ToDto).ToList(),
+ }).ToList(),
+ rawJson = config.RawJson,
+ });
+ });
+ });
+
+ // Field edits. The body is a list of (path, value); anything that isn't
+ // a field we just parsed is ignored, and values equal to the camera's
+ // current one are dropped — so "save" with nothing changed is a no-op
+ // instead of a needless config POST (which restarts the encoder).
+ app.MapPost("/api/v1/cameras/{id}/majestic/config", async (
+ string id, MajesticEditsRequest? body, HttpContext ctx, CancellationToken ct) =>
+ {
+ var (target, error) = await TryResolveAsync(ctx, id, ct);
+ if (error is not null)
+ return error;
+ if (body?.Edits is null || body.Edits.Count == 0)
+ return ValidationError("no edits supplied");
+
+ return await InvokeAsync(ctx, "apply", ct, async () =>
+ {
+ using var timeout = Timeout(ct);
+ var config = await target!.Value.Client.GetConfigAsync(target.Value.Endpoint, timeout.Token);
+ var model = target.Value.Schema.Parse(config.RawJson);
+
+ var editedByPath = new Dictionary(StringComparer.Ordinal);
+ foreach (var edit in body.Edits)
+ {
+ if (!string.IsNullOrEmpty(edit.Path) && edit.Value is not null)
+ editedByPath[edit.Path] = edit.Value;
+ }
+
+ var edits = model.ComputeEdits(editedByPath);
+ if (edits.Count == 0)
+ return Results.Json(new { applied = 0, restart = false });
+
+ var updated = target.Value.Schema.ApplyEdits(config.RawJson, edits);
+ await target.Value.Client.UpdateRawConfigAsync(target.Value.Endpoint, updated, timeout.Token);
+ Audit(ctx, "majestic.config", $"{id}/{string.Join(",", edits.Select(e => e.Section + "." + e.Key))}");
+
+ // Whether the picture is about to blink, so the UI can warn
+ // instead of leaving the viewer wondering why the tile froze.
+ var restart = model.Fields
+ .Where(f => editedByPath.ContainsKey(f.Path))
+ .Any(f => f.RequiresRestart);
+ return Results.Json(new { applied = edits.Count, restart });
+ });
+ });
+
+ // The escape hatch: post the whole config.json as typed. Validated as
+ // JSON by the client below us; everything past that is the operator's
+ // call, exactly like editing the file over SSH.
+ app.MapPost("/api/v1/cameras/{id}/majestic/raw", async (
+ string id, MajesticRawRequest? body, HttpContext ctx, CancellationToken ct) =>
+ {
+ var (target, error) = await TryResolveAsync(ctx, id, ct);
+ if (error is not null)
+ return error;
+ if (string.IsNullOrWhiteSpace(body?.Json))
+ return ValidationError("json is required");
+
+ return await InvokeAsync(ctx, "raw", ct, async () =>
+ {
+ using var timeout = Timeout(ct);
+ try
+ {
+ await target!.Value.Client.UpdateRawConfigAsync(target.Value.Endpoint, body!.Json!, timeout.Token);
+ }
+ catch (ArgumentException)
+ {
+ return ValidationError("not valid JSON");
+ }
+ Audit(ctx, "majestic.raw", id);
+ return Results.NoContent();
+ });
+ });
+
+ app.MapPost("/api/v1/cameras/{id}/majestic/night", async (
+ string id, MajesticNightRequest? body, HttpContext ctx, CancellationToken ct) =>
+ {
+ var (target, error) = await TryResolveAsync(ctx, id, ct);
+ if (error is not null)
+ return error;
+ if (!Enum.TryParse(body?.Mode, ignoreCase: true, out var mode) || mode == NightMode.Unknown)
+ return ValidationError("mode must be day, night or auto");
+
+ return await InvokeAsync(ctx, "night", ct, async () =>
+ {
+ using var timeout = Timeout(ct);
+ await target!.Value.Client.SetNightModeAsync(target.Value.Endpoint, mode, timeout.Token);
+ Audit(ctx, "majestic.night", $"{id}/{mode}");
+ return Results.NoContent();
+ });
+ });
+
+ // Read-only telemetry. Parsed here rather than in the browser so the
+ // Prometheus text format stays one implementation (Core's parser, which
+ // the desktop uses too).
+ app.MapGet("/api/v1/cameras/{id}/majestic/metrics", async (string id, HttpContext ctx, CancellationToken ct) =>
+ {
+ var (target, error) = await TryResolveAsync(ctx, id, ct);
+ if (error is not null)
+ return error;
+
+ return await InvokeAsync(ctx, "metrics", ct, async () =>
+ {
+ using var timeout = Timeout(ct);
+ var text = await target!.Value.Client.GetMetricsAsync(target.Value.Endpoint, timeout.Token);
+ var samples = PrometheusTextParser.Parse(text);
+ return Results.Json(samples.Select(s => new { name = s.Display, value = s.Value }).ToList());
+ });
+ });
+ }
+
+ private readonly record struct MajesticTarget(
+ IMajesticClient Client, IMajesticConfigSchema Schema, MajesticEndpoint Endpoint);
+
+ private static async Task<(MajesticTarget? Target, IResult? Error)> TryResolveAsync(
+ HttpContext ctx, string id, CancellationToken ct)
+ {
+ if (ctx.Deny(WebPermission.Manage) is { } forbidden)
+ return (null, forbidden);
+ if (ctx.DenyCamera(id) is { } hidden)
+ return (null, hidden);
+
+ var dir = ctx.RequestServices.GetService();
+ var majestic = ctx.RequestServices.GetService();
+ var schema = ctx.RequestServices.GetService();
+ if (dir is null || majestic is null || schema is null)
+ return (null, BackendUnavailable());
+ if (!Guid.TryParse(id, out var guid))
+ return (null, ValidationError("invalid camera id"));
+
+ var camera = await dir.GetAsync(new CameraId(guid), ct);
+ if (camera is null)
+ return (null, NotFound());
+
+ var credentials = await dir.GetCredentialsAsync(camera.Id, ct);
+ var endpoint = new MajesticEndpoint(camera.Host, camera.HttpPort, credentials);
+
+ // The IsMajestic flag is only ever set by the discovery probe, so a
+ // camera added by hand in the web UI would never show a settings panel
+ // even while running Majestic. Ping once and remember the answer — the
+ // same thing SingleCameraPageViewModel.InitMajesticAsync does on the
+ // desktop, so both heads converge on the same flag.
+ if (!camera.IsMajestic)
+ {
+ bool reachable;
+ try
+ {
+ using var ping = Timeout(ct);
+ reachable = await majestic.PingAsync(endpoint, ping.Token);
+ }
+ catch (Exception ex)
+ {
+ Log(ctx).LogInformation(ex, "Majestic ping of {Host} failed", camera.Host);
+ reachable = false;
+ }
+ if (!reachable)
+ return (null, Unavailable());
+ await dir.SetIsMajesticAsync(camera.Id, true, ct);
+ }
+
+ return (new MajesticTarget(majestic, schema, endpoint), null);
+ }
+
+ // Camera-side failures are upstream problems: 502 with a flat code, details
+ // to the log only (the exception text carries host and sometimes body).
+ //
+ // The cancellation case has to be split, or a camera that simply never
+ // answers reads as success: our own call timeout also surfaces as an
+ // OperationCanceledException, and answering 204 to that told the browser
+ // "done" while nothing had happened. Only the caller's token going down
+ // means the request was abandoned.
+ private static async Task InvokeAsync(
+ HttpContext ctx, string operation, CancellationToken requestAborted, Func> action)
+ {
+ try
+ {
+ return await action();
+ }
+ catch (OperationCanceledException) when (requestAborted.IsCancellationRequested)
+ {
+ return Results.NoContent();
+ }
+ catch (OperationCanceledException)
+ {
+ Log(ctx).LogWarning("Majestic {Operation} timed out", operation);
+ return Results.Json(new { error = "majestic_timeout" }, statusCode: StatusCodes.Status504GatewayTimeout);
+ }
+ catch (Exception ex)
+ {
+ Log(ctx).LogWarning(ex, "Majestic {Operation} failed", operation);
+ return Results.Json(new { error = "majestic_failed" }, statusCode: StatusCodes.Status502BadGateway);
+ }
+ }
+
+ private static IResult Unavailable() =>
+ Results.Json(new { error = "majestic_unavailable" }, statusCode: StatusCodes.Status409Conflict);
+
+ private static CancellationTokenSource Timeout(CancellationToken ct)
+ {
+ var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(CallTimeout);
+ return cts;
+ }
+
+ private static ILogger Log(HttpContext ctx) =>
+ ctx.RequestServices.GetRequiredService().CreateLogger("OpenIPC.Web.Majestic");
+
+ private static object ToDto(MajesticConfigField f) => new
+ {
+ path = f.Path,
+ section = f.Section,
+ key = f.Key,
+ kind = f.Kind.ToString().ToLowerInvariant(),
+ value = f.Value,
+ options = f.Options,
+ restart = f.RequiresRestart,
+ };
+}
+
+public sealed record MajesticFieldEditRequest(string? Path, string? Value);
+public sealed record MajesticEditsRequest(IReadOnlyList? Edits);
+public sealed record MajesticRawRequest(string? Json);
+public sealed record MajesticNightRequest(string? Mode);
diff --git a/src/OpenIPC.Viewer.Web/Api/RecordingApi.cs b/src/OpenIPC.Viewer.Web/Api/RecordingApi.cs
new file mode 100644
index 0000000..16b5f1f
--- /dev/null
+++ b/src/OpenIPC.Viewer.Web/Api/RecordingApi.cs
@@ -0,0 +1,417 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using OpenIPC.Viewer.Core.Entities;
+using OpenIPC.Viewer.Core.Persistence;
+using OpenIPC.Viewer.Core.Recording;
+using OpenIPC.Viewer.Core.Services;
+using OpenIPC.Viewer.Web.Auth;
+using static OpenIPC.Viewer.Web.Api.ApiHelpers;
+
+namespace OpenIPC.Viewer.Web.Api;
+
+// The recordings the desktop head wrote, listed and played back in the browser.
+//
+// Playback is a plain ranged file response, not a stream we re-encode: the
+// recorder writes fragmented MP4 with the camera's own H.264, which every
+// browser plays natively, and `enableRangeProcessing` gives the element
+// real seeking for free. H.265 recordings are the exception — the DTO says so
+// and the UI offers a download instead of pretending it will play.
+//
+// Only files that are indexed in the database can be served: the id is looked
+// up and the path comes from the row, so no request can reach an arbitrary file.
+public static class RecordingApi
+{
+ public static void MapRecordingEndpoints(this WebApplication app)
+ {
+ app.MapGet("/api/v1/recordings", async (
+ string? cameraId, string? from, string? to, int? offset, int? limit,
+ HttpContext ctx, CancellationToken ct) =>
+ {
+ if (ctx.Deny(WebPermission.ViewArchive) is { } denied)
+ return denied;
+ var repo = ctx.RequestServices.GetService();
+ if (repo is null)
+ return BackendUnavailable();
+
+ CameraId? filter = null;
+ if (!string.IsNullOrEmpty(cameraId))
+ {
+ if (!Guid.TryParse(cameraId, out var guid))
+ return ValidationError("invalid camera id");
+ filter = new CameraId(guid);
+ }
+
+ if (!TryParseRange(from, to, out var since, out var until))
+ return ValidationError("from/to must be ISO-8601 timestamps");
+
+ var names = await LoadCameraNamesAsync(ctx, ct);
+ var matching = (await repo.ListAsync(filter, ct))
+ // Same rule as everywhere else: a camera outside the caller's
+ // subset doesn't exist for them, and neither does its archive.
+ .Where(r => ctx.CanSeeCamera(r.CameraId.ToString()))
+ .Where(r => InRange(r.StartedAt, since, until))
+ .OrderByDescending(r => r.StartedAt)
+ .ToList();
+
+ // Paged rather than capped: the old 200-row ceiling silently hid the
+ // rest of the archive, which is worse than making the client ask for
+ // the next page. Total travels with the page so the UI can say where
+ // it is.
+ var take = limit is > 0 and <= MaxPageSize ? limit.Value : DefaultPageSize;
+ var skip = offset is > 0 ? offset.Value : 0;
+ return Results.Json(new
+ {
+ total = matching.Count,
+ offset = skip,
+ limit = take,
+ items = matching.Skip(skip).Take(take).Select(r => Describe(r, names)).ToList(),
+ });
+ });
+
+ app.MapGet("/api/v1/recordings/{id}/stream", async (
+ string id, bool? download, HttpContext ctx, CancellationToken ct) =>
+ {
+ if (ctx.Deny(WebPermission.ViewArchive) is { } denied)
+ return denied;
+ var (recording, error) = await ResolveAsync(ctx, id, ct);
+ if (error is not null)
+ return error;
+
+ var path = recording!.FilePath;
+ if (!File.Exists(path))
+ return Results.Json(new { error = "file_missing" }, statusCode: StatusCodes.Status410Gone);
+
+ var stream = File.OpenRead(path);
+ return download == true
+ ? Results.File(stream, "video/mp4", Path.GetFileName(path), enableRangeProcessing: true)
+ : Results.File(stream, "video/mp4", enableRangeProcessing: true);
+ });
+
+ // Just the timestamps, for drawing a calendar.
+ //
+ // Aggregation happens in the browser, not here: which day a recording
+ // belongs to depends on the VIEWER's time zone, and a web client may sit
+ // in a different one than the server. Core's CalendarActivity does the
+ // same job for the desktop, where the two are the same machine — its own
+ // comment warns that mixing zones is the classic bug, so the boundary is
+ // drawn where the answer is unambiguous.
+ app.MapGet("/api/v1/recordings/calendar", async (
+ string? cameraId, string? from, string? to, HttpContext ctx, CancellationToken ct) =>
+ {
+ if (ctx.Deny(WebPermission.ViewArchive) is { } denied)
+ return denied;
+ var repo = ctx.RequestServices.GetService();
+ if (repo is null)
+ return BackendUnavailable();
+
+ CameraId? filter = null;
+ if (!string.IsNullOrEmpty(cameraId))
+ {
+ if (!Guid.TryParse(cameraId, out var guid))
+ return ValidationError("invalid camera id");
+ filter = new CameraId(guid);
+ }
+ if (!TryParseRange(from, to, out var since, out var until))
+ return ValidationError("from/to must be ISO-8601 timestamps");
+
+ var days = (await repo.ListAsync(filter, ct))
+ .Where(r => ctx.CanSeeCamera(r.CameraId.ToString()))
+ .Where(r => InRange(r.StartedAt, since, until))
+ .OrderBy(r => r.StartedAt)
+ .Take(CalendarPointCap)
+ .Select(r => new { startedAt = r.StartedAt, sizeBytes = r.SizeBytes })
+ .ToList();
+
+ return Results.Json(days);
+ });
+
+ // Start / stop, plus who is recording right now so the UI can show it
+ // without polling every camera.
+ app.MapGet("/api/v1/recordings/active", (HttpContext ctx) =>
+ {
+ if (ctx.Deny(WebPermission.ViewArchive) is { } denied)
+ return denied;
+ var recorder = ctx.RequestServices.GetService();
+ if (recorder is null)
+ return BackendUnavailable();
+ return Results.Json(recorder.ActiveCameraIds.Where(ctx.CanSeeCamera).ToList());
+ });
+
+ app.MapPost("/api/v1/cameras/{id}/recording/start", async (string id, HttpContext ctx, CancellationToken ct) =>
+ {
+ // Recording writes to the host's disk until someone stops it, so it
+ // sits with the other install-changing operations rather than with
+ // plain viewing.
+ if (ctx.Deny(WebPermission.Manage) is { } denied)
+ return denied;
+ if (ctx.DenyCamera(id) is { } hidden)
+ return hidden;
+
+ var dir = ctx.RequestServices.GetService();
+ var recorder = ctx.RequestServices.GetService();
+ if (dir is null || recorder is null)
+ return BackendUnavailable();
+ if (!Guid.TryParse(id, out var guid))
+ return ValidationError("invalid camera id");
+
+ var camera = await dir.GetAsync(new CameraId(guid), ct);
+ if (camera is null)
+ return NotFound();
+
+ var credentials = await dir.GetCredentialsAsync(camera.Id, ct);
+ var started = await recorder.StartAsync(camera, BuildRtspUrl(camera.RtspMainUri, credentials), ct);
+ if (started is null)
+ return Results.Json(new { error = "already_recording" }, statusCode: StatusCodes.Status409Conflict);
+
+ Audit(ctx, "recording.start", started.Id);
+ return Results.Json(new { id = started.Id.ToString(), startedAt = started.StartedAt });
+ });
+
+ app.MapPost("/api/v1/cameras/{id}/recording/stop", async (string id, HttpContext ctx, CancellationToken ct) =>
+ {
+ if (ctx.Deny(WebPermission.Manage) is { } denied)
+ return denied;
+ if (ctx.DenyCamera(id) is { } hidden)
+ return hidden;
+
+ var recorder = ctx.RequestServices.GetService();
+ if (recorder is null)
+ return BackendUnavailable();
+
+ var wasRecording = recorder.IsRecording(id);
+ var stopped = await recorder.StopAsync(id, ct);
+ if (stopped is null)
+ {
+ // Recording stopped either way; the distinction is whether the
+ // camera actually gave us anything to keep.
+ return wasRecording
+ ? Results.Json(new { error = "nothing_recorded" }, statusCode: StatusCodes.Status502BadGateway)
+ : Results.Json(new { error = "not_recording" }, statusCode: StatusCodes.Status409Conflict);
+ }
+
+ Audit(ctx, "recording.stop", stopped.Id);
+ return Results.Json(new { id = stopped.Id.ToString(), sizeBytes = stopped.SizeBytes });
+ });
+
+ // Cut a fragment out of a recording and hand it back as a file.
+ //
+ // Nothing is stored: the clip is what the person asked for, and keeping a
+ // copy on the server would grow the archive behind their back. Output is
+ // fragmented MP4 straight down the response, because a pipe can't be
+ // rewound to write a normal trailer.
+ app.MapGet("/api/v1/recordings/{id}/export", async (
+ string id, double? start, double? end, bool? precise, HttpContext ctx, CancellationToken ct) =>
+ {
+ if (ctx.Deny(WebPermission.Export) is { } denied)
+ return denied;
+ var (recording, error) = await ResolveAsync(ctx, id, ct);
+ if (error is not null)
+ return error;
+ if (!File.Exists(recording!.FilePath))
+ return Results.Json(new { error = "file_missing" }, statusCode: StatusCodes.Status410Gone);
+
+ var from = Math.Max(0, start ?? 0);
+ var to = end ?? 0;
+ if (to <= from)
+ return ValidationError("end must be greater than start");
+ var duration = Math.Min(to - from, MaxExportSeconds);
+
+ var logger = ctx.RequestServices.GetRequiredService().CreateLogger("OpenIPC.Web.Export");
+ var name = Path.GetFileNameWithoutExtension(recording.FilePath)
+ + $"_{(int)from}-{(int)(from + duration)}.mp4";
+
+ var clip = await ClipAsync(recording.FilePath, from, duration, precise == true, logger, ct);
+ if (clip is null || clip.Length == 0)
+ return Results.Json(new { error = "export_failed" }, statusCode: StatusCodes.Status502BadGateway);
+
+ Audit(ctx, "recording.export", $"{recording.Id} {(int)from}+{(int)duration}s");
+ return Results.File(clip, "video/mp4", name);
+ });
+
+ app.MapDelete("/api/v1/recordings/{id}", async (string id, HttpContext ctx, CancellationToken ct) =>
+ {
+ // Deleting footage is destructive and irreversible — management only,
+ // never a plain archive viewer.
+ if (ctx.Deny(WebPermission.Manage) is { } denied)
+ return denied;
+ var (recording, error) = await ResolveAsync(ctx, id, ct);
+ if (error is not null)
+ return error;
+
+ var repo = ctx.RequestServices.GetRequiredService();
+ // Drop the row first: a file we failed to delete is a leftover, but a
+ // row pointing at a deleted file is a broken entry in every client.
+ await repo.RemoveAsync(recording!.Id, ct);
+ try
+ {
+ if (File.Exists(recording.FilePath)) File.Delete(recording.FilePath);
+ }
+ catch (IOException) { /* locked by an in-flight write — the row is gone either way */ }
+
+ Audit(ctx, "recording.delete", recording.Id);
+ return Results.NoContent();
+ });
+ }
+
+ private const int DefaultPageSize = 50;
+ private const int MaxPageSize = 200;
+ // An hour is plenty for "send me this bit"; without a ceiling a stray request
+ // could ask the server to re-encode an entire day.
+ private const double MaxExportSeconds = 3600;
+
+ // Same two ffmpeg shapes the desktop's clip exporter uses: a fast stream copy
+ // that can only start on a keyframe (so the cut lands at or before the asked
+ // point), or a re-encode when the person wants the exact frame.
+ private static async Task ClipAsync(
+ string sourcePath, double start, double duration, bool precise, ILogger logger, CancellationToken ct)
+ {
+ var psi = new System.Diagnostics.ProcessStartInfo(LiveFfmpeg.ResolveExecutable())
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ var startSec = start.ToString("0.###", CultureInfo.InvariantCulture);
+ var durSec = duration.ToString("0.###", CultureInfo.InvariantCulture);
+ void Arg(params string[] args) { foreach (var a in args) psi.ArgumentList.Add(a); }
+
+ Arg("-hide_banner");
+ if (precise)
+ {
+ // Seek after opening so the re-encode starts on the exact frame.
+ Arg("-i", sourcePath, "-ss", startSec, "-t", durSec,
+ "-c:v", "libopenh264", "-b:v", "2M", "-c:a", "copy");
+ }
+ else
+ {
+ Arg("-ss", startSec, "-i", sourcePath, "-t", durSec, "-c", "copy");
+ }
+ // empty_moov/frag_keyframe: the header can't be rewritten on a pipe.
+ Arg("-movflags", "+frag_keyframe+empty_moov+default_base_moof", "-f", "mp4", "pipe:1");
+
+ System.Diagnostics.Process? proc = null;
+ try
+ {
+ proc = System.Diagnostics.Process.Start(psi);
+ if (proc is null)
+ return null;
+ _ = LiveFfmpeg.DrainStderrAsync(proc, logger);
+ using var buffer = new MemoryStream();
+ await proc.StandardOutput.BaseStream.CopyToAsync(buffer, ct);
+ await proc.WaitForExitAsync(ct);
+ return buffer.ToArray();
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Clip export of {Path} failed", sourcePath);
+ return null;
+ }
+ finally
+ {
+ try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { /* gone */ }
+ proc?.Dispose();
+ }
+ }
+
+ // A month of a busy install is still small; the cap only stops a pathological
+ // range from serialising the entire archive.
+ private const int CalendarPointCap = 5000;
+
+ private static bool TryParseRange(string? from, string? to, out DateTime? since, out DateTime? until)
+ {
+ since = until = null;
+ if (!string.IsNullOrEmpty(from))
+ {
+ if (!DateTime.TryParse(from, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed))
+ return false;
+ since = parsed;
+ }
+ if (!string.IsNullOrEmpty(to))
+ {
+ if (!DateTime.TryParse(to, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed))
+ return false;
+ until = parsed;
+ }
+ return true;
+ }
+
+ // Inclusive lower bound, exclusive upper — so a caller can ask for one day
+ // with [midnight, next midnight) and not double-count the boundary.
+ private static bool InRange(DateTime startedAtUtc, DateTime? since, DateTime? until) =>
+ (since is null || startedAtUtc >= since) && (until is null || startedAtUtc < until);
+
+ // Credentials live in the secrets store, not in the stored URI.
+ private static string BuildRtspUrl(Uri baseUri, CameraCredentials? credentials)
+ {
+ if (credentials is null || string.IsNullOrEmpty(credentials.Username))
+ return baseUri.ToString();
+ return new UriBuilder(baseUri)
+ {
+ UserName = credentials.Username,
+ Password = credentials.Password,
+ }.Uri.ToString();
+ }
+
+ private static async Task<(Recording? Recording, IResult? Error)> ResolveAsync(
+ HttpContext ctx, string id, CancellationToken ct)
+ {
+ var repo = ctx.RequestServices.GetService();
+ if (repo is null)
+ return (null, BackendUnavailable());
+ if (!Guid.TryParse(id, out var guid))
+ return (null, ValidationError("invalid recording id"));
+
+ // The repository has no get-by-id, so find it in the indexed set. Fine at
+ // self-host scale, and it keeps the Core contract untouched.
+ var all = await repo.ListAsync(null, ct);
+ var recording = all.FirstOrDefault(r => r.Id.Value == guid);
+ if (recording is null || !ctx.CanSeeCamera(recording.CameraId.ToString()))
+ return (null, NotFound());
+
+ return (recording, null);
+ }
+
+ private static async Task> LoadCameraNamesAsync(HttpContext ctx, CancellationToken ct)
+ {
+ var names = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var cameras = ctx.RequestServices.GetService();
+ if (cameras is not null)
+ {
+ foreach (var camera in await cameras.GetAllAsync(ct))
+ names[camera.Id.ToString()] = camera.Name;
+ }
+ return names;
+ }
+
+ private static object Describe(Recording r, IReadOnlyDictionary cameraNames)
+ {
+ var cameraId = r.CameraId.ToString();
+ return new
+ {
+ id = r.Id.ToString(),
+ cameraId,
+ cameraName = cameraNames.TryGetValue(cameraId, out var name) ? name : null,
+ fileName = Path.GetFileName(r.FilePath),
+ startedAt = r.StartedAt,
+ endedAt = r.EndedAt,
+ durationSeconds = r.EndedAt is { } end ? (int)(end - r.StartedAt).TotalSeconds : (int?)null,
+ sizeBytes = r.SizeBytes,
+ codec = r.Codec,
+ hasMotion = r.HasMotion,
+ // H.265 in an MP4 plays in Safari and nowhere else worth relying on;
+ // tell the client rather than letting the fail silently.
+ playable = !(r.Codec?.Contains("hevc", StringComparison.OrdinalIgnoreCase) == true
+ || r.Codec?.Contains("265", StringComparison.Ordinal) == true),
+ };
+ }
+}
diff --git a/src/OpenIPC.Viewer.Web/Api/SnapshotApi.cs b/src/OpenIPC.Viewer.Web/Api/SnapshotApi.cs
new file mode 100644
index 0000000..17e708c
--- /dev/null
+++ b/src/OpenIPC.Viewer.Web/Api/SnapshotApi.cs
@@ -0,0 +1,173 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using OpenIPC.Viewer.Core.Entities;
+using OpenIPC.Viewer.Core.Majestic;
+using OpenIPC.Viewer.Core.Services;
+using OpenIPC.Viewer.Core.Snapshots;
+using OpenIPC.Viewer.Web.Auth;
+using static OpenIPC.Viewer.Web.Api.ApiHelpers;
+
+namespace OpenIPC.Viewer.Web.Api;
+
+// A still from a camera, on demand.
+//
+// Two sources, in the desktop's order of preference: Majestic's own
+// /image.jpg (the camera encodes it, so it costs us nothing and comes at
+// sensor resolution), falling back to one short ffmpeg pull off the RTSP
+// stream for cameras that aren't Majestic or have the HTTP server off.
+//
+// Nothing is stored: this hands the browser the bytes, which is what "take a
+// snapshot" means from a web client. The shared snapshot library the desktop
+// keeps (indexed, thumbnailed, browsable) is a separate slice — it needs the
+// image/thumbnail stack the server head doesn't compose yet.
+public static class SnapshotApi
+{
+ private static readonly TimeSpan MajesticTimeout = TimeSpan.FromSeconds(6);
+ private static readonly TimeSpan FfmpegTimeout = TimeSpan.FromSeconds(15);
+
+ public static void MapSnapshotEndpoints(this WebApplication app)
+ {
+ app.MapGet("/api/v1/cameras/{id}/snapshot", async (string id, HttpContext ctx, CancellationToken ct) =>
+ {
+ // Seeing a still is seeing the camera — same permission as live.
+ if (ctx.Deny(WebPermission.ViewLive) is { } denied)
+ return denied;
+ if (ctx.DenyCamera(id) is { } hidden)
+ return hidden;
+
+ var dir = ctx.RequestServices.GetService();
+ if (dir is null)
+ return BackendUnavailable();
+ if (!Guid.TryParse(id, out var guid))
+ return ValidationError("invalid camera id");
+
+ var camera = await dir.GetAsync(new CameraId(guid), ct);
+ if (camera is null)
+ return NotFound();
+
+ var credentials = await dir.GetCredentialsAsync(camera.Id, ct);
+ var logger = ctx.RequestServices.GetRequiredService().CreateLogger("OpenIPC.Web.Snapshot");
+
+ var (jpeg, _) = await CaptureAsync(ctx, camera, credentials, logger, ct);
+
+ if (jpeg is null || jpeg.Length == 0)
+ return Results.Json(new { error = "snapshot_failed" }, statusCode: StatusCodes.Status502BadGateway);
+
+ // No caching: a still is a point in time, and the next request means
+ // the viewer wants a fresh one.
+ ctx.Response.Headers.CacheControl = "no-store";
+ return Results.File(jpeg, "image/jpeg");
+ });
+ }
+
+ // One capture path for both callers — the transient still above and the
+ // kept one in SnapshotLibraryApi — so the library never ends up with a
+ // different (worse) picture than the preview the viewer just approved. The
+ // source travels with the bytes because the row records it: an HD-always
+ // snapshot must never claim it came off the substream.
+ internal static async Task<(byte[]? Jpeg, SnapshotSource Source)> CaptureAsync(
+ HttpContext ctx, Camera camera, CameraCredentials? credentials, ILogger logger, CancellationToken ct)
+ {
+ var majestic = await TryMajesticAsync(ctx, camera, credentials, logger, ct);
+ if (majestic is { Length: > 0 })
+ return (majestic, SnapshotSource.HttpSnapshot);
+
+ // ffmpeg pulls one keyframe off the mainstream — the same thing the
+ // desktop calls OpenedStream.
+ return (await TryFfmpegAsync(camera, credentials, logger, ct), SnapshotSource.OpenedStream);
+ }
+
+ private static async Task TryMajesticAsync(
+ HttpContext ctx, Camera camera, CameraCredentials? credentials, ILogger logger, CancellationToken ct)
+ {
+ var majestic = ctx.RequestServices.GetService();
+ if (majestic is null || !camera.IsMajestic)
+ return null;
+
+ try
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeout.CancelAfter(MajesticTimeout);
+ var endpoint = new MajesticEndpoint(camera.Host, camera.HttpPort, credentials);
+ return await majestic.SnapshotJpegAsync(endpoint, new MajesticSnapshotOptions(), timeout.Token);
+ }
+ catch (Exception ex)
+ {
+ logger.LogInformation(ex, "Majestic snapshot of {Host} failed — falling back to ffmpeg", camera.Host);
+ return null;
+ }
+ }
+
+ // One frame off the mainstream. Writes to stdout so nothing lands on disk,
+ // and dies with the request if the camera stops answering mid-pull.
+ private static async Task TryFfmpegAsync(
+ Camera camera, CameraCredentials? credentials, ILogger logger, CancellationToken ct)
+ {
+ var url = BuildRtspUrl(camera.RtspMainUri, credentials);
+ var psi = new ProcessStartInfo(LiveFfmpeg.ResolveExecutable())
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ foreach (var arg in new[]
+ {
+ "-rtsp_transport", "tcp", "-i", url,
+ "-frames:v", "1", "-q:v", "3",
+ "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1",
+ })
+ {
+ psi.ArgumentList.Add(arg);
+ }
+
+ Process? proc = null;
+ try
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeout.CancelAfter(FfmpegTimeout);
+ proc = Process.Start(psi);
+ if (proc is null)
+ return null;
+
+ _ = LiveFfmpeg.DrainStderrAsync(proc, logger);
+ using var buffer = new MemoryStream();
+ await proc.StandardOutput.BaseStream.CopyToAsync(buffer, timeout.Token);
+ await proc.WaitForExitAsync(timeout.Token);
+ return buffer.Length > 0 ? buffer.ToArray() : null;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "ffmpeg snapshot of {Host} failed", camera.Host);
+ return null;
+ }
+ finally
+ {
+ try
+ {
+ if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true);
+ }
+ catch { /* already gone */ }
+ proc?.Dispose();
+ }
+ }
+
+ // Credentials live in the secrets store, not in the stored URI.
+ private static string BuildRtspUrl(Uri baseUri, CameraCredentials? credentials)
+ {
+ if (credentials is null || string.IsNullOrEmpty(credentials.Username))
+ return baseUri.ToString();
+ return new UriBuilder(baseUri)
+ {
+ UserName = credentials.Username,
+ Password = credentials.Password,
+ }.Uri.ToString();
+ }
+}
diff --git a/src/OpenIPC.Viewer.Web/Api/SnapshotLibraryApi.cs b/src/OpenIPC.Viewer.Web/Api/SnapshotLibraryApi.cs
new file mode 100644
index 0000000..15ea01a
--- /dev/null
+++ b/src/OpenIPC.Viewer.Web/Api/SnapshotLibraryApi.cs
@@ -0,0 +1,365 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using OpenIPC.Viewer.Core.Entities;
+using OpenIPC.Viewer.Core.Platform;
+using OpenIPC.Viewer.Core.Services;
+using OpenIPC.Viewer.Core.Snapshots;
+using OpenIPC.Viewer.Web.Auth;
+using static OpenIPC.Viewer.Web.Api.ApiHelpers;
+
+namespace OpenIPC.Viewer.Web.Api;
+
+// The kept snapshots: a still saved into the shared library instead of just
+// handed to the browser (SnapshotApi does that).
+//
+// Storage is deliberately identical to the desktop's SnapshotService —
+// //yyyy-MM-dd_HH-mm-ss.jpg, thumbnail in .thumbs/.jpg,
+// one row in the same table — so a still taken from a phone shows up in the
+// desktop browser and vice versa. We can't call SnapshotService itself: it
+// depends on LiveStreamCoordinator and the Skia thumbnailer, both in the Video
+// layer the server head doesn't compose (no FFmpeg natives). Same split as
+// WebRecorder, and the same rule: match the layout exactly or the two heads
+// quietly build separate archives.
+//
+// The thumbnail is made with ffmpeg, which this head already shells out to for
+// snapshots and recording — one existing dependency instead of a new imaging
+// one, and it writes where the desktop gallery already looks.
+public static class SnapshotLibraryApi
+{
+ private const int DefaultPageSize = 60;
+ private const int MaxPageSize = 200;
+ private const int ThumbMaxDim = 320;
+ private static readonly TimeSpan ThumbTimeout = TimeSpan.FromSeconds(10);
+
+ public static void MapSnapshotLibraryEndpoints(this WebApplication app)
+ {
+ // Take a still and keep it. ViewArchive rather than Manage: this adds to
+ // the archive the caller can already read, and the picture is one they
+ // are already allowed to watch live. Deleting still needs Manage, like
+ // recordings.
+ app.MapPost("/api/v1/cameras/{id}/snapshots", async (string id, HttpContext ctx, CancellationToken ct) =>
+ {
+ if (ctx.Deny(WebPermission.ViewArchive) is { } denied)
+ return denied;
+ if (ctx.DenyCamera(id) is { } hidden)
+ return hidden;
+
+ var dir = ctx.RequestServices.GetService();
+ var repo = ctx.RequestServices.GetService();
+ var fs = ctx.RequestServices.GetService();
+ if (dir is null || repo is null || fs is null)
+ return BackendUnavailable();
+ if (!Guid.TryParse(id, out var guid))
+ return ValidationError("invalid camera id");
+
+ var camera = await dir.GetAsync(new CameraId(guid), ct);
+ if (camera is null)
+ return NotFound();
+
+ var credentials = await dir.GetCredentialsAsync(camera.Id, ct);
+ var logger = Log(ctx);
+ var (jpeg, source) = await SnapshotApi.CaptureAsync(ctx, camera, credentials, logger, ct);
+ if (jpeg is null || jpeg.Length == 0)
+ return Results.Json(new { error = "snapshot_failed" }, statusCode: StatusCodes.Status502BadGateway);
+
+ var snapshotId = SnapshotId.New();
+ var takenAt = DateTime.UtcNow;
+ var folder = Path.Combine(fs.SnapshotsDir.FullName, camera.Id.ToString());
+ Directory.CreateDirectory(folder);
+ var name = takenAt.ToLocalTime().ToString("yyyy-MM-dd_HH-mm-ss", CultureInfo.InvariantCulture) + ".jpg";
+ var path = EnsureUnique(Path.Combine(folder, name));
+ await File.WriteAllBytesAsync(path, jpeg, ct);
+
+ var thumbPath = await TryWriteThumbAsync(fs, snapshotId, path, logger, ct);
+ var (width, height) = JpegSize(jpeg);
+
+ var snapshot = new Snapshot(
+ snapshotId, camera.Id, takenAt, path, thumbPath, width, height, source, SnapshotKind.Manual);
+ await repo.AddAsync(snapshot, ct);
+ Audit(ctx, "snapshot.save", $"{id}/{snapshotId}");
+
+ return Results.Json(Describe(snapshot, camera.Name));
+ });
+
+ app.MapGet("/api/v1/snapshots", async (
+ string? cameraId, string? from, string? to, int? offset, int? limit,
+ HttpContext ctx, CancellationToken ct) =>
+ {
+ if (ctx.Deny(WebPermission.ViewArchive) is { } denied)
+ return denied;
+ var repo = ctx.RequestServices.GetService();
+ if (repo is null)
+ return BackendUnavailable();
+
+ CameraId? filter = null;
+ if (!string.IsNullOrEmpty(cameraId))
+ {
+ if (!Guid.TryParse(cameraId, out var guid))
+ return ValidationError("invalid camera id");
+ filter = new CameraId(guid);
+ }
+ if (!TryParseRange(from, to, out var since, out var until))
+ return ValidationError("from/to must be ISO-8601 timestamps");
+
+ var names = await LoadCameraNamesAsync(ctx, ct);
+ // The repository's own limit is a fetch cap, not the page: filtering
+ // by the caller's camera subset happens here, so ask for enough rows
+ // to page through and slice afterwards.
+ var all = (await repo.ListAsync(filter, since, until, MaxFetch, ct))
+ .Where(s => ctx.CanSeeCamera(s.CameraId.ToString()))
+ .ToList();
+
+ var take = limit is > 0 and <= MaxPageSize ? limit.Value : DefaultPageSize;
+ var skip = offset is > 0 ? offset.Value : 0;
+ return Results.Json(new
+ {
+ total = all.Count,
+ offset = skip,
+ limit = take,
+ items = all.Skip(skip).Take(take)
+ .Select(s => Describe(s, names.TryGetValue(s.CameraId, out var n) ? n : null))
+ .ToList(),
+ });
+ });
+
+ // The image itself. Only paths that came out of the index are served —
+ // the id is looked up and the path read off the row, so no request can
+ // point at an arbitrary file.
+ app.MapGet("/api/v1/snapshots/{id}/image", async (
+ string id, bool? thumb, bool? download, HttpContext ctx, CancellationToken ct) =>
+ {
+ if (ctx.Deny(WebPermission.ViewArchive) is { } denied)
+ return denied;
+ var (snapshot, error) = await ResolveAsync(ctx, id, ct);
+ if (error is not null)
+ return error;
+
+ // Fall back to the full image when a thumbnail was never made (an
+ // older row, or ffmpeg wasn't there) — a gallery with holes in it is
+ // worse than a gallery that decodes a few big files.
+ var path = thumb == true && !string.IsNullOrEmpty(snapshot!.ThumbPath) && File.Exists(snapshot.ThumbPath)
+ ? snapshot.ThumbPath!
+ : snapshot!.Path;
+ if (!File.Exists(path))
+ return Results.Json(new { error = "file_missing" }, statusCode: StatusCodes.Status410Gone);
+
+ var stream = File.OpenRead(path);
+ return download == true
+ ? Results.File(stream, "image/jpeg", Path.GetFileName(snapshot.Path))
+ : Results.File(stream, "image/jpeg");
+ });
+
+ app.MapDelete("/api/v1/snapshots/{id}", async (string id, HttpContext ctx, CancellationToken ct) =>
+ {
+ if (ctx.Deny(WebPermission.Manage) is { } denied)
+ return denied;
+ var (snapshot, error) = await ResolveAsync(ctx, id, ct);
+ if (error is not null)
+ return error;
+
+ var repo = ctx.RequestServices.GetRequiredService();
+ // Row first: a file that outlives its row is invisible clutter, a row
+ // that outlives its file is a broken thumbnail in everyone's gallery.
+ await repo.RemoveAsync(snapshot!.Id, ct);
+ TryDelete(snapshot.Path, ctx);
+ if (!string.IsNullOrEmpty(snapshot.ThumbPath))
+ TryDelete(snapshot.ThumbPath!, ctx);
+ Audit(ctx, "snapshot.delete", id);
+ return Results.NoContent();
+ });
+ }
+
+ // How many rows to pull before the caller's camera subset is applied. Well
+ // past a usable page count, and the response says `total` either way.
+ private const int MaxFetch = 5000;
+
+ private static async Task<(Snapshot? Snapshot, IResult? Error)> ResolveAsync(
+ HttpContext ctx, string id, CancellationToken ct)
+ {
+ var repo = ctx.RequestServices.GetService();
+ if (repo is null)
+ return (null, BackendUnavailable());
+ if (!Guid.TryParse(id, out var guid))
+ return (null, ValidationError("invalid snapshot id"));
+
+ var snapshot = await repo.GetAsync(new SnapshotId(guid), ct);
+ if (snapshot is null)
+ return (null, NotFound());
+ // A snapshot of a camera outside the caller's subset doesn't exist for
+ // them — same rule as the live and recorded surfaces.
+ if (!ctx.CanSeeCamera(snapshot.CameraId.ToString()))
+ return (null, NotFound());
+ return (snapshot, null);
+ }
+
+ private static object Describe(Snapshot s, string? cameraName) => new
+ {
+ id = s.Id.ToString(),
+ cameraId = s.CameraId.ToString(),
+ cameraName,
+ takenAt = s.TakenAt,
+ width = s.Width,
+ height = s.Height,
+ hasThumb = !string.IsNullOrEmpty(s.ThumbPath),
+ source = s.Source.ToString(),
+ };
+
+ // Downscale with ffmpeg into the same .thumbs/.jpg the desktop gallery
+ // reads. Failure is not fatal: the row simply carries no thumbnail, exactly
+ // as SnapshotService treats a thumbnailer that throws.
+ private static async Task TryWriteThumbAsync(
+ IFileSystem fs, SnapshotId id, string sourcePath, ILogger logger, CancellationToken ct)
+ {
+ var thumbDir = Path.Combine(fs.SnapshotsDir.FullName, ".thumbs");
+ var thumbPath = Path.Combine(thumbDir, id + ".jpg");
+ Process? proc = null;
+ try
+ {
+ Directory.CreateDirectory(thumbDir);
+ var psi = new ProcessStartInfo(LiveFfmpeg.ResolveExecutable())
+ {
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ foreach (var arg in new[]
+ {
+ "-y", "-i", sourcePath,
+ // Long edge to ThumbMaxDim, short edge computed and kept
+ // even (mjpeg wants even dimensions).
+ "-vf", $"scale=w={ThumbMaxDim}:h={ThumbMaxDim}:force_original_aspect_ratio=decrease",
+ "-q:v", "5", thumbPath,
+ })
+ {
+ psi.ArgumentList.Add(arg);
+ }
+
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeout.CancelAfter(ThumbTimeout);
+ proc = Process.Start(psi);
+ if (proc is null)
+ return null;
+ _ = LiveFfmpeg.DrainStderrAsync(proc, logger);
+ await proc.WaitForExitAsync(timeout.Token);
+ return proc.ExitCode == 0 && File.Exists(thumbPath) ? thumbPath : null;
+ }
+ catch (Exception ex)
+ {
+ logger.LogInformation(ex, "Snapshot thumbnail failed for {Path}", sourcePath);
+ return null;
+ }
+ finally
+ {
+ try
+ {
+ if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true);
+ }
+ catch { /* already gone */ }
+ proc?.Dispose();
+ }
+ }
+
+ // Pixel dimensions straight out of the JPEG's frame header. The desktop
+ // stores these to prove a capture really was full-resolution, and reading
+ // the two bytes is cheaper than decoding the image — which this head has no
+ // library to do anyway.
+ internal static (int Width, int Height) JpegSize(byte[] jpeg)
+ {
+ // SOI, then marker segments: 0xFF . The SOFn
+ // markers carry the size; everything else is skipped by its length.
+ var i = 2;
+ while (i + 9 < jpeg.Length)
+ {
+ if (jpeg[i] != 0xFF)
+ {
+ i++;
+ continue;
+ }
+ var marker = jpeg[i + 1];
+ // Standalone markers (no length field).
+ if (marker == 0xD8 || marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7))
+ {
+ i += 2;
+ continue;
+ }
+ var length = (jpeg[i + 2] << 8) | jpeg[i + 3];
+ // SOF0..SOF15, minus the DHT/JPG/DAC markers interleaved in that range.
+ if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC)
+ {
+ var height = (jpeg[i + 5] << 8) | jpeg[i + 6];
+ var width = (jpeg[i + 7] << 8) | jpeg[i + 8];
+ return (width, height);
+ }
+ if (length <= 0)
+ break;
+ i += 2 + length;
+ }
+ return (0, 0);
+ }
+
+ private static string EnsureUnique(string path)
+ {
+ if (!File.Exists(path)) return path;
+ var dir = Path.GetDirectoryName(path)!;
+ var stem = Path.GetFileNameWithoutExtension(path);
+ var ext = Path.GetExtension(path);
+ for (var i = 1; ; i++)
+ {
+ var candidate = Path.Combine(dir, $"{stem}_{i}{ext}");
+ if (!File.Exists(candidate)) return candidate;
+ }
+ }
+
+ private static void TryDelete(string path, HttpContext ctx)
+ {
+ try
+ {
+ if (File.Exists(path)) File.Delete(path);
+ }
+ catch (Exception ex)
+ {
+ Log(ctx).LogWarning(ex, "Deleting snapshot file {Path} failed", path);
+ }
+ }
+
+ private static async Task> LoadCameraNamesAsync(HttpContext ctx, CancellationToken ct)
+ {
+ var dir = ctx.RequestServices.GetService();
+ if (dir is null)
+ return new Dictionary();
+ var cameras = await dir.ListAsync(ct);
+ return cameras.ToDictionary(c => c.Id, c => c.Name);
+ }
+
+ private static bool TryParseRange(string? from, string? to, out DateTime? since, out DateTime? until)
+ {
+ since = null;
+ until = null;
+ if (!string.IsNullOrEmpty(from))
+ {
+ if (!DateTime.TryParse(from, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var s))
+ return false;
+ since = s;
+ }
+ if (!string.IsNullOrEmpty(to))
+ {
+ if (!DateTime.TryParse(to, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var u))
+ return false;
+ until = u;
+ }
+ return true;
+ }
+
+ private static ILogger Log(HttpContext ctx) =>
+ ctx.RequestServices.GetRequiredService().CreateLogger("OpenIPC.Web.Snapshot");
+}
diff --git a/src/OpenIPC.Viewer.Web/Api/TalkApi.cs b/src/OpenIPC.Viewer.Web/Api/TalkApi.cs
new file mode 100644
index 0000000..08ca14d
--- /dev/null
+++ b/src/OpenIPC.Viewer.Web/Api/TalkApi.cs
@@ -0,0 +1,209 @@
+using System;
+using System.Collections.Concurrent;
+using System.Net.WebSockets;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using OpenIPC.Viewer.Core.Audio;
+using OpenIPC.Viewer.Core.Entities;
+using OpenIPC.Viewer.Core.Services;
+using OpenIPC.Viewer.Core.Video;
+using OpenIPC.Viewer.Web.Auth;
+using static OpenIPC.Viewer.Web.Api.ApiHelpers;
+
+namespace OpenIPC.Viewer.Web.Api;
+
+// Push-to-talk from the browser: the mic is on the viewer's machine, the RTSP
+// backchannel is ours, and this is the bridge between them.
+//
+// The browser sends 16-bit mono PCM at the negotiated rate over a WebSocket, one
+// binary message per 20 ms chunk; we companded it to G.711, wrap it in RTP and
+// push it into the session. Encoding stays here rather than in JavaScript so the
+// wire format is decided in exactly one place — Core's G711/RtpPacketizer, the
+// same code the desktop talks with.
+//
+// PushToTalkController is deliberately NOT used: it owns an IAudioInput, i.e. a
+// microphone on the machine running it. Here the microphone is three network
+// hops away. Same reason PtzApi skips PtzController.
+//
+// One talker per camera. A speaker is a physical, exclusive thing — two people
+// talking at once would interleave RTP into one stream and come out as noise —
+// so a second attempt is refused rather than mixed.
+public static class TalkApi
+{
+ // 8 kHz G.711: 20 ms = 160 samples = 320 bytes of PCM16. Anything much
+ // larger than a couple of frames is either a confused client or an attempt
+ // to make us allocate; the session is dropped rather than trusted.
+ private const int MaxChunkBytes = 4096;
+ private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(6);
+ private static readonly ConcurrentDictionary Talking = new(StringComparer.OrdinalIgnoreCase);
+
+ public static void MapTalkEndpoints(this WebApplication app)
+ {
+ // Does this camera even have a speaker? OPTIONS + DESCRIBE only, so the
+ // UI can hide the button instead of offering something that will fail.
+ app.MapGet("/api/v1/cameras/{id}/talk", async (string id, HttpContext ctx, CancellationToken ct) =>
+ {
+ var (target, error) = await TryResolveAsync(ctx, id, ct);
+ if (error is not null)
+ return error;
+
+ try
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeout.CancelAfter(ProbeTimeout);
+ var supported = await target!.Value.Client.ProbeAsync(target.Value.Endpoint, timeout.Token);
+ return Results.Json(new { supported, busy = Talking.ContainsKey(id) });
+ }
+ catch (Exception ex)
+ {
+ // A camera that won't answer the probe isn't a "no" — say so and
+ // let the viewer try, exactly as the desktop treats it.
+ Log(ctx).LogInformation(ex, "Backchannel probe of {Camera} failed", id);
+ return Results.Json(new { supported = (bool?)null, busy = Talking.ContainsKey(id) });
+ }
+ });
+
+ app.Map("/api/v1/cameras/{id}/talk/stream", async (string id, HttpContext ctx, CancellationToken ct) =>
+ {
+ if (!ctx.WebSockets.IsWebSocketRequest)
+ return ValidationError("expected a websocket request");
+
+ var (target, error) = await TryResolveAsync(ctx, id, ct);
+ if (error is not null)
+ return error;
+
+ if (!Talking.TryAdd(id, 0))
+ return Results.Json(new { error = "talk_busy" }, statusCode: StatusCodes.Status409Conflict);
+
+ var logger = Log(ctx);
+ try
+ {
+ IAudioBackchannelSession? session;
+ try
+ {
+ session = await target!.Value.Client.OpenAsync(target.Value.Endpoint, ct);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Opening backchannel to {Camera} failed", id);
+ return Results.Json(new { error = "talk_failed" }, statusCode: StatusCodes.Status502BadGateway);
+ }
+
+ // No backchannel track advertised — the common "this camera has
+ // no speaker" case, which is a 409, not a failure.
+ if (session is null)
+ return Results.Json(new { error = "talk_unsupported" }, statusCode: StatusCodes.Status409Conflict);
+
+ await using (session)
+ {
+ var socket = await ctx.WebSockets.AcceptWebSocketAsync();
+ Audit(ctx, "talk.start", id);
+ await PumpAsync(socket, session, logger, ct);
+ }
+ return Results.Empty;
+ }
+ finally
+ {
+ Talking.TryRemove(id, out _);
+ }
+ });
+ }
+
+ // Read PCM chunks until the viewer stops talking (closes the socket) and
+ // push each one to the camera. Nothing is buffered: a chunk that arrives
+ // late is still played late, and holding it back would only add delay to a
+ // conversation.
+ private static async Task PumpAsync(
+ WebSocket socket, IAudioBackchannelSession session, ILogger logger, CancellationToken ct)
+ {
+ var rtp = new RtpPacketizer(
+ session.ALaw ? RtpPacketizer.PayloadTypePcma : RtpPacketizer.PayloadTypePcmu,
+ ssrc: (uint)Random.Shared.Next());
+
+ // Tell the client what was negotiated, so it can resample to the right
+ // rate instead of guessing 8000 and being wrong on a 16 kHz camera.
+ var hello = System.Text.Encoding.UTF8.GetBytes(
+ $"{{\"sampleRate\":{session.SampleRate},\"codec\":\"{(session.ALaw ? "pcma" : "pcmu")}\"}}");
+ await socket.SendAsync(hello, WebSocketMessageType.Text, endOfMessage: true, ct);
+
+ var buffer = new byte[MaxChunkBytes];
+ while (!ct.IsCancellationRequested)
+ {
+ WebSocketReceiveResult result;
+ try
+ {
+ result = await socket.ReceiveAsync(buffer, ct);
+ }
+ catch (Exception ex)
+ {
+ logger.LogInformation(ex, "Talk socket closed mid-stream");
+ return;
+ }
+
+ if (result.MessageType == WebSocketMessageType.Close)
+ return;
+ if (result.MessageType != WebSocketMessageType.Binary || result.Count == 0)
+ continue;
+ // A chunk split across frames would desynchronise the 16-bit samples;
+ // drop the session rather than emit noise into someone's room.
+ if (!result.EndOfMessage)
+ {
+ logger.LogWarning("Talk chunk exceeded {Max} bytes — dropping the session", MaxChunkBytes);
+ return;
+ }
+
+ // Odd byte counts can't be whole PCM16 samples.
+ var count = result.Count - (result.Count % 2);
+ if (count == 0)
+ continue;
+
+ try
+ {
+ var g711 = G711.Encode(buffer.AsSpan(0, count), session.ALaw);
+ var packet = rtp.Packetize(g711, samplesInPayload: g711.Length);
+ await session.SendRtpAsync(packet, ct);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Sending talk audio failed — ending the session");
+ return;
+ }
+ }
+ }
+
+ private readonly record struct TalkTarget(IAudioBackchannelClient Client, BackchannelEndpoint Endpoint);
+
+ private static async Task<(TalkTarget? Target, IResult? Error)> TryResolveAsync(
+ HttpContext ctx, string id, CancellationToken ct)
+ {
+ if (ctx.Deny(WebPermission.Talk) is { } forbidden)
+ return (null, forbidden);
+ if (ctx.DenyCamera(id) is { } hidden)
+ return (null, hidden);
+
+ var dir = ctx.RequestServices.GetService();
+ var client = ctx.RequestServices.GetService();
+ if (dir is null || client is null)
+ return (null, BackendUnavailable());
+ if (!Guid.TryParse(id, out var guid))
+ return (null, ValidationError("invalid camera id"));
+
+ var camera = await dir.GetAsync(new CameraId(guid), ct);
+ if (camera is null)
+ return (null, NotFound());
+ // Mirrors SingleCameraPageViewModel.CanTalk: a probed camera that says it
+ // has no audio output is a definite no; anything else is worth trying.
+ if (camera.OnvifEnabled && !camera.HasAudioOut)
+ return (null, Results.Json(new { error = "talk_unsupported" }, statusCode: StatusCodes.Status409Conflict));
+
+ var credentials = await dir.GetCredentialsAsync(camera.Id, ct);
+ return (new TalkTarget(client, new BackchannelEndpoint(camera.RtspMainUri, credentials)), null);
+ }
+
+ private static ILogger Log(HttpContext ctx) =>
+ ctx.RequestServices.GetRequiredService().CreateLogger("OpenIPC.Web.Talk");
+}
diff --git a/src/OpenIPC.Viewer.Web/Api/WebRecorder.cs b/src/OpenIPC.Viewer.Web/Api/WebRecorder.cs
new file mode 100644
index 0000000..78febfb
--- /dev/null
+++ b/src/OpenIPC.Viewer.Web/Api/WebRecorder.cs
@@ -0,0 +1,248 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using OpenIPC.Viewer.Core.Entities;
+using OpenIPC.Viewer.Core.Platform;
+using OpenIPC.Viewer.Core.Recording;
+
+namespace OpenIPC.Viewer.Web.Api;
+
+// Recording started from the browser.
+//
+// The desktop head records through the Video project's libavformat session; the
+// server head doesn't reference Video (no FFmpeg.AutoGen natives to ship), so it
+// records the way it does everything else with video: one ffmpeg process writing
+// the camera's own H.264 straight to disk. Same folder, same database table, so
+// a clip recorded from the browser shows up in the desktop's Recordings page and
+// vice versa.
+//
+// Stopping sends "q" on stdin rather than killing the process: an MP4 needs its
+// trailer written, and a killed ffmpeg leaves a file no player will open.
+//
+// Long recordings are cut into segments (like the desktop's 10-minute chunks):
+// a night-long single file is painful to download, to seek, and to lose — one
+// corrupt tail would take the whole night with it. ffmpeg's segment muxer closes
+// each part properly on its own, so every finished segment is playable even if
+// the server dies mid-recording.
+public sealed class WebRecorder : IAsyncDisposable
+{
+ private static readonly TimeSpan StopGrace = TimeSpan.FromSeconds(5);
+
+ // Matches the desktop's RecordingOptions.SegmentDuration. Overridable so an
+ // operator can trade file count against file size (and so this is testable
+ // without waiting ten minutes).
+ private static readonly TimeSpan SegmentDuration = ResolveSegmentDuration();
+
+ private static TimeSpan ResolveSegmentDuration()
+ {
+ var raw = Environment.GetEnvironmentVariable("OPENIPC_WEB_SEGMENT_SECONDS");
+ return int.TryParse(raw, out var seconds) && seconds is > 0 and <= 3600
+ ? TimeSpan.FromSeconds(seconds)
+ : TimeSpan.FromMinutes(10);
+ }
+
+ private readonly IRecordingRepository _repo;
+ private readonly IFileSystem _fs;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary _active = new(StringComparer.OrdinalIgnoreCase);
+
+ public WebRecorder(IRecordingRepository repo, IFileSystem fs, ILogger logger)
+ {
+ _repo = repo;
+ _fs = fs;
+ _logger = logger;
+ }
+
+ public bool IsRecording(string cameraId) => _active.ContainsKey(cameraId);
+
+ public IReadOnlyList ActiveCameraIds => _active.Keys.ToList();
+
+ public async Task StartAsync(Camera camera, string rtspUrl, CancellationToken ct)
+ {
+ var cameraId = camera.Id.ToString();
+ if (_active.ContainsKey(cameraId))
+ return null;
+
+ // Same layout as the desktop head — /// —
+ // so both write into one archive a person can still navigate by hand.
+ var dir = Path.Combine(
+ _fs.RecordingsDir.FullName,
+ Slug(camera.Name),
+ DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
+ Directory.CreateDirectory(dir);
+ // ffmpeg fills in the %03d; the first segment's name is predictable, which
+ // is what gets indexed up front.
+ var baseName = $"cam_{DateTime.Now:yyyyMMdd_HHmmss}";
+ var pattern = Path.Combine(dir, baseName + "_%03d.mp4");
+ var path = Path.Combine(dir, baseName + "_000.mp4");
+
+ var psi = new ProcessStartInfo(LiveFfmpeg.ResolveExecutable())
+ {
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ foreach (var arg in new[]
+ {
+ "-rtsp_transport", "tcp", "-i", rtspUrl,
+ // Copy, never re-encode: recording must not cost CPU per camera,
+ // and the archive should hold what the camera actually sent.
+ "-c", "copy",
+ "-f", "segment",
+ "-segment_time", ((int)SegmentDuration.TotalSeconds).ToString(CultureInfo.InvariantCulture),
+ // Each part starts at zero, so every segment plays as its own
+ // file instead of pretending to begin hours in.
+ "-reset_timestamps", "1",
+ "-segment_format", "mp4",
+ pattern,
+ })
+ {
+ psi.ArgumentList.Add(arg);
+ }
+
+ var process = Process.Start(psi);
+ if (process is null)
+ return null;
+ _ = LiveFfmpeg.DrainStderrAsync(process, _logger);
+
+ var recording = new Recording(
+ Id: RecordingId.New(),
+ CameraId: camera.Id,
+ FilePath: path,
+ StartedAt: DateTime.UtcNow,
+ EndedAt: null,
+ SizeBytes: 0,
+ Codec: null,
+ HasMotion: false);
+
+ // Indexed while running, so a server that dies mid-recording still leaves
+ // a row pointing at the partial file instead of an orphan on disk.
+ await _repo.AddAsync(recording, ct);
+ _active[cameraId] = new ActiveRecording(process, recording, baseName, dir);
+ _logger.LogInformation("Recording {Camera} to {Path}", camera.Name, path);
+ return recording;
+ }
+
+ /// Returns the finished recording, or null when there was nothing to stop
+ /// OR the camera produced an empty file (see below) — the caller tells the
+ /// two apart with .
+ public async Task StopAsync(string cameraId, CancellationToken ct)
+ {
+ if (!_active.TryRemove(cameraId, out var active))
+ return null;
+
+ try
+ {
+ // "q" is ffmpeg's clean shutdown: finish the current packet, write the
+ // trailer, exit. Only if it ignores us does the process get killed.
+ await active.Process.StandardInput.WriteAsync("q");
+ await active.Process.StandardInput.FlushAsync(ct);
+ using var grace = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ grace.CancelAfter(StopGrace);
+ await active.Process.WaitForExitAsync(grace.Token);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "ffmpeg did not stop cleanly; killing it (the file may be unplayable)");
+ try { active.Process.Kill(entireProcessTree: true); } catch { /* already gone */ }
+ }
+ finally
+ {
+ active.Process.Dispose();
+ }
+
+ return await ReconcileSegmentsAsync(active, DateTime.UtcNow, ct);
+ }
+
+ // Turns whatever ffmpeg actually wrote into archive rows.
+ //
+ // Only the first segment is indexed at start (its name is predictable), so
+ // the rest are added here, and the first row is updated with its real size.
+ // Timestamps come from the segment index rather than the file's mtime: mtime
+ // is when writing FINISHED, and the archive is browsed by when footage began.
+ private async Task ReconcileSegmentsAsync(
+ ActiveRecording active, DateTime stoppedAt, CancellationToken ct)
+ {
+ var segments = Directory
+ .GetFiles(active.Directory, active.BaseName + "_*.mp4")
+ .OrderBy(f => f, StringComparer.Ordinal)
+ .ToList();
+
+ Recording? first = null;
+ for (var i = 0; i < segments.Count; i++)
+ {
+ var info = new FileInfo(segments[i]);
+ var isFirst = string.Equals(info.FullName, active.Recording.FilePath, StringComparison.OrdinalIgnoreCase);
+
+ if (!info.Exists || info.Length == 0)
+ {
+ // A trailing empty part is normal when the stop lands right on a
+ // segment boundary; an empty first part means the camera gave
+ // nothing at all. Neither belongs in the archive.
+ if (isFirst) await _repo.RemoveAsync(active.Recording.Id, ct);
+ try { if (info.Exists) info.Delete(); } catch (IOException) { /* leave it */ }
+ continue;
+ }
+
+ var startedAt = active.Recording.StartedAt + i * SegmentDuration;
+ var endedAt = i + 1 < segments.Count
+ ? active.Recording.StartedAt + (i + 1) * SegmentDuration
+ : stoppedAt;
+
+ var row = isFirst
+ ? active.Recording with { EndedAt = endedAt, SizeBytes = info.Length }
+ : new Recording(
+ Id: RecordingId.New(),
+ CameraId: active.Recording.CameraId,
+ FilePath: info.FullName,
+ StartedAt: startedAt,
+ EndedAt: endedAt,
+ SizeBytes: info.Length,
+ Codec: null,
+ HasMotion: false);
+
+ if (isFirst) await _repo.UpdateAsync(row, ct);
+ else await _repo.AddAsync(row, ct);
+
+ first ??= row;
+ }
+
+ if (first is null)
+ _logger.LogWarning("Recording under {Base} produced no data; discarding it", active.BaseName);
+ return first;
+ }
+
+ // A stopping server should not leave half-written files behind.
+ public async ValueTask DisposeAsync()
+ {
+ foreach (var cameraId in _active.Keys.ToList())
+ {
+ try { await StopAsync(cameraId, CancellationToken.None); }
+ catch (Exception ex) { _logger.LogWarning(ex, "Failed to stop recording for {Camera} on shutdown", cameraId); }
+ }
+ }
+
+ // Mirrors RecordingService.Slug so a camera lands in the same folder no
+ // matter which head recorded it.
+ private static string Slug(string name)
+ {
+ var chars = new List(name.Length);
+ foreach (var raw in name.ToLowerInvariant())
+ {
+ var c = raw == ' ' ? '-' : raw;
+ if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_') chars.Add(c);
+ }
+ return chars.Count == 0 ? "camera" : new string(chars.ToArray());
+ }
+
+ private sealed record ActiveRecording(Process Process, Recording Recording, string BaseName, string Directory);
+}
diff --git a/src/OpenIPC.Viewer.Web/Auth/WebPermission.cs b/src/OpenIPC.Viewer.Web/Auth/WebPermission.cs
index f821198..efcad88 100644
--- a/src/OpenIPC.Viewer.Web/Auth/WebPermission.cs
+++ b/src/OpenIPC.Viewer.Web/Auth/WebPermission.cs
@@ -17,6 +17,11 @@ public enum WebPermission
// Everything that changes the installation: camera/group/layout CRUD,
// discovery, config import/export, sessions, users.
Manage = 16,
+ // Speaking through the camera's speaker. Its own flag rather than a reuse:
+ // it is neither "change the installation" nor PTZ, and it is the one action
+ // here that reaches people standing in front of the camera — worth being
+ // able to grant and withhold on its own.
+ Talk = 32,
- All = ViewLive | ViewArchive | Ptz | Export | Manage,
+ All = ViewLive | ViewArchive | Ptz | Export | Manage | Talk,
}
diff --git a/src/OpenIPC.Viewer.Web/Backend/WebBackend.cs b/src/OpenIPC.Viewer.Web/Backend/WebBackend.cs
index 47094cc..618d2d4 100644
--- a/src/OpenIPC.Viewer.Web/Backend/WebBackend.cs
+++ b/src/OpenIPC.Viewer.Web/Backend/WebBackend.cs
@@ -4,8 +4,12 @@
using OpenIPC.Viewer.Core.Onvif;
using OpenIPC.Viewer.Core.Onvif.Discovery;
using OpenIPC.Viewer.Core.Persistence;
+using OpenIPC.Viewer.Core.Recording;
using OpenIPC.Viewer.Core.Services;
using OpenIPC.Viewer.Core.Settings;
+using OpenIPC.Viewer.Core.Snapshots;
+using OpenIPC.Viewer.Core.Video;
+using OpenIPC.Viewer.Devices.Backchannel;
using OpenIPC.Viewer.Devices.Discovery;
using OpenIPC.Viewer.Devices.Majestic;
using OpenIPC.Viewer.Devices.Onvif;
@@ -31,6 +35,16 @@ public static IServiceCollection AddWebBackend(this IServiceCollection services,
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ // The archive the desktop head recorded: listed, played and deleted from
+ // the browser. The web server doesn't record yet, it reads the index.
+ services.AddSingleton();
+ // Recording started from the browser: its own ffmpeg process per camera,
+ // writing into the same folder and table the desktop head uses.
+ services.AddSingleton();
+ // Kept snapshots share the desktop's library: same folder layout, same
+ // table. SnapshotService itself can't be reused (it needs the Video
+ // layer), so SnapshotLibraryApi writes the rows against this repo.
+ services.AddSingleton();
// Browser-safe config export/import (never serializes camera passwords).
services.AddSingleton();
// CRUD goes through the directory service so credentials land in the
@@ -44,6 +58,10 @@ public static IServiceCollection AddWebBackend(this IServiceCollection services,
// stateless (one short-lived HTTP call per operation), so a singleton is
// safe and matches the desktop registration in SharedComposition.
services.AddSingleton();
+ // Push-to-talk: ffmpeg can't do an ONVIF backchannel, so this opens its
+ // own RTSP session per talker. Stateless between calls, like the ONVIF
+ // client — the session lives for as long as the browser holds the key.
+ services.AddSingleton();
// Discovery (same source set and aggregator as the desktop dialog) plus
// the ONVIF probe chain the add flow runs on a chosen candidate.
services.AddSingleton();
@@ -53,6 +71,9 @@ public static IServiceCollection AddWebBackend(this IServiceCollection services,
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ // The camera-settings panel renders whatever knobs the live config.json
+ // exposes, so it needs the same schema walk the desktop editor uses.
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/OpenIPC.Viewer.Web/WebServer.cs b/src/OpenIPC.Viewer.Web/WebServer.cs
index f3def0d..f7f6a25 100644
--- a/src/OpenIPC.Viewer.Web/WebServer.cs
+++ b/src/OpenIPC.Viewer.Web/WebServer.cs
@@ -183,6 +183,11 @@ private static void MapEndpoints(WebApplication app, WebServerOptions options)
app.MapLayoutEndpoints();
app.MapLiveEndpoints();
app.MapPtzEndpoints();
+ app.MapTalkEndpoints();
+ app.MapMajesticEndpoints();
+ app.MapSnapshotEndpoints();
+ app.MapSnapshotLibraryEndpoints();
+ app.MapRecordingEndpoints();
app.MapDiscoveryEndpoints();
app.MapSystemEndpoints();
app.MapUserEndpoints();