Initial commit: Futonizer MKV track stripper

This commit is contained in:
l4kr
2026-07-04 00:43:37 +02:00
commit 1208cadc3b
25 changed files with 2629 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
bin/
obj/
*.user
history.json
settings.json
+14
View File
@@ -0,0 +1,14 @@
<Application x:Class="Futonizer.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ui:ThemesDictionary Theme="Light" />
<ui:ControlsDictionary />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+7
View File
@@ -0,0 +1,7 @@
using System.Windows;
namespace Futonizer;
public partial class App : Application
{
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWPF>true</UseWPF>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>Futonizer</AssemblyName>
<RootNamespace>Futonizer</RootNamespace>
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="WPF-UI" Version="4.3.0" />
</ItemGroup>
<ItemGroup>
<Resource Include="Assets\app.png" />
</ItemGroup>
</Project>
+322
View File
@@ -0,0 +1,322 @@
<ui:FluentWindow x:Class="Futonizer.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="Futonizer"
Icon="pack://application:,,,/Assets/app.png"
Height="900" Width="1600" MinHeight="650" MinWidth="1100"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica"
WindowCornerPreference="Round"
WindowStartupLocation="CenterScreen"
AllowDrop="True"
DragOver="Window_DragOver"
Drop="Window_Drop"
PreviewKeyDown="Window_PreviewKeyDown">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<Style x:Key="QueueListBoxItemStyle" TargetType="ListBoxItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Padding" Value="8,5"/>
<Setter Property="Margin" Value="3,1"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="ItemBorder"
Background="Transparent"
CornerRadius="6"
Padding="{TemplateBinding Padding}"
SnapsToDevicePixels="True">
<Grid>
<Border x:Name="AccentBar"
Width="3" HorizontalAlignment="Left"
Margin="-8,-5,0,-5"
CornerRadius="2"
Background="{DynamicResource AccentFillColorDefaultBrush}"
Visibility="Collapsed"/>
<ContentPresenter/>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ItemBorder" Property="Background"
Value="{DynamicResource ControlFillColorSecondaryBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="ItemBorder" Property="Background"
Value="{DynamicResource AccentFillColorSecondaryBrush}"/>
<Setter TargetName="AccentBar" Property="Visibility" Value="Visible"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="Futonizer">
<ui:TitleBar.Icon>
<ui:ImageIcon Source="pack://application:,,,/Assets/app.png"/>
</ui:TitleBar.Icon>
</ui:TitleBar>
<Grid Grid.Row="1" Margin="16,4,16,16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- ── Toolbar ───────────────────────────────────────────────── -->
<Grid Grid.Row="0" Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ui:Button Grid.Column="0" Content="Settings" Icon="{ui:SymbolIcon Settings24}"
Appearance="Secondary"
Click="SettingsButton_Click"/>
<TextBlock x:Name="StatusText" Grid.Column="2" VerticalAlignment="Center"
Margin="16,0" FontWeight="SemiBold" FontSize="12"/>
<ui:Button x:Name="RunButton" Grid.Column="3"
Content="Strip Tracks" Appearance="Primary"
Icon="{ui:SymbolIcon Delete24}"
Click="RunButton_Click"/>
</Grid>
<!-- ── Two-pane content ──────────────────────────────────────── -->
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" MinWidth="280"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*" MinWidth="300"/>
</Grid.ColumnDefinitions>
<!-- File queue panel -->
<Border Grid.Column="0" CornerRadius="8" ClipToBounds="True"
Background="{DynamicResource ControlFillColorDefaultBrush}"
BorderBrush="{DynamicResource ControlElevationBorderBrush}"
BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Queue header -->
<Grid Grid.Row="0" Margin="10,8,6,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Queue"
FontWeight="SemiBold" FontSize="13"
VerticalAlignment="Center"/>
<Button x:Name="ClearQueueButton" Grid.Column="1"
Content="Clear" Click="ClearQueueButton_Click"
ToolTip="Remove all files from the queue"
Background="Transparent" BorderThickness="0"
Padding="6,2" Cursor="Hand" FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
IsEnabled="False"/>
</Grid>
<Grid Grid.Row="1">
<TextBlock x:Name="DropHint"
HorizontalAlignment="Center" VerticalAlignment="Center"
Text="Drop .mkv files&#10;onto this window"
Opacity="0.4" FontSize="14" TextAlignment="Center"
IsHitTestVisible="False"/>
<ListBox x:Name="FileQueueList"
Background="Transparent" BorderThickness="0"
HorizontalContentAlignment="Stretch"
SelectionMode="Extended"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
VirtualizingPanel.ScrollUnit="Pixel"
PreviewMouseWheel="SmoothList_PreviewMouseWheel"
PreviewKeyDown="FileQueueList_PreviewKeyDown"
ItemContainerStyle="{StaticResource QueueListBoxItemStyle}"
SelectionChanged="FileQueueList_SelectionChanged">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid ToolTip="{Binding Status}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="18"/>
</Grid.ColumnDefinitions>
<!-- Status glyphs (only one visible at a time) -->
<TextBlock Grid.Column="0" Text="&#x27F3;" FontSize="12"
VerticalAlignment="Center"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVis}}"/>
<TextBlock Grid.Column="0" Text="&#x25CF;" FontSize="9"
Opacity="0.5" VerticalAlignment="Center"
Visibility="{Binding IsLoadedNeutral, Converter={StaticResource BoolToVis}}"/>
<TextBlock Grid.Column="0" Text="&#x2713;" FontSize="12"
Foreground="#4CAF50" VerticalAlignment="Center"
Visibility="{Binding IsCorrect, Converter={StaticResource BoolToVis}}"/>
<TextBlock Grid.Column="0" Text="&#x2717;" FontSize="12"
Foreground="#F44336" VerticalAlignment="Center"
Visibility="{Binding IsIncorrect, Converter={StaticResource BoolToVis}}"/>
<TextBlock Grid.Column="1" Text="{Binding FileName}"
FontSize="12" VerticalAlignment="Center" Margin="6,0"
TextTrimming="CharacterEllipsis"/>
<Button Grid.Column="2" Content="&#xD7;"
Tag="{Binding}"
Click="RemoveFile_Click"
Background="Transparent" BorderThickness="0"
Padding="2,0" Cursor="Hand" FontSize="13"
VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Grid>
</Border>
<!-- GridSplitter -->
<GridSplitter Grid.Column="1" Width="6"
HorizontalAlignment="Center" Background="Transparent"/>
<!-- Track table panel -->
<Border Grid.Column="2" CornerRadius="8" ClipToBounds="True"
Background="{DynamicResource ControlFillColorDefaultBrush}"
BorderBrush="{DynamicResource ControlElevationBorderBrush}"
BorderThickness="1">
<Grid>
<TextBlock x:Name="NoSelectionHint"
HorizontalAlignment="Center" VerticalAlignment="Center"
Text="Select a file to view its tracks"
Opacity="0.4" FontSize="14"
IsHitTestVisible="False"/>
<DataGrid x:Name="TrackGrid"
Visibility="Collapsed"
AutoGenerateColumns="False"
CanUserAddRows="False"
CanUserDeleteRows="False"
CanUserReorderColumns="False"
IsReadOnly="False"
SelectionMode="Single"
HeadersVisibility="Column"
Background="Transparent"
BorderThickness="0"
RowHeight="30"
GridLinesVisibility="None"
VirtualizingPanel.ScrollUnit="Pixel"
PreviewMouseWheel="SmoothList_PreviewMouseWheel"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource AccentFillColorSecondaryBrush}"/>
</Trigger>
</Style.Triggers>
</Style>
</DataGrid.RowStyle>
<DataGrid.CellStyle>
<Style TargetType="DataGridCell">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}"/>
</Trigger>
</Style.Triggers>
</Style>
</DataGrid.CellStyle>
<DataGrid.Columns>
<!-- Copy (editable checkbox) -->
<DataGridTemplateColumn Header="Copy" Width="55"
CanUserSort="False" CanUserResize="False">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding Copy, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="ID"
Binding="{Binding Id}" 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"/>
<!-- Default (read-only indicator) -->
<DataGridTemplateColumn Header="Default" Width="70"
CanUserSort="False" CanUserResize="False">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding DefaultTrack, Mode=OneWay}"
IsHitTestVisible="False" IsTabStop="False"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Forced (read-only indicator) -->
<DataGridTemplateColumn Header="Forced" Width="70"
CanUserSort="False" CanUserResize="False">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding ForcedDisplay, Mode=OneWay}"
IsHitTestVisible="False" IsTabStop="False"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
</Border>
<!-- Loading overlay: blocks interaction until every queued file has finished loading -->
<Border x:Name="LoadingOverlay"
Grid.ColumnSpan="3"
Background="#B3000000"
CornerRadius="8"
Visibility="Collapsed"
IsHitTestVisible="True">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Width="240">
<TextBlock x:Name="LoadingOverlayText" Text="Loading files..."
FontSize="14" FontWeight="Medium" Foreground="White"
HorizontalAlignment="Center" Margin="0,0,0,10"/>
<ProgressBar x:Name="LoadingProgressBar"
Height="4" Minimum="0" Maximum="1" Value="0"/>
</StackPanel>
</Border>
</Grid>
</Grid>
</Grid>
</ui:FluentWindow>
+468
View File
@@ -0,0 +1,468 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using Futonizer.Models;
using Futonizer.Services;
using Futonizer.Views;
using Wpf.Ui.Controls;
namespace Futonizer;
public partial class MainWindow : FluentWindow
{
private const int MaxConcurrentScans = 4;
private AppSettings _settings = new();
private readonly ObservableCollection<QueuedFileItem> _fileQueue = new();
private readonly SemaphoreSlim _scanSemaphore = new(MaxConcurrentScans);
private bool _isProcessing;
private bool _propagatingSubtitleSelection;
public MainWindow()
{
Wpf.Ui.Appearance.SystemThemeWatcher.Watch(this);
InitializeComponent();
FileQueueList.ItemsSource = _fileQueue;
_fileQueue.CollectionChanged += (_, _) => UpdateDropHint();
LoadSettingsIntoUi();
Closing += (_, _) => SaveSettingsFromUi();
Loaded += MainWindow_Loaded;
}
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
EnsureRequiredSettingsConfigured();
}
/// <summary>
/// Warns the user and opens Settings if mkvmerge.exe isn't configured (or
/// no longer exists), or if a previously chosen output folder has since
/// disappeared. Runs once on startup; does nothing once everything is
/// already valid.
/// </summary>
private void EnsureRequiredSettingsConfigured()
{
bool mkvMissing = string.IsNullOrWhiteSpace(_settings.MkvMergePath) || !File.Exists(_settings.MkvMergePath);
bool outputInvalid = !string.IsNullOrWhiteSpace(_settings.OutputFolder) && !Directory.Exists(_settings.OutputFolder);
if (!mkvMissing && !outputInvalid) return;
string message = mkvMissing
? "mkvmerge.exe could not be found. Please locate it to enable stripping tracks."
: "The previously configured output folder no longer exists. Please choose a new one, or leave it blank to overwrite files in place.";
System.Windows.MessageBox.Show(this, message, "Futonizer — Setup required",
System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Warning);
if (outputInvalid)
_settings.OutputFolder = string.Empty;
var win = new SettingsWindow(_settings) { Owner = this };
if (win.ShowDialog() == true && win.Result != null)
{
_settings.MkvMergePath = win.Result.MkvMergePath;
_settings.OutputFolder = win.Result.OutputFolder;
SettingsService.Save(_settings);
}
}
// ── Helpers ──────────────────────────────────────────────────────────────
private void UpdateDropHint()
{
bool empty = _fileQueue.Count == 0;
DropHint.Visibility = empty ? Visibility.Visible : Visibility.Collapsed;
ClearQueueButton.IsEnabled = !empty;
}
/// <summary>
/// Shows a blocking overlay with a simple "loaded / total" progress bar
/// while any queued file is still being scanned, and keeps the Run button
/// disabled until every dropped file has finished loading.
/// </summary>
private void UpdateLoadingOverlay()
{
int total = _fileQueue.Count;
int loaded = _fileQueue.Count(f => !f.IsLoading);
if (total > 0 && loaded < total)
{
LoadingOverlay.Visibility = Visibility.Visible;
LoadingProgressBar.Maximum = total;
LoadingProgressBar.Value = loaded;
LoadingOverlayText.Text = $"Loading files... ({loaded}/{total})";
}
else
{
LoadingOverlay.Visibility = Visibility.Collapsed;
}
UpdateRunButtonEnabled();
}
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
? null
: "Select a subtitle track in the track table to enable stripping.";
}
private void QueuedFileItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(QueuedFileItem.IsLoading))
UpdateLoadingOverlay();
if (e.PropertyName == nameof(QueuedFileItem.LoadState))
UpdateRunButtonEnabled();
}
private static string GuessDefaultMkvMergePath()
{
string[] candidates =
{
@"C:\Program Files\MKVToolNix\mkvmerge.exe",
@"C:\Program Files (x86)\MKVToolNix\mkvmerge.exe",
};
return candidates.FirstOrDefault(File.Exists) ?? string.Empty;
}
// ── Settings persistence ──────────────────────────────────────────────────
private void LoadSettingsIntoUi()
{
_settings = SettingsService.Load();
if (string.IsNullOrWhiteSpace(_settings.MkvMergePath))
_settings.MkvMergePath = GuessDefaultMkvMergePath();
}
private void SaveSettingsFromUi()
{
SettingsService.Save(_settings);
}
// ── Settings button ───────────────────────────────────────────────────────
private void SettingsButton_Click(object sender, RoutedEventArgs e)
{
var win = new SettingsWindow(_settings) { Owner = this };
if (win.ShowDialog() == true && win.Result != null)
{
_settings.MkvMergePath = win.Result.MkvMergePath;
_settings.OutputFolder = win.Result.OutputFolder;
SettingsService.Save(_settings);
}
}
// ── Drag & Drop ───────────────────────────────────────────────────────────
private void Window_DragOver(object sender, DragEventArgs e)
{
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
bool hasMkv = paths.Any(p =>
string.Equals(Path.GetExtension(p), ".mkv", StringComparison.OrdinalIgnoreCase)
&& File.Exists(p));
e.Effects = hasMkv ? DragDropEffects.Copy : DragDropEffects.None;
}
else
{
e.Effects = DragDropEffects.None;
}
e.Handled = true;
}
private void Window_Drop(object sender, DragEventArgs e)
{
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
Activate();
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
var newFiles = paths
.Where(p => string.Equals(Path.GetExtension(p), ".mkv", StringComparison.OrdinalIgnoreCase)
&& File.Exists(p)
&& !_fileQueue.Any(q => string.Equals(q.FilePath, p, StringComparison.OrdinalIgnoreCase)))
.ToList();
foreach (var path in newFiles)
{
var item = new QueuedFileItem(path);
item.PropertyChanged += QueuedFileItem_PropertyChanged;
_fileQueue.Add(item);
_ = ScanFileItemAsync(item);
}
UpdateLoadingOverlay();
if (newFiles.Count > 0)
{
var first = _fileQueue.FirstOrDefault(q =>
string.Equals(q.FilePath, newFiles[0], StringComparison.OrdinalIgnoreCase));
if (first != null) FileQueueList.SelectedItem = first;
}
}
// ── File scanning ─────────────────────────────────────────────────────────
private async Task ScanFileItemAsync(QueuedFileItem item)
{
item.IsScanning = true;
item.Status = "Scanning...";
item.Tracks.Clear();
await _scanSemaphore.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(_settings.MkvMergePath) || !File.Exists(_settings.MkvMergePath))
{
item.IsScanning = false;
item.HasError = true;
item.Status = "mkvmerge.exe not found — open Settings";
return;
}
var service = new MkvService(_settings.MkvMergePath);
var info = await service.IdentifyAsync(item.FilePath);
var tracks = service.BuildTrackItems(info);
foreach (var t in tracks)
{
item.Tracks.Add(t);
if (t.Type == "subtitles")
{
t.PropertyChanged += (s, e) => OnSubtitleTrackPropertyChanged(item, (TrackItem)s!, e);
}
}
item.IsScanning = false;
item.Status = $"{tracks.Count} track{(tracks.Count == 1 ? "" : "s")}";
}
catch (Exception ex)
{
item.IsScanning = false;
item.HasError = true;
item.Status = ex.Message;
}
finally
{
_scanSemaphore.Release();
}
}
// ── 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
/// "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.
/// </summary>
private void OnSubtitleTrackPropertyChanged(QueuedFileItem sourceFile, TrackItem track, PropertyChangedEventArgs e)
{
if (_propagatingSubtitleSelection) return;
if (e.PropertyName != nameof(TrackItem.Copy)) return;
_propagatingSubtitleSelection = true;
try
{
if (track.Copy)
{
bool wasOverriding = sourceFile.Tracks.Any(t => t.Type == "subtitles" && t != track && t.Copy);
foreach (var other in sourceFile.Tracks.Where(t => t.Type == "subtitles" && t != track))
{
other.Copy = false;
other.DefaultTrack = false;
}
track.DefaultTrack = true;
string key = track.PropagationKey;
foreach (var otherFile in _fileQueue.Where(f => f != sourceFile))
{
var match = otherFile.Tracks.FirstOrDefault(t => t.Type == "subtitles" && t.PropagationKey == key);
if (match != null)
{
foreach (var sub in otherFile.Tracks.Where(t => t.Type == "subtitles"))
{
sub.Copy = sub == match;
sub.DefaultTrack = sub == match;
}
}
else if (wasOverriding)
{
foreach (var sub in otherFile.Tracks.Where(t => t.Type == "subtitles"))
{
sub.Copy = false;
sub.DefaultTrack = false;
}
}
}
}
else
{
track.DefaultTrack = false;
foreach (var otherFile in _fileQueue.Where(f => f != sourceFile))
{
foreach (var sub in otherFile.Tracks.Where(t => t.Type == "subtitles"))
{
sub.Copy = false;
sub.DefaultTrack = false;
}
}
}
}
finally
{
_propagatingSubtitleSelection = false;
}
}
// ── File queue UI ─────────────────────────────────────────────────────────
private void RemoveFile_Click(object sender, RoutedEventArgs e)
{
if (sender is System.Windows.Controls.Button btn && btn.Tag is QueuedFileItem item)
{
item.PropertyChanged -= QueuedFileItem_PropertyChanged;
_fileQueue.Remove(item);
UpdateLoadingOverlay();
}
e.Handled = true;
}
/// <summary>
/// Removes every currently-selected item from the queue (multi-select
/// via Ctrl/Shift-click), for batch removal without clearing everything.
/// </summary>
private void FileQueueList_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key != System.Windows.Input.Key.Delete) return;
var selected = FileQueueList.SelectedItems.Cast<QueuedFileItem>().ToList();
if (selected.Count == 0) return;
foreach (var item in selected)
{
item.PropertyChanged -= QueuedFileItem_PropertyChanged;
_fileQueue.Remove(item);
}
UpdateLoadingOverlay();
e.Handled = true;
}
private void ClearQueueButton_Click(object sender, RoutedEventArgs e)
{
foreach (var item in _fileQueue)
item.PropertyChanged -= QueuedFileItem_PropertyChanged;
_fileQueue.Clear();
UpdateLoadingOverlay();
}
/// <summary>
/// Scrolls the track grid or queue list by an amount proportional to the
/// wheel delta instead of relying on WPF's default per-event line
/// scrolling. Shared by both the file queue and track table so both
/// panes scroll consistently.
/// </summary>
private void SmoothList_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
=> SmoothScrollHelper.HandlePreviewMouseWheel(sender, e);
private void FileQueueList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (FileQueueList.SelectedItem is QueuedFileItem file)
{
TrackGrid.ItemsSource = file.Tracks;
TrackGrid.Visibility = Visibility.Visible;
NoSelectionHint.Visibility = Visibility.Collapsed;
}
else
{
TrackGrid.ItemsSource = null;
TrackGrid.Visibility = Visibility.Collapsed;
NoSelectionHint.Visibility = Visibility.Visible;
}
}
// ── Keyboard shortcuts ───────────────────────────────────────────
private void Window_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key == System.Windows.Input.Key.R
&& Keyboard.Modifiers == ModifierKeys.Control
&& RunButton.IsEnabled)
{
RunButton_Click(RunButton, new RoutedEventArgs());
e.Handled = true;
}
}
// ── Run ─────────────────────────────────────────────────────────────────────
private void RunButton_Click(object sender, RoutedEventArgs e)
{
if (_isProcessing) return;
if (_fileQueue.Any(f => f.IsLoading)) return;
if (string.IsNullOrWhiteSpace(_settings.MkvMergePath) || !File.Exists(_settings.MkvMergePath))
{
StatusText.Text = "mkvmerge.exe not set — open Settings.";
return;
}
var readyFiles = _fileQueue
.Where(f => f.IsCorrect)
.ToList();
if (readyFiles.Count == 0)
{
StatusText.Text = _fileQueue.Count == 0
? "No files in queue."
: "No files ready (need exactly one audio + one subtitle selected).";
return;
}
SaveSettingsFromUi();
_isProcessing = true;
RunButton.IsEnabled = false;
StatusText.Text = string.Empty;
var progressWin = new ProgressWindow(readyFiles, _settings.MkvMergePath, _settings.OutputFolder)
{
Owner = this
};
progressWin.Closed += (_, _) =>
{
_isProcessing = false;
if (progressWin.ProcessingCompleted)
{
foreach (var item in _fileQueue)
item.PropertyChanged -= QueuedFileItem_PropertyChanged;
_fileQueue.Clear();
}
UpdateLoadingOverlay();
};
progressWin.Show();
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace Futonizer.Models;
public class AppSettings
{
public string MkvMergePath { get; set; } = string.Empty;
public string OutputFolder { get; set; } = string.Empty;
}
+22
View File
@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace Futonizer.Models;
/// <summary>
/// One record of a previously-run strip (or copy) job, persisted so it can
/// be reviewed later from Settings.
/// </summary>
public class HistoryEntry
{
public DateTime Timestamp { get; set; }
public string FileName { get; set; } = string.Empty;
public string FilePath { get; set; } = string.Empty;
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
[JsonIgnore]
public bool Failed => !Success;
[JsonIgnore]
public string TimestampDisplay => Timestamp.ToString("yyyy-MM-dd HH:mm");
}
+47
View File
@@ -0,0 +1,47 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Futonizer.Models;
/// <summary>
/// Represents one currently-running strip job for display in the "active jobs" list,
/// since multiple files can be stripped concurrently.
/// </summary>
public class JobProgressItem : INotifyPropertyChanged
{
private int _percent;
private double _megabytesPerSecond;
public string FileName { get; set; } = string.Empty;
public int Percent
{
get => _percent;
set
{
_percent = value;
OnPropertyChanged();
OnPropertyChanged(nameof(Display));
}
}
public double MegabytesPerSecond
{
get => _megabytesPerSecond;
set
{
_megabytesPerSecond = value;
OnPropertyChanged();
OnPropertyChanged(nameof(Display));
}
}
public string Display => $"{FileName} - {Percent}% - {MegabytesPerSecond:F1} MB/s";
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
+52
View File
@@ -0,0 +1,52 @@
using System.Text.Json.Serialization;
namespace Futonizer.Models;
/// <summary>
/// Root object returned by `mkvmerge -J <file>`.
/// Only the fields we actually need are mapped.
/// </summary>
public class MkvIdentifyResult
{
[JsonPropertyName("file_name")]
public string? FileName { get; set; }
[JsonPropertyName("tracks")]
public List<MkvTrack> Tracks { get; set; } = new();
}
public class MkvTrack
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("codec")]
public string Codec { get; set; } = string.Empty;
[JsonPropertyName("properties")]
public MkvTrackProperties? Properties { get; set; }
}
public class MkvTrackProperties
{
[JsonPropertyName("language")]
public string? Language { get; set; }
[JsonPropertyName("language_ietf")]
public string? LanguageIetf { get; set; }
[JsonPropertyName("track_name")]
public string? TrackName { get; set; }
[JsonPropertyName("default_track")]
public bool DefaultTrack { get; set; }
[JsonPropertyName("forced_track")]
public bool ForcedTrack { get; set; }
[JsonPropertyName("audio_channels")]
public int? AudioChannels { get; set; }
}
+118
View File
@@ -0,0 +1,118 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.IO;
using System.Runtime.CompilerServices;
namespace Futonizer.Models;
/// <summary>
/// The four states shown as an icon in the file queue list.
/// </summary>
public enum FileLoadState
{
/// <summary>Currently being scanned with mkvmerge -J.</summary>
Loading,
/// <summary>Scan finished (or failed) but track selection isn't evaluated as correct/incorrect yet.</summary>
Loaded,
/// <summary>Exactly one audio track and one subtitle track are selected to be kept.</summary>
Correct,
/// <summary>Anything other than exactly one audio + one subtitle selected.</summary>
Incorrect,
}
/// <summary>
/// Represents one MKV file in the processing queue.
/// </summary>
public class QueuedFileItem : INotifyPropertyChanged
{
private string _status = string.Empty;
private bool _isScanning;
private bool _hasError;
public string FilePath { get; }
public string FileName => Path.GetFileName(FilePath);
public ObservableCollection<TrackItem> Tracks { get; } = new();
public string Status
{
get => _status;
set { _status = value; OnPropertyChanged(); }
}
public bool IsScanning
{
get => _isScanning;
set { _isScanning = value; OnPropertyChanged(); NotifyLoadState(); }
}
public bool HasError
{
get => _hasError;
set { _hasError = value; OnPropertyChanged(); NotifyLoadState(); }
}
public FileLoadState LoadState
{
get
{
if (_isScanning) return FileLoadState.Loading;
if (_hasError || Tracks.Count == 0) return FileLoadState.Loaded;
int audioSelected = Tracks.Count(t => t.Type == "audio" && t.Copy);
int subtitleSelected = Tracks.Count(t => t.Type == "subtitles" && t.Copy);
return audioSelected == 1 && subtitleSelected == 1
? FileLoadState.Correct
: FileLoadState.Incorrect;
}
}
public bool IsLoading => LoadState == FileLoadState.Loading;
public bool IsLoadedNeutral => LoadState == FileLoadState.Loaded;
public bool IsCorrect => LoadState == FileLoadState.Correct;
public bool IsIncorrect => LoadState == FileLoadState.Incorrect;
public QueuedFileItem(string filePath)
{
FilePath = filePath;
Tracks.CollectionChanged += OnTracksChanged;
}
private void OnTracksChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
{
foreach (TrackItem t in e.NewItems)
t.PropertyChanged += TrackPropertyChanged;
}
if (e.OldItems != null)
{
foreach (TrackItem t in e.OldItems)
t.PropertyChanged -= TrackPropertyChanged;
}
NotifyLoadState();
}
private void TrackPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(TrackItem.Copy))
NotifyLoadState();
}
private void NotifyLoadState()
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(LoadState)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsLoading)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsLoadedNeutral)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsCorrect)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsIncorrect)));
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
+70
View File
@@ -0,0 +1,70 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Futonizer.Models;
/// <summary>
/// Represents one track of a queued MKV file, as shown in the track table.
/// The <see cref="Copy"/> property is user-editable and drives which tracks
/// are passed to mkvmerge when stripping.
/// </summary>
public class TrackItem : INotifyPropertyChanged
{
private bool _copy;
public int Id { get; init; }
public string Type { get; init; } = string.Empty;
public string Codec { get; init; } = string.Empty;
public string Language { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public bool ForcedDisplay { get; init; }
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).
/// </summary>
public bool DefaultTrack
{
get => _defaultTrack;
set
{
if (_defaultTrack == value) return;
_defaultTrack = value;
OnPropertyChanged();
}
}
/// <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.
/// </summary>
public string DisplayName => Type == "subtitles" ? $"{Codec}/{Name}" : Name;
public bool Copy
{
get => _copy;
set
{
if (_copy == value) return;
_copy = value;
OnPropertyChanged();
}
}
/// <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.
/// </summary>
public string PropagationKey => DisplayName;
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
+65
View File
@@ -0,0 +1,65 @@
using System.IO;
using System.Text.Json;
using Futonizer.Models;
namespace Futonizer.Services;
/// <summary>
/// Persists a rolling history of previously processed files (newest first,
/// capped at <see cref="MaxEntries"/>) as a JSON file stored next to the
/// application executable.
/// </summary>
public static class HistoryService
{
private const int MaxEntries = 1000;
private static readonly string HistoryFilePath =
Path.Combine(AppContext.BaseDirectory, "history.json");
private static readonly object FileLock = new();
public static List<HistoryEntry> Load()
{
lock (FileLock)
{
try
{
if (!File.Exists(HistoryFilePath)) return new List<HistoryEntry>();
string json = File.ReadAllText(HistoryFilePath);
var list = JsonSerializer.Deserialize<List<HistoryEntry>>(json);
return list ?? new List<HistoryEntry>();
}
catch
{
return new List<HistoryEntry>();
}
}
}
/// <summary>
/// Adds a new entry at the front of the history and trims it down to
/// <see cref="MaxEntries"/> items. Safe to call concurrently from
/// multiple in-flight strip jobs.
/// </summary>
public static void Add(HistoryEntry entry)
{
lock (FileLock)
{
try
{
var list = File.Exists(HistoryFilePath)
? JsonSerializer.Deserialize<List<HistoryEntry>>(File.ReadAllText(HistoryFilePath)) ?? new List<HistoryEntry>()
: new List<HistoryEntry>();
list.Insert(0, entry);
if (list.Count > MaxEntries)
list.RemoveRange(MaxEntries, list.Count - MaxEntries);
string json = JsonSerializer.Serialize(list, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(HistoryFilePath, json);
}
catch
{
// Best effort — history is a convenience feature, never worth failing a strip job over.
}
}
}
}
+577
View File
@@ -0,0 +1,577 @@
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Text.RegularExpressions;
using Futonizer.Models;
namespace Futonizer.Services;
public class ScanResult
{
public string FilePath { get; set; } = string.Empty;
public string FileName => Path.GetFileName(FilePath);
/// <summary>Path of the file relative to the scanned media folder root.</summary>
public string RelativePath { get; set; } = string.Empty;
public bool Passed { get; set; }
public string Message { get; set; } = string.Empty;
public List<int> AudioKeepIds { get; set; } = new();
public List<int> SubtitleKeepIds { get; set; } = new();
}
public class ProcessResult
{
public string SourceFilePath { get; set; } = string.Empty;
public string FileName => Path.GetFileName(SourceFilePath);
public string OutputFilePath { get; set; } = string.Empty;
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
}
/// <summary>
/// Live progress reported while a file is being remuxed.
/// </summary>
public class ProcessingProgress
{
public string FileName { get; set; } = string.Empty;
public int PercentComplete { get; set; }
public double MegabytesPerSecond { get; set; }
}
/// <summary>
/// Wraps calls to mkvmerge.exe: identifying tracks (via -J / JSON) and
/// remuxing files to keep only the wanted audio/subtitle tracks.
/// </summary>
public class MkvService
{
private static readonly Regex ProgressRegex = new(@"(?:#GUI#progress|Progress:)\s*(\d+)%", RegexOptions.Compiled);
private readonly string _mkvmergePath;
public MkvService(string mkvmergePath)
{
_mkvmergePath = mkvmergePath;
}
public async Task<MkvIdentifyResult> IdentifyAsync(string filePath, CancellationToken ct = default)
{
var psi = new ProcessStartInfo
{
FileName = _mkvmergePath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
psi.ArgumentList.Add("-J");
psi.ArgumentList.Add(filePath);
using var process = new Process { StartInfo = psi };
process.Start();
// Drain both streams concurrently. mkvmerge sometimes writes warnings to
// stderr; if it isn't read while we await stdout, the OS pipe buffer can
// 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);
string stdout = stdoutTask.Result;
string stderr = stderrTask.Result;
if (string.IsNullOrWhiteSpace(stdout))
{
string detail = string.IsNullOrWhiteSpace(stderr) ? string.Empty : $" ({stderr.Trim()})";
throw new InvalidOperationException($"mkvmerge returned no output for '{Path.GetFileName(filePath)}'.{detail}");
}
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var result = JsonSerializer.Deserialize<MkvIdentifyResult>(stdout, options);
return result ?? throw new InvalidOperationException($"Failed to parse mkvmerge output for '{Path.GetFileName(filePath)}'.");
}
/// <summary>
/// Builds the list of <see cref="TrackItem"/> objects for a file. Video
/// tracks are always pre-checked. Exactly one audio track is pre-checked:
/// among Japanese/Chinese/undefined-language tracks, a stereo (2-channel)
/// track is preferred, otherwise the first matching track in file order.
/// Subtitle tracks are never pre-checked — the user picks one via the
/// track table, and the choice propagates to other queued files.
/// </summary>
public List<TrackItem> BuildTrackItems(MkvIdentifyResult info)
{
var audioTracks = info.Tracks.Where(t => t.Type == "audio").ToList();
var eligibleAudio = audioTracks
.Where(t => IsAcceptableAudioLanguage(t.Properties?.Language) ||
IsAcceptableAudioLanguage(t.Properties?.LanguageIetf))
.ToList();
MkvTrack? preferredAudio = eligibleAudio.FirstOrDefault(t => t.Properties?.AudioChannels == 2)
?? eligibleAudio.FirstOrDefault();
return info.Tracks.Select(t =>
{
bool copy = t.Type switch
{
"video" => true,
"audio" => preferredAudio != null && t.Id == preferredAudio.Id,
_ => false,
};
// Prefer IETF tag (e.g. "ja") over legacy 3-letter code (e.g. "jpn").
string lang = t.Properties?.LanguageIetf
?? t.Properties?.Language
?? string.Empty;
return new TrackItem
{
Id = t.Id,
Type = t.Type,
Codec = t.Codec,
Language = lang,
Name = t.Properties?.TrackName ?? string.Empty,
DefaultTrack = t.Properties?.DefaultTrack ?? false,
ForcedDisplay = t.Properties?.ForcedTrack ?? false,
Copy = copy,
};
}).ToList();
}
/// <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
/// requested subtitle track name cannot be found.
/// </summary>
public async Task<ScanResult> ScanFileAsync(string filePath, string subtitleTrackName, CancellationToken ct = default)
{
var scan = new ScanResult { FilePath = filePath };
try
{
var info = await IdentifyAsync(filePath, ct);
var audioTracks = info.Tracks.Where(t => t.Type == "audio").ToList();
var subtitleTracks = info.Tracks.Where(t => t.Type == "subtitles").ToList();
if (audioTracks.Count == 0)
{
scan.Passed = false;
scan.Message = "No audio tracks found.";
return scan;
}
var keepAudio = audioTracks
.Where(t => IsJapaneseOrUndefined(t.Properties?.Language) || IsJapaneseOrUndefined(t.Properties?.LanguageIetf))
.ToList();
if (keepAudio.Count == 0)
{
scan.Passed = false;
scan.Message = "Only English (or other non-Japanese/undefined) audio found.";
return scan;
}
if (subtitleTracks.Count == 0)
{
scan.Passed = false;
scan.Message = "No subtitle tracks found.";
return scan;
}
var keepSubs = subtitleTracks
.Where(t => string.Equals(t.Properties?.TrackName?.Trim(), subtitleTrackName.Trim(), StringComparison.Ordinal))
.ToList();
if (keepSubs.Count == 0)
{
scan.Passed = false;
scan.Message = $"Subtitle track named \"{subtitleTrackName}\" not found.";
return scan;
}
scan.AudioKeepIds = keepAudio.Select(t => t.Id).ToList();
scan.SubtitleKeepIds = keepSubs.Select(t => t.Id).ToList();
scan.Passed = true;
scan.Message = $"OK - keep audio [{string.Join(",", scan.AudioKeepIds)}], subtitle [{string.Join(",", scan.SubtitleKeepIds)}]";
return scan;
}
catch (Exception ex)
{
scan.Passed = false;
scan.Message = $"Error reading file: {ex.Message}";
return scan;
}
}
private static bool IsJapaneseOrUndefined(string? lang)
{
if (string.IsNullOrWhiteSpace(lang)) return true;
lang = lang.Trim().ToLowerInvariant();
return lang is "jpn" or "ja" or "und";
}
/// <summary>
/// Accepts Japanese, Chinese, or undefined-language audio tracks.
/// </summary>
private static bool IsAcceptableAudioLanguage(string? lang)
{
if (string.IsNullOrWhiteSpace(lang)) return true;
lang = lang.Trim().ToLowerInvariant();
return lang is "jpn" or "ja" or "und"
or "chi" or "zho" or "zh" or "cmn" or "yue";
}
// -------------------------------------------------------------------------
// Public strip overloads
// -------------------------------------------------------------------------
/// <summary>
/// Remuxes a file, keeping only the tracks indicated by the three ID lists.
/// An empty list means "drop all tracks of that type"; pass the IDs from
/// <see cref="TrackItem"/> objects whose <see cref="TrackItem.Copy"/> is true.
/// </summary>
public Task<ProcessResult> StripTracksAsync(
string filePath,
string outputFilePath,
IReadOnlyList<int> videoKeepIds,
IReadOnlyList<int> audioKeepIds,
IReadOnlyList<int> subtitleKeepIds,
IProgress<ProcessingProgress>? progress = null,
CancellationToken ct = default)
{
return StripTracksInternalAsync(
filePath, Path.GetFileName(filePath), outputFilePath,
videoKeepIds, audioKeepIds, subtitleKeepIds,
progress, ct);
}
/// <summary>
/// Legacy overload using a <see cref="ScanResult"/>. Video is always kept in full.
/// </summary>
public Task<ProcessResult> StripTracksAsync(
ScanResult scan,
string outputFilePath,
IProgress<ProcessingProgress>? progress = null,
CancellationToken ct = default)
{
return StripTracksInternalAsync(
scan.FilePath, scan.FileName, outputFilePath,
null, // null = keep all video
scan.AudioKeepIds,
scan.SubtitleKeepIds,
progress, ct);
}
/// <summary>
/// Plain byte-for-byte file copy, used instead of an mkvmerge remux when
/// every track in the source file is already selected to be kept (so
/// nothing would actually be stripped). Supports cancellation and
/// reports throughput the same way <see cref="StripTracksAsync"/> does.
/// </summary>
public async Task<ProcessResult> CopyFileAsync(
string filePath,
string outputFilePath,
IProgress<ProcessingProgress>? progress = null,
CancellationToken ct = default)
{
string fileName = Path.GetFileName(filePath);
var result = new ProcessResult { SourceFilePath = filePath, OutputFilePath = outputFilePath };
string fullSource = Path.GetFullPath(filePath);
string fullOutput = Path.GetFullPath(outputFilePath);
if (string.Equals(fullSource, fullOutput, StringComparison.OrdinalIgnoreCase))
{
// Overwriting in place with no tracks removed — there is nothing to do.
progress?.Report(new ProcessingProgress { FileName = fileName, PercentComplete = 100, MegabytesPerSecond = 0 });
result.Success = true;
result.Message = "No tracks removed — left file unchanged";
return result;
}
string? outDir = Path.GetDirectoryName(fullOutput);
string tempFile = Path.Combine(outDir ?? "", Path.GetFileNameWithoutExtension(fullOutput) + ".tmp_stripped.mkv");
try
{
if (!string.IsNullOrEmpty(outDir))
Directory.CreateDirectory(outDir);
if (File.Exists(tempFile))
File.Delete(tempFile);
long totalBytes = new FileInfo(fullSource).Length;
var stopwatch = Stopwatch.StartNew();
var lastReport = TimeSpan.Zero;
long copied = 0;
await using (var source = new FileStream(fullSource, FileMode.Open, FileAccess.Read, FileShare.Read, 1024 * 1024, useAsync: true))
await using (var dest = new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024, useAsync: true))
{
byte[] buffer = new byte[1024 * 1024];
int read;
while ((read = await source.ReadAsync(buffer, ct)) > 0)
{
await dest.WriteAsync(buffer.AsMemory(0, read), ct);
copied += read;
var elapsed = stopwatch.Elapsed;
if ((elapsed - lastReport).TotalMilliseconds >= 200 || copied >= totalBytes)
{
double mbps = elapsed.TotalSeconds > 0 ? (copied / 1024.0 / 1024.0) / elapsed.TotalSeconds : 0;
int percent = totalBytes > 0 ? (int)Math.Min(99, copied * 100 / totalBytes) : 0;
progress?.Report(new ProcessingProgress { FileName = fileName, PercentComplete = percent, MegabytesPerSecond = mbps });
lastReport = elapsed;
}
}
}
progress?.Report(new ProcessingProgress { FileName = fileName, PercentComplete = 100, MegabytesPerSecond = 0 });
if (File.Exists(fullOutput)) File.Delete(fullOutput);
File.Move(tempFile, fullOutput);
result.Success = true;
result.Message = "Copied (no tracks removed)";
return result;
}
catch (OperationCanceledException)
{
if (File.Exists(tempFile))
try { File.Delete(tempFile); } catch { }
throw;
}
catch (Exception ex)
{
result.Success = false;
result.Message = $"Error: {ex.Message}";
if (File.Exists(tempFile))
try { File.Delete(tempFile); } catch { }
return result;
}
}
// -------------------------------------------------------------------------
// Internal implementation
// -------------------------------------------------------------------------
/// <param name="videoKeepIds">null = keep all video; empty = drop all video; otherwise keep listed IDs.</param>
/// <param name="audioKeepIds">null = keep all audio; empty = drop all audio; otherwise keep listed IDs.</param>
/// <param name="subtitleKeepIds">null = keep all subtitles; empty = drop all subtitles; otherwise keep listed IDs.</param>
private async Task<ProcessResult> StripTracksInternalAsync(
string filePath,
string fileName,
string outputFilePath,
IReadOnlyList<int>? videoKeepIds,
IReadOnlyList<int>? audioKeepIds,
IReadOnlyList<int>? subtitleKeepIds,
IProgress<ProcessingProgress>? progress,
CancellationToken ct)
{
var result = new ProcessResult { SourceFilePath = filePath, OutputFilePath = outputFilePath };
string fullSource = Path.GetFullPath(filePath);
string fullOutput = Path.GetFullPath(outputFilePath);
bool inPlace = string.Equals(fullSource, fullOutput, StringComparison.OrdinalIgnoreCase);
string? outDir = Path.GetDirectoryName(fullOutput);
string tempFile = Path.Combine(outDir ?? "", Path.GetFileNameWithoutExtension(fullOutput) + ".tmp_stripped.mkv");
try
{
if (!string.IsNullOrEmpty(outDir))
Directory.CreateDirectory(outDir);
if (File.Exists(tempFile))
File.Delete(tempFile);
var psi = new ProcessStartInfo
{
FileName = _mkvmergePath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
psi.ArgumentList.Add("--gui-mode");
psi.ArgumentList.Add("-o");
psi.ArgumentList.Add(tempFile);
AddTrackTypeArgs(psi, "--video-tracks", "--no-video", videoKeepIds);
AddTrackTypeArgs(psi, "--audio-tracks", "--no-audio", audioKeepIds);
AddTrackTypeArgs(psi, "--subtitle-tracks", "--no-subtitles", subtitleKeepIds);
// The one subtitle track the user chose to keep is always flagged
// as the default subtitle track in the output.
if (subtitleKeepIds != null && subtitleKeepIds.Count == 1)
{
psi.ArgumentList.Add("--default-track-flag");
psi.ArgumentList.Add($"{subtitleKeepIds[0]}:yes");
}
psi.ArgumentList.Add(filePath);
using var process = new Process { StartInfo = psi };
process.Start();
bool wasKilled = false;
using var killOnCancel = ct.Register(() =>
{
try
{
if (!process.HasExited)
{
wasKilled = true;
process.Kill(true);
}
}
catch { /* best effort */ }
});
var percentState = new PercentState();
var stderrTask = process.StandardError.ReadToEndAsync();
var stdoutTask = ReadStdoutForPercentAsync(process, percentState);
using var throughputCts = new CancellationTokenSource();
var throughputTask = MonitorThroughputAsync(tempFile, fileName, percentState, progress, throughputCts.Token);
await process.WaitForExitAsync(CancellationToken.None);
throughputCts.Cancel();
string stdout = await stdoutTask;
string stderr = await stderrTask;
try { await throughputTask; } catch (OperationCanceledException) { }
if (wasKilled)
{
if (File.Exists(tempFile))
try { File.Delete(tempFile); } catch { }
throw new OperationCanceledException(ct);
}
progress?.Report(new ProcessingProgress { FileName = fileName, PercentComplete = 100, MegabytesPerSecond = 0 });
if (process.ExitCode >= 2)
{
result.Success = false;
result.Message = $"mkvmerge failed (exit {process.ExitCode}): {stdout} {stderr}".Trim();
if (File.Exists(tempFile)) File.Delete(tempFile);
return result;
}
if (inPlace)
{
string backupFile = fullSource + ".futonizer_bak";
if (File.Exists(backupFile)) File.Delete(backupFile);
File.Move(filePath, backupFile);
try
{
File.Move(tempFile, fullOutput);
}
catch
{
if (!File.Exists(filePath) && File.Exists(backupFile))
File.Move(backupFile, filePath);
throw;
}
if (File.Exists(backupFile)) File.Delete(backupFile);
}
else
{
if (File.Exists(fullOutput)) File.Delete(fullOutput);
File.Move(tempFile, fullOutput);
}
result.Success = true;
result.Message = process.ExitCode == 1 ? "Success (with warnings)" : "Success";
return result;
}
catch (OperationCanceledException)
{
if (File.Exists(tempFile))
try { File.Delete(tempFile); } catch { }
throw;
}
catch (Exception ex)
{
result.Success = false;
result.Message = $"Error: {ex.Message}";
if (File.Exists(tempFile))
try { File.Delete(tempFile); } catch { }
return result;
}
}
/// <summary>
/// Adds mkvmerge track-filter arguments for one track type.
/// null ids = keep all (no argument added); empty = --no-{type}; else --{type}-tracks ids.
/// </summary>
private static void AddTrackTypeArgs(ProcessStartInfo psi, string keepArg, string dropArg, IReadOnlyList<int>? ids)
{
if (ids == null) return;
if (ids.Count == 0)
{
psi.ArgumentList.Add(dropArg);
}
else
{
psi.ArgumentList.Add(keepArg);
psi.ArgumentList.Add(string.Join(",", ids));
}
}
private sealed class PercentState
{
public volatile int Percent;
}
private static async Task<string> ReadStdoutForPercentAsync(Process process, PercentState state)
{
var fullOutput = new System.Text.StringBuilder();
string? line;
while ((line = await process.StandardOutput.ReadLineAsync()) != null)
{
fullOutput.AppendLine(line);
var match = ProgressRegex.Match(line);
if (match.Success && int.TryParse(match.Groups[1].Value, out int percent))
state.Percent = percent;
}
return fullOutput.ToString();
}
private static async Task MonitorThroughputAsync(string tempFile, string fileName, PercentState state, IProgress<ProcessingProgress>? progress, CancellationToken stopToken)
{
var sampleInterval = TimeSpan.FromMilliseconds(500);
var windowSeconds = 3.0;
var stopwatch = Stopwatch.StartNew();
var samples = new Queue<(double ElapsedSeconds, long Bytes)>();
samples.Enqueue((0, 0));
try
{
while (true)
{
await Task.Delay(sampleInterval, stopToken);
long currentBytes = 0;
try { currentBytes = new FileInfo(tempFile).Length; } catch { }
double currentElapsed = stopwatch.Elapsed.TotalSeconds;
samples.Enqueue((currentElapsed, currentBytes));
while (samples.Count > 1 && currentElapsed - samples.Peek().ElapsedSeconds > windowSeconds)
samples.Dequeue();
var (oldestElapsed, oldestBytes) = samples.Peek();
double deltaTime = currentElapsed - oldestElapsed;
double mbPerSec = deltaTime > 0 ? (currentBytes - oldestBytes) / 1024.0 / 1024.0 / deltaTime : 0;
progress?.Report(new ProcessingProgress
{
FileName = fileName,
PercentComplete = state.Percent,
MegabytesPerSecond = Math.Max(0, mbPerSec),
});
}
}
catch (OperationCanceledException) { }
}
}
+48
View File
@@ -0,0 +1,48 @@
using System.IO;
using System.Text.Json;
using Futonizer.Models;
namespace Futonizer.Services;
/// <summary>
/// Loads and saves <see cref="AppSettings"/> as a JSON file stored next to
/// the application executable, so it travels with the app folder.
/// </summary>
public static class SettingsService
{
private static readonly string SettingsFilePath = Path.Combine(AppContext.BaseDirectory, "settings.json");
private static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
public static AppSettings Load()
{
try
{
if (File.Exists(SettingsFilePath))
{
string json = File.ReadAllText(SettingsFilePath);
var settings = JsonSerializer.Deserialize<AppSettings>(json, Options);
if (settings is not null)
return settings;
}
}
catch
{
// Corrupt or unreadable settings file - fall back to defaults.
}
return new AppSettings();
}
public static void Save(AppSettings settings)
{
try
{
string json = JsonSerializer.Serialize(settings, Options);
File.WriteAllText(SettingsFilePath, json);
}
catch
{
// Best effort - ignore write failures (e.g. read-only install folder).
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
namespace Futonizer.Services;
/// <summary>
/// Scrolls a list/grid by an amount proportional to the mouse wheel delta
/// instead of relying on WPF's default per-event line scrolling. Mouse
/// "smooth scroll" utilities send many finer-grained wheel events per
/// physical notch to animate motion; WPF's default handling treats each
/// event as a full notch regardless of its magnitude, which multiplies the
/// effective scroll speed far beyond what the user intended.
/// </summary>
public static class SmoothScrollHelper
{
private const double PixelsPerStandardNotch = 48.0;
public static void HandlePreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
if (sender is not DependencyObject start) return;
var scrollViewer = FindVisualChild<ScrollViewer>(start);
if (scrollViewer == null) return;
double offsetDelta = -(e.Delta / 120.0) * PixelsPerStandardNotch;
scrollViewer.ScrollToVerticalOffset(scrollViewer.VerticalOffset + offsetDelta);
e.Handled = true;
}
private static T? FindVisualChild<T>(DependencyObject parent) where T : DependencyObject
{
int count = VisualTreeHelper.GetChildrenCount(parent);
for (int i = 0; i < count; i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is T typed) return typed;
var descendant = FindVisualChild<T>(child);
if (descendant != null) return descendant;
}
return null;
}
}
+121
View File
@@ -0,0 +1,121 @@
<ui:FluentWindow x:Class="Futonizer.Views.HistoryWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="History"
Height="520" Width="640"
MinHeight="360" MinWidth="480"
WindowStartupLocation="CenterOwner"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica"
WindowCornerPreference="Round">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<Style x:Key="HistoryListBoxItemStyle" TargetType="ListBoxItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Padding" Value="10,6"/>
<Setter Property="Margin" Value="3,1"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="ItemBorder"
Background="Transparent"
CornerRadius="6"
Padding="{TemplateBinding Padding}"
SnapsToDevicePixels="True">
<Grid>
<Border x:Name="AccentBar"
Width="3" HorizontalAlignment="Left"
Margin="-10,-6,0,-6"
CornerRadius="2"
Background="{DynamicResource AccentFillColorDefaultBrush}"
Visibility="Collapsed"/>
<ContentPresenter/>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ItemBorder" Property="Background"
Value="{DynamicResource ControlFillColorSecondaryBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="ItemBorder" Property="Background"
Value="{DynamicResource AccentFillColorSecondaryBrush}"/>
<Setter TargetName="AccentBar" Property="Visibility" Value="Visible"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="History"/>
<Grid Grid.Row="1" Margin="16,4,16,16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" x:Name="SummaryText"
FontSize="12" Opacity="0.7" Margin="4,0,0,8"/>
<Border Grid.Row="1" CornerRadius="8" ClipToBounds="True"
Background="{DynamicResource ControlFillColorDefaultBrush}"
BorderBrush="{DynamicResource ControlElevationBorderBrush}"
BorderThickness="1">
<Grid>
<TextBlock x:Name="EmptyHint"
HorizontalAlignment="Center" VerticalAlignment="Center"
Text="No history yet"
Opacity="0.4" FontSize="14"
IsHitTestVisible="False"/>
<ListBox x:Name="HistoryList"
Background="Transparent" BorderThickness="0"
HorizontalContentAlignment="Stretch"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
VirtualizingPanel.ScrollUnit="Pixel"
PreviewMouseWheel="HistoryList_PreviewMouseWheel"
ItemContainerStyle="{StaticResource HistoryListBoxItemStyle}">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid ToolTip="{Binding Message}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="140"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="&#x2713;" FontSize="12"
Foreground="#4CAF50" VerticalAlignment="Center"
Visibility="{Binding Success, Converter={StaticResource BoolToVis}}"/>
<TextBlock Grid.Column="0" Text="&#x2717;" FontSize="12"
Foreground="#F44336" VerticalAlignment="Center"
Visibility="{Binding Failed, Converter={StaticResource BoolToVis}}"/>
<TextBlock Grid.Column="1" Text="{Binding TimestampDisplay}"
FontSize="11" Opacity="0.7" VerticalAlignment="Center" Margin="8,0"/>
<TextBlock Grid.Column="2" Text="{Binding FileName}"
FontSize="12" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Border>
</Grid>
</Grid>
</ui:FluentWindow>
+27
View File
@@ -0,0 +1,27 @@
using System.Windows.Input;
using Futonizer.Services;
using Wpf.Ui.Controls;
namespace Futonizer.Views;
public partial class HistoryWindow : FluentWindow
{
public HistoryWindow()
{
Wpf.Ui.Appearance.SystemThemeWatcher.Watch(this);
InitializeComponent();
var entries = HistoryService.Load();
HistoryList.ItemsSource = entries;
EmptyHint.Visibility = entries.Count == 0
? System.Windows.Visibility.Visible
: System.Windows.Visibility.Collapsed;
SummaryText.Text = entries.Count == 0
? string.Empty
: $"{entries.Count} entr{(entries.Count == 1 ? "y" : "ies")} (newest first, capped at 1000)";
}
private void HistoryList_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
=> SmoothScrollHelper.HandlePreviewMouseWheel(sender, e);
}
+71
View File
@@ -0,0 +1,71 @@
<ui:FluentWindow x:Class="Futonizer.Views.ProgressWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="Stripping Tracks"
Height="500" Width="720"
MinHeight="380" MinWidth="500"
WindowStartupLocation="CenterOwner"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica"
WindowCornerPreference="Round"
Closing="Window_Closing"
Loaded="Window_Loaded">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="Stripping Tracks"/>
<Grid Grid.Row="1" Margin="16,8,16,16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Active jobs -->
<ItemsControl x:Name="ActiveJobsList" Grid.Row="0" Margin="0,0,0,10">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,0,0,6">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ProgressBar Grid.Row="0" Height="6" Minimum="0" Maximum="100" Value="{Binding Percent}"/>
<TextBlock Grid.Row="1" Margin="0,2,0,0" FontSize="11" Opacity="0.75" Text="{Binding Display}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Log -->
<Border Grid.Row="1" CornerRadius="8"
Background="{DynamicResource ControlFillColorDefaultBrush}"
BorderBrush="{DynamicResource ControlElevationBorderBrush}" BorderThickness="1">
<ui:TextBox x:Name="LogBox" IsReadOnly="True" TextWrapping="NoWrap"
AcceptsReturn="True"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
FontFamily="Cascadia Mono, Consolas" FontSize="12"
BorderThickness="0" Background="Transparent"/>
</Border>
<!-- Status + speed + button -->
<Grid Grid.Row="2" Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="StatusText" Grid.Column="0" VerticalAlignment="Center" FontWeight="SemiBold"/>
<TextBlock x:Name="TotalSpeedText" Grid.Column="1" VerticalAlignment="Center"
Margin="16,0,16,0" Opacity="0.75"/>
<ui:Button x:Name="CancelCloseButton" Grid.Column="2" Content="Cancel" Width="100"
Appearance="Secondary" Click="CancelCloseButton_Click"/>
</Grid>
</Grid>
</Grid>
</ui:FluentWindow>
+216
View File
@@ -0,0 +1,216 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Windows;
using Futonizer.Models;
using Futonizer.Services;
using Wpf.Ui.Controls;
namespace Futonizer.Views;
public partial class ProgressWindow : FluentWindow
{
private const int MaxConcurrentStrips = 5;
private readonly IReadOnlyList<QueuedFileItem> _files;
private readonly string _mkvMergePath;
private readonly string _outputFolder;
private readonly ObservableCollection<JobProgressItem> _activeJobs = new();
private CancellationTokenSource? _cts;
private bool _isDone;
/// <summary>
/// True once processing has finished running (successfully, with failures,
/// or cancelled) — i.e. whenever the Cancel button has turned into Close.
/// Used by the caller to decide whether to clear its queue after this
/// window is closed.
/// </summary>
public bool ProcessingCompleted { get; private set; }
public ProgressWindow(IReadOnlyList<QueuedFileItem> files, string mkvMergePath, string outputFolder)
{
Wpf.Ui.Appearance.SystemThemeWatcher.Watch(this);
InitializeComponent();
_files = files;
_mkvMergePath = mkvMergePath;
_outputFolder = outputFolder;
Title = $"Stripping {files.Count} file{(files.Count == 1 ? "" : "s")}";
ActiveJobsList.ItemsSource = _activeJobs;
}
private void Window_Loaded(object sender, RoutedEventArgs e)
{
_ = StartProcessingAsync();
}
private void Window_Closing(object sender, CancelEventArgs e)
{
_cts?.Cancel();
}
private void CancelCloseButton_Click(object sender, RoutedEventArgs e)
{
if (_isDone)
{
Close();
return;
}
_cts?.Cancel();
CancelCloseButton.IsEnabled = false;
StatusText.Text = "Cancelling...";
}
private void AppendLog(string line)
{
LogBox.AppendText(line + Environment.NewLine);
LogBox.ScrollToEnd();
}
private void UpdateTotalSpeed()
{
if (_activeJobs.Count == 0)
{
TotalSpeedText.Text = string.Empty;
return;
}
double total = _activeJobs.Sum(j => j.MegabytesPerSecond);
TotalSpeedText.Text = _activeJobs.Count == 1
? $"{total:F1} MB/s"
: $"{total:F1} MB/s across {_activeJobs.Count} file(s)";
}
private async Task StartProcessingAsync()
{
StatusText.Text = "Stripping tracks...";
_cts = new CancellationTokenSource();
var ct = _cts.Token;
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(new string('-', 80));
int successCount = 0;
long totalBytesWritten = 0;
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
using var semaphore = new SemaphoreSlim(MaxConcurrentStrips);
var stripTasks = _files.Select(async file =>
{
await semaphore.WaitAsync(ct);
var job = new JobProgressItem { FileName = file.FileName };
job.PropertyChanged += (_, args) =>
{
if (args.PropertyName == nameof(JobProgressItem.MegabytesPerSecond))
UpdateTotalSpeed();
};
_activeJobs.Add(job);
UpdateTotalSpeed();
try
{
string outputFilePath = overwritingInPlace
? file.FilePath
: Path.Combine(_outputFolder, file.FileName);
var videoIds = file.Tracks
.Where(t => t.Type == "video" && t.Copy)
.Select(t => t.Id).ToList();
var audioIds = file.Tracks
.Where(t => t.Type == "audio" && t.Copy)
.Select(t => t.Id).ToList();
var subIds = file.Tracks
.Where(t => t.Type == "subtitles" && t.Copy)
.Select(t => t.Id).ToList();
bool nothingToStrip = file.Tracks.All(t => t.Copy);
var progress = new Progress<ProcessingProgress>(p =>
{
job.Percent = p.PercentComplete;
job.MegabytesPerSecond = p.MegabytesPerSecond;
});
var result = nothingToStrip
? await service.CopyFileAsync(file.FilePath, outputFilePath, progress, ct)
: await service.StripTracksAsync(
file.FilePath, outputFilePath,
videoIds, audioIds, subIds,
progress, ct);
string tag = result.Success ? "[DONE]" : "[FAIL]";
AppendLog($"{tag} {result.FileName} — {result.Message}");
HistoryService.Add(new HistoryEntry
{
Timestamp = DateTime.Now,
FileName = result.FileName,
FilePath = file.FilePath,
Success = result.Success,
Message = result.Message,
});
if (result.Success)
{
successCount++;
try
{
long bytes = new FileInfo(outputFilePath).Length;
Interlocked.Add(ref totalBytesWritten, bytes);
}
catch { /* best effort */ }
}
}
finally
{
_activeJobs.Remove(job);
UpdateTotalSpeed();
semaphore.Release();
}
}).ToList();
await Task.WhenAll(stripTasks);
AppendLog(new string('-', 80));
AppendLog($"Processing complete: {successCount}/{_files.Count} file(s) succeeded.");
stopwatch.Stop();
double totalSec = stopwatch.Elapsed.TotalSeconds;
double totalMb = totalBytesWritten / 1024.0 / 1024.0;
double avgMbps = totalSec > 0 ? totalMb / totalSec : 0;
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.";
}
catch (OperationCanceledException)
{
AppendLog(new string('-', 80));
AppendLog("Cancelled by user. Nothing further was modified.");
StatusText.Text = "Cancelled.";
}
catch (Exception ex)
{
AppendLog($"Unexpected error: {ex.Message}");
StatusText.Text = "Error.";
}
finally
{
_activeJobs.Clear();
TotalSpeedText.Text = string.Empty;
_cts?.Dispose();
_cts = null;
_isDone = true;
ProcessingCompleted = true;
CancelCloseButton.IsEnabled = true;
CancelCloseButton.Content = "Close";
}
}
}
+72
View File
@@ -0,0 +1,72 @@
<ui:FluentWindow x:Class="Futonizer.Views.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="Settings"
Height="320" Width="540"
MinHeight="320" MinWidth="540"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica"
WindowCornerPreference="Round">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="Settings"/>
<Grid Grid.Row="1" Margin="20,8,20,16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- mkvmerge path -->
<TextBlock Grid.Row="0" Text="mkvmerge.exe path" FontSize="12" Opacity="0.7" Margin="0,0,0,4"/>
<Grid Grid.Row="1" Margin="0,0,0,14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ui:TextBox x:Name="MkvMergePathBox" Grid.Column="0" Margin="0,0,8,0"
PlaceholderText="Path to mkvmerge.exe"/>
<ui:Button x:Name="BrowseMkvMergeButton" Grid.Column="1" Content="Browse..." Width="100"
Click="BrowseMkvMergeButton_Click"/>
</Grid>
<!-- Output folder -->
<TextBlock Grid.Row="2" Text="Output folder (optional — blank overwrites files in place)" FontSize="12" Opacity="0.7" Margin="0,0,0,4"/>
<Grid Grid.Row="3" Margin="0,0,0,14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ui:TextBox x:Name="OutputFolderBox" Grid.Column="0" Margin="0,0,8,0"
PlaceholderText="Leave blank to overwrite original files"/>
<ui:Button x:Name="BrowseOutputFolderButton" Grid.Column="1" Content="Browse..." Width="100"
Click="BrowseOutputFolderButton_Click"/>
</Grid>
<!-- History -->
<ui:Button x:Name="ViewHistoryButton" Grid.Row="4" Content="View History..."
HorizontalAlignment="Left" Appearance="Secondary"
Click="ViewHistoryButton_Click"/>
<!-- Buttons -->
<StackPanel Grid.Row="6" Orientation="Horizontal" HorizontalAlignment="Right">
<ui:Button x:Name="CancelButton" Content="Cancel" Width="90" Margin="0,0,10,0"
Appearance="Secondary" Click="CancelButton_Click"/>
<ui:Button x:Name="SaveButton" Content="Save" Width="90"
Appearance="Primary" Click="SaveButton_Click"/>
</StackPanel>
</Grid>
</Grid>
</ui:FluentWindow>
+60
View File
@@ -0,0 +1,60 @@
using System.IO;
using System.Windows;
using Microsoft.Win32;
using Futonizer.Models;
using Wpf.Ui.Controls;
namespace Futonizer.Views;
public partial class SettingsWindow : FluentWindow
{
public AppSettings? Result { get; private set; }
public SettingsWindow(AppSettings current)
{
Wpf.Ui.Appearance.SystemThemeWatcher.Watch(this);
InitializeComponent();
MkvMergePathBox.Text = current.MkvMergePath;
OutputFolderBox.Text = current.OutputFolder;
}
private void BrowseMkvMergeButton_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog
{
Title = "Locate mkvmerge.exe",
Filter = "mkvmerge.exe|mkvmerge.exe|Executable files (*.exe)|*.exe|All files (*.*)|*.*",
CheckFileExists = true,
};
if (dlg.ShowDialog(this) == true)
MkvMergePathBox.Text = dlg.FileName;
}
private void BrowseOutputFolderButton_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFolderDialog { Title = "Select output folder" };
if (dlg.ShowDialog(this) == true)
OutputFolderBox.Text = dlg.FolderName;
}
private void SaveButton_Click(object sender, RoutedEventArgs e)
{
Result = new AppSettings
{
MkvMergePath = MkvMergePathBox.Text.Trim(),
OutputFolder = OutputFolderBox.Text.Trim(),
};
DialogResult = true;
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
}
private void ViewHistoryButton_Click(object sender, RoutedEventArgs e)
{
var win = new HistoryWindow { Owner = this };
win.ShowDialog();
}
}
+174
View File
@@ -0,0 +1,174 @@
Add-Type -AssemblyName System.Drawing
function New-AnimeEyeBitmap {
param([int]$Size)
$bmp = New-Object System.Drawing.Bitmap($Size, $Size)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
$g.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality
$g.Clear([System.Drawing.Color]::Transparent)
$s = $Size
$rect = New-Object System.Drawing.Rectangle(0, 0, $s, $s)
$radius = [Math]::Max(2, [int]($s * 0.22))
# --- rounded-square background gradient (indigo -> violet -> magenta, anime-ish) ---
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
$d = $radius * 2
$path.AddArc(0, 0, $d, $d, 180, 90)
$path.AddArc($s - $d, 0, $d, $d, 270, 90)
$path.AddArc($s - $d, $s - $d, $d, $d, 0, 90)
$path.AddArc(0, $s - $d, $d, $d, 90, 90)
$path.CloseFigure()
$bgBrush = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
$rect,
[System.Drawing.Color]::FromArgb(255, 40, 20, 90),
[System.Drawing.Color]::FromArgb(255, 150, 40, 130),
[System.Drawing.Drawing2D.LinearGradientMode]::ForwardDiagonal)
$g.FillPath($bgBrush, $path)
# subtle top highlight
$topRect = New-Object System.Drawing.Rectangle(0, 0, $s, [int]($s * 0.55))
$topBrush = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
$topRect,
[System.Drawing.Color]::FromArgb(60, 255, 255, 255),
[System.Drawing.Color]::FromArgb(0, 255, 255, 255),
[System.Drawing.Drawing2D.LinearGradientMode]::Vertical)
$oldClip = $g.Clip
$g.SetClip($path)
$g.FillRectangle($topBrush, $topRect)
$g.Clip = $oldClip
# --- anime eye ---
$cx = $s * 0.5
$cy = $s * 0.46
$eyeW = $s * 0.72
$eyeH = $s * 0.46
# white of the eye
$eyePath = New-Object System.Drawing.Drawing2D.GraphicsPath
$eyeRect = New-Object System.Drawing.RectangleF(($cx - $eyeW/2), ($cy - $eyeH/2), $eyeW, $eyeH)
$eyePath.AddEllipse($eyeRect)
$whiteBrush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(250, 255, 255, 255))
$g.FillPath($whiteBrush, $eyePath)
# upper eyelid line (thick, sweeping) + eyelash accent
$lidPen = New-Object System.Drawing.Pen([System.Drawing.Color]::FromArgb(255, 25, 15, 40), [Math]::Max(1.0, $s * 0.045))
$lidPen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
$lidPen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
$lidRect = New-Object System.Drawing.RectangleF(($cx - $eyeW/2), ($cy - $eyeH/2 - $s*0.02), $eyeW, $eyeH*1.1)
$g.DrawArc($lidPen, $lidRect, 195, 150)
$lashX = $cx + $eyeW/2 * 0.92
$lashY = $cy - $eyeH/2 * 0.55
$g.DrawLine($lidPen, $lashX, $lashY, $lashX + $s*0.06, $lashY - $s*0.10)
# iris (gradient blue -> violet with highlight)
$irisSize = $eyeH * 0.95
$irisRect = New-Object System.Drawing.RectangleF(($cx - $irisSize/2), ($cy - $irisSize/2), $irisSize, $irisSize)
$irisBrush = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
$irisRect,
[System.Drawing.Color]::FromArgb(255, 60, 190, 255),
[System.Drawing.Color]::FromArgb(255, 130, 60, 220),
[System.Drawing.Drawing2D.LinearGradientMode]::Vertical)
$g.FillEllipse($irisBrush, $irisRect)
# pupil
$pupilSize = $irisSize * 0.42
$pupilRect = New-Object System.Drawing.RectangleF(($cx - $pupilSize/2), ($cy - $pupilSize/2), $pupilSize, $pupilSize)
$g.FillEllipse([System.Drawing.Brushes]::Black, $pupilRect)
# ring accent inside iris
$ringPen = New-Object System.Drawing.Pen([System.Drawing.Color]::FromArgb(150, 20, 20, 40), [Math]::Max(1.0, $s*0.012))
$ringSize = $irisSize * 0.72
$ringRect = New-Object System.Drawing.RectangleF(($cx - $ringSize/2), ($cy - $ringSize/2), $ringSize, $ringSize)
$g.DrawEllipse($ringPen, $ringRect)
# big specular highlight
$hlSize = $irisSize * 0.32
$hlRect = New-Object System.Drawing.RectangleF(($cx - $irisSize*0.28), ($cy - $irisSize*0.32), $hlSize, $hlSize)
$g.FillEllipse([System.Drawing.Brushes]::White, $hlRect)
# small secondary sparkle
$sp2Size = $irisSize * 0.14
$sp2Rect = New-Object System.Drawing.RectangleF(($cx + $irisSize*0.10), ($cy + $irisSize*0.18), $sp2Size, $sp2Size)
$g.FillEllipse([System.Drawing.Brushes]::White, $sp2Rect)
# --- "subtitle bar" accent motif near the bottom ---
$barY = $s * 0.80
$barH = [Math]::Max(1.0, $s * 0.07)
$barW1 = $s * 0.5
$bar1 = New-Object System.Drawing.RectangleF(($cx - $barW1/2), $barY, $barW1, $barH)
$barBrush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(235, 255, 255, 255))
$g.FillRectangle($barBrush, $bar1)
$g.Dispose()
return $bmp
}
function Get-PngBytes($bmp) {
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
return [byte[]]$ms.ToArray()
}
$sizes = @(16, 32, 48, 256)
$pngList = @()
foreach ($sz in $sizes) {
$bmp = New-AnimeEyeBitmap -Size $sz
$bytes = Get-PngBytes $bmp
$pngList += ,@{ Size = $sz; Bytes = $bytes }
$bmp.Dispose()
}
# Save the 256px version separately as a standalone PNG for use as ui:ImageIcon source.
$png256 = ($pngList | Where-Object { $_.Size -eq 256 }).Bytes
[System.IO.File]::WriteAllBytes("Assets/app.png", [byte[]]$png256)
# --- Manually pack a multi-size .ico (ICONDIR + ICONDIRENTRY headers wrapping raw PNG bytes) ---
$icoPath = "Assets/app.ico"
$fs = New-Object System.IO.FileStream($icoPath, [System.IO.FileMode]::Create)
$bw = New-Object System.IO.BinaryWriter($fs)
# ICONDIR: reserved(2)=0, type(2)=1 (icon), count(2)
$bw.Write([UInt16]0)
$bw.Write([UInt16]1)
$bw.Write([UInt16]$pngList.Count)
$headerSize = 6
$entrySize = 16
$offset = $headerSize + ($entrySize * $pngList.Count)
foreach ($entry in $pngList) {
$sz = $entry.Size
$byteLen = $entry.Bytes.Length
$widthByte = if ($sz -ge 256) { 0 } else { $sz }
$heightByte = if ($sz -ge 256) { 0 } else { $sz }
$bw.Write([byte]$widthByte) # width (0 = 256)
$bw.Write([byte]$heightByte) # height (0 = 256)
$bw.Write([byte]0) # color palette
$bw.Write([byte]0) # reserved
$bw.Write([UInt16]1) # color planes
$bw.Write([UInt16]32) # bits per pixel
$bw.Write([UInt32]$byteLen) # size of image data
$bw.Write([UInt32]$offset) # offset of image data
$offset += $byteLen
}
# NOTE: the explicit [byte[]] cast below is required - without it, PowerShell's
# method-overload binding can silently resolve BinaryWriter.Write() to the wrong
# overload and write garbage instead of the actual PNG bytes.
foreach ($entry in $pngList) {
$bw.Write([byte[]]$entry.Bytes)
}
$bw.Flush()
$bw.Close()
$fs.Close()
Write-Output "Icon generated: $icoPath ($((Get-Item $icoPath).Length) bytes)"
Write-Output "PNG generated: Assets/app.png"