mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
Suppress tvOS Now Playing while AirPlay/HomePod route is active
On tvOS, registering MPRemoteCommandCenter handlers makes the system classify the app as a long-form video media app. When audio is routed to AirPlay 2 endpoints (HomePods), the system then enforces a ~2s look-ahead buffer in the AVAudioSession → AirPlay 2 pipe for multi-speaker sync. The result is a 2-3 second audio drain on pause and refill on resume. The buffer lives downstream of mpv, so no mpv command (ao-reload, seek-flush, audio-add) can flush it; AVAudioSession setCategory overrides (mode/policy) and setActive(false)/setActive(true) cycles are also ignored once the app is media-classified. Workaround: detect the active audio route via routeChangeNotification on tvOS. While AirPlay/HomePod is the output, suppress MPNowPlayingInfoCenter publication and disable every MPRemoteCommand so tvOS un-classifies us. When the route returns to local outputs, republish the cached Now Playing info and reconfigure remote commands. A latch defers all media integration until the audio session has been activated at least once, so no commands are registered before the route can be evaluated. Trade-off: while playing to HomePods, the Control Center widget and external Siri Remote play/pause are not available — but pause/resume is responsive.
This commit is contained in:
@@ -27,6 +27,13 @@ final class NowPlayingService {
|
||||
|
||||
private var currentVideo: Video?
|
||||
private var artworkImage: NowPlayingImage?
|
||||
private var cachedNowPlayingInfo: [String: Any]?
|
||||
|
||||
#if os(tvOS)
|
||||
private var audioRouteChangeObserver: NSObjectProtocol?
|
||||
private var hasActivatedAudioSessionForRouteEvaluation = false
|
||||
private var suppressesSystemControlsForAirPlay = false
|
||||
#endif
|
||||
|
||||
weak var playerService: PlayerService?
|
||||
weak var deArrowBrandingProvider: DeArrowBrandingProvider?
|
||||
@@ -36,7 +43,20 @@ final class NowPlayingService {
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
#if os(tvOS)
|
||||
startAudioRouteMonitoring()
|
||||
clearPublishedMediaIntegration()
|
||||
#else
|
||||
configureRemoteCommands()
|
||||
#endif
|
||||
}
|
||||
|
||||
deinit {
|
||||
#if os(tvOS)
|
||||
if let audioRouteChangeObserver {
|
||||
NotificationCenter.default.removeObserver(audioRouteChangeObserver)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
@@ -92,6 +112,19 @@ final class NowPlayingService {
|
||||
category: .player
|
||||
)
|
||||
|
||||
cachedNowPlayingInfo = nowPlayingInfo
|
||||
|
||||
#if os(tvOS)
|
||||
guard hasActivatedAudioSessionForRouteEvaluation, !suppressesSystemControlsForAirPlay else {
|
||||
clearPublishedMediaIntegration()
|
||||
LoggingService.shared.debug(
|
||||
"Suppressed Now Playing update while AirPlay/HomePod route is active",
|
||||
category: .player
|
||||
)
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
infoCenter.nowPlayingInfo = nowPlayingInfo
|
||||
|
||||
// Only set playbackState on non-tvOS platforms.
|
||||
@@ -109,12 +142,18 @@ final class NowPlayingService {
|
||||
|
||||
/// Updates playback time without changing other metadata.
|
||||
func updatePlaybackTime(currentTime: TimeInterval, duration: TimeInterval, isPlaying: Bool) {
|
||||
guard var info = infoCenter.nowPlayingInfo else { return }
|
||||
guard var info = cachedNowPlayingInfo ?? infoCenter.nowPlayingInfo else { return }
|
||||
|
||||
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
||||
info[MPMediaItemPropertyPlaybackDuration] = duration
|
||||
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
|
||||
|
||||
cachedNowPlayingInfo = info
|
||||
|
||||
#if os(tvOS)
|
||||
guard hasActivatedAudioSessionForRouteEvaluation, !suppressesSystemControlsForAirPlay else { return }
|
||||
#endif
|
||||
|
||||
infoCenter.nowPlayingInfo = info
|
||||
|
||||
#if !os(tvOS)
|
||||
@@ -130,7 +169,7 @@ final class NowPlayingService {
|
||||
category: .player
|
||||
)
|
||||
|
||||
guard var info = infoCenter.nowPlayingInfo else {
|
||||
guard var info = cachedNowPlayingInfo ?? infoCenter.nowPlayingInfo else {
|
||||
LoggingService.shared.warning(
|
||||
"updatePlaybackRate: nowPlayingInfo is nil, cannot update",
|
||||
category: .player
|
||||
@@ -147,6 +186,18 @@ final class NowPlayingService {
|
||||
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time
|
||||
}
|
||||
|
||||
cachedNowPlayingInfo = info
|
||||
|
||||
#if os(tvOS)
|
||||
guard hasActivatedAudioSessionForRouteEvaluation, !suppressesSystemControlsForAirPlay else {
|
||||
LoggingService.shared.debug(
|
||||
"Suppressed playback rate update while AirPlay/HomePod route is active",
|
||||
category: .player
|
||||
)
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
infoCenter.nowPlayingInfo = info
|
||||
|
||||
#if !os(tvOS)
|
||||
@@ -161,8 +212,14 @@ final class NowPlayingService {
|
||||
|
||||
/// Immediately updates elapsed playback time (used for seek feedback in Control Center).
|
||||
func updatePlaybackTimeImmediate(_ time: TimeInterval) {
|
||||
guard var info = infoCenter.nowPlayingInfo else { return }
|
||||
guard var info = cachedNowPlayingInfo ?? infoCenter.nowPlayingInfo else { return }
|
||||
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time
|
||||
cachedNowPlayingInfo = info
|
||||
|
||||
#if os(tvOS)
|
||||
guard hasActivatedAudioSessionForRouteEvaluation, !suppressesSystemControlsForAirPlay else { return }
|
||||
#endif
|
||||
|
||||
infoCenter.nowPlayingInfo = info
|
||||
}
|
||||
|
||||
@@ -231,10 +288,10 @@ final class NowPlayingService {
|
||||
/// Helper to update Now Playing info with current artwork
|
||||
private func updateNowPlayingWithCurrentArtwork() {
|
||||
if let video = currentVideo,
|
||||
let info = infoCenter.nowPlayingInfo,
|
||||
let duration = info[MPMediaItemPropertyPlaybackDuration] as? TimeInterval,
|
||||
let info = cachedNowPlayingInfo ?? infoCenter.nowPlayingInfo,
|
||||
let currentTime = info[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? TimeInterval,
|
||||
let rate = info[MPNowPlayingInfoPropertyPlaybackRate] as? Double {
|
||||
let duration = info[MPMediaItemPropertyPlaybackDuration] as? TimeInterval ?? video.duration
|
||||
updateNowPlaying(
|
||||
video: video,
|
||||
currentTime: currentTime,
|
||||
@@ -246,9 +303,13 @@ final class NowPlayingService {
|
||||
|
||||
/// Clears Now Playing info.
|
||||
func clearNowPlaying() {
|
||||
infoCenter.nowPlayingInfo = nil
|
||||
cachedNowPlayingInfo = nil
|
||||
|
||||
#if !os(tvOS)
|
||||
#if os(tvOS)
|
||||
hasActivatedAudioSessionForRouteEvaluation = false
|
||||
clearPublishedMediaIntegration()
|
||||
#else
|
||||
infoCenter.nowPlayingInfo = nil
|
||||
infoCenter.playbackState = .stopped
|
||||
#endif
|
||||
|
||||
@@ -258,18 +319,141 @@ final class NowPlayingService {
|
||||
LoggingService.shared.debug("Cleared Now Playing info", category: .player)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
/// Re-evaluates whether system media integration is safe for the current tvOS route.
|
||||
/// AirPlay/HomePod routes add a downstream audio buffer when remote commands are enabled.
|
||||
func refreshSystemControlsForCurrentAudioRoute(reason: String) {
|
||||
let wasWaitingForActiveAudioRoute = !hasActivatedAudioSessionForRouteEvaluation
|
||||
hasActivatedAudioSessionForRouteEvaluation = true
|
||||
|
||||
let shouldSuppress = isAirPlayOutputActive
|
||||
let routeDescription = currentAudioRouteDescription()
|
||||
|
||||
guard shouldSuppress != suppressesSystemControlsForAirPlay || wasWaitingForActiveAudioRoute else {
|
||||
if shouldSuppress {
|
||||
clearPublishedMediaIntegration()
|
||||
}
|
||||
LoggingService.shared.debug(
|
||||
"tvOS audio route unchanged (\(reason)): suppressSystemControls=\(shouldSuppress), route=\(routeDescription)",
|
||||
category: .player
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
suppressesSystemControlsForAirPlay = shouldSuppress
|
||||
|
||||
if shouldSuppress {
|
||||
clearPublishedMediaIntegration()
|
||||
LoggingService.shared.warning(
|
||||
"Disabled Now Playing and remote commands for AirPlay/HomePod route (\(reason)): \(routeDescription)",
|
||||
category: .player
|
||||
)
|
||||
} else {
|
||||
LoggingService.shared.info(
|
||||
"Restoring Now Playing and remote commands for tvOS route (\(reason)): \(routeDescription)",
|
||||
category: .player
|
||||
)
|
||||
configureRemoteCommands()
|
||||
if let cachedNowPlayingInfo {
|
||||
infoCenter.nowPlayingInfo = cachedNowPlayingInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startAudioRouteMonitoring() {
|
||||
audioRouteChangeObserver = NotificationCenter.default.addObserver(
|
||||
forName: AVAudioSession.routeChangeNotification,
|
||||
object: AVAudioSession.sharedInstance(),
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleAudioRouteChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAudioRouteChange() {
|
||||
guard hasActivatedAudioSessionForRouteEvaluation else {
|
||||
clearPublishedMediaIntegration()
|
||||
LoggingService.shared.debug(
|
||||
"Deferred tvOS route change until audio session activation: \(currentAudioRouteDescription())",
|
||||
category: .player
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
refreshSystemControlsForCurrentAudioRoute(reason: "route change")
|
||||
}
|
||||
|
||||
private var isAirPlayOutputActive: Bool {
|
||||
AVAudioSession.sharedInstance().currentRoute.outputs.contains { output in
|
||||
output.portType == .airPlay ||
|
||||
output.portType.rawValue.localizedCaseInsensitiveContains("airplay") ||
|
||||
output.portName.localizedCaseInsensitiveContains("HomePod")
|
||||
}
|
||||
}
|
||||
|
||||
private func currentAudioRouteDescription() -> String {
|
||||
let outputs = AVAudioSession.sharedInstance().currentRoute.outputs
|
||||
guard !outputs.isEmpty else { return "no outputs" }
|
||||
|
||||
return outputs
|
||||
.map { "\($0.portName) (\($0.portType.rawValue))" }
|
||||
.joined(separator: ", ")
|
||||
}
|
||||
|
||||
private func clearPublishedMediaIntegration() {
|
||||
infoCenter.nowPlayingInfo = nil
|
||||
disableAllRemoteCommands()
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Remote Commands
|
||||
|
||||
/// Removes all existing command targets to allow reconfiguration.
|
||||
private func removeAllTargets() {
|
||||
commandCenter.playCommand.removeTarget(nil)
|
||||
commandCenter.pauseCommand.removeTarget(nil)
|
||||
commandCenter.stopCommand.removeTarget(nil)
|
||||
commandCenter.togglePlayPauseCommand.removeTarget(nil)
|
||||
commandCenter.skipForwardCommand.removeTarget(nil)
|
||||
commandCenter.skipBackwardCommand.removeTarget(nil)
|
||||
commandCenter.seekForwardCommand.removeTarget(nil)
|
||||
commandCenter.seekBackwardCommand.removeTarget(nil)
|
||||
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
|
||||
commandCenter.changePlaybackRateCommand.removeTarget(nil)
|
||||
commandCenter.changeRepeatModeCommand.removeTarget(nil)
|
||||
commandCenter.changeShuffleModeCommand.removeTarget(nil)
|
||||
commandCenter.nextTrackCommand.removeTarget(nil)
|
||||
commandCenter.previousTrackCommand.removeTarget(nil)
|
||||
commandCenter.ratingCommand.removeTarget(nil)
|
||||
commandCenter.likeCommand.removeTarget(nil)
|
||||
commandCenter.dislikeCommand.removeTarget(nil)
|
||||
commandCenter.bookmarkCommand.removeTarget(nil)
|
||||
}
|
||||
|
||||
/// Disables every remote command exposed by the command center.
|
||||
private func disableAllRemoteCommands() {
|
||||
removeAllTargets()
|
||||
|
||||
commandCenter.playCommand.isEnabled = false
|
||||
commandCenter.pauseCommand.isEnabled = false
|
||||
commandCenter.stopCommand.isEnabled = false
|
||||
commandCenter.togglePlayPauseCommand.isEnabled = false
|
||||
commandCenter.skipForwardCommand.isEnabled = false
|
||||
commandCenter.skipBackwardCommand.isEnabled = false
|
||||
commandCenter.seekForwardCommand.isEnabled = false
|
||||
commandCenter.seekBackwardCommand.isEnabled = false
|
||||
commandCenter.changePlaybackPositionCommand.isEnabled = false
|
||||
commandCenter.changePlaybackRateCommand.isEnabled = false
|
||||
commandCenter.changeRepeatModeCommand.isEnabled = false
|
||||
commandCenter.changeShuffleModeCommand.isEnabled = false
|
||||
commandCenter.nextTrackCommand.isEnabled = false
|
||||
commandCenter.previousTrackCommand.isEnabled = false
|
||||
commandCenter.ratingCommand.isEnabled = false
|
||||
commandCenter.likeCommand.isEnabled = false
|
||||
commandCenter.dislikeCommand.isEnabled = false
|
||||
commandCenter.bookmarkCommand.isEnabled = false
|
||||
}
|
||||
|
||||
/// Configures remote commands based on current settings.
|
||||
@@ -278,8 +462,28 @@ final class NowPlayingService {
|
||||
mode: SystemControlsMode? = nil,
|
||||
duration: SystemControlsSeekDuration? = nil
|
||||
) {
|
||||
#if os(tvOS)
|
||||
guard hasActivatedAudioSessionForRouteEvaluation else {
|
||||
clearPublishedMediaIntegration()
|
||||
LoggingService.shared.debug(
|
||||
"Deferred remote command configuration until tvOS audio session activation",
|
||||
category: .player
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard !suppressesSystemControlsForAirPlay else {
|
||||
clearPublishedMediaIntegration()
|
||||
LoggingService.shared.debug(
|
||||
"Skipped remote command configuration while AirPlay/HomePod route is active",
|
||||
category: .player
|
||||
)
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
// Remove existing targets to prevent duplicate handlers
|
||||
removeAllTargets()
|
||||
disableAllRemoteCommands()
|
||||
|
||||
// If explicit values provided, use them directly (bypasses debounce)
|
||||
if let mode, let duration {
|
||||
@@ -313,8 +517,28 @@ final class NowPlayingService {
|
||||
/// - mode: The system controls mode (seek or skip track).
|
||||
/// - duration: The seek duration when mode is .seek.
|
||||
private func configureRemoteCommandsWithSettings(mode: SystemControlsMode, duration: SystemControlsSeekDuration) {
|
||||
#if os(tvOS)
|
||||
guard hasActivatedAudioSessionForRouteEvaluation else {
|
||||
clearPublishedMediaIntegration()
|
||||
LoggingService.shared.debug(
|
||||
"Deferred remote command configuration with settings until tvOS audio session activation",
|
||||
category: .player
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard !suppressesSystemControlsForAirPlay else {
|
||||
clearPublishedMediaIntegration()
|
||||
LoggingService.shared.debug(
|
||||
"Skipped remote command configuration with settings while AirPlay/HomePod route is active",
|
||||
category: .player
|
||||
)
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
// Remove existing targets (in case called from async path)
|
||||
removeAllTargets()
|
||||
disableAllRemoteCommands()
|
||||
|
||||
// Play
|
||||
commandCenter.playCommand.isEnabled = true
|
||||
|
||||
@@ -1563,6 +1563,10 @@ final class PlayerService {
|
||||
try session.setCategory(.playback, mode: .moviePlayback)
|
||||
try session.setActive(true)
|
||||
|
||||
#if os(tvOS)
|
||||
nowPlayingService.refreshSystemControlsForCurrentAudioRoute(reason: "audio session activation")
|
||||
#endif
|
||||
|
||||
// Only register observer once
|
||||
guard !hasRegisteredInterruptionObserver else { return }
|
||||
hasRegisteredInterruptionObserver = true
|
||||
|
||||
Reference in New Issue
Block a user