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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion flutter_readium/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,33 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
`EPUBPreferences.disableSynchronization` is turned back off (`true -> false`),
the visual EPUB navigator now jumps to the last sync locator that was reached
while synchronization was disabled, matching Android behavior.
- **iOS: Media Overlay clips that span a CSS column boundary no longer desync** — in
paginated mode, a paragraph straddling two columns would start audio on the first
column but continue playing through text visible only on the second column. When
media-overlay playback is active, a `break-inside: avoid` CSS rule is now injected
so each paragraph stays whole on one page, keeping audio and visible text in sync.
Controlled by `EPUBPreferences.preventMOColumnBreaks` (default `true`; set to
`false` to opt out and restore the original layout).
- **Android + Web: Media Overlay clips that span a CSS column boundary no longer desync** — same fix
as iOS above, now applied to Android and Web via the shared `flutterReadium` helper-script bundle.
- **iOS: TTS no longer snaps back to the previous page mid-sentence** — when a spoken sentence
crossed a paginated page boundary, the reader correctly advanced to page N+1 for the word being
spoken but then flickered back to page N on each subsequent word. The cause was a double-assignment
to the `@Published playingUtterance` property (raw locator, then position mutation), which defeated
`removeDuplicates()` and fired the page-sync on every word update instead of only on utterance
changes.
- **Web: Improved error-handling** - `ttsEnable`, `audioEnable`, and `ttsGetAvailableVoices` failures are
now caught and `.stack` is now included in `PlatformException.message`.
- **iOS: early reader events are no longer dropped** — the `text-locator` and
`reader-status` event channels now buffer the most-recent event on the native
side when Dart has not yet attached a listener. The buffer is flushed
immediately when `onListen` fires.

## [0.1.0] - 2026-06-20

Brings the Web platform up to feature parity with iOS / Android (audio,
Media Overlay, TTS, Guided Navigation, decorations), plus a handful of
supporting cross-platform additions.

### Added

- **Web: Audio Navigator** — audiobook publications now play on web. `audioEnable`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ data class FlutterEpubPreferences(
val blackAndWhiteComicMode: Boolean? = false,
val disableSynchronization: Boolean? = false,
val firstElementTopMargin: Int? = null,
val preventMOColumnBreaks: Boolean? = true,
) : Configurable.Preferences<FlutterEpubPreferences> {
override fun plus(other: FlutterEpubPreferences): FlutterEpubPreferences =
FlutterEpubPreferences(
Expand Down Expand Up @@ -78,6 +79,7 @@ data class FlutterEpubPreferences(
blackAndWhiteComicMode = other.blackAndWhiteComicMode ?: blackAndWhiteComicMode,
disableSynchronization = other.disableSynchronization ?: disableSynchronization,
firstElementTopMargin = other.firstElementTopMargin ?: firstElementTopMargin,
preventMOColumnBreaks = other.preventMOColumnBreaks ?: preventMOColumnBreaks,
)

fun toEpubPreferences(): EpubPreferences =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@ object ReadiumReader :
private var audiobookNavigator: AudiobookNavigator? = null
private var syncAudiobookNavigator: SyncAudiobookNavigator? = null

/**
* Mirrors `EPUBPreferences.preventMOColumnBreaks`. Defaults to `true`.
* Consumer can opt out via preferences.
*/
private var _preventMOColumnBreaks: Boolean = true

/** True when a Media Overlay (sync-narration) navigator is active. */
val isMOActive: Boolean
get() = syncAudiobookNavigator != null

private val timebasedNavigator: TimebasedNavigator<*>?
get() = audiobookNavigator ?: syncAudiobookNavigator ?: ttsNavigator

Expand Down Expand Up @@ -1130,12 +1140,16 @@ object ReadiumReader :
audiobookNavigator = null
}

val wasMOActive = isMOActive
syncAudiobookNavigator?.apply {
pause()
dispose()

syncAudiobookNavigator = null
}
if (wasMOActive) {
epubEvaluateJavascript("window.flutterReadium.removeMOBreakCSS()")
}

ttsNavigator?.apply {
pause()
Expand Down Expand Up @@ -1289,6 +1303,9 @@ object ReadiumReader :
).apply {
initNavigator()
}
if (_preventMOColumnBreaks) {
epubEvaluateJavascript("window.flutterReadium.injectMOBreakCSS()")
}
}
}

