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"; /// The release state shown as a badge. Not the assembly version — that number is for /// log lines, not for the creator. 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."; /// Shown instead of the price and CTA once ownership is verified: a paying customer is /// never sold to. public string LicenceStateLabel => IsAppOwned ? "Licence: owned via Steam — thank you." : string.Empty; /// The About hub's optional product-information link. public bool IsPremiumAvailable => PremiumUrl.Length > 0; /// Full in-app licensing/legal text, loaded on demand from the /// shipped THIRD-PARTY-NOTICES.txt (never opens the OS viewer). 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); } /// About hub sub-view: the full licensing/legal texts panel /// (in-app scrolling — no Notepad, no browser tab). 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); } /// /// 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. /// 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"); } /// 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. 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 }); } }