Und dein Argument dafür war das beste: auf dem Mac kann ich selbst hinsehen. Kein Simulator-Tunnel, der mitten im Prüfen abstürzt — `screencapture` auf das Fenster der App, und zwar nur auf ihres, nicht auf den Bildschirm. **Ein eigenes Ziel, nicht ein gemeinsames.** Die Info.plist der iOS-App ist voller iOS-Angaben: Startbildschirm, erlaubte Ausrichtungen, Live Activities. Die alle bedingt zu machen hieße, dass jede spätere Änderung an der iOS-Seite über eine Stelle geht, die auch den Mac betrifft. Zwei Ziele sind ein paar Zeilen mehr und eine Sorge weniger. **Was den Unterschied ausmacht, steht an einer Stelle.** Ein neuer `PlatformShims` in FlixyDesign übersetzt, was auf beiden Seiten verschieden heißt: schmale Titelzeile, halbhohes Blatt, Eingabefelder ohne Großschreibung, die Plätze in der Werkzeugleiste, die Zwischenablage. Zwanzig Ansichten benutzen ihn, statt zwanzigmal `#if os(iOS)` zu tragen — sonst wandert die Fallunterscheidung in jede neue Datei und wird beim übernächsten Bildschirm vergessen. Wirklich verschieden sind nur fünf Stellen: die Bildfläche (`UIView` gegen `NSView`), der AirPlay-Knopf, die Ausrichtungssperre (auf dem Mac gibt es nichts zu sperren), der App-Delegat für Hintergrund-Downloads und das Anmeldefenster, das ein anderes Fenster als Anker braucht. **Das Karussell blättert jetzt mit einem einrastenden Streifen** statt mit `TabView(.page)` — den Seitenstil gibt es auf dem Mac nicht. Das ist nebenbei die bessere Lösung: das verschachtelte TabView hatte die Eigenschaft, beim Wechsel kurz zwei Seiten gleichzeitig zu zeigen. **Was auf dem Mac fehlt und warum:** VLC. Das Binärpaket ist auf iOS festgelegt; sein xcframework enthält zwar Mac-Teile, aber das ist eine eigene Runde. Solange fällt die Wiedergabe dort auf den Systemplayer zurück — MKV und DTS gehen also noch nicht. Live Activities gibt es auf dem Mac nicht, Bild-in- Bild startet von Hand statt beim Verlassen der App. Gesehen: die App startet, der Anmeldebildschirm steht, beide Wege sind da. Angemeldet habe ich mich auf dem Mac **nicht** — das ginge nur, indem ich einen Token in deinen Schlüsselbund schreibe, und dann fragt macOS dich beim ersten Zugriff nach dem Passwort. Das machst du mit einem Klick selbst. iOS baut unverändert, 263 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
264 lines
10 KiB
Swift
264 lines
10 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import JellyfinKit
|
|
|
|
/// Die Anmeldung an einem Jellyfin-Server.
|
|
///
|
|
/// Drei Bildschirme in einem, weil es drei Fragen sind und nicht mehr: wo
|
|
/// steht der Server, wie heißt du, und — wenn der Server es kann — bestätige
|
|
/// diesen Code. Der Weg über den Code steht vorn, weil dabei kein Passwort
|
|
/// durch diese App läuft.
|
|
struct JellyfinSignInView: View {
|
|
@Environment(FlixySession.self) private var session
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.flixyAccent) private var accent
|
|
|
|
@State private var model: JellyfinSignInModel?
|
|
@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, FlixySpacing.section)
|
|
.padding(.vertical, FlixySpacing.section)
|
|
.frame(maxWidth: 420)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.scrollDismissesKeyboard(.interactively)
|
|
.background(Color.flixyCanvas)
|
|
.navigationTitle(Text("signIn.jellyfin.title"))
|
|
.flixyInlineTitle()
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("action.cancel") {
|
|
model?.cancel()
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.tint(accent)
|
|
.task {
|
|
// Erst hier, nicht im Eigenschaftswert: das Modell braucht die
|
|
// Gerätekennung der Sitzung. Baut es sich eine eigene, steht die
|
|
// App nach dem Anmelden ein zweites Mal in der Geräteliste.
|
|
guard model == nil else { return }
|
|
let fresh = JellyfinSignInModel(identity: session.jellyfinDeviceIdentity)
|
|
fresh.onAuthenticated = { client, account, serverName in
|
|
dismiss()
|
|
Task { await session.adoptJellyfin(client: client, account: account, serverName: serverName) }
|
|
}
|
|
model = fresh
|
|
}
|
|
.onDisappear {
|
|
// Wird das Blatt weggewischt, läuft sonst die Abfrageschleife
|
|
// noch minutenlang weiter — unsichtbar und für nichts.
|
|
model?.cancel()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func content(for model: JellyfinSignInModel) -> some View {
|
|
@Bindable var model = model
|
|
|
|
VStack(alignment: .leading, spacing: FlixySpacing.section) {
|
|
switch model.step {
|
|
case .address:
|
|
addressStep(model: model)
|
|
case .checking:
|
|
waiting(text: "signIn.jellyfin.searching")
|
|
case .quickConnect(let code):
|
|
quickConnectStep(model: model, code: code)
|
|
case .credentials:
|
|
credentialsStep(model: model)
|
|
case .signingIn:
|
|
waiting(text: "signIn.jellyfin.signingIn")
|
|
}
|
|
|
|
if let message = model.errorMessage {
|
|
Label(message, systemImage: "exclamationmark.triangle")
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyDestructive)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Adresse
|
|
|
|
@ViewBuilder
|
|
private func addressStep(model: JellyfinSignInModel) -> some View {
|
|
@Bindable var model = model
|
|
|
|
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
|
Text("signIn.jellyfin.addressPrompt")
|
|
.font(.flixyRowHeader)
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
|
|
TextField("signIn.jellyfin.addressPlaceholder", text: $model.address)
|
|
.textFieldStyle(.plain)
|
|
.textContentType(.URL)
|
|
.flixyPlainEntry(isAddress: true)
|
|
.submitLabel(.go)
|
|
.focused($focusedField, equals: .address)
|
|
.onSubmit { Task { await model.findServer() } }
|
|
// Hier statt beim Öffnen des Blattes: so bekommt das Feld den
|
|
// Fokus auch dann, wenn man nach einem Vertipper zurückkommt.
|
|
.task { focusedField = .address }
|
|
.padding(FlixySpacing.regular)
|
|
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control))
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
|
|
// Ein Beispiel statt einer Erklärung: „Schema, Host, Port" sagt
|
|
// niemandem etwas, „192.168.1.50" jedem.
|
|
Text("signIn.jellyfin.addressHint")
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
|
|
Button {
|
|
focusedField = nil
|
|
Task { await model.findServer() }
|
|
} label: {
|
|
Text("signIn.jellyfin.connect")
|
|
.font(.flixyRowHeader)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, FlixySpacing.regular)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(accent)
|
|
.disabled(model.address.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
}
|
|
}
|
|
|
|
// MARK: - Code
|
|
|
|
@ViewBuilder
|
|
private func quickConnectStep(model: JellyfinSignInModel, code: String) -> some View {
|
|
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
|
foundServerHeader(model: model)
|
|
|
|
Text("signIn.jellyfin.quickConnectHint")
|
|
.font(.flixyBody)
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
|
|
Text(code)
|
|
.font(.system(.largeTitle, design: .monospaced, weight: .semibold))
|
|
.tracking(8)
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
.textSelection(.enabled)
|
|
.padding(.vertical, FlixySpacing.regular)
|
|
.frame(maxWidth: .infinity)
|
|
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control))
|
|
|
|
HStack(spacing: FlixySpacing.snug) {
|
|
ProgressView().controlSize(.small)
|
|
Text("signIn.jellyfin.waitingForApproval")
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
}
|
|
|
|
Text("signIn.jellyfin.privacy")
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
|
|
Divider().overlay(Color.flixySeparator)
|
|
|
|
Button("signIn.jellyfin.usePassword") { model.useCredentials() }
|
|
.font(.flixyCaption)
|
|
Button("signIn.jellyfin.otherAddress") { model.startOver() }
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
}
|
|
}
|
|
|
|
// MARK: - Benutzername und Passwort
|
|
|
|
@ViewBuilder
|
|
private func credentialsStep(model: JellyfinSignInModel) -> some View {
|
|
@Bindable var model = model
|
|
|
|
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
|
foundServerHeader(model: model)
|
|
|
|
TextField("signIn.jellyfin.userName", text: $model.userName)
|
|
.textContentType(.username)
|
|
.flixyPlainEntry()
|
|
.submitLabel(.next)
|
|
.focused($focusedField, equals: .userName)
|
|
.onSubmit { focusedField = .password }
|
|
.padding(FlixySpacing.regular)
|
|
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control))
|
|
|
|
SecureField("signIn.jellyfin.password", text: $model.password)
|
|
.textContentType(.password)
|
|
.submitLabel(.go)
|
|
.focused($focusedField, equals: .password)
|
|
.onSubmit { Task { await model.signInWithPassword() } }
|
|
.padding(FlixySpacing.regular)
|
|
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control))
|
|
|
|
Button {
|
|
focusedField = nil
|
|
Task { await model.signInWithPassword() }
|
|
} label: {
|
|
Text("signIn.jellyfin.submit")
|
|
.font(.flixyRowHeader)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, FlixySpacing.regular)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(accent)
|
|
.disabled(model.userName.isEmpty)
|
|
|
|
// Das Passwort wird für die eine Anfrage gehalten und danach
|
|
// verworfen. Gespeichert wird nur der Token, und der im
|
|
// Schlüsselbund.
|
|
Text("signIn.jellyfin.passwordNote")
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
|
|
Divider().overlay(Color.flixySeparator)
|
|
|
|
if model.offersQuickConnect {
|
|
Button("signIn.jellyfin.useCode") { Task { await model.beginQuickConnect() } }
|
|
.font(.flixyCaption)
|
|
}
|
|
Button("signIn.jellyfin.otherAddress") { model.startOver() }
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
}
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
}
|
|
|
|
// MARK: - Bausteine
|
|
|
|
@ViewBuilder
|
|
private func foundServerHeader(model: JellyfinSignInModel) -> some View {
|
|
VStack(alignment: .leading, spacing: FlixySpacing.tight) {
|
|
Text("signIn.jellyfin.foundServer")
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
.textCase(.uppercase)
|
|
Text(model.serverName ?? model.address)
|
|
.font(.flixyTitle)
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func waiting(text: LocalizedStringKey) -> some View {
|
|
HStack(spacing: FlixySpacing.snug) {
|
|
ProgressView().controlSize(.small)
|
|
Text(text)
|
|
.font(.flixyBody)
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.vertical, FlixySpacing.section)
|
|
}
|
|
}
|