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.
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -70,6 +70,7 @@ commit, tag `refactor-commit-N`, no per-commit push).
|
|||||||
- [x] **Commit B · `refactor-commit-B`** — **Streaming** → `MainViewModel.Streaming.cs` 789 → **330** + new **`MainViewModel.Streaming.Operations.cs`** (459 lines). Moved the go-live/record/health operation bodies (`StartSession`, `BeginRecordOnly`, `BeginGoLive`, `StartRecordFile`, `PrepareAndStartLiveAsync`, `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue`, `StopStream`, `FinalizeRecordingAsync`, `UniquePath`, `ChooseRecordFolder`, `ResetRecordFolder`, `DefaultRecordFolder`, `OnFramePumpFailed`, `OnFramePumpHealthUpdated`, `ApplyHealth`, `ResetHealth`, `BuildEncoderOptions`) into the Operations partial; Streaming.cs keeps state + quality/resolution tiers + session timer + command declarations. Pruned dead usings both files. Pending: Background, SocialsDialogViewModel, LayoutStore.
|
- [x] **Commit B · `refactor-commit-B`** — **Streaming** → `MainViewModel.Streaming.cs` 789 → **330** + new **`MainViewModel.Streaming.Operations.cs`** (459 lines). Moved the go-live/record/health operation bodies (`StartSession`, `BeginRecordOnly`, `BeginGoLive`, `StartRecordFile`, `PrepareAndStartLiveAsync`, `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue`, `StopStream`, `FinalizeRecordingAsync`, `UniquePath`, `ChooseRecordFolder`, `ResetRecordFolder`, `DefaultRecordFolder`, `OnFramePumpFailed`, `OnFramePumpHealthUpdated`, `ApplyHealth`, `ResetHealth`, `BuildEncoderOptions`) into the Operations partial; Streaming.cs keeps state + quality/resolution tiers + session timer + command declarations. Pruned dead usings both files. Pending: Background, SocialsDialogViewModel, LayoutStore.
|
||||||
- [x] **Commit C · `refactor-commit-C`** — **Background** → `MainViewModel.Background.cs` 567 → **418** + new **`MainViewModel.Background.Model.cs`** (163 lines). Moved the static scene-model helpers (`LoadBackgroundImage`, `CreatePlaceholderSnapshot`, `EnsureBackground`, `CreateBackground`, `NormalizeBackgrounds`, `AllBackgrounds`, `ResolveAutoCaptureKey`, `MonitorKeyPrefix`) into the Model partial; Background.cs keeps capture/live state + operations (`UpdateActiveBackground`, `UpdateBackgroundImage`, `RefreshSnapshotsAsync`, `ReacquireScreenCaptures`, `OnScreenPreviewBitmapChanged`, `OnFullscreenMonitorChanged`, `RefreshBackgroundAutoCapture`, `BrowseBackground`, `ClearBackgroundCaptureAsync`, `RedesignateBackgroundAsync`, `ChangeBackgroundCaptureAsync`, `SetBackgroundCapture`, `DefaultBackgroundAsset`, `HealBackgrounds`, `StampDefaultBackgroundAsset`). Pending: SocialsDialogViewModel, LayoutStore.
|
- [x] **Commit C · `refactor-commit-C`** — **Background** → `MainViewModel.Background.cs` 567 → **418** + new **`MainViewModel.Background.Model.cs`** (163 lines). Moved the static scene-model helpers (`LoadBackgroundImage`, `CreatePlaceholderSnapshot`, `EnsureBackground`, `CreateBackground`, `NormalizeBackgrounds`, `AllBackgrounds`, `ResolveAutoCaptureKey`, `MonitorKeyPrefix`) into the Model partial; Background.cs keeps capture/live state + operations (`UpdateActiveBackground`, `UpdateBackgroundImage`, `RefreshSnapshotsAsync`, `ReacquireScreenCaptures`, `OnScreenPreviewBitmapChanged`, `OnFullscreenMonitorChanged`, `RefreshBackgroundAutoCapture`, `BrowseBackground`, `ClearBackgroundCaptureAsync`, `RedesignateBackgroundAsync`, `ChangeBackgroundCaptureAsync`, `SetBackgroundCapture`, `DefaultBackgroundAsset`, `HealBackgrounds`, `StampDefaultBackgroundAsset`). Pending: SocialsDialogViewModel, LayoutStore.
|
||||||
- [x] **Commit D · `refactor-commit-D`** — **SocialsDialog** → `SocialsDialogViewModel.cs` 515 → **334** + new **`SocialSlotViewModel.cs`** (183 lines). Moved the `DialogEntry` record + the row-level `SocialSlotViewModel` (six-fixed-slot row VM: service/logo/lock/edit/validate state + computed `Show*` flags) into its own file; `SocialsDialogViewModel.cs` keeps the dialog VM (sign-in gate, validation orchestration, commit). `DialogEntry` stays `public` in the same namespace so both files see it. Pending: LayoutStore.
|
- [x] **Commit D · `refactor-commit-D`** — **SocialsDialog** → `SocialsDialogViewModel.cs` 515 → **334** + new **`SocialSlotViewModel.cs`** (183 lines). Moved the `DialogEntry` record + the row-level `SocialSlotViewModel` (six-fixed-slot row VM: service/logo/lock/edit/validate state + computed `Show*` flags) into its own file; `SocialsDialogViewModel.cs` keeps the dialog VM (sign-in gate, validation orchestration, commit). `DialogEntry` stays `public` in the same namespace so both files see it. Pending: LayoutStore.
|
||||||
- [x] **Commit E · `refactor-commit-E`** — **LayoutStore** → `LayoutStore.cs` 1402 split into 6 concern-based partials: **`LayoutStore.cs`** (47: shell — `Instance`, fields, ctor + `EnsureSchema`, `GetUserVersion`, `Dispose`), **`LayoutStore.Migrations.cs`** (495: all `Migrate*` + `EnsureSchema`), **`LayoutStore.Load.cs`** (221: `Load`), **`LayoutStore.Save.cs`** (339: `Save`), **`LayoutStore.Settings.cs`** (292: mic/record-folder/reusable-stream + license/transition/broadcast/hotkey/window `GetSetting`/`UpsertSetting`), **`LayoutStore.Assets.cs`** (41: `GetAssetBytes`/`UpsertAsset`). **PHASE 3 COMPLETE — no production `.cs` over 500.** 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.
|
||||||
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