Files
LlamaCasty/ViewModels/MainViewModel.Scoreboard.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

271 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Compositor;
namespace ytLive.ViewModels;
/// <summary>The scoreboard strip: session tallies rendered as a full-width bar, per scene.
///
/// <para>It is a <see cref="SourceType.Scoreboard"/> layer, so a creator gets the same eye toggle,
/// drag and per-scene presence as every other element — "prominent on BRB, dialled back on Live" is
/// geometry, not a second mechanism.</para></summary>
public partial class MainViewModel
{
private readonly ScoreboardRecorder _scoreboard = new();
private ScoreboardTiles _scoreboardTiles = ScoreboardTiles.Default;
private ScoreboardStyle _scoreboardStyle = new();
private ScoreboardPresenter? _scoreboardPresenter;
private WriteableBitmap? _scoreboardPreview;
/// <summary>Gap between the strip and the frame edge.</summary>
private const double ScoreboardEdgeMargin = 8;
private BarEdge _scoreboardPreferredEdge = BarEdge.Bottom;
/// <summary>The edge the creator asked for. It is only a PREFERENCE: the resolver gives the
/// scoreboard the edge the social bar does not hold (creator ruling, resolved at render time).</summary>
public BarEdge ScoreboardEdge
{
get => _scoreboardPreferredEdge;
set
{
if (!SetProperty(ref _scoreboardPreferredEdge, value)) return;
RefreshScoreboardPreviews();
}
}
public IReadOnlyList<BarEdge> ScoreboardEdgeOptions { get; } = new[] { BarEdge.Top, BarEdge.Bottom };
private Source? _scoreboardPreviewSource;
internal ScoreboardRecorder ScoreboardRecorder => _scoreboard;
private bool _scoreboardEnabled = true;
/// <summary>The scoreboard's master switch. Off hides the strip in EVERY scene (the per-scene
/// eye stays independent) and — the point of the switch — stops all polling: a scoreboard nobody
/// can see must not spend quota.</summary>
public bool ScoreboardEnabled
{
get => _scoreboardEnabled;
set
{
if (!SetProperty(ref _scoreboardEnabled, value)) return;
_layoutStore.SaveScoreboardEnabled(value);
RefreshScoreboardPreviews();
OnPropertyChanged(nameof(ScoreboardPollsEnabled));
}
}
#if DEBUG
private const bool DevelopmentBuild = true;
#else
private const bool DevelopmentBuild = false;
#endif
/// <summary>Can the scoreboard be configured at all? Signed-in creators yes; in a DEBUG build
/// always, so the strip can be previewed and judged before there is an account (creator,
/// 2026-10-30).</summary>
public bool CanUseScoreboard => IsConnected || IsDevelopmentBuild;
/// <summary>True in a DEBUG build. Used only to expose development-only controls, never to
/// change behaviour — the shipped binary differs by their absence, not by a code path.</summary>
public bool IsDevelopmentBuild => DevelopmentBuild;
private bool _scoreboardSimulate;
/// <summary>DEBUG-only: push a representative set of events so the strip is visible in the
/// preview before any real stream. Off clears back to an empty session.</summary>
public bool ScoreboardSimulate
{
get => _scoreboardSimulate;
set
{
if (!SetProperty(ref _scoreboardSimulate, value)) return;
if (value)
{
ScoreboardSample.Inject(_scoreboard);
}
else
{
_scoreboard.StartSession(); // empty session: "nothing yet"
}
RefreshScoreboardPreviews();
OnPropertyChanged(nameof(ScoreboardFrame));
}
}
/// <summary>What the cadence policy asks: the master switch AND at least one scene showing it.</summary>
internal bool ScoreboardPollsEnabled =>
!_scoreboardSimulate // dev data is made up: never poll for it
&& _scoreboardEnabled && Scenes.Any(scene =>
scene.Elements.OfType<Source>().Any(s => s.IsScoreboard && s.IsVisible && s.IsEnabled));
internal ScoreboardTiles ScoreboardTilesState => _scoreboardTiles;
/// <summary>Composited strip frame for the output path (cached by the presenter).</summary>
internal VideoFrame? ScoreboardFrame => _scoreboardPresenter?.Frame;
internal void StartScoreboardSession()
{
_scoreboard.StartSession();
RefreshScoreboardPreviews();
}
internal void EndScoreboardSession()
{
_scoreboard.EndSession();
RefreshScoreboardPreviews();
}
/// <summary>One Scoreboard layer in every scene, healed on load — "available to all scenes"
/// (creator ruling 2026-10-30). Per-scene visibility is the ordinary eye toggle.</summary>
internal void EnsureScoreboards()
{
if (ScoreboardHealer.Ensure(Scenes))
{
foreach (var scene in Scenes) _sceneGraph.InvalidateBake(scene);
RefreshScoreboardPreviews();
OnPropertyChanged(nameof(LayerCount));
}
}
private void InitScoreboard()
{
_scoreboardPresenter = new ScoreboardPresenter(_scoreboard, _scoreboardStyle)
{
// placeholder so SetTiles below drives the first render
};
_scoreboardPresenter.SetTiles(_scoreboardTiles);
_scoreboardPresenter.SetStyle(_scoreboardStyle);
_scoreboard.Changed += RefreshScoreboardPreviews;
}
/// <summary>Snapshot of the strip presenters as themed properties, for the properties panel
/// (unit 7) — kept here so the panel binds one object instead of six primitives.</summary>
internal ScoreboardStyle ScoreboardStyleState => _scoreboardStyle;
// ─── Properties panel surface (per layout: one scoreboard look) ────────────
public string ScoreboardTextColor
{
get => _scoreboardStyle.TextColor;
set { _scoreboardStyle = _scoreboardStyle with { TextColor = value }; PushStyle(); }
}
public bool ScoreboardBackgroundEnabled
{
get => _scoreboardStyle.BackgroundEnabled;
set { _scoreboardStyle = _scoreboardStyle with { BackgroundEnabled = value }; PushStyle(); }
}
public string ScoreboardBackgroundColor
{
get => _scoreboardStyle.BackgroundColor;
set { _scoreboardStyle = _scoreboardStyle with { BackgroundColor = value }; PushStyle(); }
}
public double ScoreboardBackgroundOpacity
{
get => _scoreboardStyle.BackgroundOpacity;
set
{
_scoreboardStyle = _scoreboardStyle with { BackgroundOpacity = Math.Clamp(value, 0, 1) };
PushStyle();
}
}
public double ScoreboardFontSize
{
get => _scoreboardStyle.FontSize;
set
{
_scoreboardStyle = _scoreboardStyle with
{
FontSize = Math.Clamp(value, ScoreboardLayout.FloorFontSize, ScoreboardLayout.MaxFontSize),
};
PushStyle();
}
}
/// <summary>The colours offered for text and plate: the app's one palette.</summary>
public IReadOnlyList<NamedColor> ScoreboardColorOptions => BorderColorPalette.All;
public bool ScoreboardShowSubscribers { get => _scoreboardTiles.Subscribers; set => SetTile(t => t.Subscribers = value); }
public bool ScoreboardShowWatchHours { get => _scoreboardTiles.WatchHours; set => SetTile(t => t.WatchHours = value); }
public bool ScoreboardShowSuperChats { get => _scoreboardTiles.SuperChats; set => SetTile(t => t.SuperChats = value); }
public bool ScoreboardShowNewMembers { get => _scoreboardTiles.NewMembers; set => SetTile(t => t.NewMembers = value); }
public bool ScoreboardShowMilestones { get => _scoreboardTiles.Milestones; set => SetTile(t => t.Milestones = value); }
public bool ScoreboardShowLastMember { get => _scoreboardTiles.LastMember; set => SetTile(t => t.LastMember = value); }
public bool ScoreboardShowLastSuperChat { get => _scoreboardTiles.LastSuperChat; set => SetTile(t => t.LastSuperChat = value); }
public bool ScoreboardShowLastGift { get => _scoreboardTiles.LastGift; set => SetTile(t => t.LastGift = value); }
private void SetTile(Action<ScoreboardTiles> change)
{
change(_scoreboardTiles);
_scoreboardPresenter?.SetTiles(_scoreboardTiles);
RefreshScoreboardPreviews();
OnPropertyChanged(nameof(ScoreboardTilesState));
}
private void PushStyle()
{
_scoreboardPresenter?.SetStyle(_scoreboardStyle);
RefreshScoreboardPreviews();
OnPropertyChanged(nameof(ScoreboardStyleState));
}
/// <summary>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.</summary>
private void TickScoreboard()
{
if (_scoreboardPresenter is null) return;
if (!_scoreboardEnabled && !_scoreboardSimulate) return;
_scoreboardPresenter.Advance(0.033);
RefreshScoreboardPreviews();
}
/// <summary>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.</summary>
private void RefreshScoreboardPreviews()
{
// Dev simulation OVERRIDES the master switch (creator ruling): the whole point of the dev
// toggle is to prove the strip without touching product settings.
var frame = (_scoreboardEnabled || _scoreboardSimulate) ? _scoreboardPresenter?.Frame : null;
foreach (var scene in Scenes)
foreach (var source in scene.Elements.OfType<Source>().Where(s => s.IsScoreboard))
{
if (frame is null) { source.VideoImageSource = null; continue; }
if (_scoreboardPreview is null || _scoreboardPreview.PixelWidth != frame.Width ||
_scoreboardPreview.PixelHeight != frame.Height)
{
_scoreboardPreview = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
}
_scoreboardPreview.WritePixels(new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height),
frame.BgraPixels, frame.Width * 4, 0);
source.VideoImageSource = _scoreboardPreview;
// The strip's box follows what was RENDERED (one or two lines), and its edge comes
// from the SHARED rule: the social bar keeps its side, the scoreboard takes the other.
var socialEdge = _socials?.BarPosition == SocialBarPosition.Top ? BarEdge.Top : BarEdge.Bottom;
var (_, resolved) = ScoreboardLayout.ResolveEdges(
socialVisible: SocialBarVisible, socialPreferred: socialEdge,
scoreboardVisible: true, scoreboardPreferred: _scoreboardPreferredEdge);
source.Width = frame.Width;
source.Height = frame.Height;
source.Y = resolved == BarEdge.Top
? ScoreboardEdgeMargin
: 1080 - frame.Height - ScoreboardEdgeMargin;
_scoreboardPreviewSource = source;
}
}
}