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:
@@ -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
@@ -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="×"
|
||||
<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="×"
|
||||
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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 { }
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
@@ -47,6 +47,7 @@ public partial class SettingsWindow : FluentWindow
|
||||
DialogResult = true;
|
||||
}
|
||||
|
||||
|
||||
private void CancelButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DialogResult = false;
|
||||
|
||||
Reference in New Issue
Block a user