mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2025-08-05 02:25:45 +02:00
- Changes icon
- Improved state sync - Moved player to shared
This commit is contained in:
parent
4f1f0e4db7
commit
f98aea71cb
35 changed files with 388 additions and 60 deletions
265
ios/App/Shared/AudioPlayer.swift
Normal file
265
ios/App/Shared/AudioPlayer.swift
Normal file
|
@ -0,0 +1,265 @@
|
|||
//
|
||||
// AudioPlayer.swift
|
||||
// App
|
||||
//
|
||||
// Created by Rasmus Krämer on 07.03.22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
import MediaPlayer
|
||||
|
||||
class AudioPlayer: NSObject {
|
||||
// enums and @objc are not compatible
|
||||
@objc dynamic var status: Int
|
||||
@objc dynamic var rate: Float
|
||||
|
||||
private var tmpRate: Float = 1.0
|
||||
private var lastPlayTime: Double = 0.0
|
||||
|
||||
private var playerContext = 0
|
||||
private var playerItemContext = 0
|
||||
|
||||
private var playWhenReady: Bool
|
||||
|
||||
private var audioPlayer: AVPlayer
|
||||
public var audiobook: Audiobook
|
||||
|
||||
// MARK: - Constructor
|
||||
init(audiobook: Audiobook, playWhenReady: Bool = false) {
|
||||
self.playWhenReady = playWhenReady
|
||||
self.audiobook = audiobook
|
||||
self.audioPlayer = AVPlayer()
|
||||
self.status = -1
|
||||
self.rate = 0.0
|
||||
|
||||
super.init()
|
||||
|
||||
initAudioSession()
|
||||
setupRemoteTransportControls()
|
||||
NowPlayingInfo.setAudiobook(audiobook: audiobook)
|
||||
|
||||
// Listen to player events
|
||||
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext)
|
||||
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext)
|
||||
|
||||
let playerItem = AVPlayerItem(asset: createAsset())
|
||||
playerItem.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.status), options: .new, context: &playerItemContext)
|
||||
|
||||
self.audioPlayer.replaceCurrentItem(with: playerItem)
|
||||
seek(self.audiobook.startTime)
|
||||
|
||||
NSLog("Audioplayer ready")
|
||||
}
|
||||
deinit {
|
||||
destroy()
|
||||
}
|
||||
public func destroy() {
|
||||
pause()
|
||||
audioPlayer.replaceCurrentItem(with: nil)
|
||||
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(false)
|
||||
} catch {
|
||||
NSLog("Failed to set AVAudioSession inactive")
|
||||
print(error)
|
||||
}
|
||||
|
||||
DispatchQueue.main.sync {
|
||||
UIApplication.shared.endReceivingRemoteControlEvents()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Methods
|
||||
public func play(allowSeekBack: Bool = false) {
|
||||
if allowSeekBack {
|
||||
let diffrence = Date.timeIntervalSinceReferenceDate - lastPlayTime
|
||||
var time: Int?
|
||||
|
||||
if lastPlayTime == 0 {
|
||||
time = 5
|
||||
} else if diffrence < 6 {
|
||||
time = 2
|
||||
} else if diffrence < 12 {
|
||||
time = 10
|
||||
} else if diffrence < 30 {
|
||||
time = 15
|
||||
} else if diffrence < 180 {
|
||||
time = 20
|
||||
} else if diffrence < 3600 {
|
||||
time = 25
|
||||
} else {
|
||||
time = 29
|
||||
}
|
||||
|
||||
if time != nil {
|
||||
seek(getCurrentTime() - Double(time!))
|
||||
}
|
||||
}
|
||||
lastPlayTime = Date.timeIntervalSinceReferenceDate
|
||||
|
||||
self.audioPlayer.play()
|
||||
self.status = 1
|
||||
self.rate = self.tmpRate
|
||||
self.audioPlayer.rate = self.tmpRate
|
||||
|
||||
updateNowPlaying()
|
||||
}
|
||||
public func pause() {
|
||||
self.audioPlayer.pause()
|
||||
self.status = 0
|
||||
self.rate = 0.0
|
||||
|
||||
updateNowPlaying()
|
||||
lastPlayTime = Date.timeIntervalSinceReferenceDate
|
||||
}
|
||||
public func seek(_ to: Double) {
|
||||
let continuePlaing = rate > 0.0
|
||||
|
||||
pause()
|
||||
self.audioPlayer.seek(to: CMTime(seconds: to, preferredTimescale: 1000)) { completed in
|
||||
if !completed {
|
||||
NSLog("WARNING: seeking not completed (to \(to)")
|
||||
}
|
||||
|
||||
if continuePlaing {
|
||||
self.play()
|
||||
}
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
public func setPlaybackRate(_ rate: Float, observed: Bool = false) {
|
||||
if self.audioPlayer.rate != rate {
|
||||
self.audioPlayer.rate = rate
|
||||
}
|
||||
if rate > 0.0 && !(observed && rate == 1) {
|
||||
self.tmpRate = rate
|
||||
}
|
||||
|
||||
self.rate = rate
|
||||
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
|
||||
public func getCurrentTime() -> Double {
|
||||
self.audioPlayer.currentTime().seconds
|
||||
}
|
||||
public func getDuration() -> Double {
|
||||
self.audioPlayer.currentItem?.duration.seconds ?? 0
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
private func createAsset() -> AVAsset {
|
||||
let headers: [String: String] = [
|
||||
"Authorization": "Bearer \(audiobook.token)"
|
||||
]
|
||||
|
||||
return AVURLAsset(url: URL(string: audiobook.playlistUrl)!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
|
||||
}
|
||||
private func initAudioSession() {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.allowAirPlay])
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
NSLog("Failed to set AVAudioSession category")
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Now playing
|
||||
private func setupRemoteTransportControls() {
|
||||
DispatchQueue.main.sync {
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
}
|
||||
let commandCenter = MPRemoteCommandCenter.shared()
|
||||
|
||||
commandCenter.playCommand.isEnabled = true
|
||||
commandCenter.playCommand.addTarget { [unowned self] event in
|
||||
play(allowSeekBack: true)
|
||||
return .success
|
||||
}
|
||||
commandCenter.pauseCommand.isEnabled = true
|
||||
commandCenter.pauseCommand.addTarget { [unowned self] event in
|
||||
pause()
|
||||
return .success
|
||||
}
|
||||
|
||||
commandCenter.skipForwardCommand.isEnabled = true
|
||||
commandCenter.skipForwardCommand.preferredIntervals = [30]
|
||||
commandCenter.skipForwardCommand.addTarget { [unowned self] event in
|
||||
guard let command = event.command as? MPSkipIntervalCommand else {
|
||||
return .noSuchContent
|
||||
}
|
||||
|
||||
seek(getCurrentTime() + command.preferredIntervals[0].doubleValue)
|
||||
return .success
|
||||
}
|
||||
commandCenter.skipBackwardCommand.isEnabled = true
|
||||
commandCenter.skipBackwardCommand.preferredIntervals = [30]
|
||||
commandCenter.skipBackwardCommand.addTarget { [unowned self] event in
|
||||
guard let command = event.command as? MPSkipIntervalCommand else {
|
||||
return .noSuchContent
|
||||
}
|
||||
|
||||
seek(getCurrentTime() - command.preferredIntervals[0].doubleValue)
|
||||
return .success
|
||||
}
|
||||
|
||||
commandCenter.changePlaybackPositionCommand.isEnabled = true
|
||||
commandCenter.changePlaybackPositionCommand.addTarget { event in
|
||||
guard let event = event as? MPChangePlaybackPositionCommandEvent else {
|
||||
return .noSuchContent
|
||||
}
|
||||
|
||||
self.seek(event.positionTime)
|
||||
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
|
||||
guard let event = event as? MPChangePlaybackRateCommandEvent else {
|
||||
return .noSuchContent
|
||||
}
|
||||
|
||||
self.setPlaybackRate(event.playbackRate)
|
||||
return .success
|
||||
}
|
||||
}
|
||||
private func updateNowPlaying() {
|
||||
NowPlayingInfo.update(duration: getDuration(), currentTime: getCurrentTime(), rate: rate)
|
||||
}
|
||||
|
||||
// MARK: - Observer
|
||||
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||
if context == &playerItemContext {
|
||||
if keyPath == #keyPath(AVPlayer.status) {
|
||||
guard let playerStatus = AVPlayerItem.Status(rawValue: (change?[.newKey] as? Int ?? -1)) else { return }
|
||||
|
||||
if playerStatus == .readyToPlay {
|
||||
self.updateNowPlaying()
|
||||
|
||||
self.status = 0
|
||||
if self.playWhenReady {
|
||||
self.playWhenReady = false
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if context == &playerContext {
|
||||
if keyPath == #keyPath(AVPlayer.rate) {
|
||||
self.setPlaybackRate(change?[.newKey] as? Float ?? 1.0, observed: true)
|
||||
} else if keyPath == #keyPath(AVPlayer.currentItem) {
|
||||
NSLog("WARNING: Item ended")
|
||||
}
|
||||
} else {
|
||||
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Factory
|
||||
public static var instance: AudioPlayer?
|
||||
}
|
24
ios/App/Shared/models/Audiobook.swift
Normal file
24
ios/App/Shared/models/Audiobook.swift
Normal file
|
@ -0,0 +1,24 @@
|
|||
//
|
||||
// AudioBook.swift
|
||||
// App
|
||||
//
|
||||
// Created by Rasmus Krämer on 07.03.22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Audiobook {
|
||||
var streamId: String
|
||||
var audiobookId: String
|
||||
var playlistUrl: String
|
||||
|
||||
var startTime: Double = 0.0
|
||||
var duration: Double
|
||||
|
||||
var title: String
|
||||
var series: String?
|
||||
var author: String?
|
||||
var artworkUrl: String?
|
||||
|
||||
var token: String
|
||||
}
|
79
ios/App/Shared/util/NowPlayingInfo.swift
Normal file
79
ios/App/Shared/util/NowPlayingInfo.swift
Normal file
|
@ -0,0 +1,79 @@
|
|||
//
|
||||
// NowPlaying.swift
|
||||
// App
|
||||
//
|
||||
// Created by Rasmus Krämer on 22.03.22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MediaPlayer
|
||||
|
||||
func getData(from url: URL, completion: @escaping (UIImage?) -> Void) {
|
||||
URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in
|
||||
if let data = data {
|
||||
completion(UIImage(data:data))
|
||||
}
|
||||
}).resume()
|
||||
}
|
||||
|
||||
class NowPlayingInfo {
|
||||
private static var nowPlayingInfo: [String: Any] = [:]
|
||||
private static var audiobook: Audiobook?
|
||||
|
||||
public static func setAudiobook(audiobook: Audiobook) {
|
||||
self.audiobook = audiobook
|
||||
setMetadata(nil)
|
||||
|
||||
if !shouldFetchCover() || audiobook.artworkUrl == nil {
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = URL(string: audiobook.artworkUrl!) else { return }
|
||||
getData(from: url) { [self] image in
|
||||
guard let downloadedImage = image else {
|
||||
return
|
||||
}
|
||||
let artwork = MPMediaItemArtwork.init(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in
|
||||
return downloadedImage
|
||||
})
|
||||
|
||||
self.setMetadata(artwork)
|
||||
}
|
||||
}
|
||||
public static func update(duration: Double, currentTime: Double, rate: Float) {
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
public static func reset() {
|
||||
audiobook = nil
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
}
|
||||
|
||||
private static func setMetadata(_ artwork: MPMediaItemArtwork?) {
|
||||
if self.audiobook == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if artwork != nil {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
|
||||
} else if shouldFetchCover() {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = nil
|
||||
}
|
||||
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] = audiobook!.streamId
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyAssetURL] = URL(string: audiobook!.playlistUrl)
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = false
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = "hls"
|
||||
|
||||
nowPlayingInfo[MPMediaItemPropertyTitle] = audiobook!.title
|
||||
nowPlayingInfo[MPMediaItemPropertyArtist] = audiobook!.author ?? "unknown"
|
||||
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = audiobook!.series
|
||||
}
|
||||
private static func shouldFetchCover() -> Bool {
|
||||
audiobook != nil && (nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != audiobook!.streamId || nowPlayingInfo[MPMediaItemPropertyArtwork] == nil)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue