Initial commit: Futonizer MKV track stripper
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
history.json
|
||||
settings.json
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace Futonizer;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -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
@@ -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 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="⟳" FontSize="12"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Grid.Column="0" Text="●" FontSize="9"
|
||||
Opacity="0.5" VerticalAlignment="Center"
|
||||
Visibility="{Binding IsLoadedNeutral, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Grid.Column="0" Text="✓" FontSize="12"
|
||||
Foreground="#4CAF50" VerticalAlignment="Center"
|
||||
Visibility="{Binding IsCorrect, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Grid.Column="0" Text="✗" 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="×"
|
||||
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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Futonizer.Models;
|
||||
|
||||
public class AppSettings
|
||||
{
|
||||
public string MkvMergePath { get; set; } = string.Empty;
|
||||
public string OutputFolder { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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="✓" FontSize="12"
|
||||
Foreground="#4CAF50" VerticalAlignment="Center"
|
||||
Visibility="{Binding Success, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Grid.Column="0" Text="✗" 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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user