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:
+416
-40
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user