feat(35): scenes remember the room — BRB mutes the mic on entry

591 green, 0 warnings.

- `Scene.SceneMutesMic` / `SceneMutesDesktop` are **nullable booleans**: null = leave the mix alone, so
  a scene is passive until the creator says otherwise. Schema columns added, NULL for every existing
  layout except one deliberate default.
- **Applied once when the scene takes the PROGRAM output** (`TransitionToScene`, only when live or
  recording). Staging a scene offline is auditioning, not a stream edit — the test asserts that.
- **The creator is boss:** a manual mute survives any transition into a scene with no opinion; a scene
  that does have one wins on its next entry. `SetMicMuted`/`SetGameMuted` are idempotent so a repeated
  "mute" cannot clobber the level that unmute would restore.
- **BRB ships muted** (creator spec) for new layouts *and* via a one-time migration for existing rows
  where the creator has never expressed an opinion.
- UI: two three-state checkboxes under the layers list ("When this scene goes live"), styled with
  `PropertyPillCheck` so the theme test holds.

`SceneAudioTests` covers the whole rule in one integration test: default state, offline staging does
nothing, live entry mutes, manual unmute survives a passive scene, an opinionated scene wins, desktop
untouched, and all three states roundtrip through save/reload.

Needs the creator's eye: the checkboxes are new chrome in the left panel (indeterminate = passive).
This commit is contained in:
2026-11-10 11:51:21 -08:00
parent 80ccdc9cb8
commit c257de1516
10 changed files with 278 additions and 16 deletions
+24 -5
View File
@@ -101,6 +101,7 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
@@ -341,10 +342,28 @@
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/>
<!-- TASK 35: the scene remembers the room. Three states on purpose:
checked = mute on entry, unchecked = unmute on entry, indeterminate = leave the
mix alone (the default, so a scene is passive until it is told). -->
<StackPanel Grid.Row="3" Margin="0,10,0,0"
Visibility="{Binding StagedScene, Converter={StaticResource NotNullToVis}}">
<TextBlock Text="WHEN THIS SCENE GOES LIVE" Style="{StaticResource SectionHeader}"/>
<CheckBox Content="Mute the mic" IsThreeState="True" Margin="10,6,0,0"
Style="{StaticResource PropertyPillCheck}"
IsChecked="{Binding SceneMutesMic, Mode=TwoWay}"
Foreground="{StaticResource Brush.OnSurface}" FontSize="12"
ToolTip="Checked mutes on entry · Unchecked unmutes on entry · Indeterminate leaves the mix alone"/>
<CheckBox Content="Mute desktop audio" IsThreeState="True" Margin="10,4,0,0"
Style="{StaticResource PropertyPillCheck}"
IsChecked="{Binding SceneMutesDesktop, Mode=TwoWay}"
Foreground="{StaticResource Brush.OnSurface}" FontSize="12"
ToolTip="Checked mutes on entry · Unchecked unmutes on entry · Indeterminate leaves the mix alone"/>
</StackPanel>
<!-- Resource alert: pinned to the bottom of the Layers panel.
Red when a required resource (the one-and-only webcam)
could not be locked; persists and re-polls until resolved. -->
<Border Grid.Row="3" Margin="0,8,0,0" Padding="8,6" CornerRadius="4"
<Border Grid.Row="4" Margin="0,8,0,0" Padding="8,6" CornerRadius="4"
Background="{StaticResource Brush.ErrorContainer}" BorderBrush="{StaticResource Brush.Error}" BorderThickness="1"
Visibility="{Binding WebcamLockAlert, Converter={StaticResource NotNullToVis}}">
<StackPanel>
@@ -358,17 +377,17 @@
<!-- ELEMENTS panel: per-element config beneath Sources.
Collapses entirely when no element is selected. -->
<Rectangle Grid.Row="4" Height="1" Fill="{StaticResource Brush.Outline}" Margin="0,22,0,10"/>
<StackPanel Grid.Row="5" Orientation="Horizontal" Margin="0,8,0,0">
<Rectangle Grid.Row="5" Height="1" Fill="{StaticResource Brush.Outline}" Margin="0,22,0,10"/>
<StackPanel Grid.Row="6" Orientation="Horizontal" Margin="0,8,0,0">
<Path Data="M12,8 A4,4 0 1 0 12.01,8 M19.4,15 L21,16.2 L19.4,19 L17.2,18.3 A7,7 0 0 1 15,19.4 L14.5,21.5 H9.5 L9,19.4 A7,7 0 0 1 6.8,18.3 L4.6,19 L3,16.2 L4.6,15 A7,7 0 0 1 4.6,9 L3,7.8 L4.6,5 L6.8,5.7 A7,7 0 0 1 9,4.6 L9.5,2.5 H14.5 L15,4.6 A7,7 0 0 1 17.2,5.7 L19.4,5 L21,7.8 L19.4,9 A7,7 0 0 1 19.4,15 Z"
Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round"
Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Text="PROPERTIES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Grid.Row="6" Text="Select an item to edit its properties" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
<TextBlock Grid.Row="7" Text="Select an item to edit its properties" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
Margin="8,4,0,0" TextWrapping="Wrap"
Visibility="{Binding SelectedElement, Converter={StaticResource NullToVis}}"/>
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="6" VerticalScrollBarVisibility="Auto" Background="Transparent"
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="7" VerticalScrollBarVisibility="Auto" Background="Transparent"
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
<StackPanel x:Name="ElementsPanel" Margin="8,4,0,0">
<StackPanel Visibility="{Binding SelectedElement.IsTextOverlay, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">