using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Windows; using Microsoft.Win32; using ytLive.Helpers; using ytLive.Models; using ytLive.Services; using ytLive.ViewModels; namespace ytLive.ViewModels; public partial class MainViewModel { private void AddSource(object? parameter) { var scene = StagedScene; if (scene == null) return; var sourceType = parameter is SourceType typed ? typed : Enum.TryParse(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().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, }; _sceneGraph.AddElement(scene, 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() .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 BuildImageCandidates() { var candidates = new List(); foreach (var scene in Scenes) foreach (var source in scene.Elements.OfType().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; } _sceneGraph.AddElement(scene, 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); _sceneGraph.RemoveElement(scene, element); if (element is WebcamSceneConfig && _webcam != null && !Scenes.Any(s => s.Elements.OfType().Any())) { _webcam = null; OnPropertyChanged(nameof(CanChangeWebcam)); OnPropertyChanged(nameof(CanAddWebcam)); OnPropertyChanged(nameof(CanShowWebcamInStagedScene)); } OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint)); UpdateActiveBackground(); } }