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:
co-authored by
Claude Opus 5
parent
6d5824b889
commit
172b38da06
@@ -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)
|
||||
))
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user