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
+2 -1
View File
@@ -24,6 +24,7 @@ public sealed class ChatBoxRenderer
int width,
int height,
double fontSize,
string fontFamily,
string fontColorHex,
string backgroundColorHex,
int maxMessages,
@@ -79,7 +80,7 @@ public sealed class ChatBoxRenderer
text,
InvariantCulture,
FlowDirection.LeftToRight,
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal),
new Typeface(new FontFamily(fontFamily), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal),
fontSize,
brush,
dpi);
+13
View File
@@ -18,4 +18,17 @@ public interface IFullScreenDetector
/// <summary>All monitors in capture-key order (enumeration order).</summary>
IReadOnlyList<DisplayInfo> GetDisplays();
/// <summary>
/// Raised on the UI thread whenever the foreground window changes.
/// Payload is the full-screen monitor index, or null when the new
/// foreground window is not full-screen / is one of our own windows.
/// </summary>
event Action<int?>? FullscreenMonitorChanged;
/// <summary>Hook EVENT_SYSTEM_FOREGROUND so <see cref="FullscreenMonitorChanged"/> fires.</summary>
void StartWatching();
/// <summary>Unhook the foreground listener.</summary>
void StopWatching();
}
+20 -11
View File
@@ -261,6 +261,12 @@ public class LayoutStore : IDisposable
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatSuperChatColor TEXT NOT NULL DEFAULT '#eab308';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ShowDesktop"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ShowDesktop INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
}
// v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
@@ -560,7 +566,7 @@ public class LayoutStore : IDisposable
cmd.CommandText = """
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
IsBackdrop, CaptureKey,
IsBackdrop, CaptureKey, ShowDesktop,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor
FROM Source ORDER BY SortOrder
@@ -586,14 +592,15 @@ public class LayoutStore : IDisposable
IsMirrored = reader.GetInt32(13) != 0,
IsBackdrop = reader.GetInt32(14) != 0,
CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
ChatFontSize = reader.IsDBNull(16) ? 16 : reader.GetDouble(16),
ChatFontColor = reader.IsDBNull(17) ? "#FFFFFF" : reader.GetString(17),
ChatBackgroundColor = reader.IsDBNull(18) ? "#80000000" : reader.GetString(18),
ChatMaxMessages = reader.IsDBNull(19) ? 5 : reader.GetInt32(19),
ChatShowTimestamps = reader.IsDBNull(20) ? true : reader.GetInt32(20) != 0,
ChatShowBadges = reader.IsDBNull(21) ? true : reader.GetInt32(21) != 0,
ChatMemberColor = reader.IsDBNull(22) ? "#e94560" : reader.GetString(22),
ChatSuperChatColor = reader.IsDBNull(23) ? "#eab308" : reader.GetString(23),
ShowDesktop = reader.GetInt32(16) != 0,
ChatFontSize = reader.IsDBNull(17) ? 16 : reader.GetDouble(17),
ChatFontColor = reader.IsDBNull(18) ? "#FFFFFF" : reader.GetString(18),
ChatBackgroundColor = reader.IsDBNull(19) ? "#80000000" : reader.GetString(19),
ChatMaxMessages = reader.IsDBNull(20) ? 5 : reader.GetInt32(20),
ChatShowTimestamps = reader.IsDBNull(21) ? true : reader.GetInt32(21) != 0,
ChatShowBadges = reader.IsDBNull(22) ? true : reader.GetInt32(22) != 0,
ChatMemberColor = reader.IsDBNull(23) ? "#e94560" : reader.GetString(23),
ChatSuperChatColor = reader.IsDBNull(24) ? "#eab308" : reader.GetString(24),
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<Source>();
@@ -784,12 +791,12 @@ public class LayoutStore : IDisposable
cmd.CommandText = """
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
X, Y, Width, Height, Opacity, MonitorIndex,
ClipShape, IsMirrored, IsBackdrop, CaptureKey, SortOrder,
ClipShape, IsMirrored, IsBackdrop, CaptureKey, ShowDesktop, SortOrder,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackdrop, $captureKey, $sort,
$clip, $mirrored, $isBackdrop, $captureKey, $showDesktop, $sort,
$chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs,
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor)
""";
@@ -810,6 +817,7 @@ public class LayoutStore : IDisposable
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
var isBackdropP = cmd.Parameters.Add("$isBackdrop", SqliteType.Integer);
var captureKeyP = cmd.Parameters.Add("$captureKey", SqliteType.Text);
var showDesktopP = cmd.Parameters.Add("$showDesktop", SqliteType.Integer);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
var chatFontSizeP = cmd.Parameters.Add("$chatFontSize", SqliteType.Real);
var chatFontColorP = cmd.Parameters.Add("$chatFontColor", SqliteType.Text);
@@ -842,6 +850,7 @@ public class LayoutStore : IDisposable
mirroredP.Value = source.IsMirrored ? 1 : 0;
isBackdropP.Value = source.IsBackdrop ? 1 : 0;
captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
showDesktopP.Value = source.ShowDesktop ? 1 : 0;
sortP.Value = sort++;
chatFontSizeP.Value = source.ChatFontSize;
chatFontColorP.Value = source.ChatFontColor;
+99
View File
@@ -26,6 +26,49 @@ public sealed class Win32FullScreenDetector : IFullScreenDetector
private const int DwmwaExtendedFrameBounds = 9;
private const uint MonitorDefaultToNearest = 2;
private const uint MonitorInfoFPrimary = 0x00000001;
private const uint EventSystemForeground = 0x0003;
private const uint WINEVENTOutofContext = 0x0000;
private const int GWL_EXSTYLE = -20;
private IntPtr _hookHandle;
private WinEventProc? _hookProc;
private int? _lastReportedMonitor;
public event Action<int?>? FullscreenMonitorChanged;
public void StartWatching()
{
if (_hookHandle != IntPtr.Zero) return;
_hookProc = ForegroundHookCallback;
_hookHandle = SetWinEventHook(EventSystemForeground, EventSystemForeground,
IntPtr.Zero, _hookProc, 0, 0, WINEVENTOutofContext);
var existing = FindAnyFullscreenGameMonitor();
if (existing != null)
{
_lastReportedMonitor = existing;
FullscreenMonitorChanged?.Invoke(existing);
}
}
public void StopWatching()
{
if (_hookHandle == IntPtr.Zero) return;
UnhookWinEvent(_hookHandle);
_hookHandle = IntPtr.Zero;
_hookProc = null;
_lastReportedMonitor = null;
}
private void ForegroundHookCallback(IntPtr hWinEventHook, uint eventType,
IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime)
{
if (hwnd == IntPtr.Zero || IsOwnWindow(hwnd)) return;
var monitor = GetForegroundFullScreenMonitorIndex();
if (monitor == _lastReportedMonitor) return;
_lastReportedMonitor = monitor;
FullscreenMonitorChanged?.Invoke(monitor);
}
public int? GetForegroundFullScreenMonitorIndex()
{
@@ -69,6 +112,43 @@ public sealed class Win32FullScreenDetector : IFullScreenDetector
return index >= 0 ? index : null;
}
/// <summary>
/// Scans every visible top-level window (not just the foreground one) for
/// full-screen state. Used at startup to catch a game already running on
/// another monitor before the hook has a chance to fire.
/// </summary>
private int? FindAnyFullscreenGameMonitor()
{
int? found = null;
EnumWindows((hwnd, _) =>
{
if (found != null) return false;
if (hwnd == IntPtr.Zero || IsOwnWindow(hwnd)) return true;
if (!IsWindowVisible(hwnd)) return true;
if (!HasAppWindowStyle(hwnd)) return true;
if (!TryGetExtendedFrameBounds(hwnd, out var bounds)) return true;
var monitor = MonitorFromWindow(hwnd, MonitorDefaultToNearest);
if (monitor == IntPtr.Zero || !TryGetMonitorInfo(monitor, out var info)) return true;
var r = info.RcMonitor;
if (bounds.Left <= r.Left && bounds.Top <= r.Top &&
bounds.Right >= r.Right && bounds.Bottom >= r.Bottom)
found = MonitorIndex(monitor);
return found == null;
}, IntPtr.Zero);
return found;
}
private static bool HasAppWindowStyle(IntPtr hwnd)
{
const uint WS_EX_APPWINDOW = 0x00040000;
const uint WS_EX_TOOLWINDOW = 0x00000080;
var exStyle = (uint)GetWindowLongPtr(hwnd, GWL_EXSTYLE);
return (exStyle & WS_EX_TOOLWINDOW) == 0 &&
(exStyle & WS_EX_APPWINDOW) != 0;
}
public int PrimaryMonitorIndex()
=> GetDisplays().FirstOrDefault(d => d.IsPrimary)?.Index ?? 0;
@@ -191,4 +271,23 @@ public sealed class Win32FullScreenDetector : IFullScreenDetector
private static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, ref Win32Rect pvAttribute, int cbAttribute);
private delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdcMonitor, ref Win32Rect lprcMonitor, IntPtr dwData);
private delegate void WinEventProc(IntPtr hWinEventHook, uint eventType, IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime);
[DllImport("user32.dll")]
private static extern IntPtr SetWinEventHook(uint eventMin, uint eventMax, IntPtr hmodWinEventProc, WinEventProc pfnWinEventProc, uint idProcess, uint idThread, uint dwFlags);
[DllImport("user32.dll")]
private static extern bool UnhookWinEvent(IntPtr hWinEventHook);
private delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool IsWindowVisible(IntPtr hwnd);
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")]
private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);
}