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