Files
Flaro/web/frontend/index.html
T
ScarriffleandClaude Opus 5 80292d3351 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>
2026-09-14 16:36:43 +02:00

980 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Flixy</title>
<style>
/* ============================================================================
Flixy Web — ein Frontend für Jellyfin
----------------------------------------------------------------------------
Die Werte stammen aus FlixyDesign, nicht aus dem Augenmaß: dieselben
Flächen, dieselben Radien, dieselben Abstände wie in der App.
========================================================================= */
:root {
--accent: #C04AC9; /* Orchid — wie auf dem iPad eingestellt */
--canvas: #0B0B0D;
--surface: #141417;
--elevated: #1D1D21;
--placeholder: #26262B;
--text: #F5F5F7;
--text-2: #A0A0A8;
--text-3: #6E6E76;
--separator: rgba(255,255,255,.10);
--r-card: 8px;
--r-control: 10px;
--r-sheet: 16px;
--r-panel: 22px;
--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;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
margin: 0; padding: 0; height: 100%;
background: var(--canvas); color: var(--text);
font-family: var(--font); font-size: 15px; line-height: 1.4;
-webkit-font-smoothing: antialiased;
}
body { overflow-x: hidden; }
button { font-family: inherit; cursor: pointer; border: none; }
img { display: block; }
.hidden { display: none !important; }
/* ---- Anmeldung ----------------------------------------------------------- */
#login {
min-height: 100dvh; display: grid; place-items: center; padding: var(--s-loose);
}
.login-card {
width: min(400px, 100%); display: flex; flex-direction: column; gap: var(--s-regular);
}
.login-card h1 {
font-size: 34px; font-weight: 700; margin: 0 0 var(--s-tight);
letter-spacing: -.02em; text-align: center;
}
.login-card .sub {
color: var(--text-2); text-align: center; margin: 0 0 var(--s-section);
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 13px; color: var(--text-2); }
.field input {
background: var(--elevated); border: 1px solid var(--separator);
border-radius: var(--r-control); color: var(--text);
padding: 11px var(--s-regular); font-size: 15px; font-family: inherit;
}
.field input:focus { outline: none; border-color: var(--accent); }
.btn {
border-radius: 999px; padding: 12px var(--s-section); font-size: 15px;
font-weight: 600; background: var(--elevated); color: var(--text);
transition: filter .15s ease;
}
.btn:hover { filter: brightness(1.25); }
.btn-primary { background: var(--accent); color: #fff; }
.btn-row { display: flex; gap: var(--s-regular); flex-wrap: wrap; }
.error {
color: #F08A8D; font-size: 13px; min-height: 18px; text-align: center;
}
.note { color: var(--text-3); font-size: 12px; text-align: center; margin-top: var(--s-snug); }
/* ---- Die schwebende Navigationsinsel -------------------------------------
Das Merkmal der App: eine Fläche mit Abstand ringsum, hinter der der
Inhalt durchläuft. Sie blendet beim Scrollen aus und kommt zurück, sobald
man wieder oben steht — genau wie in der App. */
#chrome {
position: fixed; top: calc(10px + env(safe-area-inset-top)); left: 0; right: 0;
z-index: 50; display: flex; justify-content: center;
transition: transform .28s cubic-bezier(.32,.72,0,1), opacity .28s ease;
pointer-events: none;
}
#chrome.away { transform: translateY(-140%); opacity: 0; }
.island {
pointer-events: auto;
display: flex; align-items: center; gap: var(--s-tight);
padding: var(--s-snug);
background: rgba(20,20,23,.72);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border: 1px solid var(--separator);
border-radius: var(--r-panel);
box-shadow: 0 8px 24px rgba(0,0,0,.45);
max-width: calc(100vw - 24px); overflow-x: auto; scrollbar-width: none;
}
.island::-webkit-scrollbar { display: none; }
.island button {
background: none; color: var(--text); font-size: 15px; white-space: nowrap;
padding: var(--s-snug) var(--s-loose); border-radius: 999px;
}
.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: var(--hero-h, 82vh); min-height: 380px; overflow: hidden;
}
#hero .shot {
position: absolute; inset: 0; background-size: cover; background-position: center;
opacity: 0; transition: opacity .55s ease;
}
#hero .shot.on { opacity: 1; }
#hero .veil {
position: absolute; inset: 0;
background:
linear-gradient(to bottom, rgba(0,0,0,.45) 0, rgba(0,0,0,0) 150px),
linear-gradient(to bottom, rgba(11,11,13,0) 45%, rgba(11,11,13,.75) 78%, var(--canvas) 100%);
}
#hero .body {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 0 var(--s-margin) var(--s-section);
display: flex; flex-direction: column; gap: var(--s-snug);
}
#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 {
color: var(--text-2); font-size: 13px; max-width: 640px;
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); }
.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);
padding: 0; transition: width .25s ease, background .25s ease;
}
.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); }
.row h3 {
font-size: 17px; font-weight: 600; margin: 0 0 var(--s-regular);
padding: 0 var(--s-margin); letter-spacing: -.01em;
}
.strip {
display: flex; gap: var(--s-regular); overflow-x: auto;
padding: 0 var(--s-margin) var(--s-tight); scroll-snap-type: x proximity;
scrollbar-width: none;
}
.strip::-webkit-scrollbar { display: none; }
.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;
position: relative; overflow: hidden;
}
.card.wide .art { aspect-ratio: 16/9; }
.card .title {
font-size: 13px; color: var(--text); margin-top: 6px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.card .sub { font-size: 12px; color: var(--text-3); }
.card .bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(255,255,255,.2); }
.card .bar span { display: block; height: 100%; background: var(--accent); }
/* ---- Raster (Mediathek) -------------------------------------------------- */
.grid {
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; }
/* ---- Detailblatt --------------------------------------------------------- */
#sheet {
position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,.55);
display: grid; place-items: center; padding: var(--s-loose);
backdrop-filter: blur(8px);
}
#sheet .panel {
background: var(--elevated); border: 1px solid var(--separator);
border-radius: var(--r-sheet); width: min(680px, 100%);
max-height: 86dvh; overflow: auto; padding: var(--s-section);
}
#sheet h2 { margin: 0 0 var(--s-snug); font-size: 24px; letter-spacing: -.02em; }
#sheet .meta { color: var(--text-2); font-size: 13px; margin-bottom: var(--s-regular); }
#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: 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); }
.pad-top { padding-top: calc(84px + env(safe-area-inset-top)); }
.spinner {
width: 26px; height: 26px; border: 2px solid var(--separator);
border-top-color: var(--accent); border-radius: 999px;
animation: spin .8s linear infinite; margin: 60px auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty { text-align: center; color: var(--text-2); padding: 60px var(--s-margin); }
@media (max-width: 600px) {
#hero { height: 70vh; }
#hero h2 { font-size: 26px; }
.card { width: 120px; }
.card.wide { width: 200px; }
}
</style>
</head>
<body>
<!-- ---------- Anmeldung ---------- -->
<section id="login">
<form class="login-card" id="login-form">
<h1>Flixy</h1>
<p class="sub">Deine Mediathek, schöner.</p>
<div class="field">
<label for="server">Serveradresse</label>
<input id="server" type="url" placeholder="https://jellyfin.example.com" required>
</div>
<div class="field">
<label for="user">Benutzer</label>
<input id="user" type="text" autocomplete="username" required>
</div>
<div class="field">
<label for="pass">Passwort</label>
<input id="pass" type="password" autocomplete="current-password">
</div>
<p class="error" id="login-error"></p>
<button class="btn btn-primary" type="submit" id="login-btn">Anmelden</button>
<p class="note">
Adresse und Zugangsschlüssel bleiben in diesem Browser (localStorage).
Das Passwort geht nur an deinen eigenen Server und wird nirgends gespeichert.
</p>
</form>
</section>
<!-- ---------- App ---------- -->
<div id="app" class="hidden">
<nav id="chrome"><div class="island" id="island"></div></nav>
<main id="content"></main>
</div>
<script>
/* ============================================================================
Der Server-Draht
----------------------------------------------------------------------------
Die Endpunkte sind dieselben, die die App benutzt — aus JellyfinKit
übernommen, nicht geraten. Die Authorization-Kopfzeile braucht ihre
Anführungszeichen auch um Werte ohne Leerzeichen; ohne sie zerlegt
Jellyfin die Zeile falsch und meldet, man sei nicht angemeldet.
========================================================================= */
const S = {
base: localStorage.getItem('flixy.server') || '',
token: localStorage.getItem('flixy.token') || '',
userId: localStorage.getItem('flixy.user') || '',
deviceId: localStorage.getItem('flixy.device') || (() => {
const id = 'flixy-web-' + Math.random().toString(36).slice(2, 12);
localStorage.setItem('flixy.device', id); return id;
})(),
};
function authHeader() {
const f = [
'Client="Flixy Web"', 'Device="Browser"',
`DeviceId="${S.deviceId}"`, 'Version="0.1"',
];
if (S.token) f.push(`Token="${S.token}"`);
return 'MediaBrowser ' + f.join(', ');
}
async function api(path, opts = {}) {
const res = await fetch(S.base + path, {
...opts,
headers: {
'Authorization': authHeader(),
'Accept': 'application/json',
...(opts.body ? { 'Content-Type': 'application/json' } : {}),
...opts.headers,
},
});
if (!res.ok) throw new Error(res.status === 401 ? 'Nicht angemeldet' : `HTTP ${res.status}`);
return res.status === 204 ? null : res.json();
}
const img = (id, kind, tag, w) =>
`${S.base}/Items/${id}/Images/${kind}?${tag ? 'tag=' + tag + '&' : ''}maxWidth=${w || 400}&quality=90`;
const hasImg = (it, kind) => kind === 'Backdrop'
? (it.BackdropImageTags?.length || it.ParentBackdropImageTags?.length)
: it.ImageTags?.[kind];
/* Das Bild eines Eintrags — mit den Ersatzwegen, die Jellyfin nötig macht:
eine Folge hat selten ein eigenes Hintergrundbild, aber ihre Serie hat eins. */
function artFor(it, kind, w) {
if (kind === 'Backdrop') {
if (it.BackdropImageTags?.length) return img(it.Id, 'Backdrop', it.BackdropImageTags[0], w);
if (it.ParentBackdropItemId) return img(it.ParentBackdropItemId, 'Backdrop', it.ParentBackdropImageTags?.[0], w);
}
if (it.ImageTags?.Primary) return img(it.Id, 'Primary', it.ImageTags.Primary, w);
if (it.SeriesPrimaryImageTag && it.SeriesId) return img(it.SeriesId, 'Primary', it.SeriesPrimaryImageTag, w);
return '';
}
const FIELDS = 'Overview,Genres,MediaSources,ParentBackdropItemId,ParentBackdropImageTags';
/* ---------- Anmeldung ---------- */
const el = (id) => document.getElementById(id);
el('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = el('login-btn'), err = el('login-error');
btn.disabled = true; btn.textContent = 'Verbinde …'; err.textContent = '';
try {
S.base = el('server').value.trim().replace(/\/+$/, '');
S.token = '';
const r = await api('/Users/AuthenticateByName', {
method: 'POST',
body: JSON.stringify({ Username: el('user').value, Pw: el('pass').value }),
});
S.token = r.AccessToken; S.userId = r.User.Id;
localStorage.setItem('flixy.server', S.base);
localStorage.setItem('flixy.token', S.token);
localStorage.setItem('flixy.user', S.userId);
start();
} catch (e2) {
err.textContent = /Failed to fetch/.test(e2.message)
? 'Server nicht erreichbar — Adresse prüfen (und ob sie per https läuft).'
: 'Anmeldung fehlgeschlagen: ' + e2.message;
} finally {
btn.disabled = false; btn.textContent = 'Anmelden';
}
});
function signOut() {
['flixy.server', 'flixy.token', 'flixy.user'].forEach((k) => localStorage.removeItem(k));
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),
// 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');
}
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 = '<div class="spinner"></div>';
try {
const r = await api(`/Users/${S.userId}/Views`);
views = (r.Items || []).filter((v) => ['movies', 'tvshows'].includes(v.CollectionType));
buildIsland();
go('home');
} catch (e) {
el('content').innerHTML = `<div class="empty pad-top">${e.message}<br><br>
<button class="btn" onclick="signOut()">Abmelden</button></div>`;
}
}
function buildIsland() {
const isl = el('island');
isl.innerHTML = '';
const add = (label, key) => {
const b = document.createElement('button');
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);
};
// 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');
add('Start', 'home');
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');
}
function go(key) {
if (key === 'out') return signOut();
current = key;
[...el('island').querySelectorAll('button')].forEach((b) =>
b.classList.toggle('active', b.dataset.key === key));
window.scrollTo({ top: 0 });
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 = `
<div class="settings">
<h2>Einstellungen</h2>
<div class="setting">
<h4>Akzentfarbe</h4>
<p>Dieselbe Palette wie in der App.</p>
<div class="swatches" id="set-accent"></div>
</div>
<div class="setting">
<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>
<div class="setting">
<h4>Kachelgröße</h4>
<p>Wie groß Plakate und Vorschaubilder erscheinen.</p>
<div class="range-row">
<input type="range" id="set-zoom" min="100" max="280" step="10">
<output id="set-zoom-out"></output>
</div>
</div>
<div class="setting">
<h4>Server</h4>
<p id="set-server"></p>
<button class="btn" onclick="signOut()">Abmelden</button>
</div>
</div>`;
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);
});
// 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';
z.oninput = () => { zo.textContent = z.value + ' px'; setPref('cardW', +z.value); };
el('set-server').textContent = S.base.replace(/^https?:\/\//, '');
}
/* ---------- Startseite ---------- */
async function renderHome() {
const c = el('content');
c.innerHTML = '<div class="spinner"></div>';
const get = async (path, q) => {
try {
const r = await api(path + '?' + new URLSearchParams({ Fields: FIELDS, Limit: 20, ...q }));
return r.Items || r || [];
} catch { return []; }
};
const [resume, next, ...latest] = await Promise.all([
get(`/Users/${S.userId}/Items/Resume`, { MediaTypes: 'Video' }),
get('/Shows/NextUp', { userId: S.userId }),
...views.map((v) => get(`/Users/${S.userId}/Items/Latest`, { ParentId: v.Id })),
]);
// „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 },
];
}
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));
else c.classList.add('pad-top');
rows.forEach((r) => c.appendChild(buildRow(r)));
if (!rows.length && !featured.length) {
c.innerHTML = '<div class="empty pad-top">Hier ist noch nichts.</div>';
}
}
/* Wählt aus, was oben groß steht — dieselbe Regel wie in der App.
----------------------------------------------------------------------------
Zwei Dinge auf einmal: die Auswahl wechselt alle sechs Stunden, und ein
Titel ohne Hintergrundbild zieht ein schlechteres Los. Bei jedem Laden zu
würfeln wäre unruhig — man tippt auf etwas, geht zurück, und es ist weg;
einmal täglich wäre zu träge. */
function pickFeatured(items) {
// Ein Startwert, der sich alle sechs Stunden ändert. Derselbe für alle
// Aufrufe im selben Fenster, damit die Auswahl beim Zurückblättern steht.
let seed = Math.floor(Date.now() / (6 * 3600 * 1000)) * 2654435761 % 2147483647;
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const seen = new Set();
return items
.filter((it) => !seen.has(it.Id) && seen.add(it.Id))
// Wer ein Hintergrundbild hat, zieht aus [0,1) — wer keines hat, nur aus
// [0,0.2). Solange es genug mit Bild gibt, kommt er nicht nach vorn; gibt
// 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, P.heroCount)
.map((x) => x.it);
}
/* Der Aufmacher wechselt von selbst, wie in der App — übergeblendet, nicht
durchgeblättert, damit keine Naht wandert. */
function buildHero(items) {
const wrap = document.createElement('section');
wrap.id = 'hero';
const shots = items.map((it, i) => {
const d = document.createElement('div');
d.className = 'shot' + (i === 0 ? ' on' : '');
d.style.backgroundImage = `url("${artFor(it, 'Backdrop', 1600)}")`;
return d;
});
shots.forEach((s) => wrap.appendChild(s));
wrap.insertAdjacentHTML('beforeend', '<div class="veil"></div>');
const body = document.createElement('div');
body.className = 'body';
wrap.appendChild(body);
const dots = document.createElement('div');
dots.className = 'dots';
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, 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);
const logo = it.ImageTags?.Logo ? img(it.Id, 'Logo', it.ImageTags.Logo, 600) : '';
body.innerHTML = `
${logo ? `<img class="logo" src="${logo}" alt="${esc(it.Name)}">`
: `<h2>${esc(it.Name)}</h2>`}
<div class="meta">${bits.join(' · ')}</div>
<div class="summary">${esc(it.Overview || '')}</div>
<div class="actions">
<button class="btn btn-primary">▶ Abspielen</button>
<button class="btn">Details</button>
</div>`;
body.querySelector('.btn-primary').onclick = () => openSheet(it, true);
body.querySelectorAll('.btn')[1].onclick = () => openSheet(it, false);
shots.forEach((s, i) => s.classList.toggle('on', i === at));
[...dots.children].forEach((d, i) => d.classList.toggle('on', i === at));
};
paint();
if (items.length > 1) {
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
}
const outer = document.createElement('div');
outer.appendChild(wrap); outer.appendChild(dots);
return outer;
}
function buildRow({ title, items, wide }) {
const sec = document.createElement('section');
sec.className = 'row';
sec.innerHTML = `<h3>${esc(title)}</h3><div class="strip"></div>`;
const strip = sec.querySelector('.strip');
items.forEach((it) => strip.appendChild(buildCard(it, wide)));
return sec;
}
function buildCard(it, wide) {
const b = document.createElement('button');
b.className = 'card' + (wide ? ' wide' : '');
const pct = it.UserData?.PlayedPercentage;
const art = artFor(it, wide ? 'Backdrop' : 'Primary', wide ? 500 : 300);
b.innerHTML = `
<div class="art" ${art ? `style="background-image:url('${art}')"` : ''}>
${pct ? `<div class="bar"><span style="width:${Math.min(pct, 100)}%"></span></div>` : ''}
</div>
<div class="title">${esc(it.SeriesName || it.Name)}</div>
<div class="sub">${esc(cardSub(it))}</div>`;
b.onclick = () => openSheet(it, false);
return b;
}
const cardSub = (it) =>
it.Type === 'Episode' && it.ParentIndexNumber != null
? `S${it.ParentIndexNumber}:E${it.IndexNumber} · ${it.Name}`
: (it.ProductionYear || '');
/* ---------- Mediathek ---------- */
async function renderLibrary(id) {
const c = el('content');
c.classList.add('pad-top');
c.innerHTML = '<div class="spinner"></div>';
try {
const r = await api(`/Users/${S.userId}/Items?` + new URLSearchParams({
ParentId: id, Recursive: 'true', IncludeItemTypes: 'Movie,Series',
SortBy: 'SortName', SortOrder: 'Ascending', Limit: 200, Fields: FIELDS,
}));
const items = r.Items || [];
c.innerHTML = items.length ? '<div class="grid"></div>' : '<div class="empty">Leer.</div>';
const grid = c.querySelector('.grid');
if (grid) items.forEach((it) => grid.appendChild(buildCard(it, false)));
} catch (e) {
c.innerHTML = `<div class="empty">${esc(e.message)}</div>`;
}
}
/* ---------- Detail und Wiedergabe ---------- */
function openSheet(item, playNow) {
const back = document.createElement('div');
back.id = 'sheet';
const bits = [item.ProductionYear, runtime(item), item.OfficialRating].filter(Boolean);
back.innerHTML = `
<div class="panel">
<h2>${esc(item.SeriesName ? item.SeriesName + ' — ' + item.Name : item.Name)}</h2>
<div class="meta">${bits.join(' · ')}</div>
<p>${esc(item.Overview || '')}</p>
<div class="btn-row">
<button class="btn btn-primary" id="sheet-play">▶ Abspielen</button>
<button class="btn" id="sheet-close">Schließen</button>
</div>
</div>`;
back.onclick = (e) => { if (e.target === back) back.remove(); };
document.body.appendChild(back);
back.querySelector('#sheet-close').onclick = () => back.remove();
back.querySelector('#sheet-play').onclick = () => play(item, back.querySelector('.panel'));
if (playNow) play(item, back.querySelector('.panel'));
}
/* HLS, weil ein Live-Remux zuverlässiger ist als die Hoffnung, dass der
Browser den Container auf der Platte versteht. Safari kann HLS von Haus
aus; alle anderen bekommen hls.js. */
function play(item, panel) {
panel.querySelector('video')?.remove();
const v = document.createElement('video');
v.controls = true; v.autoplay = true; v.playsInline = true;
panel.appendChild(v);
const url = `${S.base}/Videos/${item.Id}/master.m3u8?` + new URLSearchParams({
MediaSourceId: item.Id, api_key: S.token, VideoCodec: 'h264',
AudioCodec: 'aac,mp3', TranscodingMaxAudioChannels: '2',
SegmentContainer: 'ts', MinSegments: '1', BreakOnNonKeyFrames: 'true',
});
if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; return; }
const boot = () => { const h = new Hls(); h.loadSource(url); h.attachMedia(v); };
if (window.Hls) return boot();
const s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.13/hls.min.js';
s.onload = boot;
s.onerror = () => { v.replaceWith(Object.assign(document.createElement('p'),
{ textContent: 'Wiedergabe braucht hls.js — und das ließ sich nicht laden.' })); };
document.head.appendChild(s);
}
/* ---------- Kleinkram ---------- */
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function runtime(it) {
if (!it.RunTimeTicks) return '';
const min = Math.round(it.RunTimeTicks / 600000000);
return min >= 60 ? `${Math.floor(min / 60)} Std ${min % 60} Min` : `${min} Min`;
}
/* 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 });
/* ---------- Los ---------- */
if (S.base && S.token && S.userId) start();
else {
el('server').value = S.base;
el('login').classList.remove('hidden');
}
</script>
</body>
</html>