Social bar v2: fediverse icons resolved via nodeinfo (with redirect-following), persisted software column, icon colors — 112 tests passing, 0 warnings

This commit is contained in:
2026-08-12 18:28:59 -07:00
parent db5fc06174
commit 9873eeda7d
18 changed files with 1878 additions and 321 deletions
+47 -176
View File
@@ -170,47 +170,33 @@ public class MainViewModel : ViewModelBase
/// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
public bool CanChangeWebcam => _webcam != null;
// ─── Social bar (global resource, per-scene presence) ───
// ─── Social bar (global resource, shown on every scene) ───
private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
private static readonly SolidColorBrush BarOffBrush = CreateBrush("#e94560");
private static SolidColorBrush CreateBrush(string hex)
=> (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!;
/// <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 footer [+]/[-] can toggle the bar on the active scene only when socials exist.</summary>
public bool CanToggleSceneSocialBar => HasSocials && ActiveScene != null;
/// <summary>The bar is on the stream when it's enabled and has entries.</summary>
public bool SocialBarVisible => (_socials?.BarEnabled ?? false) && HasSocials;
/// <summary>Whether the active scene currently shows the social bar.</summary>
public bool SceneHasSocialBar
{
get => ActiveScene?.HasSocialBar ?? false;
set
{
if (ActiveScene is { } scene && scene.HasSocialBar != value)
{
scene.HasSocialBar = value;
OnPropertyChanged(nameof(SceneHasSocialBar));
ScheduleSave();
}
}
}
/// <summary>Footer indicator dot: green when the bar is on the stream, red otherwise.</summary>
public SolidColorBrush SocialBarDotBrush => SocialBarVisible ? BarOnBrush : BarOffBrush;
/// <summary>The global socials config (entries + bar position/justify), or null.</summary>
/// <summary>Green glow on the bar while it's on the stream.</summary>
public SolidColorBrush SocialBarGlowBrush => BarOnBrush;
/// <summary>The global socials config (entries + bar settings), or null.</summary>
public SocialsConfig? Socials => _socials;
/// <summary>Freemium cap: YT + 1 other = 2 entries. Premium (unlimited) gated by the unlock flag seam.</summary>
public const int FreemiumSocialCap = 2;
/// <summary>Freemium: 6 slots — YT + 1 other, the rest locked. Premium seam: all 6 open.</summary>
private static bool IsPremium => false; // itch.io unlock deferred — seam only
public int SocialCap => IsPremium ? int.MaxValue : FreemiumSocialCap;
public bool CanAddMoreSocials => _socials == null || _socials.Entries.Count < SocialCap;
/// <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;
public HorizontalAlignment SocialBarAlign => _socials?.BarJustify switch
{
SocialBarJustify.Left => HorizontalAlignment.Left,
SocialBarJustify.Right => HorizontalAlignment.Right,
_ => HorizontalAlignment.Center,
};
private readonly ISocialValidator _socialValidator = new HttpSocialValidator();
@@ -759,7 +745,6 @@ public class MainViewModel : ViewModelBase
public ICommand ToggleMicMuteCommand { get; }
public ICommand OpenMicPickerCommand { get; }
public ICommand OpenSocialDialogCommand { get; }
public ICommand ToggleSceneSocialBarCommand { get; }
public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; }
public ICommand OpenSettingsCommand { get; }
@@ -815,7 +800,6 @@ public class MainViewModel : ViewModelBase
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
ToggleSceneSocialBarCommand = new RelayCommand(_ => ToggleSceneSocialBar(), _ => CanToggleSceneSocialBar);
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
@@ -889,7 +873,6 @@ public class MainViewModel : ViewModelBase
IsConnected = true;
SyncConnectedAccount();
AutoAddYouTubeSocial();
}
catch (Exception ex)
{
@@ -904,34 +887,6 @@ public class MainViewModel : ViewModelBase
AccountDisplayName = channel?.DisplayName ?? string.Empty;
}
/// <summary>
/// When the creator is connected to YouTube and hasn't defined any socials yet,
/// auto-add YouTube as the first entry — the connected channel is the default
/// social, no extra input needed.
/// </summary>
private void AutoAddYouTubeSocial()
{
if (_socials != null && _socials.Entries.Count > 0) return;
var channel = _youtubeAuth.CurrentChannel;
if (channel == null || string.IsNullOrWhiteSpace(channel.DisplayName)) return;
_socials ??= new SocialsConfig();
var handle = channel.DisplayName.Trim().TrimStart('@');
_socials.Entries.Add(new SocialEntry
{
Service = SocialService.YouTube,
Handle = handle,
ProfileUrl = !string.IsNullOrWhiteSpace(channel.ChannelId)
? $"https://www.youtube.com/channel/{channel.ChannelId}"
: SocialServiceIcons.CanonicalUrlFor(SocialService.YouTube, handle),
});
OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(CanToggleSceneSocialBar));
OnPropertyChanged(nameof(CanAddMoreSocials));
ScheduleSave();
}
private static string DefaultLayoutPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ytLlive",
@@ -981,8 +936,8 @@ public class MainViewModel : ViewModelBase
ReacquireScreenCaptures();
_socials = _layoutStore.Socials;
OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(CanToggleSceneSocialBar));
OnPropertyChanged(nameof(SceneHasSocialBar));
OnPropertyChanged(nameof(SocialBarVisible));
OnPropertyChanged(nameof(SocialBarDotBrush));
ScheduleSave();
AppLog.Write("LoadLayout end");
}
@@ -1847,137 +1802,53 @@ public class MainViewModel : ViewModelBase
// ─── Social bar ───
private void ToggleSceneSocialBar()
{
if (ActiveScene is not { } scene) return;
SceneHasSocialBar = !scene.HasSocialBar;
}
/// <summary>
/// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate,
/// validation). On save the working copy replaces <see cref="_socials"/>.
/// </summary>
private void OpenSocialDialog()
{
if (!CanAddMoreSocials)
{
MessageBox.Show(
IsPremium
? "Something went wrong — the social cap is hit but premium is active."
: $"Free tier: up to {FreemiumSocialCap} socials (YouTube + 1 other). Upgrade to add more.",
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
// Step 1: YouTube handle — pre-filled from the connected account if available.
var ytHandle = IsConnected && _youtubeAuth.CurrentChannel is { } ch
? ch.DisplayName
: ShowInputDialog("Your YouTube handle (e.g. @yourchannel):", "Add Social — YouTube", "@yourchannel");
if (string.IsNullOrWhiteSpace(ytHandle)) return;
ytHandle = ytHandle.Trim().TrimStart('@');
// Validate the YT handle (or skip validation if it came from the connected account).
if (!IsConnected || _socials?.Entries.Any(e => e.Service == SocialService.YouTube) != true)
{
var ytResult = _socialValidator.LookupAsync(SocialService.YouTube, ytHandle).GetAwaiter().GetResult();
if (!ytResult.Success)
{
var proceed = MessageBox.Show(
$"{ytResult.Error}\n\nAdd anyway?", "ytLlive",
MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (proceed != MessageBoxResult.Yes) return;
}
_socials ??= new SocialsConfig();
_socials.Entries.Add(new SocialEntry
{
Service = SocialService.YouTube,
Handle = ytHandle,
ProfileUrl = SocialServiceIcons.CanonicalUrlFor(SocialService.YouTube, ytHandle),
});
}
// Step 2: Another social — URL or handle string, auto-detect the service.
if (!CanAddMoreSocials)
var dialog = new SocialsDialogViewModel(
_socialValidator,
SignInAsync,
SignOutYouTubeAsync,
_youtubeAuth.CurrentChannel,
IsPremium,
_socials);
var window = new ytLive.SocialsDialog(dialog) { Owner = Application.Current.MainWindow };
if (window.ShowDialog() == true)
{
_socials = dialog.BuildSocialsConfig();
NotifySocialsChanged();
return;
}
var input = ShowInputDialog(
"Enter a URL or handle for another social:\n" +
"e.g. https://x.com/yourname or @you@instance.tube",
"Add Social — Step 2 of 2", "");
if (string.IsNullOrWhiteSpace(input)) { NotifySocialsChanged(); return; }
var (service, handle, url) = SocialServiceIcons.DetectService(input);
var result = _socialValidator.LookupAsync(service, handle).GetAwaiter().GetResult();
if (!result.Success)
{
var proceed = MessageBox.Show(
$"{result.Error}\n\nAdd anyway?", "ytLlive",
MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (proceed != MessageBoxResult.Yes) { NotifySocialsChanged(); return; }
}
_socials ??= new SocialsConfig();
_socials.Entries.Add(new SocialEntry
{
Service = service,
Handle = handle,
ProfileUrl = result.Success ? result.ProfileUrl : url,
});
NotifySocialsChanged();
}
private void NotifySocialsChanged()
{
OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(CanToggleSceneSocialBar));
OnPropertyChanged(nameof(CanAddMoreSocials));
OnPropertyChanged(nameof(SocialBarVisible));
OnPropertyChanged(nameof(SocialBarDotBrush));
ScheduleSave();
}
public void RemoveSocial(int index)
/// <summary>Drag release snaps the bar to the closest edge; called by the preview drag.</summary>
public void SetSocialBarPosition(SocialBarPosition position)
{
if (_socials == null || index < 0 || index >= _socials.Entries.Count) return;
_socials.Entries.RemoveAt(index);
if (_socials.Entries.Count == 0)
{
foreach (var scene in Scenes) scene.HasSocialBar = false;
_socials = null;
}
OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(CanToggleSceneSocialBar));
OnPropertyChanged(nameof(CanAddMoreSocials));
OnPropertyChanged(nameof(SceneHasSocialBar));
if (_socials == null || _socials.BarPosition == position) return;
_socials.BarPosition = position;
OnPropertyChanged(nameof(SocialBarTop));
ScheduleSave();
}
private static string ShowInputDialog(string prompt, string title, string defaultValue)
/// <summary>Signs out of YouTube (the delete-the-YouTube-slot action in the dialog).</summary>
private async Task SignOutYouTubeAsync()
{
var window = new Window
{
Title = title,
Width = 420,
Height = 180,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Owner = Application.Current.MainWindow,
Background = (System.Windows.Media.Brush)new System.Windows.Media.BrushConverter().ConvertFromString("#1a1a2e")!,
};
var stack = new StackPanel { Margin = new Thickness(16) };
var label = new TextBlock { Text = prompt, Foreground = System.Windows.Media.Brushes.White, Margin = new Thickness(0, 0, 0, 8), FontSize = 14 };
var box = new TextBox { Text = defaultValue, FontSize = 14, Padding = new Thickness(8, 6, 8, 6) };
var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 12, 0, 0) };
var ok = new Button { Content = "OK", Padding = new Thickness(20, 6, 20, 6), Margin = new Thickness(0, 0, 8, 0), IsDefault = true };
var cancel = new Button { Content = "Cancel", Padding = new Thickness(20, 6, 20, 6), IsCancel = true };
buttons.Children.Add(ok);
buttons.Children.Add(cancel);
stack.Children.Add(label);
stack.Children.Add(box);
stack.Children.Add(buttons);
window.Content = stack;
ok.Click += (_, _) => { window.DialogResult = true; window.Close(); };
box.SelectAll();
box.Focus();
return window.ShowDialog() == true ? box.Text : string.Empty;
_youtubeAuth.ClearSession();
TokenStore.Clear();
IsConnected = false;
SyncConnectedAccount();
NotifySocialsChanged();
AppLog.Write("Socials: signed out of YouTube");
await Task.CompletedTask;
}
}