Skip to content

Commit 16e0e6b

Browse files
authored
Merge pull request #60 from OpenIPC/feat/phase-21-web-react-spa
Web console: archive, snapshots, camera settings and two-way audio
2 parents b7bfb36 + a89b376 commit 16e0e6b

43 files changed

Lines changed: 4325 additions & 109 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ Cross-platform desktop and mobile viewer for OpenIPC IP cameras.
44
Built with .NET 9 / 10 and Avalonia 12.
55

66
[![build](https://github.com/OpenIPC/viewer/actions/workflows/build.yml/badge.svg)](https://github.com/OpenIPC/viewer/actions/workflows/build.yml)
7+
[![latest release](https://img.shields.io/github/v/release/OpenIPC/viewer?sort=semver&label=release)](https://github.com/OpenIPC/viewer/releases/latest)
8+
[![downloads](https://img.shields.io/github/downloads/OpenIPC/viewer/total?label=downloads)](https://github.com/OpenIPC/viewer/releases)
79

810
> Status: **beta** — releases ship as standalone builds for Windows / Linux /
911
> macOS / Android from the [releases page](https://github.com/OpenIPC/viewer/releases).

docs/web-server.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ LAN. No cloud, no extra services — one admin password and a port.
1212
* **Find cameras on the network** — ONVIF, mDNS and an opt-in subnet sweep, then
1313
add what you found without leaving the browser.
1414
* **PTZ** — pan/tilt/zoom and presets for cameras that support it.
15+
* **Snapshots** — grab a still from any camera and download it.
16+
* **Archive** — record a camera from the browser, then browse by calendar, play,
17+
download, and export a marked fragment; seeking works because playback is a
18+
ranged file response. Both heads write into the same folders and index, so a
19+
clip recorded here shows up in the desktop app too.
1520
* **Users and permissions** — accounts with permission flags and, if you like, a
1621
per-user subset of cameras.
1722
* **H.264 over WebSocket** (fMP4 + MSE); H.265 is transcoded on the fly. One
@@ -66,6 +71,7 @@ there is no TLS at this layer; for anything beyond your trusted LAN, put it
6671
| Env var | Meaning |
6772
|-------------------------------|--------------------------------------------------|
6873
| `OPENIPC_WEB_ADMIN_PASSWORD` | Built-in admin password. Unset → random, logged on start. |
74+
| `OPENIPC_WEB_SEGMENT_SECONDS` | Length of one recording segment. Default 600 (10 min). |
6975

7076
---
7177

@@ -104,7 +110,7 @@ Out of the box there is one account: the built-in administrator from
104110
|----------------|---------------------------------------------------------------|
105111
| `watch live` | see the camera list and live video |
106112
| `PTZ` | move the camera and manage its presets |
107-
| `export` | reserved for archive export |
113+
| `export` | cut and download a fragment of a recording |
108114
| `manage` | everything that changes the installation — cameras, groups, layouts, discovery, backups, sessions, users |
109115

110116
and, optionally, a **subset of cameras**: leave *All cameras* on for full access,

docs/web-server.ru.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ OpenIPC-камерам по локальной сети. Без облака и
1515
* **Поиск камер в сети** — ONVIF, mDNS и опциональный обход подсети, с добавлением
1616
найденного прямо из браузера.
1717
* **PTZ** — поворот, наклон, зум и пресеты для камер, которые это умеют.
18+
* **Снимки** — кадр с любой камеры, с возможностью скачать.
19+
* **Архив** — запись камеры прямо из браузера, затем календарь, просмотр, скачивание
20+
и экспорт отмеченного фрагмента; перемотка работает, потому что файл отдаётся по
21+
диапазонам. Обе морды пишут в одни и те же папки и индекс, так что записанное
22+
здесь видно и в десктопе.
1823
* **Пользователи и права** — учётные записи с флагами прав и, при желании,
1924
подмножеством камер на каждого.
2025
* **H.264 по WebSocket** (fMP4 + MSE); H.265 транскодируется на лету. Одна
@@ -69,6 +74,7 @@ OpenIPC-камерам по локальной сети. Без облака и
6974
| Переменная окружения | Значение |
7075
|-------------------------------|------------------------------------------------------|
7176
| `OPENIPC_WEB_ADMIN_PASSWORD` | Пароль встроенного админа. Не задан → случайный, печатается в лог. |
77+
| `OPENIPC_WEB_SEGMENT_SECONDS` | Длина одного сегмента записи. По умолчанию 600 (10 мин). |
7278

7379
---
7480

@@ -108,7 +114,7 @@ OpenIPC-камерам по локальной сети. Без облака и
108114
|------------------|-------------------------------------------------------------|
109115
| `смотреть эфир` | видеть список камер и живое видео |
110116
| `PTZ` | двигать камеру и управлять её пресетами |
111-
| `экспорт` | зарезервировано под экспорт архива |
117+
| `экспорт` | вырезать и скачать фрагмент записи |
112118
| `управление` | всё, что меняет установку: камеры, группы, раскладки, поиск, бэкапы, сессии, пользователи |
113119

114120
и, при желании, **подмножество камер**: оставьте «Все камеры» для полного доступа

src/OpenIPC.Viewer.Web.Client/src/App.tsx

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import { Camera } from './pages/Camera'
88
import { Grid } from './pages/Grid'
99
import { Discovery } from './pages/Discovery'
1010
import { Users } from './pages/Users'
11+
import { RequireManage, Settings, SettingsIndex } from './pages/Settings'
12+
import { Recordings } from './pages/Recordings'
13+
import { Snapshots } from './pages/Snapshots'
1114
import { Groups } from './pages/Groups'
1215
import { System } from './pages/System'
1316

@@ -31,10 +34,34 @@ export function App() {
3134
<Route path="/cameras" element={<Cameras />} />
3235
<Route path="/camera/:id" element={<Camera />} />
3336
<Route path="/grid" element={<Grid />} />
34-
<Route path="/discovery" element={<Discovery />} />
35-
<Route path="/groups" element={<Groups />} />
36-
<Route path="/system" element={<System />} />
37-
<Route path="/users" element={<Users />} />
37+
<Route path="/recordings" element={<Recordings />} />
38+
<Route path="/snapshots" element={<Snapshots />} />
39+
<Route
40+
path="/discovery"
41+
element={
42+
<RequireManage>
43+
<Discovery />
44+
</RequireManage>
45+
}
46+
/>
47+
{/* Configuration lives under /settings; the old flat paths still
48+
resolve so existing links and bookmarks don't break. */}
49+
<Route path="/settings" element={<Settings />}>
50+
<Route index element={<SettingsIndex />} />
51+
<Route path="groups" element={<Groups />} />
52+
<Route
53+
path="users"
54+
element={
55+
<RequireManage>
56+
<Users />
57+
</RequireManage>
58+
}
59+
/>
60+
<Route path="system" element={<System />} />
61+
</Route>
62+
<Route path="/groups" element={<Navigate to="/settings/groups" replace />} />
63+
<Route path="/users" element={<Navigate to="/settings/users" replace />} />
64+
<Route path="/system" element={<Navigate to="/settings/system" replace />} />
3865
<Route path="*" element={<Navigate to="/cameras" replace />} />
3966
</Route>
4067
</Routes>

src/OpenIPC.Viewer.Web.Client/src/api.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,71 @@ export type WebUserDto = {
4242
cameras: string[] | null
4343
}
4444

45+
// One recorded file. `playable` is false for H.265, which browsers won't decode
46+
// — the UI then offers a download instead of a dead player.
47+
export type RecordingDto = {
48+
id: string
49+
cameraId: string
50+
cameraName: string | null
51+
fileName: string
52+
startedAt: string
53+
endedAt: string | null
54+
durationSeconds: number | null
55+
sizeBytes: number
56+
codec: string | null
57+
hasMotion: boolean
58+
playable: boolean
59+
}
60+
61+
// One dot on the archive calendar. Deliberately minimal: the browser groups
62+
// these into days in its own time zone.
63+
// A page of the archive: the rows plus where they sit in the whole set.
64+
export type RecordingPageDto = {
65+
total: number
66+
offset: number
67+
limit: number
68+
items: RecordingDto[]
69+
}
70+
71+
export type CalendarPointDto = { startedAt: string; sizeBytes: number }
72+
4573
export type GroupDto = { id: number; name: string; sortOrder: number }
4674

4775
export type PtzPresetDto = { token: string; name: string }
4876

77+
// A snapshot kept in the shared library (same rows the desktop browser reads).
78+
export type SnapshotDto = {
79+
id: string
80+
cameraId: string
81+
cameraName: string | null
82+
takenAt: string
83+
width: number
84+
height: number
85+
hasThumb: boolean
86+
source: string
87+
}
88+
89+
// A Majestic config.json, flattened by the server into whatever scalar knobs the
90+
// live config actually exposes — the schema drifts between firmware builds, so
91+
// nothing here is a fixed field list.
92+
export type MajesticFieldDto = {
93+
path: string
94+
section: string
95+
key: string
96+
kind: 'string' | 'bool' | 'int' | 'number' | 'enum'
97+
value: string
98+
options: string[] | null
99+
// Changing this one restarts the video pipeline (the tile will blink).
100+
restart: boolean
101+
}
102+
103+
export type MajesticDto = {
104+
info: { model: string | null; firmware: string | null; chip: string | null; uptime: string | null } | null
105+
nightMode: 'unknown' | 'day' | 'night' | 'auto'
106+
sections: { name: string; fields: MajesticFieldDto[] }[]
107+
rawJson: string
108+
}
109+
49110
export type LayoutDto = {
50111
id: number
51112
name: string
@@ -126,6 +187,14 @@ export class ApiError extends Error {
126187
}
127188
}
128189

190+
// Drops empty values so the URL only carries the filters that are actually set.
191+
function query(params: Record<string, string | number | undefined>): string {
192+
const pairs = Object.entries(params)
193+
.filter(([, v]) => v !== undefined && v !== '')
194+
.map(([k, v]) => [k, String(v)] as [string, string])
195+
return pairs.length ? '?' + new URLSearchParams(pairs).toString() : ''
196+
}
197+
129198
async function req<T>(method: string, path: string, body?: unknown): Promise<T> {
130199
const res = await fetch(path, {
131200
method,
@@ -174,6 +243,56 @@ export const api = {
174243
req<CameraDraftDto>('POST', '/api/v1/discovery/probe', body),
175244
addDiscovered: (body: DiscoveryAdd) => req<CameraDto>('POST', '/api/v1/discovery/add', body),
176245

246+
// The recorded archive. Playback is a ranged file response, so the URL goes
247+
// straight into a <video> and the browser does its own seeking.
248+
recordings: (filter: { cameraId?: string; from?: string; to?: string; offset?: number; limit?: number } = {}) =>
249+
req<RecordingPageDto>(
250+
'GET',
251+
'/api/v1/recordings' +
252+
query({
253+
...filter,
254+
offset: filter.offset ? String(filter.offset) : undefined,
255+
limit: filter.limit ? String(filter.limit) : undefined,
256+
}),
257+
),
258+
// Cut of a recording, as a download URL (start/end are seconds into the file).
259+
recordingExportUrl: (id: string, start: number, end: number, precise: boolean) =>
260+
`/api/v1/recordings/${id}/export?start=${start.toFixed(2)}&end=${end.toFixed(2)}` +
261+
(precise ? '&precise=true' : ''),
262+
recordingCalendar: (filter: { cameraId?: string; from?: string; to?: string } = {}) =>
263+
req<CalendarPointDto[]>('GET', '/api/v1/recordings/calendar' + query(filter)),
264+
recordingStreamUrl: (id: string, download = false) =>
265+
`/api/v1/recordings/${id}/stream` + (download ? '?download=true' : ''),
266+
deleteRecording: (id: string) => req<void>('DELETE', `/api/v1/recordings/${id}`),
267+
// Camera ids currently being recorded by this server.
268+
activeRecordings: () => req<string[]>('GET', '/api/v1/recordings/active'),
269+
startRecording: (cameraId: string) =>
270+
req<{ id: string; startedAt: string }>('POST', `/api/v1/cameras/${cameraId}/recording/start`),
271+
stopRecording: (cameraId: string) =>
272+
req<{ id: string; sizeBytes: number }>('POST', `/api/v1/cameras/${cameraId}/recording/stop`),
273+
274+
// A fresh still, straight from the camera (Majestic /image.jpg when available,
275+
// otherwise one ffmpeg pull). Used as an <img> src and as a download target,
276+
// so it's a URL rather than a fetch wrapper.
277+
snapshotUrl: (id: string) => `/api/v1/cameras/${id}/snapshot`,
278+
279+
// Keep a still: captured the same way as the preview above, but written into
280+
// the shared library so the desktop gallery sees it too.
281+
saveSnapshot: (cameraId: string) => req<SnapshotDto>('POST', `/api/v1/cameras/${cameraId}/snapshots`),
282+
snapshots: (q: { cameraId?: string; offset?: number; limit?: number } = {}) => {
283+
const p = new URLSearchParams()
284+
if (q.cameraId) p.set('cameraId', q.cameraId)
285+
if (q.offset) p.set('offset', String(q.offset))
286+
if (q.limit) p.set('limit', String(q.limit))
287+
const qs = p.toString()
288+
return req<{ total: number; offset: number; limit: number; items: SnapshotDto[] }>(
289+
'GET', '/api/v1/snapshots' + (qs ? '?' + qs : ''))
290+
},
291+
// A URL rather than a fetch: these are <img> sources and download targets.
292+
snapshotImageUrl: (id: string, thumb = false, download = false) =>
293+
`/api/v1/snapshots/${id}/image` + (thumb ? '?thumb=true' : download ? '?download=true' : ''),
294+
deleteSnapshot: (id: string) => req<void>('DELETE', `/api/v1/snapshots/${id}`),
295+
177296
// PTZ. Movement is stateless on the server: each move carries a self-stop
178297
// timeout, so the caller must repeat it while a direction is held (see PtzPad)
179298
// and send stop on release. A camera that never gets the stop halts on its own.
@@ -188,6 +307,26 @@ export const api = {
188307
ptzDeletePreset: (id: string, token: string) =>
189308
req<void>('DELETE', `/api/v1/cameras/${id}/ptz/presets/${encodeURIComponent(token)}`),
190309

310+
// Camera settings (Majestic config.json). Manage-only on the server, reads
311+
// included: the config carries the camera's own logins.
312+
majestic: (id: string) => req<MajesticDto>('GET', `/api/v1/cameras/${id}/majestic`),
313+
// Only changed fields travel; the server re-reads the camera's config and
314+
// applies them onto it, so a stale tab can't revert someone else's edit.
315+
majesticApply: (id: string, edits: { path: string; value: string }[]) =>
316+
req<{ applied: number; restart: boolean }>('POST', `/api/v1/cameras/${id}/majestic/config`, { edits }),
317+
majesticRaw: (id: string, json: string) =>
318+
req<void>('POST', `/api/v1/cameras/${id}/majestic/raw`, { json }),
319+
majesticNight: (id: string, mode: 'day' | 'night' | 'auto') =>
320+
req<void>('POST', `/api/v1/cameras/${id}/majestic/night`, { mode }),
321+
majesticMetrics: (id: string) =>
322+
req<{ name: string; value: number }[]>('GET', `/api/v1/cameras/${id}/majestic/metrics`),
323+
324+
// Can this camera be talked to, and is someone already doing it? A real RTSP
325+
// probe, so it costs a round-trip to the camera — the UI asks on press, not
326+
// on page load.
327+
talkProbe: (id: string) =>
328+
req<{ supported: boolean | null; busy: boolean }>('GET', `/api/v1/cameras/${id}/talk`),
329+
191330
system: () =>
192331
req<{ version: string; cameras: number; groups: number; sessions: number; streams: number }>(
193332
'GET',

src/OpenIPC.Viewer.Web.Client/src/auth.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type Auth = {
1616
logout: () => Promise<void>
1717
}
1818

19-
export type Permission = 'ViewLive' | 'ViewArchive' | 'Ptz' | 'Export' | 'Manage'
19+
export type Permission = 'ViewLive' | 'ViewArchive' | 'Ptz' | 'Export' | 'Manage' | 'Talk'
2020

2121
const Ctx = createContext<Auth | null>(null)
2222

0 commit comments

Comments
 (0)