515 lines
18 KiB
C#
515 lines
18 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.ComponentModel;
|
|
using System.Diagnostics;
|
|
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 = 10;
|
|
private const int MaxConcurrentStrips = 3;
|
|
|
|
private AppSettings _settings = new();
|
|
private readonly ObservableCollection<QueuedFileItem> _fileQueue = new();
|
|
private readonly SemaphoreSlim _scanSemaphore = new(MaxConcurrentScans);
|
|
private bool _isProcessing;
|
|
private bool _propagatingSelection;
|
|
|
|
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 static void RestartApplication()
|
|
{
|
|
try
|
|
{
|
|
string? exePath = Environment.ProcessPath;
|
|
if (!string.IsNullOrEmpty(exePath))
|
|
Process.Start(exePath);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
System.Windows.Application.Current.Shutdown();
|
|
}
|
|
|
|
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 allCorrect = _fileQueue.Count > 0 && _fileQueue.All(f => f.IsCorrect);
|
|
RunButton.IsEnabled = !_isProcessing && !anyLoading && allCorrect;
|
|
RunButton.ToolTip = allCorrect
|
|
? null
|
|
: "Every file in the queue needs exactly one audio and one subtitle track selected before 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);
|
|
RestartApplication();
|
|
}
|
|
}
|
|
|
|
// ── 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 is "audio" or "subtitles")
|
|
{
|
|
t.PropertyChanged += (s, e) => OnTrackSelectionChanged(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();
|
|
}
|
|
}
|
|
|
|
// ── Audio/subtitle selection propagation ────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Propagates audio/subtitle selection/deselection to every other file in
|
|
/// the queue:
|
|
/// - Checking a track matches it to other files by its exact
|
|
/// "Codec/Name" identity:
|
|
/// - If the source file had no other track of the same type
|
|
/// selected before this check ("adding"), other files that don't
|
|
/// have a matching track are left untouched.
|
|
/// - If the source file had a different track of the same type
|
|
/// selected before this check ("overriding"), other files that
|
|
/// don't have a matching track have their selection cleared.
|
|
/// - Unchecking a track clears the selection of that type in every
|
|
/// other file too, so a deselection always applies everywhere.
|
|
/// Within the source file, only one track of a given type may ever be
|
|
/// selected.
|
|
/// </summary>
|
|
private void OnTrackSelectionChanged(QueuedFileItem sourceFile, TrackItem track, PropertyChangedEventArgs e)
|
|
{
|
|
if (_propagatingSelection) return;
|
|
if (e.PropertyName != nameof(TrackItem.Copy)) return;
|
|
|
|
string type = track.Type;
|
|
|
|
_propagatingSelection = true;
|
|
try
|
|
{
|
|
if (track.Copy)
|
|
{
|
|
bool wasOverriding = sourceFile.Tracks.Any(t => t.Type == type && t != track && t.Copy);
|
|
|
|
foreach (var other in sourceFile.Tracks.Where(t => t.Type == type && 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 == type && t.PropagationKey == key);
|
|
if (match != null)
|
|
{
|
|
foreach (var sub in otherFile.Tracks.Where(t => t.Type == type))
|
|
{
|
|
sub.Copy = sub == match;
|
|
sub.DefaultTrack = sub == match;
|
|
}
|
|
}
|
|
else if (wasOverriding)
|
|
{
|
|
foreach (var sub in otherFile.Tracks.Where(t => t.Type == type))
|
|
{
|
|
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 == type))
|
|
{
|
|
sub.Copy = false;
|
|
sub.DefaultTrack = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_propagatingSelection = 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.Space)
|
|
{
|
|
OpenSelectedFileInDefaultPlayer();
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens the currently-selected queue item with whatever application
|
|
/// Windows has associated with .mkv files (typically the default video
|
|
/// player), so the user can quickly preview a file without leaving the app.
|
|
/// </summary>
|
|
private void OpenSelectedFileInDefaultPlayer()
|
|
{
|
|
if (FileQueueList.SelectedItem is not QueuedFileItem item) return;
|
|
|
|
try
|
|
{
|
|
Process.Start(new ProcessStartInfo(item.FilePath) { UseShellExecute = true });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusText.Text = $"Couldn't open {item.FileName}: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
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, MaxConcurrentStrips)
|
|
{
|
|
Owner = this
|
|
};
|
|
progressWin.Closed += (_, _) =>
|
|
{
|
|
_isProcessing = false;
|
|
if (progressWin.ShouldClearQueue)
|
|
{
|
|
foreach (var item in _fileQueue)
|
|
item.PropertyChanged -= QueuedFileItem_PropertyChanged;
|
|
_fileQueue.Clear();
|
|
}
|
|
UpdateLoadingOverlay();
|
|
};
|
|
progressWin.Show();
|
|
}
|
|
}
|