Expand Down Expand Up @@ -1367,6 +1384,16 @@ object ReadiumReader :
return
}

val newPreventBreaks = preferences.preventMOColumnBreaks ?: true
if (isMOActive && newPreventBreaks != _preventMOColumnBreaks) {
if (newPreventBreaks) {
epubEvaluateJavascript("window.flutterReadium.injectMOBreakCSS()")
} else {
epubEvaluateJavascript("window.flutterReadium.removeMOBreakCSS()")
}
}
_preventMOColumnBreaks = newPreventBreaks

navigator.updatePreferences(preferences)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ class ReadiumReaderWidget(
}

emitOnPageChanged(pageIndex, totalPages, locator)

// Re-inject MO column-break CSS for each freshly-loaded spine item.
if (ReadiumReader.isMOActive) {
ReadiumReader.epubEvaluateJavascript("window.flutterReadium.injectMOBreakCSS()")
}
}
}

Expand Down
7 changes: 7 additions & 0 deletions flutter_readium/assets/_helper_scripts/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# This package installs a git dependency (readium-css from github:readium/readium-css).
# npm's git-dep preparation spawns a sub-process with --before=<timestamp> derived from
# min-release-age, and that sub-process also inherits min-release-age from the global
# config — npm rejects the combination. Disable min-release-age here since it applies
# to registry packages only; a pinned git dep and private build tooling are outside
# the supply-chain threat model it protects against.
min-release-age=0
17 changes: 17 additions & 0 deletions flutter_readium/assets/_helper_scripts/src/FlutterReadiumTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ class FlutterReadiumTools {
};
}

/**
* Inject a style tag that prevents paragraphs from splitting across CSS columns during
* Media Overlay playback. Idempotent: no-op if the tag is already present.
*/
public injectMOBreakCSS(): void {
if (document.getElementById('flutter-readium-mo-breaks')) return;
const style = document.createElement('style');
style.id = 'flutter-readium-mo-breaks';
style.textContent = 'p { break-inside: avoid !important }';
document.head.appendChild(style);
}

/** Remove the MO column-break style tag injected by injectMOBreakCSS. No-op if absent. */
public removeMOBreakCSS(): void {
document.getElementById('flutter-readium-mo-breaks')?.remove();
}

