feat(socials): bar appearance — text colour, background plate, opacity, text shadow
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.
This commit is contained in:
@@ -70,6 +70,50 @@ public sealed class SocialsConfig : INotifyPropertyChanged
|
|||||||
set => Set(ref _barPosition, value);
|
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";
|
||||||
|
/// <summary>Text and logo colour of the bar (hex, straight alpha).</summary>
|
||||||
|
public string BarTextColor
|
||||||
|
{
|
||||||
|
get => _barTextColor;
|
||||||
|
set => Set(ref _barTextColor, string.IsNullOrWhiteSpace(value) ? "#FFFFFF" : value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _barBackgroundEnabled;
|
||||||
|
/// <summary>Draw a rounded plate behind the entries. Off by default — the bar shipped
|
||||||
|
/// transparent and most creators keep it that way.</summary>
|
||||||
|
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;
|
||||||
|
/// <summary>0 = invisible plate, 1 = solid. Only meaningful when the plate is on.</summary>
|
||||||
|
public double BarBackgroundOpacity
|
||||||
|
{
|
||||||
|
get => _barBackgroundOpacity;
|
||||||
|
set => Set(ref _barBackgroundOpacity, Math.Clamp(value, 0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _barTextShadow;
|
||||||
|
/// <summary>A soft black drop shadow behind the glyphs, for a bright or busy scene.</summary>
|
||||||
|
public bool BarTextShadow
|
||||||
|
{
|
||||||
|
get => _barTextShadow;
|
||||||
|
set => Set(ref _barTextShadow, value);
|
||||||
|
}
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,8 +33,11 @@ public static class SocialBarRenderer
|
|||||||
public const int Width = 1920;
|
public const int Width = 1920;
|
||||||
|
|
||||||
/// <summary>Returns the strip frame, or null when there is nothing to render.</summary>
|
/// <summary>Returns the strip frame, or null when there is nothing to render.</summary>
|
||||||
public static VideoFrame? Render(IEnumerable<SocialEntry>? entries)
|
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();
|
var list = entries?.Where(e => e != null).ToList();
|
||||||
if (list == null || list.Count == 0) return null;
|
if (list == null || list.Count == 0) return null;
|
||||||
|
|
||||||
@@ -51,7 +54,8 @@ public static class SocialBarRenderer
|
|||||||
item.Children.Add(new Path
|
item.Children.Add(new Path
|
||||||
{
|
{
|
||||||
Data = Geometry.Parse(entry.LogoData),
|
Data = Geometry.Parse(entry.LogoData),
|
||||||
Fill = Brushes.White,
|
Fill = textBrush,
|
||||||
|
Effect = glyphEffect,
|
||||||
Width = 24,
|
Width = 24,
|
||||||
Height = 24,
|
Height = 24,
|
||||||
Stretch = Stretch.Uniform,
|
Stretch = Stretch.Uniform,
|
||||||
@@ -60,7 +64,8 @@ public static class SocialBarRenderer
|
|||||||
item.Children.Add(new TextBlock
|
item.Children.Add(new TextBlock
|
||||||
{
|
{
|
||||||
Text = entry.Handle,
|
Text = entry.Handle,
|
||||||
Foreground = Brushes.White,
|
Foreground = textBrush,
|
||||||
|
Effect = glyphEffect,
|
||||||
FontSize = 14,
|
FontSize = 14,
|
||||||
VerticalAlignment = VerticalAlignment.Center,
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
Margin = new Thickness(8, 0, 0, 0),
|
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 },
|
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) };
|
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.Children.Add(centered);
|
||||||
|
|
||||||
container.Measure(new Size(Width, height));
|
container.Measure(new Size(Width, height));
|
||||||
@@ -102,4 +122,52 @@ public static class SocialBarRenderer
|
|||||||
|
|
||||||
return new VideoFrame(Width, height, pixels);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ public partial class LayoutStore : IDisposable
|
|||||||
|
|
||||||
using (var cmd = _connection.CreateCommand())
|
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();
|
using var reader = cmd.ExecuteReader();
|
||||||
if (reader.Read())
|
if (reader.Read())
|
||||||
{
|
{
|
||||||
@@ -58,6 +62,11 @@ public partial class LayoutStore : IDisposable
|
|||||||
{
|
{
|
||||||
BarPosition = Enum.TryParse<SocialBarPosition>(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom,
|
BarPosition = Enum.TryParse<SocialBarPosition>(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom,
|
||||||
BarEnabled = reader.FieldCount > 2 && !reader.IsDBNull(2) && reader.GetInt32(2) != 0,
|
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);
|
var socialsId = reader.GetString(0);
|
||||||
|
|
||||||
|
|||||||
@@ -591,11 +591,23 @@ public partial class LayoutStore : IDisposable
|
|||||||
columns.Add(reader.GetString(1));
|
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();
|
void AddColumnIfMissing(string name, string spec)
|
||||||
alter.CommandText = "ALTER TABLE Socials ADD COLUMN BarEnabled INTEGER NOT NULL DEFAULT 1;";
|
{
|
||||||
alter.ExecuteNonQuery();
|
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
|
// v8 → v9: SocialEntry gains Software (the fediverse instance's nodeinfo
|
||||||
|
|||||||
@@ -314,11 +314,20 @@ public partial class LayoutStore : IDisposable
|
|||||||
var socialsId = Guid.NewGuid().ToString();
|
var socialsId = Guid.NewGuid().ToString();
|
||||||
using (var cmd = _connection.CreateCommand())
|
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.Transaction = tx;
|
||||||
cmd.Parameters.AddWithValue("$id", socialsId);
|
cmd.Parameters.AddWithValue("$id", socialsId);
|
||||||
cmd.Parameters.AddWithValue("$pos", socials.BarPosition.ToString());
|
cmd.Parameters.AddWithValue("$pos", socials.BarPosition.ToString());
|
||||||
cmd.Parameters.AddWithValue("$enabled", socials.BarEnabled ? 1 : 0);
|
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();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+43
-4
@@ -20,10 +20,49 @@
|
|||||||
<TextBlock Grid.Row="0" Text="Social Media Site Promotion" FontSize="18" FontWeight="Bold"
|
<TextBlock Grid.Row="0" Text="Social Media Site Promotion" FontSize="18" FontWeight="Bold"
|
||||||
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,12"/>
|
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,12"/>
|
||||||
|
|
||||||
<!-- Show/hide the bar on the stream -->
|
<!-- Show/hide the bar on the stream + how it is painted -->
|
||||||
<CheckBox Grid.Row="1" IsChecked="{Binding BarEnabled}" Style="{StaticResource YtCheckBox}" Margin="0,0,0,10">
|
<StackPanel Grid.Row="1" Margin="0,0,0,10">
|
||||||
<TextBlock Text="Show the social bar on the stream" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"/>
|
<CheckBox IsChecked="{Binding BarEnabled}" Style="{StaticResource YtCheckBox}">
|
||||||
</CheckBox>
|
<TextBlock Text="Show the social bar on the stream" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"/>
|
||||||
|
</CheckBox>
|
||||||
|
|
||||||
|
<StackPanel Margin="18,6,0,0" IsEnabled="{Binding BarEnabled}">
|
||||||
|
<DockPanel Margin="0,2,0,0">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Text colour" VerticalAlignment="Center"
|
||||||
|
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Width="96"/>
|
||||||
|
<ComboBox Style="{StaticResource YtComboBox}" Height="24" FontSize="11"
|
||||||
|
ItemsSource="{Binding ColorOptions}" DisplayMemberPath="Name"
|
||||||
|
SelectedValuePath="Hex" SelectedValue="{Binding BarTextColor, Mode=TwoWay}"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<CheckBox IsChecked="{Binding BarBackgroundEnabled}" Style="{StaticResource YtCheckBox}" Margin="0,8,0,0">
|
||||||
|
<TextBlock Text="Background plate" Foreground="{StaticResource Brush.OnSurface}" FontSize="12"/>
|
||||||
|
</CheckBox>
|
||||||
|
|
||||||
|
<StackPanel Margin="18,4,0,0" IsEnabled="{Binding BarBackgroundEnabled}">
|
||||||
|
<DockPanel>
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Plate colour" VerticalAlignment="Center"
|
||||||
|
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Width="96"/>
|
||||||
|
<ComboBox Style="{StaticResource YtComboBox}" Height="24" FontSize="11"
|
||||||
|
ItemsSource="{Binding ColorOptions}" DisplayMemberPath="Name"
|
||||||
|
SelectedValuePath="Hex" SelectedValue="{Binding BarBackgroundColor, Mode=TwoWay}"/>
|
||||||
|
</DockPanel>
|
||||||
|
<DockPanel Margin="0,6,0,0">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Opacity" VerticalAlignment="Center"
|
||||||
|
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Width="96"/>
|
||||||
|
<TextBlock DockPanel.Dock="Right" VerticalAlignment="Center" Width="42" TextAlignment="Right"
|
||||||
|
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||||
|
Text="{Binding BarBackgroundOpacity, StringFormat={}{0:P0}}"/>
|
||||||
|
<Slider Minimum="0" Maximum="1" SmallChange="0.05" LargeChange="0.1"
|
||||||
|
Value="{Binding BarBackgroundOpacity, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<CheckBox IsChecked="{Binding BarTextShadow}" Style="{StaticResource YtCheckBox}" Margin="0,8,0,0">
|
||||||
|
<TextBlock Text="Text drop shadow" Foreground="{StaticResource Brush.OnSurface}" FontSize="12"/>
|
||||||
|
</CheckBox>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Sign-in gate: the first slot is always your YouTube channel -->
|
<!-- Sign-in gate: the first slot is always your YouTube channel -->
|
||||||
<Border Grid.Row="2" Background="{StaticResource Brush.SurfaceContainer}" CornerRadius="5" Padding="10,8" Margin="0,0,0,10"
|
<Border Grid.Row="2" Background="{StaticResource Brush.SurfaceContainer}" CornerRadius="5" Padding="10,8" Margin="0,0,0,10"
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ public partial class MainViewModel
|
|||||||
private void RenderSocialBarFrame()
|
private void RenderSocialBarFrame()
|
||||||
{
|
{
|
||||||
_socialBarFrame = _socials != null && _socials.BarEnabled
|
_socialBarFrame = _socials != null && _socials.BarEnabled
|
||||||
? SocialBarRenderer.Render(_socials.Entries)
|
? SocialBarRenderer.Render(_socials.Entries, SocialBarStyle.From(_socials))
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ public sealed class SocialsDialogViewModel : ViewModelBase
|
|||||||
_signInProvider = signInProvider;
|
_signInProvider = signInProvider;
|
||||||
_signOutAction = signOutAction;
|
_signOutAction = signOutAction;
|
||||||
_barEnabled = current?.BarEnabled ?? true;
|
_barEnabled = current?.BarEnabled ?? true;
|
||||||
|
_barTextColor = current?.BarTextColor ?? "#FFFFFF";
|
||||||
|
_barBackgroundEnabled = current?.BarBackgroundEnabled ?? false;
|
||||||
|
_barBackgroundColor = current?.BarBackgroundColor ?? "#000000";
|
||||||
|
_barBackgroundOpacity = current?.BarBackgroundOpacity ?? 0.55;
|
||||||
|
_barTextShadow = current?.BarTextShadow ?? false;
|
||||||
_account = account;
|
_account = account;
|
||||||
_isSignedIn = account != null;
|
_isSignedIn = account != null;
|
||||||
|
|
||||||
@@ -64,6 +69,47 @@ public sealed class SocialsDialogViewModel : ViewModelBase
|
|||||||
set => SetProperty(ref _barEnabled, value);
|
set => 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
public IReadOnlyList<NamedColor> ColorOptions => BorderColorPalette.All;
|
||||||
|
|
||||||
public bool IsSignedIn
|
public bool IsSignedIn
|
||||||
{
|
{
|
||||||
get => _isSignedIn;
|
get => _isSignedIn;
|
||||||
@@ -275,7 +321,15 @@ public sealed class SocialsDialogViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
var entries = CommittedEntries ?? throw new InvalidOperationException("Save was not completed.");
|
var entries = CommittedEntries ?? throw new InvalidOperationException("Save was not completed.");
|
||||||
if (entries.Count == 0) return null;
|
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)
|
foreach (var entry in entries)
|
||||||
config.Entries.Add(entry);
|
config.Entries.Add(entry);
|
||||||
return config;
|
return config;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>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.
|
||||||
|
///
|
||||||
|
/// <para>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.</para></summary>
|
||||||
|
public class SocialBarAppearanceTests
|
||||||
|
{
|
||||||
|
private static readonly List<SocialEntry> 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<string>();
|
||||||
|
|
||||||
|
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<int, int, int, int, bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user