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. // CREATOR RULING 2026-09-26: Cancel means DISCARD, not "save under the // default name". This used to fall through and File.Move anyway, so a // cancelled dialog silently kept the recording — Cancel was a lie. var dialog = new RenameRecordingDialog(autoStem); var creatorSaved = dialog.ShowDialog() == true; var result = CompleteRecordingSave( startPath, dir, autoStem, dialog.ResultFileName, creatorSaved); switch (result.Outcome) { case RecordingSaveOutcome.Saved: AppLog.Write($"Recording saved: {result.FinalPath}"); _notifications.Info("Recording saved", Path.GetFileName(result.FinalPath!) + " — you can rename it in your videos folder at any time."); break; case RecordingSaveOutcome.Discarded: AppLog.Write($"Recording discarded by creator: {startPath}"); _notifications.Info("Recording discarded", "The recording was deleted and nothing was saved."); break; case RecordingSaveOutcome.DiscardFailed: // A "discarded" recording still sitting on disk is worse than no // report at all — name the file and the folder so it can be found. AppLog.Write($"Recording discard failed: {startPath}"); _notifications.Error("Recording discarded?", $"Could not delete {Path.GetFileName(startPath)} — it is still in {dir}."); break; } } 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)}"); } } /// What happened to the footage once the save dialog was answered, plus /// where it landed (FinalPath is set only for ). internal readonly record struct RecordingSaveResult(RecordingSaveOutcome Outcome, string? FinalPath); /// What happened to the footage once the save dialog was answered. internal enum RecordingSaveOutcome { Saved, Discarded, DiscardFailed, } /// The commit-or-discard decision behind the recording save dialog, split out /// so it can be tested against real files without a frame pump (CREATOR RULING /// 2026-09-26). is the dialog's DialogResult: /// false — Cancel, Escape, or the X — DELETES the footage; only an explicit /// Save (including Enter on the pre-filled default) moves it to its final name. internal static RecordingSaveResult CompleteRecordingSave( string startPath, string dir, string autoStem, string? chosenStem, bool creatorSaved) { if (!creatorSaved) { if (!File.Exists(startPath)) return new RecordingSaveResult(RecordingSaveOutcome.Discarded, null); try { File.Delete(startPath); return new RecordingSaveResult(RecordingSaveOutcome.Discarded, null); } catch (Exception) { return new RecordingSaveResult(RecordingSaveOutcome.DiscardFailed, startPath); } } // A blank name still means "keep the auto name" (the dialog says so), but // only on an explicit Save. Enter with the pre-filled default lands here too. var stem = string.IsNullOrWhiteSpace(chosenStem) ? autoStem : chosenStem!; var finalPath = UniquePath(Path.Combine(dir, stem + ".mp4")); File.Move(startPath, finalPath); return new RecordingSaveResult(RecordingSaveOutcome.Saved, finalPath); } 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); } }