feat(topbar): the recording lives in the pill — green pulse, REC clock, Stop Recording

Build 730 (creator):

- Left pill while recording: solid #2ECC71 dot pulsing 1.0 <-> 0.2 on the existing one-second timer, and
  "REC: hh:mm:ss" in Consolas so the digits cannot jitter the pill's width. Idle face unchanged.
- Right pill while recording: a stop square and "Stop Recording"; clicking it runs the normal stop, which
  halts and resets the clock and returns the pair to "Local MP4" / "Start Recording".
- The separate reality line (dot + REC/LIVE + elapsed + End) is now LIVE-ONLY: a recording would otherwise
  print the same three facts twice. RunningVisible is IsLive, and ShowOutputStage no longer hides itself
  for a recording (it must be on screen to carry the clock and the stop).

Two rules collided and are now separated rather than merged: a creator's Stop on a recording restores the
Local MP4 pair (build 730), while a pipeline FAILURE still clears both pills (the 2026-09-01 zombie rule).
StopStream(afterFailure:) carries the difference, and the pump-failure path passes it.

RealAppHost.Run was rethrowing with `throw failure;`, which destroyed the original stack — a failing
assertion inside the action reported the wrapper's line instead of its own. ExceptionDispatchInfo now
preserves it; that is how the two failures above were located in one pass instead of five.

680 pass (one audio-pipe instance flake under full-suite load, passes alone), 0 warnings.
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
still awaiting their visual check.
This commit is contained in:
2026-11-12 16:52:36 -08:00
parent caf5d6130d
commit 70fcf89f9e
9 changed files with 89 additions and 26 deletions
@@ -404,12 +404,15 @@ public partial class MainViewModel : ViewModelBase
HealthIssueBanner = report.Text;
}
private async void StopStream()
/// <param name="afterFailure">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.</param>
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
+3 -1
View File
@@ -253,7 +253,9 @@ public partial class MainViewModel : ViewModelBase
public bool ShowIdleWorld => IsOffline && !IsRecording;
/// <summary>Running — the reality line (dot + word + elapsed) + End replace the switch.</summary>
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;
/// <summary>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
+8 -1
View File
@@ -33,7 +33,9 @@ public partial class MainViewModel
/// <summary>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.</summary>
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;
/// <summary>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.</summary>
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));
}
/// <summary>Switches worlds. Re-entering the current one still re-asserts its edit state, which is
+1 -1
View File
@@ -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).