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;
+ }
+}