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);
}
///