4b9a8e515f
- Sliding pill toggle (green ON / red OFF) replaces checkbox - Geometry mismatch error for custom backgrounds - Backdrop -> Background: C# rename, SQL column rename with migration - Source.IsBackground: new -> override (fixes WPF binding) - Legacy 'Backdrop' display name migrated to 'Background' - Per-scene UseDefaultBackground + CustomBackgroundPath - Asset files renamed backdrop -> background - StagedScene/LiveScene split, thumbnail strip, edit gating - TransitionService (Cut/Fade/Move) - Context menu: Capture Desktop, Refresh Desktop - Resolution badge removed, ON-AIR status light added
3599 lines
137 KiB
C#
3599 lines
137 KiB
C#
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;
|
||
|
||
private bool _isSettingsOpen;
|
||
private bool _isBugOpen;
|
||
private bool _isFeatureOpen;
|
||
private bool _isAboutOpen;
|
||
private bool _isLicensesOpen;
|
||
private string _overlayTitle = string.Empty;
|
||
private string _defaultStreamTitle = string.Empty;
|
||
private string _defaultStreamDescription = 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;
|
||
|
||
// 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;
|
||
|
||
// 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 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;
|
||
EnsureDefaultBackground(value);
|
||
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||
OnPropertyChanged(nameof(CanChangeBackground));
|
||
OnPropertyChanged(nameof(CanAddWebcamToStagedScene));
|
||
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
||
OnPropertyChanged(nameof(IsCountdownAvailable));
|
||
OnPropertyChanged(nameof(UseDefaultBackground));
|
||
OnPropertyChanged(nameof(CustomBackgroundPath));
|
||
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;
|
||
|
||
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 (SetProperty(ref _selectedElement, value))
|
||
{
|
||
OnPropertyChanged(nameof(IsWebcamSelected));
|
||
OnPropertyChanged(nameof(IsElementsPanelEmpty));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>The Add → Webcam menu item: enabled when the active scene doesn't show the webcam yet.</summary>
|
||
public bool CanAddWebcamToStagedScene => StagedScene?.WebcamConfig == null;
|
||
|
||
// ─── 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);
|
||
}
|
||
|
||
/// <summary>The Add → Webcam menu item: enabled when the active scene doesn't show the webcam yet.</summary>
|
||
|
||
/// <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(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) * 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 => !IsLive ? "#555555" : IsLivePrivate ? "#8f1f1f" : "#e94560";
|
||
public string RecTextBrush => IsLive ? "#ffffff" : "#888888";
|
||
public double RecDotOpacity => IsLive ? _recDotPulse : 0.55;
|
||
public bool ShowStartStream => IsOffline && IsConnected;
|
||
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>
|
||
public bool CanChangeBackground => StagedScene?.HasBackground == 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 = 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 (scene != null && !scene.UseDefaultBackground
|
||
&& !string.IsNullOrWhiteSpace(scene.CustomBackgroundPath)
|
||
&& System.IO.File.Exists(scene.CustomBackgroundPath))
|
||
{
|
||
try
|
||
{
|
||
var bmi = new System.Windows.Media.Imaging.BitmapImage();
|
||
bmi.BeginInit();
|
||
bmi.UriSource = new System.Uri(scene.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)
|
||
{
|
||
var img = LoadBackgroundImage(scene);
|
||
if (img != null)
|
||
scene.Snapshot = img;
|
||
else if (scene.Snapshot == null)
|
||
scene.Snapshot = CreatePlaceholderSnapshot(scene.Name);
|
||
}
|
||
}
|
||
|
||
private static System.Windows.Media.Imaging.BitmapSource? LoadBackgroundImage(Scene scene)
|
||
{
|
||
if (!scene.UseDefaultBackground
|
||
&& !string.IsNullOrWhiteSpace(scene.CustomBackgroundPath)
|
||
&& System.IO.File.Exists(scene.CustomBackgroundPath))
|
||
{
|
||
try
|
||
{
|
||
var bitmap = new System.Windows.Media.Imaging.BitmapImage();
|
||
bitmap.BeginInit();
|
||
bitmap.UriSource = new Uri(scene.CustomBackgroundPath);
|
||
bitmap.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
|
||
bitmap.EndInit();
|
||
bitmap.Freeze();
|
||
return bitmap;
|
||
}
|
||
catch { /* fall through to AssetId */ }
|
||
}
|
||
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||
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(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);
|
||
}
|
||
|
||
public string DefaultStreamTitle
|
||
{
|
||
get => _defaultStreamTitle;
|
||
set => SetProperty(ref _defaultStreamTitle, value);
|
||
}
|
||
|
||
public string DefaultStreamDescription
|
||
{
|
||
get => _defaultStreamDescription;
|
||
set => SetProperty(ref _defaultStreamDescription, 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);
|
||
}
|
||
|
||
// 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 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 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(_ => BeginGoLive());
|
||
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive);
|
||
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
|
||
SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs());
|
||
OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile());
|
||
ActivateLicenseCommand = new RelayCommand(_ => _ = ActivateLicenseAsync(), _ => !IsLicenseValidating);
|
||
OpenLicenseEntryCommand = new RelayCommand(_ => IsLicenseEntryOpen = !IsLicenseEntryOpen);
|
||
|
||
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
|
||
_activeLayoutPath = _layoutStore.ActivePath;
|
||
|
||
// 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");
|
||
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}");
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
SeedStartingBackground();
|
||
SeedBrbBackground();
|
||
SeedEndingBackground();
|
||
SeedChatBackground();
|
||
|
||
// The live background belongs to Live only; a pre-policy DB may have
|
||
// backgrounds lingering in other scenes — drop them, then
|
||
// ReacquireScreenCaptures heals Live's.
|
||
EnforceBackgroundPolicy(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;
|
||
}
|
||
StagedScene = Scenes.FirstOrDefault();
|
||
UpdateActiveBackground();
|
||
UpdateActiveChatBoxPreviews();
|
||
StartMockChatPreview();
|
||
UpdateBackgroundImage();
|
||
ReacquireWebcam();
|
||
ReacquireScreenCaptures();
|
||
_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;
|
||
|
||
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(CanAddWebcamToStagedScene));
|
||
|
||
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 = new Source
|
||
{
|
||
Name = "Game Capture",
|
||
Type = SourceType.DisplayCapture,
|
||
IsBackground = true,
|
||
IsEnabled = true,
|
||
X = 0,
|
||
Y = 0,
|
||
Width = MasterFrameWidth,
|
||
Height = MasterFrameHeight,
|
||
};
|
||
scene.Elements.Insert(0, background);
|
||
return background;
|
||
}
|
||
|
||
// The background belongs to the Live scene alone. Runs after every layout
|
||
// load: the flag is normalized by scene name and any background lingering in a
|
||
// non-Live scene (from a pre-policy DB) is removed. ReacquireScreenCaptures
|
||
// re-heals Live's background right after.
|
||
internal static void EnforceBackgroundPolicy(IEnumerable<Scene> scenes)
|
||
{
|
||
foreach (var scene in scenes)
|
||
{
|
||
scene.HasBackground = SceneCatalog.HasBackground(scene.Name);
|
||
if (scene.HasBackground) continue;
|
||
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||
if (background != null)
|
||
scene.Elements.Remove(background);
|
||
}
|
||
}
|
||
|
||
private IEnumerable<Source> AllBackgrounds()
|
||
=> Scenes.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()
|
||
{
|
||
foreach (var scene in Scenes)
|
||
EnsureBackground(scene);
|
||
|
||
SeedLiveBackgroundAsset();
|
||
|
||
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();
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The custom background file path for the staged scene, shown in the properties panel textbox.
|
||
/// Null means the scene is using the app default background.
|
||
/// </summary>
|
||
public string? CustomBackgroundPath => StagedScene?.CustomBackgroundPath;
|
||
|
||
/// <summary>
|
||
/// Whether the staged scene uses the shipped default background. When true,
|
||
/// CustomBackgroundPath is ignored but preserved. Toggled by the pill-switch.
|
||
/// </summary>
|
||
public bool UseDefaultBackground
|
||
{
|
||
get => StagedScene?.UseDefaultBackground ?? true;
|
||
set
|
||
{
|
||
if (StagedScene == null) return;
|
||
StagedScene.UseDefaultBackground = value;
|
||
OnPropertyChanged();
|
||
OnPropertyChanged(nameof(CustomBackgroundPath));
|
||
UpdateBackgroundImage();
|
||
RefreshSnapshotsAsync();
|
||
ScheduleSave();
|
||
}
|
||
}
|
||
|
||
private void ToggleBackgroundShowDesktop() => BackgroundShowDesktop = !BackgroundShowDesktop;
|
||
|
||
private void BrowseBackground()
|
||
{
|
||
var scene = StagedScene;
|
||
if (scene == null) return;
|
||
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||
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;
|
||
|
||
scene.CustomBackgroundPath = dlg.FileName;
|
||
scene.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 Shutdown()
|
||
{
|
||
_saveDebounce?.Stop();
|
||
SaveLayoutNow();
|
||
_audioMixer.Dispose();
|
||
_framePump.Dispose();
|
||
_cameraManager.Dispose();
|
||
_screenCaptureManager.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(CanAddWebcamToStagedScene));
|
||
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;
|
||
}
|
||
|
||
private void SeedStartingBackground()
|
||
{
|
||
try
|
||
{
|
||
var starting = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Starting"));
|
||
if (starting == null) return;
|
||
if (starting.Elements.OfType<Source>().Any(s => s.Type == SourceType.Background)) return;
|
||
|
||
var uri = new Uri("pack://application:,,,/Assets/starting-background.jpg", UriKind.Absolute);
|
||
using var stream = Application.GetResourceStream(uri)?.Stream;
|
||
if (stream == null) return;
|
||
using var ms = new MemoryStream();
|
||
stream.CopyTo(ms);
|
||
var bytes = ms.ToArray();
|
||
var assetId = AddAsset(bytes);
|
||
if (assetId == null) return;
|
||
starting.Elements.Add(new Source
|
||
{
|
||
Name = "Background",
|
||
Type = SourceType.Background,
|
||
AssetId = assetId,
|
||
Width = 1920,
|
||
Height = 1080,
|
||
});
|
||
}
|
||
catch { /* non-critical — stream works fine without a default bg */ }
|
||
}
|
||
|
||
/// <summary>
|
||
/// When switching to a scene that has no background, seed the default.
|
||
/// </summary>
|
||
private void EnsureDefaultBackground(Scene? scene)
|
||
{
|
||
if (scene == null) return;
|
||
if (scene.Elements.OfType<Source>().Any(s => s.Type == SourceType.Background)) return;
|
||
|
||
string? asset = scene.Name switch
|
||
{
|
||
"Starting" => "starting-background.jpg",
|
||
"BRB" => "brb-background.jpg",
|
||
"Ending" => "ending-background.jpg",
|
||
"Chat" => "chat-background.jpg",
|
||
_ => null,
|
||
};
|
||
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);
|
||
var bytes = ms.ToArray();
|
||
var assetId = AddAsset(bytes);
|
||
if (assetId == null) return;
|
||
scene.Elements.Add(new Source
|
||
{
|
||
Name = "Background",
|
||
Type = SourceType.Background,
|
||
AssetId = assetId,
|
||
Width = 1920,
|
||
Height = 1080,
|
||
});
|
||
UpdateActiveBackground();
|
||
}
|
||
catch { /* non-critical */ }
|
||
}
|
||
|
||
private void SeedBrbBackground()
|
||
{
|
||
try
|
||
{
|
||
var brb = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "BRB"));
|
||
if (brb == null) return;
|
||
|
||
var uri = new Uri("pack://application:,,,/Assets/brb-background.jpg", UriKind.Absolute);
|
||
using var stream = Application.GetResourceStream(uri)?.Stream;
|
||
if (stream == null) return;
|
||
using var ms = new MemoryStream();
|
||
stream.CopyTo(ms);
|
||
var bytes = ms.ToArray();
|
||
var assetId = AddAsset(bytes);
|
||
if (assetId == null) return;
|
||
|
||
var existing = brb.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||
if (existing != null)
|
||
{
|
||
existing.AssetId = assetId;
|
||
existing.Width = 1920;
|
||
existing.Height = 1080;
|
||
}
|
||
else
|
||
{
|
||
brb.Elements.Add(new Source
|
||
{
|
||
Name = "Background",
|
||
Type = SourceType.Background,
|
||
AssetId = assetId,
|
||
Width = 1920,
|
||
Height = 1080,
|
||
});
|
||
}
|
||
}
|
||
catch { /* non-critical — stream works fine without a default bg */ }
|
||
}
|
||
|
||
private void SeedLiveBackgroundAsset()
|
||
{
|
||
try
|
||
{
|
||
var live = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Live"));
|
||
if (live == null) return;
|
||
var background = live.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||
if (background == null) return;
|
||
|
||
var uri = new Uri("pack://application:,,,/Assets/live-background.jpg", UriKind.Absolute);
|
||
using var stream = Application.GetResourceStream(uri)?.Stream;
|
||
if (stream == null) return;
|
||
using var ms = new MemoryStream();
|
||
stream.CopyTo(ms);
|
||
var bytes = ms.ToArray();
|
||
var assetId = AddAsset(bytes);
|
||
if (assetId == null) return;
|
||
background.AssetId = assetId;
|
||
background.Width = 1920;
|
||
background.Height = 1080;
|
||
}
|
||
catch { /* non-critical — stream works fine without a default bg */ }
|
||
}
|
||
|
||
private void SeedEndingBackground()
|
||
{
|
||
try
|
||
{
|
||
var ending = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Ending"));
|
||
if (ending == null) return;
|
||
|
||
var uri = new Uri("pack://application:,,,/Assets/ending-background.jpg", UriKind.Absolute);
|
||
using var stream = Application.GetResourceStream(uri)?.Stream;
|
||
if (stream == null) return;
|
||
using var ms = new MemoryStream();
|
||
stream.CopyTo(ms);
|
||
var bytes = ms.ToArray();
|
||
var assetId = AddAsset(bytes);
|
||
if (assetId == null) return;
|
||
|
||
var existing = ending.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||
if (existing != null)
|
||
{
|
||
existing.AssetId = assetId;
|
||
existing.Width = 1920;
|
||
existing.Height = 1080;
|
||
}
|
||
else
|
||
{
|
||
ending.Elements.Add(new Source
|
||
{
|
||
Name = "Background",
|
||
Type = SourceType.Background,
|
||
AssetId = assetId,
|
||
Width = 1920,
|
||
Height = 1080,
|
||
});
|
||
}
|
||
}
|
||
catch { /* non-critical — stream works fine without a default bg */ }
|
||
}
|
||
|
||
private void SeedChatBackground()
|
||
{
|
||
try
|
||
{
|
||
var chat = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Chat"));
|
||
if (chat == null) return;
|
||
|
||
var uri = new Uri("pack://application:,,,/Assets/chat-background.jpg", UriKind.Absolute);
|
||
using var stream = Application.GetResourceStream(uri)?.Stream;
|
||
if (stream == null) return;
|
||
using var ms = new MemoryStream();
|
||
stream.CopyTo(ms);
|
||
var bytes = ms.ToArray();
|
||
var assetId = AddAsset(bytes);
|
||
if (assetId == null) return;
|
||
|
||
var existing = chat.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||
if (existing != null)
|
||
{
|
||
existing.AssetId = assetId;
|
||
existing.Width = 1920;
|
||
existing.Height = 1080;
|
||
}
|
||
else
|
||
{
|
||
chat.Elements.Add(new Source
|
||
{
|
||
Name = "Background",
|
||
Type = SourceType.Background,
|
||
AssetId = assetId,
|
||
Width = 1920,
|
||
Height = 1080,
|
||
});
|
||
}
|
||
}
|
||
catch { /* non-critical — stream works fine without a default bg */ }
|
||
}
|
||
|
||
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.DisplayCapture => "Screen",
|
||
SourceType.WindowCapture => "Window",
|
||
SourceType.Background => "Background",
|
||
SourceType.Image => "Image",
|
||
SourceType.TextOverlay => "Text",
|
||
SourceType.Countdown => "Countdown",
|
||
SourceType.WebSource => "Web",
|
||
SourceType.ChatBox => "YouTube Chat",
|
||
_ => "Layer",
|
||
};
|
||
|
||
if (sourceType == SourceType.Background)
|
||
{
|
||
var bytes = PickImageBytes("Choose a background 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;
|
||
}
|
||
|
||
var source = new Source
|
||
{
|
||
Name = NextSourceName(scene, baseName),
|
||
Type = sourceType,
|
||
X = sourceType == SourceType.ChatBox ? 1400 : 0,
|
||
Y = sourceType == SourceType.ChatBox ? 20 : 0,
|
||
Width = sourceType == SourceType.ChatBox ? 500 : 0,
|
||
Height = sourceType == SourceType.ChatBox ? 648 : 0,
|
||
};
|
||
scene.Elements.Add(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));
|
||
}
|
||
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.",
|
||
"LlamaCasty", 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.",
|
||
"LlamaCasty", 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 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 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}", "LlamaCasty",
|
||
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 = 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);
|
||
}
|
||
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(CanAddWebcamToStagedScene));
|
||
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)
|
||
{
|
||
MessageBox.Show("Sign-in was unsuccessful. Please try again.", "LlamaCasty",
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
return null;
|
||
}
|
||
|
||
IsConnected = true;
|
||
SyncConnectedAccount();
|
||
return channel;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
MessageBox.Show($"Sign-in failed: {ex.Message}", "LlamaCasty",
|
||
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);
|
||
|
||
// 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();
|
||
}
|
||
|
||
private void BeginGoLive()
|
||
{
|
||
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
|
||
{
|
||
StreamTitle = DefaultStreamTitle,
|
||
StreamDescription = DefaultStreamDescription,
|
||
};
|
||
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)
|
||
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
|
||
? "LlamaCasty"
|
||
: $"{dialog.StreamTitle} — LlamaCasty";
|
||
StreamStatus = StreamStatus.Streaming;
|
||
ResetHealth(StreamStatus.Streaming);
|
||
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
|
||
_ = PrepareAndStartLiveAsync();
|
||
}
|
||
}
|
||
|
||
/// <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");
|
||
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");
|
||
StreamStatus = StreamStatus.Error;
|
||
return;
|
||
}
|
||
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
|
||
|
||
// 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}");
|
||
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 void StopStream()
|
||
{
|
||
StreamStatus = StreamStatus.Offline;
|
||
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 FLV. Audio capture itself is always-on (preview
|
||
// monitoring); only the live pipe, the pump, and the session stop here.
|
||
_audioMixer.StopLive();
|
||
_ = _framePump.StopAsync();
|
||
_currentBroadcastId = null;
|
||
// 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}");
|
||
}
|
||
}
|
||
|
||
// 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 { 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();
|
||
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;
|
||
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)
|
||
{
|
||
_liveElapsed = TimeSpan.Zero;
|
||
LiveElapsedText = "00:00:00";
|
||
_recDotPulse = 1.0;
|
||
OnPropertyChanged(nameof(RecDotOpacity));
|
||
_liveTimer.Start();
|
||
if (BrandFlashEnabled) StartBrandFlashTimer();
|
||
}
|
||
else
|
||
{
|
||
_liveTimer.Stop();
|
||
_healthPollTimer.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,
|
||
_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;
|
||
}
|
||
}
|