1efde5a1e6
MainViewModel.Overlays.cs (194 lines) takes the settings/bug/feature/about panels + creator-hub links: panel state (IsSettingsOpen/IsBugOpen/IsFeatureOpen/ IsAboutOpen via SetPanel, IsLicensesOpen, IsAnyOverlayOpen, OverlayTitle), ShowOverlay/ShowLicenses, bug/feature report fields + SubmitBug/SubmitFeature/ ComposeEmail/OpenUrl, the About-hub consts (ChannelUrl/MastodonUrl/CoffeeUrl/ PremiumUrl/IsPremiumAvailable), SupportEmail/AppVersionLabel, LicensesText, and the overlay command declarations (OpenSettingsCommand..OpenLicensesCommand). Core drops 940 -> 758 lines. Ctor command assignments and all other wiring stay in core; SetPanel/ShowOverlay keep their cross-partial calls. This completes Phase 1: MainViewModel.cs has been split into 10 functional partials (1,890 -> 758 lines in core). Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures. Docs: index.md Overlays row added, About-hub row re-pointed, tracker marked Phase 1 complete in the same commit.
759 lines
31 KiB
C#
759 lines
31 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 partial class MainViewModel : ViewModelBase
|
|
{
|
|
private readonly YouTubeAuthService _youtubeAuth;
|
|
private readonly YouTubeStreamService _youtubeStream;
|
|
private readonly YouTubeChatService _youtubeChat;
|
|
|
|
private SceneElement? _selectedElement;
|
|
|
|
private LayoutStore _layoutStore;
|
|
private string? _activeLayoutPath;
|
|
private DispatcherTimer? _saveDebounce;
|
|
private bool _isLoading;
|
|
|
|
// Toasts (TASK 24): non-blocking user-facing messages. Parameterless ctor
|
|
// news up the real service; the VM is built from XAML so no DI seam exists.
|
|
private readonly INotificationService _notifications = new NotificationService();
|
|
|
|
// Live frame pipeline (TASK 4 ship step 5): composites the active scene at the
|
|
// tier's FPS and paces frames into the encoder. The RTMP URL seam stays null
|
|
// until the live-stream create flow (TASK 5) supplies the reusable stream URL.
|
|
private readonly Func<string?> _rtmpUrlProvider;
|
|
private readonly TransitionService _transition = new();
|
|
|
|
private WebView2Manager? _webView2Manager;
|
|
|
|
// 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;
|
|
|
|
public SceneElement? SelectedElement
|
|
{
|
|
get => _selectedElement;
|
|
set
|
|
{
|
|
if (_selectedElement != null)
|
|
_selectedElement.PropertyChanged -= OnSelectedElementPropertyChanged;
|
|
if (SetProperty(ref _selectedElement, value))
|
|
{
|
|
OnPropertyChanged(nameof(IsWebcamSelected));
|
|
OnPropertyChanged(nameof(IsElementsPanelEmpty));
|
|
if (_selectedElement != null)
|
|
_selectedElement.PropertyChanged += OnSelectedElementPropertyChanged;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnSelectedElementPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName == nameof(SceneElement.UseDefaultBackground))
|
|
{
|
|
UpdateBackgroundImage();
|
|
RefreshSnapshotsAsync();
|
|
ScheduleSave();
|
|
}
|
|
}
|
|
|
|
private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
|
|
private static readonly SolidColorBrush BarOffBrush = CreateBrush("#e94560");
|
|
private static SolidColorBrush CreateBrush(string hex)
|
|
=> (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!;
|
|
|
|
/// <summary>True when the Elements panel should show the "No configurable elements" hint.</summary>
|
|
public bool IsElementsPanelEmpty => SelectedElement is { } el
|
|
&& !el.IsDraggable && !el.IsWebcam
|
|
&& el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox };
|
|
|
|
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
|
|
|
|
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
|
|
// cadence timer is stopped and any active flash is hidden immediately.
|
|
public bool BrandFlashEnabled
|
|
{
|
|
get => _brandFlashEnabled;
|
|
set
|
|
{
|
|
if (!SetProperty(ref _brandFlashEnabled, value)) return;
|
|
if (value)
|
|
{
|
|
if (IsLive) StartBrandFlashTimer();
|
|
}
|
|
else
|
|
{
|
|
_brandFlashTimer.Stop();
|
|
_brandFlashOffTimer.Stop();
|
|
BrandFlashActive = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
public bool BrandFlashActive
|
|
{
|
|
get => _brandFlashActive;
|
|
private set => SetProperty(ref _brandFlashActive, value);
|
|
}
|
|
|
|
// Window geometry pass-through (code-behind owns the WPF specifics).
|
|
public (double Left, double Top, double Width, double Height, string State)? RestoreWindowState()
|
|
=> _layoutStore.LoadWindowState();
|
|
|
|
public void PersistWindowState(double left, double top, double width, double height, string state)
|
|
=> _layoutStore.SaveWindowState(left, top, width, height, state);
|
|
|
|
// Commands
|
|
public ICommand AddSceneCommand { get; }
|
|
public ICommand AddSourceCommand { get; }
|
|
public ICommand AddImageCommand { get; }
|
|
public ICommand RemoveSourceCommand { get; }
|
|
public ICommand EditElementCommand { get; }
|
|
public ICommand ToggleElementVisibilityCommand { get; }
|
|
public ICommand ActivateLicenseCommand { get; }
|
|
public ICommand OpenLicenseEntryCommand { get; }
|
|
public ICommand OpenHotkeyConfigCommand { get; }
|
|
public ICommand ChangeAccountCommand { get; }
|
|
public ICommand LogoutCommand { get; }
|
|
|
|
public MainViewModel()
|
|
{
|
|
AppLog.Write("MainViewModel ctor begin");
|
|
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret,
|
|
sessionChanged: ch => TokenStore.Save(ch));
|
|
_youtubeStream = new YouTubeStreamService(_youtubeAuth);
|
|
_youtubeChat = new YouTubeChatService(_youtubeAuth);
|
|
AppLog.Write("MainViewModel ctor: services created");
|
|
|
|
_selectedQuality = QualityOptions[0];
|
|
ApplyStreamQuality(_selectedQuality);
|
|
|
|
_youtubeChat.MessageReceived += OnChatMessageReceived;
|
|
|
|
_liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
|
_liveTimer.Tick += OnLiveTimerTick;
|
|
_volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
|
|
_volumeFlashTimer.Tick += (_, _) => EndVolumeFlash();
|
|
|
|
_gameVolumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
|
|
_gameVolumeFlashTimer.Tick += (_, _) => EndGameVolumeFlash();
|
|
|
|
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
|
|
_saveDebounce.Tick += (_, _) => SaveLayoutNow();
|
|
|
|
_brandFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(300) };
|
|
_brandFlashTimer.Tick += OnBrandFlashTimerTick;
|
|
_brandFlashOffTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(750) };
|
|
_brandFlashOffTimer.Tick += (_, _) => { _brandFlashOffTimer.Stop(); BrandFlashActive = false; };
|
|
|
|
_healthPollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) };
|
|
_healthPollTimer.Tick += OnHealthPollTick;
|
|
|
|
Scenes.CollectionChanged += OnScenesChanged;
|
|
|
|
AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty));
|
|
AddSourceCommand = new RelayCommand(parameter => AddSource(parameter));
|
|
AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToStagedSceneAsync());
|
|
AddImageCommand = new RelayCommand(_ => AddImage());
|
|
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
|
|
EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
|
|
ToggleElementVisibilityCommand = new RelayCommand(element => ToggleElementVisibility(element as SceneElement));
|
|
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
|
|
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInStagedScene(), _ => CanShowWebcamInStagedScene);
|
|
OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
|
|
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackgroundCaptureAsync());
|
|
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackgroundAutoCapture());
|
|
SetBackgroundDisplayCommand = new RelayCommand(display => SetBackgroundCapture(display as DisplayInfo));
|
|
ToggleShowDesktopCommand = new RelayCommand(_ => ToggleBackgroundShowDesktop());
|
|
BrowseBackgroundCommand = new RelayCommand(_ => BrowseBackground());
|
|
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
|
|
ToggleGameMuteCommand = new RelayCommand(_ => ToggleGameMute());
|
|
OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone());
|
|
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
|
|
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
|
|
OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
|
|
OpenAboutCommand = new RelayCommand(_ => ShowOverlay(nameof(IsAboutOpen), "About"));
|
|
CloseOverlayCommand = new RelayCommand(_ => ShowOverlay());
|
|
SubmitBugCommand = new RelayCommand(_ => SubmitBug());
|
|
SubmitFeatureCommand = new RelayCommand(_ => SubmitFeature());
|
|
OpenChannelCommand = new RelayCommand(_ => OpenUrl(ChannelUrl));
|
|
OpenMastodonCommand = new RelayCommand(_ => OpenUrl(MastodonUrl));
|
|
OpenCoffeeCommand = new RelayCommand(_ => OpenUrl(CoffeeUrl));
|
|
OpenPremiumCommand = new RelayCommand(_ => OpenUrl(PremiumUrl), _ => IsPremiumAvailable);
|
|
OpenLicensesCommand = new RelayCommand(_ => ShowLicenses());
|
|
StartStreamCommand = new RelayCommand(_ => StartSession());
|
|
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive || IsRecording);
|
|
ChooseRecordFolderCommand = new RelayCommand(_ => ChooseRecordFolder());
|
|
ResetRecordFolderCommand = new RelayCommand(_ => ResetRecordFolder());
|
|
ActivateLicenseCommand = new RelayCommand(_ => _ = ActivateLicenseAsync(), _ => !IsLicenseValidating);
|
|
OpenLicenseEntryCommand = new RelayCommand(_ => IsLicenseEntryOpen = !IsLicenseEntryOpen);
|
|
OpenHotkeyConfigCommand = new RelayCommand(_ => OpenHotkeyConfig());
|
|
ChangeAccountCommand = new RelayCommand(_ => _ = ChangeAccountAsync());
|
|
LogoutCommand = new RelayCommand(_ => _ = SignOutYouTubeAsync());
|
|
|
|
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
|
|
_activeLayoutPath = _layoutStore.ActivePath;
|
|
_recordFolder = _layoutStore.LoadRecordFolder();
|
|
|
|
// Load saved hotkey bindings (filled with defaults for any unbound actions).
|
|
_hotkeyBindings = _layoutStore.LoadHotkeyBindings();
|
|
|
|
BroadcastForm = new Services.LiveBroadcastFormViewModel(
|
|
() => _layoutStore,
|
|
() => _youtubeStream,
|
|
() => CurrentBroadcastId,
|
|
() => IsLive);
|
|
|
|
// Restore the last picked mic so the mixer's first capture attaches to
|
|
// the same, already-vetted device (green dot) or reports it missing
|
|
// (yellow) instead of silently falling back to the default.
|
|
MicSourceName = _layoutStore.LoadMicSourceName();
|
|
|
|
_cameraEnumerator = new MediaCaptureCameraEnumerator();
|
|
_cameraManager = new CameraManager(
|
|
_cameraEnumerator,
|
|
id => new MediaCaptureFrameSource(id),
|
|
System.Windows.Application.Current?.Dispatcher);
|
|
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
|
|
_cameraManager.CameraFailed += OnCameraFailed;
|
|
|
|
_microphoneEnumerator = new WinRtMicrophoneEnumerator();
|
|
|
|
// The mixer consumes these Func seams live each mix tick, so the volume
|
|
// sliders and mute buttons are stream-honest (they also update the
|
|
// meters' display). Before this wiring the gains defaulted to unity and
|
|
// the controls were decorative.
|
|
_gainProvider = new AudioGainProvider(
|
|
() => MicMuted,
|
|
() => MicVolume,
|
|
() => GameMuted,
|
|
() => GameAudioVolume);
|
|
_audioMixer = new AudioMixer(
|
|
new WasapiMicAudioSource(() => MicSourceName),
|
|
new WasapiLoopbackAudioSource(),
|
|
message => AppLog.Write(message),
|
|
micGain: _gainProvider.MicGain,
|
|
loopbackGain: _gainProvider.LoopbackGain);
|
|
_audioMixer.MicLevelChanged += OnMicLevelChanged;
|
|
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
|
|
_audioMixer.MicConnected += OnMicConnected;
|
|
_audioMixer.MicFailed += OnMicFailed;
|
|
_musicPlayer.PlaybackEnded += OnTraxPlaybackEnded;
|
|
_ = StartMicCaptureAsync();
|
|
|
|
_fullScreenDetector = new Win32FullScreenDetector();
|
|
_fullScreenDetector.FullscreenMonitorChanged += OnFullscreenMonitorChanged;
|
|
foreach (var display in _fullScreenDetector.GetDisplays())
|
|
Displays.Add(display);
|
|
|
|
_screenCaptureFactory = new ScreenCaptureSourceFactory(
|
|
() => new WindowInteropHelper(System.Windows.Application.Current?.MainWindow).Handle);
|
|
_screenCaptureManager = new ScreenCaptureManager(
|
|
_screenCaptureFactory.Resolve,
|
|
System.Windows.Application.Current?.Dispatcher);
|
|
_screenCaptureManager.PreviewBitmapChanged += OnScreenPreviewBitmapChanged;
|
|
_screenCaptureManager.CaptureFailed += (key, message) =>
|
|
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}");
|
|
|
|
_chatBoxRenderer = new ChatBoxRenderer();
|
|
|
|
_rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL
|
|
_framePump = new FramePump(
|
|
sceneProvider: () => StagedScene,
|
|
frameResolver: ResolveOutputFrame,
|
|
compositorOptions: BuildCompositorOptions,
|
|
encoderOptions: BuildEncoderOptions,
|
|
encoderFactory: () => new FfmpegEncoder(new FfmpegLocator()),
|
|
log: message => AppLog.Write(message),
|
|
socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom),
|
|
transition: _transition,
|
|
fromSceneProvider: () => LiveScene);
|
|
_framePump.Failed += OnFramePumpFailed;
|
|
_framePump.HealthUpdated += OnFramePumpHealthUpdated;
|
|
|
|
LoadLayout();
|
|
// The cached reusable stream gives the pump its RTMP URL the moment go-live
|
|
// starts (the pump reads the URL once at startup — no waiting on YouTube).
|
|
_reusableStream = _layoutStore.LoadReusableStream();
|
|
_reusableStreamUrl = _reusableStream?.RtmpUrl;
|
|
_ = LoadSavedSessionAsync();
|
|
_ = ValidateLicenseAtStartupAsync();
|
|
AppLog.Write("MainViewModel ctor end");
|
|
}
|
|
|
|
private static string DefaultLayoutPath => Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"ytLlive",
|
|
"ytLlive.db");
|
|
|
|
// Test seam: a real MainWindow test must never read or write the user's
|
|
// real layout DB (it would persist test sources over real ones). Tests set
|
|
// this to a temp path before constructing MainWindow and reset it after.
|
|
internal static string? LayoutPathOverride { get; set; }
|
|
|
|
private void LoadLayout()
|
|
{
|
|
_isLoading = true;
|
|
try
|
|
{
|
|
AppLog.Write("LoadLayout begin");
|
|
var scenes = _layoutStore.Load();
|
|
Scenes.Clear();
|
|
foreach (var scene in scenes)
|
|
Scenes.Add(scene);
|
|
|
|
if (Scenes.Count == 0)
|
|
{
|
|
foreach (var name in SceneCatalog.All)
|
|
AddScene(name);
|
|
}
|
|
HealBackgrounds();
|
|
RefreshHotkeyLabels();
|
|
|
|
foreach (var scene in Scenes)
|
|
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
|
|
{
|
|
ClampWebcamToBounds(config, scene.Name);
|
|
HealLegacySquareRect(config);
|
|
}
|
|
|
|
// One-time heal: ChatBox layers created before the "YouTube Chat"
|
|
// default still carry the old "Chat" name — creator renames untouched.
|
|
foreach (var scene in Scenes)
|
|
foreach (var source in scene.Elements.OfType<Source>())
|
|
if (source.Type == SourceType.ChatBox
|
|
&& string.Equals(source.Name, "Chat", StringComparison.OrdinalIgnoreCase))
|
|
source.Name = "YouTube Chat";
|
|
|
|
AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded");
|
|
}
|
|
finally
|
|
{
|
|
_isLoading = false;
|
|
}
|
|
StagedScene = Scenes.FirstOrDefault();
|
|
UpdateActiveBackground();
|
|
UpdateActiveChatBoxPreviews();
|
|
StartMockChatPreview();
|
|
UpdateBackgroundImage();
|
|
ReacquireWebcam();
|
|
ReacquireScreenCaptures();
|
|
RegisterLoadedWebSources();
|
|
_socials = _layoutStore.Socials;
|
|
RenderSocialBarFrame();
|
|
HealFediverseSoftwareInBackground();
|
|
OnPropertyChanged(nameof(HasSocials));
|
|
OnPropertyChanged(nameof(SocialBarVisible));
|
|
OnPropertyChanged(nameof(SocialBarDotBrush));
|
|
_music = _layoutStore.Music;
|
|
LoadTrax(play: false);
|
|
|
|
var (transType, transDur) = _layoutStore.LoadTransitionSettings();
|
|
if (Enum.TryParse<TransitionType>(transType, true, out var parsed))
|
|
SelectedTransitionType = parsed;
|
|
TransitionDurationMs = transDur;
|
|
|
|
BroadcastForm.Load();
|
|
|
|
ScheduleSave();
|
|
AppLog.Write("LoadLayout end");
|
|
}
|
|
|
|
private void RegisterLoadedWebSources()
|
|
{
|
|
if (_webView2Manager == null) return;
|
|
|
|
foreach (var source in Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
.Where(s => s.Type == SourceType.WebSource))
|
|
{
|
|
_webView2Manager.Register(source);
|
|
source.PropertyChanged += (_, e) =>
|
|
{
|
|
if (e.PropertyName == nameof(Source.WebUri))
|
|
_webView2Manager.OnWebUriChanged(source);
|
|
};
|
|
}
|
|
}
|
|
|
|
public void InitWebView2(Panel hostPanel)
|
|
{
|
|
_webView2Manager = new WebView2Manager(hostPanel, System.Windows.Application.Current.Dispatcher);
|
|
_webView2Manager.PreviewBitmapChanged += OnWebView2PreviewBitmapChanged;
|
|
RegisterLoadedWebSources();
|
|
}
|
|
|
|
private void OnWebView2PreviewBitmapChanged(string sourceId, WriteableBitmap bitmap)
|
|
{
|
|
foreach (var scene in Scenes)
|
|
foreach (var el in scene.Elements.OfType<Source>().Where(s => s.Id == sourceId))
|
|
el.VideoImageSource = bitmap;
|
|
}
|
|
|
|
public void Shutdown()
|
|
{
|
|
_saveDebounce?.Stop();
|
|
SaveLayoutNow();
|
|
_audioMixer.Dispose();
|
|
_framePump.Dispose();
|
|
_cameraManager.Dispose();
|
|
_screenCaptureManager.Dispose();
|
|
_webView2Manager?.Dispose();
|
|
_layoutStore.Dispose();
|
|
_musicPlayer.Dispose();
|
|
_youtubeChat.Dispose();
|
|
}
|
|
|
|
public void SaveLayoutNow()
|
|
{
|
|
_saveDebounce?.Stop();
|
|
try
|
|
{
|
|
_layoutStore.Save(Scenes, _webcam, _socials, _music);
|
|
_layoutStore.SaveTransitionSettings(
|
|
SelectedTransitionType.ToString(), TransitionDurationMs);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.WriteLine($"Layout save failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void ScheduleSave()
|
|
{
|
|
if (_isLoading || _saveDebounce == null) return;
|
|
_saveDebounce.Stop();
|
|
_saveDebounce.Start();
|
|
}
|
|
|
|
private void BeginEditElement(SceneElement? element)
|
|
{
|
|
if (element == null) return;
|
|
element.IsEditing = true;
|
|
}
|
|
|
|
private void ToggleElementVisibility(SceneElement? element)
|
|
{
|
|
if (element == null) return;
|
|
element.IsVisible = !element.IsVisible;
|
|
if (element is Source { IsBackground: true })
|
|
OnPropertyChanged(nameof(BackgroundVisible));
|
|
}
|
|
|
|
private void AddSource(object? parameter)
|
|
{
|
|
var scene = StagedScene;
|
|
if (scene == null) return;
|
|
|
|
var sourceType = parameter is SourceType typed
|
|
? typed
|
|
: Enum.TryParse<SourceType>(parameter?.ToString(), true, out var parsed) ? parsed : SourceType.Image;
|
|
var baseName = sourceType switch
|
|
{
|
|
SourceType.Image => "Image",
|
|
SourceType.TextOverlay => "Text",
|
|
SourceType.Countdown => "Countdown",
|
|
SourceType.WebSource => "Web Resource",
|
|
SourceType.ChatBox => "YouTube Chat",
|
|
_ => "Layer",
|
|
};
|
|
|
|
// The (+) menu only adds regular layers. The Background is a locked
|
|
// per-screen layer managed by EnsureBackground/NormalizeBackgrounds —
|
|
// never user-created, never deleted. YouTube Chat is one-per-layout.
|
|
if (sourceType is SourceType.DisplayCapture or SourceType.WindowCapture or SourceType.Background)
|
|
return;
|
|
if (sourceType == SourceType.ChatBox && !CanAddYouTubeChat)
|
|
return;
|
|
|
|
// Web resources use "Web Resource-N" naming (0-indexed per scene)
|
|
var webCount = scene.Elements.OfType<Source>().Count(s => s.Type == SourceType.WebSource);
|
|
var source = new Source
|
|
{
|
|
Name = sourceType == SourceType.WebSource
|
|
? $"{baseName}-{webCount}"
|
|
: NextSourceName(scene, baseName),
|
|
Type = sourceType,
|
|
X = sourceType == SourceType.ChatBox ? 1400 : 0,
|
|
Y = sourceType == SourceType.ChatBox ? 20 : 0,
|
|
Width = sourceType == SourceType.WebSource ? 640 : sourceType == SourceType.ChatBox ? 500 : 0,
|
|
Height = sourceType == SourceType.WebSource ? 480 : sourceType == SourceType.ChatBox ? 648 : 0,
|
|
};
|
|
scene.Elements.Add(source);
|
|
|
|
if (sourceType == SourceType.WebSource)
|
|
{
|
|
_webView2Manager?.Register(source);
|
|
source.PropertyChanged += (_, e) =>
|
|
{
|
|
if (e.PropertyName == nameof(Source.WebUri))
|
|
_webView2Manager?.OnWebUriChanged(source);
|
|
};
|
|
}
|
|
|
|
if (sourceType == SourceType.ChatBox)
|
|
{
|
|
source.PropertyChanged += (_, e) =>
|
|
{
|
|
if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor)
|
|
or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity)
|
|
or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps)
|
|
or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor)
|
|
or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily)
|
|
or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height))
|
|
UpdateChatBoxPreview(source);
|
|
};
|
|
UpdateChatBoxPreview(source);
|
|
}
|
|
|
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
|
UpdateActiveBackground();
|
|
}
|
|
|
|
// Duplicate resource names get an incrementing suffix with no space: Image,
|
|
// Image2, Image3… The next free number is derived from the names actually in
|
|
// the scene, so deleting a middle resource never collides with a survivor.
|
|
private static string NextSourceName(Scene scene, string baseName)
|
|
{
|
|
var taken = scene.Elements.OfType<Source>()
|
|
.Select(s => s.Name)
|
|
.Where(n => string.Equals(n, baseName, StringComparison.OrdinalIgnoreCase)
|
|
|| (n.Length > baseName.Length
|
|
&& n.StartsWith(baseName, StringComparison.OrdinalIgnoreCase)
|
|
&& int.TryParse(n.Substring(baseName.Length), out _)))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
if (!taken.Contains(baseName)) return baseName;
|
|
for (var i = 2; ; i++)
|
|
if (!taken.Contains($"{baseName}{i}"))
|
|
return $"{baseName}{i}";
|
|
}
|
|
|
|
private void AddImage()
|
|
{
|
|
var scene = StagedScene;
|
|
if (scene == null) return;
|
|
|
|
var candidates = BuildImageCandidates();
|
|
if (candidates.Count == 0)
|
|
{
|
|
var bytes = PickImageBytes("Choose an image");
|
|
if (bytes != null) AddImageSource(bytes);
|
|
return;
|
|
}
|
|
|
|
var dialog = new ReuseImageDialog(new ReuseImageViewModel(candidates))
|
|
{
|
|
Owner = Application.Current.MainWindow
|
|
};
|
|
if (dialog.ShowDialog() != true) return;
|
|
|
|
if (dialog.WantsNew)
|
|
{
|
|
var bytes = PickImageBytes("Choose an image");
|
|
if (bytes != null) AddImageSource(bytes);
|
|
}
|
|
else
|
|
{
|
|
AddReusedImage(dialog.PickedAssetId!);
|
|
}
|
|
}
|
|
|
|
private List<ReuseImageCandidate> BuildImageCandidates()
|
|
{
|
|
var candidates = new List<ReuseImageCandidate>();
|
|
foreach (var scene in Scenes)
|
|
foreach (var source in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.Image && !string.IsNullOrWhiteSpace(s.AssetId)))
|
|
{
|
|
candidates.Add(new ReuseImageCandidate
|
|
{
|
|
SceneName = scene.Name,
|
|
SourceName = source.Name,
|
|
AssetId = source.AssetId!
|
|
});
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
private byte[]? PickImageBytes(string title)
|
|
{
|
|
var dialog = new OpenFileDialog
|
|
{
|
|
Title = title,
|
|
Filter = "Image files (*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp)|*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp|All files (*.*)|*.*"
|
|
};
|
|
if (dialog.ShowDialog() != true) return null;
|
|
try
|
|
{
|
|
return File.ReadAllBytes(dialog.FileName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_notifications.Warning("Couldn't read that image", ex.Message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private string? AddAsset(byte[] bytes)
|
|
{
|
|
try
|
|
{
|
|
var image = ImageCache.FromBytes(bytes);
|
|
var id = _layoutStore.UpsertAsset(bytes, image?.PixelWidth ?? 0, image?.PixelHeight ?? 0);
|
|
if (id != null && image != null) ImageCache.Put(id, image);
|
|
return id;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.WriteLine($"Asset store failed: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private void AddImageSource(byte[] bytes)
|
|
{
|
|
if (bytes.Length == 0) return;
|
|
var assetId = AddAsset(bytes);
|
|
if (assetId != null) AddReusedImage(assetId);
|
|
}
|
|
|
|
private void AddReusedImage(string assetId)
|
|
{
|
|
var scene = StagedScene;
|
|
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
|
|
|
|
var source = new Source { Name = NextSourceName(scene, "Image"), Type = SourceType.Image, AssetId = assetId };
|
|
|
|
var image = ImageCache.Get(assetId);
|
|
if (image != null)
|
|
{
|
|
var scale = Math.Min(640.0 / image.PixelWidth, 480.0 / image.PixelHeight);
|
|
source.Width = image.PixelWidth * scale;
|
|
source.Height = image.PixelHeight * scale;
|
|
source.X = (1920 - source.Width) / 2;
|
|
source.Y = (1080 - source.Height) / 2;
|
|
}
|
|
|
|
scene.Elements.Add(source);
|
|
SelectedElement = source;
|
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
|
UpdateActiveBackground();
|
|
}
|
|
|
|
// Removes an element from the active scene. For a webcam config this drops the
|
|
// scene's usage and releases one camera reference (CameraManager stops the
|
|
// session when the last using scene lets go). When the last config anywhere is
|
|
// removed, the webcam identity is cleared too — re-adding opens the picker
|
|
// again instead of silently resurrecting the old camera.
|
|
private void RemoveElement(SceneElement? element)
|
|
{
|
|
var scene = StagedScene;
|
|
if (scene == null || element == null) return;
|
|
if (element is Source { IsBackground: true }) return;
|
|
if (element is WebcamSceneConfig && _webcam != null)
|
|
{
|
|
if (SelectedElement == element)
|
|
SelectedElement = null;
|
|
_ = _cameraManager.ReleaseAsync(_webcam.DeviceId);
|
|
}
|
|
if (element is Source { Type: SourceType.WebSource } webSource)
|
|
_webView2Manager?.Unregister(webSource.Id);
|
|
scene.Elements.Remove(element);
|
|
|
|
if (element is WebcamSceneConfig && _webcam != null
|
|
&& !Scenes.Any(s => s.Elements.OfType<WebcamSceneConfig>().Any()))
|
|
{
|
|
_webcam = null;
|
|
OnPropertyChanged(nameof(CanChangeWebcam));
|
|
OnPropertyChanged(nameof(CanAddWebcam));
|
|
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
|
}
|
|
|
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
|
UpdateActiveBackground();
|
|
}
|
|
|
|
// Scene-element → latest frame, for the live compositor. The map mirrors the
|
|
// preview: webcam by DeviceId, live captures (the background) by CaptureKey,
|
|
// images/background by AssetId. A null frame leaves the element transparent.
|
|
private VideoFrame? ResolveOutputFrame(SceneElement element)
|
|
{
|
|
return element switch
|
|
{
|
|
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(webcam.WebcamId),
|
|
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
|
|
Source { Type: SourceType.ChatBox } chatBox => RenderChatBox(chatBox),
|
|
Source { Type: SourceType.WebSource } web => _webView2Manager?.GetLatestFrame(web.Id),
|
|
Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId),
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
// 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,
|
|
};
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
}
|