499 lines
18 KiB
Swift
499 lines
18 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
|
|
|
|
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<Void, Never>?
|
|
|
|
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
|
|
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..<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() }
|
|
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
|
|
}
|