Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f0e81fb
WIP FXL/Divina improvements
chocolatkey Mar 5, 2026
df04a75
shift DOM parsing to readAsXML to allow for customizability in fetcher
chocolatkey Mar 6, 2026
f2fd9c0
make divina images decode before displaying
chocolatkey Mar 6, 2026
b01583d
fix use of aria-hidden
chocolatkey Mar 6, 2026
b394398
use CSS Typed Object Model API for heavy CSS updates
chocolatkey Mar 6, 2026
d4b89be
fix SSR support
chocolatkey Mar 11, 2026
a538120
bump vers
chocolatkey Mar 11, 2026
6bbe3e8
make `links` in manifest optional
chocolatkey Mar 12, 2026
edb310a
improve reflowable loading latency
chocolatkey Mar 12, 2026
efba1fd
bump versions
chocolatkey Mar 13, 2026
9ee58dd
await background resource loading
chocolatkey Mar 13, 2026
cb25ed0
improvements to locator search
chocolatkey Mar 13, 2026
b47d49d
bump vers
chocolatkey Mar 13, 2026
88d50f9
support locator without href for initial location as well
chocolatkey Mar 13, 2026
359e57f
EpubNavigator load should use `go` instead of a direct `apply`
chocolatkey Mar 13, 2026
4495e17
another version due to mistake
chocolatkey Mar 13, 2026
b65e147
WIP scrolled support
chocolatkey May 11, 2026
74433b1
fix failing test
chocolatkey May 19, 2026
dd8371e
fix: bail EpubNavigator.load when destroyed mid-load (#233)
mary-ext Jun 24, 2026
6bb3f04
Merge remote-tracking branch 'origin/develop' into fxl-divina-improve…
chocolatkey Jul 8, 2026
4fa5b3a
FramePoolManager.update race condition for fxl divina improvements (#…
kmsomebody Jul 15, 2026
6fae0c7
fix bug that appears on FF only
chocolatkey Jul 15, 2026
bddfa8c
brand new divina navigator
chocolatkey Jul 17, 2026
e43f6d3
use page number/title for placeholder
chocolatkey Jul 19, 2026
5af05d7
use resource link explicitly for blog mimetype
chocolatkey Jul 19, 2026
1609343
fixes for divina
chocolatkey Jul 19, 2026
d1ed342
change scrolled divina default strip width
chocolatkey Jul 20, 2026
913bdd0
fix for FPM
chocolatkey Jul 21, 2026
5af7642
adjustments to audiobook navigator
chocolatkey Jul 24, 2026
f26996c
audiobook engine work for MSE
chocolatkey Jul 28, 2026
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
2 changes: 2 additions & 0 deletions navigator-html-injectables/src/helpers/css.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { ReadiumWindow } from "./dom.ts";

export const isTypedOMSupported = () => typeof window.CSSTransformValue !== 'undefined';

const COSMETIC_PROPERTIES = new Set([
"backgroundColor", "textColor",
"linkColor", "visitedColor",
Expand Down
25 changes: 22 additions & 3 deletions navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { rangeFromLocator } from "../../helpers/locator.ts";
import { ReadiumWindow, deselect, findFirstVisibleLocator } from "../../helpers/dom.ts";
import { PatternAnalyzer } from "../../protection/PatternAnalyzer.ts";
import { BaseSuspiciousActivityEvent } from "../Peripherals.ts";
import { isTypedOMSupported } from "../../helpers/css.ts";

const COLUMN_SNAPPER_STYLE_ID = "readium-column-snapper-style";

Expand Down Expand Up @@ -167,7 +168,13 @@ export class ColumnSnapper extends Snapper {
const spos = position(currentScrollLeft, so, elapsed, period);
doc.scrollLeft = spos;
if(this.overscroll !== 0)
doc.style.transform = `translate3d(${-lpos}px, 0px, 0px)`;
if(isTypedOMSupported()) {
doc.attributeStyleMap.set("transform", new CSSTransformValue([
new CSSTranslate(CSS.px(-lpos), CSS.px(0), CSS.px(0))
]));
} else {
doc.style.transform = `translate3d(${-lpos}px, 0px, 0px)`;
}

if (elapsed < period)
this.wnd.requestAnimationFrame(step);
Expand Down Expand Up @@ -283,10 +290,22 @@ export class ColumnSnapper extends Snapper {

if(newpos < minScrollLeft) {
this.overscroll = newpos;
this.doc().style.transform = `translate3d(${-this.overscroll}px, 0px, 0px)`;
if(isTypedOMSupported()) {
this.doc().attributeStyleMap.set("transform", new CSSTransformValue([
new CSSTranslate(CSS.px(-newpos), CSS.px(0), CSS.px(0))
]));
} else {
this.doc().style.transform = `translate3d(${-newpos}px, 0px, 0px)`;
}
} else if(newpos > maxScrollLeft) {
this.overscroll = newpos;
this.doc().style.transform = `translate3d(${-newpos}px, 0px, 0px)`;
if(isTypedOMSupported()) {
this.doc().attributeStyleMap.set("transform", new CSSTransformValue([
new CSSTranslate(CSS.px(-newpos), CSS.px(0), CSS.px(0))
]));
} else {
this.doc().style.transform = `translate3d(${-newpos}px, 0px, 0px)`;
}
} else {
this.overscroll = 0;
this.doc().style.removeProperty("transform");
Expand Down
111 changes: 106 additions & 5 deletions navigator/src/audio/AudioNavigator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Link, Locator, LocatorLocations, Publication, Timeline, TimelineItem } from "@readium/shared";
import { MediaNavigator, IContentProtectionConfig, IKeyboardPeripheralsConfig, KeyboardPeripheralEventData } from "../Navigator.ts";
import { Configurable } from "../preferences/Configurable.ts";
import { WebAudioEngine, PlaybackState } from "./engine/index.ts";
import { WebAudioEngine, PlaybackState, AudioMseLoaderFactory } from "./engine/index.ts";
import {
AudioPreferences,
AudioDefaults,
Expand Down Expand Up @@ -69,6 +69,26 @@ export interface AudioNavigatorConfiguration {
defaults: IAudioDefaults;
contentProtection?: IAudioContentProtectionConfig;
keyboardPeripherals?: IKeyboardPeripheralsConfig;

/**
* Called with the persistent playback element before
* the first src is assigned, so the host can prepare it. Use for MSE/EME
* setups. When it returns a promise, loading of
* the initial track (and prefetching of adjacent ones) is deferred until
* the promise settles; a rejection is forwarded to the error listener and
* loading proceeds anyway.
*/
mediaElementSetup?: (element: HTMLMediaElement) => void | Promise<void>;

/**
* When provided, media bytes reach the playback element through Media
* Source Extensions instead of direct `src` assignment: the engine
* creates one loader per track via this factory and the loader owns all
* fetching. Supply one for e.g. EME encrypted audio, since browsers have
* poor support for encrypted audio directly through `src`. The container
* handling (WebM, fMP4, …) is the loader implementation's concern.
*/
mseLoaderFactory?: AudioMseLoaderFactory;
}

export class AudioNavigator extends MediaNavigator implements Configurable<AudioSettings, AudioPreferences> {
Expand All @@ -94,6 +114,8 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
/** True while a track transition is in progress; suppresses spurious mid-navigation events. */
private _isNavigating: boolean = false;
private _isStalled: boolean = false;
/** Set by destroy(); stops the deferred initial load from touching a dead instance. */
private _destroyed: boolean = false;
private _stalledWatchdog: ReturnType<typeof setInterval> | null = null;
private _stalledCheckTime: number = 0;

Expand Down Expand Up @@ -131,9 +153,19 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
}

const initialHref = this.currentLocation.href.split("#")[0];
const trackIndex = this.hrefToTrackIndex(initialHref);
let trackIndex = this.hrefToTrackIndex(initialHref);
if (trackIndex === -1) {
throw new Error(`AudioNavigator: initial href "${ initialHref }" not found in reading order`);
// Progression-only locators (e.g. restored from an OPDS progression)
// Resolve totalProgression against the
// cumulative track durations instead of failing outright.
const totalProgression = this.currentLocation.locations?.totalProgression;
if (totalProgression !== undefined) {
const resolved = this.locatorFromTotalProgression(totalProgression);
this.currentLocation = resolved.locator;
trackIndex = resolved.trackIndex;
} else {
throw new Error(`AudioNavigator: initial href "${ initialHref }" not found in reading order`);
}
}
const initialTime = this.currentLocation.locations?.time() || 0;

Expand All @@ -145,7 +177,8 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
} as PlaybackState,
playWhenReady: false,
index: trackIndex
}
},
mseLoaderFactory: configuration.mseLoaderFactory,
});

this.pool = new AudioPoolManager(audioEngine, publication, configuration.contentProtection);
Expand Down Expand Up @@ -188,6 +221,28 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
this.setupEventListeners();

this._isNavigating = true;

const startInitialLoad = () => this.startInitialLoad(trackIndex, initialTime);
if (configuration.mediaElementSetup) {
// Defer the initial load until the host has prepared the element
// (e.g. attached MediaKeys for EME) so no media data is fetched or
// decoded before protection is in place.
Promise.resolve()
.then(() => configuration.mediaElementSetup!(this.pool.audioEngine.getMediaElement()))
.catch((error) => { this.listeners.error(error, this.currentLocator); })
.then(startInitialLoad);
} else {
startInitialLoad();
}
}

/** Sets the initial track on the primary element and seeks to the starting position. */
private startInitialLoad(trackIndex: number, initialTime: number): void {
// The navigator may have been destroyed while an async
// mediaElementSetup was pending (React StrictMode does this in dev) —
// don't start loading media on the orphaned element.
if (this._destroyed) return;

this.pool.setCurrentAudio(trackIndex, "forward");

// applyPreferences() must come after setCurrentAudio() so that the src
Expand Down Expand Up @@ -289,6 +344,47 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
return this.hrefToTrackIndex(this.currentLocation.href);
}

/**
* Builds a locator for an overall publication progression (0–1) by walking
* the cumulative track durations. Falls back to the start of the first
* track when the reading order carries no duration metadata.
*/
private locatorFromTotalProgression(totalProgression: number): { locator: Locator, trackIndex: number } {
const items = this.pub.readingOrder.items;
const durations = items.map(link => link.duration ?? 0);
const totalDuration = durations.reduce((sum, d) => sum + d, 0);

let trackIndex = 0;
let time = 0;
if (totalDuration > 0) {
let remaining = Math.min(Math.max(totalProgression, 0), 1) * totalDuration;
for (let i = 0; i < durations.length; i++) {
trackIndex = i;
if (remaining <= durations[i] || i === durations.length - 1) {
time = remaining;
break;
}
remaining -= durations[i];
}
}

const link = items[trackIndex];
return {
trackIndex,
locator: new Locator({
href: link.href,
type: link.type || "", // Should have a mimetype
title: link.title,
locations: new LocatorLocations({
position: trackIndex + 1,
progression: durations[trackIndex] > 0 ? time / durations[trackIndex] : 0,
totalProgression: Math.min(Math.max(totalProgression, 0), 1),
fragments: [`t=${ time }`]
})
})
};
}

get currentLocator(): Locator {
return this.currentLocation;
}
Expand Down Expand Up @@ -384,7 +480,11 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
fragments: [`t=${this.duration}`]
}));
this.listeners.trackEnded(this.currentLocator);
if (!this.canGoForward) return;
if (!this.canGoForward) {
// Set final progress in audiobook
this.listeners.positionChanged(this.currentLocator);
return;
}
await this.nextTrack();
if (this._settings.autoPlay) this.play();
});
Expand Down Expand Up @@ -698,6 +798,7 @@ export class AudioNavigator extends MediaNavigator implements Configurable<Audio
}

