Skip to content

Commit 0455175

Browse files
committed
refactor: improved combined Locator for MediaOverlay playback position sent back to Flutter
1 parent 34867c7 commit 0455175

File tree

5 files changed

+67
-39
lines changed

5 files changed

+67
-39
lines changed

flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ReadiumShared
77

88
private let TAG = "ReadiumReaderPlugin"
99

10+
internal var currentPublicationUrlStr: String?
1011
internal var currentPublication: Publication?
1112
internal var currentReaderView: ReadiumReaderView?
1213

@@ -93,6 +94,7 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin
9394
}
9495
let pub: Publication = try await self.loadPublication(fromUrlStr: pubUrlStr).get()
9596
currentPublication = pub
97+
currentPublicationUrlStr = pubUrlStr
9698

9799
let jsonManifest = pub.jsonManifest
98100
await MainActor.run {
@@ -333,10 +335,11 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin
333335
}
334336
case "audioEnable":
335337
guard let args = call.arguments as? [Any?],
336-
let publication = currentPublication else {
338+
let publication = currentPublication,
339+
let pubUrlStr = currentPublicationUrlStr else {
337340
return result(FlutterError.init(
338341
code: "audioEnable",
339-
message: "Invalid parameters to audioEnable: \(call.arguments.debugDescription)",
342+
message: "No publication open or Invalid parameters to audioEnable: \(call.arguments.debugDescription)",
340343
details: nil))
341344
}
342345
Task.detached(priority: .high) {
@@ -349,13 +352,23 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin
349352
}
350353

351354
if (publication.containsMediaOverlays) {
352-
// TODO: May have to re-load publication from URL and pass the new copy of Publication here.
353-
self.timebasedNavigator = await FlutterMediaOverlayNavigator(publication: publication, preferences: prefs, initialLocator: locator)
355+
do {
356+
// MediaOverlayNavigator will modify the Publication readingOrder, so we first load a modifiable copy.
357+
let modifiablePublicationCopy = try await self.loadPublication(fromUrlStr: pubUrlStr).get()
358+
await MainActor.run { [locator] in
359+
self.timebasedNavigator = FlutterMediaOverlayNavigator(publication: modifiablePublicationCopy, preferences: prefs, initialLocator: locator)
360+
}
361+
} catch (let err) {
362+
return result(FlutterError.init(
363+
code: "Error",
364+
message: "Failed to reload a modifiable publication copy from: \(pubUrlStr)",
365+
details: err))
366+
}
354367
} else {
355368
if (!publication.conforms(to: Publication.Profile.audiobook)) {
356369
return result(FlutterError.init(
357370
code: "ArgumentError",
358-
message: "Publication does not contain MediaOverlays or conformTo AudioBook: \(call.arguments.debugDescription)",
371+
message: "Publication does not contain MediaOverlays or conforms to AudioBook profile. Args: \(call.arguments.debugDescription)",
359372
details: nil))
360373
}
361374
self.timebasedNavigator = await FlutterAudioNavigator(publication: publication, preferences: prefs, initialLocator: locator)
@@ -364,7 +377,9 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin
364377
self.timebasedNavigator?.listener = self
365378
await self.timebasedNavigator?.initNavigator()
366379

367-
result(nil)
380+
await MainActor.run {
381+
result(nil)
382+
}
368383
}
369384
case "audioSetPreferences":
370385
Task.detached(priority: .high) {
@@ -495,6 +510,7 @@ extension FlutterReadiumPlugin {
495510
self.timebasedNavigator = nil
496511
currentPublication?.close()
497512
currentPublication = nil
513+
currentPublicationUrlStr = nil
498514
}
499515
}
500516
}

flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterMediaOverlay.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,16 @@ final class FlutterMediaOverlayItem: NSObject {
160160
)
161161
}
162162

