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: **zero**. A sync offset is a correction for one
/// camera on one desk, never a product default — shipping a number would shift every creator's audio
/// to fix one machine's camera, and would hide whether the fault is theirs or ours.
///
public const int DefaultMicSyncOffsetMs = 0;
/// 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;
}
}