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:
+22
-6
@@ -135,11 +135,27 @@
|
|||||||
Command="{Binding SelectLocalMp4Command}"
|
Command="{Binding SelectLocalMp4Command}"
|
||||||
Visibility="{Binding LocalMp4ChipVisible, Converter={StaticResource BoolToVis}}"
|
Visibility="{Binding LocalMp4ChipVisible, Converter={StaticResource BoolToVis}}"
|
||||||
ToolTip="Record to a local MP4">
|
ToolTip="Record to a local MP4">
|
||||||
<StackPanel Orientation="Horizontal">
|
<Grid>
|
||||||
<Path Style="{StaticResource OutputChipIcon}" Tag="{Binding RecordPillOn}" Data="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20zM12,4a8,8 0 1,1 0,16a8,8 0 1,1 0,-16zM12,7.5a4.5,4.5 0 1,1 0,9a4.5,4.5 0 1,1 0,-9z"/>
|
<!-- Idle face: the record glyph and the output's name. -->
|
||||||
<TextBlock Text="Local MP4" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
|
<StackPanel Orientation="Horizontal"
|
||||||
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
|
Visibility="{Binding IsRecording, Converter={StaticResource InverseBoolToVis}}">
|
||||||
</StackPanel>
|
<Path Style="{StaticResource OutputChipIcon}" Tag="{Binding RecordPillOn}" Data="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20zM12,4a8,8 0 1,1 0,16a8,8 0 1,1 0,-16zM12,7.5a4.5,4.5 0 1,1 0,9a4.5,4.5 0 1,1 0,-9z"/>
|
||||||
|
<TextBlock Text="Local MP4" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
|
||||||
|
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<!-- Recording face: solid green dot pulsing once a second, and a tabular
|
||||||
|
clock so the pill does not jitter as the digits change. -->
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
Visibility="{Binding IsRecording, Converter={StaticResource BoolToVis}}">
|
||||||
|
<Ellipse Width="10" Height="10" VerticalAlignment="Center"
|
||||||
|
Fill="{StaticResource Brush.RecordLive}"
|
||||||
|
Opacity="{Binding RecDotOpacity, Mode=OneWay}"/>
|
||||||
|
<TextBlock Text="{Binding RecordChipText}" FontSize="12" FontWeight="Bold"
|
||||||
|
Margin="6,0,0,0" VerticalAlignment="Center"
|
||||||
|
FontFamily="Consolas"
|
||||||
|
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
</Button>
|
</Button>
|
||||||
<!-- YouTube's two actions: the real stream and the private (test) one. -->
|
<!-- YouTube's two actions: the real stream and the private (test) one. -->
|
||||||
<Button Style="{StaticResource OutputChip}" Command="{Binding GoLiveCommand}"
|
<Button Style="{StaticResource OutputChip}" Command="{Binding GoLiveCommand}"
|
||||||
@@ -173,7 +189,7 @@
|
|||||||
Visibility="{Binding ShowEndRecordingAction, Converter={StaticResource BoolToVis}}">
|
Visibility="{Binding ShowEndRecordingAction, Converter={StaticResource BoolToVis}}">
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<Path Style="{StaticResource OutputChipIcon}" Data="M6,6h12v12H6z"/>
|
<Path Style="{StaticResource OutputChipIcon}" Data="M6,6h12v12H6z"/>
|
||||||
<TextBlock Text="End Recording" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
|
<TextBlock Text="Stop Recording" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
|
||||||
Foreground="{StaticResource Brush.SegmentTextInactive}"/>
|
Foreground="{StaticResource Brush.SegmentTextInactive}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -44,6 +44,8 @@
|
|||||||
<SolidColorBrush x:Key="Brush.SegmentIconActive" Color="#F26D6D"/>
|
<SolidColorBrush x:Key="Brush.SegmentIconActive" Color="#F26D6D"/>
|
||||||
<!-- One-alpha black: transparent to the eye, opaque to hit-testing, so the whole segment is clickable. -->
|
<!-- One-alpha black: transparent to the eye, opaque to hit-testing, so the whole segment is clickable. -->
|
||||||
<SolidColorBrush x:Key="Brush.SegmentHitArea" Color="#01000000"/>
|
<SolidColorBrush x:Key="Brush.SegmentHitArea" Color="#01000000"/>
|
||||||
|
<!-- The recording dot on the output pill (creator, build 730). -->
|
||||||
|
<SolidColorBrush x:Key="Brush.RecordLive" Color="#2ECC71"/>
|
||||||
|
|
||||||
<SolidColorBrush x:Key="Brush.Primary" Color="#ff5451"/>
|
<SolidColorBrush x:Key="Brush.Primary" Color="#ff5451"/>
|
||||||
<SolidColorBrush x:Key="Brush.PrimaryContainer" Color="#b91a24"/>
|
<SolidColorBrush x:Key="Brush.PrimaryContainer" Color="#b91a24"/>
|
||||||
|
|||||||
@@ -404,12 +404,15 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
HealthIssueBanner = report.Text;
|
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;
|
var dispatcher = Application.Current?.Dispatcher;
|
||||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||||
{
|
{
|
||||||
_ = dispatcher.BeginInvoke(new Action(StopStream));
|
_ = dispatcher.BeginInvoke(new Action(() => StopStream()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,6 +422,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
RemoveAttentionAlert("stream-recovery");
|
RemoveAttentionAlert("stream-recovery");
|
||||||
var wasLive = IsLive;
|
var wasLive = IsLive;
|
||||||
var wasRecording = IsRecording;
|
var wasRecording = IsRecording;
|
||||||
|
var wasRecordingOnly = IsRecording && !IsLive && !afterFailure;
|
||||||
|
|
||||||
StreamStatus = StreamStatus.Offline;
|
StreamStatus = StreamStatus.Offline;
|
||||||
_sessionStartedAtUtc = null;
|
_sessionStartedAtUtc = null;
|
||||||
@@ -429,11 +433,20 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
// and drops the TEST face — a test never outlives its run.
|
// and drops the TEST face — a test never outlives its run.
|
||||||
TestSession.EndTestSilently();
|
TestSession.EndTestSilently();
|
||||||
IsTestStream = false;
|
IsTestStream = false;
|
||||||
// Stop ends EVERYTHING (creator ruling 2026-09-01): a lit pill with no session
|
// Stop ends the session (creator ruling 2026-09-01): a lit pill with no session behind it is a
|
||||||
// behind it is a lie — the stuck REC pill after the failed 2026-09-01 recording
|
// lie. Since build 730 the pills are the chosen OUTPUT, not an armed intent, so a finished
|
||||||
// attempt was the report. Intent resets with reality; re-arming is one click.
|
// local recording returns to its idle pair — Local MP4 chosen, Start Recording beside it
|
||||||
RecordPillOn = false;
|
// (creator's words) — while a finished stream clears both and asks again.
|
||||||
OnAirPillOn = false;
|
if (wasRecordingOnly)
|
||||||
|
{
|
||||||
|
RecordPillOn = true;
|
||||||
|
OnAirPillOn = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RecordPillOn = false;
|
||||||
|
OnAirPillOn = false;
|
||||||
|
}
|
||||||
WindowTitle = "LlamaCasty";
|
WindowTitle = "LlamaCasty";
|
||||||
// Deactivate zoom when stopping
|
// Deactivate zoom when stopping
|
||||||
if (_zoomState.IsActive || _zoomState.IsMoving)
|
if (_zoomState.IsActive || _zoomState.IsMoving)
|
||||||
@@ -544,7 +557,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
var live = IsLive;
|
var live = IsLive;
|
||||||
_notifications.Error(live ? "The stream pipeline stopped" : "Recording stopped", message);
|
_notifications.Error(live ? "The stream pipeline stopped" : "Recording stopped", message);
|
||||||
StopStream();
|
StopStream(afterFailure: true);
|
||||||
}
|
}
|
||||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||||
@@ -643,6 +656,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
OnPropertyChanged(nameof(RecDotOpacity));
|
OnPropertyChanged(nameof(RecDotOpacity));
|
||||||
OnPropertyChanged(nameof(LiveElapsedText));
|
OnPropertyChanged(nameof(LiveElapsedText));
|
||||||
|
OnPropertyChanged(nameof(RecordChipText));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnLiveTimerTick(object? sender, EventArgs e)
|
private void OnLiveTimerTick(object? sender, EventArgs e)
|
||||||
@@ -650,8 +664,9 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
|
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
|
||||||
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
|
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
|
||||||
_recordLength = _liveElapsed;
|
_recordLength = _liveElapsed;
|
||||||
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
|
_recDotPulse = _recDotPulse > 0.5 ? 0.2 : 1.0;
|
||||||
OnPropertyChanged(nameof(RecDotOpacity));
|
OnPropertyChanged(nameof(RecDotOpacity));
|
||||||
|
OnPropertyChanged(nameof(RecordChipText));
|
||||||
|
|
||||||
// TASK 55·3: the last useful warning before the encoder fails for a reason it won't explain.
|
// TASK 55·3: the last useful warning before the encoder fails for a reason it won't explain.
|
||||||
if (IsRecording && !_lowDiskWarned && _recordFolder is { } folder
|
if (IsRecording && !_lowDiskWarned && _recordFolder is { } folder
|
||||||
|
|||||||
@@ -253,7 +253,9 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
public bool ShowIdleWorld => IsOffline && !IsRecording;
|
public bool ShowIdleWorld => IsOffline && !IsRecording;
|
||||||
|
|
||||||
/// <summary>Running — the reality line (dot + word + elapsed) + End replace the switch.</summary>
|
/// <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".
|
/// <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
|
/// No Test — YouTube's stream actions don't exist here. The account zone IS
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ public partial class MainViewModel
|
|||||||
|
|
||||||
/// <summary>The output stage belongs to the Record / Go Live world, and only while nothing is
|
/// <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>
|
/// 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) ───
|
// ─── The output stage's three states (creator, builds 728/729) ───
|
||||||
// 1. nothing chosen: [YouTube] [Local MP4]
|
// 1. nothing chosen: [YouTube] [Local MP4]
|
||||||
@@ -47,6 +49,10 @@ public partial class MainViewModel
|
|||||||
public bool YouTubeChipVisible => ShowOutputChoice || ShowYouTubeSelected;
|
public bool YouTubeChipVisible => ShowOutputChoice || ShowYouTubeSelected;
|
||||||
public bool LocalMp4ChipVisible => ShowOutputChoice || ShowLocalMp4Selected;
|
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 ShowStartRecordingAction => ShowLocalMp4Selected && !IsRecording;
|
||||||
public bool ShowEndRecordingAction => ShowLocalMp4Selected && IsRecording;
|
public bool ShowEndRecordingAction => ShowLocalMp4Selected && IsRecording;
|
||||||
|
|
||||||
@@ -92,6 +98,7 @@ public partial class MainViewModel
|
|||||||
OnPropertyChanged(nameof(ShowLocalMp4Selected));
|
OnPropertyChanged(nameof(ShowLocalMp4Selected));
|
||||||
OnPropertyChanged(nameof(ShowStartRecordingAction));
|
OnPropertyChanged(nameof(ShowStartRecordingAction));
|
||||||
OnPropertyChanged(nameof(ShowEndRecordingAction));
|
OnPropertyChanged(nameof(ShowEndRecordingAction));
|
||||||
|
OnPropertyChanged(nameof(RecordChipText));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Switches worlds. Re-entering the current one still re-asserts its edit state, which is
|
/// <summary>Switches worlds. Re-entering the current one still re-asserts its edit state, which is
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public partial class MainViewModel
|
|||||||
TestSession = new TestSessionViewModel(
|
TestSession = new TestSessionViewModel(
|
||||||
_youtubeStream,
|
_youtubeStream,
|
||||||
_youtubeChat,
|
_youtubeChat,
|
||||||
onEndStream: StopStream);
|
onEndStream: () => StopStream());
|
||||||
|
|
||||||
// One drawer at a time: the three pull-outs share the preview's right edge,
|
// One drawer at a time: the three pull-outs share the preview's right edge,
|
||||||
// so opening one collapses the other two (all directions).
|
// so opening one collapses the other two (all directions).
|
||||||
|
|||||||
@@ -64,8 +64,10 @@ public sealed class RealAppHost : IDisposable
|
|||||||
failure = ex;
|
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)
|
if (failure != null)
|
||||||
throw failure;
|
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Awaits <paramref name="action"/> on the App's STA thread and rethrows
|
/// <summary>Awaits <paramref name="action"/> on the App's STA thread and rethrows
|
||||||
|
|||||||
@@ -84,16 +84,22 @@ public sealed class TopBarModeTests
|
|||||||
Assert.False(vm.ShowTestButton);
|
Assert.False(vm.ShowTestButton);
|
||||||
Assert.Equal("Start Recording", vm.PrimaryStartButtonLabel);
|
Assert.Equal("Start Recording", vm.PrimaryStartButtonLabel);
|
||||||
|
|
||||||
// Running retires the switch and worlds; the reality line pins the mode.
|
// A local recording STAYS in the output world (build 730): the pill carries REC, the
|
||||||
vm.RecordPillOn = false;
|
// clock and the stop, so the separate reality line stays away — it would be a second set
|
||||||
vm.OnAirPillOn = true;
|
// of the same facts.
|
||||||
|
vm.EnterOutputWorldCommand.Execute(null);
|
||||||
|
vm.RecordPillOn = true;
|
||||||
|
vm.OnAirPillOn = false;
|
||||||
vm.IsRecording = true;
|
vm.IsRecording = true;
|
||||||
Assert.False(vm.ShowIdleWorld);
|
Assert.True(vm.ShowOutputStage);
|
||||||
Assert.True(vm.RunningVisible);
|
Assert.False(vm.RunningVisible);
|
||||||
Assert.Equal("REC", vm.RunningText);
|
Assert.StartsWith("REC: ", vm.RecordChipText);
|
||||||
Assert.Equal("#22c55e", vm.RunningDotBrush);
|
|
||||||
vm.IsRecording = false;
|
vm.IsRecording = false;
|
||||||
|
|
||||||
|
// A live stream retires the stage instead; the reality line pins the mode.
|
||||||
vm.StreamStatus = StreamStatus.Streaming;
|
vm.StreamStatus = StreamStatus.Streaming;
|
||||||
|
Assert.True(vm.RunningVisible);
|
||||||
|
Assert.False(vm.ShowOutputStage);
|
||||||
Assert.Equal("LIVE", vm.RunningText);
|
Assert.Equal("LIVE", vm.RunningText);
|
||||||
Assert.Equal("#ff5451", vm.RunningDotBrush);
|
Assert.Equal("#ff5451", vm.RunningDotBrush);
|
||||||
vm.IsTestStream = true;
|
vm.IsTestStream = true;
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ public class TopBarWorldTests
|
|||||||
Assert.True(vm.LocalMp4ChipVisible);
|
Assert.True(vm.LocalMp4ChipVisible);
|
||||||
Assert.True(vm.ShowStartRecordingAction);
|
Assert.True(vm.ShowStartRecordingAction);
|
||||||
Assert.False(vm.ShowEndRecordingAction);
|
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.
|
// Neither action exists while building scenes.
|
||||||
vm.EnterBuildWorldCommand.Execute(null);
|
vm.EnterBuildWorldCommand.Execute(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user