Initial commit: Futonizer MKV track stripper
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user