diff --git a/Photos/App.axaml.cs b/Photos/App.axaml.cs index da47c62..d212373 100644 --- a/Photos/App.axaml.cs +++ b/Photos/App.axaml.cs @@ -23,10 +23,7 @@ public partial class App : Application // Avoid duplicate validations from both Avalonia and the CommunityToolkit. // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins DisableAvaloniaDataAnnotationValidation(); - desktop.MainWindow = new MainWindow - { - DataContext = new MainWindowViewModel(), - }; + desktop.MainWindow = new MainWindow(); } base.OnFrameworkInitializationCompleted(); diff --git a/Photos/Models/IFolderPickerService.cs b/Photos/Models/IFolderPickerService.cs new file mode 100644 index 0000000..6e1b967 --- /dev/null +++ b/Photos/Models/IFolderPickerService.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; + +namespace Photos.Models; + +public interface IFolderPickerService +{ + Task PickFolderAsync(); +} \ No newline at end of file diff --git a/Photos/Models/Photo.cs b/Photos/Models/Photo.cs index f4eb666..5c9979d 100644 --- a/Photos/Models/Photo.cs +++ b/Photos/Models/Photo.cs @@ -1,27 +1,116 @@ using System; +using System.Collections.Concurrent; +using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Platform.Storage; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; namespace Photos.Models; -public class Photo +public partial class Photo : ObservableObject, IDisposable { - public string DisplayName { get; set; } - public string FullPath { get; set; } - public string DisplayPath { get; set; } + [ObservableProperty] private string _displayName; + [ObservableProperty] private string _fullPath; + [ObservableProperty] private string _displayPath; - public IImage Source { get; set; } + [ObservableProperty] private Bitmap? _previewImage; + [ObservableProperty] private Bitmap? _fullImage; public Photo(string path, string relativeTo) { DisplayName = System.IO.Path.GetFileName(path); FullPath = path; DisplayPath = path.Remove(0, relativeTo.Length + 1); - using (var stream = File.OpenRead(path)) + } + + private async Task LoadImageAsync(bool scaled = false) + { + var image = await Task.Run(() => { - Source = new Bitmap(stream); - } + Bitmap? i = null; + + using (var stream = File.OpenRead(FullPath)) + { + i = scaled ? Bitmap.DecodeToWidth(stream, 200) : new Bitmap(stream); + } + + return i; + }); + + return image; + } + + public async Task LoadPreviewImage() + { + var image = await LoadImageAsync(true); + Dispatcher.UIThread.Post(() => + { + PreviewImage?.Dispose(); + PreviewImage = image; + }); + } + + public async Task LoadFullImage() + { + var image = await LoadImageAsync(); + Dispatcher.UIThread.Post(() => + { + FullImage?.Dispose(); + FullImage = image; + }); + } + + public void Dispose() + { + FullImage?.Dispose(); + PreviewImage?.Dispose(); + GC.SuppressFinalize(this); + } +} + +public static partial class PhotoExtension +{ + private static readonly string[] SupportedFileExtensions = [".png", ".jpg", ".jpeg"]; + + public static List? GetPhotoPathsFromDirectory(string path) + { + if (!Directory.Exists(path)) return null; + var files = Directory.EnumerateFiles(path); + + return files.Where(f => SupportedFileExtensions.Contains(Path.GetExtension(f), StringComparer.OrdinalIgnoreCase)).ToList(); + } + + public static List? GetPhotosFromDirectory(string path) + { + if (!Directory.Exists(path)) return null; + var files = Directory.EnumerateFiles(path); + + return files.Where(f => SupportedFileExtensions.Contains(Path.GetExtension(f), StringComparer.OrdinalIgnoreCase)) + .Select(x => new Photo(x, path)) + .ToList(); + } + + public static void CreateAndAddPhotosToList(List photos, string rootPath, IList toAddTo) + { + var bag = new ConcurrentBag(); + + Parallel.ForEach(photos, p => + { + bag.Add(new Photo(p, rootPath)); + }); + + Dispatcher.UIThread.InvokeAsync(() => + { + foreach (var photo in bag) + { + toAddTo.Add(photo); + } + }).GetAwaiter().GetResult(); // TODO: When reopening folder this throws exception } } \ No newline at end of file diff --git a/Photos/Photos.csproj b/Photos/Photos.csproj index 1ddaeba..ed67174 100644 --- a/Photos/Photos.csproj +++ b/Photos/Photos.csproj @@ -13,6 +13,7 @@ + diff --git a/Photos/ViewModels/MainWindowViewModel.cs b/Photos/ViewModels/MainWindowViewModel.cs index 65af5bb..a332a8e 100644 --- a/Photos/ViewModels/MainWindowViewModel.cs +++ b/Photos/ViewModels/MainWindowViewModel.cs @@ -17,20 +17,28 @@ namespace Photos.ViewModels; public partial class MainWindowViewModel : ViewModelBase { - public ObservableCollection Photos { get; private set; } = []; + private IFolderPickerService _folderPickerService; + + [ObservableProperty] private ObservableCollection _photos = []; [ObservableProperty] private Photo? _selectedPhoto; public string LibraryPath { get; private set; } = string.Empty; - public MainWindowViewModel() + public MainWindowViewModel(IFolderPickerService folderPickerService) { - + _folderPickerService = folderPickerService; } - void OpenDirectory() + public async Task OpenDirectory() { + var folder = await _folderPickerService.PickFolderAsync(); + if (folder is null) return; + foreach(var p in Photos) p.Dispose(); + + LibraryPath = folder; + await AddPhotosAsync(folder); } private void SanitizeFiles(List files, IEnumerable extensions) @@ -40,27 +48,11 @@ public partial class MainWindowViewModel : ViewModelBase { try { - var files = await Task.Run(() => - { - var files = Directory.GetFiles(path, "*", SearchOption.AllDirectories).ToList(); - SanitizeFiles(files, [".png", ".jpg", ".jpeg"]); - return files; - }); + var photoPaths = PhotoExtension.GetPhotoPathsFromDirectory(path); + if (photoPaths is null) return; - var photosToAdd = await Task.Run(() => - { - var photos = new List(); - foreach (var file in files) - { - photos.Add(new Photo(file, LibraryPath)); - } - return photos; - }); - - await Dispatcher.UIThread.InvokeAsync(() => - { - foreach (var photo in photosToAdd) Photos.Add(photo); - }); + await Task.Run(() => PhotoExtension.CreateAndAddPhotosToList(photoPaths, path, Photos)); + foreach (var p in Photos) _ = p.LoadPreviewImage(); } catch (Exception ex) { @@ -74,7 +66,7 @@ public partial class MainWindowViewModel : ViewModelBase if (files is null || files.Length == 0) return; var localPath = Path.GetDirectoryName(files[0].TryGetLocalPath()); - if (String.IsNullOrWhiteSpace(localPath)) return; + if (string.IsNullOrWhiteSpace(localPath)) return; LibraryPath = localPath; Photos.Clear(); @@ -82,14 +74,18 @@ public partial class MainWindowViewModel : ViewModelBase await AddPhotosAsync(localPath); } - public void PhotoClicked(PointerReleasedEventArgs e) + public async Task PhotoClicked(PointerReleasedEventArgs e) { if(e.Source is not Image img) return; - SelectedPhoto = Photos.FirstOrDefault(x => x.Source == img.Source); + SelectedPhoto = Photos.FirstOrDefault(x => x.PreviewImage == img.Source); + if (SelectedPhoto is null) return; + await SelectedPhoto.LoadFullImage(); } public void SelectedPhotoClicked() { + if (SelectedPhoto is null) return; + SelectedPhoto.FullImage = null; SelectedPhoto = null; } } \ No newline at end of file diff --git a/Photos/Views/FolderPickerService.cs b/Photos/Views/FolderPickerService.cs new file mode 100644 index 0000000..790620d --- /dev/null +++ b/Photos/Views/FolderPickerService.cs @@ -0,0 +1,31 @@ +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using Microsoft.VisualBasic.FileIO; +using Photos.Models; + +namespace Photos.Views; + +public class FolderPickerService(Window window) : IFolderPickerService +{ + private Window _window = window; + + public void SetDependencyWindow(Window window) => _window = window; + + public async Task PickFolderAsync() + { + var suggestedFolder = await _window.StorageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Pictures); + + var folders = await _window.StorageProvider.OpenFolderPickerAsync( + new FolderPickerOpenOptions + { + Title = "Select Folder", + AllowMultiple = false, + SuggestedStartLocation = suggestedFolder + } + ); + + return folders.FirstOrDefault()?.Path.LocalPath; + } +} \ No newline at end of file diff --git a/Photos/Views/MainWindow.axaml b/Photos/Views/MainWindow.axaml index 88dd08f..2efa74c 100644 --- a/Photos/Views/MainWindow.axaml +++ b/Photos/Views/MainWindow.axaml @@ -14,6 +14,10 @@ Title="Photos" DragDrop.AllowDrop="True" DragDrop.Drop="DropHandler" + ExtendClientAreaChromeHints="NoChrome" + TransparencyLevelHint="AcrylicBlur" + Background="Transparent" + ExtendClientAreaToDecorationsHint="True" > @@ -27,30 +31,36 @@