Ein reiner Weg von A nach B sah aus, als würde ein Kasten verschoben. Jetzt zwei Züge: die Leiste zieht sich erst in sich zusammen und zieht dann erst um — wie ein Gummiband, das sich sammelt, bevor es schnellt. Welche Richtung sich zuerst sammelt, hängt davon ab, wohin es geht. Wer nach oben zieht, wird flach, also klappt zuerst die Höhe zusammen; wer an die Seite zieht, wird schmal, also zuerst die Breite. Die Zwischenstation steht deshalb noch am alten Ort, aber schon im Zielmaß der einen Seite. Nachgemessen: bei 220 ms ist die Breite von 2.27 auf 1.38 gefallen und die Position unverändert — sie sammelt sich, ohne zu wandern. Bei 380 ms ist sie unterwegs und zieht sich auf. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1199 lines
48 KiB
HTML
1199 lines
48 KiB
HTML
<!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); }
|
||
|
||
/* ---- Der Umzug der Leiste -----------------------------------------------
|
||
Sie **zieht um**, sie verschwindet nicht und taucht woanders wieder auf.
|
||
Das ist der Unterschied zwischen einer Leiste, die man mit den Augen
|
||
verfolgt, und zweien, von denen eine geht und eine kommt — und es ist das,
|
||
was Mac und iPad tun.
|
||
|
||
Gemacht wird das mit FLIP: die alte Lage messen, umbauen, die neue Lage
|
||
messen, und das Element vom alten Zustand in den neuen fahren lassen. Der
|
||
Weg dazwischen ist eine einzige Bewegung, obwohl der Aufbau in der Mitte
|
||
ausgetauscht wurde. Über CSS allein ginge das nicht — `right: auto` und
|
||
`bottom: auto` lassen sich nicht animieren, und genau die wechseln hier.
|
||
|
||
Der Inhalt rückt gleichzeitig nach; ohne das bliebe links ein Loch stehen,
|
||
bis die Leiste angekommen ist. */
|
||
#content { transition: margin-left .42s cubic-bezier(.32,.72,0,1); }
|
||
|
||
/* Wer Bewegung nicht mag, bekommt keine. */
|
||
@media (prefers-reduced-motion: reduce) {
|
||
#content, .card .art, .shot, .island { transition: none !important; }
|
||
}
|
||
#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); }
|
||
.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);
|
||
}
|
||
.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); }
|
||
|
||
/* Ein Seitenwechsel soll nicht schlagen. Kurz und flach — was länger dauert,
|
||
fühlt sich nach Warten an statt nach Bewegung. */
|
||
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||
.row, .grid, .settings, #hero { animation: rise .32s cubic-bezier(.32,.72,0,1) both; }
|
||
/* Die Reihen kommen gestaffelt — die erste zuerst, dann nach unten weg. */
|
||
.row:nth-of-type(2) { animation-delay: .04s; }
|
||
.row:nth-of-type(3) { animation-delay: .08s; }
|
||
.row:nth-of-type(4) { animation-delay: .12s; }
|
||
.row:nth-of-type(n+5) { animation-delay: .16s; }
|
||
|
||
/* Kacheln heben sich beim Überfahren leicht an. */
|
||
.card .art { transition: transform .2s cubic-bezier(.32,.72,0,1), box-shadow .2s ease; }
|
||
.card:hover .art { transform: scale(1.04); box-shadow: 0 10px 24px rgba(0,0,0,.5); }
|
||
.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',
|
||
};
|
||
|
||
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 = () => moveNavigation(P.nav === 'side' ? 'top' : 'side');
|
||
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');
|
||
}
|
||
|
||
/* Lässt die Leiste von ihrer alten Lage in die neue fahren.
|
||
----------------------------------------------------------------------------
|
||
FLIP, in vier Schritten: **F**irst — wo steht sie jetzt? **L**ast — wo steht
|
||
sie nach dem Umbau? **I**nvert — sie zurück an die alte Stelle rechnen.
|
||
**P**lay — die Rechnung auf null laufen lassen.
|
||
|
||
Auch die Größe wird mitgefahren, nicht nur die Stelle: die Insel ist oben
|
||
breit und flach, an der Seite schmal und hoch, und ein Sprung in der Form
|
||
mitten in einer sonst ruhigen Bewegung fällt mehr auf als gar keine
|
||
Bewegung. Die Beschriftungen verziehen sich dabei kurz — bei vier Zehnteln
|
||
sieht man das als Bewegung, nicht als Verzerrung. */
|
||
function moveNavigation(to) {
|
||
const chrome = el('chrome');
|
||
const before = chrome.querySelector('.island').getBoundingClientRect();
|
||
const quick = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
|
||
setPref('nav', to);
|
||
buildIsland();
|
||
|
||
if (quick) return;
|
||
|
||
const island = chrome.querySelector('.island');
|
||
const after = island.getBoundingClientRect();
|
||
if (!before.width || !after.width) return;
|
||
|
||
const dx = before.left - after.left;
|
||
const dy = before.top - after.top;
|
||
const sx = before.width / after.width;
|
||
const sy = before.height / after.height;
|
||
|
||
// **Zwei Züge, nicht einer.**
|
||
//
|
||
// Ein reiner Weg von A nach B sieht aus, als würde ein Kasten verschoben.
|
||
// Schöner ist, was die Leiste erst *in sich* zusammenziehen lässt und dann
|
||
// erst umziehen — wie ein Gummiband, das sich sammelt, bevor es schnellt.
|
||
//
|
||
// Welche Richtung sich zuerst sammelt, hängt davon ab, wohin es geht:
|
||
// Wer nach **oben** zieht, wird flach — also klappt zuerst die Höhe
|
||
// zusammen, und die Breite kommt erst beim Hinüberschieben. Wer an die
|
||
// **Seite** zieht, wird schmal — also zuerst die Breite, die Höhe danach.
|
||
//
|
||
// Die Zwischenstation steht deshalb noch an der alten Stelle, aber schon
|
||
// mit dem Zielmaß in der einen Richtung.
|
||
const gathered = to === 'top'
|
||
? `translate(${dx}px, ${dy}px) scale(${sx}, 1)` // Höhe zuerst
|
||
: `translate(${dx}px, ${dy}px) scale(1, ${sy})`; // Breite zuerst
|
||
|
||
island.animate(
|
||
[
|
||
{ transformOrigin: 'top left', transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`,
|
||
easing: 'cubic-bezier(.5,0,.75,0)', offset: 0 },
|
||
// Gesammelt — noch am alten Ort, aber schon im Zielmaß der einen Seite.
|
||
{ transformOrigin: 'top left', transform: gathered,
|
||
easing: 'cubic-bezier(.22,1,.36,1)', offset: 0.42 },
|
||
// Und hinüber. Das leichte Überschwingen am Ende ist das Gummiband.
|
||
{ transformOrigin: 'top left', transform: 'none', offset: 1 },
|
||
],
|
||
{ duration: 520 }
|
||
);
|
||
|
||
// Die Beschriftungen gegen die Stauchung aufblenden, damit aus dem
|
||
// Verziehen kein Lesen wird.
|
||
island.animate(
|
||
[{ opacity: 0.35 }, { opacity: 0.5, offset: 0.42 }, { opacity: 1 }],
|
||
{ duration: 420, easing: 'ease-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 ---------- */
|
||
/* 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');
|
||
c.innerHTML = `
|
||
<div class="settings">
|
||
<h2>Einstellungen</h2>
|
||
|
||
<h3 class="group">Darstellung</h3>
|
||
|
||
<div class="setting">
|
||
<h4>Akzentfarbe</h4>
|
||
<p>Dieselbe Palette wie in der App.</p>
|
||
<div class="swatches" id="set-accent"></div>
|
||
<div class="hexrow">
|
||
<input type="text" id="set-hex" placeholder="#C04AC9" maxlength="7" spellcheck="false">
|
||
<button class="btn" id="set-hex-apply">Übernehmen</button>
|
||
</div>
|
||
<p class="hint" id="set-hex-note"></p>
|
||
</div>
|
||
|
||
<div class="setting">
|
||
<h4>Kachelgröße</h4>
|
||
<p>Wie groß Plakate und Vorschaubilder erscheinen — auf der Startseite
|
||
und in den Mediatheken. Gilt nur auf diesem Gerät.</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>
|
||
|
||
<h3 class="group">Startseite</h3>
|
||
|
||
<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>
|
||
|
||
<h3 class="group">Server</h3>
|
||
|
||
<div class="setting">
|
||
<h4>Verbindung</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.dataset.hex = hex;
|
||
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');
|
||
|
||
// 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); };
|
||
|
||
// 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?:\/\//, '');
|
||
}
|
||
|
||
/* ---------- 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),
|
||
// 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);
|
||
|
||
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) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|