Die Bibliothek beginnt jetzt in einer Mediathek

Vorher stand dort eine Liste aller Mediatheken: auf „Bibliothek" tippen,
dann auf „Filme", dann endlich ein Raster. In eine andere wechseln hiess
zurück und wieder hinein. Der Zwischenbildschirm hat nie etwas erzählt,
was eine Leiste nicht auch zeigen kann — er war ein Platzproblem, kein
Entwurf.

Jetzt steht man sofort in einer Mediathek, und darüber der unteren Leiste
liegt eine zweite mit den Mediatheken und Live-TV. Auf dem iPad und am Mac
ändert sich nichts: dort steht jede Mediathek einzeln in der Seitenleiste,
auf dem Fernseher nebeneinander in der oberen Leiste — es 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. Genau daran
ist der Zwischenbildschirm seinerzeit entstanden. Eine selbst gezeichnete
Leiste kennt diese Grenze nicht — und sie sieht aus wie die Insel auf dem
iPad, weil sie dieselbe Fläche benutzt: `FlixySidebar.surface(in:)` ist
ein Bauteil, kein nachgebautes Aussehen, und wird jetzt zum dritten Mal
gerufen.

Als Sicherheitsrand und nicht als Überzug, damit die Liste über der
Leiste endet statt dahinter zu verschwinden. Bei mehr als vier
Mediatheken wird sie waagerecht scrollbar.

**Das Monogramm steht jetzt in der Werkzeugleiste**, am rechten Ende, auf
derselben Höhe wie Suche, Filter und Ansicht — die rücken dafür nach
links. Als Überzug lag es über dem Inhalt, auf einem Raster also über
einem Plakat. Dass es dabei an den Anfang der Reihe rutschte, hat einen
Grund, der im Quelltext steht: SwiftUI reiht Werkzeugleisten von aussen
nach innen, ein von aussen angehängter Eintrag steht deshalb **vor** den
übrigen. Wo in der Reihe etwas steht, kann nur der entscheiden, der die
Reihe schreibt. Auf der Startseite bleibt es ein Überzug — dort gibt es
keine Navigationsleiste.

