feat(ui): Show Desktop toggle, avatar login, chat preview, backdrop seeding, border animations

- Source.ShowDesktop bool persisted in DB (schema migration + LayoutStore)
- ClearBackdropCaptureAsync clears CaptureKey + VideoImageSource for static fallback
- Top bar: red/green/pulsing status light, avatar via BitmapImage, Log In button
- ShowStartStream now requires IsConnected
- RunMockChatPreviewAsync: simple async loop, ChatPreviewEnabled property on Source
- EnsureDefaultBackdrop called on every scene switch, seeds all 5 scenes
- SeedEndingBackdrop replaces existing, SeedChatBackdrop added
- SceneCatalog: Chat scene display name corrected (layer = 'YouTube Chat')
- Left panel spacing consistency, context menu reordered
- Border animation rendering via code-behind Storyboard approach
- 3 new backdrop assets (ending-backdrop.jpg, chat-backdrop.jpg, live-backdrop.jpg)
- Build 0 warnings, 178 tests passing
This commit is contained in:
2026-08-20 18:02:41 -07:00
parent a76a3d2acd
commit 0bd17ab428
18 changed files with 1022 additions and 216 deletions
+416 -40
View File
@@ -127,11 +127,23 @@ public class MainViewModel : ViewModelBase
private readonly ScreenCaptureManager _screenCaptureManager;
private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
private readonly ChatBoxRenderer _chatBoxRenderer;
private int? _lastForegroundFullScreenMonitor;
private CancellationTokenSource? _deactivateCts;
// Mock chat messages for the ChatBox preview — branded placeholders so the
// creator sees where the overlay lands before going live.
private static readonly IReadOnlyList<ChatMessage> MockChatMessages = new[]
{
new ChatMessage { AuthorName = "LlamaCasty", Message = "welcome to the stream!", Timestamp = DateTime.Now.AddMinutes(-4), IsMember = true },
new ChatMessage { AuthorName = "LlamaCastyFan", Message = "$10 — LlamaCasty is the best!", Timestamp = DateTime.Now.AddMinutes(-3), IsSuperChat = true },
new ChatMessage { AuthorName = "GamerDude", Message = "what streaming software is this?", Timestamp = DateTime.Now.AddMinutes(-2) },
new ChatMessage { AuthorName = "LlamaCasty", Message = "thanks for watching! llamacasty.com", Timestamp = DateTime.Now.AddMinutes(-1), IsMember = true },
new ChatMessage { AuthorName = "LlamaDog", Message = "Subscribe so they can buy the subscription at llamacasty.com!", Timestamp = DateTime.Now, IsMember = true },
};
private ImageSource? _backdropImage;
private HashSet<string> _liveCaptureKeys = new();
// Chat fade timer: clears chat overlay after N seconds of inactivity.
private System.Windows.Threading.DispatcherTimer? _chatFadeTimer;
// Branding flash (monetization): a full-frame "made with ytLlive!" shown
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
// BrandFlashEnabled = false. See ai.md "Monetization".
@@ -172,6 +184,7 @@ public class MainViewModel : ViewModelBase
if (SetProperty(ref _activeScene, value))
{
SelectedElement = null;
EnsureDefaultBackdrop(value);
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
@@ -758,7 +771,7 @@ public class MainViewModel : ViewModelBase
public string RecDotBrush => !IsLive ? "#555555" : IsLivePrivate ? "#8f1f1f" : "#e94560";
public string RecTextBrush => IsLive ? "#ffffff" : "#888888";
public double RecDotOpacity => IsLive ? _recDotPulse : 0.55;
public bool ShowStartStream => IsOffline;
public bool ShowStartStream => IsOffline && IsConnected;
public bool ShowChatInactiveMessage => !IsLive;
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackdropImage == null;
@@ -1124,6 +1137,7 @@ public class MainViewModel : ViewModelBase
public ICommand ChangeCaptureCommand { get; }
public ICommand RefreshCaptureCommand { get; }
public ICommand SetBackdropDisplayCommand { get; }
public ICommand ToggleShowDesktopCommand { get; }
public ICommand ToggleMicMuteCommand { get; }
public ICommand ToggleGameMuteCommand { get; }
public ICommand OpenMicPickerCommand { get; }
@@ -1194,6 +1208,7 @@ public class MainViewModel : ViewModelBase
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
ToggleShowDesktopCommand = new RelayCommand(_ => ToggleBackdropShowDesktop());
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
ToggleGameMuteCommand = new RelayCommand(_ => ToggleGameMute());
OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone());
@@ -1256,6 +1271,7 @@ public class MainViewModel : ViewModelBase
_ = StartMicCaptureAsync();
_fullScreenDetector = new Win32FullScreenDetector();
_fullScreenDetector.FullscreenMonitorChanged += OnFullscreenMonitorChanged;
foreach (var display in _fullScreenDetector.GetDisplays())
Displays.Add(display);
@@ -1357,6 +1373,8 @@ public class MainViewModel : ViewModelBase
}
SeedStartingBackdrop();
SeedBrbBackdrop();
SeedEndingBackdrop();
SeedChatBackdrop();
// The live backdrop belongs to Live only; a pre-policy DB may have
// backdrops lingering in other scenes — drop them, then
@@ -1377,6 +1395,8 @@ public class MainViewModel : ViewModelBase
}
ActiveScene = Scenes.FirstOrDefault();
UpdateActiveBackground();
UpdateActiveChatBoxPreviews();
StartMockChatPreview();
UpdateBackdropImage();
ReacquireWebcam();
ReacquireScreenCaptures();
@@ -1486,11 +1506,21 @@ public class MainViewModel : ViewModelBase
private IEnumerable<Source> AllBackdrops()
=> Scenes.SelectMany(s => s.Elements.OfType<Source>().Where(x => x.IsBackdrop));
// Full-screen game on its monitor, else the primary display. Called at launch
// (from ReacquireScreenCaptures, before the window steals focus) and on
// focus regain (RefreshBackdropAutoCapture).
private string ResolveAutoCaptureKey()
=> $"{MonitorKeyPrefix}{_fullScreenDetector.GetForegroundFullScreenMonitorIndex() ?? _fullScreenDetector.PrimaryMonitorIndex()}";
// Full-screen game on its monitor, else null (static bg fallback — no
// infinity mirror). Called at launch (from ReacquireScreenCaptures, before
// the window steals focus) and on focus regain (RefreshBackdropAutoCapture).
// When a backdrop has ShowDesktop=true, returns the primary monitor instead
// of null so the desktop is always visible.
private string? ResolveAutoCaptureKey()
{
var gameMonitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
if (gameMonitor != null)
return $"{MonitorKeyPrefix}{gameMonitor}";
if (AllBackdrops().Any(b => b.ShowDesktop))
return $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
return null;
}
// After a layout load / file open: make sure every backdrop-enabled scene
// has a backdrop, give any backdrop without a persisted key the auto-detected
@@ -1502,10 +1532,20 @@ public class MainViewModel : ViewModelBase
foreach (var scene in Scenes)
EnsureBackdrop(scene);
SeedLiveBackdropAsset();
var auto = ResolveAutoCaptureKey();
foreach (var backdrop in AllBackdrops())
if (string.IsNullOrWhiteSpace(backdrop.CaptureKey))
backdrop.CaptureKey = auto;
if (auto == null)
{
foreach (var backdrop in AllBackdrops())
backdrop.CaptureKey = null;
}
else
{
foreach (var backdrop in AllBackdrops())
if (string.IsNullOrWhiteSpace(backdrop.CaptureKey))
backdrop.CaptureKey = auto;
}
var keys = AllBackdrops()
.Select(b => b.CaptureKey!)
@@ -1520,6 +1560,8 @@ public class MainViewModel : ViewModelBase
foreach (var key in keys)
_ = _screenCaptureManager.AcquireAsync(key);
_fullScreenDetector.StartWatching();
}
// ScreenCaptureManager creates the shared WriteableBitmap on the UI thread;
@@ -1534,38 +1576,89 @@ public class MainViewModel : ViewModelBase
backdrop.VideoImageSource = bitmap;
}
// One-shot foreground snapshot ~250ms after we lose focus, so the next
// Activated re-detect can see the full-screen game the user switched to.
// A single sample per deactivation — not a session listener or a poller.
public void NoteBackgroundWindow()
// EVENT_SYSTEM_FOREGROUND fires whenever the foreground window changes.
// When a full-screen game appears on a different monitor than the current
// backdrop, auto-redesignate. When ShowDesktop is enabled and no fullscreen
// game is detected, fall back to the primary monitor (desktop view).
private void OnFullscreenMonitorChanged(int? monitor)
{
_deactivateCts?.Cancel();
_deactivateCts = new CancellationTokenSource();
var token = _deactivateCts.Token;
_ = Task.Run(async () =>
if (monitor != null)
{
try
{
await Task.Delay(250, token);
var monitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
if (!token.IsCancellationRequested)
_lastForegroundFullScreenMonitor = monitor;
}
catch (OperationCanceledException) { }
});
var newKey = $"{MonitorKeyPrefix}{monitor}";
var current = AllBackdrops().FirstOrDefault();
if (current?.CaptureKey == newKey) return;
_ = RedesignateBackdropAsync(newKey);
}
else if (AllBackdrops().Any(b => b.ShowDesktop))
{
var desktopKey = $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
var current = AllBackdrops().FirstOrDefault();
if (current?.CaptureKey == desktopKey) return;
_ = RedesignateBackdropAsync(desktopKey);
}
}
// Re-runs full-screen detection at launch and when the app regains focus.
// A null detection (no full-screen foreground window — our app, the desktop,
// a normal window) leaves the current capture alone.
// Manual refresh: the "Refresh Capture" context menu item. Reads the
// current foreground directly (no caching needed — the hook handles
// automatic detection).
public void RefreshBackdropAutoCapture()
{
var detected = _lastForegroundFullScreenMonitor ?? _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
_lastForegroundFullScreenMonitor = null;
var detected = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
if (detected == null) return;
_ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{detected}");
}
/// <summary>Whether the active backdrop is in Show Desktop mode.
/// Bindable IsChecked property for the context menu toggle.</summary>
public bool BackdropShowDesktop
{
get => ActiveScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop)?.ShowDesktop ?? false;
set
{
var backdrop = ActiveScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
if (backdrop == null || backdrop.ShowDesktop == value) return;
backdrop.ShowDesktop = value;
OnPropertyChanged(nameof(BackdropShowDesktop));
ScheduleSave();
if (value)
{
_ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}");
}
else
{
var gameMonitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
if (gameMonitor != null)
_ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{gameMonitor}");
else
_ = ClearBackdropCaptureAsync();
}
}
}
private void ToggleBackdropShowDesktop() => BackdropShowDesktop = !BackdropShowDesktop;
/// <summary>Release the backdrop capture so the static placeholder image shows.</summary>
private async Task ClearBackdropCaptureAsync()
{
var oldKeys = AllBackdrops()
.Select(b => b.CaptureKey!)
.Where(k => !string.IsNullOrWhiteSpace(k))
.ToHashSet();
foreach (var backdrop in AllBackdrops())
{
backdrop.CaptureKey = null;
backdrop.VideoImageSource = null;
}
foreach (var key in oldKeys)
await _screenCaptureManager.ReleaseAllAsync(key);
_liveCaptureKeys.Clear();
UpdateBackdropImage();
ScheduleSave();
}
private async Task RedesignateBackdropAsync(string newKey)
{
var oldKeys = AllBackdrops()
@@ -1765,6 +1858,47 @@ public class MainViewModel : ViewModelBase
catch { /* non-critical — stream works fine without a default bg */ }
}
/// <summary>
/// When switching to a scene that has no background, seed the default.
/// </summary>
private void EnsureDefaultBackdrop(Scene? scene)
{
if (scene == null) return;
if (scene.Elements.OfType<Source>().Any(s => s.Type == SourceType.Background)) return;
string? asset = scene.Name switch
{
"Starting" => "starting-backdrop.jpg",
"BRB" => "brb-backdrop.jpg",
"Ending" => "ending-backdrop.jpg",
"Chat" => "chat-backdrop.jpg",
_ => null,
};
if (asset == null) return;
try
{
var uri = new Uri($"pack://application:,,,/Assets/{asset}", 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;
scene.Elements.Add(new Source
{
Name = "Background",
Type = SourceType.Background,
AssetId = assetId,
Width = 1920,
Height = 1080,
});
UpdateActiveBackground();
}
catch { /* non-critical */ }
}
private void SeedBrbBackdrop()
{
try
@@ -1803,6 +1937,106 @@ public class MainViewModel : ViewModelBase
catch { /* non-critical — stream works fine without a default bg */ }
}
private void SeedLiveBackdropAsset()
{
try
{
var live = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Live"));
if (live == null) return;
var backdrop = live.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
if (backdrop == null) return;
var uri = new Uri("pack://application:,,,/Assets/live-backdrop.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;
backdrop.AssetId = assetId;
backdrop.Width = 1920;
backdrop.Height = 1080;
}
catch { /* non-critical — stream works fine without a default bg */ }
}
private void SeedEndingBackdrop()
{
try
{
var ending = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Ending"));
if (ending == null) return;
var uri = new Uri("pack://application:,,,/Assets/ending-backdrop.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 SeedChatBackdrop()
{
try
{
var chat = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Chat"));
if (chat == null) return;
var uri = new Uri("pack://application:,,,/Assets/chat-backdrop.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 */ }
}
private void BeginEditElement(SceneElement? element)
{
if (element == null) return;
@@ -1834,7 +2068,7 @@ public class MainViewModel : ViewModelBase
SourceType.TextOverlay => "Text",
SourceType.Countdown => "Countdown",
SourceType.WebSource => "Web",
SourceType.ChatBox => "Chat",
SourceType.ChatBox => "YouTube Chat",
_ => "Layer",
};
@@ -1860,15 +2094,32 @@ public class MainViewModel : ViewModelBase
return;
}
scene.Elements.Add(new Source
var source = new Source
{
Name = NextSourceName(scene, baseName),
Type = sourceType,
X = sourceType == SourceType.ChatBox ? 1520 : 0,
Y = 0,
Width = sourceType == SourceType.ChatBox ? 400 : 0,
Height = sourceType == SourceType.ChatBox ? 1080 : 0,
});
X = sourceType == SourceType.ChatBox ? 1400 : 0,
Y = sourceType == SourceType.ChatBox ? 20 : 0,
Width = sourceType == SourceType.ChatBox ? 500 : 0,
Height = sourceType == SourceType.ChatBox ? 648 : 0,
};
scene.Elements.Add(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();
@@ -2264,9 +2515,42 @@ public class MainViewModel : ViewModelBase
ChatMessages.Add(message);
if (ChatMessages.Count > 500)
ChatMessages.RemoveAt(0);
// Reset chat fade timer on every message
ResetChatFadeTimer();
StopMockChatPreview();
// Switch ChatBox previews from mock to real messages
if (ActiveScene != null)
foreach (var chatBox in ActiveScene.Elements.OfType<Source>().Where(s => s.Type == SourceType.ChatBox))
UpdateChatBoxPreview(chatBox);
});
}
private void ResetChatFadeTimer()
{
_chatFadeTimer?.Stop();
var chatBoxes = Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox && s.ChatFadeSeconds > 0).ToList();
if (chatBoxes.Count == 0) return;
var delay = chatBoxes.Min(s => s.ChatFadeSeconds);
_chatFadeTimer = new System.Windows.Threading.DispatcherTimer
{ Interval = TimeSpan.FromSeconds(delay) };
_chatFadeTimer.Tick += (_, _) =>
{
_chatFadeTimer?.Stop();
_chatFadeTimer = null;
ChatMessages.Clear();
foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox))
UpdateChatBoxPreview(chatBox);
StartMockChatPreview();
};
_chatFadeTimer.Start();
}
private void BeginGoLive()
{
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
@@ -2514,6 +2798,7 @@ public class MainViewModel : ViewModelBase
width,
height,
chatBox.ChatFontSize,
chatBox.ChatFontFamily,
chatBox.ChatFontColor,
chatBox.ChatBackgroundColor,
chatBox.ChatMaxMessages,
@@ -2523,6 +2808,97 @@ public class MainViewModel : ViewModelBase
chatBox.ChatSuperChatColor);
}
/// <summary>
/// Renders the ChatBox preview: real messages when connected, mock when not.
/// </summary>
private void UpdateChatBoxPreview(Source chatBox, IReadOnlyList<ChatMessage>? messages = null)
{
try
{
messages ??= ChatMessages.Count > 0 ? (IReadOnlyList<ChatMessage>)ChatMessages : MockChatMessages;
var width = (int)Math.Max(32, chatBox.Width);
var height = (int)Math.Max(32, chatBox.Height);
var frame = _chatBoxRenderer.Render(
messages,
width,
height,
chatBox.ChatFontSize,
chatBox.ChatFontFamily,
chatBox.ChatFontColor,
chatBox.ChatBackgroundColor,
chatBox.ChatMaxMessages,
chatBox.ChatShowTimestamps,
chatBox.ChatShowBadges,
chatBox.ChatMemberColor,
chatBox.ChatSuperChatColor);
var bitmap = new WriteableBitmap(width, height, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null);
bitmap.WritePixels(new System.Windows.Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0);
chatBox.VideoImageSource = bitmap;
}
catch (Exception ex) { AppLog.Write($"UpdateChatBoxPreview failed: {ex.Message}"); }
}
private CancellationTokenSource? _mockChatCts;
private async Task RunMockChatPreviewAsync(Source chatBox, CancellationToken ct)
{
var n = 0;
while (!ct.IsCancellationRequested)
{
UpdateChatBoxPreview(chatBox, MockChatMessages.Take(n + 1).ToList());
await Task.Delay(1000, ct);
n++;
if (n == MockChatMessages.Count)
{
await Task.Delay(3000, ct);
n = 0;
}
}
}
private void StartMockChatPreview()
{
StopMockChatPreview();
_mockChatCts = new CancellationTokenSource();
var ct = _mockChatCts.Token;
foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox && s.ChatPreviewEnabled))
_ = RunMockChatPreviewAsync(chatBox, ct);
}
private void StopMockChatPreview()
{
_mockChatCts?.Cancel();
_mockChatCts?.Dispose();
_mockChatCts = null;
}
/// <summary>
/// Render previews + wire property-change subscriptions for all ChatBox
/// sources across every scene. Called after layout load so existing ChatBox
/// sources show mock content and respond to config changes.
/// </summary>
private void UpdateActiveChatBoxPreviews()
{
foreach (var scene in Scenes)
foreach (var chatBox in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.ChatBox))
{
chatBox.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(chatBox);
};
UpdateChatBoxPreview(chatBox);
}
}
// The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
// OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
private CompositorOptions BuildCompositorOptions()