Im Spurenblatt stand eine einzige Zeile: „Unbekannt". Das ist der Name, den AVFoundation einer namenlosen Spur gibt — und bei einem umgerechneten Stream liefert der Server genau eine. Der Player hatte nichts zur Auswahl, weil die Wahl längst gefallen war, bevor er gefragt wurde. Gewählt wird jetzt aus den Spuren, die **in der Datei** stehen, und zwar bevor der Stream ausgehandelt wird. Die Nummer geht als `audioStreamIndex` mit hinaus; wer mitten im Film umstellt, bekommt den Stream neu — ausser bei unveränderter Wiedergabe, wo der Player ohne Unterbrechung umschaltet. Die Abwägung selbst macht `AudioTrackChooser`: gemerkte Spur, dann Wunschsprache, dann Rangliste, dann Standardspur — und Audiodeskription mit deutlichem Abzug statt Ausschluss, damit eine Sprache, die es nur so gibt, immer noch besser ist als Stille. Drei Ebenen in `LanguagePreferences`: Serie, Mediathek, allgemein. Gefragt wird von unten nach oben. Die Mediathek wird nur nachgeschlagen, wenn überhaupt jemand eine Sprache je Mediathek gesetzt hat — sonst kostete jede Wiedergabe eine Anfrage für eine Ebene, die niemand benutzt. Wer von Hand umstellt, dessen Wahl wird für die Serie gemerkt, ohne Nachfrage. Der Dialog „für diese Serie merken?" ist genau die Art, die man nach dem dritten Mal wegklickt. Sichtbar ist es trotzdem: das Spurenblatt zeigt es und lässt es löschen. Dazu die Rangliste in den Einstellungen, ziehbar, und der Schalter „Audiodeskription meiden". Und die Player-Oberfläche blendet endlich als Ganzes über. Die Kopfzeile hatte gar keine Überblendung — sie sprang herein, während die Bedienung darunter verblasste. Jetzt steht die Animation an einer Stelle statt an sechs, und sie läuft über Deckkraft statt über Einfügen und Entfernen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
541 lines
25 KiB
Swift
541 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(LanguagePreferences.self) private var languages
|
|
@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,
|
|
languages: languages,
|
|
// **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"
|
|
}
|
|
}
|
|
}
|