Files
gramps 0bd17ab428 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
2026-08-20 18:02:41 -07:00

175 lines
6.2 KiB
C#

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 fontFamily,
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(fontFamily), 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);
}
}