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); + } +}