diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml index d1fdba2..d0a395d 100644 --- a/Controls/LeftPanel.xaml +++ b/Controls/LeftPanel.xaml @@ -41,6 +41,9 @@ + The designated alert zone: the six monetization events play their + /// unique celebration animations here (OBS alert-box equivalent, native). + AlertBox } /// Distinguishes how a media source presents its content (TASK 21). @@ -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; /// Live captures, chat, web sources and media are dynamic; static art is fixed. 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; diff --git a/Models/YouTube.cs b/Models/YouTube.cs index 587f505..da7c6ea 100644 --- a/Models/YouTube.cs +++ b/Models/YouTube.cs @@ -18,6 +18,21 @@ public record ReusableStream(string Id, string IngestionAddress, string StreamNa public string RtmpUrl => $"{IngestionAddress}/{StreamName}"; } +/// 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. None = a normal +/// chat row: it never enqueues an alert (subscriber mentions stay chat rows). +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; } + + /// YouTube's own pre-rendered amount ("$12.34") — preferred over + /// recomposing amount+currency ourselves, which mangles non-dollar locales. + 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; } + + /// membershipGiftingEvent: how many memberships were bought. + public int GiftNewMembersCount { get; set; } + + /// giftMembershipReceivedEvent: who gifted this one. + public string? GifterName { get; set; } + + /// memberMilestoneChatEvent: streak months (0 = YouTube didn't + /// expose a count — the animation falls back to the message text). + public int MilestoneMonths { get; set; } + /// 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. diff --git a/Models/index.md b/Models/index.md index 5d74324..d303e19 100644 --- a/Models/index.md +++ b/Models/index.md @@ -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:`/`window:`/`picker:`), `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:`/`window:`/`picker:`), `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; diff --git a/Services/AlertOverlayLayer.cs b/Services/AlertOverlayLayer.cs new file mode 100644 index 0000000..7fac2ba --- /dev/null +++ b/Services/AlertOverlayLayer.cs @@ -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; + +/// +/// The native alert box (OBS-style celebration zone, no third party). Owns the +/// event queue and the animation clock: events from +/// the chat feed are enqueued one-at-a-time and played through their own unique +/// animation (), frames served to the stream compositor +/// via and mirrored to the preview via +/// . Idle = transparent (null frame); playback is +/// advanced by a 33ms dispatch ticker. The clock is exposed as +/// so tests drive playback deterministically without the +/// timer. Ordinary chat rows () never enqueue — +/// a subscriber mention is a chat row, not an alert. +/// +public sealed class AlertOverlayLayer : IDisposable +{ + private readonly AlertRenderer _renderer; + private readonly Queue _queue = new(); + private readonly DispatcherTimer? _ticker; + + private ChatMessage? _current; + private double _elapsed; + private IEnumerable? _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(); + }; + } + } + + /// 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. + public void OnMessageReceived(ChatMessage message, IEnumerable scenes) + { + _scenes = scenes; + Enqueue(message); + _ticker?.Start(); + RefreshAlertPreviews(); + } + + /// Queue an event (pure — also the test seam). Chat rows carry + /// and are ignored: no alert for a mention. + 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++; + } + + /// 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. + 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++; + } + + /// Whether an alert is currently playing or queued. + public bool IsPlaying => _current != null || _queue.Count > 0; + + /// 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. + 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, + }; + + /// 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. + 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}"); + } + } + + /// Wire property-change subscriptions for all AlertBox sources across + /// every scene (geometry changes re-render), and clear/reset their previews. + public void UpdateConfiguredBoxes(IEnumerable scenes) + { + _scenes = scenes; + foreach (var scene in scenes) + foreach (var alertBox in scene.Elements.OfType().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() + .Where(s => s.Type == SourceType.AlertBox)) + UpdatePreview(alertBox); + } + + public void Dispose() + { + _ticker?.Stop(); + _queue.Clear(); + _current = null; + } +} \ No newline at end of file diff --git a/Services/AlertRenderer.cs b/Services/AlertRenderer.cs new file mode 100644 index 0000000..95a0279 --- /dev/null +++ b/Services/AlertRenderer.cs @@ -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; + +/// +/// 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. +/// +/// walks 0→1 over the whole animation; each kind splits it +/// into enter / hold / exit phases with its own easing. +/// +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); + } +} \ No newline at end of file diff --git a/Services/TestSessionViewModel.cs b/Services/TestSessionViewModel.cs index 565f753..ad7a595 100644 --- a/Services/TestSessionViewModel.cs +++ b/Services/TestSessionViewModel.cs @@ -184,7 +184,9 @@ public sealed class TestSessionViewModel : ViewModelBase /// 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. + /// shows it as a regular row, which is exactly what a sub looks like in chat. + /// It carries on purpose: no alert animation + /// fires for free subs — the chat mention is the announcement (creator ruling). 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; } diff --git a/Services/YouTubeChatService.cs b/Services/YouTubeChatService.cs index 9411f6a..844d597 100644 --- a/Services/YouTubeChatService.cs +++ b/Services/YouTubeChatService.cs @@ -6,16 +6,22 @@ namespace ytLive.Services; /// /// 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 pollingIntervalMillis (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 ). /// 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? 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(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); + } + } + + /// 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. + internal static ChatPage ParsePage(string json) + { + var data = JsonSerializer.Deserialize(json); + var messages = new List(); + + 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(); } } + +/// Decoded liveChat/messages response: the messages + where to resume + +/// how long to wait before reconnecting. +internal sealed record ChatPage(List Messages, string? NextPageToken, int PollIntervalMs); diff --git a/Services/index.md b/Services/index.md index ed1114c..0f2d2de 100644 --- a/Services/index.md +++ b/Services/index.md @@ -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 | diff --git a/TASKS.md b/TASKS.md index 722b32d..2b4ce19 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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. --- diff --git a/TASKS/task-03-capture-pipeline.md b/TASKS/task-03-capture-pipeline.md index 1c69cff..3f67751 100644 --- a/TASKS/task-03-capture-pipeline.md +++ b/TASKS/task-03-capture-pipeline.md @@ -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. diff --git a/TASKS/task-43-native-alerts.md b/TASKS/task-43-native-alerts.md new file mode 100644 index 0000000..460f160 --- /dev/null +++ b/TASKS/task-43-native-alerts.md @@ -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. \ No newline at end of file diff --git a/ViewModels/MainViewModel.Chat.cs b/ViewModels/MainViewModel.Chat.cs index e4cbe9b..6ba6461 100644 --- a/ViewModels/MainViewModel.Chat.cs +++ b/ViewModels/MainViewModel.Chat.cs @@ -10,6 +10,7 @@ public partial class MainViewModel : ViewModelBase { private readonly ChatOverlayLayer _chatLayer; + private readonly AlertOverlayLayer _alertLayer; /// The live chat message buffer — the same collection the /// 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().Any(e => e.Type == SourceType.ChatBox)); + /// The Add → Stream Alerts menu item: one alert zone per layout + /// (multiple zones would double-play every event). + public bool CanAddAlerts + => StagedScene != null + && !Scenes.Any(s => s.Elements.OfType().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? 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); } diff --git a/ViewModels/MainViewModel.Sources.cs b/ViewModels/MainViewModel.Sources.cs index 286b73b..58b1b0c 100644 --- a/ViewModels/MainViewModel.Sources.cs +++ b/ViewModels/MainViewModel.Sources.cs @@ -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); diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index b63f2df..0cee484 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -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), diff --git a/ViewModels/index.md b/ViewModels/index.md index de71e3c..141bfbf 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -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 | diff --git a/ai.md b/ai.md index d4f999b..8f87960 100644 --- a/ai.md +++ b/ai.md @@ -178,8 +178,8 @@ C# / WPF (.NET 8) following MVVM: | Path | Role | |------|------| | `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage, **Socials (`SocialService` enum + `SocialEntry`/`SocialsConfig` + `SocialServiceIcons`) — the social bar** | -| `ViewModels/` | MainViewModel — `public partial class`, one file per functional area (Scenes, Background, Webcam, Audio, Trax, Socials, Streaming, Chat, Overlays, Account, License, Recording — split complete, see `ViewModels/index.md`); **Chat.cs is a thin delegating facade over `Services/ChatOverlayLayer.cs` (Commit G, first true decomposition)**; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** | -| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **media source: `IMediaFrameSource` + `MediaVideoSource` (spawns ffmpeg `rawvideo` BGRA decode) + `MediaVideoSourceManager` (refcount-by-path session owner) + pure `RawVideoFrameReader` + `IDecodeProcess`/`FfmpegDecodeProcess` process seam (binary-stdout mirror of `IEncoderProcess`; see "Media source")**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler`/`AudioSyncDelay` + `MusicPlayer` + `IAudioPipeWriter`/`NamedPipeAudioWriter` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "Live frame pipeline")**, **notifications: `INotificationService` seam (`AppNotificationSeverity` Info/Success/Warning/Error) + `NotificationService` (Notification.Wpf toasts, see "Toast notifications")** | +| `ViewModels/` | MainViewModel — `public partial class`, one file per functional area (Scenes, Background, Webcam, Audio, Trax, Socials, Streaming, Chat, Overlays, Account, License, Recording — split complete, see `ViewModels/index.md`); **Chat.cs is a thin delegating facade over `Services/ChatOverlayLayer.cs` (Commit G, first true decomposition), and now also over `Services/AlertOverlayLayer.cs` (TASK 43 — the native alert box, same true-component pattern)**; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** | +| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **media source: `IMediaFrameSource` + `MediaVideoSource` (spawns ffmpeg `rawvideo` BGRA decode) + `MediaVideoSourceManager` (refcount-by-path session owner) + pure `RawVideoFrameReader` + `IDecodeProcess`/`FfmpegDecodeProcess` process seam (binary-stdout mirror of `IEncoderProcess`; see "Media source")**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **alerts: `ChatOverlayLayer` + `AlertOverlayLayer`/`AlertRenderer` (the native celebration-zone overlay, TASK 43 — see "Native events & alerts")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler`/`AudioSyncDelay` + `MusicPlayer` + `IAudioPipeWriter`/`NamedPipeAudioWriter` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "Live frame pipeline")**, **notifications: `INotificationService` seam (`AppNotificationSeverity` Info/Success/Warning/Error) + `NotificationService` (Notification.Wpf toasts, see "Toast notifications")** | | `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, **BuildStamp (wordmark release counter `#N`, +1 per commit, generated by `GenerateBuildStamp` in ytLive.csproj via git rev-list; the per-build GUID now logs to startup.log only — 2026-09-04)**, visibility converters | | `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) | | `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview + live controls below), left (scenes/sources), right (chat), bottom (gear + stream stats + resolution) | @@ -190,7 +190,7 @@ C# / WPF (.NET 8) following MVVM: - `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows) - **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the second line of the preview bottom row, BELOW the Socials+TRAX row, CENTERED beneath the preview panel. The TRAX button + Desktop Audio meter sit on the first line of that same row. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)` — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS display scale, because real speech sits around −40..−20 dBFS (0.01..0.1 linear) which would leave a flat scale dead (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). **Mic mute icon (2026-08-13):** a second 16px clickable glyph — a microphone, red + slash when muted — sits **between the meter and the speaker** (both mutes adjacent, spacing between the icons) and reuses the same `MicSpeaker_MouseLeftButtonUp` → `ToggleMicMuteCommand` handler. **REC / ON-AIR pills + signs (TASK 18, 2026-08-29):** the top-center area is now two **sliding pill toggles** (intent) next to two **status signs** (reality). REC pill (`RecordPillOn`) is the local-recording intent — works signed-out; ON-AIR pill (`OnAirPillOn`) is the streaming intent — greyed/disabled until `IsConnected`. The **REC sign** (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`) is dark-gray + dim offline and **green-tinged, pulsing only while actually recording** (`IsRecording`), not merely pill-on. The **ON-AIR sign** (`OnAirBrush` = `#555` offline, `#22c55e` live) is green while actually streaming (`IsLive`); the `PRIVATE` badge (`IsLivePrivate`) still shows when the broadcast is private. Both sign labels share the `StatusSignText` style (`Themes/Controls.xaml`) so REC and ON-AIR can't drift apart — status color lives on the dot only, never the label. The elapsed timer shows while `IsLive || IsRecording`. The connected account's avatar/name shows in the top bar next to the primary button (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live. - ViewModels are constructed in XAML (`` as DataContext) -- **True decomposition beats partial-shuffling (2026-08-31, Commit G):** `MainViewModel` is a ~4100-line god-object split into 17 partials — but partials are a *myth of decomposition*: every partial shares the same class, same `SetProperty` state, same collaborators via `Scenes`/`StagedScene`/`IsLive`/`Source.VideoImageSource`, so an AI fetching one partial still reconstructs the whole class. The only thing that actually improves retrieval is a boundary where a feature **owns its own state + collaborators**. `Services/ChatOverlayLayer.cs` is the model (extracted from `MainViewModel.Chat.cs` 194→44): the layer owns the message buffer, `ChatBoxRenderer`, fade/mock timers, preview renders, and the live `RenderFrame`; the VM keeps only the binding surface (`ChatMessages` delegates to `_chatLayer.Messages` so XAML + LeftPanel `CollectionChanged` hold; the two computed gate props stay on the VM because their `OnPropertyChanged` is raised from VM setters). **Diagnostic — glue vs. component:** before extracting, count `OnPropertyChanged`/`SetProperty` touches (glue) and bound-property reads per partial. Zero/low-glue + a cohesive state blob (renderer/timers/buffer) = extract (chat). High glue over an already-extracted service (Audio, Webcam, Background, Scenes — `AudioMixer`/`CameraManager`/`ScreenCaptureManager`/`ChatBoxRenderer` already exist) = leave as VM glue; forcing it adds coupling, doesn't remove it. Don't manufacture seams to hit a line count — the line count is a guideline for context, not a design goal. +- **True decomposition beats partial-shuffling (2026-08-31, Commit G):** `MainViewModel` is a ~4100-line god-object split into 17 partials — but partials are a *myth of decomposition*: every partial shares the same class, same `SetProperty` state, same collaborators via `Scenes`/`StagedScene`/`IsLive`/`Source.VideoImageSource`, so an AI fetching one partial still reconstructs the whole class. The only thing that actually improves retrieval is a boundary where a feature **owns its own state + collaborators**. `Services/ChatOverlayLayer.cs` is the model (extracted from `MainViewModel.Chat.cs` 194→44): the layer owns the message buffer, `ChatBoxRenderer`, fade/mock timers, preview renders, and the live `RenderFrame`; the VM keeps only the binding surface (`ChatMessages` delegates to `_chatLayer.Messages` so XAML + LeftPanel `CollectionChanged` hold; the two computed gate props stay on the VM because their `OnPropertyChanged` is raised from VM setters). **`Services/AlertOverlayLayer.cs` (TASK 43, 2026-09-24) is a second true component built fresh on the same contract** — it owns the event queue, the 33ms playback ticker, the per-source preview renders and the live `RenderFrame`, with `Advance(double)` exposed as the deterministic test clock; the VM again keeps only the facade (`RenderAlertBox`, `UpdateAlertBoxPreview`, `CanAddAlerts`). Two COMPONENT layers, one facade partial. **Diagnostic — glue vs. component:** before extracting, count `OnPropertyChanged`/`SetProperty` touches (glue) and bound-property reads per partial. Zero/low-glue + a cohesive state blob (renderer/timers/buffer) = extract (chat). High glue over an already-extracted service (Audio, Webcam, Background, Scenes — `AudioMixer`/`CameraManager`/`ScreenCaptureManager`/`ChatBoxRenderer` already exist) = leave as VM glue; forcing it adds coupling, doesn't remove it. Don't manufacture seams to hit a line count — the line count is a guideline for context, not a design goal. - **SceneGraph component + baked-crust compositor (TASK 31):** `Services/SceneGraph.cs` owns the scene collection (`Scenes` — the ViewModel's `Scenes` property delegates to it) and the element mutation surface (`AddElement`/`InsertElement`/`RemoveElement`/`MoveElement`, each invalidating the bake cache) plus queries that were scattered LINQ (`GetBackground`/`GetWebcam`/`GetChatBoxes`/`GetSplitPoint`/`IsStatic`). Elements expose `ElementKind Kind` (`Static` = images/background art, `Dynamic` = webcam/live capture/chat/web). The **split point** is the index of the first dynamic element; `SceneGraph.GetBakedBase` bakes/caches all static layers below it (keyed by scene id + static element identities), and `SceneCompositor.BakeStaticBase`/`CompositeLayers`/the `Render(.., staticBase, split)` overload composite the dynamic/above-split layers per frame. `FramePump.RenderScene` uses the optimized path when a `SceneGraph` is wired in (falls back to full render without one). Invariant: dynamic-only pixel changes never invalidate; static layout/opacity/visibility/asset changes do (via `InvalidateBake` from the VM's element-property and background-heal paths). **Defensive deviation from the TASK 31 spec:** `ChatOverlayLayer` keeps taking `IEnumerable` instead of depending on `GetChatBoxes()` — it is deliberately decoupled from the graph (its doc comment says "without owning the scene graph"); and the static background helpers (`EnsureBackground`/`NormalizeBackgrounds`) stay on the ViewModel because `BackgroundTests.cs` unit-tests `MainViewModel.EnsureBackground` directly. Queries + invalidation moved; helpers stayed. - Services are currently instantiated in MainViewModel's constructor — no DI container yet - Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available). Loaded sources always derive `IsBackground` from `Type` (OR'd with the persisted column, so legacy DisplayCapture backdrops keep their flag) — pre-derivation rows with `IsBackground=0` heal on load @@ -1437,13 +1437,17 @@ design, for **every** creator, free and ungated (the only subscriber swap remain removal above). It has three layers, all free — this is the product's differentiator, not a paid tier: - **Reward-event capture (the data foundation).** The live-chat poll (`YouTubeChatService.Poll`) - already receives every reward event over `liveChatMessages` but today only decodes two - (`superChatEvent`, `newSponsorEvent`); the other five — `superStickerEvent`, `membershipGiftingEvent`, - `giftMembershipReceivedEvent`, `memberMilestoneChatEvent`, `giftEvent` — are silently dropped. The - plan: decode all seven into a canonical `RewardEvent` and persist each to a SQLite table - (`RewardEvents`: broadcastId, type, timestamp, amountMicros, currency, tier, memberLevelName, - gifter/recipient channelIds). `superChatEvents.list` (30-day lookback) backfills prior broadcasts. - This single feed is the foundation that Alerts, the session report, and the journey tracker all read. + receives every reward event over `liveChat/messages` and — since TASK 43 (2026-09-24) — + **decodes all six types**, not two: `superChatEvent`, `superStickerEvent`, `newSponsorEvent`, + `membershipGiftingEvent`, `giftMembershipReceivedEvent`, `memberMilestoneChatEvent` land as + `ChatMessage.Kind` (`ChatEventKind`) with their payload fields (display string, sticker + description, gift count, gifter name, milestone months); free subscriptions emit NO event + (API fact — a sub mention is a plain text row). The remaining half of the original capture + plan stays open (TASK 3 item 20): persist each event to the canonical SQLite `RewardEvents` + table (broadcastId, type, timestamp, amountMicros, currency, tier...) + `superChatEvents.list` + (30-day lookback) backfill — that persistence is what the session report and the journey + tracker read. Alerts did not wait on it: the shipped alert path (TASK 43) renders directly + from the in-memory `ChatMessage` feed. - **Session / broadcast report.** A session is created at Go Live; the poll + End roll that session's reward totals (new members, Super Chat $, stickers $, gifts, milestones). This is the in-app replacement for the emailed external "stream activity report" — the incumbents (Streamlabs et al.) @@ -1563,4 +1567,36 @@ These are the hard facts behind every decision. Full list in `TASKS.md`. (researched + hard constraint): `liveChat/messages.insert` only creates `textMessageEvent`s — SuperChats / memberships / sponsorships cannot be inserted via any API**; simulated non-text events are local-only by design and never reach YouTube. Scopes `youtube` + `youtube.force-ssl` - already cover insert. Real stinger/TTS alert widgets remain TASK 3 items 16/17/20. + already cover insert. **Real stinger/TTS alert widgets are DONE (TASK 43, shipped + 2026-09-24) as the native alert box** — see the next section; Text source (TASK 3 item 16) + and the RewardEvent persistence (item 20) remain open. + +### Native events & alerts (TASK 43, shipped 2026-09-24 — StreamElements replaced) + +The creator asked to replace the external StreamElements feed with native YouTube events; +the answer is **native events + a native alert box**, and the product decisions are +published decisions (recorded in `TASKS/task-43-native-alerts.md`): + +- **No third party.** The chat feed IS the event feed: `liveChat/messages` carries all six + monetization events (superChat / superSticker / newSponsor / membershipGifting / + giftMembershipReceived / memberMilestoneChat). `streamList` is the *connection semantics* + of that same endpoint (pollingIntervalMillis-driven), not a separate call — the service + re-arms its one-shot poll on the server's cadence (clamp 1000–6000ms), `maxResults=2000`. + Cite: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList +- **`SourceType.AlertBox` — one "Stream Alerts" celebration zone per layout**, OBS + alert-box style: a designated draggable canvas area, idle = transparent. Mirrors the chat + gate (`CanAddAlerts`; `AddSource` refuses a second). Defaults 620×430 → 680×200. +- **Six events → six unique animations** (`Services/AlertRenderer.cs`): SuperChat slide-up + + gold count-up + shine sweep (green), SuperSticker scale-pop on a tilted chip (purple), + NewMember drop-in + flash band (violet), MemberGift slide-left + gift-chip `×N` fan + (amber), GiftReceived confetti scale-in (green→purple), MemberMilestone rise + tenure + growth bar (orange). Phases enter/hold/exit (~4–5s); every card draws the **brand line + "made with LlamaCasty!"** — each broadcasted event is free product placement. +- **`Services/AlertOverlayLayer.cs`** — the component: enqueues events one-at-a-time, + advances on a 33ms dispatch ticker, serves the live frame through `RenderFrame` (same + cache-first pattern as chat) and the preview through `UpdatePreview`; **`Advance(double)` + is the deterministic test clock** (tests drive playback with no timer). Queue cap 10 + (floods drop the tail, never stall). `ChatEventKind.None` rows (plain chat, sub mentions) + **never enqueue** — per the creator ruling, a subscriber mention is sufficient; there is + no free-sub alert (YouTube emits none) and no viewer count (vetoed — demotivating, + weaponizable). diff --git a/ytLive.Tests/AlertLayerTests.cs b/ytLive.Tests/AlertLayerTests.cs new file mode 100644 index 0000000..be641bc --- /dev/null +++ b/ytLive.Tests/AlertLayerTests.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; +using ytLive.Models; +using ytLive.Services; + +namespace ytLive.Tests; + +/// +/// 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. +/// +[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(); + + foreach (var kind in eventTypes) + { + layer.Enqueue(byKind[kind]); + Assert.True(layer.IsPlaying, $"{kind} should be playing after enqueue"); + + var samples = new List(); + 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 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; + } +} \ No newline at end of file