import AVKit import Foundation import Observation import FlixyDownloads import FlixyPlayback import FlixyVLC import PlexKit /// Führt einen Abspielwunsch aus. /// /// Holt die Metadaten, lässt den `PlaybackDecider` den Weg bestimmen, baut den /// passendie Engine und hält dessen Zustand für die Ansicht bereit. Die Ansicht /// weiß nicht, welche Engine läuft — bis auf die eine Stelle, an der die /// Bildfläche gebaut wird. @MainActor @Observable final class PlayerModel { enum Phase: Equatable { case preparing case playing case failed(message: String) } private(set) var phase: Phase = .preparing private(set) var engine: (any VideoPlayerEngine)? private(set) var decision: PlaybackDecision? private(set) var title: String private(set) var subtitle: String? private(set) var currentTime: Duration = .zero private(set) var duration: Duration? private(set) var isPlaying = false /// Läuft eigentlich, wartet aber auf Daten. /// /// Ohne diese Unterscheidung stand man vor einem schwarzen Bild ohne /// jeden Hinweis: der Player galt als „spielt", nur kam noch nichts an. private(set) var isBuffering = false private(set) var audioTracks: [MediaTrack] = [] private(set) var subtitleTracks: [MediaTrack] = [] private(set) var selectedAudioTrack: MediaTrack? private(set) var selectedSubtitleTrack: MediaTrack? /// Die Marke, in der wir uns gerade befinden — Grundlage für den /// Überspringen-Knopf. private(set) var activeMarker: PlexMarker? /// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt, /// damit sie am Ende ohne Wartezeit bereitsteht. private(set) var nextEpisode: PlexItem? private(set) var hasReachedEnd = false /// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen /// Fortschritt zu melden und keine nächste Folge nachzuschlagen. private(set) var isOffline = false /// Ob ein laufender Sender abgespielt wird. private(set) var isLive = false /// Die im Player gewählte Stufe. `automatic` folgt den Einstellungen. private(set) var quality: PlayerQuality = .automatic /// Ob gerade auf eine andere Stufe umgestellt wird. private(set) var isChangingQuality = false /// Was gerade über die Leitung kommt. Beim Öffnen der Wahl abgefragt. var statistics: PlaybackStatistics? { engine?.statistics } /// Die Höhe der Quelldatei — danach richtet sich, welche Stufen überhaupt /// Sinn ergeben. Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein /// Bild dazu. var sourceHeight: Int? { currentMedia?.height } /// Ob das Bild gezeigt werden soll. var showsVideo: Bool { quality.showsVideo } /// 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 private let diagnostics: PlaybackDiagnostics private let policy: PlaybackPolicy private let settings: PlaybackSettings private let isLocalConnection: Bool private var item: PlexItem? /// Die gerade gespielte Fassung und Datei. /// /// Festgehalten, damit ein Qualitätswechsel die Quelle neu bauen kann, /// ohne die Metadaten erneut zu holen. private var currentMedia: PlexMedia? private var currentPart: PlexPart? private var eventTask: Task? private var reportTask: Task? private var transcodeSession: String? /// Wann die angezeigte Sendung endet — danach stimmt der Untertitel nicht /// mehr. private var programmeEndsAt: Date? /// Wacht darüber, dass aus „lädt" nicht „lädt für immer" wird. @ObservationIgnored private var bufferWatchdog: Task? /// Marken, die bereits automatisch übersprungen wurden. Ohne dieses /// Gedächtnis würde ein Rücksprung ins Intro es sofort wieder wegspringen /// lassen — und man käme nie zurück. private var skippedMarkers: Set = [] init( request: PlaybackRequest, server: PlexServer?, downloads: DownloadCoordinator, diagnostics: PlaybackDiagnostics, policy: PlaybackPolicy, settings: PlaybackSettings, isLocalConnection: Bool ) { self.request = request self.server = server self.downloads = downloads self.diagnostics = diagnostics self.policy = policy self.settings = settings self.isLocalConnection = isLocalConnection self.title = request.title } // MARK: - Start func start() async { diagnostics.begin(title: request.title, connection: server?.connection) // Die lokale Datei hat immer Vorrang. Sie ist schneller, kostet kein // Datenvolumen und funktioniert auch dann, wenn der Server gerade // nicht erreichbar ist — der eigentliche Sinn des Herunterladens. if let localURL = downloads.localURL(forRatingKey: request.ratingKey) { await startLocal(url: localURL) return } if let stream = request.streamURL { await startLiveStream(url: stream) return } guard let server else { phase = .failed(message: String(localized: "error.serverUnreachable")) return } diagnostics.note("Metadaten anfragen") let item: PlexItem do { item = try await server.metadata(ratingKey: request.ratingKey) diagnostics.note("Metadaten da") } catch { diagnostics.finish(outcome: "Metadaten fehlgeschlagen: \(PlexErrorText.describe(error))") phase = .failed(message: PlexErrorText.describe(error)) return } self.item = item title = item.rowTitle subtitle = playerSubtitle(for: item) guard let media = version(of: item), let part = media.parts.first else { phase = .failed(message: String(localized: "player.noPlayableFile")) return } currentMedia = media currentPart = part let decision = PlaybackDecider().decide(media: media, part: part, policy: policy) self.decision = decision guard let source = await source(for: decision, item: item, media: media, part: part, server: server) else { phase = .failed(message: String(localized: "player.noPlayableFile")) return } await load(source, decision: decision) preloadNextEpisode() } /// Stellt auf eine andere Qualitätsstufe um. /// /// Bedeutet zwangsläufig einen Neuaufbau: die Stufe steckt in der Adresse /// des Streams, nicht in einer Einstellung, die sich unterwegs drehen ließe. /// Die Stelle wird gemerkt und danach wieder angefahren — sonst begänne der /// Film bei jedem Wechsel von vorn. func select(quality newQuality: PlayerQuality) async { guard newQuality != quality, !isOffline, let server, let item, let media = currentMedia, let part = currentPart else { return } isChangingQuality = true defer { isChangingQuality = false } let resumeAt = currentTime quality = newQuality // Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet // der Server zwei Streams gleichzeitig für einen Zuschauer. if let transcodeSession { await server.stopTranscode(sessionIdentifier: transcodeSession) self.transcodeSession = nil } eventTask?.cancel() engine?.teardown() engine = nil phase = .preparing let newPolicy = newQuality.policy(basedOn: policy) let newDecision = PlaybackDecider().decide(media: media, part: part, policy: newPolicy) decision = newDecision guard let source = await source( for: newDecision, item: item, media: media, part: part, server: server, startingAt: resumeAt ) else { // Nur dann eine allgemeine Meldung, wenn `source` keine eigene // hinterlassen hat. Vorher stand hier immer „keine abspielbare // Datei", auch wenn der Server im Klartext gesagt hatte, warum er // ablehnt — und damit war der eine brauchbare Hinweis weg. if case .failed = phase {} else { phase = .failed(message: String(localized: "player.noPlayableFile")) } return } await load(source, decision: newDecision) } /// Wiedergabe aus dem Download-Ordner. /// /// Ohne Server und ohne Transcoding: die Datei liegt so vor, wie sie auf /// dem Server lag. Welcher Engine sie öffnen kann, steht im Abzug, der beim /// Herunterladen mitgesichert wurde — offline lässt sich nichts nachfragen. /// Live-TV: die Adresse steht schon fest. /// /// Ohne Metadatenabruf, weil es nichts abzurufen gibt — ein laufender /// Sender steht in keiner Mediathek. Und ohne Fortschrittsmeldung: es /// gibt keine Stelle, an der man ihn später fortsetzen könnte. private func startLiveStream(url: URL) async { diagnostics.note("Live-Stream öffnen") isLive = true title = request.title // Was gerade läuft, kommt mit dem Wunsch — nachzuschlagen ist es // nicht, ein Sender steht in keiner Mediathek. subtitle = request.subtitle programmeEndsAt = request.subtitleEndsAt // Die Sitzungskennung aus der Adresse übernehmen. Ohne sie bleibt der // Tuner nach dem Schließen belegt — und beim nächsten Sender hat der // Server keinen freien mehr. transcodeSession = URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems?.first { $0.name == "session" }?.value // Live kommt immer umgerechnet: der Tuner liefert einen // Transportstrom, den kein iPhone direkt abspielt. let decision = PlaybackDecision(method: .transcode(maximumBitrate: nil), rationale: []) self.decision = decision await load(PlaybackSource(url: url, origin: .remote), decision: decision) } private func startLocal(url: URL) async { let offline = downloads.offlineMedia(forRatingKey: request.ratingKey) let decision = PlaybackDecider().decide( media: PlexMedia( id: 0, container: offline?.container, videoCodec: offline?.videoCodec, audioCodec: offline?.audioCodec, videoResolution: offline?.videoResolution, bitrate: offline?.bitrate ), part: PlexPart( id: 0, key: url.lastPathComponent, container: offline?.container, streams: offline.map(localStreams) ?? [] ), // Lokal gibt es nichts umzurechnen und keine Bandbreitengrenze. policy: .directPlayOnly ) self.decision = decision isOffline = true await load( PlaybackSource(url: url, origin: .local, startTime: request.startTime), decision: decision ) } /// Baut künstliche Spuren aus dem Offline-Abzug. /// /// Der `PlaybackDecider` erwartet Spuren, um überhaupt zu entscheiden — /// eine leere Liste hieße „noch nicht analysiert" und schickte jede /// heruntergeladene Datei vorsichtshalber an VLC. private func localStreams(from offline: OfflineMedia) -> [PlexStream] { var streams = [PlexStream(id: 1, streamType: 1, codec: offline.videoCodec)] if let audioCodec = offline.audioCodec { streams.append(PlexStream(id: 2, streamType: 2, codec: audioCodec, selected: true)) } return streams } private func load(_ source: PlaybackSource, decision: PlaybackDecision) async { diagnostics.record(decision: decision, source: source.url) let engine = makeEngine(for: decision) self.engine = engine attachSurface(to: engine) observe(engine) do { diagnostics.note("Engine öffnet Quelle") try await engine.load(source) engine.play() phase = .playing diagnostics.note("Engine gestartet") // Weder offline noch live gibt es einen Fortschritt zu melden. if !isOffline, !isLive { startReporting() } } catch { diagnostics.finish(outcome: "Öffnen fehlgeschlagen: \(error)") phase = .failed(message: String(localized: "player.loadFailed")) } } /// Baut die Quelle — entweder die Datei selbst oder den HLS-Stream. private func source( for decision: PlaybackDecision, item: PlexItem, media: PlexMedia, part: PlexPart, server: PlexServer, startingAt: Duration? = nil ) async -> PlaybackSource? { let start = startingAt ?? request.startTime switch decision.method { case .directPlay: guard let url = server.directPlayURL(part: part) else { return nil } return PlaybackSource(url: url, origin: .remote, startTime: start) case .transcode(let maximumBitrate): let transcode = TranscodeRequest( ratingKey: item.ratingKey, mediaIndex: request.mediaIndex, maximumBitrate: maximumBitrate, resolution: quality.resolution, isLocal: isLocalConnection ) transcodeSession = transcode.sessionIdentifier // Vorher fragen, ob der Server überhaupt mitspielt. Sagt er hier // schon nein, steht der Grund im Klartext da — statt eines // schwarzen Bildes nach zwanzig Sekunden Warten. if let decision = try? await server.transcodeDecision(for: transcode), !decision.isAllowed { phase = .failed(message: decision.explanation ?? String(localized: "player.transcodeRefused")) return nil } guard let url = server.transcodeURL(for: transcode) else { return nil } return PlaybackSource(url: url, origin: .remote, startTime: start) } } // MARK: - Steuerung /// Beendet ewiges Laden mit einer Meldung statt mit einem Kreisel. /// /// Ein Live-Stream kann sterben, ohne dass die Engine es merkt — der Tuner /// gibt auf, der Transcoder bricht ab, und der Player wartet auf Daten, /// die nie kommen. Ohne diese Grenze dreht sich der Kreisel bis zum /// nächsten Antippen, und niemand weiß, ob noch etwas kommt. private func watchBuffering(_ isBuffering: Bool) { bufferWatchdog?.cancel() guard isBuffering else { return } bufferWatchdog = Task { [weak self] in try? await Task.sleep(for: .seconds(40)) guard !Task.isCancelled, let self, self.isBuffering else { return } diagnostics.note("Seit 40 s keine Daten — abgebrochen") // Den Engine gleich stilllegen: er würde sonst weiter an einem // toten Stream ziehen, und bei Live-TV bliebe der Tuner belegt. silence() phase = .failed(message: String(localized: "player.stalled")) Task { await self.releaseOnServer() } } } /// Holt die laufende Sendung nach, falls die angezeigte vorbei ist. /// /// Beim Einschalten reist der Name mit; ein Sender läuft aber weiter, /// während die Sendung wechselt. Nur dann abgefragt, wenn die alte /// wirklich abgelaufen ist — das Raster über alle Sender zu holen ist /// nichts, was man bei jedem Antippen tun will. func refreshLiveProgrammeIfNeeded() async { guard isLive, let server else { return } if let programmeEndsAt, programmeEndsAt > .now { return } guard let provider = await server.electronicProgramGuideIdentifier() else { return } let now = Date() let entries = (try? await server.guide( providerIdentifier: provider, from: now, to: now.addingTimeInterval(2 * 3_600) )) ?? [] guard let match = entries.first(where: { $0.channel?.id == request.ratingKey && $0.program.isRunning() }) else { // Lieber gar nichts als etwas Falsches. subtitle = nil return } subtitle = match.program.title programmeEndsAt = match.program.endsAt } func togglePlayPause() { guard let engine else { return } if isPlaying { engine.pause() } else { engine.play() } } func seek(to time: Duration) { guard let engine else { return } Task { await engine.seek(to: time) } } /// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe. func skip(by offset: Duration) { guard let duration else { return } seek(to: max(.zero, min(currentTime + offset, duration))) } /// Springt ans Ende der laufenden Marke. func skipActiveMarker() { guard let marker = activeMarker else { return } skippedMarkers.insert(marker.id) seek(to: .milliseconds(marker.endTimeOffset)) } func selectAudioTrack(_ track: MediaTrack) { engine?.selectAudioTrack(track) selectedAudioTrack = track } /// `nil` schaltet die Untertitel ab. func selectSubtitleTrack(_ track: MediaTrack?) { engine?.selectSubtitleTrack(track) selectedSubtitleTrack = track } /// Legt die Engine **sofort** still. Kein Netzwerk, kein Warten. /// /// Getrennt vom Aufräumen am Server, und das ist der ganze Punkt: früher /// stand die Engine erst nach drei Netzwerkaufrufen still. Wurde in der /// Zwischenzeit etwas Neues gestartet, liefen zwei Engines gleichzeitig, /// und der alte hat der neuen Wiedergabe beim Abräumen die Audiositzung /// weggenommen. Was man hörte: der alte Ton lief weiter — auch nachdem /// die App im Hintergrund war —, und das Neue blieb stumm. func silence() { eventTask?.cancel() reportTask?.cancel() engine?.teardown() engine = nil pictureInPicture = nil pictureInPictureDelegate = nil surface.playerLayer.player = nil isPlaying = false isBuffering = false bufferWatchdog?.cancel() } /// Meldet dem Server, dass Schluss ist. Darf dauern. func releaseOnServer() async { // Letzte Meldung vor dem Verlassen: erst sie nimmt den Titel auf dem // Server aus „wird gerade abgespielt" heraus. await report(state: .stopped) // Den Transcoder anhalten. Ohne dieses Signal rechnet der Server // minutenlang weiter, obwohl niemand mehr zusieht. if let transcodeSession, let server { await server.stopTranscode(sessionIdentifier: transcodeSession) } // Und bei Live-TV den Tuner freigeben. Er ist ein Stück Hardware: // vier Stück gibt es, und ein nicht freigegebener fehlt beim nächsten // Einschalten — bis der Server von selbst merkt, dass niemand zusieht. if let subscription = request.liveSubscriptionKey, let server { await server.endLiveSession(subscriptionKey: subscription) } } func stop() async { silence() await releaseOnServer() } // MARK: - Fortschritt melden private func startReporting() { reportTask?.cancel() reportTask = Task { [weak self] in while !Task.isCancelled { // Alle zehn Sekunden — so oft melden es auch die Apps von Plex // selbst. Häufiger brächte nichts, seltener verlöre beim // Abwürgen der App zu viel. try? await Task.sleep(for: .seconds(10)) guard let self, !Task.isCancelled else { return } await report(state: isPlaying ? .playing : .paused) } } } private func report(state: PlaybackReportState) async { guard let server, let item, let duration, currentTime > .zero else { return } await server.reportProgress( ratingKey: item.ratingKey, state: state, time: currentTime, duration: duration ) } // MARK: - Marken und nächste Folge private func updateActiveMarker() { guard let item else { return } let milliseconds = currentTime.milliseconds activeMarker = item.markers.first { marker in guard milliseconds >= marker.startTimeOffset, milliseconds < marker.endTimeOffset else { return false } // Der Abspann zählt nur als Marke, wenn danach noch etwas kommt — // bei einem Film ist „Abspann überspringen" gleichbedeutend mit // „Film beenden". if marker.isCredits && item.kind != .episode { return false } return true } guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return } let shouldSkip = (marker.isIntro && settings.autoSkipIntro) || (marker.isCredits && settings.autoSkipCredits) if shouldSkip { skipActiveMarker() } } private func preloadNextEpisode() { guard let item, item.kind == .episode, let server else { return } Task { [weak self] in let next = try? await server.nextEpisode(after: item) self?.nextEpisode = next } } /// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den /// Koordinator weiter. func nextEpisodeRequest() -> PlaybackRequest? { guard let nextEpisode else { return nil } return PlaybackRequest( ratingKey: nextEpisode.ratingKey, title: nextEpisode.playbackTitle, mediaIndex: 0, startTime: .zero ) } // MARK: - Innereien private func version(of item: PlexItem) -> PlexMedia? { guard item.media.indices.contains(request.mediaIndex) else { return item.preferredMedia } return item.media[request.mediaIndex] } /// Hängt die gehaltene Bildebene an den aktuellen Engine. /// /// Nach **jedem** Engine-Wechsel nötig — also auch nach einem /// Qualitätswechsel, der die Engine 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() // Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS, und // genau darin wechselt AVPlayer die Bitrate von selbst. case .avPlayer, .none: AVPlayerEngine() } } private func observe(_ engine: any VideoPlayerEngine) { eventTask?.cancel() eventTask = Task { [weak self] in for await event in engine.events { guard let self else { return } switch event { case .stateChanged(let state): isPlaying = state == .playing isBuffering = state == .buffering || state == .loading watchBuffering(isBuffering) // Jeder Zustandswechsel mit Zeitstempel. Erst daran ist // abzulesen, ob die Engine hängt oder die Leitung. diagnostics.note("Zustand: \(state)") if case .failed(let failure) = state { diagnostics.finish(outcome: "Wiedergabe gescheitert: \(failure)") // Der Grund gehört auf den Bildschirm, nicht nur ins // Protokoll: „die Wiedergabe wurde unterbrochen" allein // ist eine Sackgasse — es sagt weder, ob der Server // ablehnt, noch ob die Engine das Format nicht mag. phase = .failed(message: [ String(localized: "player.playbackFailed"), String(describing: failure), ].joined(separator: "\n\n")) } case .timeChanged(let time): currentTime = time updateActiveMarker() case .durationResolved(let value): duration = value case .reachedEnd: isPlaying = false hasReachedEnd = true // Grundlage fürs automatische Löschen nach dem Ansehen — // und der Auslöser fürs Nachladen: der Vorrat ist jetzt // um eine Folge kleiner. downloads.markWatched(ratingKey: request.ratingKey) Task { [server, downloads] in await DownloadRefiller(server: server, coordinator: downloads).refill() } case .tracksResolved(let audio, let subtitle): audioTracks = audio subtitleTracks = subtitle applyPreferredLanguages() } } } } /// Wählt die bevorzugten Sprachen, sofern es sie gibt. /// /// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf /// eine später gemeldete Spurliste das nicht zurücksetzen. private func applyPreferredLanguages() { if selectedAudioTrack == nil, !settings.preferredAudioLanguage.isEmpty, let match = audioTracks.first(where: { $0.matches(language: settings.preferredAudioLanguage) }) { selectAudioTrack(match) } if selectedSubtitleTrack == nil, !settings.preferredSubtitleLanguage.isEmpty, let match = subtitleTracks.first(where: { $0.matches(language: settings.preferredSubtitleLanguage) }) { selectSubtitleTrack(match) } } private func playerSubtitle(for item: PlexItem) -> String? { guard item.kind == .episode else { return nil } if let season = item.parentIndex, let episode = item.index { return "\(String(localized: "episode.short \(season) \(episode)")) · \(item.title)" } return item.title } } extension MediaTrack { /// Ob die Spur zu einem Sprachcode passt. /// /// Verglichen wird nachsichtig: Plex meldet mal `de`, mal `deu`, mal /// `ger`, und AVFoundation liefert manchmal `de-DE`. Ein strenger /// Vergleich fände die Spur in der Hälfte der Fälle nicht. func matches(language code: String) -> Bool { guard let languageCode, !code.isEmpty else { return false } let normalized = languageCode.lowercased().prefix(2) 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) } }