a11b15e444
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.
365 lines
19 KiB
C#
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();
|
|
}
|
|
}
|