Backgrounds: one locked Background per screen + mini rule (TASK 25)

This commit is contained in:
2026-08-23 13:33:33 -07:00
parent b400ab59c5
commit 7dd140a4c0
13 changed files with 554 additions and 309 deletions
+130 -242
View File
@@ -201,7 +201,12 @@ public class MainViewModel : ViewModelBase
s.IsStaged = s == value;
SelectedElement = null;
EnsureDefaultBackground(value);
if (value != null)
{
var background = EnsureBackground(value);
if (background != null)
StampDefaultBackgroundAsset(background, value.Name);
}
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
@@ -904,7 +909,10 @@ public class MainViewModel : ViewModelBase
public bool ShowAddScene => MissingScenes.Any();
/// <summary>"Change Capture…"/"Refresh Capture" apply to the active scene's background.</summary>
public bool CanChangeBackground => StagedScene?.HasBackground == true;
// Capture controls (Show Desktop / monitor pick / refresh) exist only on
// the Live screen's Background. Static-art screens never expose them.
public bool CanChangeBackground =>
StagedScene != null && SceneCatalog.Is(StagedScene.Name, SceneCatalog.Live);
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
// cadence timer is stopped and any active flash is hidden immediately.
@@ -935,6 +943,10 @@ public class MainViewModel : ViewModelBase
private void UpdateActiveBackground()
{
// Static-art rows only. Capture-flavored rows (Live) must NEVER feed
// ActiveBackgroundImage — it renders above BackgroundImage, so it would
// permanently cover the desktop/game capture. Live's canvas shows
// DisplaySource instead (capture, falling back to live-background.jpg).
var background = StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId)
? ImageCache.Get(background.AssetId)
@@ -994,17 +1006,22 @@ public class MainViewModel : ViewModelBase
{
foreach (var scene in Scenes)
{
var img = LoadBackgroundImage(scene);
// Minis never render live captures. While the Live screen is staged
// (being previewed) its mini shows the green placeholder; unstaged,
// it shows the stored default art. Real-time surface = center only.
var stagedLive = ReferenceEquals(scene, StagedScene)
&& SceneCatalog.Is(scene.Name, SceneCatalog.Live);
var img = stagedLive ? null : LoadBackgroundImage(scene);
if (img != null)
scene.Snapshot = img;
else if (scene.Snapshot == null)
else if (scene.Snapshot == null || stagedLive)
scene.Snapshot = CreatePlaceholderSnapshot(scene.Name);
}
}
private static System.Windows.Media.Imaging.BitmapSource? LoadBackgroundImage(Scene scene)
internal static System.Windows.Media.Imaging.BitmapSource? LoadBackgroundImage(Scene scene)
{
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (bg != null && !bg.UseDefaultBackground
&& !string.IsNullOrWhiteSpace(bg.CustomBackgroundPath)
&& System.IO.File.Exists(bg.CustomBackgroundPath))
@@ -1746,15 +1763,7 @@ public class MainViewModel : ViewModelBase
foreach (var name in SceneCatalog.All)
AddScene(name);
}
SeedStartingBackground();
SeedBrbBackground();
SeedEndingBackground();
SeedChatBackground();
// The live background belongs to Live only; a pre-policy DB may have
// backgrounds lingering in other scenes — drop them, then
// ReacquireScreenCaptures heals Live's.
EnforceBackgroundPolicy(Scenes);
HealBackgrounds();
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
@@ -1853,10 +1862,22 @@ public class MainViewModel : ViewModelBase
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (background != null) return background;
background = new Source
background = CreateBackground(scene.Name);
scene.Elements.Insert(0, background);
return background;
}
/// <summary>The one Background row for a scene. Live gets the capture flavor
/// (Show Desktop / monitor switch / fullscreen-game detect) falling back to
/// live-background.jpg; every other screen is a static default-art layer.
/// Both are named "Background" — the user never sees capture jargon here.</summary>
internal static Source CreateBackground(string sceneName)
{
var isLive = SceneCatalog.Is(sceneName, SceneCatalog.Live);
return new Source
{
Name = "Game Capture",
Type = SourceType.DisplayCapture,
Name = "Background",
Type = isLive ? SourceType.DisplayCapture : SourceType.Background,
IsBackground = true,
IsEnabled = true,
X = 0,
@@ -1864,19 +1885,64 @@ public class MainViewModel : ViewModelBase
Width = MasterFrameWidth,
Height = MasterFrameHeight,
};
scene.Elements.Insert(0, background);
return background;
}
// Every scene has a background layer. Normalize the flag by scene name.
internal static void EnforceBackgroundPolicy(IEnumerable<Scene> scenes)
/// <summary>Heals any loaded layout to the locked Background model: exactly
/// one Background per canonical scene, correct flavor for the scene (static
/// art everywhere, DisplayCapture on Live only), at index 0, named
/// "Background". Duplicates and stale rows are dropped; missing ones seeded;
/// non-canonical scenes get HasBackground=false and lose any backgrounds.</summary>
internal static void NormalizeBackgrounds(IEnumerable<Scene> scenes)
{
foreach (var scene in scenes)
scene.HasBackground = SceneCatalog.HasBackground(scene.Name);
{
if (!SceneCatalog.HasBackground(scene.Name))
{
scene.HasBackground = false;
foreach (var stale in scene.Elements.OfType<Source>().Where(s => s.IsBackground).ToList())
scene.Elements.Remove(stale);
continue;
}
scene.HasBackground = true;
var isLive = SceneCatalog.Is(scene.Name, SceneCatalog.Live);
var wantedType = isLive ? SourceType.DisplayCapture : SourceType.Background;
var candidates = scene.Elements.OfType<Source>().Where(s => s.IsBackground).ToList();
var keep = candidates.FirstOrDefault(b => b.Type == wantedType) ?? candidates.FirstOrDefault();
foreach (var extra in candidates.Where(b => !ReferenceEquals(b, keep)))
scene.Elements.Remove(extra);
if (keep == null)
{
keep = CreateBackground(scene.Name);
scene.Elements.Insert(0, keep);
}
// Source.Type's setter derives IsBackground from the enum, so a
// flavor correction must re-assert the flag afterwards.
if (keep.Type != wantedType)
{
keep.Type = wantedType;
keep.IsBackground = true;
}
keep.Name = "Background";
keep.IsEnabled = true;
var index = scene.Elements.IndexOf(keep);
if (index > 0)
scene.Elements.Move(index, 0);
}
}
// Only the Live screen's background is a real-time surface. Every other
// lookup below (auto-capture, focus hooks, refresh) must ignore the static
// default-art rows so they can never start a desktop capture session.
private IEnumerable<Source> AllBackgrounds()
=> Scenes.SelectMany(s => s.Elements.OfType<Source>().Where(x => x.IsBackground));
=> Scenes.Where(s => SceneCatalog.Is(s.Name, SceneCatalog.Live))
.SelectMany(s => s.Elements.OfType<Source>().Where(x => x.IsBackground));
// Full-screen game on its monitor, else null (static bg fallback — no
// infinity mirror). Called at launch (from ReacquireScreenCaptures, before
@@ -1901,10 +1967,7 @@ public class MainViewModel : ViewModelBase
// session. Captures no longer referenced by any scene are released.
private void ReacquireScreenCaptures()
{
foreach (var scene in Scenes)
EnsureBackground(scene);
SeedLiveBackgroundAsset();
HealBackgrounds();
var auto = ResolveAutoCaptureKey();
if (auto == null)
@@ -2013,7 +2076,7 @@ public class MainViewModel : ViewModelBase
{
var scene = StagedScene;
if (scene == null) return;
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (bg == null) return;
var dlg = new Microsoft.Win32.OpenFileDialog
@@ -2236,50 +2299,43 @@ public class MainViewModel : ViewModelBase
StagedScene = scene;
}
private void SeedStartingBackground()
{
try
{
var starting = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Starting"));
if (starting == null) return;
if (starting.Elements.OfType<Source>().Any(s => s.Type == SourceType.Background)) return;
// ─── Background model (TASK 25): exactly ONE Background per screen, index 0,
// never removable or re-addable. Static default art on Starting/BRB/Chat/Ending;
// on Live it's the capture-flavored row (Show Desktop / monitor switch /
// fullscreen-game detect) falling back to live-background.jpg. ───
var uri = new Uri("pack://application:,,,/Assets/starting-background.jpg", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return;
using var ms = new MemoryStream();
stream.CopyTo(ms);
var bytes = ms.ToArray();
var assetId = AddAsset(bytes);
if (assetId == null) return;
starting.Elements.Add(new Source
{
Name = "Background",
Type = SourceType.Background,
AssetId = assetId,
Width = 1920,
Height = 1080,
});
private static string? DefaultBackgroundAsset(string sceneName) => sceneName switch
{
SceneCatalog.Starting => "starting-background.jpg",
SceneCatalog.Brb => "brb-background.jpg",
SceneCatalog.Chat => "chat-background.jpg",
SceneCatalog.Ending => "ending-background.jpg",
SceneCatalog.Live => "live-background.jpg",
_ => null,
};
/// <summary>Heals every scene to the locked Background model after a layout
/// load: keeps the correct flavor per scene at index 0 named "Background",
/// drops duplicates/stale rows, re-seeds missing ones.</summary>
private void HealBackgrounds()
{
NormalizeBackgrounds(Scenes);
foreach (var scene in Scenes)
{
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (background != null)
StampDefaultBackgroundAsset(background, scene.Name);
}
catch { /* non-critical — stream works fine without a default bg */ }
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
}
/// <summary>
/// When switching to a scene that has no background, seed the default.
/// </summary>
private void EnsureDefaultBackground(Scene? scene)
/// <summary>Points a background row at its scene's shipped default art when it
/// carries none. A custom image (Browse…) always wins and is never overwritten.</summary>
private void StampDefaultBackgroundAsset(Source background, string sceneName)
{
if (scene == null) return;
if (scene.Elements.OfType<Source>().Any(s => s.Type == SourceType.Background)) return;
string? asset = scene.Name switch
{
"Starting" => "starting-background.jpg",
"BRB" => "brb-background.jpg",
"Ending" => "ending-background.jpg",
"Chat" => "chat-background.jpg",
_ => null,
};
if (!string.IsNullOrWhiteSpace(background.AssetId)) return;
if (!background.UseDefaultBackground && !string.IsNullOrWhiteSpace(background.CustomBackgroundPath)) return;
var asset = DefaultBackgroundAsset(sceneName);
if (asset == null) return;
try
@@ -2289,158 +2345,9 @@ public class MainViewModel : ViewModelBase
if (stream == null) return;
using var ms = new MemoryStream();
stream.CopyTo(ms);
var bytes = ms.ToArray();
var assetId = AddAsset(bytes);
if (assetId == null) return;
scene.Elements.Add(new Source
{
Name = "Background",
Type = SourceType.Background,
AssetId = assetId,
Width = 1920,
Height = 1080,
});
UpdateActiveBackground();
background.AssetId = AddAsset(ms.ToArray());
}
catch { /* non-critical */ }
}
private void SeedBrbBackground()
{
try
{
var brb = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "BRB"));
if (brb == null) return;
var uri = new Uri("pack://application:,,,/Assets/brb-background.jpg", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return;
using var ms = new MemoryStream();
stream.CopyTo(ms);
var bytes = ms.ToArray();
var assetId = AddAsset(bytes);
if (assetId == null) return;
var existing = brb.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
if (existing != null)
{
existing.AssetId = assetId;
existing.Width = 1920;
existing.Height = 1080;
}
else
{
brb.Elements.Add(new Source
{
Name = "Background",
Type = SourceType.Background,
AssetId = assetId,
Width = 1920,
Height = 1080,
});
}
}
catch { /* non-critical — stream works fine without a default bg */ }
}
private void SeedLiveBackgroundAsset()
{
try
{
var live = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Live"));
if (live == null) return;
var background = live.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (background == null) return;
var uri = new Uri("pack://application:,,,/Assets/live-background.jpg", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return;
using var ms = new MemoryStream();
stream.CopyTo(ms);
var bytes = ms.ToArray();
var assetId = AddAsset(bytes);
if (assetId == null) return;
background.AssetId = assetId;
background.Width = 1920;
background.Height = 1080;
}
catch { /* non-critical — stream works fine without a default bg */ }
}
private void SeedEndingBackground()
{
try
{
var ending = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Ending"));
if (ending == null) return;
var uri = new Uri("pack://application:,,,/Assets/ending-background.jpg", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return;
using var ms = new MemoryStream();
stream.CopyTo(ms);
var bytes = ms.ToArray();
var assetId = AddAsset(bytes);
if (assetId == null) return;
var existing = ending.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
if (existing != null)
{
existing.AssetId = assetId;
existing.Width = 1920;
existing.Height = 1080;
}
else
{
ending.Elements.Add(new Source
{
Name = "Background",
Type = SourceType.Background,
AssetId = assetId,
Width = 1920,
Height = 1080,
});
}
}
catch { /* non-critical — stream works fine without a default bg */ }
}
private void SeedChatBackground()
{
try
{
var chat = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Chat"));
if (chat == null) return;
var uri = new Uri("pack://application:,,,/Assets/chat-background.jpg", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return;
using var ms = new MemoryStream();
stream.CopyTo(ms);
var bytes = ms.ToArray();
var assetId = AddAsset(bytes);
if (assetId == null) return;
var existing = chat.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
if (existing != null)
{
existing.AssetId = assetId;
existing.Width = 1920;
existing.Height = 1080;
}
else
{
chat.Elements.Add(new Source
{
Name = "Background",
Type = SourceType.Background,
AssetId = assetId,
Width = 1920,
Height = 1080,
});
}
}
catch { /* non-critical — stream works fine without a default bg */ }
catch { /* non-critical — the app works without default art */ }
}
private void BeginEditElement(SceneElement? element)
@@ -2467,9 +2374,6 @@ public class MainViewModel : ViewModelBase
: Enum.TryParse<SourceType>(parameter?.ToString(), true, out var parsed) ? parsed : SourceType.Image;
var baseName = sourceType switch
{
SourceType.DisplayCapture => "Screen",
SourceType.WindowCapture => "Window",
SourceType.Background => "Background",
SourceType.Image => "Image",
SourceType.TextOverlay => "Text",
SourceType.Countdown => "Countdown",
@@ -2478,27 +2382,11 @@ public class MainViewModel : ViewModelBase
_ => "Layer",
};
if (sourceType == SourceType.Background)
{
var bytes = PickImageBytes("Choose a background 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();
// The (+) menu only adds regular layers. The Background is a locked
// per-screen layer managed by EnsureBackground/NormalizeBackgrounds —
// never user-created, never deleted.
if (sourceType is SourceType.DisplayCapture or SourceType.WindowCapture or SourceType.Background)
return;
}
var source = new Source
{