From c82636e1a9a010fc5ecc95b6ec42b1c9ac206d98 Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 30 Oct 2026 08:47:50 -0700 Subject: [PATCH] =?UTF-8?q?feat(socials):=20bar=20appearance=20=E2=80=94?= =?UTF-8?q?=20text=20colour,=20background=20plate,=20opacity,=20text=20sha?= =?UTF-8?q?dow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Models/Socials.cs | 44 +++++++++++++ Services/Compositor/SocialBarRenderer.cs | 76 +++++++++++++++++++++-- Services/LayoutStore.Load.cs | 11 +++- Services/LayoutStore.Migrations.cs | 20 ++++-- Services/LayoutStore.Save.cs | 11 +++- SocialsDialog.xaml | 47 ++++++++++++-- ViewModels/MainViewModel.Socials.cs | 2 +- ViewModels/SocialsDialogViewModel.cs | 56 ++++++++++++++++- ytLive.Tests/SocialBarAppearanceTests.cs | 78 ++++++++++++++++++++++++ 9 files changed, 329 insertions(+), 16 deletions(-) create mode 100644 ytLive.Tests/SocialBarAppearanceTests.cs diff --git a/Models/Socials.cs b/Models/Socials.cs index 1608bc5..a244f4b 100644 --- a/Models/Socials.cs +++ b/Models/Socials.cs @@ -70,6 +70,50 @@ public sealed class SocialsConfig : INotifyPropertyChanged set => Set(ref _barPosition, value); } + // ─── Bar appearance (creator request 2026-10-30) ─────────────────────────── + // The bar used to be hard-wired white-on-transparent. These five knobs are the whole + // appearance surface: text colour, an optional plate behind it, and a text shadow. + + private string _barTextColor = "#FFFFFF"; + /// Text and logo colour of the bar (hex, straight alpha). + public string BarTextColor + { + get => _barTextColor; + set => Set(ref _barTextColor, string.IsNullOrWhiteSpace(value) ? "#FFFFFF" : value); + } + + private bool _barBackgroundEnabled; + /// Draw a rounded plate behind the entries. Off by default — the bar shipped + /// transparent and most creators keep it that way. + public bool BarBackgroundEnabled + { + get => _barBackgroundEnabled; + set => Set(ref _barBackgroundEnabled, value); + } + + private string _barBackgroundColor = "#000000"; + public string BarBackgroundColor + { + get => _barBackgroundColor; + set => Set(ref _barBackgroundColor, string.IsNullOrWhiteSpace(value) ? "#000000" : value); + } + + private double _barBackgroundOpacity = 0.55; + /// 0 = invisible plate, 1 = solid. Only meaningful when the plate is on. + public double BarBackgroundOpacity + { + get => _barBackgroundOpacity; + set => Set(ref _barBackgroundOpacity, Math.Clamp(value, 0, 1)); + } + + private bool _barTextShadow; + /// A soft black drop shadow behind the glyphs, for a bright or busy scene. + public bool BarTextShadow + { + get => _barTextShadow; + set => Set(ref _barTextShadow, value); + } + public event PropertyChangedEventHandler? PropertyChanged; private bool Set(ref T field, T value, [CallerMemberName] string? name = null) { diff --git a/Services/Compositor/SocialBarRenderer.cs b/Services/Compositor/SocialBarRenderer.cs index fa01af7..ede19f6 100644 --- a/Services/Compositor/SocialBarRenderer.cs +++ b/Services/Compositor/SocialBarRenderer.cs @@ -33,8 +33,11 @@ public static class SocialBarRenderer public const int Width = 1920; /// Returns the strip frame, or null when there is nothing to render. - public static VideoFrame? Render(IEnumerable? entries) + public static VideoFrame? Render(IEnumerable? 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; @@ -51,7 +54,8 @@ public static class SocialBarRenderer item.Children.Add(new Path { Data = Geometry.Parse(entry.LogoData), - Fill = Brushes.White, + Fill = textBrush, + Effect = glyphEffect, Width = 24, Height = 24, Stretch = Stretch.Uniform, @@ -60,7 +64,8 @@ public static class SocialBarRenderer item.Children.Add(new TextBlock { Text = entry.Handle, - Foreground = Brushes.White, + Foreground = textBrush, + Effect = glyphEffect, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(8, 0, 0, 0), @@ -77,7 +82,22 @@ public static class SocialBarRenderer 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) }; - centered.Children.Add(row); + 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)); @@ -102,4 +122,52 @@ public static class SocialBarRenderer return new VideoFrame(Width, height, pixels); } + + private static DropShadowEffect ShadowEffect() => new() + { + Color = Colors.Black, + BlurRadius = 4, + ShadowDepth = 2, + Direction = 315, + Opacity = 0.85, + }; +} + +/// 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). +/// +/// It is a separate value type, not , so the renderer stays a +/// pure function of "entries + look" and the preview/output can never read a half-applied +/// config mid-edit. +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); + + /// Glyph colour. A malformed hex falls back to white rather than throwing inside a + /// render tick — a bad colour must never kill the output. + public SolidColorBrush TextBrush => Solid(TextColor, 1.0, Colors.White); + + /// Plate colour carrying the configured opacity in its alpha. + 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; + } } diff --git a/Services/LayoutStore.Load.cs b/Services/LayoutStore.Load.cs index 21de2c5..5083d6c 100644 --- a/Services/LayoutStore.Load.cs +++ b/Services/LayoutStore.Load.cs @@ -50,7 +50,11 @@ public partial class LayoutStore : IDisposable using (var cmd = _connection.CreateCommand()) { - cmd.CommandText = "SELECT Id, BarPosition, BarEnabled FROM Socials LIMIT 1;"; + cmd.CommandText = """ + SELECT Id, BarPosition, BarEnabled, TextColor, BackgroundEnabled, + BackgroundColor, BackgroundOpacity, TextShadow + FROM Socials LIMIT 1; + """; using var reader = cmd.ExecuteReader(); if (reader.Read()) { @@ -58,6 +62,11 @@ public partial class LayoutStore : IDisposable { BarPosition = Enum.TryParse(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom, BarEnabled = reader.FieldCount > 2 && !reader.IsDBNull(2) && reader.GetInt32(2) != 0, + BarTextColor = reader.FieldCount > 3 && !reader.IsDBNull(3) ? reader.GetString(3) : "#FFFFFF", + BarBackgroundEnabled = reader.FieldCount > 4 && !reader.IsDBNull(4) && reader.GetInt32(4) != 0, + BarBackgroundColor = reader.FieldCount > 5 && !reader.IsDBNull(5) ? reader.GetString(5) : "#000000", + BarBackgroundOpacity = reader.FieldCount > 6 && !reader.IsDBNull(6) ? reader.GetDouble(6) : 0.55, + BarTextShadow = reader.FieldCount > 7 && !reader.IsDBNull(7) && reader.GetInt32(7) != 0, }; var socialsId = reader.GetString(0); diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs index 22b91c0..6ef9626 100644 --- a/Services/LayoutStore.Migrations.cs +++ b/Services/LayoutStore.Migrations.cs @@ -591,11 +591,23 @@ public partial class LayoutStore : IDisposable columns.Add(reader.GetString(1)); } - if (columns.Contains("BarEnabled")) return; + // Each column is added on its own, guarded by presence — the same idempotent pattern the + // webcam table uses, so a half-migrated DB heals instead of failing. + AddColumnIfMissing("BarEnabled", "INTEGER NOT NULL DEFAULT 1"); + AddColumnIfMissing("TextColor", "TEXT NOT NULL DEFAULT '#FFFFFF'"); + AddColumnIfMissing("BackgroundEnabled", "INTEGER NOT NULL DEFAULT 0"); + AddColumnIfMissing("BackgroundColor", "TEXT NOT NULL DEFAULT '#000000'"); + AddColumnIfMissing("BackgroundOpacity", "REAL NOT NULL DEFAULT 0.55"); + AddColumnIfMissing("TextShadow", "INTEGER NOT NULL DEFAULT 0"); - using var alter = _connection.CreateCommand(); - alter.CommandText = "ALTER TABLE Socials ADD COLUMN BarEnabled INTEGER NOT NULL DEFAULT 1;"; - alter.ExecuteNonQuery(); + void AddColumnIfMissing(string name, string spec) + { + if (columns.Contains(name)) return; + using var alter = _connection.CreateCommand(); + alter.CommandText = $"ALTER TABLE Socials ADD COLUMN {name} {spec};"; + alter.ExecuteNonQuery(); + columns.Add(name); + } } // v8 → v9: SocialEntry gains Software (the fediverse instance's nodeinfo diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs index 2cb5ed0..784f12c 100644 --- a/Services/LayoutStore.Save.cs +++ b/Services/LayoutStore.Save.cs @@ -314,11 +314,20 @@ public partial class LayoutStore : IDisposable var socialsId = Guid.NewGuid().ToString(); using (var cmd = _connection.CreateCommand()) { - cmd.CommandText = "INSERT INTO Socials (Id, BarPosition, BarEnabled) VALUES ($id, $pos, $enabled);"; + cmd.CommandText = """ + INSERT INTO Socials (Id, BarPosition, BarEnabled, TextColor, BackgroundEnabled, + BackgroundColor, BackgroundOpacity, TextShadow) + VALUES ($id, $pos, $enabled, $text, $bgen, $bg, $bgopacity, $shadow); + """; cmd.Transaction = tx; cmd.Parameters.AddWithValue("$id", socialsId); cmd.Parameters.AddWithValue("$pos", socials.BarPosition.ToString()); cmd.Parameters.AddWithValue("$enabled", socials.BarEnabled ? 1 : 0); + cmd.Parameters.AddWithValue("$text", socials.BarTextColor); + cmd.Parameters.AddWithValue("$bgen", socials.BarBackgroundEnabled ? 1 : 0); + cmd.Parameters.AddWithValue("$bg", socials.BarBackgroundColor); + cmd.Parameters.AddWithValue("$bgopacity", socials.BarBackgroundOpacity); + cmd.Parameters.AddWithValue("$shadow", socials.BarTextShadow ? 1 : 0); cmd.ExecuteNonQuery(); } diff --git a/SocialsDialog.xaml b/SocialsDialog.xaml index 2a8b103..7f4eced 100644 --- a/SocialsDialog.xaml +++ b/SocialsDialog.xaml @@ -20,10 +20,49 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SetProperty(ref _barEnabled, value); } + // ─── Bar appearance (creator request 2026-10-30) ─────────────────────────── + + private string _barTextColor = "#FFFFFF"; + public string BarTextColor + { + get => _barTextColor; + set => SetProperty(ref _barTextColor, value); + } + + private bool _barBackgroundEnabled; + public bool BarBackgroundEnabled + { + get => _barBackgroundEnabled; + set => SetProperty(ref _barBackgroundEnabled, value); + } + + private string _barBackgroundColor = "#000000"; + public string BarBackgroundColor + { + get => _barBackgroundColor; + set => SetProperty(ref _barBackgroundColor, value); + } + + private double _barBackgroundOpacity = 0.55; + public double BarBackgroundOpacity + { + get => _barBackgroundOpacity; + set => SetProperty(ref _barBackgroundOpacity, value); + } + + private bool _barTextShadow; + public bool BarTextShadow + { + get => _barTextShadow; + set => SetProperty(ref _barTextShadow, value); + } + + /// The colours offered for the text and the plate — the same catalogue the border + /// picker uses, so there is one list of "the app's colours" (TASK 60/63). + public IReadOnlyList ColorOptions => BorderColorPalette.All; + public bool IsSignedIn { get => _isSignedIn; @@ -275,7 +321,15 @@ public sealed class SocialsDialogViewModel : ViewModelBase { var entries = CommittedEntries ?? throw new InvalidOperationException("Save was not completed."); if (entries.Count == 0) return null; - var config = new SocialsConfig { BarEnabled = BarEnabled }; + var config = new SocialsConfig + { + BarEnabled = BarEnabled, + BarTextColor = BarTextColor, + BarBackgroundEnabled = BarBackgroundEnabled, + BarBackgroundColor = BarBackgroundColor, + BarBackgroundOpacity = BarBackgroundOpacity, + BarTextShadow = BarTextShadow, + }; foreach (var entry in entries) config.Entries.Add(entry); return config; diff --git a/ytLive.Tests/SocialBarAppearanceTests.cs b/ytLive.Tests/SocialBarAppearanceTests.cs new file mode 100644 index 0000000..350c58f --- /dev/null +++ b/ytLive.Tests/SocialBarAppearanceTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Windows.Media; +using Xunit; +using ytLive.Models; +using ytLive.Services; +using ytLive.Services.Compositor; + +namespace ytLive.Tests; + +/// The creator (2026-10-30): the social bar was hard-wired white-on-transparent. It now +/// takes a text colour, an optional background plate with its own colour and opacity, and a text +/// drop shadow — set in the Socials drawer. +/// +/// These knobs are painting rules, so the test paints the real strip and inspects its +/// pixels. Anything less would pass while the bar rendered unchanged. +public class SocialBarAppearanceTests +{ + private static readonly List Entries = new() + { + new SocialEntry { Service = SocialService.YouTube, Handle = "creator", ProfileUrl = "https://youtube.com/@creator" }, + }; + + [Fact] + public void The_bar_paints_the_chosen_text_colour_plate_and_shadow() + { + Exception? failure = null; + var results = new List(); + + var thread = new Thread(() => + { + try + { + // 1. Default look: white glyphs, no plate, no shadow. + var plain = Render(SocialBarStyle.Default); + Assert.NotNull(plain); + Assert.True(Count(plain!, (r, g, b, a) => r > 200 && g > 200 && b > 200 && a > 200) > 0, + "white glyphs must be painted by default"); + Assert.Equal(0, Count(plain!, (r, g, b, a) => b > 150 && r < 80 && g < 80)); + + // 2. Text colour: red glyphs, nothing white. + var red = Render(new SocialBarStyle("#FF0000", false, "#000000", 0.55, false)); + Assert.True(Count(red!, (r, g, b, a) => r > 180 && g < 80 && b < 80 && a > 200) > 0, + "the text colour must reach the glyph pixels"); + Assert.Equal(0, Count(red!, (r, g, b, a) => r > 200 && g > 200 && b > 200 && a > 200)); + + // 3. Plate: blue at 55% — half-transparent blue pixels must appear. + var plated = Render(new SocialBarStyle("#FFFFFF", true, "#0000FF", 0.55, false)); + Assert.True(Count(plated!, (r, g, b, a) => b > 150 && r < 60 && g < 60 && a is > 100 and < 170) > 200, + "the plate must fill with its colour at the chosen opacity"); + + // 4. Shadow: dark semi-transparent pixels appear beside the glyphs. + var shadowed = Render(new SocialBarStyle("#FFFFFF", false, "#000000", 0.55, true)); + Assert.True(Count(shadowed!, (r, g, b, a) => r < 60 && g < 60 && b < 60 && a is > 0 and < 200) + > Count(plain!, (r, g, b, a) => r < 60 && g < 60 && b < 60 && a is > 0 and < 200), + "the drop shadow must add dark pixels behind the glyphs"); + } + catch (Exception ex) { failure = ex; } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + Assert.Null(failure); + } + + private static VideoFrame? Render(SocialBarStyle style) => SocialBarRenderer.Render(Entries, style); + + private static int Count(VideoFrame frame, Func predicate) + { + var hits = 0; + for (var i = 0; i < frame.BgraPixels.Length; i += 4) + if (predicate(frame.BgraPixels[i + 2], frame.BgraPixels[i + 1], frame.BgraPixels[i], frame.BgraPixels[i + 3])) + hits++; + return hits; + } +}