using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; using ytLive.Models; namespace ytLive.Services.Compositor; /// How the scoreboard strip is painted. Same knobs as the social bar, because the creator /// asked for the same controls in the same place (2026-10-30). public readonly record struct ScoreboardStyle( string TextColor = "#FFFFFF", bool BackgroundEnabled = true, string BackgroundColor = "#000000", double BackgroundOpacity = 0.20, double FontSize = ScoreboardLayout.MaxFontSize, double SeparatorOpacity = 0.45) { /// A record STRUCT's implicit parameterless constructor zero-initialises — it does NOT /// run the parameter defaults. Without this, `new ScoreboardStyle()` produced null text colour, /// no background and zero opacity: every "default" in this file was a lie, and the plate/opacity /// controls looked dead because the renderer was faithfully drawing what it was given. public ScoreboardStyle() : this("#FFFFFF", true, "#000000", 0.20, ScoreboardLayout.MaxFontSize, 0.45) { } } /// Rasterises the scoreboard strip (one line, shrinking/wrapping via /// ) into a straight-alpha BGRA frame the compositor blits. /// /// Static by design: the strip holds state, not events — everything enabled is always /// visible, nothing waits its turn. It therefore rasterises only when a value changes, which is why /// the renderer takes plain strings and the caller keeps the cache. public static class ScoreboardRenderer { public const int Width = 1920; private const double PaddingX = 24; private const double PaddingY = 8; private const double SeparatorGap = 26; /// Builds the SCROLLING run: the strip's line plus a gap, at its natural width, so the /// publisher can crop a 1920-wide window that moves. One raster per value change, never per /// tick — the tick is a row-wise byte copy. public static VideoFrame? RenderRun(IReadOnlyList? tiles, ScoreboardStyle style, double gapPx = 240) { var texts = tiles?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).ToList(); if (texts is not { Count: > 0 }) return null; var typeface = new Typeface(OutputPalette.Sans, FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal); var size = Math.Clamp(style.FontSize, ScoreboardLayout.FloorFontSize, ScoreboardLayout.MaxFontSize); var textBrush = Brush(style.TextColor, 1.0, Colors.White); var separator = Brush(style.TextColor, Math.Clamp(style.SeparatorOpacity, 0, 1), Colors.Gray); var measured = texts.Select(t => Text(t, typeface, size, textBrush)).ToList(); var lineWidth = measured.Sum(t => t.WidthIncludingTrailingWhitespace) + SeparatorGap * (texts.Count - 1); var height = (int)Math.Ceiling(size * 1.45 + PaddingY * 2); var width = (int)Math.Ceiling(lineWidth + gapPx); var plate = Brush(style.BackgroundColor, Math.Clamp(style.BackgroundOpacity, 0, 1), Colors.Black); var visual = new DrawingVisual(); using (var dc = visual.RenderOpen()) { if (style.BackgroundEnabled) dc.DrawRectangle(plate, null, new Rect(0, 0, width, height)); var x = 0.0; for (var i = 0; i < measured.Count; i++) { dc.DrawText(measured[i], new Point(x, (height - size) / 2)); x += measured[i].WidthIncludingTrailingWhitespace; if (i == measured.Count - 1) continue; var dot = Text("·", typeface, size, separator); dc.DrawText(dot, new Point(x + (SeparatorGap - dot.WidthIncludingTrailingWhitespace) / 2, (height - size) / 2)); x += SeparatorGap; } } return Rasterise(visual, width, height); } /// Crops a -wide window out of the run at /// , wrapping. Straight-alpha rows, no scaling. public static VideoFrame Crop(VideoFrame run, int offsetPx, int width) { var height = run.Height; var pixels = new byte[width * height * 4]; var runStride = run.Width * 4; var start = ((offsetPx % run.Width) + run.Width) % run.Width; for (var y = 0; y < height; y++) { var srcRow = y * runStride; var dstRow = y * width * 4; for (var x = 0; x < width; x++) { var sx = (start + x) % run.Width; var src = srcRow + sx * 4; var dst = dstRow + x * 4; pixels[dst] = run.BgraPixels[src]; pixels[dst + 1] = run.BgraPixels[src + 1]; pixels[dst + 2] = run.BgraPixels[src + 2]; pixels[dst + 3] = run.BgraPixels[src + 3]; } } return new VideoFrame(width, height, pixels); } public static VideoFrame? Render(IReadOnlyList? tiles, ScoreboardStyle style) { var texts = tiles?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).ToList(); if (texts is not { Count: > 0 }) return null; var typeface = new Typeface(OutputPalette.Sans, FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal); var requested = Math.Clamp(style.FontSize, ScoreboardLayout.FloorFontSize, ScoreboardLayout.MaxFontSize); double Measure(string text, double size) => new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, size, Brushes.White, pixelsPerDip: 1.0).WidthIncludingTrailingWhitespace; var widths = texts.Select(t => Measure(t, requested)).ToList(); var fit = ScoreboardLayout.FitLine(widths, SeparatorGap, Width - PaddingX * 2, requested); // Re-measure at the fitted size so line breaking and drawing use the same numbers. var size = fit.FontSize; var lines = BuildLines(texts, texts.Select(t => Measure(t, size)).ToList(), Width - PaddingX * 2); var lineHeight = size * 1.45; var height = (int)Math.Ceiling(lines.Count * lineHeight + PaddingY * 2); var textBrush = Brush(style.TextColor, 1.0, Colors.White); var plate = Brush(style.BackgroundColor, Math.Clamp(style.BackgroundOpacity, 0, 1), Colors.Black); var separator = Brush(style.TextColor, Math.Clamp(style.SeparatorOpacity, 0, 1), Colors.Gray); var visual = new DrawingVisual(); using (var dc = visual.RenderOpen()) { if (style.BackgroundEnabled) dc.DrawRoundedRectangle(plate, null, new Rect(0, 0, Width, height), height / 2.0, height / 2.0); for (var line = 0; line < lines.Count; line++) { var lineTiles = lines[line]; var totalWidth = lineTiles.Sum(t => Measure(t, size)) + SeparatorGap * (lineTiles.Count - 1); var x = (Width - totalWidth) / 2; var y = PaddingY + line * lineHeight + (lineHeight - size) / 2; for (var i = 0; i < lineTiles.Count; i++) { var text = Text(lineTiles[i], typeface, size, textBrush); dc.DrawText(text, new Point(x, y)); x += text.WidthIncludingTrailingWhitespace; if (i == lineTiles.Count - 1) continue; var dot = Text("·", typeface, size, separator); dc.DrawText(dot, new Point(x + (SeparatorGap - dot.WidthIncludingTrailingWhitespace) / 2, y)); x += SeparatorGap; } } } return Rasterise(visual, Width, height); } /// Draws the visual into a straight-alpha BGRA frame (unpremultiplied, because the /// compositor blends straight alpha). private static VideoFrame Rasterise(DrawingVisual visual, int width, int height) { var bitmap = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); bitmap.Render(visual); var pixels = new byte[width * height * 4]; bitmap.CopyPixels(pixels, width * 4, 0); for (var i = 0; i < pixels.Length; i += 4) { var a = pixels[i + 3]; if (a == 0 || a == 255) continue; pixels[i] = (byte)(pixels[i] * 255 / a); pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a); pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a); } return new VideoFrame(width, height, pixels); } /// Greedy line packing at the fitted size — mirrors /// so the break points agree with the fit the caller was told about. private static List> BuildLines(IReadOnlyList tiles, IReadOnlyList widths, double available) { var lines = new List> { new() }; var lineWidth = 0.0; for (var i = 0; i < tiles.Count; i++) { var w = widths[i]; var needed = lineWidth == 0 ? w : lineWidth + SeparatorGap + w; if (needed > available && lineWidth > 0) { lines.Add(new List()); lineWidth = w; } else { lineWidth = needed; } lines[^1].Add(tiles[i]); } return lines; } private static FormattedText Text(string text, Typeface face, double size, Brush brush) => new(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, face, size, brush, pixelsPerDip: 1.0); private static SolidColorBrush Brush(string hex, double opacity, Color fallback) { Color colour; try { colour = (Color)ColorConverter.ConvertFromString(hex); } catch (Exception) { colour = fallback; } var brush = new SolidColorBrush(Color.FromArgb((byte)Math.Round(opacity * 255), colour.R, colour.G, colour.B)); brush.Freeze(); return brush; } }