163+
func toCombinedLocator(fromPlaybackLocator audioLocator: Locator) -> Locator? {
164+
guard var textLocator = self.asTextLocator else { return nil }
165+
textLocator.mediaType = .mpegAudio
166+
textLocator.locations.progression = audioLocator.locations.progression
167+
textLocator.locations.totalProgression = audioLocator.locations.totalProgression
168+
textLocator.locations.position = audioLocator.locations.position
169+
textLocator.locations.fragments = textLocator.locations.fragments + audioLocator.locations.fragments
170+
return textLocator
171+
}
172+
163173
// MARK: JSON
164174
static func fromJson(_ json: [String: Any], atPosition position: Int) -> FlutterMediaOverlayItem? {
165175
guard

flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterAudioNavigator.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,8 @@ public class FlutterAudioNavigator: FlutterTimebasedNavigator, AudioNavigatorDel
180180
}
181181

182182
public func navigator(_ navigator: any ReadiumNavigator.Navigator, presentError error: ReadiumNavigator.NavigatorError) {
183-
print(TAG, "presentError: \(error)")
183+
debugPrint(TAG, "presentError: \(error)")
184+
// TODO: Only relevant when supporting LCP, error can only be copyForbidden.
184185
}
185186

186187
public func navigator(_ navigator: any ReadiumNavigator.Navigator, didFailToLoadResourceAt href: ReadiumShared.RelativeURL, withError error: ReadiumShared.ReadError) {
@@ -189,6 +190,7 @@ public class FlutterAudioNavigator: FlutterTimebasedNavigator, AudioNavigatorDel
189190

190191
// MARK: AudioNavigator specific API
191192

193+
@MainActor
192194
func setAudioPreferences(_ preferences: FlutterAudioPreferences) {
193195
self._preferences = preferences
194196
self._audioNavigator?.submitPreferences(AudioPreferences(fromFlutterPrefs: preferences))
@@ -202,13 +204,15 @@ public class FlutterAudioNavigator: FlutterTimebasedNavigator, AudioNavigatorDel
202204
self._audioNavigator?.canGoForward ?? false
203205
}
204206

207+
@MainActor
205208
public func skipForward() async -> Bool {
206209
if _audioNavigator?.canGoForward != true {
207210
return false
208211
}
209212
return await _audioNavigator?.goForward() ?? false
210213
}
211214

215+
@MainActor
212216
public func skipBackward() async -> Bool {
213217
if _audioNavigator?.canGoBackward != true {
214218
return false
@@ -238,7 +242,7 @@ public class FlutterAudioNavigator: FlutterTimebasedNavigator, AudioNavigatorDel
238242
self._lastTimebasedPlayerState = state
239243
self.listener?.timebasedNavigator(self, didChangeState: state)
240244
} else {
241-
print(TAG, "Skipped state submission - duplicate")
245+
debugPrint(TAG, "Skipped state submission - duplicate")
242246
}
243247
}
244248
}

flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterMediaOverlayNavigator.swift

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ public class FlutterMediaOverlayNavigator : FlutterAudioNavigator
1717

1818
public override init(publication: Publication, preferences: FlutterAudioPreferences, initialLocator: Locator?) {
1919
super.init(publication: publication, preferences: preferences, initialLocator: initialLocator)
20+
2021
// Map the initial Text-based locator to Audio-based MediaOverlay Locator.
21-
self._initialLocator = self.mapTextLocatorToMediaOverlayLocator(initialLocator)
22+
self._initialLocator = self.mapTextLocatorToMediaOverlayAudioLocator(initialLocator)
2223
}
2324

2425
public override func initNavigator() async -> Void {
@@ -50,29 +51,26 @@ public class FlutterMediaOverlayNavigator : FlutterAudioNavigator
5051
audioPubManifest.readingOrder = audioReadingOrder
5152
audioPubManifest.metadata.conformsTo = [Publication.Profile.audiobook]
5253

53-
// TODO: This modifies the existing Publication reference !!!
54-
// Instead we may need to re-load the Publication from same URL, to get a separate reference.
55-
var newPub = publication
56-
newPub.manifest = audioPubManifest
54+
// Note: This modifies the Publication reference !!!
55+
// For now caller must re-load the Publication from same URL, to get a separate reference.
56+
publication.manifest = audioPubManifest
5757

5858
debugPrint(OTAG, "New audio readingOrder found: \(audioReadingOrder)")
5959
// Save the media-overlays for later position matching.
6060
self.mediaOverlays = mediaOverlays
61-
// Assign the publication, it should now conform to AudioBook.
62-
self._publication = newPub
6361

6462
await super.initNavigator()
6563
}
6664

