diff --git a/MainWindow.xaml b/MainWindow.xaml index 5bf16be..cabdcc5 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -251,6 +251,8 @@ ToolTip="Timer overlay — Starting and BRB scenes only"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +/// Renders chat messages into a VideoFrame for the chat overlay on stream. +/// Uses WPF's FormattedText + RenderTargetBitmap to produce BGRA8 output. +/// +public sealed class ChatBoxRenderer +{ + private static readonly CultureInfo InvariantCulture = CultureInfo.InvariantCulture; + + /// + /// Render the last N messages into a VideoFrame at the specified dimensions. + /// Messages are drawn bottom-up (newest at bottom, oldest at top) with + /// configurable font, colors, and background. + /// + public VideoFrame Render( + IReadOnlyList messages, + int width, + int height, + double fontSize, + string fontColorHex, + string backgroundColorHex, + int maxMessages, + bool showTimestamps, + bool showBadges, + string memberColorHex, + string superChatColorHex) + { + var dpi = 96.0; + var visual = new DrawingVisual(); + + using (var ctx = visual.RenderOpen()) + { + // Background + var bgColor = ParseColor(backgroundColorHex); + ctx.DrawRectangle( + new SolidColorBrush(bgColor), + null, + new Rect(0, 0, width, height)); + + if (messages.Count == 0) + return CreateFrame(visual, width, height, dpi); + + // Layout parameters + var lineHeight = fontSize * 1.4; + var padding = 8.0; + var maxVisibleLines = (int)((height - padding * 2) / lineHeight); + if (maxVisibleLines < 1) maxVisibleLines = 1; + + // Take the last N messages that fit + var visibleMessages = new List<(ChatMessage msg, string text, Brush brush)>(); + var totalLines = 0; + + for (var i = messages.Count - 1; i >= 0 && visibleMessages.Count < maxMessages; i--) + { + var msg = messages[i]; + var brush = GetMessageBrush(msg, fontColorHex, memberColorHex, superChatColorHex); + var text = FormatMessage(msg, showTimestamps, showBadges); + var lineCount = EstimateLineCount(text, fontSize, width - padding * 2); + if (totalLines + lineCount > maxVisibleLines) break; + totalLines += lineCount; + visibleMessages.Add((msg, text, brush)); + } + + visibleMessages.Reverse(); + + // Draw messages bottom-up + var y = height - padding; + for (var i = visibleMessages.Count - 1; i >= 0; i--) + { + var (_, text, brush) = visibleMessages[i]; + var formatted = new FormattedText( + text, + InvariantCulture, + FlowDirection.LeftToRight, + new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal), + fontSize, + brush, + dpi); + + formatted.MaxTextWidth = width - padding * 2; + formatted.Trimming = TextTrimming.CharacterEllipsis; + + y -= formatted.Height; + ctx.DrawText(formatted, new Point(padding, y)); + } + } + + return CreateFrame(visual, width, height, dpi); + } + + private static string FormatMessage(ChatMessage msg, bool showTimestamps, bool showBadges) + { + var parts = new List(); + + if (showTimestamps) + parts.Add($"[{msg.Timestamp:HH:mm}]"); + + if (showBadges) + { + if (msg.IsSuperChat) + parts.Add("💰"); + else if (msg.IsMember) + parts.Add("⭐"); + } + + parts.Add($"{msg.AuthorName}: {msg.Message}"); + return string.Join(" ", parts); + } + + private static int EstimateLineCount(string text, double fontSize, double maxWidth) + { + // Rough estimate: ~12 chars per 100px at font size 16 + var charsPerLine = (int)(maxWidth / (fontSize * 0.6)); + if (charsPerLine < 1) charsPerLine = 1; + return Math.Max(1, (int)Math.Ceiling((double)text.Length / charsPerLine)); + } + + private static Brush GetMessageBrush(ChatMessage msg, string fontColorHex, string memberColorHex, string superChatColorHex) + { + if (msg.IsSuperChat) + return new SolidColorBrush(ParseColor(superChatColorHex)); + if (msg.IsMember) + return new SolidColorBrush(ParseColor(memberColorHex)); + return new SolidColorBrush(ParseColor(fontColorHex)); + } + + private static Color ParseColor(string hex) + { + hex = hex.Trim().TrimStart('#'); + if (hex.Length == 6) + { + var r = byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber); + var g = byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber); + var b = byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber); + return Color.FromRgb(r, g, b); + } + if (hex.Length == 8) + { + var a = byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber); + var r = byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber); + var g = byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber); + var b = byte.Parse(hex.AsSpan(6, 2), NumberStyles.HexNumber); + return Color.FromArgb(a, r, g, b); + } + return Colors.White; + } + + private static VideoFrame CreateFrame(DrawingVisual visual, int width, int height, double dpi) + { + var rtb = new RenderTargetBitmap(width, height, dpi, dpi, PixelFormats.Pbgra32); + rtb.Render(visual); + + var stride = width * 4; + var pixels = new byte[stride * height]; + rtb.CopyPixels(pixels, stride, 0); + + // Pbgra32 → Bgra8: swap R and B channels (Pbgra premultiplies alpha but + // for opaque backgrounds the swap is all we need) + for (var i = 0; i < pixels.Length; i += 4) + { + (pixels[i], pixels[i + 2]) = (pixels[i + 2], pixels[i]); + } + + return new VideoFrame(width, height, pixels); + } +} diff --git a/Services/LayoutStore.cs b/Services/LayoutStore.cs index f0c241d..ea186a5 100644 --- a/Services/LayoutStore.cs +++ b/Services/LayoutStore.cs @@ -211,6 +211,56 @@ public class LayoutStore : IDisposable cmd.CommandText = "ALTER TABLE Source ADD COLUMN CaptureKey TEXT;"; cmd.ExecuteNonQuery(); } + + // TASK 3.18: ChatBox overlay source + if (!columns.Contains("ChatFontSize")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontSize REAL NOT NULL DEFAULT 16;"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("ChatFontColor")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontColor TEXT NOT NULL DEFAULT '#FFFFFF';"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("ChatBackgroundColor")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatBackgroundColor TEXT NOT NULL DEFAULT '#80000000';"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("ChatMaxMessages")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMaxMessages INTEGER NOT NULL DEFAULT 5;"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("ChatShowTimestamps")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowTimestamps INTEGER NOT NULL DEFAULT 1;"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("ChatShowBadges")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowBadges INTEGER NOT NULL DEFAULT 1;"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("ChatMemberColor")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMemberColor TEXT NOT NULL DEFAULT '#e94560';"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("ChatSuperChatColor")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatSuperChatColor TEXT NOT NULL DEFAULT '#eab308';"; + cmd.ExecuteNonQuery(); + } } // v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect, @@ -510,7 +560,9 @@ 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, + ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages, + ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor FROM Source ORDER BY SortOrder """; using var reader = cmd.ExecuteReader(); @@ -534,6 +586,14 @@ 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), }; if (!sourcesByScene.TryGetValue(sceneId, out var list)) sourcesByScene[sceneId] = list = new List(); @@ -724,10 +784,14 @@ 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, 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, $sort, + $chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs, + $chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor) """; cmd.Transaction = tx; var idP = cmd.Parameters.Add("$id", SqliteType.Text); @@ -747,6 +811,14 @@ public class LayoutStore : IDisposable var isBackdropP = cmd.Parameters.Add("$isBackdrop", SqliteType.Integer); var captureKeyP = cmd.Parameters.Add("$captureKey", SqliteType.Text); var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer); + var chatFontSizeP = cmd.Parameters.Add("$chatFontSize", SqliteType.Real); + var chatFontColorP = cmd.Parameters.Add("$chatFontColor", SqliteType.Text); + var chatBgColorP = cmd.Parameters.Add("$chatBgColor", SqliteType.Text); + var chatMaxMsgsP = cmd.Parameters.Add("$chatMaxMsgs", SqliteType.Integer); + var chatTimestampsP = cmd.Parameters.Add("$chatTimestamps", SqliteType.Integer); + var chatBadgesP = cmd.Parameters.Add("$chatBadges", SqliteType.Integer); + var chatMemberColorP = cmd.Parameters.Add("$chatMemberColor", SqliteType.Text); + var chatSuperChatColorP = cmd.Parameters.Add("$chatSuperChatColor", SqliteType.Text); foreach (var scene in scenes) { @@ -771,6 +843,14 @@ public class LayoutStore : IDisposable isBackdropP.Value = source.IsBackdrop ? 1 : 0; captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value; sortP.Value = sort++; + chatFontSizeP.Value = source.ChatFontSize; + chatFontColorP.Value = source.ChatFontColor; + chatBgColorP.Value = source.ChatBackgroundColor; + chatMaxMsgsP.Value = source.ChatMaxMessages; + chatTimestampsP.Value = source.ChatShowTimestamps ? 1 : 0; + chatBadgesP.Value = source.ChatShowBadges ? 1 : 0; + chatMemberColorP.Value = source.ChatMemberColor; + chatSuperChatColorP.Value = source.ChatSuperChatColor; cmd.ExecuteNonQuery(); } } diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 1b766b9..d9a38f9 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -126,6 +126,7 @@ public class MainViewModel : ViewModelBase private readonly IFullScreenDetector _fullScreenDetector; private readonly ScreenCaptureManager _screenCaptureManager; private readonly ScreenCaptureSourceFactory _screenCaptureFactory; + private readonly ChatBoxRenderer _chatBoxRenderer; private int? _lastForegroundFullScreenMonitor; private CancellationTokenSource? _deactivateCts; private ImageSource? _backdropImage; @@ -270,7 +271,7 @@ public class MainViewModel : ViewModelBase /// True when the Elements panel should show the "No configurable elements" hint. public bool IsElementsPanelEmpty => SelectedElement is { } el && !el.IsDraggable && !el.IsWebcam - && el is not Source { Type: SourceType.Countdown or SourceType.WebSource }; + && el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox }; /// /// The reason the webcam feed is down (device in use, offline, locked, no frames), @@ -1267,6 +1268,8 @@ public class MainViewModel : ViewModelBase _screenCaptureManager.CaptureFailed += (key, message) => AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}"); + _chatBoxRenderer = new ChatBoxRenderer(); + _rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL _framePump = new FramePump( sceneProvider: () => ActiveScene, @@ -1761,6 +1764,7 @@ public class MainViewModel : ViewModelBase SourceType.TextOverlay => "Text", SourceType.Countdown => "Countdown", SourceType.WebSource => "Web", + SourceType.ChatBox => "Chat", _ => "Source", }; @@ -2414,11 +2418,33 @@ public class MainViewModel : ViewModelBase { WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(webcam.WebcamId), Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey), + Source { Type: SourceType.ChatBox } chatBox => RenderChatBox(chatBox), Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId), _ => null, }; } + private VideoFrame? RenderChatBox(Source chatBox) + { + if (ChatMessages.Count == 0) return null; + + var width = (int)Math.Max(1, chatBox.Width); + var height = (int)Math.Max(1, chatBox.Height); + + return _chatBoxRenderer.Render( + ChatMessages, + width, + height, + chatBox.ChatFontSize, + chatBox.ChatFontColor, + chatBox.ChatBackgroundColor, + chatBox.ChatMaxMessages, + chatBox.ChatShowTimestamps, + chatBox.ChatShowBadges, + chatBox.ChatMemberColor, + chatBox.ChatSuperChatColor); + } + // 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()