Files
LlamaCasty/ViewModels/MainViewModel.Recording.cs
gramps e3e69d941d recording save dialog: Cancel discards the footage instead of saving the default
The creator reported that clicking Cancel in the post-recording save prompt still
saved the file under the default name: "cancel should cancel the save option,
discarding the recording" — Enter is what accepts the default.

The modal was always correct (Enter -> DialogResult=true, Cancel/Escape/X -> false).
The bug was in the caller: FinalizeRecordingAsync only overwrote `stem` when the
dialog returned true and then ran File.Move UNCONDITIONALLY, so a falsy result fell
straight through into the save. The nullable stem made "I don't want this" look
like "I accept the default".

Extracted the decision into internal MainViewModel.CompleteRecordingSave so it is
testable against real files without a frame pump:
  creatorSaved == false (Cancel/Escape/X) -> delete the temp file, leave the
      videos folder empty; a failed delete returns DiscardFailed and the toast
      names the file + folder (a "discarded" recording still on disk is worse
      than no report).
  creatorSaved == true (Save, or Enter on the pre-filled default) -> File.Move.
      A blank box still means "keep the auto name", but only on an explicit Save.

Test: ytLive.Tests/RecordingSaveDialogTests.cs — 5 facts over real temp files. The
headline asserts Cancel leaves the directory EMPTY, not merely "the stem is
unchanged"; a modal's own test cannot cover the decision it feeds.

Full suite 362/362 (the RealMouseDrag flake passed this run).
2026-09-27 09:25:21 -07:00

182 lines
7.2 KiB
C#

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)}");
}
}
/// <summary>What happened to the footage once the save dialog was answered, plus
/// where it landed (<c>FinalPath</c> is set only for <see cref="RecordingSaveOutcome.Saved"/>).</summary>
internal readonly record struct RecordingSaveResult(RecordingSaveOutcome Outcome, string? FinalPath);
/// <summary>What happened to the footage once the save dialog was answered.</summary>
internal enum RecordingSaveOutcome
{
Saved,
Discarded,
DiscardFailed,
}
/// <summary>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). <paramref name="creatorSaved"/> is the dialog's DialogResult:
/// <c>false</c> — 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.</summary>
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);
}
}