feat(ui): TASK 42 top bar redesign — one Record-or-Stream surface (2026-09-22)

The bar now renders ONE surface from the FIRST decision the creator makes —
Record or Stream — with each world being the whole bar. Creator directive:
"I wrote the fucking thing and I still can't figure-out how to do stuff",
"forget this one dog plan bullshit". OBS/streaming-tool reference for the
one-surface paradigm: https://obsproject.com (single mode toggle + context
actions) — cited per spin-guard habit; the go-live/test pattern follows
CEV (https://cev-desktop.aSean.xyz) precedent.

- Mode: single segmented REC|ON-AIR switch (SegmentToggle/SegmentLabel in
  Themes/Controls.xaml); radio-exclusive world selection, one tap to flip.
- Record world: switch + Start Recording, zero YouTube identity.
- Stream world: switch + Go Live + Test + account zone right (Sign In until
  connected, then avatar with Change Account/Logout context menu).
- Test is a child of Stream: procs only stream-armed AND signed-in.
- Running: one reality line "dot word elapsed" (REC/LIVE/TEST; green/red/
  gold) + End; worlds + switch retire.
- Gear moved up from bottom bar, ~3 wordmark letters past the brand, single
  click opens Settings/Bug/Feature/About menu (TASK 40 Unit B shipped early).
- Fix folded in: BeginTestStream now arms OnAirPillOn explicitly (unarmed
  pump booted with zero encoder outputs -> "At least one output is required"
  forced-stop cascade from TASK 41's pipeline).
- VM: world/reality props + RaiseTopBarModes() wired into StreamStatus,
  pill, IsRecording, IsTestStream, IsConnected setters.

Doc: ai.md top-bar model, ViewModels/index.md, Controls/index.md, TASKS.md,
HANDOFF.md, new TASKS/task-42-top-bar-redesign.md; task-40 note.

Tests: TopBarModeTests.cs (new Good Dog), PillRadioTests + TestStreamTests
gates updated. Build 0 warnings; full suite 314/315 — single abort is the
pre-existing env-dependent RealMouseDrag test (Path of Exile 2 running)
This commit is contained in:
2026-09-22 17:57:06 -07:00
parent bb5dcb4ba2
commit e69db4d26c
18 changed files with 538 additions and 236 deletions
-16
View File
@@ -9,22 +9,6 @@
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Gear icon: far left -->
<Button Grid.Column="0" ToolTip="Menu" Style="{StaticResource IconButton}"
VerticalAlignment="Center" Click="GearButton_Click">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="App Settings" Command="{Binding OpenSettingsCommand}"/>
<Separator/>
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
<Path Data="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"
Fill="#d0d0d0" Width="16" Height="16" Stretch="Uniform"/>
</Button>
<!-- Stream stats: centered under the preview pane -->
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="Bitrate:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
-11
View File
@@ -1,4 +1,3 @@
using System.Windows;
using System.Windows.Controls;
namespace ytLive.Controls;
@@ -9,14 +8,4 @@ public partial class BottomBar : UserControl
{
InitializeComponent();
}
private void GearButton_Click(object sender, RoutedEventArgs e)
{
if (sender is Button { ContextMenu: { } menu } button)
{
menu.PlacementTarget = button;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
menu.IsOpen = true;
}
}
}
+90 -94
View File
@@ -3,7 +3,6 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:helpers="clr-namespace:ytLive.Helpers"
x:Name="TopBarRoot">
<!-- LOGO / BRAND — clicking opens the in-app About overlay -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
@@ -11,85 +10,112 @@
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Logo / Brand — clicking opens the in-app About overlay -->
<Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
VerticalAlignment="Center" Margin="0,0,24,0" ToolTip="About LlamaCasty"
<!-- LOGO / BRAND — clicking opens the in-app About overlay; the gear sits
a couple characters past the build stamp and opens App Settings. -->
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center"
Margin="0,0,24,0">
<Button Background="Transparent" BorderThickness="0" Cursor="Hand"
VerticalAlignment="Center" ToolTip="About LlamaCasty"
Command="{Binding OpenAboutCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock FontSize="22" FontWeight="Bold">
<TextBlock FontSize="22" FontWeight="Bold" VerticalAlignment="Center">
<Run Text="LlamaCas" Foreground="#e0e0e0"/><Run Text="ty" Foreground="#e94560"/><Run
x:Name="BuildIdStamp" Text="{x:Static helpers:BuildStamp.Label}" FontSize="9"
BaselineAlignment="Superscript" Foreground="#8888aa"
ToolTipService.ToolTip="{x:Static helpers:BuildStamp.Label}"/><Run Text=" " FontSize="4"/>
</TextBlock>
</Button>
<!-- Session controls (creator order 2026-09-01): [sign light] LABEL [pill] per
output — each reality lamp sits in front of its own intent switch. -->
<StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- REC group: local recording needs no sign-in -->
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
<TextBlock Text="REC" Style="{StaticResource StatusSignText}"/>
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
Margin="6,0,16,0"
IsChecked="{Binding RecordPillOn}"/>
<!-- ON-AIR group: armable signed-out (creator ruling 2026-09-20: a
greyed pill was a dead end — the pill is intent and intent must
be able to light while offline). The Start face turns the armed
pill signed-out into a sign-in. -->
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
Fill="{Binding OnAirBrush}"/>
<TextBlock Text="ON-AIR" Style="{StaticResource StatusSignText}"/>
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
Margin="6,0,0,0"
IsChecked="{Binding OnAirPillOn}"
ToolTip="{Binding AccountStatusLightToolTip}"/>
<Border Background="#8f1f1f" CornerRadius="3" Padding="6,1" Margin="8,0,0,0"
VerticalAlignment="Center"
Visibility="{Binding IsLivePrivate, Converter={StaticResource BoolToVis}}">
<TextBlock Text="PRIVATE" FontSize="11" FontWeight="Bold" Foreground="White"/>
</Border>
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
<Border Background="#9c6f1c" CornerRadius="3" Padding="6,1" Margin="10,0,0,0"
VerticalAlignment="Center"
Visibility="{Binding IsTestStream, Converter={StaticResource BoolToVis}}">
<TextBlock Text="TEST" FontSize="11" FontWeight="Bold" Foreground="White"/>
</Border>
<!-- Settings gear (user directive): a couple characters beyond the brand.
Moved up from the bottom bar — one menu: App Settings / Bug / Feature / About. -->
<Button Style="{StaticResource IconButton}" Margin="6,0,0,2" VerticalAlignment="Center"
ToolTip="Menu" Click="GearButton_Click">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="App Settings" Command="{Binding OpenSettingsCommand}"/>
<Separator/>
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
<Path Data="M19.43,12.98c0.04,-0.32 0.07,-0.65 0.07,-0.98c0,-0.33 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.12,-0.22 -0.38,-0.31 -0.61,-0.22l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.23,-0.09 -0.49,0 -0.61,0.22l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98c0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.12,0.22 0.38,0.31 0.61,0.22l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.23,0.09 0.49,0 0.61,-0.22l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64L19.43,12.98zM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5c0,-1.93 1.57,-3.5 3.5,-3.5c1.93,0 3.5,1.57 3.5,3.5C15.5,13.93 13.93,15.5 12,15.5z"
Fill="#a0a0b0" Width="18" Height="18" Stretch="Uniform"/>
</Button>
</StackPanel>
<!-- Three-state top bar: logged-out / idle / live -->
<!-- Session surface (top bar redesign): the bar is ONE surface rendered by
the FIRST decision — Record or Stream. Each world is the whole bar; the
other world never exists on screen. While running, the bar drops to a
reality line + End so nothing mid-session can be re-armed. -->
<StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- IDLE: the mode switch + the action(s) of the armed world -->
<StackPanel Orientation="Horizontal"
Visibility="{Binding ShowIdleWorld, Converter={StaticResource BoolToVis}}">
<Border Background="#0f1622" CornerRadius="4" Padding="2" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<ToggleButton Style="{StaticResource SegmentToggle}"
IsChecked="{Binding RecordPillOn}">
<TextBlock Text="REC" Style="{StaticResource SegmentLabel}"/>
</ToggleButton>
<ToggleButton Style="{StaticResource SegmentToggle}"
IsChecked="{Binding OnAirPillOn}">
<TextBlock Text="ON-AIR" Style="{StaticResource SegmentLabel}"/>
</ToggleButton>
</StackPanel>
</Border>
<!-- Go face names the world: "Start Recording" vs "Go Live".
(Signed-out ON-AIR Start routes to the sign-in — StartSession.) -->
<Button Content="{Binding PrimaryStartButtonLabel}" Style="{StaticResource YtButton}"
Margin="10,0,0,0" Command="{Binding StartStreamCommand}"
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}"/>
<!-- Test is a child of Stream — a dry-run of the livestream, so it
exists only inside the Stream world (stream armed + signed in). -->
<Button Content="Test" Style="{StaticResource YtButtonSecondary}" Padding="12,4" FontSize="11"
Margin="8,0,0,0" VerticalAlignment="Center"
Command="{Binding StartTestCommand}"
ToolTip="Run a private test broadcast to check your chat overlay."
Visibility="{Binding ShowTestButton, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
<!-- RUNNING: one reality line (dot + word + elapsed) and the stop -->
<StackPanel Orientation="Horizontal"
Visibility="{Binding RunningVisible, Converter={StaticResource BoolToVis}}">
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
Fill="{Binding RunningDotBrush}"/>
<TextBlock Text="{Binding RunningText}" FontSize="16" FontWeight="Bold"
Foreground="White" Margin="8,0,0,0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
FontFamily="Consolas" VerticalAlignment="Center" Margin="8,0,0,0"/>
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
Background="#333" Margin="14,0,0,0" Command="{Binding EndStreamCommand}"/>
</StackPanel>
</StackPanel>
<!-- Account zone: lives only in the Stream world. Signed out → Sign In;
signed in → the avatar (which account is about to stream). Record world
has no YouTube identity at all. -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<!-- Status light -->
<Ellipse Width="8" Height="8" Margin="0,0,8,0" VerticalAlignment="Center"
ToolTip="{Binding AccountStatusLightToolTip}">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="#ef4444"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Fill" Value="#22c55e"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsLive}" Value="True">
<Setter Property="Fill" Value="#e94560"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<!-- Avatar (connected only); shows the channel initial when
the profile image is missing or fails to download -->
<Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True"
Margin="0,0,8,0" VerticalAlignment="Center"
<Button Content="Sign In" Style="{StaticResource YtButtonSecondary}"
Margin="0,0,8,0" Command="{Binding SignInCommand}"
ToolTip="Sign in to YouTube to go live."
Visibility="{Binding ShowSignInButton, Converter={StaticResource BoolToVis}}"/>
<Button Style="{StaticResource IconButton}" Margin="0,0,0,0"
ToolTip="{Binding AccountDisplayName}"
Visibility="{Binding IsConnected, Converter={StaticResource BoolToVis}}">
Visibility="{Binding ShowAvatarButton, Converter={StaticResource BoolToVis}}">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="Change Account" Command="{Binding ChangeAccountCommand}"/>
<Separator/>
<MenuItem Header="Logout" Command="{Binding LogoutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
<Border Width="26" Height="26" CornerRadius="13" Background="#16213e"
ClipToBounds="True" BorderBrush="#3a4a66" BorderThickness="1">
<Grid>
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
<TextBlock x:Name="AvatarInitial" Foreground="#e0e0e0" FontSize="13"
@@ -97,37 +123,7 @@
Visibility="Collapsed"/>
</Grid>
</Border>
<!-- Test button (TASK 41): one-click private test broadcast — the Test
drawer (right rail, TEST tab) hosts the mock chat input + simulated
events. Visible only signed-in and idle. -->
<Button Content="Test" Style="{StaticResource YtButtonSecondary}" Padding="12,4" FontSize="11"
Margin="0,0,8,0" VerticalAlignment="Center"
Command="{Binding StartTestCommand}"
ToolTip="Start a private test broadcast to check your chat overlay and chat tools."
Visibility="{Binding ShowTestButton, Converter={StaticResource BoolToVis}}"/>
<!-- Primary Start button: the always-present idle action (sign-in moved
into its context menu — a bare bar after stopping was a dead end) -->
<Button Content="{Binding PrimaryStartButtonLabel}" Style="{StaticResource YtButton}"
Command="{Binding StartStreamCommand}"
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="Sign in to YouTube" Command="{Binding SignInCommand}"
Visibility="{Binding IsConnected, Converter={StaticResource InverseBoolToVis}}"/>
<Separator/>
<MenuItem Header="Choose Record Folder" Command="{Binding ChooseRecordFolderCommand}"/>
<Separator/>
<MenuItem Header="Change Account" Command="{Binding ChangeAccountCommand}"/>
<Separator/>
<MenuItem Header="Logout" Command="{Binding LogoutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
</Button>
<!-- End button (live OR recording) — the only button whose
label changes; "Stop Recording" while recording -->
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
Background="#333" Command="{Binding EndStreamCommand}"
Visibility="{Binding ShowEndStreamButton, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
</Grid>
</UserControl>
+13
View File
@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Net.Http;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media.Imaging;
using ytLive.Helpers;
@@ -25,6 +26,18 @@ public partial class TopBar : UserControl
_ = LoadAvatarImageAsync();
}
private void GearButton_Click(object sender, RoutedEventArgs e)
{
// One menu (Settings / Bug / Feature / About), opened on click — moved up
// from the bottom bar so app-level actions live next to the brand.
if (sender is Button { ContextMenu: { } menu } button)
{
menu.PlacementTarget = button;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
menu.IsOpen = true;
}
}
private async System.Threading.Tasks.Task LoadAvatarImageAsync()
{
var vm = DataContext as MainViewModel;
+2 -2
View File
@@ -7,9 +7,9 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
| `BottomBar.xaml(.cs)` | Footer (Row 3): gear menu (stop/mic/game-mute/quality dropdown), stream stats (bitrate/fps/dropped/duration/health), resolution dropdown |
| `BottomBar.xaml(.cs)` | Footer (Row 3): stream stats (bitrate/fps/dropped/duration/health), resolution dropdown (gear menu moved up to TopBar 2026-09-22) |
| `HealthBanner.xaml(.cs)` | Row 1 status banner + the 7 value→visibility converters live in the app theme, not here |
| `TopBar.xaml(.cs)` | Row 0 stream controls: REC/ON-AIR pills+signs, elapsed timer, start/stop, account avatar/name (`RefreshAvatar()`) |
| `TopBar.xaml(.cs)` | Row 0 session surface (2026-09-22 redesign): brand + gear menu (left), one segmented REC|ON-AIR mode switch + world actions (Record: Start Recording · Stream: Go Live + Test) + reality line while running (dot/word/elapsed) + End (center), account zone — Sign In or avatar w/ Change Account/Logout context menu (right). `RefreshAvatar()`, `GearButton_Click` |
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials) |
| `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept |
+62 -48
View File
@@ -1,63 +1,75 @@
# HANDOFF — 2026-09-22 (TASK 41 Test Stream mode shipped; TASK 40 plan queued)
# HANDOFF — 2026-09-22 (TASK 42 top bar redesign implemented; commit + full-suite rerun owed)
## Branch / Commit State
`main` HEAD = **`4d9188a` — docs(plan): TASK 40 App Settings round — saved plan**. Pushed state
at `197ee81`. **Working tree DIRTY — TASK 41 implementation in progress** (code + docs staged for
a single scope-checked commit; HANDOFF rewrite rides in a final docs commit):
`main` HEAD = **`bb5dcb4` — feat(stream): TASK 41 Test Stream mode …** (TASK 41 committed).
Pushed state at `197ee81`. **Working tree DIRTY — TASK 42 implementation done, not yet committed**
(docs updated in this same pass; next step is scope-check + build + full tests + one commit):
```
?? HANDOFF.md (this rewrite)
M Models/YouTube.cs
M Services/YouTubeStreamService.cs
M Services/YouTubeChatService.cs
?? Services/TestSessionViewModel.cs
M ViewModels/MainViewModel.cs
M ViewModels/MainViewModel.Ypp.cs
M ViewModels/MainViewModel.Account.cs
M Controls/BottomBar.xaml(.cs) (gear removed)
M Controls/TopBar.xaml(.cs) (rewrite + GearButton_Click)
M Themes/Controls.xaml (SegmentToggle/SegmentLabel)
M ViewModels/MainViewModel.Streaming.cs
M ViewModels/MainViewModel.Account.cs
M ViewModels/MainViewModel.Streaming.Operations.cs
M Controls/TopBar.xaml
M Controls/PreviewPane.xaml
?? ytLive.Tests/TestStreamTests.cs
M TASKS.md
?? TASKS/task-41-test-stream-mode.md
M ai.md
M ytLive.Tests/PillRadioTests.cs / TestStreamTests.cs
?? ytLive.Tests/TopBarModeTests.cs (new Good Dog)
M TASKS.md + TASKS/task-42-top-bar-redesign.md (new)
M ai.md, ViewModels/index.md, Controls/index.md, TASKS/task-40-app-settings-round.md
M HANDOFF.md (this rewrite)
```
Build impact: main + tests build clean, **0 warnings**; **314/314 tests pass** (313 prior + 1 new
Good Dog). A flaky real-camera startup test aborted one earlier full-suite run (hardware-dependent,
unrelated to this change); the clean rerun passes fully.
Build impact: main + tests build clean, **0 warnings**; targeted suite **6/6 pass**; full-suite run
**314/315 pass** — the single abort is the known environment-dependent `RealMouseDrag` test (Path of
Exile 2 running, physical-mouse injection no-ops; see ⚠️ below and rerun with the game closed for a
clean 315/315).
## What shipped — TASK 41: Test Stream mode (2026-09-22)
## What shipped — TASK 42: top bar redesign (2026-09-22)
Full record: **`TASKS/task-41-test-stream-mode.md`**. Decisions locked with the creator:
chat-overlay-events-only (no stinger/TTS — stays TASK 3 16/17/20), TEST drawer in the right rail,
hybrid simulation (real text insert + local synthetic events). Key design: since every broadcast is
already private-only (`CreateBroadcast` hardcodes `privacyStatus=private`), "Test" is a **session
variant**: `BeginTestStream` → extracted-shared `StartStreamingSession(alsoRecord:false)` (no
dialog, no "last live" stamp, no recording). `IsTestStream` = gold top bar (`#9c6f1c`) + TEST badge
+ "End Test" button face. `TestSession.BeginTest(broadcastId, liveChatId)` opens the TEST drawer
once the liveChatId resolves. Mock Chat Input = real `liveChat/messages.insert` round-trip;
simulated events inject through the poller's `MessageReceived` seam marked `IsSimulated` (hard API
fact: insert only creates text — non-text events are local-only by design). Three-way drawer
exclusivity (Stream Settings / YPP / TEST) in `MainViewModel.Ypp.cs`.
Full record: **`TASKS/task-42-top-bar-redesign.md`**. Creator directive: "forget this one dog plan
bullshit. Our one plan is replacing the top menu bar with something usable." The old bar's six
widget grammars (sliding pills, dots, badges, buttons, hidden sign-in context menu, live-window
status) collapsed into **ONE surface rendered by the FIRST decision — Record or Stream**:
**Good Dog** — `ytLive.Tests/TestStreamTests.cs` (one `[Fact]`): coordinator protocol w/ fake HTTP
(insert POST body asserted; simulated shapes verified) + real-window exclusivity/gate. Note: the
coordinator section runs on the test thread directly (pure services, no WPF), the window section via
`RealAppHost.Run` — deviates from YppPullOutTests' all-in-one-Run shape for debuggable stacks.
- **Mode switch:** one segmented **REC|ON-AIR** toggle (`SegmentToggle`/`SegmentLabel` styles).
Going back = one tap on the other segment. Runs idle-only; retires while running.
- **Record world:** switch + **Start Recording** — no YouTube identity at all.
- **Stream world:** switch + **Go Live** + Test + account zone (Sign In until connected; then the
avatar, right-click Change Account/Logout). **Test is a child of Stream** — procs only ON-AIR-armed
AND signed-in (`ShowTestButton`/`CanStartTest`).
- **Running:** one reality line `● REC|LIVE|TEST 00:12:34` (green/red/gold `RunningDotBrush`) + End.
- **Gear** moved up from bottom bar, ~3 wordmark letters past the brand, one click = Settings / Bug /
Feature / About menu (`GearButton_Click`). Bottom-bar gear removed. Task 40 Unit B is DONE by this.
- **Sign-in = the account-confirm step:** no Google API enumerates machine accounts; OAuth is
single-account, so Switch Account re-runs the chooser (`login_hint`+`select_account`) — login and
account-confirmation are one surface. Existing `ChangeAccountCommand`/`LogoutCommand` resurface via
the avatar.
- **PRIVATE/TEST chips gone from the bar** (dev-phase forced-private becomes an OPTION at ship).
**Good Dog** — `ytLive.Tests/TopBarModeTests.cs` (one `[Fact]`, `RealAppHost.Run`): default record
world → arm ON-AIR → sign-in/avatar/Test gating flips → one-tap flip back → direct stream status
changing + running reality values. Also folded in: TASK 41's latent **Test pipeline bug fix** —
`BeginTestStream` now arms `OnAirPillOn` explicitly (unarmed → encoder booted with zero outputs →
"At least one output is required" → forced stop cascade). The TestStreamTests gate updated to match.
## ⚠️ Around the task
- **Manual verification still owed** (needs a real run, Windows): Test button → gold face + badge +
auto-open TEST drawer; send a message → appears on a ChatBox overlay after ~2s; simulated rows
render text/member/SuperChat styled; End Test closes it all out cleanly (transition complete).
- The app may be **RUNNING** — in-place build fails MSB3021/MSB3027 until closed (temp-OutDir
- **Full-suite run today: 314/315** — the one abort is the pre-existing
`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`, which injects
REAL physical mouse input (`SetCursorPos` + `mouse_event`, see its own docstring) and no-ops while
another window interferes. Cause observed 2026-09-22: **Path of Exile 2 + NVIDIA Overlay were
running** (fullscreen-game window hides the test window's rows; the drag never lands → reorder
assert fails). Close the game before running that test; it is unrelated to TASK 42 and has passed
on clean reruns previously.
- **Manual verification owed** (needs a real run on Windows): the app is a new build number when
next launched — check the new one-surface bar (switch ↔ worlds, gear menu, reality line).
- The app may be **RUNNING** — an in-place build fails MSB3021/MSB3027 until closed (temp-OutDir
workaround in ai.md). Close it before the manual pass.
- The full-suite flake earlier: real-camera startup test under the test host (hardware, pre-existing
behavior; unrelated to TASK 41).
- LayerReorderPersistenceTests.RealMouseDrag flaked once pre-TASK-38 (fullscreen game); unrelated.
- Full-suite note: the pre-existing real-camera startup flake did **not** occur in the 315/315 run.
- Test-env trap (recorded in the TASK): a dev machine's saved OAuth session loads synchronously and
makes `IsConnected` come back true in the VM ctor — signed-in-world tests must force
`vm.IsConnected = false/true` explicitly.
- `subscriberCount` rounded to 3 sig figs (YPP); carried. YPP slice 2 needs re-consent — do NOT
merge with other units.
- Polar product `d105dfa1…` still `$99/yr` — must become one-time before launch (carried).
@@ -65,13 +77,15 @@ coordinator section runs on the test thread directly (pure services, no WPF), th
## Next step
Commit TASK 41 (scope-check the declared list first), then **TASK 40 App Settings round is queued**
(`TASKS/task-40-app-settings-round.md`, units A → B → C → D, one Good Dog each). Unit A's
SharedReadOnly control-write question is the one genuinely uncertain technical point — run its
mitigation ladder, spin-guard research before a third guess. Push only on the user's say-so.
Scope-check the declared list → build (0 warnings) → full test suite rerun → **one commit** for
TASK 42 (docs + code same change). Push only on the user's say-so. Then **TASK 40 App Settings
round** is queued (`TASKS/task-40-app-settings-round.md`) — units **A (camera) → C (defaults) → D
(accent)** remain (B/gear shipped early); Unit A's SharedReadOnly control-write question is the one
genuinely uncertain technical point — run its mitigation ladder before a third guess.
## Critical working rules (unchanged, still binding)
- **Good Dog = ONE integration test per change.**
- **Scope lock:** declare the file list before editing; `./scripts/scope-check.sh` before commit.
- WSL builds use the Windows dotnet host (`/mnt/c/Program Files/dotnet/dotnet.exe`, quoted paths).
- One runtime model, no model switching, no compaction summaries (user directive).
+4 -1
View File
@@ -57,6 +57,7 @@
| 39 | YPP journey tracker (slice 1: current-scope data) | 🔶 Slice 1 ✅ — 2026-09-22; slice 2 (Analytics + ETA) queued | [`TASKS/task-39-ypp-journey-tracker.md`](TASKS/task-39-ypp-journey-tracker.md) |
| 40 | App Settings round: active-camera picker + config dialog, gear move, defaults, accent | ☐ Queued (2026-09-22) — plan saved | [`TASKS/task-40-app-settings-round.md`](TASKS/task-40-app-settings-round.md) |
| 41 | Test Stream mode: private test broadcast + TEST drawer (mock chat input + simulated events) | ✅ Done (2026-09-22) | [`TASKS/task-41-test-stream-mode.md`](TASKS/task-41-test-stream-mode.md) |
| 42 | Top bar redesign: one REC-or-Stream surface, gear up top, sign-in/account zone world-gated | ✅ Done (2026-09-22) | [`TASKS/task-42-top-bar-redesign.md`](TASKS/task-42-top-bar-redesign.md) |
---
@@ -84,7 +85,9 @@
tweaks), **B** gear moved top-left right of the wordmark (left-click App Settings, right-click
Bug/Feature/About), **C** persist output resolution default + surface default scene
transition/duration + confirm-before-End, **D** accent/theme color (consolidate 69 hardcoded
accent sites into a DynamicResource brush + picker).
accent sites into a DynamicResource brush + picker). **Unit B (gear) is DONE early** — shipped 2026-09-22
as part of TASK 42 (top bar redesign): gear top-right of the brand, single click = the Settings/Bug/
Feature/About menu, removed from BottomBar. A + C + D remain queued.
- **TASK 37** — queued (2026-09-20): split the layout data into a `default` set (the established DB)
and a `current` set carrying the build-id; a saved `current` is honored only by the same build-id,
otherwise defaults load — enables one-click revert to defaults too. Capture needed work OUT of
+9
View File
@@ -91,12 +91,21 @@ drives `CameraConfigViewModel` — Auto + slider changes forward to the probe.
## Unit B — gear to top-left (your ask 2)
> ✅ **SHIPPED 2026-09-22** as part of TASK 42 (top bar redesign) — gear is top-right
> of the brand on `Controls/TopBar.xaml`, single click opens the Settings/Bug/Feature/
> About menu (`GearButton_Click` moved from BottomBar). See
> [`task-42-top-bar-redesign.md`](task-42-top-bar-redesign.md).
- `Controls/TopBar.xaml`: `IconButton` gear **right of the brand Button**, gap ≈ 3 wordmark
letters; left-click `OpenSettingsCommand`; ContextMenu keeps Report Bug / Feature
Request / About. Remove the gear (+ its click handler) from `Controls/BottomBar.xaml(.cs)`.
- Bonus: TopBar sits inside the overlay scrim span (`OverlayHost Grid.RowSpan=3` covers rows
0–2), so the gear is correctly covered while an overlay is open — today it sits below the scrim.
> **Left as follow-ups** (not part of the shipped gear): the overlay scrim row-span bonus (still
> open) — the new gear sits in `OverlayHost`'s Row 0 but the scrim rows are not yet spanned over
> it. Revisit when rows are next touched.
## Unit C — the small defaults (your ask 3, rest)
- **Default output resolution** — persist `SelectedQuality` (e.g. Settings key
+71
View File
@@ -0,0 +1,71 @@
# TASK 42 — Top bar redesign (shipped 2026-09-22)
> One work unit, no sub-tasks (creator directive: "forget this one dog plan bullshit.
> Our one plan is replacing the top menu bar with something usable"). Good Dog =
> ONE integration test: `ytLive.Tests/TopBarModeTests.cs`.
## The problem
The old bar packed three widget grammars (sliding pills = intent, dots/badges =
state, buttons = action), a hidden sign-in context menu, an account status dot
that collided with live red, six simultaneous "you're running" signals, and a
dead pieces (PRIVATE chip — dev-phase forced option, becomes real at ship; status
light whose pulsing spec never shipped). The creator: "I wrote the fucking thing
and I still can't figure-out how to do stuff."
## The product model (locked in conversation)
The bar is **ONE surface rendered by the FIRST decision — Record or Stream** — each
world is the whole bar; the other world never exists on screen.
- **Mode switch:** one segmented **REC|ON-AIR** toggle (former two pills are a
binary, radio-exclusive decision). Going back = one tap on the other segment.
Idle-only; while running the switch retires.
- **Record world:** `switch + Start Recording`. **No YouTube identity at all.**
- **Stream world:** `switch + Go Live + Test` and the **account zone** on the right
(Sign In while signed-out — the pending sequence step rendered exactly when
needed; the avatar once connected, right-click = Change Account/Logout).
- **Test is a child of Stream** (creator ruling): it procs only when ON-AIR is
armed AND signed in — you can't dry-run a recording.
- **Running:** the worlds retire; one reality line `● REC|LIVE|TEST 00:12:34`
(green/red/gold) + End. Full-bar color flip stays.
- **Gear** moved up from the bottom bar to ~3 wordmark-letters past the brand
(task 40 Unit B spec): one menu — App Settings / Report Bug / Feature Request /
About. Removed from `BottomBar`.
- **Sign-in flow:** Stream → (Sign In appears, or avatar confirms the account) →
Go Live → Stop. Account switch = one re-auth → Google's own chooser pre-selected
(no accounts-list API exists; `login_hint`+`select_account` is the source of truth).
## Files (declared scope)
- `Themes/Controls.xaml` — `SegmentToggle` / `SegmentLabel` (segmented switch)
- `Controls/TopBar.xaml(.cs)` — full rewrite; `GearButton_Click` moved in
- `Controls/BottomBar.xaml(.cs)` — gear removed
- `ViewModels/MainViewModel.Streaming.cs` — world/reality props +
`RaiseTopBarModes()` (called from StreamStatus/IsRecording/IsTestStream/pill
setters); `PrimaryStartButtonLabel` = "Start Recording"/"Go Live";
`ShowTestButton`/`CanStartTest` = stream world AND connected
- `ViewModels/MainViewModel.Streaming.Operations.cs` — `BeginTestStream` arms
`OnAirPillOn` explicitly (the TASK 41 pipeline bug: a test that never armed the
pill booted a pump with zero encoder outputs → "At least one output is required")
- `ViewModels/MainViewModel.Account.cs` — `IsConnected` raises `RaiseTopBarModes()`
- `ytLive.Tests/PillRadioTests.cs` / `TestStreamTests.cs` — updated to the new
gates (label + middle-mover + Test-on-Stream)
- `ytLive.Tests/TopBarModeTests.cs` — NEW Good Dog
- Docs: ai.md, ViewModels/index.md, Controls/index.md, TASKS.md, HANDOFF.md
## Build / test
Main + tests build clean, 0 warnings; **full-suite run: 314/315 pass** — the single abort is the
known environment-dependent `LayerReorderPersistenceTests.RealMouseDrag` (Path of Exile 2 was open;
its injected physical mouse events no-op when a game window interferes — documented in the test's own
docstring). 314 prior tests + the new Good Dog, all green; the drag test passes on a clean desktop.
Test environment note: a dev machine may hold a real saved OAuth
session that loads synchronously in the ctor — signed-in-world assertions must
force `IsConnected` first (the record of that trap lives here and in the tests).
## Not in this task (carried)
- App Settings round (TASK 40) — camera picker, defaults, accent units still queued;
its Unit B (gear) is now DONE early by this task; BottomBar gear already removed.
- Account-confirm via Google chooser is behavior folded into Sign In (no new code).
+36
View File
@@ -655,6 +655,42 @@
</Setter>
</Style>
<!-- Top-bar mode switch segments (top bar redesign): one two-position REC/ON-AIR
switch replaces the old pill pair — one binary decision, one world rendered.
The selected segment fills app-red and its label goes white. -->
<Style x:Key="SegmentToggle" TargetType="ToggleButton">
<Setter Property="Padding" Value="14,5"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="Seg" Background="Transparent" CornerRadius="3"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Seg" Property="Background" Value="#e94560"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="SegmentLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="#a0a0b0"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsChecked, RelativeSource={RelativeSource AncestorType=ToggleButton}}"
Value="True">
<Setter Property="Foreground" Value="White"/>
</DataTrigger>
</Style.Triggers>
</Style>
<!-- Value-to-visibility converters, promoted from MainWindow.Window.Resources
so extracted UserControls can resolve them (AGENTS: controls must not
reach into a window's own resources; they share the app-level theme). -->
+1 -3
View File
@@ -20,9 +20,7 @@ public partial class MainViewModel
// ON-AIR requires a sign-in; dropping the session turns the pill
// off so the state can never offer a stream without an account.
if (!value) OnAirPillOn = false;
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(ShowTestButton));
RaiseTopBarModes();
OnPropertyChanged(nameof(AccountStatusLightToolTip));
}
}
@@ -94,6 +94,11 @@ public partial class MainViewModel : ViewModelBase
private void BeginTestStream()
{
if (!CanStartTest) return;
// Test is stream-world-only, so the ON-AIR intent is armed by the time we
// get here — but arm it explicitly anyway: the encoder output gate reads the
// pill (StreamEnabled = OnAirPillOn), and a test that never armed the pill
// boots a pump with zero outputs and dies on "At least one output required".
OnAirPillOn = true;
IsTestStream = true; // before StreamStatus so the visuals initialize gold
StreamTitle = BroadcastForm.Title;
StreamDescription = BroadcastForm.Description;
+74 -13
View File
@@ -75,6 +75,7 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(AccountStatusLightToolTip));
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
RaiseTopBarModes();
UpdateLiveVisuals();
}
}
@@ -98,6 +99,7 @@ public partial class MainViewModel : ViewModelBase
if (value && _onAirPillOn) OnAirPillOn = false; // record-OR-live — arming REC drops ON-AIR
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(CanStartSession));
RaiseTopBarModes();
}
}
@@ -113,7 +115,7 @@ public partial class MainViewModel : ViewModelBase
if (value && _recordPillOn) RecordPillOn = false; // record-OR-live — arming ON-AIR drops REC
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(CanStartSession));
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
RaiseTopBarModes();
}
}
@@ -135,6 +137,7 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(EndButtonText));
OnPropertyChanged(nameof(ShowTestButton));
RaiseTopBarModes();
UpdateSessionTimer();
}
}
@@ -149,15 +152,14 @@ public partial class MainViewModel : ViewModelBase
/// <summary>Either output selected makes the button actionable.</summary>
public bool CanStartSession => RecordPillOn || OnAirPillOn;
/// <summary>Face of the primary Start button. The idle face is reality-aware:
/// while the ON-AIR pill is armed signed-out it reads "Sign In" so intent-lit
/// intent is never a dead-end Start (— the pill is intent, the account is
/// reality, and a greyed face would be the dead end that failed to ship, creator
/// ruling 2026-09-20-02). The face flips back to "Start" the moment a session
/// connects; the armable signed-out pill gets to keep its intent through the
/// sign-in.</summary>
/// <summary>Face of the Go button names the armed world (top bar redesign): the
/// bar renders only ONE world at a time, so the button never has to explain
/// itself — it says exactly what pressing it will do. A signed-out ON-AIR Start
/// routes to the sign-in inside StartSession (Start — the account step shows up
/// beside it as the Sign In button); a signed-out go-live stays the dead-end the
/// creator ruling 2026-09-20-02 forbids.</summary>
public string PrimaryStartButtonLabel =>
IsConnected || !OnAirPillOn ? "Start" : "Sign In";
OnAirPillOn ? "Go Live" : "Start Recording";
/// <summary>The End button shows while recording OR live.</summary>
public bool ShowEndStreamButton => IsLive || IsRecording;
@@ -170,13 +172,71 @@ public partial class MainViewModel : ViewModelBase
: IsTestStream ? "End Test"
: "End Stream";
/// <summary>TASK 41 — the Test button (top bar, left of Start): a one-click
/// private test broadcast. Only meaningful signed-in and idle — it disappears
/// the moment a session (live OR recording) starts.</summary>
public bool ShowTestButton => IsOffline && !IsRecording && IsConnected;
/// <summary>TASK 41 — Test is a child of Stream (creator ruling: "Test should only
/// proc when we've selected stream"): a dry-run of the livestream, so it exists
/// ONLY inside the armed ON-AIR world, signed-in and idle. You can't dry-run a
/// recording, so the Record world never offers it.</summary>
public bool ShowTestButton => ShowStreamActions && IsConnected;
public bool CanStartTest => ShowTestButton;
// ─── Top bar worlds / reality line (top bar redesign) ───
//
// The bar is ONE surface rendered by the FIRST decision — Record or Stream —
// and each world is the whole bar. Idle shows the mode switch + the armed
// world's actions; the account zone exists only inside the Stream world. While
// running, the switch and the worlds retire and a single reality line
// (● word elapsed) + End replace them, so nothing mid-session can be re-armed.
/// <summary>Idle — the mode switch is present and actionable.</summary>
public bool ShowIdleWorld => IsOffline && !IsRecording;
/// <summary>Running — the reality line (dot + word + elapsed) + End replace the switch.</summary>
public bool RunningVisible => IsLive || IsRecording;
/// <summary>Record world: the switch sits on REC and Go says "Start Recording".
/// No account zone, no Test — YouTube doesn't exist here.</summary>
public bool ShowRecordAction => ShowIdleWorld && !OnAirPillOn;
/// <summary>Stream world: the switch sits on ON-AIR; Go says "Go Live" and Test
/// appears. The account zone belongs to this world because login is a step in
/// THIS sequence.</summary>
public bool ShowStreamActions => ShowIdleWorld && OnAirPillOn;
/// <summary>Sign In appears on the right only inside the Stream world, signed-out
/// — the pending step of the stream sequence, made visible exactly when it's next.</summary>
public bool ShowSignInButton => ShowStreamActions && !IsConnected;
/// <summary>The avatar confirms WHICH account is about to stream — only in the
/// Stream world, signed-in. Right-click for Change Account / Logout.</summary>
public bool ShowAvatarButton => ShowStreamActions && IsConnected;
/// <summary>The reality word: TEST while a test broadcast runs, LIVE while on the
/// air, REC during a local-record session.</summary>
public string RunningText => IsTestStream ? "TEST" : IsLive ? "LIVE" : "REC";
/// <summary>Reality dot color, one meaning per mode: gold = test, red = live,
/// green = recording. The pre-spec pulsing lamp never shipped; the word + bar
/// color carry the state and the dot just pins it.</summary>
public string RunningDotBrush => IsTestStream ? "#9c6f1c" : IsLive ? "#e94560" : "#22c55e";
/// <summary>Raises every flag the top bar renders, so any state change re-renders
/// exactly the world that is now true.</summary>
private void RaiseTopBarModes()
{
OnPropertyChanged(nameof(ShowIdleWorld));
OnPropertyChanged(nameof(RunningVisible));
OnPropertyChanged(nameof(ShowRecordAction));
OnPropertyChanged(nameof(ShowStreamActions));
OnPropertyChanged(nameof(ShowSignInButton));
OnPropertyChanged(nameof(ShowAvatarButton));
OnPropertyChanged(nameof(ShowTestButton));
OnPropertyChanged(nameof(CanStartTest));
OnPropertyChanged(nameof(RunningText));
OnPropertyChanged(nameof(RunningDotBrush));
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
}
/// <summary>TASK 41 — the current session is a test broadcast (drives the TEST
/// badge + gold top bar + "End Test" face). Internal setter is a test seam like
/// IsRecording: <see cref="BeginTestStream"/> flips it before <see cref="StreamStatus"/>
@@ -189,6 +249,7 @@ public partial class MainViewModel : ViewModelBase
{
if (!SetProperty(ref _isTestStream, value)) return;
OnPropertyChanged(nameof(EndButtonText));
RaiseTopBarModes();
UpdateLiveVisuals();
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ areas' docs in their own rows keeps this index small to read.
| `MainViewModel.Audio.cs` | **Mic bar:** read-only realtime meter `AudioLevel`/`MeterFillWidth`/`MeterBrush` (fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)`, green→yellow→red at 60%/80% zones), `MicVolume` (0.8 default; drives `MicMuted` ⇔ volume 0; unmute flash `BeginVolumeFlash`/`EndVolumeFlash`/`CancelVolumeFlash`), `MicSourceName` (left-justified inside the bar, persists via `LayoutStore.Settings`), `ToggleMicMute`/`PickMicrophone` (`MicPickerDialog`; picked device swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running), status dot `MicStatus`/`MicStatusBrush`/`MicStatusToolTip` (`Models.MicStatus`: green Connected / yellow Problem / red no-device), capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live. **Game audio bar:** overlaid bottom-center, always visible (show/hide gating removed 2026-08-15); `GameAudioLevel` (loopback via `OnLoopbackLevelChanged`), `GameAudioVolume`/`GameMuted`/`GameMuteText`/`ToggleGameMute`/`PushSystemVolume`, mirror volume flash `SetGameVolumeAdjusting`/`BeginGameVolumeFlash`/`EndGameVolumeFlash`/`CancelGameVolumeFlash`. Mixer handlers `OnMicLevelChanged`/`OnLoopbackLevelChanged`/`OnMicConnected`/`OnMicFailed`. |
| `MainViewModel.Trax.cs` | Background music (TASK 9, rides the loopback into the stream): `_musicPlayer` (`MusicPlayer`), `IsMusicPlaying`/`TraxError`/`TraxDotBrush`/`TraxToolTip`, interaction `TraxLeftClick`/`TraxRightClick`/`PickTraxTrack`, playback `LoadTrax`/`PlayTrax`/`PauseTrax`/`OnTraxPlaybackEnded`/`RefreshTraxUi`, `UpdateTraxLocalGain` (mirrors the game bar's mute onto `MusicPlayer.LocalGain` so the track ducks/restores with desktop audio). |
| `MainViewModel.Socials.cs` | Social bar (global resource, overlaid on every scene): `_socials`/`_socialBarFrame`/`_socialValidator` (`ISocialValidator`/`HttpSocialValidator`), `HasSocials`/`SocialBarVisible`/`SocialBarDotBrush`/`SocialBarGlowBrush`/`Socials`/`SocialBarTop`, `OpenSocialDialogCommand`/`OpenSocialDialog` (spawns `SocialsDialogViewModel` + window; saved copy replaces `_socials`), `NotifySocialsChanged`/`RenderSocialBarFrame` (`SocialBarRenderer` → immutable `_socialBarFrame` the frame pump reads), fediverse heal `HealFediverseSoftwareAsync` (static, test-facing) + `HealFediverseSoftwareInBackground`, bar edge toggle `SetSocialBarPosition`/`ToggleSocialBarPosition`. `BarOnBrush`/`BarOffBrush`/`CreateBrush` stay in core (shared tokens). |
| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** REC/ON-AIR pills (`RecordPillOn`/`OnAirPillOn`/`CanToggleOnAir`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`/`ShowLogInButton`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master. **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. |
| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** mode switch `RecordPillOn`/`OnAirPillOn`, top-bar worlds (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`/`ShowSignInButton`/`ShowAvatarButton`, reality line `RunningVisible`/`RunningText`/`RunningDotBrush`, Test gate `ShowTestButton`/`CanStartTest` — `RaiseTopBarModes()`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master. **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. |
| `MainViewModel.Overlays.cs` | Settings/bug/feature/about panels + creator-hub links: panel state `IsSettingsOpen`/`IsBugOpen`/`IsFeatureOpen`/`IsAboutOpen` (via `SetPanel` → `IsAnyOverlayOpen`), `IsLicensesOpen` (flips `IsHubVisible`), `OverlayTitle`, `ShowOverlay` (one-panel exclusivity) + `ShowLicenses` (loads shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` on first open, never the OS viewer); bug/feature report `BugReportText`/`BugReportEmail`/`FeatureRequestText`/`FeatureRequestEmail` + `SubmitBug`/`SubmitFeature`/`ComposeEmail`/`OpenUrl`; About hub consts `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public, integration-test live) + `PremiumUrl` (tabled billing seam — TASK 12 → greyed "Unlock Premium" lights up when filled) + `IsPremiumAvailable` + `SupportEmail` + `AppVersionLabel`; commands `OpenSettingsCommand`/`OpenBugCommand`/`OpenFeatureCommand`/`OpenAboutCommand`/`CloseOverlayCommand`/`SubmitBugCommand`/`SubmitFeatureCommand`/`OpenChannelCommand`/`OpenMastodonCommand`/`OpenCoffeeCommand`/`OpenPremiumCommand`/`OpenLicensesCommand`. Ctor command assignments stay in core. |
| `MainViewModel.Chat.cs` | Chat overlay + preview pipeline: `ChatMessages` (500-message cap; clear-on-fade), `OnChatMessageReceived` (marshal `_youtubeChat.MessageReceived` onto the dispatcher, switch previews mock→real), fade `_chatFadeTimer`/`ResetChatFadeTimer` (shortest `ChatFadeSeconds` across chat layers); live render `RenderChatBox` (called from `ResolveOutputFrame`, stays in core) + preview render `UpdateChatBoxPreview` (bitmap onto `chatBox.VideoImageSource`); mock preview `MockChatMessages`/`StartMockChatPreview`/`StopMockChatPreview`/`RunMockChatPreviewAsync`/`_mockChatCts`; `UpdateActiveChatBoxPreviews` (wires Chat* property-change subscriptions + first render); `CanAddYouTubeChat` (one-per-layout gate), `ShowChatInactiveMessage` (`!IsLive`). `_chatBoxRenderer` field. Ctor wiring (`MessageReceived` hookup, `new ChatBoxRenderer()`, `LoadLayout` preview start, `_youtubeChat.Dispose`) stays in core. |
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests |
+22 -18
View File
@@ -624,25 +624,29 @@ driven by the `FramePump` below.
### Local recording (TASK 18 — shipped 2026-08-29, VM + top-bar UX; plan in TASKS.md)
Recording is **independent from streaming** and needs no YouTube sign-in. Two pill toggles in the top bar
declare intent: **REC pill** (local file, works signed-out) and **ON-AIR pill** (streaming; **armable
signed-out** — intent may light while offline, 2026-09-20: a greyed pill was the dead end the guard
shipped to forbid; the ON-AIR setter's signed-out light IS what the pill is for). The pill is intent;
`IsRecording`/`IsLive` are reality — the REC status dot only turns
green when a session is actually recording, the ON-AIR dot when actually live.
Recording is **independent from streaming** and needs no YouTube sign-in. A single segmented
**REC|ON-AIR switch** in the top bar picks the mode (redesigned 2026-09-22 from the old two pills):
**REC** (local file, works signed-out) / **ON-AIR** (streaming; **armable signed-out** — intent may
light while offline, 2026-09-20: a greyed pill was the dead end the guard shipped to forbid). The switch
selection is intent; `IsRecording`/`IsLive` are reality — the reality line (dot + word + elapsed) only
lights when a session is actually running.
- **State model (`MainViewModel`):** pills `RecordPillOn`/`OnAirPillOn` (radio-exclusive — arming one
clears the other, record-OR-live 2026-09-01; the ON-AIR setter stays armable signed-out (2026-09-20: intent must be able to light while offline — a pill that could not light signed-out was the dead end the guard shipped to forbid)), `ShowPrimaryStartButton` = **always the idle face** (`IsOffline && !IsRecording`,
2026-09-01 — the old connected/record-only gate blanked the bar after stopping signed-out),
`ShowEndStreamButton`, `CanStartSession`, `AccountStatusLightToolTip`. Sign-in is a **context-menu
item on Start** ("Sign in to YouTube" → `SignInCommand`, visible while disconnected) — the standalone
Sign In button is gone (TASK 30's single-button rule, completed). `StartSession()` routes: ON-AIR on
→ GoLive dialog then stream; REC on or **nothing armed** → `BeginRecordOnly()` (unarmed
Start lights the REC pill and records — no dead-end no-ops; local recording needs no account).
`IsEditMode` also requires `!IsRecording` (lock scrubbing while recording).
**Top bar order (creator spec 2026-09-01):** `[sign light] REC [pill] [sign light] ON-AIR [pill]`
— each reality lamp sits before its own intent switch. **Stop ends everything:** `StopStream`
clears both pills (see "Stop ends everything; failures roll back" below).
- **State model (`MainViewModel`):** pills `RecordPillOn`/`OnAirPillOn` stay the **routing model**
(radio-exclusive — arming one clears the other, record-OR-live 2026-09-01; the ON-AIR setter stays
armable signed-out (2026-09-20: intent must be able to light while offline)). **Top bar redesign
(2026-09-22):** the bar is ONE surface rendered by the FIRST decision — Record or Stream — and each
world is the whole bar (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`, wired by
`RaiseTopBarModes()`). The old sign-lights + two pill toggles + status dot + PRIVATE/TEST chips are
gone; the mode is one **segmented REC|ON-AIR switch** (`SegmentToggle`/`SegmentLabel` styles), and
"going back" is one tap on the other segment. Idle worlds: Record = switch + "Start Recording"
(`PrimaryStartButtonLabel` names the armed world) and **no YouTube identity at all**; Stream = switch
+ "Go Live" + Test (Test is a **child of Stream** — visible only stream-armed AND signed-in) plus the
account zone on the right (`ShowSignInButton` = stream world signed-out; `ShowAvatarButton` = the
avatar, right-click Change Account/Logout). Running retires the switch and worlds; one reality line
`● word elapsed` + End replace them (`RunningVisible`/`RunningText`/`RunningDotBrush` — TEST/LIVE/REC,
gold/red/green). The account **status dot is gone** — the avatar/Sign In pair carries connectedness.
`ShowPrimaryStartButton` remains the always-idle face. `StopStream` clears both pills (see "Stop ends
everything; failures roll back" below).
- **Filenames** (pure `Services/RecordingFile`): auto-name `ty-<yyyyMMdd>-<HHmm start>-0000.mp4` at start;
**rename-on-stop** to `ty-…-<hh2mm2 actual length>.mp4` (`FinalizeRecordingAsync` after the pump stops &
the file closes), numeric `-2`/`-3` suffix on collision (`UniquePath`). Length comes from `_liveElapsed`,
+21 -13
View File
@@ -65,13 +65,13 @@ public sealed class PillRadioTests
/// <summary>Good Dog (one integration test, creator ruling 2026-09-20): the ON-AIR
/// pill is armable signed-out — intent may light while the account is offline (a
/// greyed pill was the dead end the guard shipped to forbid). A front-branch on
/// the ON-AIR setter that dropped the pill headlessly is what the pill is FOR. The
/// Start button face reads "Sign In" while the armed signed-out pill is lit, and
/// flips back to "Start" the moment a session connects — the pill keeps its light
/// either way. Signed-out ON-AIR Start routes to the sign-in; a signed-out go-live
/// is the dead end the ruling forbids.</summary>
/// the ON-AIR setter that dropped the pill headlessly is what the pill is FOR.
/// Since the top bar redesign, the Go face names the armed WORLD ("Go Live")
/// rather than the account race — a signed-out Stream press routes into the
/// sign-in inside StartSession, and the dedicated Sign In button appears on the
/// right exactly when the stream sequence needs the account step.</summary>
[Fact]
public void SignedOut_OnAir_Pill_Stays_Lit_And_Start_Face_Is_SignIn()
public void SignedOut_OnAir_Pill_Stays_Lit_And_Stream_World_Is_GoLive()
{
_app.Run(RunSignedOutOnAir);
}
@@ -86,6 +86,9 @@ public sealed class PillRadioTests
SqliteConnection.ClearAllPools();
var vm = new MainViewModel();
// The dev machine may hold a real saved OAuth session (loads synchronously
// during the ctor), so the signed-out face is forced deterministically.
vm.IsConnected = false;
// No sign-in: the ON-AIR pill MUST still be able to light — intent before
// reality, armable signed-out (creator ruling 2026-09-20).
@@ -93,16 +96,21 @@ public sealed class PillRadioTests
Assert.True(vm.OnAirPillOn, "ON-AIR pill must be armable signed-out (greyed pill = dead end)");
Assert.True(vm.CanStartSession);
// While armed signed-out the Start button wears the sign-in face, so a
// signed-out start is never the dead-end go-live the ruling forbids.
Assert.Equal("Sign In", vm.PrimaryStartButtonLabel);
Assert.False(vm.ShowPrimaryStartButton && vm.CanStartSession && false,
"sanity: face follows the pill, not the account race");
// While armed signed-out the Go face names the Stream world ("Go Live");
// the account step surfaces as the Sign In button on the right instead,
// and a signed-out start routes into the sign-in (never a dead-end go-live).
Assert.Equal("Go Live", vm.PrimaryStartButtonLabel);
Assert.True(vm.ShowSignInButton);
Assert.False(vm.ShowRecordAction);
Assert.True(vm.ShowStreamActions);
// A session connects: the pill KEEPS its light and the face flips to Start.
// A session connects: the pill KEEPS its light; the stream world's Sign In
// retires and the avatar confirms the account.
vm.IsConnected = true;
Assert.True(vm.OnAirPillOn, "the armed signed-out pill must survive the sign-in");
Assert.Equal("Start", vm.PrimaryStartButtonLabel);
Assert.Equal("Go Live", vm.PrimaryStartButtonLabel);
Assert.False(vm.ShowSignInButton);
Assert.True(vm.ShowAvatarButton);
}
finally
{
+8 -3
View File
@@ -149,15 +149,20 @@ public sealed class TestStreamTests
Assert.False(model.Ypp.IsDrawerOpen);
Assert.False(model.TestSession.IsDrawerOpen);
// Test-button gate follows the sign-in state deterministically (the
// initial state is environment-dependent — a dev machine may hold a real
// saved OAuth session).
// Test-button gate: Test is a child of Stream — it procs ONLY when the
// ON-AIR world is armed AND signed in (title-gating ruling). The initial
// sign-in state is environment-dependent (a dev machine may hold a real
// saved OAuth session), so it is forced deterministically.
model.IsConnected = false;
Assert.False(model.CanStartTest);
Assert.False(model.ShowTestButton);
model.OnAirPillOn = true; // stream world
model.IsConnected = true;
Assert.True(model.CanStartTest);
Assert.True(model.ShowTestButton);
model.RecordPillOn = true; // back to the record world → Test retires
Assert.False(model.CanStartTest);
Assert.False(model.ShowTestButton);
model.TestSession.ToggleDrawerCommand.Execute(null);
Assert.True(model.TestSession.IsDrawerOpen);
+106
View File
@@ -0,0 +1,106 @@
using System;
using System.IO;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// Top bar redesign — the bar is ONE surface rendered by the FIRST decision
/// (Record or Stream). Each world is the whole bar: the Record world has no
/// YouTube identity at all, the Stream world owns the account zone (Sign In or
/// avatar) plus Test. The switch is a two-position gate, so "going back" is one
/// tap on the other segment. While running, the switch retires and a single
/// reality line (dot + word + elapsed) + End replace it.
/// </summary>
[Collection("RealApp")]
public sealed class TopBarModeTests
{
private readonly RealAppHost _app;
public TopBarModeTests(RealAppHost app) => _app = app;
[Fact]
public void TopBar_Renders_Only_The_Armed_World()
{
_app.Run(Run);
}
private void Run()
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-topbar-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
try
{
using (var schema = new LayoutStore(tempDb)) { }
SqliteConnection.ClearAllPools();
var vm = new MainViewModel();
// The dev machine may hold a real saved OAuth session (loads synchronously
// during the ctor), so the sign-in state is forced deterministically.
vm.IsConnected = false;
// Default (nothing armed) = the Record world: Go says "Start Recording",
// no account zone, no Test — YouTube doesn't exist in this world.
Assert.True(vm.ShowIdleWorld);
Assert.True(vm.ShowRecordAction);
Assert.False(vm.ShowStreamActions);
Assert.False(vm.ShowSignInButton);
Assert.False(vm.ShowAvatarButton);
Assert.False(vm.ShowTestButton);
Assert.Equal("Start Recording", vm.PrimaryStartButtonLabel);
// Arming ON-AIR switches the whole bar to the Stream world.
vm.OnAirPillOn = true;
Assert.False(vm.ShowRecordAction);
Assert.True(vm.ShowStreamActions);
// Signed out: the account zone shows Sign In; no avatar; Test is gated
// behind the sign-in (Test rides on Stream AND a connected account).
Assert.True(vm.ShowSignInButton);
Assert.False(vm.ShowAvatarButton);
Assert.False(vm.ShowTestButton);
Assert.Equal("Go Live", vm.PrimaryStartButtonLabel);
// Connected: avatar + Test join the Stream world; Sign In retires.
vm.IsConnected = true;
Assert.False(vm.ShowSignInButton);
Assert.True(vm.ShowAvatarButton);
Assert.True(vm.ShowTestButton);
// Going back is one tap: flipping REC returns the bar to the Record world.
vm.RecordPillOn = true;
Assert.False(vm.ShowStreamActions);
Assert.True(vm.ShowRecordAction);
Assert.False(vm.ShowAvatarButton);
Assert.False(vm.ShowTestButton);
Assert.Equal("Start Recording", vm.PrimaryStartButtonLabel);
// Running retires the switch and worlds; the reality line pins the mode.
vm.RecordPillOn = false;
vm.OnAirPillOn = true;
vm.IsRecording = true;
Assert.False(vm.ShowIdleWorld);
Assert.True(vm.RunningVisible);
Assert.Equal("REC", vm.RunningText);
Assert.Equal("#22c55e", vm.RunningDotBrush);
vm.IsRecording = false;
vm.StreamStatus = StreamStatus.Streaming;
Assert.Equal("LIVE", vm.RunningText);
Assert.Equal("#e94560", vm.RunningDotBrush);
vm.IsTestStream = true;
Assert.Equal("TEST", vm.RunningText);
Assert.Equal("#9c6f1c", vm.RunningDotBrush);
}
finally
{
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
}