Files
ytLlive/ViewModels/MainViewModel.cs
T

1282 lines
45 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Microsoft.Win32;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.ViewModels;
public class MainViewModel : ViewModelBase
{
private readonly YouTubeAuthService _youtubeAuth;
private readonly YouTubeStreamService _youtubeStream;
private readonly YouTubeChatService _youtubeChat;
private readonly DispatcherTimer _liveTimer;
private Scene? _activeScene;
private SceneElement? _selectedElement;
private ImageSource? _activeBackgroundImage;
private bool _isConnected;
private StreamStatus _streamStatus = StreamStatus.Offline;
private StreamHealth _currentHealth = new();
private string _streamTitle = string.Empty;
private string _streamDescription = string.Empty;
private string _streamVisibility = "Public";
private string _windowTitle = "ytLlive";
private string _topBarBackground = "#16213e";
private string _previewGlowBrush = "Transparent";
private Thickness _previewGlowThickness = new(0);
private string _liveElapsedText = "00:00:00";
private double _livePulseOpacity = 1.0;
private TimeSpan _liveElapsed;
private bool _isSettingsOpen;
private bool _isBugOpen;
private bool _isFeatureOpen;
private bool _isAboutOpen;
private string _overlayTitle = string.Empty;
private string _defaultStreamTitle = string.Empty;
private string _defaultStreamDescription = string.Empty;
private string _defaultStreamVisibility = "Public";
private string _bugReportText = string.Empty;
private string _bugReportEmail = string.Empty;
private string _featureRequestText = string.Empty;
private string _featureRequestEmail = string.Empty;
private LayoutStore _layoutStore;
private string? _activeLayoutPath;
private DispatcherTimer? _saveDebounce;
private bool _isLoading;
// Webcam: one camera input app-wide. The identity (DeviceId) lives on the
// Webcam entity; each scene that shows the webcam has a WebcamSceneConfig
// (webcam.{scene}.config). CameraManager still owns the single session.
private readonly ICameraEnumerator _cameraEnumerator;
private readonly CameraManager _cameraManager;
private Webcam? _webcam;
// Branding flash (monetization): a full-frame "made with ytLlive!" shown
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
// BrandFlashEnabled = false. See ai.md "Monetization".
private readonly DispatcherTimer _brandFlashTimer;
private readonly DispatcherTimer _brandFlashOffTimer;
private bool _brandFlashEnabled = true;
private bool _brandFlashActive;
private const string SupportEmail = "gramps@llamachile.shop";
private const string ChannelUrl = "https://youtube.com/@llamachileshop";
public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}";
public ObservableCollection<Scene> Scenes { get; } = new();
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
public Scene? ActiveScene
{
get => _activeScene;
set
{
if (value != null && value.IsHidden) return;
if (SetProperty(ref _activeScene, value))
{
SelectedElement = null;
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
UpdateActiveBackground();
}
}
}
public ImageSource? ActiveBackgroundImage
{
get => _activeBackgroundImage;
private set => SetProperty(ref _activeBackgroundImage, value);
}
public SceneElement? SelectedElement
{
get => _selectedElement;
set
{
if (SetProperty(ref _selectedElement, value))
OnPropertyChanged(nameof(IsWebcamSelected));
}
}
/// <summary>The Add → Webcam menu item: enabled when the active scene doesn't show the webcam yet.</summary>
public bool CanAddWebcamToActiveScene => ActiveScene?.WebcamConfig == null;
/// <summary>
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no
/// webcam config (add one) or hides it (unhide — keeps the config row).
/// </summary>
public bool CanShowWebcamInActiveScene
=> ActiveScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible);
/// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
public bool CanChangeWebcam => _webcam != null;
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
public StreamStatus StreamStatus
{
get => _streamStatus;
set
{
if (SetProperty(ref _streamStatus, value))
{
OnPropertyChanged(nameof(IsOffline));
OnPropertyChanged(nameof(IsLive));
OnPropertyChanged(nameof(LiveIndicatorVisible));
OnPropertyChanged(nameof(ShowStartStream));
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
UpdateLiveVisuals();
}
}
}
public bool IsConnected
{
get => _isConnected;
set
{
if (SetProperty(ref _isConnected, value))
OnPropertyChanged(nameof(ShowStartStream));
}
}
public bool IsOffline => StreamStatus == StreamStatus.Offline;
public bool IsLive => StreamStatus == StreamStatus.Streaming;
public bool LiveIndicatorVisible => IsLive;
public bool ShowStartStream => IsOffline;
public bool ShowChatInactiveMessage => !IsLive;
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null;
public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Elements.Count == 0;
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
// cadence timer is stopped and any active flash is hidden immediately.
public bool BrandFlashEnabled
{
get => _brandFlashEnabled;
set
{
if (!SetProperty(ref _brandFlashEnabled, value)) return;
if (value)
{
if (IsLive) StartBrandFlashTimer();
}
else
{
_brandFlashTimer.Stop();
_brandFlashOffTimer.Stop();
BrandFlashActive = false;
}
}
}
public bool BrandFlashActive
{
get => _brandFlashActive;
private set => SetProperty(ref _brandFlashActive, value);
}
private void UpdateActiveBackground()
{
var background = ActiveScene?.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId)
? ImageCache.Get(background.AssetId)
: null;
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
}
public StreamHealth CurrentHealth
{
get => _currentHealth;
set => SetProperty(ref _currentHealth, value);
}
public string StreamTitle
{
get => _streamTitle;
set => SetProperty(ref _streamTitle, value);
}
public string StreamDescription
{
get => _streamDescription;
set => SetProperty(ref _streamDescription, value);
}
public string StreamVisibility
{
get => _streamVisibility;
set => SetProperty(ref _streamVisibility, value);
}
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);
}
public double LivePulseOpacity
{
get => _livePulseOpacity;
set => SetProperty(ref _livePulseOpacity, value);
}
// Overlay panels
public bool IsSettingsOpen
{
get => _isSettingsOpen;
private set => SetPanel(ref _isSettingsOpen, value);
}
public bool IsBugOpen
{
get => _isBugOpen;
private set => SetPanel(ref _isBugOpen, value);
}
public bool IsFeatureOpen
{
get => _isFeatureOpen;
private set => SetPanel(ref _isFeatureOpen, value);
}
public bool IsAboutOpen
{
get => _isAboutOpen;
private set => SetPanel(ref _isAboutOpen, value);
}
public bool IsAnyOverlayOpen => IsSettingsOpen || IsBugOpen || IsFeatureOpen || IsAboutOpen;
public string OverlayTitle
{
get => _overlayTitle;
private set => SetProperty(ref _overlayTitle, value);
}
public string DefaultStreamTitle
{
get => _defaultStreamTitle;
set => SetProperty(ref _defaultStreamTitle, value);
}
public string DefaultStreamDescription
{
get => _defaultStreamDescription;
set => SetProperty(ref _defaultStreamDescription, value);
}
public string DefaultStreamVisibility
{
get => _defaultStreamVisibility;
set => SetProperty(ref _defaultStreamVisibility, value);
}
// ─── Resolution quality dropdown (bottom bar) ───
// Tiers offered to the creator before going live. First = default. The
// dropdown is disabled while live because YouTube stream resolution is
// immutable after stream creation (see TASKS.md compliance notes).
// The composition master frame. Every tier is an output rect + target
// resolution over this frame (see ai.md "Resolution tiers"): 16:9 tiers
// use the full frame; the vertical tier uses a centered 9:16 window and
// the preview dims the cropped strips (semi-crop).
private const double MasterFrameWidth = 1920;
private const double MasterFrameHeight = 1080;
// Webcam size safeguard: no more than half the frame in any dimension
// (960x540 over the 1920x1080 master), and no less than 10% of it
// (192x108). Enforced at resize and on layout load.
public const double WebcamMaxWidth = 960;
public const double WebcamMaxHeight = 540;
public const double WebcamMinWidth = MasterFrameWidth * 0.1;
public const double WebcamMinHeight = MasterFrameHeight * 0.1;
internal static void ClampWebcamToBounds(WebcamSceneConfig config)
{
var scale = Math.Min(WebcamMaxWidth / config.Width, WebcamMaxHeight / config.Height);
if (scale < 1)
{
config.Width = Math.Round(config.Width * scale);
config.Height = Math.Round(config.Height * scale);
return;
}
var minScale = Math.Max(WebcamMinWidth / config.Width, WebcamMinHeight / config.Height);
if (minScale > 1)
{
config.Width = Math.Round(config.Width * minScale);
config.Height = Math.Round(config.Height * minScale);
}
}
// One-time heal for layouts saved before the rect dims were persisted (v3→v4):
// a Traditional webcam that ended up square (Round resize then reload lost the
// pre-Round rect) gets widened to 16:9, keeping the height. Round is skipped —
// a square bounding box is correct there — and an explicit pre-Round rect wins.
internal static void HealLegacySquareRect(WebcamSceneConfig config)
{
if (config.ClipShape != ClipShape.Traditional) return;
if (config.RectWidth != null || config.RectHeight != null) return;
if (Math.Abs(config.Width - config.Height) >= 1) return;
config.Width = Math.Round(config.Height * 16.0 / 9.0);
}
public QualityOption[] QualityOptions { get; } =
{
new("1080p60", 60, 8.0, 1920, 1080),
new("1080p30", 30, 8.0, 1920, 1080),
new("720p60", 60, 6.0, 1280, 720),
new("720p30", 30, 6.0, 1280, 720),
new("Vertical 1080p60", 60, 8.0, 1080, 1920),
};
public string ResolutionHelp { get; } =
"Pick the highest resolution your upload bandwidth can comfortably sustain. " +
"Find your upload speed on your ISP plan or with a speed test (e.g. fast.com), " +
"then choose a tier your upload comfortably exceeds. 1080p60 is the default.";
private QualityOption _selectedQuality;
public QualityOption SelectedQuality
{
get => _selectedQuality;
set
{
if (SetProperty(ref _selectedQuality, value))
ApplyStreamQuality(value);
}
}
// Output frame of the selected tier over the 1920x1080 master. The strips
// outside it are dimmed in the preview so the cropped area stays visible.
public double OutputRectX { get; private set; }
public double OutputRectY { get; private set; }
public double OutputRectWidth { get; private set; }
public double OutputRectHeight { get; private set; }
public bool IsOutputCropped { get; private set; }
public string ResolutionBadgeText { get; private set; } = string.Empty;
public ObservableCollection<Rect> DimRects { get; } = new();
private void ApplyStreamQuality(QualityOption quality)
{
var health = CurrentHealth;
health.CurrentBitrate = quality.Bitrate;
health.FPS = quality.Fps;
OnPropertyChanged(nameof(CurrentHealth));
var scale = Math.Min(MasterFrameWidth / quality.Width, MasterFrameHeight / quality.Height);
var rectW = quality.Width * scale;
var rectH = quality.Height * scale;
var rectX = (MasterFrameWidth - rectW) / 2;
var rectY = (MasterFrameHeight - rectH) / 2;
OutputRectX = rectX;
OutputRectY = rectY;
OutputRectWidth = rectW;
OutputRectHeight = rectH;
DimRects.Clear();
if (rectX > 0) DimRects.Add(new Rect(0, 0, rectX, MasterFrameHeight));
if (rectX + rectW < MasterFrameWidth)
DimRects.Add(new Rect(rectX + rectW, 0, MasterFrameWidth - rectX - rectW, MasterFrameHeight));
if (rectY > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, rectY));
if (rectY + rectH < MasterFrameHeight)
DimRects.Add(new Rect(0, rectY + rectH, MasterFrameWidth, MasterFrameHeight - rectY - rectH));
IsOutputCropped = DimRects.Count > 0;
ResolutionBadgeText = $"{quality.Label} · {quality.Width}×{quality.Height}";
OnPropertyChanged(nameof(OutputRectX));
OnPropertyChanged(nameof(OutputRectY));
OnPropertyChanged(nameof(OutputRectWidth));
OnPropertyChanged(nameof(OutputRectHeight));
OnPropertyChanged(nameof(IsOutputCropped));
OnPropertyChanged(nameof(ResolutionBadgeText));
}
public string BugReportText
{
get => _bugReportText;
set => SetProperty(ref _bugReportText, value);
}
public string BugReportEmail
{
get => _bugReportEmail;
set => SetProperty(ref _bugReportEmail, value);
}
public string FeatureRequestText
{
get => _featureRequestText;
set => SetProperty(ref _featureRequestText, value);
}
public string FeatureRequestEmail
{
get => _featureRequestEmail;
set => SetProperty(ref _featureRequestEmail, value);
}
// Commands
public ICommand AddSceneCommand { get; }
public ICommand EditSceneCommand { get; }
public ICommand RemoveSceneCommand { get; }
public ICommand ToggleSceneVisibilityCommand { get; }
public ICommand AddSourceCommand { get; }
public ICommand AddImageCommand { get; }
public ICommand RemoveSourceCommand { get; }
public ICommand ChangeWebcamCommand { get; }
public ICommand ShowWebcamCommand { get; }
public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; }
public ICommand OpenSettingsCommand { get; }
public ICommand OpenBugCommand { get; }
public ICommand OpenFeatureCommand { get; }
public ICommand OpenAboutCommand { get; }
public ICommand CloseOverlayCommand { get; }
public ICommand SubmitBugCommand { get; }
public ICommand SubmitFeatureCommand { get; }
public ICommand OpenChannelCommand { get; }
public ICommand SaveLayoutCommand { get; }
public ICommand SaveLayoutAsCommand { get; }
public ICommand OpenLayoutCommand { get; }
public MainViewModel()
{
AppLog.Write("MainViewModel ctor begin");
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret,
sessionChanged: ch => TokenStore.Save(ch));
_youtubeStream = new YouTubeStreamService(_youtubeAuth);
_youtubeChat = new YouTubeChatService(_youtubeAuth);
AppLog.Write("MainViewModel ctor: services created");
_selectedQuality = QualityOptions[0];
ApplyStreamQuality(_selectedQuality);
_youtubeChat.MessageReceived += OnChatMessageReceived;
_liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_liveTimer.Tick += OnLiveTimerTick;
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
_saveDebounce.Tick += (_, _) => SaveLayoutNow();
_brandFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(300) };
_brandFlashTimer.Tick += OnBrandFlashTimerTick;
_brandFlashOffTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(750) };
_brandFlashOffTimer.Tick += (_, _) => { _brandFlashOffTimer.Stop(); BrandFlashActive = false; };
Scenes.CollectionChanged += OnScenesChanged;
AddSceneCommand = new RelayCommand(_ => AddScene());
EditSceneCommand = new RelayCommand(scene => BeginEditScene(scene as Scene));
RemoveSceneCommand = new RelayCommand(scene => RemoveScene(scene as Scene));
ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene));
AddSourceCommand = new RelayCommand(type => AddSource(type as string));
AddImageCommand = new RelayCommand(_ => AddImage());
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
OpenAboutCommand = new RelayCommand(_ => ShowOverlay(nameof(IsAboutOpen), "About"));
CloseOverlayCommand = new RelayCommand(_ => ShowOverlay());
SubmitBugCommand = new RelayCommand(_ => SubmitBug());
SubmitFeatureCommand = new RelayCommand(_ => SubmitFeature());
OpenChannelCommand = new RelayCommand(_ => OpenUrl(ChannelUrl));
StartStreamCommand = new RelayCommand(_ => BeginGoLive());
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive);
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs());
OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile());
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
_activeLayoutPath = _layoutStore.ActivePath;
_cameraEnumerator = new MediaCaptureCameraEnumerator();
_cameraManager = new CameraManager(
_cameraEnumerator,
id => new MediaCaptureFrameSource(id),
System.Windows.Application.Current?.Dispatcher);
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
LoadLayout();
_ = LoadSavedSessionAsync();
AppLog.Write("MainViewModel ctor end");
}
// Restores the DPAPI-saved OAuth session so sign-in survives restarts.
// Best-effort: refresh a near-expiry access token; a session that can no
// longer refresh is discarded and the user signs in again.
private async Task LoadSavedSessionAsync()
{
try
{
var saved = TokenStore.Load();
if (saved == null) return;
_youtubeAuth.SetSession(saved);
if (saved.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
{
var refreshed = await Task.Run(() => _youtubeAuth.RefreshToken());
if (!refreshed)
{
TokenStore.Clear();
AppLog.Write("Saved session could not be refreshed; signed out");
return;
}
}
IsConnected = true;
}
catch (Exception ex)
{
AppLog.Write($"LoadSavedSessionAsync failed: {ex}");
}
}
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)
{
AddScene("Starting");
AddScene("Live");
AddScene("BRB");
AddScene("Chat", isChatScene: true);
AddScene("Ending");
}
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
{
ClampWebcamToBounds(config);
HealLegacySquareRect(config);
}
AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded");
}
finally
{
_isLoading = false;
}
ActiveScene = Scenes.FirstOrDefault();
UpdateActiveBackground();
ReacquireWebcam();
ScheduleSave();
AppLog.Write("LoadLayout end");
}
// After a layout load / file open: re-point the webcam identity, stop the
// previous device if it changed, and acquire the current one once per scene
// that uses it (CameraManager refcounts by DeviceId — one camera session).
private void ReacquireWebcam()
{
var previousDevice = _webcam?.DeviceId;
_webcam = _layoutStore.Webcam;
var newDevice = _webcam?.DeviceId;
if (!string.IsNullOrWhiteSpace(previousDevice) && previousDevice != newDevice)
_ = _cameraManager.ReleaseAllAsync(previousDevice);
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return;
if (previousDevice == newDevice) return;
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
_ = _cameraManager.AcquireAsync(newDevice);
}
// 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.
private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap)
{
if (_webcam?.DeviceId != deviceId) return;
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
config.VideoImageSource = bitmap;
}
public void Shutdown()
{
_saveDebounce?.Stop();
SaveLayoutNow();
_cameraManager.Dispose();
_layoutStore.Dispose();
}
public void SaveLayoutNow()
{
_saveDebounce?.Stop();
try
{
_layoutStore.Save(Scenes, _webcam);
}
catch (Exception ex)
{
Debug.WriteLine($"Layout save failed: {ex.Message}");
}
}
private void SaveLayoutAs()
{
var dialog = new SaveFileDialog
{
Title = "Save Layout As",
Filter = "ytLlive layout (*.yll)|*.yll|All files (*.*)|*.*",
DefaultExt = ".yll",
FileName = "my-layout.yll",
};
if (dialog.ShowDialog() != true) return;
_layoutStore.Dispose();
_layoutStore = new LayoutStore(dialog.FileName);
_activeLayoutPath = dialog.FileName;
SaveLayoutNow();
}
private void OpenLayoutFile()
{
var dialog = new OpenFileDialog
{
Title = "Open Layout",
Filter = "ytLlive layout (*.yll;*.db)|*.yll;*.db|All files (*.*)|*.*",
};
if (dialog.ShowDialog() != true) return;
_layoutStore.Dispose();
_layoutStore = new LayoutStore(dialog.FileName);
_activeLayoutPath = dialog.FileName;
LoadLayout();
}
// ─── Auto-save wiring ───
private void OnScenesChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
foreach (Scene scene in e.NewItems)
WireScene(scene);
if (e.OldItems != null)
foreach (Scene scene in e.OldItems)
UnwireScene(scene);
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)
=> ScheduleSave();
private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
foreach (SceneElement element in e.NewItems)
element.PropertyChanged += OnElementPropertyChanged;
if (e.OldItems != null)
foreach (SceneElement element in e.OldItems)
element.PropertyChanged -= OnElementPropertyChanged;
OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
ScheduleSave();
}
private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
ScheduleSave();
}
private void ScheduleSave()
{
if (_isLoading || _saveDebounce == null) return;
_saveDebounce.Stop();
_saveDebounce.Start();
}
private void AddScene(string? name = null, bool isChatScene = false)
{
var scene = new Scene { Name = name ?? $"New Scene {Scenes.Count + 1}", IsChatScene = isChatScene };
Scenes.Add(scene);
ActiveScene = scene;
}
private void BeginEditScene(Scene? scene)
{
if (scene == null) return;
scene.IsEditing = true;
}
private void ToggleSceneVisibility(Scene? scene)
{
if (scene == null) return;
scene.IsHidden = !scene.IsHidden;
if (scene.IsHidden && ActiveScene == scene)
ActiveScene = Scenes.FirstOrDefault(s => !s.IsHidden);
}
private void RemoveScene(Scene? scene)
{
if (scene == null) return;
Scenes.Remove(scene);
if (ActiveScene == scene)
ActiveScene = Scenes.FirstOrDefault();
}
private void AddSource(string? type)
{
var scene = ActiveScene;
if (scene == null) return;
if (string.Equals(type, "webcam", StringComparison.OrdinalIgnoreCase))
{
_ = AddWebcamToActiveSceneAsync();
return;
}
var sourceType = type?.ToLowerInvariant() switch
{
"screen" => SourceType.DisplayCapture,
"window" => SourceType.WindowCapture,
"background" => SourceType.Background,
"text" => SourceType.TextOverlay,
_ => SourceType.Image,
};
var baseName = sourceType switch
{
SourceType.DisplayCapture => "Screen",
SourceType.WindowCapture => "Window",
SourceType.Background => "Background",
SourceType.Image => "Image",
SourceType.TextOverlay => "Text",
_ => "Source",
};
if (sourceType == SourceType.Background)
{
var bytes = PickImageBytes("Choose a backdrop image");
if (bytes == null) return;
var assetId = AddAsset(bytes);
if (assetId == null) return;
var existing = scene.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
if (existing != null)
{
existing.AssetId = assetId;
UpdateActiveBackground();
return;
}
scene.Elements.Add(new Source { Name = baseName, Type = SourceType.Background, AssetId = assetId });
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
return;
}
var count = scene.Elements.OfType<Source>().Count(s => s.Type == sourceType);
var name = count == 0 ? baseName : $"{baseName} {count + 1}";
scene.Elements.Add(new Source { Name = name, Type = sourceType });
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
}
// Adds the webcam to the active scene. The camera is picked once app-wide
// (first add); afterwards "Add Webcam" just places the existing webcam here
// at the default spot — each scene's config is independent (webcam.{scene}.config).
private async Task AddWebcamToActiveSceneAsync()
{
var scene = ActiveScene;
if (scene == null || scene.WebcamConfig != null) return;
if (_webcam == null)
{
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
{
Owner = Application.Current.MainWindow
};
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
var device = dialog.PickedDevice;
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
OnPropertyChanged(nameof(CanChangeWebcam));
}
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();
var started = await _cameraManager.AcquireAsync(_webcam.DeviceId);
if (!started)
{
MessageBox.Show(
"Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
// Swaps the device on the app-wide webcam identity. The old device is stopped
// unconditionally; each scene that uses the webcam re-acquires the new one so
// the per-config refcount stays honest.
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;
var oldDevice = _webcam.DeviceId;
var newDevice = dialog.PickedDevice.Id;
if (oldDevice == newDevice) return;
_webcam.DeviceId = newDevice;
_webcam.Name = dialog.PickedDevice.DisplayName;
ScheduleSave();
if (!string.IsNullOrWhiteSpace(oldDevice))
await _cameraManager.ReleaseAllAsync(oldDevice);
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
{
var started = await _cameraManager.AcquireAsync(newDevice);
if (!started)
{
MessageBox.Show(
"Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
break;
}
}
}
// "Show Webcam" from a right-click on the empty preview. Unhides this scene's
// existing (hidden) config — the config row survives Hide in this scene — or
// adds the webcam here for the first time (which opens the camera picker).
private void ShowWebcamInActiveScene()
{
var scene = ActiveScene;
if (scene == null) return;
var config = scene.WebcamConfig;
if (config != null)
{
config.IsVisible = true;
SelectedElement = config;
return;
}
_ = AddWebcamToActiveSceneAsync();
}
private void AddImage()
{
var scene = ActiveScene;
if (scene == null) return;
var candidates = BuildImageCandidates();
if (candidates.Count == 0)
{
var bytes = PickImageBytes("Choose an image");
if (bytes != null) AddImageSource(bytes);
return;
}
var dialog = new ReuseImageDialog(new ReuseImageViewModel(candidates))
{
Owner = Application.Current.MainWindow
};
if (dialog.ShowDialog() != true) return;
if (dialog.WantsNew)
{
var bytes = PickImageBytes("Choose an image");
if (bytes != null) AddImageSource(bytes);
}
else
{
AddReusedImage(dialog.PickedAssetId!);
}
}
private List<ReuseImageCandidate> BuildImageCandidates()
{
var candidates = new List<ReuseImageCandidate>();
foreach (var scene in Scenes)
foreach (var source in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.Image && !string.IsNullOrWhiteSpace(s.AssetId)))
{
candidates.Add(new ReuseImageCandidate
{
SceneName = scene.Name,
SourceName = source.Name,
AssetId = source.AssetId!
});
}
return candidates;
}
private static byte[]? PickImageBytes(string title)
{
var dialog = new OpenFileDialog
{
Title = title,
Filter = "Image files (*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp)|*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp|All files (*.*)|*.*"
};
if (dialog.ShowDialog() != true) return null;
try
{
return File.ReadAllBytes(dialog.FileName);
}
catch (Exception ex)
{
MessageBox.Show($"Couldn't read that image: {ex.Message}", "ytLlive",
MessageBoxButton.OK, MessageBoxImage.Warning);
return null;
}
}
private string? AddAsset(byte[] bytes)
{
try
{
var image = ImageCache.FromBytes(bytes);
var id = _layoutStore.UpsertAsset(bytes, image?.PixelWidth ?? 0, image?.PixelHeight ?? 0);
if (id != null && image != null) ImageCache.Put(id, image);
return id;
}
catch (Exception ex)
{
Debug.WriteLine($"Asset store failed: {ex.Message}");
return null;
}
}
private void AddImageSource(byte[] bytes)
{
if (bytes.Length == 0) return;
var assetId = AddAsset(bytes);
if (assetId != null) AddReusedImage(assetId);
}
private void AddReusedImage(string assetId)
{
var scene = ActiveScene;
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
var count = scene.Elements.OfType<Source>().Count(s => s.Type == SourceType.Image);
var name = count == 0 ? "Image" : $"Image {count + 1}";
var source = new Source { Name = name, Type = SourceType.Image, AssetId = assetId };
var image = ImageCache.Get(assetId);
if (image != null)
{
var scale = Math.Min(640.0 / image.PixelWidth, 480.0 / image.PixelHeight);
source.Width = image.PixelWidth * scale;
source.Height = image.PixelHeight * scale;
source.X = (1920 - source.Width) / 2;
source.Y = (1080 - source.Height) / 2;
}
scene.Elements.Add(source);
SelectedElement = source;
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
}
// Removes an element from the active scene. For a webcam config this drops the
// scene's usage and releases one camera reference (CameraManager stops the
// session when the last using scene lets go). When the last config anywhere is
// removed, the webcam identity is cleared too — re-adding opens the picker
// again instead of silently resurrecting the old camera.
private void RemoveElement(SceneElement? element)
{
var scene = ActiveScene;
if (scene == null || element == null) return;
if (element is WebcamSceneConfig && _webcam != null)
{
if (SelectedElement == element)
SelectedElement = null;
_ = _cameraManager.ReleaseAsync(_webcam.DeviceId);
}
scene.Elements.Remove(element);
if (element is WebcamSceneConfig && _webcam != null
&& !Scenes.Any(s => s.Elements.OfType<WebcamSceneConfig>().Any()))
{
_webcam = null;
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
}
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
}
private void SetPanel(ref bool field, bool value, [System.Runtime.CompilerServices.CallerMemberName] string? propertyName = null)
{
if (SetProperty(ref field, value, propertyName))
OnPropertyChanged(nameof(IsAnyOverlayOpen));
}
private void ShowOverlay(string? panel = null, string title = "")
{
IsSettingsOpen = panel == nameof(IsSettingsOpen);
IsBugOpen = panel == nameof(IsBugOpen);
IsFeatureOpen = panel == nameof(IsFeatureOpen);
IsAboutOpen = panel == nameof(IsAboutOpen);
OverlayTitle = title;
}
private void SubmitBug()
{
var body = string.Join(Environment.NewLine,
BugReportText.Trim(),
string.Empty,
$"ytLlive {AppVersionLabel}",
string.IsNullOrWhiteSpace(BugReportEmail) ? string.Empty : $"Reply-to: {BugReportEmail.Trim()}");
ComposeEmail("[ytLlive Bug Report]", body);
ShowOverlay();
}
private void SubmitFeature()
{
var body = string.Join(Environment.NewLine,
FeatureRequestText.Trim(),
string.Empty,
$"ytLlive {AppVersionLabel}",
string.IsNullOrWhiteSpace(FeatureRequestEmail) ? string.Empty : $"Reply-to: {FeatureRequestEmail.Trim()}");
ComposeEmail("[ytLlive Feature Request]", body);
ShowOverlay();
}
private static void ComposeEmail(string subject, string body)
{
var uri = $"mailto:{SupportEmail}?subject={Uri.EscapeDataString(subject)}&body={Uri.EscapeDataString(body)}";
OpenUrl(uri);
}
private static void OpenUrl(string url)
{
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
// Runs the OAuth flow (browser + loopback callback) on a background thread.
// The resulting channel is returned to the caller and persisted via the
// auth service's sessionChanged hook.
private async Task<YouTubeChannel?> SignInAsync()
{
try
{
var channel = await Task.Run(() => _youtubeAuth.AuthenticateAsync());
if (channel == null)
{
MessageBox.Show("Sign-in was unsuccessful. Please try again.", "ytLlive",
MessageBoxButton.OK, MessageBoxImage.Warning);
return null;
}
IsConnected = true;
return channel;
}
catch (Exception ex)
{
MessageBox.Show($"Sign-in failed: {ex.Message}", "ytLlive",
MessageBoxButton.OK, MessageBoxImage.Error);
return null;
}
}
private void OnChatMessageReceived(ChatMessage message)
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
ChatMessages.Add(message);
if (ChatMessages.Count > 500)
ChatMessages.RemoveAt(0);
});
}
private void BeginGoLive()
{
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
{
StreamTitle = DefaultStreamTitle,
StreamDescription = DefaultStreamDescription,
Visibility = DefaultStreamVisibility,
};
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
if (window.ShowDialog() == true)
{
StreamTitle = dialog.StreamTitle;
StreamDescription = dialog.StreamDescription;
StreamVisibility = dialog.Visibility;
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
? "ytLlive"
: $"{dialog.StreamTitle} — ytLlive";
StreamStatus = StreamStatus.Streaming;
}
}
private void StopStream()
{
StreamStatus = StreamStatus.Offline;
WindowTitle = "ytLlive";
// 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;
AppLog.Write("Stream ended; session signed out");
}
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";
LivePulseOpacity = 1.0;
_liveTimer.Start();
if (BrandFlashEnabled) StartBrandFlashTimer();
}
else
{
_liveTimer.Stop();
_brandFlashTimer.Stop();
_brandFlashOffTimer.Stop();
BrandFlashActive = false;
LiveElapsedText = "00:00:00";
LivePulseOpacity = 1.0;
}
}
// 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");
LivePulseOpacity = LivePulseOpacity > 0.5 ? 0.35 : 1.0;
}
}