mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-07-27 06:58:37 +02:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9821ddabcf | ||
|
|
57777f2f4b | ||
|
|
1f2c309d8c | ||
|
|
c11977870d | ||
|
|
41753cd853 | ||
|
|
003285f567 | ||
|
|
d1c1902cd3 | ||
|
|
b697deac1c | ||
|
|
fb8e6408bb | ||
|
|
836ffddd4f | ||
|
|
c8ff5a7817 | ||
|
|
c14f6ec4c2 | ||
|
|
dc8852eb0d | ||
|
|
586405f9de | ||
|
|
34e68593bc | ||
|
|
c02adfa326 | ||
|
|
021d63d973 | ||
|
|
50f6b15d23 | ||
|
|
6257c6488b | ||
|
|
defd895995 | ||
|
|
82159ee537 | ||
|
|
11f22888d5 | ||
|
|
20d932877e | ||
|
|
35631fb211 | ||
|
|
b02dd18e0b | ||
|
|
b583a8e6e2 | ||
|
|
5775a51731 | ||
|
|
9b94a11ab9 | ||
|
|
9ee1203130 | ||
|
|
711b079963 |
@@ -58,6 +58,7 @@
|
||||
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */; };
|
||||
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507228AC218300C746DD /* DaoExtensions.swift */; };
|
||||
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507428AEF93100C746DD /* PlayerSettings.swift */; };
|
||||
E9DFCBFB28C28F4A00B36356 /* AudioPlayerSleepTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */; };
|
||||
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E985F728B02D9400957F23 /* PlayerProgress.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -117,6 +118,7 @@
|
||||
E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadItemPart.swift; sourceTree = "<group>"; };
|
||||
E9D5507228AC218300C746DD /* DaoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaoExtensions.swift; sourceTree = "<group>"; };
|
||||
E9D5507428AEF93100C746DD /* PlayerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSettings.swift; sourceTree = "<group>"; };
|
||||
E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerSleepTimer.swift; sourceTree = "<group>"; };
|
||||
E9E985F728B02D9400957F23 /* PlayerProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProgress.swift; sourceTree = "<group>"; };
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -145,6 +147,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */,
|
||||
E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */,
|
||||
3ABF618E2804325C0070250E /* PlayerHandler.swift */,
|
||||
E9E985F728B02D9400957F23 /* PlayerProgress.swift */,
|
||||
);
|
||||
@@ -438,6 +441,7 @@
|
||||
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */,
|
||||
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */,
|
||||
E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */,
|
||||
E9DFCBFB28C28F4A00B36356 /* AudioPlayerSleepTimer.swift in Sources */,
|
||||
E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */,
|
||||
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */,
|
||||
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
|
||||
@@ -595,12 +599,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
CURRENT_PROJECT_VERSION = 15;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.57;
|
||||
MARKETING_VERSION = 0.9.58;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -619,12 +623,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
CURRENT_PROJECT_VERSION = 15;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.57;
|
||||
MARKETING_VERSION = 0.9.58;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -6,6 +6,7 @@ import RealmSwift
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
var backgroundCompletionHandler: (() -> Void)?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
@@ -72,6 +73,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
|
||||
// Stores the completion handler for background downloads
|
||||
// The identifier of this method can be ignored at this time as we only have one background url session
|
||||
backgroundCompletionHandler = completionHandler
|
||||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
|
||||
@@ -169,7 +169,7 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
@objc func decreaseSleepTime(_ call: CAPPluginCall) {
|
||||
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
||||
guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
|
||||
guard let _ = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
|
||||
guard let _ = PlayerHandler.getSleepTimeRemaining() else { return call.resolve([ "success": false ]) }
|
||||
|
||||
let seconds = time/1000
|
||||
PlayerHandler.decreaseSleepTime(decreaseSeconds: seconds)
|
||||
@@ -179,7 +179,7 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
@objc func increaseSleepTime(_ call: CAPPluginCall) {
|
||||
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
||||
guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
|
||||
guard let _ = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
|
||||
guard let _ = PlayerHandler.getSleepTimeRemaining() else { return call.resolve([ "success": false ]) }
|
||||
|
||||
let seconds = time/1000
|
||||
PlayerHandler.increaseSleepTime(increaseSeconds: seconds)
|
||||
@@ -188,30 +188,29 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
|
||||
@objc func setSleepTimer(_ call: CAPPluginCall) {
|
||||
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
||||
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
|
||||
guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
|
||||
let isChapterTime = call.getBool("isChapterTime", false)
|
||||
|
||||
let seconds = time / 1000
|
||||
|
||||
NSLog("chapter time: \(isChapterTime)")
|
||||
if isChapterTime {
|
||||
PlayerHandler.setChapterSleepTime(stopAt: Double(seconds))
|
||||
PlayerHandler.setChapterSleepTime(stopAt: seconds)
|
||||
return call.resolve([ "success": true ])
|
||||
} else {
|
||||
PlayerHandler.setSleepTime(secondsUntilSleep: seconds)
|
||||
call.resolve([ "success": true ])
|
||||
}
|
||||
|
||||
PlayerHandler.setSleepTime(secondsUntilSleep: Double(seconds))
|
||||
call.resolve([ "success": true ])
|
||||
}
|
||||
|
||||
@objc func cancelSleepTimer(_ call: CAPPluginCall) {
|
||||
PlayerHandler.cancelSleepTime()
|
||||
PlayerHandler.sleepTimerChapterStopTime = nil
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func getSleepTimerTime(_ call: CAPPluginCall) {
|
||||
call.resolve([
|
||||
"value": PlayerHandler.remainingSleepTime
|
||||
"value": PlayerHandler.getSleepTimeRemaining()
|
||||
])
|
||||
}
|
||||
|
||||
@@ -223,7 +222,7 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
|
||||
@objc func sendSleepTimerSet() {
|
||||
self.notifyListeners("onSleepTimerSet", data: [
|
||||
"value": PlayerHandler.remainingSleepTime
|
||||
"value": PlayerHandler.getSleepTimeRemaining()
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,10 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
|
||||
private lazy var session: URLSession = {
|
||||
let config = URLSessionConfiguration.background(withIdentifier: "AbsDownloader")
|
||||
let queue = OperationQueue()
|
||||
queue.maxConcurrentOperationCount = 5
|
||||
return URLSession(configuration: .default, delegate: self, delegateQueue: queue)
|
||||
return URLSession(configuration: config, delegate: self, delegateQueue: queue)
|
||||
}()
|
||||
private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent)
|
||||
private var downloadItemProgress = [String: DownloadItem]()
|
||||
@@ -78,6 +79,18 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// Called when downloads are complete on the background thread
|
||||
public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||
DispatchQueue.main.async {
|
||||
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
|
||||
let backgroundCompletionHandler =
|
||||
appDelegate.backgroundCompletionHandler else {
|
||||
return
|
||||
}
|
||||
backgroundCompletionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) {
|
||||
do {
|
||||
guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription }
|
||||
|
||||
@@ -157,7 +157,19 @@ extension LocalMediaProgress {
|
||||
}
|
||||
}
|
||||
|
||||
static func getLocalMediaProgressId(localLibraryItemId: String?, localEpisodeId: String?) -> String? {
|
||||
if let itemId = localLibraryItemId, let episodeId = localEpisodeId {
|
||||
return "\(itemId)-\(episodeId)"
|
||||
} else if let itemId = localLibraryItemId {
|
||||
return itemId
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func fetchOrCreateLocalMediaProgress(localMediaProgressId: String?, localLibraryItemId: String?, localEpisodeId: String?) throws -> LocalMediaProgress? {
|
||||
let localMediaProgressId = localMediaProgressId != nil ? localMediaProgressId : LocalMediaProgress.getLocalMediaProgressId(localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
|
||||
|
||||
let realm = try Realm()
|
||||
return try realm.write { () -> LocalMediaProgress? in
|
||||
if let localMediaProgressId = localMediaProgressId {
|
||||
|
||||
@@ -19,6 +19,13 @@ class AudioTrack: EmbeddedObject, Codable {
|
||||
@Persisted var localFileId: String?
|
||||
@Persisted var serverIndex: Int?
|
||||
|
||||
var endOffset: Double? {
|
||||
if let startOffset = startOffset {
|
||||
return startOffset + duration
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case index, startOffset, duration, title, contentUrl, mimeType, metadata, localFileId, serverIndex
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ enum PlayMethod:Int {
|
||||
}
|
||||
|
||||
class AudioPlayer: NSObject {
|
||||
private let queue = DispatchQueue(label: "ABSAudioPlayerQueue")
|
||||
internal let queue = DispatchQueue(label: "ABSAudioPlayerQueue")
|
||||
|
||||
// enums and @objc are not compatible
|
||||
@objc dynamic var status: Int
|
||||
@@ -32,17 +32,20 @@ class AudioPlayer: NSObject {
|
||||
private var playWhenReady: Bool
|
||||
private var initialPlaybackRate: Float
|
||||
|
||||
private var audioPlayer: AVQueuePlayer
|
||||
internal var audioPlayer: AVQueuePlayer
|
||||
private var sessionId: String
|
||||
|
||||
private var timeObserverToken: Any?
|
||||
private var queueObserver:NSKeyValueObservation?
|
||||
private var queueItemStatusObserver:NSKeyValueObservation?
|
||||
|
||||
private var sleepTimeStopAt: Double?
|
||||
private var sleepTimeToken: Any?
|
||||
// Sleep timer values
|
||||
internal var sleepTimeChapterStopAt: Double?
|
||||
internal var sleepTimeChapterToken: Any?
|
||||
internal var sleepTimer: Timer?
|
||||
internal var sleepTimeRemaining: Double?
|
||||
|
||||
private var currentTrackIndex = 0
|
||||
internal var currentTrackIndex = 0
|
||||
private var allPlayerItems:[AVPlayerItem] = []
|
||||
|
||||
private var pausedTimer: Timer?
|
||||
@@ -63,15 +66,23 @@ class AudioPlayer: NSObject {
|
||||
initAudioSession()
|
||||
setupRemoteTransportControls()
|
||||
|
||||
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
|
||||
let playbackSession = self.getPlaybackSession()
|
||||
guard let playbackSession = playbackSession else {
|
||||
NSLog("Failed to fetch playback session. Player will not initialize")
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Listen to player events
|
||||
self.setupAudioSessionNotifications()
|
||||
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext)
|
||||
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext)
|
||||
|
||||
for track in playbackSession.audioTracks {
|
||||
let playerItem = AVPlayerItem(asset: createAsset(itemId: playbackSession.libraryItemId!, track: track))
|
||||
self.allPlayerItems.append(playerItem)
|
||||
if let playerAsset = createAsset(itemId: playbackSession.libraryItemId!, track: track) {
|
||||
let playerItem = AVPlayerItem(asset: playerAsset)
|
||||
self.allPlayerItems.append(playerItem)
|
||||
}
|
||||
}
|
||||
|
||||
self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime)
|
||||
@@ -90,20 +101,21 @@ class AudioPlayer: NSObject {
|
||||
|
||||
NSLog("Audioplayer ready")
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.stopPausedTimer()
|
||||
self.removeSleepTimer()
|
||||
self.removeTimeObserver()
|
||||
self.queueObserver?.invalidate()
|
||||
self.queueItemStatusObserver?.invalidate()
|
||||
destroy()
|
||||
}
|
||||
|
||||
public func destroy() {
|
||||
// Pause is not synchronous causing this error on below lines:
|
||||
// AVAudioSession_iOS.mm:1206 Deactivating an audio session that has running I/O. All I/O should be stopped or paused prior to deactivating the audio session
|
||||
// It is related to L79 `AVAudioSession.sharedInstance().setActive(false)`
|
||||
pause()
|
||||
audioPlayer.replaceCurrentItem(with: nil)
|
||||
self.pause()
|
||||
self.audioPlayer.replaceCurrentItem(with: nil)
|
||||
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(false)
|
||||
@@ -112,18 +124,28 @@ class AudioPlayer: NSObject {
|
||||
print(error)
|
||||
}
|
||||
|
||||
self.removeAudioSessionNotifications()
|
||||
DispatchQueue.runOnMainQueue {
|
||||
UIApplication.shared.endReceivingRemoteControlEvents()
|
||||
}
|
||||
|
||||
// Remove observers
|
||||
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.rate), context: &playerContext)
|
||||
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), context: &playerContext)
|
||||
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
|
||||
}
|
||||
|
||||
func isInitialized() -> Bool {
|
||||
public func isInitialized() -> Bool {
|
||||
return self.status != -1
|
||||
}
|
||||
|
||||
func getItemIndexForTime(time:Double) -> Int {
|
||||
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
|
||||
public func getPlaybackSession() -> PlaybackSession? {
|
||||
return Database.shared.getPlaybackSession(id: self.sessionId)
|
||||
}
|
||||
|
||||
private func getItemIndexForTime(time:Double) -> Int {
|
||||
guard let playbackSession = self.getPlaybackSession() else { return 0 }
|
||||
for index in 0..<self.allPlayerItems.count {
|
||||
let startOffset = playbackSession.audioTracks[index].startOffset ?? 0.0
|
||||
let duration = playbackSession.audioTracks[index].duration
|
||||
@@ -135,6 +157,16 @@ class AudioPlayer: NSObject {
|
||||
return 0
|
||||
}
|
||||
|
||||
private func setupAudioSessionNotifications() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(handleInteruption), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(handleRouteChange), name: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance())
|
||||
}
|
||||
|
||||
private func removeAudioSessionNotifications() {
|
||||
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
|
||||
NotificationCenter.default.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance())
|
||||
}
|
||||
|
||||
private func setupTimeObserver() {
|
||||
// Time observer should be configured on the main queue
|
||||
DispatchQueue.runOnMainQueue {
|
||||
@@ -145,14 +177,24 @@ class AudioPlayer: NSObject {
|
||||
let seconds = 0.5 * (self.rate > 0 ? self.rate : 1.0)
|
||||
let time = CMTime(seconds: Double(seconds), preferredTimescale: timeScale)
|
||||
self.timeObserverToken = self.audioPlayer.addPeriodicTimeObserver(forInterval: time, queue: self.queue) { [weak self] time in
|
||||
guard let self = self else { return }
|
||||
|
||||
guard let currentTime = self.getCurrentTime() else { return }
|
||||
let isPlaying = self.isPlaying()
|
||||
|
||||
Task {
|
||||
// Let the player update the current playback positions
|
||||
await PlayerProgress.shared.syncFromPlayer(currentTime: time.seconds, includesPlayProgress: true, isStopping: false)
|
||||
await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: isPlaying, isStopping: false)
|
||||
}
|
||||
|
||||
// Update the sleep time, if set
|
||||
if self?.sleepTimeStopAt != nil {
|
||||
if self.isSleepTimerSet() {
|
||||
// Update the UI
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
|
||||
|
||||
// Handle a sitation where the user skips past the chapter end
|
||||
if self.isChapterSleepTimerBeforeTime(currentTime) {
|
||||
self.removeSleepTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,8 +207,9 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func setupQueueObserver() {
|
||||
self.queueObserver = self.audioPlayer.observe(\.currentItem, options: [.new]) {_,_ in
|
||||
private func setupQueueObserver() {
|
||||
self.queueObserver = self.audioPlayer.observe(\.currentItem, options: [.new]) { [weak self] _,_ in
|
||||
guard let self = self else { return }
|
||||
let prevTrackIndex = self.currentTrackIndex
|
||||
self.audioPlayer.currentItem.map { item in
|
||||
self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0
|
||||
@@ -177,33 +220,50 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func setupQueueItemStatusObserver() {
|
||||
private func setupQueueItemStatusObserver() {
|
||||
NSLog("queueStatusObserver: Setting up")
|
||||
|
||||
// Listen for player item updates
|
||||
self.queueItemStatusObserver?.invalidate()
|
||||
self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { (playerItem, change) in
|
||||
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
|
||||
if (playerItem.status == .readyToPlay) {
|
||||
NSLog("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
|
||||
self.updateNowPlaying()
|
||||
|
||||
// Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session
|
||||
let firstReady = self.status < 0
|
||||
if firstReady || self.playWhenReady {
|
||||
self.seek(playbackSession.currentTime, from: "queueItemStatusObserver")
|
||||
}
|
||||
|
||||
// Mark the player as ready
|
||||
self.status = 0
|
||||
|
||||
// Start the player, if requested
|
||||
if self.playWhenReady {
|
||||
self.playWhenReady = false
|
||||
self.play()
|
||||
}
|
||||
} else if (playerItem.status == .failed) {
|
||||
NSLog("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||
}
|
||||
self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { [weak self] playerItem, change in
|
||||
self?.handleQueueItemStatus(playerItem: playerItem)
|
||||
})
|
||||
|
||||
// Ensure we didn't miss a player item update during initialization
|
||||
if let playerItem = self.audioPlayer.currentItem {
|
||||
self.handleQueueItemStatus(playerItem: playerItem)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleQueueItemStatus(playerItem: AVPlayerItem) {
|
||||
NSLog("queueStatusObserver: Current item status changed")
|
||||
guard let playbackSession = self.getPlaybackSession() else {
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||
return
|
||||
}
|
||||
if (playerItem.status == .readyToPlay) {
|
||||
NSLog("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
|
||||
|
||||
// Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session
|
||||
let firstReady = self.status < 0
|
||||
if firstReady && !self.playWhenReady {
|
||||
// Seek is async, and if we call this when also pressing play, we will get weird jumps in the scrub bar depending on timing
|
||||
// Seeking to the correct position happens during play()
|
||||
self.seek(playbackSession.currentTime, from: "queueItemStatusObserver")
|
||||
}
|
||||
|
||||
// Mark the player as ready
|
||||
self.status = 0
|
||||
|
||||
// Start the player, if requested
|
||||
if self.playWhenReady {
|
||||
self.playWhenReady = false
|
||||
self.play()
|
||||
}
|
||||
} else if (playerItem.status == .failed) {
|
||||
NSLog("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func startPausedTimer() {
|
||||
@@ -224,66 +284,81 @@ class AudioPlayer: NSObject {
|
||||
// MARK: - Methods
|
||||
public func play(allowSeekBack: Bool = false) {
|
||||
guard self.isInitialized() else { return }
|
||||
|
||||
// Capture remaining sleep time before changing the track position
|
||||
let sleepSecondsRemaining = PlayerHandler.remainingSleepTime
|
||||
|
||||
if allowSeekBack, let session = Database.shared.getPlaybackSession(id: self.sessionId) {
|
||||
let lastPlayed = (session.updatedAt ?? 0)/1000
|
||||
let difference = Date.timeIntervalSinceReferenceDate - lastPlayed
|
||||
var time: Int?
|
||||
|
||||
if lastPlayed == 0 {
|
||||
time = 5
|
||||
} else if difference < 6 {
|
||||
time = 2
|
||||
} else if difference < 12 {
|
||||
time = 10
|
||||
} else if difference < 30 {
|
||||
time = 15
|
||||
} else if difference < 180 {
|
||||
time = 20
|
||||
} else if difference < 3600 {
|
||||
time = 25
|
||||
} else {
|
||||
time = 29
|
||||
}
|
||||
|
||||
if time != nil {
|
||||
seek(getCurrentTime() - Double(time!), from: "play")
|
||||
}
|
||||
guard let session = self.getPlaybackSession() else {
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine where we are starting playback
|
||||
let lastPlayed = (session.updatedAt ?? 0)/1000
|
||||
let currentTime = allowSeekBack ? calculateSeekBackTimeAtCurrentTime(session.currentTime, lastPlayed: lastPlayed) : session.currentTime
|
||||
|
||||
// Sync our new playback position
|
||||
Task { await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: self.isPlaying(), isStopping: false) }
|
||||
|
||||
// Start playback, with a seek, for as smooth a scrub bar start as possible
|
||||
let currentTrackStartOffset = session.audioTracks[self.currentTrackIndex].startOffset ?? 0.0
|
||||
let seekTime = currentTime - currentTrackStartOffset
|
||||
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] completed in
|
||||
guard completed else { return }
|
||||
self?.resumePlayback()
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateSeekBackTimeAtCurrentTime(_ currentTime: Double, lastPlayed: Double) -> Double {
|
||||
let difference = Date.timeIntervalSinceReferenceDate - lastPlayed
|
||||
var time: Double = 0
|
||||
|
||||
// Scale seek back time based on how long since last play
|
||||
if lastPlayed == 0 {
|
||||
time = 5
|
||||
} else if difference < 6 {
|
||||
time = 2
|
||||
} else if difference < 12 {
|
||||
time = 10
|
||||
} else if difference < 30 {
|
||||
time = 15
|
||||
} else if difference < 180 {
|
||||
time = 20
|
||||
} else if difference < 3600 {
|
||||
time = 25
|
||||
} else {
|
||||
time = 29
|
||||
}
|
||||
|
||||
// Wind the clock back
|
||||
return currentTime - time
|
||||
}
|
||||
|
||||
private func resumePlayback() {
|
||||
NSLog("PLAY: Resuming playback")
|
||||
|
||||
// Stop the paused timer
|
||||
self.stopPausedTimer()
|
||||
|
||||
Task {
|
||||
let isPlaying = self.status > 0
|
||||
await PlayerProgress.shared.syncFromPlayer(currentTime: self.getCurrentTime(), includesPlayProgress: isPlaying, isStopping: false)
|
||||
}
|
||||
|
||||
self.markAudioSessionAs(active: true)
|
||||
self.audioPlayer.play()
|
||||
self.status = 1
|
||||
self.rate = self.tmpRate
|
||||
self.audioPlayer.rate = self.tmpRate
|
||||
self.status = 1
|
||||
|
||||
// If we have an active sleep timer, reschedule based on rate
|
||||
self.rescheduleSleepTimerAtTime(time: self.getCurrentTime(), secondsRemaining: sleepSecondsRemaining)
|
||||
|
||||
updateNowPlaying()
|
||||
// Update the progress
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
|
||||
public func pause() {
|
||||
guard self.isInitialized() else { return }
|
||||
|
||||
NSLog("PAUSE: Pausing playback")
|
||||
self.audioPlayer.pause()
|
||||
self.markAudioSessionAs(active: false)
|
||||
|
||||
Task {
|
||||
let wasPlaying = self.status > 0
|
||||
await PlayerProgress.shared.syncFromPlayer(currentTime: self.getCurrentTime(), includesPlayProgress: wasPlaying, isStopping: true)
|
||||
if let currentTime = self.getCurrentTime() {
|
||||
await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: self.isPlaying(), isStopping: true)
|
||||
}
|
||||
}
|
||||
|
||||
self.status = 0
|
||||
self.rate = 0.0
|
||||
|
||||
updateNowPlaying()
|
||||
|
||||
@@ -293,22 +368,19 @@ class AudioPlayer: NSObject {
|
||||
public func seek(_ to: Double, from: String) {
|
||||
let continuePlaying = rate > 0.0
|
||||
|
||||
pause()
|
||||
self.pause()
|
||||
|
||||
NSLog("Seek to \(to) from \(from)")
|
||||
NSLog("SEEK: Seek to \(to) from \(from)")
|
||||
|
||||
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
|
||||
guard let playbackSession = self.getPlaybackSession() else { return }
|
||||
|
||||
let currentTrack = playbackSession.audioTracks[self.currentTrackIndex]
|
||||
let ctso = currentTrack.startOffset ?? 0.0
|
||||
let trackEnd = ctso + currentTrack.duration
|
||||
NSLog("Seek current track END = \(trackEnd)")
|
||||
|
||||
// Capture remaining sleep time before changing the track position
|
||||
let sleepSecondsRemaining = PlayerHandler.remainingSleepTime
|
||||
NSLog("SEEK: Seek current track END = \(trackEnd)")
|
||||
|
||||
let indexOfSeek = getItemIndexForTime(time: to)
|
||||
NSLog("Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
|
||||
NSLog("SEEK: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
|
||||
|
||||
// Reconstruct queue if seeking to a different track
|
||||
if (self.currentTrackIndex != indexOfSeek) {
|
||||
@@ -329,32 +401,24 @@ class AudioPlayer: NSObject {
|
||||
|
||||
setupQueueItemStatusObserver()
|
||||
} else {
|
||||
NSLog("Seeking in current item \(to)")
|
||||
NSLog("SEEK: Seeking in current item \(to)")
|
||||
let currentTrackStartOffset = playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0
|
||||
let seekTime = to - currentTrackStartOffset
|
||||
|
||||
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { [weak self] completed in
|
||||
if !completed {
|
||||
NSLog("WARNING: seeking not completed (to \(seekTime)")
|
||||
}
|
||||
guard completed else { return NSLog("SEEK: WARNING: seeking not completed (to \(seekTime)") }
|
||||
guard let self = self else { return }
|
||||
|
||||
if continuePlaying {
|
||||
self?.play()
|
||||
self.resumePlayback()
|
||||
}
|
||||
self?.updateNowPlaying()
|
||||
|
||||
// If we have an active sleep timer, reschedule based on seek, since seek is fuzzy
|
||||
// This needs to occur after play() to capture the correct playback rate
|
||||
if let currentTime = self?.getCurrentTime() {
|
||||
self?.rescheduleSleepTimerAtTime(time: currentTime, secondsRemaining: sleepSecondsRemaining)
|
||||
}
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func setPlaybackRate(_ rate: Float, observed: Bool = false) {
|
||||
// Capture remaining sleep time before changing the rate
|
||||
let sleepSecondsRemaining = PlayerHandler.remainingSleepTime
|
||||
let playbackSpeedChanged = rate > 0.0 && rate != self.tmpRate && !(observed && rate == 1)
|
||||
|
||||
if self.audioPlayer.rate != rate {
|
||||
@@ -368,144 +432,41 @@ class AudioPlayer: NSObject {
|
||||
if playbackSpeedChanged {
|
||||
self.tmpRate = rate
|
||||
|
||||
// If we have an active sleep timer, reschedule based on rate
|
||||
self.rescheduleSleepTimerAtTime(time: self.getCurrentTime(), secondsRemaining: sleepSecondsRemaining)
|
||||
|
||||
// Setup the time observer again at the new rate
|
||||
self.setupTimeObserver()
|
||||
}
|
||||
}
|
||||
|
||||
public func getSleepStopAt() -> Double? {
|
||||
return self.sleepTimeStopAt
|
||||
}
|
||||
|
||||
// Let iOS handle the sleep timer logic by letting us know when it's time to stop
|
||||
public func setSleepTime(stopAt: Double, scaleBasedOnSpeed: Bool = false) {
|
||||
NSLog("SLEEP TIMER: Scheduling for \(stopAt)")
|
||||
|
||||
// Reset any previous sleep timer
|
||||
self.removeSleepTimer()
|
||||
|
||||
let currentTime = getCurrentTime()
|
||||
|
||||
// Mark the time to stop playing
|
||||
if scaleBasedOnSpeed {
|
||||
// Consider paused as playing at 1x
|
||||
let rate = Double(self.rate > 0 ? self.rate : 1)
|
||||
|
||||
// Calculate the scaled time to stop at
|
||||
let timeUntilSleep = (stopAt - currentTime) * rate
|
||||
self.sleepTimeStopAt = currentTime + timeUntilSleep
|
||||
|
||||
NSLog("SLEEP TIMER: Adjusted based on playback speed of \(rate) to \(self.sleepTimeStopAt!)")
|
||||
} else {
|
||||
self.sleepTimeStopAt = stopAt
|
||||
}
|
||||
|
||||
guard let sleepTimeStopAt = self.sleepTimeStopAt else { return }
|
||||
let sleepTime = CMTime(seconds: sleepTimeStopAt, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
||||
|
||||
// Schedule the observation time
|
||||
var times = [NSValue]()
|
||||
times.append(NSValue(time: sleepTime))
|
||||
|
||||
sleepTimeToken = self.audioPlayer.addBoundaryTimeObserver(forTimes: times, queue: queue) { [weak self] in
|
||||
NSLog("SLEEP TIMER: Pausing audio")
|
||||
self?.pause()
|
||||
PlayerHandler.sleepTimerChapterStopTime = nil
|
||||
self?.removeSleepTimer()
|
||||
}
|
||||
|
||||
// Update the UI
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
|
||||
}
|
||||
|
||||
private func rescheduleSleepTimerAtTime(time: Double, secondsRemaining: Int?) {
|
||||
// Not a chapter sleep timer
|
||||
let hadToCancelChapterSleepTimer = decideIfChapterSleepTimerNeedsToBeCanceled(time: time)
|
||||
guard !hadToCancelChapterSleepTimer else { return }
|
||||
guard PlayerHandler.sleepTimerChapterStopTime == nil else { return }
|
||||
|
||||
// Verify sleep timer is set
|
||||
guard self.sleepTimeToken != nil else { return }
|
||||
|
||||
// Update the sleep timer
|
||||
if let secondsRemaining = secondsRemaining {
|
||||
let newSleepTimerPosition = time + Double(secondsRemaining)
|
||||
self.setSleepTime(stopAt: newSleepTimerPosition, scaleBasedOnSpeed: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func decideIfChapterSleepTimerNeedsToBeCanceled(time: Double) -> Bool {
|
||||
if let chapterSleepTime = PlayerHandler.sleepTimerChapterStopTime {
|
||||
let sleepIsBeforeCurrentTime = Double(chapterSleepTime) <= time
|
||||
if sleepIsBeforeCurrentTime {
|
||||
PlayerHandler.sleepTimerChapterStopTime = nil
|
||||
self.removeSleepTimer()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
public func increaseSleepTime(extraTimeInSeconds: Double) {
|
||||
if let sleepTime = PlayerHandler.remainingSleepTime {
|
||||
let currentTime = getCurrentTime()
|
||||
let newSleepTimerPosition = currentTime + Double(sleepTime) + extraTimeInSeconds
|
||||
if newSleepTimerPosition > currentTime {
|
||||
self.setSleepTime(stopAt: newSleepTimerPosition, scaleBasedOnSpeed: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func decreaseSleepTime(removeTimeInSeconds: Double) {
|
||||
if let sleepTime = PlayerHandler.remainingSleepTime {
|
||||
let currentTime = getCurrentTime()
|
||||
let newSleepTimerPosition = currentTime + Double(sleepTime) - removeTimeInSeconds
|
||||
guard newSleepTimerPosition > currentTime else { return }
|
||||
if newSleepTimerPosition > currentTime {
|
||||
self.setSleepTime(stopAt: newSleepTimerPosition, scaleBasedOnSpeed: true)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public func removeSleepTimer() {
|
||||
self.sleepTimeStopAt = nil
|
||||
if let token = sleepTimeToken {
|
||||
self.audioPlayer.removeTimeObserver(token)
|
||||
sleepTimeToken = nil
|
||||
}
|
||||
|
||||
// Update the UI
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: self)
|
||||
}
|
||||
|
||||
public func getCurrentTime() -> Double {
|
||||
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
|
||||
public func getCurrentTime() -> Double? {
|
||||
guard let playbackSession = self.getPlaybackSession() else { return nil }
|
||||
let currentTrackTime = self.audioPlayer.currentTime().seconds
|
||||
let audioTrack = playbackSession.audioTracks[currentTrackIndex]
|
||||
let startOffset = audioTrack.startOffset ?? 0.0
|
||||
return startOffset + currentTrackTime
|
||||
}
|
||||
|
||||
public func getPlayMethod() -> Int {
|
||||
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
|
||||
public func getPlayMethod() -> Int? {
|
||||
guard let playbackSession = self.getPlaybackSession() else { return nil }
|
||||
return playbackSession.playMethod
|
||||
}
|
||||
|
||||
public func getPlaybackSessionId() -> String {
|
||||
return self.sessionId
|
||||
}
|
||||
public func getDuration() -> Double {
|
||||
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
|
||||
|
||||
public func getDuration() -> Double? {
|
||||
guard let playbackSession = self.getPlaybackSession() else { return nil }
|
||||
return playbackSession.duration
|
||||
}
|
||||
|
||||
public func isPlaying() -> Bool {
|
||||
return self.status > 0
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
private func createAsset(itemId:String, track:AudioTrack) -> AVAsset {
|
||||
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
|
||||
private func createAsset(itemId:String, track:AudioTrack) -> AVAsset? {
|
||||
guard let playbackSession = self.getPlaybackSession() else { return nil }
|
||||
|
||||
if (playbackSession.playMethod == PlayMethod.directplay.rawValue) {
|
||||
// The only reason this is separate is because the filename needs to be encoded
|
||||
let filename = track.metadata?.filename ?? ""
|
||||
@@ -535,13 +496,71 @@ class AudioPlayer: NSObject {
|
||||
private func initAudioSession() {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
NSLog("Failed to set AVAudioSession category")
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func markAudioSessionAs(active: Bool) {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(active)
|
||||
} catch {
|
||||
NSLog("Failed to set audio session as active=\(active)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iOS audio session notifications
|
||||
@objc private func handleInteruption(notification: Notification) {
|
||||
guard let userInfo = notification.userInfo,
|
||||
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
|
||||
return
|
||||
}
|
||||
|
||||
switch type {
|
||||
case .ended:
|
||||
guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
|
||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||
if options.contains(.shouldResume) {
|
||||
self.play(allowSeekBack: true)
|
||||
}
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleRouteChange(notification: Notification) {
|
||||
guard let userInfo = notification.userInfo,
|
||||
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
|
||||
return
|
||||
}
|
||||
|
||||
switch reason {
|
||||
case .newDeviceAvailable: // New device found.
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let headphonesConnected = hasHeadphones(in: session.currentRoute)
|
||||
if headphonesConnected {
|
||||
// We should just let things be, as it's okay to go from speaker to headphones
|
||||
}
|
||||
case .oldDeviceUnavailable: // Old device removed.
|
||||
if let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription {
|
||||
let headphonesWereConnected = hasHeadphones(in: previousRoute)
|
||||
if headphonesWereConnected {
|
||||
// Removing headphones we should pause instead of keeping on playing
|
||||
self.pause()
|
||||
}
|
||||
}
|
||||
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
|
||||
private func hasHeadphones(in routeDescription: AVAudioSessionRouteDescription) -> Bool {
|
||||
// Filter the outputs to only those with a port type of headphones.
|
||||
return !routeDescription.outputs.filter({$0.portType == .headphones}).isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Now playing
|
||||
private func setupRemoteTransportControls() {
|
||||
DispatchQueue.runOnMainQueue {
|
||||
@@ -551,61 +570,67 @@ class AudioPlayer: NSObject {
|
||||
let deviceSettings = Database.shared.getDeviceSettings()
|
||||
|
||||
commandCenter.playCommand.isEnabled = true
|
||||
commandCenter.playCommand.addTarget { [unowned self] event in
|
||||
play(allowSeekBack: true)
|
||||
commandCenter.playCommand.addTarget { [weak self] event in
|
||||
self?.play(allowSeekBack: true)
|
||||
return .success
|
||||
}
|
||||
commandCenter.pauseCommand.isEnabled = true
|
||||
commandCenter.pauseCommand.addTarget { [unowned self] event in
|
||||
pause()
|
||||
commandCenter.pauseCommand.addTarget { [weak self] event in
|
||||
self?.pause()
|
||||
return .success
|
||||
}
|
||||
|
||||
commandCenter.skipForwardCommand.isEnabled = true
|
||||
commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: deviceSettings.jumpForwardTime)]
|
||||
commandCenter.skipForwardCommand.addTarget { [unowned self] event in
|
||||
commandCenter.skipForwardCommand.addTarget { [weak self] event in
|
||||
guard let command = event.command as? MPSkipIntervalCommand else {
|
||||
return .noSuchContent
|
||||
}
|
||||
|
||||
seek(getCurrentTime() + command.preferredIntervals[0].doubleValue, from: "remote")
|
||||
guard let currentTime = self?.getCurrentTime() else {
|
||||
return .commandFailed
|
||||
}
|
||||
self?.seek(currentTime + command.preferredIntervals[0].doubleValue, from: "remote")
|
||||
return .success
|
||||
}
|
||||
commandCenter.skipBackwardCommand.isEnabled = true
|
||||
commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: deviceSettings.jumpBackwardsTime)]
|
||||
commandCenter.skipBackwardCommand.addTarget { [unowned self] event in
|
||||
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
|
||||
guard let command = event.command as? MPSkipIntervalCommand else {
|
||||
return .noSuchContent
|
||||
}
|
||||
|
||||
seek(getCurrentTime() - command.preferredIntervals[0].doubleValue, from: "remote")
|
||||
guard let currentTime = self?.getCurrentTime() else {
|
||||
return .commandFailed
|
||||
}
|
||||
self?.seek(currentTime - command.preferredIntervals[0].doubleValue, from: "remote")
|
||||
return .success
|
||||
}
|
||||
|
||||
commandCenter.changePlaybackPositionCommand.isEnabled = true
|
||||
commandCenter.changePlaybackPositionCommand.addTarget { event in
|
||||
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||
guard let event = event as? MPChangePlaybackPositionCommandEvent else {
|
||||
return .noSuchContent
|
||||
}
|
||||
|
||||
self.seek(event.positionTime, from: "remote")
|
||||
self?.seek(event.positionTime, from: "remote")
|
||||
return .success
|
||||
}
|
||||
|
||||
commandCenter.changePlaybackRateCommand.isEnabled = true
|
||||
commandCenter.changePlaybackRateCommand.supportedPlaybackRates = [0.5, 0.75, 1.0, 1.25, 1.5, 2]
|
||||
commandCenter.changePlaybackRateCommand.addTarget { event in
|
||||
commandCenter.changePlaybackRateCommand.addTarget { [weak self] event in
|
||||
guard let event = event as? MPChangePlaybackRateCommandEvent else {
|
||||
return .noSuchContent
|
||||
}
|
||||
|
||||
self.setPlaybackRate(event.playbackRate)
|
||||
self?.setPlaybackRate(event.playbackRate)
|
||||
return .success
|
||||
}
|
||||
}
|
||||
private func updateNowPlaying() {
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||
NowPlayingInfo.shared.update(duration: getDuration(), currentTime: getCurrentTime(), rate: rate)
|
||||
if let duration = self.getDuration(), let currentTime = self.getCurrentTime() {
|
||||
NowPlayingInfo.shared.update(duration: duration, currentTime: currentTime, rate: rate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observer
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//
|
||||
// AudioPlayerSleepTimer.swift
|
||||
// App
|
||||
//
|
||||
// Created by Ron Heft on 9/2/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
extension AudioPlayer {
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
public func isSleepTimerSet() -> Bool {
|
||||
return self.isCountdownSleepTimerSet() || self.isChapterSleepTimerSet()
|
||||
}
|
||||
|
||||
public func getSleepTimeRemaining() -> Double? {
|
||||
guard let currentTime = self.getCurrentTime() else { return nil }
|
||||
|
||||
// Return the player time until sleep
|
||||
var sleepTimeRemaining: Double? = nil
|
||||
if let chapterStopAt = self.sleepTimeChapterStopAt {
|
||||
sleepTimeRemaining = (chapterStopAt - currentTime) / Double(self.rate > 0 ? self.rate : 1.0)
|
||||
} else if self.isCountdownSleepTimerSet() {
|
||||
sleepTimeRemaining = self.sleepTimeRemaining
|
||||
}
|
||||
|
||||
return sleepTimeRemaining
|
||||
}
|
||||
|
||||
public func setSleepTimer(secondsUntilSleep: Double) {
|
||||
NSLog("SLEEP TIMER: Sleeping in \(secondsUntilSleep) seconds")
|
||||
self.removeSleepTimer()
|
||||
self.sleepTimeRemaining = secondsUntilSleep
|
||||
|
||||
DispatchQueue.runOnMainQueue {
|
||||
self.sleepTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
if self?.isPlaying() ?? false {
|
||||
self?.decrementSleepTimerIfRunning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the UI
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
|
||||
}
|
||||
|
||||
public func setChapterSleepTimer(stopAt: Double) {
|
||||
NSLog("SLEEP TIMER: Scheduling for chapter end \(stopAt)")
|
||||
self.removeSleepTimer()
|
||||
|
||||
// Schedule the observation time
|
||||
self.sleepTimeChapterStopAt = stopAt
|
||||
|
||||
// Get the current track
|
||||
guard let playbackSession = self.getPlaybackSession() else { return }
|
||||
let currentTrack = playbackSession.audioTracks[currentTrackIndex]
|
||||
|
||||
// Set values
|
||||
guard let trackStartTime = currentTrack.startOffset else { return }
|
||||
guard let trackEndTime = currentTrack.endOffset else { return }
|
||||
|
||||
// Verify the stop is during the current audio track
|
||||
guard trackEndTime >= stopAt else { return }
|
||||
|
||||
// Schedule the observation time
|
||||
let trackBasedStopTime = stopAt - trackStartTime
|
||||
|
||||
let sleepTime = CMTime(seconds: trackBasedStopTime, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
||||
var times = [NSValue]()
|
||||
times.append(NSValue(time: sleepTime))
|
||||
|
||||
self.sleepTimeChapterToken = self.audioPlayer.addBoundaryTimeObserver(forTimes: times, queue: self.queue) { [weak self] in
|
||||
self?.handleSleepEnd()
|
||||
}
|
||||
|
||||
// Update the UI
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
|
||||
}
|
||||
|
||||
public func increaseSleepTime(extraTimeInSeconds: Double) {
|
||||
self.removeChapterSleepTimer()
|
||||
guard let sleepTimeRemaining = self.sleepTimeRemaining else { return }
|
||||
self.sleepTimeRemaining = sleepTimeRemaining + extraTimeInSeconds
|
||||
|
||||
// Update the UI
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
|
||||
}
|
||||
|
||||
public func decreaseSleepTime(removeTimeInSeconds: Double) {
|
||||
self.removeChapterSleepTimer()
|
||||
guard let sleepTimeRemaining = self.sleepTimeRemaining else { return }
|
||||
self.sleepTimeRemaining = sleepTimeRemaining - removeTimeInSeconds
|
||||
|
||||
// Update the UI
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
|
||||
}
|
||||
|
||||
public func removeSleepTimer() {
|
||||
self.sleepTimer?.invalidate()
|
||||
self.sleepTimer = nil
|
||||
self.removeChapterSleepTimer()
|
||||
self.sleepTimeRemaining = nil
|
||||
|
||||
// Update the UI
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: self)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Internal helpers
|
||||
|
||||
internal func decrementSleepTimerIfRunning() {
|
||||
if var sleepTimeRemaining = self.sleepTimeRemaining {
|
||||
sleepTimeRemaining -= 1
|
||||
self.sleepTimeRemaining = sleepTimeRemaining
|
||||
|
||||
// Handle the sleep if the timer has expired
|
||||
if sleepTimeRemaining <= 0 {
|
||||
self.handleSleepEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSleepEnd() {
|
||||
NSLog("SLEEP TIMER: Pausing audio")
|
||||
self.pause()
|
||||
self.removeSleepTimer()
|
||||
}
|
||||
|
||||
private func removeChapterSleepTimer() {
|
||||
if let token = self.sleepTimeChapterToken {
|
||||
self.audioPlayer.removeTimeObserver(token)
|
||||
}
|
||||
self.sleepTimeChapterToken = nil
|
||||
self.sleepTimeChapterStopAt = nil
|
||||
}
|
||||
|
||||
internal func isChapterSleepTimerBeforeTime(_ time: Double) -> Bool {
|
||||
if let chapterStopAt = self.sleepTimeChapterStopAt {
|
||||
return chapterStopAt <= time
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
internal func isCountdownSleepTimerSet() -> Bool {
|
||||
return self.sleepTimeRemaining != nil
|
||||
}
|
||||
|
||||
internal func isChapterSleepTimerSet() -> Bool {
|
||||
return self.sleepTimeChapterStopAt != nil
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import RealmSwift
|
||||
class PlayerHandler {
|
||||
private static var player: AudioPlayer?
|
||||
|
||||
public static var sleepTimerChapterStopTime: Int? = nil
|
||||
|
||||
public static func startPlayback(sessionId: String, playWhenReady: Bool, playbackRate: Float) {
|
||||
guard let session = Database.shared.getPlaybackSession(id: sessionId) else { return }
|
||||
|
||||
@@ -59,33 +57,6 @@ class PlayerHandler {
|
||||
}
|
||||
}
|
||||
|
||||
public static var remainingSleepTime: Int? {
|
||||
get {
|
||||
guard let player = player else { return nil }
|
||||
|
||||
// Return the player time until sleep
|
||||
var timeUntilSleep: Double? = nil
|
||||
if let sleepTimerChapterStopTime = sleepTimerChapterStopTime {
|
||||
timeUntilSleep = Double(sleepTimerChapterStopTime) - player.getCurrentTime()
|
||||
} else if let stopAt = player.getSleepStopAt() {
|
||||
timeUntilSleep = stopAt - player.getCurrentTime()
|
||||
}
|
||||
|
||||
// Scale the time until sleep based on the playback rate
|
||||
if let timeUntilSleep = timeUntilSleep {
|
||||
// Consider paused as playing at 1x
|
||||
let rate = Double(player.rate > 0 ? player.rate : 1)
|
||||
|
||||
let timeUntilSleepScaled = timeUntilSleep / rate
|
||||
guard timeUntilSleepScaled.isNaN == false else { return nil }
|
||||
|
||||
return Int(timeUntilSleepScaled.rounded())
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func getCurrentTime() -> Double? {
|
||||
self.player?.getCurrentTime()
|
||||
}
|
||||
@@ -94,30 +65,27 @@ class PlayerHandler {
|
||||
self.player?.setPlaybackRate(speed)
|
||||
}
|
||||
|
||||
public static func getSleepTimeRemaining() -> Double? {
|
||||
return self.player?.getSleepTimeRemaining()
|
||||
}
|
||||
|
||||
public static func setSleepTime(secondsUntilSleep: Double) {
|
||||
guard let player = player else { return }
|
||||
let stopAt = secondsUntilSleep + player.getCurrentTime()
|
||||
player.setSleepTime(stopAt: stopAt, scaleBasedOnSpeed: true)
|
||||
self.player?.setSleepTimer(secondsUntilSleep: secondsUntilSleep)
|
||||
}
|
||||
|
||||
public static func setChapterSleepTime(stopAt: Double) {
|
||||
guard let player = player else { return }
|
||||
self.sleepTimerChapterStopTime = Int(stopAt)
|
||||
player.setSleepTime(stopAt: stopAt, scaleBasedOnSpeed: false)
|
||||
self.player?.setChapterSleepTimer(stopAt: stopAt)
|
||||
}
|
||||
|
||||
public static func increaseSleepTime(increaseSeconds: Double) {
|
||||
self.sleepTimerChapterStopTime = nil
|
||||
self.player?.increaseSleepTime(extraTimeInSeconds: increaseSeconds)
|
||||
}
|
||||
|
||||
public static func decreaseSleepTime(decreaseSeconds: Double) {
|
||||
self.sleepTimerChapterStopTime = nil
|
||||
self.player?.decreaseSleepTime(removeTimeInSeconds: decreaseSeconds)
|
||||
}
|
||||
|
||||
public static func cancelSleepTime() {
|
||||
PlayerHandler.sleepTimerChapterStopTime = nil
|
||||
self.player?.removeSleepTimer()
|
||||
}
|
||||
|
||||
@@ -134,20 +102,27 @@ class PlayerHandler {
|
||||
|
||||
public static func seekForward(amount: Double) {
|
||||
guard let player = player else { return }
|
||||
guard player.isInitialized() else { return }
|
||||
guard let currentTime = player.getCurrentTime() else { return }
|
||||
|
||||
let destinationTime = player.getCurrentTime() + amount
|
||||
let destinationTime = currentTime + amount
|
||||
player.seek(destinationTime, from: "handler")
|
||||
}
|
||||
|
||||
public static func seekBackward(amount: Double) {
|
||||
guard let player = player else { return }
|
||||
guard player.isInitialized() else { return }
|
||||
guard let currentTime = player.getCurrentTime() else { return }
|
||||
|
||||
let destinationTime = player.getCurrentTime() - amount
|
||||
let destinationTime = currentTime - amount
|
||||
player.seek(destinationTime, from: "handler")
|
||||
}
|
||||
|
||||
public static func seek(amount: Double) {
|
||||
player?.seek(amount, from: "handler")
|
||||
guard let player = player else { return }
|
||||
guard player.isInitialized() else { return }
|
||||
|
||||
player.seek(amount, from: "handler")
|
||||
}
|
||||
|
||||
public static func getMetdata() -> [String: Any]? {
|
||||
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.57-beta",
|
||||
"version": "0.9.58-beta",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.57-beta",
|
||||
"version": "0.9.58-beta",
|
||||
"author": "advplyr",
|
||||
"scripts": {
|
||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||
|
||||
Reference in New Issue
Block a user