Ein Kontextmenü auf jeder Kachel — mitsamt Bildauswahl

Gesehen, Favorit und Weglegen gab es ausschliesslich auf der Detailseite.
In einer Übersicht mit zwanzig Titeln hiess "die hab ich alle gesehen"
vierzig Seitenwechsel. Im ganzen Quelltext stand kein einziger
contextmenu-Zuhörer.

Drei Wege hinein, weil es drei Arten gibt zu bedienen: Rechtsklick für die
Maus, Shift+F10 und die Kontextmenü-Taste für die Tastatur, langer Druck
für den Finger. Nur den Rechtsklick anzubieten hiesse, das Menü auf dem
Telefon zu verstecken. Der lange Druck bricht bei jeder Bewegung ab, sonst
löst Scrollen ihn aus.

Die Aktionen wirken auf der Kachel selbst. Die Liste neu zu laden verlöre
Bildlauf und Filter — für einen Haken.

Dazu die Bildauswahl. Es gab genau einen Bild-Endpunkt, und der lieferte
nur aus: wählte TMDB ein schlechtes Plakat, blieb es. Jetzt stehen Plakat,
Hintergrund und Schriftzug zur Wahl, dazu eigene Bilder bis 10 MB. Die
Kandidaten kosten nichts — TMDB liefert sie beim Einzelabruf ohnehin mit,
und bisher wurde alles ausser dem Schriftzug weggeworfen.

Drei Merker je Titel, nicht einer: man kann ein eigenes Plakat wollen und
trotzdem automatische Hintergründe. Ohne sie überschriebe das nächste
Auffrischen jede Handauswahl, und wer einmal ein besseres Plakat gesucht
hat, suchte es jedes Mal neu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 12:43:52 +02:00
co-authored by Claude Opus 5
parent e1399c808f
commit eb63271e92
16 changed files with 1759 additions and 11 deletions
+46
View File
@@ -31,6 +31,52 @@ public sealed class ImageCache(
/// <summary>Mehr als das ist kein Plakat, sondern ein Versehen.</summary>
private const long HöchstensBytes = 16 * 1024 * 1024;
/// <summary>Legt ein hochgeladenes Bild ab.</summary>
///
/// <remarks>
/// <b>Derselbe Ort wie für geholte Bilder.</b> Ein eigenes Plakat liegt
/// damit unter derselben Adresse wie eins von TMDB, und nichts weiter im
/// Programm muss wissen, woher es kam.
///
/// Der Schlüssel kommt aus dem Inhalt, nicht aus einem Zufallswert:
/// dasselbe Bild zweimal hochzuladen ergibt dieselbe Datei.
/// </remarks>
public async Task<string?> StoreStreamAsync(
Stream inhalt, string? medientyp, CancellationToken ct = default)
{
using var zwischen = new MemoryStream();
await inhalt.CopyToAsync(zwischen, ct);
if (zwischen.Length == 0 || zwischen.Length > HöchstensBytes) return null;
var daten = zwischen.ToArray();
var endung = medientyp switch
{
"image/png" => ".png",
"image/webp" => ".webp",
_ => ".jpg",
};
var schlüssel = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(daten))[..32]
+ endung;
var ziel = PfadFür(schlüssel);
if (ziel is null) return null;
if (File.Exists(ziel)) return schlüssel;
try
{
Directory.CreateDirectory(Path.GetDirectoryName(ziel)!);
var vorlaeufig = ziel + ".teil";
await File.WriteAllBytesAsync(vorlaeufig, daten, ct);
File.Move(vorlaeufig, ziel, overwrite: true);
return schlüssel;
}
catch (IOException fehler)
{
log.LogWarning(fehler, "Hochgeladenes Bild liess sich nicht ablegen.");
return null;
}
}
public async Task<string?> StoreAsync(RemoteImage bild, CancellationToken ct = default)
{
var schlüssel = Schlüssel(bild.Url);
+51 -1
View File
@@ -352,6 +352,54 @@ public sealed class TmdbClient(
/// innerhalb einer Stufe entscheidet die Bewertung bei TMDB, weil es dort
/// um Bildqualität geht und nicht mehr um die Sprache.
/// </remarks>
/// <summary>Alle Bilder zu einem Titel — für die Auswahl von Hand.</summary>
///
/// <remarks>
/// <b>Die Liste kommt ohnehin mit.</b> <c>append_to_response=…,images</c>
/// hängt sie an den Einzelabruf, und bisher wurde alles ausser dem
/// Schriftzug weggeworfen. Die Auswahl kostet also keine zusätzliche
/// Anfrage.
/// </remarks>
public async Task<IReadOnlyList<Bildkandidat>> BilderAsync(
int providerId, bool serie, CancellationToken ct = default)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return [];
var sprache = Sprachkürzel(einstellung.Language);
var weg = serie ? "tv" : "movie";
var antwort = await HolenAsync<TmdbBilderAntwort>(
$"{weg}/{providerId}/images?include_image_language={Uri.EscapeDataString(sprache)},en,null",
einstellung, ct);
if (antwort is null) return [];
var liste = new List<Bildkandidat>();
Sammeln(antwort.Posters, "poster", PosterSize);
Sammeln(antwort.Backdrops, "backdrop", BackdropSize);
Sammeln(antwort.Logos, "logo", "w500");
return liste;
void Sammeln(List<TmdbImage>? bilder, string art, string groesse)
{
foreach (var b in (bilder ?? []).Where(b => !string.IsNullOrWhiteSpace(b.FilePath))
.OrderByDescending(b => b.VoteAverage ?? 0)
.Take(24))
{
liste.Add(new Bildkandidat(
art, Bild(b.FilePath, groesse)!.Url,
Bild(b.FilePath, "w185")!.Url,
string.IsNullOrEmpty(b.Language) ? null : b.Language,
b.VoteAverage));
}
}
}
private sealed record TmdbBilderAntwort(
[property: JsonPropertyName("posters")] List<TmdbImage>? Posters,
[property: JsonPropertyName("backdrops")] List<TmdbImage>? Backdrops,
[property: JsonPropertyName("logos")] List<TmdbImage>? Logos);
private static RemoteImage? Schriftzug(TmdbImages? bilder, string sprache)
{
var alle = bilder?.Logos?.Where(l => !string.IsNullOrWhiteSpace(l.FilePath)).ToList();
@@ -486,7 +534,9 @@ public sealed class TmdbClient(
private sealed record TmdbGenre([property: JsonPropertyName("name")] string? Name);
private sealed record TmdbImages(
[property: JsonPropertyName("logos")] List<TmdbImage>? Logos);
[property: JsonPropertyName("logos")] List<TmdbImage>? Logos,
[property: JsonPropertyName("posters")] List<TmdbImage>? Posters = null,
[property: JsonPropertyName("backdrops")] List<TmdbImage>? Backdrops = null);
private sealed record TmdbImage(
[property: JsonPropertyName("file_path")] string? FilePath,