Der Player deckt das ganze Fenster ab, und auf dem Mac zeichnet das System seine Ampelknöpfe darüber. Die Kopfzeile lag damit nicht nur optisch unter ihnen — der Streifen gehört der Fensterleiste, und die nimmt jeden Klick für sich. Verkleinern, Schließen, Tonspur, Untertitel und die Qualitätsstufe waren dort tot, und ein Doppelklick auf dieselbe Stelle legte das Fenster ins Dock. Jetzt beginnt die Kopfzeile auf dem Mac achtundzwanzig Punkte tiefer. Dazu: Tabulator springt in der Jellyfin-Anmeldung vom Namen zum Passwort und zurück. „Name, Tab, Passwort, Eingabe" ist der Weg, den jeder mit einem Passwortspeicher nimmt; SwiftUI schiebt den Fokus dort nicht von selbst weiter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
280 lines
11 KiB
Swift
280 lines
11 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 }
|
|
// **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)
|
|
}
|
|
}
|