Skip to content

feat: Live Activity page, plus stream-teardown and event-poller fixes - #316

Merged
pliablepixels merged 64 commits into
mainfrom
feat/313-live-activity
Jul 31, 2026
Merged

feat: Live Activity page, plus stream-teardown and event-poller fixes#316
pliablepixels merged 64 commits into
mainfrom
feat/313-live-activity

Conversation

@pliablepixels

Copy link
Copy Markdown
Member

Adds a /live-activity route showing only the monitors ZoneMinder currently reports as alarming, and fixes several defects found while building it.

Refs #313, Refs #315

The page

Alarm status is polled per monitor (getAlarmStatus), one query each, since ZoneMinder's alarm endpoint is addressed by a single monitor id. The notification stream is an accelerant, not the source: it reports alarm starts but never alarm ends, so it cannot decide when a tile leaves.

A dwell window keeps a monitor resident after its alarm clears. That is not cosmetic. Each tile entering or leaving mounts or unmounts a video player, which mints a ZMS connection key and sends CMD_QUIT, so flicker thrashes nph-zms on the server.

Tiles sort most-recent-episode first, size from each camera's aspect ratio, and pack by row span so a short tile does not leave a hole. A settings gear covers poll interval, dwell, tile cap, and a page-specific ignore list. Continuous recorders are skipped by default, since a camera that always records is always inside an event.

Fixes found along the way

Stream teardown ran against live elements. useEffect(() => cleanup, []) was treated as "unmount only". React also runs an [] cleanup against a still-committed tree on a Suspense reveal, which is a production path here. The cleanup stripped the <img> src, React's virtual DOM still held the same value, and the element stayed mounted showing a broken image forever. Guarded on isConnected.

A disabled stream hook kept its connkey, orphaning nph-zms on the server and letting a Go2RTC-to-MJPEG fallback remount on a dead key.

The event poller never retried its monitor name map. A startup race with authentication left it empty, so every notification for the rest of the session read "Monitor 4" instead of the camera name.

