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 currentVideo: Video?
|
||||||
private var artworkImage: NowPlayingImage?
|
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 playerService: PlayerService?
|
||||||
weak var deArrowBrandingProvider: DeArrowBrandingProvider?
|
weak var deArrowBrandingProvider: DeArrowBrandingProvider?
|
||||||
@@ -36,7 +43,20 @@ final class NowPlayingService {
|
|||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
#if os(tvOS)
|
||||||
|
startAudioRouteMonitoring()
|
||||||
|
clearPublishedMediaIntegration()
|
||||||
|
#else
|
||||||
configureRemoteCommands()
|
configureRemoteCommands()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
#if os(tvOS)
|
||||||
|
if let audioRouteChangeObserver {
|
||||||
|
NotificationCenter.default.removeObserver(audioRouteChangeObserver)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
@@ -92,6 +112,19 @@ final class NowPlayingService {
|
|||||||
category: .player
|
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
|
infoCenter.nowPlayingInfo = nowPlayingInfo
|
||||||
|
|
||||||
// Only set playbackState on non-tvOS platforms.
|
// Only set playbackState on non-tvOS platforms.
|
||||||
@@ -109,12 +142,18 @@ final class NowPlayingService {
|
|||||||
|
|
||||||
/// Updates playback time without changing other metadata.
|
/// Updates playback time without changing other metadata.
|
||||||
func updatePlaybackTime(currentTime: TimeInterval, duration: TimeInterval, isPlaying: Bool) {
|
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[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
||||||
info[MPMediaItemPropertyPlaybackDuration] = duration
|
info[MPMediaItemPropertyPlaybackDuration] = duration
|
||||||
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
|
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
|
||||||
|
|
||||||
|
cachedNowPlayingInfo = info
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
guard hasActivatedAudioSessionForRouteEvaluation, !suppressesSystemControlsForAirPlay else { return }
|
||||||
|
#endif
|
||||||
|
|
||||||
infoCenter.nowPlayingInfo = info
|
infoCenter.nowPlayingInfo = info
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@@ -130,7 +169,7 @@ final class NowPlayingService {
|
|||||||
category: .player
|
category: .player
|
||||||
)
|
)
|
||||||
|
|
||||||
guard var info = infoCenter.nowPlayingInfo else {
|
guard var info = cachedNowPlayingInfo ?? infoCenter.nowPlayingInfo else {
|
||||||
LoggingService.shared.warning(
|
LoggingService.shared.warning(
|
||||||
"updatePlaybackRate: nowPlayingInfo is nil, cannot update",
|
"updatePlaybackRate: nowPlayingInfo is nil, cannot update",
|
||||||
category: .player
|
category: .player
|
||||||
@@ -147,6 +186,18 @@ final class NowPlayingService {
|
|||||||
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time
|
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
|
infoCenter.nowPlayingInfo = info
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@@ -161,8 +212,14 @@ final class NowPlayingService {
|
|||||||
|
|
||||||
/// Immediately updates elapsed playback time (used for seek feedback in Control Center).
|
/// Immediately updates elapsed playback time (used for seek feedback in Control Center).
|
||||||
func updatePlaybackTimeImmediate(_ time: TimeInterval) {
|
func updatePlaybackTimeImmediate(_ time: TimeInterval) {
|
||||||
guard var info = infoCenter.nowPlayingInfo else { return }
|
guard var info = cachedNowPlayingInfo ?? infoCenter.nowPlayingInfo else { return }
|
||||||
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time
|
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time
|
||||||
|
cachedNowPlayingInfo = info
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
guard hasActivatedAudioSessionForRouteEvaluation, !suppressesSystemControlsForAirPlay else { return }
|
||||||
|
#endif
|
||||||
|
|
||||||
infoCenter.nowPlayingInfo = info
|
infoCenter.nowPlayingInfo = info
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,10 +288,10 @@ final class NowPlayingService {
|
|||||||
/// Helper to update Now Playing info with current artwork
|
/// Helper to update Now Playing info with current artwork
|
||||||
private func updateNowPlayingWithCurrentArtwork() {
|
private func updateNowPlayingWithCurrentArtwork() {
|
||||||
if let video = currentVideo,
|
if let video = currentVideo,
|
||||||
let info = infoCenter.nowPlayingInfo,
|
let info = cachedNowPlayingInfo ?? infoCenter.nowPlayingInfo,
|
||||||
let duration = info[MPMediaItemPropertyPlaybackDuration] as? TimeInterval,
|
|
||||||
let currentTime = info[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? TimeInterval,
|
let currentTime = info[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? TimeInterval,
|
||||||
let rate = info[MPNowPlayingInfoPropertyPlaybackRate] as? Double {
|
let rate = info[MPNowPlayingInfoPropertyPlaybackRate] as? Double {
|
||||||
|
let duration = info[MPMediaItemPropertyPlaybackDuration] as? TimeInterval ?? video.duration
|
||||||
updateNowPlaying(
|
updateNowPlaying(
|
||||||
video: video,
|
video: video,
|
||||||
currentTime: currentTime,
|
currentTime: currentTime,
|
||||||
@@ -246,9 +303,13 @@ final class NowPlayingService {
|
|||||||
|
|
||||||
/// Clears Now Playing info.
|
/// Clears Now Playing info.
|
||||||
func clearNowPlaying() {
|
func clearNowPlaying() {
|
||||||
infoCenter.nowPlayingInfo = nil
|
cachedNowPlayingInfo = nil
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if os(tvOS)
|
||||||
|
hasActivatedAudioSessionForRouteEvaluation = false
|
||||||
|
clearPublishedMediaIntegration()
|
||||||
|
#else
|
||||||
|
infoCenter.nowPlayingInfo = nil
|
||||||
infoCenter.playbackState = .stopped
|
infoCenter.playbackState = .stopped
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -258,18 +319,141 @@ final class NowPlayingService {
|
|||||||
LoggingService.shared.debug("Cleared Now Playing info", category: .player)
|
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
|
// MARK: - Remote Commands
|
||||||
|
|
||||||
/// Removes all existing command targets to allow reconfiguration.
|
/// Removes all existing command targets to allow reconfiguration.
|
||||||
private func removeAllTargets() {
|
private func removeAllTargets() {
|
||||||
commandCenter.playCommand.removeTarget(nil)
|
commandCenter.playCommand.removeTarget(nil)
|
||||||
commandCenter.pauseCommand.removeTarget(nil)
|
commandCenter.pauseCommand.removeTarget(nil)
|
||||||
|
commandCenter.stopCommand.removeTarget(nil)
|
||||||
commandCenter.togglePlayPauseCommand.removeTarget(nil)
|
commandCenter.togglePlayPauseCommand.removeTarget(nil)
|
||||||
commandCenter.skipForwardCommand.removeTarget(nil)
|
commandCenter.skipForwardCommand.removeTarget(nil)
|
||||||
commandCenter.skipBackwardCommand.removeTarget(nil)
|
commandCenter.skipBackwardCommand.removeTarget(nil)
|
||||||
|
commandCenter.seekForwardCommand.removeTarget(nil)
|
||||||
|
commandCenter.seekBackwardCommand.removeTarget(nil)
|
||||||
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
|
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
|
||||||
|
commandCenter.changePlaybackRateCommand.removeTarget(nil)
|
||||||
|
commandCenter.changeRepeatModeCommand.removeTarget(nil)
|
||||||
|
commandCenter.changeShuffleModeCommand.removeTarget(nil)
|
||||||
commandCenter.nextTrackCommand.removeTarget(nil)
|
commandCenter.nextTrackCommand.removeTarget(nil)
|
||||||
commandCenter.previousTrackCommand.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.
|
/// Configures remote commands based on current settings.
|
||||||
@@ -278,8 +462,28 @@ final class NowPlayingService {
|
|||||||
mode: SystemControlsMode? = nil,
|
mode: SystemControlsMode? = nil,
|
||||||
duration: SystemControlsSeekDuration? = 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
|
// Remove existing targets to prevent duplicate handlers
|
||||||
removeAllTargets()
|
disableAllRemoteCommands()
|
||||||
|
|
||||||
// If explicit values provided, use them directly (bypasses debounce)
|
// If explicit values provided, use them directly (bypasses debounce)
|
||||||
if let mode, let duration {
|
if let mode, let duration {
|
||||||
@@ -313,8 +517,28 @@ final class NowPlayingService {
|
|||||||
/// - mode: The system controls mode (seek or skip track).
|
/// - mode: The system controls mode (seek or skip track).
|
||||||
/// - duration: The seek duration when mode is .seek.
|
/// - duration: The seek duration when mode is .seek.
|
||||||
private func configureRemoteCommandsWithSettings(mode: SystemControlsMode, duration: SystemControlsSeekDuration) {
|
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)
|
// Remove existing targets (in case called from async path)
|
||||||
removeAllTargets()
|
disableAllRemoteCommands()
|
||||||
|
|
||||||
// Play
|
// Play
|
||||||
commandCenter.playCommand.isEnabled = true
|
commandCenter.playCommand.isEnabled = true
|
||||||
|
|||||||
@@ -1563,6 +1563,10 @@ final class PlayerService {
|
|||||||
try session.setCategory(.playback, mode: .moviePlayback)
|
try session.setCategory(.playback, mode: .moviePlayback)
|
||||||
try session.setActive(true)
|
try session.setActive(true)
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
nowPlayingService.refreshSystemControlsForCurrentAudioRoute(reason: "audio session activation")
|
||||||
|
#endif
|
||||||
|
|
||||||
// Only register observer once
|
// Only register observer once
|
||||||
guard !hasRegisteredInterruptionObserver else { return }
|
guard !hasRegisteredInterruptionObserver else { return }
|
||||||
hasRegisteredInterruptionObserver = true
|
hasRegisteredInterruptionObserver = true
|
||||||
|
|||||||
Reference in New Issue
Block a user