Files
ABS-Client/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift
2026-05-17 21:06:59 +02:00

425 lines
15 KiB
Swift

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?
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..<localURLs.count).map { idx in
idx < item.audioFiles.count ? item.audioFiles[idx].durationSeconds : 0
}
} else {
guard !item.audioFiles.isEmpty else {
errorMessage = "Dieses Hörbuch enthält keine abspielbaren Audiodateien."
return
}
urls = item.audioFiles.compactMap { client.audioFileURL(itemId: item.id, ino: $0.ino) }
trackDurations = item.audioFiles.map { $0.durationSeconds }
}
// When audio files carry no duration (e.g. some podcast episodes or
// freshly-scanned items), fall back to the item's reported total.
// Distribute equally across all tracks so that trackDurations.count
// always matches trackPlayerItems.count.
if trackDurations.allSatisfy({ $0 <= 0 }) && item.durationSeconds > 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
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: 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..<trackPlayerItems.count {
let it = trackPlayerItems[i]
it.seek(to: .zero, completionHandler: nil)
if player.canInsert(it, after: nil) {
player.insert(it, after: nil)
}
}
currentTrackIndex = index
if wasPlaying { player.play(); player.rate = rate }
}
private func handleTrackEnd(finishedIndex: Int) {
if finishedIndex < trackDurations.count - 1 {
currentTrackIndex = finishedIndex + 1
} else {
isPlaying = false
updateNowPlayingInfo()
}
}
private func refreshAbsoluteTime() {
guard let player, let current = player.currentItem else { return }
if isSeeking { return }
if let idx = trackPlayerItems.firstIndex(where: { $0 === current }) {
currentTrackIndex = idx
}
let trackTime = current.currentTime().seconds
let prior = trackDurations.prefix(currentTrackIndex).reduce(0, +)
let absolute = prior + (trackTime.isFinite ? trackTime : 0)
let cap = totalDuration > 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
artworkSession = 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
}
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
}