feat(reading): lecture adaptative pour mauvaises connexions — manifest de lecture avec fenêtre priorisée autour de la position du lecteur et préchargement progressif des pages voisines, ressources authentifiées servies avec Range/ETag (module reading API: controller/service/dto/ports, source archive et cache reconstructible filesystem), client web adaptatif useAdaptiveReading avec enrichissement du cache au fil de la lecture, EPUB chargé par ressources manifestées via loader Foliate avec credentials (fini le téléchargement complet du fichier), optimisation des images EPUB et fallback page PDF allégée, avec tests api/web — évolution #53

Ouvrir rapidement un très grand livre et continuer la lecture sans attendre le chargement complet sur très mauvaise connexion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-09-29 23:56:42 +02:00
parent bf5441d5c4
commit bdc6d58d45
27 changed files with 1384 additions and 95 deletions

View File

@ -59,6 +59,27 @@ describe("api fallback helpers", () => {
expect(headers.has("Content-Type")).toBe(false);
});
it("sends the adaptive reading window with the selected network policy", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ bookId: 12, locator: "chapter-2", current: null, previous: null, next: null, neighbors: [] }), {
status: 200,
headers: { "Content-Type": "application/json" }
})
);
vi.stubGlobal("fetch", fetchMock);
await api.readingWindow(12, "chapter-2", "speed", "slow");
expect(fetchMock.mock.calls[0][0]).toBe("/books/12/reading-window");
const init = fetchMock.mock.calls[0][1] as RequestInit;
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({ locator: "chapter-2", mode: "speed", networkHint: "slow" });
});
it("builds an encoded reading resource URL", () => {
expect(api.readingResourceUrl(12, "chapter/one")).toBe("/books/12/reading-resources/chapter%2Fone");
});
it("surfaces create library API errors without fallback", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "Library path does not exist" }), {

View File

@ -27,7 +27,16 @@ import {
mockProgress,
mockUser
} from "./mockData";
import type { CbzPagesDto, ContinueItem, ReaderPreferencesDto, Session } from "./types";
import type {
CbzPagesDto,
ContinueItem,
ReaderPreferencesDto,
ReadingConnectionMode,
ReadingManifestDto,
ReadingNetworkHint,
ReadingWindowDto,
Session
} from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:";
@ -179,6 +188,21 @@ export const api = {
bookCoverUrl(id: number): string {
return `${API_BASE}/books/${id}/cover`;
},
async readingManifest(id: number): Promise<ReadingManifestDto> {
return request<ReadingManifestDto>(`/books/${id}/reading-manifest`);
},
readingResourceUrl(id: number, resourceId: string): string {
return `${API_BASE}/books/${id}/reading-resources/${encodeURIComponent(resourceId)}`;
},
async readingWindow(id: number, locator: string, mode: ReadingConnectionMode, networkHint: ReadingNetworkHint): Promise<ReadingWindowDto> {
return request<ReadingWindowDto>(`/books/${id}/reading-window`, {
method: "POST",
body: JSON.stringify({ locator, mode, networkHint })
});
},
async retryReadingResource(id: number, resourceId: string): Promise<void> {
await request(`/books/${id}/reading-resources/${encodeURIComponent(resourceId)}/retry`, { method: "POST" });
},
async cbzPages(id: number): Promise<CbzPagesDto> {
return request<CbzPagesDto>(`/books/${id}/pages`);
},

View File

@ -32,6 +32,63 @@ export type ReaderMode = "horizontal" | "vertical";
export type ReaderFit = "page" | "width";
export type ReadingConnectionMode = "auto" | "speed" | "quality";
export type ReadingNetworkHint = "offline" | "slow" | "normal" | "fast" | "unknown";
export type ReadingResourceDto = {
resourceId: string;
href: string;
url: string;
kind: "chapter" | "image" | "stylesheet" | "font" | "document";
mediaType: string;
size: number;
state: "ready" | "pending" | "unsupported" | "failed";
references?: string[];
};
export type ReadingSpineItemDto = {
index: number;
href: string;
resourceId: string;
url: string;
mediaType: string;
size: number;
};
export type ReadingManifestDto = {
bookId: number;
format: "epub" | "pdf";
revision: string;
version?: string;
containerPath: string | null;
opfPath: string | null;
basePath: string | null;
resources: ReadingResourceDto[];
spine: ReadingSpineItemDto[];
capabilities: {
byteRanges: boolean;
derivedImages: boolean;
lightweightPages: boolean;
};
};
export type ReadingWindowItemDto = {
resourceId: string;
relation: "current" | "previous" | "next" | "neighbor";
state: ReadingResourceDto["state"];
};
export type ReadingWindowDto = {
bookId: number;
locator: string;
mode: ReadingConnectionMode;
networkHint: ReadingNetworkHint;
current: ReadingWindowItemDto | null;
previous: ReadingWindowItemDto | null;
next: ReadingWindowItemDto | null;
neighbors: ReadingWindowItemDto[];
};
export type ReaderPreferencesDto = {
mode: ReaderMode;
fit?: ReaderFit;

View File

@ -10,6 +10,7 @@ import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reade
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
import { useReaderPreferences } from "../reader/useReaderPreferences";
import { useReaderProgress } from "../reader/useReaderProgress";
import { useAdaptiveReading } from "../reader/useAdaptiveReading";
import { navigate } from "../router";
import { EmptyState, LoadingState, Panel } from "../components/ui";
@ -69,6 +70,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId);
const { preferences, setMode: saveReaderMode, error: preferencesError } = useReaderPreferences(bookId);
const adaptive = useAdaptiveReading(bookId, book?.format === "epub" || book?.format === "pdf");
async function loadBook() {
setLoading(true);
@ -104,7 +106,11 @@ export function ReaderPage({ bookId }: { bookId: number }) {
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
}, [progress]);
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
const legacyFileUrl = useMemo(() => (book?.format === "epub" ? "" : api.bookFileUrl(bookId)), [book?.format, bookId]);
const adaptiveFileUrl = useMemo(() => {
if (book?.format !== "pdf" || !adaptive.manifest) return legacyFileUrl;
return adaptive.manifest.spine[0]?.url ?? api.readingResourceUrl(bookId, "original");
}, [adaptive.manifest, book?.format, bookId, legacyFileUrl]);
const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
const savePdfPage = useCallback(
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
@ -113,10 +119,19 @@ export function ReaderPage({ bookId }: { bookId: number }) {
const percent = pdfPagePercent(nextPage, pages, anchor);
if (strategy === "queued") queueSave(locator, percent);
else void save(locator, percent);
void adaptive.prioritize(adaptive.manifest?.spine[0]?.resourceId ?? "original");
},
[queueSave, save]
[adaptive.manifest?.spine, adaptive.prioritize, queueSave, save]
);
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
const saveEpubLocator = useCallback((locator: string, percent: number, readingLocator: string) => {
void save(locator, percent);
void adaptive.prioritize(readingLocator);
}, [adaptive.prioritize, save]);
useEffect(() => {
const first = adaptive.manifest?.spine[0]?.resourceId;
if (first) void adaptive.prioritize(first);
}, [adaptive.manifest?.revision, adaptive.mode]);
const saveComicPage = useCallback(
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
setPage(nextPage);
@ -129,7 +144,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
[book?.format, queueSave, save]
);
const readerError = error ?? progressError ?? preferencesError;
const readerError = error ?? progressError ?? preferencesError ?? adaptive.error;
const supportsZoom = book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr";
const supportsMode = supportsZoom;
const currentVisiblePage = useCallback(() => {
@ -231,10 +246,12 @@ export function ReaderPage({ bookId }: { bookId: number }) {
title={book?.title ?? "Ouverture du lecteur"}
backHref={backHref}
error={readerError}
onRetry={error ? () => void loadBook() : undefined}
controls={readerControls}
zoomControls={zoomControls}
modeControls={modeControls}
connectionControls={book.format === "epub" || book.format === "pdf" ? { mode: adaptive.mode, onModeChange: adaptive.setMode } : undefined}
activity={book.format === "epub" || book.format === "pdf" ? adaptive.statusLabel : undefined}
onRetry={error ? () => void loadBook() : adaptive.error ? adaptive.retry : undefined}
>
<ReaderCrashBoundary
resetKey={`${bookId}:${book?.format ?? "loading"}:${mode}`}
@ -273,7 +290,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
>
{book.format === "pdf" ? (
<PdfReader
url={fileUrl}
url={adaptiveFileUrl}
page={page}
backHref={backHref}
zoom={zoom}
@ -290,15 +307,17 @@ export function ReaderPage({ bookId }: { bookId: number }) {
onPageCommit={saveComicPage}
onControlsChange={setReaderControls}
/>
) : (
) : adaptive.manifest?.format === "epub" ? (
<EpubReader
url={fileUrl}
manifest={adaptive.manifest}
locator={progress?.locator}
backHref={backHref}
mode="horizontal"
onLocatorChange={saveEpubLocator}
onControlsChange={setReaderControls}
/>
) : (
<div className="reader-fallback"><span>Ouverture de la page…</span></div>
)}
</ReaderCrashBoundary>
</ReaderShell>

View File

@ -1,36 +1,60 @@
import { useEffect, useRef, useState } from "react";
import type { ReadingManifestDto, ReaderMode } from "../api/types";
import { ReaderError, readerErrorMessage } from "./ReaderError";
import type { ReaderControls } from "./ReaderShell";
import type { ReaderMode } from "../api/types";
type FoliateLocation = {
cfi?: string;
fraction?: number;
current?: number;
total?: number;
};
type FoliateLocation = { cfi?: string; fraction?: number; current?: number; total?: number };
type FoliateView = HTMLElement & {
open(input: File | Blob | string): Promise<void>;
open(input: unknown): Promise<void>;
close(): void;
goLeft(): Promise<void>;
goRight(): Promise<void>;
goTo(target: string): Promise<unknown>;
next(): Promise<void>;
lastLocation?: FoliateLocation;
};
type EpubApiLoader = {
loadText(path: string): Promise<string | null>;
loadBlob(path: string): Promise<Blob | null>;
getSize(path: string): number;
};
export function epubFileName(url: string): string {
function normalizedResourcePath(path: string): string {
const withoutFragment = path.split("#", 1)[0].split("?", 1)[0];
try {
const base = globalThis.location?.href ?? "http://readabook.local/";
const pathname = new URL(url, base).pathname;
const name = pathname.split("/").filter(Boolean).at(-1);
return name && name.includes(".") ? name : "book.epub";
return decodeURI(withoutFragment).replace(/^\.\//, "").replace(/^\/+/, "");
} catch {
return "book.epub";
return withoutFragment.replace(/^\.\//, "").replace(/^\/+/, "");
}
}
export function createEpubApiLoader(manifest: ReadingManifestDto, fetcher: typeof fetch = fetch): EpubApiLoader {
const resources = new Map(manifest.resources.map((resource) => [normalizedResourcePath(resource.href), resource]));
const responses = new Map<string, Promise<Blob>>();
const loadBlob = async (path: string) => {
const resource = resources.get(normalizedResourcePath(path));
if (!resource) return null;
let pending = responses.get(resource.resourceId);
if (!pending) {
pending = fetcher(resource.url, { credentials: "include", cache: "default" }).then(async (response) => {
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
return response.blob();
});
responses.set(resource.resourceId, pending);
}
try {
return await pending;
} catch (error) {
responses.delete(resource.resourceId);
throw error;
}
};
return {
loadBlob,
loadText: async (path) => (await loadBlob(path))?.text() ?? null,
getSize: (path) => resources.get(normalizedResourcePath(path))?.size ?? 0
};
}
function locationPercent(location: FoliateLocation): number {
if (typeof location.fraction === "number") return Math.max(0, Math.min(100, location.fraction * 100));
if (typeof location.current === "number" && typeof location.total === "number" && location.total > 0) {
@ -39,24 +63,18 @@ function locationPercent(location: FoliateLocation): number {
return 1;
}
export function EpubReader({
url,
locator,
backHref,
mode,
onLocatorChange,
onControlsChange
}: {
url: string;
export function EpubReader({ manifest, locator, backHref, mode, onLocatorChange, onControlsChange }: {
manifest: ReadingManifestDto;
locator?: string;
backHref: string;
mode: ReaderMode;
onLocatorChange: (locator: string, percent: number) => void;
onLocatorChange: (locator: string, percent: number, readingLocator: string) => void;
onControlsChange: (controls: ReaderControls) => void;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<FoliateView | null>(null);
const locatorRef = useRef(locator);
const sectionIndexRef = useRef(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>();
const [attempt, setAttempt] = useState(0);
@ -65,43 +83,39 @@ export function EpubReader({
onControlsChange({
canPrevious: !loading && !error,
canNext: !loading && !error,
positionLabel: loading ? "Ouverture EPUB" : "Lecture integree",
positionLabel: loading ? "Ouverture de la page…" : "Lecture intégrée",
onPrevious: () => void viewRef.current?.goLeft(),
onNext: () => void viewRef.current?.goRight()
});
}, [error, loading, onControlsChange]);
useEffect(() => {
locatorRef.current = locator;
}, [locator]);
useEffect(() => { locatorRef.current = locator; }, [locator]);
useEffect(() => {
let cancelled = false;
let view: FoliateView | null = null;
async function mount() {
try {
setLoading(true);
setError(undefined);
await import("foliate-js/view.js");
const [{ EPUB }] = await Promise.all([import("foliate-js/epub.js"), import("foliate-js/view.js")]);
if (cancelled || !hostRef.current) return;
const response = await fetch(url, { credentials: "include" });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
const blob = await response.blob();
const book = await new EPUB(createEpubApiLoader(manifest)).init();
if (cancelled || !hostRef.current) return;
view = document.createElement("foliate-view") as FoliateView;
view.classList.add("epub-view");
view.addEventListener("load", (event) => {
const index = (event as CustomEvent<{ index?: number }>).detail?.index;
if (typeof index === "number") sectionIndexRef.current = index;
});
view.addEventListener("relocate", (event) => {
const location = (event as CustomEvent<FoliateLocation>).detail;
if (location?.cfi) onLocatorChange(location.cfi, locationPercent(location));
const spine = manifest.spine[sectionIndexRef.current];
if (location?.cfi && spine) onLocatorChange(location.cfi, locationPercent(location), spine.resourceId);
});
hostRef.current.replaceChildren(view);
viewRef.current = view;
const file = new File([blob], epubFileName(url), { type: blob.type || "application/epub+zip" });
await view.open(file);
await view.open(book);
if (cancelled) return;
if (locatorRef.current) await view.goTo(locatorRef.current);
else await view.next();
@ -113,7 +127,6 @@ export function EpubReader({
}
}
}
void mount();
return () => {
cancelled = true;
@ -121,7 +134,7 @@ export function EpubReader({
view?.remove();
if (viewRef.current === view) viewRef.current = null;
};
}, [attempt, onLocatorChange, url]);
}, [attempt, manifest, onLocatorChange]);
useEffect(() => {
viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical");
@ -129,28 +142,10 @@ export function EpubReader({
}, [mode]);
if (error) {
return (
<div className="epub-reader">
<ReaderError
title="Lecture EPUB indisponible"
detail="ReadaBook n'a pas pu ouvrir ce fichier dans le lecteur web."
technicalDetail={error}
downloadUrl={url}
backHref={backHref}
onRetry={() => setAttempt((value) => value + 1)}
/>
</div>
);
return <div className="epub-reader"><ReaderError title="Lecture EPUB indisponible" detail="ReadaBook n'a pas pu ouvrir cette page dans le lecteur web." technicalDetail={error} backHref={backHref} onRetry={() => setAttempt((value) => value + 1)} /></div>;
}
return (
<div className={`epub-reader epub-reader-${mode}`}>
{loading && (
<div className="reader-fallback">
<span>Ouverture EPUB</span>
</div>
)}
<div className="epub-host" ref={hostRef} />
</div>
);
return <div className={`epub-reader epub-reader-${mode}`}>
{loading && <div className="reader-fallback"><span>Ouverture de la page…</span></div>}
<div className="epub-host" ref={hostRef} />
</div>;
}

View File

@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import type { ReadingManifestDto } from "../api/types";
import { createEpubApiLoader } from "./EpubReader";
const manifest: ReadingManifestDto = {
bookId: 4,
format: "epub",
revision: "rev-1",
containerPath: "META-INF/container.xml",
opfPath: "OPS/package.opf",
basePath: "OPS/",
resources: [
{ resourceId: "container", href: "META-INF/container.xml", url: "/books/4/reading-resources/container", mediaType: "application/xml", size: 12, kind: "document", state: "ready" },
{ resourceId: "chapter", href: "OPS/text/chapter.xhtml", url: "/books/4/reading-resources/chapter", mediaType: "application/xhtml+xml", size: 18, kind: "chapter", state: "ready" }
],
spine: [{ index: 0, href: "OPS/text/chapter.xhtml", resourceId: "chapter", url: "/books/4/reading-resources/chapter", mediaType: "application/xhtml+xml", size: 18 }],
capabilities: { byteRanges: false, derivedImages: true, lightweightPages: false }
};
describe("EPUB adaptive loader", () => {
it("loads manifest resources with credentials and reuses a fetched entry", async () => {
const fetcher = vi.fn().mockResolvedValue(new Response("<html>chapter</html>", { status: 200 }));
const loader = createEpubApiLoader(manifest, fetcher);
await expect(loader.loadText("OPS/text/chapter.xhtml#part")).resolves.toBe("<html>chapter</html>");
await loader.loadBlob("OPS/text/chapter.xhtml");
expect(fetcher).toHaveBeenCalledTimes(1);
expect(fetcher).toHaveBeenCalledWith("/books/4/reading-resources/chapter", { credentials: "include", cache: "default" });
expect(loader.getSize("OPS/text/chapter.xhtml")).toBe(18);
});
it("does not contain the complete-book fetch path", () => {
const source = readFileSync(new URL("./EpubReader.tsx", import.meta.url), "utf8");
expect(source).not.toContain("bookFileUrl");
expect(source).not.toContain("fetch(url");
expect(source).not.toContain("new File(");
expect(source).toContain("new EPUB(createEpubApiLoader(manifest)).init()");
});
});

View File

@ -285,7 +285,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
onControlsChange({
canPrevious: !loadingDocument && !documentError && !pageError && currentPage > 1,
canNext: !loadingDocument && !documentError && !pageError && currentPage < pages,
positionLabel: loadingDocument ? "Chargement" : readerPositionLabel(currentPage, pages),
positionLabel: loadingDocument ? "Ouverture de la page…" : readerPositionLabel(currentPage, pages),
onPrevious: () => go(currentPage - 1),
onNext: () => go(currentPage + 1)
});
@ -584,7 +584,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
<div className={`pdf-reader${mode === "vertical" ? " pdf-reader-vertical" : ""}`} ref={frameRef}>
{loadingDocument || !documentProxy ? (
<div className="reader-fallback">
<span>Ouverture PDF</span>
<span>Ouverture de la page…</span>
</div>
) : mode === "vertical" ? (
<div className="reader-vertical-stack">
@ -619,7 +619,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
<div className="pdf-page-frame" data-reader-page={currentPage}>
{(!viewportSize || loadingPage || !pageRendered) && (
<div className="reader-fallback">
<span>Page {currentPage}</span>
<span>Ouverture de la page…</span>
</div>
)}
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />

View File

@ -18,7 +18,7 @@ export function ReaderError({
title: string;
detail: string;
technicalDetail?: string;
downloadUrl: string;
downloadUrl?: string;
backHref: string;
onRetry: () => void;
}) {
@ -37,10 +37,12 @@ export function ReaderError({
<ArrowLeft size={16} />
Retour à la fiche
</button>
<a className="ghost-button" href={downloadUrl} download>
<Download size={16} />
Télécharger
</a>
{downloadUrl && (
<a className="ghost-button" href={downloadUrl} download>
<Download size={16} />
Télécharger
</a>
)}
</div>
{technicalDetail && (
<details>

View File

@ -14,6 +14,7 @@ import {
import { readerMovedBeyondTap, readerPinchZoom, readerPointDistance, type ReaderPoint } from "./readerGestures";
import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout";
import type { ReaderMode } from "./readerScroll";
import type { ReadingConnectionMode } from "../api/types";
export type ReaderControls = {
canPrevious: boolean;
@ -36,6 +37,11 @@ export type ReaderModeControls = {
onModeChange: (mode: ReaderMode) => void;
};
export type ReaderConnectionControls = {
mode: ReadingConnectionMode;
onModeChange: (mode: ReadingConnectionMode) => void;
};
type ReaderShellProps = {
title: string;
backHref: string;
@ -44,6 +50,8 @@ type ReaderShellProps = {
controls: ReaderControls;
zoomControls?: ReaderZoomControls;
modeControls?: ReaderModeControls;
connectionControls?: ReaderConnectionControls;
activity?: string;
children: ReactNode;
};
@ -105,7 +113,7 @@ function scheduleReaderZoomAnchorRestore(stage: HTMLElement, anchor: ReaderZoomA
});
}
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, children }: ReaderShellProps) {
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, connectionControls, activity, children }: ReaderShellProps) {
const readerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLElement>(null);
const stageRef = useRef<HTMLElement>(null);
@ -368,6 +376,16 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
</select>
</label>
)}
{connectionControls && (
<label className="reader-mode-control reader-connection-control">
<span>Mode connexion lente</span>
<select value={connectionControls.mode} onChange={(event) => connectionControls.onModeChange(event.currentTarget.value as ReadingConnectionMode)}>
<option value="auto">Automatique</option>
<option value="speed">Toujours privilégier la vitesse</option>
<option value="quality">Toujours privilégier la qualité</option>
</select>
</label>
)}
<button className="ghost-button reader-nav-button reader-previous-button" onClick={controls.onPrevious} disabled={!controls.canPrevious} aria-label="Page precedente">
<ArrowLeft size={16} />
<span>Precedent</span>
@ -405,6 +423,11 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
)}
</div>
)}
{!error && activity && (
<div className="reader-status reader-activity" role="status" aria-live="polite">
<span>{activity}</span>
</div>
)}
<main className="reader-stage" ref={stageRef}>
<div className="reader-content">{children}</div>
</main>

View File

@ -0,0 +1,41 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ReaderShell } from "./ReaderShell";
describe("ReaderShell adaptive connection controls", () => {
it("renders the slow connection mode choices without technical wording or a global percentage", () => {
const html = renderToStaticMarkup(
<ReaderShell
title="Livre"
backHref="/books/1"
controls={{ canPrevious: false, canNext: true, positionLabel: "Page 2 / 12", onPrevious: () => undefined, onNext: () => undefined }}
connectionControls={{ mode: "auto", onModeChange: () => undefined }}
>
<p>Page courante</p>
</ReaderShell>
);
expect(html).toContain("Mode connexion lente");
expect(html).toContain("Automatique");
expect(html).toContain("Toujours privilégier la vitesse");
expect(html).toContain("Toujours privilégier la qualité");
expect(html).not.toMatch(/%|pourcentage global|manifest|spine|range|etag|cache|resource|locator/i);
});
it("shows retry next to offline or loading errors", () => {
const html = renderToStaticMarkup(
<ReaderShell
title="Livre"
backHref="/books/1"
error="Cette page n’a pas pu être chargée. Votre position est conservée."
onRetry={() => undefined}
controls={{ canPrevious: false, canNext: false, positionLabel: "Page 2 / 12", onPrevious: () => undefined, onNext: () => undefined }}
>
<p>Page courante</p>
</ReaderShell>
);
expect(html).toContain("Cette page n’a pas pu être chargée. Votre position est conservée.");
expect(html).toContain("Reessayer");
});
});

View File

@ -1,6 +1,5 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { epubFileName } from "./EpubReader";
import { pdfDocumentOptions } from "./pdfDocumentOptions";
import {
clampReaderPage,
@ -23,11 +22,6 @@ import { isPageInRenderWindow, majorityVisiblePage, verticalRenderWindow } from
import { readerErrorMessage } from "./ReaderError";
describe("reader runtime helpers", () => {
it("extracts an EPUB file name from the file URL", () => {
expect(epubFileName("http://readabook.local/books/12/file?token=abc")).toBe("book.epub");
expect(epubFileName("http://readabook.local/files/example.epub")).toBe("example.epub");
});
it("keeps PDF.js worker fallback source on the bundled module worker", () => {
expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs");
});

View File

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { adaptiveStatusLabel } from "./useAdaptiveReading";
describe("adaptive reading status", () => {
it("uses the short reader wording without a global percentage", () => {
expect(adaptiveStatusLabel("opening", false)).toBe("Ouverture de la page…");
expect(adaptiveStatusLabel("next", false)).toBe("Préparation de la page suivante…");
expect(adaptiveStatusLabel("neighbors", false)).toBe("Chargement des pages voisines…");
expect(adaptiveStatusLabel("slow", false)).toBe("Connexion lente. On charge d’abord cette page.");
expect(adaptiveStatusLabel("offline", false)).toBe("Hors connexion. La page déjà chargée reste disponible.");
for (const status of ["opening", "next", "neighbors", "slow", "offline", "idle"] as const) {
const label = adaptiveStatusLabel(status, false) ?? "";
expect(label).not.toMatch(/%|pourcentage|global|manifest|spine|range|etag|cache|resource|locator/i);
}
});
it("announces a lightweight resource when the manifest supports it", () => {
expect(adaptiveStatusLabel("opening", true)).toBe("Version allégée");
});
});

View File

@ -0,0 +1,134 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import type { ReadingConnectionMode, ReadingManifestDto, ReadingNetworkHint, ReadingWindowItemDto } from "../api/types";
const CONNECTION_MODE_KEY = "readabook:reading-connection-mode";
export type AdaptiveReadingStatus = "opening" | "next" | "neighbors" | "slow" | "offline" | "idle";
function storedMode(): ReadingConnectionMode {
if (typeof localStorage === "undefined") return "auto";
const value = localStorage.getItem(CONNECTION_MODE_KEY);
return value === "speed" || value === "quality" ? value : "auto";
}
export function readingNetworkHint(): ReadingNetworkHint {
if (typeof navigator === "undefined") return "unknown";
if (!navigator.onLine) return "offline";
const connection = (navigator as Navigator & { connection?: { effectiveType?: string; saveData?: boolean } }).connection;
if (connection?.saveData || connection?.effectiveType === "slow-2g" || connection?.effectiveType === "2g") return "slow";
if (connection?.effectiveType === "3g") return "normal";
if (connection?.effectiveType === "4g") return "fast";
return "unknown";
}
export function adaptiveStatusLabel(status: AdaptiveReadingStatus, lightweight: boolean): string | undefined {
if (lightweight) return "Version allégée";
if (status === "opening") return "Ouverture de la page…";
if (status === "next") return "Préparation de la page suivante…";
if (status === "neighbors") return "Chargement des pages voisines…";
if (status === "slow") return "Connexion lente. On charge d’abord cette page.";
if (status === "offline") return "Hors connexion. La page déjà chargée reste disponible.";
return undefined;
}
export function useAdaptiveReading(bookId: number, enabled: boolean) {
const [manifest, setManifest] = useState<ReadingManifestDto | null>(null);
const [mode, setModeState] = useState<ReadingConnectionMode>(storedMode);
const [status, setStatus] = useState<AdaptiveReadingStatus>("opening");
const [error, setError] = useState<string>();
const generationRef = useRef(0);
const lastLocatorRef = useRef<string | null>(null);
const currentResourceRef = useRef<string | null>(null);
const loadManifest = useCallback(async () => {
if (!enabled) return;
setError(undefined);
setStatus("opening");
try {
setManifest(await api.readingManifest(bookId));
} catch {
setError("Impossible de préparer la lecture. Réessayez sans quitter cette page.");
setStatus(readingNetworkHint() === "offline" ? "offline" : "idle");
}
}, [bookId, enabled]);
useEffect(() => {
setManifest(null);
void loadManifest();
}, [loadManifest]);
useEffect(() => {
const update = () => setStatus(navigator.onLine ? "idle" : "offline");
window.addEventListener("online", update);
window.addEventListener("offline", update);
return () => {
window.removeEventListener("online", update);
window.removeEventListener("offline", update);
};
}, []);
const setMode = useCallback((next: ReadingConnectionMode) => {
setModeState(next);
localStorage.setItem(CONNECTION_MODE_KEY, next);
}, []);
const prioritize = useCallback(
async (locator: string, prefetch = true) => {
if (!manifest) return;
lastLocatorRef.current = locator;
const generation = ++generationRef.current;
const networkHint = readingNetworkHint();
setError(undefined);
setStatus(networkHint === "offline" ? "offline" : networkHint === "slow" || mode === "speed" ? "slow" : "opening");
try {
const window = await api.readingWindow(bookId, locator, mode, networkHint);
currentResourceRef.current = window.current?.resourceId ?? null;
if (generation !== generationRef.current || networkHint === "offline") return;
// pdf.js owns its byte-range requests; fetching the resource here would download the whole PDF.
if (manifest.format === "pdf" || !prefetch) {
setStatus("idle");
return;
}
const fetchItem = async (item: ReadingWindowItemDto | null) => {
if (!item) return;
const response = await fetch(api.readingResourceUrl(bookId, item.resourceId), { credentials: "include", cache: "default" });
if (!response.ok) throw new Error(String(response.status));
await response.arrayBuffer();
};
await fetchItem(window.current);
if (generation !== generationRef.current) return;
setStatus("next");
await Promise.allSettled([fetchItem(window.previous), fetchItem(window.next)]);
if (generation !== generationRef.current) return;
if (window.neighbors.length) {
setStatus("neighbors");
await Promise.allSettled(window.neighbors.map(fetchItem));
}
if (generation === generationRef.current) setStatus("idle");
} catch {
if (generation !== generationRef.current) return;
setError("Cette page n’a pas pu être chargée. Votre position est conservée.");
setStatus(readingNetworkHint() === "offline" ? "offline" : "idle");
}
},
[bookId, manifest, mode]
);
const retry = useCallback(async () => {
const resourceId = currentResourceRef.current;
const locator = lastLocatorRef.current;
if (!resourceId || !locator) {
await loadManifest();
return;
}
try {
await api.retryReadingResource(bookId, resourceId);
} catch {
// Retrying the GET still gives an already cached response a chance while offline.
}
await prioritize(locator);
}, [bookId, loadManifest, prioritize]);
return { manifest, mode, setMode, status, statusLabel: adaptiveStatusLabel(status, Boolean(manifest?.capabilities.lightweightPages)), error, retry, prioritize };
}

View File

@ -1209,6 +1209,17 @@ main.app-main-reader .reader-page {
min-width: 0;
}
.reader-activity {
justify-content: center;
min-height: 30px;
color: var(--ink-muted);
font-size: 0.86rem;
}
.reader-connection-control select {
max-width: 220px;
}
.reader-stage {
grid-row: 3;
position: relative;
@ -1536,6 +1547,11 @@ main.app-main-reader .reader-page {
min-width: 0;
}
.reader-connection-control {
order: 8;
flex-basis: 100%;
}
.reader-mode-control select {
width: 100%;
min-width: 0;
@ -1566,7 +1582,7 @@ main.app-main-reader .reader-page {
.reader-back-button span,
.reader-nav-button span,
.reader-fullscreen-toggle span,
.reader-mode-control > span {
.reader-mode-control > span:not(:only-child) {
display: none;
}

View File

@ -1,9 +1,14 @@
/// <reference types="vite/client" />
declare module "foliate-js/epub.js" {
const module: unknown;
export default module;
export const EPUB: unknown;
export class EPUB {
constructor(loader: {
loadText(path: string): Promise<string | null>;
loadBlob(path: string): Promise<Blob | null>;
getSize(path: string): number;
});
init(): Promise<unknown>;
}
}
declare module "foliate-js/view.js" {