Er hat weiterhin angehalten statt ein- und auszublenden, und der Grund steckte in der Rechnung: der schwarze Rand wurde aus der Fenstergrösse ausgerechnet, das Bild liegt aber in einer Fläche mit `ignoresSafeArea()`. Auf dem Mac ist der Einzug der Fensterleiste genau der Unterschied zwischen beiden — die Rechnung lag um diesen Betrag daneben. Jetzt wird die Bildfläche gemessen statt angenommen, und beide Zahlen stehen in einem gemeinsamen, benannten Koordinatenraum. Vor allem aber wird zuerst `AVPlayerLayer.videoRect` gefragt: der Player weiss selbst, wo Bild steht. Die eigene Rechnung bleibt für den Moment vor dem ersten Bild und für VLC, das keine solche Ebene hat. Dazu zwei Worte: „Nachschub" heisst jetzt „Nachladen", und „Blatt" heisst wieder „Sheet" — das ist ein Fachbegriff und bleibt englisch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
539 lines
25 KiB
Swift
539 lines
25 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import MediaKit
|
|
import FlixyDownloads
|
|
import FlixyPlayback
|
|
import PlexKit
|
|
|
|
/// Die Wurzelansicht.
|
|
///
|
|
/// Entscheidet anhand des Sitzungszustands, ob überhaupt etwas zu zeigen ist.
|
|
/// Welche Form die Navigation danach annimmt — Seitenleiste, Leiste oben,
|
|
/// Leiste unten —, entscheidet das System und der Nutzer, nicht diese Datei.
|
|
struct RootView: View {
|
|
@Environment(FlixySession.self) private var session
|
|
@Environment(FlixyTheme.self) private var theme
|
|
@Environment(PlaybackCoordinator.self) private var playback
|
|
@Environment(PlaybackSettings.self) private var settings
|
|
@Environment(NetworkConditions.self) private var network
|
|
@Environment(DownloadCoordinator.self) private var downloads
|
|
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
|
|
|
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
|
|
///
|
|
/// Damit überlebt die Wiedergabe den Wechsel zwischen Kachel und
|
|
/// Vollbild. Läge das Modell im Player selbst, stürbe es mit ihm, und
|
|
/// genau das ist vorher passiert.
|
|
@State private var player: PlayerModel?
|
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
|
|
|
@State private var selection: SidebarDestination = .home
|
|
/// Was der Nutzer an der Navigation umgestellt hat.
|
|
///
|
|
/// Reihenfolge, Ein- und Ausblenden, und ob ein Eintrag in der Leiste oben
|
|
/// liegt: das System schreibt es hier hinein, wir geben ihm nur einen Ort
|
|
/// zum Aufbewahren.
|
|
@AppStorage("flixy.navigation.customization")
|
|
private var customization = TabViewCustomization()
|
|
|
|
/// Ob die Navigation an der Seite oder oben sitzt — gespeichert.
|
|
///
|
|
/// Die Wahl gehört dem Nutzer und muss den Neustart überleben.
|
|
@AppStorage("flixy.navigation.placement")
|
|
private var storedPlacement: NavigationPlacement = .sidebar
|
|
|
|
/// Dieselbe Wahl, aber als Zustand dieser Ansicht.
|
|
///
|
|
/// Der Umweg ist nötig, damit das Umschalten überhaupt zu sehen ist:
|
|
/// `@AppStorage` schreibt in die Voreinstellungen und meldet die Änderung
|
|
/// erst danach zurück. Sie kommt also außerhalb der Animation an, und die
|
|
/// Leiste sprang von einer Stellung in die andere, statt zu wandern.
|
|
///
|
|
/// Hier ändert sich der Zustand sofort und im selben Zug; gespeichert wird
|
|
/// hinterher.
|
|
@State private var placement: NavigationPlacement = .sidebar
|
|
|
|
/// Wie die Leiste umzieht. Kurz, aber sichtbar.
|
|
private static let placementAnimation: Animation = .snappy(duration: 0.32)
|
|
|
|
/// Was der Nutzer an der Navigation umgestellt hat: Reihenfolge und was
|
|
/// überhaupt dastehen soll.
|
|
@State private var navigationLayout = NavigationLayoutSettings()
|
|
|
|
/// Die Insel oben: wie hoch sie baut und ob sie gerade zu sehen ist.
|
|
@State private var topChrome = TopChrome()
|
|
|
|
/// Wie hoch die Statusleiste baut.
|
|
///
|
|
/// Gemessen und nicht angenommen: es sind nicht überall dieselben Punkte,
|
|
/// und beim Drehen ändert sich der Wert. Gemessen wird **außerhalb** des
|
|
/// Bereichs, der den Sicherheitsrand gleich darauf ignoriert — innerhalb
|
|
/// wäre er längst null.
|
|
@State private var statusBarHeight: CGFloat = 0
|
|
|
|
var body: some View {
|
|
Group {
|
|
switch session.phase {
|
|
case .launching:
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Color.flixyCanvas)
|
|
|
|
case .signedOut, .authenticating:
|
|
SignInView(
|
|
phase: session.phase,
|
|
manualCode: session.showsManualCode ? session.pendingCode : nil,
|
|
onSignIn: { Task { await session.beginSignIn() } },
|
|
onCancel: { session.cancelSignIn() }
|
|
)
|
|
|
|
case .choosingServer(let servers):
|
|
NavigationStack {
|
|
ServerPickerView(servers: servers) { resource in
|
|
Task { await session.connect(to: resource) }
|
|
}
|
|
}
|
|
|
|
case .connecting(let name):
|
|
ConnectingView(serverName: name)
|
|
|
|
case .failed(let message):
|
|
ConnectionFailureView(
|
|
message: message,
|
|
onRetry: { Task { await session.retry() } },
|
|
onSignOut: { session.signOut() }
|
|
)
|
|
|
|
case .ready:
|
|
library
|
|
}
|
|
}
|
|
.background(Color.flixyCanvas)
|
|
.environment(topChrome)
|
|
// Hier und nicht weiter innen: ab dem `HStack` wird der obere
|
|
// Sicherheitsrand ignoriert, damit der Inhalt bis an den
|
|
// Bildschirmrand reicht. Wer danach misst, bekommt null.
|
|
.onGeometryChange(for: CGFloat.self) { proxy in
|
|
proxy.safeAreaInsets.top
|
|
} action: { statusBarHeight = $0 }
|
|
// Hier statt in der Scene: eine View wertet Beobachtungsänderungen
|
|
// zuverlässig neu aus, ein Scene-Body nicht.
|
|
.flixyAccent(theme)
|
|
.tint(theme.accentColor)
|
|
// Einmal an der Wurzel statt in jedem Bildschirm, von dem aus sich
|
|
// etwas starten lässt — sonst gäbe es vier Kopien desselben Übergangs.
|
|
// Kein `fullScreenCover` mehr: das ist eine eigene Hierarchie, und
|
|
// die Bildfläche müsste bei jedem Wechsel zwischen Kachel und
|
|
// Vollbild umziehen. Als Schicht im selben Baum bleibt sie, wo sie
|
|
// ist, und der Übergang ist ruhig.
|
|
.overlay {
|
|
if playback.presentation == .full, let player {
|
|
PlayerView(model: player)
|
|
.ignoresSafeArea()
|
|
.transition(.move(edge: .bottom))
|
|
}
|
|
}
|
|
.animation(.snappy(duration: 0.3), value: playback.presentation)
|
|
.onChange(of: playback.request) { _, request in
|
|
Task { await handOver(to: request) }
|
|
}
|
|
.task { await session.restore() }
|
|
.onChange(of: session.phase) { _, newPhase in
|
|
// Nach einer Neuanmeldung darf nicht eine Mediathek ausgewählt
|
|
// bleiben, die es auf dem neuen Server gar nicht gibt.
|
|
if newPhase == .ready, case .library = selection { selection = .home }
|
|
}
|
|
.onChange(of: sizeClass) { _, _ in
|
|
// Beim Drehen oder Teilen des Fensters wechselt die Form der
|
|
// Navigation — und mit ihr, welche Ziele es überhaupt gibt. Eine
|
|
// Auswahl, die auf der anderen Seite nicht vorkommt, zeigte sonst
|
|
// einen leeren Bereich.
|
|
switch selection {
|
|
case .library, .libraryHub, .liveTV: selection = .home
|
|
default: break
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Wechselt den laufenden Titel.
|
|
///
|
|
/// Das Neue wird zuerst aufgebaut, das Alte **nebenher** beendet. Anders
|
|
/// herum säße man nach dem Antippen sekundenlang vor einem leeren Bild:
|
|
/// das Abräumen spricht mit dem Server — Transcoder anhalten, beim
|
|
/// Live-TV den Tuner freigeben —, und das darf den neuen Titel nicht
|
|
/// aufhalten. Ein kurzzeitig doppelt belegter Tuner ist der kleinere
|
|
/// Preis; der Sender, auf den umgeschaltet wird, ist ohnehin schon
|
|
/// abgestimmt, bevor es hier ankommt.
|
|
private func handOver(to request: PlaybackRequest?) async {
|
|
// Zuerst und ohne Warten: den alten Engine stilllegen. Das Aufräumen am
|
|
// Server darf dauern, das Abschalten nicht — läuft der alte Engine
|
|
// weiter, während der neue startet, streiten sich zwei um dieselbe
|
|
// Audiositzung. Zu hören war das als alter Ton, der weiterlief, und
|
|
// als neue Wiedergabe, die stumm blieb.
|
|
let previous = player
|
|
previous?.silence()
|
|
player = nil
|
|
if let previous { Task { await previous.releaseOnServer() } }
|
|
|
|
guard let request else { return }
|
|
|
|
let fresh = PlayerModel(
|
|
request: request,
|
|
backend: session.backend,
|
|
liveTV: session.liveTV,
|
|
downloads: downloads,
|
|
diagnostics: diagnostics,
|
|
policy: settings.policy(for: network),
|
|
settings: settings,
|
|
// **Nicht mehr über `server`.** Der ist ein `PlexServer` und bei
|
|
// Jellyfin immer `nil` — damit galt dort jede Wiedergabe als
|
|
// entfernt, auch zuhause am Kabel, und „Auto" hätte ein
|
|
// 67-Mbit-Remux ohne Not heruntergerechnet.
|
|
isLocalConnection: session.isLocalConnection,
|
|
connectionDescription: session.connectionDescription,
|
|
unavailableMessage: session.playbackUnavailableMessage
|
|
)
|
|
// Aus Bild-in-Bild zurückzukehren soll im Vollbild landen, nicht in
|
|
// der Kachel — dort kam man vorher gar nicht mehr heraus.
|
|
fresh.onPictureInPictureRestore { playback.expand() }
|
|
|
|
player = fresh
|
|
await fresh.start()
|
|
}
|
|
|
|
/// Die Navigation.
|
|
///
|
|
/// Sie kommt vom System, nicht von uns. `sidebarAdaptable` ist genau die
|
|
/// Form, die man aus JellyTV kennt: auf dem iPad eine Seitenleiste aus
|
|
/// Glas, über deren Kopfzeile sich mit einem Knopf auf eine Leiste **oben**
|
|
/// umschalten lässt, auf dem iPhone die gewohnte Leiste unten. Dazu gibt
|
|
/// es „Bearbeiten" mit Ziehen, Ein- und Ausblenden — alles vom System,
|
|
/// übersetzt, barrierefrei und mit der Gestaltung, die Apple in diesem
|
|
/// Jahr vorgibt.
|
|
///
|
|
/// Vorher standen hier drei selbstgebaute Formen nebeneinander: ein
|
|
/// ausfahrbares Sheet, eine geteilte Ansicht und eine Reihe oben. Sie
|
|
/// konnten zusammen weniger als das eine Bauteil und mussten dreimal
|
|
/// gepflegt werden.
|
|
/// Läuft diese App gerade auf einem Mac?
|
|
///
|
|
/// Nicht über `#if` zu haben: „Designed for iPad" **ist** der iOS-Bau,
|
|
/// unverändert, nur auf anderer Hardware. Erst zur Laufzeit ist der
|
|
/// Unterschied sichtbar.
|
|
static let isOnMac = ProcessInfo.processInfo.isMacCatalystApp
|
|
|
|
private var tabs: some View {
|
|
TabView(selection: $selection) {
|
|
Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) {
|
|
mediaStack { HomeView() }
|
|
}
|
|
.customizationID("flixy.tab.home")
|
|
|
|
// Auf dem iPad jede Mediathek einzeln, auf dem iPhone alle
|
|
// zusammen hinter einem Eintrag. Der Grund ist nicht Geschmack:
|
|
// die Leiste unten hat fünf Plätze, und alles darüber schiebt das
|
|
// System in ein „Mehr"-Verzeichnis — dessen Einträge dann zwei
|
|
// Navigationsleisten übereinander tragen, eine vom System und eine
|
|
// von uns.
|
|
if sizeClass == .regular {
|
|
TabSection {
|
|
ForEach(session.libraries) { library in
|
|
Tab(
|
|
value: SidebarDestination.library(key: library.id, title: library.title)
|
|
) {
|
|
mediaStack { LibraryView(library: library) }
|
|
} label: {
|
|
sidebarLabel(Text(library.title), symbol: symbol(for: library.kind))
|
|
}
|
|
.customizationID("flixy.tab.library.\(library.id)")
|
|
}
|
|
} header: {
|
|
Text("sidebar.libraries")
|
|
}
|
|
.customizationID("flixy.tab.libraries")
|
|
|
|
if session.supportsLiveTV {
|
|
Tab(value: SidebarDestination.liveTV) {
|
|
mediaStack { LiveTVView() }
|
|
} label: {
|
|
tabLabel("sidebar.liveTV", symbol: "dot.radiowaves.left.and.right")
|
|
}
|
|
.customizationID("flixy.tab.liveTV")
|
|
}
|
|
} else {
|
|
Tab(value: SidebarDestination.libraryHub) {
|
|
mediaStack { LibraryHubView() }
|
|
} label: {
|
|
tabLabel("sidebar.library", symbol: "books.vertical")
|
|
}
|
|
.customizationID("flixy.tab.libraryHub")
|
|
}
|
|
|
|
Tab(value: SidebarDestination.downloads) {
|
|
mediaStack { DownloadsView() }
|
|
} label: {
|
|
tabLabel("sidebar.downloads", symbol: "arrow.down.circle")
|
|
}
|
|
.customizationID("flixy.tab.downloads")
|
|
|
|
Tab(value: SidebarDestination.settings) {
|
|
mediaStack { SettingsView() }
|
|
} label: {
|
|
tabLabel("sidebar.settings", symbol: "gearshape")
|
|
}
|
|
.customizationID("flixy.tab.settings")
|
|
|
|
// Die Suche nur dort, wo Platz für sie ist.
|
|
//
|
|
// Die Leiste unten hat fünf Plätze. Mit der Suche wäre einer davon
|
|
// für etwas belegt, das man in einer Mediathek sucht und nicht in
|
|
// einer Leiste — deshalb sitzt sie auf dem iPhone über der
|
|
// Mediathek, neben Filter und Ansicht.
|
|
if sizeClass == .regular {
|
|
Tab(value: SidebarDestination.search, role: .search) {
|
|
mediaStack { SearchView() }
|
|
}
|
|
.customizationID("flixy.tab.search")
|
|
}
|
|
}
|
|
.tabViewStyle(.sidebarAdaptable)
|
|
.tabViewCustomization($customization)
|
|
}
|
|
|
|
/// Die Navigation, wie **wir** sie zeichnen.
|
|
///
|
|
/// Der `TabView` darunter bleibt stehen und macht weiter, was er kann:
|
|
/// Auswahl, ein eigener Navigationsstapel je Eintrag, erhaltener
|
|
/// Scrollstand beim Wechseln. Nur sein Kleid ist ausgeblendet — siehe
|
|
/// `TabChromeSuppressor`. Gezeichnet wird darüber.
|
|
///
|
|
/// Der Grund steht dort ausführlich: die System-Seitenleiste sitzt auf dem
|
|
/// iPad als schwebende Fläche und auf dem Mac angeklebt am Fensterrand,
|
|
/// und das lässt sich nicht umstellen. Da beides gleich aussehen soll,
|
|
/// zeichnen wir es selbst.
|
|
///
|
|
/// Auf dem iPhone bleibt alles beim System: dort gibt es keine
|
|
/// Seitenleiste, sondern die Leiste unten, und die ist überall dieselbe.
|
|
private var library: some View {
|
|
Group {
|
|
#if os(iOS)
|
|
if sizeClass == .regular {
|
|
HStack(spacing: 0) {
|
|
if placement == .sidebar {
|
|
FlixySidebar(
|
|
groups: navigationGroups,
|
|
selection: $selection,
|
|
placement: $placement.animation(Self.placementAnimation),
|
|
layout: navigationLayout
|
|
)
|
|
// Sie klappt nach **oben** weg und kommt von oben
|
|
// zurück — und die Leiste oben schiebt sich seitwärts
|
|
// herein beziehungsweise hinaus. Zusammen sieht es aus,
|
|
// als zöge dieselbe Leiste um: erst zusammenklappen,
|
|
// dann hinüberschieben.
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
// Die Statusleiste gehört ihr nicht. Seit der Baum den
|
|
// Sicherheitsrand ignoriert, säße ihre Oberkante sonst
|
|
// unter der Uhrzeit.
|
|
.padding(.top, statusBarHeight)
|
|
}
|
|
|
|
tabs
|
|
// Der Platz oben als **Sicherheitsrand**, nicht als
|
|
// Abstand.
|
|
//
|
|
// Der Unterschied ist der schwarze Balken, über den du
|
|
// dich zu Recht gewundert hast: mit einem Abstand hört
|
|
// der Inhalt dort auf und die Fläche darüber bleibt für
|
|
// immer leer. Als Sicherheitsrand fängt er im
|
|
// Ruhezustand ebenso darunter an — aber beim Scrollen
|
|
// wandert er **hindurch**, bis an den Bildschirmrand.
|
|
// Die Leiste ist eine Insel; hinter einer Insel läuft
|
|
// das Wasser durch.
|
|
//
|
|
// Anders als `safeAreaPadding` erreicht das auch die
|
|
// geschobenen Bildschirme: deren Navigationsleisten
|
|
// richten sich danach.
|
|
//
|
|
// Die Statusleiste ist mit eingerechnet, weil der Baum
|
|
// sie gleich unten ignoriert. Ohne sie begänne der
|
|
// Inhalt hinter der Uhrzeit.
|
|
.safeAreaInset(edge: .top, spacing: 0) {
|
|
Color.clear.frame(height: topInset)
|
|
}
|
|
.background { TabChromeSuppressor().frame(width: 0, height: 0) }
|
|
.overlay(alignment: .top) {
|
|
if placement == .top {
|
|
FlixyTopBar(
|
|
groups: navigationGroups.map(visible),
|
|
selection: $selection,
|
|
placement: $placement.animation(Self.placementAnimation)
|
|
)
|
|
.transition(.move(edge: .leading).combined(with: .opacity))
|
|
.padding(.top, statusBarHeight)
|
|
// Beim Scrollen fährt sie nach oben weg.
|
|
//
|
|
// Nur sie: der Sicherheitsrand darunter bleibt,
|
|
// wie er ist. Schrumpfte er mit, rückte der
|
|
// Inhalt bei jedem Ein- und Ausblenden nach —
|
|
// man scrollt, und die Seite ruckt einem unter
|
|
// dem Finger weg.
|
|
.offset(y: topChrome.isRevealed ? 0 : -topInset)
|
|
.opacity(topChrome.isRevealed ? 1 : 0)
|
|
.animation(.snappy(duration: 0.28), value: topChrome.isRevealed)
|
|
}
|
|
}
|
|
}
|
|
// Hier reißt der Baum bis an den Bildschirmrand auf.
|
|
//
|
|
// Das ist der eigentliche Griff. Vorher saß alles — Leiste,
|
|
// Bildschirme, Bildlaufansichten — innerhalb des
|
|
// Sicherheitsrands, und deshalb konnte kein noch so gut
|
|
// gesetzter Rand weiter unten den schwarzen Streifen über der
|
|
// Insel erreichen: Inhalt kam dort nie hin. Jetzt kommt er.
|
|
.ignoresSafeArea(.container, edges: .top)
|
|
} else {
|
|
tabs
|
|
}
|
|
#else
|
|
tabs
|
|
#endif
|
|
}
|
|
.miniPlayer(player, isVisible: playback.presentation == .mini)
|
|
.onAppear { placement = storedPlacement }
|
|
.onChange(of: placement) { _, new in
|
|
storedPlacement = new
|
|
// Zieht die Navigation um, soll die neue Leiste nicht
|
|
// ausgeblendet ankommen.
|
|
topChrome.isRevealed = true
|
|
}
|
|
// Weitergereicht, damit der Aufmacher weiß, worunter er greifen muss.
|
|
.onChange(of: topInset, initial: true) { _, new in topChrome.inset = new }
|
|
}
|
|
|
|
/// Wie viel Platz die Leiste oben braucht.
|
|
private static let topBarHeight: CGFloat = 52
|
|
|
|
/// Wie tief der Inhalt unter dem oberen Bildschirmrand beginnt.
|
|
///
|
|
/// Die Statusleiste plus das, was die Navigation dort belegt — bei der
|
|
/// Seitenleiste nur ihr Rand, bei der Leiste oben deren volle Höhe.
|
|
///
|
|
/// Auf dem iPhone null: dort sitzt die Leiste unten, und über dem Inhalt
|
|
/// schwebt nichts, worunter man greifen müsste.
|
|
private var topInset: CGFloat {
|
|
guard sizeClass == .regular else { return 0 }
|
|
return statusBarHeight + (placement == .sidebar ? FlixySidebar.margin : Self.topBarHeight)
|
|
}
|
|
|
|
private var navigationGroups: [NavigationGroup] {
|
|
var groups: [NavigationGroup] = [
|
|
NavigationGroup(id: "main", title: nil, entries: [
|
|
NavigationEntry(destination: .home, title: Text("sidebar.home"), symbol: "house", plainTitle: String(localized: "sidebar.home")),
|
|
NavigationEntry(destination: .downloads, title: Text("sidebar.downloads"), symbol: "arrow.down.circle", plainTitle: String(localized: "sidebar.downloads")),
|
|
NavigationEntry(destination: .settings, title: Text("sidebar.settings"), symbol: "gearshape", plainTitle: String(localized: "sidebar.settings")),
|
|
NavigationEntry(destination: .search, title: Text("sidebar.search"), symbol: "magnifyingglass", plainTitle: String(localized: "sidebar.search")),
|
|
])
|
|
]
|
|
|
|
var media = session.libraries.map { library in
|
|
NavigationEntry(
|
|
destination: .library(key: library.id, title: library.title),
|
|
title: Text(library.title),
|
|
symbol: symbol(for: library.kind),
|
|
plainTitle: library.title
|
|
)
|
|
}
|
|
if session.supportsLiveTV {
|
|
media.append(NavigationEntry(
|
|
destination: .liveTV,
|
|
title: Text("sidebar.liveTV"),
|
|
symbol: "dot.radiowaves.left.and.right",
|
|
plainTitle: String(localized: "sidebar.liveTV")
|
|
))
|
|
}
|
|
if !media.isEmpty {
|
|
groups.append(NavigationGroup(id: "libraries", title: "sidebar.libraries", entries: media))
|
|
}
|
|
|
|
return groups
|
|
}
|
|
|
|
/// Eine Gruppe so, wie sie dastehen soll — ausgeblendete Einträge raus,
|
|
/// Reihenfolge angewandt. Die Seitenleiste macht das selbst, weil sie zum
|
|
/// Bearbeiten auch die ausgeblendeten braucht.
|
|
private func visible(_ group: NavigationGroup) -> NavigationGroup {
|
|
NavigationGroup(
|
|
id: group.id,
|
|
title: group.title,
|
|
entries: navigationLayout.apply(to: group.entries)
|
|
)
|
|
}
|
|
|
|
/// Ein Navigationsstapel, in dem Detailseiten aufgehen.
|
|
///
|
|
/// Das Ziel wird **einmal je Stapel** angemeldet, an seiner Wurzel. Vorher
|
|
/// tat es jeder Bildschirm für sich — und in dem Moment, in dem zwei davon
|
|
/// 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() }
|
|
}
|
|
}
|
|
|
|
/// Die Breite der Seitenleiste auf dem Mac.
|
|
///
|
|
/// Sie richtet sich nach ihrem Inhalt. Ohne Untergrenze fällt sie bei
|
|
/// kurzen Wörtern wie „Start" so weit zusammen, dass der Kontoname darunter
|
|
/// abgeschnitten wird; **ohne Obergrenze** ließ sie sich über das halbe
|
|
/// Fenster ziehen. Beide Zahlen sind nötig, und die obere entspricht dem,
|
|
/// was eine Seitenleiste auf dem iPad breit ist.
|
|
#if os(macOS) || targetEnvironment(macCatalyst)
|
|
private static let sidebarWidth: (min: CGFloat, ideal: CGFloat, max: CGFloat) =
|
|
(min: 190, ideal: 230, max: 320)
|
|
#endif
|
|
|
|
/// Die Beschriftung eines Eintrags.
|
|
///
|
|
/// **Ein** Aufruf, nicht zwei. Es gab hier eine zweite Fassung für Titel,
|
|
/// die vom Server kommen — mit `String` statt `LocalizedStringKey`. Swift
|
|
/// darf sich bei einem Literal für beide entscheiden, und es entschied sich
|
|
/// nicht überall gleich: in der Seitenleiste stand plötzlich
|
|
/// „sidebar.downloads" statt „Downloads". Wer einen fertigen Text hat,
|
|
/// baut sein `Text` jetzt selbst.
|
|
private func tabLabel(_ title: LocalizedStringKey, symbol: String) -> some View {
|
|
sidebarLabel(Text(title), symbol: symbol)
|
|
}
|
|
|
|
private func sidebarLabel(_ title: Text, symbol: String) -> some View {
|
|
let label = Label { title } icon: { Image(systemName: symbol) }
|
|
#if os(macOS)
|
|
return label.frame(
|
|
minWidth: Self.sidebarWidth.min - 40,
|
|
maxWidth: Self.sidebarWidth.max - 40,
|
|
alignment: .leading
|
|
)
|
|
#else
|
|
return label
|
|
#endif
|
|
}
|
|
|
|
private func symbol(for kind: MediaLibrary.Kind) -> String {
|
|
switch kind {
|
|
case .movie: "film"
|
|
case .show: "tv"
|
|
default: "rectangle.stack"
|
|
}
|
|
}
|
|
}
|