feat(topbar): three activities, always visible — build, stream, record

Creator ruling 2026-11-13: "the app does three things. builds. records. live-streams." So the bar holds
three buttons that never disappear; the active one is lit and runs its own flow in place, and the other
two dim but stay clickable, so there is no state where the creator cannot see where they are or how to
step back.

  Build    [Build Scenes]  -> editing on, nothing to start
  YouTube  [Live][Private] -> [Start] -> [Stop]     (Private = the private-visibility test stream)
  Record   [Start Recording] -> [● REC hh:mm:ss][Stop Recording]

Arming and starting are separate presses on purpose (creator): Start is the last chance to tell someone
"check you are ready". Collapsing them would save one click and lose that prompt.

This replaces the two-world model (BarWorld) and its output stage outright, including the chips added
earlier today. The old pills remain the underlying state (OnAirPillOn/RecordPillOn) so the start, stop and
step-down paths are untouched; the activity only decides what the bar shows.

Closing the app no longer waits 6 s for a session that cannot stop: it waits ~2 s, then clears the state
and closes anyway. A window that refuses to close is worse than a stale flag — and that refusal is what
hung the suite in ZoomBoxInteractionTests, which sets IsRecording and closes a real window.

Test: TopBarActivityTests pins all three flows, the arm-then-start split, and that stepping back to Build
forgets the YouTube choice instead of carrying it. TopBarWorldTests is retired with the model it tested.
This commit is contained in:
2026-11-13 11:30:57 -08:00
parent f4caea09ad
commit ba60f9ab3d
9 changed files with 311 additions and 251 deletions
+102 -121
View File
@@ -66,141 +66,122 @@
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. -->
<!-- The segmented mode switcher (creator mock 2026-11-12): pill track, two equal halves,
active half on a lighter pill with off-white bold text and a coral icon. Hover tints
only the inactive half. -->
<!-- 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
-->
<Border Background="{StaticResource Brush.SegmentTrack}" CornerRadius="12" Padding="4"
VerticalAlignment="Center" Margin="0,0,12,0"
KeyboardNavigation.TabNavigation="Cycle" KeyboardNavigation.DirectionalNavigation="Cycle"
Visibility="{Binding IsBuildWorld, Converter={StaticResource BoolToVis}}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button x:Name="BuildSegmentButton" Grid.Column="0" Style="{StaticResource SegmentHalf}"
Tag="{Binding IsBuildWorld}" Command="{Binding EnterBuildWorldCommand}"
KeyboardNavigation.TabNavigation="Cycle" KeyboardNavigation.DirectionalNavigation="Cycle">
<StackPanel Orientation="Horizontal">
<!-- 1. BUILD SCENES -->
<Button x:Name="BuildSegmentButton" Style="{StaticResource SegmentHalf}"
Tag="{Binding IsBuildActivity}" Command="{Binding SelectBuildActivityCommand}"
ToolTip="Edit layers, scenes and webcam placement">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="{Binding IsBuildWorld}"
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="{Binding IsBuildActivity}" Data="M3,4h8v6H3V4zM13,4h8v6h-8V4zM3,12h8v8H3V12zM13,12h8v8h-8V12z"/>
<TextBlock Text="Build Scenes" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<Button x:Name="OutputSegmentButton" Grid.Column="1" Style="{StaticResource SegmentHalf}"
Tag="{Binding IsOutputWorld}" Command="{Binding EnterOutputWorldCommand}"
ToolTip="Choose the output and start the session">
<!-- 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 IsOutputWorld}"
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 / Go-Live" FontSize="12" Margin="6,0,0,0"
VerticalAlignment="Center"
<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>
</Grid>
</Border>
<!-- IDLE: the mode switch + the action(s) of the armed world. The three
controls share one height (build 77 feedback) so they read as one
family — segments host, Start/Go Live, and Test all sit at 32. -->
<!-- THE OUTPUT STAGE (creator, builds 728/729). Three states:
1. nothing chosen: [YouTube] [Local MP4]
2. YouTube chosen: [YouTube(red)] [Live Stream] [Private Stream]
3. Local MP4 chosen: [Local MP4(red)] [Start Recording] -> [End Recording]
"Private Stream" is the private-visibility stream, which is what this app has always
called the test stream. The action chips are never highlighted; only the chosen output
is red. -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center"
Visibility="{Binding ShowOutputStage, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Output:" FontSize="11" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}" Margin="0,0,8,0"/>
<StackPanel Orientation="Vertical" VerticalAlignment="Center">
<Border Background="{StaticResource Brush.SegmentTrack}" CornerRadius="12" Padding="4"
VerticalAlignment="Center">
<!-- 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">
<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"
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">
<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"
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">
<!-- The chosen output, or the two choices while none is chosen. -->
<Button Style="{StaticResource OutputChip}" Tag="{Binding OnAirPillOn}"
Command="{Binding SelectYouTubeCommand}"
Visibility="{Binding YouTubeChipVisible, Converter={StaticResource BoolToVis}}"
ToolTip="Stream to YouTube">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Tag="{Binding OnAirPillOn}" 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 RecordPillOn}"
Command="{Binding SelectLocalMp4Command}"
Visibility="{Binding LocalMp4ChipVisible, Converter={StaticResource BoolToVis}}"
ToolTip="Record to a local MP4">
<Grid>
<!-- Idle face: the record glyph and the output's name. -->
<StackPanel Orientation="Horizontal"
Visibility="{Binding IsRecording, Converter={StaticResource InverseBoolToVis}}">
<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>
<!-- YouTube's two actions: the real stream and the private (test) one. -->
<Button Style="{StaticResource OutputChip}" Command="{Binding GoLiveCommand}"
Visibility="{Binding ShowYouTubeSelected, Converter={StaticResource BoolToVis}}"
ToolTip="Go live on YouTube">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M8,5v14l11,-7z"/>
<TextBlock Text="Live Stream" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextInactive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding TestStreamCommand}"
Visibility="{Binding ShowYouTubeSelected, Converter={StaticResource BoolToVis}}"
ToolTip="Private test 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 Stream" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextInactive}"/>
</StackPanel>
</Button>
<!-- Local MP4's one action, which becomes End while it records. -->
<Button Style="{StaticResource OutputChip}" Command="{Binding StartRecordingCommand}"
Visibility="{Binding ShowStartRecordingAction, Converter={StaticResource BoolToVis}}">
<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" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextInactive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding EndStreamCommand}"
Visibility="{Binding ShowEndRecordingAction, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M6,6h12v12H6z"/>
<TextBlock Text="Stop Recording" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextInactive}"/>
</StackPanel>
</Button>
<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>
</Border>
<TextBlock Text="&lt;esc&gt; to return" FontSize="10" HorizontalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}" Margin="0,2,0,0"/>
</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>
<!-- 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}}">
<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}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding EndStreamCommand}"
Visibility="{Binding ShowRecordStop, 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}"/>
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
<TextBlock Text="One output at a time" FontSize="11" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}" Margin="8,0,0,0"/>
</StackPanel>
</Border>
<!-- RUNNING: one reality line (dot + word + elapsed) and the stop -->
<StackPanel Orientation="Horizontal"