Multi-scene webcam (schema v3/v4): singleton Webcam + per-scene WebcamSceneConfig, right-click OBS-style border/context menu, persisted round-to-rect restore + legacy-square 16:9 heal, dark MenuItem template, tests (25 passing)
This commit is contained in:
+302
-14
@@ -12,6 +12,9 @@ public class LayoutStore : IDisposable
|
||||
private readonly SqliteConnection _connection;
|
||||
public string ActivePath { get; }
|
||||
|
||||
/// <summary>The app-wide webcam identity loaded with the last Load() (null = never picked).</summary>
|
||||
public Webcam? Webcam { get; private set; }
|
||||
|
||||
public LayoutStore(string path)
|
||||
{
|
||||
ActivePath = path;
|
||||
@@ -31,7 +34,6 @@ public class LayoutStore : IDisposable
|
||||
{
|
||||
string[] statements =
|
||||
{
|
||||
"PRAGMA user_version = 2;",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Scene (
|
||||
Id TEXT PRIMARY KEY,
|
||||
@@ -70,6 +72,35 @@ public class LayoutStore : IDisposable
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Webcam (
|
||||
Id TEXT PRIMARY KEY,
|
||||
DeviceId TEXT NOT NULL UNIQUE,
|
||||
Name TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS WebcamSceneConfig (
|
||||
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
|
||||
WebcamId TEXT NOT NULL REFERENCES Webcam(Id) ON DELETE CASCADE,
|
||||
IsVisible INTEGER NOT NULL DEFAULT 1,
|
||||
X REAL NOT NULL DEFAULT 0,
|
||||
Y REAL NOT NULL DEFAULT 0,
|
||||
Width REAL NOT NULL DEFAULT 0,
|
||||
Height REAL NOT NULL DEFAULT 0,
|
||||
Opacity REAL NOT NULL DEFAULT 1,
|
||||
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
|
||||
IsMirrored INTEGER NOT NULL DEFAULT 0,
|
||||
RectWidth REAL,
|
||||
RectHeight REAL,
|
||||
BorderColor TEXT,
|
||||
BorderOpacity REAL NOT NULL DEFAULT 1,
|
||||
BorderWidth INTEGER NOT NULL DEFAULT 0,
|
||||
BorderAnimation TEXT NOT NULL DEFAULT 'None',
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (SceneId, WebcamId)
|
||||
);
|
||||
""",
|
||||
};
|
||||
foreach (var sql in statements)
|
||||
{
|
||||
@@ -78,6 +109,21 @@ public class LayoutStore : IDisposable
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
MigrateSourceTable();
|
||||
MigrateWebcamConfigTable();
|
||||
if (GetUserVersion() < 3)
|
||||
MigrateToV3();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA user_version = 4;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
private int GetUserVersion()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "PRAGMA user_version;";
|
||||
return Convert.ToInt32(cmd.ExecuteScalar());
|
||||
}
|
||||
|
||||
// v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
|
||||
@@ -109,10 +155,117 @@ public class LayoutStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
|
||||
// so a reloaded Round webcam restores its aspect on toggle-back).
|
||||
private void MigrateWebcamConfigTable()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(WebcamSceneConfig);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (!columns.Contains("RectWidth"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectWidth REAL;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("RectHeight"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectHeight REAL;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
// v2 → v3: the webcam leaves the Source table. Any webcam Source rows become
|
||||
// one Webcam identity (first row — one camera app-wide) + a WebcamSceneConfig
|
||||
// per scene that had one, then the webcam rows are deleted. No backfill:
|
||||
// scenes without the webcam stay webcam-free.
|
||||
private void MigrateToV3()
|
||||
{
|
||||
var rows = new List<(string Id, string SceneId, string Name, double X, double Y, double W, double H, double Opacity, string DeviceId, string ClipShape, bool IsMirrored)>();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
SELECT Id, SceneId, Name, X, Y, Width, Height, Opacity, DeviceId, ClipShape, IsMirrored
|
||||
FROM Source WHERE Type = 'Webcam' ORDER BY SortOrder;
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rows.Add((
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.GetDouble(3),
|
||||
reader.GetDouble(4),
|
||||
reader.GetDouble(5),
|
||||
reader.GetDouble(6),
|
||||
reader.GetDouble(7),
|
||||
reader.IsDBNull(8) ? string.Empty : reader.GetString(8),
|
||||
reader.GetString(9),
|
||||
reader.GetInt32(10) != 0));
|
||||
}
|
||||
}
|
||||
if (rows.Count == 0) return;
|
||||
|
||||
using var tx = _connection.BeginTransaction();
|
||||
var webcamId = Guid.NewGuid().ToString();
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
|
||||
cmd.Transaction = tx;
|
||||
cmd.Parameters.AddWithValue("$id", webcamId);
|
||||
cmd.Parameters.AddWithValue("$device", rows[0].DeviceId);
|
||||
cmd.Parameters.AddWithValue("$name", rows[0].Name);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO WebcamSceneConfig
|
||||
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
||||
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation, SortOrder)
|
||||
VALUES ($sceneId, $webcamId, 1, $x, $y, $w, $h, $opacity,
|
||||
$clip, $mirrored, NULL, 1, 0, 'None', 0);
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
cmd.Parameters.AddWithValue("$sceneId", row.SceneId);
|
||||
cmd.Parameters.AddWithValue("$webcamId", webcamId);
|
||||
cmd.Parameters.AddWithValue("$x", row.X);
|
||||
cmd.Parameters.AddWithValue("$y", row.Y);
|
||||
cmd.Parameters.AddWithValue("$w", row.W);
|
||||
cmd.Parameters.AddWithValue("$h", row.H);
|
||||
cmd.Parameters.AddWithValue("$opacity", row.Opacity);
|
||||
cmd.Parameters.AddWithValue("$clip", row.ClipShape);
|
||||
cmd.Parameters.AddWithValue("$mirrored", row.IsMirrored ? 1 : 0);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Source WHERE Type = 'Webcam';";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
public List<Scene> Load()
|
||||
{
|
||||
Webcam = null;
|
||||
var scenes = new List<Scene>();
|
||||
var sourcesByScene = new Dictionary<string, List<Source>>();
|
||||
var configsByScene = new Dictionary<string, List<WebcamSceneConfig>>();
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
@@ -130,11 +283,26 @@ public class LayoutStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, DeviceId, Name FROM Webcam;";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
Webcam = new Webcam
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
DeviceId = reader.GetString(1),
|
||||
Name = reader.GetString(2),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored
|
||||
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored
|
||||
FROM Source ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
@@ -154,9 +322,8 @@ public class LayoutStore : IDisposable
|
||||
Height = reader.GetDouble(9),
|
||||
Opacity = reader.GetDouble(10),
|
||||
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
|
||||
DeviceId = reader.IsDBNull(12) ? null : reader.GetString(12),
|
||||
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(13), out var clip) ? clip : ClipShape.Traditional,
|
||||
IsMirrored = reader.GetInt32(14) != 0,
|
||||
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
|
||||
IsMirrored = reader.GetInt32(13) != 0,
|
||||
};
|
||||
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
||||
sourcesByScene[sceneId] = list = new List<Source>();
|
||||
@@ -164,26 +331,78 @@ public class LayoutStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
||||
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
|
||||
RectWidth, RectHeight
|
||||
FROM WebcamSceneConfig ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var sceneId = reader.GetString(0);
|
||||
var config = new WebcamSceneConfig
|
||||
{
|
||||
WebcamId = reader.GetString(1),
|
||||
Name = Webcam?.Name ?? "Webcam",
|
||||
IsVisible = reader.GetInt32(2) != 0,
|
||||
X = reader.GetDouble(3),
|
||||
Y = reader.GetDouble(4),
|
||||
Width = reader.GetDouble(5),
|
||||
Height = reader.GetDouble(6),
|
||||
Opacity = reader.GetDouble(7),
|
||||
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(8), out var clip) ? clip : ClipShape.Traditional,
|
||||
IsMirrored = reader.GetInt32(9) != 0,
|
||||
BorderColor = reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
|
||||
BorderOpacity = reader.IsDBNull(11) ? 1.0 : reader.GetDouble(11),
|
||||
BorderWidth = reader.IsDBNull(12) ? 0 : reader.GetInt32(12),
|
||||
BorderAnimation = Enum.TryParse<BorderAnimation>(reader.GetString(13), out var anim) ? anim : BorderAnimation.None,
|
||||
RectWidth = reader.IsDBNull(14) ? null : reader.GetDouble(14),
|
||||
RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15),
|
||||
};
|
||||
if (!configsByScene.TryGetValue(sceneId, out var list))
|
||||
configsByScene[sceneId] = list = new List<WebcamSceneConfig>();
|
||||
list.Add(config);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
if (sourcesByScene.TryGetValue(scene.Id, out var list))
|
||||
foreach (var source in list)
|
||||
scene.Sources.Add(source);
|
||||
if (sourcesByScene.TryGetValue(scene.Id, out var sources))
|
||||
foreach (var source in sources)
|
||||
scene.Elements.Add(source);
|
||||
if (configsByScene.TryGetValue(scene.Id, out var configs))
|
||||
foreach (var config in configs)
|
||||
scene.Elements.Add(config);
|
||||
}
|
||||
|
||||
return scenes;
|
||||
}
|
||||
|
||||
public void Save(IEnumerable<Scene> scenes)
|
||||
public void Save(IEnumerable<Scene> scenes, Webcam? webcam)
|
||||
{
|
||||
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 Scene;";
|
||||
cmd.Transaction = tx;
|
||||
@@ -219,10 +438,10 @@ public class LayoutStore : IDisposable
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex, DeviceId,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex,
|
||||
ClipShape, IsMirrored, SortOrder)
|
||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||
$x, $y, $w, $h, $opacity, $monitor, $device,
|
||||
$x, $y, $w, $h, $opacity, $monitor,
|
||||
$clip, $mirrored, $sort)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
@@ -238,7 +457,6 @@ public class LayoutStore : IDisposable
|
||||
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 deviceP = cmd.Parameters.Add("$device", SqliteType.Text);
|
||||
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
|
||||
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
|
||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
@@ -246,8 +464,9 @@ public class LayoutStore : IDisposable
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
var sort = 0;
|
||||
foreach (var source in scene.Sources)
|
||||
foreach (var element in scene.Elements)
|
||||
{
|
||||
if (element is not Source source) continue;
|
||||
idP.Value = source.Id;
|
||||
sceneIdP.Value = scene.Id;
|
||||
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
|
||||
@@ -260,7 +479,6 @@ public class LayoutStore : IDisposable
|
||||
hP.Value = source.Height;
|
||||
opacityP.Value = source.Opacity;
|
||||
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
|
||||
deviceP.Value = (object?)source.DeviceId ?? DBNull.Value;
|
||||
clipP.Value = source.ClipShape.ToString();
|
||||
mirroredP.Value = source.IsMirrored ? 1 : 0;
|
||||
sortP.Value = sort++;
|
||||
@@ -269,6 +487,76 @@ public class LayoutStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
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 sort = 0;
|
||||
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
|
||||
{
|
||||
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 = sort++;
|
||||
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);";
|
||||
|
||||
Reference in New Issue
Block a user