From deb74529a61e7619b6c47ef19b1496e595c06dca Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 30 Oct 2026 14:40:31 -0700 Subject: [PATCH] =?UTF-8?q?feat(scoreboard):=20unit=206=20=E2=80=94=20it?= =?UTF-8?q?=20is=20a=20LAYER=20(source=20type,=20strip=20geometry,=20resol?= =?UTF-8?q?ver,=20session=20hook)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Models/SceneElement.cs | 3 + Models/Source.cs | 5 ++ ViewModels/MainViewModel.Scoreboard.cs | 82 +++++++++++++++++++ ViewModels/MainViewModel.Sources.cs | 10 ++- .../MainViewModel.Streaming.Operations.cs | 3 + ViewModels/MainViewModel.cs | 2 + ytLive.Tests/ScoreboardLayerTests.cs | 32 ++++++++ 7 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 ViewModels/MainViewModel.Scoreboard.cs create mode 100644 ytLive.Tests/ScoreboardLayerTests.cs 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); + } +}