feat(alerts): TASK 43 — native events & alerts (six unique animations) + chat parity

Replace the external StreamElements feed with NATIVE YouTube events per the creator's
2026-09-23 question.

Chat parity half:
- YouTubeChatService decodes ALL SIX liveChat/messages event types into
  ChatMessage.Kind (superChat/superSticker/newSponsor/membershipGifting/
  giftMembershipReceived/memberMilestoneChat) — the four formerly-empty overlay
  rows are real now — and re-arms its one-shot poll on the server's
  pollingIntervalMillis (streamList connection semantics; clamp 1000-6000ms,
  maxResults=2000). ParsePage internal static seam + ChatPage record for
  deterministic tests; optional HttpClient ctor seam kept; InjectSimulatedMessage
  (TASK 41) preserved.

Alerts half (creator rulings: one celebration zone, six UNIQUE animations, no
menus/polls, sub mention = chat row only, NO viewer count):
- New SourceType.AlertBox 'Stream Alerts' (one per layout, CanAddAlerts gate
  mirroring chat; idle = transparent).
- AlertRenderer: six distinct branded animations (SuperChat slide-up/shine/
  count-up, SuperSticker scale-pop, NewMember drop-in/flash, MemberGift slide-left/
  chip-fan, GiftReceived confetti, MemberMilestone rise/growth-bar), every card
  drawing the 'made with LlamaCasty!' brand line.
- AlertOverlayLayer: true component (Commit-G pattern) — queue (cap 10, drop tail
  never stall), 33ms UI ticker, cache-first RenderFrame + UpdatePreview,
  Advance(double) as the deterministic test clock; ChatEventKind.None rows never
  enqueue.
- Wired: resolver RenderAlertBox, _alertLayer ctor + dispose, LoadLayout previews,
  Add menu item (Controls/LeftPanel.xaml), TestSessionViewModel sims tagged
  (member->NewMember, superchat->SuperChat).

Good Dog ONE integration test: AlertLayerTests (RealApp STA, real WPF raster) —
ParsePage classifies all six kinds + cadence fields; None rows enqueue nothing;
six events play pairwise-distinct moving frames then drain to null.

Gate: clean build 0 warnings; full suite 316/317, the one failure
(LayerReorderPersistenceTests.RealMouseDrag) repros on the clean tree — the
known environmental class (real-mouse-drag no-ops with a game/fullscreen window
focused).

