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,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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user