Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
93c12d9
chore(web): upgrade @readium/* toolkit to 2.5.x
ddfreiling May 23, 2026
d05c475
feat(web): implement AudioNavigator, TTS engine, and Media Overlay
ddfreiling May 23, 2026
333d375
chore(web): collapse webpack bundle into single readiumReader.js
ddfreiling May 23, 2026
dad1435
fix(web): correct platform-interface bugs surfaced during web testing
ddfreiling May 23, 2026
06164cc
feat(example): add seek controls and extend web manifest list
ddfreiling May 23, 2026
aeb1961
chore(vscode): add web example launch configuration
ddfreiling May 23, 2026
5c8a2ea
chore(web): allow loading local files and update remote manifest URLs
ddfreiling May 23, 2026
c179cf7
fix: update FlutterReadiumWebPlugin to latest platform interface
ddfreiling May 23, 2026
baba672
chore: hide text settings not relevant on web
ddfreiling May 23, 2026
e467843
chore: update Epub preferences for web
ddfreiling May 23, 2026
43f7f66
chore(web): support syncEnabled in ttsNavigator
ddfreiling May 23, 2026
7166550
fix: debounce textLocator emission from epubNavigator
ddfreiling May 23, 2026
c415a36
fix(web): translation map parsing
ddfreiling May 23, 2026
e94d607
feat(web): add ToC enrich and skip to epubNavigator
ddfreiling May 23, 2026
10f722c
fix(web): parsing of sync-narr alternate items
ddfreiling May 23, 2026
42cfe10
chore(web): minor preferences update
ddfreiling May 23, 2026
2ed29a8
feat(web): enable audiobooks even when no visual container present
ddfreiling May 23, 2026
91e9944
fix(web): temp patch to bug in @readium/shared
ddfreiling May 23, 2026
88471a8
chore: some changelogs from Claude, not sure how relevant
ddfreiling May 23, 2026
98fb868
feat(web): implement goToProgression
ddfreiling May 23, 2026
9a99b59
chore: format
ddfreiling May 23, 2026
f836b1b
chore: implement valuable refactors from old PR 39 (draft)
ddfreiling May 23, 2026
eb86305
fix: MediaOverlayNavigator should use upstream serialize() methods
ddfreiling May 23, 2026
1144729
chore: AudioNavigator should auto-advance to next track
ddfreiling May 23, 2026
7e8e165
refactor: findLinkByHref helper
ddfreiling May 23, 2026
4d5c2ec
fix(web): audio navigator now advances and emits state correctly
ddfreiling May 23, 2026
2c37903
feat(web): improve logs and format
ddfreiling May 24, 2026
fe9c2c8
chore: apply log format to rest of plugin library code
ddfreiling May 24, 2026
c4f692f
ci: make web platform run integration tests with its own fixtures (re…
ddfreiling May 24, 2026
dc06298
chore(web): further logs at high-risk areas
ddfreiling May 24, 2026
a6e7550
fix(web): apply patch to upstream bug properly
ddfreiling May 24, 2026
b4ad074
fix(web): TTS now starts playing correctly
ddfreiling May 24, 2026
6b770ce
chore: note for web integration test
ddfreiling May 24, 2026
07f5d10
chore: claude hint on architecture
ddfreiling May 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,38 @@ jobs:
disable-animations: true
working-directory: flutter_readium/example
script: flutter test integration_test

web:
name: Web (ChromeDriver)
runs-on: ubuntu-latest
timeout-minutes: 20
continue-on-error: true # allowed to fail for now

steps:
- uses: actions/checkout@v6

- uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
channel: stable
flutter-version-file: .flutter-version
cache: true

- name: Install Dart dependencies
run: flutter pub get
working-directory: flutter_readium/example

- name: Install ChromeDriver
uses: nanasess/setup-chromedriver@42cc2998329f041de87dc3cfa33a930eacd57571 # v2.2.2

- name: Start ChromeDriver
run: |
chromedriver --port=4444 &
sleep 2

- name: Run web integration tests
working-directory: flutter_readium/example
run: |
flutter drive \
--driver=test_driver/integration_test.dart \
--target=integration_test/plugin_integration_test.dart \
-d chrome
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,4 @@ assets/_scripts/dist
**/.build/
flutter_readium/example/devtools_options.yaml
tmp
.chromedriver/
10 changes: 10 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@
"flutterMode": "debug",
"preLaunchTask": "build_helper_scripts"
},
{
"name": "example (web)",
"cwd": "flutter_readium/example",
"program": "lib/main.dart",
"request": "launch",
"type": "dart",
"deviceId": "chrome",
"flutterMode": "debug",
"preLaunchTask": "build_web"
},
{
"name": "example (profile mode)",
"cwd": "flutter_readium/example",
Expand Down
8 changes: 8 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
"problemMatcher": [],
"label": "build_helper_scripts",
"detail": "cross-env NODE_ENV=production IS_FLUTTER=1 webpack"
},
{
"label": "build_web",
"type": "shell",
"command": "${workspaceFolder}/bin/update_web_example",
"group": "build",
"problemMatcher": [],
"detail": "Build TypeScript bundle and copy readiumReader.js to example/web/"
}
]
}
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ The native sides are thin wrappers around upstream Readium code — when debuggi

