diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index b1b4c93..39a1671 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -147,8 +147,28 @@ public partial class MainWindow : Window _topBar.RefreshAvatar(); } - private void MainWindow_Closing(object? sender, CancelEventArgs e) + 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(); } diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index f4c86e2..9dc8da6 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -616,6 +616,25 @@ public partial class MainViewModel : ViewModelBase } + /// + /// Ends a running session before the app tears itself down, and says whether it is safe to close. + /// + /// Closing with a recording active used to dispose the frame pump and the audio mixer under a + /// live loop: ffmpeg never flushed, the file was never renamed or kept, and the pump was disposed + /// while it was still working. The window now holds itself open until the ordinary stop completes + /// (bounded — a stuck encoder must not trap the creator in an unclosable app). + /// + internal async Task StopBeforeShutdownAsync() + { + if (!IsLive && !IsRecording) return true; + + StopStream(); + for (var i = 0; i < 120 && (IsLive || IsRecording); i++) // up to ~6 s + await Task.Delay(50).ConfigureAwait(true); + + return !IsLive && !IsRecording; + } + public void Shutdown() { StopCurrentLaunchLogFeed(); diff --git a/ytLive.Tests/TopBarWorldTests.cs b/ytLive.Tests/TopBarWorldTests.cs index f6b19ba..98af355 100644 --- a/ytLive.Tests/TopBarWorldTests.cs +++ b/ytLive.Tests/TopBarWorldTests.cs @@ -91,6 +91,63 @@ public class TopBarWorldTests } } + /// Closing mid-session must end the session first (2026-11-12). The clear case: with + /// nothing running, closing is immediate — no hold, no wait. + [Fact] + public async Task Closing_with_nothing_running_does_not_hold_the_window() + { + var db = Path.Combine(Path.GetTempPath(), $"llamacasty-shutdown-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = db; + try + { + var vm = new MainViewModel(); + var started = DateTime.UtcNow; + Assert.True(await vm.StopBeforeShutdownAsync()); + Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(1)); + } + finally + { + MainViewModel.LayoutPathOverride = null; + try { File.Delete(db); } catch (IOException) { } + } + } + + /// + /// The symptom the creator hit: pressing Start left the idle chip on screen with a dead command, so + /// the recording could not be stopped. Reading the property directly was never the test — the UI must + /// be TOLD the stage changed. + /// + [Fact] + public void Starting_or_stopping_a_recording_announces_the_stage_change() + { + var db = Path.Combine(Path.GetTempPath(), $"llamacasty-recannounce-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = db; + try + { + var vm = new MainViewModel(); + vm.EnterOutputWorldCommand.Execute(null); + vm.SelectLocalMp4Command.Execute(null); + + var raised = new System.Collections.Generic.List(); + vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName ?? string.Empty); + + vm.IsRecording = true; + Assert.Contains(nameof(MainViewModel.ShowEndRecordingAction), raised); + Assert.Contains(nameof(MainViewModel.ShowStartRecordingAction), raised); + Assert.Contains(nameof(MainViewModel.RecordChipText), raised); + + raised.Clear(); + vm.IsRecording = false; + Assert.Contains(nameof(MainViewModel.ShowStartRecordingAction), raised); + Assert.Contains(nameof(MainViewModel.RecordChipText), raised); + } + finally + { + MainViewModel.LayoutPathOverride = null; + try { File.Delete(db); } catch (IOException) { } + } + } + [Fact] public void Build_scenes_is_the_default_world_and_is_the_only_place_editing_lives() {