From 85a4a8a7d32ed72a5daae4be8b000c10f8c3c3ea Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 30 Oct 2026 14:50:20 -0700 Subject: [PATCH] feat(scoreboard): the layer is available to ALL scenes (YT_ACTIVITY_TICKER) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creator ruling: the Scoreboard layer exists in every scene rather than being added five times. ScoreboardHealer.Ensure keeps exactly one per scene — healed on load like the Background and Brand logo — so a scene can never end up with two, and 'off' is the ordinary per-scene eye toggle rather than a delete. It is appended as the TOPMOST layer on purpose: a full-width strip draws over the scene, and inserting it above the background would break the Brand logo's documented invariant (directly above the background). That choice is also why the layer-reorder test still passes untouched. Also: a layer-list icon for the type, so the row is not blank. Guard: ScoreboardHealerTests — one per scene, topmost, exact strip geometry, idempotent, and a duplicate healed back to one. --- Controls/LeftPanel.xaml | 3 ++ Models/ScoreboardHealer.cs | 56 ++++++++++++++++++++++++++ ViewModels/MainViewModel.Background.cs | 1 + ViewModels/MainViewModel.Scoreboard.cs | 12 ++++++ ytLive.Tests/ScoreboardHealerTests.cs | 53 ++++++++++++++++++++++++ 5 files changed, 125 insertions(+) create mode 100644 Models/ScoreboardHealer.cs create mode 100644 ytLive.Tests/ScoreboardHealerTests.cs diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml index 6a6e8c8..03437a9 100644 --- a/Controls/LeftPanel.xaml +++ b/Controls/LeftPanel.xaml @@ -208,6 +208,9 @@ + + + diff --git a/Models/ScoreboardHealer.cs b/Models/ScoreboardHealer.cs new file mode 100644 index 0000000..2eabd14 --- /dev/null +++ b/Models/ScoreboardHealer.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; + +namespace ytLive.Models; + +/// Keeps exactly one Scoreboard layer in EVERY scene. +/// +/// Creator ruling (2026-10-30): "YT_ACTIVITY_TICKER … is available to all scenes." The +/// scoreboard is not something to add five times — it exists once per scene like the Background and +/// the Brand logo, and a creator turns it off per scene with the ordinary eye toggle. Removing it is +/// therefore not a delete: it heals on the next load. +/// +/// Pure over a scene collection, so the rule is testable without a view-model. +public static class ScoreboardHealer +{ + public const string LayerName = "Scoreboard"; + + /// The strip's default box: full width, a strip's height, and its Y for the bottom + /// edge. Geometry only — the EDGE itself is resolved against the social bar at render time. + public const double StripHeight = 56; + public const double BottomY = 1080 - StripHeight; + + /// Ensures one scoreboard per scene, appended as the TOPMOST layer: it is a full-width + /// strip drawn over the scene, and inserting it above the background would break the Brand + /// logo's documented invariant ("directly above the background"). Returns true when anything + /// changed, so the caller can save. + public static bool Ensure(IEnumerable scenes) + { + var changed = false; + foreach (var scene in scenes) + { + var existing = scene.Elements.OfType().Where(s => s.IsScoreboard).ToList(); + + // More than one: keep the first, drop the rest. + foreach (var extra in existing.Skip(1)) + { + scene.Elements.Remove(extra); + changed = true; + } + if (existing.Count > 0) continue; + + scene.Elements.Add(new Source + { + Name = LayerName, + Type = SourceType.Scoreboard, + IsEnabled = true, + X = 0, + Y = BottomY, + Width = 1920, + Height = StripHeight, + }); + changed = true; + } + return changed; + } +} diff --git a/ViewModels/MainViewModel.Background.cs b/ViewModels/MainViewModel.Background.cs index 1ad9ab1..dd53414 100644 --- a/ViewModels/MainViewModel.Background.cs +++ b/ViewModels/MainViewModel.Background.cs @@ -439,6 +439,7 @@ public partial class MainViewModel } SeedOnAirImage(); EnsureBrandLogos(); + EnsureScoreboards(); OnPropertyChanged(nameof(ShowSourcesEmptyHint)); OnPropertyChanged(nameof(LayerCount)); } diff --git a/ViewModels/MainViewModel.Scoreboard.cs b/ViewModels/MainViewModel.Scoreboard.cs index b33cebe..9889548 100644 --- a/ViewModels/MainViewModel.Scoreboard.cs +++ b/ViewModels/MainViewModel.Scoreboard.cs @@ -45,6 +45,18 @@ public partial class MainViewModel RefreshScoreboardPreviews(); } + /// 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. + 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) diff --git a/ytLive.Tests/ScoreboardHealerTests.cs b/ytLive.Tests/ScoreboardHealerTests.cs new file mode 100644 index 0000000..79e287b --- /dev/null +++ b/ytLive.Tests/ScoreboardHealerTests.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Linq; +using Xunit; +using ytLive.Models; + +namespace ytLive.Tests; + +/// Creator ruling (2026-10-30): the Scoreboard layer — YT_ACTIVITY_TICKER — is AVAILABLE TO +/// ALL SCENES. It therefore heals into every scene like the Background and Brand logo, and a scene +/// can never end up with two of them. +public class ScoreboardHealerTests +{ + private static Scene SceneWith(string name) + { + var scene = new Scene { Name = name }; + scene.Elements.Add(new Source { Name = "Background", Type = SourceType.Background, IsBackground = true }); + scene.Elements.Add(new Source { Name = "Brand logo", Type = SourceType.Image, IsBrandLogo = true }); + return scene; + } + + [Fact] + public void Every_scene_gets_exactly_one_scoreboard_below_the_background() + { + var scenes = new List + { + SceneWith(SceneCatalog.Starting), SceneWith(SceneCatalog.Live), SceneWith(SceneCatalog.Brb), + }; + + Assert.True(ScoreboardHealer.Ensure(scenes)); + foreach (var scene in scenes) + { + var boards = scene.Elements.OfType().Where(s => s.IsScoreboard).ToList(); + Assert.Single(boards); + Assert.Equal("Scoreboard", boards[0].Name); + Assert.Equal(0, boards[0].X); + Assert.Equal(1920, boards[0].Width); + Assert.Equal(ScoreboardHealer.StripHeight, boards[0].Height); + // Topmost: a full-width strip over the scene, and the Brand logo keeps its documented + // place directly above the background. + Assert.Equal(scene.Elements.Count - 1, scene.Elements.IndexOf(boards[0])); + Assert.Equal("Brand logo", scene.Elements[1].Name); + } + + // Idempotent: a second pass changes nothing (no duplicate strips, no churn). + Assert.False(ScoreboardHealer.Ensure(scenes)); + foreach (var scene in scenes) Assert.Single(scene.Elements.OfType().Where(s => s.IsScoreboard)); + + // A scene that somehow has two is healed back to one. + scenes[0].Elements.Add(new Source { Name = "Scoreboard2", Type = SourceType.Scoreboard }); + Assert.True(ScoreboardHealer.Ensure(scenes)); + Assert.Single(scenes[0].Elements.OfType().Where(s => s.IsScoreboard)); + } +}