Schritt drei: anmelden. Die Oberfläche ist neu, das Modell dahinter ist dasselbe wie am Telefon — `JellyfinSignInModel`, unverändert bis auf einen Wert im Initialisierer. **Die eine Verhaltensänderung.** Nach gefundenem Server sprang der Weg bisher immer zu Benutzername und Passwort, mit der Begründung, ein Passwortspeicher fülle beide Felder in einer Sekunde. Vor einem Fernseher stimmt daran kein Wort: es gibt keinen Passwortspeicher, man tippt mit dem Steuerkreuz, und das Gerät, auf dem man den Code bestätigt, liegt sowieso auf dem Sofa. Also `preferring: .quickConnect` — ein Wert im Initialisierer und keine Plattformbedingung mitten in `findServer()`. Welcher Fall gilt, weiß die Oberfläche; das Modell nicht. Der Vorgabewert hält iOS unverändert. Der sechsstellige Code steht in 96 Punkt mit Sperrung: er wird aus drei Metern abgelesen und in ein Telefon getippt. In Fliesstextgrösse wäre das eine Zumutung. Beide Wege bleiben erreichbar — für Server, auf denen Quick Connect abgeschaltet ist, und für den Fall, dass gerade kein zweites Gerät zur Hand ist. **Und der Beweis, dass die geteilte Schicht trägt.** Gegen die Jellyfin-Attrappe im Simulator: die App verbindet sich, lädt die drei Mediatheken und zeigt sie. FlixySession, JellyfinBackend, die Zustandsmaschine der Anmeldung — alles unverändert, alles über Pfadeinträge geteilt, keine Zeile kopiert. Die Startseite ist noch eine Liste von Namen. Sie kommt in Schritt vier; hier steht sie, damit die Wurzel vollständig ist und die Anmeldung für sich prüfbar bleibt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
159 lines
6.1 KiB
Swift
159 lines
6.1 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import JellyfinKit
|
|
|
|
/// Die Anmeldung am Fernseher.
|
|
///
|
|
/// **Dieselben vier Schritte wie am Telefon, andere Reihenfolge.** Das Modell
|
|
/// `JellyfinSignInModel` ist unverändert dasselbe — nur bekommt es hier
|
|
/// `preferring: .quickConnect`. Vor einem Fernseher ist der Code der bequemere
|
|
/// Weg: es gibt keinen Passwortspeicher, getippt wird mit dem Steuerkreuz, und
|
|
/// das Gerät, auf dem man den Code bestätigt, liegt sowieso auf dem Sofa.
|
|
///
|
|
/// Der Weg über Benutzername und Passwort bleibt erreichbar — für Server, auf
|
|
/// denen Quick Connect abgeschaltet ist.
|
|
struct TVSignInView: View {
|
|
@Environment(FlixySession.self) private var session
|
|
|
|
@State private var model: JellyfinSignInModel?
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.flixyCanvas.ignoresSafeArea()
|
|
|
|
if let model {
|
|
inhalt(for: model)
|
|
// Eine Spalte, nicht die volle Breite: 1760 Punkte Zeile
|
|
// liest sich aus drei Metern nicht mehr als Zeile.
|
|
.frame(maxWidth: 900)
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
}
|
|
.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 frisch = JellyfinSignInModel(
|
|
identity: session.jellyfinDeviceIdentity,
|
|
preferring: .quickConnect
|
|
)
|
|
frisch.onAuthenticated = { client, account, serverName in
|
|
Task {
|
|
await session.adoptJellyfin(
|
|
client: client, account: account, serverName: serverName
|
|
)
|
|
}
|
|
}
|
|
model = frisch
|
|
}
|
|
.onDisappear { model?.cancel() }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func inhalt(for model: JellyfinSignInModel) -> some View {
|
|
@Bindable var model = model
|
|
|
|
VStack(alignment: .leading, spacing: FlixySpacing.section) {
|
|
Text(Branding.appName).font(.flixyHero)
|
|
Text("signIn.tagline").font(.flixyBody).foregroundStyle(Color.flixyTextMuted)
|
|
|
|
switch model.step {
|
|
case .address:
|
|
adresse(model: model)
|
|
case .checking:
|
|
warten("signIn.jellyfin.searching")
|
|
case .quickConnect(let code):
|
|
codeAnzeigen(model: model, code: code)
|
|
case .credentials:
|
|
zugangsdaten(model: model)
|
|
case .signingIn:
|
|
warten("signIn.jellyfin.signingIn")
|
|
}
|
|
|
|
if let fehler = model.errorMessage {
|
|
Text(fehler).font(.flixyBody).foregroundStyle(Color.flixyDestructive)
|
|
}
|
|
}
|
|
.padding(FlixySpacing.section)
|
|
}
|
|
|
|
// MARK: - Die vier Schritte
|
|
|
|
private func adresse(model: JellyfinSignInModel) -> some View {
|
|
@Bindable var model = model
|
|
|
|
return VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
|
Text("signIn.jellyfin.addressPrompt").font(.flixyRowHeader)
|
|
// Auf tvOS öffnet ein Textfeld beim Fokussieren die
|
|
// Bildschirmtastatur ganzseitig — deshalb steht hier nur das Feld
|
|
// und keine Maske drumherum.
|
|
TextField("signIn.jellyfin.addressPlaceholder", text: $model.address)
|
|
.flixyPlainEntry(isAddress: true)
|
|
Text("signIn.jellyfin.addressHint")
|
|
.font(.flixyCaption).foregroundStyle(Color.flixyTextMuted)
|
|
Button("signIn.jellyfin.connect") {
|
|
Task { await model.findServer() }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func codeAnzeigen(model: JellyfinSignInModel, code: String) -> some View {
|
|
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
|
if let name = model.serverName {
|
|
Text(name).font(.flixyRowHeader)
|
|
}
|
|
// **Der Code ist hier der Inhalt, nicht eine Beschriftung.**
|
|
// Er wird aus drei Metern abgelesen und in ein Telefon getippt;
|
|
// in Fliesstextgrösse ist das eine Zumutung.
|
|
Text(code)
|
|
.font(.system(size: 96, weight: .bold, design: .monospaced))
|
|
.kerning(12)
|
|
.foregroundStyle(.white)
|
|
Text("signIn.jellyfin.quickConnectHint").font(.flixyBody)
|
|
Label("signIn.jellyfin.waitingForApproval", systemImage: "hourglass")
|
|
.font(.flixyCaption).foregroundStyle(Color.flixyTextMuted)
|
|
|
|
HStack(spacing: FlixySpacing.regular) {
|
|
Button("signIn.jellyfin.usePassword") { model.useCredentials() }
|
|
Button("signIn.jellyfin.otherAddress") { model.startOver() }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func zugangsdaten(model: JellyfinSignInModel) -> some View {
|
|
@Bindable var model = model
|
|
|
|
return VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
|
if let name = model.serverName {
|
|
Text(name).font(.flixyRowHeader)
|
|
}
|
|
TextField("signIn.jellyfin.userName", text: $model.userName)
|
|
.flixyPlainEntry()
|
|
SecureField("signIn.jellyfin.password", text: $model.password)
|
|
Text("signIn.jellyfin.passwordNote")
|
|
.font(.flixyCaption).foregroundStyle(Color.flixyTextMuted)
|
|
|
|
HStack(spacing: FlixySpacing.regular) {
|
|
Button("signIn.jellyfin.submit") {
|
|
Task { await model.signInWithPassword() }
|
|
}
|
|
if model.offersQuickConnect {
|
|
Button("signIn.jellyfin.useCode") {
|
|
Task { await model.beginQuickConnect() }
|
|
}
|
|
}
|
|
Button("signIn.jellyfin.otherAddress") { model.startOver() }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func warten(_ text: LocalizedStringKey) -> some View {
|
|
HStack(spacing: FlixySpacing.regular) {
|
|
ProgressView()
|
|
Text(text).font(.flixyBody)
|
|
}
|
|
}
|
|
}
|