TASK 10: Polar billing + Velopack auto-updates (steps 1-7)

This commit is contained in:
2026-08-21 08:18:52 -07:00
parent 2f135687cd
commit 8951141c65
10 changed files with 417 additions and 78 deletions
+16 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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 &quot;made with ytLlive!&quot; flash. A one-time unlock removes it and adds alerts."
<TextBlock Text="Free forever — live streams carry a brief &quot;made with ytLlive!&quot; 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 &amp; 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>
+3
View File
@@ -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)
+70
View File
@@ -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();
+87
View File
@@ -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; }
}
+8 -8
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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
View File
@@ -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>