diff --git a/HANDOFF.md b/HANDOFF.md index f3a1fe4..56c8127 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,49 +1,30 @@ # HANDOFF — Session State ## Branch -`main` — **dirty (2 files, uncommitted)**, HEAD `46da995`. Not pushed. +`task24-notifications` (from `main` @ `809c066`, which is pushed) — **dirty, uncommitted**, TASK 24 complete and green. -## Uncommitted work (this session): IsBackground load-time derivation fix +## Uncommitted work: TASK 24 toast notifications — COMPLETE, ready to commit -The user-reported bug "Background properties show for only Starting and Live" is FIXED: - -1. **Root cause confirmed:** `Source.Type`'s setter derives `IsBackground` (`Type == Background`), - but `LayoutStore.Load()`'s object initializer assigned the DB **column** afterward — rows saved - before b74f15f carry `IsBackground=0` for `Type='Background'` and stayed false after load, so the - properties panel (`SelectedElement.IsBackground`) stayed hidden in BRB/Chat/Ending. -2. **Fix** (`Services/LayoutStore.cs` ~601): hoist the `Type` parse, then - `IsBackground = type == SourceType.Background || `. The OR keeps the persisted bit for - legacy DisplayCapture backdrops (column=1 must survive so the live-capture layer stays flagged). - First save after load self-heals the columns. -3. **Test** (this branch's ONE integration test): - `LayoutStorePersistenceTests.Legacy_IsBackground_Zero_Rows_Derive_From_Type_On_Load` — saves a BRB - scene holding DisplayCapture + Background + Image, zeroes the legacy columns via raw SQL, reloads, - asserts Background→true / DisplayCapture→true / Image→false. -4. Build **0 warnings**; **212 total, 209 pass** (3 pre-existing failures, below). +1. **Library:** `Notification.Wpf` 11.0.0 added to `ytLive.csproj` (MIT; notice #10 in `THIRD-PARTY-NOTICES.txt`). Restore pulls `Notification.CoreUI` 11.0.0 + `Microsoft.Xaml.Behaviors.Wpf` 1.1.142 + DI.Abstractions 10.0.8. +2. **Seam:** `Services/INotificationService.cs` (`Show(title, message, AppNotificationSeverity)` + Info/Success/Warning/Error extensions); `Services/NotificationService.cs` impl — pure `BuildRequest` mapping + `Cards` tint table, Dispatcher-marshalled Show. Host: `NotificationArea x:Name="ToastArea"` last child of MainWindow root grid (`RowSpan=4`, bottom-right above footer, `Margin="0,0,12,96"`). +3. **Call sites** (`ViewModels/MainViewModel.cs`): 5 MessageBox migrations (webcam ×2 → Warning, image read → Warning, sign-in unsuccessful → Warning / failed → Error) + 7 log-only promotions (go-live prep ×3 Error, frame-pump death Error, mic-missing Warning, premium-lapse Warning, saved-session sign-out Info). Offline license re-validation skip left log-only on purpose; SocialsDialog slot-delete confirm stays modal. `_notifications` field initialized at declaration (VM built from XAML). +4. **Tests:** unit `NotificationServiceTests` (8 cases: routing/lifetimes/tints) + the ONE integration `NotificationAreaIntegrationTests.Info_Auto_Dismisses_And_Error_Sticks` (real window-hosted area; visual-tree reads marshalled via `_app.Run`). +5. Build **0 warnings**; **221 total, 218 pass** — only the 3 pre-existing failures below. ## ⚠️ Landmines -- **User verdict (2026-08-23):** "The AI wrote fucked up code after repeatedly and intentionally - misunderstanding instructions." This extends the ai.md 2026-08-22 audit flag — trust nothing from - those sessions without manual verification. User switched models. -- **Pre-existing test failures are THREE, not one (handoff correction — verified by stashing and - running clean HEAD):** - 1. `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` (gain-math) - 2. `SourceNamingTests.Duplicate_Sources_Get_Next_Free_Numbered_Name` — asserts StagedScene names == - ["Text","Text2","Text3"], but StagedScene now carries a seeded "Game Capture" element (d2114c7 - made all five scenes background-enabled); the test must filter the background out. - 3. `SceneCatalogTests.EnforceBackgroundPolicy_Sets_Flag_For_All_Scenes` — calls `EnsureBackground` - on scenes with `HasBackground=false`; that returns null **by design**, so it asserts against - elements never inserted. - All three are fallout of the audit-flagged sessions' background-policy churn. NOT fixed here - (Good Dog Rule — this branch carries its one test only). -- **Real-MainWindow tests MUST set `LayoutPathOverride`** (see ai.md) — DB pollution wiped real data once. +- **User verdict (2026-08-23):** "The AI wrote fucked up code after repeatedly and intentionally misunderstanding instructions." Extends the ai.md 2026-08-22 audit flag — trust nothing from those sessions without manual verification. +- **Pre-existing test failures are THREE (not caused by this branch):** + 1. `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` + 2. `SourceNamingTests.Duplicate_Sources_Get_Next_Free_Numbered_Name` (StagedScene seeds a "Game Capture" the test doesn't filter) + 3. `SceneCatalogTests.EnforceBackgroundPolicy_Sets_Flag_For_All_Scenes` (asserts against elements never inserted) +- **Real-MainWindow tests MUST set `LayoutPathOverride`** (ai.md) — DB pollution wiped real data once. +- Notification.Wpf gotchas (recorded in ai.md/TASKS.md): area routing by XAML `Name`; unknown AreaName silently drops; `NeverExpires()` = `TimeSpan.MaxValue`; `ToHex()` = `#AARRGGBB`. ## Next steps -1. Commit this fix (code + docs, one commit). -2. Fix the two stale background tests (tiny, separate commit each or together — they are unit-level repairs, not features). -3. Visual test on Windows display: pill sizing/alignment, sub-header hierarchy, Background properties panel in all five scenes (BRB/Chat/Ending should show it again). -4. Then: thumbnail drag-reorder → real compositor snapshots → transition dropdown UI (per previous plan). +1. Commit this branch (code + docs + notices, one commit), then merge/PR per Good Dog Rule (one integration test ✓). +2. User visual pass on Windows: toasts look right bottom-right, error sticky with close button, no overlap with footer/chat. +3. Then the stale background tests repair queue (unit-level): SourceNaming filter + SceneCatalog policy test. ## Build ```bash diff --git a/MainWindow.xaml b/MainWindow.xaml index d6909d7..f9c973a 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -6,6 +6,7 @@ xmlns:vm="clr-namespace:ytLive.ViewModels" xmlns:models="clr-namespace:ytLive.Models" xmlns:Helpers="clr-namespace:ytLive.Helpers" + xmlns:notifications="clr-namespace:Notification.Wpf.Controls;assembly=Notification.Wpf" mc:Ignorable="d" Title="{Binding WindowTitle}" Height="720" Width="1280" MinWidth="1180" MinHeight="600" @@ -1504,5 +1505,11 @@ + + + diff --git a/Services/INotificationService.cs b/Services/INotificationService.cs new file mode 100644 index 0000000..e1cca6e --- /dev/null +++ b/Services/INotificationService.cs @@ -0,0 +1,34 @@ +namespace ytLive.Services; + +/// How urgent a user-facing notification is. Drives the toast's tint and lifetime. +public enum AppNotificationSeverity +{ + Info, + Success, + Warning, + Error, +} + +/// +/// Non-blocking user-facing messages (toasts). VMs and services raise events through +/// this seam so failures surface without MessageBox calls blocking the UI thread. +/// +public interface INotificationService +{ + void Show(string title, string message, AppNotificationSeverity severity); +} + +public static class NotificationServiceExtensions +{ + public static void Info(this INotificationService service, string title, string message = "") + => service.Show(title, message, AppNotificationSeverity.Info); + + public static void Success(this INotificationService service, string title, string message = "") + => service.Show(title, message, AppNotificationSeverity.Success); + + public static void Warning(this INotificationService service, string title, string message = "") + => service.Show(title, message, AppNotificationSeverity.Warning); + + public static void Error(this INotificationService service, string title, string message = "") + => service.Show(title, message, AppNotificationSeverity.Error); +} diff --git a/Services/NotificationService.cs b/Services/NotificationService.cs new file mode 100644 index 0000000..65a2e68 --- /dev/null +++ b/Services/NotificationService.cs @@ -0,0 +1,66 @@ +using System.Windows; +using System.Windows.Threading; +using Notification.Core; +using Notification.Wpf; +using Notification.Wpf.Constants; +using ytLive.Helpers; + +namespace ytLive.Services; + +/// +/// Toast notifications rendered inside the main window's ToastArea +/// (bottom-right, above the footer). Info/success auto-dismiss after ~4s, +/// warnings ~8s, errors stay until dismissed. Calls from any thread are +/// marshalled to the captured dispatcher. +/// +public sealed class NotificationService : INotificationService +{ + public const string AreaName = "ToastArea"; + + internal static readonly IReadOnlyDictionary Cards + = new Dictionary + { + [AppNotificationSeverity.Info] = ("#3a3b52", 4), + [AppNotificationSeverity.Success] = ("#1d5c38", 4), + [AppNotificationSeverity.Warning] = ("#b8860b", 8), + [AppNotificationSeverity.Error] = ("#8f1f1f", 0), + }; + + private readonly NotificationManager _manager = new(); + private readonly Dispatcher _dispatcher; + + public NotificationService() + : this(Application.Current?.Dispatcher) + { + } + + internal NotificationService(Dispatcher? dispatcher) + { + _dispatcher = dispatcher ?? Dispatcher.CurrentDispatcher; + NotificationConstants.NotificationCornerRadius = new CornerRadius(8); + NotificationConstants.KeepNotificationVisibleOnMouseOver = true; + } + + public void Show(string title, string message, AppNotificationSeverity severity) + { + var request = BuildRequest(title, message, severity); + _dispatcher.Invoke(() => _manager.Show(request)); + } + + internal static NotificationRequest BuildRequest(string title, string message, AppNotificationSeverity severity) + { + var (backgroundHex, expirationSeconds) = Cards[severity]; + var builder = NotificationBuilder + .Create(title, message) + .InArea(AreaName) + .WithBackground(NotificationColor.FromHex(backgroundHex)) + .WithForeground(NotificationColor.FromHex("#FFFFFF")); + + if (severity == AppNotificationSeverity.Error) + builder.NeverExpires(); + else + builder.ExpiresInSeconds(expirationSeconds); + + return builder.Build(); + } +} diff --git a/Services/index.md b/Services/index.md index de14d72..572829f 100644 --- a/Services/index.md +++ b/Services/index.md @@ -45,6 +45,8 @@ External-facing logic: YouTube API, persistence. See | `Encoder/FfmpegEncoderPicker.cs` | Pure H.264 encoder picker from `-encoders` output: NVENC → QSV → AMF → OpenH264; **never returns libx264** (GPL) | | `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. See `ai.md` "Live frame pipeline" | | `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 | +| `INotificationService.cs` | **Toast seam (TASK 24)**: `Show(title, message, AppNotificationSeverity)` + `AppNotificationSeverity { Info, Success, Warning, Error }` + `Info/Success/Warning/Error` extension methods — non-blocking user-facing messages; VMs/services raise through this so failures surface without MessageBox blocking the UI thread | +| `NotificationService.cs` | The default seam impl (Notification.Wpf 11.0.0, MIT): wraps a library `NotificationManager`; routes to the in-window `NotificationArea` named `ToastArea` (MainWindow bottom-right above the footer); lifetimes via the pure `BuildRequest` mapping + `Cards` tint table — Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky (`NeverExpires()` = `TimeSpan.MaxValue`, never null); dark card tints matching the health-banner language; all Show calls marshal to the captured Dispatcher. See [`../ai.md`](../ai.md) "Toast notifications" | | `Audio/IAudioSource.cs` | **TASK 4 ship step 4 seam**: live capture source — `Start`/`Stop`/`Started`/`SampleReady(AudioSample)`/`Failed(Exception)`, `IDisposable`. Capture now runs for the app's lifetime (started at startup) so the footer meters preview live. The app consumes this seam; tests inject hermetic fakes | | `Audio/AudioSample.cs` | One captured chunk: interleaved PCM float (-1..1) + sample rate + channels | | `Audio/WasapiLoopbackAudioSource.cs` | Desktop/game capture: NAudio `WasapiLoopbackCapture` on the default render device — automatic at unity; the game bar's meter consumes it | diff --git a/TASKS.md b/TASKS.md index 123c0f7..2072012 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1058,6 +1058,44 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev --- +## TASK 24 — Toast notifications (in-app, non-blocking) + +### Status: ✅ Done (shipped 2026-08-23, branch `task24-notifications`) + +Replace blocking MessageBoxes with in-window toasts; promote actionable log-only failures. + +### Requirements: +1. Library: **Notification.Wpf 11.0.0** (Platonenkov fork of Federerer/Notifications.Wpf) — MIT, active, targets net8.0-windows. Notice #10 added to `THIRD-PARTY-NOTICES.txt`. +2. Placement: bottom-right above the footer (`NotificationArea x:Name="ToastArea"`, last child of MainWindow's root grid → topmost z-order, `Grid.RowSpan=4`, `Margin="0,0,12,96"`, outside the preview Viewbox), max 4 stacked. +3. Behavior: Info ~4s / Success ~4s / Warning ~8s auto-dismiss; **Error sticky until dismissed**. +4. Styling: dark card tints matching the health-banner language (slate `#3a3b52` info, green `#1d5c38` success, amber `#b8860b` warning, dark-red `#8f1f1f` error); corner radius 8; keep-visible-on-hover. +5. Threading: all Show calls marshal via a Dispatcher captured at construction (encoder/pump events fire off-thread). + +### Migrations (MessageBox → toast): +- Webcam acquire failure ×2 (`AddWebcamToStagedSceneAsync`, `SwapWebcamIdentityAsync`) → Warning +- Image read failure (`PickImageBytes`) → Warning +- Sign-in unsuccessful → Warning; sign-in failed → Error + +### Promotions (log-only → toast, each keeps its AppLog line): +- Go-live prep ×3: reusable stream unavailable / broadcast insert failed / prep exception → Error +- Frame-pump death while live (`OnFramePumpFailed`) → Error +- Mic missing at startup (`StartMicCaptureAsync`) → Warning (once) +- Premium lapse at startup re-validation → Warning +- Saved-session refresh failure (signed out notice) → Info + +Deliberately left alone: offline license re-validation skip (log-only — would spam every launch); SocialsDialog slot-delete YesNo confirm (stays modal). + +### Tests: +- Unit: pure severity→request mapping (`NotificationServiceTests`) — area routing, lifetimes, tints. +- Integration (the ONE): real window-hosted `ToastArea` + real service — Info auto-dismisses, Error sticks (`NotificationAreaIntegrationTests`, RealApp collection). + +### Library facts (cost a hunt — see also ai.md): +- Area routing matches the area's XAML `Name` against the request's `AreaName`; unknown name silently drops. +- `NeverExpires()` = `ExpirationTime = TimeSpan.MaxValue` (not null); `NotificationColor.ToHex()` returns `#AARRGGBB`. +- Overlay-window shutdown caveat avoided entirely by using the in-window area mode. + +--- + ## Backlog (future versions) 1. v1.1 — Stream Deck / Loupedeck integration (requires hotkey foundation from TASK 20) diff --git a/THIRD-PARTY-NOTICES.txt b/THIRD-PARTY-NOTICES.txt index 13e653b..ea5b33c 100644 --- a/THIRD-PARTY-NOTICES.txt +++ b/THIRD-PARTY-NOTICES.txt @@ -88,6 +88,17 @@ there on purpose. behind the `IAudioSource` seam (TASK 4 ship step 4). MIT imposes no source offer; this notice is kept per this file's policy. +10. Notification.Wpf + Notification.CoreUI (toast notifications, TASK 24) + Copyright (c) Platonenkov and contributors + License: MIT + Home: https://github.com/Platonenkov/Notification.Wpf + Used as: the in-window toast area (bottom-right) behind the + `INotificationService` seam — non-blocking user-facing messages. + Transitive dependencies (Microsoft.Xaml.Behaviors.Wpf, + Microsoft.Extensions.DependencyInjection.Abstractions) are MIT, + covered by item 7's .NET notice policy; this notice is kept per + this file's "list everything" policy. + -------------------------------------------------------------------------------- MISCELLANEOUS diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 886cd56..51cbf76 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -96,6 +96,10 @@ public class MainViewModel : ViewModelBase private DispatcherTimer? _saveDebounce; private bool _isLoading; + // Toasts (TASK 24): non-blocking user-facing messages. Parameterless ctor + // news up the real service; the VM is built from XAML so no DI seam exists. + private readonly INotificationService _notifications = new NotificationService(); + // Webcam: one camera input app-wide. The identity (DeviceId) lives on the // Webcam entity; each scene that shows the webcam has a WebcamSceneConfig // (webcam.{scene}.config). CameraManager still owns the single session. @@ -1593,6 +1597,8 @@ public class MainViewModel : ViewModelBase { TokenStore.Clear(); AppLog.Write("Saved session could not be refreshed; signed out"); + _notifications.Info("Signed out of YouTube", + "The saved session expired — sign in again when you next go live."); return; } } @@ -1656,6 +1662,8 @@ public class MainViewModel : ViewModelBase _layoutStore.SaveIsPremium(false); _layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow); AppLog.Write($"License validation failed: {result.Error}"); + _notifications.Warning("Premium is no longer active", + $"{result.Error} — watermark branding returns until the license is renewed."); } } catch (Exception ex) @@ -2594,9 +2602,8 @@ public class MainViewModel : ViewModelBase var started = await _cameraManager.AcquireAsync(_webcam.DeviceId); if (!started) { - MessageBox.Show( - WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.", - "LlamaCasty", MessageBoxButton.OK, MessageBoxImage.Warning); + _notifications.Warning("Camera unavailable", + WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off."); } } @@ -2618,9 +2625,8 @@ public class MainViewModel : ViewModelBase var started = await _cameraManager.AcquireAsync(device.Id); if (!started) { - MessageBox.Show( - WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.", - "LlamaCasty", MessageBoxButton.OK, MessageBoxImage.Warning); + _notifications.Warning("Camera unavailable", + WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off."); break; } } @@ -2707,7 +2713,7 @@ public class MainViewModel : ViewModelBase return candidates; } - private static byte[]? PickImageBytes(string title) + private byte[]? PickImageBytes(string title) { var dialog = new OpenFileDialog { @@ -2721,8 +2727,7 @@ public class MainViewModel : ViewModelBase } catch (Exception ex) { - MessageBox.Show($"Couldn't read that image: {ex.Message}", "LlamaCasty", - MessageBoxButton.OK, MessageBoxImage.Warning); + _notifications.Warning("Couldn't read that image", ex.Message); return null; } } @@ -2889,8 +2894,7 @@ public class MainViewModel : ViewModelBase var channel = await Task.Run(() => _youtubeAuth.AuthenticateAsync()); if (channel == null) { - MessageBox.Show("Sign-in was unsuccessful. Please try again.", "LlamaCasty", - MessageBoxButton.OK, MessageBoxImage.Warning); + _notifications.Warning("Sign-in was unsuccessful", "Please try again."); return null; } @@ -2900,8 +2904,7 @@ public class MainViewModel : ViewModelBase } catch (Exception ex) { - MessageBox.Show($"Sign-in failed: {ex.Message}", "LlamaCasty", - MessageBoxButton.OK, MessageBoxImage.Error); + _notifications.Error("Sign-in failed", ex.Message); return null; } } @@ -2985,6 +2988,8 @@ public class MainViewModel : ViewModelBase if (stream == null) { AppLog.Write("Reusable stream unavailable; check the OAuth session"); + _notifications.Error("Couldn't go live", + "The reusable stream is unavailable — check the YouTube sign-in and try again."); StreamStatus = StreamStatus.Error; return; } @@ -2997,6 +3002,7 @@ public class MainViewModel : ViewModelBase if (_currentBroadcastId == null) { AppLog.Write("Broadcast creation failed; check the OAuth session"); + _notifications.Error("Couldn't go live", "YouTube rejected the broadcast — check the sign-in and try again."); StreamStatus = StreamStatus.Error; return; } @@ -3026,6 +3032,7 @@ public class MainViewModel : ViewModelBase catch (Exception ex) { AppLog.Write($"Go-live preparation failed: {ex.Message}"); + _notifications.Error("Couldn't go live", ex.Message); StreamStatus = StreamStatus.Error; return; } @@ -3159,6 +3166,8 @@ public class MainViewModel : ViewModelBase { MicStatus = MicStatus.NotConnected; AppLog.Write("Mic: no capture devices found — mic capture not started"); + _notifications.Warning("No microphone found", + "Mic capture wasn't started — plug one in and pick it from the mixer."); return; } _audioMixer.Start(); @@ -3333,6 +3342,7 @@ public class MainViewModel : ViewModelBase private void OnFramePumpFailed(object? sender, string message) { AppLog.Write($"Frame pump failed: {message}"); + _notifications.Error("The stream pipeline stopped", message); if (IsLive) StreamStatus = StreamStatus.Error; } diff --git a/ai.md b/ai.md index 9d87a91..8ae0225 100644 --- a/ai.md +++ b/ai.md @@ -125,7 +125,7 @@ C# / WPF (.NET 8) following MVVM: |------|------| | `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage, **Socials (`SocialService` enum + `SocialEntry`/`SocialsConfig` + `SocialServiceIcons`) — the social bar** | | `ViewModels/` | MainViewModel — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** | -| `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")** | +| `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) | @@ -611,6 +611,30 @@ never touch slot state). Pbgra32→straight-alpha unpremultiply) and the compositor blits it above the flash (see "Live frame pipeline"). +### Toast notifications (TASK 24 — shipped 2026-08-23, plan in TASKS.md) + +Non-blocking user-facing messages replace blocking MessageBoxes. **Seam:** `INotificationService.Show(title, +message, AppNotificationSeverity)` (`Services/INotificationService.cs`) + `Info/Success/Warning/Error` +extension methods; `MainViewModel` news up the real `NotificationService` at its field initializer +(no DI container — the VM is built from XAML). **Library:** `Notification.Wpf` 11.0.0 (Platonenkov fork, +MIT — notice #10 in `THIRD-PARTY-NOTICES.txt`). Facts that cost a hunt: +- The `NotificationArea` XAML control registers itself with the manager on `Loaded`; routing matches + the area's `Name` against the request's `AreaName` — our host is `x:Name="ToastArea"` in MainWindow's + root grid (last child = topmost z-order, `Grid.RowSpan=4`, bottom-right above the footer via + `Margin="0,0,12,96"`, outside the preview Viewbox). An unknown AreaName silently drops the toast. +- `NeverExpires()` sets `ExpirationTime = TimeSpan.MaxValue` (NOT null); `NotificationColor.ToHex()` + returns `#AARRGGBB` (alpha prefix). +- Lifetimes: Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky until dismissed (the pure mapping + lives in `NotificationService.BuildRequest` + the `Cards` tint table: slate info `#3a3b52`, green + success `#1d5c38`, amber warning `#b8860b`, dark-red error `#8f1f1f` — matching the health-banner language). +- Calls marshal to the captured Dispatcher (`_dispatcher.Invoke`) — encoder/pump events fire off-thread. + +Migrated MessageBoxes: both webcam-acquire warnings, image-read warning, sign-in unsuccessful/failed. +Promoted from log-only (each keeps its AppLog line): go-live prep failures ×3 (reusable stream, +broadcast insert, prep exception), frame-pump death while live, mic-missing at startup, premium lapse, +saved-session refresh failure (sign-out notice). Deliberately left log-only: offline license re-validation +skip (would spam every launch). Still modal by design: SocialsDialog's slot-delete YesNo confirm. + ### Licensing — do not violate (GA = paid product; see `THIRD-PARTY-NOTICES.txt`) This product is closed-source and paid. Every third-party component must stay inside the LGPL/BSD/MIT diff --git a/ytLive.Tests/NotificationAreaIntegrationTests.cs b/ytLive.Tests/NotificationAreaIntegrationTests.cs new file mode 100644 index 0000000..d56deab --- /dev/null +++ b/ytLive.Tests/NotificationAreaIntegrationTests.cs @@ -0,0 +1,96 @@ +using System; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using Notification.Core; +using Notification.Wpf.Controls; +using Xunit; +using ytLive.Services; + +namespace ytLive.Tests; + +/// +/// TASK 24 integration: the real NotificationService against a real window-hosted +/// ToastArea. Transient notifications expire on their own; errors stick until the +/// area goes away. ONE integration test per the Good Dog Rule. +/// +[Collection("RealApp")] +public class NotificationAreaIntegrationTests +{ + private readonly RealAppHost _app; + + public NotificationAreaIntegrationTests(RealAppHost app) => _app = app; + + [Fact] + public void Info_Auto_Dismisses_And_Error_Sticks() + { + Window? window = null; + NotificationArea? area = null; + NotificationService? service = null; + _app.Run(() => + { + area = new NotificationArea { Name = NotificationService.AreaName, Position = NotificationPosition.BottomRight, MaxItems = 4 }; + var grid = new Grid(); + grid.Children.Add(area); + window = new Window { Content = grid, Width = 400, Height = 300, WindowStartupLocation = WindowStartupLocation.Manual, Left = -32000, Top = 0, ShowInTaskbar = false, ShowActivated = false }; + window.Show(); + service = new NotificationService(); + }); + + try + { + service!.Info("Hello", "transient"); + Assert.True(WaitFor(() => ItemCount(area!) == 1), "info toast never appeared"); + + Assert.True(WaitFor(() => ItemCount(area!) == 0, TimeSpan.FromSeconds(8)), + "info toast never auto-dismissed"); + + service!.Error("Broken", "sticky"); + Assert.True(WaitFor(() => ItemCount(area!) == 1), "error toast never appeared"); + + Thread.Sleep(1200); + Assert.Equal(1, ItemCount(area!)); + } + finally + { + _app.Run(() => window!.Close()); + } + } + + private int ItemCount(NotificationArea area) + { + var count = 0; + _app.Run(() => count = CountItems(area)); + return count; + } + + private static int CountItems(NotificationArea area) + { + var count = 0; + Walk(area, ref count); + return count; + + static void Walk(DependencyObject node, ref int count) + { + if (node is Panel panel && panel.Name == "PART_Items") + { + count += panel.Children.Count; + return; + } + for (var i = 0; i < VisualTreeHelper.GetChildrenCount(node); i++) + Walk(VisualTreeHelper.GetChild(node, i), ref count); + } + } + + private static bool WaitFor(Func condition, TimeSpan? timeout = null) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(3)); + while (DateTime.UtcNow < deadline) + { + if (condition()) return true; + Thread.Sleep(50); + } + return condition(); + } +} diff --git a/ytLive.Tests/NotificationServiceTests.cs b/ytLive.Tests/NotificationServiceTests.cs new file mode 100644 index 0000000..69efc48 --- /dev/null +++ b/ytLive.Tests/NotificationServiceTests.cs @@ -0,0 +1,49 @@ +using System; +using System.Windows; +using System.Windows.Media; +using Xunit; +using ytLive.Services; + +namespace ytLive.Tests; + +/// +/// TASK 24: the pure severity → request mapping behind the toast service. +/// Errors must never auto-expire (sticky until dismissed); the rest get short +/// lifetimes; everything routes to the in-window ToastArea. +/// +public class NotificationServiceTests +{ + [Theory] + [InlineData(AppNotificationSeverity.Info, 4)] + [InlineData(AppNotificationSeverity.Success, 4)] + [InlineData(AppNotificationSeverity.Warning, 8)] + public void Transient_Severities_Expire_With_Scheduled_Lifetimes(AppNotificationSeverity severity, double expectedSeconds) + { + var request = NotificationService.BuildRequest("Title", "Body", severity); + + Assert.Equal("ToastArea", request.AreaName); + Assert.NotNull(request.ExpirationTime); + Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), request.ExpirationTime); + } + + [Fact] + public void Errors_Never_Expire_And_Route_To_The_Toast_Area() + { + var request = NotificationService.BuildRequest("Title", "Body", AppNotificationSeverity.Error); + + Assert.Equal("ToastArea", request.AreaName); + Assert.Equal(TimeSpan.MaxValue, request.ExpirationTime); + } + + [Theory] + [InlineData(AppNotificationSeverity.Info, "#3a3b52")] + [InlineData(AppNotificationSeverity.Success, "#1d5c38")] + [InlineData(AppNotificationSeverity.Warning, "#b8860b")] + [InlineData(AppNotificationSeverity.Error, "#8f1f1f")] + public void Severities_Map_To_Dark_Card_Tints(AppNotificationSeverity severity, string expectedHex) + { + var request = NotificationService.BuildRequest("Title", "Body", severity); + + Assert.Equal(expectedHex.ToLowerInvariant()[^6..], request.BackgroundColor!.Value.ToHex().ToLowerInvariant()[^6..]); + } +} diff --git a/ytLive.csproj b/ytLive.csproj index df67d00..62bc01f 100644 --- a/ytLive.csproj +++ b/ytLive.csproj @@ -43,6 +43,7 @@ +