Compare commits

...

3 Commits

Author SHA1 Message Date
gramps 6944db8ab4 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.
2026-08-30 22:09:02 -07:00
gramps cb54637e9f docs: add Controls/index.md — the missing per-directory map for Phase-2 UserControls
Phase 2 (refactor-commit-12..18) created Controls/ with 6 UserControls that
split MainWindow.xaml, but no index.md existed — a gap against the schema.md
rule (one index.md per code directory). Documents each control, the window↔
control contract, and the Phase-2 landmines (window-resources converter rule,
control xmlns, name-looked-up host stubs, dropped-Border).
2026-08-30 21:41:18 -07:00
gramps 1f4624c636 docs: rewrite HANDOFF for Phase 2 completion (MainWindow.xaml → 6 controls)
Working tree clean. Phase 1 (pushed) + Phase 2 (local-only, commits 12-18 with
tags) both complete. Push checkpoint reached: next step is 'git push origin main
&& git push --tags' on explicit user approval. Landmines updated with the XAML
extraction gotchas (control xmlns, name-looked-up host stubs, window-resources
converter rule).
2026-08-30 21:16:50 -07:00
6 changed files with 420 additions and 322 deletions
+58
View File
@@ -0,0 +1,58 @@
# Controls — index
The phase-2 carved pieces of what used to be one monolithic `MainWindow.xaml`
(1,681 lines → a 123-line shell). Each is a `UserControl` in the `ytLive.Controls`
namespace, extracted one roll-back commit at a time (refactor-commit-12..18).
See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
| `BottomBar.xaml(.cs)` | Footer (Row 3): gear menu (stop/mic/game-mute/quality dropdown), stream stats (bitrate/fps/dropped/duration/health), resolution dropdown |
| `HealthBanner.xaml(.cs)` | Row 1 status banner + the 7 value→visibility converters live in the app theme, not here |
| `TopBar.xaml(.cs)` | Row 0 stream controls: REC/ON-AIR pills+signs, elapsed timer, start/stop, account avatar/name (`RefreshAvatar()`) |
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials) |
| `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept |
| `OverlayHost.xaml(.cs)` | Root-grid overlay dialogs: Settings / Report Bug / Feature Request / About (+ licenses + license-key entry). `BackToAbout_Click` lives here |
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
## Window ↔ control contract
The shell (`MainWindow.xaml(.cs)`) wires up each control and routes the few
cross-cutting calls through it:
- Ctor finds each control by `x:Name` (`TopBar`, `PreviewPane`, `LeftPanel`)
AFTER `InitializeComponent`, **before** any method touches it (null-ref guard).
- `_previewPane.IsClickInsidePreview/Drawer` + `_leftPanel.IsClickInside` →
deselection guard in `Window_PreviewMouseLeftButtonDown`.
- `_previewPane.UpdateSelectionOverlay(bool)` + `_leftPanel.OnSelectionChanged(vm)`
→ selection/edit-mode routing from the VM's `SelectedElement` change.
- `_topBar.RefreshAvatar()` on `IsConnected`/`AccountAvatarUrl` change.
- Handlers in moved controls cast `(DataContext as MainViewModel)` — controls
inherit the window's DataContext; never pass the VM through a property.
## Gotchas learned the hard way
- **A UserControl cannot see `Window.Resources`** (StaticResource resolves by
element lookup, so it stops at the control boundary). The 7 converters
(`BoolToVis`, `InverseBoolToVis`, `NotNullToVis`, `NullToVis`, `HiddenBoolToVis`,
`EnumToBool`, `AllTrueToVis`) were promoted from `MainWindow.Resources` into
`Themes/Controls.xaml` (see [`Themes/index.md`](../Themes/index.md)).
- **Moved XAML can lose its namespace**: a block cut from MainWindow carries no
root xmlns, so any `models:`/`Helpers:` ref in it breaks the build with
`MC2000: Value cannot be null (key)`. Declare `xmlns:models` / `xmlns:Helpers`
on each control's root (LeftPanel hit this).
- **Name-looked-up host stubs must stay in the window**, not a control:
`WebViewHostPanel` (ctor `InitWebView2(WebViewHostPanel)`) and `ToastArea`
(`NotificationService.InArea("ToastArea")`) are resolved by name from the
window root — so `OverlayHost` deliberately leaves them as thin window-root
children (Commit 18).
- When cutting visual wrappers, don't drop the outer `Border`'s
background/glow/margin — Commit 15 initially lost the preview's outer border
and had to restore it same-commit.
Related: [`ViewModels/index.md`](../ViewModels/index.md) (MainViewModel the
controls bind to), [`Themes/index.md`](../Themes/index.md) (styles/converters),
[`Helpers/index.md`](../Helpers/index.md) (ViewModelBase, converters), the
`MainWindow.xaml` shell at repo root that hosts these.
+65 -54
View File
@@ -4,39 +4,43 @@
**`main`**, working tree is **CLEAN**.
Commits `46978b8` (AGENTS push policy), `fc33022` (HANDOFF), and refactor
commits `0–5` through `e286719` are **pushed** (incl. `refactor-commit-0..5`).
Refactor commits **6–11 are LOCAL ONLY** (policy 2026-08-30: no per-commit
push; `git push --tags` only at a user-agreed milestone / "push it"):
Both split phases are now complete. Phase 1 (view-model partials) is pushed;
Phase 2 (MainWindow.xaml → UserControls) is complete but **local-only**.
| Commit | Hash | Tag | Partial | Lines |
|--------|------|-----|---------|-------|
| 6 Audio | `c1126a5` | `refactor-commit-6` | `MainViewModel.Audio.cs` | 408 |
| 7 Trax | `535a501` | `refactor-commit-7` | `MainViewModel.Trax.cs` | 132 |
| 8 Socials | `c41ff7f` | `refactor-commit-8` | `MainViewModel.Socials.cs` | 161 |
| 9 Streaming | `e1006a0` | `refactor-commit-9` | `MainViewModel.Streaming.cs` | 787 |
| 10 Chat | `3b77a13` | `refactor-commit-10` | `MainViewModel.Chat.cs` | 194 |
| 11 Overlays | `1efde5a` | `refactor-commit-11` | `MainViewModel.Overlays.cs` | 194 |
**Pushed** (reached `origin/main` == `14d3ed4`, incl. tags): Phase 1 commit `0`
and refactor commits `1..11` (`refactor-commit-0..11`), plus policy/HANDOFF.
Nightly summary: **Phase 1 complete.** The plan was 11 partials + scaffold;
all live in `ViewModels/`. Core `MainViewModel.cs` shrank 1,890 → **758
lines**. Every change: clean build (0 warnings), `scripts/verify.sh` (246
passed + 2 known failures only), `scripts/scope-check.sh` with explicit file
args, `ViewModels/index.md` updated in the SAME commit, tagged
`refactor-commit-N`, no per-commit push.
**LOCAL ONLY (Phase 2 — 12, 13, 14, 15, 16, 17, 18)** — policy 2026-08-30: no
per-commit push; `git push` only at a user-agreed milestone / "push it".
**Push pending on explicit user approval:**
| Commit | Hash | Tag | Control | From Loc |
|--------|------|-----|---------|----------|
| 12 | `bc23b64` | `refactor-commit-12` | `Controls/BottomBar` | footer |
| 13 | `6914252` | `refactor-commit-13` | `Controls/HealthBanner` | row 1 banner |
| 14 | `90f5ad5` | `refactor-commit-14` | `Controls/TopBar` | row 0 top bar |
| 15 | `c5e3466` | `refactor-commit-15` | `Controls/PreviewPane` | center preview |
| 16 | `73397cf` | `refactor-commit-16` | `Controls/SceneThumbnailStrip` | row 0 strip |
| 17 | `c9fd1bd` | `refactor-commit-17` | `Controls/LeftPanel` | row 1 col 0 |
| 18 | `544208e` | `refactor-commit-18` | `Controls/OverlayHost` | overlay dialogs |
## What's In Flight
Nothing code-in-flight — working tree clean, Phase 1 done.
Nothing code-in-flight — working tree clean, **Phase 2 complete**.
**Next (queued, not started): Phase 2 — split `MainWindow.xaml` (1,681 lines)
into UserControls.** Same rules: one work unit per control, docs in the same
commit, verify + scope-check, commit locally, `git push` only on user go.
**Phase 2 result:** `MainWindow.xaml` carved 1,681 → **123 lines** (a shell over
6 UserControls under `Controls/`, namespace `ytLive.Controls`). Every change:
0-warning build, `scripts/verify.sh` (246 pass + 2 known only), explicit-file
`scripts/scope-check.sh`, `ViewModels/index.md` in the SAME commit, tagged
`refactor-commit-N`, no per-commit push.
**Push pending:** refactor commits 6–11 + their tags are local. The Session-1
push checkpoint was never reached because the user directive was to run the
series to completion; Phase 1 completion is the natural milestone to push on
explicit user confirmation.
**Next:**
- If user says "push": `git push origin main && git push --tags` (7 commits 12-18
+ their tags).
- Then optional Phase 3 if queued. Nothing is queued beyond Phase 2.
**Pending bus:** `ViewModels/index.md` Phase 2 tracker has rows 12–18 (closed);
Phase 1 tracker closed. `Controls/` has `index.md`-style facts only if needed.
## Landmines
@@ -45,44 +49,51 @@ explicit user confirmation.
`RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle`.
- **Tests occasionally abort early (236/237)** — known flake, accepted by `verify.sh`.
- **App running locks `ytLive.exe`** → build fails MSB3027. Kill PID before building.
- **Implicit usings are SDK-default but partials must still carry their own
domain usings** (they do not inherit core's): `ICommand`/`ObservableCollection`/
`WriteableBitmap`/`DispatcherTimer` etc. are NOT implicit. Grep-verified usings:
- Audio: Input, Media, Threading, `NAudio.CoreAudioApi`, `ytLive`, Helpers, Models, Services, Services.Audio
- Trax: IO, Media, `Microsoft.Win32`, Helpers, Models, Services.Audio
- Socials: Windows, Input, Media, `ytLive`, Helpers, Models, Services, Services.Compositor
- Streaming: ObjectModel, IO, Windows, Input, Threading, `Microsoft.Win32`, Helpers, Models, Services, Services.Encoder
- Chat: ObjectModel, Media, Media.Imaging, Threading, Helpers, Models, Services
- Overlays: Diagnostics, IO, Input, Helpers
- **WinForm crash from `Window_PreviewMouseLeftButtonDown`** — handled in-side the
preview/left panel; don't add handlers that deref `OriginalSource` unguarded.
- **Controls cannot see `Window.Resources`** (converter rule, Commit 13): any
StaticResource the window once supplied must live at app level in
`Themes/Controls.xaml`. The 7 converters were promoted there.
- **Some control XAML needs its own `xmlns:models` / `xmlns:Helpers`** — a moved
block lost its namespace when it left MainWindow's root (LeftPanel hit this,
MC2000 "key null"). Always add the model/helper xmlns on each control root.
- **Name-looked-up host stubs must stay in the window:** `WebViewHostPanel`
(ctor `InitWebView2(WebViewHostPanel)`) and `ToastArea`
(`NotificationService.InArea("ToastArea")`) are resolved by name from the
window — keep them as window-root children, don't bury them in a control.
OverlayHost deliberately left them in place (Commit 18) for this reason.
- **Pre-commit gate:** `scripts/verify.sh` (0-warning build + full tests) and
`scripts/scope-check.sh "file1" …` — scope-check REQUIRES explicit file args.
- Build from WSL with the Windows dotnet host only (see `AGENTS.md`).
## Todo state
- MainViewModel refactor: **Phase 1 complete (11/11 partials + scaffold).**
`ViewModels/index.md` has a per-partial table row (code↔commit map in the
tracker section). Core row now describes only what remains (ctor wiring,
lifecycle, source commands, WebView2 hosting, frame-pump seam,
`SelectedElement`, `BrandFlash*`, `BuildCompositorOptions`/`ResolveOutputFrame`).
- MainViewModel refactor: **Phase 1 complete (11/11 partials)**, pushed.
- MainWindow.xaml refactor: **Phase 2 complete (6/6 controls)**, local-only.
- TASK 3: 27/30 (preview compositor 16, text source 17, alerts 20 still open).
- TASK 4: ✅ shipped. TASK 9: items 1–3 shipped; 4–7 open. TASK 10: steps 1–7; Velopack pending.
- TASK 18: shipped, creator verification pending. TASK 19/23, 20, 21-A: shipped.
- TASK 22 (audio sync offset): not started — required for v1.
- Monitoring: cross-partial private access is the point of partial classes —
no moved member re-declared anywhere; moved setters reference other partials
purely by name.
## Session summary (2026-08-30)
- Ran the MainViewModel split to completion under the standing directive
("continue until you can continue no longer" — no interruption prompts).
- Commits 9 (Streaming), 10 (Chat), 11 (Overlays) landed locally, each ~8:40
back-to-back: cut → 0-warning build → verify (246 pass + 2 known) →
scope-check → index.md in-commit → `git tag refactor-commit-N`. No push.
- Streaming was the hardest cut (interleaved props, master-frame consts,
session timer, health pipeline, record rename-on-stop); Chat and Overlays
were clean contiguous cuts. One build fix each: Streaming needed
`ObjectModel`/`Input`, Chat needed `Media.Imaging` for `WriteableBitmap`.
- Docs kept honest throughout: `index.md` rows for Streaming/Chat/Overlays,
About-hub row re-pointed to the Overlays partial, core row trimmed each time.
- Ran the **MainWindow.xaml split to completion** under the standing "continue
without stopping" directive — no interruption prompts, no `question` tool.
- Commits 12–18 landed locally, each: cut → 0-warning build → verify (246 pass +
2 known) → scope-check → index.md in-commit → `git tag refactor-commit-N`. No push.
- **Commit 15 (PreviewPane** the hardest: 1920×1080 canvas + drag/select/resize/
hit-test, webcam menu handlers, social glow, audio meters. A dropped outer
`Border` (background/glow/margin) was caught and restored same-commit.
- **Commit 17 (LeftPanel)** the biggest: property editors + chat autoscroll
(moved to self-subscribe on `Loaded`), drag-to-reorder, snapshot revert/accept
family; window routes via `_leftPanel.IsClickInside` and
`_leftPanel.OnSelectionChanged(vm)`.
- **Commit 18 (OverlayHost)** the scope-decision: moved only the three dialogs;
kept `WebViewHostPanel` + `ToastArea` as window-root stubs because their
name-based lookups (WebView2 init, toast `InArea`) would break if buried in a
control — a spin-guard decision, not a guess.
- Two XAML extraction gotchas recorded: (a) a moved block losing its `xmlns`
→ MC2000 "key null" (Line n col m), fixed by adding `xmlns:models` on the
control root; (b) re-deriving line numbers after each edit before `sed`/awk
deletion ranges (the edit tool shifts numbers).
- Push checkpoint reached; awaiting explicit "push" for commits 12–18 + tags.
+251
View File
@@ -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();
}
}
+41
View File
@@ -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;
}
}
-268
View File
@@ -1,5 +1,3 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
@@ -8,10 +6,7 @@ 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;
@@ -383,35 +378,6 @@ public partial class MainViewModel : ViewModelBase
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()
{
@@ -463,240 +429,6 @@ public partial class MainViewModel : ViewModelBase
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,
+5
View File
@@ -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 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
proceeds; each partial carries its own `using`s and re-declares nothing from core.