Das Web spielt jetzt ab — und sagt, wenn es nicht geht
Es fehlte der erste Schritt. Die Stream-Adresse wurde selbst
zusammengebaut, und damit fehlten PlaySessionId, die ausgehandelte
MediaSourceId und die Adresse, die der Server eigentlich liefert. Im
Swift-Code steht seit jeher, warum das nicht geht: "eine selbst
zusammengebaute Adresse wäre eine Vermutung darüber."
Jetzt vier Schritte wie in der App: PlaybackInfo mit Geräteprofil, die
Quelle aus der Antwort wählen, die Adresse des Servers nehmen, die
Sitzungskennung mitführen.
Vor allem aber sind Fehler nicht mehr unsichtbar. Vorher endete jeder
Fehlschlag in einem stummen schwarzen Kasten — ein 401 sah aus wie ein
404 und wie ein Netzproblem. Geprüft: Ablehnung, fehlende Quelle und
nicht abspielbares Format nennen jetzt alle drei Ross und Reiter.
Serien und Staffeln haben keinen Abspielknopf mehr. Sie sind Ordner;
/Videos/{serienId} zeigt auf nichts. Wer über eine Serienkachel getestet
hat, konnte gar nichts starten.
Dazu Fortschrittsmeldung an /Sessions/Playing — ohne die erscheint der
Titel auf dem Server nie als "läuft gerade", und "Weiterschauen" bliebe
im Web für immer leer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
14e1fdbe3f
commit
ecfa454136
@@ -0,0 +1,185 @@
|
||||
# Flixy — offene Punkte und der Weg nach vorn
|
||||
|
||||
## Kontext
|
||||
|
||||
Flixy gibt es als App für iPhone, iPad und Mac und seit heute als Weboberfläche für Rechner ohne App — Windows vor allem. In den letzten Stunden sind Live-TV für Jellyfin, ein randloser Aufmacher, ein Webfrontend und der Abgleich der Einstellungen entstanden.
|
||||
|
||||
Vieles davon ist **gebaut, aber nicht belegt**. Der Anlass für diesen Plan ist doppelt: erstens eine ehrliche Liste dessen, was offen ist, zweitens die vier Stücke, die als Nächstes anstehen — die Weboberfläche brauchbar machen, eine Apple-TV-App, eine Admin-Oberfläche, und das Gefühl der Bedienung angleichen.
|
||||
|
||||
Der Maßstab für den Funktionsumfang ist **JellyTV** (App Store), ausdrücklich als Anregung, nicht als Vorlage. Flixy kann dabei etwas, das JellyTV nicht kann: Plex als Server.
|
||||
|
||||
**Ausdrücklich nicht gewollt:** iCloud-Sync der Einstellungen, Vision Pro, Push bei neuen Inhalten, Jellyseerr.
|
||||
|
||||
---
|
||||
|
||||
## Teil A — Was offen ist
|
||||
|
||||
### A1. Gebaut, aber nie an echten Daten geprüft
|
||||
|
||||
Alles hiervon kompiliert und ist committet; keines davon hat je einen echten Server gesehen.
|
||||
|
||||
| Was | Wo | Warum ungeprüft |
|
||||
|---|---|---|
|
||||
| **Live-TV für Jellyfin** — Sender, Programmführer, Einschalten | `JellyfinBackend+LiveTV.swift` | Kein Simulator-Zugang zu einem angemeldeten Server |
|
||||
| **Aufnahmen und Timer** | `JellyfinBackend+Recordings.swift` | dito |
|
||||
| **Plex-Live-TV nach dem Umbau** | `PlexBackend+LiveTV.swift` | **Das größte Risiko:** hier hat vorher etwas funktioniert, und die Schicht darunter wurde ausgetauscht |
|
||||
| **Cover-Nachladen** (Wiederholung bei Zeitablauf) | `ArtworkLoader.swift` | Trat nur auf großen Fenstern mit vielen Kacheln auf |
|
||||
| **Karussell-Auswahl alle 6 Stunden** | `HomeView.swift` | Wechselt erst nach Ablauf eines Fensters |
|
||||
| **Kachelgröße und Aufmacherhöhe** | `HomeLayoutSettings.swift` | Neu, nie in der laufenden App gesehen |
|
||||
| **Randloser Aufmacher, Insel blendet aus** | `RootView.swift`, `TopChrome.swift` | Vom Nutzer als „sieht ok aus" bestätigt, nicht im Einzelnen geprüft |
|
||||
|
||||
### A2. Bewusst liegen gelassen
|
||||
|
||||
- **Programmführer ohne Scroll-Melder** (`ProgramGuide.swift`): Die Insel bleibt dort beim Scrollen stehen, während sie überall sonst ausblendet. Verschachtelte Scroll-Struktur mit eigener `onScrollGeometryChange`-Logik — das Risiko war zu hoch für einen blinden Eingriff.
|
||||
- **Doppelte Chrome-Zeile auf Detailseiten**: „Staffel 2" steht klein in der Leiste und groß im Bild. Entscheidung war, das zu bewerten, wenn der schwarze Balken weg ist. Er ist weg.
|
||||
|
||||
### A3. Halbfertig im Arbeitsverzeichnis
|
||||
|
||||
- **Phase 4 des Einstellungs-Abgleichs** (`web/frontend/index.html`, uncommittet): Wiedergabe (Tempo, Spulweite, Qualität) und Sprachen als Einstellungen, dazu Spulknöpfe und Tempo-Wahl im Player. Geschrieben, **nicht getestet**.
|
||||
|
||||
### A4. Vom Nutzer angekündigt, noch nicht da
|
||||
|
||||
- Screenshots von **JellyTV** für den Gefühls-Abgleich.
|
||||
- Die **eine Sache**, die am iPhone-Bildschirmfoto aufgefallen war — nie genannt.
|
||||
|
||||
---
|
||||
|
||||
## Teil B — Was gebaut werden soll
|
||||
|
||||
Reihenfolge: **Web brauchbar → Admin → Apple TV.** Eine Oberfläche, die nichts kann, nützt niemandem.
|
||||
|
||||
---
|
||||
|
||||
### B1. Das Web muss abspielen können
|
||||
|
||||
**Befund.** Das Web ruft `/Items/{id}/PlaybackInfo` **kein einziges Mal** auf. Es baut die Stream-Adresse in `web/frontend/index.html:1395-1406` selbst zusammen und springt damit direkt zu einem geratenen Schritt. Im Swift-Code steht wörtlich, warum das nicht geht (`JellyfinBackend+Playback.swift:47-49`):
|
||||
|
||||
> „Die Adresse kommt vom Server, nicht von uns. Er weiß, welche Spuren er kopieren kann und welche er neu rechnen muss; eine selbst zusammengebaute Adresse wäre eine Vermutung darüber."
|
||||
|
||||
Es fehlen dadurch: `PlaySessionId`, die ausgehandelte `MediaSourceId`, die `TranscodingUrl` des Servers und die `DeviceId` am Stream. Der Stream-Abruf geht an `api()` vorbei und trägt **keine einzige Kopfzeile** — der Server sieht eine Anfrage ohne Gerät und ohne Sitzung.
|
||||
|
||||
**Die Reihenfolge, die die App einhält und das Web nachziehen muss:**
|
||||
|
||||
1. `POST /Items/{id}/PlaybackInfo` mit `DeviceProfile` → Vorbild: `playbackInfo` in `JellyfinBackend+Playback.swift:116-177`
|
||||
2. Quelle aus `MediaSources` wählen (nicht `item.Id` annehmen — bei mehreren Fassungen falsch)
|
||||
3. `TranscodingUrl` des Servers relativ zur Basisadresse auflösen
|
||||
4. `PlaySessionId` mitführen — für Fortschritt und für `DELETE /Videos/ActiveEncodings` am Ende
|
||||
|
||||
**Weitere Funde, die mitbehoben werden:**
|
||||
|
||||
- **Fehler sind unsichtbar.** Kein `error`-Ereignis am `<video>`, kein `Hls.Events.ERROR`. Ein 401, ein CORS-Block oder gemischtes HTTP/HTTPS enden in einem stummen schwarzen Player. Das kommt zuerst dran — ohne Fehleranzeige ist jede weitere Suche blind.
|
||||
- **Serien und Staffeln bekommen einen Abspielknopf** (`index.html:1250`, `1337-1356`). `/Videos/{seriesId}/master.m3u8` zeigt auf einen Ordner. Wer über eine Serienkachel getestet hat, konnte gar nichts starten.
|
||||
- **Meine Phase-4-Arbeit ist an dieser Stelle falsch:** `AudioLanguage`/`SubtitleLanguage` sind keine Parameter dieses Endpunkts. Jellyfin erwartet `AudioStreamIndex`/`SubtitleStreamIndex` — Indizes, die erst PlaybackInfo liefert. Ebenso gehört `MaxStreamingBitrate` ins `DeviceProfile`, nicht an die URL. Das wird beim Umbau berichtigt, nicht übernommen.
|
||||
- **Kein `/Sessions/Playing`.** Der Titel erscheint auf dem Server nie als „läuft gerade", Fortschritt wird nie gespeichert. Damit ist „Weiterschauen" im Web wertlos.
|
||||
|
||||
### B2. Das Web wird ein richtiges Projekt
|
||||
|
||||
Entschieden: **Frontend-Projekt plus schlanker eigener Server.**
|
||||
|
||||
Der Server ist nicht Zierde, sondern löst zwei Dinge, an denen die Einzeldatei scheitert: Er reicht die Jellyfin-Anfragen weiter (damit ist CORS ein für alle Mal erledigt, auch beim Streamen), und der Token bleibt bei ihm statt im Browser-Speicher zu liegen.
|
||||
|
||||
- Vite + TypeScript, Quelltext in Module zerlegt statt 1500 Zeilen in einer Datei
|
||||
- Node-Server davor, der `/api/*` an den Medienserver weiterreicht
|
||||
- Zum Betreiben auf einem Container; Node ist auf dem Entwicklungsrechner vorhanden (v26), Docker nicht — das Bauen läuft lokal, das Ausliefern als fertige Dateien
|
||||
- Der bestehende Entwurf wandert hinüber, er wird nicht weggeworfen: Gestaltung, Aufmacher, Insel-Umzug und Einstellungen sind bereits erprobt
|
||||
|
||||
---
|
||||
|
||||
### B3. Admin-Oberfläche
|
||||
|
||||
**Befund.** `BackendCapability.administration` existiert seit Langem, wird in `JellyfinBackend.swift:21-29` gesetzt — und **nirgends abgefragt**. Eine tote Fähigkeit: berechnet, gespeichert, überlebt Neustarts, niemand sieht sie an. Es gibt keine Admin-View, keinen Admin-Endpunkt, keinen `admin.*`-Übersetzungsschlüssel.
|
||||
|
||||
**Nur Jellyfin.** Plex hat kein Gegenstück zu `JellyfinAccount` — `PlexAuthenticator` liefert einen Token-String, kein Konto, keine Rechte. Verwaltung wäre über plex.tv grundsätzlich denkbar, ist aber nirgends angelegt. Genau dafür ist `BackendCapability` da: bei Plex erscheint der Bereich schlicht nicht, so wie der dritte Schalter im Live-TV dort fehlt.
|
||||
|
||||
**Aufbau, nach dem Vorbild von Live-TV.** Ein eigenes `AdministrationBackend`-Protokoll in MediaKit, **außerhalb** von `MediaBackend` — die Begründung steht wörtlich in `LiveTV.swift:320-325` und gilt hier noch stärker, weil Plex es nie erfüllen wird. Neue Modelltypen: `ServerUser`, `ServerTask`, `ActivityEntry`, `ActiveSession`.
|
||||
|
||||
Zu ergänzen:
|
||||
- `JellyfinBackend+Administration.swift` — neue Datei, Extension wie bei Live-TV. Die Endpunkte fehlen alle: `/Users`, `/ScheduledTasks`, `/Sessions`, `/System/ActivityLog/Entries`, `/Library/VirtualFolders`.
|
||||
- `JellyfinAuthenticator.swift:177-183` — die `Policy` dekodiert heute **nur** `IsAdministrator`; alles andere wird verworfen. Achtung: `JellyfinAccount.init` hat vier Parameter und wird an mehreren Stellen gerufen (`FlixySession.swift:521, 588`).
|
||||
- `FlixySession` — `supportsAdministration` und `administration`, exakt nach dem Muster der beiden Live-TV-Zeilen (Z. 168-176).
|
||||
- `SettingsView.swift` — bedingte Section **zwischen** Server und „Darstellung": derselbe Gegenstand wie Server (nicht das Gerät), aber ein anderer Umgang damit. Nicht in `ServerSettingsView` hinein — dort steht heute ausschließlich Beobachtetes.
|
||||
- `SettingsChrome.swift:58-66` — `SettingsPalette` fehlt ein `admin`-Eintrag.
|
||||
|
||||
**Web:** `index.html:502-506` wirft `User.Policy` beim Anmelden weg. Ohne diese eine Zeile ist dort gar keine Entscheidung möglich, ob jemand Admin ist.
|
||||
|
||||
**Vorbild für schreibende Server-Aktionen** ist `LibraryRow` in `ServerSettingsView.swift:72-133` — heute die einzige der ganzen App, mit ihrer Zustandsmaschine und dem ehrlichen Kommentar, warum dort kein Fortschrittsbalken steht.
|
||||
|
||||
**Umfang, in dieser Reihenfolge — nach Risiko, nicht nach Aufwand:**
|
||||
|
||||
1. **Wer schaut gerade** (`GET /Sessions`). Nur lesend, nichts kann kaputtgehen, und im Alltag das, was man am häufigsten wissen will. Gut geeignet, um Protokoll und Oberfläche einmal durchzuspielen, bevor etwas Schreibendes dazukommt.
|
||||
2. **Server-Aufgaben** (`GET /ScheduledTasks`, `POST /ScheduledTasks/Running/{id}`). Schreibend, aber folgenlos — eine angestoßene Aufgabe kann man laufen lassen. Schließt unmittelbar an das an, was „Mediathek aktualisieren" schon tut.
|
||||
3. **Nutzer verwalten** (`/Users`, `/Users/{id}/Policy`, `/Users/{id}/Password`). Der größte Brocken: Jellyfins Rechteobjekt hat gut dreißig Felder. Nicht alle davon gehören in eine Oberfläche — die Auswahl wird beim Bauen begründet, nicht stillschweigend getroffen.
|
||||
4. **Mediatheken verwalten** (`/Library/VirtualFolders`). Zuletzt, weil hier ein Fehlgriff eine Mediathek löscht. Jede zerstörerische Aktion bekommt eine Rückfrage, die den Namen nennt — wie bei den Aufnahmen: „Wirklich löschen?" allein beantwortet nicht, *was*.
|
||||
|
||||
---
|
||||
|
||||
### B4. Apple TV
|
||||
|
||||
**Die Trennung zahlt sich aus.** Rund die Hälfte des Codes trägt sich hinüber, die andere Hälfte ist die Oberfläche:
|
||||
|
||||
| Schicht | Zeilen | Zustand auf tvOS |
|
||||
|---|---|---|
|
||||
| MediaKit, PlexKit, JellyfinKit, FlixyPlayback | 9.972 | ~95 % unverändert, drei kleine Korrekturen |
|
||||
| `Flixy/Session/` | 2.050 | größtenteils übernehmbar |
|
||||
| FlixyDesign | 1.479 | Farben und Rechnerei ja, Bauteile brauchen Fokuszustände |
|
||||
| FlixyVLC | 262 | **ja** — VLCKit hat echte tvOS-Slices |
|
||||
| FlixyDownloads + Widget | 1.384 | entfällt |
|
||||
| `Flixy/Views/` | 9.133 | **praktisch komplett neu** |
|
||||
|
||||
**Die erfreuliche Überraschung:** VLCKit liefert `tvos-arm64` mit, das Upstream-Paket deklariert tvOS, und `VLCPlayerEngine.swift` benutzt nichts, was dort fehlt. Eine Zeile im Manifest. Damit könnte die TV-App MKV, DTS und TrueHD direkt abspielen — **besser als die Mac-App**, wo der Server rechnen muss.
|
||||
|
||||
**Zwei stille Fehler, die heute niemand bemerkt** (und die auch iOS betreffen, sobald man sie sieht):
|
||||
- `AVPlayerEngine.swift:320,330` — `AudioSession.activate()` steht unter `#if os(iOS)`, obwohl `AVAudioSession` auf tvOS existiert. Ohne Korrektur würde die Kategorie dort stillschweigend nie gesetzt.
|
||||
- Sechs Abfragen von `horizontalSizeClass` liefern auf tvOS immer `.regular`. Nichts bricht — es läuft einfach immer der iPad-Pfad, obwohl das nicht mehr die gemeinte Frage ist.
|
||||
|
||||
**Was echte Arbeit ist:**
|
||||
1. **Die Oberfläche, wegen des Eingabemodells.** Nicht fehlende APIs sind das Problem, sondern Fokus statt Berührung. Die ehrlichste Kennzahl: **`@FocusState` kommt im ganzen Projekt genau einmal vor.** Jede Kachel braucht auf tvOS einen Fokuszustand — das ist dort keine Zutat, sondern die Grundmechanik.
|
||||
2. **Der Player zerfällt in zwei Pfade.** `AVPlayerViewController` bringt auf tvOS Transport, Spurwahl und Info-Tafel fertig mit — aber nur für AVPlayer. VLC braucht eine eigene fokusgesteuerte Bedienung. Das ist der unangenehmste Einzelposten.
|
||||
3. **`PlatformShims.swift` ist binär gedacht** („iOS oder macOS"). `UIPasteboard` ist auf tvOS ein **harter Übersetzungsfehler**, die übrigen sieben Helfer meinen im `#else`-Zweig stillschweigend das Falsche. Muss auf drei Plattformen umgestellt werden, bevor irgendeine Ansicht sie benutzt.
|
||||
4. **Eigenes Target mit eigenem Quellverzeichnis.** Der Kniff, mit dem `FlixyMac` sich `Flixy/` teilt, funktioniert nur, weil Catalyst buchstäblich der iOS-Bau ist. tvOS ist eine eigene Plattform.
|
||||
|
||||
**Was entfällt:** Downloads (der Speicherort ist auf tvOS genau falsch herum, und ein Apple TV hat 64 GB), Mini-Player (kein Ziehen, kein Pinch), AirPlay-Knopf (das Gerät *ist* das Ziel), Bildschirmdrehung.
|
||||
|
||||
**Was schon vorbereitet ist:** Plex braucht dort den PIN-Fluss statt `ASWebAuthenticationSession` — `PlexPin.swift` und `session.showsManualCode` existieren bereits, nur der Bildschirm fehlt.
|
||||
|
||||
---
|
||||
|
||||
## Teil C — Wie geprüft wird
|
||||
|
||||
Der wunde Punkt dieser ganzen Sitzung: Vieles ist gebaut und nichts davon an echten Daten belegt. Das soll sich nicht fortsetzen.
|
||||
|
||||
**Was ich selbst prüfen kann:**
|
||||
- Übersetzt es? (`xcodebuild` für iOS und Mac Catalyst, `swift test` für die Pakete — 37 Tests)
|
||||
- Web: Oberfläche und Verhalten im Browser, mit abgefangenen Server-Antworten. So sind Karussellpunkte, Insel-Umzug, Reihenfolge der Reihen und die Kontrastrechnung belegt worden.
|
||||
- iOS-Simulator: Bildschirmfotos, Tippen, Wischen — aber **nur bis zum Anmeldebildschirm**, solange dort kein Konto hinterlegt ist.
|
||||
|
||||
**Was ich nicht selbst prüfen kann:**
|
||||
- Alles, was einen angemeldeten Server braucht. Das ist der Grund, warum Teil A1 so lang ist.
|
||||
- macOS-Fenster (keine Werkzeuge dafür), JellyTV (App Store, nicht quelloffen).
|
||||
|
||||
**Der Weg, der das auflöst:** Eine einmalige Anmeldung im iPad-Simulator — bei Plex oder Jellyfin. Danach kann ich Startseite, Live-TV, Aufnahmen, Kachelgröße und Aufmacher selbst ansehen und nachbessern, statt jede Runde an den Nutzer zurückzugeben. Das würde Teil A1 in einem Zug abräumen.
|
||||
|
||||
**Für das Web:** Sobald es einen eigenen Server hat, lässt sich dieser gegen einen erfundenen Jellyfin richten — dann ist auch die Wiedergabekette prüfbar, ohne dass ein echter Medienserver nötig ist.
|
||||
|
||||
---
|
||||
|
||||
## Teil D — Reihenfolge
|
||||
|
||||
**Vor allem anderen: die Fehleranzeige im Web-Player.** Ein halber Tag Suche nach „warum geht das nicht", wenn ein 401 schon die Antwort gewesen wäre, ist verlorener Tag. Das kommt zuerst, weil alles Weitere daran hängt.
|
||||
|
||||
1. **Web spielt ab** — Fehleranzeige, dann `PlaybackInfo`-Kette, dann Fortschrittsmeldung. Kein Abspielknopf mehr auf Serien und Staffeln.
|
||||
2. **Web wird ein Projekt** — Vite plus Server, der bestehende Entwurf wandert hinüber. Phase 4 wird dabei berichtigt, nicht übernommen.
|
||||
3. **Admin** — in vier Schritten nach Risiko: wer schaut gerade → Server-Aufgaben → Nutzer → Mediatheken. In App und Web.
|
||||
4. **Apple TV** — Pakete tvOS-fähig machen (inklusive der zwei stillen Fehler und `PlatformShims`), dann die Oberfläche.
|
||||
|
||||
**Das Gefühl der Bedienung** (JellyTV als Anregung) läuft nebenher, sobald die Screenshots da sind — es ist Feinschliff an Vorhandenem, kein eigener Bauabschnitt.
|
||||
|
||||
---
|
||||
|
||||
## Was ich anders sehe als der Auftrag
|
||||
|
||||
Zwei Anmerkungen, die ich nicht unterschlagen will:
|
||||
|
||||
**Teil A1 wächst schneller, als er abgetragen wird.** Sieben Dinge sind gebaut und keines davon belegt — darunter der Umbau von Plex-Live-TV, wo vorher etwas funktionierte. Vier weitere Bauabschnitte davorzusetzen vergrößert den Stapel. Mein Vorschlag wäre, **eine einzige Anmeldung im iPad-Simulator** dazwischenzuschieben: danach kann ich A1 in einem Zug selbst abräumen, statt jede Runde zurückzugeben. Das kostet dich fünf Minuten und mich eine Sitzung.
|
||||
|
||||
**Apple TV ist kein viertes Stück, sondern ein eigenes Projekt.** Rund 9.100 Zeilen Oberfläche neu, ein Player in zwei Pfaden, und ein Eingabemodell, mit dem dieses Projekt bisher einmal in Berührung kam. Das ist machbar und lohnt sich — aber es sollte nicht in einem Zug mit „Admin-Oberfläche" geplant werden, sondern eine eigene Spec bekommen, wenn es soweit ist.
|
||||
+298
-18
@@ -238,6 +238,20 @@ img { display: block; }
|
||||
#sheet .meta { color: var(--text-2); font-size: 13px; margin-bottom: var(--s-regular); }
|
||||
#sheet p { color: var(--text-2); font-size: 14px; }
|
||||
#sheet video { width: 100%; border-radius: var(--r-card); background: #000; margin-top: var(--s-regular); }
|
||||
.playerbar {
|
||||
display: flex; align-items: center; gap: var(--s-regular);
|
||||
margin-top: var(--s-regular); flex-wrap: wrap;
|
||||
}
|
||||
.playerbar .btn { padding: 8px var(--s-loose); font-size: 14px; }
|
||||
.playerbar label { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: var(--s-snug); }
|
||||
.playerbar .pick { width: auto; padding: 7px var(--s-regular); }
|
||||
.playerror {
|
||||
margin-top: var(--s-regular); padding: var(--s-loose);
|
||||
background: rgba(229,72,77,.12); border: 1px solid rgba(229,72,77,.35);
|
||||
border-radius: var(--r-control); display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.playerror strong { color: #F08A8D; font-weight: 600; font-size: 14px; }
|
||||
.playerror span { color: var(--text-2); font-size: 13px; }
|
||||
|
||||
/* ---- Die Navigation an der Seite -----------------------------------------
|
||||
Dieselbe Wahl wie in der App: die Leiste sitzt oben als Insel oder links
|
||||
@@ -318,6 +332,15 @@ img { display: block; }
|
||||
}
|
||||
.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);
|
||||
@@ -533,8 +556,31 @@ const P = {
|
||||
// steht — dieselbe Aufteilung wie `Arrangement` in der App.
|
||||
hiddenRows: JSON.parse(localStorage.getItem('flixy.hiddenRows') || '[]'),
|
||||
rowOrder: JSON.parse(localStorage.getItem('flixy.rowOrder') || '[]'),
|
||||
// Wiedergabe — dieselben Stufen wie in der App, damit derselbe Begriff
|
||||
// überall dasselbe heißt.
|
||||
rate: +(localStorage.getItem('flixy.rate') || 1.0),
|
||||
skipForward: +(localStorage.getItem('flixy.skipForward') || 30),
|
||||
skipBackward: +(localStorage.getItem('flixy.skipBackward') || 10),
|
||||
quality: +(localStorage.getItem('flixy.quality') || 0), // 0 = Original
|
||||
// Sprachen. Leer heißt: was der Server vorgibt.
|
||||
audioLanguage: localStorage.getItem('flixy.audioLanguage') || '',
|
||||
subtitleLanguage: localStorage.getItem('flixy.subtitleLanguage') || '',
|
||||
};
|
||||
|
||||
/// Die Stufen der App, damit die Begriffe sich decken.
|
||||
const RATES = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
|
||||
const SKIPS = [5, 10, 15, 30, 45];
|
||||
const QUALITIES = [
|
||||
[0, 'Original'], [12000, 'Hoch — 12 Mbit/s'], [8000, 'Mittel — 8 Mbit/s'],
|
||||
[4000, 'Niedrig — 4 Mbit/s'], [2000, 'Sparsam — 2 Mbit/s'],
|
||||
];
|
||||
/// Sprachen als ISO-639-2, wie Jellyfin sie führt.
|
||||
const LANGUAGES = [
|
||||
['', 'Serverstandard'], ['ger', 'Deutsch'], ['eng', 'Englisch'],
|
||||
['fre', 'Französisch'], ['spa', 'Spanisch'], ['ita', 'Italienisch'],
|
||||
['jpn', 'Japanisch'], ['kor', 'Koreanisch'],
|
||||
];
|
||||
|
||||
function applyPrefs() {
|
||||
const r = document.documentElement.style;
|
||||
r.setProperty('--accent', P.accent);
|
||||
@@ -860,6 +906,43 @@ function renderSettings() {
|
||||
<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">
|
||||
@@ -906,6 +989,25 @@ function renderSettings() {
|
||||
|
||||
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); };
|
||||
@@ -1242,6 +1344,9 @@ async function renderLibrary(id) {
|
||||
function openSheet(item, playNow) {
|
||||
const back = document.createElement('div');
|
||||
back.id = 'sheet';
|
||||
// Serien und Staffeln sind Ordner. `/Videos/{serienId}` zeigt auf nichts
|
||||
// Abspielbares — ein Knopf dafür verspricht etwas, das nicht eingelöst wird.
|
||||
const playable = !['Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder'].includes(item.Type);
|
||||
const bits = [item.ProductionYear, runtime(item), item.OfficialRating].filter(Boolean);
|
||||
back.innerHTML = `
|
||||
<div class="panel">
|
||||
@@ -1249,41 +1354,216 @@ function openSheet(item, playNow) {
|
||||
<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>
|
||||
${playable ? '<button class="btn btn-primary" id="sheet-play">▶ Abspielen</button>' : ''}
|
||||
<button class="btn" id="sheet-close">Schließen</button>
|
||||
</div>
|
||||
${playable ? '' : '<p class="hint">Eine Serie spielt man nicht ab — öffne eine Folge.</p>'}
|
||||
</div>`;
|
||||
back.onclick = (e) => { if (e.target === back) back.remove(); };
|
||||
document.body.appendChild(back);
|
||||
back.querySelector('#sheet-close').onclick = () => back.remove();
|
||||
back.querySelector('#sheet-play').onclick = () => play(item, back.querySelector('.panel'));
|
||||
if (playNow) play(item, back.querySelector('.panel'));
|
||||
if (playable) {
|
||||
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) {
|
||||
/* Spielt einen Titel ab — auf dem Weg, den der Server vorgibt.
|
||||
----------------------------------------------------------------------------
|
||||
**Vier Schritte, und der erste ist der wichtigste.** Vorher stand hier nur
|
||||
der dritte: eine selbst zusammengebaute `master.m3u8`-Adresse. Die App sagt
|
||||
dazu seit jeher das Richtige — „die Adresse kommt vom Server, nicht von
|
||||
uns. Er weiß, welche Spuren er kopieren kann und welche er neu rechnen
|
||||
muss; eine selbst zusammengebaute Adresse wäre eine Vermutung darüber."
|
||||
|
||||
Ohne den Aushandlungsschritt fehlen `PlaySessionId`, die ausgehandelte
|
||||
`MediaSourceId` und die Adresse selbst. Der Server bekam eine Anfrage ohne
|
||||
Sitzung und ohne Gerät — und antwortete entsprechend.
|
||||
|
||||
1. `POST /Items/{id}/PlaybackInfo` mit einem Geräteprofil
|
||||
2. Die Quelle aus der Antwort wählen, nicht die Eintragskennung annehmen
|
||||
3. Die Adresse des Servers nehmen
|
||||
4. Die Sitzungskennung mitführen: für den Fortschritt und fürs Aufräumen */
|
||||
async function play(item, panel) {
|
||||
panel.querySelector('video')?.remove();
|
||||
panel.querySelector('.playerbar')?.remove();
|
||||
panel.querySelector('.playerror')?.remove();
|
||||
|
||||
const fail = (satz, hinweis) => {
|
||||
panel.querySelector('video')?.remove();
|
||||
const box = document.createElement('div');
|
||||
box.className = 'playerror';
|
||||
box.innerHTML = `<strong>${esc(satz)}</strong>${hinweis ? `<span>${esc(hinweis)}</span>` : ''}`;
|
||||
panel.appendChild(box);
|
||||
};
|
||||
|
||||
const v = document.createElement('video');
|
||||
v.controls = true; v.autoplay = true; v.playsInline = true;
|
||||
panel.appendChild(v);
|
||||
|
||||
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',
|
||||
// **Fehler sichtbar machen.** Vorher endete jeder Fehlschlag in einem
|
||||
// stummen schwarzen Kasten: kein Ereignis abgehört, keine Meldung. Ein 401
|
||||
// sah aus wie ein 404 und wie ein Netzproblem.
|
||||
v.addEventListener('error', () => {
|
||||
const c = v.error?.code;
|
||||
fail('Der Browser kann diesen Strom nicht abspielen.',
|
||||
c === 4 ? 'Das Format wird nicht unterstützt — der Server sollte umrechnen, tat es aber nicht.'
|
||||
: `Fehlercode ${c ?? '?'}. Im Netzwerk-Tab steht, was der Server geantwortet hat.`);
|
||||
});
|
||||
|
||||
let info;
|
||||
try {
|
||||
info = await playbackInfo(item);
|
||||
} catch (e) {
|
||||
return fail('Der Server hat die Wiedergabe abgelehnt.', e.message);
|
||||
}
|
||||
|
||||
const source = info.MediaSources?.[0];
|
||||
if (!source) {
|
||||
return fail('Der Server nennt keine abspielbare Quelle.',
|
||||
'Bei einer Serie oder Staffel ist das richtig — dort gibt es nur Folgen zum Abspielen.');
|
||||
}
|
||||
|
||||
// Die Adresse des Servers, nicht unsere. Sie trägt DeviceId, PlaySessionId
|
||||
// und die ausgehandelten Codec-Angaben bereits in sich.
|
||||
let url = source.TranscodingUrl
|
||||
? new URL(source.TranscodingUrl, S.base).href
|
||||
: client_url(`/Videos/${item.Id}/stream`, {
|
||||
static: 'true', mediaSourceId: source.Id || item.Id,
|
||||
api_key: S.token, PlaySessionId: info.PlaySessionId || '',
|
||||
});
|
||||
|
||||
v.addEventListener('loadedmetadata', () => { v.playbackRate = P.rate; }, { once: true });
|
||||
panel.appendChild(playerBar(v));
|
||||
|
||||
// Fortschritt melden, damit „Weiterschauen" auch im Web etwas bedeutet.
|
||||
const session = { id: info.PlaySessionId, sourceID: source.Id || item.Id, itemID: item.Id };
|
||||
wireProgress(v, session);
|
||||
|
||||
if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; return; }
|
||||
const boot = () => { const h = new Hls(); h.loadSource(url); h.attachMedia(v); };
|
||||
|
||||
const boot = () => {
|
||||
const h = new Hls();
|
||||
h.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (!data.fatal) return;
|
||||
fail('Die Wiedergabe ist abgebrochen.',
|
||||
`${data.type}: ${data.details}` + (data.response?.code ? ` (HTTP ${data.response.code})` : ''));
|
||||
h.destroy();
|
||||
});
|
||||
h.loadSource(url); h.attachMedia(v);
|
||||
};
|
||||
if (window.Hls) return boot();
|
||||
const 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);
|
||||
|
||||
const tag = document.createElement('script');
|
||||
tag.src = 'https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.13/hls.min.js';
|
||||
tag.onload = boot;
|
||||
tag.onerror = () => fail('Die Wiedergabe braucht hls.js.',
|
||||
'Das Skript ließ sich nicht laden — ohne Internetzugang oder mit einem Blocker davor geht es nicht.');
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
|
||||
/* Handelt mit dem Server aus, wie abgespielt wird.
|
||||
----------------------------------------------------------------------------
|
||||
Das Geräteprofil sagt, was dieses Ende kann; die Antwort sagt, was daraus
|
||||
folgt. Die Werte entsprechen denen der App (`playbackInfo` in
|
||||
JellyfinBackend+Playback.swift) — leere DirectPlayProfiles heißt: nichts
|
||||
geht unverändert, rechne um. Im Browser ist das die ehrliche Auskunft, denn
|
||||
ein <video>-Element kann kein MKV und kein DTS. */
|
||||
async function playbackInfo(item) {
|
||||
const profile = {
|
||||
MaxStreamingBitrate: (P.quality > 0 ? P.quality : 20000) * 1000,
|
||||
DirectPlayProfiles: [],
|
||||
TranscodingProfiles: [{
|
||||
Type: 'Video', Container: 'ts', Protocol: 'hls', VideoCodec: 'h264',
|
||||
AudioCodec: 'aac,mp3', Context: 'Streaming', MaxAudioChannels: '2',
|
||||
MinSegments: 1, BreakOnNonKeyFrames: true,
|
||||
}],
|
||||
SubtitleProfiles: [{ Format: 'vtt', Method: 'Hls' }, { Format: 'srt', Method: 'External' }],
|
||||
};
|
||||
|
||||
const body = {
|
||||
DeviceProfile: profile,
|
||||
UserId: S.userId,
|
||||
MaxStreamingBitrate: profile.MaxStreamingBitrate,
|
||||
EnableDirectPlay: false,
|
||||
EnableDirectStream: false,
|
||||
EnableTranscoding: true,
|
||||
AutoOpenLiveStream: true,
|
||||
};
|
||||
|
||||
return api(`/Items/${item.Id}/PlaybackInfo?` + new URLSearchParams({ userId: S.userId }), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/* Meldet dem Server, wo man steht.
|
||||
----------------------------------------------------------------------------
|
||||
Ohne das erscheint der Titel dort nie als „läuft gerade", und der
|
||||
Fortschritt wird nie gespeichert — „Weiterschauen" wäre im Web eine leere
|
||||
Reihe. Alle zehn Sekunden genügt; häufiger zu melden ändert nichts außer
|
||||
der Last. */
|
||||
function wireProgress(video, session) {
|
||||
const ticks = () => Math.round(video.currentTime * 10_000_000);
|
||||
const send = (path, extra = {}) => {
|
||||
// Absichtlich ohne await und ohne catch: eine verlorene Meldung kostet ein
|
||||
// paar Sekunden Fortschritt. Dafür mitten im Film einen Fehler zu zeigen
|
||||
// wäre die schlechtere Hälfte des Tauschs.
|
||||
api(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
ItemId: session.itemID, PlaySessionId: session.id,
|
||||
MediaSourceId: session.sourceID, PositionTicks: ticks(), ...extra,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
video.addEventListener('playing', () => send('/Sessions/Playing'), { once: true });
|
||||
|
||||
let last = 0;
|
||||
video.addEventListener('timeupdate', () => {
|
||||
if (video.currentTime - last < 10) return;
|
||||
last = video.currentTime;
|
||||
send('/Sessions/Playing/Progress', { IsPaused: video.paused });
|
||||
});
|
||||
|
||||
const stop = () => send('/Sessions/Playing/Stopped');
|
||||
video.addEventListener('ended', stop);
|
||||
video.addEventListener('emptied', stop);
|
||||
}
|
||||
|
||||
/* Die Spulknöpfe und die Tempowahl, die es im nackten <video> nicht gibt. */
|
||||
function playerBar(video) {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'playerbar';
|
||||
bar.innerHTML = `
|
||||
<button class="btn">−${P.skipBackward}s</button>
|
||||
<button class="btn">+${P.skipForward}s</button>
|
||||
<label>Tempo <select class="pick"></select></label>`;
|
||||
|
||||
const [back, fwd] = bar.querySelectorAll('button');
|
||||
back.onclick = () => { video.currentTime = Math.max(0, video.currentTime - P.skipBackward); };
|
||||
fwd.onclick = () => { video.currentTime = Math.min(video.duration || 1e9, video.currentTime + P.skipForward); };
|
||||
|
||||
const rate = bar.querySelector('select');
|
||||
RATES.forEach((r) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = r; o.textContent = r === 1 ? 'Normal' : r + '×'; o.selected = r === P.rate;
|
||||
rate.appendChild(o);
|
||||
});
|
||||
// Nur für diese Wiedergabe — die Voreinstellung bleibt, was sie ist.
|
||||
rate.onchange = () => { video.playbackRate = +rate.value; };
|
||||
return bar;
|
||||
}
|
||||
|
||||
/* Eine Adresse mit Anmeldung im Anhang.
|
||||
----------------------------------------------------------------------------
|
||||
Für alles, was nicht `api()` abruft, sondern das <video>-Element selbst:
|
||||
es schickt keine Kopfzeilen mit. Genau denselben Umweg geht die App. */
|
||||
function client_url(path, query) {
|
||||
const u = new URL(S.base + path);
|
||||
Object.entries(query).forEach(([k, v]) => v !== '' && u.searchParams.set(k, v));
|
||||
return u.href;
|
||||
}
|
||||
|
||||
/* ---------- Kleinkram ---------- */
|
||||
|
||||
Reference in New Issue
Block a user