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
    
  1. Before React mounts, the safety nets go up. main.tsx is 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 Lifecycle

  2. The stores wake up and read the disk. Importing the app pulls in the Zustand stores. stores/profile.ts is wrapped in persist(...), so the moment it loads it reads your saved profiles from local storage. App.tsx also builds the single React Query queryClient and registers it with setQueryClient() so non-React code can reach the same cache later. source · → State Management with Zustand

  3. Rehydration decides what kind of start this is. Once persist finishes reading, stores/profile.ts fires onRehydrateStorage, which calls handleProfileRehydration in services/profile-initialization.ts. No saved profile sends you to the Profiles screen and stops; a valid one continues. Any error still flips isInitialized: true, which guarantees the splash can never hang forever. source · → Application Lifecycle

  4. Throw away last session’s leftovers. clearStaleState calls logout() on the auth store and clearQueryCache(). 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 Zustand

  5. Point the HTTP client at this server. initializeApiClient calls setApiClient(createStoreApiClient(profile.apiUrl, reLogin)). From here on every httpGet / httpPost resolves through getApiClient(), so this one call decides which server all later requests talk to. The reLogin callback is what lets the client quietly re-authenticate a lapsed token. source · → API and Data Fetching

  6. Let the user in (the important bit). setInitializationState(true) flips isInitialized and isBootstrapping. 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 Lifecycle

  7. Background setup, SSL trust first. performBootstrap runs bootstrapSSLTrust before any network call. For a self-signed server it applies the trust override (and, the first time, shows the trust-on-first-use dialog) via lib/ssl-trust.ts applySSLTrustSetting, dispatching to the native ssl-trust plugin or Electron. If trust were applied after the login call, a self-signed server would reject it. source · → External Network Endpoints

  8. Log in. bootstrapAuth decrypts the stored password and calls the auth store’s login(), 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 sets isAuthenticated: 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 Fetching

  9. Fetch the server’s shape. Still in the background, performBootstrap runs bootstrapServerMap (multi-server routing), bootstrapTimezone, bootstrapZmsPath, bootstrapGo2RTCPath, and bootstrapMultiPortStreaming, each wrapped on its own so one failure does not sink the rest, then clears isBootstrapping. These resolve the streaming and routing details the monitor and montage views rely on. source · → External Network Endpoints

  10. Hide the splash, land on a page. An effect in App.tsx hides the native splash once isInitialized is set, and AppRoutes navigates to your last route (or /monitors) and starts the periodic token refresh (useTokenRefresh). source · → Pages and Views

  11. First real data. pages/Monitors.tsx runs 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
    
  1. The page fetches its monitors. pages/Montage.tsx runs the profile-scoped, bandwidth-throttled useQuery(['monitors', ...]) for the list and live status the grid renders. source · → Pages and Views

  2. Do 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 Views

  3. One tile per monitor. Each monitor becomes a grid cell wrapping an error boundary and MontageMonitor (memoized), keyed by Monitor.Id. memo keeps grid re-renders (drag, resize) from tearing the stream down and back up, and the boundary isolates a crashing tile. source · → Project Architecture

  4. Pick a streaming method. LiveMonitorPlayer computes effectiveStreamingMethod (webrtc vs mjpeg) from the user setting, monitor.Go2RTCEnabled, and profile.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 Integration

  5. The hook that owns the stream. LiveMonitorPlayer calls useMonitorStream, 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 Architecture

  6. Mint a connection key. useMonitorStream delegates the connkey lifecycle to useStreamLifecycle, whose mount effect calls regenerateConnKey(monitorId) and sets connKey. Each concurrent stream needs a unique key so ZoneMinder’s nph-zms processes do not collide. source · → Shared Services and Reusable Components

  7. The key is stored, not just held. stores/monitors.ts generateAndSetConnKey generates a random number and stores it in the persisted connKeys[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 Components

  8. Build the stream URL (only when safe). Once connKey !== 0 and the token is fresh, useMonitorStream builds the URL via getStreamUrl (api/monitors.ts then lib/url-builder.ts to /cgi-bin/nph-zms) and mirrors it into imageSrc. The double gate prevents minting a zombie stream before a key exists. source · → API and Data Fetching

  9. The browser opens the feed. LiveMonitorPlayer binds <img src={imageSrc} onLoad onError>. The browser itself opens the multipart MJPEG connection through the <img>; the connkey lives right in the src. A good frame calls reportStreamLoad, which zeroes the reconnect backoff. source · → Project Architecture

  10. When the feed drops, reconnect with backoff. An <img onError> calls reportStreamError (scheduleReconnect), which waits an exponentially growing delay (capped, and uncapped under insomnia) then calls forceRegenerate({ killPrevious: true }); at the attempt cap it calls releaseConnection() 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 Components

  11. Quit cleanly on every mint and unmount. forceRegenerate (and the unmount cleanup quitStreamForParams) 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 leaked nph-zms processes when a tile reconnects or leaves the grid. source · → Application Lifecycle

  12. A profile switch tears down all streams first. Each lifecycle registers a teardown thunk in lib/active-streams.ts; stores/profile.ts switchProfile awaits quitAllActiveStreams() 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 an nph-zms process 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
    
  1. A headless component wires it all up. App.tsx mounts <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 Architecture

  2. Set up push for the active profile. useNotificationPushSetup runs an effect gated on Platform.isNative && settings.enabled. It registers the token against the current profile and the chosen backend: re-register if the push service is already running, otherwise initialize() for the first time. source · → Application Lifecycle

  3. One push service for the whole app. services/pushNotifications.ts exposes getPushService, a module-level singleton holding currentToken and init state, so token state survives re-renders and profile switches instead of being recreated. source · → Shared Services and Reusable Components

  4. Ask permission. initialize imports FirebaseMessaging and calls requestPermissions(), continuing only if granted. No token can be obtained without OS push permission. source · → Shared Services and Reusable Components

  5. Create the Android channel. _createNotificationChannel (Android only) creates the FCM channel id: 'zmninja-ng' at importance: 4 (HIGH). Android needs a high-importance channel for heads-up banners, and the manifest’s default_notification_channel_id routes channel-less server pushes here so they alert instead of landing silently. source · → Shared Services and Reusable Components

  6. Listen before fetching the token. _setupListeners registers tokenReceived, notificationReceived, and notificationActionPerformed before getToken so a token refresh is never missed. source · → Application Lifecycle

  7. Get the FCM token. getToken requests the token, stores it in currentToken, and retries once after 5s on a transient failure. source · → Shared Services and Reusable Components

  8. Register the token with the server. _registerWithServer forks on settings.notificationMode: direct mode calls api/notifications registerToken; ES mode registers over the websocket, deferring until connected. source · → API and Data Fetching

  9. The actual REST call. api/notifications.ts registerToken POSTs a form-encoded Notification[...] body to /notifications.json via client.postForm. ZoneMinder’s notifications endpoint expects form fields, not JSON. source · → API and Data Fetching

  10. A push arrives while the app is open. notificationReceived_handleNotification ignores 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 Zustand

  11. The user taps the notification. notificationActionPerformed_handleNotificationAction resolves the target profile and stores the event under it. source · → Shared Services and Reusable Components

  12. Same profile or switch? resolveProfileForNotification matches the payload’s profile name to a stored profile. Same profile navigates directly; a different one calls requestProfileSwitch to ask first (the dialog lives in NotificationHandler). source · → Shared Services and Reusable Components

  13. Navigate to the event. A service cannot use React Router’s hook, so it calls navigationService.navigateToEvent; NotificationHandler’s listener catches that event and calls navigate, landing on /events/:id. source · → Shared Services and Reusable Components

  14. Reconcile pushes you missed. useNotificationDelivered covers pushes that arrived while the app was killed or backgrounded: on cold start and on appStateChange it reads getDeliveredNotifications(), 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
    
  1. The form. ProfileForm holds 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 Views

  2. One method runs the whole thing. handleTestConnection (the Connect button) sets up an AbortController, validates the inputs, then drives discovery → trust → login → save → switch in order. source · → Pages and Views

  3. Trust the cert before probing. When self-signed is on, applySSLTrustSetting enables trust-all first, so the upcoming discovery calls can reach a self-signed host instead of failing the handshake. source · → Shared Services and Reusable Components

  4. Find the real URLs. discoverUrls wraps discoverZoneminder with one retry (to absorb the iOS local-network permission prompt) and installs the API client once a candidate answers. source · → API and Data Fetching

  5. Probe candidates. discoverZoneminder crosses https/http with /api and /zm/api, probing host/getVersion.json, then derives the portal URL and CGI URL from whichever responds. source · → API and Data Fetching

  6. Read the server’s ZMS path. With credentials, fetchCgiUrl logs in and reads ZM_PATH_ZMS from config, so the streaming URL matches the server’s real setup rather than a guess. source · → External Network Endpoints

  7. Trust on first use. On native with self-signed enabled, getServerCertFingerprint fetches the cert; CertTrustDialog shows it and waits. Accepting pins the fingerprint (applySSLTrustSetting(true, fingerprint)); rejecting aborts. source · → Project Architecture

  8. Confirm with a login. After a fresh logout(), the auth store’s login() authenticates against the confirmed server; failure is surfaced as a localized error. source · → Application Lifecycle

  9. Save it. addProfile validates 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 Lifecycle

  10. Switch to it. For a non-first profile, switchProfile quits the old profile’s streams, resets the client, and runs performBootstrap (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
    
  1. Assemble the filters. Events reads monitor, date, tag, and favorite filters from useEventFilters and computes the effective monitor set that drives the query. source · → Pages and Views

  2. Run 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 Fetching

  3. Build the ZM filter path and paginate. getEvents turns 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 Fetching

  4. Render thumbnail cards. EventListView maps each event to an EventCard, each showing a representative still built from a fallback chain of frame ids (snapshot/objdetect/alarm). source · → Project Architecture

  5. Open 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 Views

  6. Load the event. EventDetail fetches the full event (getEvent) and its monitor, and resolves the monitor’s portal URL and streaming port for multi-server support. source · → Pages and Views

  7. Pick the player. isHlsEvent (an .m3u8 DefaultVideo) chooses HLS; otherwise MP4. JPEG-only events, TV devices, and any MP4 error flip useZmsFallback to the ZMS player. source · → Pages and Views

  8. Build the video URL (stably). videoUrl is memoized so its identity does not change mid-playback (re-issuing the source resets iOS WKWebView); it calls getEventVideoUrl with the token, port, and HLS flag. source · → API and Data Fetching

  9. The URL shape. url-builder.ts getEventVideoUrl emits the HLS (view_event_hls) or MP4 (view_video) /index.php URL, appends the token, and rewrites the port when multi-port streaming is on. source · → Key Libraries

  10. MP4/HLS playback. Mp4EventPlayer creates a Video.js player, wires alarm markers and PiP, and bubbles a playback error up to EventDetail, which switches to ZMS. source · → Project Architecture

  11. The ZMS fallback. ZmsEventPlayer streams MJPEG from /cgi-bin/nph-zms into 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
    
  1. A minute timer watches expiry. useTokenRefresh mounts once, arms a one-minute interval and a visibilitychange listener so it re-checks the moment the app returns from background. source · → Application Lifecycle

  2. Refresh before it lapses. checkAndRefresh refreshes when the time to expiry drops below the leeway window, covering both “expiring soon” and “already expired while backgrounded”. source · → Application Lifecycle

  3. One shared entry point. getFreshAccessToken returns the current token if still fresh, else attaches to (or installs) the module-level pendingFreshToken gate, so concurrent callers share one outcome. source · → State Management with Zustand

  4. The deduped refresh POST. refreshAccessToken runs the network refresh behind its own pendingRefresh gate 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 Zustand

  5. New tokens land. setTokens converts 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 Zustand

  6. Every request reads the token through a gate. The API client’s request pulls the token via the injected AuthGate rather than importing the store; login and skipAuth requests bypass it. source · → API and Data Fetching

  7. Just-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 Fetching

  8. 401 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 Fetching

  9. The gate that breaks the import cycle. storeGates injects 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 Components

  10. A switch clears the gates. resetAuthGates (a reset hook run by resetApiClient) 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
    
  1. The handler wires the hook. NotificationHandler hands the store’s connect/disconnect/reconnect and the current profile to useNotificationAutoConnect. source · → Project Architecture

  2. Choose the ES path. The auto-connect effect reads notificationMode; for es it proceeds only when a host is set and nothing is connected, guarded against re-entry. source · → Application Lifecycle

  3. Decrypt and connect (race-checked). attemptConnect decrypts the password, re-reads the connection state right before connecting (the await could have changed it), then calls the store’s connect. source · → Application Lifecycle

  4. Store builds the config and listeners. connect disconnects any other profile, builds the server config, registers state/event listeners, and awaits the service connect. source · → State Management with Zustand

  5. Inject store-derived providers. _buildServiceProviders hands the import-free service its token getter, image-URL builder, and bandwidth-derived keepalive interval. source · → Shared Services and Reusable Components

  6. Open the socket. The service connect builds the ws(s)://host:port URL, opens the websocket with stale-socket guards on every handler, and waits for auth. source · → Shared Services and Reusable Components

  7. Send credentials on open. _handleOpen sends the auth message and a 20-second timer rejects (and reconnects) if no response comes back. source · → External Network Endpoints

  8. Handle the auth reply. _handleMessage resolves the pending auth on Success (starts keepalive, state connected) or disconnects without reconnect on bad credentials. source · → External Network Endpoints

  9. Keep it alive. _startPingInterval sends a periodic version request at the bandwidth-derived interval; the same request backs the liveness check on resume. source · → Shared Services and Reusable Components

  10. Reconnect with backoff. On an unintended close, _scheduleReconnect waits an exponential, jittered delay (capped at two minutes); reconnectNow jumps the queue on network-restored. source · → Shared Services and Reusable Components

  11. Bridge events into the store. _initialize subscribes to the service’s state and event streams, mirroring connection state and calling addEvent per alarm. source · → State Management with Zustand

  12. Record the alarm. addEvent wraps 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
    
  1. Choose WebRTC. streamingMethod resolves webrtc only when the user setting allows it, the monitor has go2rtc enabled, and the profile has a go2rtc URL. source · → Project Architecture

  2. Skip 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

  3. MJPEG-first placeholder. effectiveStreamingMethod shows the MJPEG stream as a placeholder while WebRTC establishes, swapping to <video> once decoded frames appear, so the tile is never blank. source · → Project Architecture

  4. Call the hook. useGo2RTCStream is invoked with the go2rtc URL, channel, protocols, and a host guard against leaking the token to the wrong origin. source · → Project Architecture

  5. Connect 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

  6. Build the websocket URL. getGo2RTCWebSocketUrl converts http(s) to ws(s), appends /ws, and sets src={monitorId}_{channel} plus the token. source · → API and Data Fetching

  7. Create the element. connect instantiates VideoRTC, wraps its oninit/onopen/ondisconnect handlers into React state, and assigns src to kick off the socket. source · → Shared Services and Reusable Components

  8. Negotiate 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

  9. 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 Architecture

  10. Watchdog: freeze after playing. A 3-second liveness check watches currentTime advance; a stall past the threshold retries up to twice, then demotes to MJPEG. Healthy playback for a minute clears the retry count. source · → Project Architecture

  11. Resume after background. useVisibilityResume resets 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
    
  1. The page and its range. Timeline reads 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 Views

  2. Fetch monitors and events. useTimelineData runs a monitors query and a range-bounded events query, using “now” as the end in live mode. source · → API and Data Fetching

  3. Fan out per monitor when filtered. With a cause filter active, it issues one capped getEvents per monitor at limited concurrency and merges them, so one busy camera cannot eat the whole page budget. source · → API and Data Fetching

  4. Inject 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

  5. Transform to bars. allTimelineEvents maps each event to a TimelineEvent (start/end ms, alarm ratio, pulse timestamp), merging live synthetics with the API winning on id collisions. source · → Project Architecture

  6. The canvas orchestrator. TimelineCanvas wires the viewport, gestures, render loop, and hit-testing, translating one-shot actions (reset, zoom, go-to-now) into animated viewport changes. source · → Project Architecture

  7. Viewport math. useTimelineViewport holds the visible range and does pan, zoom (clamped between one minute and 90 days), and eased animations. source · → Project Architecture

  8. Input gestures. useTimelineGestures normalizes mouse, touch, wheel, and pinch into pan/zoom/hover/click/brush callbacks. source · → Project Architecture

  9. Hit-testing. hitTest maps 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 Components

  10. Paint the canvas. renderTimeline layers 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 Libraries

  11. Scrub and preview. TimelineScrubber drags the playhead and shows thumbnail buttons for the events under it; a canvas click opens EventPreviewPopover, 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
    
  1. The trigger. The “Download video” button calls downloadEventVideo with the URL inputs and returns immediately; the drawer surfaces progress. source · → Project Architecture

  2. Orchestrate and register a task. downloadEventVideo builds the URL, sanitizes the filename, creates an AbortController, registers a background task with a cancel function, and kicks off the work asynchronously. source · → Shared Services and Reusable Components

  3. The task store. addTask creates 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 Zustand

  4. Platform dispatch. downloadFile picks the native or web handler from the platform; this is the split point. source · → Shared Services and Reusable Components

  5. Mobile: base64, never a Blob. downloadFileNative fetches with responseType: 'base64' and uses the string directly, explicitly avoiding a Blob to prevent out-of-memory on large videos. source · → Shared Services and Reusable Components

  6. The native HTTP adapter. nativeHttpRequest uses CapacitorHttp and returns base64; it has no abort support, so a timeout race stands in. source · → API and Data Fetching

  7. Mobile 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

  8. Web: streaming Blob. downloadFileWeb fetches 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 Components

  9. Progress feeds the store. Each tick calls updateProgress (web has real streaming progress; native emits a single 100% tick), then completeTask / failTask on finish. source · → State Management with Zustand

  10. The 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"]
    
  1. The contract. BandwidthSettings declares 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 Components

  2. The two presets. BANDWIDTH_SETTINGS holds the normal and low objects; low roughly doubles every interval and halves image scale and fps. This is the source of every cadence number. source · → Shared Services and Reusable Components

  3. The 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 Components

  4. The user’s knob. bandwidthMode is a profile-scoped setting in the settings store, so switching profiles can switch cadence. source · → State Management with Zustand

  5. The React hook. useBandwidthSettings reads bandwidthMode from the current profile and memoizes the matching preset, so components get a live, profile-correct settings object. source · → Project Architecture

  6. A typical consumer. useMonitors feeds bandwidth.monitorStatusInterval straight into the React Query refetchInterval (overridable by the caller). source · → API and Data Fetching

  7. The seeded path. Toggling low mode in LiveStreamingSection copies the preset’s stream knobs (scale, fps, snapshot refresh) into the profile settings, which is why useMonitorStream reads them as settings.*. source · → State Management with Zustand

  8. The non-React consumers. Outside React, the notification keepalive and the direct-mode poller call getBandwidthSettings directly 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
    
  1. The page reads the saved list. Dashboard resolves the current profile and pulls that profile’s widgets from the dashboard store, falling back to an empty array. source · → Pages and Views

  2. A dedicated persisted store. useDashboardStore keeps widgets: 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 by getProfileSettings. source · → State Management with Zustand

  3. The grid. DashboardLayout maps each widget’s stored geometry into a react-grid-layout and packs widgets upward, showing an empty state when there are none. source · → Project Architecture

  4. Card chrome per cell. DashboardWidget wraps 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 Architecture

  5. Add a widget. DashboardConfig opens 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 Architecture

  6. The store appends and auto-places it. addWidget generates a UUID, computes a y below the existing widgets so it stacks, and persists immediately so it survives a reload. source · → State Management with Zustand

  7. A widget fetches its own data. EventsWidget runs a React Query against getEvents with its refetchInterval drawn from the widget override or bandwidth.eventsWidgetInterval (never a hardcoded interval), then renders a clickable list. source · → API and Data Fetching

  8. Drag and resize persist. handleLayoutChange fires on every move, and only while editing (guarded against a store→state→store feedback loop) writes the new geometry back. source · → Project Architecture

  9. Per-breakpoint layout write. updateLayouts merges the new geometry per breakpoint and recomputes the primary layout, so a resized widget keeps its size across reloads. source · → State Management with Zustand

  10. Remove 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)
    
  1. Set the PIN. AdvancedSection hosts setting, changing, and clearing the global kiosk PIN, each gated behind biometric-then-PIN re-verification. source · → Pages and Views

  2. The PIN secret. kioskPin.ts stores 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 Components

  3. The lock-state store. useKioskStore holds isLocked, the failed-attempt count, and a cooldown timestamp. It is not persisted, so locking does not survive a restart. source · → State Management with Zustand

  4. Activating the lock. useKioskLock is 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 Architecture

  5. The 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

  6. The gate. KioskOverlay renders 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 Architecture

  7. Unlock: biometric first. handleUnlockTap checks the cooldown, tries biometrics, and unlocks on success; if biometrics are unavailable or cancelled it falls through to the PIN pad. source · → Project Architecture

  8. The native prompt and its web fallback. useBiometricAuth dynamically 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 Components

  9. PIN 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 Architecture

  10. Mount and restore. AppLayout mounts 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
    
  1. The button. handleDownloadSnapshot on a monitor card reads the live media ref and shows a success or failure toast; MonitorDetail has the same button. Snapshots show a toast and are not tracked as background tasks. source · → Project Architecture

  2. What the ref points at. LiveMonitorPlayer syncs the external media ref to the <img> for MJPEG or the <video> for WebRTC, which is what makes the downstream branch real. source · → Project Architecture

  3. Capture dispatch. downloadSnapshotFromElement builds 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 Components

  4. Rewriting a stream to one frame. convertToSnapshotUrl unwraps any image proxy, then sets mode=single and strips the streaming params so ZoneMinder returns a single still instead of a live multipart stream. source · → Shared Services and Reusable Components

  5. Data-URL dispatch. downloadSnapshot builds the .jpg filename and picks the platform-specific data-URL handler, or falls back to fetching a converted still. source · → Shared Services and Reusable Components

  6. Mobile save (no Blob). downloadDataUrlNative splits 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 Components

  7. Web save. downloadFromDataUrlWeb creates 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 Components

  8. The MJPEG-still fetch path. When an <img> carries a stream URL rather than a data URL, the same downloadFile split from Flow 10 fetches the mode=single still: 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.