Files
LlamaCasty/Controls/TopBar.xaml
T
gramps dd51f7842a feat(stream): one-click Back on air — a new broadcast, honestly labelled (TASK 32·3)
The last resilience slice. When a drop ends the broadcast, YouTube cannot resume it, so the only way
back is a new one — and that is what the app now offers, in one click.

- `Services/BroadcastTitle.cs` — the resume marker is applied to the title the show was using, and any
  existing marker is REPLACED rather than stacked ("Show (resumed)" → "Show (resumed 2)"), with the base
  truncated so the whole title fits YouTube's 100-character limit. Otherwise a channel grows two
  identically named videos and the creator has to explain it.
- `MainViewModel.BackOnAirCommand` — reuses the ordinary go-live path with the saved details and the
  cached stream: new broadcast, new VOD, no dialog. It appears only after a drop ended the session and
  the ordinary start button steps aside, so there is exactly one way forward.
- The running line goes red OFF AIR (not amber) in that state; the button reads "● Back on air".

Tests: `BackOnAirTests` — first re-air marks the title, a second drop counts instead of stacking, the
100-character limit holds, an empty title still works, and the post-drop state exposes exactly one
start affordance. FAST gate: 552 passed, 0 failed, 0 warnings.

TASK 32 is now complete: fragmented MP4, blip retry, poll-driven RECONNECTING, ingest pre-flight, Back
on air.
2026-11-05 14:59:00 -08:00

175 lines
12 KiB
XML

<UserControl x:Class="ytLive.Controls.TopBar"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:helpers="clr-namespace:ytLive.Helpers"
x:Name="TopBarRoot">
<Grid>
<Grid.ColumnDefinitions>
<!-- 352 = the layers panel's right edge in the work area below: window margin 8 +
360px column = 368, minus this bar's own 16px padding. EDIT is right-aligned in
this column so the two edges line up exactly (creator ruling 2026-11-01). -->
<ColumnDefinition Width="352"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- BRAND + EDIT. The gear moved to the preview's right rail, first tab (2026-11-01):
app-level actions live with the other pull-outs now. -->
<Grid Grid.Column="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- LOGO / BRAND — left-click opens the in-app About overlay; right-click opens the app
menu (Report Bug / Feature Request / About). App Settings is no longer in this
menu: the gear rail tab opens the Settings drawer directly (creator ruling
2026-11-01). -->
<Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
HorizontalAlignment="Left"
VerticalAlignment="Center" ToolTip="About LlamaCasty — right-click for the menu"
Command="{Binding OpenAboutCommand}"
PreviewMouseRightButtonDown="BrandWordmark_PreviewMouseRightButtonDown">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<!-- Wordmark: "LlamaCas" + "ty" in YouTube red (creator ruling 2026-10-30 — never
re-colour it). The Run elements MUST stay adjacent: whitespace between inline
Runs renders as a space and splits the name into "LlamaCas ty". -->
<TextBlock FontSize="22" FontWeight="Bold" VerticalAlignment="Center">
<Run Text="LlamaCas" Foreground="{StaticResource Brush.OnSurface}"/><Run Text="ty" Foreground="{StaticResource Brush.Youtube}"/><Run
x:Name="BuildIdStamp" Text="{x:Static helpers:BuildStamp.Label}" FontSize="9"
BaselineAlignment="Superscript" Foreground="{StaticResource Brush.OnSurfaceVariant}"
ToolTipService.ToolTip="{x:Static helpers:BuildStamp.Label}"/><Run Text=" " FontSize="4"/>
</TextBlock>
</Button>
<!-- EDIT: explicit toggle. Neutral = off (chat), blue = on (layers/properties). The
trigger used to set the SAME colour as the resting state, so the button never
looked pressed. Right edge aligns with the layers panel below. -->
<ToggleButton Grid.Column="1" IsChecked="{Binding EditingActive, Mode=TwoWay}" Content="EDIT"
Cursor="Hand" Height="32" Padding="14,0" Margin="18,0,0,2"
HorizontalAlignment="Right" VerticalAlignment="Center"
FontWeight="Bold" FontSize="12" Foreground="{StaticResource Brush.OnAccent}" BorderThickness="0">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="EditBody" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="EditBody" Property="Background" Value="{StaticResource Brush.Tertiary}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
</Grid>
<!-- 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. The three
controls share one height (build 77 feedback) so they read as one
family — segments host, Start/Go Live, and Test all sit at 32. -->
<StackPanel Orientation="Horizontal"
Visibility="{Binding ShowIdleWorld, Converter={StaticResource BoolToVis}}">
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" Padding="2" Height="32" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<ToggleButton Style="{StaticResource RecordPillToggle}"
IsChecked="{Binding RecordPillOn}">
<!-- the label follows the pill's own foreground: muted when armed-off,
dark on red when armed (SegmentLabel pins its own colour) -->
<TextBlock Text="REC" FontSize="12" FontWeight="Bold" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ToggleButton}}"/>
</ToggleButton>
<ToggleButton Style="{StaticResource RecordPillToggle}"
IsChecked="{Binding OnAirPillOn}">
<TextBlock Text="ON-AIR" FontSize="12" FontWeight="Bold" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ToggleButton}}"/>
</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}"
Height="32" Margin="10,0,0,0" Command="{Binding PrimaryStartCommand}"
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}"/>
<!-- TASK 32·3: the drop ended this broadcast. YouTube cannot resume a completed one, so
the only honest action is a new broadcast — one click, saved details, and the title
says it is a resume so the channel shows what happened. -->
<Button x:Name="BackOnAirButton" Content="{Binding BackOnAirLabel}"
Style="{StaticResource YtButton}" Height="32" Margin="10,0,0,0"
Background="{StaticResource Brush.Primary}"
Command="{Binding BackOnAirCommand}"
ToolTip="Start a new broadcast from your saved details — this begins a new video"
Visibility="{Binding IsOffAirAfterDrop, Converter={StaticResource BoolToVis}}"/>
<!-- No separate TEST button (creator ruling 2026-10-30): EDIT decides whether
the ON-AIR start is the real livestream or the test one. -->
</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"/>
<!-- TASK 32·2: measured facts only — elapsed seconds and which attempt. No countdown:
YouTube's cutoff is not a constant we can promise. -->
<TextBlock x:Name="ReconnectDetail" Text="{Binding ReconnectDetailText}"
Foreground="{StaticResource Brush.Attention}" FontSize="13"
FontFamily="Consolas" VerticalAlignment="Center" Margin="10,0,0,0"
Visibility="{Binding IsReconnecting, Converter={StaticResource BoolToVis}}"/>
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
Background="{StaticResource Brush.Primary}" Margin="14,0,0,0"
Command="{Binding EndStreamCommand}"/>
</StackPanel>
</StackPanel>
<!-- Account zone: lives only in the Stream world. Signed out → Sign In;
signed in → a big avatar of the streaming account (build 77 feedback:
"make the user icon much bigger"). Record world has no YouTube
identity at all. -->
<StackPanel Grid.Column="2" Orientation="Horizontal" 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 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="40" Height="40" CornerRadius="20" Background="{StaticResource Brush.SurfaceContainer}"
ClipToBounds="True" BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1">
<Grid>
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
<TextBlock x:Name="AvatarInitial" Foreground="{StaticResource Brush.OnSurface}" FontSize="20"
FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed"/>
</Grid>
</Border>
</Button>
</StackPanel>
</Grid>
</UserControl>