diff --git a/web/frontend/index.html b/web/frontend/index.html index 9f8d2c6..59720c0 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -124,11 +124,22 @@ img { display: block; } .island button.active { background: var(--elevated); color: var(--accent); } .island .sep { width: 1px; height: 22px; background: var(--separator); margin: 0 var(--s-tight); } +/* Der Umschalter zwischen oben und seitlich — wie auf dem iPad ein Knopf in + der Leiste selbst. Ihn in die Einstellungen zu verbannen hieße, für einen + Handgriff zwei Ebenen tief zu gehen. */ +.island .toggle-nav { + padding: var(--s-snug); color: var(--text-2); display: flex; align-items: center; + border-radius: var(--r-control); flex: 0 0 auto; +} +.island .toggle-nav:hover { color: var(--text); background: var(--elevated); } +.island .toggle-nav svg { width: 18px; height: 18px; display: block; } +#app.side .island .toggle-nav { align-self: flex-end; margin: 0 var(--s-snug) var(--s-snug); } + /* ---- Aufmacher ----------------------------------------------------------- Randlos bis an den oberen Rand, die Insel schwebt darüber. Genau die Entscheidung, die in der App getroffen wurde. */ #hero { - position: relative; height: 82vh; min-height: 420px; overflow: hidden; + position: relative; height: var(--hero-h, 82vh); min-height: 380px; overflow: hidden; } #hero .shot { position: absolute; inset: 0; background-size: cover; background-position: center; @@ -278,7 +289,12 @@ img { display: block; } .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; } +.range-row output { min-width: 82px; text-align: right; color: var(--text-2); font-size: 13px; } +.check { + display: flex; align-items: center; gap: var(--s-snug); + padding: var(--s-snug) 0; font-size: 14px; color: var(--text-2); cursor: pointer; +} +.check input { accent-color: var(--accent); width: 17px; height: 17px; } /* ---- Zustände ------------------------------------------------------------ */ #content { padding-bottom: var(--s-section); } @@ -444,14 +460,22 @@ const PALETTE = [ const P = { accent: localStorage.getItem('flixy.accent') || '#C04AC9', - nav: localStorage.getItem('flixy.nav') || 'top', // 'top' | 'side' + nav: localStorage.getItem('flixy.nav') || 'top', // 'top' | 'side' cardW: +(localStorage.getItem('flixy.cardW') || 150), + // Startseite — dieselben Stellschrauben wie in der App. + heroSource: localStorage.getItem('flixy.heroSource') || 'continueFirst', + heroCount: +(localStorage.getItem('flixy.heroCount') || 5), + heroHeight: +(localStorage.getItem('flixy.heroHeight') || 82), + combineContinue: localStorage.getItem('flixy.combineContinue') !== 'false', + hideWatched: localStorage.getItem('flixy.hideWatched') === 'true', + rowLayout: localStorage.getItem('flixy.rowLayout') || 'poster', // 'poster' | 'wide' }; function applyPrefs() { const r = document.documentElement.style; r.setProperty('--accent', P.accent); r.setProperty('--card-w', P.cardW + 'px'); + r.setProperty('--hero-h', P.heroHeight + 'vh'); 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'); @@ -498,6 +522,24 @@ function buildIsland() { isl.appendChild(d); }; + // Der Umschalter zuerst, wie auf dem iPad: an der Seite oben in der Ecke, + // oben ganz links vor den Einträgen. + const nav = document.createElement('button'); + nav.className = 'toggle-nav'; + nav.title = P.nav === 'side' ? 'Leiste nach oben' : 'Leiste an die Seite'; + nav.setAttribute('aria-label', nav.title); + nav.innerHTML = P.nav === 'side' + // Rechteck mit Balken oben — die Leiste zieht nach oben. + ? '' + // Rechteck mit Spalte links — die Leiste zieht an die Seite. + : ''; + nav.onclick = () => { + setPref('nav', P.nav === 'side' ? 'top' : 'side'); + buildIsland(); + go(current); + }; + isl.appendChild(nav); + // Die Marke steht nur in der Spalte — oben nähme sie Platz weg, den die // Einträge brauchen. bit('brand', 'Flixy'); @@ -540,12 +582,33 @@ function renderSettings() {
-

