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);