/**
* Find current page information, including physical page, css selector of the current position, and the nearest ToC element id.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public class EPUBReaderView: NSObject, FlutterPlatformView, ReadiumReaderView, E
private var hasSentReady = false
private var isJumpingToLocator = false
private var lastHrefLocation: String?
private var isMOActive = false
private var shouldPreventColumnBreaks: Bool { isMOActive && (preferences?.preventMOColumnBreaks ?? true) }
private var preferences: FlutterEPUBPreferences?
private var lastSyncLocator: Locator?
private var lastSyncSegmentDuration: TimeInterval?
Expand Down Expand Up @@ -267,6 +269,9 @@ public class EPUBReaderView: NSObject, FlutterPlatformView, ReadiumReaderView, E
if let preferences = self.preferences {
updateCustomPreferences(preferences)
}
if shouldPreventColumnBreaks {
injectColumnBreakCSS()
}
}
emitOnPageChanged(locator: locator)
}
Expand Down Expand Up @@ -391,8 +396,10 @@ public class EPUBReaderView: NSObject, FlutterPlatformView, ReadiumReaderView, E
}

private func setUserPreferences(preferences: FlutterEPUBPreferences) {
self.preferences = preferences
self.readiumViewController.submitPreferences(preferences.readium)
self.updateCustomPreferences(preferences)
applyColumnBreakPrevention()
}

private func updateCustomPreferences(_ preferences: FlutterEPUBPreferences) {
Expand All @@ -407,6 +414,46 @@ public class EPUBReaderView: NSObject, FlutterPlatformView, ReadiumReaderView, E
}
}

public func setMOActive(_ active: Bool) {
isMOActive = active
applyColumnBreakPrevention()
}

private func applyColumnBreakPrevention() {
if shouldPreventColumnBreaks {
injectColumnBreakCSS()
} else {
removeColumnBreakCSS()
}
}

private func injectColumnBreakCSS() {
Task.detached(priority: .high) {
let js = """
(function(){
if (document.getElementById('flutter-readium-mo-breaks')) return;
var s = document.createElement('style');
s.id = 'flutter-readium-mo-breaks';
s.textContent = 'p { break-inside: avoid !important; }';
document.head && document.head.appendChild(s);
})();
"""
await self.readiumViewController.evaluateJavaScript(js)
}
}

private func removeColumnBreakCSS() {
Task.detached(priority: .high) {
let js = """
(function(){
var s = document.getElementById('flutter-readium-mo-breaks');
if (s) s.parentNode.removeChild(s);
})();
"""
await self.readiumViewController.evaluateJavaScript(js)
}
}

private func emitOnPageChanged(locator: Locator) -> Void {
Log.reader.debug("emitOnPageChanged, locator: \(locator)")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,10 +307,14 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin
case "stop":
Task { @MainActor in
if self.timebasedNavigator != nil {
let wasMO = self.timebasedNavigator is FlutterMediaOverlayNavigator
self.timebasedNavigator?.dispose()
self.timebasedNavigator = nil
self.timebasedPlayerStateStreamHandler?.sendEvent(ReadiumTimebasedState(state: .none).toJsonString())
self.updateReaderViewTimebasedDecorations([])
if wasMO {
self.currentReaderView?.setMOActive(false)
}
}
}
result(nil)
Expand Down Expand Up @@ -436,6 +440,12 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin
self.timebasedNavigator?.listener = self
await self.timebasedNavigator?.initNavigator()

if self.timebasedNavigator is FlutterMediaOverlayNavigator {
await MainActor.run {
self.currentReaderView?.setMOActive(true)
}
}

await MainActor.run {
result(nil)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ public class PDFReaderView: NSObject, FlutterPlatformView, ReadiumReaderView, PD
Log.reader.debug("onCustomEditingAction: not supported for PDF")
}

public func setMOActive(_ active: Bool) {
// PDF has no CSS column layout — no-op.
}

// MARK: - Locator emission

private func emitOnPageChanged(locator: Locator) -> Void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,8 @@ public protocol ReadiumReaderView: AnyObject {
func syncToLocator(_ locator: Locator, animated: Bool, segmentDuration: TimeInterval?, isWordRange: Bool) async -> Bool
func applyDecorations(_ decorations: [Decoration], forGroup groupIdentifier: String)
func onCustomEditingAction() -> Void
/// Called when media-overlay playback starts (`active: true`) or stops (`active: false`).
/// The reader view decides whether to inject column-break-prevention CSS based on this
/// flag combined with the `preventMOColumnBreaks` preference.
func setMOActive(_ active: Bool)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ReadiumNavigator
let blackAndWhiteComicModeKey: String = "blackAndWhiteComicMode";
let disableSynchronizationKey: String = "disableSynchronization";
let firstElementTopMarginKey: String = "firstElementTopMargin";
let preventMOColumnBreaksKey: String = "preventMOColumnBreaks";

let topMarginCssVariable = "--FLUTTER_READIUM-first-element-top-margin"
let blackAndWhiteComicModeCssVariable = "--FLUTTER_READIUM-black-white-comic-mode";
Expand All @@ -18,6 +19,9 @@ public struct FlutterEPUBPreferences {
/// Top margin to the first element in the content.
/// This is used to create space for UI elements like a toolbar without overlapping the content.
public var firstElementTopMargin: Int?
/// When true (default), prevents paragraph elements from splitting across CSS columns during
/// media-overlay playback. Nil means the Dart-side default (true) applies.
public var preventMOColumnBreaks: Bool?

init() {
readium = EPUBPreferences.init();
Expand All @@ -38,7 +42,11 @@ public struct FlutterEPUBPreferences {
self.firstElementTopMargin = firstElementTopMargin
mutableMap.removeValue(forKey: firstElementTopMarginKey);
}

if let preventMOColumnBreaks = jsonMap[preventMOColumnBreaksKey] as? Bool {
self.preventMOColumnBreaks = preventMOColumnBreaks
mutableMap.removeValue(forKey: preventMOColumnBreaksKey);
}

readium = EPUBPreferences.init(fromMap: mutableMap)
}

Expand Down
Loading
Loading