feat(chat-overlay): add ChatBox config UI + schema persistence
- Elements panel config UI for ChatBox source (font size slider, font/member/super-chat/background color inputs, max messages slider, timestamp/badge toggles) - Schema migration: 8 new ChatBox columns on Source table (ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages, ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor) - Load/save: Source SELECT and INSERT now include all ChatBox fields - IsElementsPanelEmpty excludes ChatBox so hint doesn't cover config - ChatBoxRenderer.cs created: WPF FormattedText + RenderTargetBitmap renderer for on-stream chat overlay - Slider bindings set to TwoWay for live propagation
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Renders chat messages into a VideoFrame for the chat overlay on stream.
|
||||
/// Uses WPF's FormattedText + RenderTargetBitmap to produce BGRA8 output.
|
||||
/// </summary>
|
||||
public sealed class ChatBoxRenderer
|
||||
{
|
||||
private static readonly CultureInfo InvariantCulture = CultureInfo.InvariantCulture;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public VideoFrame Render(
|
||||
IReadOnlyList<ChatMessage> 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<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user