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
+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 |