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 });