Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2226 lines
94 KiB
HTML
2226 lines
94 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>Halo</title>
|
||
<style>
|
||
/* ============================================================================
|
||
Halo Web — ein Frontend für Jellyfin
|
||
----------------------------------------------------------------------------
|
||
Die Werte stammen aus HaloDesign, 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; }
|
||
|
||
/* **Die Fläche selbst nimmt keine Klicks entgegen, nur ihre Knöpfe.**
|
||
Sonst liegt sie über dem Bild und schluckt jeden Klick — und „ins Bild
|
||
tippen hält an" funktioniert genau dann nicht, wenn die Bedienung zu
|
||
sehen ist, also fast immer. */
|
||
.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; }
|
||
.pl-chrome.on button,
|
||
.pl-chrome.on .pl-track,
|
||
.pl-chrome.on select { 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 — jetzt ohne Kreis darum, also ohne Widerspruch. */
|
||
.pl-quality { font-size: 14px; 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); }
|
||
|
||
/* **Keine Knopfflächen.** In der App sind das blanke weiße Symbole auf dem
|
||
Bild — der einzige Hintergrund ist die Abdunklung über allem. Runde
|
||
Flächen darunter waren meine Erfindung, und sie sind der Grund, warum
|
||
„Original" als Text darin schief aussah: ein Wort passt in keinen Kreis. */
|
||
.pl-icon {
|
||
background: none; border: none; color: #fff; flex: 0 0 auto;
|
||
padding: var(--s-snug); font-size: 15px; border-radius: var(--r-control);
|
||
opacity: .9; transition: opacity .15s ease, background .15s ease;
|
||
}
|
||
.pl-icon:hover { opacity: 1; background: rgba(255,255,255,.12); }
|
||
/* Eine Vorgabegröße für alle Symbole, damit sie zueinander passen; einzelne
|
||
überschreiben sie über `icon(name, size)`. */
|
||
.pl-icon svg, .tiny svg, .hero-nav 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);
|
||
}
|
||
/* Auch hier keine Fläche — nur das Symbol, wie in der App. Lesbar bleibt es
|
||
durch die Abdunklung, die über dem ganzen Bild liegt. */
|
||
.pl-round {
|
||
background: none; border: none; color: #fff; padding: var(--s-snug);
|
||
opacity: .95; transition: opacity .15s ease, transform .15s ease;
|
||
}
|
||
.pl-round:hover { opacity: 1; transform: scale(1.08); }
|
||
|
||
/* Die Sekundenzahl als Text, nicht als Ziffer im Pfeil: übereinander war
|
||
beides unlesbar, und die Zahl ist die eigentliche Auskunft. */
|
||
|
||
|
||
/* Wie in der App: der Regler über die **volle Breite**, die Zeiten darunter.
|
||
Nebeneinander gestellt nimmt er in der Mitte Platz weg und wirkt wie ein
|
||
Formularfeld zwischen zwei Beschriftungen. */
|
||
.pl-times {
|
||
display: flex; justify-content: space-between;
|
||
margin-top: var(--s-tight);
|
||
font-size: 13px; color: rgba(255,255,255,.8);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.pl-track {
|
||
position: relative; width: 100%; 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-spacer { flex: 1; }
|
||
|
||
/* 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. */
|
||
/* Während des ersten Ladens haben Vor- und Zurückspulen keine Wirkung — es
|
||
gibt noch keine Zeitachse. Der mittlere Knopf bleibt stehen: er trägt
|
||
jetzt selbst den Kreisel. */
|
||
#player.loading .pl-center > :not(.pl-play) { 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, .pl-wait {
|
||
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;
|
||
}
|
||
|
||
/* Feste Größe für den mittleren Knopf, damit die beiden daneben stehen
|
||
bleiben: der Kreisel ist schmaler als das Symbol, das er vertritt. */
|
||
.pl-round.pl-play { width: 68px; height: 68px; }
|
||
|
||
.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>Halo</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('halo.server') || '',
|
||
token: localStorage.getItem('halo.token') || '',
|
||
userId: localStorage.getItem('halo.user') || '',
|
||
deviceId: localStorage.getItem('halo.device') || (() => {
|
||
const id = 'halo-web-' + Math.random().toString(36).slice(2, 12);
|
||
localStorage.setItem('halo.device', id); return id;
|
||
})(),
|
||
};
|
||
|
||
function authHeader() {
|
||
const f = [
|
||
'Client="Halo 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('halo.server', S.base);
|
||
localStorage.setItem('halo.token', S.token);
|
||
localStorage.setItem('halo.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() {
|
||
['halo.server', 'halo.token', 'halo.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('halo.accent') || '#C04AC9',
|
||
nav: localStorage.getItem('halo.nav') || 'top', // 'top' | 'side'
|
||
cardW: +(localStorage.getItem('halo.cardW') || 150),
|
||
// Startseite — dieselben Stellschrauben wie in der App.
|
||
heroSource: localStorage.getItem('halo.heroSource') || 'continueFirst',
|
||
heroCount: +(localStorage.getItem('halo.heroCount') || 5),
|
||
heroHeight: +(localStorage.getItem('halo.heroHeight') || 82),
|
||
combineContinue: localStorage.getItem('halo.combineContinue') !== 'false',
|
||
hideWatched: localStorage.getItem('halo.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('halo.hiddenRows') || '[]'),
|
||
rowOrder: JSON.parse(localStorage.getItem('halo.rowOrder') || '[]'),
|
||
// Wiedergabe — dieselben Stufen wie in der App, damit derselbe Begriff
|
||
// überall dasselbe heißt.
|
||
rate: +(localStorage.getItem('halo.rate') || 1.0),
|
||
skipForward: +(localStorage.getItem('halo.skipForward') || 30),
|
||
skipBackward: +(localStorage.getItem('halo.skipBackward') || 10),
|
||
quality: +(localStorage.getItem('halo.quality') || 0), // 0 = Original
|
||
// Sprachen. Leer heißt: was der Server vorgibt.
|
||
audioLanguage: localStorage.getItem('halo.audioLanguage') || '',
|
||
subtitleLanguage: localStorage.getItem('halo.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('halo.' + 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', 'Halo');
|
||
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.
|
||
----------------------------------------------------------------------------
|
||
Halo 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>
|
||
<button class="pl-icon" id="pl-speed" aria-label="Geschwindigkeit">${icon('speed')}</button>
|
||
<button class="pl-icon" id="pl-mute" aria-label="Ton aus">${icon('sound')}</button>
|
||
<button class="pl-icon" id="pl-full" aria-label="Vollbild">${icon('expand')}</button>
|
||
</div>
|
||
|
||
<div class="pl-center">
|
||
<button class="pl-round" id="pl-back" aria-label="Zurückspulen">${icon('goBack', 40, P.skipBackward)}</button>
|
||
<button class="pl-round pl-play" id="pl-toggle" aria-label="Abspielen">${icon('play', 52)}</button>
|
||
<button class="pl-round" id="pl-fwd" aria-label="Vorspulen">${icon('goFwd', 40, P.skipForward)}</button>
|
||
</div>
|
||
|
||
<div class="pl-bottom">
|
||
<div class="pl-track" id="pl-track"><div class="pl-buf"></div><div class="pl-fill"></div><i></i></div>
|
||
<div class="pl-times">
|
||
<span id="pl-now">0:00</span>
|
||
<span id="pl-left">–:--</span>
|
||
</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);
|
||
|
||
/* Ob gerade auf Daten gewartet wird — am Anfang immer. */
|
||
let wartet = true;
|
||
|
||
const fail = (satz, hinweis, adresse) => {
|
||
layer.classList.remove('loading');
|
||
el2('pl-busy').classList.remove('on');
|
||
// Sonst dreht sich der Kreisel im Knopf hinter dem Fehlerkasten weiter,
|
||
// als käme noch etwas.
|
||
wartet = false;
|
||
paint();
|
||
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'); spin(); }, 3000);
|
||
}
|
||
spin();
|
||
};
|
||
|
||
/* Der freistehende Kreisel — nur, wenn die Bedienung aus ist. Sonst
|
||
stünden zwei übereinander: einer in der Mitte des Bildes und einer im
|
||
Abspielknopf, der genau davor sitzt. */
|
||
const spin = () => {
|
||
el2('pl-busy').classList.toggle('on', wartet && !chrome.classList.contains('on'));
|
||
};
|
||
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);
|
||
|
||
/* Zeichnet den mittleren Knopf: Kreisel, Dreieck oder Doppelbalken.
|
||
|
||
Solange auf Daten gewartet wird, steht dort der Kreisel — **kein**
|
||
Dreieck. Ein Dreieck heißt „angehalten, drück mich", und beides stimmte
|
||
nicht: es lief bereits, und der Klick bewirkte nichts. */
|
||
const paint = () => {
|
||
const knopf = el2('pl-toggle');
|
||
knopf.innerHTML = wartet ? '<i class="pl-wait"></i>' : icon(v.paused ? 'play' : 'pause', 52);
|
||
knopf.setAttribute('aria-label', wartet ? 'Lädt' : v.paused ? 'Abspielen' : 'Anhalten');
|
||
};
|
||
|
||
const waiting = (an) => { wartet = an; paint(); spin(); };
|
||
|
||
v.addEventListener('play', () => { paint(); show(); });
|
||
v.addEventListener('pause', () => { waiting(false); show(); });
|
||
v.addEventListener('waiting', () => waiting(true));
|
||
v.addEventListener('playing', () => {
|
||
waiting(false);
|
||
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 paintTon = () => {
|
||
el2('pl-mute').innerHTML = icon(v.muted || v.volume === 0 ? 'muted' : 'sound');
|
||
el2('pl-mute').title = `Lautstärke ${Math.round(v.volume * 100)} %`;
|
||
};
|
||
el2('pl-mute').onclick = () => { v.muted = !v.muted; paintTon(); };
|
||
|
||
// Das Tempo als Liste wie Ton und Untertitel — ein Auswahlfeld mitten in
|
||
// der Bedienleiste war das einzige Bauteil, das nach Formular aussah.
|
||
el2('pl-speed').onclick = () => zeigeListe(el2('pl-speed'), 'Geschwindigkeit',
|
||
RATES.map((r) => ({
|
||
text: r === 1 ? 'Normal' : r + '×',
|
||
gewaehlt: Math.abs(v.playbackRate - r) < 0.01,
|
||
waehlen: () => { v.playbackRate = r; closeMenu(); show(); },
|
||
})));
|
||
|
||
// Lautstärke über das Mausrad auf dem Tonknopf und über die Pfeiltasten —
|
||
// ein Schieberegler in der Leiste steht in der App auch nicht.
|
||
el2('pl-mute').addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
v.volume = Math.min(1, Math.max(0, v.volume - Math.sign(e.deltaY) * 0.05));
|
||
v.muted = v.volume === 0;
|
||
paintTon();
|
||
}, { passive: false });
|
||
|
||
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); paintTon(); },
|
||
ArrowDown: () => { v.volume = Math.max(0, v.volume - 0.1); paintTon(); },
|
||
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);
|
||
document.removeEventListener('visibilitychange', wieder);
|
||
clearTimeout(hideTimer);
|
||
if (document.fullscreenElement) document.exitFullscreen?.();
|
||
|
||
// **Erst melden, dann abräumen.** Andersherum steht die Stelle schon auf
|
||
// null, wenn die Meldung hinausgeht.
|
||
lauf.sitzung?.melden?.('/Sessions/Playing/Stopped');
|
||
|
||
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();
|
||
|
||
// Jellyfin merkt sich eine Fortsetzposition nur zwischen fünf und
|
||
// neunzig Prozent der Laufzeit (MinResumePct/MaxResumePct). Wer früher
|
||
// abbricht, findet den Titel nicht unter „Weiterschauen" — das ist keine
|
||
// Panne, sondern die Regel des Servers. Gesagt wird es trotzdem, sonst
|
||
// sucht man den Fehler bei sich.
|
||
const anteil = lauf.sitzung?.stelle && v.duration
|
||
? lauf.sitzung.stelle / v.duration : 0;
|
||
if (anteil > 0 && anteil < 0.05) {
|
||
console.info(`[Halo] Bei ${(anteil * 100).toFixed(1)} % beendet — `
|
||
+ 'Jellyfin merkt sich das erst ab 5 %. Einstellbar im Dashboard unter '
|
||
+ 'Wiedergabe → Fortsetzen.');
|
||
}
|
||
}
|
||
|
||
/* **Zurück aus dem Hintergrund.**
|
||
Wer die Anwendung wechselt, kommt zu einem stehenden Bild bei laufendem
|
||
Ton zurück: der Browser hält das Entpacken an, während der Tab verdeckt
|
||
ist, und hls.js findet danach nicht von selbst zurück in den Puffer.
|
||
Ein Anstoß an derselben Stelle setzt das Bild wieder in Gang. */
|
||
const wieder = () => {
|
||
if (document.hidden || v.paused) return;
|
||
const h = v.__hls;
|
||
if (!h) return;
|
||
const stelle = v.currentTime;
|
||
h.startLoad(stelle);
|
||
// Ein winziger Sprung erzwingt einen neuen Schlüsselbildpunkt. Ohne ihn
|
||
// bleibt das zuletzt gezeigte Bild stehen, obwohl Segmente ankommen.
|
||
if (stelle > 0.5) v.currentTime = stelle - 0.1;
|
||
};
|
||
document.addEventListener('visibilitychange', wieder);
|
||
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');
|
||
waiting(true);
|
||
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');
|
||
waiting(true);
|
||
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 });
|
||
lauf.sitzung = { id: info.PlaySessionId, sourceID: source.Id || item.Id, itemID: item.Id };
|
||
wireProgress(v, lauf.sitzung);
|
||
|
||
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) {
|
||
// **Die zuletzt bekannte Stelle, getrennt vom Element gehalten.**
|
||
//
|
||
// Das ist der Kern der Sache: Beim Schließen wird das Video abgeräumt, und
|
||
// dabei fällt `currentTime` auf null — *bevor* die Stopp-Meldung hinausgeht.
|
||
// Jellyfin bekam dann „gestoppt bei 0:00" und löschte den Fortschritt. Der
|
||
// Film war gesehen und stand trotzdem nicht unter „Weiterschauen".
|
||
session.stelle = 0;
|
||
|
||
const send = (path, stelle = session.stelle, extra = {}) => {
|
||
const ticks = Math.round(stelle * 10_000_000);
|
||
// Ohne await, aber **nicht** ohne Hinsehen: mitten im Film einen Fehler
|
||
// anzuzeigen wäre die schlechtere Hälfte des Tauschs — ihn gar nicht
|
||
// erst zu bemerken aber auch. Ein verschlucktes `catch` hat genau das
|
||
// getan, und deshalb war beim Suchen nach dem verlorenen Fortschritt
|
||
// nirgends zu sehen, ob die Meldungen überhaupt ankamen.
|
||
api(path, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
ItemId: session.itemID, PlaySessionId: session.id,
|
||
MediaSourceId: session.sourceID,
|
||
PositionTicks: ticks,
|
||
// Ohne die ordnet Jellyfin die Meldung keiner Art von Wiedergabe zu.
|
||
PlayMethod: 'Transcode', CanSeek: true,
|
||
...extra,
|
||
}),
|
||
}).then(() => {
|
||
console.debug(`[Halo] ${path} → ${(stelle / 60).toFixed(1)} min angenommen`);
|
||
}).catch((e) => {
|
||
console.warn(`[Halo] ${path} abgelehnt: ${e.message}`);
|
||
});
|
||
};
|
||
session.melden = send;
|
||
|
||
video.addEventListener('playing', () => send('/Sessions/Playing'), { once: true });
|
||
|
||
let last = 0;
|
||
video.addEventListener('timeupdate', () => {
|
||
if (video.currentTime > 0) session.stelle = video.currentTime;
|
||
if (video.currentTime - last < 10) return;
|
||
last = video.currentTime;
|
||
send('/Sessions/Playing/Progress', session.stelle, { IsPaused: video.paused });
|
||
});
|
||
|
||
// Anhalten und Weiterlaufen sofort melden: wer pausiert und weggeht, hat
|
||
// sonst bis zur nächsten Zehn-Sekunden-Marke nichts hinterlassen.
|
||
video.addEventListener('pause', () =>
|
||
send('/Sessions/Playing/Progress', session.stelle, { IsPaused: true }));
|
||
video.addEventListener('play', () =>
|
||
send('/Sessions/Playing/Progress', session.stelle, { IsPaused: false }));
|
||
|
||
// Am Ende steht die Stelle nicht mehr zur Verfügung — gemeint ist das Ende.
|
||
video.addEventListener('ended', () => send('/Sessions/Playing/Stopped', video.duration || session.stelle));
|
||
}
|
||
|
||
/* 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"/>',
|
||
speed: '<path d="M12 20a8 8 0 100-16 8 8 0 000 16z"/><path d="M12 12l4-3"/>',
|
||
// Kreispfeile mit der Sekundenzahl darin — dasselbe Bild wie
|
||
// `gobackward.10` und `goforward.30` auf dem iPad.
|
||
goBack: (n) => `<path d="M11.5 4.5L7 8l4.5 3.5" /><path d="M7 8h4.5a7.5 7.5 0 11-7.2 9.6"/>`
|
||
+ `<text x="12" y="17.6" text-anchor="middle" font-size="7.5" font-weight="700"`
|
||
+ ` fill="currentColor" stroke="none" font-family="inherit">${n}</text>`,
|
||
goFwd: (n) => `<path d="M12.5 4.5L17 8l-4.5 3.5"/><path d="M17 8h-4.5a7.5 7.5 0 107.2 9.6"/>`
|
||
+ `<text x="12" y="17.6" text-anchor="middle" font-size="7.5" font-weight="700"`
|
||
+ ` fill="currentColor" stroke="none" font-family="inherit">${n}</text>`,
|
||
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, arg) {
|
||
const inhalt = typeof ICONS[name] === 'function' ? ICONS[name](arg) : ICONS[name];
|
||
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"` : ''}>${inhalt}</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>
|