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:
@@ -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"/>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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). -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user