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:
@@ -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 & 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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user