Call Flows
The other chapters are reference material, organized by topic. This one is a guided tour: it follows a few real user actions end to end, scene by scene, through the actual code. If you are returning to the codebase and have lost the mental model, start here, then dip into the reference chapters for depth.
How to read this
Each flow opens with a sequence diagram (the whole flow at a glance), then walks
the steps in detail. Every step has a bold plain-language lead, says what happens
and why it matters, names the code it lives in (file.ts plus the function or
symbol), and ends with two links: source opens that exact code on GitHub, and
the → link goes to the reference chapter that explains that layer in full.
The source links point at main and the function may drift by a few lines
over time, so the symbol name in the text is the durable anchor.
The layers a request moves through, top to bottom:
pages/ route-level views (what you see)
components/ reusable UI
hooks/ component logic (React Query, stream lifecycle, ...)
stores/ global state (Zustand: profile, auth, settings, notifications)
services/ startup orchestration + native plugin glue
api/ thin ZoneMinder request wrappers
lib/ pure helpers (http, url-builder, crypto, ...)
<native> Capacitor plugins (iOS/Android/Electron)
Flow 1: Cold start to an authenticated session
When you launch the app with a profile already saved, a lot happens before the monitor list appears, but the shape is simple: the app restores your saved profile, throws away any leftover session from last time, points its HTTP client at your server, and then does the slow network setup (logging in, fetching server details) in the background so the splash screen never sits there waiting on the network.
sequenceDiagram
autonumber
participant Shell as App shell
participant Store as Profile store
participant Boot as Bootstrap services
participant Auth as Auth + HTTP client
participant ZM as ZoneMinder server
participant UI as Monitors page
Shell->>Store: import store, persist restores the saved profile
Store->>Boot: onRehydrateStorage then handleProfileRehydration
Boot->>Auth: clear stale auth and query cache
Boot->>Auth: create and install the API client
Boot->>Store: mark initialized
Note over Shell,UI: UI unblocks here. Splash hides, routes render.
Boot->>Boot: run bootstrap tasks in the background
Boot->>Auth: apply SSL trust, then log in
Auth->>ZM: POST /host/login.json
ZM-->>Auth: access and refresh tokens
Boot->>ZM: servers, timezone, ZMS path, multi-port
UI->>ZM: GET /monitors.json once authenticated
ZM-->>UI: monitor list, rendered
Before React mounts, the safety nets go up.
main.tsxis the very first code to run. It installs the global error handlers (so an uncaught error anywhere ends up in the in-app log instead of vanishing), tags the<html>element as native vs web, and starts the iOS safe-area bootstrap, then renders<App/>. The reason this is first: nothing that happens later should be invisible. source · → Application LifecycleThe stores wake up and read the disk. Importing the app pulls in the Zustand stores.
stores/profile.tsis wrapped inpersist(...), so the moment it loads it reads your saved profiles from local storage.App.tsxalso builds the single React QueryqueryClientand registers it withsetQueryClient()so non-React code can reach the same cache later. source · → State Management with ZustandRehydration decides what kind of start this is. Once persist finishes reading,
stores/profile.tsfiresonRehydrateStorage, which callshandleProfileRehydrationinservices/profile-initialization.ts. No saved profile sends you to the Profiles screen and stops; a valid one continues. Any error still flipsisInitialized: true, which guarantees the splash can never hang forever. source · → Application LifecycleThrow away last session’s leftovers.
clearStaleStatecallslogout()on the auth store andclearQueryCache(). A persisted token or cached monitor list from a previous run must not be shown before we have re-authenticated this run, especially after switching servers. source · → State Management with ZustandPoint the HTTP client at this server.
initializeApiClientcallssetApiClient(createStoreApiClient(profile.apiUrl, reLogin)). From here on everyhttpGet/httpPostresolves throughgetApiClient(), so this one call decides which server all later requests talk to. ThereLogincallback is what lets the client quietly re-authenticate a lapsed token. source · → API and Data FetchingLet the user in (the important bit).
setInitializationState(true)flipsisInitializedandisBootstrapping. This is the moment the UI becomes usable: the splash hides, routing renders, and the slow network setup is kicked off without being awaited, so it runs in the background. The app is interactive even while it is still logging in. source · → Application LifecycleBackground setup, SSL trust first.
performBootstraprunsbootstrapSSLTrustbefore any network call. For a self-signed server it applies the trust override (and, the first time, shows the trust-on-first-use dialog) vialib/ssl-trust.tsapplySSLTrustSetting, dispatching to the nativessl-trustplugin or Electron. If trust were applied after the login call, a self-signed server would reject it. source · → External Network EndpointsLog in.
bootstrapAuthdecrypts the stored password and calls the auth store’slogin(), which is single-flight (concurrent callers share one request) and POSTs to/host/login.json. On success it stores the access and refresh tokens and setsisAuthenticated: true. A failure here is only a warning, since some servers do not require auth. This is the step that produces the authenticated session. source · → API and Data FetchingFetch the server’s shape. Still in the background,
performBootstraprunsbootstrapServerMap(multi-server routing),bootstrapTimezone,bootstrapZmsPath,bootstrapGo2RTCPath, andbootstrapMultiPortStreaming, each wrapped on its own so one failure does not sink the rest, then clearsisBootstrapping. These resolve the streaming and routing details the monitor and montage views rely on. source · → External Network EndpointsHide the splash, land on a page. An effect in
App.tsxhides the native splash onceisInitializedis set, andAppRoutesnavigates to your last route (or/monitors) and starts the periodic token refresh (useTokenRefresh). source · → Pages and ViewsFirst real data.
pages/Monitors.tsxruns a React Query for the monitor list, keyed by profile and gated on ``isAuthenticated``, so it only fires after step 8 set the token. It polls at the bandwidth-profile interval. The rendered monitor list is what you finally see. source · → API and Data Fetching
Switching profiles at runtime (stores/profile.ts switchProfile) converges
on this same performBootstrap; it just tears down the old profile’s streams
and resets the client first. That teardown is the last scene of Flow 2.
Flow 2: Montage opens and a live MJPEG stream runs
This is the busiest flow in the app and the one most worth understanding. A
montage tile goes from “just mounted” to a live nph-zms feed, and along the
way the app manages a connection key (connkey) per stream so feeds never
collide on the server and never leak a zombie process when they go away.
sequenceDiagram
autonumber
participant Page as Montage page
participant Tile as Tile (LiveMonitorPlayer)
participant Stream as useMonitorStream
participant Life as useStreamLifecycle
participant Store as Monitor store
participant ZM as ZoneMinder (nph-zms)
Page->>ZM: GET /monitors.json
Page->>Tile: render one tile per filtered monitor
Tile->>Stream: useMonitorStream (mjpeg)
Stream->>Life: useStreamLifecycle
Life->>Store: regenerateConnKey, get a unique key
Stream->>ZM: img src = nph-zms?connkey=K
ZM-->>Tile: MJPEG frames
Note over Tile,ZM: on img error: backoff, CMD_QUIT old key, mint new key
Tile->>ZM: CMD_QUIT on unmount or profile switch
The page fetches its monitors.
pages/Montage.tsxruns the profile-scoped, bandwidth-throttleduseQuery(['monitors', ...])for the list and live status the grid renders. source · → Pages and ViewsDo not render until the filter is ready. The page holds rendering behind
isLoading || !isFilterReady. This guard matters because mounting a tile starts a stream, so flashing the full monitor set for even one frame before the group/hidden filter narrows it would briefly open every stream at once. source · → Pages and ViewsOne tile per monitor. Each monitor becomes a grid cell wrapping an error boundary and
MontageMonitor(memoized), keyed byMonitor.Id.memokeeps grid re-renders (drag, resize) from tearing the stream down and back up, and the boundary isolates a crashing tile. source · → Project ArchitecturePick a streaming method.
LiveMonitorPlayercomputeseffectiveStreamingMethod(webrtcvsmjpeg) from the user setting,monitor.Go2RTCEnabled, andprofile.go2rtcUrl; a go2rtc failure falls back to MJPEG. For a plain MJPEG monitor this is'mjpeg'and the rest of this flow follows the<img>path. source · → Go2RTC WebRTC Streaming IntegrationThe hook that owns the stream.
LiveMonitorPlayercallsuseMonitorStream, which resolves the profile, a fresh access token, the per-server URLs, the view mode, and the multi-port base. It assembles everything needed to build a valid stream URL and the matching CMD_QUIT URL. source · → Project ArchitectureMint a connection key.
useMonitorStreamdelegates the connkey lifecycle touseStreamLifecycle, whose mount effect callsregenerateConnKey(monitorId)and setsconnKey. Each concurrent stream needs a unique key so ZoneMinder’snph-zmsprocesses do not collide. source · → Shared Services and Reusable ComponentsThe key is stored, not just held.
stores/monitors.tsgenerateAndSetConnKeygenerates a random number and stores it in the persistedconnKeys[monitorId]map. Keeping it in the store is what lets teardown later compare-and-clear exactly the key it owns, never a newer concurrent one. source · → Shared Services and Reusable ComponentsBuild the stream URL (only when safe). Once
connKey !== 0and the token is fresh,useMonitorStreambuilds the URL viagetStreamUrl(api/monitors.tsthenlib/url-builder.tsto/cgi-bin/nph-zms) and mirrors it intoimageSrc. The double gate prevents minting a zombie stream before a key exists. source · → API and Data FetchingThe browser opens the feed.
LiveMonitorPlayerbinds<img src={imageSrc} onLoad onError>. The browser itself opens the multipart MJPEG connection through the<img>; the connkey lives right in thesrc. A good frame callsreportStreamLoad, which zeroes the reconnect backoff. source · → Project ArchitectureWhen the feed drops, reconnect with backoff. An
<img onError>callsreportStreamError(scheduleReconnect), which waits an exponentially growing delay (capped, and uncapped under insomnia) then callsforceRegenerate({ killPrevious: true }); at the attempt cap it callsreleaseConnection()instead. The error cannot tell a dead server process from a dropped-but-alive one, so it must CMD_QUIT the old key before minting a new one. source · → Shared Services and Reusable ComponentsQuit cleanly on every mint and unmount.
forceRegenerate(and the unmount cleanupquitStreamForParams) send a CMD_QUIT for the old connkey and clear it from the store with a compare-and-clear, then the<img>src is removed to abort the in-flight connection. This is what prevents leakednph-zmsprocesses when a tile reconnects or leaves the grid. source · → Application LifecycleA profile switch tears down all streams first. Each lifecycle registers a teardown thunk in
lib/active-streams.ts;stores/profile.tsswitchProfileawaitsquitAllActiveStreams()before logout and the SSL-trust flip, while the old profile’s trust and token are still in effect. Relying on React unmount alone races the switch and can orphan annph-zmsprocess on a self-signed server. source · → Application Lifecycle
Flow 3: A push notification, from registration to tap
Native only (iOS/Android). Every @capacitor-firebase/messaging call sits
behind a Platform.isNative check and a dynamic import(), so on web the
whole flow short-circuits and none of it runs. There are two halves: registering
a token on startup, and reacting when a push arrives.
sequenceDiagram
autonumber
participant App as NotificationHandler
participant Svc as Push service
participant OS as FCM / OS
participant ZM as ZoneMinder
participant Nav as Router
App->>Svc: initialize (on startup)
Svc->>OS: request permission
Svc->>OS: create channel zmninja-ng (Android)
Svc->>OS: getToken
OS-->>Svc: FCM token
Svc->>ZM: register token (postForm /notifications.json)
OS->>Svc: user taps a push (notificationActionPerformed)
Svc->>Svc: resolve profile, store the event
Svc->>Nav: navigate to /events/:id
A headless component wires it all up.
App.tsxmounts<NotificationHandler/>once. It renders no UI of its own (only the cross-profile switch dialog) and exists purely to wire the notification side effects through three hooks. source · → Project ArchitectureSet up push for the active profile.
useNotificationPushSetupruns an effect gated onPlatform.isNative && settings.enabled. It registers the token against the current profile and the chosen backend: re-register if the push service is already running, otherwiseinitialize()for the first time. source · → Application LifecycleOne push service for the whole app.
services/pushNotifications.tsexposesgetPushService, a module-level singleton holdingcurrentTokenand init state, so token state survives re-renders and profile switches instead of being recreated. source · → Shared Services and Reusable ComponentsAsk permission.
initializeimportsFirebaseMessagingand callsrequestPermissions(), continuing only if granted. No token can be obtained without OS push permission. source · → Shared Services and Reusable ComponentsCreate the Android channel.
_createNotificationChannel(Android only) creates the FCM channelid: 'zmninja-ng'atimportance: 4(HIGH). Android needs a high-importance channel for heads-up banners, and the manifest’sdefault_notification_channel_idroutes channel-less server pushes here so they alert instead of landing silently. source · → Shared Services and Reusable ComponentsListen before fetching the token.
_setupListenersregisterstokenReceived,notificationReceived, andnotificationActionPerformedbeforegetTokenso a token refresh is never missed. source · → Application LifecycleGet the FCM token.
getTokenrequests the token, stores it incurrentToken, and retries once after 5s on a transient failure. source · → Shared Services and Reusable ComponentsRegister the token with the server.
_registerWithServerforks onsettings.notificationMode: direct mode callsapi/notificationsregisterToken; ES mode registers over the websocket, deferring until connected. source · → API and Data FetchingThe actual REST call.
api/notifications.tsregisterTokenPOSTs a form-encodedNotification[...]body to/notifications.jsonviaclient.postForm. ZoneMinder’s notifications endpoint expects form fields, not JSON. source · → API and Data FetchingA push arrives while the app is open.
notificationReceived→_handleNotificationignores the push if already connected to the event server (the same event also arrives over the websocket), otherwise it builds a snapshot URL for the current profile and adds the event to the notification store. source · → State Management with ZustandThe user taps the notification.
notificationActionPerformed→_handleNotificationActionresolves the target profile and stores the event under it. source · → Shared Services and Reusable ComponentsSame profile or switch?
resolveProfileForNotificationmatches the payload’s profile name to a stored profile. Same profile navigates directly; a different one callsrequestProfileSwitchto ask first (the dialog lives inNotificationHandler). source · → Shared Services and Reusable ComponentsNavigate to the event. A service cannot use React Router’s hook, so it calls
navigationService.navigateToEvent;NotificationHandler’s listener catches that event and callsnavigate, landing on/events/:id. source · → Shared Services and Reusable ComponentsReconcile pushes you missed.
useNotificationDeliveredcovers pushes that arrived while the app was killed or backgrounded: on cold start and onappStateChangeit readsgetDeliveredNotifications(), ingests them into history, clears them, and syncs the badge. source · → Application Lifecycle
Flow 4: Adding a server profile
Adding a profile is one long orchestrated method, handleTestConnection: it
discovers the server’s real API and CGI URLs, trusts its certificate the first
time, logs in to confirm the details, then saves the profile and switches to it.
sequenceDiagram
autonumber
participant Form as ProfileForm
participant Disc as Discovery
participant SSL as SSL trust
participant ZM as ZoneMinder
participant Store as Profile store
Form->>SSL: enable trust-all (if self-signed)
Form->>Disc: discover URLs from the portal you typed
Disc->>ZM: probe /api and /zm/api for getVersion
Disc->>ZM: read ZM_PATH_ZMS for the real CGI URL
Form->>SSL: fetch the cert fingerprint
SSL-->>Form: show trust-on-first-use dialog
Form->>ZM: log in to confirm
Form->>Store: addProfile, then switchProfile
The form.
ProfileFormholds state for the portal URL, credentials, the self-signed switch, manual-URL mode, and the trust-dialog. The same screen serves first-time setup and adding another profile. source · → Pages and ViewsOne method runs the whole thing.
handleTestConnection(the Connect button) sets up anAbortController, validates the inputs, then drives discovery → trust → login → save → switch in order. source · → Pages and ViewsTrust the cert before probing. When self-signed is on,
applySSLTrustSettingenables trust-all first, so the upcoming discovery calls can reach a self-signed host instead of failing the handshake. source · → Shared Services and Reusable ComponentsFind the real URLs.
discoverUrlswrapsdiscoverZoneminderwith one retry (to absorb the iOS local-network permission prompt) and installs the API client once a candidate answers. source · → API and Data FetchingProbe candidates.
discoverZonemindercrosseshttps/httpwith/apiand/zm/api, probinghost/getVersion.json, then derives the portal URL and CGI URL from whichever responds. source · → API and Data FetchingRead the server’s ZMS path. With credentials,
fetchCgiUrllogs in and readsZM_PATH_ZMSfrom config, so the streaming URL matches the server’s real setup rather than a guess. source · → External Network EndpointsTrust on first use. On native with self-signed enabled,
getServerCertFingerprintfetches the cert;CertTrustDialogshows it and waits. Accepting pins the fingerprint (applySSLTrustSetting(true, fingerprint)); rejecting aborts. source · → Project ArchitectureConfirm with a login. After a fresh
logout(), the auth store’slogin()authenticates against the confirmed server; failure is surfaced as a localized error. source · → Application LifecycleSave it.
addProfilevalidates the name, generates a UUID, writes the password to secure storage (never to Zustand), appends the profile, and makes it current if it is the first. source · → Application LifecycleSwitch to it. For a non-first profile,
switchProfilequits the old profile’s streams, resets the client, and runsperformBootstrap(the same bootstrap as Flow 1) before navigating away. source · → Application Lifecycle
Flow 5: Browse events and play a video
From the Events list to a playing video. The player choice is data-driven: an
event whose DefaultVideo is an .m3u8 plays HLS, a normal recording plays
MP4, and anything else (or any MP4 error, or a TV device) falls back to the ZMS
MJPEG player.
sequenceDiagram
autonumber
participant Page as Events page
participant API as getEvents
participant ZM as ZoneMinder
participant Detail as EventDetail
participant Player as Video player
Page->>API: query with the active filters
API->>ZM: GET /events/index...json (paged)
ZM-->>Page: event list, rendered as thumbnail cards
Page->>Detail: tap a card, navigate to /events/:id
Detail->>ZM: GET /events/:id.json
Detail->>Detail: pick MP4 vs HLS vs ZMS
Detail->>Player: build the video URL and play
Player->>ZM: stream from /index.php or /cgi-bin/nph-zms
Assemble the filters.
Eventsreads monitor, date, tag, and favorite filters fromuseEventFiltersand computes the effective monitor set that drives the query. source · → Pages and ViewsRun the events query. A React Query keyed by filters calls
getEvents, keeping the previous page visible during pagination, gated on auth. source · → API and Data FetchingBuild the ZM filter path and paginate.
getEventsturns filters into CakePHP-style URL segments, fetches up to ten pages of 100, dedupes by id, drops excluded monitors, and returns a synthesized pagination block. source · → API and Data FetchingRender thumbnail cards.
EventListViewmaps each event to anEventCard, each showing a representative still built from a fallback chain of frame ids (snapshot/objdetect/alarm). source · → Project ArchitectureOpen one. Tapping a card navigates to
/events/:id, carrying the referrer and active filters in router state so the detail page can do next/prev within the same filtered set. source · → Pages and ViewsLoad the event.
EventDetailfetches the full event (getEvent) and its monitor, and resolves the monitor’s portal URL and streaming port for multi-server support. source · → Pages and ViewsPick the player.
isHlsEvent(an.m3u8DefaultVideo) chooses HLS; otherwise MP4. JPEG-only events, TV devices, and any MP4 error flipuseZmsFallbackto the ZMS player. source · → Pages and ViewsBuild the video URL (stably).
videoUrlis memoized so its identity does not change mid-playback (re-issuing the source resets iOS WKWebView); it callsgetEventVideoUrlwith the token, port, and HLS flag. source · → API and Data FetchingThe URL shape.
url-builder.tsgetEventVideoUrlemits the HLS (view_event_hls) or MP4 (view_video)/index.phpURL, appends the token, and rewrites the port when multi-port streaming is on. source · → Key LibrariesMP4/HLS playback.
Mp4EventPlayercreates a Video.js player, wires alarm markers and PiP, and bubbles a playbackerrorup toEventDetail, which switches to ZMS. source · → Project ArchitectureThe ZMS fallback.
ZmsEventPlayerstreams MJPEG from/cgi-bin/nph-zmsinto an<img>and sends play/pause/seek/speed as ZMS commands over the same connkey, quitting on unmount, just like a live stream. source · → Project Architecture
Flow 6: The access-token lifecycle
The app keeps the access token fresh two ways and recovers from a stale one a third way, and all three collapse to a single network request when they overlap. A timer refreshes proactively, each request refreshes just-in-time if needed, and a 401 triggers recovery, all behind module-level single-flight gates.
sequenceDiagram
autonumber
participant Timer as useTokenRefresh
participant Auth as Auth store (gates)
participant ZM as ZoneMinder
participant Client as API client
Timer->>Auth: token expiring soon? getFreshAccessToken
Auth->>ZM: POST refresh (single-flight)
ZM-->>Auth: new tokens
Client->>Auth: getAccessToken before each request
Client->>Auth: expired? getFreshAccessToken (shares the same gate)
Client->>ZM: request with token
ZM-->>Client: 401
Client->>Auth: recoverFromAuthFailure (refresh, then re-login)
Client->>ZM: retry once
A minute timer watches expiry.
useTokenRefreshmounts once, arms a one-minute interval and avisibilitychangelistener so it re-checks the moment the app returns from background. source · → Application LifecycleRefresh before it lapses.
checkAndRefreshrefreshes when the time to expiry drops below the leeway window, covering both “expiring soon” and “already expired while backgrounded”. source · → Application LifecycleOne shared entry point.
getFreshAccessTokenreturns the current token if still fresh, else attaches to (or installs) the module-levelpendingFreshTokengate, so concurrent callers share one outcome. source · → State Management with ZustandThe deduped refresh POST.
refreshAccessTokenruns the network refresh behind its ownpendingRefreshgate and logs out if the refresh token is already expired, so a proactive refresh and a 401 recovery collapse to one POST. source · → State Management with ZustandNew tokens land.
setTokensconverts the relative expiry seconds to absolute timestamps and stores them (access in memory, refresh in secure storage); updating the expiry is what re-arms the timer. source · → State Management with ZustandEvery request reads the token through a gate. The API client’s
requestpulls the token via the injectedAuthGaterather than importing the store; login andskipAuthrequests bypass it. source · → API and Data FetchingJust-in-time refresh. If the token is already expired when a request is about to fire, the client calls
getFreshAccessToken(the same gate) and attaches the new token, catching tokens that died between timer ticks. source · → API and Data Fetching401 recovery, single-flight. A 401 triggers
recoverFromAuthFailure, which refreshes, falls back to re-login, logs out once if both fail, never rejects, and on success retries the original request exactly once. source · → API and Data FetchingThe gate that breaks the import cycle.
storeGatesinjects the auth accessors into the client so it never imports the store directly, keeping all the single-flight dedup in the store and the client mockable. source · → Shared Services and Reusable ComponentsA switch clears the gates.
resetAuthGates(a reset hook run byresetApiClient) nulls all five pending gates so a new profile never attaches to an old profile’s in-flight login or refresh. source · → Shared Services and Reusable Components
Flow 7: Live notifications over the Event Server websocket
The other notification path (separate from FCM push in Flow 3): in “ES mode” the app opens a websocket to the ZoneMinder event server, authenticates, keeps it alive, and turns each live alarm into an event in the store and a toast on screen.
sequenceDiagram
autonumber
participant Hook as Auto-connect hook
participant Store as Notification store
participant Svc as WS service
participant ES as Event server
Hook->>Store: connect(profile, user, pass)
Store->>Svc: connect + inject providers
Svc->>ES: open websocket, send auth
ES-->>Svc: auth Success
Svc->>ES: keepalive ping (bandwidth interval)
ES-->>Svc: alarm event
Svc->>Store: onEvent, addEvent
Store-->>Hook: toast + badge update
The handler wires the hook.
NotificationHandlerhands the store’sconnect/disconnect/reconnectand the current profile touseNotificationAutoConnect. source · → Project ArchitectureChoose the ES path. The auto-connect effect reads
notificationMode; foresit proceeds only when a host is set and nothing is connected, guarded against re-entry. source · → Application LifecycleDecrypt and connect (race-checked).
attemptConnectdecrypts the password, re-reads the connection state right before connecting (the await could have changed it), then calls the store’sconnect. source · → Application LifecycleStore builds the config and listeners.
connectdisconnects any other profile, builds the server config, registers state/event listeners, and awaits the service connect. source · → State Management with ZustandInject store-derived providers.
_buildServiceProvidershands the import-free service its token getter, image-URL builder, and bandwidth-derived keepalive interval. source · → Shared Services and Reusable ComponentsOpen the socket. The service
connectbuilds thews(s)://host:portURL, opens the websocket with stale-socket guards on every handler, and waits for auth. source · → Shared Services and Reusable ComponentsSend credentials on open.
_handleOpensends theauthmessage and a 20-second timer rejects (and reconnects) if no response comes back. source · → External Network EndpointsHandle the auth reply.
_handleMessageresolves the pending auth onSuccess(starts keepalive, stateconnected) or disconnects without reconnect on bad credentials. source · → External Network EndpointsKeep it alive.
_startPingIntervalsends a periodic version request at the bandwidth-derived interval; the same request backs the liveness check on resume. source · → Shared Services and Reusable ComponentsReconnect with backoff. On an unintended close,
_scheduleReconnectwaits an exponential, jittered delay (capped at two minutes);reconnectNowjumps the queue on network-restored. source · → Shared Services and Reusable ComponentsBridge events into the store.
_initializesubscribes to the service’s state and event streams, mirroring connection state and callingaddEventper alarm. source · → State Management with ZustandRecord the alarm.
addEventwraps it as a notification, dedupes and caps the history, recomputes the unread badge, and pushes the count back to the server. A toast then shows for the latest event. source · → State Management with Zustand
Flow 8: A go2rtc WebRTC live stream
The alternative to the MJPEG tile in Flow 2. When a monitor has go2rtc enabled,
the tile drives a low-latency WebRTC/MSE <video> via the vendored video-rtc
element, with a ladder of watchdogs that fall back to MJPEG if anything stalls.
sequenceDiagram
autonumber
participant Tile as LiveMonitorPlayer
participant Hook as useGo2RTCStream
participant El as video-rtc element
participant GR as go2rtc server
Tile->>Tile: webrtc selected? (not failed recently)
Tile->>Hook: useGo2RTCStream
Hook->>El: new VideoRTC, src = ws URL
El->>GR: open websocket, negotiate webrtc/mse/hls
GR-->>El: video frames
Note over Tile,GR: no frames in 15s, or freeze, or error
Tile->>Tile: record failure, fall back to MJPEG
Choose WebRTC.
streamingMethodresolveswebrtconly when the user setting allows it, the monitor has go2rtc enabled, and the profile has a go2rtc URL. source · → Project ArchitectureSkip known-broken monitors. A module-level failure cache (5-minute TTL) makes a monitor that recently failed go2rtc go straight to MJPEG, so montage tiles do not each re-attempt a broken stream. source · → Project Architecture
MJPEG-first placeholder.
effectiveStreamingMethodshows the MJPEG stream as a placeholder while WebRTC establishes, swapping to<video>once decoded frames appear, so the tile is never blank. source · → Project ArchitectureCall the hook.
useGo2RTCStreamis invoked with the go2rtc URL, channel, protocols, and a host guard against leaking the token to the wrong origin. source · → Project ArchitectureConnect lifecycle. The hook waits a short delay (to survive Strict-Mode double-invoke) then connects, and tears down on unmount or disable. source · → Application Lifecycle
Build the websocket URL.
getGo2RTCWebSocketUrlconverts http(s) to ws(s), appends/ws, and setssrc={monitorId}_{channel}plus the token. source · → API and Data FetchingCreate the element.
connectinstantiatesVideoRTC, wraps itsoninit/onopen/ondisconnecthandlers into React state, and assignssrcto kick off the socket. source · → Shared Services and Reusable ComponentsNegotiate protocols. On open, the vendored element starts MSE (or HLS) and WebRTC in parallel; whichever delivers video first wins and becomes the active protocol. source · → Go2RTC WebRTC Streaming Integration
Watchdog: connected but no frames. A 15-second timer checks for actual video dimensions; if none, it records the failure and falls back to MJPEG (a faster poll swaps to
<video>the instant frames appear). source · → Project ArchitectureWatchdog: freeze after playing. A 3-second liveness check watches
currentTimeadvance; a stall past the threshold retries up to twice, then demotes to MJPEG. Healthy playback for a minute clears the retry count. source · → Project ArchitectureResume after background.
useVisibilityResumeresets freeze counters, clears any latched MJPEG fallback, and nudges a retry, recovering tiles the browser suspended. source · → Application Lifecycle
Flow 9: The Timeline view
The Timeline fetches events for a time range, transforms them into bars, and
paints them on a <canvas> you can pan, zoom, and scrub, with live events
injected the instant they arrive.
sequenceDiagram
autonumber
participant Page as Timeline page
participant Data as useTimelineData
participant ZM as ZoneMinder
participant Canvas as TimelineCanvas
participant Render as renderer
Page->>Data: fetch monitors + events for the range
Data->>ZM: GET events (fan out per monitor if filtered)
ZM-->>Data: events, transformed to bars
Data->>Page: live events injected from the store
Page->>Canvas: viewport + gestures
Canvas->>Render: paint axis, swimlanes, bars, playhead
The page and its range.
Timelinereads filters, defaults to the last 24 hours, and restores the scrubber from session storage so the playhead survives a round-trip to an event page. source · → Pages and ViewsFetch monitors and events.
useTimelineDataruns a monitors query and a range-bounded events query, using “now” as the end in live mode. source · → API and Data FetchingFan out per monitor when filtered. With a cause filter active, it issues one capped
getEventsper monitor at limited concurrency and merges them, so one busy camera cannot eat the whole page budget. source · → API and Data FetchingInject live events. In live mode it subscribes to the notification store and adds a synthetic bar immediately on a new alarm, then debounces a refetch and prunes the synthetic once the real event lands. source · → API and Data Fetching
Transform to bars.
allTimelineEventsmaps each event to aTimelineEvent(start/end ms, alarm ratio, pulse timestamp), merging live synthetics with the API winning on id collisions. source · → Project ArchitectureThe canvas orchestrator.
TimelineCanvaswires the viewport, gestures, render loop, and hit-testing, translating one-shot actions (reset, zoom, go-to-now) into animated viewport changes. source · → Project ArchitectureViewport math.
useTimelineViewportholds the visible range and does pan, zoom (clamped between one minute and 90 days), and eased animations. source · → Project ArchitectureInput gestures.
useTimelineGesturesnormalizes mouse, touch, wheel, and pinch into pan/zoom/hover/click/brush callbacks. source · → Project ArchitectureHit-testing.
hitTestmaps a canvas point to a monitor row and event, expanding thin bars to a minimum width so they stay clickable. source · → Shared Services and Reusable ComponentsPaint the canvas.
renderTimelinelayers swimlanes, a collision-pruned time axis, rounded per-monitor event bars (with a pulse halo for live arrivals), the dashed “NOW” pill, and the scrubber playhead. source · → Key LibrariesScrub and preview.
TimelineScrubberdrags the playhead and shows thumbnail buttons for the events under it; a canvas click opensEventPreviewPopover, whose Play button navigates to the event. source · → Project Architecture
Flow 10: Downloading an event
A download registers a background task and then splits by platform: on mobile it fetches base64 and writes via Capacitor (never a Blob, to avoid OOM); on web it streams a Blob with real progress. The drawer shows progress and a cancel button.
sequenceDiagram
autonumber
participant UI as Download button
participant Svc as download service
participant Task as Background-task store
participant HTTP as http adapter
participant Save as Filesystem / Media
participant Drawer as Task drawer
UI->>Svc: downloadEventVideo
Svc->>Task: addTask (drawer pops open)
Svc->>HTTP: fetch (base64 on mobile, Blob on web)
HTTP->>Save: write file + add to media library
Svc->>Task: updateProgress, then completeTask
Task-->>Drawer: progress bar + cancel
The trigger. The “Download video” button calls
downloadEventVideowith the URL inputs and returns immediately; the drawer surfaces progress. source · → Project ArchitectureOrchestrate and register a task.
downloadEventVideobuilds the URL, sanitizes the filename, creates anAbortController, registers a background task with a cancel function, and kicks off the work asynchronously. source · → Shared Services and Reusable ComponentsThe task store.
addTaskcreates the task, trims old finished ones, and auto-expands the drawer. Using.getState()is what lets a non-React service drive the store. source · → State Management with ZustandPlatform dispatch.
downloadFilepicks the native or web handler from the platform; this is the split point. source · → Shared Services and Reusable ComponentsMobile: base64, never a Blob.
downloadFileNativefetches withresponseType: 'base64'and uses the string directly, explicitly avoiding a Blob to prevent out-of-memory on large videos. source · → Shared Services and Reusable ComponentsThe native HTTP adapter.
nativeHttpRequestuses CapacitorHttp and returns base64; it has no abort support, so a timeout race stands in. source · → API and Data FetchingMobile save. The base64 is written to Documents, then added to the Photo or Video library by extension; a media-library failure is non-fatal because the file is already saved. source · → Shared Services and Reusable Components
Web: streaming Blob.
downloadFileWebfetches a Blob with streaming progress and triggers a browser download via a temporary anchor, falling back to a direct link if the fetch fails. source · → Shared Services and Reusable ComponentsProgress feeds the store. Each tick calls
updateProgress(web has real streaming progress; native emits a single 100% tick), thencompleteTask/failTaskon finish. source · → State Management with ZustandThe drawer.
BackgroundTaskDrawer(mounted globally) renders progress bars and a cancel button that calls the task’s cancel function, which aborts the request. source · → Project Architecture
Flow 11: A bandwidth setting becomes polling cadence
This is a data-propagation flow, not a time sequence: one per-profile setting
(bandwidthMode) selects a preset, and every poll in the app reads its interval
from that one place, so flipping low mode re-cadences the whole app at once.
graph LR
Presets["BANDWIDTH_SETTINGS<br/>(normal / low)"] --> Getter["getBandwidthSettings()"]
Mode["bandwidthMode<br/>(per-profile setting)"] --> Hook["useBandwidthSettings()"]
Getter --> Hook
Hook --> C1["useMonitors<br/>refetchInterval"]
Hook --> C2["Monitors page<br/>queries"]
Getter --> C3["notifications<br/>keepalive / poller"]
The contract.
BandwidthSettingsdeclares the shape: every interval and quality knob the app polls on (monitor status, alarm status, snapshot refresh, keepalive, and more). source · → Shared Services and Reusable ComponentsThe two presets.
BANDWIDTH_SETTINGSholds thenormalandlowobjects;lowroughly doubles every interval and halves image scale and fps. This is the source of every cadence number. source · → Shared Services and Reusable ComponentsThe non-React getter.
getBandwidthSettings(mode)is the one sanctioned way for services and stores (outside React) to read a preset. source · → Shared Services and Reusable ComponentsThe user’s knob.
bandwidthModeis a profile-scoped setting in the settings store, so switching profiles can switch cadence. source · → State Management with ZustandThe React hook.
useBandwidthSettingsreadsbandwidthModefrom the current profile and memoizes the matching preset, so components get a live, profile-correct settings object. source · → Project ArchitectureA typical consumer.
useMonitorsfeedsbandwidth.monitorStatusIntervalstraight into the React QueryrefetchInterval(overridable by the caller). source · → API and Data FetchingThe seeded path. Toggling low mode in
LiveStreamingSectioncopies the preset’s stream knobs (scale, fps, snapshot refresh) into the profile settings, which is whyuseMonitorStreamreads them assettings.*. source · → State Management with ZustandThe non-React consumers. Outside React, the notification keepalive and the direct-mode poller call
getBandwidthSettingsdirectly for their intervals, the same presets without a hardcoded number anywhere. source · → State Management with Zustand
Flow 12: A Dashboard widget
The Dashboard is a per-profile grid of widgets you add, arrange, and resize. The layout lives in its own persisted store (not profile settings), keyed by profile id, and each widget fetches its own live data.
sequenceDiagram
autonumber
participant Page as Dashboard page
participant Store as Dashboard store
participant Grid as DashboardLayout
participant Widget as A widget
participant ZM as ZoneMinder
Page->>Store: read widgets[profileId]
Store-->>Grid: saved widgets + layouts
Grid->>Widget: render each by type
Widget->>ZM: query its own data on an interval
Page->>Store: add / move / resize / remove
Store-->>Page: persisted, survives reload
The page reads the saved list.
Dashboardresolves the current profile and pulls that profile’s widgets from the dashboard store, falling back to an empty array. source · → Pages and ViewsA dedicated persisted store.
useDashboardStorekeepswidgets: Record<profileId, DashboardWidget[]>plus an editing flag, persisted under its own key with versioned migrations. It is profile-scoped by keying on profile id, not bygetProfileSettings. source · → State Management with ZustandThe grid.
DashboardLayoutmaps each widget’s stored geometry into a react-grid-layout and packs widgets upward, showing an empty state when there are none. source · → Project ArchitectureCard chrome per cell.
DashboardWidgetwraps each cell in a card with the drag handle and, in edit mode, the per-widget edit and delete buttons; the live content is passed in by type. source · → Project ArchitectureAdd a widget.
DashboardConfigopens the Add Widget dialog with four type tiles (monitor, events, timeline, heatmap) and the per-type options (monitor multi-select, feed fit, and so on). source · → Project ArchitectureThe store appends and auto-places it.
addWidgetgenerates a UUID, computes aybelow the existing widgets so it stacks, and persists immediately so it survives a reload. source · → State Management with ZustandA widget fetches its own data.
EventsWidgetruns a React Query againstgetEventswith itsrefetchIntervaldrawn from the widget override orbandwidth.eventsWidgetInterval(never a hardcoded interval), then renders a clickable list. source · → API and Data FetchingDrag and resize persist.
handleLayoutChangefires on every move, and only while editing (guarded against a store→state→store feedback loop) writes the new geometry back. source · → Project ArchitecturePer-breakpoint layout write.
updateLayoutsmerges the new geometry per breakpoint and recomputes the primary layout, so a resized widget keeps its size across reloads. source · → State Management with ZustandRemove a widget. The edit-mode X button calls
removeWidget, which filters it out of that profile’s array and re-renders the grid. source · → State Management with Zustand
Flow 13: Kiosk lock and biometric unlock
A wall-display lock: a full-screen overlay, protected by a global PIN, with biometric unlock on mobile. There is one feature here, not two; the lock is triggered only by a manual tap and resets to unlocked on app restart. There is no idle timeout and no auto-lock on backgrounding.
sequenceDiagram
autonumber
participant Btn as Lock button
participant Store as Kiosk store
participant Overlay as KioskOverlay
participant Bio as Biometrics
participant Pin as PinPad
Btn->>Store: lock() (manual)
Store-->>Overlay: isLocked, mount full-screen gate
Overlay->>Bio: try biometrics on unlock tap
Bio-->>Overlay: success unlocks
Overlay->>Pin: on unavailable / cancel, show PIN
Pin->>Store: verifyPin, count failed attempts
Store-->>Overlay: unlock (or 30s cooldown after 5 misses)
Set the PIN.
AdvancedSectionhosts setting, changing, and clearing the global kiosk PIN, each gated behind biometric-then-PIN re-verification. source · → Pages and ViewsThe PIN secret.
kioskPin.tsstores a salted SHA-256 of the PIN in secure storage and verifies against it; this is the single source of truth for whether a PIN is configured. source · → Shared Services and Reusable ComponentsThe lock-state store.
useKioskStoreholdsisLocked, the failed-attempt count, and a cooldown timestamp. It is not persisted, so locking does not survive a restart. source · → State Management with ZustandActivating the lock.
useKioskLockis the shared logic behind the lock buttons: with no PIN it opens first-time setup, otherwise it locks and force-enables screen keep-awake (restoring the prior value on unlock). source · → Project ArchitectureThe trigger. The sidebar lock button calls that hook to lock, or signals the overlay to begin unlock when already locked. The fullscreen montage controls expose the same button. source · → Project Architecture
The gate.
KioskOverlayrenders nothing until locked, then mounts a full-screen overlay that captures pointer events, swallows keyboard shortcuts, and blocks browser and Android hardware back. The live view keeps updating underneath. source · → Project ArchitectureUnlock: biometric first.
handleUnlockTapchecks the cooldown, tries biometrics, and unlocks on success; if biometrics are unavailable or cancelled it falls through to the PIN pad. source · → Project ArchitectureThe native prompt and its web fallback.
useBiometricAuthdynamically imports the biometric plugin inside try/catch, so on web or desktop the import throws and the flow degrades to PIN. Cancelling routes to the PIN pad, not the OS passcode. source · → Shared Services and Reusable ComponentsPIN entry.
PinPad(in unlock mode) verifies the entry; a miss records a failed attempt and, after five, surfaces a 30-second cooldown. The same component serves first-time set and PIN change. source · → Project ArchitectureMount and restore.
AppLayoutmounts the overlay and, on unlock, restores the pre-lock keep-awake state, closing the lock lifecycle. source · → Application Lifecycle
Flow 14: Capturing a snapshot
Saving a still of a live monitor. The source is whatever the tile is currently
showing: a WebRTC <video> is drawn to a canvas, while an MJPEG <img> is
either reused as-is or re-fetched as a single still. The save then splits by
platform, base64 on mobile and an anchor download on web.
sequenceDiagram
autonumber
participant Btn as Snapshot button
participant Cap as downloadSnapshotFromElement
participant ZM as ZoneMinder
participant Save as Platform save
Btn->>Cap: pass the live media element
alt video element
Cap->>Cap: draw current frame to canvas, toDataURL
else img element
Cap->>ZM: re-fetch as mode=single still (if not a data URL)
end
Cap->>Save: base64 on mobile / anchor on web
Save-->>Btn: toast: saved
The button.
handleDownloadSnapshoton a monitor card reads the live media ref and shows a success or failure toast;MonitorDetailhas the same button. Snapshots show a toast and are not tracked as background tasks. source · → Project ArchitectureWhat the ref points at.
LiveMonitorPlayersyncs the external media ref to the<img>for MJPEG or the<video>for WebRTC, which is what makes the downstream branch real. source · → Project ArchitectureCapture dispatch.
downloadSnapshotFromElementbuilds a timestamped filename, then for a<video>draws the current frame to a canvas and reads a JPEG data URL; for an<img>it reuses a data URL or sends the stream URL on to be re-fetched. source · → Shared Services and Reusable ComponentsRewriting a stream to one frame.
convertToSnapshotUrlunwraps any image proxy, then setsmode=singleand strips the streaming params so ZoneMinder returns a single still instead of a live multipart stream. source · → Shared Services and Reusable ComponentsData-URL dispatch.
downloadSnapshotbuilds the.jpgfilename and picks the platform-specific data-URL handler, or falls back to fetching a converted still. source · → Shared Services and Reusable ComponentsMobile save (no Blob).
downloadDataUrlNativesplits the base64 off the data URL and writes it straight to Documents via Capacitor Filesystem, then adds it to the photo library. It never builds a Blob, per the OOM rule. source · → Shared Services and Reusable ComponentsWeb save.
downloadFromDataUrlWebcreates a temporary<a download>with the data URL as its href, clicks it, and removes it, triggering the browser’s native download. source · → Shared Services and Reusable ComponentsThe MJPEG-still fetch path. When an
<img>carries a stream URL rather than a data URL, the samedownloadFilesplit from Flow 10 fetches themode=singlestill: base64 on mobile, Blob on web. source · → API and Data Fetching
These flows touch most of the moving parts of the app. When you need to change
something, find the nearest scene, open its source link to land on the exact
code, and follow the → link for the chapter that explains that layer.