Add overall progress, notifications, and UI polish

- Unify audio/subtitle selection: single-select, auto-default,
  cross-file propagation by exact name
- Show file size in queue, group divider and reordered columns in
  track table, drop accent highlight on row selection
- Add cumulative progress bar (files finished, MB copied) to the
  stripping window
- Add Windows toast notifications on stripping completion/cancel/error
- Restart the app after settings are changed; Cancel no longer
  clears the queue, only closes the window
- Add ConfigureAwait(false) throughout MkvService so concurrent
  strips don't starve the UI thread
- Hardcode scan/strip concurrency (10/5) instead of exposing it in
  Settings
This commit is contained in:
l4kr
2026-07-04 02:35:26 +02:00
parent 9b1bd51bd3
commit 05c496c10b
12 changed files with 272 additions and 68 deletions
+52 -5
View File
@@ -10,7 +10,7 @@ namespace Futonizer.Views;
public partial class ProgressWindow : FluentWindow
{
private const int MaxConcurrentStrips = 5;
private readonly int _maxConcurrentStrips;
private readonly IReadOnlyList<QueuedFileItem> _files;
private readonly string _mkvMergePath;
@@ -19,6 +19,9 @@ public partial class ProgressWindow : FluentWindow
private CancellationTokenSource? _cts;
private bool _isDone;
private long _totalInputBytes;
private long _completedBytes;
private int _finishedCount;
/// <summary>
/// True once processing has finished running (successfully, with failures,
@@ -28,7 +31,15 @@ public partial class ProgressWindow : FluentWindow
/// </summary>
public bool ProcessingCompleted { get; private set; }
public ProgressWindow(IReadOnlyList<QueuedFileItem> files, string mkvMergePath, string outputFolder)
/// <summary>
/// True only if processing ran through to a normal finish (whether
/// individual files succeeded or failed). False if the run was cancelled
/// or crashed with an unexpected error — in either of those cases the
/// caller should leave its queue untouched rather than clearing it.
/// </summary>
public bool ShouldClearQueue { get; private set; }
public ProgressWindow(IReadOnlyList<QueuedFileItem> files, string mkvMergePath, string outputFolder, int maxConcurrentStrips = 5)
{
Wpf.Ui.Appearance.SystemThemeWatcher.Watch(this);
InitializeComponent();
@@ -36,6 +47,7 @@ public partial class ProgressWindow : FluentWindow
_files = files;
_mkvMergePath = mkvMergePath;
_outputFolder = outputFolder;
_maxConcurrentStrips = maxConcurrentStrips > 0 ? maxConcurrentStrips : 5;
Title = $"Stripping {files.Count} file{(files.Count == 1 ? "" : "s")}";
ActiveJobsList.ItemsSource = _activeJobs;
@@ -82,6 +94,28 @@ public partial class ProgressWindow : FluentWindow
: $"{total:F1} MB/s across {_activeJobs.Count} file(s)";
}
private void UpdateTotalProgress()
{
long activeBytes = _activeJobs.Sum(j => (long)(j.FileSizeBytes * (j.Percent / 100.0)));
long doneBytes = Math.Min(_totalInputBytes, _completedBytes + activeBytes);
TotalProgressBar.Value = _totalInputBytes > 0
? doneBytes * 100.0 / _totalInputBytes
: 0;
TotalFilesText.Text = $"{_finishedCount}/{_files.Count} file{(_files.Count == 1 ? "" : "s")} finished";
double doneMb = doneBytes / 1024.0 / 1024.0;
double totalMb = _totalInputBytes / 1024.0 / 1024.0;
TotalBytesText.Text = $"{doneMb:F0} / {totalMb:F0} MB copied";
}
private static long SafeFileLength(string path)
{
try { return new FileInfo(path).Length; }
catch { return 0; }
}
private async Task StartProcessingAsync()
{
StatusText.Text = "Stripping tracks...";
@@ -91,26 +125,32 @@ public partial class ProgressWindow : FluentWindow
bool overwritingInPlace = string.IsNullOrWhiteSpace(_outputFolder);
var service = new MkvService(_mkvMergePath);
AppendLog($"Starting track removal for {_files.Count} file(s) (up to {MaxConcurrentStrips} at a time)...");
AppendLog($"Starting track removal for {_files.Count} file(s) (up to {_maxConcurrentStrips} at a time)...");
AppendLog(new string('-', 80));
int successCount = 0;
long totalBytesWritten = 0;
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
_totalInputBytes = _files.Sum(f => SafeFileLength(f.FilePath));
_completedBytes = 0;
_finishedCount = 0;
UpdateTotalProgress();
try
{
using var semaphore = new SemaphoreSlim(MaxConcurrentStrips);
using var semaphore = new SemaphoreSlim(_maxConcurrentStrips);
var stripTasks = _files.Select(async file =>
{
await semaphore.WaitAsync(ct);
var job = new JobProgressItem { FileName = file.FileName };
var job = new JobProgressItem { FileName = file.FileName, FileSizeBytes = SafeFileLength(file.FilePath) };
job.PropertyChanged += (_, args) =>
{
if (args.PropertyName == nameof(JobProgressItem.MegabytesPerSecond))
UpdateTotalSpeed();
if (args.PropertyName == nameof(JobProgressItem.Percent))
UpdateTotalProgress();
};
_activeJobs.Add(job);
UpdateTotalSpeed();
@@ -172,7 +212,10 @@ public partial class ProgressWindow : FluentWindow
finally
{
_activeJobs.Remove(job);
Interlocked.Add(ref _completedBytes, job.FileSizeBytes);
Interlocked.Increment(ref _finishedCount);
UpdateTotalSpeed();
UpdateTotalProgress();
semaphore.Release();
}
}).ToList();
@@ -189,17 +232,21 @@ public partial class ProgressWindow : FluentWindow
AppendLog($"Total data written: {totalMb / 1024.0:F2} GB in {totalSec:F1}s (avg {avgMbps:F1} MB/s combined).");
StatusText.Text = $"Done: {successCount}/{_files.Count} succeeded.";
NotificationService.ShowToast("Futonizer", $"Done: {successCount}/{_files.Count} file{(_files.Count == 1 ? "" : "s")} succeeded.");
ShouldClearQueue = true;
}
catch (OperationCanceledException)
{
AppendLog(new string('-', 80));
AppendLog("Cancelled by user. Nothing further was modified.");
StatusText.Text = "Cancelled.";
NotificationService.ShowToast("Futonizer", "Cancelled — nothing further was modified.");
}
catch (Exception ex)
{
AppendLog($"Unexpected error: {ex.Message}");
StatusText.Text = "Error.";
NotificationService.ShowToast("Futonizer", $"Error: {ex.Message}");
}
finally
{