added iOS source code
[wl-app.git] / iOS / Pods / FolioReaderKit / Source / FolioReaderAudioPlayer.swift
1 //
2 //  FolioReaderAudioPlayer.swift
3 //  FolioReaderKit
4 //
5 //  Created by Kevin Jantzer on 1/4/16.
6 //  Copyright (c) 2015 Folio Reader. All rights reserved.
7 //
8
9 import UIKit
10 import AVFoundation
11 import MediaPlayer
12
13 open class FolioReaderAudioPlayer: NSObject {
14
15     var isTextToSpeech = false
16     var synthesizer: AVSpeechSynthesizer!
17     var playing = false
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
29
30     fileprivate var book: FRBook
31     fileprivate var folioReader: FolioReader
32
33     // MARK: Init
34
35     init(withFolioReader folioReader: FolioReader, book: FRBook) {
36         self.book = book
37         self.folioReader = folioReader
38
39         super.init()
40
41         UIApplication.shared.beginReceivingRemoteControlEvents()
42
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)
47
48         self.updateNowPlayingInfo()
49     }
50
51     deinit {
52         UIApplication.shared.endReceivingRemoteControlEvents()
53     }
54
55     // MARK: Reading speed
56
57     func setRate(_ rate: Int) {
58         if let player = player {
59             switch rate {
60             case 0:
61                 player.rate = 0.5
62                 break
63             case 1:
64                 player.rate = 1.0
65                 break
66             case 2:
67                 player.rate = 1.5
68                 break
69             case 3:
70                 player.rate = 2
71                 break
72             default:
73                 break
74             }
75
76             updateNowPlayingInfo()
77         }
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, *) {
82                 switch rate {
83                 case 0:
84                     utteranceRate = 0.42
85                     break
86                 case 1:
87                     utteranceRate = 0.5
88                     break
89                 case 2:
90                     utteranceRate = 0.53
91                     break
92                 case 3:
93                     utteranceRate = 0.56
94                     break
95                 default:
96                     break
97                 }
98             } else {
99                 switch rate {
100                 case 0:
101                     utteranceRate = 0
102                     break
103                 case 1:
104                     utteranceRate = 0.06
105                     break
106                 case 2:
107                     utteranceRate = 0.15
108                     break
109                 case 3:
110                     utteranceRate = 0.23
111                     break
112                 default:
113                     break
114                 }
115             }
116
117             updateNowPlayingInfo()
118         }
119     }
120
121     // MARK: Play, Pause, Stop controls
122
123     func stop(immediate: Bool = false) {
124         playing = false
125         if !isTextToSpeech {
126             if let player = player , player.isPlaying {
127                 player.stop()
128             }
129         } else {
130             stopSynthesizer(immediate: immediate, completion: nil)
131         }
132     }
133
134     func stopSynthesizer(immediate: Bool = false, completion: (() -> Void)? = nil) {
135         synthesizer.stopSpeaking(at: immediate ? .immediate : .word)
136         completion?()
137     }
138
139     @objc func pause() {
140         playing = false
141
142         if !isTextToSpeech {
143             if let player = player , player.isPlaying {
144                 player.pause()
145             }
146         } else {
147             if synthesizer.isSpeaking {
148                 synthesizer.pauseSpeaking(at: .word)
149             }
150         }
151     }
152
153     @objc func togglePlay() {
154         isPlaying() ? pause() : play()
155     }
156
157     @objc func play() {
158         if book.hasAudio {
159             guard let currentPage = self.folioReader.readerCenter?.currentPage else { return }
160             currentPage.webView?.js("playAudio()")
161         } else {
162             self.readCurrentSentence()
163         }
164     }
165
166     func isPlaying() -> Bool {
167         return playing
168     }
169
170     /**
171      Play Audio (href/fragmentID)
172
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
175      */
176     func playAudio(_ href: String, fragmentID: String) {
177         isTextToSpeech = false
178
179         self.stop()
180
181         let smilFile = book.smilFile(forHref: href)
182
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 {
185             return
186         }
187
188         playing = true
189         currentHref = href
190         currentFragment = "#"+fragmentID
191         currentSmilFile = smilFile
192
193         // if no smil file, delay for a second, then move on to the next chapter
194         if smilFile == nil {
195             Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(_autoPlayNextChapter), userInfo: nil, repeats: false)
196             return
197         }
198
199         let fragment = smilFile?.parallelAudioForFragment(currentFragment)
200
201         if fragment != nil {
202             if _playFragment(fragment) {
203                 startPlayerTimer()
204             }
205         }
206     }
207
208     @objc func _autoPlayNextChapter() {
209         // if user has stopped playing, dont play the next chapter
210         if isPlaying() == false { return }
211         playNextChapter()
212     }
213
214     @objc func playPrevChapter() {
215         stopPlayerTimer()
216         // Wait for "currentPage" to update, then request to play audio
217         self.folioReader.readerCenter?.changePageToPrevious {
218             if self.isPlaying() {
219                 self.play()
220             } else {
221                 self.pause()
222             }
223         }
224     }
225
226     @objc func playNextChapter() {
227         stopPlayerTimer()
228         // Wait for "currentPage" to update, then request to play audio
229         self.folioReader.readerCenter?.changePageToNext {
230             if self.isPlaying() {
231                 self.play()
232             }
233         }
234     }
235
236
237     /**
238      Play Fragment of audio
239
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.
242      */
243     @discardableResult fileprivate func _playFragment(_ smil: FRSmilElement?) -> Bool {
244
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")
248             self.stop()
249             return false
250         }
251
252         let textFragment = smil.textElement().attributes["src"]
253         let audioFile = smil.audioElement().attributes["src"]
254
255         currentBeginTime = smil.clipBegin()
256         currentEndTime = smil.clipEnd()
257
258         // new audio file to play, create the audio player
259         if player == nil || (audioFile != nil && audioFile != currentAudioFile) {
260
261             currentAudioFile = audioFile
262
263             let fileURL = currentSmilFile.resource.basePath() + ("/"+audioFile!)
264             let audioData = try? Data(contentsOf: URL(fileURLWithPath: fileURL))
265
266             do {
267
268                 player = try AVAudioPlayer(data: audioData!)
269
270                 guard let player = player else { return false }
271
272                 setRate(self.folioReader.currentAudioRate)
273                 player.enableRate = true
274                 player.prepareToPlay()
275                 player.delegate = self
276
277                 updateNowPlayingInfo()
278             } catch {
279                 print("could not read audio file:", audioFile ?? "nil")
280                 return false
281             }
282         }
283
284         // if player is initialized properly, begin playing
285         guard let player = player else { return false }
286
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()
292         }
293
294         player.play()
295
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)
300
301         return true
302     }
303
304     /**
305      Next Audio Fragment
306
307      Gets the next audio fragment in the current smil file, or moves on to the next smil file
308      */
309     fileprivate func nextAudioFragment() -> FRSmilElement? {
310
311         guard let smilFile = book.smilFile(forHref: currentHref) else {
312             return nil
313         }
314
315         let smil = (self.currentFragment == nil ? smilFile.parallelAudioForFragment(nil) : smilFile.nextParallelAudioForFragment(currentFragment))
316
317         if (smil != nil) {
318             self.currentFragment = smil?.textElement().attributes["src"]
319             return smil
320         }
321
322         self.currentHref = self.book.spine.nextChapter(currentHref)?.href
323         self.currentFragment = nil
324         self.currentSmilFile = smilFile
325
326         guard (self.currentHref != nil) else {
327             return nil
328         }
329
330         return self.nextAudioFragment()
331     }
332
333     func playText(_ href: String, text: String) {
334         isTextToSpeech = true
335         playing = true
336         currentHref = href
337
338         if synthesizer == nil {
339             synthesizer = AVSpeechSynthesizer()
340             synthesizer.delegate = self
341             setRate(self.folioReader.currentAudioRate)
342         }
343
344         let utterance = AVSpeechUtterance(string: text)
345         utterance.rate = utteranceRate
346         utterance.voice = AVSpeechSynthesisVoice(language: self.book.metadata.language)
347
348         if synthesizer.isSpeaking {
349             stopSynthesizer()
350         }
351         synthesizer.speak(utterance)
352
353         updateNowPlayingInfo()
354     }
355
356     // MARK: TTS Sentence
357
358     func speakSentence() {
359         guard
360             let readerCenter = self.folioReader.readerCenter,
361             let currentPage = readerCenter.currentPage else {
362                 return
363         }
364
365         let playbackActiveClass = book.playbackActiveClass
366         guard let sentence = currentPage.webView?.js("getSentenceWithIndex('\(playbackActiveClass)')") else {
367             if (readerCenter.isLastPage() == true) {
368                 self.stop()
369             } else {
370                 readerCenter.changePageToNext()
371             }
372
373             return
374         }
375
376         guard let href = readerCenter.getCurrentChapter()?.href else {
377             return
378         }
379
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)
382     }
383
384     func readCurrentSentence() {
385         guard synthesizer != nil else { return speakSentence() }
386
387         if synthesizer.isPaused {
388             playing = true
389             synthesizer.continueSpeaking()
390         } else {
391             if synthesizer.isSpeaking {
392                 stopSynthesizer(immediate: false, completion: {
393                     if let currentPage = self.folioReader.readerCenter?.currentPage {
394                         currentPage.webView?.js("resetCurrentSentenceIndex()")
395                     }
396                     self.speakSentence()
397                 })
398             } else {
399                 speakSentence()
400             }
401         }
402     }
403
404     // MARK: - Audio timing events
405
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)
410     }
411
412     fileprivate func stopPlayerTimer() {
413         if playingTimer != nil {
414             playingTimer.invalidate()
415             playingTimer = nil
416         }
417     }
418
419     @objc func playerTimerObserver() {
420         guard let player = player else { return }
421
422         if currentEndTime != nil && currentEndTime > 0 && player.currentTime > currentEndTime {
423             _playFragment(self.nextAudioFragment())
424         }
425     }
426
427     // MARK: - Now Playing Info and Controls
428
429     /**
430      Update Now Playing info
431
432      Gets the book and audio information and updates on Now Playing Center
433      */
434     func updateNowPlayingInfo() {
435         var songInfo = [String: AnyObject]()
436
437         // Get book Artwork
438         if let coverImage = self.book.coverImage, let artwork = UIImage(contentsOfFile: coverImage.fullHref) {
439             let albumArt = MPMediaItemArtwork(image: artwork)
440             songInfo[MPMediaItemPropertyArtwork] = albumArt
441         }
442
443         // Get book title
444         if let title = self.book.title {
445             songInfo[MPMediaItemPropertyAlbumTitle] = title as AnyObject?
446         }
447
448         // Get chapter name
449         if let chapter = getCurrentChapterName() {
450             songInfo[MPMediaItemPropertyTitle] = chapter as AnyObject?
451         }
452
453         // Get author name
454         if let author = self.book.metadata.creators.first {
455             songInfo[MPMediaItemPropertyArtist] = author.name as AnyObject?
456         }
457
458         // Set player times
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?
463         }
464
465         // Set Audio Player info
466         MPNowPlayingInfoCenter.default().nowPlayingInfo = songInfo
467
468         registerCommandsIfNeeded()
469     }
470
471     /**
472      Get Current Chapter Name
473
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
476      */
477     func getCurrentChapterName() -> String? {
478         guard let chapter = self.folioReader.readerCenter?.getCurrentChapter() else {
479             return nil
480         }
481
482         currentHref = chapter.href
483
484         for item in (self.book.flatTableOfContents ?? []) {
485             if let resource = item.resource , resource.href == currentHref {
486                 return item.title
487             }
488         }
489         return nil
490     }
491
492     /**
493      Register commands if needed, check if it's registered to avoid register twice.
494      */
495     func registerCommandsIfNeeded() {
496
497         guard !registeredCommands else { return }
498
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))
510
511         registeredCommands = true
512     }
513 }
514
515 // MARK: AVSpeechSynthesizerDelegate
516
517 extension FolioReaderAudioPlayer: AVSpeechSynthesizerDelegate {
518     public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
519         completionHandler()
520     }
521     
522     public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
523         if isPlaying() {
524             readCurrentSentence()
525         }
526     }
527 }
528
529 // MARK: AVAudioPlayerDelegate
530
531 extension FolioReaderAudioPlayer: AVAudioPlayerDelegate {
532     public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
533         _playFragment(self.nextAudioFragment())
534     }
535 }