Creator's defaults: white text, Background ON (black, 20%), speed capped at 200 px/s, text size shown in real pixels, and "plate" dropped from the label. The important find is underneath those: `ScoreboardStyle` is a record STRUCT, and `new ScoreboardStyle()` zero-initialises — it does NOT run the constructor's parameter defaults. So the shipped strip had null text colour, NO background and zero opacity, whatever the source said. That is why the Background checkbox and the opacity slider looked like they had no code behind them: the renderer was faithfully drawing the zeros it was handed. An explicit parameterless constructor now carries the real defaults, and the renderer test asserts them (white / on / black / 0.20 / max speed 200) so this class of silent-zero cannot return. Readouts fixed too: the opacity and text-size setters never raised their own property, so the numbers never moved while the render did. Text size now reads "24 px" instead of a bare slider value, and snaps to whole pixels.
293 lines
12 KiB
C#
293 lines
12 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 };
|
|
|
|
/// <summary>How fast the ticker travels, in pixels per second. Persisted like the rest of the
|
|
/// strip's look: it is a readability choice, and re-dialling it every launch would be a tax.</summary>
|
|
public double ScoreboardScrollSpeed
|
|
{
|
|
get => _scoreboardPresenter?.PixelsPerSecond ?? ScoreboardPresenter.DefaultPixelsPerSecond;
|
|
set
|
|
{
|
|
var clamped = Math.Clamp(value, ScoreboardPresenter.MinPixelsPerSecond, ScoreboardPresenter.MaxPixelsPerSecond);
|
|
if (_scoreboardPresenter != null) _scoreboardPresenter.PixelsPerSecond = clamped;
|
|
_layoutStore.SaveScoreboardScrollSpeed((int)Math.Round(clamped));
|
|
OnPropertyChanged(nameof(ScoreboardScrollSpeed));
|
|
}
|
|
}
|
|
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();
|
|
// The readout binds THIS property: without its own raise the number never moves even
|
|
// though the render does (creator: "bzzzt!").
|
|
OnPropertyChanged(nameof(ScoreboardBackgroundOpacity));
|
|
}
|
|
}
|
|
|
|
public double ScoreboardFontSize
|
|
{
|
|
get => _scoreboardStyle.FontSize;
|
|
set
|
|
{
|
|
_scoreboardStyle = _scoreboardStyle with
|
|
{
|
|
FontSize = Math.Clamp(value, ScoreboardLayout.FloorFontSize, ScoreboardLayout.MaxFontSize),
|
|
};
|
|
PushStyle();
|
|
OnPropertyChanged(nameof(ScoreboardFontSize)); // same bug as the opacity readout
|
|
OnPropertyChanged(nameof(ScoreboardFontSizeLabel));
|
|
}
|
|
}
|
|
|
|
/// <summary>"24 px" — the panel shows real pixels, not a bare slider number.</summary>
|
|
public string ScoreboardFontSizeLabel => $"{_scoreboardStyle.FontSize:0} px";
|
|
|
|
/// <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;
|
|
}
|
|
}
|
|
}
|