6765
override public func play(fromLocator: Locator?) async {
6866
// Map the initial Text-based locator to Audio-based MediaOverlay Locator.
69-
let audioFromLocator = mapTextLocatorToMediaOverlayLocator(fromLocator)
67+
let audioFromLocator = mapTextLocatorToMediaOverlayAudioLocator(fromLocator)
7068
await super.play(fromLocator: audioFromLocator)
7169
}
7270

7371
override public func seek(toLocator: Locator) async -> Bool {
7472
guard let navigator = _audioNavigator,
75-
let audioLocator = mapTextLocatorToMediaOverlayLocator(toLocator) else {
73+
let audioLocator = mapTextLocatorToMediaOverlayAudioLocator(toLocator) else {
7674
return false
7775
}
7876
// Found a matching Audio Locator from given Text-based Locator.
@@ -88,28 +86,29 @@ public class FlutterMediaOverlayNavigator : FlutterAudioNavigator
8886
if let timeOffsetStr = location.locations.fragments.first(where: { $0.starts(with: "t=") })?.dropFirst(2),
8987
let timeOffset = Double(timeOffsetStr),
9088
let mediaOverlay = mediaOverlays.first(where: { $0.itemInRangeOfTime(timeOffset, inHref: location.href.string) }),
91-
var textLocator = mediaOverlay.asTextLocator {
92-
if (!mediaOverlay.isEqual(lastMediaOverlayItem)) {
93-
// Matched a new MediaOverlayItem -> sync reader with its textLocator.
94-
lastMediaOverlayItem = mediaOverlay
95-
textLocator.locations.progression = location.locations.progression
96-
textLocator.locations.position = location.locations.position
97-
98-
// TextLocator matching the audio position is created and should be sent back.
99-
self.listener?.timebasedNavigator(self, reachedLocator: textLocator, readingOrderLink: nil)
100-
self.listener?.timebasedNavigator(self, requestsHighlightAt: textLocator, withWordLocator: nil)
101-
}
89+
let combinedLocator = mediaOverlay.toCombinedLocator(fromPlaybackLocator: location) {
90+
91+
// Combined Text/Audio Locator matching the audio position is created and should be sent back.
92+
self.listener?.timebasedNavigator(self, reachedLocator: combinedLocator, readingOrderLink: nil)
93+
self.listener?.timebasedNavigator(self, requestsHighlightAt: combinedLocator, withWordLocator: nil)
10294
} else {
10395
debugPrint(OTAG, "Did not find MediaOverlay matching audio Locator: \(location)")
10496
}
10597
}
10698

107-
internal func mapTextLocatorToMediaOverlayLocator(_ textLocator: Locator?) -> Locator? {
99+
internal func mapTextLocatorToMediaOverlayAudioLocator(_ textLocator: Locator?) -> Locator? {
108100
guard let textLocator = textLocator,
109101
let matchingItem = self.mediaOverlays.firstMap({ $0.itemFromLocator(textLocator)}),
110-
let audioLocator = matchingItem.asAudioLocator else {
102+
var audioLocator = matchingItem.asAudioLocator else {
111103
return nil
112104
}
105+
// If the input Text Locator, is a combined locator with a time fragment
106+
// we use this, as it can be more precise than the MediaOverlayItem fragment.
107+
if let textLocatorTime = textLocator.locations.time,
108+
let textLocatorTimeBegin = textLocatorTime.begin {
109+
debugPrint(OTAG, "TextLocator had more precise time offset: \(textLocatorTimeBegin)")
110+
audioLocator.locations.fragments = ["t=\(textLocatorTimeBegin)"]
111+
}
113112
return audioLocator
114113
}
115114
}

flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/FlutterTTSNavigator.swift

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public class FlutterTTSNavigator: FlutterTimebasedNavigator, PublicationSpeechSy
7070
guard let self = self, let locator = locator else {
7171
return
7272
}
73-
print(TAG, "tts send audio-locator")
73+
debugPrint(TAG, "tts send audio-locator")
7474
let chapterNo = publication.readingOrder.firstIndexWithHREF(locator.href)
7575
let link = self.publication.readingOrder.firstWithHREF(locator.href)
7676

@@ -89,7 +89,7 @@ public class FlutterTTSNavigator: FlutterTimebasedNavigator, PublicationSpeechSy
8989
return
9090
}
9191

92-
print(TAG, "sync reader to locator")
92+
debugPrint(TAG, "sync reader to locator")
9393
let link = self.publication.readingOrder.firstWithHREF(locator.href)
9494
listener?.timebasedNavigator(self, reachedLocator: locator, readingOrderLink: link)
9595
}
@@ -150,7 +150,6 @@ public class FlutterTTSNavigator: FlutterTimebasedNavigator, PublicationSpeechSy
150150
return true
151151
}
152152

