TASK 10: Polar billing + Velopack auto-updates (steps 1-7)
This commit is contained in:
+16
-3
@@ -1,5 +1,6 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using Velopack;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive;
|
||||
@@ -16,9 +17,6 @@ public partial class App : Application
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
// The app is a WinExe (no visible console) and is often debugged from
|
||||
// WSL where it can't even run, so startup checkpoints and any unhandled
|
||||
// exception are written to %APPDATA%\ytLlive\startup.log via AppLog.
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||
AppLog.Write(args.ExceptionObject is Exception ex
|
||||
? "AppDomain unhandled exception: " + ex
|
||||
@@ -28,6 +26,21 @@ public partial class App : Application
|
||||
AppLog.Write(args.Exception, "Dispatcher unhandled exception");
|
||||
|
||||
AppLog.Write("App OnStartup begin");
|
||||
|
||||
// Velopack auto-update bootstrap — checks for updates on startup and
|
||||
// applies them (the app restarts automatically if an update is found).
|
||||
// Update URL is configured per-deployment (self-hosted on DO droplet);
|
||||
// when no URL is set, the check is a no-op.
|
||||
try
|
||||
{
|
||||
VelopackApp.Build()
|
||||
.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Velopack bootstrap failed (non-fatal): {ex.Message}");
|
||||
}
|
||||
|
||||
base.OnStartup(e);
|
||||
AppLog.Write("App OnStartup end");
|
||||
}
|
||||
|
||||
+24
-39
@@ -1,56 +1,36 @@
|
||||
# HANDOFF — Session State
|
||||
|
||||
## Branch
|
||||
`main` — dirty (all changes uncommitted, builds clean)
|
||||
`main` — clean, uncommitted TASK 10 changes
|
||||
|
||||
## What shipped this session
|
||||
|
||||
1. **Show Desktop toggle on Live backdrop** — `Source.ShowDesktop` bool persisted in DB (schema migration + read/write in LayoutStore). When checked, the primary monitor captures regardless of fullscreen game state. Toggling off falls back to the running game or releases to the static placeholder. `ClearBackdropCaptureAsync` clears `CaptureKey` + `VideoImageSource` so the static fallback actually renders.
|
||||
1. **TASK 10 — Polar billing + Velopack auto-updates (steps 1–7)** — full license key activation flow:
|
||||
- **Velopack** — NuGet `Velopack` 1.2.0 added, bootstrap in `App.xaml.cs` (`VelopackApp.Build().Run()` in try/catch). Self-hosted update URL TBD (DO droplet). The bootstrap is a no-op until the update URL is configured.
|
||||
- **PolarLicenseService** — `Services/PolarLicenseService.cs`: `POST .../validate` with `key` + `organization_id`. Returns `status` (`granted`/`revoked`/`expired`) + `expires_at`. No auth. Network errors surfaced gracefully.
|
||||
- **License key entry UI** — About overlay gains a third panel (same pattern as Licenses): text input + "Activate" button + status message. "Enter License Key" button added to the hub (alongside "Unlock Premium" which opens the Polar checkout URL). `IsHubVisible` computed property hides the hub when either sub-panel is open.
|
||||
- **Entitlement persistence** — `LayoutStore` gains `LoadLicenseKey`/`SaveLicenseKey`, `LoadLicenseValidatedAt`/`SaveLicenseValidatedAt`, `LoadIsPremium`/`SaveIsPremium` via the Settings key/value table (no schema version bump needed). 14-day offline grace period: cached premium state trusted while online re-validation runs in the background.
|
||||
- **BrandFlash toggle** — `IsPremium` property setter flips `BrandFlashEnabled = !value`. On startup, `ValidateLicenseAtStartupAsync` loads cached state → attempts online re-validation → overrides cache on success → lapses on failure/expiry. Premium removes the watermark; free tier keeps it.
|
||||
- **PremiumUrl wired** — set to `https://buy.polar.sh/polar_cl_ueMy8AyAClO0o0yo19IroZwKTbD587gzUsjEW3bFapw`. `IsPremiumAvailable` now true. About overlay "Unlock Premium" button lights up. Tooltip updated.
|
||||
- **About text updated** — "one-time unlock" → "subscription".
|
||||
|
||||
2. **Top bar — status light + avatar + Log In button** — red/green/pulsing ellipse (disconnected/connected/live). Avatar border visible only when connected, loads via `BitmapImage` in code-behind (not XAML binding). "Log In" button visible when disconnected, calls `StartStreamCommand` which opens GoLiveWindow with the sign-in flow. `ShowStartStream` now requires `IsConnected` too.
|
||||
|
||||
3. **Chat preview — one-at-a-time mock messages** — `RunMockChatPreviewAsync`: simple async loop, displays `MockChatMessages[n]` via `UpdateChatBoxPreview(chatBox, Take(n+1))`, 1000ms between messages, wraps at end. `ChatPreviewEnabled` property on Source (default true). Stops when real messages arrive, restarts on fade timer clear.
|
||||
|
||||
4. **Default backdrop seeds for all scenes** — `SeedEndingBackdrop` replaces existing (not skip-if-exists). `SeedChatBackdrop` added. `EnsureDefaultBackdrop` called on every scene switch — if a scene has no background, seeds the default from `Assets/`. Removed Chat skip from EnsureDefaultBackdrop.
|
||||
|
||||
5. **New backdrop assets** — `ending-backdrop.jpg` and `chat-backdrop.jpg` added to Assets + csproj as `<Resource>`.
|
||||
|
||||
6. **SceneCatalog fix** — Chat scene display name corrected to "Chat" (was "YouTube Chat" — that name belongs to the layer/SourceType.ChatBox, not the scene).
|
||||
|
||||
7. **Left panel spacing + context menu reorder** — HR margins matched, Row 4 changed from `*` to `Auto`, context menu items reordered (Border Thickness first).
|
||||
|
||||
8. **Border animation rendering** — code-behind Storyboard approach via `_activeBorderAnimations` dictionary, `FindBorderShape`, `CreateBorderAnimation` for all 9 types.
|
||||
2. **Polar MCP auth** — authenticated via `opencode mcp auth Polar`. Polar MCP connected.
|
||||
|
||||
## Files changed
|
||||
- `ViewModels/MainViewModel.cs` — `ShowDesktop`, `ClearBackdropCaptureAsync`, `BackdropShowDesktop`, top bar avatar/login/status light, `RunMockChatPreviewAsync`, `StartMockChatPreview`/`StopMockChatPreview`, `SeedEndingBackdrop`/`SeedChatBackdrop`/`EnsureDefaultBackdrop`, `ShowStartStream` requires `IsConnected`
|
||||
- `Models/Source.cs` — `ShowDesktop`, `ChatPreviewEnabled`
|
||||
- `Models/SceneCatalog.cs` — removed Chat → "YouTube Chat" display name override
|
||||
- `Services/LayoutStore.cs` — ShowDesktop column migration + read/write
|
||||
- `MainWindow.xaml` — top bar status light + avatar + Log In button, Show Desktop context menu toggle
|
||||
- `MainWindow.xaml.cs` — `LoadAvatarImage`, `OnViewModelPropertyChanged` handles avatar
|
||||
- `Assets/ending-backdrop.jpg` — new Ending scene default backdrop
|
||||
- `Assets/chat-backdrop.jpg` — new Chat scene default backdrop
|
||||
- `ytLive.csproj` — new asset resources
|
||||
- `Themes/Controls.xaml` — slider fix (Background="{x:Null}")
|
||||
- `App.xaml.cs` — Velopack bootstrap (try/catch, non-fatal)
|
||||
- `ytLive.csproj` — added `Velopack` 1.2.0 NuGet
|
||||
- `Services/PolarLicenseService.cs` — new: Polar license validation API client
|
||||
- `Services/LayoutStore.cs` — new: `Load/SaveLicenseKey`, `Load/SaveLicenseValidatedAt`, `Load/SaveIsPremium`
|
||||
- `ViewModels/MainViewModel.cs` — `IsPremium` (was `static false`, now instance + DB-backed), `PremiumUrl` set, `BrandFlashEnabled` wired to `!IsPremium`, license fields/commands/validation methods, `IsHubVisible`, startup validation
|
||||
- `MainWindow.xaml` — license entry panel, "Enter License Key" button, hub visibility binding, About text
|
||||
- `MainWindow.xaml.cs` — `BackToAbout_Click` closes both sub-panels
|
||||
|
||||
## Build
|
||||
```bash
|
||||
taskkill.exe /F /IM ytLive.exe # if running
|
||||
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj" --no-restore
|
||||
```
|
||||
|
||||
## Known issues
|
||||
- YouTube session expired at 11:45 — need to re-sign-in via the new "Log In" button
|
||||
- Build often fails when ytLive.exe is running — `taskkill.exe /F /IM ytLive.exe` first
|
||||
- `Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` integration test is flaky in headless CI
|
||||
|
||||
## Next steps
|
||||
1. **TASK 10 + Velopack** — license validation + auto-updates (1 full session)
|
||||
2. **TASK 20** — hotkeys (global keyboard shortcuts)
|
||||
3. **TASK 19** — scene transitions (Cut/Fade/Move/Custom)
|
||||
4. **TASK 21** — media source (video file playback)
|
||||
5. **TASK 22** — audio sync offset
|
||||
6. **TASK 23** — studio mode
|
||||
**0 warnings, 0 errors.**
|
||||
|
||||
## Polar.sh resources
|
||||
| Resource | ID |
|
||||
@@ -63,9 +43,14 @@ taskkill.exe /F /IM ytLive.exe # if running
|
||||
| LLAMAFOUNDER | `01a8c29f-9bb8-4e96-9cbe-417a6a5cec9b` |
|
||||
| LLAMA50 | `8cff803e-053f-49b4-8d12-d74d468f2943` |
|
||||
| Checkout URL | `https://buy.polar.sh/polar_cl_ueMy8AyAClO0o0yo19IroZwKTbD587gzUsjEW3bFapw` |
|
||||
| Customer-portal validate | `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` |
|
||||
| Validate endpoint | `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` |
|
||||
| Key prefix | `LCYT-` |
|
||||
|
||||
## Next session
|
||||
1. **TASK 20** — Hotkeys (global keyboard shortcuts)
|
||||
2. **TASK 19** — Scene transitions (Cut/Fade/Move/Custom)
|
||||
3. **Velopack update URL** — self-host on DO droplet (`llamacasty.com/updates`)
|
||||
|
||||
## v1 vision
|
||||
> A streaming tool that's intuitive and ready to go out-the-gate with nothing held back.
|
||||
> Minimal configuration — they're ready to go live or record. Eliminate the OBS pain-point completely.
|
||||
|
||||
+26
-3
@@ -1456,7 +1456,7 @@
|
||||
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
|
||||
|
||||
<!-- Hub view -->
|
||||
<StackPanel Visibility="{Binding IsLicensesOpen, Converter={StaticResource InverseBoolToVis}}">
|
||||
<StackPanel Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
|
||||
<Image Source="/ytLive;component/Assets/llamacasty-logo.jpg" Height="100" Stretch="Uniform"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,12"/>
|
||||
<TextBlock Text="ytLlive" FontSize="22" FontWeight="Bold" Foreground="#e94560"
|
||||
@@ -1466,7 +1466,7 @@
|
||||
<TextBlock Text="Live streaming for YouTube — simple enough that even the most right-brained person can intuit it."
|
||||
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="380"
|
||||
TextAlignment="Center" Margin="0,16,0,0"/>
|
||||
<TextBlock Text="Free forever — live streams carry a brief "made with ytLlive!" flash. A one-time unlock removes it and adds alerts."
|
||||
<TextBlock Text="Free forever — live streams carry a brief "made with ytLlive!" flash. A subscription removes it and adds alerts."
|
||||
Foreground="#888" FontSize="12" TextWrapping="Wrap" MaxWidth="380"
|
||||
TextAlignment="Center" Margin="0,12,0,0"/>
|
||||
<TextBlock Text="Made by gramps · llama chile shop" Foreground="#a0a0b0" FontSize="12"
|
||||
@@ -1485,7 +1485,10 @@
|
||||
<Button Content="Unlock Premium" Command="{Binding OpenPremiumCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}"
|
||||
IsEnabled="{Binding IsPremiumAvailable}"
|
||||
ToolTip="Coming soon — subscription billing is on the way (TASK 23)"/>
|
||||
ToolTip="Open the LlamaCasty subscription page"/>
|
||||
<Button Content="Enter License Key" Command="{Binding OpenLicenseEntryCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}"
|
||||
ToolTip="Activate a license key to remove the watermark"/>
|
||||
</StackPanel>
|
||||
|
||||
<Button Content="Licenses & legal" Command="{Binding OpenLicensesCommand}"
|
||||
@@ -1508,6 +1511,26 @@
|
||||
HorizontalAlignment="Left" Margin="0,12,0,0"
|
||||
Click="BackToAbout_Click"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- License key entry panel -->
|
||||
<StackPanel Visibility="{Binding IsLicenseEntryOpen, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Activate LlamaCasty Premium" FontSize="15" FontWeight="Bold"
|
||||
Foreground="#e0e0e0" Margin="0,0,0,8"/>
|
||||
<TextBlock Text="Enter your license key to remove the branding watermark."
|
||||
Foreground="#a0a0b0" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,12"/>
|
||||
<TextBlock Text="License Key" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
||||
Text="{Binding LicenseKey, UpdateSourceTrigger=PropertyChanged}"
|
||||
MaxLength="100"/>
|
||||
<Button Content="Activate" Command="{Binding ActivateLicenseCommand}"
|
||||
Style="{StaticResource YtButton}" HorizontalAlignment="Right"
|
||||
Margin="0,0,0,8"/>
|
||||
<TextBlock Text="{Binding LicenseMessage}" Foreground="#a0a0b0" FontSize="12"
|
||||
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
|
||||
HorizontalAlignment="Left" Margin="0,8,0,0"
|
||||
Click="BackToAbout_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -218,7 +218,10 @@ public partial class MainWindow : Window
|
||||
private void BackToAbout_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ViewModels.MainViewModel vm)
|
||||
{
|
||||
vm.IsLicensesOpen = false;
|
||||
vm.IsLicenseEntryOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSourceButton_Click(object sender, RoutedEventArgs e)
|
||||
|
||||
@@ -1027,6 +1027,76 @@ public class LayoutStore : IDisposable
|
||||
return (string)cmd.ExecuteScalar()!;
|
||||
}
|
||||
|
||||
// ─── License (TASK 10 — Polar billing) ───
|
||||
|
||||
/// <summary>The saved license key, or null if never entered.</summary>
|
||||
public string? LoadLicenseKey()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'LicenseKey';";
|
||||
var value = cmd.ExecuteScalar() as string;
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
public void SaveLicenseKey(string? key)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'LicenseKey';";
|
||||
cmd.ExecuteNonQuery();
|
||||
if (string.IsNullOrWhiteSpace(key)) return;
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('LicenseKey', $value);";
|
||||
insert.Parameters.AddWithValue("$value", key);
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>When the license was last validated online (ISO 8601), or null if never.</summary>
|
||||
public DateTime? LoadLicenseValidatedAt()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'LicenseValidatedAt';";
|
||||
var value = cmd.ExecuteScalar() as string;
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
return DateTime.TryParse(value, out var dt) ? dt : null;
|
||||
}
|
||||
|
||||
public void SaveLicenseValidatedAt(DateTime? dt)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'LicenseValidatedAt';";
|
||||
cmd.ExecuteNonQuery();
|
||||
if (dt == null) return;
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('LicenseValidatedAt', $value);";
|
||||
insert.Parameters.AddWithValue("$value", dt.Value.ToString("o"));
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>Cached premium state (1 = premium, 0 = free). Used for offline
|
||||
/// grace period — the app checks online at startup but uses this cached
|
||||
/// value when offline.</summary>
|
||||
public bool LoadIsPremium()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'IsPremium';";
|
||||
var value = cmd.ExecuteScalar() as string;
|
||||
return value == "1";
|
||||
}
|
||||
|
||||
public void SaveIsPremium(bool isPremium)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'IsPremium';";
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('IsPremium', $value);";
|
||||
insert.Parameters.AddWithValue("$value", isPremium ? "1" : "0");
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Validates LlamaCasty license keys against the Polar.sh customer-portal API.
|
||||
/// No auth required for client-side validation — the endpoint is public by design.
|
||||
/// </summary>
|
||||
public class PolarLicenseService
|
||||
{
|
||||
private const string ValidateUrl = "https://api.polar.sh/v1/customer-portal/license-keys/validate";
|
||||
public const string OrganizationId = "c05fb364-b967-4f6c-adf2-8a144e46d085";
|
||||
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public PolarLicenseService(HttpClient? http = null)
|
||||
{
|
||||
_http = http ?? new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
|
||||
}
|
||||
|
||||
public async Task<LicenseValidationResult> ValidateAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
return new LicenseValidationResult { IsValid = false, Error = "Key is empty" };
|
||||
|
||||
try
|
||||
{
|
||||
var request = new { key = key.Trim(), organization_id = OrganizationId };
|
||||
var response = await _http.PostAsJsonAsync(ValidateUrl, request, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Error = $"Validation failed ({(int)response.StatusCode}): {body}"
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<PolarValidateResponse>(ct);
|
||||
if (result == null)
|
||||
return new LicenseValidationResult { IsValid = false, Error = "Empty response from Polar" };
|
||||
|
||||
var status = result.Status?.ToLowerInvariant();
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = status == "granted",
|
||||
Status = status ?? "unknown",
|
||||
ExpiresAt = result.ExpiresAt,
|
||||
Error = status == "granted" ? null : $"License status: {status}"
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return new LicenseValidationResult { IsValid = false, Error = "Validation timed out" };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return new LicenseValidationResult { IsValid = false, Error = $"Network error: {ex.Message}" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new LicenseValidationResult { IsValid = false, Error = $"Unexpected error: {ex.Message}" };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PolarValidateResponse
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; set; }
|
||||
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LicenseValidationResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
@@ -714,16 +714,16 @@ free — the only difference between free and paid is the watermark.
|
||||
**Key prefix:** `LCYT-`
|
||||
**Validate endpoint:** `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` (no auth required for client-side validation)
|
||||
|
||||
### Status: 🔶 Ready to execute — Polar infra live, org ID obtained, in-app plumbing not built
|
||||
### Status: 🔶 In progress — steps 1–7 shipped, Velopack update URL pending
|
||||
|
||||
1. ✅ **Billing provider** — Polar (polar.sh) chosen and configured. Product, price, license key benefit, discounts, checkout link all created.
|
||||
2. ☐ **License key entry + verification UI** — About overlay or dedicated dialog; key entry field + validate against Polar API
|
||||
3. ☐ **Offline entitlement** — cached license state in `LayoutStore` (schema bump); online check at startup, offline grace period
|
||||
4. ☐ **BrandFlash → watermark toggle** — `BrandFlashEnabled = false` when licensed; the flash asset image needs to be created (the XAML `TextBlock` is preview-only, the compositor needs a `VideoFrame`)
|
||||
2. ✅ **License key entry + verification UI** — About overlay swap panel (same pattern as Licenses panel). Text input + "Activate" button + status message. "Enter License Key" button in the hub.
|
||||
3. ✅ **Offline entitlement** — `LayoutStore` Settings table: `LicenseKey`, `LicenseValidatedAt`, `IsPremium`. 14-day offline grace period. Startup re-validates online, falls back to cache.
|
||||
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 `llamacasty.com` checkout; `IsPremiumAvailable` becomes true
|
||||
7. ☐ **Renewal/lapse handling** — key expiry check, grace period, re-prompt
|
||||
8. ☐ **Velopack auto-updates** — NuGet packages + UpdateManager shim + GitHub Releases (`gramps/ytLlive`)
|
||||
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.
|
||||
8. 🔶 **Velopack auto-updates** — NuGet packages + bootstrap in `App.xaml.cs`. Update URL self-hosted on DO droplet (pending configuration).
|
||||
|
||||
---
|
||||
|
||||
@@ -954,7 +954,7 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev
|
||||
|
||||
1. ✅ **TASK 9.4** — Live chat (right panel) — wire `YouTubeChatService.Start()`, parse messages, render in panel. Foundation for chat box source.
|
||||
2. ✅ **TASK 3.18** — Chat box source — renders chat ON the stream. Depends on TASK 9.4 (same message parsing).
|
||||
3. **TASK 10** — Polar billing — in-app license key entry + watermark toggle + Velopack auto-updates.
|
||||
3. ✅ **TASK 10** — Polar billing — license key entry + watermark toggle + Velopack auto-updates (steps 1–7 shipped; Velopack update URL pending).
|
||||
4. **TASK 20** — Hotkeys — global keyboard shortcuts. Small scope, huge UX impact.
|
||||
5. **TASK 19** — Scene transitions — Cut/Fade/Move/Custom. Depends on compositor.
|
||||
6. **TASK 17** — Web source (WebView2) — enables alert ecosystem.
|
||||
|
||||
+163
-6
@@ -152,14 +152,19 @@ public class MainViewModel : ViewModelBase
|
||||
private bool _brandFlashEnabled = true;
|
||||
private bool _brandFlashActive;
|
||||
|
||||
// TASK 10 — Polar license validation
|
||||
private string _licenseKey = string.Empty;
|
||||
private string _licenseMessage = string.Empty;
|
||||
private bool _isLicenseValidating;
|
||||
private bool _isLicenseEntryOpen;
|
||||
private static readonly TimeSpan OfflineGracePeriod = TimeSpan.FromDays(14);
|
||||
|
||||
private const string SupportEmail = "gramps@llamachile.shop";
|
||||
// The creator-hub link targets (About overlay) — public so the hub's
|
||||
// integration test can assert they point at real destinations.
|
||||
public const string ChannelUrl = "https://youtube.com/@llamachileshop";
|
||||
public const string MastodonUrl = "https://mastodon.llamachile.tube/@gramps";
|
||||
// Tabled 2026-08-14: billing provider chosen (Polar — polar.sh);
|
||||
// the subscription page (premium unlock) lands here once set up. Coffee/thanks URL is live.
|
||||
public const string PremiumUrl = "";
|
||||
public const string PremiumUrl = "https://buy.polar.sh/polar_cl_ueMy8AyAClO0o0yo19IroZwKTbD587gzUsjEW3bFapw";
|
||||
public const string CoffeeUrl = "https://buymeacoffee.com/llamachiley";
|
||||
public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}";
|
||||
|
||||
@@ -270,8 +275,19 @@ public class MainViewModel : ViewModelBase
|
||||
/// <summary>The global socials config (entries + bar settings), or null.</summary>
|
||||
public SocialsConfig? Socials => _socials;
|
||||
|
||||
/// <summary>Freemium: 6 slots — YT + 1 other, the rest locked. Premium seam: all 6 open.</summary>
|
||||
private static bool IsPremium => false; // unlock deferred (TASK 10) — seam only
|
||||
/// <summary>True when the user holds a valid LlamaCasty license (Polar). When
|
||||
/// false, the branding flash runs on every live stream.</summary>
|
||||
private bool _isPremium;
|
||||
public bool IsPremium
|
||||
{
|
||||
get => _isPremium;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isPremium, value)) return;
|
||||
// Flip the branding flash when premium state changes.
|
||||
BrandFlashEnabled = !value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Canvas.Top for the social bar: 0 = top, 1040 = bottom (40px from the 1080 edge).</summary>
|
||||
public double SocialBarTop => _socials?.BarPosition == SocialBarPosition.Top ? 0 : 1040;
|
||||
@@ -942,7 +958,11 @@ public class MainViewModel : ViewModelBase
|
||||
public bool IsLicensesOpen
|
||||
{
|
||||
get => _isLicensesOpen;
|
||||
set => SetProperty(ref _isLicensesOpen, value);
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isLicensesOpen, value))
|
||||
OnPropertyChanged(nameof(IsHubVisible));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsAnyOverlayOpen => IsSettingsOpen || IsBugOpen || IsFeatureOpen || IsAboutOpen;
|
||||
@@ -1124,6 +1144,39 @@ public class MainViewModel : ViewModelBase
|
||||
set => SetProperty(ref _featureRequestEmail, value);
|
||||
}
|
||||
|
||||
// TASK 10 — license key entry
|
||||
public string LicenseKey
|
||||
{
|
||||
get => _licenseKey;
|
||||
set => SetProperty(ref _licenseKey, value);
|
||||
}
|
||||
|
||||
public string LicenseMessage
|
||||
{
|
||||
get => _licenseMessage;
|
||||
private set => SetProperty(ref _licenseMessage, value);
|
||||
}
|
||||
|
||||
public bool IsLicenseValidating
|
||||
{
|
||||
get => _isLicenseValidating;
|
||||
private set => SetProperty(ref _isLicenseValidating, value);
|
||||
}
|
||||
|
||||
public bool IsLicenseEntryOpen
|
||||
{
|
||||
get => _isLicenseEntryOpen;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isLicenseEntryOpen, value))
|
||||
OnPropertyChanged(nameof(IsHubVisible));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The About hub is visible only when neither the licenses panel
|
||||
/// nor the license entry panel is open.</summary>
|
||||
public bool IsHubVisible => !IsLicensesOpen && !IsLicenseEntryOpen;
|
||||
|
||||
// Commands
|
||||
public ICommand AddSceneCommand { get; }
|
||||
public ICommand AddSourceCommand { get; }
|
||||
@@ -1159,6 +1212,8 @@ public class MainViewModel : ViewModelBase
|
||||
public ICommand SaveLayoutCommand { get; }
|
||||
public ICommand SaveLayoutAsCommand { get; }
|
||||
public ICommand OpenLayoutCommand { get; }
|
||||
public ICommand ActivateLicenseCommand { get; }
|
||||
public ICommand OpenLicenseEntryCommand { get; }
|
||||
|
||||
public MainViewModel()
|
||||
{
|
||||
@@ -1229,6 +1284,8 @@ public class MainViewModel : ViewModelBase
|
||||
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
|
||||
SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs());
|
||||
OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile());
|
||||
ActivateLicenseCommand = new RelayCommand(_ => _ = ActivateLicenseAsync(), _ => !IsLicenseValidating);
|
||||
OpenLicenseEntryCommand = new RelayCommand(_ => IsLicenseEntryOpen = !IsLicenseEntryOpen);
|
||||
|
||||
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
|
||||
_activeLayoutPath = _layoutStore.ActivePath;
|
||||
@@ -1304,6 +1361,7 @@ public class MainViewModel : ViewModelBase
|
||||
_reusableStream = _layoutStore.LoadReusableStream();
|
||||
_reusableStreamUrl = _reusableStream?.RtmpUrl;
|
||||
_ = LoadSavedSessionAsync();
|
||||
_ = ValidateLicenseAtStartupAsync();
|
||||
AppLog.Write("MainViewModel ctor end");
|
||||
}
|
||||
|
||||
@@ -1345,6 +1403,105 @@ public class MainViewModel : ViewModelBase
|
||||
AccountDisplayName = channel?.DisplayName ?? string.Empty;
|
||||
}
|
||||
|
||||
// ─── TASK 10 — Polar license validation ───
|
||||
|
||||
/// <summary>At startup, load the cached license state and re-validate online
|
||||
/// if a key exists. If offline and within the 14-day grace period, the cached
|
||||
/// premium state is trusted. If the grace period is exceeded, premium lapses.</summary>
|
||||
private async Task ValidateLicenseAtStartupAsync()
|
||||
{
|
||||
var savedKey = _layoutStore.LoadLicenseKey();
|
||||
if (string.IsNullOrWhiteSpace(savedKey))
|
||||
{
|
||||
IsPremium = false;
|
||||
return;
|
||||
}
|
||||
|
||||
LicenseKey = savedKey;
|
||||
var validatedAt = _layoutStore.LoadLicenseValidatedAt();
|
||||
var cachedPremium = _layoutStore.LoadIsPremium();
|
||||
|
||||
// If the last validation was within the grace period, trust the cache
|
||||
// while we attempt an online re-validation in the background.
|
||||
if (validatedAt.HasValue && DateTime.UtcNow - validatedAt.Value < OfflineGracePeriod)
|
||||
{
|
||||
IsPremium = cachedPremium;
|
||||
}
|
||||
|
||||
// Try an online check — if it succeeds, it overrides the cache.
|
||||
try
|
||||
{
|
||||
var service = new PolarLicenseService();
|
||||
var result = await service.ValidateAsync(savedKey);
|
||||
if (result.IsValid)
|
||||
{
|
||||
IsPremium = true;
|
||||
_layoutStore.SaveIsPremium(true);
|
||||
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Key revoked or expired — clear premium.
|
||||
IsPremium = false;
|
||||
_layoutStore.SaveIsPremium(false);
|
||||
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
|
||||
AppLog.Write($"License validation failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Offline or network error — keep the cached state, log it.
|
||||
AppLog.Write($"License re-validation skipped (offline?): {ex.Message}");
|
||||
if (!validatedAt.HasValue || DateTime.UtcNow - validatedAt.Value >= OfflineGracePeriod)
|
||||
{
|
||||
IsPremium = false;
|
||||
_layoutStore.SaveIsPremium(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called from the Activate button in the About overlay. Validates
|
||||
/// the entered key, persists it, and flips premium state.</summary>
|
||||
private async Task ActivateLicenseAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(LicenseKey)) return;
|
||||
|
||||
IsLicenseValidating = true;
|
||||
LicenseMessage = "Validating\u2026";
|
||||
|
||||
try
|
||||
{
|
||||
var service = new PolarLicenseService();
|
||||
var result = await service.ValidateAsync(LicenseKey);
|
||||
if (result.IsValid)
|
||||
{
|
||||
IsPremium = true;
|
||||
_layoutStore.SaveLicenseKey(LicenseKey.Trim());
|
||||
_layoutStore.SaveIsPremium(true);
|
||||
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
|
||||
LicenseMessage = "Premium activated! The watermark has been removed.";
|
||||
AppLog.Write("License activated successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
IsPremium = false;
|
||||
_layoutStore.SaveLicenseKey(null);
|
||||
_layoutStore.SaveIsPremium(false);
|
||||
LicenseMessage = result.Error ?? "Invalid license key.";
|
||||
AppLog.Write($"License activation failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LicenseMessage = $"Activation error: {ex.Message}";
|
||||
AppLog.Write($"License activation error: {ex}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLicenseValidating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DefaultLayoutPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"ytLlive",
|
||||
|
||||
@@ -128,11 +128,11 @@ C# / WPF (.NET 8) following MVVM:
|
||||
### Current limitations / TODOs
|
||||
|
||||
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`)
|
||||
- Scene/source/asset layout + the social bar persist (SQLite, schema v8); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — subscription entitlement verification pending, TASK 10)
|
||||
- Scene/source/asset layout + the social bar persist (SQLite, schema v8); the OAuth session persists (DPAPI); the paid-unlock state persists (LayoutStore Settings table — `LicenseKey`/`LicenseValidatedAt`/`IsPremium`, 14-day offline grace)
|
||||
- `YouTubeStreamService` manages the **variable reusable stream** (shipped 2026-08-16): `GetOrCreateReusableStreamAsync` lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, creating it only on first use (`resolution=variable`, `frameRate=variable`); the stream is cached via `LayoutStore` (`SaveReusableStream`/`LoadReusableStream`, Settings table) and bound at broadcast insert (`boundStreamId`). Health (shipped 2026-08-16): `GetStreamHealthAsync(streamId)` polls `liveStreams?part=status` for `healthStatus` + `configurationIssues[]` → `StreamHealth`; banner decision in pure `StreamHealthReporter`
|
||||
- Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED**, **the encoder + RTMP push (TASK 4 ship step 3) is SHIPPED**, **WASAPI audio capture (TASK 4 ship step 4) is SHIPPED** — full plan in `TASKS.md`; the frame-pipeline wiring follows (its own PR)
|
||||
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
|
||||
- **v1 task queue (2026-08-19):** tasks 19-23 added for v1 feature completeness; TASK 3.18 (chat box) shipped; TASK 16 (infinity display) shipped; TASK 10 (Polar billing) ready to execute (org ID obtained):
|
||||
- **v1 task queue (2026-08-19):** tasks 19-23 added for v1 feature completeness; TASK 3.18 (chat box) shipped; TASK 16 (infinity display) shipped; TASK 10 (Polar billing) shipped — steps 1-7 (Velopack bootstrap, PolarLicenseService, license entry UI, LayoutStore entitlement, BrandFlash toggle, startup re-validation, PremiumUrl wired); Velopack update URL pending:
|
||||
- TASK 10: Polar billing — license key entry, offline entitlement, brand flash gating, Velopack auto-updates
|
||||
- TASK 19: Scene transitions (Cut/Fade/Move/Custom) — required for professional polish
|
||||
- TASK 20: Hotkeys (global keyboard shortcuts) — the single biggest UX gap
|
||||
|
||||
+18
-17
@@ -13,24 +13,24 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="ytLive.Tests\**"/>
|
||||
<EmbeddedResource Remove="ytLive.Tests\**"/>
|
||||
<None Remove="ytLive.Tests\**"/>
|
||||
<Page Remove="ytLive.Tests\**"/>
|
||||
<Compile Remove="ytLive.Tests\**" />
|
||||
<EmbeddedResource Remove="ytLive.Tests\**" />
|
||||
<None Remove="ytLive.Tests\**" />
|
||||
<Page Remove="ytLive.Tests\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Resource Include="Assets\llamacasty-icon.png"/>
|
||||
<Resource Include="Assets\llamacasty-logo.jpg"/>
|
||||
<Resource Include="Assets\starting-backdrop.jpg"/>
|
||||
<Resource Include="Assets\brb-backdrop.jpg"/>
|
||||
<Resource Include="Assets\live-backdrop.jpg"/>
|
||||
<Resource Include="Assets\ending-backdrop.jpg"/>
|
||||
<Resource Include="Assets\chat-backdrop.jpg"/>
|
||||
<Resource Include="Assets\llamacasty-icon.png" />
|
||||
<Resource Include="Assets\llamacasty-logo.jpg" />
|
||||
<Resource Include="Assets\starting-backdrop.jpg" />
|
||||
<Resource Include="Assets\brb-backdrop.jpg" />
|
||||
<Resource Include="Assets\live-backdrop.jpg" />
|
||||
<Resource Include="Assets\ending-backdrop.jpg" />
|
||||
<Resource Include="Assets\chat-backdrop.jpg" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="THIRD-PARTY-NOTICES.txt" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<None Include="THIRD-PARTY-NOTICES.txt" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -40,11 +40,12 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10"/>
|
||||
<PackageReference Include="NAudio.Wasapi" Version="2.2.1"/>
|
||||
<PackageReference Include="NAudio.WinMM" Version="2.2.1"/>
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12"/>
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0"/>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
|
||||
<PackageReference Include="NAudio.Wasapi" Version="2.2.1" />
|
||||
<PackageReference Include="NAudio.WinMM" Version="2.2.1" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
<PackageReference Include="Velopack" Version="1.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user