diff --git a/Controls/TopBar.xaml b/Controls/TopBar.xaml index 2233f4f..471360c 100644 --- a/Controls/TopBar.xaml +++ b/Controls/TopBar.xaml @@ -135,11 +135,27 @@ Command="{Binding SelectLocalMp4Command}" Visibility="{Binding LocalMp4ChipVisible, Converter={StaticResource BoolToVis}}" ToolTip="Record to a local MP4"> - - - - + + + + + + + + + + + + diff --git a/Themes/Tokens.xaml b/Themes/Tokens.xaml index 84b3240..c21e2f7 100644 --- a/Themes/Tokens.xaml +++ b/Themes/Tokens.xaml @@ -44,6 +44,8 @@ + + diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index e823742..e33b5df 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -404,12 +404,15 @@ public partial class MainViewModel : ViewModelBase HealthIssueBanner = report.Text; } - private async void StopStream() + /// True when the session is being torn down because something broke, + /// not because the creator pressed Stop. A failure clears both outputs (there is no session and no + /// choice worth keeping); a creator's Stop of a recording restores the Local MP4 pair. + private async void StopStream(bool afterFailure = false) { var dispatcher = Application.Current?.Dispatcher; if (dispatcher != null && !dispatcher.CheckAccess()) { - _ = dispatcher.BeginInvoke(new Action(StopStream)); + _ = dispatcher.BeginInvoke(new Action(() => StopStream())); return; } @@ -419,6 +422,7 @@ public partial class MainViewModel : ViewModelBase RemoveAttentionAlert("stream-recovery"); var wasLive = IsLive; var wasRecording = IsRecording; + var wasRecordingOnly = IsRecording && !IsLive && !afterFailure; StreamStatus = StreamStatus.Offline; _sessionStartedAtUtc = null; @@ -429,11 +433,20 @@ public partial class MainViewModel : ViewModelBase // and drops the TEST face — a test never outlives its run. TestSession.EndTestSilently(); IsTestStream = false; - // Stop ends EVERYTHING (creator ruling 2026-09-01): a lit pill with no session - // behind it is a lie — the stuck REC pill after the failed 2026-09-01 recording - // attempt was the report. Intent resets with reality; re-arming is one click. - RecordPillOn = false; - OnAirPillOn = false; + // Stop ends the session (creator ruling 2026-09-01): a lit pill with no session behind it is a + // lie. Since build 730 the pills are the chosen OUTPUT, not an armed intent, so a finished + // local recording returns to its idle pair — Local MP4 chosen, Start Recording beside it + // (creator's words) — while a finished stream clears both and asks again. + if (wasRecordingOnly) + { + RecordPillOn = true; + OnAirPillOn = false; + } + else + { + RecordPillOn = false; + OnAirPillOn = false; + } WindowTitle = "LlamaCasty"; // Deactivate zoom when stopping if (_zoomState.IsActive || _zoomState.IsMoving) @@ -544,7 +557,7 @@ public partial class MainViewModel : ViewModelBase { var live = IsLive; _notifications.Error(live ? "The stream pipeline stopped" : "Recording stopped", message); - StopStream(); + StopStream(afterFailure: true); } var dispatcher = System.Windows.Application.Current?.Dispatcher; if (dispatcher != null && !dispatcher.CheckAccess()) @@ -643,6 +656,7 @@ public partial class MainViewModel : ViewModelBase } OnPropertyChanged(nameof(RecDotOpacity)); OnPropertyChanged(nameof(LiveElapsedText)); + OnPropertyChanged(nameof(RecordChipText)); } private void OnLiveTimerTick(object? sender, EventArgs e) @@ -650,8 +664,9 @@ public partial class MainViewModel : ViewModelBase _liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1)); LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss"); _recordLength = _liveElapsed; - _recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0; + _recDotPulse = _recDotPulse > 0.5 ? 0.2 : 1.0; OnPropertyChanged(nameof(RecDotOpacity)); + OnPropertyChanged(nameof(RecordChipText)); // TASK 55·3: the last useful warning before the encoder fails for a reason it won't explain. if (IsRecording && !_lowDiskWarned && _recordFolder is { } folder diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs index e094bcc..25104b4 100644 --- a/ViewModels/MainViewModel.Streaming.cs +++ b/ViewModels/MainViewModel.Streaming.cs @@ -253,7 +253,9 @@ public partial class MainViewModel : ViewModelBase public bool ShowIdleWorld => IsOffline && !IsRecording; /// Running — the reality line (dot + word + elapsed) + End replace the switch. - public bool RunningVisible => IsLive || IsRecording; + // Live only: a local recording's status lives in the output pill now (creator, build 730), so its + // separate dot/elapsed/stop cluster would be a second, redundant set of the same facts. + public bool RunningVisible => IsLive; /// Record world: the switch sits on REC and Go says "Start Recording". /// No Test — YouTube's stream actions don't exist here. The account zone IS diff --git a/ViewModels/MainViewModel.TopBar.cs b/ViewModels/MainViewModel.TopBar.cs index e4235fa..c729eae 100644 --- a/ViewModels/MainViewModel.TopBar.cs +++ b/ViewModels/MainViewModel.TopBar.cs @@ -33,7 +33,9 @@ public partial class MainViewModel /// The output stage belongs to the Record / Go Live world, and only while nothing is /// running — a live session cannot change its output, so its switch is not offered. - public bool ShowOutputStage => IsOutputWorld && IsOffline && !IsRecording; + // A recording KEEPS the stage visible: the pill carries REC, the elapsed time and the stop. A live + // stream does not — its reality line takes over (see RunningVisible). + public bool ShowOutputStage => IsOutputWorld && !IsLive; // ─── The output stage's three states (creator, builds 728/729) ─── // 1. nothing chosen: [YouTube] [Local MP4] @@ -47,6 +49,10 @@ public partial class MainViewModel public bool YouTubeChipVisible => ShowOutputChoice || ShowYouTubeSelected; public bool LocalMp4ChipVisible => ShowOutputChoice || ShowLocalMp4Selected; + /// The left chip reads "Local MP4" while idle and "REC: hh:mm:ss" while recording, so the + /// tabular figure cannot jitter the pill's width. + public string RecordChipText => IsRecording ? $"REC: {LiveElapsedText}" : "Local MP4"; + public bool ShowStartRecordingAction => ShowLocalMp4Selected && !IsRecording; public bool ShowEndRecordingAction => ShowLocalMp4Selected && IsRecording; @@ -92,6 +98,7 @@ public partial class MainViewModel OnPropertyChanged(nameof(ShowLocalMp4Selected)); OnPropertyChanged(nameof(ShowStartRecordingAction)); OnPropertyChanged(nameof(ShowEndRecordingAction)); + OnPropertyChanged(nameof(RecordChipText)); } /// Switches worlds. Re-entering the current one still re-asserts its edit state, which is diff --git a/ViewModels/MainViewModel.Ypp.cs b/ViewModels/MainViewModel.Ypp.cs index 0aa2320..7e7e701 100644 --- a/ViewModels/MainViewModel.Ypp.cs +++ b/ViewModels/MainViewModel.Ypp.cs @@ -32,7 +32,7 @@ public partial class MainViewModel TestSession = new TestSessionViewModel( _youtubeStream, _youtubeChat, - onEndStream: StopStream); + onEndStream: () => StopStream()); // One drawer at a time: the three pull-outs share the preview's right edge, // so opening one collapses the other two (all directions). diff --git a/ytLive.Tests/RealAppCollection.cs b/ytLive.Tests/RealAppCollection.cs index 0d96f40..e775ed2 100644 --- a/ytLive.Tests/RealAppCollection.cs +++ b/ytLive.Tests/RealAppCollection.cs @@ -64,8 +64,10 @@ public sealed class RealAppHost : IDisposable failure = ex; } }); + // Rethrow preserving the ORIGINAL stack: `throw failure;` reset it to this line, which hid + // which assertion actually failed inside the passed action. if (failure != null) - throw failure; + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw(); } /// Awaits on the App's STA thread and rethrows diff --git a/ytLive.Tests/TopBarModeTests.cs b/ytLive.Tests/TopBarModeTests.cs index 701c6c7..a0e8b5f 100644 --- a/ytLive.Tests/TopBarModeTests.cs +++ b/ytLive.Tests/TopBarModeTests.cs @@ -84,16 +84,22 @@ public sealed class TopBarModeTests Assert.False(vm.ShowTestButton); Assert.Equal("Start Recording", vm.PrimaryStartButtonLabel); - // Running retires the switch and worlds; the reality line pins the mode. - vm.RecordPillOn = false; - vm.OnAirPillOn = true; + // A local recording STAYS in the output world (build 730): the pill carries REC, the + // clock and the stop, so the separate reality line stays away — it would be a second set + // of the same facts. + vm.EnterOutputWorldCommand.Execute(null); + vm.RecordPillOn = true; + vm.OnAirPillOn = false; vm.IsRecording = true; - Assert.False(vm.ShowIdleWorld); - Assert.True(vm.RunningVisible); - Assert.Equal("REC", vm.RunningText); - Assert.Equal("#22c55e", vm.RunningDotBrush); + Assert.True(vm.ShowOutputStage); + Assert.False(vm.RunningVisible); + Assert.StartsWith("REC: ", vm.RecordChipText); vm.IsRecording = false; + + // A live stream retires the stage instead; the reality line pins the mode. vm.StreamStatus = StreamStatus.Streaming; + Assert.True(vm.RunningVisible); + Assert.False(vm.ShowOutputStage); Assert.Equal("LIVE", vm.RunningText); Assert.Equal("#ff5451", vm.RunningDotBrush); vm.IsTestStream = true; diff --git a/ytLive.Tests/TopBarWorldTests.cs b/ytLive.Tests/TopBarWorldTests.cs index 680f06b..f6b19ba 100644 --- a/ytLive.Tests/TopBarWorldTests.cs +++ b/ytLive.Tests/TopBarWorldTests.cs @@ -63,6 +63,19 @@ public class TopBarWorldTests Assert.True(vm.LocalMp4ChipVisible); Assert.True(vm.ShowStartRecordingAction); Assert.False(vm.ShowEndRecordingAction); + Assert.Equal("Local MP4", vm.RecordChipText); + + // Recording: the pill carries REC + the clock, the stage stays up, and the live reality + // line does not (its dot/elapsed/stop would be a second set of the same facts). + vm.IsRecording = true; + Assert.True(vm.ShowOutputStage); + Assert.False(vm.RunningVisible); + Assert.False(vm.ShowStartRecordingAction); + Assert.True(vm.ShowEndRecordingAction); + Assert.StartsWith("REC: ", vm.RecordChipText); + + vm.IsRecording = false; + Assert.Equal("Local MP4", vm.RecordChipText); // Neither action exists while building scenes. vm.EnterBuildWorldCommand.Execute(null);