diff --git a/Flixy/Session/JellyfinSignInModel.swift b/Flixy/Session/JellyfinSignInModel.swift index 74a056f..c3ca91a 100644 --- a/Flixy/Session/JellyfinSignInModel.swift +++ b/Flixy/Session/JellyfinSignInModel.swift @@ -32,6 +32,16 @@ final class JellyfinSignInModel { 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 userName = "" var password = "" @@ -51,12 +61,15 @@ final class JellyfinSignInModel { var onAuthenticated: ((JellyfinClient, JellyfinAccount, String) -> Void)? private let identity: JellyfinIdentity + private let preferred: PreferredMethod private var client: JellyfinClient? private var authenticator: JellyfinAuthenticator? private var pollTask: Task? - init(identity: JellyfinIdentity) { + /// Der Vorgabewert hält iOS unverändert. + init(identity: JellyfinIdentity, preferring: PreferredMethod = .credentials) { self.identity = identity + self.preferred = preferring } // MARK: - Schritt 1: den Server finden @@ -89,17 +102,26 @@ final class JellyfinSignInModel { address = candidate.absoluteString 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 - // der Server ihn anbietet. Das war gut gemeint — dabei läuft kein - // Passwort durch diese App —, geht aber am Alltag vorbei: der Code - // muss in einem zweiten Gerät bestätigt werden, und wer gerade im - // Zug sitzt, hat das nicht offen. Ein Passwortspeicher füllt - // dagegen beide Felder in einer Sekunde. + // Hier sprang der Weg früher immer von selbst in den Code-Fluss, + // sobald der Server ihn anbietet. Das war gut gemeint — dabei + // läuft kein Passwort durch diese App —, geht am Telefon aber am + // Alltag vorbei: der Code muss in einem zweiten Gerät bestätigt + // werden, und wer gerade im Zug sitzt, hat das nicht offen. Ein + // Passwortspeicher füllt dagegen beide Felder in einer Sekunde. // - // Der Code bleibt erreichbar: `offersQuickConnect` steuert den - // Knopf darunter, und der steht schon da. + // Vor einem Fernseher stimmt jedes Wort davon nicht mehr: dort + // 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 return } diff --git a/FlixyTV/FlixyTVApp.swift b/FlixyTV/FlixyTVApp.swift index 97e73f7..24396f4 100644 --- a/FlixyTV/FlixyTVApp.swift +++ b/FlixyTV/FlixyTVApp.swift @@ -1,5 +1,9 @@ import SwiftUI import FlixyDesign +import FlixyPlayback +import MediaKit +import JellyfinKit +import PlexKit /// Die App auf dem Fernseher. /// @@ -15,13 +19,59 @@ import FlixyDesign /// kommt über einen Pfadeintrag in `project.yml` mit. Die Naht verläuft /// zwischen Modell und Ansicht, nicht zwischen den Plattformen. /// -/// **Kein SwiftData.** Der `ModelContainer` der iOS-App trägt ausschliesslich -/// Downloads, und die gibt es hier nicht. +/// **Kein SwiftData und kein Download-Koordinator.** Der `ModelContainer` der +/// iOS-App trägt ausschliesslich Downloads, und die gibt es hier nicht — damit +/// fällt beides weg. @main 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 { WindowGroup { 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) } } } diff --git a/FlixyTV/Views/SignIn/TVSignInView.swift b/FlixyTV/Views/SignIn/TVSignInView.swift new file mode 100644 index 0000000..c78a074 --- /dev/null +++ b/FlixyTV/Views/SignIn/TVSignInView.swift @@ -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) + } + } +} diff --git a/FlixyTV/Views/TVRootView.swift b/FlixyTV/Views/TVRootView.swift index 9366d0c..1d16b92 100644 --- a/FlixyTV/Views/TVRootView.swift +++ b/FlixyTV/Views/TVRootView.swift @@ -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) + } }