Files
yattee/Yattee/Services/Player/NowPlayingService.swift
Arkadiusz Fal 4770bbe3b9 Remove dead code found by Periphery on all platforms
Delete declarations that Periphery reports unused on every platform that
compiles them (iOS, macOS, tvOS), including nine views used only by their
own previews. Platform-gated code still used on another platform, device-
only MPV render paths, migration models, CloudKit record names, App
Intents metadata and unused parameters are kept.
2026-10-08 23:57:56 +02:00

663 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 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
)
}
}