Added Giphy support, Favourites
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user