Files
LlamaCasty/ViewModels/MainViewModel.cs
T
gramps a9eb360288 TASK 18: local (record-only) recording pipeline + top-bar pills/light/Start-End button UX
Encoder dual-output: StreamEnabled/RecordEnabled/RecordPath options; per-output
ffmpeg block (stream ->flv, record ->mp4); throw unless at least one output.

RecordingFile: auto-name ty-<yyyymmdd>-<start hhmm>-0000.mp4, rename-on-stop to
ty-...-<len hhmmss>.mp4 with numeric-suffix on collision. LayoutStore persists
record folder. MainViewModel: REC/ON-AIR pills, status light, single Start<->End
button (replaces Start/Stop variants), StopStream no longer signs out, explicit
session timer. About overlay widened. Sign In label + spacing before Start.

Tests: 244 passed / 246 (2 pre-existing: AudioPipeline gains-mute, RoundClip).
Build 0 warnings. Scope check clean.

Memory-map correction (derived-solution rule, 2026-08-29): image-shrink recipe was
never recorded when first done, so it was re-derived. Added Derived-solution/recipes
rule to AGENTS.md; MyMistakes.md now a permanent recipes registry (records the
verified WPF-imaging shrink recipe); schema.md rows MyMistakes as registry; ai.md
points to the rule. README hero image replaced: 3.2MB screenshot -> 1400x794 ->
188KB docs/ytLlive-preview.jpg (JPEG q82).
2026-08-29 09:02:55 -07:00

