Files
LlamaCasty/Services/Compositor/ScoreboardPresenter.cs
T
gramps 460b36204d feat(scoreboard): it ticks — the marquee is the design, not an option
Creator, plainly: "the entire point of a ticker is to tick… so, tick." The static strip was my
reading of an earlier exchange; it is gone.

How it ticks without becoming the compositor's most expensive layer:
- `ScoreboardRenderer.RenderRun` rasterises the run ONCE per value change at its natural width plus
  a gap.
- `ScoreboardPresenter` crops a 1920-wide window at a moving offset each tick — a row-wise byte
  copy, no text layout, no re-raster.
- `TickScoreboard` runs on the existing 30 Hz meter clock and only while the strip is shown, so a
  hidden or disabled scoreboard costs nothing.

The dev simulation uses the same path, so what is previewed is what ships.

Test updated to the real contract: no tick → identical pixels; a tick → different pixels; a value
change → re-rasterised run. (The old test asserted frame-instance identity, which a cropped window
can never satisfy.)
2026-10-30 15:17:55 -07:00

88 lines
3.0 KiB
C#

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 VideoFrame? _run; // the wide scrollable run (dev preview only)
private double _offsetPx;
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 TICKS (creator ruling 2026-10-30: "the point is, it's a ticker… so,
/// tick"). True for the product, and the dev simulation uses the same path — so what the
/// creator previews is what ships.</summary>
public bool Scroll { get; set; } = true;
/// <summary>Advances the scroll by <paramref name="seconds"/>. The strip is a marquee: one
/// measured run, cropped to a moving window, so a tick is a row-wise byte copy rather than a
/// re-rasterisation.</summary>
public void Advance(double seconds, double pixelsPerSecond = 120)
{
if (!Scroll) return;
_offsetPx += seconds * pixelsPerSecond;
}
/// <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 (Scroll)
{
if (_run is null || fingerprint != _tilesFingerprint)
{
_tilesFingerprint = fingerprint;
_run = ScoreboardRenderer.RenderRun(tiles, _style);
}
return _run is null ? null : ScoreboardRenderer.Crop(_run, (int)_offsetPx, ScoreboardRenderer.Width);
}
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;
_run = null;
_tilesFingerprint = null;
}
}