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:
+102
-121
@@ -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="<esc> 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"
|
||||
|
||||
+2
-2
@@ -204,9 +204,9 @@ 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.IsOutputWorld)
|
||||
if (e.Key == System.Windows.Input.Key.Escape && _viewModel.Activity != ViewModels.TopActivity.Build)
|
||||
{
|
||||
_viewModel.EnterBuildWorldCommand.Execute(null);
|
||||
_viewModel.SelectBuildActivityCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,7 +656,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
}
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
OnPropertyChanged(nameof(LiveElapsedText));
|
||||
OnPropertyChanged(nameof(RecordChipText));
|
||||
OnPropertyChanged(nameof(RecordActionLabel));
|
||||
}
|
||||
|
||||
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(RecordChipText));
|
||||
OnPropertyChanged(nameof(RecordActionLabel));
|
||||
|
||||
// TASK 55·3: the last useful warning before the encoder fails for a reason it won't explain.
|
||||
if (IsRecording && !_lowDiskWarned && _recordFolder is { } folder
|
||||
|
||||
@@ -87,7 +87,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
||||
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||||
RaiseTopBarModes();
|
||||
RaiseOutputStage(); // IsLive drives ShowOutputStage
|
||||
RaiseActivityStage(); // 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
|
||||
RaiseOutputStage();
|
||||
RaiseActivityStage();
|
||||
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
|
||||
RaiseOutputStage();
|
||||
RaiseActivityStage();
|
||||
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();
|
||||
RaiseOutputStage(); // Start Recording <-> Stop Recording, and the REC clock
|
||||
RaiseActivityStage(); // Start Recording <-> Stop Recording, and the REC clock
|
||||
UpdateSessionTimer();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,123 +3,115 @@ using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
/// <summary>Which half of the top bar's dual segment is in force.</summary>
|
||||
public enum BarWorld
|
||||
/// <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>Build Scenes — the edit surface. The default at launch (creator ruling 2026-11-12).</summary>
|
||||
Build,
|
||||
|
||||
/// <summary>Record / Go Live — the output surface: which output, and the one action that starts it.</summary>
|
||||
Output,
|
||||
None,
|
||||
Live,
|
||||
Private,
|
||||
}
|
||||
|
||||
public enum TopActivity
|
||||
{
|
||||
Build,
|
||||
YouTube,
|
||||
Record,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The top bar's one decision (creator ruling 2026-11-12): <c>[ Build Scenes ][ Record / Go Live ]</c>.
|
||||
/// Build Scenes <b>is</b> edit mode — there is no separate EDIT toggle any more, so "may I edit?" and
|
||||
/// "which world am I in?" cannot disagree.
|
||||
///
|
||||
/// <para>Clicking the highlighted segment is not a no-op: it re-enters that world, which matters while a
|
||||
/// session runs because Build Scenes is the only way back to the layers (see
|
||||
/// <see cref="LockEditWhileLive"/>).</para>
|
||||
/// </summary>
|
||||
public partial class MainViewModel
|
||||
{
|
||||
private BarWorld _barWorld = BarWorld.Build;
|
||||
private TopActivity _activity = TopActivity.Build;
|
||||
|
||||
public BarWorld BarWorld => _barWorld;
|
||||
public TopActivity Activity => _activity;
|
||||
|
||||
public bool IsBuildWorld => _barWorld == BarWorld.Build;
|
||||
public bool IsOutputWorld => _barWorld == BarWorld.Output;
|
||||
public bool IsBuildActivity => _activity == TopActivity.Build;
|
||||
public bool IsYouTubeActivity => _activity == TopActivity.YouTube;
|
||||
public bool IsRecordActivity => _activity == TopActivity.Record;
|
||||
|
||||
/// <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>
|
||||
// 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;
|
||||
// ─── Each activity's own flow ───
|
||||
// Build: nothing to start. YouTube: Live or Private, then Stop. Record: Start, then Stop.
|
||||
|
||||
// ─── The output stage's three states (creator, builds 728/729) ───
|
||||
// 1. nothing chosen: [YouTube] [Local MP4]
|
||||
// 2. YouTube chosen: [YouTube(red)] [Live Stream] [Private Stream] (Private = the test run)
|
||||
// 3. Local MP4 chosen: [Local MP4(red)] [Start Recording] -> [End Recording] while recording
|
||||
public bool ShowOutputChoice => ShowOutputStage && !RecordPillOn && !OnAirPillOn;
|
||||
public bool ShowYouTubeSelected => ShowOutputStage && OnAirPillOn;
|
||||
public bool ShowLocalMp4Selected => ShowOutputStage && RecordPillOn;
|
||||
/// <summary>The YouTube chip shows while nothing is chosen and while YouTube IS the choice; once
|
||||
/// Local MP4 is chosen the MP4 chip replaces it in the left slot (creator, build 729).</summary>
|
||||
public bool YouTubeChipVisible => ShowOutputChoice || ShowYouTubeSelected;
|
||||
public bool LocalMp4ChipVisible => ShowOutputChoice || ShowLocalMp4Selected;
|
||||
// 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;
|
||||
|
||||
/// <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 IsLiveModeChosen => _streamMode == StreamMode.Live;
|
||||
public bool IsPrivateModeChosen => _streamMode == StreamMode.Private;
|
||||
|
||||
public bool ShowStartRecordingAction => ShowLocalMp4Selected && !IsRecording;
|
||||
public bool ShowEndRecordingAction => ShowLocalMp4Selected && IsRecording;
|
||||
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;
|
||||
|
||||
public ICommand SelectYouTubeCommand { get; private set; } = null!;
|
||||
public ICommand SelectLocalMp4Command { get; private set; } = null!;
|
||||
public ICommand StartRecordingCommand { get; private set; } = null!;
|
||||
public ICommand TestStreamCommand { get; private set; } = null!;
|
||||
public ICommand GoLiveCommand { get; private set; } = null!;
|
||||
/// <summary>The Record activity's own words: its name while idle, its action while chosen.</summary>
|
||||
public string RecordActionLabel => IsRecording ? "Stop Recording" : "Start Recording";
|
||||
public string RecordElapsedLabel => $"REC: {LiveElapsedText}";
|
||||
|
||||
public ICommand EnterBuildWorldCommand { get; private set; } = null!;
|
||||
public ICommand EnterOutputWorldCommand { get; private set; } = null!;
|
||||
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!;
|
||||
|
||||
private void InitializeWorldCommands()
|
||||
private void InitializeActivityCommands()
|
||||
{
|
||||
SelectYouTubeCommand = new RelayCommand(_ => OnAirPillOn = true, _ => ShowOutputChoice);
|
||||
SelectLocalMp4Command = new RelayCommand(_ => RecordPillOn = true, _ => ShowOutputChoice);
|
||||
StartRecordingCommand = new RelayCommand(_ => BeginRecordOnly(), _ => ShowStartRecordingAction);
|
||||
// A signed-out press starts the sign-in flow instead of sitting dead (no-dead-ends rule).
|
||||
TestStreamCommand = new RelayCommand(_ =>
|
||||
SelectBuildActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.Build));
|
||||
SelectYouTubeActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.YouTube));
|
||||
SelectRecordActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.Record));
|
||||
|
||||
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(_ =>
|
||||
{
|
||||
if (!IsConnected) { _ = SignInAsync(); return; }
|
||||
BeginTestStream();
|
||||
}, _ => ShowYouTubeSelected);
|
||||
GoLiveCommand = new RelayCommand(_ =>
|
||||
{
|
||||
if (!IsConnected) { _ = SignInAsync(); return; }
|
||||
StartSession();
|
||||
}, _ => ShowYouTubeSelected);
|
||||
|
||||
EnterBuildWorldCommand = new RelayCommand(_ => EnterWorld(BarWorld.Build));
|
||||
EnterOutputWorldCommand = new RelayCommand(_ => EnterWorld(BarWorld.Output));
|
||||
if (_streamMode == StreamMode.Private) BeginTestStream();
|
||||
else StartSession();
|
||||
}, _ => ShowYouTubeStart);
|
||||
}
|
||||
|
||||
/// <summary>Everything the output stage shows or hides, in one place so no caller can forget
|
||||
/// one of them.</summary>
|
||||
internal void RaiseOutputStage()
|
||||
/// <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)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowOutputStage));
|
||||
OnPropertyChanged(nameof(ShowOutputChoice));
|
||||
OnPropertyChanged(nameof(ShowYouTubeSelected));
|
||||
OnPropertyChanged(nameof(YouTubeChipVisible));
|
||||
OnPropertyChanged(nameof(LocalMp4ChipVisible));
|
||||
OnPropertyChanged(nameof(ShowLocalMp4Selected));
|
||||
OnPropertyChanged(nameof(ShowStartRecordingAction));
|
||||
OnPropertyChanged(nameof(ShowEndRecordingAction));
|
||||
OnPropertyChanged(nameof(RecordChipText));
|
||||
_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();
|
||||
}
|
||||
|
||||
/// <summary>Switches worlds. Re-entering the current one still re-asserts its edit state, which is
|
||||
/// what lets a creator take EDIT back mid-show by clicking Build Scenes.</summary>
|
||||
internal void EnterWorld(BarWorld world)
|
||||
private void SetStreamMode(StreamMode mode)
|
||||
{
|
||||
_barWorld = world;
|
||||
_streamMode = mode;
|
||||
OnPropertyChanged(nameof(IsLiveModeChosen));
|
||||
OnPropertyChanged(nameof(IsPrivateModeChosen));
|
||||
RaiseActivityStage();
|
||||
}
|
||||
|
||||
// Entering a world starts with NOTHING chosen inside it (creator, build 729): the output
|
||||
// switches are a fresh decision each time, not a remembered one.
|
||||
if (world == BarWorld.Output)
|
||||
{
|
||||
RecordPillOn = false;
|
||||
OnAirPillOn = false;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(BarWorld));
|
||||
OnPropertyChanged(nameof(IsBuildWorld));
|
||||
OnPropertyChanged(nameof(IsOutputWorld));
|
||||
EditingActive = world == BarWorld.Build;
|
||||
OnPropertyChanged(nameof(ShowOutputStage));
|
||||
RaiseOutputStage();
|
||||
internal void RaiseActivityStage()
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowYouTubeModeChoices));
|
||||
OnPropertyChanged(nameof(ShowYouTubeStart));
|
||||
OnPropertyChanged(nameof(ShowYouTubeStop));
|
||||
OnPropertyChanged(nameof(ShowRecordStart));
|
||||
OnPropertyChanged(nameof(ShowRecordStop));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,7 +342,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
OpenHotkeyConfigCommand = new RelayCommand(_ => OpenHotkeyConfig());
|
||||
ChangeAccountCommand = new RelayCommand(_ => _ = ChangeAccountAsync());
|
||||
LogoutCommand = new RelayCommand(_ => _ = SignOutYouTubeAsync());
|
||||
InitializeWorldCommands();
|
||||
InitializeActivityCommands();
|
||||
|
||||
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
|
||||
_activeLayoutPath = _layoutStore.ActivePath;
|
||||
@@ -635,10 +635,19 @@ public partial class MainViewModel : ViewModelBase
|
||||
|
||||
_shuttingDown = true;
|
||||
StopStream();
|
||||
for (var i = 0; i < 120 && (IsLive || IsRecording); i++) // up to ~6 s
|
||||
for (var i = 0; i < 40 && (IsLive || IsRecording); i++) // ~2 s: long enough for a flush
|
||||
await Task.Delay(50).ConfigureAwait(true);
|
||||
|
||||
return !IsLive && !IsRecording;
|
||||
if (IsLive || IsRecording)
|
||||
{
|
||||
// The stop could not finish (no pump, a dead encoder, a test harness). A window that cannot
|
||||
// close is a worse failure than a session flag left set, so clear the UI state and go.
|
||||
AppLog.Write("Shutdown: session did not stop in time; closing anyway", AppLogMessageType.Warning);
|
||||
IsRecording = false;
|
||||
StreamStatus = StreamStatus.Offline;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Shutdown()
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,22 +84,21 @@ public sealed class TopBarModeTests
|
||||
Assert.False(vm.ShowTestButton);
|
||||
Assert.Equal("Start Recording", vm.PrimaryStartButtonLabel);
|
||||
|
||||
// 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;
|
||||
// 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.ShowOutputStage);
|
||||
Assert.True(vm.IsRecordActivity);
|
||||
Assert.True(vm.ShowRecordStop);
|
||||
Assert.False(vm.RunningVisible);
|
||||
Assert.StartsWith("REC: ", vm.RecordChipText);
|
||||
Assert.StartsWith("REC: ", vm.RecordElapsedLabel);
|
||||
vm.IsRecording = false;
|
||||
|
||||
// A live stream retires the stage instead; the reality line pins the mode.
|
||||
// A live stream is the YouTube activity's flow; the reality line pins the mode.
|
||||
vm.SelectYouTubeActivityCommand.Execute(null);
|
||||
vm.StreamStatus = StreamStatus.Streaming;
|
||||
Assert.True(vm.RunningVisible);
|
||||
Assert.False(vm.ShowOutputStage);
|
||||
Assert.True(vm.ShowYouTubeStop);
|
||||
Assert.Equal("LIVE", vm.RunningText);
|
||||
Assert.Equal("#ff5451", vm.RunningDotBrush);
|
||||
vm.IsTestStream = true;
|
||||
|
||||
@@ -29,10 +29,11 @@ public class UiShellTests
|
||||
|
||||
// 2. The world segment replaced the EDIT toggle (creator ruling 2026-11-12): both halves are
|
||||
// present, each lit by its own world flag through the shared style.
|
||||
Assert.Contains("Text=\"Build scenes\"", topBar);
|
||||
Assert.Contains("Text=\"Record / Go-Live\"", topBar);
|
||||
Assert.Contains("Tag=\"{Binding IsBuildWorld}\"", topBar);
|
||||
Assert.Contains("Tag=\"{Binding IsOutputWorld}\"", topBar);
|
||||
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.DoesNotContain("x:Name=\"EditBody\"", topBar);
|
||||
|
||||
// 2b. The segment is Build-world only: choosing Record / Go Live REPLACES it with the output
|
||||
@@ -42,18 +43,16 @@ public class UiShellTests
|
||||
Assert.Contains("Brush.SegmentTrack", topBar);
|
||||
Assert.Contains("Style=\"{StaticResource SegmentHalf}\"", topBar);
|
||||
Assert.Contains("Style=\"{StaticResource SegmentIcon}\"", topBar);
|
||||
Assert.Contains("Visibility=\"{Binding IsBuildWorld, Converter={StaticResource BoolToVis}}\"", topBar);
|
||||
Assert.Contains("Text=\"Output:\"", topBar);
|
||||
Assert.Contains("Text=\"<esc> to return\"", topBar);
|
||||
|
||||
|
||||
|
||||
Assert.DoesNotContain("ToolTip=\"Back to Build Scenes\"", topBar); // Esc replaced the chevron
|
||||
|
||||
// 2c. The output stage: two labelled switches and the exclusivity promise beside them.
|
||||
Assert.Contains("Text=\"Output:\"", topBar);
|
||||
|
||||
Assert.Contains("Text=\"YouTube\"", topBar);
|
||||
Assert.Contains("Text=\"Local MP4\"", topBar);
|
||||
Assert.Contains("Text=\"One output at a time\"", topBar);
|
||||
Assert.Contains("Command=\"{Binding SelectYouTubeCommand}\"", topBar);
|
||||
Assert.Contains("Command=\"{Binding SelectLocalMp4Command}\"", topBar);
|
||||
Assert.Contains("Command=\"{Binding SelectYouTubeActivityCommand}\"", topBar);
|
||||
Assert.Contains("Command=\"{Binding SelectRecordActivityCommand}\"", 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).
|
||||
@@ -120,9 +119,9 @@ public class UiShellTests
|
||||
|
||||
// The world segment leads the centre cluster; the brand column carries no edit control.
|
||||
var centre = topBar.IndexOf("<StackPanel Grid.Column=\"1\"", System.StringComparison.Ordinal);
|
||||
var segment = topBar.IndexOf("Text=\"Build scenes\"", System.StringComparison.Ordinal);
|
||||
var segment = topBar.IndexOf("Text=\"Build Scenes\"", System.StringComparison.Ordinal);
|
||||
Assert.True(centre > 0 && segment > centre,
|
||||
"the [ Build Scenes ][ Record / Go Live ] segment must lead the centre cluster");
|
||||
"the three-activity segment must lead the centre cluster");
|
||||
Assert.DoesNotContain("x:Name=\"EditBody\"", topBar);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user