Reported: the ticker was painting OVER the socials. The coupling rule existed and was tested (ScoreboardLayout.ResolveEdges) but nothing ever CALLED it — the strip's Y still came from its own preference, so both bars landed on the south edge. Now the resolution runs at publish time: the social bar keeps its side, the scoreboard takes the other one, and its Y follows from that edge. The pair re-sorts whenever the social bar moves (the preview click-toggle) or shows/hides, because the rule is "resolve from what is visible", not a stored pairing. The layer also gains an Edge preference (Top/Bottom) in its properties, labelled "socials take the other" — a preference, never a claim on an occupied edge. Also stabilised the opacity assertion: it samples a plate-only pixel 5px from the left edge instead of the first semi-transparent pixel it finds (which could be anti-aliased text).
253 lines
11 KiB
C#
253 lines
11 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.
|
|
/// Transparent when the plate is off — "off" means no plate, never no bar.</summary>
|
|
public SolidColorBrush SocialBarBackgroundBrush => _socials?.BarBackgroundEnabled == true
|
|
? SocialBarStyle.From(_socials).BackgroundBrush
|
|
: Brushes.Transparent;
|
|
|
|
/// <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.
|
|
// Snapshot the VALUES, not the object: the drawer edits the live config in place for the
|
|
// preview, so holding the reference would "restore" the very object that was mutated.
|
|
_appearanceBeforeEdit = _socials is null ? null : (
|
|
_socials.BarEnabled, _socials.BarTextColor, _socials.BarBackgroundEnabled,
|
|
_socials.BarBackgroundColor, _socials.BarBackgroundOpacity, _socials.BarTextShadow);
|
|
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 += () =>
|
|
{
|
|
if (_socials is { } live && _appearanceBeforeEdit is { } before)
|
|
{
|
|
live.BarEnabled = before.BarEnabled;
|
|
live.BarTextColor = before.TextColor;
|
|
live.BarBackgroundEnabled = before.BackgroundEnabled;
|
|
live.BarBackgroundColor = before.BackgroundColor;
|
|
live.BarBackgroundOpacity = before.BackgroundOpacity;
|
|
live.BarTextShadow = before.TextShadow;
|
|
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 (bool BarEnabled, string TextColor, bool BackgroundEnabled, string BackgroundColor,
|
|
double BackgroundOpacity, bool TextShadow)? _appearanceBeforeEdit;
|
|
|
|
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();
|
|
RefreshScoreboardPreviews(); // the pair re-sorts when the social bar shows/hides
|
|
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));
|
|
// Coupled pair: moving the social bar moves the scoreboard to the other edge.
|
|
RefreshScoreboardPreviews();
|
|
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);
|
|
}
|