Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
318a354
feat(android): extract mediaoverlays
m-abs Oct 13, 2025
59096a5
feat(android): add audioLocator getter for FlutterMediaOverlayItem
m-abs Oct 13, 2025
ea73105
feat(android): refactor media-overlay parsing and add to state
m-abs Oct 13, 2025
031d647
android: create a pseudo publication for narrated books
m-abs Oct 13, 2025
801dd8d
fix: start mediaoverlay book as audiobook
ddfreiling Oct 14, 2025
7db2e30
feat(android): Sync between audiobook navigator and epub navigator
m-abs Oct 14, 2025
697d963
feat(android): Basic highlighting
m-abs Oct 14, 2025
89e9eca
feat(media-overlay): initial iOS implementation
ddfreiling Oct 14, 2025
70dfb61
chore: let reader preload further ahead
ddfreiling Oct 14, 2025
29dbc07
Merge remote-tracking branch 'origin/feat/mediaoverlay-books' into fe…
m-abs Oct 14, 2025
6f7d1e3
refactor(android): Make sync audio navigator
m-abs Oct 14, 2025
523a683
feat(android): SyncAudiobookNavigator now handles ToC locators
m-abs Oct 15, 2025
87be0af
fix(android): handle sync audiobook with initial locator
m-abs Oct 15, 2025
d949bf4
fix(android): ReadiumTimebasedState was broken
m-abs Oct 15, 2025
a07caf1
fix(android): play() didn't use first visible locator for anything de…
m-abs Oct 15, 2025
a831ad9
feat(android): Generalize decorations for SyncAudiobook and TTS
m-abs Oct 20, 2025
b8600fe
chor(android): Document functions and properties
m-abs Oct 21, 2025
4e915ff
fix(android): DatabaseMediaMetadataFactory never loaded the cover
m-abs Oct 21, 2025
867ec64
fix: handle goToLocator for mediaoverlay books on iOS
ddfreiling Oct 21, 2025
355f87b
refactor: better structure for iOS code
ddfreiling Oct 21, 2025
4a7e9be
chore(example): use theme 2 in example app
ddfreiling Oct 21, 2025
d876e2a
chor: update gradle build
m-abs Oct 22, 2025
c2642f9
fix(iOS): goToLocator did nothing for regular ebooks
ddfreiling Oct 23, 2025
ad05bb4
fix(iOS): use FlutterAudioPreferences to seek correct duration on nex…
ddfreiling Oct 23, 2025
29ba874
fix(iOS): also make sure to resume playing after audiobook goToLocator
ddfreiling Oct 23, 2025
a807120
feat(example): use plugin's timebasedPlayerStateChanged stream in Pla…
ddfreiling Oct 23, 2025
14fd99a
chor(example): move timebased stream debounce to after distinct
ddfreiling Oct 24, 2025
d5d79e7
feat: add control panel info type to TTSPreferences
SifAa Oct 24, 2025
f3dec64
chore(iOS): use already implemented seek functions
ddfreiling Oct 24, 2025
e388e60
refactor(ios): clean up code formatting for tts
SifAa Oct 24, 2025
a4d9ff4
feat(ios): customizable control panel info for tts
SifAa Oct 24, 2025
0ff5d38
chore(android): control panel info fallback should be the same as sta…
SifAa Oct 24, 2025
3c4e710
fix(android): faulty authors line in notification
ddfreiling Oct 27, 2025
a95278c
chore: fallback title for chapter in notification/control-panel
ddfreiling Oct 27, 2025
d94d355
refactor(ios): reindent
SifAa Oct 27, 2025
409ee43
refactor(ios): streamline now playing info handling for audiobooks an…
SifAa Oct 27, 2025
c28ab33
Merge branch 'feat/mediaoverlay-books' into controlpanel
ddfreiling Oct 27, 2025
6b6e2fb
Merge pull request #31 from Notalib/controlpanel
ddfreiling Oct 27, 2025
16ea65f
Merge remote-tracking branch 'origin/feat/mediaoverlay-books' into fe…
m-abs Oct 27, 2025
98d3c47
fix: fallback chapterTitle after merge
ddfreiling Oct 27, 2025
34ea242
Merge branch 'feat/mediaoverlay-books' of github.com:Notalib/flutter_…
m-abs Oct 27, 2025
dc2de93
chore: make fallback chapter-title follow publication language
ddfreiling Oct 27, 2025
1f889d3
fix: No element, containsMediaOverlays threw an exception when link.a…
m-abs Oct 27, 2025
fc78699
Merge branch 'feat/mediaoverlay-books' of github.com:Notalib/flutter_…
m-abs Oct 27, 2025
df1a2b6
fix: correct Swedish localization key for fallback chapter title
SifAa Oct 28, 2025
2c62b4d
wip: refactor iOS navigators into common protocol and delegate
ddfreiling Oct 28, 2025
198c87b
refactor: move extension code into separate TimebasedNavigators
ddfreiling Oct 29, 2025
a6b8b76
chor: minor rename
ddfreiling Oct 29, 2025
34867c7
chor: log with correct tag
ddfreiling Oct 29, 2025
0455175
refactor: improved combined Locator for MediaOverlay playback positio…
ddfreiling Oct 30, 2025
b66728d
Merge pull request #34 from ddfreiling/refactor/ios-navigators
SifAa Oct 30, 2025
7ce7b8f
fix(android): Locator mapping didn't use correct time fragment for sy…
m-abs Oct 30, 2025
65babf2
fix: do not modify mediatype on the combined media-overlay Locator
ddfreiling Oct 30, 2025
98c6f2e
refactor: MediaOverlayItem as struct + documentation
ddfreiling Oct 30, 2025
6dc49ed
feat(android): use xhtml mimetype for synced audiobook locators
m-abs Oct 30, 2025
1eeb7b8
Merge pull request #35 from Notalib/fix/android-locators
ddfreiling Oct 30, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions flutter_readium/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ rootProject.allprojects {
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'

import org.jetbrains.kotlin.gradle.dsl.JvmTarget

android {
if (project.android.hasProperty('namespace')) {
namespace 'dk.nota.flutter_readium'
Expand All @@ -36,11 +38,13 @@ android {
targetCompatibility JavaVersion.VERSION_18
}

kotlinOptions {
jvmTarget = '18'
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_18
}
}

compileSdk 35
compileSdkVersion 35
ndkVersion = "26.3.11579264"

sourceSets {
Expand Down Expand Up @@ -73,12 +77,12 @@ dependencies {
//implementation "org.readium.kotlin-toolkit:readium-lcp:$readium_version"

// Used for media-session controls
def media_version = "1.7.1"
def media_version = "1.8.0"
implementation "androidx.media3:media3-common:$media_version"
implementation "androidx.media3:media3-session:$media_version"
implementation "androidx.media3:media3-exoplayer:$media_version"

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0"
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import org.json.JSONObject
import org.readium.adapter.exoplayer.audio.ExoPlayerPreferences
import org.readium.r2.navigator.preferences.Configurable

/**
* Audio preferences for Flutter Readium with extra properties.
*/
@Serializable
data class FlutterAudioPreferences(
val volume: Double? = null,
Expand All @@ -23,6 +26,9 @@ data class FlutterAudioPreferences(
controlPanelInfoType = other.controlPanelInfoType
)

/**
* Converts FlutterAudioPreferences to ExoPlayerPreferences.
*/
fun toExoPlayerPreferences(): ExoPlayerPreferences {
return ExoPlayerPreferences(
pitch = this.pitch,
Expand All @@ -31,10 +37,16 @@ data class FlutterAudioPreferences(
}

companion object {
/**
* Creates FlutterAudioPreferences from a JSON string.
*/
fun fromJSON(json: String): FlutterAudioPreferences {
return fromJSON(JSONObject(json))
}

/**
* Creates FlutterAudioPreferences from a JSON object.
*/
fun fromJSON(jsonObject: JSONObject): FlutterAudioPreferences {
return FlutterAudioPreferences(
volume = jsonObject.getDouble("volume"),
Expand All @@ -45,6 +57,9 @@ data class FlutterAudioPreferences(
)
}

/**
* Converts FlutterAudioPreferences to a JSON object.
*/
fun toJSON(preferences: FlutterAudioPreferences): JSONObject {
val jsonObject = JSONObject()
jsonObject.put("volume", preferences.volume)
Expand All @@ -55,6 +70,9 @@ data class FlutterAudioPreferences(
return jsonObject
}

/**
* Creates FlutterAudioPreferences from a Map.
*/
fun fromMap(prefs: Map<*, *>): FlutterAudioPreferences {
return FlutterAudioPreferences(
volume = prefs["volume"] as? Double ?: 1.0,
Expand All @@ -67,3 +85,4 @@ data class FlutterAudioPreferences(
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package dk.nota.flutter_readium

import android.graphics.Color
import org.readium.r2.navigator.Decoration
import java.io.Serializable

// TODO: Decision on appropriate defaults
// TODO: Can this be made configurable at built time?
// TODO: More complex styles? Like bold or italic plus background and text colors?
private val defaultUtteranceStyle = Decoration.Style.Highlight(tint = Color.YELLOW)
private val defaultCurrentRangeStyle = Decoration.Style.Underline(tint = Color.RED)

/**
* Decoration preferences used in the Flutter Readium plugin.
*/
data class FlutterDecorationPreferences(
/**
* Style for utterance decoration.
*/
var utteranceStyle: Decoration.Style? = defaultUtteranceStyle,

/**
* Style for current reading range decoration.
*/
var currentRangeStyle: Decoration.Style? = defaultCurrentRangeStyle
) : Serializable {
companion object {
/**
* Create Decoration.Style from map.
*/
fun fromMap(
uttDecoMap: Map<*, *>?,
rangeDecoMap: Map<*, *>?
): FlutterDecorationPreferences {
return FlutterDecorationPreferences(
decorationStyleFromMap(uttDecoMap) ?: defaultUtteranceStyle,
decorationStyleFromMap(rangeDecoMap) ?: defaultCurrentRangeStyle,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@ private const val TAG = "FlutterReadiumPlugin"

@ExperimentalCoroutinesApi
class FlutterReadiumPlugin : FlutterPlugin, ActivityAware, MethodCallHandler {
/// The MethodChannel that will the communication between Flutter and native Android
///
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
/// when the Flutter Engine is detached from the Activity
/**
* The MethodChannel that will the communication between Flutter and native Android
*
* This local reference serves to register the plugin with the Flutter Engine and unregister it
* when the Flutter Engine is detached from the Activity
*/
private lateinit var publicationChannel: MethodChannel

private lateinit var publicationMethodCallHandler: PublicationMethodCallHandler

private lateinit var binaryMessenger: BinaryMessenger
Expand Down Expand Up @@ -64,6 +67,9 @@ class FlutterReadiumPlugin : FlutterPlugin, ActivityAware, MethodCallHandler {
publicationChannel.setMethodCallHandler(null)
}

/**
* Recursively list all asset files in the given root path.
*/
private fun listAssetFiles(c: Context, rootPath: String): List<String> {
Log.i("ListAssetFiles", "Listing assets in $rootPath")
val files: MutableList<String> = ArrayList()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import org.readium.navigator.media.tts.android.AndroidTtsPreferences
import org.readium.r2.shared.ExperimentalReadiumApi
import org.readium.r2.shared.util.Language

/**
* TTS preferences used in the Flutter Readium plugin.
*/
@Serializable
data class FlutterTtsPreferences(
val language: String? = null,
Expand All @@ -15,6 +18,9 @@ data class FlutterTtsPreferences(
val voices: Map<String, String>? = null,
val controlPanelInfoType: ControlPanelInfoType? = ControlPanelInfoType.STANDARD,
) {
/**
* Convert to AndroidTtsPreferences.
*/
@OptIn(ExperimentalReadiumApi::class)
fun toAndroidTtsPreferences(): AndroidTtsPreferences {
return AndroidTtsPreferences(
Expand All @@ -36,10 +42,16 @@ data class FlutterTtsPreferences(
)

companion object {
/**
* Create FlutterTtsPreferences from JSON string.
*/
fun fromJSON(json: String): FlutterTtsPreferences {
return fromJSON(JSONObject(json))
}

/**
* Create FlutterTtsPreferences from JSON object.
*/
fun fromJSON(jsonObject: JSONObject): FlutterTtsPreferences {
val voicesMap = mutableMapOf<String, String>()
if (jsonObject.has("voices")) {
Expand All @@ -62,6 +74,9 @@ data class FlutterTtsPreferences(
)
}

/**
* Convert FlutterTtsPreferences to JSON object.
*/
fun toJSON(preferences: FlutterTtsPreferences): JSONObject {
val jsonObject = JSONObject()
jsonObject.put("language", preferences.language)
Expand All @@ -76,6 +91,9 @@ data class FlutterTtsPreferences(
return jsonObject
}

/**
* Create FlutterTtsPreferences from a map.
*/
fun fromMap(prefs: Map<*, *>?): FlutterTtsPreferences {
val voices = (prefs?.get("voices") as? Map<*, *>)?.mapNotNull {
val key = it.key as? String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import org.readium.navigator.media.common.Media3Adapter
import org.readium.navigator.media.common.MediaNavigator
import org.readium.r2.shared.ExperimentalReadiumApi
Expand Down Expand Up @@ -365,7 +364,7 @@ class PluginSimpleBasePlayer(player: Player) : ForwardingSimpleBasePlayer(player
positionMs: Long,
seekCommand: Int
): ListenableFuture<*> {
// NOTE: Maps seek to next/previous track, to seek forward/backward.
// NOTE: Maps seek to next/previous track, to seek forward/backward in current track.
if (seekCommand == COMMAND_SEEK_TO_NEXT) {
return super.handleSeek(mediaItemIndex, positionMs, COMMAND_SEEK_FORWARD)
} else if (seekCommand == COMMAND_SEEK_TO_PREVIOUS) {
Expand All @@ -376,73 +375,67 @@ class PluginSimpleBasePlayer(player: Player) : ForwardingSimpleBasePlayer(player

// FIX: Hacky way to fix missing COMMAND_GET_TIMELINE from TtsSessionAdapter
override fun getState(): State {
// This is a copy & override of the super implementation, due to assert on empty playlist,
// which Readium TTSPlayer sometimes provides during active states.
// See https://github.com/readium/kotlin-toolkit/pull/716

// Ordered alphabetically by State.Builder setters.
val state = State.Builder()
//val positionSuppliers = livePositionSuppliers
// if (player.isCommandAvailable(COMMAND_GET_CURRENT_MEDIA_ITEM)) {
// state.setAdBufferedPositionMs(positionSuppliers.bufferedPositionSupplier)
// state.setAdPositionMs(positionSuppliers.currentPositionSupplier)
// }
// val positionSuppliers = livePositionSuppliers
if (player.isCommandAvailable(COMMAND_GET_AUDIO_ATTRIBUTES)) {
state.setAudioAttributes(player.getAudioAttributes())
state.setAudioAttributes(player.audioAttributes)
}
state.setAvailableCommands(player.getAvailableCommands())
state.setAvailableCommands(player.availableCommands)
if (player.isCommandAvailable(COMMAND_GET_CURRENT_MEDIA_ITEM)) {
state.setContentPositionMs { player.contentPosition }
state.setContentBufferedPositionMs { player.contentBufferedPosition }
// state.setContentBufferedPositionMs(positionSuppliers.contentBufferedPositionSupplier)
// state.setContentPositionMs(positionSuppliers.contentPositionSupplier)
}
if (player.isCommandAvailable(COMMAND_GET_TEXT)) {
state.setCurrentCues(player.getCurrentCues())
state.setCurrentCues(player.currentCues)
}
//if (player.isCommandAvailable(COMMAND_GET_TIMELINE)) {
state.setCurrentMediaItemIndex(player.getCurrentMediaItemIndex())
state.setCurrentMediaItemIndex(player.currentMediaItemIndex)
//}
state.setDeviceInfo(player.getDeviceInfo())
if (player.isCommandAvailable(COMMAND_GET_DEVICE_VOLUME)) {
state.setDeviceVolume(player.getDeviceVolume())
state.setIsDeviceMuted(player.isDeviceMuted())
state.setDeviceVolume(player.deviceVolume)
state.setIsDeviceMuted(player.isDeviceMuted)
}
state.setIsLoading(player.isLoading())
state.setMaxSeekToPreviousPositionMs(player.getMaxSeekToPreviousPosition())
state.setPlaybackParameters(player.getPlaybackParameters())
state.setPlaybackState(player.getPlaybackState())
state.setPlaybackSuppressionReason(player.getPlaybackSuppressionReason())
state.setPlayerError(player.getPlayerError())
state.setIsLoading(player.isLoading)
state.setMaxSeekToPreviousPositionMs(player.maxSeekToPreviousPosition)
state.setPlaybackParameters(player.playbackParameters)
state.setPlaybackState(player.playbackState)
state.setPlaybackSuppressionReason(player.playbackSuppressionReason)
state.setPlayerError(player.playerError)
//if (player.isCommandAvailable(COMMAND_GET_TIMELINE)) {
val tracks =
if (player.isCommandAvailable(COMMAND_GET_TRACKS))
player.getCurrentTracks()
player.currentTracks
else
Tracks.EMPTY
val mediaMetadata =
if (player.isCommandAvailable(COMMAND_GET_METADATA)) player.getMediaMetadata() else null
state.setPlaylist(player.getCurrentTimeline(), tracks, mediaMetadata)
if (player.isCommandAvailable(COMMAND_GET_METADATA)) player.mediaMetadata else null
state.setPlaylist(player.currentTimeline, tracks, mediaMetadata)
//}
if (player.isCommandAvailable(COMMAND_GET_METADATA)) {
state.setPlaylistMetadata(player.getPlaylistMetadata())
state.setPlaylistMetadata(player.playlistMetadata)
}
state.setPlayWhenReady(player.getPlayWhenReady(), PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM)
// if (pendingPositionDiscontinuityNewPositionMs != C.TIME_UNSET) {
// state.setPositionDiscontinuity(
// pendingDiscontinuityReason, pendingPositionDiscontinuityNewPositionMs
// )
// pendingPositionDiscontinuityNewPositionMs = C.TIME_UNSET
// }
state.setRepeatMode(player.getRepeatMode())
state.setSeekBackIncrementMs(player.getSeekBackIncrement())
state.setSeekForwardIncrementMs(player.getSeekForwardIncrement())
state.setShuffleModeEnabled(player.getShuffleModeEnabled())
state.setSurfaceSize(player.getSurfaceSize())
state.setPlayWhenReady(player.playWhenReady, PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM)
state.setRepeatMode(player.repeatMode)
state.setSeekBackIncrementMs(player.seekBackIncrement)
state.setSeekForwardIncrementMs(player.seekForwardIncrement)
state.setShuffleModeEnabled(player.shuffleModeEnabled)
state.setSurfaceSize(player.surfaceSize)
//state.setTimedMetadata(lastTimedMetadata)
if (player.isCommandAvailable(COMMAND_GET_CURRENT_MEDIA_ITEM)) {
state.setTotalBufferedDurationMs { player.totalBufferedDuration }
}
state.setTrackSelectionParameters(player.getTrackSelectionParameters())
state.setVideoSize(player.getVideoSize())
state.setTrackSelectionParameters(player.trackSelectionParameters)
state.setVideoSize(player.videoSize)
if (player.isCommandAvailable(COMMAND_GET_VOLUME)) {
state.setVolume(player.getVolume())
state.setVolume(player.volume)
}
return state.build()
}
Expand Down
Loading