Files
LlamaCasty/ViewModels/MainViewModel.Overlays.cs
T
gramps 0de953ef66 feat(about): rebuild the About box as a conversion surface
Creator: the About box was "shitty" — an unreadable logo, a version number nobody asked for, copy that
insulted the creator, and buttons that looked like text. Rebuilt around one job: convert.

- **820×860 modal** (was 650×700) with a scrollable hub, and the logo at **520 px wide** instead of
  125 — the asset is 2816×1536, so it was being drawn as a thumbnail, not an icon problem.
- **Pre-Release pill** replaces "Version 1.0.0" (the assembly version stays in log lines). The pill is a
  button: it opens llamacasty.com until a Steam store page exists, so the link changes in one place.
- Tagline: "1-2-3, and go live to the world on YouTube!" The old "simple enough that even the most
  right-brained person can intuit it" is gone, and a test asserts it stays gone.
- The offer in three short blocks (free 30 fps · paid 60 fps · one-time, lifetime, no account /
  subscription / tracking), then the launch price and **one primary CTA**: "Unlock 60 fps — $79".
- Price: $79 until **April 1, 2027**, then $99 locked for life. The numbers live once, in
  `MainViewModel.Overlays`, and are recorded in `docs/ai/commercial.md`.
- **A paying customer is never sold to**: ownership collapses the price/CTA block and shows
  "Licence: owned via Steam — thank you."
- Secondary destinations (channel, Mastodon, coffee, licences) are one quiet row; footer is
  `© Llama Chile Shop`; the "Made by gramps" line is removed.
- `YtButtonSecondary` now carries a higher surface, a hairline border and a keyboard focus ring — they
  were the same tone as the panel behind them, which is why they read as plain text.

Test: `AboutHubTests.About_ShowsTheOfferToFreeUsers_AndNothingToSellToOwners` — tagline, pill, price,
CTA text, copyright, the removed lines stay removed, and the CTA block collapses when owned.
FAST gate: 540 passed, 0 failed, 0 warnings.
2026-11-05 13:00:57 -08:00

262 lines
9.5 KiB
C#

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";
public const string LaunchPriceEndsLabel = "April 1, 2027";
/// <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 => $"Until {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>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 });
}
}