Er war nun einmal der erste in "Weiterschauen", und genommen wurden schlicht die ersten fünf der Liste. Jetzt wird gezogen — aber nicht bei jedem Blick: der Zufall hängt an einem Zeitfenster von sechs Stunden. Wer die App dreimal am Abend öffnet, findet sich zurecht; wer am nächsten Morgen wiederkommt, sieht etwas anderes. Titel ohne Hintergrundbild sind dabei nicht ausgeschlossen, sondern ziehen ein schlechteres Los. Sie ganz auszusperren war zu hart — in einer Mediathek, in der wenige Titel eines tragen, blieben immer dieselben drei übrig. Dieselbe Regel im Web, damit beide nicht auseinanderlaufen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
875 lines
34 KiB
HTML
875 lines
34 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<title>Flixy</title>
|
||
<style>
|
||
/* ============================================================================
|
||
Flixy Web — ein Frontend für Jellyfin
|
||
----------------------------------------------------------------------------
|
||
Die Werte stammen aus FlixyDesign, nicht aus dem Augenmaß: dieselben
|
||
Flächen, dieselben Radien, dieselben Abstände wie in der App.
|
||
========================================================================= */
|
||
:root {
|
||
--accent: #C04AC9; /* Orchid — wie auf dem iPad eingestellt */
|
||
--canvas: #0B0B0D;
|
||
--surface: #141417;
|
||
--elevated: #1D1D21;
|
||
--placeholder: #26262B;
|
||
--text: #F5F5F7;
|
||
--text-2: #A0A0A8;
|
||
--text-3: #6E6E76;
|
||
--separator: rgba(255,255,255,.10);
|
||
|
||
--r-card: 8px;
|
||
--r-control: 10px;
|
||
--r-sheet: 16px;
|
||
--r-panel: 22px;
|
||
|
||
--s-tight: 4px; --s-snug: 8px; --s-regular: 12px;
|
||
--s-loose: 16px; --s-section: 24px; --s-margin: 16px;
|
||
|
||
/* Die Kachelbreite. Der Regler in den Einstellungen verstellt genau diese
|
||
eine Zahl — Reihen, Raster und Querformat rechnen sich daraus ab. */
|
||
--card-w: 150px;
|
||
--sidebar-w: 235px;
|
||
|
||
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text",
|
||
"Segoe UI Variable Text", "Segoe UI", system-ui, Roboto, sans-serif;
|
||
}
|
||
|
||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||
|
||
html, body {
|
||
margin: 0; padding: 0; height: 100%;
|
||
background: var(--canvas); color: var(--text);
|
||
font-family: var(--font); font-size: 15px; line-height: 1.4;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
body { overflow-x: hidden; }
|
||
button { font-family: inherit; cursor: pointer; border: none; }
|
||
img { display: block; }
|
||
|
||
.hidden { display: none !important; }
|
||
|
||
/* ---- Anmeldung ----------------------------------------------------------- */
|
||
#login {
|
||
min-height: 100dvh; display: grid; place-items: center; padding: var(--s-loose);
|
||
}
|
||
.login-card {
|
||
width: min(400px, 100%); display: flex; flex-direction: column; gap: var(--s-regular);
|
||
}
|
||
.login-card h1 {
|
||
font-size: 34px; font-weight: 700; margin: 0 0 var(--s-tight);
|
||
letter-spacing: -.02em; text-align: center;
|
||
}
|
||
.login-card .sub {
|
||
color: var(--text-2); text-align: center; margin: 0 0 var(--s-section);
|
||
}
|
||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||
.field label { font-size: 13px; color: var(--text-2); }
|
||
.field input {
|
||
background: var(--elevated); border: 1px solid var(--separator);
|
||
border-radius: var(--r-control); color: var(--text);
|
||
padding: 11px var(--s-regular); font-size: 15px; font-family: inherit;
|
||
}
|
||
.field input:focus { outline: none; border-color: var(--accent); }
|
||
|
||
.btn {
|
||
border-radius: 999px; padding: 12px var(--s-section); font-size: 15px;
|
||
font-weight: 600; background: var(--elevated); color: var(--text);
|
||
transition: filter .15s ease;
|
||
}
|
||
.btn:hover { filter: brightness(1.25); }
|
||
.btn-primary { background: var(--accent); color: #fff; }
|
||
.btn-row { display: flex; gap: var(--s-regular); flex-wrap: wrap; }
|
||
|
||
.error {
|
||
color: #F08A8D; font-size: 13px; min-height: 18px; text-align: center;
|
||
}
|
||
.note { color: var(--text-3); font-size: 12px; text-align: center; margin-top: var(--s-snug); }
|
||
|
||
/* ---- Die schwebende Navigationsinsel -------------------------------------
|
||
Das Merkmal der App: eine Fläche mit Abstand ringsum, hinter der der
|
||
Inhalt durchläuft. Sie blendet beim Scrollen aus und kommt zurück, sobald
|
||
man wieder oben steht — genau wie in der App. */
|
||
#chrome {
|
||
position: fixed; top: calc(10px + env(safe-area-inset-top)); left: 0; right: 0;
|
||
z-index: 50; display: flex; justify-content: center;
|
||
transition: transform .28s cubic-bezier(.32,.72,0,1), opacity .28s ease;
|
||
pointer-events: none;
|
||
}
|
||
#chrome.away { transform: translateY(-140%); opacity: 0; }
|
||
|
||
.island {
|
||
pointer-events: auto;
|
||
display: flex; align-items: center; gap: var(--s-tight);
|
||
padding: var(--s-snug);
|
||
background: rgba(20,20,23,.72);
|
||
backdrop-filter: saturate(180%) blur(20px);
|
||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||
border: 1px solid var(--separator);
|
||
border-radius: var(--r-panel);
|
||
box-shadow: 0 8px 24px rgba(0,0,0,.45);
|
||
max-width: calc(100vw - 24px); overflow-x: auto; scrollbar-width: none;
|
||
}
|
||
.island::-webkit-scrollbar { display: none; }
|
||
|
||
.island button {
|
||
background: none; color: var(--text); font-size: 15px; white-space: nowrap;
|
||
padding: var(--s-snug) var(--s-loose); border-radius: 999px;
|
||
}
|
||
.island button.active { background: var(--elevated); color: var(--accent); }
|
||
.island .sep { width: 1px; height: 22px; background: var(--separator); margin: 0 var(--s-tight); }
|
||
|
||
/* ---- 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: 82vh; min-height: 420px; overflow: hidden;
|
||
}
|
||
#hero .shot {
|
||
position: absolute; inset: 0; background-size: cover; background-position: center;
|
||
opacity: 0; transition: opacity .55s ease;
|
||
}
|
||
#hero .shot.on { opacity: 1; }
|
||
#hero .veil {
|
||
position: absolute; inset: 0;
|
||
background:
|
||
linear-gradient(to bottom, rgba(0,0,0,.45) 0, rgba(0,0,0,0) 150px),
|
||
linear-gradient(to bottom, rgba(11,11,13,0) 45%, rgba(11,11,13,.75) 78%, var(--canvas) 100%);
|
||
}
|
||
#hero .body {
|
||
position: absolute; left: 0; right: 0; bottom: 0;
|
||
padding: 0 var(--s-margin) var(--s-section);
|
||
display: flex; flex-direction: column; gap: var(--s-snug);
|
||
}
|
||
#hero .logo { max-width: 286px; max-height: 84px; object-fit: contain; } /* 10 % über der App-Größe, auf Wunsch */
|
||
#hero h2 { font-size: 34px; font-weight: 700; margin: 0; letter-spacing: -.02em; }
|
||
#hero .meta { color: var(--text-2); font-size: 13px; }
|
||
#hero .summary {
|
||
color: var(--text-2); font-size: 13px; max-width: 640px;
|
||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||
}
|
||
#hero .actions { display: flex; gap: var(--s-regular); margin-top: var(--s-tight); }
|
||
.dots { display: flex; gap: var(--s-snug); justify-content: center; padding: var(--s-regular) 0 var(--s-section); }
|
||
.dots button {
|
||
width: 6px; height: 6px; border-radius: 999px; background: rgba(255,255,255,.35);
|
||
padding: 0; transition: width .25s ease, background .25s ease;
|
||
}
|
||
.dots button:hover { background: rgba(255,255,255,.6); }
|
||
.dots button.on { width: 18px; background: var(--accent); }
|
||
|
||
/* Blättern von Hand. Die Pfeile liegen auf dem Bild und halten sich zurück,
|
||
bis der Zeiger da ist — auf einem Zeigegerät. Wer wischt, braucht sie nicht. */
|
||
.hero-nav {
|
||
position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
|
||
width: 44px; height: 44px; border-radius: 999px; padding: 0;
|
||
background: rgba(20,20,23,.55); color: var(--text); font-size: 20px;
|
||
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||
border: 1px solid var(--separator); opacity: 0; transition: opacity .2s ease;
|
||
}
|
||
#hero:hover .hero-nav { opacity: 1; }
|
||
.hero-nav.prev { left: var(--s-margin); }
|
||
.hero-nav.next { right: var(--s-margin); }
|
||
@media (hover: none) { .hero-nav { display: none; } }
|
||
|
||
/* ---- Reihen -------------------------------------------------------------- */
|
||
.row { margin-bottom: var(--s-section); }
|
||
.row h3 {
|
||
font-size: 17px; font-weight: 600; margin: 0 0 var(--s-regular);
|
||
padding: 0 var(--s-margin); letter-spacing: -.01em;
|
||
}
|
||
.strip {
|
||
display: flex; gap: var(--s-regular); overflow-x: auto;
|
||
padding: 0 var(--s-margin) var(--s-tight); scroll-snap-type: x proximity;
|
||
scrollbar-width: none;
|
||
}
|
||
.strip::-webkit-scrollbar { display: none; }
|
||
|
||
.card { flex: 0 0 auto; width: var(--card-w); scroll-snap-align: start; background: none; padding: 0; text-align: left; }
|
||
.card.wide { width: calc(var(--card-w) * 1.73); }
|
||
.card .art {
|
||
width: 100%; aspect-ratio: 2/3; border-radius: var(--r-card);
|
||
background: var(--placeholder) center/cover no-repeat;
|
||
position: relative; overflow: hidden;
|
||
}
|
||
.card.wide .art { aspect-ratio: 16/9; }
|
||
.card .title {
|
||
font-size: 13px; color: var(--text); margin-top: 6px;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.card .sub { font-size: 12px; color: var(--text-3); }
|
||
.card .bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(255,255,255,.2); }
|
||
.card .bar span { display: block; height: 100%; background: var(--accent); }
|
||
|
||
/* ---- Raster (Mediathek) -------------------------------------------------- */
|
||
.grid {
|
||
display: grid; grid-template-columns: repeat(auto-fill, minmax(calc(var(--card-w) * .75), 1fr));
|
||
gap: var(--s-loose) var(--s-regular); padding: 0 var(--s-margin) var(--s-section);
|
||
}
|
||
.grid .card { width: auto; }
|
||
|
||
/* ---- Detailblatt --------------------------------------------------------- */
|
||
#sheet {
|
||
position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,.55);
|
||
display: grid; place-items: center; padding: var(--s-loose);
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
#sheet .panel {
|
||
background: var(--elevated); border: 1px solid var(--separator);
|
||
border-radius: var(--r-sheet); width: min(680px, 100%);
|
||
max-height: 86dvh; overflow: auto; padding: var(--s-section);
|
||
}
|
||
#sheet h2 { margin: 0 0 var(--s-snug); font-size: 24px; letter-spacing: -.02em; }
|
||
#sheet .meta { color: var(--text-2); font-size: 13px; margin-bottom: var(--s-regular); }
|
||
#sheet p { color: var(--text-2); font-size: 14px; }
|
||
#sheet video { width: 100%; border-radius: var(--r-card); background: #000; margin-top: var(--s-regular); }
|
||
|
||
/* ---- Die Navigation an der Seite -----------------------------------------
|
||
Dieselbe Wahl wie in der App: die Leiste sitzt oben als Insel oder links
|
||
als Spalte. Links bleibt sie stehen, statt beim Scrollen wegzufahren —
|
||
dort nimmt sie niemandem etwas weg. */
|
||
#app.side #chrome {
|
||
top: 0; bottom: 0; left: 0; right: auto; width: calc(var(--sidebar-w) + 24px);
|
||
align-items: stretch; padding: 12px; transform: none !important; opacity: 1 !important;
|
||
}
|
||
#app.side .island {
|
||
flex-direction: column; align-items: stretch; gap: 2px;
|
||
width: 100%; max-width: none; overflow-y: auto; overflow-x: hidden;
|
||
padding: var(--s-regular) var(--s-snug);
|
||
}
|
||
#app.side .island button { text-align: left; border-radius: var(--r-control); }
|
||
#app.side .island .sep { width: auto; height: 1px; margin: var(--s-snug); }
|
||
#app.side #content { margin-left: calc(var(--sidebar-w) + 24px); }
|
||
#app.side .pad-top { padding-top: var(--s-loose); }
|
||
|
||
.island .brand {
|
||
display: none; font-size: 20px; font-weight: 700; letter-spacing: -.02em;
|
||
padding: var(--s-snug) var(--s-loose) var(--s-regular); color: var(--text);
|
||
}
|
||
#app.side .island .brand { display: block; }
|
||
.island .group-label {
|
||
display: none; font-size: 12px; color: var(--text-3);
|
||
padding: var(--s-regular) var(--s-loose) var(--s-tight);
|
||
}
|
||
#app.side .island .group-label { display: block; }
|
||
|
||
/* ---- Einstellungen -------------------------------------------------------- */
|
||
.settings { max-width: 620px; margin: 0 auto; padding: 0 var(--s-margin) var(--s-section); }
|
||
.settings h2 { font-size: 28px; letter-spacing: -.02em; margin: 0 0 var(--s-section); }
|
||
.setting {
|
||
background: var(--surface); border: 1px solid var(--separator);
|
||
border-radius: var(--r-sheet); padding: var(--s-loose); margin-bottom: var(--s-regular);
|
||
}
|
||
.setting h4 { margin: 0 0 var(--s-snug); font-size: 15px; font-weight: 600; }
|
||
.setting p { margin: 0 0 var(--s-regular); color: var(--text-2); font-size: 13px; }
|
||
.swatches { display: flex; flex-wrap: wrap; gap: var(--s-regular); }
|
||
.swatch {
|
||
width: 34px; height: 34px; border-radius: 999px; padding: 0;
|
||
border: 2px solid transparent; transition: transform .12s ease;
|
||
}
|
||
.swatch:hover { transform: scale(1.12); }
|
||
.swatch.on { border-color: var(--text); }
|
||
.seg { display: flex; gap: var(--s-snug); }
|
||
.seg button { flex: 1; background: var(--elevated); color: var(--text-2); border-radius: var(--r-control); padding: 10px; }
|
||
.seg button.on { background: var(--accent); color: #fff; }
|
||
input[type=range] { width: 100%; accent-color: var(--accent); }
|
||
.range-row { display: flex; align-items: center; gap: var(--s-regular); }
|
||
.range-row output { min-width: 56px; text-align: right; color: var(--text-2); font-size: 13px; }
|
||
|
||
/* ---- Zustände ------------------------------------------------------------ */
|
||
#content { padding-bottom: var(--s-section); }
|
||
.pad-top { padding-top: calc(84px + env(safe-area-inset-top)); }
|
||
.spinner {
|
||
width: 26px; height: 26px; border: 2px solid var(--separator);
|
||
border-top-color: var(--accent); border-radius: 999px;
|
||
animation: spin .8s linear infinite; margin: 60px auto;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
.empty { text-align: center; color: var(--text-2); padding: 60px var(--s-margin); }
|
||
|
||
@media (max-width: 600px) {
|
||
#hero { height: 70vh; }
|
||
#hero h2 { font-size: 26px; }
|
||
.card { width: 120px; }
|
||
.card.wide { width: 200px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ---------- Anmeldung ---------- -->
|
||
<section id="login">
|
||
<form class="login-card" id="login-form">
|
||
<h1>Flixy</h1>
|
||
<p class="sub">Deine Mediathek, schöner.</p>
|
||
<div class="field">
|
||
<label for="server">Serveradresse</label>
|
||
<input id="server" type="url" placeholder="https://jellyfin.example.com" required>
|
||
</div>
|
||
<div class="field">
|
||
<label for="user">Benutzer</label>
|
||
<input id="user" type="text" autocomplete="username" required>
|
||
</div>
|
||
<div class="field">
|
||
<label for="pass">Passwort</label>
|
||
<input id="pass" type="password" autocomplete="current-password">
|
||
</div>
|
||
<p class="error" id="login-error"></p>
|
||
<button class="btn btn-primary" type="submit" id="login-btn">Anmelden</button>
|
||
<p class="note">
|
||
Adresse und Zugangsschlüssel bleiben in diesem Browser (localStorage).
|
||
Das Passwort geht nur an deinen eigenen Server und wird nirgends gespeichert.
|
||
</p>
|
||
</form>
|
||
</section>
|
||
|
||
<!-- ---------- App ---------- -->
|
||
<div id="app" class="hidden">
|
||
<nav id="chrome"><div class="island" id="island"></div></nav>
|
||
<main id="content"></main>
|
||
</div>
|
||
|
||
<script>
|
||
/* ============================================================================
|
||
Der Server-Draht
|
||
----------------------------------------------------------------------------
|
||
Die Endpunkte sind dieselben, die die App benutzt — aus JellyfinKit
|
||
übernommen, nicht geraten. Die Authorization-Kopfzeile braucht ihre
|
||
Anführungszeichen auch um Werte ohne Leerzeichen; ohne sie zerlegt
|
||
Jellyfin die Zeile falsch und meldet, man sei nicht angemeldet.
|
||
========================================================================= */
|
||
const S = {
|
||
base: localStorage.getItem('flixy.server') || '',
|
||
token: localStorage.getItem('flixy.token') || '',
|
||
userId: localStorage.getItem('flixy.user') || '',
|
||
deviceId: localStorage.getItem('flixy.device') || (() => {
|
||
const id = 'flixy-web-' + Math.random().toString(36).slice(2, 12);
|
||
localStorage.setItem('flixy.device', id); return id;
|
||
})(),
|
||
};
|
||
|
||
function authHeader() {
|
||
const f = [
|
||
'Client="Flixy Web"', 'Device="Browser"',
|
||
`DeviceId="${S.deviceId}"`, 'Version="0.1"',
|
||
];
|
||
if (S.token) f.push(`Token="${S.token}"`);
|
||
return 'MediaBrowser ' + f.join(', ');
|
||
}
|
||
|
||
async function api(path, opts = {}) {
|
||
const res = await fetch(S.base + path, {
|
||
...opts,
|
||
headers: {
|
||
'Authorization': authHeader(),
|
||
'Accept': 'application/json',
|
||
...(opts.body ? { 'Content-Type': 'application/json' } : {}),
|
||
...opts.headers,
|
||
},
|
||
});
|
||
if (!res.ok) throw new Error(res.status === 401 ? 'Nicht angemeldet' : `HTTP ${res.status}`);
|
||
return res.status === 204 ? null : res.json();
|
||
}
|
||
|
||
const img = (id, kind, tag, w) =>
|
||
`${S.base}/Items/${id}/Images/${kind}?${tag ? 'tag=' + tag + '&' : ''}maxWidth=${w || 400}&quality=90`;
|
||
|
||
const hasImg = (it, kind) => kind === 'Backdrop'
|
||
? (it.BackdropImageTags?.length || it.ParentBackdropImageTags?.length)
|
||
: it.ImageTags?.[kind];
|
||
|
||
/* Das Bild eines Eintrags — mit den Ersatzwegen, die Jellyfin nötig macht:
|
||
eine Folge hat selten ein eigenes Hintergrundbild, aber ihre Serie hat eins. */
|
||
function artFor(it, kind, w) {
|
||
if (kind === 'Backdrop') {
|
||
if (it.BackdropImageTags?.length) return img(it.Id, 'Backdrop', it.BackdropImageTags[0], w);
|
||
if (it.ParentBackdropItemId) return img(it.ParentBackdropItemId, 'Backdrop', it.ParentBackdropImageTags?.[0], w);
|
||
}
|
||
if (it.ImageTags?.Primary) return img(it.Id, 'Primary', it.ImageTags.Primary, w);
|
||
if (it.SeriesPrimaryImageTag && it.SeriesId) return img(it.SeriesId, 'Primary', it.SeriesPrimaryImageTag, w);
|
||
return '';
|
||
}
|
||
|
||
const FIELDS = 'Overview,Genres,MediaSources,ParentBackdropItemId,ParentBackdropImageTags';
|
||
|
||
/* ---------- Anmeldung ---------- */
|
||
const el = (id) => document.getElementById(id);
|
||
|
||
el('login-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const btn = el('login-btn'), err = el('login-error');
|
||
btn.disabled = true; btn.textContent = 'Verbinde …'; err.textContent = '';
|
||
try {
|
||
S.base = el('server').value.trim().replace(/\/+$/, '');
|
||
S.token = '';
|
||
const r = await api('/Users/AuthenticateByName', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ Username: el('user').value, Pw: el('pass').value }),
|
||
});
|
||
S.token = r.AccessToken; S.userId = r.User.Id;
|
||
localStorage.setItem('flixy.server', S.base);
|
||
localStorage.setItem('flixy.token', S.token);
|
||
localStorage.setItem('flixy.user', S.userId);
|
||
start();
|
||
} catch (e2) {
|
||
err.textContent = /Failed to fetch/.test(e2.message)
|
||
? 'Server nicht erreichbar — Adresse prüfen (und ob sie per https läuft).'
|
||
: 'Anmeldung fehlgeschlagen: ' + e2.message;
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = 'Anmelden';
|
||
}
|
||
});
|
||
|
||
function signOut() {
|
||
['flixy.server', 'flixy.token', 'flixy.user'].forEach((k) => localStorage.removeItem(k));
|
||
location.reload();
|
||
}
|
||
|
||
/* ============================================================================
|
||
Einstellungen
|
||
----------------------------------------------------------------------------
|
||
Im Browser gespeichert, nicht auf dem Server: es sind Fragen der Ansicht
|
||
auf *diesem* Gerät. Wer am Fernseher größere Kacheln will als am Laptop,
|
||
soll das haben, ohne es dem anderen mitzuteilen.
|
||
========================================================================= */
|
||
const PALETTE = [
|
||
['Ember','#E5484D'], ['Tangerine','#F76B15'], ['Amber','#FFB224'], ['Lime','#99D52A'],
|
||
['Mint','#3DD68C'], ['Teal','#12A594'], ['Sky','#52A9FF'], ['Indigo','#7C77E8'],
|
||
['Orchid','#C04AC9'], ['Rose','#F76190'],
|
||
];
|
||
|
||
const P = {
|
||
accent: localStorage.getItem('flixy.accent') || '#C04AC9',
|
||
nav: localStorage.getItem('flixy.nav') || 'top', // 'top' | 'side'
|
||
cardW: +(localStorage.getItem('flixy.cardW') || 150),
|
||
};
|
||
|
||
function applyPrefs() {
|
||
const r = document.documentElement.style;
|
||
r.setProperty('--accent', P.accent);
|
||
r.setProperty('--card-w', P.cardW + 'px');
|
||
el('app').classList.toggle('side', P.nav === 'side');
|
||
// An der Seite fährt nichts weg — die Regel gilt nur für die Insel oben.
|
||
if (P.nav === 'side') el('chrome').classList.remove('away');
|
||
}
|
||
|
||
function setPref(key, value) {
|
||
P[key] = value;
|
||
localStorage.setItem('flixy.' + key, value);
|
||
applyPrefs();
|
||
}
|
||
|
||
/* ---------- Navigation ---------- */
|
||
let views = [], current = null;
|
||
|
||
async function start() {
|
||
el('login').classList.add('hidden');
|
||
el('app').classList.remove('hidden');
|
||
applyPrefs();
|
||
el('content').innerHTML = '<div class="spinner"></div>';
|
||
try {
|
||
const r = await api(`/Users/${S.userId}/Views`);
|
||
views = (r.Items || []).filter((v) => ['movies', 'tvshows'].includes(v.CollectionType));
|
||
buildIsland();
|
||
go('home');
|
||
} catch (e) {
|
||
el('content').innerHTML = `<div class="empty pad-top">${e.message}<br><br>
|
||
<button class="btn" onclick="signOut()">Abmelden</button></div>`;
|
||
}
|
||
}
|
||
|
||
function buildIsland() {
|
||
const isl = el('island');
|
||
isl.innerHTML = '';
|
||
const add = (label, key) => {
|
||
const b = document.createElement('button');
|
||
b.textContent = label; b.dataset.key = key;
|
||
b.onclick = () => go(key);
|
||
isl.appendChild(b);
|
||
return b;
|
||
};
|
||
const bit = (cls, text) => {
|
||
const d = document.createElement('div');
|
||
d.className = cls; if (text) d.textContent = text;
|
||
isl.appendChild(d);
|
||
};
|
||
|
||
// Die Marke steht nur in der Spalte — oben nähme sie Platz weg, den die
|
||
// Einträge brauchen.
|
||
bit('brand', 'Flixy');
|
||
add('Start', 'home');
|
||
add('Einstellungen', 'settings');
|
||
|
||
if (views.length) {
|
||
bit('sep');
|
||
bit('group-label', 'Mediatheken');
|
||
views.forEach((v) => add(v.Name, 'lib:' + v.Id));
|
||
}
|
||
bit('sep');
|
||
add('Abmelden', 'out');
|
||
}
|
||
|
||
function go(key) {
|
||
if (key === 'out') return signOut();
|
||
current = key;
|
||
[...el('island').querySelectorAll('button')].forEach((b) =>
|
||
b.classList.toggle('active', b.dataset.key === key));
|
||
window.scrollTo({ top: 0 });
|
||
el('content').classList.remove('pad-top');
|
||
if (key === 'home') renderHome();
|
||
else if (key === 'settings') renderSettings();
|
||
else renderLibrary(key.slice(4));
|
||
}
|
||
|
||
/* ---------- Einstellungen ---------- */
|
||
function renderSettings() {
|
||
const c = el('content');
|
||
c.classList.add('pad-top');
|
||
c.innerHTML = `
|
||
<div class="settings">
|
||
<h2>Einstellungen</h2>
|
||
|
||
<div class="setting">
|
||
<h4>Akzentfarbe</h4>
|
||
<p>Dieselbe Palette wie in der App.</p>
|
||
<div class="swatches" id="set-accent"></div>
|
||
</div>
|
||
|
||
<div class="setting">
|
||
<h4>Navigation</h4>
|
||
<p>Oben als schwebende Insel oder links als Spalte. Oben blendet sie
|
||
beim Scrollen aus, an der Seite bleibt sie stehen.</p>
|
||
<div class="seg" id="set-nav">
|
||
<button data-v="top">Oben</button>
|
||
<button data-v="side">Seitlich</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="setting">
|
||
<h4>Kachelgröße</h4>
|
||
<p>Wie groß Plakate und Vorschaubilder erscheinen.</p>
|
||
<div class="range-row">
|
||
<input type="range" id="set-zoom" min="100" max="280" step="10">
|
||
<output id="set-zoom-out"></output>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="setting">
|
||
<h4>Server</h4>
|
||
<p id="set-server"></p>
|
||
<button class="btn" onclick="signOut()">Abmelden</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
const sw = el('set-accent');
|
||
PALETTE.forEach(([name, hex]) => {
|
||
const b = document.createElement('button');
|
||
b.className = 'swatch' + (hex.toLowerCase() === P.accent.toLowerCase() ? ' on' : '');
|
||
b.style.background = hex; b.title = name;
|
||
b.onclick = () => { setPref('accent', hex); renderSettings(); };
|
||
sw.appendChild(b);
|
||
});
|
||
|
||
[...el('set-nav').children].forEach((b) => {
|
||
b.classList.toggle('on', b.dataset.v === P.nav);
|
||
b.onclick = () => { setPref('nav', b.dataset.v); renderSettings(); };
|
||
});
|
||
|
||
const z = el('set-zoom'), zo = el('set-zoom-out');
|
||
z.value = P.cardW; zo.textContent = P.cardW + ' px';
|
||
z.oninput = () => { zo.textContent = z.value + ' px'; setPref('cardW', +z.value); };
|
||
|
||
el('set-server').textContent = S.base.replace(/^https?:\/\//, '');
|
||
}
|
||
|
||
/* ---------- Startseite ---------- */
|
||
async function renderHome() {
|
||
const c = el('content');
|
||
c.innerHTML = '<div class="spinner"></div>';
|
||
|
||
const get = async (path, q) => {
|
||
try {
|
||
const r = await api(path + '?' + new URLSearchParams({ Fields: FIELDS, Limit: 20, ...q }));
|
||
return r.Items || r || [];
|
||
} catch { return []; }
|
||
};
|
||
|
||
const [resume, next, ...latest] = await Promise.all([
|
||
get(`/Users/${S.userId}/Items/Resume`, { MediaTypes: 'Video' }),
|
||
get('/Shows/NextUp', { userId: S.userId }),
|
||
...views.map((v) => get(`/Users/${S.userId}/Items/Latest`, { ParentId: v.Id })),
|
||
]);
|
||
|
||
const rows = [
|
||
{ title: 'Weiterschauen', items: resume, wide: true },
|
||
{ title: 'Als Nächstes', items: next, wide: true },
|
||
...views.map((v, i) => ({ title: `Neu in ${v.Name}`, items: latest[i] || [] })),
|
||
].filter((r) => r.items.length);
|
||
|
||
const featured = pickFeatured([...resume, ...next, ...latest.flat()]);
|
||
|
||
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, 5)
|
||
.map((x) => x.it);
|
||
}
|
||
|
||
/* Der Aufmacher wechselt von selbst, wie in der App — übergeblendet, nicht
|
||
durchgeblättert, damit keine Naht wandert. */
|
||
function buildHero(items) {
|
||
const wrap = document.createElement('section');
|
||
wrap.id = 'hero';
|
||
const shots = items.map((it, i) => {
|
||
const d = document.createElement('div');
|
||
d.className = 'shot' + (i === 0 ? ' on' : '');
|
||
d.style.backgroundImage = `url("${artFor(it, 'Backdrop', 1600)}")`;
|
||
return d;
|
||
});
|
||
shots.forEach((s) => wrap.appendChild(s));
|
||
wrap.insertAdjacentHTML('beforeend', '<div class="veil"></div>');
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'body';
|
||
wrap.appendChild(body);
|
||
|
||
const dots = document.createElement('div');
|
||
dots.className = 'dots';
|
||
items.forEach((_, i) => {
|
||
const d = document.createElement('button');
|
||
d.className = i ? '' : 'on';
|
||
d.setAttribute('aria-label', `Titel ${i + 1}`);
|
||
d.onclick = () => { stop(); at = i; paint(); };
|
||
dots.appendChild(d);
|
||
});
|
||
|
||
let at = 0, timer = null;
|
||
// Wer selbst blättert, fährt selbst: das Weiterwechseln hört auf, sonst
|
||
// springt der Aufmacher genau in der Sekunde, in der man nach „Abspielen"
|
||
// greift — und man startet den falschen Film.
|
||
const stop = () => { if (timer) { clearInterval(timer); timer = null; } };
|
||
const step = (by) => { stop(); at = (at + by + items.length) % items.length; paint(); };
|
||
const paint = () => {
|
||
const it = items[at];
|
||
const bits = [it.ProductionYear, runtime(it), it.OfficialRating].filter(Boolean);
|
||
const logo = it.ImageTags?.Logo ? img(it.Id, 'Logo', it.ImageTags.Logo, 600) : '';
|
||
body.innerHTML = `
|
||
${logo ? `<img class="logo" src="${logo}" alt="${esc(it.Name)}">`
|
||
: `<h2>${esc(it.Name)}</h2>`}
|
||
<div class="meta">${bits.join(' · ')}</div>
|
||
<div class="summary">${esc(it.Overview || '')}</div>
|
||
<div class="actions">
|
||
<button class="btn btn-primary">▶ Abspielen</button>
|
||
<button class="btn">Details</button>
|
||
</div>`;
|
||
body.querySelector('.btn-primary').onclick = () => openSheet(it, true);
|
||
body.querySelectorAll('.btn')[1].onclick = () => openSheet(it, false);
|
||
shots.forEach((s, i) => s.classList.toggle('on', i === at));
|
||
[...dots.children].forEach((d, i) => d.classList.toggle('on', i === at));
|
||
};
|
||
paint();
|
||
|
||
if (items.length > 1) {
|
||
const prev = document.createElement('button');
|
||
prev.className = 'hero-nav prev'; prev.innerHTML = '‹';
|
||
prev.setAttribute('aria-label', 'Vorheriger Titel');
|
||
prev.onclick = () => step(-1);
|
||
|
||
const next = document.createElement('button');
|
||
next.className = 'hero-nav next'; next.innerHTML = '›';
|
||
next.setAttribute('aria-label', 'Nächster Titel');
|
||
next.onclick = () => step(1);
|
||
|
||
wrap.append(prev, next);
|
||
|
||
// Wischen — erst ab einem ordentlichen Weg und nur, wenn es waagerecht
|
||
// gemeint ist. Sonst nimmt die Geste dem Scrollen den Vortritt.
|
||
let x0 = null, y0 = null;
|
||
wrap.addEventListener('touchstart', (e) => {
|
||
x0 = e.touches[0].clientX; y0 = e.touches[0].clientY;
|
||
}, { passive: true });
|
||
wrap.addEventListener('touchend', (e) => {
|
||
if (x0 == null) return;
|
||
const dx = e.changedTouches[0].clientX - x0;
|
||
const dy = e.changedTouches[0].clientY - y0;
|
||
if (Math.abs(dx) > 48 && Math.abs(dx) > Math.abs(dy)) step(dx < 0 ? 1 : -1);
|
||
x0 = y0 = null;
|
||
}, { passive: true });
|
||
|
||
// Pfeiltasten, solange kein Blatt offen ist.
|
||
const keys = (e) => {
|
||
if (!document.body.contains(wrap) || document.getElementById('sheet')) return;
|
||
if (e.key === 'ArrowRight') step(1);
|
||
if (e.key === 'ArrowLeft') step(-1);
|
||
};
|
||
addEventListener('keydown', keys);
|
||
|
||
timer = setInterval(() => {
|
||
if (!document.body.contains(wrap)) {
|
||
stop(); removeEventListener('keydown', keys); return;
|
||
}
|
||
at = (at + 1) % items.length; paint();
|
||
}, 7000); // sieben Sekunden — lang genug, um den Satz zu Ende zu lesen
|
||
}
|
||
|
||
const outer = document.createElement('div');
|
||
outer.appendChild(wrap); outer.appendChild(dots);
|
||
return outer;
|
||
}
|
||
|
||
function buildRow({ title, items, wide }) {
|
||
const sec = document.createElement('section');
|
||
sec.className = 'row';
|
||
sec.innerHTML = `<h3>${esc(title)}</h3><div class="strip"></div>`;
|
||
const strip = sec.querySelector('.strip');
|
||
items.forEach((it) => strip.appendChild(buildCard(it, wide)));
|
||
return sec;
|
||
}
|
||
|
||
function buildCard(it, wide) {
|
||
const b = document.createElement('button');
|
||
b.className = 'card' + (wide ? ' wide' : '');
|
||
const pct = it.UserData?.PlayedPercentage;
|
||
const art = artFor(it, wide ? 'Backdrop' : 'Primary', wide ? 500 : 300);
|
||
b.innerHTML = `
|
||
<div class="art" ${art ? `style="background-image:url('${art}')"` : ''}>
|
||
${pct ? `<div class="bar"><span style="width:${Math.min(pct, 100)}%"></span></div>` : ''}
|
||
</div>
|
||
<div class="title">${esc(it.SeriesName || it.Name)}</div>
|
||
<div class="sub">${esc(cardSub(it))}</div>`;
|
||
b.onclick = () => openSheet(it, false);
|
||
return b;
|
||
}
|
||
|
||
const cardSub = (it) =>
|
||
it.Type === 'Episode' && it.ParentIndexNumber != null
|
||
? `S${it.ParentIndexNumber}:E${it.IndexNumber} · ${it.Name}`
|
||
: (it.ProductionYear || '');
|
||
|
||
/* ---------- Mediathek ---------- */
|
||
async function renderLibrary(id) {
|
||
const c = el('content');
|
||
c.classList.add('pad-top');
|
||
c.innerHTML = '<div class="spinner"></div>';
|
||
try {
|
||
const r = await api(`/Users/${S.userId}/Items?` + new URLSearchParams({
|
||
ParentId: id, Recursive: 'true', IncludeItemTypes: 'Movie,Series',
|
||
SortBy: 'SortName', SortOrder: 'Ascending', Limit: 200, Fields: FIELDS,
|
||
}));
|
||
const items = r.Items || [];
|
||
c.innerHTML = items.length ? '<div class="grid"></div>' : '<div class="empty">Leer.</div>';
|
||
const grid = c.querySelector('.grid');
|
||
if (grid) items.forEach((it) => grid.appendChild(buildCard(it, false)));
|
||
} catch (e) {
|
||
c.innerHTML = `<div class="empty">${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
/* ---------- Detail und Wiedergabe ---------- */
|
||
function openSheet(item, playNow) {
|
||
const back = document.createElement('div');
|
||
back.id = 'sheet';
|
||
const bits = [item.ProductionYear, runtime(item), item.OfficialRating].filter(Boolean);
|
||
back.innerHTML = `
|
||
<div class="panel">
|
||
<h2>${esc(item.SeriesName ? item.SeriesName + ' — ' + item.Name : item.Name)}</h2>
|
||
<div class="meta">${bits.join(' · ')}</div>
|
||
<p>${esc(item.Overview || '')}</p>
|
||
<div class="btn-row">
|
||
<button class="btn btn-primary" id="sheet-play">▶ Abspielen</button>
|
||
<button class="btn" id="sheet-close">Schließen</button>
|
||
</div>
|
||
</div>`;
|
||
back.onclick = (e) => { if (e.target === back) back.remove(); };
|
||
document.body.appendChild(back);
|
||
back.querySelector('#sheet-close').onclick = () => back.remove();
|
||
back.querySelector('#sheet-play').onclick = () => play(item, back.querySelector('.panel'));
|
||
if (playNow) play(item, back.querySelector('.panel'));
|
||
}
|
||
|
||
/* HLS, weil ein Live-Remux zuverlässiger ist als die Hoffnung, dass der
|
||
Browser den Container auf der Platte versteht. Safari kann HLS von Haus
|
||
aus; alle anderen bekommen hls.js. */
|
||
function play(item, panel) {
|
||
panel.querySelector('video')?.remove();
|
||
const v = document.createElement('video');
|
||
v.controls = true; v.autoplay = true; v.playsInline = true;
|
||
panel.appendChild(v);
|
||
|
||
const url = `${S.base}/Videos/${item.Id}/master.m3u8?` + new URLSearchParams({
|
||
MediaSourceId: item.Id, api_key: S.token, VideoCodec: 'h264',
|
||
AudioCodec: 'aac,mp3', TranscodingMaxAudioChannels: '2',
|
||
SegmentContainer: 'ts', MinSegments: '1', BreakOnNonKeyFrames: 'true',
|
||
});
|
||
|
||
if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; return; }
|
||
const boot = () => { const h = new Hls(); h.loadSource(url); h.attachMedia(v); };
|
||
if (window.Hls) return boot();
|
||
const s = document.createElement('script');
|
||
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.13/hls.min.js';
|
||
s.onload = boot;
|
||
s.onerror = () => { v.replaceWith(Object.assign(document.createElement('p'),
|
||
{ textContent: 'Wiedergabe braucht hls.js — und das ließ sich nicht laden.' })); };
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
/* ---------- Kleinkram ---------- */
|
||
function esc(s) {
|
||
return String(s ?? '').replace(/[&<>"']/g, (c) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
}
|
||
|
||
function runtime(it) {
|
||
if (!it.RunTimeTicks) return '';
|
||
const min = Math.round(it.RunTimeTicks / 600000000);
|
||
return min >= 60 ? `${Math.floor(min / 60)} Std ${min % 60} Min` : `${min} Min`;
|
||
}
|
||
|
||
/* Die Insel fährt beim Scrollen weg und kommt zurück, sobald man oben steht —
|
||
nicht beim ersten Hochwischen. Dieselbe Regel wie in der App. */
|
||
addEventListener('scroll', () => {
|
||
if (P.nav === 'side') return; // die Spalte fährt nicht weg
|
||
el('chrome').classList.toggle('away', scrollY > 8);
|
||
}, { passive: true });
|
||
|
||
/* ---------- Los ---------- */
|
||
if (S.base && S.token && S.userId) start();
|
||
else {
|
||
el('server').value = S.base;
|
||
el('login').classList.remove('hidden');
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|