Es fehlte der erste Schritt. Die Stream-Adresse wurde selbst
zusammengebaut, und damit fehlten PlaySessionId, die ausgehandelte
MediaSourceId und die Adresse, die der Server eigentlich liefert. Im
Swift-Code steht seit jeher, warum das nicht geht: "eine selbst
zusammengebaute Adresse wäre eine Vermutung darüber."
Jetzt vier Schritte wie in der App: PlaybackInfo mit Geräteprofil, die
Quelle aus der Antwort wählen, die Adresse des Servers nehmen, die
Sitzungskennung mitführen.
Vor allem aber sind Fehler nicht mehr unsichtbar. Vorher endete jeder
Fehlschlag in einem stummen schwarzen Kasten — ein 401 sah aus wie ein
404 und wie ein Netzproblem. Geprüft: Ablehnung, fehlende Quelle und
nicht abspielbares Format nennen jetzt alle drei Ross und Reiter.
Serien und Staffeln haben keinen Abspielknopf mehr. Sie sind Ordner;
/Videos/{serienId} zeigt auf nichts. Wer über eine Serienkachel getestet
hat, konnte gar nichts starten.
Dazu Fortschrittsmeldung an /Sessions/Playing — ohne die erscheint der
Titel auf dem Server nie als "läuft gerade", und "Weiterschauen" bliebe
im Web für immer leer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1597 lines
65 KiB
HTML
1597 lines
65 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<title>Flixy</title>
|
||
<style>
|
||
/* ============================================================================
|
||
Flixy Web — ein Frontend für Jellyfin
|
||
----------------------------------------------------------------------------
|
||
Die Werte stammen aus FlixyDesign, nicht aus dem Augenmaß: dieselben
|
||
Flächen, dieselben Radien, dieselben Abstände wie in der App.
|
||
========================================================================= */
|
||
:root {
|
||
--accent: #C04AC9; /* Orchid — wie auf dem iPad eingestellt */
|
||
--canvas: #0B0B0D;
|
||
--surface: #141417;
|
||
--elevated: #1D1D21;
|
||
--placeholder: #26262B;
|
||
--text: #F5F5F7;
|
||
--text-2: #A0A0A8;
|
||
--text-3: #6E6E76;
|
||
--separator: rgba(255,255,255,.10);
|
||
|
||
--r-card: 8px;
|
||
--r-control: 10px;
|
||
--r-sheet: 16px;
|
||
--r-panel: 22px;
|
||
|
||
--s-tight: 4px; --s-snug: 8px; --s-regular: 12px;
|
||
--s-loose: 16px; --s-section: 24px; --s-margin: 16px;
|
||
|
||
/* Die Kachelbreite. Der Regler in den Einstellungen verstellt genau diese
|
||
eine Zahl — Reihen, Raster und Querformat rechnen sich daraus ab. */
|
||
--card-w: 150px;
|
||
--sidebar-w: 235px;
|
||
|
||
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text",
|
||
"Segoe UI Variable Text", "Segoe UI", system-ui, Roboto, sans-serif;
|
||
}
|
||
|
||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||
|
||
html, body {
|
||
margin: 0; padding: 0; height: 100%;
|
||
background: var(--canvas); color: var(--text);
|
||
font-family: var(--font); font-size: 15px; line-height: 1.4;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
body { overflow-x: hidden; }
|
||
button { font-family: inherit; cursor: pointer; border: none; }
|
||
img { display: block; }
|
||
|
||
.hidden { display: none !important; }
|
||
|
||
/* ---- Anmeldung ----------------------------------------------------------- */
|
||
#login {
|
||
min-height: 100dvh; display: grid; place-items: center; padding: var(--s-loose);
|
||
}
|
||
.login-card {
|
||
width: min(400px, 100%); display: flex; flex-direction: column; gap: var(--s-regular);
|
||
}
|
||
.login-card h1 {
|
||
font-size: 34px; font-weight: 700; margin: 0 0 var(--s-tight);
|
||
letter-spacing: -.02em; text-align: center;
|
||
}
|
||
.login-card .sub {
|
||
color: var(--text-2); text-align: center; margin: 0 0 var(--s-section);
|
||
}
|
||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||
.field label { font-size: 13px; color: var(--text-2); }
|
||
.field input {
|
||
background: var(--elevated); border: 1px solid var(--separator);
|
||
border-radius: var(--r-control); color: var(--text);
|
||
padding: 11px var(--s-regular); font-size: 15px; font-family: inherit;
|
||
}
|
||
.field input:focus { outline: none; border-color: var(--accent); }
|
||
|
||
.btn {
|
||
border-radius: 999px; padding: 12px var(--s-section); font-size: 15px;
|
||
font-weight: 600; background: var(--elevated); color: var(--text);
|
||
transition: filter .15s ease;
|
||
}
|
||
.btn:hover { filter: brightness(1.25); }
|
||
.btn-primary { background: var(--accent); color: #fff; }
|
||
.btn-row { display: flex; gap: var(--s-regular); flex-wrap: wrap; }
|
||
|
||
.error {
|
||
color: #F08A8D; font-size: 13px; min-height: 18px; text-align: center;
|
||
}
|
||
.note { color: var(--text-3); font-size: 12px; text-align: center; margin-top: var(--s-snug); }
|
||
|
||
/* ---- Die schwebende Navigationsinsel -------------------------------------
|
||
Das Merkmal der App: eine Fläche mit Abstand ringsum, hinter der der
|
||
Inhalt durchläuft. Sie blendet beim Scrollen aus und kommt zurück, sobald
|
||
man wieder oben steht — genau wie in der App. */
|
||
#chrome {
|
||
position: fixed; top: calc(10px + env(safe-area-inset-top)); left: 0; right: 0;
|
||
z-index: 50; display: flex; justify-content: center;
|
||
transition: transform .28s cubic-bezier(.32,.72,0,1), opacity .28s ease;
|
||
pointer-events: none;
|
||
}
|
||
#chrome.away { transform: translateY(-140%); opacity: 0; }
|
||
|
||
.island {
|
||
pointer-events: auto;
|
||
display: flex; align-items: center; gap: var(--s-tight);
|
||
padding: var(--s-snug);
|
||
background: rgba(20,20,23,.72);
|
||
backdrop-filter: saturate(180%) blur(20px);
|
||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||
border: 1px solid var(--separator);
|
||
border-radius: var(--r-panel);
|
||
box-shadow: 0 8px 24px rgba(0,0,0,.45);
|
||
max-width: calc(100vw - 24px); overflow-x: auto; scrollbar-width: none;
|
||
}
|
||
.island::-webkit-scrollbar { display: none; }
|
||
|
||
.island button {
|
||
background: none; color: var(--text); font-size: 15px; white-space: nowrap;
|
||
padding: var(--s-snug) var(--s-loose); border-radius: 999px;
|
||
}
|
||
.island button.active { background: var(--elevated); color: var(--accent); }
|
||
.island .sep { width: 1px; height: 22px; background: var(--separator); margin: 0 var(--s-tight); }
|
||
|
||
/* Der Umschalter zwischen oben und seitlich — wie auf dem iPad ein Knopf in
|
||
der Leiste selbst. Ihn in die Einstellungen zu verbannen hieße, für einen
|
||
Handgriff zwei Ebenen tief zu gehen. */
|
||
.island .toggle-nav {
|
||
padding: var(--s-snug); color: var(--text-2); display: flex; align-items: center;
|
||
border-radius: var(--r-control); flex: 0 0 auto;
|
||
}
|
||
.island .toggle-nav:hover { color: var(--text); background: var(--elevated); }
|
||
.island .toggle-nav svg { width: 18px; height: 18px; display: block; }
|
||
#app.side .island .toggle-nav { align-self: flex-end; margin: 0 var(--s-snug) var(--s-snug); }
|
||
|
||
/* ---- Aufmacher -----------------------------------------------------------
|
||
Randlos bis an den oberen Rand, die Insel schwebt darüber. Genau die
|
||
Entscheidung, die in der App getroffen wurde. */
|
||
#hero {
|
||
position: relative; height: var(--hero-h, 82vh); min-height: 380px; overflow: hidden;
|
||
}
|
||
#hero .shot {
|
||
position: absolute; inset: 0; background-size: cover; background-position: center;
|
||
opacity: 0; transition: opacity .55s ease;
|
||
}
|
||
#hero .shot.on { opacity: 1; }
|
||
#hero .veil {
|
||
position: absolute; inset: 0;
|
||
background:
|
||
linear-gradient(to bottom, rgba(0,0,0,.45) 0, rgba(0,0,0,0) 150px),
|
||
linear-gradient(to bottom, rgba(11,11,13,0) 45%, rgba(11,11,13,.75) 78%, var(--canvas) 100%);
|
||
}
|
||
#hero .body {
|
||
position: absolute; left: 0; right: 0; bottom: 0;
|
||
padding: 0 var(--s-margin) var(--s-section);
|
||
display: flex; flex-direction: column; gap: var(--s-snug);
|
||
}
|
||
#hero .logo { max-width: 286px; max-height: 84px; object-fit: contain; } /* 10 % über der App-Größe, auf Wunsch */
|
||
#hero h2 { font-size: 34px; font-weight: 700; margin: 0; letter-spacing: -.02em; }
|
||
#hero .meta { color: var(--text-2); font-size: 13px; }
|
||
#hero .summary {
|
||
color: var(--text-2); font-size: 13px; max-width: 640px;
|
||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||
}
|
||
#hero .actions { display: flex; gap: var(--s-regular); margin-top: var(--s-tight); }
|
||
.dots { display: flex; gap: var(--s-snug); justify-content: center; padding: var(--s-regular) 0 var(--s-section); }
|
||
.dots button {
|
||
width: 6px; height: 6px; border-radius: 999px; background: rgba(255,255,255,.35);
|
||
padding: 0; transition: width .25s ease, background .25s ease;
|
||
}
|
||
.dots button:hover { background: rgba(255,255,255,.6); }
|
||
.dots button.on { width: 18px; background: var(--accent); }
|
||
|
||
/* Blättern von Hand. Die Pfeile liegen auf dem Bild und halten sich zurück,
|
||
bis der Zeiger da ist — auf einem Zeigegerät. Wer wischt, braucht sie nicht. */
|
||
.hero-nav {
|
||
position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
|
||
width: 44px; height: 44px; border-radius: 999px; padding: 0;
|
||
background: rgba(20,20,23,.55); color: var(--text); font-size: 20px;
|
||
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||
border: 1px solid var(--separator); opacity: 0; transition: opacity .2s ease;
|
||
}
|
||
#hero:hover .hero-nav { opacity: 1; }
|
||
.hero-nav.prev { left: var(--s-margin); }
|
||
.hero-nav.next { right: var(--s-margin); }
|
||
@media (hover: none) { .hero-nav { display: none; } }
|
||
|
||
/* ---- Reihen -------------------------------------------------------------- */
|
||
.row { margin-bottom: var(--s-section); }
|
||
.row h3 {
|
||
font-size: 17px; font-weight: 600; margin: 0 0 var(--s-regular);
|
||
padding: 0 var(--s-margin); letter-spacing: -.01em;
|
||
}
|
||
.strip {
|
||
display: flex; gap: var(--s-regular); overflow-x: auto;
|
||
padding: 0 var(--s-margin) var(--s-tight); scroll-snap-type: x proximity;
|
||
scrollbar-width: none;
|
||
}
|
||
.strip::-webkit-scrollbar { display: none; }
|
||
|
||
.card { flex: 0 0 auto; width: var(--card-w); scroll-snap-align: start; background: none; padding: 0; text-align: left; }
|
||
.card.wide { width: calc(var(--card-w) * 1.73); }
|
||
.card .art {
|
||
width: 100%; aspect-ratio: 2/3; border-radius: var(--r-card);
|
||
background: var(--placeholder) center/cover no-repeat;
|
||
position: relative; overflow: hidden;
|
||
}
|
||
.card.wide .art { aspect-ratio: 16/9; }
|
||
.card .title {
|
||
font-size: 13px; color: var(--text); margin-top: 6px;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.card .sub { font-size: 12px; color: var(--text-3); }
|
||
.card .bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(255,255,255,.2); }
|
||
.card .bar span { display: block; height: 100%; background: var(--accent); }
|
||
|
||
/* ---- Raster (Mediathek) -------------------------------------------------- */
|
||
.grid {
|
||
display: grid; grid-template-columns: repeat(auto-fill, minmax(calc(var(--card-w) * .75), 1fr));
|
||
gap: var(--s-loose) var(--s-regular); padding: 0 var(--s-margin) var(--s-section);
|
||
}
|
||
.grid .card { width: auto; }
|
||
|
||
/* ---- Detailblatt --------------------------------------------------------- */
|
||
#sheet {
|
||
position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,.55);
|
||
display: grid; place-items: center; padding: var(--s-loose);
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
#sheet .panel {
|
||
background: var(--elevated); border: 1px solid var(--separator);
|
||
border-radius: var(--r-sheet); width: min(680px, 100%);
|
||
max-height: 86dvh; overflow: auto; padding: var(--s-section);
|
||
}
|
||
#sheet h2 { margin: 0 0 var(--s-snug); font-size: 24px; letter-spacing: -.02em; }
|
||
#sheet .meta { color: var(--text-2); font-size: 13px; margin-bottom: var(--s-regular); }
|
||
#sheet p { color: var(--text-2); font-size: 14px; }
|
||
#sheet video { width: 100%; border-radius: var(--r-card); background: #000; margin-top: var(--s-regular); }
|
||
.playerbar {
|
||
display: flex; align-items: center; gap: var(--s-regular);
|
||
margin-top: var(--s-regular); flex-wrap: wrap;
|
||
}
|
||
.playerbar .btn { padding: 8px var(--s-loose); font-size: 14px; }
|
||
.playerbar label { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: var(--s-snug); }
|
||
.playerbar .pick { width: auto; padding: 7px var(--s-regular); }
|
||
.playerror {
|
||
margin-top: var(--s-regular); padding: var(--s-loose);
|
||
background: rgba(229,72,77,.12); border: 1px solid rgba(229,72,77,.35);
|
||
border-radius: var(--r-control); display: flex; flex-direction: column; gap: 6px;
|
||
}
|
||
.playerror strong { color: #F08A8D; font-weight: 600; font-size: 14px; }
|
||
.playerror span { color: var(--text-2); font-size: 13px; }
|
||
|
||
/* ---- 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">▶ Abspielen</button>
|
||
<button class="btn">Details</button>
|
||
</div>`;
|
||
body.querySelector('.btn-primary').onclick = () => openSheet(it, true);
|
||
body.querySelectorAll('.btn')[1].onclick = () => openSheet(it, false);
|
||
shots.forEach((s, i) => s.classList.toggle('on', i === at));
|
||
[...dots.children].forEach((d, i) => d.classList.toggle('on', i === at));
|
||
};
|
||
paint();
|
||
|
||
if (items.length > 1) {
|
||
const prev = document.createElement('button');
|
||
prev.className = 'hero-nav prev'; prev.innerHTML = '‹';
|
||
prev.setAttribute('aria-label', 'Vorheriger Titel');
|
||
prev.onclick = () => step(-1);
|
||
|
||
const next = document.createElement('button');
|
||
next.className = 'hero-nav next'; next.innerHTML = '›';
|
||
next.setAttribute('aria-label', 'Nächster Titel');
|
||
next.onclick = () => step(1);
|
||
|
||
wrap.append(prev, next);
|
||
|
||
// Wischen — erst ab einem ordentlichen Weg und nur, wenn es waagerecht
|
||
// gemeint ist. Sonst nimmt die Geste dem Scrollen den Vortritt.
|
||
let x0 = null, y0 = null;
|
||
wrap.addEventListener('touchstart', (e) => {
|
||
x0 = e.touches[0].clientX; y0 = e.touches[0].clientY;
|
||
}, { passive: true });
|
||
wrap.addEventListener('touchend', (e) => {
|
||
if (x0 == null) return;
|
||
const dx = e.changedTouches[0].clientX - x0;
|
||
const dy = e.changedTouches[0].clientY - y0;
|
||
if (Math.abs(dx) > 48 && Math.abs(dx) > Math.abs(dy)) step(dx < 0 ? 1 : -1);
|
||
x0 = y0 = null;
|
||
}, { passive: true });
|
||
|
||
// Pfeiltasten, solange kein Blatt offen ist.
|
||
const keys = (e) => {
|
||
if (!document.body.contains(wrap) || document.getElementById('sheet')) return;
|
||
if (e.key === 'ArrowRight') step(1);
|
||
if (e.key === 'ArrowLeft') step(-1);
|
||
};
|
||
addEventListener('keydown', keys);
|
||
|
||
timer = setInterval(() => {
|
||
if (!document.body.contains(wrap)) {
|
||
stop(); removeEventListener('keydown', keys); return;
|
||
}
|
||
at = (at + 1) % items.length; paint();
|
||
}, 7000); // sieben Sekunden — lang genug, um den Satz zu Ende zu lesen
|
||
}
|
||
|
||
const outer = document.createElement('div');
|
||
outer.appendChild(wrap); outer.appendChild(dots);
|
||
return outer;
|
||
}
|
||
|
||
function buildRow({ title, items, wide }) {
|
||
const sec = document.createElement('section');
|
||
sec.className = 'row';
|
||
sec.innerHTML = `<h3>${esc(title)}</h3><div class="strip"></div>`;
|
||
const strip = sec.querySelector('.strip');
|
||
items.forEach((it) => strip.appendChild(buildCard(it, wide)));
|
||
return sec;
|
||
}
|
||
|
||
function buildCard(it, wide) {
|
||
const b = document.createElement('button');
|
||
b.className = 'card' + (wide ? ' wide' : '');
|
||
const pct = it.UserData?.PlayedPercentage;
|
||
const art = artFor(it, wide ? 'Backdrop' : 'Primary', wide ? 500 : 300);
|
||
b.innerHTML = `
|
||
<div class="art" ${art ? `style="background-image:url('${art}')"` : ''}>
|
||
${pct ? `<div class="bar"><span style="width:${Math.min(pct, 100)}%"></span></div>` : ''}
|
||
</div>
|
||
<div class="title">${esc(it.SeriesName || it.Name)}</div>
|
||
<div class="sub">${esc(cardSub(it))}</div>`;
|
||
b.onclick = () => openSheet(it, false);
|
||
return b;
|
||
}
|
||
|
||
const cardSub = (it) =>
|
||
it.Type === 'Episode' && it.ParentIndexNumber != null
|
||
? `S${it.ParentIndexNumber}:E${it.IndexNumber} · ${it.Name}`
|
||
: (it.ProductionYear || '');
|
||
|
||
/* 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' : ''}>↑</button>
|
||
<button class="tiny" aria-label="Nach unten" ${index === ordered.length - 1 ? 'disabled' : ''}>↓</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. `/Videos/{serienId}` zeigt auf nichts
|
||
// Abspielbares — ein Knopf dafür verspricht etwas, das nicht eingelöst wird.
|
||
const playable = !['Series', 'Season', '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">▶ Abspielen</button>' : ''}
|
||
<button class="btn" id="sheet-close">Schließen</button>
|
||
</div>
|
||
${playable ? '' : '<p class="hint">Eine Serie spielt man nicht ab — öffne eine Folge.</p>'}
|
||
</div>`;
|
||
back.onclick = (e) => { if (e.target === back) back.remove(); };
|
||
document.body.appendChild(back);
|
||
back.querySelector('#sheet-close').onclick = () => back.remove();
|
||
if (playable) {
|
||
back.querySelector('#sheet-play').onclick = () => play(item, back.querySelector('.panel'));
|
||
if (playNow) play(item, back.querySelector('.panel'));
|
||
}
|
||
}
|
||
|
||
/* Spielt einen Titel ab — auf dem Weg, den der Server vorgibt.
|
||
----------------------------------------------------------------------------
|
||
**Vier Schritte, und der erste ist der wichtigste.** Vorher stand hier nur
|
||
der dritte: eine selbst zusammengebaute `master.m3u8`-Adresse. Die App sagt
|
||
dazu seit jeher das Richtige — „die Adresse kommt vom Server, nicht von
|
||
uns. Er weiß, welche Spuren er kopieren kann und welche er neu rechnen
|
||
muss; eine selbst zusammengebaute Adresse wäre eine Vermutung darüber."
|
||
|
||
Ohne den Aushandlungsschritt fehlen `PlaySessionId`, die ausgehandelte
|
||
`MediaSourceId` und die Adresse selbst. Der Server bekam eine Anfrage ohne
|
||
Sitzung und ohne Gerät — und antwortete entsprechend.
|
||
|
||
1. `POST /Items/{id}/PlaybackInfo` mit einem Geräteprofil
|
||
2. Die Quelle aus der Antwort wählen, nicht die Eintragskennung annehmen
|
||
3. Die Adresse des Servers nehmen
|
||
4. Die Sitzungskennung mitführen: für den Fortschritt und fürs Aufräumen */
|
||
async function play(item, panel) {
|
||
panel.querySelector('video')?.remove();
|
||
panel.querySelector('.playerbar')?.remove();
|
||
panel.querySelector('.playerror')?.remove();
|
||
|
||
const fail = (satz, hinweis) => {
|
||
panel.querySelector('video')?.remove();
|
||
const box = document.createElement('div');
|
||
box.className = 'playerror';
|
||
box.innerHTML = `<strong>${esc(satz)}</strong>${hinweis ? `<span>${esc(hinweis)}</span>` : ''}`;
|
||
panel.appendChild(box);
|
||
};
|
||
|
||
const v = document.createElement('video');
|
||
v.controls = true; v.autoplay = true; v.playsInline = true;
|
||
panel.appendChild(v);
|
||
|
||
// **Fehler sichtbar machen.** Vorher endete jeder Fehlschlag in einem
|
||
// stummen schwarzen Kasten: kein Ereignis abgehört, keine Meldung. Ein 401
|
||
// sah aus wie ein 404 und wie ein Netzproblem.
|
||
v.addEventListener('error', () => {
|
||
const c = v.error?.code;
|
||
fail('Der Browser kann diesen Strom nicht abspielen.',
|
||
c === 4 ? 'Das Format wird nicht unterstützt — der Server sollte umrechnen, tat es aber nicht.'
|
||
: `Fehlercode ${c ?? '?'}. Im Netzwerk-Tab steht, was der Server geantwortet hat.`);
|
||
});
|
||
|
||
let info;
|
||
try {
|
||
info = await playbackInfo(item);
|
||
} 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.',
|
||
'Bei einer Serie oder Staffel ist das richtig — dort gibt es nur Folgen zum Abspielen.');
|
||
}
|
||
|
||
// Die Adresse des Servers, nicht unsere. Sie trägt DeviceId, PlaySessionId
|
||
// und die ausgehandelten Codec-Angaben bereits in sich.
|
||
let url = source.TranscodingUrl
|
||
? new URL(source.TranscodingUrl, S.base).href
|
||
: client_url(`/Videos/${item.Id}/stream`, {
|
||
static: 'true', mediaSourceId: source.Id || item.Id,
|
||
api_key: S.token, PlaySessionId: info.PlaySessionId || '',
|
||
});
|
||
|
||
v.addEventListener('loadedmetadata', () => { v.playbackRate = P.rate; }, { once: true });
|
||
panel.appendChild(playerBar(v));
|
||
|
||
// Fortschritt melden, damit „Weiterschauen" auch im Web etwas bedeutet.
|
||
const session = { id: info.PlaySessionId, sourceID: source.Id || item.Id, itemID: item.Id };
|
||
wireProgress(v, session);
|
||
|
||
if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; return; }
|
||
|
||
const boot = () => {
|
||
const h = new Hls();
|
||
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. Die Werte entsprechen denen der App (`playbackInfo` in
|
||
JellyfinBackend+Playback.swift) — leere DirectPlayProfiles heißt: nichts
|
||
geht unverändert, rechne um. Im Browser ist das die ehrliche Auskunft, denn
|
||
ein <video>-Element kann kein MKV und kein DTS. */
|
||
async function playbackInfo(item) {
|
||
const profile = {
|
||
MaxStreamingBitrate: (P.quality > 0 ? P.quality : 20000) * 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' }],
|
||
};
|
||
|
||
const body = {
|
||
DeviceProfile: profile,
|
||
UserId: S.userId,
|
||
MaxStreamingBitrate: profile.MaxStreamingBitrate,
|
||
EnableDirectPlay: false,
|
||
EnableDirectStream: false,
|
||
EnableTranscoding: true,
|
||
AutoOpenLiveStream: true,
|
||
};
|
||
|
||
return api(`/Items/${item.Id}/PlaybackInfo?` + new URLSearchParams({ userId: S.userId }), {
|
||
method: 'POST',
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
/* 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 eine leere
|
||
Reihe. Alle zehn Sekunden genügt; häufiger zu melden ändert nichts außer
|
||
der Last. */
|
||
function wireProgress(video, session) {
|
||
const ticks = () => Math.round(video.currentTime * 10_000_000);
|
||
const send = (path, extra = {}) => {
|
||
// Absichtlich ohne await und ohne catch: 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: ticks(), ...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);
|
||
}
|
||
|
||
/* Die Spulknöpfe und die Tempowahl, die es im nackten <video> nicht gibt. */
|
||
function playerBar(video) {
|
||
const bar = document.createElement('div');
|
||
bar.className = 'playerbar';
|
||
bar.innerHTML = `
|
||
<button class="btn">−${P.skipBackward}s</button>
|
||
<button class="btn">+${P.skipForward}s</button>
|
||
<label>Tempo <select class="pick"></select></label>`;
|
||
|
||
const [back, fwd] = bar.querySelectorAll('button');
|
||
back.onclick = () => { video.currentTime = Math.max(0, video.currentTime - P.skipBackward); };
|
||
fwd.onclick = () => { video.currentTime = Math.min(video.duration || 1e9, video.currentTime + P.skipForward); };
|
||
|
||
const rate = bar.querySelector('select');
|
||
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);
|
||
});
|
||
// Nur für diese Wiedergabe — die Voreinstellung bleibt, was sie ist.
|
||
rate.onchange = () => { video.playbackRate = +rate.value; };
|
||
return bar;
|
||
}
|
||
|
||
/* Eine Adresse mit Anmeldung im Anhang.
|
||
----------------------------------------------------------------------------
|
||
Für alles, was nicht `api()` abruft, sondern das <video>-Element selbst:
|
||
es schickt keine Kopfzeilen mit. Genau denselben Umweg geht die App. */
|
||
function client_url(path, query) {
|
||
const u = new URL(S.base + path);
|
||
Object.entries(query).forEach(([k, v]) => v !== '' && u.searchParams.set(k, v));
|
||
return u.href;
|
||
}
|
||
|
||
/* ---------- 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>
|