535a50192c
MainViewModel.Trax.cs: music-player state (_musicPlayer/_music/_musicPlaying, TraxIdleBrush), TRAX UI (IsMusicPlaying/TraxError/TraxDotBrush/TraxToolTip), interaction (TraxLeftClick/TraxRightClick/PickTraxTrack), playback (LoadTrax/PlayTrax/PauseTrax/OnTraxPlaybackEnded/RefreshTraxUi), local loopback ducking (UpdateTraxLocalGain). Core keeps the ctor event hookup, LoadLayout/Shutdown/SaveLayoutNow call sites, and Scenes partial's hotkey dispatch. Verify: 0 warnings, 246 passed, 2 known failures only.
90 lines
15 KiB
Markdown
90 lines
15 KiB
Markdown
# ViewModels — index
|
||
|
||
MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions.
|
||
|
||
| File | Purpose |
|
||
|------|---------|
|
||
| `MainViewModel.cs` | `public partial class MainViewModel : ViewModelBase`. The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **go-live (ship step 7): `BeginGoLive` accepts the dialog → sets `StreamVisibility` (always "Private") → fires `CreateBroadcastAsync` (stores `_currentBroadcastId` for TASK 9's bind/transition; failure → `StreamStatus.Error`) + `_framePump.StartAsync()`**; **End Livestream signs out** (`StopStream` clears session + token + `_currentBroadcastId` — crash-safe). **Connected account:** top-bar avatar/name (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`, cleared on End). **Element rows:** scenes are pure selection rows (no per-row icons); source rows carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; the Backdrop's trash slot stays **reserved** via `HiddenBoolToVisibilityConverter` so the edit/eye icons keep their fixed columns — TASK 13); duplicate resource names get a no-space incrementing suffix via shared `NextSourceName` (Image, Image2, Image3… — next free number derived from actual names, so deletions never collide; used by `AddSource` + `AddReusedImage`). **Audio (KISS — the mic is the creator's only audio control; capture SHIPPED, runs for the app's lifetime so the meters preview live):** the sound meter is a **READ-ONLY realtime level display**: fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)` (`MeterFillWidth`/`MeterBrush`, green→yellow→red, zone markers at 60%/80% — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS scale with +10 dB amplification so real speech/game levels occupy the bar and peaks hit red at maxed volume), where `AudioLevel` (live input, 0 with no input) is fed by the audio mixer once capture lands and `MicVolume` acts as a gain on ambient noise; while the slider is dragged the bar previews the slider position (`SetVolumeAdjusting`), returning to the live level on release (0 with no input — clicking the meter does nothing); `MicVolume` (default 0.8) + read-only `MicMuted`/`MicMuteText`/`ToggleMicMuteCommand` — **MicVolume drives MicMuted** (muted ⇔ volume 0): sliding to 0 flips the speaker to muted, sliding up from 0 clears it; muting stores the prior volume, unmuting restores it (default 0.8 if unknown) and flashes the meter to the restored position ~300ms (`BeginVolumeFlash`/`EndVolumeFlash`); `MicSourceName` = picked voice source, shown left-justified inside the meter bar (the fill runs at 75% opacity so the text + ruler markings show through); `OpenMicPickerCommand`/`PickMicrophone()` open the `MicPickerDialog` (a picked device takes effect immediately — `PickMicrophone` swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running); the **MIC label is a button** (`OpenMicPickerCommand`) with a **status dot** (`MicStatus`, `Models/MicStatus`: green = `MicConnected` via the source's `Started` event, yellow = `MicFailed` — in use/unplugged, red = no mic device at startup; `MicStatusBrush`/`MicStatusToolTip`); capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live (`BeginGoLive`/`StopStream` no longer touch the mixer) — zero devices = red dot + the mixer never starts. **Game audio bar** (desktop/game, **overlaid at the bottom of the preview window** — bottom-center chip, a mirror of the mic bar; **always visible** — the TASK 4 show/hide gating (`IGameAudioDetector`/`GameAudioDetector`/`GameAudioHysteresis` + the 250ms poll timer) was **removed 2026-08-15** because the bar kept hiding the desktop meter whenever no full-screen game with sound was up): `GameAudioLevel` (loopback meter via `LoopbackLevelChanged`, scaled by volume), `GameMuted`/`GameMuteText`/`ToggleGameMuteCommand`, `GameAudioVolume` (0..1 volume slider), `Begin/End/CancelGameVolumeFlash` (mirrors the mic bar's volume flash); the game speaker + slider share the mic bar's `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` code-behind pattern. **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; the Windows camera picker ALWAYS opens so the creator chooses — never silently reuses the previous camera; picking a different camera swaps the app-wide identity via `SwapWebcamIdentityAsync`, same path as `ChangeWebcamAsync`; propagates the running shared bitmap via `CameraManager.GetPreviewBitmap` first, so a webcam added mid-session never renders a transparent container; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load (re-propagates the shared bitmap to every config), `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds(config, sceneName)` (per-scene size cap seam: 50%-per-dimension everywhere, half-screen-AREA in Chat via `MaxWebcamWidthFor`/`MaxWebcamHeightFor`). **Scenes (five-scene catalog):** empty DB seeds Starting/Live/BRB/Chat/Ending (`SceneCatalog.All`); `AddScene(name)` accepts only canonical, missing names; `MissingScenes`/`ShowAddScene` drive the "+" button (hidden once all five exist; its menu lists only the missing scenes via `AddSceneCommand`). **Screen backdrop (Live-only by policy):** owns `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture), `internal static EnsureBackdrop` heals one per backdrop-enabled scene (gated on `Scene.HasBackdrop`), `ReacquireScreenCaptures`/`RefreshBackdropAutoCapture`/`NoteBackgroundWindow` key by full-screen game monitor or the **primary display** via `Win32FullScreenDetector` (`GetDisplays()`/`PrimaryMonitorIndex()`), `ChangeBackdropCaptureAsync` (OS picker) + `SetBackdropCapture(DisplayInfo)` (in-app "Capture Display"), `RedesignateBackdropAsync` re-targets all backdrops and releases orphaned sessions, `internal static EnforceBackdropPolicy` normalizes `Scene.HasBackdrop` by name on every load + strips lingering non-Live backdrops, `CanChangeBackdrop` gates the capture menu to Live (the only scene with a backdrop), `BackdropImage` hides the preview watermark (`ShowPreviewPlaceholder`). **REC sign (2026-08-13):** top-center indicator always visible — `RecDotBrush` (offline `#555555`, live `#e94560`, live-private `#8f1f1f`), `RecTextBrush` (dim offline, white live), `RecDotOpacity` (0.55 offline, pulsing 1.0/0.35 live via `_recDotPulse` flipped on the live tick), `IsLivePrivate` (`IsLive && StreamVisibility == "Private"`) — notified from the `StreamStatus` + `StreamVisibility` setters; `LiveIndicatorVisible`/`LivePulseOpacity` removed. **Mic mute icon:** a second 16px clickable glyph (mic, red + slash when muted) between the meter and the speaker on the mic bar — same `ToggleMicMuteCommand`. **TASK 11 (2026-08-15):** the gain seams (`AudioGainProvider` → `micGain`/`loopbackGain`) reach the mixer at construction so volume sliders + mutes are stream-honest; the game bar also scales TRAX locally (`MusicPlayer.LocalGain`); `MicSourceName` persists via `LayoutStore`'s `Settings` table + restores before first `Start`; TRAX moved into the game audio bar (left of "Desktop Audio") in the preview overlay. **Health stats (TASK 4 ship step 6, 2026-08-13):** `OnFramePumpHealthUpdated` marshals `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration — raised on the stderr thread) onto the UI thread into `CurrentHealth` (bottom bar bindings); `ResetHealth(status)` zeroes dropped/duration on go-live/End so stats never linger |
|
||
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests |
|
||
| **About hub (TASK 11, in `MainViewModel`)** | the About overlay is the in-app creator hub: `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public consts, live) + `PremiumUrl` (tabled billing seam — TASK 12 → the greyed "Unlock Premium" button lights up when filled); `OpenLicensesCommand` → `ShowLicenses()` loads the shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` and sets `IsLicensesOpen` (in-app scrolling panel, never the OS viewer); `BackToAbout_Click` in `MainWindow.xaml.cs` flips it off. |
|
||
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
|
||
| `CameraPickerViewModel.cs` | Add Webcam dialog: async camera list (loading / has / none states), `UseRequested(CameraDeviceInfo)`/`CancelRequested` |
|
||
| `MicPickerViewModel.cs` | Select Microphone dialog (voice source): async mic list (loading / has / none states), `UseRequested(MicrophoneDeviceInfo)`/`CancelRequested` |
|
||
| `SocialsDialogViewModel.cs` | Social Media Site Promotion dialog (six fixed slots, `SocialSlotViewModel`): row 0 = YouTube sign-in gate (sign-in provider fills the channel, delete = sign-out); rows 1-5 validated entries / empty / freemium locks 2-5 (`isPremium` seam); `DetectService` → `ISocialValidator` on confirm (input snaps to text + real logo on success, red do-not + stays editable on failure); `CanSave` blocked while any slot edits/validates/errors; `BarEnabled` switch; commit via `CommittedEntries` + `BuildSocialsConfig()` (YouTube first) + `SaveRequested`/`CancelRequested`. WPF-free — the window owns the dialogs (delete confirm, sign-in gate) and calls `StartEdit`/`ConfirmEdit`/`DeleteSlotAsync`. |
|
||
|
||
Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
|
||
for what the ViewModels drive; base class in [`Helpers/ViewModelBase.cs`](../Helpers/ViewModelBase.cs).
|
||
|
||
## MainViewModel partial split (in progress)
|
||
|
||
`MainViewModel.cs` is being split into one file per functional area, zero behavior
|
||
change, one commit per partial (each commit a roll-back point). **Commit naming:**
|
||
titles carry the series id (`Commit N/12`), tags are `refactor-commit-N` — e.g.
|
||
`git show refactor-commit-1`. Every partial lands with a `ViewModels/index.md`
|
||
update in the SAME commit.
|
||
|
||
Progress (Commit N/12 · tag):
|
||
- **Commit 1/12 · `refactor-commit-1`: Account landed** → `MainViewModel.Account.cs`
|
||
(session restore `LoadSavedSessionAsync`, sign-in/out/change account,
|
||
`IsConnected`/`AccountAvatarUrl`/`AccountDisplayName`/`AccountStatusLightToolTip`).
|
||
- **Commit 2/12 · `refactor-commit-2`: License landed** → `MainViewModel.License.cs`
|
||
(Polar license state: `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/
|
||
`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`,
|
||
`ValidateLicenseAtStartupAsync`, `ActivateLicenseAsync`).
|
||
- **Commit 3/12 · `refactor-commit-3`: Scenes landed** → `MainViewModel.Scenes.cs`
|
||
(`Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, transitions
|
||
`SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/
|
||
`ApplyTransitionToLive`, global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/
|
||
`RefreshHotkeyLabels`, scene-gating hints, `MissingScenes`/`ShowAddScene`/
|
||
`CanChangeBackground`, scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`,
|
||
`AddScene`).
|
||
- **Commit 4/12 · `refactor-commit-4`: Background landed** → `MainViewModel.Background.cs`
|
||
(background model `EnsureBackground`/`CreateBackground`/`NormalizeBackgrounds`/
|
||
`HealBackgrounds`/`StampDefaultBackgroundAsset`, display picker `Displays` +
|
||
`SetBackgroundCapture`, screen capture `ReacquireScreenCaptures`/
|
||
`ResolveAutoCaptureKey`/`RedesignateBackgroundAsync`/`ClearBackgroundCaptureAsync`/
|
||
`OnScreenPreviewBitmapChanged`/`OnFullscreenMonitorChanged`/
|
||
`RefreshBackgroundAutoCapture`/`BackgroundShowDesktop`, preview fns
|
||
`ActiveBackgroundImage`/`BackgroundImage`/`UpdateActiveBackground`/
|
||
`UpdateBackgroundImage`/`RefreshSnapshotsAsync`/`LoadBackgroundImage`,
|
||
`ChangeCaptureCommand`/`RefreshCaptureCommand`/`SetBackgroundDisplayCommand`/
|
||
`ToggleShowDesktopCommand`/`BrowseBackgroundCommand`). `ShowPreviewPlaceholder`
|
||
stays in core.
|
||
- **Commit 5/12 · `refactor-commit-5`: Webcam landed** → `MainViewModel.Webcam.cs`
|
||
(webcam identity `_webcam`/`ReacquireWebcam`/`SwapWebcamIdentityAsync`, per-scene
|
||
configs `AddWebcamToStagedSceneAsync`/`ShowWebcamInStagedScene`, capture lifetime
|
||
`OnCameraPreviewBitmapChanged`/`OnCameraFailed`, gating `CanAddWebcam`/
|
||
`CanChangeWebcam`/`CanShowWebcamInStagedScene`/`IsWebcamSelected`/`WebcamError`,
|
||
size safeguard `WebcamMax*`/`ClampWebcamToBounds`/`HealLegacySquareRect`,
|
||
`AddWebcamCommand`/`ChangeWebcamCommand`/`ShowWebcamCommand`).
|
||
- **Commit 6/12 · `refactor-commit-6`: Audio landed** → `MainViewModel.Audio.cs`
|
||
(mic bar `AudioLevel`/`MeterFillWidth`/`MeterBrush`/`MicVolume`/`MicMuted`/
|
||
`MicMuteText`/`MicSourceName`/`ToggleMicMute`/`PickMicrophone`, volume flash
|
||
`SetVolumeAdjusting`/`BeginVolumeFlash`/`EndVolumeFlash`/`CancelVolumeFlash`,
|
||
mic status dot `MicStatus`/`MicStatusBrush`/`MicStatusToolTip`/`MicProblemBrush`,
|
||
game bar `GameAudioLevel`/`GameMeterLevel`/`GameMeterFillWidth`/`GameMeterBrush`/
|
||
`SetGameVolumeAdjusting`/`GameAudioVolume`/`GameMuted`/`GameMuteText`/
|
||
`ToggleGameMute`, `PushSystemVolume`, mixer handlers `OnMicLevelChanged`/
|
||
`OnLoopbackLevelChanged`/`OnMicConnected`/`OnMicFailed`, `StartMicCaptureAsync`,
|
||
audio fields + `ToggleMicMuteCommand`/`ToggleGameMuteCommand`/`OpenMicPickerCommand`).
|
||
TRAX stays in core (next commit).
|
||
- **Commit 7/12 · `refactor-commit-7`: Trax landed** → `MainViewModel.Trax.cs`
|
||
(`_musicPlayer`/`_music`/`_musicPlaying` + `TraxIdleBrush`, `IsMusicPlaying`/
|
||
`TraxError`/`TraxDotBrush`/`TraxToolTip`, `TraxLeftClick`/`TraxRightClick`/
|
||
`PickTraxTrack`/`LoadTrax`/`PlayTrax`/`PauseTrax`/`OnTraxPlaybackEnded`/
|
||
`RefreshTraxUi`/`UpdateTraxLocalGain`). Core keeps the ctor hookup
|
||
(`_musicPlayer.PlaybackEnded += ...`), the `LoadLayout`/`Shutdown`/
|
||
`SaveLayoutNow` call sites, and the Scenes partial's hotkey dispatch into `TraxLeftClick`.
|
||
|
||
- Core file keeps: fields, ctor, `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`,
|
||
`SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush`, `OpenUrl`/`ComposeEmail`,
|
||
`SubmitBug`/`SubmitFeature`, window-geometry pass-through.
|
||
- Partials: `Account` ✔ (Commit 1), `License` ✔ (Commit 2), `Scenes` ✔ (Commit 3), `Background` ✔ (Commit 4),
|
||
`Webcam` ✔ (Commit 5), `Audio` ✔ (Commit 6), `Trax` ✔ (Commit 7),
|
||
`Socials` (social bar + dialog), `Streaming` (go-live/record + health + quality tiers + media source pipeline),
|
||
`Chat` (YouTube chat + mock preview + chatbox render), `Overlays` (settings/bug/feature/about
|
||
panels + license entry).
|
||
|
||
The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split
|
||
proceeds; each partial carries its own `using`s and re-declares nothing from core.
|