Files
Flaro/Flixy/Views/Navigation/RootView.swift
T
ScarriffleandClaude Opus 5 3f6c6d8816 Konto unten in der Seitenleiste — und eine Einstellung, die es nicht gab
Zwei Dinge aus deinen Einstellungs-Bildern.

**Das Konto sitzt jetzt unten in der Seitenleiste**, nicht als gleichrangiger
Eintrag zwischen den Mediatheken. Dort sucht man es, und von dort führt der
Weg in die Einstellungen und zum Abmelden. Auch das ist eine Systemstelle —
`tabViewSidebarBottomBar` —, also dieselbe Zeile, an der sie bei JellyTV
steht. Auf dem iPhone gibt es sie nicht; dort bleibt es beim eigenen Eintrag.

Der Buchstabe auf der Scheibe ist kein Platzhalter für ein Bild, das noch
kommt: Jellyfin gibt kein Profilbild heraus, solange keines hinterlegt ist,
und Plex kennt den Kontonamen am Server gar nicht. Bei Plex steht deshalb der
Servername oben und darunter nichts — statt zweimal dasselbe.

**Ton- und Untertitelsprache lassen sich jetzt einstellen.** Beim Durchsehen
deiner Bilder ist mir aufgefallen, dass es diese Vorliebe längst gibt: der
Player liest sie bei jeder Wiedergabe aus und wählt die passende Spur. Nur
äußern konnte man sie nirgends — sie stand in den Voreinstellungen und blieb
dort für immer leer. Eine Vorliebe, die niemand äußern kann, ist keine.

Fünfzehn Sprachen, keine vollständige Liste: ein Rad mit zweihundert
Einträgen, um „Deutsch" zu finden, wäre keine Hilfe. Was fehlt, kommt später
mit einem Suchfeld dazu.

Gebaut und übersetzt, aber **nicht angesehen**: der Simulator-Tunnel ist
mitten im Prüfen abgestürzt und ließ sich nicht wieder aufbauen. Dass es die
Systemstelle gibt, beweist der Compiler; wie sie aussieht, habe ich diesmal
nicht gesehen.

258 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:45:16 +02:00

238 lines
9.7 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()
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()
}
/// 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 Blatt, eine geteilte Ansicht und eine Reihe oben. Sie
/// konnten zusammen weniger als das eine Bauteil und mussten dreimal
/// gepflegt werden.
private var library: some View {
TabView(selection: $selection) {
Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) {
NavigationStack { HomeView() }
}
.customizationID("flixy.tab.home")
TabSection {
ForEach(session.libraries) { library in
Tab(
library.title,
systemImage: symbol(for: library.kind),
value: SidebarDestination.library(key: library.id, title: library.title)
) {
NavigationStack { LibraryView(library: library) }
}
.customizationID("flixy.tab.library.\(library.id)")
}
} header: {
Text("sidebar.libraries")
}
.customizationID("flixy.tab.libraries")
if session.supportsLiveTV {
Tab(
"sidebar.liveTV",
systemImage: "dot.radiowaves.left.and.right",
value: SidebarDestination.liveTV
) {
NavigationStack { LiveTVView() }
}
.customizationID("flixy.tab.liveTV")
}
Tab(
"sidebar.downloads",
systemImage: "arrow.down.circle",
value: SidebarDestination.downloads
) {
NavigationStack { DownloadsView() }
}
.customizationID("flixy.tab.downloads")
Tab("sidebar.settings", systemImage: "gearshape", value: SidebarDestination.settings) {
NavigationStack { SettingsView() }
}
.customizationID("flixy.tab.settings")
// Die Suche bekommt ihre eigene Rolle: das System stellt sie
// getrennt von den übrigen Einträgen dar, so wie man es aus jeder
// anderen App kennt.
Tab(value: SidebarDestination.search, role: .search) {
NavigationStack { SearchView() }
}
.customizationID("flixy.tab.search")
}
.tabViewStyle(.sidebarAdaptable)
.tabViewCustomization($customization)
// Das Konto sitzt unten in der Seitenleiste, nicht als Eintrag
// zwischen den Mediatheken. Dort sucht man es.
.tabViewSidebarBottomBar {
AccountBar(selection: $selection)
.padding(.horizontal, FlixySpacing.snug)
}
.miniPlayer(player, isVisible: playback.presentation == .mini)
}
private func symbol(for kind: MediaLibrary.Kind) -> String {
switch kind {
case .movie: "film"
case .show: "tv"
default: "rectangle.stack"
}
}
}