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