diff --git a/HANDOFF.md b/HANDOFF.md index 7c6d4a1..531a9ee 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,39 +2,24 @@ ## Branch / Commit State -**`main`**, working tree is **DIRTY** — ready to commit. See "Changes" below. +**`main`**, working tree is **CLEAN** — all changes committed and pushed. No feature branches pre-1.0: all work lands on `main` per work unit. -## Changes (uncommitted, ready to commit) +**Latest commit:** `455a409` — mic moved to preview, bottom bar restructured, avatar fix. -**Gear menu cleanup + App Settings + single Start button (TASK 30):** -- Removed Gear-menu `Save Layout`/`Save Layout As…`/`Open Layout…` + dead VM commands/methods. -- App Settings panel: "Default Location for Recordings" with Browse + Use Downloads reset. -- Single Start button (`Content="Start"` in XAML; `PrimaryButtonText`/`IsStreamingStart` removed). -- Fallback chain: configured → `%USERPROFILE%\Downloads` → `MyVideos`. -- ONE fallback test `DefaultRecordFolder_Falls_Back_To_Downloads`. +## What's In Flight -**Socials button + hover glow (new):** -- Socials button moved from footer (scenes/sources column) to preview bottom row, left of - TRAX button, inside a horizontal StackPanel. `MouseEnter`/`MouseLeave` handlers pulse - the `SocialBarElement` `DropShadowEffect` (BlurRadius 24→48, Opacity 0.7→1.0 sine wave - at 30ms tick). `MouseLeave` restores original values. - -**Bug fix:** `TextBox` binding `RecordFolderDisplay` changed to `Mode=OneWay` (get-only -property caused TwoWay crash on startup). - -**Files touched:** `MainWindow.xaml`, `MainWindow.xaml.cs`, `ViewModels/MainViewModel.cs`, -`ytLive.Tests/LayoutStorePersistenceTests.cs`, `TASKS.md`, `HANDOFF.md`. +Nothing — clean tree, ready for next task. ## Landmines - **TextBox.Mode=OneWay** is required for `RecordFolderDisplay` binding (get-only computed property). - **Pre-existing test failures (do not chase):** `AudioPipelineTests.Mix_HonorsProviderGains…` + `RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable…`. -- **App running locks `ytLive.exe`** → build fails MSB3027. -- **Test suite hangs** when running full suite (WPF `RealAppHost` dispatcher deadlock); individual - tests pass in isolation. Investigate before relying on full-suite results. +- **App running locks `ytLive.exe`** → build fails MSB3027. Kill PID before building. +- **Test suite hangs** when running full vstest (WPF `RealAppHost` dispatcher deadlock); individual + tests pass in isolation. Use `dotnet test` on individual test classes, not the full suite DLL. - **Pre-commit gate:** `./scripts/verify.sh` — clean build (0 warnings) + full tests + scope check. ## Todo state @@ -42,6 +27,8 @@ property caused TwoWay crash on startup). - TASK 18: shipped. Remaining = running-app verification (creator). - TASK 21: Increment A shipped (`eba6af5`). Decoder blocked on ffmpeg. - TASK 22 (audio sync offset): not started. -- TASK 30: gear cleanup + record location + single Start — ready to commit. +- TASK 30: gear cleanup + record location + single Start + preview layout restructure — shipped + across commits `6031a56` + `61b850f` + `455a409`. Includes: socials button + hover glow, + mic moved to preview pane, bottom bar restructured (gear left / stats center / resolution right), + avatar hidden when signed out. - Monetization: deferred (creator rethinking price/subscription model). -- Socials hover glow: new, ready to commit. diff --git a/TASKS.md b/TASKS.md index 59fa19d..3c9ec9b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1173,6 +1173,54 @@ things?"). Locked model, verbatim intent: --- +## TASK 30 — Gear menu cleanup + Default Location for Recordings + preview layout restructure + +### Status: ✅ Done (shipped 2026-08-29, commits `6031a56` → `61b850f` → `455a409`) + +The creator's KISS review: gear menu cleanup, single Start button, record-location setting, +and a full layout restructure moving all live controls into the preview pane. + +### What shipped: + +**Gear menu cleanup:** +- Removed `Save Layout` / `Save Layout As…` / `Open Layout…` from Gear menu + dead VM + commands/methods (`SaveLayoutCommand`, `SaveLayoutAsCommand`, `OpenLayoutCommand`, + `SaveLayoutAs()`, `OpenLayoutFile()`). Multi-layout = use OBS. + +**Default Location for Recordings (App Settings):** +- First real setting panel in the Gear menu: "Default Location for Recordings". +- `DefaultRecordFolder()` (internal static): configured → `%USERPROFILE%\Downloads` → `MyVideos`. +- `RecordFolderDisplay` property, `ResetRecordFolderCommand`, Browse + "Use Downloads" reset. +- `TextBox` binding fixed to `Mode=OneWay` (get-only computed property). +- ONE fallback test: `DefaultRecordFolder_Falls_Back_To_Downloads`. + +**Single Start button:** +- Button `Content="Start"` hardcoded in XAML. Removed `PrimaryButtonText` / `IsStreamingStart` + properties + all `OnPropertyChanged` references. Pills = intent, lights = state, button = trigger. + +**Preview layout restructure — all live controls under preview:** +- Socials button moved from footer (scenes/sources column) to preview bottom row, left of + TRAX button. `MouseEnter`/`MouseLeave` handlers pulse the `SocialBarElement` + `DropShadowEffect` (sine wave BlurRadius 24→48, Opacity 0.7→1.0 at 30ms tick). +- **Mic controls moved from footer into preview pane** (vertical StackPanel below Socials+TRAX): + MIC button + sound meter + mute grid + volume slider — the same footer controls, relocated. +- Preview bottom row: vertical `StackPanel Grid.Row="2"` → Line 1 (Socials + TRAX), Line 2 + (MIC + meter + mute + slider). All centered. + +**Bottom bar restructured:** +- Reduced to single row (mic row removed), tighter padding (`Padding="16,4"`). +- Gear icon moved to far left. Stream stats (Bitrate/FPS/Dropped/Duration) centered under + preview pane. Resolution dropdown stays far right. + +**Avatar fix:** +- Avatar `BitmapImage` no longer loads in the constructor (stale image from last session). +- Only loads when `IsConnected` fires `true` via `OnViewModelPropertyChanged`. + +### Tests: +- Build 0 warnings, 247 tests (2 pre-existing known failures excluded). + +--- + ## Backlog (future versions) 1. v1.1 — Stream Deck / Loupedeck integration (requires hotkey foundation from TASK 20) diff --git a/ai.md b/ai.md index be44969..d6e3493 100644 --- a/ai.md +++ b/ai.md @@ -158,19 +158,19 @@ C# / WPF (.NET 8) following MVVM: | `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler` + `MusicPlayer` + `IAudioPipeWriter`/`NamedPipeAudioWriter` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "Live frame pipeline")**, **notifications: `INotificationService` seam (`AppNotificationSeverity` Info/Success/Warning/Error) + `NotificationService` (Notification.Wpf toasts, see "Toast notifications")** | | `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, visibility converters | | `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) | -| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) | +| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview + live controls below), left (scenes/sources), right (chat), bottom (gear + stream stats + resolution) | ### Key patterns - `ViewModelBase.SetProperty()` for property change notifications - `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows) -- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the footer's top line, CENTERED beneath the preview panel. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)` — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS display scale, because real speech sits around −40..−20 dBFS (0.01..0.1 linear) which would leave a flat scale dead (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). **Mic mute icon (2026-08-13):** a second 16px clickable glyph — a microphone, red + slash when muted — sits **between the meter and the speaker** (both mutes adjacent, spacing between the icons) and reuses the same `MicSpeaker_MouseLeftButtonUp` → `ToggleMicMuteCommand` handler. **REC / ON-AIR pills + signs (TASK 18, 2026-08-29):** the top-center area is now two **sliding pill toggles** (intent) next to two **status signs** (reality). REC pill (`RecordPillOn`) is the local-recording intent — works signed-out; ON-AIR pill (`OnAirPillOn`) is the streaming intent — greyed/disabled until `IsConnected`. The **REC sign** (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`) is dark-gray + dim offline and **green-tinged, pulsing only while actually recording** (`IsRecording`), not merely pill-on. The **ON-AIR sign** (`OnAirBrush` = `#555` offline, `#22c55e` live) is green while actually streaming (`IsLive`); the `PRIVATE` badge (`IsLivePrivate`) still shows when the broadcast is private. Both sign labels share the `StatusSignText` style (`Themes/Controls.xaml`) so REC and ON-AIR can't drift apart — status color lives on the dot only, never the label. The elapsed timer shows while `IsLive || IsRecording`. The connected account's avatar/name shows in the top bar next to the primary button (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live. +- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the second line of the preview bottom row, BELOW the Socials+TRAX row, CENTERED beneath the preview panel. The TRAX button + Desktop Audio meter sit on the first line of that same row. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)` — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS display scale, because real speech sits around −40..−20 dBFS (0.01..0.1 linear) which would leave a flat scale dead (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). **Mic mute icon (2026-08-13):** a second 16px clickable glyph — a microphone, red + slash when muted — sits **between the meter and the speaker** (both mutes adjacent, spacing between the icons) and reuses the same `MicSpeaker_MouseLeftButtonUp` → `ToggleMicMuteCommand` handler. **REC / ON-AIR pills + signs (TASK 18, 2026-08-29):** the top-center area is now two **sliding pill toggles** (intent) next to two **status signs** (reality). REC pill (`RecordPillOn`) is the local-recording intent — works signed-out; ON-AIR pill (`OnAirPillOn`) is the streaming intent — greyed/disabled until `IsConnected`. The **REC sign** (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`) is dark-gray + dim offline and **green-tinged, pulsing only while actually recording** (`IsRecording`), not merely pill-on. The **ON-AIR sign** (`OnAirBrush` = `#555` offline, `#22c55e` live) is green while actually streaming (`IsLive`); the `PRIVATE` badge (`IsLivePrivate`) still shows when the broadcast is private. Both sign labels share the `StatusSignText` style (`Themes/Controls.xaml`) so REC and ON-AIR can't drift apart — status color lives on the dot only, never the label. The elapsed timer shows while `IsLive || IsRecording`. The connected account's avatar/name shows in the top bar next to the primary button (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live. - ViewModels are constructed in XAML (`` as DataContext) - Services are currently instantiated in MainViewModel's constructor — no DI container yet - Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available). Loaded sources always derive `IsBackground` from `Type` (OR'd with the persisted column, so legacy DisplayCapture backdrops keep their flag) — pre-derivation rows with `IsBackground=0` heal on load - **Five-scene catalog (`Models/SceneCatalog.cs`):** the product is exactly Starting/Live/BRB/Chat/Ending — work with less, never more (the escape hatch for "more" is OBS). Scenes are matched **by name** (`SceneCatalog.Is`, case-insensitive trim). Empty DBs seed all five; the scenes-header "+" (`ShowAddScene`/`MissingScenes` on `MainViewModel`) only appears while ≥1 canonical scene is missing and its menu lists only the missing ones, re-adding them by name (`AddSceneCommand`). Renaming a canonical scene makes it missing again; `AddScene` rejects non-canonical names. - Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary) -- Resolution tiers (bottom bar): 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; the vertical tier uses a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier; the bottom bar shows bitrate/FPS. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height +- Resolution tiers: 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; the vertical tier uses a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. Bottom bar: gear icon far left, stream stats (bitrate/FPS/dropped/duration) centered under preview, resolution dropdown far right. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height - Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug) ### ⚠️ Known issue: screens/layers settings audit needed (2026-08-22) @@ -605,7 +605,7 @@ devices, no timers). track persists via **schema v9** single-row `Music` (`TrackPath`/`IsEnabled`) in `LayoutStore`. Known wrinkle (out of scope, future feature): YouTube mutes VODs carrying copyrighted music — "music on live, off VOD" is tabled. -- **Mic status dot (`Models/MicStatus.cs`)** on the footer's MIC button: green = `MicConnected`, +- **Mic status dot (`Models/MicStatus.cs`)** on the preview-bottom MIC button: green = `MicConnected`, yellow = `MicFailed` (in use/unplugged), red = no mic device at startup (the mixer is never started, so loopback and the game bar can't run either — no capture devices at all). **The picked mic persists: `LayoutStore`'s `Settings` key/value table stores `MicSourceName` (saved on pick via