Merge iOS and Mac app into one
This commit is contained in:
424
ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift
Normal file
424
ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift
Normal file
@@ -0,0 +1,424 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user