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
@@ -0,0 +1,42 @@
using Microsoft.Win32;
namespace EbbesMemeClipboard.Services;
public sealed class AutostartService : IAutostartService
{
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string ValueName = "EbbesMemeClipboard";
public bool IsEnabled
{
get
{
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: false);
return key?.GetValue(ValueName) is string existing && existing.Contains(ExecutablePath, StringComparison.OrdinalIgnoreCase);
}
}
public void SetEnabled(bool enabled)
{
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: true)
?? Registry.CurrentUser.CreateSubKey(RunKeyPath, writable: true);
if (enabled)
{
// Quoted because the path can contain spaces, which Windows would otherwise treat
// as an argument boundary and fail to launch.
key.SetValue(ValueName, $"\"{ExecutablePath}\"");
}
else
{
key.DeleteValue(ValueName, throwOnMissingValue: false);
}
}
/// <summary>
/// Environment.ProcessPath, not Assembly.Location: in a single-file published build the
/// managed assemblies are never extracted to disk, so Assembly.Location returns an empty
/// string and the registry entry would point at nothing.
/// </summary>
private static string ExecutablePath => Environment.ProcessPath ?? string.Empty;
}
@@ -27,12 +27,20 @@ public sealed class ClipboardService : IClipboardService
// reading a pasted image, because those older formats are lossy for transparency. This
// covers Teams, Slack, Discord's web layer, and browsers - without it, paste into any of
// them can silently produce nothing even though CF_DIB is present and perfectly valid.
using var pngStream = new MemoryStream();
var pngEncoder = new PngBitmapEncoder();
pngEncoder.Frames.Add(BitmapFrame.Create(firstFrame));
pngEncoder.Save(pngStream);
pngStream.Position = 0;
data.SetData("PNG", pngStream);
//
// Deliberately skipped for GIFs: a PNG can only ever hold one static frame, and since
// Chromium PREFERS this format over all others, offering it for a GIF is what makes
// animated GIFs paste as a still first frame. Omitting it lets those targets fall
// through to CF_HTML below, which carries the full animated data URI.
if (!ImageDecoding.IsGif(filePath))
{
using var pngStream = new MemoryStream();
var pngEncoder = new PngBitmapEncoder();
pngEncoder.Frames.Add(BitmapFrame.Create(firstFrame));
pngEncoder.Save(pngStream);
pngStream.Position = 0;
data.SetData("PNG", pngStream);
}
// CF_HTML: the other route those same web-based paste targets check, and the only one of
// these formats that preserves animation for them, for GIFs.
@@ -0,0 +1,73 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public sealed class FavoritesService : IFavoritesService
{
private readonly string _path;
private readonly List<FavoriteRecord> _records;
private readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() },
};
public FavoritesService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var dir = Path.Combine(appData, "EbbesMemeClipboard");
Directory.CreateDirectory(dir);
_path = Path.Combine(dir, "favorites.json");
_records = Load();
}
public IReadOnlyList<FavoriteRecord> GetForSource(MemeSource source) =>
_records.Where(r => r.Source == source)
.OrderByDescending(r => r.DateAdded)
.ToList();
public bool IsFavorite(MemeSource source, string key) =>
_records.Any(r => r.Source == source && r.Key == key);
public async Task AddAsync(FavoriteRecord record)
{
if (IsFavorite(record.Source, record.Key)) return;
_records.Add(record);
await SaveAsync();
}
public async Task RemoveAsync(MemeSource source, string key)
{
int removed = _records.RemoveAll(r => r.Source == source && r.Key == key);
if (removed > 0)
{
await SaveAsync();
}
}
private List<FavoriteRecord> Load()
{
if (!File.Exists(_path)) return new List<FavoriteRecord>();
try
{
var json = File.ReadAllText(_path);
return JsonSerializer.Deserialize<List<FavoriteRecord>>(json, _jsonOptions) ?? new List<FavoriteRecord>();
}
catch (JsonException)
{
// Corrupt file: start clean rather than blocking the whole picker from opening.
return new List<FavoriteRecord>();
}
}
private async Task SaveAsync()
{
var json = JsonSerializer.Serialize(_records, _jsonOptions);
await File.WriteAllTextAsync(_path, json);
}
}
@@ -0,0 +1,45 @@
using System.IO;
using System.Net.Http;
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public sealed class GifCacheService : IGifCacheService
{
private readonly HttpClient _http;
private readonly string _cacheRoot;
public GifCacheService(HttpClient http)
{
_http = http;
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
// LocalApplicationData rather than the roaming AppData used by the library: this is
// re-downloadable cache, not user content, so it shouldn't follow a roaming profile.
_cacheRoot = Path.Combine(localAppData, "EbbesMemeClipboard", "GifCache");
Directory.CreateDirectory(_cacheRoot);
}
public async Task<string> GetOrDownloadAsync(GifSearchResult result, CancellationToken ct)
{
var safeId = string.Concat(result.Id.Where(char.IsLetterOrDigit));
var path = Path.Combine(_cacheRoot, $"{result.Source}_{safeId}.gif");
if (File.Exists(path) && new FileInfo(path).Length > 0)
{
return path;
}
var bytes = await _http.GetByteArrayAsync(result.FullUrl, ct);
// Write to a temp name then move, so an interrupted download can't leave a truncated
// file behind that later runs would treat as a valid cache hit.
var tempPath = path + ".partial";
await File.WriteAllBytesAsync(tempPath, bytes, ct);
File.Move(tempPath, path, overwrite: true);
return path;
}
public Task<byte[]> DownloadPreviewAsync(GifSearchResult result, CancellationToken ct) =>
_http.GetByteArrayAsync(result.PreviewUrl, ct);
}
@@ -0,0 +1,79 @@
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public sealed class GiphyGifProvider : IGifProvider
{
private const string BaseUrl = "https://api.giphy.com/v1/gifs";
private const string Rating = "pg-13";
private readonly HttpClient _http;
private readonly ISettingsService _settings;
public MemeSource Source => MemeSource.Giphy;
public bool IsConfigured => !string.IsNullOrWhiteSpace(_settings.Current.GiphyApiKey);
public string NotConfiguredMessage =>
"No Giphy API key set. Add one in Settings - a free key is available from developers.giphy.com.";
public GiphyGifProvider(HttpClient http, ISettingsService settings)
{
_http = http;
_settings = settings;
}
public async Task<IReadOnlyList<GifSearchResult>> SearchAsync(string query, int limit, CancellationToken ct)
{
if (!IsConfigured) return Array.Empty<GifSearchResult>();
var apiKey = Uri.EscapeDataString(_settings.Current.GiphyApiKey!);
var url = string.IsNullOrWhiteSpace(query)
? $"{BaseUrl}/trending?api_key={apiKey}&limit={limit}&rating={Rating}"
: $"{BaseUrl}/search?api_key={apiKey}&q={Uri.EscapeDataString(query)}&limit={limit}&rating={Rating}";
var response = await _http.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<GiphyResponse>(cancellationToken: ct);
if (payload?.Data is null) return Array.Empty<GifSearchResult>();
return payload.Data
.Where(item => item.Images?.FixedWidth?.Url is not null && item.Images.Original?.Url is not null)
.Select(item => new GifSearchResult
{
Id = item.Id ?? Guid.NewGuid().ToString("N"),
Title = string.IsNullOrWhiteSpace(item.Title) ? "Giphy GIF" : item.Title,
PreviewUrl = item.Images!.FixedWidth!.Url!,
FullUrl = item.Images.Original!.Url!,
Source = MemeSource.Giphy,
})
.ToList();
}
private sealed class GiphyResponse
{
[JsonPropertyName("data")] public List<GiphyItem>? Data { get; set; }
}
private sealed class GiphyItem
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("title")] public string? Title { get; set; }
[JsonPropertyName("images")] public GiphyImages? Images { get; set; }
}
private sealed class GiphyImages
{
[JsonPropertyName("fixed_width")] public GiphyRendition? FixedWidth { get; set; }
[JsonPropertyName("original")] public GiphyRendition? Original { get; set; }
}
private sealed class GiphyRendition
{
[JsonPropertyName("url")] public string? Url { get; set; }
}
}
@@ -0,0 +1,10 @@
namespace EbbesMemeClipboard.Services;
public interface IAutostartService
{
/// <summary>Reads the live registry state rather than a cached setting, so it stays truthful
/// even if the user removes the entry through Task Manager or another tool.</summary>
bool IsEnabled { get; }
void SetEnabled(bool enabled);
}
@@ -0,0 +1,11 @@
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public interface IFavoritesService
{
IReadOnlyList<FavoriteRecord> GetForSource(MemeSource source);
bool IsFavorite(MemeSource source, string key);
Task AddAsync(FavoriteRecord record);
Task RemoveAsync(MemeSource source, string key);
}
@@ -0,0 +1,16 @@
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public interface IGifCacheService
{
/// <summary>
/// Ensures the result's full-quality GIF exists as a real file on disk and returns its path.
/// Required before a remote result can go on the clipboard at all, since the file-drop
/// format (the one that preserves animation for chat apps) needs an actual local file.
/// </summary>
Task<string> GetOrDownloadAsync(GifSearchResult result, CancellationToken ct);
/// <summary>Downloads a preview rendition into memory for grid thumbnails.</summary>
Task<byte[]> DownloadPreviewAsync(GifSearchResult result, CancellationToken ct);
}
@@ -0,0 +1,17 @@
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public interface IGifProvider
{
MemeSource Source { get; }
/// <summary>False when the provider isn't usable yet (e.g. no API key configured).</summary>
bool IsConfigured { get; }
/// <summary>Shown in the UI when IsConfigured is false, explaining how to fix it.</summary>
string NotConfiguredMessage { get; }
/// <summary>An empty query returns whatever the provider considers trending.</summary>
Task<IReadOnlyList<GifSearchResult>> SearchAsync(string query, int limit, CancellationToken ct);
}
@@ -1,4 +1,5 @@
using System.IO;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace EbbesMemeClipboard.Services;
@@ -9,7 +10,7 @@ internal static class ImageDecoding
Path.GetExtension(path).Equals(".gif", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Decodes just the first frame of an image (for GIFs, frame 0 only - the rest of the
/// Decodes just the first frame of an image file (for GIFs, frame 0 only - the rest of the
/// animation is never touched). The result is frozen so it can be handed back across
/// threads safely when called from a background thread.
/// </summary>
@@ -18,9 +19,7 @@ internal static class ImageDecoding
if (IsGif(path))
{
var decoder = new GifBitmapDecoder(new Uri(path), BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
var frame = decoder.Frames[0];
frame.Freeze();
return frame;
return Finalize(decoder.Frames[0], decodePixelWidth);
}
var bitmap = new BitmapImage();
@@ -35,4 +34,31 @@ internal static class ImageDecoding
bitmap.Freeze();
return bitmap;
}
/// <summary>Same as DecodeFirstFrame but for bytes already in memory (remote previews).</summary>
internal static BitmapSource DecodeFirstFrameFromBytes(byte[] data, int? decodePixelWidth = null)
{
using var stream = new MemoryStream(data);
var decoder = BitmapDecoder.Create(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
return Finalize(decoder.Frames[0], decodePixelWidth);
}
/// <summary>
/// Scales a decoded frame down to the requested width and freezes it. GIF frames can't use
/// BitmapImage.DecodePixelWidth (that only applies when decoding from a URI), so without
/// this every GIF thumbnail would sit in memory at full resolution.
/// </summary>
private static BitmapSource Finalize(BitmapSource frame, int? decodePixelWidth)
{
if (decodePixelWidth is not int width || frame.PixelWidth <= width)
{
frame.Freeze();
return frame;
}
double scale = width / (double)frame.PixelWidth;
var scaled = new TransformedBitmap(frame, new ScaleTransform(scale, scale));
scaled.Freeze();
return scaled;
}
}