Drei Dinge aus dem letzten Durchgang. **Die Startseite hatte zu wenig anzubieten.** Es gab eine Reihe „Zuletzt hinzugefügt" für alles zusammen. Jetzt gibt es eine je Mediathek — „Neu in Filme" und „Neu in Serien" sind zwei verschiedene Fragen, und wer nach einem Film sucht, blättert in einer gemeinsamen Reihe an Folgen vorbei. Dazu Favoriten, die es bei Jellyfin ohnehin gibt. Die drei Abfragen laufen nebeneinander; nur die Mediatheken werden streng geholt, weil sie die Probe darauf sind, ob der Server überhaupt antwortet. **Die Seitenleiste ist jetzt echtes Glas**, nicht das alte Material: im ausfahrbaren Blatt über `glassEffect`, in der geteilten Ansicht auf dem iPad vom System selbst — dort lag mein eigener Hintergrund darüber und machte ihn stumpf. Die zum Inhalt zeigende Kante ist gerundet, damit das Blatt sichtbar über der Seite liegt statt daneben zu stehen. **Die Navigation kann nach oben.** Eine Einstellung unter Erscheinungsbild, seitlich oder oben. Oben stehen alle Einträge gleichzeitig da, als Glaskapseln in einer waagerecht scrollenden Reihe — die Zahl der Mediatheken kommt vom Server, und zusammengequetschte Beschriftungen wären die schlechtere Antwort als eine Reihe, die weiterläuft. Angesehen auf iPhone und iPad: beide Formen, das Glas in beiden Leisten, die neuen Reihen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
255 lines
9.8 KiB
Swift
255 lines
9.8 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import FlixyDownloads
|
|
import FlixyPlayback
|
|
import PlexKit
|
|
|
|
/// Die Wurzelansicht.
|
|
///
|
|
/// Entscheidet zuerst anhand des Sitzungszustands, ob überhaupt etwas zu
|
|
/// zeigen ist — und erst danach, in welcher Form die Seitenleiste erscheint:
|
|
/// auf dem iPhone als ausfahrbares Blatt, auf dem iPad dauerhaft neben dem
|
|
/// Inhalt. Beide Wege nutzen dieselbe `SidebarView`.
|
|
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
|
|
@Environment(NavigationSettings.self) private var navigation
|
|
|
|
/// 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
|
|
@State private var isSidebarOpen = 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)
|
|
// 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 }
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
// Live-TV spricht als Einziges noch unmittelbar mit Plex.
|
|
liveTV: session.server,
|
|
downloads: downloads,
|
|
diagnostics: diagnostics,
|
|
policy: settings.policy(for: network),
|
|
settings: settings,
|
|
isLocalConnection: session.server?.connection.local ?? false,
|
|
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()
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var library: some View {
|
|
switch navigation.placement {
|
|
case .top:
|
|
topLayout
|
|
case .side:
|
|
if sizeClass == .regular {
|
|
splitLayout
|
|
} else {
|
|
drawerLayout
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Die Navigation als Reihe über dem Inhalt.
|
|
///
|
|
/// Ohne Seitenleiste und ohne Blatt: die Einträge stehen alle gleichzeitig
|
|
/// da. Auf einem Fernseher und auf einem breiten Fenster ist das die
|
|
/// natürlichere Form — und auf dem Handy für den, der lieber tippt als
|
|
/// wischt.
|
|
private var topLayout: some View {
|
|
NavigationStack {
|
|
destination
|
|
.safeAreaInset(edge: .top, spacing: 0) {
|
|
TopNavigationBar(items: allDestinations, selection: $selection)
|
|
}
|
|
}
|
|
.miniPlayer(player, isVisible: playback.presentation == .mini)
|
|
}
|
|
|
|
/// Alle Einträge in einer Reihe — für die Navigation oben, die keine
|
|
/// Abschnitte kennt.
|
|
private var allDestinations: [SidebarDestination] {
|
|
var items: [SidebarDestination] = [.home]
|
|
items.append(contentsOf: sidebarDestinations)
|
|
if session.supportsLiveTV { items.append(.liveTV) }
|
|
items.append(contentsOf: [.downloads, .settings])
|
|
return items
|
|
}
|
|
|
|
private var sidebarDestinations: [SidebarDestination] {
|
|
session.libraries.map { .library(key: $0.id, title: $0.title) }
|
|
}
|
|
|
|
/// iPad: die Seitenleiste ist Teil des Fensters.
|
|
private var splitLayout: some View {
|
|
NavigationSplitView {
|
|
SidebarView(
|
|
presentation: .column,
|
|
libraries: sidebarDestinations,
|
|
showsLiveTV: session.supportsLiveTV,
|
|
selection: $selection,
|
|
showsHeader: false
|
|
) {}
|
|
.navigationTitle(Branding.appName)
|
|
} detail: {
|
|
NavigationStack { destination }
|
|
.miniPlayer(player, isVisible: playback.presentation == .mini)
|
|
}
|
|
}
|
|
|
|
/// iPhone: die Seitenleiste fährt über den Inhalt.
|
|
private var drawerLayout: some View {
|
|
DrawerContainer(isOpen: $isSidebarOpen) {
|
|
SidebarView(
|
|
libraries: sidebarDestinations,
|
|
showsLiveTV: session.supportsLiveTV,
|
|
selection: $selection
|
|
) {
|
|
isSidebarOpen = false
|
|
}
|
|
} content: {
|
|
NavigationStack {
|
|
destination
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
Button {
|
|
withAnimation(.snappy(duration: 0.28)) { isSidebarOpen = true }
|
|
} label: {
|
|
Image(systemName: "line.3.horizontal")
|
|
}
|
|
.accessibilityLabel(Text("sidebar.open"))
|
|
}
|
|
}
|
|
}
|
|
// Innerhalb des Inhalts, nicht darüber: fährt die Seitenleiste
|
|
// aus, soll sie die Kachel verdecken und nicht umgekehrt.
|
|
.miniPlayer(player, isVisible: playback.presentation == .mini)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var destination: some View {
|
|
switch selection {
|
|
case .home:
|
|
HomeView()
|
|
case .library(let key, let title):
|
|
if let library = session.libraries.first(where: { $0.id == key }) {
|
|
LibraryView(library: library)
|
|
} else {
|
|
PlaceholderScreen(destination: .library(key: key, title: title))
|
|
}
|
|
case .liveTV:
|
|
LiveTVView()
|
|
case .downloads:
|
|
DownloadsView()
|
|
case .settings:
|
|
SettingsView()
|
|
}
|
|
}
|
|
}
|