The promo read 'until April 1, 2027', which is a public promise tied to a review queue outside our control: identity verification and the tax interview are in Valve's hands (they quote up to 30 business days), and the store page and build reviews follow. If any of that slips, the date becomes untrue. It now reads '30 days after the store page goes live — after that $99, locked for life', in the About box and in commercial.md, so the claim cannot be wrong however long Valve takes. Also recorded: the $100 Steam Direct fee is PAID (creator, 2026-11-05); a store page can be configured once identity clears. The launch-price deadline no longer depends on Valve's calendar.
270 lines
10 KiB
C#
270 lines
10 KiB
C#
using System;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Windows.Input;
|
|
using ytLive.Helpers;
|
|
using ytLive.Models;
|
|
|
|
namespace ytLive.ViewModels;
|
|
|
|
public partial class MainViewModel : ViewModelBase
|
|
{
|
|
|
|
private bool _isSettingsOpen;
|
|
private bool _isBugOpen;
|
|
private bool _isFeatureOpen;
|
|
private bool _isAboutOpen;
|
|
private bool _isLicensesOpen;
|
|
private string _overlayTitle = string.Empty;
|
|
private string _bugReportText = string.Empty;
|
|
private string _bugReportEmail = string.Empty;
|
|
private string _featureRequestText = string.Empty;
|
|
private string _featureRequestEmail = string.Empty;
|
|
|
|
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";
|
|
public const string PremiumUrl = "https://llamacasty.com";
|
|
public const string CoffeeUrl = "https://buymeacoffee.com/llamachiley";
|
|
public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}";
|
|
|
|
// ── About box copy (creator ruling, 2026-11-05). The price and its end date live HERE, once, so
|
|
// the app, the site and docs/commercial.md cannot drift apart. ──
|
|
public const string LaunchPrice = "$79";
|
|
public const string StandardPrice = "$99";
|
|
/// <summary>When the launch price ends. Deliberately EVENT-relative, not a date: the store page is
|
|
/// in Valve's queue (identity verification + tax review), so a fixed date would be a public promise
|
|
/// we cannot keep if their backlog slips (creator ruling 2026-11-05).</summary>
|
|
public const string LaunchPriceEndsLabel = "30 days after the store page goes live";
|
|
|
|
/// <summary>The release state shown as a badge. Not the assembly version — that number is for
|
|
/// log lines, not for the creator.</summary>
|
|
public static string AppReleaseLabel => "Pre-Release";
|
|
|
|
public string UpgradeCtaLabel => $"Unlock 60 fps — {LaunchPrice}";
|
|
public string PriceLine => $"Launch price {LaunchPrice}";
|
|
public string PriceNote => $"{LaunchPriceEndsLabel} — after that {StandardPrice}, locked for life.";
|
|
public string FreeOfferText => "Livestream and record at 30 fps.";
|
|
public string PaidOfferText => "Unlocks 60 fps for streaming and recording.";
|
|
public string TrustLine => "One-time purchase · lifetime licence · no account, no subscription, no tracking.";
|
|
|
|
/// <summary>Footer copyright. The year is the current one, not a hardcoded literal, so a build
|
|
/// shipped next year does not carry a stale date.</summary>
|
|
public string CopyrightLabel => $"© {DateTime.Now.Year} Llama Chile Shop";
|
|
|
|
/// <summary>Shown instead of the price and CTA once ownership is verified: a paying customer is
|
|
/// never sold to.</summary>
|
|
public string LicenceStateLabel => IsAppOwned ? "Licence: owned via Steam — thank you." : string.Empty;
|
|
|
|
/// <summary>The About hub's optional product-information link.</summary>
|
|
public bool IsPremiumAvailable => PremiumUrl.Length > 0;
|
|
|
|
/// <summary>Full in-app licensing/legal text, loaded on demand from the
|
|
/// shipped THIRD-PARTY-NOTICES.txt (never opens the OS viewer).</summary>
|
|
public string LicensesText { get; private set; } = string.Empty;
|
|
|
|
// Overlay panels
|
|
public bool IsSettingsOpen
|
|
{
|
|
get => _isSettingsOpen;
|
|
private set => SetPanel(ref _isSettingsOpen, value);
|
|
}
|
|
|
|
public bool IsBugOpen
|
|
{
|
|
get => _isBugOpen;
|
|
private set => SetPanel(ref _isBugOpen, value);
|
|
}
|
|
|
|
public bool IsFeatureOpen
|
|
{
|
|
get => _isFeatureOpen;
|
|
private set => SetPanel(ref _isFeatureOpen, value);
|
|
}
|
|
|
|
public bool IsAboutOpen
|
|
{
|
|
get => _isAboutOpen;
|
|
private set => SetPanel(ref _isAboutOpen, value);
|
|
}
|
|
|
|
/// <summary>About hub sub-view: the full licensing/legal texts panel
|
|
/// (in-app scrolling — no Notepad, no browser tab).</summary>
|
|
public bool IsLicensesOpen
|
|
{
|
|
get => _isLicensesOpen;
|
|
set
|
|
{
|
|
if (SetProperty(ref _isLicensesOpen, value))
|
|
OnPropertyChanged(nameof(IsHubVisible));
|
|
}
|
|
}
|
|
|
|
public bool IsAnyOverlayOpen => IsSettingsOpen || IsBugOpen || IsFeatureOpen || IsAboutOpen;
|
|
|
|
private ZoomShape _zoomShape = ZoomShape.Circle;
|
|
public ZoomShape ZoomShape
|
|
{
|
|
get => _zoomShape;
|
|
set
|
|
{
|
|
if (SetProperty(ref _zoomShape, value))
|
|
{
|
|
try { _layoutStore.UpsertSetting("ZoomShape", value.ToString()); } catch { }
|
|
}
|
|
}
|
|
}
|
|
|
|
public string OverlayTitle
|
|
{
|
|
get => _overlayTitle;
|
|
private set => SetProperty(ref _overlayTitle, value);
|
|
}
|
|
|
|
public string BugReportText
|
|
{
|
|
get => _bugReportText;
|
|
set => SetProperty(ref _bugReportText, value);
|
|
}
|
|
|
|
public string BugReportEmail
|
|
{
|
|
get => _bugReportEmail;
|
|
set => SetProperty(ref _bugReportEmail, value);
|
|
}
|
|
|
|
public string FeatureRequestText
|
|
{
|
|
get => _featureRequestText;
|
|
set => SetProperty(ref _featureRequestText, value);
|
|
}
|
|
|
|
public string FeatureRequestEmail
|
|
{
|
|
get => _featureRequestEmail;
|
|
set => SetProperty(ref _featureRequestEmail, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Properties-panel "Test alert" (creator ruling 2026-11-01): fire one synthetic Super Chat
|
|
/// through the REAL alert layer, so what plays is exactly what a viewer would trigger — the
|
|
/// custom clip if one is set, the built-in clip otherwise. Local only; nothing is sent to
|
|
/// YouTube and no chat row is invented.
|
|
/// </summary>
|
|
public RelayCommand TestAlertCommand { get; private set; } = null!;
|
|
|
|
private void TestAlert()
|
|
{
|
|
// ⛔ OnMessageReceived, not Enqueue: Enqueue only fills the queue. The layer needs its scene
|
|
// list, its playback ticker started and its previews refreshed — that is what the chat path
|
|
// does (MainViewModel.Chat.cs), and it is why a bare Enqueue played nothing in the pane.
|
|
_alertLayer.OnMessageReceived(new Models.ChatMessage
|
|
{
|
|
AuthorName = "Test viewer",
|
|
Message = "Test alert — this is what a Super Chat looks like.",
|
|
Timestamp = System.DateTime.Now,
|
|
IsSimulated = true,
|
|
IsSuperChat = true,
|
|
SuperChatAmount = 5.00,
|
|
SuperChatCurrency = "$",
|
|
SuperChatDisplayString = "$5.00",
|
|
Kind = Models.ChatEventKind.SuperChat,
|
|
}, Scenes);
|
|
}
|
|
|
|
public ICommand OpenSettingsCommand { get; }
|
|
public ICommand OpenBugCommand { get; }
|
|
public ICommand OpenFeatureCommand { get; }
|
|
public ICommand OpenAboutCommand { get; }
|
|
public ICommand CloseOverlayCommand { get; }
|
|
public ICommand SubmitBugCommand { get; }
|
|
public ICommand SubmitFeatureCommand { get; }
|
|
public ICommand OpenChannelCommand { get; }
|
|
public ICommand OpenMastodonCommand { get; }
|
|
public ICommand OpenCoffeeCommand { get; }
|
|
public ICommand OpenPremiumCommand { get; }
|
|
public ICommand OpenLicensesCommand { get; }
|
|
|
|
private void SetPanel(ref bool field, bool value, [System.Runtime.CompilerServices.CallerMemberName] string? propertyName = null)
|
|
{
|
|
if (SetProperty(ref field, value, propertyName))
|
|
OnPropertyChanged(nameof(IsAnyOverlayOpen));
|
|
}
|
|
|
|
private void ShowOverlay(string? panel = null, string title = "")
|
|
{
|
|
IsSettingsOpen = panel == nameof(IsSettingsOpen);
|
|
IsBugOpen = panel == nameof(IsBugOpen);
|
|
IsFeatureOpen = panel == nameof(IsFeatureOpen);
|
|
IsAboutOpen = panel == nameof(IsAboutOpen);
|
|
OverlayTitle = title;
|
|
if (panel != nameof(IsAboutOpen))
|
|
IsLicensesOpen = false;
|
|
}
|
|
|
|
private void ShowAbout()
|
|
{
|
|
IsLicensesOpen = false;
|
|
IsLicenseEntryOpen = false;
|
|
ShowOverlay(nameof(IsAboutOpen), "About");
|
|
}
|
|
|
|
/// <summary>Shows the in-app licensing panel, loading the full texts from
|
|
/// the shipped THIRD-PARTY-NOTICES.txt on first open (never the OS viewer).
|
|
/// Falls back to a "not found" note if the file is missing so the panel
|
|
/// never hangs on nothing.</summary>
|
|
private void ShowLicenses()
|
|
{
|
|
if (string.IsNullOrEmpty(LicensesText))
|
|
{
|
|
try
|
|
{
|
|
var path = Path.Combine(AppContext.BaseDirectory, "THIRD-PARTY-NOTICES.txt");
|
|
LicensesText = File.Exists(path)
|
|
? File.ReadAllText(path)
|
|
: "THIRD-PARTY-NOTICES.txt not found next to the executable.";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LicensesText = $"Could not read THIRD-PARTY-NOTICES.txt: {ex.Message}";
|
|
}
|
|
}
|
|
IsLicensesOpen = true;
|
|
}
|
|
|
|
private void SubmitBug()
|
|
{
|
|
var body = string.Join(Environment.NewLine,
|
|
BugReportText.Trim(),
|
|
string.Empty,
|
|
$"LlamaCasty {AppVersionLabel}",
|
|
string.IsNullOrWhiteSpace(BugReportEmail) ? string.Empty : $"Reply-to: {BugReportEmail.Trim()}");
|
|
ComposeEmail("[LlamaCasty Bug Report]", body);
|
|
ShowOverlay();
|
|
}
|
|
|
|
private void SubmitFeature()
|
|
{
|
|
var body = string.Join(Environment.NewLine,
|
|
FeatureRequestText.Trim(),
|
|
string.Empty,
|
|
$"LlamaCasty {AppVersionLabel}",
|
|
string.IsNullOrWhiteSpace(FeatureRequestEmail) ? string.Empty : $"Reply-to: {FeatureRequestEmail.Trim()}");
|
|
ComposeEmail("[LlamaCasty Feature Request]", body);
|
|
ShowOverlay();
|
|
}
|
|
|
|
private static void ComposeEmail(string subject, string body)
|
|
{
|
|
var uri = $"mailto:{SupportEmail}?subject={Uri.EscapeDataString(subject)}&body={Uri.EscapeDataString(body)}";
|
|
OpenUrl(uri);
|
|
}
|
|
|
|
private static void OpenUrl(string url)
|
|
{
|
|
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
|
}
|
|
}
|