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:
+24
-5
@@ -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">
|
||||
|
||||
@@ -72,6 +72,28 @@ public class Scene : INotifyPropertyChanged
|
||||
|
||||
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>
|
||||
/// 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
|
||||
|
||||
@@ -16,7 +16,7 @@ public partial class LayoutStore : IDisposable
|
||||
|
||||
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();
|
||||
while (reader.Read())
|
||||
{
|
||||
@@ -29,6 +29,8 @@ public partial class LayoutStore : IDisposable
|
||||
HasBackground = reader.GetInt32(4) != 0,
|
||||
HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ public partial class LayoutStore : IDisposable
|
||||
IsHidden INTEGER NOT NULL DEFAULT 0,
|
||||
IsChatScene INTEGER NOT NULL DEFAULT 0,
|
||||
HasBackground INTEGER NOT NULL DEFAULT 1,
|
||||
SceneMutesMic INTEGER NULL,
|
||||
SceneMutesDesktop INTEGER NULL,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
@@ -139,6 +141,7 @@ public partial class LayoutStore : IDisposable
|
||||
MigrateSceneTable();
|
||||
MigrateSceneSocialBarColumn();
|
||||
MigrateSceneThumbnailColumn();
|
||||
MigrateSceneAudioColumns();
|
||||
MigrateSocialsTable();
|
||||
MigrateSocialEntryTable();
|
||||
if (GetUserVersion() < 3)
|
||||
@@ -154,6 +157,40 @@ public partial class LayoutStore : IDisposable
|
||||
// v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
|
||||
// CREATE TABLE IF NOT EXISTS doesn't touch existing tables, so pre-v2 DBs
|
||||
// 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()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -54,8 +54,10 @@ public partial class LayoutStore : IDisposable
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId, SortOrder)
|
||||
VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $thumbnail, $sort)
|
||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId,
|
||||
SceneMutesMic, SceneMutesDesktop, SortOrder)
|
||||
VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $thumbnail,
|
||||
$mutesMic, $mutesDesktop, $sort)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
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 socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer);
|
||||
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 sort = 0;
|
||||
@@ -77,6 +81,12 @@ public partial class LayoutStore : IDisposable
|
||||
backgroundP.Value = scene.HasBackground ? 1 : 0;
|
||||
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
|
||||
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++;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
**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,
|
||||
publishing, moderation and channel management are YouTube's; the app owns capture → scene → sound →
|
||||
@@ -19,7 +19,7 @@ output.
|
||||
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
@@ -7,13 +7,19 @@
|
||||
audio at transitions — the scene carries the room state. OBS needs scripts/plugins; native = the
|
||||
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.
|
||||
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).
|
||||
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.
|
||||
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
|
||||
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.
|
||||
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
|
||||
survives until the next entry.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user