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
8 changes: 4 additions & 4 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,12 @@
"java.configuration.updateBuildConfiguration": "interactive",
"dart.mcpServer": true,
"chat.tools.terminal.autoApprove": {
"flutter": true,
"dart": true,
"./gradlew": true,
"bin/analyze": true,
"command": true,
"dart": true,
"flutter": true,
"ktlint": true,
"npm run build": true,
"bin/analyze": true
"npm run build": true
}
}
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ When upgrading a toolkit, move all three platforms together where API surface ov

## Build / toolchain facts

- Dart SDK: `>=3.8.0 <4.0.0`. Flutter version pinned in `.flutter-version` (synced to pubspecs via `bin/update_flutter_version`).
- Dart SDK: `>=3.8.0 <4.0.0`, Flutter `>=3.32.0` (pinned in `.flutter-version`, synced to pubspecs via `bin/update_flutter_version`).
- Android: `minSdkVersion 24`, `compileSdk 36`, Kotlin 2.3.21, AGP 8.13.2, Java 18 source/target.
- iOS: requires `use_frameworks!` and `use_modular_headers!` in consuming `Podfile` (see top-level `README.md`).
- Web: webpack 5, TypeScript 5.7+.
Expand Down
5 changes: 5 additions & 0 deletions flutter_readium/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

- **Extra JS/CSS injection** — `FlutterReadium().setJavaScriptInjections(List<InjectionAsset>)`
and `FlutterReadium().setCssInjections(List<InjectionAsset>)` register additional JavaScript
and CSS assets to inject into every EPUB HTML resource alongside the
built-in `flutterReadiumTools.js` / `flutterReadiumTools.css`. Supported on iOS and Android.
Call before opening a publication so the injections are active when the reader view is created.
- **EPUB image tap** — tapping an image in an EPUB now fires `onImageTapped`
with an `ImageTapEvent` carrying the publication-relative `href`, optional
`alt` / `caption`, on-screen `rect`, and pixel dimensions. Detection runs on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,35 @@ internal class PublicationMethodCallHandler : MethodChannel.MethodCallHandler {
return Try.success(null)
}

