YouTube Chat layer: one-per-layout gate + legacy label heal (TASK 27)

This commit is contained in:
2026-08-24 09:08:03 -07:00
parent ef710c5537
commit 708b336da7
6 changed files with 169 additions and 27 deletions
+17 -25
View File
@@ -1,44 +1,36 @@
# HANDOFF — Session State
## Branch
`task26-webcam-menu-gate` @ working tree — **code + docs complete, NOT yet committed** (commit on your word). Parent: `task25-backgrounds` @ `7dd140a`, committed and pushed, clean.
`task26-webcam-menu-gate` @ `ef710c5` — TASK 26 committed (not pushed). Working tree now carries **TASK 27, NOT yet committed** (commit on your word). Parent chain: `task25-backgrounds` @ `7dd140a` (pushed), still unmerged to main.
## Shipped: TASK 27 — YouTube Chat layer gate + legacy label heal
Creator follow-up to TASK 26: "do the same thing with the YouTube chat layer" + make the Layers-row label match the (+) picklist.
1. **Gate:** `CanAddYouTubeChat` = `StagedScene != null && !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox))` — one chat layer per layout; the (+) item greys out while any scene carries one. Raised in the StagedScene setter + `OnElementsChanged`; `AddSource` refuses a second as defense in depth. XAML `IsEnabled` + tooltip "One chat layer at a time — it's already in your stream".
2. **Label heal:** layers added by commit `65641d8` were named "Chat"; LoadLayout heals exactly that un-renamed default to "YouTube Chat" so the Layers row matches the picklist. Creator renames untouched; idempotent.
3. **Test (Good Dog Rule — ONE):** `ChatLayerGateTests.LegacyChatName_Heals_And_CanAddYouTubeChat_Gates_On_The_Existing_Layer` — real window + temp DB with a legacy "Chat" ChatBox row in Starting: healed on load, persisted on save, greyed cross-scene + in-scene, re-enabled after `RemoveSourceCommand`.
4. **Suite: 225 total, 224 pass** — only failure is the known pre-existing `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`. Build 0 warnings.
5. **Docs same commit:** ai.md (chat rule + heal next to the webcam gate), TASKS.md TASK 3 row 28, this file.
## Shipped: TASK 26 — webcam (+) menu gates on the app-wide identity
Creator's visual pass of TASK 25 came back green except one item: the (+) menu's Webcam row wasn't
greyed out when a webcam was already deployed in another scene. Root cause: `CanAddWebcamToStagedScene`
only checked the staged scene's config; the truth is the `_webcam` singleton.
1. **Renamed + regated:** `CanAddWebcamToStagedScene` → **`CanAddWebcam`** =
`StagedScene != null && _webcam == null` (`MainViewModel.cs:308`). Raised at all five sites:
StagedScene setter, `ReacquireWebcam` (layout load), `OnElementsChanged`, `_webcam` create
(`AddWebcamToStagedSceneAsync`), last-config removal (`RemoveElement`).
2. **XAML:** the Webcam MenuItem's `IsEnabled` binding renamed to match; tooltip verbatim.
3. **Test (Good Dog Rule — ONE):** `WebcamMenuGateTests.CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity`
— real window + temp DB seeded with a `Webcam` row + one config in Starting; asserts greyed while
Live is staged (the old gate's false positive), greyed while Starting staged, re-enabled after
`RemoveSourceCommand` drops the last config, and zero `Webcam` rows after save.
4. **Suite: 224 total, 223 pass.** The only failure is the known pre-existing
`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` (unrelated).
Build 0 warnings.
5. **Docs same commit:** ai.md (gate rule + rename; dropped the stale "empty-canvas right-click
Show Webcam" claim — that XAML never shipped), TASKS.md TASK 3 row 27, this file.
6. **Dead code flagged, not touched:** `ShowWebcamCommand` / `CanShowWebcamInStagedScene` are wired
in the VM but bound nowhere — audit item, separate branch if wanted.
`CanAddWebcamToStagedScene` → **`CanAddWebcam`** = `StagedScene != null && _webcam == null` (`MainViewModel.cs:308`), raised at StagedScene setter / `ReacquireWebcam` / `OnElementsChanged` / `_webcam` create / last-config removal; XAML binding renamed, tooltip verbatim. ONE integration test `WebcamMenuGateTests.CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity`. Committed `ef710c5` with ai.md/TASKS row 27/HANDOFF docs.
## ⚠️ Landmines
- **User verdict (2026-08-23):** "The AI wrote fucked up code after repeatedly and intentionally misunderstanding instructions." Extends the ai.md 2026-08-22 audit flag — trust nothing from those sessions without manual verification.
- **Pre-existing test failure is ONE** (`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`) — not caused by this branch.
- **Pre-existing test failure is ONE** (`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`) — not caused by these branches.
- **Real-MainWindow tests MUST set `LayoutPathOverride`** (ai.md) — DB pollution wiped real data once.
- **`Source.Type` setter derives `IsBackground`** (Models/Source.cs:40) — any Type change must re-set IsBackground after.
- **vstest quirk:** only one `--tests:` param allowed; plain substrings work, alternation regexes silently match nothing.
- Notification.Wpf gotchas (ai.md/TASKS.md): area routing by XAML `Name`; unknown AreaName silently drops; `NeverExpires()` = `TimeSpan.MaxValue`; `ToHex()` = `#AARRGGBB`.
- **Dead code flagged, not touched:** `ShowWebcamCommand` / `CanShowWebcamInStagedScene` are wired in the VM but bound nowhere — audit item, separate branch if wanted.
## Next steps
1. Commit this branch (code + docs, one commit), then merge/PR per Good Dog Rule (one integration test ✓).
2. Creator visual pass on Windows: add a webcam to any scene → the (+) menu's Webcam row greys out everywhere; remove it → row re-enables.
3. Then merge `task25-backgrounds` → main (still pending from last session), relaunch app once so the real `%APPDATA%` DB self-heals.
1. Commit TASK 27 (code + docs, one commit), push both branches.
2. Creator visual pass on Windows: add YouTube Chat to any scene → (+) item greys everywhere; legacy "Chat"-named row (if present) shows as "YouTube Chat"; remove → re-enables.
3. Merge `task25-backgrounds` + `task26-webcam-menu-gate` + this branch to main per Good Dog Rule (one integration test each ✓); relaunch app once so the real `%APPDATA%` DB self-heals (backgrounds + chat label).
## Build
```bash
+2 -1
View File
@@ -286,7 +286,8 @@
<MenuItem Header="Web" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.WebSource}"
ToolTip="Embedded web page — set URI in Properties panel"/>
<MenuItem Header="YouTube Chat" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.ChatBox}"
ToolTip="YouTube live chat overlay on stream"/>
IsEnabled="{Binding CanAddYouTubeChat}"
ToolTip="One chat layer at a time — it's already in your stream"/>
</ContextMenu>
</Button.ContextMenu>
<Path Data="M12,4 L12,20 M4,12 L20,12" Stroke="#e94560" StrokeThickness="2"
+1
View File
@@ -129,6 +129,7 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
21. ✅ **Logo + richer in-app About (2026-08-13, queued → 2026-08-14 SHIPPED)** — the ytLlive wordmark in the top bar opens the About overlay (already wired); the About overlay is now the **creator hub**: the real logo (the "llama fortnite superman logo" from the creator's vault, copied to `Assets/llama-logo.png` — the creator's own art, no third-party license), plus **creator-hub links** — llama chile shop on YouTube (`MainViewModel.ChannelUrl`), Mastodon (`https://mastodon.llamachile.tube/@gramps`), **Buy me a coffee** (`https://buymeacoffee.com/llamachiley` — live), and **Unlock Premium** (greyed "coming soon" — the **billing** product URL is a tabled seam, `PremiumUrl`, until TASK 10 picks the subscription provider). A **Licenses & legal** button flips the About overlay to an in-app scrolling panel that loads the full `THIRD-PARTY-NOTICES.txt` text (`MainViewModel.ShowLicenses` reads the shipped file from the executable directory on first open; graceful "not found" fallback — **never the OS viewer, everything stays in-app**); "← Back to About" returns. The About overlay is also the in-app home of the notices — the top-bar About button that opened the file in the OS viewer was removed on 2026-08-13 for exactly this reason. Integration test (Good Dog Rule — ONE): `AboutHubTests.About_Opens_InApp_Licensing_Loads_Shipped_Notices` drives the real window + VM, asserting the hub opens, the link URLs are real, the licensing panel loads the shipped notices text (contains "Third-Party Notices" + "LGPL"), and back returns to the hub. 174 tests passing, 0 warnings
22. ✅ **Voice filters on the mic channel (2026-08-13 queued → SHIPPED 2026-08-14 inside TASK 9, the audio milestone)** — the standard four applied to the sound input path (before the meter/encoder mix): **bass boost, treble, noise suppression, compressor** (set decided with the creator 2026-08-13). Noise suppression = a **pure-C# noise gate** (creator chose over RNNoise / a second ffmpeg `afftdn` pipe, 2026-08-14 — KISS). Always-on — no UI knobs; the mic stays the creator's single audio control
27. ✅ **Webcam row gates on the app-wide identity (2026-08-24, TASK 26)** — the (+) menu's Webcam item greys out whenever a webcam exists **anywhere** (`CanAddWebcam` = `StagedScene != null && _webcam == null`; renamed from `CanAddWebcamToStagedScene` whose per-scene rule let a second picker run from a scene lacking the config), raised at both `_webcam` mutation sites (create / last-config removal) + scene staging + elements change. Creator's visual pass found it: minis/no-capture-rows/capture-controls all good. ONE integration test `WebcamMenuGateTests.CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity` (real window + temp DB seeded with a webcam in Starting; asserts greyed while Live staged, re-enabled after `RemoveSourceCommand` clears the last config + the `Webcam` DB row). Stale map fixed in the same commit: ai.md's "empty-canvas right-click Show Webcam" claim dropped — that XAML never shipped (`ShowWebcamCommand`/`CanShowWebcamInStagedScene` are wired but unbound dead code, audit item). 224 tests (223 pass; the pre-existing AudioPipelineTests failure is unrelated)
28. ✅ **YouTube Chat layer: one-per-layout gate + legacy label heal (2026-08-24, TASK 27)** — same rule as TASK 26 applied to the chat layer: the (+) menu's YouTube Chat item greys out while any scene carries a ChatBox source (`CanAddYouTubeChat`, raised on staging + elements change; `AddSource` refuses a second), tooltip "One chat layer at a time — it's already in your stream". Plus the creator's label fix: layers added by commit `65641d8` were named "Chat"; LoadLayout now heals exactly that un-renamed default to "YouTube Chat" so the Layers row matches the (+) picklist (creator renames untouched, idempotent). ONE integration test `ChatLayerGateTests.LegacyChatName_Heals_And_CanAddYouTubeChat_Gates_On_The_Existing_Layer` (real window + temp DB with a legacy "Chat" row: healed on load + persisted on save, greyed cross-scene and in-scene, re-enabled after remove). 225 tests (224 pass; pre-existing AudioPipelineTests failure unrelated)
### The Minimal Source Set (design decision — do not expand casually)
+20 -1
View File
@@ -213,6 +213,7 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
OnPropertyChanged(nameof(CanChangeBackground));
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanAddYouTubeChat));
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
OnPropertyChanged(nameof(IsCountdownAvailable));
UpdateActiveBackground();
@@ -307,6 +308,12 @@ public class MainViewModel : ViewModelBase
/// one camera identity app-wide; re-adding always opens the picker.</summary>
public bool CanAddWebcam => StagedScene != null && _webcam == null;
/// <summary>The Add → YouTube Chat menu item: enabled only while no chat layer exists
/// anywhere — one chat overlay per layout.</summary>
public bool CanAddYouTubeChat
=> StagedScene != null
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox));
// ─── Transitions (TASK 19/23) ───
public TransitionType SelectedTransitionType
@@ -1770,6 +1777,15 @@ public class MainViewModel : ViewModelBase
ClampWebcamToBounds(config, scene.Name);
HealLegacySquareRect(config);
}
// One-time heal: ChatBox layers created before the "YouTube Chat"
// default still carry the old "Chat" name — creator renames untouched.
foreach (var scene in Scenes)
foreach (var source in scene.Elements.OfType<Source>())
if (source.Type == SourceType.ChatBox
&& string.Equals(source.Name, "Chat", StringComparison.OrdinalIgnoreCase))
source.Name = "YouTube Chat";
AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded");
}
finally
@@ -2264,6 +2280,7 @@ public class MainViewModel : ViewModelBase
foreach (SceneElement element in e.OldItems)
element.PropertyChanged -= OnElementPropertyChanged;
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanAddYouTubeChat));
ScheduleSave();
}
@@ -2383,9 +2400,11 @@ public class MainViewModel : ViewModelBase
// The (+) menu only adds regular layers. The Background is a locked
// per-screen layer managed by EnsureBackground/NormalizeBackgrounds —
// never user-created, never deleted.
// never user-created, never deleted. YouTube Chat is one-per-layout.
if (sourceType is SourceType.DisplayCapture or SourceType.WindowCapture or SourceType.Background)
return;
if (sourceType == SourceType.ChatBox && !CanAddYouTubeChat)
return;
var source = new Source
{
+5
View File
@@ -318,6 +318,11 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
TASK 26; the old per-staged-scene gate let a second picker run from a scene that lacked the config).
**Removing the last webcam config anywhere clears the identity** (`_webcam = null`, raising
`CanAddWebcam`/`CanChangeWebcam`), which also drops "Change Webcam…" and re-enables Add.
The **YouTube Chat layer follows the same one-per-layout rule** (TASK 27):
`CanAddYouTubeChat` greys out the (+) item while any scene carries a ChatBox source (raised on
staging + elements change; `AddSource` refuses a second as defense in depth). Chat layers created
before the "YouTube Chat" default still carried the legacy name "Chat" (commit `65641d8` era) —
LoadLayout heals exactly that un-renamed default to "YouTube Chat"; creator renames are untouched.
- **Round→rect restores the aspect (persisted, schema v4):** `SceneElement.ToggleClipShape()`
snapshots the rectangular Width/Height into public `RectWidth`/`RectHeight` before going Round and
restores them when switching back — otherwise the Round resize lock (square) would leave a square
+124
View File
@@ -0,0 +1,124 @@
using System;
using System.IO;
using System.Linq;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// TASK 27 integration: the (+) menu's YouTube Chat row gates on one chat layer
/// per layout (same rule as the webcam's app-wide identity), and ChatBox layers
/// created before the "YouTube Chat" default (legacy name "Chat") heal to the
/// picklist's label on load — creator renames stay untouched.
/// Real-App + temp-DB pattern (see WebcamMenuGateTests).
/// </summary>
[Collection("RealApp")]
public sealed class ChatLayerGateTests
{
[Fact]
public void LegacyChatName_Heals_And_CanAddYouTubeChat_Gates_On_The_Existing_Layer()
{
_app.Run(Run);
}
private readonly RealAppHost _app;
public ChatLayerGateTests(RealAppHost app) => _app = app;
private void Run()
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-chatgate-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
var window = default(MainWindow);
try
{
using (var schema = new ytLive.Services.LayoutStore(tempDb)) { }
SqliteConnection.ClearAllPools();
SeedDatabaseWithLegacyChatLayer(tempDb);
window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
var starting = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Starting));
var live = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
var chatLayer = Assert.Single(starting.Elements.OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox).ToList());
Assert.Equal("YouTube Chat", chatLayer.Name);
vm.StagedScene = live;
Assert.False(vm.CanAddYouTubeChat,
"chat layer exists in another scene — Add YouTube Chat must be greyed out");
vm.StagedScene = starting;
Assert.False(vm.CanAddYouTubeChat, "staged scene itself has the chat layer");
vm.SaveLayoutNow();
using (var connection = new SqliteConnection($"Data Source={tempDb}"))
{
connection.Open();
using var names = connection.CreateCommand();
names.CommandText =
"SELECT Name FROM Source WHERE Type = 'ChatBox';";
Assert.Equal("YouTube Chat", (string)names.ExecuteScalar()!);
}
vm.RemoveSourceCommand.Execute(chatLayer);
Assert.True(vm.CanAddYouTubeChat,
"the only chat layer was removed — the (+) item re-enables");
}
finally
{
window?.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
/// <summary>Five canonical scenes + a ChatBox layer still carrying the
/// pre-rename default name "Chat" in Starting.</summary>
private static void SeedDatabaseWithLegacyChatLayer(string path)
{
using var connection = new SqliteConnection($"Data Source={path}");
connection.Open();
var scenes = new (string Id, string Name)[]
{
("scene-starting", "Starting"),
("scene-live", "Live"),
("scene-brb", "BRB"),
("scene-chat", "Chat"),
("scene-ending", "Ending"),
};
foreach (var (id, name) in scenes)
{
using var cmd = connection.CreateCommand();
cmd.CommandText =
"INSERT INTO Scene (Id, Name, HasBackground, SortOrder) VALUES ($id, $name, 1, $order);";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$name", name);
cmd.Parameters.AddWithValue("$order", Array.IndexOf(scenes, (id, name)));
cmd.ExecuteNonQuery();
}
using (var background = connection.CreateCommand())
{
background.CommandText =
@"INSERT INTO Source (Id, SceneId, Type, Name, IsEnabled, X, Y, Width, Height, IsBackground, SortOrder)
VALUES ('bg-1', 'scene-starting', 'Background', 'Background', 1, 0, 0, 1920, 1080, 1, 0);";
background.ExecuteNonQuery();
}
using (var chat = connection.CreateCommand())
{
chat.CommandText =
@"INSERT INTO Source (Id, SceneId, Type, Name, IsEnabled, X, Y, Width, Height, IsBackground, SortOrder)
VALUES ('chat-1', 'scene-starting', 'ChatBox', 'Chat', 1, 1400, 20, 500, 648, 0, 1);";
chat.ExecuteNonQuery();
}
}
}