import Foundation import AVFoundation import MediaPlayer import Observation #if canImport(UIKit) import UIKit private typealias PlayerArtworkImage = UIImage #else import AppKit private typealias PlayerArtworkImage = NSImage #endif @Observable @MainActor final class PlayerEngine { var isPlaying: Bool = false var absoluteCurrentTime: Double = 0 var totalDuration: Double = 0 var rate: Float = 1.0 var isReady: Bool = false var errorMessage: String? private var player: AVQueuePlayer? private var trackDurations: [Double] = [] private var trackPlayerItems: [AVPlayerItem] = [] private var currentTrackIndex: Int = 0 private var timeObserver: Any? private var endObservers: [NSObjectProtocol] = [] private var isSeeking: Bool = false var itemId: String? // Now-playing metadata that travels with the current item. private var currentTitle: String = "" private var currentAuthor: String = "" private var currentCoverURL: URL? private var remoteCommandsConfigured: Bool = false nonisolated init() {} func load(item: LibraryItem, client: ABSClient, downloads: DownloadManager, startAt absoluteTime: Double) { teardown() self.itemId = item.id self.errorMessage = nil let useLocal = downloads.isDownloaded(downloadKey: item.downloadKey) let urls: [URL] if useLocal, let localURLs = downloads.localTrackURLs(for: item.downloadKey), !localURLs.isEmpty { urls = localURLs trackDurations = (0.. 0 { let count = max(1, trackDurations.count) let perTrack = item.durationSeconds / Double(count) trackDurations = Array(repeating: perTrack, count: count) } totalDuration = trackDurations.reduce(0, +) trackPlayerItems = urls.map { AVPlayerItem(url: $0) } let queue = AVQueuePlayer(items: trackPlayerItems) queue.rate = rate self.player = queue let center = NotificationCenter.default for (idx, playerItem) in trackPlayerItems.enumerated() { let token = center.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: playerItem, queue: .main) { [weak self] _ in Task { @MainActor [weak self] in self?.handleTrackEnd(finishedIndex: idx) } } endObservers.append(token) } seekAbsolute(absoluteTime) timeObserver = queue.addPeriodicTimeObserver( forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in self?.refreshAbsoluteTime() } } currentTitle = item.title currentAuthor = item.author currentCoverURL = client.coverURL(itemId: item.id) configureRemoteCommandsIfNeeded() updateNowPlayingInfo() fetchAndAttachArtwork() isReady = true } func play() { player?.play() player?.rate = rate isPlaying = true updateNowPlayingInfo() } func pause() { player?.pause() isPlaying = false updateNowPlayingInfo() } func togglePlay() { isPlaying ? pause() : play() } func setRate(_ newRate: Float) { rate = newRate if isPlaying { player?.rate = newRate } updateNowPlayingInfo() } func skip(by seconds: Double) { seekAbsolute(absoluteCurrentTime + seconds) } func seekAbsolute(_ target: Double) { let clamped = max(0, min(target, max(totalDuration - 0.5, 0))) var remaining = clamped var trackIndex = 0 for (idx, dur) in trackDurations.enumerated() { if remaining <= dur || idx == trackDurations.count - 1 { trackIndex = idx break } remaining -= dur } switchToTrack(index: trackIndex) absoluteCurrentTime = clamped guard let currentItem = player?.currentItem else { // No item to seek (e.g. empty queue after failed insert) — don't // leave isSeeking stuck, which would freeze refreshAbsoluteTime. return } isSeeking = true let cmTime = CMTime(seconds: max(0, remaining), preferredTimescale: 600) // Use a small tolerance so seeking succeeds on formats where exact // keyframe alignment isn't guaranteed (e.g. VBR MP3 without a Xing // header). Zero-tolerance seeks fail silently on such files, causing // the slider to snap back because the player position never moved. let tolerance = CMTime(seconds: 0.5, preferredTimescale: 600) currentItem.seek(to: cmTime, toleranceBefore: tolerance, toleranceAfter: tolerance) { [weak self] _ in Task { @MainActor [weak self] in self?.isSeeking = false self?.refreshAbsoluteTime() self?.updateNowPlayingInfo() } } } private func switchToTrack(index: Int) { guard index < trackPlayerItems.count, let player else { return } if index == currentTrackIndex, player.currentItem === trackPlayerItems[index] { return } let wasPlaying = isPlaying player.removeAllItems() for i in index.. 0 ? totalDuration : absolute absoluteCurrentTime = max(0, min(absolute, cap)) let wasPlaying = isPlaying isPlaying = player.timeControlStatus == .playing if wasPlaying != isPlaying { updateNowPlayingInfo() } } func teardown() { if let token = timeObserver { player?.removeTimeObserver(token) } timeObserver = nil for obs in endObservers { NotificationCenter.default.removeObserver(obs) } endObservers.removeAll() player?.pause() player?.removeAllItems() player = nil trackPlayerItems.removeAll() trackDurations.removeAll() isPlaying = false isReady = false absoluteCurrentTime = 0 totalDuration = 0 currentTrackIndex = 0 itemId = nil errorMessage = nil isSeeking = false currentTitle = "" currentAuthor = "" currentCoverURL = nil clearNowPlayingInfo() } // MARK: - Now-playing / remote commands private func configureRemoteCommandsIfNeeded() { guard !remoteCommandsConfigured else { return } remoteCommandsConfigured = true let center = MPRemoteCommandCenter.shared() center.playCommand.addTarget { [weak self] _ in Task { @MainActor in self?.play() } return .success } center.pauseCommand.addTarget { [weak self] _ in Task { @MainActor in self?.pause() } return .success } center.togglePlayPauseCommand.addTarget { [weak self] _ in Task { @MainActor in self?.togglePlay() } return .success } applyRemoteSkipInterval(seconds: Self.currentSkipSeconds()) center.skipForwardCommand.addTarget { [weak self] _ in let s = Double(Self.currentSkipSeconds()) Task { @MainActor in self?.skip(by: s) } return .success } center.skipBackwardCommand.addTarget { [weak self] _ in let s = Double(Self.currentSkipSeconds()) Task { @MainActor in self?.skip(by: -s) } return .success } // Keep the lock-screen icon in sync with the user's preference. NotificationCenter.default.addObserver( forName: UserDefaults.didChangeNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in self?.applyRemoteSkipInterval(seconds: Self.currentSkipSeconds()) } } center.changePlaybackPositionCommand.addTarget { [weak self] event in guard let posEvent = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } let target = posEvent.positionTime Task { @MainActor in self?.seekAbsolute(target) } return .success } center.changePlaybackRateCommand.supportedPlaybackRates = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0] center.changePlaybackRateCommand.addTarget { [weak self] event in guard let rateEvent = event as? MPChangePlaybackRateCommandEvent else { return .commandFailed } Task { @MainActor in self?.setRate(rateEvent.playbackRate) } return .success } } private func updateNowPlayingInfo() { guard itemId != nil else { clearNowPlayingInfo() return } var info: [String: Any] = [ MPMediaItemPropertyTitle: currentTitle, MPMediaItemPropertyArtist: currentAuthor, MPMediaItemPropertyPlaybackDuration: totalDuration, MPNowPlayingInfoPropertyElapsedPlaybackTime: absoluteCurrentTime, MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? Double(rate) : 0.0, MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0, MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue, ] // Preserve artwork across updates so we don't blank the lock-screen image. if let existing = MPNowPlayingInfoCenter.default().nowPlayingInfo, let art = existing[MPMediaItemPropertyArtwork] { info[MPMediaItemPropertyArtwork] = art } MPNowPlayingInfoCenter.default().nowPlayingInfo = info } private func fetchAndAttachArtwork() { guard let url = currentCoverURL else { return } Task.detached { do { let (data, _) = try await URLSession.shared.data(from: url) guard let img = PlayerArtworkImage(data: data) else { return } let artwork = MPMediaItemArtwork(boundsSize: img.size) { _ in img } await MainActor.run { [weak self] in // Drop the result if the user has since switched items. guard let self, self.currentCoverURL == url else { return } var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] info[MPMediaItemPropertyArtwork] = artwork MPNowPlayingInfoCenter.default().nowPlayingInfo = info } } catch { } } } private func clearNowPlayingInfo() { MPNowPlayingInfoCenter.default().nowPlayingInfo = nil } private func applyRemoteSkipInterval(seconds: Int) { let center = MPRemoteCommandCenter.shared() center.skipForwardCommand.preferredIntervals = [NSNumber(value: seconds)] center.skipBackwardCommand.preferredIntervals = [NSNumber(value: seconds)] } /// Reads the user-configured skip duration; defaults to 30s when unset. static func currentSkipSeconds() -> Int { let raw = UserDefaults.standard.integer(forKey: "skipDurationSeconds") return raw > 0 ? raw : 30 } }