diff --git a/Services/Compositor/ScoreboardPresenter.cs b/Services/Compositor/ScoreboardPresenter.cs index 29c64e3..12b25c0 100644 --- a/Services/Compositor/ScoreboardPresenter.cs +++ b/Services/Compositor/ScoreboardPresenter.cs @@ -13,6 +13,8 @@ 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; @@ -24,6 +26,20 @@ public sealed class ScoreboardPresenter _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; + + /// 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 = 120) + { + if (!Scroll) return; + _offsetPx += seconds * pixelsPerSecond; + } + /// The strip frame for the current tallies, or null when nothing is enabled/shown. public VideoFrame? Frame { @@ -31,8 +47,18 @@ public sealed class ScoreboardPresenter { var tiles = _tiles.Build(_recorder.State); var fingerprint = $"{_tiles.CacheKey}|{string.Join('|', tiles)}|{_style}"; - if (_frame != null && fingerprint == _tilesFingerprint) return _frame; + 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; @@ -55,6 +81,7 @@ public sealed class ScoreboardPresenter public void Invalidate() { _frame = null; + _run = null; _tilesFingerprint = null; } } diff --git a/Services/Compositor/ScoreboardRenderer.cs b/Services/Compositor/ScoreboardRenderer.cs index 9c138d8..578d26f 100644 --- a/Services/Compositor/ScoreboardRenderer.cs +++ b/Services/Compositor/ScoreboardRenderer.cs @@ -32,6 +32,73 @@ public static class ScoreboardRenderer private const double PaddingY = 8; private const double SeparatorGap = 26; + /// Builds the SCROLLING run: the strip's line plus a gap, at its natural width, so the + /// publisher can crop a 1920-wide window that moves. One raster per value change, never per + /// tick — the tick is a row-wise byte copy. + public static VideoFrame? RenderRun(IReadOnlyList? tiles, ScoreboardStyle style, double gapPx = 240) + { + var texts = tiles?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).ToList(); + if (texts is not { Count: > 0 }) return null; + + var typeface = new Typeface(OutputPalette.Sans, FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal); + var size = Math.Clamp(style.FontSize, ScoreboardLayout.FloorFontSize, ScoreboardLayout.MaxFontSize); + var textBrush = Brush(style.TextColor, 1.0, Colors.White); + var separator = Brush(style.TextColor, Math.Clamp(style.SeparatorOpacity, 0, 1), Colors.Gray); + + var measured = texts.Select(t => Text(t, typeface, size, textBrush)).ToList(); + var lineWidth = measured.Sum(t => t.WidthIncludingTrailingWhitespace) + SeparatorGap * (texts.Count - 1); + var height = (int)Math.Ceiling(size * 1.45 + PaddingY * 2); + var width = (int)Math.Ceiling(lineWidth + gapPx); + + var plate = Brush(style.BackgroundColor, Math.Clamp(style.BackgroundOpacity, 0, 1), Colors.Black); + var visual = new DrawingVisual(); + using (var dc = visual.RenderOpen()) + { + if (style.BackgroundEnabled) + dc.DrawRectangle(plate, null, new Rect(0, 0, width, height)); + + var x = 0.0; + for (var i = 0; i < measured.Count; i++) + { + dc.DrawText(measured[i], new Point(x, (height - size) / 2)); + x += measured[i].WidthIncludingTrailingWhitespace; + if (i == measured.Count - 1) continue; + var dot = Text("·", typeface, size, separator); + dc.DrawText(dot, new Point(x + (SeparatorGap - dot.WidthIncludingTrailingWhitespace) / 2, (height - size) / 2)); + x += SeparatorGap; + } + } + + return Rasterise(visual, width, height); + } + + /// Crops a -wide window out of the run at + /// , wrapping. Straight-alpha rows, no scaling. + public static VideoFrame Crop(VideoFrame run, int offsetPx, int width) + { + var height = run.Height; + var pixels = new byte[width * height * 4]; + var runStride = run.Width * 4; + var start = ((offsetPx % run.Width) + run.Width) % run.Width; + + for (var y = 0; y < height; y++) + { + var srcRow = y * runStride; + var dstRow = y * width * 4; + for (var x = 0; x < width; x++) + { + var sx = (start + x) % run.Width; + var src = srcRow + sx * 4; + var dst = dstRow + x * 4; + pixels[dst] = run.BgraPixels[src]; + pixels[dst + 1] = run.BgraPixels[src + 1]; + pixels[dst + 2] = run.BgraPixels[src + 2]; + pixels[dst + 3] = run.BgraPixels[src + 3]; + } + } + return new VideoFrame(width, height, pixels); + } + public static VideoFrame? Render(IReadOnlyList? tiles, ScoreboardStyle style) { var texts = tiles?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).ToList(); @@ -84,11 +151,18 @@ public static class ScoreboardRenderer } } - var bitmap = new RenderTargetBitmap(Width, height, 96, 96, PixelFormats.Pbgra32); + return Rasterise(visual, Width, height); + } + + /// Draws the visual into a straight-alpha BGRA frame (unpremultiplied, because the + /// compositor blends straight alpha). + private static VideoFrame Rasterise(DrawingVisual visual, int width, int height) + { + var bitmap = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); bitmap.Render(visual); - var pixels = new byte[Width * height * 4]; - bitmap.CopyPixels(pixels, Width * 4, 0); + var pixels = new byte[width * height * 4]; + bitmap.CopyPixels(pixels, width * 4, 0); for (var i = 0; i < pixels.Length; i += 4) { var a = pixels[i + 3]; @@ -97,7 +171,7 @@ public static class ScoreboardRenderer pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a); pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a); } - return new VideoFrame(Width, height, pixels); + return new VideoFrame(width, height, pixels); } /// Greedy line packing at the fitted size — mirrors diff --git a/ViewModels/MainViewModel.Audio.cs b/ViewModels/MainViewModel.Audio.cs index abd2aa5..f0bb824 100644 --- a/ViewModels/MainViewModel.Audio.cs +++ b/ViewModels/MainViewModel.Audio.cs @@ -31,6 +31,7 @@ public partial class MainViewModel RaiseMasterReadouts(); UpdateDuckState(); CheckTraxSilence(); + TickScoreboard(); } /// One honest diagnostic: "playing" with a flat player meter means the samples never diff --git a/ViewModels/MainViewModel.Scoreboard.cs b/ViewModels/MainViewModel.Scoreboard.cs index 7a9ca9e..f45b3e5 100644 --- a/ViewModels/MainViewModel.Scoreboard.cs +++ b/ViewModels/MainViewModel.Scoreboard.cs @@ -89,8 +89,14 @@ public partial class MainViewModel set { if (!SetProperty(ref _scoreboardSimulate, value)) return; - if (value) ScoreboardSample.Inject(_scoreboard); - else _scoreboard.StartSession(); // empty session: "nothing yet" + if (value) + { + ScoreboardSample.Inject(_scoreboard); + } + else + { + _scoreboard.StartSession(); // empty session: "nothing yet" + } RefreshScoreboardPreviews(); OnPropertyChanged(nameof(ScoreboardFrame)); } @@ -98,7 +104,8 @@ public partial class MainViewModel /// What the cadence policy asks: the master switch AND at least one scene showing it. internal bool ScoreboardPollsEnabled => - _scoreboardEnabled && Scenes.Any(scene => + !_scoreboardSimulate // dev data is made up: never poll for it + && _scoreboardEnabled && Scenes.Any(scene => scene.Elements.OfType().Any(s => s.IsScoreboard && s.IsVisible && s.IsEnabled)); internal ScoreboardTiles ScoreboardTilesState => _scoreboardTiles; @@ -215,6 +222,16 @@ public partial class MainViewModel OnPropertyChanged(nameof(ScoreboardStyleState)); } + /// One tick of the marquee: advance the window and republish. Runs only while the strip + /// is actually shown, so an idle/hidden scoreboard costs nothing. + private void TickScoreboard() + { + if (_scoreboardPresenter is null) return; + if (!_scoreboardEnabled && !_scoreboardSimulate) return; + _scoreboardPresenter.Advance(0.033); + RefreshScoreboardPreviews(); + } + /// Publishes the strip to every scoreboard layer's preview element. The pump thread /// must never touch a WriteableBitmap, so this runs on the UI thread only. private void RefreshScoreboardPreviews() diff --git a/ytLive.Tests/ScoreboardPresenterTests.cs b/ytLive.Tests/ScoreboardPresenterTests.cs index 617fbdb..ae43954 100644 --- a/ytLive.Tests/ScoreboardPresenterTests.cs +++ b/ytLive.Tests/ScoreboardPresenterTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Threading; using Xunit; using ytLive.Models; @@ -38,18 +39,25 @@ public class ScoreboardPresenterTests var first = presenter.Frame; Assert.NotNull(first); - // No change: the SAME frame instance comes back (cache hit). - Assert.Same(first, presenter.Frame); + // No tick, no change: the same pixels come back (the run is cached). + Assert.Equal(first!.BgraPixels, presenter.Frame!.BgraPixels); - // A visible change: a new frame. + // A TICK moves the window: the pixels must differ. + presenter.Advance(0.5); + var ticked = presenter.Frame; + Assert.NotNull(ticked); + Assert.NotEqual(first.BgraPixels, ticked!.BgraPixels); + + // A visible change re-rasterises the run. recorder.Record(Member("Second")); var second = presenter.Frame; Assert.NotNull(second); - Assert.NotSame(first, second); + Assert.NotEqual(ticked.BgraPixels, second!.BgraPixels); - // A gift-recipient event changes nothing displayed, so the raster is reused. + // A gift-recipient event changes nothing displayed: same window, same pixels. + var beforeGift = presenter.Frame!.BgraPixels.ToArray(); recorder.Record(new ChatMessage { Kind = ChatEventKind.GiftReceived, AuthorName = "Recipient" }); - Assert.Same(second, presenter.Frame); + Assert.Equal(beforeGift, presenter.Frame!.BgraPixels); // Disabling every tile returns no frame, and the layer draws nothing. presenter.SetTiles(ScoreboardTiles.None);