Files
LlamaCasty/ViewModels/MainViewModel.Alerts.cs
gramps 1e3a4f7fe0 feat(entitlement): pre-release switch so Release builds can test 60 fps
The frame-rate boundary is correct as a product rule, but it only bites in a
Release build, where DevBuild.IsDevelopment is false and DeveloperMode cannot
unlock anything — exactly the build whose performance numbers are worth reading
(see docs/ai/environment.md: Debug pixel paths are several times slower). 60 fps
could not be tested at all.

OutputProfile.EntitlementEnforced is false: every shipped tier is selectable by
any build, and HasProOutput — the one property the tier setter, the dropdown's
strikethrough and the locked-item style all read — answers yes regardless of
ownership. static readonly rather than const, so the consumed `||` does not fold
into unreachable operand code (same reason as DevBuild.IsDevelopment).

The licence indicator row is suppressed while the switch is off: it exists to
explain what the boundary costs, and claiming "Steam ID Limit: 60fps" to an
unowned build would be a false statement. Docs corrected in place — the enforced
rule is unchanged, only its state is recorded.

Tests: one integration case covers both switch states (unowned build reaches
1080p60 with the switch off, 1080p30 with it on), so flipping at launch leaves no
red test behind. The two suites that anchor on the pinned licence row early-return
while it is absent; EntitlementTests covers that state meanwhile.

667 pass, build 0 warnings.
2026-11-12 07:50:59 -08:00

142 lines
5.6 KiB
C#

using System.Collections.ObjectModel;
using System.Linq;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.ViewModels;
/// <summary>
/// The left panel's alert stack (creator design, 2026-11-05): standing state on top, transient events
/// beneath it, newest first, pushing the panel's content down. Only red rows belong here — the panel
/// is where "something needs you" lives, so it never fills with chatter.
/// <para><b>Row 1 is the Steam licence indicator</b> and nothing displaces it. It is pinned while
/// higher output tiers are unavailable, and becomes dismissible — permanently — once ownership is
/// verified. If ownership is later lost, it comes back as a pinned indicator, because that is a change
/// of state rather than a preference.</para>
/// </summary>
public partial class MainViewModel
{
private const string SteamLicenceAlertId = "steam-licence";
private readonly ObservableCollection<AttentionAlert> _attentionAlerts = new();
private bool _steamLicenceDismissed;
public ObservableCollection<AttentionAlert> AttentionAlerts => _attentionAlerts;
public bool HasAttentionAlerts => _attentionAlerts.Count > 0;
public RelayCommand<AttentionAlert> DismissAlertCommand => new(DismissAlert);
public RelayCommand<AttentionAlert> ActivateAlertCommand => new(ActivateAlert);
/// <summary>The licence indicator's text (creator wording, 2026-11-05): the limit this build runs
/// at, plus where to find out why. Not a warning — the resolution picker explains the detail, and
/// clicking the row opens About.</summary>
private string SteamLicenceText()
{
var entitled = 30;
if (QualityOptions.Length > 0)
{
entitled = QualityOptions
.Where(option => HasProOutput || !option.RequiresPro)
.Select(option => option.Fps)
.DefaultIfEmpty(30)
.Max();
}
return $"Steam ID Limit: {entitled}fps\nClick for More Info";
}
/// <summary>Recomputes row 1 in place. Called at startup and whenever ownership changes.</summary>
private void RefreshSteamLicenceAlert()
{
var existing = _attentionAlerts.FirstOrDefault(alert => alert.Id == SteamLicenceAlertId);
// The row exists to explain what the frame-rate boundary costs when it applies. While the
// pre-release switch is off nothing is limited, so announcing a limit would be a false claim.
var hidden = !OutputProfile.EntitlementEnforced
|| (IsAppOwned && _steamLicenceDismissed);
if (hidden)
{
if (existing != null) _attentionAlerts.Remove(existing);
RaiseAttentionChanged();
return;
}
var alert = new AttentionAlert(
SteamLicenceAlertId,
SteamLicenceText(),
IsPinned: !IsAppOwned,
AttentionAlertAction.About);
var index = existing == null ? -1 : _attentionAlerts.IndexOf(existing);
if (index < 0) _attentionAlerts.Insert(0, alert);
else _attentionAlerts[index] = alert; // same row, updated state
RaiseAttentionChanged();
}
/// <summary>Pushes an event alert. Newest sits directly under the licence indicator; older ones
/// stack beneath, so the newest is the one nearest the eye.</summary>
private AttentionAlert? PushAttentionAlert(string id, string text, long logFrom, long logTo)
{
var alert = new AttentionAlert(id, text, IsPinned: false, AttentionAlertAction.Logs, logFrom, logTo);
var insertAt = _attentionAlerts.Count(alert => alert.Id == SteamLicenceAlertId);
var existing = _attentionAlerts.FirstOrDefault(candidate => candidate.Id == id);
if (existing != null)
{
// Same event advancing (reconnecting attempt 2 → 3): update the row, never duplicate it.
_attentionAlerts[_attentionAlerts.IndexOf(existing)] = alert;
}
else
{
_attentionAlerts.Insert(insertAt, alert);
}
RaiseAttentionChanged();
return alert;
}
/// <summary>Test seam: pushes an event row without a stream session behind it.</summary>
internal AttentionAlert? PushAttentionAlertForTest(string id, string text, long logFrom, long logTo)
=> PushAttentionAlert(id, text, logFrom, logTo);
private void RemoveAttentionAlert(string id)
{
var existing = _attentionAlerts.FirstOrDefault(alert => alert.Id == id);
if (existing == null) return;
_attentionAlerts.Remove(existing);
RaiseAttentionChanged();
}
private void DismissAlert(AttentionAlert? alert)
{
if (alert == null || alert.IsPinned) return;
if (alert.Id == SteamLicenceAlertId)
{
// Permanent for this profile: the creator said "I have seen it". Only honoured while
// ownership holds — RefreshSteamLicenceAlert ignores it when Steam stops confirming.
_steamLicenceDismissed = true;
_layoutStore.SaveSteamLicenceIndicatorDismissed(true);
}
RemoveAttentionAlert(alert.Id);
}
private void ActivateAlert(AttentionAlert? alert)
{
if (alert == null) return;
if (alert.Action == AttentionAlertAction.About)
{
ShowAbout();
return;
}
// Event alert: open the log and paint this event's lines, so the alert is an entry point to
// the detail rather than a dead end.
HighlightLogRange(alert.LogFrom, alert.LogTo);
SelectLogsTab(true);
}
private void RaiseAttentionChanged() => OnPropertyChanged(nameof(HasAttentionAlerts));
}