From a665d79e6d84e03e8ce7c7919b50b2c2f821ab64 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 1 Oct 2026 10:39:45 +0200 Subject: [PATCH] =?UTF-8?q?Antippen=20spielt=20ab=20=E2=80=94=20und=20vier?= =?UTF-8?q?=20Dinge,=20die=20daneben=20lagen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Antippen startet, langes Drücken führt auf die Detailseite.** Am Telefon und am iPad; am Mac bleibt der Klick die Seite, denn dort gibt es den Abspielknopf beim Überfahren und langes Drücken ist keine übliche Geste. Die Hürde war nicht die Geste, sondern das Schieben: ein `NavigationLink` schiebt selbst, ein Menüeintrag kann es nicht. Der Stapel ist deshalb ein eigener Typ mit Pfad (`MediaStack`) und reicht eine Schiebefunktion über die Umgebung nach innen — denselben Weg nimmt schon die Meldung, dass sich am Server etwas geändert hat. **Zwei Knöpfe übereinander auf der Detailseite.** Das Monogramm hing als Überzug an `mediaStack` — also über **jedem** Bildschirm dieses Stapels, auch den geschobenen, und dort über deren Drei-Punkte-Menü. Es gilt jetzt für die Startseite und endet mit ihr. **Der Kopf der Detailseite war viel zu flach.** Dort stand kein Seitenverhältnis, also galt die Vorgabe 16∶9 — während `HeroArtwork` auf einem schmalen Gerät ein **Plakat** anfordert, also hochkant. Zu sehen war ein Streifen aus der Mitte. Der Aufmacher auf der Startseite macht es seit Langem richtig; die beiden Detailseiten nicht. **Die Mediathekenleiste stand linksbündig in einer halbleeren Kapsel.** Jetzt ist sie so breit wie ihr Inhalt und mittig. `ViewThatFits` nimmt die ungescrollte Reihe, solange sie passt, sonst die scrollbare — damit steht nirgends eine Zahl, ab wann es kippt. **Und der Grund, warum die Mediathekenauswahl beim Nutzer nie griff:** Plex. Dessen Hubs sagten nicht, aus welcher Mediathek sie stammen, also warf der Filter jede Reihe weg und fiel auf „alles" zurück — ein Schalter, der nichts tut. `PlexHub` liest jetzt `librarySectionID`, als Zahl oder als Zeichenkette, je nachdem was der Server liefert. Vier Tests dazu. Das ist **kein** Wiederaufnehmen der Plex-Arbeit: es repariert eine Funktion, die dort gelogen hat. Neues kommt weiterhin nicht dazu. iOS und tvOS bauen, 476 Tests grün. Co-Authored-By: Claude Opus 5 --- Flixy/Views/Components/QuickPlayTile.swift | 48 ++++++++++++++++-- Flixy/Views/Detail/PlayableDetailView.swift | 10 +++- Flixy/Views/Detail/SeriesDetailView.swift | 10 +++- Flixy/Views/Library/LibraryBrowser.swift | 37 +++++++++----- Flixy/Views/Navigation/MediaStack.swift | 49 +++++++++++++++++++ Flixy/Views/Navigation/RootView.swift | 36 ++++++-------- .../PlexKit/Models/PlexItem+Media.swift | 1 + .../Sources/PlexKit/Models/PlexLibrary.swift | 24 ++++++++- .../Tests/PlexKitTests/HubShapeTests.swift | 43 ++++++++++++++++ 9 files changed, 219 insertions(+), 39 deletions(-) create mode 100644 Flixy/Views/Navigation/MediaStack.swift diff --git a/Flixy/Views/Components/QuickPlayTile.swift b/Flixy/Views/Components/QuickPlayTile.swift index 9252d8d..836c2ae 100644 --- a/Flixy/Views/Components/QuickPlayTile.swift +++ b/Flixy/Views/Components/QuickPlayTile.swift @@ -29,6 +29,8 @@ struct QuickPlayTile: View { /// Was die umgebende Seite tun soll, wenn sich am Server etwas geändert /// hat. Siehe `ItemActionEntries`. @Environment(\.flixyItemChanged) private var itemChanged + /// Der Weg auf die Detailseite — siehe `MediaStack`. + @Environment(\.flixyPush) private var push private var isMac: Bool { FlixyPlatform.isMac } @@ -37,10 +39,10 @@ struct QuickPlayTile: View { /// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung. private var isPlayable: Bool { item.kind != .collection } + private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) } + var body: some View { - NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) { - card() - } + kachel .buttonStyle(.plain) // **Als Überzug auf dem Link, nicht als Kind darin.** // @@ -66,6 +68,15 @@ struct QuickPlayTile: View { // Kachel steht. .contextMenu { if isPlayable { + // **Am Telefon und am iPad führt der Weg zur Detailseite + // hierher.** Antippen spielt dort ab; wer stattdessen + // nachsehen will, was das überhaupt ist, drückt lang. Am Mac + // ist es umgekehrt: dort öffnet der Klick die Seite, und zum + // Starten gibt es den Knopf beim Überfahren. + if !isMac { + Button("hero.details", systemImage: "info.circle") { push(route) } + Divider() + } Button("tile.play", systemImage: "play.fill") { trigger() } Divider() } @@ -79,6 +90,13 @@ struct QuickPlayTile: View { // bleibt einer, und im Aktionen-Rotor steht „Abspielen". Zwanzig // Kacheln mit je zwei Elementen wären sechzig Wischer. .accessibilityAction(named: Text("tile.play")) { trigger() } + // Für VoiceOver der zweite Weg: am Telefon führt das Antippen in den + // Film, und die Detailseite wäre sonst nur über ein Kontextmenü zu + // erreichen, das eine Geste verlangt. + .accessibilityAction(named: Text("hero.details")) { + if isMac { return } + push(route) + } // **Nur der Fehler, kein Kreisel.** Wer im Kontextmenü tippt, sieht // das Menü verschwinden; ein Kreisel auf der Kachel käme für eine // Aktion, die Millisekunden dauert, zu spät und ginge zu früh. Geht @@ -94,6 +112,30 @@ struct QuickPlayTile: View { } } + /// Die Kachel selbst — und was ein Antippen bedeutet. + /// + /// **Am Telefon und am iPad spielt es ab.** Wer eine Kachel antippt, weiss + /// meistens schon, was er sehen will; der Umweg über die Detailseite + /// kostete zwei Schritte für eine Absicht. Die Seite ist weiter da — über + /// langes Drücken, wo auch „gesehen" und „entfernen" stehen. + /// + /// **Am Mac bleibt der Klick die Detailseite.** Dort gibt es den + /// Abspielknopf beim Überfahren, und langes Drücken ist keine übliche + /// Geste — ein Rechtsklick ist der Weg ins Menü, nicht in den Film. + /// + /// Ein `NavigationLink` schiebt selbst; ein Knopf braucht dafür den Weg + /// aus der Umgebung. Deshalb zwei Fassungen und nicht eine mit `if` im + /// Inneren: die Kachel soll am Mac ein Link **sein**, nicht einer + /// aussehen. + @ViewBuilder + private var kachel: some View { + if isMac || !isPlayable { + NavigationLink(value: route) { card() } + } else { + Button { trigger() } label: { card() } + } + } + /// Oben rechts, weil der Fortschrittsbalken unten liegt. private var playButton: some View { Button(action: trigger) { diff --git a/Flixy/Views/Detail/PlayableDetailView.swift b/Flixy/Views/Detail/PlayableDetailView.swift index 3157192..3b6e44f 100644 --- a/Flixy/Views/Detail/PlayableDetailView.swift +++ b/Flixy/Views/Detail/PlayableDetailView.swift @@ -80,7 +80,15 @@ struct PlayableDetailView: View { artworkURLs: session.backend.map { HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact) } ?? [], - logoURL: session.backend?.rawImageURL(path: item.clearLogoPath) + logoURL: session.backend?.rawImageURL(path: item.clearLogoPath), + // **Dasselbe Verhältnis, in dem das Bild bestellt wurde.** Hier + // stand keines, also galt die Vorgabe 16∶9 — während `HeroArtwork` + // auf einem schmalen Gerät ein **Plakat** anfordert, also hochkant. + // Zu sehen war davon ein Streifen aus der Mitte: das Bild wirkte + // angeschnitten und der Kopf der Seite viel zu flach. Der Aufmacher + // auf der Startseite macht es seit Langem richtig; die Detailseiten + // nicht. + aspectRatio: HeroArtwork.aspectRatio(compact: sizeClass == .compact) ) { Button { playback.play(item, mediaIndex: selectedVersion) diff --git a/Flixy/Views/Detail/SeriesDetailView.swift b/Flixy/Views/Detail/SeriesDetailView.swift index 7e29ba8..d89a65f 100644 --- a/Flixy/Views/Detail/SeriesDetailView.swift +++ b/Flixy/Views/Detail/SeriesDetailView.swift @@ -64,7 +64,15 @@ struct SeriesDetailView: View { artworkURLs: session.backend.map { HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact) } ?? [], - logoURL: session.backend?.rawImageURL(path: item.clearLogoPath) + logoURL: session.backend?.rawImageURL(path: item.clearLogoPath), + // **Dasselbe Verhältnis, in dem das Bild bestellt wurde.** Hier + // stand keines, also galt die Vorgabe 16∶9 — während `HeroArtwork` + // auf einem schmalen Gerät ein **Plakat** anfordert, also hochkant. + // Zu sehen war davon ein Streifen aus der Mitte: das Bild wirkte + // angeschnitten und der Kopf der Seite viel zu flach. Der Aufmacher + // auf der Startseite macht es seit Langem richtig; die Detailseiten + // nicht. + aspectRatio: HeroArtwork.aspectRatio(compact: sizeClass == .compact) ) { DownloadAllButton(container: item) } diff --git a/Flixy/Views/Library/LibraryBrowser.swift b/Flixy/Views/Library/LibraryBrowser.swift index 71f8f93..79e5757 100644 --- a/Flixy/Views/Library/LibraryBrowser.swift +++ b/Flixy/Views/Library/LibraryBrowser.swift @@ -68,19 +68,22 @@ struct LibraryBrowser: View { // MARK: - Die Leiste private var leiste: some View { - ScrollView(.horizontal) { - HStack(spacing: 0) { - ForEach(eintraege, id: \.auswahl) { eintrag in - pille(eintrag) - } - } - .padding(.horizontal, FlixySpacing.snug) - .padding(.vertical, FlixySpacing.snug) + // **So breit wie nötig, nicht wie möglich.** Über die ganze Breite + // gezogen stand sie linksbündig in einer halbleeren Kapsel — eine + // Leiste, die dreimal so lang ist wie ihr Inhalt, sieht aus wie ein + // Fehler im Layout. + // + // `ViewThatFits` nimmt die erste Fassung, die hineinpasst: die + // ungescrollte Reihe, solange sie passt, sonst die scrollbare. Damit + // ist beides richtig, ohne dass irgendwo eine Zahl stünde, ab wann es + // kippt — bei vier Mediatheken passt es, bei acht nicht, und keine + // Fassung muss das wissen. + ViewThatFits(in: .horizontal) { + reihe + ScrollView(.horizontal) { reihe } + .scrollBounceBehavior(.basedOnSize) + .scrollIndicators(.never) } - // Bei vier Einträgen passt alles; die Leiste soll trotzdem nicht - // brechen, wenn jemand sieben Mediatheken führt. - .scrollBounceBehavior(.basedOnSize) - .scrollIndicators(.never) // Dieselbe Fläche wie die Insel auf dem iPad — ein `static`-Bauteil, // kein nachgebautes Aussehen. .background { FlixySidebar.surface(in: .capsule) } @@ -90,6 +93,16 @@ struct LibraryBrowser: View { .padding(.bottom, FlixySpacing.snug) } + private var reihe: some View { + HStack(spacing: 0) { + ForEach(eintraege, id: \.auswahl) { eintrag in + pille(eintrag) + } + } + .padding(.horizontal, FlixySpacing.snug) + .padding(.vertical, FlixySpacing.snug) + } + private func pille(_ eintrag: Eintrag) -> some View { let gewaehlt = eintrag.auswahl == aktuell diff --git a/Flixy/Views/Navigation/MediaStack.swift b/Flixy/Views/Navigation/MediaStack.swift new file mode 100644 index 0000000..ac29ff9 --- /dev/null +++ b/Flixy/Views/Navigation/MediaStack.swift @@ -0,0 +1,49 @@ +import SwiftUI +import MediaKit + +/// Ein Navigationsstapel mit allen Zielen — und einem Weg, von innen zu +/// schieben. +/// +/// **Warum der Pfad hier liegt und nicht beim System.** Ein `NavigationLink` +/// schiebt selbst; wer stattdessen aus einem Knopf heraus schieben will, +/// braucht eine Bindung. Genau das verlangt die Kachel, seit Antippen +/// abspielt: der Weg auf die Detailseite führt dann über das Kontextmenü, und +/// ein Menüeintrag ist kein Link. +/// +/// **Die Ziele stehen hier und nur hier.** Sie je Bildschirm anzumelden hat +/// schon einmal nicht funktioniert: liegen zwei Bildschirme übereinander, +/// entscheidet sonst die Reihenfolge, und ein Tippen landete im Nichts. +struct MediaStack: View { + @ViewBuilder let content: () -> Content + + @Environment(FlixySession.self) private var session + @State private var path = NavigationPath() + + var body: some View { + NavigationStack(path: $path) { + content() + .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } + .navigationDestination(for: LibraryRoute.self) { route in + if let library = session.libraries.first(where: { $0.id == route.id }) { + LibraryView(library: library) + } + } + .navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() } + .navigationDestination(for: SearchRoute.self) { _ in SearchView() } + } + .environment(\.flixyPush) { ziel in path.append(ziel) } + } +} + +extension EnvironmentValues { + /// Schiebt ein Ziel auf den Stapel, in dem man gerade steht. + /// + /// **Über die Umgebung, weil der Weg sonst lang ist.** Eine Kachel steckt + /// in einer Reihe, die in der Startseite steckt, die im Stapel steckt. + /// Eine Bindung durch jede Ebene zu reichen hiesse, drei Ansichten um ein + /// Argument zu erweitern, das sie nur weitergeben. + /// + /// Die Voreinstellung tut nichts: ausserhalb eines Stapels gibt es nichts + /// zu schieben, und ein Absturz wäre die schlechtere Antwort. + @Entry var flixyPush: (any Hashable) -> Void = { _ in } +} diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index c472454..7afc157 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -274,15 +274,19 @@ struct RootView: View { private var tabs: some View { TabView(selection: $selection) { Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) { - mediaStack { HomeView() } - // **Nur hier.** Die Startseite hat keine - // Navigationsleiste — sie war leer und belegte trotzdem - // Höhe —, also ist ein Überzug der einzige Platz. Die - // übrigen Bildschirme haben eine, und dort stünde er - // entweder über deren Knöpfen oder mitten im Inhalt. Wer - // zu den Einstellungen will, geht auf Start; das ist ein - // Schritt und kein Umweg. - .overlay(alignment: .topTrailing) { profileButton } + // **Innerhalb des Stapels, nicht darum herum.** Als Überzug + // über `mediaStack` lag er auch über jedem **geschobenen** + // Bildschirm — auf einer Detailseite also über deren + // Drei-Punkte-Menü, zwei Knöpfe übereinander. Hier gilt er + // für die Startseite und endet mit ihr. + // + // Dort ist ein Überzug der einzige Platz: die Startseite + // schaltet ihre Navigationsleiste ab, ein `ToolbarItem` + // erschiene gar nicht. + mediaStack { + HomeView() + .overlay(alignment: .topTrailing) { profileButton } + } } .customizationID("flixy.tab.home") @@ -592,18 +596,8 @@ struct RootView: View { /// im selben Stapel übereinanderlagen (die Mediathek in der Bibliothek), /// führte kein Antippen einer Kachel mehr irgendwohin. SwiftUI meldet das /// nicht; es passiert einfach nichts. - private func mediaStack(@ViewBuilder content: () -> Content) -> some View { - NavigationStack { - content() - .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } - .navigationDestination(for: LibraryRoute.self) { route in - if let library = session.libraries.first(where: { $0.id == route.id }) { - LibraryView(library: library) - } - } - .navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() } - .navigationDestination(for: SearchRoute.self) { _ in SearchView() } - } + private func mediaStack(@ViewBuilder content: @escaping () -> Content) -> some View { + MediaStack(content: content) } /// Die Breite der Seitenleiste auf dem Mac. diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem+Media.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem+Media.swift index f25c87c..773aa36 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem+Media.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem+Media.swift @@ -180,6 +180,7 @@ extension PlexHub { id: hubIdentifier, title: title, moreKey: more == true ? key : nil, + libraryID: librarySectionID, items: items.map(\.asMediaItem) ) } diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLibrary.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLibrary.swift index 6b5965e..bb91daa 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLibrary.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLibrary.swift @@ -63,6 +63,17 @@ public struct PlexHub: Sendable, Equatable, Decodable, Identifiable { /// Ob es mehr Einträge gibt, als die Reihe zeigt — Grundlage für ein /// „Alle anzeigen" am Ende. public let more: Bool + /// Aus welcher Mediathek diese Reihe stammt. + /// + /// `nil` bei den übergreifenden Reihen — „Fortsetzen" geht quer durch + /// alle, und Plex nennt dort keine Nummer. + /// + /// **Warum es überhaupt gelesen wird.** Ohne diese Angabe kann die App + /// eine Reihe keiner Mediathek zuordnen, und die Auswahl „welche + /// Mediatheken speisen den Aufmacher" greift ins Leere: sie wirft dann + /// jede Reihe weg, fällt auf „alles" zurück und sieht aus wie ein + /// Schalter, der nichts tut. Genau so war es. + public let librarySectionID: String? public let items: [PlexItem] public var id: String { hubIdentifier } @@ -84,7 +95,7 @@ public struct PlexHub: Sendable, Equatable, Decodable, Identifiable { } private enum CodingKeys: String, CodingKey { - case hubIdentifier, title, type, key, size, more + case hubIdentifier, title, type, key, size, more, librarySectionID case items = "Metadata" } @@ -99,6 +110,15 @@ public struct PlexHub: Sendable, Equatable, Decodable, Identifiable { self.key = try values.decodeIfPresent(String.self, forKey: .key) self.size = try values.decodeIfPresent(Int.self, forKey: .size) self.more = try values.decodeIfPresent(Bool.self, forKey: .more) ?? false + // **Zahl oder Zeichenkette.** Plex liefert die Abschnittsnummer je + // nach Endpunkt mal als `3`, mal als `"3"`. Nur eines davon zu lesen + // hiesse, dass es auf manchen Servern geht und auf anderen nicht — + // und zwar lautlos, denn eine fehlende Nummer ist ein gültiger Fall. + if let zahl = try? values.decodeIfPresent(Int.self, forKey: .librarySectionID) { + self.librarySectionID = String(zahl) + } else { + self.librarySectionID = try values.decodeIfPresent(String.self, forKey: .librarySectionID) + } self.items = try values.decodeIfPresent([PlexItem].self, forKey: .items) ?? [] } @@ -109,9 +129,11 @@ public struct PlexHub: Sendable, Equatable, Decodable, Identifiable { key: String? = nil, size: Int? = nil, more: Bool = false, + librarySectionID: String? = nil, items: [PlexItem] = [] ) { self.hubIdentifier = hubIdentifier + self.librarySectionID = librarySectionID self.title = title self.type = type self.key = key diff --git a/Packages/FlixyKit/Tests/PlexKitTests/HubShapeTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/HubShapeTests.swift index 939409d..4219953 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/HubShapeTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/HubShapeTests.swift @@ -45,3 +45,46 @@ struct HubShapeTests { #expect(PlexHub(hubIdentifier: "Home.ContinueWatching", title: "x").isContinueWatching) } } + +/// Aus welcher Mediathek eine Reihe stammt. +/// +/// **Der Fehler, der es hierher gebracht hat.** Ohne diese Angabe kann die +/// App eine Reihe keiner Mediathek zuordnen — und die Einstellung „welche +/// Mediatheken speisen den Aufmacher" wirft dann jede Reihe weg, fällt auf +/// „alles" zurück und sieht aus wie ein Schalter, der nichts tut. +@Suite("Mediathek einer Reihe") +struct HubLibraryTests { + + private func hub(_ json: String) throws -> PlexHub { + try JSONDecoder().decode(PlexHub.self, from: Data(json.utf8)) + } + + /// Plex liefert die Abschnittsnummer je nach Endpunkt als Zahl oder als + /// Zeichenkette. Nur eines zu lesen hiesse: geht auf manchen Servern und + /// auf anderen nicht, und zwar lautlos. + @Test("Die Nummer kommt als Zahl") + func readsNumber() throws { + let reihe = try hub(#"{"hubIdentifier":"movie.recentlyadded","title":"Neu","librarySectionID":3}"#) + #expect(reihe.librarySectionID == "3") + } + + @Test("Die Nummer kommt als Zeichenkette") + func readsString() throws { + let reihe = try hub(#"{"hubIdentifier":"movie.recentlyadded","title":"Neu","librarySectionID":"3"}"#) + #expect(reihe.librarySectionID == "3") + } + + /// „Fortsetzen" geht quer durch alle Mediatheken; Plex nennt dort keine + /// Nummer. Das ist kein Fehler, sondern die richtige Auskunft. + @Test("Übergreifende Reihen nennen keine") + func crossLibraryHasNone() throws { + let reihe = try hub(#"{"hubIdentifier":"home.continue","title":"Fortsetzen"}"#) + #expect(reihe.librarySectionID == nil) + } + + @Test("Die Reihe reicht sie weiter") + func rowCarriesIt() throws { + let reihe = try hub(#"{"hubIdentifier":"movie.recentlyadded","title":"Neu","librarySectionID":7}"#) + #expect(reihe.asMediaRowGroup.libraryID == "7") + } +}