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 _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(); } /// /// 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. /// 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; } /// /// 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. /// 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 ───────────────────────────────────────── /// /// 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. /// 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; } /// /// Removes every currently-selected item from the queue (multi-select /// via Ctrl/Shift-click), for batch removal without clearing everything. /// 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().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(); } /// /// 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. /// 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(); } }