Four reports, three causes: 1. "Background plate has no code behind it" — the plate COLOUR and OPACITY controls were disabled until the plate switch was on, which reads as dead controls. They are always settable now; the switch is what decides whether a plate is drawn. 2. "Opacity doesn't work" — proven in the renderer test: 20% and 80% produce alphas of ~51 and ~204, so the slider reaches the pixels. What was actually wrong was the box. 3. The strip's BOX was fixed at 56px while a wrapped strip renders taller — the frame was scaled into the box and looked squashed/clipped. The source's width/height now follow the rendered frame, and its Y follows from the edge. 4. "Simulate (dev) should override Show the scoreboard" — it does: the preview gate is (_scoreboardEnabled || _scoreboardSimulate), so the dev toggle works even with the product setting off. Not changed: the strip is STATIC by design (all enabled facts always visible, wrap rather than scroll). If scrolling is wanted after all, that is a presentation mode to add, not a fix.
236 lines
9.7 KiB
C#
236 lines
9.7 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 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 =>
|
|
_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>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 height follows what was RENDERED (one line or two). A fixed 56px box
|
|
// squashed a wrapped strip instead of showing it, and its Y follows from the edge.
|
|
if (Math.Abs(source.Height - frame.Height) > 0.5)
|
|
{
|
|
source.Width = frame.Width;
|
|
source.Height = frame.Height;
|
|
source.Y = _scoreboardStyle is var _ && source.Y > 540
|
|
? 1080 - frame.Height - ScoreboardEdgeMargin
|
|
: ScoreboardEdgeMargin;
|
|
}
|
|
_scoreboardPreviewSource = source;
|
|
}
|
|
}
|
|
}
|