diff --git a/HANDOFF.md b/HANDOFF.md
index 7b41014..fc3330b 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -26,6 +26,8 @@
- **`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`.
+- **Text-tab crash fix (2026-08-24):** clicking any text `Run` (e.g. the LlamaCasty logo) crashed the app — `Window_PreviewMouseLeftButtonDown → IsDescendantOf` used only `VisualTreeHelper.GetParent`, which throws on non-Visuals; now falls back to `LogicalTreeHelper.GetParent`. Landmine for ANY new hit-testing code: text Runs are not Visuals.
+- **Avatar display:** connected-but-image-missing now shows the channel initial in the circle + logs to startup.log (`Avatar load failed` / `connected but no profile image URL`). If the creator reports "no avatar", first check whether the app is actually CONNECTED (green status light vs Log In button) — ending a stream clears the OAuth session by design.
- **Dead code flagged, not touched:** `ShowWebcamCommand` / `CanShowWebcamInStagedScene` wired but unbound — audit item.
## Next steps
diff --git a/MainWindow.xaml b/MainWindow.xaml
index 9c70d65..2818cc4 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -149,12 +149,18 @@
-
+
-
+
+
+
+
-
-
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 30b8b79..35d637a 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -135,24 +135,35 @@ public partial class MainWindow : Window
private void LoadAvatarImage()
{
- if (_viewModel.IsConnected && !string.IsNullOrWhiteSpace(_viewModel.AccountAvatarUrl))
+ var url = _viewModel.AccountAvatarUrl;
+ if (_viewModel.IsConnected && !string.IsNullOrWhiteSpace(url))
{
try
{
var bitmap = new BitmapImage();
bitmap.BeginInit();
- bitmap.UriSource = new Uri(_viewModel.AccountAvatarUrl, UriKind.Absolute);
+ bitmap.UriSource = new Uri(url, UriKind.Absolute);
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.EndInit();
bitmap.Freeze();
AvatarImage.Source = bitmap;
+ AvatarInitial.Visibility = Visibility.Collapsed;
+ return;
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Avatar load failed ({url}): {ex.Message}");
}
- catch { AvatarImage.Source = null; }
}
- else
+ else if (_viewModel.IsConnected)
{
- AvatarImage.Source = null;
+ AppLog.Write("Avatar: connected but no profile image URL on the session");
}
+ AvatarImage.Source = null;
+ AvatarInitial.Text = string.IsNullOrWhiteSpace(_viewModel.AccountDisplayName)
+ ? "?"
+ : _viewModel.AccountDisplayName.Substring(0, 1).ToUpperInvariant();
+ AvatarInitial.Visibility = _viewModel.IsConnected ? Visibility.Visible : Visibility.Collapsed;
}
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
@@ -311,7 +322,10 @@ public partial class MainWindow : Window
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
{
while (child != null && !ReferenceEquals(child, ancestor))
- child = VisualTreeHelper.GetParent(child);
+ {
+ var parent = VisualTreeHelper.GetParent(child);
+ child = parent ?? LogicalTreeHelper.GetParent(child);
+ }
return child != null;
}
diff --git a/TASKS.md b/TASKS.md
index 7875b4f..b4f42f6 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -130,7 +130,7 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
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)
-29. ✅ **Broadcast metadata pull-out + launch geometry (2026-08-24)** — creator ask: "a tab-pullout on the right side of the preview pane — white tab, YouTube-red label reading 'Text' — opening a form with all settable liveBroadcast fields; fields that can't be filled before launch greyed out; saving/updating remote content; field data saved and pre-loaded on app run". Shipped as specified with one correction: the API is the inverse of "more fields once live" for contentDetails (those lock in created/ready) — the pull-out carries the always-editable snippet/status set: Title, Description, Tags (csv), Visibility (private/unlisted/public), Made-for-Kids, plus read-only Scheduled Start. **Live screen only** (`IsBroadcastMetadataVisible => IsLive`). White 30px tab, rotated red "Text", right edge vertically centered; click slides a dark 320px drawer left over the preview (200ms CubicEase). Every edit persists to Settings keys (`Broadcast.*`) immediately; **Update Broadcast** button calls the new `YouTubeStreamService.UpdateBroadcast` (PUT `liveBroadcasts?part=snippet,status`, echoes scheduledStartTime because update replaces the whole snippet part). Go Live prefills from the form and captures what was inserted (`CaptureGoLive`). The old un-persisted Default Stream Title/Description fields came OFF the App Settings overlay (replaced by the form); `DefaultStreamTitle/Description` VM properties deleted. **Launch geometry** (same unit): default 1920×1040, MinWidth 1366, MinHeight 768; `WindowStartupLocation=Manual` + window size/position persisted on close via `RestoreBounds` (maximized-safe), restored in ctor clamped to minimums and the primary work area (disconnected-secondary fallback). ONE integration test `BroadcastPullOutTests.Metadata_Persists_WindowRestores_Clamped_And_UpdatePatchesRemote`. 228 tests (227 pass; pre-existing AudioPipelineTests failure unrelated)
+29. ✅ **Broadcast metadata pull-out + launch geometry (2026-08-24)** — creator ask: "a tab-pullout on the right side of the preview pane — white tab, YouTube-red label reading 'Text' — opening a form with all settable liveBroadcast fields; fields that can't be filled before launch greyed out; saving/updating remote content; field data saved and pre-loaded on app run". Shipped as specified with one correction: the API is the inverse of "more fields once live" for contentDetails (those lock in created/ready) — the pull-out carries the always-editable snippet/status set: Title, Description, Tags (csv), Visibility (private/unlisted/public), Made-for-Kids, plus read-only Scheduled Start. **Always visible** (creator revised same day from Live-only gating). White 30px tab, rotated red "Text", right edge vertically centered; click slides a dark 320px drawer left over the preview (200ms CubicEase). Every edit persists to Settings keys (`Broadcast.*`) immediately; **Update Broadcast** button calls the new `YouTubeStreamService.UpdateBroadcast` (PUT `liveBroadcasts?part=snippet,status`, echoes scheduledStartTime because update replaces the whole snippet part). Go Live prefills from the form and captures what was inserted (`CaptureGoLive`). The old un-persisted Default Stream Title/Description fields came OFF the App Settings overlay (replaced by the form); `DefaultStreamTitle/Description` VM properties deleted. **Launch geometry** (same unit): default 1920×1040, MinWidth 1366, MinHeight 768; `WindowStartupLocation=Manual` + window size/position persisted on close via `RestoreBounds` (maximized-safe), restored in ctor clamped to minimums and the primary work area (disconnected-secondary fallback). ONE integration test `BroadcastPullOutTests.Metadata_Persists_WindowRestores_Clamped_And_UpdatePatchesRemote`. 228 tests (227 pass; pre-existing AudioPipelineTests failure unrelated)
### The Minimal Source Set (design decision — do not expand casually)
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 6633403..8d21586 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -463,7 +463,6 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(IsOffline));
OnPropertyChanged(nameof(IsLive));
OnPropertyChanged(nameof(IsEditMode));
- OnPropertyChanged(nameof(IsBroadcastMetadataVisible));
OnPropertyChanged(nameof(RecDotBrush));
OnPropertyChanged(nameof(RecTextBrush));
OnPropertyChanged(nameof(RecDotOpacity));
@@ -1247,9 +1246,6 @@ public class MainViewModel : ViewModelBase
/// succeeds. Gates the pull-out's Update button (nothing remote to patch).
public string? CurrentBroadcastId => _currentBroadcastId;
- /// The pull-out exists on the Live screen only — visible while streaming.
- public bool IsBroadcastMetadataVisible => IsLive;
-
public Services.LiveBroadcastFormViewModel BroadcastForm { get; }
// Window geometry pass-through (code-behind owns the WPF specifics).
diff --git a/ai.md b/ai.md
index b59c8f9..e2dfcbc 100644
--- a/ai.md
+++ b/ai.md
@@ -195,7 +195,7 @@ The app is a **director's control surface**, not an editor. This replaces the OB
### Broadcast metadata pull-out + launch geometry (2026-08-24)
-**The "Text" tab** (Live screen only): a white 30px tab with rotated YouTube-red "Text" sits on the right edge of the preview, vertically centered, visible only while `IsLive` (`IsBroadcastMetadataVisible`). Click slides out a 320px dark drawer over the preview's right edge (200ms CubicEase on `Width`, `BroadcastForm.IsDrawerOpen` drives it; ✕ closes).
+**The "Text" tab** (always visible, 2026-08-24 revision): a white 30px tab with rotated YouTube-red "Text" sits on the right edge of the preview, vertically centered — **shown regardless of login/stream state** (creator decision; original IsLive gating removed same day). Click slides out a 320px dark drawer over the preview's right edge (200ms CubicEase on `Width`, `BroadcastForm.IsDrawerOpen` drives it; ✕ closes). The Update button still greys until `CurrentBroadcastId` exists.
**Fields — the always-editable snippet/status set** (⚠️ creator premise inverted: contentDetails fields like latency/DVR/embed are editable ONLY in created/ready, NOT while live; they are deliberately absent):
- Title / Description / Tags (comma-separated string → `snippet.tags[]`) / Visibility ComboBox (private/unlisted/public) / Made-for-Kids CheckBox — all live-editable pre- and post-launch
diff --git a/ytLive.Tests/BroadcastPullOutTests.cs b/ytLive.Tests/BroadcastPullOutTests.cs
index e2a0b84..7e8f367 100644
--- a/ytLive.Tests/BroadcastPullOutTests.cs
+++ b/ytLive.Tests/BroadcastPullOutTests.cs
@@ -76,10 +76,11 @@ public sealed class BroadcastPullOutTests
vm.BroadcastForm.Load();
Assert.Equal("Hello", vm.BroadcastForm.Title);
- vm.StreamStatus = StreamStatus.Streaming;
- Assert.True(vm.IsBroadcastMetadataVisible);
- vm.StreamStatus = StreamStatus.Offline;
- Assert.False(vm.IsBroadcastMetadataVisible);
+ Assert.False(vm.BroadcastForm.IsDrawerOpen);
+ vm.BroadcastForm.OpenDrawerCommand.Execute(null);
+ Assert.True(vm.BroadcastForm.IsDrawerOpen);
+ vm.BroadcastForm.CloseDrawerCommand.Execute(null);
+ Assert.False(vm.BroadcastForm.IsDrawerOpen);
using (var writer = new LayoutStore(tempDb))
writer.SaveWindowState(100, 50, 1500, 900, "Normal");