Am Fernseher steht der Code vorn, nicht das Passwort

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>
This commit is contained in:
Scarriffle
2026-09-29 17:56:50 +02:00
co-authored by Claude Opus 5
parent 22f824b000
commit 1552e5f2b9
4 changed files with 312 additions and 19 deletions
+158
View File
@@ -0,0 +1,158 @@
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)
}
}
}
+70 -7
View File
@@ -3,17 +3,80 @@ import FlixyDesign
/// Was auf dem Fernseher zu sehen ist.
///
/// Zunächst nur der Name: dieser Schritt beweist Target, Icon, Signierung und
/// Paketauflösung, bevor eine Zeile Logik daran hängt. Scheitert etwas davon,
/// scheitert es hier in fünf Minuten statt später mit sechs offenen
/// Baustellen daneben.
/// **Nur der Zustandswechsel, keine Navigation.** Das ist alles, was von
/// `RootView` auf tvOS übrig bleibt: dort sind es 556 Zeilen Seitenleiste,
/// Tab-Anpassung, gemessene Statusleistenhöhe und Mini-Player-Übergang, und
/// nichts davon gibt es hier. Deshalb eine eigene Datei und kein `#if` in der
/// bestehenden — ein `#if` wäre kein Zweig gewesen, sondern ein zweiter Rumpf
/// in derselben Datei.
struct TVRootView: View {
@Environment(FlixySession.self) private var session
var body: some View {
ZStack {
Color.flixyCanvas.ignoresSafeArea()
Text(Branding.appName)
.font(.flixyHero)
.foregroundStyle(.white)
switch session.phase {
case .launching:
ProgressView()
case .signedOut, .authenticating, .choosingServer:
// Plex bietet die erste Fassung nicht an: die Anmeldung dort
// läuft über ein Browserfenster, und das gibt es auf tvOS
// nicht. Alle drei Zustände führen deshalb zur
// Jellyfin-Anmeldung.
TVSignInView()
case .connecting(let name):
verbinden(mit: name)
case .failed(let meldung):
fehlgeschlagen(meldung)
case .ready:
TVHomeView()
}
}
.task { await session.restore() }
}
private func verbinden(mit name: String) -> some View {
VStack(spacing: FlixySpacing.regular) {
ProgressView()
Text(name).font(.flixyRowHeader).foregroundStyle(.white)
}
}
private func fehlgeschlagen(_ meldung: String) -> some View {
VStack(spacing: FlixySpacing.regular) {
Text(meldung).font(.flixyBody).foregroundStyle(.white)
HStack(spacing: FlixySpacing.regular) {
Button("action.retry") { Task { await session.retry() } }
Button("action.signOut") { session.signOut() }
}
}
.frame(maxWidth: 900)
}
}
/// Die Startseite — vorerst nur die Bestätigung, dass die Anmeldung steht.
///
/// Sie kommt in Schritt vier; hier steht sie, damit `TVRootView` schon
/// vollständig ist und die Anmeldung für sich prüfbar bleibt.
struct TVHomeView: View {
@Environment(FlixySession.self) private var session
var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
Text(session.serverName.isEmpty ? Branding.appName : session.serverName).font(.flixyHero)
ForEach(session.libraries, id: \.id) { mediathek in
Text(mediathek.title).font(.flixyRowHeader)
}
Button("action.signOut") { session.signOut() }
.padding(.top, FlixySpacing.section)
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(FlixySpacing.screenMargin)
}
}