Compare commits

..

3 Commits

Author SHA1 Message Date
gramps 3107f928ab refactor: extract recording-output concern into MainViewModel.Recording.cs — Commit F
The original premise (extract FFmpegEncoder / StreamHealthMonitor / FramePump
classes) was a no-op — those already exist as Services/Encoder/FfmpegEncoder.cs
and Services/Encoder/FramePump.cs. The honest functional seam was the local
recording-output concern, which was interleaved with live-stream orchestration:

- New MainViewModel.Recording.cs (121): StartRecordFile, FinalizeRecordingAsync,
  UniquePath, ChooseRecordFolder, ResetRecordFolder, DefaultRecordFolder + the
  recording fields (_recordFolder, RecordFolderDisplay, _activeRecordPath,
  _recordStartTime, _recordLength).
- MainViewModel.Streaming.Operations.cs 475 -> 377 (keeps live session lifecycle,
  health, visuals).
- MainViewModel.Streaming.cs 328 -> 323 (keeps pills/state/properties intact).

Same partial class — all MVVM bound-property glue untouched, no behavior change.
Build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md tracker updated in same commit.
2026-08-31 10:21:47 -07:00
gramps 5b9ff5b742 handoff: Phase 3 complete (all production .cs <= 500), push pending for A-E + docs
Rewrite HANDOFF.md: Phase 2 is pushed at 1f4624c (stale claim corrected);
Phase 3 commit rows A-E + Controls/index.md listed as local-only, push pending
on explicit approval. Records the re-structuring principle (split by concern,
500-line count is a ceiling not the goal) and the session's truncation-accident
lesson. MyMistakes.md gains the never-awk-into-the-source-you-read rule.
2026-08-31 09:11:32 -07:00
gramps dcb3637cca refactor: split LayoutStore by concern — 500-limit Phase 3, Commit E
LayoutStore.cs 1402 -> 47-line shell + 5 partials, grouped by functionality
(line count is a consequence, not the goal):

- LayoutStore.Migrations.cs: all DB schema migration (EnsureSchema, GetUserVersion,
  MigrateSourceTable/WebcamConfigTable/ToV3/SceneTable/SceneSocialBarColumn/
  SocialsTable/SocialEntryTable)
- LayoutStore.Load.cs: Load() scene/source read-back
- LayoutStore.Save.cs: Save() full scene tree write
- LayoutStore.Settings.cs: key/value settings + mic/record-folder/reusable-stream
  + license/transition/broadcast/hotkey/window-state GetSetting/UpsertSetting
- LayoutStore.Assets.cs: GetAssetBytes/UpsertAsset blob IO
- LayoutStore.cs: shell — Instance, fields, ctor, Dispose

