diff --git a/apps/readest-app/src/__tests__/hooks/useAppRouter.test.ts b/apps/readest-app/src/__tests__/hooks/useAppRouter.test.ts
new file mode 100644
index 00000000..bfdd9344
--- /dev/null
+++ b/apps/readest-app/src/__tests__/hooks/useAppRouter.test.ts
@@ -0,0 +1,43 @@
+import { renderHook } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { useAppRouter } from '@/hooks/useAppRouter';
+
+const transitionRouter = { push: vi.fn(), replace: vi.fn(), back: vi.fn() };
+const plainRouter = { push: vi.fn(), replace: vi.fn(), back: vi.fn() };
+
+vi.mock('next-view-transitions', () => ({
+ useTransitionRouter: () => transitionRouter,
+}));
+vi.mock('next/navigation', () => ({
+ useRouter: () => plainRouter,
+}));
+
+const useEnvMock = vi.fn();
+vi.mock('@/context/EnvContext', () => ({
+ useEnv: () => useEnvMock(),
+}));
+
+afterEach(() => {
+ useEnvMock.mockReset();
+});
+
+describe('useAppRouter', () => {
+ it('routes through the View Transition router when the engine has the API', () => {
+ useEnvMock.mockReturnValue({ appService: { supportsViewTransitionsAPI: true } });
+ const { result } = renderHook(() => useAppRouter());
+ expect(result.current).toBe(transitionRouter);
+ });
+
+ it('falls back to the plain router when the engine lacks the View Transitions API', () => {
+ useEnvMock.mockReturnValue({ appService: { supportsViewTransitionsAPI: false } });
+ const { result } = renderHook(() => useAppRouter());
+ expect(result.current).toBe(plainRouter);
+ });
+
+ it('falls back to the plain router before the app service is ready', () => {
+ useEnvMock.mockReturnValue({ appService: null });
+ const { result } = renderHook(() => useAppRouter());
+ expect(result.current).toBe(plainRouter);
+ });
+});
diff --git a/apps/readest-app/src/__tests__/hooks/useCapturedTurn.test.ts b/apps/readest-app/src/__tests__/hooks/useCapturedTurn.test.ts
index 1056fcc2..184be4da 100644
--- a/apps/readest-app/src/__tests__/hooks/useCapturedTurn.test.ts
+++ b/apps/readest-app/src/__tests__/hooks/useCapturedTurn.test.ts
@@ -1,9 +1,5 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
-import {
- applyPageTurnAttributes,
- getCapturedTurnStyle,
- supportsViewTransitionTurns,
-} from '@/app/reader/hooks/useCapturedTurn';
+import { applyPageTurnAttributes, getCapturedTurnStyle } from '@/app/reader/hooks/useCapturedTurn';
import type { FoliateView } from '@/types/view';
import type { ViewSettings } from '@/types/book';
@@ -50,23 +46,6 @@ afterEach(() => {
delete (document as unknown as VTDocument).startViewTransition;
});
-describe('supportsViewTransitionTurns', () => {
- it('reports no support without startViewTransition', () => {
- stubEngine({ startViewTransition: false, nestedGroups: true });
- expect(supportsViewTransitionTurns()).toBe(false);
- });
-
- it('reports no support when nested view-transition groups are missing (iOS 18 WebKit)', () => {
- stubEngine({ startViewTransition: true, nestedGroups: false });
- expect(supportsViewTransitionTurns()).toBe(false);
- });
-
- it('reports support on engines with nested view-transition groups', () => {
- stubEngine({ startViewTransition: true, nestedGroups: true });
- expect(supportsViewTransitionTurns()).toBe(true);
- });
-});
-
describe('getCapturedTurnStyle', () => {
it('captures the slide on Tauri when the engine cannot layer View Transitions', () => {
vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM', 'tauri');
diff --git a/apps/readest-app/src/__tests__/utils/viewTransition.test.ts b/apps/readest-app/src/__tests__/utils/viewTransition.test.ts
new file mode 100644
index 00000000..befd1e51
--- /dev/null
+++ b/apps/readest-app/src/__tests__/utils/viewTransition.test.ts
@@ -0,0 +1,57 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { detectViewTransitionGroup, detectViewTransitionsAPI } from '@/utils/viewTransition';
+
+// The DOM lib types startViewTransition as always present; go through a loose
+// shape so the stub can also remove it.
+type VTDocument = { startViewTransition?: () => void };
+
+const stubEngine = ({
+ startViewTransition,
+ nestedGroups,
+}: {
+ startViewTransition: boolean;
+ nestedGroups: boolean;
+}) => {
+ const doc = document as unknown as VTDocument;
+ if (startViewTransition) doc.startViewTransition = () => {};
+ else delete doc.startViewTransition;
+ vi.stubGlobal('CSS', {
+ supports: (property: string, value: string) =>
+ nestedGroups && property === 'view-transition-group' && value === 'nearest',
+ });
+};
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ delete (document as unknown as VTDocument).startViewTransition;
+});
+
+describe('detectViewTransitionsAPI', () => {
+ it('is true on any engine with document.startViewTransition, even without nested groups', () => {
+ stubEngine({ startViewTransition: true, nestedGroups: false });
+ expect(detectViewTransitionsAPI()).toBe(true);
+ });
+
+ it('is false without the View Transitions API', () => {
+ stubEngine({ startViewTransition: false, nestedGroups: true });
+ expect(detectViewTransitionsAPI()).toBe(false);
+ });
+});
+
+describe('detectViewTransitionGroup', () => {
+ it('requires nested view-transition groups on top of the API', () => {
+ stubEngine({ startViewTransition: true, nestedGroups: false });
+ expect(detectViewTransitionGroup()).toBe(false);
+ });
+
+ it('is true only when the API and nested groups are both present', () => {
+ stubEngine({ startViewTransition: true, nestedGroups: true });
+ expect(detectViewTransitionGroup()).toBe(true);
+ });
+
+ it('is false without the API even if the group query matches', () => {
+ stubEngine({ startViewTransition: false, nestedGroups: true });
+ expect(detectViewTransitionGroup()).toBe(false);
+ });
+});
diff --git a/apps/readest-app/src/app/library/components/NowPlayingBar.tsx b/apps/readest-app/src/app/library/components/NowPlayingBar.tsx
index 273659d6..6ea703bb 100644
--- a/apps/readest-app/src/app/library/components/NowPlayingBar.tsx
+++ b/apps/readest-app/src/app/library/components/NowPlayingBar.tsx
@@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { MdClose, MdPauseCircleFilled, MdPlayCircleFilled } from 'react-icons/md';
import { ttsSessionManager, TTSSession } from '@/services/tts';
+import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useBookDataStore } from '@/store/bookDataStore';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
@@ -39,6 +40,8 @@ const NowPlayingBar = ({ isSelectMode }: NowPlayingBarProps) => {
const [stopping, setStopping] = useState(false);
const [entered, setEntered] = useState(false);
const [timerLabel, setTimerLabel] = useState('');
+ const size20 = useResponsiveSize(20);
+ const size30 = useResponsiveSize(30);
useEffect(() => {
const onSessionChanged = () => {
@@ -126,7 +129,7 @@ const NowPlayingBar = ({ isSelectMode }: NowPlayingBarProps) => {
'motion-safe:transition-all motion-safe:duration-200',
entered ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0',
)}
- style={{ paddingBottom: `${(safeAreaInsets?.bottom ?? 0) + 16}px` }}
+ style={{ paddingBottom: `${(safeAreaInsets?.bottom ?? 0) / 3 + 16}px` }}
>
{
aria-label={`${_('Open Book')}: ${title}`}
className={clsx(
'not-eink:bg-base-300 eink-bordered flex items-center gap-2 rounded-full shadow-lg',
- 'h-11 max-w-[calc(100vw-1rem)] cursor-pointer ps-2 pe-1',
+ 'h-14 max-w-[calc(100vw-1rem)] cursor-pointer px-2',
'focus-visible:ring-primary focus-visible:ring-2 focus-visible:outline-none',
)}
>
{coverImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
-

+

) : null}
{title}
{timerLabel && (
@@ -159,7 +166,7 @@ const NowPlayingBar = ({ isSelectMode }: NowPlayingBarProps) => {
handleToggle();
}}
>
- {isPlaying ?
:
}
+ {isPlaying ?
:
}
diff --git a/apps/readest-app/src/app/library/hooks/useOpenBook.ts b/apps/readest-app/src/app/library/hooks/useOpenBook.ts
index f8ee700d..27a89593 100644
--- a/apps/readest-app/src/app/library/hooks/useOpenBook.ts
+++ b/apps/readest-app/src/app/library/hooks/useOpenBook.ts
@@ -3,8 +3,8 @@ import { Book } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
-import { useRouter } from 'next/navigation';
import { useTranslation } from '@/hooks/useTranslation';
+import { useAppRouter } from '@/hooks/useAppRouter';
import { eventDispatcher } from '@/utils/event';
import { navigateToReader, showReaderWindow } from '@/utils/nav';
@@ -25,11 +25,7 @@ interface UseOpenBookOptions {
*/
export const useOpenBook = ({ setLoading, handleBookDownload }: UseOpenBookOptions) => {
const _ = useTranslation();
- // Open the reader with the plain router, NOT the View Transition router:
- // the reader's initial render is heavy enough to blow the transition's ~4s
- // DOM-update budget, which aborts with a TimeoutError (Sentry READEST-9).
- // This matches how every other into-reader path already navigates.
- const router = useRouter();
+ const router = useAppRouter();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { updateBook } = useLibraryStore();
diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx
index d720f4ad..685c5e72 100644
--- a/apps/readest-app/src/app/library/page.tsx
+++ b/apps/readest-app/src/app/library/page.tsx
@@ -4,7 +4,7 @@ import clsx from 'clsx';
import * as React from 'react';
import { MdChevronRight } from 'react-icons/md';
import { useState, useRef, useEffect, Suspense, useCallback } from 'react';
-import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/navigation';
+import { ReadonlyURLSearchParams, useSearchParams } from 'next/navigation';
import { Book } from '@/types/book';
import { AppService, DeleteAction } from '@/types/system';
@@ -163,10 +163,6 @@ const LibraryPageWithSearchParams = () => {
const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchParams | null }) => {
const router = useAppRouter();
- // Opening the reader is a heavy render that overruns the View Transition
- // DOM-update budget (TimeoutError, Sentry READEST-9), so navigate to it with
- // the plain router; `router` above keeps transitions for lighter navigation.
- const readerRouter = useRouter();
const { envConfig, appService } = useEnv();
const { token, user } = useAuth();
const {
@@ -580,10 +576,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const bookIds = pendingNavigationBookIds;
setPendingNavigationBookIds(null);
if (bookIds.length > 0) {
- navigateToReader(readerRouter, bookIds);
+ navigateToReader(router, bookIds);
}
}
- }, [pendingNavigationBookIds, appService, readerRouter]);
+ }, [pendingNavigationBookIds, appService, router]);
useEffect(() => {
if (isInitiating.current) return;
diff --git a/apps/readest-app/src/app/reader/hooks/useCapturedTurn.ts b/apps/readest-app/src/app/reader/hooks/useCapturedTurn.ts
index e5710c3d..07685e68 100644
--- a/apps/readest-app/src/app/reader/hooks/useCapturedTurn.ts
+++ b/apps/readest-app/src/app/reader/hooks/useCapturedTurn.ts
@@ -5,6 +5,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { captureWebviewRegion } from '@/utils/bridge';
import { isTauriAppPlatform } from '@/services/environment';
+import { detectViewTransitionGroup } from '@/utils/viewTransition';
import { CapturedPageTurn, CapturedTurnStyle } from '../utils/capturedTurn';
import { useTouchInterceptor } from './useTouchInterceptor';
@@ -13,21 +14,6 @@ import { useTouchInterceptor } from './useTouchInterceptor';
// take over where the engine supports them, push everywhere else.
let captureBroken = false;
-/**
- * Whether the engine runs the paginator's layered View Transition turns
- * reliably. iOS 18 WebKit ships `document.startViewTransition` but crashes
- * the WebContent process when a turn transition snapshots the reader, so
- * the presence of the API is not enough; nested view-transition groups
- * (Chrome/WebView 140+) mark the mature engines where the layered turns
- * are known to work.
- */
-export const supportsViewTransitionTurns = (): boolean =>
- typeof document !== 'undefined' &&
- 'startViewTransition' in document &&
- typeof CSS !== 'undefined' &&
- typeof CSS.supports === 'function' &&
- CSS.supports('view-transition-group', 'nearest');
-
/**
* The turn style the captured-page pipeline should drive for this view, or
* null when the paginator's own turns apply. The pipeline needs a native
@@ -46,7 +32,7 @@ export const getCapturedTurnStyle = (
return null;
}
if (viewSettings.pageTurnStyle === 'curl') return 'curl';
- if (viewSettings.pageTurnStyle === 'slide' && !supportsViewTransitionTurns()) return 'slide';
+ if (viewSettings.pageTurnStyle === 'slide' && !detectViewTransitionGroup()) return 'slide';
return null;
};
@@ -66,7 +52,7 @@ export const applyPageTurnAttributes = (
) => {
const captured = getCapturedTurnStyle(viewSettings, isFixedLayout);
const style = viewSettings.pageTurnStyle;
- if (style && style !== 'push' && !captured && supportsViewTransitionTurns()) {
+ if (style && style !== 'push' && !captured && detectViewTransitionGroup()) {
view.renderer.setAttribute('turn-style', style);
} else {
view.renderer.removeAttribute('turn-style');
diff --git a/apps/readest-app/src/components/settings/ControlPanel.tsx b/apps/readest-app/src/components/settings/ControlPanel.tsx
index c3534abe..34b02624 100644
--- a/apps/readest-app/src/components/settings/ControlPanel.tsx
+++ b/apps/readest-app/src/components/settings/ControlPanel.tsx
@@ -12,10 +12,7 @@ import { saveSysSettings, saveViewSettings } from '@/helpers/settings';
import { PageTurnStyle } from '@/types/book';
import { SettingsPanelPanelProp } from './SettingsDialog';
import { annotationToolQuickActions } from '@/app/reader/components/annotator/AnnotationTools';
-import {
- applyPageTurnAttributes,
- supportsViewTransitionTurns,
-} from '@/app/reader/hooks/useCapturedTurn';
+import { applyPageTurnAttributes } from '@/app/reader/hooks/useCapturedTurn';
import { isTauriAppPlatform } from '@/services/environment';
import {
BoxedList,
@@ -84,7 +81,7 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes
// the VT turns, so on the web they only get Push (readest#555).
const turnStyleOptions = [
{ value: 'push', label: _('Push') },
- ...(supportsViewTransitionTurns() || isTauriAppPlatform()
+ ...(appService?.supportsViewTransitionGroup || isTauriAppPlatform()
? [
{ value: 'slide', label: _('Slide') },
{ value: 'curl', label: _('Page Curl') },
diff --git a/apps/readest-app/src/hooks/useAppRouter.ts b/apps/readest-app/src/hooks/useAppRouter.ts
index 10538e70..3712f0fd 100644
--- a/apps/readest-app/src/hooks/useAppRouter.ts
+++ b/apps/readest-app/src/hooks/useAppRouter.ts
@@ -7,6 +7,11 @@ export const useAppRouter = () => {
const transitionRouter = useTransitionRouter();
const plainRouter = useRouter();
- // View Transitions API crashes WebKitGTK 4.1 on Linux
- return appService?.isLinuxApp ? plainRouter : transitionRouter;
+ // A route transition is a plain full-page crossfade, so it only needs the
+ // base View Transitions API - not the nested view-transition groups the
+ // paginator turns require. Route through the transition router wherever the
+ // API is usable (appService folds in the Linux WebKitGTK carve-out); engines
+ // without it navigate plainly, sidestepping the DOM-update-budget TimeoutError
+ // seen on unsupported webviews (Sentry READEST-9).
+ return appService?.supportsViewTransitionsAPI ? transitionRouter : plainRouter;
};
diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts
index aa9c9475..18e0be08 100644
--- a/apps/readest-app/src/services/appService.ts
+++ b/apps/readest-app/src/services/appService.ts
@@ -65,6 +65,8 @@ export abstract class BaseAppService implements AppService {
canCustomizeRootDir = false;
canReadExternalDir = false;
supportsCanvasContext2DFilter = true;
+ supportsViewTransitionsAPI = false;
+ supportsViewTransitionGroup = false;
distChannel = 'readest' as DistChannel;
storefrontRegionCode: string | null = null;
isOnlineCatalogsAccessible = true;
diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts
index 57e4a290..92f52641 100644
--- a/apps/readest-app/src/services/nativeAppService.ts
+++ b/apps/readest-app/src/services/nativeAppService.ts
@@ -40,6 +40,7 @@ import { getDirPath, getFilename } from '@/utils/path';
import { NativeFile, RemoteFile } from '@/utils/file';
import { copyURIToPath, getStorefrontRegionCode, saveImageToGallery } from '@/utils/bridge';
import { copyFiles } from '@/utils/files';
+import { detectViewTransitionGroup, detectViewTransitionsAPI } from '@/utils/viewTransition';
import { BaseAppService } from './appService';
import { DatabaseOpts, DatabaseService } from '@/types/database';
@@ -578,6 +579,11 @@ export class NativeAppService extends BaseAppService {
override canReadExternalDir = DIST_CHANNEL !== 'appstore' && DIST_CHANNEL !== 'playstore';
override supportsCanvasContext2DFilter =
OS_TYPE !== 'ios' && OS_TYPE !== 'macos' && OS_TYPE !== 'linux';
+ // WebKitGTK on Linux crashes when a View Transition snapshots the window,
+ // so both capabilities are unavailable there regardless of what the engine
+ // reports; every other webview is gated on the real feature probe.
+ override supportsViewTransitionsAPI = OS_TYPE !== 'linux' && detectViewTransitionsAPI();
+ override supportsViewTransitionGroup = OS_TYPE !== 'linux' && detectViewTransitionGroup();
override distChannel = DIST_CHANNEL;
override storefrontRegionCode: string | null = null;
override isOnlineCatalogsAccessible = true;
diff --git a/apps/readest-app/src/services/webAppService.ts b/apps/readest-app/src/services/webAppService.ts
index 6a10213f..e485ed4a 100644
--- a/apps/readest-app/src/services/webAppService.ts
+++ b/apps/readest-app/src/services/webAppService.ts
@@ -4,6 +4,7 @@ import { SchemaType } from '@/services/database/migrate';
import { getOSPlatform, isValidURL } from '@/utils/misc';
import { isSafariBrowser } from '@/utils/ua';
import { RemoteFile } from '@/utils/file';
+import { detectViewTransitionGroup, detectViewTransitionsAPI } from '@/utils/viewTransition';
import { isPWA } from './environment';
import { BaseAppService } from './appService';
import {
@@ -289,6 +290,8 @@ export class WebAppService extends BaseAppService {
override isMobile = ['android', 'ios'].includes(getOSPlatform());
override appPlatform = 'web' as AppPlatform;
override supportsCanvasContext2DFilter = !isSafariBrowser();
+ override supportsViewTransitionsAPI = detectViewTransitionsAPI();
+ override supportsViewTransitionGroup = detectViewTransitionGroup();
override hasSafeAreaInset = isPWA();
override async init() {
diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts
index 22506d15..cd53231f 100644
--- a/apps/readest-app/src/types/system.ts
+++ b/apps/readest-app/src/types/system.ts
@@ -107,6 +107,8 @@ export interface AppService {
canCustomizeRootDir: boolean;
canReadExternalDir: boolean;
supportsCanvasContext2DFilter: boolean;
+ supportsViewTransitionsAPI: boolean;
+ supportsViewTransitionGroup: boolean;
distChannel: DistChannel;
storefrontRegionCode: string | null;
isOnlineCatalogsAccessible: boolean;
diff --git a/apps/readest-app/src/utils/viewTransition.ts b/apps/readest-app/src/utils/viewTransition.ts
new file mode 100644
index 00000000..ebd206a3
--- /dev/null
+++ b/apps/readest-app/src/utils/viewTransition.ts
@@ -0,0 +1,22 @@
+/**
+ * Whether the engine implements the View Transitions API at all
+ * (`document.startViewTransition`). This is the baseline a simple route
+ * crossfade needs, and it lands broadly: Chrome 111+, Edge, Safari 18+, and
+ * recent Android WebView.
+ */
+export const detectViewTransitionsAPI = (): boolean =>
+ typeof document !== 'undefined' && 'startViewTransition' in document;
+
+/**
+ * Whether the engine also supports nested view-transition groups
+ * (`view-transition-group: nearest`, Chrome/WebView 140+) - a far narrower
+ * target than the base API. This is what the paginator's layered turns
+ * require: iOS 18 WebKit ships `startViewTransition` but crashes the
+ * WebContent process on layered snapshots, so the group query marks the
+ * mature engines where the layered turns are known to work.
+ */
+export const detectViewTransitionGroup = (): boolean =>
+ detectViewTransitionsAPI() &&
+ typeof CSS !== 'undefined' &&
+ typeof CSS.supports === 'function' &&
+ CSS.supports('view-transition-group', 'nearest');