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
7 changes: 7 additions & 0 deletions flutter_readium/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions flutter_readium/web/src/ReadiumReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
66 changes: 66 additions & 0 deletions flutter_readium/web/src/__tests__/ReadiumPluginLogger.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
61 changes: 61 additions & 0 deletions flutter_readium/web/src/__tests__/ReadiumReader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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);
});
});
19 changes: 17 additions & 2 deletions flutter_readium/web/src/utils/ReadiumPluginLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down