From 98e804837154e25e0d5b677d26a164e06d943a40 Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Thu, 17 Sep 2026 00:27:23 +0200 Subject: [PATCH] fix(web): replay an audioEnable that arrives before the publication is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On web a text publication is only opened once `ReadiumWebView` mounts and supplies the `#container` element — `FlutterReadiumWeb.openPublication` calls the JS side only for pure audiobooks. An `audioEnable` issued straight after `openPublication` therefore found no publication, logged "no audiobook or Media Overlay content detected" and returned, and nothing ever retried it: 21:31:40.475 audioEnable 21:31:40.476 WARN audioEnable: no audiobook or Media Overlay content detected 21:31:41.101 Guided Navigation detected Remember such a call and replay it at the end of `openPublication`. It still resolves immediately rather than waiting for the publication. That is required, not merely convenient: the host app awaits `audioEnable` before it routes to the page hosting the reader view, so blocking would leave the open waiting on a reader that is waiting on the open. The replay is awaited inside `openPublication`, so a failure travels the existing open-failure path instead of being lost — the deferred caller can no longer be thrown to. The entry is consumed before replaying (a reader remount or hot restart cannot run it twice) and cleared by `closePublication`. The guard requires both no publication and no audio navigator, so it cannot intercept the pure-audiobook path, where the navigator exists on its own. Co-Authored-By: Claude Opus 5 --- flutter_readium/CHANGELOG.md | 7 ++++ flutter_readium/web/src/ReadiumReader.ts | 29 +++++++++++++ .../web/src/__tests__/ReadiumReader.test.ts | 42 +++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/flutter_readium/CHANGELOG.md b/flutter_readium/CHANGELOG.md index ad3b37e6..d45c310e 100644 --- a/flutter_readium/CHANGELOG.md +++ b/flutter_readium/CHANGELOG.md @@ -7,6 +7,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed +- **Web: audio never started for a text publication with synchronised audio.** On web a + publication is only opened once `ReadiumWebView` mounts and supplies its container element, + so an `audioEnable` issued right after `openPublication` found nothing and gave up silently, + with nothing to retry it. Such a call is now remembered and replayed at the end of + `openPublication`. It still resolves immediately, because the caller awaits it before the + host app routes to the page that hosts the reader view. + - **Guided-navigation playback reported later reading-order positions on iOS and web.** Those platforms converted the matched reading-order index twice, so the second item was emitted as position 3. Locators now apply Readium's 1-based position convention exactly once. diff --git a/flutter_readium/web/src/ReadiumReader.ts b/flutter_readium/web/src/ReadiumReader.ts index 3a574199..3facd223 100644 --- a/flutter_readium/web/src/ReadiumReader.ts +++ b/flutter_readium/web/src/ReadiumReader.ts @@ -110,6 +110,7 @@ class _ReadiumReader { /** True when the current EPUB publication has embedded Sync Narration JSON. */ private _hasSyncNarration = false; private _hasGuidedNavigation = false; + private _pendingAudioEnable?: { prefsJson: string; fromLocatorJson?: string }; /** Parsed sync-narration items for the current MediaOverlay publication. Empty for plain audiobooks. */ private _syncItems: SyncNarrationItem[] = []; /** @@ -454,6 +455,11 @@ class _ReadiumReader { ); } } + + + // Failures fall into the catch below and surface through the existing + // open-failure path, since the deferred caller can no longer be thrown to. + await this._replayDeferredAudioEnable(); } catch (error) { log.error("Failed to open publication:", error); this.closePublication(error); @@ -461,6 +467,15 @@ class _ReadiumReader { } } + private async _replayDeferredAudioEnable(): Promise { + const pending = this._pendingAudioEnable; + if (!pending) return; + // Consume first so a second open (reader remount, hot restart) cannot run it twice. + this._pendingAudioEnable = undefined; + log.info("openPublication: replaying deferred audioEnable"); + await this.audioEnable(pending.prefsJson, pending.fromLocatorJson); + } + public setEPUBPreferences(newPreferencesString: string) { if (!this._nav) { log.error("setEPUBPreferences: navigator is not initialized"); @@ -636,6 +651,7 @@ class _ReadiumReader { this._audioNav = undefined; this._stoppedAudioLocator = undefined; + this._pendingAudioEnable = undefined; this._hasSyncNarration = false; this._hasGuidedNavigation = false; this._syncItems = []; @@ -1191,6 +1207,19 @@ class _ReadiumReader { */ public async audioEnable(prefsJson: string, fromLocatorJson?: string): Promise { log.info("audioEnable"); + if (!this._publication && !this._audioNav) { + // A text publication is only opened once the reader view mounts and supplies + // the #container element, so audioEnable issued right after the Dart-side + // openPublication arrives before there is anything to enable. Remember it and + // replay it at the end of openPublication instead of dropping it. Resolving + // now is required, not just convenient: the caller awaits this before the app + // routes to the page that hosts the reader view, so blocking here would wait + // on a reader that is waiting on us. No matching play() is recorded because + // audioEnable resumes playback itself via _seekAudioAndResume(..., true). + log.info("audioEnable: publication not open yet, deferring until it is"); + this._pendingAudioEnable = { prefsJson, fromLocatorJson }; + return; + } const preferencesJsonString = !prefsJson || prefsJson === "null" ? "{}" : prefsJson; this._activeAudioPreferencesJson = preferencesJsonString; diff --git a/flutter_readium/web/src/__tests__/ReadiumReader.test.ts b/flutter_readium/web/src/__tests__/ReadiumReader.test.ts index 56d8f218..6042badc 100644 --- a/flutter_readium/web/src/__tests__/ReadiumReader.test.ts +++ b/flutter_readium/web/src/__tests__/ReadiumReader.test.ts @@ -365,3 +365,45 @@ describe("audioEnable restore sequencing", () => { expect(audioNav.play).toHaveBeenCalledTimes(1); }); }); + +describe("audioEnable before the publication is open", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("defers the call instead of dropping it, and resolves right away", async () => { + const reader = new ReadiumReader(); + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + + await reader.audioEnable('{"speed":1.5}', undefined); + + expect((reader as any)._pendingAudioEnable).toEqual({ + prefsJson: '{"speed":1.5}', + fromLocatorJson: undefined, + }); + // Must not take the "no audiobook or Media Overlay content detected" exit: + // nothing retries after it, so the book would stay silent forever. + expect(warn).not.toHaveBeenCalled(); + }); + + it("replays the deferred call once the publication is open", async () => { + const reader = new ReadiumReader(); + (reader as any)._pendingAudioEnable = { prefsJson: "{}", fromLocatorJson: undefined }; + const replay = jest.spyOn(reader, "audioEnable").mockResolvedValue(undefined); + + await (reader as any)._replayDeferredAudioEnable(); + + expect(replay).toHaveBeenCalledWith("{}", undefined); + // Consumed, so a reader remount or hot restart cannot run it twice. + expect((reader as any)._pendingAudioEnable).toBeUndefined(); + }); + + it("drops the deferred call when the publication is closed first", () => { + const reader = new ReadiumReader(); + (reader as any)._pendingAudioEnable = { prefsJson: "{}", fromLocatorJson: undefined }; + + withDomGlobals(() => reader.closePublication()); + + expect((reader as any)._pendingAudioEnable).toBeUndefined(); + }); +});