Die Apps können jetzt beides: Jellyfin oder Flaro

`FlaroServerKit` steht neben `JellyfinKit`, nicht darin — genau auf dem
Steckplatz, den Plex geräumt hat. Dass `MediaBackend` zwei Server zugleich
trägt, war schon einmal bewiesen; die Naht hat gehalten, und keine einzige
Ansicht musste dafür neu geschrieben werden.

`ServerKind` ist zurück. Es wurde beim Entfernen von Plex gestrichen, weil
ein Aufzählungstyp mit einem Fall ein umständliches Bool ist. Jetzt gibt es
wieder zwei, und es ist wieder eine Unterscheidung — einer zur Zeit, nicht
beide nebeneinander.

**Flaro steht oben auf dem Anmeldebildschirm.** Nicht aus Eitelkeit: dort
bringt ein Download alle Tonspuren mit und der Fortschritt wird ab der
ersten Sekunde gemerkt. Wer Jellyfin benutzt, sucht gezielt danach — wer neu
anfängt, soll nicht in der schwächeren Hälfte landen, weil ihr Knopf
auffälliger war. Am Fernseher steht bei beiden der sechsstellige Code voran.

---

**Der Zugang geht als Cookie, nicht in der Adresse.** Jellyfin hängt sein
Token an jede Bildadresse, weil eine Bildansicht keine Kopfzeilen schickt;
es steht damit in jedem Zugriffsprotokoll und jedem weitergegebenen Link.
Flaro tut das nicht, und deshalb legt der Client sein Token selbst im
gemeinsamen Keksspeicher ab — auch beim Wiederaufnehmen aus dem
Schlüsselbund, wo gar keine Anmeldung stattfindet und der Server es nie
setzen könnte.

`URLSession` schickt es von allein mit; **AVFoundation nicht**. Der
Systemplayer bekommt die Kekse jetzt ausdrücklich. Serverneutral: wo keine
liegen, ist die Liste leer.

**`BackendCapability.allTracksDownload`** statt einer Spurwahl, die ins
Leere führt. Die Frage „welche Tonspur?" gibt es nur, weil Jellyfins
Transcoder eine je Sitzung abbildet. Bei Flaro führen beide Antworten zum
selben Ergebnis — also wird nicht gefragt, und das Umpacken mit einer
einzigen Spur entfällt gleich mit.

**Die Adressraterei wandert nach `MediaKit`.** Sechzig Zeilen sorgfältig
begründeter Reihenfolge — IP-Adressen sprechen selten HTTPS, Domänennamen
fast immer — unterscheiden sich zwischen beiden Servern nur im Standardport.
Zweimal hingeschrieben wäre die eine Fassung beim nächsten Sonderfall
nachgezogen worden und die andere nicht. Jellyfins Prüfungen laufen
unverändert gegen die verschobene Fassung; das ist der Beweis.

**Nebenbei zwei Altlasten:** `player.jellyfinPending` war unerreichbar — der
Player zeigt diese Meldung nur, wenn gar kein Server da ist, dann aber nie
die mit Jellyfin. Und `signIn.jellyfin.userName` hiess „Benutzername"; der
Schlüssel heisst jetzt so neutral wie der Text.

---

**Der Fehler, den erst der Simulator zeigte:** „Stelle wird
wiederhergestellt", 0:00, für immer.

Muss der Server umpacken, antwortet er auf den Download mit 202 und fängt
an. AVPlayer bekommt darauf kein Video, sondern eine leere Antwort — und
wartet, unbegrenzt, ohne Fehler. Die Weboberfläche fragt in einer Schleife
nach; `preparePlayback` tat es nicht. Jetzt wartet es, mit Obergrenze und
einer Begründung, wenn sie reisst. Mit Prüfung, die zwei Runden erzwingt.

