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:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user