2 // FolioReaderAudioPlayer.swift
5 // Created by Kevin Jantzer on 1/4/16.
6 // Copyright (c) 2015 Folio Reader. All rights reserved.
13 open class FolioReaderAudioPlayer: NSObject {
15 var isTextToSpeech = false
16 var synthesizer: AVSpeechSynthesizer!
18 var player: AVAudioPlayer?
19 var currentHref: String!
20 var currentFragment: String!
21 var currentSmilFile: FRSmilFile!
22 var currentAudioFile: String!
23 var currentBeginTime: Double!
24 var currentEndTime: Double!
25 var playingTimer: Timer!
26 var registeredCommands = false
27 var completionHandler: () -> Void = {}
28 var utteranceRate: Float = 0
30 fileprivate var book: FRBook
31 fileprivate var folioReader: FolioReader
35 init(withFolioReader folioReader: FolioReader, book: FRBook) {
37 self.folioReader = folioReader
41 UIApplication.shared.beginReceivingRemoteControlEvents()
43 // this is needed to the audio can play even when the "silent/vibrate" toggle is on
44 let session = AVAudioSession.sharedInstance()
45 try? session.setCategory(AVAudioSessionCategoryPlayback)
46 try? session.setActive(true)
48 self.updateNowPlayingInfo()
52 UIApplication.shared.endReceivingRemoteControlEvents()
55 // MARK: Reading speed
57 func setRate(_ rate: Int) {
58 if let player = player {
76 updateNowPlayingInfo()
78 if synthesizer != nil {
79 // Need to change between version IOS
80 // http://stackoverflow.com/questions/32761786/ios9-avspeechutterance-rate-for-avspeechsynthesizer-issue
81 if #available(iOS 9, *) {
117 updateNowPlayingInfo()
121 // MARK: Play, Pause, Stop controls
123 func stop(immediate: Bool = false) {
126 if let player = player , player.isPlaying {
130 stopSynthesizer(immediate: immediate, completion: nil)
134 func stopSynthesizer(immediate: Bool = false, completion: (() -> Void)? = nil) {
135 synthesizer.stopSpeaking(at: immediate ? .immediate : .word)
143 if let player = player , player.isPlaying {
147 if synthesizer.isSpeaking {
148 synthesizer.pauseSpeaking(at: .word)
153 @objc func togglePlay() {
154 isPlaying() ? pause() : play()
159 guard let currentPage = self.folioReader.readerCenter?.currentPage else { return }
160 currentPage.webView?.js("playAudio()")
162 self.readCurrentSentence()
166 func isPlaying() -> Bool {
171 Play Audio (href/fragmentID)
173 Begins to play audio for the given chapter (href) and text fragment.
174 If this chapter does not have audio, it will delay for a second, then attempt to play the next chapter
176 func playAudio(_ href: String, fragmentID: String) {
177 isTextToSpeech = false
181 let smilFile = book.smilFile(forHref: href)
183 // if no smil file for this href and the same href is being requested, we've hit the end. stop playing
184 if smilFile == nil && currentHref != nil && href == currentHref {
190 currentFragment = "#"+fragmentID
191 currentSmilFile = smilFile
193 // if no smil file, delay for a second, then move on to the next chapter
195 Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(_autoPlayNextChapter), userInfo: nil, repeats: false)
199 let fragment = smilFile?.parallelAudioForFragment(currentFragment)
202 if _playFragment(fragment) {
208 @objc func _autoPlayNextChapter() {
209 // if user has stopped playing, dont play the next chapter
210 if isPlaying() == false { return }
214 @objc func playPrevChapter() {
216 // Wait for "currentPage" to update, then request to play audio
217 self.folioReader.readerCenter?.changePageToPrevious {
218 if self.isPlaying() {
226 @objc func playNextChapter() {
228 // Wait for "currentPage" to update, then request to play audio
229 self.folioReader.readerCenter?.changePageToNext {
230 if self.isPlaying() {
238 Play Fragment of audio
240 Once an audio fragment begins playing, the audio clip will continue playing until the player timer detects
241 the audio is out of the fragment timeframe.
243 @discardableResult fileprivate func _playFragment(_ smil: FRSmilElement?) -> Bool {
245 guard let smil = smil else {
246 // FIXME: What about the log that the library prints in the console? shouldn’t we disable it? use another library for that or some compiler flags?
247 print("no more parallel audio to play")
252 let textFragment = smil.textElement().attributes["src"]
253 let audioFile = smil.audioElement().attributes["src"]
255 currentBeginTime = smil.clipBegin()
256 currentEndTime = smil.clipEnd()
258 // new audio file to play, create the audio player
259 if player == nil || (audioFile != nil && audioFile != currentAudioFile) {
261 currentAudioFile = audioFile
263 let fileURL = currentSmilFile.resource.basePath() + ("/"+audioFile!)
264 let audioData = try? Data(contentsOf: URL(fileURLWithPath: fileURL))
268 player = try AVAudioPlayer(data: audioData!)
270 guard let player = player else { return false }
272 setRate(self.folioReader.currentAudioRate)
273 player.enableRate = true
274 player.prepareToPlay()
275 player.delegate = self
277 updateNowPlayingInfo()
279 print("could not read audio file:", audioFile ?? "nil")
284 // if player is initialized properly, begin playing
285 guard let player = player else { return false }
287 // the audio may be playing already, so only set the player time if it is NOT already within the fragment timeframe
288 // this is done to mitigate milisecond skips in the audio when changing fragments
289 if player.currentTime < currentBeginTime || ( currentEndTime > 0 && player.currentTime > currentEndTime) {
290 player.currentTime = currentBeginTime;
291 updateNowPlayingInfo()
296 // get the fragment ID so we can "mark" it in the webview
297 let textParts = textFragment!.components(separatedBy: "#")
298 let fragmentID = textParts[1];
299 self.folioReader.readerCenter?.audioMark(href: currentHref, fragmentID: fragmentID)
307 Gets the next audio fragment in the current smil file, or moves on to the next smil file
309 fileprivate func nextAudioFragment() -> FRSmilElement? {
311 guard let smilFile = book.smilFile(forHref: currentHref) else {
315 let smil = (self.currentFragment == nil ? smilFile.parallelAudioForFragment(nil) : smilFile.nextParallelAudioForFragment(currentFragment))
318 self.currentFragment = smil?.textElement().attributes["src"]
322 self.currentHref = self.book.spine.nextChapter(currentHref)?.href
323 self.currentFragment = nil
324 self.currentSmilFile = smilFile
326 guard (self.currentHref != nil) else {
330 return self.nextAudioFragment()
333 func playText(_ href: String, text: String) {
334 isTextToSpeech = true
338 if synthesizer == nil {
339 synthesizer = AVSpeechSynthesizer()
340 synthesizer.delegate = self
341 setRate(self.folioReader.currentAudioRate)
344 let utterance = AVSpeechUtterance(string: text)
345 utterance.rate = utteranceRate
346 utterance.voice = AVSpeechSynthesisVoice(language: self.book.metadata.language)
348 if synthesizer.isSpeaking {
351 synthesizer.speak(utterance)
353 updateNowPlayingInfo()
356 // MARK: TTS Sentence
358 func speakSentence() {
360 let readerCenter = self.folioReader.readerCenter,
361 let currentPage = readerCenter.currentPage else {
365 let playbackActiveClass = book.playbackActiveClass
366 guard let sentence = currentPage.webView?.js("getSentenceWithIndex('\(playbackActiveClass)')") else {
367 if (readerCenter.isLastPage() == true) {
370 readerCenter.changePageToNext()
376 guard let href = readerCenter.getCurrentChapter()?.href else {
380 // TODO QUESTION: The previous code made it possible to call `playText` with the parameter `href` being an empty string. Was that valid? should this logic be kept?
381 self.playText(href, text: sentence)
384 func readCurrentSentence() {
385 guard synthesizer != nil else { return speakSentence() }
387 if synthesizer.isPaused {
389 synthesizer.continueSpeaking()
391 if synthesizer.isSpeaking {
392 stopSynthesizer(immediate: false, completion: {
393 if let currentPage = self.folioReader.readerCenter?.currentPage {
394 currentPage.webView?.js("resetCurrentSentenceIndex()")
404 // MARK: - Audio timing events
406 fileprivate func startPlayerTimer() {
407 // we must add the timer in this mode in order for it to continue working even when the user is scrolling a webview
408 playingTimer = Timer(timeInterval: 0.01, target: self, selector: #selector(playerTimerObserver), userInfo: nil, repeats: true)
409 RunLoop.current.add(playingTimer, forMode: RunLoopMode.commonModes)
412 fileprivate func stopPlayerTimer() {
413 if playingTimer != nil {
414 playingTimer.invalidate()
419 @objc func playerTimerObserver() {
420 guard let player = player else { return }
422 if currentEndTime != nil && currentEndTime > 0 && player.currentTime > currentEndTime {
423 _playFragment(self.nextAudioFragment())
427 // MARK: - Now Playing Info and Controls
430 Update Now Playing info
432 Gets the book and audio information and updates on Now Playing Center
434 func updateNowPlayingInfo() {
435 var songInfo = [String: AnyObject]()
438 if let coverImage = self.book.coverImage, let artwork = UIImage(contentsOfFile: coverImage.fullHref) {
439 let albumArt = MPMediaItemArtwork(image: artwork)
440 songInfo[MPMediaItemPropertyArtwork] = albumArt
444 if let title = self.book.title {
445 songInfo[MPMediaItemPropertyAlbumTitle] = title as AnyObject?
449 if let chapter = getCurrentChapterName() {
450 songInfo[MPMediaItemPropertyTitle] = chapter as AnyObject?
454 if let author = self.book.metadata.creators.first {
455 songInfo[MPMediaItemPropertyArtist] = author.name as AnyObject?
459 if let player = player , !isTextToSpeech {
460 songInfo[MPMediaItemPropertyPlaybackDuration] = player.duration as AnyObject?
461 songInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate as AnyObject?
462 songInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime ] = player.currentTime as AnyObject?
465 // Set Audio Player info
466 MPNowPlayingInfoCenter.default().nowPlayingInfo = songInfo
468 registerCommandsIfNeeded()
472 Get Current Chapter Name
474 This is done here and not in ReaderCenter because even though `currentHref` is accurate,
475 the `currentPage` in ReaderCenter may not have updated just yet
477 func getCurrentChapterName() -> String? {
478 guard let chapter = self.folioReader.readerCenter?.getCurrentChapter() else {
482 currentHref = chapter.href
484 for item in (self.book.flatTableOfContents ?? []) {
485 if let resource = item.resource , resource.href == currentHref {
493 Register commands if needed, check if it's registered to avoid register twice.
495 func registerCommandsIfNeeded() {
497 guard !registeredCommands else { return }
499 let command = MPRemoteCommandCenter.shared()
500 command.previousTrackCommand.isEnabled = true
501 command.previousTrackCommand.addTarget(self, action: #selector(playPrevChapter))
502 command.nextTrackCommand.isEnabled = true
503 command.nextTrackCommand.addTarget(self, action: #selector(playNextChapter))
504 command.pauseCommand.isEnabled = true
505 command.pauseCommand.addTarget(self, action: #selector(pause))
506 command.playCommand.isEnabled = true
507 command.playCommand.addTarget(self, action: #selector(play))
508 command.togglePlayPauseCommand.isEnabled = true
509 command.togglePlayPauseCommand.addTarget(self, action: #selector(togglePlay))
511 registeredCommands = true
515 // MARK: AVSpeechSynthesizerDelegate
517 extension FolioReaderAudioPlayer: AVSpeechSynthesizerDelegate {
518 public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
522 public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
524 readCurrentSentence()
529 // MARK: AVAudioPlayerDelegate
531 extension FolioReaderAudioPlayer: AVAudioPlayerDelegate {
532 public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
533 _playFragment(self.nextAudioFragment())