From 4c5b801c454b10b3f6016592e45e6540e2c63566 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Tue, 18 Nov 2025 16:43:17 +0100 Subject: [PATCH] Fix Now Playing controls when switching between MPV and AVPlayer backends When switching from AVPlayer to MPV backend, Now Playing controls (play/pause/seek) were disabled because AVPlayer maintained control of the remote command center and audio session. This fix ensures MPV can properly reclaim control. Key changes: - Clear AVPlayer's current item when switching to MPV to release media control - Clear Now Playing info and set playback state to stopped before MPV takes over - Reset remote command center by removing all targets (including AVPlayer's internal handlers) and re-adding custom handlers - Force audio session deactivation/reactivation with .notifyOthersOnDeactivation - Add forceReactivate parameter to setupAudioSessionForNowPlaying() for backend switches - Ensure stream loading continues after Now Playing setup (don't return early) The fix properly handles the transition by: 1. Clearing AVPlayer's media session completely 2. Scheduling async Now Playing setup without blocking stream loading 3. Resetting remote command handlers to reclaim control from AVPlayer 4. Re-activating audio session to establish MPV as the active player --- Model/Player/PlayerModel.swift | 83 ++++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 9 deletions(-) diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index 090e79db..2d0910dc 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -640,8 +640,45 @@ final class PlayerModel: ObservableObject { fromBackend.pause() } - // Update Now Playing when switching backends to ensure the new backend takes control - updateNowPlayingInfo() + // When switching away from AVPlayer, clear its current item to release Now Playing control + #if !os(macOS) + if from == .appleAVPlayer && to == .mpv { + avPlayerBackend.avPlayer.replaceCurrentItem(with: nil) + + // Clear Now Playing info entirely before MPV takes over + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + MPNowPlayingInfoCenter.default().playbackState = .stopped + + logger.info("Cleared AVPlayer's Now Playing control") + + // Schedule Now Playing setup after a brief delay, but don't block stream loading + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self else { return } + + // Re-activate audio session when switching to MPV to ensure Now Playing controls work + // Force deactivate/reactivate to take control from AVPlayer + self.setupAudioSessionForNowPlaying(forceReactivate: true) + // Reset and re-enable remote commands to take control from AVPlayer + self.updateRemoteCommandCenter(reset: true) + // Set up Now Playing for MPV + self.updateNowPlayingInfo() + + logger.info("Set up Now Playing for MPV backend") + } + // Continue to load the stream (don't return early) + } else if to == .mpv { + // Re-activate audio session when switching to MPV to ensure Now Playing controls work + // Force deactivate/reactivate to take control from AVPlayer + setupAudioSessionForNowPlaying(forceReactivate: true) + // Re-enable remote commands to take control from AVPlayer + updateRemoteCommandCenter() + updateNowPlayingInfo() + } else { + updateNowPlayingInfo() + } + #else + updateNowPlayingInfo() + #endif guard var stream, changingStream else { return @@ -656,6 +693,12 @@ final class PlayerModel: ObservableObject { toBackend.play() // Update Now Playing after resuming playback on new backend DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + #if !os(macOS) + if to == .mpv { + self?.setupAudioSessionForNowPlaying(forceReactivate: true) + self?.updateRemoteCommandCenter(reset: true) + } + #endif self?.updateNowPlayingInfo() } } @@ -950,13 +993,27 @@ final class PlayerModel: ObservableObject { } } - func updateRemoteCommandCenter() { + func updateRemoteCommandCenter(reset: Bool = false) { let commandCenter = MPRemoteCommandCenter.shared() let skipForwardCommand = commandCenter.skipForwardCommand let skipBackwardCommand = commandCenter.skipBackwardCommand let previousTrackCommand = commandCenter.previousTrackCommand let nextTrackCommand = commandCenter.nextTrackCommand + // If resetting (e.g., after AVPlayer was active), remove all targets and re-add them + if reset { + logger.info("Resetting remote command center to reclaim control from AVPlayer") + commandCenter.playCommand.removeTarget(nil) + commandCenter.pauseCommand.removeTarget(nil) + commandCenter.togglePlayPauseCommand.removeTarget(nil) + commandCenter.changePlaybackPositionCommand.removeTarget(nil) + skipForwardCommand.removeTarget(nil) + skipBackwardCommand.removeTarget(nil) + previousTrackCommand.removeTarget(nil) + nextTrackCommand.removeTarget(nil) + remoteCommandCenterConfigured = false + } + if !remoteCommandCenterConfigured { remoteCommandCenterConfigured = true @@ -986,25 +1043,21 @@ final class PlayerModel: ObservableObject { return .success } - commandCenter.playCommand.isEnabled = true commandCenter.playCommand.addTarget { [weak self] _ in self?.play() return .success } - commandCenter.pauseCommand.isEnabled = true commandCenter.pauseCommand.addTarget { [weak self] _ in self?.pause() return .success } - commandCenter.togglePlayPauseCommand.isEnabled = true commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in self?.togglePlay() return .success } - commandCenter.changePlaybackPositionCommand.isEnabled = true commandCenter.changePlaybackPositionCommand.addTarget { [weak self] remoteEvent in guard let event = remoteEvent as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } @@ -1014,6 +1067,12 @@ final class PlayerModel: ObservableObject { } } + // Always re-enable commands to ensure they work after backend switches + commandCenter.playCommand.isEnabled = true + commandCenter.pauseCommand.isEnabled = true + commandCenter.togglePlayPauseCommand.isEnabled = true + commandCenter.changePlaybackPositionCommand.isEnabled = true + switch Defaults[.systemControlsCommands] { case .seek: previousTrackCommand.isEnabled = false @@ -1149,13 +1208,19 @@ final class PlayerModel: ObservableObject { } } - func setupAudioSessionForNowPlaying() { + func setupAudioSessionForNowPlaying(forceReactivate: Bool = false) { #if !os(macOS) do { let audioSession = AVAudioSession.sharedInstance() + + // If forcing reactivation (e.g., after backend switch), deactivate first + if forceReactivate { + try? audioSession.setActive(false, options: .notifyOthersOnDeactivation) + } + try audioSession.setCategory(.playback, mode: .moviePlayback, options: []) try audioSession.setActive(true, options: []) - logger.info("Audio session activated for Now Playing") + logger.info("Audio session activated for Now Playing (forceReactivate: \(forceReactivate))") } catch { logger.error("Failed to set up audio session: \(error)") }