Plex ist raus — und nahm zwei stille Fehler mit
Vollständig entfernt: das Paket `PlexKit` samt 108 Tests, der Web-Authenticator, die Anmeldung über plex.tv, die Serverauswahl und alles, was daran hing. 43 Dateien gelöscht, 68 Stellen in 28 weiteren bereinigt. **Zwei Dinge hätten beim blossen Streichen still das Falsche getan.** `playbackUnavailableMessage` deckte im Else-Zweig zwei Lagen gleichzeitig ab — „es ist Plex" und „niemand ist angemeldet". Wer die Serverart naiv herausstreicht, bekommt als nicht angemeldeter Nutzer „Jellyfin-Wiedergabe steht aus" statt „Server nicht erreichbar". Die Unterscheidung hängt jetzt daran, ob überhaupt ein Server da ist. Die Schlüsselbund-Einträge `plex.account` und `plex.server` wurden allein über den Authenticator aus dem gelöschten Paket entfernt. Danach stünde `plex.account` in keiner Zeile dieses Projekts mehr: wer sich nie abgemeldet hat, trüge ein gültiges Token am fremden Konto bis in alle Ewigkeit mit sich, ohne dass irgendein Weg es noch löschen könnte. `purgePlexLeftovers()` räumt beide Token und fünf Voreinstellungen einmalig ab — und fasst nichts an, wo nichts liegt, damit kein Jellyfin-Nutzer dafür seinen Mediathekenspeicher verliert. **Was Nutzer sehen.** Die Berechtigungsfrage fürs lokale Netz sagte wörtlich, die App brauche es, „um deinen Plex-Server zu finden" — das stand so im Systemdialog. Der Anmeldebildschirm hatte zwei Knöpfe, und der hervorgehobene war der für Plex; der verbliebene erbt die Hervorhebung, denn ein Knopf, der aussieht wie die zweite Wahl, lässt nach der ersten suchen. 14 verwaiste Textbausteine sind weg, vier umformuliert. Darunter einer, der schlicht nicht mehr stimmte: `signIn.privacy` versprach, das Passwort erreiche diese App nie. Beim Jellyfin-Rückfall auf das Passwort tut es das sehr wohl. Die Zusage, die weiterhin gilt, ist die über fremde Dienste — und nur die steht jetzt da. **Was ausdrücklich bleibt:** `MediaBackend`. Diese Abstraktion hat zwei Server gleichzeitig getragen, und der Steckplatz, den Plex räumt, ist genau der, auf den Halo kommt. `ServerKind` fällt dagegen weg — mit einem Fall ist es ein Bool, das `backend != nil` schon beantwortet. Nebenbei zog die README nach. Sie beschrieb noch einen „nativen Plex-Client" mit zwei Playern und VLCKit, beides seit Längerem nicht mehr wahr. 368 Tests grün (von 476 minus die 108 aus PlexKitTests, kein anderer). iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
93695dc332
commit
2fcf6821c8
@@ -18,13 +18,6 @@ enum Branding {
|
||||
/// Wie die App sich selbst nennt.
|
||||
static let appName = "Flixy"
|
||||
|
||||
/// Unter welchem Namen die App in der Geräteliste des Plex-Kontos steht.
|
||||
///
|
||||
/// Wandert in die Kopfzeile `X-Plex-Product`. Eine Änderung hier legt beim
|
||||
/// nächsten Anmelden einen neuen Eintrag an — der alte bleibt als Karteileiche
|
||||
/// stehen, bis er von Hand entfernt wird.
|
||||
static let plexProduct = appName
|
||||
|
||||
/// Unter welchem Namen die Tokens im Schlüsselbund liegen.
|
||||
///
|
||||
/// An die Bundle-Kennung gebunden statt an den Anzeigenamen: eine spätere
|
||||
@@ -33,20 +26,6 @@ enum Branding {
|
||||
Bundle.main.bundleIdentifier ?? "com.scarriffleservices.flixy"
|
||||
}
|
||||
|
||||
/// Das eigene URL-Schema, über das Plex nach der Anmeldung zurückschickt.
|
||||
///
|
||||
/// Die Bundle-Kennung statt eines kurzen Wortes wie „flixy": sie ist
|
||||
/// garantiert eindeutig. Bei einem kurzen Schema könnte eine andere App
|
||||
/// denselben Namen beanspruchen und die Anmeldung abfangen.
|
||||
static var urlScheme: String {
|
||||
Bundle.main.bundleIdentifier ?? "com.scarriffleservices.flixy"
|
||||
}
|
||||
|
||||
/// Wohin Plex nach der Anmeldung zurückschickt.
|
||||
static var authCallbackURL: URL {
|
||||
URL(string: "\(urlScheme)://auth")!
|
||||
}
|
||||
|
||||
/// Wann dieser Build entstanden ist.
|
||||
///
|
||||
/// Gelesen als Änderungsdatum der eigenen Binärdatei — kein Build-Skript,
|
||||
|
||||
@@ -8,7 +8,6 @@ import FlixyDownloads
|
||||
import FlixyPlayback
|
||||
import JellyfinKit
|
||||
import MediaKit
|
||||
import PlexKit
|
||||
|
||||
@main
|
||||
struct FlixyApp: App {
|
||||
@@ -41,12 +40,7 @@ struct FlixyApp: App {
|
||||
private let modelContainer: ModelContainer
|
||||
|
||||
init() {
|
||||
let identity = PlexClientIdentity.current(
|
||||
product: Branding.plexProduct,
|
||||
version: Branding.version
|
||||
)
|
||||
_session = State(initialValue: FlixySession(
|
||||
identity: identity,
|
||||
jellyfinIdentity: JellyfinIdentity.current(
|
||||
client: Branding.appName,
|
||||
version: Branding.version
|
||||
|
||||
+1
-12
@@ -18,17 +18,6 @@
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.scarriffleservices.flixy.auth</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>com.scarriffleservices.flixy</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
@@ -39,7 +28,7 @@
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Flixy benötigt Zugriff auf das lokale Netzwerk, um deinen Plex-Server zu finden und Medien direkt von ihm zu laden.</string>
|
||||
<string>Flixy benötigt Zugriff auf das lokale Netzwerk, um deinen Jellyfin-Server zu erreichen und Medien direkt von ihm zu laden.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
|
||||
@@ -888,39 +888,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics.liveTV": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Live-TV-Antwort ansehen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Inspect Live TV response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics.liveTVHint": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Zeigt den Aufbau der Serverantwort — nur Schlüssel, Anzahl und Sendernamen, keine Zugangsdaten. Hilft dabei zu finden, wo die Sender stehen."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Shows the structure of the server's response — keys, counts and channel names only, no credentials. Helps locate where the channels are."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics.method": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -970,23 +937,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics.relayWarning": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Über die Plex-Vermittlung. Stark gedrosselt — große Dateien laufen darüber kaum an."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Via the Plex relay. Heavily throttled — large files barely start over it."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics.source": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -1801,23 +1751,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"error.noServers": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "An deinem Plex-Konto hängt kein Server."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "There's no server on your Plex account."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"error.notFound": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -1909,13 +1842,13 @@
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Die Antwort des Servers war unerwartet. Möglicherweise ist eine neuere Plex-Version installiert."
|
||||
"value": "Die Antwort des Servers war unerwartet. Möglicherweise läuft dort eine neuere Fassung, als diese App kennt."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The server's response was unexpected. It may be running a newer version of Plex."
|
||||
"value": "The server's response was unexpected. It may be running a newer version than this app knows."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3371,13 +3304,13 @@
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Live-TV braucht eine Fernsehkarte oder einen Netzwerk-Tuner am Plex-Server. Richte ihn in den Servereinstellungen ein, dann erscheinen die Sender hier."
|
||||
"value": "Live-TV braucht eine Fernsehkarte oder einen Netzwerk-Tuner am Server. Richte ihn in den Servereinstellungen ein, dann erscheinen die Sender hier."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Live TV needs a tuner card or network tuner on your Plex server. Set one up in the server settings and channels will appear here."
|
||||
"value": "Live TV needs a tuner card or network tuner on your server. Set one up in the server settings and channels will appear here."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4158,24 +4091,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.relayWarning": {
|
||||
"comment": "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft",
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Die Verbindung läuft über die Vermittlung von Plex und ist stark gedrosselt. Große Dateien laufen darüber kaum an."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "You're connected through the Plex relay, which is heavily throttled. Large files barely start over it."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.rememberedTrack": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
@@ -4418,13 +4333,13 @@
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Plex kennt für Filme keinen reinen Tonstrom. Diese Stufe überträgt das Bild in der kleinsten Auflösung und blendet es aus."
|
||||
"value": "Der Server kennt für Filme keinen reinen Tonstrom. Diese Stufe überträgt das Bild in der kleinsten Auflösung und blendet es aus."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Plex has no audio-only stream for video. This setting sends the picture at the smallest size and hides it."
|
||||
"value": "The server has no audio-only stream for video. This setting sends the picture at the smallest size and hides it."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5142,56 +5057,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"serverPicker.owned": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Eigener Server"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Your server"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"serverPicker.shared": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Geteilt"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Shared"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"serverPicker.title": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Server wählen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Choose a server"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.account": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -5395,23 +5260,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.connectionRelay": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Über Plex-Vermittlung (gedrosselt)"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Via Plex relay (throttled)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.connectionRemote": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -5998,23 +5846,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.serverKind": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Art"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Type"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.serverName": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -6770,35 +6601,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"signIn.manualHint": {
|
||||
"comment": "Hinweis, wenn die Browser-Verknüpfung nicht greift",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Hat das Fenster nicht verknüpft? Öffne plex.tv/link und gib diesen Code ein:"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Window didn't link? Open plex.tv/link and enter this code:"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"signIn.privacy": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Die Anmeldung läuft über deinen Server. Dein Passwort erreicht diese App nie."
|
||||
"value": "Die Anmeldung läuft unmittelbar mit deinem Server. Kein fremder Dienst ist daran beteiligt."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Sign-in happens on your server. Your password never reaches this app."
|
||||
"value": "You sign in directly with your own server. No third-party service is involved."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6820,41 +6634,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"signIn.timedOut": {
|
||||
"comment": "Die Anmeldung wurde nicht innerhalb der Gültigkeit des Codes abgeschlossen",
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Die Anmeldung wurde nicht abgeschlossen. Versuch es noch einmal."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Sign-in wasn't completed. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"signIn.windowFailed": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Das Anmeldefenster ließ sich nicht öffnen."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The sign-in window couldn't be opened."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"signIn.withJellyfin": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -6871,22 +6650,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"signIn.withPlex": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Mit Plex anmelden"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Sign in with Plex"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sort.lastPlayed": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
|
||||
@@ -2,7 +2,6 @@ import Foundation
|
||||
import JellyfinKit
|
||||
import MediaKit
|
||||
import Observation
|
||||
import PlexKit
|
||||
|
||||
/// Der Zustand der Verbindung zum Server.
|
||||
///
|
||||
@@ -17,54 +16,22 @@ import PlexKit
|
||||
@MainActor
|
||||
@Observable
|
||||
final class FlixySession {
|
||||
/// Welche Art von Server gerade bedient wird.
|
||||
enum ServerKind: String, Sendable, CaseIterable {
|
||||
case plex
|
||||
case jellyfin
|
||||
}
|
||||
|
||||
enum Phase: Equatable {
|
||||
/// Beim Start: liegt ein Token im Schlüsselbund?
|
||||
case launching
|
||||
case signedOut
|
||||
/// Das Anmeldefenster von Plex ist offen.
|
||||
case authenticating
|
||||
/// Mehrere Server am Konto — einer muss gewählt werden.
|
||||
case choosingServer([PlexResource])
|
||||
/// Ein Weg zum Server wird gesucht.
|
||||
case connecting(serverName: String)
|
||||
case ready
|
||||
case failed(message: String)
|
||||
|
||||
static func == (lhs: Phase, rhs: Phase) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.launching, .launching), (.signedOut, .signedOut), (.ready, .ready):
|
||||
true
|
||||
case (.authenticating, .authenticating):
|
||||
true
|
||||
case (.choosingServer(let l), .choosingServer(let r)):
|
||||
l.map(\.clientIdentifier) == r.map(\.clientIdentifier)
|
||||
case (.connecting(let l), .connecting(let r)):
|
||||
l == r
|
||||
case (.failed(let l), .failed(let r)):
|
||||
l == r
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
// Kein eigener ==-Operator mehr: er war nur nötig, weil `PlexResource`
|
||||
// nicht `Equatable` war und über die Kennung verglichen werden musste.
|
||||
// Alle verbliebenen Nutzlasten sind `String`, also leitet der Compiler
|
||||
// die Gleichheit selbst ab — und ein künftiger Fall wird nicht
|
||||
// stillschweigend als „ungleich" behandelt.
|
||||
}
|
||||
|
||||
private enum Key {
|
||||
/// Welche Serverart zuletzt aktiv war.
|
||||
///
|
||||
/// Fehlt der Eintrag, aber es liegt ein Plex-Token bereit, war es
|
||||
/// Plex — so kam die App bis hierher, und niemand soll durch ein
|
||||
/// Update abgemeldet werden.
|
||||
static let serverKind = "flixy.backend.kind"
|
||||
static let preferredServer = "flixy.server.clientIdentifier"
|
||||
static let cachedConnectionURI = "flixy.server.connectionURI"
|
||||
static let cachedServerName = "flixy.server.name"
|
||||
static let cachedConnectionIsLocal = "flixy.server.connectionIsLocal"
|
||||
static let jellyfinAddress = "flixy.jellyfin.address"
|
||||
static let jellyfinUserID = "flixy.jellyfin.userID"
|
||||
static let jellyfinUserName = "flixy.jellyfin.userName"
|
||||
@@ -72,33 +39,38 @@ final class FlixySession {
|
||||
static let jellyfinIsAdministrator = "flixy.jellyfin.isAdministrator"
|
||||
}
|
||||
|
||||
/// Unter welchem Namen die Server-Token im Schlüsselbund liegen.
|
||||
/// Unter welchem Namen das Server-Token im Schlüsselbund liegt.
|
||||
///
|
||||
/// Nicht in den Voreinstellungen: sie geben vollen Zugriff auf die
|
||||
/// Mediathek. Getrennte Konten je Serverart, damit ein späteres Umschalten
|
||||
/// zwischen beiden nicht jedes Mal eine neue Anmeldung kostet. Das
|
||||
/// ausdrückliche Abmelden räumt trotzdem beide ab — siehe `signOut()`.
|
||||
private static let plexTokenAccount = "plex.server"
|
||||
/// Nicht in den Voreinstellungen: es gibt vollen Zugriff auf die
|
||||
/// 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"
|
||||
|
||||
/// Wird für die Anmelde-Adresse gebraucht: plex.tv ordnet den
|
||||
/// bestätigten Code darüber diesem Gerät zu.
|
||||
var clientIdentifier: String { identity.clientIdentifier }
|
||||
|
||||
/// Der Code der laufenden Anmeldung.
|
||||
/// Was die Plex-Zeit auf dem Gerät hinterlassen hat.
|
||||
///
|
||||
/// Wird nach kurzer Zeit im Anmeldebildschirm eingeblendet — als Ausweg,
|
||||
/// falls die Browser-Seite den Code nicht verknüpft. Das passiert, wenn
|
||||
/// man dort schon angemeldet ist, und ohne diesen Ausweg säße man fest.
|
||||
private(set) var pendingCode: String?
|
||||
private(set) var showsManualCode = false
|
||||
/// **Diese Namen stehen sonst nirgends mehr.** Mit dem Plex-Code ging auch
|
||||
/// das Einzige, was die beiden Token je wieder entfernt hätte — wer sich
|
||||
/// nie abgemeldet hat, trüge ein gültiges plex.tv-Token bis in alle
|
||||
/// Ewigkeit im Schlüsselbund, ohne dass irgendein Weg es noch löschen
|
||||
/// könnte. Deshalb wird einmalig aufgeräumt, bevor die Namen endgültig
|
||||
/// verschwinden.
|
||||
private enum Altlast {
|
||||
static let merker = "flixy.migration.plexPurged"
|
||||
static let tokenKonten = ["plex.account", "plex.server"]
|
||||
static let schlüssel = [
|
||||
"flixy.backend.kind",
|
||||
"flixy.server.clientIdentifier",
|
||||
"flixy.server.connectionURI",
|
||||
"flixy.server.name",
|
||||
"flixy.server.connectionIsLocal",
|
||||
]
|
||||
}
|
||||
|
||||
/// Ob der Server in **demselben** Netz steht wie wir.
|
||||
///
|
||||
/// Entscheidet, ob „Auto" gedeckelt anfängt: zuhause trägt die Leitung
|
||||
/// das Original, unterwegs nicht. Bei Plex steht es im gewählten Weg; bei
|
||||
/// Jellyfin wird der Server gefragt, weil ein Blick auf die Adresse nicht
|
||||
/// genügt — wer über einen Reverse Proxy geht, erreicht seinen Server
|
||||
/// das Original, unterwegs nicht. Der Server wird danach gefragt, weil
|
||||
/// ein Blick auf die Adresse nicht genügt — wer über einen Reverse Proxy geht, erreicht seinen Server
|
||||
/// auch von der Couch aus unter einem öffentlichen Namen.
|
||||
///
|
||||
/// Im Zweifel `false`: lieber einmal zu vorsichtig gedeckelt als eine
|
||||
@@ -106,32 +78,19 @@ final class FlixySession {
|
||||
private(set) var isLocalConnection = false
|
||||
|
||||
private(set) var phase: Phase = .launching
|
||||
/// Welche Serverart gerade bedient wird — `nil`, solange niemand
|
||||
/// angemeldet ist.
|
||||
private(set) var kind: ServerKind?
|
||||
|
||||
/// Der aktive Server hinter dem neutralen Vertrag.
|
||||
///
|
||||
/// Die Ansichten sprechen ausschließlich hierüber. Ob dahinter Plex oder
|
||||
/// Jellyfin steckt, sehen sie nicht — und genau deshalb musste für
|
||||
/// Jellyfin keine einzige Ansicht neu geschrieben werden.
|
||||
/// Die Ansichten sprechen ausschließlich hierüber und sehen nicht, wer
|
||||
/// dahintersteckt. Dass dieser Vertrag einmal zwei Server gleichzeitig
|
||||
/// getragen hat, ist kein Zufall, sondern der Grund, warum für einen
|
||||
/// weiteren keine einzige Ansicht neu geschrieben werden muss.
|
||||
private(set) var backend: (any MediaBackend)?
|
||||
|
||||
/// Der Plex-Server, solange Plex aktiv ist.
|
||||
///
|
||||
/// Player, Live-TV und die Diagnose sprechen noch unmittelbar mit ihm.
|
||||
/// Bei Jellyfin ist er `nil` — diese drei Bereiche wandern als Nächstes
|
||||
/// auf den neutralen Vertrag.
|
||||
private(set) var server: PlexServer?
|
||||
|
||||
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
|
||||
private(set) var jellyfinAccount: JellyfinAccount?
|
||||
|
||||
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
|
||||
///
|
||||
/// Plex führt den Kontonamen am Konto, nicht am Server; dort steht er hier
|
||||
/// noch nicht zur Verfügung, und deshalb bleibt die Zeile leer, statt
|
||||
/// etwas Falsches zu behaupten.
|
||||
var accountName: String? { jellyfinAccount?.userName }
|
||||
|
||||
/// Die Mediatheken, die Flixy darstellen kann — Musik und Fotos
|
||||
@@ -142,46 +101,31 @@ final class FlixySession {
|
||||
var serverName: String { backend?.serverName ?? "" }
|
||||
|
||||
/// Unter welcher Adresse er erreicht wird.
|
||||
var serverAddress: String? {
|
||||
switch kind {
|
||||
case .plex: server?.connection.uri
|
||||
case .jellyfin: defaults.string(forKey: Key.jellyfinAddress)
|
||||
case nil: nil
|
||||
}
|
||||
}
|
||||
var serverAddress: String? { defaults.string(forKey: Key.jellyfinAddress) }
|
||||
|
||||
/// Wie die Verbindung zum Server zu beschreiben ist.
|
||||
///
|
||||
/// Für die Diagnose, und deshalb hier: welcher Weg zum Server führt, weiß
|
||||
/// die Sitzung. Bei Plex ist es die entscheidende Angabe — über die
|
||||
/// Vermittlung von plex.tv ist alles gedrosselt, und das erklärt die
|
||||
/// Hälfte aller Ruckler, bevor man weitersucht.
|
||||
/// die Sitzung. Nah oder fern ist dabei die Angabe, die etwas erklärt —
|
||||
/// an ihr hängt, ob „Auto" gedeckelt anfängt, und damit die Hälfte aller
|
||||
/// Fragen nach Rucklern. Die blosse Adresse steht schon eine Zeile höher.
|
||||
var connectionDescription: String? {
|
||||
switch kind {
|
||||
case .plex:
|
||||
guard let connection = server?.connection else { return nil }
|
||||
if connection.local { return "Lokal · \(connection.uri)" }
|
||||
if connection.relay { return "Plex-Vermittlung (gedrosselt)" }
|
||||
return "Direkt von außen · \(connection.uri)"
|
||||
case .jellyfin:
|
||||
return serverAddress
|
||||
case nil:
|
||||
return nil
|
||||
}
|
||||
guard let serverAddress else { return nil }
|
||||
return isLocalConnection
|
||||
? "Lokal · \(serverAddress)"
|
||||
: "Von außen · \(serverAddress)"
|
||||
}
|
||||
|
||||
/// Ob der aktive Server Live-TV anbietet.
|
||||
///
|
||||
/// Endlich die Fähigkeit selbst. Hier stand `server != nil` — also „nur
|
||||
/// Plex" —, weil der Programmführer unmittelbar mit Plex sprach; einen
|
||||
/// Die Fähigkeit selbst, nicht der Umweg über die Serverart. Einen
|
||||
/// Eintrag zu zeigen, der zu einem leeren Bildschirm führt, wäre eine
|
||||
/// Zusage gewesen, die niemand einlöst. Seit Live-TV über den neutralen
|
||||
/// Vertrag läuft, löst sie jeder ein, der sie gibt.
|
||||
/// Zusage, die niemand einlöst — so gibt sie nur, wer sie auch einlöst.
|
||||
var supportsLiveTV: Bool { backend?.capabilities.contains(.liveTV) == true }
|
||||
|
||||
/// Ob er darüber hinaus aufnehmen kann.
|
||||
///
|
||||
/// Getrennt gefragt, weil Plex das eine kann und das andere nicht.
|
||||
/// Getrennt gefragt: ein Server kann empfangen, ohne aufnehmen zu können.
|
||||
var supportsRecordings: Bool { backend?.capabilities.contains(.liveTVRecordings) == true }
|
||||
|
||||
/// Der Server als Live-TV-Server — sofern er einer ist.
|
||||
@@ -192,229 +136,83 @@ final class FlixySession {
|
||||
|
||||
/// Warum gerade nicht abgespielt werden kann.
|
||||
///
|
||||
/// Bei Jellyfin ist die Anmeldung fertig, die Wiedergabe noch nicht — sie
|
||||
/// ist der nächste Schritt. „Server nicht erreichbar" wäre an dieser
|
||||
/// Stelle schlicht gelogen: er ist erreichbar, die App kann nur noch
|
||||
/// nichts von ihm abspielen.
|
||||
/// 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.
|
||||
var playbackUnavailableMessage: String {
|
||||
kind == .jellyfin
|
||||
backend != nil
|
||||
? String(localized: "player.jellyfinPending")
|
||||
: String(localized: "error.serverUnreachable")
|
||||
}
|
||||
|
||||
private let identity: PlexClientIdentity
|
||||
private let jellyfinIdentity: JellyfinIdentity
|
||||
private let tokenStore: any TokenStoring
|
||||
private let authenticator: PlexAuthenticator
|
||||
private let webAuthenticator = PlexWebAuthenticator()
|
||||
private let defaults: UserDefaults
|
||||
private var authorizationTask: Task<Void, Never>?
|
||||
|
||||
init(
|
||||
identity: PlexClientIdentity,
|
||||
jellyfinIdentity: JellyfinIdentity,
|
||||
tokenStore: any TokenStoring,
|
||||
defaults: UserDefaults = .standard
|
||||
) {
|
||||
self.identity = identity
|
||||
self.jellyfinIdentity = jellyfinIdentity
|
||||
self.tokenStore = tokenStore
|
||||
self.defaults = defaults
|
||||
self.authenticator = PlexAuthenticator(identity: identity, tokenStore: tokenStore)
|
||||
}
|
||||
|
||||
// MARK: - Start
|
||||
|
||||
/// Versucht, mit einem bereits gespeicherten Token weiterzumachen.
|
||||
func restore() async {
|
||||
purgePlexLeftovers()
|
||||
|
||||
#if DEBUG
|
||||
if await adoptStubServerIfRequested() { return }
|
||||
#endif
|
||||
|
||||
switch storedKind {
|
||||
case .jellyfin:
|
||||
if await restoreJellyfin() { return }
|
||||
phase = .signedOut
|
||||
|
||||
case .plex, nil:
|
||||
guard let token = authenticator.storedToken else {
|
||||
phase = .signedOut
|
||||
return
|
||||
}
|
||||
// Wer vor dieser Version angemeldet war, hat keinen Eintrag über
|
||||
// die Serverart — nachtragen, damit „nicht eingetragen" von hier
|
||||
// an eindeutig „abgemeldet" heißt.
|
||||
defaults.set(ServerKind.plex.rawValue, forKey: Key.serverKind)
|
||||
|
||||
// Erst der kurze Weg: die Verbindung, die beim letzten Mal
|
||||
// funktioniert hat. Ohne ihn kostet jeder Start eine Anfrage an
|
||||
// plex.tv plus eine neue Verbindungssuche — zusammen mehrere
|
||||
// Sekunden, in denen nur ein Ladekreis zu sehen ist.
|
||||
if await restoreFromRememberedConnection() { return }
|
||||
|
||||
await connect(accountToken: token)
|
||||
}
|
||||
}
|
||||
|
||||
private var storedKind: ServerKind? {
|
||||
defaults.string(forKey: Key.serverKind).flatMap(ServerKind.init(rawValue:))
|
||||
}
|
||||
|
||||
/// Nimmt die zuletzt funktionierende Verbindung wieder auf.
|
||||
///
|
||||
/// Gibt `false` zurück, wenn nichts gespeichert ist oder der Server nicht
|
||||
/// mehr darüber erreichbar ist — dann übernimmt die vollständige Suche.
|
||||
/// Das ist der Fall, wenn man von zuhause weggeht: die lokale Adresse
|
||||
/// antwortet nicht mehr, und der direkte Weg muss neu gefunden werden.
|
||||
private func restoreFromRememberedConnection() async -> Bool {
|
||||
guard let identifier = defaults.string(forKey: Key.preferredServer),
|
||||
let uri = defaults.string(forKey: Key.cachedConnectionURI),
|
||||
let serverToken = tokenStore.token(for: Self.plexTokenAccount)
|
||||
else { return false }
|
||||
|
||||
let name = defaults.string(forKey: Key.cachedServerName) ?? ""
|
||||
phase = .connecting(serverName: name)
|
||||
|
||||
let connection = PlexConnection(
|
||||
uri: uri,
|
||||
local: defaults.bool(forKey: Key.cachedConnectionIsLocal)
|
||||
)
|
||||
let resource = PlexResource(
|
||||
name: name,
|
||||
clientIdentifier: identifier,
|
||||
accessToken: serverToken,
|
||||
connections: [connection]
|
||||
)
|
||||
|
||||
// Kurze Zeitgrenze: wenn die gemerkte Adresse nicht sofort antwortet,
|
||||
// ist die vollständige Suche schneller als weiteres Warten.
|
||||
let selector = PlexConnectionSelector(identity: identity, probeTimeout: .seconds(2))
|
||||
guard await selector.selectConnection(for: resource) != nil,
|
||||
let server = PlexServer(resource: resource, connection: connection, identity: identity)
|
||||
else { return false }
|
||||
|
||||
let plex = adopt(server: server)
|
||||
do {
|
||||
libraries = try await loadLibraries(from: plex)
|
||||
phase = .ready
|
||||
return true
|
||||
} catch {
|
||||
clearActiveServer()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Anmeldung bei Plex
|
||||
|
||||
/// Öffnet die Anmeldeseite von Plex.
|
||||
///
|
||||
/// Der PIN steckt weiterhin darunter — er ist der einzige Weg, auf dem
|
||||
/// Plex einen Token herausgibt. Sichtbar ist er nicht mehr.
|
||||
///
|
||||
/// Das Fenster wird geöffnet, aber **nicht abgewartet**: Plex führt den
|
||||
/// Rücksprung über `forwardUrl` nicht zuverlässig aus. Auf dem iPad landet
|
||||
/// der Browser nach dem Anmelden auf watch.plex.tv und bleibt dort stehen
|
||||
/// — der Token liegt längst bereit, aber die App wartete auf einen
|
||||
/// Rückruf, der nie kam. Stattdessen wird hier im Sekundentakt beim Server
|
||||
/// nachgefragt und nebenbei geprüft, ob das Fenster geschlossen wurde.
|
||||
func beginSignIn() async {
|
||||
phase = .authenticating
|
||||
|
||||
let pin: PlexPin
|
||||
do {
|
||||
pin = try await authenticator.requestPin()
|
||||
} catch {
|
||||
phase = .failed(message: ErrorText.describe(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = pin.linkURL else {
|
||||
phase = .failed(message: String(localized: "signIn.windowFailed"))
|
||||
return
|
||||
}
|
||||
|
||||
pendingCode = pin.code
|
||||
showsManualCode = false
|
||||
webAuthenticator.present(url: url, callbackScheme: Branding.urlScheme)
|
||||
|
||||
// Die Frist entspricht der Gültigkeit des Codes bei Plex.
|
||||
for second in 0..<900 {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
|
||||
// Nach einer Viertelminute den Code zeigen. Wer bis dahin nicht
|
||||
// durch ist, hat vermutlich den Fall erwischt, in dem die Seite
|
||||
// nicht verknüpft — dann hilft nur noch das Abtippen.
|
||||
if second == 15 { showsManualCode = true }
|
||||
|
||||
if let token = try? await authenticator.token(for: pin) {
|
||||
webAuthenticator.dismiss()
|
||||
clearPendingCode()
|
||||
await connect(accountToken: token)
|
||||
return
|
||||
}
|
||||
|
||||
// Fenster geschlossen, ohne dass ein Token da ist.
|
||||
if webAuthenticator.didReturn {
|
||||
switch webAuthenticator.outcome {
|
||||
case .cancelled, nil:
|
||||
// Abgebrochen oder zurückgesprungen, ohne dass der Token
|
||||
// schon bereitlag. Noch ein paar Sekunden nachfragen,
|
||||
// bevor aufgegeben wird — zwischen Rücksprung und Eintrag
|
||||
// bei Plex liegt manchmal eine Sekunde.
|
||||
if let token = try? await graceTokenLookup(pin: pin) {
|
||||
clearPendingCode()
|
||||
await connect(accountToken: token)
|
||||
return
|
||||
}
|
||||
// Fenster zu, kein Token: nicht abmelden, sondern den Code
|
||||
// stehen lassen. Er gilt weiter, und das Abtippen bei
|
||||
// plex.tv/link ist jetzt der schnellste Weg.
|
||||
showsManualCode = true
|
||||
case .presentationFailed(let description):
|
||||
phase = .failed(message: description)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
webAuthenticator.dismiss()
|
||||
clearPendingCode()
|
||||
phase = .failed(message: String(localized: "signIn.timedOut"))
|
||||
}
|
||||
|
||||
private func clearPendingCode() {
|
||||
pendingCode = nil
|
||||
showsManualCode = false
|
||||
}
|
||||
|
||||
/// Fragt nach dem Schließen des Fensters noch ein paar Sekunden nach.
|
||||
private func graceTokenLookup(pin: PlexPin) async throws -> String? {
|
||||
for _ in 0..<5 {
|
||||
if let token = try? await authenticator.token(for: pin) { return token }
|
||||
try? await Task.sleep(for: .milliseconds(700))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cancelSignIn() {
|
||||
authorizationTask?.cancel()
|
||||
authorizationTask = nil
|
||||
webAuthenticator.dismiss()
|
||||
clearPendingCode()
|
||||
if await restoreJellyfin() { return }
|
||||
phase = .signedOut
|
||||
}
|
||||
|
||||
/// Meldet ab — von allen Servern, nicht nur vom gerade aktiven.
|
||||
/// Räumt einmal weg, was die Plex-Zeit hinterlassen hat.
|
||||
///
|
||||
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen". Nur den
|
||||
/// aktiven zu vergessen hieße, dass nach dem Abmelden beim nächsten Start
|
||||
/// wieder eine Mediathek dasteht — die des anderen Servers.
|
||||
/// **Der einzige Weg, auf dem das je wieder passiert.** Vorher entfernte
|
||||
/// `signOut()` die Plex-Token über den Authenticator; den gibt es nicht
|
||||
/// mehr, und `plex.account` stünde sonst in keiner Zeile dieses Projekts.
|
||||
/// Wer bis heute angemeldet war und nie auf „Abmelden" gedrückt hat,
|
||||
/// behielte ein gültiges Token am fremden Konto — unsichtbar und
|
||||
/// unlöschbar.
|
||||
///
|
||||
/// Der Merker wird **vor** der Prüfung gesetzt: ein Aufräumen, das
|
||||
/// scheitert, soll nicht bei jedem Start wiederkehren.
|
||||
private func purgePlexLeftovers() {
|
||||
guard !defaults.bool(forKey: Altlast.merker) else { return }
|
||||
defaults.set(true, forKey: Altlast.merker)
|
||||
|
||||
// Nur anfassen, wo auch etwas liegt. Sonst verlöre jeder
|
||||
// Jellyfin-Nutzer beim ersten Start seinen Mediatheken-Speicher für
|
||||
// nichts.
|
||||
let hattePlex = Altlast.tokenKonten.contains { tokenStore.token(for: $0) != nil }
|
||||
|| Altlast.schlüssel.contains { defaults.object(forKey: $0) != nil }
|
||||
guard hattePlex else { return }
|
||||
|
||||
for konto in Altlast.tokenKonten { tokenStore.remove(for: konto) }
|
||||
for schlüssel in Altlast.schlüssel { defaults.removeObject(forKey: schlüssel) }
|
||||
// Titel und Sehfortschritt gehörten zu einem Konto, das es hier nicht
|
||||
// mehr gibt. Gelesen würden sie ohnehin nie wieder.
|
||||
Task { await LibraryCache.shared.clearAll() }
|
||||
}
|
||||
|
||||
/// Meldet ab und räumt dabei alles ab, was zur Anmeldung gehörte.
|
||||
///
|
||||
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach
|
||||
/// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen.
|
||||
func signOut() {
|
||||
authorizationTask?.cancel()
|
||||
authenticator.signOut()
|
||||
forgetRememberedConnection()
|
||||
forgetJellyfin()
|
||||
defaults.removeObject(forKey: Key.serverKind)
|
||||
clearActiveServer()
|
||||
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
|
||||
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
|
||||
@@ -423,91 +221,6 @@ final class FlixySession {
|
||||
phase = .signedOut
|
||||
}
|
||||
|
||||
// MARK: - Verbinden mit Plex
|
||||
|
||||
private func connect(accountToken: String) async {
|
||||
phase = .connecting(serverName: "")
|
||||
|
||||
let directory = PlexServerDirectory(identity: identity, token: accountToken)
|
||||
let servers: [PlexResource]
|
||||
do {
|
||||
servers = try await directory.servers()
|
||||
} catch PlexError.unauthorized {
|
||||
// Der Token wurde am Konto zurückgezogen. Ihn zu behalten würde
|
||||
// bei jedem Start denselben Fehler erzeugen.
|
||||
authenticator.signOut()
|
||||
phase = .signedOut
|
||||
return
|
||||
} catch {
|
||||
phase = .failed(message: ErrorText.describe(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard !servers.isEmpty else {
|
||||
phase = .failed(message: String(localized: "error.noServers"))
|
||||
return
|
||||
}
|
||||
|
||||
// Beim zweiten Start soll niemand erneut wählen müssen.
|
||||
let remembered = defaults.string(forKey: Key.preferredServer)
|
||||
if let chosen = servers.first(where: { $0.clientIdentifier == remembered }) ?? servers.singleElement {
|
||||
await connect(to: chosen)
|
||||
} else {
|
||||
phase = .choosingServer(servers)
|
||||
}
|
||||
}
|
||||
|
||||
func connect(to resource: PlexResource) async {
|
||||
phase = .connecting(serverName: resource.name)
|
||||
|
||||
let selector = PlexConnectionSelector(identity: identity)
|
||||
guard let connection = await selector.selectConnection(for: resource) else {
|
||||
phase = .failed(message: String(localized: "error.serverUnreachable"))
|
||||
return
|
||||
}
|
||||
|
||||
guard let server = PlexServer(
|
||||
resource: resource,
|
||||
connection: connection,
|
||||
identity: identity
|
||||
) else {
|
||||
phase = .failed(message: String(localized: "error.serverUnreachable"))
|
||||
return
|
||||
}
|
||||
|
||||
let plex = adopt(server: server)
|
||||
remember(resource: resource, connection: connection)
|
||||
|
||||
do {
|
||||
libraries = try await loadLibraries(from: plex)
|
||||
phase = .ready
|
||||
} catch {
|
||||
phase = .failed(message: ErrorText.describe(error))
|
||||
}
|
||||
}
|
||||
|
||||
/// Merkt sich, worüber es funktioniert hat.
|
||||
private func remember(resource: PlexResource, connection: PlexConnection) {
|
||||
defaults.set(ServerKind.plex.rawValue, forKey: Key.serverKind)
|
||||
defaults.set(resource.clientIdentifier, forKey: Key.preferredServer)
|
||||
defaults.set(connection.uri, forKey: Key.cachedConnectionURI)
|
||||
defaults.set(connection.local, forKey: Key.cachedConnectionIsLocal)
|
||||
isLocalConnection = connection.local
|
||||
defaults.set(resource.name, forKey: Key.cachedServerName)
|
||||
if let token = resource.accessToken {
|
||||
try? tokenStore.store(token, for: Self.plexTokenAccount)
|
||||
}
|
||||
}
|
||||
|
||||
/// Vergisst die gemerkte Verbindung.
|
||||
private func forgetRememberedConnection() {
|
||||
for key in [Key.preferredServer, Key.cachedConnectionURI,
|
||||
Key.cachedConnectionIsLocal, Key.cachedServerName] {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
tokenStore.remove(for: Self.plexTokenAccount)
|
||||
}
|
||||
|
||||
// MARK: - Verbinden mit Jellyfin
|
||||
|
||||
/// Übernimmt eine fertige Jellyfin-Anmeldung.
|
||||
@@ -540,7 +253,6 @@ final class FlixySession {
|
||||
let client = JellyfinClient(baseURL: baseURL, identity: jellyfinIdentity, token: account.token)
|
||||
let backend = JellyfinBackend(client: client, account: account, serverName: "Attrappe")
|
||||
|
||||
kind = .jellyfin
|
||||
jellyfinAccount = account
|
||||
self.backend = backend
|
||||
|
||||
@@ -563,7 +275,6 @@ final class FlixySession {
|
||||
|
||||
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
|
||||
clearActiveServer()
|
||||
kind = .jellyfin
|
||||
jellyfinAccount = account
|
||||
self.backend = backend
|
||||
isLocalConnection = await backend.isInLocalNetwork()
|
||||
@@ -573,7 +284,6 @@ final class FlixySession {
|
||||
} catch {
|
||||
self.backend = nil
|
||||
jellyfinAccount = nil
|
||||
kind = nil
|
||||
phase = .failed(message: ErrorText.describe(error))
|
||||
return
|
||||
}
|
||||
@@ -609,7 +319,6 @@ final class FlixySession {
|
||||
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
|
||||
|
||||
clearActiveServer()
|
||||
kind = .jellyfin
|
||||
jellyfinAccount = account
|
||||
self.backend = backend
|
||||
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
|
||||
@@ -633,7 +342,6 @@ final class FlixySession {
|
||||
}
|
||||
|
||||
private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) {
|
||||
defaults.set(ServerKind.jellyfin.rawValue, forKey: Key.serverKind)
|
||||
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
|
||||
defaults.set(account.userID, forKey: Key.jellyfinUserID)
|
||||
defaults.set(account.userName, forKey: Key.jellyfinUserName)
|
||||
@@ -652,22 +360,6 @@ final class FlixySession {
|
||||
|
||||
// MARK: - Der aktive Server
|
||||
|
||||
/// Setzt Plex als aktiven Server und gibt die Hülle zurück.
|
||||
///
|
||||
/// Zurückgegeben, damit der Aufrufer sie benutzen kann, ohne sich ein
|
||||
/// zweites Mal eine zu bauen — die trüge eine eigene Live-TV-Sitzung mit
|
||||
/// sich, und dann wären zwei im Umlauf, die sich nichts voneinander
|
||||
/// erzählen.
|
||||
@discardableResult
|
||||
private func adopt(server: PlexServer) -> PlexBackend {
|
||||
let hülle = PlexBackend(server: server)
|
||||
kind = .plex
|
||||
self.server = server
|
||||
backend = hülle
|
||||
jellyfinAccount = nil
|
||||
return hülle
|
||||
}
|
||||
|
||||
/// Holt die Mediathekenliste und merkt sie sich.
|
||||
///
|
||||
/// **Der Speicher ist hier nur ein Rettungsanker**, kein Beschleuniger:
|
||||
@@ -691,26 +383,15 @@ final class FlixySession {
|
||||
}
|
||||
|
||||
private func clearActiveServer() {
|
||||
server = nil
|
||||
backend = nil
|
||||
jellyfinAccount = nil
|
||||
kind = nil
|
||||
libraries = []
|
||||
}
|
||||
|
||||
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
|
||||
func retry() async {
|
||||
switch storedKind {
|
||||
case .jellyfin:
|
||||
if await restoreJellyfin() { return }
|
||||
phase = .signedOut
|
||||
case .plex, nil:
|
||||
if let token = authenticator.storedToken {
|
||||
await connect(accountToken: token)
|
||||
} else {
|
||||
phase = .signedOut
|
||||
}
|
||||
}
|
||||
if await restoreJellyfin() { return }
|
||||
phase = .signedOut
|
||||
}
|
||||
|
||||
/// Die Kennung für Jellyfin-Anmeldungen.
|
||||
@@ -720,8 +401,3 @@ final class FlixySession {
|
||||
/// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers.
|
||||
var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity }
|
||||
}
|
||||
|
||||
extension Array {
|
||||
/// Das einzige Element — oder `nil`, wenn es keines oder mehrere gibt.
|
||||
var singleElement: Element? { count == 1 ? first : nil }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import MediaKit
|
||||
import PlexKit
|
||||
|
||||
/// Welche Reihen die Startseite zeigt und in welcher Reihenfolge.
|
||||
///
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
#if os(tvOS)
|
||||
import Foundation
|
||||
|
||||
/// **Auf dem Fernseher gibt es kein Anmeldefenster.**
|
||||
///
|
||||
/// `ASWebAuthenticationSession` steht im tvOS-SDK, aber fast alles daran ist
|
||||
/// als nicht verfügbar markiert: kein `presentationContextProvider`, kein
|
||||
/// `prefersEphemeralWebBrowserSession`, nicht einmal `cancel()`. Ein
|
||||
/// Fernseher hat auch keinen Browser, in dem sich jemand bei Plex anmelden
|
||||
/// würde — und Plex ist in der ersten Fassung ohnehin nicht dabei.
|
||||
///
|
||||
/// **Der Typ bleibt trotzdem stehen.** `FlixySession` hält ihn als Feld und
|
||||
/// ruft ihn an sechs Stellen; ihn herauszuschneiden hiesse, die
|
||||
/// Zustandsmaschine der Anmeldung für eine Plattform zu spalten, die diesen
|
||||
/// Zweig nie betritt. Dieselbe Bauform wie bei `OrientationLock`: derselbe
|
||||
/// Name, dieselben Aufrufe, und nichts passiert.
|
||||
@MainActor
|
||||
final class PlexWebAuthenticator {
|
||||
enum Failure: Error, Equatable {
|
||||
case cancelled
|
||||
case presentationFailed(String)
|
||||
}
|
||||
|
||||
/// Immer „ging nicht": wer hier landet, soll nicht warten.
|
||||
private(set) var outcome: Failure?
|
||||
private(set) var didReturn = false
|
||||
|
||||
func present(url: URL, callbackScheme: String) {
|
||||
outcome = .presentationFailed(String(localized: "signIn.windowFailed"))
|
||||
didReturn = true
|
||||
}
|
||||
|
||||
func dismiss() {}
|
||||
}
|
||||
#else
|
||||
import AuthenticationServices
|
||||
import Foundation
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Öffnet die Anmeldeseite von Plex in einem Fenster innerhalb der App.
|
||||
///
|
||||
/// `ASWebAuthenticationSession` ist dafür der vorgesehene Weg: die Seite kommt
|
||||
/// von Plex, das Passwort wird dort eingegeben und erreicht die App nie. Nach
|
||||
/// dem Anmelden schickt Plex den Browser auf eine Adresse mit unserem eigenen
|
||||
/// Schema, das Fenster schließt sich von selbst, und die App weiß, dass sie
|
||||
/// den Token abholen kann.
|
||||
///
|
||||
/// Ein einfacher `SFSafariViewController` täte es nicht: er meldet nicht, wann
|
||||
/// die Rückadresse erreicht wurde, und müsste von Hand geschlossen werden.
|
||||
@MainActor
|
||||
final class PlexWebAuthenticator: NSObject {
|
||||
enum Failure: Error, Equatable {
|
||||
/// Der Nutzer hat das Fenster geschlossen. Kein Fehler im eigentlichen
|
||||
/// Sinn — die Oberfläche soll darauf kommentarlos zurückgehen.
|
||||
case cancelled
|
||||
case presentationFailed(String)
|
||||
}
|
||||
|
||||
/// Das laufende Fenster — damit es sich auch von außen schließen lässt.
|
||||
///
|
||||
/// Nötig, weil Plex den Rücksprung nicht immer ausführt: manchmal landet
|
||||
/// der Browser nach dem Anmelden auf watch.plex.tv und bleibt dort stehen.
|
||||
/// Der Token liegt dann längst bereit, nur das Fenster weiß es nicht.
|
||||
private var session: ASWebAuthenticationSession?
|
||||
|
||||
/// Wie das Fenster ausgegangen ist.
|
||||
///
|
||||
/// `nil`, solange es noch offen ist.
|
||||
private(set) var outcome: Failure?
|
||||
private(set) var didReturn = false
|
||||
|
||||
/// Öffnet das Anmeldefenster und kehrt **sofort** zurück.
|
||||
///
|
||||
/// Bewusst nicht abwartend: Plex führt den Rücksprung über `forwardUrl`
|
||||
/// nicht zuverlässig aus. Auf dem iPad landet der Browser nach dem
|
||||
/// Anmelden auf watch.plex.tv und bleibt stehen — der Token liegt längst
|
||||
/// bereit, aber ein abwartender Aufruf hinge auf einen Rückruf, der nie
|
||||
/// kommt. Der Aufrufer fragt deshalb parallel beim Server nach und sieht
|
||||
/// hier nur nach, ob das Fenster zwischendurch geschlossen wurde.
|
||||
func present(url: URL, callbackScheme: String) {
|
||||
outcome = nil
|
||||
didReturn = false
|
||||
|
||||
let session = ASWebAuthenticationSession(
|
||||
url: url,
|
||||
callbackURLScheme: callbackScheme
|
||||
) { [weak self] _, error in
|
||||
// ASWebAuthenticationSession ruft den Abschlussblock auf dem
|
||||
// Hauptthread auf — anders als mancher Rückruf, wo dieselbe Annahme die App
|
||||
// zum Absturz gebracht hat. Hier ist sie dokumentiert.
|
||||
MainActor.assumeIsolated {
|
||||
guard let self else { return }
|
||||
if let error = error as? ASWebAuthenticationSessionError,
|
||||
error.code == .canceledLogin {
|
||||
self.outcome = .cancelled
|
||||
} else if let error {
|
||||
self.outcome = .presentationFailed(error.localizedDescription)
|
||||
}
|
||||
self.didReturn = true
|
||||
self.session = nil
|
||||
}
|
||||
}
|
||||
|
||||
session.presentationContextProvider = self
|
||||
// `false`, damit eine bestehende Plex-Anmeldung in Safari mitgenutzt
|
||||
// wird: wer dort schon angemeldet ist, braucht nur zu bestätigen.
|
||||
session.prefersEphemeralWebBrowserSession = false
|
||||
|
||||
guard session.start() else {
|
||||
outcome = .presentationFailed(String(localized: "signIn.windowFailed"))
|
||||
didReturn = true
|
||||
return
|
||||
}
|
||||
self.session = session
|
||||
}
|
||||
}
|
||||
|
||||
extension PlexWebAuthenticator {
|
||||
/// Schließt das Anmeldefenster.
|
||||
///
|
||||
/// Wird gerufen, sobald der Token auf anderem Weg eingetroffen ist — sonst
|
||||
/// bliebe der Browser mit der Plex-Seite offen stehen, obwohl die App
|
||||
/// schon angemeldet ist.
|
||||
func dismiss() {
|
||||
session?.cancel()
|
||||
session = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension PlexWebAuthenticator: ASWebAuthenticationPresentationContextProviding {
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
// Das Fenster der App. Gesucht statt gehalten, weil eine SwiftUI-App
|
||||
// keinen festen Verweis darauf hat — und ein frisch erzeugtes Fenster
|
||||
// erschiene hinter dem sichtbaren.
|
||||
#if canImport(UIKit)
|
||||
let scene = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first { $0.activationState == .foregroundActive }
|
||||
|
||||
return scene?.keyWindow ?? ASPresentationAnchor()
|
||||
#else
|
||||
return NSApplication.shared.keyWindow
|
||||
?? NSApplication.shared.windows.first
|
||||
?? ASPresentationAnchor()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -2,7 +2,6 @@ import SwiftUI
|
||||
import FlixyDesign
|
||||
import FlixyDownloads
|
||||
import MediaKit
|
||||
import PlexKit
|
||||
|
||||
/// Der Download-Knopf auf einer Detailseite.
|
||||
///
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
import PlexKit
|
||||
|
||||
/// Der Ladezustand eines Bildschirms.
|
||||
///
|
||||
@@ -58,15 +57,12 @@ extension LoadState {
|
||||
/// Bildschirm eine andere Erklärung.
|
||||
enum ErrorText {
|
||||
static func describe(_ error: any Error) -> String {
|
||||
// Die neutrale Form zuerst, weil die Ansichten nur noch sie sehen.
|
||||
// Plex' eigener Fehler kommt nur noch dort an, wo unmittelbar mit ihm
|
||||
// gesprochen wird — Player, Downloads, Live-TV.
|
||||
// Die neutrale Form zuerst: die Ansichten sehen nur noch sie, und
|
||||
// jeder Server übersetzt seine eigenen Fehler schon an der Grenze
|
||||
// seines Moduls dorthin.
|
||||
if let backendError = error as? MediaBackendError {
|
||||
return describe(backendError)
|
||||
}
|
||||
if let plexError = error as? PlexError {
|
||||
return describe(plexError.asMediaBackendError)
|
||||
}
|
||||
return error.localizedDescription
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import FlixyDesign
|
||||
import MediaKit
|
||||
import FlixyDownloads
|
||||
import FlixyPlayback
|
||||
import PlexKit
|
||||
|
||||
/// Die Wurzelansicht.
|
||||
///
|
||||
@@ -88,20 +87,8 @@ struct RootView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.flixyCanvas)
|
||||
|
||||
case .signedOut, .authenticating:
|
||||
SignInView(
|
||||
phase: session.phase,
|
||||
manualCode: session.showsManualCode ? session.pendingCode : nil,
|
||||
onSignIn: { Task { await session.beginSignIn() } },
|
||||
onCancel: { session.cancelSignIn() }
|
||||
)
|
||||
|
||||
case .choosingServer(let servers):
|
||||
NavigationStack {
|
||||
ServerPickerView(servers: servers) { resource in
|
||||
Task { await session.connect(to: resource) }
|
||||
}
|
||||
}
|
||||
case .signedOut:
|
||||
SignInView()
|
||||
|
||||
case .connecting(let name):
|
||||
ConnectingView(serverName: name)
|
||||
@@ -247,10 +234,11 @@ struct RootView: View {
|
||||
settings: settings,
|
||||
languages: languages,
|
||||
subtitles: subtitleAppearance,
|
||||
// **Nicht mehr über `server`.** Der ist ein `PlexServer` und bei
|
||||
// Jellyfin immer `nil` — damit galt dort jede Wiedergabe als
|
||||
// entfernt, auch zuhause am Kabel, und „Auto" hätte ein
|
||||
// 67-Mbit-Remux ohne Not heruntergerechnet.
|
||||
// **Vom Server erfragt, nicht aus der Adresse geschlossen.** Wer
|
||||
// über einen Reverse Proxy geht, erreicht seinen Server auch von
|
||||
// der Couch aus unter einem öffentlichen Namen — ihn deshalb als
|
||||
// entfernt zu führen hiesse, „Auto" würde ein 67-Mbit-Remux
|
||||
// zuhause am Kabel ohne Not herunterrechnen.
|
||||
isLocalConnection: session.isLocalConnection,
|
||||
connectionDescription: session.connectionDescription,
|
||||
unavailableMessage: session.playbackUnavailableMessage
|
||||
|
||||
@@ -4,7 +4,6 @@ import MediaKit
|
||||
import Observation
|
||||
import FlixyDownloads
|
||||
import FlixyPlayback
|
||||
import PlexKit
|
||||
|
||||
/// Führt einen Abspielwunsch aus.
|
||||
///
|
||||
|
||||
@@ -2,7 +2,6 @@ import SwiftUI
|
||||
import FlixyDesign
|
||||
import FlixyDownloads
|
||||
import FlixyPlayback
|
||||
import PlexKit
|
||||
|
||||
/// Der Vollbildplayer.
|
||||
struct PlayerView: View {
|
||||
@@ -757,18 +756,6 @@ struct PlayerView: View {
|
||||
Text("player.preparing")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
|
||||
// Über die Vermittlung von Plex ist die Leitung auf wenige Mbit
|
||||
// gedrosselt. Ein großer Film läuft darüber nicht an, und ohne
|
||||
// diesen Hinweis sucht man den Fehler in der App statt in der
|
||||
// Verbindung.
|
||||
if session.server?.connection.relay == true {
|
||||
Label("player.relayWarning", systemImage: "exclamationmark.triangle")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, FlixySpacing.snug)
|
||||
}
|
||||
}
|
||||
.padding(FlixySpacing.section)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// Was beim letzten Abspielversuch passiert ist.
|
||||
///
|
||||
@@ -11,23 +10,18 @@ struct DiagnosticsView: View {
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||
@State private var liveTVStructure: String?
|
||||
@State private var isInspectingLiveTV = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("diagnostics.connection") {
|
||||
if let server = session.server {
|
||||
LabeledContent("settings.serverName", value: server.name)
|
||||
LabeledContent("settings.address", value: server.connection.uri)
|
||||
.font(.flixyCaption)
|
||||
|
||||
if server.connection.relay {
|
||||
// Der häufigste Grund dafür, dass nichts anläuft. Ohne
|
||||
// Hinweis sucht man den Fehler in der App.
|
||||
Label("diagnostics.relayWarning", systemImage: "exclamationmark.triangle")
|
||||
if session.backend != nil {
|
||||
LabeledContent("settings.serverName", value: session.serverName)
|
||||
if let address = session.serverAddress {
|
||||
LabeledContent("settings.address", value: address)
|
||||
.font(.flixyCaption)
|
||||
}
|
||||
if let beschreibung = session.connectionDescription {
|
||||
LabeledContent("settings.connection", value: beschreibung)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyDestructive)
|
||||
}
|
||||
} else {
|
||||
Text("diagnostics.noServer").foregroundStyle(Color.flixyTextMuted)
|
||||
@@ -73,36 +67,6 @@ struct DiagnosticsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
Task { await inspectLiveTV() }
|
||||
} label: {
|
||||
if isInspectingLiveTV {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Label("diagnostics.liveTV", systemImage: "antenna.radiowaves.left.and.right")
|
||||
}
|
||||
}
|
||||
.disabled(isInspectingLiveTV)
|
||||
|
||||
if let liveTVStructure {
|
||||
Text(liveTVStructure)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
.textSelection(.enabled)
|
||||
|
||||
Button {
|
||||
FlixyClipboard.copy(liveTVStructure)
|
||||
} label: {
|
||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("sidebar.liveTV")
|
||||
} footer: {
|
||||
Text("diagnostics.liveTVHint")
|
||||
}
|
||||
|
||||
Section("settings.app") {
|
||||
LabeledContent("settings.version", value: Branding.version)
|
||||
LabeledContent("settings.build", value: Branding.buildStamp)
|
||||
@@ -122,14 +86,6 @@ struct DiagnosticsView: View {
|
||||
.flixyInlineTitle()
|
||||
}
|
||||
|
||||
/// Fragt den Server, wie seine Antwort auf `/livetv/dvrs` aufgebaut ist.
|
||||
private func inspectLiveTV() async {
|
||||
guard let server = session.server else { return }
|
||||
isInspectingLiveTV = true
|
||||
defer { isInspectingLiveTV = false }
|
||||
liveTVStructure = await server.liveTVStructure()
|
||||
}
|
||||
|
||||
/// Der Bericht als Text zum Weitergeben.
|
||||
///
|
||||
/// Enthält bewusst keinen Token: die Quelladresse ist schon ohne
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import MediaKit
|
||||
import PlexKit
|
||||
|
||||
/// Mit welchem Server gesprochen wird — und was er gerade anbietet.
|
||||
struct ServerSettingsView: View {
|
||||
@@ -12,12 +11,11 @@ struct ServerSettingsView: View {
|
||||
Form {
|
||||
Section("settings.server") {
|
||||
LabeledContent("settings.serverName", value: session.serverName)
|
||||
// Welche Art von Server — bei zwei möglichen ist das keine
|
||||
// Nebensache mehr, sondern die erste Frage bei jeder
|
||||
// Rückmeldung.
|
||||
LabeledContent("settings.serverKind", value: kindLabel)
|
||||
if let connection = session.server?.connection {
|
||||
LabeledContent("settings.connection", value: connectionLabel(connection))
|
||||
// Nah oder fern: daran hängt, ob „Auto" gedeckelt anfängt,
|
||||
// und damit die erste Frage bei jeder Rückmeldung über
|
||||
// Ruckeln.
|
||||
if session.backend != nil {
|
||||
LabeledContent("settings.connection", value: connectionLabel)
|
||||
}
|
||||
if let address = session.serverAddress {
|
||||
LabeledContent("settings.address", value: address)
|
||||
@@ -53,20 +51,12 @@ struct ServerSettingsView: View {
|
||||
.flixyInlineTitle()
|
||||
}
|
||||
|
||||
private var kindLabel: String {
|
||||
switch session.kind {
|
||||
case .plex: "Plex"
|
||||
case .jellyfin: "Jellyfin"
|
||||
case nil: "—"
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeigt, über welchen Weg die Verbindung läuft — beim Suchen nach
|
||||
/// Ruckeln ist das die erste Frage.
|
||||
private func connectionLabel(_ connection: PlexConnection) -> String {
|
||||
if connection.local { return String(localized: "settings.connectionLocal") }
|
||||
if connection.relay { return String(localized: "settings.connectionRelay") }
|
||||
return String(localized: "settings.connectionRemote")
|
||||
/// Zeigt, ob der Server im selben Netz steht — beim Suchen nach Ruckeln
|
||||
/// ist das die erste Frage.
|
||||
private var connectionLabel: String {
|
||||
session.isLocalConnection
|
||||
? String(localized: "settings.connectionLocal")
|
||||
: String(localized: "settings.connectionRemote")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// Während ein Weg zum Server gesucht wird.
|
||||
struct ConnectingView: View {
|
||||
@@ -20,58 +19,6 @@ struct ConnectingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wenn am Konto mehrere Server hängen.
|
||||
struct ServerPickerView: View {
|
||||
let servers: [PlexResource]
|
||||
let onSelect: (PlexResource) -> Void
|
||||
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
List(servers) { server in
|
||||
Button {
|
||||
onSelect(server)
|
||||
} label: {
|
||||
HStack(spacing: FlixySpacing.regular) {
|
||||
Image(systemName: server.owned ? "server.rack" : "person.2")
|
||||
.foregroundStyle(accent)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(server.name)
|
||||
.font(.flixyCardTitle)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
Text(subtitle(for: server))
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowBackground(Color.flixySurface)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(Text("serverPicker.title"))
|
||||
}
|
||||
|
||||
private func subtitle(for server: PlexResource) -> String {
|
||||
var pieces: [String] = []
|
||||
pieces.append(server.owned
|
||||
? String(localized: "serverPicker.owned")
|
||||
: String(localized: "serverPicker.shared"))
|
||||
if let platform = server.platform { pieces.append(platform) }
|
||||
if let version = server.productVersion { pieces.append(version) }
|
||||
return pieces.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Wenn etwas schiefgegangen ist.
|
||||
struct ConnectionFailureView: View {
|
||||
let message: String
|
||||
|
||||
@@ -3,30 +3,18 @@ import FlixyDesign
|
||||
|
||||
/// Der Anmeldebildschirm.
|
||||
///
|
||||
/// Zwei Wege, weil es zwei Serverarten gibt. **Plex** öffnet seine eigene
|
||||
/// Anmeldemaske in einem Fenster innerhalb der App; das Passwort wird dort
|
||||
/// eingegeben und erreicht Flixy nie. **Jellyfin** hat kein Verzeichnis wie
|
||||
/// plex.tv — dort wird die Adresse des eigenen Servers eingetippt und, wann
|
||||
/// immer der Server es anbietet, ein Code bestätigt statt eines Passworts.
|
||||
/// **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.
|
||||
///
|
||||
/// Die beiden Wege sind absichtlich nicht zu einem zusammengefasst. Sie haben
|
||||
/// nichts gemeinsam außer dem Ziel, und der Versuch, das zu verstecken, würde
|
||||
/// beide verschlechtern.
|
||||
/// 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`.
|
||||
struct SignInView: View {
|
||||
let phase: FlixySession.Phase
|
||||
/// Der Code der laufenden Plex-Anmeldung, sobald er gezeigt werden soll.
|
||||
let manualCode: String?
|
||||
let onSignIn: () -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
@State private var showsJellyfinSignIn = false
|
||||
|
||||
private var isAuthenticating: Bool {
|
||||
if case .authenticating = phase { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: FlixySpacing.section) {
|
||||
Spacer()
|
||||
@@ -49,21 +37,6 @@ struct SignInView: View {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: FlixySpacing.regular) {
|
||||
Button(action: onSignIn) {
|
||||
Group {
|
||||
if isAuthenticating {
|
||||
ProgressView().tint(Color.flixyCanvas)
|
||||
} else {
|
||||
Text("signIn.withPlex").font(.flixyRowHeader)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, FlixySpacing.regular)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
.disabled(isAuthenticating)
|
||||
|
||||
Button {
|
||||
showsJellyfinSignIn = true
|
||||
} label: {
|
||||
@@ -72,18 +45,13 @@ struct SignInView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, FlixySpacing.regular)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Color.flixyTextSecondary)
|
||||
.disabled(isAuthenticating)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
|
||||
if let manualCode {
|
||||
manualFallback(code: manualCode)
|
||||
} else {
|
||||
Text("signIn.privacy")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
Text("signIn.privacy")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Spacer().frame(height: FlixySpacing.section)
|
||||
@@ -96,35 +64,4 @@ struct SignInView: View {
|
||||
JellyfinSignInView()
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Ausweg, wenn die Browser-Seite den Code nicht verknüpft.
|
||||
///
|
||||
/// Das passiert, wenn man dort schon bei Plex angemeldet ist: die Seite
|
||||
/// springt weiter, ohne zu verknüpfen, und die App wartete vergeblich.
|
||||
/// Der Code gilt weiter — abtippen führt immer zum Ziel.
|
||||
@ViewBuilder
|
||||
private func manualFallback(code: String) -> some View {
|
||||
VStack(spacing: FlixySpacing.regular) {
|
||||
Divider().overlay(Color.flixySeparator)
|
||||
|
||||
Text("signIn.manualHint")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(code)
|
||||
.font(.system(.title, design: .monospaced, weight: .semibold))
|
||||
.tracking(6)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
.textSelection(.enabled)
|
||||
.padding(.vertical, FlixySpacing.snug)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control))
|
||||
|
||||
Button("action.cancel", action: onCancel)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user