355 lines
13 KiB
Swift
355 lines
13 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?
|
|
|
|
// 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
|
|
}
|
|
}
|