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:
co-authored by
Claude Opus 5
parent
1fa8011094
commit
a665d79e6d
@@ -29,6 +29,8 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
/// Was die umgebende Seite tun soll, wenn sich am Server etwas geändert
|
/// Was die umgebende Seite tun soll, wenn sich am Server etwas geändert
|
||||||
/// hat. Siehe `ItemActionEntries`.
|
/// hat. Siehe `ItemActionEntries`.
|
||||||
@Environment(\.flixyItemChanged) private var itemChanged
|
@Environment(\.flixyItemChanged) private var itemChanged
|
||||||
|
/// Der Weg auf die Detailseite — siehe `MediaStack`.
|
||||||
|
@Environment(\.flixyPush) private var push
|
||||||
|
|
||||||
private var isMac: Bool { FlixyPlatform.isMac }
|
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.
|
/// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung.
|
||||||
private var isPlayable: Bool { item.kind != .collection }
|
private var isPlayable: Bool { item.kind != .collection }
|
||||||
|
|
||||||
|
private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
kachel
|
||||||
card()
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
// **Als Überzug auf dem Link, nicht als Kind darin.**
|
// **Als Überzug auf dem Link, nicht als Kind darin.**
|
||||||
//
|
//
|
||||||
@@ -66,6 +68,15 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
// Kachel steht.
|
// Kachel steht.
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if isPlayable {
|
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() }
|
Button("tile.play", systemImage: "play.fill") { trigger() }
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
@@ -79,6 +90,13 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
// bleibt einer, und im Aktionen-Rotor steht „Abspielen". Zwanzig
|
// bleibt einer, und im Aktionen-Rotor steht „Abspielen". Zwanzig
|
||||||
// Kacheln mit je zwei Elementen wären sechzig Wischer.
|
// Kacheln mit je zwei Elementen wären sechzig Wischer.
|
||||||
.accessibilityAction(named: Text("tile.play")) { trigger() }
|
.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
|
// **Nur der Fehler, kein Kreisel.** Wer im Kontextmenü tippt, sieht
|
||||||
// das Menü verschwinden; ein Kreisel auf der Kachel käme für eine
|
// 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
|
// 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.
|
/// Oben rechts, weil der Fortschrittsbalken unten liegt.
|
||||||
private var playButton: some View {
|
private var playButton: some View {
|
||||||
Button(action: trigger) {
|
Button(action: trigger) {
|
||||||
|
|||||||
@@ -80,7 +80,15 @@ struct PlayableDetailView: View {
|
|||||||
artworkURLs: session.backend.map {
|
artworkURLs: session.backend.map {
|
||||||
HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact)
|
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 {
|
Button {
|
||||||
playback.play(item, mediaIndex: selectedVersion)
|
playback.play(item, mediaIndex: selectedVersion)
|
||||||
|
|||||||
@@ -64,7 +64,15 @@ struct SeriesDetailView: View {
|
|||||||
artworkURLs: session.backend.map {
|
artworkURLs: session.backend.map {
|
||||||
HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact)
|
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)
|
DownloadAllButton(container: item)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,19 +68,22 @@ struct LibraryBrowser: View {
|
|||||||
// MARK: - Die Leiste
|
// MARK: - Die Leiste
|
||||||
|
|
||||||
private var leiste: some View {
|
private var leiste: some View {
|
||||||
ScrollView(.horizontal) {
|
// **So breit wie nötig, nicht wie möglich.** Über die ganze Breite
|
||||||
HStack(spacing: 0) {
|
// gezogen stand sie linksbündig in einer halbleeren Kapsel — eine
|
||||||
ForEach(eintraege, id: \.auswahl) { eintrag in
|
// Leiste, die dreimal so lang ist wie ihr Inhalt, sieht aus wie ein
|
||||||
pille(eintrag)
|
// Fehler im Layout.
|
||||||
}
|
//
|
||||||
}
|
// `ViewThatFits` nimmt die erste Fassung, die hineinpasst: die
|
||||||
.padding(.horizontal, FlixySpacing.snug)
|
// ungescrollte Reihe, solange sie passt, sonst die scrollbare. Damit
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
// ist beides richtig, ohne dass irgendwo eine Zahl stünde, ab wann es
|
||||||
}
|
// kippt — bei vier Mediatheken passt es, bei acht nicht, und keine
|
||||||
// Bei vier Einträgen passt alles; die Leiste soll trotzdem nicht
|
// Fassung muss das wissen.
|
||||||
// brechen, wenn jemand sieben Mediatheken führt.
|
ViewThatFits(in: .horizontal) {
|
||||||
|
reihe
|
||||||
|
ScrollView(.horizontal) { reihe }
|
||||||
.scrollBounceBehavior(.basedOnSize)
|
.scrollBounceBehavior(.basedOnSize)
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
|
}
|
||||||
// Dieselbe Fläche wie die Insel auf dem iPad — ein `static`-Bauteil,
|
// Dieselbe Fläche wie die Insel auf dem iPad — ein `static`-Bauteil,
|
||||||
// kein nachgebautes Aussehen.
|
// kein nachgebautes Aussehen.
|
||||||
.background { FlixySidebar.surface(in: .capsule) }
|
.background { FlixySidebar.surface(in: .capsule) }
|
||||||
@@ -90,6 +93,16 @@ struct LibraryBrowser: View {
|
|||||||
.padding(.bottom, FlixySpacing.snug)
|
.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 {
|
private func pille(_ eintrag: Eintrag) -> some View {
|
||||||
let gewaehlt = eintrag.auswahl == aktuell
|
let gewaehlt = eintrag.auswahl == aktuell
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -274,16 +274,20 @@ struct RootView: View {
|
|||||||
private var tabs: some View {
|
private var tabs: some View {
|
||||||
TabView(selection: $selection) {
|
TabView(selection: $selection) {
|
||||||
Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) {
|
Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) {
|
||||||
mediaStack { HomeView() }
|
// **Innerhalb des Stapels, nicht darum herum.** Als Überzug
|
||||||
// **Nur hier.** Die Startseite hat keine
|
// über `mediaStack` lag er auch über jedem **geschobenen**
|
||||||
// Navigationsleiste — sie war leer und belegte trotzdem
|
// Bildschirm — auf einer Detailseite also über deren
|
||||||
// Höhe —, also ist ein Überzug der einzige Platz. Die
|
// Drei-Punkte-Menü, zwei Knöpfe übereinander. Hier gilt er
|
||||||
// übrigen Bildschirme haben eine, und dort stünde er
|
// für die Startseite und endet mit ihr.
|
||||||
// entweder über deren Knöpfen oder mitten im Inhalt. Wer
|
//
|
||||||
// zu den Einstellungen will, geht auf Start; das ist ein
|
// Dort ist ein Überzug der einzige Platz: die Startseite
|
||||||
// Schritt und kein Umweg.
|
// schaltet ihre Navigationsleiste ab, ein `ToolbarItem`
|
||||||
|
// erschiene gar nicht.
|
||||||
|
mediaStack {
|
||||||
|
HomeView()
|
||||||
.overlay(alignment: .topTrailing) { profileButton }
|
.overlay(alignment: .topTrailing) { profileButton }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.customizationID("flixy.tab.home")
|
.customizationID("flixy.tab.home")
|
||||||
|
|
||||||
// Auf dem iPad jede Mediathek einzeln, auf dem iPhone alle
|
// Auf dem iPad jede Mediathek einzeln, auf dem iPhone alle
|
||||||
@@ -592,18 +596,8 @@ struct RootView: View {
|
|||||||
/// im selben Stapel übereinanderlagen (die Mediathek in der Bibliothek),
|
/// im selben Stapel übereinanderlagen (die Mediathek in der Bibliothek),
|
||||||
/// führte kein Antippen einer Kachel mehr irgendwohin. SwiftUI meldet das
|
/// führte kein Antippen einer Kachel mehr irgendwohin. SwiftUI meldet das
|
||||||
/// nicht; es passiert einfach nichts.
|
/// nicht; es passiert einfach nichts.
|
||||||
private func mediaStack<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
private func mediaStack<Content: View>(@ViewBuilder content: @escaping () -> Content) -> some View {
|
||||||
NavigationStack {
|
MediaStack(content: content)
|
||||||
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() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Breite der Seitenleiste auf dem Mac.
|
/// Die Breite der Seitenleiste auf dem Mac.
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ extension PlexHub {
|
|||||||
id: hubIdentifier,
|
id: hubIdentifier,
|
||||||
title: title,
|
title: title,
|
||||||
moreKey: more == true ? key : nil,
|
moreKey: more == true ? key : nil,
|
||||||
|
libraryID: librarySectionID,
|
||||||
items: items.map(\.asMediaItem)
|
items: items.map(\.asMediaItem)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
/// Ob es mehr Einträge gibt, als die Reihe zeigt — Grundlage für ein
|
||||||
/// „Alle anzeigen" am Ende.
|
/// „Alle anzeigen" am Ende.
|
||||||
public let more: Bool
|
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 let items: [PlexItem]
|
||||||
|
|
||||||
public var id: String { hubIdentifier }
|
public var id: String { hubIdentifier }
|
||||||
@@ -84,7 +95,7 @@ public struct PlexHub: Sendable, Equatable, Decodable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case hubIdentifier, title, type, key, size, more
|
case hubIdentifier, title, type, key, size, more, librarySectionID
|
||||||
case items = "Metadata"
|
case items = "Metadata"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +110,15 @@ public struct PlexHub: Sendable, Equatable, Decodable, Identifiable {
|
|||||||
self.key = try values.decodeIfPresent(String.self, forKey: .key)
|
self.key = try values.decodeIfPresent(String.self, forKey: .key)
|
||||||
self.size = try values.decodeIfPresent(Int.self, forKey: .size)
|
self.size = try values.decodeIfPresent(Int.self, forKey: .size)
|
||||||
self.more = try values.decodeIfPresent(Bool.self, forKey: .more) ?? false
|
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) ?? []
|
self.items = try values.decodeIfPresent([PlexItem].self, forKey: .items) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,9 +129,11 @@ public struct PlexHub: Sendable, Equatable, Decodable, Identifiable {
|
|||||||
key: String? = nil,
|
key: String? = nil,
|
||||||
size: Int? = nil,
|
size: Int? = nil,
|
||||||
more: Bool = false,
|
more: Bool = false,
|
||||||
|
librarySectionID: String? = nil,
|
||||||
items: [PlexItem] = []
|
items: [PlexItem] = []
|
||||||
) {
|
) {
|
||||||
self.hubIdentifier = hubIdentifier
|
self.hubIdentifier = hubIdentifier
|
||||||
|
self.librarySectionID = librarySectionID
|
||||||
self.title = title
|
self.title = title
|
||||||
self.type = type
|
self.type = type
|
||||||
self.key = key
|
self.key = key
|
||||||
|
|||||||
@@ -45,3 +45,46 @@ struct HubShapeTests {
|
|||||||
#expect(PlexHub(hubIdentifier: "Home.ContinueWatching", title: "x").isContinueWatching)
|
#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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user