Files
yattee/Yattee/Services/Player/NowPlayingService.swift
Arkadiusz Fal 42621b8193 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.
2026-05-09 14:18:17 +02:00

664 lines
25 KiB
Swift

//
// NowPlayingService.swift
// Yattee
//
// Manages Now Playing info for Control Center and Lock Screen.
//
import Foundation
import MediaPlayer
import AVFoundation
#if os(iOS) || os(tvOS)
import UIKit
typealias NowPlayingImage = UIImage
#elseif os(macOS)
import AppKit
typealias NowPlayingImage = NSImage
#endif
/// Service for updating system Now Playing info (Control Center, Lock Screen).
@MainActor
final class NowPlayingService {
// MARK: - Properties
private let infoCenter = MPNowPlayingInfoCenter.default()
private let commandCenter = MPRemoteCommandCenter.shared()
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?
weak var settingsManager: SettingsManager?
weak var playerControlsLayoutService: PlayerControlsLayoutService?
// 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
/// Updates Now Playing info for a video.
func updateNowPlaying(
video: Video,
currentTime: TimeInterval,
duration: TimeInterval,
isPlaying: Bool
) {
currentVideo = video
// Use DeArrow title if available, otherwise use original title
let title = deArrowBrandingProvider?.title(for: video) ?? video.title
// Determine if this is a live stream
let isLive = video.isLive
// Build the Now Playing info dictionary with all properties needed for tvOS
var nowPlayingInfo: [String: Any] = [
MPMediaItemPropertyTitle: title,
MPMediaItemPropertyArtist: video.author.name,
MPNowPlayingInfoPropertyIsLiveStream: isLive,
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime,
MPNowPlayingInfoPropertyPlaybackQueueCount: 1,
MPNowPlayingInfoPropertyPlaybackQueueIndex: 0,
MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue,
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0,
MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0
]
// Only add duration for non-live content
if !isLive && duration > 0 {
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
}
// Add artwork if available
if let artwork = artworkImage {
#if os(iOS) || os(tvOS)
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(
boundsSize: artwork.size
) { _ in artwork }
#elseif os(macOS)
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(
boundsSize: artwork.size
) { _ in artwork }
#endif
}
LoggingService.shared.debug(
"Setting Now Playing info with \(nowPlayingInfo.count) keys: \(nowPlayingInfo.keys.joined(separator: ", "))",
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.
// tvOS requires com.apple.mediaremote.set-playback-state entitlement which is restricted.
// The MPNowPlayingInfoPropertyPlaybackRate property is sufficient to indicate state.
#if !os(tvOS)
infoCenter.playbackState = isPlaying ? .playing : .paused
#endif
LoggingService.shared.debug(
"Updated Now Playing: \(video.id.id) - title: \(title), duration: \(duration), time: \(currentTime), playing: \(isPlaying), live: \(isLive)",
category: .player
)
}
/// Updates playback time without changing other metadata.
func updatePlaybackTime(currentTime: TimeInterval, duration: TimeInterval, isPlaying: Bool) {
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)
infoCenter.playbackState = isPlaying ? .playing : .paused
#endif
}
/// Updates playback rate (playing/paused state).
/// Also updates elapsed time to ensure Now Playing info persists in Control Center.
func updatePlaybackRate(isPlaying: Bool, currentTime: TimeInterval? = nil) {
LoggingService.shared.debug(
"updatePlaybackRate called: isPlaying=\(isPlaying), currentTime=\(currentTime ?? -1)",
category: .player
)
guard var info = cachedNowPlayingInfo ?? infoCenter.nowPlayingInfo else {
LoggingService.shared.warning(
"updatePlaybackRate: nowPlayingInfo is nil, cannot update",
category: .player
)
return
}
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
// When pausing, we must also update the elapsed time to ensure iOS
// properly preserves the Now Playing info in Control Center.
// Without this, iOS may clear the metadata when playback stops.
if let time = currentTime {
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)
infoCenter.playbackState = isPlaying ? .playing : .paused
#endif
LoggingService.shared.debug(
"updatePlaybackRate completed: rate=\(isPlaying ? 1.0 : 0.0), info keys=\(info.keys.count)",
category: .player
)
}
/// Immediately updates elapsed playback time (used for seek feedback in Control Center).
func updatePlaybackTimeImmediate(_ time: TimeInterval) {
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
}
/// Loads artwork from local path (for offline playback) or URL and updates Now Playing info.
/// - Parameters:
/// - url: Remote URL to fetch artwork from (used as fallback if localPath fails)
/// - localPath: Local file path for offline artwork (tried first if provided)
func loadArtwork(from url: URL?, localPath: URL? = nil) async {
// 1. Try local path first (for offline playback of downloaded videos)
if let localPath {
do {
let data = try Data(contentsOf: localPath)
#if os(iOS) || os(tvOS)
if let image = UIImage(data: data) {
artworkImage = image
updateNowPlayingWithCurrentArtwork()
LoggingService.shared.debug(
"Loaded artwork from local path: \(localPath.lastPathComponent)",
category: .player
)
return
}
#elseif os(macOS)
if let image = NSImage(data: data) {
artworkImage = image
updateNowPlayingWithCurrentArtwork()
LoggingService.shared.debug(
"Loaded artwork from local path: \(localPath.lastPathComponent)",
category: .player
)
return
}
#endif
} catch {
LoggingService.shared.debug(
"Local artwork not available, falling back to network: \(error.localizedDescription)",
category: .player
)
}
}
// 2. Fall back to network fetch
guard let url else {
artworkImage = nil
return
}
do {
let (data, _) = try await URLSession.shared.data(from: url)
#if os(iOS) || os(tvOS)
artworkImage = UIImage(data: data)
#elseif os(macOS)
artworkImage = NSImage(data: data)
#endif
updateNowPlayingWithCurrentArtwork()
} catch {
LoggingService.shared.error(
"Failed to load artwork: \(error.localizedDescription)",
category: .player
)
}
}
/// Helper to update Now Playing info with current artwork
private func updateNowPlayingWithCurrentArtwork() {
if let video = currentVideo,
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,
duration: duration,
isPlaying: rate > 0
)
}
}
/// Clears Now Playing info.
func clearNowPlaying() {
cachedNowPlayingInfo = nil
#if os(tvOS)
hasActivatedAudioSessionForRouteEvaluation = false
clearPublishedMediaIntegration()
#else
infoCenter.nowPlayingInfo = nil
infoCenter.playbackState = .stopped
#endif
currentVideo = nil
artworkImage = nil
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.
/// Call this method when settings change to reconfigure the commands.
func configureRemoteCommands(
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
disableAllRemoteCommands()
// If explicit values provided, use them directly (bypasses debounce)
if let mode, let duration {
configureRemoteCommandsWithSettings(mode: mode, duration: duration)
return
}
// Read settings from active preset's cached global settings
if let layoutService = playerControlsLayoutService {
Task {
let layout = await layoutService.activeLayout()
await MainActor.run {
self.configureRemoteCommandsWithSettings(
mode: layout.globalSettings.systemControlsMode,
duration: layout.globalSettings.systemControlsSeekDuration
)
}
}
// Return early - async task will call configureRemoteCommandsWithSettings
return
}
// Fallback to cached defaults if no layout service
let mode = GlobalLayoutSettings.cached.systemControlsMode
let duration = GlobalLayoutSettings.cached.systemControlsSeekDuration
configureRemoteCommandsWithSettings(mode: mode, duration: duration)
}
/// Configures remote commands with the specified settings.
/// - Parameters:
/// - 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)
disableAllRemoteCommands()
// Play
commandCenter.playCommand.isEnabled = true
commandCenter.playCommand.addTarget { [weak self] _ in
LoggingService.shared.debug("Remote playCommand received", category: .player)
guard let self else {
LoggingService.shared.warning("Remote playCommand: self is nil", category: .player)
return .commandFailed
}
self.playerService?.resume()
return .success
}
// Pause
commandCenter.pauseCommand.isEnabled = true
commandCenter.pauseCommand.addTarget { [weak self] _ in
LoggingService.shared.debug("Remote pauseCommand received", category: .player)
guard let self else {
LoggingService.shared.warning("Remote pauseCommand: self is nil", category: .player)
return .commandFailed
}
self.playerService?.pause()
return .success
}
// Toggle play/pause
commandCenter.togglePlayPauseCommand.isEnabled = true
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
LoggingService.shared.debug("Remote togglePlayPauseCommand received", category: .player)
guard let self else {
LoggingService.shared.warning("Remote togglePlayPauseCommand: self is nil", category: .player)
return .commandFailed
}
self.playerService?.togglePlayPause()
return .success
}
// Configure skip commands based on mode
let seekEnabled = mode == .seek
commandCenter.skipForwardCommand.isEnabled = seekEnabled
commandCenter.skipBackwardCommand.isEnabled = seekEnabled
if seekEnabled {
commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: duration.timeInterval)]
commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: duration.timeInterval)]
// Skip forward
commandCenter.skipForwardCommand.addTarget { [weak self] event in
guard let self,
let skipEvent = event as? MPSkipIntervalCommandEvent else {
return .commandFailed
}
// Immediately update Now Playing time to prevent UI jumping
if let currentTime = self.infoCenter.nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? TimeInterval,
let videoDuration = self.infoCenter.nowPlayingInfo?[MPMediaItemPropertyPlaybackDuration] as? TimeInterval {
let newTime = min(currentTime + skipEvent.interval, videoDuration)
self.updatePlaybackTimeImmediate(newTime)
}
Task {
self.playerService?.seekForward(by: skipEvent.interval)
}
return .success
}
// Skip backward
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
guard let self,
let skipEvent = event as? MPSkipIntervalCommandEvent else {
return .commandFailed
}
// Immediately update Now Playing time to prevent UI jumping
if let currentTime = self.infoCenter.nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? TimeInterval {
let newTime = max(currentTime - skipEvent.interval, 0)
self.updatePlaybackTimeImmediate(newTime)
}
Task {
self.playerService?.seekBackward(by: skipEvent.interval)
}
return .success
}
}
// Seek (scrubbing) - always enabled
commandCenter.changePlaybackPositionCommand.isEnabled = true
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let self,
let positionEvent = event as? MPChangePlaybackPositionCommandEvent else {
return .commandFailed
}
// Immediately update Now Playing time to prevent UI jumping back
self.updatePlaybackTimeImmediate(positionEvent.positionTime)
Task {
await self.playerService?.seek(to: positionEvent.positionTime)
}
return .success
}
// Next/Previous track - always enabled
commandCenter.nextTrackCommand.isEnabled = true
commandCenter.nextTrackCommand.addTarget { [weak self] _ in
guard let self else { return .commandFailed }
Task {
await self.playerService?.playNext()
}
return .success
}
commandCenter.previousTrackCommand.isEnabled = true
commandCenter.previousTrackCommand.addTarget { [weak self] _ in
guard let self else { return .commandFailed }
Task {
await self.playerService?.playPrevious()
}
return .success
}
LoggingService.shared.debug(
"Remote commands configured: mode=\(mode), seekDuration=\(duration.rawValue)s",
category: .player
)
}
}