Toasts: non-blocking notifications replace MessageBoxes + log-only failures (TASK 24)

This commit is contained in:
2026-08-23 12:25:04 -07:00
parent 809c066184
commit b400ab59c5
12 changed files with 369 additions and 50 deletions
+17 -36
View File
@@ -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 || <column>`. 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
+7
View File
@@ -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 @@
</Grid>
</Border>
</Grid>
<!-- ═══ TOASTS: non-blocking notifications (bottom-right, above footer) ═══ -->
<notifications:NotificationArea x:Name="ToastArea"
Grid.RowSpan="4"
Position="BottomRight" MaxItems="4"
Margin="0,0,12,96"/>
</Grid>
</Window>
+34
View File
@@ -0,0 +1,34 @@
namespace ytLive.Services;
/// <summary>How urgent a user-facing notification is. Drives the toast's tint and lifetime.</summary>
public enum AppNotificationSeverity
{
Info,
Success,
Warning,
Error,
}
/// <summary>
/// Non-blocking user-facing messages (toasts). VMs and services raise events through
/// this seam so failures surface without MessageBox calls blocking the UI thread.
/// </summary>
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);
}
+66
View File
@@ -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;
/// <summary>
/// Toast notifications rendered inside the main window's <c>ToastArea</c>
/// (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.
/// </summary>
public sealed class NotificationService : INotificationService
{
public const string AreaName = "ToastArea";
internal static readonly IReadOnlyDictionary<AppNotificationSeverity, (string BackgroundHex, double ExpirationSeconds)> Cards
= new Dictionary<AppNotificationSeverity, (string, double)>
{
[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();
}
}
+2
View File
@@ -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<SceneElement, VideoFrame?>` 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 |
+38
View File
@@ -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)
+11
View File
@@ -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
+23 -13
View File
@@ -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;
}
+25 -1
View File
@@ -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
@@ -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;
/// <summary>
/// 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.
/// </summary>
[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<bool> 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();
}
}
+49
View File
@@ -0,0 +1,49 @@
using System;
using System.Windows;
using System.Windows.Media;
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// 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.
/// </summary>
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..]);
}
}
+1
View File
@@ -43,6 +43,7 @@
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
<PackageReference Include="NAudio.Wasapi" Version="2.2.1" />
<PackageReference Include="NAudio.WinMM" Version="2.2.1" />
<PackageReference Include="Notification.Wpf" Version="11.0.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="Velopack" Version="1.2.0" />