Files
LlamaCasty/Services/LayoutStore.Save.cs
T
gramps a11b15e444 feat(alerts): TASK 47 — alert box plays a video (built-in/custom clip) + read-time fade + message ticker
TASK 43's alert box grows a real video celebration. Per-alert IAlertClipDecoder
(ffmpeg bgra + f32le pipes, real-time paced, disposed at drain) plays the shipped
Assets/alert-default.mp4 (stamped into the Asset table at startup) unless the
creator picks their own file — path reference only, never stored in the DB; the
six AlertRenderer animations stay the fallback. ~0.3s fade rides the alpha
envelope on straight-source copies (EOF freeze-frames then fades out); audio
forwards to a new AudioMixer alert ring (8s, 48k stereo) drained at unity — no
duck, creator ruling — scaled by volume × fade. An auto-composed marquee ticker
('Funder — Super Chat · $10.00', 140px/s) scrolls top-of-frame via a
FramePump._alertTicker seam through Render/CompositeLayers, mixed into the cache
signature (dynamic overlay, never baked). New Stream Alerts section in LeftPanel.

Derivative-work references (how OBS/Streamlabs alert boxes do per-alert video):
- https://support.streamlabs.com/hc/en-us/articles/217741147-Setting-Up-Your-Streamlabs-Alerts (custom image/video per alert type + variations)
- https://obsproject.com/kb/stream-tutorial-2-alerts (alert overlay as an on-screen zone)
- https://streamlabs.com/content-hub/widgets/alert-box (per-event alert playback)

Good Dog: AlertLayerVideoTests drives a fake IAlertClipDecoder through the whole
lifecycle in one pass (custom path wins, decoder spawns/disposes, fade envelope
0→127→255, audio volume×fade, ticker scrolls, EOF fade-drain to idle). It caught
the clip branch of Advance not clearing _current before AdvanceToNext — the layer
stayed IsPlaying after drain (MyMistakes post-mortem).

Full vstest 319/319; clean build 0 warnings; scope check green.
2026-09-26 11:15:17 -07:00

365 lines
19 KiB
C#

using Microsoft.Data.Sqlite;
using ytLive.Models;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
public void Save(IEnumerable<Scene> 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,
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume)
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,
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume)
""";
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);
var alertVideoAssetIdP = cmd.Parameters.Add("$alertVideoAssetId", SqliteType.Text);
var alertVideoPathP = cmd.Parameters.Add("$alertVideoPath", SqliteType.Text);
var alertUseDefaultVideoP = cmd.Parameters.Add("$alertUseDefaultVideo", SqliteType.Integer);
var alertShowTickerP = cmd.Parameters.Add("$alertShowTicker", SqliteType.Integer);
var alertVideoVolumeP = cmd.Parameters.Add("$alertVideoVolume", SqliteType.Real);
// Source and WebcamSceneConfig share ONE z-order space per scene:
// each element gets its index within scene.Elements regardless of
// table, so a webcam slotted between sources survives save→load.
// (Pre-unified rows tie at 0 — load breaks ties sources-first.)
foreach (var scene in scenes)
{
var z = 0;
foreach (var element in scene.Elements)
{
if (element is not Source source) { z++; 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 = z++;
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();
alertVideoAssetIdP.Value = (object?)source.AlertVideoAssetId ?? DBNull.Value;
alertVideoPathP.Value = (object?)source.AlertVideoPath ?? DBNull.Value;
alertUseDefaultVideoP.Value = source.AlertUseDefaultVideo ? 1 : 0;
alertShowTickerP.Value = source.AlertShowTicker ? 1 : 0;
alertVideoVolumeP.Value = source.AlertVideoVolume;
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 z = 0;
foreach (var element in scene.Elements)
{
if (element is not WebcamSceneConfig config) { z++; continue; }
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 = z++;
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
UNION
SELECT AlertVideoAssetId FROM Source WHERE AlertVideoAssetId IS NOT NULL
UNION
SELECT Value FROM Settings WHERE Key = 'AlertDefaultVideoAssetId'
);
""";
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<Source>().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();
}
}