Files
LlamaCasty/ViewModels/MainViewModel.Socials.cs
T
gramps c1212644b0 fix(socials): live preview of the bar appearance + the crash on dragging a slider
Two bugs from the creator's first look at the new controls.

1. CRASH — '{DependencyProperty.UnsetValue}' is not a valid value for property 'Fill'.
   The token conversion deleted `YtSliderKnobHoverBrush` while two slider triggers still
   referenced it. WPF does not fail that at build time: it substitutes UnsetValue and throws at
   the next layout, inside `Ellipse.OnRender` — so the first slider touched (the new opacity one)
   killed the app. The brush is back, as a glossier version of the rest state.
   Guard: `ThemeResourceTests` scans every app XAML and proves each {StaticResource} key is
   defined (theme/App resources or the same file). Proven by removing the key again — the test
   names it.

2. PREVIEW DID NOTHING — the preview bar is its own XAML rendering (not the rasterized strip the
   compositor overlays), and it was hard-wired white-on-transparent, so the drawer's controls had
   nothing to move. Now:
   - `SocialBarTextBrush` / `SocialBarBackgroundBrush` come from the same `SocialBarStyle` the
     renderer bakes, so preview and output cannot disagree.
   - the preview draws the plate (same colour + opacity) and the drop shadow.
   - the drawer applies changes to the live config as you edit (`ApplyAppearancePreview`), and
     Cancel restores the pre-edit snapshot — so the preview tracks the controls without
     persisting anything until Save.

46 tests green (theme, socials, design tokens, layout store, real-MainWindow meter test).
2026-10-30 08:57:09 -07:00

234 lines
9.5 KiB
C#

