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');