Creator ruling 2026-11-13: "the app does three things. builds. records. live-streams." So the bar holds three buttons that never disappear; the active one is lit and runs its own flow in place, and the other two dim but stay clickable, so there is no state where the creator cannot see where they are or how to step back. Build [Build Scenes] -> editing on, nothing to start YouTube [Live][Private] -> [Start] -> [Stop] (Private = the private-visibility test stream) Record [Start Recording] -> [● REC hh:mm:ss][Stop Recording] Arming and starting are separate presses on purpose (creator): Start is the last chance to tell someone "check you are ready". Collapsing them would save one click and lose that prompt. This replaces the two-world model (BarWorld) and its output stage outright, including the chips added earlier today. The old pills remain the underlying state (OnAirPillOn/RecordPillOn) so the start, stop and step-down paths are untouched; the activity only decides what the bar shows. Closing the app no longer waits 6 s for a session that cannot stop: it waits ~2 s, then clears the state and closes anyway. A window that refuses to close is worse than a stale flag — and that refusal is what hung the suite in ZoomBoxInteractionTests, which sets IsRecording and closes a real window. Test: TopBarActivityTests pins all three flows, the arm-then-start split, and that stepping back to Build forgets the YouTube choice instead of carrying it. TopBarWorldTests is retired with the model it tested.
254 lines
10 KiB
C#
254 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.
|
|
if (e.Key == System.Windows.Input.Key.Escape && _viewModel.Activity != ViewModels.TopActivity.Build)
|
|
{
|
|
_viewModel.SelectBuildActivityCommand.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);
|
|
}
|
|
}
|