Skip to content

Web console: archive, snapshots, camera settings and two-way audio#60

Merged
keyldev merged 13 commits into
mainfrom
feat/phase-21-web-react-spa
Jul 22, 2026
Merged

Web console: archive, snapshots, camera settings and two-way audio#60
keyldev merged 13 commits into
mainfrom
feat/phase-21-web-react-spa

Conversation

@keyldev

@keyldev keyldev commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Builds on the React SPA web console and closes most of the gap with the desktop head. Everything here is server-side + SPA only — no change to the video pipeline, the desktop UI, or the mobile heads.

Live grid

  • Stills mode — periodic snapshots instead of live streams, refresh interval
    2–60 s. A 25-cell wall costs one HTTP GET per camera per interval instead of an
    ffmpeg session each; the camera encodes the JPEG itself when it runs Majestic.
  • Digital zoom in a tile: mouse wheel, drag to pan, buttons for touch. The
    transform lives on the tile's slot, never on the pooled <video>, so zoom
    can't follow a camera into another cell.
  • Kiosk mode — chrome-free full-viewport wall with optional page cycling.
    Uses the Fullscreen API when granted and falls back to a fixed overlay when it
    isn't (embedded/iframed pages refuse it outright).
  • Wall preferences live in the browser, not the install: the same layout is
    watched from a control-room screen and from someone's laptop.

Archive

  • Recording start/stop from the browser, written into the same folder and table
    the desktop head uses (-c copy, segmented, q on stdin so the MP4 trailer is
    written).
  • Calendar + date filtering, paging, and clip export (fast -c copy or precise
    re-encode), streamed as a download without leaving a copy on the server.
  • Playback is a ranged file response, so seeking is the browser's own.

Snapshot library

  • Stills taken from the browser can be kept: same folder layout, same
    .thumbs/<id>.jpg naming and the same table as the desktop's
    SnapshotService, so a still taken from a phone shows up in the desktop
    gallery and vice versa.
  • Thumbnails are produced with ffmpeg — already a dependency of this head —
    rather than pulling the Video layer's Skia thumbnailer into the server.
  • Pixel dimensions are read from the JPEG frame header; no imaging library
    needed to record what the desktop records.
  • Gallery page with lightbox, download and delete, under the Archive tab.

Camera settings (Majestic)

  • Read the live config.json, edit fields, post raw JSON, set day/night, read
    /metrics. Schema-driven like the desktop editor (IMajesticConfigSchema), so
    it renders whatever knobs the firmware actually exposes instead of a fixed form
    that drifts between builds.
  • ISP fields get their own panel: Majestic re-reads those live, so tuning the
    picture does not restart the encoder. Fields that do restart it are flagged.
  • Edits travel as (path, value) and are applied against a freshly read copy of
    the camera's config, so a stale tab can't revert someone else's change.

Two-way audio

  • Listen: audio rides in the existing live stream (transcoded to AAC, ~1% of
    the video bitrate) rather than a second session, so nobody's picture restarts
    when they unmute.
  • Push-to-talk: the browser sends 16-bit PCM over a WebSocket; G.711
    encoding and RTP packetizing happen server-side in the same Core code the
    desktop talks with. One talker per camera — a speaker is exclusive.
  • Held, not toggled, and the camera is released when the route changes.

Permissions

  • New Talk flag, separate from Ptz/Manage: it is the one action that
    reaches people standing in front of the camera.
  • ViewArchive now gates the archive and the snapshot library; Export gates
    clip export. Both flags previously existed without gating anything.
  • Camera settings are Manage-only including readsconfig.json carries
    the camera's own logins and, on many builds, the Wi-Fi PSK.

Fixes

  • The live WebSocket was write-only, so a viewer's close frame went unnoticed and
    the shared ffmpeg process outlived its last viewer. Verified: 8 streams → 0
    after the last tile releases.
  • A camera that never answered a Majestic call was reported as success (204): our
    own call deadline surfaced as the same cancellation as an abandoned request.
    Now 504.
  • Player: a viewer joining an already-running stream got a buffer starting at
    t=90s with currentTime=0 and sat paused forever. A watchdog now jumps to the
    live edge.
  • ViewArchive was missing from the users page, which quietly made the archive
    ungrantable to anyone created from the web console.
  • Recording a camera that delivered nothing left a 0-byte file and an archive row.

Related

Type

  • Bug fix
  • Feature
  • Refactor / cleanup
  • Docs / CI
  • Other:

Checklist

  • Builds with 0 warnings (TreatWarningsAsErrors=true).
  • Tests pass (dotnet test); new Core logic has unit tests.
  • No layering violation — App references Core only (Infrastructure / Video / Devices wired via DI in a head).
  • Scope stays within one phase (didn't pull work from a later phase's "Не входит").
  • README / docs updated if public commands, options, or setup changed.

Platforms tested

  • Windows
  • Linux
  • macOS
  • Android
  • iOS
  • CI build only

Screenshots / notes

keyldev added 13 commits July 22, 2026 01:27
- GET /api/v1/cameras/{id}/snapshot returns a fresh JPEG, preferring
  Majestic's own /image.jpg and falling back to one short ffmpeg pull for
  cameras that don't have it
- camera button on every tile and on the single-camera page; the modal
  fetches as a blob so a camera that doesn't answer says so instead of
  showing a broken image, and the download keeps a dated filename
- guarded like live video: ViewLive plus the caller's camera subset
- nothing is stored server-side yet; the shared, indexed snapshot library
  the desktop keeps is a separate slice
- GET /api/v1/recordings lists what the desktop head recorded, scoped to
  the caller's cameras; /stream serves the file with range support so the
  browser's own <video> seeking just works
- only files indexed in the database can be served: the path comes from
  the row, so no request can reach an arbitrary file
- listing and playback need ViewArchive (the flag finally has a use);
  deleting is Manage, and drops the row before the file so a failed
  delete leaves a leftover rather than a broken entry
- H.265 recordings are flagged unplayable in the DTO and offered as a
  download instead of a dead player
- recording itself is still started from the desktop app
- ffmpeg no longer drops audio (-an); it transcodes whatever the camera
  sends to AAC, because G.711/PCM/Opus don't play in MSE
- audio rides in the existing shared stream rather than a second session:
  64 kbps is about a percent of the video, so carrying it for everyone is
  cheaper than a second ffmpeg for the one who listens — and nobody's
  picture restarts when they unmute
- the codec string is built from the init segment's actual boxes, so a
  camera without sound is still declared video-only and keeps working
- per-tile listen toggle, shown only when the stream really has audio;
  tiles stay muted by default, which is also what autoplay requires
- new components/Icon.tsx: inline SVG in the Lucide style the desktop app
  already uses, inheriting currentColor and sized by the caller
- emoji rendered in a system font that differs per platform, ignored the
  theme colour and didn't scale with their button; the same went for the
  fullwidth plus and friends
- labels in strings.ts no longer carry a glyph — the icon goes in the
  markup next to the text
- WebRecorder spawns one ffmpeg per camera writing the stream to disk with
  -c copy, and indexes the row in the same table the desktop head uses;
  files land in the same <recordings>/<camera>/<date>/ layout, so one
  archive stays navigable by hand and both heads see each other's clips
- stopping sends "q" on ffmpeg's stdin instead of killing it: an MP4 needs
  its trailer, and a killed process leaves a file nothing will play
- a camera that delivered nothing produces no archive entry — the empty
  file and its row are dropped and the caller gets nothing_recorded, which
  the camera page reports instead of silently flipping the button
- recording is Manage: it writes to the host's disk until someone stops it
- /api/v1/recordings takes from/to; a new /recordings/calendar returns
  just timestamps and sizes for a range
- days are grouped in the BROWSER, not on the server: which day a clip
  belongs to depends on the viewer's time zone, and a web client may sit
  in a different one than the machine that recorded it (Core's
  CalendarActivity solves the same problem for the desktop, where the two
  are always the same host)
- month grid shades each day by how busy it was; clicking one filters the
  list, clicking again or "whole archive" clears it
- /api/v1/recordings answers with a page ({total, offset, limit, items})
  instead of a silently capped array — the old 200-row ceiling hid the
  rest of the archive with nothing in the response to say so
- new /recordings/{id}/export cuts start..end with ffmpeg and streams the
  result as a download; nothing is stored, since keeping a copy would grow
  the archive behind the person's back
- two cut modes, same as the desktop exporter: a fast stream copy that can
  only begin on a keyframe, or an exact-frame re-encode on request
- export needs the Export permission, which until now gated nothing
- player gets in/out marks taken from the playhead rather than typed
  timecodes
Recording:
- ffmpeg now writes through the segment muxer (10 minutes by default,
  OPENIPC_WEB_SEGMENT_SECONDS to change): a night in one file is painful
  to download and to seek, and a corrupt tail would take the whole night
  with it. Each part is closed on its own, so finished segments survive a
  server that dies mid-recording.
