import SwiftUI import FlixyDesign import MediaKit /// Die Startseite auf dem Fernseher. /// /// **Dasselbe Modell wie am Telefon.** `HomeFeed` lädt, ordnet und lost den /// Aufmacher aus; hier steht nur, wie das aussieht. Der Unterschied ist die /// Bedienart, nicht der Inhalt. struct TVHomeView: View { /// Wohin ein Link aus dem Regal führen soll. /// /// **Als Bindung von aussen**, nicht als eigener Zustand: das Regal /// erreicht die Wurzel, der Navigationsstapel liegt hier. Einen zweiten /// Stapel eine Ebene höher anzulegen, nur um ein Ziel durchzureichen, /// hiesse zwei Stapel gleich zu halten. @Binding var deepLinkTarget: MediaRoute? @Environment(FlixySession.self) private var session @Environment(HomeLayoutSettings.self) private var layout @Environment(PlaybackCoordinator.self) private var playback @Environment(QuickPlay.self) private var quickPlay @State private var feed = HomeFeed() @State private var pfad = NavigationPath() /// Welcher Titel gerade oben gross steht. /// /// **Der Fokus bestimmt ihn, nicht eine Uhr.** Am Telefon wechselt der /// Aufmacher alle sieben Sekunden von selbst — dort blättert man ihn auch /// mit dem Daumen. Vor einem Fernseher wäre ein Bild, das sich von allein /// austauscht, während man es ansieht, eine Zumutung: man verliert den /// Titel, den man gerade lesen wollte. @State private var aufmacher: MediaItem? var body: some View { NavigationStack(path: $pfad) { inhalt .navigationDestination(for: MediaRoute.self) { route in TVDetailView(route: route) } } .task(id: session.serverName) { await feed.load(from: session.backend) } .onChange(of: deepLinkTarget) { _, neu in guard let neu else { return } pfad.append(neu) deepLinkTarget = nil } } @ViewBuilder private var inhalt: some View { switch feed.state { case .loading: ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) case .failed(let meldung): VStack(spacing: FlixySpacing.regular) { Text(meldung).font(.flixyBody).foregroundStyle(.white) Button("action.retry") { Task { await feed.load(from: session.backend) } } } case .loaded: let reihen = feed.rows(for: layout) if reihen.isEmpty && session.libraries.isEmpty { Text("home.empty.title").font(.flixyRowHeader).foregroundStyle(.white) } else { seite(reihen: reihen) } } } private func seite(reihen: [MediaRowGroup]) -> some View { ScrollView { LazyVStack(alignment: .leading, spacing: FlixySpacing.section) { if let gezeigt = aufmacher ?? feed.heroItems(for: layout).first { TVHeroSection( item: gezeigt, onPlay: { spiele(gezeigt) }, onDetails: { zeige(gezeigt) } ) .frame(height: 720) } ForEach(reihen) { reihe in // **Der Fokus setzt den Aufmacher.** Wer durch „Zuletzt // hinzugefügt" wandert, sieht oben, was er gerade // anschaut — ohne irgendwo hindrücken zu müssen. TVMediaRow( row: reihe, onSelect: { spiele($0) }, onFocus: { aufmacher = $0 } ) } } .padding(.bottom, FlixySpacing.screenMargin) } .scrollIndicators(.never) // **Bildfüllend, und der Rand steckt in `screenMargin`.** Der // Aufmacher muss bis an die Bildschirmkante reichen; ein Bild, das // achtzig Punkte davor aufhört, sieht nach Fehler aus. .ignoresSafeArea() // Als Überlagerung und nicht als letzte Zeile der Liste: dorthin // müsste man erst an allen Reihen vorbeiscrollen, und wer nachsieht, // warum das Regal leer ist, soll es sofort sehen. .overlay(alignment: .bottom) { regalStatus } } /// Was das Regal über der App zuletzt bekommen hat. /// /// **Nur im Entwicklerbau.** Es gehört nicht in die fertige App — aber /// solange das Regal leer bleibt, sieht man von aussen nicht, woran es /// liegt: fehlende App-Gruppe, fehlende Datei und ein System, das nicht /// fragt, sehen alle gleich aus. Diese Zeile unterscheidet sie. @ViewBuilder private var regalStatus: some View { #if DEBUG VStack(alignment: .leading, spacing: FlixySpacing.tight) { Text("topShelf.title") .font(.flixyCaption) .foregroundStyle(Color.flixyTextMuted) Text(TopShelfWriter.status) .font(.flixyCaption) .foregroundStyle(Color.flixyTextSecondary) } .padding(.horizontal, FlixySpacing.screenMargin) .padding(.bottom, FlixySpacing.regular) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.black.opacity(0.6)) #endif } // MARK: - Was ein Druck auslöst /// **Über QuickPlay, nicht unmittelbar.** Steht in der Reihe eine Serie — /// und Jellyfins „Zuletzt hinzugefügt" liefert neue Folgen als ihre Serie /// —, schickte der direkte Weg deren eigene Kennung an den Player, und der /// scheiterte mit „keine abspielbare Datei". Eine Serie hat keine /// Fassungen. private func spiele(_ item: MediaItem) { quickPlay.start(item, backend: session.backend, coordinator: playback) } private func zeige(_ item: MediaItem) { pfad.append(MediaRoute(ratingKey: item.id, title: item.title)) } }