diff --git a/MainWindow.xaml b/MainWindow.xaml index af9b226..8704b38 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -137,14 +137,22 @@ - + + + + + + diff --git a/Models/OutputProfile.cs b/Models/OutputProfile.cs index 31763a9..00e0ee9 100644 --- a/Models/OutputProfile.cs +++ b/Models/OutputProfile.cs @@ -3,11 +3,8 @@ namespace ytLive.Models; /// /// The one entitlement rule, as pure policy: what a build is allowed to emit. /// -/// Why pixels and a frame rate rather than "720p". The paid delta is output -/// quality, and a resolution name is a marketing word, not a measurable bound — 720p60 is -/// 921,600 pixels at twice the frame rate, and vertical 720×1280 is the same pixel count -/// with a different shape. A pixel budget plus a frame-rate ceiling expresses the same -/// promise without a lookup table that a new tier can quietly escape. +/// Frame rate is the paid boundary. Every supported resolution at 30 fps is free; +/// 60 fps requires paid ownership, regardless of frame dimensions. /// /// Fail closed. Callers pass pro: false when entitlement is unknown, /// unreadable or unresolved; the free profile is the default state, never the fallback @@ -19,19 +16,16 @@ namespace ytLive.Models; /// public static class OutputProfile { - /// Free ceiling: 921,600 px = 1280×720, i.e. one 720p frame. - public const int FreeMaxPixels = 921_600; - /// Free ceiling: 30 frames per second. public const int FreeMaxFps = 30; - /// The named tier a capped build falls back to when nothing else qualifies. - public const string FreeFallbackName = "720p30"; + /// The default free tier and fallback when no shipped tier is available. + public const string FreeFallbackName = "1080p30"; /// Is this tier within the free budget? Pro may use anything shipped. public static bool IsAllowed(QualityOption tier, bool pro) => pro - || (tier.Width * tier.Height <= FreeMaxPixels && tier.Fps <= FreeMaxFps); + || tier.Fps <= FreeMaxFps; /// /// The tier a session will actually encode. A permitted selection passes through @@ -57,6 +51,6 @@ public static class OutputProfile // No shipped tier fits the budget: build the floor explicitly rather than trusting // the table. A build whose tiers were all redefined must still emit a legal frame. - return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 6.0, 1280, 720); + return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 8.0, 1920, 1080); } } diff --git a/Services/Entitlement/FakeEntitlement.cs b/Services/Entitlement/FakeEntitlement.cs index 3dafdc8..c0d17b3 100644 --- a/Services/Entitlement/FakeEntitlement.cs +++ b/Services/Entitlement/FakeEntitlement.cs @@ -5,7 +5,12 @@ namespace ytLive.Services.Entitlement; /// public sealed class FakeEntitlement : IEntitlement { - public FakeEntitlement(bool isAppOwned = false) => IsAppOwned = isAppOwned; + public FakeEntitlement(bool isAppOwned = false, string? errorMessage = null) + { + IsAppOwned = isAppOwned; + ErrorMessage = errorMessage; + } public bool IsAppOwned { get; set; } + public string? ErrorMessage { get; set; } } diff --git a/Services/Entitlement/IEntitlement.cs b/Services/Entitlement/IEntitlement.cs index 968f1f1..996b41b 100644 --- a/Services/Entitlement/IEntitlement.cs +++ b/Services/Entitlement/IEntitlement.cs @@ -9,4 +9,6 @@ namespace ytLive.Services.Entitlement; public interface IEntitlement { bool IsAppOwned { get; } + + string? ErrorMessage { get; } } diff --git a/Services/Entitlement/SteamEntitlement.cs b/Services/Entitlement/SteamEntitlement.cs index 25b1670..76fbe77 100644 --- a/Services/Entitlement/SteamEntitlement.cs +++ b/Services/Entitlement/SteamEntitlement.cs @@ -17,6 +17,7 @@ public sealed class SteamEntitlement : IEntitlement private bool _initAttempted; private bool _available; private bool? _cached; + private string? _errorMessage; public SteamEntitlement(uint paidAppId) : this( @@ -42,11 +43,18 @@ public sealed class SteamEntitlement : IEntitlement /// Configured with no paid app ID; ownership fails closed without touching Steam. public static SteamEntitlement NotConfigured() => new(0); + public string? ErrorMessage => _errorMessage; + public bool IsAppOwned { get { - if (_paidAppId == 0) return false; + if (_paidAppId == 0) + { + _cached = false; + _errorMessage = "The paid Steam app ID is not configured for this build. Contact the creator or support."; + return false; + } if (_cached is { } known) return known; if (!_initAttempted) @@ -56,22 +64,31 @@ public sealed class SteamEntitlement : IEntitlement { _available = _initializeSteam(); } - catch (Exception) + catch (Exception ex) { _available = false; + _errorMessage = $"Steam could not start ({ex.Message}). Open Steam, sign in, then restart LlamaCasty."; } } - if (!_available) { _cached = false; return false; } + if (!_available) + { + _cached = false; + _errorMessage ??= "Steam could not verify ownership. Open Steam, sign in, then restart LlamaCasty."; + return false; + } try { var owned = _isAppSubscribed(_paidAppId); _cached = owned; + _errorMessage = null; return owned; } - catch (Exception) + catch (Exception ex) { + _cached = false; + _errorMessage = $"Steam could not check ownership ({ex.Message}). Open Steam, sign in, then restart LlamaCasty."; return false; } } @@ -83,5 +100,6 @@ public sealed class SteamEntitlement : IEntitlement _available = false; _cached = null; _initAttempted = false; + _errorMessage = null; } } diff --git a/Themes/Controls.xaml b/Themes/Controls.xaml index ca02355..9449bdc 100644 --- a/Themes/Controls.xaml +++ b/Themes/Controls.xaml @@ -380,6 +380,24 @@ + + + + + + + + diff --git a/ViewModels/MainViewModel.OutputProfile.cs b/ViewModels/MainViewModel.OutputProfile.cs index ed521dc..0ea30b3 100644 --- a/ViewModels/MainViewModel.OutputProfile.cs +++ b/ViewModels/MainViewModel.OutputProfile.cs @@ -1,4 +1,5 @@ using ytLive.Helpers; +using ytLive.Models; using ytLive.Services.Entitlement; namespace ytLive.ViewModels; @@ -13,17 +14,28 @@ public partial class MainViewModel { private IEntitlement _entitlement = CreateEntitlement(); private bool _isAppOwned; + private string? _entitlementErrorMessage; public bool IsAppOwned { get => _isAppOwned; private set { - if (!SetProperty(ref _isAppOwned, value)) return; + var changed = SetProperty(ref _isAppOwned, value); + EntitlementErrorMessage = _entitlement.ErrorMessage; + if (!changed) return; OnPropertyChanged(nameof(HasProOutput)); + if (!HasProOutput && _selectedQuality is { RequiresPro: true } && CanChangeQuality) + SelectedQuality = QualityOptions.Single(option => option.Display == OutputProfile.FreeFallbackName); } } + public string? EntitlementErrorMessage + { + get => _entitlementErrorMessage; + private set => SetProperty(ref _entitlementErrorMessage, value); + } + public bool HasProOutput => DeveloperMode || IsAppOwned; private static IEntitlement CreateEntitlement() @@ -43,6 +55,7 @@ public partial class MainViewModel { _entitlement = value; IsAppOwned = value.IsAppOwned; + EntitlementErrorMessage = value.ErrorMessage; } } diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs index c735bf1..153c757 100644 --- a/ViewModels/MainViewModel.Streaming.cs +++ b/ViewModels/MainViewModel.Streaming.cs @@ -66,6 +66,7 @@ public partial class MainViewModel : ViewModelBase { _youtubeBroadcastLive = false; OnPropertyChanged(nameof(IsOffline)); + OnPropertyChanged(nameof(CanChangeQuality)); OnPropertyChanged(nameof(IsLive)); OnPropertyChanged(nameof(IsEditMode)); OnPropertyChanged(nameof(RecDotBrush)); @@ -91,6 +92,7 @@ public partial class MainViewModel : ViewModelBase public bool IsOffline => StreamStatus == StreamStatus.Offline; public bool IsLive => StreamStatus == StreamStatus.Streaming; + public bool CanChangeQuality => IsOffline && !IsRecording; public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase); public string RecDotBrush => !IsRecording ? ThemeBrush.Hex("Brush.OnSurfaceDisabled") @@ -151,6 +153,7 @@ public partial class MainViewModel : ViewModelBase internal set { if (!SetProperty(ref _isRecording, value)) return; + OnPropertyChanged(nameof(CanChangeQuality)); OnPropertyChanged(nameof(RecDotBrush)); OnPropertyChanged(nameof(RecTextBrush)); OnPropertyChanged(nameof(RecDotOpacity)); @@ -450,10 +453,15 @@ public partial class MainViewModel : ViewModelBase get => _selectedQuality; set { + if (!CanChangeQuality) + { + OnPropertyChanged(nameof(SelectedQuality)); + return; + } + if (!HasProOutput && value.RequiresPro) { OnPropertyChanged(nameof(SelectedQuality)); - OpenAboutCommand.Execute(null); return; } diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 96d5888..4e25922 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -200,7 +200,10 @@ public partial class MainViewModel : ViewModelBase _youtubeChat = new YouTubeChatService(_youtubeAuth); AppLog.Write("MainViewModel ctor: services created"); - _selectedQuality = OutputProfile.Resolve(QualityOptions[0], QualityOptions, HasProOutput); + var defaultQuality = HasProOutput + ? QualityOptions[0] + : QualityOptions.Single(option => option.Display == OutputProfile.FreeFallbackName); + _selectedQuality = OutputProfile.Resolve(defaultQuality, QualityOptions, HasProOutput); ApplyStreamQuality(_selectedQuality); _youtubeChat.MessageReceived += OnChatMessageReceived; diff --git a/ytLive.Tests/AboutHubTests.cs b/ytLive.Tests/AboutHubTests.cs index 0b9a1d0..bb923b6 100644 --- a/ytLive.Tests/AboutHubTests.cs +++ b/ytLive.Tests/AboutHubTests.cs @@ -2,9 +2,11 @@ using System; using System.Windows; using Microsoft.Data.Sqlite; using System.Windows.Controls; +using System.Windows.Media; using System.Windows.Threading; using Xunit; using ytLive.Controls; +using ytLive.Models; using ytLive.Services.Entitlement; using ytLive.ViewModels; @@ -74,7 +76,7 @@ public sealed class AboutHubTests } [Fact] - public void Selecting_a_locked_quality_opens_about_and_keeps_the_free_tier_selected() + public void Free_user_gets_1080p30_and_cannot_select_or_change_to_60fps() { _app.Run(() => { @@ -85,36 +87,59 @@ public sealed class AboutHubTests { window = new MainWindow(); var vm = (MainViewModel)window.DataContext; - vm.EntitlementOverride = new FakeEntitlement(false); - var freeTier = vm.QualityOptions.Single(option => !option.RequiresPro); - var lockedTier = vm.QualityOptions.First(option => option.RequiresPro); - vm.SelectedQuality = freeTier; + vm.EntitlementOverride = new FakeEntitlement(false, + "Steam could not verify ownership. Open Steam, sign in, then restart LlamaCasty."); window.Show(); + window.UpdateLayout(); + + var freeTier = vm.QualityOptions.Single(option => option.Display == "1080p30"); + var lockedTier = vm.QualityOptions.First(option => option.Fps == 60); + Assert.Same(freeTier, vm.SelectedQuality); + + var errorPanel = (Border)window.FindName("EntitlementErrorPanel"); + var errorText = (TextBlock)window.FindName("EntitlementErrorText"); + Assert.Equal(Visibility.Visible, errorPanel.Visibility); + Assert.Contains("Open Steam, sign in", errorText.Text); var picker = (ComboBox)window.FindName("QualityPicker"); + Assert.True(vm.CanChangeQuality); + Assert.True(picker.IsEnabled); picker.IsDropDownOpen = true; picker.UpdateLayout(); Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded); var lockedItem = (ComboBoxItem)picker.ItemContainerGenerator.ContainerFromItem(lockedTier)!; - Assert.True(lockedItem.IsEnabled); - Assert.Equal(0.55, lockedItem.Opacity); - Assert.Equal(Application.Current.Resources["Brush.OnSurfaceDisabled"], lockedItem.Foreground); + Assert.False(lockedItem.IsEnabled); + var struckText = FindVisualChild(lockedItem); + Assert.NotNull(struckText); + Assert.Contains(struckText!.TextDecorations, decoration => + decoration.Location == TextDecorationLocation.Strikethrough); picker.SelectedItem = lockedTier; picker.UpdateLayout(); Assert.Same(freeTier, vm.SelectedQuality); - Assert.True(vm.IsAboutOpen); + Assert.False(vm.IsAboutOpen); - var about = (OverlayHost)window.FindName("AboutOverlay"); - var copy = (TextBlock)about.FindName("UpgradeCopy"); - var link = (Button)about.FindName("UpgradeLinkButton"); - Assert.Contains("720p30", copy.Text); - Assert.Contains("one-time purchase", copy.Text, StringComparison.OrdinalIgnoreCase); - Assert.Contains("no subscription", copy.Text, StringComparison.OrdinalIgnoreCase); - Assert.Contains("llamacasty.com", link.Content!.ToString()); - Assert.Same(vm.OpenPremiumCommand, link.Command); + picker.IsDropDownOpen = false; + vm.IsRecording = true; + window.UpdateLayout(); + Assert.False(vm.CanChangeQuality); + Assert.False(picker.IsEnabled); + vm.SelectedQuality = vm.QualityOptions.Single(option => option.Display == "720p30"); + Assert.Same(freeTier, vm.SelectedQuality); + + vm.IsRecording = false; + vm.StreamStatus = StreamStatus.Streaming; + window.UpdateLayout(); + Assert.False(vm.CanChangeQuality); + Assert.False(picker.IsEnabled); + vm.SelectedQuality = vm.QualityOptions.Single(option => option.Display == "720p30"); + Assert.Same(freeTier, vm.SelectedQuality); + + vm.StreamStatus = StreamStatus.Offline; + Assert.True(vm.CanChangeQuality); + Assert.True(picker.IsEnabled); } finally { @@ -125,4 +150,16 @@ public sealed class AboutHubTests } }); } + + private static T? FindVisualChild(DependencyObject parent) where T : DependencyObject + { + for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + if (child is T match) return match; + var nested = FindVisualChild(child); + if (nested != null) return nested; + } + return null; + } } diff --git a/ytLive.Tests/EntitlementTests.cs b/ytLive.Tests/EntitlementTests.cs index e78e9cf..883e02a 100644 --- a/ytLive.Tests/EntitlementTests.cs +++ b/ytLive.Tests/EntitlementTests.cs @@ -20,7 +20,9 @@ public class EntitlementTests [Fact] public void No_configured_paid_app_id_reads_as_not_owned() { - Assert.False(SteamEntitlement.NotConfigured().IsAppOwned); + var entitlement = SteamEntitlement.NotConfigured(); + Assert.False(entitlement.IsAppOwned); + Assert.Contains("paid Steam app ID is not configured", entitlement.ErrorMessage); } [Fact] @@ -40,6 +42,7 @@ public class EntitlementTests Assert.True(entitlement.IsAppOwned); Assert.Equal(paidAppId, queriedAppId); + Assert.Null(entitlement.ErrorMessage); } [Fact] @@ -47,6 +50,16 @@ public class EntitlementTests { var entitlement = new SteamEntitlement(1234567u, () => false, _ => true, () => { }); Assert.False(entitlement.IsAppOwned); + Assert.Contains("Open Steam", entitlement.ErrorMessage); + } + + [Fact] + public void A_valid_steam_check_for_an_unowned_account_is_not_an_authentication_error() + { + var entitlement = new SteamEntitlement(1234567u, () => true, _ => false, () => { }); + + Assert.False(entitlement.IsAppOwned); + Assert.Null(entitlement.ErrorMessage); } [Fact] diff --git a/ytLive.Tests/OutputProfileTests.cs b/ytLive.Tests/OutputProfileTests.cs index d84a257..966cdfa 100644 --- a/ytLive.Tests/OutputProfileTests.cs +++ b/ytLive.Tests/OutputProfileTests.cs @@ -8,8 +8,8 @@ using ytLive.Models; namespace ytLive.Tests; /// -/// The output-profile rule (2026-11-01): a build without Pro may emit at most 921,600 pixels -/// at 30 fps, and this is the only place that decision is made. +/// The output-profile rule: a free build may emit every shipped resolution at 30 fps; +/// 60 fps requires Pro, independent of resolution. /// /// The one thing that must never break: a cracked or unlicensed build silently gets a /// *smaller frame*, never a smaller feature set. If a future tier is added above the budget, @@ -32,21 +32,21 @@ public class OutputProfileTests private static QualityOption Tier(string name) => Shipped.Single(t => t.Display == name); [Fact] - public void A_free_build_resolves_1080p60_down_to_720p30_with_its_own_bitrate() + public void A_free_build_resolves_1080p60_to_1080p30() { var resolved = OutputProfile.Resolve(Tier("1080p60"), Shipped, pro: false); - Assert.Equal(1280, resolved.Width); - Assert.Equal(720, resolved.Height); + Assert.Equal("1080p30", resolved.Display); + Assert.Equal(1920, resolved.Width); + Assert.Equal(1080, resolved.Height); Assert.Equal(30, resolved.Fps); - // The bitrate follows the profile: a capped user is not offered an 8 Mbps budget. - Assert.Equal(6.0, resolved.Bitrate); + Assert.Equal(8.0, resolved.Bitrate); } [Fact] public void A_free_build_leaves_its_own_tier_untouched() { - var requested = Tier("720p30"); + var requested = Tier("1080p30"); Assert.Same(requested, OutputProfile.Resolve(requested, Shipped, pro: false)); } @@ -57,21 +57,15 @@ public class OutputProfileTests Assert.Same(tier, OutputProfile.Resolve(tier, Shipped, pro: true)); } - [Theory] - [InlineData("1080p60")] - [InlineData("1080p30")] - [InlineData("720p60")] - [InlineData("720p30")] - [InlineData("Vertical 1080p30")] - [InlineData("Vertical 1080p60")] - public void No_free_selection_can_emit_more_than_the_budget(string name) + [Fact] + public void Free_access_depends_on_frame_rate_not_resolution() { - var resolved = OutputProfile.Resolve(Tier(name), Shipped, pro: false); - - Assert.True(resolved.Width * resolved.Height <= OutputProfile.FreeMaxPixels, - $"{resolved.Display} resolves to {resolved.Width * resolved.Height} px on a free build"); - Assert.True(resolved.Fps <= OutputProfile.FreeMaxFps, - $"{resolved.Display} resolves to {resolved.Fps} fps on a free build"); + foreach (var tier in Shipped) + { + var freeAllowed = tier.Fps <= OutputProfile.FreeMaxFps; + Assert.Equal(freeAllowed, OutputProfile.IsAllowed(tier, pro: false)); + Assert.Equal(!freeAllowed, tier.RequiresPro); + } } [Fact] @@ -79,9 +73,11 @@ public class OutputProfileTests { var resolved = OutputProfile.Resolve(Tier("1080p60"), Array.Empty(), pro: false); - Assert.Equal(1280, resolved.Width); - Assert.Equal(720, resolved.Height); + Assert.Equal("1080p30", resolved.Display); + Assert.Equal(1920, resolved.Width); + Assert.Equal(1080, resolved.Height); Assert.Equal(30, resolved.Fps); + Assert.Equal(8.0, resolved.Bitrate); } ///