added url search for giphy, install wizard, fixed ui and added minor changes

This commit is contained in:
Ebbe Baß
2026-08-19 13:04:46 +02:00
parent 9b0ffcccdf
commit b1ffb3924a
7 changed files with 251 additions and 31 deletions
@@ -9,7 +9,9 @@ namespace EbbesMemeClipboard.ViewModels;
public partial class PickerViewModel : ObservableObject
{
private const int RemoteResultLimit = 30;
// 50 is Giphy's maximum per request; more results come from paging via offset as the user
// scrolls rather than from a single bigger call.
private const int RemoteResultLimit = 50;
private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(350);
private readonly ILocalMemeLibraryService _library;
@@ -23,6 +25,13 @@ public partial class PickerViewModel : ObservableObject
/// <summary>Cancels the in-flight remote search when a newer keystroke supersedes it.</summary>
private CancellationTokenSource? _searchCts;
private int _remoteOffset;
private bool _remoteHasMore;
private bool _isLoadingMore;
/// <summary>True when the grid is showing a single item resolved from a pasted link.</summary>
private bool _showingResolvedLink;
public ObservableCollection<MemeTileViewModel> Items { get; } = new();
/// <summary>Favourites for the currently active source only.</summary>
@@ -49,6 +58,12 @@ public partial class PickerViewModel : ObservableObject
public bool IsLocalSource => ActiveSource == MemeSource.Local;
public bool IsGiphySource => ActiveSource == MemeSource.Giphy;
public string SearchPlaceholder => IsLocalSource
? "Search your memes..."
: ActiveProvider?.SearchPlaceholder ?? "Search...";
private IGifProvider? ActiveProvider => _providers.FirstOrDefault(p => p.Source == ActiveSource);
public event EventHandler? RequestClose;
public PickerViewModel(
@@ -78,6 +93,7 @@ public partial class PickerViewModel : ObservableObject
{
OnPropertyChanged(nameof(IsLocalSource));
OnPropertyChanged(nameof(IsGiphySource));
OnPropertyChanged(nameof(SearchPlaceholder));
SearchText = string.Empty;
RefreshFavorites();
// Setting SearchText above only triggers a refresh if the value actually changed, so
@@ -264,6 +280,50 @@ public partial class PickerViewModel : ObservableObject
RefreshFavorites();
}
/// <summary>
/// Appends the next page of remote results. Called as the grid is scrolled near the bottom,
/// so browsing feels continuous instead of stopping dead at the first batch.
/// </summary>
public async Task LoadMoreAsync()
{
if (IsLocalSource || _showingResolvedLink || !_remoteHasMore || _isLoadingMore) return;
var provider = ActiveProvider;
if (provider is null || !provider.IsConfigured) return;
_isLoadingMore = true;
var queryAtStart = SearchText;
try
{
var results = await provider.SearchAsync(queryAtStart, RemoteResultLimit, _remoteOffset, CancellationToken.None);
// The query may have changed while this page was in flight; if so the newer search
// owns the grid and these results are stale.
if (!string.Equals(queryAtStart, SearchText, StringComparison.Ordinal)) return;
foreach (var result in results)
{
var tile = new MemeTileViewModel(result, _gifCache);
Items.Add(tile);
_ = tile.LoadThumbnailAsync();
}
_remoteOffset += results.Count;
_remoteHasMore = results.Count >= RemoteResultLimit;
SyncFavoriteFlags();
StatusMessage = $"{Items.Count} result(s).";
}
catch (Exception ex)
{
StatusMessage = $"Couldn't load more: {ex.Message}";
_remoteHasMore = false;
}
finally
{
_isLoadingMore = false;
}
}
private async Task RefreshAsync()
{
// Any pending remote search is stale the moment the query or tab changes.
@@ -271,13 +331,17 @@ public partial class PickerViewModel : ObservableObject
_searchCts?.Dispose();
_searchCts = null;
_remoteOffset = 0;
_remoteHasMore = false;
_showingResolvedLink = false;
if (IsLocalSource)
{
RefreshLocalItems();
return;
}
var provider = _providers.FirstOrDefault(p => p.Source == ActiveSource);
var provider = ActiveProvider;
if (provider is null) return;
if (!provider.IsConfigured)
@@ -297,9 +361,15 @@ public partial class PickerViewModel : ObservableObject
await Task.Delay(SearchDebounce, cts.Token);
IsBusy = true;
StatusMessage = "Searching...";
var results = await provider.SearchAsync(SearchText, RemoteResultLimit, cts.Token);
if (provider.CanResolveLink(SearchText))
{
await ResolveLinkAsync(provider, cts.Token);
return;
}
StatusMessage = "Searching...";
var results = await provider.SearchAsync(SearchText, RemoteResultLimit, 0, cts.Token);
cts.Token.ThrowIfCancellationRequested();
Items.Clear();
@@ -310,6 +380,9 @@ public partial class PickerViewModel : ObservableObject
_ = tile.LoadThumbnailAsync(cts.Token);
}
_remoteOffset = results.Count;
_remoteHasMore = results.Count >= RemoteResultLimit;
SyncFavoriteFlags();
StatusMessage = results.Count == 0 ? "No results." : $"{results.Count} result(s).";
}
@@ -330,6 +403,30 @@ public partial class PickerViewModel : ObservableObject
}
}
private async Task ResolveLinkAsync(IGifProvider provider, CancellationToken ct)
{
StatusMessage = "Opening link...";
var result = await provider.ResolveLinkAsync(SearchText, ct);
ct.ThrowIfCancellationRequested();
Items.Clear();
if (result is null)
{
StatusMessage = "Couldn't find that GIF - check the link.";
return;
}
var tile = new MemeTileViewModel(result, _gifCache);
Items.Add(tile);
_ = tile.LoadThumbnailAsync(ct);
// A single resolved item has nothing to page through.
_showingResolvedLink = true;
SyncFavoriteFlags();
StatusMessage = "Found 1 GIF from link.";
}
private void RefreshLocalItems()
{
var records = string.IsNullOrWhiteSpace(SearchText)