Navigation

-

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

-
- - +

Aufmacher

+

Woher die Titel oben kommen. Die Auswahl wechselt alle sechs Stunden; + Titel ohne Hintergrundbild ziehen dabei ein schlechteres Los.

+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Reihen

+

Wie die Startseite darunter aufgebaut ist.

+ + +
+ +
@@ -574,10 +637,32 @@ function 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(); }; - }); + // Segmentierte Schalter, die alle gleich funktionieren. + const segment = (id, key, after) => { + [...el(id).children].forEach((b) => { + b.classList.toggle('on', b.dataset.v === String(P[key])); + b.onclick = () => { setPref(key, b.dataset.v); renderSettings(); after?.(); }; + }); + }; + segment('set-herosource', 'heroSource'); + segment('set-rowlayout', 'rowLayout'); + + // Regler, die alle gleich funktionieren. + const slider = (id, key, unit) => { + const r = el(id), o = el(id + '-out'); + r.value = P[key]; o.textContent = P[key] + unit; + r.oninput = () => { o.textContent = r.value + unit; setPref(key, +r.value); }; + }; + slider('set-herocount', 'heroCount', ' Titel'); + slider('set-heroheight', 'heroHeight', ' % Höhe'); + + const check = (id, key) => { + const c = el(id); + c.checked = P[key]; + c.onchange = () => setPref(key, c.checked); + }; + check('set-combine', 'combineContinue'); + check('set-hidewatched', 'hideWatched'); const z = el('set-zoom'), zo = el('set-zoom-out'); z.value = P.cardW; zo.textContent = P.cardW + ' px'; @@ -604,13 +689,33 @@ async function renderHome() { ...views.map((v) => get(`/Users/${S.userId}/Items/Latest`, { ParentId: v.Id })), ]); - const rows = [ - { title: 'Weiterschauen', items: resume, wide: true }, - { title: 'Als Nächstes', items: next, wide: true }, - ...views.map((v, i) => ({ title: `Neu in ${v.Name}`, items: latest[i] || [] })), - ].filter((r) => r.items.length); + // „Weiterschauen" und „Als Nächstes" sind zwei Fragen — oder eine, je + // nachdem, wie man es eingestellt hat. + let rows; + if (P.combineContinue) { + const seen = new Set(); + const merged = [...resume, ...next].filter((i) => !seen.has(i.Id) && seen.add(i.Id)); + rows = [{ title: 'Weiterschauen', items: merged, wide: true }]; + } else { + rows = [ + { title: 'Weiterschauen', items: resume, wide: true }, + { title: 'Als Nächstes', items: next, wide: true }, + ]; + } - const featured = pickFeatured([...resume, ...next, ...latest.flat()]); + rows.push(...views.map((v, i) => ({ + title: `Neu in ${v.Name}`, + // Gesehenes ausblenden gilt nur für Neuzugänge: in „Weiterschauen" wäre + // es sinnlos, dort ist alles angefangen. + items: (latest[i] || []).filter((it) => !P.hideWatched || !it.UserData?.Played), + wide: P.rowLayout === 'wide', + }))); + rows = rows.filter((r) => r.items.length); + + const heroPool = P.heroSource === 'latestOnly' + ? latest.flat() + : [...resume, ...next, ...latest.flat()]; + const featured = pickFeatured(heroPool); c.innerHTML = ''; if (featured.length) c.appendChild(buildHero(featured)); @@ -642,7 +747,7 @@ function pickFeatured(items) { // es wenige, hat er seine Chance. .map((it) => ({ it, key: rnd() * (hasImg(it, 'Backdrop') ? 1 : 0.2) })) .sort((a, b) => b.key - a.key) - .slice(0, 5) + .slice(0, P.heroCount) .map((x) => x.it); }