413 Tests grün. Im Simulator gegen den echten Server nachgewiesen: Adresse
eintippen, Server gefunden, angemeldet, Mediathek mit Plakaten, Neustart
ohne erneute Anmeldung — und eine MKV abgespielt, die der Server dafür erst
umpacken musste. Danach stand auf dem Server: Matrix, 2,0 s von 2,0 s,
gesehen, ein Durchlauf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 22:00:02 +02:00
co-authored by Claude Opus 5
parent 6d5824b889
commit 172b38da06
28 changed files with 3203 additions and 153 deletions
@@ -0,0 +1,130 @@
import SwiftUI
import FlaroDesign
import FlaroServerKit
/// Die Anmeldung an einem Flaro-Server, am Fernseher.
///
/// Dasselbe Modell wie am Telefon, nur mit `preferring: .code`: sechs Ziffern
/// auf dem Bildschirm, bestätigt am Telefon. Das Passwort bleibt der Rückfall
/// — für den Fall, dass gerade kein angemeldetes Gerät zur Hand ist.
struct TVFlaroSignInView: View {
@Environment(FlaroSession.self) private var session
let zurueck: () -> Void
@State private var model: FlaroSignInModel?
var body: some View {
Group {
if let model {
inhalt(for: model)
} else {
ProgressView()
}
}
.task {
guard model == nil else { return }
let frisch = FlaroSignInModel(
identity: session.flaroDeviceIdentity, preferring: .code)
frisch.onAuthenticated = { client, konto, token in
Task { await session.adoptFlaro(client: client, account: konto, token: token) }
}
model = frisch
}
.onDisappear { model?.cancel() }
}
@ViewBuilder
private func inhalt(for model: FlaroSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
Text(Branding.appName).font(.flaroHero)
Text("signIn.flaro.title").font(.flaroBody).foregroundStyle(Color.flaroTextMuted)
switch model.step {
case .address:
adresse(model: model)
case .checking:
warten("signIn.flaro.searching")
case .code(let code):
codeAnzeigen(model: model, code: code)
case .credentials:
zugangsdaten(model: model)
case .signingIn:
warten("signIn.flaro.signingIn")
}
if let fehler = model.errorMessage {
Text(fehler).font(.flaroBody).foregroundStyle(Color.flaroDestructive)
}
}
.padding(FlaroSpacing.section)
}
private func adresse(model: FlaroSignInModel) -> some View {
@Bindable var model = model
return VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
Text("signIn.flaro.addressPrompt").font(.flaroRowHeader)
TextField("signIn.flaro.addressPlaceholder", text: $model.address)
.flaroPlainEntry(isAddress: true)
Text("signIn.flaro.addressHint")
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
HStack(spacing: FlaroSpacing.regular) {
Button("signIn.flaro.findServer") { Task { await model.findServer() } }
Button("action.back", action: zurueck)
}
}
}
private func codeAnzeigen(model: FlaroSignInModel, code: String) -> some View {
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
if let name = model.serverName {
Text(name).font(.flaroRowHeader)
}
// **Der Code ist hier der Inhalt, nicht eine Beschriftung.**
// Er wird aus drei Metern abgelesen und in ein Telefon getippt;
// in Fliesstextgrösse ist das eine Zumutung.
Text(code)
.font(.system(size: 96, weight: .bold, design: .monospaced))
.kerning(12)
.foregroundStyle(.white)
Text("signIn.flaro.codeHint").font(.flaroBody)
Label("signIn.flaro.waitingForApproval", systemImage: "hourglass")
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
HStack(spacing: FlaroSpacing.regular) {
Button("signIn.flaro.usePassword") { model.useCredentials() }
Button("signIn.flaro.changeAddress") { model.changeAddress() }
}
}
}
private func zugangsdaten(model: FlaroSignInModel) -> some View {
@Bindable var model = model
return VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
if let name = model.serverName {
Text(name).font(.flaroRowHeader)
}
TextField("signIn.userName", text: $model.userName)
.flaroPlainEntry()
SecureField("signIn.password", text: $model.password)
HStack(spacing: FlaroSpacing.regular) {
Button("signIn.flaro.signIn") { Task { await model.signIn() } }
Button("signIn.flaro.useCode") { Task { await model.beginCode() } }
Button("signIn.flaro.changeAddress") { model.changeAddress() }
}
}
}
private func warten(_ text: LocalizedStringKey) -> some View {
HStack(spacing: FlaroSpacing.regular) {
ProgressView()
Text(text).font(.flaroBody)
}
}
}
+42 -18
View File
@@ -1,40 +1,51 @@
import SwiftUI
import FlaroDesign
import FlaroServerKit
import JellyfinKit
/// Die Anmeldung am Fernseher.
///
/// **Dieselben vier Schritte wie am Telefon, andere Reihenfolge.** Das Modell
/// `JellyfinSignInModel` ist unverändert dasselbe — nur bekommt es hier
/// `preferring: .quickConnect`. Vor einem Fernseher ist der Code der bequemere
/// Weg: es gibt keinen Passwortspeicher, getippt wird mit dem Steuerkreuz, und
/// das Gerät, auf dem man den Code bestätigt, liegt sowieso auf dem Sofa.
/// **Erst die Frage, welcher Server.** Seit es zwei gibt, steht sie vorn —
/// und Flaro oben, weil die App dort mehr kann.
///
/// Der Weg über Benutzername und Passwort bleibt erreichbar — für Server, auf
/// denen Quick Connect abgeschaltet ist.
/// Danach dieselben Schritte wie am Telefon, nur andere Reihenfolge: beide
/// Modelle bekommen hier den Code-Weg voran. Vor einem Fernseher ist er der
/// bequemere: es gibt keinen Passwortspeicher, getippt wird mit dem
/// Steuerkreuz, und das Gerät, auf dem man bestätigt, liegt sowieso auf dem
/// Sofa. Benutzername und Passwort bleiben erreichbar.
struct TVSignInView: View {
@Environment(FlaroSession.self) private var session
@State private var art: FlaroSession.ServerKind?
@State private var model: JellyfinSignInModel?
var body: some View {
ZStack {
Color.flaroCanvas.ignoresSafeArea()
if let model {
inhalt(for: model)
// Eine Spalte, nicht die volle Breite: 1760 Punkte Zeile
// liest sich aus drei Metern nicht mehr als Zeile.
.frame(maxWidth: 900)
} else {
ProgressView()
Group {
switch art {
case nil:
auswahl()
case .flaro:
TVFlaroSignInView(zurueck: { art = nil })
case .jellyfin:
if let model {
inhalt(for: model)
} else {
ProgressView()
}
}
}
// Eine Spalte, nicht die volle Breite: 1760 Punkte Zeile
// liest sich aus drei Metern nicht mehr als Zeile.
.frame(maxWidth: 900)
}
.task {
.onChange(of: art) { _, neu in
guard neu == .jellyfin, model == nil else { return }
// Erst hier, nicht im Eigenschaftswert: das Modell braucht die
// Gerätekennung der Sitzung. Baut es sich eine eigene, steht die
// App nach dem Anmelden ein zweites Mal in der Geräteliste.
guard model == nil else { return }
let frisch = JellyfinSignInModel(
identity: session.jellyfinDeviceIdentity,
preferring: .quickConnect
@@ -51,6 +62,19 @@ struct TVSignInView: View {
.onDisappear { model?.cancel() }
}
private func auswahl() -> some View {
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
Text(Branding.appName).font(.flaroHero)
Text("signIn.tagline").font(.flaroBody).foregroundStyle(Color.flaroTextMuted)
VStack(spacing: FlaroSpacing.regular) {
Button("signIn.withFlaro") { art = .flaro }
Button("signIn.withJellyfin") { art = .jellyfin }
}
}
.padding(FlaroSpacing.section)
}
@ViewBuilder
private func inhalt(for model: JellyfinSignInModel) -> some View {
@Bindable var model = model
@@ -129,9 +153,9 @@ struct TVSignInView: View {
if let name = model.serverName {
Text(name).font(.flaroRowHeader)
}
TextField("signIn.jellyfin.userName", text: $model.userName)
TextField("signIn.userName", text: $model.userName)
.flaroPlainEntry()
SecureField("signIn.jellyfin.password", text: $model.password)
SecureField("signIn.password", text: $model.password)
Text("signIn.jellyfin.passwordNote")
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)