Skip to content

Commit

Permalink
Added: Mod & Pager Thumbnails to Library Page, Collections Page, Load…
Browse files Browse the repository at this point in the history
…out Page (#2446)

* WIP: Show Thumbnail in Collections Download View

* Improve: Merge Image with Name Column, And Display in Main Library

* First pass at styling for mod thumbnail images

* Added: Icons to loadout items from Nexus

* Added: Show icon on mod page in library.

TODO: Hide it from child when using this view.

* Added: Show icons in parents of Library and Loadout Items

* Improve: Remove icons of children in loadout when trees are used.

* Added: Hide Library Page Child Item Views in TreeView

Now fully matches design (to my awareness)

* Attempted to add ShowThumbnail to LoadoutItemModel

Style selector for thumbnail column is more generalized
Removed panel for missing thumbnail

* Restored: Placeholder Icon for Page Thumbnail

* Fixed: FakeParentLoadoutItemModel should load the thumbnail.

* Restored: Placeholder to library item thumbnails.

* Removed: Redundant 'update this' comment.

* Improved: Better Clarify Thumbnail Loading Expected Behaviour

* Cleanup: Removed unused style.

* Changed: Use final design dimensions for encoded thumbnails.

* Re-added placeholder image styles

* Added: Suppress an unhandled exception that can surface from a missing thumbnail.

* Disabled: Warnings Around Async Void Lambda

* Removed: Caching from Image Pipeline

* Added: A transparent fallback for images rather than a black box.

* Added: erri-seal-of-approval libraryItemsDisposable creation.

* Moved: Thumbnail Acquisition to FakeParentLoadoutItemModel

Dispose: ChildrenObservable with FakeParentLoadoutItemModel

* Added: static method handler for LoadoutItemModel

* Fixed: Accidental regression in 16e9ccc due to unready childrenObservable

* Refactored: We now load thumbnail of mod page directly into FakeParentLoadoutItem

See: #2446 (review)

* Fix: Tree Children now hide thumbnail as needed.

---------

Co-authored-by: Simon Davies <[email protected]>
  • Loading branch information
Sewer56 and insomnious authored Jan 13, 2025
1 parent 0667217 commit c66e552
Show file tree
Hide file tree
Showing 25 changed files with 350 additions and 95 deletions.
Binary file added src/NexusMods.App.UI/Assets/transparent.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 55 additions & 0 deletions src/NexusMods.App.UI/ImagePipelines.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using BitFaster.Caching.Lru;
using Microsoft.Extensions.DependencyInjection;
using NexusMods.Abstractions.IO;
using NexusMods.Abstractions.NexusModsLibrary;
using NexusMods.Abstractions.NexusModsLibrary.Models;
using NexusMods.Abstractions.Resources;
using NexusMods.Abstractions.Resources.Caching;
Expand All @@ -14,6 +15,7 @@
using NexusMods.Media;
using NexusMods.MnemonicDB.Abstractions;
using R3;
using SkiaSharp;

namespace NexusMods.App.UI;

Expand All @@ -23,13 +25,15 @@ public static class ImagePipelines
private const string CollectionTileImagePipelineKey = nameof(CollectionTileImagePipelineKey);
private const string CollectionBackgroundImagePipelineKey = nameof(CollectionBackgroundImagePipelineKey);
private const string UserAvatarPipelineKey = nameof(UserAvatarPipelineKey);
private const string ModPageThumbnailPipelineKey = nameof(ModPageThumbnailPipelineKey);
private const string GuidedInstallerRemoteImagePipelineKey = nameof(GuidedInstallerRemoteImagePipelineKey);
private const string GuidedInstallerFileImagePipelineKey = nameof(GuidedInstallerFileImagePipelineKey);
private const string MarkdownRendererRemoteImagePipelineKey = nameof(MarkdownRendererRemoteImagePipelineKey);

private static readonly Bitmap CollectionTileFallback = new(AssetLoader.Open(new Uri("avares://NexusMods.App.UI/Assets/collection-tile-fallback.png")));
private static readonly Bitmap CollectionBackgroundFallback = new(AssetLoader.Open(new Uri("avares://NexusMods.App.UI/Assets/black-box.png")));
private static readonly Bitmap UserAvatarFallback = new(AssetLoader.Open(new Uri("avares://NexusMods.App.UI/Assets/DesignTime/avatar.webp")));
private static readonly Bitmap ModPageThumbnailFallback = new(AssetLoader.Open(new Uri("avares://NexusMods.App.UI/Assets/transparent.png")));

public static Observable<Bitmap> CreateObservable(EntityId input, IResourceLoader<EntityId, Bitmap> pipeline)
{
Expand Down Expand Up @@ -64,6 +68,12 @@ public static IServiceCollection AddImagePipelines(this IServiceCollection servi
connection: serviceProvider.GetRequiredService<IConnection>()
)
)
.AddKeyedSingleton<IResourceLoader<EntityId, Bitmap>>(
serviceKey: ModPageThumbnailPipelineKey,
implementationFactory: static (serviceProvider, _) => CreateModPageThumbnailPipeline(
connection: serviceProvider.GetRequiredService<IConnection>()
)
)
.AddKeyedSingleton<IResourceLoader<Uri, Lifetime<Bitmap>>>(
serviceKey: GuidedInstallerRemoteImagePipelineKey,
implementationFactory: static (serviceProvider, _) => CreateGuidedInstallerRemoteImagePipeline(
Expand Down Expand Up @@ -98,6 +108,15 @@ public static IResourceLoader<EntityId, Bitmap> GetCollectionBackgroundImagePipe
{
return serviceProvider.GetRequiredKeyedService<IResourceLoader<EntityId, Bitmap>>(serviceKey: CollectionBackgroundImagePipelineKey);
}

/// <summary>
/// Input: ModPageMetadataId
/// Output: Image (cached)
/// </summary>
public static IResourceLoader<EntityId, Bitmap> GetModPageThumbnailPipeline(IServiceProvider serviceProvider)
{
return serviceProvider.GetRequiredKeyedService<IResourceLoader<EntityId, Bitmap>>(serviceKey: ModPageThumbnailPipelineKey);
}

public static IResourceLoader<Uri, Lifetime<Bitmap>> GetGuidedInstallerRemoteImagePipeline(IServiceProvider serviceProvider)
{
Expand Down Expand Up @@ -182,6 +201,42 @@ private static IResourceLoader<EntityId, Bitmap> CreateCollectionBackgroundImage

return pipeline;
}

/// <summary>
/// Input: ModPageMetadataId
/// Output: Image (cached)
/// </summary>
private static IResourceLoader<EntityId, Bitmap> CreateModPageThumbnailPipeline(
IConnection connection)
{
var pipeline = new HttpLoader(new HttpClient())
.ChangeIdentifier<ValueTuple<EntityId, Uri>, Uri, byte[]>(static tuple => tuple.Item2)
.Decode(decoderType: DecoderType.Skia)
.Resize(newSize: new SKSizeI(
width: 90,
height: 56
))
.Encode(encoderType: EncoderType.Qoi)
.PersistInDb(
connection: connection,
referenceAttribute: NexusModsModPageMetadata.ThumbnailResource,
identifierToHash: static uri => uri.ToString().xxHash3AsUtf8(),
partitionId: PartitionId.User(ImagePartitionId)
)
.Decode(decoderType: DecoderType.Qoi)
.ToAvaloniaBitmap()
// Note(sewer): This is transparent, the actual fallback is provided on the
// UI end; which we show during the asynchronous load of the actual thumbnail
// from the pipeline. If the load fails, we show an all transparent fallback;
// meaning the underlying placeholder from before is still shown.
.UseFallbackValue(ModPageThumbnailFallback)
.EntityIdToIdentifier(
connection: connection,
attribute: NexusModsModPageMetadata.ThumbnailUri
);

return pipeline;
}

private static IResourceLoader<Uri, Lifetime<Bitmap>> CreateGuidedInstallerRemoteImagePipeline(HttpClient httpClient)
{
Expand Down
6 changes: 3 additions & 3 deletions src/NexusMods.App.UI/NexusMods.App.UI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -616,9 +616,6 @@
<Compile Update="Pages\LibraryPage\Collections\CollectionCardViewModel.cs">
<DependentUpon>ICollectionCardViewModel.cs</DependentUpon>
</Compile>
<Compile Update="Pages\LibraryPage\ILibraryItemWithName.cs">
<DependentUpon>ILibraryItemModel.cs</DependentUpon>
</Compile>
<Compile Update="Pages\LibraryPage\ILibraryItemWithVersion.cs">
<DependentUpon>ILibraryItemModel.cs</DependentUpon>
</Compile>
Expand Down Expand Up @@ -697,6 +694,9 @@
<Compile Update="Pages\Diagnostics\List\DiagnosticListDesignViewModel.cs.old">
<DependentUpon>IDiagnosticListViewModel.cs</DependentUpon>
</Compile>
<Compile Update="Pages\LibraryPage\ILibraryItemWithThumbnailAndName.cs">
<DependentUpon>ILibraryItemModel.cs</DependentUpon>
</Compile>
<Compile Update="LeftMenu\Items\IconDesignViewModel.cs">
<DependentUpon>IIconViewModel.cs</DependentUpon>
</Compile>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ protected override IColumn<ILibraryItemModel>[] CreateColumns(bool viewHierarchi
{
return
[
ColumnCreator.CreateColumn<ILibraryItemModel, ILibraryItemWithName>(),
ColumnCreator.CreateColumn<ILibraryItemModel, ILibraryItemWithThumbnailAndName>(),
ColumnCreator.CreateColumn<ILibraryItemModel, ILibraryItemWithVersion>(),
ColumnCreator.CreateColumn<ILibraryItemModel, ILibraryItemWithSize>(),
ColumnCreator.CreateColumn<ILibraryItemModel, ILibraryItemWithAction>(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Avalonia.Media.Imaging;
using NexusMods.Abstractions.Jobs;
using NexusMods.Abstractions.NexusModsLibrary.Models;
using NexusMods.Abstractions.UI.Extensions;
Expand All @@ -11,16 +12,22 @@
namespace NexusMods.App.UI.Pages.CollectionDownload;

public class ExternalDownloadItemModel : TreeDataGridItemModel<ILibraryItemModel, EntityId>,
ILibraryItemWithName,
ILibraryItemWithThumbnailAndName,
ILibraryItemWithSize,
ILibraryItemWithDownloadAction
{
public ExternalDownloadItemModel(CollectionDownloadExternal.ReadOnly externalDownload)
public ExternalDownloadItemModel(CollectionDownloadExternal.ReadOnly externalDownload, IServiceProvider serviceProvider)
{
DownloadableItem = new DownloadableItem(externalDownload);
FormattedSize = ItemSize.ToFormattedProperty();
DownloadItemCommand = ILibraryItemWithDownloadAction.CreateCommand(this);

// Note: Because this is an external file with no known image, this always hits the fallback.
var modPageThumbnailPipeline = ImagePipelines.GetModPageThumbnailPipeline(serviceProvider);
var imageDisposable = ImagePipelines.CreateObservable(externalDownload.Id, modPageThumbnailPipeline)
.ObserveOnUIThreadDispatcher()
.Subscribe(this, static (bitmap, self) => self.Thumbnail.Value = bitmap);

// ReSharper disable once NotDisposedResource
var modelActivationDisposable = this.WhenActivated(static (self, disposables) =>
{
Expand All @@ -43,13 +50,16 @@ public ExternalDownloadItemModel(CollectionDownloadExternal.ReadOnly externalDow
FormattedSize,
DownloadItemCommand,
DownloadState,
DownloadButtonText
DownloadButtonText,
imageDisposable
);
}

public required Observable<bool> IsInLibraryObservable { get; init; }
public required Observable<IJob> DownloadJobObservable { get; init; }

public BindableReactiveProperty<Bitmap> Thumbnail { get; } = new();
public BindableReactiveProperty<bool> ShowThumbnail { get; } = new(value: true);
public BindableReactiveProperty<string> Name { get; } = new(value: "-");

public ReactiveProperty<Size> ItemSize { get; } = new();
Expand Down
8 changes: 4 additions & 4 deletions src/NexusMods.App.UI/Pages/ILoadoutDataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ public static IObservable<IChangeSet<Datom, EntityId>> FilterInStaticLoadout(
});
}

public static LoadoutItemModel ToLoadoutItemModel(IConnection connection, LibraryLinkedLoadoutItem.ReadOnly libraryLinkedLoadoutItem)
public static LoadoutItemModel ToLoadoutItemModel(IConnection connection, LibraryLinkedLoadoutItem.ReadOnly libraryLinkedLoadoutItem, IServiceProvider serviceProvider, bool loadThumbnail)
{
// NOTE(erri120): We'll only show the library linked loadout item group for now.
// Showing sub-groups, like SMAPI mods for Stardew Valley, will not be shown for now.
// We'll probably have a setting or something that the game extension can control.

return ToLoadoutItemModel(connection, libraryLinkedLoadoutItem.AsLoadoutItemGroup());
return ToLoadoutItemModel(connection, libraryLinkedLoadoutItem.AsLoadoutItemGroup(), serviceProvider, loadThumbnail);

// var db = libraryLinkedLoadoutItem.Db;

Expand Down Expand Up @@ -73,7 +73,7 @@ public static LoadoutItemModel ToLoadoutItemModel(IConnection connection, Librar
// return arr;
}

private static LoadoutItemModel ToLoadoutItemModel(IConnection connection, LoadoutItemGroup.ReadOnly loadoutItemGroup)
private static LoadoutItemModel ToLoadoutItemModel(IConnection connection, LoadoutItemGroup.ReadOnly loadoutItemGroup, IServiceProvider serviceProvider, bool loadThumbnail)
{
var observable = LoadoutItemGroup
.Observe(connection, loadoutItemGroup.Id)
Expand All @@ -86,7 +86,7 @@ private static LoadoutItemModel ToLoadoutItemModel(IConnection connection, Loado
// TODO: version (need to ask the game extension)
// TODO: size (probably with RevisionsWithChildUpdates)

var model = new LoadoutItemModel(loadoutItemGroup.Id)
var model = new LoadoutItemModel(loadoutItemGroup.Id, serviceProvider, connection, loadThumbnail, loadThumbnail)
{
NameObservable = nameObservable,
IsEnabledObservable = isEnabledObservable,
Expand Down
6 changes: 5 additions & 1 deletion src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ [MustDisposeResource] static IDisposable SetupLinkedLoadoutItems<TModel>(TModel

if (serialDisposable.Disposable is null)
{
serialDisposable.Disposable = self.LinkedLoadoutItemsObservable.OnUI().SubscribeWithErrorLogging(changes => self.LinkedLoadoutItems.ApplyChanges(changes));
serialDisposable.Disposable = self.LinkedLoadoutItemsObservable.OnUI().SubscribeWithErrorLogging(changes =>
{
self.LinkedLoadoutItems.ApplyChanges(changes);
}
);
}

return disposable;
Expand Down
15 changes: 0 additions & 15 deletions src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithName.cs

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Avalonia.Media.Imaging;
using NexusMods.App.UI.Controls;
using R3;

namespace NexusMods.App.UI.Pages.LibraryPage;

public interface ILibraryItemWithThumbnailAndName : ILibraryItemModel, IComparable<ILibraryItemWithThumbnailAndName>, IColumnDefinition<ILibraryItemModel, ILibraryItemWithThumbnailAndName>
{
BindableReactiveProperty<Bitmap> Thumbnail { get; }
BindableReactiveProperty<string> Name { get; }
BindableReactiveProperty<bool> ShowThumbnail { get; }

int IComparable<ILibraryItemWithThumbnailAndName>.CompareTo(ILibraryItemWithThumbnailAndName? other) => string.CompareOrdinal(Name.Value, other?.Name.Value);

public const string ColumnTemplateResourceKey = "LibraryItemNameColumn";
static string IColumnDefinition<ILibraryItemModel, ILibraryItemWithThumbnailAndName>.GetColumnHeader() => "Name";
static string IColumnDefinition<ILibraryItemModel, ILibraryItemWithThumbnailAndName>.GetColumnTemplateResourceKey() => ColumnTemplateResourceKey;
}
2 changes: 1 addition & 1 deletion src/NexusMods.App.UI/Pages/LibraryPage/LibraryView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
</controls:EmptyState.Subtitle>

<Grid>
<TreeDataGrid x:Name="TreeDataGrid"
<TreeDataGrid x:Name="LibraryTreeDataGrid"
ShowColumnHeaders="True"
CanUserResizeColumns="True"
CanUserSortColumns="True"
Expand Down
4 changes: 2 additions & 2 deletions src/NexusMods.App.UI/Pages/LibraryPage/LibraryView.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public LibraryView()
{
InitializeComponent();

TreeDataGridViewHelper.SetupTreeDataGridAdapter<LibraryView, ILibraryViewModel, ILibraryItemModel, EntityId>(this, TreeDataGrid, vm => vm.Adapter);
TreeDataGridViewHelper.SetupTreeDataGridAdapter<LibraryView, ILibraryViewModel, ILibraryItemModel, EntityId>(this, LibraryTreeDataGrid, vm => vm.Adapter);

this.WhenActivated(disposables =>
{
Expand Down Expand Up @@ -46,7 +46,7 @@ public LibraryView()
this.BindCommand(ViewModel, vm => vm.OpenNexusModsCommand, view => view.GetModsFromNexusButton)
.AddTo(disposables);

this.OneWayBind(ViewModel, vm => vm.Adapter.Source.Value, view => view.TreeDataGrid.Source)
this.OneWayBind(ViewModel, vm => vm.Adapter.Source.Value, view => view.LibraryTreeDataGrid.Source)
.AddTo(disposables);

this.OneWayBind(ViewModel, vm => vm.Adapter.IsSourceEmpty.Value, view => view.EmptyState.IsActive)
Expand Down
2 changes: 1 addition & 1 deletion src/NexusMods.App.UI/Pages/LibraryPage/LibraryViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ protected override IObservable<IChangeSet<ILibraryItemModel, EntityId>> GetRoots

protected override IColumn<ILibraryItemModel>[] CreateColumns(bool viewHierarchical)
{
var nameColumn = ColumnCreator.CreateColumn<ILibraryItemModel, ILibraryItemWithName>();
var nameColumn = ColumnCreator.CreateColumn<ILibraryItemModel, ILibraryItemWithThumbnailAndName>();

return
[
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Avalonia.Media.Imaging;
using DynamicData;
using NexusMods.Abstractions.Library.Models;
using NexusMods.Abstractions.Loadouts;
Expand All @@ -12,14 +13,14 @@
namespace NexusMods.App.UI.Pages.LibraryPage;

public class LocalFileLibraryItemModel : TreeDataGridItemModel<ILibraryItemModel, EntityId>,
ILibraryItemWithName,
ILibraryItemWithThumbnailAndName,
ILibraryItemWithSize,
ILibraryItemWithDates,
ILibraryItemWithInstallAction,
IHasLinkedLoadoutItems,
IIsChildLibraryItemModel
{
public LocalFileLibraryItemModel(LocalFile.ReadOnly localFile)
public LocalFileLibraryItemModel(LocalFile.ReadOnly localFile, IServiceProvider serviceProvider)
{
LibraryItemId = localFile.Id;

Expand All @@ -28,6 +29,12 @@ public LocalFileLibraryItemModel(LocalFile.ReadOnly localFile)
FormattedInstalledDate = InstalledDate.ToFormattedProperty();
InstallItemCommand = ILibraryItemWithInstallAction.CreateCommand(this);

// Note: Because this is a local file, this always hits the fallback thumbnail in practice.
var modPageThumbnailPipeline = ImagePipelines.GetModPageThumbnailPipeline(serviceProvider);
var imageDisposable = ImagePipelines.CreateObservable(localFile.Id, modPageThumbnailPipeline)
.ObserveOnUIThreadDispatcher()
.Subscribe(this, static (bitmap, self) => self.Thumbnail.Value = bitmap);

// ReSharper disable once NotDisposedResource
var datesDisposable = ILibraryItemWithDates.SetupDates(this);

Expand All @@ -42,6 +49,7 @@ public LocalFileLibraryItemModel(LocalFile.ReadOnly localFile)

_modelDisposable = Disposable.Combine(
datesDisposable,
imageDisposable,
linkedLoadoutItemsDisposable,
modelActivationDisposable,
Name,
Expand All @@ -53,7 +61,8 @@ public LocalFileLibraryItemModel(LocalFile.ReadOnly localFile)
FormattedInstalledDate,
InstallItemCommand,
IsInstalled,
InstallButtonText
InstallButtonText,
Thumbnail
);
}

Expand All @@ -64,7 +73,9 @@ public LocalFileLibraryItemModel(LocalFile.ReadOnly localFile)
public required IObservable<IChangeSet<LibraryLinkedLoadoutItem.ReadOnly, EntityId>> LinkedLoadoutItemsObservable { get; init; }
public ObservableDictionary<EntityId, LibraryLinkedLoadoutItem.ReadOnly> LinkedLoadoutItems { get; private set; } = [];

public BindableReactiveProperty<Bitmap> Thumbnail { get; set; } = new();
public BindableReactiveProperty<string> Name { get; } = new(value: "-");
public BindableReactiveProperty<bool> ShowThumbnail { get; } = new(value: true);

public ReactiveProperty<Size> ItemSize { get; } = new();
public BindableReactiveProperty<string> FormattedSize { get; }
Expand Down
Loading

0 comments on commit c66e552

Please sign in to comment.