Added Giphy support, Favourites

This commit is contained in:
Ebbe Baß
2026-08-14 13:55:34 +02:00
parent 747cd2a8aa
commit 4c755919d4
24 changed files with 1087 additions and 160 deletions
@@ -5,26 +5,85 @@ using EbbesMemeClipboard.Services;
namespace EbbesMemeClipboard.ViewModels;
/// <summary>
/// One grid tile. Covers both a local library file and a remote provider result so the picker
/// can use a single DataTemplate for every source.
/// </summary>
public partial class MemeTileViewModel : ObservableObject
{
private const int ThumbnailPixelWidth = 150;
public LocalMemeRecord Record { get; }
public string FullPath { get; }
public string DisplayName => Record.OriginalFileName;
private readonly IGifCacheService? _cache;
private string? _localPath;
public LocalMemeRecord? Record { get; }
public GifSearchResult? RemoteResult { get; }
public bool IsLocal => Record is not null;
public string DisplayName => Record?.OriginalFileName ?? RemoteResult?.Title ?? "Meme";
public MemeSource Source => Record is not null ? MemeSource.Local : RemoteResult!.Source;
/// <summary>Stable identity for favouriting: the library Id locally, the provider Id remotely.</summary>
public string FavoriteKey => Record?.Id ?? RemoteResult!.Id;
[ObservableProperty]
private BitmapSource? _thumbnail;
[ObservableProperty]
private bool _isFavorite;
public string FavoriteActionLabel => IsFavorite ? "Remove from favourites" : "Add to favourites";
partial void OnIsFavoriteChanged(bool value) => OnPropertyChanged(nameof(FavoriteActionLabel));
public MemeTileViewModel(LocalMemeRecord record, string fullPath)
{
Record = record;
FullPath = fullPath;
_localPath = fullPath;
}
public async Task LoadThumbnailAsync()
public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache)
{
var path = FullPath;
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth));
RemoteResult = remoteResult;
_cache = cache;
}
/// <summary>
/// Resolves a real file on disk, downloading and caching first for remote results. The
/// clipboard's file-drop format needs an actual path, so this must complete before copying.
/// </summary>
public async Task<string> EnsureLocalPathAsync(CancellationToken ct = default)
{
if (_localPath is not null) return _localPath;
_localPath = await _cache!.GetOrDownloadAsync(RemoteResult!, ct);
return _localPath;
}
public async Task LoadThumbnailAsync(CancellationToken ct = default)
{
try
{
if (IsLocal)
{
var path = _localPath!;
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth), ct);
}
else
{
var bytes = await _cache!.DownloadPreviewAsync(RemoteResult!, ct);
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrameFromBytes(bytes, ThumbnailPixelWidth), ct);
}
}
catch (OperationCanceledException)
{
// Superseded by a newer search - drop it silently, the tile is already discarded.
}
catch (Exception)
{
// A single unreadable/failed thumbnail shouldn't blank the whole grid; the tile just
// renders empty and stays clickable.
}
}
}
@@ -9,43 +9,84 @@ namespace EbbesMemeClipboard.ViewModels;
public partial class PickerViewModel : ObservableObject
{
private const int RemoteResultLimit = 30;
private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(350);
private readonly ILocalMemeLibraryService _library;
private readonly IClipboardService _clipboard;
private readonly ISettingsService _settings;
private readonly IAutoPasteService _autoPaste;
private readonly IGifCacheService _gifCache;
private readonly IFavoritesService _favorites;
private readonly IReadOnlyList<IGifProvider> _providers;
/// <summary>Cancels the in-flight remote search when a newer keystroke supersedes it.</summary>
private CancellationTokenSource? _searchCts;
public ObservableCollection<MemeTileViewModel> Items { get; } = new();
/// <summary>Favourites for the currently active source only.</summary>
public ObservableCollection<MemeTileViewModel> Favorites { get; } = new();
[ObservableProperty]
private string _searchText = string.Empty;
[ObservableProperty]
private string _statusMessage = string.Empty;
/// <summary>
/// True while a modal dialog (currently: the "add memes" file picker) opened from within the
/// picker is showing. The window's Deactivated handler checks this so opening that dialog
/// doesn't get treated as "user clicked away" and hide the picker out from under it.
/// </summary>
[ObservableProperty]
private bool _isBusy;
[ObservableProperty]
private bool _isDialogOpen;
[ObservableProperty]
private bool _hasFavorites;
[ObservableProperty]
private MemeSource _activeSource = MemeSource.Local;
public bool IsLocalSource => ActiveSource == MemeSource.Local;
public bool IsGiphySource => ActiveSource == MemeSource.Giphy;
public event EventHandler? RequestClose;
public PickerViewModel(
ILocalMemeLibraryService library,
IClipboardService clipboard,
ISettingsService settings,
IAutoPasteService autoPaste)
IAutoPasteService autoPaste,
IGifCacheService gifCache,
IFavoritesService favorites,
IEnumerable<IGifProvider> providers)
{
_library = library;
_clipboard = clipboard;
_settings = settings;
_autoPaste = autoPaste;
RefreshItems();
_gifCache = gifCache;
_favorites = favorites;
_providers = providers.ToList();
RefreshLocalItems();
RefreshFavorites();
}
partial void OnSearchTextChanged(string value) => RefreshItems();
partial void OnSearchTextChanged(string value) => _ = RefreshAsync();
partial void OnActiveSourceChanged(MemeSource value)
{
OnPropertyChanged(nameof(IsLocalSource));
OnPropertyChanged(nameof(IsGiphySource));
SearchText = string.Empty;
RefreshFavorites();
// Setting SearchText above only triggers a refresh if the value actually changed, so
// refresh explicitly here to cover switching tabs with an already-empty box.
_ = RefreshAsync();
}
[RelayCommand]
private void SelectSource(MemeSource source) => ActiveSource = source;
[RelayCommand]
private async Task SelectItemAsync(MemeTileViewModel? tile)
@@ -54,7 +95,12 @@ public partial class PickerViewModel : ObservableObject
try
{
await _clipboard.CopyLocalFileAsync(tile.FullPath);
IsBusy = true;
// For remote results this downloads and caches the full-quality GIF first; the
// clipboard's file-drop format needs a real file on disk.
var path = await tile.EnsureLocalPathAsync();
await _clipboard.CopyLocalFileAsync(path);
StatusMessage = $"Copied {tile.DisplayName}";
RequestClose?.Invoke(this, EventArgs.Empty);
@@ -66,20 +112,55 @@ public partial class PickerViewModel : ObservableObject
}
catch (Exception ex)
{
// Deliberately broad: CommunityToolkit's AsyncRelayCommand otherwise swallows any
// exception here silently (no crash, no message, the click just appears to do
// nothing), which makes clipboard/paste failures impossible to diagnose from the UI.
// Deliberately broad: AsyncRelayCommand otherwise swallows exceptions silently, so
// a clipboard/download failure would look like the click simply did nothing.
StatusMessage = $"Couldn't insert {tile.DisplayName}: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task ToggleFavoriteAsync(MemeTileViewModel? tile)
{
if (tile is null) return;
if (_favorites.IsFavorite(tile.Source, tile.FavoriteKey))
{
await _favorites.RemoveAsync(tile.Source, tile.FavoriteKey);
StatusMessage = $"Removed {tile.DisplayName} from favourites";
}
else
{
await _favorites.AddAsync(new FavoriteRecord
{
Source = tile.Source,
Key = tile.FavoriteKey,
Title = tile.DisplayName,
PreviewUrl = tile.RemoteResult?.PreviewUrl,
FullUrl = tile.RemoteResult?.FullUrl,
DateAdded = DateTimeOffset.Now,
});
StatusMessage = $"Added {tile.DisplayName} to favourites";
}
RefreshFavorites();
SyncFavoriteFlags();
}
[RelayCommand]
private async Task RemoveItemAsync(MemeTileViewModel? tile)
{
if (tile is null) return;
if (tile?.Record is null) return;
await _library.RemoveAsync(tile.Record);
RefreshItems();
// Drop the matching favourite too, otherwise it lingers pointing at a deleted file.
await _favorites.RemoveAsync(MemeSource.Local, tile.Record.Id);
RefreshLocalItems();
RefreshFavorites();
StatusMessage = $"Removed {tile.DisplayName}";
}
@@ -120,17 +201,88 @@ public partial class PickerViewModel : ObservableObject
}
var imported = await _library.ImportAsync(candidates);
RefreshItems();
ActiveSource = MemeSource.Local;
RefreshLocalItems();
StatusMessage = $"Added {imported.Count} meme(s).";
}
public void OnShown()
{
SearchText = string.Empty;
RefreshItems();
if (IsLocalSource)
{
RefreshLocalItems();
}
RefreshFavorites();
}
private void RefreshItems()
private async Task RefreshAsync()
{
// Any pending remote search is stale the moment the query or tab changes.
_searchCts?.Cancel();
_searchCts?.Dispose();
_searchCts = null;
if (IsLocalSource)
{
RefreshLocalItems();
return;
}
var provider = _providers.FirstOrDefault(p => p.Source == ActiveSource);
if (provider is null) return;
if (!provider.IsConfigured)
{
Items.Clear();
StatusMessage = provider.NotConfiguredMessage;
return;
}
var cts = new CancellationTokenSource();
_searchCts = cts;
try
{
// Debounce: without this every keystroke fires an API call, which burns through a
// free-tier rate limit almost immediately.
await Task.Delay(SearchDebounce, cts.Token);
IsBusy = true;
StatusMessage = "Searching...";
var results = await provider.SearchAsync(SearchText, RemoteResultLimit, cts.Token);
cts.Token.ThrowIfCancellationRequested();
Items.Clear();
foreach (var result in results)
{
var tile = new MemeTileViewModel(result, _gifCache);
Items.Add(tile);
_ = tile.LoadThumbnailAsync(cts.Token);
}
SyncFavoriteFlags();
StatusMessage = results.Count == 0 ? "No results." : $"{results.Count} result(s).";
}
catch (OperationCanceledException)
{
// Superseded by a newer query - the newer one owns the UI state now.
}
catch (Exception ex)
{
StatusMessage = $"Search failed: {ex.Message}";
}
finally
{
if (_searchCts == cts)
{
IsBusy = false;
}
}
}
private void RefreshLocalItems()
{
var records = string.IsNullOrWhiteSpace(SearchText)
? _library.GetAll()
@@ -143,5 +295,56 @@ public partial class PickerViewModel : ObservableObject
Items.Add(tile);
_ = tile.LoadThumbnailAsync();
}
SyncFavoriteFlags();
StatusMessage = string.Empty;
}
private void RefreshFavorites()
{
Favorites.Clear();
foreach (var fav in _favorites.GetForSource(ActiveSource))
{
MemeTileViewModel tile;
if (fav.Source == MemeSource.Local)
{
var record = _library.GetAll().FirstOrDefault(r => r.Id == fav.Key);
// Favourite pointing at a meme that's since been deleted - skip it rather than
// rendering a broken tile.
if (record is null) continue;
tile = new MemeTileViewModel(record, _library.GetFullPath(record));
}
else
{
if (fav.PreviewUrl is null || fav.FullUrl is null) continue;
tile = new MemeTileViewModel(
new GifSearchResult
{
Id = fav.Key,
Title = fav.Title ?? "Favourite",
PreviewUrl = fav.PreviewUrl,
FullUrl = fav.FullUrl,
Source = fav.Source,
},
_gifCache);
}
tile.IsFavorite = true;
Favorites.Add(tile);
_ = tile.LoadThumbnailAsync();
}
HasFavorites = Favorites.Count > 0;
}
/// <summary>Keeps the star state on the main grid in step with the favourites store.</summary>
private void SyncFavoriteFlags()
{
foreach (var tile in Items)
{
tile.IsFavorite = _favorites.IsFavorite(tile.Source, tile.FavoriteKey);
}
}
}
@@ -9,6 +9,7 @@ public partial class SettingsViewModel : ObservableObject
{
private readonly ISettingsService _settings;
private readonly IGlobalHotkeyService _hotkeyService;
private readonly IAutostartService _autostart;
[ObservableProperty]
private string _hotkeyDisplay = string.Empty;
@@ -19,6 +20,12 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty]
private InsertMode _insertMode;
[ObservableProperty]
private bool _autostartEnabled;
[ObservableProperty]
private string _giphyApiKey = string.Empty;
public bool IsCopyOnly
{
get => InsertMode == InsertMode.CopyOnly;
@@ -37,12 +44,18 @@ public partial class SettingsViewModel : ObservableObject
set { if (value) InsertMode = InsertMode.PasteAndSend; }
}
public SettingsViewModel(ISettingsService settings, IGlobalHotkeyService hotkeyService)
public SettingsViewModel(ISettingsService settings, IGlobalHotkeyService hotkeyService, IAutostartService autostart)
{
_settings = settings;
_hotkeyService = hotkeyService;
_autostart = autostart;
_insertMode = _settings.Current.InsertMode;
_giphyApiKey = _settings.Current.GiphyApiKey ?? string.Empty;
_hotkeyDisplay = HotkeyFormatter.Format(_settings.Current.HotkeyModifiers, _settings.Current.HotkeyKey);
// Read from the registry rather than a stored setting, so the checkbox reflects reality
// even if the entry was removed outside the app.
_autostartEnabled = _autostart.IsEnabled;
}
partial void OnInsertModeChanged(InsertMode value)
@@ -54,6 +67,17 @@ public partial class SettingsViewModel : ObservableObject
OnPropertyChanged(nameof(IsPasteAndSend));
}
partial void OnAutostartEnabledChanged(bool value) => _autostart.SetEnabled(value);
partial void OnGiphyApiKeyChanged(string value)
{
_settings.Current.GiphyApiKey = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
_ = _settings.SaveAsync();
}
/// <summary>Called when the settings window is shown, to re-sync externally-changed state.</summary>
public void Refresh() => AutostartEnabled = _autostart.IsEnabled;
public void TrySetHotkey(ModifierKeys modifiers, Key key)
{
if (_hotkeyService.Register(modifiers, key))