Made improvements on loading photos

This commit is contained in:
Ano-sys
2026-03-21 14:40:30 +01:00
parent 7f632531fd
commit 54268fc0b4
8 changed files with 189 additions and 53 deletions
+1 -4
View File
@@ -23,10 +23,7 @@ public partial class App : Application
// Avoid duplicate validations from both Avalonia and the CommunityToolkit. // Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation(); DisableAvaloniaDataAnnotationValidation();
desktop.MainWindow = new MainWindow desktop.MainWindow = new MainWindow();
{
DataContext = new MainWindowViewModel(),
};
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
+8
View File
@@ -0,0 +1,8 @@
using System.Threading.Tasks;
namespace Photos.Models;
public interface IFolderPickerService
{
Task<string?> PickFolderAsync();
}
+97 -8
View File
@@ -1,27 +1,116 @@
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Photos.Models; namespace Photos.Models;
public class Photo public partial class Photo : ObservableObject, IDisposable
{ {
public string DisplayName { get; set; } [ObservableProperty] private string _displayName;
public string FullPath { get; set; } [ObservableProperty] private string _fullPath;
public string DisplayPath { get; set; } [ObservableProperty] private string _displayPath;
public IImage Source { get; set; } [ObservableProperty] private Bitmap? _previewImage;
[ObservableProperty] private Bitmap? _fullImage;
public Photo(string path, string relativeTo) public Photo(string path, string relativeTo)
{ {
DisplayName = System.IO.Path.GetFileName(path); DisplayName = System.IO.Path.GetFileName(path);
FullPath = path; FullPath = path;
DisplayPath = path.Remove(0, relativeTo.Length + 1); DisplayPath = path.Remove(0, relativeTo.Length + 1);
using (var stream = File.OpenRead(path)) }
private async Task<Bitmap?> 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<string>? 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<Photo>? 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<string> photos, string rootPath, IList<Photo> toAddTo)
{
var bag = new ConcurrentBag<Photo>();
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
} }
} }
+1
View File
@@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.10"/> <PackageReference Include="Avalonia" Version="11.3.10"/>
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.10"/> <PackageReference Include="Avalonia.Desktop" Version="11.3.10"/>
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.10"/> <PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.10"/>
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.10"/> <PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.10"/>
+23 -27
View File
@@ -17,20 +17,28 @@ namespace Photos.ViewModels;
public partial class MainWindowViewModel : ViewModelBase public partial class MainWindowViewModel : ViewModelBase
{ {
public ObservableCollection<Photo> Photos { get; private set; } = []; private IFolderPickerService _folderPickerService;
[ObservableProperty] private ObservableCollection<Photo> _photos = [];
[ObservableProperty] private Photo? _selectedPhoto; [ObservableProperty] private Photo? _selectedPhoto;
public string LibraryPath { get; private set; } = string.Empty; 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<string> files, IEnumerable<string> extensions) private void SanitizeFiles(List<string> files, IEnumerable<string> extensions)
@@ -40,27 +48,11 @@ public partial class MainWindowViewModel : ViewModelBase
{ {
try try
{ {
var files = await Task.Run(() => var photoPaths = PhotoExtension.GetPhotoPathsFromDirectory(path);
{ if (photoPaths is null) return;
var files = Directory.GetFiles(path, "*", SearchOption.AllDirectories).ToList();
SanitizeFiles(files, [".png", ".jpg", ".jpeg"]);
return files;
});
var photosToAdd = await Task.Run(() => await Task.Run(() => PhotoExtension.CreateAndAddPhotosToList(photoPaths, path, Photos));
{ foreach (var p in Photos) _ = p.LoadPreviewImage();
var photos = new List<Photo>();
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);
});
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -74,7 +66,7 @@ public partial class MainWindowViewModel : ViewModelBase
if (files is null || files.Length == 0) return; if (files is null || files.Length == 0) return;
var localPath = Path.GetDirectoryName(files[0].TryGetLocalPath()); var localPath = Path.GetDirectoryName(files[0].TryGetLocalPath());
if (String.IsNullOrWhiteSpace(localPath)) return; if (string.IsNullOrWhiteSpace(localPath)) return;
LibraryPath = localPath; LibraryPath = localPath;
Photos.Clear(); Photos.Clear();
@@ -82,14 +74,18 @@ public partial class MainWindowViewModel : ViewModelBase
await AddPhotosAsync(localPath); await AddPhotosAsync(localPath);
} }
public void PhotoClicked(PointerReleasedEventArgs e) public async Task PhotoClicked(PointerReleasedEventArgs e)
{ {
if(e.Source is not Image img) return; 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() public void SelectedPhotoClicked()
{ {
if (SelectedPhoto is null) return;
SelectedPhoto.FullImage = null;
SelectedPhoto = null; SelectedPhoto = null;
} }
} }
+31
View File
@@ -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<string?> 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;
}
}
+23 -13
View File
@@ -14,6 +14,10 @@
Title="Photos" Title="Photos"
DragDrop.AllowDrop="True" DragDrop.AllowDrop="True"
DragDrop.Drop="DropHandler" DragDrop.Drop="DropHandler"
ExtendClientAreaChromeHints="NoChrome"
TransparencyLevelHint="AcrylicBlur"
Background="Transparent"
ExtendClientAreaToDecorationsHint="True"
> >
<Design.DataContext> <Design.DataContext>
@@ -27,30 +31,36 @@
<Button Content="Open Directory" Command="{Binding OpenDirectory}"/> <Button Content="Open Directory" Command="{Binding OpenDirectory}"/>
</Menu> </Menu>
<ScrollViewer Grid.Column="1"> <ScrollViewer Grid.Column="1">
<ItemsControl ItemsSource="{Binding Photos}" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> <ItemsRepeater ItemsSource="{Binding Photos}" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<ItemsControl.ItemsPanel> <ItemsRepeater.Layout>
<ItemsPanelTemplate> <UniformGridLayout MinItemWidth="190"
<WrapPanel ScrollViewer.VerticalScrollBarVisibility="Auto"/> MinItemHeight="240"
</ItemsPanelTemplate> MinRowSpacing="12"
</ItemsControl.ItemsPanel> MinColumnSpacing="12" />
<ItemsControl.ItemTemplate> </ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate DataType="models:Photo"> <DataTemplate DataType="models:Photo">
<StackPanel Orientation="Vertical" Margin="20"> <StackPanel Orientation="Vertical" Margin="12">
<i:Interaction.Behaviors> <i:Interaction.Behaviors>
<ic:EventTriggerBehavior EventName="PointerReleased"> <ic:EventTriggerBehavior EventName="PointerReleased">
<ic:InvokeCommandAction Command="{Binding DataContext.PhotoClicked, ElementName=Root}" PassEventArgsToCommand="True"/> <ic:InvokeCommandAction Command="{Binding DataContext.PhotoClicked, ElementName=Root}" PassEventArgsToCommand="True"/>
</ic:EventTriggerBehavior> </ic:EventTriggerBehavior>
</i:Interaction.Behaviors> </i:Interaction.Behaviors>
<Image Source="{Binding Source}" Width="150" Height="150"/> <Image Source="{Binding PreviewImage}"
<TextBlock Text="{Binding DisplayName}" HorizontalAlignment="Center" Width="150"/> Width="150"
Height="150" />
<TextBlock Text="{Binding DisplayName}"
HorizontalAlignment="Center"
Width="150" />
<TextBlock Text="{Binding DisplayPath}" HorizontalAlignment="Center" Width="150"/> <TextBlock Text="{Binding DisplayPath}" HorizontalAlignment="Center" Width="150"/>
</StackPanel> </StackPanel>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsRepeater.ItemTemplate>
</ItemsControl> </ItemsRepeater>
</ScrollViewer> </ScrollViewer>
<Grid Grid.Column="0" Grid.ColumnSpan="2" IsEnabled="{Binding SelectedPhoto}"> <Grid Grid.Column="0" Grid.ColumnSpan="2" IsEnabled="{Binding SelectedPhoto}">
<Image Source="{Binding SelectedPhoto.Source}" Margin="50"> <Image Source="{Binding SelectedPhoto.FullImage}" Margin="50">
<i:Interaction.Behaviors> <i:Interaction.Behaviors>
<ic:EventTriggerBehavior EventName="PointerReleased"> <ic:EventTriggerBehavior EventName="PointerReleased">
<ic:InvokeCommandAction Command="{Binding Path=SelectedPhotoClicked}"/> <ic:InvokeCommandAction Command="{Binding Path=SelectedPhotoClicked}"/>
+5 -1
View File
@@ -1,16 +1,20 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Xaml.Interactions.DragAndDrop; using Avalonia.Xaml.Interactions.DragAndDrop;
using Photos.Models;
using Photos.ViewModels; using Photos.ViewModels;
namespace Photos.Views; namespace Photos.Views;
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
private IFolderPickerService _folderPickerService;
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
DataContext = new MainWindowViewModel(); _folderPickerService = new FolderPickerService(this);
DataContext = new MainWindowViewModel(_folderPickerService);
} }
public void DropHandler(object sender, DragEventArgs e) public void DropHandler(object sender, DragEventArgs e)