"setAudioRecoveryPolicy" -> {
val args = arguments as? Map<*, *>
ReadiumReader.audioRecoveryPolicy = AudioRecoveryPolicy.fromMap(args)
"setAudioRecoveryPolicy" -> {
val args = arguments as? Map<*, *>
ReadiumReader.audioRecoveryPolicy = AudioRecoveryPolicy.fromMap(args)
return Try.success(null)
}

"setCssInjections" -> {
@Suppress("UNCHECKED_CAST")
val items = arguments as? List<Map<String, Any?>> ?: emptyList()
ReadiumReader.cssInjections =
items.map { map ->
InjectionAsset(
assetPath = map["assetPath"] as String,
packageName = map["package"] as? String,
)
}
return Try.success(null)
}

"setJavaScriptInjections" -> {
@Suppress("UNCHECKED_CAST")
val items = arguments as? List<Map<String, Any?>> ?: emptyList()
ReadiumReader.javaScriptInjections =
items.map { map ->
InjectionAsset(
assetPath = map["assetPath"] as String,
packageName = map["package"] as? String,
)
}
return Try.success(null)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,19 +131,41 @@ fun decorationStyleFromMap(decoMap: Map<*, *>?): Decoration.Style? {
}
}

private const val READIUM_FLUTTER_PATH_PREFIX =
"https://readium_assets/flutter_assets/packages/flutter_readium"
private const val FLUTTER_ASSETS_BASE = "https://readium_assets/flutter_assets"

private const val INJECT_START_MARKER = "<!-- flutter_readium:start -->"
private const val INJECT_END_MARKER = "<!-- flutter_readium:end -->"

/** A Flutter asset (JS or CSS) to inject into every EPUB HTML resource. */
data class InjectionAsset(
val assetPath: String,
val packageName: String?,
) {
val assetUrl: String
get() =
if (packageName != null) {
"$FLUTTER_ASSETS_BASE/packages/$packageName/$assetPath"
} else {
"$FLUTTER_ASSETS_BASE/$assetPath"
}
}

private val BUILT_IN_INJECTIONS =
listOf(
InjectionAsset("assets/helpers/flutterReadiumTools.js", "flutter_readium"),
InjectionAsset("assets/helpers/flutterReadiumTools.css", "flutter_readium"),
)

// Helper for injecting extra files into an epub.
fun Resource.injectScriptsAndStyles(
tocIds: List<String>,
epubPreferences: FlutterEpubPreferences?,
extraInjections: List<InjectionAsset> = emptyList(),
): Resource =
TransformingResource(this) { bytes ->
val props = this.properties().getOrNull()
val filename = props?.filename ?: return@TransformingResource Try.success(bytes)

// Skip all non-html files
if (!filename.endsWith("html", ignoreCase = true)) {
return@TransformingResource Try.success(bytes)
}
Expand All @@ -155,37 +177,26 @@ fun Resource.injectScriptsAndStyles(
return@TransformingResource Try.success(bytes)
}

val injectStyle = epubPreferences?.toInjectableStyleSheet()
val assetLines =
(BUILT_IN_INJECTIONS + extraInjections).mapNotNull { injection ->
when {
injection.assetPath.endsWith(".js", ignoreCase = true) -> {
"""<script type="text/javascript" src="${injection.assetUrl}"></script>"""
}

if (content.take(headEndIndex).contains(READIUM_FLUTTER_PATH_PREFIX)) {
injectStyle?.let {
if (!content.contains(it)) {
PluginLog.d(
TAG,
"Scripts already loaded for $filename, but custom css needs to be updated.",
)
return@TransformingResource Try.success(
content
.replace(
"</head>",
"$it</head>",
true,
).toByteArray(),
)
injection.assetPath.endsWith(".css", ignoreCase = true) -> {
"""<link rel="stylesheet" type="text/css" href="${injection.assetUrl}"></link>"""
}

else -> {
null
}
}
}

PluginLog.d(TAG, "Skip injecting - already done for: $filename")
return@TransformingResource Try.success(bytes)
}

PluginLog.d(TAG, "Injecting files into: $filename")

val injectLines =
listOf(
"""<script type="text/javascript" src="$READIUM_FLUTTER_PATH_PREFIX/assets/helpers/flutterReadiumTools.js"></script>""",
"""<link rel="stylesheet" type="text/css" href="$READIUM_FLUTTER_PATH_PREFIX/assets/helpers/flutterReadiumTools.css"></link>""",
"""<script type="text/javascript">
val injectStyle = epubPreferences?.toInjectableStyleSheet()
val platformScript =
"""<script type="text/javascript">
const isAndroid = true;
const isIos = false;
window.readiumTocIDs = ${jsonEncode(tocIds)};
Expand All @@ -196,13 +207,36 @@ fun Resource.injectScriptsAndStyles(
};
</script>
$injectStyle
""",
)
"""

val allLines = assetLines + listOf(platformScript) + listOfNotNull(injectStyle)
val newBlock = "$INJECT_START_MARKER\n${allLines.joinToString("\n")}\n$INJECT_END_MARKER"

val startIdx = content.indexOf(INJECT_START_MARKER)
if (startIdx != -1) {
val endIdx = content.indexOf(INJECT_END_MARKER, startIdx)
if (endIdx == -1) {
PluginLog.w(TAG, "Injection start marker found without end marker in: $filename")
} else {
val existingBlock = content.substring(startIdx, endIdx + INJECT_END_MARKER.length)
if (existingBlock == newBlock) {
PluginLog.d(TAG, "Skip injecting - no changes for: $filename")
return@TransformingResource Try.success(bytes)
}
PluginLog.d(TAG, "Replacing injection block for: $filename")
val newContent =
content.substring(0, startIdx) +
newBlock +
content.substring(endIdx + INJECT_END_MARKER.length)
return@TransformingResource Try.success(newContent.toByteArray())
}
}

PluginLog.d(TAG, "Injecting files into: $filename")
val newContent =
StringBuilder(content)
.insert(headEndIndex, "\n" + injectLines.joinToString("\n") + "\n")
.insert(headEndIndex, "\n$newBlock\n")
.toString()

Try.success(newContent.toByteArray())
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ private const val TAG = "ReadiumReader"
private val HTTP_CONNECT_TIMEOUT = 10.seconds
private val HTTP_READ_TIMEOUT = 30.seconds

private val stateKey = "dk.nota.flutterreadium.ReadiumReaderState"
private const val stateKey = "dk.nota.flutterreadium.ReadiumReaderState"

private val currentPublicationUrlKey = "currentPublicationUrl"
private val ttsEnabledKey = "ttsEnabled"
Expand Down Expand Up @@ -506,6 +506,12 @@ object ReadiumReader :
/** Selection actions configured from Dart. Used by EpubReaderFragment to build ActionMode menu. */
var selectionActions: List<SelectionActionConfig> = emptyList()

/** Extra CSS assets injected alongside the built-in helpers. */
var cssInjections: List<InjectionAsset> = emptyList()

/** Extra JavaScript assets injected alongside the built-in helpers. */
var javaScriptInjections: List<InjectionAsset> = emptyList()

private val context: Context
get() = application.applicationContext

Expand Down Expand Up @@ -694,7 +700,11 @@ object ReadiumReader :
val epubPreferences =
navigator.preferences?.effectiveForLayout(publication.metadata.layout)
if (url.extension?.value?.endsWith("html", ignoreCase = true) == true) {
resource.injectScriptsAndStyles(tocIds, epubPreferences)
resource.injectScriptsAndStyles(
tocIds,
epubPreferences,
javaScriptInjections + cssInjections,
)
} else {
resource
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ import MediaPlayer
import ReadiumNavigator
import ReadiumShared

/// A Flutter asset (JS or CSS) to inject into every EPUB HTML resource.
struct InjectionAsset {
let assetPath: String
let packageName: String?

init(assetPath: String, packageName: String? = nil) {
self.assetPath = assetPath
self.packageName = packageName
}

init(from map: [String: Any?]) {
assetPath = map["assetPath"] as! String
packageName = map["package"] as? String
}
}

/// Reports resource read failures during publication open (audio streaming
/// errors are otherwise swallowed inside upstream AudioNavigator — no handler
/// set means no-op). Module scope: installed in `openPublication` before the
Expand All @@ -20,6 +36,12 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin
public var currentPublication: Publication?
public var currentReaderView: (any ReadiumReaderView)?

/// Extra CSS assets injected alongside the built-in helpers.
var cssInjections: [InjectionAsset] = []

/// Extra JavaScript assets injected alongside the built-in helpers.
var javaScriptInjections: [InjectionAsset] = []

/// Incremented each time a new publication is successfully opened.
/// Used to guard against stale `closePublication` calls from a previous
/// Dart session (hot restart) clobbering a freshly opened publication.
Expand Down Expand Up @@ -200,6 +222,14 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin
}
}
}
case "setCssInjections":
let items = call.arguments as? [[String: Any?]] ?? []
self.cssInjections = items.map { InjectionAsset(from: $0) }
result(nil)
case "setJavaScriptInjections":
let items = call.arguments as? [[String: Any?]] ?? []
self.javaScriptInjections = items.map { InjectionAsset(from: $0) }
result(nil)
case "setCustomHeaders":
guard let args = call.arguments as? [String: Any],
let httpHeaders = args["httpHeaders"] as? [String: String] else {
Expand Down
11 changes: 11 additions & 0 deletions flutter_readium/lib/flutter_readium.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ class FlutterReadium {
_platform.setDefaultPreferences(preferences);
}

/// Registers extra CSS assets to inject into every EPUB HTML resource,
/// in addition to the built-in `flutterReadiumTools.css`.
/// Call before opening a publication so the injections are active when the reader view is created.
Future<void> setCssInjections(List<InjectionAsset> injections) => _platform.setCssInjections(injections);

/// Registers extra JavaScript assets to inject into every EPUB HTML resource,
/// in addition to the built-in `flutterReadiumTools.js`.
/// Call before opening a publication so the injections are active when the reader view is created.
Future<void> setJavaScriptInjections(List<InjectionAsset> injections) =>
_platform.setJavaScriptInjections(injections);

/// Loads a publication from the given URL and returns a [Publication] object representing its metadata and structure. This does not open the publication for reading.
Future<Publication> loadPublication(String pubUrl) => _readiumCall(() => _platform.loadPublication(pubUrl));

Expand Down
9 changes: 9 additions & 0 deletions flutter_readium/test/flutter_readium_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,15 @@ class MockFlutterReadiumPlatform with MockPlatformInterfaceMixin implements Flut

AudioRecoveryPolicy? lastAudioRecoveryPolicy;

@override
Future<void> setCssInjections(List<InjectionAsset> injections) async {}

@override
Future<void> setJavaScriptInjections(List<InjectionAsset> injections) async {}

@override
Future<Uint8List> getResourceBytes(String href) async => Uint8List(0);

@override
Future<void> setAudioRecoveryPolicy(AudioRecoveryPolicy policy) async {
lastAudioRecoveryPolicy = policy;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ abstract class FlutterReadiumPlatform extends PlatformInterface {
/// Sets the log verbosity of the plugin's internal logging system, for both Dart and native code.
Future<void> setLogLevel(LogLevel level) => throw UnimplementedError('setLogLevel() has not been implemented.');

/// Registers extra CSS assets to inject into every EPUB HTML resource,
/// in addition to the built-in `flutterReadiumTools.css`.
/// Call before opening a publication so the injections are in effect when the reader view is created.
Future<void> setCssInjections(List<InjectionAsset> injections) =>
throw UnimplementedError('setCssInjections() has not been implemented.');

/// Registers extra JavaScript assets to inject into every EPUB HTML resource,
/// in addition to the built-in `flutterReadiumTools.js`.
/// Call before opening a publication so the injections are in effect when the reader view is created.
Future<void> setJavaScriptInjections(List<InjectionAsset> injections) =>
throw UnimplementedError('setJavaScriptInjections() has not been implemented.');

/// Configures the automatic audio-stream error recovery loop (retry attempts,
/// backoff, and stall detection).
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,22 @@ class MethodChannelFlutterReadium extends FlutterReadiumPlatform {
ReadiumLog.setLevel(level);
}

@override
Future<void> setCssInjections(List<InjectionAsset> injections) async {
await methodChannel.invokeMethod<void>(
'setCssInjections',
injections.map((e) => e.toJson()).toList(),
);
}

@override
Future<void> setJavaScriptInjections(List<InjectionAsset> injections) async {
await methodChannel.invokeMethod<void>(
'setJavaScriptInjections',
injections.map((e) => e.toJson()).toList(),
);
}

@override
Future<void> setAudioRecoveryPolicy(AudioRecoveryPolicy policy) async {
await methodChannel.invokeMethod<void>('setAudioRecoveryPolicy', policy.toJson());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export 'epub.dart';
export 'guided_navigation.dart';
export 'injection_asset.dart';
export 'mediatype.dart';
export 'opds.dart';
export 'publication.dart';
Loading
Loading