**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>
168 lines
6.3 KiB
Swift
168 lines
6.3 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import MediaKit
|
|
|
|
/// Die Mediatheken auf dem iPhone — mit einer Leiste zum Wechseln.
|
|
///
|
|
/// **Was hier wegfällt, ist ein Zwischenschritt.** Vorher stand an dieser
|
|
/// Stelle eine Liste aller Mediatheken; wer Filme sehen wollte, tippte auf
|
|
/// „Bibliothek", dann auf „Filme", und bekam erst dann ein Raster. Zurück und
|
|
/// in eine andere hiess: zweimal tippen. Der Zwischenbildschirm hat nie etwas
|
|
/// erzählt, was die Leiste nicht auch zeigen kann.
|
|
///
|
|
/// Auf dem iPad und am Mac gibt es ihn ohnehin nicht — dort steht jede
|
|
/// Mediathek einzeln in der Seitenleiste, und auf dem Fernseher nebeneinander
|
|
/// in der oberen Leiste. Das hier ist dieselbe Idee in der Form, die ein
|
|
/// Telefon hergibt.
|
|
///
|
|
/// **Warum eine zweite Leiste und nicht Plätze in der unteren.** Die hat
|
|
/// fünf, und alles darüber schiebt das System in ein „Mehr"-Verzeichnis,
|
|
/// dessen Einträge zwei Navigationsleisten übereinander tragen. Eine selbst
|
|
/// gezeichnete Leiste kennt diese Grenze nicht.
|
|
struct LibraryBrowser: View {
|
|
@Environment(FlixySession.self) private var session
|
|
|
|
/// Was gerade gezeigt wird.
|
|
///
|
|
/// `nil` heisst „noch nichts gewählt" — dann gilt die erste Mediathek.
|
|
/// Eine Vorauswahl beim Anlegen ginge nicht: die Mediatheken kommen vom
|
|
/// Server und stehen beim ersten Aufbau noch nicht fest.
|
|
@State private var auswahl: Auswahl?
|
|
|
|
private enum Auswahl: Hashable {
|
|
case library(String)
|
|
case liveTV
|
|
}
|
|
|
|
var body: some View {
|
|
inhalt
|
|
.background(Color.flixyCanvas)
|
|
// **Als Sicherheitsrand, nicht als Überzug.** So endet die Liste
|
|
// über der Leiste, statt dahinter zu verschwinden — und das gilt
|
|
// auch für die Ladeanzeige und den Fuss „alles geladen".
|
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
|
if eintraege.count > 1 { leiste }
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var inhalt: some View {
|
|
switch aktuell {
|
|
case .library(let id):
|
|
if let library = session.libraries.first(where: { $0.id == id }) {
|
|
// **Mit `id`, damit der Wechsel sauber neu aufsetzt.** Ohne
|
|
// das behielte die Ansicht die Titel der vorigen Mediathek,
|
|
// bis der Server antwortet — und für einen Augenblick stünden
|
|
// Filme unter „Serien".
|
|
LibraryView(library: library).id(library.id)
|
|
}
|
|
case .liveTV:
|
|
LiveTVView()
|
|
case nil:
|
|
ContentUnavailableView {
|
|
Label("libraryHub.empty", systemImage: "books.vertical")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Die Leiste
|
|
|
|
private var leiste: some View {
|
|
// **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)
|
|
}
|
|
// Dieselbe Fläche wie die Insel auf dem iPad — ein `static`-Bauteil,
|
|
// kein nachgebautes Aussehen.
|
|
.background { FlixySidebar.surface(in: .capsule) }
|
|
.clipShape(.capsule)
|
|
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
|
|
.padding(.horizontal, FlixySpacing.regular)
|
|
.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
|
|
|
|
return Button {
|
|
auswahl = eintrag.auswahl
|
|
} label: {
|
|
Label {
|
|
Text(eintrag.titel).lineLimit(1)
|
|
} icon: {
|
|
Image(systemName: eintrag.symbol)
|
|
}
|
|
// Kleiner als in der Leiste auf dem iPad: dort stehen drei
|
|
// Einträge, hier bis zu fünf — und auf einem Telefon.
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(gewaehlt ? Color.flixyTextPrimary : Color.flixyTextSecondary)
|
|
.padding(.horizontal, FlixySpacing.snug)
|
|
.padding(.vertical, FlixySpacing.snug)
|
|
.background {
|
|
if gewaehlt { Capsule().fill(Color.flixyElevated) }
|
|
}
|
|
.contentShape(Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityAddTraits(gewaehlt ? .isSelected : [])
|
|
}
|
|
|
|
// MARK: - Was in der Leiste steht
|
|
|
|
private struct Eintrag {
|
|
let auswahl: Auswahl
|
|
let titel: String
|
|
let symbol: String
|
|
}
|
|
|
|
private var eintraege: [Eintrag] {
|
|
var liste = session.libraries.map {
|
|
Eintrag(auswahl: .library($0.id), titel: $0.title, symbol: $0.kind.symbol)
|
|
}
|
|
// Live-TV wohnte im Zwischenbildschirm und bräuchte sonst ein neues
|
|
// Zuhause. Hier steht es, wo man es sucht: neben den Mediatheken.
|
|
if session.supportsLiveTV {
|
|
liste.append(Eintrag(
|
|
auswahl: .liveTV,
|
|
titel: String(localized: "sidebar.liveTV"),
|
|
symbol: MediaLibrary.Kind.liveTV.symbol
|
|
))
|
|
}
|
|
return liste
|
|
}
|
|
|
|
/// Die gültige Auswahl — die gespeicherte, solange es sie noch gibt.
|
|
///
|
|
/// **Nicht einfach `auswahl`.** Nach einem Serverwechsel zeigt die
|
|
/// gespeicherte Kennung auf eine Mediathek, die es nicht mehr gibt; dann
|
|
/// stünde ein leerer Bildschirm da, ohne dass irgendetwas erklärt, warum.
|
|
private var aktuell: Auswahl? {
|
|
if let auswahl, eintraege.contains(where: { $0.auswahl == auswahl }) {
|
|
return auswahl
|
|
}
|
|
return eintraege.first?.auswahl
|
|
}
|
|
}
|