From 41b0cd6e98aa042797154ca49d92bf20ce331fe7 Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 30 Oct 2026 14:38:59 -0700 Subject: [PATCH] =?UTF-8?q?feat(scoreboard):=20unit=205=20=E2=80=94=20tile?= =?UTF-8?q?=20selection=20and=20the=20cached=20strip=20presenter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScoreboardTiles decides which of the eight facts appear and shapes their text (money uses the session's dominant currency and flags a second one rather than converting; watch hours carry 'as of {date}' because Analytics is a daily aggregate). Tiles are OPT-IN, so 'None' really means the layer draws nothing, and 'Default' is the six session facts. ScoreboardPresenter owns the raster cache: ScoreboardRecorder.Changed fires only on a visible change, so the strip rebuilds only then — a per-tick raster would be the compositor's most expensive layer for identical pixels. Guard: ScoreboardPresenterTests asserts the cache identity (same instance when nothing moved, a new frame when a value moved, reuse when a recipient-side gift event arrives) and that an empty selection returns no frame at all. --- Models/ScoreboardTiles.cs | 87 ++++++++++++++++++++++ Services/Compositor/ScoreboardPresenter.cs | 60 +++++++++++++++ ytLive.Tests/ScoreboardPresenterTests.cs | 65 ++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 Models/ScoreboardTiles.cs create mode 100644 Services/Compositor/ScoreboardPresenter.cs create mode 100644 ytLive.Tests/ScoreboardPresenterTests.cs diff --git a/Models/ScoreboardTiles.cs b/Models/ScoreboardTiles.cs new file mode 100644 index 0000000..9b1728f --- /dev/null +++ b/Models/ScoreboardTiles.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace ytLive.Models; + +/// Which of the eight facts the creator put on screen, and how each renders. +/// +/// Every tile is optional because the strip is an ego wall — the creator chooses the +/// billboard. Goal tiles carry their targets here so the rendering stays a pure function of the +/// state plus this selection. +public sealed class ScoreboardTiles +{ + /// Nothing enabled — the layer draws nothing. + public static ScoreboardTiles None => new(); + + /// The selection a new layer starts with: the six session facts, no channel polls + /// (those need the Analytics scope). + public static ScoreboardTiles Default => new() + { + SuperChats = true, NewMembers = true, Milestones = true, + LastMember = true, LastSuperChat = true, LastGift = true, + }; + + public bool Subscribers { get; set; } + public bool WatchHours { get; set; } + public bool SuperChats { get; set; } + public bool NewMembers { get; set; } + public bool Milestones { get; set; } + public bool LastMember { get; set; } + public bool LastSuperChat { get; set; } + public bool LastGift { get; set; } + + /// Goal targets for the two polled tiles; 0 means "no goal, show the raw number". + public int SubscriberGoal { get; set; } + public int WatchHourGoal { get; set; } + + /// Channel state fed by the polled side (subscribers, watch hours). Kept here so the + /// strip is built from ONE source of truth: tallies + channel values + this selection. + public int SubscriberCount { get; set; } + public int WatchHoursValue { get; set; } + + /// Cache key: the selection alone. Values are folded in by the presenter's fingerprint, + /// so this only needs to change when the ENABLED SET changes. + public string CacheKey => + $"{Subscribers}{WatchHours}{SuperChats}{NewMembers}{Milestones}{LastMember}{LastSuperChat}{LastGift}" + + $"{SubscriberGoal}{WatchHourGoal}"; + + /// The strip's text, in reading order. Empty strings are dropped by the renderer. + public IReadOnlyList Build(ScoreboardState state) + { + var tiles = new List(8); + + if (Subscribers) + tiles.Add(SubscriberGoal > 0 + ? $"Subs {SubscriberCount:N0}/{SubscriberGoal:N0}" + : $"Subs {SubscriberCount:N0}"); + + if (WatchHours) + // Analytics is a DAILY aggregate, so the tile must not imply live data. + tiles.Add(WatchHourGoal > 0 + ? $"Hours {WatchHoursValue:N0}/{WatchHourGoal:N0} (as of {DateTime.Now:MMM d})" + : $"Hours {WatchHoursValue:N0} (as of {DateTime.Now:MMM d})"); + + if (SuperChats && state.SessionTotal.Total > 0) + tiles.Add(state.HasMixedCurrencies + ? $"SC {state.SessionTotal.Currency} {state.SessionTotal.Total:0.00} +{state.MoneyByCurrency.Count - 1}" + : $"SC {state.SessionTotal.Currency} {state.SessionTotal.Total:0.00}"); + + if (NewMembers && state.NewMembers > 0) + tiles.Add($"Members +{state.NewMembers}"); + + if (Milestones && state.MilestoneCount > 0) + tiles.Add($"Milestones {state.MilestoneCount}"); + + if (LastMember && !string.IsNullOrWhiteSpace(state.LastMemberName)) + tiles.Add($"Last member {state.LastMemberName}"); + + if (LastSuperChat && !string.IsNullOrWhiteSpace(state.LastSuperChatDisplay)) + tiles.Add($"Last SC {state.LastSuperChatDisplay}"); + + if (LastGift && !string.IsNullOrWhiteSpace(state.LastGifterName)) + tiles.Add($"Last gift {state.LastGifterName} ×{Math.Max(1, state.LastGiftCount)}"); + + return tiles; + } +} diff --git a/Services/Compositor/ScoreboardPresenter.cs b/Services/Compositor/ScoreboardPresenter.cs new file mode 100644 index 0000000..29c64e3 --- /dev/null +++ b/Services/Compositor/ScoreboardPresenter.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using ytLive.Models; + +namespace ytLive.Services.Compositor; + +/// Turns the session's tallies into the strip frame, and keeps the raster until a displayed +/// value actually moves. +/// +/// The cache is the point: a strip re-rasterised every tick would be the most expensive layer +/// the compositor carries, for pixels that did not change. `ScoreboardRecorder.Changed` only fires +/// on a visible change, so the presenter rebuilds only then. +public sealed class ScoreboardPresenter +{ + private readonly ScoreboardRecorder _recorder; + private VideoFrame? _frame; + private string? _tilesFingerprint; + private ScoreboardStyle _style; + private ScoreboardTiles _tiles = ScoreboardTiles.None; + + public ScoreboardPresenter(ScoreboardRecorder recorder, ScoreboardStyle style) + { + _recorder = recorder; + _style = style; + _recorder.Changed += Invalidate; + } + + /// The strip frame for the current tallies, or null when nothing is enabled/shown. + public VideoFrame? Frame + { + get + { + var tiles = _tiles.Build(_recorder.State); + var fingerprint = $"{_tiles.CacheKey}|{string.Join('|', tiles)}|{_style}"; + if (_frame != null && fingerprint == _tilesFingerprint) return _frame; + + _tilesFingerprint = fingerprint; + _frame = ScoreboardRenderer.Render(tiles, _style); + return _frame; + } + } + + public void SetTiles(ScoreboardTiles tiles) + { + _tiles = tiles; + Invalidate(); + } + + public void SetStyle(ScoreboardStyle style) + { + _style = style; + Invalidate(); + } + + /// Drops the cached raster so the next read rebuilds it. + public void Invalidate() + { + _frame = null; + _tilesFingerprint = null; + } +} diff --git a/ytLive.Tests/ScoreboardPresenterTests.cs b/ytLive.Tests/ScoreboardPresenterTests.cs new file mode 100644 index 0000000..617fbdb --- /dev/null +++ b/ytLive.Tests/ScoreboardPresenterTests.cs @@ -0,0 +1,65 @@ +using System.Threading; +using Xunit; +using ytLive.Models; +using ytLive.Services.Compositor; + +namespace ytLive.Tests; + +/// The strip's cache is a performance rule with teeth: it must rebuild when a displayed +/// value moves and must NOT rebuild otherwise, or the compositor pays for a raster per tick into +/// pixels that did not change. +public class ScoreboardPresenterTests +{ + private static ChatMessage Member(string name) => new() { Kind = ChatEventKind.NewMember, AuthorName = name }; + + [Fact] + public void The_strip_rebuilds_only_when_a_displayed_value_moves() + { + System.Exception? failure = null; + var thread = new Thread(() => + { + try + { + var recorder = new ScoreboardRecorder(); + var tiles = new ScoreboardTiles + { + SuperChats = false, WatchHours = false, Subscribers = false, + NewMembers = true, Milestones = false, LastMember = true, + LastSuperChat = false, LastGift = false, + }; + var presenter = new ScoreboardPresenter(recorder, new ScoreboardStyle()); + presenter.SetTiles(tiles); + + // Before a session: nothing counted, nothing to draw. + Assert.Null(presenter.Frame); + + recorder.StartSession(); + recorder.Record(Member("First")); + var first = presenter.Frame; + Assert.NotNull(first); + + // No change: the SAME frame instance comes back (cache hit). + Assert.Same(first, presenter.Frame); + + // A visible change: a new frame. + recorder.Record(Member("Second")); + var second = presenter.Frame; + Assert.NotNull(second); + Assert.NotSame(first, second); + + // A gift-recipient event changes nothing displayed, so the raster is reused. + recorder.Record(new ChatMessage { Kind = ChatEventKind.GiftReceived, AuthorName = "Recipient" }); + Assert.Same(second, presenter.Frame); + + // Disabling every tile returns no frame, and the layer draws nothing. + presenter.SetTiles(ScoreboardTiles.None); + Assert.Null(presenter.Frame); + } + catch (System.Exception ex) { failure = ex; } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + Assert.Null(failure); + } +}