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="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
@@ -341,10 +342,28 @@
HorizontalAlignment="Center" VerticalAlignment="Center" HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/> 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. <!-- Resource alert: pinned to the bottom of the Layers panel.
Red when a required resource (the one-and-only webcam) Red when a required resource (the one-and-only webcam)
could not be locked; persists and re-polls until resolved. --> 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" Background="{StaticResource Brush.ErrorContainer}" BorderBrush="{StaticResource Brush.Error}" BorderThickness="1"
Visibility="{Binding WebcamLockAlert, Converter={StaticResource NotNullToVis}}"> Visibility="{Binding WebcamLockAlert, Converter={StaticResource NotNullToVis}}">
<StackPanel> <StackPanel>
@@ -358,17 +377,17 @@
<!-- ELEMENTS panel: per-element config beneath Sources. <!-- ELEMENTS panel: per-element config beneath Sources.
Collapses entirely when no element is selected. --> Collapses entirely when no element is selected. -->
<Rectangle Grid.Row="4" Height="1" Fill="{StaticResource Brush.Outline}" Margin="0,22,0,10"/> <Rectangle Grid.Row="5" Height="1" Fill="{StaticResource Brush.Outline}" Margin="0,22,0,10"/>
<StackPanel Grid.Row="5" Orientation="Horizontal" Margin="0,8,0,0"> <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" <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" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round"
Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,6,0"/> Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Text="PROPERTIES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/> <TextBlock Text="PROPERTIES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
</StackPanel> </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" Margin="8,4,0,0" TextWrapping="Wrap"
Visibility="{Binding SelectedElement, Converter={StaticResource NullToVis}}"/> 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}}"> Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
<StackPanel x:Name="ElementsPanel" Margin="8,4,0,0"> <StackPanel x:Name="ElementsPanel" Margin="8,4,0,0">
<StackPanel Visibility="{Binding SelectedElement.IsTextOverlay, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8"> <StackPanel Visibility="{Binding SelectedElement.IsTextOverlay, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
+22
View File
@@ -72,6 +72,28 @@ public class Scene : INotifyPropertyChanged
public bool IsChatScene { get; init; } public bool IsChatScene { get; init; }
private bool? _sceneMutesMic;
/// <summary>TASK 35: what this scene does to the mix when it takes the program output.
/// <c>null</c> = leave it alone (the default; scenes are passive until told); true mutes the mic on
/// entry. Applied once at entry, never while staging offline — auditioning a scene must not edit
/// the stream's state.</summary>
public bool? SceneMutesMic
{
get => _sceneMutesMic;
set => Set(ref _sceneMutesMic, value);
}
private bool? _sceneMutesDesktop;
/// <summary>TASK 35: as <see cref="SceneMutesMic"/>, for desktop/game audio.</summary>
public bool? SceneMutesDesktop
{
get => _sceneMutesDesktop;
set => Set(ref _sceneMutesDesktop, value);
}
/// <summary> /// <summary>
/// Asset-table id of this scene's top-strip tile artwork. The tile is a /// Asset-table id of this scene's top-strip tile artwork. The tile is a
/// separate image from the scene's background: the preview pane keeps /// separate image from the scene's background: the preview pane keeps
+3 -1
View File
@@ -16,7 +16,7 @@ public partial class LayoutStore : IDisposable
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{ {
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId FROM Scene ORDER BY SortOrder"; cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId, SceneMutesMic, SceneMutesDesktop FROM Scene ORDER BY SortOrder";
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
while (reader.Read()) while (reader.Read())
{ {
@@ -29,6 +29,8 @@ public partial class LayoutStore : IDisposable
HasBackground = reader.GetInt32(4) != 0, HasBackground = reader.GetInt32(4) != 0,
HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0, HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
ThumbnailAssetId = reader.FieldCount > 6 && !reader.IsDBNull(6) ? reader.GetString(6) : null, ThumbnailAssetId = reader.FieldCount > 6 && !reader.IsDBNull(6) ? reader.GetString(6) : null,
SceneMutesMic = reader.FieldCount > 7 && !reader.IsDBNull(7) ? reader.GetInt32(7) != 0 : null,
SceneMutesDesktop = reader.FieldCount > 8 && !reader.IsDBNull(8) ? reader.GetInt32(8) != 0 : null,
}); });
} }
} }
+37
View File
@@ -16,6 +16,8 @@ public partial class LayoutStore : IDisposable
IsHidden INTEGER NOT NULL DEFAULT 0, IsHidden INTEGER NOT NULL DEFAULT 0,
IsChatScene INTEGER NOT NULL DEFAULT 0, IsChatScene INTEGER NOT NULL DEFAULT 0,
HasBackground INTEGER NOT NULL DEFAULT 1, HasBackground INTEGER NOT NULL DEFAULT 1,
SceneMutesMic INTEGER NULL,
SceneMutesDesktop INTEGER NULL,
SortOrder INTEGER NOT NULL DEFAULT 0 SortOrder INTEGER NOT NULL DEFAULT 0
); );
""", """,
@@ -139,6 +141,7 @@ public partial class LayoutStore : IDisposable
MigrateSceneTable(); MigrateSceneTable();
MigrateSceneSocialBarColumn(); MigrateSceneSocialBarColumn();
MigrateSceneThumbnailColumn(); MigrateSceneThumbnailColumn();
MigrateSceneAudioColumns();
MigrateSocialsTable(); MigrateSocialsTable();
MigrateSocialEntryTable(); MigrateSocialEntryTable();
if (GetUserVersion() < 3) if (GetUserVersion() < 3)
@@ -154,6 +157,40 @@ public partial class LayoutStore : IDisposable
// v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs). // v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
// CREATE TABLE IF NOT EXISTS doesn't touch existing tables, so pre-v2 DBs // CREATE TABLE IF NOT EXISTS doesn't touch existing tables, so pre-v2 DBs
// get the columns here instead. // get the columns here instead.
/// <summary>TASK 35: scene-linked audio. NULL means "leave the mix alone", so a scene is passive
/// until the creator says otherwise — including every layout written before this column existed.</summary>
private void MigrateSceneAudioColumns()
{
var sceneColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(Scene);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
sceneColumns.Add(reader.GetString(1));
}
if (!sceneColumns.Contains("SceneMutesMic"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN SceneMutesMic INTEGER NULL;";
cmd.ExecuteNonQuery();
}
if (!sceneColumns.Contains("SceneMutesDesktop"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN SceneMutesDesktop INTEGER NULL;";
cmd.ExecuteNonQuery();
}
// BRB ships muted (creator spec), applied only where the creator has never expressed an opinion.
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "UPDATE Scene SET SceneMutesMic = 1 WHERE SceneMutesMic IS NULL AND LOWER(TRIM(Name)) = 'brb';";
cmd.ExecuteNonQuery();
}
}
private void MigrateSourceTable() private void MigrateSourceTable()
{ {
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+12 -2
View File
@@ -54,8 +54,10 @@ public partial class LayoutStore : IDisposable
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{ {
cmd.CommandText = """ cmd.CommandText = """
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId, SortOrder) INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId,
VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $thumbnail, $sort) SceneMutesMic, SceneMutesDesktop, SortOrder)
VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $thumbnail,
$mutesMic, $mutesDesktop, $sort)
"""; """;
cmd.Transaction = tx; cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text); var idP = cmd.Parameters.Add("$id", SqliteType.Text);
@@ -65,6 +67,8 @@ public partial class LayoutStore : IDisposable
var backgroundP = cmd.Parameters.Add("$hasBackground", SqliteType.Integer); var backgroundP = cmd.Parameters.Add("$hasBackground", SqliteType.Integer);
var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer); var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer);
var thumbnailP = cmd.Parameters.Add("$thumbnail", SqliteType.Text); var thumbnailP = cmd.Parameters.Add("$thumbnail", SqliteType.Text);
var mutesMicP = cmd.Parameters.Add("$mutesMic", SqliteType.Integer);
var mutesDesktopP = cmd.Parameters.Add("$mutesDesktop", SqliteType.Integer);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer); var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
var sort = 0; var sort = 0;
@@ -77,6 +81,12 @@ public partial class LayoutStore : IDisposable
backgroundP.Value = scene.HasBackground ? 1 : 0; backgroundP.Value = scene.HasBackground ? 1 : 0;
socialBarP.Value = scene.HasSocialBar ? 1 : 0; socialBarP.Value = scene.HasSocialBar ? 1 : 0;
thumbnailP.Value = (object?)scene.ThumbnailAssetId ?? DBNull.Value; thumbnailP.Value = (object?)scene.ThumbnailAssetId ?? DBNull.Value;
mutesMicP.Value = scene.SceneMutesMic.HasValue
? (object)(scene.SceneMutesMic.Value ? 1 : 0)
: DBNull.Value;
mutesDesktopP.Value = scene.SceneMutesDesktop.HasValue
? (object)(scene.SceneMutesDesktop.Value ? 1 : 0)
: DBNull.Value;
sortP.Value = sort++; sortP.Value = sort++;
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
+2 -2
View File
@@ -9,7 +9,7 @@
**Creator ruling 2026-11-09 — finish the queue before the new features, gold pass always last.** **Creator ruling 2026-11-09 — finish the queue before the new features, gold pass always last.**
`35 → 37 → 40·A/C/D → 55·2–3 → Move → 9·5 → 14 (slices) → 65 → 66 → 67 → 68 → 36` `37 → 40·A/C/D → 55·2–3 → Move → 9·5 → 14 (slices) → 65 → 66 → 67 → 68 → 36`
The rule behind the two retirements below: **do not rebuild the platform's own surface.** Scheduling, The rule behind the two retirements below: **do not rebuild the platform's own surface.** Scheduling,
publishing, moderation and channel management are YouTube's; the app owns capture → scene → sound → publishing, moderation and channel management are YouTube's; the app owns capture → scene → sound →
@@ -19,7 +19,7 @@ output.
| Task | Status | Scope | Detail | | Task | Status | Scope | Detail |
|---|---|---|---| |---|---|---|---|
| 35 | ☐ | **Scene-linked audio** — the scene remembers the room: BRB mutes the mic, Chat restores it, applied once at the live transition | [`task-35`](TASKS/task-35-scene-linked-audio.md) | | 35 | ✅ | **Scene-linked audio** — the scene remembers the room: BRB ships muted, three-state checkboxes per scene, applied once when the scene takes the program output (done 2026-11-10) | [`task-35`](TASKS/task-35-scene-linked-audio.md) |
| 37 | ☐ | **Defaults vs current layout split** — separate branded defaults from the live workspace; enables one-click revert | [`task-37`](TASKS/task-37-defaults-current-split.md) | | 37 | ☐ | **Defaults vs current layout split** — separate branded defaults from the live workspace; enables one-click revert | [`task-37`](TASKS/task-37-defaults-current-split.md) |
| 40·A/C/D | ☐ | **Settings round** — active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) | | 40·A/C/D | ☐ | **Settings round** — active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) |
| 55·2–3 | ☐ | **Disk hygiene** — delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) | | 55·2–3 | ☐ | **Disk hygiene** — delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) |
+12 -6
View File
@@ -7,13 +7,19 @@
audio at transitions — the scene carries the room state. OBS needs scripts/plugins; native = the audio at transitions — the scene carries the room state. OBS needs scripts/plugins; native = the
creator-proof pitch. creator-proof pitch.
1. ☐ `Scene` gains nullable `SceneMutesMic` / `SceneMutesDesktop` — `null` = don't touch (the default; **Shipped 2026-11-10.** Columns `Scene.SceneMutesMic` / `SceneMutesDesktop` (nullable = passive),
three-state checkboxes under the layers list ("When this scene goes live"), applied once at the program
transition in `TransitionToScene`. `SceneAudioTests` pins the whole rule: offline staging edits nothing,
`null` never overrides a manual mute, a scene with an opinion wins on its next entry, and all three
states survive a save/reload.
1. ✅ `Scene` gains nullable `SceneMutesMic` / `SceneMutesDesktop` — `null` = don't touch (the default;
scenes are passive until told). Schema column bump + roundtrip. scenes are passive until told). Schema column bump + roundtrip.
2. ☐ Applied on **live transitions only** (thumbnail click / hotkey), ONCE at entry — never on 2. ✅ Applied on **live transitions only** (thumbnail click / hotkey), ONCE at entry — never on
offline staging (no surprise stream-state edits while auditioning). offline staging (no surprise stream-state edits while auditioning).
3. ☐ The creator is boss: a manual mute/unmute mid-scene is never overridden until the next entry. 3. ✅ The creator is boss: a manual mute/unmute mid-scene is never overridden until the next entry.
4. ☐ BRB ships `SceneMutesMic = true`; other scenes default `null` — creator's choice. 4. ✅ BRB ships `SceneMutesMic = true`; other scenes default `null` — creator's choice.
5. ☐ Edit UI: two checkboxes in the scene's Properties section (left panel, edit mode) — no new chrome. 5. ✅ Edit UI: two checkboxes in the scene's Properties section (left panel, edit mode) — no new chrome.
6. ☐ Test (the ONE): transition applies scene state; `null` leaves state untouched; manual override 6. ✅ Test (the ONE): transition applies scene state; `null` leaves state untouched; manual override
survives until the next entry. survives until the next entry.
+32
View File
@@ -294,6 +294,38 @@ public partial class MainViewModel
/// lip-sync is right", so it deserves one click, not a drag.</summary> /// lip-sync is right", so it deserves one click, not a drag.</summary>
internal void SetSyncOffset(int milliseconds) => AudioSyncOffsetMs = milliseconds; internal void SetSyncOffset(int milliseconds) => AudioSyncOffsetMs = milliseconds;
/// <summary>TASK 35: mute/unmute from a scene entry. Idempotent — a scene that says "mute" while
/// the mic is already muted must not overwrite the level it would restore.</summary>
internal void SetMicMuted(bool muted)
{
if (muted == MicMuted) return;
if (muted)
{
_volumeBeforeMute = MicVolume;
MicVolume = 0;
}
else
{
MicVolume = _volumeBeforeMute ?? 0.8;
BeginVolumeFlash();
}
}
/// <summary>TASK 35: as <see cref="SetMicMuted"/>, for desktop/game audio.</summary>
internal void SetGameMuted(bool muted)
{
if (muted == GameMuted) return;
if (muted)
{
_gameVolumeBeforeMute = GameAudioVolume;
GameAudioVolume = 0;
}
else
{
GameAudioVolume = _gameVolumeBeforeMute ?? 1.0;
}
}
private void ToggleMicMute() private void ToggleMicMute()
{ {
if (MicMuted) if (MicMuted)
+45
View File
@@ -21,6 +21,32 @@ public partial class MainViewModel
// XAML bindings and MainViewModel call sites keep working unchanged. // XAML bindings and MainViewModel call sites keep working unchanged.
public ObservableCollection<Scene> Scenes => _sceneGraph.Scenes; public ObservableCollection<Scene> Scenes => _sceneGraph.Scenes;
/// <summary>TASK 35: the staged scene's audio state, bound by the left panel's three-state
/// checkboxes (indeterminate = leave the mix alone).</summary>
public bool? SceneMutesMic
{
get => StagedScene?.SceneMutesMic;
set
{
if (StagedScene is not { } scene || scene.SceneMutesMic == value) return;
scene.SceneMutesMic = value;
ScheduleSave();
OnPropertyChanged(nameof(SceneMutesMic));
}
}
public bool? SceneMutesDesktop
{
get => StagedScene?.SceneMutesDesktop;
set
{
if (StagedScene is not { } scene || scene.SceneMutesDesktop == value) return;
scene.SceneMutesDesktop = value;
ScheduleSave();
OnPropertyChanged(nameof(SceneMutesDesktop));
}
}
public Scene? StagedScene public Scene? StagedScene
{ {
get => _stagedScene; get => _stagedScene;
@@ -38,6 +64,8 @@ public partial class MainViewModel
// (build 1082: the option vanished after the first layout load). // (build 1082: the option vanished after the first layout load).
OnPropertyChanged(nameof(CanAddBlur)); OnPropertyChanged(nameof(CanAddBlur));
OnPropertyChanged(nameof(StagedBrandLogo)); OnPropertyChanged(nameof(StagedBrandLogo));
OnPropertyChanged(nameof(SceneMutesMic));
OnPropertyChanged(nameof(SceneMutesDesktop));
SelectedElement = null; SelectedElement = null;
if (value != null) if (value != null)
@@ -137,9 +165,24 @@ public partial class MainViewModel
{ {
LiveScene = scene; LiveScene = scene;
_transition.Start(SelectedTransitionType, TransitionDurationMs, null); _transition.Start(SelectedTransitionType, TransitionDurationMs, null);
// TASK 35: the scene carries the room. Applied ONCE at entry, and only when the program
// output actually changes — staging a scene offline must never edit the stream's state.
ApplySceneAudio(scene);
} }
} }
/// <summary>TASK 35: a scene's audio state, applied once when it takes the program output.
/// <c>null</c> means "leave the mix alone" — the creator's manual mute survives until a scene that
/// actually has an opinion takes over.</summary>
private void ApplySceneAudio(Scene scene)
{
if (scene.SceneMutesMic is { } muteMic)
SetMicMuted(muteMic);
if (scene.SceneMutesDesktop is { } muteDesktop)
SetGameMuted(muteDesktop);
}
/// <summary>Raised before a scene switch so the preview can animate the old picture away.</summary> /// <summary>Raised before a scene switch so the preview can animate the old picture away.</summary>
public event Action<TransitionType, double>? PreviewTransition; public event Action<TransitionType, double>? PreviewTransition;
@@ -377,6 +420,8 @@ public partial class MainViewModel
Name = name.Trim(), Name = name.Trim(),
IsChatScene = SceneCatalog.IsChat(name), IsChatScene = SceneCatalog.IsChat(name),
HasBackground = SceneCatalog.HasBackground(name), HasBackground = SceneCatalog.HasBackground(name),
// TASK 35: BRB ships with the mic muted; every other scene is passive until told.
SceneMutesMic = SceneCatalog.Is(name, SceneCatalog.Brb) ? true : null,
}; };
EnsureBackground(scene); EnsureBackground(scene);
EnsureZoomLayer(scene); EnsureZoomLayer(scene);
+89
View File
@@ -0,0 +1,89 @@
using System;
using System.IO;
using System.Linq;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// TASK 35 — the scene remembers the room.
///
/// <para>The rule the test pins, in the creator's words: a scene's audio state is applied <b>once when
/// it takes the program output</b>. `null` means "leave the mix alone", so staging a scene offline, or
/// switching to a scene with no opinion, must never undo a manual mute.</para>
/// </summary>
[Collection("RealApp")]
public sealed class SceneAudioTests
{
private readonly RealAppHost _app;
public SceneAudioTests(RealAppHost app) => _app = app;
[Fact]
public void EnteringALiveScene_AppliesItsAudioState_OnceAndOnlyLive() => _app.Run(() =>
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-sceneaudio-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
try
{
var vm = new MainViewModel();
var brb = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Brb));
var chat = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Chat));
// Ships muted (the creator's default), while every other scene is passive.
Assert.True(brb.SceneMutesMic);
Assert.Null(chat.SceneMutesMic);
Assert.Null(brb.SceneMutesDesktop);
vm.MicVolume = 0.8;
Assert.False(vm.MicMuted);
// Staging offline: no program output, so no audio edit. The creator is auditioning.
vm.TransitionToScene(brb);
Assert.False(vm.MicMuted);
// Live: entering BRB mutes the mic.
vm.StreamStatus = StreamStatus.Streaming;
vm.TransitionToScene(vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live)));
vm.TransitionToScene(brb);
Assert.True(vm.MicMuted);
// The creator is boss: a manual unmute survives a scene with no opinion.
vm.SetMicMuted(false);
Assert.False(vm.MicMuted);
vm.TransitionToScene(chat);
Assert.False(vm.MicMuted);
// ...but a scene that DOES have an opinion wins on its next entry.
chat.SceneMutesMic = true;
vm.TransitionToScene(brb);
vm.TransitionToScene(chat);
Assert.True(vm.MicMuted);
// Desktop audio was never told anything, so it was never touched.
Assert.False(vm.GameMuted);
// Roundtrip: the three states survive a save + reload, null included.
vm.SaveLayoutNow();
SqliteConnection.ClearAllPools();
using var store = new LayoutStore(tempDb);
var reloaded = store.Load();
var reloadedBrb = reloaded.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Brb));
var reloadedChat = reloaded.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Chat));
var reloadedLive = reloaded.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
Assert.True(reloadedBrb.SceneMutesMic);
Assert.True(reloadedChat.SceneMutesMic);
Assert.Null(reloadedLive.SceneMutesMic);
}
finally
{
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best effort */ }
}
});
}