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__/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/__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); + }); +}); 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)