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:
co-authored by
Claude Opus 5
parent
22f824b000
commit
1552e5f2b9
@@ -32,6 +32,16 @@ final class JellyfinSignInModel {
|
|||||||
case signingIn
|
case signingIn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Welcher Weg nach dem Fund zuerst angeboten wird.
|
||||||
|
///
|
||||||
|
/// **Am Telefon und am Fernseher verschieden herum.** Dort füllt ein
|
||||||
|
/// Passwortspeicher beide Felder in einer Sekunde; hier tippt man sie mit
|
||||||
|
/// einem Steuerkreuz, Buchstabe für Buchstabe, und der Code ist der
|
||||||
|
/// bequemere Weg. Welcher Fall gilt, weiß die Oberfläche — dieses Modell
|
||||||
|
/// nicht. Deshalb ein Wert im Initialisierer statt einer
|
||||||
|
/// Plattformbedingung mitten in `findServer()`.
|
||||||
|
enum PreferredMethod { case credentials, quickConnect }
|
||||||
|
|
||||||
var address = ""
|
var address = ""
|
||||||
var userName = ""
|
var userName = ""
|
||||||
var password = ""
|
var password = ""
|
||||||
@@ -51,12 +61,15 @@ final class JellyfinSignInModel {
|
|||||||
var onAuthenticated: ((JellyfinClient, JellyfinAccount, String) -> Void)?
|
var onAuthenticated: ((JellyfinClient, JellyfinAccount, String) -> Void)?
|
||||||
|
|
||||||
private let identity: JellyfinIdentity
|
private let identity: JellyfinIdentity
|
||||||
|
private let preferred: PreferredMethod
|
||||||
private var client: JellyfinClient?
|
private var client: JellyfinClient?
|
||||||
private var authenticator: JellyfinAuthenticator?
|
private var authenticator: JellyfinAuthenticator?
|
||||||
private var pollTask: Task<Void, Never>?
|
private var pollTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(identity: JellyfinIdentity) {
|
/// Der Vorgabewert hält iOS unverändert.
|
||||||
|
init(identity: JellyfinIdentity, preferring: PreferredMethod = .credentials) {
|
||||||
self.identity = identity
|
self.identity = identity
|
||||||
|
self.preferred = preferring
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Schritt 1: den Server finden
|
// MARK: - Schritt 1: den Server finden
|
||||||
@@ -89,17 +102,26 @@ final class JellyfinSignInModel {
|
|||||||
address = candidate.absoluteString
|
address = candidate.absoluteString
|
||||||
offersQuickConnect = await authenticator.supportsQuickConnect()
|
offersQuickConnect = await authenticator.supportsQuickConnect()
|
||||||
|
|
||||||
// **Name und Passwort zuerst.**
|
// **Am Telefon Name und Passwort zuerst, am Fernseher der Code.**
|
||||||
//
|
//
|
||||||
// Hier sprang der Weg bisher von selbst in den Code-Fluss, sobald
|
// Hier sprang der Weg früher immer von selbst in den Code-Fluss,
|
||||||
// der Server ihn anbietet. Das war gut gemeint — dabei läuft kein
|
// sobald der Server ihn anbietet. Das war gut gemeint — dabei
|
||||||
// Passwort durch diese App —, geht aber am Alltag vorbei: der Code
|
// läuft kein Passwort durch diese App —, geht am Telefon aber am
|
||||||
// muss in einem zweiten Gerät bestätigt werden, und wer gerade im
|
// Alltag vorbei: der Code muss in einem zweiten Gerät bestätigt
|
||||||
// Zug sitzt, hat das nicht offen. Ein Passwortspeicher füllt
|
// werden, und wer gerade im Zug sitzt, hat das nicht offen. Ein
|
||||||
// dagegen beide Felder in einer Sekunde.
|
// Passwortspeicher füllt dagegen beide Felder in einer Sekunde.
|
||||||
//
|
//
|
||||||
// Der Code bleibt erreichbar: `offersQuickConnect` steuert den
|
// Vor einem Fernseher stimmt jedes Wort davon nicht mehr: dort
|
||||||
// Knopf darunter, und der steht schon da.
|
// gibt es keinen Passwortspeicher, dort tippt man mit dem
|
||||||
|
// Steuerkreuz, und das zweite Gerät liegt sowieso auf dem Sofa.
|
||||||
|
//
|
||||||
|
// Beide Wege bleiben erreichbar: `offersQuickConnect` steuert den
|
||||||
|
// Knopf darunter, und `useCredentials()` führt zurück.
|
||||||
|
if preferred == .quickConnect, offersQuickConnect {
|
||||||
|
await beginQuickConnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
step = .credentials
|
step = .credentials
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import FlixyDesign
|
||||||
|
import FlixyPlayback
|
||||||
|
import MediaKit
|
||||||
|
import JellyfinKit
|
||||||
|
import PlexKit
|
||||||
|
|
||||||
/// Die App auf dem Fernseher.
|
/// Die App auf dem Fernseher.
|
||||||
///
|
///
|
||||||
@@ -15,13 +19,59 @@ import FlixyDesign
|
|||||||
/// kommt über einen Pfadeintrag in `project.yml` mit. Die Naht verläuft
|
/// kommt über einen Pfadeintrag in `project.yml` mit. Die Naht verläuft
|
||||||
/// zwischen Modell und Ansicht, nicht zwischen den Plattformen.
|
/// zwischen Modell und Ansicht, nicht zwischen den Plattformen.
|
||||||
///
|
///
|
||||||
/// **Kein SwiftData.** Der `ModelContainer` der iOS-App trägt ausschliesslich
|
/// **Kein SwiftData und kein Download-Koordinator.** Der `ModelContainer` der
|
||||||
/// Downloads, und die gibt es hier nicht.
|
/// iOS-App trägt ausschliesslich Downloads, und die gibt es hier nicht — damit
|
||||||
|
/// fällt beides weg.
|
||||||
@main
|
@main
|
||||||
struct FlixyTVApp: App {
|
struct FlixyTVApp: App {
|
||||||
|
@State private var session: FlixySession
|
||||||
|
@State private var theme = FlixyTheme()
|
||||||
|
@State private var playback = PlaybackCoordinator()
|
||||||
|
@State private var playbackSettings = PlaybackSettings()
|
||||||
|
@State private var languages = LanguagePreferences()
|
||||||
|
@State private var quickPlay = QuickPlay()
|
||||||
|
@State private var subtitleAppearance = SubtitleAppearance()
|
||||||
|
@State private var homeLayout = HomeLayoutSettings()
|
||||||
|
@State private var diagnostics = PlaybackDiagnostics()
|
||||||
|
/// Beobachtet die Verbindung. Lebt hier, damit der Beobachter einmal läuft
|
||||||
|
/// statt bei jedem Öffnen des Players neu zu starten.
|
||||||
|
///
|
||||||
|
/// Ein Apple TV hängt fast immer am Kabel oder an festem WLAN — aber
|
||||||
|
/// „fast" ist kein Grund, die Entscheidungen des Players zu raten.
|
||||||
|
@State private var network = NetworkConditions()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
_session = State(initialValue: FlixySession(
|
||||||
|
identity: PlexClientIdentity.current(
|
||||||
|
product: Branding.plexProduct,
|
||||||
|
version: Branding.version
|
||||||
|
),
|
||||||
|
jellyfinIdentity: JellyfinIdentity.current(
|
||||||
|
client: Branding.appName,
|
||||||
|
version: Branding.version
|
||||||
|
),
|
||||||
|
tokenStore: KeychainTokenStore(service: Branding.keychainService)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
TVRootView()
|
TVRootView()
|
||||||
|
.environment(session)
|
||||||
|
.environment(theme)
|
||||||
|
.environment(playback)
|
||||||
|
.environment(playbackSettings)
|
||||||
|
.environment(languages)
|
||||||
|
.environment(quickPlay)
|
||||||
|
.environment(subtitleAppearance)
|
||||||
|
.environment(homeLayout)
|
||||||
|
.environment(diagnostics)
|
||||||
|
.environment(network)
|
||||||
|
.flixyAccent(theme)
|
||||||
|
.tint(theme.accentColor)
|
||||||
|
// Ein Fernseher ist immer dunkel. Der Schalter in der
|
||||||
|
// Info.plist gilt fürs System, diese Zeile für die Vorschauen.
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,17 +3,80 @@ import FlixyDesign
|
|||||||
|
|
||||||
/// Was auf dem Fernseher zu sehen ist.
|
/// Was auf dem Fernseher zu sehen ist.
|
||||||
///
|
///
|
||||||
/// Zunächst nur der Name: dieser Schritt beweist Target, Icon, Signierung und
|
/// **Nur der Zustandswechsel, keine Navigation.** Das ist alles, was von
|
||||||
/// Paketauflösung, bevor eine Zeile Logik daran hängt. Scheitert etwas davon,
|
/// `RootView` auf tvOS übrig bleibt: dort sind es 556 Zeilen Seitenleiste,
|
||||||
/// scheitert es hier in fünf Minuten statt später mit sechs offenen
|
/// Tab-Anpassung, gemessene Statusleistenhöhe und Mini-Player-Übergang, und
|
||||||
/// Baustellen daneben.
|
/// 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 {
|
struct TVRootView: View {
|
||||||
|
@Environment(FlixySession.self) private var session
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.flixyCanvas.ignoresSafeArea()
|
Color.flixyCanvas.ignoresSafeArea()
|
||||||
Text(Branding.appName)
|
|
||||||
.font(.flixyHero)
|
switch session.phase {
|
||||||
.foregroundStyle(.white)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user