Converted to public partial class LayoutStore : IDisposable. Pruned per-file
dead usings. Phase 3 DOWN: no production .cs exceeds 500 lines.

Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
2026-08-31 09:10:17 -07:00
12 changed files with 1588 additions and 1509 deletions
+53 -50
View File
@@ -2,45 +2,50 @@
## Branch / Commit State ## Branch / Commit State
**`main`**, working tree is **CLEAN**. **`main`**, working tree is **CLEAN**. `origin/main` == `1f4624c` (pushed).
Both split phases are now complete. Phase 1 (view-model partials) is pushed; **Pushed** (reached `origin/main` == `1f4624c`, incl. tags): Phase 1 commit `0`
Phase 2 (MainWindow.xaml → UserControls) is complete but **local-only**. + refactor commits `1..11`, **and Phase 2 commits `12..18`** (they were pushed
at the Phase-2 milestone; the old "Phase 2 local-only" claim below was written
before that push and is stale).
**Pushed** (reached `origin/main` == `14d3ed4`, incl. tags): Phase 1 commit `0` **LOCAL ONLY (Phase 3 — A, B, C, D, E) + the Controls doc commit** — policy
and refactor commits `1..11` (`refactor-commit-0..11`), plus policy/HANDOFF. 2026-08-30: no per-commit push; `git push` only at a user-agreed milestone /
"push it". **Push pending on explicit user approval:**
**LOCAL ONLY (Phase 2 — 12, 13, 14, 15, 16, 17, 18)** — policy 2026-08-30: no | Commit | Hash | Tag | Payload |
per-commit push; `git push` only at a user-agreed milestone / "push it". |--------|------|-----|---------|
**Push pending on explicit user approval:** | docs | `cb54637` | — | `Controls/index.md` (missing per-directory map) |
| A | `6944db8` | `refactor-commit-A` | split `MainViewModel.cs` core → **495** |
| Commit | Hash | Tag | Control | From Loc | | B | `31362d1` | `refactor-commit-B` | split `MainViewModel.Streaming.cs` → **330** |
|--------|------|-----|---------|----------| | C | `fab2e09` | `refactor-commit-C` | split `MainViewModel.Background.cs` → **418** |
| 12 | `bc23b64` | `refactor-commit-12` | `Controls/BottomBar` | footer | | D | `d3271a0` | `refactor-commit-D` | trim `SocialsDialogViewModel.cs` → **334** |
| 13 | `6914252` | `refactor-commit-13` | `Controls/HealthBanner` | row 1 banner | | E | `dcb3637` | `refactor-commit-E` | split `LayoutStore.cs` → 6 partials, **≤495** |
| 14 | `90f5ad5` | `refactor-commit-14` | `Controls/TopBar` | row 0 top bar |
| 15 | `c5e3466` | `refactor-commit-15` | `Controls/PreviewPane` | center preview |
| 16 | `73397cf` | `refactor-commit-16` | `Controls/SceneThumbnailStrip` | row 0 strip |
| 17 | `c9fd1bd` | `refactor-commit-17` | `Controls/LeftPanel` | row 1 col 0 |
| 18 | `544208e` | `refactor-commit-18` | `Controls/OverlayHost` | overlay dialogs |
## What's In Flight ## What's In Flight
Nothing code-in-flight — working tree clean, **Phase 2 complete**. Nothing code-in-flight — working tree clean, **Phase 3 complete**.
**Phase 2 result:** `MainWindow.xaml` carved 1,681 → **123 lines** (a shell over **Phase 3 result:** every production `.cs` is now **≤ 500 lines** (checked via
6 UserControls under `Controls/`, namespace `ytLive.Controls`). Every change: `git ls-files '*.cs'` minus tests; nothing exceeds 500). The 500-line count is a
0-warning build, `scripts/verify.sh` (246 pass + 2 known only), explicit-file **hard ceiling, not the goal** — each file was split **grouped by functionality**
`scripts/scope-check.sh`, `ViewModels/index.md` in the SAME commit, tagged so an AI can process one concern per file. Splits were **concern-driven**:
`refactor-commit-N`, no per-commit push. - `MainViewModel` → `.Sources.cs`/`.Web.cs`, `Streaming.cs` + `Streaming.Operations.cs`,
`Background.cs` + `Background.Model.cs`; `SocialSlotViewModel.cs` (row VM).
- `LayoutStore` (was 1402) → `Migrations.cs`/`Load.cs`/`Save.cs`/`Settings.cs`/
`Assets.cs` + a 47-line shell; `public partial class LayoutStore : IDisposable`.
Every change: 0-warning build, `scripts/verify.sh` (246 pass + 2 known only),
explicit-file `scripts/scope-check.sh`, `ViewModels/index.md` tracker in the
SAME commit, tagged `refactor-commit-N`, no per-commit push.
**Next:** **Next:**
- If user says "push": `git push origin main && git push --tags` (7 commits 12-18 - If user says "push": `git push origin main && git push --tags` (6 commits:
+ their tags). `cb54637` + A–E, and tags `refactor-commit-A..E`).
- Then optional Phase 3 if queued. Nothing is queued beyond Phase 2.
**Pending bus:** `ViewModels/index.md` Phase 2 tracker has rows 12–18 (closed); **Pending bus:** `ViewModels/index.md` Phase 3 tracker has rows A–E (closed);
Phase 1 tracker closed. `Controls/` has `index.md`-style facts only if needed. Phase 3 marked COMPLETE. Watch-list (under limit, no action): `Controls/PreviewPane.xaml.cs`
(464), `ViewModels/MainViewModel.Audio.cs` (408).
## Landmines ## Landmines
@@ -69,31 +74,29 @@ Phase 1 tracker closed. `Controls/` has `index.md`-style facts only if needed.
## Todo state ## Todo state
- MainViewModel refactor: **Phase 1 complete (11/11 partials)**, pushed. - MainViewModel refactor: **Phase 1 complete (11/11 partials)**, pushed.
- MainWindow.xaml refactor: **Phase 2 complete (6/6 controls)**, local-only. - MainWindow.xaml refactor: **Phase 2 complete (6/6 controls)**, pushed at `1f4624c`.
- 500-line compliance: **Phase 3 complete (all 5 offenders ≤ 500)**, local-only A–E.
- TASK 3: 27/30 (preview compositor 16, text source 17, alerts 20 still open). - TASK 3: 27/30 (preview compositor 16, text source 17, alerts 20 still open).
- TASK 4: ✅ shipped. TASK 9: items 1–3 shipped; 4–7 open. TASK 10: steps 1–7; Velopack pending. - TASK 4: ✅ shipped. TASK 9: items 1–3 shipped; 4–7 open. TASK 10: steps 1–7; Velopack pending.
- TASK 18: shipped, creator verification pending. TASK 19/23, 20, 21-A: shipped. - TASK 18: shipped, creator verification pending. TASK 19/23, 20, 21-A: shipped.
- TASK 22 (audio sync offset): not started — required for v1. - TASK 22 (audio sync offset): not started — required for v1.
## Session summary (2026-08-30) ## Session summary (2026-08-31)
- Ran the **MainWindow.xaml split to completion** under the standing "continue - Ran the **500-line Phase 3 split to completion** under the standing "continue
without stopping" directive — no interruption prompts, no `question` tool. without stopping" directive — no interruption prompts, no `question` tool.
- Commits 12–18 landed locally, each: cut → 0-warning build → verify (246 pass + - Commits A–E landed locally, each: cut → 0-warning build → verify (246 pass + 2
2 known) → scope-check → index.md in-commit → `git tag refactor-commit-N`. No push. known) → scope-check → index.md in-commit → `git tag refactor-commit-{A..E}`.
- **Commit 15 (PreviewPane** the hardest: 1920×1080 canvas + drag/select/resize/ - **Re-structuring principle (user correction, 2026-08-31):** split by
hit-test, webcam menu handlers, social glow, audio meters. A dropped outer **functionality**, so an AI can process one concern per file; the 500-line
`Border` (background/glow/margin) was caught and restored same-commit. count is a **ceiling**, NOT the target. Do not pad or reshape code to a
- **Commit 17 (LeftPanel)** the biggest: property editors + chat autoscroll number — group by concern and only split enough to stay under the ceiling.
(moved to self-subscribe on `Loaded`), drag-to-reorder, snapshot revert/accept - **Commit E (LayoutStore)** the biggest: 1402 → 6 concern-based partials
family; window routes via `_leftPanel.IsClickInside` and (Migrations/Load/Save/Settings/Assets/shell), default partial split.
`_leftPanel.OnSelectionChanged(vm)`. - **Accident caught & recovered (Commit D):** a `> ViewModels/SocialsDialogViewModel.cs`
- **Commit 18 (OverlayHost)** the scope-decision: moved only the three dialogs; write that read from the file it was simultaneously truncating left the file at
kept `WebViewHostPanel` + `ToastArea` as window-root stubs because their 1 line. Fixed IMMEDIATELY via `git checkout --` and re-ran the split. **Lesson**
name-based lookups (WebView2 init, toast `InArea`) would break if buried in a (goes in `MyMistakes.md`): never `awk … > SRC` where `awk` also reads `SRC`;
control — a spin-guard decision, not a guess. stage every multi-block cut into temp files first, then assemble. All later
- Two XAML extraction gotchas recorded: (a) a moved block losing its `xmlns` LayoutStore cuts did exactly that (read original once into temp files).
→ MC2000 "key null" (Line n col m), fixed by adding `xmlns:models` on the - Push checkpoint reached; awaiting explicit "push" for `cb54637` + A–E + tags.
control root; (b) re-deriving line numbers after each edit before `sed`/awk
deletion ranges (the edit tool shifts numbers).
- Push checkpoint reached; awaiting explicit "push" for commits 12–18 + tags.
+23
View File
@@ -44,3 +44,26 @@ packages, high-quality downscale via `TransformedBitmap`. Screenshots compress
4. Point `README.md` at the `.jpg` (not `.png`). 4. Point `README.md` at the `.jpg` (not `.png`).
**Result:** 3.2MB screenshot → 1400×794 → **188KB** `ytLlive-preview.jpg` in `docs/`. **Result:** 3.2MB screenshot → 1400×794 → **188KB** `ytLlive-preview.jpg` in `docs/`.
---
## Splitting a large file into partials — NEVER `awk … > SRC` while awking SRC
(2026-08-31, Commit D) Tried to split `SocialsDialogViewModel.cs` in one line:
`{ awk '…' SRC; echo ""; awk '…2…' SRC; } > SRC`. The **first write truncated
SRC to 1 line**, so the second `awk` read the already-truncated file → the whole
source was lost (1 line left). Recovered with `git checkout -- SRC`, then redid
it, but the same bug could have meant making it up from scratch.
**Rule:** when a cut needs N blocks from one source into N files, never write a
block back onto the source that the `awk`s still read. Instead:
1. Read the source **once** at the start into temp files (`mktemp -d`, one file
per block), with a `$D` variable you carry forward.
2. Verify block sizes (`wc -l`) and brace balance (`python3 -c` counting `{`/`}`)
before touching any real file.
3. Then assemble each new file from `cat D/block …` — never truncating the source
until every read is done.
`git status` can't save you here if you don't notice until the file is gone —
`git checkout -- <path>` from the last commit is the recovery. Cheap insurance:
restore-then-retry, do it atomically from temp files the first time.
+41
View File
@@ -0,0 +1,41 @@
using System.Security.Cryptography;
using Microsoft.Data.Sqlite;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
public byte[]? GetAssetBytes(string assetId)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Data FROM Asset WHERE Id = $id";
cmd.Parameters.AddWithValue("$id", assetId);
return cmd.ExecuteScalar() as byte[];
}
public string UpsertAsset(byte[] data, int pixelWidth, int pixelHeight)
{
var hash = Convert.ToHexString(SHA256.HashData(data));
var id = Guid.NewGuid().ToString();
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT INTO Asset (Id, Hash, Data, PixelWidth, PixelHeight)
VALUES ($id, $hash, $data, $w, $h)
ON CONFLICT(Hash) DO NOTHING;
""";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$hash", hash);
cmd.Parameters.AddWithValue("$data", data);
cmd.Parameters.AddWithValue("$w", pixelWidth);
cmd.Parameters.AddWithValue("$h", pixelHeight);
cmd.ExecuteNonQuery();
cmd.CommandText = "SELECT Id FROM Asset WHERE Hash = $hash;";
return (string)cmd.ExecuteScalar()!;
}
// ─── License (TASK 10 — Polar billing) ───
/// <summary>The saved license key, or null if never entered.</summary>
}
+221
View File
@@ -0,0 +1,221 @@
using Microsoft.Data.Sqlite;
using ytLive.Models;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
public List<Scene> Load()
{
Webcam = null;
Socials = null;
Music = 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())
{
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar FROM Scene ORDER BY SortOrder";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
scenes.Add(new Scene
{
Id = reader.GetString(0),
Name = reader.GetString(1),
IsHidden = reader.GetInt32(2) != 0,
IsChatScene = reader.GetInt32(3) != 0,
HasBackground = reader.GetInt32(4) != 0,
HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
});
}
}
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, BarPosition, BarEnabled FROM Socials LIMIT 1;";
using var reader = cmd.ExecuteReader();
if (reader.Read())
{
Socials = new SocialsConfig
{
BarPosition = Enum.TryParse<SocialBarPosition>(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom,
BarEnabled = reader.FieldCount > 2 && !reader.IsDBNull(2) && reader.GetInt32(2) != 0,
};
var socialsId = reader.GetString(0);
using var entryCmd = _connection.CreateCommand();
entryCmd.CommandText = "SELECT Service, Handle, ProfileUrl, Software FROM SocialEntry WHERE SocialsId = $id ORDER BY SortOrder;";
entryCmd.Parameters.AddWithValue("$id", socialsId);
using var entryReader = entryCmd.ExecuteReader();
while (entryReader.Read())
{
Socials.Entries.Add(new SocialEntry
{
Service = Enum.TryParse<SocialService>(entryReader.GetString(0), out var svc) ? svc : SocialService.Link,
Handle = entryReader.GetString(1),
ProfileUrl = entryReader.GetString(2),
FediverseSoftware = entryReader.FieldCount > 3 && !entryReader.IsDBNull(3) ? entryReader.GetString(3) : null,
});
}
}
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "SELECT TrackPath, IsEnabled FROM Music LIMIT 1;";
using var reader = cmd.ExecuteReader();
if (reader.Read())
{
Music = new Music
{
TrackPath = reader.GetString(0),
IsEnabled = reader.GetInt32(1) != 0,
};
}
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
IsBackground, CaptureKey, ShowDesktop,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState
FROM Source ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var sceneId = reader.GetString(1);
var type = Enum.Parse<SourceType>(reader.GetString(3));
var source = new Source
{
Id = reader.GetString(0),
AssetId = reader.IsDBNull(2) ? null : reader.GetString(2),
Type = type,
Name = reader.GetString(4),
IsEnabled = reader.GetInt32(5) != 0,
X = reader.GetDouble(6),
Y = reader.GetDouble(7),
Width = reader.GetDouble(8),
Height = reader.GetDouble(9),
Opacity = reader.GetDouble(10),
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
IsMirrored = reader.GetInt32(13) != 0,
// Rows saved before the Type setter derived the flag carry
// IsBackground=0; derive from Type, keeping the persisted bit
// for legacy DisplayCapture backdrops.
IsBackground = type == SourceType.Background || reader.GetInt32(14) != 0,
CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
ShowDesktop = reader.GetInt32(16) != 0,
ChatFontSize = reader.IsDBNull(17) ? 16 : reader.GetDouble(17),
ChatFontColor = reader.IsDBNull(18) ? "#FFFFFF" : reader.GetString(18),
ChatBackgroundColor = reader.IsDBNull(19) ? "#80000000" : reader.GetString(19),
ChatMaxMessages = reader.IsDBNull(20) ? 5 : reader.GetInt32(20),
ChatShowTimestamps = reader.IsDBNull(21) ? true : reader.GetInt32(21) != 0,
ChatShowBadges = reader.IsDBNull(22) ? true : reader.GetInt32(22) != 0,
ChatMemberColor = reader.IsDBNull(23) ? "#e94560" : reader.GetString(23),
ChatSuperChatColor = reader.IsDBNull(24) ? "#eab308" : reader.GetString(24),
WebUri = reader.IsDBNull(25) ? null : reader.GetString(25),
MediaPath = reader.IsDBNull(26) ? null : reader.GetString(26),
MediaIsLooping = reader.IsDBNull(27) ? false : reader.GetInt32(27) != 0,
MediaVolume = reader.IsDBNull(28) ? 1.0 : reader.GetDouble(28),
MediaPlaybackState = reader.IsDBNull(29)
? MediaPlaybackState.Stopped
: Enum.TryParse<MediaPlaybackState>(reader.GetString(29), out var ps)
? ps
: MediaPlaybackState.Stopped,
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<Source>();
list.Add(source);
}
}
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 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);
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (bg != null)
{
// Load from source-keyed settings (new) or scene-keyed (legacy migration)
var path = GetSetting($"BackgroundPath_{bg.Id}");
if (string.IsNullOrWhiteSpace(path))
path = GetSetting($"BackgroundPath_{scene.Id}");
if (!string.IsNullOrWhiteSpace(path))
bg.CustomBackgroundPath = path;
var useDefault = GetSetting($"BackgroundUseDefault_{bg.Id}");
if (string.IsNullOrWhiteSpace(useDefault))
useDefault = GetSetting($"BackgroundUseDefault_{scene.Id}");
if (useDefault == "0")
bg.UseDefaultBackground = false;
}
}
return scenes;
}
}
+495
View File
@@ -0,0 +1,495 @@
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',
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
);
""",
};
foreach (var sql in statements)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
MigrateSourceTable();
MigrateWebcamConfigTable();
MigrateSceneTable();
MigrateSceneSocialBarColumn();
MigrateSocialsTable();
MigrateSocialEntryTable();
if (GetUserVersion() < 3)
MigrateToV3();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA user_version = 9;";
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("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();
}
// 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();
}
}
// 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();
}
// 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();
}
}
+339
View File
@@ -0,0 +1,339 @@
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)
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)
""";
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);
foreach (var scene in scenes)
{
var sort = 0;
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;
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 = sort++;
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();
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 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();
}
}
}
}
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);";
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();
}
}
+292
View File
@@ -0,0 +1,292 @@
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 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);
}
private 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;
}
private 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();
}
}
+1 -1356
View File
File diff suppressed because it is too large Load Diff
+121
View File
@@ -0,0 +1,121 @@
using System;
using System.IO;
using System.Threading.Tasks;
using System.Windows;
using Microsoft.Win32;
using ytLive.Helpers;
using ytLive.Services;
namespace ytLive.ViewModels;
public partial class MainViewModel : ViewModelBase
{
private string? _recordFolder;
public string RecordFolderDisplay => _recordFolder ?? DefaultRecordFolder();
private string? _activeRecordPath;
private DateTime _recordStartTime;
private TimeSpan _recordLength;
private string? StartRecordFile()
{
try
{
var folder = _recordFolder ?? DefaultRecordFolder();
Directory.CreateDirectory(folder);
var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
var path = Path.Combine(folder, name);
_activeRecordPath = path;
return path;
}
catch (Exception ex)
{
AppLog.Write(ex, "Recording: failed to prepare output path");
return null;
}
}
private async Task FinalizeRecordingAsync()
{
var startPath = _activeRecordPath;
_activeRecordPath = null;
if (string.IsNullOrEmpty(startPath)) return;
try
{
while (_framePump.IsRunning) await Task.Delay(50);
if (!File.Exists(startPath))
{
AppLog.Write($"Recording: output not found for rename: {startPath}");
return;
}
_recordLength = _liveElapsed;
var autoStem = RecordingFile.BuildFinalName(_recordStartTime, _recordLength);
var dir = Path.GetDirectoryName(startPath)!;
// Manual rename modal: give the creator a chance to name the recording;
// null = keep the auto name (or the default recorded above).
string stem = autoStem;
var dialog = new RenameRecordingDialog(autoStem);
if (dialog.ShowDialog() == true && !string.IsNullOrEmpty(dialog.ResultFileName))
stem = dialog.ResultFileName;
var finalPath = UniquePath(Path.Combine(dir, stem + ".mp4"));
File.Move(startPath, finalPath);
AppLog.Write($"Recording saved: {finalPath}");
_notifications.Info("Recording saved",
Path.GetFileName(finalPath) + " — you can rename it in your videos folder at any time.");
}
catch (Exception ex)
{
AppLog.Write(ex, "Recording: rename-on-stop failed");
_notifications.Error("Recording saved",
$"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}");
}
}
private static string UniquePath(string path)
{
if (!File.Exists(path)) return path;
var dir = Path.GetDirectoryName(path)!;
var name = Path.GetFileNameWithoutExtension(path);
var ext = Path.GetExtension(path);
for (var i = 2; ; i++)
{
var candidate = Path.Combine(dir, $"{name}-{i}{ext}");
if (!File.Exists(candidate)) return candidate;
}
}
private void ChooseRecordFolder()
{
var dialog = new Microsoft.Win32.OpenFolderDialog
{
Title = "Choose a folder for local recordings",
InitialDirectory = _recordFolder ?? DefaultRecordFolder(),
};
if (dialog.ShowDialog() == true)
{
_recordFolder = dialog.FolderName;
_layoutStore.SaveRecordFolder(_recordFolder);
OnPropertyChanged(nameof(RecordFolderDisplay));
_notifications.Info("Record folder set", _recordFolder);
}
}
private void ResetRecordFolder()
{
_recordFolder = null;
_layoutStore.SaveRecordFolder(null);
OnPropertyChanged(nameof(RecordFolderDisplay));
_notifications.Info("Record folder reset", RecordFolderDisplay);
}
internal static string DefaultRecordFolder()
{
var downloads = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
return Directory.Exists(downloads)
? downloads
: Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
}
}
@@ -96,23 +96,6 @@ public partial class MainViewModel : ViewModelBase
/// <summary>Creates the record file (auto-named <c>ty-…-0000.mp4</c> in the record /// <summary>Creates the record file (auto-named <c>ty-…-0000.mp4</c> in the record
/// folder) and returns its path, or null when the folder can't be prepared.</summary> /// folder) and returns its path, or null when the folder can't be prepared.</summary>
private string? StartRecordFile()
{
try
{
var folder = _recordFolder ?? DefaultRecordFolder();
Directory.CreateDirectory(folder);
var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
var path = Path.Combine(folder, name);
_activeRecordPath = path;
return path;
}
catch (Exception ex)
{
AppLog.Write(ex, "Recording: failed to prepare output path");
return null;
}
}
/// <summary>One-click go-live (TASK 5): ensure the reusable stream, create a /// <summary>One-click go-live (TASK 5): ensure the reusable stream, create a
/// broadcast bound to it, then start the frame pump. The pump reads the RTMP /// broadcast bound to it, then start the frame pump. The pump reads the RTMP
@@ -269,92 +252,11 @@ public partial class MainViewModel : ViewModelBase
/// <summary>On stop the auto-named <c>ty-…-0000.mp4</c> file is renamed to carry /// <summary>On stop the auto-named <c>ty-…-0000.mp4</c> file is renamed to carry
/// the real recording length (<c>ty-…-hh2mm2.mp4</c>), with a numeric suffix on /// the real recording length (<c>ty-…-hh2mm2.mp4</c>), with a numeric suffix on
/// name collisions. Runs only after the pump has fully closed the file.</summary> /// name collisions. Runs only after the pump has fully closed the file.</summary>
private async Task FinalizeRecordingAsync()
{
var startPath = _activeRecordPath;
_activeRecordPath = null;
if (string.IsNullOrEmpty(startPath)) return;
try
{
while (_framePump.IsRunning) await Task.Delay(50);
if (!File.Exists(startPath))
{
AppLog.Write($"Recording: output not found for rename: {startPath}");
return;
}
_recordLength = _liveElapsed;
var autoStem = RecordingFile.BuildFinalName(_recordStartTime, _recordLength);
var dir = Path.GetDirectoryName(startPath)!;
// Manual rename modal: give the creator a chance to name the recording;
// null = keep the auto name (or the default recorded above).
string stem = autoStem;
var dialog = new RenameRecordingDialog(autoStem);
if (dialog.ShowDialog() == true && !string.IsNullOrEmpty(dialog.ResultFileName))
stem = dialog.ResultFileName;
var finalPath = UniquePath(Path.Combine(dir, stem + ".mp4"));
File.Move(startPath, finalPath);
AppLog.Write($"Recording saved: {finalPath}");
_notifications.Info("Recording saved",
Path.GetFileName(finalPath) + " — you can rename it in your videos folder at any time.");
}
catch (Exception ex)
{
AppLog.Write(ex, "Recording: rename-on-stop failed");
_notifications.Error("Recording saved",
$"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}");
}
}
private static string UniquePath(string path)
{
if (!File.Exists(path)) return path;
var dir = Path.GetDirectoryName(path)!;
var name = Path.GetFileNameWithoutExtension(path);
var ext = Path.GetExtension(path);
for (var i = 2; ; i++)
{
var candidate = Path.Combine(dir, $"{name}-{i}{ext}");
if (!File.Exists(candidate)) return candidate;
}
}
private void ChooseRecordFolder()
{
var dialog = new Microsoft.Win32.OpenFolderDialog
{
Title = "Choose a folder for local recordings",
InitialDirectory = _recordFolder ?? DefaultRecordFolder(),
};
if (dialog.ShowDialog() == true)
{
_recordFolder = dialog.FolderName;
_layoutStore.SaveRecordFolder(_recordFolder);
OnPropertyChanged(nameof(RecordFolderDisplay));
_notifications.Info("Record folder set", _recordFolder);
}
}
private void ResetRecordFolder()
{
_recordFolder = null;
_layoutStore.SaveRecordFolder(null);
OnPropertyChanged(nameof(RecordFolderDisplay));
_notifications.Info("Record folder reset", RecordFolderDisplay);
}
/// <summary>Falls back to the user's Downloads folder when no preference is set; /// <summary>Falls back to the user's Downloads folder when no preference is set;
/// returns MyVideos if Downloads doesn't exist.</summary> /// returns MyVideos if Downloads doesn't exist.</summary>
internal static string DefaultRecordFolder()
{
var downloads = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
return Directory.Exists(downloads)
? downloads
: Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
}
// Full encoder options for the current tier, or null when no RTMP URL is // Full encoder options for the current tier, or null when no RTMP URL is
// available — the pump then skips the encoder entirely (TASK 5 fills the seam). // available — the pump then skips the encoder entirely (TASK 5 fills the seam).
-5
View File
@@ -38,14 +38,9 @@ public partial class MainViewModel : ViewModelBase
private bool _recordPillOn; private bool _recordPillOn;
private bool _onAirPillOn; private bool _onAirPillOn;
private bool _isRecording; private bool _isRecording;
private string? _recordFolder;
/// <summary>Resolved record folder — the configured choice, or Downloads (0th case).</summary> /// <summary>Resolved record folder — the configured choice, or Downloads (0th case).</summary>
public string RecordFolderDisplay => _recordFolder ?? DefaultRecordFolder();
private string? _activeRecordPath;
private DateTime _recordStartTime;
private TimeSpan _recordLength;
private readonly FramePump _framePump; private readonly FramePump _framePump;
+2
View File
@@ -70,5 +70,7 @@ commit, tag `refactor-commit-N`, no per-commit push).
- [x] **Commit B · `refactor-commit-B`** — **Streaming** → `MainViewModel.Streaming.cs` 789 → **330** + new **`MainViewModel.Streaming.Operations.cs`** (459 lines). Moved the go-live/record/health operation bodies (`StartSession`, `BeginRecordOnly`, `BeginGoLive`, `StartRecordFile`, `PrepareAndStartLiveAsync`, `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue`, `StopStream`, `FinalizeRecordingAsync`, `UniquePath`, `ChooseRecordFolder`, `ResetRecordFolder`, `DefaultRecordFolder`, `OnFramePumpFailed`, `OnFramePumpHealthUpdated`, `ApplyHealth`, `ResetHealth`, `BuildEncoderOptions`) into the Operations partial; Streaming.cs keeps state + quality/resolution tiers + session timer + command declarations. Pruned dead usings both files. Pending: Background, SocialsDialogViewModel, LayoutStore. - [x] **Commit B · `refactor-commit-B`** — **Streaming** → `MainViewModel.Streaming.cs` 789 → **330** + new **`MainViewModel.Streaming.Operations.cs`** (459 lines). Moved the go-live/record/health operation bodies (`StartSession`, `BeginRecordOnly`, `BeginGoLive`, `StartRecordFile`, `PrepareAndStartLiveAsync`, `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue`, `StopStream`, `FinalizeRecordingAsync`, `UniquePath`, `ChooseRecordFolder`, `ResetRecordFolder`, `DefaultRecordFolder`, `OnFramePumpFailed`, `OnFramePumpHealthUpdated`, `ApplyHealth`, `ResetHealth`, `BuildEncoderOptions`) into the Operations partial; Streaming.cs keeps state + quality/resolution tiers + session timer + command declarations. Pruned dead usings both files. Pending: Background, SocialsDialogViewModel, LayoutStore.
- [x] **Commit C · `refactor-commit-C`** — **Background** → `MainViewModel.Background.cs` 567 → **418** + new **`MainViewModel.Background.Model.cs`** (163 lines). Moved the static scene-model helpers (`LoadBackgroundImage`, `CreatePlaceholderSnapshot`, `EnsureBackground`, `CreateBackground`, `NormalizeBackgrounds`, `AllBackgrounds`, `ResolveAutoCaptureKey`, `MonitorKeyPrefix`) into the Model partial; Background.cs keeps capture/live state + operations (`UpdateActiveBackground`, `UpdateBackgroundImage`, `RefreshSnapshotsAsync`, `ReacquireScreenCaptures`, `OnScreenPreviewBitmapChanged`, `OnFullscreenMonitorChanged`, `RefreshBackgroundAutoCapture`, `BrowseBackground`, `ClearBackgroundCaptureAsync`, `RedesignateBackgroundAsync`, `ChangeBackgroundCaptureAsync`, `SetBackgroundCapture`, `DefaultBackgroundAsset`, `HealBackgrounds`, `StampDefaultBackgroundAsset`). Pending: SocialsDialogViewModel, LayoutStore. - [x] **Commit C · `refactor-commit-C`** — **Background** → `MainViewModel.Background.cs` 567 → **418** + new **`MainViewModel.Background.Model.cs`** (163 lines). Moved the static scene-model helpers (`LoadBackgroundImage`, `CreatePlaceholderSnapshot`, `EnsureBackground`, `CreateBackground`, `NormalizeBackgrounds`, `AllBackgrounds`, `ResolveAutoCaptureKey`, `MonitorKeyPrefix`) into the Model partial; Background.cs keeps capture/live state + operations (`UpdateActiveBackground`, `UpdateBackgroundImage`, `RefreshSnapshotsAsync`, `ReacquireScreenCaptures`, `OnScreenPreviewBitmapChanged`, `OnFullscreenMonitorChanged`, `RefreshBackgroundAutoCapture`, `BrowseBackground`, `ClearBackgroundCaptureAsync`, `RedesignateBackgroundAsync`, `ChangeBackgroundCaptureAsync`, `SetBackgroundCapture`, `DefaultBackgroundAsset`, `HealBackgrounds`, `StampDefaultBackgroundAsset`). Pending: SocialsDialogViewModel, LayoutStore.
- [x] **Commit D · `refactor-commit-D`** — **SocialsDialog** → `SocialsDialogViewModel.cs` 515 → **334** + new **`SocialSlotViewModel.cs`** (183 lines). Moved the `DialogEntry` record + the row-level `SocialSlotViewModel` (six-fixed-slot row VM: service/logo/lock/edit/validate state + computed `Show*` flags) into its own file; `SocialsDialogViewModel.cs` keeps the dialog VM (sign-in gate, validation orchestration, commit). `DialogEntry` stays `public` in the same namespace so both files see it. Pending: LayoutStore. - [x] **Commit D · `refactor-commit-D`** — **SocialsDialog** → `SocialsDialogViewModel.cs` 515 → **334** + new **`SocialSlotViewModel.cs`** (183 lines). Moved the `DialogEntry` record + the row-level `SocialSlotViewModel` (six-fixed-slot row VM: service/logo/lock/edit/validate state + computed `Show*` flags) into its own file; `SocialsDialogViewModel.cs` keeps the dialog VM (sign-in gate, validation orchestration, commit). `DialogEntry` stays `public` in the same namespace so both files see it. Pending: LayoutStore.
- [x] **Commit E · `refactor-commit-E`** — **LayoutStore** → `LayoutStore.cs` 1402 split into 6 concern-based partials: **`LayoutStore.cs`** (47: shell — `Instance`, fields, ctor + `EnsureSchema`, `GetUserVersion`, `Dispose`), **`LayoutStore.Migrations.cs`** (495: all `Migrate*` + `EnsureSchema`), **`LayoutStore.Load.cs`** (221: `Load`), **`LayoutStore.Save.cs`** (339: `Save`), **`LayoutStore.Settings.cs`** (292: mic/record-folder/reusable-stream + license/transition/broadcast/hotkey/window `GetSetting`/`UpsertSetting`), **`LayoutStore.Assets.cs`** (41: `GetAssetBytes`/`UpsertAsset`). **PHASE 3 COMPLETE — no production `.cs` over 500.**
- [x] **Commit F · `refactor-commit-F`** — **Recording (context/concern)** → new **`MainViewModel.Recording.cs`** (121 lines). The encoder (`Services/Encoder/FfmpegEncoder.cs`) and frame pump (`Services/Encoder/FramePump.cs`) were ALREADY extracted services — the planned "extract FFmpegEncoder/StreamHealthMonitor/FramePump" was a no-op premise. The honest functional seam was the local recording-output concern: `StartRecordFile`, `FinalizeRecordingAsync`, `UniquePath`, `ChooseRecordFolder`, `ResetRecordFolder`, `DefaultRecordFolder` + fields `_recordFolder`/`RecordFolderDisplay`/`_activeRecordPath`/`_recordStartTime`/`_recordLength` moved out of the live-stream orchestration. `MainViewModel.Streaming.Operations.cs` 475 → **377**; `MainViewModel.Streaming.cs` 328 → **323**. Same partial class, MVVM glue untouched.
The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split
proceeds; each partial carries its own `using`s and re-declares nothing from core. proceeds; each partial carries its own `using`s and re-declares nothing from core.