Social bar UX: YT auto-add from connected account, two-box dialog (YT handle + URL/handle for other services), service auto-detection from URL domain, fediverse @user@domain support, footer repositioned (Social controls left under scenes/sources, meter centered under preview), validation with add-anyway fallback
This commit is contained in:
+44
-32
@@ -795,11 +795,49 @@
|
|||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Line 1: sound meter + volume control, centered beneath the
|
<!-- Line 1: Social controls on the LEFT (under scenes/sources),
|
||||||
middle (preview) panel. Audio is KISS: desktop/game audio is
|
sound meter + volume control CENTERED beneath the preview panel.
|
||||||
automatic ("it just is" — WASAPI loopback at unity, zero UI);
|
Audio is KISS: desktop/game audio is automatic ("it just is" —
|
||||||
the creator's only audio control is the mic — meter, volume, mute. -->
|
WASAPI loopback at unity, zero UI); the creator's only audio
|
||||||
<StackPanel Grid.Row="0" Orientation="Horizontal"
|
control is the mic — meter, volume, mute. -->
|
||||||
|
<Grid Grid.Row="0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="220"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="300"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- Social controls: centered under the scenes/sources listboxes -->
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<Button Content="Social" Command="{Binding OpenSocialDialogCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" Padding="12,4"
|
||||||
|
FontSize="11" VerticalAlignment="Center"
|
||||||
|
ToolTip="Add or manage your social handles"/>
|
||||||
|
<Button Command="{Binding ToggleSceneSocialBarCommand}"
|
||||||
|
Visibility="{Binding CanToggleSceneSocialBar, Converter={StaticResource BoolToVis}}"
|
||||||
|
Style="{StaticResource IconButton}" Width="28" Height="28" Margin="4,0,0,0"
|
||||||
|
VerticalAlignment="Center" FontSize="16" FontWeight="Bold"
|
||||||
|
ToolTip="Add or remove the social bar on this scene">
|
||||||
|
<Button.Content>
|
||||||
|
<TextBlock FontSize="16" FontWeight="Bold">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Text" Value="+"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding SceneHasSocialBar}" Value="True">
|
||||||
|
<Setter Property="Text" Value="−"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
</Button.Content>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Meter + volume: centered under the preview panel -->
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
<TextBlock Text="MIC" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold"
|
<TextBlock Text="MIC" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold"
|
||||||
VerticalAlignment="Center" Margin="0,0,8,0" Cursor="Hand"
|
VerticalAlignment="Center" Margin="0,0,8,0" Cursor="Hand"
|
||||||
@@ -854,34 +892,8 @@
|
|||||||
PreviewMouseLeftButtonDown="VolumeSlider_PreviewMouseLeftButtonDown"
|
PreviewMouseLeftButtonDown="VolumeSlider_PreviewMouseLeftButtonDown"
|
||||||
PreviewMouseLeftButtonUp="VolumeSlider_PreviewMouseLeftButtonUp"
|
PreviewMouseLeftButtonUp="VolumeSlider_PreviewMouseLeftButtonUp"
|
||||||
LostMouseCapture="VolumeSlider_LostMouseCapture"/>
|
LostMouseCapture="VolumeSlider_LostMouseCapture"/>
|
||||||
|
|
||||||
<Separator Margin="16,0,0,0" Background="#2a3a5e" Width="1" Height="20"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<Button Content="Social" Command="{Binding OpenSocialDialogCommand}"
|
|
||||||
Style="{StaticResource YtButtonSecondary}" Padding="12,4" Margin="8,0,0,0"
|
|
||||||
FontSize="11" VerticalAlignment="Center"
|
|
||||||
ToolTip="Add or manage your social handles"/>
|
|
||||||
<Button Command="{Binding ToggleSceneSocialBarCommand}"
|
|
||||||
Visibility="{Binding CanToggleSceneSocialBar, Converter={StaticResource BoolToVis}}"
|
|
||||||
Style="{StaticResource IconButton}" Width="28" Height="28" Margin="4,0,0,0"
|
|
||||||
VerticalAlignment="Center" FontSize="16" FontWeight="Bold"
|
|
||||||
ToolTip="Add or remove the social bar on this scene">
|
|
||||||
<Button.Content>
|
|
||||||
<TextBlock FontSize="16" FontWeight="Bold">
|
|
||||||
<TextBlock.Style>
|
|
||||||
<Style TargetType="TextBlock">
|
|
||||||
<Setter Property="Text" Value="+"/>
|
|
||||||
<Style.Triggers>
|
|
||||||
<DataTrigger Binding="{Binding SceneHasSocialBar}" Value="True">
|
|
||||||
<Setter Property="Text" Value="−"/>
|
|
||||||
</DataTrigger>
|
|
||||||
</Style.Triggers>
|
|
||||||
</Style>
|
|
||||||
</TextBlock.Style>
|
|
||||||
</TextBlock>
|
|
||||||
</Button.Content>
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!-- Line 2: everything else — stream stats on the left, quality +
|
<!-- Line 2: everything else — stream stats on the left, quality +
|
||||||
gear on the right. -->
|
gear on the right. -->
|
||||||
|
|||||||
@@ -136,4 +136,64 @@ public static class SocialServiceIcons
|
|||||||
_ => $"https://{h}",
|
_ => $"https://{h}",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auto-detects the social service from a URL or fediverse handle. Supports
|
||||||
|
/// full URLs (https://x.com/user), domain-only (x.com/user), and fediverse
|
||||||
|
/// handles (@user@instance.tube). Returns Link/Website for unknown domains
|
||||||
|
/// or bare handles — the caller can then ask which service it is.
|
||||||
|
/// </summary>
|
||||||
|
public static (SocialService service, string handle, string url) DetectService(string input)
|
||||||
|
{
|
||||||
|
var trimmed = input.Trim();
|
||||||
|
|
||||||
|
// Fediverse handle: @user@domain
|
||||||
|
if (trimmed.StartsWith("@") && trimmed.IndexOf('@', 1) > 0)
|
||||||
|
{
|
||||||
|
var parts = trimmed.TrimStart('@').Split('@', 2);
|
||||||
|
if (parts.Length == 2)
|
||||||
|
{
|
||||||
|
var (user, domain) = (parts[0], parts[1]);
|
||||||
|
return (SocialService.Link, user, $"https://{domain}/@{user}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip protocol for domain parsing
|
||||||
|
var url = trimmed;
|
||||||
|
if (!trimmed.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||||
|
url = $"https://{trimmed}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = new Uri(url);
|
||||||
|
var domain = uri.Host.ToLowerInvariant();
|
||||||
|
var path = uri.AbsolutePath.Trim('/');
|
||||||
|
|
||||||
|
return domain switch
|
||||||
|
{
|
||||||
|
"youtube.com" or "www.youtube.com" or "youtu.be" => (SocialService.YouTube, path, url),
|
||||||
|
"twitch.tv" or "www.twitch.tv" => (SocialService.Twitch, path, url),
|
||||||
|
"x.com" or "twitter.com" => (SocialService.X, path, url),
|
||||||
|
"instagram.com" or "www.instagram.com" => (SocialService.Instagram, path, url),
|
||||||
|
"tiktok.com" or "www.tiktok.com" => (SocialService.TikTok, path, url),
|
||||||
|
"facebook.com" or "www.facebook.com" or "fb.com" => (SocialService.Facebook, path, url),
|
||||||
|
"discord.gg" or "discord.com" => (SocialService.Discord, path, url),
|
||||||
|
"kick.com" => (SocialService.Kick, path, url),
|
||||||
|
"threads.net" or "www.threads.net" => (SocialService.Threads, path, url),
|
||||||
|
"bsky.app" => (SocialService.Bluesky, path, url),
|
||||||
|
"github.com" => (SocialService.GitHub, path, url),
|
||||||
|
"linkedin.com" or "www.linkedin.com" => (SocialService.LinkedIn, path, url),
|
||||||
|
"pinterest.com" or "www.pinterest.com" => (SocialService.Pinterest, path, url),
|
||||||
|
"snapchat.com" => (SocialService.Snapchat, path, url),
|
||||||
|
"reddit.com" or "www.reddit.com" => (SocialService.Reddit, path, url),
|
||||||
|
"wa.me" or "whatsapp.com" => (SocialService.WhatsApp, path, url),
|
||||||
|
"t.me" => (SocialService.Telegram, path, url),
|
||||||
|
_ => (SocialService.Website, path, url),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return (SocialService.Link, trimmed.TrimStart('@'), SocialServiceIcons.CanonicalUrlFor(SocialService.Link, trimmed));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-16
@@ -889,6 +889,7 @@ public class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
IsConnected = true;
|
IsConnected = true;
|
||||||
SyncConnectedAccount();
|
SyncConnectedAccount();
|
||||||
|
AutoAddYouTubeSocial();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -903,6 +904,34 @@ public class MainViewModel : ViewModelBase
|
|||||||
AccountDisplayName = channel?.DisplayName ?? string.Empty;
|
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(
|
private static string DefaultLayoutPath => Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
"ytLlive",
|
"ytLlive",
|
||||||
@@ -1826,10 +1855,6 @@ public class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
private void OpenSocialDialog()
|
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)
|
if (!CanAddMoreSocials)
|
||||||
{
|
{
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
@@ -1840,34 +1865,69 @@ public class MainViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var services = string.Join(", ", Enum.GetNames<SocialService>());
|
// Step 1: YouTube handle — pre-filled from the connected account if available.
|
||||||
var input = ShowInputDialog($"Service ({services}):", "Add Social — Step 1 of 2", "YouTube");
|
var ytHandle = IsConnected && _youtubeAuth.CurrentChannel is { } ch
|
||||||
if (string.IsNullOrWhiteSpace(input)) return;
|
? ch.DisplayName
|
||||||
if (!Enum.TryParse<SocialService>(input, true, out var service))
|
: 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)
|
||||||
{
|
{
|
||||||
MessageBox.Show($"'{input}' isn't a recognized service. Try one of: {services}.",
|
var ytResult = _socialValidator.LookupAsync(SocialService.YouTube, ytHandle).GetAwaiter().GetResult();
|
||||||
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
|
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)
|
||||||
|
{
|
||||||
|
NotifySocialsChanged();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var handle = ShowInputDialog($"Your {service} handle or profile URL:", "Add Social — Step 2 of 2", "@yourhandle");
|
var input = ShowInputDialog(
|
||||||
if (string.IsNullOrWhiteSpace(handle)) return;
|
"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();
|
var result = _socialValidator.LookupAsync(service, handle).GetAwaiter().GetResult();
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
MessageBox.Show(result.Error, "ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
|
var proceed = MessageBox.Show(
|
||||||
return;
|
$"{result.Error}\n\nAdd anyway?", "ytLlive",
|
||||||
|
MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
||||||
|
if (proceed != MessageBoxResult.Yes) { NotifySocialsChanged(); return; }
|
||||||
}
|
}
|
||||||
|
|
||||||
_socials ??= new SocialsConfig();
|
_socials ??= new SocialsConfig();
|
||||||
_socials.Entries.Add(new SocialEntry
|
_socials.Entries.Add(new SocialEntry
|
||||||
{
|
{
|
||||||
Service = service,
|
Service = service,
|
||||||
Handle = result.Handle,
|
Handle = handle,
|
||||||
ProfileUrl = result.ProfileUrl,
|
ProfileUrl = result.Success ? result.ProfileUrl : url,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
NotifySocialsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifySocialsChanged()
|
||||||
|
{
|
||||||
OnPropertyChanged(nameof(HasSocials));
|
OnPropertyChanged(nameof(HasSocials));
|
||||||
OnPropertyChanged(nameof(CanToggleSceneSocialBar));
|
OnPropertyChanged(nameof(CanToggleSceneSocialBar));
|
||||||
OnPropertyChanged(nameof(CanAddMoreSocials));
|
OnPropertyChanged(nameof(CanAddMoreSocials));
|
||||||
|
|||||||
Reference in New Issue
Block a user