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 Sheet 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 Sheets: 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 } // **Tabulator weiterreichen, von Hand.** // // Auf dem Mac ist „Name, Tab, Passwort, Eingabe" der Weg, den // jeder nimmt, der einen Passwortspeicher benutzt. SwiftUI // schiebt den Fokus hier aber nicht selbst weiter: die Taste // kommt im Feld an und bleibt dort liegen. .onKeyPress(.tab) { focusedField = .password return .handled } .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() } } // Und zurück: sonst wäre der Weg eine Einbahnstraße, und ein // Vertipper im Namen hieße, zur Maus greifen. .onKeyPress(.tab) { focusedField = .userName return .handled } .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) } }