Fünf Aufmacher je Titel, und der Abspann zählt als Schluss
**Der Aufmacher darf wechseln.** Bisher hing hinter jedem Titel genau ein Hintergrundbild, und das Karussell war nach einer Woche ein Standbild. Jetzt lassen sich bis zu fünf Aufnahmen aussuchen — im Bilderdialog unter einem vierten Reiter, gespeist aus denselben Hintergründen. Welches gerade gilt, entscheidet der Server aus Uhrzeit und Kennung: alle fünf Stunden ein anderes, überall dasselbe — im Web, in der App, nach einem Neustart. Ein Zufall je Abruf hiesse, dass das Bild beim Blättern springt; eine gespeicherte Wahl hiesse einen Schreibvorgang je Titel und Fenster. Die Kennung geht mit in die Rechnung, damit nicht alle Titel im selben Augenblick umschalten. Plakat und Hintergrund bleiben unberührt. Sie stehen hinter Kacheln und Kopfzeilen, und dort soll nichts wechseln. **Und der Abspann zählt als Schluss.** Eine Serie hängt sechs Minuten Abspann an eine Folge von fünfundvierzig; neunzig Prozent liegen dann mittendrin, und die Folge gilt erst als gesehen, wenn man ihn sich ganz ansieht. Die Schwelle herunterzudrehen hiesse, jeden anderen Titel zu früh abzuhaken. Der erkannte Abspann ist die genauere Antwort auf dieselbe Frage — er zieht die Schwelle nach vorn, nie nach hinten. Dazu der Verlauf über dem Aufmacher: er war immer noch zu dunkel. Jetzt trägt der Schattenwurf der Schrift, was vorher eine Decke über dem ganzen Bild tat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8cf2118723
commit
1d70be0f99
@@ -85,6 +85,8 @@ export type Film = {
|
||||
*
|
||||
* Das Auffrischen hängt an der Mediathek, nicht am Titel. */
|
||||
libraryId: string | null;
|
||||
/** Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt. */
|
||||
heroImage?: string | null;
|
||||
};
|
||||
export type Serie = {
|
||||
id: string; title: string; year: number | null; overview: string | null;
|
||||
@@ -112,6 +114,8 @@ export type Serie = {
|
||||
*
|
||||
* Das Auffrischen hängt an der Mediathek, nicht am Titel. */
|
||||
libraryId: string | null;
|
||||
/** Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt. */
|
||||
heroImage?: string | null;
|
||||
};
|
||||
/** Ein überspringbarer Abschnitt einer Folge. */
|
||||
export type Marke = { kind: 'intro' | 'credits'; startSeconds: number; endSeconds: number };
|
||||
@@ -146,6 +150,8 @@ export type Angefangen = {
|
||||
publishAt?: string | null;
|
||||
/** Das breite Bild der Serie — für den Aufmacher, nicht für die Kachel. */
|
||||
seriesBackdropImage?: string | null;
|
||||
/** Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt. */
|
||||
heroImage?: string | null;
|
||||
};
|
||||
export type Bibliothek = {
|
||||
id: string; name: string;
|
||||
@@ -897,6 +903,8 @@ export type Bildauswahl = {
|
||||
current: {
|
||||
poster: string | null; backdrop: string | null; logo: string | null;
|
||||
posterPinned: boolean; backdropPinned: boolean; logoPinned: boolean;
|
||||
/** Die fürs Karussell gewählten Bilder, höchstens fünf. */
|
||||
heroes?: string[] | null;
|
||||
};
|
||||
candidates: Bildkandidat[];
|
||||
};
|
||||
@@ -904,6 +912,16 @@ export type Bildauswahl = {
|
||||
export const bildauswahl = (art: 'movies' | 'series', id: string) =>
|
||||
anfrage<Bildauswahl>(`/${art}/${id}/artwork`);
|
||||
|
||||
/** Setzt die Auswahl fürs Aufmacher-Karussell — alle auf einmal.
|
||||
*
|
||||
* Eine Liste und kein einzelner Wert: hier wird nicht ein Bild gesetzt,
|
||||
* sondern die ganze Auswahl ersetzt. Vorhandene gehen als
|
||||
* Zwischenspeicherschlüssel mit, neue als Adresse — der Server
|
||||
* unterscheidet das selbst. */
|
||||
export const aufmacherSetzen = (art: 'movies' | 'series', id: string, urls: string[]) =>
|
||||
anfrage<void>(`/${art}/${id}/artwork/aufmacher`,
|
||||
{ method: 'PUT', body: JSON.stringify({ urls }) });
|
||||
|
||||
export const bildSetzen = (art: 'movies' | 'series', id: string, kind: string, url: string) =>
|
||||
anfrage<void>(`/${art}/${id}/artwork`,
|
||||
{ method: 'PUT', body: JSON.stringify({ kind, url }) });
|
||||
|
||||
+124
-2
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
bildAdresse, bildauswahl, bildSetzen, bildHochladen, bildFreigeben, type Bildkandidat,
|
||||
bildAdresse, bildauswahl, bildSetzen, bildHochladen, bildFreigeben, aufmacherSetzen,
|
||||
type Bildkandidat,
|
||||
} from './api';
|
||||
import { knopf, fehlerzeile, meldung, hinweisZeigen } from './util';
|
||||
import { icons } from './icons';
|
||||
@@ -8,6 +9,13 @@ import { zuschneiden } from './bildzuschnitt';
|
||||
|
||||
type Bildart = 'poster' | 'backdrop' | 'logo';
|
||||
|
||||
/** Wie viele Bilder das Karussell höchstens je Titel durchwechselt.
|
||||
*
|
||||
* Muss zu `Aufmacherwahl.Hoechstens` auf dem Server passen. Mehr hiesse,
|
||||
* dass ein einzelnes kaum noch vorkommt: bei zehn Bildern und fünf Stunden
|
||||
* Fenster sähe man dasselbe zweimal im Monat. */
|
||||
const AUFMACHER_HOECHSTENS = 5;
|
||||
|
||||
/** Was jede Bildart ist — und in welcher Form sie hochgeladen wird.
|
||||
*
|
||||
* **`zuschnitt: null` beim Schriftzug, und das ist kein Versehen.** Ein
|
||||
@@ -93,7 +101,7 @@ export function bilderflaeche(
|
||||
koerper.innerHTML = '<div class="spinner"></div>';
|
||||
|
||||
/** Welcher Reiter offen ist — überlebt das Neuzeichnen nach jeder Wahl. */
|
||||
let offen: Bildart = 'poster';
|
||||
let offen: Bildart | 'aufmacher' = 'poster';
|
||||
|
||||
void laden();
|
||||
|
||||
@@ -258,6 +266,120 @@ export function bilderflaeche(
|
||||
buehne.appendChild(abschnitt);
|
||||
bloecke.set(bildart, abschnitt);
|
||||
});
|
||||
|
||||
aufmacherreiter(auswahl, reiter, buehne);
|
||||
}
|
||||
|
||||
/** Der vierte Reiter: mehrere Bilder fürs Karussell.
|
||||
*
|
||||
* **Warum er nicht in der Schleife darüber steht.** Die drei anderen
|
||||
* setzen genau ein Bild, lassen sich festsetzen, wieder freigeben und von
|
||||
* der Platte hochladen. Hier geht es um eine Auswahl aus denselben
|
||||
* Hintergründen — kein Festsetzen, kein Hochladen, dafür mehrere. In
|
||||
* dieselbe Schleife gezwängt wäre jede zweite Zeile ein `if`.
|
||||
*/
|
||||
function aufmacherreiter(
|
||||
auswahl: Awaited<ReturnType<typeof bildauswahl>>,
|
||||
reiter: HTMLElement, buehne: HTMLElement,
|
||||
): void {
|
||||
const gewaehlt = [...(auswahl.current.heroes ?? [])];
|
||||
const passende = auswahl.candidates.filter(k => k.kind === 'backdrop');
|
||||
|
||||
const knopfReiter = document.createElement('button');
|
||||
knopfReiter.type = 'button';
|
||||
knopfReiter.className = 'bildreiter-knopf' + (offen === 'aufmacher' ? ' an' : '');
|
||||
knopfReiter.setAttribute('role', 'tab');
|
||||
knopfReiter.setAttribute('aria-selected', String(offen === 'aufmacher'));
|
||||
knopfReiter.innerHTML = '<span>Aufmacher</span>'
|
||||
+ `<span class="winzig">${gewaehlt.length}/${AUFMACHER_HOECHSTENS}</span>`;
|
||||
knopfReiter.onclick = () => { offen = 'aufmacher'; void laden(); };
|
||||
reiter.appendChild(knopfReiter);
|
||||
|
||||
if (offen !== 'aufmacher') return;
|
||||
|
||||
const abschnitt = document.createElement('section');
|
||||
abschnitt.className = 'bildblock';
|
||||
|
||||
const kopfzeile = document.createElement('div');
|
||||
kopfzeile.className = 'knopfreihe';
|
||||
const u = document.createElement('h3');
|
||||
u.textContent = 'Aufmacher';
|
||||
const hinweis = document.createElement('p');
|
||||
hinweis.className = 'winzig';
|
||||
hinweis.textContent = `Bis zu ${AUFMACHER_HOECHSTENS} Bilder. Das Karussell wechselt alle `
|
||||
+ 'paar Stunden zwischen ihnen — Plakat und Hintergrund bleiben, wie sie sind.';
|
||||
kopfzeile.appendChild(u);
|
||||
abschnitt.append(kopfzeile, hinweis);
|
||||
|
||||
if (passende.length === 0 && gewaehlt.length === 0) {
|
||||
const leer = document.createElement('p');
|
||||
leer.className = 'winzig';
|
||||
leer.textContent = 'Nichts zur Auswahl.';
|
||||
abschnitt.appendChild(leer);
|
||||
buehne.appendChild(abschnitt);
|
||||
return;
|
||||
}
|
||||
|
||||
const gitter = document.createElement('div');
|
||||
gitter.className = 'bildgitter';
|
||||
gitter.style.setProperty('--verhaeltnis', '16 / 9');
|
||||
|
||||
// Die gewählten zuerst, in ihrer Reihenfolge — so sieht man auf einen
|
||||
// Blick, was das Karussell zeigt.
|
||||
gewaehlt.forEach(schluessel => gitter.appendChild(
|
||||
aufmacherkachel(bildAdresse(schluessel)!, true, () => speichern(
|
||||
gewaehlt.filter(x => x !== schluessel)))));
|
||||
|
||||
passende.forEach(k => gitter.appendChild(
|
||||
aufmacherkachel(k.thumbUrl, false, () => {
|
||||
if (gewaehlt.length >= AUFMACHER_HOECHSTENS) {
|
||||
hinweisZeigen(`Mehr als ${AUFMACHER_HOECHSTENS} gehen nicht. Nimm erst eines weg.`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return speichern([...gewaehlt, k.url]);
|
||||
})));
|
||||
|
||||
abschnitt.appendChild(gitter);
|
||||
buehne.appendChild(abschnitt);
|
||||
|
||||
async function speichern(neu: string[]): Promise<void> {
|
||||
await aufmacherSetzen(art, id, neu);
|
||||
beiAenderung?.();
|
||||
await laden();
|
||||
}
|
||||
}
|
||||
|
||||
/** Eine Kachel im Aufmacher-Reiter — angekreuzt oder nicht. */
|
||||
function aufmacherkachel(
|
||||
adresse: string, drin: boolean, tun: () => Promise<void>,
|
||||
): HTMLElement {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'bildwahl' + (drin ? ' an' : '');
|
||||
b.title = drin ? 'Aus dem Karussell nehmen' : 'Ins Karussell aufnehmen';
|
||||
|
||||
const bild = document.createElement('img');
|
||||
bild.src = adresse;
|
||||
bild.alt = '';
|
||||
bild.loading = 'lazy';
|
||||
b.appendChild(bild);
|
||||
|
||||
if (drin) {
|
||||
const marke = document.createElement('span');
|
||||
marke.className = 'bildwahl-marke';
|
||||
marke.innerHTML = icons.haken;
|
||||
b.appendChild(marke);
|
||||
}
|
||||
|
||||
b.onclick = async () => {
|
||||
b.disabled = true;
|
||||
try { await tun(); }
|
||||
catch (fehler) {
|
||||
b.after(fehlerzeile(meldung(fehler)));
|
||||
b.disabled = false;
|
||||
}
|
||||
};
|
||||
return b;
|
||||
}
|
||||
|
||||
function kachelFuer(
|
||||
|
||||
+6
-2
@@ -450,7 +450,11 @@ export function alsHero(eintrag: Film | Serie, art: 'filme' | 'serien'): Heroein
|
||||
id: eintrag.id,
|
||||
titel: eintrag.title,
|
||||
schriftzug: eintrag.logoImage,
|
||||
hintergrund: eintrag.backdropImage ?? '',
|
||||
// **Das Aufmacherbild sticht den Hintergrund.** Wer mehrere Aufnahmen
|
||||
// ausgesucht hat, bekommt hier die, die gerade gilt — der Server
|
||||
// entscheidet das, damit Web und App dasselbe zeigen. Ohne Auswahl
|
||||
// bleibt es beim Hintergrund.
|
||||
hintergrund: eintrag.heroImage ?? eintrag.backdropImage ?? '',
|
||||
jahr: eintrag.year,
|
||||
laufzeit: eintrag.runtimeMinutes,
|
||||
freigabe: eintrag.contentRating,
|
||||
@@ -483,7 +487,7 @@ export function angefangenAlsHero(eintrag: Angefangen): Heroeintrag {
|
||||
// einer Folge ihr eigenes Standbild — richtig für die Kachel, falsch für
|
||||
// eine Fläche über den halben Bildschirm: wer der Serie ein Bild
|
||||
// ausgesucht hat, fand hier ein anderes vor.
|
||||
hintergrund: eintrag.seriesBackdropImage ?? eintrag.backdropImage ?? '',
|
||||
hintergrund: eintrag.heroImage ?? eintrag.seriesBackdropImage ?? eintrag.backdropImage ?? '',
|
||||
jahr: eintrag.year,
|
||||
laufzeit: null,
|
||||
freigabe: null,
|
||||
|
||||
+21
-5
@@ -1499,15 +1499,23 @@ html[data-aufmacher="voll"] {
|
||||
**Kurz und steil, nicht lang und flach.** Hier stand 55 % Deckung auf
|
||||
halber Höhe und durchsichtig erst bei 85 % — von einem starken Aufmacher
|
||||
war dadurch vor allem Grau zu sehen. Der Text sitzt unten links; genau
|
||||
dort muss es tragen, und darüber nicht. */
|
||||
dort muss es tragen, und darüber nicht.
|
||||
|
||||
**Zweite Runde, und zwar nach unten.** Auch kurz und steil war es noch
|
||||
zu dunkel: der Verlauf von links legte sich bis zur Mitte des Bildes und
|
||||
nahm ihm die Farbe, und der von unten begann sein Grau schon auf
|
||||
Zweidritteln. Jetzt trägt der untere das Gewicht — dort steht die
|
||||
Schrift —, und der von links ist nur noch ein Hauch über dem linken
|
||||
Drittel. Dass Schrift auf einem hellen Bild lesbar bleibt, übernimmt
|
||||
ihr eigener Schattenwurf, nicht eine Decke über dem ganzen Aufmacher. */
|
||||
.hero-buehne::after {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background:
|
||||
linear-gradient(to top, var(--canvas) 0%, rgba(11, 11, 13, .70) 16%,
|
||||
rgba(11, 11, 13, .25) 38%, transparent 62%),
|
||||
linear-gradient(to right, rgba(11, 11, 13, .85) 0%, rgba(11, 11, 13, .30) 30%,
|
||||
transparent 55%);
|
||||
linear-gradient(to top, var(--canvas) 0%, rgba(11, 11, 13, .62) 12%,
|
||||
rgba(11, 11, 13, .16) 30%, transparent 48%),
|
||||
linear-gradient(to right, rgba(11, 11, 13, .55) 0%, rgba(11, 11, 13, .12) 22%,
|
||||
transparent 38%);
|
||||
}
|
||||
|
||||
.hero-inhalt {
|
||||
@@ -1537,6 +1545,14 @@ html[data-aufmacher="voll"] {
|
||||
font-size: var(--t-hero); font-weight: 700; letter-spacing: -.03em;
|
||||
}
|
||||
|
||||
/* **Der Schatten trägt, nicht die Decke.** Seit der Verlauf heller ist,
|
||||
kann das Bild hinter der Schrift hell sein. Ein Schattenwurf am Text
|
||||
kostet nichts und wirkt genau dort, wo er gebraucht wird — eine dunklere
|
||||
Decke über dem ganzen Aufmacher wirkte überall. */
|
||||
.hero-titel,
|
||||
.hero-meta,
|
||||
.hero-text { text-shadow: 0 1px 4px rgba(0, 0, 0, .85), 0 0 18px rgba(0, 0, 0, .55); }
|
||||
|
||||
.hero-meta { font-size: var(--t-normal); color: var(--text-dim); }
|
||||
|
||||
/* Drei Zeilen, dann Schluss — eine volle Inhaltsangabe gehört auf die
|
||||
|
||||
Reference in New Issue
Block a user