diff --git a/Models/SceneElement.cs b/Models/SceneElement.cs
index e7443bc..ad939d6 100644
--- a/Models/SceneElement.cs
+++ b/Models/SceneElement.cs
@@ -85,6 +85,9 @@ public abstract class SceneElement : INotifyPropertyChanged
/// True only for AlertBox-type sources. Gates stream alerts properties panel.
public virtual bool IsAlertBox => false;
public virtual bool IsTextOverlay => false;
+
+ /// The scoreboard strip: a per-scene, full-width bar.
+ public virtual bool IsScoreboard => false;
public virtual bool IsMediaSource => false;
public virtual bool IsLiveCapture => false;
public virtual bool IsBackground { get; set; }
diff --git a/Models/Source.cs b/Models/Source.cs
index 817be1d..74940fa 100644
--- a/Models/Source.cs
+++ b/Models/Source.cs
@@ -13,6 +13,8 @@ public enum SourceType
Countdown,
WebSource,
ChatBox,
+ /// The session scoreboard strip: full-width, state not events.
+ Scoreboard,
MediaSource,
/// The designated alert zone: the six monetization events play their
/// unique celebration animations here (OBS alert-box equivalent, native).
@@ -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;
+
/// The permanent brand logo layer (one per scene, directly above the background).
/// Locked against removal; its image comes from the App Settings brand logo.
public bool IsBrandLogo { get; set; }
diff --git a/ViewModels/MainViewModel.Scoreboard.cs b/ViewModels/MainViewModel.Scoreboard.cs
new file mode 100644
index 0000000..43433b9
--- /dev/null
+++ b/ViewModels/MainViewModel.Scoreboard.cs
@@ -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;
+
+/// The scoreboard strip: session tallies rendered as a full-width bar, per scene.
+///
+/// It is a 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.
+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;
+
+ /// Composited strip frame for the output path (cached by the presenter).
+ 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;
+ }
+
+ /// 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.
+ internal ScoreboardStyle ScoreboardStyleState => _scoreboardStyle;
+
+ /// 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.
+ private void RefreshScoreboardPreviews()
+ {
+ var frame = _scoreboardPresenter?.Frame;
+ foreach (var scene in Scenes)
+ foreach (var source in scene.Elements.OfType().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;
+ }
+ }
+}
diff --git a/ViewModels/MainViewModel.Sources.cs b/ViewModels/MainViewModel.Sources.cs
index 59fc6c4..0660369 100644
--- a/ViewModels/MainViewModel.Sources.cs
+++ b/ViewModels/MainViewModel.Sources.cs
@@ -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);
diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs
index 3d0bd67..70b3d96 100644
--- a/ViewModels/MainViewModel.Streaming.Operations.cs
+++ b/ViewModels/MainViewModel.Streaming.Operations.cs
@@ -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,
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 8d3d047..b1fed40 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -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),
diff --git a/ytLive.Tests/ScoreboardLayerTests.cs b/ytLive.Tests/ScoreboardLayerTests.cs
new file mode 100644
index 0000000..6835b62
--- /dev/null
+++ b/ytLive.Tests/ScoreboardLayerTests.cs
@@ -0,0 +1,32 @@
+using System.Linq;
+using Xunit;
+using ytLive.Models;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+/// 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.
+public class ScoreboardLayerTests
+{
+ [Fact]
+ public void The_scoreboard_is_a_real_source_with_strip_geometry_and_a_resolver()
+ {
+ Assert.Contains("Scoreboard", System.Enum.GetNames());
+
+ // 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);
+ }
+}