3919 lines
154 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.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 class MainViewModel : ViewModelBase
{
private readonly YouTubeAuthService _youtubeAuth;
private readonly YouTubeStreamService _youtubeStream;
private readonly YouTubeChatService _youtubeChat;
private readonly DispatcherTimer _liveTimer;
private readonly DispatcherTimer _volumeFlashTimer;
private readonly DispatcherTimer _gameVolumeFlashTimer;
private readonly DispatcherTimer _healthPollTimer;
private Scene? _stagedScene;
private Scene? _liveScene;
private SceneElement? _selectedElement;
private ImageSource? _activeBackgroundImage;
private bool _isConnected;
private string _accountAvatarUrl = string.Empty;
private string _accountDisplayName = string.Empty;
private double _audioLevel;
private bool _volumeAdjusting;
private bool _volumeFlash;
private double _micVolume = 0.8;
private bool _micMuted;
private double? _volumeBeforeMute;
private string? _micSourceName;
private MicStatus _micStatus = MicStatus.NotConnected;
private double _gameAudioLevel;
private bool _gameVolumeAdjusting;
private bool _gameVolumeFlash;
private double _gameVolume = 1.0;
private bool _gameMuted;
private double? _gameVolumeBeforeMute;
// TRAX (TASK 9): free background music at a fixed quiet volume, playing to
// the default device so it rides the loopback into the stream. The dot is
// red (no track) / yellow (loaded, stopped) / green (playing).
private readonly MusicPlayer _musicPlayer = new();
private Music? _music;
private bool _musicPlaying;
private AudioGainProvider? _gainProvider;
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;
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();
// Webcam: one camera input app-wide. The identity (DeviceId) lives on the
// Webcam entity; each scene that shows the webcam has a WebcamSceneConfig
// (webcam.{scene}.config). CameraManager still owns the single session.
private readonly ICameraEnumerator _cameraEnumerator;
private readonly CameraManager _cameraManager;
private Webcam? _webcam;
private string? _webcamError;
private SocialsConfig? _socials;
private VideoFrame? _socialBarFrame;
private readonly IMicrophoneEnumerator _microphoneEnumerator;
// Live audio capture (TASK 4 ship step 4): desktop/game via WASAPI loopback,
// mic via WASAPI capture, both owned by the mixer. Capture runs for the app's
// lifetime (started at startup, stopped on shutdown) so the footer meters
// stay live in preview. Mic level feeds AudioLevel (the meter); loopback
// feeds the game audio bar's meter. Private by design — the mixer surfaces
// the levels + mic connection state to the UI.
private readonly AudioMixer _audioMixer;
// 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();
// Screen background: a permanent live capture (desktop/game) that every scene
// shows at the bottom layer. One shared capture session per key — the
// ScreenCaptureManager refcounts by key, mirroring CameraManager.
private readonly IFullScreenDetector _fullScreenDetector;
private readonly ScreenCaptureManager _screenCaptureManager;
private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
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 },
};
private ImageSource? _backgroundImage;
private bool _backgroundSizeMismatch;
private string _backgroundSizeMismatchText = "";
private HashSet<string> _liveCaptureKeys = new();
// 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;
// TASK 20 step 2 — hotkey bindings (custom chord per action)
private Dictionary<HotkeyId, HotkeyChord> _hotkeyBindings = new();
// TASK 10 — Polar license validation
private string _licenseKey = string.Empty;
private string _licenseMessage = string.Empty;
private bool _isLicenseValidating;
private bool _isLicenseEntryOpen;
private static readonly TimeSpan OfflineGracePeriod = TimeSpan.FromDays(14);
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<Scene> Scenes { get; } = new();
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
public ObservableCollection<DisplayInfo> Displays { get; } = new();
public Scene? StagedScene
{
get => _stagedScene;
set
{
if (value != null && value.IsHidden) return;
if (SetProperty(ref _stagedScene, value))
{
// Update IsStaged flags for thumbnail glow
foreach (var s in Scenes)
s.IsStaged = s == value;
SelectedElement = null;
if (value != null)
{
var background = EnsureBackground(value);
if (background != null)
StampDefaultBackgroundAsset(background, value.Name);
}
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
OnPropertyChanged(nameof(CanChangeBackground));
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanAddYouTubeChat));
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
OnPropertyChanged(nameof(IsCountdownAvailable));
UpdateActiveBackground();
UpdateBackgroundImage();
RefreshSnapshotsAsync();
}
}
}
/// <summary>The scene the encoder outputs. Set on Go Live (StagedScene → LiveScene)
/// or on transition completion. FramePump reads this every tick.</summary>
public Scene? LiveScene
{
get => _liveScene;
set
{
if (SetProperty(ref _liveScene, value))
{
foreach (var s in Scenes)
s.IsLive = s == value;
}
}
}
/// <summary>True when offline — edit mode is available. When live/recording,
/// all edit operations are locked out (gun safety).</summary>
public bool IsEditMode => StreamStatus == StreamStatus.Offline && !IsRecording;
public ImageSource? ActiveBackgroundImage
{
get => _activeBackgroundImage;
private set => SetProperty(ref _activeBackgroundImage, value);
}
/// <summary>The active scene's live-capture background frame (rendered below the
/// static background). Set from the shared capture bitmap as it arrives.</summary>
public ImageSource? BackgroundImage
{
get => _backgroundImage;
private set
{
if (SetProperty(ref _backgroundImage, value))
{
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
OnPropertyChanged(nameof(BackgroundVisible));
}
}
}
public bool BackgroundVisible => StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground)?.IsVisible == true;
public bool BackgroundSizeMismatch
{
get => _backgroundSizeMismatch;
private set => SetProperty(ref _backgroundSizeMismatch, value);
}
public string BackgroundSizeMismatchText
{
get => _backgroundSizeMismatchText;
private set => SetProperty(ref _backgroundSizeMismatchText, value);
}
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 → Webcam menu item: enabled only while no webcam exists anywhere —
/// one camera identity app-wide; re-adding always opens the picker.</summary>
public bool CanAddWebcam => StagedScene != null && _webcam == null;
/// <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));
// ─── Transitions (TASK 19/23) ───
public TransitionType SelectedTransitionType
{
get => _selectedTransitionType;
set => SetProperty(ref _selectedTransitionType, value);
}
private TransitionType _selectedTransitionType = TransitionType.Fade;
public double TransitionDurationMs
{
get => _transitionDurationMs;
set => SetProperty(ref _transitionDurationMs, value);
}
private double _transitionDurationMs = 300;
/// <summary>
/// Called when a scene thumbnail is clicked. If live, triggers a transition from
/// LiveScene to the selected StagedScene. Always sets StagedScene first.
/// </summary>
public void TransitionToScene(Scene scene)
{
if (scene.IsHidden || scene == StagedScene) return;
StagedScene = scene;
if (IsLive)
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
}
/// <summary>
/// Apply the current transition type to the next StagedScene change while live.
/// Called from XAML when user picks a transition from the dropdown.
/// </summary>
public void ApplyTransitionToLive()
{
if (IsLive)
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
}
// ─── Global hotkeys (TASK 20) — dispatch a registered hotkey to its action ───
/// <summary>
/// Callback set by MainWindow so the VM can trigger hotkey re-registration
/// after the user saves custom bindings from the config dialog.
/// </summary>
public Action<Dictionary<HotkeyId, HotkeyChord>>? HotkeyBindingsChanged { get; set; }
/// <summary>The current hotkey bindings (loaded from DB, updated on dialog save).</summary>
public Dictionary<HotkeyId, HotkeyChord> CurrentHotkeyBindings => _hotkeyBindings;
public void HandleHotkey(Services.HotkeyId id)
{
switch (id)
{
case Services.HotkeyId.SceneStarting: TransitionToCanonical(SceneCatalog.Starting); break;
case Services.HotkeyId.SceneLive: TransitionToCanonical(SceneCatalog.Live); break;
case Services.HotkeyId.SceneBrb: TransitionToCanonical(SceneCatalog.Brb); break;
case Services.HotkeyId.SceneChat: TransitionToCanonical(SceneCatalog.Chat); break;
case Services.HotkeyId.SceneEnding: TransitionToCanonical(SceneCatalog.Ending); break;
case Services.HotkeyId.StartStopStream:
if (IsLive || IsRecording)
{
if (EndStreamCommand.CanExecute(null)) EndStreamCommand.Execute(null);
}
else if (StartStreamCommand.CanExecute(null))
{
StartStreamCommand.Execute(null);
}
break;
case Services.HotkeyId.ToggleMicMute: ToggleMicMuteCommand.Execute(null); break;
case Services.HotkeyId.ToggleGameMute: ToggleGameMuteCommand.Execute(null); break;
case Services.HotkeyId.TraxPlayPause: TraxLeftClick(); break;
}
}
private void TransitionToCanonical(string name)
{
var scene = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, name));
if (scene != null) TransitionToScene(scene);
}
private void OpenHotkeyConfig()
{
var vm = new HotkeyConfigViewModel(_layoutStore);
var dialog = new HotkeyConfigDialog(vm) { Owner = Application.Current.MainWindow };
if (dialog.ShowDialog() == true)
{
// Reload bindings from DB (the dialog already saved them).
_hotkeyBindings = _layoutStore.LoadHotkeyBindings();
HotkeyBindingsChanged?.Invoke(_hotkeyBindings);
RefreshHotkeyLabels();
}
}
private void RefreshHotkeyLabels()
{
foreach (var scene in Scenes)
{
if (SceneCatalog.SceneHotkeys.TryGetValue(scene.Name, out var hid)
&& _hotkeyBindings.TryGetValue(hid, out var chord))
scene.HotkeyLabel = chord.ToDisplayString();
else if (SceneCatalog.SceneHotkeys.TryGetValue(scene.Name, out var defId)
&& GlobalHotkeyManager.Defaults.TryGetValue(defId, out var defChord))
scene.HotkeyLabel = defChord.ToDisplayString();
else
scene.HotkeyLabel = string.Empty;
}
}
/// <summary>
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no
/// webcam config (add one) or hides it (unhide — keeps the config row).
/// </summary>
public bool CanShowWebcamInStagedScene
=> StagedScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible);
/// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
public bool CanChangeWebcam => _webcam != null;
// ─── Social bar (global resource, shown on every scene) ───
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 global socials config has at least one validated entry.</summary>
public bool HasSocials => _socials != null && _socials.Entries.Count > 0;
/// <summary>The bar is on the stream when it's enabled and has entries.</summary>
public bool SocialBarVisible => (_socials?.BarEnabled ?? false) && HasSocials;
/// <summary>Footer indicator dot: green when the bar is on the stream, red otherwise.</summary>
public SolidColorBrush SocialBarDotBrush => SocialBarVisible ? BarOnBrush : BarOffBrush;
/// <summary>Green glow on the bar while it's on the stream.</summary>
public SolidColorBrush SocialBarGlowBrush => BarOnBrush;
/// <summary>The global socials config (entries + bar settings), or null.</summary>
public SocialsConfig? Socials => _socials;
/// <summary>True when the user holds a valid LlamaCasty license (Polar). When
/// false, the branding flash runs on every live stream.</summary>
private bool _isPremium;
public bool IsPremium
{
get => _isPremium;
private set
{
if (!SetProperty(ref _isPremium, value)) return;
// Flip the branding flash when premium state changes.
BrandFlashEnabled = !value;
}
}
/// <summary>Canvas.Top for the social bar: 0 = top, 1040 = bottom (40px from the 1080 edge).</summary>
public double SocialBarTop => _socials?.BarPosition == SocialBarPosition.Top ? 0 : 1040;
private readonly ISocialValidator _socialValidator = new HttpSocialValidator();
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
/// <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 };
/// <summary>
/// The reason the webcam feed is down (device in use, offline, locked, no frames),
/// or null when it's alive. Surfaced as a red chip so a dead camera is never a
/// silent empty box. Cleared the moment a real frame arrives.
/// </summary>
public string? WebcamError
{
get => _webcamError;
private set => SetProperty(ref _webcamError, value);
}
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(IsStreamingStart));
OnPropertyChanged(nameof(PrimaryButtonText));
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(ShowLogInButton));
OnPropertyChanged(nameof(ShowEndStreamButton));
OnPropertyChanged(nameof(EndButtonText));
OnPropertyChanged(nameof(AccountStatusLightToolTip));
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
UpdateLiveVisuals();
}
}
}
public bool IsConnected
{
get => _isConnected;
set
{
if (SetProperty(ref _isConnected, value))
{
// ON-AIR requires a sign-in; dropping the session turns the pill
// off so the state can never offer a stream without an account.
if (!value) OnAirPillOn = false;
OnPropertyChanged(nameof(CanToggleOnAir));
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(PrimaryButtonText));
OnPropertyChanged(nameof(IsStreamingStart));
OnPropertyChanged(nameof(AccountStatusLightToolTip));
}
}
}
/// <summary>The connected YouTube account's avatar + name — shown in the top
/// bar so the creator always sees WHICH account is about to go live.</summary>
public string AccountAvatarUrl
{
get => _accountAvatarUrl;
private set => SetProperty(ref _accountAvatarUrl, value);
}
public string AccountDisplayName
{
get => _accountDisplayName;
private set => SetProperty(ref _accountDisplayName, value);
}
// ─── Audio: the mic bar (meter + volume + mute + status dot) is always
// ─── visible; the game bar (meter + volume + mute) appears only while a
// ─── full-screen game is producing sound. Both meters preview live. ───
/// <summary>Live mic input level (0..1) — fed by the audio mixer once capture
/// lands; 0 with no input. Read by the meter, scaled by MicVolume.</summary>
public double AudioLevel
{
get => _audioLevel;
set
{
if (SetProperty(ref _audioLevel, Math.Clamp(value, 0, 1)))
{
OnPropertyChanged(nameof(MeterFillWidth));
OnPropertyChanged(nameof(MeterBrush));
}
}
}
/// <summary>Displayed meter level: 0 while muted; the volume position while
/// the slider is being dragged (or briefly after an unmute, so the restored
/// level flashes on the bar) so the creator sees where they're setting it;
/// otherwise the realtime live level scaled by volume (raising the volume
/// moves ambient noise up the bar). Read-only meter.</summary>
private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * 1.2 * MicVolume);
public double MeterFillWidth => MeterLevel * 288;
public string MeterBrush => MeterLevel switch
{
< 0.6 => "#22c55e",
< 0.8 => "#eab308",
_ => "#ef4444",
};
/// <summary>Previewes the volume position while the slider is dragged; the
/// bar returns to the live level on release (empty when there is no input).
/// Starting a drag cancels any pending unmute flash.</summary>
public void SetVolumeAdjusting(bool adjusting)
{
if (adjusting)
CancelVolumeFlash();
if (SetProperty(ref _volumeAdjusting, adjusting))
{
OnPropertyChanged(nameof(MeterFillWidth));
OnPropertyChanged(nameof(MeterBrush));
}
}
private void BeginVolumeFlash()
{
_volumeFlash = true;
OnPropertyChanged(nameof(MeterFillWidth));
OnPropertyChanged(nameof(MeterBrush));
_volumeFlashTimer.Stop();
_volumeFlashTimer.Start();
}
private void EndVolumeFlash()
{
_volumeFlashTimer.Stop();
if (_volumeFlash)
{
_volumeFlash = false;
OnPropertyChanged(nameof(MeterFillWidth));
OnPropertyChanged(nameof(MeterBrush));
}
}
private void CancelVolumeFlash()
{
_volumeFlashTimer.Stop();
_volumeFlash = false;
}
/// <summary>Mic gain (0..1). Running to 0 mutes (the speaker flips to muted);
/// moving up from 0 unmutes (the mute indicator clears). The level being left
/// is remembered so the speaker button can restore it.</summary>
public double MicVolume
{
get => _micVolume;
set
{
var clamped = Math.Clamp(value, 0, 1);
if (clamped == 0 && !_micMuted)
_volumeBeforeMute ??= _micVolume;
if (SetProperty(ref _micVolume, clamped))
{
var muted = clamped == 0;
if (_micMuted != muted)
{
_micMuted = muted;
OnPropertyChanged(nameof(MicMuted));
OnPropertyChanged(nameof(MicMuteText));
}
if (!muted)
_volumeBeforeMute = null;
OnPropertyChanged(nameof(MeterFillWidth));
OnPropertyChanged(nameof(MeterBrush));
}
}
}
/// <summary>Read-only: true whenever the volume is 0. Only MicVolume may
/// change it, so the slider and the speaker can never disagree.</summary>
public bool MicMuted => _micMuted;
public string MicMuteText => MicMuted ? "Unmute" : "Mute";
/// <summary>Name of the picked voice source, shown left-justified INSIDE the meter
/// bar (FontSize 10, ellipsized to the bar) — ai.md is the authority here.</summary>
public string? MicSourceName
{
get => _micSourceName;
private set => SetProperty(ref _micSourceName, value);
}
private void ToggleMicMute()
{
if (MicMuted)
{
MicVolume = _volumeBeforeMute ?? 0.8;
BeginVolumeFlash();
}
else
{
_volumeBeforeMute = MicVolume;
MicVolume = 0;
}
}
private void PickMicrophone()
{
var dialog = new MicPickerDialog(new MicPickerViewModel(_microphoneEnumerator))
{
Owner = System.Windows.Application.Current?.MainWindow
};
if (dialog.ShowDialog() == true && dialog.PickedDevice != null)
{
MicSourceName = dialog.PickedDevice.DisplayName;
_layoutStore.SaveMicSourceName(MicSourceName); // persist across restarts
_audioMixer.RestartMic(); // swap the live device immediately
}
}
// ─── Mic status dot (the MIC button) ───
private static readonly SolidColorBrush MicProblemBrush = CreateBrush("#f1c40f");
private static readonly SolidColorBrush TraxIdleBrush = CreateBrush("#555");
/// <summary>Mic connection state, driven by the mixer's MicConnected/MicFailed
/// events and the startup device check (see StartMicCaptureAsync).</summary>
public MicStatus MicStatus
{
get => _micStatus;
private set
{
if (SetProperty(ref _micStatus, value))
{
OnPropertyChanged(nameof(MicStatusBrush));
OnPropertyChanged(nameof(MicStatusToolTip));
}
}
}
/// <summary>Status dot: green = connected, yellow = problem with the requested
/// connection, red = not connected (no device / not started).</summary>
public SolidColorBrush MicStatusBrush => MicStatus switch
{
MicStatus.Connected => BarOnBrush,
MicStatus.Problem => MicProblemBrush,
_ => BarOffBrush,
};
public string MicStatusToolTip => MicStatus switch
{
MicStatus.Connected => "Mic connected — click to change",
MicStatus.Problem => "Mic problem — the requested microphone is unavailable (in use or unplugged). Click to change",
_ => "No mic connected — click to choose a microphone",
};
// ─── Game audio bar (desktop/game): always visible — a mirror of the mic bar
// ─── (meter + mute + volume). Desktop/game audio is automatic (WASAPI loopback). ───
/// <summary>True while the TRAX background track is playing.</summary>
public bool IsMusicPlaying
{
get => _musicPlaying;
private set
{
if (SetProperty(ref _musicPlaying, value))
{
OnPropertyChanged(nameof(TraxDotBrush));
OnPropertyChanged(nameof(TraxToolTip));
}
}
}
private string? _traxError;
/// <summary>Error message from the last TRAX load/playback failure; null = no error.</summary>
public string? TraxError
{
get => _traxError;
private set => SetProperty(ref _traxError, value);
}
/// <summary>TRAX status dot: green = playing, gray = idle or no track.</summary>
public SolidColorBrush TraxDotBrush => _musicPlayer.IsPlaying
? BarOnBrush
: TraxIdleBrush;
public string TraxToolTip => _musicPlayer.TrackName != null
? $"TRAX: {_musicPlayer.TrackName} — left-click {(IsMusicPlaying ? "pauses" : "plays")}, right-click chooses a track"
: "No track — left-click or right-click to choose background music";
/// <summary>Left-click on TRAX: no track → pick one; otherwise toggle play/pause.</summary>
public void TraxLeftClick()
{
if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
PickTraxTrack();
else if (IsMusicPlaying)
PauseTrax();
else
PlayTrax();
}
/// <summary>Right-click on TRAX: always open the track picker (never the OS viewer).</summary>
public void TraxRightClick() => PickTraxTrack();
private void PickTraxTrack()
{
var dialog = new OpenFileDialog
{
Title = "Choose background music",
Filter = "Music (*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg)|*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg|All files (*.*)|*.*",
};
if (dialog.ShowDialog() != true)
return;
_music = new Music { TrackPath = dialog.FileName, IsEnabled = true };
LoadTrax(play: true);
}
private void LoadTrax(bool play)
{
if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
return;
try
{
_musicPlayer.Load(_music.TrackPath);
UpdateTraxLocalGain();
_music.IsEnabled = true;
TraxError = null;
if (play)
_musicPlayer.Play();
}
catch (Exception ex)
{
AppLog.Write($"TRAX: failed to load '{_music.TrackPath}': {ex.Message}");
TraxError = $"Failed to load: {Path.GetFileName(_music.TrackPath)} — {ex.Message}";
}
RefreshTraxUi();
ScheduleSave();
}
private void PlayTrax() => _musicPlayer.Play();
private void PauseTrax() => _musicPlayer.Pause();
private void OnTraxPlaybackEnded(Exception? ex)
{
if (ex != null)
{
AppLog.Write($"TRAX: playback error: {ex.Message}");
TraxError = $"Playback error: {ex.Message}";
}
RefreshTraxUi();
}
private void RefreshTraxUi()
{
IsMusicPlaying = _musicPlayer.IsPlaying;
OnPropertyChanged(nameof(TraxDotBrush));
OnPropertyChanged(nameof(TraxToolTip));
}
/// <summary>Mirrors the desktop/game audio bar's volume and mute onto the
/// music so the creator hears the controls work in the headphones (the
/// track rides the loopback channel, so it must duck the same way locally).
/// Called whenever the game volume/mute change and on TRAX load.</summary>
private void UpdateTraxLocalGain()
{
_musicPlayer.LocalGain = GameMuted ? 0f : 1f;
}
private static void PushSystemVolume(double volume)
{
try
{
using var enumerator = new MMDeviceEnumerator();
using var device = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
device.AudioEndpointVolume.MasterVolumeLevelScalar = (float)volume;
}
catch { }
}
/// <summary>Live desktop/game input level (0..1), fed by the mixer's loopback
/// capture. Read by the game meter, scaled by GameAudioVolume.</summary>
public double GameAudioLevel
{
get => _gameAudioLevel;
set
{
if (SetProperty(ref _gameAudioLevel, Math.Clamp(value, 0, 1)))
{
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
}
}
}
/// <summary>Displayed game meter level: 0 while muted; the volume position
/// while the slider is dragged (or briefly after an unmute flash); otherwise
/// the realtime live level scaled by volume.</summary>
private double GameMeterLevel => GameMuted ? 0 : _gameVolumeAdjusting || _gameVolumeFlash ? GameAudioVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)GameAudioLevel));
public double GameMeterFillWidth => GameMeterLevel * 288;
public string GameMeterBrush => GameMeterLevel switch
{
< 0.6 => "#22c55e",
< 0.8 => "#eab308",
_ => "#ef4444",
};
public void SetGameVolumeAdjusting(bool adjusting)
{
if (adjusting)
CancelGameVolumeFlash();
if (SetProperty(ref _gameVolumeAdjusting, adjusting))
{
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
}
}
private void BeginGameVolumeFlash()
{
_gameVolumeFlash = true;
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
_gameVolumeFlashTimer.Stop();
_gameVolumeFlashTimer.Start();
}
private void EndGameVolumeFlash()
{
_gameVolumeFlashTimer.Stop();
if (_gameVolumeFlash)
{
_gameVolumeFlash = false;
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
}
}
private void CancelGameVolumeFlash()
{
_gameVolumeFlashTimer.Stop();
_gameVolumeFlash = false;
}
/// <summary>Game audio gain (0..1, unity default). Running to 0 mutes; the
/// prior level is remembered so the speaker button can restore it.</summary>
public double GameAudioVolume
{
get => _gameVolume;
set
{
var clamped = Math.Clamp(value, 0, 1);
if (clamped == 0 && !_gameMuted)
_gameVolumeBeforeMute ??= _gameVolume;
if (SetProperty(ref _gameVolume, clamped))
{
var muted = clamped == 0;
if (_gameMuted != muted)
{
_gameMuted = muted;
OnPropertyChanged(nameof(GameMuted));
OnPropertyChanged(nameof(GameMuteText));
}
if (!muted)
_gameVolumeBeforeMute = null;
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
UpdateTraxLocalGain();
PushSystemVolume(clamped);
}
}
}
/// <summary>Read-only: true whenever the volume is 0 (the slider and the
/// speaker can never disagree).</summary>
public bool GameMuted => _gameMuted;
public string GameMuteText => GameMuted ? "Unmute" : "Mute";
private void ToggleGameMute()
{
if (GameMuted)
{
GameAudioVolume = _gameVolumeBeforeMute ?? 1.0;
BeginGameVolumeFlash();
}
else
{
_gameVolumeBeforeMute = GameAudioVolume;
GameAudioVolume = 0;
}
}
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(PrimaryButtonText));
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(PrimaryButtonText));
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>"Start Streaming" unless ONLY the record pill is on → "Start Recording".</summary>
public string PrimaryButtonText => IsStreamingStart ? "Start Streaming" : "Start Recording";
/// <summary>Streaming (not record-only) — the ON-AIR pill is on OR we're already live.</summary>
public bool IsStreamingStart => OnAirPillOn || IsLive;
/// <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;
/// <summary>Tooltip for the account status light explaining the color.</summary>
public string AccountStatusLightToolTip =>
IsLive ? "Live on YouTube — the top bar is red to show the stream is on the air."
: IsConnected ? "You're signed in to YouTube. The light is green; ON-AIR is available."
: "Signed out — the light is red and the ON-AIR pill is greyed out. You can still record locally.";
public bool ShowChatInactiveMessage => !IsLive;
public bool ShowEmptySceneHint => StagedScene != null && StagedScene.Elements.Count == 0;
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
public bool ShowSourcesEmptyHint => StagedScene == null || StagedScene.Elements.Count == 0;
public bool IsCountdownAvailable => StagedScene != null
&& (SceneCatalog.Is(StagedScene.Name, "Starting") || SceneCatalog.Is(StagedScene.Name, "BRB"));
/// <summary>The canonical scenes (SceneCatalog) not present right now — what the
/// "+" button may re-add. Work with less, never more.</summary>
public IEnumerable<string> MissingScenes
=> SceneCatalog.All.Where(canonical => Scenes.All(s => !SceneCatalog.Is(s.Name, canonical)));
/// <summary>Only show the "+" button when one of the five canonical scenes is missing.</summary>
public bool ShowAddScene => MissingScenes.Any();
/// <summary>"Change Capture…"/"Refresh Capture" apply to the active scene's background.</summary>
// Capture controls (Show Desktop / monitor pick / refresh) exist only on
// the Live screen's Background. Static-art screens never expose them.
public bool CanChangeBackground =>
StagedScene != null && SceneCatalog.Is(StagedScene.Name, SceneCatalog.Live);
// 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);
}
private void UpdateActiveBackground()
{
// Static-art rows only. Capture-flavored rows (Live) must NEVER feed
// ActiveBackgroundImage — it renders above BackgroundImage, so it would
// permanently cover the desktop/game capture. Live's canvas shows
// DisplaySource instead (capture, falling back to live-background.jpg).
var background = StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId)
? ImageCache.Get(background.AssetId)
: null;
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
}
private void UpdateBackgroundImage()
{
var scene = StagedScene;
var background = scene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
BackgroundImage = background?.DisplaySource;
if (background != null && !background.UseDefaultBackground
&& !string.IsNullOrWhiteSpace(background.CustomBackgroundPath)
&& System.IO.File.Exists(background.CustomBackgroundPath))
{
try
{
var bmi = new System.Windows.Media.Imaging.BitmapImage();
bmi.BeginInit();
bmi.UriSource = new System.Uri(background.CustomBackgroundPath);
bmi.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
bmi.EndInit();
bmi.Freeze();
double expectedRatio = 1920.0 / 1080.0;
double imageRatio = (double)bmi.PixelWidth / bmi.PixelHeight;
double ratioDiff = Math.Abs(imageRatio - expectedRatio) / expectedRatio;
if (ratioDiff > 0.05)
{
BackgroundSizeMismatch = true;
BackgroundSizeMismatchText =
$"Image is {bmi.PixelWidth}\u00d7{bmi.PixelHeight} \u2014 stream expects 1920\u00d71080 (16:9)";
}
else
{
BackgroundSizeMismatch = false;
BackgroundSizeMismatchText = "";
}
}
catch
{
BackgroundSizeMismatch = false;
BackgroundSizeMismatchText = "";
}
}
else
{
BackgroundSizeMismatch = false;
BackgroundSizeMismatchText = "";
}
}
private void RefreshSnapshotsAsync()
{
foreach (var scene in Scenes)
{
// Minis never render live captures. While the Live screen is staged
// (being previewed) its mini shows the green placeholder; unstaged,
// it shows the stored default art. Real-time surface = center only.
var stagedLive = ReferenceEquals(scene, StagedScene)
&& SceneCatalog.Is(scene.Name, SceneCatalog.Live);
var img = stagedLive ? null : LoadBackgroundImage(scene);
if (img != null)
scene.Snapshot = img;
else if (scene.Snapshot == null || stagedLive)
scene.Snapshot = CreatePlaceholderSnapshot(scene.Name);
}
}
internal static System.Windows.Media.Imaging.BitmapSource? LoadBackgroundImage(Scene scene)
{
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (bg != null && !bg.UseDefaultBackground
&& !string.IsNullOrWhiteSpace(bg.CustomBackgroundPath)
&& System.IO.File.Exists(bg.CustomBackgroundPath))
{
try
{
var bitmap = new System.Windows.Media.Imaging.BitmapImage();
bitmap.BeginInit();
bitmap.UriSource = new Uri(bg.CustomBackgroundPath);
bitmap.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
bitmap.EndInit();
bitmap.Freeze();
return bitmap;
}
catch { /* fall through to AssetId */ }
}
if (bg != null && !string.IsNullOrWhiteSpace(bg.AssetId))
return ImageCache.Get(bg.AssetId);
return null;
}
private static BitmapSource CreatePlaceholderSnapshot(string sceneName)
{
var colors = new Dictionary<string, System.Windows.Media.Brush>(StringComparer.OrdinalIgnoreCase)
{
["Starting"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(233, 69, 96)),
["Live"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(34, 197, 94)),
["BRB"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(234, 179, 8)),
["Chat"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(59, 130, 246)),
["Ending"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(168, 85, 247)),
};
var brush = colors.TryGetValue(sceneName, out var c) ? c
: new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(60, 60, 80));
var dv = new System.Windows.Media.DrawingVisual();
using (var ctx = dv.RenderOpen())
{
ctx.DrawRectangle(brush, null, new System.Windows.Rect(0, 0, 320, 180));
var ft = new System.Windows.Media.FormattedText(
sceneName,
System.Globalization.CultureInfo.InvariantCulture,
System.Windows.FlowDirection.LeftToRight,
new System.Windows.Media.Typeface("Segoe UI"),
18,
System.Windows.Media.Brushes.White,
96);
ctx.DrawText(ft, new System.Windows.Point(160 - ft.Width / 2, 90 - ft.Height / 2));
}
var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(
320, 180, 96, 96, System.Windows.Media.PixelFormats.Pbgra32);
rtb.Render(dv);
rtb.Freeze();
return rtb;
}
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;
// Webcam size safeguard: no more than half the frame in any dimension
// (960x540 over the 1920x1080 master), and no less than 10% of it
// (192x108). Enforced at resize and on layout load. The Chat scene is
// exempt from the per-dimension half — its webcam may take half the
// screen AREA (~1358x764 @16:9) so the viewer sees the creator better,
// matched by canonical name (SceneCatalog.IsChat).
public const double WebcamMaxWidth = 960;
public const double WebcamMaxHeight = 540;
public const double WebcamChatMaxWidth = 1358;
public const double WebcamChatMaxHeight = 764;
public const double WebcamMinWidth = MasterFrameWidth * 0.1;
public const double WebcamMinHeight = MasterFrameHeight * 0.1;
public static double MaxWebcamWidthFor(string? sceneName)
=> SceneCatalog.IsChat(sceneName) ? WebcamChatMaxWidth : WebcamMaxWidth;
public static double MaxWebcamHeightFor(string? sceneName)
=> SceneCatalog.IsChat(sceneName) ? WebcamChatMaxHeight : WebcamMaxHeight;
internal static void ClampWebcamToBounds(WebcamSceneConfig config, string? sceneName)
{
var maxWidth = MaxWebcamWidthFor(sceneName);
var maxHeight = MaxWebcamHeightFor(sceneName);
var scale = Math.Min(maxWidth / config.Width, maxHeight / config.Height);
if (scale < 1)
{
config.Width = Math.Round(config.Width * scale);
config.Height = Math.Round(config.Height * scale);
return;
}
var minScale = Math.Max(WebcamMinWidth / config.Width, WebcamMinHeight / config.Height);
if (minScale > 1)
{
config.Width = Math.Round(config.Width * minScale);
config.Height = Math.Round(config.Height * minScale);
}
}
// One-time heal for layouts saved before the rect dims were persisted (v3→v4):
// a Traditional webcam that ended up square (Round resize then reload lost the
// pre-Round rect) gets widened to 16:9, keeping the height. Round is skipped —
// a square bounding box is correct there — and an explicit pre-Round rect wins.
internal static void HealLegacySquareRect(WebcamSceneConfig config)
{
if (config.ClipShape != ClipShape.Traditional) return;
if (config.RectWidth != null || config.RectHeight != null) return;
if (Math.Abs(config.Width - config.Height) >= 1) return;
config.Width = Math.Round(config.Height * 16.0 / 9.0);
}
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);
}
// TASK 10 — license key entry
public string LicenseKey
{
get => _licenseKey;
set => SetProperty(ref _licenseKey, value);
}
public string LicenseMessage
{
get => _licenseMessage;
private set => SetProperty(ref _licenseMessage, value);
}
public bool IsLicenseValidating
{
get => _isLicenseValidating;
private set => SetProperty(ref _isLicenseValidating, value);
}
public bool IsLicenseEntryOpen
{
get => _isLicenseEntryOpen;
set
{
if (SetProperty(ref _isLicenseEntryOpen, value))
OnPropertyChanged(nameof(IsHubVisible));
}
}
/// <summary>The About hub is visible only when neither the licenses panel
/// nor the license entry panel is open.</summary>
public bool IsHubVisible => !IsLicensesOpen && !IsLicenseEntryOpen;
// Commands
public ICommand AddSceneCommand { get; }
public ICommand AddSourceCommand { get; }
public ICommand AddWebcamCommand { get; }
public ICommand AddImageCommand { get; }
public ICommand RemoveSourceCommand { get; }
public ICommand EditElementCommand { get; }
public ICommand ToggleElementVisibilityCommand { get; }
public ICommand ChangeWebcamCommand { get; }
public ICommand ShowWebcamCommand { get; }
public ICommand ChangeCaptureCommand { get; }
public ICommand RefreshCaptureCommand { get; }
public ICommand SetBackgroundDisplayCommand { get; }
public ICommand ToggleShowDesktopCommand { get; }
public ICommand BrowseBackgroundCommand { get; }
public ICommand ToggleMicMuteCommand { get; }
public ICommand ToggleGameMuteCommand { get; }
public ICommand OpenMicPickerCommand { get; }
public ICommand OpenSocialDialogCommand { get; }
public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; }
public ICommand ChooseRecordFolderCommand { 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 SaveLayoutCommand { get; }
public ICommand SaveLayoutAsCommand { get; }
public ICommand OpenLayoutCommand { 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());
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs());
OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile());
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");
}
// Restores the DPAPI-saved OAuth session so sign-in survives restarts.
// Best-effort: refresh a near-expiry access token; a session that can no
// longer refresh is discarded and the user signs in again.
private async Task LoadSavedSessionAsync()
{
try
{
var saved = TokenStore.Load();
if (saved == null) return;
_youtubeAuth.SetSession(saved);
if (saved.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
{
var refreshed = await Task.Run(() => _youtubeAuth.RefreshToken());
if (!refreshed)
{
TokenStore.Clear();
AppLog.Write("Saved session could not be refreshed; signed out");
_notifications.Info("Signed out of YouTube",
"The saved session expired — sign in again when you next go live.");
return;
}
}
IsConnected = true;
SyncConnectedAccount();
}
catch (Exception ex)
{
AppLog.Write($"LoadSavedSessionAsync failed: {ex}");
}
}
private void SyncConnectedAccount()
{
var channel = _youtubeAuth.CurrentChannel;
AccountAvatarUrl = channel?.ProfileImageUrl ?? string.Empty;
AccountDisplayName = channel?.DisplayName ?? string.Empty;
}
// ─── TASK 10 — Polar license validation ───
/// <summary>At startup, load the cached license state and re-validate online
/// if a key exists. If offline and within the 14-day grace period, the cached
/// premium state is trusted. If the grace period is exceeded, premium lapses.</summary>
private async Task ValidateLicenseAtStartupAsync()
{
var savedKey = _layoutStore.LoadLicenseKey();
if (string.IsNullOrWhiteSpace(savedKey))
{
IsPremium = false;
return;
}
LicenseKey = savedKey;
var validatedAt = _layoutStore.LoadLicenseValidatedAt();
var cachedPremium = _layoutStore.LoadIsPremium();
// If the last validation was within the grace period, trust the cache
// while we attempt an online re-validation in the background.
if (validatedAt.HasValue && DateTime.UtcNow - validatedAt.Value < OfflineGracePeriod)
{
IsPremium = cachedPremium;
}
// Try an online check — if it succeeds, it overrides the cache.
try
{
var service = new PolarLicenseService();
var result = await service.ValidateAsync(savedKey);
if (result.IsValid)
{
IsPremium = true;
_layoutStore.SaveIsPremium(true);
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
}
else
{
// Key revoked or expired — clear premium.
IsPremium = false;
_layoutStore.SaveIsPremium(false);
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
AppLog.Write($"License validation failed: {result.Error}");
_notifications.Warning("Premium is no longer active",
$"{result.Error} — watermark branding returns until the license is renewed.");
}
}
catch (Exception ex)
{
// Offline or network error — keep the cached state, log it.
AppLog.Write($"License re-validation skipped (offline?): {ex.Message}");
if (!validatedAt.HasValue || DateTime.UtcNow - validatedAt.Value >= OfflineGracePeriod)
{
IsPremium = false;
_layoutStore.SaveIsPremium(false);
}
}
}
/// <summary>Called from the Activate button in the About overlay. Validates
/// the entered key, persists it, and flips premium state.</summary>
private async Task ActivateLicenseAsync()
{
if (string.IsNullOrWhiteSpace(LicenseKey)) return;
IsLicenseValidating = true;
LicenseMessage = "Validating\u2026";
try
{
var service = new PolarLicenseService();
var result = await service.ValidateAsync(LicenseKey);
if (result.IsValid)
{
IsPremium = true;
_layoutStore.SaveLicenseKey(LicenseKey.Trim());
_layoutStore.SaveIsPremium(true);
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
LicenseMessage = "Premium activated! The watermark has been removed.";
AppLog.Write("License activated successfully");
}
else
{
IsPremium = false;
_layoutStore.SaveLicenseKey(null);
_layoutStore.SaveIsPremium(false);
LicenseMessage = result.Error ?? "Invalid license key.";
AppLog.Write($"License activation failed: {result.Error}");
}
}
catch (Exception ex)
{
LicenseMessage = $"Activation error: {ex.Message}";
AppLog.Write($"License activation error: {ex}");
}
finally
{
IsLicenseValidating = false;
}
}
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");
}
// After a layout load / file open: re-point the webcam identity, stop the
// previous device if it changed, and acquire the current one once per scene
// that uses it (CameraManager refcounts by DeviceId — one camera session).
private void ReacquireWebcam()
{
var previousDevice = _webcam?.DeviceId;
_webcam = _layoutStore.Webcam;
var newDevice = _webcam?.DeviceId;
if (!string.IsNullOrWhiteSpace(previousDevice) && previousDevice != newDevice)
_ = _cameraManager.ReleaseAllAsync(previousDevice);
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcam));
if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return;
if (previousDevice != newDevice)
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
_ = _cameraManager.AcquireAsync(newDevice);
// A session already running (same device, or one that produced a first
// frame) has its shared bitmap; freshly loaded configs must adopt it here,
// because PreviewBitmapChanged never re-fires for an existing bitmap.
if (_cameraManager.GetPreviewBitmap(newDevice) is { } running)
foreach (var config in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
config.VideoImageSource = running;
}
// CameraManager creates the shared WriteableBitmap on the UI thread at the
// device's frame size; every scene's webcam config picks it up from here. A
// bitmap means a real frame arrived — the camera is provably alive.
private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap)
{
if (_webcam?.DeviceId != deviceId) return;
WebcamError = null;
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
config.VideoImageSource = bitmap;
}
// The camera failed to start or died asynchronously (in use, offline, locked,
// no frames within the proof timeout) — surface it instead of a silent box.
private void OnCameraFailed(string deviceId, string message)
{
if (_webcam?.DeviceId != deviceId) return;
WebcamError = $"Webcam offline: {message}";
}
// ─── Screen background capture (live desktop/game) ───
private const string MonitorKeyPrefix = "monitor:";
/// <summary>Every background-enabled scene has exactly one background, kept at index 0 (bottom layer).
/// Returns null for a scene with <see cref="Scene.HasBackground"/> = false.</summary>
internal static Source? EnsureBackground(Scene scene)
{
if (!scene.HasBackground) return null;
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (background != null) return background;
background = CreateBackground(scene.Name);
scene.Elements.Insert(0, background);
return background;
}
/// <summary>The one Background row for a scene. Live gets the capture flavor
/// (Show Desktop / monitor switch / fullscreen-game detect) falling back to
/// live-background.jpg; every other screen is a static default-art layer.
/// Both are named "Background" — the user never sees capture jargon here.</summary>
internal static Source CreateBackground(string sceneName)
{
var isLive = SceneCatalog.Is(sceneName, SceneCatalog.Live);
return new Source
{
Name = "Background",
Type = isLive ? SourceType.DisplayCapture : SourceType.Background,
IsBackground = true,
IsEnabled = true,
X = 0,
Y = 0,
Width = MasterFrameWidth,
Height = MasterFrameHeight,
};
}
/// <summary>Heals any loaded layout to the locked Background model: exactly
/// one Background per canonical scene, correct flavor for the scene (static
/// art everywhere, DisplayCapture on Live only), at index 0, named
/// "Background". Duplicates and stale rows are dropped; missing ones seeded;
/// non-canonical scenes get HasBackground=false and lose any backgrounds.</summary>
internal static void NormalizeBackgrounds(IEnumerable<Scene> scenes)
{
foreach (var scene in scenes)
{
if (!SceneCatalog.HasBackground(scene.Name))
{
scene.HasBackground = false;
foreach (var stale in scene.Elements.OfType<Source>().Where(s => s.IsBackground).ToList())
scene.Elements.Remove(stale);
continue;
}
scene.HasBackground = true;
var isLive = SceneCatalog.Is(scene.Name, SceneCatalog.Live);
var wantedType = isLive ? SourceType.DisplayCapture : SourceType.Background;
var candidates = scene.Elements.OfType<Source>().Where(s => s.IsBackground).ToList();
var keep = candidates.FirstOrDefault(b => b.Type == wantedType) ?? candidates.FirstOrDefault();
foreach (var extra in candidates.Where(b => !ReferenceEquals(b, keep)))
scene.Elements.Remove(extra);
if (keep == null)
{
keep = CreateBackground(scene.Name);
scene.Elements.Insert(0, keep);
}
// Source.Type's setter derives IsBackground from the enum, so a
// flavor correction must re-assert the flag afterwards.
if (keep.Type != wantedType)
{
keep.Type = wantedType;
keep.IsBackground = true;
}
keep.Name = "Background";
keep.IsEnabled = true;
var index = scene.Elements.IndexOf(keep);
if (index > 0)
scene.Elements.Move(index, 0);
}
}
// Only the Live screen's background is a real-time surface. Every other
// lookup below (auto-capture, focus hooks, refresh) must ignore the static
// default-art rows so they can never start a desktop capture session.
private IEnumerable<Source> AllBackgrounds()
=> Scenes.Where(s => SceneCatalog.Is(s.Name, SceneCatalog.Live))
.SelectMany(s => s.Elements.OfType<Source>().Where(x => x.IsBackground));
// Full-screen game on its monitor, else null (static bg fallback — no
// infinity mirror). Called at launch (from ReacquireScreenCaptures, before
// the window steals focus) and on focus regain (RefreshBackgroundAutoCapture).
// When a background has ShowDesktop=true, returns the primary monitor instead
// of null so the desktop is always visible.
private string? ResolveAutoCaptureKey()
{
var gameMonitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
if (gameMonitor != null)
return $"{MonitorKeyPrefix}{gameMonitor}";
if (AllBackgrounds().Any(b => b.ShowDesktop))
return $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
return null;
}
// After a layout load / file open: make sure every background-enabled scene
// has a background, give any background without a persisted key the auto-detected
// one, then acquire one capture per unique background key. ScreenCaptureManager
// refcounts by key, so every scene pointing at the same monitor shares one
// session. Captures no longer referenced by any scene are released.
private void ReacquireScreenCaptures()
{
HealBackgrounds();
var auto = ResolveAutoCaptureKey();
if (auto == null)
{
foreach (var background in AllBackgrounds())
background.CaptureKey = null;
}
else
{
foreach (var background in AllBackgrounds())
if (string.IsNullOrWhiteSpace(background.CaptureKey))
background.CaptureKey = auto;
}
var keys = AllBackgrounds()
.Select(b => b.CaptureKey!)
.Where(k => !string.IsNullOrWhiteSpace(k))
.ToHashSet();
// A layout reload replaces the scenes; stop captures that are no longer
// referenced (mirrors ReacquireWebcam's device swap).
foreach (var stale in _liveCaptureKeys.Except(keys))
_ = _screenCaptureManager.ReleaseAllAsync(stale);
_liveCaptureKeys = keys;
foreach (var key in keys)
_ = _screenCaptureManager.AcquireAsync(key);
_fullScreenDetector.StartWatching();
}
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);
};
}
}
// ScreenCaptureManager creates the shared WriteableBitmap on the UI thread;
// every background pointing at that key picks it up.
private void OnScreenPreviewBitmapChanged(string key, WriteableBitmap bitmap)
{
var activeBackground = StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (activeBackground?.CaptureKey == key)
BackgroundImage = bitmap;
foreach (var background in AllBackgrounds().Where(b => b.CaptureKey == key))
background.VideoImageSource = bitmap;
}
// EVENT_SYSTEM_FOREGROUND fires whenever the foreground window changes.
// When a full-screen game appears on a different monitor than the current
// background, auto-redesignate. When ShowDesktop is enabled and no fullscreen
// game is detected, fall back to the primary monitor (desktop view).
private void OnFullscreenMonitorChanged(int? monitor)
{
if (monitor != null)
{
var newKey = $"{MonitorKeyPrefix}{monitor}";
var current = AllBackgrounds().FirstOrDefault();
if (current?.CaptureKey == newKey) return;
_ = RedesignateBackgroundAsync(newKey);
}
else if (AllBackgrounds().Any(b => b.ShowDesktop))
{
var desktopKey = $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
var current = AllBackgrounds().FirstOrDefault();
if (current?.CaptureKey == desktopKey) return;
_ = RedesignateBackgroundAsync(desktopKey);
}
}
// Manual refresh: the "Refresh Capture" context menu item. Reads the
// current foreground directly (no caching needed — the hook handles
// automatic detection).
public void RefreshBackgroundAutoCapture()
{
var detected = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
if (detected == null) return;
_ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{detected}");
}
/// <summary>Whether the active background is in Show Desktop mode.
/// Bindable IsChecked property for the context menu toggle.</summary>
public bool BackgroundShowDesktop
{
get => StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground)?.ShowDesktop ?? false;
set
{
var background = StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (background == null || background.ShowDesktop == value) return;
background.ShowDesktop = value;
OnPropertyChanged(nameof(BackgroundShowDesktop));
ScheduleSave();
if (value)
{
_ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}");
}
else
{
var gameMonitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
if (gameMonitor != null)
_ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{gameMonitor}");
else
_ = ClearBackgroundCaptureAsync();
}
}
}
private void ToggleBackgroundShowDesktop() => BackgroundShowDesktop = !BackgroundShowDesktop;
private void BrowseBackground()
{
var scene = StagedScene;
if (scene == null) return;
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (bg == null) return;
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = "Select background image",
Filter = "Image files (*.jpg;*.jpeg;*.png;*.bmp)|*.jpg;*.jpeg;*.png;*.bmp|All files (*.*)|*.*",
};
if (dlg.ShowDialog() != true) return;
bg.CustomBackgroundPath = dlg.FileName;
bg.UseDefaultBackground = false;
try
{
using var fs = new System.IO.FileStream(dlg.FileName, System.IO.FileMode.Open, System.IO.FileAccess.Read);
var bytes = new byte[fs.Length];
fs.Read(bytes, 0, bytes.Length);
var assetId = AddAsset(bytes);
if (assetId != null)
bg.AssetId = assetId;
}
catch { /* non-critical */ }
UpdateActiveBackground();
RefreshSnapshotsAsync();
ScheduleSave();
}
/// <summary>Release the background capture so the static placeholder image shows.</summary>
private async Task ClearBackgroundCaptureAsync()
{
var oldKeys = AllBackgrounds()
.Select(b => b.CaptureKey!)
.Where(k => !string.IsNullOrWhiteSpace(k))
.ToHashSet();
foreach (var background in AllBackgrounds())
{
background.CaptureKey = null;
background.VideoImageSource = null;
}
foreach (var key in oldKeys)
await _screenCaptureManager.ReleaseAllAsync(key);
_liveCaptureKeys.Clear();
UpdateBackgroundImage();
ScheduleSave();
}
private async Task RedesignateBackgroundAsync(string newKey)
{
var oldKeys = AllBackgrounds()
.Select(b => b.CaptureKey!)
.Where(k => !string.IsNullOrWhiteSpace(k))
.ToHashSet();
foreach (var background in AllBackgrounds())
background.CaptureKey = newKey;
foreach (var oldKey in oldKeys.Where(k => k != newKey))
await _screenCaptureManager.ReleaseAllAsync(oldKey);
if (!oldKeys.Contains(newKey))
await _screenCaptureManager.AcquireAsync(newKey);
_liveCaptureKeys = new HashSet<string> { newKey };
UpdateBackgroundImage();
ScheduleSave();
}
// "Change Capture…": the OS GraphicsCapturePicker designates the target.
// Picks are transient (see ScreenCaptureSourceFactory) — a reload falls
// back to auto-detection.
public async Task ChangeBackgroundCaptureAsync()
{
var key = await _screenCaptureFactory.PickAsync();
if (key == null) return;
await RedesignateBackgroundAsync(key);
}
// In-app display picker: point every background at a specific monitor.
private void SetBackgroundCapture(DisplayInfo? display)
{
if (display == null) return;
_ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{display.Index}");
}
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 SaveLayoutAs()
{
var dialog = new SaveFileDialog
{
Title = "Save Layout As",
Filter = "LlamaCasty layout (*.yll)|*.yll|All files (*.*)|*.*",
DefaultExt = ".yll",
FileName = "my-layout.yll",
};
if (dialog.ShowDialog() != true) return;
_layoutStore.Dispose();
_layoutStore = new LayoutStore(dialog.FileName);
_activeLayoutPath = dialog.FileName;
SaveLayoutNow();
}
private void OpenLayoutFile()
{
var dialog = new OpenFileDialog
{
Title = "Open Layout",
Filter = "LlamaCasty layout (*.yll;*.db)|*.yll;*.db|All files (*.*)|*.*",
};
if (dialog.ShowDialog() != true) return;
_layoutStore.Dispose();
_layoutStore = new LayoutStore(dialog.FileName);
_activeLayoutPath = dialog.FileName;
LoadLayout();
}
// ─── Auto-save wiring ───
private void OnScenesChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
foreach (Scene scene in e.NewItems)
WireScene(scene);
if (e.OldItems != null)
foreach (Scene scene in e.OldItems)
UnwireScene(scene);
OnPropertyChanged(nameof(MissingScenes));
OnPropertyChanged(nameof(ShowAddScene));
ScheduleSave();
}
private void WireScene(Scene scene)
{
scene.PropertyChanged += OnScenePropertyChanged;
scene.Elements.CollectionChanged += OnElementsChanged;
}
private void UnwireScene(Scene scene)
{
scene.PropertyChanged -= OnScenePropertyChanged;
scene.Elements.CollectionChanged -= OnElementsChanged;
}
private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e)
{
// A rename can drop a canonical scene in/out of MissingScenes.
OnPropertyChanged(nameof(MissingScenes));
OnPropertyChanged(nameof(ShowAddScene));
ScheduleSave();
}
private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
foreach (SceneElement element in e.NewItems)
element.PropertyChanged += OnElementPropertyChanged;
if (e.OldItems != null)
foreach (SceneElement element in e.OldItems)
element.PropertyChanged -= OnElementPropertyChanged;
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanAddYouTubeChat));
ScheduleSave();
}
private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
ScheduleSave();
}
private void ScheduleSave()
{
if (_isLoading || _saveDebounce == null) return;
_saveDebounce.Stop();
_saveDebounce.Start();
}
// Adds a canonical scene by name — only when it's actually missing. The
// background flag follows the policy (Live yes, everyone else no).
private void AddScene(string name)
{
if (!SceneCatalog.IsCanonical(name)) return;
if (Scenes.Any(s => SceneCatalog.Is(s.Name, name))) return;
var scene = new Scene
{
Name = name.Trim(),
IsChatScene = SceneCatalog.IsChat(name),
HasBackground = SceneCatalog.HasBackground(name),
};
EnsureBackground(scene);
Scenes.Add(scene);
StagedScene = scene;
}
// ─── Background model (TASK 25): exactly ONE Background per screen, index 0,
// never removable or re-addable. Static default art on Starting/BRB/Chat/Ending;
// on Live it's the capture-flavored row (Show Desktop / monitor switch /
// fullscreen-game detect) falling back to live-background.jpg. ───
private static string? DefaultBackgroundAsset(string sceneName) => sceneName switch
{
SceneCatalog.Starting => "starting-background.jpg",
SceneCatalog.Brb => "brb-background.jpg",
SceneCatalog.Chat => "chat-background.jpg",
SceneCatalog.Ending => "ending-background.jpg",
SceneCatalog.Live => "live-background.jpg",
_ => null,
};
/// <summary>Heals every scene to the locked Background model after a layout
/// load: keeps the correct flavor per scene at index 0 named "Background",
/// drops duplicates/stale rows, re-seeds missing ones.</summary>
private void HealBackgrounds()
{
NormalizeBackgrounds(Scenes);
foreach (var scene in Scenes)
{
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (background != null)
StampDefaultBackgroundAsset(background, scene.Name);
}
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
}
/// <summary>Points a background row at its scene's shipped default art when it
/// carries none. A custom image (Browse…) always wins and is never overwritten.</summary>
private void StampDefaultBackgroundAsset(Source background, string sceneName)
{
if (!string.IsNullOrWhiteSpace(background.AssetId)) return;
if (!background.UseDefaultBackground && !string.IsNullOrWhiteSpace(background.CustomBackgroundPath)) return;
var asset = DefaultBackgroundAsset(sceneName);
if (asset == null) return;
try
{
var uri = new Uri($"pack://application:,,,/Assets/{asset}", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return;
using var ms = new MemoryStream();
stream.CopyTo(ms);
background.AssetId = AddAsset(ms.ToArray());
}
catch { /* non-critical — the app works without default art */ }
}
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}";
}
// Adds the webcam to the active scene. The creator ALWAYS picks from the
// cameras Windows has registered — never silently resurrects the previous
// camera (which is what happened after deleting one scene's webcam while
// another scene still used it). Picking a different camera than the current
// app-wide one swaps it everywhere, so the single-identity model stays honest;
// each scene's placement config is independent (webcam.{scene}.config).
private async Task AddWebcamToStagedSceneAsync()
{
var scene = StagedScene;
if (scene == null || scene.WebcamConfig != null) return;
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
{
Owner = Application.Current.MainWindow
};
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
var device = dialog.PickedDevice;
if (_webcam == null)
{
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcam));
}
else if (_webcam.DeviceId != device.Id)
{
await SwapWebcamIdentityAsync(device);
}
var config = new WebcamSceneConfig
{
WebcamId = _webcam.Id,
Name = _webcam.Name,
Width = 480,
Height = 270,
X = MasterFrameWidth - 480 - 32,
Y = MasterFrameHeight - 270 - 32,
};
scene.Elements.Add(config);
SelectedElement = config;
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
// PreviewBitmapChanged fires only on the camera's first frame, so a config
// added while the session is already running must pick up the shared bitmap
// directly (it's written in place from then on).
if (_cameraManager.GetPreviewBitmap(_webcam.DeviceId) is { } running)
config.VideoImageSource = running;
var started = await _cameraManager.AcquireAsync(_webcam.DeviceId);
if (!started)
{
_notifications.Warning("Camera unavailable",
WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.");
}
}
// Swaps the device on the app-wide webcam identity. The old device is stopped
// unconditionally; each scene that uses the webcam re-acquires the new one so
// the per-config refcount stays honest. Only called when the device differs.
private async Task SwapWebcamIdentityAsync(CameraDeviceInfo device)
{
var oldDevice = _webcam!.DeviceId;
_webcam.DeviceId = device.Id;
_webcam.Name = device.DisplayName;
ScheduleSave();
if (!string.IsNullOrWhiteSpace(oldDevice) && oldDevice != device.Id)
await _cameraManager.ReleaseAllAsync(oldDevice);
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
{
var started = await _cameraManager.AcquireAsync(device.Id);
if (!started)
{
_notifications.Warning("Camera unavailable",
WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.");
break;
}
}
}
// "Change Webcam…" from the webcam's context menu: picker, then swap the
// app-wide identity if a different device was chosen.
private async Task ChangeWebcamAsync()
{
if (_webcam == null) return;
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
{
Owner = Application.Current.MainWindow
};
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
if (dialog.PickedDevice.Id == _webcam.DeviceId) return;
await SwapWebcamIdentityAsync(dialog.PickedDevice);
}
// "Show Webcam" from a right-click on the empty preview. Unhides this scene's
// existing (hidden) config — the config row survives Hide in this scene — or
// adds the webcam here for the first time (which opens the camera picker).
private void ShowWebcamInStagedScene()
{
var scene = StagedScene;
if (scene == null) return;
var config = scene.WebcamConfig;
if (config != null)
{
config.IsVisible = true;
SelectedElement = config;
return;
}
_ = AddWebcamToStagedSceneAsync();
}
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 });
}
// Runs the OAuth flow (browser + loopback callback) on a background thread.
// The resulting channel is returned to the caller and persisted via the
// auth service's sessionChanged hook.
private async Task<YouTubeChannel?> SignInAsync()
{
try
{
var channel = await Task.Run(() => _youtubeAuth.AuthenticateAsync());
if (channel == null)
{
_notifications.Warning("Sign-in was unsuccessful", "Please try again.");
return null;
}
IsConnected = true;
SyncConnectedAccount();
return channel;
}
catch (Exception ex)
{
_notifications.Error("Sign-in failed", ex.Message);
return null;
}
}
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 ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ytLlive", "recordings");
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 finalName = RecordingFile.BuildFinalName(_recordStartTime, _recordLength) + ".mp4";
var dir = Path.GetDirectoryName(startPath)!;
var finalPath = UniquePath(Path.Combine(dir, finalName));
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 ??
Environment.GetFolderPath(Environment.SpecialFolder.MyVideos),
};
if (dialog.ShowDialog() == true)
{
_recordFolder = dialog.FolderName;
_layoutStore.SaveRecordFolder(_recordFolder);
_notifications.Info("Record folder set", _recordFolder);
}
}
private void OnMicLevelChanged(float level)
{
// NAudio raises on its capture thread; marshal to the UI thread so the
// meter binding updates safely.
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => AudioLevel = level);
else
AudioLevel = level;
}
private void OnLoopbackLevelChanged(float level)
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => GameAudioLevel = level);
else
GameAudioLevel = level;
}
private void OnMicConnected()
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => MicStatus = MicStatus.Connected);
else
MicStatus = MicStatus.Connected;
}
private void OnMicFailed(Exception ex)
{
// The mixer logs the failure detail; here we only flip the dot to yellow.
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => MicStatus = MicStatus.Problem);
else
MicStatus = MicStatus.Problem;
}
/// <summary>Starts capture once at startup: with no mic device present the
/// dot stays red and capture never starts; otherwise the mixer starts and
/// raises MicConnected (green) or MicFailed (yellow).</summary>
private async Task StartMicCaptureAsync()
{
try
{
var mics = await _microphoneEnumerator.GetMicrophonesAsync();
if (mics.Count == 0)
{
MicStatus = MicStatus.NotConnected;
AppLog.Write("Mic: no capture devices found — mic capture not started");
_notifications.Warning("No microphone found",
"Mic capture wasn't started — plug one in and pick it from the mixer.");
return;
}
_audioMixer.Start();
}
catch (Exception ex)
{
AppLog.Write($"Mic: device check failed: {ex.Message}");
}
}
// 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));
}
// ─── Social bar ───
/// <summary>
/// Resolves a missing nodeinfo software name (mastodon, peertube, ...) for every
/// fediverse entry that doesn't have one, so the bar shows the instance's real
/// logo instead of the generic fediverse glyph. Best-effort: a failed resolution
/// leaves the glyph untouched. Returns handle → software for what was resolved —
/// the caller applies + persists (the app hops to the dispatcher; a test applies
/// directly).
/// </summary>
public static async Task<Dictionary<string, string>> HealFediverseSoftwareAsync(
SocialsConfig socials,
ISocialValidator validator,
Action<string>? log = null)
{
var resolved = new Dictionary<string, string>();
if (socials == null || validator == null) return resolved;
foreach (var entry in socials.Entries)
{
if (entry.Service != SocialService.Fediverse
|| !string.IsNullOrWhiteSpace(entry.FediverseSoftware))
continue;
if (!SocialServiceIcons.TryParseFediverse(entry.Handle, out _, out var domain))
continue;
var software = await validator.ResolveFediverseSoftwareAsync(
domain, System.Threading.CancellationToken.None);
if (string.IsNullOrWhiteSpace(software)) continue;
resolved[entry.Handle] = software;
log?.Invoke($"Socials heal: {entry.Handle} → {software}");
}
return resolved;
}
/// <summary>Runs the heal off the UI thread and applies + saves any result.</summary>
private void HealFediverseSoftwareInBackground()
{
var socials = _socials;
if (socials == null) return;
_ = Task.Run(async () =>
{
try
{
var resolved = await HealFediverseSoftwareAsync(
socials, _socialValidator, m => AppLog.Write(m));
if (resolved.Count == 0) return;
await Application.Current.Dispatcher.InvokeAsync(() =>
{
var current = _socials;
if (current == null) return;
var applied = false;
foreach (var entry in current.Entries)
{
if (!resolved.TryGetValue(entry.Handle, out var software)) continue;
if (entry.FediverseSoftware == software) continue;
entry.FediverseSoftware = software;
applied = true;
}
if (applied) NotifySocialsChanged(); // re-renders the bar + saves
});
}
catch (Exception ex)
{
AppLog.Write($"Socials heal failed: {ex.Message}");
}
});
}
/// <summary>
/// Re-rasterizes the social bar strip the output compositor overlays. UI thread
/// only (WPF rendering); the resulting frame is immutable, so the frame pump may
/// read it from its own thread. Null when the bar is off or empty.
/// </summary>
private void RenderSocialBarFrame()
{
_socialBarFrame = _socials != null && _socials.BarEnabled
? SocialBarRenderer.Render(_socials.Entries)
: null;
}
/// <summary>
/// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate,
/// validation). On save the working copy replaces <see cref="_socials"/>.
/// </summary>
private void OpenSocialDialog()
{
var dialog = new SocialsDialogViewModel(
_socialValidator,
SignInAsync,
SignOutYouTubeAsync,
_youtubeAuth.CurrentChannel,
_socials);
var window = new ytLive.SocialsDialog(dialog) { Owner = Application.Current.MainWindow };
if (window.ShowDialog() == true)
{
_socials = dialog.BuildSocialsConfig();
NotifySocialsChanged();
}
}
private void NotifySocialsChanged()
{
RenderSocialBarFrame();
OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(SocialBarVisible));
OnPropertyChanged(nameof(SocialBarDotBrush));
ScheduleSave();
}
/// <summary>Sets the bar's edge; called by the preview click-toggle.</summary>
public void SetSocialBarPosition(SocialBarPosition position)
{
if (_socials == null || _socials.BarPosition == position) return;
_socials.BarPosition = position;
OnPropertyChanged(nameof(SocialBarTop));
ScheduleSave();
}
/// <summary>Preview click flips the bar top ⇄ bottom (KISS — no drag math).</summary>
public void ToggleSocialBarPosition()
=> SetSocialBarPosition(_socials?.BarPosition == SocialBarPosition.Top
? SocialBarPosition.Bottom
: SocialBarPosition.Top);
/// <summary>Signs out of YouTube (the delete-the-YouTube-slot action in the dialog).</summary>
private async Task SignOutYouTubeAsync()
{
_youtubeAuth.ClearSession();
TokenStore.Clear();
IsConnected = false;
SyncConnectedAccount();
NotifySocialsChanged();
AppLog.Write("Socials: signed out of YouTube");
await Task.CompletedTask;
}
private async Task ChangeAccountAsync()
{
await SignOutYouTubeAsync();
await SignInAsync();
}
}