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 enum SleepTimerMode: Equatable, Hashable { case off case minutes(Int) case endOfBook } @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? var sleepTimer: SleepTimerMode = .off /// Verbleibende Wallclock-Sekunden bis der Sleep-Timer auslöst (0 wenn off). /// Pausiert mit der Wiedergabe; bei `.endOfBook` rate-skaliert aus der Restspielzeit. var sleepRemainingSeconds: Double = 0 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 private var sleepTickTask: Task? var itemId: String? private var currentTitle: String = "" private var currentAuthor: String = "" private var currentCoverURL: URL? private var remoteCommandsConfigured: Bool = false private var artworkSession: URLSession? #if os(iOS) private var audioSessionObserversConfigured: Bool = false private var wasPlayingBeforeInterruption: Bool = false #endif nonisolated init() {} func load(item: LibraryItem, client: ABSClient, downloads: DownloadManager, startAt absoluteTime: Double) { teardown() self.itemId = item.id self.errorMessage = nil self.artworkSession = client.session 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) #if os(iOS) configureAudioSessionObserversIfNeeded() #endif configureRemoteCommandsIfNeeded() updateNowPlayingInfo() fetchAndAttachArtwork() isReady = true } func play() { #if os(iOS) try? AVAudioSession.sharedInstance().setActive(true) #endif player?.play() player?.rate = rate isPlaying = true if case .minutes = sleepTimer, sleepRemainingSeconds > 0, sleepTickTask == nil { startSleepTickTask() } updateNowPlayingInfo() } func pause() { player?.pause() isPlaying = false cancelSleepTickTask() updateNowPlayingInfo() } func togglePlay() { isPlaying ? pause() : play() } func setRate(_ newRate: Float) { rate = newRate if isPlaying { player?.rate = newRate } if case .endOfBook = sleepTimer { sleepRemainingSeconds = wallclockRemainingUntilEndOfBook() } 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: don't leave isSeeking stuck, which would freeze the scrubber. return } isSeeking = true let cmTime = CMTime(seconds: max(0, remaining), preferredTimescale: 600) // Small tolerance so seeking succeeds on VBR MP3s without a Xing header. // Zero-tolerance seeks fail silently on such files, snapping the slider back. 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() } updateEndOfBookSleep() } func teardown() { cancelSleepTickTask() sleepTimer = .off sleepRemainingSeconds = 0 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 artworkSession = nil clearNowPlayingInfo() } // MARK: - Sleep timer func setSleepTimer(_ mode: SleepTimerMode) { cancelSleepTickTask() sleepTimer = mode switch mode { case .off: sleepRemainingSeconds = 0 case .minutes(let m): sleepRemainingSeconds = Double(m * 60) if isPlaying { startSleepTickTask() } case .endOfBook: sleepRemainingSeconds = wallclockRemainingUntilEndOfBook() } } private func startSleepTickTask() { cancelSleepTickTask() sleepTickTask = Task { @MainActor [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 500_000_000) guard !Task.isCancelled, let self else { return } self.sleepRemainingSeconds = max(0, self.sleepRemainingSeconds - 0.5) if self.sleepRemainingSeconds <= 0 { self.sleepTimer = .off self.pause() return } } } } private func cancelSleepTickTask() { sleepTickTask?.cancel() sleepTickTask = nil } private func updateEndOfBookSleep() { guard case .endOfBook = sleepTimer else { return } sleepRemainingSeconds = wallclockRemainingUntilEndOfBook() if sleepRemainingSeconds <= 0 { sleepTimer = .off } } private func wallclockRemainingUntilEndOfBook() -> Double { let playback = max(0, totalDuration - absoluteCurrentTime) let r = max(0.1, Double(rate)) return playback / r } // 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 } 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, ] 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 } let session = artworkSession ?? URLSession.shared Task.detached { do { let (data, _) = try await session.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 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)] } static func currentSkipSeconds() -> Int { let raw = UserDefaults.standard.integer(forKey: "skipDurationSeconds") return raw > 0 ? raw : 30 } // MARK: - iOS audio session observers #if os(iOS) private func configureAudioSessionObserversIfNeeded() { guard !audioSessionObserversConfigured else { return } audioSessionObserversConfigured = true let center = NotificationCenter.default center.addObserver( forName: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance(), queue: .main ) { [weak self] notification in Task { @MainActor [weak self] in self?.handleAudioInterruption(notification: notification) } } center.addObserver( forName: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance(), queue: .main ) { [weak self] notification in Task { @MainActor [weak self] in self?.handleRouteChange(notification: notification) } } } private func handleAudioInterruption(notification: Notification) { guard let info = notification.userInfo, let typeRaw = info[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeRaw) else { return } switch type { case .began: wasPlayingBeforeInterruption = isPlaying if isPlaying { player?.pause() isPlaying = false updateNowPlayingInfo() } case .ended: let optionsRaw = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0 let options = AVAudioSession.InterruptionOptions(rawValue: optionsRaw) try? AVAudioSession.sharedInstance().setActive(true) if wasPlayingBeforeInterruption && options.contains(.shouldResume) { play() } @unknown default: break } } private func handleRouteChange(notification: Notification) { guard let info = notification.userInfo, let reasonRaw = info[AVAudioSessionRouteChangeReasonKey] as? UInt, let reason = AVAudioSession.RouteChangeReason(rawValue: reasonRaw) else { return } // Pause when headphones are unplugged (Apple's recommended behavior). if reason == .oldDeviceUnavailable { pause() } } #endif }