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
@@ -6,7 +6,13 @@ import PlexKit
|
||||
|
||||
/// Der Vollbildplayer.
|
||||
struct PlayerView: View {
|
||||
let request: PlaybackRequest
|
||||
/// Das Modell gehört der Wurzel, nicht dieser Ansicht.
|
||||
///
|
||||
/// Vorher wurde es hier gebaut und beim Verschwinden abgeräumt — und
|
||||
/// genau daran starb die Wiedergabe, sobald man zurückging. Jetzt
|
||||
/// überdauert es die Ansicht, und das Beenden ist eine Entscheidung des
|
||||
/// Nutzers statt eine Nebenwirkung der Navigation.
|
||||
let model: PlayerModel
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@@ -16,7 +22,6 @@ struct PlayerView: View {
|
||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
@State private var model: PlayerModel?
|
||||
@State private var showsControls = true
|
||||
@State private var showsTrackPicker = false
|
||||
@State private var showsQualityChoice = false
|
||||
@@ -27,8 +32,8 @@ struct PlayerView: View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
if let model {
|
||||
VideoSurface(engine: model.engine)
|
||||
Group {
|
||||
VideoSurface(engine: model.engine, surface: model.surface)
|
||||
.ignoresSafeArea()
|
||||
|
||||
switch model.phase {
|
||||
@@ -73,25 +78,23 @@ struct PlayerView: View {
|
||||
// Immer aus, nicht nur wenn die Bedienelemente ausgeblendet sind.
|
||||
// Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen —
|
||||
// und im Querformat erst recht nicht.
|
||||
.animation(.easeInOut(duration: 0.2), value: model?.isBuffering)
|
||||
.animation(.easeInOut(duration: 0.2), value: model.isBuffering)
|
||||
.statusBarHidden(true)
|
||||
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
||||
.landscapeWhileVisible()
|
||||
.contentShape(.rect)
|
||||
.onTapGesture { toggleControls() }
|
||||
.sheet(isPresented: $showsTrackPicker) {
|
||||
if let model { TrackPickerSheet(model: model) }
|
||||
TrackPickerSheet(model: model)
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text("player.quality"),
|
||||
isPresented: $showsQualityChoice,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
if let model {
|
||||
ForEach(PlayerQuality.allCases) { option in
|
||||
Button(qualityChoiceLabel(option, current: model.quality)) {
|
||||
Task { await model.select(quality: option) }
|
||||
}
|
||||
ForEach(PlayerQuality.allCases) { option in
|
||||
Button(qualityChoiceLabel(option, current: model.quality)) {
|
||||
Task { await model.select(quality: option) }
|
||||
}
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
@@ -104,29 +107,8 @@ struct PlayerView: View {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { showsControls = true }
|
||||
scheduleControlsHide()
|
||||
}
|
||||
.task {
|
||||
let model = PlayerModel(
|
||||
request: request,
|
||||
server: session.server,
|
||||
downloads: downloads,
|
||||
diagnostics: diagnostics,
|
||||
policy: settings.policy(for: network),
|
||||
settings: settings,
|
||||
isLocalConnection: session.server?.connection.local ?? false
|
||||
)
|
||||
self.model = model
|
||||
await model.start()
|
||||
scheduleControlsHide()
|
||||
}
|
||||
.onDisappear {
|
||||
hideTask?.cancel()
|
||||
// Losgelöst von der Ansicht: das Anhalten des Transcoders und die
|
||||
// Schlussmeldung sollen auch dann durchlaufen, wenn die Ansicht
|
||||
// schon verschwunden ist.
|
||||
if let model {
|
||||
Task { await model.stop() }
|
||||
}
|
||||
}
|
||||
.task { scheduleControlsHide() }
|
||||
.onDisappear { hideTask?.cancel() }
|
||||
}
|
||||
|
||||
// MARK: - Bedienelemente
|
||||
@@ -151,14 +133,27 @@ struct PlayerView: View {
|
||||
|
||||
private func topBar(for model: PlayerModel) -> some View {
|
||||
HStack(alignment: .top, spacing: FlixySpacing.regular) {
|
||||
// Der Pfeil verkleinert, er beendet nicht. Das ist der ganze
|
||||
// Zweck der Übung: weiterschauen, während man stöbert.
|
||||
Button {
|
||||
playback.dismiss()
|
||||
playback.minimize()
|
||||
} label: {
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.title3)
|
||||
.padding(FlixySpacing.snug)
|
||||
}
|
||||
.accessibilityLabel(Text("player.close"))
|
||||
.accessibilityLabel(Text("player.minimize"))
|
||||
|
||||
// Und daneben der Weg, wirklich Schluss zu machen — sonst gäbe es
|
||||
// keinen mehr, seit der Pfeil nur noch verkleinert.
|
||||
Button {
|
||||
playback.close()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.callout.weight(.semibold))
|
||||
.padding(FlixySpacing.snug)
|
||||
}
|
||||
.accessibilityLabel(Text("player.stop"))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(model.title)
|
||||
@@ -294,7 +289,7 @@ struct PlayerView: View {
|
||||
if model.hasReachedEnd, model.nextEpisodeRequest() != nil {
|
||||
Button {
|
||||
if let next = model.nextEpisodeRequest() {
|
||||
playback.request = next
|
||||
playback.play(next)
|
||||
}
|
||||
} label: {
|
||||
Label("player.nextEpisode", systemImage: "forward.end.fill")
|
||||
@@ -362,7 +357,7 @@ struct PlayerView: View {
|
||||
.font(.flixyBody)
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("action.close") { playback.dismiss() }
|
||||
Button("action.close") { playback.close() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
}
|
||||
@@ -397,7 +392,7 @@ struct PlayerView: View {
|
||||
hideTask?.cancel()
|
||||
hideTask = Task {
|
||||
try? await Task.sleep(for: .seconds(3.5))
|
||||
guard !Task.isCancelled, model?.isPlaying == true else { return }
|
||||
guard !Task.isCancelled, model.isPlaying else { return }
|
||||
withAnimation(.easeInOut(duration: 0.3)) { showsControls = false }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user