Add independent AppLog message types and explicit Class 1/2 severity. Wrap and color log rows, pin Class 1 above Class 2, and let acknowledgement return entries to the current-launch timeline without changing file format or rotation. Ref: https://logtape.org/manual/levels
184 lines
7.4 KiB
C#
184 lines
7.4 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", 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.
|
|
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.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);
|
|
}
|
|
}
|