diff --git a/web/frontend/index.html b/web/frontend/index.html index c79d954..e8f6bcb 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -292,6 +292,20 @@ img { 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); } +.settings .group { + font-size: 13px; font-weight: 600; color: var(--text-3); + text-transform: uppercase; letter-spacing: .06em; + margin: var(--s-section) 0 var(--s-snug); padding-left: var(--s-tight); +} +.settings .group:first-of-type { margin-top: 0; } +.hexrow { display: flex; gap: var(--s-snug); margin-top: var(--s-regular); } +.hexrow input { + flex: 1; background: var(--elevated); border: 1px solid var(--separator); + border-radius: var(--r-control); color: var(--text); padding: 9px var(--s-regular); + font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 14px; +} +.hexrow input:focus { outline: none; border-color: var(--accent); } +.hint { font-size: 12px; color: var(--text-3); margin: var(--s-snug) 0 0; min-height: 16px; } .setting { background: var(--surface); border: 1px solid var(--separator); border-radius: var(--r-sheet); padding: var(--s-loose); margin-bottom: var(--s-regular); @@ -503,7 +517,6 @@ const P = { 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() { @@ -644,6 +657,83 @@ function go(key) { } /* ---------- Einstellungen ---------- */ +/* Kontrastrechnung nach WCAG 2.1 — dieselbe wie in der App. + ---------------------------------------------------------------------------- + Flixy lässt jede Farbe zu, aber ein dunkles Marineblau auf fast schwarzem + Grund ist als Schrift unlesbar. Solche Farben werden angehoben, bis sie den + geforderten Kontrast erreichen — und es wird gesagt, statt die Farbe + stillschweigend zu verändern. + + Zuerst die Helligkeit, weil das den Farbton erhält: eine gewählte + Akzentfarbe soll erkennbar dieselbe bleiben. Reicht selbst volle Helligkeit + nicht, wird die Sättigung zurückgenommen, und die Farbe wandert Richtung + Weiß. */ +const Contrast = { + minimumForText: 4.5, + + luminance([r, g, b]) { + const lin = (c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); + }, + + ratio(a, b) { + const la = Contrast.luminance(a), lb = Contrast.luminance(b); + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); + }, + + /// Hebt die Farbe gerade so weit an, dass sie auf dem Grund lesbar wird. + legible(rgb, background) { + if (Contrast.ratio(rgb, background) >= Contrast.minimumForText) return { rgb, changed: false }; + + let [h, sat, v] = rgbToHsv(rgb); + // Beide Schritte sind monoton, deshalb genügt eine binäre Suche. + const find = (lo, hi, build) => { + if (Contrast.ratio(build(hi), background) < Contrast.minimumForText) return null; + for (let i = 0; i < 24; i++) { + const mid = (lo + hi) / 2; + if (Contrast.ratio(build(mid), background) >= Contrast.minimumForText) hi = mid; else lo = mid; + } + return build(hi); + }; + + const brighter = find(v, 1, (x) => hsvToRgb(h, sat, x)); + if (brighter) return { rgb: brighter, changed: true }; + + const paler = find(sat, 0, (x) => hsvToRgb(h, x, 1)); + return { rgb: paler || [1, 1, 1], changed: true }; + }, +}; + +function rgbToHsv([r, g, b]) { + const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min; + let h = 0; + if (d) { + if (max === r) h = ((g - b) / d) % 6; + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h /= 6; if (h < 0) h += 1; + } + return [h, max ? d / max : 0, max]; +} + +function hsvToRgb(h, s, v) { + const i = Math.floor(h * 6), f = h * 6 - i; + const p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + const sets = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]]; + return sets[i % 6]; +} + +const hexToRgb = (hex) => { + const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); + if (!m) return null; + const n = parseInt(m[1], 16); + return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255]; +}; + +const rgbToHex = ([r, g, b]) => + '#' + [r, g, b].map((c) => Math.round(Math.min(Math.max(c, 0), 1) * 255) + .toString(16).padStart(2, '0')).join('').toUpperCase(); + function renderSettings() { const c = el('content'); c.classList.add('pad-top'); @@ -651,12 +741,31 @@ function renderSettings() {

Einstellungen

+

Darstellung

+

Akzentfarbe

Dieselbe Palette wie in der App.

+
+ + +
+

+
+

Kachelgröße

+

Wie groß Plakate und Vorschaubilder erscheinen — auf der Startseite + und in den Mediatheken. Gilt nur auf diesem Gerät.

+
+ + +
+
+ +

Startseite

+

Aufmacher

Woher die Titel oben kommen. Die Auswahl wechselt alle sechs Stunden; @@ -682,23 +791,12 @@ function renderSettings() { „Weiterschauen“ und „Als Nächstes“ zusammenlegen -

- - -
-
-

Kachelgröße

-

Wie groß Plakate und Vorschaubilder erscheinen.

-
- - -
-
+

Server

-

Server

+

Verbindung

@@ -708,7 +806,7 @@ function renderSettings() { 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.style.background = hex; b.title = name; b.dataset.hex = hex; b.onclick = () => { setPref('accent', hex); renderSettings(); }; sw.appendChild(b); }); @@ -721,7 +819,6 @@ function renderSettings() { }); }; segment('set-herosource', 'heroSource'); - segment('set-rowlayout', 'rowLayout'); // Regler, die alle gleich funktionieren. const slider = (id, key, unit) => { @@ -744,6 +841,26 @@ function renderSettings() { z.value = P.cardW; zo.textContent = P.cardW + ' px'; z.oninput = () => { zo.textContent = z.value + ' px'; setPref('cardW', +z.value); }; + // Eigene Farbe, mit derselben Prüfung wie in der App. + const hex = el('set-hex'), note = el('set-hex-note'); + hex.value = P.accent; + const applyHex = () => { + const rgb = hexToRgb(hex.value); + if (!rgb) { note.textContent = 'Das ist kein Farbwert — erwartet wird etwa #C04AC9.'; return; } + const { rgb: fixed, changed } = Contrast.legible(rgb, hexToRgb('#0B0B0D')); + const value = rgbToHex(fixed); + setPref('accent', value); + note.textContent = changed + ? `Auf ${value} aufgehellt — die gewählte Farbe wäre auf dem dunklen Grund nicht lesbar gewesen.` + : ''; + hex.value = value; + // Die Kachel der Palette verliert ihre Markierung, sobald die Farbe nicht + // mehr aus ihr stammt. + [...el('set-accent').children].forEach((b) => b.classList.toggle('on', b.dataset.hex === value)); + }; + el('set-hex-apply').onclick = applyHex; + hex.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyHex(); } }; + el('set-server').textContent = S.base.replace(/^https?:\/\//, ''); } @@ -784,7 +901,10 @@ async function renderHome() { // 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', + // Plakate, nicht Querformat — und das ist keine Einstellung, sondern + // dieselbe Entscheidung wie in der App: Angefangenes erkennt man am + // Szenenbild wieder, Neues sucht man nach Plakaten aus. + wide: false, }))); rows = rows.filter((r) => r.items.length);