References (OBS/overlay ecosystem):
- streamList semantics: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList
- OBS alert-box pattern (designated celebration zone, idle transparent): creator-chosen model
This commit is contained in:
2026-09-24 08:27:00 -07:00
parent 3a4e17c735
commit d4aa588da0
19 changed files with 1347 additions and 185 deletions
+3
View File
@@ -41,6 +41,9 @@
<MenuItem Header="YouTube Chat" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.ChatBox}"
IsEnabled="{Binding CanAddYouTubeChat}"
ToolTip="One chat layer at a time — it's already in your stream"/>
<MenuItem Header="Stream Alerts" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.AlertBox}"
IsEnabled="{Binding CanAddAlerts}"
ToolTip="Celebration zone for Super Chat &amp; memberships — one alert layer at a time"/>
</ContextMenu>
</Button.ContextMenu>
<Path Data="M12,4 L12,20 M4,12 L20,12" Stroke="#e94560" StrokeThickness="2"
+58 -80
View File
@@ -1,103 +1,81 @@
# HANDOFF — 2026-09-23 (build #77 feedback round; LOG fixes all shipped; app likely running)
# HANDOFF — 2026-09-24 (TASK 43 shipped: native events & alerts — creator said "go")
## Branch / Commit State
`main` HEAD = **`d72949e`** (build-77 top-bar feedback: avatar/gear/cluster). Backlog:
`ac6e67a` (end close-out), `18ab553` (health-poll), `e69db4d` (TASK 42), `bb5dcb4` (TASK 41).
Pushed state at `197ee81` (36 commits ahead locally — push only at a sub-milestone / on the
user's say-so).
`main` HEAD = new commit for TASK 43 (native events & six-animation alert box); previous:
`3a4e17c` (account zone world-independent), `cb75066` (YPP 403), `d72949e` (top-bar feedback),
`ac6e67a` (end close-out). Repo is ~10 commits ahead of pushed `197ee81` — **push is the
creator's call** (sub-milestone decision, not per-commit).
**Working tree DIRTY — account zone made world-independent (creator 2026-09-23):**
**Working tree CLEAN after this unit's commit.**
```
M ViewModels/MainViewModel.Streaming.cs (ShowSignInButton = !IsConnected, ShowAvatarButton = IsConnected)
M ytLive.Tests/TopBarModeTests.cs (REC-world asserts: avatar stays, Sign In visible signed-out)
M ai.md, TASKS/task-42, HANDOFF.md
```
## ⚠️ The unit that just shipped (this turn, creator said "go")
Previous two hotfix units (audio-sync dot + YPP 403/statistics-string) are COMMITTED (`cb75066`);
top-bar feedback COMMITTED (`d72949e`).
The creator asked (2026-09-23) whether the app could use **native YouTube events instead of
StreamElements**. Research pinned the API facts; creator rulings locked the product shape. One
combined work unit ("go"), task filed as **TASK 43** (`TASKS/task-43-native-alerts.md`):
## ⚠️ The account-zone unit (this turn)
1. **Chat parity half** — `YouTubeChatService` now decodes **all six** `liveChat/messages`
event types into `ChatMessage.Kind` (the four previously-empty event rows are real now) and
re-arms its poll on the server's `pollingIntervalMillis` (streamList semantics; clamp
1000–6000ms, `maxResults=2000`).
2. **Alerts half** — new **`SourceType.AlertBox`** ("Stream Alerts", one per layout,
`CanAddAlerts` gate mirroring chat): an OBS-style celebration zone, idle = transparent.
`Services/AlertRenderer.cs` draws **six DISTINCT branded animations** (SuperChat slide-up/
shine/count-up; SuperSticker scale-pop; NewMember drop-in/flash; MemberGift slide-left/chip-fan;
GiftReceived confetti; MemberMilestone rise/growth-bar), each card carrying the "made with
LlamaCasty!" brand line (free product placement per broadcast). `Services/AlertOverlayLayer.cs`
is a true component (Commit-G pattern): queue (cap 10) + 33ms ticker + cache-first
`RenderFrame` + `UpdatePreview`; **`Advance(double)` = deterministic test clock**.
3. **Creator rulings baked in:** no menus/polls (asked once, held); free-sub mention = chat row
only, **no sub alert** (YouTube emits none); **no viewer count** (vetoed — demotivating,
weaponizable); six events = six unique animations, not a recolored template.
Creator, clicking RECORD on the live build: "why do you make the youtube user icon go away?" — the
TASK 42 one-surface bar had scoped the account zone to the Stream world ("Record world has no YouTube
identity at all"), so arming REC dropped the avatar AND the bar height bounced with it ("really fucking
annoying"). Fix: `ShowSignInButton`/`ShowAvatarButton` are now world-independent (`!IsConnected` /
`IsConnected`) — the right column never pops in/out, bar height pinned. Test stays a child of Stream.
`TopBarModeTests` updated: signed-out Record world shows Sign In; flipping back to REC keeps the avatar.
**Good Dog:** `ytLive.Tests/AlertLayerTests.cs` (RealApp STA, real WPF raster) — ParsePage
classifies all six kinds + cadence fields; playback never enqueues a `None` (sub-mention) row,
plays six pairwise-distinct moving frames, drains to idle. **Gate: clean build 0 warnings,
316/317** — the ONE failure (`LayerReorderPersistenceTests.RealMouseDrag…`) repros on the CLEAN
tree (proved via `git stash` + rebuild + isolated run): a real-mouse-drag test that no-ops while
a game/fullscreen window steals the mouse (the HANDOFF-documented environmental class).
**NOT a regression — do not chase it in-app; close games and rerun if in doubt.**
## ⚠️ The app is RUNNING, and that's the story of this round
## Follow-ups queued (NOT done in this unit)
Ac6e67a **could not be verified through the normal gate** because the user was *looking at the
running app* (PID 17752) while it happened. `verify.sh` (clean build 0-warnings + full suite +
scope check) **cannot run until the app is closed** — an in-place build fails the `apphost.exe`
copy MSB3021/MSB3027 while the exe is locked. Compile gate used instead: `dotnet build` reaches
the copy stage with XAML/markup compiling clean (only the 2 copy errors → code + XAML are valid).
## What the user asked this round (live-launch feedback on #77, all XAML-only)
1. **Avatar** much bigger — now 40×40 (was 26), CornerRadius 20, initial 20pt. Shows the streaming
account at a glance (113% bigger than the old 26 read).
2. **Gear** moved further from the brand — margin 6 → 18 (a couple more "places").
3. **Uniform 32-height cluster in the top center** — segments host Border, Start/Go Live, and Test
all `Height="32"` so the REC|ON-AIR switch + its actions read as ONE family ("so the user can
intuit their purpose"). Running world (reality line) intentionally untouched. **[COMMITTED `d72949e`]**
4. **Audio-sync status light LOST** — `"AUDIO SYNC"` slider keeps its numeric tooltip; the green/
amber 8×8 dot was "what is the point" dead weight. `IntToSyncBrushConverter` + its resource +
TASK-22's "visual feedback" decision all retired with it, both docs updated in-place.
5. **YPP refresh bug (this turn, "Seriously?")** — `channels.list?mine=true&part=statistics,
auditDetails,contentDetails` 403'd `insufficientPermissions` on EVERY real refresh since slice 1
shipped: the **`auditDetails` part alone requires the `youtubepartner-channel-audit` scope**
(MCN partner tooling the app must never hold; the "no re-consent / scopes suffice" slice-1 claim
was wrong). Fix: part list is now `statistics,contentDetails`; standing flags dropped from
snapshot→VM→drawer in favour of an honest "not exposed to apps — check the Earn page" row;
`channels.list` failures now log the response body's `error.reason` (the bare code hid this for
days); new Good Dog `ChannelStatsServiceTests` guards no-auditDetails + still-parses — which it
then used to catch a SECOND latent bug the 403 masked (statistics are JSON strings; `GetInt64`
throws → now the tolerant `ReadInt64`, ValueKind-first). Docs:
ai.md / TASK-39 / TASKS.md / MyMistakes all corrected. Cite:
https://developers.google.com/youtube/v3/docs/channels/list
## What shipped before this round (all committed)
- **`ac6e67a` end-of-stream close-out fix** — every 2026-09-22 stop logged
`transition(complete) 403 invalidTransition`; the blind POST raced YouTube/autoStop marking the
broadcast complete (`enableAutoStop=true` always set). `EndBroadcastAsync` now pre-flights
`liveBroadcasts.list→status.lifeCycleStatus` and skips the POST when already complete/revoked;
inconclusive pre-check still posts (backstopped); never throws. Good Dog:
`EndBroadcast_Verifies_LifeCycle_Then_Transitions_Complete_Never_Throws` (3 halves: live→GET+POST,
complete→skip, 403→error-string). Cite: youtube liveBroadcasts/transition errors table. **Full
suite 315/315** (audio-timing flake `Mix_HonorsProviderGains…` cleared on rerun); verify.sh clean
pass predates this round's XAML-only edits.
- **`18ab553` health-poll fix** — `status.healthStatus` is an OBJECT, not a string; every session
start logged `requires an element of type 'String'`. Nested shape parsed (flat tolerated), banner
alive again.
- **TASK 42** (`e69db4d`) + **TASK 41** (`bb5dcb4`) — one-surface top bar, Test Stream drawer.
- **Creator report still open — not yet diagnosed:** "I still cannot post a chat message in the
TEST tab." The TEST drawer's Mock Chat Input posts a REAL `liveChat/messages.insert`; the fix
needs its own scoped unit.
- **TASK 3 item 20 persistence half** — the canonical `RewardEvents` SQLite table +
`superChatEvents.list` (30-day) backfill + session-report rollup (the shipped alert path parses
events to `ChatMessage` in memory; the report reads that table once it exists).
- TASK 3 item 16 (Text source) still open; TASK 40 units A/C/D queued; TASK 32–36 queued.
## Around the task (carried facts)
- RealMouseDrag test no-ops while a game/fullscreen window steals the mouse (POE 2 seen 2026-09-22)
— close games before full-suite runs. AudioPipeline timing flake similar (load-dependent).
- RealMouseDrag tests no-op while a game/fullscreen window steals the mouse (POE 2 seen
2026-09-22) — close games before full-suite runs. AudioPipeline timing flake similar.
- Test-env trap: saved OAuth session loads synchronously → force `vm.IsConnected` in signed-in tests.
- `subscriberCount` YPP slice 2 needs re-consent — do not merge with other units. Polar `$99/yr`
- `subscriberCount` YPP slice 2 needs re-consent — do not merge with other units. `$99/yr` Polar
must become one-time before launch. `MARCOM.md`/`MONETIZATION.md` gitignored — never commit.
- The **`YppSnapshot.OverallGoodStanding` etc. columns now serialize false forever** (the API path
no longer populates them; schema kept stable for the analytics slice). Don't "restore" the
standing flags via auditDetails — see the MyMistakes recipe.
- TASK 40 App Settings round: units **A (camera) → C (defaults) → D (accent)** remain; A's
SharedReadOnly control-write question = only genuine uncertainty.
- Latent bug the YPP 403 masked: statistics are JSON **strings** — always the ValueKind-first
`ReadInt64` (MyMistakes recipe), never `GetInt64`.
- New facts recorded: the chat feed IS the event feed (six types, no sub event); streamList is a
connection-semantics description of that same endpoint (pollingIntervalMillis drives cadence).
- Every committed change needs a **close + relaunch** of the running app to be seen — running
builds are live; verify.sh can't run while the exe is locked.
## Next step
1. **Run the gate on the account-zone unit** (declared scope: `ViewModels/MainViewModel.Streaming.cs`,
`ytLive.Tests/TopBarModeTests.cs`, `ai.md`, `TASKS/task-42-top-bar-redesign.md`, `HANDOFF.md`) —
`./scripts/verify.sh`: clean build 0-warnings + full suite + scope check. Then commit it
("account zone is world-independent — avatar stays in Record world", code + docs in one).
Repo is 36 commits ahead of pushed `197ee81`; push decision is the creator's (sub-milestone).
1. **Creator verifies the alerts unit on the next run**: Add → Stream Alerts, then (Test Stream
drawer) Simulated Super Chat / New Member — confirm the zone stays transparent at idle and each
event plays its own animation on the canvas and preview; a real Super Chat during the next live
run confirms the real path.
2. Push decision is the creator's (repo ~10 ahead). If pushed, note the commit id in this file.
3. When scoped: diagnose the TEST-tab chat posting report ("I still cannot post a chat message in
the TEST tab") as its own Good-Dog unit.
## Critical working rules (unchanged, still binding)
- **Good Dog = ONE integration test per change.** Scope lock + `./scripts/scope-check.sh` before commit.
- Windows dotnet host for all WSL builds (`/mnt/c/Program Files/dotnet/dotnet.exe`, quoted paths).
- 0 warnings on real clean builds (verify.sh only, never incremental). One runtime model.
- 0 warnings on real clean builds (verify.sh only, never incremental). One runtime model.
- No menus/polls with the creator; decisions asked once then held; do the work, then report.
+7 -4
View File
@@ -13,7 +13,10 @@ public enum SourceType
Countdown,
WebSource,
ChatBox,
MediaSource
MediaSource,
/// <summary>The designated alert zone: the six monetization events play their
/// unique celebration animations here (OBS alert-box equivalent, native).</summary>
AlertBox
}
/// <summary>Distinguishes how a media source presents its content (TASK 21).</summary>
@@ -61,11 +64,11 @@ public class Source : SceneElement
}
}
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox;
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox or SourceType.AlertBox;
/// <summary>Live captures, chat, web sources and media are dynamic; static art is fixed.</summary>
public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture
or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource
or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox
? ElementKind.Dynamic : ElementKind.Static;
public override bool IsWebSource => Type == SourceType.WebSource;
@@ -118,7 +121,7 @@ public class Source : SceneElement
public override ImageSource? DisplaySource => IsLiveCapture
? (VideoImageSource ?? _imageSource)
: (Type is SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource ? VideoImageSource : _imageSource);
: (Type is SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox ? VideoImageSource : _imageSource);
public override bool IsImageSource => Type == SourceType.Image;
+34
View File
@@ -18,6 +18,21 @@ public record ReusableStream(string Id, string IngestionAddress, string StreamNa
public string RtmpUrl => $"{IngestionAddress}/{StreamName}";
}
/// <summary>The six monetization events YouTube's live chat feed emits (the ONLY
/// native event stream there is — free subscribers produce no event). Each kind
/// drives its own celebration animation in the AlertBox. <c>None</c> = a normal
/// chat row: it never enqueues an alert (subscriber mentions stay chat rows).</summary>
public enum ChatEventKind
{
None,
SuperChat,
SuperSticker,
NewMember,
MemberGift,
GiftReceived,
MemberMilestone,
}
public class ChatMessage
{
public string Id { get; init; } = Guid.NewGuid().ToString();
@@ -29,9 +44,28 @@ public class ChatMessage
public bool IsSuperChat { get; set; }
public double SuperChatAmount { get; set; }
public string? SuperChatCurrency { get; set; }
/// <summary>YouTube's own pre-rendered amount ("$12.34") — preferred over
/// recomposing amount+currency ourselves, which mangles non-dollar locales.</summary>
public string? SuperChatDisplayString { get; set; }
public bool IsMember { get; set; }
public string? MembershipLevel { get; set; }
public ChatEventKind Kind { get; set; }
public string? SuperStickerDescription { get; set; }
/// <summary>membershipGiftingEvent: how many memberships were bought.</summary>
public int GiftNewMembersCount { get; set; }
/// <summary>giftMembershipReceivedEvent: who gifted this one.</summary>
public string? GifterName { get; set; }
/// <summary>memberMilestoneChatEvent: streak months (0 = YouTube didn't
/// expose a count — the animation falls back to the message text).</summary>
public int MilestoneMonths { get; set; }
/// <summary>TASK 41: the Test Stream dock injects synthetic member / SuperChat /
/// subscriber events locally (YouTube's insert API only supports text), so the
/// buffer can label them honestly — simulated events never reach YouTube.</summary>
+2 -2
View File
@@ -7,14 +7,14 @@ Plain data types. No logic beyond what a property can carry. See
|------|---------|
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
| `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (TASK 43 — the celebration zone, one per layout)** — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`; `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` |
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
| `MicStatus.cs` | Footer mic indicator: `NotConnected` / `Connected` / `Problem` — the state behind the MIC button's status dot (red = no device/capture off, green = capturing, yellow = a requested mic failed: in use/unplugged) |
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) — since TASK 43 every message carries `Kind` (`ChatEventKind` None/SuperChat/SuperSticker/NewMember/MemberGift/GiftReceived/MemberMilestone) + event payload fields (`SuperChatDisplayString`, `SuperStickerDescription`, `GiftNewMembersCount`, `GifterName`, `MilestoneMonths`) |
| `Socials.cs` | **Social bar** (global layer, never a Source): `SocialService` enum (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` Top/Bottom + `BarEnabled` on/off — `BarJustify` dropped, column back-compat), `SocialServiceIcons` (canonical URL builder + `DetectService` (URL domain / fediverse `@user@domain` → **Fediverse** / bare→Website) + bundled SVG logo path data per service (`LogoDataFor`) + `LogoDataForFediverse(software)` mapping nodeinfo software names (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish) to logos with a generic fediverse glyph fallback (`FediverseIconData`) + `LockedIconData`/`DoNotIconData` — initials/colors gone) |
Related: [`ViewModels/index.md`](../ViewModels/index.md) consume these;
+231
View File
@@ -0,0 +1,231 @@
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// The native alert box (OBS-style celebration zone, no third party). Owns the
/// event queue and the animation clock: <see cref="ChatEventKind"/> events from
/// the chat feed are enqueued one-at-a-time and played through their own unique
/// animation (<see cref="AlertRenderer"/>), frames served to the stream compositor
/// via <see cref="RenderFrame"/> and mirrored to the preview via
/// <see cref="UpdatePreview"/>. Idle = transparent (null frame); playback is
/// advanced by a 33ms dispatch ticker. The clock is exposed as
/// <see cref="Advance"/> so tests drive playback deterministically without the
/// timer. Ordinary chat rows (<see cref="ChatEventKind.None"/>) never enqueue —
/// a subscriber mention is a chat row, not an alert.
/// </summary>
public sealed class AlertOverlayLayer : IDisposable
{
private readonly AlertRenderer _renderer;
private readonly Queue<ChatMessage> _queue = new();
private readonly DispatcherTimer? _ticker;
private ChatMessage? _current;
private double _elapsed;
private IEnumerable<Scene>? _scenes;
private int _contentVersion;
private int _frameVersion = -1;
private string? _frameKey;
private VideoFrame? _cachedFrame;
private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(33);
public AlertOverlayLayer(AlertRenderer renderer)
{
_renderer = renderer;
if (Application.Current != null)
{
_ticker = new DispatcherTimer { Interval = TickInterval };
_ticker.Tick += (_, _) =>
{
Advance(TickInterval.TotalSeconds);
RefreshAlertPreviews();
};
}
}
/// <summary>Production entry from the chat feed: enqueue one event. The queue
/// lives on the UI thread (the poller marshals here), so the ticker's own
/// thread expectations hold for preview writes.</summary>
public void OnMessageReceived(ChatMessage message, IEnumerable<Scene> scenes)
{
_scenes = scenes;
Enqueue(message);
_ticker?.Start();
RefreshAlertPreviews();
}
/// <summary>Queue an event (pure — also the test seam). Chat rows carry
/// <see cref="ChatEventKind.None"/> and are ignored: no alert for a mention.</summary>
public void Enqueue(ChatMessage message)
{
if (message.Kind == ChatEventKind.None) return;
if (_queue.Count >= 10) return; // back-to-back floods drop the tail, never stall playback
_queue.Enqueue(message);
if (_current == null)
{
_current = _queue.Dequeue();
_elapsed = 0;
}
_contentVersion++;
}
/// <summary>Advance the playback clock (the ticker's beat — and the test seam;
/// tests call this directly and never start the timer). Draining the queue to
/// nothing stops the ticker and leaves the box transparent.</summary>
public void Advance(double deltaSeconds)
{
if (_current == null)
{
if (_queue.Count == 0) return;
_current = _queue.Dequeue();
_elapsed = 0;
_contentVersion++;
return;
}
_elapsed += deltaSeconds;
if (_elapsed >= DurationOf(_current))
{
_current = null;
_elapsed = 0;
if (_queue.Count > 0)
{
_current = _queue.Dequeue();
_elapsed = 0;
}
else
{
_ticker?.Stop();
}
}
_contentVersion++;
}
/// <summary>Whether an alert is currently playing or queued.</summary>
public bool IsPlaying => _current != null || _queue.Count > 0;
/// <summary>The live-output frame for one AlertBox element: the current
/// animation frame while playing, null (transparent) when idle. Mirrors the
/// chat cache pattern — the raster is WPF/UI-thread, so only the cache miss
/// marshals to the dispatcher and re-validates there.</summary>
public VideoFrame? RenderFrame(Source alertBox)
{
if (_current == null)
{
_frameKey = null;
return null;
}
var width = (int)Math.Max(1, alertBox.Width);
var height = (int)Math.Max(1, alertBox.Height);
var key = $"{width}x{height}|{_current.Id}|{Progress()}";
if (_frameKey == key && _frameVersion == _contentVersion)
return _cachedFrame;
var app = Application.Current;
if (app != null && !app.Dispatcher.CheckAccess())
{
var capturedKey = key;
var capturedVersion = _contentVersion;
return app.Dispatcher.Invoke(() =>
{
if (_frameKey == capturedKey && _frameVersion == capturedVersion)
return _cachedFrame;
return RenderCore(alertBox, width, height, capturedKey, capturedVersion);
});
}
return RenderCore(alertBox, width, height, key, _contentVersion);
}
private VideoFrame? RenderCore(Source alertBox, int width, int height, string key, int version)
{
_frameKey = key;
_frameVersion = version;
if (_current == null) return _cachedFrame = null;
return _cachedFrame = _renderer.Render(_current, Progress(), width, height);
}
private double Progress()
{
if (_current == null) return 0;
return Math.Clamp(_elapsed / DurationOf(_current), 0.0, 1.0);
}
private static double DurationOf(ChatMessage m) => m.Kind switch
{
ChatEventKind.SuperChat => 4.6,
ChatEventKind.SuperSticker => 4.2,
ChatEventKind.NewMember => 4.0,
ChatEventKind.MemberGift => 5.0,
ChatEventKind.GiftReceived => 4.4,
ChatEventKind.MemberMilestone => 4.8,
_ => 4.0,
};
/// <summary>Render the current state onto one AlertBox preview (mirrors chat):
/// the playing frame, or null when idle so the source shows nothing until an
/// event actually fires.</summary>
public void UpdatePreview(Source alertBox)
{
try
{
var frame = RenderFrame(alertBox);
if (frame == null)
{
alertBox.VideoImageSource = null;
return;
}
var width = (int)Math.Max(1, alertBox.Width);
var height = (int)Math.Max(1, alertBox.Height);
var bitmap = new WriteableBitmap(width, height, 96, 96, PixelFormats.Bgra32, null);
bitmap.WritePixels(new Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0);
alertBox.VideoImageSource = bitmap;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Alert preview failed: {ex.Message}");
}
}
/// <summary>Wire property-change subscriptions for all AlertBox sources across
/// every scene (geometry changes re-render), and clear/reset their previews.</summary>
public void UpdateConfiguredBoxes(IEnumerable<Scene> scenes)
{
_scenes = scenes;
foreach (var scene in scenes)
foreach (var alertBox in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.AlertBox))
{
alertBox.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(Source.Width) or nameof(Source.Height))
UpdatePreview(alertBox);
};
UpdatePreview(alertBox);
}
}
private void RefreshAlertPreviews()
{
if (_scenes == null) return;
foreach (var alertBox in _scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.AlertBox))
UpdatePreview(alertBox);
}
public void Dispose()
{
_ticker?.Stop();
_queue.Clear();
_current = null;
}
}
+389
View File
@@ -0,0 +1,389 @@
using System.Globalization;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// Renders one event's celebration animation into a BGRA8 frame for the AlertBox.
/// Six event kinds, six UNIQUE animations — each a distinct gesture, palette and
/// layout (the creator's ruling: these are the monetization shout-outs and free
/// branded product placement; a one-template recoloring loses all interest in two
/// iterations). Each card carries the bottom-right brand line so every broadcast
/// of an event is an in-video ad placement.
///
/// <paramref name="t01"/> walks 0→1 over the whole animation; each kind splits it
/// into enter / hold / exit phases with its own easing.
/// </summary>
public sealed class AlertRenderer
{
private const double EnterEnd = 0.16;
private const double ExitStart = 0.80;
private const string Brand = "made with LlamaCasty!";
private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;
private static readonly Color White = Colors.White;
private static readonly Color[] ConfettiPalette =
{
Color.FromRgb(0xf4, 0x2f, 0x5f), Color.FromRgb(0xff, 0xd1, 0x66),
Color.FromRgb(0x38, 0xb6, 0xf8), Color.FromRgb(0xa7, 0x8b, 0xfa),
Color.FromRgb(0x39, 0xd3, 0x53),
};
public VideoFrame Render(ChatMessage e, double t01, int width, int height)
{
return e.Kind switch
{
ChatEventKind.SuperChat => SuperChat(e, t01, width, height),
ChatEventKind.SuperSticker => SuperSticker(e, t01, width, height),
ChatEventKind.NewMember => NewMember(e, t01, width, height),
ChatEventKind.MemberGift => MemberGift(e, t01, width, height),
ChatEventKind.GiftReceived => GiftReceived(e, t01, width, height),
ChatEventKind.MemberMilestone => Milestone(e, t01, width, height),
_ => SuperChat(e, t01, width, height),
};
}
private static VideoFrame SuperChat(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.03;
var cardX = margin;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = cardX + cardW / 2;
var enter = Clamp01(t / EnterEnd);
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var exitEase = EaseInCubic(exit);
var cardY = Lerp(h + cardH, margin, EaseOutCubic(enter));
cardY = Lerp(cardY, -cardH, exitEase);
DrawCard(ctx, cardX, cardY, cardW, cardH, "#0d3b2b", "#14532d", 14);
DrawBrand(ctx, cardX, cardY, cardW, cardH);
var shownAmount = e.SuperChatDisplayString
?? (e.SuperChatCurrency is { Length: > 0 } cur ? $"{cur}{e.SuperChatAmount:0.00}" : $"${e.SuperChatAmount:0.00}");
var fontSize = cardH * 0.24;
DrawCentered(ctx, shownAmount, cardCX, cardY + cardH * 0.24, fontSize, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW);
DrawCentered(ctx, e.AuthorName, cardCX, cardY + cardH * 0.56, cardH * 0.13, FontWeights.SemiBold, White, cardW);
DrawCentered(ctx, e.Message, cardCX, cardY + cardH * 0.74, cardH * 0.10, FontWeights.Normal, Color.FromRgb(0xdd, 0xff, 0xf0), cardW * 0.92);
if (t >= EnterEnd && t <= ExitStart)
{
var sweep = (t - EnterEnd) / (ExitStart - EnterEnd);
var shineX = cardX + (sweep * 2 - 0.5) * cardW;
ctx.DrawRectangle(
new LinearGradientBrush(
Colors.Transparent,
Color.FromArgb(0x30, 0xff, 0xff, 0xff),
new Point(0, 0),
new Point(1, 0)),
null,
new Rect(shineX - cardW * 0.25, cardY, cardW * 0.5, cardH));
}
}
return CreateFrame(visual, w, h, dpi);
}
private static VideoFrame SuperSticker(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.04;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = w / 2.0;
var enter = Clamp01(t / EnterEnd);
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var scale = EaseOutBack(enter) * (0.85 + 0.15 * (1 - exit));
if (t > EnterEnd && t <= ExitStart)
scale = 1.0 + 0.02 * Math.Sin(t * 26.0);
var alpha = 1.0 - exit;
ctx.PushOpacity(alpha);
var slick = new RotateTransform(-6, w / 2.0, h / 2.0);
ctx.PushTransform(slick);
ctx.PushTransform(new ScaleTransform(scale, scale, w / 2.0, h / 2.0));
DrawCard(ctx, margin, margin, cardW, cardH, "#4a044e", "#7e22ce", 20);
DrawBrand(ctx, margin, margin, cardW, cardH);
var desc = string.IsNullOrWhiteSpace(e.SuperStickerDescription) ? "SUPER STICKER" : e.SuperStickerDescription.ToUpperInvariant();
DrawCentered(ctx, desc, cardCX, h / 2.0 - cardH * 0.12, cardH * 0.17, FontWeights.Bold, White, cardW * 0.9);
DrawCentered(ctx, e.AuthorName, cardCX, h / 2.0 + cardH * 0.16, cardH * 0.12, FontWeights.SemiBold, Color.FromRgb(0xff, 0xb3, 0xd9), cardW * 0.9);
var tier = e.SuperChatDisplayString ?? (e.SuperChatCurrency is { Length: > 0 } cur ? $"{cur}{e.SuperChatAmount:0.00}" : null);
if (!string.IsNullOrWhiteSpace(tier))
DrawCentered(ctx, tier, cardCX, h / 2.0 + cardH * 0.32, cardH * 0.10, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
ctx.Pop();
ctx.Pop();
ctx.Pop();
}
return CreateFrame(visual, w, h, dpi);
}
private static VideoFrame NewMember(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.035;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = w / 2.0;
var enter = Clamp01(t / EnterEnd);
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var cardY = Lerp(-cardH, margin, EaseOutCubic(enter));
cardY = Lerp(cardY, h, EaseInCubic(exit));
DrawCard(ctx, margin, cardY, cardW, cardH, "#2e1065", "#5b21b6", 16);
DrawBrand(ctx, margin, cardY, cardW, cardH);
if (t <= ExitStart)
{
var bandP = EaseOutCubic(Clamp01(t / 0.22));
ctx.DrawRectangle(
new LinearGradientBrush(Color.FromArgb(0, 0xff, 0xff, 0xff), Colors.Transparent, new Point(0, 0), new Point(0, 1)),
null,
new Rect(margin, cardY + cardH * 0.30, cardW * bandP, cardH * 0.18));
}
DrawCentered(ctx, e.AuthorName, cardCX, cardY + cardH * 0.36, cardH * 0.22, FontWeights.Bold, White, cardW * 0.9);
DrawCentered(ctx, "JOINED THE MEMBERSHIP", cardCX, cardY + cardH * 0.62, cardH * 0.11, FontWeights.SemiBold, Color.FromRgb(0xf5, 0xd0, 0xfe), cardW * 0.9);
var level = string.IsNullOrWhiteSpace(e.MembershipLevel) ? "MEMBER" : e.MembershipLevel.ToUpperInvariant();
DrawCentered(ctx, level, cardCX, cardY + cardH * 0.80, cardH * 0.10, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
}
return CreateFrame(visual, w, h, dpi);
}
private static VideoFrame MemberGift(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.035;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = w / 2.0;
var enter = Clamp01(t / EnterEnd);
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var cardX = Lerp(-cardW, margin, EaseOutCubic(enter));
cardX = Lerp(cardX, -cardW, EaseInCubic(exit));
DrawCard(ctx, cardX, margin, cardW, cardH, "#422006", "#a16207", 16);
DrawBrand(ctx, cardX, margin, cardW, cardH);
var count = Math.Max(1, e.GiftNewMembersCount);
var spread = 1.0 - exit;
var hero = count > 1 ? $"×{count}" : "MEMBERSHIP";
DrawCentered(ctx, hero, cardCX, margin + cardH * 0.24, cardH * 0.26, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.8);
DrawCentered(ctx, $"{e.AuthorName} is gifting", cardCX, margin + cardH * 0.52, cardH * 0.11, FontWeights.SemiBold, White, cardW * 0.9);
var chipCount = Math.Min(count, 6);
for (var i = 0; i < chipCount; i++)
{
var slot = (chipCount - 1) * 0.5;
var gap = cardH * 0.13 * EaseOutCubic(Clamp01(t / 0.30)) * spread;
var chipX = cardCX + (i - slot) * gap;
var chipY = margin + cardH * 0.70 + Math.Sin((i + t * 6) * 2.4) * cardH * 0.015;
ctx.DrawRoundedRectangle(
new SolidColorBrush(Color.FromRgb(0xff, 0xf3, 0xc4)),
null,
new Rect(chipX - cardH * 0.05, chipY, cardH * 0.10, cardH * 0.10),
cardH * 0.03, cardH * 0.03);
}
}
return CreateFrame(visual, w, h, dpi);
}
private static VideoFrame GiftReceived(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.04;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = w / 2.0;
var enter = Clamp01(t / (EnterEnd * 1.4));
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var scale = 0.5 + 0.5 * EaseOutBack(enter);
var alpha = Math.Min(1.0, enter * 1.6) * (1 - exit);
ctx.PushOpacity(alpha);
ctx.PushTransform(new ScaleTransform(scale, scale, w / 2.0, h / 2.0));
DrawCard(ctx, margin, margin, cardW, cardH, "#14532d", "#4c1d95", 18);
DrawBrand(ctx, margin, margin, cardW, cardH);
// Confetti falling inside the card during the hold (deterministic seed).
if (t >= EnterEnd && t <= ExitStart)
{
var hold = (t - EnterEnd) / (ExitStart - EnterEnd);
for (var i = 0; i < 14; i++)
{
var seed = i * 37 + 11;
var dotX = margin + cardW * (((seed * 53) % 97) / 97.0);
var dotY = margin + cardH * (((seed + hold * 0.85) % 1.0));
var size = cardH * 0.02 + (i % 3) * cardH * 0.006;
ctx.DrawRectangle(
new SolidColorBrush(ConfettiPalette[i % ConfettiPalette.Length]),
null,
new Rect(dotX, dotY, size, size));
}
}
DrawCentered(ctx, "GIFTED MEMBERSHIP", cardCX, margin + cardH * 0.22, cardH * 0.12, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
var giver = string.IsNullOrWhiteSpace(e.GifterName) ? e.AuthorName : e.GifterName;
DrawCentered(ctx, giver, cardCX, margin + cardH * 0.42, cardH * 0.20, FontWeights.Bold, White, cardW * 0.9);
DrawCentered(ctx, "gave you a membership — celebrate!", cardCX, margin + cardH * 0.68, cardH * 0.11, FontWeights.Normal, Color.FromRgb(0xdb, 0xff, 0xf0), cardW * 0.9);
ctx.Pop();
ctx.Pop();
}
return CreateFrame(visual, w, h, dpi);
}
private static VideoFrame Milestone(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.04;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = w / 2.0;
var enter = Clamp01(t / EnterEnd);
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var cardY = Lerp(h + cardH, margin, EaseOutCubic(enter));
cardY = Lerp(cardY, -cardH, EaseInCubic(exit));
DrawCard(ctx, margin, cardY, cardW, cardH, "#431407", "#c2410c", 14);
DrawBrand(ctx, margin, cardY, cardW, cardH);
var hero = e.MilestoneMonths > 0 ? $"{e.MilestoneMonths}" : "MILESTONE";
DrawCentered(ctx, hero, cardCX, cardY + cardH * 0.26, cardH * 0.30, FontWeights.Bold, Color.FromRgb(0xff, 0xdd, 0x66), cardW * 0.8);
var sub = e.MilestoneMonths > 0 ? "MONTHS STRONG" : "SUPPORTING THE STREAM";
DrawCentered(ctx, sub, cardCX, cardY + cardH * 0.52, cardH * 0.11, FontWeights.SemiBold, White, cardW * 0.9);
DrawCentered(ctx, "— " + e.AuthorName + " —", cardCX, cardY + cardH * 0.70, cardH * 0.10, FontWeights.Normal, Color.FromRgb(0xff, 0xe8, 0xd0), cardW * 0.9);
// Tenure bar: grows through the first half, retracts on exit.
var barP = Clamp01((t - 0.10) / 0.55) * (1 - exit);
var barW = cardW * 0.6 * barP;
ctx.DrawRoundedRectangle(
new SolidColorBrush(Color.FromRgb(0xff, 0xd1, 0x66)),
null,
new Rect(cardCX - barW / 2, cardY + cardH * 0.84, barW, cardH * 0.045),
4, 4);
}
return CreateFrame(visual, w, h, dpi);
}
// ─── Shared drawing ───
private static void DrawCard(DrawingContext ctx, double x, double y, double w, double h, string fromHex, string toHex, double radius)
{
var brush = new LinearGradientBrush(ParseColor(fromHex), ParseColor(toHex), new Point(0, 0), new Point(1, 1));
ctx.DrawRoundedRectangle(brush, new Pen(new SolidColorBrush(Color.FromArgb(0x66, 0xff, 0xff, 0xff)), Math.Max(1, radius * 0.06)), new Rect(x, y, w, h), radius, radius);
}
private static void DrawBrand(DrawingContext ctx, double x, double y, double w, double h)
{
var formatted = new FormattedText(
Brand, Invariant, FlowDirection.LeftToRight,
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal),
Math.Max(9, h * 0.055),
new SolidColorBrush(Color.FromArgb(0x88, 0xff, 0xff, 0xff)),
96.0);
ctx.DrawText(formatted, new Point(x + w - formatted.Width - h * 0.08, y + h - formatted.Height - h * 0.05));
}
private static void DrawCentered(DrawingContext ctx, string text, double cx, double cy, double size, FontWeight weight, Color color, double maxWidth)
{
var formatted = new FormattedText(
text, Invariant, FlowDirection.LeftToRight,
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, weight, FontStretches.Normal),
size,
new SolidColorBrush(color),
96.0);
formatted.MaxTextWidth = maxWidth;
formatted.MaxLineCount = 1;
formatted.Trimming = TextTrimming.CharacterEllipsis;
ctx.DrawText(formatted, new Point(cx - formatted.Width / 2, cy - formatted.Height / 2));
}
private static Color ParseColor(string hex)
{
hex = hex.Trim().TrimStart('#');
if (hex.Length == 6)
{
var r = byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber);
var g = byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber);
var b = byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber);
return Color.FromRgb(r, g, b);
}
if (hex.Length == 8)
{
var a = byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber);
var r = byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber);
var g = byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber);
var b = byte.Parse(hex.AsSpan(6, 2), NumberStyles.HexNumber);
return Color.FromArgb(a, r, g, b);
}
return Colors.White;
}
private static VideoFrame CreateFrame(DrawingVisual visual, int width, int height, double dpi)
{
var rtb = new RenderTargetBitmap(width, height, dpi, dpi, PixelFormats.Pbgra32);
rtb.Render(visual);
var stride = width * 4;
var pixels = new byte[stride * height];
rtb.CopyPixels(pixels, stride, 0);
for (var i = 0; i < pixels.Length; i += 4)
{
(pixels[i], pixels[i + 2]) = (pixels[i + 2], pixels[i]);
}
return new VideoFrame(width, height, pixels);
}
private static double Clamp01(double v) => Math.Clamp(v, 0.0, 1.0);
private static double Lerp(double a, double b, double p) => a + (b - a) * p;
private static double EaseInCubic(double p) => p * p * p;
private static double EaseOutCubic(double p) => 1 - Math.Pow(1 - p, 3);
private static double EaseOutBack(double p)
{
const double c1 = 1.70158;
const double c3 = c1 + 1;
return 1 + c3 * Math.Pow(p - 1, 3) + c1 * Math.Pow(p - 1, 2);
}
}
+6 -1
View File
@@ -184,7 +184,9 @@ public sealed class TestSessionViewModel : ViewModelBase
/// <summary>Subscriptions aren't chat events on YouTube, so a "subscriber" row
/// is a plain text-shaped message carrying the count — honest: the chat overlay
/// shows it as a regular row, which is exactly what a sub looks like in chat.</summary>
/// shows it as a regular row, which is exactly what a sub looks like in chat.
/// It carries <see cref="ChatEventKind.None"/> on purpose: no alert animation
/// fires for free subs — the chat mention is the announcement (creator ruling).</summary>
private ChatMessage SimulatedSubscriber()
{
var author = NextViewer();
@@ -197,6 +199,7 @@ public sealed class TestSessionViewModel : ViewModelBase
var message = NewSimulated(author, $"🎉 {author} became a member!");
message.IsMember = true;
message.MembershipLevel = "New member";
message.Kind = ChatEventKind.NewMember;
return message;
}
@@ -207,6 +210,8 @@ public sealed class TestSessionViewModel : ViewModelBase
message.IsSuperChat = true;
message.SuperChatAmount = 5.00;
message.SuperChatCurrency = "$";
message.SuperChatDisplayString = "$5.00";
message.Kind = ChatEventKind.SuperChat;
return message;
}
+188 -69
View File
@@ -6,16 +6,22 @@ namespace ytLive.Services;
/// <summary>
/// Polls YouTube Live Chat API for messages.
/// YouTube doesn't have WebSocket for chat — polling is the only option.
/// YouTube doesn't have WebSocket for chat — polling is the only option. The
/// cadence follows the server's <c>pollingIntervalMillis</c> (the streamList
/// connection semantics: reconnect with nextPageToken on that cadence, never
/// faster than the refresh rate — YouTube rejects requests sent too quickly).
/// The chat feed IS YouTube's event feed: Super Chats, stickers, memberships,
/// gifts and milestones arrive here as typed messages (see <see cref="ChatEventKind"/>).
/// </summary>
public class YouTubeChatService : IDisposable
{
private readonly YouTubeAuthService _auth;
private readonly HttpClient _http = new();
private readonly HttpClient _http;
private Timer? _pollTimer;
private string? _nextPageToken;
private string? _liveChatId;
private bool _isRunning;
private int _intervalMs = 2000;
public event Action<ChatMessage>? MessageReceived;
@@ -24,9 +30,10 @@ public class YouTubeChatService : IDisposable
PropertyNameCaseInsensitive = true
};
public YouTubeChatService(YouTubeAuthService auth)
public YouTubeChatService(YouTubeAuthService auth, HttpClient? http = null)
{
_auth = auth;
_http = http ?? new HttpClient();
}
public void Start(string liveChatId, int pollIntervalMs = 2000)
@@ -34,7 +41,9 @@ public class YouTubeChatService : IDisposable
_liveChatId = liveChatId;
_isRunning = true;
_nextPageToken = null;
_pollTimer = new(async _ => await Poll(), null, 0, pollIntervalMs);
_intervalMs = Math.Clamp(pollIntervalMs, 1000, 6000);
_pollTimer = new Timer(async _ => await Poll(), null, Timeout.Infinite, Timeout.Infinite);
_pollTimer.Change(0, Timeout.Infinite);
}
public void Stop()
@@ -54,14 +63,14 @@ public class YouTubeChatService : IDisposable
private async Task Poll()
{
if (!_isRunning || _liveChatId == null || _auth.CurrentChannel == null) return;
try
{
if (!_isRunning || _liveChatId == null || _auth.CurrentChannel == null) return;
if (_auth.CurrentChannel.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
await _auth.RefreshToken();
var url = $"https://www.googleapis.com/youtube/v3/liveChat/messages?liveChatId={_liveChatId}&part=snippet,authorDetails&maxResults=50";
var url = $"https://www.googleapis.com/youtube/v3/liveChat/messages?liveChatId={_liveChatId}&part=snippet,authorDetails&maxResults=2000";
if (_nextPageToken != null)
url += $"&pageToken={_nextPageToken}";
@@ -70,76 +79,182 @@ public class YouTubeChatService : IDisposable
if (!response.IsSuccessStatusCode) return;
var json = await response.Content.ReadAsStringAsync();
var data = JsonSerializer.Deserialize<JsonElement>(json);
var page = ParsePage(json);
if (data.TryGetProperty("nextPageToken", out var token))
_nextPageToken = token.GetString();
if (!data.TryGetProperty("items", out var items)) return;
foreach (var item in items.EnumerateArray())
{
var snippet = item.GetProperty("snippet");
var author = item.GetProperty("authorDetails");
var type = snippet.GetProperty("type").GetString() ?? "textMessageEvent";
var message = new ChatMessage
{
Id = item.GetProperty("id").GetString()!,
AuthorName = author.GetProperty("displayName").GetString()!,
AuthorChannelId = author.GetProperty("channelId").GetString()!,
AuthorImageUrl = author.GetProperty("profileImageUrl").GetString()!,
Timestamp = DateTime.Parse(snippet.GetProperty("publishedAt").GetString()!),
IsMember = author.GetProperty("isChatSponsor").GetBoolean(),
MembershipLevel = author.TryGetProperty("badgeText", out var badgeText)
? badgeText.GetString()
: null
};
switch (type)
{
case "superChatEvent":
message.IsSuperChat = true;
if (snippet.TryGetProperty("superChatDetails", out var scDetails))
{
message.SuperChatAmount = scDetails.TryGetProperty("amountMicros", out var amt)
? amt.GetDouble() / 1_000_000.0
: 0;
message.SuperChatCurrency = scDetails.TryGetProperty("currency", out var cur)
? cur.GetString()
: null;
message.Message = scDetails.TryGetProperty("userComment", out var comment)
? comment.GetString() ?? string.Empty
: string.Empty;
}
break;
case "newSponsorEvent":
message.Message = $"🎉 {message.AuthorName} became a member!";
if (snippet.TryGetProperty("membershipDetails", out var memDetails))
{
message.MembershipLevel = memDetails.TryGetProperty("memberLevelName", out var level)
? level.GetString()
: message.MembershipLevel;
}
break;
default: // textMessageEvent
message.Message = snippet.TryGetProperty("textMessageDetails", out var textDetails)
? textDetails.TryGetProperty("messageText", out var text)
? text.GetString() ?? string.Empty
: string.Empty
: string.Empty;
break;
}
if (page.NextPageToken != null)
_nextPageToken = page.NextPageToken;
if (page.PollIntervalMs > 0 && page.PollIntervalMs != _intervalMs)
_intervalMs = page.PollIntervalMs;
foreach (var message in page.Messages)
MessageReceived?.Invoke(message);
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Chat poll error: {ex.Message}");
}
finally
{
// Re-arm the one-shot poll on the server's cadence. Guarded by
// _isRunning so a concurrent Stop() (dispose + null) can't race a
// Change() back onto the disposed timer.
if (_isRunning)
_pollTimer?.Change(_intervalMs, Timeout.Infinite);
}
}
/// <summary>One liveChat/messages page → typed chat messages + pagination +
/// the server's poll cadence. Internal + separated from the HTTP so the six
/// event parses are deterministically testable without any network.</summary>
internal static ChatPage ParsePage(string json)
{
var data = JsonSerializer.Deserialize<JsonElement>(json);
var messages = new List<ChatMessage>();
var nextPageToken = data.TryGetProperty("nextPageToken", out var token)
? token.GetString()
: null;
var intervalMs = data.TryGetProperty("pollingIntervalMillis", out var interval)
&& interval.ValueKind == JsonValueKind.Number
? interval.GetInt32()
: 0;
if (!data.TryGetProperty("items", out var items)) return new ChatPage(messages, nextPageToken, intervalMs);
foreach (var item in items.EnumerateArray())
{
var snippet = item.GetProperty("snippet");
var type = snippet.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
? t.GetString()
: "textMessageEvent";
var message = new ChatMessage
{
Id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? Guid.NewGuid().ToString() : Guid.NewGuid().ToString(),
Timestamp = DateTime.UtcNow,
};
if (item.TryGetProperty("authorDetails", out var author))
{
message.AuthorName = author.TryGetProperty("displayName", out var dn) ? dn.GetString() ?? string.Empty : string.Empty;
message.AuthorChannelId = author.TryGetProperty("channelId", out var cid) ? cid.GetString() ?? string.Empty : string.Empty;
message.AuthorImageUrl = author.TryGetProperty("profileImageUrl", out var im) ? im.GetString() ?? string.Empty : string.Empty;
message.IsMember = author.TryGetProperty("isChatSponsor", out var sp) ? sp.GetBoolean() : false;
message.MembershipLevel = author.TryGetProperty("badgeText", out var badge)
? badge.GetString()
: null;
}
if (snippet.TryGetProperty("publishedAt", out var published)
&& DateTime.TryParse(published.GetString(), out var publishedAt))
message.Timestamp = publishedAt;
switch (type)
{
case "superChatEvent":
message.IsSuperChat = true;
message.Kind = ChatEventKind.SuperChat;
if (snippet.TryGetProperty("superChatDetails", out var scDetails))
{
if (scDetails.TryGetProperty("displayString", out var display)
&& display.ValueKind == JsonValueKind.String)
message.SuperChatDisplayString = display.GetString();
if (scDetails.TryGetProperty("amountMicros", out var amt)
&& amt.ValueKind == JsonValueKind.Number)
message.SuperChatAmount = amt.GetDouble() / 1_000_000.0;
message.SuperChatCurrency = scDetails.TryGetProperty("currency", out var cur)
? cur.GetString()
: null;
message.Message = scDetails.TryGetProperty("userComment", out var comment)
? comment.GetString() ?? string.Empty
: string.Empty;
}
break;
case "superStickerEvent":
message.Kind = ChatEventKind.SuperSticker;
if (snippet.TryGetProperty("superStickerDetails", out var stickerDetails))
{
message.SuperStickerDescription = stickerDetails.TryGetProperty("stickerDescription", out var desc)
? desc.GetString()
: stickerDetails.TryGetProperty("description", out var descAlt)
? descAlt.GetString()
: null;
if (stickerDetails.TryGetProperty("displayString", out var display)
&& display.ValueKind == JsonValueKind.String)
message.SuperChatDisplayString = display.GetString();
message.SuperChatCurrency = stickerDetails.TryGetProperty("currency", out var cur)
? cur.GetString()
: null;
message.Message = stickerDetails.TryGetProperty("userComment", out var comment)
? comment.GetString() ?? string.Empty
: string.Empty;
}
break;
case "newSponsorEvent":
message.Kind = ChatEventKind.NewMember;
message.IsMember = true;
message.Message = $"🎉 {message.AuthorName} became a member!";
if (snippet.TryGetProperty("membershipDetails", out var memDetails))
{
message.MembershipLevel = memDetails.TryGetProperty("memberLevelName", out var level)
? level.GetString()
: message.MembershipLevel;
}
break;
case "membershipGiftingEvent":
message.Kind = ChatEventKind.MemberGift;
if (snippet.TryGetProperty("membershipGiftingDetails", out var giftingDetails)
&& giftingDetails.TryGetProperty("giftNewMembersCount", out var count)
&& count.ValueKind == JsonValueKind.Number)
message.GiftNewMembersCount = count.GetInt32();
message.Message = message.GiftNewMembersCount > 0
? $"🎁 {message.AuthorName} gifted {message.GiftNewMembersCount} memberships!"
: $"🎁 {message.AuthorName} gifted memberships!";
break;
case "giftMembershipReceivedEvent":
message.Kind = ChatEventKind.GiftReceived;
if (snippet.TryGetProperty("giftMembershipReceivedDetails", out var giftReceived)
&& giftReceived.TryGetProperty("gifterName", out var gifter))
message.GifterName = gifter.GetString();
message.Message = message.GifterName != null
? $"{message.GifterName} gifted you a membership! 🎉"
: $"{message.AuthorName} received a membership gift! 🎉";
break;
case "memberMilestoneChatEvent":
message.Kind = ChatEventKind.MemberMilestone;
message.IsMember = true;
message.Message = snippet.TryGetProperty("textMessageDetails", out var msd)
? msd.TryGetProperty("messageText", out var mst)
? mst.GetString() ?? string.Empty
: string.Empty
: string.Empty;
if (message.Message.Length > 0)
{
var match = System.Text.RegularExpressions.Regex.Match(message.Message, @"(\d+)\s*month", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (match.Success && int.TryParse(match.Groups[1].Value, out var months))
message.MilestoneMonths = months;
}
break;
default: // textMessageEvent and anything else — anonymous chat rows only.
message.Message = snippet.TryGetProperty("textMessageDetails", out var textDetails)
? textDetails.TryGetProperty("messageText", out var text)
? text.GetString() ?? string.Empty
: string.Empty
: string.Empty;
break;
}
messages.Add(message);
}
return new ChatPage(messages, nextPageToken, intervalMs);
}
public void Dispose()
@@ -148,3 +263,7 @@ public class YouTubeChatService : IDisposable
_http.Dispose();
}
}
/// <summary>Decoded liveChat/messages response: the messages + where to resume +
/// how long to wait before reconnecting.</summary>
internal sealed record ChatPage(List<ChatMessage> Messages, string? NextPageToken, int PollIntervalMs);
+5 -1
View File
@@ -8,7 +8,11 @@ External-facing logic: YouTube API, persistence. See
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) |
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API. **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1) and the one-click v3 flags (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`); injectable `HttpClient? http = null` ctor seam so tests can fake the API. **`GetOrCreateReusableStreamAsync` (TASK 9, shipped 2026-08-16)** returns the channel's `ReusableStream` — lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once only on first use (`resolution=variable`, `frameRate=variable`, `isReusable=true`); a stream id given to `CreateBroadcast` binds at insert via `contentDetails.boundStreamId`. RTMP URL = `ingestionAddress + "/" + streamName`. **`GetStreamHealthAsync(streamId)` (TASK 9 item 3, shipped 2026-08-16)** polls `liveStreams?part=status` → `StreamHealth` with parsed `configurationIssues[]` (NOTE 2026-09-22: the real API nests `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` as an OBJECT — the parse reads the nested `.status` and tolerates the old flat-string shape; the object shape used to crash every poll) | **`EndBroadcastAsync` (2026-09-01; hardened 2026-09-22)** posts `liveBroadcasts/transition?broadcastStatus=complete&part=status` AFTER RTMP EOF with a pre-flight `liveBroadcasts.list` read of the broadcast's OWN `lifeCycleStatus`: only transitions from `live`/`testing`, skips already-complete broadcasts silently (2026-09-22: every blind end logged `invalidTransition` — autoStop races us to complete); `enableAutoStop` finishes every skip |
| `StreamHealthReporter.cs` | Pure report-by-exception decision (TASK 9 item 3): `BannerFor(issues)` → `HealthIssueReport(Text?, IsError)` — null text on good/ok/noData/info-only, first warning/error issue produces its type text, error beats warning for color |
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable`. **TASK 43 rewrite (2026-09-24):** re-arms its one-shot poll on the server's `pollingIntervalMillis` (streamList semantics — reconnect on that cadence with `nextPageToken`, clamp 1000–6000ms, `maxResults=2000`); `ParsePage` (internal static seam) decodes **all six** event types into `ChatMessage.Kind`/fields (superChat / superSticker / newSponsor / membershipGifting / giftMembershipReceived / memberMilestoneChat + milestone months best-effort regex); optional `HttpClient` ctor seam endures; `InjectSimulatedMessage` (TASK 41) preserved |
| `ChatBoxRenderer.cs` | Chat overlay rasterizer (WPF FormattedText → BGRA `VideoFrame`), live rows + Super Chat/member styling |
| `ChatOverlayLayer.cs` | **The chat component (Commit G):** owns the message buffer, `ChatBoxRenderer`, fade/mock timers, per-source preview renders, live `RenderFrame`; raster-on-change cache pattern (OBS text-source contract) |
| `AlertRenderer.cs` | **The alert rasterizer (TASK 43, 2026-09-24):** renders one event's celebration animation into a BGRA `VideoFrame` — SIX DISTINCT animations (SuperChat slide-up/shine/count-up, SuperSticker scale-pop, NewMember drop-in/flash-band, MemberGift slide-left/chip-fan, GiftReceived confetti scale-in, MemberMilestone rise/growth-bar), enter/hold/exit phases, brand line on every card |
| `AlertOverlayLayer.cs` | **The native alert-box component (TASK 43):** event queue (cap 10) + 33ms dispatch ticker + `RenderFrame` cache-first live path + `UpdatePreview`; **`Advance(double)` is the deterministic test clock** (tests never start the timer); `ChatEventKind.None` rows (sub mentions) never enqueue; idle = transparent (null frame) |
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup) |
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
+13 -2
View File
@@ -58,12 +58,13 @@
| 40 | App Settings round: active-camera picker + config dialog, gear move, defaults, accent | ☐ Queued (2026-09-22) — plan saved | [`TASKS/task-40-app-settings-round.md`](TASKS/task-40-app-settings-round.md) |
| 41 | Test Stream mode: private test broadcast + TEST drawer (mock chat input + simulated events) | ✅ Done (2026-09-22) | [`TASKS/task-41-test-stream-mode.md`](TASKS/task-41-test-stream-mode.md) |
| 42 | Top bar redesign: one REC-or-Stream surface, gear up top, sign-in/account zone world-gated | ✅ Done (2026-09-22) | [`TASKS/task-42-top-bar-redesign.md`](TASKS/task-42-top-bar-redesign.md) |
| 43 | Native events & alerts: chat parity (all six event types + server poll cadence) + six-animation native alert box | ✅ Done (2026-09-24) | [`TASKS/task-43-native-alerts.md`](TASKS/task-43-native-alerts.md) |
---
## Open items (at a glance)
- **TASK 3** — items 16 (Text source), 17 (Alerts), 20 (Alerts precursor: RewardEvent capture) open
- **TASK 3** — items 16 (Text source) + 20 (RewardEvent capture → SQLite, Alerts' persistence half) open; **17 (Alerts) is DONE via TASK 43 (2026-09-24) — native six-event alert box**
- **TASK 9** — item 5 open (webcam identity key reconciliation)
- **TASK 10** — Velopack update URL pending
- **TASK 12** — queued future
@@ -98,7 +99,17 @@
poll round-trip renders on the overlay) + simulated Subscriber/New Member/Super Chat events that
inject through the poller's `MessageReceived` seam (`IsSimulated` — YouTube's insert API only
creates text, so these are local-only by design; styled member/SuperChat rows validate the chat
overlay). Real stinger/TTS alert widgets stay out of scope (TASK 3 items 16/17/20).
overlay). **Real Stinger/TTS alert widgets resolved by TASK 43's native alert box.**
- **TASK 43 — Native events & alerts** — **DONE 2026-09-24** (`TASKS/task-43-native-alerts.md`):
StreamElements replaced with NATIVE YouTube events. Chat parity half: `YouTubeChatService` now
decodes all SIX `liveChat/messages` event types (`ChatEventKind` on `ChatMessage`) — the four
formerly-empty event rows fixed — and re-arms its poll on the server's `pollingIntervalMillis`
(streamList semantics, clamp 1000–6000ms, `maxResults=2000`). Alerts half: a new
**`SourceType.AlertBox`** ("Stream Alerts", one per layout) is an OBS-style celebration zone
where the six events play **six unique branded animations** (`AlertRenderer` + the
`AlertOverlayLayer` component; 33ms ticker, deterministic `Advance` test clock; idle =
transparent). Creator rulings baked in: free-sub mention = chat row only (no sub alert — YouTube
emits none), NO viewer count, every alert card carries the "made with LlamaCasty!" brand line.
---
+20 -6
View File
@@ -5,7 +5,7 @@
**Goal:** Real video preview in the center panel — the minimal source set below, composited per scene.
### Status: 🔶 In progress — 27 of 30 items done (1 excluded: background removal); items 16, 17, 20 open
### Status: 🔶 In progress — **Alerts (item 20) SHIPPED 2026-09-24 via TASK 43**; still open: item 17 (Text source), item 20's `RewardEvents` SQLite persistence half (16 compositing + 19 background removal excluded)
1. ✅ **Milestone 1 — webcam** — MediaCapture (WinRT SDK projection) with device enumeration, CPU-first frame source, refcounted `CameraManager`, picker dialog, clip shapes (Traditional + Round) + mirror, 480×270 default placement — schema v2
2. ✅ **Schema v3 (Ship Branch A)** — multi-scene webcam (singleton `Webcam` + per-scene `WebcamSceneConfig`), right-click border/context menu, static OBS-style borders, 50%-per-dimension webcam size cap, device-swap (`ReleaseAllAsync`) — 25 tests passing
@@ -26,7 +26,19 @@
17. ☐ **Text source** — live text ("Starting soon", "Back in 5", handle, callout)
18. ✅ **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video — `ChatBoxRenderer` (WPF FormattedText → VideoFrame), configurable font size/color/badges/timestamps/max-messages via Elements panel, schema v10, persisted across save/load
19. ❌ **Background removal (milestone 2)** — ONNX Runtime + DirectML, MediaPipe Selfie Segmentation — deliberately NOT in this build
20. ☐ **Alerts** — Super Chat / membership / subscribe pop-ins; build after the six; **free and ungated** (the stale "one paid feature" label predates the 2026-08-31 monetization reversal and is corrected 2026-09-01 — the paid swap is the branding flash ONLY; see Monetization in `ai.md`). **v1 IN** per the complete-v1 ruling. **Precursor — reward-event capture (2026-09-01):** Alerts render from the app's canonical `RewardEvent` feed (all 7 `liveChatMessage` reward types, persisted to SQLite + the session report — see TASK 10 → "Related work — monetization awareness"), so it consumes that already-built data rather than re-integrating `liveChatMessages`. That capture is the required prior milestone.
20. ✅ **Alerts — Super Chat / membership pop-ins (SHIPPED 2026-09-24 via TASK 43, NATIVE)** —
free and ungated (see Monetization in `ai.md`). **v1 IN** per the complete-v1 ruling. The
deliverable is now **native events + native animations, no third-party widget**: the chat feed
(`liveChat/messages`, six types decoded — TASK 43 half 1) drives a `SourceType.AlertBox`
("Stream Alerts", one celebration zone per layout) where each event plays its **own unique
branded animation** (`AlertRenderer`/`AlertOverlayLayer`, 33ms ticker, `Advance` test clock).
**Creator rulings:** a free-subscriber mention is a plain chat row — NO sub alert (YouTube
emits no public event for free subs), NO viewer count (vetoed), and every card draws the
"made with LlamaCasty!" brand line (each broadcast = free product placement). **Precursor
(still open): the reward-event persistence half — the canonical `RewardEvents` SQLite table
(all six types persisted per broadcast) + `superChatEvents.list` backfill + the session-report
rollup (TASK 10 → "Related work — monetization awareness"). The shipped alert path parses
events into `ChatMessage` directly in memory; the report reads that table once it exists.**
21. ✅ **Show Desktop toggle** — `Source.ShowDesktop` bool persisted in DB (schema migration + LayoutStore read/write). When checked, primary monitor captures regardless of fullscreen game state. Toggling off falls back to running game or releases to static placeholder. `ClearBackdropCaptureAsync` clears `CaptureKey` + `VideoImageSource` so static fallback renders. Toggle on Live backdrop context menu (IsCheckable MenuItem).
22. ✅ **Top bar — status light + avatar + Log In button** — red/green/pulsing ellipse (disconnected/connected/live). Avatar border visible only when connected, loads via `BitmapImage` code-behind. "Log In" button visible when disconnected, calls `StartStreamCommand` → GoLiveWindow. `ShowStartStream` now requires `IsConnected`.
23. ✅ **Chat preview — one-at-a-time mock messages** — `RunMockChatPreviewAsync`: simple async loop, displays `MockChatMessages[n]` via `Take(n+1)`, 1000ms between messages, wraps at end. `ChatPreviewEnabled` property on Source (default true). Stops on real messages, restarts on fade timer clear.
@@ -53,10 +65,12 @@ the hot few and nothing esoteric. If a user needs more, they've graduated to OBS
4. **Image** — a floating graphic/logo overlay (watermark, badge, corner branding). Free-positioned.
5. **Text** — live text ("Starting soon", "Back in 5", handle, callout). Casual streamers live on this.
6. **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video. YT-native.
7. **Alerts** — Super Chat / membership / subscribe pop-ins. The dopamine source. **The one big lift**
(Super Chat event streaming + on-stream rendering/animation); build after the six. **Free and
ungated** — paid = flash removal only (stale "one paid feature" corrected 2026-09-01). Consumes
the canonical `RewardEvent` feed (reward-event capture, TASK 10 → related work).
7. **Alerts** — Super Chat / membership pop-ins. The dopamine source. **The one big lift — DONE
(2026-09-24, TASK 43): native events + native animations.** One `SourceType.AlertBox`
celebration zone; six event types = six unique branded animations; free-sub mention = chat row
only; no viewer count. **Free and ungated** — paid = flash removal only (corrected 2026-09-01).
Pending: the `RewardEvents` SQLite persistence half (item 20; the report + journey read it,
TASK 10 → related work).
Deliberately NOT supported: game capture, browser source, media playlist, VLC, color-key voodoo, MIDI.
+125
View File
@@ -0,0 +1,125 @@
# TASK 43 — Native events & alerts (the "go" unit: chat parity + six animation alert box)
> Catalog: [`TASKS.md`](../TASKS.md). Status: ✅ **SHIPPED 2026-09-24** — creator said
> **"go"** on the combined work unit (native-chat parity + native alert box). See
> [`ai.md`](../ai.md) → "Native events & alerts" for the architecture.
## Provenance (recorded per the feature-provenance rule)
- **2026-09-23, the creator asked** — "Could you use native YouTube events instead of
StreamElements?" (the app was designed to feed an external StreamElements alert box).
Research (websearch + the liveChat/messages + streamList docs, URLs in the commit
message) established the API facts below; the creator then ruled on product shape.
- **Decisions the creator made in conversation (asked ONCE, then held):**
1. **No menus / no polls** — "I've asked you before to not use menus/polls." Decisions
are asked on the open floor once and then executed.
2. **Events = ONE animated alert box** (OBS alert-box pattern), a designated area on
the canvas where celebration animations play. NOT a list pane, NOT per-source.
3. **Six monetization events → SIX UNIQUE animations** — "not cookie-cutter": each of
Super Chat, Super Sticker, New Member (newSponsor), Member Gift, Gift Received,
Member Milestone gets its own distinct gesture, palette and layout. Each animation
is a free branded product-placement ad in the stream (the brand line is drawn into
every card) and a celebration of the supporter's investment.
4. **Free subscribers: NO alert** — "subscriber mention in chat is sufficient."
YouTube exposes no public event for free subs (API fact); a mention arrives as a
normal chat row and is deliberately `ChatEventKind.None` → the alert engine never
fires. The app keeps its opinion: none. (The third-party socket + delta-counter
folklore was explicitly rejected with it.)
5. **Viewer count: dropped entirely** — demotivating and weaponizable against the
creator; not even hidden.
6. **Native chat replaces the SE URI** — the chat box already exists in-app
(`SourceType.ChatBox`, fed by real `liveChat/messages` polling). The remaining
parity gaps closed here: the four previously-undecoded event types displayed as
**empty rows** in the overlay, and the poll cadence was a hardcoded 2s instead of
the server's `pollingIntervalMillis`.
## API facts (researched + cited in commit)
- Native event feed = the **same** `liveChat/messages` endpoint already polled.
`streamList` is a *connection semantics* description of that endpoint, not a separate
call: response carries `pollingIntervalMillis`; reconnect with `nextPageToken` on that
cadence, never faster (YouTube rejects rapid polling). The code now re-arms the
one-shot poll on the server's number (clamped 1000–6000ms), and `maxResults=2000`.
Cite: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList
- Six `snippet.type` event kinds exist: `superChatEvent`, `superStickerEvent`,
`newSponsorEvent`, `membershipGiftingEvent`, `giftMembershipReceivedEvent`,
`memberMilestoneChatEvent`. Free subscriptions emit **nothing** — no API event, no
delta, no count (the "newSponsorEvent counts" folklore is wrong for public subs).
- `videos.list → liveStreamingDetails.concurrentViewers` exists but was **vetoed** by
the creator (decision 4 above).
## Product model (as shipped)
- **`SourceType.AlertBox`** ("Stream Alerts") — a one-per-layout celebration zone,
mirroring the chat layer's gate (`CanAddAlerts` = no AlertBox exists in any scene;
`AddSource` refuses a second). Defaults 620×430 → 680×200. Draggable, previews like a
source, idle = fully transparent (nothing on stream until an event actually fires).
- **Playback:** events from the chat feed enqueue one-at-a-time on a 33ms UI
dispatcher tick, play their own animation (4–5s each), then drain; back-to-back floods
cap the queue at 10 and drop the tail, never stalling playback. The clock is exposed
as `Advance(double)` so tests drive playback deterministically with no timer.
- **The six animations** (`Services/AlertRenderer.cs`), phases enter[0–0.16] /
hold[0.16–0.8] / exit[0.8–1]:
| Kind | Gesture | Palette |
|---|---|---|
| SuperChat | slide-up + gold amount count-up + shine sweep | green `#0d3b2b→#14532d` |
| SuperSticker | scale-pop overshoot, tilted sticker chip, bob | purple `#4a044e→#7e22ce` |
| NewMember | drop-in + white flash band + gold level chip | violet `#2e1065→#5b21b6` |
| MemberGift | slide-left + gift-chip fan `×N` | amber `#422006→#a16207` |
| GiftReceived | confetti burst scale-in | green→purple `#14532d→#4c1d95` |
| MemberMilestone | rise-up + tenure growth bar | orange `#431407→#c2410c` |
- Every card draws **"made with LlamaCasty!"** bottom-right — each broadcast of an event
is in-video product placement (creator decision: "it's a free ad each time").
## Chat parity (half 1 of the unit)
- `YouTubeChatService` rewritten around the real cadence + all six event parses:
`ParsePage(string)` (internal static seam) → `ChatPage(Messages, NextPageToken,
PollIntervalMs)`; fields landed on `ChatMessage`: `Kind` (`ChatEventKind`),
`SuperChatDisplayString`, `SuperStickerDescription`, `GiftNewMembersCount`,
`GifterName`, `MilestoneMonths` (best-effort `(\d+) months` regex from the milestone
chat text). The four formerly-empty event types now parse as real rows AND drive
animations. `InjectSimulatedMessage` (TASK 41) preserved.
## Test (Good Dog — ONE integration test per change)
`ytLive.Tests/AlertLayerTests.cs` (RealApp STA host, real WPF raster) — `AlertLayerTests`:
1. **Parse:** a mocked 7-item feed → all six kinds classified, `None` for plain chat,
`pollingIntervalMillis`/`nextPageToken` honored, fields verified (incl. the
formerly-empty `3 memberships` / `gifted you a membership` rows).
2. **Playback:** subscriber-mention (`None`) enqueues nothing; the six events play
through the queue with non-null, non-blank, *moving* frames; every event's mid-hold
frame is **pairwise distinct from every other** (six unique animations, pinned);
playback drains to transparent/null.
## Files (declared scope)
- `Models/YouTube.cs` (ChatEventKind + fields), `Models/Source.cs` (SourceType.AlertBox
+ IsDraggable/Kind/DisplaySource)
- `Services/YouTubeChatService.cs` (cadence + six-event ParsePage seam)
- `Services/TestSessionViewModel.cs` (sims tagged: member→NewMember, superchat→SuperChat)
- **NEW** `Services/AlertRenderer.cs`, `Services/AlertOverlayLayer.cs`
- `ViewModels/MainViewModel.cs`, `ViewModels/MainViewModel.Chat.cs`,
`ViewModels/MainViewModel.Sources.cs` (wiring: `_alertLayer`, render resolver,
`CanAddAlerts`, menu name, shakedown `LoadLayout`, dispose)
- `Controls/LeftPanel.xaml` (Add → Stream Alerts menu item)
- **NEW** `ytLive.Tests/AlertLayerTests.cs`
- Docs: `ai.md`, `TASKS.md`, this file, `Services/index.md`, `ViewModels/index.md`,
`Models/index.md`, `TASKS/task-03-capture-pipeline.md`, `HANDOFF.md`
## Gate
Clean build 0 warnings (Windows dotnet host); full suite **316/317** — the one failure
`LayerReorderPersistenceTests.RealMouseDrag…` repros on the CLEAN tree (proved via
`git stash`; it is a real-mouse-drag test that no-ops when a game/fullscreen window
steals the mouse — HANDOFF-documented environmental class). Scope check green.
## Open follow-ups (NOT this unit)
- TASK 3 item 17 (Alerts) is DONE via native events; **item 20 stays open** — the
RewardEvent SQLite persistence + session-report rollup + `superChatEvents.list`
backfill were scoped as the capture foundation and remain queued (the shipped alert
path parses events directly into `ChatMessage`, which is what the report reads).
- TASK 3 item 16 (Text source) still open.
- Creator report still open: "I still cannot post a chat message in the TEST tab" —
not yet diagnosed; the TEST drawer's Mock Chat Input posts via a real insert.
+20 -1
View File
@@ -10,6 +10,7 @@ public partial class MainViewModel : ViewModelBase
{
private readonly ChatOverlayLayer _chatLayer;
private readonly AlertOverlayLayer _alertLayer;
/// <summary>The live chat message buffer — the same collection the
/// <see cref="ChatOverlayLayer"/> owns; here so XAML can bind it and the
@@ -22,10 +23,19 @@ public partial class MainViewModel : ViewModelBase
=> StagedScene != null
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox));
/// <summary>The Add → Stream Alerts menu item: one alert zone per layout
/// (multiple zones would double-play every event).</summary>
public bool CanAddAlerts
=> StagedScene != null
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.AlertBox));
public bool ShowChatInactiveMessage => !IsLive;
private void OnChatMessageReceived(ChatMessage message)
=> _chatLayer.OnMessageReceived(message, Scenes);
{
_chatLayer.OnMessageReceived(message, Scenes);
_alertLayer.OnMessageReceived(message, Scenes);
}
private void UpdateChatBoxPreview(Source chatBox, IReadOnlyList<ChatMessage>? messages = null)
=> _chatLayer.UpdatePreview(chatBox, messages);
@@ -33,6 +43,12 @@ public partial class MainViewModel : ViewModelBase
private VideoFrame? RenderChatBox(Source chatBox)
=> _chatLayer.RenderFrame(chatBox);
private void UpdateAlertBoxPreview(Source alertBox)
=> _alertLayer.UpdatePreview(alertBox);
private VideoFrame? RenderAlertBox(Source alertBox)
=> _alertLayer.RenderFrame(alertBox);
private void StartMockChatPreview()
=> _chatLayer.StartMockPreview(Scenes);
@@ -41,4 +57,7 @@ public partial class MainViewModel : ViewModelBase
private void UpdateActiveChatBoxPreviews()
=> _chatLayer.UpdateConfiguredBoxes(Scenes);
private void UpdateActiveAlertBoxPreviews()
=> _alertLayer.UpdateConfiguredBoxes(Scenes);
}
+9 -6
View File
@@ -29,15 +29,18 @@ public partial class MainViewModel
SourceType.Countdown => "Countdown",
SourceType.WebSource => "Web Resource",
SourceType.ChatBox => "YouTube Chat",
SourceType.AlertBox => "Stream Alerts",
_ => "Layer",
};
// The (+) menu only adds regular layers. The Background is a locked
// per-screen layer managed by EnsureBackground/NormalizeBackgrounds —
// never user-created, never deleted. YouTube Chat is one-per-layout.
// never user-created, never deleted. YouTube Chat and Stream Alerts
// are one-per-layout.
if (sourceType is SourceType.DisplayCapture or SourceType.WindowCapture or SourceType.Background)
return;
if (sourceType == SourceType.ChatBox && !CanAddYouTubeChat)
if ((sourceType == SourceType.ChatBox && !CanAddYouTubeChat)
|| (sourceType == SourceType.AlertBox && !CanAddAlerts))
return;
// Web resources use "Web Resource-N" naming (0-indexed per scene)
@@ -48,10 +51,10 @@ public partial class MainViewModel
? $"{baseName}-{webCount}"
: NextSourceName(scene, baseName),
Type = sourceType,
X = sourceType == SourceType.ChatBox ? 1400 : 0,
Y = sourceType == SourceType.ChatBox ? 20 : 0,
Width = sourceType == SourceType.WebSource ? 640 : sourceType == SourceType.ChatBox ? 500 : 0,
Height = sourceType == SourceType.WebSource ? 480 : sourceType == SourceType.ChatBox ? 648 : 0,
X = sourceType == SourceType.ChatBox ? 1400 : sourceType == SourceType.AlertBox ? 620 : 0,
Y = sourceType == SourceType.ChatBox ? 20 : sourceType == SourceType.AlertBox ? 430 : 0,
Width = sourceType == SourceType.WebSource ? 640 : sourceType == SourceType.ChatBox ? 500 : sourceType == SourceType.AlertBox ? 680 : 0,
Height = sourceType == SourceType.WebSource ? 480 : sourceType == SourceType.ChatBox ? 648 : sourceType == SourceType.AlertBox ? 200 : 0,
};
_sceneGraph.AddElement(scene, source);
+4
View File
@@ -308,6 +308,7 @@ public partial class MainViewModel : ViewModelBase
_mediaManager.MediaFailed += OnMediaFailed;
_chatLayer = new ChatOverlayLayer(new ChatBoxRenderer());
_alertLayer = new AlertOverlayLayer(new AlertRenderer());
_rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL
_framePump = new FramePump(
@@ -397,6 +398,7 @@ public partial class MainViewModel : ViewModelBase
StagedScene = Scenes.FirstOrDefault();
UpdateActiveBackground();
UpdateActiveChatBoxPreviews();
UpdateActiveAlertBoxPreviews();
StartMockChatPreview();
UpdateBackgroundImage();
ReacquireWebcam();
@@ -437,6 +439,7 @@ public partial class MainViewModel : ViewModelBase
_layoutStore.Dispose();
_musicPlayer.Dispose();
_youtubeChat.Dispose();
_alertLayer.Dispose();
}
public void SaveLayoutNow()
@@ -520,6 +523,7 @@ public partial class MainViewModel : ViewModelBase
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(DeviceKeyForWebcam(webcam.WebcamId)),
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
Source { Type: SourceType.ChatBox } chatBox => RenderChatBox(chatBox),
Source { Type: SourceType.AlertBox } alertBox => RenderAlertBox(alertBox),
Source { Type: SourceType.WebSource } web => _webView2Manager?.GetLatestFrame(web.Id),
Source { IsMediaSource: true, MediaPath: not null } media => _mediaManager.GetLatestFrame(media.MediaPath),
Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId),
+2 -2
View File
@@ -8,7 +8,7 @@ areas' docs in their own rows keeps this index small to read.
| File | Purpose |
|------|---------|
| `MainViewModel.cs` | Core. `public partial class MainViewModel : ViewModelBase`. **Source commands + element rows:** scenes are pure selection rows; sources carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; Backdrop trash slot reserved via `HiddenBoolToVisibilityConverter` — TASK 13); shared `NextSourceName` (Image, Image2, Image3… no-space increment from actual names — used by `AddSource` + `AddReusedImage`), `AddImageCommand`. **Lifecycle/ctor wiring:** fields + ctor wiring (all command assignments, mixer/camera/chat/hotkey/subscription hookups), `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `CreateBrush` + shared `BarOnBrush`/`BarOffBrush` (referenced by Audio/Trax/Socials partials), window-geometry pass-through, `SelectedElement`/`OnSelectedElementPropertyChanged`, `ShowPreviewPlaceholder`, `BrandFlash*`, WebView2 hosting (`InitWebView2`/`RegisterLoadedWebSources`/`OnWebView2PreviewBitmapChanged`), frame-pump wiring (construction + `_rtmpUrlProvider` seam + `Dispose`, plus `ResolveOutputFrame` → `RenderChatBox` for chat layers) — the pump's stream-state consumers live in the Streaming partial. |
| `MainViewModel.cs` | Core. `public partial class MainViewModel : ViewModelBase`. **Source commands + element rows:** scenes are pure selection rows; sources carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; Backdrop trash slot reserved via `HiddenBoolToVisibilityConverter` — TASK 13); shared `NextSourceName` (Image, Image2, Image3… no-space increment from actual names — used by `AddSource` + `AddReusedImage`), `AddImageCommand`. **Lifecycle/ctor wiring:** fields + ctor wiring (all command assignments, mixer/camera/chat/hotkey/subscription hookups), `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `CreateBrush` + shared `BarOnBrush`/`BarOffBrush` (referenced by Audio/Trax/Socials partials), window-geometry pass-through, `SelectedElement`/`OnSelectedElementPropertyChanged`, `ShowPreviewPlaceholder`, `BrandFlash*`, WebView2 hosting (`InitWebView2`/`RegisterLoadedWebSources`/`OnWebView2PreviewBitmapChanged`), frame-pump wiring (construction + `_rtmpUrlProvider` seam + `Dispose`, plus `ResolveOutputFrame` → `RenderChatBox`/`RenderAlertBox` for chat + alert layers) — the pump's stream-state consumers live in the Streaming partial. |
| `MainViewModel.Account.cs` | Session + connected account: `LoadSavedSessionAsync` (DPAPI restore at startup), `SignInAsync`/`ChangeAccountAsync`/`SignOutYouTubeAsync`, `IsConnected`/`AccountAvatarUrl`/`AccountDisplayName`/`AccountStatusLightToolTip` via `SyncConnectedAccount` (cleared on End). |
| `MainViewModel.License.cs` | Polar license (freemium): `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`, `ValidateLicenseAtStartupAsync`/`ActivateLicenseAsync`. |
| `MainViewModel.Scenes.cs` | Five-scene catalog: `Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, `AddScene` (canonical names only), `MissingScenes`/`ShowAddScene`/`AddSceneCommand`; scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`; transitions `SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/`ApplyTransitionToLive`; global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/`RefreshHotkeyLabels`; scene-gating hints (`CanChangeBackground`). |
@@ -19,7 +19,7 @@ areas' docs in their own rows keeps this index small to read.
| `MainViewModel.Socials.cs` | Social bar (global resource, overlaid on every scene): `_socials`/`_socialBarFrame`/`_socialValidator` (`ISocialValidator`/`HttpSocialValidator`), `HasSocials`/`SocialBarVisible`/`SocialBarDotBrush`/`SocialBarGlowBrush`/`Socials`/`SocialBarTop`, `OpenSocialDialogCommand`/`OpenSocialDialog` (spawns `SocialsDialogViewModel` + window; saved copy replaces `_socials`), `NotifySocialsChanged`/`RenderSocialBarFrame` (`SocialBarRenderer` → immutable `_socialBarFrame` the frame pump reads), fediverse heal `HealFediverseSoftwareAsync` (static, test-facing) + `HealFediverseSoftwareInBackground`, bar edge toggle `SetSocialBarPosition`/`ToggleSocialBarPosition`. `BarOnBrush`/`BarOffBrush`/`CreateBrush` stay in core (shared tokens). |
| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** mode switch `RecordPillOn`/`OnAirPillOn`, top-bar worlds (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`/`ShowSignInButton`/`ShowAvatarButton`, reality line `RunningVisible`/`RunningText`/`RunningDotBrush`, Test gate `ShowTestButton`/`CanStartTest` — `RaiseTopBarModes()`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master. **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. |
| `MainViewModel.Overlays.cs` | Settings/bug/feature/about panels + creator-hub links: panel state `IsSettingsOpen`/`IsBugOpen`/`IsFeatureOpen`/`IsAboutOpen` (via `SetPanel` → `IsAnyOverlayOpen`), `IsLicensesOpen` (flips `IsHubVisible`), `OverlayTitle`, `ShowOverlay` (one-panel exclusivity) + `ShowLicenses` (loads shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` on first open, never the OS viewer); bug/feature report `BugReportText`/`BugReportEmail`/`FeatureRequestText`/`FeatureRequestEmail` + `SubmitBug`/`SubmitFeature`/`ComposeEmail`/`OpenUrl`; About hub consts `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public, integration-test live) + `PremiumUrl` (tabled billing seam — TASK 12 → greyed "Unlock Premium" lights up when filled) + `IsPremiumAvailable` + `SupportEmail` + `AppVersionLabel`; commands `OpenSettingsCommand`/`OpenBugCommand`/`OpenFeatureCommand`/`OpenAboutCommand`/`CloseOverlayCommand`/`SubmitBugCommand`/`SubmitFeatureCommand`/`OpenChannelCommand`/`OpenMastodonCommand`/`OpenCoffeeCommand`/`OpenPremiumCommand`/`OpenLicensesCommand`. Ctor command assignments stay in core. |
| `MainViewModel.Chat.cs` | Chat overlay + preview pipeline: `ChatMessages` (500-message cap; clear-on-fade), `OnChatMessageReceived` (marshal `_youtubeChat.MessageReceived` onto the dispatcher, switch previews mock→real), fade `_chatFadeTimer`/`ResetChatFadeTimer` (shortest `ChatFadeSeconds` across chat layers); live render `RenderChatBox` (called from `ResolveOutputFrame`, stays in core) + preview render `UpdateChatBoxPreview` (bitmap onto `chatBox.VideoImageSource`); mock preview `MockChatMessages`/`StartMockChatPreview`/`StopMockChatPreview`/`RunMockChatPreviewAsync`/`_mockChatCts`; `UpdateActiveChatBoxPreviews` (wires Chat* property-change subscriptions + first render); `CanAddYouTubeChat` (one-per-layout gate), `ShowChatInactiveMessage` (`!IsLive`). `_chatBoxRenderer` field. Ctor wiring (`MessageReceived` hookup, `new ChatBoxRenderer()`, `LoadLayout` preview start, `_youtubeChat.Dispose`) stays in core. |
| `MainViewModel.Chat.cs` | Chat overlay + preview pipeline: `ChatMessages` (500-message cap; clear-on-fade), `OnChatMessageReceived` (marshal `_youtubeChat.MessageReceived` onto the dispatcher, switch previews mock→real, then feed **both** component layers), fade `_chatFadeTimer`/`ResetChatFadeTimer` (shortest `ChatFadeSeconds` across chat layers); live render `RenderChatBox` (called from `ResolveOutputFrame`, stays in core) + preview render `UpdateChatBoxPreview` (bitmap onto `chatBox.VideoImageSource`); mock preview `MockChatMessages`/`StartMockChatPreview`/`StopMockChatPreview`/`RunMockChatPreviewAsync`/`_mockChatCts`; `UpdateActiveChatBoxPreviews` (wires Chat* property-change subscriptions + first render); `CanAddYouTubeChat` (one-per-layout gate), `ShowChatInactiveMessage` (`!IsLive`). **Alerts (TASK 43):** same facade for the native alert box — `_alertLayer` (`AlertOverlayLayer`), `RenderAlertBox`/`UpdateAlertBoxPreview`, `UpdateActiveAlertBoxPreviews`, `CanAddAlerts` (one alert zone per layout). Ctor wiring (`MessageReceived` hookup, `new ChatBoxRenderer()`, `new AlertRenderer()`, `LoadLayout` preview start, `_youtubeChat.Dispose` + `_alertLayer.Dispose`) stays in core. |
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests |
| **About hub (TASK 11, in `MainViewModel.Overlays.cs`)** | the About overlay is the in-app creator hub: `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public consts, live) + `PremiumUrl` (tabled billing seam — TASK 12 → the greyed "Unlock Premium" button lights up when filled); `OpenLicensesCommand` → `ShowLicenses()` loads the shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` and sets `IsLicensesOpen` (in-app scrolling panel, never the OS viewer); `BackToAbout_Click` in `MainWindow.xaml.cs` flips it off. |
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
+47 -11
View File
File diff suppressed because one or more lines are too long
+184
View File
@@ -0,0 +1,184 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// TASK 42 Good Dog: the native alerts unit (StreamElements removed — events are
/// now real YouTube chat events with native celebration animations). One [Fact],
/// two halves pinned together on the RealApp STA host so the WPF raster is real:
/// (1) ParsePage classifies all SIX chat event types + a plain text row (the former
/// "event rows render empty" gap) AND honors the streamList cadence fields; (2) the
/// AlertOverlayLayer plays them as PART OF ONE QUEUE of six UNIQUE animations
/// (pairwise-distinct hold frames), never plays a subscriber-mention (None rows
/// don't enqueue — a mention is a chat row, not an alert, per the creator ruling),
/// and drains back to the transparent/idle state.
/// </summary>
[Collection("RealApp")]
public sealed class AlertLayerTests
{
private readonly RealAppHost _app;
public AlertLayerTests(RealAppHost app) => _app = app;
private const string Payload = /*lang=json,strict*/ """
{
"nextPageToken": "Cg0KC0NOTIFTEST",
"pollingIntervalMillis": 1753,
"items": [
{ "id": "t1",
"snippet": { "type": "textMessageEvent", "textMessageDetails": { "messageText": "plain row" } },
"authorDetails": { "displayName": "Chatter" } },
{ "id": "sc1",
"snippet": { "type": "superChatEvent", "superChatDetails": { "displayString": "$10.00", "currency": "USD", "amountMicros": 10000000, "userComment": "keep the llama going" } },
"authorDetails": { "displayName": "Funder" } },
{ "id": "ss1",
"snippet": { "type": "superStickerEvent", "superStickerDetails": { "stickerDescription": "Good Vibes!!!", "displayString": "$2.00", "currency": "USD", "userComment": "" } },
"authorDetails": { "displayName": "Sticker Stan" } },
{ "id": "nm1",
"snippet": { "type": "newSponsorEvent", "membershipDetails": { "memberLevelName": "New member" } },
"authorDetails": { "displayName": "Joiner", "isChatSponsor": true, "badgeText": "New member" } },
{ "id": "mg1",
"snippet": { "type": "membershipGiftingEvent", "membershipGiftingDetails": { "giftNewMembersCount": 3 } },
"authorDetails": { "displayName": "Gifter" } },
{ "id": "gr1",
"snippet": { "type": "giftMembershipReceivedEvent", "giftMembershipReceivedDetails": { "gifterName": "Gifter" } },
"authorDetails": { "displayName": "Recipient" } },
{ "id": "mm1",
"snippet": { "type": "memberMilestoneChatEvent", "textMessageDetails": { "messageText": "I've been a member for 12 months!" } },
"authorDetails": { "displayName": "Veteran", "isChatSponsor": true, "badgeText": "Member" } }
]
}
""";
[Fact]
public void Alerts_ParseSixEventKinds_PlaySixUniqueAnimations_DrainIdle()
{
_app.Run(Run);
}
private void Run()
{
// ─── 1. Parse: all six event kinds classified, cadence honored ───
var page = YouTubeChatService.ParsePage(Payload);
Assert.Equal("Cg0KC0NOTIFTEST", page.NextPageToken);
Assert.Equal(1753, page.PollIntervalMs);
Assert.Equal(7, page.Messages.Count);
var byKind = page.Messages.ToDictionary(m => m.Kind);
Assert.Equal(ChatEventKind.None, byKind[ChatEventKind.None].Kind); // the plain chat row
Assert.Equal(ChatEventKind.SuperChat, byKind[ChatEventKind.SuperChat].Kind);
Assert.Equal(ChatEventKind.SuperSticker, byKind[ChatEventKind.SuperSticker].Kind);
Assert.Equal(ChatEventKind.NewMember, byKind[ChatEventKind.NewMember].Kind);
Assert.Equal(ChatEventKind.MemberGift, byKind[ChatEventKind.MemberGift].Kind);
Assert.Equal(ChatEventKind.GiftReceived, byKind[ChatEventKind.GiftReceived].Kind);
Assert.Equal(ChatEventKind.MemberMilestone, byKind[ChatEventKind.MemberMilestone].Kind);
var superChat = byKind[ChatEventKind.SuperChat];
Assert.Equal("$10.00", superChat.SuperChatDisplayString);
Assert.Equal(10.0, superChat.SuperChatAmount, 3);
Assert.Equal("keep the llama going", superChat.Message);
var sticker = byKind[ChatEventKind.SuperSticker];
Assert.Equal("Good Vibes!!!", sticker.SuperStickerDescription);
var member = byKind[ChatEventKind.NewMember];
Assert.True(member.IsMember);
var gift = byKind[ChatEventKind.MemberGift];
Assert.Equal(3, gift.GiftNewMembersCount);
var received = byKind[ChatEventKind.GiftReceived];
Assert.Equal("Gifter", received.GifterName);
var milestone = byKind[ChatEventKind.MemberMilestone];
Assert.Equal(12, milestone.MilestoneMonths);
// Rows that were EMPTY before this work now carry a real message.
Assert.Contains("3 memberships", gift.Message);
Assert.Contains("gifted you a membership", received.Message);
// ─── 2. Playback: queue, uniqueness, drain ───
var layer = new AlertOverlayLayer(new AlertRenderer());
var box = new Source { Type = SourceType.AlertBox, Name = "Stream Alerts", Width = 600, Height = 200 };
// A subscriber mention (None) is a chat row — no alert plays.
layer.Enqueue(byKind[ChatEventKind.None]);
Assert.False(layer.IsPlaying);
Assert.Null(layer.RenderFrame(box));
Assert.Null(layer.RenderFrame(box));
var eventTypes = new[]
{
ChatEventKind.SuperChat, ChatEventKind.SuperSticker, ChatEventKind.NewMember,
ChatEventKind.MemberGift, ChatEventKind.GiftReceived, ChatEventKind.MemberMilestone,
};
var holdFrames = new Dictionary<ChatEventKind, VideoFrame>();
foreach (var kind in eventTypes)
{
layer.Enqueue(byKind[kind]);
Assert.True(layer.IsPlaying, $"{kind} should be playing after enqueue");
var samples = new List<VideoFrame>();
for (var step = 0; step < 26; step++) // 12.5s covers the longest 5.0s animation
{
var frame = layer.RenderFrame(box);
if (frame != null)
{
Assert.Equal(600, frame.Width);
Assert.Equal(200, frame.Height);
samples.Add(frame);
}
layer.Advance(0.5);
}
Assert.True(samples.Count >= 3, $"{kind}: expected several non-null frames, got {samples.Count}");
Assert.True(samples.Any(HasPixels), $"{kind}: a hold frame must not be blank");
Assert.True(AnyPairDiffer(samples), $"{kind}: the animation must move, not hold one static frame");
Assert.False(layer.IsPlaying, $"{kind}: playback must drain once the animation completes");
Assert.Null(layer.RenderFrame(box));
holdFrames[kind] = samples[samples.Count / 2];
}
// SIX UNIQUE animations — every event's mid-hold frame differs from every other.
foreach (var a in eventTypes)
foreach (var b in eventTypes)
{
if (a == b) continue;
Assert.True(Hash(holdFrames[a].BgraPixels) != Hash(holdFrames[b].BgraPixels),
$"{a} and {b} must have distinct animations");
}
}
private static bool HasPixels(VideoFrame frame)
=> frame.BgraPixels.Any(b => b != 0);
private static bool AnyPairDiffer(List<VideoFrame> frames)
{
for (var i = 0; i < frames.Count; i++)
for (var j = i + 1; j < frames.Count; j++)
{
var f = frames[i].BgraPixels;
var g = frames[j].BgraPixels;
if (f.Length != g.Length || !f.SequenceEqual(g)) return true;
}
return false;
}
private static long Hash(byte[] bytes)
{
var hash = 1469598103934665603L;
foreach (var b in bytes)
{
hash ^= b;
hash *= 1099511628211L;
}
return hash;
}
}