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 5ade663048
12 changed files with 272 additions and 68 deletions
+6
View File
@@ -5,6 +5,7 @@
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>Futonizer</AssemblyName>
<RootNamespace>Futonizer</RootNamespace>
@@ -15,6 +16,11 @@
<PackageReference Include="WPF-UI" Version="4.3.0" />
</ItemGroup>
<ItemGroup>
<Using Remove="System.Windows.Forms" />
</ItemGroup>
<ItemGroup>
<Resource Include="Assets\app.png" />
</ItemGroup>
+28 -6
View File
@@ -157,6 +157,7 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="18"/>
</Grid.ColumnDefinitions>
@@ -178,7 +179,12 @@
FontSize="12" VerticalAlignment="Center" Margin="6,0"
TextTrimming="CharacterEllipsis"/>
<Button Grid.Column="2" Content="&#xD7;"
<TextBlock Grid.Column="2" Text="{Binding FileSizeDisplay}"
FontSize="11" VerticalAlignment="Center" Margin="4,0"
Opacity="0.6"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
<Button Grid.Column="3" Content="&#xD7;"
Tag="{Binding}"
Click="RemoveFile_Click"
Background="Transparent" BorderThickness="0"
@@ -229,16 +235,32 @@
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{DynamicResource ControlElevationBorderBrush}"/>
<Setter Property="BorderThickness" Value="0"/>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource AccentFillColorSecondaryBrush}"/>
</Trigger>
<DataTrigger Binding="{Binding IsGroupStart}" Value="True">
<Setter Property="BorderThickness" Value="0,1,0,0"/>
</DataTrigger>
<DataTrigger Binding="{Binding Type}" Value="video">
<Setter Property="Opacity" Value="0.5"/>
</DataTrigger>
<DataTrigger Binding="{Binding Type}" Value="chapters">
<Setter Property="Opacity" Value="0.5"/>
</DataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding Type}" Value="audio"/>
<Condition Binding="{Binding Copy}" Value="False"/>
</MultiDataTrigger.Conditions>
<Setter Property="Opacity" Value="0.5"/>
</MultiDataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding Type}" Value="subtitles"/>
<Condition Binding="{Binding Copy}" Value="False"/>
</MultiDataTrigger.Conditions>
<Setter Property="Opacity" Value="0.5"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</DataGrid.RowStyle>
@@ -273,12 +295,12 @@
<DataGridTextColumn Header="ID"
Binding="{Binding IdDisplay}" Width="45" IsReadOnly="True"/>
<DataGridTextColumn Header="Codec"
Binding="{Binding Codec}" Width="185" IsReadOnly="True"/>
<DataGridTextColumn Header="Language"
Binding="{Binding Language}" Width="100" IsReadOnly="True"/>
<DataGridTextColumn Header="Name"
Binding="{Binding DisplayName}" Width="*" IsReadOnly="True"/>
<DataGridTextColumn Header="Codec"
Binding="{Binding Codec}" Width="185" IsReadOnly="True"/>
<!-- Default (read-only indicator) -->
<DataGridTemplateColumn Header="Default" Width="70"
+52 -33
View File
@@ -15,13 +15,14 @@ namespace Futonizer;
public partial class MainWindow : FluentWindow
{
private const int MaxConcurrentScans = 4;
private const int MaxConcurrentScans = 10;
private const int MaxConcurrentStrips = 5;
private AppSettings _settings = new();
private readonly ObservableCollection<QueuedFileItem> _fileQueue = new();
private readonly SemaphoreSlim _scanSemaphore = new(MaxConcurrentScans);
private bool _isProcessing;
private bool _propagatingSubtitleSelection;
private bool _propagatingSelection;
public MainWindow()
{
@@ -76,6 +77,20 @@ public partial class MainWindow : FluentWindow
// ── Helpers ──────────────────────────────────────────────────────────────
private static void RestartApplication()
{
try
{
string? exePath = Environment.ProcessPath;
if (!string.IsNullOrEmpty(exePath))
Process.Start(exePath);
}
catch
{
}
System.Windows.Application.Current.Shutdown();
}
private void UpdateDropHint()
{
bool empty = _fileQueue.Count == 0;
@@ -111,11 +126,11 @@ public partial class MainWindow : FluentWindow
private void UpdateRunButtonEnabled()
{
bool anyLoading = _fileQueue.Any(f => f.IsLoading);
bool anySubtitleSelected = _fileQueue.Any(f => f.Tracks.Any(t => t.Type == "subtitles" && t.Copy));
RunButton.IsEnabled = !_isProcessing && !anyLoading && _fileQueue.Count > 0 && anySubtitleSelected;
RunButton.ToolTip = anySubtitleSelected
bool allCorrect = _fileQueue.Count > 0 && _fileQueue.All(f => f.IsCorrect);
RunButton.IsEnabled = !_isProcessing && !anyLoading && allCorrect;
RunButton.ToolTip = allCorrect
? null
: "Select a subtitle track in the track table to enable stripping.";
: "Every file in the queue needs exactly one audio and one subtitle track selected before stripping.";
}
private void QueuedFileItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
@@ -161,6 +176,7 @@ public partial class MainWindow : FluentWindow
_settings.MkvMergePath = win.Result.MkvMergePath;
_settings.OutputFolder = win.Result.OutputFolder;
SettingsService.Save(_settings);
RestartApplication();
}
}
@@ -240,9 +256,9 @@ public partial class MainWindow : FluentWindow
foreach (var t in tracks)
{
item.Tracks.Add(t);
if (t.Type == "subtitles")
if (t.Type is "audio" or "subtitles")
{
t.PropertyChanged += (s, e) => OnSubtitleTrackPropertyChanged(item, (TrackItem)s!, e);
t.PropertyChanged += (s, e) => OnTrackSelectionChanged(item, (TrackItem)s!, e);
}
}
@@ -261,36 +277,39 @@ public partial class MainWindow : FluentWindow
}
}
// ── Subtitle selection propagation ─────────────────────────────────────────
// ── Audio/subtitle selection propagation ────────────────────────────────────
/// <summary>
/// Propagates subtitle selection/deselection to every other file in the
/// queue:
/// - Checking a subtitle matches it to other files by its exact
/// Propagates audio/subtitle selection/deselection to every other file in
/// the queue:
/// - Checking a track matches it to other files by its exact
/// "Codec/Name" identity:
/// - If the source file had no other subtitle selected before this
/// check ("adding"), other files that don't have a matching track
/// are left untouched.
/// - If the source file had a different subtitle selected before
/// this check ("overriding"), other files that don't have a
/// matching track have their subtitle selection cleared.
/// - Unchecking a subtitle clears the subtitle selection in every other
/// file too, so a deselection always applies everywhere.
/// Within the source file, only one subtitle may ever be selected.
/// - If the source file had no other track of the same type
/// selected before this check ("adding"), other files that don't
/// have a matching track are left untouched.
/// - If the source file had a different track of the same type
/// selected before this check ("overriding"), other files that
/// don't have a matching track have their selection cleared.
/// - Unchecking a track clears the selection of that type in every
/// other file too, so a deselection always applies everywhere.
/// Within the source file, only one track of a given type may ever be
/// selected.
/// </summary>
private void OnSubtitleTrackPropertyChanged(QueuedFileItem sourceFile, TrackItem track, PropertyChangedEventArgs e)
private void OnTrackSelectionChanged(QueuedFileItem sourceFile, TrackItem track, PropertyChangedEventArgs e)
{
if (_propagatingSubtitleSelection) return;
if (_propagatingSelection) return;
if (e.PropertyName != nameof(TrackItem.Copy)) return;
_propagatingSubtitleSelection = true;
string type = track.Type;
_propagatingSelection = true;
try
{
if (track.Copy)
{
bool wasOverriding = sourceFile.Tracks.Any(t => t.Type == "subtitles" && t != track && t.Copy);
bool wasOverriding = sourceFile.Tracks.Any(t => t.Type == type && t != track && t.Copy);
foreach (var other in sourceFile.Tracks.Where(t => t.Type == "subtitles" && t != track))
foreach (var other in sourceFile.Tracks.Where(t => t.Type == type && t != track))
{
other.Copy = false;
other.DefaultTrack = false;
@@ -301,10 +320,10 @@ public partial class MainWindow : FluentWindow
foreach (var otherFile in _fileQueue.Where(f => f != sourceFile))
{
var match = otherFile.Tracks.FirstOrDefault(t => t.Type == "subtitles" && t.PropagationKey == key);
var match = otherFile.Tracks.FirstOrDefault(t => t.Type == type && t.PropagationKey == key);
if (match != null)
{
foreach (var sub in otherFile.Tracks.Where(t => t.Type == "subtitles"))
foreach (var sub in otherFile.Tracks.Where(t => t.Type == type))
{
sub.Copy = sub == match;
sub.DefaultTrack = sub == match;
@@ -312,7 +331,7 @@ public partial class MainWindow : FluentWindow
}
else if (wasOverriding)
{
foreach (var sub in otherFile.Tracks.Where(t => t.Type == "subtitles"))
foreach (var sub in otherFile.Tracks.Where(t => t.Type == type))
{
sub.Copy = false;
sub.DefaultTrack = false;
@@ -325,7 +344,7 @@ public partial class MainWindow : FluentWindow
track.DefaultTrack = false;
foreach (var otherFile in _fileQueue.Where(f => f != sourceFile))
{
foreach (var sub in otherFile.Tracks.Where(t => t.Type == "subtitles"))
foreach (var sub in otherFile.Tracks.Where(t => t.Type == type))
{
sub.Copy = false;
sub.DefaultTrack = false;
@@ -335,7 +354,7 @@ public partial class MainWindow : FluentWindow
}
finally
{
_propagatingSubtitleSelection = false;
_propagatingSelection = false;
}
}
@@ -475,14 +494,14 @@ public partial class MainWindow : FluentWindow
RunButton.IsEnabled = false;
StatusText.Text = string.Empty;
var progressWin = new ProgressWindow(readyFiles, _settings.MkvMergePath, _settings.OutputFolder)
var progressWin = new ProgressWindow(readyFiles, _settings.MkvMergePath, _settings.OutputFolder, MaxConcurrentStrips)
{
Owner = this
};
progressWin.Closed += (_, _) =>
{
_isProcessing = false;
if (progressWin.ProcessingCompleted)
if (progressWin.ShouldClearQueue)
{
foreach (var item in _fileQueue)
item.PropertyChanged -= QueuedFileItem_PropertyChanged;
+6
View File
@@ -14,6 +14,12 @@ public class JobProgressItem : INotifyPropertyChanged
public string FileName { get; set; } = string.Empty;
/// <summary>
/// Size of the source file in bytes, used as this job's weight when
/// computing overall cumulative progress across all queued files.
/// </summary>
public long FileSizeBytes { get; set; }
public int Percent
{
get => _percent;
+25
View File
@@ -32,6 +32,7 @@ public class QueuedFileItem : INotifyPropertyChanged
public string FilePath { get; }
public string FileName => Path.GetFileName(FilePath);
public string FileSizeDisplay { get; }
public ObservableCollection<TrackItem> Tracks { get; } = new();
public string Status
@@ -76,9 +77,33 @@ public class QueuedFileItem : INotifyPropertyChanged
public QueuedFileItem(string filePath)
{
FilePath = filePath;
FileSizeDisplay = FormatFileSize(filePath);
Tracks.CollectionChanged += OnTracksChanged;
}
private static string FormatFileSize(string filePath)
{
try
{
long bytes = new FileInfo(filePath).Length;
string[] units = { "B", "KB", "MB", "GB", "TB" };
double size = bytes;
int unitIndex = 0;
while (size >= 1024 && unitIndex < units.Length - 1)
{
size /= 1024;
unitIndex++;
}
return unitIndex == 0
? $"{size:0} {units[unitIndex]}"
: $"{size:0.#} {units[unitIndex]}";
}
catch
{
return string.Empty;
}
}
private void OnTracksChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
+18 -11
View File
@@ -19,12 +19,21 @@ public class TrackItem : INotifyPropertyChanged
public string Name { get; init; } = string.Empty;
public bool ForcedDisplay { get; init; }
/// <summary>
/// True for the first track of each type group (video/audio/subtitles/
/// chapters) in the track table, computed once when the list is built.
/// Used to draw a divider line above the row in the UI.
/// </summary>
public bool IsGroupStart { get; set; }
private bool _defaultTrack;
/// <summary>
/// Whether this track should be flagged as the default track of its type
/// when stripped. For subtitles this is kept in sync with <see cref="Copy"/>
/// (selecting a subtitle also makes it the default one).
/// when stripped. For audio and subtitles this is kept in sync with
/// <see cref="Copy"/> (selecting a track also makes it the default one
/// of its type).
/// </summary>
public bool DefaultTrack
{
@@ -38,11 +47,9 @@ public class TrackItem : INotifyPropertyChanged
}
/// <summary>
/// Text shown in the track table's Name column. Subtitle tracks are shown
/// as "Codec/Name" so tracks that share a display name but use a different
/// codec are easy to tell apart.
/// Text shown in the track table's Name column.
/// </summary>
public string DisplayName => Type == "subtitles" ? $"{Codec}/{Name}" : Name;
public string DisplayName => Name;
/// <summary>
/// Video is always kept, and chapters (a synthetic pseudo-track, not a
@@ -70,12 +77,12 @@ public class TrackItem : INotifyPropertyChanged
}
/// <summary>
/// Key used to match subtitle tracks with the same identity across different
/// queued files when propagating a subtitle selection. Subtitles are matched
/// on "Codec/Name" so tracks with the same name but a different codec are
/// treated as distinct.
/// Key used to match a track's identity across different queued files
/// when propagating an audio/subtitle selection. Matched on "Codec/Name"
/// so tracks with the same name but a different codec are treated as
/// distinct, even though only the name is shown in the table.
/// </summary>
public string PropagationKey => DisplayName;
public string PropagationKey => $"{Codec}/{Name}";
public event PropertyChangedEventHandler? PropertyChanged;
+17 -11
View File
@@ -74,8 +74,8 @@ public class MkvService
// fill up and deadlock the child process indefinitely.
var stdoutTask = process.StandardOutput.ReadToEndAsync(ct);
var stderrTask = process.StandardError.ReadToEndAsync(ct);
await Task.WhenAll(stdoutTask, stderrTask);
await process.WaitForExitAsync(ct);
await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
await process.WaitForExitAsync(ct).ConfigureAwait(false);
string stdout = stdoutTask.Result;
string stderr = stderrTask.Result;
@@ -151,9 +151,15 @@ public class MkvService
});
}
for (int i = 0; i < items.Count; i++)
{
items[i].IsGroupStart = i > 0 && items[i].Type != items[i - 1].Type;
}
return items;
}
/// <summary>
/// Scans a single file and determines which audio/subtitle track IDs
/// should be kept. Fails if only English audio is present, or if the
@@ -164,7 +170,7 @@ public class MkvService
var scan = new ScanResult { FilePath = filePath };
try
{
var info = await IdentifyAsync(filePath, ct);
var info = await IdentifyAsync(filePath, ct).ConfigureAwait(false);
var audioTracks = info.Tracks.Where(t => t.Type == "audio").ToList();
var subtitleTracks = info.Tracks.Where(t => t.Type == "subtitles").ToList();
@@ -325,9 +331,9 @@ public class MkvService
{
byte[] buffer = new byte[1024 * 1024];
int read;
while ((read = await source.ReadAsync(buffer, ct)) > 0)
while ((read = await source.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0)
{
await dest.WriteAsync(buffer.AsMemory(0, read), ct);
await dest.WriteAsync(buffer.AsMemory(0, read), ct).ConfigureAwait(false);
copied += read;
var elapsed = stopwatch.Elapsed;
@@ -450,11 +456,11 @@ public class MkvService
using var throughputCts = new CancellationTokenSource();
var throughputTask = MonitorThroughputAsync(tempFile, fileName, percentState, progress, throughputCts.Token);
await process.WaitForExitAsync(CancellationToken.None);
await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
throughputCts.Cancel();
string stdout = await stdoutTask;
string stderr = await stderrTask;
try { await throughputTask; } catch (OperationCanceledException) { }
string stdout = await stdoutTask.ConfigureAwait(false);
string stderr = await stderrTask.ConfigureAwait(false);
try { await throughputTask.ConfigureAwait(false); } catch (OperationCanceledException) { }
if (wasKilled)
{
@@ -543,7 +549,7 @@ public class MkvService
{
var fullOutput = new System.Text.StringBuilder();
string? line;
while ((line = await process.StandardOutput.ReadLineAsync()) != null)
while ((line = await process.StandardOutput.ReadLineAsync().ConfigureAwait(false)) != null)
{
fullOutput.AppendLine(line);
var match = ProgressRegex.Match(line);
@@ -565,7 +571,7 @@ public class MkvService
{
while (true)
{
await Task.Delay(sampleInterval, stopToken);
await Task.Delay(sampleInterval, stopToken).ConfigureAwait(false);
long currentBytes = 0;
try { currentBytes = new FileInfo(tempFile).Length; } catch { }
+48
View File
@@ -0,0 +1,48 @@
using System.Drawing;
using System.Windows.Forms;
namespace Futonizer.Services;
/// <summary>
/// Fires native Windows notifications (via a transient tray icon's balloon
/// tip) to let the user know processing has finished, even if the app
/// window isn't focused.
/// </summary>
public static class NotificationService
{
public static void ShowToast(string title, string message)
{
try
{
Icon icon;
try
{
icon = System.Drawing.Icon.ExtractAssociatedIcon(Environment.ProcessPath!) ?? SystemIcons.Application;
}
catch
{
icon = SystemIcons.Application;
}
var notifyIcon = new NotifyIcon
{
Icon = icon,
Visible = true,
BalloonTipTitle = title,
BalloonTipText = message,
BalloonTipIcon = ToolTipIcon.Info,
};
notifyIcon.BalloonTipClosed += (_, _) => notifyIcon.Dispose();
notifyIcon.BalloonTipClicked += (_, _) => notifyIcon.Dispose();
notifyIcon.ShowBalloonTip(5000);
// Fallback in case neither event fires (e.g. notifications disabled).
_ = Task.Delay(10000).ContinueWith(_ => notifyIcon.Dispose());
}
catch
{
// Notifications are best-effort; never let a failure here affect the app.
}
}
}
+19 -1
View File
@@ -24,6 +24,7 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Active jobs -->
@@ -53,8 +54,25 @@
BorderThickness="0" Background="Transparent"/>
</Border>
<!-- Status + speed + button -->
<!-- Total progress -->
<Grid Grid.Row="2" Margin="0,10,0,0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ProgressBar x:Name="TotalProgressBar" Grid.Row="0" Height="8" Minimum="0" Maximum="100" Value="0"/>
<Grid Grid.Row="1" Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TotalFilesText" Grid.Column="0" FontSize="11" Opacity="0.75"/>
<TextBlock x:Name="TotalBytesText" Grid.Column="1" FontSize="11" Opacity="0.75"/>
</Grid>
</Grid>
<!-- Status + speed + button -->
<Grid Grid.Row="3" Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
+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
{
-1
View File
@@ -24,7 +24,6 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
+1
View File
@@ -47,6 +47,7 @@ public partial class SettingsWindow : FluentWindow
DialogResult = true;
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;