feat(topbar): three fixed slots — every state changes inside its own slot

Creator correction (2026-11-13): "OpenCode literally inserted Live and Private as two extra flat siblings
inside the outer pill bar, shoving Record to the right and making it look like five ungrouped toolbar
items". Correct — completion states were being added to the BAR instead of to a SLOT.

Now the bar is exactly three slots, and the bar's shape never changes:

  [ Build Scenes ]   [ YouTube ]   [ Record ]

  YouTube:  ▶ YouTube -> [Live | Private | ✕] -> [● Start Live | ✕] (amber: next click goes on air)
            -> [■ Stop Live]
  Record:   ◉ Record  -> [● Start Recording | ✕] -> ● REC hh:mm:ss [■ Stop]
  Build:    enabled only while BOTH action slots are Idle; otherwise disabled at 40% with the tooltip
            "Scene editing is locked while streaming or recording"

State lives in YouTubeSlotState / RecordSlotState, with one RaiseSlotStates() announcing every flag so no
caller can forget one. Esc and the ✕ cancel the slot you are in and nothing else.

This retires the BarWorld model from earlier today, along with TopBarActivityTests (superseded by
TopBarSlotTests, which pins all three flows, the lock, and cancel-to-Idle).

679 pass, 0 failed (the host-hang fallback in f4caea0 counted the per-test lines).
Scope justification: TopBarActivityTests.cs is deleted as part of this unit — it tested the model being
replaced; TopBarSlotTests is its successor.
This commit is contained in:
2026-11-13 12:20:11 -08:00
parent ba60f9ab3d
commit 69f08a478e
10 changed files with 373 additions and 273 deletions
+139 -92
View File
@@ -66,121 +66,168 @@
question "which world am I in?" and "can I edit?" cannot disagree. Clicking the
highlighted half re-enters it, which is how a creator gets back to the layers
during a session. -->
<!-- THREE ACTIVITIES, ALWAYS VISIBLE (creator, 2026-11-13): build, stream, record. The
active one is lit; the others stay put and dim, so the creator can see where they are
and step back without hunting. Each active button runs its own flow in place:
Build nothing to start
YouTube [Live][Private] -> Start -> Stop (arming and starting are separate presses:
Start is the last chance to check you are ready)
Record Start Recording -> Stop Recording
-->
<!-- THREE FIXED SLOTS (creator, 2026-11-13): [Build Scenes] [YouTube] [Record]. The bar's
shape NEVER changes — every transition happens inside one slot, so the neighbouring
buttons cannot move. Each slot is its own little flow. -->
<Border Background="{StaticResource Brush.SegmentTrack}" CornerRadius="12" Padding="4"
VerticalAlignment="Center" Margin="0,0,12,0"
KeyboardNavigation.TabNavigation="Cycle" KeyboardNavigation.DirectionalNavigation="Cycle">
<StackPanel Orientation="Horizontal">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 1. BUILD SCENES -->
<Button x:Name="BuildSegmentButton" Style="{StaticResource SegmentHalf}"
Tag="{Binding IsBuildActivity}" Command="{Binding SelectBuildActivityCommand}"
ToolTip="Edit layers, scenes and webcam placement">
<!-- ══ SLOT 1: BUILD SCENES ══ -->
<Button x:Name="BuildSegmentButton" Grid.Column="0" Style="{StaticResource SegmentHalf}"
Tag="True" Command="{Binding SelectBuildCommand}"
IsEnabled="{Binding IsBuildScenesEnabled}"
ToolTip="{Binding BuildScenesTooltip}" FocusVisualStyle="{x:Null}">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="{Binding IsBuildActivity}" Data="M3,4h8v6H3V4zM13,4h8v6h-8V4zM3,12h8v8H3V12zM13,12h8v8h-8V12z"/>
<TextBlock Text="Build Scenes" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
<Path Style="{StaticResource SegmentIcon}" Tag="True" Data="M3,4h8v6H3V4zM13,4h8v6h-8V4zM3,12h8v8H3V12zM13,12h8v8h-8V12z"/>
<TextBlock Text="Build Scenes" FontSize="12" FontWeight="Bold" Margin="6,0,0,0"
VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<!-- 2. YOUTUBE -->
<Button x:Name="YouTubeActivityButton" Style="{StaticResource SegmentHalf}"
Tag="{Binding IsYouTubeActivity}" Command="{Binding SelectYouTubeActivityCommand}"
ToolTip="Stream to YouTube">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="{Binding IsYouTubeActivity}" Data="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
<TextBlock Text="YouTube" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<!-- YouTube's own flow, in place. -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center"
Visibility="{Binding ShowYouTubeModeChoices, Converter={StaticResource BoolToVis}}">
<Button Style="{StaticResource OutputChip}" Tag="{Binding IsLiveModeChosen}"
Command="{Binding ChooseLiveModeCommand}" ToolTip="Public livestream">
<!-- ══ SLOT 2: YOUTUBE ══ -->
<Grid Grid.Column="1" Margin="4,0">
<!-- State 0: idle -->
<Button Style="{StaticResource SegmentHalf}" Tag="False"
Command="{Binding OpenYouTubeCommand}" FocusVisualStyle="{x:Null}"
Visibility="{Binding IsYouTubeIdle, Converter={StaticResource BoolToVis}}"
ToolTip="Stream to YouTube">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Tag="{Binding IsLiveModeChosen}" Data="M8,5v14l11,-7z"/>
<TextBlock Text="Live" FontSize="12" Margin="5,0,0,0" VerticalAlignment="Center"
<Path Style="{StaticResource SegmentIcon}" Tag="False" Data="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
<TextBlock Text="YouTube" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Tag="{Binding IsPrivateModeChosen}"
Command="{Binding ChoosePrivateModeCommand}" ToolTip="Private stream — nothing public is published">
<!-- State 1: choose privacy — grouped inside a bordered sub-pill -->
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
Visibility="{Binding IsYouTubeChoosingPrivacy, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Tag="{Binding IsPrivateModeChosen}" Data="M7,10V7a5,5 0 0,1 10,0v3h1a1,1 0 0,1 1,1v9a1,1 0 0,1 -1,1H6a1,1 0 0,1 -1,-1v-9a1,1 0 0,1 1,-1h1zM9,10h6V7a3,3 0 0,0 -6,0v3z"/>
<TextBlock Text="Private" FontSize="12" Margin="5,0,0,0" VerticalAlignment="Center"
<Button Style="{StaticResource OutputChip}" Command="{Binding ChooseLiveCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Public livestream">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M8,5v14l11,-7z"/>
<TextBlock Text="Live" FontSize="12" Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding ChoosePrivateCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Private stream — nothing public is published">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M7,10V7a5,5 0 0,1 10,0v3h1a1,1 0 0,1 1,1v9a1,1 0 0,1 -1,1H6a1,1 0 0,1 -1,-1v-9a1,1 0 0,1 1,-1h1zM9,10h6V7a3,3 0 0,0 -6,0v3z"/>
<TextBlock Text="Private" FontSize="12" Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelYouTubeCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
</Button>
</StackPanel>
</Border>
<!-- State 2: armed — amber border, because the next click goes on air -->
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
BorderBrush="{StaticResource Brush.Attention}" BorderThickness="1"
Visibility="{Binding IsYouTubeArmed, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource OutputChip}" Command="{Binding StartYouTubeCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Start broadcasting now">
<StackPanel Orientation="Horizontal">
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
Fill="{StaticResource Brush.Attention}"/>
<TextBlock Text="{Binding YouTubeArmLabel}" FontSize="12" FontWeight="Bold"
Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelYouTubeCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
</Button>
</StackPanel>
</Border>
<!-- State 3: streaming -->
<Button Style="{StaticResource SegmentHalf}" Tag="True"
Command="{Binding StopYouTubeCommand}" FocusVisualStyle="{x:Null}"
Visibility="{Binding IsYouTubeStreaming, Converter={StaticResource BoolToVis}}"
ToolTip="End the broadcast">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="True" Data="M6,6h12v12H6z"/>
<TextBlock Text="{Binding YouTubeStopLabel}" FontSize="12" FontWeight="Bold"
Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
</StackPanel>
<Button Style="{StaticResource OutputChip}" Command="{Binding StartChosenStreamCommand}"
Visibility="{Binding ShowYouTubeStart, Converter={StaticResource BoolToVis}}"
ToolTip="Start the stream you armed">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M8,5v14l11,-7z"/>
<TextBlock Text="Start" FontSize="12" FontWeight="Bold" Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding EndStreamCommand}"
Visibility="{Binding ShowYouTubeStop, Converter={StaticResource BoolToVis}}"
ToolTip="End the livestream">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M6,6h12v12H6z"/>
<TextBlock Text="Stop" FontSize="12" FontWeight="Bold" Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
</Grid>
<!-- 3. RECORD -->
<Button x:Name="RecordActivityButton" Style="{StaticResource SegmentHalf}"
Tag="{Binding IsRecordActivity}" Command="{Binding SelectRecordActivityCommand}"
ToolTip="Record to a local MP4">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="{Binding IsRecordActivity}" 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="Record" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<!-- Record's own flow: the clock while it runs, and one action that swaps. -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center"
Visibility="{Binding IsRecordActivity, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal" Margin="2,0,6,0" VerticalAlignment="Center"
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 RecordElapsedLabel}" FontSize="12" FontWeight="Bold"
FontFamily="Consolas" Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
<Button Style="{StaticResource OutputChip}" Command="{Binding StartRecordingCommand}"
Visibility="{Binding ShowRecordStart, Converter={StaticResource BoolToVis}}">
<!-- ══ SLOT 3: RECORD ══ -->
<Grid Grid.Column="2" Margin="4,0,0,0">
<!-- State 0: idle -->
<Button Style="{StaticResource SegmentHalf}" Tag="False"
Command="{Binding OpenRecordCommand}" FocusVisualStyle="{x:Null}"
Visibility="{Binding IsRecordIdle, Converter={StaticResource BoolToVis}}"
ToolTip="Record to a local MP4">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" 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="Start Recording" FontSize="12" FontWeight="Bold" Margin="5,0,0,0"
VerticalAlignment="Center" Foreground="{StaticResource Brush.SegmentTextActive}"/>
<Path Style="{StaticResource SegmentIcon}" Tag="False" 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="Record" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding EndStreamCommand}"
Visibility="{Binding ShowRecordStop, Converter={StaticResource BoolToVis}}">
<!-- State 1: armed -->
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
BorderBrush="{StaticResource Brush.Attention}" BorderThickness="1"
Visibility="{Binding IsRecordArmed, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M6,6h12v12H6z"/>
<TextBlock Text="Stop Recording" FontSize="12" FontWeight="Bold" Margin="5,0,0,0"
VerticalAlignment="Center" Foreground="{StaticResource Brush.SegmentTextActive}"/>
<Button Style="{StaticResource OutputChip}" Command="{Binding StartRecordingCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Start recording now">
<StackPanel Orientation="Horizontal">
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
Fill="{StaticResource Brush.Attention}"/>
<TextBlock Text="Start Recording" FontSize="12" FontWeight="Bold" Margin="5,0,0,0"
VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelRecordCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
</Button>
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</Border>
<!-- State 2: recording — green pulse, clock, stop -->
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
Visibility="{Binding IsRecordRecording, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
Fill="{StaticResource Brush.RecordLive}"
Opacity="{Binding RecDotOpacity, Mode=OneWay}"/>
<TextBlock Text="{Binding RecordElapsedLabel}" FontSize="11" FontWeight="Bold"
FontFamily="Consolas" Margin="5,0,8,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
<Button Style="{StaticResource OutputChip}" Command="{Binding StopRecordingCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Stop recording and save">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M6,6h12v12H6z"/>
<TextBlock Text="Stop" FontSize="12" FontWeight="Bold" Margin="5,0,0,0"
VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
</StackPanel>
</Border>
</Grid>
</Grid>
</Border>
<!-- RUNNING: one reality line (dot + word + elapsed) and the stop -->
+13 -3
View File
@@ -204,10 +204,20 @@ public partial class MainWindow : Window
// Esc leaves a sub-menu (creator ruling 2026-11-12): the Output world is a sub-menu, and its
// back chevron is gone in favour of this. The bar says so under the switches.
if (e.Key == System.Windows.Input.Key.Escape && _viewModel.Activity != ViewModels.TopActivity.Build)
// Esc backs out of whichever slot is mid-flow (creator, 2026-11-13): the slots cancel in place
// rather than moving the bar around.
if (e.Key == System.Windows.Input.Key.Escape)
{
_viewModel.SelectBuildActivityCommand.Execute(null);
e.Handled = true;
if (_viewModel.IsYouTubeChoosingPrivacy || _viewModel.IsYouTubeArmed)
{
_viewModel.CancelYouTubeCommand.Execute(null);
e.Handled = true;
}
else if (_viewModel.IsRecordArmed)
{
_viewModel.CancelRecordCommand.Execute(null);
e.Handled = true;
}
}
}
@@ -656,7 +656,7 @@ public partial class MainViewModel : ViewModelBase
}
OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(LiveElapsedText));
OnPropertyChanged(nameof(RecordActionLabel));
OnPropertyChanged(nameof(RecordElapsedLabel));
}
private void OnLiveTimerTick(object? sender, EventArgs e)
@@ -666,7 +666,7 @@ public partial class MainViewModel : ViewModelBase
_recordLength = _liveElapsed;
_recDotPulse = _recDotPulse > 0.5 ? 0.2 : 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(RecordActionLabel));
OnPropertyChanged(nameof(RecordElapsedLabel));
// TASK 55·3: the last useful warning before the encoder fails for a reason it won't explain.
if (IsRecording && !_lowDiskWarned && _recordFolder is { } folder
+4 -4
View File
@@ -87,7 +87,7 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
RaiseTopBarModes();
RaiseActivityStage(); // IsLive drives ShowOutputStage
RaiseSlotStates(); // IsLive drives ShowOutputStage
UpdateLiveVisuals();
}
}
@@ -118,7 +118,7 @@ public partial class MainViewModel : ViewModelBase
{
if (!SetProperty(ref _recordPillOn, value)) return;
if (value && _onAirPillOn) OnAirPillOn = false; // record-OR-live — arming REC drops ON-AIR
RaiseActivityStage();
RaiseSlotStates();
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(PrimaryStartKind));
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
@@ -138,7 +138,7 @@ public partial class MainViewModel : ViewModelBase
// intent, not reality; the Start face turns this into a sign-in.
if (!SetProperty(ref _onAirPillOn, value)) return;
if (value && _recordPillOn) RecordPillOn = false; // record-OR-live — arming ON-AIR drops REC
RaiseActivityStage();
RaiseSlotStates();
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(PrimaryStartKind));
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
@@ -171,7 +171,7 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(EndButtonText));
OnPropertyChanged(nameof(ShowTestButton));
RaiseTopBarModes();
RaiseActivityStage(); // Start Recording <-> Stop Recording, and the REC clock
RaiseSlotStates(); // Start Recording <-> Stop Recording, and the REC clock
UpdateSessionTimer();
}
}
+125 -79
View File
@@ -3,115 +3,161 @@ using ytLive.Helpers;
namespace ytLive.ViewModels;
/// <summary>What the app is doing (creator, 2026-11-13): build, record, or stream. Three activities,
/// three buttons, always visible.</summary>
/// <summary>Which YouTube stream is armed. Choosing does not start it — Start does.</summary>
public enum StreamMode
/// <summary>Where the YouTube slot is in its own five-step flow.</summary>
public enum YouTubeSlotState
{
None,
Live,
Private,
/// <summary>[ ▶ YouTube ]</summary>
Idle,
/// <summary>[ ▶ Live | 🔒 Private | ✕ ]</summary>
ChoosePrivacy,
/// <summary>[ ● Start Live | ✕ ] or [ ● Start Private | ✕ ]</summary>
Armed,
/// <summary>[ ■ Stop Live ] or [ ■ Stop Private ]</summary>
Streaming,
}
public enum TopActivity
/// <summary>Where the Record slot is in its own three-step flow.</summary>
public enum RecordSlotState
{
Build,
YouTube,
Record,
/// <summary>[ ◉ Record ]</summary>
Idle,
/// <summary>[ ● Start Recording | ✕ ]</summary>
Armed,
/// <summary>[ ■ Stop Recording ]</summary>
Recording,
}
/// <summary>
/// The top bar is THREE FIXED SLOTS (creator, 2026-11-13): Build Scenes, YouTube, Record. A slot never
/// changes the bar's shape — every state transition happens INSIDE its own slot, and the neighbouring
/// slots never move. Earlier attempts added completion states as siblings in the bar, which read as five
/// ungrouped toolbar items and pushed Record around.
/// </summary>
public partial class MainViewModel
{
private TopActivity _activity = TopActivity.Build;
private YouTubeSlotState _youTubeStep = YouTubeSlotState.Idle;
private RecordSlotState _recordStep = RecordSlotState.Idle;
private bool _podcastLive; // true = Private chosen (the private-visibility test stream)
public TopActivity Activity => _activity;
// ─── Build Scenes slot ───
// Enabled only while BOTH other slots are idle: editing a scene under a live stream or a running
// recording is what the lock is for, and the tooltip says so out loud.
public bool IsBuildScenesEnabled =>
_youTubeStep == YouTubeSlotState.Idle && _recordStep == RecordSlotState.Idle;
public bool IsBuildActivity => _activity == TopActivity.Build;
public bool IsYouTubeActivity => _activity == TopActivity.YouTube;
public bool IsRecordActivity => _activity == TopActivity.Record;
public string BuildScenesTooltip => IsBuildScenesEnabled
? "Edit layers, scenes and webcam placement"
: "Scene editing is locked while streaming or recording";
// ─── Each activity's own flow ───
// Build: nothing to start. YouTube: Live or Private, then Stop. Record: Start, then Stop.
// ─── YouTube slot ───
public YouTubeSlotState YouTubeStep => _youTubeStep;
public bool IsYouTubeIdle => _youTubeStep == YouTubeSlotState.Idle;
public bool IsYouTubeChoosingPrivacy => _youTubeStep == YouTubeSlotState.ChoosePrivacy;
public bool IsYouTubeArmed => _youTubeStep == YouTubeSlotState.Armed;
public bool IsYouTubeStreaming => _youTubeStep == YouTubeSlotState.Streaming;
// YouTube is two steps ON PURPOSE (creator, 2026-11-13): choosing Live or Private only ARMS it,
// because Start is the last moment to tell someone "check you are ready". Collapsing the two would
// trade that prompt for one fewer click.
private StreamMode _streamMode = StreamMode.None;
public bool IsLiveChosen => !_podcastLive;
public bool IsPrivateChosen => _podcastLive;
public string YouTubeArmLabel => _podcastLive ? "Start Private" : "Start Live";
public string YouTubeStopLabel => _podcastLive ? "Stop Private" : "Stop Live";
public bool IsLiveModeChosen => _streamMode == StreamMode.Live;
public bool IsPrivateModeChosen => _streamMode == StreamMode.Private;
public bool ShowYouTubeModeChoices => IsYouTubeActivity && !IsLive && _streamMode == StreamMode.None;
public bool ShowYouTubeStart => IsYouTubeActivity && !IsLive && _streamMode != StreamMode.None;
public bool ShowYouTubeStop => IsYouTubeActivity && IsLive;
public bool ShowRecordStart => IsRecordActivity && !IsRecording;
public bool ShowRecordStop => IsRecordActivity && IsRecording;
/// <summary>The Record activity's own words: its name while idle, its action while chosen.</summary>
public string RecordActionLabel => IsRecording ? "Stop Recording" : "Start Recording";
// ─── Record slot ───
public RecordSlotState RecordStep => _recordStep;
public bool IsRecordIdle => _recordStep == RecordSlotState.Idle;
public bool IsRecordArmed => _recordStep == RecordSlotState.Armed;
public bool IsRecordRecording => _recordStep == RecordSlotState.Recording;
public string RecordElapsedLabel => $"REC: {LiveElapsedText}";
public ICommand SelectBuildActivityCommand { get; private set; } = null!;
public ICommand SelectYouTubeActivityCommand { get; private set; } = null!;
public ICommand SelectRecordActivityCommand { get; private set; } = null!;
public ICommand ChooseLiveModeCommand { get; private set; } = null!;
public ICommand ChoosePrivateModeCommand { get; private set; } = null!;
public ICommand StartChosenStreamCommand { get; private set; } = null!;
public ICommand SelectBuildCommand { get; private set; } = null!;
public ICommand OpenYouTubeCommand { get; private set; } = null!;
public ICommand ChooseLiveCommand { get; private set; } = null!;
public ICommand ChoosePrivateCommand { get; private set; } = null!;
public ICommand CancelYouTubeCommand { get; private set; } = null!;
public ICommand StartYouTubeCommand { get; private set; } = null!;
public ICommand StopYouTubeCommand { get; private set; } = null!;
public ICommand OpenRecordCommand { get; private set; } = null!;
public ICommand StartRecordingCommand { get; private set; } = null!;
public ICommand CancelRecordCommand { get; private set; } = null!;
public ICommand StopRecordingCommand { get; private set; } = null!;
private void InitializeActivityCommands()
{
SelectBuildActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.Build));
SelectYouTubeActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.YouTube));
SelectRecordActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.Record));
SelectBuildCommand = new RelayCommand(_ => RaiseSlotStates(), _ => IsBuildScenesEnabled);
OpenYouTubeCommand = new RelayCommand(_ => SetYouTubeStep(YouTubeSlotState.ChoosePrivacy));
ChooseLiveCommand = new RelayCommand(_ => { _podcastLive = false; SetYouTubeStep(YouTubeSlotState.Armed); });
ChoosePrivateCommand = new RelayCommand(_ => { _podcastLive = true; SetYouTubeStep(YouTubeSlotState.Armed); });
CancelYouTubeCommand = new RelayCommand(_ => SetYouTubeStep(YouTubeSlotState.Idle));
ChooseLiveModeCommand = new RelayCommand(_ => SetStreamMode(StreamMode.Live), _ => ShowYouTubeModeChoices);
ChoosePrivateModeCommand = new RelayCommand(_ => SetStreamMode(StreamMode.Private), _ => ShowYouTubeModeChoices);
// Start is its own press: the last chance to say "you're about to be live".
// Signed out still routes to sign-in rather than sitting dead (no-dead-ends rule).
StartChosenStreamCommand = new RelayCommand(_ =>
StartYouTubeCommand = new RelayCommand(_ =>
{
if (!IsConnected) { _ = SignInAsync(); return; }
if (_streamMode == StreamMode.Private) BeginTestStream();
else StartSession();
}, _ => ShowYouTubeStart);
if (_podcastLive) BeginTestStream(); else StartSession();
SetYouTubeStep(YouTubeSlotState.Streaming);
});
StopYouTubeCommand = new RelayCommand(_ =>
{
StopStream();
SetYouTubeStep(YouTubeSlotState.Idle);
});
OpenRecordCommand = new RelayCommand(_ => SetRecordStep(RecordSlotState.Armed));
StartRecordingCommand = new RelayCommand(_ =>
{
BeginRecordOnly();
SetRecordStep(RecordSlotState.Recording);
});
CancelRecordCommand = new RelayCommand(_ => SetRecordStep(RecordSlotState.Idle));
StopRecordingCommand = new RelayCommand(_ =>
{
StopStream();
SetRecordStep(RecordSlotState.Idle);
});
}
/// <summary>Switches activity. Build is the only one that edits; the other two take the layers away
/// but leave the button lit, so the creator can see where they are and step back.</summary>
internal void SelectActivity(TopActivity activity)
private void SetYouTubeStep(YouTubeSlotState step)
{
_activity = activity;
OnPropertyChanged(nameof(Activity));
OnPropertyChanged(nameof(IsBuildActivity));
OnPropertyChanged(nameof(IsYouTubeActivity));
OnPropertyChanged(nameof(IsRecordActivity));
EditingActive = activity == TopActivity.Build;
// The underlying outputs follow the activity, so the existing start commands and the
// record/live paths agree with what the bar is showing.
RecordPillOn = activity == TopActivity.Record;
OnAirPillOn = activity == TopActivity.YouTube;
_streamMode = StreamMode.None; // the YouTube flow always restarts at the choice
RaiseActivityStage();
_youTubeStep = step;
RaiseSlotStates();
}
private void SetStreamMode(StreamMode mode)
private void SetRecordStep(RecordSlotState step)
{
_streamMode = mode;
OnPropertyChanged(nameof(IsLiveModeChosen));
OnPropertyChanged(nameof(IsPrivateModeChosen));
RaiseActivityStage();
_recordStep = step;
RaiseSlotStates();
}
internal void RaiseActivityStage()
/// <summary>One place that announces everything the three slots show, so no caller can forget a flag.
/// </summary>
internal void RaiseSlotStates()
{
OnPropertyChanged(nameof(ShowYouTubeModeChoices));
OnPropertyChanged(nameof(ShowYouTubeStart));
OnPropertyChanged(nameof(ShowYouTubeStop));
OnPropertyChanged(nameof(ShowRecordStart));
OnPropertyChanged(nameof(ShowRecordStop));
OnPropertyChanged(nameof(IsBuildScenesEnabled));
OnPropertyChanged(nameof(BuildScenesTooltip));
OnPropertyChanged(nameof(YouTubeStep));
OnPropertyChanged(nameof(IsYouTubeIdle));
OnPropertyChanged(nameof(IsYouTubeChoosingPrivacy));
OnPropertyChanged(nameof(IsYouTubeArmed));
OnPropertyChanged(nameof(IsYouTubeStreaming));
OnPropertyChanged(nameof(IsLiveChosen));
OnPropertyChanged(nameof(IsPrivateChosen));
OnPropertyChanged(nameof(YouTubeArmLabel));
OnPropertyChanged(nameof(YouTubeStopLabel));
OnPropertyChanged(nameof(RecordStep));
OnPropertyChanged(nameof(IsRecordIdle));
OnPropertyChanged(nameof(IsRecordArmed));
OnPropertyChanged(nameof(IsRecordRecording));
OnPropertyChanged(nameof(RecordElapsedLabel));
// Build Scenes is the edit surface, so the lock has to reach editing too.
EditingActive = IsBuildScenesEnabled;
}
}
+12 -1
View File
@@ -145,7 +145,18 @@ Placement math itself is covered by `WindowPlacementTests` (pure function, no HW
`FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard:
`ytLive.Tests/GeometryFieldWidthTests.cs`
- Resolution tiers: 1080p60@12 (default) → 1080p30@10 → 720p60@6 → 720p30@6 → **Vertical 1080p30@10 and 1080p60@12 (9:16, 1080×1920)** — the `@` is the CBR target the encoder is asked to hold, between YouTube's H.264 recommendation and the practitioner sweet spot (its 14/17/8 recommendations are the max, the tier minimums 5/6/3 the step-down floor). Vertical mirrors its landscape pair: same pixel count, same rate. The old flat 8 Mbps was an inherited round number, not a measurement. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. Under the picker sits the creator's own **rate box** (Mbps, per tier, persisted as `Video.Rate.<tier>`): the tier chooses geometry, the box chooses bits, clamped at the tier's YouTube minimum and otherwise unbounded because live step-down is the thing that catches a wrong guess. Its tooltip carries YouTube's recommendation for the tier (`Models/RateLadder`). Every consumer — encoder options, step-down policy, clip buffer — reads `MainViewModel.EffectiveQuality`, never the raw tier. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
- **The top bar has ONE decision (creator ruling 2026-11-12): `[ Build Scenes ][ Record / Go Live ]`.**
- **The top bar is THREE FIXED SLOTS (creator ruling 2026-11-13): `[Build Scenes] [YouTube] [Record]`.**
The bar's shape never changes — every transition happens INSIDE one slot, so neighbouring buttons cannot
move. Attempts that added completion states as siblings (`Live`, `Private`, `Stop`) read as five
ungrouped toolbar items and pushed Record around; that is the mistake this replaces.
Slot flows (`MainViewModel.TopBar.cs`, `YouTubeSlotState` / `RecordSlotState`):
- **YouTube:** Idle → ChoosePrivacy (`Live | Private | ✕`) → Armed (`Start Live|Start Private | ✕`, amber
border because the next click goes on air) → Streaming (`Stop Live|Stop Private`).
- **Record:** Idle → Armed (`Start Recording | ✕`) → Recording (`● REC hh:mm:ss` + `Stop`).
- **Build Scenes** is enabled only while BOTH action slots are Idle; otherwise it is disabled at 40%
opacity with the tooltip "Scene editing is locked while streaming or recording".
Arming and starting stay separate presses: Start is the last "are you ready?" moment.
- **Legacy note — superseded by the three-slot bar above:**
Build Scenes ships highlighted and **is** edit mode — the standalone EDIT toggle is gone, so "which
world am I in?" and "can I edit?" cannot disagree (`MainViewModel.TopBar.cs`, `BarWorld`). The segment
leads the centre cluster and stays present in every state; clicking the highlighted half re-enters it,
-80
View File
@@ -1,80 +0,0 @@
using System;
using System.IO;
using Xunit;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// Three activities, always visible (creator, 2026-11-13): build, stream, record. The active one is lit
/// and runs its own flow in place; the other two stay on the bar and dim, so there is no state where the
/// creator cannot see where they are or how to step back.
/// </summary>
[Collection("MainViewModelLayoutPath")]
public class TopBarActivityTests
{
private static MainViewModel NewVm(string db)
{
MainViewModel.LayoutPathOverride = db;
return new MainViewModel();
}
[Fact]
public void Three_activities_stay_on_the_bar_and_the_active_one_owns_its_flow()
{
var db = Path.Combine(Path.GetTempPath(), $"llamacasty-activity-{Guid.NewGuid():N}.db");
try
{
var vm = NewVm(db);
// Launch: Build is the activity and the only one editing.
Assert.Equal(TopActivity.Build, vm.Activity);
Assert.True(vm.IsBuildActivity);
Assert.True(vm.IsEditMode);
Assert.False(vm.IsRecording);
// Record: its own flow becomes Start, and editing steps aside.
vm.SelectRecordActivityCommand.Execute(null);
Assert.True(vm.IsRecordActivity);
Assert.False(vm.IsEditMode);
Assert.True(vm.ShowRecordStart);
Assert.False(vm.ShowRecordStop);
Assert.Equal("Start Recording", vm.RecordActionLabel);
// Recording swaps the flow to Stop, and the elapsed label carries REC.
vm.IsRecording = true;
Assert.False(vm.ShowRecordStart);
Assert.True(vm.ShowRecordStop);
Assert.Equal("Stop Recording", vm.RecordActionLabel);
Assert.StartsWith("REC: ", vm.RecordElapsedLabel);
vm.IsRecording = false;
// YouTube: choosing Live only ARMS it. Start is a second press — the "are you ready?" moment.
vm.SelectYouTubeActivityCommand.Execute(null);
Assert.True(vm.IsYouTubeActivity);
Assert.True(vm.ShowYouTubeModeChoices);
Assert.False(vm.ShowYouTubeStart);
vm.ChooseLiveModeCommand.Execute(null);
Assert.True(vm.IsLiveModeChosen);
Assert.False(vm.ShowYouTubeModeChoices);
Assert.True(vm.ShowYouTubeStart);
vm.ChoosePrivateModeCommand.Execute(null);
Assert.True(vm.IsPrivateModeChosen);
Assert.False(vm.IsLiveModeChosen);
// Back to Build: editing returns, and the YouTube choice is forgotten, not carried.
vm.SelectBuildActivityCommand.Execute(null);
Assert.True(vm.IsBuildActivity);
Assert.True(vm.IsEditMode);
Assert.False(vm.ShowYouTubeModeChoices);
Assert.False(vm.ShowYouTubeStart);
}
finally
{
MainViewModel.LayoutPathOverride = null;
try { File.Delete(db); } catch (IOException) { }
}
}
}
+6 -8
View File
@@ -87,18 +87,16 @@ public sealed class TopBarModeTests
// A local recording lives in the Record activity (2026-11-13): the bar keeps its three
// buttons and the Record one carries REC + the clock + Stop, so the separate reality line
// stays away — it would be a second set of the same facts.
vm.SelectRecordActivityCommand.Execute(null);
vm.IsRecording = true;
Assert.True(vm.IsRecordActivity);
Assert.True(vm.ShowRecordStop);
vm.OpenRecordCommand.Execute(null);
vm.StartRecordingCommand.Execute(null);
Assert.True(vm.IsRecordRecording);
Assert.False(vm.RunningVisible);
Assert.StartsWith("REC: ", vm.RecordElapsedLabel);
vm.IsRecording = false;
vm.StopRecordingCommand.Execute(null);
// A live stream is the YouTube activity's flow; the reality line pins the mode.
vm.SelectYouTubeActivityCommand.Execute(null);
// A live stream is the YouTube slot's flow; the reality line pins the mode.
vm.StreamStatus = StreamStatus.Streaming;
Assert.True(vm.ShowYouTubeStop);
Assert.True(vm.RunningVisible);
Assert.Equal("LIVE", vm.RunningText);
Assert.Equal("#ff5451", vm.RunningDotBrush);
vm.IsTestStream = true;
+68
View File
@@ -0,0 +1,68 @@
using System;
using System.IO;
using Xunit;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// The bar is three FIXED SLOTS (creator, 2026-11-13). Each slot owns its own flow and mutates only its
/// own interior, so neighbouring buttons never move; and while either action slot is past Idle, Build
/// Scenes is locked.
/// </summary>
[Collection("MainViewModelLayoutPath")]
public class TopBarSlotTests
{
[Fact]
public void Each_slot_runs_its_own_flow_in_place_and_build_locks_while_they_do()
{
var db = Path.Combine(Path.GetTempPath(), $"llamacasty-slots-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = db;
try
{
var vm = new MainViewModel();
// All three idle: YouTube and Record are single buttons, Build is live.
Assert.True(vm.IsYouTubeIdle);
Assert.True(vm.IsRecordIdle);
Assert.True(vm.IsBuildScenesEnabled);
Assert.Contains("Edit layers", vm.BuildScenesTooltip);
// YouTube: idle -> choose privacy -> armed -> (start) streaming.
vm.OpenYouTubeCommand.Execute(null);
Assert.True(vm.IsYouTubeChoosingPrivacy);
Assert.False(vm.IsBuildScenesEnabled);
Assert.Contains("locked", vm.BuildScenesTooltip);
vm.ChoosePrivateCommand.Execute(null);
Assert.True(vm.IsYouTubeArmed);
Assert.Equal("Start Private", vm.YouTubeArmLabel);
vm.ChooseLiveCommand.Execute(null); // switching choice while armed is allowed
Assert.Equal("Start Live", vm.YouTubeArmLabel);
// Cancel returns that slot to Idle — and Build unlocks because both slots are idle again.
vm.CancelYouTubeCommand.Execute(null);
Assert.True(vm.IsYouTubeIdle);
Assert.True(vm.IsBuildScenesEnabled);
// Record: idle -> armed -> (start) recording, with the clock carrying REC.
vm.OpenRecordCommand.Execute(null);
Assert.True(vm.IsRecordArmed);
Assert.False(vm.IsBuildScenesEnabled);
vm.StartRecordingCommand.Execute(null);
Assert.True(vm.IsRecordRecording);
Assert.StartsWith("REC: ", vm.RecordElapsedLabel);
vm.StopRecordingCommand.Execute(null);
Assert.True(vm.IsRecordIdle);
Assert.True(vm.IsBuildScenesEnabled);
}
finally
{
MainViewModel.LayoutPathOverride = null;
try { File.Delete(db); } catch (IOException) { }
}
}
}
+4 -4
View File
@@ -32,8 +32,8 @@ public class UiShellTests
Assert.Contains("Text=\"Build Scenes\"", topBar);
Assert.Contains("Text=\"YouTube\"", topBar);
Assert.Contains("Text=\"Record\"", topBar);
Assert.Contains("Tag=\"{Binding IsBuildActivity}\"", topBar);
Assert.Contains("Tag=\"{Binding IsYouTubeActivity}\"", topBar);
Assert.Contains("IsEnabled=\"{Binding IsBuildScenesEnabled}\"", topBar);
Assert.Contains("Command=\"{Binding OpenYouTubeCommand}\"", topBar);
Assert.DoesNotContain("x:Name=\"EditBody\"", topBar);
// 2b. The segment is Build-world only: choosing Record / Go Live REPLACES it with the output
@@ -51,8 +51,8 @@ public class UiShellTests
// 2c. The output stage: two labelled switches and the exclusivity promise beside them.
Assert.Contains("Text=\"YouTube\"", topBar);
Assert.Contains("Command=\"{Binding SelectYouTubeActivityCommand}\"", topBar);
Assert.Contains("Command=\"{Binding SelectRecordActivityCommand}\"", topBar);
Assert.Contains("Command=\"{Binding OpenYouTubeCommand}\"", topBar);
Assert.Contains("Command=\"{Binding OpenRecordCommand}\"", topBar);
// 2d. The bar carries NO action button: the output line is the bar's whole middle
// (creator ruling 2026-11-12 — the action is placed elsewhere, on instruction).