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="*"/>
</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">
+22
View File
@@ -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
+3 -1
View File
@@ -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,
});
}
}
+37
View File
@@ -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);
+12 -2
View File
@@ -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();
}
+2 -2
View File
@@ -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) |
+12 -6
View File
@@ -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.
+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);
+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 */ }
}
});
}