Antippen spielt ab — und vier Dinge, die daneben lagen

**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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-01 10:39:45 +02:00
co-authored by Claude Opus 5
parent 1fa8011094
commit a665d79e6d
9 changed files with 219 additions and 39 deletions
+45 -3
View File
@@ -29,6 +29,8 @@ struct QuickPlayTile<Card: View>: 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<Card: View>: 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<Card: View>: 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<Card: View>: 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<Card: View>: 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) {
+9 -1
View File
@@ -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)
+9 -1
View File
@@ -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)
}
+25 -12
View File
@@ -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
+49
View File
@@ -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<Content: View>: 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 }
}
+15 -21
View File
@@ -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<Content: View>(@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<Content: View>(@ViewBuilder content: @escaping () -> Content) -> some View {
MediaStack(content: content)
}
/// Die Breite der Seitenleiste auf dem Mac.