556 lines
15 KiB
C#
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();
|
|
}
|
|
}
|
|
|
|
}
|