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
+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)
}
}