diff --git a/Services/LayoutStore.Assets.cs b/Services/LayoutStore.Assets.cs
new file mode 100644
index 0000000..11e66ec
--- /dev/null
+++ b/Services/LayoutStore.Assets.cs
@@ -0,0 +1,41 @@
+using System.Security.Cryptography;
+using Microsoft.Data.Sqlite;
+
+namespace ytLive.Services;
+
+public partial class LayoutStore : IDisposable
+{
+ public byte[]? GetAssetBytes(string assetId)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT Data FROM Asset WHERE Id = $id";
+ cmd.Parameters.AddWithValue("$id", assetId);
+ return cmd.ExecuteScalar() as byte[];
+ }
+
+ public string UpsertAsset(byte[] data, int pixelWidth, int pixelHeight)
+ {
+ var hash = Convert.ToHexString(SHA256.HashData(data));
+ var id = Guid.NewGuid().ToString();
+
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO Asset (Id, Hash, Data, PixelWidth, PixelHeight)
+ VALUES ($id, $hash, $data, $w, $h)
+ ON CONFLICT(Hash) DO NOTHING;
+ """;
+ cmd.Parameters.AddWithValue("$id", id);
+ cmd.Parameters.AddWithValue("$hash", hash);
+ cmd.Parameters.AddWithValue("$data", data);
+ cmd.Parameters.AddWithValue("$w", pixelWidth);
+ cmd.Parameters.AddWithValue("$h", pixelHeight);
+ cmd.ExecuteNonQuery();
+
+ cmd.CommandText = "SELECT Id FROM Asset WHERE Hash = $hash;";
+ return (string)cmd.ExecuteScalar()!;
+ }
+
+ // ─── License (TASK 10 — Polar billing) ───
+
+ /// The saved license key, or null if never entered.
+}
diff --git a/Services/LayoutStore.Load.cs b/Services/LayoutStore.Load.cs
new file mode 100644
index 0000000..907cc77
--- /dev/null
+++ b/Services/LayoutStore.Load.cs
@@ -0,0 +1,221 @@
+using Microsoft.Data.Sqlite;
+using ytLive.Models;
+
+namespace ytLive.Services;
+
+public partial class LayoutStore : IDisposable
+{
+ public List Load()
+ {
+ Webcam = null;
+ Socials = null;
+ Music = null;
+ var scenes = new List();
+ var sourcesByScene = new Dictionary>();
+ var configsByScene = new Dictionary>();
+
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar FROM Scene ORDER BY SortOrder";
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ {
+ scenes.Add(new Scene
+ {
+ Id = reader.GetString(0),
+ Name = reader.GetString(1),
+ IsHidden = reader.GetInt32(2) != 0,
+ IsChatScene = reader.GetInt32(3) != 0,
+ HasBackground = reader.GetInt32(4) != 0,
+ HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
+ });
+ }
+ }
+
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "SELECT Id, DeviceId, Name FROM Webcam;";
+ using var reader = cmd.ExecuteReader();
+ if (reader.Read())
+ {
+ Webcam = new Webcam
+ {
+ Id = reader.GetString(0),
+ DeviceId = reader.GetString(1),
+ Name = reader.GetString(2),
+ };
+ }
+ }
+
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "SELECT Id, BarPosition, BarEnabled FROM Socials LIMIT 1;";
+ using var reader = cmd.ExecuteReader();
+ if (reader.Read())
+ {
+ Socials = new SocialsConfig
+ {
+ BarPosition = Enum.TryParse(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom,
+ BarEnabled = reader.FieldCount > 2 && !reader.IsDBNull(2) && reader.GetInt32(2) != 0,
+ };
+ var socialsId = reader.GetString(0);
+
+ using var entryCmd = _connection.CreateCommand();
+ entryCmd.CommandText = "SELECT Service, Handle, ProfileUrl, Software 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(entryReader.GetString(0), out var svc) ? svc : SocialService.Link,
+ Handle = entryReader.GetString(1),
+ ProfileUrl = entryReader.GetString(2),
+ FediverseSoftware = entryReader.FieldCount > 3 && !entryReader.IsDBNull(3) ? entryReader.GetString(3) : null,
+ });
+ }
+ }
+ }
+
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "SELECT TrackPath, IsEnabled FROM Music LIMIT 1;";
+ using var reader = cmd.ExecuteReader();
+ if (reader.Read())
+ {
+ Music = new Music
+ {
+ TrackPath = reader.GetString(0),
+ IsEnabled = reader.GetInt32(1) != 0,
+ };
+ }
+ }
+
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = """
+ SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
+ X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
+ IsBackground, CaptureKey, ShowDesktop,
+ ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
+ ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
+ WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState
+ FROM Source ORDER BY SortOrder
+ """;
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ {
+ var sceneId = reader.GetString(1);
+ var type = Enum.Parse(reader.GetString(3));
+ var source = new Source
+ {
+ Id = reader.GetString(0),
+ AssetId = reader.IsDBNull(2) ? null : reader.GetString(2),
+ Type = type,
+ Name = reader.GetString(4),
+ IsEnabled = reader.GetInt32(5) != 0,
+ X = reader.GetDouble(6),
+ Y = reader.GetDouble(7),
+ Width = reader.GetDouble(8),
+ Height = reader.GetDouble(9),
+ Opacity = reader.GetDouble(10),
+ MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
+ ClipShape = Enum.TryParse(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
+ IsMirrored = reader.GetInt32(13) != 0,
+ // Rows saved before the Type setter derived the flag carry
+ // IsBackground=0; derive from Type, keeping the persisted bit
+ // for legacy DisplayCapture backdrops.
+ IsBackground = type == SourceType.Background || reader.GetInt32(14) != 0,
+ CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
+ ShowDesktop = reader.GetInt32(16) != 0,
+ ChatFontSize = reader.IsDBNull(17) ? 16 : reader.GetDouble(17),
+ ChatFontColor = reader.IsDBNull(18) ? "#FFFFFF" : reader.GetString(18),
+ ChatBackgroundColor = reader.IsDBNull(19) ? "#80000000" : reader.GetString(19),
+ ChatMaxMessages = reader.IsDBNull(20) ? 5 : reader.GetInt32(20),
+ ChatShowTimestamps = reader.IsDBNull(21) ? true : reader.GetInt32(21) != 0,
+ ChatShowBadges = reader.IsDBNull(22) ? true : reader.GetInt32(22) != 0,
+ ChatMemberColor = reader.IsDBNull(23) ? "#e94560" : reader.GetString(23),
+ ChatSuperChatColor = reader.IsDBNull(24) ? "#eab308" : reader.GetString(24),
+ WebUri = reader.IsDBNull(25) ? null : reader.GetString(25),
+ MediaPath = reader.IsDBNull(26) ? null : reader.GetString(26),
+ MediaIsLooping = reader.IsDBNull(27) ? false : reader.GetInt32(27) != 0,
+ MediaVolume = reader.IsDBNull(28) ? 1.0 : reader.GetDouble(28),
+ MediaPlaybackState = reader.IsDBNull(29)
+ ? MediaPlaybackState.Stopped
+ : Enum.TryParse(reader.GetString(29), out var ps)
+ ? ps
+ : MediaPlaybackState.Stopped,
+ };
+ if (!sourcesByScene.TryGetValue(sceneId, out var list))
+ sourcesByScene[sceneId] = list = new List();
+ list.Add(source);
+ }
+ }
+
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = """
+ SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
+ ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
+ RectWidth, RectHeight
+ FROM WebcamSceneConfig ORDER BY SortOrder
+ """;
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ {
+ var sceneId = reader.GetString(0);
+ var config = new WebcamSceneConfig
+ {
+ WebcamId = reader.GetString(1),
+ Name = Webcam?.Name ?? "Webcam",
+ IsVisible = reader.GetInt32(2) != 0,
+ X = reader.GetDouble(3),
+ Y = reader.GetDouble(4),
+ Width = reader.GetDouble(5),
+ Height = reader.GetDouble(6),
+ Opacity = reader.GetDouble(7),
+ ClipShape = Enum.TryParse(reader.GetString(8), out var clip) ? clip : ClipShape.Traditional,
+ IsMirrored = reader.GetInt32(9) != 0,
+ BorderColor = reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
+ BorderOpacity = reader.IsDBNull(11) ? 1.0 : reader.GetDouble(11),
+ BorderWidth = reader.IsDBNull(12) ? 0 : reader.GetInt32(12),
+ BorderAnimation = Enum.TryParse(reader.GetString(13), out var anim) ? anim : BorderAnimation.None,
+ RectWidth = reader.IsDBNull(14) ? null : reader.GetDouble(14),
+ RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15),
+ };
+ if (!configsByScene.TryGetValue(sceneId, out var list))
+ configsByScene[sceneId] = list = new List();
+ list.Add(config);
+ }
+ }
+
+ foreach (var scene in scenes)
+ {
+ if (sourcesByScene.TryGetValue(scene.Id, out var sources))
+ foreach (var source in sources)
+ scene.Elements.Add(source);
+ if (configsByScene.TryGetValue(scene.Id, out var configs))
+ foreach (var config in configs)
+ scene.Elements.Add(config);
+
+ var bg = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground);
+ if (bg != null)
+ {
+ // Load from source-keyed settings (new) or scene-keyed (legacy migration)
+ var path = GetSetting($"BackgroundPath_{bg.Id}");
+ if (string.IsNullOrWhiteSpace(path))
+ path = GetSetting($"BackgroundPath_{scene.Id}");
+ if (!string.IsNullOrWhiteSpace(path))
+ bg.CustomBackgroundPath = path;
+
+ var useDefault = GetSetting($"BackgroundUseDefault_{bg.Id}");
+ if (string.IsNullOrWhiteSpace(useDefault))
+ useDefault = GetSetting($"BackgroundUseDefault_{scene.Id}");
+ if (useDefault == "0")
+ bg.UseDefaultBackground = false;
+ }
+ }
+
+ return scenes;
+ }
+}
diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs
new file mode 100644
index 0000000..c102e8f
--- /dev/null
+++ b/Services/LayoutStore.Migrations.cs
@@ -0,0 +1,495 @@
+using Microsoft.Data.Sqlite;
+using ytLive.Models;
+
+namespace ytLive.Services;
+
+public partial class LayoutStore : IDisposable
+{
+ private void EnsureSchema()
+ {
+ string[] statements =
+ {
+ """
+ CREATE TABLE IF NOT EXISTS Scene (
+ Id TEXT PRIMARY KEY,
+ Name TEXT NOT NULL,
+ IsHidden INTEGER NOT NULL DEFAULT 0,
+ IsChatScene INTEGER NOT NULL DEFAULT 0,
+ HasBackground INTEGER NOT NULL DEFAULT 1,
+ SortOrder INTEGER NOT NULL DEFAULT 0
+ );
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS Asset (
+ Id TEXT PRIMARY KEY,
+ Hash TEXT NOT NULL UNIQUE,
+ Data BLOB NOT NULL,
+ PixelWidth INTEGER NOT NULL DEFAULT 0,
+ PixelHeight INTEGER NOT NULL DEFAULT 0
+ );
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS Source (
+ Id TEXT PRIMARY KEY,
+ SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
+ AssetId TEXT REFERENCES Asset(Id),
+ Type TEXT NOT NULL,
+ Name TEXT NOT NULL,
+ IsEnabled INTEGER NOT NULL DEFAULT 1,
+ X REAL NOT NULL DEFAULT 0,
+ Y REAL NOT NULL DEFAULT 0,
+ Width REAL NOT NULL DEFAULT 0,
+ Height REAL NOT NULL DEFAULT 0,
+ Opacity REAL NOT NULL DEFAULT 1,
+ MonitorIndex INTEGER,
+ DeviceId TEXT,
+ ClipShape TEXT NOT NULL DEFAULT 'Traditional',
+ IsMirrored INTEGER NOT NULL DEFAULT 0,
+ IsBackground INTEGER NOT NULL DEFAULT 0,
+ CaptureKey TEXT,
+ SortOrder INTEGER NOT NULL DEFAULT 0
+ );
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS Webcam (
+ Id TEXT PRIMARY KEY,
+ DeviceId TEXT NOT NULL UNIQUE,
+ Name TEXT NOT NULL
+ );
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS WebcamSceneConfig (
+ SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
+ WebcamId TEXT NOT NULL REFERENCES Webcam(Id) ON DELETE CASCADE,
+ IsVisible INTEGER NOT NULL DEFAULT 1,
+ X REAL NOT NULL DEFAULT 0,
+ Y REAL NOT NULL DEFAULT 0,
+ Width REAL NOT NULL DEFAULT 0,
+ Height REAL NOT NULL DEFAULT 0,
+ Opacity REAL NOT NULL DEFAULT 1,
+ ClipShape TEXT NOT NULL DEFAULT 'Traditional',
+ IsMirrored INTEGER NOT NULL DEFAULT 0,
+ RectWidth REAL,
+ RectHeight REAL,
+ BorderColor TEXT,
+ BorderOpacity REAL NOT NULL DEFAULT 1,
+ BorderWidth INTEGER NOT NULL DEFAULT 0,
+ BorderAnimation TEXT NOT NULL DEFAULT 'None',
+ SortOrder INTEGER NOT NULL DEFAULT 0,
+ 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
+ );
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS Music (
+ Id TEXT PRIMARY KEY,
+ TrackPath TEXT NOT NULL,
+ IsEnabled INTEGER NOT NULL DEFAULT 0
+ );
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS Settings (
+ Key TEXT PRIMARY KEY,
+ Value TEXT NOT NULL
+ );
+ """,
+ };
+ foreach (var sql in statements)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = sql;
+ cmd.ExecuteNonQuery();
+ }
+ MigrateSourceTable();
+ MigrateWebcamConfigTable();
+ MigrateSceneTable();
+ MigrateSceneSocialBarColumn();
+ MigrateSocialsTable();
+ MigrateSocialEntryTable();
+ if (GetUserVersion() < 3)
+ MigrateToV3();
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "PRAGMA user_version = 9;";
+ cmd.ExecuteNonQuery();
+ }
+ }
+
+
+ // v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
+ // CREATE TABLE IF NOT EXISTS doesn't touch existing tables, so pre-v2 DBs
+ // get the columns here instead.
+ private void MigrateSourceTable()
+ {
+ var columns = new HashSet(StringComparer.OrdinalIgnoreCase);
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "PRAGMA table_info(Source);";
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ columns.Add(reader.GetString(1));
+ }
+
+ if (!columns.Contains("ClipShape"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ClipShape TEXT NOT NULL DEFAULT 'Traditional';";
+ cmd.ExecuteNonQuery();
+ }
+
+ if (!columns.Contains("IsMirrored"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsMirrored INTEGER NOT NULL DEFAULT 0;";
+ cmd.ExecuteNonQuery();
+ }
+
+ if (columns.Contains("IsBackdrop"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source RENAME COLUMN IsBackdrop TO IsBackground;";
+ cmd.ExecuteNonQuery();
+ }
+ else if (!columns.Contains("IsBackground"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBackground INTEGER NOT NULL DEFAULT 0;";
+ cmd.ExecuteNonQuery();
+ }
+
+ if (!columns.Contains("CaptureKey"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN CaptureKey TEXT;";
+ cmd.ExecuteNonQuery();
+ }
+
+ // TASK 3.18: ChatBox overlay source
+ if (!columns.Contains("ChatFontSize"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontSize REAL NOT NULL DEFAULT 16;";
+ cmd.ExecuteNonQuery();
+ }
+ if (!columns.Contains("ChatFontColor"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontColor TEXT NOT NULL DEFAULT '#FFFFFF';";
+ cmd.ExecuteNonQuery();
+ }
+ if (!columns.Contains("ChatBackgroundColor"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatBackgroundColor TEXT NOT NULL DEFAULT '#80000000';";
+ cmd.ExecuteNonQuery();
+ }
+ if (!columns.Contains("ChatMaxMessages"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMaxMessages INTEGER NOT NULL DEFAULT 5;";
+ cmd.ExecuteNonQuery();
+ }
+ if (!columns.Contains("ChatShowTimestamps"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowTimestamps INTEGER NOT NULL DEFAULT 1;";
+ cmd.ExecuteNonQuery();
+ }
+ if (!columns.Contains("ChatShowBadges"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowBadges INTEGER NOT NULL DEFAULT 1;";
+ cmd.ExecuteNonQuery();
+ }
+ if (!columns.Contains("ChatMemberColor"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMemberColor TEXT NOT NULL DEFAULT '#e94560';";
+ cmd.ExecuteNonQuery();
+ }
+ if (!columns.Contains("ChatSuperChatColor"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatSuperChatColor TEXT NOT NULL DEFAULT '#eab308';";
+ cmd.ExecuteNonQuery();
+ }
+ if (!columns.Contains("ShowDesktop"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ShowDesktop INTEGER NOT NULL DEFAULT 0;";
+ cmd.ExecuteNonQuery();
+ }
+
+ if (!columns.Contains("WebUri"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN WebUri TEXT;";
+ cmd.ExecuteNonQuery();
+ }
+
+ // TASK 21: Media source (video/audio file playback)
+ if (!columns.Contains("MediaPath"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPath TEXT;";
+ cmd.ExecuteNonQuery();
+ }
+ if (!columns.Contains("MediaIsLooping"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaIsLooping INTEGER NOT NULL DEFAULT 0;";
+ cmd.ExecuteNonQuery();
+ }
+ if (!columns.Contains("MediaVolume"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaVolume REAL NOT NULL DEFAULT 1;";
+ cmd.ExecuteNonQuery();
+ }
+ if (!columns.Contains("MediaPlaybackState"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPlaybackState TEXT NOT NULL DEFAULT 'Stopped';";
+ cmd.ExecuteNonQuery();
+ }
+
+ // Rename legacy "Backdrop" display name to "Background" in existing rows
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "UPDATE Source SET Name = 'Background' WHERE Name = 'Backdrop';";
+ cmd.ExecuteNonQuery();
+ }
+ }
+
+ // v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
+ // so a reloaded Round webcam restores its aspect on toggle-back).
+ private void MigrateWebcamConfigTable()
+ {
+ var columns = new HashSet(StringComparer.OrdinalIgnoreCase);
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "PRAGMA table_info(WebcamSceneConfig);";
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ columns.Add(reader.GetString(1));
+ }
+
+ if (!columns.Contains("RectWidth"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectWidth REAL;";
+ cmd.ExecuteNonQuery();
+ }
+
+ if (!columns.Contains("RectHeight"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectHeight REAL;";
+ cmd.ExecuteNonQuery();
+ }
+ }
+
+ // v2 → v3: the webcam leaves the Source table. Any webcam Source rows become
+ // one Webcam identity (first row — one camera app-wide) + a WebcamSceneConfig
+ // per scene that had one, then the webcam rows are deleted. No backfill:
+ // scenes without the webcam stay webcam-free.
+ private void MigrateToV3()
+ {
+ var rows = new List<(string Id, string SceneId, string Name, double X, double Y, double W, double H, double Opacity, string DeviceId, string ClipShape, bool IsMirrored)>();
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = """
+ SELECT Id, SceneId, Name, X, Y, Width, Height, Opacity, DeviceId, ClipShape, IsMirrored
+ FROM Source WHERE Type = 'Webcam' ORDER BY SortOrder;
+ """;
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ {
+ rows.Add((
+ reader.GetString(0),
+ reader.GetString(1),
+ reader.GetString(2),
+ reader.GetDouble(3),
+ reader.GetDouble(4),
+ reader.GetDouble(5),
+ reader.GetDouble(6),
+ reader.GetDouble(7),
+ reader.IsDBNull(8) ? string.Empty : reader.GetString(8),
+ reader.GetString(9),
+ reader.GetInt32(10) != 0));
+ }
+ }
+ if (rows.Count == 0) return;
+
+ using var tx = _connection.BeginTransaction();
+ var webcamId = Guid.NewGuid().ToString();
+
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
+ cmd.Transaction = tx;
+ cmd.Parameters.AddWithValue("$id", webcamId);
+ cmd.Parameters.AddWithValue("$device", rows[0].DeviceId);
+ cmd.Parameters.AddWithValue("$name", rows[0].Name);
+ cmd.ExecuteNonQuery();
+ }
+
+ foreach (var row in rows)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO WebcamSceneConfig
+ (SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
+ ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation, SortOrder)
+ VALUES ($sceneId, $webcamId, 1, $x, $y, $w, $h, $opacity,
+ $clip, $mirrored, NULL, 1, 0, 'None', 0);
+ """;
+ cmd.Transaction = tx;
+ cmd.Parameters.AddWithValue("$sceneId", row.SceneId);
+ cmd.Parameters.AddWithValue("$webcamId", webcamId);
+ cmd.Parameters.AddWithValue("$x", row.X);
+ cmd.Parameters.AddWithValue("$y", row.Y);
+ cmd.Parameters.AddWithValue("$w", row.W);
+ cmd.Parameters.AddWithValue("$h", row.H);
+ cmd.Parameters.AddWithValue("$opacity", row.Opacity);
+ cmd.Parameters.AddWithValue("$clip", row.ClipShape);
+ cmd.Parameters.AddWithValue("$mirrored", row.IsMirrored ? 1 : 0);
+ cmd.ExecuteNonQuery();
+ }
+
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "DELETE FROM Source WHERE Type = 'Webcam';";
+ cmd.Transaction = tx;
+ cmd.ExecuteNonQuery();
+ }
+ tx.Commit();
+ }
+
+ // v5 → v6: Scene gains HasBackground. Added columns default to true; the
+ // ONE-TIME backfill (runs only when the column is first added) turns every
+ // non-Live canonical scene off and drops their background sources — the fix for
+ // DBs saved before the Live-only policy. MainViewModel.NormalizeBackgrounds
+ // re-runs the same normalization on every load for DBs that miss the backfill.
+ private void MigrateSceneTable()
+ {
+ var columns = new HashSet(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("HasBackdrop"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Scene RENAME COLUMN HasBackdrop TO HasBackground;";
+ cmd.ExecuteNonQuery();
+ }
+ else if (columns.Contains("HasBackground"))
+ {
+ return;
+ }
+
+ if (!columns.Contains("HasBackground"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Scene ADD COLUMN HasBackground INTEGER NOT NULL DEFAULT 1;";
+ cmd.ExecuteNonQuery();
+ }
+
+ const string noBackground = "('starting', 'brb', 'chat', 'ending')";
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = $"UPDATE Scene SET HasBackground = 0 WHERE LOWER(TRIM(Name)) IN {noBackground};";
+ cmd.ExecuteNonQuery();
+ }
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = $"""
+ DELETE FROM Source
+ WHERE IsBackground = 1 AND SceneId IN
+ (SELECT Id FROM Scene WHERE LOWER(TRIM(Name)) IN {noBackground});
+ """;
+ cmd.ExecuteNonQuery();
+ }
+ }
+
+ // 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(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();
+ }
+
+ // v7 → v8: Socials gains BarEnabled (the dialog's show/hide switch). The old
+ // BarJustify column stays for back-compat but is no longer read or written.
+ private void MigrateSocialsTable()
+ {
+ var columns = new HashSet(StringComparer.OrdinalIgnoreCase);
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "PRAGMA table_info(Socials);";
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ columns.Add(reader.GetString(1));
+ }
+
+ if (columns.Contains("BarEnabled")) return;
+
+ using var alter = _connection.CreateCommand();
+ alter.CommandText = "ALTER TABLE Socials ADD COLUMN BarEnabled INTEGER NOT NULL DEFAULT 1;";
+ alter.ExecuteNonQuery();
+ }
+
+ // v8 → v9: SocialEntry gains Software (the fediverse instance's nodeinfo
+ // software name) so a reloaded entry still shows the right service logo.
+ private void MigrateSocialEntryTable()
+ {
+ var columns = new HashSet(StringComparer.OrdinalIgnoreCase);
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "PRAGMA table_info(SocialEntry);";
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ columns.Add(reader.GetString(1));
+ }
+
+ if (columns.Contains("Software")) return;
+
+ using var alter = _connection.CreateCommand();
+ alter.CommandText = "ALTER TABLE SocialEntry ADD COLUMN Software TEXT;";
+ alter.ExecuteNonQuery();
+ }
+}
diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs
new file mode 100644
index 0000000..93c50b9
--- /dev/null
+++ b/Services/LayoutStore.Save.cs
@@ -0,0 +1,339 @@
+using Microsoft.Data.Sqlite;
+using ytLive.Models;
+
+namespace ytLive.Services;
+
+public partial class LayoutStore : IDisposable
+{
+ public void Save(IEnumerable scenes, Webcam? webcam, SocialsConfig? socials, Music? music = null)
+ {
+ using var tx = _connection.BeginTransaction();
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "DELETE FROM WebcamSceneConfig;";
+ cmd.Transaction = tx;
+ cmd.ExecuteNonQuery();
+ }
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "DELETE FROM Source;";
+ cmd.Transaction = tx;
+ cmd.ExecuteNonQuery();
+ }
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "DELETE FROM Webcam;";
+ cmd.Transaction = tx;
+ 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 Music;";
+ cmd.Transaction = tx;
+ cmd.ExecuteNonQuery();
+ }
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "DELETE FROM Scene;";
+ cmd.Transaction = tx;
+ cmd.ExecuteNonQuery();
+ }
+
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = """
+ INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, SortOrder)
+ VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $sort)
+ """;
+ cmd.Transaction = tx;
+ var idP = cmd.Parameters.Add("$id", SqliteType.Text);
+ var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
+ var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
+ var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
+ var backgroundP = cmd.Parameters.Add("$hasBackground", SqliteType.Integer);
+ var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer);
+ var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
+
+ var sort = 0;
+ foreach (var scene in scenes)
+ {
+ idP.Value = scene.Id;
+ nameP.Value = scene.Name;
+ hiddenP.Value = scene.IsHidden ? 1 : 0;
+ chatP.Value = scene.IsChatScene ? 1 : 0;
+ backgroundP.Value = scene.HasBackground ? 1 : 0;
+ socialBarP.Value = scene.HasSocialBar ? 1 : 0;
+ sortP.Value = sort++;
+ cmd.ExecuteNonQuery();
+ }
+ }
+
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = """
+ INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
+ X, Y, Width, Height, Opacity, MonitorIndex,
+ ClipShape, IsMirrored, IsBackground, CaptureKey, ShowDesktop, SortOrder,
+ ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
+ ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
+ WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState)
+ VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
+ $x, $y, $w, $h, $opacity, $monitor,
+ $clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
+ $chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs,
+ $chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
+ $webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState)
+ """;
+ cmd.Transaction = tx;
+ var idP = cmd.Parameters.Add("$id", SqliteType.Text);
+ var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
+ var assetIdP = cmd.Parameters.Add("$assetId", SqliteType.Text);
+ var typeP = cmd.Parameters.Add("$type", SqliteType.Text);
+ var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
+ var enabledP = cmd.Parameters.Add("$isEnabled", SqliteType.Integer);
+ var xP = cmd.Parameters.Add("$x", SqliteType.Real);
+ var yP = cmd.Parameters.Add("$y", SqliteType.Real);
+ var wP = cmd.Parameters.Add("$w", SqliteType.Real);
+ var hP = cmd.Parameters.Add("$h", SqliteType.Real);
+ var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
+ var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
+ var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
+ var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
+ var isBackgroundP = cmd.Parameters.Add("$isBackground", SqliteType.Integer);
+ var captureKeyP = cmd.Parameters.Add("$captureKey", SqliteType.Text);
+ var showDesktopP = cmd.Parameters.Add("$showDesktop", SqliteType.Integer);
+ var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
+ var chatFontSizeP = cmd.Parameters.Add("$chatFontSize", SqliteType.Real);
+ var chatFontColorP = cmd.Parameters.Add("$chatFontColor", SqliteType.Text);
+ var chatBgColorP = cmd.Parameters.Add("$chatBgColor", SqliteType.Text);
+ var chatMaxMsgsP = cmd.Parameters.Add("$chatMaxMsgs", SqliteType.Integer);
+ var chatTimestampsP = cmd.Parameters.Add("$chatTimestamps", SqliteType.Integer);
+ var chatBadgesP = cmd.Parameters.Add("$chatBadges", SqliteType.Integer);
+ var chatMemberColorP = cmd.Parameters.Add("$chatMemberColor", SqliteType.Text);
+ var chatSuperChatColorP = cmd.Parameters.Add("$chatSuperChatColor", SqliteType.Text);
+ var webUriP = cmd.Parameters.Add("$webUri", SqliteType.Text);
+ var mediaPathP = cmd.Parameters.Add("$mediaPath", SqliteType.Text);
+ var mediaIsLoopingP = cmd.Parameters.Add("$mediaIsLooping", SqliteType.Integer);
+ var mediaVolumeP = cmd.Parameters.Add("$mediaVolume", SqliteType.Real);
+ var mediaPlaybackStateP = cmd.Parameters.Add("$mediaPlaybackState", SqliteType.Text);
+
+ foreach (var scene in scenes)
+ {
+ var sort = 0;
+ foreach (var element in scene.Elements)
+ {
+ if (element is not Source source) continue;
+ idP.Value = source.Id;
+ sceneIdP.Value = scene.Id;
+ assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
+ typeP.Value = source.Type.ToString();
+ nameP.Value = source.Name;
+ enabledP.Value = source.IsEnabled ? 1 : 0;
+ xP.Value = source.X;
+ yP.Value = source.Y;
+ wP.Value = source.Width;
+ hP.Value = source.Height;
+ opacityP.Value = source.Opacity;
+ monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
+ clipP.Value = source.ClipShape.ToString();
+ mirroredP.Value = source.IsMirrored ? 1 : 0;
+ isBackgroundP.Value = source.IsBackground ? 1 : 0;
+ captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
+ showDesktopP.Value = source.ShowDesktop ? 1 : 0;
+ sortP.Value = sort++;
+ chatFontSizeP.Value = source.ChatFontSize;
+ chatFontColorP.Value = source.ChatFontColor;
+ chatBgColorP.Value = source.ChatBackgroundColor;
+ chatMaxMsgsP.Value = source.ChatMaxMessages;
+ chatTimestampsP.Value = source.ChatShowTimestamps ? 1 : 0;
+ chatBadgesP.Value = source.ChatShowBadges ? 1 : 0;
+ chatMemberColorP.Value = source.ChatMemberColor;
+ chatSuperChatColorP.Value = source.ChatSuperChatColor;
+ webUriP.Value = (object?)source.WebUri ?? DBNull.Value;
+ mediaPathP.Value = (object?)source.MediaPath ?? DBNull.Value;
+ mediaIsLoopingP.Value = source.MediaIsLooping ? 1 : 0;
+ mediaVolumeP.Value = source.MediaVolume;
+ mediaPlaybackStateP.Value = source.MediaPlaybackState.ToString();
+ cmd.ExecuteNonQuery();
+ }
+ }
+ }
+
+ if (webcam != null)
+ {
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
+ cmd.Transaction = tx;
+ cmd.Parameters.AddWithValue("$id", webcam.Id);
+ cmd.Parameters.AddWithValue("$device", webcam.DeviceId);
+ cmd.Parameters.AddWithValue("$name", webcam.Name);
+ cmd.ExecuteNonQuery();
+ }
+
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = """
+ INSERT INTO WebcamSceneConfig
+ (SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
+ ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
+ RectWidth, RectHeight, SortOrder)
+ VALUES ($sceneId, $webcamId, $isVisible, $x, $y, $w, $h, $opacity,
+ $clip, $mirrored, $borderColor, $borderOpacity, $borderWidth, $borderAnimation,
+ $rectWidth, $rectHeight, $sort)
+ """;
+ cmd.Transaction = tx;
+ var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
+ var webcamIdP = cmd.Parameters.Add("$webcamId", SqliteType.Text);
+ var visibleP = cmd.Parameters.Add("$isVisible", SqliteType.Integer);
+ var xP = cmd.Parameters.Add("$x", SqliteType.Real);
+ var yP = cmd.Parameters.Add("$y", SqliteType.Real);
+ var wP = cmd.Parameters.Add("$w", SqliteType.Real);
+ var hP = cmd.Parameters.Add("$h", SqliteType.Real);
+ var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
+ var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
+ var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
+ var colorP = cmd.Parameters.Add("$borderColor", SqliteType.Text);
+ var borderOpacityP = cmd.Parameters.Add("$borderOpacity", SqliteType.Real);
+ var borderWidthP = cmd.Parameters.Add("$borderWidth", SqliteType.Integer);
+ var animationP = cmd.Parameters.Add("$borderAnimation", SqliteType.Text);
+ var rectWidthP = cmd.Parameters.Add("$rectWidth", SqliteType.Real);
+ var rectHeightP = cmd.Parameters.Add("$rectHeight", SqliteType.Real);
+ var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
+
+ foreach (var scene in scenes)
+ {
+ var sort = 0;
+ foreach (var config in scene.Elements.OfType())
+ {
+ sceneIdP.Value = scene.Id;
+ webcamIdP.Value = config.WebcamId;
+ visibleP.Value = config.IsVisible ? 1 : 0;
+ xP.Value = config.X;
+ yP.Value = config.Y;
+ wP.Value = config.Width;
+ hP.Value = config.Height;
+ opacityP.Value = config.Opacity;
+ clipP.Value = config.ClipShape.ToString();
+ mirroredP.Value = config.IsMirrored ? 1 : 0;
+ colorP.Value = (object?)(string.IsNullOrEmpty(config.BorderColor) ? null : config.BorderColor) ?? DBNull.Value;
+ borderOpacityP.Value = config.BorderOpacity;
+ borderWidthP.Value = config.BorderWidth;
+ animationP.Value = config.BorderAnimation.ToString();
+ rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value;
+ rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value;
+ sortP.Value = sort++;
+ cmd.ExecuteNonQuery();
+ }
+ }
+ }
+ }
+
+ if (socials != null && socials.Entries.Count > 0)
+ {
+ var socialsId = Guid.NewGuid().ToString();
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "INSERT INTO Socials (Id, BarPosition, BarEnabled) VALUES ($id, $pos, $enabled);";
+ cmd.Transaction = tx;
+ cmd.Parameters.AddWithValue("$id", socialsId);
+ cmd.Parameters.AddWithValue("$pos", socials.BarPosition.ToString());
+ cmd.Parameters.AddWithValue("$enabled", socials.BarEnabled ? 1 : 0);
+ cmd.ExecuteNonQuery();
+ }
+
+ using var entryCmd = _connection.CreateCommand();
+ entryCmd.CommandText = """
+ INSERT INTO SocialEntry (Id, SocialsId, Service, Handle, ProfileUrl, Software, SortOrder)
+ VALUES ($id, $socialsId, $service, $handle, $url, $software, $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 eSoftware = entryCmd.Parameters.Add("$software", 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;
+ eSoftware.Value = (object?)entry.FediverseSoftware ?? DBNull.Value;
+ eSort.Value = entrySort++;
+ entryCmd.ExecuteNonQuery();
+ }
+ }
+
+ if (music != null && !string.IsNullOrWhiteSpace(music.TrackPath))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "INSERT INTO Music (Id, TrackPath, IsEnabled) VALUES ($id, $path, $enabled);";
+ cmd.Transaction = tx;
+ cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString());
+ cmd.Parameters.AddWithValue("$path", music.TrackPath);
+ cmd.Parameters.AddWithValue("$enabled", music.IsEnabled ? 1 : 0);
+ cmd.ExecuteNonQuery();
+ }
+
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
+ cmd.Transaction = tx;
+ cmd.ExecuteNonQuery();
+ }
+
+ // Save custom background paths and use-default flags per background source
+ foreach (var scene in scenes)
+ {
+ var bg = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground);
+ if (bg == null) continue;
+
+ var pathKey = $"BackgroundPath_{bg.Id}";
+ if (!string.IsNullOrWhiteSpace(bg.CustomBackgroundPath))
+ UpsertSetting(pathKey, bg.CustomBackgroundPath);
+ else
+ {
+ using var del = _connection.CreateCommand();
+ del.CommandText = "DELETE FROM Settings WHERE Key = $key;";
+ del.Parameters.AddWithValue("$key", pathKey);
+ del.ExecuteNonQuery();
+ }
+
+ var defaultKey = $"BackgroundUseDefault_{bg.Id}";
+ UpsertSetting(defaultKey, bg.UseDefaultBackground ? "1" : "0");
+ }
+
+ // Purge background settings left behind by deleted/duplicated rows
+ // (TASK 25 heal drops extras; their keys must not accumulate).
+ using (var purge = _connection.CreateCommand())
+ {
+ purge.Transaction = tx;
+ purge.CommandText =
+ @"DELETE FROM Settings
+ WHERE (Key LIKE 'BackgroundUseDefault\_%' ESCAPE '\'
+ OR Key LIKE 'BackgroundPath\_%' ESCAPE '\')
+ AND SUBSTR(Key, INSTR(Key, '_') + 1) NOT IN (SELECT Id FROM Source);";
+ purge.ExecuteNonQuery();
+ }
+
+ tx.Commit();
+ }
+}
diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs
new file mode 100644
index 0000000..0ba2042
--- /dev/null
+++ b/Services/LayoutStore.Settings.cs
@@ -0,0 +1,292 @@
+using Microsoft.Data.Sqlite;
+using ytLive.Models;
+
+namespace ytLive.Services;
+
+public partial class LayoutStore : IDisposable
+{
+ /// The last picked mic source name, or null if never chosen. Restored
+ /// at startup so the sound bar reconnects to the same, already-vetted device.
+ public string? LoadMicSourceName()
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'MicSourceName';";
+ var value = cmd.ExecuteScalar() as string;
+ return string.IsNullOrWhiteSpace(value) ? null : value;
+ }
+
+ public void SaveMicSourceName(string? name)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM Settings WHERE Key = 'MicSourceName';";
+ cmd.ExecuteNonQuery();
+ if (string.IsNullOrWhiteSpace(name))
+ return;
+
+ using var insert = _connection.CreateCommand();
+ insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('MicSourceName', $name);";
+ insert.Parameters.AddWithValue("$name", name);
+ insert.ExecuteNonQuery();
+ }
+
+ /// The user's chosen record output folder (TASK 18), or null to use the
+ /// default %APPDATA%\ytLlive\recordings. Restored at startup.
+ public string? LoadRecordFolder()
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'RecordFolder';";
+ var value = cmd.ExecuteScalar() as string;
+ return string.IsNullOrWhiteSpace(value) ? null : value;
+ }
+
+ public void SaveRecordFolder(string? folder)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM Settings WHERE Key = 'RecordFolder';";
+ cmd.ExecuteNonQuery();
+ if (string.IsNullOrWhiteSpace(folder))
+ return;
+
+ using var insert = _connection.CreateCommand();
+ insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('RecordFolder', $folder);";
+ insert.Parameters.AddWithValue("$folder", folder);
+ insert.ExecuteNonQuery();
+ }
+
+ /// The cached reusable stream (TASK 5), or null if never created.
+ /// Survives restarts so the pump has its RTMP URL the moment go-live starts
+ /// instead of waiting on the YouTube round-trip.
+ public ReusableStream? LoadReusableStream()
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'ReusableStream';";
+ var value = cmd.ExecuteScalar() as string;
+ if (string.IsNullOrWhiteSpace(value)) return null;
+
+ var parts = value.Split('\u001F');
+ return parts.Length == 3
+ ? new ReusableStream(parts[0], parts[1], parts[2])
+ : null;
+ }
+
+ public void SaveReusableStream(ReusableStream? stream)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM Settings WHERE Key = 'ReusableStream';";
+ cmd.ExecuteNonQuery();
+ if (stream == null) return;
+
+ using var insert = _connection.CreateCommand();
+ insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('ReusableStream', $value);";
+ insert.Parameters.AddWithValue("$value", $"{stream.Id}\u001F{stream.IngestionAddress}\u001F{stream.StreamName}");
+ insert.ExecuteNonQuery();
+ }
+
+ public string? LoadLicenseKey()
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'LicenseKey';";
+ var value = cmd.ExecuteScalar() as string;
+ return string.IsNullOrWhiteSpace(value) ? null : value;
+ }
+
+ public void SaveLicenseKey(string? key)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM Settings WHERE Key = 'LicenseKey';";
+ cmd.ExecuteNonQuery();
+ if (string.IsNullOrWhiteSpace(key)) return;
+
+ using var insert = _connection.CreateCommand();
+ insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('LicenseKey', $value);";
+ insert.Parameters.AddWithValue("$value", key);
+ insert.ExecuteNonQuery();
+ }
+
+ /// When the license was last validated online (ISO 8601), or null if never.
+ public DateTime? LoadLicenseValidatedAt()
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'LicenseValidatedAt';";
+ var value = cmd.ExecuteScalar() as string;
+ if (string.IsNullOrWhiteSpace(value)) return null;
+ return DateTime.TryParse(value, out var dt) ? dt : null;
+ }
+
+ public void SaveLicenseValidatedAt(DateTime? dt)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM Settings WHERE Key = 'LicenseValidatedAt';";
+ cmd.ExecuteNonQuery();
+ if (dt == null) return;
+
+ using var insert = _connection.CreateCommand();
+ insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('LicenseValidatedAt', $value);";
+ insert.Parameters.AddWithValue("$value", dt.Value.ToString("o"));
+ insert.ExecuteNonQuery();
+ }
+
+ /// Cached premium state (1 = premium, 0 = free). Used for offline
+ /// grace period — the app checks online at startup but uses this cached
+ /// value when offline.
+ public bool LoadIsPremium()
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'IsPremium';";
+ var value = cmd.ExecuteScalar() as string;
+ return value == "1";
+ }
+
+ public void SaveIsPremium(bool isPremium)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM Settings WHERE Key = 'IsPremium';";
+ cmd.ExecuteNonQuery();
+
+ using var insert = _connection.CreateCommand();
+ insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('IsPremium', $value);";
+ insert.Parameters.AddWithValue("$value", isPremium ? "1" : "0");
+ insert.ExecuteNonQuery();
+ }
+
+ public (string TransitionType, double DurationMs) LoadTransitionSettings()
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'TransitionType';";
+ var type = cmd.ExecuteScalar() as string;
+
+ using var cmd2 = _connection.CreateCommand();
+ cmd2.CommandText = "SELECT Value FROM Settings WHERE Key = 'TransitionDurationMs';";
+ var dur = cmd2.ExecuteScalar() as string;
+
+ return (type ?? "Fade", dur != null && double.TryParse(dur, out var d) ? d : 300);
+ }
+
+ public void SaveTransitionSettings(string transitionType, double durationMs)
+ {
+ UpsertSetting("TransitionType", transitionType);
+ UpsertSetting("TransitionDurationMs", durationMs.ToString("F0"));
+ }
+
+ // ─── Broadcast metadata (broadcast pull-out) + window geometry ───
+
+ public Models.BroadcastMetadata LoadBroadcastMetadata()
+ {
+ return new Models.BroadcastMetadata
+ {
+ Title = GetSetting("Broadcast.Title") ?? string.Empty,
+ Description = GetSetting("Broadcast.Description") ?? string.Empty,
+ TagsCsv = GetSetting("Broadcast.TagsCsv") ?? string.Empty,
+ Visibility = GetSetting("Broadcast.Visibility") ?? "private",
+ MadeForKids = GetSetting("Broadcast.MadeForKids") == "1",
+ ScheduledStartTime = DateTime.TryParse(GetSetting("Broadcast.ScheduledStartTime"), out var t) ? t : null,
+ };
+ }
+
+ public void SaveBroadcastMetadata(Models.BroadcastMetadata meta)
+ {
+ UpsertSetting("Broadcast.Title", meta.Title);
+ UpsertSetting("Broadcast.Description", meta.Description);
+ UpsertSetting("Broadcast.TagsCsv", meta.TagsCsv);
+ UpsertSetting("Broadcast.Visibility", meta.Visibility);
+ UpsertSetting("Broadcast.MadeForKids", meta.MadeForKids ? "1" : "0");
+ UpsertSetting("Broadcast.ScheduledStartTime",
+ meta.ScheduledStartTime?.ToString("o") ?? string.Empty);
+ }
+
+ // ─── Hotkey bindings (TASK 20 step 2) ───
+
+ ///
+ /// Load saved hotkey bindings. Returns only entries that have been explicitly
+ /// saved — missing HotkeyIds are unbound (not in the returned dictionary).
+ /// Caller fills in defaults for any missing entries as needed.
+ ///
+ public Dictionary LoadHotkeyBindings()
+ {
+ var result = new Dictionary();
+ foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
+ {
+ var raw = GetSetting($"Hotkey.{id}");
+ if (raw == null) continue; // not saved = unbound
+ if (raw == "none") continue; // explicitly unbound
+ try
+ {
+ result[id] = HotkeyChord.ParseStorage(raw);
+ }
+ catch (FormatException)
+ {
+ // corrupt entry — skip, will fall back to default
+ }
+ }
+ return result;
+ }
+
+ ///
+ /// Save hotkey bindings. Null entries in the dictionary mean "unbound" (stored
+ /// as "none"). Only the provided entries are saved; previously saved entries
+ /// for HotkeyIds not in the dictionary are deleted.
+ ///
+ public void SaveHotkeyBindings(Dictionary bindings)
+ {
+ // Delete all existing hotkey settings first
+ foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
+ cmd.Parameters.AddWithValue("$key", $"Hotkey.{id}");
+ cmd.ExecuteNonQuery();
+ }
+
+ // Save the new bindings
+ foreach (var (id, chord) in bindings)
+ {
+ var value = chord?.ToString() ?? "none";
+ UpsertSetting($"Hotkey.{id}", value);
+ }
+ }
+
+ public (double Left, double Top, double Width, double Height, string State)? LoadWindowState()
+ {
+ var left = GetSetting("Window.Left");
+ var top = GetSetting("Window.Top");
+ var width = GetSetting("Window.Width");
+ var height = GetSetting("Window.Height");
+ var state = GetSetting("Window.State");
+ if (left == null || top == null || width == null || height == null) return null;
+ if (!double.TryParse(left, out var l) || !double.TryParse(top, out var t) ||
+ !double.TryParse(width, out var w) || !double.TryParse(height, out var h))
+ return null;
+ return (l, t, w, h, state ?? "Normal");
+ }
+
+ public void SaveWindowState(double left, double top, double width, double height, string state)
+ {
+ UpsertSetting("Window.Left", left.ToString("F0"));
+ UpsertSetting("Window.Top", top.ToString("F0"));
+ UpsertSetting("Window.Width", width.ToString("F0"));
+ UpsertSetting("Window.Height", height.ToString("F0"));
+ UpsertSetting("Window.State", state);
+ }
+
+ private string? GetSetting(string key)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT Value FROM Settings WHERE Key = $key;";
+ cmd.Parameters.AddWithValue("$key", key);
+ return cmd.ExecuteScalar() as string;
+ }
+
+ private void UpsertSetting(string key, string value)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
+ cmd.Parameters.AddWithValue("$key", key);
+ cmd.ExecuteNonQuery();
+
+ using var insert = _connection.CreateCommand();
+ insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ($key, $value);";
+ insert.Parameters.AddWithValue("$key", key);
+ insert.Parameters.AddWithValue("$value", value);
+ insert.ExecuteNonQuery();
+ }
+}
diff --git a/Services/LayoutStore.cs b/Services/LayoutStore.cs
index 47e0631..5f789f2 100644
--- a/Services/LayoutStore.cs
+++ b/Services/LayoutStore.cs
@@ -1,11 +1,10 @@
using System.IO;
-using System.Security.Cryptography;
using Microsoft.Data.Sqlite;
using ytLive.Models;
namespace ytLive.Services;
-public class LayoutStore : IDisposable
+public partial class LayoutStore : IDisposable
{
public static LayoutStore? Instance { get; private set; }
@@ -21,7 +20,6 @@ public class LayoutStore : IDisposable
/// The app-wide background music (TASK 9 TRAX) loaded with the last
/// Load() (null = no track chosen).
public Music? Music { get; private set; }
-
public LayoutStore(string path)
{
ActivePath = path;
@@ -36,1365 +34,12 @@ public class LayoutStore : IDisposable
EnsureSchema();
Instance = this;
}
-
- private void EnsureSchema()
- {
- string[] statements =
- {
- """
- CREATE TABLE IF NOT EXISTS Scene (
- Id TEXT PRIMARY KEY,
- Name TEXT NOT NULL,
- IsHidden INTEGER NOT NULL DEFAULT 0,
- IsChatScene INTEGER NOT NULL DEFAULT 0,
- HasBackground INTEGER NOT NULL DEFAULT 1,
- SortOrder INTEGER NOT NULL DEFAULT 0
- );
- """,
- """
- CREATE TABLE IF NOT EXISTS Asset (
- Id TEXT PRIMARY KEY,
- Hash TEXT NOT NULL UNIQUE,
- Data BLOB NOT NULL,
- PixelWidth INTEGER NOT NULL DEFAULT 0,
- PixelHeight INTEGER NOT NULL DEFAULT 0
- );
- """,
- """
- CREATE TABLE IF NOT EXISTS Source (
- Id TEXT PRIMARY KEY,
- SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
- AssetId TEXT REFERENCES Asset(Id),
- Type TEXT NOT NULL,
- Name TEXT NOT NULL,
- IsEnabled INTEGER NOT NULL DEFAULT 1,
- X REAL NOT NULL DEFAULT 0,
- Y REAL NOT NULL DEFAULT 0,
- Width REAL NOT NULL DEFAULT 0,
- Height REAL NOT NULL DEFAULT 0,
- Opacity REAL NOT NULL DEFAULT 1,
- MonitorIndex INTEGER,
- DeviceId TEXT,
- ClipShape TEXT NOT NULL DEFAULT 'Traditional',
- IsMirrored INTEGER NOT NULL DEFAULT 0,
- IsBackground INTEGER NOT NULL DEFAULT 0,
- CaptureKey TEXT,
- SortOrder INTEGER NOT NULL DEFAULT 0
- );
- """,
- """
- CREATE TABLE IF NOT EXISTS Webcam (
- Id TEXT PRIMARY KEY,
- DeviceId TEXT NOT NULL UNIQUE,
- Name TEXT NOT NULL
- );
- """,
- """
- CREATE TABLE IF NOT EXISTS WebcamSceneConfig (
- SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
- WebcamId TEXT NOT NULL REFERENCES Webcam(Id) ON DELETE CASCADE,
- IsVisible INTEGER NOT NULL DEFAULT 1,
- X REAL NOT NULL DEFAULT 0,
- Y REAL NOT NULL DEFAULT 0,
- Width REAL NOT NULL DEFAULT 0,
- Height REAL NOT NULL DEFAULT 0,
- Opacity REAL NOT NULL DEFAULT 1,
- ClipShape TEXT NOT NULL DEFAULT 'Traditional',
- IsMirrored INTEGER NOT NULL DEFAULT 0,
- RectWidth REAL,
- RectHeight REAL,
- BorderColor TEXT,
- BorderOpacity REAL NOT NULL DEFAULT 1,
- BorderWidth INTEGER NOT NULL DEFAULT 0,
- BorderAnimation TEXT NOT NULL DEFAULT 'None',
- SortOrder INTEGER NOT NULL DEFAULT 0,
- 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
- );
- """,
- """
- CREATE TABLE IF NOT EXISTS Music (
- Id TEXT PRIMARY KEY,
- TrackPath TEXT NOT NULL,
- IsEnabled INTEGER NOT NULL DEFAULT 0
- );
- """,
- """
- CREATE TABLE IF NOT EXISTS Settings (
- Key TEXT PRIMARY KEY,
- Value TEXT NOT NULL
- );
- """,
- };
- foreach (var sql in statements)
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = sql;
- cmd.ExecuteNonQuery();
- }
- MigrateSourceTable();
- MigrateWebcamConfigTable();
- MigrateSceneTable();
- MigrateSceneSocialBarColumn();
- MigrateSocialsTable();
- MigrateSocialEntryTable();
- if (GetUserVersion() < 3)
- MigrateToV3();
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "PRAGMA user_version = 9;";
- cmd.ExecuteNonQuery();
- }
- }
-
private int GetUserVersion()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "PRAGMA user_version;";
return Convert.ToInt32(cmd.ExecuteScalar());
}
-
- // v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
- // CREATE TABLE IF NOT EXISTS doesn't touch existing tables, so pre-v2 DBs
- // get the columns here instead.
- private void MigrateSourceTable()
- {
- var columns = new HashSet(StringComparer.OrdinalIgnoreCase);
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "PRAGMA table_info(Source);";
- using var reader = cmd.ExecuteReader();
- while (reader.Read())
- columns.Add(reader.GetString(1));
- }
-
- if (!columns.Contains("ClipShape"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN ClipShape TEXT NOT NULL DEFAULT 'Traditional';";
- cmd.ExecuteNonQuery();
- }
-
- if (!columns.Contains("IsMirrored"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsMirrored INTEGER NOT NULL DEFAULT 0;";
- cmd.ExecuteNonQuery();
- }
-
- if (columns.Contains("IsBackdrop"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source RENAME COLUMN IsBackdrop TO IsBackground;";
- cmd.ExecuteNonQuery();
- }
- else if (!columns.Contains("IsBackground"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBackground INTEGER NOT NULL DEFAULT 0;";
- cmd.ExecuteNonQuery();
- }
-
- if (!columns.Contains("CaptureKey"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN CaptureKey TEXT;";
- cmd.ExecuteNonQuery();
- }
-
- // TASK 3.18: ChatBox overlay source
- if (!columns.Contains("ChatFontSize"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontSize REAL NOT NULL DEFAULT 16;";
- cmd.ExecuteNonQuery();
- }
- if (!columns.Contains("ChatFontColor"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontColor TEXT NOT NULL DEFAULT '#FFFFFF';";
- cmd.ExecuteNonQuery();
- }
- if (!columns.Contains("ChatBackgroundColor"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatBackgroundColor TEXT NOT NULL DEFAULT '#80000000';";
- cmd.ExecuteNonQuery();
- }
- if (!columns.Contains("ChatMaxMessages"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMaxMessages INTEGER NOT NULL DEFAULT 5;";
- cmd.ExecuteNonQuery();
- }
- if (!columns.Contains("ChatShowTimestamps"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowTimestamps INTEGER NOT NULL DEFAULT 1;";
- cmd.ExecuteNonQuery();
- }
- if (!columns.Contains("ChatShowBadges"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowBadges INTEGER NOT NULL DEFAULT 1;";
- cmd.ExecuteNonQuery();
- }
- if (!columns.Contains("ChatMemberColor"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMemberColor TEXT NOT NULL DEFAULT '#e94560';";
- cmd.ExecuteNonQuery();
- }
- if (!columns.Contains("ChatSuperChatColor"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatSuperChatColor TEXT NOT NULL DEFAULT '#eab308';";
- cmd.ExecuteNonQuery();
- }
- if (!columns.Contains("ShowDesktop"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN ShowDesktop INTEGER NOT NULL DEFAULT 0;";
- cmd.ExecuteNonQuery();
- }
-
- if (!columns.Contains("WebUri"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN WebUri TEXT;";
- cmd.ExecuteNonQuery();
- }
-
- // TASK 21: Media source (video/audio file playback)
- if (!columns.Contains("MediaPath"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPath TEXT;";
- cmd.ExecuteNonQuery();
- }
- if (!columns.Contains("MediaIsLooping"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaIsLooping INTEGER NOT NULL DEFAULT 0;";
- cmd.ExecuteNonQuery();
- }
- if (!columns.Contains("MediaVolume"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaVolume REAL NOT NULL DEFAULT 1;";
- cmd.ExecuteNonQuery();
- }
- if (!columns.Contains("MediaPlaybackState"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPlaybackState TEXT NOT NULL DEFAULT 'Stopped';";
- cmd.ExecuteNonQuery();
- }
-
- // Rename legacy "Backdrop" display name to "Background" in existing rows
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "UPDATE Source SET Name = 'Background' WHERE Name = 'Backdrop';";
- cmd.ExecuteNonQuery();
- }
- }
-
- // v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
- // so a reloaded Round webcam restores its aspect on toggle-back).
- private void MigrateWebcamConfigTable()
- {
- var columns = new HashSet(StringComparer.OrdinalIgnoreCase);
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "PRAGMA table_info(WebcamSceneConfig);";
- using var reader = cmd.ExecuteReader();
- while (reader.Read())
- columns.Add(reader.GetString(1));
- }
-
- if (!columns.Contains("RectWidth"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectWidth REAL;";
- cmd.ExecuteNonQuery();
- }
-
- if (!columns.Contains("RectHeight"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectHeight REAL;";
- cmd.ExecuteNonQuery();
- }
- }
-
- // v2 → v3: the webcam leaves the Source table. Any webcam Source rows become
- // one Webcam identity (first row — one camera app-wide) + a WebcamSceneConfig
- // per scene that had one, then the webcam rows are deleted. No backfill:
- // scenes without the webcam stay webcam-free.
- private void MigrateToV3()
- {
- var rows = new List<(string Id, string SceneId, string Name, double X, double Y, double W, double H, double Opacity, string DeviceId, string ClipShape, bool IsMirrored)>();
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = """
- SELECT Id, SceneId, Name, X, Y, Width, Height, Opacity, DeviceId, ClipShape, IsMirrored
- FROM Source WHERE Type = 'Webcam' ORDER BY SortOrder;
- """;
- using var reader = cmd.ExecuteReader();
- while (reader.Read())
- {
- rows.Add((
- reader.GetString(0),
- reader.GetString(1),
- reader.GetString(2),
- reader.GetDouble(3),
- reader.GetDouble(4),
- reader.GetDouble(5),
- reader.GetDouble(6),
- reader.GetDouble(7),
- reader.IsDBNull(8) ? string.Empty : reader.GetString(8),
- reader.GetString(9),
- reader.GetInt32(10) != 0));
- }
- }
- if (rows.Count == 0) return;
-
- using var tx = _connection.BeginTransaction();
- var webcamId = Guid.NewGuid().ToString();
-
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
- cmd.Transaction = tx;
- cmd.Parameters.AddWithValue("$id", webcamId);
- cmd.Parameters.AddWithValue("$device", rows[0].DeviceId);
- cmd.Parameters.AddWithValue("$name", rows[0].Name);
- cmd.ExecuteNonQuery();
- }
-
- foreach (var row in rows)
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- INSERT INTO WebcamSceneConfig
- (SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
- ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation, SortOrder)
- VALUES ($sceneId, $webcamId, 1, $x, $y, $w, $h, $opacity,
- $clip, $mirrored, NULL, 1, 0, 'None', 0);
- """;
- cmd.Transaction = tx;
- cmd.Parameters.AddWithValue("$sceneId", row.SceneId);
- cmd.Parameters.AddWithValue("$webcamId", webcamId);
- cmd.Parameters.AddWithValue("$x", row.X);
- cmd.Parameters.AddWithValue("$y", row.Y);
- cmd.Parameters.AddWithValue("$w", row.W);
- cmd.Parameters.AddWithValue("$h", row.H);
- cmd.Parameters.AddWithValue("$opacity", row.Opacity);
- cmd.Parameters.AddWithValue("$clip", row.ClipShape);
- cmd.Parameters.AddWithValue("$mirrored", row.IsMirrored ? 1 : 0);
- cmd.ExecuteNonQuery();
- }
-
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "DELETE FROM Source WHERE Type = 'Webcam';";
- cmd.Transaction = tx;
- cmd.ExecuteNonQuery();
- }
- tx.Commit();
- }
-
- // v5 → v6: Scene gains HasBackground. Added columns default to true; the
- // ONE-TIME backfill (runs only when the column is first added) turns every
- // non-Live canonical scene off and drops their background sources — the fix for
- // DBs saved before the Live-only policy. MainViewModel.NormalizeBackgrounds
- // re-runs the same normalization on every load for DBs that miss the backfill.
- private void MigrateSceneTable()
- {
- var columns = new HashSet(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("HasBackdrop"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Scene RENAME COLUMN HasBackdrop TO HasBackground;";
- cmd.ExecuteNonQuery();
- }
- else if (columns.Contains("HasBackground"))
- {
- return;
- }
-
- if (!columns.Contains("HasBackground"))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "ALTER TABLE Scene ADD COLUMN HasBackground INTEGER NOT NULL DEFAULT 1;";
- cmd.ExecuteNonQuery();
- }
-
- const string noBackground = "('starting', 'brb', 'chat', 'ending')";
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = $"UPDATE Scene SET HasBackground = 0 WHERE LOWER(TRIM(Name)) IN {noBackground};";
- cmd.ExecuteNonQuery();
- }
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = $"""
- DELETE FROM Source
- WHERE IsBackground = 1 AND SceneId IN
- (SELECT Id FROM Scene WHERE LOWER(TRIM(Name)) IN {noBackground});
- """;
- cmd.ExecuteNonQuery();
- }
- }
-
- // 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(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();
- }
-
- // v7 → v8: Socials gains BarEnabled (the dialog's show/hide switch). The old
- // BarJustify column stays for back-compat but is no longer read or written.
- private void MigrateSocialsTable()
- {
- var columns = new HashSet(StringComparer.OrdinalIgnoreCase);
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "PRAGMA table_info(Socials);";
- using var reader = cmd.ExecuteReader();
- while (reader.Read())
- columns.Add(reader.GetString(1));
- }
-
- if (columns.Contains("BarEnabled")) return;
-
- using var alter = _connection.CreateCommand();
- alter.CommandText = "ALTER TABLE Socials ADD COLUMN BarEnabled INTEGER NOT NULL DEFAULT 1;";
- alter.ExecuteNonQuery();
- }
-
- // v8 → v9: SocialEntry gains Software (the fediverse instance's nodeinfo
- // software name) so a reloaded entry still shows the right service logo.
- private void MigrateSocialEntryTable()
- {
- var columns = new HashSet(StringComparer.OrdinalIgnoreCase);
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "PRAGMA table_info(SocialEntry);";
- using var reader = cmd.ExecuteReader();
- while (reader.Read())
- columns.Add(reader.GetString(1));
- }
-
- if (columns.Contains("Software")) return;
-
- using var alter = _connection.CreateCommand();
- alter.CommandText = "ALTER TABLE SocialEntry ADD COLUMN Software TEXT;";
- alter.ExecuteNonQuery();
- }
-
- public List Load()
- {
- Webcam = null;
- Socials = null;
- Music = null;
- var scenes = new List();
- var sourcesByScene = new Dictionary>();
- var configsByScene = new Dictionary>();
-
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar FROM Scene ORDER BY SortOrder";
- using var reader = cmd.ExecuteReader();
- while (reader.Read())
- {
- scenes.Add(new Scene
- {
- Id = reader.GetString(0),
- Name = reader.GetString(1),
- IsHidden = reader.GetInt32(2) != 0,
- IsChatScene = reader.GetInt32(3) != 0,
- HasBackground = reader.GetInt32(4) != 0,
- HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
- });
- }
- }
-
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "SELECT Id, DeviceId, Name FROM Webcam;";
- using var reader = cmd.ExecuteReader();
- if (reader.Read())
- {
- Webcam = new Webcam
- {
- Id = reader.GetString(0),
- DeviceId = reader.GetString(1),
- Name = reader.GetString(2),
- };
- }
- }
-
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "SELECT Id, BarPosition, BarEnabled FROM Socials LIMIT 1;";
- using var reader = cmd.ExecuteReader();
- if (reader.Read())
- {
- Socials = new SocialsConfig
- {
- BarPosition = Enum.TryParse(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom,
- BarEnabled = reader.FieldCount > 2 && !reader.IsDBNull(2) && reader.GetInt32(2) != 0,
- };
- var socialsId = reader.GetString(0);
-
- using var entryCmd = _connection.CreateCommand();
- entryCmd.CommandText = "SELECT Service, Handle, ProfileUrl, Software 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(entryReader.GetString(0), out var svc) ? svc : SocialService.Link,
- Handle = entryReader.GetString(1),
- ProfileUrl = entryReader.GetString(2),
- FediverseSoftware = entryReader.FieldCount > 3 && !entryReader.IsDBNull(3) ? entryReader.GetString(3) : null,
- });
- }
- }
- }
-
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "SELECT TrackPath, IsEnabled FROM Music LIMIT 1;";
- using var reader = cmd.ExecuteReader();
- if (reader.Read())
- {
- Music = new Music
- {
- TrackPath = reader.GetString(0),
- IsEnabled = reader.GetInt32(1) != 0,
- };
- }
- }
-
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = """
- SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
- X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
- IsBackground, CaptureKey, ShowDesktop,
- ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
- ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
- WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState
- FROM Source ORDER BY SortOrder
- """;
- using var reader = cmd.ExecuteReader();
- while (reader.Read())
- {
- var sceneId = reader.GetString(1);
- var type = Enum.Parse(reader.GetString(3));
- var source = new Source
- {
- Id = reader.GetString(0),
- AssetId = reader.IsDBNull(2) ? null : reader.GetString(2),
- Type = type,
- Name = reader.GetString(4),
- IsEnabled = reader.GetInt32(5) != 0,
- X = reader.GetDouble(6),
- Y = reader.GetDouble(7),
- Width = reader.GetDouble(8),
- Height = reader.GetDouble(9),
- Opacity = reader.GetDouble(10),
- MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
- ClipShape = Enum.TryParse(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
- IsMirrored = reader.GetInt32(13) != 0,
- // Rows saved before the Type setter derived the flag carry
- // IsBackground=0; derive from Type, keeping the persisted bit
- // for legacy DisplayCapture backdrops.
- IsBackground = type == SourceType.Background || reader.GetInt32(14) != 0,
- CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
- ShowDesktop = reader.GetInt32(16) != 0,
- ChatFontSize = reader.IsDBNull(17) ? 16 : reader.GetDouble(17),
- ChatFontColor = reader.IsDBNull(18) ? "#FFFFFF" : reader.GetString(18),
- ChatBackgroundColor = reader.IsDBNull(19) ? "#80000000" : reader.GetString(19),
- ChatMaxMessages = reader.IsDBNull(20) ? 5 : reader.GetInt32(20),
- ChatShowTimestamps = reader.IsDBNull(21) ? true : reader.GetInt32(21) != 0,
- ChatShowBadges = reader.IsDBNull(22) ? true : reader.GetInt32(22) != 0,
- ChatMemberColor = reader.IsDBNull(23) ? "#e94560" : reader.GetString(23),
- ChatSuperChatColor = reader.IsDBNull(24) ? "#eab308" : reader.GetString(24),
- WebUri = reader.IsDBNull(25) ? null : reader.GetString(25),
- MediaPath = reader.IsDBNull(26) ? null : reader.GetString(26),
- MediaIsLooping = reader.IsDBNull(27) ? false : reader.GetInt32(27) != 0,
- MediaVolume = reader.IsDBNull(28) ? 1.0 : reader.GetDouble(28),
- MediaPlaybackState = reader.IsDBNull(29)
- ? MediaPlaybackState.Stopped
- : Enum.TryParse(reader.GetString(29), out var ps)
- ? ps
- : MediaPlaybackState.Stopped,
- };
- if (!sourcesByScene.TryGetValue(sceneId, out var list))
- sourcesByScene[sceneId] = list = new List();
- list.Add(source);
- }
- }
-
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = """
- SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
- ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
- RectWidth, RectHeight
- FROM WebcamSceneConfig ORDER BY SortOrder
- """;
- using var reader = cmd.ExecuteReader();
- while (reader.Read())
- {
- var sceneId = reader.GetString(0);
- var config = new WebcamSceneConfig
- {
- WebcamId = reader.GetString(1),
- Name = Webcam?.Name ?? "Webcam",
- IsVisible = reader.GetInt32(2) != 0,
- X = reader.GetDouble(3),
- Y = reader.GetDouble(4),
- Width = reader.GetDouble(5),
- Height = reader.GetDouble(6),
- Opacity = reader.GetDouble(7),
- ClipShape = Enum.TryParse(reader.GetString(8), out var clip) ? clip : ClipShape.Traditional,
- IsMirrored = reader.GetInt32(9) != 0,
- BorderColor = reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
- BorderOpacity = reader.IsDBNull(11) ? 1.0 : reader.GetDouble(11),
- BorderWidth = reader.IsDBNull(12) ? 0 : reader.GetInt32(12),
- BorderAnimation = Enum.TryParse(reader.GetString(13), out var anim) ? anim : BorderAnimation.None,
- RectWidth = reader.IsDBNull(14) ? null : reader.GetDouble(14),
- RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15),
- };
- if (!configsByScene.TryGetValue(sceneId, out var list))
- configsByScene[sceneId] = list = new List();
- list.Add(config);
- }
- }
-
- foreach (var scene in scenes)
- {
- if (sourcesByScene.TryGetValue(scene.Id, out var sources))
- foreach (var source in sources)
- scene.Elements.Add(source);
- if (configsByScene.TryGetValue(scene.Id, out var configs))
- foreach (var config in configs)
- scene.Elements.Add(config);
-
- var bg = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground);
- if (bg != null)
- {
- // Load from source-keyed settings (new) or scene-keyed (legacy migration)
- var path = GetSetting($"BackgroundPath_{bg.Id}");
- if (string.IsNullOrWhiteSpace(path))
- path = GetSetting($"BackgroundPath_{scene.Id}");
- if (!string.IsNullOrWhiteSpace(path))
- bg.CustomBackgroundPath = path;
-
- var useDefault = GetSetting($"BackgroundUseDefault_{bg.Id}");
- if (string.IsNullOrWhiteSpace(useDefault))
- useDefault = GetSetting($"BackgroundUseDefault_{scene.Id}");
- if (useDefault == "0")
- bg.UseDefaultBackground = false;
- }
- }
-
- return scenes;
- }
-
- /// The last picked mic source name, or null if never chosen. Restored
- /// at startup so the sound bar reconnects to the same, already-vetted device.
- public string? LoadMicSourceName()
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'MicSourceName';";
- var value = cmd.ExecuteScalar() as string;
- return string.IsNullOrWhiteSpace(value) ? null : value;
- }
-
- public void SaveMicSourceName(string? name)
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "DELETE FROM Settings WHERE Key = 'MicSourceName';";
- cmd.ExecuteNonQuery();
- if (string.IsNullOrWhiteSpace(name))
- return;
-
- using var insert = _connection.CreateCommand();
- insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('MicSourceName', $name);";
- insert.Parameters.AddWithValue("$name", name);
- insert.ExecuteNonQuery();
- }
-
- /// The user's chosen record output folder (TASK 18), or null to use the
- /// default %APPDATA%\ytLlive\recordings. Restored at startup.
- public string? LoadRecordFolder()
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'RecordFolder';";
- var value = cmd.ExecuteScalar() as string;
- return string.IsNullOrWhiteSpace(value) ? null : value;
- }
-
- public void SaveRecordFolder(string? folder)
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "DELETE FROM Settings WHERE Key = 'RecordFolder';";
- cmd.ExecuteNonQuery();
- if (string.IsNullOrWhiteSpace(folder))
- return;
-
- using var insert = _connection.CreateCommand();
- insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('RecordFolder', $folder);";
- insert.Parameters.AddWithValue("$folder", folder);
- insert.ExecuteNonQuery();
- }
-
- /// The cached reusable stream (TASK 5), or null if never created.
- /// Survives restarts so the pump has its RTMP URL the moment go-live starts
- /// instead of waiting on the YouTube round-trip.
- public ReusableStream? LoadReusableStream()
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'ReusableStream';";
- var value = cmd.ExecuteScalar() as string;
- if (string.IsNullOrWhiteSpace(value)) return null;
-
- var parts = value.Split('\u001F');
- return parts.Length == 3
- ? new ReusableStream(parts[0], parts[1], parts[2])
- : null;
- }
-
- public void SaveReusableStream(ReusableStream? stream)
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "DELETE FROM Settings WHERE Key = 'ReusableStream';";
- cmd.ExecuteNonQuery();
- if (stream == null) return;
-
- using var insert = _connection.CreateCommand();
- insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('ReusableStream', $value);";
- insert.Parameters.AddWithValue("$value", $"{stream.Id}\u001F{stream.IngestionAddress}\u001F{stream.StreamName}");
- insert.ExecuteNonQuery();
- }
-
- public void Save(IEnumerable scenes, Webcam? webcam, SocialsConfig? socials, Music? music = null)
- {
- using var tx = _connection.BeginTransaction();
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "DELETE FROM WebcamSceneConfig;";
- cmd.Transaction = tx;
- cmd.ExecuteNonQuery();
- }
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "DELETE FROM Source;";
- cmd.Transaction = tx;
- cmd.ExecuteNonQuery();
- }
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "DELETE FROM Webcam;";
- cmd.Transaction = tx;
- 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 Music;";
- cmd.Transaction = tx;
- cmd.ExecuteNonQuery();
- }
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "DELETE FROM Scene;";
- cmd.Transaction = tx;
- cmd.ExecuteNonQuery();
- }
-
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = """
- INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, SortOrder)
- VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $sort)
- """;
- cmd.Transaction = tx;
- var idP = cmd.Parameters.Add("$id", SqliteType.Text);
- var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
- var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
- var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
- var backgroundP = cmd.Parameters.Add("$hasBackground", SqliteType.Integer);
- var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer);
- var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
-
- var sort = 0;
- foreach (var scene in scenes)
- {
- idP.Value = scene.Id;
- nameP.Value = scene.Name;
- hiddenP.Value = scene.IsHidden ? 1 : 0;
- chatP.Value = scene.IsChatScene ? 1 : 0;
- backgroundP.Value = scene.HasBackground ? 1 : 0;
- socialBarP.Value = scene.HasSocialBar ? 1 : 0;
- sortP.Value = sort++;
- cmd.ExecuteNonQuery();
- }
- }
-
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = """
- INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
- X, Y, Width, Height, Opacity, MonitorIndex,
- ClipShape, IsMirrored, IsBackground, CaptureKey, ShowDesktop, SortOrder,
- ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
- ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
- WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState)
- VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
- $x, $y, $w, $h, $opacity, $monitor,
- $clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
- $chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs,
- $chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
- $webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState)
- """;
- cmd.Transaction = tx;
- var idP = cmd.Parameters.Add("$id", SqliteType.Text);
- var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
- var assetIdP = cmd.Parameters.Add("$assetId", SqliteType.Text);
- var typeP = cmd.Parameters.Add("$type", SqliteType.Text);
- var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
- var enabledP = cmd.Parameters.Add("$isEnabled", SqliteType.Integer);
- var xP = cmd.Parameters.Add("$x", SqliteType.Real);
- var yP = cmd.Parameters.Add("$y", SqliteType.Real);
- var wP = cmd.Parameters.Add("$w", SqliteType.Real);
- var hP = cmd.Parameters.Add("$h", SqliteType.Real);
- var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
- var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
- var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
- var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
- var isBackgroundP = cmd.Parameters.Add("$isBackground", SqliteType.Integer);
- var captureKeyP = cmd.Parameters.Add("$captureKey", SqliteType.Text);
- var showDesktopP = cmd.Parameters.Add("$showDesktop", SqliteType.Integer);
- var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
- var chatFontSizeP = cmd.Parameters.Add("$chatFontSize", SqliteType.Real);
- var chatFontColorP = cmd.Parameters.Add("$chatFontColor", SqliteType.Text);
- var chatBgColorP = cmd.Parameters.Add("$chatBgColor", SqliteType.Text);
- var chatMaxMsgsP = cmd.Parameters.Add("$chatMaxMsgs", SqliteType.Integer);
- var chatTimestampsP = cmd.Parameters.Add("$chatTimestamps", SqliteType.Integer);
- var chatBadgesP = cmd.Parameters.Add("$chatBadges", SqliteType.Integer);
- var chatMemberColorP = cmd.Parameters.Add("$chatMemberColor", SqliteType.Text);
- var chatSuperChatColorP = cmd.Parameters.Add("$chatSuperChatColor", SqliteType.Text);
- var webUriP = cmd.Parameters.Add("$webUri", SqliteType.Text);
- var mediaPathP = cmd.Parameters.Add("$mediaPath", SqliteType.Text);
- var mediaIsLoopingP = cmd.Parameters.Add("$mediaIsLooping", SqliteType.Integer);
- var mediaVolumeP = cmd.Parameters.Add("$mediaVolume", SqliteType.Real);
- var mediaPlaybackStateP = cmd.Parameters.Add("$mediaPlaybackState", SqliteType.Text);
-
- foreach (var scene in scenes)
- {
- var sort = 0;
- foreach (var element in scene.Elements)
- {
- if (element is not Source source) continue;
- idP.Value = source.Id;
- sceneIdP.Value = scene.Id;
- assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
- typeP.Value = source.Type.ToString();
- nameP.Value = source.Name;
- enabledP.Value = source.IsEnabled ? 1 : 0;
- xP.Value = source.X;
- yP.Value = source.Y;
- wP.Value = source.Width;
- hP.Value = source.Height;
- opacityP.Value = source.Opacity;
- monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
- clipP.Value = source.ClipShape.ToString();
- mirroredP.Value = source.IsMirrored ? 1 : 0;
- isBackgroundP.Value = source.IsBackground ? 1 : 0;
- captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
- showDesktopP.Value = source.ShowDesktop ? 1 : 0;
- sortP.Value = sort++;
- chatFontSizeP.Value = source.ChatFontSize;
- chatFontColorP.Value = source.ChatFontColor;
- chatBgColorP.Value = source.ChatBackgroundColor;
- chatMaxMsgsP.Value = source.ChatMaxMessages;
- chatTimestampsP.Value = source.ChatShowTimestamps ? 1 : 0;
- chatBadgesP.Value = source.ChatShowBadges ? 1 : 0;
- chatMemberColorP.Value = source.ChatMemberColor;
- chatSuperChatColorP.Value = source.ChatSuperChatColor;
- webUriP.Value = (object?)source.WebUri ?? DBNull.Value;
- mediaPathP.Value = (object?)source.MediaPath ?? DBNull.Value;
- mediaIsLoopingP.Value = source.MediaIsLooping ? 1 : 0;
- mediaVolumeP.Value = source.MediaVolume;
- mediaPlaybackStateP.Value = source.MediaPlaybackState.ToString();
- cmd.ExecuteNonQuery();
- }
- }
- }
-
- if (webcam != null)
- {
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
- cmd.Transaction = tx;
- cmd.Parameters.AddWithValue("$id", webcam.Id);
- cmd.Parameters.AddWithValue("$device", webcam.DeviceId);
- cmd.Parameters.AddWithValue("$name", webcam.Name);
- cmd.ExecuteNonQuery();
- }
-
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = """
- INSERT INTO WebcamSceneConfig
- (SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
- ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
- RectWidth, RectHeight, SortOrder)
- VALUES ($sceneId, $webcamId, $isVisible, $x, $y, $w, $h, $opacity,
- $clip, $mirrored, $borderColor, $borderOpacity, $borderWidth, $borderAnimation,
- $rectWidth, $rectHeight, $sort)
- """;
- cmd.Transaction = tx;
- var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
- var webcamIdP = cmd.Parameters.Add("$webcamId", SqliteType.Text);
- var visibleP = cmd.Parameters.Add("$isVisible", SqliteType.Integer);
- var xP = cmd.Parameters.Add("$x", SqliteType.Real);
- var yP = cmd.Parameters.Add("$y", SqliteType.Real);
- var wP = cmd.Parameters.Add("$w", SqliteType.Real);
- var hP = cmd.Parameters.Add("$h", SqliteType.Real);
- var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
- var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
- var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
- var colorP = cmd.Parameters.Add("$borderColor", SqliteType.Text);
- var borderOpacityP = cmd.Parameters.Add("$borderOpacity", SqliteType.Real);
- var borderWidthP = cmd.Parameters.Add("$borderWidth", SqliteType.Integer);
- var animationP = cmd.Parameters.Add("$borderAnimation", SqliteType.Text);
- var rectWidthP = cmd.Parameters.Add("$rectWidth", SqliteType.Real);
- var rectHeightP = cmd.Parameters.Add("$rectHeight", SqliteType.Real);
- var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
-
- foreach (var scene in scenes)
- {
- var sort = 0;
- foreach (var config in scene.Elements.OfType())
- {
- sceneIdP.Value = scene.Id;
- webcamIdP.Value = config.WebcamId;
- visibleP.Value = config.IsVisible ? 1 : 0;
- xP.Value = config.X;
- yP.Value = config.Y;
- wP.Value = config.Width;
- hP.Value = config.Height;
- opacityP.Value = config.Opacity;
- clipP.Value = config.ClipShape.ToString();
- mirroredP.Value = config.IsMirrored ? 1 : 0;
- colorP.Value = (object?)(string.IsNullOrEmpty(config.BorderColor) ? null : config.BorderColor) ?? DBNull.Value;
- borderOpacityP.Value = config.BorderOpacity;
- borderWidthP.Value = config.BorderWidth;
- animationP.Value = config.BorderAnimation.ToString();
- rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value;
- rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value;
- sortP.Value = sort++;
- cmd.ExecuteNonQuery();
- }
- }
- }
- }
-
- if (socials != null && socials.Entries.Count > 0)
- {
- var socialsId = Guid.NewGuid().ToString();
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "INSERT INTO Socials (Id, BarPosition, BarEnabled) VALUES ($id, $pos, $enabled);";
- cmd.Transaction = tx;
- cmd.Parameters.AddWithValue("$id", socialsId);
- cmd.Parameters.AddWithValue("$pos", socials.BarPosition.ToString());
- cmd.Parameters.AddWithValue("$enabled", socials.BarEnabled ? 1 : 0);
- cmd.ExecuteNonQuery();
- }
-
- using var entryCmd = _connection.CreateCommand();
- entryCmd.CommandText = """
- INSERT INTO SocialEntry (Id, SocialsId, Service, Handle, ProfileUrl, Software, SortOrder)
- VALUES ($id, $socialsId, $service, $handle, $url, $software, $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 eSoftware = entryCmd.Parameters.Add("$software", 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;
- eSoftware.Value = (object?)entry.FediverseSoftware ?? DBNull.Value;
- eSort.Value = entrySort++;
- entryCmd.ExecuteNonQuery();
- }
- }
-
- if (music != null && !string.IsNullOrWhiteSpace(music.TrackPath))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "INSERT INTO Music (Id, TrackPath, IsEnabled) VALUES ($id, $path, $enabled);";
- cmd.Transaction = tx;
- cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString());
- cmd.Parameters.AddWithValue("$path", music.TrackPath);
- cmd.Parameters.AddWithValue("$enabled", music.IsEnabled ? 1 : 0);
- cmd.ExecuteNonQuery();
- }
-
- using (var cmd = _connection.CreateCommand())
- {
- cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
- cmd.Transaction = tx;
- cmd.ExecuteNonQuery();
- }
-
- // Save custom background paths and use-default flags per background source
- foreach (var scene in scenes)
- {
- var bg = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground);
- if (bg == null) continue;
-
- var pathKey = $"BackgroundPath_{bg.Id}";
- if (!string.IsNullOrWhiteSpace(bg.CustomBackgroundPath))
- UpsertSetting(pathKey, bg.CustomBackgroundPath);
- else
- {
- using var del = _connection.CreateCommand();
- del.CommandText = "DELETE FROM Settings WHERE Key = $key;";
- del.Parameters.AddWithValue("$key", pathKey);
- del.ExecuteNonQuery();
- }
-
- var defaultKey = $"BackgroundUseDefault_{bg.Id}";
- UpsertSetting(defaultKey, bg.UseDefaultBackground ? "1" : "0");
- }
-
- // Purge background settings left behind by deleted/duplicated rows
- // (TASK 25 heal drops extras; their keys must not accumulate).
- using (var purge = _connection.CreateCommand())
- {
- purge.Transaction = tx;
- purge.CommandText =
- @"DELETE FROM Settings
- WHERE (Key LIKE 'BackgroundUseDefault\_%' ESCAPE '\'
- OR Key LIKE 'BackgroundPath\_%' ESCAPE '\')
- AND SUBSTR(Key, INSTR(Key, '_') + 1) NOT IN (SELECT Id FROM Source);";
- purge.ExecuteNonQuery();
- }
-
- tx.Commit();
- }
-
- public byte[]? GetAssetBytes(string assetId)
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT Data FROM Asset WHERE Id = $id";
- cmd.Parameters.AddWithValue("$id", assetId);
- return cmd.ExecuteScalar() as byte[];
- }
-
- public string UpsertAsset(byte[] data, int pixelWidth, int pixelHeight)
- {
- var hash = Convert.ToHexString(SHA256.HashData(data));
- var id = Guid.NewGuid().ToString();
-
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- INSERT INTO Asset (Id, Hash, Data, PixelWidth, PixelHeight)
- VALUES ($id, $hash, $data, $w, $h)
- ON CONFLICT(Hash) DO NOTHING;
- """;
- cmd.Parameters.AddWithValue("$id", id);
- cmd.Parameters.AddWithValue("$hash", hash);
- cmd.Parameters.AddWithValue("$data", data);
- cmd.Parameters.AddWithValue("$w", pixelWidth);
- cmd.Parameters.AddWithValue("$h", pixelHeight);
- cmd.ExecuteNonQuery();
-
- cmd.CommandText = "SELECT Id FROM Asset WHERE Hash = $hash;";
- return (string)cmd.ExecuteScalar()!;
- }
-
- // ─── License (TASK 10 — Polar billing) ───
-
- /// The saved license key, or null if never entered.
- public string? LoadLicenseKey()
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'LicenseKey';";
- var value = cmd.ExecuteScalar() as string;
- return string.IsNullOrWhiteSpace(value) ? null : value;
- }
-
- public void SaveLicenseKey(string? key)
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "DELETE FROM Settings WHERE Key = 'LicenseKey';";
- cmd.ExecuteNonQuery();
- if (string.IsNullOrWhiteSpace(key)) return;
-
- using var insert = _connection.CreateCommand();
- insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('LicenseKey', $value);";
- insert.Parameters.AddWithValue("$value", key);
- insert.ExecuteNonQuery();
- }
-
- /// When the license was last validated online (ISO 8601), or null if never.
- public DateTime? LoadLicenseValidatedAt()
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'LicenseValidatedAt';";
- var value = cmd.ExecuteScalar() as string;
- if (string.IsNullOrWhiteSpace(value)) return null;
- return DateTime.TryParse(value, out var dt) ? dt : null;
- }
-
- public void SaveLicenseValidatedAt(DateTime? dt)
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "DELETE FROM Settings WHERE Key = 'LicenseValidatedAt';";
- cmd.ExecuteNonQuery();
- if (dt == null) return;
-
- using var insert = _connection.CreateCommand();
- insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('LicenseValidatedAt', $value);";
- insert.Parameters.AddWithValue("$value", dt.Value.ToString("o"));
- insert.ExecuteNonQuery();
- }
-
- /// Cached premium state (1 = premium, 0 = free). Used for offline
- /// grace period — the app checks online at startup but uses this cached
- /// value when offline.
- public bool LoadIsPremium()
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'IsPremium';";
- var value = cmd.ExecuteScalar() as string;
- return value == "1";
- }
-
- public void SaveIsPremium(bool isPremium)
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "DELETE FROM Settings WHERE Key = 'IsPremium';";
- cmd.ExecuteNonQuery();
-
- using var insert = _connection.CreateCommand();
- insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('IsPremium', $value);";
- insert.Parameters.AddWithValue("$value", isPremium ? "1" : "0");
- insert.ExecuteNonQuery();
- }
-
- public (string TransitionType, double DurationMs) LoadTransitionSettings()
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'TransitionType';";
- var type = cmd.ExecuteScalar() as string;
-
- using var cmd2 = _connection.CreateCommand();
- cmd2.CommandText = "SELECT Value FROM Settings WHERE Key = 'TransitionDurationMs';";
- var dur = cmd2.ExecuteScalar() as string;
-
- return (type ?? "Fade", dur != null && double.TryParse(dur, out var d) ? d : 300);
- }
-
- public void SaveTransitionSettings(string transitionType, double durationMs)
- {
- UpsertSetting("TransitionType", transitionType);
- UpsertSetting("TransitionDurationMs", durationMs.ToString("F0"));
- }
-
- // ─── Broadcast metadata (broadcast pull-out) + window geometry ───
-
- public Models.BroadcastMetadata LoadBroadcastMetadata()
- {
- return new Models.BroadcastMetadata
- {
- Title = GetSetting("Broadcast.Title") ?? string.Empty,
- Description = GetSetting("Broadcast.Description") ?? string.Empty,
- TagsCsv = GetSetting("Broadcast.TagsCsv") ?? string.Empty,
- Visibility = GetSetting("Broadcast.Visibility") ?? "private",
- MadeForKids = GetSetting("Broadcast.MadeForKids") == "1",
- ScheduledStartTime = DateTime.TryParse(GetSetting("Broadcast.ScheduledStartTime"), out var t) ? t : null,
- };
- }
-
- public void SaveBroadcastMetadata(Models.BroadcastMetadata meta)
- {
- UpsertSetting("Broadcast.Title", meta.Title);
- UpsertSetting("Broadcast.Description", meta.Description);
- UpsertSetting("Broadcast.TagsCsv", meta.TagsCsv);
- UpsertSetting("Broadcast.Visibility", meta.Visibility);
- UpsertSetting("Broadcast.MadeForKids", meta.MadeForKids ? "1" : "0");
- UpsertSetting("Broadcast.ScheduledStartTime",
- meta.ScheduledStartTime?.ToString("o") ?? string.Empty);
- }
-
- // ─── Hotkey bindings (TASK 20 step 2) ───
-
- ///
- /// Load saved hotkey bindings. Returns only entries that have been explicitly
- /// saved — missing HotkeyIds are unbound (not in the returned dictionary).
- /// Caller fills in defaults for any missing entries as needed.
- ///
- public Dictionary LoadHotkeyBindings()
- {
- var result = new Dictionary();
- foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
- {
- var raw = GetSetting($"Hotkey.{id}");
- if (raw == null) continue; // not saved = unbound
- if (raw == "none") continue; // explicitly unbound
- try
- {
- result[id] = HotkeyChord.ParseStorage(raw);
- }
- catch (FormatException)
- {
- // corrupt entry — skip, will fall back to default
- }
- }
- return result;
- }
-
- ///
- /// Save hotkey bindings. Null entries in the dictionary mean "unbound" (stored
- /// as "none"). Only the provided entries are saved; previously saved entries
- /// for HotkeyIds not in the dictionary are deleted.
- ///
- public void SaveHotkeyBindings(Dictionary bindings)
- {
- // Delete all existing hotkey settings first
- foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
- cmd.Parameters.AddWithValue("$key", $"Hotkey.{id}");
- cmd.ExecuteNonQuery();
- }
-
- // Save the new bindings
- foreach (var (id, chord) in bindings)
- {
- var value = chord?.ToString() ?? "none";
- UpsertSetting($"Hotkey.{id}", value);
- }
- }
-
- public (double Left, double Top, double Width, double Height, string State)? LoadWindowState()
- {
- var left = GetSetting("Window.Left");
- var top = GetSetting("Window.Top");
- var width = GetSetting("Window.Width");
- var height = GetSetting("Window.Height");
- var state = GetSetting("Window.State");
- if (left == null || top == null || width == null || height == null) return null;
- if (!double.TryParse(left, out var l) || !double.TryParse(top, out var t) ||
- !double.TryParse(width, out var w) || !double.TryParse(height, out var h))
- return null;
- return (l, t, w, h, state ?? "Normal");
- }
-
- public void SaveWindowState(double left, double top, double width, double height, string state)
- {
- UpsertSetting("Window.Left", left.ToString("F0"));
- UpsertSetting("Window.Top", top.ToString("F0"));
- UpsertSetting("Window.Width", width.ToString("F0"));
- UpsertSetting("Window.Height", height.ToString("F0"));
- UpsertSetting("Window.State", state);
- }
-
- private string? GetSetting(string key)
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT Value FROM Settings WHERE Key = $key;";
- cmd.Parameters.AddWithValue("$key", key);
- return cmd.ExecuteScalar() as string;
- }
-
- private void UpsertSetting(string key, string value)
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
- cmd.Parameters.AddWithValue("$key", key);
- cmd.ExecuteNonQuery();
-
- using var insert = _connection.CreateCommand();
- insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ($key, $value);";
- insert.Parameters.AddWithValue("$key", key);
- insert.Parameters.AddWithValue("$value", value);
- insert.ExecuteNonQuery();
- }
-
public void Dispose()
{
_connection.Dispose();
diff --git a/ViewModels/index.md b/ViewModels/index.md
index cce9911..0ca2f2c 100644
--- a/ViewModels/index.md
+++ b/ViewModels/index.md
@@ -70,5 +70,6 @@ commit, tag `refactor-commit-N`, no per-commit push).
- [x] **Commit B · `refactor-commit-B`** — **Streaming** → `MainViewModel.Streaming.cs` 789 → **330** + new **`MainViewModel.Streaming.Operations.cs`** (459 lines). Moved the go-live/record/health operation bodies (`StartSession`, `BeginRecordOnly`, `BeginGoLive`, `StartRecordFile`, `PrepareAndStartLiveAsync`, `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue`, `StopStream`, `FinalizeRecordingAsync`, `UniquePath`, `ChooseRecordFolder`, `ResetRecordFolder`, `DefaultRecordFolder`, `OnFramePumpFailed`, `OnFramePumpHealthUpdated`, `ApplyHealth`, `ResetHealth`, `BuildEncoderOptions`) into the Operations partial; Streaming.cs keeps state + quality/resolution tiers + session timer + command declarations. Pruned dead usings both files. Pending: Background, SocialsDialogViewModel, LayoutStore.
- [x] **Commit C · `refactor-commit-C`** — **Background** → `MainViewModel.Background.cs` 567 → **418** + new **`MainViewModel.Background.Model.cs`** (163 lines). Moved the static scene-model helpers (`LoadBackgroundImage`, `CreatePlaceholderSnapshot`, `EnsureBackground`, `CreateBackground`, `NormalizeBackgrounds`, `AllBackgrounds`, `ResolveAutoCaptureKey`, `MonitorKeyPrefix`) into the Model partial; Background.cs keeps capture/live state + operations (`UpdateActiveBackground`, `UpdateBackgroundImage`, `RefreshSnapshotsAsync`, `ReacquireScreenCaptures`, `OnScreenPreviewBitmapChanged`, `OnFullscreenMonitorChanged`, `RefreshBackgroundAutoCapture`, `BrowseBackground`, `ClearBackgroundCaptureAsync`, `RedesignateBackgroundAsync`, `ChangeBackgroundCaptureAsync`, `SetBackgroundCapture`, `DefaultBackgroundAsset`, `HealBackgrounds`, `StampDefaultBackgroundAsset`). Pending: SocialsDialogViewModel, LayoutStore.
- [x] **Commit D · `refactor-commit-D`** — **SocialsDialog** → `SocialsDialogViewModel.cs` 515 → **334** + new **`SocialSlotViewModel.cs`** (183 lines). Moved the `DialogEntry` record + the row-level `SocialSlotViewModel` (six-fixed-slot row VM: service/logo/lock/edit/validate state + computed `Show*` flags) into its own file; `SocialsDialogViewModel.cs` keeps the dialog VM (sign-in gate, validation orchestration, commit). `DialogEntry` stays `public` in the same namespace so both files see it. Pending: LayoutStore.
+- [x] **Commit E · `refactor-commit-E`** — **LayoutStore** → `LayoutStore.cs` 1402 split into 6 concern-based partials: **`LayoutStore.cs`** (47: shell — `Instance`, fields, ctor + `EnsureSchema`, `GetUserVersion`, `Dispose`), **`LayoutStore.Migrations.cs`** (495: all `Migrate*` + `EnsureSchema`), **`LayoutStore.Load.cs`** (221: `Load`), **`LayoutStore.Save.cs`** (339: `Save`), **`LayoutStore.Settings.cs`** (292: mic/record-folder/reusable-stream + license/transition/broadcast/hotkey/window `GetSetting`/`UpsertSetting`), **`LayoutStore.Assets.cs`** (41: `GetAssetBytes`/`UpsertAsset`). **PHASE 3 COMPLETE — no production `.cs` over 500.** Pending: session wrap-up (HANDOFF.md) + push on approval.
The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split
proceeds; each partial carries its own `using`s and re-declares nothing from core.
\ No newline at end of file