From 62a257b5380af9e5d8fecd35c7d136914a0025af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ebbe=20Ba=C3=9F?= Date: Wed, 19 Aug 2026 15:16:57 +0200 Subject: [PATCH] Seperated favourites from sources, added gif playback preview --- .../EbbesMemeClipboard.csproj | 1 + src/EbbesMemeClipboard/Models/AppSettings.cs | 6 + .../ViewModels/MemeTileViewModel.cs | 36 +++- .../ViewModels/PickerViewModel.cs | 180 +++++++++++------- .../ViewModels/SettingsViewModel.cs | 10 + .../Views/PickerWindow.xaml | 106 +++++------ .../Views/SettingsWindow.xaml | 5 + 7 files changed, 209 insertions(+), 135 deletions(-) diff --git a/src/EbbesMemeClipboard/EbbesMemeClipboard.csproj b/src/EbbesMemeClipboard/EbbesMemeClipboard.csproj index 16ee60f..933941a 100644 --- a/src/EbbesMemeClipboard/EbbesMemeClipboard.csproj +++ b/src/EbbesMemeClipboard/EbbesMemeClipboard.csproj @@ -22,6 +22,7 @@ + diff --git a/src/EbbesMemeClipboard/Models/AppSettings.cs b/src/EbbesMemeClipboard/Models/AppSettings.cs index aee7342..4c39160 100644 --- a/src/EbbesMemeClipboard/Models/AppSettings.cs +++ b/src/EbbesMemeClipboard/Models/AppSettings.cs @@ -14,4 +14,10 @@ public sealed class AppSettings /// key is its own source of truth, so caching it would risk the two drifting apart. /// public string? GiphyApiKey { get; set; } + + /// + /// Animate GIF thumbnails in the grid. Off by default: playing a screenful of GIFs at once + /// costs noticeably more CPU and memory than showing static first frames. + /// + public bool PlayGifPreviews { get; set; } } diff --git a/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs b/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs index f9135a9..2d4f20b 100644 --- a/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs +++ b/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Windows.Media.Imaging; using CommunityToolkit.Mvvm.ComponentModel; using EbbesMemeClipboard.Models; @@ -11,11 +12,10 @@ namespace EbbesMemeClipboard.ViewModels; /// public partial class MemeTileViewModel : ObservableObject { - // Comfortably above the ~126px logical tile width so thumbnails stay sharp at 150-200% - // display scaling, without decoding anything near full resolution. private const int ThumbnailPixelWidth = 240; private readonly IGifCacheService? _cache; + private readonly bool _animatePreviews; private string? _localPath; public LocalMemeRecord? Record { get; } @@ -35,20 +35,33 @@ public partial class MemeTileViewModel : ObservableObject [ObservableProperty] private bool _isFavorite; + /// + /// Animation sources for XamlAnimatedGif. Only one is ever set, and only when GIF preview + /// playback is switched on - otherwise both stay null and the static Thumbnail shows. + /// A local file animates straight from disk; a remote one needs its bytes kept in memory. + /// + [ObservableProperty] + private Uri? _animationUri; + + [ObservableProperty] + private Stream? _animationStream; + public string FavoriteActionLabel => IsFavorite ? "Remove from favourites" : "Add to favourites"; partial void OnIsFavoriteChanged(bool value) => OnPropertyChanged(nameof(FavoriteActionLabel)); - public MemeTileViewModel(LocalMemeRecord record, string fullPath) + public MemeTileViewModel(LocalMemeRecord record, string fullPath, bool animatePreviews = false) { Record = record; _localPath = fullPath; + _animatePreviews = animatePreviews; } - public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache) + public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache, bool animatePreviews = false) { RemoteResult = remoteResult; _cache = cache; + _animatePreviews = animatePreviews; } /// @@ -71,11 +84,23 @@ public partial class MemeTileViewModel : ObservableObject { var path = _localPath!; Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth), ct); + + if (_animatePreviews && ImageDecoding.IsGif(path)) + { + AnimationUri = new Uri(path); + } } else { var bytes = await _cache!.DownloadPreviewAsync(RemoteResult!, ct); Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrameFromBytes(bytes, ThumbnailPixelWidth), ct); + + // Remote previews have no file on disk, so animation has to run off the bytes + // we already fetched for the thumbnail. + if (_animatePreviews && LooksLikeGif(RemoteResult!.PreviewUrl)) + { + AnimationStream = new MemoryStream(bytes); + } } } catch (OperationCanceledException) @@ -88,4 +113,7 @@ public partial class MemeTileViewModel : ObservableObject // renders empty and stays clickable. } } + + private static bool LooksLikeGif(string url) => + url.Contains(".gif", StringComparison.OrdinalIgnoreCase); } diff --git a/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs b/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs index 3941ea3..de72872 100644 --- a/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs +++ b/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Collections.Specialized; using System.IO; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -9,7 +10,7 @@ namespace EbbesMemeClipboard.ViewModels; public partial class PickerViewModel : ObservableObject { - // 50 is Giphy's maximum per request; more results come from paging via offset as the user + // 50 is the Giphy per-request maximum; 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); @@ -34,9 +35,6 @@ public partial class PickerViewModel : ObservableObject public ObservableCollection Items { get; } = new(); - /// Favourites for the currently active source only. - public ObservableCollection Favorites { get; } = new(); - [ObservableProperty] private string _searchText = string.Empty; @@ -49,8 +47,12 @@ public partial class PickerViewModel : ObservableObject [ObservableProperty] private bool _isDialogOpen; + /// + /// Favourites act as a filtered view of the active source rather than an inline row, so they + /// get the full grid and the search box narrows within them. + /// [ObservableProperty] - private bool _hasFavorites; + private bool _showingFavorites; [ObservableProperty] private MemeSource _activeSource = MemeSource.Local; @@ -58,12 +60,25 @@ 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..."; + /// Importing only applies to the local library, and not while browsing favourites. + public bool CanImport => IsLocalSource && !ShowingFavorites; + + public string SearchPlaceholder => ShowingFavorites + ? "Search favourites..." + : IsLocalSource + ? "Search your memes..." + : ActiveProvider?.SearchPlaceholder ?? "Search..."; + + public bool ShowEmptyState => Items.Count == 0 && !IsBusy && (ShowingFavorites || IsLocalSource); + + public string EmptyStateText => ShowingFavorites + ? "No favourites here yet. Right-click any meme to add one." + : "No memes yet. Click +, drag files in, or paste with Ctrl+V."; private IGifProvider? ActiveProvider => _providers.FirstOrDefault(p => p.Source == ActiveSource); + private bool AnimatePreviews => _settings.Current.PlayGifPreviews; + public event EventHandler? RequestClose; public PickerViewModel( @@ -83,19 +98,35 @@ public partial class PickerViewModel : ObservableObject _favorites = favorites; _providers = providers.ToList(); + Items.CollectionChanged += OnItemsChanged; RefreshLocalItems(); - RefreshFavorites(); } + private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) => + OnPropertyChanged(nameof(ShowEmptyState)); + partial void OnSearchTextChanged(string value) => _ = RefreshAsync(); + partial void OnIsBusyChanged(bool value) => OnPropertyChanged(nameof(ShowEmptyState)); + + partial void OnShowingFavoritesChanged(bool value) + { + OnPropertyChanged(nameof(SearchPlaceholder)); + OnPropertyChanged(nameof(CanImport)); + OnPropertyChanged(nameof(EmptyStateText)); + OnPropertyChanged(nameof(ShowEmptyState)); + SearchText = string.Empty; + _ = RefreshAsync(); + } + partial void OnActiveSourceChanged(MemeSource value) { OnPropertyChanged(nameof(IsLocalSource)); OnPropertyChanged(nameof(IsGiphySource)); OnPropertyChanged(nameof(SearchPlaceholder)); + OnPropertyChanged(nameof(CanImport)); + OnPropertyChanged(nameof(ShowEmptyState)); 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(); @@ -104,6 +135,9 @@ public partial class PickerViewModel : ObservableObject [RelayCommand] private void SelectSource(MemeSource source) => ActiveSource = source; + [RelayCommand] + private void ToggleFavoritesView() => ShowingFavorites = !ShowingFavorites; + [RelayCommand] private async Task SelectItemAsync(MemeTileViewModel? tile) { @@ -146,7 +180,11 @@ public partial class PickerViewModel : ObservableObject if (_favorites.IsFavorite(tile.Source, tile.FavoriteKey)) { await _favorites.RemoveAsync(tile.Source, tile.FavoriteKey); + tile.IsFavorite = false; StatusMessage = $"Removed {tile.DisplayName} from favourites"; + + // In the favourites view an unfavourited tile no longer belongs on screen. + if (ShowingFavorites) Items.Remove(tile); } else { @@ -159,11 +197,9 @@ public partial class PickerViewModel : ObservableObject FullUrl = tile.RemoteResult?.FullUrl, DateAdded = DateTimeOffset.Now, }); + tile.IsFavorite = true; StatusMessage = $"Added {tile.DisplayName} to favourites"; } - - RefreshFavorites(); - SyncFavoriteFlags(); } [RelayCommand] @@ -171,13 +207,13 @@ public partial class PickerViewModel : ObservableObject { if (tile?.Record is null) return; + var name = tile.DisplayName; await _library.RemoveAsync(tile.Record); // 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}"; + await RefreshAsync(); + StatusMessage = $"Removed {name}"; } [RelayCommand] @@ -242,6 +278,7 @@ public partial class PickerViewModel : ObservableObject return; } + ShowingFavorites = false; ActiveSource = MemeSource.Local; RefreshLocalItems(); StatusMessage = $"Pasted {name}"; @@ -265,6 +302,7 @@ public partial class PickerViewModel : ObservableObject } var imported = await _library.ImportAsync(candidates); + ShowingFavorites = false; ActiveSource = MemeSource.Local; RefreshLocalItems(); StatusMessage = $"Added {imported.Count} meme(s)."; @@ -273,11 +311,9 @@ public partial class PickerViewModel : ObservableObject public void OnShown() { SearchText = string.Empty; - if (IsLocalSource) - { - RefreshLocalItems(); - } - RefreshFavorites(); + // Rebuild rather than just reset: picks up memes added since last time, and lets a + // changed "animate GIF previews" setting take effect. + _ = RefreshAsync(); } /// @@ -286,7 +322,7 @@ public partial class PickerViewModel : ObservableObject /// public async Task LoadMoreAsync() { - if (IsLocalSource || _showingResolvedLink || !_remoteHasMore || _isLoadingMore) return; + if (IsLocalSource || ShowingFavorites || _showingResolvedLink || !_remoteHasMore || _isLoadingMore) return; var provider = ActiveProvider; if (provider is null || !provider.IsConfigured) return; @@ -303,14 +339,11 @@ public partial class PickerViewModel : ObservableObject foreach (var result in results) { - var tile = new MemeTileViewModel(result, _gifCache); - Items.Add(tile); - _ = tile.LoadThumbnailAsync(); + AddRemoteTile(result); } _remoteOffset += results.Count; _remoteHasMore = results.Count >= RemoteResultLimit; - SyncFavoriteFlags(); StatusMessage = $"{Items.Count} result(s)."; } catch (Exception ex) @@ -326,7 +359,7 @@ public partial class PickerViewModel : ObservableObject private async Task RefreshAsync() { - // Any pending remote search is stale the moment the query or tab changes. + // Any pending remote search is stale the moment the query, tab or view changes. _searchCts?.Cancel(); _searchCts?.Dispose(); _searchCts = null; @@ -335,6 +368,12 @@ public partial class PickerViewModel : ObservableObject _remoteHasMore = false; _showingResolvedLink = false; + if (ShowingFavorites) + { + RefreshFavoriteItems(); + return; + } + if (IsLocalSource) { RefreshLocalItems(); @@ -375,15 +414,11 @@ public partial class PickerViewModel : ObservableObject Items.Clear(); foreach (var result in results) { - var tile = new MemeTileViewModel(result, _gifCache); - Items.Add(tile); - _ = tile.LoadThumbnailAsync(cts.Token); + AddRemoteTile(result, cts.Token); } _remoteOffset = results.Count; _remoteHasMore = results.Count >= RemoteResultLimit; - - SyncFavoriteFlags(); StatusMessage = results.Count == 0 ? "No results." : $"{results.Count} result(s)."; } catch (OperationCanceledException) @@ -417,16 +452,33 @@ public partial class PickerViewModel : ObservableObject return; } - var tile = new MemeTileViewModel(result, _gifCache); - Items.Add(tile); - _ = tile.LoadThumbnailAsync(ct); + AddRemoteTile(result, ct); // A single resolved item has nothing to page through. _showingResolvedLink = true; - SyncFavoriteFlags(); StatusMessage = "Found 1 GIF from link."; } + private void AddRemoteTile(GifSearchResult result, CancellationToken ct = default) + { + var tile = new MemeTileViewModel(result, _gifCache, AnimatePreviews) + { + IsFavorite = _favorites.IsFavorite(result.Source, result.Id), + }; + Items.Add(tile); + _ = tile.LoadThumbnailAsync(ct); + } + + private void AddLocalTile(LocalMemeRecord record) + { + var tile = new MemeTileViewModel(record, _library.GetFullPath(record), AnimatePreviews) + { + IsFavorite = _favorites.IsFavorite(MemeSource.Local, record.Id), + }; + Items.Add(tile); + _ = tile.LoadThumbnailAsync(); + } + private void RefreshLocalItems() { var records = string.IsNullOrWhiteSpace(SearchText) @@ -436,60 +488,48 @@ public partial class PickerViewModel : ObservableObject Items.Clear(); foreach (var record in records) { - var tile = new MemeTileViewModel(record, _library.GetFullPath(record)); - Items.Add(tile); - _ = tile.LoadThumbnailAsync(); + AddLocalTile(record); } - SyncFavoriteFlags(); StatusMessage = string.Empty; } - private void RefreshFavorites() + /// Favourites for the active source, narrowed by the search box like any other view. + private void RefreshFavoriteItems() { - Favorites.Clear(); + Items.Clear(); + var query = SearchText; 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. + // Favourite pointing at a meme that has since been deleted - skip it rather + // than rendering a broken tile. if (record is null) continue; - tile = new MemeTileViewModel(record, _library.GetFullPath(record)); + if (!Matches(record.OriginalFileName, query)) continue; + AddLocalTile(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); + if (!Matches(fav.Title ?? string.Empty, query)) continue; + + AddRemoteTile(new GifSearchResult + { + Id = fav.Key, + Title = fav.Title ?? "Favourite", + PreviewUrl = fav.PreviewUrl, + FullUrl = fav.FullUrl, + Source = fav.Source, + }); } - - tile.IsFavorite = true; - Favorites.Add(tile); - _ = tile.LoadThumbnailAsync(); } - HasFavorites = Favorites.Count > 0; + StatusMessage = Items.Count == 0 ? string.Empty : $"{Items.Count} favourite(s)."; } - /// Keeps the star state on the main grid in step with the favourites store. - private void SyncFavoriteFlags() - { - foreach (var tile in Items) - { - tile.IsFavorite = _favorites.IsFavorite(tile.Source, tile.FavoriteKey); - } - } + private static bool Matches(string text, string query) => + string.IsNullOrWhiteSpace(query) || text.Contains(query, StringComparison.OrdinalIgnoreCase); } diff --git a/src/EbbesMemeClipboard/ViewModels/SettingsViewModel.cs b/src/EbbesMemeClipboard/ViewModels/SettingsViewModel.cs index 46adaf7..6c67729 100644 --- a/src/EbbesMemeClipboard/ViewModels/SettingsViewModel.cs +++ b/src/EbbesMemeClipboard/ViewModels/SettingsViewModel.cs @@ -26,6 +26,9 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private string _giphyApiKey = string.Empty; + [ObservableProperty] + private bool _playGifPreviews; + public bool IsCopyOnly { get => InsertMode == InsertMode.CopyOnly; @@ -52,6 +55,7 @@ public partial class SettingsViewModel : ObservableObject _insertMode = _settings.Current.InsertMode; _giphyApiKey = _settings.Current.GiphyApiKey ?? string.Empty; + _playGifPreviews = _settings.Current.PlayGifPreviews; _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. @@ -69,6 +73,12 @@ public partial class SettingsViewModel : ObservableObject partial void OnAutostartEnabledChanged(bool value) => _autostart.SetEnabled(value); + partial void OnPlayGifPreviewsChanged(bool value) + { + _settings.Current.PlayGifPreviews = value; + _ = _settings.SaveAsync(); + } + partial void OnGiphyApiKeyChanged(string value) { _settings.Current.GiphyApiKey = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); diff --git a/src/EbbesMemeClipboard/Views/PickerWindow.xaml b/src/EbbesMemeClipboard/Views/PickerWindow.xaml index b62c715..aa1f7ac 100644 --- a/src/EbbesMemeClipboard/Views/PickerWindow.xaml +++ b/src/EbbesMemeClipboard/Views/PickerWindow.xaml @@ -4,6 +4,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="clr-namespace:EbbesMemeClipboard.ViewModels" xmlns:models="clr-namespace:EbbesMemeClipboard.Models" + xmlns:gif="clr-namespace:XamlAnimatedGif;assembly=XamlAnimatedGif" Title="Meme Clipboard" Width="420" Height="520" WindowStyle="None" @@ -98,7 +99,13 @@ CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/> - + + @@ -133,18 +140,27 @@ - -