Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2a39907
remove getLocatorFragments from bridge
m-abs Feb 26, 2026
d3f980d
chor: remove getCurrentLocator - Use onTextLocatorChanged instead
m-abs Feb 26, 2026
c3c7a20
feat(android): find current toc item for EPUB in both reader widget a…
m-abs Feb 27, 2026
68378e9
chor: removed unused/unneeded functions from EpubPage.ts
m-abs Feb 27, 2026
ba6d58b
minor cleanup
m-abs Feb 27, 2026
351e058
feat(android): add toc info to other locations for sync audiobook nav…
m-abs Mar 2, 2026
dc3a6bb
feat(android): add toc info to other locations for pure audiobook nav…
m-abs Mar 2, 2026
cbb83fe
feat(android): add page information to tex-locator
m-abs Mar 2, 2026
cacd7fd
fix: findCssSelectorForLocator already returned the first cssSelector…
m-abs Mar 3, 2026
1495886
feat(iOS): use PageInformation and find toc link
ddfreiling Mar 3, 2026
2b6162d
findCssSelectorForLocator sometimes found the wrong cssSelector
m-abs Mar 3, 2026
5403847
fix(android): List<Link>.flatten also flattened alternates, we only w…
m-abs Mar 3, 2026
e879ec3
chore: add AudioNavigator ToC matching
ddfreiling Mar 3, 2026
df416bf
Merge branch 'feat/current-toc' of github.com:Notalib/flutter_readium…
ddfreiling Mar 3, 2026
bae3155
fix(android): Using the content service to find current cssSelector d…
m-abs Mar 4, 2026
d714618
fix(android): remote resource to toc mapping for audiobook didn't wor…
m-abs Mar 4, 2026
fb6c94c
(android): Use native pageIndex and totalPages in emitOnPageChanged
m-abs Mar 4, 2026
7a8e03a
refactor(iOS): improve ToC identification code
ddfreiling Mar 5, 2026
8e3e43d
feat: add ToC references in emitted Locator for MediaOverlay books
ddfreiling Mar 5, 2026
79d1222
Improve find cssSelector and tocId from javascript
m-abs Mar 5, 2026
65e206d
Merge branch 'feat/current-toc' of github.com:Notalib/flutter_readium…
m-abs Mar 5, 2026
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
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"cSpell.words": [
"acsm",
"androidx",
"animejs",
"ascii",
"Ascii",
"charsets",
Expand Down
1 change: 1 addition & 0 deletions bin/install
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ if [ "$(uname)" == "Darwin" ]; then
fi

$PROJECT_ROOT/flutter_readium/bin/build_helper_scripts.sh
$PROJECT_ROOT/bin/build_js
$PROJECT_ROOT/bin/update_web_example
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,17 @@ import org.readium.r2.shared.publication.Manifest
import org.readium.r2.shared.publication.Publication
import org.readium.r2.shared.publication.flatten
import org.readium.r2.shared.publication.html.cssSelector
import org.readium.r2.shared.publication.services.content.Content
import org.readium.r2.shared.publication.services.content.ContentService
import org.readium.r2.shared.util.Try
import org.readium.r2.shared.util.Url
import org.readium.r2.shared.util.mediatype.MediaType
import org.readium.r2.shared.util.resource.Resource
import org.readium.r2.shared.util.resource.TransformingResource
import org.readium.r2.shared.util.resource.filename
import java.util.Locale
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import org.readium.r2.navigator.preferences.Color as ReadiumColor

private const val TAG = "ReadiumExtensions"
Expand Down Expand Up @@ -150,10 +156,10 @@ suspend fun Publication.getMediaOverlays(): List<FlutterMediaOverlay?>? {
if (!hasMediaOverlays()) return null

// Flatten TOC for title lookup
val toc = tableOfContents.flatten().map { Pair(it.href.toString(), it.title) }
val toc = tableOfContents.flatten().map { Pair(it.href.toString(), it) }

// Remember last matched TOC item for titles
var lastTocMatch: Pair<String, String?>? = null
var lastTocMatch: Pair<String, Link>? = null

return this.readingOrder.mapNotNull { r ->
r.alternates.find { a ->
Expand All @@ -163,7 +169,7 @@ suspend fun Publication.getMediaOverlays(): List<FlutterMediaOverlay?>? {
val jsonString =
this.get(link)?.read()?.getOrNull()?.let { String(it) } ?: return@mapIndexedNotNull null
val jsonObject = JSONObject(jsonString)
FlutterMediaOverlay.fromJson(jsonObject, index + 1, link.title ?: "")
FlutterMediaOverlay.fromJson(jsonObject, index + 1, null, link.title ?: "")
}
.map { mo ->
val items = mo.items.map { item ->
Expand All @@ -174,9 +180,15 @@ suspend fun Publication.getMediaOverlays(): List<FlutterMediaOverlay?>? {

if (match?.second != null) {
lastTocMatch = match
item.copy(title = match.second ?: "")
item.copy(
title = match.second.title ?: "",
tocHref = match.second.href.resolve()
)
} else if (lastTocMatch?.second != null && lastTocMatch.first.substringBefore("#") == item.textFile) {
item.copy(title = lastTocMatch.second ?: "")
item.copy(
title = lastTocMatch.second.title ?: "",
tocHref = lastTocMatch.second.href.resolve()
)
} else {
item
}
Expand Down Expand Up @@ -247,7 +259,127 @@ fun Locator.getTextId(): String? {
fun Locator.copyWithTimeFragment(time: Double): Locator {
return copy(
locations = locations.copy(
fragments = listOf("${time}")
fragments = listOf("t=${time}")
)
)
}

/**
* Helper for getting all cssSelectors for a HTML document.
*/
suspend fun Publication.findAllCssSelectors(href: Url): List<String>? {
if (!conformsTo(Publication.Profile.EPUB)) {
Log.d(TAG, ":findAllCssSelectors - this only works for an EPUB Profile")
return null
}

val contentService = findService(ContentService::class) ?: run {
Log.d(TAG, ":findAllCssSelectors - no content service found")
return null
}

val cleanHref = href.cleanHref()

val ids = arrayListOf<String>()
for (element in contentService.content(
Locator(
href = cleanHref,
mediaType = MediaType.XHTML
)
)) {
if (element !is Content.TextElement) {
continue
}

if (element.locator.href.cleanHref().path != cleanHref.path) {
// We iterated to the next document, stopping
break
}

// We are only interested in #id type of cssSelectors.
val cssSelector =
element.locator.locations.cssSelector?.takeIf { it.startsWith("#") } ?: continue

ids.add(cssSelector)
}

return ids
}

/**
* Find the cssSelector for a locator. If it already have one return it, otherwise we need to look it up.
*/
suspend fun Publication.findCssSelectorForLocator(locator: Locator): String? {
// If our locator already has a cssSelector, use it.
locator.locations.cssSelector?.takeIf { it.startsWith("#") }?.let { return it }

val contentService = findService(ContentService::class) ?: run {
Log.d(TAG, ":findCssSelectorForLocator - no content service found")
return null
}

val cleanHref = locator.href.cleanHref()

val locatorProgress = locator.locations.progression ?: 0.0
var cssSelector: String? = null
for (element in contentService.content(locator)) {
if (element !is Content.TextElement) {
continue
}

if (element.locator.href.cleanHref().path != cleanHref.path) {
// We iterated to the next document, stopping
break
}

val eCssSelector = element.locator.locations.cssSelector?.takeIf { it.startsWith("#") }
if (eCssSelector == null || !eCssSelector.startsWith("#")) continue

val progression = element.locator.locations.progression ?: 0.0
if (progression > locatorProgress && cssSelector != null) {
break
}

cssSelector = eCssSelector
}

return cssSelector?.takeIf { it.isNotEmpty() && it.startsWith("#") }
}

/**
* Remove query and fragment from the Url
*/
fun Url.cleanHref() = removeFragment().removeQuery()

val Href.fragmentParameters: Map<String, String>
get() = resolve()
.fragment
// Splits parameters
?.split("&")
?.filter { !it.startsWith("=") }
?.map { it.split("=") }
// Only keep named parameters
?.filter { it.size == 2 }
?.associate { it[0].trim().lowercase(Locale.ROOT) to it[1].trim() }
?: mapOf()


/**
* Media fragment, used for example in audiobooks.
*
* https://www.w3.org/TR/media-frags/
*/
val Href.time: Duration?
get() =
fragmentParameters["t"]?.toIntOrNull()?.seconds

/**
* Returns a list of `Link` after flattening the `children`.
*/
fun List<Link>.flattenChildren(): List<Link> {
fun Link.flattenChildren(): List<Link> {
return listOf(this) + children.flattenChildren()
}

return flatMap { it.flattenChildren() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import org.readium.navigator.media.tts.android.AndroidTtsPreferences
import org.readium.navigator.media.tts.android.AndroidTtsSettings
import org.readium.r2.navigator.Decoration
import org.readium.r2.navigator.epub.EpubPreferences
import org.readium.r2.navigator.extensions.time
import org.readium.r2.shared.ExperimentalReadiumApi
import org.readium.r2.shared.InternalReadiumApi
import org.readium.r2.shared.publication.Link
Expand Down Expand Up @@ -176,9 +177,6 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua

private var epubNavigator: EpubNavigator? = null

val epubCurrentLocator: Locator?
get() = epubNavigator?.currentLocator?.value

private var _audioPreferences: FlutterAudioPreferences = FlutterAudioPreferences()

/** Current audio preferences (defaults if audio hasn't been enabled yet). */
Expand Down Expand Up @@ -398,6 +396,13 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua
state[currentPublicationUrlKey] = value
}

/***
* For EPUB profile, maps document [Url] to a list of all the cssSelectors in the document.
*
* This is used to find the current toc item.
*/
private var currentPublicationCssSelectorMap: MutableMap<Url, List<String>>? = null

/**
* Sets the headers used in the HTTP requests for fetching publication resources, including
* resources in already created `Publication` objects.
Expand Down Expand Up @@ -579,6 +584,7 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua
mainScope.async {
_currentPublication?.close()
_currentPublication = null
currentPublicationCssSelectorMap = null

ttsNavigator?.dispose()
ttsNavigator = null
Expand Down Expand Up @@ -608,12 +614,13 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua
// TODO: Notify client
}

@OptIn(InternalReadiumApi::class)
override fun onTimebasedCurrentLocatorChanges(
locator: Locator, currentReadingOrderLink: Link?
) {
val duration = currentReadingOrderLink?.duration
val timeOffset =
locator.locations.fragments.find { it.startsWith("t=") }?.substring(2)?.toDoubleOrNull()
locator.locations.time?.inWholeSeconds?.toDouble()
?: (duration?.let { duration ->
locator.locations.progression?.let { prog -> duration * prog }
})
Expand All @@ -631,6 +638,63 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua
currentReaderWidget?.go(locator, true)
}

/**
* Find the current table of content item from a locator.
*/
suspend fun epubFindCurrentToc(locator: Locator): Locator {
val publication = currentPublication ?: run {
Log.e(TAG, ":epubFindCurrentToc - no currentPublication")
return locator
}

if (!publication.conformsTo(Publication.Profile.EPUB)) {
Log.d(TAG, ":epubFindCurrentToc - not an EPUB profile")
return locator
}

val cssSelector = publication.findCssSelectorForLocator(locator) ?: run {
Log.e(TAG, ":epubFindCurrentToc - missing cssSelector in locator")
return locator
}

val resultLocator =
locator.copyWithLocations(
otherLocations = locator.locations.otherLocations +
("cssSelector" to cssSelector)
)

val cleanHref = resultLocator.href.cleanHref()
val tocLinks = publication.tableOfContents.flattenChildren().filter {
it.href.resolve().cleanHref().path == cleanHref.path
}

val documentCssSelectors = epubGetAllDocumentCssSelectors(resultLocator.href)
val idx = documentCssSelectors.indexOf(cssSelector).takeIf { it > -1 } ?: run {
// cssSelector wasn't found in the list of document cssSelectors, best effort is to assume first
Log.d(
TAG,
":epubFindCurrentToc cssSelector:${cssSelector} not found in contentIds, assume idx = 0"
)
0
}

val toc =
tocLinks.associateBy { documentCssSelectors.indexOf("#${it.href.resolve().fragment}") }

val tocItem = toc.entries.lastOrNull { it.key <= idx }?.value
?: toc.entries.firstOrNull()?.value ?: run {
Log.d(TAG, ":epubFindCurrentToc - no tocItem found")
return resultLocator
}

return resultLocator.copy(
title = tocItem.title
).copyWithLocations(
otherLocations = resultLocator.locations.otherLocations +
("toc" to tocItem.href.resolve().toString())
)
}

@OptIn(InternalReadiumApi::class)
suspend fun epubEnable(
initialLocator: Locator?,
Expand Down Expand Up @@ -716,7 +780,8 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua
)
},
{ language, availableVoices -> null },
AndroidTtsPreferences())?.voices ?: setOf()
AndroidTtsPreferences()
)?.voices ?: setOf()
}

fun ttsGetPreferences(): FlutterTtsPreferences? {
Expand Down Expand Up @@ -929,10 +994,26 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua
}

/**
* Get locator fragments from EPUB navigator.
* Get first visible locator from the EPUB navigator.
*/
suspend fun firstVisibleElementLocator(): Locator? {
return epubNavigator?.firstVisibleElementLocator()
}

/**
* Get all cssSelectors for an EPUB file.
* Note: These only includes text elements, so body, page breaks etc are not included.
*/
suspend fun epubGetLocatorFragments(locator: Locator): Locator? {
return epubNavigator?.getLocatorFragments(locator)
suspend fun epubGetAllDocumentCssSelectors(href: Url): List<String> {
val cssSelectorMap = currentPublicationCssSelectorMap ?: mutableMapOf()
currentPublicationCssSelectorMap = cssSelectorMap

val cleanHref = href.cleanHref()
return cssSelectorMap.getOrPut(cleanHref) {
currentPublication?.findAllCssSelectors(
cleanHref
) ?: listOf()
}
}

/**
Expand Down
Loading