using Microsoft.Data.Sqlite; using Notification.Core; using ytLive.Services.Compositor; using ytLive.Models; namespace ytLive.Services; public partial class LayoutStore : IDisposable { public NotificationPosition LoadNotificationPosition() { var value = GetSetting("Notifications.Position"); return Enum.TryParse(value, true, out NotificationPosition position) && IsSupportedNotificationPosition(position) ? position : NotificationPosition.BottomRight; } public void SaveNotificationPosition(NotificationPosition position) { if (!IsSupportedNotificationPosition(position)) position = NotificationPosition.BottomRight; UpsertSetting("Notifications.Position", position.ToString()); } public bool LoadStickyAlertsUntilDismissed() => GetSetting("Notifications.StickyUntilDismissed") == "1"; public void SaveStickyAlertsUntilDismissed(bool enabled) => UpsertSetting("Notifications.StickyUntilDismissed", enabled ? "1" : "0"); /// The creator's own video rate for one tier, in Mbps, or null to use the shipped default. /// Per tier because each has its own ceiling; the rate is a target the encoder is asked to hold, /// so a stored number means the same thing on every run (CBR). public double? LoadRatedMbps(string tierDisplay) => double.TryParse(GetSetting($"Video.Rate.{tierDisplay}"), out var mbps) && mbps > 0 ? mbps : null; /// Finished sessions, oldest first — the evidence behind any rate suggestion. Capped so the /// row never grows without bound: 40 sessions is several weeks of streaming for anyone. public List LoadRateHistory() { var raw = GetSetting("Video.RateHistory"); if (string.IsNullOrWhiteSpace(raw)) return new List(); return raw.Split('\n') .Select(RateHistory.Parse) .Where(entry => entry is not null) .Select(entry => entry!.Value) .ToList(); } public void AppendRateHistory(SessionRateResult entry) { var history = LoadRateHistory(); history.Add(entry); if (history.Count > RateHistoryCap) history.RemoveRange(0, history.Count - RateHistoryCap); UpsertSetting("Video.RateHistory", string.Join('\n', history.Select(RateHistory.Format))); } private const int RateHistoryCap = 40; /// The proposed rate the creator already refused for this tier, or zero for none. Stored as the /// number rather than a flag, so a materially different suggestion may still be asked. public double LoadDismissedRateProposal(string tierDisplay) => double.TryParse(GetSetting($"Video.RateProposalDismissed.{tierDisplay}"), out var mbps) ? mbps : 0; public void SaveDismissedRateProposal(string tierDisplay, double mbps) => UpsertSetting($"Video.RateProposalDismissed.{tierDisplay}", mbps.ToString("0.#")); public void SaveRatedMbps(string tierDisplay, double? mbps) { if (mbps is not > 0) { using var delete = _connection.CreateCommand(); delete.CommandText = "DELETE FROM Settings WHERE Key = $key;"; delete.Parameters.AddWithValue("$key", $"Video.Rate.{tierDisplay}"); delete.ExecuteNonQuery(); return; } UpsertSetting($"Video.Rate.{tierDisplay}", mbps.Value.ToString("0.#")); } private static bool IsSupportedNotificationPosition(NotificationPosition position) => position is NotificationPosition.TopLeft or NotificationPosition.TopRight or NotificationPosition.CenterLeft or NotificationPosition.CenterRight or NotificationPosition.BottomLeft or NotificationPosition.BottomRight; /// 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 global audio-sync offset in milliseconds (TASK 22), −500..500, /// default 0: positive delays the mix (audio ahead of video), negative /// advances it (audio behind video). Restored at startup so the mixer starts /// on the user's offset. public int LoadAudioSyncOffsetMs() { var value = GetSetting("Audio.SyncOffsetMs"); return int.TryParse(value, out var ms) ? Math.Clamp(ms, -500, 500) : 0; } /// The shipped default for the microphone-only delay. The creator's measurement for a /// C920 pipeline (asked for +150..+200 ms); a camera with less latency wants less. public const int DefaultMicSyncOffsetMs = 175; /// Microphone-only sync delay (0..500 ms), so the voice can wait for the camera's picture /// without moving desktop or alert audio. Absent means the shipped default. public int LoadMicSyncOffsetMs() { var value = GetSetting("Audio.MicSyncOffsetMs"); return int.TryParse(value, out var ms) ? Math.Clamp(ms, 0, 500) : DefaultMicSyncOffsetMs; } public void SaveMicSyncOffsetMs(int ms) => UpsertSetting("Audio.MicSyncOffsetMs", Math.Clamp(ms, 0, 500).ToString()); public void SaveAudioSyncOffsetMs(int ms) { UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString()); } /// Zoom box follow mode (creator ruling 2026-11-01): false = ANCHOR (the capture /// stays on the point that was clicked, the default), true = FOLLOW (the capture re-anchors /// to the box centre while it is dragged). /// TASK 64: key a detected green/blue screen without asking. Off by default — the /// app offers, the creator accepts; this switch is for the creator who is tired of clicking. /// Creator closed the Steam licence indicator while ownership was verified. Honoured only /// while Steam keeps confirming ownership — losing it brings the indicator back (2026-11-05). public bool LoadSteamLicenceIndicatorDismissed() => GetSetting("Steam.LicenceIndicatorDismissed") == "1"; public void SaveSteamLicenceIndicatorDismissed(bool dismissed) => UpsertSetting("Steam.LicenceIndicatorDismissed", dismissed ? "1" : "0"); /// TASK 3·20: show money in the app's own analytics surfaces. OFF by default — most /// creators never monetize, and a $0.00 report is a spit in the face (creator ruling 2026-11-05). public bool LoadShowMonetaryMetrics() => GetSetting("Metrics.ShowMonetary") == "1"; public void SaveShowMonetaryMetrics(bool show) => UpsertSetting("Metrics.ShowMonetary", show ? "1" : "0"); /// TASK 65: replay clips. Off by default — the ring costs memory and the feature is opt-in. public bool LoadClipsEnabled() => GetSetting("Clips.Enabled") == "1"; public void SaveClipsEnabled(bool enabled) => UpsertSetting("Clips.Enabled", enabled ? "1" : "0"); /// Clip length in seconds: 10 or 15 (default 10). public int LoadClipSeconds() => int.TryParse(GetSetting("Clips.Seconds"), out var s) && (s == 10 || s == 15) ? s : 10; public void SaveClipSeconds(int seconds) => UpsertSetting("Clips.Seconds", seconds.ToString()); /// How many clips survive on disk: 3, 4 or 5 (default 3). public int LoadClipKeep() => int.TryParse(GetSetting("Clips.Keep"), out var k) && k >= 3 && k <= 5 ? k : 3; public void SaveClipKeep(int keep) => UpsertSetting("Clips.Keep", keep.ToString()); public bool LoadAutoChromaScreen() => GetSetting("Video.AutoChromaScreen") == "1"; public void SaveAutoChromaScreen(bool enabled) => UpsertSetting("Video.AutoChromaScreen", enabled ? "1" : "0"); public bool LoadZoomFollowBox() => GetSetting("Zoom.FollowBox") == "1"; public void SaveZoomFollowBox(bool follow) => UpsertSetting("Zoom.FollowBox", follow ? "1" : "0"); /// Mono output (creator ruling 2026-10-30): folds the mix so single-sided audio is /// audible on both sides. Restored at startup — an accessibility switch that resets is a /// failure, not a default. public bool LoadMonoOutput() => GetSetting("Audio.MonoOutput") == "1"; public void SaveMonoOutput(bool enabled) => UpsertSetting("Audio.MonoOutput", enabled ? "1" : "0"); /// Scoreboard master switch (creator 2026-10-30). Persisted because "off" also means /// "stop polling" — a switch that resets would quietly resume spending quota. public bool LoadScoreboardEnabled() => GetSetting("Scoreboard.Enabled") != "0"; public void SaveScoreboardEnabled(bool enabled) => UpsertSetting("Scoreboard.Enabled", enabled ? "1" : "0"); /// Scoreboard marquee speed (px/s), restored at startup. public int LoadScoreboardScrollSpeed() => int.TryParse(GetSetting("Scoreboard.ScrollSpeed"), out var px) ? Math.Clamp(px, (int)ScoreboardPresenter.MinPixelsPerSecond, (int)ScoreboardPresenter.MaxPixelsPerSecond) : (int)ScoreboardPresenter.DefaultPixelsPerSecond; public void SaveScoreboardScrollSpeed(int pixelsPerSecond) => UpsertSetting("Scoreboard.ScrollSpeed", pixelsPerSecond.ToString()); /// 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); } public 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; } public 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(); } internal const string AlertDefaultVideoKey = "AlertDefaultVideoAssetId"; /// Stamps the shipped stream-alert placeholder clip (TASK 47): like any /// source image it lives as a BLOB in the internal Asset table, tracked by this /// Setting so the Save pruner keeps it alive. The creator's OWN replacement /// videos are never stored — they are plain path references (the 2026-09-26 /// decision: the file the user picks stays at its real location on disk). /// UpsertAsset is hash-keyed, so re-stamping the same bytes returns the /// same id. public string EnsureAlertDefaultVideo(byte[] data) { var saved = GetSetting(AlertDefaultVideoKey); if (!string.IsNullOrWhiteSpace(saved) && GetAssetBytes(saved) != null) return saved; var id = UpsertAsset(data, 0, 0); UpsertSetting(AlertDefaultVideoKey, id); return id; } /// The stored default clip's Asset id, or null when never stamped. public string? GetAlertDefaultVideoId() => GetSetting(AlertDefaultVideoKey); internal const string OnAirImageKey = "OnAirImageAssetId"; /// The strip tile that labels the scene currently PREVIEWED (creator /// ruling 2026-10-10 — the "on-air background screen"). One shared image, not /// per-scene: whichever scene is staged shows it in the top bar, so it "jumps /// around" as the creator swaps scenes. Lives as a BLOB in the internal Asset /// table, tracked by this Setting so the Save pruner keeps it alive — the same /// contract as the alert default clip. UpsertAsset is hash-keyed, so /// re-stamping the same bytes returns the same id. public string EnsureOnAirImage(byte[] data) { var saved = GetSetting(OnAirImageKey); if (!string.IsNullOrWhiteSpace(saved) && GetAssetBytes(saved) != null) return saved; var id = UpsertAsset(data, 0, 0); UpsertSetting(OnAirImageKey, id); return id; } /// The stored on-air image's Asset id, or null when never stamped. public string? GetOnAirImageId() => GetSetting(OnAirImageKey); private const string BrandLogoAssetKey = "BrandLogoAssetId"; private const string BrandLogoNameKey = "BrandLogoFileName"; public string? GetBrandLogoAssetId() => GetSetting(BrandLogoAssetKey); public string? GetBrandLogoFileName() => GetSetting(BrandLogoNameKey); /// Stores the brand logo image as an asset and records its file name for display. public string SetBrandLogo(byte[] data, string fileName) { var id = UpsertAsset(data, 0, 0); UpsertSetting(BrandLogoAssetKey, id); UpsertSetting(BrandLogoNameKey, fileName); return id; } }