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);