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 _fileQueue = new(); private readonly List _pendingSubtitles = 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(); } /// /// 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 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; } /// /// 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 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."; UpdateRunButtonLabel(); } private void UpdateRunButtonLabel() { bool anyRename = _fileQueue.Any(f => !string.Equals(f.DisplayFileName, f.FileName, StringComparison.Ordinal)); RunButton.Content = anyRename ? "Rename and Strip Tracks" : "Strip Tracks"; } 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 hasAcceptedFile = paths.Any(p => File.Exists(p) && (IsMkvFile(p) || SubtitleMatcher.IsSubtitleFile(p))); e.Effects = hasAcceptedFile ? DragDropEffects.Copy : DragDropEffects.None; } else { e.Effects = DragDropEffects.None; } e.Handled = true; } private static bool IsMkvFile(string path) => string.Equals(Path.GetExtension(path), ".mkv", StringComparison.OrdinalIgnoreCase); 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 newVideoPaths = paths .Where(p => IsMkvFile(p) && File.Exists(p) && !_fileQueue.Any(q => string.Equals(q.FilePath, p, StringComparison.OrdinalIgnoreCase))) .ToList(); var subtitlePaths = paths .Where(p => SubtitleMatcher.IsSubtitleFile(p) && File.Exists(p)) .ToList(); var newItems = new List(); foreach (var path in newVideoPaths) { var item = new QueuedFileItem(path); item.PropertyChanged += QueuedFileItem_PropertyChanged; _fileQueue.Add(item); newItems.Add(item); _ = ScanFileItemAsync(item); } // Newly-added videos might satisfy subtitles dropped in earlier (or // in this same batch, if the subtitle sorts before its video). foreach (var item in newItems) MatchPendingSubtitlesToFile(item); int matchedSubtitles = 0; foreach (var subPath in subtitlePaths) { if (MatchSubtitleToQueue(subPath)) matchedSubtitles++; else if (!_pendingSubtitles.Contains(subPath, StringComparer.OrdinalIgnoreCase)) _pendingSubtitles.Add(subPath); } UpdateLoadingOverlay(); if (newItems.Count > 0) { var first = _fileQueue.FirstOrDefault(q => string.Equals(q.FilePath, newVideoPaths[0], StringComparison.OrdinalIgnoreCase)); if (first != null) FileQueueList.SelectedItem = first; } int unmatchedSubtitles = subtitlePaths.Count - matchedSubtitles; if (matchedSubtitles > 0 || unmatchedSubtitles > 0) { StatusText.Text = unmatchedSubtitles > 0 ? $"Matched {matchedSubtitles} subtitle(s); {unmatchedSubtitles} couldn't be matched to an episode (S04E06, E06, or plain episode number like 06/6/16)." : $"Matched {matchedSubtitles} subtitle(s) to their episode."; } } // ── Subtitle matching ─────────────────────────────────────────────────── /// /// Attempts to attach a dropped subtitle file to whichever queued video(s) /// share the same episode. Prefers a full season+episode code (e.g. /// S04E06) when the subtitle's filename has one; otherwise falls back to /// matching by bare episode number alone (e.g. "06", "6", "16", "E06"), /// season-agnostic. Falls back further to the single queued file when /// there's exactly one and no episode info at all could be extracted /// from the subtitle's filename. Returns false if no target could be /// determined, in which case the file is kept pending until a matching /// video is dropped later. /// private bool MatchSubtitleToQueue(string subtitlePath) { string subFileName = Path.GetFileName(subtitlePath); string? subCode = SubtitleMatcher.ExtractEpisodeCode(subFileName); int? subEpisodeNumber = subCode == null ? SubtitleMatcher.ExtractEpisodeNumber(subFileName) : null; List targets; if (subCode != null) { targets = _fileQueue .Where(q => string.Equals(SubtitleMatcher.ExtractEpisodeCode(q.FileName), subCode, StringComparison.OrdinalIgnoreCase)) .ToList(); } else if (subEpisodeNumber != null) { targets = _fileQueue .Where(q => SubtitleMatcher.ExtractEpisodeNumber(q.FileName) == subEpisodeNumber) .ToList(); } else if (_fileQueue.Count == 1) { targets = new List { _fileQueue[0] }; } else { targets = new List(); } if (targets.Count == 0) return false; foreach (var target in targets) AttachExternalSubtitle(target, subtitlePath); return true; } /// /// Called whenever a new video is added to the queue: checks the pending /// (previously unmatched) subtitle files for one matching this video's /// episode — by full season+episode code when both have one, or by bare /// episode number otherwise — and attaches + removes any matches from /// the pending list. /// private void MatchPendingSubtitlesToFile(QueuedFileItem item) { if (_pendingSubtitles.Count == 0) return; string? videoCode = SubtitleMatcher.ExtractEpisodeCode(item.FileName); int? videoEpisodeNumber = SubtitleMatcher.ExtractEpisodeNumber(item.FileName); if (videoCode == null && videoEpisodeNumber == null) return; var matches = _pendingSubtitles.Where(p => { string subName = Path.GetFileName(p); string? subCode = SubtitleMatcher.ExtractEpisodeCode(subName); if (subCode != null) return videoCode != null && string.Equals(subCode, videoCode, StringComparison.OrdinalIgnoreCase); int? subEpisodeNumber = SubtitleMatcher.ExtractEpisodeNumber(subName); return subEpisodeNumber != null && subEpisodeNumber == videoEpisodeNumber; }).ToList(); foreach (var match in matches) { AttachExternalSubtitle(item, match); _pendingSubtitles.Remove(match); } } private static void AttachExternalSubtitle(QueuedFileItem target, string subtitlePath) { if (target.ExternalSubtitles.Any(s => string.Equals(s.FilePath, subtitlePath, StringComparison.OrdinalIgnoreCase))) return; var info = SubtitleLanguageHelper.Detect(Path.GetFileName(subtitlePath)); var sub = new ExternalSubtitleTrack(subtitlePath) { Language = info.Language, TrackName = info.TrackName, Forced = info.Forced, HearingImpaired = info.HearingImpaired, IsDefault = target.ExternalSubtitles.Count == 0, }; target.ExternalSubtitles.Add(sub); } private void RemoveExternalSubtitle_Click(object sender, RoutedEventArgs e) { if (sender is not System.Windows.Controls.Button btn || btn.Tag is not ExternalSubtitleTrack sub) return; var owner = _fileQueue.FirstOrDefault(f => f.ExternalSubtitles.Contains(sub)); if (owner == null) return; bool wasDefault = sub.IsDefault; owner.ExternalSubtitles.Remove(sub); if (wasDefault) { var next = owner.ExternalSubtitles.FirstOrDefault(); if (next != null) next.IsDefault = true; } if (ReferenceEquals(FileQueueList.SelectedItem, owner)) ExternalSubsPanel.Visibility = owner.HasExternalSubtitles ? Visibility.Visible : Visibility.Collapsed; e.Handled = true; } // ── 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 ──────────────────────────────────── /// /// 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. /// 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; } /// /// 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; } /// /// 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. /// private void FileQueueList_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) { OpenSelectedFileInDefaultPlayer(); e.Handled = true; } /// /// Same as , but for the track /// table. Ignores double-clicks on the "Copy" checkbox column so toggling /// it rapidly doesn't also launch the default player. /// private void TrackGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) { if (e.OriginalSource is DependencyObject source && FindAncestor(source) != null) return; OpenSelectedFileInDefaultPlayer(); e.Handled = true; } /// /// Walks up the visual tree from looking for an /// ancestor (or the element itself) of type . /// private static T? FindAncestor(DependencyObject source) where T : DependencyObject { var current = source; while (current != null) { if (current is T typed) return typed; current = System.Windows.Media.VisualTreeHelper.GetParent(current); } return null; } 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(); } /// /// 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; ExternalSubsList.ItemsSource = file.ExternalSubtitles; ExternalSubsPanel.Visibility = file.HasExternalSubtitles ? Visibility.Visible : Visibility.Collapsed; } else { TrackGrid.ItemsSource = null; TrackGrid.Visibility = Visibility.Collapsed; NoSelectionHint.Visibility = Visibility.Visible; ExternalSubsList.ItemsSource = null; ExternalSubsPanel.Visibility = Visibility.Collapsed; } } // ── 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; ProcessingOverlay.Visibility = Visibility.Visible; var progressWin = new ProgressWindow(readyFiles, _settings.MkvMergePath, _settings.OutputFolder, MaxConcurrentStrips) { Owner = this }; progressWin.Closed += (_, _) => { _isProcessing = false; ProcessingOverlay.Visibility = Visibility.Collapsed; if (progressWin.ShouldClearQueue) { foreach (var item in _fileQueue) item.PropertyChanged -= QueuedFileItem_PropertyChanged; _fileQueue.Clear(); } UpdateLoadingOverlay(); }; progressWin.Show(); } }