Toasts: non-blocking notifications replace MessageBoxes + log-only failures (TASK 24)
This commit is contained in:
+17
-36
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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..]);
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user