Files
LlamaCasty/ViewModels/MainViewModel.Streaming.cs
T
gramps 5a1a3c566a fix(18): stop ends everything — pills clear, pump/prep failures roll the session back
Creator ruling 2026-09-01 (stuck REC pill after the failed first recording):
- StopStream clears RecordPillOn + OnAirPillOn — intent resets with reality.
- OnFramePumpFailed: dispatcher-marshalled FULL rollback via StopStream (was:
  toast + Error-status only when live — record-only sessions zombied with
  IsRecording=true over a dead encoder; the 20:34 attempt proved it). Toast copy
  says 'Recording stopped' vs 'stream pipeline stopped'.
- Same zombie class in the three go-live prep failure branches: StreamStatus.Error
  limbo replaced by StopStream() (audio loop + recording + pills unwind; a created
  broadcast still gets the close-out).
- AudioMixer.StopLive made explicitly idempotent (Stop() twice, rollback paths that
  never reached StartLive).
- Seams: OnFramePumpFailed + IsRecording setter internal (InternalsVisibleTo; test
  pattern mirrors LayoutPathOverride).
ONE integration test: SessionTeardownTests (real window + temp DB: pump death with
lit pill -> no zombie, no End button, Offline). AudioPipelineTests confirmed to hang
STANDALONE (pre-existing, the declared-known audio class) — ai.md test-count
paragraph corrected to stop claiming a suite total that cannot currently be measured.
2026-09-01 21:15:57 -07:00