When you need to inspect upstream implementation details (e.g. how a navigator handles a locator, what fields a model uses), read the source on GitHub — do NOT decompile local JARs, .framework bundles, or other build artifacts. Use `gh api` or `WebFetch` against the repos above.

If unsure about plugin architecture, be sure to read the README.md files, /docs/architecture.md and /docs/api-reference files.

Voice data for TTS comes from https://github.com/readium/speech (refreshed by `bin/update_readium_voice_data`).

When upgrading any toolkit version, check that all three platforms move together where API surface overlaps — divergence between platforms is a recurring source of bugs. Keep the build/package files above as the source-of-truth, and avoid duplicating exact version numbers broadly in docs.
Expand Down
78 changes: 78 additions & 0 deletions bin/integration_test_web
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/bin/bash
# Installs ChromeDriver (matching the local Chrome version), starts it, runs
# web integration tests, then cleans up.
#
# Usage:
# bin/integration_test_web
#
# Requirements: npx, flutter, Google Chrome installed

set -e

PROJECT_ROOT=$(cd "$(dirname "$0")/.." && pwd)
EXAMPLE_DIR="$PROJECT_ROOT/flutter_readium/example"
CHROMEDRIVER_DIR="$PROJECT_ROOT/.chromedriver"

cleanup() {
if [ -n "$CHROMEDRIVER_PID" ]; then
echo "Stopping ChromeDriver (PID $CHROMEDRIVER_PID)..."
kill "$CHROMEDRIVER_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT

# Detect installed Chrome major version
if [ "$(uname)" == "Darwin" ]; then
CHROME_VERSION=$("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --version 2>/dev/null | grep -oE '[0-9]+' | head -1)
else
CHROME_VERSION=$(google-chrome --version 2>/dev/null | grep -oE '[0-9]+' | head -1)
fi

if [ -z "$CHROME_VERSION" ]; then
echo "ERROR: Could not detect Chrome version. Is Google Chrome installed?"
exit 1
fi
echo "==> Detected Chrome major version: $CHROME_VERSION"

# Install matching ChromeDriver
echo "==> Installing ChromeDriver for Chrome $CHROME_VERSION..."
mkdir -p "$CHROMEDRIVER_DIR"
INSTALL_OUTPUT=$(npx --yes @puppeteer/browsers install "chromedriver@$CHROME_VERSION" --path "$CHROMEDRIVER_DIR" 2>&1)
echo "$INSTALL_OUTPUT"

# Extract the binary path from install output (format: "chromedriver@<version> <path>")
CHROMEDRIVER_BIN=$(echo "$INSTALL_OUTPUT" | grep -oE '/.*chromedriver$' | head -1)
if [ -z "$CHROMEDRIVER_BIN" ]; then
# Fallback: find it in the cache dir
CHROMEDRIVER_BIN=$(find "$CHROMEDRIVER_DIR" -name chromedriver -type f | head -1)
fi

if [ -z "$CHROMEDRIVER_BIN" ] || [ ! -x "$CHROMEDRIVER_BIN" ]; then
echo "ERROR: Could not find chromedriver binary after install."
echo "Install output was: $INSTALL_OUTPUT"
exit 1
fi

echo "==> Using ChromeDriver at: $CHROMEDRIVER_BIN"

# Start ChromeDriver in the background
"$CHROMEDRIVER_BIN" --port=4444 &
CHROMEDRIVER_PID=$!
sleep 2

if ! kill -0 "$CHROMEDRIVER_PID" 2>/dev/null; then
echo "ERROR: ChromeDriver failed to start."
exit 1
fi
echo "==> ChromeDriver running on port 4444 (PID $CHROMEDRIVER_PID)"

# Run the web integration tests
# Note: --test-arguments --report=expanded is needed to see the full test output in the console
echo "==> Running web integration tests..."
cd "$EXAMPLE_DIR"
flutter drive \
--driver=test_driver/integration_test.dart \
--target=integration_test/plugin_integration_test.dart \
-d chrome \
--no-headless \
--test-arguments "test --reporter=expanded"
125 changes: 125 additions & 0 deletions flutter_readium/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,131 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

- **Web: structured console logging** — all web TS modules now log through a
tagged logger (`[Readium/<Module>] LEVEL: message`) with runtime level control.
The `setLogLevel` interface method now propagates to the JS bundle so web
logging verbosity is controlled from Dart alongside the native platforms.
- **Dart: tagged logging (`TaggedReadiumLog`)** — new `ReadiumLog.tag('Name')`
factory creates child loggers named `flutter_readium.<Name>`, surfacing the
source/area in log records (e.g. `[INFO] flutter_readium.WebPlugin: ...`).

### Fixed

- **Web: TTS read-aloud not starting** — extended the `@readium/shared` 2.2.0
patch to also fix the compiled `dist/` bundles (`index.js` and `index.umd.cjs`).
The previous patch only modified the `src/` TypeScript file, which webpack
never consumes (the package's `module`/`main` entries point to `dist/`), so
the `PublicationContentIterator.loadIteratorAt` bug (missing `return`)
remained in the shipped bundle and `hasNext()` always returned false.
- **Web: Media Overlay audio playback crash** — fixed "Failed to create new
Audiobook manifest" error when starting Media Overlay playback. The synthetic
manifest was built using `JSON.stringify` on class instances (producing invalid
RWPM JSON); now uses the proper `Manifest.serialize()` / `Link.serialize()` API.
- **Web: Audio does not advance to next track** — two issues fixed: (1)
AudioNavigator's `autoPlay` preference was hardcoded to `false`, overriding the
default and preventing auto-advance; now passes `null` so the `true` default
takes effect. (2) The `trackEnded` listener unconditionally emitted "ended" state
to Dart on every track boundary, causing the example app's player bloc to close
the session. Now only emits "ended" on the final track.
- **Web: Audio requires pressing play twice** — `audioEnable` and `play` with a
`fromLocator` called `go()` to seek without starting playback first. Since the
navigator wasn't playing, `go()`'s `wasPlaying` check was `false` and it never
resumed after the seek. Now calls `play()` before `go()` so the play-intent is
captured and playback resumes automatically after seeking.

### Added

- **Web: `goToProgression` implemented** — navigates to an absolute progression
(0.0–1.0) on the web platform. Supports EPUB (position-list lookup), audiobook
(seek to `progression × duration`), and Media Overlay content types.
- **Web: `EPUBPreferences.disableSynchronization` honored** — when set, the web
TTS engine no longer scrolls the visual navigator on each utterance, matching the
native (iOS / Android) behaviour. Plumbed through `ReadiumReader.setEPUBPreferences`
and the new `WebTTSEngine` constructor.
- **Web: TTS (text-to-speech)** — `ttsEnable`, `ttsGetAvailableVoices`, `ttsSetVoice`,
`ttsSetPreferences` are now implemented on web using the browser's `SpeechSynthesis` API
and `@readium/shared`'s `PublicationContentIterator` + `HTMLResourceContentIterator` for
paragraph-level text extraction. Playback state streams through
`onTimebasedPlayerStateChanged` and position bookmarks through `onTextLocatorChanged`.
Voice gender/quality is enriched automatically via the bundled `voices.json` from
https://readium.org/speech/. `play`, `pause`, `resume`, `stop`, `next`, `previous` are
dispatched to the TTS engine when it is active, falling back to AudioNavigator otherwise.
Visual word/sentence highlighting is deferred until ts-toolkit PR #209 (Decorator API)
merges.
- **Web: Media Overlay (Sync Narration)** — EPUBs with embedded Sync Narration JSON
alternates (`application/vnd.readium.narration+json`) can now play their synchronized
narration. Calling `audioEnable()` on such a publication parses the narration, builds a
synthetic audio reading order, and drives `AudioNavigator`. Audio time is mapped back to
text locators, so `onTextLocatorChanged` emits text-href locators as narration advances
(matching iOS/Android `reachedLocator` behaviour). `audioSeekBy` is also wired up via
`AudioNavigator.jump()`.
- **Web: `audioSeekBy`** — `audioSeekBy(Duration offset)` is now implemented on web for
audiobook and Media Overlay playback via `AudioNavigator.jump(seconds)`.
- **Web: Audio Navigator** — audiobook publications now play on web. `audioEnable`,
`play`, `pause`, `resume`, `stop`, `next`, `previous`, `audioSetPreferences` are all
wired up via the upstream `AudioNavigator` (ts-toolkit 2.4.0+). Playback state
(offset, duration, locator) streams through `onTimebasedPlayerStateChanged`, matching
the iOS/Android contract.
- **Web: `scrollPaddingLeft` / `scrollPaddingRight` EPUB preferences** — new fields
added in ts-toolkit 2.5.x are now passed through to the navigator.
- **Web: content-protection, peripheral, and context-menu listener stubs** — new
required listener fields from ts-toolkit 2.3.0 are now present on both EPUB and
WebPub navigator configurations.

### Fixed

- **Web: `setEPUBPreferences` no longer wipes existing preferences** — the converter
now emits only fields the Dart caller explicitly set, leaving the navigator's
prior preferences untouched on merge. (Previously, building a `new EpubPreferences(...)`
initialised every field to `null` via the toolkit's `ensure*` guards, and the
navigator's `merging()` does not skip `null` — only `undefined`. The submit was
also extracted from `nav.submitPreferences` into a free variable, which stripped
the `this` binding — that's now a method call.)
- **Web: `goToProgression` and `searchInPublication` no longer throw** — both now have
dummy implementations on web that log a debug message and return a safe default.
- **Web: `goBackward` / `goForward` no longer error with `is not a function`** — the
JS-side `ReadiumReader` was missing the progression-aware navigation methods that
Dart's `JsPublicationChannel` calls.
- **Web: TOC link navigation now works** — `ReadiumReader.goTo` searches `readingOrder`
before `resources`. Chapter links from the example's TOC page point into reading
order, so the previous resources-only lookup always failed.
- **Web: `onTextLocatorChanged.locations.tocHref` is populated** — the EPUB navigator
enriches each emitted locator with the current chapter's TOC href, matching the
iOS / Android contract. Unblocks chapter-skip features on the consumer side.
- **Web: `onTextLocatorChanged` no longer floods consumers during scroll** — text-locator
events are now trailing-edge debounced at 250 ms inside the EPUB navigator listener.
In scroll mode the ts-toolkit emits ~60 events/sec; this matches the per-page cadence
of the iOS/Android plugins.
- **Example: EPUB Settings popover now dismisses when tapping outside (web)** — the
default `showModalBottomSheet` barrier sits behind the iframe on Flutter web, so
taps fell through. The route now provides its own `PointerInterceptor`-backed barrier.
- **Web: opening a Media Overlay (Sync Narration) WebPub no longer throws** — Sync
Narration detection treated `link.alternates` as a plain array, but `@readium/shared`
exposes it as a `Links` instance (`.items` array + helper methods). Opening a
publication with media overlays now uses `Links.findWithMediaType` / `Links.items`
instead of `.find()`, eliminating `TypeError: alternates.find is not a function`.
The same Links-vs-array bug was silently dropping nested TOC children in
`flattenToc`; nested TOC entries are now flattened correctly.

### Changed

- **Web: EPUB preferences mapping cleanup** — `epubPreferences.ts` now mirrors the Dart
`EPUBPreferences` shape (one preference per Dart field), with documented conversions:
`columnCount` enum (auto/one/two) → `number | null`, `imageFilter` enum (darken/invert)
→ `darkenFilter` / `invertFilter`, and `fontSize` divided by 100 to match the iOS
plugin (Dart `120` → web `1.2`). Live updates and initialization now go through the
same conversion. Dart fields the web navigator can't honor (`publisherStyles`,
`readingProgression`, `spread`, `typeScale`, `verticalText`, `language`,
`blackAndWhiteComicMode`, `firstElementTopMargin`) are dropped with inline rationale.
- **Example: EPUB settings popover hides web-unsupported controls** — Reading Direction,
Publisher Styles, and B&W Comic Mode toggles are now wrapped in `kIsWeb` so they
only render on native platforms where they actually have an effect.
- **Web: ts-toolkit version bump** — `@readium/navigator` `^2.2.4` → `^2.5.5`,
`@readium/navigator-html-injectables` `^2.2.1` → `^2.4.2`,
`@readium/shared` `^2.1.1` → `^2.2.0`. Picks up FXL `positionChanged` reliability
fix (navigator #218), vertical/RTL writing-mode support, Readium CSS v2.0.0, and
content-protection infrastructure.

- **Text selection callback** — `ReadiumReaderWidget.onTextSelected` fires a
`TextSelectionEvent` (locator + selected text) when the user selects text in the reader.
Supported on iOS, Android, and Web.
Expand Down
7 changes: 5 additions & 2 deletions flutter_readium/example/assets/webManifestList.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
[
"https://merkur.live.dbb.dk/opds2/publication/free/merkur:libraryid:37881/manifest.json",
"/test-audiobook/manifest.json",
"https://readium.org/css/docs/manifest.json",
"https://readium.org/webpub-manifest/examples/MobyDick/manifest.json",
"https://publication-server.readium.org/webpub/Z3M6Ly9yZWFkaXVtLXBsYXlncm91bmQtZmlsZXMvZGVtby9sZXNfZGlhYm9saXF1ZXMuZXB1Yg/manifest.json",
"https://publication-server.readium.org/webpub/Z3M6Ly9yZWFkaXVtLXBsYXlncm91bmQtZmlsZXMvZGVtby9CZWxsYU9yaWdpbmFsMy5lcHVi/manifest.json",
"https://publication-server.readium.org/webpub/Z3M6Ly9yZWFkaXVtLXBsYXlncm91bmQtZmlsZXMvZGVtby9uYXRoYW5pZWwtaGF3dGhvcm5lX3RoZS1ob3VzZS1vZi10aGUtc2V2ZW4tZ2FibGVzX2FkdmFuY2VkLmVwdWI/manifest.json",
"https://publication-server.readium.org/webpub/Z3M6Ly9yZWFkaXVtLXBsYXlncm91bmQtZmlsZXMvZGVtby9tb2xseS1ob3BwZXItdjEuMS53ZWJwdWI/manifest.json"
"https://publication-server.readium.org/webpub/Z3M6Ly9yZWFkaXVtLXBsYXlncm91bmQtZmlsZXMvZGVtby9tb2xseS1ob3BwZXItdjEuMS53ZWJwdWI/manifest.json",
"https://merkur.nota.dk/opds2/publication/free/merkur:libraryid:50272/stream/WebPubText/manifest.json",
"https://merkur.nota.dk/opds2/publication/free/merkur:libraryid:50791/stream/WebPubAudioOnly/manifest.json",
"https://merkur.nota.dk/opds2/publication/free/merkur:libraryid:INSL20260002/stream/WebPubAudio/manifest.json"
]
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@
// and verify the Dart side receives a well-formed [Publication].
//
// These tests exercise the Dart -> native -> Dart contract that pure Dart
// unit tests cannot reach. They run on iOS and Android via the example app.

import 'dart:io';
// unit tests cannot reach. They run on iOS, Android, and Web via the example app.

import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_readium/flutter_readium.dart';
import 'package:flutter_readium_example/utils/publication_utils.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;

import 'test_fixtures.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
Expand All @@ -21,8 +20,7 @@ void main() {
final reader = FlutterReadium();

setUpAll(() async {
final pubs = await PublicationUtils.moveAssetPublicationsToReadiumStorage();
fixturePaths = {for (final pub in pubs) p.basename(pub): pub};
fixturePaths = await loadFixturePaths();
});

// NOTE: Every testWidgets that mounts a ReadiumReaderWidget must end with
Expand Down Expand Up @@ -145,7 +143,7 @@ void main() {
// PDF
// ---------------------------------------------------------------------------

group('PDF navigation and state', () {
group('PDF navigation and state', skip: kIsWeb ? 'PDF not supported on web' : null, () {
test('opens PDF successfully', () async {
final path = fixturePaths['pdf_test.pdf'];
expect(path, isNotNull, reason: 'Fixture pdf_test.pdf missing from asset bundle');
Expand Down Expand Up @@ -414,7 +412,7 @@ void main() {
reason: 'PDF search hit should carry a 1-based page position',
);
},
skip: Platform.isAndroid
skip: kIsWeb || _isAndroid()
? 'PDF text search not supported on Android (kotlin-toolkit has no PDF SearchService)'
: false,
);
Expand All @@ -424,9 +422,13 @@ void main() {
// Error path
// ---------------------------------------------------------------------------

test('openPublication throws ReadiumException for an invalid path', () async {
await expectLater(reader.openPublication('/does-not-exist/no-such.epub'), throwsA(isA<ReadiumException>()));
});
test(
'openPublication throws ReadiumException for an invalid path',
skip: kIsWeb ? 'Error path differs on web (HTTP fetch vs file I/O)' : null,
() async {
await expectLater(reader.openPublication('/does-not-exist/no-such.epub'), throwsA(isA<ReadiumException>()));
},
);

// ---------------------------------------------------------------------------
// EPUB navigation & state
Expand Down Expand Up @@ -792,3 +794,7 @@ Future<void> _waitForListStable<T>(
}
}
}

/// Returns true on Android. Uses [defaultTargetPlatform] which is safe on all
/// platforms (unlike `dart:io` Platform which is unavailable on web).
bool _isAndroid() => defaultTargetPlatform == TargetPlatform.android;
Loading
Loading