diff --git a/ViewModels/MainViewModel.Recording.cs b/ViewModels/MainViewModel.Recording.cs new file mode 100644 index 0000000..09307d4 --- /dev/null +++ b/ViewModels/MainViewModel.Recording.cs @@ -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); + } +} diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index 15811d1..73d07c4 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -96,23 +96,6 @@ public partial class MainViewModel : ViewModelBase /// Creates the record file (auto-named ty-…-0000.mp4 in the record /// folder) and returns its path, or null when the folder can't be prepared. - 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; - } - } /// 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 /// On stop the auto-named ty-…-0000.mp4 file is renamed to carry /// the real recording length (ty-…-hh2mm2.mp4), with a numeric suffix on /// name collisions. Runs only after the pump has fully closed the file. - 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); - } /// Falls back to the user's Downloads folder when no preference is set; /// returns MyVideos if Downloads doesn't exist. - 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). diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs index a024f07..1f19e8d 100644 --- a/ViewModels/MainViewModel.Streaming.cs +++ b/ViewModels/MainViewModel.Streaming.cs @@ -38,14 +38,9 @@ public partial class MainViewModel : ViewModelBase private bool _recordPillOn; private bool _onAirPillOn; private bool _isRecording; - private string? _recordFolder; /// Resolved record folder — the configured choice, or Downloads (0th case). - public string RecordFolderDisplay => _recordFolder ?? DefaultRecordFolder(); - private string? _activeRecordPath; - private DateTime _recordStartTime; - private TimeSpan _recordLength; private readonly FramePump _framePump; diff --git a/ViewModels/index.md b/ViewModels/index.md index 0ca2f2c..3ded473 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -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 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.** 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 proceeds; each partial carries its own `using`s and re-declares nothing from core. \ No newline at end of file