Skip to content

Split MainWindowViewModel into feature view-models - #80

Merged
danielchalmers merged 8 commits into
mainfrom
claude/split-main-viewmodel
Sep 24, 2026
Merged

danielchalmers merged 8 commits into
mainfrom
claude/split-main-viewmodel

Conversation

@danielchalmers

Copy link
Copy Markdown
Owner

MainWindowViewModel was about 1,900 lines owning every feature. It's now a roughly 430-line shell that composes one view-model per feature and owns only what spans them. One commit per extraction, so merge without squashing to keep them.

View-model Owns
ErrorOverlayViewModel the notice over the video area (errors, first-run empty state, FFmpeg prompt)
AboutViewModel version, environment, and update check
CameraViewsViewModel enlarged camera or grid, the camera strip, event-camera focus, export camera
PlaybackViewModel the player: loading the selection, transport, seek gesture, markers, now-playing, speed
ClipLibraryViewModel scanning, search, the selected clip, clip context actions
TrimViewModel in/out marks, range export, "Save event clip"
MainWindowViewModel startup, the overlay logic that combines state across features, About page toggle, FFmpeg download, keyboard shortcuts

The new view-models live in SentryDeck/ViewModels/. The window binds to them through the shell (Playback.CanSeek, Library.SelectedClip, and so on), and the code-behind talks to Cameras and Playback directly.

Behavior

No intended behavior change. Two internal changes are worth reviewing:

  • IsLoading was one flag written by the clip scan, the FFmpeg download, and clip loading, which never overlap. The shell now computes it from the three.
  • The player reports clip changes back to the list through PlaybackViewModel.CurrentClipChanged instead of writing the selection itself.

The unused render-progress state (IsRendering/RenderProgress, never set outside a test) is removed first, so it isn't carried into the split.

How this was checked

  • UI regression harness (not in the repo). It drives the real window through its UI only: automation-peer clicks, list selection, slider gestures, and keys through the shortcut entry point. It records every data-bound property value in the visual tree, plus each camera's Flyleaf state, at 34 checkpoints across search, playback, grid and camera views, trim, speed, frame stepping, next/previous, end of clip, stop, the About page, and rescan.
    • Two runs on main were identical. After every commit the diff against main was 0 lines with 0 binding errors.
    • A deliberately broken binding was caught both as a binding error and as a snapshot diff.
  • Playback stress harness. Rapid switching, stop and pause during open, scrub and arrow storms, end-of-clip races, frame stepping, and corrupt-footage recovery all pass on this branch. Clip-open latency matches main within run-to-run noise.
  • Unit tests. 395 pass, and each commit builds and passes on its own. New tests cover the three wirings that now cross view-models (player to list, scan to overlay, media rebuild to trim marks), and each was confirmed to fail with its wiring removed.

Found along the way (not changed here)

  • The clip list realizes all 174 items at startup, so virtualization isn't taking effect and every thumbnail is decoded up front.
  • Next and Previous follow scan order (oldest first), while the list shows newest first, so "Next clip" is disabled on the top clip.
  • Stop is disabled while paused (CanStop is IsPlaying || IsLoading).

IsRendering and RenderProgress were never set outside a test, so the loading overlay always read "Loading..." with an indeterminate bar. The overlay now says so directly, which keeps the dead state from being carried into the view-model split.
The first step of splitting MainWindowViewModel by feature. ErrorOverlayViewModel owns the notice that covers the video area (title, details, dismissability, the first-run empty state, and the FFmpeg download prompt), and every feature reports through the one instance the main window owns. The overlay-visibility logic that combines errors with loading and selection state stays on the main view-model.
AboutViewModel owns what the About and help page shows: this build's version and runtime, and the result of the update check. Opening and closing the page stays on the main view-model, since the window and keyboard handling drive it.
CameraViewsViewModel owns which camera is enlarged (or the grid), the selector strip built from the cameras a clip actually recorded, the event-camera mapping, and which camera an export of the current view uses. The window now listens to it directly to re-parent its Flyleaf hosts, and the main view-model only tells it when the selected clip changes.
PlaybackViewModel owns the player: loading the selected clip, transport commands, the seek gesture and scrubbing, the event marker, chunk seams and gap ticks, the now-playing badge, and speed. Siblings reach the player only through a small surface (the playlist, a raw stop, the open clip and its media source), and the player reports clip changes back through an event instead of writing the list selection itself.

IsLoading used to be one flag written by the clip scan, the FFmpeg download, and clip loading, which never overlap. The main view-model now computes it from the three, while CanSeek and the transport gates read only the playback part, which is the only part that was ever set whenever they could be true.
ClipLibraryViewModel owns the clip list: scanning dashcam folders, search with its debounce, the selected clip, and the per-clip context menu actions (open folder, copy, delete, show on map). The main view-model reacts to the selection (camera tiles, trim, loading the clip) and feeds clip changes from the player back into it.
TrimViewModel owns the trim panel's in/out marks and both exports (the marked range and "Save event clip"). It follows the player on its own: its commands re-check whenever seeking becomes possible or impossible, and the marks are dropped when the player rebuilds the clip's media, since they no longer point at the same footage.

This finishes the split. MainWindowViewModel is down from about 1,900 lines to about 430 and now only composes the feature view-models and owns what spans them: startup, the overlay over the video area, the About page toggle, the FFmpeg prompt, and keyboard shortcuts.
Three connections now cross view-model boundaries and had no direct test:
- The player reports clip changes back to the list, so Next moves the list selection.
- The loading overlay follows the clip scan, not only clip loading, now that IsLoading combines the scan, the FFmpeg download, and playback.
- The trim marks are dropped when recovery rebuilds a clip's media, now that TrimViewModel follows PlaybackViewModel itself.

Each test was checked to fail with its wiring removed.
@danielchalmers
danielchalmers merged commit 5ade169 into main Sep 24, 2026
1 check passed
@danielchalmers
danielchalmers deleted the claude/split-main-viewmodel branch September 24, 2026 13:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant