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 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; } /// 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. public bool Scroll { get; set; } = true; /// Marquee speed in pixels per second — the creator's dial (a read-speed choice, not a /// styling one). public double PixelsPerSecond { get; set; } = DefaultPixelsPerSecond; public const double DefaultPixelsPerSecond = 120; public const double MinPixelsPerSecond = 20; public const double MaxPixelsPerSecond = 200; // creator cap: faster than this is unreadable /// Advances the scroll by . 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. public void Advance(double seconds, double? pixelsPerSecond = null) { if (!Scroll) return; _offsetPx += seconds * (pixelsPerSecond ?? PixelsPerSecond); } /// 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 (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(); } /// Drops the cached raster so the next read rebuilds it. public void Invalidate() { _frame = null; _run = null; _tilesFingerprint = null; } }