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
|
# 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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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/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 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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="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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user