From daa836fd8c8547287d203aed2a7e721a3c9acbae Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 14 Sep 2026 16:30:19 +0200 Subject: [PATCH] Das Aufmacherbild lag im falschen Format, und die Punkte fehlten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Flixy/Views/Detail/PlayableDetailView.swift | 13 +- Flixy/Views/Detail/SeriesDetailView.swift | 13 +- web/frontend/index.html | 267 ++++++++++++++++++-- 3 files changed, 267 insertions(+), 26 deletions(-) diff --git a/Flixy/Views/Detail/PlayableDetailView.swift b/Flixy/Views/Detail/PlayableDetailView.swift index a4a27fd..6e7af06 100644 --- a/Flixy/Views/Detail/PlayableDetailView.swift +++ b/Flixy/Views/Detail/PlayableDetailView.swift @@ -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 { diff --git a/Flixy/Views/Detail/SeriesDetailView.swift b/Flixy/Views/Detail/SeriesDetailView.swift index f775c08..1f890f0 100644 --- a/Flixy/Views/Detail/SeriesDetailView.swift +++ b/Flixy/Views/Detail/SeriesDetailView.swift @@ -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) diff --git a/web/frontend/index.html b/web/frontend/index.html index c4d3243..460ddcc 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -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 = '
'; 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 = ` +
+

Einstellungen

+ +
+

Akzentfarbe

+

Dieselbe Palette wie in der App.

+
+
+ +
+

Navigation

+

Oben als schwebende Insel oder links als Spalte. Oben blendet sie + beim Scrollen aus, an der Seite bleibt sie stehen.

+
+ + +
+
+ +
+

Kachelgröße

+

Wie groß Plakate und Vorschaubilder erscheinen.

+
+ + +
+
+ +
+

Server

+

+ +
+
`; + + 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', ``)); + 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 });