From 53207d71495da7de1f966db26c7f3da0b11bd85c Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Thu, 10 Sep 2026 15:34:04 +0200 Subject: [PATCH 1/2] fix(web): resume audiobook playback after reopen audioEnable fired its restore seek without awaiting it, so the promise resolved immediately and Dart's follow-up play() landed while upstream AudioNavigator.go() still had _isNavigating true. Readium's DOM "play" handler is guarded by that flag, so the event was swallowed: audio ran but position polling never restarted and the player reported paused until the track ended. Await the seek in both "navigator already exists" branches so audioEnable resolves only once navigation has finished. Co-Authored-By: Claude Opus 5 --- flutter_readium/CHANGELOG.md | 7 +++ flutter_readium/web/src/ReadiumReader.ts | 7 ++- .../web/src/__tests__/ReadiumReader.test.ts | 61 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/flutter_readium/CHANGELOG.md b/flutter_readium/CHANGELOG.md index 6b152e36..414832ff 100644 --- a/flutter_readium/CHANGELOG.md +++ b/flutter_readium/CHANGELOG.md @@ -5,6 +5,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Fixed + +- **Web: reopening an audiobook restored the saved position but stayed paused.** `audioEnable` + did not await its restore seek, so the following `play()` arrived while Readium was still + navigating and was silently discarded. Playback appeared frozen until the track ended, then + resumed from the next track. The seek is now awaited before `audioEnable` resolves. + ## [0.4.4] - 2026-09-08 ### Fixed diff --git a/flutter_readium/web/src/ReadiumReader.ts b/flutter_readium/web/src/ReadiumReader.ts index a5a083d9..01a74203 100644 --- a/flutter_readium/web/src/ReadiumReader.ts +++ b/flutter_readium/web/src/ReadiumReader.ts @@ -1194,12 +1194,15 @@ class _ReadiumReader { if (this._syncItems.length > 0) { locator = textLocatorToAudioLocator(this._syncItems, locator) ?? locator; } - this._seekAudioAndResume(locator, true); + // Must await: callers chain play() on this promise, and a play() that + // lands while upstream go() still has _isNavigating true is swallowed + // by its DOM "play" guard, so position polling never restarts. + await this._seekAudioAndResume(locator, true); return; } // Use the safe restart path so polling resumes even when we're already at // the current cue/position (upstream same-position seek quirk). - this._seekAudioAndResume(this._audioNav.currentLocator, true); + await this._seekAudioAndResume(this._audioNav.currentLocator, true); return; } diff --git a/flutter_readium/web/src/__tests__/ReadiumReader.test.ts b/flutter_readium/web/src/__tests__/ReadiumReader.test.ts index 419bffdd..56d8f218 100644 --- a/flutter_readium/web/src/__tests__/ReadiumReader.test.ts +++ b/flutter_readium/web/src/__tests__/ReadiumReader.test.ts @@ -304,3 +304,64 @@ describe("audiobook playback intent", () => { expect(setPlaybackIntent.mock.calls).toEqual([[true], [false], [true]]); }); }); + +describe("audioEnable restore sequencing", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("resolves only after the restore seek finishes", async () => { + jest.spyOn(FlutterAudioNavigator, "setPlaybackIntent").mockImplementation(() => {}); + const reader = new ReadiumReader(); + const target = new Locator({ + href: "track-01.mp3", + type: "audio/mpeg", + locations: new LocatorLocations({ fragments: ["t=5.6"] }), + }); + let releaseGo: (() => void) | undefined; + const audioNav = { + currentLocator: new Locator({ + href: "track-01.mp3", + type: "audio/mpeg", + locations: new LocatorLocations({ fragments: ["t=0"] }), + }), + currentTime: 0, + isPlaying: false, + play: jest.fn(), + pause: jest.fn(), + go: jest.fn( + (_locator: Locator, _animated: boolean, cb: (ok: boolean) => void) => + new Promise((resolve) => { + releaseGo = () => { + cb(true); + resolve(); + }; + }) + ), + }; + (reader as any)._audioNav = audioNav; + + let settled = false; + const enabling = reader + .audioEnable("{}", JSON.stringify(target.serialize())) + .then(() => { + settled = true; + }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(audioNav.go).toHaveBeenCalledTimes(1); + // Must still be pending. Dart calls play() right after audioEnable resolves, + // and a play() that lands while upstream go() is still navigating is + // swallowed, so position polling never restarts and the player looks frozen. + expect(settled).toBe(false); + expect(audioNav.play).not.toHaveBeenCalled(); + + releaseGo!(); + await enabling; + + expect(settled).toBe(true); + expect(audioNav.play).toHaveBeenCalledTimes(1); + }); +}); From 46c5357063b4984274d7ef2fa5587e51e9151287 Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Thu, 10 Sep 2026 15:34:15 +0200 Subject: [PATCH 2/2] fix(web): emit debug logs where flutter drive can capture them flutter drive hardcodes the WebDriver capability goog:loggingPrefs.browser to INFO and offers no flag to change it, so chromedriver discards every console.debug entry before the test driver reads the browser log. A log.debug added while investigating a failure therefore looks like code that never ran. Detect navigator.webdriver and route debug output through console.log under WebDriver, keeping console.debug (and the DevTools "Verbose" filter) in a normal browser. Co-Authored-By: Claude Opus 5 --- .../src/__tests__/ReadiumPluginLogger.test.ts | 66 +++++++++++++++++++ .../web/src/utils/ReadiumPluginLogger.ts | 19 +++++- 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 flutter_readium/web/src/__tests__/ReadiumPluginLogger.test.ts diff --git a/flutter_readium/web/src/__tests__/ReadiumPluginLogger.test.ts b/flutter_readium/web/src/__tests__/ReadiumPluginLogger.test.ts new file mode 100644 index 00000000..877b69b3 --- /dev/null +++ b/flutter_readium/web/src/__tests__/ReadiumPluginLogger.test.ts @@ -0,0 +1,66 @@ +/** + * Regression tests for the logger's debug sink. + * + * `flutter drive` hardcodes the chromedriver browser log level to INFO, so + * console.debug output is dropped before the test driver captures it and a + * debug line looks like code that never ran. Under WebDriver the logger must + * fall back to console.log; everywhere else it must keep console.debug so the + * lines stay under DevTools' "Verbose" filter. + */ + +/** Re-imports the logger with `navigator.webdriver` already set. */ +function loadLogger(webdriver: boolean): typeof import("../utils/ReadiumPluginLogger") { + jest.resetModules(); + Object.defineProperty(globalThis, "navigator", { + value: { webdriver }, + configurable: true, + writable: true, + }); + return require("../utils/ReadiumPluginLogger"); +} + +describe("logger debug sink", () => { + const savedNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + + afterEach(() => { + jest.restoreAllMocks(); + if (savedNavigator) Object.defineProperty(globalThis, "navigator", savedNavigator); + else delete (globalThis as { navigator?: unknown }).navigator; + }); + + it("routes debug through console.log under WebDriver", () => { + const logger = loadLogger(true); + logger.setLogLevel(logger.LogLevel.debug); + const consoleLog = jest.spyOn(console, "log").mockImplementation(() => {}); + const consoleDebug = jest.spyOn(console, "debug").mockImplementation(() => {}); + + logger.createLogger("Test").debug("hello"); + + expect(consoleDebug).not.toHaveBeenCalled(); + expect(consoleLog).toHaveBeenCalledTimes(1); + expect(consoleLog.mock.calls[0][0]).toContain("DEBUG [Readium/Test] hello"); + }); + + it("keeps console.debug in a normal browser", () => { + const logger = loadLogger(false); + logger.setLogLevel(logger.LogLevel.debug); + const consoleLog = jest.spyOn(console, "log").mockImplementation(() => {}); + const consoleDebug = jest.spyOn(console, "debug").mockImplementation(() => {}); + + logger.createLogger("Test").debug("hello"); + + expect(consoleLog).not.toHaveBeenCalled(); + expect(consoleDebug).toHaveBeenCalledTimes(1); + expect(consoleDebug.mock.calls[0][0]).toContain("DEBUG [Readium/Test] hello"); + }); + + it("stays silent below debug level", () => { + const logger = loadLogger(true); + logger.setLogLevel(logger.LogLevel.info); + const consoleLog = jest.spyOn(console, "log").mockImplementation(() => {}); + + logger.createLogger("Test").debug("hello"); + + expect(consoleLog).not.toHaveBeenCalled(); + }); +}); diff --git a/flutter_readium/web/src/utils/ReadiumPluginLogger.ts b/flutter_readium/web/src/utils/ReadiumPluginLogger.ts index 2c62c4d5..c0688101 100644 --- a/flutter_readium/web/src/utils/ReadiumPluginLogger.ts +++ b/flutter_readium/web/src/utils/ReadiumPluginLogger.ts @@ -76,6 +76,16 @@ function inspectables(args: unknown[]): unknown[] { return args.filter((a) => a !== null && typeof a === "object"); } +/** + * True when the page is driven by WebDriver (`flutter drive` + chromedriver). + * + * chromedriver never captures `console.debug`: `flutter drive` hardcodes the + * WebDriver browser log level to INFO, so DEBUG entries are dropped before the + * test driver reads them and a debug line looks like code that never ran. + */ +const _isWebDriver = + typeof navigator !== "undefined" && navigator.webdriver === true; + /** * Each log call passes the fully-formatted message as the FIRST argument, then * re-appends any object/Error args. @@ -91,8 +101,13 @@ export function createLogger(tag: string): Logger { const prefix = `[Readium/${tag}]`; return { debug: (...args) => { - if (_currentLevel >= LogLevel.debug) - console.debug(`DEBUG ${prefix} ${format(args)}`, ...inspectables(args)); + if (_currentLevel >= LogLevel.debug) { + const line = `DEBUG ${prefix} ${format(args)}`; + // console.log is INFO level, so it survives the capture; a real browser + // keeps console.debug and its DevTools "Verbose" filter. + if (_isWebDriver) console.log(line, ...inspectables(args)); + else console.debug(line, ...inspectables(args)); + } }, info: (...args) => { if (_currentLevel >= LogLevel.info)