Files
LlamaCasty/ViewModels/MainViewModel.Recording.cs
T
gramps bd61e8df2a fix(lifecycle): no dialogs while the app is closing
1ce6b9c made closing stop the session first, which was right and incomplete: the stop path opens TWO
modals, and WPF forbids that during a close. The creator's #2 test produced it immediately —

  System.InvalidOperationException: Cannot set Visibility to Visible or call Show, ShowDialog, Close,
  or WindowInteropHelper.EnsureHandle while a Window is closing.

Two prompts are suppressed by a `_shuttingDown` flag, each taking its safe default rather than a prompt
nobody can answer:
- The rename dialog: the recording is saved under its automatic name. Never discard footage the creator
  did not choose to discard.
- The private-broadcast keep/delete prompt: the broadcast is KEPT (stays private on YouTube) instead of
  being deleted by an answer that was never given.

The general rule this establishes: a teardown path may not ask questions. Any future prompt reached from
stop must check IsShuttingDown.

Not covered by a unit test — the trigger is a real WPF close, not reachable from the view model. Verified
against the exception in %APPDATA%\ytLlive\startup.log (09:40:27) and it needs the creator's retest.
2026-11-13 09:42:44 -08:00

214 lines
8.9 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 bool _lowDiskWarned;
private string? StartRecordFile()
{
try
{
var folder = _recordFolder ?? DefaultRecordFolder();
Directory.CreateDirectory(folder);
// TASK 55·3: never start something the drive cannot finish.
if (DiskSpaceGuard.FreeBytes(folder) is { } free)
{
var decision = DiskSpaceGuard.ForStart(free);
if (decision == DiskSpaceGuard.StartDecision.Block)
{
AppLog.Write($"Recording refused: {free / (1024 * 1024)} MB free",
AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Not enough disk space",
"There isn't room to record safely. Free up some space or pick another record folder.");
return null;
}
if (decision == DiskSpaceGuard.StartDecision.Warn)
_notifications.Warning("Disk space is getting low",
"Your recording starts, but there isn't much room left. A long show may not fit.");
}
_lowDiskWarned = false;
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", AppLogErrorClass.Class2);
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}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
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.
//
// APP CLOSE (2026-11-13): a modal cannot open while the window is closing — WPF throws
// "Cannot call ShowDialog while a Window is closing", which killed the app mid-shutdown.
// So closing saves under the automatic name instead: never discard footage the creator
// did not choose to discard, and never hold the window hostage to a dialog.
var creatorSaved = true;
string? chosenName = null;
if (!_shuttingDown)
{
var dialog = new RenameRecordingDialog(autoStem);
creatorSaved = dialog.ShowDialog() == true;
chosenName = dialog.ResultFileName;
}
var result = CompleteRecordingSave(startPath, dir, autoStem, chosenName, creatorSaved);
switch (result.Outcome)
{
case RecordingSaveOutcome.Saved:
AppLog.WriteSuccess($"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}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
_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", AppLogErrorClass.Class2);
_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);
}
}