diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 1e604be..7a66beb 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -486,11 +486,26 @@ public sealed record ItemStateResponse( /// steht in Title die Serie und in SubTitle die Folge — so liest /// sich die Kachel wie bei Plex: oben, worum es geht, darunter, wo man ist. /// +/// +/// Die Kennung des Titels selbst — bei einer Folge also die der Folge. Dorthin +/// geht der Fortschritt. +/// +/// +/// Zu welcher Serie die Folge gehört — null bei einem Film. +/// +/// +/// Ohne diese Angabe führte die Kachel ins Leere. Sie trug die +/// Folgenkennung und sollte damit eine Serienseite öffnen; die gibt es unter +/// dieser Kennung nie, und heraus kam „Diese Serie gibt es nicht mehr" — +/// ausgerechnet unter „Nächste Folge", wo man gerade weiterschauen wollte. +/// +/// public sealed record ContinueEntry( Guid Id, string Kind, string Title, string? SubTitle, int? Season, int? Episode, int? Year, string? PosterImage, string? BackdropImage, - ItemStateResponse State); + ItemStateResponse State, + Guid? SeriesId = null); /// Eine laufende Wiedergabe — für das Dashboard. public sealed record SessionResponse( diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs index 702f6d6..7984bab 100644 --- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs @@ -318,7 +318,8 @@ public static class PlaybackEndpoints naechste.Id, "episode", serie.Title, naechste.Title, naechste.SeasonNumber, naechste.EpisodeNumber, null, serie.PosterImage, naechste.StillImage ?? serie.PosterImage, - zustand is null ? Leer() : Abbild(zustand))); + zustand is null ? Leer() : Abbild(zustand), + SeriesId: x.SeriesId)); } return Results.Ok(new Page(liste, liste.Count)); @@ -432,7 +433,8 @@ public static class PlaybackEndpoints else if (z.EpisodeId is { } folge && folgendaten.TryGetValue(folge, out var f)) liste.Add(new ContinueEntry( f.Id, "episode", f.SerienTitel, f.FolgenTitel, f.SeasonNumber, f.EpisodeNumber, null, - f.PosterImage, f.StillImage ?? f.PosterImage, Abbild(z))); + f.PosterImage, f.StillImage ?? f.PosterImage, Abbild(z), + SeriesId: f.SerienId)); } return liste; } diff --git a/server/tests/Flaro.Api.Tests/PlaybackTests.cs b/server/tests/Flaro.Api.Tests/PlaybackTests.cs index ec77a6f..6518c3e 100644 --- a/server/tests/Flaro.Api.Tests/PlaybackTests.cs +++ b/server/tests/Flaro.Api.Tests/PlaybackTests.cs @@ -556,6 +556,86 @@ public class PlaybackTests }); } + /// Eine Folge in „Weiterschauen" nennt ihre Serie. + /// + /// + /// Ohne diese Angabe führte die Kachel ins Leere. Sie trug die + /// Folgenkennung und öffnete damit eine Serienseite — die es unter dieser + /// Kennung nie gibt. Heraus kam „Diese Serie gibt es nicht mehr", + /// ausgerechnet unter „Nächste Folge", wo man gerade weiterschauen wollte. + /// Es lag nicht am Scan und nicht am Auffrischen: die Kachel hat von + /// Anfang an auf die falsche Kennung gezeigt. + /// + [Test] + public async Task Eine_angefangene_Folge_nennt_ihre_Serie() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var serie = serien!.Items[0]; + var folgen = await client.GetFromJsonAsync>( + $"/api/v1/series/{serie.Id}/episodes"); + + // Mittendrin stehenbleiben — dann steht die Folge in „Weiterschauen". + await Melden(client, folgen!.Items[0].Id, 600, 7200); + + var weiter = await client.GetFromJsonAsync>("/api/v1/home/continue"); + var eintrag = weiter!.Items.Single(); + + Assert.Multiple(() => + { + Assert.That(eintrag.Id, Is.EqualTo(folgen.Items[0].Id), "die Kennung bleibt die der Folge"); + Assert.That(eintrag.SeriesId, Is.EqualTo(serie.Id), "und daneben steht, wohin sie gehört"); + }); + } + + /// Dasselbe für „Nächste Folge". + [Test] + public async Task Die_naechste_Folge_nennt_ihre_Serie() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var serie = serien!.Items[0]; + var folgen = await client.GetFromJsonAsync>( + $"/api/v1/series/{serie.Id}/episodes"); + + // Die erste ganz gesehen — dann ist die zweite „die nächste". + await client.PutAsJsonAsync($"/api/v1/items/{folgen!.Items[0].Id}/watched", new WatchedRequest(true)); + + var naechste = await client.GetFromJsonAsync>("/api/v1/home/next"); + var eintrag = naechste!.Items.Single(); + + Assert.Multiple(() => + { + Assert.That(eintrag.Id, Is.EqualTo(folgen.Items[1].Id)); + Assert.That(eintrag.SeriesId, Is.EqualTo(serie.Id)); + }); + } + + /// Ein Film hat keine Serie — und behauptet es auch nicht. + [Test] + public async Task Ein_angefangener_Film_nennt_keine_Serie() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + await Melden(client, film, 600, 7200); + + var weiter = await client.GetFromJsonAsync>("/api/v1/home/continue"); + Assert.That(weiter!.Items.Single().SeriesId, Is.Null); + } + [Test] public async Task Die_naechste_Folge_richtet_sich_nach_Staffel_und_Nummer() { diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 58361f4..3f5b46c 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -78,11 +78,14 @@ export type Folge = { /** Eine Kachel in „Weiterschauen" oder „Nächste Folge" — Film wie Folge. */ export type Angefangen = { + /** Die Kennung des Titels selbst — bei einer Folge die der Folge. */ id: string; kind: 'movie' | 'episode'; title: string; subTitle: string | null; season: number | null; episode: number | null; year: number | null; posterImage: string | null; backdropImage: string | null; state: Stand; + /** Zu welcher Serie die Folge gehört — dorthin führt die Kachel. */ + seriesId: string | null; }; export type Bibliothek = { id: string; name: string; diff --git a/web/app/src/card.ts b/web/app/src/card.ts index 533679b..72b7bae 100644 --- a/web/app/src/card.ts +++ b/web/app/src/card.ts @@ -11,7 +11,14 @@ import { titeldialog } from './titel'; /** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */ export type Kachel = { + /** Wohin der Klick führt — bei einer Folge die Serie, zu der sie gehört. */ id: string; + /** Welche Kennung den Sehstand trägt. Fehlt sie, ist es `id`. + * + * **Nur dort verschieden, wo die Kachel woandershin führt als sie misst:** + * eine Folge aus „Weiterschauen" öffnet die Serie, abgehakt wird aber die + * Folge. */ + standId?: string; art: 'filme' | 'serien'; /** Die obere, fette Zeile. Bei einer Folge die Serie. */ titel: string; @@ -43,10 +50,25 @@ export const alsKachel = (e: Film | Serie): Kachel => ({ freigabe: e.publishAt, }); -/** Eine Kachel aus „Weiterschauen" — bei Folgen dreizeilig, wie bei Plex. */ +/** Eine Kachel aus „Weiterschauen" oder „Nächste Folge" — bei Folgen + * dreizeilig, wie bei Plex. + * + * **Die Kachel führt zur Serie, der Sehstand bleibt bei der Folge.** + * + * Hier stand die Folgenkennung, und die Kachel öffnete damit eine Serienseite + * — die es unter dieser Kennung nie gibt. Heraus kam „Diese Serie gibt es + * nicht mehr", ausgerechnet unter „Nächste Folge", wo man gerade + * weiterschauen wollte. Eine eigene Seite je Folge gibt es nicht; die Staffeln + * stehen auf der Serienseite, und dorthin gehört der Klick. + * + * **Beides auf dieselbe Kennung zu legen wäre der nächste Fehler gewesen:** + * „Als gesehen markieren" hätte dann die ganze Serie abgehakt statt der + * einen Folge, die man gerade zur Hälfte gesehen hat. + */ export const angefangenAlsKachel = (e: Angefangen): Kachel => e.kind === 'episode' ? { - id: e.id, art: 'serien', + id: e.seriesId ?? e.id, art: 'serien', + standId: e.id, titel: e.title, zweite: e.subTitle, dritte: `S${e.season} · F${e.episode}`, @@ -116,6 +138,10 @@ export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string) bild.src = adresse; bild.alt = ''; bild.loading = 'lazy'; + // **Kein Ziehen des Bildes.** Browser machen aus jedem Bild von sich aus + // ein Ziehgut; wer über die Kacheln streicht, um mehrere auszuwählen, + // schleppte sonst ein halbdurchsichtiges Plakat hinter sich her. + bild.draggable = false; // Erst einblenden, wenn es da ist — sonst blitzt der Platzhalter durch. bild.onload = () => rahmen.classList.add('geladen'); rahmen.appendChild(bild); @@ -259,7 +285,7 @@ function kachelpunkte( // **Die Antwort des Servers gilt, nicht die Annahme hier.** Bei einer // Serie zieht das Häkchen alle Folgen mit; was dabei herauskommt, // weiss nur der Server. - k.stand = await gesehen(k.id, !istGesehen); + k.stand = await gesehen(k.standId ?? k.id, !istGesehen); // **Die Kachel spiegelt den neuen Zustand selbst.** Die Liste neu zu // laden verlöre Bildlauf und Filter — für einen Haken. k.offen = istGesehen ? (k.gesamt ?? k.offen) : 0; @@ -271,7 +297,7 @@ function kachelpunkte( icon: 'stern', tun: async () => { const neuerWert = !k.stand?.favourite; - k.stand = await favorit(k.id, neuerWert); + k.stand = await favorit(k.standId ?? k.id, neuerWert); }, }, ]; @@ -282,7 +308,7 @@ function kachelpunkte( punkte.push({ text: 'Aus Weiterschauen nehmen', tun: async () => { - await weglegen(k.id); + await weglegen(k.standId ?? k.id); if (k.stand) k.stand.dismissed = true; knopf.querySelector('.fortschritt')?.remove(); }, @@ -361,6 +387,7 @@ async function kachelAuffrischen(k: Kachel, knopf: HTMLElement): Promise { const bild = document.createElement('img'); bild.src = adresse; bild.alt = ''; + bild.draggable = false; bild.onload = () => plakat.classList.add('geladen'); plakat.prepend(bild); } diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts index 49aec4c..999ee21 100644 --- a/web/app/src/spieler.ts +++ b/web/app/src/spieler.ts @@ -699,7 +699,15 @@ export function spieler(anfrage: Spieleranfrage): void { } schicht.onmousemove = () => { if (!klein) zeigen(); }; schicht.onclick = e => { - if (klein) return; + // **In der Leiste holt auch das Bild den Film zurück.** + // + // Die Leiste selbst tat es schon, das Vorschaubild liegt aber darüber und + // gehört nicht zu ihr — ein Klick genau darauf tat deshalb nichts. Das ist + // ausgerechnet die Stelle, auf die man zeigt. + if (klein) { + if (e.target === video || e.target === schicht) kleinSetzen(false); + return; + } // Ein Klick neben ein offenes Menü schliesst es, statt den Film anzuhalten. const offen = bedienung.querySelector('.pl-menue.auf'); if (offen && !offen.contains(e.target as Node)) { offen.classList.remove('auf'); return; } diff --git a/web/app/src/style.css b/web/app/src/style.css index b8f7627..a59742d 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -867,9 +867,14 @@ body.pl-offen { overflow: hidden; } box-shadow: 0 -8px 28px rgba(0, 0, 0, .45); padding-bottom: env(safe-area-inset-bottom); } +/* **Unter dem Fortschrittsstrich, nicht darüber.** + * + * Das Bild lag bündig am oberen Rand und schob sich über den Strich — auf der + * rechten Seite, wo noch nichts gefüllt war, verschwand er ganz darunter. + * Zwei Bildpunkte Abstand und eine Ebene darunter, dann läuft er durch. */ .pl.pl-klein .pl-video { - position: absolute; left: 0; top: 1px; - width: 128px; height: 71px; + position: absolute; left: 0; top: 2px; z-index: 0; + width: 128px; height: 70px; object-fit: cover; cursor: pointer; } .pl.pl-klein .pl-kopf, @@ -894,7 +899,7 @@ body.pl-offen { overflow: hidden; } /* Der Fortschritt als dünner Strich ganz oben auf der Leiste. */ .pl-kleinbalken { - position: absolute; left: 0; right: 0; top: 0; height: 2px; + position: absolute; left: 0; right: 0; top: 0; height: 2px; z-index: 2; background: rgba(255, 255, 255, .14); } .pl-kleinbalken div { height: 100%; background: var(--accent); transition: width .25s linear; } @@ -1493,6 +1498,24 @@ input[type="datetime-local"].feld::-webkit-calendar-picker-indicator:hover { } .auswahlzahl { font-size: 14px; font-weight: 600; white-space: nowrap; } +/* Das Kreuz steht abgesetzt — es tut nichts mit den Titeln, es räumt auf. */ +.auswahl-zu { + display: grid; place-items: center; flex: 0 0 auto; + width: 34px; height: 34px; border-radius: 50%; + border: 0; background: transparent; color: var(--text-dim); + cursor: pointer; transition: background .15s, color .15s; + margin-left: 4px; +} +.auswahl-zu:hover, .auswahl-zu:focus-visible { background: var(--raised); color: var(--text); } +.auswahl-zu svg { width: 17px; height: 17px; } + +/* **Während des Streichens markiert der Browser nichts.** Sonst zieht er die + Beschriftungen blau an, als wolle man Text kopieren. */ +body.keine-auswahl { user-select: none; -webkit-user-select: none; } +.raster.zieht .kachel { cursor: crosshair; } +/* Kein Ziehgut aus Plakaten — sonst hängt eines am Mauszeiger. */ +.plakat img { -webkit-user-drag: none; user-drag: none; } + @media (max-width: 640px) { .auswahlleiste { left: 12px; right: 12px; transform: none; max-width: none; } .auswahlleiste .knopfreihe { width: 100%; } diff --git a/web/app/src/views/library.ts b/web/app/src/views/library.ts index 0ab2d73..83ef6c6 100644 --- a/web/app/src/views/library.ts +++ b/web/app/src/views/library.ts @@ -239,6 +239,15 @@ export function mediathek( const k = ziel.closest('.kachel'); if (!k || !gitter.contains(k)) return; + // **Nach einem Streichen kommt noch ein Klick.** Der Browser schickt ihn + // auf die Kachel, auf der die Maus losgelassen wurde — und der würde + // genau das wieder umschalten, was gerade gewählt wurde. + if (geradeGestrichen) { + e.preventDefault(); + e.stopPropagation(); + return; + } + // Das Kästchen wählt immer. Die Kachel selbst nur dann, wenn schon // etwas gewählt ist — sonst bliebe die Mediathek unbenutzbar. if (!ziel.closest('.wahlmarke') && gewaehlt.size === 0) return; @@ -248,6 +257,166 @@ export function mediathek( umschalten(k); }, true); + /* ---------- Streichen wie im Dateimanager ---------- + * + * **Gedrückt halten und darüberfahren wählt aus.** + * + * Ein kurzer Klick bleibt, was er war: die Detailseite. Erst eine + * Bewegung über ein paar Bildpunkte hinaus macht daraus ein Streichen — + * dieselbe Unterscheidung, die jeder Dateimanager trifft, und deshalb + * muss sie niemand lernen. + * + * **Was die erste Kachel wird, werden alle.** Fängt man auf einer + * ungewählten an, wählt der ganze Zug aus; fängt man auf einer gewählten + * an, nimmt er weg. Jede einzeln umzuschalten hiesse, dass ein Zug über + * eine gemischte Reihe sie nur vertauscht — und das will niemand. + */ + + /** Wie weit die Maus muss, bevor aus dem Klick ein Streichen wird. */ + const SCHWELLE = 6; + + /** Wie nah an den Rand, bevor die Seite mitwandert. */ + const RANDBEREICH = 72; + + /** In welchen Abständen die Strecke zwischen zwei Ereignissen abgetastet wird. + * + * **Kleiner als die schmalste Kachel.** Bei 150 Bildpunkten Breite liesse + * sich mit 48 keine überspringen — 24 ist der halbe Weg dahin und kostet + * nichts, weil `elementFromPoint` nur nachschlägt. */ + const ABTASTUNG = 24; + + let geradeGestrichen = false; + let zug: { + anschalten: boolean; + losX: number; losY: number; + losKachel: HTMLElement; + begonnen: boolean; + zeiger: number; + /** Wo der Zeiger zuletzt war — für die Strecke dazwischen. */ + letztX: number; letztY: number; + } | null = null; + let randtakt = 0; + + gitter.addEventListener('pointerdown', e => { + // **Jede neue Berührung fängt sauber an.** + // + // Die Sperre hier zurückzusetzen und nicht erst im Klick ist der + // Unterschied zwischen „funktioniert" und „funktioniert meistens": + // ging der Klick nach einem Streichen woandershin — auf das Kreuz der + // Auswahlleiste etwa —, blieb sie stehen, und der nächste Klick auf + // eine Kachel öffnete nichts mehr. Genau so gemessen. + geradeGestrichen = false; + + // **Nur die Maus.** Auf einem Berührungsbildschirm wäre jedes Wischen + // zum Blättern ein Streichen über die Kacheln — die Liste liesse sich + // nicht mehr scrollen. + if (e.pointerType !== 'mouse' || e.button !== 0) return; + + const k = (e.target as HTMLElement).closest('.kachel'); + if (!k || !gitter.contains(k) || !k.dataset.id) return; + + zug = { + anschalten: !gewaehlt.has(k.dataset.id), + losX: e.clientX, losY: e.clientY, + losKachel: k, + begonnen: false, + zeiger: e.pointerId, + letztX: e.clientX, letztY: e.clientY, + }; + + window.addEventListener('pointermove', beiZug); + window.addEventListener('pointerup', zugEnde); + window.addEventListener('pointercancel', zugEnde); + }); + + function beiZug(e: PointerEvent): void { + if (!zug || e.pointerId !== zug.zeiger) return; + + if (!zug.begonnen) { + if (Math.abs(e.clientX - zug.losX) < SCHWELLE + && Math.abs(e.clientY - zug.losY) < SCHWELLE) return; + + zug.begonnen = true; + gitter.classList.add('zieht'); + // Ohne das markiert der Browser nebenher die Beschriftungen blau. + document.body.classList.add('keine-auswahl'); + setzen(zug.losKachel, zug.anschalten); + } + + // **Die Strecke zählt, nicht nur der Punkt.** + // + // Ein Zeigerereignis kommt nicht für jeden Bildpunkt: wer schnell + // streicht, springt mit einem Sprung über zwei Kacheln hinweg, und die + // blieben ungewählt. Gemessen beim Prüfen: ein Zug über vier Kacheln + // erwischte die erste und die letzte. Deshalb wird die Strecke seit dem + // letzten Mal abgeschritten. + // + // Nicht `e.target`: der Zeiger ist gedrückt, und damit gehen alle + // Ereignisse an die Kachel, auf der es angefangen hat. + const weitX = e.clientX - zug.letztX; + const weitY = e.clientY - zug.letztY; + const strecke = Math.hypot(weitX, weitY); + const schritte = Math.max(1, Math.ceil(strecke / ABTASTUNG)); + + for (let i = 1; i <= schritte; i++) { + const x = zug.letztX + (weitX * i) / schritte; + const y = zug.letztY + (weitY * i) / schritte; + const k = document.elementFromPoint(x, y)?.closest('.kachel'); + if (k && gitter.contains(k)) setzen(k, zug.anschalten); + } + + zug.letztX = e.clientX; + zug.letztY = e.clientY; + randlauf(e.clientY); + } + + function zugEnde(e: PointerEvent): void { + if (zug && e.pointerId !== zug.zeiger) return; + + window.removeEventListener('pointermove', beiZug); + window.removeEventListener('pointerup', zugEnde); + window.removeEventListener('pointercancel', zugEnde); + window.cancelAnimationFrame(randtakt); + randtakt = 0; + + gitter.classList.remove('zieht'); + document.body.classList.remove('keine-auswahl'); + geradeGestrichen = zug?.begonnen === true; + zug = null; + } + + /** Setzt eine Kachel auf einen Wert — ohne hin und her zu schalten. */ + function setzen(k: HTMLElement, an: boolean): void { + const id = k.dataset.id; + if (!id || gewaehlt.has(id) === an) return; + + if (an) gewaehlt.add(id); else gewaehlt.delete(id); + k.classList.toggle('gewaehlt', an); + k.querySelector('.wahlmarke')?.setAttribute('aria-checked', String(an)); + nachAuswahl(); + } + + /** Streicht man an den Rand, wandert die Seite mit. + * + * **Sonst endet jede Auswahl am unteren Bildrand.** Bei 447 Titeln sind + * das zwei Reihen von vierzig. */ + function randlauf(y: number): void { + window.cancelAnimationFrame(randtakt); + if (!zug?.begonnen) return; + + const oben = y - RANDBEREICH; + const unten = y - (window.innerHeight - RANDBEREICH); + const schritt = oben < 0 ? Math.max(-24, oben / 3) + : unten > 0 ? Math.min(24, unten / 3) + : 0; + if (schritt === 0) return; + + randtakt = window.requestAnimationFrame(() => { + window.scrollBy(0, schritt); + randlauf(y); + }); + } + // Dieselbe Geste für die Tastatur. gitter.addEventListener('keydown', e => { if (e.key !== 'Enter' && e.key !== ' ') return; @@ -312,12 +481,19 @@ export function mediathek( })); } - // **Abwählen bleibt immer anklickbar** — es ist der Weg heraus. - const wegKnopf = knopf('Abwählen', 'btn'); + // **Ein Kreuz, kein Wort.** + // + // „Abwählen" stand neben drei Knöpfen, die etwas tun — und sah damit aus + // wie ein vierter davon. Das Kreuz sagt, was es ist: die Auswahl + // schliessen. Es steht abgesetzt am Rand, nicht in der Reihe. + const wegKnopf = document.createElement('button'); + wegKnopf.className = 'auswahl-zu'; + wegKnopf.innerHTML = icons.schliessen; + wegKnopf.title = 'Auswahl schliessen'; + wegKnopf.setAttribute('aria-label', 'Auswahl schliessen'); wegKnopf.onclick = abwaehlen; - knoepfe.appendChild(wegKnopf); - b.append(zahl, knoepfe); + b.append(zahl, knoepfe, wegKnopf); return { element: b,