Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -367,19 +367,19 @@ suspend fun Publication.getGuidedNavigationMediaOverlays(): List<FlutterMediaOve
}

// Strategy 2: per-item alternates in reading order.
val hasGuidedNav =
readingOrder.any { r -> 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<List<FlutterMediaOverlay>?> =
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) }
Expand All @@ -393,10 +393,27 @@ suspend fun Publication.getGuidedNavigationMediaOverlays(): List<FlutterMediaOve
val document =
GuidedNavigationDocument.fromJSON(jsonString)
?: return@withPermit null
document.toMediaOverlays(
position = index + 1,
readiumOrderItemDuration = roLink.duration ?: 0.0,
)
document.toMediaOverlays().mapNotNull { overlay ->
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()
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)
Expand Down
32 changes: 7 additions & 25 deletions flutter_readium/web/src/mediaoverlay/guidedNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,16 @@ async function _parseReadingOrderAlternates(
publication: ReadiumPublication
): Promise<SyncNarrationItem[]> {
const result: SyncNarrationItem[] = [];
const seenHrefs = new Set<string>();

for (let i = 0; i < publication.readingOrder.items.length; i++) {
const roLink = publication.readingOrder.items[i];
const alternates = roLink.alternates;
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 {
Expand All @@ -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);
}
}

Expand All @@ -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`.
*/
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> json) => InjectionAsset(
assetPath: json['assetPath'] as String,
package: json['package'] as String?,
);

final String assetPath;
final String? package;

@override
Map<String, dynamic> toJson() => {}
..put('assetPath', assetPath)
..putOpt('package', package);
}
Loading