153-
@MainActor
154153
public func seek(toOffset: Double) async -> Bool {
155154
// Cannot be implemented for TTS
156155
return false
@@ -172,7 +171,7 @@ public class FlutterTTSNavigator: FlutterTimebasedNavigator, PublicationSpeechSy
172171
}
173172

174173
func ttsSetVoice(voiceIdentifier: String) throws {
175-
print(TAG, "ttsSetVoice: voiceIdent=\(String(describing: voiceIdentifier))")
174+
debugPrint(TAG, "ttsSetVoice: voiceIdent=\(String(describing: voiceIdentifier))")
176175

177176
/// Check that voice with given identifier exists
178177
guard let _ = synthesizer?.voiceWithIdentifier(voiceIdentifier) else {
@@ -186,23 +185,23 @@ public class FlutterTTSNavigator: FlutterTimebasedNavigator, PublicationSpeechSy
186185
// MARK: PublicationSpeechSynthesizerDelegate
187186

188187
public func publicationSpeechSynthesizer(_ synthesizer: ReadiumNavigator.PublicationSpeechSynthesizer, stateDidChange state: ReadiumNavigator.PublicationSpeechSynthesizer.State) {
189-
print(TAG, "publicationSpeechSynthesizerStateDidChange")
188+
debugPrint(TAG, "publicationSpeechSynthesizerStateDidChange")
190189

191190
switch state {
192191
case let .playing(utt, wordRange):
193-
print(TAG, "tts playing")
192+
debugPrint(TAG, "tts playing")
194193
/// utterance is a full sentence/paragraph, while range is the currently spoken part.
195194
playingUtterance = utt.locator
196195
if let wordRange = wordRange {
197196
playingWordRangeSubject.send(wordRange)
198197
}
199198
self.listener?.timebasedNavigator(self, requestsHighlightAt: utt.locator, withWordLocator: wordRange)
200199
case let .paused(utt):
201-
print(TAG, "tts paused at utterance: \(utt.text)")
200+
debugPrint(TAG, "tts paused at utterance: \(utt.text)")
202201
playingUtterance = utt.locator
203202
case .stopped:
204203
playingUtterance = nil
205-
print(TAG, "tts stopped")
204+
debugPrint(TAG, "tts stopped")
206205
self.listener?.timebasedNavigator(self, requestsHighlightAt: nil, withWordLocator: nil)
207206
//updateDecorations(uttLocator: nil, rangeLocator: nil)
208207
self.nowPlayingUpdater.clearNowPlaying()
@@ -213,7 +212,7 @@ public class FlutterTTSNavigator: FlutterTimebasedNavigator, PublicationSpeechSy
213212
}
214213

215214
public func publicationSpeechSynthesizer(_ synthesizer: ReadiumNavigator.PublicationSpeechSynthesizer, utterance: ReadiumNavigator.PublicationSpeechSynthesizer.Utterance, didFailWithError error: ReadiumNavigator.PublicationSpeechSynthesizer.Error) {
216-
print(TAG, "publicationSpeechSynthesizerUtteranceDidFail: \(error)")
215+
debugPrint(TAG, "publicationSpeechSynthesizerUtteranceDidFail: \(error)")
217216

218217
self.listener?.timebasedNavigator(self, encounteredError: error, withDescription: "TTSUtteranceFailed")
219218

0 commit comments

Comments
 (0)