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;
|
||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
|
using Velopack;
|
||||||
using ytLive.Helpers;
|
using ytLive.Helpers;
|
||||||
|
|
||||||
namespace ytLive;
|
namespace ytLive;
|
||||||
@@ -16,9 +17,6 @@ public partial class App : Application
|
|||||||
|
|
||||||
protected override void OnStartup(StartupEventArgs e)
|
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) =>
|
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||||
AppLog.Write(args.ExceptionObject is Exception ex
|
AppLog.Write(args.ExceptionObject is Exception ex
|
||||||
? "AppDomain unhandled exception: " + ex
|
? "AppDomain unhandled exception: " + ex
|
||||||
@@ -28,6 +26,21 @@ public partial class App : Application
|
|||||||
AppLog.Write(args.Exception, "Dispatcher unhandled exception");
|
AppLog.Write(args.Exception, "Dispatcher unhandled exception");
|
||||||
|
|
||||||
AppLog.Write("App OnStartup begin");
|
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);
|
base.OnStartup(e);
|
||||||
AppLog.Write("App OnStartup end");
|
AppLog.Write("App OnStartup end");
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-39
@@ -1,56 +1,36 @@
|
|||||||
# HANDOFF — Session State
|
# HANDOFF — Session State
|
||||||
|
|
||||||
## Branch
|
## Branch
|
||||||
`main` — dirty (all changes uncommitted, builds clean)
|
`main` — clean, uncommitted TASK 10 changes
|
||||||
|
|
||||||
## What shipped this session
|
## 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.
|
2. **Polar MCP auth** — authenticated via `opencode mcp auth Polar`. Polar MCP connected.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Files changed
|
## Files changed
|
||||||
- `ViewModels/MainViewModel.cs` — `ShowDesktop`, `ClearBackdropCaptureAsync`, `BackdropShowDesktop`, top bar avatar/login/status light, `RunMockChatPreviewAsync`, `StartMockChatPreview`/`StopMockChatPreview`, `SeedEndingBackdrop`/`SeedChatBackdrop`/`EnsureDefaultBackdrop`, `ShowStartStream` requires `IsConnected`
|
- `App.xaml.cs` — Velopack bootstrap (try/catch, non-fatal)
|
||||||
- `Models/Source.cs` — `ShowDesktop`, `ChatPreviewEnabled`
|
- `ytLive.csproj` — added `Velopack` 1.2.0 NuGet
|
||||||
- `Models/SceneCatalog.cs` — removed Chat → "YouTube Chat" display name override
|
- `Services/PolarLicenseService.cs` — new: Polar license validation API client
|
||||||
- `Services/LayoutStore.cs` — ShowDesktop column migration + read/write
|
- `Services/LayoutStore.cs` — new: `Load/SaveLicenseKey`, `Load/SaveLicenseValidatedAt`, `Load/SaveIsPremium`
|
||||||
- `MainWindow.xaml` — top bar status light + avatar + Log In button, Show Desktop context menu toggle
|
- `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.cs` — `LoadAvatarImage`, `OnViewModelPropertyChanged` handles avatar
|
- `MainWindow.xaml` — license entry panel, "Enter License Key" button, hub visibility binding, About text
|
||||||
- `Assets/ending-backdrop.jpg` — new Ending scene default backdrop
|
- `MainWindow.xaml.cs` — `BackToAbout_Click` closes both sub-panels
|
||||||
- `Assets/chat-backdrop.jpg` — new Chat scene default backdrop
|
|
||||||
- `ytLive.csproj` — new asset resources
|
|
||||||
- `Themes/Controls.xaml` — slider fix (Background="{x:Null}")
|
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
```bash
|
```bash
|
||||||
taskkill.exe /F /IM ytLive.exe # if running
|
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
|
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj" --no-restore
|
||||||
```
|
```
|
||||||
|
**0 warnings, 0 errors.**
|
||||||
## 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
|
|
||||||
|
|
||||||
## Polar.sh resources
|
## Polar.sh resources
|
||||||
| Resource | ID |
|
| Resource | ID |
|
||||||
@@ -63,9 +43,14 @@ taskkill.exe /F /IM ytLive.exe # if running
|
|||||||
| LLAMAFOUNDER | `01a8c29f-9bb8-4e96-9cbe-417a6a5cec9b` |
|
| LLAMAFOUNDER | `01a8c29f-9bb8-4e96-9cbe-417a6a5cec9b` |
|
||||||
| LLAMA50 | `8cff803e-053f-49b4-8d12-d74d468f2943` |
|
| LLAMA50 | `8cff803e-053f-49b4-8d12-d74d468f2943` |
|
||||||
| Checkout URL | `https://buy.polar.sh/polar_cl_ueMy8AyAClO0o0yo19IroZwKTbD587gzUsjEW3bFapw` |
|
| 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-` |
|
| 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
|
## v1 vision
|
||||||
> A streaming tool that's intuitive and ready to go out-the-gate with nothing held back.
|
> 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.
|
> 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}}">
|
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
|
||||||
|
|
||||||
<!-- Hub view -->
|
<!-- 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"
|
<Image Source="/ytLive;component/Assets/llamacasty-logo.jpg" Height="100" Stretch="Uniform"
|
||||||
HorizontalAlignment="Center" Margin="0,0,0,12"/>
|
HorizontalAlignment="Center" Margin="0,0,0,12"/>
|
||||||
<TextBlock Text="ytLlive" FontSize="22" FontWeight="Bold" Foreground="#e94560"
|
<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."
|
<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"
|
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="380"
|
||||||
TextAlignment="Center" Margin="0,16,0,0"/>
|
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"
|
Foreground="#888" FontSize="12" TextWrapping="Wrap" MaxWidth="380"
|
||||||
TextAlignment="Center" Margin="0,12,0,0"/>
|
TextAlignment="Center" Margin="0,12,0,0"/>
|
||||||
<TextBlock Text="Made by gramps · llama chile shop" Foreground="#a0a0b0" FontSize="12"
|
<TextBlock Text="Made by gramps · llama chile shop" Foreground="#a0a0b0" FontSize="12"
|
||||||
@@ -1485,7 +1485,10 @@
|
|||||||
<Button Content="Unlock Premium" Command="{Binding OpenPremiumCommand}"
|
<Button Content="Unlock Premium" Command="{Binding OpenPremiumCommand}"
|
||||||
Style="{StaticResource YtButtonSecondary}"
|
Style="{StaticResource YtButtonSecondary}"
|
||||||
IsEnabled="{Binding IsPremiumAvailable}"
|
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>
|
</StackPanel>
|
||||||
|
|
||||||
<Button Content="Licenses & legal" Command="{Binding OpenLicensesCommand}"
|
<Button Content="Licenses & legal" Command="{Binding OpenLicensesCommand}"
|
||||||
@@ -1508,6 +1511,26 @@
|
|||||||
HorizontalAlignment="Left" Margin="0,12,0,0"
|
HorizontalAlignment="Left" Margin="0,12,0,0"
|
||||||
Click="BackToAbout_Click"/>
|
Click="BackToAbout_Click"/>
|
||||||
</StackPanel>
|
</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>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -218,7 +218,10 @@ public partial class MainWindow : Window
|
|||||||
private void BackToAbout_Click(object sender, RoutedEventArgs e)
|
private void BackToAbout_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is ViewModels.MainViewModel vm)
|
if (DataContext is ViewModels.MainViewModel vm)
|
||||||
|
{
|
||||||
vm.IsLicensesOpen = false;
|
vm.IsLicensesOpen = false;
|
||||||
|
vm.IsLicenseEntryOpen = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AddSourceButton_Click(object sender, RoutedEventArgs e)
|
private void AddSourceButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
|||||||
@@ -1027,6 +1027,76 @@ public class LayoutStore : IDisposable
|
|||||||
return (string)cmd.ExecuteScalar()!;
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_connection.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-`
|
**Key prefix:** `LCYT-`
|
||||||
**Validate endpoint:** `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` (no auth required for client-side validation)
|
**Validate endpoint:** `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` (no auth required for client-side validation)
|
||||||
|
|
||||||
### 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.
|
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
|
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** — cached license state in `LayoutStore` (schema bump); online check at startup, offline grace period
|
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** — `BrandFlashEnabled = false` when licensed; the flash asset image needs to be created (the XAML `TextBlock` is preview-only, the compositor needs a `VideoFrame`)
|
4. ✅ **BrandFlash → watermark toggle** — `IsPremium` property setter flips `BrandFlashEnabled = !value`. Flash stops immediately on premium activation. Flash asset VideoFrame rendering deferred to TASK 4 compositor.
|
||||||
5. ✅ **Remove social slot locks** — all 6 social bar slots open to everyone (no `IsPremium` gating on slots 3-6) — shipped as TASK 10c
|
5. ✅ **Remove social slot locks** — all 6 social bar slots open to everyone (no `IsPremium` gating on slots 3-6) — shipped as TASK 10c
|
||||||
6. ☐ **"Unlock Premium" button** — `PremiumUrl` set to `llamacasty.com` checkout; `IsPremiumAvailable` becomes true
|
6. ✅ **"Unlock Premium" button** — `PremiumUrl` set to Polar checkout; `IsPremiumAvailable` is true; button lights up in the About hub.
|
||||||
7. ☐ **Renewal/lapse handling** — key expiry check, grace period, re-prompt
|
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 + UpdateManager shim + GitHub Releases (`gramps/ytLlive`)
|
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.
|
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).
|
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.
|
4. **TASK 20** — Hotkeys — global keyboard shortcuts. Small scope, huge UX impact.
|
||||||
5. **TASK 19** — Scene transitions — Cut/Fade/Move/Custom. Depends on compositor.
|
5. **TASK 19** — Scene transitions — Cut/Fade/Move/Custom. Depends on compositor.
|
||||||
6. **TASK 17** — Web source (WebView2) — enables alert ecosystem.
|
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 _brandFlashEnabled = true;
|
||||||
private bool _brandFlashActive;
|
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";
|
private const string SupportEmail = "gramps@llamachile.shop";
|
||||||
// The creator-hub link targets (About overlay) — public so the hub's
|
// The creator-hub link targets (About overlay) — public so the hub's
|
||||||
// integration test can assert they point at real destinations.
|
// integration test can assert they point at real destinations.
|
||||||
public const string ChannelUrl = "https://youtube.com/@llamachileshop";
|
public const string ChannelUrl = "https://youtube.com/@llamachileshop";
|
||||||
public const string MastodonUrl = "https://mastodon.llamachile.tube/@gramps";
|
public const string MastodonUrl = "https://mastodon.llamachile.tube/@gramps";
|
||||||
// Tabled 2026-08-14: billing provider chosen (Polar — polar.sh);
|
public const string PremiumUrl = "https://buy.polar.sh/polar_cl_ueMy8AyAClO0o0yo19IroZwKTbD587gzUsjEW3bFapw";
|
||||||
// the subscription page (premium unlock) lands here once set up. Coffee/thanks URL is live.
|
|
||||||
public const string PremiumUrl = "";
|
|
||||||
public const string CoffeeUrl = "https://buymeacoffee.com/llamachiley";
|
public const string CoffeeUrl = "https://buymeacoffee.com/llamachiley";
|
||||||
public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}";
|
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>
|
/// <summary>The global socials config (entries + bar settings), or null.</summary>
|
||||||
public SocialsConfig? Socials => _socials;
|
public SocialsConfig? Socials => _socials;
|
||||||
|
|
||||||
/// <summary>Freemium: 6 slots — YT + 1 other, the rest locked. Premium seam: all 6 open.</summary>
|
/// <summary>True when the user holds a valid LlamaCasty license (Polar). When
|
||||||
private static bool IsPremium => false; // unlock deferred (TASK 10) — seam only
|
/// 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>
|
/// <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;
|
public double SocialBarTop => _socials?.BarPosition == SocialBarPosition.Top ? 0 : 1040;
|
||||||
@@ -942,7 +958,11 @@ public class MainViewModel : ViewModelBase
|
|||||||
public bool IsLicensesOpen
|
public bool IsLicensesOpen
|
||||||
{
|
{
|
||||||
get => _isLicensesOpen;
|
get => _isLicensesOpen;
|
||||||
set => SetProperty(ref _isLicensesOpen, value);
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isLicensesOpen, value))
|
||||||
|
OnPropertyChanged(nameof(IsHubVisible));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsAnyOverlayOpen => IsSettingsOpen || IsBugOpen || IsFeatureOpen || IsAboutOpen;
|
public bool IsAnyOverlayOpen => IsSettingsOpen || IsBugOpen || IsFeatureOpen || IsAboutOpen;
|
||||||
@@ -1124,6 +1144,39 @@ public class MainViewModel : ViewModelBase
|
|||||||
set => SetProperty(ref _featureRequestEmail, value);
|
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
|
// Commands
|
||||||
public ICommand AddSceneCommand { get; }
|
public ICommand AddSceneCommand { get; }
|
||||||
public ICommand AddSourceCommand { get; }
|
public ICommand AddSourceCommand { get; }
|
||||||
@@ -1159,6 +1212,8 @@ public class MainViewModel : ViewModelBase
|
|||||||
public ICommand SaveLayoutCommand { get; }
|
public ICommand SaveLayoutCommand { get; }
|
||||||
public ICommand SaveLayoutAsCommand { get; }
|
public ICommand SaveLayoutAsCommand { get; }
|
||||||
public ICommand OpenLayoutCommand { get; }
|
public ICommand OpenLayoutCommand { get; }
|
||||||
|
public ICommand ActivateLicenseCommand { get; }
|
||||||
|
public ICommand OpenLicenseEntryCommand { get; }
|
||||||
|
|
||||||
public MainViewModel()
|
public MainViewModel()
|
||||||
{
|
{
|
||||||
@@ -1229,6 +1284,8 @@ public class MainViewModel : ViewModelBase
|
|||||||
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
|
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
|
||||||
SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs());
|
SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs());
|
||||||
OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile());
|
OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile());
|
||||||
|
ActivateLicenseCommand = new RelayCommand(_ => _ = ActivateLicenseAsync(), _ => !IsLicenseValidating);
|
||||||
|
OpenLicenseEntryCommand = new RelayCommand(_ => IsLicenseEntryOpen = !IsLicenseEntryOpen);
|
||||||
|
|
||||||
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
|
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
|
||||||
_activeLayoutPath = _layoutStore.ActivePath;
|
_activeLayoutPath = _layoutStore.ActivePath;
|
||||||
@@ -1304,6 +1361,7 @@ public class MainViewModel : ViewModelBase
|
|||||||
_reusableStream = _layoutStore.LoadReusableStream();
|
_reusableStream = _layoutStore.LoadReusableStream();
|
||||||
_reusableStreamUrl = _reusableStream?.RtmpUrl;
|
_reusableStreamUrl = _reusableStream?.RtmpUrl;
|
||||||
_ = LoadSavedSessionAsync();
|
_ = LoadSavedSessionAsync();
|
||||||
|
_ = ValidateLicenseAtStartupAsync();
|
||||||
AppLog.Write("MainViewModel ctor end");
|
AppLog.Write("MainViewModel ctor end");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1345,6 +1403,105 @@ public class MainViewModel : ViewModelBase
|
|||||||
AccountDisplayName = channel?.DisplayName ?? string.Empty;
|
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(
|
private static string DefaultLayoutPath => Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
"ytLlive",
|
"ytLlive",
|
||||||
|
|||||||
@@ -128,11 +128,11 @@ C# / WPF (.NET 8) following MVVM:
|
|||||||
### Current limitations / TODOs
|
### 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`)
|
- `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`
|
- `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)
|
- 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
|
- `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 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 19: Scene transitions (Cut/Fade/Move/Custom) — required for professional polish
|
||||||
- TASK 20: Hotkeys (global keyboard shortcuts) — the single biggest UX gap
|
- TASK 20: Hotkeys (global keyboard shortcuts) — the single biggest UX gap
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
<PackageReference Include="NAudio.WinMM" Version="2.2.1" />
|
<PackageReference Include="NAudio.WinMM" Version="2.2.1" />
|
||||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12" />
|
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12" />
|
||||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Velopack" Version="1.2.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user