feat(scoreboard): unit 6 — it is a LAYER (source type, strip geometry, resolver, session hook)

Adds SourceType.Scoreboard with the same per-scene eye toggle, drag and presence as every other
element — 'prominent on BRB, dialled back on Live' is geometry, not a second mechanism. Default
geometry is the strip: 1920 wide, 56 tall, bottom edge.

The output resolver returns the presenter's cached frame exactly like the chat box and alert box do,
and every session start clears the tallies. Preview publication writes one shared WriteableBitmap on
the UI thread (the pump thread must never touch one), refreshed only when a displayed value moves.

Guard: ScoreboardLayerTests pins the enum member, the geometry constants, the resolver case and the
session hook, so the layer cannot quietly become a special widget.
This commit is contained in:
2026-10-30 14:40:31 -07:00
parent 41b0cd6e98
commit deb74529a6
7 changed files with 134 additions and 3 deletions
+3
View File
@@ -85,6 +85,9 @@ public abstract class SceneElement : INotifyPropertyChanged
/// <summary>True only for AlertBox-type sources. Gates stream alerts properties panel.</summary>
public virtual bool IsAlertBox => false;
public virtual bool IsTextOverlay => false;
/// <summary>The scoreboard strip: a per-scene, full-width bar.</summary>
public virtual bool IsScoreboard => false;
public virtual bool IsMediaSource => false;
public virtual bool IsLiveCapture => false;
public virtual bool IsBackground { get; set; }
+5
View File
@@ -13,6 +13,8 @@ public enum SourceType
Countdown,
WebSource,
ChatBox,
/// <summary>The session scoreboard strip: full-width, state not events.</summary>
Scoreboard,
MediaSource,
/// <summary>The designated alert zone: the six monetization events play their
/// unique celebration animations here (OBS alert-box equivalent, native).</summary>
@@ -74,6 +76,7 @@ public class Source : SceneElement
Raise(nameof(DisplaySource));
Raise(nameof(IsWebSource));
Raise(nameof(IsTextOverlay));
Raise(nameof(IsScoreboard));
}
}
}
@@ -88,6 +91,8 @@ public class Source : SceneElement
public override bool IsWebSource => Type == SourceType.WebSource;
public override bool IsTextOverlay => Type == SourceType.TextOverlay;
public override bool IsScoreboard => Type == SourceType.Scoreboard;
/// <summary>The permanent brand logo layer (one per scene, directly above the background).
/// Locked against removal; its image comes from the App Settings brand logo.</summary>
public bool IsBrandLogo { get; set; }
+82
View File
@@ -0,0 +1,82 @@
using System;
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;
private Source? _scoreboardPreviewSource;
internal ScoreboardRecorder ScoreboardRecorder => _scoreboard;
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();
}
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;
/// <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()
{
var frame = _scoreboardPresenter?.Frame;
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;
_scoreboardPreviewSource = source;
}
}
}
+7 -3
View File
@@ -30,6 +30,7 @@ public partial class MainViewModel
SourceType.WebSource => "Web Resource",
SourceType.ChatBox => "YouTube Chat",
SourceType.AlertBox => "Stream Alerts",
SourceType.Scoreboard => "Scoreboard",
_ => "Layer",
};
@@ -52,9 +53,12 @@ public partial class MainViewModel
: NextSourceName(scene, baseName),
Type = sourceType,
X = sourceType == SourceType.ChatBox ? 1400 : sourceType == SourceType.AlertBox ? 620 : 0,
Y = sourceType == SourceType.ChatBox ? 20 : sourceType == SourceType.AlertBox ? 430 : 0,
Width = sourceType == SourceType.WebSource ? 640 : sourceType == SourceType.ChatBox ? 500 : sourceType == SourceType.AlertBox ? 680 : 0,
Height = sourceType == SourceType.WebSource ? 480 : sourceType == SourceType.ChatBox ? 648 : sourceType == SourceType.AlertBox ? 200 : 0,
// The scoreboard is a full-width bar: its Y comes from the edge it owns (resolved
// against the social bar at render time), never from a drag.
Y = sourceType == SourceType.Scoreboard ? 1024
: sourceType == SourceType.ChatBox ? 20 : sourceType == SourceType.AlertBox ? 430 : 0,
Width = sourceType == SourceType.WebSource ? 640 : sourceType == SourceType.ChatBox ? 500 : sourceType == SourceType.AlertBox ? 680 : sourceType == SourceType.Scoreboard ? 1920 : 0,
Height = sourceType == SourceType.WebSource ? 480 : sourceType == SourceType.ChatBox ? 648 : sourceType == SourceType.AlertBox ? 200 : sourceType == SourceType.Scoreboard ? 56 : 0,
};
_sceneGraph.AddElement(scene, source);
@@ -21,6 +21,9 @@ public partial class MainViewModel : ViewModelBase
if (IsLive || IsRecording) return;
var streaming = OnAirPillOn;
var recording = RecordPillOn;
// The scoreboard's scope is the session: every start clears it (creator ruling 2026-10-30).
StartScoreboardSession();
if (!streaming && !recording)
{
// Nothing armed = "record something" (local recording needs no account,
+2
View File
@@ -278,6 +278,7 @@ public partial class MainViewModel : ViewModelBase
AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs();
MonoOutput = _layoutStore.LoadMonoOutput(); // applied to the mixer by the setter
_musicPlayer.UserGain = (float)Helpers.GainDb.FromDb(TraxDefaultDb); // background by default
InitScoreboard();
try
{
var s = _layoutStore.GetSetting("ZoomShape");
@@ -595,6 +596,7 @@ public partial class MainViewModel : ViewModelBase
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(DeviceKeyForWebcam(webcam.WebcamId), EffectKeyFor(webcam)),
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
Source { Type: SourceType.ChatBox } chatBox => RenderChatBox(chatBox),
Source { Type: SourceType.Scoreboard } => _scoreboardPresenter.Frame,
Source { Type: SourceType.AlertBox } alertBox => RenderAlertBox(alertBox),
Source { Type: SourceType.WebSource } web => _webView2Manager?.GetLatestFrame(web.Id),
Source { IsMediaSource: true, MediaPath: not null } media => _mediaManager.GetLatestFrame(media.MediaPath),
+32
View File
@@ -0,0 +1,32 @@
using System.Linq;
using Xunit;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>The scoreboard is a LAYER, not a special widget: it must appear as a source type, carry
/// the full-width strip geometry, be resolvable on the output path like a chat box, and hook the
/// session lifecycle so its numbers are session-scoped.</summary>
public class ScoreboardLayerTests
{
[Fact]
public void The_scoreboard_is_a_real_source_with_strip_geometry_and_a_resolver()
{
Assert.Contains("Scoreboard", System.Enum.GetNames<SourceType>());
// Geometry: full width, a strip's height, bottom by default.
var sources = SourceAssert.Text("ViewModels", "MainViewModel.Sources.cs");
Assert.Contains("SourceType.Scoreboard ? 1920", sources);
Assert.Contains("SourceType.Scoreboard ? 56", sources);
Assert.Contains("SourceType.Scoreboard ? 1024", sources);
// The output resolver knows how to produce its frame (same shape as the chat box/alert box).
var resolver = SourceAssert.Text("ViewModels", "MainViewModel.cs");
Assert.Contains("Source { Type: SourceType.Scoreboard } => _scoreboardPresenter.Frame", resolver);
// Session lifecycle: every start clears the tallies.
var ops = SourceAssert.Text("ViewModels", "MainViewModel.Streaming.Operations.cs");
Assert.Contains("StartScoreboardSession();", ops);
}
}