using System.Collections.ObjectModel; using System.Diagnostics; using System.Globalization; using System.IO; using System.Runtime.InteropServices; using System.Text; using System.Windows; using System.Windows.Controls; using System.Windows.Interop; using AmigaDB.VideoRenderer.Models; using AmigaDB.VideoRenderer.Services; using Microsoft.Win32; using System.Windows.Media; namespace AmigaDB.VideoRenderer; public partial class MainWindow : Window { private readonly ObservableCollection _inputs = []; private readonly AppSettings _loadedSettings; private readonly StringBuilder _pendingLog = new(); private readonly object _logLock = new(); private CancellationTokenSource? _renderCancellation; private string? _lastRenderedFile; private bool _logFlushScheduled; private bool _titleBarIsLight; private bool _exitConfirmed; public MainWindow() { InitializeComponent(); InputList.ItemsSource = _inputs; _loadedSettings = AppSettingsStore.Load(); OutputFolderBox.Text = _loadedSettings.OutputFolder; EndCardBox.Text = _loadedSettings.EndCardPath; FfmpegPathBox.Text = _loadedSettings.FfmpegPath; FfprobePathBox.Text = _loadedSettings.FfprobePath; PreviewPlayerPathBox.Text = _loadedSettings.PreviewPlayerPath; TrimBox.Text = _loadedSettings.TrimStart; FadeBox.Text = _loadedSettings.FadeSeconds; HoldBox.Text = _loadedSettings.EndCardHoldSeconds; IntervalBox.Text = _loadedSettings.ThumbnailInterval; SelectEncoder(_loadedSettings.Encoder); SelectTheme(_loadedSettings.Theme); ApplyTheme(_loadedSettings.Theme); MoveSourcesBox.IsChecked = _loadedSettings.ShouldMoveSourcesToOriginals; Closing += MainWindow_Closing; SourceInitialized += (_, _) => UpdateTitleBarTheme(_titleBarIsLight); StateChanged += (_, _) => MaximizeButton.Content = WindowState == WindowState.Maximized ? "\uE923" : "\uE922"; } private void Minimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized; private void MaximizeRestore_Click(object sender, RoutedEventArgs e) => WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; private void CloseWindowButton_Click(object sender, RoutedEventArgs e) => Close(); private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e) { if (_exitConfirmed) return; e.Cancel = true; ExitConfirmOverlay.Visibility = Visibility.Visible; } private void CancelExit_Click(object sender, RoutedEventArgs e) => ExitConfirmOverlay.Visibility = Visibility.Collapsed; private void ConfirmExit_Click(object sender, RoutedEventArgs e) { _exitConfirmed = true; SaveSettings(); Close(); } private void InputList_DragEnter(object sender, DragEventArgs e) { e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None; e.Handled = true; } private void InputList_Drop(object sender, DragEventArgs e) { if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return; string[] files = (string[])e.Data.GetData(DataFormats.FileDrop)!; foreach (string file in files.Where(f => string.Equals(Path.GetExtension(f), ".avi", StringComparison.OrdinalIgnoreCase)).OrderBy(NaturalKey)) if (!_inputs.Contains(file, StringComparer.OrdinalIgnoreCase)) _inputs.Add(file); } private void AddInputs_Click(object sender, RoutedEventArgs e) { OpenFileDialog dialog = new() { Filter = "AVI recordings (*.avi)|*.avi", Multiselect = true }; if (dialog.ShowDialog(this) != true) return; foreach (string file in dialog.FileNames.OrderBy(NaturalKey)) if (!_inputs.Contains(file, StringComparer.OrdinalIgnoreCase)) _inputs.Add(file); } private void ClearInputs_Click(object sender, RoutedEventArgs e) => _inputs.Clear(); private void BrowseEndCard_Click(object sender, RoutedEventArgs e) { OpenFileDialog dialog = new() { Filter = "Images|*.png;*.jpg;*.jpeg;*.webp;*.bmp|All files|*.*" }; if (dialog.ShowDialog(this) == true) EndCardBox.Text = dialog.FileName; } private void BrowseOutput_Click(object sender, RoutedEventArgs e) { OpenFolderDialog dialog = new() { InitialDirectory = OutputFolderBox.Text }; if (dialog.ShowDialog(this) == true) OutputFolderBox.Text = dialog.FolderName; } private void BrowseFfmpeg_Click(object sender, RoutedEventArgs e) { OpenFileDialog dialog = new() { Filter = "FFmpeg executable (ffmpeg.exe)|ffmpeg.exe|Executable files (*.exe)|*.exe|All files|*.*" }; if (dialog.ShowDialog(this) == true) FfmpegPathBox.Text = dialog.FileName; } private void BrowseFfprobe_Click(object sender, RoutedEventArgs e) { OpenFileDialog dialog = new() { Filter = "FFprobe executable (ffprobe.exe)|ffprobe.exe|Executable files (*.exe)|*.exe|All files|*.*" }; if (dialog.ShowDialog(this) == true) FfprobePathBox.Text = dialog.FileName; } private void BrowsePreviewPlayer_Click(object sender, RoutedEventArgs e) { OpenFileDialog dialog = new() { Filter = "Video player (*.exe)|*.exe|All files|*.*" }; if (dialog.ShowDialog(this) == true) PreviewPlayerPathBox.Text = dialog.FileName; } private void OpenSettings_Click(object sender, RoutedEventArgs e) => SettingsOverlay.Visibility = Visibility.Visible; private void CloseSettings_Click(object sender, RoutedEventArgs e) { SettingsOverlay.Visibility = Visibility.Collapsed; SaveSettings(); } private async void Render_Click(object sender, RoutedEventArgs e) { try { RenderSettings settings = ReadSettings(); SaveSettings(); SetRendering(true); LogBox.Clear(); _renderCancellation = new CancellationTokenSource(); Progress progress = new(UpdateProgress); IReadOnlyList resolvedInputs = await new RenderPipeline().RenderAsync(settings, progress, AppendLog, _renderCancellation.Token); ReplaceInputs(resolvedInputs); _lastRenderedFile = Path.Combine(settings.OutputDirectory, settings.OutputName + "_final.mp4"); OpenOutputButton.IsEnabled = true; PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile); MessageBox.Show(this, "The AmiReel render completed successfully.", "Render complete", MessageBoxButton.OK, MessageBoxImage.Information); } catch (OperationCanceledException) { UpdateProgress(new(RenderProgress.Value, "Cancelled", "The render was cancelled.")); } catch (Exception exception) { AppendLog("ERROR: " + exception); MessageBox.Show(this, UserFacingErrors.Summarize(exception), "Render failed", MessageBoxButton.OK, MessageBoxImage.Error); StageText.Text = "Failed"; } finally { _renderCancellation?.Dispose(); _renderCancellation = null; SetRendering(false); } } private RenderSettings ReadSettings() { static double Number(string text, string name) { if (!double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double value) || value < 0) throw new ArgumentException($"Enter a valid non-negative value for {name}."); return value; } if (!int.TryParse(IntervalBox.Text, out int interval) || interval < 1) throw new ArgumentException("Thumbnail interval must be at least one second."); EncoderMode encoder = Enum.Parse(((ComboBoxItem)EncoderBox.SelectedItem).Tag!.ToString()!); return new(_inputs.ToList(), EndCardBox.Text.Trim(), OutputFolderBox.Text.Trim(), OutputNameBox.Text.Trim(), FfmpegPathBox.Text.Trim(), FfprobePathBox.Text.Trim(), encoder, Number(TrimBox.Text, "trim start"), Number(FadeBox.Text, "fade"), Number(HoldBox.Text, "end-card hold"), interval, MoveSourcesBox.IsChecked == true); } private void ReplaceInputs(IReadOnlyList paths) { _inputs.Clear(); foreach (string path in paths) _inputs.Add(path); } private void Cancel_Click(object sender, RoutedEventArgs e) { CancelButton.IsEnabled = false; StatusText.Text = "Stopping FFmpeg…"; _renderCancellation?.Cancel(); } private void OpenOutput_Click(object sender, RoutedEventArgs e) { if (Directory.Exists(OutputFolderBox.Text)) Process.Start(new ProcessStartInfo("explorer.exe", OutputFolderBox.Text) { UseShellExecute = true }); } private void PreviewSource_Click(object sender, RoutedEventArgs e) { if (InputList.SelectedItem is string path) OpenPreview(path); } private void PreviewRendered_Click(object sender, RoutedEventArgs e) { if (!string.IsNullOrWhiteSpace(_lastRenderedFile) && File.Exists(_lastRenderedFile)) OpenPreview(_lastRenderedFile); } private void InputList_SelectionChanged(object sender, SelectionChangedEventArgs e) => PreviewSourceButton.IsEnabled = InputList.SelectedItem is string; private void SetRendering(bool rendering) { RenderButton.IsEnabled = !rendering; CancelButton.IsEnabled = rendering; } private void UpdateProgress(RenderProgress value) { RenderProgress.Value = value.Percent; PercentText.Text = $"{value.Percent:0}%"; StageText.Text = value.Stage; StatusText.Text = value.Message; } private void AppendLog(string line) { lock (_logLock) { _pendingLog.AppendLine(line); if (_logFlushScheduled) return; _logFlushScheduled = true; } _ = Dispatcher.BeginInvoke(() => { string chunk; lock (_logLock) { chunk = _pendingLog.ToString(); _pendingLog.Clear(); _logFlushScheduled = false; } if (chunk.Length == 0) return; LogBox.AppendText(chunk); LogBox.ScrollToEnd(); }); } private static string NaturalKey(string path) { string name = Path.GetFileNameWithoutExtension(path); int underscore = name.LastIndexOf('_'); return underscore >= 0 && int.TryParse(name[(underscore + 1)..], out int number) ? name[..underscore] + number.ToString("D10") : name; } private void ThemeBox_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (!IsLoaded) return; string theme = SelectedTheme(); ApplyTheme(theme); SaveSettings(); } private void SaveSettings() { AppSettingsStore.Save(new AppSettings( OutputFolderBox.Text.Trim(), EndCardBox.Text.Trim(), FfmpegPathBox.Text.Trim(), FfprobePathBox.Text.Trim(), PreviewPlayerPathBox.Text.Trim(), SelectedTheme(), SelectedEncoder(), TrimBox.Text.Trim(), FadeBox.Text.Trim(), HoldBox.Text.Trim(), IntervalBox.Text.Trim(), MoveSourcesBox.IsChecked == true)); } private void OpenPreview(string mediaPath) { if (!File.Exists(mediaPath)) { MessageBox.Show(this, "The selected media file was not found.", "Preview unavailable", MessageBoxButton.OK, MessageBoxImage.Warning); return; } string playerPath = PreviewPlayerPathBox.Text.Trim(); try { if (!string.IsNullOrWhiteSpace(playerPath)) { if (!File.Exists(playerPath)) throw new FileNotFoundException("Configured preview player was not found.", playerPath); ProcessStartInfo customPlayer = new() { FileName = playerPath, UseShellExecute = false }; customPlayer.ArgumentList.Add(mediaPath); Process.Start(customPlayer); return; } Process.Start(new ProcessStartInfo(mediaPath) { UseShellExecute = true }); } catch (Exception exception) { MessageBox.Show(this, exception.Message, "Preview failed", MessageBoxButton.OK, MessageBoxImage.Error); } } private string SelectedTheme() => ((ComboBoxItem)ThemeBox.SelectedItem).Tag!.ToString()!; private string SelectedEncoder() => ((ComboBoxItem)EncoderBox.SelectedItem).Tag!.ToString()!; private void SelectTheme(string theme) { foreach (ComboBoxItem item in ThemeBox.Items) { if (string.Equals(item.Tag?.ToString(), theme, StringComparison.OrdinalIgnoreCase)) { ThemeBox.SelectedItem = item; return; } } ThemeBox.SelectedIndex = 0; } private void SelectEncoder(string encoder) { foreach (ComboBoxItem item in EncoderBox.Items) { if (string.Equals(item.Tag?.ToString(), encoder, StringComparison.OrdinalIgnoreCase)) { EncoderBox.SelectedItem = item; return; } } EncoderBox.SelectedIndex = 0; } private void ApplyTheme(string theme) { bool light = string.Equals(theme, "Light", StringComparison.OrdinalIgnoreCase); SetBrush("PageBrush", light ? "#F4F7FB" : "#0E1525"); SetBrush("PanelBrush", light ? "#FFFFFF" : "#162033"); SetBrush("PanelAltBrush", light ? "#EEF4FB" : "#1A2740"); SetBrush("FieldBrush", light ? "#F7FAFD" : "#1D2940"); SetBrush("BorderBrush", light ? "#C7D3E3" : "#2B3A58"); SetBrush("AccentBrush", light ? "#0E9AEF" : "#4AB8FF"); SetBrush("AccentSoftBrush", light ? "#D6EEFF" : "#13324F"); SetBrush("TextBrush", light ? "#122033" : "#F5F7FB"); SetBrush("MutedBrush", light ? "#5F7390" : "#9FB1CC"); SetBrush("ButtonBrush", light ? "#E5EDF7" : "#243554"); SetBrush("ButtonHoverBrush", light ? "#D7E4F4" : "#2E446C"); SetBrush("ButtonDisabledBrush", light ? "#EEF2F7" : "#2A3140"); SetBrush("ButtonDisabledTextBrush", light ? "#8C99AB" : "#7E8BA1"); SetBrush("PrimaryTextBrush", light ? "#FFFFFF" : "#07111D"); SetBrush("SelectionBrush", light ? "#BEE4FF" : "#295C87"); SetBrush("SelectionTextBrush", light ? "#122033" : "#FFFFFF"); _titleBarIsLight = light; UpdateTitleBarTheme(light); } private void SetBrush(string key, string color) { Application.Current.Resources[key] = new SolidColorBrush((Color)ColorConverter.ConvertFromString(color)); } private void UpdateTitleBarTheme(bool light) { IntPtr hwnd = new WindowInteropHelper(this).Handle; if (hwnd == IntPtr.Zero) return; int useImmersiveDarkMode = light ? 0 : 1; DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref useImmersiveDarkMode, sizeof(int)); int captionColor = ToColorRef(light ? "#F4F7FB" : "#0E1525"); int textColor = ToColorRef(light ? "#122033" : "#F5F7FB"); DwmSetWindowAttribute(hwnd, DWMWA_CAPTION_COLOR, ref captionColor, sizeof(int)); DwmSetWindowAttribute(hwnd, DWMWA_TEXT_COLOR, ref textColor, sizeof(int)); } private static int ToColorRef(string hexColor) { Color color = (Color)ColorConverter.ConvertFromString(hexColor); return color.R | (color.G << 8) | (color.B << 16); } private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; private const int DWMWA_CAPTION_COLOR = 35; private const int DWMWA_TEXT_COLOR = 36; [DllImport("dwmapi.dll", PreserveSig = true)] private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size); }