Merge social-bar: global social bar resource with per-scene presence, validated lookups, schema v7
This commit is contained in:
+5
-5
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
## Session state (last updated: 2026-08-12)
|
## Session state (last updated: 2026-08-12)
|
||||||
|
|
||||||
- **Finished:** Webcam resource validation + first-frame proof — merged to `main` and pushed. `MediaCaptureFrameSource` validates post-init (VideoDeviceId match, stream properties, reader StartAsync status), subscribes `Failed`/`CameraStreamStateChanged` → `SourceFailed`; `CameraManager.AcquireAsync` requires first-frame proof (4s timeout); `MainViewModel` surfaces `WebcamError` chip + MessageBox with suspect-app names (`CameraConflictProbe`). Tested locally — NVIDIA Broadcast identified as the camera hog, killed it, webcam works. 81 tests passing, 0 warnings.
|
- **Finished:** Webcam resource validation (merged to main, `e1d9e13`) + Social bar (on branch `social-bar`, ready to merge). Social bar: `Models/Socials.cs` (19 services + icons/URLs), `Services/SocialValidator.cs` (HTTP lookup seam), schema v7 (`Socials` + `SocialEntry` tables + `Scene.HasSocialBar`), footer Social button + [+/-] toggle, preview bar rendering (top/bottom, LCR), freemium cap (YT+1, premium seam). 85 tests passing, 0 warnings.
|
||||||
- **In flight:** Nothing. Clean tree on `main`.
|
- **In flight:** Social bar needs merge to main + push, then local testing.
|
||||||
- **Landmine:** 19041 SDK projection gaps: `MediaCaptureSharingMode.Exclusive` + `MediaCapture.DeviceLost` not projected; `CameraStreamState` enum member names omitted (compare by `(int)` — 2=Failed). The Aug 11 social-bar work is **gone** (git gc-pruned, opencode.db reinitialized, gitea refuses unadvertised fetch) — rebuild from scratch per TASKS.md item 14.
|
- **Landmine:** 19041 SDK projection gaps (see Branch A). `HttpSocialValidator` uses synchronous `.GetAwaiter().GetResult()` in the dialog flow — fine for a UI-blocking dialog, but a proper async dialog is a follow-up. The social dialog is currently a simple `ShowInputDialog` (inline WPF Window) — a proper dialog window with service picker + URL field + validation feedback is a UX follow-up.
|
||||||
- **Next step:** Branch B — social bar (global resource, validated lookups, top/bottom + LCR, freemium YT+1 / premium unlimited, +/- scene toggle). Spec in TASKS.md item 14.
|
- **Next step:** Merge `social-bar` to main, push. Then: local test the social bar (add a social, verify bar renders, toggle on/off scenes). Then: continue TASK 4 (encoder + RTMP push) or the next queued item.
|
||||||
- **Secret/DB/port facts live:** OAuth client id/secret in `Helpers/OAuthCredentials.cs`; OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`); layout DB `%APPDATA%\ytLlive\ytLlive.db` (schema v6); OAuth callback `http://localhost:8765/oauth2/callback`; crash log `%APPDATA%\ytLlive\startup.log`.
|
- **Secret/DB/port facts live:** OAuth client id/secret in `Helpers/OAuthCredentials.cs`; OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`); layout DB `%APPDATA%\ytLlive\ytLlive.db` (schema v7); OAuth callback `http://localhost:8765/oauth2/callback`; crash log `%APPDATA%\ytLlive\startup.log`.
|
||||||
|
|||||||
@@ -651,6 +651,40 @@
|
|||||||
FontSize="180" TextAlignment="Center"/>
|
FontSize="180" TextAlignment="Center"/>
|
||||||
</Viewbox>
|
</Viewbox>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Social bar: horizontal chip row at the top or bottom of the
|
||||||
|
frame, justified LCR. Global resource, shown per-scene. -->
|
||||||
|
<Grid Width="1920" Canvas.Top="{Binding SocialBarTop}" IsHitTestVisible="False"
|
||||||
|
Visibility="{Binding SceneHasSocialBar, Converter={StaticResource BoolToVis}}">
|
||||||
|
<ItemsControl ItemsSource="{Binding Socials.Entries}"
|
||||||
|
HorizontalAlignment="{Binding SocialBarAlign}"
|
||||||
|
Margin="40,8">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,20,0">
|
||||||
|
<Border Width="28" Height="28" CornerRadius="14"
|
||||||
|
Background="{Binding AccentColor}"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Initials}" Foreground="White"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="{Binding Handle}" Foreground="White"
|
||||||
|
FontSize="14" VerticalAlignment="Center"
|
||||||
|
Margin="8,0,0,0"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
MaxWidth="200"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Grid>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Viewbox>
|
</Viewbox>
|
||||||
@@ -820,6 +854,33 @@
|
|||||||
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>
|
||||||
|
|
||||||
<!-- Line 2: everything else — stream stats on the left, quality +
|
<!-- Line 2: everything else — stream stats on the left, quality +
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public class Scene : INotifyPropertyChanged
|
|||||||
private bool _isEditing;
|
private bool _isEditing;
|
||||||
private bool _isHidden;
|
private bool _isHidden;
|
||||||
private bool _hasBackdrop;
|
private bool _hasBackdrop;
|
||||||
|
private bool _hasSocialBar;
|
||||||
|
|
||||||
public string Name
|
public string Name
|
||||||
{
|
{
|
||||||
@@ -41,6 +42,16 @@ public class Scene : INotifyPropertyChanged
|
|||||||
set => Set(ref _hasBackdrop, value);
|
set => Set(ref _hasBackdrop, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the social bar plays on this scene. Toggled by the footer [+]/[-]
|
||||||
|
/// control — the socials themselves are a global resource (see SocialsConfig).
|
||||||
|
/// </summary>
|
||||||
|
public bool HasSocialBar
|
||||||
|
{
|
||||||
|
get => _hasSocialBar;
|
||||||
|
set => Set(ref _hasSocialBar, value);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The scene's rendered elements in z-order (back to front): multi-instance
|
/// The scene's rendered elements in z-order (back to front): multi-instance
|
||||||
/// Sources plus this scene's webcam usage (<see cref="WebcamConfig"/>), if any.
|
/// Sources plus this scene's webcam usage (<see cref="WebcamConfig"/>), if any.
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
public enum SocialService
|
||||||
|
{
|
||||||
|
YouTube, Twitch, X, Instagram, TikTok, Facebook, Discord, Kick,
|
||||||
|
Threads, Bluesky, GitHub, LinkedIn, Pinterest, Snapchat, Reddit,
|
||||||
|
WhatsApp, Telegram, Link, Website
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SocialBarPosition { Top, Bottom }
|
||||||
|
public enum SocialBarJustify { Left, Center, Right }
|
||||||
|
|
||||||
|
public sealed class SocialEntry : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public SocialService Service { get; init; }
|
||||||
|
public string Handle { get; init; } = string.Empty;
|
||||||
|
public string ProfileUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string ServiceName => Service.ToString();
|
||||||
|
public string Initials => SocialServiceIcons.InitialsFor(Service);
|
||||||
|
public string AccentColor => SocialServiceIcons.ColorFor(Service);
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
private void Raise([CallerMemberName] string? name = null)
|
||||||
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The app-wide social bar config — a global resource (like the webcam) whose
|
||||||
|
/// per-scene presence is toggled by <see cref="Scene.HasSocialBar"/>. The creator
|
||||||
|
/// provides handles/URLs; the app validates each by looking up the social page
|
||||||
|
/// before adding it. Freemium: YT + 1 other. Premium: as many as fit one line.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SocialsConfig : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public ObservableCollection<SocialEntry> Entries { get; } = new();
|
||||||
|
|
||||||
|
private SocialBarPosition _barPosition = SocialBarPosition.Bottom;
|
||||||
|
public SocialBarPosition BarPosition
|
||||||
|
{
|
||||||
|
get => _barPosition;
|
||||||
|
set => Set(ref _barPosition, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SocialBarJustify _barJustify = SocialBarJustify.Center;
|
||||||
|
public SocialBarJustify BarJustify
|
||||||
|
{
|
||||||
|
get => _barJustify;
|
||||||
|
set => Set(ref _barJustify, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||||
|
{
|
||||||
|
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||||
|
field = value;
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SocialServiceIcons
|
||||||
|
{
|
||||||
|
public static string InitialsFor(SocialService service) => service switch
|
||||||
|
{
|
||||||
|
SocialService.YouTube => "YT",
|
||||||
|
SocialService.Twitch => "TW",
|
||||||
|
SocialService.X => "X",
|
||||||
|
SocialService.Instagram => "IG",
|
||||||
|
SocialService.TikTok => "TT",
|
||||||
|
SocialService.Facebook => "FB",
|
||||||
|
SocialService.Discord => "DC",
|
||||||
|
SocialService.Kick => "K",
|
||||||
|
SocialService.Threads => "TH",
|
||||||
|
SocialService.Bluesky => "BS",
|
||||||
|
SocialService.GitHub => "GH",
|
||||||
|
SocialService.LinkedIn => "LI",
|
||||||
|
SocialService.Pinterest => "PN",
|
||||||
|
SocialService.Snapchat => "SC",
|
||||||
|
SocialService.Reddit => "RD",
|
||||||
|
SocialService.WhatsApp => "WA",
|
||||||
|
SocialService.Telegram => "TG",
|
||||||
|
SocialService.Link => "L",
|
||||||
|
_ => "★",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string ColorFor(SocialService service) => service switch
|
||||||
|
{
|
||||||
|
SocialService.YouTube => "#FF0000",
|
||||||
|
SocialService.Twitch => "#9146FF",
|
||||||
|
SocialService.X => "#000000",
|
||||||
|
SocialService.Instagram => "#E4405F",
|
||||||
|
SocialService.TikTok => "#010101",
|
||||||
|
SocialService.Facebook => "#1877F2",
|
||||||
|
SocialService.Discord => "#5865F2",
|
||||||
|
SocialService.Kick => "#53FC18",
|
||||||
|
SocialService.Threads => "#010101",
|
||||||
|
SocialService.Bluesky => "#0085FF",
|
||||||
|
SocialService.GitHub => "#181717",
|
||||||
|
SocialService.LinkedIn => "#0A66C2",
|
||||||
|
SocialService.Pinterest => "#BD081C",
|
||||||
|
SocialService.Snapchat => "#FFFC00",
|
||||||
|
SocialService.Reddit => "#FF4500",
|
||||||
|
SocialService.WhatsApp => "#25D366",
|
||||||
|
SocialService.Telegram => "#26A5E4",
|
||||||
|
_ => "#e94560",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string CanonicalUrlFor(SocialService service, string handle)
|
||||||
|
{
|
||||||
|
var h = handle.Trim().TrimStart('@');
|
||||||
|
return service switch
|
||||||
|
{
|
||||||
|
SocialService.YouTube => $"https://www.youtube.com/@{h}",
|
||||||
|
SocialService.Twitch => $"https://www.twitch.tv/{h}",
|
||||||
|
SocialService.X => $"https://x.com/{h}",
|
||||||
|
SocialService.Instagram => $"https://www.instagram.com/{h}",
|
||||||
|
SocialService.TikTok => $"https://www.tiktok.com/@{h}",
|
||||||
|
SocialService.Facebook => $"https://www.facebook.com/{h}",
|
||||||
|
SocialService.Discord => $"https://discord.gg/{h}",
|
||||||
|
SocialService.Kick => $"https://kick.com/{h}",
|
||||||
|
SocialService.Threads => $"https://www.threads.net/@{h}",
|
||||||
|
SocialService.Bluesky => $"https://bsky.app/profile/{h}",
|
||||||
|
SocialService.GitHub => $"https://github.com/{h}",
|
||||||
|
SocialService.LinkedIn => $"https://www.linkedin.com/in/{h}",
|
||||||
|
SocialService.Pinterest => $"https://www.pinterest.com/{h}",
|
||||||
|
SocialService.Snapchat => $"https://www.snapchat.com/add/{h}",
|
||||||
|
SocialService.Reddit => $"https://www.reddit.com/user/{h}",
|
||||||
|
SocialService.WhatsApp => $"https://wa.me/{h}",
|
||||||
|
SocialService.Telegram => $"https://t.me/{h}",
|
||||||
|
SocialService.Link or SocialService.Website => h.StartsWith("http") ? h : $"https://{h}",
|
||||||
|
_ => $"https://{h}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ Plain data types. No logic beyond what a property can carry. See
|
|||||||
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
|
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
|
||||||
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
|
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
|
||||||
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
|
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
|
||||||
|
| `Socials.cs` | **Social bar** (global resource, per-scene presence): `SocialService` enum (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/Snapchat/Reddit/WhatsApp/Telegram/Link/Website), `SocialEntry` (Service/Handle/ProfileUrl + Initials/AccentColor), `SocialsConfig` (Entries + BarPosition Top/Bottom + BarJustify LCR), `SocialServiceIcons` (canonical URL builder + initials/color per service) |
|
||||||
|
|
||||||
Related: [`ViewModels/index.md`](../ViewModels/index.md) consume these;
|
Related: [`ViewModels/index.md`](../ViewModels/index.md) consume these;
|
||||||
[`Services/LayoutStore.cs`](../Services/LayoutStore.cs) persists `Scene`/`Source`.
|
[`Services/LayoutStore.cs`](../Services/LayoutStore.cs) persists `Scene`/`Source`.
|
||||||
|
|||||||
+130
-5
@@ -15,6 +15,9 @@ public class LayoutStore : IDisposable
|
|||||||
/// <summary>The app-wide webcam identity loaded with the last Load() (null = never picked).</summary>
|
/// <summary>The app-wide webcam identity loaded with the last Load() (null = never picked).</summary>
|
||||||
public Webcam? Webcam { get; private set; }
|
public Webcam? Webcam { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>The app-wide social bar config loaded with the last Load() (null = never defined).</summary>
|
||||||
|
public SocialsConfig? Socials { get; private set; }
|
||||||
|
|
||||||
public LayoutStore(string path)
|
public LayoutStore(string path)
|
||||||
{
|
{
|
||||||
ActivePath = path;
|
ActivePath = path;
|
||||||
@@ -104,6 +107,23 @@ public class LayoutStore : IDisposable
|
|||||||
PRIMARY KEY (SceneId, WebcamId)
|
PRIMARY KEY (SceneId, WebcamId)
|
||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS Socials (
|
||||||
|
Id TEXT PRIMARY KEY,
|
||||||
|
BarPosition TEXT NOT NULL DEFAULT 'Bottom',
|
||||||
|
BarJustify TEXT NOT NULL DEFAULT 'Center'
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS SocialEntry (
|
||||||
|
Id TEXT PRIMARY KEY,
|
||||||
|
SocialsId TEXT NOT NULL REFERENCES Socials(Id) ON DELETE CASCADE,
|
||||||
|
Service TEXT NOT NULL,
|
||||||
|
Handle TEXT NOT NULL,
|
||||||
|
ProfileUrl TEXT NOT NULL,
|
||||||
|
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
""",
|
||||||
};
|
};
|
||||||
foreach (var sql in statements)
|
foreach (var sql in statements)
|
||||||
{
|
{
|
||||||
@@ -114,11 +134,12 @@ public class LayoutStore : IDisposable
|
|||||||
MigrateSourceTable();
|
MigrateSourceTable();
|
||||||
MigrateWebcamConfigTable();
|
MigrateWebcamConfigTable();
|
||||||
MigrateSceneTable();
|
MigrateSceneTable();
|
||||||
|
MigrateSceneSocialBarColumn();
|
||||||
if (GetUserVersion() < 3)
|
if (GetUserVersion() < 3)
|
||||||
MigrateToV3();
|
MigrateToV3();
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = "PRAGMA user_version = 6;";
|
cmd.CommandText = "PRAGMA user_version = 7;";
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,16 +340,37 @@ public class LayoutStore : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v6 → v7: Scene gains HasSocialBar (the per-scene social-bar toggle).
|
||||||
|
// Defaults to 0 (off) — the creator adds it explicitly via the footer [+].
|
||||||
|
private void MigrateSceneSocialBarColumn()
|
||||||
|
{
|
||||||
|
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "PRAGMA table_info(Scene);";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
columns.Add(reader.GetString(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (columns.Contains("HasSocialBar")) return;
|
||||||
|
|
||||||
|
using var alter = _connection.CreateCommand();
|
||||||
|
alter.CommandText = "ALTER TABLE Scene ADD COLUMN HasSocialBar INTEGER NOT NULL DEFAULT 0;";
|
||||||
|
alter.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
public List<Scene> Load()
|
public List<Scene> Load()
|
||||||
{
|
{
|
||||||
Webcam = null;
|
Webcam = null;
|
||||||
|
Socials = null;
|
||||||
var scenes = new List<Scene>();
|
var scenes = new List<Scene>();
|
||||||
var sourcesByScene = new Dictionary<string, List<Source>>();
|
var sourcesByScene = new Dictionary<string, List<Source>>();
|
||||||
var configsByScene = new Dictionary<string, List<WebcamSceneConfig>>();
|
var configsByScene = new Dictionary<string, List<WebcamSceneConfig>>();
|
||||||
|
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackdrop FROM Scene ORDER BY SortOrder";
|
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar FROM Scene ORDER BY SortOrder";
|
||||||
using var reader = cmd.ExecuteReader();
|
using var reader = cmd.ExecuteReader();
|
||||||
while (reader.Read())
|
while (reader.Read())
|
||||||
{
|
{
|
||||||
@@ -339,6 +381,7 @@ public class LayoutStore : IDisposable
|
|||||||
IsHidden = reader.GetInt32(2) != 0,
|
IsHidden = reader.GetInt32(2) != 0,
|
||||||
IsChatScene = reader.GetInt32(3) != 0,
|
IsChatScene = reader.GetInt32(3) != 0,
|
||||||
HasBackdrop = reader.GetInt32(4) != 0,
|
HasBackdrop = reader.GetInt32(4) != 0,
|
||||||
|
HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,6 +401,35 @@ public class LayoutStore : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT Id, BarPosition, BarJustify FROM Socials LIMIT 1;";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
if (reader.Read())
|
||||||
|
{
|
||||||
|
Socials = new SocialsConfig
|
||||||
|
{
|
||||||
|
BarPosition = Enum.TryParse<SocialBarPosition>(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom,
|
||||||
|
BarJustify = Enum.TryParse<SocialBarJustify>(reader.GetString(2), out var just) ? just : SocialBarJustify.Center,
|
||||||
|
};
|
||||||
|
var socialsId = reader.GetString(0);
|
||||||
|
|
||||||
|
using var entryCmd = _connection.CreateCommand();
|
||||||
|
entryCmd.CommandText = "SELECT Service, Handle, ProfileUrl FROM SocialEntry WHERE SocialsId = $id ORDER BY SortOrder;";
|
||||||
|
entryCmd.Parameters.AddWithValue("$id", socialsId);
|
||||||
|
using var entryReader = entryCmd.ExecuteReader();
|
||||||
|
while (entryReader.Read())
|
||||||
|
{
|
||||||
|
Socials.Entries.Add(new SocialEntry
|
||||||
|
{
|
||||||
|
Service = Enum.TryParse<SocialService>(entryReader.GetString(0), out var svc) ? svc : SocialService.Link,
|
||||||
|
Handle = entryReader.GetString(1),
|
||||||
|
ProfileUrl = entryReader.GetString(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = """
|
cmd.CommandText = """
|
||||||
@@ -444,7 +516,7 @@ public class LayoutStore : IDisposable
|
|||||||
return scenes;
|
return scenes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Save(IEnumerable<Scene> scenes, Webcam? webcam)
|
public void Save(IEnumerable<Scene> scenes, Webcam? webcam, SocialsConfig? socials)
|
||||||
{
|
{
|
||||||
using var tx = _connection.BeginTransaction();
|
using var tx = _connection.BeginTransaction();
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
@@ -466,6 +538,18 @@ public class LayoutStore : IDisposable
|
|||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "DELETE FROM SocialEntry;";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "DELETE FROM Socials;";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = "DELETE FROM Scene;";
|
cmd.CommandText = "DELETE FROM Scene;";
|
||||||
cmd.Transaction = tx;
|
cmd.Transaction = tx;
|
||||||
@@ -475,8 +559,8 @@ public class LayoutStore : IDisposable
|
|||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = """
|
cmd.CommandText = """
|
||||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackdrop, SortOrder)
|
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar, SortOrder)
|
||||||
VALUES ($id, $name, $isHidden, $isChat, $hasBackdrop, $sort)
|
VALUES ($id, $name, $isHidden, $isChat, $hasBackdrop, $hasSocialBar, $sort)
|
||||||
""";
|
""";
|
||||||
cmd.Transaction = tx;
|
cmd.Transaction = tx;
|
||||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||||
@@ -484,6 +568,7 @@ public class LayoutStore : IDisposable
|
|||||||
var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
|
var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
|
||||||
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
|
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
|
||||||
var backdropP = cmd.Parameters.Add("$hasBackdrop", SqliteType.Integer);
|
var backdropP = cmd.Parameters.Add("$hasBackdrop", SqliteType.Integer);
|
||||||
|
var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer);
|
||||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||||
|
|
||||||
var sort = 0;
|
var sort = 0;
|
||||||
@@ -494,6 +579,7 @@ public class LayoutStore : IDisposable
|
|||||||
hiddenP.Value = scene.IsHidden ? 1 : 0;
|
hiddenP.Value = scene.IsHidden ? 1 : 0;
|
||||||
chatP.Value = scene.IsChatScene ? 1 : 0;
|
chatP.Value = scene.IsChatScene ? 1 : 0;
|
||||||
backdropP.Value = scene.HasBackdrop ? 1 : 0;
|
backdropP.Value = scene.HasBackdrop ? 1 : 0;
|
||||||
|
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
|
||||||
sortP.Value = sort++;
|
sortP.Value = sort++;
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
@@ -626,6 +712,45 @@ public class LayoutStore : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (socials != null && socials.Entries.Count > 0)
|
||||||
|
{
|
||||||
|
var socialsId = Guid.NewGuid().ToString();
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "INSERT INTO Socials (Id, BarPosition, BarJustify) VALUES ($id, $pos, $just);";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.Parameters.AddWithValue("$id", socialsId);
|
||||||
|
cmd.Parameters.AddWithValue("$pos", socials.BarPosition.ToString());
|
||||||
|
cmd.Parameters.AddWithValue("$just", socials.BarJustify.ToString());
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var entryCmd = _connection.CreateCommand();
|
||||||
|
entryCmd.CommandText = """
|
||||||
|
INSERT INTO SocialEntry (Id, SocialsId, Service, Handle, ProfileUrl, SortOrder)
|
||||||
|
VALUES ($id, $socialsId, $service, $handle, $url, $sort)
|
||||||
|
""";
|
||||||
|
entryCmd.Transaction = tx;
|
||||||
|
var eId = entryCmd.Parameters.Add("$id", SqliteType.Text);
|
||||||
|
var eSocialsId = entryCmd.Parameters.Add("$socialsId", SqliteType.Text);
|
||||||
|
var eService = entryCmd.Parameters.Add("$service", SqliteType.Text);
|
||||||
|
var eHandle = entryCmd.Parameters.Add("$handle", SqliteType.Text);
|
||||||
|
var eUrl = entryCmd.Parameters.Add("$url", SqliteType.Text);
|
||||||
|
var eSort = entryCmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||||
|
|
||||||
|
var entrySort = 0;
|
||||||
|
foreach (var entry in socials.Entries)
|
||||||
|
{
|
||||||
|
eId.Value = Guid.NewGuid().ToString();
|
||||||
|
eSocialsId.Value = socialsId;
|
||||||
|
eService.Value = entry.Service.ToString();
|
||||||
|
eHandle.Value = entry.Handle;
|
||||||
|
eUrl.Value = entry.ProfileUrl;
|
||||||
|
eSort.Value = entrySort++;
|
||||||
|
entryCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
|
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ytLive.Models;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
public sealed class SocialLookupResult
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
public string ProfileUrl { get; init; } = string.Empty;
|
||||||
|
public string Handle { get; init; } = string.Empty;
|
||||||
|
public string Error { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ISocialValidator
|
||||||
|
{
|
||||||
|
Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a social handle/URL by constructing the canonical profile URL and
|
||||||
|
/// issuing an HTTP GET — the "act of validation" the creator trusts before the
|
||||||
|
/// icon lands on their bar. Best-effort: some platforms return challenges/blocks
|
||||||
|
/// to HEAD requests, so a 200 OR a 301/302 redirect counts as "exists"; 404 or
|
||||||
|
/// connection failure = rejected. Never blind trust.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HttpSocialValidator : ISocialValidator
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public HttpSocialValidator(HttpClient? client = null)
|
||||||
|
{
|
||||||
|
_client = client ?? new HttpClient();
|
||||||
|
_client.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||||
|
"Mozilla/5.0 (compatible; ytLlive social validator)");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl)
|
||||||
|
{
|
||||||
|
var input = handleOrUrl.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
return new SocialLookupResult { Error = "Enter a handle or URL." };
|
||||||
|
|
||||||
|
var handle = input;
|
||||||
|
if (input.StartsWith("http://", System.StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
input.StartsWith("https://", System.StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var uri = new System.Uri(input);
|
||||||
|
handle = uri.AbsolutePath.Trim('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = SocialServiceIcons.CanonicalUrlFor(service, handle);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var resp = await _client.GetAsync(url, CancellationToken.None);
|
||||||
|
if (resp.IsSuccessStatusCode || (int)resp.StatusCode is 301 or 302 or 303 or 307 or 308)
|
||||||
|
return new SocialLookupResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
ProfileUrl = url,
|
||||||
|
Handle = handle.TrimStart('@'),
|
||||||
|
};
|
||||||
|
return new SocialLookupResult
|
||||||
|
{
|
||||||
|
Error = $"{service} returned {resp.StatusCode} for '{handle}'. The handle may be wrong or the page doesn't exist.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (System.Net.Http.HttpRequestException)
|
||||||
|
{
|
||||||
|
return new SocialLookupResult
|
||||||
|
{
|
||||||
|
Error = $"Couldn't reach {service} to validate '{handle}'. Check your connection and try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
return new SocialLookupResult { Error = ex.Message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ External-facing logic: YouTube API, persistence. See
|
|||||||
| `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) |
|
| `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) |
|
||||||
| `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) |
|
| `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) |
|
||||||
| `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
|
| `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
|
||||||
|
| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Constructor takes optional `HttpClient` for tests |
|
||||||
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Not yet constructed by the app (the encoder step wires it) |
|
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Not yet constructed by the app (the encoder step wires it) |
|
||||||
|
|
||||||
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
|
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
|||||||
11. ✅ **Scenes/sources UI** — add/reorder/rename, image + background overlays with move/resize/opacity/reuse
|
11. ✅ **Scenes/sources UI** — add/reorder/rename, image + background overlays with move/resize/opacity/reuse
|
||||||
12. ✅ **Audio UX shipped (UI)** — the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with the mic's sound meter + mute button + volume slider grouped CENTERED on the footer's top line, beneath the preview panel (meter: 288px, muted slate track with ruler graduations + muted yellow/red zone tints, green→yellow→red fill; mute = speaker icon → red do-not-symbol when muted, and the slider and speaker stay in sync (volume 0 ⇔ muted — sliding off flips the speaker to muted, sliding up from 0 clears it); mic volume defaults to 80%, muting zeroes the meter and restores the prior volume on unmute (which flashes the meter to the restored position ~300ms before it returns to the live level); the meter is a READ-ONLY realtime level display (fill = live level × volume — volume is a gain on ambient noise; while the slider is dragged the bar previews the slider position and bounces back to the live level on release, which is 0 with no input — clicking the meter does nothing), clicking the MIC label opens a microphone picker whose chosen source shows left-justified inside the meter bar (fill at 75% opacity so the name + ruler markings show through); slim dimensional slider — gradient track/fill, gloss-sphere thumb; the old flat pink 18px-filled one is gone), everything else on line 2 (bitrate/fps/dropped/duration/health left, quality + gear right) — the creator's only audio control, desktop/game audio is automatic (KISS rule)
|
12. ✅ **Audio UX shipped (UI)** — the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with the mic's sound meter + mute button + volume slider grouped CENTERED on the footer's top line, beneath the preview panel (meter: 288px, muted slate track with ruler graduations + muted yellow/red zone tints, green→yellow→red fill; mute = speaker icon → red do-not-symbol when muted, and the slider and speaker stay in sync (volume 0 ⇔ muted — sliding off flips the speaker to muted, sliding up from 0 clears it); mic volume defaults to 80%, muting zeroes the meter and restores the prior volume on unmute (which flashes the meter to the restored position ~300ms before it returns to the live level); the meter is a READ-ONLY realtime level display (fill = live level × volume — volume is a gain on ambient noise; while the slider is dragged the bar previews the slider position and bounces back to the live level on release, which is 0 with no input — clicking the meter does nothing), clicking the MIC label opens a microphone picker whose chosen source shows left-justified inside the meter bar (fill at 75% opacity so the name + ruler markings show through); slim dimensional slider — gradient track/fill, gloss-sphere thumb; the old flat pink 18px-filled one is gone), everything else on line 2 (bitrate/fps/dropped/duration/health left, quality + gear right) — the creator's only audio control, desktop/game audio is automatic (KISS rule)
|
||||||
13. ✅ The connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES)
|
13. ✅ The connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES)
|
||||||
14. ☐ **Social bar** — global resource like the audio meter; user provides handle/URL → app validates by looking up the social page → adds icon + handle to a single-line horizontal bar (top/bottom, LCR justify). Freemium: YT + 1 other. Premium: unlimited (no wrapping past 1 line). [+] adds to scene, [-] removes. Creates a socials resource per scene. (Work started Aug 11, lost in git incident — rebuild from scratch.)
|
14. ✅ **Social bar** — global resource like the audio meter; user provides handle/URL → app validates by looking up the social page (`HttpSocialValidator`) → adds icon + handle to a single-line horizontal bar (top/bottom, LCR justify). Freemium: YT + 1 other (`FreemiumSocialCap=2`). Premium: unlimited (flag seam, itch.io deferred). [+] adds to scene, [-] removes (`Scene.HasSocialBar`, schema v7). Footer has Social button + [+/-] on the meter line. 85 tests passing.
|
||||||
15. ☐ **Window capture** — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending
|
15. ☐ **Window capture** — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending
|
||||||
16. ☐ **Scene compositing** — the D3DImage/MediaElement preview compositor (this task's requirement 5; the output compositor ships as TASK 4 ship step 1)
|
16. ☐ **Scene compositing** — the D3DImage/MediaElement preview compositor (this task's requirement 5; the output compositor ships as TASK 4 ship step 1)
|
||||||
17. ☐ **Text source** — live text ("Starting soon", "Back in 5", handle, callout)
|
17. ☐ **Text source** — live text ("Starting soon", "Back in 5", handle, callout)
|
||||||
|
|||||||
+160
-1
@@ -4,6 +4,7 @@ using System.ComponentModel;
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
using System.Windows.Interop;
|
using System.Windows.Interop;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
@@ -75,6 +76,7 @@ public class MainViewModel : ViewModelBase
|
|||||||
private readonly CameraManager _cameraManager;
|
private readonly CameraManager _cameraManager;
|
||||||
private Webcam? _webcam;
|
private Webcam? _webcam;
|
||||||
private string? _webcamError;
|
private string? _webcamError;
|
||||||
|
private SocialsConfig? _socials;
|
||||||
private readonly IMicrophoneEnumerator _microphoneEnumerator;
|
private readonly IMicrophoneEnumerator _microphoneEnumerator;
|
||||||
|
|
||||||
// Screen backdrop: a permanent live capture (desktop/game) that every scene
|
// 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>
|
/// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
|
||||||
public bool CanChangeWebcam => _webcam != null;
|
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>
|
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
|
||||||
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
|
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
|
||||||
|
|
||||||
@@ -712,6 +758,8 @@ public class MainViewModel : ViewModelBase
|
|||||||
public ICommand SetBackdropDisplayCommand { get; }
|
public ICommand SetBackdropDisplayCommand { get; }
|
||||||
public ICommand ToggleMicMuteCommand { get; }
|
public ICommand ToggleMicMuteCommand { get; }
|
||||||
public ICommand OpenMicPickerCommand { get; }
|
public ICommand OpenMicPickerCommand { get; }
|
||||||
|
public ICommand OpenSocialDialogCommand { get; }
|
||||||
|
public ICommand ToggleSceneSocialBarCommand { get; }
|
||||||
public ICommand StartStreamCommand { get; }
|
public ICommand StartStreamCommand { get; }
|
||||||
public ICommand EndStreamCommand { get; }
|
public ICommand EndStreamCommand { get; }
|
||||||
public ICommand OpenSettingsCommand { get; }
|
public ICommand OpenSettingsCommand { get; }
|
||||||
@@ -766,6 +814,8 @@ public class MainViewModel : ViewModelBase
|
|||||||
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
|
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
|
||||||
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
|
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
|
||||||
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
|
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
|
||||||
|
OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
|
||||||
|
ToggleSceneSocialBarCommand = new RelayCommand(_ => ToggleSceneSocialBar(), _ => CanToggleSceneSocialBar);
|
||||||
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
|
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
|
||||||
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
|
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
|
||||||
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
|
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
|
||||||
@@ -900,6 +950,10 @@ public class MainViewModel : ViewModelBase
|
|||||||
UpdateBackdropImage();
|
UpdateBackdropImage();
|
||||||
ReacquireWebcam();
|
ReacquireWebcam();
|
||||||
ReacquireScreenCaptures();
|
ReacquireScreenCaptures();
|
||||||
|
_socials = _layoutStore.Socials;
|
||||||
|
OnPropertyChanged(nameof(HasSocials));
|
||||||
|
OnPropertyChanged(nameof(CanToggleSceneSocialBar));
|
||||||
|
OnPropertyChanged(nameof(SceneHasSocialBar));
|
||||||
ScheduleSave();
|
ScheduleSave();
|
||||||
AppLog.Write("LoadLayout end");
|
AppLog.Write("LoadLayout end");
|
||||||
}
|
}
|
||||||
@@ -1128,7 +1182,7 @@ public class MainViewModel : ViewModelBase
|
|||||||
_saveDebounce?.Stop();
|
_saveDebounce?.Stop();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_layoutStore.Save(Scenes, _webcam);
|
_layoutStore.Save(Scenes, _webcam, _socials);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1761,4 +1815,109 @@ public class MainViewModel : ViewModelBase
|
|||||||
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
|
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
|
||||||
LivePulseOpacity = LivePulseOpacity > 0.5 ? 0.35 : 1.0;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,14 +30,14 @@ public class LayoutStorePersistenceTests
|
|||||||
Width = 480,
|
Width = 480,
|
||||||
Height = 270,
|
Height = 270,
|
||||||
});
|
});
|
||||||
store.Save(new[] { scene }, webcam);
|
store.Save(new[] { scene }, webcam, null);
|
||||||
|
|
||||||
var reloaded = store.Load();
|
var reloaded = store.Load();
|
||||||
var config = Assert.Single(reloaded[0].Elements);
|
var config = Assert.Single(reloaded[0].Elements);
|
||||||
Assert.IsType<WebcamSceneConfig>(config);
|
Assert.IsType<WebcamSceneConfig>(config);
|
||||||
|
|
||||||
reloaded[0].Elements.RemoveAt(0);
|
reloaded[0].Elements.RemoveAt(0);
|
||||||
store.Save(reloaded, store.Webcam);
|
store.Save(reloaded, store.Webcam, null);
|
||||||
|
|
||||||
var afterDelete = store.Load();
|
var afterDelete = store.Load();
|
||||||
Assert.Empty(afterDelete[0].Elements);
|
Assert.Empty(afterDelete[0].Elements);
|
||||||
@@ -74,7 +74,7 @@ public class LayoutStorePersistenceTests
|
|||||||
};
|
};
|
||||||
var scene = new Scene { Name = "Starting" };
|
var scene = new Scene { Name = "Starting" };
|
||||||
scene.Elements.Add(backdrop);
|
scene.Elements.Add(backdrop);
|
||||||
store.Save(new[] { scene }, null);
|
store.Save(new[] { scene }, null, null);
|
||||||
|
|
||||||
var reloaded = store.Load();
|
var reloaded = store.Load();
|
||||||
var restored = Assert.IsType<Source>(Assert.Single(reloaded[0].Elements));
|
var restored = Assert.IsType<Source>(Assert.Single(reloaded[0].Elements));
|
||||||
@@ -102,7 +102,7 @@ public class LayoutStorePersistenceTests
|
|||||||
using var store = new LayoutStore(path);
|
using var store = new LayoutStore(path);
|
||||||
var on = new Scene { Name = "Live", HasBackdrop = true };
|
var on = new Scene { Name = "Live", HasBackdrop = true };
|
||||||
var off = new Scene { Name = "Ending", HasBackdrop = false };
|
var off = new Scene { Name = "Ending", HasBackdrop = false };
|
||||||
store.Save(new[] { on, off }, null);
|
store.Save(new[] { on, off }, null, null);
|
||||||
|
|
||||||
var reloaded = store.Load();
|
var reloaded = store.Load();
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ public class LayoutStorePersistenceTests
|
|||||||
RectWidth = 480,
|
RectWidth = 480,
|
||||||
RectHeight = 270,
|
RectHeight = 270,
|
||||||
});
|
});
|
||||||
store.Save(new[] { scene }, webcam);
|
store.Save(new[] { scene }, webcam, null);
|
||||||
|
|
||||||
var reloaded = store.Load();
|
var reloaded = store.Load();
|
||||||
var config = Assert.IsType<WebcamSceneConfig>(Assert.Single(reloaded[0].Elements));
|
var config = Assert.IsType<WebcamSceneConfig>(Assert.Single(reloaded[0].Elements));
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Xunit;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
public class SocialBarTests
|
||||||
|
{
|
||||||
|
private static string TempDbPath()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-social-test-{System.Guid.NewGuid():N}.db");
|
||||||
|
SqliteConnection.ClearAllPools();
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SocialsConfig_RoundTrip_PersistsEntriesAndBarSettings()
|
||||||
|
{
|
||||||
|
var path = TempDbPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var socials = new SocialsConfig
|
||||||
|
{
|
||||||
|
BarPosition = SocialBarPosition.Top,
|
||||||
|
BarJustify = SocialBarJustify.Left,
|
||||||
|
};
|
||||||
|
socials.Entries.Add(new SocialEntry
|
||||||
|
{
|
||||||
|
Service = SocialService.YouTube,
|
||||||
|
Handle = "mychannel",
|
||||||
|
ProfileUrl = "https://www.youtube.com/@mychannel",
|
||||||
|
});
|
||||||
|
socials.Entries.Add(new SocialEntry
|
||||||
|
{
|
||||||
|
Service = SocialService.Twitch,
|
||||||
|
Handle = "streamer123",
|
||||||
|
ProfileUrl = "https://www.twitch.tv/streamer123",
|
||||||
|
});
|
||||||
|
|
||||||
|
using (var store = new LayoutStore(path))
|
||||||
|
{
|
||||||
|
var scene = new Scene { Name = "Live" };
|
||||||
|
scene.HasSocialBar = true;
|
||||||
|
store.Save(new[] { scene }, null, socials);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var store = new LayoutStore(path))
|
||||||
|
{
|
||||||
|
var scenes = store.Load();
|
||||||
|
Assert.NotNull(store.Socials);
|
||||||
|
Assert.Equal(2, store.Socials!.Entries.Count);
|
||||||
|
Assert.Equal(SocialService.YouTube, store.Socials.Entries[0].Service);
|
||||||
|
Assert.Equal("mychannel", store.Socials.Entries[0].Handle);
|
||||||
|
Assert.Equal("https://www.youtube.com/@mychannel", store.Socials.Entries[0].ProfileUrl);
|
||||||
|
Assert.Equal(SocialService.Twitch, store.Socials.Entries[1].Service);
|
||||||
|
Assert.Equal(SocialBarPosition.Top, store.Socials.BarPosition);
|
||||||
|
Assert.Equal(SocialBarJustify.Left, store.Socials.BarJustify);
|
||||||
|
Assert.True(scenes[0].HasSocialBar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SqliteConnection.ClearAllPools();
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SocialsConfig_EmptyEntries_NotPersisted()
|
||||||
|
{
|
||||||
|
var path = TempDBPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var socials = new SocialsConfig();
|
||||||
|
using (var store = new LayoutStore(path))
|
||||||
|
{
|
||||||
|
store.Save(new[] { new Scene { Name = "Live" } }, null, socials);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var store = new LayoutStore(path))
|
||||||
|
{
|
||||||
|
Assert.Null(store.Socials);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SqliteConnection.ClearAllPools();
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SocialServiceIcons_CanonicalUrlFor_AllServices()
|
||||||
|
{
|
||||||
|
Assert.Equal("https://www.youtube.com/@test", SocialServiceIcons.CanonicalUrlFor(SocialService.YouTube, "test"));
|
||||||
|
Assert.Equal("https://www.youtube.com/@test", SocialServiceIcons.CanonicalUrlFor(SocialService.YouTube, "@test"));
|
||||||
|
Assert.Equal("https://x.com/user", SocialServiceIcons.CanonicalUrlFor(SocialService.X, "user"));
|
||||||
|
Assert.Equal("https://github.com/dev", SocialServiceIcons.CanonicalUrlFor(SocialService.GitHub, "dev"));
|
||||||
|
Assert.Equal("https://example.com", SocialServiceIcons.CanonicalUrlFor(SocialService.Website, "example.com"));
|
||||||
|
Assert.Equal("https://example.com", SocialServiceIcons.CanonicalUrlFor(SocialService.Website, "https://example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SocialServiceIcons_InitialsAndColor_AllServices()
|
||||||
|
{
|
||||||
|
Assert.Equal("YT", SocialServiceIcons.InitialsFor(SocialService.YouTube));
|
||||||
|
Assert.Equal("#FF0000", SocialServiceIcons.ColorFor(SocialService.YouTube));
|
||||||
|
Assert.Equal("TW", SocialServiceIcons.InitialsFor(SocialService.Twitch));
|
||||||
|
Assert.NotEmpty(SocialServiceIcons.InitialsFor(SocialService.Website));
|
||||||
|
Assert.NotEmpty(SocialServiceIcons.ColorFor(SocialService.Website));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TempDBPath() => TempDbPath();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user