diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml index f384da6..c4d4244 100644 --- a/Controls/LeftPanel.xaml +++ b/Controls/LeftPanel.xaml @@ -101,6 +101,7 @@ + @@ -341,10 +342,28 @@ HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/> + + + + + + + - @@ -358,17 +377,17 @@ - - + + - - diff --git a/Models/Scene.cs b/Models/Scene.cs index 34a1baf..8aea7e5 100644 --- a/Models/Scene.cs +++ b/Models/Scene.cs @@ -72,6 +72,28 @@ public class Scene : INotifyPropertyChanged public bool IsChatScene { get; init; } + private bool? _sceneMutesMic; + + /// TASK 35: what this scene does to the mix when it takes the program output. + /// null = 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. + public bool? SceneMutesMic + { + get => _sceneMutesMic; + set => Set(ref _sceneMutesMic, value); + } + + private bool? _sceneMutesDesktop; + + /// TASK 35: as , for desktop/game audio. + public bool? SceneMutesDesktop + { + get => _sceneMutesDesktop; + set => Set(ref _sceneMutesDesktop, value); + } + + /// /// 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 diff --git a/Services/LayoutStore.Load.cs b/Services/LayoutStore.Load.cs index c0035b3..1ff071a 100644 --- a/Services/LayoutStore.Load.cs +++ b/Services/LayoutStore.Load.cs @@ -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, }); } } diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs index 61a9e62..03baaf0 100644 --- a/Services/LayoutStore.Migrations.cs +++ b/Services/LayoutStore.Migrations.cs @@ -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. + /// 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. + private void MigrateSceneAudioColumns() + { + var sceneColumns = new HashSet(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(StringComparer.OrdinalIgnoreCase); diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs index fe5849a..bb839cd 100644 --- a/Services/LayoutStore.Save.cs +++ b/Services/LayoutStore.Save.cs @@ -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(); } diff --git a/TASKS.md b/TASKS.md index 18779ac..5f3f759 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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) | diff --git a/TASKS/task-35-scene-linked-audio.md b/TASKS/task-35-scene-linked-audio.md index 9c9767c..2f5ab0b 100644 --- a/TASKS/task-35-scene-linked-audio.md +++ b/TASKS/task-35-scene-linked-audio.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. diff --git a/ViewModels/MainViewModel.Audio.cs b/ViewModels/MainViewModel.Audio.cs index f0bb824..c38796c 100644 --- a/ViewModels/MainViewModel.Audio.cs +++ b/ViewModels/MainViewModel.Audio.cs @@ -294,6 +294,38 @@ public partial class MainViewModel /// lip-sync is right", so it deserves one click, not a drag. internal void SetSyncOffset(int milliseconds) => AudioSyncOffsetMs = milliseconds; + /// 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. + internal void SetMicMuted(bool muted) + { + if (muted == MicMuted) return; + if (muted) + { + _volumeBeforeMute = MicVolume; + MicVolume = 0; + } + else + { + MicVolume = _volumeBeforeMute ?? 0.8; + BeginVolumeFlash(); + } + } + + /// TASK 35: as , for desktop/game audio. + 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) diff --git a/ViewModels/MainViewModel.Scenes.cs b/ViewModels/MainViewModel.Scenes.cs index ade76aa..06bb45e 100644 --- a/ViewModels/MainViewModel.Scenes.cs +++ b/ViewModels/MainViewModel.Scenes.cs @@ -21,6 +21,32 @@ public partial class MainViewModel // XAML bindings and MainViewModel call sites keep working unchanged. public ObservableCollection Scenes => _sceneGraph.Scenes; + /// TASK 35: the staged scene's audio state, bound by the left panel's three-state + /// checkboxes (indeterminate = leave the mix alone). + 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); } } + /// TASK 35: a scene's audio state, applied once when it takes the program output. + /// null means "leave the mix alone" — the creator's manual mute survives until a scene that + /// actually has an opinion takes over. + private void ApplySceneAudio(Scene scene) + { + if (scene.SceneMutesMic is { } muteMic) + SetMicMuted(muteMic); + + if (scene.SceneMutesDesktop is { } muteDesktop) + SetGameMuted(muteDesktop); + } + /// Raised before a scene switch so the preview can animate the old picture away. public event Action? 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); diff --git a/ytLive.Tests/SceneAudioTests.cs b/ytLive.Tests/SceneAudioTests.cs new file mode 100644 index 0000000..19d21a6 --- /dev/null +++ b/ytLive.Tests/SceneAudioTests.cs @@ -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; + +/// +/// TASK 35 — the scene remembers the room. +/// +/// The rule the test pins, in the creator's words: a scene's audio state is applied once when +/// it takes the program output. `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. +/// +[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 */ } + } + }); +}