The paid delta is output quality, so the rule needs to be measurable rather than a word:
921,600 pixels and 30 fps, fail-closed. Models/OutputProfile.cs is that rule as pure
policy — a permitted tier passes through untouched (Pro, or the free tier's own 720p30),
a disallowed one resolves to the best tier the budget allows and carries ITS bitrate, so
a capped user is never offered an 8 Mbps budget for a 720p frame.
Applied once, in BuildEncoderOptions. That is genuinely one place: the stream and the
local recording are two output blocks of ONE ffmpeg process fed by one EncoderOptions,
so there is no second encoder to forget. (commercial.md claimed "both encoders — never
two checks"; the code is stronger than the doc, and the doc is corrected.)
Developer override, as asked: a "Developer mode — all resolutions" checkbox in App
Settings, bound to MainViewModel.DeveloperMode, which feeds HasProOutput. It is not
hidden in a release build — Controls/DeveloperPanel's Page and Compile items are removed
when Configuration != Debug, and OverlayHost adds it at runtime inside #if DEBUG, so a
shipped exe has no BAML for it at all. The setter also refuses the value when
DevBuild.IsDevelopment is false, which is now the single source for that question (the
scoreboard partial had its own duplicate const).
Two traps worth their lines: a `const bool` dev flag makes `if (!Flag) return;` an
unreachable-code warning in one configuration or the other (CS0162), so DevBuild uses
static readonly; and a Condition on an ItemGroup is invisible to a test reading
item.Attribute("Condition") — read the parent.
Tests: 14 pass across OutputProfileTests, AssemblyIdentityTests and StorePackagingTests.
The invariant test is the one that matters: iterate every shipped tier under a free
entitlement and assert none emits more than the budget, so a future 4K tier fails here
instead of shipping to everyone. App builds Debug and Release at 0 warnings.
288 lines
12 KiB
C#
288 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));
|
|
}
|
|
}
|
|
|
|
/// <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.
|
|
/// Single source: <see cref="DevBuild.IsDevelopment"/>.</summary>
|
|
public bool IsDevelopmentBuild => DevBuild.IsDevelopment;
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|