Initial commit: Futonizer MKV track stripper

This commit is contained in:
l4kr
2026-07-04 00:43:37 +02:00
commit 1208cadc3b
25 changed files with 2629 additions and 0 deletions
+468
View File
@@ -0,0 +1,468 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using Futonizer.Models;
using Futonizer.Services;
using Futonizer.Views;
using Wpf.Ui.Controls;
namespace Futonizer;
public partial class MainWindow : FluentWindow
{
private const int MaxConcurrentScans = 4;
private AppSettings _settings = new();
private readonly ObservableCollection<QueuedFileItem> _fileQueue = new();
private readonly SemaphoreSlim _scanSemaphore = new(MaxConcurrentScans);
private bool _isProcessing;
private bool _propagatingSubtitleSelection;
public MainWindow()
{
Wpf.Ui.Appearance.SystemThemeWatcher.Watch(this);
InitializeComponent();
FileQueueList.ItemsSource = _fileQueue;
_fileQueue.CollectionChanged += (_, _) => UpdateDropHint();
LoadSettingsIntoUi();
Closing += (_, _) => SaveSettingsFromUi();
Loaded += MainWindow_Loaded;
}
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
EnsureRequiredSettingsConfigured();
}
/// <summary>
/// Warns the user and opens Settings if mkvmerge.exe isn't configured (or
/// no longer exists), or if a previously chosen output folder has since
/// disappeared. Runs once on startup; does nothing once everything is
/// already valid.
/// </summary>
private void EnsureRequiredSettingsConfigured()
{
bool mkvMissing = string.IsNullOrWhiteSpace(_settings.MkvMergePath) || !File.Exists(_settings.MkvMergePath);
bool outputInvalid = !string.IsNullOrWhiteSpace(_settings.OutputFolder) && !Directory.Exists(_settings.OutputFolder);
if (!mkvMissing && !outputInvalid) return;
string message = mkvMissing
? "mkvmerge.exe could not be found. Please locate it to enable stripping tracks."
: "The previously configured output folder no longer exists. Please choose a new one, or leave it blank to overwrite files in place.";
System.Windows.MessageBox.Show(this, message, "Futonizer — Setup required",
System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Warning);
if (outputInvalid)
_settings.OutputFolder = string.Empty;
var win = new SettingsWindow(_settings) { Owner = this };
if (win.ShowDialog() == true && win.Result != null)
{
_settings.MkvMergePath = win.Result.MkvMergePath;
_settings.OutputFolder = win.Result.OutputFolder;
SettingsService.Save(_settings);
}
}
// ── Helpers ──────────────────────────────────────────────────────────────
private void UpdateDropHint()
{
bool empty = _fileQueue.Count == 0;
DropHint.Visibility = empty ? Visibility.Visible : Visibility.Collapsed;
ClearQueueButton.IsEnabled = !empty;
}
/// <summary>
/// Shows a blocking overlay with a simple "loaded / total" progress bar
/// while any queued file is still being scanned, and keeps the Run button
/// disabled until every dropped file has finished loading.
/// </summary>
private void UpdateLoadingOverlay()
{
int total = _fileQueue.Count;
int loaded = _fileQueue.Count(f => !f.IsLoading);
if (total > 0 && loaded < total)
{
LoadingOverlay.Visibility = Visibility.Visible;
LoadingProgressBar.Maximum = total;
LoadingProgressBar.Value = loaded;
LoadingOverlayText.Text = $"Loading files... ({loaded}/{total})";
}
else
{
LoadingOverlay.Visibility = Visibility.Collapsed;
}
UpdateRunButtonEnabled();
}
private void UpdateRunButtonEnabled()
{
bool anyLoading = _fileQueue.Any(f => f.IsLoading);
bool anySubtitleSelected = _fileQueue.Any(f => f.Tracks.Any(t => t.Type == "subtitles" && t.Copy));
RunButton.IsEnabled = !_isProcessing && !anyLoading && _fileQueue.Count > 0 && anySubtitleSelected;
RunButton.ToolTip = anySubtitleSelected
? null
: "Select a subtitle track in the track table to enable stripping.";
}
private void QueuedFileItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(QueuedFileItem.IsLoading))
UpdateLoadingOverlay();
if (e.PropertyName == nameof(QueuedFileItem.LoadState))
UpdateRunButtonEnabled();
}
private static string GuessDefaultMkvMergePath()
{
string[] candidates =
{
@"C:\Program Files\MKVToolNix\mkvmerge.exe",
@"C:\Program Files (x86)\MKVToolNix\mkvmerge.exe",
};
return candidates.FirstOrDefault(File.Exists) ?? string.Empty;
}
// ── Settings persistence ──────────────────────────────────────────────────
private void LoadSettingsIntoUi()
{
_settings = SettingsService.Load();
if (string.IsNullOrWhiteSpace(_settings.MkvMergePath))
_settings.MkvMergePath = GuessDefaultMkvMergePath();
}
private void SaveSettingsFromUi()
{
SettingsService.Save(_settings);
}
// ── Settings button ───────────────────────────────────────────────────────
private void SettingsButton_Click(object sender, RoutedEventArgs e)
{
var win = new SettingsWindow(_settings) { Owner = this };
if (win.ShowDialog() == true && win.Result != null)
{
_settings.MkvMergePath = win.Result.MkvMergePath;
_settings.OutputFolder = win.Result.OutputFolder;
SettingsService.Save(_settings);
}
}
// ── Drag & Drop ───────────────────────────────────────────────────────────
private void Window_DragOver(object sender, DragEventArgs e)
{
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
bool hasMkv = paths.Any(p =>
string.Equals(Path.GetExtension(p), ".mkv", StringComparison.OrdinalIgnoreCase)
&& File.Exists(p));
e.Effects = hasMkv ? DragDropEffects.Copy : DragDropEffects.None;
}
else
{
e.Effects = DragDropEffects.None;
}
e.Handled = true;
}
private void Window_Drop(object sender, DragEventArgs e)
{
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
Activate();
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
var newFiles = paths
.Where(p => string.Equals(Path.GetExtension(p), ".mkv", StringComparison.OrdinalIgnoreCase)
&& File.Exists(p)
&& !_fileQueue.Any(q => string.Equals(q.FilePath, p, StringComparison.OrdinalIgnoreCase)))
.ToList();
foreach (var path in newFiles)
{
var item = new QueuedFileItem(path);
item.PropertyChanged += QueuedFileItem_PropertyChanged;
_fileQueue.Add(item);
_ = ScanFileItemAsync(item);
}
UpdateLoadingOverlay();
if (newFiles.Count > 0)
{
var first = _fileQueue.FirstOrDefault(q =>
string.Equals(q.FilePath, newFiles[0], StringComparison.OrdinalIgnoreCase));
if (first != null) FileQueueList.SelectedItem = first;
}
}
// ── File scanning ─────────────────────────────────────────────────────────
private async Task ScanFileItemAsync(QueuedFileItem item)
{
item.IsScanning = true;
item.Status = "Scanning...";
item.Tracks.Clear();
await _scanSemaphore.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(_settings.MkvMergePath) || !File.Exists(_settings.MkvMergePath))
{
item.IsScanning = false;
item.HasError = true;
item.Status = "mkvmerge.exe not found — open Settings";
return;
}
var service = new MkvService(_settings.MkvMergePath);
var info = await service.IdentifyAsync(item.FilePath);
var tracks = service.BuildTrackItems(info);
foreach (var t in tracks)
{
item.Tracks.Add(t);
if (t.Type == "subtitles")
{
t.PropertyChanged += (s, e) => OnSubtitleTrackPropertyChanged(item, (TrackItem)s!, e);
}
}
item.IsScanning = false;
item.Status = $"{tracks.Count} track{(tracks.Count == 1 ? "" : "s")}";
}
catch (Exception ex)
{
item.IsScanning = false;
item.HasError = true;
item.Status = ex.Message;
}
finally
{
_scanSemaphore.Release();
}
}
// ── Subtitle selection propagation ─────────────────────────────────────────
/// <summary>
/// Propagates subtitle selection/deselection to every other file in the
/// queue:
/// - Checking a subtitle matches it to other files by its exact
/// "Codec/Name" identity:
/// - If the source file had no other subtitle selected before this
/// check ("adding"), other files that don't have a matching track
/// are left untouched.
/// - If the source file had a different subtitle selected before
/// this check ("overriding"), other files that don't have a
/// matching track have their subtitle selection cleared.
/// - Unchecking a subtitle clears the subtitle selection in every other
/// file too, so a deselection always applies everywhere.
/// Within the source file, only one subtitle may ever be selected.
/// </summary>
private void OnSubtitleTrackPropertyChanged(QueuedFileItem sourceFile, TrackItem track, PropertyChangedEventArgs e)
{
if (_propagatingSubtitleSelection) return;
if (e.PropertyName != nameof(TrackItem.Copy)) return;
_propagatingSubtitleSelection = true;
try
{
if (track.Copy)
{
bool wasOverriding = sourceFile.Tracks.Any(t => t.Type == "subtitles" && t != track && t.Copy);
foreach (var other in sourceFile.Tracks.Where(t => t.Type == "subtitles" && t != track))
{
other.Copy = false;
other.DefaultTrack = false;
}
track.DefaultTrack = true;
string key = track.PropagationKey;
foreach (var otherFile in _fileQueue.Where(f => f != sourceFile))
{
var match = otherFile.Tracks.FirstOrDefault(t => t.Type == "subtitles" && t.PropagationKey == key);
if (match != null)
{
foreach (var sub in otherFile.Tracks.Where(t => t.Type == "subtitles"))
{
sub.Copy = sub == match;
sub.DefaultTrack = sub == match;
}
}
else if (wasOverriding)
{
foreach (var sub in otherFile.Tracks.Where(t => t.Type == "subtitles"))
{
sub.Copy = false;
sub.DefaultTrack = false;
}
}
}
}
else
{
track.DefaultTrack = false;
foreach (var otherFile in _fileQueue.Where(f => f != sourceFile))
{
foreach (var sub in otherFile.Tracks.Where(t => t.Type == "subtitles"))
{
sub.Copy = false;
sub.DefaultTrack = false;
}
}
}
}
finally
{
_propagatingSubtitleSelection = false;
}
}
// ── File queue UI ─────────────────────────────────────────────────────────
private void RemoveFile_Click(object sender, RoutedEventArgs e)
{
if (sender is System.Windows.Controls.Button btn && btn.Tag is QueuedFileItem item)
{
item.PropertyChanged -= QueuedFileItem_PropertyChanged;
_fileQueue.Remove(item);
UpdateLoadingOverlay();
}
e.Handled = true;
}
/// <summary>
/// Removes every currently-selected item from the queue (multi-select
/// via Ctrl/Shift-click), for batch removal without clearing everything.
/// </summary>
private void FileQueueList_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key != System.Windows.Input.Key.Delete) return;
var selected = FileQueueList.SelectedItems.Cast<QueuedFileItem>().ToList();
if (selected.Count == 0) return;
foreach (var item in selected)
{
item.PropertyChanged -= QueuedFileItem_PropertyChanged;
_fileQueue.Remove(item);
}
UpdateLoadingOverlay();
e.Handled = true;
}
private void ClearQueueButton_Click(object sender, RoutedEventArgs e)
{
foreach (var item in _fileQueue)
item.PropertyChanged -= QueuedFileItem_PropertyChanged;
_fileQueue.Clear();
UpdateLoadingOverlay();
}
/// <summary>
/// Scrolls the track grid or queue list by an amount proportional to the
/// wheel delta instead of relying on WPF's default per-event line
/// scrolling. Shared by both the file queue and track table so both
/// panes scroll consistently.
/// </summary>
private void SmoothList_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
=> SmoothScrollHelper.HandlePreviewMouseWheel(sender, e);
private void FileQueueList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (FileQueueList.SelectedItem is QueuedFileItem file)
{
TrackGrid.ItemsSource = file.Tracks;
TrackGrid.Visibility = Visibility.Visible;
NoSelectionHint.Visibility = Visibility.Collapsed;
}
else
{
TrackGrid.ItemsSource = null;
TrackGrid.Visibility = Visibility.Collapsed;
NoSelectionHint.Visibility = Visibility.Visible;
}
}
// ── Keyboard shortcuts ───────────────────────────────────────────
private void Window_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key == System.Windows.Input.Key.R
&& Keyboard.Modifiers == ModifierKeys.Control
&& RunButton.IsEnabled)
{
RunButton_Click(RunButton, new RoutedEventArgs());
e.Handled = true;
}
}
// ── Run ─────────────────────────────────────────────────────────────────────
private void RunButton_Click(object sender, RoutedEventArgs e)
{
if (_isProcessing) return;
if (_fileQueue.Any(f => f.IsLoading)) return;
if (string.IsNullOrWhiteSpace(_settings.MkvMergePath) || !File.Exists(_settings.MkvMergePath))
{
StatusText.Text = "mkvmerge.exe not set — open Settings.";
return;
}
var readyFiles = _fileQueue
.Where(f => f.IsCorrect)
.ToList();
if (readyFiles.Count == 0)
{
StatusText.Text = _fileQueue.Count == 0
? "No files in queue."
: "No files ready (need exactly one audio + one subtitle selected).";
return;
}
SaveSettingsFromUi();
_isProcessing = true;
RunButton.IsEnabled = false;
StatusText.Text = string.Empty;
var progressWin = new ProgressWindow(readyFiles, _settings.MkvMergePath, _settings.OutputFolder)
{
Owner = this
};
progressWin.Closed += (_, _) =>
{
_isProcessing = false;
if (progressWin.ProcessingCompleted)
{
foreach (var item in _fileQueue)
item.PropertyChanged -= QueuedFileItem_PropertyChanged;
_fileQueue.Clear();
}
UpdateLoadingOverlay();
};
progressWin.Show();
}
}