diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumExtensions.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumExtensions.kt index e8623bc2..97e8b1e8 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumExtensions.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumExtensions.kt @@ -367,19 +367,19 @@ suspend fun Publication.getGuidedNavigationMediaOverlays(): List r.alternates.any { it.mediaType == guidedNavigationMediaType } } - if (!hasGuidedNav) return null + // Deduplicate: several readingOrder items may reference the same guided-navigation document. + val guidedLinks = + readingOrder + .mapNotNull { roLink -> roLink.alternates.find { it.mediaType == guidedNavigationMediaType } } + .distinctBy { it.href } + if (guidedLinks.isEmpty()) return null val parsed: List?> = coroutineScope { val gate = Semaphore(permits = mediaOverlayFetchConcurrency) - readingOrder - .mapIndexed { index, roLink -> + guidedLinks + .map { guidedLink -> async(Dispatchers.IO) { - val guidedLink = - roLink.alternates.find { it.mediaType == guidedNavigationMediaType } - ?: return@async null gate.withPermit { val jsonString = get(guidedLink)?.read()?.getOrNull()?.let { String(it) } @@ -393,10 +393,27 @@ suspend fun Publication.getGuidedNavigationMediaOverlays(): List + val textFile = + overlay.items.firstOrNull()?.textFile + ?: return@mapNotNull null + val roEntry = + readingOrder.withIndex().firstOrNull { (_, link) -> + Url + .invoke(textFile) + ?.let { link.href.resolve().isEquivalent(it) } == true + } + val position = (roEntry?.index ?: -1) + 1 + val duration = roEntry?.value?.duration ?: 0.0 + FlutterMediaOverlay( + overlay.items.map { item -> + item.copy( + position = position, + readingOrderItemDuration = duration, + ) + }, + ) + } } } }.awaitAll() 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 9f467a7c..14058849 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 @@ -179,14 +179,31 @@ extension Publication { // Strategy 2: per-item alternates in reading order. var hasAny = false var allOverlays: [FlutterMediaOverlay] = [] - for (idx, roLink) in readingOrder.enumerated() { + var seenHrefs = Set() + for (_, roLink) in readingOrder.enumerated() { guard let gnLink = roLink.alternates.filterByMediaType(guidedNavMediaType).first else { continue } hasAny = true + guard !seenHrefs.contains(gnLink.href) else { continue } + seenHrefs.insert(gnLink.href) guard let json = try? await get(gnLink)?.read().asJSONObject().get(), let document = GuidedNavigationDocument.fromJson(json) else { continue } - allOverlays += document.toMediaOverlays(atPosition: idx + 1, readingOrderDuration: roLink.duration) + let rawOverlays = document.toMediaOverlays() + let positionedOverlays = rawOverlays.compactMap { overlay -> FlutterMediaOverlay? in + guard let textFile = overlay.textFile else { return nil } + let roEntry = readingOrder.enumerated().first { + $1.href.split(separator: "#", maxSplits: 1).first.map(String.init) == textFile + } + let position = (roEntry?.offset ?? -1) + 1 + let duration = roEntry?.element.duration + let items = overlay.items.map { item in + FlutterMediaOverlayItem( + audio: item.audio, text: item.text, position: position, readingOrderDuration: duration) + } + return FlutterMediaOverlay(items: items, readingOrderDuration: duration) + } + allOverlays += positionedOverlays } guard hasAny else { return nil } return enrichOverlaysWithToc(allOverlays) diff --git a/flutter_readium/web/src/mediaoverlay/guidedNavigation.ts b/flutter_readium/web/src/mediaoverlay/guidedNavigation.ts index 647ee081..9917aa91 100644 --- a/flutter_readium/web/src/mediaoverlay/guidedNavigation.ts +++ b/flutter_readium/web/src/mediaoverlay/guidedNavigation.ts @@ -131,6 +131,7 @@ async function _parseReadingOrderAlternates( publication: ReadiumPublication ): Promise { const result: SyncNarrationItem[] = []; + const seenHrefs = new Set(); for (let i = 0; i < publication.readingOrder.items.length; i++) { const roLink = publication.readingOrder.items[i]; @@ -138,6 +139,8 @@ async function _parseReadingOrderAlternates( if (!alternates) continue; const gnLink = alternates.findWithMediaType(GUIDED_NAVIGATION_MEDIA_TYPE); if (!gnLink) continue; + if (seenHrefs.has(gnLink.href)) continue; + seenHrefs.add(gnLink.href); let document: GuidedNavigationDocument | null = null; try { @@ -154,10 +157,8 @@ async function _parseReadingOrderAlternates( continue; } - const position = i + 1; - const readingOrderDuration = roLink.duration; for (const obj of document.guided) { - _flattenWithFixedPosition(obj, position, readingOrderDuration, result); + _flattenWithReadingOrderLookup(obj, result, publication); } } @@ -169,9 +170,9 @@ async function _parseReadingOrderAlternates( // --------------------------------------------------------------------------- /** - * Strategy 1 flatten: position is derived per item by matching the textref's - * file path against the publication's reading order — 1-based index when - * matched, 0 when unmatched. The matched reading-order link's `duration` + * Flatten helper used by both strategies: position is derived per item by matching + * the textref's file path against the publication's reading order — 1-based index + * when matched, 0 when unmatched. The matched reading-order link's `duration` * (when declared) is attached to the item as `readingOrderDuration`. * Mirrors iOS: `(roEntry?.offset ?? -1) + 1` and `roEntry?.element.duration`. */ @@ -195,25 +196,6 @@ function _flattenWithReadingOrderLookup( } } -/** - * Strategy 2 flatten: position is fixed (readingOrderIndex + 1) for every item - * in this document, because the document is itself attached to that reading-order - * entry. The reading-order entry's duration is propagated to every item. - */ -function _flattenWithFixedPosition( - obj: GuidedNavigationObject, - position: number, - readingOrderDuration: number | undefined, - out: SyncNarrationItem[] -): void { - if (obj.audioref !== undefined && obj.textref !== undefined) { - out.push(_buildItem(obj.audioref, obj.textref, obj.imgref, position, readingOrderDuration)); - } - for (const child of obj.children) { - _flattenWithFixedPosition(child, position, readingOrderDuration, out); - } -} - function _buildItem( audioref: string, textref: string, diff --git a/flutter_readium_platform_interface/lib/src/shared/injection_asset.dart b/flutter_readium_platform_interface/lib/src/shared/injection_asset.dart new file mode 100644 index 00000000..6dfd4574 --- /dev/null +++ b/flutter_readium_platform_interface/lib/src/shared/injection_asset.dart @@ -0,0 +1,26 @@ +import 'package:meta/meta.dart'; + +import '../utils/jsonable.dart'; + +/// Identifies a Flutter asset (JS or CSS file) to inject into EPUB HTML resources. +/// +/// [assetPath] is the asset path as declared in `pubspec.yaml`, e.g. `assets/custom.js`. +/// [package] is the pub package that owns the asset, or `null` for app-level assets. +/// The file type is inferred from the path extension (`.js` or `.css`). +@immutable +class InjectionAsset implements JSONable { + const InjectionAsset({required this.assetPath, this.package}); + + factory InjectionAsset.fromJson(Map json) => InjectionAsset( + assetPath: json['assetPath'] as String, + package: json['package'] as String?, + ); + + final String assetPath; + final String? package; + + @override + Map toJson() => {} + ..put('assetPath', assetPath) + ..putOpt('package', package); +}