refactor: split MainViewModel core under 500 lines — 500-limit Phase 3, Commit A
Core MainViewModel.cs 758 -> 495 lines. Moved the source add/remove/image block (AddSource, NextSourceName, AddImage, BuildImageCandidates, PickImageBytes, AddAsset, AddImageSource, AddReusedImage, RemoveElement) into a new MainViewModel.Sources.cs partial, and WebView2 source hosting (RegisterLoadedWebSources, InitWebView2, OnWebView2PreviewBitmapChanged) into a new MainViewModel.Web.cs partial. Pruned now-dead usings from core. Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures. Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
This commit is contained in:
@@ -0,0 +1,251 @@
|
|||||||
|
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<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services;
|
||||||
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainViewModel
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
using System.Collections.Specialized;
|
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -8,10 +6,7 @@ using System.Windows.Controls;
|
|||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
using System.Windows.Interop;
|
using System.Windows.Interop;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using System.Windows.Media.Imaging;
|
|
||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
using Microsoft.Win32;
|
|
||||||
using NAudio.CoreAudioApi;
|
|
||||||
using ytLive.Helpers;
|
using ytLive.Helpers;
|
||||||
using ytLive.Models;
|
using ytLive.Models;
|
||||||
using ytLive.Services;
|
using ytLive.Services;
|
||||||
@@ -383,35 +378,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
AppLog.Write("LoadLayout end");
|
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()
|
public void Shutdown()
|
||||||
{
|
{
|
||||||
@@ -463,240 +429,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(BackgroundVisible));
|
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
|
// Scene-element → latest frame, for the live compositor. The map mirrors the
|
||||||
// preview: webcam by DeviceId, live captures (the background) by CaptureKey,
|
// preview: webcam by DeviceId, live captures (the background) by CaptureKey,
|
||||||
|
|||||||
@@ -62,5 +62,10 @@ Progress (Commit N/12 · tag):
|
|||||||
- [x] **Commit 17 · `refactor-commit-17`** — **LeftPanel** → `Controls/LeftPanel.xaml(.cs)` (Row 1 Col 0 left panel: layers/properties edit UI + live chat list). Moved ALL property-editor and chat handlers: `SourceNameBox_*`/`CommitSourceEdit`, `AddSourceButton_Click`, `OnChatMessagesChanged` (now self-subscribed on Loaded), `MirrorButton_Click`, `ShapeButton_Click`, `SourceMenu_Remove`, `BackgroundMenu_*`, `OpacitySlider_ValueChanged`, list drag-to-reorder (`_dragStartPoint`+`List_Preview*`+`EndListDrag`+`FindItemContainerAt`), and the snapshot family (`TakeSnapshot`, `WebcamBorder*`, `Countdown*`, `Web*`). Window exposes via `_leftPanel.IsClickInside(original)` (deselect guard) and `_leftPanel.OnSelectionChanged(vm)` (opacity label + snapshot). Pending: overlay host region.
|
- [x] **Commit 17 · `refactor-commit-17`** — **LeftPanel** → `Controls/LeftPanel.xaml(.cs)` (Row 1 Col 0 left panel: layers/properties edit UI + live chat list). Moved ALL property-editor and chat handlers: `SourceNameBox_*`/`CommitSourceEdit`, `AddSourceButton_Click`, `OnChatMessagesChanged` (now self-subscribed on Loaded), `MirrorButton_Click`, `ShapeButton_Click`, `SourceMenu_Remove`, `BackgroundMenu_*`, `OpacitySlider_ValueChanged`, list drag-to-reorder (`_dragStartPoint`+`List_Preview*`+`EndListDrag`+`FindItemContainerAt`), and the snapshot family (`TakeSnapshot`, `WebcamBorder*`, `Countdown*`, `Web*`). Window exposes via `_leftPanel.IsClickInside(original)` (deselect guard) and `_leftPanel.OnSelectionChanged(vm)` (opacity label + snapshot). Pending: overlay host region.
|
||||||
- [x] **Commit 18 · `refactor-commit-18`** — **OverlayHost** → `Controls/OverlayHost.xaml(.cs)` (settings/bug/feature/about dialogs). `BackToAbout_Click` moved with it (uses inherited `DataContext`). Window keeps the thin `WebViewHostPanel` + `ToastArea` root stubs so name lookups are untouched (WebView2 ctor init, `NotificationService.InArea("ToastArea")`) — toast/WebView2 behavior unchanged. **Phase 2 COMPLETE** — MainWindow.xaml carved down to a 123-line shell over 6 controls (TopBar, HealthBanner, BottomBar, SceneThumbnailStrip, LeftPanel, PreviewPane, OverlayHost). Pending: session wrap-up (HANDOFF.md) + push on approval.
|
- [x] **Commit 18 · `refactor-commit-18`** — **OverlayHost** → `Controls/OverlayHost.xaml(.cs)` (settings/bug/feature/about dialogs). `BackToAbout_Click` moved with it (uses inherited `DataContext`). Window keeps the thin `WebViewHostPanel` + `ToastArea` root stubs so name lookups are untouched (WebView2 ctor init, `NotificationService.InArea("ToastArea")`) — toast/WebView2 behavior unchanged. **Phase 2 COMPLETE** — MainWindow.xaml carved down to a 123-line shell over 6 controls (TopBar, HealthBanner, BottomBar, SceneThumbnailStrip, LeftPanel, PreviewPane, OverlayHost). Pending: session wrap-up (HANDOFF.md) + push on approval.
|
||||||
|
|
||||||
|
**Phase 3 — 500-line compliance (production `.cs` only).** Bring all production
|
||||||
|
source files under 500 lines. One roll-back commit per file; same drill
|
||||||
|
(0-warning build, `verify.sh` 246+2, explicit-file scope-check, tracker in same
|
||||||
|
commit, tag `refactor-commit-N`, no per-commit push).
|
||||||
|
- [x] **Commit A · `refactor-commit-A`** — **core** → `MainViewModel.cs` 758 → **495**. Moved source add/remove/image block to new **`MainViewModel.Sources.cs`** (234 lines: `AddSource`, `NextSourceName`, `AddImage`, `BuildImageCandidates`, `PickImageBytes`, `AddAsset`, `AddImageSource`, `AddReusedImage`, `RemoveElement`) and WebView2 hosting (`RegisterLoadedWebSources`, `InitWebView2`, `OnWebView2PreviewBitmapChanged`) to new **`MainViewModel.Web.cs`**. Pruned now-dead usings (`Microsoft.Win32`, `NAudio.CoreAudioApi`, `System.Windows.Media.Imaging`, `Collections.ObjectModel/Specialized`). Pending: Streaming, Background, SocialsDialogViewModel, LayoutStore.
|
||||||
The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split
|
The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split
|
||||||
proceeds; each partial carries its own `using`s and re-declares nothing from core.
|
proceeds; each partial carries its own `using`s and re-declares nothing from core.
|
||||||
Reference in New Issue
Block a user