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
+32
View File
@@ -294,6 +294,38 @@ public partial class MainViewModel
/// lip-sync is right", so it deserves one click, not a drag.</summary>
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()
{
if (MicMuted)
+45
View File
@@ -21,6 +21,32 @@ public partial class MainViewModel
// XAML bindings and MainViewModel call sites keep working unchanged.
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
{
get => _stagedScene;
@@ -38,6 +64,8 @@ public partial class MainViewModel
// (build 1082: the option vanished after the first layout load).
OnPropertyChanged(nameof(CanAddBlur));
OnPropertyChanged(nameof(StagedBrandLogo));
OnPropertyChanged(nameof(SceneMutesMic));
OnPropertyChanged(nameof(SceneMutesDesktop));
SelectedElement = null;
if (value != null)
@@ -137,9 +165,24 @@ public partial class MainViewModel
{
LiveScene = scene;
_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>
public event Action<TransitionType, double>? PreviewTransition;
@@ -377,6 +420,8 @@ public partial class MainViewModel
Name = name.Trim(),
IsChatScene = SceneCatalog.IsChat(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);
EnsureZoomLayer(scene);