Files
Flaro/Flixy/Views/Library/LibraryBrowser.swift
T
ScarriffleandClaude Opus 5 a665d79e6d 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>
2026-10-01 10:39:45 +02:00

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
}
}