Mini-Player: die Wiedergabe überlebt das Zurückgehen
Wer im Fernsehen etwas sieht und nebenher nachsehen will, was sonst läuft, musste bisher abbrechen. Plex' eigene Handy-Apps können das auch nicht — kein Grund, es hier genauso zu machen. Der eigentliche Umbau ist nicht die Kachel, sondern der Besitz. Die Wiedergabe hing an der Sichtbarkeit der Ansicht, und zwar dreifach: das Modell war `@State` im Player, `onDisappear` hat den Motor abgeräumt, und die Bildebene entstand in `makeUIView` neu. Zurückgehen hieß deshalb: Motor tot, Transcoder aus, beim Live-TV der Tuner freigegeben. Jetzt gehört das Modell der Wurzelansicht, die immer da ist. Verkleinern und Vergrößern wechseln nur noch die Darstellung; beendet wird die Wiedergabe an genau einer Stelle, und zwar weil jemand es will. Die Bildebene hält das Modell selbst — dasselbe Muster, das VLC seit jeher verwendet. Dieselbe `UIView` wandert zwischen Kachel und Vollbild, statt neu zu entstehen; sonst setzte das Bild bei jedem Wechsel neu an. Bild-in-Bild wandert mit und bekommt endlich einen Delegaten: bisher war es eine Einbahnstraße, aus der man nicht in die App zurückkam. Die Kachel schwebt und lässt sich in jede Ecke ziehen, statt als Leiste unten zu kleben — eine Leiste nähme jeder Liste Platz weg und müsste in jedem Bildschirm bedacht werden. Die Eckenberechnung steht in MiniPlayerPlacement und ist geprüft: in einer View wäre eine Kachel, die halb aus dem Bild ragt, erst auf dem Gerät aufgefallen. Der Pfeil oben verkleinert, ein neuer Knopf daneben beendet wirklich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d28df346f2
commit
26c909a2e2
@@ -1,5 +1,7 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import FlixyDownloads
|
||||
import FlixyPlayback
|
||||
import PlexKit
|
||||
|
||||
/// Die Wurzelansicht.
|
||||
@@ -12,6 +14,17 @@ 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
|
||||
@@ -61,8 +74,20 @@ struct RootView: View {
|
||||
.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.
|
||||
.fullScreenCover(item: Bindable(playback).request) { request in
|
||||
PlayerView(request: request)
|
||||
// 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
|
||||
@@ -72,6 +97,42 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let previous = player
|
||||
|
||||
guard let request else {
|
||||
player = nil
|
||||
if let previous { await previous.stop() }
|
||||
return
|
||||
}
|
||||
|
||||
let fresh = PlayerModel(
|
||||
request: request,
|
||||
server: session.server,
|
||||
downloads: downloads,
|
||||
diagnostics: diagnostics,
|
||||
policy: settings.policy(for: network),
|
||||
settings: settings,
|
||||
isLocalConnection: session.server?.connection.local ?? false
|
||||
)
|
||||
// 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
|
||||
if let previous { Task { await previous.stop() } }
|
||||
await fresh.start()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var library: some View {
|
||||
if sizeClass == .regular {
|
||||
@@ -96,6 +157,7 @@ struct RootView: View {
|
||||
.navigationTitle(Branding.appName)
|
||||
} detail: {
|
||||
NavigationStack { destination }
|
||||
.miniPlayer(player, isVisible: playback.presentation == .mini)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +181,9 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user