Skip to content
Merged
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
6 changes: 6 additions & 0 deletions flutter_readium/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions flutter_readium/web/src/ReadiumReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
/**
Expand Down Expand Up @@ -454,13 +455,27 @@ 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);
throw error;
}
}

private async _replayDeferredAudioEnable(): Promise<void> {
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");
Expand Down Expand Up @@ -636,6 +651,7 @@ class _ReadiumReader {
this._audioNav = undefined;
this._stoppedAudioLocator = undefined;

this._pendingAudioEnable = undefined;
this._hasSyncNarration = false;
this._hasGuidedNavigation = false;
this._syncItems = [];
Expand Down Expand Up @@ -1191,6 +1207,19 @@ class _ReadiumReader {
*/
public async audioEnable(prefsJson: string, fromLocatorJson?: string): Promise<void> {
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;
Expand Down
42 changes: 42 additions & 0 deletions flutter_readium/web/src/__tests__/ReadiumReader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});