Backgrounds: one locked Background per screen + mini rule (TASK 25)
This commit is contained in:
+130
-242
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user