Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85893ea709 | |||
| 3107f928ab | |||
| 5b9ff5b742 |
+53
-50
@@ -2,45 +2,50 @@
|
|||||||
|
|
||||||
## Branch / Commit State
|
## Branch / Commit State
|
||||||
|
|
||||||
**`main`**, working tree is **CLEAN**.
|
**`main`**, working tree is **CLEAN**. `origin/main` == `1f4624c` (pushed).
|
||||||
|
|
||||||
Both split phases are now complete. Phase 1 (view-model partials) is pushed;
|
**Pushed** (reached `origin/main` == `1f4624c`, incl. tags): Phase 1 commit `0`
|
||||||
Phase 2 (MainWindow.xaml → UserControls) is complete but **local-only**.
|
+ refactor commits `1..11`, **and Phase 2 commits `12..18`** (they were pushed
|
||||||
|
at the Phase-2 milestone; the old "Phase 2 local-only" claim below was written
|
||||||
|
before that push and is stale).
|
||||||
|
|
||||||
**Pushed** (reached `origin/main` == `14d3ed4`, incl. tags): Phase 1 commit `0`
|
**LOCAL ONLY (Phase 3 — A, B, C, D, E) + the Controls doc commit** — policy
|
||||||
and refactor commits `1..11` (`refactor-commit-0..11`), plus policy/HANDOFF.
|
2026-08-30: no per-commit push; `git push` only at a user-agreed milestone /
|
||||||
|
"push it". **Push pending on explicit user approval:**
|
||||||
|
|
||||||
**LOCAL ONLY (Phase 2 — 12, 13, 14, 15, 16, 17, 18)** — policy 2026-08-30: no
|
| Commit | Hash | Tag | Payload |
|
||||||
per-commit push; `git push` only at a user-agreed milestone / "push it".
|
|--------|------|-----|---------|
|
||||||
**Push pending on explicit user approval:**
|
| docs | `cb54637` | — | `Controls/index.md` (missing per-directory map) |
|
||||||
|
| A | `6944db8` | `refactor-commit-A` | split `MainViewModel.cs` core → **495** |
|
||||||
| Commit | Hash | Tag | Control | From Loc |
|
| B | `31362d1` | `refactor-commit-B` | split `MainViewModel.Streaming.cs` → **330** |
|
||||||
|--------|------|-----|---------|----------|
|
| C | `fab2e09` | `refactor-commit-C` | split `MainViewModel.Background.cs` → **418** |
|
||||||
| 12 | `bc23b64` | `refactor-commit-12` | `Controls/BottomBar` | footer |
|
| D | `d3271a0` | `refactor-commit-D` | trim `SocialsDialogViewModel.cs` → **334** |
|
||||||
| 13 | `6914252` | `refactor-commit-13` | `Controls/HealthBanner` | row 1 banner |
|
| E | `dcb3637` | `refactor-commit-E` | split `LayoutStore.cs` → 6 partials, **≤495** |
|
||||||
| 14 | `90f5ad5` | `refactor-commit-14` | `Controls/TopBar` | row 0 top bar |
|
|
||||||
| 15 | `c5e3466` | `refactor-commit-15` | `Controls/PreviewPane` | center preview |
|
|
||||||
| 16 | `73397cf` | `refactor-commit-16` | `Controls/SceneThumbnailStrip` | row 0 strip |
|
|
||||||
| 17 | `c9fd1bd` | `refactor-commit-17` | `Controls/LeftPanel` | row 1 col 0 |
|
|
||||||
| 18 | `544208e` | `refactor-commit-18` | `Controls/OverlayHost` | overlay dialogs |
|
|
||||||
|
|
||||||
## What's In Flight
|
## What's In Flight
|
||||||
|
|
||||||
Nothing code-in-flight — working tree clean, **Phase 2 complete**.
|
Nothing code-in-flight — working tree clean, **Phase 3 complete**.
|
||||||
|
|
||||||
**Phase 2 result:** `MainWindow.xaml` carved 1,681 → **123 lines** (a shell over
|
**Phase 3 result:** every production `.cs` is now **≤ 500 lines** (checked via
|
||||||
6 UserControls under `Controls/`, namespace `ytLive.Controls`). Every change:
|
`git ls-files '*.cs'` minus tests; nothing exceeds 500). The 500-line count is a
|
||||||
0-warning build, `scripts/verify.sh` (246 pass + 2 known only), explicit-file
|
**hard ceiling, not the goal** — each file was split **grouped by functionality**
|
||||||
`scripts/scope-check.sh`, `ViewModels/index.md` in the SAME commit, tagged
|
so an AI can process one concern per file. Splits were **concern-driven**:
|
||||||
`refactor-commit-N`, no per-commit push.
|
- `MainViewModel` → `.Sources.cs`/`.Web.cs`, `Streaming.cs` + `Streaming.Operations.cs`,
|
||||||
|
`Background.cs` + `Background.Model.cs`; `SocialSlotViewModel.cs` (row VM).
|
||||||
|
- `LayoutStore` (was 1402) → `Migrations.cs`/`Load.cs`/`Save.cs`/`Settings.cs`/
|
||||||
|
`Assets.cs` + a 47-line shell; `public partial class LayoutStore : IDisposable`.
|
||||||
|
|
||||||
|
Every change: 0-warning build, `scripts/verify.sh` (246 pass + 2 known only),
|
||||||
|
explicit-file `scripts/scope-check.sh`, `ViewModels/index.md` tracker in the
|
||||||
|
SAME commit, tagged `refactor-commit-N`, no per-commit push.
|
||||||
|
|
||||||
**Next:**
|
**Next:**
|
||||||
- If user says "push": `git push origin main && git push --tags` (7 commits 12-18
|
- If user says "push": `git push origin main && git push --tags` (6 commits:
|
||||||
+ their tags).
|
`cb54637` + A–E, and tags `refactor-commit-A..E`).
|
||||||
- Then optional Phase 3 if queued. Nothing is queued beyond Phase 2.
|
|
||||||
|
|
||||||
**Pending bus:** `ViewModels/index.md` Phase 2 tracker has rows 12–18 (closed);
|
**Pending bus:** `ViewModels/index.md` Phase 3 tracker has rows A–E (closed);
|
||||||
Phase 1 tracker closed. `Controls/` has `index.md`-style facts only if needed.
|
Phase 3 marked COMPLETE. Watch-list (under limit, no action): `Controls/PreviewPane.xaml.cs`
|
||||||
|
(464), `ViewModels/MainViewModel.Audio.cs` (408).
|
||||||
|
|
||||||
## Landmines
|
## Landmines
|
||||||
|
|
||||||
@@ -69,31 +74,29 @@ Phase 1 tracker closed. `Controls/` has `index.md`-style facts only if needed.
|
|||||||
## Todo state
|
## Todo state
|
||||||
|
|
||||||
- MainViewModel refactor: **Phase 1 complete (11/11 partials)**, pushed.
|
- MainViewModel refactor: **Phase 1 complete (11/11 partials)**, pushed.
|
||||||
- MainWindow.xaml refactor: **Phase 2 complete (6/6 controls)**, local-only.
|
- MainWindow.xaml refactor: **Phase 2 complete (6/6 controls)**, pushed at `1f4624c`.
|
||||||
|
- 500-line compliance: **Phase 3 complete (all 5 offenders ≤ 500)**, local-only A–E.
|
||||||
- TASK 3: 27/30 (preview compositor 16, text source 17, alerts 20 still open).
|
- TASK 3: 27/30 (preview compositor 16, text source 17, alerts 20 still open).
|
||||||
- TASK 4: ✅ shipped. TASK 9: items 1–3 shipped; 4–7 open. TASK 10: steps 1–7; Velopack pending.
|
- TASK 4: ✅ shipped. TASK 9: items 1–3 shipped; 4–7 open. TASK 10: steps 1–7; Velopack pending.
|
||||||
- TASK 18: shipped, creator verification pending. TASK 19/23, 20, 21-A: shipped.
|
- TASK 18: shipped, creator verification pending. TASK 19/23, 20, 21-A: shipped.
|
||||||
- TASK 22 (audio sync offset): not started — required for v1.
|
- TASK 22 (audio sync offset): not started — required for v1.
|
||||||
|
|
||||||
## Session summary (2026-08-30)
|
## Session summary (2026-08-31)
|
||||||
|
|
||||||
- Ran the **MainWindow.xaml split to completion** under the standing "continue
|
- Ran the **500-line Phase 3 split to completion** under the standing "continue
|
||||||
without stopping" directive — no interruption prompts, no `question` tool.
|
without stopping" directive — no interruption prompts, no `question` tool.
|
||||||
- Commits 12–18 landed locally, each: cut → 0-warning build → verify (246 pass +
|
- Commits A–E landed locally, each: cut → 0-warning build → verify (246 pass + 2
|
||||||
2 known) → scope-check → index.md in-commit → `git tag refactor-commit-N`. No push.
|
known) → scope-check → index.md in-commit → `git tag refactor-commit-{A..E}`.
|
||||||
- **Commit 15 (PreviewPane** the hardest: 1920×1080 canvas + drag/select/resize/
|
- **Re-structuring principle (user correction, 2026-08-31):** split by
|
||||||
hit-test, webcam menu handlers, social glow, audio meters. A dropped outer
|
**functionality**, so an AI can process one concern per file; the 500-line
|
||||||
`Border` (background/glow/margin) was caught and restored same-commit.
|
count is a **ceiling**, NOT the target. Do not pad or reshape code to a
|
||||||
- **Commit 17 (LeftPanel)** the biggest: property editors + chat autoscroll
|
number — group by concern and only split enough to stay under the ceiling.
|
||||||
(moved to self-subscribe on `Loaded`), drag-to-reorder, snapshot revert/accept
|
- **Commit E (LayoutStore)** the biggest: 1402 → 6 concern-based partials
|
||||||
family; window routes via `_leftPanel.IsClickInside` and
|
(Migrations/Load/Save/Settings/Assets/shell), default partial split.
|
||||||
`_leftPanel.OnSelectionChanged(vm)`.
|
- **Accident caught & recovered (Commit D):** a `> ViewModels/SocialsDialogViewModel.cs`
|
||||||
- **Commit 18 (OverlayHost)** the scope-decision: moved only the three dialogs;
|
write that read from the file it was simultaneously truncating left the file at
|
||||||
kept `WebViewHostPanel` + `ToastArea` as window-root stubs because their
|
1 line. Fixed IMMEDIATELY via `git checkout --` and re-ran the split. **Lesson**
|
||||||
name-based lookups (WebView2 init, toast `InArea`) would break if buried in a
|
(goes in `MyMistakes.md`): never `awk … > SRC` where `awk` also reads `SRC`;
|
||||||
control — a spin-guard decision, not a guess.
|
stage every multi-block cut into temp files first, then assemble. All later
|
||||||
- Two XAML extraction gotchas recorded: (a) a moved block losing its `xmlns`
|
LayoutStore cuts did exactly that (read original once into temp files).
|
||||||
→ MC2000 "key null" (Line n col m), fixed by adding `xmlns:models` on the
|
- Push checkpoint reached; awaiting explicit "push" for `cb54637` + A–E + tags.
|
||||||
control root; (b) re-deriving line numbers after each edit before `sed`/awk
|
|
||||||
deletion ranges (the edit tool shifts numbers).
|
|
||||||
- Push checkpoint reached; awaiting explicit "push" for commits 12–18 + tags.
|
|
||||||
|
|||||||
@@ -44,3 +44,26 @@ packages, high-quality downscale via `TransformedBitmap`. Screenshots compress
|
|||||||
4. Point `README.md` at the `.jpg` (not `.png`).
|
4. Point `README.md` at the `.jpg` (not `.png`).
|
||||||
|
|
||||||
**Result:** 3.2MB screenshot → 1400×794 → **188KB** `ytLlive-preview.jpg` in `docs/`.
|
**Result:** 3.2MB screenshot → 1400×794 → **188KB** `ytLlive-preview.jpg` in `docs/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Splitting a large file into partials — NEVER `awk … > SRC` while awking SRC
|
||||||
|
|
||||||
|
(2026-08-31, Commit D) Tried to split `SocialsDialogViewModel.cs` in one line:
|
||||||
|
`{ awk '…' SRC; echo ""; awk '…2…' SRC; } > SRC`. The **first write truncated
|
||||||
|
SRC to 1 line**, so the second `awk` read the already-truncated file → the whole
|
||||||
|
source was lost (1 line left). Recovered with `git checkout -- SRC`, then redid
|
||||||
|
it, but the same bug could have meant making it up from scratch.
|
||||||
|
|
||||||
|
**Rule:** when a cut needs N blocks from one source into N files, never write a
|
||||||
|
block back onto the source that the `awk`s still read. Instead:
|
||||||
|
1. Read the source **once** at the start into temp files (`mktemp -d`, one file
|
||||||
|
per block), with a `$D` variable you carry forward.
|
||||||
|
2. Verify block sizes (`wc -l`) and brace balance (`python3 -c` counting `{`/`}`)
|
||||||
|
before touching any real file.
|
||||||
|
3. Then assemble each new file from `cat D/block …` — never truncating the source
|
||||||
|
until every read is done.
|
||||||
|
|
||||||
|
`git status` can't save you here if you don't notice until the file is gone —
|
||||||
|
`git checkout -- <path>` from the last commit is the recovery. Cheap insurance:
|
||||||
|
restore-then-retry, do it atomically from temp files the first time.
|
||||||
|
|||||||
@@ -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 => _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Windows.Media;
|
using System.Linq;
|
||||||
using System.Windows.Media.Imaging;
|
|
||||||
using System.Windows.Threading;
|
|
||||||
using ytLive.Helpers;
|
using ytLive.Helpers;
|
||||||
using ytLive.Models;
|
using ytLive.Models;
|
||||||
using ytLive.Services;
|
using ytLive.Services;
|
||||||
@@ -11,22 +9,12 @@ namespace ytLive.ViewModels;
|
|||||||
public partial class MainViewModel : ViewModelBase
|
public partial class MainViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
|
|
||||||
private readonly ChatBoxRenderer _chatBoxRenderer;
|
private readonly ChatOverlayLayer _chatLayer;
|
||||||
|
|
||||||
// Mock chat messages for the ChatBox preview — branded placeholders so the
|
/// <summary>The live chat message buffer — the same collection the
|
||||||
// creator sees where the overlay lands before going live.
|
/// <see cref="ChatOverlayLayer"/> owns; here so XAML can bind it and the
|
||||||
private static readonly IReadOnlyList<ChatMessage> MockChatMessages = new[]
|
/// left panel can subscribe to its changes.</summary>
|
||||||
{
|
public ObservableCollection<ChatMessage> ChatMessages => _chatLayer.Messages;
|
||||||
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 Add → YouTube Chat menu item: enabled only while no chat layer exists
|
/// <summary>The Add → YouTube Chat menu item: enabled only while no chat layer exists
|
||||||
/// anywhere — one chat overlay per layout.</summary>
|
/// anywhere — one chat overlay per layout.</summary>
|
||||||
@@ -37,158 +25,20 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
public bool ShowChatInactiveMessage => !IsLive;
|
public bool ShowChatInactiveMessage => !IsLive;
|
||||||
|
|
||||||
private void OnChatMessageReceived(ChatMessage message)
|
private void OnChatMessageReceived(ChatMessage message)
|
||||||
{
|
=> _chatLayer.OnMessageReceived(message, Scenes);
|
||||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
|
||||||
{
|
|
||||||
ChatMessages.Add(message);
|
|
||||||
if (ChatMessages.Count > 500)
|
|
||||||
ChatMessages.RemoveAt(0);
|
|
||||||
|
|
||||||
// Reset chat fade timer on every message
|
private void UpdateChatBoxPreview(Source chatBox, IReadOnlyList<ChatMessage>? messages = null)
|
||||||
ResetChatFadeTimer();
|
=> _chatLayer.UpdatePreview(chatBox, messages);
|
||||||
|
|
||||||
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 VideoFrame? RenderChatBox(Source chatBox)
|
private VideoFrame? RenderChatBox(Source chatBox)
|
||||||
{
|
=> _chatLayer.RenderFrame(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StartMockChatPreview()
|
private void StartMockChatPreview()
|
||||||
{
|
=> _chatLayer.StartMockPreview(Scenes);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StopMockChatPreview()
|
private void StopMockChatPreview()
|
||||||
{
|
=> _chatLayer.StopMockPreview();
|
||||||
_mockChatCts?.Cancel();
|
|
||||||
_mockChatCts?.Dispose();
|
|
||||||
_mockChatCts = 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>
|
|
||||||
private void UpdateActiveChatBoxPreviews()
|
private void UpdateActiveChatBoxPreviews()
|
||||||
{
|
=> _chatLayer.UpdateConfiguredBoxes(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))
|
|
||||||
UpdateChatBoxPreview(chatBox);
|
|
||||||
};
|
|
||||||
UpdateChatBoxPreview(chatBox);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private string? _recordFolder;
|
||||||
|
public string RecordFolderDisplay => _recordFolder ?? DefaultRecordFolder();
|
||||||
|
private string? _activeRecordPath;
|
||||||
|
private DateTime _recordStartTime;
|
||||||
|
private TimeSpan _recordLength;
|
||||||
|
|
||||||
|
private string? StartRecordFile()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var folder = _recordFolder ?? DefaultRecordFolder();
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
|
||||||
|
var path = Path.Combine(folder, name);
|
||||||
|
_activeRecordPath = path;
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write(ex, "Recording: failed to prepare output path");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FinalizeRecordingAsync()
|
||||||
|
{
|
||||||
|
var startPath = _activeRecordPath;
|
||||||
|
_activeRecordPath = null;
|
||||||
|
if (string.IsNullOrEmpty(startPath)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (_framePump.IsRunning) await Task.Delay(50);
|
||||||
|
|
||||||
|
if (!File.Exists(startPath))
|
||||||
|
{
|
||||||
|
AppLog.Write($"Recording: output not found for rename: {startPath}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_recordLength = _liveElapsed;
|
||||||
|
var autoStem = RecordingFile.BuildFinalName(_recordStartTime, _recordLength);
|
||||||
|
var dir = Path.GetDirectoryName(startPath)!;
|
||||||
|
|
||||||
|
// Manual rename modal: give the creator a chance to name the recording;
|
||||||
|
// null = keep the auto name (or the default recorded above).
|
||||||
|
string stem = autoStem;
|
||||||
|
var dialog = new RenameRecordingDialog(autoStem);
|
||||||
|
if (dialog.ShowDialog() == true && !string.IsNullOrEmpty(dialog.ResultFileName))
|
||||||
|
stem = dialog.ResultFileName;
|
||||||
|
|
||||||
|
var finalPath = UniquePath(Path.Combine(dir, stem + ".mp4"));
|
||||||
|
File.Move(startPath, finalPath);
|
||||||
|
AppLog.Write($"Recording saved: {finalPath}");
|
||||||
|
_notifications.Info("Recording saved",
|
||||||
|
Path.GetFileName(finalPath) + " — you can rename it in your videos folder at any time.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write(ex, "Recording: rename-on-stop failed");
|
||||||
|
_notifications.Error("Recording saved",
|
||||||
|
$"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string UniquePath(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path)) return path;
|
||||||
|
var dir = Path.GetDirectoryName(path)!;
|
||||||
|
var name = Path.GetFileNameWithoutExtension(path);
|
||||||
|
var ext = Path.GetExtension(path);
|
||||||
|
for (var i = 2; ; i++)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(dir, $"{name}-{i}{ext}");
|
||||||
|
if (!File.Exists(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ChooseRecordFolder()
|
||||||
|
{
|
||||||
|
var dialog = new Microsoft.Win32.OpenFolderDialog
|
||||||
|
{
|
||||||
|
Title = "Choose a folder for local recordings",
|
||||||
|
InitialDirectory = _recordFolder ?? DefaultRecordFolder(),
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() == true)
|
||||||
|
{
|
||||||
|
_recordFolder = dialog.FolderName;
|
||||||
|
_layoutStore.SaveRecordFolder(_recordFolder);
|
||||||
|
OnPropertyChanged(nameof(RecordFolderDisplay));
|
||||||
|
_notifications.Info("Record folder set", _recordFolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetRecordFolder()
|
||||||
|
{
|
||||||
|
_recordFolder = null;
|
||||||
|
_layoutStore.SaveRecordFolder(null);
|
||||||
|
OnPropertyChanged(nameof(RecordFolderDisplay));
|
||||||
|
_notifications.Info("Record folder reset", RecordFolderDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string DefaultRecordFolder()
|
||||||
|
{
|
||||||
|
var downloads = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
||||||
|
return Directory.Exists(downloads)
|
||||||
|
? downloads
|
||||||
|
: Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,23 +96,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
/// <summary>Creates the record file (auto-named <c>ty-…-0000.mp4</c> in the record
|
/// <summary>Creates the record file (auto-named <c>ty-…-0000.mp4</c> in the record
|
||||||
/// folder) and returns its path, or null when the folder can't be prepared.</summary>
|
/// folder) and returns its path, or null when the folder can't be prepared.</summary>
|
||||||
private string? StartRecordFile()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var folder = _recordFolder ?? DefaultRecordFolder();
|
|
||||||
Directory.CreateDirectory(folder);
|
|
||||||
var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
|
|
||||||
var path = Path.Combine(folder, name);
|
|
||||||
_activeRecordPath = path;
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AppLog.Write(ex, "Recording: failed to prepare output path");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>One-click go-live (TASK 5): ensure the reusable stream, create a
|
/// <summary>One-click go-live (TASK 5): ensure the reusable stream, create a
|
||||||
/// broadcast bound to it, then start the frame pump. The pump reads the RTMP
|
/// broadcast bound to it, then start the frame pump. The pump reads the RTMP
|
||||||
@@ -269,92 +252,11 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
/// <summary>On stop the auto-named <c>ty-…-0000.mp4</c> file is renamed to carry
|
/// <summary>On stop the auto-named <c>ty-…-0000.mp4</c> file is renamed to carry
|
||||||
/// the real recording length (<c>ty-…-hh2mm2.mp4</c>), with a numeric suffix on
|
/// the real recording length (<c>ty-…-hh2mm2.mp4</c>), with a numeric suffix on
|
||||||
/// name collisions. Runs only after the pump has fully closed the file.</summary>
|
/// name collisions. Runs only after the pump has fully closed the file.</summary>
|
||||||
private async Task FinalizeRecordingAsync()
|
|
||||||
{
|
|
||||||
var startPath = _activeRecordPath;
|
|
||||||
_activeRecordPath = null;
|
|
||||||
if (string.IsNullOrEmpty(startPath)) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (_framePump.IsRunning) await Task.Delay(50);
|
|
||||||
|
|
||||||
if (!File.Exists(startPath))
|
|
||||||
{
|
|
||||||
AppLog.Write($"Recording: output not found for rename: {startPath}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_recordLength = _liveElapsed;
|
|
||||||
var autoStem = RecordingFile.BuildFinalName(_recordStartTime, _recordLength);
|
|
||||||
var dir = Path.GetDirectoryName(startPath)!;
|
|
||||||
|
|
||||||
// Manual rename modal: give the creator a chance to name the recording;
|
|
||||||
// null = keep the auto name (or the default recorded above).
|
|
||||||
string stem = autoStem;
|
|
||||||
var dialog = new RenameRecordingDialog(autoStem);
|
|
||||||
if (dialog.ShowDialog() == true && !string.IsNullOrEmpty(dialog.ResultFileName))
|
|
||||||
stem = dialog.ResultFileName;
|
|
||||||
|
|
||||||
var finalPath = UniquePath(Path.Combine(dir, stem + ".mp4"));
|
|
||||||
File.Move(startPath, finalPath);
|
|
||||||
AppLog.Write($"Recording saved: {finalPath}");
|
|
||||||
_notifications.Info("Recording saved",
|
|
||||||
Path.GetFileName(finalPath) + " — you can rename it in your videos folder at any time.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AppLog.Write(ex, "Recording: rename-on-stop failed");
|
|
||||||
_notifications.Error("Recording saved",
|
|
||||||
$"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string UniquePath(string path)
|
|
||||||
{
|
|
||||||
if (!File.Exists(path)) return path;
|
|
||||||
var dir = Path.GetDirectoryName(path)!;
|
|
||||||
var name = Path.GetFileNameWithoutExtension(path);
|
|
||||||
var ext = Path.GetExtension(path);
|
|
||||||
for (var i = 2; ; i++)
|
|
||||||
{
|
|
||||||
var candidate = Path.Combine(dir, $"{name}-{i}{ext}");
|
|
||||||
if (!File.Exists(candidate)) return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ChooseRecordFolder()
|
|
||||||
{
|
|
||||||
var dialog = new Microsoft.Win32.OpenFolderDialog
|
|
||||||
{
|
|
||||||
Title = "Choose a folder for local recordings",
|
|
||||||
InitialDirectory = _recordFolder ?? DefaultRecordFolder(),
|
|
||||||
};
|
|
||||||
if (dialog.ShowDialog() == true)
|
|
||||||
{
|
|
||||||
_recordFolder = dialog.FolderName;
|
|
||||||
_layoutStore.SaveRecordFolder(_recordFolder);
|
|
||||||
OnPropertyChanged(nameof(RecordFolderDisplay));
|
|
||||||
_notifications.Info("Record folder set", _recordFolder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ResetRecordFolder()
|
|
||||||
{
|
|
||||||
_recordFolder = null;
|
|
||||||
_layoutStore.SaveRecordFolder(null);
|
|
||||||
OnPropertyChanged(nameof(RecordFolderDisplay));
|
|
||||||
_notifications.Info("Record folder reset", RecordFolderDisplay);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Falls back to the user's Downloads folder when no preference is set;
|
/// <summary>Falls back to the user's Downloads folder when no preference is set;
|
||||||
/// returns MyVideos if Downloads doesn't exist.</summary>
|
/// returns MyVideos if Downloads doesn't exist.</summary>
|
||||||
internal static string DefaultRecordFolder()
|
|
||||||
{
|
|
||||||
var downloads = Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
|
||||||
return Directory.Exists(downloads)
|
|
||||||
? downloads
|
|
||||||
: Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full encoder options for the current tier, or null when no RTMP URL is
|
// Full encoder options for the current tier, or null when no RTMP URL is
|
||||||
// available — the pump then skips the encoder entirely (TASK 5 fills the seam).
|
// available — the pump then skips the encoder entirely (TASK 5 fills the seam).
|
||||||
|
|||||||
@@ -38,14 +38,9 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private bool _recordPillOn;
|
private bool _recordPillOn;
|
||||||
private bool _onAirPillOn;
|
private bool _onAirPillOn;
|
||||||
private bool _isRecording;
|
private bool _isRecording;
|
||||||
private string? _recordFolder;
|
|
||||||
|
|
||||||
/// <summary>Resolved record folder — the configured choice, or Downloads (0th case).</summary>
|
/// <summary>Resolved record folder — the configured choice, or Downloads (0th case).</summary>
|
||||||
public string RecordFolderDisplay => _recordFolder ?? DefaultRecordFolder();
|
|
||||||
|
|
||||||
private string? _activeRecordPath;
|
|
||||||
private DateTime _recordStartTime;
|
|
||||||
private TimeSpan _recordLength;
|
|
||||||
|
|
||||||
private readonly FramePump _framePump;
|
private readonly FramePump _framePump;
|
||||||
|
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
_screenCaptureManager.CaptureFailed += (key, message) =>
|
_screenCaptureManager.CaptureFailed += (key, message) =>
|
||||||
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {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
|
_rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL
|
||||||
_framePump = new FramePump(
|
_framePump = new FramePump(
|
||||||
|
|||||||
+3
-1
@@ -70,6 +70,8 @@ commit, tag `refactor-commit-N`, no per-commit push).
|
|||||||
- [x] **Commit B · `refactor-commit-B`** — **Streaming** → `MainViewModel.Streaming.cs` 789 → **330** + new **`MainViewModel.Streaming.Operations.cs`** (459 lines). Moved the go-live/record/health operation bodies (`StartSession`, `BeginRecordOnly`, `BeginGoLive`, `StartRecordFile`, `PrepareAndStartLiveAsync`, `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue`, `StopStream`, `FinalizeRecordingAsync`, `UniquePath`, `ChooseRecordFolder`, `ResetRecordFolder`, `DefaultRecordFolder`, `OnFramePumpFailed`, `OnFramePumpHealthUpdated`, `ApplyHealth`, `ResetHealth`, `BuildEncoderOptions`) into the Operations partial; Streaming.cs keeps state + quality/resolution tiers + session timer + command declarations. Pruned dead usings both files. Pending: Background, SocialsDialogViewModel, LayoutStore.
|
- [x] **Commit B · `refactor-commit-B`** — **Streaming** → `MainViewModel.Streaming.cs` 789 → **330** + new **`MainViewModel.Streaming.Operations.cs`** (459 lines). Moved the go-live/record/health operation bodies (`StartSession`, `BeginRecordOnly`, `BeginGoLive`, `StartRecordFile`, `PrepareAndStartLiveAsync`, `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue`, `StopStream`, `FinalizeRecordingAsync`, `UniquePath`, `ChooseRecordFolder`, `ResetRecordFolder`, `DefaultRecordFolder`, `OnFramePumpFailed`, `OnFramePumpHealthUpdated`, `ApplyHealth`, `ResetHealth`, `BuildEncoderOptions`) into the Operations partial; Streaming.cs keeps state + quality/resolution tiers + session timer + command declarations. Pruned dead usings both files. Pending: Background, SocialsDialogViewModel, LayoutStore.
|
||||||
- [x] **Commit C · `refactor-commit-C`** — **Background** → `MainViewModel.Background.cs` 567 → **418** + new **`MainViewModel.Background.Model.cs`** (163 lines). Moved the static scene-model helpers (`LoadBackgroundImage`, `CreatePlaceholderSnapshot`, `EnsureBackground`, `CreateBackground`, `NormalizeBackgrounds`, `AllBackgrounds`, `ResolveAutoCaptureKey`, `MonitorKeyPrefix`) into the Model partial; Background.cs keeps capture/live state + operations (`UpdateActiveBackground`, `UpdateBackgroundImage`, `RefreshSnapshotsAsync`, `ReacquireScreenCaptures`, `OnScreenPreviewBitmapChanged`, `OnFullscreenMonitorChanged`, `RefreshBackgroundAutoCapture`, `BrowseBackground`, `ClearBackgroundCaptureAsync`, `RedesignateBackgroundAsync`, `ChangeBackgroundCaptureAsync`, `SetBackgroundCapture`, `DefaultBackgroundAsset`, `HealBackgrounds`, `StampDefaultBackgroundAsset`). Pending: SocialsDialogViewModel, LayoutStore.
|
- [x] **Commit C · `refactor-commit-C`** — **Background** → `MainViewModel.Background.cs` 567 → **418** + new **`MainViewModel.Background.Model.cs`** (163 lines). Moved the static scene-model helpers (`LoadBackgroundImage`, `CreatePlaceholderSnapshot`, `EnsureBackground`, `CreateBackground`, `NormalizeBackgrounds`, `AllBackgrounds`, `ResolveAutoCaptureKey`, `MonitorKeyPrefix`) into the Model partial; Background.cs keeps capture/live state + operations (`UpdateActiveBackground`, `UpdateBackgroundImage`, `RefreshSnapshotsAsync`, `ReacquireScreenCaptures`, `OnScreenPreviewBitmapChanged`, `OnFullscreenMonitorChanged`, `RefreshBackgroundAutoCapture`, `BrowseBackground`, `ClearBackgroundCaptureAsync`, `RedesignateBackgroundAsync`, `ChangeBackgroundCaptureAsync`, `SetBackgroundCapture`, `DefaultBackgroundAsset`, `HealBackgrounds`, `StampDefaultBackgroundAsset`). Pending: SocialsDialogViewModel, LayoutStore.
|
||||||
- [x] **Commit D · `refactor-commit-D`** — **SocialsDialog** → `SocialsDialogViewModel.cs` 515 → **334** + new **`SocialSlotViewModel.cs`** (183 lines). Moved the `DialogEntry` record + the row-level `SocialSlotViewModel` (six-fixed-slot row VM: service/logo/lock/edit/validate state + computed `Show*` flags) into its own file; `SocialsDialogViewModel.cs` keeps the dialog VM (sign-in gate, validation orchestration, commit). `DialogEntry` stays `public` in the same namespace so both files see it. Pending: LayoutStore.
|
- [x] **Commit D · `refactor-commit-D`** — **SocialsDialog** → `SocialsDialogViewModel.cs` 515 → **334** + new **`SocialSlotViewModel.cs`** (183 lines). Moved the `DialogEntry` record + the row-level `SocialSlotViewModel` (six-fixed-slot row VM: service/logo/lock/edit/validate state + computed `Show*` flags) into its own file; `SocialsDialogViewModel.cs` keeps the dialog VM (sign-in gate, validation orchestration, commit). `DialogEntry` stays `public` in the same namespace so both files see it. Pending: LayoutStore.
|
||||||
- [x] **Commit E · `refactor-commit-E`** — **LayoutStore** → `LayoutStore.cs` 1402 split into 6 concern-based partials: **`LayoutStore.cs`** (47: shell — `Instance`, fields, ctor + `EnsureSchema`, `GetUserVersion`, `Dispose`), **`LayoutStore.Migrations.cs`** (495: all `Migrate*` + `EnsureSchema`), **`LayoutStore.Load.cs`** (221: `Load`), **`LayoutStore.Save.cs`** (339: `Save`), **`LayoutStore.Settings.cs`** (292: mic/record-folder/reusable-stream + license/transition/broadcast/hotkey/window `GetSetting`/`UpsertSetting`), **`LayoutStore.Assets.cs`** (41: `GetAssetBytes`/`UpsertAsset`). **PHASE 3 COMPLETE — no production `.cs` over 500.** Pending: session wrap-up (HANDOFF.md) + push on approval.
|
- [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
|
The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split
|
||||||
proceeds; each partial carries its own `using`s and re-declares nothing from core.
|
proceeds; each partial carries its own `using`s and re-declares nothing from core.
|
||||||
Reference in New Issue
Block a user