Files
LlamaCasty/Services/LayoutStore.Settings.cs
T
gramps 9c0c40c688 feat(settings): make slide-out alerts persist until dismissed
Add a persisted global App Settings switch. Off keeps the existing per-severity
lifetimes (errors remain sticky); on makes every alert non-expiring and closes
it when its body is clicked. The NotificationService supplies the current
setting to each toast request.

StickyNotificationIntegrationTests covers persistence and both request modes.
618 tests green, clean build with 0 warnings.

Reference: Notification.Wpf exposes a CloseOnClick request option:
https://github.com/G-Stas/Notification.Wpf
2026-11-11 10:19:20 -08:00

460 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
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;
/// <summary>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.</summary>
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();
}
/// <summary>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.</summary>
public int LoadAudioSyncOffsetMs()
{
var value = GetSetting("Audio.SyncOffsetMs");
return int.TryParse(value, out var ms) ? Math.Clamp(ms, -500, 500) : 0;
}
public void SaveAudioSyncOffsetMs(int ms)
{
UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString());
}
/// <summary>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).</summary>
/// <summary>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.</summary>
/// <summary>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).</summary>
public bool LoadSteamLicenceIndicatorDismissed() => GetSetting("Steam.LicenceIndicatorDismissed") == "1";
public void SaveSteamLicenceIndicatorDismissed(bool dismissed)
=> UpsertSetting("Steam.LicenceIndicatorDismissed", dismissed ? "1" : "0");
/// <summary>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).</summary>
public bool LoadShowMonetaryMetrics() => GetSetting("Metrics.ShowMonetary") == "1";
public void SaveShowMonetaryMetrics(bool show) => UpsertSetting("Metrics.ShowMonetary", show ? "1" : "0");
/// <summary>TASK 65: replay clips. Off by default — the ring costs memory and the feature is opt-in.</summary>
public bool LoadClipsEnabled() => GetSetting("Clips.Enabled") == "1";
public void SaveClipsEnabled(bool enabled) => UpsertSetting("Clips.Enabled", enabled ? "1" : "0");
/// <summary>Clip length in seconds: 10 or 15 (default 10).</summary>
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());
/// <summary>How many clips survive on disk: 3, 4 or 5 (default 3).</summary>
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");
/// <summary>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.</summary>
public bool LoadMonoOutput() => GetSetting("Audio.MonoOutput") == "1";
public void SaveMonoOutput(bool enabled) => UpsertSetting("Audio.MonoOutput", enabled ? "1" : "0");
/// <summary>Scoreboard master switch (creator 2026-10-30). Persisted because "off" also means
/// "stop polling" — a switch that resets would quietly resume spending quota.</summary>
public bool LoadScoreboardEnabled() => GetSetting("Scoreboard.Enabled") != "0";
public void SaveScoreboardEnabled(bool enabled) => UpsertSetting("Scoreboard.Enabled", enabled ? "1" : "0");
/// <summary>Scoreboard marquee speed (px/s), restored at startup.</summary>
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());
/// <summary>The user's chosen record output folder (TASK 18), or null to use the
/// default <c>%APPDATA%\ytLlive\recordings</c>. Restored at startup.</summary>
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();
}
/// <summary>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.</summary>
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();
}
/// <summary>When the license was last validated online (ISO 8601), or null if never.</summary>
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();
}
/// <summary>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.</summary>
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) ───
/// <summary>
/// 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.
/// </summary>
public Dictionary<HotkeyId, HotkeyChord> LoadHotkeyBindings()
{
var result = new Dictionary<HotkeyId, HotkeyChord>();
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;
}
/// <summary>
/// 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.
/// </summary>
public void SaveHotkeyBindings(Dictionary<HotkeyId, HotkeyChord?> 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";
/// <summary>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).
/// <c>UpsertAsset</c> is hash-keyed, so re-stamping the same bytes returns the
/// same id.</summary>
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;
}
/// <summary>The stored default clip's Asset id, or null when never stamped.</summary>
public string? GetAlertDefaultVideoId() => GetSetting(AlertDefaultVideoKey);
internal const string OnAirImageKey = "OnAirImageAssetId";
/// <summary>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. <c>UpsertAsset</c> is hash-keyed, so
/// re-stamping the same bytes returns the same id.</summary>
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;
}
/// <summary>The stored on-air image's Asset id, or null when never stamped.</summary>
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);
/// <summary>Stores the brand logo image as an asset and records its file name for display.</summary>
public string SetBrandLogo(byte[] data, string fileName)
{
var id = UpsertAsset(data, 0, 0);
UpsertSetting(BrandLogoAssetKey, id);
UpsertSetting(BrandLogoNameKey, fileName);
return id;
}
}