Fix logo-click crash (Run is not a Visual); Text tab always visible; avatar initial fallback
This commit is contained in:
@@ -26,6 +26,8 @@
|
|||||||
- **`Source.Type` setter derives `IsBackground`** (Models/Source.cs:40) — any Type change must re-set IsBackground after.
|
- **`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.
|
- **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`.
|
- 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.
|
- **Dead code flagged, not touched:** `ShowWebcamCommand` / `CanShowWebcamInStagedScene` wired but unbound — audit item.
|
||||||
|
|
||||||
## Next steps
|
## Next steps
|
||||||
|
|||||||
+9
-4
@@ -149,12 +149,18 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</Ellipse.Style>
|
</Ellipse.Style>
|
||||||
</Ellipse>
|
</Ellipse>
|
||||||
<!-- Avatar (connected only) -->
|
<!-- Avatar (connected only); shows the channel initial when
|
||||||
|
the profile image is missing or fails to download -->
|
||||||
<Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True"
|
<Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True"
|
||||||
Margin="0,0,8,0" VerticalAlignment="Center"
|
Margin="0,0,8,0" VerticalAlignment="Center"
|
||||||
ToolTip="{Binding AccountDisplayName}"
|
ToolTip="{Binding AccountDisplayName}"
|
||||||
Visibility="{Binding IsConnected, Converter={StaticResource BoolToVis}}">
|
Visibility="{Binding IsConnected, Converter={StaticResource BoolToVis}}">
|
||||||
|
<Grid>
|
||||||
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
|
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
|
||||||
|
<TextBlock x:Name="AvatarInitial" Foreground="#e0e0e0" FontSize="13"
|
||||||
|
FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Visibility="Collapsed"/>
|
||||||
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
<!-- Log In button (disconnected) -->
|
<!-- Log In button (disconnected) -->
|
||||||
<Button Content="Log In" Style="{StaticResource YtButton}"
|
<Button Content="Log In" Style="{StaticResource YtButton}"
|
||||||
@@ -1114,12 +1120,11 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- ═══ BROADCAST TEXT PULL-OUT (Live screen only) ═══
|
<!-- ═══ BROADCAST TEXT PULL-OUT (always visible) ═══
|
||||||
White "Text" tab on the preview's right edge; clicking it
|
White "Text" tab on the preview's right edge; clicking it
|
||||||
slides out the broadcast-metadata form. Edits persist
|
slides out the broadcast-metadata form. Edits persist
|
||||||
locally immediately; Update pushes liveBroadcasts.update. -->
|
locally immediately; Update pushes liveBroadcasts.update. -->
|
||||||
<Grid Grid.Row="0"
|
<Grid Grid.Row="0">
|
||||||
Visibility="{Binding IsBroadcastMetadataVisible, Converter={StaticResource BoolToVis}}">
|
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Stretch">
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Stretch">
|
||||||
<Border Background="#16213e" ClipToBounds="True">
|
<Border Background="#16213e" ClipToBounds="True">
|
||||||
<Border.Style>
|
<Border.Style>
|
||||||
|
|||||||
+21
-7
@@ -135,25 +135,36 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void LoadAvatarImage()
|
private void LoadAvatarImage()
|
||||||
{
|
{
|
||||||
if (_viewModel.IsConnected && !string.IsNullOrWhiteSpace(_viewModel.AccountAvatarUrl))
|
var url = _viewModel.AccountAvatarUrl;
|
||||||
|
if (_viewModel.IsConnected && !string.IsNullOrWhiteSpace(url))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var bitmap = new BitmapImage();
|
var bitmap = new BitmapImage();
|
||||||
bitmap.BeginInit();
|
bitmap.BeginInit();
|
||||||
bitmap.UriSource = new Uri(_viewModel.AccountAvatarUrl, UriKind.Absolute);
|
bitmap.UriSource = new Uri(url, UriKind.Absolute);
|
||||||
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
||||||
bitmap.EndInit();
|
bitmap.EndInit();
|
||||||
bitmap.Freeze();
|
bitmap.Freeze();
|
||||||
AvatarImage.Source = bitmap;
|
AvatarImage.Source = bitmap;
|
||||||
|
AvatarInitial.Visibility = Visibility.Collapsed;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
catch { AvatarImage.Source = null; }
|
catch (Exception ex)
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
AvatarImage.Source = null;
|
AppLog.Write($"Avatar load failed ({url}): {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (_viewModel.IsConnected)
|
||||||
|
{
|
||||||
|
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)
|
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)
|
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
||||||
{
|
{
|
||||||
while (child != null && !ReferenceEquals(child, ancestor))
|
while (child != null && !ReferenceEquals(child, ancestor))
|
||||||
child = VisualTreeHelper.GetParent(child);
|
{
|
||||||
|
var parent = VisualTreeHelper.GetParent(child);
|
||||||
|
child = parent ?? LogicalTreeHelper.GetParent(child);
|
||||||
|
}
|
||||||
return child != null;
|
return child != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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)
|
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)
|
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)
|
### The Minimal Source Set (design decision — do not expand casually)
|
||||||
|
|
||||||
|
|||||||
@@ -463,7 +463,6 @@ public class MainViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(IsOffline));
|
OnPropertyChanged(nameof(IsOffline));
|
||||||
OnPropertyChanged(nameof(IsLive));
|
OnPropertyChanged(nameof(IsLive));
|
||||||
OnPropertyChanged(nameof(IsEditMode));
|
OnPropertyChanged(nameof(IsEditMode));
|
||||||
OnPropertyChanged(nameof(IsBroadcastMetadataVisible));
|
|
||||||
OnPropertyChanged(nameof(RecDotBrush));
|
OnPropertyChanged(nameof(RecDotBrush));
|
||||||
OnPropertyChanged(nameof(RecTextBrush));
|
OnPropertyChanged(nameof(RecTextBrush));
|
||||||
OnPropertyChanged(nameof(RecDotOpacity));
|
OnPropertyChanged(nameof(RecDotOpacity));
|
||||||
@@ -1247,9 +1246,6 @@ public class MainViewModel : ViewModelBase
|
|||||||
/// succeeds. Gates the pull-out's Update button (nothing remote to patch).</summary>
|
/// succeeds. Gates the pull-out's Update button (nothing remote to patch).</summary>
|
||||||
public string? CurrentBroadcastId => _currentBroadcastId;
|
public string? CurrentBroadcastId => _currentBroadcastId;
|
||||||
|
|
||||||
/// <summary>The pull-out exists on the Live screen only — visible while streaming.</summary>
|
|
||||||
public bool IsBroadcastMetadataVisible => IsLive;
|
|
||||||
|
|
||||||
public Services.LiveBroadcastFormViewModel BroadcastForm { get; }
|
public Services.LiveBroadcastFormViewModel BroadcastForm { get; }
|
||||||
|
|
||||||
// Window geometry pass-through (code-behind owns the WPF specifics).
|
// Window geometry pass-through (code-behind owns the WPF specifics).
|
||||||
|
|||||||
@@ -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)
|
### 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):
|
**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
|
- Title / Description / Tags (comma-separated string → `snippet.tags[]`) / Visibility ComboBox (private/unlisted/public) / Made-for-Kids CheckBox — all live-editable pre- and post-launch
|
||||||
|
|||||||
@@ -76,10 +76,11 @@ public sealed class BroadcastPullOutTests
|
|||||||
vm.BroadcastForm.Load();
|
vm.BroadcastForm.Load();
|
||||||
Assert.Equal("Hello", vm.BroadcastForm.Title);
|
Assert.Equal("Hello", vm.BroadcastForm.Title);
|
||||||
|
|
||||||
vm.StreamStatus = StreamStatus.Streaming;
|
Assert.False(vm.BroadcastForm.IsDrawerOpen);
|
||||||
Assert.True(vm.IsBroadcastMetadataVisible);
|
vm.BroadcastForm.OpenDrawerCommand.Execute(null);
|
||||||
vm.StreamStatus = StreamStatus.Offline;
|
Assert.True(vm.BroadcastForm.IsDrawerOpen);
|
||||||
Assert.False(vm.IsBroadcastMetadataVisible);
|
vm.BroadcastForm.CloseDrawerCommand.Execute(null);
|
||||||
|
Assert.False(vm.BroadcastForm.IsDrawerOpen);
|
||||||
|
|
||||||
using (var writer = new LayoutStore(tempDb))
|
using (var writer = new LayoutStore(tempDb))
|
||||||
writer.SaveWindowState(100, 50, 1500, 900, "Normal");
|
writer.SaveWindowState(100, 50, 1500, 900, "Normal");
|
||||||
|
|||||||
Reference in New Issue
Block a user