mirror of
https://github.com/yattee/yattee.git
synced 2026-06-04 13:54:19 +00:00
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.
664 lines
25 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|