diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin+Audiobook.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin+Audiobook.swift deleted file mode 100644 index f868b69..0000000 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin+Audiobook.swift +++ /dev/null @@ -1,338 +0,0 @@ -import Combine -import Foundation -import MediaPlayer -import ReadiumNavigator -import MediaPlayer -import ReadiumNavigator -import ReadiumShared - -private let TAG = "ReadiumReaderPlugin/Audiobook" - -//@MainActor -class AudiobookViewModel: ObservableObject { - let navigator: AudioNavigator - var preferences: FlutterAudioPreferences - - @Published var cover: UIImage? - @Published var playback: MediaPlaybackInfo = .init() - - init(navigator: AudioNavigator, preferences: FlutterAudioPreferences) { - self.navigator = navigator - self.preferences = preferences - - Task { - cover = try? await navigator.publication.cover().get() - } - } - - func onPlaybackChanged(info: MediaPlaybackInfo) { - playback = info - } -} - -extension FlutterReadiumPlugin : AudioNavigatorDelegate { - - @MainActor func setupAudiobookNavigator( - publication: Publication, - initialLocator: Locator?, - initialPreferences: FlutterAudioPreferences, - ) async { - let navigator = AudioNavigator( - publication: publication, - initialLocation: initialLocator, - config: AudioNavigator.Configuration( - preferences: AudioPreferences(fromFlutterPrefs: initialPreferences) - ) - ) - if (audiobookVM != nil) { - await endAudiobookNavigator() - } - - audiobookVM = AudiobookViewModel( - navigator: navigator, - preferences: initialPreferences - ) - navigator.delegate = self - - /// Subscribe to changes - audiobookVM?.$playback - .throttle(for: 1, scheduler: RunLoop.main, latest: true) - .sink { [weak self] info in - guard let _ = self else { - return - } - debugPrint(TAG, "model.$playback updated.state=\(info.state),index=\(info.resourceIndex),time=\(info.time),progress=\(info.progress)") - } - .store(in: &subscriptions) - } - - public func setAudioPreferences(prefs: FlutterAudioPreferences) { - self.audiobookVM?.preferences = prefs - self.audiobookVM?.navigator.submitPreferences(AudioPreferences(fromFlutterPrefs: prefs)) - } - - public func endAudiobookNavigator() async { - self.audiobookVM?.navigator.delegate = nil - self.audiobookVM?.navigator.pause() - self.audiobookVM = nil - clearNowPlaying() - } - - public func pause() { - audiobookVM?.navigator.pause() - } - - public func play() { - Task { - audiobookVM?.navigator.play() - await setupNowPlaying() - setupCommandCenterControls() - } - } - - public func playPause() { - audiobookVM?.navigator.playPause() - } - - public func goForward(animated: Bool) async -> Bool { - let navOptions = animated ? NavigatorGoOptions.animated : NavigatorGoOptions.none - if (audiobookVM?.navigator.canGoForward == true) { - return await audiobookVM?.navigator.goForward(options: navOptions) == true - } else { - return false - } - } - - public func goBackward(animated: Bool) async -> Bool { - let navOptions = animated ? NavigatorGoOptions.animated : NavigatorGoOptions.none - if (audiobookVM?.navigator.canGoBackward == true) { - return await audiobookVM?.navigator.goBackward(options: navOptions) == true - } else { - return false - } - } - - public func seek(by delta: Double) async { - let wasTryingToPlay = audiobookVM?.navigator.state != .paused - await audiobookVM?.navigator.seek(by: delta) - if (wasTryingToPlay) { - play() - } - } - - public func seek(to offset: Double) async { - let wasTryingToPlay = audiobookVM?.navigator.state != .paused - await audiobookVM?.navigator.seek(to: offset) - if (wasTryingToPlay) { - play() - } - } - - public func navigator(_ navigator: Navigator, locationDidChange location: Locator) { - debugPrint(TAG, "locationDidChange") - - // Send new locator over the audio-locator stream. - self.audioLocatorStreamHandler?.sendEvent(location) - - if (mediaOverlays != nil) { - if let timeOffsetStr = location.locations.fragments.first(where: { $0.starts(with: "t=") })?.dropFirst(2), - let timeOffset = Double(timeOffsetStr), - let mediaOverlay = mediaOverlays?.first(where: { $0.itemInRangeOfTime(timeOffset, inHref: location.href.string) }), - var textLocator = mediaOverlay.textLocator { - if (!mediaOverlay.isEqual(lastMediaOverlayItem)) { - // Matched a new MediaOverlayItem -> sync reader with its textLocator. - lastMediaOverlayItem = mediaOverlay - textLocator.locations.progression = location.locations.progression - textLocator.locations.position = location.locations.position - Task.detached(priority: .background) { - let _ = await self.syncWithAudioLocator(textLocator) - await self.updateDecorations(uttLocator: textLocator, rangeLocator: nil) - } - } - } else { - debugPrint(TAG, "Did not find MediaOverlay matching audio Locator: \(location)") - } - } - - // Create TimebasedState and send it over the timebased-state stream. - guard let navigator = audiobookVM?.navigator else { - return - } - let state = ReadiumTimebasedState( - state: navigator.playbackInfo.state.asTimebasedState, - currentOffset: navigator.playbackInfo.time, - currentDuration: navigator.playbackInfo.duration ?? nil, - //currentBuffered: navigator.lastLoadedTimeRanges, - currentLocator: location) - lastTimebasedPlayerState = state - self.timebasedPlayerStateStreamHandler?.sendEvent(state.toJsonString()) - } - - // MARK: - AudioNavigatorDelegate (MainActor) - - /// Called when the playback updates. - public func navigator(_ navigator: AudioNavigator, playbackDidChange info: MediaPlaybackInfo) { - debugPrint(TAG, "playbackDidChange: \(info)") - - audiobookVM?.onPlaybackChanged(info: info) - let controlPanelInfoType = audiobookVM?.preferences.controlPanelInfoType ?? .standard - updateNowPlaying(info: info, infoType: controlPanelInfoType) - updateCommandCenterControls() - } - - /// Called when the navigator finished playing the current resource. - /// Returns whether the next resource should be played. Default is true. - public func navigator(_ navigator: AudioNavigator, shouldPlayNextResource info: MediaPlaybackInfo) -> Bool { - debugPrint(TAG, "shouldPlayNextResource? (true)") - return true - } - - /// Called when the ranges of buffered media data change. - /// Warning: They may be discontinuous. - public func navigator(_ navigator: AudioNavigator, loadedTimeRangesDidChange ranges: [Range]) { - debugPrint(TAG, "loadedTimeRangesDidChange: \(ranges)") - // TODO: Notify flutter client. - } - - // MARK: - AudioNavigatorDelegate - - public func navigator(_ navigator: any ReadiumNavigator.Navigator, presentError error: ReadiumNavigator.NavigatorError) { - debugPrint(TAG, "presentError: \(error.localizedDescription)") - // TODO: Notify flutter client. - } - - public func navigator(_ navigator: any ReadiumNavigator.Navigator, didFailToLoadResourceAt href: ReadiumShared.RelativeURL, withError error: ReadiumShared.ReadError) { - debugPrint(TAG, "didFailToLoadResourceAt: \(href.string), err: \(error.localizedDescription)") - // TODO: Notify flutter client. - } - - // MARK: - ControlCenter - - private func setupCommandCenterControls() { - Task { - let publication = audiobookVM?.navigator.publication - NowPlayingInfo.shared.media = await .init( - title: publication?.metadata.title ?? "", - artist: publication?.metadata.authors.map(\.name).joined(separator: ", "), - artwork: try? publication?.cover().get() - ) - } - - let rcc = MPRemoteCommandCenter.shared() - - func on(_ command: MPRemoteCommand, _ block: @escaping (AudioNavigator, MPRemoteCommandEvent) -> Void) { - command.addTarget { [weak self] event in - guard let self = self, - let vm = self.audiobookVM else { - return .noActionableNowPlayingItem - } - block(vm.navigator, event) - return .success - } - } - - on(rcc.playCommand) { audioNavigator, _ in - audioNavigator.play() - } - - on(rcc.pauseCommand) { audioNavigator, _ in - audioNavigator.pause() - } - - on(rcc.togglePlayPauseCommand) { audioNavigator, _ in - audioNavigator.playPause() - } - - on(rcc.previousTrackCommand) { audioNavigator, _ in - Task { - await audioNavigator.goBackward() - } - } - - on(rcc.nextTrackCommand) { audioNavigator, _ in - Task { - await audioNavigator.goForward() - } - } - - let seekInterval = self.audiobookVM?.preferences.seekInterval ?? 30 - - rcc.skipBackwardCommand.preferredIntervals = [seekInterval as NSNumber] - on(rcc.skipBackwardCommand) { [seekInterval] audioNavigator, _ in - Task { - await audioNavigator.seek(by: -(seekInterval)) - } - } - - rcc.skipForwardCommand.preferredIntervals = [seekInterval as NSNumber] - on(rcc.skipForwardCommand) { [seekInterval] audioNavigator, _ in - Task { - await audioNavigator.seek(by: +(seekInterval)) - } - } - - on(rcc.changePlaybackPositionCommand) { audioNavigator, event in - guard let event = event as? MPChangePlaybackPositionCommandEvent else { - return - } - Task { - await audioNavigator.seek(to: event.positionTime) - } - } - } - - private func updateCommandCenterControls() { - let rcc = MPRemoteCommandCenter.shared() - rcc.previousTrackCommand.isEnabled = audiobookVM?.navigator.canGoBackward ?? false - rcc.nextTrackCommand.isEnabled = audiobookVM?.navigator.canGoForward ?? false - } - - // MARK: - Now Playing metadata - - @MainActor - private func setupNowPlaying() { - let nowPlaying = NowPlayingInfo.shared - - let publication = audiobookVM?.navigator.publication - - // Initial publication metadata. - nowPlaying.media = NowPlayingInfo.Media( - title: publication?.metadata.title ?? "", - artist: publication?.metadata.authors.map(\.name).joined(separator: ", "), - chapterCount: publication?.readingOrder.count - ) - - // Update the artwork after the view model loaded it. - audiobookVM?.$cover - .sink { cover in - nowPlaying.media?.artwork = cover - } - .store(in: &subscriptions) - } - - @MainActor - private func updateNowPlaying(info: MediaPlaybackInfo, infoType: ControlPanelInfoType) { - let nowPlaying = NowPlayingInfo.shared - - let actualRate = switch info.state { - case .paused, .loading: 0.0 - case .playing: audiobookVM?.navigator.settings.speed ?? 1.0 - } - - nowPlaying.playback = NowPlayingInfo.Playback( - duration: info.duration, - elapsedTime: info.time, - rate: actualRate - ) - - nowPlaying.media?.chapterNumber = info.resourceIndex - - let publication = audiobookVM?.navigator.publication - if (infoType == .standard || infoType == .standardWCh) { - standardNowPlayingInfo(chapterNo: info.resourceIndex, infoType: infoType, publication: publication) - } else { - nonStandardNowPlayingInfo(chapterNo: info.resourceIndex, infoType: infoType, publication: publication) - } - } - -} diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin+MediaOverlays.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin+MediaOverlays.swift deleted file mode 100644 index 7b19b33..0000000 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin+MediaOverlays.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Combine -import Foundation -import MediaPlayer -import ReadiumNavigator -import MediaPlayer -import ReadiumNavigator -import ReadiumShared - -private let TAG = "ReadiumReaderPlugin/MediaOverlays" - -extension FlutterReadiumPlugin { - - func openAsMediaOverlayAudiobook(_ publication: Publication) async -> Publication { - debugPrint("Publication with Synchronized Narration reading-order found!") - let narrationLinks = publication.readingOrder.compactMap { - var link = $0.alternates.filterByMediaType(MediaType("application/vnd.syncnarr+json")!).first - link?.title = $0.title - return link - } - let narrationJson = await narrationLinks.asyncCompactMap { try? await publication.get($0)?.readAsJSONObject().get() - } - let mediaOverlays = narrationJson.enumerated().compactMap({ idx, json in FlutterMediaOverlay.fromJson(json, atPosition: idx) }) - - // Assert that we did not lose any MediaOverlays during JSON deserialization. - assert(mediaOverlays.count == narrationLinks.count) - - let audioReadingOrder = mediaOverlays.enumerated().map { (idx, narr) in - narrationLinks.getOrNil(idx).map { - return Link( - href: narr.items.first!.audioFile, - mediaType: MediaType.mpegAudio, - title: $0.title, - duration: narr.items.reduce(0, { $0 + ($1.audioDuration ?? 0) }) - ) - } - } as! [Link] - - // Copy the manifest and set its readingOrder to audioReadingOrder. - var audioPubManifest = publication.manifest // var of struct == implicit copy - audioPubManifest.readingOrder = audioReadingOrder - audioPubManifest.metadata.conformsTo = [Publication.Profile.audiobook] - - // TODO: This modifies the existing Publication reference !!! - var newPub = publication - newPub.manifest = audioPubManifest - - debugPrint("New audio readingOrder found: \(audioReadingOrder)") - // Save the media-overlays for later position matching. - self.mediaOverlays = mediaOverlays - // Assign the publication, it should now conform to AudioBook. - return newPub - } -} diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin+TTS.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin+TTS.swift deleted file mode 100644 index f498c80..0000000 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin+TTS.swift +++ /dev/null @@ -1,231 +0,0 @@ -import MediaPlayer -import ReadiumNavigator -import ReadiumShared - -private let TAG = "ReadiumReaderPlugin/TTS" - -extension FlutterReadiumPlugin : PublicationSpeechSynthesizerDelegate, AVTTSEngineDelegate { - - fileprivate func setupSynthesizer(withPreferences prefs: TTSPreferences?) async throws { - print(TAG, "setupSynthesizer") - - var engine: AVTTSEngine? - - guard let publication = getCurrentPublication() else { - throw ReadiumError.notFound("No current publication") - } - - self.synthesizer = PublicationSpeechSynthesizer( - publication: publication, - config: PublicationSpeechSynthesizer.Configuration( - defaultLanguage: prefs?.overrideLanguage, - voiceIdentifier: prefs?.voiceIdentifier, - ), - engineFactory: { - engine = AVTTSEngine() - return engine! - } - ) - engine?.delegate = self - self.ttsPrefs = prefs - self.synthesizer?.delegate = self - - $playingUtterance - .removeDuplicates() - .sink { [weak self] locator in - guard let self = self, let locator = locator else { - return - } - print(TAG, "tts send audio-locator") - audioLocatorStreamHandler?.sendEvent(locator) - let chapterNo = publication.readingOrder.firstIndexWithHREF(locator.href) - updateNowPlaying(chapterNo: chapterNo, infoType: prefs?.controlPanelInfoType ?? ControlPanelInfoType.standard, publication: publication) - } - .store(in: &subscriptions) - - playingWordRangeSubject - .removeDuplicates() - // Improve performances by throttling the moves to maximum one per second. - .throttle(for: 1, scheduler: RunLoop.main, latest: true) - .drop(while: { [weak self] _ in self?.isMoving ?? true }) - .sink { [weak self] locator in - guard let self = self else { - return - } - - print(TAG, "tts navigate reader to locator") - isMoving = true - Task { - let _ = await self.syncWithAudioLocator(locator) - self.isMoving = false - } - } - .store(in: &subscriptions) - } - - @MainActor - func updateDecorations(uttLocator: Locator?, rangeLocator: Locator?) { - // Update Reader text decorations - var decorations: [Decoration] = [] - if let uttLocator = uttLocator, - let uttDecorationStyle = ttsUtteranceDecorationStyle { - decorations.append(Decoration( - id: "tts-utterance", locator: uttLocator, style: uttDecorationStyle - )) - } - if let rangeLocator = rangeLocator, - let rangeDecorationStyle = ttsRangeDecorationStyle { - decorations.append(Decoration( - id: "tts-range", locator: rangeLocator, style: rangeDecorationStyle - )) - } - currentReaderView?.applyDecorations(decorations, forGroup: "tts") - } - - func ttsEnable(withPreferences ttsPrefs: TTSPreferences) async throws { - print(TAG, "ttsEnable") - try await setupSynthesizer(withPreferences: ttsPrefs) - } - - func ttsStart(fromLocator: Locator?) { - print(TAG, "ttsStart: fromLocator=\(fromLocator?.jsonString ?? "nil")") - self.synthesizer?.start(from: fromLocator) - setupTTSNowPlaying() - } - - func ttsStop() { - self.synthesizer?.stop() - } - - func ttsPause() { - self.synthesizer?.pause() - } - - func ttsResume() { - self.synthesizer?.resume() - } - - func ttsPauseOrResume() { - self.synthesizer?.pauseOrResume() - } - - func ttsNext() { - self.synthesizer?.next() - } - - func ttsPrevious() { - self.synthesizer?.previous() - } - - func ttsGetAvailableVoices() -> [TTSVoice] { - return self.synthesizer?.availableVoices ?? [] - } - - func ttsSetVoice(voiceIdentifier: String) throws { - print(TAG, "ttsSetVoice: voiceIdent=\(String(describing: voiceIdentifier))") - - /// Check that voice with given identifier exists - guard let _ = synthesizer?.voiceWithIdentifier(voiceIdentifier) else { - throw ReadiumError.voiceNotFound - } - - /// Changes will be applied for the next utterance. - synthesizer?.config.voiceIdentifier = voiceIdentifier - } - - func ttsSetPreferences(prefs: TTSPreferences) { - self.ttsPrefs?.rate = prefs.rate ?? self.ttsPrefs?.rate - self.ttsPrefs?.pitch = prefs.pitch ?? self.ttsPrefs?.pitch - self.ttsPrefs?.voiceIdentifier = prefs.voiceIdentifier ?? self.ttsPrefs?.voiceIdentifier - self.ttsPrefs?.overrideLanguage = prefs.overrideLanguage ?? self.ttsPrefs?.overrideLanguage - self.synthesizer?.config.voiceIdentifier = prefs.voiceIdentifier - self.synthesizer?.config.defaultLanguage = prefs.overrideLanguage - } - - // MARK: - Protocol impl. - - public func avTTSEngine(_ engine: AVTTSEngine, didCreateUtterance utterance: AVSpeechUtterance) { - utterance.rate = self.ttsPrefs?.rate ?? AVSpeechUtteranceDefaultSpeechRate - utterance.pitchMultiplier = self.ttsPrefs?.pitch ?? 1.0 - } - - - public func publicationSpeechSynthesizer(_ synthesizer: ReadiumNavigator.PublicationSpeechSynthesizer, stateDidChange state: ReadiumNavigator.PublicationSpeechSynthesizer.State) { - print(TAG, "publicationSpeechSynthesizerStateDidChange") - - switch state { - case let .playing(utt, wordRange): - print(TAG, "tts playing") - /// utterance is a full sentence/paragraph, while range is the currently spoken part. - playingUtterance = utt.locator - if let wordRange = wordRange { - playingWordRangeSubject.send(wordRange) - } - updateDecorations(uttLocator: utt.locator, rangeLocator: wordRange) - case let .paused(utt): - print(TAG, "tts paused at utterance: \(utt.text)") - playingUtterance = utt.locator - case .stopped: - playingUtterance = nil - print(TAG, "tts stopped") - updateDecorations(uttLocator: nil, rangeLocator: nil) - clearNowPlaying() - } - } - - public func publicationSpeechSynthesizer(_ synthesizer: ReadiumNavigator.PublicationSpeechSynthesizer, utterance: ReadiumNavigator.PublicationSpeechSynthesizer.Utterance, didFailWithError error: ReadiumNavigator.PublicationSpeechSynthesizer.Error) { - print(TAG, "publicationSpeechSynthesizerUtteranceDidFail: \(error)") - - //TODO: How can both Reader and Plugin submit on this channel? - //let error = FlutterReadiumError(message: error.localizedDescription, code: "TTSUtteranceFailed", data: utterance.text) - //self.errorStreamHandler?.sendEvent(error) - } - - // MARK: - Now Playing - - // This will display the publication in the Control Center and support - // external controls. - - private func setupTTSNowPlaying() { - Task { - guard let publication = getCurrentPublication() else { - throw ReadiumError.notFound("No current publication") - } - NowPlayingInfo.shared.media = .init( - title: publication.metadata.title ?? "", - artist: publication.metadata.authors.map(\.name).joined(separator: ", "), - ) - - // Async load the cover. - let cover = try? await publication.cover().get() - NowPlayingInfo.shared.media?.artwork = cover - } - - let commandCenter = MPRemoteCommandCenter.shared() - - commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in - self?.ttsPauseOrResume() - return .success - } - commandCenter.nextTrackCommand.addTarget { [weak self] _ in - self?.ttsNext() - return .success - } - commandCenter.previousTrackCommand.addTarget { [weak self] _ in - self?.ttsPrevious() - return .success - } - } - - private func updateNowPlaying(chapterNo: Int?, infoType: ControlPanelInfoType, publication: Publication) { - let nowPlaying = NowPlayingInfo.shared - - nowPlaying.media?.chapterNumber = chapterNo - - if(infoType == .standard || infoType == .standardWCh || chapterNo == nil) { - standardNowPlayingInfo(chapterNo: chapterNo, infoType: infoType, publication: publication) - } else { - nonStandardNowPlayingInfo(chapterNo: chapterNo!, infoType: infoType, publication: publication) - } - } -} diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift index f49fafb..af70540 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift @@ -7,6 +7,7 @@ import ReadiumShared private let TAG = "ReadiumReaderPlugin" +internal var currentPublicationUrlStr: String? internal var currentPublication: Publication? internal var currentReaderView: ReadiumReaderView? @@ -18,31 +19,21 @@ func setCurrentReadiumReaderView(_ readerView: ReadiumReaderView?) { currentReaderView = readerView } -public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.WarningLogger { +public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.WarningLogger, TimebasedListener { + static var registrar: FlutterPluginRegistrar? = nil - /// Audiobook related variables - internal var audiobookVM: AudiobookViewModel? = nil - - internal var mediaOverlays: [FlutterMediaOverlay]? = nil - internal var lastMediaOverlayItem: FlutterMediaOverlayItem? = nil - - /// TTS related variables - @Published internal var playingUtterance: Locator? - internal let playingWordRangeSubject = PassthroughSubject() - internal let playingAudioSubject = PassthroughSubject() - internal var subscriptions: Set = [] - internal var isMoving = false + /// TTS Decoration style + internal var ttsUtteranceDecorationStyle: Decoration.Style? = .highlight(tint: .yellow) + internal var ttsRangeDecorationStyle: Decoration.Style? = .underline(tint: .black) + /// Timebased player events & state internal var audioLocatorStreamHandler: EventStreamHandler? internal var timebasedPlayerStateStreamHandler: EventStreamHandler? internal var lastTimebasedPlayerState: ReadiumTimebasedState? = nil - - internal var synthesizer: PublicationSpeechSynthesizer? = nil - internal var ttsPrefs: TTSPreferences? = nil - - internal var ttsUtteranceDecorationStyle: Decoration.Style? = .highlight(tint: .yellow) - internal var ttsRangeDecorationStyle: Decoration.Style? = .underline(tint: .black) + + /// Timebased Navigator. Can be TTS, Audio or MediaOverlay implementations. + internal var timebasedNavigator: FlutterTimebasedNavigator? = nil lazy var fallbackChapterTitle: LocalizedString = LocalizedString.localized([ "en": "Chapter", @@ -84,8 +75,6 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin result(nil) case "dispose": closePublication() - self.synthesizer?.stop() - self.synthesizer = nil self.audioLocatorStreamHandler?.dispose() self.audioLocatorStreamHandler = nil self.timebasedPlayerStateStreamHandler?.dispose() @@ -105,6 +94,7 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin } let pub: Publication = try await self.loadPublication(fromUrlStr: pubUrlStr).get() currentPublication = pub + currentPublicationUrlStr = pubUrlStr let jsonManifest = pub.jsonManifest await MainActor.run { @@ -180,12 +170,24 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin do { let args = call.arguments as? Dictionary, ttsPrefs = (try? TTSPreferences(fromMap: args ?? [:])) ?? TTSPreferences() - try await self.ttsEnable(withPreferences: ttsPrefs) - await MainActor.run { + //try await self.ttsEnable(withPreferences: ttsPrefs) + + guard let publication = getCurrentPublication() else { + throw ReadiumError.notFound("No publication opened") + } + + Task { @MainActor in + // Start TTS from the reader's current location + let currentLocation = currentReaderView?.getCurrentLocation() + self.timebasedNavigator = FlutterTTSNavigator(publication: publication, preferences: ttsPrefs, initialLocator: currentLocation) + self.timebasedNavigator?.listener = self + Task { + await self.timebasedNavigator?.initNavigator() + } result(nil) } } catch { - await MainActor.run { + Task { @MainActor in result(FlutterError.init( code: "TTSEnableFailed", message: "Failed to enable TTS: \(error.localizedDescription)", @@ -194,14 +196,28 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin } } case "ttsGetAvailableVoices": - let availableVoices = self.ttsGetAvailableVoices() + guard let ttsNavigator = self.timebasedNavigator as? FlutterTTSNavigator else { + return result(FlutterError.init( + code: "TTSError", + message: "TTS Navigator not initialized", + details: nil)) + } + let availableVoices = ttsNavigator.ttsGetAvailableVoices() result(availableVoices.map { $0.jsonString } ) case "ttsSetVoice": let args = call.arguments as! [Any?] let voiceIdentifier = args[0] as! String // TODO: language might be supplied as args[1], ignored on iOS for now. + + guard let ttsNavigator = self.timebasedNavigator as? FlutterTTSNavigator else { + return result(FlutterError.init( + code: "TTSError", + message: "TTS Navigator not initialized", + details: nil)) + } + do { - try self.ttsSetVoice(voiceIdentifier: voiceIdentifier) + try ttsNavigator.ttsSetVoice(voiceIdentifier: voiceIdentifier) result(nil) } catch { result(FlutterError.init( @@ -222,9 +238,15 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin result(nil) case "ttsSetPreferences": let args = call.arguments as! Dictionary + guard let ttsNavigator = self.timebasedNavigator as? FlutterTTSNavigator else { + return result(FlutterError.init( + code: "TTSError", + message: "TTS Navigator not initialized", + details: nil)) + } do { let ttsPrefs = try TTSPreferences(fromMap: args) - ttsSetPreferences(prefs: ttsPrefs) + ttsNavigator.ttsSetPreferences(prefs: ttsPrefs) result(nil) } catch { result(FlutterError.init( @@ -240,54 +262,47 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin } Task.detached(priority: .high) { - - if (self.synthesizer != nil) { - if (locator == nil) { - locator = await currentReaderView?.getFirstVisibleLocator() - } - self.ttsStart(fromLocator: locator) + // If no locator provided, try to start from current ReaderView position. + if (locator == nil) { + locator = await currentReaderView?.getFirstVisibleLocator() } - if (locator != nil) { - await self.audiobookVM?.navigator.go(to: locator!) - } - self.audiobookVM?.navigator.play() + await self.timebasedNavigator?.play(fromLocator: locator) + await MainActor.run { result(nil) } } case "stop": - self.audiobookVM?.navigator.pause() - self.synthesizer?.stop() + Task { @MainActor in + self.timebasedNavigator?.dispose() + self.timebasedNavigator = nil + self.updateReaderViewTimebasedDecorations([]) + } result(nil) case "pause": - self.audiobookVM?.navigator.pause() - self.synthesizer?.pause() + Task { @MainActor in + await self.timebasedNavigator?.pause() + } result(nil) case "resume": - self.audiobookVM?.navigator.play() - self.synthesizer?.resume() + Task { @MainActor in + await self.timebasedNavigator?.resume() + } result(nil) case "togglePlayback": - self.audiobookVM?.navigator.playPause() - self.synthesizer?.pauseOrResume() + Task { @MainActor in + await self.timebasedNavigator?.togglePlayPause() + } result(nil) case "next": - if let vm = self.audiobookVM { - Task { - let seekInterval = vm.preferences.seekInterval ?? 30 - await self.seek(by: seekInterval) - } + Task { @MainActor in + await self.timebasedNavigator?.seekForward() } - self.synthesizer?.next() result(nil) case "previous": - if let vm = self.audiobookVM { - Task { - let seekInterval = vm.preferences.seekInterval ?? 30 - await self.seek(by: -1 * seekInterval) - } + Task { @MainActor in + await self.timebasedNavigator?.seekBackward() } - self.synthesizer?.previous() result(nil) case "goToLocator": Task.detached(priority: .high) { @@ -304,28 +319,15 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin return } var navigated = false - if (self.mediaOverlays != nil && self.audiobookVM != nil) { - if let navigator = self.audiobookVM?.navigator, - let matchingItem = self.mediaOverlays!.firstMap({ $0.itemFromLocator(locator)}), - let audioLocator = matchingItem.audioLocator { - navigated = await navigator.go(to: audioLocator) - // Go will sometimes result in a pause, if buffering was necessary. - // So we actively ensure we resume playing. - self.audiobookVM?.navigator.play() - } - } - else if (self.audiobookVM != nil) { - navigated = await self.audiobookVM!.navigator.go(to: locator) - // Go will sometimes result in a pause, if buffering was necessary. - // So we actively ensure we resume playing. - self.audiobookVM?.navigator.play() - } - else if (self.synthesizer != nil) { - self.synthesizer!.start(from: locator) - navigated = true + + // Timebased Naviagor seek + if (self.timebasedNavigator != nil) { + navigated = await self.timebasedNavigator?.seek(toLocator: locator) ?? false } + // ReaderView goTo else if (currentReaderView != nil) { await currentReaderView?.goToLocator(locator: locator, animated: false) + navigated = true } await MainActor.run { [navigated] in result(navigated) @@ -333,10 +335,11 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin } case "audioEnable": guard let args = call.arguments as? [Any?], - var publication = currentPublication else { + let publication = currentPublication, + let pubUrlStr = currentPublicationUrlStr else { return result(FlutterError.init( code: "audioEnable", - message: "Invalid parameters to audioEnable: \(call.arguments.debugDescription)", + message: "No publication open or Invalid parameters to audioEnable: \(call.arguments.debugDescription)", details: nil)) } Task.detached(priority: .high) { @@ -349,53 +352,102 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin } if (publication.containsMediaOverlays) { - print("Publication with Synchronized Narration reading-order found!") - let newPub = await self.openAsMediaOverlayAudiobook(publication) - // Assign the publication, it should now conform to AudioBook. - publication = newPub + do { + // MediaOverlayNavigator will modify the Publication readingOrder, so we first load a modifiable copy. + let modifiablePublicationCopy = try await self.loadPublication(fromUrlStr: pubUrlStr).get() + await MainActor.run { [locator] in + self.timebasedNavigator = FlutterMediaOverlayNavigator(publication: modifiablePublicationCopy, preferences: prefs, initialLocator: locator) + } + } catch (let err) { + return result(FlutterError.init( + code: "Error", + message: "Failed to reload a modifiable publication copy from: \(pubUrlStr)", + details: err)) + } + } else { + if (!publication.conforms(to: Publication.Profile.audiobook)) { + return result(FlutterError.init( + code: "ArgumentError", + message: "Publication does not contain MediaOverlays or conforms to AudioBook profile. Args: \(call.arguments.debugDescription)", + details: nil)) + } + self.timebasedNavigator = await FlutterAudioNavigator(publication: publication, preferences: prefs, initialLocator: locator) } - if (!publication.conforms(to: Publication.Profile.audiobook)) { - return result(FlutterError.init( - code: "ArgumentError", - message: "Publication does not conformTo AudioBook: \(call.arguments.debugDescription)", - details: nil)) + self.timebasedNavigator?.listener = self + await self.timebasedNavigator?.initNavigator() + + await MainActor.run { + result(nil) } - await self.initAudioPlayback(forPublication: publication, withPrefs: prefs, atLocator: locator) - result(nil) } case "audioSetPreferences": - guard let prefsMap = call.arguments as? Dictionary, - let prefs = try? FlutterAudioPreferences.init(fromMap: prefsMap) else { - return result(FlutterError.init( - code: "audioSetPreferences", - message: "Invalid parameters to audioSetPreferences: \(call.arguments.debugDescription)", - details: nil)) + Task.detached(priority: .high) { + guard let audioNavigator = self.timebasedNavigator as? FlutterAudioNavigator, + let prefsMap = call.arguments as? Dictionary, + let prefs = try? FlutterAudioPreferences.init(fromMap: prefsMap) else { + return result(FlutterError.init( + code: "audioSetPreferences", + message: "AudioNavigator not initialized or Invalid parameters to audioSetPreferences: \(call.arguments.debugDescription)", + details: nil)) + } + Task { @MainActor in + audioNavigator.setAudioPreferences(prefs) + } } - setAudioPreferences(prefs: prefs) default: result(FlutterMethodNotImplemented) } } + + public func timebasedNavigator(_: any FlutterTimebasedNavigator, didChangeState state: ReadiumTimebasedState) { + print(TAG, "TimebasedNavigator state: \(state)") + timebasedPlayerStateStreamHandler?.sendEvent(state.toJsonString()) + } + + public func timebasedNavigator(_: any FlutterTimebasedNavigator, encounteredError error: any Error, withDescription description: String?) { + print(TAG, "TimebasedNavigator error: \(error), description: \(String(describing: description))") + // TODO: submit on error stream + } + + public func timebasedNavigator(_: any FlutterTimebasedNavigator, reachedLocator locator: ReadiumShared.Locator, readingOrderLink: ReadiumShared.Link?) { + print(TAG, "TimebasedNavigator reachedLocator: \(locator), readingOrderLink: \(String(describing: readingOrderLink))") + + Task { @MainActor [locator] in + await currentReaderView?.goToLocator(locator: locator, animated: false) + } + } + + public func timebasedNavigator(_: any FlutterTimebasedNavigator, requestsHighlightAt locator: ReadiumShared.Locator?, withWordLocator wordLocator: ReadiumShared.Locator?) { + print(TAG, "TimebasedNavigator requestsHighlightAt: \(String(describing: locator)), withWordLocator: \(String(describing: wordLocator))") + + // Update Reader text decorations + var decorations: [Decoration] = [] + if let uttLocator = locator, + let uttDecorationStyle = ttsUtteranceDecorationStyle { + decorations.append(Decoration( + id: "tts-utt", locator: uttLocator, style: uttDecorationStyle + )) + } + if let rangeLocator = wordLocator, + let rangeDecorationStyle = ttsRangeDecorationStyle { + decorations.append(Decoration( + id: "tts-range", locator: rangeLocator, style: rangeDecorationStyle + )) + } + Task { @MainActor [decorations] in + updateReaderViewTimebasedDecorations(decorations) + } + } } /// Extension for handling publication interactions extension FlutterReadiumPlugin { - private func initAudioPlayback( - forPublication publication: Publication, - withPrefs prefs: FlutterAudioPreferences, - atLocator locator: Locator?, - ) async -> Void { - await self.setupAudiobookNavigator(publication: publication, initialLocator: locator, initialPreferences: prefs) - // TODO: Should we still auto-play on iOS? - self.play() - } - @MainActor - func syncWithAudioLocator(_ locator: Locator) async -> Bool? { - return await currentReaderView?.justGoToLocator(locator, animated: false) + func updateReaderViewTimebasedDecorations(_ decorations: [Decoration]) { + currentReaderView?.applyDecorations(decorations, forGroup: "timebased-highlight") } func clearNowPlaying() { @@ -453,65 +505,12 @@ extension FlutterReadiumPlugin { private func closePublication() { // Clean-up any resources associated with the publication. - synthesizer?.stop() - synthesizer?.delegate = nil - synthesizer = nil - if (audiobookVM != nil) { - audiobookVM?.navigator.pause() - audiobookVM?.navigator.delegate = nil - audiobookVM = nil - } - // Cancel any locator/event subscription jobs - subscriptions.forEach { job in job.cancel() } - currentPublication?.close() - currentPublication = nil - } - - func standardNowPlayingInfo(chapterNo: Int?, infoType: ControlPanelInfoType, publication: Publication?) { - let authors = publication?.metadata.authors.map(\.name).joined(separator: ", ") ?? "" - var title = publication?.metadata.title ?? "" - - NowPlayingInfo.shared.media?.artist = authors - - if (infoType == .standardWCh && chapterNo != nil) { - let currentChapter = publication?.readingOrder[chapterNo!].title ?? "\(generatedFallbackChapterTitle) \(chapterNo! + 1)" - title += " - \(currentChapter)" - - NowPlayingInfo.shared.media?.title = title - } else { - NowPlayingInfo.shared.media?.title = title - } - - } - - func nonStandardNowPlayingInfo(chapterNo: Int, infoType: ControlPanelInfoType, publication: Publication?) { - var currentChapter = publication?.readingOrder[chapterNo].title - let title = publication?.metadata.title ?? "" - - if (infoType == .chapterTitleAuthor || infoType == .chapterTitle) { - - if (currentChapter == nil) { - currentChapter = "\(generatedFallbackChapterTitle) \(chapterNo + 1)" - } - - NowPlayingInfo.shared.media?.title = currentChapter! - - if (infoType == .chapterTitle) { - NowPlayingInfo.shared.media?.artist = title - } else { - let authors = publication?.metadata.authors.map(\.name).joined(separator: ", ") ?? "" - let titleWithAuthors = "\(title) - \(authors)" - NowPlayingInfo.shared.media?.artist = titleWithAuthors - } - - } else { - NowPlayingInfo.shared.media?.artist = currentChapter - NowPlayingInfo.shared.media?.title = title + Task { @MainActor in + self.timebasedNavigator?.dispose() + self.timebasedNavigator = nil + currentPublication?.close() + currentPublication = nil + currentPublicationUrlStr = nil } } - - var generatedFallbackChapterTitle: String { - let code = currentPublication?.metadata.language?.code.bcp47 - return fallbackChapterTitle.string(forLanguageCode: code) - } } diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift index e27feb7..35688e0 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift @@ -446,6 +446,7 @@ class ReadiumReaderView: NSObject, FlutterPlatformView, EPUBNavigatorDelegate { print(TAG, "Disposing readiumViewController") readiumViewController.view.removeFromSuperview() readiumViewController.delegate = nil + self.readerStatusStreamHandler?.sendEvent(ReadiumReaderStatusClosed) result(nil) break default: diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterAudioPreferences.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterAudioPreferences.swift index b79ff18..093034a 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterAudioPreferences.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterAudioPreferences.swift @@ -1,20 +1,30 @@ -public struct FlutterAudioPreferences { - public var volume: Double? +public struct FlutterAudioPreferences { + public var volume: Double - public var speed: Double? + public var speed: Double - public var pitch: Double? + public var pitch: Double - public var seekInterval: Double? + public var seekInterval: Double - public var controlPanelInfoType: ControlPanelInfoType? + public var controlPanelInfoType: ControlPanelInfoType + + public var updateIntervalSecs: TimeInterval - public init(volume: Double = 1.0, rate: Double = 1.0, pitch: Double = 1.0, seekInterval: Double = 30, controlPanelInfoType: ControlPanelInfoType = ControlPanelInfoType.standard) { + public init( + volume: Double = 1.0, + rate: Double = 1.0, + pitch: Double = 1.0, + seekInterval: Double = 30, + controlPanelInfoType: ControlPanelInfoType = ControlPanelInfoType.standard, + updateIntervalSecs: TimeInterval = 0.2) + { self.volume = volume self.speed = rate self.pitch = pitch self.seekInterval = seekInterval self.controlPanelInfoType = controlPanelInfoType + self.updateIntervalSecs = updateIntervalSecs } init(fromMap jsonMap: Dictionary) throws { @@ -22,13 +32,12 @@ volume = map["volume"] as? Double ?? 1.0, rate = map["speed"] as? Double ?? 1.0, pitch = map["pitch"] as? Double ?? 1.0, - seekInterval = map["seekInterval"] as? Double ?? 30 + seekInterval = map["seekInterval"] as? Double ?? 30, + updateIntervalSecs: TimeInterval = map["updateIntervalSecs"] as? TimeInterval ?? 0.2, + controlPanelInfoType = ControlPanelInfoType(from: map["controlPanelInfoType"] as? String) - let controlPanelInfoTypeStr = map["controlPanelInfoType"] as? String - let mapControlPanelInfoType = ControlPanelInfoType(from: controlPanelInfoTypeStr) - // TODO: Does audio prefs need to be clamped? let avRate = clamp(rate, minValue: 0.1, maxValue: 5.0) let avPitch = clamp(pitch, minValue: 0.5, maxValue: 2.0) - self.init(volume: volume, rate: avRate, pitch: avPitch, seekInterval: seekInterval, controlPanelInfoType: mapControlPanelInfoType ) + self.init(volume: volume, rate: avRate, pitch: avPitch, seekInterval: seekInterval, controlPanelInfoType: controlPanelInfoType, updateIntervalSecs: updateIntervalSecs) } } diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterMediaOverlay.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterMediaOverlay.swift index 9cbdefe..6d07045 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterMediaOverlay.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterMediaOverlay.swift @@ -131,7 +131,7 @@ final class FlutterMediaOverlayItem: NSObject { } // MARK: Locators - var textLocator: Locator? { + var asTextLocator: Locator? { guard let href = URL(string: text.split(separator: "#", maxSplits: 1).first.map(String.init) ?? "") else { return nil } @@ -150,7 +150,7 @@ final class FlutterMediaOverlayItem: NSObject { return locator } - var audioLocator: Locator? { + var asAudioLocator: Locator? { guard let href = URL(string: audioFile) else { return nil } let start = audioStart ?? 0.0 return Locator( @@ -160,6 +160,16 @@ final class FlutterMediaOverlayItem: NSObject { ) } + func toCombinedLocator(fromPlaybackLocator audioLocator: Locator) -> Locator? { + guard var textLocator = self.asTextLocator else { return nil } + textLocator.mediaType = .mpegAudio + textLocator.locations.progression = audioLocator.locations.progression + textLocator.locations.totalProgression = audioLocator.locations.totalProgression + textLocator.locations.position = audioLocator.locations.position + textLocator.locations.fragments = textLocator.locations.fragments + audioLocator.locations.fragments + return textLocator + } + // MARK: JSON static func fromJson(_ json: [String: Any], atPosition position: Int) -> FlutterMediaOverlayItem? { guard diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/ReadiumTimeBasedState.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/ReadiumTimeBasedState.swift index 077297d..25cff93 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/ReadiumTimeBasedState.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/ReadiumTimeBasedState.swift @@ -1,6 +1,6 @@ -import ReadiumShared +import ReadiumShared -enum TimebasedState: String { +public enum TimebasedState: String { case playing case loading case paused @@ -8,7 +8,8 @@ enum TimebasedState: String { case failure } -class ReadiumTimebasedState { +public class ReadiumTimebasedState : Equatable { + var state: TimebasedState var currentOffset: TimeInterval? var currentBuffered: TimeInterval? @@ -55,4 +56,12 @@ class ReadiumTimebasedState { guard let data = try? JSONSerialization.data(withJSONObject: toJson(), options: options) else { return nil } return String(data: data, encoding: .utf8) } + + public static func == (lhs: ReadiumTimebasedState, rhs: ReadiumTimebasedState) -> Bool { + return lhs.state == rhs.state && + lhs.currentOffset == rhs.currentOffset && + lhs.currentBuffered == rhs.currentBuffered && + lhs.currentDuration == rhs.currentDuration && + lhs.currentLocator == rhs.currentLocator + } } diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterAudioNavigator.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterAudioNavigator.swift new file mode 100644 index 0000000..7e63b2f --- /dev/null +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterAudioNavigator.swift @@ -0,0 +1,248 @@ +import Combine +import MediaPlayer +import ReadiumShared +import ReadiumNavigator + +public class FlutterAudioNavigator: FlutterTimebasedNavigator, AudioNavigatorDelegate +{ + internal let TAG = "FlutterAudioNavigator" + + internal var _publication: Publication + internal var _initialLocator: Locator? + internal var _preferences: FlutterAudioPreferences + internal var _lastTimebasedPlayerState: ReadiumTimebasedState? + internal var _nowPlayingUpdater: NowPlayingInfoUpdater + @MainActor internal var _audioNavigator: AudioNavigator? + + internal var subscriptions: Set = [] + + @Published var cover: UIImage? + @Published var playback: MediaPlaybackInfo = .init() + + public var publication: Publication { + get { + return self._publication + } + } + public var initialLocator: Locator? { + get { + return self._initialLocator + } + } + + public var listener: (any TimebasedListener)? + + public init(publication: Publication, preferences: FlutterAudioPreferences, initialLocator: Locator?) { + self._publication = publication + self._preferences = preferences + self._initialLocator = initialLocator + self._nowPlayingUpdater = NowPlayingInfoUpdater( + withPublication: publication, + infoType: preferences.controlPanelInfoType + ) + } + + public func initNavigator() async -> Void { + _audioNavigator = AudioNavigator( + publication: publication, + initialLocation: initialLocator, + config: AudioNavigator.Configuration( + preferences: AudioPreferences(fromFlutterPrefs: _preferences) + ) + ) + _audioNavigator?.delegate = self + + // TODO: Why is this public, if always called from itself? + self.setupNavigatorListeners() + + Task { + cover = try? await publication.cover().get() + } + } + + public func setupNavigatorListeners() { + /// Subscribe to changes + $playback + .throttle(for: .seconds(self._preferences.updateIntervalSecs), scheduler: RunLoop.main, latest: true) + .sink { [weak self, TAG] info in + guard let self = self else { + return + } + debugPrint(TAG, "$playback updated: state=\(info.state),index=\(info.resourceIndex),time=\(info.time),progress=\(info.progress)") + + if let location = _audioNavigator?.currentLocation { + self.submitTimebasedPlayerStateToListener(info: info, location: location) + } + } + .store(in: &subscriptions) + } + + public func dispose() -> Void { + self._audioNavigator?.pause() + self._audioNavigator?.delegate = nil + self._audioNavigator = nil + self.listener?.timebasedNavigator(self, didChangeState: .init(state: .ended)) + self.listener = nil + } + + public func play(fromLocator: Locator?) async -> Void { + if (fromLocator != nil) { + let _ = await seek(toLocator: fromLocator!) + } + _audioNavigator?.play() + _nowPlayingUpdater.setupNowPlayingInfo() + _nowPlayingUpdater.setupCommandCenterControls( + preferredIntervals: [_preferences.seekInterval], + seekToEnabled: true, + timebasedNavigator: self + ) + } + + public func pause() async -> Void { + _audioNavigator?.pause() + } + + public func resume() async -> Void { + _audioNavigator?.play() + } + + public func togglePlayPause() async -> Void { + if (playback.state == .playing) { + await pause() + } else { + await resume() + } + } + + public func seekForward() async -> Bool { + await _audioNavigator?.seek(by: self._preferences.seekInterval) + return true + } + + public func seekBackward() async -> Bool { + await _audioNavigator?.seek(by: -1 * self._preferences.seekInterval) + return true + } + + public func seek(toLocator: Locator) async -> Bool { + let wasPlaying = _audioNavigator?.state == .playing || _audioNavigator?.state == .loading + let navigated = await _audioNavigator?.go(to: toLocator) ?? false + if (wasPlaying && navigated) { + _audioNavigator?.play() + } + return navigated + } + + public func seek(toOffset: Double) async -> Bool { + let wasPlaying = _audioNavigator?.state == .playing || _audioNavigator?.state == .loading + await _audioNavigator?.seek(to: toOffset) + if (wasPlaying) { + _audioNavigator?.play() + } + return true + } + + // MARK: AudioNavigatorDelegate + + /// Called when the playback updates. + public func navigator(_ navigator: AudioNavigator, playbackDidChange info: MediaPlaybackInfo) { + self._nowPlayingUpdater.updateNowPlaying(chapterNo: info.resourceIndex) + self._nowPlayingUpdater.updateCommandCenterControls() + self.playback = info + } + + public func navigator(_ navigator: Navigator, locationDidChange locator: Locator) { + // Submit new locator to the listener + self.submitAudioLocatorToListener(locator) + + if let info = _audioNavigator?.playbackInfo { + self.submitTimebasedPlayerStateToListener(info: info, location: locator) + } + } + + /// Called when the ranges of buffered media data change. + /// Warning: They may be discontinuous. + public func navigator(_ navigator: AudioNavigator, loadedTimeRangesDidChange ranges: [Range]) { + // Simplified buffer range to TimeInterval, by just taking highest upper bound. + // May be too optimistic if ranges are discontinuous. + let highestUpperBound: TimeInterval = ranges.map(\.upperBound).max() ?? 0 + + if let info = _audioNavigator?.playbackInfo, + let location = _audioNavigator?.currentLocation { + self.submitTimebasedPlayerStateToListener(info: info, location: location, bufferedInterval: highestUpperBound) + } + } + + /// Called when the navigator finished playing the current resource. + /// Returns whether the next resource should be played. Default is true. + public func navigator(_ navigator: AudioNavigator, shouldPlayNextResource info: MediaPlaybackInfo) -> Bool { + return true + } + + public func navigator(_ navigator: any ReadiumNavigator.Navigator, presentError error: ReadiumNavigator.NavigatorError) { + debugPrint(TAG, "presentError: \(error)") + // TODO: Only relevant when supporting LCP, error can only be copyForbidden. + } + + public func navigator(_ navigator: any ReadiumNavigator.Navigator, didFailToLoadResourceAt href: ReadiumShared.RelativeURL, withError error: ReadiumShared.ReadError) { + self.listener?.timebasedNavigator(self, encounteredError: error, withDescription: "DidFailToLoadResourceAt: \(href)") + } + + // MARK: AudioNavigator specific API + + @MainActor + func setAudioPreferences(_ preferences: FlutterAudioPreferences) { + self._preferences = preferences + self._audioNavigator?.submitPreferences(AudioPreferences(fromFlutterPrefs: preferences)) + } + + var canGoBackward: Bool { + self._audioNavigator?.canGoBackward ?? false + } + + var canGoForward: Bool { + self._audioNavigator?.canGoForward ?? false + } + + @MainActor + public func skipForward() async -> Bool { + if _audioNavigator?.canGoForward != true { + return false + } + return await _audioNavigator?.goForward() ?? false + } + + @MainActor + public func skipBackward() async -> Bool { + if _audioNavigator?.canGoBackward != true { + return false + } + return await _audioNavigator?.goBackward() ?? false + } + + // MARK: Internal AudioNavigator API + + internal func submitAudioLocatorToListener(_ locator: Locator) { + let readingOrderLink = self.publication.readingOrder.firstWithHREF(locator.href) + self.listener?.timebasedNavigator(self, reachedLocator: locator, readingOrderLink: readingOrderLink) + } + + internal func submitTimebasedPlayerStateToListener(info: MediaPlaybackInfo, location: Locator, bufferedInterval: TimeInterval? = nil) { + // Create TimebasedState and send it over the timebased-state stream. + let state = ReadiumTimebasedState( + state: info.state.asTimebasedState, + currentOffset: info.time, + currentBuffered: bufferedInterval, + currentDuration: info.duration ?? nil, + currentLocator: location + ) + + // If state has changed, submit it to listener. + if (state != self._lastTimebasedPlayerState) { + self._lastTimebasedPlayerState = state + self.listener?.timebasedNavigator(self, didChangeState: state) + } else { + debugPrint(TAG, "Skipped state submission - duplicate") + } + } +} diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterMediaOverlayNavigator.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterMediaOverlayNavigator.swift new file mode 100644 index 0000000..0a55bdf --- /dev/null +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterMediaOverlayNavigator.swift @@ -0,0 +1,114 @@ +// +// FlutterMediaOverlayNavigator.swift +// Pods +// +// Created by Daniel Dam Freiling on 29/10/2025. +// + +import ReadiumShared +import ReadiumNavigator + +public class FlutterMediaOverlayNavigator : FlutterAudioNavigator +{ + internal let OTAG = "FlutterMediaOverlayNavigator" + + internal var mediaOverlays: [FlutterMediaOverlay] = [] + internal var lastMediaOverlayItem: FlutterMediaOverlayItem? = nil + + public override init(publication: Publication, preferences: FlutterAudioPreferences, initialLocator: Locator?) { + super.init(publication: publication, preferences: preferences, initialLocator: initialLocator) + + // Map the initial Text-based locator to Audio-based MediaOverlay Locator. + self._initialLocator = self.mapTextLocatorToMediaOverlayAudioLocator(initialLocator) + } + + public override func initNavigator() async -> Void { + debugPrint(OTAG, "Publication with Synchronized Narration reading-order found!") + let narrationLinks = publication.readingOrder.compactMap { + var link = $0.alternates.filterByMediaType(MediaType("application/vnd.syncnarr+json")!).first + link?.title = $0.title + return link + } + let narrationJson = await narrationLinks.asyncCompactMap { try? await publication.get($0)?.readAsJSONObject().get() } + let mediaOverlays = narrationJson.enumerated().compactMap({ idx, json in FlutterMediaOverlay.fromJson(json, atPosition: idx) }) + + // Assert that we did not lose any MediaOverlays during JSON deserialization. + assert(mediaOverlays.count == narrationLinks.count) + + let audioReadingOrder = mediaOverlays.enumerated().map { (idx, narr) in + narrationLinks.getOrNil(idx).map { + return Link( + href: narr.items.first!.audioFile, + mediaType: MediaType.mpegAudio, + title: $0.title, + duration: narr.items.reduce(0, { $0 + ($1.audioDuration ?? 0) }) + ) + } + }.filter({ $0 != nil }) as! [Link] + + // Copy the manifest and set its readingOrder to audioReadingOrder. + var audioPubManifest = publication.manifest // var of struct == implicit copy + audioPubManifest.readingOrder = audioReadingOrder + audioPubManifest.metadata.conformsTo = [Publication.Profile.audiobook] + + // Note: This modifies the Publication reference !!! + // For now caller must re-load the Publication from same URL, to get a separate reference. + publication.manifest = audioPubManifest + + debugPrint(OTAG, "New audio readingOrder found: \(audioReadingOrder)") + // Save the media-overlays for later position matching. + self.mediaOverlays = mediaOverlays + + await super.initNavigator() + } + + override public func play(fromLocator: Locator?) async { + // Map the initial Text-based locator to Audio-based MediaOverlay Locator. + let audioFromLocator = mapTextLocatorToMediaOverlayAudioLocator(fromLocator) + await super.play(fromLocator: audioFromLocator) + } + + override public func seek(toLocator: Locator) async -> Bool { + guard let navigator = _audioNavigator, + let audioLocator = mapTextLocatorToMediaOverlayAudioLocator(toLocator) else { + return false + } + // Found a matching Audio Locator from given Text-based Locator. + let navigated = await navigator.go(to: audioLocator) + // Go will sometimes result in a pause, if buffering was necessary. + // So we actively ensure we resume playing. + navigator.play() + return navigated + } + + internal override func submitAudioLocatorToListener(_ location: Locator) { + // Map audio offset Locator to a Text-based Locator, before submitting to listener. + if let timeOffsetStr = location.locations.fragments.first(where: { $0.starts(with: "t=") })?.dropFirst(2), + let timeOffset = Double(timeOffsetStr), + let mediaOverlay = mediaOverlays.first(where: { $0.itemInRangeOfTime(timeOffset, inHref: location.href.string) }), + let combinedLocator = mediaOverlay.toCombinedLocator(fromPlaybackLocator: location) { + + // Combined Text/Audio Locator matching the audio position is created and should be sent back. + self.listener?.timebasedNavigator(self, reachedLocator: combinedLocator, readingOrderLink: nil) + self.listener?.timebasedNavigator(self, requestsHighlightAt: combinedLocator, withWordLocator: nil) + } else { + debugPrint(OTAG, "Did not find MediaOverlay matching audio Locator: \(location)") + } + } + + internal func mapTextLocatorToMediaOverlayAudioLocator(_ textLocator: Locator?) -> Locator? { + guard let textLocator = textLocator, + let matchingItem = self.mediaOverlays.firstMap({ $0.itemFromLocator(textLocator)}), + var audioLocator = matchingItem.asAudioLocator else { + return nil + } + // If the input Text Locator, is a combined locator with a time fragment + // we use this, as it can be more precise than the MediaOverlayItem fragment. + if let textLocatorTime = textLocator.locations.time, + let textLocatorTimeBegin = textLocatorTime.begin { + debugPrint(OTAG, "TextLocator had more precise time offset: \(textLocatorTimeBegin)") + audioLocator.locations.fragments = ["t=\(textLocatorTimeBegin)"] + } + return audioLocator + } +} diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterTTSNavigator.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterTTSNavigator.swift new file mode 100644 index 0000000..38f9e6f --- /dev/null +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterTTSNavigator.swift @@ -0,0 +1,231 @@ +import Combine +import AVFAudio +import ReadiumShared +import ReadiumNavigator + +public class FlutterTTSNavigator: FlutterTimebasedNavigator, PublicationSpeechSynthesizerDelegate, AVTTSEngineDelegate +{ + private let TAG = "FlutterTTSNavigator" + private var _publication: Publication + private var _initialLocator: Locator? + + internal var synthesizer: PublicationSpeechSynthesizer? + internal var engine: AVTTSEngine? + internal var preferences: TTSPreferences + internal var nowPlayingUpdater: NowPlayingInfoUpdater + + /// TTS related variables + @Published internal var playingUtterance: Locator? + internal let playingWordRangeSubject = PassthroughSubject() + internal var subscriptions: Set = [] + internal var isMoving = false + + public var publication: Publication { + get { + return self._publication + } + } + public var initialLocator: Locator? { + get { + return self._initialLocator + } + } + + public var listener: (any TimebasedListener)? + + public init( + publication: Publication, + preferences: TTSPreferences = TTSPreferences.init(), + initialLocator: Locator? + ) { + self._publication = publication + self._initialLocator = initialLocator + self.preferences = preferences + self.nowPlayingUpdater = .init(withPublication: publication) + } + + public func initNavigator() -> Void { + self.engine = AVTTSEngine() + self.synthesizer = PublicationSpeechSynthesizer( + publication: publication, + config: PublicationSpeechSynthesizer.Configuration( + defaultLanguage: preferences.overrideLanguage, + voiceIdentifier: preferences.voiceIdentifier, + ), + engineFactory: { + return self.engine! + } + )! + engine?.delegate = self + self.synthesizer?.delegate = self + + // TODO: Why is this public, if always called from itself? + self.setupNavigatorListeners() + } + + public func setupNavigatorListeners() -> Void { + $playingUtterance + .removeDuplicates() + .sink { [weak self] locator in + guard let self = self, let locator = locator else { + return + } + debugPrint(TAG, "tts send audio-locator") + let chapterNo = publication.readingOrder.firstIndexWithHREF(locator.href) + let link = self.publication.readingOrder.firstWithHREF(locator.href) + + self.nowPlayingUpdater.updateNowPlaying(chapterNo: chapterNo) + self.nowPlayingUpdater.updateCommandCenterControls() + listener?.timebasedNavigator(self, reachedLocator: locator, readingOrderLink: link) + } + .store(in: &subscriptions) + + playingWordRangeSubject + .removeDuplicates() + // Improve performances by throttling the reader sync + .throttle(for: .milliseconds(100), scheduler: RunLoop.main, latest: true) + .sink { [weak self] locator in + guard let self = self else { + return + } + + debugPrint(TAG, "sync reader to locator") + let link = self.publication.readingOrder.firstWithHREF(locator.href) + listener?.timebasedNavigator(self, reachedLocator: locator, readingOrderLink: link) + } + .store(in: &subscriptions) + } + + public func dispose() -> Void { + nowPlayingUpdater.clearNowPlaying() + self.subscriptions.forEach { $0.cancel() } + self.synthesizer?.stop() + self.synthesizer?.delegate = nil + self.engine?.delegate = nil + self.listener?.timebasedNavigator(self, didChangeState: .init(state: .ended)) + self.listener = nil + } + + public func play(fromLocator: Locator?) async -> Void { + self.synthesizer?.start(from: fromLocator) + nowPlayingUpdater.setupNowPlayingInfo() + nowPlayingUpdater.setupCommandCenterControls( + preferredIntervals: [], + skipTrackEnabled: true, + timebasedNavigator: self + ) + } + + public func pause() async -> Void { + self.synthesizer?.pause() + } + + public func resume() async -> Void { + self.synthesizer?.resume() + } + + public func togglePlayPause() async -> Void { + guard let synth = self.synthesizer else { + return + } + if case .playing(_,_) = synth.state { + await self.pause() + } else { + await self.play(fromLocator: nil) + } + } + + public func seekForward() async -> Bool { + self.synthesizer?.next() + return true + } + + public func seekBackward() async -> Bool { + self.synthesizer?.previous() + return true + } + + public func seek(toLocator: Locator) async -> Bool { + self.synthesizer?.start(from: toLocator) + return true + } + + public func seek(toOffset: Double) async -> Bool { + // Cannot be implemented for TTS + return false + } + + // MARK: TTS Specific APIs + + func ttsSetPreferences(prefs: TTSPreferences) { + preferences.rate = prefs.rate + preferences.pitch = prefs.pitch + preferences.voiceIdentifier = prefs.voiceIdentifier + preferences.overrideLanguage = prefs.overrideLanguage + self.synthesizer?.config.voiceIdentifier = preferences.voiceIdentifier + self.synthesizer?.config.defaultLanguage = preferences.overrideLanguage + } + + func ttsGetAvailableVoices() -> [TTSVoice] { + return self.synthesizer?.availableVoices ?? [] + } + + func ttsSetVoice(voiceIdentifier: String) throws { + debugPrint(TAG, "ttsSetVoice: voiceIdent=\(String(describing: voiceIdentifier))") + + /// Check that voice with given identifier exists + guard let _ = synthesizer?.voiceWithIdentifier(voiceIdentifier) else { + throw ReadiumError.voiceNotFound + } + + /// Changes will be applied for the next utterance. + synthesizer?.config.voiceIdentifier = voiceIdentifier + } + + // MARK: PublicationSpeechSynthesizerDelegate + + public func publicationSpeechSynthesizer(_ synthesizer: ReadiumNavigator.PublicationSpeechSynthesizer, stateDidChange state: ReadiumNavigator.PublicationSpeechSynthesizer.State) { + debugPrint(TAG, "publicationSpeechSynthesizerStateDidChange") + + switch state { + case let .playing(utt, wordRange): + debugPrint(TAG, "tts playing") + /// utterance is a full sentence/paragraph, while range is the currently spoken part. + playingUtterance = utt.locator + if let wordRange = wordRange { + playingWordRangeSubject.send(wordRange) + } + self.listener?.timebasedNavigator(self, requestsHighlightAt: utt.locator, withWordLocator: wordRange) + case let .paused(utt): + debugPrint(TAG, "tts paused at utterance: \(utt.text)") + playingUtterance = utt.locator + case .stopped: + playingUtterance = nil + debugPrint(TAG, "tts stopped") + self.listener?.timebasedNavigator(self, requestsHighlightAt: nil, withWordLocator: nil) + //updateDecorations(uttLocator: nil, rangeLocator: nil) + self.nowPlayingUpdater.clearNowPlaying() + } + + let state = ReadiumTimebasedState(state: state.asTimebasedState, currentLocator: playingUtterance) + self.listener?.timebasedNavigator(self, didChangeState: state) + } + + public func publicationSpeechSynthesizer(_ synthesizer: ReadiumNavigator.PublicationSpeechSynthesizer, utterance: ReadiumNavigator.PublicationSpeechSynthesizer.Utterance, didFailWithError error: ReadiumNavigator.PublicationSpeechSynthesizer.Error) { + debugPrint(TAG, "publicationSpeechSynthesizerUtteranceDidFail: \(error)") + + self.listener?.timebasedNavigator(self, encounteredError: error, withDescription: "TTSUtteranceFailed") + + //TODO: How can both Reader and Plugin submit on this channel? + //let error = FlutterReadiumError(message: error.localizedDescription, code: "TTSUtteranceFailed", data: utterance.text) + //self.errorStreamHandler?.sendEvent(error) + } + + // MARK: AVTTSEngineDelegate + + public func avTTSEngine(_ engine: ReadiumNavigator.AVTTSEngine, didCreateUtterance utterance: AVSpeechUtterance) { + // This is the place to hook into, in order to change rate & pitch for TTS. + utterance.rate = preferences.rate ?? AVSpeechUtteranceDefaultSpeechRate + utterance.pitchMultiplier = preferences.pitch ?? 1.0 + } +} diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterTimebasedNavigator.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterTimebasedNavigator.swift new file mode 100644 index 0000000..38703bf --- /dev/null +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterTimebasedNavigator.swift @@ -0,0 +1,40 @@ +import Combine +import ReadiumShared + +public protocol TimebasedListener { + func timebasedNavigator(_: FlutterTimebasedNavigator, didChangeState state: ReadiumTimebasedState) + func timebasedNavigator(_: FlutterTimebasedNavigator, encounteredError error: Error, withDescription description: String?) + func timebasedNavigator(_: FlutterTimebasedNavigator, reachedLocator locator: Locator, readingOrderLink: Link?) + func timebasedNavigator(_: FlutterTimebasedNavigator, requestsHighlightAt locator: Locator?, withWordLocator wordLocator: Locator?) +} + +public protocol FlutterTimebasedNavigator +{ + var publication: Publication { get } + var initialLocator: Locator? { get } + var listener: TimebasedListener? { get set } + + // Current Locator which should be sent back over the bridge to Flutter. + //var currentLocator: PassthroughSubject { get } + + func initNavigator() async -> Void + func setupNavigatorListeners() -> Void + @MainActor + func dispose() -> Void + @MainActor + func play(fromLocator: Locator?) async -> Void + @MainActor + func pause() async -> Void + @MainActor + func resume() async -> Void + @MainActor + func togglePlayPause() async -> Void + @MainActor + func seekForward() async -> Bool + @MainActor + func seekBackward() async -> Bool + @MainActor + func seek(toLocator: Locator) async -> Bool + @MainActor + func seek(toOffset: Double) async -> Bool +} diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/NowPlayingInfoUpdater.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/NowPlayingInfoUpdater.swift new file mode 100644 index 0000000..b761d92 --- /dev/null +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/NowPlayingInfoUpdater.swift @@ -0,0 +1,217 @@ +// +// NowPlayingInfoUpdater.swift +// Pods +// +// Created by Daniel Dam Freiling on 28/10/2025. +// +import Combine +import ReadiumShared +import MediaPlayer + +public class NowPlayingInfoUpdater { + + public var infoType: ControlPanelInfoType + internal var publication: Publication + private var coverSub: Set = [] + + lazy var fallbackChapterTitle: LocalizedString = LocalizedString.localized([ + "en": "Chapter", + "da": "Kapitel", + "sv": "Kapitel", + "no": "Kapittel", + "is": "Kafli", + ]) + + lazy var generatedFallbackChapterTitle: String = { + let code = publication.metadata.language?.code.bcp47 + return fallbackChapterTitle.string(forLanguageCode: code) + }() + + @Published var cover: UIImage? = nil + + init( + withPublication publication: Publication, + infoType: ControlPanelInfoType = .standard + ) { + self.publication = publication + self.infoType = infoType + + Task { + // TODO: Should we limit cover size here? + cover = try? await publication.cover().get() + } + } + + public func setupNowPlayingInfo() { + let nowPlaying = NowPlayingInfo.shared + + // Initial publication metadata. + nowPlaying.media = NowPlayingInfo.Media( + title: publication.metadata.title ?? "", + artist: publication.metadata.authors.map(\.name).joined(separator: ", "), + chapterCount: publication.readingOrder.count + ) + + // Update the artwork once cover is loaded. + $cover + .sink { cover in + nowPlaying.media?.artwork = cover + } + .store(in: &coverSub) + } + + public func clearNowPlaying() { + NowPlayingInfo.shared.clear() + coverSub.forEach { $0.cancel() } + } + + public func updateNowPlaying(chapterNo: Int?) { + let nowPlaying = NowPlayingInfo.shared + + nowPlaying.media?.chapterNumber = chapterNo + + if (infoType == .standard || infoType == .standardWCh || chapterNo == nil) { + self.standardNowPlayingInfo(chapterNo: chapterNo) + } else { + self.nonStandardNowPlayingInfo(chapterNo: chapterNo!) + } + } + + private func standardNowPlayingInfo(chapterNo: Int?) { + let authors = publication.metadata.authors.map(\.name).joined(separator: ", ") + var title = publication.metadata.title ?? "" + + NowPlayingInfo.shared.media?.artist = authors + + if (infoType == .standardWCh && chapterNo != nil) { + let currentChapter = publication.readingOrder[chapterNo!].title ?? "\(generatedFallbackChapterTitle) \(chapterNo! + 1)" + title += " - \(currentChapter)" + + NowPlayingInfo.shared.media?.title = title + } else { + NowPlayingInfo.shared.media?.title = title + } + + } + + private func nonStandardNowPlayingInfo(chapterNo: Int) { + var currentChapter = publication.readingOrder[chapterNo].title + let title = publication.metadata.title ?? "" + + if (infoType == .chapterTitleAuthor || infoType == .chapterTitle) { + + if (currentChapter == nil) { + currentChapter = "\(generatedFallbackChapterTitle) \(chapterNo + 1)" + } + + NowPlayingInfo.shared.media?.title = currentChapter! + + if (infoType == .chapterTitle) { + NowPlayingInfo.shared.media?.artist = title + } else { + let authors = publication.metadata.authors.map(\.name).joined(separator: ", ") + let titleWithAuthors = "\(title) - \(authors)" + NowPlayingInfo.shared.media?.artist = titleWithAuthors + } + + } else { + NowPlayingInfo.shared.media?.artist = currentChapter + NowPlayingInfo.shared.media?.title = title + } + } + + // MARK: Control Center + + public func setupCommandCenterControls( + preferredIntervals: [Double], + skipTrackEnabled: Bool = false, + seekToEnabled: Bool = false, + timebasedNavigator: FlutterTimebasedNavigator? = nil) + { + let rcc = MPRemoteCommandCenter.shared() + + func on(_ command: MPRemoteCommand, _ block: @escaping (FlutterTimebasedNavigator, MPRemoteCommandEvent) -> Void) { + command.addTarget { [weak self] event in + guard let self = self, + let navigator = timebasedNavigator else { + return .noActionableNowPlayingItem + } + block(navigator, event) + return .success + } + } + + on(rcc.playCommand) { navigator, _ in + Task { @MainActor in + await navigator.resume() + } + } + + on(rcc.pauseCommand) { navigator, _ in + Task { @MainActor in + await navigator.pause() + } + } + + on(rcc.togglePlayPauseCommand) { navigator, _ in + Task { @MainActor in + await navigator.togglePlayPause() + } + } + + if (skipTrackEnabled) { + on(rcc.previousTrackCommand) { navigator, _ in + Task { @MainActor in + // TODO: Should these actually skip a full track? + await navigator.seekBackward() + } + } + + on(rcc.nextTrackCommand) { navigator, _ in + Task { @MainActor in + // TODO: Should these actually skip a full track? + await navigator.seekForward() + } + } + } + + rcc.skipBackwardCommand.preferredIntervals = preferredIntervals as [NSNumber] + rcc.skipForwardCommand.preferredIntervals = preferredIntervals as [NSNumber] + + if (!preferredIntervals.isEmpty) { + on(rcc.skipBackwardCommand) { navigator, _ in + Task { + await navigator.seekBackward() + } + } + + on(rcc.skipForwardCommand) { navigator, _ in + Task { + await navigator.seekForward() + } + } + } + + if (seekToEnabled) { + on(rcc.changePlaybackPositionCommand) { navigator, event in + guard let event = event as? MPChangePlaybackPositionCommandEvent else { + return + } + Task { + await navigator.seek(toOffset: event.positionTime) + } + } + } + } + + public func updateCommandCenterControls(timebasedNavigator: FlutterTimebasedNavigator? = nil) { + let rcc = MPRemoteCommandCenter.shared() + + if let audioNavigator = timebasedNavigator as? FlutterAudioNavigator { + Task { @MainActor in + rcc.previousTrackCommand.isEnabled = audioNavigator.canGoBackward + rcc.nextTrackCommand.isEnabled = audioNavigator.canGoForward + } + } + } +} diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/ReadiumExtensions.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/ReadiumExtensions.swift index a4f04ea..fc4d9d8 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/ReadiumExtensions.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/ReadiumExtensions.swift @@ -34,6 +34,16 @@ extension MediaPlaybackState { } } +extension PublicationSpeechSynthesizer.State { + var asTimebasedState: TimebasedState { + switch self { + case .paused: return .paused + case .playing: return .playing + case .stopped: return .ended + } + } +} + extension Link { init(fromJsonString jsonString: String) throws { do { diff --git a/flutter_readium_platform_interface/lib/src/reader/reader_audio_preferences.dart b/flutter_readium_platform_interface/lib/src/reader/reader_audio_preferences.dart index 251b36e..f9b5c0c 100644 --- a/flutter_readium_platform_interface/lib/src/reader/reader_audio_preferences.dart +++ b/flutter_readium_platform_interface/lib/src/reader/reader_audio_preferences.dart @@ -11,6 +11,7 @@ class AudioPreferences { double? speed; double? pitch; double? seekInterval; + double? updateIntervalSecs; ControlPanelInfoType? controlPanelInfoType; Map toMap() => { @@ -18,6 +19,7 @@ class AudioPreferences { 'speed': speed, 'pitch': pitch, 'seekInterval': seekInterval, + 'updateIntervalSecs': updateIntervalSecs, 'controlPanelInfoType': controlPanelInfoType?.toString().split('.').last, }; }