The vertical tier is a centred band crop of the SAME 1920x1080 master (band x
656.25, w 607.5, h 1080), so the 16:9 camera placement (x~1408) falls outside the
cropped window and the creator is simply not in the vertical stream. Each scene
now keeps its own vertical position, size and shape; switching back to 16:9
restores the desktop layout exactly (the creator's locked table, 2026-10-17).
Geometry only - NO render-path change. The preview and the compositor both read
the element's own X/Y/W/H + ClipShape, so the tier switch swaps the LIVE geometry:
- Models/VerticalCameraLayout.cs (new): the pure math - the band, its two 607x540
areas, the six spots (top/bottom x left/centre/right), the 32px inset and the
270x152 default tile (480x270 in the 1080x1920 output) in the bottom-right spot,
the corner the 16:9 default webcam uses.
- Models/WebcamSceneConfig.cs: the vertical placement lives in seven additive
columns (VerticalX/Y/Width/Height/ClipShape/RectWidth/RectHeight; NULL = "this
scene has no vertical placement yet"). The 16:9 layout stays in the old columns
and is parked in the transient Landscape* fields while the vertical tier is
live; Begin/EndVerticalLayout + CommitLiveLayoutToVertical do the swap. The
X/Y/W/H overrides clamp every write into the band - outside it the camera is
invisible in the vertical output.
- Models/SceneElement.cs: X/Y/Width/Height became virtual so the derived element
constrains every writer (drag, resize handle, typed fields) in ONE choke point
instead of three writers each re-implementing the rule.
- ViewModels/MainViewModel.Webcam.cs + .Streaming.cs: IsVerticalTier/VerticalBand,
ResolveVerticalCameraLayout(s) (the tier swap), MoveWebcamInVerticalTier /
SnapWebcamInVerticalTier (the six-spot snap, by the tile's centre),
EnsureVerticalCameraLayout; ApplyStreamQuality is the tier-change hook. The
Live scene gets its small tile automatically on the first vertical switch (the
vertical stream would otherwise have no camera at all); the other scenes keep
their 16:9 placement until the creator drags them into the band.
- Controls/PreviewPane.xaml.cs: the drag/resize route through the VM snap under
the vertical tier instead of free-floating over the master.
Controls/LeftPanel.xaml: the webcam properties gained a Geometry section
(X/Y/W/H) for the manual entry the creator asked for, bound to the LIVE
placement, gated on the VM's IsWebcamSelected (NOT SelectedElement.IsWebcam -
the TASK 51 test finds that panel by binding text and would match twice).
- The store: seven guarded ADD COLUMNs, the INSERT/SELECT extended, and the
X/Y/W/H/ClipShape columns stay the 16:9 layout - while the vertical tier is
live the save writes the parked values there and the live ones into Vertical*.
Test: ytLive.Tests/VerticalWebcamLayoutTests.cs - the ONE Good Dog test. The
vertical tier brings the Live camera INTO the band as a small tile; dragging it to
the top-right lands it top-right inside the band; back to 16:9 the desktop layout
is restored exactly (1408/778/480x270); both placements survive a save + reload.
Full suite 429/429, 0 warnings. LayerReorderPersistenceTests.RealMouseDrag fails
ALONE on a clean tree in this environment - pre-existing flake, proven by stashing
this work, not a regression.
Derivative work: OBS has no built-in per-aspect-ratio layout storage - the
maintainers' answer is "create a second scene and add the scene you want to send
as source" (obsproject.com/forum/threads/161307) - and Streamyard's dual-aspect
guide says the same ("no built-in way to store separate layouts per aspect ratio",
so you rely on manual workarounds and plugins):
https://streamyard.com/blog/dual-aspect-ratio-streaming-tool
Our per-scene, per-tier stored placement, resolved on the tier change, is the
step past both.
Memory in the same commit: ai.md (new section), TASKS.md + TASKS/task-57-*.md
(Unit 1 shipped, Unit 2 = the Chat scene still open), HANDOFF.md, MyMistakes.md
(the per-tier geometry-swap recipe, the binding-text panel trap, the store's
webcam-identity rule) and the Models/Services/ViewModels/Controls index rows.
614 lines
25 KiB
C#
614 lines
25 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using ytLive.Models;
|
|
|
|
namespace ytLive.Services;
|
|
|
|
public partial class LayoutStore : IDisposable
|
|
{
|
|
private void EnsureSchema()
|
|
{
|
|
string[] statements =
|
|
{
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS Scene (
|
|
Id TEXT PRIMARY KEY,
|
|
Name TEXT NOT NULL,
|
|
IsHidden INTEGER NOT NULL DEFAULT 0,
|
|
IsChatScene INTEGER NOT NULL DEFAULT 0,
|
|
HasBackground INTEGER NOT NULL DEFAULT 1,
|
|
SortOrder INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS Asset (
|
|
Id TEXT PRIMARY KEY,
|
|
Hash TEXT NOT NULL UNIQUE,
|
|
Data BLOB NOT NULL,
|
|
PixelWidth INTEGER NOT NULL DEFAULT 0,
|
|
PixelHeight INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS Source (
|
|
Id TEXT PRIMARY KEY,
|
|
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
|
|
AssetId TEXT REFERENCES Asset(Id),
|
|
Type TEXT NOT NULL,
|
|
Name TEXT NOT NULL,
|
|
IsEnabled 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,
|
|
MonitorIndex INTEGER,
|
|
DeviceId TEXT,
|
|
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
|
|
IsMirrored INTEGER NOT NULL DEFAULT 0,
|
|
IsBackground INTEGER NOT NULL DEFAULT 0,
|
|
CaptureKey TEXT,
|
|
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',
|
|
BackgroundEffect TEXT NOT NULL DEFAULT 'None',
|
|
BackgroundStrength REAL NOT NULL DEFAULT 0.5,
|
|
SortOrder INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (SceneId, WebcamId)
|
|
);
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS Socials (
|
|
Id TEXT PRIMARY KEY,
|
|
BarPosition TEXT NOT NULL DEFAULT 'Bottom',
|
|
BarJustify TEXT NOT NULL DEFAULT 'Center'
|
|
);
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS SocialEntry (
|
|
Id TEXT PRIMARY KEY,
|
|
SocialsId TEXT NOT NULL REFERENCES Socials(Id) ON DELETE CASCADE,
|
|
Service TEXT NOT NULL,
|
|
Handle TEXT NOT NULL,
|
|
ProfileUrl TEXT NOT NULL,
|
|
SortOrder INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS Music (
|
|
Id TEXT PRIMARY KEY,
|
|
TrackPath TEXT NOT NULL,
|
|
IsEnabled INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS Settings (
|
|
Key TEXT PRIMARY KEY,
|
|
Value TEXT NOT NULL
|
|
);
|
|
""",
|
|
// v9 → v10 (TASK 39): YPP journey-tracker snapshots, appended on every
|
|
// stats refresh. The history is the raw data a later analytics slice
|
|
// reads to compute velocity/(de)acceleration/ETA.
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS YppSnapshot (
|
|
CapturedAtUtc TEXT NOT NULL PRIMARY KEY,
|
|
SubscriberCount INTEGER NOT NULL,
|
|
VideoCount INTEGER NOT NULL,
|
|
TotalViews INTEGER NOT NULL,
|
|
UploadsLast90Days INTEGER NOT NULL,
|
|
CommunityGuidelinesGoodStanding INTEGER NOT NULL,
|
|
CopyrightStrikesGoodStanding INTEGER NOT NULL,
|
|
ContentIdClaimsGoodStanding INTEGER NOT NULL,
|
|
OverallGoodStanding INTEGER NOT NULL
|
|
);
|
|
""",
|
|
};
|
|
foreach (var sql in statements)
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = sql;
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
MigrateSourceTable();
|
|
MigrateWebcamConfigTable();
|
|
MigrateSceneTable();
|
|
MigrateSceneSocialBarColumn();
|
|
MigrateSceneThumbnailColumn();
|
|
MigrateSocialsTable();
|
|
MigrateSocialEntryTable();
|
|
if (GetUserVersion() < 3)
|
|
MigrateToV3();
|
|
using (var cmd = _connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = "PRAGMA user_version = 10;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
}
|
|
|
|
|
|
// v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
|
|
// CREATE TABLE IF NOT EXISTS doesn't touch existing tables, so pre-v2 DBs
|
|
// get the columns here instead.
|
|
private void MigrateSourceTable()
|
|
{
|
|
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
using (var cmd = _connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = "PRAGMA table_info(Source);";
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
columns.Add(reader.GetString(1));
|
|
}
|
|
|
|
if (!columns.Contains("ClipShape"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ClipShape TEXT NOT NULL DEFAULT 'Traditional';";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
if (!columns.Contains("AlertDisplayMethod"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertDisplayMethod INTEGER NOT NULL DEFAULT 0;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
if (!columns.Contains("IsMirrored"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsMirrored INTEGER NOT NULL DEFAULT 0;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
if (columns.Contains("IsBackdrop"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source RENAME COLUMN IsBackdrop TO IsBackground;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
else if (!columns.Contains("IsBackground"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBackground INTEGER NOT NULL DEFAULT 0;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
if (!columns.Contains("CaptureKey"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN CaptureKey TEXT;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
// TASK 3.18: ChatBox overlay source
|
|
if (!columns.Contains("ChatFontSize"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontSize REAL NOT NULL DEFAULT 16;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
if (!columns.Contains("ChatFontColor"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontColor TEXT NOT NULL DEFAULT '#FFFFFF';";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
if (!columns.Contains("ChatBackgroundColor"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatBackgroundColor TEXT NOT NULL DEFAULT '#80000000';";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
if (!columns.Contains("ChatMaxMessages"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMaxMessages INTEGER NOT NULL DEFAULT 5;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
if (!columns.Contains("ChatShowTimestamps"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowTimestamps INTEGER NOT NULL DEFAULT 1;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
if (!columns.Contains("ChatShowBadges"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowBadges INTEGER NOT NULL DEFAULT 1;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
if (!columns.Contains("ChatMemberColor"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMemberColor TEXT NOT NULL DEFAULT '#e94560';";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
if (!columns.Contains("ChatSuperChatColor"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatSuperChatColor TEXT NOT NULL DEFAULT '#eab308';";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
if (!columns.Contains("ShowDesktop"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ShowDesktop INTEGER NOT NULL DEFAULT 0;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
if (!columns.Contains("WebUri"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN WebUri TEXT;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
// TASK 21: Media source (video/audio file playback)
|
|
if (!columns.Contains("MediaPath"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPath TEXT;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
if (!columns.Contains("MediaIsLooping"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaIsLooping INTEGER NOT NULL DEFAULT 0;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
if (!columns.Contains("MediaVolume"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaVolume REAL NOT NULL DEFAULT 1;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
if (!columns.Contains("MediaPlaybackState"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPlaybackState TEXT NOT NULL DEFAULT 'Stopped';";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
// TASK 47: alert video config. The default clip asset id + the creator's
|
|
// replacement path (a FILE reference, never a stored BLOB) + the pill,
|
|
// ticker toggle and volume live on the row.
|
|
if (!columns.Contains("AlertVideoAssetId"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertVideoAssetId TEXT;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
if (!columns.Contains("AlertVideoPath"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertVideoPath TEXT;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
if (!columns.Contains("AlertUseDefaultVideo"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertUseDefaultVideo INTEGER NOT NULL DEFAULT 1;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
if (!columns.Contains("AlertShowTicker"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertShowTicker INTEGER NOT NULL DEFAULT 1;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
if (!columns.Contains("AlertVideoVolume"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertVideoVolume REAL NOT NULL DEFAULT 1;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
// Rename legacy "Backdrop" display name to "Background" in existing rows
|
|
using (var cmd = _connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = "UPDATE Source SET Name = 'Background' WHERE Name = 'Backdrop';";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
// TASK 51: per-webcam background effect + strength (additive, no version bump).
|
|
if (!columns.Contains("BackgroundEffect"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN BackgroundEffect TEXT NOT NULL DEFAULT 'None';";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
if (!columns.Contains("BackgroundStrength"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN BackgroundStrength REAL NOT NULL DEFAULT 0.5;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
// TASK 57 (the "vv" feature): the vertical (9:16) tier keeps its OWN
|
|
// camera placement per scene — position, size, shape and the pre-Round
|
|
// rect. Additive, no version bump: a NULL column means "this scene has
|
|
// no vertical placement yet" (the scene keeps its 16:9 placement).
|
|
AddWebcamConfigColumnIfMissing(columns, "VerticalX", "REAL");
|
|
AddWebcamConfigColumnIfMissing(columns, "VerticalY", "REAL");
|
|
AddWebcamConfigColumnIfMissing(columns, "VerticalWidth", "REAL");
|
|
AddWebcamConfigColumnIfMissing(columns, "VerticalHeight", "REAL");
|
|
AddWebcamConfigColumnIfMissing(columns, "VerticalClipShape", "TEXT");
|
|
AddWebcamConfigColumnIfMissing(columns, "VerticalRectWidth", "REAL");
|
|
AddWebcamConfigColumnIfMissing(columns, "VerticalRectHeight", "REAL");
|
|
}
|
|
|
|
// One guarded ADD COLUMN for the webcam config's vertical-placement columns.
|
|
private void AddWebcamConfigColumnIfMissing(HashSet<string> columns, string name, string type)
|
|
{
|
|
if (columns.Contains(name)) return;
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = $"ALTER TABLE WebcamSceneConfig ADD COLUMN {name} {type};";
|
|
cmd.ExecuteNonQuery();
|
|
columns.Add(name);
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
// v5 → v6: Scene gains HasBackground. Added columns default to true; the
|
|
// ONE-TIME backfill (runs only when the column is first added) turns every
|
|
// non-Live canonical scene off and drops their background sources — the fix for
|
|
// DBs saved before the Live-only policy. MainViewModel.NormalizeBackgrounds
|
|
// re-runs the same normalization on every load for DBs that miss the backfill.
|
|
private void MigrateSceneTable()
|
|
{
|
|
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
using (var cmd = _connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = "PRAGMA table_info(Scene);";
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
columns.Add(reader.GetString(1));
|
|
}
|
|
|
|
if (columns.Contains("HasBackdrop"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Scene RENAME COLUMN HasBackdrop TO HasBackground;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
else if (columns.Contains("HasBackground"))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!columns.Contains("HasBackground"))
|
|
{
|
|
using var cmd = _connection.CreateCommand();
|
|
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN HasBackground INTEGER NOT NULL DEFAULT 1;";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
const string noBackground = "('starting', 'brb', 'chat', 'ending')";
|
|
using (var cmd = _connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = $"UPDATE Scene SET HasBackground = 0 WHERE LOWER(TRIM(Name)) IN {noBackground};";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
using (var cmd = _connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = $"""
|
|
DELETE FROM Source
|
|
WHERE IsBackground = 1 AND SceneId IN
|
|
(SELECT Id FROM Scene WHERE LOWER(TRIM(Name)) IN {noBackground});
|
|
""";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
}
|
|
|
|
// v6 → v7: Scene gains HasSocialBar (the per-scene social-bar toggle).
|
|
// Defaults to 0 (off) — the creator adds it explicitly via the footer [+].
|
|
private void MigrateSceneSocialBarColumn()
|
|
{
|
|
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
using (var cmd = _connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = "PRAGMA table_info(Scene);";
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
columns.Add(reader.GetString(1));
|
|
}
|
|
|
|
if (columns.Contains("HasSocialBar")) return;
|
|
|
|
using var alter = _connection.CreateCommand();
|
|
alter.CommandText = "ALTER TABLE Scene ADD COLUMN HasSocialBar INTEGER NOT NULL DEFAULT 0;";
|
|
alter.ExecuteNonQuery();
|
|
}
|
|
|
|
// 2026-10-10: Scene gains ThumbnailAssetId (the top-strip tile artwork, stored
|
|
// as an Asset row like every image). Additive, no version bump — old DBs just
|
|
// get a NULL column and the strip falls back to the scene's background art.
|
|
private void MigrateSceneThumbnailColumn()
|
|
{
|
|
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
using (var cmd = _connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = "PRAGMA table_info(Scene);";
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
columns.Add(reader.GetString(1));
|
|
}
|
|
|
|
if (columns.Contains("ThumbnailAssetId")) return;
|
|
|
|
using var alter = _connection.CreateCommand();
|
|
alter.CommandText = "ALTER TABLE Scene ADD COLUMN ThumbnailAssetId TEXT;";
|
|
alter.ExecuteNonQuery();
|
|
}
|
|
|
|
// v7 → v8: Socials gains BarEnabled (the dialog's show/hide switch). The old
|
|
// BarJustify column stays for back-compat but is no longer read or written.
|
|
private void MigrateSocialsTable()
|
|
{
|
|
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
using (var cmd = _connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = "PRAGMA table_info(Socials);";
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
columns.Add(reader.GetString(1));
|
|
}
|
|
|
|
if (columns.Contains("BarEnabled")) return;
|
|
|
|
using var alter = _connection.CreateCommand();
|
|
alter.CommandText = "ALTER TABLE Socials ADD COLUMN BarEnabled INTEGER NOT NULL DEFAULT 1;";
|
|
alter.ExecuteNonQuery();
|
|
}
|
|
|
|
// v8 → v9: SocialEntry gains Software (the fediverse instance's nodeinfo
|
|
// software name) so a reloaded entry still shows the right service logo.
|
|
private void MigrateSocialEntryTable()
|
|
{
|
|
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
using (var cmd = _connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = "PRAGMA table_info(SocialEntry);";
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
columns.Add(reader.GetString(1));
|
|
}
|
|
|
|
if (columns.Contains("Software")) return;
|
|
|
|
using var alter = _connection.CreateCommand();
|
|
alter.CommandText = "ALTER TABLE SocialEntry ADD COLUMN Software TEXT;";
|
|
alter.ExecuteNonQuery();
|
|
}
|
|
}
|