Per-monitor "always use ZMS for events" (Refs #315), for cameras whose container the app cannot play. MP4 is never attempted for them, so the failure toast is unreachable rather than suppressed.

Verification

npm run gates green: 3237 tests, lint ratchet unchanged at 213 across 12 rules. npm run test:e2e -- live-activity.feature passing.

Not device-verified: the broken-image fix and the tile layout work were reasoned and unit-tested but only observable on a real server with real cameras.

Known debt: the ratchet baseline for react-hooks/set-state-in-effect was raised 14 to 15 with reasoning in its commit; two reviewers independently found no safe restructure.

Posted by Claude, assisting @pliablepixels.

pliablepixels and others added 30 commits July 30, 2026 17:00
A dedicated route showing only monitors currently in alarm, sourced from
per-monitor alarm status rather than the notification stream, which reports
alarm starts but never alarm ends.

Refs #313

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Corrects three spec assumptions found while planning: the alarmStatusInterval
bandwidth key, the monitorAlarmStatus query key, and the alarm-status parse in
useAlarmControl all already exist.

Refs #313

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
useAlarmControl parsed the alarm-status response inline. The Live Activity
page needs the same parse, so it moves to a shared pure function with a
named state type rather than a loose number.

Refs #313
Decides which monitors the page shows and for how long. The dwell window
protects the server as much as the eye: each tile entering or leaving mints
a ZMS connection key and sends CMD_QUIT.

Refs #313
One alarm-status query per monitor, enabled only while the caller is on
screen. The states map is total over monitorIds while enabled: a monitor
whose query has not resolved yet, or has errored, still reports 'unknown'
rather than being left out, so the downstream dwell reducer never reads a
transient gap as the caller having stopped watching that monitor.

Refs #313
resolvePollIntervalMs hardcoded the event-poller interval as its bandwidth
floor. It now takes the key, so a second feature with a user-tunable
interval does not need a second copy of the clamp.

Refs #313
Shows only monitors currently in alarm, as montage tiles. Reusing
MontageMonitor brings the ZMS connection-key and CMD_QUIT lifecycle with it,
so no stream teardown code is written here.

Refs #313
A push event arrives before the next poll tick would notice. Hints only
promote monitors already being polled, so the ignore list still holds.
Tiles now show a count badge once a monitor has alarmed more than once
while resident.

Refs #313
react-hooks/set-state-in-effect: 14 -> 15. The mount/poll-driven effect in
LiveActivity.tsx (Refs #313, commit 313fd57) calls setActive from an
effect, which the rule flags on the assumption that state can instead be
derived during render. It cannot here: `active` is a stateful reduction
over its own previous value plus wall-clock time (reduceActiveMonitors
folds in alarmCount increments and the dwell window), driven by an
external poll/notification source, not a pure function of the current
render's props/state. Rewriting it as the "adjust state during render"
pattern (compare hintedStates to a tracked previous value, setState
inline) risks either skipping the state population on first mount or,
under React's Strict Mode double-render, double-applying
reduceActiveMonitors and double-counting alarmCount -- the exact failure
mode this project's dwell/alarm-count semantics cannot tolerate. Confirmed
pre-existing as of Task 5 (2887b5c), which predates the ratchet check
being run against this file; Task 5b's changes did not add a new
violation, just changed the effect's second argument from states to
hintedStates.

Refs #313
Poll interval, dwell window, tile cap, and a page-specific monitor ignore
list. The ignore list is separate from the profile-wide monitor exclusion,
which hides a monitor everywhere.

Refs #313
Binding the poll/dwell/tiles inputs straight to the committed store value
clamped on every keystroke, so clearing a field snapped it back to the
minimum instead of letting the user retype. Each field now keeps a local
draft and only commits a clamped value once it parses as a real number.

Refs #313
Committing a clamped value on every keystroke resynced the draft to the
clamped string mid-edit, so typing "12" one digit at a time landed on 22:
"1" clamped to the minimum "2", and the next digit appended to that. Move
the commit to blur (and Enter), so onChange only ever updates local state
and the store cannot change mid-edit. The resync-from-store runs during
render (React's "adjusting state when a prop changes" pattern) rather than
in an effect, to keep the lint-ratchet baseline from growing.

Refs #313
lastStoredValue advanced unconditionally even while the field was focused,
so an external write mid-edit was marked seen without ever being applied to
the draft. On blur, commit() then wrote the stale draft back over that
external value, superseding it either way but as an accident of the guard
rather than a decision. lastStoredValue now only advances together with
applying it to the draft, so a value can never be marked seen without being
shown, and a later external change still syncs once the field is unfocused.
Policy is now explicit and documented: the in-progress edit wins on blur.

Refs #313
The test added alongside the draft sync guard fix passes against the
pre-fix component, so it never guarded that fix. Checked whether the old
guard was reachable: it is not. `commit()` ends with
`setDraft(String(clamped))` and runs on every route out of focus (blur and
Enter), so the draft is re-anchored to the store whenever editing stops,
and `lastStoredValue`, which is never rendered, can only suppress a resync
whose value is already on screen. A differential run of the old and current
components over 4000 random 7-step and 1500 random 12-step sequences of
focus, typing, Enter, blur, and external writes found no sequence where the
rendered value or the stored value differ; the same harness did report
differences once the old guard was deliberately mutated.

The test still pins behavior worth keeping, the mid-edit conflict policy,
so it is renamed to say that instead of claiming a regression, and its
comment now states that the guard defect was defensive rather than
observable.

Refs #313
useQueries without `combine` re-maps its results array on every render, so
the useMemo in useAlarmStates that listed it as a dependency never hit and
`states` got a new identity per render. The page derives its dwell list from
that in an effect, and reduceActiveMonitors stamps Date.now() into every
alarming entry, so each render produced a new list, set state, and rendered
again. The wall clock advancing between iterations meant it never converged.
It also starved the one-second cooling timer, whose effect lists the same
value and so cleared and recreated the interval before it could ever fire.

Building the states map inside `combine` fixes it at the source: TanStack
runs replaceEqualDeep over the combined value, so an unchanged poll yields
the very same object all the way to the consumer.

Measured with one alarming monitor over a 300ms window: 77 tile renders
before, 0 after.

Refs #313
…failed poll

A failed request mapped the monitor to `unknown`, which is not alarming, so
the dwell reducer marked a resident tile as cooling. The next successful poll
then counted that as a fresh alarm and incremented alarmCount, so one
continuous alarm on flaky wifi displayed as "x7".

React Query keeps the last successful payload in `data` across a failed
refetch, so parsing `data` unconditionally holds the monitor steady. A
monitor that has never succeeded still has no `data` and parses to `unknown`,
so the map stays total over the requested ids.

Refs #313
…ng state

If the monitors query failed, monitorsLoading was false and visible was
empty, so the page rendered "All quiet, watching 0 monitors" right next to
the error banner. A failing alarm fanout did the same thing more slowly, by
dwelling every resident tile out. For a page whose whole job is answering
"is anything alarming right now", a false negative during an outage is the
worst possible reading, so the quiet state is now gated on there being no
error.

useAlarmStates already reported isLoading and the page discarded it, so a
cold open painted an empty grid before anything had been asked. It now shows
the shared Skeleton while the first poll is outstanding.

Refs #313
The badge counted alarms against LIVE_ACTIVITY.defaultDwellSeconds, so a user
with a 300s dwell still got a 30s badge window: the badge went dark while the
page was still showing the tile. It now reads the profile's own
liveActivityDwellSeconds, falling back to the default only when no profile
settings exist yet.

The badge still only re-evaluates on a store write or a sidebar render, so it
can stay lit up to one dwell window after the last alarm ages out. That
ceiling and its upgrade path are recorded in a ponytail comment rather than
paid for with a permanent sidebar interval.

Refs #313
watching_count, overflow, and alarm_count were passed a count with no _one /
_other family behind them, so English read "Watching 1 monitors". Each now
has both forms in all five locales, matching the convention the existing
plural families already use. Chinese has one grammatical number, so its two
forms carry the same text.

The page test's i18next stub now resolves plural families the way i18next
does, so its assertions still read the real English copy.

Refs #313
minDwellSeconds was 0, which let a user turn off the exact damping the dwell
window exists for: every tile entry and exit mounts or unmounts a
MontageMonitor, which mints a ZMS connection key and sends CMD_QUIT, so a
zero dwell thrashes nph-zms processes on the user's own server. The floor is
now 5 seconds, with the reason recorded next to the constant.

Refs #313
"Live-Aktivität Einstellungen" left the compound half-open. German
Durchkopplung hyphenates it through: "Live-Aktivität-Einstellungen". Same
length, so it still fits the dialog title.

Refs #313
The user guide now covers what the page does before its first answer and when
it cannot reach the server, and names the 5 second dwell floor. The developer
guide and Flow 20 explain why the state map is built in the useQueries
`combine` option rather than a downstream useMemo, since that placement is
what keeps the page from looping.

Refs #313
The watched-count assertion matched "Watching N monitors" literally, so it
would fail on a server with exactly one watched monitor now that the string
has a singular form.

Refs #313
MontageMonitor hardcoded '/montage' as the route it was rendered from, so
a tile opened from Live Activity offered a back link to Montage. The
timeline navigate carried no state at all, so that page had nothing to
offer either.

The route is now a `fromRoute` prop defaulting to '/montage', passed to
useOpenMonitorEvents and as `{ state: { from } }` on the timeline
navigate, matching the shape the events page already reads. Live Activity
passes '/live-activity', which viewNameForPath now names too.

Refs #313
The list ordered by first-entered and never re-sorted, so the camera that
just went off could sit below one that alarmed minutes ago, and the tile
cap kept whoever arrived first rather than the freshest activity.

reduceActiveMonitors now sorts by lastAlarmingAt descending with the
monitor id as a tiebreak, so monitors alarming in the same poll hold a
fixed order instead of depending on states-map iteration. The sort runs
before the identity check, so an unchanged order still returns the
previous array and no tile re-renders.

Refs #313
The header read "Front Door(3):Alarmed". The id is noise to anyone looking
at their own cameras, and the state word ate the width the name needs at
320px.

The header is now a state icon plus the monitor name. The icon is a new
slot on MontageMonitor rather than a widened titleOverride, because that
string is also the truncation tooltip and has to stay a string. Replacing
a word with a glyph would drop the state for screen readers, so the icon
carries the same live_activity.state_* string as its accessible name and
its hover title. live_activity.tile_title now has no reader and is gone
from all five locales.

Refs #313
Tiles popped into the grid, stepped straight to opacity-60 when they
stopped alarming, and jumped to new rows now that the list re-sorts by
recency.

Entering tiles use tailwindcss-animate's fade-in-0 zoom-in-95, and cooling
is a 700ms fade with the color draining out, so no library was added.
Reordering runs the state update through document.startViewTransition when
the browser has it, with each tile carrying a view-transition-name; the
root snapshot is pinned to no animation so live video does not cross-fade
behind the tiles. Electron and Capacitor webviews without the API get the
instant update, as does anyone who asked for reduced motion.

The update path is now one dependency-free callback reading the previous
list from a ref. Putting `active` in the cooling effect's deps, or giving
that callback a per-render identity, would rearm the one-second interval
before it ever fired and cooling tiles would never expire.

Refs #313
Twelve was a guess. Sites large enough to want this page tend to have more
cameras than that alarming at once, and the overflow row is a worse default
than simply showing them.

Refs #313
A cooling tile carried opacity-60 and saturate-50 on the same element that
carries view-transition-name, so the browser snapshotted a dimmed box. A
captured image is generated with the element's own visual effects already
applied while ::view-transition-new is the live element, and the pair is
composited with the UA stylesheet's mix-blend-mode: plus-lighter, which only
cross-fades correctly when both halves are the same image. The two halves
never matched, because the tile was partway through its own 700ms fade.

That repeats: the grid reorders about once a second while ZoneMinder flaps a
winding-down monitor between alert (alarming) and tape (not alarming), and
every reorder runs a view transition that suppresses each tile's live
rendering for its duration. Measured against the reducer, a two-monitor tail
produces 11 transitions in 14 seconds, so the mis-composite covers the whole
window before a tile dwells out.

Move the cooling opacity and filter onto an inner element and leave the
captured box holding only the enter animation. The name stays permanent:
dropping it while a tile cools was tried in a Chromium harness and is worse,
because a name present in the old capture and absent from the new one leaves
an orphaned ::view-transition-old group painting the tile a second time at
its old position.

Refs #313
A cooling tile now renders identically to an alarming one. The only signal
that a monitor is winding down is its state icon leaving the tile header.

This supersedes a1bcccf, which moved the cooling opacity and filter onto an
inner element to keep them off the element carrying view-transition-name.
With the dim gone there is nothing to keep off it, so that wrapper and its
test go too rather than sitting in the tree as scaffolding around a rule
nothing can break any more.

The rule itself still holds and is worth stating: the tile is what the
browser snapshots, a captured image is generated with the element's own
visual effects already applied, ::view-transition-new is the live element,
and the UA stylesheet composites the pair with mix-blend-mode: plus-lighter,
which only cross-fades correctly when both halves are the same image. Nothing
on that element may animate opacity or filter. The replacement test asserts a
cooling tile's resolved class list is identical to an alarming one's, which
fails against the pre-a1bcccfb markup.

Refs #313
A bell for alarming, a raised shield for the part-way states, a struck-out
shield for winding down. prealarm moves in with alert: both mean ZoneMinder
is part way into an alarm decision, so they read the same to someone
scanning the grid.

The cooling tile no longer dims, so this glyph is now the only thing saying
a tile is on its way out.

Refs #313
reduceActiveMonitors sorted on lastAlarmingAt, which it restamps to the
current clock on every pass for whatever is alarming at that instant. ZM
walks a winding-down event through alarm, alert, tape or idle and back,
and only alarm and alert read as alarming, so a monitor leaves and
rejoins the alarming set every second or two through an event's tail and
the tiles traded places on almost every tick. Each reorder starts a view
transition, and captured elements are not painted while one runs, so the
grid stopped showing live video for a large share of exactly the window
a user is watching.

Entries now carry episodeStartedAt, which is set when a monitor joins and
left alone while it keeps alarming. A monitor that stops alarming has to
stay quiet for LIVE_ACTIVITY.episodeGraceSeconds before its next alarm
counts as a new episode and moves it back to the top, so a blip out of
the alarming set and straight back in does not move the tile.
lastAlarmingAt is still restamped every pass because the dwell window
runs from it; it just no longer decides position.

Driving the real reducer once a second over a realistic two-monitor tail:
13 reorders in 66 seconds before, 2 after, and both survivors are real
dwell expiries. The same script with the tail spelled alert/idle rather
than alert/tape churns identically, so treating tape as alarming would
not have fixed this, and it would strand every continuous recorder on the
page permanently.

Refs #313
The carousel sat above the video, so on a short viewport the player itself
started below the fold on a page whose whole point is watching the event.
It now sits between the player and the metadata, where it reads as a detail
of what is on screen above.
The count was wrong as well as unwanted. It incremented on any re-alarm
while cooling, with none of the grace window that guards the sort key, and
ZoneMinder drops a winding-down monitor out of the alarming set and back
every second or two. So a single event's tail read as x2 or x3 rather than
one alarm.

alarmCount had no other reader, so the field goes with the badge.
The poller loaded the monitor name map once, in start(), and never again.
start() runs while the profile is still bootstrapping, and a profile switch
deliberately suppresses re-login while it is in flight, so getMonitors can
come back unauthenticated. The catch logged a warning and left the map
empty, and nothing retried it, so every notification for the rest of the
session was titled "Monitor 4" instead of the camera's name.

An event naming a monitor the map does not know now reloads it, rate
limited so a genuinely deleted monitor does not refetch the list on every
poll. A failed reload leaves the existing names intact.
A wall display or a TV stick wants the tiles and nothing else, which is
what the Montage page already offers.

useFullscreenMode hardcoded `montageIsFullscreen`, so adopting it as-is
would have made the two pages share one fullscreen flag: entering
fullscreen here would silently have put Montage in fullscreen too. The
key is now a parameter and the hook moves to src/hooks, since it is no
longer a Montage-only concern.

Montage's own fullscreen bar is deliberately not reused: it carries the
kiosk lock and the tile-label toggle, so importing it would pull the
kiosk store and the PIN pad onto a page that offers neither.

Refs #313
A tile said that a monitor is alarming, never whether that started five
seconds or four minutes ago. `episodeStartedAt` already holds the answer,
so this needs no request and no new state beyond a clock.

Two things the label deliberately is not. It is not a MontageMonitor
prop: that component is memo'd to keep a live video tile from
re-rendering, and a value that changes every second would defeat that for
every tile at once, so the label is a sibling overlay and the state icon
is now memoized for the same reason. And it drives off the existing
one-second cooling interval rather than a timer of its own; that interval
is already scoped to exactly when tiles exist.

The tile moves into its own component to keep the page near its size
limit, and the stopwatch formatter lands next to the other time
formatting. Digits and colons only, so it needs no translation and fits a
320px tile.

Refs #313
The state glyph says something happened, never what. ZoneMinder's Cause
tells "Motion: All" from "Forced Web" from a detection, and the
notification payload already carries it.

The cause belongs to the alarm episode, so it lives on the entry the
reducer already keeps per episode rather than being looked up at render
time: notification events expire on their own schedule, and a tile that
lost its label halfway through would be worse than one that never had it.
An entry adopts a cause that lands after the poll had already found the
alarm, keeps it for the rest of the episode, and takes a fresh one only
when a genuinely new episode starts.

The hint selector now collects the causes in the same pass and the hint
set derives from its keys, so this adds no second subscription. Map
values stay plain strings, which is what lets useShallow compare it entry
by entry.

Only the notification stream reports a cause, so most tiles show none;
nothing else on the tile depends on it.

Refs #313
Once a user has looked at a camera it still holds a slot for the rest of
its dwell window. The cross clears it now.

A dismissal has to suppress re-entry or the control is a no-op with an
animation: the monitor is usually still alarming, so the reducer would
readmit it on the very next poll and the tile would pop straight back.
The dismissed set is a reducer input, so removal goes through the same
path as any other departure and the tile really unmounts, which is what
quits its stream.

Dismissals live in a page-local ref, not in profile settings: they are
not a preference, nothing renders them, and every read happens inside
applyStates, which publishes the list a dismissal changes. A dismissal is
released once its monitor has genuinely stopped alarming or the page has
stopped watching it, so the camera's next separate alarm shows normally.
Releasing runs after the reduce, not before, or a tile dismissed while
already cooling would survive its own dismissal.

Refs #313
The page only ever showed "now", so a camera that alarmed while nobody
was looking vanished without trace once its dwell window closed. A thin
row under the grid names what left in the last few minutes.

Static by design: names and how long ago, no players. Mounting tiles
there would reopen the streams the dwell window had just closed. It is
bounded in both count and age, with both bounds in LIVE_ACTIVITY, so it
stays a footnote rather than a second, unbounded list.

Departures are recorded inside applyStates, the only place the old and
new lists exist together, and queued before the list is published so the
strip lands in the same commit rather than repainting partway through a
view transition. It starts no transition of its own and cannot reorder
the grid. The existing one-second interval now also ages it out, and
keeps running while the strip has entries so it cannot freeze on screen
after the last tile has gone.

The page header and the fullscreen bar move into a chrome component to
keep the page near its size limit.

Refs #313
The fullscreen toggle is a new interactive element and a behavior change,
so it needs an outcome-based scenario: chrome hidden, choice remembered
across a reload, and Montage still where it was left. That last step is
the regression the shared settings key would have caused.

The other five additions all need a camera that is actually alarming, so
they stay covered by unit tests.

Refs #313
The page chapter now covers the tile component and why its overlays are
siblings rather than props, where a cause is recorded and why it lives on
the episode, how dismissal suppresses re-entry, the bounds on the cleared
strip, and the generalized fullscreen hook. Flow 20 gains the dismissal
step.

Refs #313
The glyphs changed to a bell, a raised shield and a struck-out shield when
the cooling dim was removed, but the user guide still described the siren,
warning triangle and hourglass it replaced.

Refs #313
Its chip read "FrontDoor 0:45", using the same mm:ss format as the per-tile
elapsed label but meaning the opposite: the tile counts how long an alarm has
been running, the chip counted how long since one ended. Same shape, inverted
meaning, no tooltip to tell them apart.

Removed rather than relabelled: the heading already said the list was recent,
the list was bounded to a few minutes, and the page reads fine without it.

Refs #313
The unmount cleanup in useStreamLifecycle treated any cleanup of its
empty-dependency effect as an unmount and removed the <img> src. React
runs that cleanup against a live tree in three situations that are not
unmounts (StrictMode's mount double-invoke, revealing a hidden Suspense
subtree, a Fast Refresh update), and none of them is followed by a
re-render: React's virtual DOM still carries the same URL, so the next
diff writes nothing and the tile keeps a mounted <img> with no src, which
is the browser's broken-image glyph. The same cleanup also sent CMD_QUIT,
killing a stream the user was still watching.

React detaches a real unmount's subtree in the mutation phase, before the
passive cleanup runs, so a media element still connected to the document
means the component is staying. Bail out in that case. A real unmount is
unaffected and still aborts the nph-zms connection.

refs #313
The activity glyph read as a heartbeat, which is what the page shows when
something IS happening. A closed eye says nothing is being watched right now.

Refs #313
The page laid tiles out in a plain CSS grid, which fixes their width and
says nothing about their height, so every camera got the same box and its
video was cropped or letterboxed to fit. MontageMonitor takes an optional
mediaAspectRatio, applied to its video area with the flex-1 dropped, so the
card's height is the h-8 header plus the video: the same sum useMontageGrid
computes for a Montage tile. The ratio lands on the video area rather than
the card because on the card the header would eat into the camera's shape.
Montage passes nothing and keeps sizing through react-grid-layout.

The grid is items-start, so a tile keeps the height its camera gives it
rather than being stretched to the tallest tile in its row, which would add
dead space under the picture with the elapsed label floating in it.

Refs #313
Montage renders its grid with margin and containerPadding both zero, so the
feeds meet edge to edge. Live Activity had an 8px gap on both its grids,
which read as a different page rather than the same wall.

Refs #313
A CSS grid row is as tall as the tallest tile in it, so a 16:9 camera
beside a portrait fisheye left black space under the short tile and
started the next row below the tall one. items-start kept each tile at
its natural height but never shortened the row, which is the actual
cause.

The grid now uses a one pixel row unit and gives each tile a
grid-row-end span of its own height, computed from the measured grid
width, the column count and the camera's aspect ratio: the same
header-plus-video sum useMontageGrid computes for a Montage tile. Tiles
no longer share a row, so auto-placement fills the first free slot and
the left-to-right, most-recent-first order is untouched. CSS
multi-column masonry would have reversed that order and is not used.

The width comes from useMeasuredWidth, wrapping montage's
useContainerResize: first measurement immediate, everything after it
debounced, and rounded into state so sub-pixel resizes cannot re-render
every tile. The loading skeleton carries the same ref, so the width is
known before the first tiles arrive.

Refs #313
A bare 5 in a box does not say 5 what, and the answer was only in the
description underneath. The poll interval and dwell window now carry the
unit next to the field. Maximum tiles stays bare, being a count rather than
a duration.

Refs #313
w-24 was wider than any value these fields hold: the poll interval tops out
at 60, the dwell window at 300, the tile cap at 40. w-20 matches the number
fields in the live streaming settings section.
Some cameras record events in a container the app cannot play. The only
recovery was reactive: mount the MP4 player, let it fail, show a red toast,
then switch to ZMS. That state was per-mount, so every visit to every event
on the camera paid the failed load and the toast again.

The Video tab of the monitor settings dialog now carries a toggle that
forces every event for that monitor through ZMS, so MP4 is never attempted
and the error path is unreachable.

It is an app preference keyed by monitor id, not a ZoneMinder monitor
column, so it applies on toggle and stays out of the dialog's change
detection and save payload. It lives beside the Go2RTC row in a new
MonitorAppPreferences component, which makes that boundary structural
rather than a comment, and pulls SettingsRow out into its own module so
both files can use it without a cycle.

EventDetail reads the setting off the resolved event rather than seeding
the fallback state, because that state is created before the event query
resolves and seeding it would flash one MP4 frame before the monitor id is
known. When the setting decides playback, log.eventDetail records that the
monitor is set to always use ZMS, so a reader can tell why MP4 was never
tried.

Refs #315
Every other top-level destination had a single-key shortcut; the new page
was the only one without. `a` for activity, which was unclaimed.

Refs #313
@pliablepixels pliablepixels added the core Changes core behavior; full review ceremony label Jul 31, 2026
@pliablepixels
pliablepixels enabled auto-merge July 31, 2026 23:53
@pliablepixels
pliablepixels merged commit 2556feb into main Jul 31, 2026
9 of 10 checks passed
@pliablepixels
pliablepixels deleted the feat/313-live-activity branch July 31, 2026 23:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Changes core behavior; full review ceremony

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant