diff --git a/flutter_readium/CHANGELOG.md b/flutter_readium/CHANGELOG.md index 1752028b..7bb0693f 100644 --- a/flutter_readium/CHANGELOG.md +++ b/flutter_readium/CHANGELOG.md @@ -7,6 +7,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed +- **Web: audio may never start 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. - **iOS reader views mounted after `audioEnable` never applied the media-overlay column-break CSS.** The MO-active flag lives on the reader view, so a view created after audio was enabled started with it off and word highlighting could be split 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(); + }); +});