import './style.css'; import type { Item, Seite } from './types'; import { api, sitzung, angemeldet, abmelden } from './api'; import { P, anwenden, setzen } from './prefs'; import { icon } from './icons'; import { esc, FELDER } from './util'; import { anmeldung } from './views/login'; import { startseite } from './views/home'; import { mediathek } from './views/library'; import { einstellungen } from './views/settings'; import { blatt } from './views/cards'; import { playerOeffnen } from './views/player'; const app = document.getElementById('app')!; const inhalt = document.getElementById('content')!; const insel = document.getElementById('island')!; const chrome = document.getElementById('chrome')!; let ansichten: Item[] = []; let wo = 'home'; /* ---------- Navigation ---------- */ function inselBauen(): void { insel.innerHTML = ''; // 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 = icon(P.nav === 'side' ? 'barTop' : 'barSide', 18); nav.onclick = () => leisteUmziehen(P.nav === 'side' ? 'top' : 'side'); insel.appendChild(nav); const stueck = (klasse: string, text?: string): void => { const d = document.createElement('div'); d.className = klasse; if (text) d.textContent = text; insel.appendChild(d); }; const eintrag = (text: string, ziel: string): void => { const b = document.createElement('button'); b.textContent = text; b.dataset.key = ziel; b.onclick = () => gehe(ziel); insel.appendChild(b); }; stueck('brand', 'Flixy'); eintrag('Start', 'home'); eintrag('Einstellungen', 'settings'); if (ansichten.length) { stueck('sep'); stueck('group-label', 'Mediatheken'); for (const v of ansichten) eintrag(v.Name ?? '—', 'lib:' + v.Id); } stueck('sep'); eintrag('Abmelden', 'out'); [...insel.querySelectorAll('button[data-key]')].forEach((b) => b.classList.toggle('active', (b as HTMLElement).dataset.key === wo)); } /* Lässt die Leiste von ihrer alten Lage in die neue fahren. ---------------------------------------------------------------------------- **Zwei Züge, nicht einer.** Ein reiner Weg von A nach B sieht aus, als würde ein Kasten verschoben. Die Leiste zieht sich erst *in sich* zusammen und zieht dann erst um — wie ein Gummiband, das sich sammelt, bevor es schnellt. Gemacht mit FLIP: alte Lage messen, umbauen, neue Lage messen, und vom alten Zustand in den neuen fahren. Über CSS allein ginge das nicht — `right: auto` und `bottom: auto` lassen sich nicht animieren, und genau die wechseln. */ function leisteUmziehen(nach: 'top' | 'side'): void { const vorher = insel.getBoundingClientRect(); const sparsam = matchMedia('(prefers-reduced-motion: reduce)').matches; setzen('nav', nach); inselBauen(); if (sparsam) return; const nachher = insel.getBoundingClientRect(); if (!vorher.width || !nachher.width) return; const dx = vorher.left - nachher.left; const dy = vorher.top - nachher.top; const sx = vorher.width / nachher.width; const sy = vorher.height / nachher.height; // 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. const gesammelt = nach === 'top' ? `translate(${dx}px, ${dy}px) scale(${sx}, 1)` : `translate(${dx}px, ${dy}px) scale(1, ${sy})`; insel.animate( [ { transformOrigin: 'top left', transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`, easing: 'cubic-bezier(.5,0,.75,0)', offset: 0 }, { transformOrigin: 'top left', transform: gesammelt, easing: 'cubic-bezier(.22,1,.36,1)', offset: 0.42 }, { transformOrigin: 'top left', transform: 'none', offset: 1 }, ], { duration: 520 }, ); insel.animate([{ opacity: 0.35 }, { opacity: 0.5, offset: 0.42 }, { opacity: 1 }], { duration: 420, easing: 'ease-out' }); } function gehe(ziel: string): void { if (ziel === 'out') return abmelden(); wo = ziel; [...insel.querySelectorAll('button[data-key]')].forEach((b) => b.classList.toggle('active', (b as HTMLElement).dataset.key === ziel)); scrollTo({ top: 0 }); inhalt.classList.remove('pad-top'); if (ziel === 'home') void startseite(inhalt, ansichten, tippen); else if (ziel === 'settings') einstellungen(inhalt, () => gehe('home')); else void mediathek(inhalt, ziel.slice(4), tippen); } /* ---------- Was beim Antippen geschieht ---------- */ function tippen(item: Item): void { blatt(item, starten); } /** Startet — und bei einer Serie die Folge, die als Nächstes dran ist. * * Eine Serie gar nicht abzuspielen war die falsche Antwort: die * offensichtliche Erwartung ist, dass es losgeht. */ async function starten(item: Item): Promise { const ordner = ['Series', 'Season'].includes(item.Type ?? ''); const ziel = ordner ? await naechsteFolge(item) : item; if (!ziel) return; playerOeffnen(ziel, () => { if (wo === 'home') void startseite(inhalt, ansichten, tippen); }); } async function naechsteFolge(item: Item): Promise { const serie = item.Type === 'Season' ? (item.SeriesId ?? item.Id) : item.Id; try { // Erst „Als Nächstes": wer eine Serie anfängt und zurückkommt, meint die // Folge, bei der er stehengeblieben ist — nicht die erste. const weiter = await api('/Shows/NextUp?' + new URLSearchParams({ userId: sitzung.userId, seriesId: serie, Limit: '1', Fields: FELDER, })); const folge = weiter.Items?.[0]; if (folge && (item.Type !== 'Season' || folge.SeasonId === item.Id)) return folge; } catch { /* dann eben die erste */ } try { const q: Record = { userId: sitzung.userId, Limit: '1', Fields: FELDER }; if (item.Type === 'Season') q.seasonId = item.Id; const alle = await api(`/Shows/${serie}/Episodes?` + new URLSearchParams(q)); return alle.Items?.[0] ?? null; } catch { return null; } } /* ---------- Los ---------- */ async function starte(): Promise { document.getElementById('login')!.classList.add('hidden'); app.classList.remove('hidden'); anwenden(); inhalt.innerHTML = '
'; try { const r = await api(`/Users/${sitzung.userId}/Views`); ansichten = (r.Items ?? []).filter((v) => ['movies', 'tvshows'].includes(v.CollectionType ?? '')); inselBauen(); gehe('home'); } catch (e) { inhalt.innerHTML = `
${esc((e as Error).message)}

`; document.getElementById('raus')!.onclick = abmelden; } } // Die Insel oben fährt beim Scrollen weg und kommt zurück, sobald man wieder // oben steht. An der Seite bleibt sie stehen — dort nimmt sie niemandem etwas. addEventListener('scroll', () => { if (P.nav === 'side') return; chrome.classList.toggle('away', scrollY > 8); }, { passive: true }); anwenden(); if (angemeldet()) void starte(); else anmeldung(document.getElementById('login')!, () => void starte());