Das Aufmacherbild lag im falschen Format, und die Punkte fehlten

Drei Dinge aus einer Runde Rückmeldungen.

Die Detailseiten forderten ihr Bild in 900×1200 an — hochkant — und
zeigten es in einem 16:9-Rahmen; zu sehen war ein Streifen aus der
Mitte. Derselbe Fehler war im Aufmacher schon einmal behoben worden, nur
hier nicht. Beide gehen jetzt über HeroArtwork, wo die richtigen Maße
schon stehen.

Im Web hingen die Karussellpunkte neben dem Aufmacher, das CSS zielte
aber hinein — sie waren unsichtbar. Jetzt sind sie da, anklickbar, und
es gibt Pfeile, Wischen und Pfeiltasten dazu. Wer selbst blättert, hält
das Weiterwechseln an: sonst springt der Aufmacher genau in der Sekunde,
in der man nach "Abspielen" greift.

Dazu Seitenleiste und Einstellungen fürs Web — Akzentfarbe aus derselben
Palette wie die App, Navigation oben oder seitlich, und ein Regler für
die Kachelgröße. Das Filmlogo ist um zehn Prozent gewachsen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 16:30:19 +02:00
co-authored by Claude Opus 5
parent 018eaa3308
commit daa836fd8c
3 changed files with 267 additions and 26 deletions
+8 -5
View File
@@ -11,6 +11,7 @@ struct PlayableDetailView: View {
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.flixyAccent) private var accent
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var selectedVersion = 0
@State private var showsTechnicalDetails = false
@@ -72,11 +73,13 @@ struct PlayableDetailView: View {
metadata: item.kind == .episode ? nil : metadataLine,
metadataContent: item.kind == .episode ? AnyView(episodeMetadata) : nil,
summary: nil,
artworkURL: session.backend?.artworkURL(
path: item.art ?? item.seriesArt ?? item.thumb,
width: 900,
height: 1200
),
// Über `HeroArtwork`, nicht von Hand: hier standen 900×1200 —
// hochkant — für ein Bild, das im 16:9-Rahmen landet. Zu sehen war
// davon ein Streifen aus der Mitte. Derselbe Fehler war im
// Aufmacher schon einmal behoben worden, nur hier nicht.
artworkURL: session.backend.flatMap {
HeroArtwork.backdropURL(for: item, backend: $0, compact: sizeClass == .compact)
},
logoURL: session.backend?.rawImageURL(path: item.clearLogoPath)
) {
Button {
+8 -5
View File
@@ -10,6 +10,7 @@ struct SeriesDetailView: View {
let item: MediaItem
@Environment(FlixySession.self) private var session
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var children: LoadState<[MediaItem]> = .loading
var body: some View {
@@ -44,11 +45,13 @@ struct SeriesDetailView: View {
title: item.rowTitle,
metadata: metadataLine,
summary: nil,
artworkURL: session.backend?.artworkURL(
path: item.art ?? item.thumb,
width: 900,
height: 1200
),
// Über `HeroArtwork`, nicht von Hand: hier standen 900×1200 —
// hochkant — für ein Bild, das im 16:9-Rahmen landet. Zu sehen war
// davon ein Streifen aus der Mitte. Derselbe Fehler war im
// Aufmacher schon einmal behoben worden, nur hier nicht.
artworkURL: session.backend.flatMap {
HeroArtwork.backdropURL(for: item, backend: $0, compact: sizeClass == .compact)
},
logoURL: session.backend?.rawImageURL(path: item.clearLogoPath)
) {
DownloadAllButton(container: item)
+251 -16
View File
@@ -30,6 +30,11 @@
--s-tight: 4px; --s-snug: 8px; --s-regular: 12px;
--s-loose: 16px; --s-section: 24px; --s-margin: 16px;
/* Die Kachelbreite. Der Regler in den Einstellungen verstellt genau diese
eine Zahl — Reihen, Raster und Querformat rechnen sich daraus ab. */
--card-w: 150px;
--sidebar-w: 235px;
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text",
"Segoe UI Variable Text", "Segoe UI", system-ui, Roboto, sans-serif;
}
@@ -141,7 +146,7 @@ img { display: block; }
padding: 0 var(--s-margin) var(--s-section);
display: flex; flex-direction: column; gap: var(--s-snug);
}
#hero .logo { max-width: 260px; max-height: 76px; object-fit: contain; }
#hero .logo { max-width: 286px; max-height: 84px; object-fit: contain; } /* 10 % über der App-Größe, auf Wunsch */
#hero h2 { font-size: 34px; font-weight: 700; margin: 0; letter-spacing: -.02em; }
#hero .meta { color: var(--text-2); font-size: 13px; }
#hero .summary {
@@ -149,12 +154,27 @@ img { display: block; }
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
#hero .actions { display: flex; gap: var(--s-regular); margin-top: var(--s-tight); }
#hero .dots { display: flex; gap: var(--s-snug); justify-content: center; padding: var(--s-regular) 0; }
#hero .dots i {
.dots { display: flex; gap: var(--s-snug); justify-content: center; padding: var(--s-regular) 0 var(--s-section); }
.dots button {
width: 6px; height: 6px; border-radius: 999px; background: rgba(255,255,255,.35);
transition: width .25s ease, background .25s ease;
padding: 0; transition: width .25s ease, background .25s ease;
}
#hero .dots i.on { width: 18px; background: var(--accent); }
.dots button:hover { background: rgba(255,255,255,.6); }
.dots button.on { width: 18px; background: var(--accent); }
/* Blättern von Hand. Die Pfeile liegen auf dem Bild und halten sich zurück,
bis der Zeiger da ist — auf einem Zeigegerät. Wer wischt, braucht sie nicht. */
.hero-nav {
position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
width: 44px; height: 44px; border-radius: 999px; padding: 0;
background: rgba(20,20,23,.55); color: var(--text); font-size: 20px;
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border: 1px solid var(--separator); opacity: 0; transition: opacity .2s ease;
}
#hero:hover .hero-nav { opacity: 1; }
.hero-nav.prev { left: var(--s-margin); }
.hero-nav.next { right: var(--s-margin); }
@media (hover: none) { .hero-nav { display: none; } }
/* ---- Reihen -------------------------------------------------------------- */
.row { margin-bottom: var(--s-section); }
@@ -169,8 +189,8 @@ img { display: block; }
}
.strip::-webkit-scrollbar { display: none; }
.card { flex: 0 0 auto; width: 150px; scroll-snap-align: start; background: none; padding: 0; text-align: left; }
.card.wide { width: 260px; }
.card { flex: 0 0 auto; width: var(--card-w); scroll-snap-align: start; background: none; padding: 0; text-align: left; }
.card.wide { width: calc(var(--card-w) * 1.73); }
.card .art {
width: 100%; aspect-ratio: 2/3; border-radius: var(--r-card);
background: var(--placeholder) center/cover no-repeat;
@@ -187,7 +207,7 @@ img { display: block; }
/* ---- Raster (Mediathek) -------------------------------------------------- */
.grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
display: grid; grid-template-columns: repeat(auto-fill, minmax(calc(var(--card-w) * .75), 1fr));
gap: var(--s-loose) var(--s-regular); padding: 0 var(--s-margin) var(--s-section);
}
.grid .card { width: auto; }
@@ -208,6 +228,58 @@ img { display: block; }
#sheet p { color: var(--text-2); font-size: 14px; }
#sheet video { width: 100%; border-radius: var(--r-card); background: #000; margin-top: var(--s-regular); }
/* ---- Die Navigation an der Seite -----------------------------------------
Dieselbe Wahl wie in der App: die Leiste sitzt oben als Insel oder links
als Spalte. Links bleibt sie stehen, statt beim Scrollen wegzufahren —
dort nimmt sie niemandem etwas weg. */
#app.side #chrome {
top: 0; bottom: 0; left: 0; right: auto; width: calc(var(--sidebar-w) + 24px);
align-items: stretch; padding: 12px; transform: none !important; opacity: 1 !important;
}
#app.side .island {
flex-direction: column; align-items: stretch; gap: 2px;
width: 100%; max-width: none; overflow-y: auto; overflow-x: hidden;
padding: var(--s-regular) var(--s-snug);
}
#app.side .island button { text-align: left; border-radius: var(--r-control); }
#app.side .island .sep { width: auto; height: 1px; margin: var(--s-snug); }
#app.side #content { margin-left: calc(var(--sidebar-w) + 24px); }
#app.side .pad-top { padding-top: var(--s-loose); }
.island .brand {
display: none; font-size: 20px; font-weight: 700; letter-spacing: -.02em;
padding: var(--s-snug) var(--s-loose) var(--s-regular); color: var(--text);
}
#app.side .island .brand { display: block; }
.island .group-label {
display: none; font-size: 12px; color: var(--text-3);
padding: var(--s-regular) var(--s-loose) var(--s-tight);
}
#app.side .island .group-label { display: block; }
/* ---- Einstellungen -------------------------------------------------------- */
.settings { max-width: 620px; margin: 0 auto; padding: 0 var(--s-margin) var(--s-section); }
.settings h2 { font-size: 28px; letter-spacing: -.02em; margin: 0 0 var(--s-section); }
.setting {
background: var(--surface); border: 1px solid var(--separator);
border-radius: var(--r-sheet); padding: var(--s-loose); margin-bottom: var(--s-regular);
}
.setting h4 { margin: 0 0 var(--s-snug); font-size: 15px; font-weight: 600; }
.setting p { margin: 0 0 var(--s-regular); color: var(--text-2); font-size: 13px; }
.swatches { display: flex; flex-wrap: wrap; gap: var(--s-regular); }
.swatch {
width: 34px; height: 34px; border-radius: 999px; padding: 0;
border: 2px solid transparent; transition: transform .12s ease;
}
.swatch:hover { transform: scale(1.12); }
.swatch.on { border-color: var(--text); }
.seg { display: flex; gap: var(--s-snug); }
.seg button { flex: 1; background: var(--elevated); color: var(--text-2); border-radius: var(--r-control); padding: 10px; }
.seg button.on { background: var(--accent); color: #fff; }
input[type=range] { width: 100%; accent-color: var(--accent); }
.range-row { display: flex; align-items: center; gap: var(--s-regular); }
.range-row output { min-width: 56px; text-align: right; color: var(--text-2); font-size: 13px; }
/* ---- Zustände ------------------------------------------------------------ */
#content { padding-bottom: var(--s-section); }
.pad-top { padding-top: calc(84px + env(safe-area-inset-top)); }
@@ -357,12 +429,47 @@ function signOut() {
location.reload();
}
/* ============================================================================
Einstellungen
----------------------------------------------------------------------------
Im Browser gespeichert, nicht auf dem Server: es sind Fragen der Ansicht
auf *diesem* Gerät. Wer am Fernseher größere Kacheln will als am Laptop,
soll das haben, ohne es dem anderen mitzuteilen.
========================================================================= */
const PALETTE = [
['Ember','#E5484D'], ['Tangerine','#F76B15'], ['Amber','#FFB224'], ['Lime','#99D52A'],
['Mint','#3DD68C'], ['Teal','#12A594'], ['Sky','#52A9FF'], ['Indigo','#7C77E8'],
['Orchid','#C04AC9'], ['Rose','#F76190'],
];
const P = {
accent: localStorage.getItem('flixy.accent') || '#C04AC9',
nav: localStorage.getItem('flixy.nav') || 'top', // 'top' | 'side'
cardW: +(localStorage.getItem('flixy.cardW') || 150),
};
function applyPrefs() {
const r = document.documentElement.style;
r.setProperty('--accent', P.accent);
r.setProperty('--card-w', P.cardW + 'px');
el('app').classList.toggle('side', P.nav === 'side');
// An der Seite fährt nichts weg — die Regel gilt nur für die Insel oben.
if (P.nav === 'side') el('chrome').classList.remove('away');
}
function setPref(key, value) {
P[key] = value;
localStorage.setItem('flixy.' + key, value);
applyPrefs();
}
/* ---------- Navigation ---------- */
let views = [], current = null;
async function start() {
el('login').classList.add('hidden');
el('app').classList.remove('hidden');
applyPrefs();
el('content').innerHTML = '<div class="spinner"></div>';
try {
const r = await api(`/Users/${S.userId}/Views`);
@@ -383,11 +490,26 @@ function buildIsland() {
b.textContent = label; b.dataset.key = key;
b.onclick = () => go(key);
isl.appendChild(b);
return b;
};
const bit = (cls, text) => {
const d = document.createElement('div');
d.className = cls; if (text) d.textContent = text;
isl.appendChild(d);
};
// Die Marke steht nur in der Spalte — oben nähme sie Platz weg, den die
// Einträge brauchen.
bit('brand', 'Flixy');
add('Start', 'home');
if (views.length) isl.appendChild(Object.assign(document.createElement('div'), { className: 'sep' }));
views.forEach((v) => add(v.Name, 'lib:' + v.Id));
isl.appendChild(Object.assign(document.createElement('div'), { className: 'sep' }));
add('Einstellungen', 'settings');
if (views.length) {
bit('sep');
bit('group-label', 'Mediatheken');
views.forEach((v) => add(v.Name, 'lib:' + v.Id));
}
bit('sep');
add('Abmelden', 'out');
}
@@ -397,7 +519,71 @@ function go(key) {
[...el('island').querySelectorAll('button')].forEach((b) =>
b.classList.toggle('active', b.dataset.key === key));
window.scrollTo({ top: 0 });
key === 'home' ? renderHome() : renderLibrary(key.slice(4));
el('content').classList.remove('pad-top');
if (key === 'home') renderHome();
else if (key === 'settings') renderSettings();
else renderLibrary(key.slice(4));
}
/* ---------- Einstellungen ---------- */
function renderSettings() {
const c = el('content');
c.classList.add('pad-top');
c.innerHTML = `
<div class="settings">
<h2>Einstellungen</h2>
<div class="setting">
<h4>Akzentfarbe</h4>
<p>Dieselbe Palette wie in der App.</p>
<div class="swatches" id="set-accent"></div>
</div>
<div class="setting">
<h4>Navigation</h4>
<p>Oben als schwebende Insel oder links als Spalte. Oben blendet sie
beim Scrollen aus, an der Seite bleibt sie stehen.</p>
<div class="seg" id="set-nav">
<button data-v="top">Oben</button>
<button data-v="side">Seitlich</button>
</div>
</div>
<div class="setting">
<h4>Kachelgröße</h4>
<p>Wie groß Plakate und Vorschaubilder erscheinen.</p>
<div class="range-row">
<input type="range" id="set-zoom" min="100" max="280" step="10">
<output id="set-zoom-out"></output>
</div>
</div>
<div class="setting">
<h4>Server</h4>
<p id="set-server"></p>
<button class="btn" onclick="signOut()">Abmelden</button>
</div>
</div>`;
const sw = el('set-accent');
PALETTE.forEach(([name, hex]) => {
const b = document.createElement('button');
b.className = 'swatch' + (hex.toLowerCase() === P.accent.toLowerCase() ? ' on' : '');
b.style.background = hex; b.title = name;
b.onclick = () => { setPref('accent', hex); renderSettings(); };
sw.appendChild(b);
});
[...el('set-nav').children].forEach((b) => {
b.classList.toggle('on', b.dataset.v === P.nav);
b.onclick = () => { setPref('nav', b.dataset.v); renderSettings(); };
});
const z = el('set-zoom'), zo = el('set-zoom-out');
z.value = P.cardW; zo.textContent = P.cardW + ' px';
z.oninput = () => { zo.textContent = z.value + ' px'; setPref('cardW', +z.value); };
el('set-server').textContent = S.base.replace(/^https?:\/\//, '');
}
/* ---------- Startseite ---------- */
@@ -456,9 +642,20 @@ function buildHero(items) {
const dots = document.createElement('div');
dots.className = 'dots';
items.forEach((_, i) => dots.insertAdjacentHTML('beforeend', `<i class="${i ? '' : 'on'}"></i>`));
items.forEach((_, i) => {
const d = document.createElement('button');
d.className = i ? '' : 'on';
d.setAttribute('aria-label', `Titel ${i + 1}`);
d.onclick = () => { stop(); at = i; paint(); };
dots.appendChild(d);
});
let at = 0;
let at = 0, timer = null;
// Wer selbst blättert, fährt selbst: das Weiterwechseln hört auf, sonst
// springt der Aufmacher genau in der Sekunde, in der man nach „Abspielen"
// greift — und man startet den falschen Film.
const stop = () => { if (timer) { clearInterval(timer); timer = null; } };
const step = (by) => { stop(); at = (at + by + items.length) % items.length; paint(); };
const paint = () => {
const it = items[at];
const bits = [it.ProductionYear, runtime(it), it.OfficialRating].filter(Boolean);
@@ -478,9 +675,46 @@ function buildHero(items) {
[...dots.children].forEach((d, i) => d.classList.toggle('on', i === at));
};
paint();
if (items.length > 1) {
const timer = setInterval(() => {
if (!document.body.contains(wrap)) return clearInterval(timer);
const prev = document.createElement('button');
prev.className = 'hero-nav prev'; prev.innerHTML = '‹';
prev.setAttribute('aria-label', 'Vorheriger Titel');
prev.onclick = () => step(-1);
const next = document.createElement('button');
next.className = 'hero-nav next'; next.innerHTML = '›';
next.setAttribute('aria-label', 'Nächster Titel');
next.onclick = () => step(1);
wrap.append(prev, next);
// Wischen — erst ab einem ordentlichen Weg und nur, wenn es waagerecht
// gemeint ist. Sonst nimmt die Geste dem Scrollen den Vortritt.
let x0 = null, y0 = null;
wrap.addEventListener('touchstart', (e) => {
x0 = e.touches[0].clientX; y0 = e.touches[0].clientY;
}, { passive: true });
wrap.addEventListener('touchend', (e) => {
if (x0 == null) return;
const dx = e.changedTouches[0].clientX - x0;
const dy = e.changedTouches[0].clientY - y0;
if (Math.abs(dx) > 48 && Math.abs(dx) > Math.abs(dy)) step(dx < 0 ? 1 : -1);
x0 = y0 = null;
}, { passive: true });
// Pfeiltasten, solange kein Blatt offen ist.
const keys = (e) => {
if (!document.body.contains(wrap) || document.getElementById('sheet')) return;
if (e.key === 'ArrowRight') step(1);
if (e.key === 'ArrowLeft') step(-1);
};
addEventListener('keydown', keys);
timer = setInterval(() => {
if (!document.body.contains(wrap)) {
stop(); removeEventListener('keydown', keys); return;
}
at = (at + 1) % items.length; paint();
}, 7000); // sieben Sekunden — lang genug, um den Satz zu Ende zu lesen
}
@@ -601,6 +835,7 @@ function runtime(it) {
/* Die Insel fährt beim Scrollen weg und kommt zurück, sobald man oben steht —
nicht beim ersten Hochwischen. Dieselbe Regel wie in der App. */
addEventListener('scroll', () => {
if (P.nav === 'side') return; // die Spalte fährt nicht weg
el('chrome').classList.toggle('away', scrollY > 8);
}, { passive: true });