**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>
641 lines
30 KiB
Swift
641 lines
30 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(LanguagePreferences.self) private var languages
|
|
@Environment(QuickPlay.self) private var quickPlay
|
|
@Environment(SubtitleAppearance.self) private var subtitleAppearance
|
|
@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
|
|
|
|
/// Ob die Einstellungen gerade offen sind.
|
|
///
|
|
/// Nur auf dem iPhone: dort gibt es den Tab nicht mehr, und der Weg führt
|
|
/// über das Monogramm oben rechts.
|
|
@State private var showsSettings = false
|
|
|
|
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)
|
|
// **Aus dem Fenster, nicht aus dem Layout.**
|
|
//
|
|
// Hier stand `proxy.safeAreaInsets.top` — ein Wert, der beim Drehen
|
|
// erst eine Runde später stimmt. Nachgemessen sah man das genau
|
|
// einmal: quer war die Seitenleiste zuerst 620,67 Punkte hoch und
|
|
// dann 558,67. Die Differenz sind zweiundsechzig, und die kamen von
|
|
// `.padding(.top, statusBarHeight)` an der Leiste weiter unten, das
|
|
// noch den Wert aus dem Hochformat trug.
|
|
//
|
|
// Das Fenster kennt seinen Rand ohne Umweg über das Layout, das er
|
|
// selbst beeinflusst. Die Grösse bleibt der Anlass zum Nachfragen,
|
|
// der **Wert** kommt von aussen — und stimmt damit im ersten
|
|
// Durchgang.
|
|
//
|
|
// **Nicht** die Ursache des Players, der oben und unten hinausging:
|
|
// das war die Seitenleiste selbst, siehe `FlixySidebar`. Diese Zeile
|
|
// spart eine Layoutrunde, mehr nicht.
|
|
.onGeometryChange(for: CGSize.self) { proxy in
|
|
proxy.size
|
|
} action: { _ in statusBarHeight = ScreenSafeArea.current.top }
|
|
.onAppear { statusBarHeight = ScreenSafeArea.current.top }
|
|
// Hier statt in der Scene: eine View wertet Beobachtungsänderungen
|
|
// zuverlässig neu aus, ein Scene-Body nicht.
|
|
.flixyAccent(theme)
|
|
.tint(theme.accentColor)
|
|
// **Einer für alle Kacheln.** Einer je Kachel wäre bei einem
|
|
// Serverausfall ein Stapel aus zwanzig gleichen Meldungen.
|
|
.alert(
|
|
Text("quickPlay.failed"),
|
|
isPresented: Binding(
|
|
get: { quickPlay.failure != nil },
|
|
set: { if !$0 { quickPlay.failure = nil } }
|
|
)
|
|
) {
|
|
Button("action.close", role: .cancel) { quickPlay.failure = nil }
|
|
} message: {
|
|
if let grund = quickPlay.failure { Text(grund) }
|
|
}
|
|
// 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 {
|
|
// **Bildfüllend, und der Player passt drinnen selbst auf.**
|
|
//
|
|
// Ohne diese Zeile verliert die Bildfläche ihr
|
|
// `ignoresSafeArea` mit — das Bild wäre im Querformat um die
|
|
// Notch-Breite schmaler. Wo der Schirm aufhört, holt sich der
|
|
// Player deshalb aus dem Fenster; siehe `ScreenSafeArea`.
|
|
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 {
|
|
// `.settings` gehört dazu, seit es den Tab auf dem iPhone nicht
|
|
// mehr gibt: wer am iPad in den Einstellungen steht und das
|
|
// Fenster schmal zieht, stünde sonst vor einem Bereich, den die
|
|
// Leiste gar nicht führt.
|
|
case .library, .libraryHub, .liveTV, .settings: 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,
|
|
languages: languages,
|
|
subtitles: subtitleAppearance,
|
|
// **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) {
|
|
// **Innerhalb des Stapels, nicht darum herum.** Als Überzug
|
|
// über `mediaStack` lag er auch über jedem **geschobenen**
|
|
// Bildschirm — auf einer Detailseite also über deren
|
|
// Drei-Punkte-Menü, zwei Knöpfe übereinander. Hier gilt er
|
|
// für die Startseite und endet mit ihr.
|
|
//
|
|
// Dort ist ein Überzug der einzige Platz: die Startseite
|
|
// schaltet ihre Navigationsleiste ab, ein `ToolbarItem`
|
|
// erschiene gar nicht.
|
|
mediaStack {
|
|
HomeView()
|
|
.overlay(alignment: .topTrailing) { profileButton }
|
|
}
|
|
}
|
|
.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: library.kind.symbol)
|
|
}
|
|
.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 { LibraryBrowser() }
|
|
} 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")
|
|
|
|
// **Nicht auf dem iPhone.** Dort führt der Weg über das
|
|
// Monogramm oben rechts — siehe `profileButton`. Einstellungen
|
|
// sind nichts, was man im Wechsel mit der Mediathek benutzt; ein
|
|
// Platz in der Leiste unten unterstellt genau das. In der
|
|
// Seitenleiste bleibt der Eintrag, dort ist Platz.
|
|
if sizeClass == .regular {
|
|
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
|
|
}
|
|
// **Als Blatt, nicht als Bereich.** `SettingsView` ist ein Formular
|
|
// aus geschobenen Unterbildschirmen und braucht nur einen
|
|
// 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()
|
|
// Ein Blatt lässt sich nach unten wegziehen — aber nicht
|
|
// jeder weiss das, und wer in einem Unterbildschirm steht,
|
|
// zieht damit gleich alles weg. Ein Knopf sagt es.
|
|
.toolbar {
|
|
ToolbarItem(placement: .flixyTrailing) {
|
|
Button("action.close") { showsSettings = false }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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 }
|
|
}
|
|
|
|
/// Der Weg in die Einstellungen auf dem iPhone.
|
|
///
|
|
/// **Ein Überzug, kein Werkzeugleisten-Knopf.** Die Startseite schaltet
|
|
/// ihre Navigationsleiste ganz ab — sie war leer und belegte trotzdem
|
|
/// Höhe —, ein `ToolbarItem` erschiene dort also gar nicht. Hier liegt er
|
|
/// einmal über allen Bildschirmen statt dreimal in deren Leisten, und er
|
|
/// sitzt überall an derselben Stelle.
|
|
///
|
|
/// Er blendet sich beim Scrollen aus wie die Insel auf dem iPad: über
|
|
/// einem Film, den man gerade sucht, hat ein Kontoknopf nichts verloren.
|
|
@ViewBuilder
|
|
private var profileButton: some View {
|
|
Button {
|
|
showsSettings = true
|
|
} label: {
|
|
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)
|
|
// **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)
|
|
.accessibilityLabel(Text("sidebar.settings"))
|
|
}
|
|
|
|
/// 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: library.kind.symbol,
|
|
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: @escaping () -> Content) -> some View {
|
|
MediaStack(content: content)
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
}
|