Dazu: die Zuordnung Mediathekenart → Symbol stand dreimal im Projekt, eine
davon falsch (jede Mediathek bekam „film", auch eine mit Serien). Jetzt
einmal, neben den übrigen Ableitungen aus den Modellen.

Geprüft im Simulator: „Bibliothek" öffnet sofort das Raster, die Leiste
wechselt zwischen Filme, Serien, Gemischtes und Live-TV, und das
Monogramm steht ganz rechts in der Werkzeugleiste.

iOS und tvOS bauen, 469 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-01 09:40:00 +02:00
co-authored by Claude Opus 5
parent 0aa11e1c25
commit 7f380e5f4d
7 changed files with 244 additions and 76 deletions
@@ -9,6 +9,7 @@ struct DownloadsView: View {
@Environment(PlaybackCoordinator.self) private var playback
@Environment(NetworkConditions.self) private var network
@Environment(\.flixyAccent) private var accent
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
@@ -25,6 +26,13 @@ struct DownloadsView: View {
}
.background(Color.flixyCanvas)
.navigationTitle(Text("sidebar.downloads"))
.toolbar {
// Nur auf dem iPhone: dort gibt es den Einstellungen-Tab nicht
// mehr, und dies ist der Weg dorthin.
if sizeClass == .compact {
ToolbarItem(placement: .flixyTrailing) { AccountToolbarButton() }
}
}
.flixyInlineTitle()
// Keine Rückfrage beim Entfernen.
//
+154
View File
@@ -0,0 +1,154 @@
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 {
ScrollView(.horizontal) {
HStack(spacing: 0) {
ForEach(eintraege, id: \.auswahl) { eintrag in
pille(eintrag)
}
}
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.snug)
}
// 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) }
.clipShape(.capsule)
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
.padding(.horizontal, FlixySpacing.regular)
.padding(.bottom, 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
}
}
-60
View File
@@ -1,60 +0,0 @@
import SwiftUI
import FlixyDesign
import MediaKit
/// Alle Mediatheken auf einem Bildschirm.
///
/// Auf dem iPad hat die Seitenleiste Platz für jede Mediathek einzeln. Auf dem
/// iPhone hat die Leiste unten fünf Plätze — und sobald es mehr Ziele gibt,
/// schiebt das System den Rest in ein „Mehr"-Verzeichnis. Das sah nicht nur
/// gedrängt aus, es war kaputt: die Einträge dort bekamen **zwei**
/// Navigationsleisten übereinander, eine vom System und eine von uns.
///
/// Dieser Bildschirm fasst sie zu einem Ziel zusammen. Damit bleibt die Leiste
/// unter fünf Einträgen, und die Mediatheken stehen trotzdem alle da — mit
/// mehr Platz für ihre Namen, als eine Leiste je hätte.
struct LibraryHubView: View {
@Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent
var body: some View {
List {
if session.libraries.isEmpty {
Text("libraryHub.empty")
.foregroundStyle(Color.flixyTextSecondary)
.listRowBackground(Color.flixySurface)
} else {
Section("sidebar.libraries") {
ForEach(session.libraries) { library in
NavigationLink(value: LibraryRoute(id: library.id)) {
Label(library.title, systemImage: symbol(for: library.kind))
}
.listRowBackground(Color.flixySurface)
}
}
}
if session.supportsLiveTV {
Section {
NavigationLink(value: LiveTVRoute()) {
Label("sidebar.liveTV", systemImage: "dot.radiowaves.left.and.right")
}
.listRowBackground(Color.flixySurface)
}
}
}
.tint(accent)
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.navigationTitle(Text("sidebar.library"))
.flixyInlineTitle()
}
private func symbol(for kind: MediaLibrary.Kind) -> String {
switch kind {
case .movie: "film"
case .show: "tv"
default: "rectangle.stack"
}
}
}
+15 -3
View File
@@ -199,9 +199,12 @@ struct LibraryView: View {
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
// Nur auf schmalen Geräten: dort hat die Suche keinen eigenen Eintrag
// in der Leiste unten, weil deren fünf Plätze für die Mediatheken
// gebraucht werden. Auf dem iPad steht sie in der Seitenleiste, und
// ein zweiter Weg dorthin wäre nur Verdopplung.
// in der Leiste unten. Die Begründung dafür war lange, die fünf
// Plätze würden für die Mediatheken gebraucht — seit die in einer
// eigenen Leiste darüber stehen, stimmt das nicht mehr. Geblieben
// ist der bessere Grund: gesucht wird in einer Mediathek, und dort
// steht der Knopf. Auf dem iPad steht die Suche in der Seitenleiste,
// und ein zweiter Weg dorthin wäre nur Verdopplung.
if sizeClass == .compact {
ToolbarItem(placement: .flixyTrailing) {
NavigationLink(value: SearchRoute()) {
@@ -245,6 +248,15 @@ struct LibraryView: View {
.accessibilityLabel(Text("library.toggleLayout"))
}
// **Ganz rechts, und deshalb hier unten.** Das Monogramm ist der Weg
// in die Einstellungen, seit es auf dem iPhone keinen Tab dafür mehr
// gibt. Von aussen angehängt stünde es **vor** den übrigen Knöpfen —
// SwiftUI reiht Werkzeugleisten von aussen nach innen. Wo in der
// Reihe etwas steht, kann nur der entscheiden, der die Reihe
// schreibt.
if sizeClass == .compact {
ToolbarItem(placement: .flixyTrailing) { AccountToolbarButton() }
}
}
// MARK: - Laden
@@ -30,3 +30,32 @@ struct AccountAvatar: View {
.background(accent, in: .circle)
}
}
extension EnvironmentValues {
/// Öffnet die Einstellungen.
///
/// **Über die Umgebung, weil der Knopf an mehreren Stellen steht.** Das
/// Blatt gehört der Wurzel — nur sie überlebt den Wechsel des Bereichs.
/// Jeder Bildschirm, der den Knopf zeigt, müsste den Weg dorthin sonst
/// als Argument durchgereicht bekommen.
@Entry var flixyOpenSettings: () -> Void = {}
}
/// Das Monogramm als Knopf für eine Werkzeugleiste.
///
/// **Als Ansicht und nicht als Modifikator.** Ein Modifikator hängt seine
/// Einträge aussen an, und SwiftUI reiht aussen zuerst — das Monogramm stand
/// damit **vor** Suche, Filter und Ansicht statt dahinter. Wo in der Reihe es
/// steht, kann nur der entscheiden, der die Reihe schreibt.
struct AccountToolbarButton: View {
@Environment(FlixySession.self) private var session
@Environment(\.flixyOpenSettings) private var openSettings
var body: some View {
Button(action: openSettings) {
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("sidebar.settings"))
}
}
+18 -13
View File
@@ -275,6 +275,14 @@ struct RootView: 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 }
}
.customizationID("flixy.tab.home")
@@ -292,7 +300,7 @@ struct RootView: View {
) {
mediaStack { LibraryView(library: library) }
} label: {
sidebarLabel(Text(library.title), symbol: symbol(for: library.kind))
sidebarLabel(Text(library.title), symbol: library.kind.symbol)
}
.customizationID("flixy.tab.library.\(library.id)")
}
@@ -311,7 +319,7 @@ struct RootView: View {
}
} else {
Tab(value: SidebarDestination.libraryHub) {
mediaStack { LibraryHubView() }
mediaStack { LibraryBrowser() }
} label: {
tabLabel("sidebar.library", symbol: "books.vertical")
}
@@ -449,7 +457,7 @@ struct RootView: View {
// Insel erreichen: Inhalt kam dort nie hin. Jetzt kommt er.
.ignoresSafeArea(.container, edges: .top)
} else {
tabs.overlay(alignment: .topTrailing) { profileButton }
tabs
}
#else
tabs
@@ -460,6 +468,7 @@ struct RootView: View {
// Navigationsstapel um sich — die Medienziele aus `mediaStack` ruft
// es nie auf. Und beim Schliessen steht man wieder dort, wo man war,
// statt in einem Bereich, den man erst wieder verlassen muss.
.environment(\.flixyOpenSettings, { showsSettings = true })
.sheet(isPresented: $showsSettings) {
NavigationStack {
SettingsView()
@@ -500,14 +509,17 @@ struct RootView: View {
Button {
showsSettings = true
} label: {
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 32)
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
.padding(FlixySpacing.snug)
// Damit er auch über einem hellen Aufmacher steht.
.background(.black.opacity(0.28), in: .circle)
}
.buttonStyle(.plain)
.padding(.trailing, FlixySpacing.regular)
.padding(.top, statusBarHeight + FlixySpacing.snug)
// **Ohne die Statusleiste.** Auf dem iPhone sitzt der Baum innerhalb
// des Sicherheitsrands; sie noch einmal dazuzurechnen schöbe den
// Knopf um ihre Höhe nach unten, mitten ins Bild.
.padding(.top, FlixySpacing.snug)
.opacity(topChrome.isRevealed ? 1 : 0)
.allowsHitTesting(topChrome.isRevealed)
.animation(.snappy(duration: 0.28), value: topChrome.isRevealed)
@@ -543,7 +555,7 @@ struct RootView: View {
NavigationEntry(
destination: .library(key: library.id, title: library.title),
title: Text(library.title),
symbol: symbol(for: library.kind),
symbol: library.kind.symbol,
plainTitle: library.title
)
}
@@ -631,11 +643,4 @@ struct RootView: View {
#endif
}
private func symbol(for kind: MediaLibrary.Kind) -> String {
switch kind {
case .movie: "film"
case .show: "tv"
default: "rectangle.stack"
}
}
}