Files
LlamaCasty/ViewModels/MainViewModel.cs
T
gramps c41ff7fd0a refactor: split Socials out of MainViewModel — Commit 8/12
MainViewModel.Socials.cs: social-bar state (_socials/_socialBarFrame/
_socialValidator), bar props (HasSocials/SocialBarVisible/SocialBarDotBrush/
SocialBarGlowBrush/Socials/SocialBarTop), OpenSocialDialog + NotifySocialsChanged
+ RenderSocialBarFrame (SocialBarRenderer), fediverse heal
(HealFediverseSoftwareAsync/HealFediverseSoftwareInBackground),
SetSocialBarPosition/ToggleSocialBarPosition, OpenSocialDialogCommand.

BarOnBrush/BarOffBrush/CreateBrush stay in core (shared tokens used by
Audio/Trax partials); core keeps ctor command assignment, LoadLayout,
SaveLayoutNow, and the compositor socialBar seam.

Docs: index.md restructured — each landed partial (Account..Trax) now owns its
own table row, the core row shrinks to only what remains in MainViewModel.cs,
and the split tracker is a compact code<->commit map.

Verify: 0 warnings, 246 passed, 2 known failures only.
2026-08-30 08:34:10 -07:00

1891 lines
75 KiB
C#
Raw Permalink 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.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Microsoft.Win32;
using NAudio.CoreAudioApi;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Audio;
using ytLive.Services.Compositor;
using ytLive.Services.Encoder;
namespace ytLive.ViewModels;
public partial class MainViewModel : ViewModelBase
{
private readonly YouTubeAuthService _youtubeAuth;
private readonly YouTubeStreamService _youtubeStream;
private readonly YouTubeChatService _youtubeChat;
private readonly DispatcherTimer _liveTimer;
private readonly DispatcherTimer _healthPollTimer;
private SceneElement? _selectedElement;
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;
private string? _recordFolder;
/// <summary>Resolved record folder — the configured choice, or Downloads (0th case).</summary>
public string RecordFolderDisplay => _recordFolder ?? DefaultRecordFolder();
private string? _activeRecordPath;
private DateTime _recordStartTime;
private TimeSpan _recordLength;
private bool _isSettingsOpen;
private bool _isBugOpen;
private bool _isFeatureOpen;
private bool _isAboutOpen;
private bool _isLicensesOpen;
private string _overlayTitle = string.Empty;
private string _bugReportText = string.Empty;
private string _bugReportEmail = string.Empty;
private string _featureRequestText = string.Empty;
private string _featureRequestEmail = string.Empty;
private LayoutStore _layoutStore;
private string? _activeLayoutPath;
private DispatcherTimer? _saveDebounce;
private bool _isLoading;
// Toasts (TASK 24): non-blocking user-facing messages. Parameterless ctor
// news up the real service; the VM is built from XAML so no DI seam exists.
private readonly INotificationService _notifications = new NotificationService();
// Live frame pipeline (TASK 4 ship step 5): composites the active scene at the
// tier's FPS and paces frames into the encoder. The RTMP URL seam stays null
// until the live-stream create flow (TASK 5) supplies the reusable stream URL.
private readonly Func<string?> _rtmpUrlProvider;
private readonly FramePump _framePump;
private readonly TransitionService _transition = new();
private readonly ChatBoxRenderer _chatBoxRenderer;
private WebView2Manager? _webView2Manager;
// Mock chat messages for the ChatBox preview — branded placeholders so the
// creator sees where the overlay lands before going live.
private static readonly IReadOnlyList<ChatMessage> MockChatMessages = new[]
{
new ChatMessage { AuthorName = "LlamaCasty", Message = "welcome to the stream!", Timestamp = DateTime.Now.AddMinutes(-4), IsMember = true },
new ChatMessage { AuthorName = "LlamaCastyFan", Message = "$10 — LlamaCasty is the best!", Timestamp = DateTime.Now.AddMinutes(-3), IsSuperChat = true },
new ChatMessage { AuthorName = "GamerDude", Message = "what streaming software is this?", Timestamp = DateTime.Now.AddMinutes(-2) },
new ChatMessage { AuthorName = "LlamaCasty", Message = "thanks for watching! llamacasty.com", Timestamp = DateTime.Now.AddMinutes(-1), IsMember = true },
new ChatMessage { AuthorName = "LlamaDog", Message = "Subscribe so they can buy the subscription at llamacasty.com!", Timestamp = DateTime.Now, IsMember = true },
};
// Chat fade timer: clears chat overlay after N seconds of inactivity.
private System.Windows.Threading.DispatcherTimer? _chatFadeTimer;
// Branding flash (monetization): a full-frame "made with LlamaCasty!" shown
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
// BrandFlashEnabled = false. See ai.md "Monetization".
private readonly DispatcherTimer _brandFlashTimer;
private readonly DispatcherTimer _brandFlashOffTimer;
private bool _brandFlashEnabled = true;
private bool _brandFlashActive;
private const string SupportEmail = "gramps@llamachile.shop";
// The creator-hub link targets (About overlay) — public so the hub's
// integration test can assert they point at real destinations.
public const string ChannelUrl = "https://youtube.com/@llamachileshop";
public const string MastodonUrl = "https://mastodon.llamachile.tube/@gramps";
public const string PremiumUrl = "https://buy.polar.sh/polar_cl_ueMy8AyAClO0o0yo19IroZwKTbD587gzUsjEW3bFapw";
public const string CoffeeUrl = "https://buymeacoffee.com/llamachiley";
public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}";
/// <summary>The About hub's Unlock Premium button lights up only once the
/// billing URL is set (Polar — polar.sh, pending TASK 10 implementation).</summary>
public bool IsPremiumAvailable => PremiumUrl.Length > 0;
/// <summary>Full in-app licensing/legal text, loaded on demand from the
/// shipped THIRD-PARTY-NOTICES.txt (never opens the OS viewer).</summary>
public string LicensesText { get; private set; } = string.Empty;
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
public SceneElement? SelectedElement
{
get => _selectedElement;
set
{
if (_selectedElement != null)
_selectedElement.PropertyChanged -= OnSelectedElementPropertyChanged;
if (SetProperty(ref _selectedElement, value))
{
OnPropertyChanged(nameof(IsWebcamSelected));
OnPropertyChanged(nameof(IsElementsPanelEmpty));
if (_selectedElement != null)
_selectedElement.PropertyChanged += OnSelectedElementPropertyChanged;
}
}
}
private void OnSelectedElementPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(SceneElement.UseDefaultBackground))
{
UpdateBackgroundImage();
RefreshSnapshotsAsync();
ScheduleSave();
}
}
/// <summary>The Add → YouTube Chat menu item: enabled only while no chat layer exists
/// anywhere — one chat overlay per layout.</summary>
public bool CanAddYouTubeChat
=> StagedScene != null
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox));
private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
private static readonly SolidColorBrush BarOffBrush = CreateBrush("#e94560");
private static SolidColorBrush CreateBrush(string hex)
=> (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!;
/// <summary>True when the Elements panel should show the "No configurable elements" hint.</summary>
public bool IsElementsPanelEmpty => SelectedElement is { } el
&& !el.IsDraggable && !el.IsWebcam
&& el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox };
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).</summary>
public bool IsRecording
{
get => _isRecording;
private 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 bool ShowChatInactiveMessage => !IsLive;
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
// cadence timer is stopped and any active flash is hidden immediately.
public bool BrandFlashEnabled
{
get => _brandFlashEnabled;
set
{
if (!SetProperty(ref _brandFlashEnabled, value)) return;
if (value)
{
if (IsLive) StartBrandFlashTimer();
}
else
{
_brandFlashTimer.Stop();
_brandFlashOffTimer.Stop();
BrandFlashActive = false;
}
}
}
public bool BrandFlashActive
{
get => _brandFlashActive;
private set => SetProperty(ref _brandFlashActive, value);
}
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);
}
// Overlay panels
public bool IsSettingsOpen
{
get => _isSettingsOpen;
private set => SetPanel(ref _isSettingsOpen, value);
}
public bool IsBugOpen
{
get => _isBugOpen;
private set => SetPanel(ref _isBugOpen, value);
}
public bool IsFeatureOpen
{
get => _isFeatureOpen;
private set => SetPanel(ref _isFeatureOpen, value);
}
public bool IsAboutOpen
{
get => _isAboutOpen;
private set => SetPanel(ref _isAboutOpen, value);
}
/// <summary>About hub sub-view: the full licensing/legal texts panel
/// (in-app scrolling — no Notepad, no browser tab).</summary>
public bool IsLicensesOpen
{
get => _isLicensesOpen;
set
{
if (SetProperty(ref _isLicensesOpen, value))
OnPropertyChanged(nameof(IsHubVisible));
}
}
public bool IsAnyOverlayOpen => IsSettingsOpen || IsBugOpen || IsFeatureOpen || IsAboutOpen;
public string OverlayTitle
{
get => _overlayTitle;
private set => SetProperty(ref _overlayTitle, 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; }
// Window geometry pass-through (code-behind owns the WPF specifics).
public (double Left, double Top, double Width, double Height, string State)? RestoreWindowState()
=> _layoutStore.LoadWindowState();
public void PersistWindowState(double left, double top, double width, double height, string state)
=> _layoutStore.SaveWindowState(left, top, width, height, state);
// ─── 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 string BugReportText
{
get => _bugReportText;
set => SetProperty(ref _bugReportText, value);
}
public string BugReportEmail
{
get => _bugReportEmail;
set => SetProperty(ref _bugReportEmail, value);
}
public string FeatureRequestText
{
get => _featureRequestText;
set => SetProperty(ref _featureRequestText, value);
}
public string FeatureRequestEmail
{
get => _featureRequestEmail;
set => SetProperty(ref _featureRequestEmail, value);
}
// Commands
public ICommand AddSceneCommand { get; }
public ICommand AddSourceCommand { get; }
public ICommand AddImageCommand { get; }
public ICommand RemoveSourceCommand { get; }
public ICommand EditElementCommand { get; }
public ICommand ToggleElementVisibilityCommand { get; }
public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; }
public ICommand ChooseRecordFolderCommand { get; }
public ICommand ResetRecordFolderCommand { get; }
public ICommand OpenSettingsCommand { get; }
public ICommand OpenBugCommand { get; }
public ICommand OpenFeatureCommand { get; }
public ICommand OpenAboutCommand { get; }
public ICommand CloseOverlayCommand { get; }
public ICommand SubmitBugCommand { get; }
public ICommand SubmitFeatureCommand { get; }
public ICommand OpenChannelCommand { get; }
public ICommand OpenMastodonCommand { get; }
public ICommand OpenCoffeeCommand { get; }
public ICommand OpenPremiumCommand { get; }
public ICommand OpenLicensesCommand { get; }
public ICommand ActivateLicenseCommand { get; }
public ICommand OpenLicenseEntryCommand { get; }
public ICommand OpenHotkeyConfigCommand { get; }
public ICommand ChangeAccountCommand { get; }
public ICommand LogoutCommand { get; }
public MainViewModel()
{
AppLog.Write("MainViewModel ctor begin");
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret,
sessionChanged: ch => TokenStore.Save(ch));
_youtubeStream = new YouTubeStreamService(_youtubeAuth);
_youtubeChat = new YouTubeChatService(_youtubeAuth);
AppLog.Write("MainViewModel ctor: services created");
_selectedQuality = QualityOptions[0];
ApplyStreamQuality(_selectedQuality);
_youtubeChat.MessageReceived += OnChatMessageReceived;
_liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_liveTimer.Tick += OnLiveTimerTick;
_volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_volumeFlashTimer.Tick += (_, _) => EndVolumeFlash();
_gameVolumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_gameVolumeFlashTimer.Tick += (_, _) => EndGameVolumeFlash();
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
_saveDebounce.Tick += (_, _) => SaveLayoutNow();
_brandFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(300) };
_brandFlashTimer.Tick += OnBrandFlashTimerTick;
_brandFlashOffTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(750) };
_brandFlashOffTimer.Tick += (_, _) => { _brandFlashOffTimer.Stop(); BrandFlashActive = false; };
_healthPollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) };
_healthPollTimer.Tick += OnHealthPollTick;
Scenes.CollectionChanged += OnScenesChanged;
AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty));
AddSourceCommand = new RelayCommand(parameter => AddSource(parameter));
AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToStagedSceneAsync());
AddImageCommand = new RelayCommand(_ => AddImage());
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
ToggleElementVisibilityCommand = new RelayCommand(element => ToggleElementVisibility(element as SceneElement));
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInStagedScene(), _ => CanShowWebcamInStagedScene);
OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackgroundCaptureAsync());
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackgroundAutoCapture());
SetBackgroundDisplayCommand = new RelayCommand(display => SetBackgroundCapture(display as DisplayInfo));
ToggleShowDesktopCommand = new RelayCommand(_ => ToggleBackgroundShowDesktop());
BrowseBackgroundCommand = new RelayCommand(_ => BrowseBackground());
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
ToggleGameMuteCommand = new RelayCommand(_ => ToggleGameMute());
OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone());
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
OpenAboutCommand = new RelayCommand(_ => ShowOverlay(nameof(IsAboutOpen), "About"));
CloseOverlayCommand = new RelayCommand(_ => ShowOverlay());
SubmitBugCommand = new RelayCommand(_ => SubmitBug());
SubmitFeatureCommand = new RelayCommand(_ => SubmitFeature());
OpenChannelCommand = new RelayCommand(_ => OpenUrl(ChannelUrl));
OpenMastodonCommand = new RelayCommand(_ => OpenUrl(MastodonUrl));
OpenCoffeeCommand = new RelayCommand(_ => OpenUrl(CoffeeUrl));
OpenPremiumCommand = new RelayCommand(_ => OpenUrl(PremiumUrl), _ => IsPremiumAvailable);
OpenLicensesCommand = new RelayCommand(_ => ShowLicenses());
StartStreamCommand = new RelayCommand(_ => StartSession());
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive || IsRecording);
ChooseRecordFolderCommand = new RelayCommand(_ => ChooseRecordFolder());
ResetRecordFolderCommand = new RelayCommand(_ => ResetRecordFolder());
ActivateLicenseCommand = new RelayCommand(_ => _ = ActivateLicenseAsync(), _ => !IsLicenseValidating);
OpenLicenseEntryCommand = new RelayCommand(_ => IsLicenseEntryOpen = !IsLicenseEntryOpen);
OpenHotkeyConfigCommand = new RelayCommand(_ => OpenHotkeyConfig());
ChangeAccountCommand = new RelayCommand(_ => _ = ChangeAccountAsync());
LogoutCommand = new RelayCommand(_ => _ = SignOutYouTubeAsync());
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
_activeLayoutPath = _layoutStore.ActivePath;
_recordFolder = _layoutStore.LoadRecordFolder();
// Load saved hotkey bindings (filled with defaults for any unbound actions).
_hotkeyBindings = _layoutStore.LoadHotkeyBindings();
BroadcastForm = new Services.LiveBroadcastFormViewModel(
() => _layoutStore,
() => _youtubeStream,
() => CurrentBroadcastId,
() => IsLive);
// Restore the last picked mic so the mixer's first capture attaches to
// the same, already-vetted device (green dot) or reports it missing
// (yellow) instead of silently falling back to the default.
MicSourceName = _layoutStore.LoadMicSourceName();
_cameraEnumerator = new MediaCaptureCameraEnumerator();
_cameraManager = new CameraManager(
_cameraEnumerator,
id => new MediaCaptureFrameSource(id),
System.Windows.Application.Current?.Dispatcher);
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
_cameraManager.CameraFailed += OnCameraFailed;
_microphoneEnumerator = new WinRtMicrophoneEnumerator();
// The mixer consumes these Func seams live each mix tick, so the volume
// sliders and mute buttons are stream-honest (they also update the
// meters' display). Before this wiring the gains defaulted to unity and
// the controls were decorative.
_gainProvider = new AudioGainProvider(
() => MicMuted,
() => MicVolume,
() => GameMuted,
() => GameAudioVolume);
_audioMixer = new AudioMixer(
new WasapiMicAudioSource(() => MicSourceName),
new WasapiLoopbackAudioSource(),
message => AppLog.Write(message),
micGain: _gainProvider.MicGain,
loopbackGain: _gainProvider.LoopbackGain);
_audioMixer.MicLevelChanged += OnMicLevelChanged;
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
_audioMixer.MicConnected += OnMicConnected;
_audioMixer.MicFailed += OnMicFailed;
_musicPlayer.PlaybackEnded += OnTraxPlaybackEnded;
_ = StartMicCaptureAsync();
_fullScreenDetector = new Win32FullScreenDetector();
_fullScreenDetector.FullscreenMonitorChanged += OnFullscreenMonitorChanged;
foreach (var display in _fullScreenDetector.GetDisplays())
Displays.Add(display);
_screenCaptureFactory = new ScreenCaptureSourceFactory(
() => new WindowInteropHelper(System.Windows.Application.Current?.MainWindow).Handle);
_screenCaptureManager = new ScreenCaptureManager(
_screenCaptureFactory.Resolve,
System.Windows.Application.Current?.Dispatcher);
_screenCaptureManager.PreviewBitmapChanged += OnScreenPreviewBitmapChanged;
_screenCaptureManager.CaptureFailed += (key, message) =>
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}");
_chatBoxRenderer = new ChatBoxRenderer();
_rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL
_framePump = new FramePump(
sceneProvider: () => StagedScene,
frameResolver: ResolveOutputFrame,
compositorOptions: BuildCompositorOptions,
encoderOptions: BuildEncoderOptions,
encoderFactory: () => new FfmpegEncoder(new FfmpegLocator()),
log: message => AppLog.Write(message),
socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom),
transition: _transition,
fromSceneProvider: () => LiveScene);
_framePump.Failed += OnFramePumpFailed;
_framePump.HealthUpdated += OnFramePumpHealthUpdated;
LoadLayout();
// The cached reusable stream gives the pump its RTMP URL the moment go-live
// starts (the pump reads the URL once at startup — no waiting on YouTube).
_reusableStream = _layoutStore.LoadReusableStream();
_reusableStreamUrl = _reusableStream?.RtmpUrl;
_ = LoadSavedSessionAsync();
_ = ValidateLicenseAtStartupAsync();
AppLog.Write("MainViewModel ctor end");
}
private static string DefaultLayoutPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ytLlive",
"ytLlive.db");
// Test seam: a real MainWindow test must never read or write the user's
// real layout DB (it would persist test sources over real ones). Tests set
// this to a temp path before constructing MainWindow and reset it after.
internal static string? LayoutPathOverride { get; set; }
private void LoadLayout()
{
_isLoading = true;
try
{
AppLog.Write("LoadLayout begin");
var scenes = _layoutStore.Load();
Scenes.Clear();
foreach (var scene in scenes)
Scenes.Add(scene);
if (Scenes.Count == 0)
{
foreach (var name in SceneCatalog.All)
AddScene(name);
}
HealBackgrounds();
RefreshHotkeyLabels();
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
{
ClampWebcamToBounds(config, scene.Name);
HealLegacySquareRect(config);
}
// One-time heal: ChatBox layers created before the "YouTube Chat"
// default still carry the old "Chat" name — creator renames untouched.
foreach (var scene in Scenes)
foreach (var source in scene.Elements.OfType<Source>())
if (source.Type == SourceType.ChatBox
&& string.Equals(source.Name, "Chat", StringComparison.OrdinalIgnoreCase))
source.Name = "YouTube Chat";
AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded");
}
finally
{
_isLoading = false;
}
StagedScene = Scenes.FirstOrDefault();
UpdateActiveBackground();
UpdateActiveChatBoxPreviews();
StartMockChatPreview();
UpdateBackgroundImage();
ReacquireWebcam();
ReacquireScreenCaptures();
RegisterLoadedWebSources();
_socials = _layoutStore.Socials;
RenderSocialBarFrame();
HealFediverseSoftwareInBackground();
OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(SocialBarVisible));
OnPropertyChanged(nameof(SocialBarDotBrush));
_music = _layoutStore.Music;
LoadTrax(play: false);
var (transType, transDur) = _layoutStore.LoadTransitionSettings();
if (Enum.TryParse<TransitionType>(transType, true, out var parsed))
SelectedTransitionType = parsed;
TransitionDurationMs = transDur;
BroadcastForm.Load();
ScheduleSave();
AppLog.Write("LoadLayout end");
}
private void RegisterLoadedWebSources()
{
if (_webView2Manager == null) return;
foreach (var source in Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.WebSource))
{
_webView2Manager.Register(source);
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Source.WebUri))
_webView2Manager.OnWebUriChanged(source);
};
}
}
public void InitWebView2(Panel hostPanel)
{
_webView2Manager = new WebView2Manager(hostPanel, System.Windows.Application.Current.Dispatcher);
_webView2Manager.PreviewBitmapChanged += OnWebView2PreviewBitmapChanged;
RegisterLoadedWebSources();
}
private void OnWebView2PreviewBitmapChanged(string sourceId, WriteableBitmap bitmap)
{
foreach (var scene in Scenes)
foreach (var el in scene.Elements.OfType<Source>().Where(s => s.Id == sourceId))
el.VideoImageSource = bitmap;
}
public void Shutdown()
{
_saveDebounce?.Stop();
SaveLayoutNow();
_audioMixer.Dispose();
_framePump.Dispose();
_cameraManager.Dispose();
_screenCaptureManager.Dispose();
_webView2Manager?.Dispose();
_layoutStore.Dispose();
_musicPlayer.Dispose();
_youtubeChat.Dispose();
}
public void SaveLayoutNow()
{
_saveDebounce?.Stop();
try
{
_layoutStore.Save(Scenes, _webcam, _socials, _music);
_layoutStore.SaveTransitionSettings(
SelectedTransitionType.ToString(), TransitionDurationMs);
}
catch (Exception ex)
{
Debug.WriteLine($"Layout save failed: {ex.Message}");
}
}
private void ScheduleSave()
{
if (_isLoading || _saveDebounce == null) return;
_saveDebounce.Stop();
_saveDebounce.Start();
}
private void BeginEditElement(SceneElement? element)
{
if (element == null) return;
element.IsEditing = true;
}
private void ToggleElementVisibility(SceneElement? element)
{
if (element == null) return;
element.IsVisible = !element.IsVisible;
if (element is Source { IsBackground: true })
OnPropertyChanged(nameof(BackgroundVisible));
}
private void AddSource(object? parameter)
{
var scene = StagedScene;
if (scene == null) return;
var sourceType = parameter is SourceType typed
? typed
: Enum.TryParse<SourceType>(parameter?.ToString(), true, out var parsed) ? parsed : SourceType.Image;
var baseName = sourceType switch
{
SourceType.Image => "Image",
SourceType.TextOverlay => "Text",
SourceType.Countdown => "Countdown",
SourceType.WebSource => "Web Resource",
SourceType.ChatBox => "YouTube Chat",
_ => "Layer",
};
// The (+) menu only adds regular layers. The Background is a locked
// per-screen layer managed by EnsureBackground/NormalizeBackgrounds —
// never user-created, never deleted. YouTube Chat is one-per-layout.
if (sourceType is SourceType.DisplayCapture or SourceType.WindowCapture or SourceType.Background)
return;
if (sourceType == SourceType.ChatBox && !CanAddYouTubeChat)
return;
// Web resources use "Web Resource-N" naming (0-indexed per scene)
var webCount = scene.Elements.OfType<Source>().Count(s => s.Type == SourceType.WebSource);
var source = new Source
{
Name = sourceType == SourceType.WebSource
? $"{baseName}-{webCount}"
: NextSourceName(scene, baseName),
Type = sourceType,
X = sourceType == SourceType.ChatBox ? 1400 : 0,
Y = sourceType == SourceType.ChatBox ? 20 : 0,
Width = sourceType == SourceType.WebSource ? 640 : sourceType == SourceType.ChatBox ? 500 : 0,
Height = sourceType == SourceType.WebSource ? 480 : sourceType == SourceType.ChatBox ? 648 : 0,
};
scene.Elements.Add(source);
if (sourceType == SourceType.WebSource)
{
_webView2Manager?.Register(source);
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Source.WebUri))
_webView2Manager?.OnWebUriChanged(source);
};
}
if (sourceType == SourceType.ChatBox)
{
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor)
or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity)
or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps)
or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor)
or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily)
or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height))
UpdateChatBoxPreview(source);
};
UpdateChatBoxPreview(source);
}
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
}
// Duplicate resource names get an incrementing suffix with no space: Image,
// Image2, Image3… The next free number is derived from the names actually in
// the scene, so deleting a middle resource never collides with a survivor.
private static string NextSourceName(Scene scene, string baseName)
{
var taken = scene.Elements.OfType<Source>()
.Select(s => s.Name)
.Where(n => string.Equals(n, baseName, StringComparison.OrdinalIgnoreCase)
|| (n.Length > baseName.Length
&& n.StartsWith(baseName, StringComparison.OrdinalIgnoreCase)
&& int.TryParse(n.Substring(baseName.Length), out _)))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!taken.Contains(baseName)) return baseName;
for (var i = 2; ; i++)
if (!taken.Contains($"{baseName}{i}"))
return $"{baseName}{i}";
}
private void AddImage()
{
var scene = StagedScene;
if (scene == null) return;
var candidates = BuildImageCandidates();
if (candidates.Count == 0)
{
var bytes = PickImageBytes("Choose an image");
if (bytes != null) AddImageSource(bytes);
return;
}
var dialog = new ReuseImageDialog(new ReuseImageViewModel(candidates))
{
Owner = Application.Current.MainWindow
};
if (dialog.ShowDialog() != true) return;
if (dialog.WantsNew)
{
var bytes = PickImageBytes("Choose an image");
if (bytes != null) AddImageSource(bytes);
}
else
{
AddReusedImage(dialog.PickedAssetId!);
}
}
private List<ReuseImageCandidate> BuildImageCandidates()
{
var candidates = new List<ReuseImageCandidate>();
foreach (var scene in Scenes)
foreach (var source in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.Image && !string.IsNullOrWhiteSpace(s.AssetId)))
{
candidates.Add(new ReuseImageCandidate
{
SceneName = scene.Name,
SourceName = source.Name,
AssetId = source.AssetId!
});
}
return candidates;
}
private byte[]? PickImageBytes(string title)
{
var dialog = new OpenFileDialog
{
Title = title,
Filter = "Image files (*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp)|*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp|All files (*.*)|*.*"
};
if (dialog.ShowDialog() != true) return null;
try
{
return File.ReadAllBytes(dialog.FileName);
}
catch (Exception ex)
{
_notifications.Warning("Couldn't read that image", ex.Message);
return null;
}
}
private string? AddAsset(byte[] bytes)
{
try
{
var image = ImageCache.FromBytes(bytes);
var id = _layoutStore.UpsertAsset(bytes, image?.PixelWidth ?? 0, image?.PixelHeight ?? 0);
if (id != null && image != null) ImageCache.Put(id, image);
return id;
}
catch (Exception ex)
{
Debug.WriteLine($"Asset store failed: {ex.Message}");
return null;
}
}
private void AddImageSource(byte[] bytes)
{
if (bytes.Length == 0) return;
var assetId = AddAsset(bytes);
if (assetId != null) AddReusedImage(assetId);
}
private void AddReusedImage(string assetId)
{
var scene = StagedScene;
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
var source = new Source { Name = NextSourceName(scene, "Image"), Type = SourceType.Image, AssetId = assetId };
var image = ImageCache.Get(assetId);
if (image != null)
{
var scale = Math.Min(640.0 / image.PixelWidth, 480.0 / image.PixelHeight);
source.Width = image.PixelWidth * scale;
source.Height = image.PixelHeight * scale;
source.X = (1920 - source.Width) / 2;
source.Y = (1080 - source.Height) / 2;
}
scene.Elements.Add(source);
SelectedElement = source;
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
}
// Removes an element from the active scene. For a webcam config this drops the
// scene's usage and releases one camera reference (CameraManager stops the
// session when the last using scene lets go). When the last config anywhere is
// removed, the webcam identity is cleared too — re-adding opens the picker
// again instead of silently resurrecting the old camera.
private void RemoveElement(SceneElement? element)
{
var scene = StagedScene;
if (scene == null || element == null) return;
if (element is Source { IsBackground: true }) return;
if (element is WebcamSceneConfig && _webcam != null)
{
if (SelectedElement == element)
SelectedElement = null;
_ = _cameraManager.ReleaseAsync(_webcam.DeviceId);
}
if (element is Source { Type: SourceType.WebSource } webSource)
_webView2Manager?.Unregister(webSource.Id);
scene.Elements.Remove(element);
if (element is WebcamSceneConfig && _webcam != null
&& !Scenes.Any(s => s.Elements.OfType<WebcamSceneConfig>().Any()))
{
_webcam = null;
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
}
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
}
private void SetPanel(ref bool field, bool value, [System.Runtime.CompilerServices.CallerMemberName] string? propertyName = null)
{
if (SetProperty(ref field, value, propertyName))
OnPropertyChanged(nameof(IsAnyOverlayOpen));
}
private void ShowOverlay(string? panel = null, string title = "")
{
IsSettingsOpen = panel == nameof(IsSettingsOpen);
IsBugOpen = panel == nameof(IsBugOpen);
IsFeatureOpen = panel == nameof(IsFeatureOpen);
IsAboutOpen = panel == nameof(IsAboutOpen);
OverlayTitle = title;
if (panel != nameof(IsAboutOpen))
IsLicensesOpen = false;
}
/// <summary>Shows the in-app licensing panel, loading the full texts from
/// the shipped THIRD-PARTY-NOTICES.txt on first open (never the OS viewer).
/// Falls back to a "not found" note if the file is missing so the panel
/// never hangs on nothing.</summary>
private void ShowLicenses()
{
if (string.IsNullOrEmpty(LicensesText))
{
try
{
var path = Path.Combine(AppContext.BaseDirectory, "THIRD-PARTY-NOTICES.txt");
LicensesText = File.Exists(path)
? File.ReadAllText(path)
: "THIRD-PARTY-NOTICES.txt not found next to the executable.";
}
catch (Exception ex)
{
LicensesText = $"Could not read THIRD-PARTY-NOTICES.txt: {ex.Message}";
}
}
IsLicensesOpen = true;
}
private void SubmitBug()
{
var body = string.Join(Environment.NewLine,
BugReportText.Trim(),
string.Empty,
$"LlamaCasty {AppVersionLabel}",
string.IsNullOrWhiteSpace(BugReportEmail) ? string.Empty : $"Reply-to: {BugReportEmail.Trim()}");
ComposeEmail("[LlamaCasty Bug Report]", body);
ShowOverlay();
}
private void SubmitFeature()
{
var body = string.Join(Environment.NewLine,
FeatureRequestText.Trim(),
string.Empty,
$"LlamaCasty {AppVersionLabel}",
string.IsNullOrWhiteSpace(FeatureRequestEmail) ? string.Empty : $"Reply-to: {FeatureRequestEmail.Trim()}");
ComposeEmail("[LlamaCasty Feature Request]", body);
ShowOverlay();
}
private static void ComposeEmail(string subject, string body)
{
var uri = $"mailto:{SupportEmail}?subject={Uri.EscapeDataString(subject)}&body={Uri.EscapeDataString(body)}";
OpenUrl(uri);
}
private static void OpenUrl(string url)
{
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
private void OnChatMessageReceived(ChatMessage message)
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
ChatMessages.Add(message);
if (ChatMessages.Count > 500)
ChatMessages.RemoveAt(0);
// Reset chat fade timer on every message
ResetChatFadeTimer();
StopMockChatPreview();
// Switch ChatBox previews from mock to real messages
if (StagedScene != null)
foreach (var chatBox in StagedScene.Elements.OfType<Source>().Where(s => s.Type == SourceType.ChatBox))
UpdateChatBoxPreview(chatBox);
});
}
private void ResetChatFadeTimer()
{
_chatFadeTimer?.Stop();
var chatBoxes = Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox && s.ChatFadeSeconds > 0).ToList();
if (chatBoxes.Count == 0) return;
var delay = chatBoxes.Min(s => s.ChatFadeSeconds);
_chatFadeTimer = new System.Windows.Threading.DispatcherTimer
{ Interval = TimeSpan.FromSeconds(delay) };
_chatFadeTimer.Tick += (_, _) =>
{
_chatFadeTimer?.Stop();
_chatFadeTimer = null;
ChatMessages.Clear();
foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox))
UpdateChatBoxPreview(chatBox);
StartMockChatPreview();
};
_chatFadeTimer.Start();
}
/// <summary>Entry point for the primary Start button (and the start/stop hotkey):
/// routes to a record session or the YouTube go-live dialog by the pill state.</summary>
private void StartSession()
{
if (IsLive || IsRecording) return;
var streaming = OnAirPillOn;
var recording = RecordPillOn;
if (!streaming && !recording) return;
if (!streaming)
{
BeginRecordOnly();
return;
}
BeginGoLive(recording);
}
/// <summary>Record-only (REC pill on, ON-AIR off): no YouTube dialog — recording
/// is local and works signed-out. Generates the auto-named file and starts the
/// pipeline immediately.</summary>
private async void BeginRecordOnly()
{
var path = StartRecordFile();
if (path == null)
{
_notifications.Error("Couldn't record", "No record output path could be created.");
return;
}
_recordStartTime = DateTime.Now;
_liveElapsed = TimeSpan.Zero;
_recordLength = TimeSpan.Zero;
ResetHealth(StreamStatus.Offline);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
IsRecording = true;
await _framePump.StartAsync();
}
private void BeginGoLive(bool alsoRecord)
{
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
{
StreamTitle = BroadcastForm.Title,
StreamDescription = BroadcastForm.Description,
};
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
if (window.ShowDialog() == true)
{
StreamTitle = dialog.StreamTitle;
StreamDescription = dialog.StreamDescription;
StreamVisibility = dialog.Visibility; // always "Private" (ship step 7)
BroadcastForm.CaptureGoLive(dialog.StreamTitle, dialog.StreamDescription, DateTime.UtcNow);
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
? "LlamaCasty"
: $"{dialog.StreamTitle} — LlamaCasty";
StreamStatus = StreamStatus.Streaming;
ResetHealth(StreamStatus.Streaming);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
_liveElapsed = TimeSpan.Zero;
_recordLength = TimeSpan.Zero;
var recordStarted = false;
if (alsoRecord)
{
if (StartRecordFile() == null)
{
_notifications.Error("Couldn't record",
"No record output path could be created — streaming continues without recording.");
}
else
{
_recordStartTime = DateTime.Now;
recordStarted = true;
}
}
IsRecording = recordStarted;
_ = PrepareAndStartLiveAsync();
}
}
/// <summary>Creates the record file (auto-named <c>ty-…-0000.mp4</c> in the record
/// folder) and returns its path, or null when the folder can't be prepared.</summary>
private string? StartRecordFile()
{
try
{
var folder = _recordFolder ?? DefaultRecordFolder();
Directory.CreateDirectory(folder);
var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
var path = Path.Combine(folder, name);
_activeRecordPath = path;
return path;
}
catch (Exception ex)
{
AppLog.Write(ex, "Recording: failed to prepare output path");
return null;
}
}
/// <summary>One-click go-live (TASK 5): ensure the reusable stream, create a
/// broadcast bound to it, then start the frame pump. The pump reads the RTMP
/// URL once at startup (FramePump.StartAsync), so the stream URL must be set
/// before the pump starts — hence the ordering here. Failures surface as an
/// error health state, never a crash.</summary>
private async Task PrepareAndStartLiveAsync()
{
try
{
var stream = await _youtubeStream.GetOrCreateReusableStreamAsync();
if (stream == null)
{
AppLog.Write("Reusable stream unavailable; check the OAuth session");
_notifications.Error("Couldn't go live",
"The reusable stream is unavailable — check the YouTube sign-in and try again.");
StreamStatus = StreamStatus.Error;
return;
}
_reusableStream = stream;
_reusableStreamUrl = stream.RtmpUrl;
_layoutStore.SaveReusableStream(stream);
_currentBroadcastId = await _youtubeStream.CreateBroadcast(
StreamTitle, StreamDescription, DateTime.UtcNow, stream.Id);
if (_currentBroadcastId == null)
{
AppLog.Write("Broadcast creation failed; check the OAuth session");
_notifications.Error("Couldn't go live", "YouTube rejected the broadcast — check the sign-in and try again.");
StreamStatus = StreamStatus.Error;
return;
}
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
OnPropertyChanged(nameof(CurrentBroadcastId));
if (string.IsNullOrWhiteSpace(StreamTitle) && string.IsNullOrWhiteSpace(StreamDescription))
{
BroadcastForm.IsDrawerOpen = true;
_notifications.Warning("Complete your broadcast details",
"Your title and description are empty — fill them in from the Text panel on the right.");
}
// Start chat polling (TASK 9.4): fetch the liveChatId from the broadcast's
// contentDetails, then start the poll loop. Chat is non-fatal — if it fails,
// the stream continues without on-panel chat.
try
{
var liveChatId = await _youtubeStream.GetBroadcastLiveChatIdAsync(_currentBroadcastId);
if (liveChatId != null)
{
_youtubeChat.Start(liveChatId);
AppLog.Write($"Chat polling started: {liveChatId}");
}
else
{
AppLog.Write("Could not retrieve liveChatId; chat polling disabled");
}
}
catch (Exception chatEx)
{
AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}");
}
}
catch (Exception ex)
{
AppLog.Write($"Go-live preparation failed: {ex.Message}");
_notifications.Error("Couldn't go live", ex.Message);
StreamStatus = StreamStatus.Error;
return;
}
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
// Health polling (TASK 5 item 3): poll immediately, then every 30s while
// live. Report-by-exception — a banner appears only on warning/error.
_healthPollTimer.Start();
_ = PollHealthAsync();
}
private void OnHealthPollTick(object? sender, EventArgs e) => _ = PollHealthAsync();
/// <summary>One liveStreams.list poll (TASK 5 item 3): updates the YouTube
/// health state + the report-by-exception banner. Never throws — a poll
/// failure logs and leaves the last state untouched.</summary>
private async Task PollHealthAsync()
{
if (_reusableStream == null || !IsLive) return;
try
{
var health = await _youtubeStream.GetStreamHealthAsync(_reusableStream.Id);
if (health == null)
{
AppLog.Write("Stream health poll failed; check the OAuth session");
return;
}
CurrentHealth.HealthStatus = health.HealthStatus;
CurrentHealth.HealthMessage = health.HealthStatus;
CurrentHealth.ConfigurationIssues.Clear();
CurrentHealth.ConfigurationIssues.AddRange(health.ConfigurationIssues);
OnPropertyChanged(nameof(CurrentHealth));
ApplyHealthIssue(health.ConfigurationIssues);
}
catch (Exception ex)
{
AppLog.Write($"Stream health poll failed: {ex.Message}");
}
}
// Report-by-exception: good/ok/noData (or info-only issues) clear the banner;
// the first warning/error issue raises it, colored by the worst severity.
private void ApplyHealthIssue(List<StreamConfigurationIssue> issues)
{
var report = StreamHealthReporter.BannerFor(issues);
if (report.Text == null)
{
HealthIssueBanner = null;
HealthIssueBackground = "Transparent";
return;
}
HealthIssueBackground = report.IsError ? "#8f1f1f" : "#b8860b";
HealthIssueBanner = report.Text;
}
private async void StopStream()
{
var wasLive = IsLive;
var wasRecording = IsRecording;
StreamStatus = StreamStatus.Offline;
IsRecording = false;
WindowTitle = "LlamaCasty";
ResetHealth(StreamStatus.Offline);
// Stop chat polling first (TASK 9.4) — no more messages arriving while
// we're tearing down the stream.
_youtubeChat.Stop();
// Close the audio pipe FIRST so ffmpeg sees EOF on the audio input,
// then stop the pump (stdin EOF) — both inputs end and the encoder
// finalizes the output. Audio capture itself is always-on (preview
// monitoring); only the live/report pipe, the pump, and the session stop here.
_audioMixer.StopLive();
await _framePump.StopAsync();
_currentBroadcastId = null;
OnPropertyChanged(nameof(CurrentBroadcastId));
if (wasRecording) await FinalizeRecordingAsync();
// Explicit sign-out only (TASK 18): stopping the stream does NOT clear
// the session/token — the creator signs out deliberately via the
// Logout / Change Account commands.
AppLog.Write(wasLive ? "Stream ended" : "Recording ended");
}
/// <summary>On stop the auto-named <c>ty-…-0000.mp4</c> file is renamed to carry
/// the real recording length (<c>ty-…-hh2mm2.mp4</c>), with a numeric suffix on
/// name collisions. Runs only after the pump has fully closed the file.</summary>
private async Task FinalizeRecordingAsync()
{
var startPath = _activeRecordPath;
_activeRecordPath = null;
if (string.IsNullOrEmpty(startPath)) return;
try
{
while (_framePump.IsRunning) await Task.Delay(50);
if (!File.Exists(startPath))
{
AppLog.Write($"Recording: output not found for rename: {startPath}");
return;
}
_recordLength = _liveElapsed;
var autoStem = RecordingFile.BuildFinalName(_recordStartTime, _recordLength);
var dir = Path.GetDirectoryName(startPath)!;
// Manual rename modal: give the creator a chance to name the recording;
// null = keep the auto name (or the default recorded above).
string stem = autoStem;
var dialog = new RenameRecordingDialog(autoStem);
if (dialog.ShowDialog() == true && !string.IsNullOrEmpty(dialog.ResultFileName))
stem = dialog.ResultFileName;
var finalPath = UniquePath(Path.Combine(dir, stem + ".mp4"));
File.Move(startPath, finalPath);
AppLog.Write($"Recording saved: {finalPath}");
_notifications.Info("Recording saved",
Path.GetFileName(finalPath) + " — you can rename it in your videos folder at any time.");
}
catch (Exception ex)
{
AppLog.Write(ex, "Recording: rename-on-stop failed");
_notifications.Error("Recording saved",
$"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}");
}
}
private static string UniquePath(string path)
{
if (!File.Exists(path)) return path;
var dir = Path.GetDirectoryName(path)!;
var name = Path.GetFileNameWithoutExtension(path);
var ext = Path.GetExtension(path);
for (var i = 2; ; i++)
{
var candidate = Path.Combine(dir, $"{name}-{i}{ext}");
if (!File.Exists(candidate)) return candidate;
}
}
private void ChooseRecordFolder()
{
var dialog = new Microsoft.Win32.OpenFolderDialog
{
Title = "Choose a folder for local recordings",
InitialDirectory = _recordFolder ?? DefaultRecordFolder(),
};
if (dialog.ShowDialog() == true)
{
_recordFolder = dialog.FolderName;
_layoutStore.SaveRecordFolder(_recordFolder);
OnPropertyChanged(nameof(RecordFolderDisplay));
_notifications.Info("Record folder set", _recordFolder);
}
}
private void ResetRecordFolder()
{
_recordFolder = null;
_layoutStore.SaveRecordFolder(null);
OnPropertyChanged(nameof(RecordFolderDisplay));
_notifications.Info("Record folder reset", RecordFolderDisplay);
}
/// <summary>Falls back to the user's Downloads folder when no preference is set;
/// returns MyVideos if Downloads doesn't exist.</summary>
internal static string DefaultRecordFolder()
{
var downloads = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
return Directory.Exists(downloads)
? downloads
: Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
}
// Scene-element → latest frame, for the live compositor. The map mirrors the
// preview: webcam by DeviceId, live captures (the background) by CaptureKey,
// images/background by AssetId. A null frame leaves the element transparent.
private VideoFrame? ResolveOutputFrame(SceneElement element)
{
return element switch
{
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(webcam.WebcamId),
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
Source { Type: SourceType.ChatBox } chatBox => RenderChatBox(chatBox),
Source { Type: SourceType.WebSource } web => _webView2Manager?.GetLatestFrame(web.Id),
Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId),
_ => null,
};
}
private VideoFrame? RenderChatBox(Source chatBox)
{
if (ChatMessages.Count == 0) return null;
var width = (int)Math.Max(1, chatBox.Width);
var height = (int)Math.Max(1, chatBox.Height);
return _chatBoxRenderer.Render(
ChatMessages,
width,
height,
chatBox.ChatFontSize,
chatBox.ChatFontFamily,
chatBox.ChatFontColor,
chatBox.ChatBackgroundColor,
chatBox.ChatMaxMessages,
chatBox.ChatShowTimestamps,
chatBox.ChatShowBadges,
chatBox.ChatMemberColor,
chatBox.ChatSuperChatColor);
}
/// <summary>
/// Renders the ChatBox preview: real messages when connected, mock when not.
/// </summary>
private void UpdateChatBoxPreview(Source chatBox, IReadOnlyList<ChatMessage>? messages = null)
{
try
{
messages ??= ChatMessages.Count > 0 ? (IReadOnlyList<ChatMessage>)ChatMessages : MockChatMessages;
var width = (int)Math.Max(32, chatBox.Width);
var height = (int)Math.Max(32, chatBox.Height);
var frame = _chatBoxRenderer.Render(
messages,
width,
height,
chatBox.ChatFontSize,
chatBox.ChatFontFamily,
chatBox.ChatFontColor,
chatBox.ChatBackgroundColor,
chatBox.ChatMaxMessages,
chatBox.ChatShowTimestamps,
chatBox.ChatShowBadges,
chatBox.ChatMemberColor,
chatBox.ChatSuperChatColor);
var bitmap = new WriteableBitmap(width, height, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null);
bitmap.WritePixels(new System.Windows.Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0);
chatBox.VideoImageSource = bitmap;
}
catch (Exception ex) { AppLog.Write($"UpdateChatBoxPreview failed: {ex.Message}"); }
}
private CancellationTokenSource? _mockChatCts;
private async Task RunMockChatPreviewAsync(Source chatBox, CancellationToken ct)
{
var n = 0;
while (!ct.IsCancellationRequested)
{
UpdateChatBoxPreview(chatBox, MockChatMessages.Take(n + 1).ToList());
await Task.Delay(1000, ct);
n++;
if (n == MockChatMessages.Count)
{
await Task.Delay(3000, ct);
n = 0;
}
}
}
private void StartMockChatPreview()
{
StopMockChatPreview();
_mockChatCts = new CancellationTokenSource();
var ct = _mockChatCts.Token;
foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox && s.ChatPreviewEnabled))
_ = RunMockChatPreviewAsync(chatBox, ct);
}
private void StopMockChatPreview()
{
_mockChatCts?.Cancel();
_mockChatCts?.Dispose();
_mockChatCts = null;
}
/// <summary>
/// Render previews + wire property-change subscriptions for all ChatBox
/// sources across every scene. Called after layout load so existing ChatBox
/// sources show mock content and respond to config changes.
/// </summary>
private void UpdateActiveChatBoxPreviews()
{
foreach (var scene in Scenes)
foreach (var chatBox in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.ChatBox))
{
chatBox.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor)
or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity)
or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps)
or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor)
or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily)
or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height))
UpdateChatBoxPreview(chatBox);
};
UpdateChatBoxPreview(chatBox);
}
}
// The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
// OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
private CompositorOptions BuildCompositorOptions()
{
var quality = SelectedQuality;
return new CompositorOptions
{
SourceRectX = (int)Math.Round(OutputRectX),
SourceRectY = (int)Math.Round(OutputRectY),
SourceRectWidth = (int)Math.Round(OutputRectWidth),
SourceRectHeight = (int)Math.Round(OutputRectHeight),
OutputWidth = quality.Width,
OutputHeight = quality.Height,
};
}
// Full encoder options for the current tier, or null when no RTMP URL is
// available — the pump then skips the encoder entirely (TASK 5 fills the seam).
private EncoderOptions? BuildEncoderOptions()
{
var url = _rtmpUrlProvider();
var quality = SelectedQuality;
return new EncoderOptions
{
RtmpUrl = url ?? string.Empty,
StreamEnabled = OnAirPillOn,
RecordEnabled = RecordPillOn,
RecordPath = _activeRecordPath,
Width = quality.Width,
Height = quality.Height,
Fps = quality.Fps,
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
};
}
private void OnFramePumpFailed(object? sender, string message)
{
AppLog.Write($"Frame pump failed: {message}");
_notifications.Error("The stream pipeline stopped", message);
if (IsLive) StreamStatus = StreamStatus.Error;
}
// TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/
// duration) lands in the bottom bar. The stderr loop raises on a background
// thread — marshal to the UI thread like the audio level handlers.
private void OnFramePumpHealthUpdated(object? sender, StreamHealth health)
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => ApplyHealth(health));
else
ApplyHealth(health);
}
private void ApplyHealth(StreamHealth health)
{
CurrentHealth.Status = health.Status;
CurrentHealth.CurrentBitrate = health.CurrentBitrate;
CurrentHealth.FPS = health.FPS;
CurrentHealth.DroppedFrames = health.DroppedFrames;
CurrentHealth.StreamDuration = health.StreamDuration;
CurrentHealth.LastError = health.LastError;
CurrentHealth.HealthMessage = health.HealthMessage;
OnPropertyChanged(nameof(CurrentHealth));
}
// Keeps the bottom-bar stats honest across sessions: dropped frames and the
// elapsed duration must not linger from a previous go-live (bitrate/FPS stay
// on the tier's targets — ApplyStreamQuality sets them on pick).
private void ResetHealth(StreamStatus status)
{
CurrentHealth.Status = status;
CurrentHealth.DroppedFrames = 0;
CurrentHealth.StreamDuration = TimeSpan.Zero;
CurrentHealth.LastError = null;
CurrentHealth.HealthStatus = null;
CurrentHealth.HealthMessage = null;
CurrentHealth.ConfigurationIssues.Clear();
HealthIssueBanner = null;
HealthIssueBackground = "Transparent";
OnPropertyChanged(nameof(CurrentHealth));
}
private void UpdateLiveVisuals()
{
var live = IsLive;
TopBarBackground = live ? "#e94560" : "#16213e";
PreviewGlowBrush = live ? "#e94560" : "Transparent";
PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0);
if (live)
{
if (BrandFlashEnabled) StartBrandFlashTimer();
}
else
{
_healthPollTimer.Stop();
_brandFlashTimer.Stop();
_brandFlashOffTimer.Stop();
BrandFlashActive = false;
}
UpdateSessionTimer();
}
/// <summary>Runs the elapsed/rec-dot-pulse timer while a session is active —
/// live (Streaming) OR recording. Reset the elapsed when a new session starts.</summary>
private void UpdateSessionTimer()
{
var active = IsLive || IsRecording;
if (active)
{
_recDotPulse = 1.0;
_liveTimer.Start();
}
else
{
_liveTimer.Stop();
LiveElapsedText = "00:00:00";
_recDotPulse = 1.0;
}
OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(LiveElapsedText));
}
// First flash shortly after go-live, then every 300s (the cadence resets on
// the first tick so the interval is 5s only for that one shot).
private void StartBrandFlashTimer()
{
_brandFlashTimer.Stop();
_brandFlashTimer.Interval = TimeSpan.FromSeconds(5);
_brandFlashTimer.Start();
}
private void OnBrandFlashTimerTick(object? sender, EventArgs e)
{
_brandFlashTimer.Stop();
_brandFlashTimer.Interval = TimeSpan.FromSeconds(300);
_brandFlashTimer.Start();
ShowBrandFlash();
}
private void ShowBrandFlash()
{
if (!IsLive || !BrandFlashEnabled) return;
BrandFlashActive = true;
_brandFlashOffTimer.Stop();
_brandFlashOffTimer.Start();
}
private void OnLiveTimerTick(object? sender, EventArgs e)
{
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
_recordLength = _liveElapsed;
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
}
}