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()