Restructure project folders
This commit is contained in:
354
ABS Client Mac/Audiobookshelf swift/Services/PlayerEngine.swift
Normal file
354
ABS Client Mac/Audiobookshelf swift/Services/PlayerEngine.swift
Normal file
@@ -0,0 +1,354 @@
|
||||
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..<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 — a mismatch breaks
|
||||
// handleTrackEnd and refreshAbsoluteTime.
|
||||
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)
|
||||
|
||||
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..<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)
|
||||
// AVPlayer can report an item duration slightly longer than the metadata we have.
|
||||
// Clamp the visible time so the scrubber/labels never exceed totalDuration.
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user