Compare commits

..

5 Commits

Author SHA1 Message Date
gramps 85893ea709 refactor: extract ChatOverlayLayer — real component decomposition, Commit G
The first true decomposition of the 4105-line god-object, not another partial
shuffle. MainViewModel.Chat.cs 194 -> 44 lines of thin delegation; all chat
behavior now lives in a self-contained Services/ChatOverlayLayer.cs (199):

- Owns the message buffer (Messages), ChatBoxRenderer, fade + mock-preview
  timers, per-source preview renders, and the live-output RenderFrame path.
- The VM keeps only the binding surface: ChatMessages delegates to
  _chatLayer.Messages (so XAML ItemsSource + LeftPanel CollectionChanged hold),
  CanAddYouTubeChat / ShowChatInactiveMessage stay computed on the VM
  (OnPropertyChanged raised from VM setters + XAML-bound), and thin forwards.
- Scenes handed in as args (no Func seam), so the layer owns no scene graph.

An AI reading ChatOverlayLayer.cs now sees the entire chat feature in one
self-contained unit. Zero 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:48:10 -07:00
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
gramps d3271a0405 refactor: split SocialsDialogViewModel under 500 lines — 500-limit Phase 3, Commit D
SocialsDialogViewModel.cs 515 -> 334. Extracted the DialogEntry record + the
row-level SocialSlotViewModel (service/logo/lock/edit/validate state, computed
Show* flags) into a new SocialSlotViewModel.cs partial. The dialog VM keeps the
sign-in gate, validation orchestration, and commit. DialogEntry stays public in
the same namespace.

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 08:59:38 -07:00
17 changed files with 1986 additions and 1854 deletions
+53 -50
View File
@@ -2,45 +2,50 @@
## 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;
Phase 2 (MainWindow.xaml → UserControls) is complete but **local-only**.
**Pushed** (reached `origin/main` == `1f4624c`, incl. tags): Phase 1 commit `0`
+ 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`
and refactor commits `1..11` (`refactor-commit-0..11`), plus policy/HANDOFF.
**LOCAL ONLY (Phase 3 — A, B, C, D, E) + the Controls doc commit** — policy
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
per-commit push; `git push` only at a user-agreed milestone / "push it".
**Push pending on explicit user approval:**
| Commit | Hash | Tag | Control | From Loc |
|--------|------|-----|---------|----------|
| 12 | `bc23b64` | `refactor-commit-12` | `Controls/BottomBar` | footer |
| 13 | `6914252` | `refactor-commit-13` | `Controls/HealthBanner` | row 1 banner |
| 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 |
| Commit | Hash | Tag | Payload |
|--------|------|-----|---------|
| docs | `cb54637` | — | `Controls/index.md` (missing per-directory map) |
| A | `6944db8` | `refactor-commit-A` | split `MainViewModel.cs` core → **495** |
| B | `31362d1` | `refactor-commit-B` | split `MainViewModel.Streaming.cs` → **330** |
| C | `fab2e09` | `refactor-commit-C` | split `MainViewModel.Background.cs` → **418** |
| D | `d3271a0` | `refactor-commit-D` | trim `SocialsDialogViewModel.cs` → **334** |
| E | `dcb3637` | `refactor-commit-E` | split `LayoutStore.cs` → 6 partials, **≤495** |
## 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
6 UserControls under `Controls/`, namespace `ytLive.Controls`). Every change:
0-warning build, `scripts/verify.sh` (246 pass + 2 known only), explicit-file
`scripts/scope-check.sh`, `ViewModels/index.md` in the SAME commit, tagged
`refactor-commit-N`, no per-commit push.
**Phase 3 result:** every production `.cs` is now **≤ 500 lines** (checked via
`git ls-files '*.cs'` minus tests; nothing exceeds 500). The 500-line count is a
**hard ceiling, not the goal** — each file was split **grouped by functionality**
so an AI can process one concern per file. Splits were **concern-driven**:
- `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:**
- If user says "push": `git push origin main && git push --tags` (7 commits 12-18
+ their tags).
- Then optional Phase 3 if queued. Nothing is queued beyond Phase 2.
- If user says "push": `git push origin main && git push --tags` (6 commits:
`cb54637` + A–E, and tags `refactor-commit-A..E`).
**Pending bus:** `ViewModels/index.md` Phase 2 tracker has rows 12–18 (closed);
Phase 1 tracker closed. `Controls/` has `index.md`-style facts only if needed.
**Pending bus:** `ViewModels/index.md` Phase 3 tracker has rows A–E (closed);
Phase 3 marked COMPLETE. Watch-list (under limit, no action): `Controls/PreviewPane.xaml.cs`
(464), `ViewModels/MainViewModel.Audio.cs` (408).
## Landmines
@@ -69,31 +74,29 @@ Phase 1 tracker closed. `Controls/` has `index.md`-style facts only if needed.
## Todo state
- 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 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 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.
- Commits 12–18 landed locally, each: cut → 0-warning build → verify (246 pass +
2 known) → scope-check → index.md in-commit → `git tag refactor-commit-N`. No push.
- **Commit 15 (PreviewPane** the hardest: 1920×1080 canvas + drag/select/resize/
hit-test, webcam menu handlers, social glow, audio meters. A dropped outer
`Border` (background/glow/margin) was caught and restored same-commit.
- **Commit 17 (LeftPanel)** the biggest: property editors + chat autoscroll
(moved to self-subscribe on `Loaded`), drag-to-reorder, snapshot revert/accept
family; window routes via `_leftPanel.IsClickInside` and
`_leftPanel.OnSelectionChanged(vm)`.
- **Commit 18 (OverlayHost)** the scope-decision: moved only the three dialogs;
kept `WebViewHostPanel` + `ToastArea` as window-root stubs because their
name-based lookups (WebView2 init, toast `InArea`) would break if buried in a
control — a spin-guard decision, not a guess.
- Two XAML extraction gotchas recorded: (a) a moved block losing its `xmlns`
→ MC2000 "key null" (Line n col m), fixed by adding `xmlns:models` on the
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.
- Commits A–E landed locally, each: cut → 0-warning build → verify (246 pass + 2
known) → scope-check → index.md in-commit → `git tag refactor-commit-{A..E}`.
- **Re-structuring principle (user correction, 2026-08-31):** split by
**functionality**, so an AI can process one concern per file; the 500-line
count is a **ceiling**, NOT the target. Do not pad or reshape code to a
number — group by concern and only split enough to stay under the ceiling.
- **Commit E (LayoutStore)** the biggest: 1402 → 6 concern-based partials
(Migrations/Load/Save/Settings/Assets/shell), default partial split.
- **Accident caught & recovered (Commit D):** a `> ViewModels/SocialsDialogViewModel.cs`
write that read from the file it was simultaneously truncating left the file at
1 line. Fixed IMMEDIATELY via `git checkout --` and re-ran the split. **Lesson**
(goes in `MyMistakes.md`): never `awk … > SRC` where `awk` also reads `SRC`;
stage every multi-block cut into temp files first, then assemble. All later
LayoutStore cuts did exactly that (read original once into temp files).
- Push checkpoint reached; awaiting explicit "push" for `cb54637` + A–E + 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`).
**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.
+199
View File
@@ -0,0 +1,199 @@
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>Owns the YouTube-chat overlay behavior: the message buffer, the
/// renderer, and the fade/mock-preview timers. The MainViewModel keeps only the
/// thin binding surface (the shared <see cref="Messages"/> collection + two
/// computed gate props) and hands the scene list in at each call — this class
/// owns all chat logic so an AI can read one self-contained unit.</summary>
public sealed class ChatOverlayLayer
{
private readonly ChatBoxRenderer _renderer;
// Mock chat messages for the ChatBox preview — branded placeholders so the
// creator sees where the overlay lands before going live.
private static readonly IReadOnlyList<ChatMessage> MockChatMessages = new[]
{
new ChatMessage { AuthorName = "LlamaCasty", Message = "welcome to the stream!", Timestamp = DateTime.Now.AddMinutes(-4), IsMember = true },
new ChatMessage { AuthorName = "LlamaCastyFan", Message = "$10 — LlamaCasty is the best!", Timestamp = DateTime.Now.AddMinutes(-3), IsSuperChat = true },
new ChatMessage { AuthorName = "GamerDude", Message = "what streaming software is this?", Timestamp = DateTime.Now.AddMinutes(-2) },
new ChatMessage { AuthorName = "LlamaCasty", Message = "thanks for watching! llamacasty.com", Timestamp = DateTime.Now.AddMinutes(-1), IsMember = true },
new ChatMessage { AuthorName = "LlamaDog", Message = "Subscribe so they can buy the subscription at llamacasty.com!", Timestamp = DateTime.Now, IsMember = true },
};
// Chat fade timer: clears chat overlay after N seconds of inactivity.
private DispatcherTimer? _fadeTimer;
private CancellationTokenSource? _mockCts;
/// <summary>The live chat message buffer. Shared with the ViewModel (which
/// binds it to the panel) via <c>ChatMessages =&gt; _chatLayer.Messages</c>.</summary>
public ObservableCollection<ChatMessage> Messages { get; } = new();
public ChatOverlayLayer(ChatBoxRenderer renderer) => _renderer = renderer;
/// <summary>Ingest a message from the poller. UI-thread marshalled: appended
/// to the buffer (capped at 500), mock preview stopped, swipe to real
/// messages, and the fade timer reset. Scenes are handed in so the layer can
/// refresh every ChatBox preview without owning the scene graph.</summary>
public void OnMessageReceived(ChatMessage message, IEnumerable<Scene> scenes)
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
Messages.Add(message);
if (Messages.Count > 500)
Messages.RemoveAt(0);
// Reset chat fade timer on every message
ResetChatFadeTimer(scenes);
StopMockPreview();
// Switch ChatBox previews from mock to real messages
foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox))
UpdatePreview(chatBox);
});
}
private void ResetChatFadeTimer(IEnumerable<Scene> scenes)
{
_fadeTimer?.Stop();
var chatBoxes = scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox && s.ChatFadeSeconds > 0).ToList();
if (chatBoxes.Count == 0) return;
var delay = chatBoxes.Min(s => s.ChatFadeSeconds);
_fadeTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(delay) };
_fadeTimer.Tick += (_, _) =>
{
_fadeTimer?.Stop();
_fadeTimer = null;
Messages.Clear();
foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox))
UpdatePreview(chatBox);
StartMockPreview(scenes);
};
_fadeTimer.Start();
}
/// <summary>Renders the live-output frame for one ChatBox element (the
/// compositor path), or null when no messages are buffered yet. Mirrors the
/// preview render but without writing onto the source's VideoImageSource.</summary>
public VideoFrame? RenderFrame(Source chatBox)
{
if (Messages.Count == 0) return null;
var width = (int)Math.Max(1, chatBox.Width);
var height = (int)Math.Max(1, chatBox.Height);
return _renderer.Render(
Messages,
width,
height,
chatBox.ChatFontSize,
chatBox.ChatFontFamily,
chatBox.ChatFontColor,
chatBox.ChatBackgroundColor,
chatBox.ChatMaxMessages,
chatBox.ChatShowTimestamps,
chatBox.ChatShowBadges,
chatBox.ChatMemberColor,
chatBox.ChatSuperChatColor);
}
/// <summary>Renders one ChatBox preview: real messages when connected, mock
/// when not. Writes the resulting bitmap onto the source's
/// <c>VideoImageSource</c>.</summary>
public void UpdatePreview(Source chatBox, IReadOnlyList<ChatMessage>? messages = null)
{
try
{
messages ??= Messages.Count > 0 ? (IReadOnlyList<ChatMessage>)Messages : MockChatMessages;
var width = (int)Math.Max(32, chatBox.Width);
var height = (int)Math.Max(32, chatBox.Height);
var frame = _renderer.Render(
messages,
width,
height,
chatBox.ChatFontSize,
chatBox.ChatFontFamily,
chatBox.ChatFontColor,
chatBox.ChatBackgroundColor,
chatBox.ChatMaxMessages,
chatBox.ChatShowTimestamps,
chatBox.ChatShowBadges,
chatBox.ChatMemberColor,
chatBox.ChatSuperChatColor);
var bitmap = new WriteableBitmap(width, height, 96, 96, PixelFormats.Bgra32, null);
bitmap.WritePixels(new Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0);
chatBox.VideoImageSource = bitmap;
}
catch (Exception ex) { AppLog.Write($"UpdateChatBoxPreview failed: {ex.Message}"); }
}
private async Task RunMockPreviewAsync(Source chatBox, CancellationToken ct)
{
var n = 0;
while (!ct.IsCancellationRequested)
{
UpdatePreview(chatBox, MockChatMessages.Take(n + 1).ToList());
await Task.Delay(1000, ct);
n++;
if (n == MockChatMessages.Count)
{
await Task.Delay(3000, ct);
n = 0;
}
}
}
public void StartMockPreview(IEnumerable<Scene> scenes)
{
StopMockPreview();
_mockCts = new CancellationTokenSource();
var ct = _mockCts.Token;
foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox && s.ChatPreviewEnabled))
_ = RunMockPreviewAsync(chatBox, ct);
}
public void StopMockPreview()
{
_mockCts?.Cancel();
_mockCts?.Dispose();
_mockCts = null;
}
/// <summary>Render previews + wire property-change subscriptions for all
/// ChatBox sources across every scene. Called after layout load so existing
/// ChatBox sources show mock content and respond to config changes.</summary>
public void UpdateConfiguredBoxes(IEnumerable<Scene> scenes)
{
foreach (var scene in scenes)
foreach (var chatBox in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.ChatBox))
{
chatBox.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor)
or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity)
or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps)
or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor)
or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily)
or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height))
UpdatePreview(chatBox);
};
UpdatePreview(chatBox);
}
}
}
+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
+13 -163
View File
@@ -1,7 +1,5 @@
using System.Collections.ObjectModel;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using System.Linq;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
@@ -11,22 +9,12 @@ namespace ytLive.ViewModels;
public partial class MainViewModel : ViewModelBase
{
private readonly ChatBoxRenderer _chatBoxRenderer;
private readonly ChatOverlayLayer _chatLayer;
// Mock chat messages for the ChatBox preview — branded placeholders so the
// creator sees where the overlay lands before going live.
private static readonly IReadOnlyList<ChatMessage> MockChatMessages = new[]
{
new ChatMessage { AuthorName = "LlamaCasty", Message = "welcome to the stream!", Timestamp = DateTime.Now.AddMinutes(-4), IsMember = true },
new ChatMessage { AuthorName = "LlamaCastyFan", Message = "$10 — LlamaCasty is the best!", Timestamp = DateTime.Now.AddMinutes(-3), IsSuperChat = true },
new ChatMessage { AuthorName = "GamerDude", Message = "what streaming software is this?", Timestamp = DateTime.Now.AddMinutes(-2) },
new ChatMessage { AuthorName = "LlamaCasty", Message = "thanks for watching! llamacasty.com", Timestamp = DateTime.Now.AddMinutes(-1), IsMember = true },
new ChatMessage { AuthorName = "LlamaDog", Message = "Subscribe so they can buy the subscription at llamacasty.com!", Timestamp = DateTime.Now, IsMember = true },
};
// Chat fade timer: clears chat overlay after N seconds of inactivity.
private System.Windows.Threading.DispatcherTimer? _chatFadeTimer;
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
/// <summary>The live chat message buffer — the same collection the
/// <see cref="ChatOverlayLayer"/> owns; here so XAML can bind it and the
/// left panel can subscribe to its changes.</summary>
public ObservableCollection<ChatMessage> ChatMessages => _chatLayer.Messages;
/// <summary>The Add → YouTube Chat menu item: enabled only while no chat layer exists
/// anywhere — one chat overlay per layout.</summary>
@@ -37,158 +25,20 @@ public partial class MainViewModel : ViewModelBase
public bool ShowChatInactiveMessage => !IsLive;
private void OnChatMessageReceived(ChatMessage message)
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
ChatMessages.Add(message);
if (ChatMessages.Count > 500)
ChatMessages.RemoveAt(0);
=> _chatLayer.OnMessageReceived(message, Scenes);
// Reset chat fade timer on every message
ResetChatFadeTimer();
StopMockChatPreview();
// Switch ChatBox previews from mock to real messages
if (StagedScene != null)
foreach (var chatBox in StagedScene.Elements.OfType<Source>().Where(s => s.Type == SourceType.ChatBox))
UpdateChatBoxPreview(chatBox);
});
}
private void ResetChatFadeTimer()
{
_chatFadeTimer?.Stop();
var chatBoxes = Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox && s.ChatFadeSeconds > 0).ToList();
if (chatBoxes.Count == 0) return;
var delay = chatBoxes.Min(s => s.ChatFadeSeconds);
_chatFadeTimer = new System.Windows.Threading.DispatcherTimer
{ Interval = TimeSpan.FromSeconds(delay) };
_chatFadeTimer.Tick += (_, _) =>
{
_chatFadeTimer?.Stop();
_chatFadeTimer = null;
ChatMessages.Clear();
foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox))
UpdateChatBoxPreview(chatBox);
StartMockChatPreview();
};
_chatFadeTimer.Start();
}
private void UpdateChatBoxPreview(Source chatBox, IReadOnlyList<ChatMessage>? messages = null)
=> _chatLayer.UpdatePreview(chatBox, messages);
private VideoFrame? RenderChatBox(Source chatBox)
{
if (ChatMessages.Count == 0) return null;
var width = (int)Math.Max(1, chatBox.Width);
var height = (int)Math.Max(1, chatBox.Height);
return _chatBoxRenderer.Render(
ChatMessages,
width,
height,
chatBox.ChatFontSize,
chatBox.ChatFontFamily,
chatBox.ChatFontColor,
chatBox.ChatBackgroundColor,
chatBox.ChatMaxMessages,
chatBox.ChatShowTimestamps,
chatBox.ChatShowBadges,
chatBox.ChatMemberColor,
chatBox.ChatSuperChatColor);
}
/// <summary>
/// Renders the ChatBox preview: real messages when connected, mock when not.
/// </summary>
private void UpdateChatBoxPreview(Source chatBox, IReadOnlyList<ChatMessage>? messages = null)
{
try
{
messages ??= ChatMessages.Count > 0 ? (IReadOnlyList<ChatMessage>)ChatMessages : MockChatMessages;
var width = (int)Math.Max(32, chatBox.Width);
var height = (int)Math.Max(32, chatBox.Height);
var frame = _chatBoxRenderer.Render(
messages,
width,
height,
chatBox.ChatFontSize,
chatBox.ChatFontFamily,
chatBox.ChatFontColor,
chatBox.ChatBackgroundColor,
chatBox.ChatMaxMessages,
chatBox.ChatShowTimestamps,
chatBox.ChatShowBadges,
chatBox.ChatMemberColor,
chatBox.ChatSuperChatColor);
var bitmap = new WriteableBitmap(width, height, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null);
bitmap.WritePixels(new System.Windows.Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0);
chatBox.VideoImageSource = bitmap;
}
catch (Exception ex) { AppLog.Write($"UpdateChatBoxPreview failed: {ex.Message}"); }
}
private CancellationTokenSource? _mockChatCts;
private async Task RunMockChatPreviewAsync(Source chatBox, CancellationToken ct)
{
var n = 0;
while (!ct.IsCancellationRequested)
{
UpdateChatBoxPreview(chatBox, MockChatMessages.Take(n + 1).ToList());
await Task.Delay(1000, ct);
n++;
if (n == MockChatMessages.Count)
{
await Task.Delay(3000, ct);
n = 0;
}
}
}
=> _chatLayer.RenderFrame(chatBox);
private void StartMockChatPreview()
{
StopMockChatPreview();
_mockChatCts = new CancellationTokenSource();
var ct = _mockChatCts.Token;
foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox && s.ChatPreviewEnabled))
_ = RunMockChatPreviewAsync(chatBox, ct);
}
=> _chatLayer.StartMockPreview(Scenes);
private void StopMockChatPreview()
{
_mockChatCts?.Cancel();
_mockChatCts?.Dispose();
_mockChatCts = null;
}
=> _chatLayer.StopMockPreview();
/// <summary>
/// Render previews + wire property-change subscriptions for all ChatBox
/// sources across every scene. Called after layout load so existing ChatBox
/// sources show mock content and respond to config changes.
/// </summary>
private void UpdateActiveChatBoxPreviews()
{
foreach (var scene in Scenes)
foreach (var chatBox in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.ChatBox))
{
chatBox.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor)
or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity)
or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps)
or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor)
or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily)
or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height))
UpdateChatBoxPreview(chatBox);
};
UpdateChatBoxPreview(chatBox);
}
}
=> _chatLayer.UpdateConfiguredBoxes(Scenes);
}
+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
/// 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
/// 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
/// 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>
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;
/// 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
// 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 _onAirPillOn;
private bool _isRecording;
private string? _recordFolder;
/// <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;
+1 -1
View File
@@ -274,7 +274,7 @@ public partial class MainViewModel : ViewModelBase
_screenCaptureManager.CaptureFailed += (key, message) =>
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}");
_chatBoxRenderer = new ChatBoxRenderer();
_chatLayer = new ChatOverlayLayer(new ChatBoxRenderer());
_rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL
_framePump = new FramePump(
+183
View File
@@ -0,0 +1,183 @@
using System.ComponentModel;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.ViewModels;
public sealed record DialogEntry(SocialService Service, string Handle, string ProfileUrl, string? FediverseSoftware = null);
/// <summary>
/// One of the six fixed dialog rows. Row 0 is always YouTube (signed-in account
/// or a sign-in prompt); rows 1-5 hold validated entries, an empty slot, or — on
/// the free tier — a locked "Premium" slot. Rows are a positional projection of
/// the dialog's working list, so deleting an entry makes the ones above advance up.
/// </summary>
public sealed class SocialSlotViewModel : ViewModelBase
{
public int Index { get; }
public bool IsYoutube => Index == 0;
private SocialService _service = SocialService.Link;
public SocialService Service
{
get => _service;
set { if (SetProperty(ref _service, value)) OnPropertyChanged(nameof(LogoData)); }
}
public string LogoData => Service == SocialService.Fediverse
? SocialServiceIcons.LogoDataForFediverse(FediverseSoftware)
: SocialServiceIcons.LogoDataFor(Service);
public string LockedIconData => SocialServiceIcons.LockedIconData;
public string DoNotIconData => SocialServiceIcons.DoNotIconData;
private bool _isLocked;
public bool IsLocked
{
get => _isLocked;
set
{
if (SetProperty(ref _isLocked, value))
{
OnPropertyChanged(nameof(ShowAddButton));
OnPropertyChanged(nameof(ShowDoNot));
OnPropertyChanged(nameof(DisplayText));
}
}
}
private bool _isFilled;
public bool IsFilled
{
get => _isFilled;
set
{
if (SetProperty(ref _isFilled, value))
{
OnPropertyChanged(nameof(ShowEditButton));
OnPropertyChanged(nameof(ShowDeleteButton));
OnPropertyChanged(nameof(ShowAddButton));
OnPropertyChanged(nameof(ShowDoNot));
OnPropertyChanged(nameof(DisplayText));
}
}
}
private bool _isSignIn;
public bool IsSignIn
{
get => _isSignIn;
set
{
if (SetProperty(ref _isSignIn, value))
{
OnPropertyChanged(nameof(ShowSignInButton));
OnPropertyChanged(nameof(ShowDeleteButton));
OnPropertyChanged(nameof(ShowLogo));
OnPropertyChanged(nameof(DisplayText));
}
}
}
private bool _isEditing;
public bool IsEditing
{
get => _isEditing;
set
{
if (SetProperty(ref _isEditing, value))
{
OnPropertyChanged(nameof(ShowEditBox));
OnPropertyChanged(nameof(ShowDisplay));
OnPropertyChanged(nameof(ShowEditButton));
OnPropertyChanged(nameof(ShowDeleteButton));
OnPropertyChanged(nameof(ShowAddButton));
OnPropertyChanged(nameof(ShowDoNot));
}
}
}
private bool _isValidating;
public bool IsValidating
{
get => _isValidating;
set
{
if (SetProperty(ref _isValidating, value))
{
OnPropertyChanged(nameof(ShowBusy));
OnPropertyChanged(nameof(ShowDoNot));
}
}
}
private string _handle = string.Empty;
public string Handle
{
get => _handle;
set
{
if (SetProperty(ref _handle, value))
OnPropertyChanged(nameof(DisplayText));
}
}
private string? _fediverseSoftware;
public string? FediverseSoftware
{
get => _fediverseSoftware;
set
{
if (SetProperty(ref _fediverseSoftware, value))
OnPropertyChanged(nameof(LogoData));
}
}
public string ProfileUrl { get; set; } = string.Empty;
private string? _error;
public string? Error
{
get => _error;
set
{
if (SetProperty(ref _error, value))
{
OnPropertyChanged(nameof(HasError));
OnPropertyChanged(nameof(ShowError));
OnPropertyChanged(nameof(ShowDoNot));
}
}
}
private string _editText = string.Empty;
public string EditText
{
get => _editText;
set => SetProperty(ref _editText, value);
}
/// <summary>The last text submitted for validation (used to skip re-validating
/// identical input — e.g. a LostFocus firing as the user clicks Cancel).</summary>
public string? LastValidatedInput { get; set; }
public string DisplayText =>
IsYoutube
? (IsSignIn ? "Sign in to YouTube" : Handle)
: IsLocked ? "Unlock with Premium"
: IsFilled ? Handle
: "No social yet";
public bool HasError => !string.IsNullOrEmpty(Error);
public bool ShowError => HasError;
public bool ShowLogo => IsYoutube ? !IsSignIn : IsFilled;
public bool ShowBusy => IsValidating;
public bool ShowEditBox => IsEditing;
public bool ShowDisplay => !IsEditing && !IsValidating;
public bool ShowSignInButton => IsYoutube && IsSignIn && !IsValidating;
public bool ShowAddButton => !IsYoutube && !IsLocked && !IsFilled && !IsEditing && !IsValidating;
public bool ShowEditButton => !IsYoutube && IsFilled && !IsEditing && !IsValidating;
public bool ShowDeleteButton => (IsFilled || IsYoutube) && !IsEditing && !IsValidating;
public bool ShowDoNot => !IsYoutube && !IsLocked && (!IsFilled || HasError || IsValidating);
public SocialSlotViewModel(int index) => Index = index;
}
-181
View File
@@ -8,187 +8,6 @@ using ytLive.Services;
namespace ytLive.ViewModels;
/// <summary>
/// A validated social entry in the dialog's working copy (non-YouTube slots).
/// </summary>
public sealed record DialogEntry(SocialService Service, string Handle, string ProfileUrl, string? FediverseSoftware = null);
/// <summary>
/// One of the six fixed dialog rows. Row 0 is always YouTube (signed-in account
/// or a sign-in prompt); rows 1-5 hold validated entries, an empty slot, or — on
/// the free tier — a locked "Premium" slot. Rows are a positional projection of
/// the dialog's working list, so deleting an entry makes the ones above advance up.
/// </summary>
public sealed class SocialSlotViewModel : ViewModelBase
{
public int Index { get; }
public bool IsYoutube => Index == 0;
private SocialService _service = SocialService.Link;
public SocialService Service
{
get => _service;
set { if (SetProperty(ref _service, value)) OnPropertyChanged(nameof(LogoData)); }
}
public string LogoData => Service == SocialService.Fediverse
? SocialServiceIcons.LogoDataForFediverse(FediverseSoftware)
: SocialServiceIcons.LogoDataFor(Service);
public string LockedIconData => SocialServiceIcons.LockedIconData;
public string DoNotIconData => SocialServiceIcons.DoNotIconData;
private bool _isLocked;
public bool IsLocked
{
get => _isLocked;
set
{
if (SetProperty(ref _isLocked, value))
{
OnPropertyChanged(nameof(ShowAddButton));
OnPropertyChanged(nameof(ShowDoNot));
OnPropertyChanged(nameof(DisplayText));
}
}
}
private bool _isFilled;
public bool IsFilled
{
get => _isFilled;
set
{
if (SetProperty(ref _isFilled, value))
{
OnPropertyChanged(nameof(ShowEditButton));
OnPropertyChanged(nameof(ShowDeleteButton));
OnPropertyChanged(nameof(ShowAddButton));
OnPropertyChanged(nameof(ShowDoNot));
OnPropertyChanged(nameof(DisplayText));
}
}
}
private bool _isSignIn;
public bool IsSignIn
{
get => _isSignIn;
set
{
if (SetProperty(ref _isSignIn, value))
{
OnPropertyChanged(nameof(ShowSignInButton));
OnPropertyChanged(nameof(ShowDeleteButton));
OnPropertyChanged(nameof(ShowLogo));
OnPropertyChanged(nameof(DisplayText));
}
}
}
private bool _isEditing;
public bool IsEditing
{
get => _isEditing;
set
{
if (SetProperty(ref _isEditing, value))
{
OnPropertyChanged(nameof(ShowEditBox));
OnPropertyChanged(nameof(ShowDisplay));
OnPropertyChanged(nameof(ShowEditButton));
OnPropertyChanged(nameof(ShowDeleteButton));
OnPropertyChanged(nameof(ShowAddButton));
OnPropertyChanged(nameof(ShowDoNot));
}
}
}
private bool _isValidating;
public bool IsValidating
{
get => _isValidating;
set
{
if (SetProperty(ref _isValidating, value))
{
OnPropertyChanged(nameof(ShowBusy));
OnPropertyChanged(nameof(ShowDoNot));
}
}
}
private string _handle = string.Empty;
public string Handle
{
get => _handle;
set
{
if (SetProperty(ref _handle, value))
OnPropertyChanged(nameof(DisplayText));
}
}
private string? _fediverseSoftware;
public string? FediverseSoftware
{
get => _fediverseSoftware;
set
{
if (SetProperty(ref _fediverseSoftware, value))
OnPropertyChanged(nameof(LogoData));
}
}
public string ProfileUrl { get; set; } = string.Empty;
private string? _error;
public string? Error
{
get => _error;
set
{
if (SetProperty(ref _error, value))
{
OnPropertyChanged(nameof(HasError));
OnPropertyChanged(nameof(ShowError));
OnPropertyChanged(nameof(ShowDoNot));
}
}
}
private string _editText = string.Empty;
public string EditText
{
get => _editText;
set => SetProperty(ref _editText, value);
}
/// <summary>The last text submitted for validation (used to skip re-validating
/// identical input — e.g. a LostFocus firing as the user clicks Cancel).</summary>
public string? LastValidatedInput { get; set; }
public string DisplayText =>
IsYoutube
? (IsSignIn ? "Sign in to YouTube" : Handle)
: IsLocked ? "Unlock with Premium"
: IsFilled ? Handle
: "No social yet";
public bool HasError => !string.IsNullOrEmpty(Error);
public bool ShowError => HasError;
public bool ShowLogo => IsYoutube ? !IsSignIn : IsFilled;
public bool ShowBusy => IsValidating;
public bool ShowEditBox => IsEditing;
public bool ShowDisplay => !IsEditing && !IsValidating;
public bool ShowSignInButton => IsYoutube && IsSignIn && !IsValidating;
public bool ShowAddButton => !IsYoutube && !IsLocked && !IsFilled && !IsEditing && !IsValidating;
public bool ShowEditButton => !IsYoutube && IsFilled && !IsEditing && !IsValidating;
public bool ShowDeleteButton => (IsFilled || IsYoutube) && !IsEditing && !IsValidating;
public bool ShowDoNot => !IsYoutube && !IsLocked && (!IsFilled || HasError || IsValidating);
public SocialSlotViewModel(int index) => Index = index;
}
/// <summary>
/// View model for the Social Media Site Promotion dialog. Owns the working copy
/// of the social bar config: the six fixed slots, the sign-in gate, per-row
+4
View File
@@ -69,5 +69,9 @@ commit, tag `refactor-commit-N`, no per-commit push).
- [x] **Commit A · `refactor-commit-A`** — **core** → `MainViewModel.cs` 758 → **495**. Moved source add/remove/image block to new **`MainViewModel.Sources.cs`** (234 lines: `AddSource`, `NextSourceName`, `AddImage`, `BuildImageCandidates`, `PickImageBytes`, `AddAsset`, `AddImageSource`, `AddReusedImage`, `RemoveElement`) and WebView2 hosting (`RegisterLoadedWebSources`, `InitWebView2`, `OnWebView2PreviewBitmapChanged`) to new **`MainViewModel.Web.cs`**. Pruned now-dead usings (`Microsoft.Win32`, `NAudio.CoreAudioApi`, `System.Windows.Media.Imaging`, `Collections.ObjectModel/Specialized`). Pending: Streaming, 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 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.
- [x] **Commit G · `refactor-commit-G`** — **ChatOverlayLayer (true component)** → new **`Services/ChatOverlayLayer.cs`** (199 lines). The first REAL decomposition of the 4,105-line god-object: `MainViewModel.Chat.cs` 194 → **44** (thin delegation). The layer owns all chat behavior — `Messages` buffer, `ChatBoxRenderer`, fade timer, mock-preview timer, per-source preview renders, live-output `RenderFrame`. The VM keeps only the binding surface: `ChatMessages` (delegates to `_chatLayer.Messages`, so XAML bind + LeftPanel `CollectionChanged` hold), `CanAddYouTubeChat`/`ShowChatInactiveMessage` (computed + `OnPropertyChanged` from VM setters), and thin forwards. Scenes are handed in as args (no `Func` seam) so the layer stays scene-graph-free. Zero behavior change; 246 pass / 2 known.
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.