326 lines
12 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.Collections.ObjectModel;
using System.Windows;
using System.Windows.Input;
using System.Windows.Threading;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Encoder;
namespace ytLive.ViewModels;
public partial class MainViewModel : ViewModelBase
{
private readonly DispatcherTimer _liveTimer;
private readonly DispatcherTimer _healthPollTimer;
private StreamStatus _streamStatus = StreamStatus.Offline;
private StreamHealth _currentHealth = new();
private string _streamTitle = string.Empty;
private string _streamDescription = string.Empty;
private string _streamVisibility = "Private";
private string? _currentBroadcastId;
private string? _reusableStreamUrl;
private ReusableStream? _reusableStream;
private string? _healthIssueBanner;
private string _healthIssueBackground = "Transparent";
private string _windowTitle = "LlamaCasty";
private string _topBarBackground = "#16213e";
private string _previewGlowBrush = "Transparent";
private Thickness _previewGlowThickness = new(0);
private string _liveElapsedText = "00:00:00";
private double _recDotPulse = 1.0;
private TimeSpan _liveElapsed;
// ─── Recording (TASK 18): independent REC / ON-AIR pills. The pills are
// ─── intent; IsRecording / IsLive are reality. ON-AIR needs a sign-in.
private bool _recordPillOn;
private bool _onAirPillOn;
private bool _isRecording;
/// <summary>Resolved record folder — the configured choice, or Downloads (0th case).</summary>
private readonly FramePump _framePump;
public StreamStatus StreamStatus
{
get => _streamStatus;
set
{
if (SetProperty(ref _streamStatus, value))
{
OnPropertyChanged(nameof(IsOffline));
OnPropertyChanged(nameof(IsLive));
OnPropertyChanged(nameof(IsEditMode));
OnPropertyChanged(nameof(RecDotBrush));
OnPropertyChanged(nameof(RecTextBrush));
OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(OnAirBrush));
OnPropertyChanged(nameof(IsLivePrivate));
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(ShowLogInButton));
OnPropertyChanged(nameof(ShowEndStreamButton));
OnPropertyChanged(nameof(EndButtonText));
OnPropertyChanged(nameof(AccountStatusLightToolTip));
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
UpdateLiveVisuals();
}
}
}
public bool IsOffline => StreamStatus == StreamStatus.Offline;
public bool IsLive => StreamStatus == StreamStatus.Streaming;
public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
public string RecDotBrush => !IsRecording ? "#555555" : "#e94560";
public string RecTextBrush => (IsLive || IsRecording) ? "#ffffff" : "#888888";
public double RecDotOpacity => IsRecording ? _recDotPulse : 0.55;
public string OnAirBrush => !IsLive ? "#555555" : IsLivePrivate ? "#22c55e" : "#e94560";
// ─── REC / ON-AIR pills (TASK 18) ───
public bool RecordPillOn
{
get => _recordPillOn;
set
{
if (!SetProperty(ref _recordPillOn, value)) return;
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(CanStartSession));
}
}
public bool OnAirPillOn
{
get => _onAirPillOn;
set
{
if (value && !CanToggleOnAir) return; // needs a sign-in
if (!SetProperty(ref _onAirPillOn, value)) return;
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(CanStartSession));
}
}
/// <summary>The ON-AIR pill (and thus live streaming) needs a YouTube sign-in.</summary>
public bool CanToggleOnAir => IsConnected;
/// <summary>Whether the session is actively recording — the REC status light
/// turns green only when this is true (the pill is intent, this is reality).
/// Internal setter is a test seam (same pattern as LayoutPathOverride) so the
/// failure-rollback path can be exercised without running the real pipeline.</summary>
public bool IsRecording
{
get => _isRecording;
internal set
{
if (!SetProperty(ref _isRecording, value)) return;
OnPropertyChanged(nameof(RecDotBrush));
OnPropertyChanged(nameof(RecTextBrush));
OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(IsEditMode));
OnPropertyChanged(nameof(ShowEndStreamButton));
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(EndButtonText));
UpdateSessionTimer();
}
}
/// <summary>The primary button shows when there's something to start, but never
/// while a session is running — once recording/live the ONLY control is the End
/// button.</summary>
public bool ShowPrimaryStartButton =>
IsOffline && !IsRecording && !IsLive
&& (IsConnected || (RecordPillOn && !OnAirPillOn));
/// <summary>Either output selected makes the button actionable.</summary>
public bool CanStartSession => RecordPillOn || OnAirPillOn;
/// <summary>The End button shows while recording OR live.</summary>
public bool ShowEndStreamButton => IsLive || IsRecording;
/// <summary>The one button whose label changes: while recording it reads
/// "Stop Recording", while live "End Stream".</summary>
public string EndButtonText => IsRecording && !IsLive ? "Stop Recording" : "End Stream";
public bool ShowLogInButton => !IsConnected && IsOffline && !IsRecording;
public StreamHealth CurrentHealth
{
get => _currentHealth;
set => SetProperty(ref _currentHealth, value);
}
/// <summary>Report-by-exception YouTube health (TASK 5 item 3): non-null only
/// while a warning/error configuration issue exists — the banner's text.
/// good/ok/noData keep it null so nothing renders.</summary>
public string? HealthIssueBanner
{
get => _healthIssueBanner;
private set => SetProperty(ref _healthIssueBanner, value);
}
/// <summary>Banner background: app red (#8f1f1f) for an error issue, amber
/// (#b8860b) for a warning. Transparent when no banner is shown.</summary>
public string HealthIssueBackground
{
get => _healthIssueBackground;
private set => SetProperty(ref _healthIssueBackground, value);
}
public string StreamTitle
{
get => _streamTitle;
set => SetProperty(ref _streamTitle, value);
}
public string StreamDescription
{
get => _streamDescription;
set => SetProperty(ref _streamDescription, value);
}
public string StreamVisibility
{
get => _streamVisibility;
set
{
if (SetProperty(ref _streamVisibility, value))
{
OnPropertyChanged(nameof(RecDotBrush));
OnPropertyChanged(nameof(RecTextBrush));
OnPropertyChanged(nameof(OnAirBrush));
OnPropertyChanged(nameof(IsLivePrivate));
}
}
}
public string WindowTitle
{
get => _windowTitle;
set => SetProperty(ref _windowTitle, value);
}
public string TopBarBackground
{
get => _topBarBackground;
set => SetProperty(ref _topBarBackground, value);
}
public string PreviewGlowBrush
{
get => _previewGlowBrush;
set => SetProperty(ref _previewGlowBrush, value);
}
public Thickness PreviewGlowThickness
{
get => _previewGlowThickness;
set => SetProperty(ref _previewGlowThickness, value);
}
public string LiveElapsedText
{
get => _liveElapsedText;
set => SetProperty(ref _liveElapsedText, value);
}
// ─── Broadcast metadata pull-out (the "Text" tab on the Live screen) ───
/// <summary>The broadcast id of the current/last session; null until Go Live
/// succeeds. Gates the pull-out's Update button (nothing remote to patch).</summary>
public string? CurrentBroadcastId => _currentBroadcastId;
public Services.LiveBroadcastFormViewModel BroadcastForm { get; }
// ─── Resolution quality dropdown (bottom bar) ───
// Tiers offered to the creator before going live. First = default. The
// dropdown is disabled while live because YouTube stream resolution is
// immutable after stream creation (see TASKS.md compliance notes).
// The composition master frame. Every tier is an output rect + target
// resolution over this frame (see ai.md "Resolution tiers"): 16:9 tiers
// use the full frame; the vertical tier uses a centered 9:16 window and
// the preview dims the cropped strips (semi-crop).
private const double MasterFrameWidth = 1920;
private const double MasterFrameHeight = 1080;
public QualityOption[] QualityOptions { get; } =
{
new("1080p60", 60, 8.0, 1920, 1080),
new("1080p30", 30, 8.0, 1920, 1080),
new("720p60", 60, 6.0, 1280, 720),
new("720p30", 30, 6.0, 1280, 720),
new("Vertical 1080p60", 60, 8.0, 1080, 1920),
};
public string ResolutionHelp { get; } =
"Pick the highest resolution your upload bandwidth can comfortably sustain. " +
"Find your upload speed on your ISP plan or with a speed test (e.g. fast.com), " +
"then choose a tier your upload comfortably exceeds. 1080p60 is the default.";
private QualityOption _selectedQuality;
public QualityOption SelectedQuality
{
get => _selectedQuality;
set
{
if (SetProperty(ref _selectedQuality, value))
ApplyStreamQuality(value);
}
}
// Output frame of the selected tier over the 1920x1080 master. The strips
// outside it are dimmed in the preview so the cropped area stays visible.
public double OutputRectX { get; private set; }
public double OutputRectY { get; private set; }
public double OutputRectWidth { get; private set; }
public double OutputRectHeight { get; private set; }
public bool IsOutputCropped { get; private set; }
public string ResolutionBadgeText { get; private set; } = string.Empty;
public ObservableCollection<Rect> DimRects { get; } = new();
private void ApplyStreamQuality(QualityOption quality)
{
var health = CurrentHealth;
health.CurrentBitrate = quality.Bitrate;
health.FPS = quality.Fps;
OnPropertyChanged(nameof(CurrentHealth));
var scale = Math.Min(MasterFrameWidth / quality.Width, MasterFrameHeight / quality.Height);
var rectW = quality.Width * scale;
var rectH = quality.Height * scale;
var rectX = (MasterFrameWidth - rectW) / 2;
var rectY = (MasterFrameHeight - rectH) / 2;
OutputRectX = rectX;
OutputRectY = rectY;
OutputRectWidth = rectW;
OutputRectHeight = rectH;
DimRects.Clear();
if (rectX > 0) DimRects.Add(new Rect(0, 0, rectX, MasterFrameHeight));
if (rectX + rectW < MasterFrameWidth)
DimRects.Add(new Rect(rectX + rectW, 0, MasterFrameWidth - rectX - rectW, MasterFrameHeight));
if (rectY > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, rectY));
if (rectY + rectH < MasterFrameHeight)
DimRects.Add(new Rect(0, rectY + rectH, MasterFrameWidth, MasterFrameHeight - rectY - rectH));
IsOutputCropped = DimRects.Count > 0;
ResolutionBadgeText = $"{quality.Label} · {quality.Width}×{quality.Height}";
OnPropertyChanged(nameof(OutputRectX));
OnPropertyChanged(nameof(OutputRectY));
OnPropertyChanged(nameof(OutputRectWidth));
OnPropertyChanged(nameof(OutputRectHeight));
OnPropertyChanged(nameof(IsOutputCropped));
OnPropertyChanged(nameof(ResolutionBadgeText));
}
public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; }
public ICommand ChooseRecordFolderCommand { get; }
public ICommand ResetRecordFolderCommand { get; }
}