diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index c4fd04e..9c4d2ee 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -3968,6 +3968,24 @@ } } }, + "player.pictureInPicture": { + "comment": "Knopf im Player: das Bild in ein kleines Fenster legen", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bild im Bild" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Picture in Picture" + } + } + } + }, "player.play": { "extractionState": "stale", "localizations": { diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 83649c1..a052b63 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -201,6 +201,18 @@ final class PlayerModel { /// `@Observable` einen `lazy`-Delegaten nicht überlebt. @ObservationIgnored private var pictureInPicture: AVPictureInPictureController? @ObservationIgnored private var pictureInPictureDelegate: PictureInPictureBridge? + @ObservationIgnored private var pictureInPictureObservation: NSKeyValueObservation? + + /// Ob Bild-in-Bild **jetzt** ginge. + /// + /// **Beobachtet, nicht abgefragt.** `isPictureInPicturePossible` steht erst + /// fest, wenn der Player ein Bild hat — wer beim Zeichnen fragt, bekommt + /// `false` und zeichnet den Knopf nie wieder. Also meldet es sich selbst. + /// + /// Bleibt bei VLC dauerhaft falsch: `AVPictureInPictureController` braucht + /// eine `AVPlayerLayer`, und VLC zeichnet in eine eigene Ansicht. + private(set) var canUsePictureInPicture = false + private(set) var isPictureInPictureActive = false /// Was beim Beenden von Bild-in-Bild wieder ins Vollbild führen soll. @ObservationIgnored private var restoreFromPictureInPicture: (() -> Void)? @@ -1578,8 +1590,11 @@ final class PlayerModel { streamServer = nil streamCache = nil prefetchStatus = nil + pictureInPictureObservation = nil pictureInPicture = nil pictureInPictureDelegate = nil + canUsePictureInPicture = false + isPictureInPictureActive = false surface.playerLayer.player = nil isPlaying = false isBuffering = false @@ -1766,6 +1781,32 @@ final class PlayerModel { controller?.delegate = bridge pictureInPictureDelegate = bridge pictureInPicture = controller + // KVO meldet auf einem beliebigen Thread — deshalb der Sprung, + // wie bei den Beobachtungen in `AVPlayerEngine`. + pictureInPictureObservation = controller?.observe( + \.isPictureInPicturePossible, + options: [.initial, .new] + ) { [weak self] controller, _ in + let moeglich = controller.isPictureInPicturePossible + Task { @MainActor [weak self] in self?.canUsePictureInPicture = moeglich } + } + } + } + + /// Schaltet Bild-in-Bild ein oder wieder aus. + /// + /// **Von Hand, nicht nur beim Verlassen der App.** Automatisch wechselt der + /// Player schon, wenn man zum Homebildschirm geht — aber nur dann, und auf + /// dem Mac gar nicht. Wer das Bild klein neben etwas anderes legen will, + /// ohne die App zu verlassen, hatte dafür keinen Weg. + func togglePictureInPicture() { + guard let pictureInPicture else { return } + if pictureInPicture.isPictureInPictureActive { + pictureInPicture.stopPictureInPicture() + isPictureInPictureActive = false + } else { + pictureInPicture.startPictureInPicture() + isPictureInPictureActive = true } } diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index 16545ba..55ca4f3 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -516,6 +516,22 @@ struct PlayerView: View { ) } + // **Bild-in-Bild von Hand.** Automatisch geht es bisher nur beim + // Verlassen der App — und auf dem Mac gar nicht. Der Knopf + // erscheint nur, wenn es wirklich ginge: bei VLC gibt es keine + // `AVPlayerLayer`, und ohne die kann das System kein kleines Fenster + // aufziehen. + if model.canUsePictureInPicture { + Button { + model.togglePictureInPicture() + } label: { + Image(systemName: model.isPictureInPictureActive ? "pip.exit" : "pip.enter") + .font(.title3) + .padding(FlixySpacing.snug) + } + .accessibilityLabel(Text("player.pictureInPicture")) + } + if model.engine?.supportsAirPlay == true { AirPlayButton(tint: accent) .frame(width: 28, height: 28)