using System.Windows.Input; using ytLive.Helpers; using ytLive.Models; namespace ytLive.ViewModels; /// /// The creator's own video rate, per tier — the latitude the tier table deliberately does not take /// away. A creator who knows their upload can set any rate from the tier's YouTube minimum upward; /// nothing here invents a ceiling, because a wrong guess is what live step-down exists to catch. /// /// Why per tier: a number that is sensible at 1080p60 is wasteful at 720p30, and the /// creator who tuned one tier has not tuned the other. Unset means "use the shipped default", which /// is what substitutes. /// /// Why this matters now: with CBR holding the target (FfmpegArgs.AddRateControl) /// the number the creator types is the number that goes out, so it is a real quality choice rather /// than an average to undershoot. /// public partial class MainViewModel { private readonly Dictionary _ratedMbpsByTier = new(StringComparer.Ordinal); /// The tier with the creator's rate substituted for the shipped default. Every consumer /// that builds encoder options or a step-down policy must use this, never the raw tier. public QualityOption EffectiveQuality(QualityOption tier) => RatedFor(tier) is var rated && Math.Abs(rated - tier.Bitrate) > 0.001 ? tier with { Bitrate = rated } : tier; private double RatedFor(QualityOption tier) { if (_ratedMbpsByTier.TryGetValue(tier.Display, out var cached)) return cached; // The constructor resolves the default tier before the layout store exists, so the first read // must not cache: a cached default would shadow the creator's stored rate for that run. var stored = _layoutStore is null ? null : _layoutStore.LoadRatedMbps(tier.Display); var value = Math.Max(stored ?? tier.Bitrate, RateLadder.For(tier).Minimum); if (_layoutStore is not null) _ratedMbpsByTier[tier.Display] = value; return value; } /// The rate the selected tier will stream at, in Mbps. public double RatedMbps { get => RatedFor(SelectedQuality); set { var tier = SelectedQuality; var band = RateLadder.For(tier); // Below the tier minimum YouTube will not hold the resolution; above ~100 is not a stream. var clamped = Math.Clamp(Math.Round(value, 1), band.Minimum, 100.0); if (Math.Abs(clamped - RatedFor(tier)) < 0.05) { OnPropertyChanged(nameof(RatedMbps)); return; } _ratedMbpsByTier[tier.Display] = clamped; _layoutStore.SaveRatedMbps(tier.Display, clamped); ApplyStreamQuality(tier); OnPropertyChanged(nameof(RatedMbps)); OnPropertyChanged(nameof(RateHint)); } } /// What YouTube expects for this tier, shown next to the field. public string RateHint { get { var band = RateLadder.For(SelectedQuality); return $"YouTube recommends {band.Recommended:0.#} Mbps for {SelectedQuality.Display}; " + $"it will not take less than {band.Minimum:0.#}. Your connection must carry this " + "plus ~20% headroom."; } } /// Restores the shipped default for the selected tier. public void ResetRatedMbps() { var tier = SelectedQuality; if (!_ratedMbpsByTier.ContainsKey(tier.Display)) return; _ratedMbpsByTier.Remove(tier.Display); _layoutStore.SaveRatedMbps(tier.Display, null); _ratedMbpsByTier[tier.Display] = RatedFor(tier); ApplyStreamQuality(tier); OnPropertyChanged(nameof(RatedMbps)); OnPropertyChanged(nameof(RateHint)); } // ─── Between-session suggestion (Models/RateHistory) ─── // // Never applied on its own. The creator is asked once per materially different answer and may say // "keep mine"; that refusal is remembered against the proposed value, so the same question is not // asked again until the evidence actually moves. private RateHistory.Proposal? _rateProposal; public bool HasRateProposal => _rateProposal is not null; public string? RateProposalText { get { if (_rateProposal is not { } proposal) return null; var tier = SelectedQuality.Display; return proposal.Raise ? $"Your last {RateHistory.Window} sessions on {tier} held {proposal.Mbps:0.#} Mbps. " + $"Start the next stream there instead of {RatedMbps:0.#}?" : $"Your last {RateHistory.Window} sessions on {tier} had to drop quality. " + $"Start the next stream at {proposal.Mbps:0.#} Mbps instead of {RatedMbps:0.#}?"; } } public ICommand AcceptRateProposalCommand { get; private set; } = null!; public ICommand DismissRateProposalCommand { get; private set; } = null!; private void InitializeRateProposalCommands() { AcceptRateProposalCommand = new RelayCommand(_ => { if (_rateProposal is not { } proposal) return; RatedMbps = proposal.Mbps; // explicit consent is the only path that changes it ClearRateProposal(); }); DismissRateProposalCommand = new RelayCommand(_ => { if (_rateProposal is { } proposal) _layoutStore.SaveDismissedRateProposal(SelectedQuality.Display, proposal.Mbps); ClearRateProposal(); }); } /// Recomputes the suggestion for the selected tier. Called at startup and whenever the tier /// or the rate changes; silent unless the evidence warrants a change and the creator has not already /// refused this exact number. internal void RefreshRateProposal() { _rateProposal = null; if (_layoutStore is not null) { var tier = SelectedQuality; var history = _layoutStore.LoadRateHistory(); // Before this run's first stream the encoder is unknown; the last session on this tier is // the honest guess, and evidence about a different encoder stays out either way. var lastForTier = history.LastOrDefault(entry => entry.Tier == tier.Display); var encoder = CurrentHealth.VideoEncoder ?? (string.IsNullOrEmpty(lastForTier.Encoder) ? null : lastForTier.Encoder); var suggestion = RateHistory.Suggest(history, tier.Display, encoder, RatedMbps); if (suggestion is { } candidate && _layoutStore.LoadDismissedRateProposal(tier.Display) != candidate.Mbps) { _rateProposal = candidate; } } OnPropertyChanged(nameof(HasRateProposal)); OnPropertyChanged(nameof(RateProposalText)); } private void ClearRateProposal() { _rateProposal = null; OnPropertyChanged(nameof(HasRateProposal)); OnPropertyChanged(nameof(RateProposalText)); } }