Mein Menü machte genau den Fehler, den der iPad-Code im Kommentar ausschließt: alles in einer Liste. Dort steht, warum das falsch ist — "die Tonspur stellt man einmal am Anfang um, die Untertitel mitten im Film. Ein gemeinsames Blatt bedeutete jedes Mal Scrollen an der anderen Liste vorbei." Jetzt drei Knöpfe in der Kopfzeile, jeder mit seiner eigenen Liste darunter, und jede Liste hängt sichtbar an ihrem Knopf. Die Tonspur erscheint auch dann, wenn es nur eine gibt — welche läuft, will man wissen, nicht nur ob man wechseln kann. Die Untertitel-Taste ist gefüllt, solange welche laufen. Die Qualität steht als Text im Knopf, nicht als Symbol. Auch das aus dem iPad-Player übernommen: "Drei Schieberegler sagen nicht, was eingestellt ist; '1080p' schon — und genau das ist die Frage, wegen der man hintippt." Dazu eine gleichmäßige Abdunklung statt zweier Verläufe an den Rändern. Die Verläufe ließen die Mitte frei — wo die Transportknöpfe stehen, die damit auf hellen Szenen nichts hinter sich hatten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2120 lines
88 KiB
HTML
2120 lines
88 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; }
|
||
/* ---- Der Player ----------------------------------------------------------
|
||
Vollbild und schwarz. Ein Film nimmt den Bildschirm ein — alles andere ist
|
||
ein Vorschaufenster. */
|
||
#player {
|
||
position: fixed; inset: 0; z-index: 100; background: #000;
|
||
display: grid; place-items: center; overflow: hidden;
|
||
}
|
||
#player video { width: 100%; height: 100%; object-fit: contain; background: #000; cursor: pointer; }
|
||
|
||
.pl-chrome {
|
||
position: absolute; inset: 0; opacity: 0; pointer-events: none;
|
||
transition: opacity .25s ease;
|
||
display: grid; grid-template-rows: auto 1fr auto;
|
||
}
|
||
.pl-chrome.on { opacity: 1; pointer-events: auto; }
|
||
|
||
/* Zwei Verläufe statt einer Fläche: die Mitte bleibt frei, und genau dort
|
||
ist das Bild. */
|
||
/* Eine gleichmäßige Abdunklung über allem, wie in der App: „genug, damit
|
||
weiße Schrift auf hellen Szenen lesbar bleibt, wenig genug, dass das Bild
|
||
nicht verfälscht wirkt." Zwei Verläufe an den Rändern taten das nicht —
|
||
in der Mitte stehen die Transportknöpfe und hatten nichts hinter sich. */
|
||
.pl-chrome { background: rgba(0,0,0,.35); }
|
||
|
||
.pl-top, .pl-bottom { padding: var(--s-loose) var(--s-section); }
|
||
.pl-top {
|
||
display: flex; align-items: center; gap: var(--s-regular);
|
||
padding-top: calc(var(--s-loose) + env(safe-area-inset-top));
|
||
}
|
||
.pl-bottom { padding-bottom: calc(var(--s-loose) + env(safe-area-inset-bottom)); }
|
||
|
||
/* Die Stufe als Text, deshalb kein rundes Feld. */
|
||
.pl-quality {
|
||
width: auto; padding: 0 var(--s-regular); border-radius: var(--r-control);
|
||
font-size: 13px; font-weight: 600; white-space: nowrap;
|
||
}
|
||
|
||
.pl-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||
.pl-title strong { font-size: 17px; font-weight: 600; letter-spacing: -.01em; }
|
||
.pl-title span { font-size: 13px; color: var(--text-2); }
|
||
|
||
.pl-icon {
|
||
width: 38px; height: 38px; border-radius: 999px; flex: 0 0 auto;
|
||
background: rgba(20,20,23,.55); color: var(--text); font-size: 15px;
|
||
border: 1px solid rgba(255,255,255,.14);
|
||
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
||
transition: background .15s ease;
|
||
}
|
||
.pl-icon:hover { background: rgba(40,40,46,.75); }
|
||
/* Eine Vorgabegröße für alle Symbole, damit sie zueinander passen; einzelne
|
||
überschreiben sie über `icon(name, size)`. */
|
||
.pl-icon svg, .pl-round svg, .tiny svg, .hero-nav svg { width: 18px; height: 18px; }
|
||
.pl-round svg { width: 22px; height: 22px; }
|
||
.btn svg { width: 15px; height: 15px; vertical-align: -2px; margin-right: 2px; }
|
||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
|
||
.tiny, .pl-icon, .pl-round, .hero-nav { display: inline-flex; align-items: center; justify-content: center; }
|
||
|
||
.pl-center {
|
||
display: flex; align-items: center; justify-content: center;
|
||
gap: calc(var(--s-section) * 2);
|
||
}
|
||
/* Dunkel statt hell durchscheinend: über einer sonnigen Szene verschwanden
|
||
helle Knöpfe vollständig. Ein Schatten hebt sie zusätzlich ab. */
|
||
.pl-round {
|
||
position: relative; width: 58px; height: 58px; border-radius: 999px;
|
||
background: rgba(20,20,23,.55); color: var(--text); font-size: 22px;
|
||
border: 1px solid rgba(255,255,255,.14);
|
||
box-shadow: 0 4px 16px rgba(0,0,0,.45);
|
||
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
||
transition: background .15s ease, transform .15s ease;
|
||
}
|
||
.pl-round:hover { background: rgba(40,40,46,.8); transform: scale(1.06); }
|
||
.pl-round.pl-play { width: 84px; height: 84px; }
|
||
.pl-round.pl-play svg { width: 34px; height: 34px; }
|
||
/* Die Sekundenzahl als Text, nicht als Ziffer im Pfeil: übereinander war
|
||
beides unlesbar, und die Zahl ist die eigentliche Auskunft. */
|
||
#pl-back, #pl-fwd { font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||
|
||
.pl-scrub { display: flex; align-items: center; gap: var(--s-regular); }
|
||
.pl-scrub span {
|
||
font-size: 13px; color: var(--text-2); font-variant-numeric: tabular-nums;
|
||
min-width: 52px;
|
||
}
|
||
.pl-scrub span:last-child { text-align: right; }
|
||
|
||
.pl-track {
|
||
position: relative; flex: 1; height: 22px; cursor: pointer;
|
||
display: flex; align-items: center;
|
||
}
|
||
.pl-track::before {
|
||
content: ''; position: absolute; left: 0; right: 0; height: 4px;
|
||
background: rgba(0,0,0,.45); border-radius: 999px;
|
||
box-shadow: inset 0 0 0 1px rgba(255,255,255,.14);
|
||
}
|
||
.pl-buf, .pl-fill { position: absolute; left: 0; height: 4px; border-radius: 999px; }
|
||
.pl-buf { background: rgba(255,255,255,.3); width: 0; }
|
||
.pl-fill { background: var(--accent); width: 0; }
|
||
.pl-track i {
|
||
position: absolute; width: 13px; height: 13px; border-radius: 999px;
|
||
background: var(--accent); transform: translateX(-50%);
|
||
box-shadow: 0 1px 4px rgba(0,0,0,.6); transition: transform .12s ease;
|
||
}
|
||
.pl-track:hover i { transform: translateX(-50%) scale(1.25); }
|
||
|
||
.pl-tools {
|
||
display: flex; align-items: center; gap: var(--s-regular);
|
||
margin-top: var(--s-snug);
|
||
}
|
||
.pl-spacer { flex: 1; }
|
||
.pl-tools input[type=range] { width: 92px; accent-color: var(--accent); }
|
||
.pl-rate { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: var(--s-snug); }
|
||
.pl-rate .pick { width: auto; padding: 6px var(--s-regular); font-size: 13px; }
|
||
|
||
/* Während geladen wird, treten die Transportknöpfe zurück: sie tun ohnehin
|
||
nichts, und der Kreisel lag vorher genau hinter dem größten von ihnen. */
|
||
#player.loading .pl-center { opacity: 0; pointer-events: none; }
|
||
.pl-center { transition: opacity .2s ease; }
|
||
|
||
.pl-busy {
|
||
position: absolute; display: none; pointer-events: none;
|
||
}
|
||
.pl-busy.on { display: block; }
|
||
.pl-busy i {
|
||
display: block; width: 44px; height: 44px; border-radius: 999px;
|
||
border: 3px solid rgba(255,255,255,.2); border-top-color: var(--accent);
|
||
animation: spin .8s linear infinite;
|
||
}
|
||
|
||
.pl-error {
|
||
position: absolute; max-width: 460px; padding: var(--s-section);
|
||
background: var(--elevated); border: 1px solid rgba(229,72,77,.35);
|
||
border-radius: var(--r-sheet); display: flex; flex-direction: column; gap: var(--s-regular);
|
||
box-shadow: 0 20px 60px rgba(0,0,0,.6);
|
||
}
|
||
.pl-error strong { color: #F08A8D; font-size: 16px; font-weight: 600; }
|
||
.pl-error span { color: var(--text-2); font-size: 14px; }
|
||
.pl-error .btn { align-self: flex-start; }
|
||
/* Das Menü für Qualität und Spuren — über der Leiste, nicht als Blatt.
|
||
Ein Blatt würde das Bild verdecken, und man stellt hier etwas ein, das man
|
||
am Bild beurteilen will. */
|
||
.pl-menu {
|
||
position: fixed; z-index: 4;
|
||
min-width: 232px; max-width: 320px; max-height: 60vh; overflow-y: auto;
|
||
background: rgba(20,20,23,.92); border: 1px solid var(--separator);
|
||
border-radius: var(--r-sheet); padding: var(--s-snug);
|
||
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
||
box-shadow: 0 16px 48px rgba(0,0,0,.6);
|
||
}
|
||
.pl-group {
|
||
font-size: 11px; font-weight: 600; color: var(--text-3);
|
||
text-transform: uppercase; letter-spacing: .06em;
|
||
padding: var(--s-regular) var(--s-regular) var(--s-tight);
|
||
}
|
||
.pl-group:first-child { padding-top: var(--s-snug); }
|
||
.pl-item {
|
||
display: flex; align-items: center; justify-content: space-between; gap: var(--s-regular);
|
||
width: 100%; padding: 9px var(--s-regular); border-radius: var(--r-control);
|
||
background: none; color: var(--text); font-size: 14px; text-align: left;
|
||
}
|
||
.pl-item:hover { background: rgba(255,255,255,.08); }
|
||
.pl-item.on { color: var(--accent); }
|
||
.pl-item svg { width: 16px; height: 16px; flex: 0 0 auto; }
|
||
.pl-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
|
||
.pl-error code {
|
||
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px;
|
||
color: var(--text-3); background: rgba(0,0,0,.4); padding: var(--s-snug);
|
||
border-radius: 6px; word-break: break-all; line-height: 1.5;
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.pl-top, .pl-bottom { padding-left: var(--s-loose); padding-right: var(--s-loose); }
|
||
.pl-round { width: 50px; height: 50px; }
|
||
.pl-round.pl-play { width: 64px; height: 64px; }
|
||
.pl-rate, .pl-tools input[type=range] { display: none; }
|
||
}
|
||
|
||
/* ---- 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; }
|
||
.rowline {
|
||
display: flex; align-items: center; gap: var(--s-snug);
|
||
padding: var(--s-snug) 0; border-bottom: 1px solid var(--separator);
|
||
}
|
||
.rowline:last-child { border-bottom: none; }
|
||
.rowline.off span { color: var(--text-3); text-decoration: line-through; }
|
||
.tiny {
|
||
width: 30px; height: 30px; border-radius: var(--r-control);
|
||
background: var(--elevated); color: var(--text-2); font-size: 14px; padding: 0;
|
||
}
|
||
.tiny:hover:not(:disabled) { color: var(--text); }
|
||
.tiny:disabled { opacity: .3; cursor: default; }
|
||
.pick {
|
||
background: var(--elevated); border: 1px solid var(--separator);
|
||
border-radius: var(--r-control); color: var(--text);
|
||
padding: 9px var(--s-regular); font-family: inherit; font-size: 14px; width: 100%;
|
||
}
|
||
.pick:focus { outline: none; border-color: var(--accent); }
|
||
.pickrow { display: flex; gap: var(--s-regular); flex-wrap: wrap; }
|
||
.pickrow label { flex: 1; min-width: 160px; font-size: 13px; color: var(--text-2); }
|
||
.pickrow .pick { margin-top: 6px; }
|
||
.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',
|
||
// Welche Reihen ausgeblendet sind und in welcher Reihenfolge der Rest
|
||
// steht — dieselbe Aufteilung wie `Arrangement` in der App.
|
||
hiddenRows: JSON.parse(localStorage.getItem('flixy.hiddenRows') || '[]'),
|
||
rowOrder: JSON.parse(localStorage.getItem('flixy.rowOrder') || '[]'),
|
||
// Wiedergabe — dieselben Stufen wie in der App, damit derselbe Begriff
|
||
// überall dasselbe heißt.
|
||
rate: +(localStorage.getItem('flixy.rate') || 1.0),
|
||
skipForward: +(localStorage.getItem('flixy.skipForward') || 30),
|
||
skipBackward: +(localStorage.getItem('flixy.skipBackward') || 10),
|
||
quality: +(localStorage.getItem('flixy.quality') || 0), // 0 = Original
|
||
// Sprachen. Leer heißt: was der Server vorgibt.
|
||
audioLanguage: localStorage.getItem('flixy.audioLanguage') || '',
|
||
subtitleLanguage: localStorage.getItem('flixy.subtitleLanguage') || '',
|
||
};
|
||
|
||
/// Die Stufen der App, damit die Begriffe sich decken.
|
||
const RATES = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
|
||
const SKIPS = [5, 10, 15, 30, 45];
|
||
const QUALITIES = [
|
||
[0, 'Original'], [12000, 'Hoch — 12 Mbit/s'], [8000, 'Mittel — 8 Mbit/s'],
|
||
[4000, 'Niedrig — 4 Mbit/s'], [2000, 'Sparsam — 2 Mbit/s'],
|
||
];
|
||
/// Sprachen als ISO-639-2, wie Jellyfin sie führt.
|
||
const LANGUAGES = [
|
||
['', 'Serverstandard'], ['ger', 'Deutsch'], ['eng', 'Englisch'],
|
||
['fre', 'Französisch'], ['spa', 'Spanisch'], ['ita', 'Italienisch'],
|
||
['jpn', 'Japanisch'], ['kor', 'Koreanisch'],
|
||
];
|
||
|
||
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;
|
||
// Listen müssen als JSON abgelegt werden — ein Array landete sonst als
|
||
// "a,b,c" im Speicher und käme als Zeichenkette zurück.
|
||
localStorage.setItem('flixy.' + key, Array.isArray(value) ? JSON.stringify(value) : value);
|
||
applyPrefs();
|
||
}
|
||
|
||
/* Wendet Auswahl und Reihenfolge auf die Reihen an.
|
||
----------------------------------------------------------------------------
|
||
Dieselbe Regel wie `Arrangement` in der App, samt ihrer wichtigsten
|
||
Eigenschaft: **unbekannte Einträge landen am Ende statt zu verschwinden.**
|
||
Sie zu verwerfen wäre die einfachere Regel — und der Grund dafür, dass eine
|
||
neu angelegte Mediathek nie aufgetaucht wäre. */
|
||
function applyArrangement(rows) {
|
||
const visible = rows.filter((r) => !P.hiddenRows.includes(r.id));
|
||
if (!P.rowOrder.length) return visible;
|
||
|
||
const at = new Map(P.rowOrder.map((id, i) => [id, i]));
|
||
return visible
|
||
.map((row, i) => ({ row, known: at.has(row.id) ? at.get(row.id) : Infinity, i }))
|
||
// Bei gleichem Rang gilt die ursprüngliche Folge — sonst springen zwei
|
||
// unbekannte Reihen bei jedem Laden gegeneinander.
|
||
.sort((a, b) => (a.known - b.known) || (a.i - b.i))
|
||
.map((x) => x.row);
|
||
}
|
||
|
||
/* ---------- 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>
|
||
|
||
<div class="setting">
|
||
<h4>Reihen der Startseite</h4>
|
||
<p>Welche Reihen erscheinen und in welcher Folge. Was der Server neu
|
||
anlegt, kommt ans Ende, statt zu fehlen.</p>
|
||
<div id="set-rows"></div>
|
||
</div>
|
||
|
||
<h3 class="group">Wiedergabe</h3>
|
||
|
||
<div class="setting">
|
||
<h4>Geschwindigkeit</h4>
|
||
<p>Womit ein Titel beginnt. Im Player lässt sie sich jederzeit ändern.</p>
|
||
<select id="set-rate" class="pick"></select>
|
||
</div>
|
||
|
||
<div class="setting">
|
||
<h4>Spulweite</h4>
|
||
<p>Wie weit die beiden Knöpfe im Player springen.</p>
|
||
<div class="pickrow">
|
||
<label>Zurück <select id="set-skipback" class="pick"></select></label>
|
||
<label>Vor <select id="set-skipfwd" class="pick"></select></label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="setting">
|
||
<h4>Qualität</h4>
|
||
<p>„Original“ schickt die Datei unverändert, alles andere lässt den
|
||
Server umrechnen. Die App unterscheidet hier WLAN und Mobilfunk —
|
||
im Browser lässt sich das nicht verlässlich feststellen, deshalb
|
||
gibt es eine Stufe.</p>
|
||
<select id="set-quality" class="pick"></select>
|
||
</div>
|
||
|
||
<h3 class="group">Sprachen</h3>
|
||
|
||
<div class="setting">
|
||
<h4>Bevorzugte Sprachen</h4>
|
||
<p>Welche Spur beim Start gewählt wird, sofern der Titel sie mitbringt.</p>
|
||
<div class="pickrow">
|
||
<label>Ton <select id="set-audio" class="pick"></select></label>
|
||
<label>Untertitel <select id="set-subs" class="pick"></select></label>
|
||
</div>
|
||
</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');
|
||
|
||
renderRowArrangement();
|
||
|
||
// Auswahlfelder, die alle gleich funktionieren.
|
||
const choose = (id, key, options) => {
|
||
const sel = el(id);
|
||
sel.innerHTML = '';
|
||
options.forEach(([value, text]) => {
|
||
const o = document.createElement('option');
|
||
o.value = value; o.textContent = text;
|
||
o.selected = String(value) === String(P[key]);
|
||
sel.appendChild(o);
|
||
});
|
||
sel.onchange = () => setPref(key, typeof P[key] === 'number' ? +sel.value : sel.value);
|
||
};
|
||
choose('set-rate', 'rate', RATES.map((r) => [r, r === 1 ? 'Normal' : r + '×']));
|
||
choose('set-skipback', 'skipBackward', SKIPS.map((n) => [n, n + ' s']));
|
||
choose('set-skipfwd', 'skipForward', SKIPS.map((n) => [n, n + ' s']));
|
||
choose('set-quality', 'quality', QUALITIES);
|
||
choose('set-audio', 'audioLanguage', LANGUAGES);
|
||
choose('set-subs', 'subtitleLanguage', LANGUAGES);
|
||
|
||
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 ---------- */
|
||
|
||
/// Welche Reihen es gibt — für die Einstellungen. Erst nach dem ersten Laden
|
||
/// gefüllt: vorher weiß niemand, welche Mediatheken dieser Server hat.
|
||
let knownRows = [];
|
||
|
||
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));
|
||
// Die zusammengelegte Reihe behält die Kennung der ersten — sonst wäre
|
||
// sie für die gespeicherte Reihenfolge eine neue und stünde wieder unten.
|
||
rows = [{ id: 'resume', title: 'Weiterschauen', items: merged, wide: true }];
|
||
} else {
|
||
rows = [
|
||
{ id: 'resume', title: 'Weiterschauen', items: resume, wide: true },
|
||
{ id: 'nextup', title: 'Als Nächstes', items: next, wide: true },
|
||
];
|
||
}
|
||
|
||
rows.push(...views.map((v, i) => ({
|
||
id: 'latest.' + v.Id,
|
||
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);
|
||
|
||
// Was es bei diesem Server überhaupt gibt, merken — **vor** dem Ausblenden.
|
||
// Die Einstellungen müssen auch die Reihen anbieten, die gerade versteckt
|
||
// sind; sonst könnte man sie nie wieder einblenden.
|
||
knownRows = rows.map((r) => ({ id: r.id, title: r.title }));
|
||
rows = applyArrangement(rows);
|
||
|
||
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">${icon('play', 15)} 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 = icon('left', 22);
|
||
prev.setAttribute('aria-label', 'Vorheriger Titel');
|
||
prev.onclick = () => step(-1);
|
||
|
||
const next = document.createElement('button');
|
||
next.className = 'hero-nav next'; next.innerHTML = icon('right', 22);
|
||
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 || '');
|
||
|
||
/* Die Liste der Reihen — sichtbar machen, ausblenden, verschieben.
|
||
----------------------------------------------------------------------------
|
||
Knöpfe statt Ziehen: auf dem Zeigegerät ist Ziehen schöner, auf einem
|
||
Laptop-Trackpad mit einer Liste dieser Länge aber fummelig, und mit der
|
||
Tastatur gar nicht zu machen. Zwei Pfeile können alle drei. */
|
||
function renderRowArrangement() {
|
||
const host = el('set-rows');
|
||
if (!host) return;
|
||
|
||
if (!knownRows.length) {
|
||
host.innerHTML = '<p class="hint">Erst sichtbar, wenn die Startseite einmal geladen war.</p>';
|
||
return;
|
||
}
|
||
|
||
// In der Folge, die gerade gilt — ausgeblendete hinten dran, damit man sie
|
||
// wiederfindet.
|
||
const ordered = applyArrangement(knownRows)
|
||
.concat(knownRows.filter((r) => P.hiddenRows.includes(r.id)));
|
||
|
||
host.innerHTML = '';
|
||
ordered.forEach((row, index) => {
|
||
const hidden = P.hiddenRows.includes(row.id);
|
||
const line = document.createElement('div');
|
||
line.className = 'rowline' + (hidden ? ' off' : '');
|
||
line.innerHTML = `
|
||
<label class="check" style="flex:1;padding:0">
|
||
<input type="checkbox" ${hidden ? '' : 'checked'}>
|
||
<span>${esc(row.title)}</span>
|
||
</label>
|
||
<button class="tiny" aria-label="Nach oben" ${index === 0 ? 'disabled' : ''}>${icon('up', 16)}</button>
|
||
<button class="tiny" aria-label="Nach unten" ${index === ordered.length - 1 ? 'disabled' : ''}>${icon('down', 16)}</button>`;
|
||
|
||
line.querySelector('input').onchange = (e) => {
|
||
const next = e.target.checked
|
||
? P.hiddenRows.filter((id) => id !== row.id)
|
||
: [...P.hiddenRows, row.id];
|
||
setPref('hiddenRows', next);
|
||
renderRowArrangement();
|
||
};
|
||
|
||
const move = (by) => {
|
||
// Die aktuelle Folge festschreiben, bevor darin verschoben wird: ohne
|
||
// gespeicherte Folge gäbe es nichts, worin man tauschen könnte.
|
||
const ids = ordered.map((r) => r.id);
|
||
const to = index + by;
|
||
if (to < 0 || to >= ids.length) return;
|
||
[ids[index], ids[to]] = [ids[to], ids[index]];
|
||
setPref('rowOrder', ids);
|
||
renderRowArrangement();
|
||
};
|
||
const [up, down] = line.querySelectorAll('button');
|
||
up.onclick = () => move(-1);
|
||
down.onclick = () => move(1);
|
||
|
||
host.appendChild(line);
|
||
});
|
||
}
|
||
|
||
/* ---------- 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';
|
||
// Serien und Staffeln sind Ordner — abgespielt wird trotzdem. Nur eben
|
||
// nicht der Ordner, sondern die Folge, die als Nächstes dran ist.
|
||
const isFolder = ['Series', 'Season'].includes(item.Type);
|
||
const playable = isFolder || !['BoxSet', 'Folder', 'CollectionFolder'].includes(item.Type);
|
||
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">
|
||
${playable ? `<button class="btn btn-primary" id="sheet-play">${icon('play', 15)} ${isFolder ? 'Weiterschauen' : '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();
|
||
if (playable) {
|
||
const start = async (button) => {
|
||
if (button) { button.disabled = true; button.textContent = 'Suche Folge …'; }
|
||
const target = isFolder ? await nextEpisode(item) : item;
|
||
if (button) { button.disabled = false; button.innerHTML = icon('play', 15) + (isFolder ? ' Weiterschauen' : ' Abspielen'); }
|
||
if (!target) {
|
||
back.querySelector('.btn-row').insertAdjacentHTML('afterend',
|
||
'<p class="hint">Diese Serie hat keine Folgen, die sich abspielen ließen.</p>');
|
||
return;
|
||
}
|
||
openPlayer(target);
|
||
back.remove();
|
||
};
|
||
const button = back.querySelector('#sheet-play');
|
||
button.onclick = () => start(button);
|
||
if (playNow) start(button);
|
||
}
|
||
}
|
||
|
||
/* Welche Folge einer Serie oder Staffel gemeint ist.
|
||
----------------------------------------------------------------------------
|
||
Erst „Als Nächstes": wer eine Serie anfängt und zurückkommt, meint die
|
||
Folge, bei der er stehengeblieben ist — nicht die erste. Das ist dieselbe
|
||
Frage, die die Startseite mit `/Shows/NextUp` beantwortet.
|
||
|
||
Erst wenn es die nicht gibt (nichts angefangen, oder alles gesehen), wird
|
||
es die erste Folge. Eine Serie gar nicht abzuspielen war die falsche
|
||
Antwort — die offensichtliche Erwartung ist, dass es losgeht. */
|
||
async function nextEpisode(item) {
|
||
const seriesID = item.Type === 'Season' ? (item.SeriesId || item.Id) : item.Id;
|
||
|
||
try {
|
||
const weiter = await api('/Shows/NextUp?' + new URLSearchParams({
|
||
userId: S.userId, seriesId: seriesID, Limit: 1, Fields: FIELDS,
|
||
}));
|
||
const folge = weiter.Items?.[0];
|
||
// Bei einer Staffel muss die Folge auch zu ihr gehören.
|
||
if (folge && (item.Type !== 'Season' || folge.SeasonId === item.Id)) return folge;
|
||
} catch { /* dann eben die erste */ }
|
||
|
||
try {
|
||
const query = { userId: S.userId, Limit: 1, Fields: FIELDS };
|
||
if (item.Type === 'Season') query.seasonId = item.Id;
|
||
const alle = await api(`/Shows/${seriesID}/Episodes?` + new URLSearchParams(query));
|
||
return alle.Items?.[0] || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/* Der Player.
|
||
----------------------------------------------------------------------------
|
||
Vollbild und eigene Bedienung. Vorher stand hier das nackte
|
||
<video controls> des Browsers in einem Blatt — das ist kein Player, das ist
|
||
ein eingebettetes Vorschaufenster. Ein Film nimmt den Bildschirm ein.
|
||
|
||
Die Bedienung blendet nach drei Sekunden Ruhe aus und kommt bei jeder
|
||
Bewegung zurück, wie in der App. Tastatur: Leertaste hält an, Pfeile
|
||
spulen, Escape beendet. */
|
||
function openPlayer(item) {
|
||
document.getElementById('player')?.remove();
|
||
|
||
const layer = document.createElement('div');
|
||
layer.id = 'player';
|
||
layer.innerHTML = `
|
||
<video playsinline></video>
|
||
<div class="pl-chrome">
|
||
<div class="pl-top">
|
||
<button class="pl-icon" id="pl-close" aria-label="Schließen">${icon('close')}</button>
|
||
<div class="pl-title">
|
||
<strong>${esc(item.SeriesName || item.Name)}</strong>
|
||
${item.SeriesName ? `<span>${esc(episodeLabel(item))}</span>` : ''}
|
||
</div>
|
||
<span class="pl-spacer"></span>
|
||
<button class="pl-icon" id="pl-audio" aria-label="Tonspur" hidden>${icon('waveform')}</button>
|
||
<button class="pl-icon" id="pl-subs" aria-label="Untertitel" hidden>${icon('captions')}</button>
|
||
<button class="pl-icon pl-quality" id="pl-qual" aria-label="Qualität">Original</button>
|
||
</div>
|
||
|
||
<div class="pl-center">
|
||
<button class="pl-round" id="pl-back" aria-label="Zurückspulen">−${P.skipBackward}</button>
|
||
<button class="pl-round pl-play" id="pl-toggle" aria-label="Abspielen">${icon('play', 30)}</button>
|
||
<button class="pl-round" id="pl-fwd" aria-label="Vorspulen">+${P.skipForward}</button>
|
||
</div>
|
||
|
||
<div class="pl-bottom">
|
||
<div class="pl-scrub">
|
||
<span id="pl-now">0:00</span>
|
||
<div class="pl-track" id="pl-track"><div class="pl-buf"></div><div class="pl-fill"></div><i></i></div>
|
||
<span id="pl-left">–:--</span>
|
||
</div>
|
||
<div class="pl-tools">
|
||
<button class="pl-icon" id="pl-mute" aria-label="Ton aus">${icon('sound')}</button>
|
||
<input type="range" id="pl-vol" min="0" max="1" step="0.05" value="1" aria-label="Lautstärke">
|
||
<span class="pl-spacer"></span>
|
||
<label class="pl-rate">Tempo <select class="pick" id="pl-rate"></select></label>
|
||
<button class="pl-icon" id="pl-full" aria-label="Vollbild">${icon('expand')}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="pl-menu" id="pl-menu" hidden></div>
|
||
<div class="pl-busy" id="pl-busy"><i></i></div>`;
|
||
document.body.appendChild(layer);
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
const v = layer.querySelector('video');
|
||
const chrome = layer.querySelector('.pl-chrome');
|
||
const el2 = (id) => layer.querySelector('#' + id);
|
||
|
||
const fail = (satz, hinweis, adresse) => {
|
||
layer.classList.remove('loading');
|
||
el2('pl-busy').classList.remove('on');
|
||
layer.querySelector('.pl-error')?.remove();
|
||
const box = document.createElement('div');
|
||
box.className = 'pl-error';
|
||
box.innerHTML = `<strong>${esc(satz)}</strong>${hinweis ? `<span>${esc(hinweis)}</span>` : ''}
|
||
${adresse ? `<code>${esc(adresse.replace(/api_key=[^&]*/, 'api_key=…'))}</code>` : ''}
|
||
<button class="btn">Schließen</button>`;
|
||
box.querySelector('button').onclick = close;
|
||
layer.appendChild(box);
|
||
};
|
||
|
||
// --- Bedienung ein- und ausblenden ---
|
||
let hideTimer;
|
||
const show = () => {
|
||
chrome.classList.add('on');
|
||
clearTimeout(hideTimer);
|
||
// Solange etwas steht, bleibt die Bedienung: sie auszublenden, während
|
||
// niemand spielt, versteckt den einzigen Knopf, der weiterhilft.
|
||
if (!v.paused) hideTimer = setTimeout(() => chrome.classList.remove('on'), 3000);
|
||
};
|
||
layer.addEventListener('mousemove', show);
|
||
layer.addEventListener('touchstart', show, { passive: true });
|
||
|
||
// --- Transport ---
|
||
const toggle = () => (v.paused ? v.play() : v.pause());
|
||
el2('pl-toggle').onclick = toggle;
|
||
el2('pl-back').onclick = () => { v.currentTime = Math.max(0, v.currentTime - P.skipBackward); show(); };
|
||
el2('pl-fwd').onclick = () => { v.currentTime = Math.min(v.duration || 1e9, v.currentTime + P.skipForward); show(); };
|
||
v.addEventListener('click', toggle);
|
||
|
||
const paint = () => {
|
||
el2('pl-toggle').innerHTML = icon(v.paused ? 'play' : 'pause', 30);
|
||
el2('pl-toggle').setAttribute('aria-label', v.paused ? 'Abspielen' : 'Anhalten');
|
||
show();
|
||
};
|
||
v.addEventListener('play', paint);
|
||
v.addEventListener('pause', paint);
|
||
v.addEventListener('waiting', () => el2('pl-busy').classList.add('on'));
|
||
v.addEventListener('playing', () => {
|
||
el2('pl-busy').classList.remove('on');
|
||
layer.classList.remove('loading');
|
||
});
|
||
|
||
// --- Fortschrittsleiste ---
|
||
const track = el2('pl-track');
|
||
const clock = (sec) => {
|
||
if (!isFinite(sec)) return '–:--';
|
||
const s = Math.max(0, Math.floor(sec)), h = Math.floor(s / 3600);
|
||
const m = Math.floor((s % 3600) / 60), r = s % 60;
|
||
return (h ? `${h}:${String(m).padStart(2, '0')}` : `${m}`) + ':' + String(r).padStart(2, '0');
|
||
};
|
||
v.addEventListener('timeupdate', () => {
|
||
const teil = v.duration ? v.currentTime / v.duration : 0;
|
||
track.querySelector('.pl-fill').style.width = (teil * 100) + '%';
|
||
track.querySelector('i').style.left = (teil * 100) + '%';
|
||
el2('pl-now').textContent = clock(v.currentTime);
|
||
// Restzeit statt Gesamtlänge: „noch 42 Minuten" beantwortet die Frage,
|
||
// die man sich beim Schauen tatsächlich stellt.
|
||
el2('pl-left').textContent = '−' + clock((v.duration || 0) - v.currentTime);
|
||
});
|
||
v.addEventListener('progress', () => {
|
||
if (!v.buffered.length || !v.duration) return;
|
||
track.querySelector('.pl-buf').style.width =
|
||
(v.buffered.end(v.buffered.length - 1) / v.duration * 100) + '%';
|
||
});
|
||
|
||
const seekTo = (e) => {
|
||
const r = track.getBoundingClientRect();
|
||
const x = ((e.clientX ?? e.touches?.[0]?.clientX) - r.left) / r.width;
|
||
if (v.duration) v.currentTime = Math.min(Math.max(x, 0), 1) * v.duration;
|
||
show();
|
||
};
|
||
track.onclick = seekTo;
|
||
track.onmousedown = (e) => {
|
||
seekTo(e);
|
||
const move = (ev) => seekTo(ev);
|
||
const up = () => { removeEventListener('mousemove', move); removeEventListener('mouseup', up); };
|
||
addEventListener('mousemove', move); addEventListener('mouseup', up);
|
||
};
|
||
|
||
// --- Ton, Tempo, Vollbild ---
|
||
const vol = el2('pl-vol');
|
||
vol.oninput = () => { v.volume = +vol.value; v.muted = +vol.value === 0; };
|
||
el2('pl-mute').onclick = () => {
|
||
v.muted = !v.muted;
|
||
el2('pl-mute').innerHTML = icon(v.muted ? 'muted' : 'sound');
|
||
};
|
||
|
||
const rate = el2('pl-rate');
|
||
RATES.forEach((r) => {
|
||
const o = document.createElement('option');
|
||
o.value = r; o.textContent = r === 1 ? 'Normal' : r + '×'; o.selected = r === P.rate;
|
||
rate.appendChild(o);
|
||
});
|
||
rate.onchange = () => { v.playbackRate = +rate.value; };
|
||
|
||
el2('pl-full').onclick = () =>
|
||
document.fullscreenElement ? document.exitFullscreen() : layer.requestFullscreen?.();
|
||
|
||
// --- Tastatur ---
|
||
const keys = (e) => {
|
||
if (e.target.tagName === 'SELECT' || e.target.tagName === 'INPUT') return;
|
||
const map = {
|
||
' ': () => toggle(), k: () => toggle(),
|
||
ArrowLeft: () => { v.currentTime -= P.skipBackward; },
|
||
ArrowRight: () => { v.currentTime += P.skipForward; },
|
||
ArrowUp: () => { v.volume = Math.min(1, v.volume + 0.1); vol.value = v.volume; },
|
||
ArrowDown: () => { v.volume = Math.max(0, v.volume - 0.1); vol.value = v.volume; },
|
||
f: () => el2('pl-full').click(),
|
||
Escape: () => close(),
|
||
};
|
||
if (!map[e.key]) return;
|
||
e.preventDefault(); map[e.key](); show();
|
||
};
|
||
addEventListener('keydown', keys);
|
||
|
||
// --- Schließen ---
|
||
function close() {
|
||
removeEventListener('keydown', keys);
|
||
clearTimeout(hideTimer);
|
||
if (document.fullscreenElement) document.exitFullscreen?.();
|
||
v.__hls?.destroy(); v.__hls = null;
|
||
v.pause(); v.removeAttribute('src'); v.load();
|
||
layer.remove();
|
||
document.body.style.overflow = '';
|
||
// Die Startseite kennt den neuen Fortschritt noch nicht.
|
||
if (current === 'home') renderHome();
|
||
}
|
||
el2('pl-close').onclick = close;
|
||
|
||
// Womit gerade gespielt wird — und was die Auswahl hergibt.
|
||
const lauf = { quality: P.quality, audio: null, subtitle: null, streams: [], stelle: 0 };
|
||
|
||
/* Lädt mit geänderter Wahl neu, an derselben Stelle.
|
||
--------------------------------------------------------------------------
|
||
Im Browser lässt sich die Tonspur nicht im Lauf umschalten, wie es die App
|
||
über die Engine tut: bei einem umgerechneten HLS-Strom bestimmt der Server
|
||
beim Aushandeln, was drin ist. Also wird neu ausgehandelt — und die Stelle
|
||
mitgenommen, damit es nicht wie ein Neuanfang aussieht. */
|
||
const neuLaden = () => {
|
||
lauf.stelle = v.currentTime || 0;
|
||
layer.querySelector('.pl-error')?.remove();
|
||
layer.classList.add('loading');
|
||
el2('pl-busy').classList.add('on');
|
||
closeMenu();
|
||
startStream(item, v, fail, lauf);
|
||
};
|
||
|
||
/* **Drei Knöpfe, drei Listen** — wie auf dem iPad, und aus demselben Grund:
|
||
die Tonspur stellt man einmal am Anfang um, die Untertitel mitten im
|
||
Film, die Qualität, wenn es hakt. Das sind drei verschiedene Fragen, und
|
||
eine gemeinsame Liste hieße jedes Mal an den beiden anderen vorbeizusuchen. */
|
||
const menu = el2('pl-menu');
|
||
const closeMenu = () => { menu.hidden = true; };
|
||
|
||
const spuren = (art) => lauf.streams.filter((x) => x.Type === art);
|
||
const beschriftung = (t) => t.DisplayTitle || t.Language || `Spur ${t.Index}`;
|
||
|
||
function zeigeListe(knopf, titel, eintraege) {
|
||
if (!menu.hidden && menu.dataset.von === knopf.id) return closeMenu();
|
||
menu.dataset.von = knopf.id;
|
||
menu.innerHTML = `<div class="pl-group">${esc(titel)}</div>`;
|
||
|
||
eintraege.forEach(({ text, gewaehlt, waehlen }) => {
|
||
const b = document.createElement('button');
|
||
b.className = 'pl-item' + (gewaehlt ? ' on' : '');
|
||
b.innerHTML = `<span>${esc(text)}</span>${gewaehlt ? icon('check', 16) : ''}`;
|
||
b.onclick = () => (gewaehlt ? closeMenu() : waehlen());
|
||
menu.appendChild(b);
|
||
});
|
||
|
||
// Unter seinem Knopf, nicht in der Mitte: so ist zu sehen, wozu die Liste
|
||
// gehört, wenn drei davon nebeneinander sitzen.
|
||
const r = knopf.getBoundingClientRect();
|
||
menu.style.right = Math.max(12, innerWidth - r.right) + 'px';
|
||
menu.style.top = (r.bottom + 8) + 'px';
|
||
menu.hidden = false;
|
||
show();
|
||
}
|
||
|
||
el2('pl-audio').onclick = () => zeigeListe(el2('pl-audio'), 'Tonspur',
|
||
spuren('Audio').map((t) => ({
|
||
text: beschriftung(t), gewaehlt: lauf.audio === t.Index,
|
||
waehlen: () => { lauf.audio = t.Index; neuLaden(); },
|
||
})));
|
||
|
||
el2('pl-subs').onclick = () => zeigeListe(el2('pl-subs'), 'Untertitel', [
|
||
{ text: 'Aus', gewaehlt: lauf.subtitle < 0, waehlen: () => { lauf.subtitle = -1; neuLaden(); } },
|
||
...spuren('Subtitle').map((t) => ({
|
||
text: beschriftung(t), gewaehlt: lauf.subtitle === t.Index,
|
||
waehlen: () => { lauf.subtitle = t.Index; neuLaden(); },
|
||
})),
|
||
]);
|
||
|
||
el2('pl-qual').onclick = () => zeigeListe(el2('pl-qual'), 'Qualität',
|
||
QUALITIES.map(([wert, text]) => ({
|
||
text, gewaehlt: lauf.quality === wert,
|
||
waehlen: () => { lauf.quality = wert; neuLaden(); },
|
||
})));
|
||
|
||
layer.addEventListener('click', (e) => {
|
||
if (menu.hidden || menu.contains(e.target) || e.target.closest('.pl-icon')) return;
|
||
closeMenu();
|
||
});
|
||
|
||
/* Was die Kopfzeile zeigt, sobald der Server geantwortet hat. */
|
||
layer.__spurenBekannt = () => {
|
||
el2('pl-audio').hidden = spuren('Audio').length === 0;
|
||
el2('pl-subs').hidden = spuren('Subtitle').length === 0;
|
||
// Gefülltes Symbol, solange Untertitel laufen — wie auf dem iPad.
|
||
el2('pl-subs').innerHTML = icon(lauf.subtitle >= 0 ? 'captionsOn' : 'captions');
|
||
// Die Stufe als Text: drei Schieberegler sagen nicht, was eingestellt ist,
|
||
// „8 Mbit/s" schon — und genau das ist die Frage, wegen der man hintippt.
|
||
el2('pl-qual').textContent =
|
||
(QUALITIES.find(([w]) => w === lauf.quality)?.[1] || 'Original').split(' — ').pop();
|
||
};
|
||
|
||
show();
|
||
layer.classList.add('loading');
|
||
el2('pl-busy').classList.add('on');
|
||
startStream(item, v, fail, lauf);
|
||
}
|
||
|
||
/* Der Folgentitel in der Kopfzeile — „S2:E4 · Der Fall" statt nur „Der Fall". */
|
||
function episodeLabel(item) {
|
||
const nummer = item.ParentIndexNumber != null && item.IndexNumber != null
|
||
? `S${item.ParentIndexNumber}:E${item.IndexNumber}` : '';
|
||
return [nummer, item.Name].filter(Boolean).join(' · ');
|
||
}
|
||
|
||
/* Handelt die Wiedergabe aus und hängt den Strom ins Element.
|
||
----------------------------------------------------------------------------
|
||
Vier Schritte wie in der App: PlaybackInfo mit Geräteprofil, Quelle aus der
|
||
Antwort wählen, die Adresse des Servers nehmen, die Sitzungskennung
|
||
mitführen. Die Adresse selbst zusammenzubauen wäre eine Vermutung darüber,
|
||
was der Server kopieren kann und was er rechnen muss. */
|
||
async function startStream(item, v, fail, lauf) {
|
||
// Ein früherer Strom liegt vielleicht noch an.
|
||
v.__hls?.destroy();
|
||
v.__hls = null;
|
||
|
||
// Erst nach dem Aushandeln bekannt — deshalb eine Schachtel, die später
|
||
// gefüllt wird. Sie hängt am Element, damit ein zweiter Aufruf denselben
|
||
// Horcher weiterbenutzt: sonst käme bei jedem Spurwechsel ein weiterer
|
||
// dazu und ein einziger Fehler meldete sich dreimal.
|
||
v.__diagnose = v.__diagnose || { text: '', url: '' };
|
||
if (!v.__fehlerHorcht) {
|
||
v.__fehlerHorcht = true;
|
||
v.addEventListener('error', () => {
|
||
const c = v.error?.code;
|
||
fail('Der Browser kann diesen Strom nicht abspielen.',
|
||
(c === 4 ? 'Das Format wird nicht unterstützt. ' : `Fehlercode ${c ?? '?'}. `)
|
||
+ v.__diagnose.text, v.__diagnose.url);
|
||
});
|
||
}
|
||
const diagnose = v.__diagnose;
|
||
|
||
let info;
|
||
try {
|
||
info = await playbackInfo(item, lauf);
|
||
} catch (e) {
|
||
return fail('Der Server hat die Wiedergabe abgelehnt.', e.message);
|
||
}
|
||
|
||
const source = info.MediaSources?.[0];
|
||
if (!source) return fail('Der Server nennt keine abspielbare Quelle.');
|
||
|
||
// Was zur Auswahl steht, weiß man erst jetzt — der Server nennt die Spuren
|
||
// in seiner Antwort. Beim ersten Lauf gilt seine Vorauswahl.
|
||
lauf.streams = source.MediaStreams || [];
|
||
if (lauf.audio == null) {
|
||
lauf.audio = source.DefaultAudioStreamIndex
|
||
?? lauf.streams.find((x) => x.Type === 'Audio')?.Index ?? null;
|
||
}
|
||
if (lauf.subtitle == null) lauf.subtitle = source.DefaultSubtitleStreamIndex ?? -1;
|
||
v.closest('#player')?.__spurenBekannt?.();
|
||
|
||
// **Der Rückfall muss Umrechnung erzwingen, nicht sie umgehen.**
|
||
//
|
||
// Hier stand `static=true` — das schickt die Datei so, wie sie auf der
|
||
// Platte liegt. Bei einem MKV mit DTS ist das für einen Browser unlesbar,
|
||
// und er meldet bloß „Format nicht unterstützt". Genau dieser Rückfall war
|
||
// der Grund, warum nichts lief: er tat das Gegenteil dessen, was er sollte.
|
||
//
|
||
// `master.m3u8` verlangt dagegen ausdrücklich einen HLS-Strom. Nennt der
|
||
// Server keine eigene Adresse, ist das die richtige Bitte — nicht das
|
||
// Original.
|
||
const url = source.TranscodingUrl
|
||
? new URL(source.TranscodingUrl, S.base).href
|
||
: streamURL(`/Videos/${item.Id}/master.m3u8`, {
|
||
mediaSourceId: source.Id || item.Id,
|
||
api_key: S.token,
|
||
PlaySessionId: info.PlaySessionId || '',
|
||
DeviceId: S.deviceId,
|
||
VideoCodec: 'h264', AudioCodec: 'aac,mp3',
|
||
TranscodingMaxAudioChannels: '2', SegmentContainer: 'ts',
|
||
MinSegments: '1', BreakOnNonKeyFrames: 'true',
|
||
TranscodingProtocol: 'hls',
|
||
...(lauf.audio != null ? { AudioStreamIndex: lauf.audio } : {}),
|
||
...(lauf.subtitle >= 0 ? { SubtitleStreamIndex: lauf.subtitle } : {}),
|
||
...(lauf.quality > 0 ? { MaxStreamingBitrate: lauf.quality * 1000 } : {}),
|
||
});
|
||
|
||
// Was der Server geantwortet hat — damit im Fehlerfall nicht geraten werden
|
||
// muss, sondern dasteht, woran es lag.
|
||
const auskunft = [
|
||
source.TranscodingUrl ? 'Server nannte eine Adresse' : 'Server nannte keine Adresse (Rückfall auf HLS)',
|
||
source.Container ? `Container ${source.Container}` : null,
|
||
source.SupportsTranscoding === false ? 'Server sagt: kann nicht umrechnen' : null,
|
||
].filter(Boolean).join(' · ');
|
||
|
||
diagnose.text = auskunft;
|
||
diagnose.url = url;
|
||
|
||
v.addEventListener('loadedmetadata', () => {
|
||
v.playbackRate = P.rate;
|
||
// Zurück an die Stelle, an der umgeschaltet wurde. Ohne das begänne jeder
|
||
// Wechsel der Tonspur den Film von vorn.
|
||
if (lauf.stelle > 1) v.currentTime = lauf.stelle;
|
||
v.play();
|
||
}, { once: true });
|
||
wireProgress(v, { id: info.PlaySessionId, sourceID: source.Id || item.Id, itemID: item.Id });
|
||
|
||
if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; return; }
|
||
|
||
const boot = () => {
|
||
const h = new Hls();
|
||
v.__hls = h;
|
||
h.on(Hls.Events.ERROR, (_, data) => {
|
||
if (!data.fatal) return;
|
||
fail('Die Wiedergabe ist abgebrochen.',
|
||
`${data.type}: ${data.details}` + (data.response?.code ? ` (HTTP ${data.response.code})` : ''));
|
||
h.destroy();
|
||
});
|
||
h.loadSource(url); h.attachMedia(v);
|
||
};
|
||
if (window.Hls) return boot();
|
||
|
||
const tag = document.createElement('script');
|
||
tag.src = 'https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.13/hls.min.js';
|
||
tag.onload = boot;
|
||
tag.onerror = () => fail('Die Wiedergabe braucht hls.js.',
|
||
'Das Skript ließ sich nicht laden — ohne Internetzugang oder mit einem Blocker davor geht es nicht.');
|
||
document.head.appendChild(tag);
|
||
}
|
||
|
||
/* Handelt mit dem Server aus, wie abgespielt wird.
|
||
----------------------------------------------------------------------------
|
||
Das Geräteprofil sagt, was dieses Ende kann; die Antwort sagt, was daraus
|
||
folgt. Leere DirectPlayProfiles heißt: nichts geht unverändert, rechne um.
|
||
Im Browser ist das die ehrliche Auskunft — ein <video>-Element kann kein
|
||
MKV und kein DTS. */
|
||
async function playbackInfo(item, lauf = {}) {
|
||
const grenze = (lauf.quality ?? P.quality) > 0 ? (lauf.quality ?? P.quality) : 20000;
|
||
const profile = {
|
||
MaxStreamingBitrate: grenze * 1000,
|
||
DirectPlayProfiles: [],
|
||
TranscodingProfiles: [{
|
||
Type: 'Video', Container: 'ts', Protocol: 'hls', VideoCodec: 'h264',
|
||
AudioCodec: 'aac,mp3', Context: 'Streaming', MaxAudioChannels: '2',
|
||
MinSegments: 1, BreakOnNonKeyFrames: true,
|
||
}],
|
||
SubtitleProfiles: [{ Format: 'vtt', Method: 'Hls' }, { Format: 'srt', Method: 'External' }],
|
||
};
|
||
|
||
return api(`/Items/${item.Id}/PlaybackInfo?` + new URLSearchParams({ userId: S.userId }), {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
DeviceProfile: profile, UserId: S.userId,
|
||
MaxStreamingBitrate: profile.MaxStreamingBitrate,
|
||
EnableDirectPlay: false, EnableDirectStream: false,
|
||
EnableTranscoding: true, AutoOpenLiveStream: true,
|
||
...(lauf.audio != null ? { AudioStreamIndex: lauf.audio } : {}),
|
||
...(lauf.subtitle >= 0 ? { SubtitleStreamIndex: lauf.subtitle } : {}),
|
||
}),
|
||
});
|
||
}
|
||
|
||
/* Meldet dem Server, wo man steht.
|
||
----------------------------------------------------------------------------
|
||
Ohne das erscheint der Titel dort nie als „läuft gerade", und der
|
||
Fortschritt wird nie gespeichert — „Weiterschauen" wäre im Web für immer
|
||
eine leere Reihe. Alle zehn Sekunden genügt. */
|
||
function wireProgress(video, session) {
|
||
const send = (path, extra = {}) => {
|
||
// Absichtlich ohne await: eine verlorene Meldung kostet ein paar Sekunden
|
||
// Fortschritt. Dafür mitten im Film einen Fehler zu zeigen wäre die
|
||
// schlechtere Hälfte des Tauschs.
|
||
api(path, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
ItemId: session.itemID, PlaySessionId: session.id,
|
||
MediaSourceId: session.sourceID,
|
||
PositionTicks: Math.round(video.currentTime * 10_000_000), ...extra,
|
||
}),
|
||
}).catch(() => {});
|
||
};
|
||
|
||
video.addEventListener('playing', () => send('/Sessions/Playing'), { once: true });
|
||
|
||
let last = 0;
|
||
video.addEventListener('timeupdate', () => {
|
||
if (video.currentTime - last < 10) return;
|
||
last = video.currentTime;
|
||
send('/Sessions/Playing/Progress', { IsPaused: video.paused });
|
||
});
|
||
|
||
const stop = () => send('/Sessions/Playing/Stopped');
|
||
video.addEventListener('ended', stop);
|
||
video.addEventListener('emptied', stop);
|
||
}
|
||
|
||
/* Eine Adresse mit Anmeldung im Anhang.
|
||
----------------------------------------------------------------------------
|
||
Für alles, was nicht `api()` abruft, sondern das <video>-Element selbst:
|
||
es schickt keine Kopfzeilen mit. Denselben Umweg geht die App. */
|
||
function streamURL(path, query) {
|
||
const u = new URL(S.base + path);
|
||
Object.entries(query).forEach(([k, val]) => val !== '' && u.searchParams.set(k, val));
|
||
return u.href;
|
||
}
|
||
|
||
/* Die Symbole der Oberfläche.
|
||
----------------------------------------------------------------------------
|
||
Als Vektor, nicht als Schriftzeichen. Ein ▶ oder ein 🔊 wird auf jeder
|
||
Plattform anders gezeichnet, trägt fremde Farben, sitzt schief auf der
|
||
Grundlinie und lässt sich weder in der Strichstärke noch in der Farbe
|
||
anpassen — es sieht nach Platzhalter aus, weil es einer ist.
|
||
|
||
`currentColor` erbt Farbe und Zustand vom Knopf darum; `stroke-width` bleibt
|
||
überall gleich, damit die Symbole zueinander passen. */
|
||
const ICONS = {
|
||
play: '<path d="M8 5.5v13l11-6.5z" fill="currentColor" stroke="none"/>',
|
||
waveform: '<path d="M3 12h2M7 8v8M11 4.5v15M15 8v8M19 11h2"/>',
|
||
captions: '<rect x="3" y="5.5" width="18" height="13" rx="3"/><path d="M9.5 10.5a2.2 2.2 0 100 3M16 10.5a2.2 2.2 0 100 3"/>',
|
||
captionsOn: '<rect x="3" y="5.5" width="18" height="13" rx="3" fill="currentColor" stroke="none"/><path d="M9.5 10.5a2.2 2.2 0 100 3M16 10.5a2.2 2.2 0 100 3" stroke="#0B0B0D"/>',
|
||
check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
||
pause: '<path d="M9 5.5v13M15 5.5v13"/>',
|
||
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
|
||
sound: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4z" fill="currentColor"/><path d="M16 9a4 4 0 010 6"/><path d="M18.5 6.5a7.5 7.5 0 010 11"/>',
|
||
muted: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4z" fill="currentColor"/><path d="M16 9.5l5 5M21 9.5l-5 5"/>',
|
||
expand: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
|
||
left: '<path d="M14.5 5.5L8 12l6.5 6.5"/>',
|
||
right: '<path d="M9.5 5.5L16 12l-6.5 6.5"/>',
|
||
up: '<path d="M12 18.5v-13M6.5 11L12 5.5 17.5 11"/>',
|
||
down: '<path d="M12 5.5v13M6.5 13L12 18.5 17.5 13"/>',
|
||
};
|
||
|
||
/// Ein Symbol als Vektor. `size` nur, wo es von der Vorgabe abweicht.
|
||
function icon(name, size) {
|
||
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
|
||
${size ? `style="width:${size}px;height:${size}px"` : ''}>${ICONS[name]}</svg>`;
|
||
}
|
||
|
||
/* ---------- 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>
|