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
+2
View File
@@ -6,6 +6,7 @@ import UIKit
import FlaroDesign
import FlaroDownloads
import FlaroPlayback
import FlaroServerKit
import JellyfinKit
import MediaKit
@@ -45,6 +46,7 @@ struct FlaroApp: App {
client: Branding.appName,
version: Branding.version
),
flaroIdentity: FlaroIdentity.current(),
tokenStore: KeychainTokenStore(service: Branding.keychainService)
))
+374 -51
View File
@@ -108,6 +108,23 @@
}
}
},
"action.back": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zurück"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Back"
}
}
}
},
"action.cancel": {
"extractionState": "stale",
"localizations": {
@@ -3854,23 +3871,6 @@
}
}
},
"player.jellyfinPending": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Die Wiedergabe von Jellyfin ist noch nicht fertig — sie ist der nächste Schritt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Playback from Jellyfin isn't finished yet — it's the next step."
}
}
}
},
"player.loadFailed": {
"extractionState": "stale",
"localizations": {
@@ -6254,6 +6254,312 @@
}
}
},
"signIn.flaro.addressHint": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Schema und Port darfst du weglassen — Flaro probiert die üblichen durch."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "You can leave out the scheme and port — Flaro tries the usual ones."
}
}
}
},
"signIn.flaro.addressMissing": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Trag eine Adresse ein."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Enter an address."
}
}
}
},
"signIn.flaro.addressPlaceholder": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "z. B. 192.168.1.50 oder medien.example.com"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "e.g. 192.168.1.50 or media.example.com"
}
}
}
},
"signIn.flaro.addressPrompt": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wo steht dein Server?"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Where is your server?"
}
}
}
},
"signIn.flaro.changeAddress": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Andere Adresse"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Change address"
}
}
}
},
"signIn.flaro.codeExpired": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Code ist abgelaufen. Fordere einen neuen an."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The code has expired. Request a new one."
}
}
}
},
"signIn.flaro.codeHint": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Öffne Flaro auf deinem Telefon und gib diesen Code ein."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Open Flaro on your phone and enter this code."
}
}
}
},
"signIn.flaro.credentialsMissing": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Benutzername und Passwort fehlen."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Username and password are missing."
}
}
}
},
"signIn.flaro.findServer": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Server suchen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Find server"
}
}
}
},
"signIn.flaro.needsSetup": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Dieser Server ist noch nicht eingerichtet. Öffne ihn im Browser und lege ein Konto an."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "This server isn't set up yet. Open it in a browser and create an account."
}
}
}
},
"signIn.flaro.notFound": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Unter dieser Adresse antwortet kein Flaro-Server."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No Flaro server answers at this address."
}
}
}
},
"signIn.flaro.searching": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Server wird gesucht …"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Looking for the server…"
}
}
}
},
"signIn.flaro.signIn": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Anmelden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Sign in"
}
}
}
},
"signIn.flaro.signingIn": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wird angemeldet …"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Signing in…"
}
}
}
},
"signIn.flaro.title": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Flaro-Server"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Flaro Server"
}
}
}
},
"signIn.flaro.useCode": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Stattdessen Code"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Use a code instead"
}
}
}
},
"signIn.flaro.usePassword": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Stattdessen Passwort"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Use a password instead"
}
}
}
},
"signIn.flaro.waitingForApproval": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Warte auf Bestätigung"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Waiting for approval"
}
}
}
},
"signIn.jellyfin.addressHint": {
"localizations": {
"de": {
@@ -6403,23 +6709,6 @@
}
}
},
"signIn.jellyfin.password": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Passwort"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Password"
}
}
}
},
"signIn.jellyfin.passwordNote": {
"localizations": {
"de": {
@@ -6568,23 +6857,6 @@
}
}
},
"signIn.jellyfin.userName": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Benutzername"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Username"
}
}
}
},
"signIn.jellyfin.waitingForApproval": {
"localizations": {
"de": {
@@ -6601,6 +6873,23 @@
}
}
},
"signIn.password": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Passwort"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Password"
}
}
}
},
"signIn.privacy": {
"localizations": {
"de": {
@@ -6634,6 +6923,40 @@
}
}
},
"signIn.userName": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Benutzername"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Username"
}
}
}
},
"signIn.withFlaro": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Mit Flaro verbinden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Connect to Flaro"
}
}
}
},
"signIn.withJellyfin": {
"localizations": {
"de": {
+19 -1
View File
@@ -122,7 +122,8 @@ struct DownloadService {
// hinterher niemanden mehr, der das nachholen könnte — und seit es
// keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und
// schwarz. Also wird schon beim Herunterladen umgepackt.
let umpacken = quality == .original && !systemPlayerOpens(media: media, part: part)
let umpacken = quality == .original && !kannAlleSpuren
&& !systemPlayerOpens(media: media, part: part)
let url: URL?
switch quality {
@@ -200,6 +201,17 @@ struct DownloadService {
)
}
/// Ob der Server beim Herunterladen alle Spuren mitgibt.
///
/// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Flaros
/// Download liefert eine MP4 mit jeder Ton- und Textspur, auch wenn dafür
/// umgepackt werden muss. Dann entfällt die Spurwahl, und das Umpacken
/// mit genau einer Spur entfällt gleich mit — es gäbe nichts zu
/// verbessern, nur etwas zu verlieren.
private var kannAlleSpuren: Bool {
backend?.capabilities.contains(.allTracksDownload) == true
}
/// Ob beim Herunterladen eine Tonspur verlorengeht — und deshalb gefragt
/// werden muss.
///
@@ -211,6 +223,12 @@ struct DownloadService {
/// falsche Weg.
func needsAudioChoice(for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality) -> Bool {
guard quality == .original else { return false }
// **Kann der Server alles mitgeben, gibt es nichts zu entscheiden.**
// Die Frage ist nur deshalb da, weil Jellyfins Transcoder eine
// Tonspur je Sitzung abbildet. Wo das nicht gilt, wäre sie eine
// Entscheidung, die der Nutzer treffen soll, obwohl beide Antworten
// zum selben Ergebnis führen.
guard !kannAlleSpuren else { return false }
guard let media = mediaIndex < item.versions.count
? item.versions[mediaIndex] : item.preferredVersion,
let part = media.files.first
+180 -17
View File
@@ -1,4 +1,5 @@
import Foundation
import FlaroServerKit
import JellyfinKit
import MediaKit
import Observation
@@ -31,12 +32,41 @@ final class FlaroSession {
// stillschweigend als „ungleich" behandelt.
}
/// Mit welcher Art Server gesprochen wird.
///
/// **Diese Unterscheidung gab es schon einmal** — damals für Plex und
/// Jellyfin — und sie 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.** Zwei gleichzeitig hiessen: zwei
/// Startseiten, zwei Mediatheken, zwei Suchergebnisse — und bei jedem
/// Titel die Frage, woher er stammt.
enum ServerKind: String, CaseIterable, Sendable, Identifiable {
case jellyfin, flaro
var id: String { rawValue }
}
private enum Key {
/// Welche Art zuletzt angemeldet war.
///
/// Nicht `flaro.backend.kind`: so hiess der Schlüssel zu Plex-Zeiten,
/// und der steht in der Aufräumliste weiter unten. Denselben Namen
/// mit anderer Bedeutung zu benutzen wäre eine Falle für den
/// Nächsten, der die Liste liest.
static let activeKind = "flaro.backend.active"
static let jellyfinAddress = "flaro.jellyfin.address"
static let jellyfinUserID = "flaro.jellyfin.userID"
static let jellyfinUserName = "flaro.jellyfin.userName"
static let jellyfinServerName = "flaro.jellyfin.serverName"
static let jellyfinIsAdministrator = "flaro.jellyfin.isAdministrator"
static let flaroAddress = "flaro.flaroServer.address"
static let flaroUserID = "flaro.flaroServer.userID"
static let flaroUserName = "flaro.flaroServer.userName"
static let flaroServerName = "flaro.flaroServer.serverName"
static let flaroIsAdministrator = "flaro.flaroServer.isAdministrator"
}
/// Unter welchem Namen das Server-Token im Schlüsselbund liegt.
@@ -45,6 +75,7 @@ final class FlaroSession {
/// Mediathek. Der Name trägt die Serverart, damit ein zweiter Server
/// später daneben passt, ohne diesen zu verdrängen.
private static let jellyfinTokenAccount = "jellyfin.server"
private static let flaroTokenAccount = "flaro.server"
/// Was die Plex-Zeit auf dem Gerät hinterlassen hat.
///
@@ -90,8 +121,14 @@ final class FlaroSession {
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
private(set) var jellyfinAccount: JellyfinAccount?
/// Das angemeldete Flaro-Konto, solange Flaro aktiv ist.
private(set) var flaroAccount: FlaroAccount?
/// Mit welcher Art Server gerade gesprochen wird.
private(set) var activeKind: ServerKind?
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
var accountName: String? { jellyfinAccount?.userName }
var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName }
/// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos
/// erscheinen gar nicht erst.
@@ -101,7 +138,12 @@ final class FlaroSession {
var serverName: String { backend?.serverName ?? "" }
/// Unter welcher Adresse er erreicht wird.
var serverAddress: String? { defaults.string(forKey: Key.jellyfinAddress) }
var serverAddress: String? {
switch activeKind {
case .flaro: defaults.string(forKey: Key.flaroAddress)
case .jellyfin, nil: defaults.string(forKey: Key.jellyfinAddress)
}
}
/// Wie die Verbindung zum Server zu beschreiben ist.
///
@@ -136,30 +178,28 @@ final class FlaroSession {
/// Warum gerade nicht abgespielt werden kann.
///
/// Zwei verschiedene Lagen, und sie dürfen nicht verwechselt werden: ist
/// ein Server da, ist die Anmeldung fertig und nur die Wiedergabe noch
/// nicht — „nicht erreichbar" wäre dann gelogen. Ist **keiner** da, ist
/// niemand angemeldet, und „Wiedergabe steht aus" wäre es.
///
/// Die Unterscheidung hing früher an der Serverart, und deren `nil`-Fall
/// lief stillschweigend im selben Zweig mit. Jetzt steht sie da, wo sie
/// hingehört: an der Frage, ob überhaupt ein Server da ist.
/// **Nur noch ein Fall, und das ist eine Bereinigung.** Hier stand eine
/// Fallunterscheidung aus der Plex-Zeit: war Jellyfin aktiv, hiess es
/// „die Wiedergabe von Jellyfin ist noch nicht fertig". Sie ist längst
/// fertig — und der Satz war ohnehin unerreichbar, denn der Player zeigt
/// diese Meldung ausschliesslich dann, wenn gar kein Server da ist.
var playbackUnavailableMessage: String {
backend != nil
? String(localized: "player.jellyfinPending")
: String(localized: "error.serverUnreachable")
String(localized: "error.serverUnreachable")
}
private let jellyfinIdentity: JellyfinIdentity
private let flaroIdentity: FlaroIdentity
private let tokenStore: any TokenStoring
private let defaults: UserDefaults
init(
jellyfinIdentity: JellyfinIdentity,
flaroIdentity: FlaroIdentity,
tokenStore: any TokenStoring,
defaults: UserDefaults = .standard
) {
self.jellyfinIdentity = jellyfinIdentity
self.flaroIdentity = flaroIdentity
self.tokenStore = tokenStore
self.defaults = defaults
}
@@ -174,7 +214,13 @@ final class FlaroSession {
if await adoptStubServerIfRequested() { return }
#endif
if await restoreJellyfin() { return }
// **Die gespeicherte Art zuerst.** Wer ohne sie startet, war vor
// dieser Fassung angemeldet — damals gab es nur Jellyfin, und genau
// das ist dann gemeint.
switch defaults.string(forKey: Key.activeKind).flatMap(ServerKind.init(rawValue:)) {
case .flaro: if await restoreFlaro() { return }
case .jellyfin, nil: if await restoreJellyfin() { return }
}
phase = .signedOut
}
@@ -212,7 +258,16 @@ final class FlaroSession {
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach
/// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen.
func signOut() {
// **Dem Flaro-Server sagen, dass dieses Gerät geht.** Jellyfin
// erfährt es über das Verfallen des Tokens; Flaro zieht es auf
// Zuruf zurück, und ein nicht zurückgezogenes Token stünde sonst
// bis zum Ablauf in der Geräteliste.
if let client = flaroClient {
Task { await FlaroSignIn.signOut(client: client) }
}
forgetJellyfin()
forgetFlaro()
defaults.removeObject(forKey: Key.activeKind)
clearActiveServer()
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
@@ -276,14 +331,14 @@ final class FlaroSession {
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
clearActiveServer()
jellyfinAccount = account
activeKind = .jellyfin
self.backend = backend
isLocalConnection = await backend.isInLocalNetwork()
do {
libraries = try await loadLibraries(from: backend)
} catch {
self.backend = nil
jellyfinAccount = nil
clearActiveServer()
phase = .failed(message: ErrorText.describe(error))
return
}
@@ -320,6 +375,7 @@ final class FlaroSession {
clearActiveServer()
jellyfinAccount = account
activeKind = .jellyfin
self.backend = backend
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
// Zug wiederhergestellt werden, und die Antwort gilt je Sitzung.
@@ -341,7 +397,99 @@ final class FlaroSession {
}
}
// MARK: - Verbinden mit Flaro
/// Übernimmt eine fertige Flaro-Anmeldung.
///
/// Dieselbe Form wie bei Jellyfin, und das ist der Beweis für die Naht:
/// was danach kommt — Mediatheken laden, Phase setzen, merken — steht
/// zweimal genau gleich da, weil `MediaBackend` dazwischen liegt.
func adoptFlaro(client: FlaroClient, account: FlaroAccount) async {
phase = .connecting(serverName: account.serverName)
let backend = FlaroBackend(client: client, account: account)
clearActiveServer()
flaroAccount = account
flaroClient = client
activeKind = .flaro
self.backend = backend
// **Flaro fragt danach nicht.** Jellyfin meldet, ob die Verbindung
// im selben Netz liegt; Flaro hat dafür keinen Endpunkt und rechnet
// auch nichts daran fest. Im Zweifel vorsichtig: lieber einmal zu
// stark gedeckelt als eine Wiedergabe, die unterwegs nicht anläuft.
isLocalConnection = false
do {
libraries = try await loadLibraries(from: backend)
} catch {
clearActiveServer()
phase = .failed(message: ErrorText.describe(error))
return
}
rememberFlaro(account: account, token: nil)
phase = .ready
}
/// Dasselbe mit frischem Token — nach einer Anmeldung mit Passwort.
func adoptFlaro(client: FlaroClient, account: FlaroAccount, token: String) async {
await adoptFlaro(client: client, account: account)
guard phase == .ready else { return }
rememberFlaro(account: account, token: token)
}
/// Nimmt eine gespeicherte Flaro-Anmeldung wieder auf.
private func restoreFlaro() async -> Bool {
guard let address = defaults.string(forKey: Key.flaroAddress),
let baseURL = URL(string: address),
let token = tokenStore.token(for: Self.flaroTokenAccount)
else { return false }
phase = .connecting(serverName: defaults.string(forKey: Key.flaroServerName) ?? "")
do {
let (account, client) = try await FlaroSignIn.restore(
baseURL: baseURL, token: token, identity: flaroIdentity)
await adoptFlaro(client: client, account: account)
return true
} catch MediaBackendError.unauthorized {
// Am Server zurückgezogen. Ihn zu behalten erzeugte bei jedem
// Start denselben Fehler.
forgetFlaro()
clearActiveServer()
return false
} catch {
// Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder
// Server das, und es ist kein Grund, jemanden abzumelden.
//
// **Trotzdem mit Server:** sonst stünde beim Erneut-Versuchen
// kein Konto da, und das Heruntergeladene wäre unerreichbar.
phase = .failed(message: ErrorText.describe(error))
activeKind = .flaro
return true
}
}
private func rememberFlaro(account: FlaroAccount, token: String?) {
defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind)
defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress)
defaults.set(account.userID, forKey: Key.flaroUserID)
defaults.set(account.userName, forKey: Key.flaroUserName)
defaults.set(account.serverName, forKey: Key.flaroServerName)
defaults.set(account.isAdministrator, forKey: Key.flaroIsAdministrator)
if let token { try? tokenStore.store(token, for: Self.flaroTokenAccount) }
}
private func forgetFlaro() {
for key in [Key.flaroAddress, Key.flaroUserID, Key.flaroUserName,
Key.flaroServerName, Key.flaroIsAdministrator] {
defaults.removeObject(forKey: key)
}
tokenStore.remove(for: Self.flaroTokenAccount)
}
private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) {
defaults.set(ServerKind.jellyfin.rawValue, forKey: Key.activeKind)
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
defaults.set(account.userID, forKey: Key.jellyfinUserID)
defaults.set(account.userName, forKey: Key.jellyfinUserName)
@@ -385,12 +533,18 @@ final class FlaroSession {
private func clearActiveServer() {
backend = nil
jellyfinAccount = nil
flaroAccount = nil
flaroClient = nil
activeKind = nil
libraries = []
}
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
func retry() async {
if await restoreJellyfin() { return }
switch defaults.string(forKey: Key.activeKind).flatMap(ServerKind.init(rawValue:)) {
case .flaro: if await restoreFlaro() { return }
case .jellyfin, nil: if await restoreJellyfin() { return }
}
phase = .signedOut
}
@@ -400,4 +554,13 @@ final class FlaroSession {
/// Sitzung gibt — er braucht dieselbe Gerätekennung, sonst steht die App
/// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers.
var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity }
/// Dasselbe für Flaro.
var flaroDeviceIdentity: FlaroIdentity { flaroIdentity }
/// Der Client des aktiven Flaro-Servers — zum Abmelden.
///
/// Nicht `@Observable`-sichtbar: keine Ansicht liest ihn, und eine
/// Änderung daran soll kein Neuzeichnen auslösen.
@ObservationIgnored private var flaroClient: FlaroClient?
}
+211
View File
@@ -0,0 +1,211 @@
import Foundation
import FlaroServerKit
import MediaKit
import Observation
/// Führt durch die Anmeldung an einem Flaro-Server.
///
/// Zwei Fragen am Telefon: wo steht der Server, und wer bist du.
///
/// **Am Fernseher andersherum.** Dort steht der sechsstellige Code vorn:
/// es gibt keinen Passwortspeicher, getippt wird mit dem Steuerkreuz, und
/// das Gerät, auf dem man bestätigt, liegt sowieso auf dem Sofa. Am Telefon
/// führt derselbe Weg nirgendwohin — der Code braucht ein zweites, bereits
/// angemeldetes Gerät, und das erste ist gerade dieses hier.
///
/// Welcher Fall gilt, weiss die Oberfläche; dieses Modell bekommt es im
/// Initialisierer gesagt.
@MainActor
@Observable
final class FlaroSignInModel {
enum Step: Equatable {
case address
case checking
/// Der Code steht auf dem Bildschirm, jetzt wird gewartet.
case code(String)
case credentials
case signingIn
}
enum PreferredMethod { case credentials, code }
var address = ""
var userName = ""
var password = ""
private(set) var step: Step = .address
private(set) var errorMessage: String?
/// Wie der gefundene Server heißt — die Bestätigung, dass die Adresse
/// stimmt, noch bevor jemand Anmeldedaten eintippt.
private(set) var serverName: String?
/// Der Pflichthinweis auf die Metadatenquelle, schon vor der Anmeldung.
private(set) var attributions: [FlaroSignIn.MetadataAttribution] = []
var onAuthenticated: ((FlaroClient, FlaroAccount, String) -> Void)?
private let identity: FlaroIdentity
private let preferred: PreferredMethod
private var baseURL: URL?
private var pollTask: Task<Void, Never>?
/// Der Vorgabewert hält iOS unverändert.
init(identity: FlaroIdentity, preferring: PreferredMethod = .credentials) {
self.identity = identity
self.preferred = preferring
}
// Kein `deinit`, der abbricht: `pollTask` gehört dem Hauptakteur, und
// ein `deinit` läuft nicht dort. Abgebrochen wird über `cancel()` beim
// Verschwinden der Ansicht — wie beim Jellyfin-Modell auch.
/// Hält die Abfrageschleife an.
///
/// Ohne das läuft sie nach dem Wegwischen des Sheets noch minutenlang
/// weiter — unsichtbar und für nichts.
func cancel() {
pollTask?.cancel()
pollTask = nil
}
// MARK: - Schritt 1: den Server finden
func findServer() async {
errorMessage = nil
let kandidaten = FlaroAddress.candidates(for: address)
guard !kandidaten.isEmpty else {
errorMessage = String(localized: "signIn.flaro.addressMissing")
return
}
step = .checking
for kandidat in kandidaten {
guard let gruss = try? await FlaroSignIn.greet(
baseURL: kandidat, identity: identity) else { continue }
// **Ein frischer Server ist kein falsches Passwort.** Wer hier
// weitermachte, bekäme „Benutzername oder Passwort stimmt nicht"
// für ein Konto, das es noch gar nicht gibt.
guard !gruss.needsSetup else {
baseURL = kandidat
serverName = gruss.serverName
step = .address
errorMessage = String(localized: "signIn.flaro.needsSetup")
return
}
baseURL = kandidat
address = kandidat.absoluteString
serverName = gruss.serverName
attributions = gruss.attributions
if preferred == .code {
await beginCode()
} else {
step = .credentials
}
return
}
step = .address
errorMessage = String(localized: "signIn.flaro.notFound")
}
/// Zurück zur Adresse — etwa, weil der gefundene Server der falsche ist.
func changeAddress() {
cancel()
baseURL = nil
serverName = nil
errorMessage = nil
step = .address
}
// MARK: - Schritt 2a: der Code
/// Fordert einen Code an und wartet auf die Bestätigung.
func beginCode() async {
guard let baseURL else { return }
cancel()
errorMessage = nil
let offen: FlaroSignIn.PendingCode
do {
offen = try await FlaroSignIn.requestCode(baseURL: baseURL, identity: identity)
} catch {
step = .credentials
errorMessage = ErrorText.describe(error)
return
}
step = .code(offen.code)
// **Nachfragen, nicht offen halten.** Eine Anfrage, die fünf Minuten
// auf eine Bestätigung wartet, läuft hinter jedem Reverse Proxy ins
// Zeitlimit.
pollTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2))
if Task.isCancelled { return }
guard let self else { return }
guard await self.checkCode(baseURL: baseURL, pending: offen) else { return }
}
}
}
/// Eine Runde der Abfrage. `false` heisst: es gibt nichts mehr zu warten.
private func checkCode(baseURL: URL, pending: FlaroSignIn.PendingCode) async -> Bool {
guard let antwort = try? await FlaroSignIn.pollCode(
baseURL: baseURL, pending: pending, identity: identity)
else { return true } // Ein Aussetzer ist kein Abbruch.
switch antwort.state {
case .pending:
return true
case .expired:
errorMessage = String(localized: "signIn.flaro.codeExpired")
step = .credentials
return false
case .approved(let token, let konto):
guard let client = antwort.client else { return false }
onAuthenticated?(client, konto, token)
return false
}
}
/// Zurück zum Passwort — für Server, auf denen niemand bestätigen kann.
func useCredentials() {
cancel()
errorMessage = nil
step = .credentials
}
// MARK: - Schritt 2b: anmelden
func signIn() async {
guard let baseURL else { return }
cancel()
errorMessage = nil
let name = userName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, !password.isEmpty else {
errorMessage = String(localized: "signIn.flaro.credentialsMissing")
return
}
step = .signingIn
do {
let (token, konto, client) = try await FlaroSignIn.signIn(
baseURL: baseURL, userName: name, password: password, identity: identity)
// **Das Passwort sofort wegwerfen.** Es wird für die Dauer einer
// Anfrage gehalten und danach nicht mehr gebraucht; es stehen zu
// lassen hiesse, es in einem Speicherabbild wiederzufinden.
password = ""
onAuthenticated?(client, konto, token)
} catch {
password = ""
step = .credentials
errorMessage = ErrorText.describe(error)
}
}
}
+176
View File
@@ -0,0 +1,176 @@
import SwiftUI
import FlaroDesign
import FlaroServerKit
/// Die Anmeldung an einem Flaro-Server.
///
/// Zwei Schritte: wo steht er, und wer bist du. Mehr Fragen gibt es nicht —
/// und eine davon beantwortet der Server selbst, indem er seinen Namen nennt.
struct FlaroSignInView: View {
@Environment(FlaroSession.self) private var session
@Environment(\.dismiss) private var dismiss
@Environment(\.flaroAccent) private var accent
@State private var model: FlaroSignInModel?
@FocusState private var focusedField: Field?
private enum Field { case address, userName, password }
var body: some View {
NavigationStack {
ScrollView {
if let model {
content(for: model)
.padding(.horizontal, FlaroSpacing.section)
.padding(.vertical, FlaroSpacing.section)
.frame(maxWidth: 420)
.frame(maxWidth: .infinity)
}
}
.scrollDismissesKeyboard(.interactively)
.background(Color.flaroCanvas)
.navigationTitle(Text("signIn.flaro.title"))
.flaroInlineTitle()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("action.cancel") { dismiss() }
}
}
}
.tint(accent)
.task {
guard model == nil else { return }
let fresh = FlaroSignInModel(identity: session.flaroDeviceIdentity)
fresh.onAuthenticated = { client, konto, token in
dismiss()
Task { await session.adoptFlaro(client: client, account: konto, token: token) }
}
model = fresh
}
}
@ViewBuilder
private func content(for model: FlaroSignInModel) -> some View {
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
switch model.step {
case .address, .checking:
addressStep(model: model)
case .credentials, .signingIn:
credentialsStep(model: model)
case .code:
// **Am Telefon führt der Code nirgendwohin.** Er braucht ein
// zweites, bereits angemeldetes Gerät — und das erste ist
// gerade dieses hier. Das Modell bietet ihn deshalb nur an,
// wenn es mit `preferring: .code` gebaut wurde; am Fernseher
// ist das so, hier nicht. Der Fall steht trotzdem da, weil
// ein `default` die nächste neue Lage verschlucken würde.
EmptyView()
}
if let message = model.errorMessage {
Text(message)
.font(.flaroCaption)
.foregroundStyle(Color.flaroDestructive)
}
}
}
@ViewBuilder
private func addressStep(model: FlaroSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
Text("signIn.flaro.addressPrompt")
.font(.flaroRowHeader)
.foregroundStyle(Color.flaroTextPrimary)
TextField("signIn.flaro.addressPlaceholder", text: $model.address)
.textFieldStyle(.roundedBorder)
.textContentType(.URL)
#if os(iOS)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.focused($focusedField, equals: .address)
.onSubmit { Task { await model.findServer() } }
Text("signIn.flaro.addressHint")
.font(.flaroCaption)
.foregroundStyle(Color.flaroTextMuted)
Button {
Task { await model.findServer() }
} label: {
if model.step == .checking {
ProgressView().frame(maxWidth: .infinity)
} else {
Text("signIn.flaro.findServer").frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(model.step == .checking)
}
.onAppear { focusedField = .address }
}
@ViewBuilder
private func credentialsStep(model: FlaroSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
if let name = model.serverName {
// Der Name des Servers steht hier, damit sichtbar ist,
// **wo** man sich gleich anmeldet — eine Adresse allein sagt
// das nicht.
HStack {
Text(name)
.font(.flaroRowHeader)
.foregroundStyle(Color.flaroTextPrimary)
Spacer()
Button("signIn.flaro.changeAddress") { model.changeAddress() }
.font(.flaroCaption)
}
}
TextField("signIn.userName", text: $model.userName)
.textFieldStyle(.roundedBorder)
.textContentType(.username)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.focused($focusedField, equals: .userName)
.onSubmit { focusedField = .password }
SecureField("signIn.password", text: $model.password)
.textFieldStyle(.roundedBorder)
.textContentType(.password)
.focused($focusedField, equals: .password)
.onSubmit { Task { await model.signIn() } }
Button {
Task { await model.signIn() }
} label: {
if model.step == .signingIn {
ProgressView().frame(maxWidth: .infinity)
} else {
Text("signIn.flaro.signIn").frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(model.step == .signingIn)
// Der Pflichthinweis auf TMDB — Bedingung ihrer Lizenz, und der
// Server liefert ihn ohne Anmeldung genau dafür.
ForEach(model.attributions, id: \.text) { hinweis in
Text(hinweis.text)
.font(.flaroCaption)
.foregroundStyle(Color.flaroTextMuted)
}
}
.onAppear { focusedField = .userName }
}
}
+2 -2
View File
@@ -184,7 +184,7 @@ struct JellyfinSignInView: View {
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
foundServerHeader(model: model)
TextField("signIn.jellyfin.userName", text: $model.userName)
TextField("signIn.userName", text: $model.userName)
.textContentType(.username)
.flaroPlainEntry()
.submitLabel(.next)
@@ -203,7 +203,7 @@ struct JellyfinSignInView: View {
.padding(FlaroSpacing.regular)
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
SecureField("signIn.jellyfin.password", text: $model.password)
SecureField("signIn.password", text: $model.password)
.textContentType(.password)
.submitLabel(.go)
.focused($focusedField, equals: .password)
+29 -9
View File
@@ -3,16 +3,23 @@ import FlaroDesign
/// Der Anmeldebildschirm.
///
/// **Ein Weg, seit es einen Server gibt.** Hier standen zwei Knöpfe
/// nebeneinander, und der obere war der auffälligere — er führte zu Plex.
/// Bleibt einer übrig, erbt er die Hervorhebung: ein Knopf, der aussieht wie
/// die zweite Wahl, lässt den Benutzer nach der ersten suchen.
/// **Wieder zwei Knöpfe, und diesmal zu zwei Servern.** Hier standen schon
/// einmal zwei — Plex oben, Jellyfin darunter —, und nach dem Entfernen von
/// Plex blieb einer übrig. Jetzt gibt es Flaro, und die Frage stellt sich
/// neu: welcher steht oben?
///
/// Jellyfin hat kein Verzeichnis, an dem die eigenen Server hängen. Deshalb
/// wird die Adresse eingetippt und, wann immer der Server es anbietet, ein
/// Code bestätigt statt eines Passworts — das führt `JellyfinSignInView`.
/// **Flaro.** Nicht aus Eitelkeit, sondern weil die App dort mehr kann: der
/// Download bringt alle Tonspuren mit, 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.
///
/// Beide Server haben kein Verzeichnis, an dem die eigenen Instanzen hängen.
/// Die Adresse wird deshalb eingetippt; den Rest führen die beiden
/// Anmeldeansichten.
struct SignInView: View {
@Environment(\.flaroAccent) private var accent
@State private var showsFlaroSignIn = false
@State private var showsJellyfinSignIn = false
var body: some View {
@@ -37,6 +44,17 @@ struct SignInView: View {
Spacer()
VStack(spacing: FlaroSpacing.regular) {
Button {
showsFlaroSignIn = true
} label: {
Text("signIn.withFlaro")
.font(.flaroRowHeader)
.frame(maxWidth: .infinity)
.padding(.vertical, FlaroSpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
Button {
showsJellyfinSignIn = true
} label: {
@@ -45,8 +63,7 @@ struct SignInView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, FlaroSpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
.buttonStyle(.bordered)
Text("signIn.privacy")
.font(.flaroCaption)
@@ -60,6 +77,9 @@ struct SignInView: View {
.frame(maxWidth: 420)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.flaroCanvas)
.sheet(isPresented: $showsFlaroSignIn) {
FlaroSignInView()
}
.sheet(isPresented: $showsJellyfinSignIn) {
JellyfinSignInView()
}