Files
LlamaCasty/MainWindow.xaml.cs
T
gramps 69f08a478e feat(topbar): three fixed slots — every state changes inside its own slot
Creator correction (2026-11-13): "OpenCode literally inserted Live and Private as two extra flat siblings
inside the outer pill bar, shoving Record to the right and making it look like five ungrouped toolbar
items". Correct — completion states were being added to the BAR instead of to a SLOT.

Now the bar is exactly three slots, and the bar's shape never changes:

  [ Build Scenes ]   [ YouTube ]   [ Record ]

  YouTube:  ▶ YouTube -> [Live | Private | ✕] -> [● Start Live | ✕] (amber: next click goes on air)
            -> [■ Stop Live]
  Record:   ◉ Record  -> [● Start Recording | ✕] -> ● REC hh:mm:ss [■ Stop]
  Build:    enabled only while BOTH action slots are Idle; otherwise disabled at 40% with the tooltip
            "Scene editing is locked while streaming or recording"

State lives in YouTubeSlotState / RecordSlotState, with one RaiseSlotStates() announcing every flag so no
caller can forget one. Esc and the ✕ cancel the slot you are in and nothing else.

This retires the BarWorld model from earlier today, along with TopBarActivityTests (superseded by
TopBarSlotTests, which pins all three flows, the lock, and cancel-to-Idle).

679 pass, 0 failed (the host-hang fallback in f4caea0 counted the per-test lines).
Scope justification: TopBarActivityTests.cs is deleted as part of this unit — it tested the model being
replaced; TopBarSlotTests is its successor.
2026-11-13 12:20:11 -08:00

264 lines
10 KiB
C#