using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
using ytLive;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Compositor;
namespace ytLive.ViewModels;
public partial class MainViewModel
{
private SocialsConfig? _socials;
private VideoFrame? _socialBarFrame;
private readonly ISocialValidator _socialValidator = new HttpSocialValidator();
public ICommand OpenSocialDialogCommand { get; }
// ─── Social bar (global resource, shown on every scene) ───
/// <summary>True when the global socials config has at least one validated entry.</summary>
public bool HasSocials => _socials != null && _socials.Entries.Count > 0;
/// <summary>The bar is on the stream when it's enabled and has entries.</summary>
public bool SocialBarVisible => (_socials?.BarEnabled ?? false) && HasSocials;
/// <summary>Footer indicator dot: green when the bar is on the stream, red otherwise.</summary>
public SolidColorBrush SocialBarDotBrush => SocialBarVisible ? BarOnBrush : BarOffBrush;
/// <summary>Green glow on the bar while it's on the stream.</summary>
public SolidColorBrush SocialBarGlowBrush => BarOnBrush;
/// <summary>Glyph colour of the bar — the preview draws the same value the renderer paints,
/// so the two cannot drift apart (creator request 2026-10-30).</summary>
public SolidColorBrush SocialBarTextBrush => SocialBarStyle.From(_socials).TextBrush;
/// <summary>The preview's plate: same colour AND same opacity the renderer bakes in.</summary>
public SolidColorBrush SocialBarBackgroundBrush => SocialBarStyle.From(_socials).BackgroundBrush;
/// <summary>The global socials config (entries + bar settings), or null.</summary>
public SocialsConfig? Socials => _socials;
/// <summary>Canvas.Top for the social bar: 0 = top, 1040 = bottom (40px from the 1080 edge).</summary>
public double SocialBarTop => _socials?.BarPosition == SocialBarPosition.Top ? 0 : 1040;
/// <summary>
/// Resolves a missing nodeinfo software name (mastodon, peertube, ...) for every
/// fediverse entry that doesn't have one, so the bar shows the instance's real
/// logo instead of the generic fediverse glyph. Best-effort: a failed resolution
/// leaves the glyph untouched. Returns handle → software for what was resolved —
/// the caller applies + persists (the app hops to the dispatcher; a test applies
/// directly).
/// </summary>
public static async Task<Dictionary<string, string>> HealFediverseSoftwareAsync(
SocialsConfig socials,
ISocialValidator validator,
Action<string>? log = null)
{
var resolved = new Dictionary<string, string>();
if (socials == null || validator == null) return resolved;
foreach (var entry in socials.Entries)
{
if (entry.Service != SocialService.Fediverse
|| !string.IsNullOrWhiteSpace(entry.FediverseSoftware))
continue;
if (!SocialServiceIcons.TryParseFediverse(entry.Handle, out _, out var domain))
continue;
var software = await validator.ResolveFediverseSoftwareAsync(
domain, System.Threading.CancellationToken.None);
if (string.IsNullOrWhiteSpace(software)) continue;
resolved[entry.Handle] = software;
log?.Invoke($"Socials heal: {entry.Handle} → {software}");
}
return resolved;
}
/// <summary>Runs the heal off the UI thread and applies + saves any result.</summary>
private void HealFediverseSoftwareInBackground()
{
var socials = _socials;
if (socials == null) return;
_ = Task.Run(async () =>
{
try
{
var resolved = await HealFediverseSoftwareAsync(
socials, _socialValidator, m => AppLog.Write(m));
if (resolved.Count == 0) return;
await Application.Current.Dispatcher.InvokeAsync(() =>
{
var current = _socials;
if (current == null) return;
var applied = false;
foreach (var entry in current.Entries)
{
if (!resolved.TryGetValue(entry.Handle, out var software)) continue;
if (entry.FediverseSoftware == software) continue;
entry.FediverseSoftware = software;
applied = true;
}
if (applied) NotifySocialsChanged(); // re-renders the bar + saves
});
}
catch (Exception ex)
{
AppLog.Write($"Socials heal failed: {ex.Message}");
}
});
}
/// <summary>
/// Re-rasterizes the social bar strip the output compositor overlays. UI thread
/// only (WPF rendering); the resulting frame is immutable, so the frame pump may
/// read it from its own thread. Null when the bar is off or empty.
/// </summary>
private void RenderSocialBarFrame()
{
_socialBarFrame = _socials != null && _socials.BarEnabled
? SocialBarRenderer.Render(_socials.Entries, SocialBarStyle.From(_socials))
: null;
}
/// <summary>
/// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate,
/// validation). On save the working copy replaces <see cref="_socials"/>.
/// </summary>
/// <summary>The Socials pull-out (same drawer pattern as Stream Settings / YPP / TEST).</summary>
public bool SocialsDrawerOpen { get; private set; }
public SocialsDialogViewModel? SocialsDrawer { get; private set; }
private void OpenSocialDialog()
{
if (SocialsDrawerOpen)
{
CloseSocialsDrawer();
return;
}
var dialog = new SocialsDialogViewModel(
_socialValidator,
SignInAsync,
SignOutYouTubeAsync,
_youtubeAuth.CurrentChannel,
_socials);
// Live preview: the drawer edits the REAL config, so colour/opacity/shadow land on the
// preview immediately instead of waiting for Save. Cancel restores the snapshot below.
_socialsBeforeEdit = _socials;
dialog.PropertyChanged += (_, e) =>
{
if (e.PropertyName is null) return;
if (!IsAppearanceProperty(e.PropertyName)) return;
ApplyAppearancePreview(dialog);
};
dialog.SaveRequested += () =>
{
_socials = dialog.BuildSocialsConfig();
NotifySocialsChanged();
CloseSocialsDrawer();
};
dialog.CancelRequested += () =>
{
_socials = _socialsBeforeEdit;
NotifySocialsChanged();
OnPropertyChanged(nameof(SocialBarVisible));
CloseSocialsDrawer();
};
SocialsDrawer = dialog;
BroadcastForm.IsDrawerOpen = false;
Ypp.IsDrawerOpen = false;
TestSession.IsDrawerOpen = false;
SocialsDrawerOpen = true;
OnPropertyChanged(nameof(SocialsDrawer));
OnPropertyChanged(nameof(SocialsDrawerOpen));
}
private SocialsConfig? _socialsBeforeEdit;
private static bool IsAppearanceProperty(string name) => name is
nameof(SocialsDialogViewModel.BarEnabled) or nameof(SocialsDialogViewModel.BarTextColor)
or nameof(SocialsDialogViewModel.BarBackgroundEnabled) or nameof(SocialsDialogViewModel.BarBackgroundColor)
or nameof(SocialsDialogViewModel.BarBackgroundOpacity) or nameof(SocialsDialogViewModel.BarTextShadow);
/// <summary>Paints the drawer's current appearance onto the live config and re-renders the
/// strip. Nothing is persisted here — Save writes, Cancel restores the snapshot.</summary>
private void ApplyAppearancePreview(SocialsDialogViewModel dialog)
{
if (_socials == null) return;
_socials.BarEnabled = dialog.BarEnabled;
_socials.BarTextColor = dialog.BarTextColor;
_socials.BarBackgroundEnabled = dialog.BarBackgroundEnabled;
_socials.BarBackgroundColor = dialog.BarBackgroundColor;
_socials.BarBackgroundOpacity = dialog.BarBackgroundOpacity;
_socials.BarTextShadow = dialog.BarTextShadow;
NotifySocialsChanged();
OnPropertyChanged(nameof(SocialBarVisible));
}
internal void CloseSocialsDrawer()
{
if (!SocialsDrawerOpen) return;
SocialsDrawerOpen = false;
SocialsDrawer?.AbortPending();
OnPropertyChanged(nameof(SocialsDrawerOpen));
}
private void NotifySocialsChanged()
{
RenderSocialBarFrame();
OnPropertyChanged(nameof(SocialBarTextBrush));
OnPropertyChanged(nameof(SocialBarBackgroundBrush));
OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(SocialBarVisible));
OnPropertyChanged(nameof(SocialBarDotBrush));
ScheduleSave();
}
/// <summary>Sets the bar's edge; called by the preview click-toggle.</summary>
public void SetSocialBarPosition(SocialBarPosition position)
{
if (_socials == null || _socials.BarPosition == position) return;
_socials.BarPosition = position;
OnPropertyChanged(nameof(SocialBarTop));
ScheduleSave();
}
/// <summary>Preview click flips the bar top ⇄ bottom (KISS — no drag math).</summary>
public void ToggleSocialBarPosition()
=> SetSocialBarPosition(_socials?.BarPosition == SocialBarPosition.Top
? SocialBarPosition.Bottom
: SocialBarPosition.Top);
}