From 548b29c01b24f4a35748e535733082236f94c20a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Fri, 12 Jul 2024 14:47:01 +0200 Subject: [PATCH] Fix asynchronoucity with the TTS --- .../EPUB/EPUBNavigatorViewController.swift | 1 - Sources/Navigator/TTS/AVTTSEngine.swift | 1 + .../TTS/PublicationSpeechSynthesizer.swift | 8 +- .../Format/Sniffers/EPUBFormatSniffer.swift | 2 +- .../Shared/Toolkit/Media/AudioSession.swift | 74 +++++++++++++++---- TestApp/Sources/Library/LibraryError.swift | 1 + TestApp/Sources/Library/LibraryService.swift | 3 +- .../Common/VisualReaderViewController.swift | 2 + 8 files changed, 70 insertions(+), 22 deletions(-) diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index 2aa8c4b57..17da57513 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -377,7 +377,6 @@ open class EPUBNavigatorViewController: UIViewController, /// Intercepts tap gesture when the web views are not loaded. @objc private func didTapBackground(_ gesture: UITapGestureRecognizer) { - guard case .loading = state else { return } didTap(at: gesture.location(in: view)) } diff --git a/Sources/Navigator/TTS/AVTTSEngine.swift b/Sources/Navigator/TTS/AVTTSEngine.swift index 7fc0ca956..c93d355dc 100644 --- a/Sources/Navigator/TTS/AVTTSEngine.swift +++ b/Sources/Navigator/TTS/AVTTSEngine.swift @@ -64,6 +64,7 @@ public class AVTTSEngine: NSObject, TTSEngine, AVSpeechSynthesizerDelegate, Logg .map { TTSVoice(voice: $0) } } + @MainActor public func speak( _ utterance: TTSUtterance, onSpeakRange: @escaping (Range) -> Void diff --git a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift index 8dbf06e74..e53e97b12 100644 --- a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift +++ b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift @@ -10,9 +10,11 @@ import ReadiumShared public protocol PublicationSpeechSynthesizerDelegate: AnyObject { /// Called when the synthesizer's state is updated. + @MainActor func publicationSpeechSynthesizer(_ synthesizer: PublicationSpeechSynthesizer, stateDidChange state: PublicationSpeechSynthesizer.State) /// Called when an `error` occurs while speaking `utterance`. + @MainActor func publicationSpeechSynthesizer(_ synthesizer: PublicationSpeechSynthesizer, utterance: PublicationSpeechSynthesizer.Utterance, didFailWithError error: PublicationSpeechSynthesizer.Error) } @@ -89,7 +91,9 @@ public class PublicationSpeechSynthesizer: Loggable { AudioSession.shared.user(audioSessionUser, didChangePlaying: state.isPlaying) } - delegate?.publicationSpeechSynthesizer(self, stateDidChange: state) + Task { + await delegate?.publicationSpeechSynthesizer(self, stateDidChange: state) + } } } @@ -296,7 +300,7 @@ public class PublicationSpeechSynthesizer: Loggable { await playNextUtterance(.forward) case let .failure(error): state = .paused(utterance) - delegate?.publicationSpeechSynthesizer(self, utterance: utterance, didFailWithError: .engine(error)) + await delegate?.publicationSpeechSynthesizer(self, utterance: utterance, didFailWithError: .engine(error)) } } diff --git a/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift index 139020232..b532c9343 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift @@ -38,7 +38,7 @@ public struct EPUBFormatSniffer: FormatSniffer { return await resource.readAsString() .flatMap { mimetype in - if MediaType.epub.matches(MediaType(mimetype)) { + if MediaType.epub.matches(MediaType(mimetype.trimmingCharacters(in: .whitespacesAndNewlines))) { var format = format format.addSpecifications(.epub) if format.conformsTo(.zip) { diff --git a/Sources/Shared/Toolkit/Media/AudioSession.swift b/Sources/Shared/Toolkit/Media/AudioSession.swift index b90da399c..d91779abc 100644 --- a/Sources/Shared/Toolkit/Media/AudioSession.swift +++ b/Sources/Shared/Toolkit/Media/AudioSession.swift @@ -28,6 +28,7 @@ public extension AudioSessionUser { } /// Manages an activated `AVAudioSession`. +@MainActor public final class AudioSession: Loggable { public struct Configuration: Equatable { let category: AVAudioSession.Category @@ -49,41 +50,67 @@ public final class AudioSession: Loggable { } /// Shared `AudioSession` for this app. - public static let shared = AudioSession() + public nonisolated static let shared = AudioSession() - private init() { - observeAppStateChanges() + private nonisolated init() { + Task { + await observeAppStateChanges() + } } deinit { NotificationCenter.default.removeObserver(self) } + struct User { + let id: ObjectIdentifier + private(set) weak var user: AudioSessionUser? + + init(_ user: AudioSessionUser) { + id = ObjectIdentifier(user) + self.user = user + } + } + /// Current user of the `AudioSession`. - private weak var user: AudioSessionUser? + private var user: User? /// Starts a new audio session with the given `user`. - public func start(with user: AudioSessionUser, isPlaying: Bool) { - guard self.user !== user else { + public nonisolated func start(with user: AudioSessionUser, isPlaying: Bool) { + Task { + await start(with: user, isPlaying: isPlaying) + } + } + + private func start(with user: AudioSessionUser, isPlaying: Bool) async { + let id = ObjectIdentifier(user) + guard self.user?.id != id else { return } if let oldUser = self.user { - end(for: oldUser) + end(forUserID: oldUser.id) } - self.user = user + self.user = User(user) self.isPlaying = false startSession(with: user.audioConfiguration) } /// Ends the current audio session. - public func end(for user: AudioSessionUser) { - guard self.user === user || self.user == nil else { + public nonisolated func end(for user: AudioSessionUser) { + let id = ObjectIdentifier(user) + Task { + await end(forUserID: id) + } + } + + private func end(forUserID id: ObjectIdentifier) { + guard user?.id == id else { return } - self.user = nil + user = nil isPlaying = false endSession() @@ -92,8 +119,15 @@ public final class AudioSession: Loggable { /// Indicates whether the `user` is playing. private var isPlaying: Bool = false - public func user(_ user: AudioSessionUser, didChangePlaying isPlaying: Bool) { - guard self.user === user, self.isPlaying != isPlaying else { + public nonisolated func user(_ user: AudioSessionUser, didChangePlaying isPlaying: Bool) { + Task { + await self.user(user, didChangePlaying: isPlaying) + } + } + + private func user(_ user: AudioSessionUser, didChangePlaying isPlaying: Bool) async { + let id = ObjectIdentifier(user) + guard self.user?.id == id, self.isPlaying != isPlaying else { return } @@ -131,6 +165,7 @@ public final class AudioSession: Loggable { do { try audioSession.setCategory(config.category, mode: config.mode, policy: config.routeSharingPolicy, options: config.options) try audioSession.setActive(true) + log(.info, "Started audio session with category: \(config.category), mode: \(config.mode), policy: \(config.routeSharingPolicy), options: \(config.options)") } catch { log(.error, "Failed to start the audio session: \(error)") } @@ -154,6 +189,7 @@ public final class AudioSession: Loggable { do { AVAudioSession.sharedInstance() try AVAudioSession.sharedInstance().setActive(false) + log(.info, "Ended audio session") } catch { log(.error, "Failed to end the audio session: \(error)") } @@ -168,7 +204,7 @@ public final class AudioSession: Loggable { /// The observer of audio session interruption notifications. private var interruptionObserver: Any? - private func handleAudioSessionInterruption(notification: Notification) { + private nonisolated func handleAudioSessionInterruption(notification: Notification) { guard let userInfo = notification.userInfo, let rawInterruptionType = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, let interruptionType = AVAudioSession.InterruptionType(rawValue: rawInterruptionType) @@ -180,7 +216,13 @@ public final class AudioSession: Loggable { .map(AVAudioSession.InterruptionOptions.init(rawValue:)) ?? [] - switch interruptionType { + Task { + await handleAudioSessionInterruption(type: interruptionType, options: options) + } + } + + private func handleAudioSessionInterruption(type: AVAudioSession.InterruptionType, options: AVAudioSession.InterruptionOptions) { + switch type { case .began: isInterrupted = true @@ -190,7 +232,7 @@ public final class AudioSession: Loggable { // When an interruption ends, determine whether playback should resume automatically, // and reactivate the audio session if necessary. do { - if let user = user { + if let user = user?.user { try AVAudioSession.sharedInstance().setActive(true) if options.contains(.shouldResume) { diff --git a/TestApp/Sources/Library/LibraryError.swift b/TestApp/Sources/Library/LibraryError.swift index 27e185113..092bc5dac 100644 --- a/TestApp/Sources/Library/LibraryError.swift +++ b/TestApp/Sources/Library/LibraryError.swift @@ -12,6 +12,7 @@ enum LibraryError: LocalizedError { case bookNotFound case bookDeletionFailed(Error?) case importFailed(Error) + case publicationIsRestricted(Error) case openFailed(Error) case downloadFailed(Error?) case cancelled diff --git a/TestApp/Sources/Library/LibraryService.swift b/TestApp/Sources/Library/LibraryService.swift index f6dcf6583..c28df9742 100644 --- a/TestApp/Sources/Library/LibraryService.swift +++ b/TestApp/Sources/Library/LibraryService.swift @@ -68,11 +68,10 @@ final class LibraryService: Loggable { } /// Checks if the publication is not still locked by a DRM. - // FIXME: Better error private func checkIsReadable(publication: Publication) throws { guard !publication.isRestricted else { if let error = publication.protectionError { - throw LibraryError.openFailed(error) + throw LibraryError.publicationIsRestricted(error) } else { throw LibraryError.cancelled } diff --git a/TestApp/Sources/Reader/Common/VisualReaderViewController.swift b/TestApp/Sources/Reader/Common/VisualReaderViewController.swift index b2da91276..f6b67a6e2 100644 --- a/TestApp/Sources/Reader/Common/VisualReaderViewController.swift +++ b/TestApp/Sources/Reader/Common/VisualReaderViewController.swift @@ -83,6 +83,7 @@ class VisualReaderViewController: ReaderViewCon if let state = ttsViewModel?.$state, let controls = ttsControlsViewController { controls.view.backgroundColor = .clear + controls.view.isHidden = true addChild(controls) controls.view.translatesAutoresizingMaskIntoConstraints = false @@ -94,6 +95,7 @@ class VisualReaderViewController: ReaderViewCon controls.didMove(toParent: self) state + .receive(on: DispatchQueue.main) .sink { state in controls.view.isHidden = !state.showControls }