feat(scoreboard): unit 5 — tile selection and the cached strip presenter
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.
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>Which of the eight facts the creator put on screen, and how each renders.
|
||||||
|
///
|
||||||
|
/// <para>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.</para></summary>
|
||||||
|
public sealed class ScoreboardTiles
|
||||||
|
{
|
||||||
|
/// <summary>Nothing enabled — the layer draws nothing.</summary>
|
||||||
|
public static ScoreboardTiles None => new();
|
||||||
|
|
||||||
|
/// <summary>The selection a new layer starts with: the six session facts, no channel polls
|
||||||
|
/// (those need the Analytics scope).</summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>Goal targets for the two polled tiles; 0 means "no goal, show the raw number".</summary>
|
||||||
|
public int SubscriberGoal { get; set; }
|
||||||
|
public int WatchHourGoal { get; set; }
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public int SubscriberCount { get; set; }
|
||||||
|
public int WatchHoursValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public string CacheKey =>
|
||||||
|
$"{Subscribers}{WatchHours}{SuperChats}{NewMembers}{Milestones}{LastMember}{LastSuperChat}{LastGift}" +
|
||||||
|
$"{SubscriberGoal}{WatchHourGoal}";
|
||||||
|
|
||||||
|
/// <summary>The strip's text, in reading order. Empty strings are dropped by the renderer.</summary>
|
||||||
|
public IReadOnlyList<string> Build(ScoreboardState state)
|
||||||
|
{
|
||||||
|
var tiles = new List<string>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using ytLive.Models;
|
||||||
|
|
||||||
|
namespace ytLive.Services.Compositor;
|
||||||
|
|
||||||
|
/// <summary>Turns the session's tallies into the strip frame, and keeps the raster until a displayed
|
||||||
|
/// value actually moves.
|
||||||
|
///
|
||||||
|
/// <para>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.</para></summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The strip frame for the current tallies, or null when nothing is enabled/shown.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drops the cached raster so the next read rebuilds it.</summary>
|
||||||
|
public void Invalidate()
|
||||||
|
{
|
||||||
|
_frame = null;
|
||||||
|
_tilesFingerprint = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using Xunit;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services.Compositor;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user