Files
LlamaCasty/ViewModels/MainViewModel.StepDown.cs
T
gramps 134103c08b feat(33): the step-down acts — one encoder relaunch, one toast, same URL
Slice 3. 589 green, 0 warnings.

- **`FramePump.ChangeEncoderAsync`** is a full pump stop/start, not an encoder swap. A swap leaves an
  in-flight submit pointing at a disposed process, and that faults the loop — which ends the
  broadcast, the one thing a quality step-down must never do. `StopAsync` already awaits the loop and
  flushes, so this reuses the tested path and costs one short freeze, which the ladder accepts.
- **`MainViewModel.StepDown`**: one planner per broadcast, subscribed to `StatsReported`, unsubscribed on
  every stop path. The pump thread only decides; the restart is posted to the dispatcher, because the
  act stops the loop that raised the event.
- **The creator's choice is not rewritten**: `_encoderTierOverride` changes what the encoder emits while
  the dropdown keeps showing what they picked, and the badge adds `· lowered` so the two never disagree
  silently.
- Copy comes from `StepDownPlanner.Describe` (machine cause owns it, upload cause blames the upload);
  the log gets the cause-tagged line for support.

Test: `EncoderRestartTests` runs a real pump with a fake encoder, changes the tier, and asserts a NEW
encoder started at the new fps/bitrate and produced a frame — the regression that would otherwise ship
as "it lowered the badge and the stream died".

Remaining for 33: shed our own capture cost before the fps step, and the session-report line.
2026-11-09 12:50:53 -08:00

95 lines
3.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Windows;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Encoder;
namespace ytLive.ViewModels;
/// <summary>
/// TASK 33, slice 3: the session's step-down watch — policy feed, one encoder relaunch, one toast.
///
/// <para>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 <see cref="StepDownPolicy"/>, the sentence in
/// <see cref="StepDownPlanner"/>.</para>
/// </summary>
public partial class MainViewModel
{
private StepDownPlanner? _stepDownPlanner;
private (int Width, int Height, int Fps, double BitrateMbps)? _encoderTierOverride;
private bool _stepDownBusy;
/// <summary>Starts the watch for this broadcast. Called once per session, before the pump starts.</summary>
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),
_sessionStartedAtUtc ?? DateTime.UtcNow);
_framePump.StatsReported += OnPumpStatsReported;
}
/// <summary>Ends the watch. Called from StopStream on every path, so a stale planner can never
/// carry a spent budget into the next broadcast.</summary>
private void StopStepDownWatch()
{
_framePump.StatsReported -= OnPumpStatsReported;
_stepDownPlanner = null;
_encoderTierOverride = null;
_stepDownBusy = false;
}
/// <summary>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.</summary>
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
{
_encoderTierOverride = landed;
AppLog.Write($"Quality step-down: {decision.Why}");
await _framePump.ChangeEncoderAsync();
// 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;
}
}
}