feat(scoreboard): the layer is available to ALL scenes (YT_ACTIVITY_TICKER)

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.
This commit is contained in:
2026-10-30 14:50:20 -07:00
parent 96d528b3ac
commit 85a4a8a7d3
5 changed files with 125 additions and 0 deletions
+3
View File
@@ -208,6 +208,9 @@
<Path Data="M4,5 H20 V19 H4 Z M10,9 L15,12 L10,15 Z" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsMediaSource}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
</Path>
<Path Data="M3,9 H21 V15 H3 Z M6,12 H12 M15,12 H18" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsScoreboard}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
</Path>
<Path Data="M6,16 V11 A6,6 0 0 1 18,11 V16 L20,18 H4 Z M10,20 H14" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsAlertBox}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
</Path>
+56
View File
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
namespace ytLive.Models;
/// <summary>Keeps exactly one Scoreboard layer in EVERY scene.
///
/// <para>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.</para>
///
/// <para>Pure over a scene collection, so the rule is testable without a view-model.</para></summary>
public static class ScoreboardHealer
{
public const string LayerName = "Scoreboard";
/// <summary>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.</summary>
public const double StripHeight = 56;
public const double BottomY = 1080 - StripHeight;
/// <summary>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.</summary>
public static bool Ensure(IEnumerable<Scene> scenes)
{
var changed = false;
foreach (var scene in scenes)
{
var existing = scene.Elements.OfType<Source>().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;
}
}
+1
View File
@@ -439,6 +439,7 @@ public partial class MainViewModel
}
SeedOnAirImage();
EnsureBrandLogos();
EnsureScoreboards();
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
OnPropertyChanged(nameof(LayerCount));
}
+12
View File
@@ -45,6 +45,18 @@ public partial class MainViewModel
RefreshScoreboardPreviews();
}
/// <summary>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.</summary>
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)
+53
View File
@@ -0,0 +1,53 @@
using System.Collections.Generic;
using System.Linq;
using Xunit;
using ytLive.Models;
namespace ytLive.Tests;
/// <summary>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.</summary>
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<Scene>
{
SceneWith(SceneCatalog.Starting), SceneWith(SceneCatalog.Live), SceneWith(SceneCatalog.Brb),
};
Assert.True(ScoreboardHealer.Ensure(scenes));
foreach (var scene in scenes)
{
var boards = scene.Elements.OfType<Source>().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<Source>().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<Source>().Where(s => s.IsScoreboard));
}
}