Compare commits

...

1 Commits

Author SHA1 Message Date
gramps 90f5ad50dd refactor: extract TopBar out of MainWindow.xaml — Phase 2, Commit 14
TopBar.xaml(.cs) takes the Row 0 top controls: logo/About button, REC/ON-AIR
pills + status signs, avatar, Sign In/Start/End buttons. The avatar fetch
(AvatarHttp/_loadedAvatarUrl/LoadAvatarImage/ShowAvatarFallback) moved into the
control with async semantics preserved; the window grabs the control via
FindName in its ctor and calls _topBar.RefreshAvatar() when IsConnected or
AccountAvatarUrl changes (previously OnViewModelPropertyChanged -> LoadAvatarImage).
AccountLight is pure binding (never touched by code-behind) so it moved with the XAML.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Also dropped the now-unused System.IO using from MainWindow.xaml.cs.
Docs: ViewModels/index.md Phase 2 tracker updated in the same commit.
2026-08-30 09:13:47 -07:00
5 changed files with 197 additions and 165 deletions
+118
View File
@@ -0,0 +1,118 @@
<UserControl x:Class="ytLive.Controls.TopBar"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="TopBarRoot">
<!-- LOGO / BRAND — clicking opens the in-app About overlay -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Logo / Brand — clicking opens the in-app About overlay -->
<Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
VerticalAlignment="Center" Margin="0,0,24,0" ToolTip="About LlamaCasty"
Command="{Binding OpenAboutCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock FontSize="22" FontWeight="Bold">
<Run Text="LlamaCas" Foreground="#e0e0e0"/><Run Text="ty" Foreground="#e94560"/>
</TextBlock>
</Button>
<!-- REC / ON-AIR pills (intent) + status signs (reality) -->
<StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- REC pill: local recording needs no sign-in -->
<TextBlock Text="REC" Foreground="#e94560" FontWeight="Bold" FontSize="12"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
IsChecked="{Binding RecordPillOn}"/>
<!-- ON-AIR pill: greyed out until signed in -->
<TextBlock Text="ON-AIR" Foreground="#e94560" FontWeight="Bold" FontSize="12"
VerticalAlignment="Center" Margin="16,0,6,0"/>
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
IsChecked="{Binding OnAirPillOn}"
IsEnabled="{Binding CanToggleOnAir}"
ToolTip="{Binding AccountStatusLightToolTip}"/>
<!-- Status signs: REC dot + ON-AIR dot -->
<Ellipse Width="12" Height="12" VerticalAlignment="Center" Margin="16,0,0,0"
Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
<TextBlock Text="REC" Style="{StaticResource StatusSignText}"/>
<!-- ON-AIR sign: gray offline, red public, green private -->
<Ellipse Width="12" Height="12" VerticalAlignment="Center" Margin="16,0,0,0"
Fill="{Binding OnAirBrush}"/>
<TextBlock Text="ON-AIR" Style="{StaticResource StatusSignText}"/>
<Border Background="#8f1f1f" CornerRadius="3" Padding="6,1" Margin="8,0,0,0"
VerticalAlignment="Center"
Visibility="{Binding IsLivePrivate, Converter={StaticResource BoolToVis}}">
<TextBlock Text="PRIVATE" FontSize="11" FontWeight="Bold" Foreground="White"/>
</Border>
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
<!-- Three-state top bar: logged-out / idle / live -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<!-- Status light -->
<Ellipse Width="8" Height="8" Margin="0,0,8,0" VerticalAlignment="Center"
ToolTip="{Binding AccountStatusLightToolTip}">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="#ef4444"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Fill" Value="#22c55e"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsLive}" Value="True">
<Setter Property="Fill" Value="#e94560"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<!-- 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"
Margin="0,0,8,0" VerticalAlignment="Center"
ToolTip="{Binding AccountDisplayName}"
Visibility="{Binding IsConnected, Converter={StaticResource BoolToVis}}">
<Grid>
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
<TextBlock x:Name="AvatarInitial" Foreground="#e0e0e0" FontSize="13"
FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed"/>
</Grid>
</Border>
<!-- Sign In button (disconnected + offline) -->
<Button Content="Sign In" Style="{StaticResource YtButton}"
Margin="0,0,12,0"
Command="{Binding StartStreamCommand}"
Visibility="{Binding ShowLogInButton, Converter={StaticResource BoolToVis}}"/>
<!-- Primary Start button (offline): dynamic label from the pills -->
<Button Content="Start" Style="{StaticResource YtButton}"
Command="{Binding StartStreamCommand}"
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="Choose Record Folder" Command="{Binding ChooseRecordFolderCommand}"/>
<Separator/>
<MenuItem Header="Change Account" Command="{Binding ChangeAccountCommand}"/>
<Separator/>
<MenuItem Header="Logout" Command="{Binding LogoutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
</Button>
<!-- End button (live OR recording) — the only button whose
label changes; "Stop Recording" while recording -->
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
Background="#333" Command="{Binding EndStreamCommand}"
Visibility="{Binding ShowEndStreamButton, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
</Grid>
</UserControl>
+72
View File
@@ -0,0 +1,72 @@
using System;
using System.IO;
using System.Net.Http;
using System.Windows.Controls;
using System.Windows.Media.Imaging;
using ytLive.Helpers;
using ytLive.ViewModels;
namespace ytLive.Controls;
public partial class TopBar : UserControl
{
private static readonly HttpClient AvatarHttp = new() { Timeout = TimeSpan.FromSeconds(15) };
private string? _loadedAvatarUrl;
public TopBar()
{
InitializeComponent();
AvatarImage.Source = null;
AvatarInitial.Visibility = System.Windows.Visibility.Collapsed;
}
public void RefreshAvatar()
{
_ = LoadAvatarImageAsync();
}
private async System.Threading.Tasks.Task LoadAvatarImageAsync()
{
var vm = DataContext as MainViewModel;
var url = vm?.AccountAvatarUrl;
if (vm is not { IsConnected: true })
{
ShowAvatarFallback(null);
return;
}
if (string.IsNullOrWhiteSpace(url))
{
AppLog.Write("Avatar: connected but no profile image URL on the session");
ShowAvatarFallback(null);
return;
}
if (url == _loadedAvatarUrl && AvatarImage.Source != null) return;
try
{
var bytes = await AvatarHttp.GetByteArrayAsync(url);
using var ms = new MemoryStream(bytes);
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.StreamSource = ms;
bitmap.EndInit();
bitmap.Freeze();
_loadedAvatarUrl = url;
AvatarImage.Source = bitmap;
AvatarInitial.Visibility = System.Windows.Visibility.Collapsed;
}
catch (Exception ex)
{
AppLog.Write($"Avatar load failed ({url}): {ex.Message}");
ShowAvatarFallback(vm?.AccountDisplayName);
}
}
private void ShowAvatarFallback(string? name)
{
_loadedAvatarUrl = null;
AvatarImage.Source = null;
AvatarInitial.Text = string.IsNullOrWhiteSpace(name) ? "?" : name.Substring(0, 1).ToUpperInvariant();
AvatarInitial.Visibility = System.Windows.Visibility.Visible;
}
}
+1 -113
View File
@@ -76,119 +76,7 @@
<!-- ═══ TOP BAR: Stream Controls ═══ --> <!-- ═══ TOP BAR: Stream Controls ═══ -->
<Border Grid.Row="0" Background="{Binding TopBarBackground}" Padding="16,10"> <Border Grid.Row="0" Background="{Binding TopBarBackground}" Padding="16,10">
<Grid> <controls:TopBar x:Name="TopBar"/>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Logo / Brand — clicking opens the in-app About overlay -->
<Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
VerticalAlignment="Center" Margin="0,0,24,0" ToolTip="About LlamaCasty"
Command="{Binding OpenAboutCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock FontSize="22" FontWeight="Bold">
<Run Text="LlamaCas" Foreground="#e0e0e0"/><Run Text="ty" Foreground="#e94560"/>
</TextBlock>
</Button>
<!-- REC / ON-AIR pills (intent) + status signs (reality) -->
<StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- REC pill: local recording needs no sign-in -->
<TextBlock Text="REC" Foreground="#e94560" FontWeight="Bold" FontSize="12"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
IsChecked="{Binding RecordPillOn}"/>
<!-- ON-AIR pill: greyed out until signed in -->
<TextBlock Text="ON-AIR" Foreground="#e94560" FontWeight="Bold" FontSize="12"
VerticalAlignment="Center" Margin="16,0,6,0"/>
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
IsChecked="{Binding OnAirPillOn}"
IsEnabled="{Binding CanToggleOnAir}"
ToolTip="{Binding AccountStatusLightToolTip}"/>
<!-- Status signs: REC dot + ON-AIR dot -->
<Ellipse Width="12" Height="12" VerticalAlignment="Center" Margin="16,0,0,0"
Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
<TextBlock Text="REC" Style="{StaticResource StatusSignText}"/>
<!-- ON-AIR sign: gray offline, red public, green private -->
<Ellipse Width="12" Height="12" VerticalAlignment="Center" Margin="16,0,0,0"
Fill="{Binding OnAirBrush}"/>
<TextBlock Text="ON-AIR" Style="{StaticResource StatusSignText}"/>
<Border Background="#8f1f1f" CornerRadius="3" Padding="6,1" Margin="8,0,0,0"
VerticalAlignment="Center"
Visibility="{Binding IsLivePrivate, Converter={StaticResource BoolToVis}}">
<TextBlock Text="PRIVATE" FontSize="11" FontWeight="Bold" Foreground="White"/>
</Border>
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
<!-- Three-state top bar: logged-out / idle / live -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<!-- Status light -->
<Ellipse Width="8" Height="8" Margin="0,0,8,0" VerticalAlignment="Center"
ToolTip="{Binding AccountStatusLightToolTip}"
x:Name="AccountLight">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="#ef4444"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Fill" Value="#22c55e"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsLive}" Value="True">
<Setter Property="Fill" Value="#e94560"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<!-- 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"
Margin="0,0,8,0" VerticalAlignment="Center"
ToolTip="{Binding AccountDisplayName}"
Visibility="{Binding IsConnected, Converter={StaticResource BoolToVis}}">
<Grid>
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
<TextBlock x:Name="AvatarInitial" Foreground="#e0e0e0" FontSize="13"
FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed"/>
</Grid>
</Border>
<!-- Sign In button (disconnected + offline) -->
<Button Content="Sign In" Style="{StaticResource YtButton}"
Margin="0,0,12,0"
Command="{Binding StartStreamCommand}"
Visibility="{Binding ShowLogInButton, Converter={StaticResource BoolToVis}}"/>
<!-- Primary Start button (offline): dynamic label from the pills -->
<Button Content="Start" Style="{StaticResource YtButton}"
Command="{Binding StartStreamCommand}"
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="Choose Record Folder" Command="{Binding ChooseRecordFolderCommand}"/>
<Separator/>
<MenuItem Header="Change Account" Command="{Binding ChangeAccountCommand}"/>
<Separator/>
<MenuItem Header="Logout" Command="{Binding LogoutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
</Button>
<!-- End button (live OR recording) — the only button whose
label changes; "Stop Recording" while recording -->
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
Background="#333" Command="{Binding EndStreamCommand}"
Visibility="{Binding ShowEndStreamButton, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
</Grid>
</Border> </Border>
<!-- ═══ MIDDLE: Main Content ═══ --> <!-- ═══ MIDDLE: Main Content ═══ -->
+4 -51
View File
@@ -1,7 +1,6 @@
using System; using System;
using System.Collections; using System.Collections;
using System.ComponentModel; using System.ComponentModel;
using System.IO;
using System.Net.Http; using System.Net.Http;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
@@ -22,6 +21,7 @@ public partial class MainWindow : Window
{ {
private readonly MainViewModel _viewModel; private readonly MainViewModel _viewModel;
private readonly Dictionary<string, Storyboard> _activeBorderAnimations = new(); private readonly Dictionary<string, Storyboard> _activeBorderAnimations = new();
private Controls.TopBar _topBar = null!;
public MainWindow() public MainWindow()
{ {
@@ -34,8 +34,8 @@ public partial class MainWindow : Window
_viewModel.ChatMessages.CollectionChanged += OnChatMessagesChanged; _viewModel.ChatMessages.CollectionChanged += OnChatMessagesChanged;
UpdateTaskbarOverlay(); UpdateTaskbarOverlay();
UpdateSelectionOverlay(); UpdateSelectionOverlay();
AvatarImage.Source = null; _topBar = (Controls.TopBar)FindName("TopBar")!;
AvatarInitial.Visibility = Visibility.Collapsed; _topBar.RefreshAvatar();
RestoreWindowGeometry(); RestoreWindowGeometry();
AppLog.Write("MainWindow ctor: end"); AppLog.Write("MainWindow ctor: end");
} }
@@ -116,7 +116,7 @@ public partial class MainWindow : Window
UpdateSelectionOverlay(); UpdateSelectionOverlay();
else if (e.PropertyName == nameof(MainViewModel.AccountAvatarUrl) else if (e.PropertyName == nameof(MainViewModel.AccountAvatarUrl)
|| e.PropertyName == nameof(MainViewModel.IsConnected)) || e.PropertyName == nameof(MainViewModel.IsConnected))
LoadAvatarImage(); _topBar.RefreshAvatar();
} }
private void MainWindow_Closing(object? sender, CancelEventArgs e) private void MainWindow_Closing(object? sender, CancelEventArgs e)
@@ -141,53 +141,6 @@ public partial class MainWindow : Window
: null; : null;
} }
private static readonly HttpClient AvatarHttp = new() { Timeout = TimeSpan.FromSeconds(15) };
private string? _loadedAvatarUrl;
private async void LoadAvatarImage()
{
var url = _viewModel.AccountAvatarUrl;
if (!_viewModel.IsConnected)
{
ShowAvatarFallback(null);
return;
}
if (string.IsNullOrWhiteSpace(url))
{
AppLog.Write("Avatar: connected but no profile image URL on the session");
ShowAvatarFallback(null);
return;
}
if (url == _loadedAvatarUrl && AvatarImage.Source != null) return;
try
{
var bytes = await AvatarHttp.GetByteArrayAsync(url);
using var ms = new MemoryStream(bytes);
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.StreamSource = ms;
bitmap.EndInit();
bitmap.Freeze();
_loadedAvatarUrl = url;
AvatarImage.Source = bitmap;
AvatarInitial.Visibility = Visibility.Collapsed;
}
catch (Exception ex)
{
AppLog.Write($"Avatar load failed ({url}): {ex.Message}");
ShowAvatarFallback(_viewModel.AccountDisplayName);
}
}
private void ShowAvatarFallback(string? name)
{
_loadedAvatarUrl = null;
AvatarImage.Source = null;
AvatarInitial.Text = string.IsNullOrWhiteSpace(name) ? "?" : name.Substring(0, 1).ToUpperInvariant();
AvatarInitial.Visibility = Visibility.Visible;
}
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{ {
if (sender is TextBox { IsVisible: true } box) if (sender is TextBox { IsVisible: true } box)
+2 -1
View File
@@ -55,7 +55,8 @@ Progress (Commit N/12 · tag):
**Phase 2 — `MainWindow.xaml` split into UserControls** (new `Controls/` folder, namespace `ytLive.Controls`): **Phase 2 — `MainWindow.xaml` split into UserControls** (new `Controls/` folder, namespace `ytLive.Controls`):
- [x] **Commit 12 · `refactor-commit-12`** — **BottomBar** → `Controls/BottomBar.xaml(.cs)` (gear menu + stream stats + quality dropdown; `GearButton_Click` moved to the control's code-behind). - [x] **Commit 12 · `refactor-commit-12`** — **BottomBar** → `Controls/BottomBar.xaml(.cs)` (gear menu + stream stats + quality dropdown; `GearButton_Click` moved to the control's code-behind).
- [x] **Commit 13 · `refactor-commit-13`** — **HealthBanner** → `Controls/HealthBanner.xaml` (Row 1). Also promoted the 7 value↔visibility converters out of `MainWindow.Window.Resources` into `Themes/Controls.xaml` so extracted controls can resolve them (app-level, not window-level). Pending: TopBar, left scene/chat panel, preview pane, overlay host. - [x] **Commit 13 · `refactor-commit-13`** — **HealthBanner** → `Controls/HealthBanner.xaml` (Row 1). Also promoted the 7 value↔visibility converters out of `MainWindow.Window.Resources` into `Themes/Controls.xaml` so extracted controls can resolve them (app-level, not window-level).
- [x] **Commit 14 · `refactor-commit-14`** — **TopBar** → `Controls/TopBar.xaml(.cs)` (Row 0: logo, REC/ON-AIR pills + signs, avatar, Sign In/Start/End buttons). Avatar fetch moved (async-preserved) into the control; window exposes `_topBar.RefreshAvatar()` invoked on `IsConnected`/`AccountAvatarUrl` changes. Pending: left scene/chat panel, preview pane, overlay host.
The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split
proceeds; each partial carries its own `using`s and re-declares nothing from core. proceeds; each partial carries its own `using`s and re-declares nothing from core.