using System; using System.Windows; using ytLive.Helpers; using ytLive.Models; using ytLive.Services; using ytLive.Services.Encoder; namespace ytLive.ViewModels; /// /// TASK 33, slice 3: the session's step-down watch — policy feed, one encoder relaunch, one toast. /// /// Kept in its own file because it is the only place that both reads the pump's stats and acts on /// the session; the pure decision lives in , the sentence in /// . /// public partial class MainViewModel { private StepDownPlanner? _stepDownPlanner; private (int Width, int Height, int Fps, double BitrateMbps)? _encoderTierOverride; private bool _stepDownBusy; /// Starts the watch for this broadcast. Called once per session, before the pump starts. private void StartStepDownWatch() { var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput); _encoderTierOverride = null; _stepDownPlanner = new StepDownPlanner( new StepDownPolicy(quality.Width, quality.Height, quality.Fps, quality.Bitrate, floorBitrateMbps: RateLadder.For(quality).Minimum), _sessionStartedAtUtc ?? DateTime.UtcNow); _framePump.StatsReported += OnPumpStatsReported; } /// Ends the watch. Called from StopStream on every path, so a stale planner can never /// carry a spent budget into the next broadcast. private void StopStepDownWatch() { _framePump.StatsReported -= OnPumpStatsReported; _stepDownPlanner = null; _encoderTierOverride = null; _stepDownBusy = false; _screenCaptureManager.ShedLoad(false); // never carry a shed cadence into the next show } /// Runs on the PUMP thread, so it must never block: the restart it may trigger stops the /// very loop that raised this event. Decide here (pure and fast), act on the dispatcher. private void OnPumpStatsReported(object? sender, FramePumpStats stats) { if (_stepDownPlanner is not { } planner) return; var decision = planner.Observe(stats, DateTime.UtcNow); if (!decision.Acted) return; var dispatcher = Application.Current?.Dispatcher; if (dispatcher == null) return; _ = dispatcher.InvokeAsync(() => ApplyStepDownAsync(decision)); } private async void ApplyStepDownAsync(StepDownDecision decision) { var planner = _stepDownPlanner; if (planner == null || _stepDownBusy) return; _stepDownBusy = true; var landed = planner.Current; try { if (decision.Action == StepAction.ShedCaptureCadence) { _screenCaptureManager.ShedLoad(true); AppLog.Write($"Capture load shed: {decision.Why}"); return; } _encoderTierOverride = landed; AppLog.Write($"Quality step-down: {decision.Why}"); await _framePump.ChangeEncoderAsync( _audioMixer.StopLive, () => _audioMixer.StartLive(InstanceProfile.AudioPipeName(EncoderOptions.DefaultAudioPipeName))); // Show what is actually going out. The dropdown keeps the creator's choice — the policy // does not get to rewrite their intent, only the encoder's output. CurrentHealth.FPS = landed.Fps; CurrentHealth.CurrentBitrate = landed.BitrateMbps; ResolutionBadgeText = $"{landed.Width}×{landed.Height} @ {landed.Fps} fps · lowered"; OnPropertyChanged(nameof(CurrentHealth)); OnPropertyChanged(nameof(ResolutionBadgeText)); var copy = StepDownPlanner.Describe(decision, landed); if (copy.Length > 0) _notifications.Info("Quality lowered", copy); AppLog.Write($"Quality step-down complete: {StepDownPlanner.ReportLine(DateTime.Now, decision)}"); } catch (Exception ex) { AppLog.Write($"Quality step-down failed: {ex.Message}", AppLogMessageType.Error, AppLogErrorClass.Class2); } finally { _stepDownBusy = false; } } }