diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumExtensions.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumExtensions.kt index fe16944f..b5421c8c 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumExtensions.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumExtensions.kt @@ -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" @@ -150,10 +156,10 @@ suspend fun Publication.getMediaOverlays(): List? { 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? = null + var lastTocMatch: Pair? = null return this.readingOrder.mapNotNull { r -> r.alternates.find { a -> @@ -163,7 +169,7 @@ suspend fun Publication.getMediaOverlays(): List? { 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 -> @@ -174,9 +180,15 @@ suspend fun Publication.getMediaOverlays(): List? { 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 } @@ -251,3 +263,107 @@ fun Locator.copyWithTimeFragment(time: Double): Locator { ) ) } + +/** + * Helper for getting all cssSelectors for a HTML document. + */ +suspend fun Publication.findAllCssSelectors(href: Url): List? { + 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() + for (element in contentService.content( + Locator( + href = cleanHref, + mediaType = MediaType.XHTML + ) + )) { + if (element !is Content.TextElement) { + continue + } + + if (element.locator.href.cleanHref() != cleanHref) { + // 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? { + 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() + for (element in contentService.content(locator)) { + if (element !is Content.TextElement) { + continue + } + + if (element.locator.href.cleanHref() != cleanHref) { + // We iterated to the next document, stopping + break + } + + val cssSelector = + element.locator.locations.cssSelector?.takeIf { it.startsWith("#") } ?: continue + + return cssSelector + } + + return null +} + +/** + * Remove query and fragment from the Url + */ +fun Url.cleanHref() = removeFragment().removeQuery() + +/** + * Remove query and fragment from the Href + */ +fun Href.cleanHref() = Href(resolve().cleanHref()) + +val Href.fragmentParameters: Map + 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 diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumReader.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumReader.kt index 191292c4..d663aa22 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumReader.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumReader.kt @@ -40,12 +40,14 @@ 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 import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.publication.allAreHtml +import org.readium.r2.shared.publication.flatten import org.readium.r2.shared.util.AbsoluteUrl import org.readium.r2.shared.util.DebugError import org.readium.r2.shared.util.Language @@ -176,9 +178,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). */ @@ -398,6 +397,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>? = null + /** * Sets the headers used in the HTTP requests for fetching publication resources, including * resources in already created `Publication` objects. @@ -579,6 +585,7 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua mainScope.async { _currentPublication?.close() _currentPublication = null + currentPublicationCssSelectorMap = null ttsNavigator?.dispose() ttsNavigator = null @@ -608,12 +615,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 } }) @@ -631,6 +639,47 @@ 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 + } + + val cssSelector = publication.findCssSelectorForLocator(locator) ?: run { + Log.e(TAG, ":epubFindCurrentToc, missing cssSelector in locator") + return locator + } + + val resultLocator = locator.copyWithLocations(otherLocations = locator.locations.otherLocations + ("cssLocator" to cssSelector)) + + val contentIds = epubGetAllDocumentCssSelectors(locator.href) + val idx = contentIds.indexOf(cssSelector).takeIf { it > -1 } ?: run { + Log.d(TAG, ":epubFindCurrentToc cssSelector:${cssSelector} not found in contentIds") + return resultLocator + } + + val cleanHref = resultLocator.href.cleanHref() + val toc = publication.tableOfContents.flatten().filter { + it.href.resolve().cleanHref() == cleanHref + }.associateBy { contentIds.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?, @@ -716,7 +765,8 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua ) }, { language, availableVoices -> null }, - AndroidTtsPreferences())?.voices ?: setOf() + AndroidTtsPreferences() + )?.voices ?: setOf() } fun ttsGetPreferences(): FlutterTtsPreferences? { @@ -929,10 +979,25 @@ object ReadiumReader : TimebasedNavigator.TimebasedListener, EpubNavigator.Visua } /** - * Get locator fragments from EPUB navigator. + * Get first visible locator from the EPUB navigator. */ - suspend fun epubGetLocatorFragments(locator: Locator): Locator? { - return epubNavigator?.getLocatorFragments(locator) + suspend fun firstVisibleElementLocator(): Locator? { + return epubNavigator?.firstVisibleElementLocator() + } + + /** + * Get all cssSelectors for an EPUB file. + */ + suspend fun epubGetAllDocumentCssSelectors(href: Url): List { + val cssSelectorMap = currentPublicationCssSelectorMap ?: mutableMapOf() + currentPublicationCssSelectorMap = cssSelectorMap + + val cleanHref = href.cleanHref() + return cssSelectorMap.getOrPut(cleanHref) { + currentPublication?.findAllCssSelectors( + cleanHref + ) ?: listOf() + } } /** diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumReaderWidget.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumReaderWidget.kt index 4f81f1ce..4251e61a 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumReaderWidget.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/ReadiumReaderWidget.kt @@ -13,6 +13,7 @@ import androidx.fragment.app.FragmentActivity import androidx.fragment.app.commitNow import dk.nota.flutter_readium.events.ReadiumReaderStatus import dk.nota.flutter_readium.fragments.EpubReaderFragment +import dk.nota.flutter_readium.models.PageInformation import dk.nota.flutter_readium.navigators.EpubNavigator import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodCall @@ -41,8 +42,8 @@ class ReadiumReaderWidget( creationParams: Map, messenger: BinaryMessenger, attrs: AttributeSet? = null -) : PlatformView, MethodChannel.MethodCallHandler, - EpubReaderFragment.Listener, EpubNavigator.VisualListener { +) : PlatformView, MethodChannel.MethodCallHandler, EpubReaderFragment.Listener, + EpubNavigator.VisualListener { private val channel: ReadiumReaderChannel @@ -94,8 +95,8 @@ class ReadiumReaderWidget( init { Log.d(TAG, "::init") - @Suppress("UNCHECKED_CAST") - val initPrefsMap = creationParams["preferences"] as Map? + @Suppress("UNCHECKED_CAST") val initPrefsMap = + creationParams["preferences"] as Map? val publication = ReadiumReader.currentPublication val locatorString = creationParams["initialLocator"] as String? val allowScreenReaderNavigation = creationParams["allowScreenReaderNavigation"] as Boolean? @@ -103,8 +104,7 @@ class ReadiumReaderWidget( if (locatorString == null) null else Locator.fromJSON(jsonDecode(locatorString) as JSONObject) val initialPreferences = if (initPrefsMap == null) EpubPreferences() else epubPreferencesFromMap( - initPrefsMap, - null + initPrefsMap, null ) Log.d(TAG, "publication = $publication") @@ -126,8 +126,7 @@ class ReadiumReaderWidget( // This can be toggled back on via the 'allowScreenReaderNavigation' creation param. // See issue: https://notalib.atlassian.net/browse/NOTA-9828 if (allowScreenReaderNavigation != true) { - layout.importantForAccessibility = - View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + layout.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS } // Remove existing fragment if any (this is to avoid crashing on restore). @@ -212,16 +211,28 @@ class ReadiumReaderWidget( private suspend fun emitOnPageChanged(locator: Locator) { try { - val locatorWithFragments = ReadiumReader.epubGetLocatorFragments(locator) - if (locatorWithFragments == null) { - Log.e(TAG, "emitOnPageChanged: window.epubPage.getVisibleRange failed!") - return + var emittingLocator = ReadiumReader.epubFindCurrentToc(locator) + try { + evaluateJavascript("window.epubPage.getPageInformation()")?.let { + PageInformation.fromJson( + it + ) + }?.let { pageInfo -> + emittingLocator = emittingLocator.copy( + locations = emittingLocator.locations.copy( + otherLocations = emittingLocator.locations.otherLocations + pageInfo.otherLocations, + ), + ); + } + } catch (e: Error) { + Log.d(TAG, ":pageInformation error: $e") } - channel.onPageChanged(locatorWithFragments) - ReadiumReader.emitTextLocatorUpdate(locatorWithFragments) + channel.onPageChanged(emittingLocator) + ReadiumReader.emitTextLocatorUpdate(emittingLocator) + Log.d(TAG, "emitOnPageChanged: emitted $emittingLocator") } catch (e: Exception) { - Log.e(TAG, "emitOnPageChanged: window.epubPage.getVisibleRange failed! $e") + Log.e(TAG, "emitOnPageChanged: failed! $e") } } @@ -229,15 +240,6 @@ class ReadiumReaderWidget( channel.onExternalLinkActivated(url) } - private suspend fun setLocation( - locator: Locator, - isAudioBookWithText: Boolean - ) { - val json = locator.toJSON().toString() - Log.d(TAG, "::scrollToLocations: Go to locations $json") - evaluateJavascript("window.epubPage.setLocation($json, $isAudioBookWithText);") - } - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { // TODO: To be safe we're doing everything on the Main thread right now. // Could probably optimize by using .IO and then change to Main @@ -246,9 +248,17 @@ class ReadiumReaderWidget( Log.d(TAG, "::onMethodCall ${call.method}") when (call.method) { "setPreferences" -> { - @Suppress("UNCHECKED_CAST") - val prefsMap = call.arguments as Map try { + @Suppress("UNCHECKED_CAST") val prefsMap = + call.arguments as? Map ?: run { + result.error( + "FlutterReadium", + "Failed to set preferences", + "Invalid argument" + ) + return@launch + } + setPreferencesFromMap(prefsMap) result.success(null) } catch (ex: Exception) { @@ -264,13 +274,11 @@ class ReadiumReaderWidget( if (locatorJson.optString("type") == "") { locatorJson.put("type", " ") Log.e( - TAG, - "Got locator with empty type! This shouldn't happen. $locatorJson" + TAG, "Got locator with empty type! This shouldn't happen. $locatorJson" ) } val locator = Locator.fromJSON(locatorJson)!! ReadiumReader.epubGoToLocator(locator, animated) - setLocation(locator, isAudioBookWithText) result.success(null) } @@ -286,53 +294,12 @@ class ReadiumReaderWidget( result.success(null) } - "setLocation" -> { - val args = call.arguments as List<*> - val locatorJson = JSONObject(args[0] as String) - val isAudioBookWithText = args[1] as Boolean - val locator = Locator.fromJSON(locatorJson)!! - setLocation(locator, isAudioBookWithText) - result.success(null) - } - - "isLocatorVisible" -> { - val args = call.arguments as String - val locatorJson = JSONObject(args) - val locator = Locator.fromJSON(locatorJson)!! - var visible = locator.href == ReadiumReader.epubCurrentLocator?.href - if (visible) { - val jsonRes = - evaluateJavascript("window.epubPage.isLocatorVisible($args);") - ?: "false" - try { - visible = jsonDecode(jsonRes) as Boolean - } catch (e: Error) { - Log.e(TAG, "::isLocatorVisible - invalid response:$jsonRes - e:$e") - visible = false - } - } - result.success(visible) - } - - "getLocatorFragments" -> { - val args = call.arguments as String? - Log.d(TAG, "::====== $args") - val locatorJson = JSONObject(args!!) - Log.d(TAG, "::====== $locatorJson") - - val locator = - ReadiumReader.epubGetLocatorFragments(Locator.fromJSON(locatorJson)!!) - Log.d(TAG, "::====== $locator") - - result.success(jsonEncode(locator?.toJSON())) - } - "applyDecorations" -> { val args = call.arguments as List<*> val groupId = args[0] as String - @Suppress("UNCHECKED_CAST") - val decorationListStr = args[1] as List> + @Suppress("UNCHECKED_CAST") val decorationListStr = + args[1] as List> val decorations = decorationListStr.mapNotNull { decorationFromMap(it) } ReadiumReader.applyDecorations(decorations, groupId) @@ -344,10 +311,6 @@ class ReadiumReaderWidget( result.success(null) } - "getCurrentLocator" -> { - result.success(ReadiumReader.epubCurrentLocator?.let { jsonEncode(it.toJSON()) }) - } - else -> { Log.e(TAG, "Unhandled call ${call.method}") result.notImplemented() diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/Utils.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/Utils.kt index 59cc76a4..8cf1ea4e 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/Utils.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/Utils.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext import org.json.JSONArray +import org.json.JSONException import org.json.JSONObject import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.publication.html.cssSelector @@ -90,3 +91,12 @@ fun canScroll(locations: Locator.Locations) = fun MutableStateFlow.update(new: T) { if (this.value != new) this.value = new } + +@Throws(JSONException::class) +fun JSONArray.toList(): List { + val list = mutableListOf() + for (i in 0 until this.length()) { + list.add(this[i]) + } + return list +} diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/FlutterMediaOverlay.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/FlutterMediaOverlay.kt index 83aa0e93..b08af5b3 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/FlutterMediaOverlay.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/FlutterMediaOverlay.kt @@ -111,14 +111,14 @@ data class FlutterMediaOverlay(val items: List) : Seria } companion object { - fun fromJson(json: JSONObject, position: Int, title: String): FlutterMediaOverlay? { + fun fromJson(json: JSONObject, position: Int, tocHref: Url?, title: String): FlutterMediaOverlay? { val topNarration = json.opt("narration") as? JSONArray ?: return null val items = mutableListOf() for (i in 0 until topNarration.length()) { val itemJson = topNarration.getJSONObject(i) - FlutterMediaOverlayItem.fromJson(itemJson, position, title)?.let { items.add(it) } + FlutterMediaOverlayItem.fromJson(itemJson, position, tocHref, title)?.let { items.add(it) } - fromJson(itemJson, position, title)?.let { items.addAll(it.items) } + fromJson(itemJson, position, tocHref, title)?.let { items.addAll(it.items) } } return FlutterMediaOverlay(items) diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/FlutterMediaOverlayItem.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/FlutterMediaOverlayItem.kt index 0786a589..4ce587c4 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/FlutterMediaOverlayItem.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/FlutterMediaOverlayItem.kt @@ -25,6 +25,11 @@ data class FlutterMediaOverlayItem( */ val position: Int, + /** + * The ToC item for this item. + */ + val tocHref: Url?, + /** * The title of the chapter or section this item belongs to */ @@ -114,6 +119,7 @@ data class FlutterMediaOverlayItem( textLocator.copy( locations = textLocator.locations.copy( fragments = listOf("t=${audioStart ?: 0.0}"), + otherLocations = textLocator.locations.otherLocations + ("toc" to tocHref.toString()) ), ) } @@ -142,11 +148,11 @@ data class FlutterMediaOverlayItem( * Creates a [FlutterMediaOverlayItem] from a JSON object. * Returns null if the JSON object does not contain valid "audio" and "text" */ - fun fromJson(json: JSONObject, position: Int, title: String): FlutterMediaOverlayItem? { + fun fromJson(json: JSONObject, position: Int, tocHref: Url?, title: String): FlutterMediaOverlayItem? { val audio = json.optString("audio") val text = json.optString("text") return if (audio != "" && text != "") { - FlutterMediaOverlayItem(audio, text, position, title) + FlutterMediaOverlayItem(audio, text, position, tocHref, title) } else { null } diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/PageInformation.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/PageInformation.kt new file mode 100644 index 00000000..67ec65a8 --- /dev/null +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/models/PageInformation.kt @@ -0,0 +1,32 @@ +package dk.nota.flutter_readium.models + +import dk.nota.flutter_readium.jsonDecode +import org.json.JSONObject + +class PageInformation(val pageIndex: Long?, val totalPages: Long?, val physicalPageIndex: String?) { + val otherLocations: Map + get() { + val res = mutableMapOf() + if (pageIndex != null && totalPages != null) { + res["currentPage"] = pageIndex + res["totalPages"] = totalPages + } + + physicalPageIndex?.takeIf { it.isNotEmpty() }?.let { + res["physicalPage"] = it + } + return res; + } + + companion object { + fun fromJson(json: String): PageInformation = fromJson(jsonDecode(json) as JSONObject) + + fun fromJson(json: JSONObject): PageInformation { + val pageIndex = json.optLong("pageIndex") + val totalPages = json.optLong("totalPages") + val physicalPageIndex = json.optString("physicalPageIndex") + + return PageInformation(pageIndex, totalPages, physicalPageIndex) + } + } +} diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/AudiobookNavigator.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/AudiobookNavigator.kt index dc06a246..b2b591d2 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/AudiobookNavigator.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/AudiobookNavigator.kt @@ -7,7 +7,9 @@ import dk.nota.flutter_readium.FlutterAudioPreferences import dk.nota.flutter_readium.PluginMediaServiceFacade import dk.nota.flutter_readium.PublicationError import dk.nota.flutter_readium.ReadiumReader +import dk.nota.flutter_readium.cleanHref import dk.nota.flutter_readium.throttleLatest +import dk.nota.flutter_readium.time import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -23,9 +25,13 @@ import org.readium.adapter.exoplayer.audio.ExoPlayerNavigatorFactory import org.readium.adapter.exoplayer.audio.ExoPlayerPreferences import org.readium.adapter.exoplayer.audio.ExoPlayerSettings import org.readium.navigator.media.audio.AudioNavigator +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 import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.publication.Publication +import org.readium.r2.shared.publication.flatten import org.readium.r2.shared.util.getOrElse import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -225,6 +231,32 @@ open class AudiobookNavigator( } } + @OptIn(InternalReadiumApi::class) + override fun onCurrentLocatorChanges(locator: Locator) { + var emittingLocator = locator + + locator.locations.time?.let { time -> + var matchedTocItem: Link? = null + for (link in publication.tableOfContents.flatten().filter { it.href.resolve().cleanHref() == locator.href.cleanHref() }) { + val tocTime = link.href.time ?: continue + if (tocTime > time) { + continue + } + matchedTocItem = link + } + + matchedTocItem?.href?.resolve()?.let { + emittingLocator = emittingLocator.copy( + locations = emittingLocator.locations.copy( + otherLocations = emittingLocator.locations.otherLocations + ( "toc" to it ) + ) + ) + } + } + + super.onCurrentLocatorChanges(emittingLocator) + } + override fun onPlaybackStateChanged(pb: AudioNavigator.Playback) { when (pb.state) { is AudioNavigator.State.Failure<*> -> { diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/EpubNavigator.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/EpubNavigator.kt index 4f01acf7..1ee35d5d 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/EpubNavigator.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/EpubNavigator.kt @@ -6,7 +6,6 @@ import android.view.ViewGroup import androidx.fragment.app.FragmentManager import androidx.fragment.app.commitNow import dk.nota.flutter_readium.ReadiumReaderWidget.Companion.NAVIGATOR_FRAGMENT_TAG -import dk.nota.flutter_readium.canScroll import dk.nota.flutter_readium.fragments.EpubReaderFragment import dk.nota.flutter_readium.jsonDecode import dk.nota.flutter_readium.models.EpubReaderViewModel @@ -102,11 +101,6 @@ class EpubNavigator : BaseNavigator, EpubReaderFragment.Listener { */ private var editor: EpubPreferencesEditor? = null - /** - * Pending scroll target to be applied when the page is loaded. - */ - var pendingScrollToLocations: Locator.Locations? = null - /** * Current EPUB preferences. */ @@ -134,11 +128,6 @@ class EpubNavigator : BaseNavigator, EpubReaderFragment.Listener { } override suspend fun initNavigator() { - pendingScrollToLocations = - initialLocator?.locations?.let { locations -> - if (canScroll(locations)) locations else null - } - epubNavigator = EpubReaderFragment().apply { vm = EpubReaderViewModel().apply { navigatorFactory = EpubNavigatorFactory(publication) @@ -254,17 +243,6 @@ class EpubNavigator : BaseNavigator, EpubReaderFragment.Listener { Log.d(TAG, "::onPageLoaded") visualListener.onPageLoaded() - pendingScrollToLocations?.let { locations -> - Log.d(TAG, "::onPageLoaded - pendingScrollToLocations: $locations") - - mainScope.async { - scrollToLocations(locations, toStart = true) - } - - pendingScrollToLocations = null - - } - notifyIsReady() } @@ -354,30 +332,6 @@ class EpubNavigator : BaseNavigator, EpubReaderFragment.Listener { navigatorStarted.first { it } } - suspend fun getLocatorFragments(locator: Locator): Locator? { - val json = - evaluateJavascript("window.epubPage.getLocatorFragments(${locator.toJSON()}, $isVerticalScroll)") - try { - if (json == null || json == "null" || json == "undefined") { - Log.e( - TAG, - "getLocatorFragments: window.epubPage.getVisibleRange failed!" - ) - return null - } - val jsonLocator = jsonDecode(json) as JSONObject - val locatorWithFragments = Locator.fromJSON(jsonLocator) - - return locatorWithFragments - } catch (e: Exception) { - Log.e( - TAG, - "getLocatorFragments: window.epubPage.getVisibleRange json: $json failed! $e" - ) - } - return null - } - suspend fun firstVisibleElementLocator(): Locator? { val navigator = epubNavigator if (navigator == null) { @@ -399,41 +353,11 @@ class EpubNavigator : BaseNavigator, EpubReaderFragment.Listener { }.await() } - private suspend fun scrollToLocations( - locations: Locator.Locations, - toStart: Boolean - ) { - val json = locations.toJSON().toString() - Log.d(TAG, "::scrollToLocations: Go to locations $json, toStart: $toStart") - evaluateJavascript("window.epubPage.scrollToLocations($json,$isVerticalScroll,$toStart);") - } - /** * Go to a specific locator in the EPUB navigator, this scrolls to the locator position if needed. */ suspend fun goToLocator(locator: Locator, animated: Boolean) { - mainScope.async { - val locations = locator.locations - val shouldScroll = canScroll(locations) - val locatorHref = locator.href - val currentHref = currentLocator?.value?.href - val shouldGo = currentHref?.isEquivalent(locatorHref) == false - - // TODO: Figure out why we can't just use rely on Readium's own go-function to scroll - // the locator. - if (shouldGo) { - Log.d(TAG, "::goToLocator: Go to $locatorHref from $currentHref") - pendingScrollToLocations = locations - go(locator, animated) - } else if (!shouldScroll) { - Log.w(TAG, "::goToLocator: Already at $locatorHref, no scroll target, go to start") - scrollToLocations(Locator.Locations(progression = 0.0), true) - } else { - Log.d(TAG, "::goToLocator: Already at $locatorHref, scroll to position") - - scrollToLocations(locations, false) - } - }.await() + go(locator, animated) } companion object { diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/SyncAudiobookNavigator.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/SyncAudiobookNavigator.kt index 1353b720..be9776d5 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/SyncAudiobookNavigator.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/SyncAudiobookNavigator.kt @@ -67,7 +67,7 @@ class SyncAudiobookNavigator( if (mediaOverlay == null) { Log.d( TAG, - ":onTimebasedCurrentLocatorChanges no media-overlay item found for locator=$locator, timeOffset=$timeOffset" + ":onCurrentLocatorChanges no media-overlay item found for locator=$locator, timeOffset=$timeOffset" ) return } diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/TTSNavigator.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/TTSNavigator.kt index 0748c538..9ad0f975 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/TTSNavigator.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/TTSNavigator.kt @@ -301,8 +301,10 @@ class TTSNavigator( .throttleLatest(100.milliseconds) .distinctUntilChanged() .onEach { locator -> - onCurrentLocatorChanges(locator) - state[currentTimebasedLocatorKey] = locator + val emittingLocator = + ReadiumReader.epubFindCurrentToc(locator) + onCurrentLocatorChanges(emittingLocator) + state[currentTimebasedLocatorKey] = emittingLocator } .launchIn(mainScope) .let { jobs.add(it) } diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/TimebasedNavigator.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/TimebasedNavigator.kt index 56ff9e25..6b4b8b0d 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/TimebasedNavigator.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutter_readium/navigators/TimebasedNavigator.kt @@ -75,7 +75,8 @@ abstract class TimebasedNavigator

