Remove legacy WPF app, flatten WinUI project to repo root, rename namespace

The repo carried two parallel UIs (WPF + WinUI) sharing Models/Services via
cross-directory Link includes. Now that WinUI is the only frontend, collapse
the structure so the WinUI project IS the repo root instead of a nested
sibling folder:

- Delete the WPF project entirely (App.xaml, MainWindow.xaml, csproj) and its
  bin/obj output
- Move AmiReel.WinUI/* up to the repo root (App, MainWindow, MainPage,
  DialogHelper, Assets, Package.appxmanifest, app.manifest, Properties,
  .github/instructions, AGENTS.md) via git mv, preserving history
- Rename AmiReel.WinUI.csproj -> AmiReel.csproj; regenerate the solution as
  AmiReel.slnx (the newer XML solution format) with a single project
- Rename namespace AmigaDB.VideoRenderer.{Models,Services} -> AmiReel.{...}
  and AmiReel_WinUI -> AmiReel across all files, including the embedded
  ffmpeg/ffprobe resource logical names in the csproj and ToolExtractor
- Models/ and Services/ no longer need the Link-based cross-directory
  <Compile Include>; they're picked up by the SDK's default globbing now
  that they live under the project directory
- Rename assets/ -> branding/ (source icon art) to avoid a case-insensitive
  collision with Assets/ (packaged tile art) once both sit at repo root
- Merge the two .gitignore files into one; track the PublishProfiles pubxml
  files instead of ignoring them (no secrets, and they keep publish
  reproducible across machines) as branding, gitignore, etc.
- Simplify publish-win-x64.ps1 (drop the -Target Wpf/WinUI switch, there's
  only one target now) and rewrite README.md to describe the single-project
  layout, build/run/publish commands, and file structure

Verified: dotnet build succeeds for both AmiReel.csproj and AmiReel.slnx, and
the built exe launches and renders identically to before the move.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 14:07:10 +02:00
parent 3e881b79b8
commit 603af17e56
66 changed files with 641 additions and 1905 deletions
+93 -402
View File
@@ -1,425 +1,116 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using Microsoft.UI;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System;
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;
using Windows.Graphics;
using Windows.UI;
using WinRT.Interop;
namespace AmigaDB.VideoRenderer;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
public partial class MainWindow : Window
namespace AmiReel;
/// <summary>
/// The application window. This hosts a Frame that displays pages. Add your
/// UI and logic to MainPage.xaml / MainPage.xaml.cs instead of here so you
/// can use Page features such as navigation events and the Loaded lifecycle.
/// </summary>
public sealed partial class MainWindow : Window
{
private readonly ObservableCollection<string> _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";
App.MainWindowInstance = this;
ExtendsContentIntoTitleBar = true;
SetTitleBar(AppTitleBar);
string? executablePath = Environment.ProcessPath;
if (!string.IsNullOrWhiteSpace(executablePath) && File.Exists(executablePath))
AppWindow.SetIcon(executablePath);
SetDefaultSizeAndPosition();
// Navigate the root frame to the main page on startup.
RootFrame.Navigate(typeof(MainPage));
AppWindow.Closing += AppWindow_Closing;
}
private void Minimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
private void SetDefaultSizeAndPosition()
{
const int DefaultWidth = 1280;
const int DefaultHeight = 860;
const int MinWidth = 1100;
const int MinHeight = 780;
private void MaximizeRestore_Click(object sender, RoutedEventArgs e)
=> WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
IntPtr hwnd = WindowNative.GetWindowHandle(this);
double scale = GetDpiForWindow(hwnd) / 96.0;
int width = (int)(DefaultWidth * scale);
int height = (int)(DefaultHeight * scale);
private void CloseWindowButton_Click(object sender, RoutedEventArgs e) => Close();
AppWindow.Resize(new SizeInt32(width, height));
private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
if (AppWindow.Presenter is OverlappedPresenter presenter)
{
presenter.PreferredMinimumWidth = (int)(MinWidth * scale);
presenter.PreferredMinimumHeight = (int)(MinHeight * scale);
}
DisplayArea? displayArea = DisplayArea.GetFromWindowId(Win32Interop.GetWindowIdFromWindow(hwnd), DisplayAreaFallback.Primary);
if (displayArea is not null)
{
int centerX = displayArea.WorkArea.X + (displayArea.WorkArea.Width - width) / 2;
int centerY = displayArea.WorkArea.Y + (displayArea.WorkArea.Height - height) / 2;
AppWindow.Move(new PointInt32(centerX, centerY));
}
}
[DllImport("user32.dll")]
private static extern int GetDpiForWindow(IntPtr hwnd);
public void ApplyTheme(bool isDark)
{
if (Content is FrameworkElement root)
root.RequestedTheme = isDark ? ElementTheme.Dark : ElementTheme.Light;
AppWindowTitleBar titleBar = AppWindow.TitleBar;
Color foreground = isDark ? Color.FromArgb(255, 0xF5, 0xF7, 0xFB) : Color.FromArgb(255, 0x12, 0x20, 0x33);
Color hoverBackground = isDark ? Color.FromArgb(255, 0x2E, 0x44, 0x6C) : Color.FromArgb(255, 0xD7, 0xE4, 0xF4);
titleBar.ButtonBackgroundColor = Colors.Transparent;
titleBar.ButtonInactiveBackgroundColor = Colors.Transparent;
titleBar.ButtonForegroundColor = foreground;
titleBar.ButtonInactiveForegroundColor = foreground;
titleBar.ButtonHoverBackgroundColor = hoverBackground;
titleBar.ButtonHoverForegroundColor = foreground;
titleBar.ButtonPressedBackgroundColor = hoverBackground;
titleBar.ButtonPressedForegroundColor = foreground;
}
private async void AppWindow_Closing(AppWindow sender, AppWindowClosingEventArgs args)
{
if (_exitConfirmed) return;
e.Cancel = true;
ExitConfirmOverlay.Visibility = Visibility.Visible;
}
args.Cancel = true;
private void CancelExit_Click(object sender, RoutedEventArgs e) => ExitConfirmOverlay.Visibility = Visibility.Collapsed;
FrameworkElement root = (FrameworkElement)Content;
ContentDialog dialog = DialogHelper.CreateStyled(RootFrame.XamlRoot, root.RequestedTheme);
dialog.Title = "Exit AmiReel?";
dialog.Content = "Are you sure you want to close the application?";
dialog.PrimaryButtonText = "Exit";
dialog.CloseButtonText = "Cancel";
dialog.DefaultButton = ContentDialogButton.Primary;
ContentDialogResult result = await dialog.ShowAsync();
if (result != ContentDialogResult.Primary) return;
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(Models.SupportedVideoFormats.IsSupported).OrderBy(NaturalKey))
if (!_inputs.Contains(file, StringComparer.OrdinalIgnoreCase)) _inputs.Add(file);
}
private void AddInputs_Click(object sender, RoutedEventArgs e)
{
OpenFileDialog dialog = new() { Filter = $"Video files|{Models.SupportedVideoFormats.PickerFilter}", 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<RenderProgress> progress = new(UpdateProgress);
IReadOnlyList<string> 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<EncoderMode>(((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<string> 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);
}