Der Umschalter gehört in die Leiste, nicht in die Einstellungen

Wie auf dem iPad: ein Knopf in der Leiste selbst — an der Seite oben in
der Ecke, oben ganz links. Ihn in die Einstellungen zu verbannen hieß,
für einen Handgriff zwei Ebenen tief zu gehen.

Dazu die Einstellungen, die bisher aus vier Zeilen bestanden: Aufmacher
(Quelle, Anzahl, Höhe), Reihen (Weiterschauen zusammenlegen, Gesehenes
ausblenden, Plakate oder Querformat), Kachelgröße, Akzentfarbe, Server.
Sie wirken auch — die Startseite baut sich danach auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 16:36:43 +02:00
co-authored by Claude Opus 5
parent c3f9afdb07
commit 80292d3351
+125 -20
View File
@@ -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.
? '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="2" y="3.5" width="16" height="13" rx="2.5"/><path d="M2 8h16" /><rect x="2" y="3.5" width="16" height="4.5" rx="2.5" fill="currentColor" stroke="none" opacity=".9"/></svg>'
// Rechteck mit Spalte links — die Leiste zieht an die Seite.
: '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="2" y="3.5" width="16" height="13" rx="2.5"/><path d="M7.5 3.5v13"/><rect x="2" y="3.5" width="5.5" height="13" rx="2.5" fill="currentColor" stroke="none" opacity=".9"/></svg>';
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() {
</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>
<h4>Aufmacher</h4>
<p>Woher die Titel oben kommen. Die Auswahl wechselt alle sechs Stunden;
Titel ohne Hintergrundbild ziehen dabei ein schlechteres Los.</p>
<div class="seg" id="set-herosource">
<button data-v="continueFirst">Angefangenes zuerst</button>
<button data-v="latestOnly">Nur Neuzugänge</button>
</div>
<div class="range-row" style="margin-top:var(--s-regular)">
<input type="range" id="set-herocount" min="1" max="10" step="1">
<output id="set-herocount-out"></output>
</div>
<div class="range-row">
<input type="range" id="set-heroheight" min="40" max="95" step="5">
<output id="set-heroheight-out"></output>
</div>
</div>
<div class="setting">
<h4>Reihen</h4>
<p>Wie die Startseite darunter aufgebaut ist.</p>
<label class="check"><input type="checkbox" id="set-combine">
„Weiterschauen“ und „Als Nächstes“ zusammenlegen</label>
<label class="check"><input type="checkbox" id="set-hidewatched">
Gesehenes aus den Neuzugängen ausblenden</label>
<div class="seg" id="set-rowlayout" style="margin-top:var(--s-regular)">
<button data-v="poster">Plakate</button>
<button data-v="wide">Querformat</button>
</div>
</div>
@@ -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);
}