Social bar (Branch B): global resource with per-scene presence — Models/Socials.cs (19 services, icons, canonical URLs), Services/SocialValidator.cs (HTTP lookup validation seam), LayoutStore schema v7 (Socials + SocialEntry tables + Scene.HasSocialBar), MainViewModel (socials collection, AddSocial/RemoveSocial/ToggleSceneSocialBar commands, freemium cap YT+1, premium seam), MainWindow.xaml (footer Social button + [+/-] toggle on meter line, preview bar rendering top/bottom + LCR), 4 integration tests (roundtrip, empty-not-persisted, canonical URLs, icons), 85 tests passing, 0 warnings

This commit is contained in:
2026-08-12 07:33:03 -07:00
parent e1d9e1388d
commit 5e8b8e5835
12 changed files with 715 additions and 17 deletions
+160 -1
View File
@@ -4,6 +4,7 @@ using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
@@ -75,6 +76,7 @@ public class MainViewModel : ViewModelBase
private readonly CameraManager _cameraManager;
private Webcam? _webcam;
private string? _webcamError;
private SocialsConfig? _socials;
private readonly IMicrophoneEnumerator _microphoneEnumerator;
// Screen backdrop: a permanent live capture (desktop/game) that every scene
@@ -168,6 +170,50 @@ 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) ───
/// <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>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>The global socials config (entries + bar position/justify), 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;
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();
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
@@ -712,6 +758,8 @@ public class MainViewModel : ViewModelBase
public ICommand SetBackdropDisplayCommand { get; }
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; }
@@ -766,6 +814,8 @@ public class MainViewModel : ViewModelBase
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
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));
@@ -900,6 +950,10 @@ public class MainViewModel : ViewModelBase
UpdateBackdropImage();
ReacquireWebcam();
ReacquireScreenCaptures();
_socials = _layoutStore.Socials;
OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(CanToggleSceneSocialBar));
OnPropertyChanged(nameof(SceneHasSocialBar));
ScheduleSave();
AppLog.Write("LoadLayout end");
}
@@ -1128,7 +1182,7 @@ public class MainViewModel : ViewModelBase
_saveDebounce?.Stop();
try
{
_layoutStore.Save(Scenes, _webcam);
_layoutStore.Save(Scenes, _webcam, _socials);
}
catch (Exception ex)
{
@@ -1761,4 +1815,109 @@ public class MainViewModel : ViewModelBase
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
LivePulseOpacity = LivePulseOpacity > 0.5 ? 0.35 : 1.0;
}
// ─── Social bar ───
private void ToggleSceneSocialBar()
{
if (ActiveScene is not { } scene) return;
SceneHasSocialBar = !scene.HasSocialBar;
}
private void OpenSocialDialog()
{
// The social dialog is a simple input flow: pick a service, enter a
// handle/URL, the app validates by looking up the page, and on success
// adds the entry. For now this is a MessageBox-based flow — a proper
// dialog window follows once the bar rendering is validated.
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;
}
var services = string.Join(", ", Enum.GetNames<SocialService>());
var input = ShowInputDialog($"Service ({services}):", "Add Social — Step 1 of 2", "YouTube");
if (string.IsNullOrWhiteSpace(input)) return;
if (!Enum.TryParse<SocialService>(input, true, out var service))
{
MessageBox.Show($"'{input}' isn't a recognized service. Try one of: {services}.",
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var handle = ShowInputDialog($"Your {service} handle or profile URL:", "Add Social — Step 2 of 2", "@yourhandle");
if (string.IsNullOrWhiteSpace(handle)) return;
var result = _socialValidator.LookupAsync(service, handle).GetAwaiter().GetResult();
if (!result.Success)
{
MessageBox.Show(result.Error, "ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
_socials ??= new SocialsConfig();
_socials.Entries.Add(new SocialEntry
{
Service = service,
Handle = result.Handle,
ProfileUrl = result.ProfileUrl,
});
OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(CanToggleSceneSocialBar));
OnPropertyChanged(nameof(CanAddMoreSocials));
ScheduleSave();
}
public void RemoveSocial(int index)
{
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));
ScheduleSave();
}
private static string ShowInputDialog(string prompt, string title, string defaultValue)
{
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;
}
}