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:
Scarriffle
2026-09-12 17:09:22 +02:00
co-authored by Claude Opus 5
parent d28df346f2
commit 26c909a2e2
11 changed files with 3269 additions and 2668 deletions
+190
View File
@@ -0,0 +1,190 @@
import SwiftUI
import FlixyDesign
/// Die schwebende Videokachel.
///
/// Damit läuft weiter, was man gerade sieht, während man nach dem Nächsten
/// stöbert — der Grund, warum es sie gibt. Plex' eigene Handy-Apps reißen die
/// Wiedergabe beim Zurückgehen ab, und genau in dem Moment will man
/// weiterschauen und nebenher nachsehen, was sonst noch läuft.
///
/// Bewusst schwebend statt als Leiste am unteren Rand: eine Leiste nimmt jeder
/// Liste Platz weg und müsste in jedem Bildschirm berücksichtigt werden. Eine
/// Kachel schwebt darüber, und wem sie im Weg ist, der zieht sie weg.
struct MiniPlayer: View {
let model: PlayerModel
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.horizontalSizeClass) private var sizeClass
/// Die Ecke bleibt über Starts hinweg erhalten. Wer die Kachel einmal
/// nach links unten gezogen hat, will sie beim nächsten Mal dort haben.
@AppStorage("miniPlayer.corner") private var storedCorner = MiniPlayerPlacement.Corner.bottomTrailing.rawValue
/// Verschiebung während des Ziehens, noch nicht eingerastet.
@State private var drag: CGSize = .zero
@State private var showsControls = false
@State private var hideTask: Task<Void, Never>?
private var corner: MiniPlayerPlacement.Corner {
MiniPlayerPlacement.Corner(rawValue: storedCorner) ?? .bottomTrailing
}
/// Auf dem iPad darf sie größer sein — dort ist genug Fläche, und bei
/// 180 Punkten sähe man auf einem 13-Zoll-Schirm nichts.
private var tileWidth: CGFloat { sizeClass == .regular ? 280 : 180 }
private var tileSize: CGSize {
CGSize(width: tileWidth, height: (tileWidth / FlixyAspectRatio.landscape).rounded())
}
var body: some View {
GeometryReader { geometry in
let placement = MiniPlayerPlacement(bounds: geometry.size, tile: tileSize)
let resting = placement.center(for: corner)
let position = placement.clamped(
center: CGPoint(x: resting.x + drag.width, y: resting.y + drag.height)
)
tile
.frame(width: tileSize.width, height: tileSize.height)
.position(position)
.gesture(dragGesture(in: placement, from: resting))
// Nur während des Ziehens ohne Animation, sonst würde die
// Kachel dem Finger hinterherfedern statt an ihm zu kleben.
.animation(drag == .zero ? .snappy(duration: 0.3) : nil, value: position)
}
.transition(.scale(scale: 0.8).combined(with: .opacity))
}
private var tile: some View {
ZStack {
Color.black
VideoSurface(engine: model.engine, surface: model.surface)
if model.isBuffering {
ProgressView().tint(.white)
}
if showsControls {
controls.transition(.opacity)
}
// Bei einem laufenden Sender hat ein Fortschritt keine Bedeutung —
// es gibt kein Ende, auf das er zulaufen könnte.
if !model.isLive, let fraction = progress {
VStack {
Spacer()
WatchProgressBar(fraction: fraction)
}
}
}
.clipShape(.rect(cornerRadius: FlixyRadius.card))
.overlay {
RoundedRectangle(cornerRadius: FlixyRadius.card)
.strokeBorder(Color.flixySeparator, lineWidth: 0.5)
}
.shadow(color: .black.opacity(0.5), radius: 12, y: 4)
.contentShape(.rect)
.onTapGesture {
// Antippen zeigt erst die Bedienelemente, dann öffnet es. Ein
// sofortiges Aufklappen bei jeder Berührung wäre lästig, wenn man
// die Kachel nur anfassen will.
if showsControls {
playback.expand()
} else {
reveal()
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel(Text(model.title))
}
private var controls: some View {
ZStack {
Color.black.opacity(0.45)
Button {
model.togglePlayPause()
reveal()
} label: {
Image(systemName: model.isPlaying ? "pause.fill" : "play.fill")
.font(.title2)
.foregroundStyle(.white)
.padding(FlixySpacing.snug)
.contentShape(.rect)
}
.accessibilityLabel(Text(model.isPlaying ? "player.pause" : "player.play"))
VStack {
HStack {
Spacer()
Button {
playback.close()
} label: {
Image(systemName: "xmark")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(FlixySpacing.snug)
.contentShape(.rect)
}
.accessibilityLabel(Text("miniPlayer.close"))
}
Spacer()
}
}
}
private var progress: Double? {
guard let duration = model.duration, duration.seconds > 0 else { return nil }
return model.currentTime.seconds / duration.seconds
}
private func dragGesture(
in placement: MiniPlayerPlacement,
from resting: CGPoint
) -> some Gesture {
DragGesture()
.onChanged { value in
hideTask?.cancel()
drag = value.translation
}
.onEnded { value in
// Nach dem **Mittelpunkt** der Kachel entscheiden, nicht nach
// dem Finger: sonst springt eine Kachel, die man an der Kante
// angefasst hat, in die falsche Hälfte.
let center = CGPoint(
x: resting.x + value.predictedEndTranslation.width,
y: resting.y + value.predictedEndTranslation.height
)
storedCorner = placement.nearestCorner(toCenter: center).rawValue
drag = .zero
}
}
/// Zeigt die Bedienelemente und blendet sie nach kurzer Zeit wieder aus.
private func reveal() {
withAnimation(.easeInOut(duration: 0.2)) { showsControls = true }
hideTask?.cancel()
hideTask = Task {
try? await Task.sleep(for: .seconds(3))
guard !Task.isCancelled else { return }
withAnimation(.easeInOut(duration: 0.2)) { showsControls = false }
}
}
}
extension View {
/// Legt die schwebende Kachel über diesen Bildschirm.
///
/// Bewusst hier und nicht ganz oben in der Wurzel: auf dem iPhone soll die
/// Seitenleiste die Kachel verdecken, wenn sie aufgeht, und nicht
/// umgekehrt. Deshalb liegt sie **innerhalb** des Inhalts.
func miniPlayer(_ model: PlayerModel?, isVisible: Bool) -> some View {
overlay {
if let model, isVisible {
MiniPlayer(model: model)
}
}
}
}
+77
View File
@@ -1,3 +1,4 @@
import AVKit
import Foundation
import Observation
import FlixyDownloads
@@ -57,6 +58,21 @@ final class PlayerModel {
/// Ob gerade auf eine andere Stufe umgestellt wird.
private(set) var isChangingQuality = false
/// Die Bildebene des Systemplayers.
///
/// Gehört dem Modell, nicht der Ansicht: das Modell überlebt den Wechsel
/// zwischen Kachel und Vollbild, die Ansicht nicht. Läge sie dort, entstünde
/// bei jedem Wechsel eine neue `AVPlayerLayer`, und das Bild setzte neu an.
let surface = PlayerLayerView()
/// Bild-in-Bild hängt an genau dieser Ebene und wandert deshalb mit.
///
/// `@ObservationIgnored`, weil keine Ansicht darauf zeichnet — und weil
/// `@Observable` einen `lazy`-Delegaten nicht überlebt.
@ObservationIgnored private var pictureInPicture: AVPictureInPictureController?
@ObservationIgnored private var pictureInPictureDelegate: PictureInPictureBridge?
/// Was beim Beenden von Bild-in-Bild wieder ins Vollbild führen soll.
@ObservationIgnored private var restoreFromPictureInPicture: (() -> Void)?
private let request: PlaybackRequest
private let server: PlexServer?
private let downloads: DownloadCoordinator
@@ -280,6 +296,7 @@ final class PlayerModel {
let engine = makeEngine(for: decision)
self.engine = engine
attachSurface(to: engine)
observe(engine)
do {
@@ -395,6 +412,9 @@ final class PlayerModel {
engine?.teardown()
engine = nil
pictureInPicture = nil
pictureInPictureDelegate = nil
surface.playerLayer.player = nil
}
// MARK: - Fortschritt melden
@@ -473,6 +493,40 @@ final class PlayerModel {
return item.media[request.mediaIndex]
}
/// Hängt die gehaltene Bildebene an den aktuellen Motor.
///
/// Nach **jedem** Motorwechsel nötig — also auch nach einem
/// Qualitätswechsel, der den Motor neu baut. Ohne das zeigt die Ebene
/// weiter auf einen abgeräumten Player und bleibt schwarz.
private func attachSurface(to engine: any VideoPlayerEngine) {
guard let avEngine = engine as? AVPlayerEngine else { return }
surface.playerLayer.player = avEngine.player
surface.playerLayer.videoGravity = .resizeAspect
guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
if pictureInPicture == nil {
let bridge = PictureInPictureBridge { [weak self] in
self?.restoreFromPictureInPicture?()
}
let controller = AVPictureInPictureController(playerLayer: surface.playerLayer)
// Beim Verlassen der App ins Bild-in-Bild wechseln, statt die
// Wiedergabe anzuhalten.
controller?.canStartPictureInPictureAutomaticallyFromInline = true
controller?.delegate = bridge
pictureInPictureDelegate = bridge
pictureInPicture = controller
}
}
/// Was passieren soll, wenn der Nutzer aus Bild-in-Bild zurückkehrt.
///
/// Bisher gab es das nicht: Bild-in-Bild war eine Einbahnstraße, weil
/// niemand den Delegaten gesetzt hat.
func onPictureInPictureRestore(_ action: @escaping () -> Void) {
restoreFromPictureInPicture = action
}
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
switch decision.engine {
case .vlc: VLCPlayerEngine()
@@ -565,3 +619,26 @@ extension MediaTrack {
return normalized == code.lowercased().prefix(2)
}
}
/// Verbindet Bild-in-Bild mit der App.
///
/// Ein eigener Typ, weil `AVPictureInPictureControllerDelegate` von `NSObject`
/// erben muss und `PlayerModel` ein `@Observable` ist. Ohne einen Delegaten
/// war Bild-in-Bild eine Einbahnstraße: man kam hinein, aber nicht zurück in
/// den Player.
private final class PictureInPictureBridge: NSObject, AVPictureInPictureControllerDelegate {
private let restore: () -> Void
init(restore: @escaping () -> Void) {
self.restore = restore
}
func pictureInPictureController(
_ controller: AVPictureInPictureController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completion: @escaping (Bool) -> Void
) {
restore()
completion(true)
}
}
+34 -39
View File
@@ -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 }
}
}
+14 -33
View File
@@ -11,11 +11,20 @@ import FlixyVLC
/// `UIView`.
struct VideoSurface: View {
let engine: (any VideoPlayerEngine)?
/// Die Bildebene des Systemplayers, gehalten vom Modell.
///
/// **Nicht** hier gebaut. Eine `AVPlayerLayer`, die in `makeUIView`
/// entsteht, stirbt mit jedem Identitätswechsel der Ansicht — und genau
/// das passiert beim Wechsel zwischen Kachel und Vollbild. Das Bild
/// setzte dann jedes Mal neu an, sichtbar als Zucken und Schwarzbild.
/// VLC macht es seit jeher richtig und reicht seine `videoView` durch;
/// hier ist es jetzt derselbe Weg.
let surface: PlayerLayerView
var body: some View {
switch engine {
case let avEngine as AVPlayerEngine:
AVPlayerSurface(player: avEngine.player)
case is AVPlayerEngine:
UIViewContainer(view: surface)
case let vlcEngine as VLCPlayerEngine:
UIViewContainer(view: vlcEngine.videoView)
default:
@@ -24,37 +33,6 @@ struct VideoSurface: View {
}
}
/// Die Bildebene des Systemplayers, samt Bild-in-Bild.
private struct AVPlayerSurface: UIViewRepresentable {
let player: AVPlayer
func makeUIView(context: Context) -> PlayerLayerView {
let view = PlayerLayerView()
view.playerLayer.player = player
view.playerLayer.videoGravity = .resizeAspect
context.coordinator.setUp(with: view.playerLayer)
return view
}
func updateUIView(_ uiView: PlayerLayerView, context: Context) {
uiView.playerLayer.player = player
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator {
private var pictureInPicture: AVPictureInPictureController?
func setUp(with layer: AVPlayerLayer) {
guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
pictureInPicture = AVPictureInPictureController(playerLayer: layer)
// Beim Verlassen der App ins Bild-in-Bild wechseln, statt die
// Wiedergabe anzuhalten.
pictureInPicture?.canStartPictureInPictureAutomaticallyFromInline = true
}
}
}
/// Eine `UIView`, deren Ebene eine `AVPlayerLayer` ist.
///
/// Der übliche Weg: eine Ebene von Hand als Unterebene einzuhängen bedeutet,
@@ -66,6 +44,9 @@ final class PlayerLayerView: UIView {
}
/// Hängt eine bestehende `UIView` in den SwiftUI-Baum.
///
/// Umhängen statt neu bauen: dieselbe `UIView` wandert zwischen Kachel und
/// Vollbild hin und her, und die Wiedergabe merkt davon nichts.
private struct UIViewContainer: UIViewRepresentable {
let view: UIView