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:
@@ -94,6 +94,31 @@
|
||||
</MultiBinding>
|
||||
</MenuItem.Visibility>
|
||||
</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.Visibility>
|
||||
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||
|
||||
@@ -116,6 +116,11 @@ public partial class LeftPanel : UserControl
|
||||
(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)
|
||||
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
||||
|
||||
|
||||
@@ -35,6 +35,20 @@
|
||||
</Style>
|
||||
</MenuItem.ItemContainerStyle>
|
||||
</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}"
|
||||
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"/>
|
||||
</ContextMenu>
|
||||
|
||||
+2
-2
@@ -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. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
| **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:**
|
||||
|
||||
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:
|
||||
- **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.
|
||||
|
||||
+92
-40
@@ -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
|
||||
|
||||
`main` HEAD = **`0c55645` — feat(pills): ON-AIR armable signed-out; Start face reads "Sign In"
|
||||
while armed signed-out (Good Dog 2026-09-20 ruling)** — **PUSHED** to `origin/main`
|
||||
(`5a1993b..0c55645`, non-force). Working tree **clean** (all 6 units of the change committed).
|
||||
`main` HEAD = **`523c17b` — docs(handoff): rewrite for pushed 0c55645** (last code commit is
|
||||
`0c55645`, ON-AIR armable signed-out — PUSHED). **Working tree DIRTY — TWO uncommitted work units:**
|
||||
|
||||
This is a continuation of the 2026-09-18 drag-reorder + 2026-09-20 radio-exclusive-pill work: the
|
||||
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.
|
||||
**Unit A — pricing docs session (started 2026-09-21, still uncommitted):**
|
||||
```
|
||||
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**
|
||||
(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.
|
||||
## ⚠️ Open action items / landmines
|
||||
|
||||
## 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
|
||||
`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).
|
||||
## What this session did (TASK 38 — "Capture Window…", creator ruling 2026-09-21)
|
||||
|
||||
## 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
|
||||
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`)
|
||||
with **quoted** paths — a path containing a space left unquoted was the #1 tool-spin cause this
|
||||
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.
|
||||
with **quoted** paths; test filter runs via `dotnet vstest <dll> --filter …`.
|
||||
- **ai.md + code in the SAME commit**; rewrite HANDOFF.md at session end or interruption.
|
||||
|
||||
## Where the machine facts live
|
||||
|
||||
- `%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.
|
||||
- `ai.md` §Local recording (TASK 18, ≈lines 605-640) = the authoritative pill/state-model doc
|
||||
(record-OR-live, armable signed-out, Start-button-face).
|
||||
- `ai.md` §Local recording (TASK 18, ≈lines 605-640) = authoritative pill/state-model doc.
|
||||
- `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).
|
||||
@@ -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:<hwnd>" 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:<hwnd>").</summary>
|
||||
public string CaptureKey => $"window:0x{Hwnd:X}";
|
||||
}
|
||||
@@ -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
|
||||
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;
|
||||
the subscription only removes the branding flash.
|
||||
the one-time license only removes the branding flash.
|
||||
|
||||
## Structure
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -38,7 +38,11 @@ public sealed class ScreenCaptureSourceFactory
|
||||
|
||||
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))
|
||||
return ScreenCaptureFrameSource.CreateForWindow(new IntPtr(hwnd));
|
||||
return null;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
@@ -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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
**Goal:** annual subscription via Polar.sh that removes the branding watermark. All features are
|
||||
free — the only difference between free and paid is the watermark.
|
||||
**Goal:** a **one-time perpetual license** via Polar.sh that removes the branding watermark. All features
|
||||
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
|
||||
*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).**
|
||||
|
||||
**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)
|
||||
**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)
|
||||
**Key prefix:** `LCYT-`
|
||||
**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.
|
||||
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.
|
||||
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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -18,6 +18,11 @@ public partial class MainViewModel
|
||||
private readonly IFullScreenDetector _fullScreenDetector;
|
||||
private readonly ScreenCaptureManager _screenCaptureManager;
|
||||
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? _backgroundImage;
|
||||
@@ -172,6 +177,14 @@ public partial class MainViewModel
|
||||
{
|
||||
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();
|
||||
if (auto == null)
|
||||
{
|
||||
@@ -220,17 +233,22 @@ public partial class MainViewModel
|
||||
// game is detected, fall back to the primary monitor (desktop view).
|
||||
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)
|
||||
{
|
||||
var newKey = $"{MonitorKeyPrefix}{monitor}";
|
||||
var current = AllBackgrounds().FirstOrDefault();
|
||||
if (current?.CaptureKey == newKey) return;
|
||||
_ = RedesignateBackgroundAsync(newKey);
|
||||
}
|
||||
else if (AllBackgrounds().Any(b => b.ShowDesktop))
|
||||
{
|
||||
var desktopKey = $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
|
||||
var current = AllBackgrounds().FirstOrDefault();
|
||||
if (current?.CaptureKey == desktopKey) return;
|
||||
_ = 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);
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,14 @@ public partial class MainViewModel : ViewModelBase
|
||||
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackgroundCaptureAsync());
|
||||
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackgroundAutoCapture());
|
||||
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());
|
||||
BrowseBackgroundCommand = new RelayCommand(_ => BrowseBackground());
|
||||
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
|
||||
@@ -285,8 +293,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
_screenCaptureFactory.Resolve,
|
||||
System.Windows.Application.Current?.Dispatcher);
|
||||
_screenCaptureManager.PreviewBitmapChanged += OnScreenPreviewBitmapChanged;
|
||||
_screenCaptureManager.CaptureFailed += (key, message) =>
|
||||
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}");
|
||||
_screenCaptureManager.CaptureFailed += OnScreenCaptureFailed;
|
||||
|
||||
_mediaManager = new MediaVideoSourceManager(
|
||||
path => new MediaVideoSource(
|
||||
|
||||
@@ -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
|
||||
`IInitializeWithWindow` ComImport) and returns a **transient** `picker:` key — a reload falls back to
|
||||
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,
|
||||
so `Direct3D11Helper` P/Invokes `d3d11.dll!D3D11CreateDevice` (hardware, BGRA_SUPPORT, explicit 11.1-first
|
||||
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
|
||||
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
|
||||
task); window capture (`window:<hwnd>`) and multi-monitor live re-targeting beyond the auto-detected
|
||||
game are behind the picker; picker-based captures don't survive reload.
|
||||
task); multi-monitor live re-targeting beyond the auto-detected game is behind the picker; picker- and
|
||||
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 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
|
||||
@@ -1384,22 +1400,27 @@ crooked.
|
||||
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
|
||||
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.
|
||||
- **Pricing:** early-access founders rate **$49.99/yr** → **$99/yr list at GA** (v1). **Grandfathering:
|
||||
early adopters keep $49.99/yr for as long as the subscription is maintained**; a lapse means renewal
|
||||
at list. That's the whole policy — no escalation matrix (a realistic product lifetime is a few years;
|
||||
keep the promise simple).
|
||||
- **Billing:** **Polar (polar.sh)** — open-source MoR (Apache 2.0), handles payments, subscriptions,
|
||||
- **Pricing (2026-09-21 — switched from subscription to one-time "own it"):** **$29 lifetime** founder's
|
||||
price (launch → 90 days) → **$49 lifetime** list at GA. The key is **perpetual** (`IsPremium` never
|
||||
lapses; the old renewal/lapse path is dead). Rationale: a local app with no per-user server cost and a
|
||||
renewal-averse, free-surrounded audience — minimize commitment size, don't charge rent. Accessibility is
|
||||
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.
|
||||
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).
|
||||
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
|
||||
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
|
||||
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.
|
||||
emergency patches; not a 24/7 service promise. The only license chatter is the paid-user **expiry
|
||||
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
|
||||
ruling — build order: capture → report → journey → Alerts):** the app is monetization-aware by
|
||||
|
||||
@@ -102,7 +102,7 @@ public sealed class PillRadioTests
|
||||
// A session connects: the pill KEEPS its light and the face flips to Start.
|
||||
vm.IsConnected = true;
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user