From dcb3637cca8d3f688d98b6ff0d5f0ff00de6b5eb Mon Sep 17 00:00:00 2001 From: gramps Date: Mon, 31 Aug 2026 09:10:17 -0700 Subject: [PATCH] =?UTF-8?q?refactor:=20split=20LayoutStore=20by=20concern?= =?UTF-8?q?=20=E2=80=94=20500-limit=20Phase=203,=20Commit=20E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LayoutStore.cs 1402 -> 47-line shell + 5 partials, grouped by functionality (line count is a consequence, not the goal): - LayoutStore.Migrations.cs: all DB schema migration (EnsureSchema, GetUserVersion, MigrateSourceTable/WebcamConfigTable/ToV3/SceneTable/SceneSocialBarColumn/ SocialsTable/SocialEntryTable) - LayoutStore.Load.cs: Load() scene/source read-back - LayoutStore.Save.cs: Save() full scene tree write - LayoutStore.Settings.cs: key/value settings + mic/record-folder/reusable-stream + license/transition/broadcast/hotkey/window-state GetSetting/UpsertSetting - LayoutStore.Assets.cs: GetAssetBytes/UpsertAsset blob IO - LayoutStore.cs: shell — Instance, fields, ctor, Dispose Converted to public partial class LayoutStore : IDisposable. Pruned per-file dead usings. Phase 3 DOWN: no production .cs exceeds 500 lines. Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures. Docs: ViewModels/index.md Phase 3 tracker updated in same commit. --- Services/LayoutStore.Assets.cs | 41 + Services/LayoutStore.Load.cs | 221 +++++ Services/LayoutStore.Migrations.cs | 495 ++++++++++ Services/LayoutStore.Save.cs | 339 +++++++ Services/LayoutStore.Settings.cs | 292 ++++++ Services/LayoutStore.cs | 1357 +--------------------------- ViewModels/index.md | 1 + 7 files changed, 1390 insertions(+), 1356 deletions(-) create mode 100644 Services/LayoutStore.Assets.cs create mode 100644 Services/LayoutStore.Load.cs create mode 100644 Services/LayoutStore.Migrations.cs create mode 100644 Services/LayoutStore.Save.cs create mode 100644 Services/LayoutStore.Settings.cs 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