destroy(): void {
this._destroyed = true;
this.stopPositionPolling();
this._stopStalledWatchdog();
this.destroyMediaSession();
Expand Down
22 changes: 14 additions & 8 deletions navigator/src/audio/AudioPoolManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,20 @@ export class AudioPoolManager {
return supported;
}

private pickPlayableHref(link: Link): string {
private pickPlayableHref(link: Link): { href: string, type?: string } {
const base = this._publication.baseURL;
const candidates = [link, ...(link.alternates?.items ?? [])];
let best: { href: string; confidence: "probably" | "maybe" } | undefined;
let best: { href: string; type?: string; confidence: "probably" | "maybe" } | undefined;
for (const candidate of candidates) {
if (!candidate.type) continue;
const confidence = this._supportedAudioTypes.get(candidate.type);
if (!confidence) continue;
const href = candidate.toURL(base) ?? candidate.href;
if (confidence === "probably") return href;
if (!best) best = { href, confidence };
if (confidence === "probably") return { href, type: candidate.type };
if (!best) best = { href, type: candidate.type, confidence };
}
return best?.href ?? (link.toURL(base) ?? link.href);
if (best) return { href: best.href, type: best.type };
return { href: link.toURL(base) ?? link.href, type: link.type };
}

get audioEngine(): WebAudioEngine {
Expand Down Expand Up @@ -84,12 +85,16 @@ export class AudioPoolManager {
* The current track is excluded — the primary engine element represents it.
*/
private update(currentIndex: number): void {
// Progressive preload elements would feed encrypted media through the
// broken src= demuxer path — the MSE loader owns fetching instead
if (this._audioEngine.usesMse) return;

const items = this._publication.readingOrder.items;
const keep = new Set<string>();

for (let j = 0; j < items.length; j++) {
if (j === currentIndex) continue; // primary element handles the current track
const href = this.pickPlayableHref(items[j]);
const { href } = this.pickPlayableHref(items[j]);
if (j >= currentIndex - LOWER_BOUNDARY && j <= currentIndex + LOWER_BOUNDARY) {
this.ensure(href);
keep.add(href);
Expand Down Expand Up @@ -117,8 +122,8 @@ export class AudioPoolManager {
* session and any Web Audio graph connections across track changes.
*/
setCurrentAudio(currentIndex: number, _direction: 'forward' | 'backward'): void {
const href = this.pickPlayableHref(this._publication.readingOrder.items[currentIndex]);
this.audioEngine.changeSrc(href);
const { href, type } = this.pickPlayableHref(this._publication.readingOrder.items[currentIndex]);
this.audioEngine.changeSrc(href, type);

// Discard any pool entry for this href — the primary element owns it now
if (this.pool.has(href)) {
Expand All @@ -134,6 +139,7 @@ export class AudioPoolManager {

destroy(): void {
this.audioEngine.stop();
this.audioEngine.destroy();
for (const [, element] of this.pool) {
element.removeAttribute("src");
element.load();
Expand Down
35 changes: 34 additions & 1 deletion navigator/src/audio/engine/AudioEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,37 @@ export interface Playback {
buffered?: number;
}

/**
* A consumer-supplied loader that feeds one resource to the playback element
* through Media Source Extensions. The engine constructs one per track via
* the [AudioMseLoaderFactory] and calls [start] immediately; the loader owns
* the element's `src` (a MediaSource object URL), all fetching, buffering and
* seek servicing until [destroy] is called. Container specifics (WebM, fMP4,
* byte ranges vs. segments…) are entirely up to the implementation.
*/
export interface AudioMseLoader {
/** Attaches to the element and begins streaming. Called exactly once. */
start(): void;

/**
* Cancels in-flight fetches, detaches element listeners and releases the
* MediaSource object URL. The element itself must be left intact — the
* engine reuses it for the next track.
*/
destroy(): void;
}

/**
* Creates the [AudioMseLoader] for a resource. [mimeType] is the resource's
* media type from the manifest, when known — needed to configure the
* SourceBuffer.
*/
export type AudioMseLoaderFactory = (
element: HTMLMediaElement,
href: string,
mimeType?: string,
) => AudioMseLoader;

/**
* An audio engine that plays audio resources from a publication.
* @playback - The current [Playback] state.
Expand Down Expand Up @@ -58,8 +89,10 @@ export interface AudioEngine {
* Changes the src of the primary media element without swapping it,
* preserving the RemotePlayback session and all attached event listeners.
* @param href The URL of the new audio resource.
* @param mimeType The media type of the resource, when known. Required for
* MSE-based loading to configure the SourceBuffer.
*/
changeSrc(href: string): void;
changeSrc(href: string, mimeType?: string): void;

/**
* Plays the current audio resource.
Expand Down
Loading