using System;
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using ytLive.Helpers;
using ytLive.ViewModels;
namespace ytLive;
public partial class MainWindow : Window
{
private readonly MainViewModel _viewModel;
private Controls.TopBar _topBar = null!;
private Controls.PreviewPane _previewPane = null!;
private Controls.LeftPanel _leftPanel = null!;
public MainWindow()
{
AppLog.Write("MainWindow ctor: before InitializeComponent");
InitializeComponent();
SizeChanged += (_, e) => Helpers.AppLog.Write($"WIDTHS window={e.NewSize.Width:F0} "
+ $"layers={LeftPanel.ActualWidth:F0} strip={SceneStrip.ActualWidth:F0} preview={PreviewPane.ActualWidth:F0}");
AppLog.Write("MainWindow ctor: after InitializeComponent");
_viewModel = (MainViewModel)DataContext;
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
// Don't steal focus from an exclusive-fullscreen game the creator is already
// playing (e.g. while starting a gameplay stream): activating our window would
// drop that game to the taskbar and make the desktop look "locked".
ShowActivated = !_viewModel.FullscreenGameRunningNow;
_topBar = (Controls.TopBar)FindName("TopBar")!;
_previewPane = (Controls.PreviewPane)FindName("PreviewPane")!;
_leftPanel = (Controls.LeftPanel)FindName("LeftPanel")!;
UpdateTaskbarOverlay();
UpdateSelectionOverlay();
_topBar.RefreshAvatar();
RestoreWindowGeometry();
AppLog.Write("MainWindow ctor: end");
}
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
AppLog.Write("MainWindow loaded");
// Web sources need the window HWND as the composition controller's parent,
// which does not exist in the ctor — InitWebView2 runs here (Loaded), after
// SourceInitialized. Nothing renders from web frames before this.
_viewModel.InitWebView2();
}
// ─── Window geometry (launch position/size) ───
// Launch POSITION is derived, never restored: upper-left of the work area plus
// padding (creator ruling 2026-10-04). The old restore clamped the saved position
// to [work.Left - width + 120, work.Right - 120] horizontally and
// [work.Top, work.Bottom - 80] vertically — ranges wide enough to leave the window
// almost entirely off-screen, which is what the creator was seeing ("below the
// fold"). Size and maximized state are still persisted. Placement math is pure and
// tested (WindowPlacementTests); changing it back to restore position is reverting
// this block only, since the saved values are still written.
/// <summary>Launch inset from the work-area corner, in device-independent units —
/// so it scales with DPI rather than being a fixed physical pixel count.</summary>
internal const double LaunchPadding = 75;
/// <summary>Upper-left of <paramref name="workArea"/> plus padding, clamped so the
/// window is never pushed off-screen. The clamp is the point: a window that fits
/// stays wholly visible, and one too big to fit falls back to the work-area origin
/// rather than hanging off an edge.</summary>
internal static Point TopLeftPlacement(Rect workArea, double width, double height, double padding)
{
var left = workArea.Left + padding;
var top = workArea.Top + padding;
if (width <= workArea.Width) left = Math.Min(left, workArea.Right - width);
else left = workArea.Left;
if (height <= workArea.Height) top = Math.Min(top, workArea.Bottom - height);
else top = workArea.Top;
return new Point(left, top);
}
private void RestoreWindowGeometry()
{
try
{
var work = SystemParameters.WorkArea;
var saved = _viewModel.RestoreWindowState();
// No saved state: keep the XAML-declared size rather than collapsing to Min*.
var width = Math.Clamp(saved?.Width ?? Width, MinWidth, work.Width);
var height = Math.Clamp(saved?.Height ?? Height, MinHeight, work.Height);
Width = width;
Height = height;
var place = TopLeftPlacement(work, width, height, LaunchPadding);
Left = place.X;
Top = place.Y;
if (saved?.State == "Maximized") WindowState = WindowState.Maximized;
}
catch (Exception ex)
{
AppLog.Write($"Window geometry restore failed: {ex.Message}");
}
}
private void SaveWindowGeometry()
{
try
{
var bounds = RestoreBounds;
var state = WindowState == WindowState.Maximized ? "Maximized" : "Normal";
_viewModel.PersistWindowState(bounds.Left, bounds.Top, bounds.Width, bounds.Height, state);
}
catch (Exception ex)
{
AppLog.Write($"Window geometry save failed: {ex.Message}");
}
}
private ytLive.Services.GlobalHotkeyManager? _hotkeys;
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
_hotkeys = new ytLive.Services.GlobalHotkeyManager();
_hotkeys.Activated += id => _viewModel.HandleHotkey(id);
_hotkeys.Attach(this, _viewModel.CurrentHotkeyBindings);
_viewModel.HotkeyBindingsChanged = bindings => _hotkeys.Reattach(this, bindings);
}
private void Window_ContextMenuOpening(object sender, ContextMenuEventArgs e)
{
if (!_viewModel.IsEditMode)
e.Handled = true;
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MainViewModel.IsLive))
UpdateTaskbarOverlay();
else if (e.PropertyName == nameof(MainViewModel.SelectedElement))
UpdateSelectionOverlay();
else if (e.PropertyName == nameof(MainViewModel.AccountAvatarUrl)
|| e.PropertyName == nameof(MainViewModel.IsConnected))
_topBar.RefreshAvatar();
}
private bool _shutdownComplete;
private async void MainWindow_Closing(object? sender, CancelEventArgs e)
{
if (_shutdownComplete)
{
SaveWindowGeometry();
_viewModel.Shutdown();
return;
}
// A session must END before anything is disposed (2026-11-12: closing mid-recording used to
// pull the pump out from under itself). Hold the window open, stop properly, then close again.
if (_viewModel.IsLive || _viewModel.IsRecording)
{
e.Cancel = true;
await _viewModel.StopBeforeShutdownAsync();
_shutdownComplete = true;
Close();
return;
}
SaveWindowGeometry();
_viewModel.Shutdown();
}
private void MainWindow_Activated(object? sender, EventArgs e)
{
_viewModel.RefreshBackgroundAutoCapture();
}
private void MainWindow_Deactivated(object? sender, EventArgs e)
{
}
/// <summary>
/// Ctrl released/late-pressed while the magnifier is open → the box closes (creator ruling
/// 2026-11-01). This is the "let go and it's gone" half of the zoom gesture; it also means one
/// hand can dismiss without reaching for the mouse. Creating the box still takes Ctrl+right
/// (edit) or a left click (preview), so holding Ctrl to *make* it never cancels it: the key is
/// already down before the box exists.
/// </summary>
private void MainWindow_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
// ⛔ e.IsRepeat matters: Windows auto-repeats a held modifier. Creation holds Ctrl (the
// chord is Ctrl+right-click), so without this guard the repeat tick ~500 ms later saw an
// open box and closed it — the box appeared and vanished as it was created.
if (e.IsRepeat) return;
if (e.Key is System.Windows.Input.Key.LeftCtrl or System.Windows.Input.Key.RightCtrl
&& _viewModel.IsZoomActive)
{
_viewModel.DismissZoomIfActive();
}
// Esc leaves a sub-menu (creator ruling 2026-11-12): the Output world is a sub-menu, and its
// back chevron is gone in favour of this. The bar says so under the switches.
// Esc backs out of whichever slot is mid-flow (creator, 2026-11-13): the slots cancel in place
// rather than moving the bar around.
if (e.Key == System.Windows.Input.Key.Escape)
{
if (_viewModel.IsYouTubeChoosingPrivacy || _viewModel.IsYouTubeArmed)
{
_viewModel.CancelYouTubeCommand.Execute(null);
e.Handled = true;
}
else if (_viewModel.IsRecordArmed)
{
_viewModel.CancelRecordCommand.Execute(null);
e.Handled = true;
}
}
}
private void UpdateTaskbarOverlay()
{
TaskbarInfo.Overlay = _viewModel.IsLive
? (ImageSource)FindResource("LiveOverlay")
: null;
}
private void Window_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.OriginalSource is not DependencyObject original) return;
var form = _viewModel.BroadcastForm;
var ypp = _viewModel.Ypp;
var test = _viewModel.TestSession;
if (!_previewPane.IsClickInsideDrawer(original))
{
if (form.IsDrawerOpen) form.CloseDrawerCommand.Execute(null);
if (ypp.IsDrawerOpen) ypp.CloseDrawerCommand.Execute(null);
if (test.IsDrawerOpen) test.CloseDrawerCommand.Execute(null);
// The Settings (gear) and Socials drawers live in the same host but were left out of this
// guard, so clicking anywhere else left them open (reported 2026-11-05).
_viewModel.CloseSettingsDrawerIfOpen();
_viewModel.CloseSocialsDrawer();
}
if (_previewPane.IsClickInsidePreview(original)) return;
if (_leftPanel.IsClickInside(original)) return;
_viewModel.SelectedElement = null;
}
private void UpdateSelectionOverlay()
{
if (!_viewModel.IsEditMode)
{
_previewPane.UpdateSelectionOverlay(false);
return;
}
var selected = _viewModel.SelectedElement is { } s && Controls.PreviewPane.IsDraggableElement(s);
_previewPane.UpdateSelectionOverlay(selected);
_leftPanel.OnSelectionChanged(_viewModel);
}
}