- on stop the parts are reconciled into archive rows; segment start times
  come from the segment index rather than file mtime, which is when the
  writing finished, not when the footage began. Empty parts (a stop that
  lands on a boundary, or a camera that gave nothing) are dropped.

Navigation:
- the sidebar had grown to seven entries, which crowded the mobile tab
  strip and mixed daily viewing with once-a-month configuration. Now:
  Grid, Cameras, Archive, Settings — with groups, users and system as
  tabs under Settings, and the old paths redirecting.
- discovery moves next to "Add camera" on the Cameras page, since that is
  what it is: another way to add one.
- typing a URL you may not use (users, discovery) now lands on a page you
  can, instead of a screen stuck loading behind a 403.
- Grid-wide stills mode: periodic snapshots instead of live streams, so a big
  wall costs one HTTP GET per camera per interval instead of an ffmpeg session
  each; interval 2-60s, remembered per browser.
- Digital zoom in a tile (wheel, drag, buttons) on the tile's own slot, never on
  the pooled <video>, so the zoom can't follow a camera into another cell.
- Kiosk: chrome-free full-viewport wall with optional page cycling; uses the
  Fullscreen API when granted and falls back to a fixed overlay when it isn't.
- Fix a pre-existing leak: the live WebSocket was never read, so a viewer's close
  frame went unnoticed and the shared ffmpeg outlived its last viewer. Verified
  8 streams -> 0 after the last tile releases.
- Api/MajesticApi.cs: read the parsed config, apply field edits, post raw
  config.json, set day/night, read /metrics. Schema-driven like the desktop
  editor (IMajesticConfigSchema), so it renders whatever knobs the live
  firmware exposes instead of a fixed form.
- Manage-gated including reads: config.json carries the camera's own logins
  and often the Wi-Fi PSK, which is not what ViewLive grants.
- Edits are read-modify-write against a fresh copy of the camera's config and
  travel as (path, value), so a stale tab can't revert someone else's change.
- ISP fields get their own panel: Majestic re-reads those live, so tuning the
  picture doesn't restart the encoder. Restart-y fields are flagged in the UI.
- Probe once when the IsMajestic flag is unset, like the desktop does — a
  hand-added camera would otherwise never show the panel.
- A camera timeout no longer answers 204: our own call deadline was being
  read as "the viewer left", so silence looked like success. Now 504.
- Api/SnapshotLibraryApi.cs: keep a still (capture, write, index), list it,
  serve it, delete it. Storage matches SnapshotService exactly — same folder
  layout, same .thumbs naming, same table — so a still taken from a phone
  shows up in the desktop browser and vice versa. Verified against the two
  snapshots the desktop wrote in June.
- Thumbnails via ffmpeg, which this head already shells out to, instead of
  pulling the Video layer's Skia thumbnailer into the server.
- Pixel dimensions read from the JPEG frame header: the desktop stores them to
  prove a capture was full-resolution, and no imaging library is needed to get
  two bytes.
- Saving needs ViewArchive (it adds to an archive the caller can already read),
  deleting needs Manage — same split as recordings.
- Archive page grows tabs (recordings / snapshots) rather than a fifth nav
  entry: the sidebar doubles as the phone tab bar.
- Api/TalkApi.cs: probe the camera's backchannel, then bridge a WebSocket of
  16-bit PCM into it — G.711 encoding and RTP packetizing stay server-side, in
  the same Core code the desktop talks with.
- PushToTalkController is not used: it owns a microphone on the machine running
  it, and here the microphone is three network hops away. Same reason PtzApi
  skips PtzController.
- One talker per camera. A speaker is exclusive; two streams interleaved into
  one RTP session would come out as noise, so a second attempt is refused.
- New Talk permission rather than reusing Ptz/Manage: it is the one action that
  reaches people standing in front of the camera.
- The client follows the negotiated format (an AudioContext at the camera's
  rate resamples the mic), holds to talk, and releases the camera when the
  route changes.
- Microphones need a secure context, so over plain HTTP the button is replaced
  by a note saying so instead of failing at the press.
- Fix: ViewArchive was missing from the users page, which quietly made the
  archive ungrantable to anyone created from the web console.
- Latest stable release and total download count, next to the build badge.
- Live from the GitHub API rather than written out, so the numbers can't
  go stale between releases.
@keyldev
keyldev merged commit 16e0e6b into main Jul 22, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant