Eine Datei, die gegen Jellyfin spricht: Anmeldung, Startseite mit Aufmacher und Reihen, Mediathek als Raster, Wiedergabe über HLS. Die Endpunkte sind aus JellyfinKit übernommen, nicht geraten — inklusive der Anführungszeichen in der Authorization-Zeile, ohne die Jellyfin die Zeile falsch zerlegt. Gestaltet aus denselben Werten wie die App: Flächen, Radien, Abstände aus FlixyDesign. Die Navigationsinsel schwebt über einem randlosen Aufmacher und fährt beim Scrollen weg — dieselbe Entscheidung wie dort. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
616 lines
23 KiB
HTML
616 lines
23 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;
|
|
|
|
--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: 260px; max-height: 76px; object-fit: contain; }
|
|
#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); }
|
|
#hero .dots { display: flex; gap: var(--s-snug); justify-content: center; padding: var(--s-regular) 0; }
|
|
#hero .dots i {
|
|
width: 6px; height: 6px; border-radius: 999px; background: rgba(255,255,255,.35);
|
|
transition: width .25s ease, background .25s ease;
|
|
}
|
|
#hero .dots i.on { width: 18px; background: var(--accent); }
|
|
|
|
/* ---- 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: 150px; scroll-snap-align: start; background: none; padding: 0; text-align: left; }
|
|
.card.wide { width: 260px; }
|
|
.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(110px, 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); }
|
|
|
|
/* ---- 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();
|
|
}
|
|
|
|
/* ---------- Navigation ---------- */
|
|
let views = [], current = null;
|
|
|
|
async function start() {
|
|
el('login').classList.add('hidden');
|
|
el('app').classList.remove('hidden');
|
|
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);
|
|
};
|
|
add('Start', 'home');
|
|
if (views.length) isl.appendChild(Object.assign(document.createElement('div'), { className: 'sep' }));
|
|
views.forEach((v) => add(v.Name, 'lib:' + v.Id));
|
|
isl.appendChild(Object.assign(document.createElement('div'), { className: '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 });
|
|
key === 'home' ? renderHome() : renderLibrary(key.slice(4));
|
|
}
|
|
|
|
/* ---------- 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 = [...resume, ...(latest[0] || [])].filter((i) => hasImg(i, 'Backdrop')).slice(0, 5);
|
|
|
|
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>';
|
|
}
|
|
}
|
|
|
|
/* 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) => dots.insertAdjacentHTML('beforeend', `<i class="${i ? '' : 'on'}"></i>`));
|
|
|
|
let at = 0;
|
|
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 timer = setInterval(() => {
|
|
if (!document.body.contains(wrap)) return clearInterval(timer);
|
|
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', () => {
|
|
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>
|