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>
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<UseWPF>true</UseWPF>
|
<UseWPF>true</UseWPF>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<AssemblyName>Futonizer</AssemblyName>
|
<AssemblyName>Futonizer</AssemblyName>
|
||||||
<RootNamespace>Futonizer</RootNamespace>
|
<RootNamespace>Futonizer</RootNamespace>
|
||||||
@@ -15,6 +16,11 @@
|
|||||||
<PackageReference Include="WPF-UI" Version="4.3.0" />
|
<PackageReference Include="WPF-UI" Version="4.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Remove="System.Windows.Forms" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Resource Include="Assets\app.png" />
|
<Resource Include="Assets\app.png" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
+28
-6
@@ -157,6 +157,7 @@
|
|||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="16"/>
|
<ColumnDefinition Width="16"/>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
<ColumnDefinition Width="18"/>
|
<ColumnDefinition Width="18"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
@@ -178,7 +179,12 @@
|
|||||||
FontSize="12" VerticalAlignment="Center" Margin="6,0"
|
FontSize="12" VerticalAlignment="Center" Margin="6,0"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
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}"
|
Tag="{Binding}"
|
||||||
Click="RemoveFile_Click"
|
Click="RemoveFile_Click"
|
||||||
Background="Transparent" BorderThickness="0"
|
Background="Transparent" BorderThickness="0"
|
||||||
@@ -229,16 +235,32 @@
|
|||||||
<DataGrid.RowStyle>
|
<DataGrid.RowStyle>
|
||||||
<Style TargetType="DataGridRow">
|
<Style TargetType="DataGridRow">
|
||||||
<Setter Property="Background" Value="Transparent"/>
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ControlElevationBorderBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
<Style.Triggers>
|
<Style.Triggers>
|
||||||
<Trigger Property="IsSelected" Value="True">
|
<DataTrigger Binding="{Binding IsGroupStart}" Value="True">
|
||||||
<Setter Property="Background" Value="{DynamicResource AccentFillColorSecondaryBrush}"/>
|
<Setter Property="BorderThickness" Value="0,1,0,0"/>
|
||||||
</Trigger>
|
</DataTrigger>
|
||||||
<DataTrigger Binding="{Binding Type}" Value="video">
|
<DataTrigger Binding="{Binding Type}" Value="video">
|
||||||
<Setter Property="Opacity" Value="0.5"/>
|
<Setter Property="Opacity" Value="0.5"/>
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
<DataTrigger Binding="{Binding Type}" Value="chapters">
|
<DataTrigger Binding="{Binding Type}" Value="chapters">
|
||||||
<Setter Property="Opacity" Value="0.5"/>
|
<Setter Property="Opacity" Value="0.5"/>
|
||||||
</DataTrigger>
|
</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.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
</DataGrid.RowStyle>
|
</DataGrid.RowStyle>
|
||||||
@@ -273,12 +295,12 @@
|
|||||||
|
|
||||||
<DataGridTextColumn Header="ID"
|
<DataGridTextColumn Header="ID"
|
||||||
Binding="{Binding IdDisplay}" Width="45" IsReadOnly="True"/>
|
Binding="{Binding IdDisplay}" Width="45" IsReadOnly="True"/>
|
||||||
<DataGridTextColumn Header="Codec"
|
|
||||||
Binding="{Binding Codec}" Width="185" IsReadOnly="True"/>
|
|
||||||
<DataGridTextColumn Header="Language"
|
<DataGridTextColumn Header="Language"
|
||||||
Binding="{Binding Language}" Width="100" IsReadOnly="True"/>
|
Binding="{Binding Language}" Width="100" IsReadOnly="True"/>
|
||||||
<DataGridTextColumn Header="Name"
|
<DataGridTextColumn Header="Name"
|
||||||
Binding="{Binding DisplayName}" Width="*" IsReadOnly="True"/>
|
Binding="{Binding DisplayName}" Width="*" IsReadOnly="True"/>
|
||||||
|
<DataGridTextColumn Header="Codec"
|
||||||
|
Binding="{Binding Codec}" Width="185" IsReadOnly="True"/>
|
||||||
|
|
||||||
<!-- Default (read-only indicator) -->
|
<!-- Default (read-only indicator) -->
|
||||||
<DataGridTemplateColumn Header="Default" Width="70"
|
<DataGridTemplateColumn Header="Default" Width="70"
|
||||||
|
|||||||
+52
-33
@@ -15,13 +15,14 @@ namespace Futonizer;
|
|||||||
|
|
||||||
public partial class MainWindow : FluentWindow
|
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 AppSettings _settings = new();
|
||||||
private readonly ObservableCollection<QueuedFileItem> _fileQueue = new();
|
private readonly ObservableCollection<QueuedFileItem> _fileQueue = new();
|
||||||
private readonly SemaphoreSlim _scanSemaphore = new(MaxConcurrentScans);
|
private readonly SemaphoreSlim _scanSemaphore = new(MaxConcurrentScans);
|
||||||
private bool _isProcessing;
|
private bool _isProcessing;
|
||||||
private bool _propagatingSubtitleSelection;
|
private bool _propagatingSelection;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
@@ -76,6 +77,20 @@ public partial class MainWindow : FluentWindow
|
|||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── 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()
|
private void UpdateDropHint()
|
||||||
{
|
{
|
||||||
bool empty = _fileQueue.Count == 0;
|
bool empty = _fileQueue.Count == 0;
|
||||||
@@ -111,11 +126,11 @@ public partial class MainWindow : FluentWindow
|
|||||||
private void UpdateRunButtonEnabled()
|
private void UpdateRunButtonEnabled()
|
||||||
{
|
{
|
||||||
bool anyLoading = _fileQueue.Any(f => f.IsLoading);
|
bool anyLoading = _fileQueue.Any(f => f.IsLoading);
|
||||||
bool anySubtitleSelected = _fileQueue.Any(f => f.Tracks.Any(t => t.Type == "subtitles" && t.Copy));
|
bool allCorrect = _fileQueue.Count > 0 && _fileQueue.All(f => f.IsCorrect);
|
||||||
RunButton.IsEnabled = !_isProcessing && !anyLoading && _fileQueue.Count > 0 && anySubtitleSelected;
|
RunButton.IsEnabled = !_isProcessing && !anyLoading && allCorrect;
|
||||||
RunButton.ToolTip = anySubtitleSelected
|
RunButton.ToolTip = allCorrect
|
||||||
? null
|
? 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)
|
private void QueuedFileItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
@@ -161,6 +176,7 @@ public partial class MainWindow : FluentWindow
|
|||||||
_settings.MkvMergePath = win.Result.MkvMergePath;
|
_settings.MkvMergePath = win.Result.MkvMergePath;
|
||||||
_settings.OutputFolder = win.Result.OutputFolder;
|
_settings.OutputFolder = win.Result.OutputFolder;
|
||||||
SettingsService.Save(_settings);
|
SettingsService.Save(_settings);
|
||||||
|
RestartApplication();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,9 +256,9 @@ public partial class MainWindow : FluentWindow
|
|||||||
foreach (var t in tracks)
|
foreach (var t in tracks)
|
||||||
{
|
{
|
||||||
item.Tracks.Add(t);
|
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>
|
/// <summary>
|
||||||
/// Propagates subtitle selection/deselection to every other file in the
|
/// Propagates audio/subtitle selection/deselection to every other file in
|
||||||
/// queue:
|
/// the queue:
|
||||||
/// - Checking a subtitle matches it to other files by its exact
|
/// - Checking a track matches it to other files by its exact
|
||||||
/// "Codec/Name" identity:
|
/// "Codec/Name" identity:
|
||||||
/// - If the source file had no other subtitle selected before this
|
/// - If the source file had no other track of the same type
|
||||||
/// check ("adding"), other files that don't have a matching track
|
/// selected before this check ("adding"), other files that don't
|
||||||
/// are left untouched.
|
/// have a matching track are left untouched.
|
||||||
/// - If the source file had a different subtitle selected before
|
/// - If the source file had a different track of the same type
|
||||||
/// this check ("overriding"), other files that don't have a
|
/// selected before this check ("overriding"), other files that
|
||||||
/// matching track have their subtitle selection cleared.
|
/// don't have a matching track have their selection cleared.
|
||||||
/// - Unchecking a subtitle clears the subtitle selection in every other
|
/// - Unchecking a track clears the selection of that type in every
|
||||||
/// file too, so a deselection always applies everywhere.
|
/// other file too, so a deselection always applies everywhere.
|
||||||
/// Within the source file, only one subtitle may ever be selected.
|
/// Within the source file, only one track of a given type may ever be
|
||||||
|
/// selected.
|
||||||
/// </summary>
|
/// </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;
|
if (e.PropertyName != nameof(TrackItem.Copy)) return;
|
||||||
|
|
||||||
_propagatingSubtitleSelection = true;
|
string type = track.Type;
|
||||||
|
|
||||||
|
_propagatingSelection = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (track.Copy)
|
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.Copy = false;
|
||||||
other.DefaultTrack = false;
|
other.DefaultTrack = false;
|
||||||
@@ -301,10 +320,10 @@ public partial class MainWindow : FluentWindow
|
|||||||
|
|
||||||
foreach (var otherFile in _fileQueue.Where(f => f != sourceFile))
|
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)
|
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.Copy = sub == match;
|
||||||
sub.DefaultTrack = sub == match;
|
sub.DefaultTrack = sub == match;
|
||||||
@@ -312,7 +331,7 @@ public partial class MainWindow : FluentWindow
|
|||||||
}
|
}
|
||||||
else if (wasOverriding)
|
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.Copy = false;
|
||||||
sub.DefaultTrack = false;
|
sub.DefaultTrack = false;
|
||||||
@@ -325,7 +344,7 @@ public partial class MainWindow : FluentWindow
|
|||||||
track.DefaultTrack = false;
|
track.DefaultTrack = false;
|
||||||
foreach (var otherFile in _fileQueue.Where(f => f != sourceFile))
|
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.Copy = false;
|
||||||
sub.DefaultTrack = false;
|
sub.DefaultTrack = false;
|
||||||
@@ -335,7 +354,7 @@ public partial class MainWindow : FluentWindow
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_propagatingSubtitleSelection = false;
|
_propagatingSelection = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,14 +494,14 @@ public partial class MainWindow : FluentWindow
|
|||||||
RunButton.IsEnabled = false;
|
RunButton.IsEnabled = false;
|
||||||
StatusText.Text = string.Empty;
|
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
|
Owner = this
|
||||||
};
|
};
|
||||||
progressWin.Closed += (_, _) =>
|
progressWin.Closed += (_, _) =>
|
||||||
{
|
{
|
||||||
_isProcessing = false;
|
_isProcessing = false;
|
||||||
if (progressWin.ProcessingCompleted)
|
if (progressWin.ShouldClearQueue)
|
||||||
{
|
{
|
||||||
foreach (var item in _fileQueue)
|
foreach (var item in _fileQueue)
|
||||||
item.PropertyChanged -= QueuedFileItem_PropertyChanged;
|
item.PropertyChanged -= QueuedFileItem_PropertyChanged;
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ public class JobProgressItem : INotifyPropertyChanged
|
|||||||
|
|
||||||
public string FileName { get; set; } = string.Empty;
|
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
|
public int Percent
|
||||||
{
|
{
|
||||||
get => _percent;
|
get => _percent;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public class QueuedFileItem : INotifyPropertyChanged
|
|||||||
|
|
||||||
public string FilePath { get; }
|
public string FilePath { get; }
|
||||||
public string FileName => Path.GetFileName(FilePath);
|
public string FileName => Path.GetFileName(FilePath);
|
||||||
|
public string FileSizeDisplay { get; }
|
||||||
public ObservableCollection<TrackItem> Tracks { get; } = new();
|
public ObservableCollection<TrackItem> Tracks { get; } = new();
|
||||||
|
|
||||||
public string Status
|
public string Status
|
||||||
@@ -76,9 +77,33 @@ public class QueuedFileItem : INotifyPropertyChanged
|
|||||||
public QueuedFileItem(string filePath)
|
public QueuedFileItem(string filePath)
|
||||||
{
|
{
|
||||||
FilePath = filePath;
|
FilePath = filePath;
|
||||||
|
FileSizeDisplay = FormatFileSize(filePath);
|
||||||
Tracks.CollectionChanged += OnTracksChanged;
|
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)
|
private void OnTracksChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.NewItems != null)
|
if (e.NewItems != null)
|
||||||
|
|||||||
+18
-11
@@ -19,12 +19,21 @@ public class TrackItem : INotifyPropertyChanged
|
|||||||
public string Name { get; init; } = string.Empty;
|
public string Name { get; init; } = string.Empty;
|
||||||
public bool ForcedDisplay { get; init; }
|
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;
|
private bool _defaultTrack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether this track should be flagged as the default track of its type
|
/// 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"/>
|
/// when stripped. For audio and subtitles this is kept in sync with
|
||||||
/// (selecting a subtitle also makes it the default one).
|
/// <see cref="Copy"/> (selecting a track also makes it the default one
|
||||||
|
/// of its type).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool DefaultTrack
|
public bool DefaultTrack
|
||||||
{
|
{
|
||||||
@@ -38,11 +47,9 @@ public class TrackItem : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Text shown in the track table's Name column. Subtitle tracks are shown
|
/// Text shown in the track table's Name column.
|
||||||
/// as "Codec/Name" so tracks that share a display name but use a different
|
|
||||||
/// codec are easy to tell apart.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string DisplayName => Type == "subtitles" ? $"{Codec}/{Name}" : Name;
|
public string DisplayName => Name;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Video is always kept, and chapters (a synthetic pseudo-track, not a
|
/// Video is always kept, and chapters (a synthetic pseudo-track, not a
|
||||||
@@ -70,12 +77,12 @@ public class TrackItem : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Key used to match subtitle tracks with the same identity across different
|
/// Key used to match a track's identity across different queued files
|
||||||
/// queued files when propagating a subtitle selection. Subtitles are matched
|
/// when propagating an audio/subtitle selection. Matched on "Codec/Name"
|
||||||
/// on "Codec/Name" so tracks with the same name but a different codec are
|
/// so tracks with the same name but a different codec are treated as
|
||||||
/// treated as distinct.
|
/// distinct, even though only the name is shown in the table.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string PropagationKey => DisplayName;
|
public string PropagationKey => $"{Codec}/{Name}";
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
|||||||
+17
-11
@@ -74,8 +74,8 @@ public class MkvService
|
|||||||
// fill up and deadlock the child process indefinitely.
|
// fill up and deadlock the child process indefinitely.
|
||||||
var stdoutTask = process.StandardOutput.ReadToEndAsync(ct);
|
var stdoutTask = process.StandardOutput.ReadToEndAsync(ct);
|
||||||
var stderrTask = process.StandardError.ReadToEndAsync(ct);
|
var stderrTask = process.StandardError.ReadToEndAsync(ct);
|
||||||
await Task.WhenAll(stdoutTask, stderrTask);
|
await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
|
||||||
await process.WaitForExitAsync(ct);
|
await process.WaitForExitAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
string stdout = stdoutTask.Result;
|
string stdout = stdoutTask.Result;
|
||||||
string stderr = stderrTask.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;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scans a single file and determines which audio/subtitle track IDs
|
/// Scans a single file and determines which audio/subtitle track IDs
|
||||||
/// should be kept. Fails if only English audio is present, or if the
|
/// 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 };
|
var scan = new ScanResult { FilePath = filePath };
|
||||||
try
|
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 audioTracks = info.Tracks.Where(t => t.Type == "audio").ToList();
|
||||||
var subtitleTracks = info.Tracks.Where(t => t.Type == "subtitles").ToList();
|
var subtitleTracks = info.Tracks.Where(t => t.Type == "subtitles").ToList();
|
||||||
@@ -325,9 +331,9 @@ public class MkvService
|
|||||||
{
|
{
|
||||||
byte[] buffer = new byte[1024 * 1024];
|
byte[] buffer = new byte[1024 * 1024];
|
||||||
int read;
|
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;
|
copied += read;
|
||||||
|
|
||||||
var elapsed = stopwatch.Elapsed;
|
var elapsed = stopwatch.Elapsed;
|
||||||
@@ -450,11 +456,11 @@ public class MkvService
|
|||||||
using var throughputCts = new CancellationTokenSource();
|
using var throughputCts = new CancellationTokenSource();
|
||||||
var throughputTask = MonitorThroughputAsync(tempFile, fileName, percentState, progress, throughputCts.Token);
|
var throughputTask = MonitorThroughputAsync(tempFile, fileName, percentState, progress, throughputCts.Token);
|
||||||
|
|
||||||
await process.WaitForExitAsync(CancellationToken.None);
|
await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
throughputCts.Cancel();
|
throughputCts.Cancel();
|
||||||
string stdout = await stdoutTask;
|
string stdout = await stdoutTask.ConfigureAwait(false);
|
||||||
string stderr = await stderrTask;
|
string stderr = await stderrTask.ConfigureAwait(false);
|
||||||
try { await throughputTask; } catch (OperationCanceledException) { }
|
try { await throughputTask.ConfigureAwait(false); } catch (OperationCanceledException) { }
|
||||||
|
|
||||||
if (wasKilled)
|
if (wasKilled)
|
||||||
{
|
{
|
||||||
@@ -543,7 +549,7 @@ public class MkvService
|
|||||||
{
|
{
|
||||||
var fullOutput = new System.Text.StringBuilder();
|
var fullOutput = new System.Text.StringBuilder();
|
||||||
string? line;
|
string? line;
|
||||||
while ((line = await process.StandardOutput.ReadLineAsync()) != null)
|
while ((line = await process.StandardOutput.ReadLineAsync().ConfigureAwait(false)) != null)
|
||||||
{
|
{
|
||||||
fullOutput.AppendLine(line);
|
fullOutput.AppendLine(line);
|
||||||
var match = ProgressRegex.Match(line);
|
var match = ProgressRegex.Match(line);
|
||||||
@@ -565,7 +571,7 @@ public class MkvService
|
|||||||
{
|
{
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
await Task.Delay(sampleInterval, stopToken);
|
await Task.Delay(sampleInterval, stopToken).ConfigureAwait(false);
|
||||||
|
|
||||||
long currentBytes = 0;
|
long currentBytes = 0;
|
||||||
try { currentBytes = new FileInfo(tempFile).Length; } catch { }
|
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="Auto"/>
|
||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Active jobs -->
|
<!-- Active jobs -->
|
||||||
@@ -53,8 +54,25 @@
|
|||||||
BorderThickness="0" Background="Transparent"/>
|
BorderThickness="0" Background="Transparent"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Status + speed + button -->
|
<!-- Total progress -->
|
||||||
<Grid Grid.Row="2" Margin="0,10,0,0">
|
<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>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace Futonizer.Views;
|
|||||||
|
|
||||||
public partial class ProgressWindow : FluentWindow
|
public partial class ProgressWindow : FluentWindow
|
||||||
{
|
{
|
||||||
private const int MaxConcurrentStrips = 5;
|
private readonly int _maxConcurrentStrips;
|
||||||
|
|
||||||
private readonly IReadOnlyList<QueuedFileItem> _files;
|
private readonly IReadOnlyList<QueuedFileItem> _files;
|
||||||
private readonly string _mkvMergePath;
|
private readonly string _mkvMergePath;
|
||||||
@@ -19,6 +19,9 @@ public partial class ProgressWindow : FluentWindow
|
|||||||
|
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
private bool _isDone;
|
private bool _isDone;
|
||||||
|
private long _totalInputBytes;
|
||||||
|
private long _completedBytes;
|
||||||
|
private int _finishedCount;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True once processing has finished running (successfully, with failures,
|
/// True once processing has finished running (successfully, with failures,
|
||||||
@@ -28,7 +31,15 @@ public partial class ProgressWindow : FluentWindow
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool ProcessingCompleted { get; private set; }
|
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);
|
Wpf.Ui.Appearance.SystemThemeWatcher.Watch(this);
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -36,6 +47,7 @@ public partial class ProgressWindow : FluentWindow
|
|||||||
_files = files;
|
_files = files;
|
||||||
_mkvMergePath = mkvMergePath;
|
_mkvMergePath = mkvMergePath;
|
||||||
_outputFolder = outputFolder;
|
_outputFolder = outputFolder;
|
||||||
|
_maxConcurrentStrips = maxConcurrentStrips > 0 ? maxConcurrentStrips : 5;
|
||||||
|
|
||||||
Title = $"Stripping {files.Count} file{(files.Count == 1 ? "" : "s")}";
|
Title = $"Stripping {files.Count} file{(files.Count == 1 ? "" : "s")}";
|
||||||
ActiveJobsList.ItemsSource = _activeJobs;
|
ActiveJobsList.ItemsSource = _activeJobs;
|
||||||
@@ -82,6 +94,28 @@ public partial class ProgressWindow : FluentWindow
|
|||||||
: $"{total:F1} MB/s across {_activeJobs.Count} file(s)";
|
: $"{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()
|
private async Task StartProcessingAsync()
|
||||||
{
|
{
|
||||||
StatusText.Text = "Stripping tracks...";
|
StatusText.Text = "Stripping tracks...";
|
||||||
@@ -91,26 +125,32 @@ public partial class ProgressWindow : FluentWindow
|
|||||||
bool overwritingInPlace = string.IsNullOrWhiteSpace(_outputFolder);
|
bool overwritingInPlace = string.IsNullOrWhiteSpace(_outputFolder);
|
||||||
var service = new MkvService(_mkvMergePath);
|
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));
|
AppendLog(new string('-', 80));
|
||||||
|
|
||||||
int successCount = 0;
|
int successCount = 0;
|
||||||
long totalBytesWritten = 0;
|
long totalBytesWritten = 0;
|
||||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
_totalInputBytes = _files.Sum(f => SafeFileLength(f.FilePath));
|
||||||
|
_completedBytes = 0;
|
||||||
|
_finishedCount = 0;
|
||||||
|
UpdateTotalProgress();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var semaphore = new SemaphoreSlim(MaxConcurrentStrips);
|
using var semaphore = new SemaphoreSlim(_maxConcurrentStrips);
|
||||||
|
|
||||||
var stripTasks = _files.Select(async file =>
|
var stripTasks = _files.Select(async file =>
|
||||||
{
|
{
|
||||||
await semaphore.WaitAsync(ct);
|
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) =>
|
job.PropertyChanged += (_, args) =>
|
||||||
{
|
{
|
||||||
if (args.PropertyName == nameof(JobProgressItem.MegabytesPerSecond))
|
if (args.PropertyName == nameof(JobProgressItem.MegabytesPerSecond))
|
||||||
UpdateTotalSpeed();
|
UpdateTotalSpeed();
|
||||||
|
if (args.PropertyName == nameof(JobProgressItem.Percent))
|
||||||
|
UpdateTotalProgress();
|
||||||
};
|
};
|
||||||
_activeJobs.Add(job);
|
_activeJobs.Add(job);
|
||||||
UpdateTotalSpeed();
|
UpdateTotalSpeed();
|
||||||
@@ -172,7 +212,10 @@ public partial class ProgressWindow : FluentWindow
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_activeJobs.Remove(job);
|
_activeJobs.Remove(job);
|
||||||
|
Interlocked.Add(ref _completedBytes, job.FileSizeBytes);
|
||||||
|
Interlocked.Increment(ref _finishedCount);
|
||||||
UpdateTotalSpeed();
|
UpdateTotalSpeed();
|
||||||
|
UpdateTotalProgress();
|
||||||
semaphore.Release();
|
semaphore.Release();
|
||||||
}
|
}
|
||||||
}).ToList();
|
}).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).");
|
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.";
|
StatusText.Text = $"Done: {successCount}/{_files.Count} succeeded.";
|
||||||
|
NotificationService.ShowToast("Futonizer", $"Done: {successCount}/{_files.Count} file{(_files.Count == 1 ? "" : "s")} succeeded.");
|
||||||
|
ShouldClearQueue = true;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
AppendLog(new string('-', 80));
|
AppendLog(new string('-', 80));
|
||||||
AppendLog("Cancelled by user. Nothing further was modified.");
|
AppendLog("Cancelled by user. Nothing further was modified.");
|
||||||
StatusText.Text = "Cancelled.";
|
StatusText.Text = "Cancelled.";
|
||||||
|
NotificationService.ShowToast("Futonizer", "Cancelled — nothing further was modified.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppendLog($"Unexpected error: {ex.Message}");
|
AppendLog($"Unexpected error: {ex.Message}");
|
||||||
StatusText.Text = "Error.";
|
StatusText.Text = "Error.";
|
||||||
|
NotificationService.ShowToast("Futonizer", $"Error: {ex.Message}");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,7 +24,6 @@
|
|||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ public partial class SettingsWindow : FluentWindow
|
|||||||
DialogResult = true;
|
DialogResult = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void CancelButton_Click(object sender, RoutedEventArgs e)
|
private void CancelButton_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
DialogResult = false;
|
DialogResult = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user