diff --git a/HANDOFF.md b/HANDOFF.md
index 8e9b271..7782556 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -7,34 +7,39 @@
## Session state (last updated: 2026-08-13)
-- **Branch:** `main`. The audio UX follow-up (`9f9ed34`), round 2 (`18abe42`),
- and round 3 (`dc29adf`) are **committed and pushed**.
-- **This session (TASK 4 ship step 6 — health stats in the bottom bar):**
- 1. **Wiring:** `MainViewModel.OnFramePumpHealthUpdated` subscribes to
- `FramePump.HealthUpdated` and marshals onto the UI thread (the encoder's
- stderr loop raises on a background thread — same pattern as the audio
- level handlers), copying the parsed bitrate/FPS/dropped/duration into
- `CurrentHealth`, which the bottom bar's left stats group already binds
- (bitrate/FPS/dropped/duration/health-message).
- 2. **Session hygiene:** `ResetHealth(status)` zeroes dropped frames + the
- elapsed duration (and clears `LastError`) on go-live and on End so stats
- never linger from a previous stream; bitrate/FPS stay on the tier's
- targets from `ApplyStreamQuality`.
- 3. **Reality:** the bar lights up with REAL encoder values once TASK 5 fills
- `_rtmpUrlProvider` with the reusable stream's ingest URL — until then the
- pump skips the encoder (logged) and the bar shows the tier's targets.
- No new tests needed: `FramePumpTests.HealthUpdated_ForwardsEncoderHealth`
- already covers the pump→event seam; the VM handler is a thin marshal+copy.
-- **Mic status contract (creator's rule, verified — do NOT "fix"):** the status dot is
- **red until a mic resource is actually connected**. `MicStatus` starts `NotConnected`
- (red); it goes green ONLY when the mixer's `MicConnected` fires, which comes strictly
- from the mic source's `Started` event raised after `StartRecording()` succeeds. Zero
- devices at startup → stays red and the mixer is never started; capture failure → yellow.
- Green must never be raised earlier (e.g. on loopback start or on `Start()` being called).
-- **Uncommitted:** this session — `ViewModels/MainViewModel.cs` (health wiring +
- `ResetHealth`), `TASKS.md` (ship step 6 ✅), `ai.md`, `ViewModels/index.md`, `HANDOFF`.
-- **Verified:** pending (build + full test run right before commit).
+- **Branch:** `main`, in sync with `origin/main`.
+- **This session (TASK 7 — UI polish batch, gramps's 6-point review):**
+ 1. **Scenes list cleaned:** the per-row edit/trash/visibility icons and the inline
+ rename TextBox are gone. Scenes are pure selection rows; `IsHidden` stays
+ persisted and still dims a hidden row to 45%. Removed dead surface:
+ `EditSceneCommand`/`RemoveSceneCommand`/`ToggleSceneVisibilityCommand` +
+ `BeginEditScene`/`ToggleSceneVisibility`/`RemoveScene` handlers + `Scene.IsEditing`.
+ 2. **Sources list upgraded:** each row now has edit + visibility eye + trash. New
+ `EditElementCommand` (`SceneElement.IsEditing` → inline rename TextBox, Enter/Esc/
+ lost-focus commits) and `ToggleElementVisibilityCommand` (flips `SceneElement.IsVisible`);
+ the eye style now binds `IsVisible` and hidden rows dim to 45%.
+ 3. **Duplicate naming:** shared `NextSourceName(scene, baseName)` → `Image`, `Image2`,
+ `Image3`… (no space), next free number derived from actual names so deletions never
+ collide. Used by both `AddSource` and `AddReusedImage`.
+ 4. **Social bar:** `MaxWidth=200` + `CharacterEllipsis` removed from BOTH
+ `SocialBarRenderer.cs` and the preview DataTemplate — full validated handle renders.
+ 5. **Panels:** left 220 / right 300 fixed widths are deliberate — panels never re-layout
+ on resize; the preview absorbs it. No change.
+ 6. **Focus-loss capture lag:** recorded in `ai.md` as a known OS limit (DWM/WGC
+ throttling when unfocused + GPU readback contention + the `_framePending` /
+ `DispatcherPriority.Render` gates). NOT an in-app throttle; deferred by user decision.
+- **Test infra change:** the two real-WPF-App tests (round-clip + new source-naming) now
+ share `RealAppHost` — a dedicated STA thread owning the single `App` — via the `RealApp`
+ serial collection. WPF allows exactly one `Application` per AppDomain; never add a test
+ that calls `new App()` directly again — marshal onto `RealAppHost` instead.
+- **Uncommitted:** `MainWindow.xaml` + `MainWindow.xaml.cs` (rows), `ViewModels/MainViewModel.cs`
+ (commands + naming), `Models/SceneElement.cs` (+`IsEditing`), `Models/Scene.cs` (−`IsEditing`),
+ `Services/Compositor/SocialBarRenderer.cs` (no truncation), `ytLive.Tests/SourceNamingTests.cs`
+ (new), `ytLive.Tests/RealAppCollection.cs` (new), `ytLive.Tests/RoundClipInteractionTests.cs`
+ (now uses the shared host), `TASKS.md` (TASK 7 ✅), `ai.md`, `ViewModels/index.md`, `HANDOFF`.
+- **Verified:** build 0 warnings / 0 errors; **170/170 tests pass** (169 + the naming test).
- **Landmines:**
+ - Never add another test that constructs `new App()` — use `RealAppHost.Run(...)`.
- Never set a local `Canvas.SetTop` on the social bar — a local value permanently
overrides `{Binding SocialBarTop}` (the `ClearValue` lesson from 5.5).
- `AudioMixer` meter `Push` is unconditional **by design now**: `OnMicSample`/
@@ -44,7 +49,7 @@
- `MicConnected` comes from the source `Started` event, raised right after
`StartRecording()` succeeds — tests must `MarkStarted()` the fake source
before asserting connection state.
- - The mic dot is red until a resource connects (see contract above) — green
+ - The mic dot is red until a resource connects (see contract below) — green
only after `Started`, yellow on `Failed`.
- Zero mic devices at startup = red dot AND the mixer is never started, so
loopback + the game bar can't run either (no capture at all) — acceptable.
@@ -56,17 +61,22 @@
- `StopAsync` must stop the encoder (closes stdin) **before** awaiting the pump
loop — closing stdin unblocks a write stuck on pipe backpressure; the reverse
order deadlocks.
- - Tests never instantiate `MainViewModel` directly except the round-clip
- integration test (a real `MainWindow`), which never goes live — keep it that way.
+ - Tests never instantiate `MainViewModel` directly except via a real `MainWindow`
+ on the `RealAppHost` STA thread (round-clip + naming), which never go live.
- Sandbox can't reach outbound HTTPS — `HttpSocialValidator` stub-handler tests
only, never the real instance.
-- **Next step:** commit + push this ship-step-6 work (one commit). Then the remaining
- TASK 4 requirement is **ship step 7 — one-click go live + private-only enforcement**
- (honor the dialog's chosen visibility / enforce `privacyStatus = "private"`; also
- fixes the REC sign's private state — see round-3 note above). Also queued: task 21
- (logo + About hub), task 22 (voice filters). Nothing else queued — do not expand
- the task queue on your own. Optional, not queued: rewriting the healed entry's
- `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps` (user must say the word).
+- **Mic status contract (creator's rule, verified — do NOT "fix"):** the status dot is
+ **red until a mic resource is actually connected**. `MicStatus` starts `NotConnected`
+ (red); it goes green ONLY when the mixer's `MicConnected` fires, which comes strictly
+ from the mic source's `Started` event raised after `StartRecording()` succeeds. Zero
+ devices at startup → stays red and the mixer is never started; capture failure → yellow.
+- **Next step:** commit + push this TASK 7 batch (one commit). Then the remaining TASK 4
+ requirement is **ship step 7 — one-click go live + private-only enforcement** (honor the
+ dialog's chosen visibility / enforce `privacyStatus = "private"`; also fixes the REC sign's
+ private state — see round-3 note). Also queued: task 21 (logo + About hub), task 22 (voice
+ filters). Nothing else queued — do not expand the task queue on your own. Optional, not
+ queued: rewriting the healed entry's `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps`
+ (user must say the word).
- **Secret/DB/port facts live:** OAuth client id/secret in `Helpers/OAuthCredentials.cs`;
OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`);
diff --git a/MainWindow.xaml b/MainWindow.xaml
index 5d09d35..d46a109 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -49,21 +49,21 @@
-
+
@@ -193,64 +193,8 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -287,6 +231,11 @@
@@ -295,6 +244,8 @@
+
+
@@ -310,9 +261,51 @@
+ Padding="4,2" TextTrimming="CharacterEllipsis">
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -684,9 +677,7 @@
Stretch="Uniform" VerticalAlignment="Center"/>
+ Margin="8,0,0,0"/>
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index f34185a..6b19329 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -63,7 +63,7 @@ public partial class MainWindow : Window
: null;
}
- private void SceneNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
+ private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (sender is TextBox { IsVisible: true } box)
{
@@ -372,24 +372,24 @@ public partial class MainWindow : Window
return null;
}
- private void SceneNameBox_KeyDown(object sender, KeyEventArgs e)
+ private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key is Key.Enter or Key.Escape)
{
- CommitSceneEdit(sender);
+ CommitSourceEdit(sender);
e.Handled = true;
}
}
- private void SceneNameBox_LostFocus(object sender, RoutedEventArgs e)
+ private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
{
- CommitSceneEdit(sender);
+ CommitSourceEdit(sender);
}
- private static void CommitSceneEdit(object sender)
+ private static void CommitSourceEdit(object sender)
{
- if (sender is FrameworkElement { DataContext: Scene scene })
- scene.IsEditing = false;
+ if (sender is FrameworkElement { DataContext: SceneElement element })
+ element.IsEditing = false;
}
// ─── List drag-to-reorder (scenes list + sources list) ───
diff --git a/Models/Scene.cs b/Models/Scene.cs
index 8aa0828..48f18dd 100644
--- a/Models/Scene.cs
+++ b/Models/Scene.cs
@@ -9,7 +9,6 @@ public class Scene : INotifyPropertyChanged
public string Id { get; init; } = Guid.NewGuid().ToString();
private string _name = string.Empty;
- private bool _isEditing;
private bool _isHidden;
private bool _hasBackdrop;
private bool _hasSocialBar;
@@ -20,12 +19,6 @@ public class Scene : INotifyPropertyChanged
set => Set(ref _name, value);
}
- public bool IsEditing
- {
- get => _isEditing;
- set => Set(ref _isEditing, value);
- }
-
public bool IsHidden
{
get => _isHidden;
diff --git a/Models/SceneElement.cs b/Models/SceneElement.cs
index 2e0d686..88e4629 100644
--- a/Models/SceneElement.cs
+++ b/Models/SceneElement.cs
@@ -53,6 +53,9 @@ public abstract class SceneElement : INotifyPropertyChanged
private bool _isVisible = true;
public bool IsVisible { get => _isVisible; set => Set(ref _isVisible, value); }
+ private bool _isEditing;
+ public bool IsEditing { get => _isEditing; set => Set(ref _isEditing, value); }
+
private ClipShape _clipShape = ClipShape.Traditional;
public ClipShape ClipShape
{
diff --git a/Services/Compositor/SocialBarRenderer.cs b/Services/Compositor/SocialBarRenderer.cs
index fcdec72..fa01af7 100644
--- a/Services/Compositor/SocialBarRenderer.cs
+++ b/Services/Compositor/SocialBarRenderer.cs
@@ -64,8 +64,6 @@ public static class SocialBarRenderer
FontSize = 14,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(8, 0, 0, 0),
- MaxWidth = 200,
- TextTrimming = TextTrimming.CharacterEllipsis,
});
row.Children.Add(item);
}
diff --git a/TASKS.md b/TASKS.md
index 2e284a5..d5959f2 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -603,6 +603,26 @@ the validator → persisted), compositor bar overlay (top/bottom + above-flash),
---
+## TASK 7 — UI polish batch (scenes/sources rows, dedup naming, social bar)
+
+**Goal:** clean up the two side lists and the social bar per gramps's review.
+
+### Status: ✅ Done
+
+1. ✅ Scene rows are pure selection rows — the per-row edit/trash/visibility icons and the inline rename TextBox are gone (`EditSceneCommand`/`RemoveSceneCommand`/`ToggleSceneVisibilityCommand` + handlers + `Scene.IsEditing` removed; `IsHidden` stays persisted + dims hidden rows)
+2. ✅ Source rows gained the trio — edit (inline rename via new `EditElementCommand` + `SceneElement.IsEditing`), visibility eye (new `ToggleElementVisibilityCommand` flips `SceneElement.IsVisible`; the eye style now binds `IsVisible`, open/slashed + row dims to 0.45 when hidden), and the existing trash
+3. ✅ Duplicate resource names get a no-space incrementing suffix via shared `NextSourceName` (Image, Image2, Image3…) — next free number derived from the names actually in the scene, so deleting a middle resource never collides (`AddSource` + `AddReusedImage` both use it)
+4. ✅ Social bar renders the full validated handle — `MaxWidth=200` + `TextTrimming` removed from BOTH `SocialBarRenderer` and the preview template (mastodon `@gramps@…` no longer cuts off)
+5. ✅ Side panels stay fixed-width (left 220 / right 300) — deliberate: they never re-layout on resize, the preview absorbs it
+6. ✅ Focus-loss capture lag documented as a known OS limit in `ai.md` — deferred by user decision (no code change)
+
+### Design decisions
+
+1. **Next free number from names, not type counts** — the old scheme counted elements by `SourceType` (`count == 0 ? baseName : base+count+1`), which collided after deletions; the new helper scans actual names.
+2. **One WPF App per AppDomain** — the real-App tests (round-clip + naming) share `RealAppHost` (a dedicated STA thread owning the single `App`) via the `RealApp` serial collection, instead of each calling `new App()`.
+
+---
+
## Backlog (future versions)
1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization)
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 28707ce..d121bb9 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -931,13 +931,12 @@ public class MainViewModel : ViewModelBase
// Commands
public ICommand AddSceneCommand { get; }
- public ICommand EditSceneCommand { get; }
- public ICommand RemoveSceneCommand { get; }
- public ICommand ToggleSceneVisibilityCommand { get; }
public ICommand AddSourceCommand { get; }
public ICommand AddWebcamCommand { get; }
public ICommand AddImageCommand { get; }
public ICommand RemoveSourceCommand { get; }
+ public ICommand EditElementCommand { get; }
+ public ICommand ToggleElementVisibilityCommand { get; }
public ICommand ChangeWebcamCommand { get; }
public ICommand ShowWebcamCommand { get; }
public ICommand ChangeCaptureCommand { get; }
@@ -995,13 +994,12 @@ public class MainViewModel : ViewModelBase
Scenes.CollectionChanged += OnScenesChanged;
AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty));
- EditSceneCommand = new RelayCommand(scene => BeginEditScene(scene as Scene));
- RemoveSceneCommand = new RelayCommand(scene => RemoveScene(scene as Scene));
- ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene));
AddSourceCommand = new RelayCommand(parameter => AddSource(parameter));
AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToActiveSceneAsync());
AddImageCommand = new RelayCommand(_ => AddImage());
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
+ EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
+ ToggleElementVisibilityCommand = new RelayCommand(element => ToggleElementVisibility(element as SceneElement));
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
@@ -1524,26 +1522,16 @@ public class MainViewModel : ViewModelBase
ActiveScene = scene;
}
- private void BeginEditScene(Scene? scene)
+ private void BeginEditElement(SceneElement? element)
{
- if (scene == null) return;
- scene.IsEditing = true;
+ if (element == null) return;
+ element.IsEditing = true;
}
- private void ToggleSceneVisibility(Scene? scene)
+ private void ToggleElementVisibility(SceneElement? element)
{
- if (scene == null) return;
- scene.IsHidden = !scene.IsHidden;
- if (scene.IsHidden && ActiveScene == scene)
- ActiveScene = Scenes.FirstOrDefault(s => !s.IsHidden);
- }
-
- private void RemoveScene(Scene? scene)
- {
- if (scene == null) return;
- Scenes.Remove(scene);
- if (ActiveScene == scene)
- ActiveScene = Scenes.FirstOrDefault();
+ if (element == null) return;
+ element.IsVisible = !element.IsVisible;
}
private void AddSource(object? parameter)
@@ -1586,15 +1574,30 @@ public class MainViewModel : ViewModelBase
return;
}
- var count = scene.Elements.OfType().Count(s => s.Type == sourceType);
- var name = count == 0 ? baseName : $"{baseName} {count + 1}";
-
- scene.Elements.Add(new Source { Name = name, Type = sourceType });
+ scene.Elements.Add(new Source { Name = NextSourceName(scene, baseName), Type = sourceType });
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
}
+ // Duplicate resource names get an incrementing suffix with no space: Image,
+ // Image2, Image3… The next free number is derived from the names actually in
+ // the scene, so deleting a middle resource never collides with a survivor.
+ private static string NextSourceName(Scene scene, string baseName)
+ {
+ var taken = scene.Elements.OfType()
+ .Select(s => s.Name)
+ .Where(n => string.Equals(n, baseName, StringComparison.OrdinalIgnoreCase)
+ || (n.Length > baseName.Length
+ && n.StartsWith(baseName, StringComparison.OrdinalIgnoreCase)
+ && int.TryParse(n.Substring(baseName.Length), out _)))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ if (!taken.Contains(baseName)) return baseName;
+ for (var i = 2; ; i++)
+ if (!taken.Contains($"{baseName}{i}"))
+ return $"{baseName}{i}";
+ }
+
// Adds the webcam to the active scene. The creator ALWAYS picks from the
// cameras Windows has registered — never silently resurrects the previous
// camera (which is what happened after deleting one scene's webcam while
@@ -1809,10 +1812,7 @@ public class MainViewModel : ViewModelBase
var scene = ActiveScene;
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
- var count = scene.Elements.OfType().Count(s => s.Type == SourceType.Image);
- var name = count == 0 ? "Image" : $"Image {count + 1}";
-
- var source = new Source { Name = name, Type = SourceType.Image, AssetId = assetId };
+ var source = new Source { Name = NextSourceName(scene, "Image"), Type = SourceType.Image, AssetId = assetId };
var image = ImageCache.Get(assetId);
if (image != null)
diff --git a/ViewModels/index.md b/ViewModels/index.md
index 093c84a..d647d89 100644
--- a/ViewModels/index.md
+++ b/ViewModels/index.md
@@ -4,7 +4,7 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
-| `MainViewModel.cs` | The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **End Livestream signs out** (`StopStream` clears session + token — crash-safe). **Connected account:** top-bar avatar/name (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`, cleared on End). **Audio (KISS — the mic is the creator's only audio control; capture SHIPPED, runs for the app's lifetime so the meters preview live):** the sound meter is a **READ-ONLY realtime level display**: fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)` (`MeterFillWidth`/`MeterBrush`, green→yellow→red, zone markers at 60%/80% — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS scale so real speech/game levels actually occupy the bar), where `AudioLevel` (live input, 0 with no input) is fed by the audio mixer once capture lands and `MicVolume` acts as a gain on ambient noise; while the slider is dragged the bar previews the slider position (`SetVolumeAdjusting`), returning to the live level on release (0 with no input — clicking the meter does nothing); `MicVolume` (default 0.8) + read-only `MicMuted`/`MicMuteText`/`ToggleMicMuteCommand` — **MicVolume drives MicMuted** (muted ⇔ volume 0): sliding to 0 flips the speaker to muted, sliding up from 0 clears it; muting stores the prior volume, unmuting restores it (default 0.8 if unknown) and flashes the meter to the restored position ~300ms (`BeginVolumeFlash`/`EndVolumeFlash`); `MicSourceName` = picked voice source, shown left-justified inside the meter bar (the fill runs at 75% opacity so the text + ruler markings show through); `OpenMicPickerCommand`/`PickMicrophone()` open the `MicPickerDialog` (a picked device takes effect immediately — `PickMicrophone` swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running); the **MIC label is a button** (`OpenMicPickerCommand`) with a **status dot** (`MicStatus`, `Models/MicStatus`: green = `MicConnected` via the source's `Started` event, yellow = `MicFailed` — in use/unplugged, red = no mic device at startup; `MicStatusBrush`/`MicStatusToolTip`); capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live (`BeginGoLive`/`StopStream` no longer touch the mixer) — zero devices = red dot + the mixer never starts. **Game audio bar** (desktop/game, **overlaid at the bottom of the preview window** — bottom-center chip, a mirror of the mic bar): `IsGameAudioBarVisible` (shown only while a full-screen game is producing sound — the VM polls `IGameAudioDetector` via the default `GameAudioDetector` every 250ms (`_gameAudioTimer`); the pure `GameAudioHysteresis` SHOWs after ~500ms of fullscreen+sound, HIDEs ~1s after leaving fullscreen, and **silence never hides an active bar**), `GameAudioLevel` (loopback meter via `LoopbackLevelChanged`, scaled by volume), `GameMuted`/`GameMuteText`/`ToggleGameMuteCommand`, `GameAudioVolume` (0..1 volume slider), `Begin/End/CancelGameVolumeFlash` (mirrors the mic bar's volume flash); the game speaker + slider share the mic bar's `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` code-behind pattern. **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; the Windows camera picker ALWAYS opens so the creator chooses — never silently reuses the previous camera; picking a different camera swaps the app-wide identity via `SwapWebcamIdentityAsync`, same path as `ChangeWebcamAsync`; propagates the running shared bitmap via `CameraManager.GetPreviewBitmap` first, so a webcam added mid-session never renders a transparent container; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load (re-propagates the shared bitmap to every config), `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds(config, sceneName)` (per-scene size cap seam: 50%-per-dimension everywhere, half-screen-AREA in Chat via `MaxWebcamWidthFor`/`MaxWebcamHeightFor`). **Scenes (five-scene catalog):** empty DB seeds Starting/Live/BRB/Chat/Ending (`SceneCatalog.All`); `AddScene(name)` accepts only canonical, missing names; `MissingScenes`/`ShowAddScene` drive the "+" button (hidden once all five exist; its menu lists only the missing scenes via `AddSceneCommand`). **Screen backdrop (Live-only by policy):** owns `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture), `internal static EnsureBackdrop` heals one per backdrop-enabled scene (gated on `Scene.HasBackdrop`), `ReacquireScreenCaptures`/`RefreshBackdropAutoCapture`/`NoteBackgroundWindow` key by full-screen game monitor or the **primary display** via `Win32FullScreenDetector` (`GetDisplays()`/`PrimaryMonitorIndex()`), `ChangeBackdropCaptureAsync` (OS picker) + `SetBackdropCapture(DisplayInfo)` (in-app "Capture Display"), `RedesignateBackdropAsync` re-targets all backdrops and releases orphaned sessions, `internal static EnforceBackdropPolicy` normalizes `Scene.HasBackdrop` by name on every load + strips lingering non-Live backdrops, `CanChangeBackdrop` gates the capture menu to Live (the only scene with a backdrop), `BackdropImage` hides the preview watermark (`ShowPreviewPlaceholder`). **REC sign (2026-08-13):** top-center indicator always visible — `RecDotBrush` (offline `#555555`, live `#e94560`, live-private `#8f1f1f`), `RecTextBrush` (dim offline, white live), `RecDotOpacity` (0.55 offline, pulsing 1.0/0.35 live via `_recDotPulse` flipped on the live tick), `IsLivePrivate` (`IsLive && StreamVisibility == "Private"`) — notified from the `StreamStatus` + `StreamVisibility` setters; `LiveIndicatorVisible`/`LivePulseOpacity` removed. **Mic mute icon:** a second 16px clickable glyph (mic, red + slash when muted) between the meter and the speaker on the mic bar — same `ToggleMicMuteCommand`. **Health stats (TASK 4 ship step 6, 2026-08-13):** `OnFramePumpHealthUpdated` marshals `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration — raised on the stderr thread) onto the UI thread into `CurrentHealth` (bottom bar bindings); `ResetHealth(status)` zeroes dropped/duration on go-live/End so stats never linger |
+| `MainViewModel.cs` | The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **End Livestream signs out** (`StopStream` clears session + token — crash-safe). **Connected account:** top-bar avatar/name (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`, cleared on End). **Element rows:** scenes are pure selection rows (no per-row icons); source rows carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`); duplicate resource names get a no-space incrementing suffix via shared `NextSourceName` (Image, Image2, Image3… — next free number derived from actual names, so deletions never collide; used by `AddSource` + `AddReusedImage`). **Audio (KISS — the mic is the creator's only audio control; capture SHIPPED, runs for the app's lifetime so the meters preview live):** the sound meter is a **READ-ONLY realtime level display**: fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)` (`MeterFillWidth`/`MeterBrush`, green→yellow→red, zone markers at 60%/80% — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS scale so real speech/game levels actually occupy the bar), where `AudioLevel` (live input, 0 with no input) is fed by the audio mixer once capture lands and `MicVolume` acts as a gain on ambient noise; while the slider is dragged the bar previews the slider position (`SetVolumeAdjusting`), returning to the live level on release (0 with no input — clicking the meter does nothing); `MicVolume` (default 0.8) + read-only `MicMuted`/`MicMuteText`/`ToggleMicMuteCommand` — **MicVolume drives MicMuted** (muted ⇔ volume 0): sliding to 0 flips the speaker to muted, sliding up from 0 clears it; muting stores the prior volume, unmuting restores it (default 0.8 if unknown) and flashes the meter to the restored position ~300ms (`BeginVolumeFlash`/`EndVolumeFlash`); `MicSourceName` = picked voice source, shown left-justified inside the meter bar (the fill runs at 75% opacity so the text + ruler markings show through); `OpenMicPickerCommand`/`PickMicrophone()` open the `MicPickerDialog` (a picked device takes effect immediately — `PickMicrophone` swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running); the **MIC label is a button** (`OpenMicPickerCommand`) with a **status dot** (`MicStatus`, `Models/MicStatus`: green = `MicConnected` via the source's `Started` event, yellow = `MicFailed` — in use/unplugged, red = no mic device at startup; `MicStatusBrush`/`MicStatusToolTip`); capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live (`BeginGoLive`/`StopStream` no longer touch the mixer) — zero devices = red dot + the mixer never starts. **Game audio bar** (desktop/game, **overlaid at the bottom of the preview window** — bottom-center chip, a mirror of the mic bar): `IsGameAudioBarVisible` (shown only while a full-screen game is producing sound — the VM polls `IGameAudioDetector` via the default `GameAudioDetector` every 250ms (`_gameAudioTimer`); the pure `GameAudioHysteresis` SHOWs after ~500ms of fullscreen+sound, HIDEs ~1s after leaving fullscreen, and **silence never hides an active bar**), `GameAudioLevel` (loopback meter via `LoopbackLevelChanged`, scaled by volume), `GameMuted`/`GameMuteText`/`ToggleGameMuteCommand`, `GameAudioVolume` (0..1 volume slider), `Begin/End/CancelGameVolumeFlash` (mirrors the mic bar's volume flash); the game speaker + slider share the mic bar's `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` code-behind pattern. **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; the Windows camera picker ALWAYS opens so the creator chooses — never silently reuses the previous camera; picking a different camera swaps the app-wide identity via `SwapWebcamIdentityAsync`, same path as `ChangeWebcamAsync`; propagates the running shared bitmap via `CameraManager.GetPreviewBitmap` first, so a webcam added mid-session never renders a transparent container; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load (re-propagates the shared bitmap to every config), `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds(config, sceneName)` (per-scene size cap seam: 50%-per-dimension everywhere, half-screen-AREA in Chat via `MaxWebcamWidthFor`/`MaxWebcamHeightFor`). **Scenes (five-scene catalog):** empty DB seeds Starting/Live/BRB/Chat/Ending (`SceneCatalog.All`); `AddScene(name)` accepts only canonical, missing names; `MissingScenes`/`ShowAddScene` drive the "+" button (hidden once all five exist; its menu lists only the missing scenes via `AddSceneCommand`). **Screen backdrop (Live-only by policy):** owns `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture), `internal static EnsureBackdrop` heals one per backdrop-enabled scene (gated on `Scene.HasBackdrop`), `ReacquireScreenCaptures`/`RefreshBackdropAutoCapture`/`NoteBackgroundWindow` key by full-screen game monitor or the **primary display** via `Win32FullScreenDetector` (`GetDisplays()`/`PrimaryMonitorIndex()`), `ChangeBackdropCaptureAsync` (OS picker) + `SetBackdropCapture(DisplayInfo)` (in-app "Capture Display"), `RedesignateBackdropAsync` re-targets all backdrops and releases orphaned sessions, `internal static EnforceBackdropPolicy` normalizes `Scene.HasBackdrop` by name on every load + strips lingering non-Live backdrops, `CanChangeBackdrop` gates the capture menu to Live (the only scene with a backdrop), `BackdropImage` hides the preview watermark (`ShowPreviewPlaceholder`). **REC sign (2026-08-13):** top-center indicator always visible — `RecDotBrush` (offline `#555555`, live `#e94560`, live-private `#8f1f1f`), `RecTextBrush` (dim offline, white live), `RecDotOpacity` (0.55 offline, pulsing 1.0/0.35 live via `_recDotPulse` flipped on the live tick), `IsLivePrivate` (`IsLive && StreamVisibility == "Private"`) — notified from the `StreamStatus` + `StreamVisibility` setters; `LiveIndicatorVisible`/`LivePulseOpacity` removed. **Mic mute icon:** a second 16px clickable glyph (mic, red + slash when muted) between the meter and the speaker on the mic bar — same `ToggleMicMuteCommand`. **Health stats (TASK 4 ship step 6, 2026-08-13):** `OnFramePumpHealthUpdated` marshals `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration — raised on the stderr thread) onto the UI thread into `CurrentHealth` (bottom bar bindings); `ResetHealth(status)` zeroes dropped/duration on go-live/End so stats never linger |
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description/visibility, start/cancel requests |
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
| `CameraPickerViewModel.cs` | Add Webcam dialog: async camera list (loading / has / none states), `UseRequested(CameraDeviceInfo)`/`CancelRequested` |
diff --git a/ai.md b/ai.md
index 131a185..bf99a49 100644
--- a/ai.md
+++ b/ai.md
@@ -180,6 +180,14 @@ instead of a normal draggable source.
- **Known v1 limits:** full-desktop captures are CPU-copied at native resolution (GPU downscale = encoder
task); window capture (`window:`) and multi-monitor live re-targeting beyond the auto-detected
game are behind the picker; picker-based captures don't survive reload.
+- **Focus-loss capture lag (known OS limit, NOT an in-app throttle — deferred):** when the app window loses
+ focus the preview visibly slows (mouse-movement lag). Nothing in the repo checks focus to slow capture —
+ the only focus hooks (`NoteBackgroundWindow`/`RefreshBackdropAutoCapture`) re-target the backdrop, never
+ pace it. The lag is inherited: `Windows.Graphics.Capture` is content-driven and DWM/OS-throttled while the
+ app is in the background (worse under a full-screen game on 24H2/26100), GPU readback
+ (`CreateCopyFromSurfaceAsync`) contends with the foreground game, the source's one-in-flight `_framePending`
+ gate drops frames during stalls, and the manager's `DispatcherPriority.Render` copies only run as fast as
+ WPF presents the window. Recorded 2026-08-13; no mitigation attempted yet (deferred by user decision).
- **GPU posture:** same as webcam — CPU frames, WPF hardware-presents; D3DImage GPU compositing deferred
to the encoder task.
- **Preview watermark:** the "Preview" placeholder hides while a backdrop renders —
diff --git a/ytLive.Tests/RealAppCollection.cs b/ytLive.Tests/RealAppCollection.cs
new file mode 100644
index 0000000..4a60f97
--- /dev/null
+++ b/ytLive.Tests/RealAppCollection.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Threading;
+using System.Windows.Threading;
+using Xunit;
+
+namespace ytLive.Tests;
+
+///
+/// Tests that spin up the real WPF App need exactly one Application instance per
+/// AppDomain (WPF enforces it) — so they share this serial collection and a
+/// single App created on one dedicated STA thread. Marshal test bodies onto it
+/// via , never `new App()` per test.
+///
+[CollectionDefinition("RealApp", DisableParallelization = true)]
+public sealed class RealAppCollection : ICollectionFixture
+{
+}
+
+/// Owns the one-and-only WPF App on a dedicated STA thread.
+public sealed class RealAppHost : IDisposable
+{
+ private readonly Thread _thread;
+ private readonly Dispatcher _dispatcher;
+
+ public RealAppHost()
+ {
+ Exception? boot = null;
+ Dispatcher? dispatcher = null;
+ _thread = new Thread(() =>
+ {
+ try
+ {
+ var app = new App();
+ app.InitializeComponent();
+ dispatcher = Dispatcher.CurrentDispatcher;
+ Dispatcher.Run();
+ }
+ catch (Exception ex)
+ {
+ boot = ex;
+ }
+ });
+ _thread.SetApartmentState(ApartmentState.STA);
+ _thread.Start();
+ while (dispatcher == null && boot == null)
+ Thread.Sleep(5);
+ if (boot != null)
+ throw new Xunit.Sdk.XunitException("Real WPF App failed to start: " + boot);
+ _dispatcher = dispatcher!;
+ }
+
+ /// Runs on the App's STA thread and rethrows any failure.
+ public void Run(Action action)
+ {
+ Exception? failure = null;
+ _dispatcher.Invoke(() =>
+ {
+ try
+ {
+ action();
+ }
+ catch (Exception ex)
+ {
+ failure = ex;
+ }
+ });
+ if (failure != null)
+ throw failure;
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ _dispatcher.InvokeShutdown();
+ }
+ catch
+ {
+ // The App never ran its message loop — shutdown is best-effort.
+ }
+ _thread.Join(2000);
+ }
+}
diff --git a/ytLive.Tests/RoundClipInteractionTests.cs b/ytLive.Tests/RoundClipInteractionTests.cs
index 2dedcd8..b6d3001 100644
--- a/ytLive.Tests/RoundClipInteractionTests.cs
+++ b/ytLive.Tests/RoundClipInteractionTests.cs
@@ -18,36 +18,24 @@ namespace ytLive.Tests;
/// (doesn't fall through and deselect the source) and whether the round clip
/// renders as a circle rather than an oval.
///
+[Collection("RealApp")]
public sealed class RoundClipInteractionTests
{
+ private readonly RealAppHost _app;
+
+ public RoundClipInteractionTests(RealAppHost app)
+ {
+ _app = app;
+ }
+
[Fact]
public void Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle()
{
- Exception? failure = null;
- var thread = new Thread(() =>
- {
- try
- {
- Run();
- }
- catch (Exception ex)
- {
- failure = ex;
- }
- });
- thread.SetApartmentState(ApartmentState.STA);
- thread.Start();
- thread.Join();
-
- if (failure != null)
- throw new Xunit.Sdk.XunitException("Round-clip interaction failed: " + failure);
+ _app.Run(Run);
}
private void Run()
{
- var app = new App();
- app.InitializeComponent();
-
// Never let the real MainWindow read/write the user's actual layout DB —
// Shutdown() saves the layout, which would persist these test sources
// over the real ones. Point it at a throwaway temp DB instead.
diff --git a/ytLive.Tests/SourceNamingTests.cs b/ytLive.Tests/SourceNamingTests.cs
new file mode 100644
index 0000000..eea943d
--- /dev/null
+++ b/ytLive.Tests/SourceNamingTests.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Linq;
+using Microsoft.Data.Sqlite;
+using Xunit;
+using ytLive.Models;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// Duplicate source names get an incrementing suffix with no space (Image,
+/// Image2, Image3…) and the next free number is derived from the names actually
+/// in the scene — deleting a middle resource never collides with a survivor.
+/// Drives the real VM through the Add Source command (TextOverlay needs no
+/// dialog), the same real-App + temp-DB pattern as the round-clip test.
+///
+[Collection("RealApp")]
+public sealed class SourceNamingTests
+{
+ private readonly RealAppHost _app;
+
+ public SourceNamingTests(RealAppHost app)
+ {
+ _app = app;
+ }
+
+ [Fact]
+ public void Duplicate_Sources_Get_Next_Free_Numbered_Name()
+ {
+ _app.Run(Run);
+ }
+
+ private void Run()
+ {
+ var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-srcname-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = tempDb;
+ var window = new MainWindow();
+ var vm = (MainViewModel)window.DataContext;
+ try
+ {
+ var scene = vm.ActiveScene!;
+
+ vm.AddSourceCommand.Execute(SourceType.TextOverlay);
+ vm.AddSourceCommand.Execute(SourceType.TextOverlay);
+ vm.AddSourceCommand.Execute(SourceType.TextOverlay);
+
+ var names = scene.Elements.OfType().Select(s => s.Name).ToList();
+ Assert.Equal(new[] { "Text", "Text2", "Text3" }, names);
+
+ var middle = scene.Elements.OfType().Single(s => s.Name == "Text2");
+ scene.Elements.Remove(middle);
+ vm.AddSourceCommand.Execute(SourceType.TextOverlay);
+
+ var survivors = scene.Elements.OfType().Select(s => s.Name).ToList();
+ Assert.Equal(new[] { "Text", "Text3", "Text2" }, survivors);
+ }
+ finally
+ {
+ window.Close();
+ MainViewModel.LayoutPathOverride = null;
+ SqliteConnection.ClearAllPools();
+ try { System.IO.File.Delete(tempDb); } catch { /* best-effort cleanup */ }
+ }
+ }
+}