Files
Olive/Olive/Pages/GifPickerPage.cs
T
2026-07-30 18:39:38 +02:00

556 lines
15 KiB
C#

using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Olive.Commands;
using Olive.Helpers;
using Olive.Klipy;
using Olive.Services;
using Windows.Foundation;
using Windows.System;
namespace Olive.Pages;
internal sealed partial class GifPickerPage : IDynamicListPage
{
private const int PageSize = 30;
private const int InitialBatchPages = 1;
private const int LoadMoreBatchPages = 1;
private static readonly TimeSpan SearchDebounceDelay = TimeSpan.FromMilliseconds(500);
private readonly Lock _itemsLock = new();
private readonly GifCache _cache = new();
private readonly SettingsManager _settingsManager;
private readonly List<IListItem> _items = [];
private readonly IIconInfo _icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
private readonly IGridProperties _gridProperties = new GalleryGridLayout { ShowTitle = true, ShowSubtitle = false };
private CancellationTokenSource? _searchCancellation;
private string _searchText = string.Empty;
private string _activeSearch = string.Empty;
private string _title = "Olive";
private int _searchVersion;
private int _loadRequestId;
private int _nextPage = 1;
private int _loadedItemCount;
private bool _hasMoreItems;
private bool _isLoading;
private bool _isLoadingMore;
private ICommandItem _emptyContent;
public GifPickerPage(SettingsManager settingsManager)
{
_settingsManager = settingsManager;
_emptyContent = InitialContent();
}
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
public IIconInfo Icon => _icon;
public string Id => string.Empty;
public string Name => "Olive GIF Picker";
public string Title => _title;
public OptionalColor AccentColor => default;
public bool IsLoading
{
get => _isLoading;
private set
{
if (_isLoading == value)
{
return;
}
_isLoading = value;
RaisePropChanged(nameof(IsLoading));
}
}
public string PlaceholderText => "Search for GIFs...";
public bool ShowDetails => false;
public bool HasMoreItems
{
get => _hasMoreItems;
private set
{
if (_hasMoreItems == value)
{
return;
}
_hasMoreItems = value;
RaisePropChanged(nameof(HasMoreItems));
}
}
public IFilters? Filters => null;
public IGridProperties GridProperties => _gridProperties;
public ICommandItem EmptyContent
{
get => _emptyContent;
private set
{
_emptyContent = value;
RaisePropChanged(nameof(EmptyContent));
}
}
public string? SearchText
{
get => _searchText;
set
{
value ??= string.Empty;
if (StringComparer.Ordinal.Equals(_searchText, value))
{
return;
}
_searchText = value;
UpdateSearchText(value);
}
}
public IListItem[] GetItems()
{
lock (_itemsLock)
{
return BuildDisplayItemsLocked();
}
}
public void LoadMore()
{
if (_isLoadingMore || !HasMoreItems || string.IsNullOrWhiteSpace(_activeSearch))
{
return;
}
var apiKey = _settingsManager.KlipyApiKey;
if (string.IsNullOrWhiteSpace(apiKey))
{
HasMoreItems = false;
EmptyContent = MissingApiKeyContent();
RaiseItemsChanged(DisplayItemCount());
return;
}
var version = _searchVersion;
_ = LoadPageBatchAsync(apiKey, _activeSearch, version, LoadMoreBatchPages, CancellationToken.None);
}
private void UpdateSearchText(string newSearch)
{
CancelSearch();
var trimmedSearch = newSearch.Trim();
if (string.IsNullOrWhiteSpace(trimmedSearch))
{
Interlocked.Increment(ref _searchVersion);
Interlocked.Increment(ref _loadRequestId);
IsLoading = false;
_isLoadingMore = false;
HasMoreItems = false;
_activeSearch = string.Empty;
_nextPage = 1;
ReplaceItems([]);
UpdateTitle();
EmptyContent = _settingsManager.HasKlipyApiKey ? InitialContent() : MissingApiKeyContent();
return;
}
var version = Interlocked.Increment(ref _searchVersion);
Interlocked.Increment(ref _loadRequestId);
var cancellation = new CancellationTokenSource();
_searchCancellation = cancellation;
_ = SearchAfterDebounceAsync(trimmedSearch, version, cancellation.Token);
}
private void CancelSearch()
{
var cancellation = _searchCancellation;
_searchCancellation = null;
if (cancellation is null)
{
return;
}
try
{
cancellation.Cancel();
}
catch (ObjectDisposedException)
{
}
cancellation.Dispose();
}
private async Task SearchAfterDebounceAsync(string search, int version, CancellationToken cancellationToken)
{
try
{
await Task.Delay(SearchDebounceDelay, cancellationToken).ConfigureAwait(false);
var apiKey = _settingsManager.KlipyApiKey;
if (string.IsNullOrWhiteSpace(apiKey))
{
if (version != _searchVersion || cancellationToken.IsCancellationRequested)
{
return;
}
IsLoading = false;
HasMoreItems = false;
EmptyContent = MissingApiKeyContent();
ReplaceItems([]);
return;
}
if (version != _searchVersion || cancellationToken.IsCancellationRequested)
{
return;
}
_activeSearch = search;
_nextPage = 1;
_isLoadingMore = false;
HasMoreItems = false;
IsLoading = true;
EmptyContent = LoadingContent(search);
UpdateTitle();
await LoadPageBatchAsync(apiKey, search, version, InitialBatchPages, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
if (version == _searchVersion)
{
ShowSearchError(ex, resetItems: true);
}
}
}
private async Task LoadPageBatchAsync(string apiKey, string search, int version, int pageCount, CancellationToken cancellationToken)
{
if (_isLoadingMore)
{
return;
}
var loadRequestId = Interlocked.Increment(ref _loadRequestId);
var startPage = _nextPage;
var resetItems = startPage == 1;
try
{
_isLoadingMore = true;
IsLoading = true;
var gifs = new List<KlipyGif>(PageSize * pageCount);
var nextPage = startPage;
var hasMore = false;
for (var i = 0; i < pageCount && version == _searchVersion && !cancellationToken.IsCancellationRequested; i++)
{
var page = startPage + i;
var result = await KlipyClient.SearchAsync(apiKey, search, page, PageSize, cancellationToken).ConfigureAwait(false);
if (version != _searchVersion || cancellationToken.IsCancellationRequested)
{
return;
}
gifs.AddRange(result.Gifs);
nextPage = page + 1;
hasMore = result.HasMore;
if (!hasMore)
{
break;
}
}
_nextPage = nextPage;
HasMoreItems = hasMore;
if (resetItems && gifs.Count == 0)
{
EmptyContent = new CommandItem(new NoOpCommand())
{
Title = "No GIF found",
Subtitle = $"Try another search for \"{search}\".",
Icon = Icon,
};
ReplaceItems([]);
return;
}
var newItems = gifs.Select(CreateItem).ToArray();
if (resetItems)
{
ReplaceItems(newItems);
}
else
{
AppendItems(newItems);
}
}
catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException or TaskCanceledException)
{
if (version == _searchVersion)
{
ShowSearchError(ex, resetItems);
}
}
catch (Exception ex)
{
if (version == _searchVersion)
{
ShowSearchError(ex, resetItems);
}
}
finally
{
if (loadRequestId == _loadRequestId)
{
_isLoadingMore = false;
}
if (version == _searchVersion && loadRequestId == _loadRequestId)
{
IsLoading = false;
}
}
}
private void ShowSearchError(Exception ex, bool resetItems)
{
IsLoading = false;
HasMoreItems = false;
EmptyContent = new CommandItem(new NoOpCommand())
{
Title = "Search failed",
Subtitle = ex.Message,
Icon = Icon,
};
if (resetItems)
{
ReplaceItems([]);
}
else
{
RaiseItemsChanged(DisplayItemCount());
}
}
private ListItem CreateItem(KlipyGif gif)
{
return new ListItem(new CopyGifCommand(gif, _cache))
{
Title = string.Empty,
Subtitle = string.Empty,
Icon = new IconInfo(gif.ThumbnailGifUrl.ToString()),
MoreCommands = CreateLoadMoreContextItems(),
};
}
private IContextItem[] CreateLoadMoreContextItems()
{
return [
CreateLoadMoreContextItem("Load 30 more GIFs", VirtualKey.L),
];
}
private CommandContextItem CreateLoadMoreContextItem(string title, VirtualKey key)
{
return new CommandContextItem(new LoadMoreGifsCommand(LoadMore))
{
Title = title,
Subtitle = "Append the next 30 results without moving the current selection",
Icon = Icon,
RequestedShortcut = KeyChordHelpers.FromModifiers(true, false, false, false, key, 0),
};
}
private CommandItem InitialContent()
{
return new CommandItem(new NoOpCommand())
{
Title = _settingsManager.HasKlipyApiKey ? "Search GIFs" : "Klipy API key missing",
Subtitle = _settingsManager.HasKlipyApiKey
? "Type something like thanks, excited, cat, or confused."
: "Open Olive settings and set the Klipy API key.",
Icon = Icon,
};
}
private CommandItem LoadingContent(string search)
{
return new CommandItem(new NoOpCommand())
{
Title = "Loading GIFs...",
Subtitle = $"Searching Klipy for \"{search}\"",
Icon = Icon,
};
}
private CommandItem MissingApiKeyContent()
{
return new CommandItem(new NoOpCommand())
{
Title = "Klipy API key missing",
Subtitle = "Open Olive settings and paste your Klipy API key.",
Icon = Icon,
};
}
private void ReplaceItems(IListItem[] items)
{
int count;
lock (_itemsLock)
{
_items.Clear();
_items.AddRange(items);
_loadedItemCount = items.Length;
AddPlaceholderItemsLocked();
count = DisplayItemCountLocked();
}
RaiseItemsChanged(count);
UpdateTitle();
}
private void AppendItems(IListItem[] items)
{
int count;
lock (_itemsLock)
{
RemovePlaceholderItemsLocked();
_items.AddRange(items);
_loadedItemCount += items.Length;
AddPlaceholderItemsLocked();
count = DisplayItemCountLocked();
}
RaiseItemsChanged(count);
UpdateTitle();
}
private IListItem[] BuildDisplayItemsLocked()
{
return [.. _items];
}
private void AddPlaceholderItemsLocked()
{
if (!HasMoreItems)
{
return;
}
for (var i = 0; i < PageSize * LoadMoreBatchPages; i++)
{
_items.Add(CreateVirtualLoadingItem());
}
}
private void RemovePlaceholderItemsLocked()
{
if (_items.Count > _loadedItemCount)
{
_items.RemoveRange(_loadedItemCount, _items.Count - _loadedItemCount);
}
}
private ListItem CreateVirtualLoadingItem()
{
return new ListItem(new LoadMoreGifsCommand(LoadMore))
{
Title = "Loading more GIFs...",
Subtitle = "Scroll here or press Ctrl+L to load the next 30",
Icon = Icon,
MoreCommands = CreateLoadMoreContextItems(),
};
}
private int DisplayItemCount()
{
lock (_itemsLock)
{
return DisplayItemCountLocked();
}
}
private int DisplayItemCountLocked()
{
return _items.Count;
}
private void RaiseItemsChanged(int count)
{
ItemsChanged?.Invoke(this, new ItemsChangedEventArgs(count));
}
private void RaisePropChanged(string propertyName)
{
PropChanged?.Invoke(this, new PropChangedEventArgs(propertyName));
}
private void UpdateTitle()
{
var loadedCount = LoadedItemCount();
var title = string.IsNullOrWhiteSpace(_activeSearch) || loadedCount == 0
? "Olive"
: HasMoreItems
? $"Olive - {loadedCount} GIFs loaded"
: $"Olive - {loadedCount} GIFs loaded (all)";
if (StringComparer.Ordinal.Equals(_title, title))
{
return;
}
_title = title;
RaisePropChanged(nameof(Title));
}
private int LoadedItemCount()
{
lock (_itemsLock)
{
return _loadedItemCount;
}
}
private sealed partial class LoadMoreGifsCommand : InvokableCommand
{
private readonly Action _loadMore;
public LoadMoreGifsCommand(Action loadMore)
{
_loadMore = loadMore;
Name = "Load 30 more GIFs";
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
}
public override ICommandResult Invoke()
{
_loadMore();
return CommandResult.KeepOpen();
}
}
}