Seperated favourites from sources, added gif playback preview
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
<PackageReference Include="WPF-UI" Version="4.3.0" />
|
<PackageReference Include="WPF-UI" Version="4.3.0" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||||
|
<PackageReference Include="XamlAnimatedGif" Version="2.3.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -14,4 +14,10 @@ public sealed class AppSettings
|
|||||||
/// key is its own source of truth, so caching it would risk the two drifting apart.
|
/// key is its own source of truth, so caching it would risk the two drifting apart.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? GiphyApiKey { get; set; }
|
public string? GiphyApiKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool PlayGifPreviews { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.IO;
|
||||||
using System.Windows.Media.Imaging;
|
using System.Windows.Media.Imaging;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using EbbesMemeClipboard.Models;
|
using EbbesMemeClipboard.Models;
|
||||||
@@ -11,11 +12,10 @@ namespace EbbesMemeClipboard.ViewModels;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class MemeTileViewModel : ObservableObject
|
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 const int ThumbnailPixelWidth = 240;
|
||||||
|
|
||||||
private readonly IGifCacheService? _cache;
|
private readonly IGifCacheService? _cache;
|
||||||
|
private readonly bool _animatePreviews;
|
||||||
private string? _localPath;
|
private string? _localPath;
|
||||||
|
|
||||||
public LocalMemeRecord? Record { get; }
|
public LocalMemeRecord? Record { get; }
|
||||||
@@ -35,20 +35,33 @@ public partial class MemeTileViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isFavorite;
|
private bool _isFavorite;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private Uri? _animationUri;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private Stream? _animationStream;
|
||||||
|
|
||||||
public string FavoriteActionLabel => IsFavorite ? "Remove from favourites" : "Add to favourites";
|
public string FavoriteActionLabel => IsFavorite ? "Remove from favourites" : "Add to favourites";
|
||||||
|
|
||||||
partial void OnIsFavoriteChanged(bool value) => OnPropertyChanged(nameof(FavoriteActionLabel));
|
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;
|
Record = record;
|
||||||
_localPath = fullPath;
|
_localPath = fullPath;
|
||||||
|
_animatePreviews = animatePreviews;
|
||||||
}
|
}
|
||||||
|
|
||||||
public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache)
|
public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache, bool animatePreviews = false)
|
||||||
{
|
{
|
||||||
RemoteResult = remoteResult;
|
RemoteResult = remoteResult;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
_animatePreviews = animatePreviews;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -71,11 +84,23 @@ public partial class MemeTileViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
var path = _localPath!;
|
var path = _localPath!;
|
||||||
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth), ct);
|
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth), ct);
|
||||||
|
|
||||||
|
if (_animatePreviews && ImageDecoding.IsGif(path))
|
||||||
|
{
|
||||||
|
AnimationUri = new Uri(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var bytes = await _cache!.DownloadPreviewAsync(RemoteResult!, ct);
|
var bytes = await _cache!.DownloadPreviewAsync(RemoteResult!, ct);
|
||||||
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrameFromBytes(bytes, ThumbnailPixelWidth), 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)
|
catch (OperationCanceledException)
|
||||||
@@ -88,4 +113,7 @@ public partial class MemeTileViewModel : ObservableObject
|
|||||||
// renders empty and stays clickable.
|
// renders empty and stays clickable.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool LooksLikeGif(string url) =>
|
||||||
|
url.Contains(".gif", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Collections.Specialized;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
@@ -9,7 +10,7 @@ namespace EbbesMemeClipboard.ViewModels;
|
|||||||
|
|
||||||
public partial class PickerViewModel : ObservableObject
|
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.
|
// scrolls rather than from a single bigger call.
|
||||||
private const int RemoteResultLimit = 50;
|
private const int RemoteResultLimit = 50;
|
||||||
private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(350);
|
private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(350);
|
||||||
@@ -34,9 +35,6 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
|
|
||||||
public ObservableCollection<MemeTileViewModel> Items { get; } = new();
|
public ObservableCollection<MemeTileViewModel> Items { get; } = new();
|
||||||
|
|
||||||
/// <summary>Favourites for the currently active source only.</summary>
|
|
||||||
public ObservableCollection<MemeTileViewModel> Favorites { get; } = new();
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _searchText = string.Empty;
|
private string _searchText = string.Empty;
|
||||||
|
|
||||||
@@ -49,8 +47,12 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isDialogOpen;
|
private bool _isDialogOpen;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _hasFavorites;
|
private bool _showingFavorites;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private MemeSource _activeSource = MemeSource.Local;
|
private MemeSource _activeSource = MemeSource.Local;
|
||||||
@@ -58,12 +60,25 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
public bool IsLocalSource => ActiveSource == MemeSource.Local;
|
public bool IsLocalSource => ActiveSource == MemeSource.Local;
|
||||||
public bool IsGiphySource => ActiveSource == MemeSource.Giphy;
|
public bool IsGiphySource => ActiveSource == MemeSource.Giphy;
|
||||||
|
|
||||||
public string SearchPlaceholder => IsLocalSource
|
/// <summary>Importing only applies to the local library, and not while browsing favourites.</summary>
|
||||||
? "Search your memes..."
|
public bool CanImport => IsLocalSource && !ShowingFavorites;
|
||||||
: ActiveProvider?.SearchPlaceholder ?? "Search...";
|
|
||||||
|
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 IGifProvider? ActiveProvider => _providers.FirstOrDefault(p => p.Source == ActiveSource);
|
||||||
|
|
||||||
|
private bool AnimatePreviews => _settings.Current.PlayGifPreviews;
|
||||||
|
|
||||||
public event EventHandler? RequestClose;
|
public event EventHandler? RequestClose;
|
||||||
|
|
||||||
public PickerViewModel(
|
public PickerViewModel(
|
||||||
@@ -83,19 +98,35 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
_favorites = favorites;
|
_favorites = favorites;
|
||||||
_providers = providers.ToList();
|
_providers = providers.ToList();
|
||||||
|
|
||||||
|
Items.CollectionChanged += OnItemsChanged;
|
||||||
RefreshLocalItems();
|
RefreshLocalItems();
|
||||||
RefreshFavorites();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
|
||||||
|
OnPropertyChanged(nameof(ShowEmptyState));
|
||||||
|
|
||||||
partial void OnSearchTextChanged(string value) => _ = RefreshAsync();
|
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)
|
partial void OnActiveSourceChanged(MemeSource value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsLocalSource));
|
OnPropertyChanged(nameof(IsLocalSource));
|
||||||
OnPropertyChanged(nameof(IsGiphySource));
|
OnPropertyChanged(nameof(IsGiphySource));
|
||||||
OnPropertyChanged(nameof(SearchPlaceholder));
|
OnPropertyChanged(nameof(SearchPlaceholder));
|
||||||
|
OnPropertyChanged(nameof(CanImport));
|
||||||
|
OnPropertyChanged(nameof(ShowEmptyState));
|
||||||
SearchText = string.Empty;
|
SearchText = string.Empty;
|
||||||
RefreshFavorites();
|
|
||||||
// Setting SearchText above only triggers a refresh if the value actually changed, so
|
// 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.
|
// refresh explicitly here to cover switching tabs with an already-empty box.
|
||||||
_ = RefreshAsync();
|
_ = RefreshAsync();
|
||||||
@@ -104,6 +135,9 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void SelectSource(MemeSource source) => ActiveSource = source;
|
private void SelectSource(MemeSource source) => ActiveSource = source;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleFavoritesView() => ShowingFavorites = !ShowingFavorites;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task SelectItemAsync(MemeTileViewModel? tile)
|
private async Task SelectItemAsync(MemeTileViewModel? tile)
|
||||||
{
|
{
|
||||||
@@ -146,7 +180,11 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
if (_favorites.IsFavorite(tile.Source, tile.FavoriteKey))
|
if (_favorites.IsFavorite(tile.Source, tile.FavoriteKey))
|
||||||
{
|
{
|
||||||
await _favorites.RemoveAsync(tile.Source, tile.FavoriteKey);
|
await _favorites.RemoveAsync(tile.Source, tile.FavoriteKey);
|
||||||
|
tile.IsFavorite = false;
|
||||||
StatusMessage = $"Removed {tile.DisplayName} from favourites";
|
StatusMessage = $"Removed {tile.DisplayName} from favourites";
|
||||||
|
|
||||||
|
// In the favourites view an unfavourited tile no longer belongs on screen.
|
||||||
|
if (ShowingFavorites) Items.Remove(tile);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -159,11 +197,9 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
FullUrl = tile.RemoteResult?.FullUrl,
|
FullUrl = tile.RemoteResult?.FullUrl,
|
||||||
DateAdded = DateTimeOffset.Now,
|
DateAdded = DateTimeOffset.Now,
|
||||||
});
|
});
|
||||||
|
tile.IsFavorite = true;
|
||||||
StatusMessage = $"Added {tile.DisplayName} to favourites";
|
StatusMessage = $"Added {tile.DisplayName} to favourites";
|
||||||
}
|
}
|
||||||
|
|
||||||
RefreshFavorites();
|
|
||||||
SyncFavoriteFlags();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -171,13 +207,13 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
if (tile?.Record is null) return;
|
if (tile?.Record is null) return;
|
||||||
|
|
||||||
|
var name = tile.DisplayName;
|
||||||
await _library.RemoveAsync(tile.Record);
|
await _library.RemoveAsync(tile.Record);
|
||||||
// Drop the matching favourite too, otherwise it lingers pointing at a deleted file.
|
// Drop the matching favourite too, otherwise it lingers pointing at a deleted file.
|
||||||
await _favorites.RemoveAsync(MemeSource.Local, tile.Record.Id);
|
await _favorites.RemoveAsync(MemeSource.Local, tile.Record.Id);
|
||||||
|
|
||||||
RefreshLocalItems();
|
await RefreshAsync();
|
||||||
RefreshFavorites();
|
StatusMessage = $"Removed {name}";
|
||||||
StatusMessage = $"Removed {tile.DisplayName}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -242,6 +278,7 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ShowingFavorites = false;
|
||||||
ActiveSource = MemeSource.Local;
|
ActiveSource = MemeSource.Local;
|
||||||
RefreshLocalItems();
|
RefreshLocalItems();
|
||||||
StatusMessage = $"Pasted {name}";
|
StatusMessage = $"Pasted {name}";
|
||||||
@@ -265,6 +302,7 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
var imported = await _library.ImportAsync(candidates);
|
var imported = await _library.ImportAsync(candidates);
|
||||||
|
ShowingFavorites = false;
|
||||||
ActiveSource = MemeSource.Local;
|
ActiveSource = MemeSource.Local;
|
||||||
RefreshLocalItems();
|
RefreshLocalItems();
|
||||||
StatusMessage = $"Added {imported.Count} meme(s).";
|
StatusMessage = $"Added {imported.Count} meme(s).";
|
||||||
@@ -273,11 +311,9 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
public void OnShown()
|
public void OnShown()
|
||||||
{
|
{
|
||||||
SearchText = string.Empty;
|
SearchText = string.Empty;
|
||||||
if (IsLocalSource)
|
// Rebuild rather than just reset: picks up memes added since last time, and lets a
|
||||||
{
|
// changed "animate GIF previews" setting take effect.
|
||||||
RefreshLocalItems();
|
_ = RefreshAsync();
|
||||||
}
|
|
||||||
RefreshFavorites();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -286,7 +322,7 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task LoadMoreAsync()
|
public async Task LoadMoreAsync()
|
||||||
{
|
{
|
||||||
if (IsLocalSource || _showingResolvedLink || !_remoteHasMore || _isLoadingMore) return;
|
if (IsLocalSource || ShowingFavorites || _showingResolvedLink || !_remoteHasMore || _isLoadingMore) return;
|
||||||
|
|
||||||
var provider = ActiveProvider;
|
var provider = ActiveProvider;
|
||||||
if (provider is null || !provider.IsConfigured) return;
|
if (provider is null || !provider.IsConfigured) return;
|
||||||
@@ -303,14 +339,11 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
|
|
||||||
foreach (var result in results)
|
foreach (var result in results)
|
||||||
{
|
{
|
||||||
var tile = new MemeTileViewModel(result, _gifCache);
|
AddRemoteTile(result);
|
||||||
Items.Add(tile);
|
|
||||||
_ = tile.LoadThumbnailAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_remoteOffset += results.Count;
|
_remoteOffset += results.Count;
|
||||||
_remoteHasMore = results.Count >= RemoteResultLimit;
|
_remoteHasMore = results.Count >= RemoteResultLimit;
|
||||||
SyncFavoriteFlags();
|
|
||||||
StatusMessage = $"{Items.Count} result(s).";
|
StatusMessage = $"{Items.Count} result(s).";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -326,7 +359,7 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
|
|
||||||
private async Task RefreshAsync()
|
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?.Cancel();
|
||||||
_searchCts?.Dispose();
|
_searchCts?.Dispose();
|
||||||
_searchCts = null;
|
_searchCts = null;
|
||||||
@@ -335,6 +368,12 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
_remoteHasMore = false;
|
_remoteHasMore = false;
|
||||||
_showingResolvedLink = false;
|
_showingResolvedLink = false;
|
||||||
|
|
||||||
|
if (ShowingFavorites)
|
||||||
|
{
|
||||||
|
RefreshFavoriteItems();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (IsLocalSource)
|
if (IsLocalSource)
|
||||||
{
|
{
|
||||||
RefreshLocalItems();
|
RefreshLocalItems();
|
||||||
@@ -375,15 +414,11 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
Items.Clear();
|
Items.Clear();
|
||||||
foreach (var result in results)
|
foreach (var result in results)
|
||||||
{
|
{
|
||||||
var tile = new MemeTileViewModel(result, _gifCache);
|
AddRemoteTile(result, cts.Token);
|
||||||
Items.Add(tile);
|
|
||||||
_ = tile.LoadThumbnailAsync(cts.Token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_remoteOffset = results.Count;
|
_remoteOffset = results.Count;
|
||||||
_remoteHasMore = results.Count >= RemoteResultLimit;
|
_remoteHasMore = results.Count >= RemoteResultLimit;
|
||||||
|
|
||||||
SyncFavoriteFlags();
|
|
||||||
StatusMessage = results.Count == 0 ? "No results." : $"{results.Count} result(s).";
|
StatusMessage = results.Count == 0 ? "No results." : $"{results.Count} result(s).";
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
@@ -417,16 +452,33 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tile = new MemeTileViewModel(result, _gifCache);
|
AddRemoteTile(result, ct);
|
||||||
Items.Add(tile);
|
|
||||||
_ = tile.LoadThumbnailAsync(ct);
|
|
||||||
|
|
||||||
// A single resolved item has nothing to page through.
|
// A single resolved item has nothing to page through.
|
||||||
_showingResolvedLink = true;
|
_showingResolvedLink = true;
|
||||||
SyncFavoriteFlags();
|
|
||||||
StatusMessage = "Found 1 GIF from link.";
|
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()
|
private void RefreshLocalItems()
|
||||||
{
|
{
|
||||||
var records = string.IsNullOrWhiteSpace(SearchText)
|
var records = string.IsNullOrWhiteSpace(SearchText)
|
||||||
@@ -436,60 +488,48 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
Items.Clear();
|
Items.Clear();
|
||||||
foreach (var record in records)
|
foreach (var record in records)
|
||||||
{
|
{
|
||||||
var tile = new MemeTileViewModel(record, _library.GetFullPath(record));
|
AddLocalTile(record);
|
||||||
Items.Add(tile);
|
|
||||||
_ = tile.LoadThumbnailAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SyncFavoriteFlags();
|
|
||||||
StatusMessage = string.Empty;
|
StatusMessage = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RefreshFavorites()
|
/// <summary>Favourites for the active source, narrowed by the search box like any other view.</summary>
|
||||||
|
private void RefreshFavoriteItems()
|
||||||
{
|
{
|
||||||
Favorites.Clear();
|
Items.Clear();
|
||||||
|
|
||||||
|
var query = SearchText;
|
||||||
foreach (var fav in _favorites.GetForSource(ActiveSource))
|
foreach (var fav in _favorites.GetForSource(ActiveSource))
|
||||||
{
|
{
|
||||||
MemeTileViewModel tile;
|
|
||||||
|
|
||||||
if (fav.Source == MemeSource.Local)
|
if (fav.Source == MemeSource.Local)
|
||||||
{
|
{
|
||||||
var record = _library.GetAll().FirstOrDefault(r => r.Id == fav.Key);
|
var record = _library.GetAll().FirstOrDefault(r => r.Id == fav.Key);
|
||||||
// Favourite pointing at a meme that's since been deleted - skip it rather than
|
// Favourite pointing at a meme that has since been deleted - skip it rather
|
||||||
// rendering a broken tile.
|
// than rendering a broken tile.
|
||||||
if (record is null) continue;
|
if (record is null) continue;
|
||||||
tile = new MemeTileViewModel(record, _library.GetFullPath(record));
|
if (!Matches(record.OriginalFileName, query)) continue;
|
||||||
|
AddLocalTile(record);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (fav.PreviewUrl is null || fav.FullUrl is null) continue;
|
if (fav.PreviewUrl is null || fav.FullUrl is null) continue;
|
||||||
tile = new MemeTileViewModel(
|
if (!Matches(fav.Title ?? string.Empty, query)) continue;
|
||||||
new GifSearchResult
|
|
||||||
{
|
AddRemoteTile(new GifSearchResult
|
||||||
Id = fav.Key,
|
{
|
||||||
Title = fav.Title ?? "Favourite",
|
Id = fav.Key,
|
||||||
PreviewUrl = fav.PreviewUrl,
|
Title = fav.Title ?? "Favourite",
|
||||||
FullUrl = fav.FullUrl,
|
PreviewUrl = fav.PreviewUrl,
|
||||||
Source = fav.Source,
|
FullUrl = fav.FullUrl,
|
||||||
},
|
Source = fav.Source,
|
||||||
_gifCache);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
tile.IsFavorite = true;
|
|
||||||
Favorites.Add(tile);
|
|
||||||
_ = tile.LoadThumbnailAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HasFavorites = Favorites.Count > 0;
|
StatusMessage = Items.Count == 0 ? string.Empty : $"{Items.Count} favourite(s).";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Keeps the star state on the main grid in step with the favourites store.</summary>
|
private static bool Matches(string text, string query) =>
|
||||||
private void SyncFavoriteFlags()
|
string.IsNullOrWhiteSpace(query) || text.Contains(query, StringComparison.OrdinalIgnoreCase);
|
||||||
{
|
|
||||||
foreach (var tile in Items)
|
|
||||||
{
|
|
||||||
tile.IsFavorite = _favorites.IsFavorite(tile.Source, tile.FavoriteKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _giphyApiKey = string.Empty;
|
private string _giphyApiKey = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _playGifPreviews;
|
||||||
|
|
||||||
public bool IsCopyOnly
|
public bool IsCopyOnly
|
||||||
{
|
{
|
||||||
get => InsertMode == InsertMode.CopyOnly;
|
get => InsertMode == InsertMode.CopyOnly;
|
||||||
@@ -52,6 +55,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
|
|
||||||
_insertMode = _settings.Current.InsertMode;
|
_insertMode = _settings.Current.InsertMode;
|
||||||
_giphyApiKey = _settings.Current.GiphyApiKey ?? string.Empty;
|
_giphyApiKey = _settings.Current.GiphyApiKey ?? string.Empty;
|
||||||
|
_playGifPreviews = _settings.Current.PlayGifPreviews;
|
||||||
_hotkeyDisplay = HotkeyFormatter.Format(_settings.Current.HotkeyModifiers, _settings.Current.HotkeyKey);
|
_hotkeyDisplay = HotkeyFormatter.Format(_settings.Current.HotkeyModifiers, _settings.Current.HotkeyKey);
|
||||||
// Read from the registry rather than a stored setting, so the checkbox reflects reality
|
// Read from the registry rather than a stored setting, so the checkbox reflects reality
|
||||||
// even if the entry was removed outside the app.
|
// 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 OnAutostartEnabledChanged(bool value) => _autostart.SetEnabled(value);
|
||||||
|
|
||||||
|
partial void OnPlayGifPreviewsChanged(bool value)
|
||||||
|
{
|
||||||
|
_settings.Current.PlayGifPreviews = value;
|
||||||
|
_ = _settings.SaveAsync();
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnGiphyApiKeyChanged(string value)
|
partial void OnGiphyApiKeyChanged(string value)
|
||||||
{
|
{
|
||||||
_settings.Current.GiphyApiKey = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
_settings.Current.GiphyApiKey = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:EbbesMemeClipboard.ViewModels"
|
xmlns:vm="clr-namespace:EbbesMemeClipboard.ViewModels"
|
||||||
xmlns:models="clr-namespace:EbbesMemeClipboard.Models"
|
xmlns:models="clr-namespace:EbbesMemeClipboard.Models"
|
||||||
|
xmlns:gif="clr-namespace:XamlAnimatedGif;assembly=XamlAnimatedGif"
|
||||||
Title="Meme Clipboard"
|
Title="Meme Clipboard"
|
||||||
Width="420" Height="520"
|
Width="420" Height="520"
|
||||||
WindowStyle="None"
|
WindowStyle="None"
|
||||||
@@ -98,7 +99,13 @@
|
|||||||
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
|
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</Button.ContextMenu>
|
</Button.ContextMenu>
|
||||||
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Margin="5"/>
|
<!-- Source is the static first frame; the gif attached properties take over only when
|
||||||
|
preview playback is enabled (both stay null otherwise). -->
|
||||||
|
<Image Stretch="Uniform" Margin="5"
|
||||||
|
Source="{Binding Thumbnail}"
|
||||||
|
gif:AnimationBehavior.SourceUri="{Binding AnimationUri}"
|
||||||
|
gif:AnimationBehavior.SourceStream="{Binding AnimationStream}"
|
||||||
|
gif:AnimationBehavior.RepeatBehavior="Forever"/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Star overlay: marks favourites at a glance without needing a second grid. -->
|
<!-- Star overlay: marks favourites at a glance without needing a second grid. -->
|
||||||
@@ -133,18 +140,27 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,4,0,8">
|
<Grid Grid.Row="1" Margin="0,4,0,8">
|
||||||
<Button Content="Local"
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
|
||||||
|
<Button Content="Local"
|
||||||
|
Style="{StaticResource TabButtonStyle}"
|
||||||
|
Tag="{Binding IsLocalSource}"
|
||||||
|
Command="{Binding SelectSourceCommand}"
|
||||||
|
CommandParameter="{x:Static models:MemeSource.Local}"/>
|
||||||
|
<Button Content="Giphy" Margin="6,0,0,0"
|
||||||
|
Style="{StaticResource TabButtonStyle}"
|
||||||
|
Tag="{Binding IsGiphySource}"
|
||||||
|
Command="{Binding SelectSourceCommand}"
|
||||||
|
CommandParameter="{x:Static models:MemeSource.Giphy}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<!-- Right-aligned and separated from the source tabs on purpose: it filters
|
||||||
|
the active source rather than being a source of its own. -->
|
||||||
|
<Button Content="★ Favourites" HorizontalAlignment="Right"
|
||||||
Style="{StaticResource TabButtonStyle}"
|
Style="{StaticResource TabButtonStyle}"
|
||||||
Tag="{Binding IsLocalSource}"
|
Tag="{Binding ShowingFavorites}"
|
||||||
Command="{Binding SelectSourceCommand}"
|
Command="{Binding ToggleFavoritesViewCommand}"
|
||||||
CommandParameter="{x:Static models:MemeSource.Local}"/>
|
ToolTip="Show only your favourites from this source"/>
|
||||||
<Button Content="Giphy" Margin="6,0,0,0"
|
</Grid>
|
||||||
Style="{StaticResource TabButtonStyle}"
|
|
||||||
Tag="{Binding IsGiphySource}"
|
|
||||||
Command="{Binding SelectSourceCommand}"
|
|
||||||
CommandParameter="{x:Static models:MemeSource.Giphy}"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<Grid Grid.Row="2" Margin="0,0,0,10">
|
<Grid Grid.Row="2" Margin="0,0,0,10">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
@@ -182,61 +198,29 @@
|
|||||||
<Button Grid.Column="1" Content="+" Width="36" Height="36" Margin="8,0,0,0"
|
<Button Grid.Column="1" Content="+" Width="36" Height="36" Margin="8,0,0,0"
|
||||||
FontSize="18" Style="{StaticResource FlatButtonStyle}"
|
FontSize="18" Style="{StaticResource FlatButtonStyle}"
|
||||||
Command="{Binding ImportFilesCommand}"
|
Command="{Binding ImportFilesCommand}"
|
||||||
Visibility="{Binding IsLocalSource, Converter={StaticResource BoolToVisibility}}"
|
Visibility="{Binding CanImport, Converter={StaticResource BoolToVisibility}}"
|
||||||
ToolTip="Add memes"/>
|
ToolTip="Add memes"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto" Padding="0,0,4,0"
|
<ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto" Padding="0,0,4,0"
|
||||||
ScrollChanged="ResultsScrollViewer_OnScrollChanged">
|
ScrollChanged="ResultsScrollViewer_OnScrollChanged">
|
||||||
<StackPanel>
|
<Grid>
|
||||||
<!-- Favourites for the active source. Scrolls with the content rather than
|
<TextBlock Text="{Binding EmptyStateText}"
|
||||||
being pinned, so it doesn't permanently eat height in a 520px window. -->
|
Foreground="#7B7D85" FontSize="13"
|
||||||
<StackPanel Visibility="{Binding HasFavorites, Converter={StaticResource BoolToVisibility}}">
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
<TextBlock Text="★ Favourites" Foreground="#9A9CA3" FontSize="11"
|
TextWrapping="Wrap" TextAlignment="Center" Width="260"
|
||||||
FontWeight="SemiBold" Margin="4,0,0,4"/>
|
Margin="0,40,0,0"
|
||||||
<ItemsControl ItemsSource="{Binding Favorites}"
|
Visibility="{Binding ShowEmptyState, Converter={StaticResource BoolToVisibility}}"/>
|
||||||
ItemTemplate="{StaticResource MemeTileTemplate}">
|
|
||||||
<ItemsControl.ItemsPanel>
|
|
||||||
<ItemsPanelTemplate>
|
|
||||||
<UniformGrid Columns="3"/>
|
|
||||||
</ItemsPanelTemplate>
|
|
||||||
</ItemsControl.ItemsPanel>
|
|
||||||
</ItemsControl>
|
|
||||||
<Border Height="1" Background="#3A3B3E" Margin="4,10,4,10"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<Grid>
|
<ItemsControl ItemsSource="{Binding Items}"
|
||||||
<TextBlock Text="No memes yet. Click +, drag files in, or paste with Ctrl+V."
|
ItemTemplate="{StaticResource MemeTileTemplate}">
|
||||||
Foreground="#7B7D85" FontSize="13"
|
<ItemsControl.ItemsPanel>
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
<ItemsPanelTemplate>
|
||||||
TextWrapping="Wrap" TextAlignment="Center" Width="260"
|
<UniformGrid Columns="3"/>
|
||||||
Margin="0,40,0,0">
|
</ItemsPanelTemplate>
|
||||||
<TextBlock.Style>
|
</ItemsControl.ItemsPanel>
|
||||||
<Style TargetType="TextBlock">
|
</ItemsControl>
|
||||||
<Setter Property="Visibility" Value="Collapsed"/>
|
</Grid>
|
||||||
<Style.Triggers>
|
|
||||||
<MultiDataTrigger>
|
|
||||||
<MultiDataTrigger.Conditions>
|
|
||||||
<Condition Binding="{Binding Items.Count}" Value="0"/>
|
|
||||||
<Condition Binding="{Binding IsLocalSource}" Value="True"/>
|
|
||||||
</MultiDataTrigger.Conditions>
|
|
||||||
<Setter Property="Visibility" Value="Visible"/>
|
|
||||||
</MultiDataTrigger>
|
|
||||||
</Style.Triggers>
|
|
||||||
</Style>
|
|
||||||
</TextBlock.Style>
|
|
||||||
</TextBlock>
|
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding Items}"
|
|
||||||
ItemTemplate="{StaticResource MemeTileTemplate}">
|
|
||||||
<ItemsControl.ItemsPanel>
|
|
||||||
<ItemsPanelTemplate>
|
|
||||||
<UniformGrid Columns="3"/>
|
|
||||||
</ItemsPanelTemplate>
|
|
||||||
</ItemsControl.ItemsPanel>
|
|
||||||
</ItemsControl>
|
|
||||||
</Grid>
|
|
||||||
</StackPanel>
|
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
<Grid Grid.Row="4" Margin="2,8,0,0">
|
<Grid Grid.Row="4" Margin="2,8,0,0">
|
||||||
|
|||||||
@@ -37,6 +37,11 @@
|
|||||||
<CheckBox Content="Start automatically when Windows starts" Foreground="White"
|
<CheckBox Content="Start automatically when Windows starts" Foreground="White"
|
||||||
IsChecked="{Binding AutostartEnabled}"/>
|
IsChecked="{Binding AutostartEnabled}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Previews" Foreground="White" FontSize="14" FontWeight="Bold" Margin="0,24,0,8"/>
|
||||||
|
<CheckBox Content="Play GIF previews in the picker" Foreground="White"
|
||||||
|
IsChecked="{Binding PlayGifPreviews}"/>
|
||||||
|
<TextBlock Text="Animates GIF thumbnails instead of showing a still frame. Uses more CPU and memory, especially with lots of Giphy results."
|
||||||
|
Foreground="#7B7D85" FontSize="10" TextWrapping="Wrap" Margin="20,4,0,0"/>
|
||||||
<TextBlock Text="Giphy" Foreground="White" FontSize="14" FontWeight="Bold" Margin="0,24,0,8"/>
|
<TextBlock Text="Giphy" Foreground="White" FontSize="14" FontWeight="Bold" Margin="0,24,0,8"/>
|
||||||
<Border Background="#2B2D31" CornerRadius="6">
|
<Border Background="#2B2D31" CornerRadius="6">
|
||||||
<TextBox Text="{Binding GiphyApiKey, UpdateSourceTrigger=PropertyChanged}"
|
<TextBox Text="{Binding GiphyApiKey, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
|||||||
Reference in New Issue
Block a user