In the Socials drawer, under the show/hide switch: text colour, an optional background plate with its own colour and opacity, and a text drop shadow. The bar had been hard-wired white-on-transparent since it shipped. - `SocialsConfig` gains five additive properties (BarTextColor, BarBackgroundEnabled, BarBackgroundColor, BarBackgroundOpacity, BarTextShadow). - Persistence: five new Socials columns, added by the per-column idempotent migration pattern (a half-migrated DB heals instead of failing), written in Save and read back in Load with defaults for pre-existing rows. - `SocialBarRenderer.Render(entries, SocialBarStyle?)` — the look is a separate value type so the renderer stays a pure "entries + look" function and the preview can never read a half-applied config mid-edit. A malformed hex falls back to white/black instead of throwing inside a render tick. - The plate hugs the entries (rounded 20px, padding) so it reads as a caption bar, not a band across the frame. Off by default, because transparent is what creators have today. - The colour pickers reuse `BorderColorPalette` — one catalogue of the app's colours, per the TASK 60/63 rule. Guard: SocialBarAppearanceTests paints the real strip and inspects pixels — default is white with no plate, a red text colour reaches the glyphs, a 55% blue plate yields half-transparent blue pixels, and the shadow adds dark pixels that were not there before.
174 lines
6.8 KiB
C#
174 lines
6.8 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Effects;
|
|
using System.Windows.Media.Imaging;
|
|
using System.Windows.Shapes;
|
|
using ytLive.Models;
|
|
|
|
namespace ytLive.Services.Compositor;
|
|
|
|
/// <summary>
|
|
/// Rasterizes the global social bar into a transparent BGRA8 strip the output
|
|
/// compositor overlays onto the master frame (top or bottom edge). Replicates the
|
|
/// preview's bar DataTemplate in code — one Path (logo) + one TextBlock (handle)
|
|
/// per entry, white on transparent, green glow — then software-renders it via
|
|
/// <c>RenderTargetBitmap</c> and converts Pbgra32 (premultiplied) to straight-alpha
|
|
/// BGRA for the compositor. WPF glue like <see cref="StaticPixelCache"/>: the
|
|
/// compositor core itself stays pure byte-math. Renders on the UI thread only;
|
|
/// the returned frame is immutable afterwards, so the frame pump may read it from
|
|
/// any thread.
|
|
/// </summary>
|
|
public static class SocialBarRenderer
|
|
{
|
|
/// <summary>Icon size + the preview's 8px top/bottom margins.</summary>
|
|
private const int ContentHeight = 40;
|
|
|
|
/// <summary>Room for the green DropShadowEffect blur to bleed past the content.</summary>
|
|
private const int GlowPad = 24;
|
|
|
|
/// <summary>Master-frame width — the bar spans the full 1920px output.</summary>
|
|
public const int Width = 1920;
|
|
|
|
/// <summary>Returns the strip frame, or null when there is nothing to render.</summary>
|
|
public static VideoFrame? Render(IEnumerable<SocialEntry>? entries, SocialBarStyle? style = null)
|
|
{
|
|
var look = style ?? SocialBarStyle.Default;
|
|
var textBrush = look.TextBrush;
|
|
var glyphEffect = look.TextShadow ? ShadowEffect() : null;
|
|
var list = entries?.Where(e => e != null).ToList();
|
|
if (list == null || list.Count == 0) return null;
|
|
|
|
var height = ContentHeight + GlowPad * 2;
|
|
|
|
var row = new StackPanel { Orientation = Orientation.Horizontal };
|
|
foreach (var entry in list)
|
|
{
|
|
var item = new StackPanel
|
|
{
|
|
Orientation = Orientation.Horizontal,
|
|
Margin = new Thickness(0, 0, 20, 0),
|
|
};
|
|
item.Children.Add(new Path
|
|
{
|
|
Data = Geometry.Parse(entry.LogoData),
|
|
Fill = textBrush,
|
|
Effect = glyphEffect,
|
|
Width = 24,
|
|
Height = 24,
|
|
Stretch = Stretch.Uniform,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
});
|
|
item.Children.Add(new TextBlock
|
|
{
|
|
Text = entry.Handle,
|
|
Foreground = textBrush,
|
|
Effect = glyphEffect,
|
|
FontSize = 14,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
Margin = new Thickness(8, 0, 0, 0),
|
|
});
|
|
row.Children.Add(item);
|
|
}
|
|
|
|
// The glow is baked in so the output bar matches the preview's green halo.
|
|
var container = new Grid
|
|
{
|
|
Width = Width,
|
|
Height = height,
|
|
Background = Brushes.Transparent,
|
|
Effect = new DropShadowEffect { Color = Color.FromRgb(0x2e, 0xcc, 0x71), BlurRadius = 18, ShadowDepth = 0, Opacity = 0.9 },
|
|
};
|
|
var centered = new Grid { HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, GlowPad, 0, 0) };
|
|
if (look.BackgroundEnabled)
|
|
{
|
|
// The plate hugs the entries, so it reads as a caption bar rather than a band across
|
|
// the frame; 20px radius matches the pill language used elsewhere.
|
|
centered.Children.Add(new Border
|
|
{
|
|
Background = look.BackgroundBrush,
|
|
CornerRadius = new CornerRadius(20),
|
|
Padding = new Thickness(18, 6, 18, 6),
|
|
Child = row,
|
|
});
|
|
}
|
|
else
|
|
{
|
|
centered.Children.Add(row);
|
|
}
|
|
container.Children.Add(centered);
|
|
|
|
container.Measure(new Size(Width, height));
|
|
container.Arrange(new Rect(0, 0, Width, height));
|
|
|
|
var bitmap = new RenderTargetBitmap(Width, height, 96, 96, PixelFormats.Pbgra32);
|
|
bitmap.Render(container);
|
|
|
|
var pixels = new byte[Width * height * 4];
|
|
bitmap.CopyPixels(pixels, Width * 4, 0);
|
|
|
|
// Pbgra32 is premultiplied — unpremultiply so the compositor's straight-alpha
|
|
// source-over blend doesn't darken the logo edges with a halo.
|
|
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);
|
|
}
|
|
|
|
private static DropShadowEffect ShadowEffect() => new()
|
|
{
|
|
Color = Colors.Black,
|
|
BlurRadius = 4,
|
|
ShadowDepth = 2,
|
|
Direction = 315,
|
|
Opacity = 0.85,
|
|
};
|
|
}
|
|
|
|
/// <summary>How the social bar is painted (creator request 2026-10-30: text colour, an optional
|
|
/// plate with its own colour and opacity, and a text drop shadow).
|
|
///
|
|
/// <para>It is a separate value type, not <see cref="SocialsConfig"/>, so the renderer stays a
|
|
/// pure function of "entries + look" and the preview/output can never read a half-applied
|
|
/// config mid-edit.</para></summary>
|
|
public readonly record struct SocialBarStyle(
|
|
string TextColor,
|
|
bool BackgroundEnabled,
|
|
string BackgroundColor,
|
|
double BackgroundOpacity,
|
|
bool TextShadow)
|
|
{
|
|
public static SocialBarStyle Default => new("#FFFFFF", false, "#000000", 0.55, false);
|
|
|
|
public static SocialBarStyle From(SocialsConfig? config) => config is null
|
|
? Default
|
|
: new(config.BarTextColor, config.BarBackgroundEnabled, config.BarBackgroundColor,
|
|
config.BarBackgroundOpacity, config.BarTextShadow);
|
|
|
|
/// <summary>Glyph colour. A malformed hex falls back to white rather than throwing inside a
|
|
/// render tick — a bad colour must never kill the output.</summary>
|
|
public SolidColorBrush TextBrush => Solid(TextColor, 1.0, Colors.White);
|
|
|
|
/// <summary>Plate colour carrying the configured opacity in its alpha.</summary>
|
|
public SolidColorBrush BackgroundBrush
|
|
=> Solid(BackgroundColor, Math.Clamp(BackgroundOpacity, 0, 1), Colors.Black);
|
|
|
|
private static SolidColorBrush Solid(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;
|
|
}
|
|
}
|