( var timebasedState: TimebasedState when (pb.state) { is MediaNavigator.State.Ready -> { - timebasedState = if (pb.playWhenReady) TimebasedState.Playing else TimebasedState.Paused + timebasedState = + if (pb.playWhenReady) TimebasedState.Playing else TimebasedState.Paused } is MediaNavigator.State.Buffering -> { @@ -100,26 +101,24 @@ abstract class TimebasedNavigator

( } override fun onCurrentLocatorChanges(locator: Locator) { + var emittingLocator = locator + val readingOrderLink = publication.readingOrder.find { link -> link.href.toString() == locator.href.toString() } - if (locator.locations.position == null) { - val index = - publication.readingOrder.indexOfFirst { link -> - link == readingOrderLink - } - if (index != -1) { - val newLocator = locator.copy( + if (emittingLocator.locations.position == null) { + publication.readingOrder.indexOfFirst { link -> + link == readingOrderLink + }.takeIf { it > -1 }?.let { index -> + emittingLocator = emittingLocator.copy( locations = locator.locations.copy(position = index + 1) ) - timebaseListener.onTimebasedCurrentLocatorChanges(newLocator, readingOrderLink) - return } } - timebaseListener.onTimebasedCurrentLocatorChanges(locator, readingOrderLink) + timebaseListener.onTimebasedCurrentLocatorChanges(emittingLocator, readingOrderLink) } /** diff --git a/flutter_readium/assets/_helper_scripts/package-lock.json b/flutter_readium/assets/_helper_scripts/package-lock.json index 63d5def3..ce089d8e 100644 --- a/flutter_readium/assets/_helper_scripts/package-lock.json +++ b/flutter_readium/assets/_helper_scripts/package-lock.json @@ -9,7 +9,6 @@ "version": "1.0.0", "dependencies": { "animejs": "~3.2.2", - "css-selector-generator": "~3.6.9", "lit": "~3.3.0", "readium-css": "github:readium/readium-css" }, @@ -612,10 +611,11 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -707,10 +707,11 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -740,18 +741,32 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", - "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.14.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2304,10 +2319,11 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2337,10 +2353,11 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3244,11 +3261,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/css-selector-generator": { - "version": "3.6.9", - "resolved": "https://registry.npmjs.org/css-selector-generator/-/css-selector-generator-3.6.9.tgz", - "integrity": "sha512-OXV+a4wlKs+8TGxTZ8g96mQOKz5QDVE52QYdYusdbcmt0XkJd6F9zkXpMZbRk3pwwRF+K2pJkj1DINpWm7Isqw==" - }, "node_modules/css-what": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", @@ -3480,10 +3492,11 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -4074,10 +4087,11 @@ } }, "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -4218,10 +4232,11 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -4515,10 +4530,11 @@ } }, "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -4811,10 +4827,11 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5629,10 +5646,11 @@ } }, "node_modules/jake/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -6627,12 +6645,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, + "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -7735,10 +7754,11 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -8271,10 +8291,11 @@ } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, diff --git a/flutter_readium/assets/_helper_scripts/package.json b/flutter_readium/assets/_helper_scripts/package.json index 597479b3..066edfa8 100644 --- a/flutter_readium/assets/_helper_scripts/package.json +++ b/flutter_readium/assets/_helper_scripts/package.json @@ -51,7 +51,6 @@ }, "dependencies": { "animejs": "~3.2.2", - "css-selector-generator": "~3.6.9", "lit": "~3.3.0", "readium-css": "github:readium/readium-css" } diff --git a/flutter_readium/assets/_helper_scripts/src/EpubPage.scss b/flutter_readium/assets/_helper_scripts/src/EpubPage.scss index 5de97c61..4aede2f6 100644 --- a/flutter_readium/assets/_helper_scripts/src/EpubPage.scss +++ b/flutter_readium/assets/_helper_scripts/src/EpubPage.scss @@ -10,12 +10,6 @@ font-size: 98%; } -span#activeLocation { - border-radius: 4px; - background-color: var(--USER__highlightBackgroundColor) !important; - color: var(--USER__highlightForegroundColor) !important; -} - body > *:first-child { margin-top: 50px !important; } diff --git a/flutter_readium/assets/_helper_scripts/src/EpubPage.ts b/flutter_readium/assets/_helper_scripts/src/EpubPage.ts index 78ccbfbc..fbbb426e 100644 --- a/flutter_readium/assets/_helper_scripts/src/EpubPage.ts +++ b/flutter_readium/assets/_helper_scripts/src/EpubPage.ts @@ -1,726 +1,45 @@ -/* eslint-disable @typescript-eslint/restrict-template-expressions */ - -import { ComicBookPage } from 'ComicBookPage'; -import { getCssSelector } from 'css-selector-generator'; import { initResponsiveTables } from './Tables'; -import { DomRange, ICurrentHeading, IHeadingElement, Locations, Locator, Readium, Rect } from 'types'; +import { PageInformation, Readium } from 'types'; import './EpubPage.scss'; declare const isIos: boolean; declare const isAndroid: boolean; declare const webkit: any; declare const readium: Readium; -declare const comicBookPage: ComicBookPage; -declare const Android: any | null; export class EpubPage { - private readonly _headingTagNames = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; - - private _allHeadings: IHeadingElement[] | null; - - private readonly _activeLocationId = 'activeLocation'; - - private readonly _locationTag = 'span'; - - private _documentRange = document.createRange(); - - // Sets an active location, and optionally navigates to a specific frame in a comic book - public setLocation(locator: Locator | null, isAudioBookWithText: boolean): void { - this._debugLog(locator); - - try { - if (locator == null) { - this._debugLog('No locator set'); - - return; - } - - this._removeLocation(); - - this._setLocation(locator, isAudioBookWithText); - - if (this._isComicBook()) { - const cssSelector = - locator?.locations?.cssSelector ?? locator?.locations?.domRange?.start?.cssSelector ?? locator?.locations?.domRange?.end?.cssSelector; - - if (cssSelector == null) { - this._errorLog('Css selector not set!'); - return; - } - - const duration = this._getDurationFragment(locator?.locations?.fragments); - if (duration == null) { - this._errorLog('Duration not set!'); - return; - } - - window.GotoComicFrame(cssSelector, duration * 1000); - } - - // this.debugLog(`setAll END`); - } catch (error) { - this._errorLog(error); - } - } - - // Scrolls such that the given Locations is visible. If part of the given Locations is - // already visible, only scrolls to the start of it if toStart is true. - public scrollToLocations(locations: Locations, isVerticalScroll: boolean, toStart: boolean): boolean { - try { - const range = this._processLocations(locations); - if (range != null) { - this._scrollToProcessedRange(range, isVerticalScroll, toStart); - - return true; - } - - const progression = locations.progression; - if (progression != null) { - readium?.scrollToPosition(progression); - - return true; - } - - this._debugLog(`ScrollToLocations: Unknown range`, locations); - } catch (error) { - this._errorLog(error); - } - - return false; - } - - // Checks whether a given locator is (at least partially) visible. - public isLocatorVisible(locator: Locator): boolean { - this._debugLog(locator); - - try { - const locations = locator.locations; - const selector = locations.cssSelector ?? locations.domRange?.start?.cssSelector; - - if (this._isComicBook()) { - const res = document.querySelector(selector) != null; - this._debugLog(`Comic book`, locations, { found: res, selector }); - - return res; - } - - const range = this._processLocations(locations); - if (range == null) { - this._debugLog(`isLocatorVisible: Unknown range`, locations); - return false; - } - // Checks also that the locator also contains `active` class. - // TODO: This doesn't do what we expect, if the range is visible but not active, this function will return false. - return this._isProcessedRangeVisible(range) && !!document.querySelector(`${selector} #${this._activeLocationId}`); - } catch (error) { - this._errorLog(error); - - // Use true as default to prevent showing the sync button. - return true; - } + private get _isScrollModeEnabled(): boolean { + return getComputedStyle(document.documentElement).getPropertyValue("--USER__view") === "readium-scroll-on"; } - // Returns fragments for current location. - public getLocatorFragments(locator: Locator, isVerticalScroll: boolean): Locator { - try { - const cssSelector = locator?.locations?.cssSelector ?? this._findFirstVisibleCssSelector(); - if (cssSelector == null || !cssSelector?.length) { - this._debugLog('getLocatorFragments: selector not found, returning locator from args'); + public getPageInformation(): PageInformation { + const physicalPageIndex = this._findCurrentPhysicalPage(); - return locator; - } - - const fragments = [...this._getPageFragments(isVerticalScroll), ...this._getTocFragments(cssSelector), ...this._getPhysicalPageFragments(cssSelector)]; - - const locatorWithFragments = { - ...locator, - locations: { - cssSelector, - ...locator.locations, - fragments: [...(locator.locations?.fragments ?? []), ...fragments], - }, - }; - - return locatorWithFragments; - } catch (error) { - this._errorLog(error); - - return locator; - } - } - - private _isComicBook(): boolean { - try { - return !!comicBookPage?.isComicBook(); - } catch (_) { - return false; - } - } - - private _isTextNode(node: Node) { - // const TEXT_NODE = 3; - // const CDATA_SECTION_NODE = 4; - const nodeType = node.nodeType; - return nodeType === 3 || nodeType === 4; - } - - // private - private _clamp(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), max); - } - - // Returns the `Text` node starting at the character with offset `charOffset` in node, splitting - // the text node if needed to do so. Returns `null` if charOffset is at (or past) the end of node. - private _findAndSplitOffset(node: Node, charOffset: number): Node | null { - // Using nested function to make sure node is not returned by reference. - function process(n: Node): Node | null { - // TEXT_NODE = 3; - // CDATA_SECTION_NODE = 4; - if (n.nodeType === 3 || n.nodeType === 4) { - const text = n as Text; - if (charOffset <= 0) { - return text; - } - - const length = text.length; - if (charOffset < length) { - return text.splitText(charOffset); - } - - charOffset -= length; - } - - const children = n.childNodes; - const childCount = children.length; - - for (let i = 0; i < childCount; ++i) { - const tn = process(children[i]); - - if (tn != null) { - return tn; - } - } - - return null; - } - - return process(node); - } - - // Next node following the end or closing tag of the given node. - private _nextNodeNotChild(node: HTMLElement | Node): Node | null { - return node && (node.nextSibling ?? this._nextNodeNotChild(node.parentNode)); - } - - // Next node following the start of the given node, including child nodes. - private _nextNode(node: HTMLElement | Node): Node { - return node && (node.firstChild ?? node.nextSibling ?? this._nextNodeNotChild(node.parentNode)); - } - - // Previous node before the beginning or opening tag of the given node. - private _previousNodeNotChild(node: HTMLElement | Node): Node { - return node && (node.previousSibling ?? this._previousNodeNotChild(node.parentNode)); - } - - // Previous node before the beginning of the given node, including child nodes. - private _previousNode(node: HTMLElement | Node): Node { - return node && (node.lastChild ?? node.previousSibling ?? this._previousNodeNotChild(node.parentNode)); - } - - // First non-whitespace character at or after the given node/charOffset. - private _findNonWhitespaceForward(node: HTMLElement | Node, charOffset: number) { - while (node != null) { - if (this._isTextNode(node)) { - const data = (node as Text).data; - charOffset = Math.max(charOffset, 0); - - while (charOffset < data.length) { - if (data[charOffset].trim() !== '') { - return { - node, - charOffset, - }; - } - - ++charOffset; - } - - charOffset = 0; - } - - node = this._nextNode(node); - } - } - - // First non-whitespace character at or before the given node/charOffset. - private _findNonWhitespaceBackward(node: HTMLElement | Node, charOffset?: number) { - while (node != null) { - if (this._isTextNode(node)) { - const data = (node as Text).data; - const last = data.length - 1; - charOffset = Math.min(charOffset ?? last, last); - - while (charOffset >= 0) { - if (data[charOffset].trim() !== '') { - return { - node, - charOffset, - }; - } - - --charOffset; - } - - charOffset = undefined; - } - - node = this._previousNode(node); - } - } - - // First non-whitespace character at or before the given node/charOffset. - private _findNonWhitespace(node: HTMLElement | Node, charOffset: number) { - return this._findNonWhitespaceBackward(node, charOffset) ?? this._findNonWhitespaceForward(node, charOffset); - } - - // Creates a new span specified by locator, with the style attribute given by style. Split into - // multiple spans if needed. - private _setLocation(locator: Locator, isAudioBookWithText: boolean) { - const currentLink = readium.link; - this._debugLog(`create:`, locator, currentLink); - - const locations = locator.locations; - const startCssSelector = locations.cssSelector ?? locations?.domRange?.start?.cssSelector; - - if (!startCssSelector) { - this._errorLog(`Start css selector not found`); - - return; - } - - const startParent = document.querySelector(startCssSelector); - if (!startParent) { - this._errorLog(`Start parent not found`); - - return; - } - - const endCssSelector = locations?.domRange?.end.cssSelector ?? startCssSelector; - const endParent = endCssSelector === startCssSelector ? startParent : document.querySelector(endCssSelector); - - if (!endParent) { - this._errorLog(`End parent not found`); - - return; - } - - const startOffset = locations?.domRange?.start?.charOffset; - const endOffset = locations?.domRange?.end?.charOffset; - - // highlight for audiobooks with text - if (!startOffset && !endOffset && isAudioBookWithText) { - this._wrapWithLocationElement(startParent); - - return; - } - - // Iterate over text nodes between startText and endText (if null, use end of parent). - const startNode = this._findAndSplitOffset(startParent, startOffset) ?? this._nextNodeNotChild(startParent); - const endNode = this._findAndSplitOffset(endParent, endOffset) ?? this._nextNodeNotChild(endParent); - - const texts = new Array(); - - for (let node = startNode; node && node !== endNode; node = this._nextNode(node)) { - if (this._isTextNode(node)) { - texts.push(node as Text); - } - } - - for (const text of texts) { - const locationEl = this._setLocationElement(); - locationEl.appendChild(text.cloneNode(true)); - text.replaceWith(locationEl); - } - } - - // Removes a previously-added span (but doesn't remove the contents of the span). - private _removeLocation() { - this._debugLog('Remove old location'); - - const nodes = document.querySelectorAll(`#${this._activeLocationId}`); - nodes?.forEach((node) => { - if (this._isAndroid()) { - // Ugly workaround for randomly changing layout on Android emulator. - // Leaks lots of useless s. - node.removeAttribute('id'); - return; - } - - const parent = node.parentNode; - node.replaceWith(...node.childNodes); - parent.normalize(); - }); - } - - // The screen rectangle in horizontal scrolling mode and a slightly shortened screen rectangle in - // vertical scrolling mode. - private _safeVisibleRect(isVerticalScroll: boolean): Rect { - const { innerWidth, innerHeight } = window; - - if (isVerticalScroll) { - return { - left: 0, - top: 0.05 * innerHeight, - right: innerWidth, - bottom: 0.95 * innerHeight, - }; - } - return { - left: 0, - top: 0, - right: innerWidth, - bottom: innerHeight, - }; - } - - private *_descendentTextNodes(node: Node): Generator { - if (this._isTextNode(node)) { - yield node as Text; - } else { - for (const child of node.childNodes) { - yield* this._descendentTextNodes(child); - } - } - } - - private _findTextPosition(node: Node, charOffset: number) { - // Converts a text offset in a node into something suitable for Range.setStart or Range.setEnd. - if (node == null) { - this._errorLog(`findTextPosition: no node, charOffset=${charOffset}`); - return; - } - - if (charOffset < 0 || isNaN(charOffset)) { - this._errorLog(`findTextPosition: invalid charOffset, node=${node.nodeValue}, charOffset=${charOffset}`); - return; - } - - if (charOffset === 0) { - return { - node, - charOffset, - }; - } - - for (const textNode of this._descendentTextNodes(node)) { - const length = textNode.length; - - if (charOffset <= length) { + if (readium?.isReflowable) { + if (this._isScrollModeEnabled) { return { - node: textNode, - charOffset, - }; - } - - charOffset -= length; - } - - this._errorLog(`findTextPosition: failed, node=${this._debugNode(node)}, charOffset=${charOffset}`); - - return; - } - - private _processDomRange(domRange: DomRange) { - const { start, end } = domRange; - const { cssSelector: startSelector, charOffset: startOffset } = start; - const { cssSelector: endSelector, charOffset: endOffset } = end != null && end !== void 0 ? end : start; - const startNode = document.querySelector(startSelector); - const startBoundary = this._findTextPosition(startNode, startOffset ?? 0); - - if (startBoundary == null) { - this._errorLog(`DomRange bad start, selector=${startSelector}`); - return; - } - - const endNode = endSelector === startSelector ? startNode : document.querySelector(endSelector); - const endBoundary = this._findTextPosition(endNode, endOffset != null && endOffset !== void 0 ? endOffset : 0); - - if (endBoundary == null) { - this._errorLog(`DomRange bad end, selector=${endSelector}`); - return; - } - - try { - this._documentRange.setStart(startBoundary.node, startBoundary.charOffset); - // this.debugLog(`range.setStart(${startBoundary.node.id ?? startBoundary.node.nodeName}, ${startBoundary.charOffset});`); - } catch (e) { - this._errorLog(`${this._debugNode(startBoundary.node)}, ${startBoundary.charOffset}`, e); - this._documentRange.setStartAfter(startBoundary.node); - } - - try { - this._documentRange.setEnd(endBoundary.node, endBoundary.charOffset); - // this.debugLog(`range.setEnd(${endBoundary.node.id ?? endBoundary.node.nodeName}, ${endBoundary.charOffset});`); - } catch (e) { - this._errorLog(`${this._debugNode(endBoundary.node)}, ${endBoundary.charOffset}`, e); - this._documentRange.setEndAfter(endBoundary.node); - } - - // Work around possible bad getClientBoundingRect data when the start/end of the range is the - // same. Browser bug? Seen on an Android device, not sure whether it happens on iOS. - // https://stackoverflow.com/questions/59767515/incorrect-positioning-of-getboundingclientrect-after-newline-character - if (this._documentRange.getClientRects().length === 0) { - const pos = this._findNonWhitespace(startBoundary.node, startBoundary.charOffset); - - if (pos == null) { - this._errorLog(`Couldn't find any non-whitespace characters in the document!'`); - return; - } - - const { node, charOffset } = pos; - this._documentRange.setStart(node, charOffset); - this._documentRange.setEnd(node, charOffset + 1); - } - - return this._documentRange; - } - - private _processCssSelector(cssSelector: string) { - const node = document.querySelector(cssSelector); - - if (node == null) { - this._errorLog(`processCssSelector: error: node not found ${cssSelector}`); - return; - } - - // Make sure node is visible on the page in order to get the range. - if (window.getComputedStyle(node).display === 'none') { - (node as HTMLElement).style.display = this._isPageBreakElement(node) ? 'flex' : 'block'; - } - - this._documentRange.selectNode(node); - return this._documentRange; - } - - private _processLocations(locations: Locations): Range | null { - if (locations == null) { - this._errorLog('location not set'); - - return; - } - - if (locations.domRange) { - return this._processDomRange(locations.domRange); - } - - const selector = locations.cssSelector ?? locations.domRange?.start?.cssSelector; - if (selector) { - return this._processCssSelector(selector); - } - } - - private _scrollToProcessedRange(range: Range, isVerticalScroll: boolean, toStart: boolean) { - if (toStart || !this._isProcessedRangeVisible(range)) { - this._scrollToBoundingClientRect(range, isVerticalScroll); - } - } - - private _scrollToBoundingClientRect(range: Range, isVerticalScroll: boolean) { - const { top, right, bottom, left } = range.getBoundingClientRect(); - - if (top === 0 && right === 0 && bottom === 0 && left === 0) { - this._debugLog(`scrollToBoundingClientRect: Scrolling to defective bounding rect, abort! `, range.getClientRects(), range.getClientRects().length); - return; - } - - const { scrollLeft, scrollWidth, scrollTop, scrollHeight } = document.scrollingElement; - - if (isVerticalScroll) { - const { top: minHeight, bottom: maxHeight } = this._safeVisibleRect(isVerticalScroll); - - if (top < minHeight || bottom > maxHeight) { - const offset = this._clamp((scrollTop + top - minHeight) / scrollHeight, 0, 1); - readium?.scrollToPosition(offset); + pageIndex: null, + totalPages: null, + physicalPageIndex, + } } - } else { - const offset = (scrollLeft + 0.5 * (left + right)) / scrollWidth; - readium?.scrollToPosition(offset); - } - } - private _isProcessedRangeVisible(range: Range) { - const { innerWidth, innerHeight } = window; - const { top, right, bottom, left } = range.getBoundingClientRect(); - return top < innerHeight && 0 < bottom && left < innerWidth && 0 < right; - } - - private _getPageFragments(isVerticalScroll: boolean): string[] { - try { const { scrollLeft, scrollWidth } = document.scrollingElement; - const { innerWidth } = window; - const pageIndex = isVerticalScroll ? null : Math.round(scrollLeft / innerWidth) + 1; - const totalPages = isVerticalScroll ? null : Math.round(scrollWidth / innerWidth); - - return [`page=${pageIndex}`, `totalPages=${totalPages}`]; - } catch (error) { - this._errorLog(error); - - return []; - } - } - - private _getDurationFragment(fragments: string[]): number | null { - try { - const durationFragment = fragments.find((fragment) => fragment.includes('duration=')); - if (!durationFragment) { - this._errorLog('Duration fragment not found.'); - return; - } - - const durationMatch = /duration=(\d+(?:\.\d+)?)/.exec(durationFragment); - if (!durationMatch) { - this._errorLog('Invalid duration format.'); - return; - } - - this._debugLog(`Duration fragment:`, durationMatch[1]); - return parseFloat(durationMatch[1]); - } catch (error) { - this._errorLog('Could not retrieve duration fragment!'); - return; - } - } - - private _getTocFragments(selector: string): string[] { - try { - const headings = this._findPrecedingAncestorSiblingHeadings(selector); - const id = headings[0]?.id; - if (id == null) { - return []; - } - - return [`toc=${id}`]; - } catch (error) { - this._errorLog(error); - - return []; - } - } - - private _getPhysicalPageFragments(selector: string): string[] { - try { - const currentPhysicalPage = this._findCurrentPhysicalPage(selector); - - if (currentPhysicalPage == null) { - return []; - } - - return [`physicalPage=${currentPhysicalPage}`]; - } catch (error) { - this._errorLog(`Selector:${selector} -- ${error}`); - - return []; - } - } - - // TODO: Code below is from Thorium project. - // Use Intersection Observer API instead: - // https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API - private _findPrecedingAncestorSiblingHeadings(selector: string | null): ICurrentHeading[] | null { - const selectorElement = document.querySelector(selector); - - // Check if the element contains any heading before finding parent or sibling heading. - const currentElement = selectorElement?.querySelectorAll(this._headingTagNames.join(','))[0] ?? selectorElement; - - if (currentElement == null) { - return; - } - - if (!this._allHeadings) { - const headingElements = Array.from(window.document.querySelectorAll(this._headingTagNames.join(','))); - for (const hElement of headingElements) { - if (hElement) { - const el = hElement; - const t = el.textContent || el.getAttribute('title') || el.getAttribute('aria-label'); - let i = el.getAttribute('id'); - if (!i) { - // common authoring pattern: parent section (or other container element) has the navigation target anchor - let cur = el; - let p: Element | null; - while ((p = cur.parentNode as Element | null) && p?.nodeType === Node.ELEMENT_NODE) { - if (p.firstElementChild !== cur) { - break; - } - - const di = p.getAttribute('id'); - if (di) { - i = di; - break; - } - - cur = p; - } - } - const heading: IHeadingElement = { - element: el, - id: i ? i : null, - level: parseInt(el.localName.substring(1), 10), - text: t, - }; - if (!this._allHeadings) { - this._allHeadings = []; - } - this._allHeadings.push(heading); - } - } - - if (!this._allHeadings) { - this._allHeadings = []; - } - } - - let arr: ICurrentHeading[] | null; - for (let i = this._allHeadings.length - 1; i >= 0; i--) { - const heading = this._allHeadings[i]; - - const c = currentElement.compareDocumentPosition(heading.element); - - // eslint-disable-next-line no-bitwise - if (c === 0 || c & Node.DOCUMENT_POSITION_PRECEDING || c & Node.DOCUMENT_POSITION_CONTAINS) { - if (!arr) { - arr = []; - } - - // Don't add the heading since the id is missing and it means that toc element does not - // points to this heading. Probably the element is wrapped in `body` or `section` element - // which will handled further below. - if (heading?.id) { - arr.push({ - id: heading.id, - level: heading.level, - text: heading.text, - }); - } + return { + pageIndex: this._isScrollModeEnabled ? null : Math.round(scrollLeft / innerWidth) + 1, + totalPages: this._isScrollModeEnabled ? null : Math.round(scrollWidth / innerWidth), + physicalPageIndex, } } - if (arr?.length) { - return arr; - } - - // No heading found try with closes section or body - const closetSectionOrBody = selectorElement.closest('section') ?? selectorElement.closest('body'); - if (closetSectionOrBody) { - return [ - { - id: closetSectionOrBody.id, - level: 0, - text: closetSectionOrBody.innerText, - }, - ]; - } + // Assume fixed layout has only one page, and the physical page index is determined by the current visible element. + return { + pageIndex: 1, + totalPages: 1, + physicalPageIndex, + }; } private _isPageBreakElement(element: Element | null): boolean { @@ -761,7 +80,14 @@ export class EpubPage { return sibs; } - private _findCurrentPhysicalPage(cssSelector: string): string | null { + /** + * Find the current physical page index. + * + * @returns The physical page index, or null if it cannot be determined. + */ + public _findCurrentPhysicalPage(): string | null { + const cssSelector = readium.findFirstVisibleLocator()?.locations?.cssSelector; + let element = document.querySelector(cssSelector); if (element == null) { @@ -772,7 +98,7 @@ export class EpubPage { return this._getPhysicalPageIndexFromElement(element as HTMLElement); } - while (element.nodeType === Node.ELEMENT_NODE) { + while (!!element && element.nodeType === Node.ELEMENT_NODE) { const siblings = this._getAllSiblings(element); if (siblings == null) { return; @@ -797,213 +123,6 @@ export class EpubPage { } } - private _findFirstVisibleCssSelector(): string { - const selector = this._getCssSelector(this._getFirstVisibleElement()); - - return selector; - } - - private _getCssSelector(element: Element): string { - try { - const selector = getCssSelector(element, { - root: document.querySelector('body'), - }); - - // Sometimes getCssSelector returns `:root > :nth-child(2)` instead of `body` - // In such cases, replace it with `body` - const cssSelector = selector?.replace(':root > :nth-child(2)', 'body')?.trim() ?? 'body'; - - this._debugLog(cssSelector); - - return cssSelector; - } catch (error) { - this._errorLog(error); - - return 'body'; - } - } - - private _getFirstVisibleElement(): Element { - const element = this._findFirstVisibleElement(document.body); - - this._debugLog(`First visible element:`, { - tagName: element.nodeName.toLocaleLowerCase(), - id: element.id, - className: element.className, - }); - - return element; - } - - private _findFirstVisibleElement(node: Element): Element { - const nodeData = { - tagName: node.nodeName.toLocaleLowerCase(), - id: node.id, - className: node.className, - }; - - for (const child of node.children) { - const childData = { - tagName: child.nodeName.toLocaleLowerCase(), - id: child.id, - className: child.className, - }; - - if (!this._isElementVisible(child)) { - // Uncomment only when debugging. - // this._debugLog(`Not visible - continue`, childData); - - continue; - } - - if (this._shouldIgnoreElement(child)) { - this._debugLog(`Element is ignored - continue`, childData); - - continue; - } - - if (child.id.includes(`${this._activeLocationId}`)) { - this._debugLog(`Child is an active location element, return closest element with id`, { childData, nodeData }); - - return node.id ? node : this._findClosestElementWithId(child); - } - - if (child.hasChildNodes()) { - this._debugLog(`Loop into children`, childData); - - return this._findFirstVisibleElement(child); - } - - // This should not happens - if (!child.id) { - this._debugLog(`Element has no ID attribute - return closest element with id`, childData); - - return node.id ? node : this._findClosestElementWithId(child); - } - } - - this._debugLog(`return:`, nodeData); - - return node; - } - - private _findClosestElementWithId(element: Element): Element | null { - let currentElement = element.parentElement; - - while (currentElement !== null) { - if (currentElement.id) { - return currentElement; - } - currentElement = currentElement.parentElement; - } - - this._debugLog('No element with id attr found!'); - return element; - } - - // Returns first visible element in viewport. - // True `fullVisibility` will ignore the element if it starts on previous pages. - private _isElementVisible(element: Element, fullVisibility = false): boolean { - if (readium?.isFixedLayout) { - return true; - } - - if (element === document.body || element === document.documentElement) { - return true; - } else if (!document || !document.documentElement || !document.body) { - return false; - } - - const rect = element.getBoundingClientRect(); - - if (fullVisibility) { - if (this._isScrollModeEnabled()) { - return rect.top >= 0 && rect.top <= document.documentElement.clientHeight; - } - - if (rect.left >= 1) { - return true; - } - - return false; - } - - if (this._isScrollModeEnabled()) { - return rect.bottom > 0 && rect.top < window.innerHeight; - } - - return rect.right > 0 && rect.left < window.innerWidth; - } - - private _shouldIgnoreElement(element: Element): boolean { - const elStyle = window.getComputedStyle(element); - if (elStyle) { - const display = elStyle.getPropertyValue('display'); - if (display === 'none') { - return true; - } - // Cannot be relied upon, because web browser engine reports invisible when out of view in - // scrolled columns! - // const visibility = elStyle.getPropertyValue("visibility"); - // if (visibility === "hidden") { - // return false; - // } - const opacity = elStyle.getPropertyValue('opacity'); - if (opacity === '0') { - return true; - } - } - - return this._isElementEmpty(element); - } - - private _isElementEmpty(element: Element): boolean { - const nodeName = element.tagName.toLowerCase(); - if (nodeName === 'img') { - return false; - } - - return element.textContent.trim() === ''; - } - - private _wrapWithLocationElement(el: Element): void { - const parentElement = el; - - if (parentElement) { - const locationEl = this._setLocationElement(); - - while (parentElement.firstChild) { - const child = parentElement.firstChild; - parentElement.removeChild(child); - locationEl.appendChild(child); - } - - parentElement.appendChild(locationEl); - } - } - - private _setLocationElement() { - const el = document.createElement(this._locationTag); - el.id = this._activeLocationId; - - return el; - } - - private _isScrollModeEnabled() { - const style = document.documentElement.style; - return ( - style.getPropertyValue('--USER__view').trim() === 'readium-scroll-on' || - // FIXME: Will need to be removed in Readium 3.0, --USER__scroll was incorrect. - style.getPropertyValue('--USER__scroll').trim() === 'readium-scroll-on' - ); - } - - private _debugLog(...args: unknown[]) { - this._log(`=======Flutter Readium Debug=====`); - this._log(args); - this._log(`=================================`); - } - private _log(...args: unknown[]) { // Alternative for webkit in order to print logs in flutter log outputs. @@ -1024,16 +143,6 @@ export class EpubPage { console.log(JSON.stringify(args)); } - private _debugNode(node: HTMLElement | Node | null): string | undefined { - if (node instanceof Node) { - const xmlSerializer = new XMLSerializer(); - return xmlSerializer.serializeToString(node); - } else if ('innerHTML' in node || 'textContent' in node) { - const element = node as HTMLElement; - return element.innerHTML ?? element.textContent ?? '?'; - } - } - private _errorLog(...error: any) { this._log(`v===v===v===v===v===v`); this._log(`Error:`, error); diff --git a/flutter_readium/assets/_helper_scripts/src/types.ts b/flutter_readium/assets/_helper_scripts/src/types.ts index 670cc740..c39cd0bf 100644 --- a/flutter_readium/assets/_helper_scripts/src/types.ts +++ b/flutter_readium/assets/_helper_scripts/src/types.ts @@ -25,24 +25,25 @@ export interface ComicFramePosition { * Readium JS library injected by kotlin/swift-toolkit. **/ export interface Readium { - link: any; - isFixedLayout: boolean; - isReflowable: boolean; + get isFixedLayout(): boolean | undefined; + get isReflowable(): boolean | undefined; /** * @param progression // Position must be in the range [0 - 1], 0-100%. */ scrollToPosition(progression: number): void; - getColumnCountPerScreen(): void; - - isScrollModeEnabled(): boolean; - - isVerticalWritingMode(): boolean; - - // Scroll to the given TagId in document and snap. + /** + * Scroll to the given TagId in document and snap. + */ scrollToId(id: string): void; + /** + * Scrolls to the first occurrence of the given text snippet. + * + * The expected text argument is a Locator object, as defined here: + * https://readium.org/architecture/models/locators/ + */ scrollToLocator(locator: Locator): void; scrollToStart(): void; @@ -51,16 +52,14 @@ export interface Readium { scrollLeft(): void; - snapCurrentOffset(): void; - - rangeFromLocator(): Range; + scrollRight(): void; setCSSProperties(properties: Record): void; setProperty(key: string, value: string): void; removeProperty(key: string): void; - + getCurrentSelection(): CurrentSelection; registerDecorationTemplates(newStyles: Record): void; @@ -132,4 +131,10 @@ export interface CurrentSelectionRect { export interface CurrentSelection { text: CurrentSelectionText; rect: CurrentSelectionRect; -} \ No newline at end of file +} + +export interface PageInformation { + pageIndex: number | null; + totalPages: number | null; + physicalPageIndex: string | null; +} diff --git a/flutter_readium/assets/helpers/epub.css b/flutter_readium/assets/helpers/epub.css index c71da7d4..43474c5a 100644 --- a/flutter_readium/assets/helpers/epub.css +++ b/flutter_readium/assets/helpers/epub.css @@ -1 +1 @@ -[type=pagebreak]{border-top:1px solid;display:block;width:100%;line-height:100%;padding-top:8px;margin-top:40px;margin-bottom:20px;text-align:right;font-size:98%}span#activeLocation{border-radius:4px;background-color:var(--USER__highlightBackgroundColor) !important;color:var(--USER__highlightForegroundColor) !important}body>*:first-child{margin-top:50px !important}*{word-wrap:break-word}table{border-collapse:collapse;border-spacing:0;margin:15px 0;width:calc(100% - var(--RS__pageGutter)/2);word-break:break-word}table h1,table h2,table h3,table h4,table h5,table h6{margin:0}table *{font-size:1rem}table td,table th{border-collapse:collapse;border:1px solid #ccc;margin:0;padding:16px;vertical-align:top}table caption{margin-bottom:16px}table.transparent-table,table.docx-table,table.plain-table{table-layout:fixed;width:100%}table.transparent-table th,table.transparent-table td,table.docx-table th,table.docx-table td,table.plain-table th,table.plain-table td{width:auto;max-width:100%}table.transparent-table{border-width:0}table.transparent-table td,table.transparent-table th{border-width:0}table.has-first-row-headers tr:first-child{display:none}table.has-header{border:none !important}table.has-header tr{display:block;margin-bottom:25px}table.has-header tr td{border-top-width:0;display:block;width:100% !important;box-sizing:border-box}table.has-header tr td:first-child{border-top-width:1px}table.has-header tr td:not(.mobile-header):before{background-color:#7c7c7c;color:#fff;content:attr(data-th);display:block;margin:-16px -16px 5px;padding:8px 16px}table.has-header tr td.mobile-header{text-transform:uppercase;background-color:#241f20 !important}table.has-header tr td.mobile-header h6{color:#fff}table.has-header thead tr:first-child{display:none} +[type=pagebreak]{border-top:1px solid;display:block;width:100%;line-height:100%;padding-top:8px;margin-top:40px;margin-bottom:20px;text-align:right;font-size:98%}body>*:first-child{margin-top:50px !important}*{word-wrap:break-word}table{border-collapse:collapse;border-spacing:0;margin:15px 0;width:calc(100% - var(--RS__pageGutter)/2);word-break:break-word}table h1,table h2,table h3,table h4,table h5,table h6{margin:0}table *{font-size:1rem}table td,table th{border-collapse:collapse;border:1px solid #ccc;margin:0;padding:16px;vertical-align:top}table caption{margin-bottom:16px}table.transparent-table,table.docx-table,table.plain-table{table-layout:fixed;width:100%}table.transparent-table th,table.transparent-table td,table.docx-table th,table.docx-table td,table.plain-table th,table.plain-table td{width:auto;max-width:100%}table.transparent-table{border-width:0}table.transparent-table td,table.transparent-table th{border-width:0}table.has-first-row-headers tr:first-child{display:none}table.has-header{border:none !important}table.has-header tr{display:block;margin-bottom:25px}table.has-header tr td{border-top-width:0;display:block;width:100% !important;box-sizing:border-box}table.has-header tr td:first-child{border-top-width:1px}table.has-header tr td:not(.mobile-header):before{background-color:#7c7c7c;color:#fff;content:attr(data-th);display:block;margin:-16px -16px 5px;padding:8px 16px}table.has-header tr td.mobile-header{text-transform:uppercase;background-color:#241f20 !important}table.has-header tr td.mobile-header h6{color:#fff}table.has-header thead tr:first-child{display:none} diff --git a/flutter_readium/assets/helpers/epub.js b/flutter_readium/assets/helpers/epub.js index fc8af5ff..69955a43 100644 --- a/flutter_readium/assets/helpers/epub.js +++ b/flutter_readium/assets/helpers/epub.js @@ -1 +1 @@ -var epub;(()=>{var e={324(e,t,n){"use strict";n.r(t)},679(e,t){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.initResponsiveTables=function(){let e=Array.from(document.querySelectorAll("table"));if(e=e.filter((e=>!e.classList.contains("docx-table")&&!e.classList.contains("transparent-table")&&!e.classList.contains("plain-table"))),!e||0===e.length)return;e.forEach((e=>{if(function(e){let t=0,n=0;if(e.querySelectorAll("tr").forEach((e=>{e.querySelectorAll("td").forEach((e=>{""===e.textContent.trim()?t++:n++}))})),t>n)return e.classList.add("plain-table"),!0;return!1}(e))return;const t=e.querySelectorAll("thead th").length,n=e.querySelectorAll("tbody tr");if(1===t&&Array.from(n).every((e=>2===e.querySelectorAll("td").length))){const t=n[0].querySelectorAll("td");if(Array.from(t).every((e=>e.querySelector("strong")||Array.from({length:6},((t,n)=>e.querySelector(`h${n+1}`))).some((e=>null!==e)))))return void e.classList.add("plain-table");!function(e){const t=e.querySelector("thead th").textContent,n=Array.from(e.querySelectorAll("tbody td"));e.innerHTML="";const o=document.createElement("tbody"),r=document.createElement("tr"),i=document.createElement("tr");r.innerHTML=" ",i.innerHTML=`${t}`,n.forEach(((e,t)=>{if(t%2==0){const t=document.createElement("td");t.innerHTML=`

${e.textContent}

`,r.appendChild(t)}else{const t=document.createElement("td");Array.from(e.childNodes).forEach((e=>t.appendChild(e.cloneNode(!0)))),i.appendChild(t)}})),o.appendChild(r),o.appendChild(i),e.appendChild(o)}(e)}!function(e){let t=null,n=[];if(e.tHead)t=e.tHead.rows[0],n=Array.from(t.cells);else{const o=e.rows[0];o&&o.querySelector("th, h1, h2, h3, h4, h5, h6, strong")&&(t=o,n=Array.from(o.cells),e.classList.add("has-first-row-headers"))}if(n.length){e.classList.add("has-header");const o=n.map((e=>{var t;let n=(null===(t=e.textContent)||void 0===t?void 0:t.trim())||"";return n.length&&(n+=":"),n}));Array.from(e.rows).filter((e=>e!==t)).forEach((e=>{if(Array.from(e.cells).forEach(((e,t)=>{e.setAttribute("data-th",o[t]||"")})),!e.cells[0].getAttribute("data-th")){e.cells[0].classList.add("mobile-header");const t=document.createElement("h6");t.textContent=e.cells[0].textContent,e.cells[0].textContent="",e.cells[0].appendChild(t)}}))}}(e)}))}},50(e){self,e.exports=(()=>{"use strict";var e={d:(t,n)=>{for(var o in n)e.o(n,o)&&!e.o(t,o)&&Object.defineProperty(t,o,{enumerable:!0,get:n[o]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},t={};function n(e){return"object"==typeof e&&null!==e&&e.nodeType===Node.ELEMENT_NODE}e.r(t),e.d(t,{default:()=>K,getCssSelector:()=>X});const o={NONE:"",DESCENDANT:" ",CHILD:" > "},r={id:"id",class:"class",tag:"tag",attribute:"attribute",nthchild:"nthchild",nthoftype:"nthoftype"},i="CssSelectorGenerator";function l(e="unknown problem",...t){console.warn(`${i}: ${e}`,...t)}const s={selectors:[r.id,r.class,r.tag,r.attribute],includeTag:!1,whitelist:[],blacklist:[],combineWithinSelector:!0,combineBetweenSelectors:!0,root:null,maxCombinations:Number.POSITIVE_INFINITY,maxCandidates:Number.POSITIVE_INFINITY};function c(e){return e instanceof RegExp}function a(e){return["string","function"].includes(typeof e)||c(e)}function d(e){return Array.isArray(e)?e.filter(a):[]}function u(e){const t=[Node.DOCUMENT_NODE,Node.DOCUMENT_FRAGMENT_NODE,Node.ELEMENT_NODE];return function(e){return e instanceof Node}(e)&&t.includes(e.nodeType)}function h(e,t){if(u(e))return e.contains(t)||l("element root mismatch","Provided root does not contain the element. This will most likely result in producing a fallback selector using element's real root node. If you plan to use the selector using provided root (e.g. `root.querySelector`), it will not work as intended."),e;const n=t.getRootNode({composed:!1});return u(n)?(n!==document&&l("shadow root inferred","You did not provide a root and the element is a child of Shadow DOM. This will produce a selector using ShadowRoot as a root. If you plan to use the selector using document as a root (e.g. `document.querySelector`), it will not work as intended."),n):y(t)}function f(e){return"number"==typeof e?e:Number.POSITIVE_INFINITY}function g(e=[]){const[t=[],...n]=e;return 0===n.length?t:n.reduce(((e,t)=>e.filter((e=>t.includes(e)))),t)}function m(e){return[].concat(...e)}function v(e){const t=e.map((e=>{if(c(e))return t=>e.test(t);if("function"==typeof e)return t=>{const n=e(t);return"boolean"!=typeof n?(l("pattern matcher function invalid","Provided pattern matching function does not return boolean. It's result will be ignored.",e),!1):n};if("string"==typeof e){const t=new RegExp("^"+e.replace(/[|\\{}()[\]^$+?.]/g,"\\$&").replace(/\*/g,".+")+"$");return e=>t.test(e)}return l("pattern matcher invalid","Pattern matching only accepts strings, regular expressions and/or functions. This item is invalid and will be ignored.",e),()=>!1}));return e=>t.some((t=>t(e)))}function p(e,t,n){const o=Array.from(h(n,e[0]).querySelectorAll(t));return o.length===e.length&&e.every((e=>o.includes(e)))}function _(e,t){t=null!=t?t:y(e);const o=[];let r=e;for(;n(r)&&r!==t;)o.push(r),r=r.parentElement;return o}function b(e,t){return g(e.map((e=>_(e,t))))}function y(e){return e.ownerDocument.querySelector(":root")}const N=", ",S=new RegExp(["^$","\\s"].join("|")),E=new RegExp(["^$"].join("|")),L=[r.nthoftype,r.tag,r.id,r.class,r.attribute,r.nthchild],C=v(["class","id","ng-*"]);function T({name:e}){return`[${e}]`}function w({name:e,value:t}){return`[${e}='${t}']`}function x({nodeName:e,nodeValue:t}){return{name:H(e),value:H(null!=t?t:void 0)}}function P(e){const t=Array.from(e.attributes).filter((t=>function({nodeName:e,nodeValue:t},n){const o=n.tagName.toLowerCase();return!(["input","option"].includes(o)&&"value"===e||"src"===e&&(null==t?void 0:t.startsWith("data:"))||C(e))}(t,e))).map(x);return[...t.map(T),...t.map(w)]}function O(e){var t;return(null!==(t=e.getAttribute("class"))&&void 0!==t?t:"").trim().split(/\s+/).filter((e=>!E.test(e))).map((e=>`.${H(e)}`))}function A(e){var t;const n=null!==(t=e.getAttribute("id"))&&void 0!==t?t:"",o=`#${H(n)}`,r=e.getRootNode({composed:!1});return!S.test(n)&&p([e],o,r)?[o]:[]}function R(e){const t=e.parentNode;if(t){const o=Array.from(t.childNodes).filter(n).indexOf(e);if(o>-1)return[`:nth-child(${String(o+1)})`]}return[]}function I(e){return[H(e.tagName.toLowerCase())]}function $(e){const t=[...new Set(m(e.map(I)))];return 0===t.length||t.length>1?[]:[t[0]]}function k(e){const t=$([e])[0],n=e.parentElement;if(n){const o=Array.from(n.children).filter((e=>e.tagName.toLowerCase()===t)),r=o.indexOf(e);if(r>-1)return[`${t}:nth-of-type(${String(r+1)})`]}return[]}function M(e=[],{maxResults:t=Number.POSITIVE_INFINITY}={}){return Array.from(function*(e=[],{maxResults:t=Number.POSITIVE_INFINITY}={}){let n=0,o=F(1);for(;o.length<=e.length&&ne[t]));yield t,o=D(o,e.length-1)}}(e,{maxResults:t}))}function D(e=[],t=0){const n=e.length;if(0===n)return[];const o=[...e];o[n-1]+=1;for(let e=n-1;e>=0;e--)if(o[e]>t){if(0===e)return F(n+1);o[e-1]++,o[e]=o[e-1]+1}return o[n-1]>t?F(n+1):o}function F(e=1){return Array.from(Array(e).keys())}const q=":".charCodeAt(0).toString(16).toUpperCase(),j=/[ !"#$%&'()\[\]{|}<>*+,./;=?@^`~\\]/;function H(e=""){return CSS?CSS.escape(e):function(e=""){return e.split("").map((e=>":"===e?`\\${q} `:j.test(e)?`\\${e}`:escape(e).replace(/%/g,"\\"))).join("")}(e)}const V={tag:$,id:function(e){return 0===e.length||e.length>1?[]:A(e[0])},class:function(e){return g(e.map(O))},attribute:function(e){return g(e.map(P))},nthchild:function(e){return g(e.map(R))},nthoftype:function(e){return g(e.map(k))}},W={tag:I,id:A,class:O,attribute:P,nthchild:R,nthoftype:k};function B(e){return e.includes(r.tag)||e.includes(r.nthoftype)?[...e]:[...e,r.tag]}function U(e={}){const t=[...L];return e[r.tag]&&e[r.nthoftype]&&t.splice(t.indexOf(r.tag),1),t.map((t=>{return(o=e)[n=t]?o[n].join(""):"";var n,o})).join("")}function Y(e,t,n="",r){const i=function(e,t){return""===t?e:function(e,t){return[...e.map((e=>t+o.DESCENDANT+e)),...e.map((e=>t+o.CHILD+e))]}(e,t)}(function(e,t,n){const o=function(e,t){const{blacklist:n,whitelist:o,combineWithinSelector:r,maxCombinations:i}=t,l=v(n),s=v(o);return function(e){const{selectors:t,includeTag:n}=e,o=[...t];return n&&!o.includes("tag")&&o.push("tag"),o}(t).reduce(((t,n)=>{const o=function(e,t){return(0,V[t])(e)}(e,n),c=function(e=[],t,n){return e.filter((e=>n(e)||!t(e)))}(o,l,s),a=function(e=[],t){return e.sort(((e,n)=>{const o=t(e),r=t(n);return o&&!r?-1:!o&&r?1:0}))}(c,s);return t[n]=r?M(a,{maxResults:i}):a.map((e=>[e])),t}),{})}(e,n),r=function(e,t){return function(e){const{selectors:t,combineBetweenSelectors:n,includeTag:o,maxCandidates:r}=e,i=n?M(t,{maxResults:r}):t.map((e=>[e]));return o?i.map(B):i}(t).map((t=>function(e,t){const n={};return e.forEach((e=>{const o=t[e];o&&o.length>0&&(n[e]=o)})),function(e={}){let t=[];return Object.entries(e).forEach((([e,n])=>{t=n.flatMap((n=>0===t.length?[{[e]:n}]:t.map((t=>Object.assign(Object.assign({},t),{[e]:n})))))})),t}(n).map(U)}(t,e))).filter((e=>e.length>0))}(o,n),i=m(r);return[...new Set(i)]}(e,0,r),n);for(const n of i)if(p(e,n,t))return n;return null}function G(e){return{value:e,include:!1}}function z({selectors:e,operator:t}){let n=[...L];e[r.tag]&&e[r.nthoftype]&&(n=n.filter((e=>e!==r.tag)));let o="";return n.forEach((t=>{var n;(null!==(n=e[t])&&void 0!==n?n:[]).forEach((({value:e,include:t})=>{t&&(o+=e)}))})),t+o}function J(e){return[":root",..._(e).reverse().map((e=>{const t=function(e,t,n=o.NONE){const r={};return t.forEach((t=>{Reflect.set(r,t,function(e,t){return W[t](e)}(e,t).map(G))})),{element:e,operator:n,selectors:r}}(e,[r.nthchild],o.CHILD);return t.selectors.nthchild.forEach((e=>{e.include=!0})),t})).map(z)].join("")}function X(e,t={}){var o;const i=function(e){(e instanceof NodeList||e instanceof HTMLCollection)&&(e=Array.from(e));const t=(Array.isArray(e)?e:[e]).filter(n);return[...new Set(t)]}(e),l=function(e,t={}){const n=Object.assign(Object.assign({},s),t);return{selectors:(o=n.selectors,Array.isArray(o)?o.filter((e=>{return t=r,n=e,Object.values(t).includes(n);var t,n})):[]),whitelist:d(n.whitelist),blacklist:d(n.blacklist),root:h(n.root,e),combineWithinSelector:!!n.combineWithinSelector,combineBetweenSelectors:!!n.combineBetweenSelectors,includeTag:!!n.includeTag,maxCombinations:f(n.maxCombinations),maxCandidates:f(n.maxCandidates)};var o}(i[0],t),c=null!==(o=l.root)&&void 0!==o?o:y(i[0]);let a="",u=c;function g(){return function(e,t,n="",o){if(0===e.length)return null;const r=[e.length>1?e:[],...b(e,t).map((e=>[e]))];for(const e of r){const r=Y(e,t,n,o);if(r)return{foundElements:e,selector:r}}return null}(i,u,a,l)}let m=g();for(;m;){const{foundElements:e,selector:t}=m;if(p(i,t,c))return t;u=e[0],a=t,m=g()}return i.length>1?i.map((e=>X(e,l))).join(N):function(e){return e.map(J).join(N)}(i)}const K=X;return t})()}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}n.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var o={};(()=>{"use strict";var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.EpubPage=void 0;const t=n(50),r=n(679);n(324);class i{constructor(){this._headingTagNames=["h1","h2","h3","h4","h5","h6"],this._activeLocationId="activeLocation",this._locationTag="span",this._documentRange=document.createRange(),document.documentElement.style.setProperty("-webkit-line-box-contain","block inline replaced")}isReaderReady(){return!!readium}setLocation(e,t){var n,o,r,i,l,s,c,a,d,u;this._debugLog(e);try{if(null==e)return void this._debugLog("No locator set");if(this._removeLocation(),this._setLocation(e,t),this._isComicBook()){const t=null!==(s=null!==(o=null===(n=null==e?void 0:e.locations)||void 0===n?void 0:n.cssSelector)&&void 0!==o?o:null===(l=null===(i=null===(r=null==e?void 0:e.locations)||void 0===r?void 0:r.domRange)||void 0===i?void 0:i.start)||void 0===l?void 0:l.cssSelector)&&void 0!==s?s:null===(d=null===(a=null===(c=null==e?void 0:e.locations)||void 0===c?void 0:c.domRange)||void 0===a?void 0:a.end)||void 0===d?void 0:d.cssSelector;if(null==t)return void this._errorLog("Css selector not set!");const h=this._getDurationFragment(null===(u=null==e?void 0:e.locations)||void 0===u?void 0:u.fragments);if(null==h)return void this._errorLog("Duration not set!");window.GotoComicFrame(t,1e3*h)}}catch(e){this._errorLog(e)}}scrollToLocations(e,t,n){try{const o=this._processLocations(e);if(null!=o)return this._scrollToProcessedRange(o,t,n),!0;const r=e.progression;if(null!=r)return null===readium||void 0===readium||readium.scrollToPosition(r),!0;this._debugLog("ScrollToLocations: Unknown range",e)}catch(e){this._errorLog(e)}return!1}isLocatorVisible(e){var t,n,o;this._debugLog(e);try{const r=e.locations,i=null!==(t=r.cssSelector)&&void 0!==t?t:null===(o=null===(n=r.domRange)||void 0===n?void 0:n.start)||void 0===o?void 0:o.cssSelector;if(this._isComicBook()){const e=null!=document.querySelector(i);return this._debugLog("Comic book",r,{found:e,selector:i}),e}const l=this._processLocations(r);return null==l?(this._debugLog("isLocatorVisible: Unknown range",r),!1):this._isProcessedRangeVisible(l)&&!!document.querySelector(`${i} #${this._activeLocationId}`)}catch(e){return this._errorLog(e),!0}}getLocatorFragments(e,t){var n,o,r,i;try{const l=null!==(o=null===(n=null==e?void 0:e.locations)||void 0===n?void 0:n.cssSelector)&&void 0!==o?o:this._findFirstVisibleCssSelector();if(null==l||!(null==l?void 0:l.length))return this._debugLog("getLocatorFragments: selector not found, returning locator from args"),e;const s=[...this._getPageFragments(t),...this._getTocFragments(l),...this._getPhysicalPageFragments(l)];return Object.assign(Object.assign({},e),{locations:Object.assign(Object.assign({cssSelector:l},e.locations),{fragments:[...null!==(i=null===(r=e.locations)||void 0===r?void 0:r.fragments)&&void 0!==i?i:[],...s]})})}catch(t){return this._errorLog(t),e}}_isComicBook(){try{return!!(null===comicBookPage||void 0===comicBookPage?void 0:comicBookPage.isComicBook())}catch(e){return!1}}_isTextNode(e){const t=e.nodeType;return 3===t||4===t}_clamp(e,t,n){return Math.min(Math.max(e,t),n)}_findAndSplitOffset(e,t){return function e(n){if(3===n.nodeType||4===n.nodeType){const e=n;if(t<=0)return e;const o=e.length;if(t=0;){if(""!==n[t].trim())return{node:e,charOffset:t};--t}t=void 0}e=this._previousNode(e)}}_findNonWhitespace(e,t){var n;return null!==(n=this._findNonWhitespaceBackward(e,t))&&void 0!==n?n:this._findNonWhitespaceForward(e,t)}_setLocation(e,t){var n,o,r,i,l,s,c,a,d,u,h;const f=readium.link;this._debugLog("create:",e,f);const g=e.locations,m=null!==(n=g.cssSelector)&&void 0!==n?n:null===(r=null===(o=null==g?void 0:g.domRange)||void 0===o?void 0:o.start)||void 0===r?void 0:r.cssSelector;if(!m)return void this._errorLog("Start css selector not found");const v=document.querySelector(m);if(!v)return void this._errorLog("Start parent not found");const p=null!==(l=null===(i=null==g?void 0:g.domRange)||void 0===i?void 0:i.end.cssSelector)&&void 0!==l?l:m,_=p===m?v:document.querySelector(p);if(!_)return void this._errorLog("End parent not found");const b=null===(c=null===(s=null==g?void 0:g.domRange)||void 0===s?void 0:s.start)||void 0===c?void 0:c.charOffset,y=null===(d=null===(a=null==g?void 0:g.domRange)||void 0===a?void 0:a.end)||void 0===d?void 0:d.charOffset;if(!b&&!y&&t)return void this._wrapWithLocationElement(v);const N=null!==(u=this._findAndSplitOffset(v,b))&&void 0!==u?u:this._nextNodeNotChild(v),S=null!==(h=this._findAndSplitOffset(_,y))&&void 0!==h?h:this._nextNodeNotChild(_),E=new Array;for(let e=N;e&&e!==S;e=this._nextNode(e))this._isTextNode(e)&&E.push(e);for(const e of E){const t=this._setLocationElement();t.appendChild(e.cloneNode(!0)),e.replaceWith(t)}}_removeLocation(){this._debugLog("Remove old location");const e=document.querySelectorAll(`#${this._activeLocationId}`);null==e||e.forEach((e=>{if(this._isAndroid())return void e.removeAttribute("id");const t=e.parentNode;e.replaceWith(...e.childNodes),t.normalize()}))}_safeVisibleRect(e){const{innerWidth:t,innerHeight:n}=window;return e?{left:0,top:.05*n,right:t,bottom:.95*n}:{left:0,top:0,right:t,bottom:n}}*_descendentTextNodes(e){if(this._isTextNode(e))yield e;else for(const t of e.childNodes)yield*this._descendentTextNodes(t)}_findTextPosition(e,t){if(null!=e)if(t<0||isNaN(t))this._errorLog(`findTextPosition: invalid charOffset, node=${e.nodeValue}, charOffset=${t}`);else{if(0===t)return{node:e,charOffset:t};for(const n of this._descendentTextNodes(e)){const e=n.length;if(t<=e)return{node:n,charOffset:t};t-=e}this._errorLog(`findTextPosition: failed, node=${this._debugNode(e)}, charOffset=${t}`)}else this._errorLog(`findTextPosition: no node, charOffset=${t}`)}_processDomRange(e){const{start:t,end:n}=e,{cssSelector:o,charOffset:r}=t,{cssSelector:i,charOffset:l}=null!=n&&void 0!==n?n:t,s=document.querySelector(o),c=this._findTextPosition(s,null!=r?r:0);if(null==c)return void this._errorLog(`DomRange bad start, selector=${o}`);const a=i===o?s:document.querySelector(i),d=this._findTextPosition(a,null!=l&&void 0!==l?l:0);if(null!=d){try{this._documentRange.setStart(c.node,c.charOffset)}catch(e){this._errorLog(`${this._debugNode(c.node)}, ${c.charOffset}`,e),this._documentRange.setStartAfter(c.node)}try{this._documentRange.setEnd(d.node,d.charOffset)}catch(e){this._errorLog(`${this._debugNode(d.node)}, ${d.charOffset}`,e),this._documentRange.setEndAfter(d.node)}if(0===this._documentRange.getClientRects().length){const e=this._findNonWhitespace(c.node,c.charOffset);if(null==e)return void this._errorLog("Couldn't find any non-whitespace characters in the document!'");const{node:t,charOffset:n}=e;this._documentRange.setStart(t,n),this._documentRange.setEnd(t,n+1)}return this._documentRange}this._errorLog(`DomRange bad end, selector=${i}`)}_processCssSelector(e){const t=document.querySelector(e);if(null!=t)return"none"===window.getComputedStyle(t).display&&(t.style.display=this._isPageBreakElement(t)?"flex":"block"),this._documentRange.selectNode(t),this._documentRange;this._errorLog(`processCssSelector: error: node not found ${e}`)}_processLocations(e){var t,n,o;if(null==e)return void this._errorLog("location not set");if(e.domRange)return this._processDomRange(e.domRange);const r=null!==(t=e.cssSelector)&&void 0!==t?t:null===(o=null===(n=e.domRange)||void 0===n?void 0:n.start)||void 0===o?void 0:o.cssSelector;return r?this._processCssSelector(r):void 0}_scrollToProcessedRange(e,t,n){!n&&this._isProcessedRangeVisible(e)||this._scrollToBoundingClientRect(e,t)}_scrollToBoundingClientRect(e,t){const{top:n,right:o,bottom:r,left:i}=e.getBoundingClientRect();if(0===n&&0===o&&0===r&&0===i)return void this._debugLog("scrollToBoundingClientRect: Scrolling to defective bounding rect, abort! ",e.getClientRects(),e.getClientRects().length);const{scrollLeft:l,scrollWidth:s,scrollTop:c,scrollHeight:a}=document.scrollingElement;if(t){const{top:e,bottom:o}=this._safeVisibleRect(t);if(no){const t=this._clamp((c+n-e)/a,0,1);null===readium||void 0===readium||readium.scrollToPosition(t)}}else{const e=(l+.5*(i+o))/s;null===readium||void 0===readium||readium.scrollToPosition(e)}}_isProcessedRangeVisible(e){const{innerWidth:t,innerHeight:n}=window,{top:o,right:r,bottom:i,left:l}=e.getBoundingClientRect();return oe.includes("duration=")));if(!t)return void this._errorLog("Duration fragment not found.");const n=/duration=(\d+(?:\.\d+)?)/.exec(t);return n?(this._debugLog("Duration fragment:",n[1]),parseFloat(n[1])):void this._errorLog("Invalid duration format.")}catch(e){return void this._errorLog("Could not retrieve duration fragment!")}}_getTocFragments(e){var t;try{const n=null===(t=this._findPrecedingAncestorSiblingHeadings(e)[0])||void 0===t?void 0:t.id;return null==n?[]:[`toc=${n}`]}catch(e){return this._errorLog(e),[]}}_getPhysicalPageFragments(e){try{const t=this._findCurrentPhysicalPage(e);return null==t?[]:[`physicalPage=${t}`]}catch(t){return this._errorLog(`Selector:${e} -- ${t}`),[]}}_findPrecedingAncestorSiblingHeadings(e){var t,n;const o=document.querySelector(e),r=null!==(t=null==o?void 0:o.querySelectorAll(this._headingTagNames.join(","))[0])&&void 0!==t?t:o;if(null==r)return;if(!this._allHeadings){const e=Array.from(window.document.querySelectorAll(this._headingTagNames.join(",")));for(const t of e)if(t){const e=t,n=e.textContent||e.getAttribute("title")||e.getAttribute("aria-label");let o=e.getAttribute("id");if(!o){let t,n=e;for(;(t=n.parentNode)&&(null==t?void 0:t.nodeType)===Node.ELEMENT_NODE&&t.firstElementChild===n;){const e=t.getAttribute("id");if(e){o=e;break}n=t}}const r={element:e,id:o||null,level:parseInt(e.localName.substring(1),10),text:n};this._allHeadings||(this._allHeadings=[]),this._allHeadings.push(r)}this._allHeadings||(this._allHeadings=[])}let i;for(let e=this._allHeadings.length-1;e>=0;e--){const t=this._allHeadings[e],n=r.compareDocumentPosition(t.element);(0===n||n&Node.DOCUMENT_POSITION_PRECEDING||n&Node.DOCUMENT_POSITION_CONTAINS)&&(i||(i=[]),(null==t?void 0:t.id)&&i.push({id:t.id,level:t.level,text:t.text}))}if(null==i?void 0:i.length)return i;const l=null!==(n=o.closest("section"))&&void 0!==n?n:o.closest("body");return l?[{id:l.id,level:0,text:l.innerText}]:void 0}_isPageBreakElement(e){return null!=e&&"pagebreak"===e.getAttribute("type")}_getPhysicalPageIndexFromElement(e){var t;return null!==(t=null==e?void 0:e.getAttribute("title"))&&void 0!==t?t:null==e?void 0:e.innerText.trim()}_findPhysicalPageIndex(e){if(!(null!=e&&e instanceof Element))return null;if(this._isPageBreakElement(e))return this._getPhysicalPageIndexFromElement(e);const t=null==e?void 0:e.querySelector(".page-normal, .page-front, .page-special");return null==t?null:this._getPhysicalPageIndexFromElement(t)}_getAllSiblings(e){var t;const n=[];e=null===(t=null==e?void 0:e.parentNode)||void 0===t?void 0:t.firstChild;do{3!==(null==e?void 0:e.nodeType)&&n.push(e)}while(e=null==e?void 0:e.nextSibling);return n}_findCurrentPhysicalPage(e){var t;let n=document.querySelector(e);if(null!=n){if(this._isPageBreakElement(n))return this._getPhysicalPageIndexFromElement(n);for(;n.nodeType===Node.ELEMENT_NODE;){const e=this._getAllSiblings(n);if(null==e)return;for(let t=e.findIndex((e=>null==e?void 0:e.isEqualNode(n)));t>=0;t--){const n=e[t],o=this._findPhysicalPageIndex(n);if(null!=o)return o}if(n=n.parentNode,null==n||"body"===n.nodeName.toLowerCase())return null===(t=document.querySelector("head [name='webpub:currentPage']"))||void 0===t?void 0:t.getAttribute("content")}}}_findFirstVisibleCssSelector(){return this._getCssSelector(this._getFirstVisibleElement())}_getCssSelector(e){var n,o;try{const r=(0,t.getCssSelector)(e,{root:document.querySelector("body")}),i=null!==(o=null===(n=null==r?void 0:r.replace(":root > :nth-child(2)","body"))||void 0===n?void 0:n.trim())&&void 0!==o?o:"body";return this._debugLog(i),i}catch(e){return this._errorLog(e),"body"}}_getFirstVisibleElement(){const e=this._findFirstVisibleElement(document.body);return this._debugLog("First visible element:",{tagName:e.nodeName.toLocaleLowerCase(),id:e.id,className:e.className}),e}_findFirstVisibleElement(e){const t={tagName:e.nodeName.toLocaleLowerCase(),id:e.id,className:e.className};for(const n of e.children){const o={tagName:n.nodeName.toLocaleLowerCase(),id:n.id,className:n.className};if(this._isElementVisible(n))if(this._shouldIgnoreElement(n))this._debugLog("Element is ignored - continue",o);else{if(n.id.includes(`${this._activeLocationId}`))return this._debugLog("Child is an active location element, return closest element with id",{childData:o,nodeData:t}),e.id?e:this._findClosestElementWithId(n);if(n.hasChildNodes())return this._debugLog("Loop into children",o),this._findFirstVisibleElement(n);if(!n.id)return this._debugLog("Element has no ID attribute - return closest element with id",o),e.id?e:this._findClosestElementWithId(n)}}return this._debugLog("return:",t),e}_findClosestElementWithId(e){let t=e.parentElement;for(;null!==t;){if(t.id)return t;t=t.parentElement}return this._debugLog("No element with id attr found!"),e}_isElementVisible(e,t=!1){if(null===readium||void 0===readium?void 0:readium.isFixedLayout)return!0;if(e===document.body||e===document.documentElement)return!0;if(!document||!document.documentElement||!document.body)return!1;const n=e.getBoundingClientRect();return t?this._isScrollModeEnabled()?n.top>=0&&n.top<=document.documentElement.clientHeight:n.left>=1:this._isScrollModeEnabled()?n.bottom>0&&n.top0&&n.lefte instanceof String?`${e}`:`${JSON.stringify(e)}`)).join(", ")):console.log(JSON.stringify(e))}_debugNode(e){var t,n;if(e instanceof Node){return(new XMLSerializer).serializeToString(e)}if("innerHTML"in e||"textContent"in e){const o=e;return null!==(n=null!==(t=o.innerHTML)&&void 0!==t?t:o.textContent)&&void 0!==n?n:"?"}}_errorLog(...e){var t;this._log("v===v===v===v===v===v"),this._log("Error:",e),this._log("Stack:",null!==(t=null==e?void 0:e.stack)&&void 0!==t?t:(new Error).stack.replace("\n","->").replace("_errorLog","")),this._log("^===^===^===^===^===^")}_isIos(){try{return isIos}catch(e){return!1}}_isAndroid(){try{return isAndroid}catch(e){return!1}}}function l(){window.epubPage||((0,r.initResponsiveTables)(),document.removeEventListener("DOMContentLoaded",l),window.epubPage=new i)}e.EpubPage=i,"loading"!==document.readyState?window.setTimeout(l):document.addEventListener("DOMContentLoaded",l)})(),epub=o})(); \ No newline at end of file +var epub;(()=>{"use strict";var e={324(e,t,n){n.r(t)},679(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.initResponsiveTables=function(){let e=Array.from(document.querySelectorAll("table"));if(e=e.filter((e=>!e.classList.contains("docx-table")&&!e.classList.contains("transparent-table")&&!e.classList.contains("plain-table"))),!e||0===e.length)return;e.forEach((e=>{if(function(e){let t=0,n=0;if(e.querySelectorAll("tr").forEach((e=>{e.querySelectorAll("td").forEach((e=>{""===e.textContent.trim()?t++:n++}))})),t>n)return e.classList.add("plain-table"),!0;return!1}(e))return;const t=e.querySelectorAll("thead th").length,n=e.querySelectorAll("tbody tr");if(1===t&&Array.from(n).every((e=>2===e.querySelectorAll("td").length))){const t=n[0].querySelectorAll("td");if(Array.from(t).every((e=>e.querySelector("strong")||Array.from({length:6},((t,n)=>e.querySelector(`h${n+1}`))).some((e=>null!==e)))))return void e.classList.add("plain-table");!function(e){const t=e.querySelector("thead th").textContent,n=Array.from(e.querySelectorAll("tbody td"));e.innerHTML="";const r=document.createElement("tbody"),l=document.createElement("tr"),o=document.createElement("tr");l.innerHTML=" ",o.innerHTML=`${t}`,n.forEach(((e,t)=>{if(t%2==0){const t=document.createElement("td");t.innerHTML=`

${e.textContent}

`,l.appendChild(t)}else{const t=document.createElement("td");Array.from(e.childNodes).forEach((e=>t.appendChild(e.cloneNode(!0)))),o.appendChild(t)}})),r.appendChild(l),r.appendChild(o),e.appendChild(r)}(e)}!function(e){let t=null,n=[];if(e.tHead)t=e.tHead.rows[0],n=Array.from(t.cells);else{const r=e.rows[0];r&&r.querySelector("th, h1, h2, h3, h4, h5, h6, strong")&&(t=r,n=Array.from(r.cells),e.classList.add("has-first-row-headers"))}if(n.length){e.classList.add("has-header");const r=n.map((e=>{var t;let n=(null===(t=e.textContent)||void 0===t?void 0:t.trim())||"";return n.length&&(n+=":"),n}));Array.from(e.rows).filter((e=>e!==t)).forEach((e=>{if(Array.from(e.cells).forEach(((e,t)=>{e.setAttribute("data-th",r[t]||"")})),!e.cells[0].getAttribute("data-th")){e.cells[0].classList.add("mobile-header");const t=document.createElement("h6");t.textContent=e.cells[0].textContent,e.cells[0].textContent="",e.cells[0].appendChild(t)}}))}}(e)}))}}},t={};function n(r){var l=t[r];if(void 0!==l)return l.exports;var o=t[r]={exports:{}};return e[r](o,o.exports,n),o.exports}n.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var r={};(()=>{var e=r;Object.defineProperty(e,"__esModule",{value:!0}),e.EpubPage=void 0;const t=n(679);n(324);class l{get _isScrollModeEnabled(){return"readium-scroll-on"===getComputedStyle(document.documentElement).getPropertyValue("--USER__view")}getPageInformation(){const e=this._findCurrentPhysicalPage();if(null===readium||void 0===readium?void 0:readium.isReflowable){if(this._isScrollModeEnabled)return{pageIndex:null,totalPages:null,physicalPageIndex:e};const{scrollLeft:t,scrollWidth:n}=document.scrollingElement,{innerWidth:r}=window;return{pageIndex:this._isScrollModeEnabled?null:Math.round(t/r)+1,totalPages:this._isScrollModeEnabled?null:Math.round(n/r),physicalPageIndex:e}}return{pageIndex:1,totalPages:1,physicalPageIndex:e}}_isPageBreakElement(e){return null!=e&&"pagebreak"===e.getAttribute("type")}_getPhysicalPageIndexFromElement(e){var t;return null!==(t=null==e?void 0:e.getAttribute("title"))&&void 0!==t?t:null==e?void 0:e.innerText.trim()}_findPhysicalPageIndex(e){if(!(null!=e&&e instanceof Element))return null;if(this._isPageBreakElement(e))return this._getPhysicalPageIndexFromElement(e);const t=null==e?void 0:e.querySelector(".page-normal, .page-front, .page-special");return null==t?null:this._getPhysicalPageIndexFromElement(t)}_getAllSiblings(e){var t;const n=[];e=null===(t=null==e?void 0:e.parentNode)||void 0===t?void 0:t.firstChild;do{3!==(null==e?void 0:e.nodeType)&&n.push(e)}while(e=null==e?void 0:e.nextSibling);return n}_findCurrentPhysicalPage(){var e,t,n;const r=null===(t=null===(e=readium.findFirstVisibleLocator())||void 0===e?void 0:e.locations)||void 0===t?void 0:t.cssSelector;let l=document.querySelector(r);if(null!=l){if(this._isPageBreakElement(l))return this._getPhysicalPageIndexFromElement(l);for(;l&&l.nodeType===Node.ELEMENT_NODE;){const e=this._getAllSiblings(l);if(null==e)return;for(let t=e.findIndex((e=>null==e?void 0:e.isEqualNode(l)));t>=0;t--){const n=e[t],r=this._findPhysicalPageIndex(n);if(null!=r)return r}if(l=l.parentNode,null==l||"body"===l.nodeName.toLowerCase())return null===(n=document.querySelector("head [name='webpub:currentPage']"))||void 0===n?void 0:n.getAttribute("content")}}}_log(...e){this._isIos()?null===webkit||void 0===webkit||webkit.messageHandlers.log.postMessage([].slice.call(e).map((e=>e instanceof String?`${e}`:`${JSON.stringify(e)}`)).join(", ")):console.log(JSON.stringify(e))}_errorLog(...e){var t;this._log("v===v===v===v===v===v"),this._log("Error:",e),this._log("Stack:",null!==(t=null==e?void 0:e.stack)&&void 0!==t?t:(new Error).stack.replace("\n","->").replace("_errorLog","")),this._log("^===^===^===^===^===^")}_isIos(){try{return isIos}catch(e){return!1}}_isAndroid(){try{return isAndroid}catch(e){return!1}}}function o(){window.epubPage||((0,t.initResponsiveTables)(),document.removeEventListener("DOMContentLoaded",o),window.epubPage=new l)}e.EpubPage=l,"loading"!==document.readyState?window.setTimeout(o):document.addEventListener("DOMContentLoaded",o)})(),epub=r})(); \ No newline at end of file diff --git a/flutter_readium/example/ios/Podfile.lock b/flutter_readium/example/ios/Podfile.lock index 2409b080..e1bb3271 100644 --- a/flutter_readium/example/ios/Podfile.lock +++ b/flutter_readium/example/ios/Podfile.lock @@ -20,9 +20,6 @@ PODS: - Minizip (1.0.0) - package_info_plus (0.4.5): - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - pointer_interceptor_ios (0.0.1): - Flutter - PromiseKit (8.2.0): @@ -73,7 +70,6 @@ DEPENDENCIES: - flutter_readium (from `.symlinks/plugins/flutter_readium/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`) - PromiseKit (~> 8.1) - ReadiumAdapterGCDWebServer (from `https://raw.githubusercontent.com/readium/swift-toolkit/3.7.0/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec`) @@ -104,8 +100,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/integration_test/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" pointer_interceptor_ios: :path: ".symlinks/plugins/pointer_interceptor_ios/ios" ReadiumAdapterGCDWebServer: @@ -133,7 +127,6 @@ SPEC CHECKSUMS: integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e Minizip: 188cb3e39a1195c283ae03bf673d182596fefd0b package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0 PromiseKit: 74e6ab5894856b4762fef547055c809bca91d440 ReadiumAdapterGCDWebServer: 6b7864065eabf708b8d04e6b98ad1a63fbfccf0d diff --git a/flutter_readium/example/lib/main.dart b/flutter_readium/example/lib/main.dart index ca5a90d9..aa0dbd93 100644 --- a/flutter_readium/example/lib/main.dart +++ b/flutter_readium/example/lib/main.dart @@ -16,17 +16,15 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); HydratedBloc.storage = await HydratedStorage.build( - storageDirectory: - kIsWeb ? HydratedStorageDirectory.web : HydratedStorageDirectory((await getTemporaryDirectory()).path), + storageDirectory: kIsWeb + ? HydratedStorageDirectory.web + : HydratedStorageDirectory((await getTemporaryDirectory()).path), ); runApp( MultiBlocProvider( providers: [ - BlocProvider( - create: (final _) => PublicationBloc(), - lazy: false, - ), + BlocProvider(create: (final _) => PublicationBloc(), lazy: false), BlocProvider( create: (final _) { final bloc = TextSettingsBloc(); @@ -34,10 +32,7 @@ Future main() async { return bloc; }, ), - // BlocProvider( - // create: (final _) => TtsSettingsBloc(), - // lazy: false, - // ), + BlocProvider(create: (final _) => TtsSettingsBloc(), lazy: false), BlocProvider(create: (final _) => PlayerControlsBloc()), ], child: MyApp(), diff --git a/flutter_readium/example/lib/pages/player.page.dart b/flutter_readium/example/lib/pages/player.page.dart index c56529a5..c3f0d556 100644 --- a/flutter_readium/example/lib/pages/player.page.dart +++ b/flutter_readium/example/lib/pages/player.page.dart @@ -58,24 +58,21 @@ class _PlayerPageState extends State with RestorationMixin { ); List _buildActionButtons() => [ - // IconButton( - // icon: const Icon(Icons.headphones), - // onPressed: () { - // context.read().add(GetTtsVoicesEvent()); + IconButton( + icon: const Icon(Icons.headphones), + onPressed: () { + context.read().add(GetTtsVoicesEvent()); - // final pubLang = - // context.read().state.publication?.metadata.language ?? ['en']; + final pubLang = context.read().state.publication?.metadata.languages ?? ['en']; - // showModalBottomSheet( - // context: context, - // isScrollControlled: true, - // builder: (final context) => TtsSettingsWidget( - // pubLang: pubLang, - // ), - // ); - // }, - // tooltip: 'Open tts settings', - // ), + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (final context) => TtsSettingsWidget(pubLang: pubLang), + ); + }, + tooltip: 'Open tts settings', + ), IconButton( icon: const Icon(Icons.format_paint), onPressed: () { diff --git a/flutter_readium/example/lib/state/player_controls_bloc.dart b/flutter_readium/example/lib/state/player_controls_bloc.dart index 623d2126..92e82b97 100644 --- a/flutter_readium/example/lib/state/player_controls_bloc.dart +++ b/flutter_readium/example/lib/state/player_controls_bloc.dart @@ -8,81 +8,96 @@ import 'package:collection/collection.dart'; import 'package:flutter_readium/flutter_readium.dart'; -abstract class PlayerControlsEvent {} +@immutable +abstract class PlayerControlsEvent { + const PlayerControlsEvent(); +} +@immutable class PlayTTS extends PlayerControlsEvent { - PlayTTS({this.fromLocator}); + const PlayTTS({this.fromLocator, this.ttsPreferences}); - Locator? fromLocator; + final Locator? fromLocator; + final TTSPreferences? ttsPreferences; } +@immutable class Play extends PlayerControlsEvent { - Play({this.fromLocator}); + const Play({this.fromLocator, this.audioPreferences}); - Locator? fromLocator; + final Locator? fromLocator; + final AudioPreferences? audioPreferences; } +@immutable class Pause extends PlayerControlsEvent {} +@immutable class Stop extends PlayerControlsEvent {} +@immutable class TogglePlayingState extends PlayerControlsEvent { - TogglePlayingState({required this.isPlaying}); - bool isPlaying; + const TogglePlayingState({required this.isPlaying}); + final bool isPlaying; } -class SkipToNext extends PlayerControlsEvent {} +@immutable +class SkipToNext extends PlayerControlsEvent { + const SkipToNext(); +} -class SkipToPrevious extends PlayerControlsEvent {} +@immutable +class SkipToPrevious extends PlayerControlsEvent { + const SkipToPrevious(); +} -class SkipToNextChapter extends PlayerControlsEvent {} +@immutable +class SkipToNextChapter extends PlayerControlsEvent { + const SkipToNextChapter(); +} +@immutable class SkipToPreviousChapter extends PlayerControlsEvent {} +@immutable class SkipToNextPage extends PlayerControlsEvent {} +@immutable class SkipToPreviousPage extends PlayerControlsEvent {} +@immutable class GoToLocator extends PlayerControlsEvent { - GoToLocator(this.locator); + const GoToLocator(this.locator); final Locator locator; } -class GetAvailableVoices extends PlayerControlsEvent {} - +@immutable class PlayerControlsState { - PlayerControlsState({required this.playing, required this.ttsEnabled, required this.audioEnabled}); + const PlayerControlsState({required this.playing, required this.ttsEnabled, required this.audioEnabled}); final bool playing; final bool ttsEnabled; final bool audioEnabled; Future togglePlay(final bool playing) async { - final newState = PlayerControlsState(playing: playing, ttsEnabled: ttsEnabled, audioEnabled: audioEnabled); - - return newState; + return copyWith(playing: playing); } Future toggleTTSEnabled(final bool ttsEnabled) async { - final newState = PlayerControlsState( - playing: ttsEnabled && playing, - ttsEnabled: ttsEnabled, - audioEnabled: audioEnabled, - ); - - return newState; + return copyWith(ttsEnabled: ttsEnabled, playing: ttsEnabled && playing); } Future toggleAudioEnabled(final bool audioEnabled) async { - final newState = PlayerControlsState( - playing: audioEnabled && playing, - ttsEnabled: ttsEnabled, - audioEnabled: audioEnabled, - ); - - return newState; + return copyWith(audioEnabled: audioEnabled, playing: audioEnabled && playing); } + + PlayerControlsState copyWith({final bool? playing, final bool? ttsEnabled, final bool? audioEnabled}) => + PlayerControlsState( + playing: playing ?? this.playing, + ttsEnabled: ttsEnabled ?? this.ttsEnabled, + audioEnabled: audioEnabled ?? this.audioEnabled, + ); } class PlayerControlsBloc extends Bloc { @@ -121,7 +136,7 @@ class PlayerControlsBloc extends Bloc on((final event, final emit) async { if (!state.ttsEnabled) { - await instance.ttsEnable(TTSPreferences(speed: 1.2)); + await instance.ttsEnable(event.ttsPreferences ?? TTSPreferences(speed: 1.2)); await instance.play(event.fromLocator); emit(await state.toggleTTSEnabled(true)); } else { @@ -132,7 +147,7 @@ class PlayerControlsBloc extends Bloc on((final event, final emit) async { if (!state.audioEnabled) { await instance.audioEnable( - prefs: AudioPreferences(speed: 1.5, seekInterval: 10), + prefs: event.audioPreferences ?? AudioPreferences(speed: 1.5, seekInterval: 10), fromLocator: event.fromLocator, ); emit(await state.toggleAudioEnabled(true)); @@ -170,31 +185,6 @@ class PlayerControlsBloc extends Bloc on((event, emit) => instance.goToLocator(event.locator)); - on((final event, final emit) async { - final voices = await instance.ttsGetAvailableVoices(); - - // Sort by identifer - voices.sortBy((v) => v.identifier); - - for (final i in voices.groupListsBy((v) => v.language).entries) { - debugPrint('Language: ${i.key}'); - debugPrint(' Available voices:'); - for (final v in i.value) { - debugPrint( - ' - ${v.identifier},name=${v.name},quality=${v.quality?.name},gender=${v.gender.name},active=${v.active},networkRequired=${v.networkRequired}', - ); - } - } - - final dkVoices = voices.where((v) => v.language == "da-DK").toList(); - - // TODO: Demo: change to first voice matching "da-DK" language. - final daVoice = dkVoices.lastOrNull; - if (daVoice != null) { - await instance.ttsSetVoice(daVoice.identifier, daVoice.language); - } - }); - @override // ignore: unused_element Future close() async { diff --git a/flutter_readium/example/lib/state/tts_settings_bloc.dart b/flutter_readium/example/lib/state/tts_settings_bloc.dart index 99d49ed3..fd7a17b9 100644 --- a/flutter_readium/example/lib/state/tts_settings_bloc.dart +++ b/flutter_readium/example/lib/state/tts_settings_bloc.dart @@ -1,123 +1,131 @@ -// import 'package:flutter_bloc/flutter_bloc.dart'; -// import 'package:flutter_readium/flutter_readium.dart'; - -// abstract class TtsSettingsEvent {} - -// class GetTtsVoicesEvent extends TtsSettingsEvent { -// GetTtsVoicesEvent({this.fallbackLang}); -// final List? fallbackLang; -// } - -// class SetTtsVoiceEvent extends TtsSettingsEvent { -// SetTtsVoiceEvent(this.selectedVoice); -// final ReadiumTtsVoice selectedVoice; -// } - -// class SetTtsHighlightModeEvent extends TtsSettingsEvent { -// SetTtsHighlightModeEvent(this.highlightMode); -// final ReadiumHighlightMode highlightMode; -// } - -// class ToggleTtsHighlightModeEvent extends TtsSettingsEvent {} - -// class SetTtsSpeakPhysicalPageIndexEvent extends TtsSettingsEvent { -// SetTtsSpeakPhysicalPageIndexEvent(this.speak); -// final bool speak; -// } - -// class TtsSettingsState { -// TtsSettingsState({ -// this.voices, -// this.loaded, -// this.preferredVoices, -// this.highlightMode, -// this.ttsSpeakPhysicalPageIndex, -// }); -// final List? voices; -// final bool? loaded; -// final List? preferredVoices; -// final ReadiumHighlightMode? highlightMode; -// final bool? ttsSpeakPhysicalPageIndex; - -// TtsSettingsState copyWith({ -// final List? voices, -// final bool? loaded, -// final List? preferredVoices, -// final ReadiumHighlightMode? highlightMode, -// final bool? ttsSpeakPhysicalPageIndex, -// }) => -// TtsSettingsState( -// voices: voices ?? this.voices, -// loaded: loaded ?? this.loaded, -// preferredVoices: preferredVoices ?? this.preferredVoices, -// highlightMode: highlightMode ?? this.highlightMode, -// ttsSpeakPhysicalPageIndex: ttsSpeakPhysicalPageIndex ?? this.ttsSpeakPhysicalPageIndex, -// ); - -// TtsSettingsState updateVoices(final List voices) => copyWith( -// voices: voices, -// loaded: true, -// ); - -// TtsSettingsState updatePreferredVoices(final ReadiumTtsVoice selectedVoice) { -// final preferredVoicesList = preferredVoices ?? []; -// final updatedVoices = preferredVoicesList -// .where((final voice) => voice.langCode != selectedVoice.langCode) -// .toList() -// ..add(selectedVoice); - -// FlutterReadium().updateCurrentTtsVoicesReadium(updatedVoices); - -// return copyWith(preferredVoices: updatedVoices); -// } - -// TtsSettingsState setHighlightMode(final ReadiumHighlightMode highlightMode) { -// FlutterReadium().setHighlightMode(highlightMode); -// return copyWith(highlightMode: highlightMode); -// } - -// TtsSettingsState setTtsSpeakPhysicalPageIndex(final bool speak) { -// FlutterReadium().setTtsSpeakPhysicalPageIndex(speak: speak); -// return copyWith(ttsSpeakPhysicalPageIndex: speak); -// } -// } - -// class TtsSettingsBloc extends Bloc { -// TtsSettingsBloc() -// : super( -// TtsSettingsState( -// voices: [], -// loaded: false, -// preferredVoices: [], -// highlightMode: ReadiumHighlightMode.paragraph, // to reflect default in ReadiumState -// ttsSpeakPhysicalPageIndex: false, -// ), -// ) { -// on((final event, final emit) async { -// final voices = await instance.getTtsVoices(fallbackLang: event.fallbackLang); -// emit(state.updateVoices(voices)); -// }); - -// on((final event, final emit) async { -// await instance.setTtsVoice(event.selectedVoice); -// emit(state.updatePreferredVoices(event.selectedVoice)); -// }); - -// on((final event, final emit) async { -// emit(state.setHighlightMode(event.highlightMode)); -// }); - -// on((final event, final emit) async { -// final newHighlightMode = state.highlightMode == ReadiumHighlightMode.word -// ? ReadiumHighlightMode.paragraph -// : ReadiumHighlightMode.word; -// emit(state.setHighlightMode(newHighlightMode)); -// }); - -// on((final event, final emit) async { -// emit(state.setTtsSpeakPhysicalPageIndex(event.speak)); -// }); -// } - -// final FlutterReadium instance = FlutterReadium(); -// } +import 'package:flutter/cupertino.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_readium/flutter_readium.dart'; + +@immutable +abstract class TtsSettingsEvent { + const TtsSettingsEvent(); +} + +@immutable +class GetTtsVoicesEvent extends TtsSettingsEvent { + const GetTtsVoicesEvent({this.fallbackLang}); + final List? fallbackLang; +} + +@immutable +class SetTtsVoiceEvent extends TtsSettingsEvent { + const SetTtsVoiceEvent(this.selectedVoice); + final ReaderTTSVoice selectedVoice; +} + +/* +@immutable +class SetTtsHighlightModeEvent extends TtsSettingsEvent { + const SetTtsHighlightModeEvent(this.highlightMode); + final ReadiumHighlightMode highlightMode; +} + +@immutable +class ToggleTtsHighlightModeEvent extends TtsSettingsEvent { + const ToggleTtsHighlightModeEvent(); +} + +class SetTtsSpeakPhysicalPageIndexEvent extends TtsSettingsEvent { + const SetTtsSpeakPhysicalPageIndexEvent(this.speak); + final bool speak; +} +*/ + +@immutable +class TtsSettingsState { + const TtsSettingsState({ + this.voices, + this.loaded, + this.preferredVoices, + this.highlightMode, + this.ttsSpeakPhysicalPageIndex, + }); + final List? voices; + final bool? loaded; + final List? preferredVoices; + final ReadiumHighlightMode? highlightMode; + final bool? ttsSpeakPhysicalPageIndex; + + TtsSettingsState copyWith({ + final List? voices, + final bool? loaded, + final List? preferredVoices, + final ReadiumHighlightMode? highlightMode, + final bool? ttsSpeakPhysicalPageIndex, + }) => TtsSettingsState( + voices: voices ?? this.voices, + loaded: loaded ?? this.loaded, + preferredVoices: preferredVoices ?? this.preferredVoices, + highlightMode: highlightMode ?? this.highlightMode, + ttsSpeakPhysicalPageIndex: ttsSpeakPhysicalPageIndex ?? this.ttsSpeakPhysicalPageIndex, + ); + + TtsSettingsState updateVoices(final List voices) => copyWith(voices: voices, loaded: true); + + TtsSettingsState updatePreferredVoices(final ReaderTTSVoice selectedVoice) { + final preferredVoicesList = preferredVoices ?? []; + final updatedVoices = preferredVoicesList.where((final voice) => voice.language != selectedVoice.language).toList() + ..add(selectedVoice); + + FlutterReadium().ttsSetVoice(selectedVoice.identifier, selectedVoice.language); + + return copyWith(preferredVoices: updatedVoices); + } + + /* + TtsSettingsState setHighlightMode(final ReadiumHighlightMode highlightMode) { + FlutterReadium().setHighlightMode(highlightMode); + return copyWith(highlightMode: highlightMode); + } + + TtsSettingsState setTtsSpeakPhysicalPageIndex(final bool speak) { + FlutterReadium().setTtsSpeakPhysicalPageIndex(speak: speak); + return copyWith(ttsSpeakPhysicalPageIndex: speak); + } */ +} + +class TtsSettingsBloc extends Bloc { + TtsSettingsBloc() + : super( + TtsSettingsState( + voices: [], + loaded: false, + preferredVoices: [], + highlightMode: ReadiumHighlightMode.paragraph, // to reflect default in ReadiumState + ttsSpeakPhysicalPageIndex: false, + ), + ) { + on((final event, final emit) async { + final voices = await instance.ttsGetAvailableVoices(); + emit(state.updateVoices(voices)); + }); + + on((final event, final emit) async { + await instance.ttsSetVoice(event.selectedVoice.identifier, event.selectedVoice.language); + emit(state.updatePreferredVoices(event.selectedVoice)); + }); + + /* on((final event, final emit) async { + emit(state.setHighlightMode(event.highlightMode)); + }); + + on((final event, final emit) async { + final newHighlightMode = state.highlightMode == ReadiumHighlightMode.word + ? ReadiumHighlightMode.paragraph + : ReadiumHighlightMode.word; + emit(state.setHighlightMode(newHighlightMode)); + }); + + on((final event, final emit) async { + emit(state.setTtsSpeakPhysicalPageIndex(event.speak)); + }); */ + } + + final FlutterReadium instance = FlutterReadium(); +} diff --git a/flutter_readium/example/lib/widgets/player_controls.widget.dart b/flutter_readium/example/lib/widgets/player_controls.widget.dart index a54e9861..ad4afedc 100644 --- a/flutter_readium/example/lib/widgets/player_controls.widget.dart +++ b/flutter_readium/example/lib/widgets/player_controls.widget.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_readium/flutter_readium.dart' show Locator; +import 'package:flutter_readium/flutter_readium.dart' show Locator, TTSPreferences; import 'package:flutter_readium_example/state/index.dart'; -import '../state/player_controls_bloc.dart'; - class PlayerControls extends StatelessWidget { const PlayerControls({super.key, required this.isAudioBook}); @@ -32,6 +30,12 @@ class PlayerControls extends StatelessWidget { onPressed: state.playing ? () => context.read().add(Pause()) : () { + TtsSettingsState ttsSettingsState = context.read().state; + + TTSPreferences ttsPreferences = TTSPreferences( + voiceIdentifier: ttsSettingsState.preferredVoices?.firstOrNull?.identifier, + ); + Locator? fakeInitialLocator; // DEMO: Start from the 3rd item in readingOrder. // final pub = context.read().state.publication; @@ -39,7 +43,9 @@ class PlayerControls extends StatelessWidget { // fakeInitialLocator = pub?.locatorFromLink(fakeInitialLink!); isAudioBook ? context.read().add(Play(fromLocator: fakeInitialLocator)) - : context.read().add(PlayTTS(fromLocator: fakeInitialLocator)); + : context.read().add( + PlayTTS(fromLocator: fakeInitialLocator, ttsPreferences: ttsPreferences), + ); }, tooltip: state.playing ? 'Pause' : 'Play', ), @@ -59,11 +65,6 @@ class PlayerControls extends StatelessWidget { onPressed: () => context.read().add(SkipToNextChapter()), tooltip: 'Skip to next chapter', ), - IconButton( - icon: const Icon(Icons.settings_voice), - onPressed: () => context.read().add(GetAvailableVoices()), - tooltip: 'Change voice', - ), ], ), ); diff --git a/flutter_readium/example/lib/widgets/tts_settings.widget.dart b/flutter_readium/example/lib/widgets/tts_settings.widget.dart index f23242e2..82acc42f 100644 --- a/flutter_readium/example/lib/widgets/tts_settings.widget.dart +++ b/flutter_readium/example/lib/widgets/tts_settings.widget.dart @@ -1,236 +1,207 @@ -// import 'dart:io'; - -// import 'package:flutter/material.dart'; -// import 'package:flutter_bloc/flutter_bloc.dart'; -// import 'package:flutter_readium/flutter_readium.dart'; - -// import '../state/tts_settings_bloc.dart'; -// import 'index.dart'; - -// class TtsSettingsWidget extends StatefulWidget { -// const TtsSettingsWidget({required this.pubLang, super.key}); - -// final List pubLang; - -// @override -// _TtsSettingsWidgetState createState() => _TtsSettingsWidgetState(); -// } - -// class _TtsSettingsWidgetState extends State { -// String? selectedLocale; -// ReadiumTtsVoice? selectedVoice; - -// @override -// void initState() { -// super.initState(); -// } - -// @override -// Widget build(final BuildContext context) => SafeArea( -// child: Wrap( -// children: [ -// Padding( -// padding: const EdgeInsets.all(20.0), -// child: Semantics( -// header: true, -// child: const Align( -// alignment: Alignment.center, -// child: Text( -// 'TTS settings', -// style: TextStyle(fontSize: 25), -// ), -// ), -// ), -// ), -// const Divider(), -// SingleChildScrollView( -// child: Column( -// children: [ -// ListItemWidget( -// label: 'Voice', -// child: _buildVoiceOptions(context), -// ), -// const Divider(), -// // TODO: Remember that it will only highlight paragraphs if google network voices are used. Implement this in the UI. -// ListItemWidget( -// label: 'Highlight', -// child: BlocBuilder( -// builder: (final context, final state) { -// final chosenVoices = _findVoicesByLangCode(state, widget.pubLang); -// final isGoogleNetworkVoice = -// chosenVoices.any((final voice) => voice.androidIsLocal == false); -// final highlightModes = isGoogleNetworkVoice -// ? [ReadiumHighlightMode.paragraph] -// : ReadiumHighlightMode.values; - -// return SingleChildScrollView( -// scrollDirection: Axis.horizontal, -// child: ToggleButtons( -// isSelected: highlightModes -// .map( -// (final mode) => mode == state.highlightMode, -// ) -// .toList(), -// selectedBorderColor: Colors.blue, -// borderWidth: 4.0, -// borderColor: Colors.transparent, -// onPressed: (final index) { -// context.read().add( -// SetTtsHighlightModeEvent( -// ReadiumHighlightMode.values[index], -// ), -// ); -// }, -// children: highlightModes -// .map( -// (final mode) => SizedBox( -// width: 120, -// child: Padding( -// padding: const EdgeInsets.symmetric(horizontal: 8.0), -// child: Center( -// child: Text( -// mode.toString().split('.').last[0].toUpperCase() + -// mode -// .toString() -// .split('.') -// .last -// .substring(1) -// .toLowerCase(), -// style: TextStyle(fontSize: 16), -// ), -// ), -// ), -// ), -// ) -// .toList(), -// ), -// ); -// }, -// ), -// ), -// const Divider(), -// ListItemWidget( -// label: 'Announce page numbers', -// isVerticalAlignment: true, -// child: BlocSelector( -// selector: (final state) => state.ttsSpeakPhysicalPageIndex ?? false, -// builder: (final context, final ttsSpeakPhysicalPageIndex) => Switch( -// value: ttsSpeakPhysicalPageIndex, -// onChanged: (final value) { -// context.read().add( -// SetTtsSpeakPhysicalPageIndexEvent(value), -// ); -// }, -// ), -// ), -// ), -// ], -// ), -// ), -// ], -// ), -// ); - -// Widget _buildVoiceOptions(final BuildContext context) { -// final ttsSettingsBloc = context.watch(); -// final state = ttsSettingsBloc.state; - -// final voices = state.voices; -// final voicesLocale = voices?.map((final voice) => voice.locale).toSet(); -// final preferredVoices = state.preferredVoices; - -// final voicesLoaded = state.loaded ?? false; - -// final preferredLocale = voicesLocale != null -// ? preferredVoices -// ?.firstWhereOrNull( -// (final preferredVoice) => voicesLocale.contains(preferredVoice.locale), -// ) -// ?.locale -// : null; - -// final showVoiceOptions = voicesLoaded && -// voices != null && -// voices.isNotEmpty && -// (selectedLocale != null || voicesLocale == null || voicesLocale.length == 1); - -// if (!voicesLoaded) return const CircularProgressIndicator(); -// if (voicesLoaded && (voices == null || voices.isEmpty)) { -// return const Text('No voices available'); -// } -// if (voicesLoaded && voices != null && voices.isNotEmpty) { -// return Row( -// children: [ -// if (voicesLocale != null && voicesLocale.length > 1) -// DropdownButton( -// value: selectedLocale ?? preferredLocale, -// onChanged: (final locale) { -// setState(() { -// selectedLocale = locale; -// selectedVoice = null; -// }); -// }, -// items: voicesLocale -// .map( -// (final locale) => DropdownMenuItem( -// value: locale, -// child: Text(locale), -// ), -// ) -// .toList(), -// ), -// if (showVoiceOptions) -// DropdownButton( -// value: selectedVoice ?? -// preferredVoices?.firstWhereOrNull( -// (final preferredVoice) => voices -// .where( -// (final voice) => voice.locale == selectedLocale || selectedLocale == null, -// ) -// .contains(preferredVoice), -// ), -// onChanged: (final voice) { -// ttsSettingsBloc.add(SetTtsVoiceEvent(voice!)); -// setState(() { -// selectedVoice = voice; -// }); -// }, -// items: voices -// .where((final voice) => voice.locale == selectedLocale || selectedLocale == null) -// .map( -// (final voice) => DropdownMenuItem( -// value: voice, -// child: Platform.isAndroid -// ? Text(_getAndroidTtsVoiceName(voice)) -// : Text(voice.name), -// ), -// ) -// .toList(), -// ), -// ], -// ); -// } -// return const Text('Something went wrong. Please try again.'); -// } -// } - -// _getAndroidTtsVoiceName(final ReadiumTtsVoice voice) { -// final name = voice.androidVoiceName ?? voice.name; -// final localOrNetwork = voice.androidIsLocal == true -// ? ' (Local)' -// : voice.androidIsLocal == false -// ? ' (Network)' -// : ''; -// return '$name$localOrNetwork'; -// } - -// List _findVoicesByLangCode( -// final TtsSettingsState state, -// final List pubLang, -// ) => -// state.preferredVoices -// ?.where( -// (final voice) => pubLang.contains(voice.langCode), -// ) -// .toList() ?? -// []; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_readium/flutter_readium.dart'; + +import '../state/tts_settings_bloc.dart'; +import 'index.dart'; + +class TtsSettingsWidget extends StatefulWidget { + const TtsSettingsWidget({required this.pubLang, super.key}); + + final List pubLang; + + @override + State createState() => _TtsSettingsWidgetState(); +} + +class _TtsSettingsWidgetState extends State { + String? selectedLanguage; + ReaderTTSVoice? selectedVoice; + + @override + void initState() { + super.initState(); + } + + @override + Widget build(final BuildContext context) => SafeArea( + child: Wrap( + children: [ + Padding( + padding: const EdgeInsets.all(20.0), + child: Semantics( + header: true, + child: const Align( + alignment: Alignment.center, + child: Text('TTS settings', style: TextStyle(fontSize: 25)), + ), + ), + ), + const Divider(), + SingleChildScrollView( + child: Column( + children: [ + ListItemWidget(label: 'Voice', child: _buildVoiceOptions(context)), + /* + const Divider(), + // TODO: Remember that it will only highlight paragraphs if google network voices are used. Implement this in the UI. + ListItemWidget( + label: 'Highlight', + child: BlocBuilder( + builder: (final context, final state) { + final chosenVoices = _findVoicesByLangCode(state, widget.pubLang); + final isGoogleNetworkVoice = + Platform.isAndroid && chosenVoices.any((final voice) => voice.networkRequired); + final highlightModes = isGoogleNetworkVoice + ? [ReadiumHighlightMode.paragraph] + : ReadiumHighlightMode.values; + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ToggleButtons( + isSelected: highlightModes.map((final mode) => mode == state.highlightMode).toList(), + selectedBorderColor: Colors.blue, + borderWidth: 4.0, + borderColor: Colors.transparent, + onPressed: (final index) { + context.read().add( + SetTtsHighlightModeEvent(ReadiumHighlightMode.values[index]), + ); + }, + children: highlightModes + .map( + (final mode) => SizedBox( + width: 120, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Center( + child: Text( + mode.toString().split('.').last[0].toUpperCase() + + mode.toString().split('.').last.substring(1).toLowerCase(), + style: TextStyle(fontSize: 16), + ), + ), + ), + ), + ) + .toList(), + ), + ); + }, + ), + ), + const Divider(), + ListItemWidget( + label: 'Announce page numbers', + isVerticalAlignment: true, + child: BlocSelector( + selector: (final state) => state.ttsSpeakPhysicalPageIndex ?? false, + builder: (final context, final ttsSpeakPhysicalPageIndex) => Switch( + value: ttsSpeakPhysicalPageIndex, + onChanged: (final value) { + context.read().add(SetTtsSpeakPhysicalPageIndexEvent(value)); + }, + ), + ), + ), + */ + const Divider(), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + style: ButtonStyle( + padding: WidgetStateProperty.all(const EdgeInsets.symmetric(vertical: 16.0)), + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0)), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8.0, + children: [ + Icon(Icons.close, size: 20), + // SizedBox(width: 10), + Text('Close', style: TextStyle(fontSize: 20)), + ], + ), + ), + ], + ), + ), + ], + ), + ); + + Widget _buildVoiceOptions(final BuildContext context) { + final ttsSettingsBloc = context.watch(); + final state = ttsSettingsBloc.state; + + final voices = state.voices; + final voiceLanguages = voices?.map((final voice) => voice.language).sortedBy((final lang) => lang).toSet(); + final preferredVoices = state.preferredVoices; + + final voicesLoaded = state.loaded ?? false; + + final preferredLanguage = voiceLanguages != null + ? preferredVoices + ?.firstWhereOrNull((final preferredVoice) => voiceLanguages.contains(preferredVoice.language)) + ?.language + : null; + + final showVoiceOptions = + voicesLoaded && + voices != null && + voices.isNotEmpty && + (selectedLanguage != null || preferredLanguage != null || voiceLanguages == null || voiceLanguages.length == 1); + + if (!voicesLoaded) return const CircularProgressIndicator(); + if (voicesLoaded && (voices == null || voices.isEmpty)) { + return const Text('No voices available'); + } + if (voicesLoaded && voices != null && voices.isNotEmpty) { + return Row( + children: [ + if (voiceLanguages != null && voiceLanguages.length > 1) + DropdownButton( + value: selectedLanguage ?? preferredLanguage, + onChanged: (final language) { + setState(() { + selectedLanguage = language; + selectedVoice = null; + }); + }, + items: voiceLanguages + .map((final language) => DropdownMenuItem(value: language, child: Text(language))) + .toList(), + ), + if (showVoiceOptions) + DropdownButton( + value: + selectedVoice ?? + preferredVoices?.firstWhereOrNull( + (final preferredVoice) => voices + .where((final voice) => voice.language == selectedLanguage || selectedLanguage == null) + .contains(preferredVoice), + ), + onChanged: (final voice) { + ttsSettingsBloc.add(SetTtsVoiceEvent(voice!)); + setState(() { + selectedVoice = voice; + }); + }, + items: voices + .where((final voice) => voice.language == selectedLanguage || selectedLanguage == null) + .map((final voice) => DropdownMenuItem(value: voice, child: Text(voice.name))) + .toList(), + ), + ], + ); + } + return const Text('Something went wrong. Please try again.'); + } +} + +List _findVoicesByLangCode(final TtsSettingsState state, final List pubLang) => + state.preferredVoices?.where((final voice) => pubLang.contains(voice.language)).toList() ?? []; diff --git a/flutter_readium/example/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter_readium/example/macos/Flutter/GeneratedPluginRegistrant.swift index 0ec16401..c085c19b 100644 --- a/flutter_readium/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter_readium/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,12 +7,10 @@ import Foundation import flutter_readium import package_info_plus -import path_provider_foundation import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterReadiumPlugin.register(with: registry.registrar(forPlugin: "FlutterReadiumPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/flutter_readium/example/pubspec.lock b/flutter_readium/example/pubspec.lock index ecdc13c4..b7936f31 100644 --- a/flutter_readium/example/pubspec.lock +++ b/flutter_readium/example/pubspec.lock @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: build_config - sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" characters: dependency: transitive description: @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: "direct main" description: @@ -125,10 +133,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "15a7db352c8fc6a4d2bc475ba901c25b39fe7157541da4c16eacce6f8be83e49" + sha256: "6f6b30cba0301e7b38f32bdc9a6bdae6f5921a55f0a1eb9450e1e6515645dbb2" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" dartx: dependency: transitive description: @@ -264,6 +272,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.19.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" http: dependency: transitive description: @@ -305,26 +321,18 @@ packages: dependency: transitive description: name: json_annotation - sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 url: "https://pub.dev" source: hosted - version: "4.10.0" - json_schema: - dependency: transitive - description: - name: json_schema - sha256: f37d9c3fdfe8c9aae55fdfd5af815d24ce63c3a0f6a2c1f0982c30f43643fa1a - url: "https://pub.dev" - source: hosted - version: "5.2.2" + version: "4.11.0" json_serializable: dependency: transitive description: name: json_serializable - sha256: "93fba3ad139dab2b1ce59ecc6fdce6da46a42cdb6c4399ecda30f1e7e725760d" + sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" url: "https://pub.dev" source: hosted - version: "6.12.0" + version: "6.13.0" leak_tracker: dependency: transitive description: @@ -389,6 +397,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" nested: dependency: transitive description: @@ -397,6 +413,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" package_config: dependency: transitive description: @@ -449,10 +473,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -565,22 +589,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" - quiver: - dependency: transitive - description: - name: quiver - sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - rfc_6901: - dependency: transitive - description: - name: rfc_6901 - sha256: "6a43b1858dca2febaf93e15639aa6b0c49ccdfd7647775f15a499f872b018154" - url: "https://pub.dev" - source: hosted - version: "0.2.1" rxdart: dependency: "direct main" description: @@ -690,14 +698,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - uri: - dependency: transitive - description: - name: uri - sha256: "889eea21e953187c6099802b7b4cf5219ba8f3518f604a1033064d45b1b8268a" - url: "https://pub.dev" - source: hosted - version: "1.0.0" vector_math: dependency: transitive description: @@ -787,5 +787,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" 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 9069dcaa..6537de8b 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift @@ -91,10 +91,10 @@ public class ReadiumReaderView: NSObject, FlutterPlatformView, EPUBNavigatorDele config.preloadPreviousPositionCount = 2 config.preloadNextPositionCount = 4 config.debugState = true - + // TODO: Use experimentalPositioning for now. It places highlights on z-index -1 behind text, instead of in-front. config.decorationTemplates = HTMLDecorationTemplate.defaultTemplates(alpha: 1.0, experimentalPositioning: true) - + // TODO: This is a PoC for adding custom editing actions, like user highlights. It should be configurable from Flutter. config.editingActions = [.lookup, .translate, EditingAction(title: "Custom Highlight Action", action: #selector(onCustomEditingAction))] @@ -327,12 +327,6 @@ public class ReadiumReaderView: NSObject, FlutterPlatformView, EPUBNavigatorDele return await readiumViewController.go(to: locator, options: NavigatorGoOptions(animated: animated)) } - private func setLocation(locator: Locator, isAudioBookWithText: Bool) async -> Result { - let json = locator.jsonString ?? "null" - - return await evaluateJavascript("window.epubPage.setLocation(\(json), \(isAudioBookWithText));") - } - private func emitOnPageChanged() { guard let locator = readiumViewController.currentLocation else { print(TAG, "emitOnPageChanged: currentLocation = nil!") @@ -353,7 +347,6 @@ public class ReadiumReaderView: NSObject, FlutterPlatformView, EPUBNavigatorDele Task.detached(priority: .high) { await self.goToLocator(locator: locator, animated: animated) - let _ = await self.setLocation(locator: locator, isAudioBookWithText: isAudioBookWithText) await MainActor.run() { result(true) } @@ -381,18 +374,6 @@ public class ReadiumReaderView: NSObject, FlutterPlatformView, EPUBNavigatorDele } } break - case "setLocation": - let args = call.arguments as! [Any] - print(TAG, "onMethodCall[setLocation] locator = \(args[0] as! String)") - let locator = try! Locator(jsonString: args[0] as! String, warnings: readiumBugLogger)! - let isAudioBookWithText = args[1] as? Bool ?? false - Task.detached(priority: .high) { - let _ = await self.setLocation(locator: locator, isAudioBookWithText: isAudioBookWithText) - return await MainActor.run() { - result(true) - } - } - break case "getLocatorFragments": let args = call.arguments as? String ?? "null" Task.detached(priority: .high) { @@ -409,32 +390,6 @@ public class ReadiumReaderView: NSObject, FlutterPlatformView, EPUBNavigatorDele } } break - case "getCurrentLocator": - let args = call.arguments as? String ?? "null" - print(TAG, "onMethodCall[currentLocator] args = \(args)") - Task.detached(priority: .high) { [isVerticalScroll] in - let json = await self.readiumViewController.currentLocation?.jsonString ?? nil - if (json == nil) { - await MainActor.run() { - return result(nil) - } - } - let data = await self.getLocatorFragments(json!, isVerticalScroll) - await MainActor.run() { - return result(data?.jsonString) - } - } - break - case "isLocatorVisible": - let args = call.arguments as! String - print(TAG, "onMethodCall[isLocatorVisible] locator = \(args)") - let locator = try! Locator(jsonString: args, warnings: readiumBugLogger)! - if locator.href != self.readiumViewController.currentLocation?.href { - result(false) - return - } - evaluateJSReturnResult("window.epubPage.isLocatorVisible(\(args));", result: result) - break case "setPreferences": let args = call.arguments as! [String: String] print(TAG, "onMethodCall[setPreferences] args = \(args)") diff --git a/flutter_readium/lib/reader_channel.dart b/flutter_readium/lib/reader_channel.dart index 0f65ff68..a090c2ec 100644 --- a/flutter_readium/lib/reader_channel.dart +++ b/flutter_readium/lib/reader_channel.dart @@ -4,18 +4,7 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:flutter_readium/flutter_readium.dart'; -enum _ReaderChannelMethodInvoke { - applyDecorations, - go, - goLeft, - goRight, - getCurrentLocator, - getLocatorFragments, - setLocation, - isLocatorVisible, - dispose, - setPreferences, -} +enum _ReaderChannelMethodInvoke { applyDecorations, go, goLeft, goRight, dispose, setPreferences } /// Internal use only. /// Used by ReadiumReaderWidget to talk to the native widget. @@ -50,24 +39,6 @@ class ReadiumReaderChannel extends MethodChannel { return _invokeMethod(_ReaderChannelMethodInvoke.goRight, animated); } - /// Get locator fragments for the given [locator]. - Future getLocatorFragments(final Locator locator) { - R2Log.d('locator: ${locator.toString()}'); - - return _invokeMethod( - _ReaderChannelMethodInvoke.getLocatorFragments, - json.encode(locator.toJson()), - ).then((final value) => Locator.fromJson(json.decode(value))).onError((final error, final _) { - R2Log.e(error ?? 'Unknown Error'); - - throw ReadiumException('getLocatorFragments failed $locator'); - }); - } - - /// Set the current location to the given [locator]. - Future setLocation(final Locator locator, final bool isAudioBookWithText) async => - _invokeMethod(_ReaderChannelMethodInvoke.setLocation, [json.encode(locator), isAudioBookWithText]); - /// Set EPUB preferences. Future setEPUBPreferences(EPUBPreferences preferences) async { await _invokeMethod(_ReaderChannelMethodInvoke.setPreferences, preferences.toJson()); @@ -78,18 +49,6 @@ class ReadiumReaderChannel extends MethodChannel { return await _invokeMethod(_ReaderChannelMethodInvoke.applyDecorations, [id, decorations.map((d) => d.toJson())]); } - /// Get the current locator. - Future getCurrentLocator() async => await _invokeMethod( - _ReaderChannelMethodInvoke.getCurrentLocator, - [], - ).then((locStr) => locStr != null ? Locator.fromJson(json.decode(locStr) as Map) : null); - - /// Check if a locator is currently visible on screen. - Future isLocatorVisible(final Locator locator) => _invokeMethod( - _ReaderChannelMethodInvoke.isLocatorVisible, - json.encode(locator), - ).then((final isVisible) => isVisible!).onError((final error, final _) => true); - Future dispose() async { try { await _invokeMethod(_ReaderChannelMethodInvoke.dispose); diff --git a/flutter_readium/lib/reader_widget.dart b/flutter_readium/lib/reader_widget.dart index 48415b5a..edb6d086 100644 --- a/flutter_readium/lib/reader_widget.dart +++ b/flutter_readium/lib/reader_widget.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart' as mq show Orientation; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_readium_platform_interface/flutter_readium_platform_interface.dart'; +import 'package:flutter_readium/flutter_readium.dart'; import 'package:rxdart/rxdart.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -53,6 +53,7 @@ class _ReadiumReaderWidgetState extends State implements Re final _isReadyCompleter = Completer(); final _readium = FlutterReadiumPlatform.instance; + final FlutterReadium _flutterReadium = FlutterReadium(); mq.Orientation? _lastOrientation; late Widget _readerWidget; @@ -124,7 +125,7 @@ class _ReadiumReaderWidgetState extends State implements Re // TODO: Find a better way to do this, maybe a `lastVisibleLocator` ? if (_readium.defaultPreferences?.verticalScroll != true) { await _channel?.goRight(animated: false); - final loc = await _channel?.getCurrentLocator(); + final loc = await _flutterReadium.onTextLocatorChanged.first; currentHref = getTextLocatorHrefWithTocFragment(loc); } @@ -155,21 +156,6 @@ class _ReadiumReaderWidgetState extends State implements Re } } - @override - Future getLocatorFragments(final Locator locator) async { - R2Log.d('getLocatorFragments: $locator'); - - await _awaitNativeViewReady(); - - return await _channel?.getLocatorFragments(locator); - } - - @override - Future getCurrentLocator() async { - R2Log.d('GetCurrentLocator()'); - return _channel?.getCurrentLocator(); - } - @override Future setEPUBPreferences(EPUBPreferences preferences) async { _channel?.setEPUBPreferences(preferences); @@ -288,10 +274,6 @@ class _ReadiumReaderWidgetState extends State implements Re }); } - Future _awaitNativeViewReady() { - return _isReadyCompleter.future; - } - /// Gets a Locator's href with toc fragment appended as identifier String? getTextLocatorHrefWithTocFragment(Locator? locator) { if (locator == null) { diff --git a/flutter_readium/lib/reader_widget_web.dart b/flutter_readium/lib/reader_widget_web.dart index 944fa016..54e83eb0 100644 --- a/flutter_readium/lib/reader_widget_web.dart +++ b/flutter_readium/lib/reader_widget_web.dart @@ -75,13 +75,6 @@ class _ReadiumReaderWidgetState extends State implements Re JsPublicationChannel.goRight(); } - @override - // ignore: prefer_expression_function_bodies - Future getLocatorFragments(final Locator locator) async { - // Implement this method if needed - return null; - } - @override Future skipToPrevious({final bool animated = true}) async { R2Log.d('skipToPrevious not implemented in web version'); @@ -92,12 +85,6 @@ class _ReadiumReaderWidgetState extends State implements Re R2Log.d('skipToNext not implemented in web version'); } - @override - Future getCurrentLocator() async { - R2Log.d('getCurrentLocator not implemented in web version'); - return null; - } - @override Future setEPUBPreferences(EPUBPreferences preferences) async { R2Log.d('setEPUBPreferences not implemented in web version'); diff --git a/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart b/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart index 659f1ef5..a1765860 100644 --- a/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart +++ b/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart @@ -131,8 +131,9 @@ class MethodChannelFlutterReadium extends FlutterReadiumPlatform { await currentReaderWidget?.applyDecorations(id, decorations); @override - Future ttsEnable(TTSPreferences? preferences) async => - await methodChannel.invokeMethod('ttsEnable', preferences?.toJson()); + Future ttsEnable(TTSPreferences? preferences) async { + await methodChannel.invokeMethod('ttsEnable', preferences?.toJson()); + } @override Future play(Locator? fromLocator) async => await methodChannel.invokeMethod('play', [fromLocator?.toJson()]); diff --git a/flutter_readium_platform_interface/lib/src/extensions/readium_string_extensions.dart b/flutter_readium_platform_interface/lib/src/extensions/readium_string_extensions.dart index 4fbeccd9..cb4c6522 100644 --- a/flutter_readium_platform_interface/lib/src/extensions/readium_string_extensions.dart +++ b/flutter_readium_platform_interface/lib/src/extensions/readium_string_extensions.dart @@ -53,6 +53,4 @@ extension ReadiumStringExtension on String { /// /// Returns `null` if path couldn't be retrieved from uri. String? get path => Uri.tryParse(this)?.path; - - String stripLeadingSlash() => startsWith('/') ? substring(1) : this; } diff --git a/flutter_readium_platform_interface/lib/src/extensions/strings.dart b/flutter_readium_platform_interface/lib/src/extensions/strings.dart index f950111c..3504e9c1 100644 --- a/flutter_readium_platform_interface/lib/src/extensions/strings.dart +++ b/flutter_readium_platform_interface/lib/src/extensions/strings.dart @@ -105,6 +105,13 @@ extension StringExtension on String { /// /// @sample samples.text.Strings.take String takeWhile(bool Function(String) predicate) => characters.takeWhile(predicate).string; + + (String, String?) splitPathAndFragment() { + final components = split('#'); + final path = components.firstOrDefault(this); + final fragment = (components.length > 1 && components[1].isNotEmpty) ? components[1] : null; + return (path, fragment); + } } extension StringHashExtension on String { diff --git a/flutter_readium_platform_interface/lib/src/reader/reader_widget_interface.dart b/flutter_readium_platform_interface/lib/src/reader/reader_widget_interface.dart index 642e5c51..7bd2f6a6 100644 --- a/flutter_readium_platform_interface/lib/src/reader/reader_widget_interface.dart +++ b/flutter_readium_platform_interface/lib/src/reader/reader_widget_interface.dart @@ -16,12 +16,6 @@ abstract class ReadiumReaderWidgetInterface { /// Skip to next chapter (toc) Future skipToNext({final bool animated = true}); - /// Gets the current Navigator's locator. - Future getCurrentLocator(); - - /// Get a locator with relevant fragments - Future getLocatorFragments(final Locator locator); - /// Set EPUB preferences Future setEPUBPreferences(EPUBPreferences preferences); diff --git a/flutter_readium_platform_interface/lib/src/shared/publication/link.dart b/flutter_readium_platform_interface/lib/src/shared/publication/link.dart index 0124452b..7614002f 100644 --- a/flutter_readium_platform_interface/lib/src/shared/publication/link.dart +++ b/flutter_readium_platform_interface/lib/src/shared/publication/link.dart @@ -10,11 +10,7 @@ import 'package:fimber/fimber.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:meta/meta.dart'; -import '../../utils/href.dart'; -import '../../utils/jsonable.dart'; -import '../../utils/uri_template.dart'; -import '../mediatype/mediatype.dart'; -import 'properties.dart'; +import '../../../flutter_readium_platform_interface.dart'; export 'link_list_extension.dart'; @@ -123,11 +119,11 @@ class Link with EquatableMixin implements JSONable { /// given collection role. final List children; - List get _hrefParts => href.split('#'); + (String, String?) get _hrefParts => href.splitPathAndFragment(); - String get hrefPart => _hrefParts[0]; + String get hrefPart => _hrefParts.$1; - String? get elementId => (_hrefParts.length > 1) ? _hrefParts[1] : null; + String? get elementId => _hrefParts.$2; Link copyWith({ String? id, diff --git a/flutter_readium_platform_interface/lib/src/shared/publication/locator.dart b/flutter_readium_platform_interface/lib/src/shared/publication/locator.dart index 7b99d8b9..62fdd5d6 100644 --- a/flutter_readium_platform_interface/lib/src/shared/publication/locator.dart +++ b/flutter_readium_platform_interface/lib/src/shared/publication/locator.dart @@ -4,7 +4,6 @@ import 'dart:convert'; -import 'package:dartx/dartx.dart'; import 'package:dfunc/dfunc.dart'; import 'package:equatable/equatable.dart'; import 'package:fimber/fimber.dart'; @@ -12,6 +11,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:meta/meta.dart'; import '../../extensions/readium_string_extensions.dart'; +import '../../extensions/strings.dart'; import '../../utils/additional_properties.dart'; import '../../utils/jsonable.dart'; import '../../utils/take.dart'; @@ -383,11 +383,10 @@ class LocatorText with EquatableMixin implements JSONable { extension LinkLocator on Link { /// Creates a [Locator] from a reading order [Link]. Locator toLocator() { - final components = href.split('#'); - final fragment = (components.length > 1 && components[1].isNotEmpty) ? components[1] : null; + final (hrefPath, fragment) = href.splitPathAndFragment(); return Locator( - href: components.firstOrDefault(href).stripLeadingSlash(), + href: hrefPath, type: type ?? '', title: title, text: LocatorText(), diff --git a/flutter_readium_platform_interface/lib/src/shared/publication/publication.dart b/flutter_readium_platform_interface/lib/src/shared/publication/publication.dart index e3960017..ef964773 100644 --- a/flutter_readium_platform_interface/lib/src/shared/publication/publication.dart +++ b/flutter_readium_platform_interface/lib/src/shared/publication/publication.dart @@ -145,24 +145,21 @@ class Publication with EquatableMixin implements JSONable { } Locator? locatorFromLink(final Link link, {final MediaType? typeOverride}) { - final href = link.href; - final hashIndex = href.indexOf(_hrefEnd); - final hrefHead = hashIndex == -1 ? href : href.substring(0, hashIndex); - final hrefTail = hashIndex == -1 ? null : href.substring(hashIndex + 1); - final resourceLink = linkWithHref(hrefHead); + final (href, fragments) = link.href.splitPathAndFragment(); + final resourceLink = linkWithHref(href); final type = resourceLink?.type ?? typeOverride?.name; final linkIndex = resourceLink == null ? -1 : readingOrder.indexOf(resourceLink); return type == null ? null : Locator( - href: hrefHead.stripLeadingSlash(), + href: href, type: type, title: resourceLink!.title ?? link.title, text: LocatorText(), locations: Locations( - cssSelector: hrefTail != null && hrefTail.isNotEmpty ? '#$hrefTail' : null, - fragments: hrefTail == null ? [] : [hrefTail], - progression: hrefTail == null ? 0 : null, + cssSelector: fragments != null && fragments.isNotEmpty ? '#$fragments' : null, + fragments: fragments == null ? [] : [fragments], + progression: fragments == null ? 0 : null, position: linkIndex == -1 ? null : linkIndex + 1, ), );