Files
ytLlive/ViewModels/MainViewModel.cs
T

2353 lines
89 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 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 _gameAudioTimer;
private Scene? _activeScene;
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;
private bool _isGameAudioBarVisible;
private StreamStatus _streamStatus = StreamStatus.Offline;
private StreamHealth _currentHealth = new();
private string _streamTitle = string.Empty;
private string _streamDescription = string.Empty;
private string _streamVisibility = "Public";
private string _windowTitle = "ytLlive";
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;
private bool _isSettingsOpen;
private bool _isBugOpen;
private bool _isFeatureOpen;
private bool _isAboutOpen;
private string _overlayTitle = string.Empty;
private string _defaultStreamTitle = string.Empty;
private string _defaultStreamDescription = string.Empty;
private string _defaultStreamVisibility = "Public";
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;
// 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;
// Screen backdrop: 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 IGameAudioDetector _gameAudioDetector;
private int? _lastForegroundFullScreenMonitor;
private CancellationTokenSource? _deactivateCts;
private ImageSource? _backdropImage;
private HashSet<string> _liveCaptureKeys = new();
// Branding flash (monetization): a full-frame "made with ytLlive!" shown
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
// BrandFlashEnabled = false. See ai.md "Monetization".
private readonly DispatcherTimer _brandFlashTimer;
private readonly DispatcherTimer _brandFlashOffTimer;
private bool _brandFlashEnabled = true;
private bool _brandFlashActive;
private const string SupportEmail = "gramps@llamachile.shop";
private const string ChannelUrl = "https://youtube.com/@llamachileshop";
public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}";
public ObservableCollection<Scene> Scenes { get; } = new();
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
public ObservableCollection<DisplayInfo> Displays { get; } = new();
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
public Scene? ActiveScene
{
get => _activeScene;
set
{
if (value != null && value.IsHidden) return;
if (SetProperty(ref _activeScene, value))
{
SelectedElement = null;
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
OnPropertyChanged(nameof(CanChangeBackdrop));
OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
UpdateActiveBackground();
UpdateBackdropImage();
}
}
}
public ImageSource? ActiveBackgroundImage
{
get => _activeBackgroundImage;
private set => SetProperty(ref _activeBackgroundImage, value);
}
/// <summary>The active scene's live-capture backdrop frame (rendered below the
/// static background). Set from the shared capture bitmap as it arrives.</summary>
public ImageSource? BackdropImage
{
get => _backdropImage;
private set
{
if (SetProperty(ref _backdropImage, value))
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
}
}
public SceneElement? SelectedElement
{
get => _selectedElement;
set
{
if (SetProperty(ref _selectedElement, value))
OnPropertyChanged(nameof(IsWebcamSelected));
}
}
/// <summary>The Add → Webcam menu item: enabled when the active scene doesn't show the webcam yet.</summary>
public bool CanAddWebcamToActiveScene => ActiveScene?.WebcamConfig == null;
/// <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 CanShowWebcamInActiveScene
=> ActiveScene 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>Freemium: 6 slots — YT + 1 other, the rest locked. Premium seam: all 6 open.</summary>
private static bool IsPremium => false; // itch.io unlock deferred — seam only
/// <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>
/// 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(RecDotBrush));
OnPropertyChanged(nameof(RecTextBrush));
OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(IsLivePrivate));
OnPropertyChanged(nameof(ShowStartStream));
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
UpdateLiveVisuals();
}
}
}
public bool IsConnected
{
get => _isConnected;
set
{
if (SetProperty(ref _isConnected, value))
OnPropertyChanged(nameof(ShowStartStream));
}
}
/// <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) * 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;
_audioMixer.RestartMic(); // swap the live device immediately
}
}
// ─── Mic status dot (the MIC button) ───
private static readonly SolidColorBrush MicProblemBrush = CreateBrush("#f1c40f");
/// <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): visible only while a full-screen game
// ─── is producing sound (IGameAudioDetector). Meter + mute + volume mirror
// ─── the mic bar. ───
/// <summary>True while the game audio bar should be shown (driven by
/// IGameAudioDetector via the poll timer).</summary>
public bool IsGameAudioBarVisible
{
get => _isGameAudioBarVisible;
private set => SetProperty(ref _isGameAudioBarVisible, value);
}
/// <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) * GameAudioVolume);
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));
}
}
}
/// <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 => !IsLive ? "#555555" : IsLivePrivate ? "#8f1f1f" : "#e94560";
public string RecTextBrush => IsLive ? "#ffffff" : "#888888";
public double RecDotOpacity => IsLive ? _recDotPulse : 0.55;
public bool ShowStartStream => IsOffline;
public bool ShowChatInactiveMessage => !IsLive;
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackdropImage == null;
public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Elements.Count == 0;
/// <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 backdrop.</summary>
public bool CanChangeBackdrop => ActiveScene?.HasBackdrop == true;
// 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()
{
var background = ActiveScene?.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 UpdateBackdropImage()
{
var backdrop = ActiveScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
BackdropImage = backdrop?.DisplaySource;
}
public StreamHealth CurrentHealth
{
get => _currentHealth;
set => SetProperty(ref _currentHealth, 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(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);
}
public bool IsAnyOverlayOpen => IsSettingsOpen || IsBugOpen || IsFeatureOpen || IsAboutOpen;
public string OverlayTitle
{
get => _overlayTitle;
private set => SetProperty(ref _overlayTitle, value);
}
public string DefaultStreamTitle
{
get => _defaultStreamTitle;
set => SetProperty(ref _defaultStreamTitle, value);
}
public string DefaultStreamDescription
{
get => _defaultStreamDescription;
set => SetProperty(ref _defaultStreamDescription, value);
}
public string DefaultStreamVisibility
{
get => _defaultStreamVisibility;
set => SetProperty(ref _defaultStreamVisibility, value);
}
// ─── 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);
}
// 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 SetBackdropDisplayCommand { 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 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 SaveLayoutCommand { get; }
public ICommand SaveLayoutAsCommand { get; }
public ICommand OpenLayoutCommand { 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; };
Scenes.CollectionChanged += OnScenesChanged;
AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty));
AddSourceCommand = new RelayCommand(parameter => AddSource(parameter));
AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToActiveSceneAsync());
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(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
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));
StartStreamCommand = new RelayCommand(_ => BeginGoLive());
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive);
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs());
OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile());
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
_activeLayoutPath = _layoutStore.ActivePath;
_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();
_audioMixer = new AudioMixer(
new WasapiMicAudioSource(() => MicSourceName),
new WasapiLoopbackAudioSource(),
message => AppLog.Write(message));
_audioMixer.MicLevelChanged += OnMicLevelChanged;
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
_audioMixer.MicConnected += OnMicConnected;
_audioMixer.MicFailed += OnMicFailed;
_ = StartMicCaptureAsync();
_fullScreenDetector = new Win32FullScreenDetector();
foreach (var display in _fullScreenDetector.GetDisplays())
Displays.Add(display);
_gameAudioDetector = new GameAudioDetector(
() => _fullScreenDetector.GetForegroundFullScreenMonitorIndex(),
() => (float)GameAudioLevel);
_gameAudioDetector.IsGameAudioActiveChanged += OnGameAudioActiveChanged;
_gameAudioTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_gameAudioTimer.Tick += OnGameAudioPollTick;
_gameAudioTimer.Start();
_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}");
_rtmpUrlProvider = () => null; // TASK 5: the reusable stream's ingest URL
_framePump = new FramePump(
sceneProvider: () => ActiveScene,
frameResolver: ResolveOutputFrame,
compositorOptions: BuildCompositorOptions,
encoderOptions: BuildEncoderOptions,
encoderFactory: () => new FfmpegEncoder(new FfmpegLocator()),
log: message => AppLog.Write(message),
socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom));
_framePump.Failed += OnFramePumpFailed;
_framePump.HealthUpdated += OnFramePumpHealthUpdated;
LoadLayout();
_ = LoadSavedSessionAsync();
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");
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;
}
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);
// The live backdrop belongs to Live only; a pre-policy DB may have
// backdrops lingering in other scenes — drop them, then
// ReacquireScreenCaptures heals Live's.
EnforceBackdropPolicy(Scenes);
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
{
ClampWebcamToBounds(config, scene.Name);
HealLegacySquareRect(config);
}
AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded");
}
finally
{
_isLoading = false;
}
ActiveScene = Scenes.FirstOrDefault();
UpdateActiveBackground();
UpdateBackdropImage();
ReacquireWebcam();
ReacquireScreenCaptures();
_socials = _layoutStore.Socials;
RenderSocialBarFrame();
HealFediverseSoftwareInBackground();
OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(SocialBarVisible));
OnPropertyChanged(nameof(SocialBarDotBrush));
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(CanAddWebcamToActiveScene));
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 backdrop capture (live desktop/game) ───
private const string MonitorKeyPrefix = "monitor:";
/// <summary>Every backdrop-enabled scene has exactly one backdrop, kept at index 0 (bottom layer).
/// Returns null for a scene with <see cref="Scene.HasBackdrop"/> = false.</summary>
internal static Source? EnsureBackdrop(Scene scene)
{
if (!scene.HasBackdrop) return null;
var backdrop = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
if (backdrop != null) return backdrop;
backdrop = new Source
{
Name = "Backdrop",
Type = SourceType.DisplayCapture,
IsBackdrop = true,
IsEnabled = true,
X = 0,
Y = 0,
Width = MasterFrameWidth,
Height = MasterFrameHeight,
};
scene.Elements.Insert(0, backdrop);
return backdrop;
}
// The backdrop belongs to the Live scene alone. Runs after every layout
// load: the flag is normalized by scene name and any backdrop lingering in a
// non-Live scene (from a pre-policy DB) is removed. ReacquireScreenCaptures
// re-heals Live's backdrop right after.
internal static void EnforceBackdropPolicy(IEnumerable<Scene> scenes)
{
foreach (var scene in scenes)
{
scene.HasBackdrop = SceneCatalog.HasBackdrop(scene.Name);
if (scene.HasBackdrop) continue;
var backdrop = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
if (backdrop != null)
scene.Elements.Remove(backdrop);
}
}
private IEnumerable<Source> AllBackdrops()
=> Scenes.SelectMany(s => s.Elements.OfType<Source>().Where(x => x.IsBackdrop));
// Full-screen game on its monitor, else the primary display. Called at launch
// (from ReacquireScreenCaptures, before the window steals focus) and on
// focus regain (RefreshBackdropAutoCapture).
private string ResolveAutoCaptureKey()
=> $"{MonitorKeyPrefix}{_fullScreenDetector.GetForegroundFullScreenMonitorIndex() ?? _fullScreenDetector.PrimaryMonitorIndex()}";
// After a layout load / file open: make sure every backdrop-enabled scene
// has a backdrop, give any backdrop without a persisted key the auto-detected
// one, then acquire one capture per unique backdrop 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()
{
foreach (var scene in Scenes)
EnsureBackdrop(scene);
var auto = ResolveAutoCaptureKey();
foreach (var backdrop in AllBackdrops())
if (string.IsNullOrWhiteSpace(backdrop.CaptureKey))
backdrop.CaptureKey = auto;
var keys = AllBackdrops()
.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);
}
// ScreenCaptureManager creates the shared WriteableBitmap on the UI thread;
// every backdrop pointing at that key picks it up.
private void OnScreenPreviewBitmapChanged(string key, WriteableBitmap bitmap)
{
var activeBackdrop = ActiveScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
if (activeBackdrop?.CaptureKey == key)
BackdropImage = bitmap;
foreach (var backdrop in AllBackdrops().Where(b => b.CaptureKey == key))
backdrop.VideoImageSource = bitmap;
}
// One-shot foreground snapshot ~250ms after we lose focus, so the next
// Activated re-detect can see the full-screen game the user switched to.
// A single sample per deactivation — not a session listener or a poller.
public void NoteBackgroundWindow()
{
_deactivateCts?.Cancel();
_deactivateCts = new CancellationTokenSource();
var token = _deactivateCts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(250, token);
var monitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
if (!token.IsCancellationRequested)
_lastForegroundFullScreenMonitor = monitor;
}
catch (OperationCanceledException) { }
});
}
// Re-runs full-screen detection at launch and when the app regains focus.
// A null detection (no full-screen foreground window — our app, the desktop,
// a normal window) leaves the current capture alone.
public void RefreshBackdropAutoCapture()
{
var detected = _lastForegroundFullScreenMonitor ?? _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
_lastForegroundFullScreenMonitor = null;
if (detected == null) return;
_ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{detected}");
}
private async Task RedesignateBackdropAsync(string newKey)
{
var oldKeys = AllBackdrops()
.Select(b => b.CaptureKey!)
.Where(k => !string.IsNullOrWhiteSpace(k))
.ToHashSet();
foreach (var backdrop in AllBackdrops())
backdrop.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 };
UpdateBackdropImage();
ScheduleSave();
}
// "Change Capture…": the OS GraphicsCapturePicker designates the target.
// Picks are transient (see ScreenCaptureSourceFactory) — a reload falls
// back to auto-detection.
public async Task ChangeBackdropCaptureAsync()
{
var key = await _screenCaptureFactory.PickAsync();
if (key == null) return;
await RedesignateBackdropAsync(key);
}
// In-app display picker: point every backdrop at a specific monitor.
private void SetBackdropCapture(DisplayInfo? display)
{
if (display == null) return;
_ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{display.Index}");
}
public void Shutdown()
{
_saveDebounce?.Stop();
SaveLayoutNow();
_gameAudioTimer.Stop();
_audioMixer.Dispose();
_framePump.Dispose();
_cameraManager.Dispose();
_screenCaptureManager.Dispose();
_layoutStore.Dispose();
}
public void SaveLayoutNow()
{
_saveDebounce?.Stop();
try
{
_layoutStore.Save(Scenes, _webcam, _socials);
}
catch (Exception ex)
{
Debug.WriteLine($"Layout save failed: {ex.Message}");
}
}
private void SaveLayoutAs()
{
var dialog = new SaveFileDialog
{
Title = "Save Layout As",
Filter = "ytLlive 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 = "ytLlive 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(CanAddWebcamToActiveScene));
ScheduleSave();
}
private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
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
// backdrop 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),
HasBackdrop = SceneCatalog.HasBackdrop(name),
};
EnsureBackdrop(scene);
Scenes.Add(scene);
ActiveScene = scene;
}
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;
}
private void AddSource(object? parameter)
{
var scene = ActiveScene;
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.DisplayCapture => "Screen",
SourceType.WindowCapture => "Window",
SourceType.Background => "Background",
SourceType.Image => "Image",
SourceType.TextOverlay => "Text",
_ => "Source",
};
if (sourceType == SourceType.Background)
{
var bytes = PickImageBytes("Choose a backdrop image");
if (bytes == null) return;
var assetId = AddAsset(bytes);
if (assetId == null) return;
var existing = scene.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
if (existing != null)
{
existing.AssetId = assetId;
UpdateActiveBackground();
return;
}
scene.Elements.Add(new Source { Name = baseName, Type = SourceType.Background, AssetId = assetId });
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
return;
}
scene.Elements.Add(new Source { Name = NextSourceName(scene, baseName), Type = sourceType });
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 AddWebcamToActiveSceneAsync()
{
var scene = ActiveScene;
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));
}
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)
{
MessageBox.Show(
WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
// 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)
{
MessageBox.Show(
WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
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 ShowWebcamInActiveScene()
{
var scene = ActiveScene;
if (scene == null) return;
var config = scene.WebcamConfig;
if (config != null)
{
config.IsVisible = true;
SelectedElement = config;
return;
}
_ = AddWebcamToActiveSceneAsync();
}
private void AddImage()
{
var scene = ActiveScene;
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 static 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)
{
MessageBox.Show($"Couldn't read that image: {ex.Message}", "ytLlive",
MessageBoxButton.OK, MessageBoxImage.Warning);
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 = ActiveScene;
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 = ActiveScene;
if (scene == null || element == null) return;
if (element is Source { IsBackdrop: true }) return;
if (element is WebcamSceneConfig && _webcam != null)
{
if (SelectedElement == element)
SelectedElement = null;
_ = _cameraManager.ReleaseAsync(_webcam.DeviceId);
}
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(CanAddWebcamToActiveScene));
OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
}
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;
}
private void SubmitBug()
{
var body = string.Join(Environment.NewLine,
BugReportText.Trim(),
string.Empty,
$"ytLlive {AppVersionLabel}",
string.IsNullOrWhiteSpace(BugReportEmail) ? string.Empty : $"Reply-to: {BugReportEmail.Trim()}");
ComposeEmail("[ytLlive Bug Report]", body);
ShowOverlay();
}
private void SubmitFeature()
{
var body = string.Join(Environment.NewLine,
FeatureRequestText.Trim(),
string.Empty,
$"ytLlive {AppVersionLabel}",
string.IsNullOrWhiteSpace(FeatureRequestEmail) ? string.Empty : $"Reply-to: {FeatureRequestEmail.Trim()}");
ComposeEmail("[ytLlive 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)
{
MessageBox.Show("Sign-in was unsuccessful. Please try again.", "ytLlive",
MessageBoxButton.OK, MessageBoxImage.Warning);
return null;
}
IsConnected = true;
SyncConnectedAccount();
return channel;
}
catch (Exception ex)
{
MessageBox.Show($"Sign-in failed: {ex.Message}", "ytLlive",
MessageBoxButton.OK, MessageBoxImage.Error);
return null;
}
}
private void OnChatMessageReceived(ChatMessage message)
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
ChatMessages.Add(message);
if (ChatMessages.Count > 500)
ChatMessages.RemoveAt(0);
});
}
private void BeginGoLive()
{
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
{
StreamTitle = DefaultStreamTitle,
StreamDescription = DefaultStreamDescription,
Visibility = DefaultStreamVisibility,
};
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;
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
? "ytLlive"
: $"{dialog.StreamTitle} — ytLlive";
StreamStatus = StreamStatus.Streaming;
ResetHealth(StreamStatus.Streaming);
_ = _framePump.StartAsync(); // never throws; failures log + surface via Failed
}
}
private void StopStream()
{
StreamStatus = StreamStatus.Offline;
WindowTitle = "ytLlive";
ResetHealth(StreamStatus.Offline);
// Audio capture is always-on (preview monitoring); only the frame pump
// and the session stop here.
_ = _framePump.StopAsync();
// Graceful end completes the session = signs out (the DPAPI token is
// cleared so the next Start Stream requires a fresh sign-in). A crash
// never runs this, so the token survives and the creator stays signed in.
_youtubeAuth.ClearSession();
TokenStore.Clear();
IsConnected = false;
SyncConnectedAccount();
AppLog.Write("Stream ended; session signed out");
}
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");
return;
}
_audioMixer.Start();
}
catch (Exception ex)
{
AppLog.Write($"Mic: device check failed: {ex.Message}");
}
}
private void OnGameAudioActiveChanged(bool active) => IsGameAudioBarVisible = active;
private void OnGameAudioPollTick(object? sender, EventArgs e)
{
try
{
_gameAudioDetector.Poll();
}
catch (Exception ex)
{
AppLog.Write($"Game audio detection failed: {ex.Message}");
}
}
// Scene-element → latest frame, for the live compositor. The map mirrors the
// preview: webcam by DeviceId, live captures (the backdrop) 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 { AssetId: not null } image => StaticPixelCache.Get(image.AssetId),
_ => null,
};
}
// 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();
if (string.IsNullOrWhiteSpace(url)) return null;
var quality = SelectedQuality;
return new EncoderOptions
{
RtmpUrl = url,
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}");
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;
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)
{
_liveElapsed = TimeSpan.Zero;
LiveElapsedText = "00:00:00";
_recDotPulse = 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
_liveTimer.Start();
if (BrandFlashEnabled) StartBrandFlashTimer();
}
else
{
_liveTimer.Stop();
_brandFlashTimer.Stop();
_brandFlashOffTimer.Stop();
BrandFlashActive = false;
LiveElapsedText = "00:00:00";
_recDotPulse = 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
}
}
// 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");
_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,
IsPremium,
_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;
}
}