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 # HANDOFF — Session State
## Branch ## 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. **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"`).
1. **Root cause confirmed:** `Source.Type`'s setter derives `IsBackground` (`Type == Background`), 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).
but `LayoutStore.Load()`'s object initializer assigned the DB **column** afterward — rows saved 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`).
before b74f15f carry `IsBackground=0` for `Type='Background'` and stayed false after load, so the 5. Build **0 warnings**; **221 total, 218 pass** — only the 3 pre-existing failures below.
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).
## ⚠️ Landmines ## ⚠️ Landmines
- **User verdict (2026-08-23):** "The AI wrote fucked up code after repeatedly and intentionally - **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.
misunderstanding instructions." This extends the ai.md 2026-08-22 audit flag — trust nothing from - **Pre-existing test failures are THREE (not caused by this branch):**
those sessions without manual verification. User switched models. 1. `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`
- **Pre-existing test failures are THREE, not one (handoff correction — verified by stashing and 2. `SourceNamingTests.Duplicate_Sources_Get_Next_Free_Numbered_Name` (StagedScene seeds a "Game Capture" the test doesn't filter)
running clean HEAD):** 3. `SceneCatalogTests.EnforceBackgroundPolicy_Sets_Flag_For_All_Scenes` (asserts against elements never inserted)
1. `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` (gain-math) - **Real-MainWindow tests MUST set `LayoutPathOverride`** (ai.md) — DB pollution wiped real data once.
2. `SourceNamingTests.Duplicate_Sources_Get_Next_Free_Numbered_Name` — asserts StagedScene names == - Notification.Wpf gotchas (recorded in ai.md/TASKS.md): area routing by XAML `Name`; unknown AreaName silently drops; `NeverExpires()` = `TimeSpan.MaxValue`; `ToHex()` = `#AARRGGBB`.
["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.
## Next steps ## Next steps
1. Commit this fix (code + docs, one commit). 1. Commit this branch (code + docs + notices, one commit), then merge/PR per Good Dog Rule (one integration test ✓).
2. Fix the two stale background tests (tiny, separate commit each or together — they are unit-level repairs, not features). 2. User visual pass on Windows: toasts look right bottom-right, error sticky with close button, no overlap with footer/chat.
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). 3. Then the stale background tests repair queue (unit-level): SourceNaming filter + SceneCatalog policy test.
4. Then: thumbnail drag-reorder → real compositor snapshots → transition dropdown UI (per previous plan).
## Build ## Build
```bash ```bash
+7
View File
@@ -6,6 +6,7 @@
xmlns:vm="clr-namespace:ytLive.ViewModels" xmlns:vm="clr-namespace:ytLive.ViewModels"
xmlns:models="clr-namespace:ytLive.Models" xmlns:models="clr-namespace:ytLive.Models"
xmlns:Helpers="clr-namespace:ytLive.Helpers" xmlns:Helpers="clr-namespace:ytLive.Helpers"
xmlns:notifications="clr-namespace:Notification.Wpf.Controls;assembly=Notification.Wpf"
mc:Ignorable="d" mc:Ignorable="d"
Title="{Binding WindowTitle}" Height="720" Width="1280" Title="{Binding WindowTitle}" Height="720" Width="1280"
MinWidth="1180" MinHeight="600" MinWidth="1180" MinHeight="600"
@@ -1504,5 +1505,11 @@
</Grid> </Grid>
</Border> </Border>
</Grid> </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> </Grid>
</Window> </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/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/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 | | `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/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/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 | | `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) ## Backlog (future versions)
1. v1.1 — Stream Deck / Loupedeck integration (requires hotkey foundation from TASK 20) 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 behind the `IAudioSource` seam (TASK 4 ship step 4). MIT imposes
no source offer; this notice is kept per this file's policy. 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 MISCELLANEOUS
+23 -13
View File
@@ -96,6 +96,10 @@ public class MainViewModel : ViewModelBase
private DispatcherTimer? _saveDebounce; private DispatcherTimer? _saveDebounce;
private bool _isLoading; 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: one camera input app-wide. The identity (DeviceId) lives on the
// Webcam entity; each scene that shows the webcam has a WebcamSceneConfig // Webcam entity; each scene that shows the webcam has a WebcamSceneConfig
// (webcam.{scene}.config). CameraManager still owns the single session. // (webcam.{scene}.config). CameraManager still owns the single session.
@@ -1593,6 +1597,8 @@ public class MainViewModel : ViewModelBase
{ {
TokenStore.Clear(); TokenStore.Clear();
AppLog.Write("Saved session could not be refreshed; signed out"); 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; return;
} }
} }
@@ -1656,6 +1662,8 @@ public class MainViewModel : ViewModelBase
_layoutStore.SaveIsPremium(false); _layoutStore.SaveIsPremium(false);
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow); _layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
AppLog.Write($"License validation failed: {result.Error}"); 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) catch (Exception ex)
@@ -2594,9 +2602,8 @@ public class MainViewModel : ViewModelBase
var started = await _cameraManager.AcquireAsync(_webcam.DeviceId); var started = await _cameraManager.AcquireAsync(_webcam.DeviceId);
if (!started) if (!started)
{ {
MessageBox.Show( _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.", 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);
} }
} }
@@ -2618,9 +2625,8 @@ public class MainViewModel : ViewModelBase
var started = await _cameraManager.AcquireAsync(device.Id); var started = await _cameraManager.AcquireAsync(device.Id);
if (!started) if (!started)
{ {
MessageBox.Show( _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.", 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);
break; break;
} }
} }
@@ -2707,7 +2713,7 @@ public class MainViewModel : ViewModelBase
return candidates; return candidates;
} }
private static byte[]? PickImageBytes(string title) private byte[]? PickImageBytes(string title)
{ {
var dialog = new OpenFileDialog var dialog = new OpenFileDialog
{ {
@@ -2721,8 +2727,7 @@ public class MainViewModel : ViewModelBase
} }
catch (Exception ex) catch (Exception ex)
{ {
MessageBox.Show($"Couldn't read that image: {ex.Message}", "LlamaCasty", _notifications.Warning("Couldn't read that image", ex.Message);
MessageBoxButton.OK, MessageBoxImage.Warning);
return null; return null;
} }
} }
@@ -2889,8 +2894,7 @@ public class MainViewModel : ViewModelBase
var channel = await Task.Run(() => _youtubeAuth.AuthenticateAsync()); var channel = await Task.Run(() => _youtubeAuth.AuthenticateAsync());
if (channel == null) if (channel == null)
{ {
MessageBox.Show("Sign-in was unsuccessful. Please try again.", "LlamaCasty", _notifications.Warning("Sign-in was unsuccessful", "Please try again.");
MessageBoxButton.OK, MessageBoxImage.Warning);
return null; return null;
} }
@@ -2900,8 +2904,7 @@ public class MainViewModel : ViewModelBase
} }
catch (Exception ex) catch (Exception ex)
{ {
MessageBox.Show($"Sign-in failed: {ex.Message}", "LlamaCasty", _notifications.Error("Sign-in failed", ex.Message);
MessageBoxButton.OK, MessageBoxImage.Error);
return null; return null;
} }
} }
@@ -2985,6 +2988,8 @@ public class MainViewModel : ViewModelBase
if (stream == null) if (stream == null)
{ {
AppLog.Write("Reusable stream unavailable; check the OAuth session"); 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; StreamStatus = StreamStatus.Error;
return; return;
} }
@@ -2997,6 +3002,7 @@ public class MainViewModel : ViewModelBase
if (_currentBroadcastId == null) if (_currentBroadcastId == null)
{ {
AppLog.Write("Broadcast creation failed; check the OAuth session"); 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; StreamStatus = StreamStatus.Error;
return; return;
} }
@@ -3026,6 +3032,7 @@ public class MainViewModel : ViewModelBase
catch (Exception ex) catch (Exception ex)
{ {
AppLog.Write($"Go-live preparation failed: {ex.Message}"); AppLog.Write($"Go-live preparation failed: {ex.Message}");
_notifications.Error("Couldn't go live", ex.Message);
StreamStatus = StreamStatus.Error; StreamStatus = StreamStatus.Error;
return; return;
} }
@@ -3159,6 +3166,8 @@ public class MainViewModel : ViewModelBase
{ {
MicStatus = MicStatus.NotConnected; MicStatus = MicStatus.NotConnected;
AppLog.Write("Mic: no capture devices found — mic capture not started"); 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; return;
} }
_audioMixer.Start(); _audioMixer.Start();
@@ -3333,6 +3342,7 @@ public class MainViewModel : ViewModelBase
private void OnFramePumpFailed(object? sender, string message) private void OnFramePumpFailed(object? sender, string message)
{ {
AppLog.Write($"Frame pump failed: {message}"); AppLog.Write($"Frame pump failed: {message}");
_notifications.Error("The stream pipeline stopped", message);
if (IsLive) StreamStatus = StreamStatus.Error; 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** | | `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** | | `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 | | `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`) | | `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), 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 Pbgra32→straight-alpha unpremultiply) and the compositor blits it above the flash (see "Live frame
pipeline"). 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`) ### 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 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="Microsoft.Data.Sqlite" Version="8.0.10" />
<PackageReference Include="NAudio.Wasapi" Version="2.2.1" /> <PackageReference Include="NAudio.Wasapi" Version="2.2.1" />
<PackageReference Include="NAudio.WinMM" 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="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" /> <PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="Velopack" Version="1.2.0" /> <PackageReference Include="Velopack" Version="1.2.0" />