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
+130 -5
View File
@@ -15,6 +15,9 @@ public class LayoutStore : IDisposable
/// <summary>The app-wide webcam identity loaded with the last Load() (null = never picked).</summary>
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)
{
ActivePath = path;
@@ -104,6 +107,23 @@ public class LayoutStore : IDisposable
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)
{
@@ -114,11 +134,12 @@ public class LayoutStore : IDisposable
MigrateSourceTable();
MigrateWebcamConfigTable();
MigrateSceneTable();
MigrateSceneSocialBarColumn();
if (GetUserVersion() < 3)
MigrateToV3();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA user_version = 6;";
cmd.CommandText = "PRAGMA user_version = 7;";
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()
{
Webcam = null;
Socials = null;
var scenes = new List<Scene>();
var sourcesByScene = new Dictionary<string, List<Source>>();
var configsByScene = new Dictionary<string, List<WebcamSceneConfig>>();
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();
while (reader.Read())
{
@@ -339,6 +381,7 @@ public class LayoutStore : IDisposable
IsHidden = reader.GetInt32(2) != 0,
IsChatScene = reader.GetInt32(3) != 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())
{
cmd.CommandText = """
@@ -444,7 +516,7 @@ public class LayoutStore : IDisposable
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 cmd = _connection.CreateCommand())
@@ -466,6 +538,18 @@ public class LayoutStore : IDisposable
cmd.ExecuteNonQuery();
}
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.Transaction = tx;
@@ -475,8 +559,8 @@ public class LayoutStore : IDisposable
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackdrop, SortOrder)
VALUES ($id, $name, $isHidden, $isChat, $hasBackdrop, $sort)
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar, SortOrder)
VALUES ($id, $name, $isHidden, $isChat, $hasBackdrop, $hasSocialBar, $sort)
""";
cmd.Transaction = tx;
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 chatP = cmd.Parameters.Add("$isChat", 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 sort = 0;
@@ -494,6 +579,7 @@ public class LayoutStore : IDisposable
hiddenP.Value = scene.IsHidden ? 1 : 0;
chatP.Value = scene.IsChatScene ? 1 : 0;
backdropP.Value = scene.HasBackdrop ? 1 : 0;
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
sortP.Value = sort++;
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())
{
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";