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