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:
+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>
|
||||
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);";
|
||||
|
||||
Reference in New Issue
Block a user