Unit 9, the one that matters most for this creator. MASTER STRIP (top, nearest the preview): - one ladder fed by the MEASURED mix — `AudioMixer` now measures the buffer the encoder receives (`MasterLevel`, `MasterImbalanceDb`), so the master cannot show a source instead of the output - `-12.4 dB` readout, and ONE L/R flag that appears above a 6 dB gap instead of a second ladder - bitrate / FPS / dropped / duration from the existing health poll MONO OUTPUT: - `AudioFold.ToMono` averages each stereo pair into both channels, applied after the limiter and the sync delay, so the stream AND the recording get what was folded - `MonoOutput` is persisted (`Audio.MonoOutput`) and restored at startup: an accessibility switch that resets each session is an accessibility failure - one click in the strip, remembered forever Guard: AudioFoldTests (hard-left reaches the right at half amplitude, hard-right — the inaudible case — reaches the left, centred material keeps its level, mono buffers untouched) and MasterStripTests (the strip binds the measured mix + the switch, the store round-trips it, and the fold is the tested helper rather than new arithmetic). 33 audio tests green.
375 lines
16 KiB
C#
375 lines
16 KiB
C#
using Microsoft.Data.Sqlite;
|
||
using ytLive.Models;
|
||
|
||
namespace ytLive.Services;
|
||
|
||
public partial class LayoutStore : IDisposable
|
||
{
|
||
/// <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>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>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;
|
||
}
|
||
}
|