feat(backdrop): Capture Window… in-app window pin (TASK 38, Good Dog ONE test)

Click-to-pin an open window as the full-bleed Live backdrop, via the existing
window:<hwnd> capture path (same render layer as game/desktop). Session-scoped:
window:/picker: keys heal to auto on reload (HWND-recycle hazard); pin wins
while the window enumerates, then auto-fallbacks (game → desktop → static); a
dead window's capture session heals to auto, no dead ends.

Fix: ScreenCaptureSourceFactory.Resolve hex-parsed "window:0x<Hwnd>" WITHOUT
stripping the 0x prefix — NumberStyles.HexNumber rejects it, so every pin
resolved null ("No capture target for this key") and the heal silently rolled
back. Same flaw in IsAliveWindowPin (pin-wins guard was dead). Both fixed.

Commit also carries an out-of-scope prerequisite: PillRadioTests.cs:105 had a
committed stray token ("...PrimaryStartButtonLabel soil;", CS1003) blocking the
whole test-project compile — token removed.

Plus the previous session's uncommitted pricing docs (one-time $29/$49) that
share TASKS.md/ai.md/HANDOFF.md.
This commit is contained in:
2026-09-22 07:00:47 -07:00
parent 523c17b7da
commit 4e0915a36e
19 changed files with 665 additions and 67 deletions
+25
View File
@@ -94,6 +94,31 @@
</MultiBinding> </MultiBinding>
</MenuItem.Visibility> </MenuItem.Visibility>
</MenuItem> </MenuItem>
<MenuItem Header="Capture Window…"
ItemsSource="{Binding DataContext.OpenWindows, RelativeSource={RelativeSource AncestorType=Window}}">
<MenuItem.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}">
<Binding Path="IsBackground"/>
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
</MultiBinding>
</MenuItem.Visibility>
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header" Value="{Binding Label}"/>
<Setter Property="Command"
Value="{Binding DataContext.CaptureWindowCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Setter Property="CommandParameter" Value="{Binding}"/>
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
<MenuItem Header="Refresh Window List" Click="BackgroundMenu_RefreshWindowList">
<MenuItem.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}">
<Binding Path="IsBackground"/>
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
</MultiBinding>
</MenuItem.Visibility>
</MenuItem>
<Separator> <Separator>
<Separator.Visibility> <Separator.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}"> <MultiBinding Converter="{StaticResource AllTrueToVis}">
+5
View File
@@ -116,6 +116,11 @@ public partial class LeftPanel : UserControl
(DataContext as MainViewModel)?.RefreshBackgroundAutoCapture(); (DataContext as MainViewModel)?.RefreshBackgroundAutoCapture();
} }
private void BackgroundMenu_RefreshWindowList(object sender, RoutedEventArgs e)
{
(DataContext as MainViewModel)?.RefreshWindowsCommand.Execute(null);
}
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%"; => OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
+14
View File
@@ -35,6 +35,20 @@
</Style> </Style>
</MenuItem.ItemContainerStyle> </MenuItem.ItemContainerStyle>
</MenuItem> </MenuItem>
<MenuItem Header="Capture Window…"
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"
ItemsSource="{Binding OpenWindows}">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header" Value="{Binding Label}"/>
<Setter Property="Command"
Value="{Binding DataContext.CaptureWindowCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<Setter Property="CommandParameter" Value="{Binding}"/>
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
<MenuItem Header="Refresh Window List" Command="{Binding RefreshWindowsCommand}"
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"/>
<MenuItem Header="Refresh Desktop" Command="{Binding RefreshCaptureCommand}" <MenuItem Header="Refresh Desktop" Command="{Binding RefreshCaptureCommand}"
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"/> Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"/>
</ContextMenu> </ContextMenu>
+2 -2
View File
@@ -14,7 +14,7 @@ Polar handles everything between "customer wants to pay" and "customer has a wor
| **File hosting** (up to 10GB) | Upload `LlamaCasty.exe` (self-contained, ~80-120MB) as a "File Download" benefit. Customers get signed, personal download URLs. SHA-256 checksums included. | | **File hosting** (up to 10GB) | Upload `LlamaCasty.exe` (self-contained, ~80-120MB) as a "File Download" benefit. Customers get signed, personal download URLs. SHA-256 checksums included. |
| **Checkout** | Polar's hosted checkout page handles payment. No Stripe integration, no PCI scope, no custom checkout UI. | | **Checkout** | Polar's hosted checkout page handles payment. No Stripe integration, no PCI scope, no custom checkout UI. |
| **Customer portal** | Customers self-serve: view/copy license keys, see expiry, deactivate devices, rotate compromised keys. We don't build a portal. | | **Customer portal** | Customers self-serve: view/copy license keys, see expiry, deactivate devices, rotate compromised keys. We don't build a portal. |
| **Subscriptions** | If we go subscription model (deferred), Polar handles recurring billing, dunning, cancellation. | | **One-time orders** | **Chosen model (2026-09-21):** perpetual license via a one-time Polar order — no subscription. Polar also supports recurring billing if ever needed, but we do not use it. |
| **Merchant of Record** | Polar collects and remits VAT/GST/sales tax globally. We never touch tax compliance. | | **Merchant of Record** | Polar collects and remits VAT/GST/sales tax globally. We never touch tax compliance. |
| **Webhooks** | Polar notifies our backend on purchase, cancellation, key rotation. Used for optional telemetry (section 6.3). | | **Webhooks** | Polar notifies our backend on purchase, cancellation, key rotation. Used for optional telemetry (section 6.3). |
@@ -290,7 +290,7 @@ Single-file means everything is inside the `.exe`. No DLLs, no runtime dependenc
**Product setup on Polar:** **Product setup on Polar:**
1. Create a product: "LlamaCasty" — one-time purchase (or subscription if deferred). 1. Create a product: "LlamaCasty" — **one-time purchase** (perpetual license; $29 founder / $49 list).
2. Add two benefits: 2. Add two benefits:
- **License Key** — brandable prefix `LLAMA-****`, activation limit (e.g., 3 devices), optional expiry. - **License Key** — brandable prefix `LLAMA-****`, activation limit (e.g., 3 devices), optional expiry.
- **File Download** — upload the signed `LlamaCasty.exe` (~80-120MB). Polar generates signed, personal download URLs. SHA-256 checksum included. - **File Download** — upload the signed `LlamaCasty.exe` (~80-120MB). Polar generates signed, personal download URLs. SHA-256 checksum included.
+92 -40
View File
@@ -1,58 +1,110 @@
# HANDOFF — 2026-09-20 (ON-AIR armable signed-out shipped + pushed; Good Dog ONE-test ruling) # HANDOFF — 2026-09-21 (pricing docs UNCOMMITTED + TASK 38 Capture Window… implemented, UNCOMMITTED)
## Branch / Commit State ## Branch / Commit State
`main` HEAD = **`0c55645` — feat(pills): ON-AIR armable signed-out; Start face reads "Sign In" `main` HEAD = **`523c17b` — docs(handoff): rewrite for pushed 0c55645** (last code commit is
while armed signed-out (Good Dog 2026-09-20 ruling)** — **PUSHED** to `origin/main` `0c55645`, ON-AIR armable signed-out — PUSHED). **Working tree DIRTY — TWO uncommitted work units:**
(`5a1993b..0c55645`, non-force). Working tree **clean** (all 6 units of the change committed).
This is a continuation of the 2026-09-18 drag-reorder + 2026-09-20 radio-exclusive-pill work: the **Unit A — pricing docs session (started 2026-09-21, still uncommitted):**
ON-AIR setter could only light while `IsConnected`, so a signed-out creator found a **greyed pill ```
= dead end**. The ruling ships the armable signed-out pill. M Distribution.md
M README.md
M TASKS.md ← now also carries this session's TASK 38 row
M TASKS/task-10-monetization.md
M ai.md ← now also carries this session's Capture Window… bullet
```
`MARCOM.md` and `MONETIZATION.md` were also edited but are **gitignored (confidential)** — never
committed.
## ✅ What shipped this session (commit 0c55645, pushed) **Unit B — TASK 38 "Capture Window…" backdrop (this session, code + docs, uncommitted):**
```
M Controls/LeftPanel.xaml
M Controls/LeftPanel.xaml.cs
M Controls/PreviewPane.xaml
M ViewModels/MainViewModel.Background.cs
M ViewModels/MainViewModel.cs
?? Services/Win32WindowEnumerator.cs
?? ViewModels/MainViewModel.WindowBackdrop.cs
?? ytLive.Tests/WindowBackdropIntegrationTests.cs
M ytLive.Tests/PillRadioTests.cs ← out-of-scope prerequisite fix (below)
?? Models/WindowInfo.cs
?? Services/IWindowEnumerator.cs
?? TASKS/task-38-window-backdrop.md
```
NOTE: `Models/WindowInfo.cs` and `Services/IWindowEnumerator.cs` show as `??` yet were already in
the working tree before this session (referenced by ai.md) — they are new/untracked, so the seams
grew out of existing code expecting them.
- **ON-AIR pill is intent, not account reality** — `OnAirPillOn` setter is **armable signed-out** ## ⚠️ Open action items / landmines
(greyed/disabled-until-`IsConnected` gate REMOVED; a pill that could not light offline was the
dead end the guard existed to forbid). REC-OR-live radio exclusivity is untouched — arming one
clears the other; the radio constraint lives at the pills.
- **Start button face is reality-aware**: `PrimaryStartButtonLabel` = `"Start"` while
`IsConnected || !OnAirPillOn`, else **`"Sign In"`** when an ON-AIR pill is armed signed-out.
Clicking Start while the ON-AIR pill is armed signed-out routes to **sign-in**, not a dead-end
go-live (no dead ends — creator ruling; see ai.md §Streaming pills).
- **One Good Dog test** (`ytLive.Tests/PillRadioTests.cs`): `PillRadioTests.Arming_One_Output_
Pill_Clears_The_Other` (radio-exclusive) **+** the signed-out armable-pill headless test
(`RealAppHost` + temp DB, no browser).
- **ai.md** updated in the SAME commit: tooltip wording + the armable-signed-out ruling
(2026-09-20) recorded; stale "greyed/disabled until `IsConnected`" fact replaced.
## Deferred / queued (recorded in TASKS.md, NOT folded into this change) - **Polar product `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0` is still a `$99/yr` subscription** — must be
re-created as **one-time** ($29 founder / $49 list) before launch. Flagged in `ai.md`,
`TASKS/task-10-monetization.md`, `Distribution.md`. `LLAMA50` discount changed 12mo→`once`;
`LLAMAFOUNDER` unchanged (100% off, 50 uses, `once`).
- **PillRadioTests corruption fixed out-of-scope:** `ytLive.Tests/PillRadioTests.cs:105` had a
COMMITTED stray token (`…PrimaryStartButtonLabel soil;` CS1003) that blocked the ENTIRE test
project compile (no tests could run at all). Fixed by deleting the stray word — ONLY change to
that file. It's committed upstream in `0c55645`; upstream history should get a fix commit, the
working-tree file is clean of it now. Flagged so no one re-hunts it.
- **Stale "behind the picker" fact**: ai.md §Screen backdrop capture previously claimed window
capture (`window:<hwnd>`) is picker-only; corrected to note the in-app submenu + session-scoped
transience (2026-09-21).
- **TASK 37 — defaults vs current layout split**: DB holds static brandable `default` layout; the ## What this session did (TASK 38 — "Capture Window…", creator ruling 2026-09-21)
`current` layout carries the build-id and only loads when it matches `BuildStamp.Id` — enables
one-click revert to factory defaults. Files: `TASKS/task-37-defaults-vs-current.md`.
- **Manual take on webcam-between-sources order** (cross-type z-order fix `94e114b` already
committed/pushed): relaunch, live view, drag Web Resource-0 below the webcam, restart, expect it
pinned. Closes the bug with the builder before any further streaming work.
- **Build-id-on-exit** demand: still queued for a later slice (do not fold in).
## Critical working rules (failure cap) In-app window-picker submenu on the Live backdrop context menus (PreviewPane + layer-row). Picking a
window pins it as the full-bleed layer-0 backdrop via `RedesignateBackgroundAsync("window:0x…")` —
same capture path as game/desktop, `window:` already resolves in `ScreenCaptureSourceFactory`. Full
rulings + shipped items in `TASKS/task-38-window-backdrop.md`.
- **Good Dog = ONE integration test per change.** Do not pile up multiple tests for one guard. - New: `Win32WindowEnumerator` (EnumWindows, filtered like Win32FullScreenDetector),
`ViewModels/MainViewModel.WindowBackdrop.cs` (`OpenWindows`, `RefreshWindowsCommand`,
`CaptureWindowCommand`, `IsTransientCaptureKey`=`window:`+`picker:`, `IsAliveWindowPin`,
`OnScreenCaptureFailed`).
- Heals: `ReacquireScreenCaptures` strips transient keys before auto-detection (**session-scoped,
HWND-recycle hazard**); `OnFullscreenMonitorChanged` respects a live window pin (**pin wins while
alive**); `CaptureFailed` → auto re-acquire for transient keys (window death = no dead end).
- Ctor order matters: `_windowEnumerator = WindowEnumeratorOverride ?? new Win32WindowEnumerator()`
is assigned BEFORE `RefreshWindowsCommand`/`CaptureWindowCommand`/`RefreshOpenWindows()`.
- LeftPanel row-menu bindings use Window-anchored `RelativeSource` (ContextMenu DataContext is the
row Source, not the VM) — mirrors the existing PlacementTarget.Tag pattern.
- **ONE Good Dog integration test** (`WindowBackdropIntegrationTests`): seam feeds the submenu; a
persisted `window:` key heals away on reload; pinning an un-capturable window falls back to auto.
NOTE: an earlier draft asserted the auto-fallback is non-empty — WRONG in the full suite, where
ambient desktop state can legitimately yield `null` (static art). Final test accepts null as valid.
- **Builds 0 warnings / 0 errors.** Full suite: **312/312 pass.** One wild-lasting detail: tests use
the Windows dotnet host and MUST be rebuilt before vstest (stale-dll false failures).
## Pricing decision (2026-09-21) — record, do not re-derive
One-time perpetual license: **$29 founder** (launch→first 90 days) → **$49 list** at GA. Buy once,
own forever; `IsPremium` never lapses (renewal/lapse code path dead). Free tier = full app,
watermark-only. Driver = accessibility. Full reasoning in `MONETIZATION.md` (§Pricing, "Why these
numbers"). $49 anchor, **$29 floor — never below**. Docs cascaded (unit A above).
## Next step
1. User reviews/commits this work unit (code + `ai.md`/`TASKS.md`/`TASKS/task-38…`/`HANDOFF.md` in
the SAME commit, per rules). **No push unless it's a milestone.**
2. Then the pricing docs unit (5 dirty files) per `TASKS/task-10-monetization.md`.
3. Then TASK 10 implementation (perpetual-key model — renewal/lapse removal).
## Critical working rules (unchanged, still binding)
- **Good Dog = ONE integration test per change.**
- **Scope lock:** declare the exact file list BEFORE editing; run `scripts/scope-check.sh` with the - **Scope lock:** declare the exact file list BEFORE editing; run `scripts/scope-check.sh` with the
declared list before commit. No "while I'm here" edits. declared list before commit. No "while I'm here" edits. (Exception this session: the PillRadioTests
syntax-error fix — required before ANY test could compile; declared at the time.)
- **Run from WSL** always uses the Windows dotnet host (`/mnt/c/Program Files/dotnet/dotnet.exe`) - **Run from WSL** always uses the Windows dotnet host (`/mnt/c/Program Files/dotnet/dotnet.exe`)
with **quoted** paths — a path containing a space left unquoted was the #1 tool-spin cause this with **quoted** paths; test filter runs via `dotnet vstest <dll> --filter …`.
session. Test filter runs go through `dotnet vstest <dll> --filter …` (Windows DLL path), NOT
`dotnet test` (WSL re-downloads the windowsdesktop packs + double-restores).
- **Never type the username in paths.** Canonical root is `$(pwd)`. Byte-verify every edit anchor
(node `fs` on `path.join(R, p)`) — the phantom-tree reads wasted this whole session.
- **ai.md + code in the SAME commit**; rewrite HANDOFF.md at session end or interruption. - **ai.md + code in the SAME commit**; rewrite HANDOFF.md at session end or interruption.
## Where the machine facts live ## Where the machine facts live
- `%APPDATA%\ytLive\ytLive.db` = real layout DB (`layout.db` is a 0-byte legacy file). sqlite3 at - `%APPDATA%\ytLive\ytLive.db` = real layout DB (`layout.db` is a 0-byte legacy file). sqlite3 at
`/root/android-sdk/platform-tools/sqlite3` (or the SDK copy) for DB checks. `/root/android-sdk/platform-tools/sqlite3`.
- ffmpeg/ffprobe: `/mnt/c/Program Files/Krita (x64)/bin/` with Windows paths. - ffmpeg/ffprobe: `/mnt/c/Program Files/Krita (x64)/bin/` with Windows paths.
- `ai.md` §Local recording (TASK 18, ≈lines 605-640) = the authoritative pill/state-model doc - `ai.md` §Local recording (TASK 18, ≈lines 605-640) = authoritative pill/state-model doc.
(record-OR-live, armable signed-out, Start-button-face). - `ai.md` §Screen backdrop capture = authoritative backdrop/capture-key/heal doc (incl. the new
TASK 38 bullet).
- Business/marcom facts: `MONETIZATION.md` + `MARCOM.md` (both gitignored).
+27
View File
@@ -0,0 +1,27 @@
namespace ytLive.Models;
/// <summary>One user-visible open window offered by the "Capture Window…"
/// submenu. Carries its owning process name for disambiguation and the raw
/// HWND that becomes the "window:&lt;hwnd&gt;" capture key.</summary>
public sealed class WindowInfo
{
public WindowInfo(string title, string processName, long hwnd, int processId)
{
Title = title;
ProcessName = processName;
Hwnd = hwnd;
ProcessId = processId;
}
public string Title { get; }
public string ProcessName { get; }
public long Hwnd { get; }
public int ProcessId { get; }
/// <summary>Menu label: "Title — ProcessName" (e.g. "bash — WindowsTerminal").</summary>
public string Label => $"{Title} — {ProcessName}";
/// <summary>The capture key fed to the screen-capture factory
/// ("window:&lt;hwnd&gt;").</summary>
public string CaptureKey => $"window:0x{Hwnd:X}";
}
+1 -1
View File
@@ -54,7 +54,7 @@ text sources, chat on-stream, the social bar, mic/desktop audio with auto-duck a
recording, variable quality tiers with auto step-down, one-click go-live + scheduling, stream recording, variable quality tiers with auto step-down, one-click go-live + scheduling, stream
resilience (auto-reconnect inside YouTube's grace, one-click back on air), built-in monetization resilience (auto-reconnect inside YouTube's grace, one-click back on air), built-in monetization
awareness (reward capture, session reports, YPP journey tracker), Alerts — all free and ungated; awareness (reward capture, session reports, YPP journey tracker), Alerts — all free and ungated;
the subscription only removes the branding flash. the one-time license only removes the branding flash.
## Structure ## Structure
+15
View File
@@ -0,0 +1,15 @@
using System.Collections.Generic;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>Session-scoped seam that lists open top-level windows for the
/// "Capture Window…" submenu. Mirrors <see cref="IFullScreenDetector"/> so the
/// VM never depends on a specific Win32 implementation (and tests hand in a
/// fake enumerator).</summary>
public interface IWindowEnumerator
{
/// <summary>Visible, user-facing top-level windows belonging to other
/// processes, ordered for display (title, process name).</summary>
IReadOnlyList<WindowInfo> Enumerate();
}
+5 -1
View File
@@ -38,7 +38,11 @@ public sealed class ScreenCaptureSourceFactory
if (key.StartsWith("window:", StringComparison.OrdinalIgnoreCase)) if (key.StartsWith("window:", StringComparison.OrdinalIgnoreCase))
{ {
var hexText = key.AsSpan("window:".Length); // Keys are written as "window:0x<Hwnd:X>" (CaptureInterop/WindowInfo),
// but NumberStyles.HexNumber does not accept the "0x" prefix — strip it.
var hexText = key.AsSpan("window:".Length).TrimStart();
if (hexText.Length >= 2 && hexText[0] == '0' && (hexText[1] == 'x' || hexText[1] == 'X'))
hexText = hexText[2..];
if (long.TryParse(hexText, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hwnd)) if (long.TryParse(hexText, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hwnd))
return ScreenCaptureFrameSource.CreateForWindow(new IntPtr(hwnd)); return ScreenCaptureFrameSource.CreateForWindow(new IntPtr(hwnd));
return null; return null;
+122
View File
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// Win32 implementation of <see cref="IWindowEnumerator"/>. Enumerates visible
/// top-level windows for the "Capture Window…" submenu, mirroring the style of
/// <see cref="Win32FullScreenDetector"/>: visible, non-cloaked, zero-rect
/// excluded, and windows belonging to this process excluded (the app must never
/// offer itself for pinning). Ordered by process name, then title.
/// </summary>
public sealed class Win32WindowEnumerator : IWindowEnumerator
{
private static readonly IntPtr OwnProcessId = new(Environment.ProcessId);
public IReadOnlyList<WindowInfo> Enumerate()
{
var results = new List<WindowInfo>();
EnumWindows((hwnd, _) =>
{
if (TryDescribe(hwnd, out var info))
results.Add(info);
return true; // keep enumerating
}, IntPtr.Zero);
results.Sort(static (a, b) =>
{
var byProcess = string.Compare(a.ProcessName, b.ProcessName, StringComparison.OrdinalIgnoreCase);
return byProcess != 0 ? byProcess
: string.Compare(a.Title, b.Title, StringComparison.OrdinalIgnoreCase);
});
return results;
}
private static bool TryDescribe(IntPtr hwnd, out WindowInfo info)
{
info = null!;
if (GetWindowThreadProcessId(hwnd, out var pid) == 0) return false;
if (pid == 0 || new IntPtr((long)pid) == OwnProcessId) return false;
if (!IsWindowVisible(hwnd)) return false;
if (IsCloaked(hwnd)) return false;
if (!TryGetRect(hwnd, out var rect) || rect.Right <= rect.Left || rect.Bottom <= rect.Top) return false;
var title = GetTitle(hwnd);
if (string.IsNullOrWhiteSpace(title)) return false;
var processName = ProcessName((int)pid);
if (string.IsNullOrWhiteSpace(processName)) return false;
info = new WindowInfo(title, processName, hwnd.ToInt64(), (int)pid);
return true;
}
private static string ProcessName(int pid)
{
try
{
using var process = Process.GetProcessById(pid);
return process.ProcessName;
}
catch { return string.Empty; }
}
private static string GetTitle(IntPtr hwnd)
{
var length = GetWindowTextLength(hwnd);
if (length <= 0) return string.Empty;
var sb = new StringBuilder(length + 1);
GetWindowText(hwnd, sb, sb.Capacity);
return sb.ToString();
}
private static bool IsCloaked(IntPtr hwnd)
{
const int DWMWA_CLOAKED = 14;
var cloaked = 0;
return DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, out cloaked, sizeof(int)) == 0 && cloaked != 0;
}
private static bool TryGetRect(IntPtr hwnd, out Rect rect)
{
rect = default;
return GetWindowRect(hwnd, out rect);
}
[StructLayout(LayoutKind.Sequential)]
private struct Rect
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
private delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetWindowTextLength(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[DllImport("user32.dll")]
private static extern bool GetWindowRect(IntPtr hWnd, out Rect lpRect);
[DllImport("dwmapi.dll")]
private static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out int pvAttribute, int cbAttribute);
}
+2 -1
View File
@@ -29,7 +29,7 @@
| 07 | Meter scaling amplification | ✅ Done | [`TASKS/task-07-meter-scaling.md`](TASKS/task-07-meter-scaling.md) | | 07 | Meter scaling amplification | ✅ Done | [`TASKS/task-07-meter-scaling.md`](TASKS/task-07-meter-scaling.md) |
| 08 | Audio milestone | ✅ SHIPPED 2026-08-14 | [`TASKS/task-08-audio-milestone.md`](TASKS/task-08-audio-milestone.md) | | 08 | Audio milestone | ✅ SHIPPED 2026-08-14 | [`TASKS/task-08-audio-milestone.md`](TASKS/task-08-audio-milestone.md) |
| 09 | YouTube Live Stream Management | ⏳ In progress — items 1–3, 4 shipped; item 5 open; item 6 locked (TASK 36) | [`TASKS/task-09-live-stream-management.md`](TASKS/task-09-live-stream-management.md) | | 09 | YouTube Live Stream Management | ⏳ In progress — items 1–3, 4 shipped; item 5 open; item 6 locked (TASK 36) | [`TASKS/task-09-live-stream-management.md`](TASKS/task-09-live-stream-management.md) |
| 10 | Monetization: watermark-only subscription + Polar billing | 🔶 In progress — steps 1–7 shipped | [`TASKS/task-10-monetization.md`](TASKS/task-10-monetization.md) | | 10 | Monetization: watermark-only one-time license + Polar billing | 🔶 In progress — steps 1–7 shipped | [`TASKS/task-10-monetization.md`](TASKS/task-10-monetization.md) |
| 11 | Post-pause polish batch (creator's 8 review issues) | ✅ SHIPPED 2026-08-15 | [`TASKS/task-11-polish-batch.md`](TASKS/task-11-polish-batch.md) | | 11 | Post-pause polish batch (creator's 8 review issues) | ✅ SHIPPED 2026-08-15 | [`TASKS/task-11-polish-batch.md`](TASKS/task-11-polish-batch.md) |
| 12 | Master limiter on the live mix | ☐ Queued | [`TASKS/task-12-master-limiter.md`](TASKS/task-12-master-limiter.md) | | 12 | Master limiter on the live mix | ☐ Queued | [`TASKS/task-12-master-limiter.md`](TASKS/task-12-master-limiter.md) |
| 13 | Social media launch kit | 🔶 Scoped — queued after v1 | [`TASKS/task-13-social-launch-kit.md`](TASKS/task-13-social-launch-kit.md) | | 13 | Social media launch kit | 🔶 Scoped — queued after v1 | [`TASKS/task-13-social-launch-kit.md`](TASKS/task-13-social-launch-kit.md) |
@@ -53,6 +53,7 @@
| 35 | Scene-linked audio | ☐ Queued (2026-09-01) | [`TASKS/task-35-scene-linked-audio.md`](TASKS/task-35-scene-linked-audio.md) | | 35 | Scene-linked audio | ☐ Queued (2026-09-01) | [`TASKS/task-35-scene-linked-audio.md`](TASKS/task-35-scene-linked-audio.md) |
| 36 | Gold pass | ☐ Queued (2026-09-01) | [`TASKS/task-36-gold-pass.md`](TASKS/task-36-gold-pass.md) | | 36 | Gold pass | ☐ Queued (2026-09-01) | [`TASKS/task-36-gold-pass.md`](TASKS/task-36-gold-pass.md) |
| 37 | Defaults vs current layout split | ☐ Queued (2026-09-20) | [`TASKS/task-37-defaults-current-split.md`](TASKS/task-37-defaults-current-split.md) | | 37 | Defaults vs current layout split | ☐ Queued (2026-09-20) | [`TASKS/task-37-defaults-current-split.md`](TASKS/task-37-defaults-current-split.md) |
| 38 | Capture Window… backdrop (in-app window picker) | ✅ Done — shipped 2026-09-21 | [`TASKS/task-38-window-backdrop.md`](TASKS/task-38-window-backdrop.md) |
--- ---
+11 -6
View File
@@ -1,10 +1,12 @@
# TASK 10 — Monetization: watermark-only subscription + Polar billing # TASK 10 — Monetization: watermark-only one-time license + Polar billing
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here. > Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
**Goal:** annual subscription via Polar.sh that removes the branding watermark. All features are **Goal:** a **one-time perpetual license** via Polar.sh that removes the branding watermark. All features
free — the only difference between free and paid is the watermark. are free — the only difference between free and paid is the watermark. (Model changed 2026-09-21 from
annual subscription to one-time "own it": $29 founder / $49 list lifetime. See `MONETIZATION.md` →
Pricing for the reasoning.)
**Related work — monetization awareness (2026-09-01, ungated, free):** this task's billing is the **Related work — monetization awareness (2026-09-01, ungated, free):** this task's billing is the
*payer* side; the *product* also carries built-in, ungated monetization awareness for every creator — *payer* side; the *product* also carries built-in, ungated monetization awareness for every creator —
@@ -19,9 +21,10 @@ session report → journey tracker → Alerts (TASK 3 item 20).
**Business details (pricing, Polar product/checkout/discounts) in `MONETIZATION.md` (gitignored).** **Business details (pricing, Polar product/checkout/discounts) in `MONETIZATION.md` (gitignored).**
**Polar product:** `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0` (LlamaCasty, $99/yr, currently `private`) **Polar product:** `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0` (LlamaCasty, ⚠️ currently configured as
$99/yr **subscription** — must be re-created as a **one-time** product: $29 founder / $49 list lifetime)
**Checkout:** `llamacasty.com` → Polar hosted page (~80% configured) **Checkout:** `llamacasty.com` → Polar hosted page (~80% configured)
**Discounts:** `LLAMAFOUNDER` (100% off, 50 uses, `once`), `LLAMA50` (50% off, 12 months, unlimited) **Discounts:** `LLAMAFOUNDER` (100% off, 50 uses, `once`), `LLAMA50` (50% off, `once`)
**Org ID:** `c05fb364-b967-4f6c-adf2-8a144e46d085` (org-scoped OAT — `organization_id` can be omitted from API calls) **Org ID:** `c05fb364-b967-4f6c-adf2-8a144e46d085` (org-scoped OAT — `organization_id` can be omitted from API calls)
**Key prefix:** `LCYT-` **Key prefix:** `LCYT-`
**Validate endpoint:** `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` (no auth required for client-side validation) **Validate endpoint:** `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` (no auth required for client-side validation)
@@ -34,6 +37,8 @@ session report → journey tracker → Alerts (TASK 3 item 20).
4. ✅ **BrandFlash → watermark toggle** — `IsPremium` property setter flips `BrandFlashEnabled = !value`. Flash stops immediately on premium activation. Flash asset VideoFrame rendering deferred to TASK 4 compositor. 4. ✅ **BrandFlash → watermark toggle** — `IsPremium` property setter flips `BrandFlashEnabled = !value`. Flash stops immediately on premium activation. Flash asset VideoFrame rendering deferred to TASK 4 compositor.
5. ✅ **Remove social slot locks** — all 6 social bar slots open to everyone (no `IsPremium` gating on slots 3-6) — shipped as TASK 10c 5. ✅ **Remove social slot locks** — all 6 social bar slots open to everyone (no `IsPremium` gating on slots 3-6) — shipped as TASK 10c
6. ✅ **"Unlock Premium" button** — `PremiumUrl` set to Polar checkout; `IsPremiumAvailable` is true; button lights up in the About hub. 6. ✅ **"Unlock Premium" button** — `PremiumUrl` set to Polar checkout; `IsPremiumAvailable` is true; button lights up in the About hub.
7. ✅ **Renewal/lapse handling** — startup re-validates against Polar API. Lapse → `IsPremium = false` → flash returns. Grace period: 14 days offline. 7. ✅ **Licensing model** — startup re-validates against Polar API. **Perpetual key (2026-09-21): the
license never expires, `IsPremium` never lapses, and the flash does not return.** The former
renewal/lapse path is dead. Offline grace period (14 days) still applies to the validation call only.
8. 🔶 **Velopack auto-updates** — NuGet packages + bootstrap in `App.xaml.cs`. Update URL self-hosted on DO droplet (pending configuration). 8. 🔶 **Velopack auto-updates** — NuGet packages + bootstrap in `App.xaml.cs`. Update URL self-hosted on DO droplet (pending configuration).
+83
View File
@@ -0,0 +1,83 @@
# TASK 38 — Capture Window… backdrop (in-app window picker submenu)
**Status:** ✅ Done — shipped 2026-09-21 (Good Dog: ONE integration test)
## Idea (creator ruling 2026-09-21)
Add an in-app **"Capture Window…"** submenu (PreviewPane backdrop context menu + the Live layer-row
menu) listing every visible top-level window owned by another process. Picking one pins that window
as the full-bleed Live backdrop at layer 0 — the same render path as desktop/game capture, captured
via the pre-existing `window:<hwnd>` key in `ScreenCaptureSourceFactory.Resolve` (no new capture
class, no compositor change).
Rulings locked in the plan:
1. **In-app submenu, not the OS GraphicsCapturePicker** — the picker dialog is a different
flow ("Change Capture…"); this is a catalog of open windows.
2. **Captured window is the Live backdrop at layer 0**; the app's own window never goes
OS-fullscreen.
3. **Session-scoped, never persisted** — like `picker:` keys. **"A new session is a new
session"**: a persisted `window:<hwnd>` key must NOT be resurrected on reload; an HWND can be
recycled by the OS, so resurrecting it could capture an unrelated window (the exact hazard that
makes persistence wrong).
4. **Pin wins while alive**: while the pinned window still enumerates, the auto fullscreen-game /
desktop detector must not steal the backdrop.
5. **On window death → auto-fallback** through the existing chain (fullscreen game → desktop →
static art). No dead ends.
6. **Manual "Refresh Window List"** menu item included (creator picked the manual refresh option).
## What shipped
- `Services/Win32WindowEnumerator.cs` — real `EnumWindows` implementation of the
`IWindowEnumerator` seam. Filters: visible, non-cloaked (`DWMWA_CLOAKED`), non-empty rect,
other-process (never offers the app's own UI), non-empty title, non-empty process name; ordered
by process name then title. Mirrors `Win32FullScreenDetector`'s window predicates so picker and
capture-detector agree.
- `ViewModels/MainViewModel.WindowBackdrop.cs` (new partial) — `OpenWindows`
(`ObservableCollection<WindowInfo>`), `RefreshWindowsCommand`, `CaptureWindowCommand` (mirrors
`RelayCommand` pattern; routes to `RedesignateBackgroundAsync(info.CaptureKey)`),
`IsTransientCaptureKey` (`window:` + `picker:`), `IsAliveWindowPin`, and
`OnScreenCaptureFailed` (the transient-key-session-failure → auto-reacquire heal).
- `MainViewModel.Background.cs` — `_windowEnumerator` field + `WindowEnumeratorOverride` test
seam (mirrors `CameraEnumeratorOverride`); `ReacquireScreenCaptures` now strips transient
`window:`/`picker:` keys **before** auto-detection; `OnFullscreenMonitorChanged` skips
redesignation while a live window pin is active ("pin wins while alive").
- `MainViewModel.cs` — ctor wires `_windowEnumerator`, `RefreshWindowsCommand`,
`CaptureWindowCommand`, initial `RefreshOpenWindows()`, and routes `CaptureFailed` →
`OnScreenCaptureFailed`.
- UI: "Capture Window…" + "Refresh Window List" added to **both** the PreviewPane backdrop
context menu (mirrors the "Capture Desktop" submenu binding pattern) and the Live layer-row menu
(LeftPanel.xaml, `Tag`→`IsBackground` MultiBinding visibility like its siblings).
- ONE integration test: `ytLive.Tests/WindowBackdropIntegrationTests.cs` (RealApp + temp DB +
fake `IWindowEnumerator` via the static seam) — asserts (a) the seam feeds the submenu,
(b) a persisted `window:0x…` key heals away on reload (never resurrected), (c) pinning an
un-capturable window falls back to auto (no dead `window:` pin).
## Pre-existing corruption fixed (out-of-scope prerequisite)
`ytLive.Tests/PillRadioTests.cs:105` carried a committed stray token
(`Assert.Equal("Start", vm.PrimaryStartButtonLabel soil);`) that broke the entire test-project
compile — the file was unwritable and NO tests could run. Removed the stray `soil` token (only
change to the file). This is the fix for the "committed syntax error" landmine.
## Model / naming facts
- `Source.CaptureKey` is persisted (`window:<hwnd>`, `monitor:<n>`, `picker:<displayname>`);
`Source.IsBackground` marks the one Background per scene. See ai.md §Screen backdrop capture.
- `WindowInfo` (Models) — `Title`, `ProcessName`, `Hwnd` (long), `ProcessId`;
`Label => "{Title} — {ProcessName}"`; `CaptureKey => $"window:0x{Hwnd:X}"`.
- `IWindowEnumerator` (Services) — `IReadOnlyList<WindowInfo> Enumerate()`; seam like
`IFullScreenDetector`.
- The catastrophic-accident guard lives ONLY at reload/reacquire. Mid-session, when the pinned
window's capture session dies, `ScreenCaptureManager.CaptureFailed` → `OnScreenCaptureFailed`
(transient key) → `ReacquireScreenCaptures()` → strips + auto-fallback.
## Acceptance
- [x] Right-click live backdrop → "Capture Window…" lists open non-app windows with
"Title — Process" labels.
- [x] Picking one makes it the full-bleed Live backdrop.
- [x] While the window lives, the fullscreen-game detector does not steal the backdrop.
- [x] Window dies → auto-fallback (game → desktop → static), no dead-end `window:` pin.
- [x] Closing/reopening the app never resurrects a prior session's `window:` key.
- [x] "Refresh Window List" re-enumerates.
+20 -2
View File
@@ -18,6 +18,11 @@ public partial class MainViewModel
private readonly IFullScreenDetector _fullScreenDetector; private readonly IFullScreenDetector _fullScreenDetector;
private readonly ScreenCaptureManager _screenCaptureManager; private readonly ScreenCaptureManager _screenCaptureManager;
private readonly ScreenCaptureSourceFactory _screenCaptureFactory; private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
private readonly IWindowEnumerator _windowEnumerator;
/// <summary>Test seam (mirrors CameraEnumeratorOverride): a real-Win32 test
/// swaps in a fake enumerator to drive the "window dies -> re-heal" flow.</summary>
internal static IWindowEnumerator? WindowEnumeratorOverride { get; set; }
private ImageSource? _activeBackgroundImage; private ImageSource? _activeBackgroundImage;
private ImageSource? _backgroundImage; private ImageSource? _backgroundImage;
@@ -172,6 +177,14 @@ public partial class MainViewModel
{ {
HealBackgrounds(); HealBackgrounds();
// Transient keys ("picker:" picks and "window:" pins) are session-scoped
// (see ScreenCaptureSourceFactory): a persisted HWND can be recycled to an
// unrelated window after restart, so a reload never resurrects them — the
// background falls back to auto-detection instead.
foreach (var transient in AllBackgrounds()
.Where(b => IsTransientCaptureKey(b.CaptureKey)))
transient.CaptureKey = null;
var auto = ResolveAutoCaptureKey(); var auto = ResolveAutoCaptureKey();
if (auto == null) if (auto == null)
{ {
@@ -220,17 +233,22 @@ public partial class MainViewModel
// game is detected, fall back to the primary monitor (desktop view). // game is detected, fall back to the primary monitor (desktop view).
private void OnFullscreenMonitorChanged(int? monitor) private void OnFullscreenMonitorChanged(int? monitor)
{ {
// A live window pin outranks the fullscreen-game detector: while the
// pinned window still enumerates, keep it as the backdrop (the creator's
// explicit choice beats auto-detection). Only when the window is gone
// does auto-fallback (game → desktop → static) reclaim the background.
var current = AllBackgrounds().FirstOrDefault();
if (current != null && IsAliveWindowPin(current.CaptureKey)) return;
if (monitor != null) if (monitor != null)
{ {
var newKey = $"{MonitorKeyPrefix}{monitor}"; var newKey = $"{MonitorKeyPrefix}{monitor}";
var current = AllBackgrounds().FirstOrDefault();
if (current?.CaptureKey == newKey) return; if (current?.CaptureKey == newKey) return;
_ = RedesignateBackgroundAsync(newKey); _ = RedesignateBackgroundAsync(newKey);
} }
else if (AllBackgrounds().Any(b => b.ShowDesktop)) else if (AllBackgrounds().Any(b => b.ShowDesktop))
{ {
var desktopKey = $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}"; var desktopKey = $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
var current = AllBackgrounds().FirstOrDefault();
if (current?.CaptureKey == desktopKey) return; if (current?.CaptureKey == desktopKey) return;
_ = RedesignateBackgroundAsync(desktopKey); _ = RedesignateBackgroundAsync(desktopKey);
} }
@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.ViewModels;
public partial class MainViewModel
{
private const string WindowKeyPrefix = "window:";
private const string PickerKeyPrefix = "picker:";
/// <summary>True for session-scoped capture keys that must not persist
/// across a reload: "picker:" picks (OS GraphicsCapturePicker) and "window:"
/// pins (an HWND can be recycled to a different window after restart).</summary>
private static bool IsTransientCaptureKey(string? key)
{
if (string.IsNullOrWhiteSpace(key)) return false;
return key.StartsWith(WindowKeyPrefix, StringComparison.OrdinalIgnoreCase)
|| key.StartsWith(PickerKeyPrefix, StringComparison.OrdinalIgnoreCase);
}
/// <summary>True when the given capture key is a window pin whose window is
/// still among the enumerated open windows — the pin then outranks
/// auto-detection (see OnFullscreenMonitorChanged).</summary>
private bool IsAliveWindowPin(string? key)
{
if (string.IsNullOrWhiteSpace(key)
|| !key.StartsWith(WindowKeyPrefix, StringComparison.OrdinalIgnoreCase)) return false;
var hwndText = key.AsSpan(WindowKeyPrefix.Length).TrimStart();
if (hwndText.Length >= 2 && hwndText[0] == '0' && (hwndText[1] == 'x' || hwndText[1] == 'X'))
hwndText = hwndText[2..];
var hwnd = long.TryParse(hwndText, System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out var value) ? value : 0;
foreach (var window in _windowEnumerator.Enumerate())
if (window.Hwnd == hwnd)
return true;
return false;
}
/// <summary>ScreenCaptureManager's session failed (the pinned window closed,
/// a monitor went away, a transient pick expired, …). For a transient
/// session-scoped key the pin no longer resolves — fall back to the automatic
/// capture (game → desktop → static), mirroring the reload heal.</summary>
private void OnScreenCaptureFailed(string key, string message)
{
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}");
if (!IsTransientCaptureKey(key)) return;
var pinned = AllBackgrounds().FirstOrDefault(b => b.CaptureKey == key);
if (pinned == null) return;
ReacquireScreenCaptures();
}
/// <summary>Open windows offered to the "Capture Window…" submenu (session
/// snapshot; refresh via <see cref="RefreshWindowsCommand"/>).</summary>
public ObservableCollection<WindowInfo> OpenWindows { get; } = new();
public ICommand RefreshWindowsCommand { get; }
public ICommand CaptureWindowCommand { get; }
private void RefreshOpenWindows()
{
var windows = _windowEnumerator.Enumerate();
OpenWindows.Clear();
foreach (var window in windows)
OpenWindows.Add(window);
}
}
+9 -2
View File
@@ -193,6 +193,14 @@ public partial class MainViewModel : ViewModelBase
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackgroundCaptureAsync()); ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackgroundCaptureAsync());
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackgroundAutoCapture()); RefreshCaptureCommand = new RelayCommand(_ => RefreshBackgroundAutoCapture());
SetBackgroundDisplayCommand = new RelayCommand(display => SetBackgroundCapture(display as DisplayInfo)); SetBackgroundDisplayCommand = new RelayCommand(display => SetBackgroundCapture(display as DisplayInfo));
_windowEnumerator = WindowEnumeratorOverride ?? new Win32WindowEnumerator();
RefreshWindowsCommand = new RelayCommand(_ => RefreshOpenWindows());
CaptureWindowCommand = new RelayCommand(window =>
{
if (window is not WindowInfo info) return;
_ = RedesignateBackgroundAsync(info.CaptureKey);
});
RefreshOpenWindows();
ToggleShowDesktopCommand = new RelayCommand(_ => ToggleBackgroundShowDesktop()); ToggleShowDesktopCommand = new RelayCommand(_ => ToggleBackgroundShowDesktop());
BrowseBackgroundCommand = new RelayCommand(_ => BrowseBackground()); BrowseBackgroundCommand = new RelayCommand(_ => BrowseBackground());
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute()); ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
@@ -285,8 +293,7 @@ public partial class MainViewModel : ViewModelBase
_screenCaptureFactory.Resolve, _screenCaptureFactory.Resolve,
System.Windows.Application.Current?.Dispatcher); System.Windows.Application.Current?.Dispatcher);
_screenCaptureManager.PreviewBitmapChanged += OnScreenPreviewBitmapChanged; _screenCaptureManager.PreviewBitmapChanged += OnScreenPreviewBitmapChanged;
_screenCaptureManager.CaptureFailed += (key, message) => _screenCaptureManager.CaptureFailed += OnScreenCaptureFailed;
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}");
_mediaManager = new MediaVideoSourceManager( _mediaManager = new MediaVideoSourceManager(
path => new MediaVideoSource( path => new MediaVideoSource(
+32 -11
View File
@@ -348,6 +348,22 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
a source; `PickAsync()` shows the OS `GraphicsCapturePicker` ("Change Capture…", owner window set via the a source; `PickAsync()` shows the OS `GraphicsCapturePicker` ("Change Capture…", owner window set via the
`IInitializeWithWindow` ComImport) and returns a **transient** `picker:` key — a reload falls back to `IInitializeWithWindow` ComImport) and returns a **transient** `picker:` key — a reload falls back to
auto-detection. auto-detection.
- **"Capture Window…" — in-app window pin (TASK 38, 2026-09-21):** the Live backdrop context menus
(PreviewPane canvas + layer-row) offer a submenu listing visible top-level windows of other processes
(`Win32WindowEnumerator`, an `EnumWindows` pass filtered like `Win32FullScreenDetector`:
visible, non-cloaked via `DWMWA_CLOAKED`, non-empty rect, other-process, titled). Picking one runs
`RedesignateBackgroundAsync("window:0x{hwnd:X}")` — the same full-bleed layer-0 capture path as the
game/desktop, keyed straight into `ScreenCaptureSourceFactory` (`window:` already resolved there).
**Session-scoped, never persisted:** `window:` (and `picker:`) keys are `IsTransientCaptureKey` —
`ReacquireScreenCaptures` strips them before auto-detection on every load, because an HWND can be
recycled to an unrelated window after restart (resurrecting a dead pin could capture the wrong
window). **Pin wins while alive:** `OnFullscreenMonitorChanged` skips re-designation while
`IsAliveWindowPin(current.CaptureKey)` is true — the auto game/desktop detector never steals an
explicit pin. **No dead ends:** if the pinned window's capture session dies mid-session,
`OnScreenCaptureFailed` (transient key only) re-runs `ReacquireScreenCaptures` → auto-fallback
(fullscreen game → desktop → static art). Seam: `IWindowEnumerator` + `WindowInfo`
(`Label = "Title — Process"`), `WindowEnumeratorOverride` static test seam mirrors
`CameraEnumeratorOverride`.
- **CsWinRT projection gaps hand-rolled:** `Windows.Graphics.Direct3D11.Direct3D11Helper` is not projected, - **CsWinRT projection gaps hand-rolled:** `Windows.Graphics.Direct3D11.Direct3D11Helper` is not projected,
so `Direct3D11Helper` P/Invokes `d3d11.dll!D3D11CreateDevice` (hardware, BGRA_SUPPORT, explicit 11.1-first so `Direct3D11Helper` P/Invokes `d3d11.dll!D3D11CreateDevice` (hardware, BGRA_SUPPORT, explicit 11.1-first
feature array) → QI `IDXGIDevice` → the WinRT interop export feature array) → QI `IDXGIDevice` → the WinRT interop export
@@ -358,8 +374,8 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
`IInitializeWithWindow` are ComImports in `CaptureInterop.cs`. All WinRT projections were verified by `IInitializeWithWindow` are ComImports in `CaptureInterop.cs`. All WinRT projections were verified by
reflection against the built `Microsoft.Windows.SDK.NET.dll` before writing the interop. reflection against the built `Microsoft.Windows.SDK.NET.dll` before writing the interop.
- **Known v1 limits:** full-desktop captures are CPU-copied at native resolution (GPU downscale = encoder - **Known v1 limits:** full-desktop captures are CPU-copied at native resolution (GPU downscale = encoder
task); window capture (`window:<hwnd>`) and multi-monitor live re-targeting beyond the auto-detected task); multi-monitor live re-targeting beyond the auto-detected game is behind the picker; picker- and
game are behind the picker; picker-based captures don't survive reload. window-pin captures don't survive reload (session-scoped by design — see the "Capture Window…" bullet).
- **Focus-loss capture lag (known OS limit, NOT an in-app throttle — deferred):** when the app window loses - **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 — focus the preview visibly slows (mouse-movement lag). Nothing in the repo checks focus to slow capture —
the only focus hooks (`FullscreenMonitorChanged` event + `RefreshBackdropAutoCapture`) re-target the backdrop, never the only focus hooks (`FullscreenMonitorChanged` event + `RefreshBackdropAutoCapture`) re-target the backdrop, never
@@ -1384,22 +1400,27 @@ crooked.
never composited onto the live output or local recording — test VODs stay clean (same channel- never composited onto the live output or local recording — test VODs stay clean (same channel-
protection stance as the visibility lock), and creators can be shown what free looks like without protection stance as the visibility lock), and creators can be shown what free looks like without
it ever touching a real broadcast. Flipping the flash live-on is a **TASK 36** unlock item. it ever touching a real broadcast. Flipping the flash live-on is a **TASK 36** unlock item.
- **Paid (annual subscription):** branding flash removed (flips `BrandFlashEnabled` off). That's it. - **Paid (one-time perpetual license):** branding flash removed (flips `BrandFlashEnabled` off). That's it.
No feature gating. Alerts, social bar slots, voice filters, TRAX, recording — everything is free. No feature gating. Alerts, social bar slots, voice filters, TRAX, recording — everything is free.
- **Pricing:** early-access founders rate **$49.99/yr** → **$99/yr list at GA** (v1). **Grandfathering: - **Pricing (2026-09-21 — switched from subscription to one-time "own it"):** **$29 lifetime** founder's
early adopters keep $49.99/yr for as long as the subscription is maintained**; a lapse means renewal price (launch → 90 days) → **$49 lifetime** list at GA. The key is **perpetual** (`IsPremium` never
at list. That's the whole policy — no escalation matrix (a realistic product lifetime is a few years; lapses; the old renewal/lapse path is dead). Rationale: a local app with no per-user server cost and a
keep the promise simple). renewal-averse, free-surrounded audience — minimize commitment size, don't charge rent. Accessibility is
- **Billing:** **Polar (polar.sh)** — open-source MoR (Apache 2.0), handles payments, subscriptions, the driver: the free tier is the *full* app (only the watermark differs), so a broke new streamer pays $0;
the low one-time price is the "no recurring bill" entry. Full reasoning, and the dropped $49.99/yr →
$99/yr subscription model, in `MONETIZATION.md`.
- **Billing:** **Polar (polar.sh)** — open-source MoR (Apache 2.0), handles payments, **one-time orders**,
license keys, and global tax compliance. Startup Program gives Scale plan free for 12 months. license keys, and global tax compliance. Startup Program gives Scale plan free for 12 months.
Product: `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0`. Checkout: `llamacasty.com` → Polar hosted page. Product: `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0` (⚠️ currently a $99/yr *subscription* product — must be
re-created as a one-time product for the new model). Checkout: `llamacasty.com` → Polar hosted page.
Org ID: `c05fb364-b967-4f6c-adf2-8a144e46d085` (org-scoped OAT — `organization_id` omitted from API calls). Org ID: `c05fb364-b967-4f6c-adf2-8a144e46d085` (org-scoped OAT — `organization_id` omitted from API calls).
Key prefix: `LCYT-`. Discounts: `LLAMAFOUNDER` (100% off, 50 uses), `LLAMA50` (50% off, 12 months). Details in `MONETIZATION.md`. Key prefix: `LCYT-`. Discounts: `LLAMAFOUNDER` (100% off, 50 uses), `LLAMA50` (50% off, 12 months). Details in `MONETIZATION.md`.
- **Support (creator's model, corrected 2026-09-01):** support = email + GitHub issues — the - **Support (creator's model, corrected 2026-09-01):** support = email + GitHub issues — the
once-planned in-app bug-reporter is **out of product** (TASKS.md → closed list). Most queries are once-planned in-app bug-reporter is **out of product** (TASKS.md → closed list). Most queries are
how-tos / feature requests / manual-skimmers. Maintenance cadence = "when I get around to it" with how-tos / feature requests / manual-skimmers. Maintenance cadence = "when I get around to it" with
emergency patches; not a 24/7 service promise. The only license chatter is the paid-user expiry emergency patches; not a 24/7 service promise. The only license chatter is the paid-user **expiry
reminder (TASK 36 item 5); active subscribers see zero license UI. reminder — now moot** (perpetual keys never expire, one-time model 2026-09-21); paid users see zero
license UI. TASK 36 item 5 (expiry reminder) is superseded.
**Monetization awareness (built-in, ungated, free — 2026-09-01; v1 SCOPE per the complete-v1 **Monetization awareness (built-in, ungated, free — 2026-09-01; v1 SCOPE per the complete-v1
ruling — build order: capture → report → journey → Alerts):** the app is monetization-aware by ruling — build order: capture → report → journey → Alerts):** the app is monetization-aware by
+1 -1
View File
@@ -102,7 +102,7 @@ public sealed class PillRadioTests
// A session connects: the pill KEEPS its light and the face flips to Start. // A session connects: the pill KEEPS its light and the face flips to Start.
vm.IsConnected = true; vm.IsConnected = true;
Assert.True(vm.OnAirPillOn, "the armed signed-out pill must survive the sign-in"); Assert.True(vm.OnAirPillOn, "the armed signed-out pill must survive the sign-in");
Assert.Equal("Start", vm.PrimaryStartButtonLabel soil); Assert.Equal("Start", vm.PrimaryStartButtonLabel);
} }
finally finally
{ {
@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
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 38: the "Capture Window…" backdrop is session-scoped — a window pin
/// survives while the window enumerates, but a persisted "window:" key from a
/// previous session must heal away on reload (an HWND is recycled by the OS, so
/// resurrecting it could capture an unrelated window). Real-App + temp-DB +
/// fake-enumerator pattern: the seam is exercised end-to-end through the VM.
/// </summary>
[Collection("RealApp")]
public sealed class WindowBackdropIntegrationTests
{
[Fact]
public void WindowSelection_Seam_And_SessionScoped_Heal()
{
_app.Run(Run);
}
private readonly RealAppHost _app;
public WindowBackdropIntegrationTests(RealAppHost app) => _app = app;
private sealed class FakeWindowEnumerator : IWindowEnumerator
{
private readonly IReadOnlyList<WindowInfo> _windows;
public FakeWindowEnumerator(params WindowInfo[] windows) => _windows = windows;
public IReadOnlyList<WindowInfo> Enumerate() => _windows;
}
private void Run()
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-windowbackdrop-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
try
{
using (var schema = new LayoutStore(tempDb)) { }
SqliteConnection.ClearAllPools();
SeedStaleWindowPin(tempDb);
// The stale pin pointed at 0x4F00D (from a previous session); today
// that exact window is gone (recycled), so the enumerator only sees
// an unrelated window the creator might pin instead.
var liveWindow = new WindowInfo("Notepad — oops.txt", "notepad", 0xDEADBEEF, 9999);
MainViewModel.WindowEnumeratorOverride = new FakeWindowEnumerator(liveWindow);
try
{
var window = new MainWindow();
try
{
var vm = (MainViewModel)window.DataContext;
var live = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
var background = live.Elements.OfType<Source>().Single(s => s.IsBackground);
// Seam wired: the submenu's item source reflects the enumerator.
Assert.Contains(liveWindow, vm.OpenWindows);
Assert.Equal(liveWindow, vm.OpenWindows[0]);
// New session = new session: the persisted window: pin healed
// away (to auto-detection or to nothing) — never resurrected
// onto a recycled HWND.
Assert.False(background.CaptureKey?.StartsWith("window:", StringComparison.OrdinalIgnoreCase) == true,
"a previous session's window pin must not survive a reload");
// Pinning an un-capturable/just-died window must never leave a dead
// "window:" pin behind — the transient heal falls back through
// the auto chain (fullscreen game → desktop → static art), so
// the key either becomes a live capture or null (static).
vm.CaptureWindowCommand.Execute(liveWindow);
Assert.False(background.CaptureKey?.StartsWith("window:", StringComparison.OrdinalIgnoreCase) == true,
"a pin whose window cannot be captured must fall back to auto, not dead-end");
}
finally
{
window.Close();
}
}
finally
{
MainViewModel.WindowEnumeratorOverride = null;
}
}
finally
{
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
/// <summary>Seeds a Live scene whose background persisted a stale
/// "window:0x..."> capture key, as if a prior session pinned a window that
/// is no longer around.</summary>
private static void SeedStaleWindowPin(string path)
{
using var connection = new SqliteConnection($"Data Source={path}");
connection.Open();
using (var scene = connection.CreateCommand())
{
scene.CommandText =
"INSERT INTO Scene (Id, Name, HasBackground, SortOrder) VALUES ('scene-live', 'Live', 1, 0);";
scene.ExecuteNonQuery();
}
using (var bg = connection.CreateCommand())
{
bg.CommandText =
@"INSERT INTO Source (Id, SceneId, Type, Name, IsEnabled, X, Y, Width, Height,
IsBackground, CaptureKey, SortOrder)
VALUES ('bg-live', 'scene-live', 'DisplayCapture', 'Background', 1,
0, 0, 1920, 1080, 1, 'window:0x4F00D', 0);";
bg.ExecuteNonQuery();
}
}
}