diff --git a/docs/architecture.md b/docs/architecture.md index e7f462e..8f4ad3e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,10 +37,18 @@ terminate their sessions on disposal. The browser must permit `worker-src blob:` Flutter's `TextPainter` measures styled runs at the actual column width, text scale and direction. Pagination cuts at line boundaries, keeps run styles and uses content offsets for restoration. Work yields between bounded batches. -Paginated reading consumes a resumable page stream, preparing a few spreads ahead -instead of waiting for an entire spine item. Worker blocks are converted lazily, +Paginated reading consumes a resumable page stream. Opening prepares the visible +spread, then a yielding background task keeps five spreads ahead ready, plus a +coverage boundary. A turn to a prepared spread never waits for an additional page. +Worker blocks are converted lazily, including image decoding. Adjacent spine items share in-flight loading and page cursors with foreground navigation, so turning does not restart prefetch work. +Forward and backward neighbor preparation run independently. The previous item's +tail is prepared when within three spreads of its boundary, while already measured +pages remain available for reversing direction within an item. Nearby image decoding +is warmed five spreads ahead and three behind using Flutter's image cache. Exact +page counting starts after the current buffer is ready and cannot block preparation +in either direction. The viewport retains at most three layout cursors for the current geometry. Document, font, viewport or typography changes dispose them. Only visible pages are built. Already measured pages remain available for backward turns. The location @@ -61,7 +69,9 @@ keeps readable content and offers retry. Coverage describes committed viewports. The viewport owns page and scroll controllers. Paginated commands use the same slide duration within and across spine items. At a boundary it retains the outgoing spread, animates to the prepared destination, then commits reading coverage and -the visible locator. Reduced motion skips the animation. Rapid commands wait for +the visible locator. The page-turn animation preference and reduced motion both +skip the animation, including drag-following and snap transitions. Touch swipes +still turn one page on release. Changing motion does not invalidate layout. Rapid commands wait for each transition to finish. Spine indices identify content and saved positions, independently of any table-of-contents chapter divisions. Single-column content has a maximum line width; spreads collapse on narrow or highly scaled viewports. Scrolling uses the @@ -90,9 +100,22 @@ independence without exposing PDFium details to the shell or host persistence. ## Shell The shell retains the content widget during position updates, serializes user -commands and keeps error views escapable. Initial opening uses a centered spinner +commands and keeps error views escapable. Progress-slider dragging previews locally +and commits on release. New drags and releases supersede earlier slider requests, +so queued intermediate destinations are skipped and stale failures cannot replace +the latest result. Ordinary page-turn commands retain their order. Background +snapshots cannot move the thumb during a drag or a pending seek. + +EPUB progress seeks have cancellation signals. Cancellation releases the navigation +queue without waiting for an obsolete chapter request, and layout demand stops at +the next yield while retaining reusable pages. Late results cannot commit a locator. +Existing worker parsing can finish into the content cache, rather than restarting +the document worker. The destination viewport replenishes the nearby-page buffer. +Engines may implement `cancelPendingSeek`; engines without cooperative cancellation +finish their active operation before the shell executes the latest queued seek. Initial opening uses a centered spinner without toolbar chrome; hosts can supply a loading builder to match their file-fetch -loader. Reader appearance controls the +loader. Initial EPUB pagination uses the same builder, host theme, centered +position and background, without a separate preparation label. Reader appearance controls the chrome brightness as well as page colors. Wide panels overlay the reading area; compact panels fit available height and insets. A distinct surface color and outlined page-facing edges separate panels from book content without shadows. @@ -124,7 +147,9 @@ The presentation layer keeps session, keyboard and viewport coordination in `papyrus_reader.dart`. Toolbar/progress controls, contents, settings and the panel header live in separate presentation modules. These modules are implementation details and are not exported by the package entry point. Public builder contexts -and custom-engine integration remain in the shell's existing public API. +and custom-engine integration remain in the shell's existing public API. Custom +panels can call `ReaderPanelContext.buildHeader(context, title)` to reuse the +standard title, divider, and close button with the reader's chrome theme. Hosts can supply `ReaderUiBuilders.compactPanelRoute` to own compact panel presentation. The factory returns an **unpushed** `Route`; the reader pushes diff --git a/lib/src/controller/reader_controller.dart b/lib/src/controller/reader_controller.dart index 0ed94b5..32bb317 100644 --- a/lib/src/controller/reader_controller.dart +++ b/lib/src/controller/reader_controller.dart @@ -152,6 +152,8 @@ final class ReaderController extends ChangeNotifier { ); } + void cancelPendingSeek() => _engine?.cancelPendingSeek(); + Future goNext() async { await _navigate(ReaderNavigationCause.turn, (engine) => engine.goNext()); } diff --git a/lib/src/domain/reader_capabilities.dart b/lib/src/domain/reader_capabilities.dart index e47ad83..8a4cba0 100644 --- a/lib/src/domain/reader_capabilities.dart +++ b/lib/src/domain/reader_capabilities.dart @@ -4,12 +4,14 @@ final class ReaderCapabilities { this.supportsScrolling = false, this.supportsTextCustomization = false, this.supportsColumnMode = false, + this.supportsPageTurnAnimation = false, }); final bool supportsPagination; final bool supportsScrolling; final bool supportsTextCustomization; final bool supportsColumnMode; + final bool supportsPageTurnAnimation; @override bool operator ==(Object other) => @@ -18,7 +20,8 @@ final class ReaderCapabilities { supportsPagination == other.supportsPagination && supportsScrolling == other.supportsScrolling && supportsTextCustomization == other.supportsTextCustomization && - supportsColumnMode == other.supportsColumnMode; + supportsColumnMode == other.supportsColumnMode && + supportsPageTurnAnimation == other.supportsPageTurnAnimation; @override int get hashCode => Object.hash( @@ -26,5 +29,6 @@ final class ReaderCapabilities { supportsScrolling, supportsTextCustomization, supportsColumnMode, + supportsPageTurnAnimation, ); } diff --git a/lib/src/domain/reader_preferences.dart b/lib/src/domain/reader_preferences.dart index b82ef7f..b2eb928 100644 --- a/lib/src/domain/reader_preferences.dart +++ b/lib/src/domain/reader_preferences.dart @@ -17,6 +17,7 @@ final class ReaderPreferences { this.brightness = Brightness.light, this.layoutMode = ReaderLayoutMode.paginated, this.columnMode = ReaderColumnMode.single, + this.pageTurnAnimation = true, }) : assert(fontSize > 0), assert(lineHeight > 0), assert(paragraphSpacing >= 0); @@ -32,6 +33,7 @@ final class ReaderPreferences { final Brightness brightness; final ReaderLayoutMode layoutMode; final ReaderColumnMode columnMode; + final bool pageTurnAnimation; ReaderPreferences copyWith({ Object? fontFamily = _unsetFontFamily, @@ -45,6 +47,7 @@ final class ReaderPreferences { Brightness? brightness, ReaderLayoutMode? layoutMode, ReaderColumnMode? columnMode, + bool? pageTurnAnimation, }) { return ReaderPreferences( fontFamily: identical(fontFamily, _unsetFontFamily) @@ -60,6 +63,7 @@ final class ReaderPreferences { brightness: brightness ?? this.brightness, layoutMode: layoutMode ?? this.layoutMode, columnMode: columnMode ?? this.columnMode, + pageTurnAnimation: pageTurnAnimation ?? this.pageTurnAnimation, ); } @@ -77,7 +81,8 @@ final class ReaderPreferences { foregroundColor == other.foregroundColor && brightness == other.brightness && layoutMode == other.layoutMode && - columnMode == other.columnMode; + columnMode == other.columnMode && + pageTurnAnimation == other.pageTurnAnimation; @override int get hashCode => Object.hash( @@ -92,5 +97,6 @@ final class ReaderPreferences { brightness, layoutMode, columnMode, + pageTurnAnimation, ); } diff --git a/lib/src/engine/epub/epub_reader_engine.dart b/lib/src/engine/epub/epub_reader_engine.dart index 6b3d683..7a59521 100644 --- a/lib/src/engine/epub/epub_reader_engine.dart +++ b/lib/src/engine/epub/epub_reader_engine.dart @@ -50,6 +50,7 @@ final class EpubReaderEngine extends ReaderEngine { Future Function( EpubReaderLocator target, List blocks, + bool Function() cancelled, )? prepareLocation; Future _turnQueue = Future.value(); @@ -57,6 +58,7 @@ final class EpubReaderEngine extends ReaderEngine { int _restorationRevision = 0; int _contentRevision = 0; int _navigationGeneration = 0; + _EpubSeek? _activeSeek; bool _disposed = false; int _visiblePage = 1; int _pageCount = 0; @@ -88,6 +90,7 @@ final class EpubReaderEngine extends ReaderEngine { supportsScrolling: true, supportsTextCustomization: true, supportsColumnMode: true, + supportsPageTurnAnimation: true, ); @override ReaderSnapshot snapshot = const ReaderIdleSnapshot(); @@ -105,6 +108,7 @@ final class EpubReaderEngine extends ReaderEngine { 'The EPUB document or initial locator has the wrong format.', ); } + cancelPendingSeek(); final generation = ++_navigationGeneration; EpubWorker? candidate; try { @@ -255,7 +259,12 @@ final class EpubReaderEngine extends ReaderEngine { } @override - Future goTo(ReaderLocator locator) async { + Future goTo(ReaderLocator locator) { + cancelPendingSeek(); + return _goTo(locator); + } + + Future _goTo(ReaderLocator locator, {_EpubSeek? seek}) async { if (locator is! EpubReaderLocator || locator.spineIndex >= _chapterCount || _worker == null) { @@ -274,7 +283,7 @@ final class EpubReaderEngine extends ReaderEngine { local: locator.localProgression, textOffset: offset?.clamp(0, _contentLength), ); - await prepareLocation?.call(target, _blocks); + await _prepare(target, _blocks, seek); _requireCurrent(generation); _locator = target; _coverage = const []; @@ -282,9 +291,10 @@ final class EpubReaderEngine extends ReaderEngine { _atEnd = false; _restorationRevision++; } else { - final chapter = await chapterContent(locator.spineIndex); + final request = chapterContent(locator.spineIndex); + final chapter = await (seek?.wait(request) ?? request); _requireCurrent(generation); - await prepareLocation?.call( + await _prepare( _locatorFor( locator.spineIndex, local: locator.localProgression, @@ -294,6 +304,7 @@ final class EpubReaderEngine extends ReaderEngine { locator.textOffset, ), EpubContentBlocks(chapter['blocks'] as List), + seek, ); _requireCurrent(generation); _setChapter(chapter, locator); @@ -301,6 +312,27 @@ final class EpubReaderEngine extends ReaderEngine { _publish(); } + Future _prepare( + EpubReaderLocator target, + List blocks, + _EpubSeek? seek, + ) async { + final preparation = prepareLocation?.call( + target, + blocks, + () => seek?.cancelled ?? false, + ); + if (preparation != null) await (seek?.wait(preparation) ?? preparation); + } + + @override + void cancelPendingSeek() { + final seek = _activeSeek; + if (seek == null || seek.cancelled) return; + _navigationGeneration++; + seek.cancel(); + } + @override Future goToProgress(double progress) async { if (!progress.isFinite || progress < 0 || progress > 1) { @@ -316,14 +348,25 @@ final class EpubReaderEngine extends ReaderEngine { 'No EPUB document is loaded.', ); } + cancelPendingSeek(); + final seek = _EpubSeek(); + _activeSeek = seek; final position = _progressIndex!.position(progress); - await goTo( - _locatorFor( - position.chapter, - local: progress == 1 ? 1 : 0, - textOffset: position.offset, - ), - ); + try { + await _goTo( + _locatorFor( + position.chapter, + local: progress == 1 ? 1 : 0, + textOffset: position.offset, + ), + seek: seek, + ); + } catch (_) { + // A cancelled request must not report a late preparation failure. + if (!seek.cancelled) rethrow; + } finally { + if (identical(_activeSeek, seek)) _activeSeek = null; + } } @override @@ -525,6 +568,7 @@ final class EpubReaderEngine extends ReaderEngine { backgroundColor: _preferences.backgroundColor, foregroundColor: _preferences.foregroundColor, brightness: _preferences.brightness, + pageTurnAnimation: _preferences.pageTurnAnimation, ) != _preferences; _preferences = preferences; @@ -576,6 +620,7 @@ final class EpubReaderEngine extends ReaderEngine { @override void dispose() { if (_disposed) return; + cancelPendingSeek(); _disposed = true; _navigationGeneration++; _openingWorker?.dispose(); @@ -589,3 +634,18 @@ final class EpubReaderEngine extends ReaderEngine { super.dispose(); } } + +final class _EpubSeek { + final _cancellation = Completer(); + bool get cancelled => _cancellation.isCompleted; + void cancel() => _cancellation.complete(); + + Future wait(Future operation) => Future.any([ + operation, + _cancellation.future.then((_) => throw const _CancelledSeek()), + ]); +} + +final class _CancelledSeek implements Exception { + const _CancelledSeek(); +} diff --git a/lib/src/engine/epub/epub_viewport.dart b/lib/src/engine/epub/epub_viewport.dart index 6735515..8a49623 100644 --- a/lib/src/engine/epub/epub_viewport.dart +++ b/lib/src/engine/epub/epub_viewport.dart @@ -9,6 +9,7 @@ import '../../domain/reader_locator.dart'; import '../../domain/reader_types.dart'; import '../../domain/reader_preferences.dart'; import '../../presentation/reader_interaction_scope.dart'; +import '../../presentation/reader_loading.dart'; import '../../presentation/reader_swipe_tracker.dart'; import '../../presentation/reader_highlight_interaction.dart'; import '../../presentation/reader_text_toolbar.dart'; @@ -39,6 +40,9 @@ final class _EpubViewportState extends State { List? _turnPages; int _turnDirection = 0; int? _turnSpine; + static const _spreadsAhead = 5; + static const _spreadsBehind = 3; + final Set _warmingImages = {}; static const _turnDuration = Duration(milliseconds: 220); Timer? _prefetchTimer; int _prefetchGeneration = 0; @@ -48,8 +52,13 @@ final class _EpubViewportState extends State { bool _showLoading = false; int _restoration = -1; int _pageIndex = 0; + int _pageViewCount = 0; int _columns = 1; final _swipe = ReaderSwipeTracker(); + bool get _animateTurns => + widget.engine.preferences.pageTurnAnimation && + !(MediaQuery.maybeOf(context)?.disableAnimations ?? false); + int _startPage = 0; double _startScroll = 0; bool _restoring = false; @@ -435,8 +444,12 @@ final class _EpubViewportState extends State { final next = _pageIndex + direction; final layout = _layoutCache[_layoutKey]; - if (next >= 0 && layout != null && !layout.complete) { - await layout.ensure(count: (next + 1) * _columns + 1); + final requiredPages = (next + 1) * _columns; + if (next >= 0 && + layout != null && + !layout.complete && + layout.pages.length < requiredPages) { + await layout.ensure(count: requiredPages); if (!mounted || !identical(layout, _layoutCache[_layoutKey])) return true; setState(() {}); } @@ -446,8 +459,7 @@ final class _EpubViewportState extends State { if (next < 0 || next >= count) { final index = widget.engine.locator.spineIndex + direction; if (index < 0 || index >= widget.engine.chapterCount) return false; - final animate = - !(MediaQuery.maybeOf(context)?.disableAnimations ?? false); + final animate = _animateTurns; final from = _pageIndex * _columns; final outgoing = _pages.sublist( from, @@ -472,13 +484,20 @@ final class _EpubViewportState extends State { return true; } + if (next >= _pageViewCount) { + // A prepared page may not yet be included in the PageView's scroll extent. + setState(() {}); + await WidgetsBinding.instance.endOfFrame; + if (!mounted || !identical(layout, _layoutCache[_layoutKey])) return true; + } + final controller = _pagesController; if (!controller.hasClients) { return true; } - if (MediaQuery.maybeOf(context)?.disableAnimations ?? false) { + if (!_animateTurns) { controller.jumpToPage(next); } else { await controller.animateToPage( @@ -623,9 +642,15 @@ final class _EpubViewportState extends State { EpubReaderLocator? target, bool toEnd = false, int? prefetch, + bool Function()? cancelled, }) async { final geometry = _geometryKey; final key = (index, geometry); + bool obsolete() => + !mounted || + geometry != _geometryKey || + (cancelled?.call() ?? false) || + (prefetch != null && prefetch != _prefetchGeneration); final layout = _layoutCache.putIfAbsent(key, () { final contentSize = _contentSize; final preferences = widget.engine.preferences; @@ -654,7 +679,7 @@ final class _EpubViewportState extends State { try { await layout.ensure( - count: _columns * 3 + 1, + count: prefetch == null ? _columns : (_spreadsAhead + 1) * _columns + 1, offset: target?.textOffset, toEnd: toEnd || @@ -662,7 +687,7 @@ final class _EpubViewportState extends State { (target != null && target.textOffset == null && target.localProgression > 0), - cancelled: () => prefetch != null && prefetch != _prefetchGeneration, + cancelled: obsolete, ); final offset = target?.textOffset; if (offset != null && !layout.complete) { @@ -670,7 +695,8 @@ final class _EpubViewportState extends State { (page) => page.offset <= offset, ); await layout.ensure( - count: ((targetPage ~/ _columns) + 2) * _columns + 1, + count: ((targetPage ~/ _columns) + 1) * _columns, + cancelled: obsolete, ); } } catch (_) { @@ -685,18 +711,26 @@ final class _EpubViewportState extends State { Future _prepareLocation( EpubReaderLocator target, List blocks, + bool Function() cancelled, ) async { if (widget.engine.renderer != null && widget.engine.preferences.layoutMode == ReaderLayoutMode.scroll) { return; } + _prefetchGeneration++; + _prefetchTimer?.cancel(); // A resize can supersede measurement while navigation is waiting. Object? geometry; do { geometry = _geometryKey; - await _measure(target.spineIndex, blocks, target: target); - } while (mounted && geometry != _geometryKey); + await _measure( + target.spineIndex, + blocks, + target: target, + cancelled: cancelled, + ); + } while (mounted && !cancelled() && geometry != _geometryKey); } void _publishPageCount(EpubPageLayout layout) { @@ -714,6 +748,74 @@ final class _EpubViewportState extends State { ); } + bool _prefetchCancelled(int generation) => + !mounted || generation != _prefetchGeneration; + + void _warmImages(List pages, int first, int end) { + final limit = math.min(end, pages.length); + for (var index = math.max(0, first); index < limit; index++) { + for (final fragment in pages[index].fragments) { + final bytes = fragment.block.image; + if (bytes == null) continue; + final image = MemoryImage(bytes); + if (PaintingBinding.instance.imageCache.containsKey(image) || + !_warmingImages.add(image)) { + continue; + } + unawaited( + precacheImage( + image, + context, + onError: (_, _) {}, + ).whenComplete(() => _warmingImages.remove(image)), + ); + } + } + } + + Future _countInBackground(EpubPageLayout layout, int generation) async { + try { + // Let nearby-page preparation start before spending time on exact totals. + await layout.yieldWork(); + if (_prefetchCancelled(generation)) return; + await layout.countPages(cancelled: () => _prefetchCancelled(generation)); + if (!_prefetchCancelled(generation)) _publishPageCount(layout); + } catch (_) { + // Keep the estimated total when speculative counting fails. + } + } + + Future _prefetchNeighbor( + int index, + int direction, + int generation, + ) async { + final neighbor = index + direction; + if (neighbor < 0 || neighbor >= widget.engine.chapterCount) return; + try { + final chapter = await widget.engine.chapterContent(neighbor); + if (_prefetchCancelled(generation)) return; + final pages = await _measure( + neighbor, + EpubContentBlocks(chapter['blocks'] as List), + toEnd: direction < 0, + prefetch: generation, + ); + if (_prefetchCancelled(generation)) return; + _warmImages( + pages, + direction < 0 ? pages.length - _spreadsBehind * _columns : 0, + direction < 0 ? pages.length : _spreadsAhead * _columns, + ); + final layout = _layoutCache[(neighbor, _geometryKey)]; + if (direction > 0 && layout != null) { + await _countInBackground(layout, generation); + } + } catch (_) { + // One unreadable or slow neighbor must not block the other direction. + } + } + void _schedulePrefetch() { _prefetchTimer?.cancel(); final generation = ++_prefetchGeneration; @@ -721,49 +823,31 @@ final class _EpubViewportState extends State { _prefetchTimer = Timer(Duration.zero, () async { try { final current = _layoutCache[_layoutKey]; - if (current != null) { - final previousCount = current.pages.length; - final wasComplete = current.complete; - await current.ensure( - count: (_pageIndex + 4) * _columns + 1, - cancelled: () => generation != _prefetchGeneration, - ); - if (!mounted || generation != _prefetchGeneration) return; - if (previousCount != current.pages.length || - wasComplete != current.complete) { - setState(() {}); - } - _publishPageCount(current); - } - - for (final neighbor in [index + 1, index - 1]) { - if (!mounted || generation != _prefetchGeneration) return; - if (neighbor < 0 || neighbor >= widget.engine.chapterCount) continue; - try { - final chapter = await widget.engine.chapterContent(neighbor); - if (!mounted || generation != _prefetchGeneration) return; - await _measure( - neighbor, - EpubContentBlocks(chapter['blocks'] as List), - toEnd: neighbor < index, - prefetch: generation, - ); - } catch (_) { - // An unreadable neighbor must not prevent counting the current pages. - } + if (current == null) return; + final previousCount = current.pages.length; + final wasComplete = current.complete; + await current.ensure( + count: (_pageIndex + _spreadsAhead + 1) * _columns + 1, + cancelled: () => _prefetchCancelled(generation), + ); + if (_prefetchCancelled(generation)) return; + if (previousCount != current.pages.length || + wasComplete != current.complete) { + setState(() {}); } + _publishPageCount(current); + _warmImages( + current.pages, + (_pageIndex - _spreadsBehind) * _columns, + (_pageIndex + _spreadsAhead + 1) * _columns, + ); - final geometry = _geometryKey; - for (final item in [index, index + 1]) { - if (!mounted || generation != _prefetchGeneration) return; - final layout = _layoutCache[(item, geometry)]; - if (layout == null) continue; - await layout.countPages( - cancelled: () => generation != _prefetchGeneration, - ); - if (!mounted || generation != _prefetchGeneration) return; - _publishPageCount(layout); - } + await Future.wait([ + _prefetchNeighbor(index, 1, generation), + if (_pageIndex < _spreadsBehind) + _prefetchNeighbor(index, -1, generation), + _countInBackground(current, generation), + ]); } catch (_) { // Foreground navigation surfaces errors. Speculation preserves the page. } @@ -807,7 +891,10 @@ final class _EpubViewportState extends State { ); } return ColoredBox( - color: preferences.backgroundColor, + color: _pages.isEmpty + ? ReaderLoadingScope.maybeOf(context)?.backgroundColor ?? + preferences.backgroundColor + : preferences.backgroundColor, child: LayoutBuilder( builder: (context, constraints) { final size = Size( @@ -901,22 +988,26 @@ final class _EpubViewportState extends State { _layoutReady!.complete(); } _busy = false; - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Could not prepare these pages.', - style: TextStyle( - color: preferences.foregroundColor, + return ColoredBox( + color: preferences.backgroundColor, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Could not prepare these pages.', + style: TextStyle( + color: preferences.foregroundColor, + ), ), - ), - const SizedBox(height: 12), - FilledButton( - onPressed: () => setState(() => _layoutKey = null), - child: const Text('Try again'), - ), - ], + const SizedBox(height: 12), + FilledButton( + onPressed: () => + setState(() => _layoutKey = null), + child: const Text('Try again'), + ), + ], + ), ), ); } @@ -926,23 +1017,7 @@ final class _EpubViewportState extends State { snapshot.data!.isNotEmpty; if (!ready && _pages.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator( - color: preferences.foregroundColor, - ), - const SizedBox(height: 12), - Text( - 'Preparing pages…', - style: TextStyle( - color: preferences.foregroundColor, - ), - ), - ], - ), - ); + return const ReaderLoading(); } if (ready) { _displayPreferences = preferences; @@ -993,13 +1068,17 @@ final class _EpubViewportState extends State { ); } else { final outgoing = _turnPages; + _pageViewCount = outgoing == null + ? (pages.length / columns).ceil() + : 2; content = PageView.builder( key: ValueKey(_pagesController), controller: _pagesController, + physics: _animateTurns + ? null + : const NeverScrollableScrollPhysics(), onPageChanged: _publishPage, - itemCount: outgoing == null - ? (pages.length / columns).ceil() - : 2, + itemCount: _pageViewCount, itemBuilder: (context, index) { if (outgoing != null) { final oldPage = index == (_turnDirection > 0 ? 0 : 1); @@ -1063,12 +1142,13 @@ final class _EpubViewportState extends State { } } else { if (drag < 0 && - _startPage == - (pages.length / columns).ceil() - 1) { + (!_animateTurns || + _startPage == + (pages.length / columns).ceil() - 1)) { _requestTurn(1); } - if (drag > 0 && _startPage == 0) { + if (drag > 0 && (!_animateTurns || _startPage == 0)) { _requestTurn(-1); } } diff --git a/lib/src/engine/reader_engine.dart b/lib/src/engine/reader_engine.dart index 9a9df80..b98bf38 100644 --- a/lib/src/engine/reader_engine.dart +++ b/lib/src/engine/reader_engine.dart @@ -26,6 +26,10 @@ abstract class ReaderEngine extends ChangeNotifier { Future goToProgress(double progress); + /// Stop a pending progress seek when supported. Engines that cannot cancel + /// finish the active operation before the shell runs the latest queued seek. + void cancelPendingSeek() {} + Future goNext(); Future goPrevious(); diff --git a/lib/src/presentation/papyrus_reader.dart b/lib/src/presentation/papyrus_reader.dart index e6fac19..d9e149b 100644 --- a/lib/src/presentation/papyrus_reader.dart +++ b/lib/src/presentation/papyrus_reader.dart @@ -21,6 +21,8 @@ import 'reader_controls.dart'; import 'reader_surface.dart'; import 'reader_contents_panel.dart'; import 'reader_settings_panel.dart'; +import 'reader_panel_header.dart'; +import 'reader_loading.dart'; import 'reader_theme_data.dart'; import 'reader_ui_builders.dart'; import '../domain/reader_selection.dart'; @@ -76,9 +78,14 @@ final class _PapyrusReaderState extends State { late ReaderController _controller; late bool _ownsController; ReaderSnapshot? _observedSnapshot; + bool _hasReadableContent = false; _ReaderPanel _panel = _ReaderPanel.none; bool _controlsVisible = false; double? _dragProgress; + bool _draggingProgress = false; + int _seekRevision = 0; + int? _pendingSeekRevision; + int? _activeSeekRevision; int _loadGeneration = 0; bool _loadedDependencies = false; Future _commandQueue = Future.value(); @@ -195,6 +202,7 @@ final class _PapyrusReaderState extends State { widget.initialPreferences ?? const ReaderPreferences(), ); _observedSnapshot = _controller.snapshot; + _hasReadableContent = _controller.snapshot.contentReady; _controller.addListener(_controllerChanged); } @@ -218,6 +226,11 @@ final class _PapyrusReaderState extends State { } void _invalidateCommands({bool notify = true}) { + _controller.cancelPendingSeek(); + _seekRevision++; + _pendingSeekRevision = null; + _activeSeekRevision = null; + _draggingProgress = false; _commandGeneration++; _commandQueue = Future.value(); _pendingCommands = 0; @@ -273,8 +286,13 @@ final class _PapyrusReaderState extends State { final previous = _observedSnapshot; final next = _controller.snapshot; _observedSnapshot = next; + _hasReadableContent = + next.status == ReaderStatus.ready && + (_hasReadableContent || next.contentReady); - if (next.contentReady) { + if (next.contentReady && + !_draggingProgress && + _pendingSeekRevision == null) { _dragProgress = null; } @@ -369,7 +387,7 @@ final class _PapyrusReaderState extends State { data: readerTheme, child: Builder( builder: (context) => Material( - color: snapshot.status == ReaderStatus.ready + color: _hasReadableContent ? snapshot.preferences.backgroundColor : theme.surfaceColor, child: SafeArea( @@ -380,7 +398,19 @@ final class _PapyrusReaderState extends State { _buildError(context, snapshot, theme), theme, ), - ReaderStatus.ready => _buildReady(context, snapshot, theme), + ReaderStatus.ready => ReaderLoadingScope( + backgroundColor: theme.surfaceColor, + builder: (context) => Theme( + data: readerTheme, + child: Builder( + builder: (context) => ColoredBox( + color: theme.surfaceColor, + child: _buildLoading(context, snapshot, theme), + ), + ), + ), + child: _buildReady(context, snapshot, theme), + ), }, ), ), @@ -584,8 +614,11 @@ final class _PapyrusReaderState extends State { _dragProgress ?? _progressOf(snapshot.locator), locationLabel: snapshot.locationLabel, - onProgressChanged: (value) => - setState(() => _dragProgress = value), + onProgressChangeStart: _startProgressDrag, + onProgressChanged: (value) => setState(() { + _draggingProgress = true; + _dragProgress = value; + }), onProgressChangeEnd: _goToProgress, onPrevious: _goPrevious, onNext: _goNext, @@ -1016,6 +1049,8 @@ final class _PapyrusReaderState extends State { close: close, isBusy: _isCommandBusy, showHeader: showHeader, + buildHeader: (context, title) => + ReaderPanelHeader(title: title, close: close, theme: theme), navigate: (locator) { close(); _enqueueCommand( @@ -1078,11 +1113,44 @@ final class _PapyrusReaderState extends State { }; } + void _startProgressDrag(double progress) { + _seekRevision++; + _pendingSeekRevision = null; + if (_activeSeekRevision != null) _controller.cancelPendingSeek(); + setState(() { + _draggingProgress = true; + _dragProgress = progress; + }); + } + void _goToProgress(double progress) { - setState(() => _dragProgress = progress); + final revision = ++_seekRevision; + if (_activeSeekRevision != null) _controller.cancelPendingSeek(); + setState(() { + _draggingProgress = false; + _pendingSeekRevision = revision; + _dragProgress = progress; + }); _enqueueCommand( - (controller) => controller.goToProgress(progress), + (controller) async { + _activeSeekRevision = revision; + _pendingSeekRevision = revision; + try { + await controller.goToProgress(progress); + } finally { + if (_activeSeekRevision == revision) _activeSeekRevision = null; + if (mounted && revision == _seekRevision) { + setState(() { + _pendingSeekRevision = null; + if (!_draggingProgress && controller.snapshot.contentReady) { + _dragProgress = null; + } + }); + } + } + }, context: 'while changing reader progress', + isCurrent: () => revision == _seekRevision, ); } @@ -1106,6 +1174,7 @@ final class _PapyrusReaderState extends State { Future Function(ReaderController controller) operation, { required String context, bool expectedBoundary = false, + bool Function()? isCurrent, }) { if (!mounted) { return; @@ -1131,14 +1200,18 @@ final class _PapyrusReaderState extends State { setState(() {}); final scheduled = _commandQueue.then((_) async { - if (!mounted || generation != _commandGeneration) { + if (!mounted || + generation != _commandGeneration || + !(isCurrent?.call() ?? true)) { return; } try { await operation(controller); } catch (error, stackTrace) { - if (!mounted || generation != _commandGeneration) { + if (!mounted || + generation != _commandGeneration || + !(isCurrent?.call() ?? true)) { return; } if (expectedBoundary && @@ -1151,6 +1224,7 @@ final class _PapyrusReaderState extends State { operation, context: context, expectedBoundary: expectedBoundary, + isCurrent: isCurrent, ); _handleCommandError(error, stackTrace, context); } @@ -1168,9 +1242,11 @@ final class _PapyrusReaderState extends State { } _commandRevision.value++; setState(() {}); - if (restoreReadingFocus) { + if (restoreReadingFocus && (isCurrent?.call() ?? true)) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && + !_draggingProgress && + (isCurrent?.call() ?? true) && generation == _commandGeneration && _panel == _ReaderPanel.none && _compactPanelRoute == null) { diff --git a/lib/src/presentation/reader_controls.dart b/lib/src/presentation/reader_controls.dart index 024383d..dc4f3fd 100644 --- a/lib/src/presentation/reader_controls.dart +++ b/lib/src/presentation/reader_controls.dart @@ -164,6 +164,7 @@ final class ReaderProgressControls extends StatelessWidget { const ReaderProgressControls({ super.key, required this.progress, + required this.onProgressChangeStart, required this.onProgressChanged, required this.onProgressChangeEnd, required this.onPrevious, @@ -174,6 +175,7 @@ final class ReaderProgressControls extends StatelessWidget { }); final double progress; + final ValueChanged onProgressChangeStart; final ValueChanged onProgressChanged; final ValueChanged onProgressChangeEnd; final VoidCallback onPrevious; @@ -207,6 +209,7 @@ final class ReaderProgressControls extends StatelessWidget { ), child: Slider( value: normalized, + onChangeStart: enabled ? onProgressChangeStart : null, onChanged: enabled ? onProgressChanged : null, onChangeEnd: enabled ? onProgressChangeEnd : null, ), diff --git a/lib/src/presentation/reader_loading.dart b/lib/src/presentation/reader_loading.dart new file mode 100644 index 0000000..8407e08 --- /dev/null +++ b/lib/src/presentation/reader_loading.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +/// Keeps engine preparation visually consistent with the host's opening state. +final class ReaderLoadingScope extends InheritedWidget { + const ReaderLoadingScope({ + required this.builder, + required this.backgroundColor, + required super.child, + super.key, + }); + + final WidgetBuilder builder; + final Color backgroundColor; + + static ReaderLoadingScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify(ReaderLoadingScope oldWidget) => + builder != oldWidget.builder || + backgroundColor != oldWidget.backgroundColor; +} + +final class ReaderLoading extends StatelessWidget { + const ReaderLoading({super.key}); + + @override + Widget build(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType(); + return scope?.builder(context) ?? + const Center( + child: CircularProgressIndicator(semanticsLabel: 'Opening document'), + ); + } +} diff --git a/lib/src/presentation/reader_settings_panel.dart b/lib/src/presentation/reader_settings_panel.dart index bfbec71..63916e4 100644 --- a/lib/src/presentation/reader_settings_panel.dart +++ b/lib/src/presentation/reader_settings_panel.dart @@ -146,6 +146,30 @@ final class ReaderSettingsPanel extends StatelessWidget { : null, ), ], + if (capabilities.supportsPageTurnAnimation && + preferences.layoutMode == ReaderLayoutMode.paginated) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey(('reader-animation', preferences.pageTurnAnimation)), + initialValue: preferences.pageTurnAnimation, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Page turn animation', + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem(value: true, child: Text('Slide')), + DropdownMenuItem(value: false, child: Text('None')), + ], + onChanged: enabled + ? (value) { + if (value != null) { + _update(preferences.copyWith(pageTurnAnimation: value)); + } + } + : null, + ), + ], if (capabilities.supportsColumnMode) ...[ const SizedBox(height: 12), DropdownButtonFormField( diff --git a/lib/src/presentation/reader_ui_builders.dart b/lib/src/presentation/reader_ui_builders.dart index b200fbe..39960ea 100644 --- a/lib/src/presentation/reader_ui_builders.dart +++ b/lib/src/presentation/reader_ui_builders.dart @@ -9,6 +9,8 @@ typedef ReaderToolbarBuilder = Widget Function(BuildContext context, ReaderToolbarContext state); typedef ReaderPanelBuilder = Widget Function(BuildContext context, ReaderPanelContext state); +typedef ReaderPanelHeaderBuilder = + Widget Function(BuildContext context, String title); typedef ReaderStateBuilder = Widget Function(BuildContext context, ReaderStateContext state); typedef ReaderViewportBuilder = @@ -74,11 +76,15 @@ final class ReaderPanelContext { this.isBusy = false, this.showHeader = true, this.buildContents, + this.buildHeader, this.navigate, }); final bool showHeader; final WidgetBuilder? buildContents; + + /// Builds the standard panel header with the reader's chrome and close action. + final ReaderPanelHeaderBuilder? buildHeader; final ValueChanged? navigate; final ReaderSnapshot snapshot; final ReaderController controller; @@ -133,6 +139,8 @@ final class ReaderUiBuilders { final ReaderPanelRouteBuilder? compactPanelRoute; final ReaderPanelBuilder? tableOfContents; final ReaderPanelBuilder? settings; + + /// Used while opening the document and preparing its first readable pages. final ReaderStateBuilder? loading; final ReaderStateBuilder? error; final ReaderStateBuilder? empty; diff --git a/test/engine/epub_seek_test.dart b/test/engine/epub_seek_test.dart new file mode 100644 index 0000000..1524d29 --- /dev/null +++ b/test/engine/epub_seek_test.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; +import 'package:papyrus_reader/src/engine/epub/worker/epub_worker.dart'; + +import '../support/in_process_epub_worker.dart'; +import '../support/synthetic_epub.dart'; + +void main() { + for (final lateError in [false, true]) { + test( + 'latest seek does not wait for an obsolete chapter read, late error $lateError', + () async { + final worker = _SeekWorker(); + final engine = EpubReaderEngine(workerFactory: () async => worker); + addTearDown(engine.dispose); + await engine.load(_document(), preferences: const ReaderPreferences()); + final visited = []; + engine.addListener(() => visited.add(engine.locator.spineIndex)); + final old = engine.goToProgress(.7); + await worker.started.future; + final latest = engine.goToProgress(1); + await Future.wait([old, latest]).timeout(const Duration(seconds: 2)); + expect(engine.locator.spineIndex, 2); + expect(visited, isNot(contains(1))); + if (lateError) { + worker.gate.completeError(StateError('Obsolete chapter read failed')); + } else { + worker.gate.complete(); + } + await Future.delayed(Duration.zero); + expect(engine.locator.spineIndex, 2); + expect(visited, isNot(contains(1))); + }, + ); + } + + test( + 'superseding a seek cancels its layout demand without waiting for it', + () async { + final engine = EpubReaderEngine( + workerFactory: () async => InProcessEpubWorker(), + ); + addTearDown(engine.dispose); + await engine.load(_document(), preferences: const ReaderPreferences()); + final started = Completer(); + final gate = Completer(); + late bool Function() cancelled; + engine.prepareLocation = (target, blocks, isCancelled) async { + if (target.spineIndex == 1) { + cancelled = isCancelled; + started.complete(); + await gate.future; + } + }; + final old = engine.goToProgress(.7); + await started.future; + final latest = engine.goToProgress(1); + await Future.wait([old, latest]).timeout(const Duration(seconds: 2)); + expect(cancelled(), isTrue); + expect(engine.locator.spineIndex, 2); + gate.complete(); + await Future.delayed(Duration.zero); + expect(engine.locator.spineIndex, 2); + }, + ); +} + +ReaderDocument _document() => ReaderDocument( + id: 'seeking', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub( + firstChapterBody: '

${List.filled(300, 'First').join(' ')}

', + secondChapterBody: '

${List.filled(300, 'Later').join(' ')}

', + ), +); + +final class _SeekWorker implements EpubWorker { + final delegate = InProcessEpubWorker(); + final started = Completer(); + final gate = Completer(); + + @override + Future> request(String command, Object? argument) async { + if (command == 'chapter' && argument == 1) { + if (!started.isCompleted) started.complete(); + await gate.future; + } + return delegate.request(command, argument); + } + + @override + void dispose() => delegate.dispose(); +} diff --git a/test/presentation/epub_page_turn_test.dart b/test/presentation/epub_page_turn_test.dart index 6266aad..5133d35 100644 --- a/test/presentation/epub_page_turn_test.dart +++ b/test/presentation/epub_page_turn_test.dart @@ -6,9 +6,66 @@ import '../support/in_process_epub_worker.dart'; import '../support/synthetic_epub.dart'; void main() { - for (final reducedMotion in [false, true]) { + testWidgets('instant turns do not slide on commands or touch drags', ( + tester, + ) async { + final engine = testEpubEngine(); + await engine.load( + ReaderDocument( + id: 'instant', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub(longChapter: true), + ), + preferences: const ReaderPreferences(), + ); + await tester.pumpWidget( + MaterialApp(home: Builder(builder: engine.buildViewport)), + ); + await tester.pumpAndSettle(); + await pumpReaderCommand(tester, engine.goNext()); + final controller = tester + .widget(find.byType(PageView)) + .controller!; + final location = engine.locator; + final revision = engine.restorationRevision; + await engine.updatePreferences( + engine.preferences.copyWith(pageTurnAnimation: false), + ); + await tester.pump(); + expect(engine.restorationRevision, revision); + expect(engine.locator, location); + expect( + tester.widget(find.byType(PageView)).controller, + same(controller), + ); + final next = engine.goNext(); + await tester.pump(); + expect(controller.page, 2); + await next; + final gesture = await tester.startGesture( + tester.getCenter(find.byType(PageView)), + ); + await gesture.moveBy(const Offset(-150, 0)); + await tester.pump(const Duration(milliseconds: 16)); + expect(controller.page, 2, reason: 'No sliding while a finger is down'); + await gesture.up(); + await tester.pump(); + expect(controller.page, 3); + await tester.pumpAndSettle(); + await tester.drag(find.byType(PageView), const Offset(150, 0)); + await tester.pump(); + expect(controller.page, 2); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + for (final (reducedMotion, pageTurnAnimation) in [ + (false, true), + (true, true), + (false, false), + ]) { testWidgets( - 'boundary turns preserve the committed location until visible, reduced motion $reducedMotion', + 'boundary turns preserve the committed location until visible, reduced motion $reducedMotion, animation $pageTurnAnimation', (tester) async { final engine = testEpubEngine(); await engine.load( @@ -18,7 +75,7 @@ void main() { loadBytes: () async => syntheticEpub(firstChapterBody: '

First page.

'), ), - preferences: const ReaderPreferences(), + preferences: ReaderPreferences(pageTurnAnimation: pageTurnAnimation), ); await tester.pumpWidget( MaterialApp( @@ -36,7 +93,7 @@ void main() { await tester.pump(const Duration(milliseconds: 16)); } - if (!reducedMotion) { + if (!reducedMotion && pageTurnAnimation) { final controller = tester .widget(find.byType(PageView)) .controller!; @@ -55,7 +112,7 @@ void main() { await tester.pump(const Duration(milliseconds: 16)); } - if (!reducedMotion) { + if (!reducedMotion && pageTurnAnimation) { final controller = tester .widget(find.byType(PageView)) .controller!; @@ -260,7 +317,7 @@ void main() { .widget(find.byType(PageView)) .childrenDelegate .estimatedChildCount, - lessThanOrEqualTo(5), + lessThanOrEqualTo(7), ); for (var i = 0; i < 8; i++) { diff --git a/test/presentation/epub_prefetch_test.dart b/test/presentation/epub_prefetch_test.dart new file mode 100644 index 0000000..dbeee3e --- /dev/null +++ b/test/presentation/epub_prefetch_test.dart @@ -0,0 +1,161 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; +import 'package:papyrus_reader/src/engine/epub/worker/epub_worker.dart'; + +import '../support/in_process_epub_worker.dart'; +import '../support/synthetic_epub.dart'; + +void main() { + testWidgets( + 'an immediate turn can use a page prepared before the viewport grows', + (tester) async { + final engine = testEpubEngine(); + await engine.load( + ReaderDocument( + id: 'early-turn', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub(longChapter: true), + ), + preferences: const ReaderPreferences(pageTurnAnimation: false), + ); + await tester.pumpWidget( + MaterialApp(home: Builder(builder: engine.buildViewport)), + ); + for ( + var frame = 0; + frame < 100 && !engine.snapshot.contentReady; + frame++ + ) { + await tester.pump(const Duration(milliseconds: 1)); + } + expect(engine.snapshot.contentReady, isTrue); + final before = engine.locator.textOffset!; + await pumpReaderCommand(tester, engine.goNext()); + expect( + tester.widget(find.byType(PageView)).controller!.page, + 1, + ); + expect(engine.locator.textOffset, greaterThan(before)); + }, + ); + + for (final blocked in [0, 2]) { + testWidgets( + 'a slow neighbor $blocked does not block preloading the other direction', + (tester) async { + final worker = _GatedNeighbor(blocked); + final engine = EpubReaderEngine(workerFactory: () async => worker); + addTearDown(engine.dispose); + await engine.load( + ReaderDocument( + id: 'neighbors', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub(), + ), + initialLocator: EpubReaderLocator( + spineIndex: 1, + localProgression: 0, + totalProgression: .5, + ), + preferences: const ReaderPreferences(pageTurnAnimation: false), + ); + await tester.pumpWidget( + MaterialApp(home: Builder(builder: engine.buildViewport)), + ); + await tester.pumpAndSettle(); + expect(worker.reads[0], 1); + expect(worker.reads[2], 1); + expect(engine.locator.spineIndex, 1); + final destination = blocked == 0 ? 2 : 0; + await pumpReaderCommand( + tester, + blocked == 0 ? engine.goNext() : engine.goPrevious(), + ); + expect(engine.locator.spineIndex, destination); + expect( + worker.reads[destination], + 1, + reason: 'Use the already prepared neighbor', + ); + worker.gate.complete(); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets( + 'keeps five forward turns ready and retains pages for reversing direction', + (tester) async { + final engine = testEpubEngine(); + await engine.load( + ReaderDocument( + id: 'buffered', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub( + singleSpine: true, + firstChapterBody: List.filled( + 1000, + '

A long uninterrupted book keeps nearby pages ready.

', + ).join(), + ), + ), + preferences: const ReaderPreferences(pageTurnAnimation: false), + ); + await tester.pumpWidget( + MaterialApp(home: Builder(builder: engine.buildViewport)), + ); + await tester.pumpAndSettle(); + final view = tester.widget(find.byType(PageView)); + expect( + view.childrenDelegate.estimatedChildCount, + greaterThanOrEqualTo(6), + ); + expect( + view.childrenDelegate.estimatedChildCount, + lessThan(12), + reason: 'Do not paginate the whole book to turn', + ); + final start = engine.locator.textOffset; + for (var page = 1; page <= 5; page++) { + final turn = engine.goNext(); + await tester.pump(); + expect(view.controller!.page, page); + await turn; + } + for (var page = 4; page >= 0; page--) { + final turn = engine.goPrevious(); + await tester.pump(); + expect(view.controller!.page, page); + await turn; + } + expect(engine.locator.textOffset, start); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); +} + +final class _GatedNeighbor implements EpubWorker { + _GatedNeighbor(this.blocked); + final int blocked; + final delegate = InProcessEpubWorker(); + final gate = Completer(); + final reads = {}; + + @override + Future> request(String command, Object? argument) async { + if (command == 'chapter') { + final index = argument as int; + reads.update(index, (count) => count + 1, ifAbsent: () => 1); + if (index == blocked) await gate.future; + } + return delegate.request(command, argument); + } + + @override + void dispose() => delegate.dispose(); +} diff --git a/test/presentation/epub_seek_test.dart b/test/presentation/epub_seek_test.dart new file mode 100644 index 0000000..69068cc --- /dev/null +++ b/test/presentation/epub_seek_test.dart @@ -0,0 +1,101 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; +import 'package:papyrus_reader/src/engine/epub/worker/epub_worker.dart'; + +import '../support/in_process_epub_worker.dart'; +import '../support/synthetic_epub.dart'; + +void main() { + testWidgets( + 'rapid scrubbing displays and persists only the latest prepared destination', + (tester) async { + final worker = _SlowChapterWorker(); + final engine = EpubReaderEngine(workerFactory: () async => worker); + final controller = ReaderController( + registry: ReaderEngineRegistry([ + ReaderEngineRegistration( + formats: const {ReaderFormat.epub}, + factory: () => engine, + ), + ]), + ); + addTearDown(controller.dispose); + final saved = []; + await tester.pumpWidget( + MaterialApp( + home: PapyrusReader( + controller: controller, + document: ReaderDocument( + id: 'scrub', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub( + firstChapterBody: + '

${List.filled(300, 'First').join(' ')}

', + secondChapterBody: + '

${List.filled(300, 'Later').join(' ')}

', + ), + ), + initialPreferences: const ReaderPreferences( + pageTurnAnimation: false, + ), + onLocatorChanged: (locator) => + saved.add(locator as EpubReaderLocator), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + saved.clear(); + Slider slider() => tester.widget(find.byType(Slider).first); + slider().onChangeEnd!(.7); + await tester.pump(); + expect(worker.blocked, isTrue); + + slider().onChangeStart!(.7); + slider().onChanged!(.3); + await tester.pumpAndSettle(); + expect(slider().value, .3); + expect(engine.locator.spineIndex, 0); + expect(saved, isEmpty); + + slider().onChangeEnd!(.3); + slider().onChangeEnd!(1); + await tester.pumpAndSettle(); + expect(worker.gate.isCompleted, isFalse); + expect(controller.snapshot.contentReady, isTrue); + expect(engine.locator.spineIndex, 2); + expect(find.text('Chapter Three'), findsOneWidget); + expect(saved, isNotEmpty); + expect(saved.every((locator) => locator.spineIndex == 2), isTrue); + final position = engine.locator; + worker.gate.complete(); + await tester.pumpAndSettle(); + expect(engine.locator, position); + expect(saved.every((locator) => locator.spineIndex == 2), isTrue); + expect(tester.takeException(), isNull); + }, + ); +} + +final class _SlowChapterWorker implements EpubWorker { + final delegate = InProcessEpubWorker(); + final gate = Completer(); + bool blocked = false; + + @override + Future> request(String command, Object? argument) async { + if (command == 'chapter' && argument == 1) { + blocked = true; + await gate.future; + } + return delegate.request(command, argument); + } + + @override + void dispose() => delegate.dispose(); +} diff --git a/test/presentation/epub_selection_test.dart b/test/presentation/epub_selection_test.dart index d4e5469..e1615e1 100644 --- a/test/presentation/epub_selection_test.dart +++ b/test/presentation/epub_selection_test.dart @@ -9,9 +9,14 @@ import '../support/in_process_epub_worker.dart'; import '../support/synthetic_epub.dart'; void main() { - for (final kind in [PointerDeviceKind.mouse, PointerDeviceKind.touch]) { + for (final (kind, animation) in [ + (PointerDeviceKind.mouse, true), + (PointerDeviceKind.touch, true), + (PointerDeviceKind.mouse, false), + (PointerDeviceKind.touch, false), + ]) { testWidgets( - 'selecting text at a chapter boundary does not turn the page ($kind)', + 'selecting text at a chapter boundary does not turn the page ($kind, animation $animation)', (tester) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(390, 844); @@ -27,6 +32,9 @@ void main() { : TargetPlatform.linux, ), home: PapyrusReader( + initialPreferences: ReaderPreferences( + pageTurnAnimation: animation, + ), onAnnotate: (selection) => saved = selection, document: ReaderDocument( id: 'selection', diff --git a/test/presentation/papyrus_reader_test.dart b/test/presentation/papyrus_reader_test.dart index e359408..f722344 100644 --- a/test/presentation/papyrus_reader_test.dart +++ b/test/presentation/papyrus_reader_test.dart @@ -1070,6 +1070,106 @@ void main() { expect(find.text('Reading mode'), findsOneWidget); }); + testWidgets( + 'slider releases coalesce and keep the latest preview while waiting', + (tester) async { + final gate = Completer(); + final engine = _UiReaderEngine(progressGates: [gate]); + final controller = controllerFor([engine]); + addTearDown(controller.dispose); + await pumpReader( + tester, + document: document('Seek'), + controller: controller, + ); + await tester.pumpAndSettle(); + Slider slider() => tester.widget(find.byType(Slider).first); + slider().onChangeEnd!(.2); + await tester.pump(); + slider().onChangeEnd!(.4); + slider().onChangeEnd!(.9); + await tester.pump(); + expect(engine.goToProgressCalls, [.2]); + engine._publishLocator( + EpubReaderLocator( + spineIndex: 0, + localProgression: 0, + totalProgression: 0, + ), + ); + await tester.pump(); + expect(slider().value, .9); + gate.complete(); + await tester.pumpAndSettle(); + expect(engine.goToProgressCalls, [.2, .9]); + expect(slider().value, .9); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('background updates do not move the thumb during a drag', ( + tester, + ) async { + final engine = _UiReaderEngine(); + final controller = controllerFor([engine]); + addTearDown(controller.dispose); + await pumpReader( + tester, + document: document('Drag'), + controller: controller, + ); + await tester.pumpAndSettle(); + Slider slider() => tester.widget(find.byType(Slider).first); + slider().onChangeStart!(0); + slider().onChanged!(.8); + engine._publishLocator( + EpubReaderLocator( + spineIndex: 0, + localProgression: 0, + totalProgression: 0, + ), + ); + await tester.pump(); + expect(slider().value, .8); + expect(engine.goToProgressCalls, isEmpty); + slider().onChangeEnd!(.8); + await tester.pumpAndSettle(); + expect(engine.goToProgressCalls, [.8]); + }); + + testWidgets('obsolete seek errors do not replace the latest result', ( + tester, + ) async { + final gate = Completer(); + final engine = _UiReaderEngine( + progressGates: [gate], + progressFailures: [ + const ReaderException( + ReaderErrorCode.invalidDocument, + 'Obsolete seek failure', + ), + ], + ); + final controller = controllerFor([engine]); + addTearDown(controller.dispose); + await pumpReader( + tester, + document: document('Failure'), + controller: controller, + ); + await tester.pumpAndSettle(); + final slider = tester.widget(find.byType(Slider).first); + slider.onChangeEnd!(.2); + await tester.pump(); + slider.onChangeEnd!(.9); + gate.complete(); + await tester.pumpAndSettle(); + expect(engine.goToProgressCalls, [.2, .9]); + expect(find.text('Obsolete seek failure'), findsNothing); + expect(tester.widget(find.byType(Slider).first).value, .9); + expect(tester.takeException(), isNull); + }); + testWidgets('slider, buttons, and keyboard delegate navigation', ( tester, ) async { @@ -1607,6 +1707,8 @@ final class _UiReaderEngine extends ReaderEngine { this.loadGate, this.loadFailure, this.nextGates = const [], + this.progressGates = const [], + this.progressFailures = const [], this.nextFailure, this.previousFailure, this.preferenceFailure, @@ -1622,6 +1724,8 @@ final class _UiReaderEngine extends ReaderEngine { final Widget Function(BuildContext, ReaderSnapshot)? viewportBuilder; final ReaderException? loadFailure; final List> nextGates; + final List> progressGates; + final List progressFailures; final ReaderException? nextFailure; final ReaderException? previousFailure; final ReaderException? preferenceFailure; @@ -1717,7 +1821,12 @@ final class _UiReaderEngine extends ReaderEngine { @override Future goToProgress(double progress) async { + final call = goToProgressCalls.length; goToProgressCalls.add(progress); + if (call < progressGates.length) await progressGates[call].future; + if (call < progressFailures.length && progressFailures[call] != null) { + throw progressFailures[call]!; + } _publishLocator( EpubReaderLocator( spineIndex: 0, diff --git a/test/presentation/reader_loading_motion_test.dart b/test/presentation/reader_loading_motion_test.dart new file mode 100644 index 0000000..27ebd2c --- /dev/null +++ b/test/presentation/reader_loading_motion_test.dart @@ -0,0 +1,247 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; + +import '../support/in_process_epub_worker.dart'; +import '../support/synthetic_epub.dart'; + +void main() { + testWidgets( + 'host loader stays consistent through opening and initial pagination', + (tester) async { + final gate = Completer(); + final engine = testEpubEngine(); + final phases = {}; + const loaderKey = ValueKey('host-loader'); + await tester.pumpWidget( + MaterialApp( + home: PapyrusReader( + registry: ReaderEngineRegistry([ + ReaderEngineRegistration( + formats: const {ReaderFormat.epub}, + factory: () => engine, + ), + ]), + document: ReaderDocument( + id: 'loading', + format: ReaderFormat.epub, + loadBytes: () async { + await gate.future; + return syntheticEpub(longChapter: true); + }, + ), + initialPreferences: const ReaderPreferences( + brightness: Brightness.dark, + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + builders: ReaderUiBuilders( + loading: (context, state) { + phases.add(state.snapshot.status); + expect(Theme.of(context).brightness, Brightness.light); + return const Center( + child: CircularProgressIndicator(key: loaderKey), + ); + }, + ), + ), + ), + ); + expect(find.byKey(loaderKey), findsOneWidget); + final center = tester.getCenter(find.byKey(loaderKey)); + gate.complete(); + + for ( + var frame = 0; + frame < 100 && !engine.snapshot.contentReady; + frame++ + ) { + await tester.pump(const Duration(milliseconds: 16)); + expect(find.text('Preparing pages…'), findsNothing); + if (find.byType(CircularProgressIndicator).evaluate().isNotEmpty) { + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byKey(loaderKey), findsOneWidget); + expect(tester.getCenter(find.byKey(loaderKey)), center); + } + } + + await tester.pumpAndSettle(); + expect(phases, containsAll([ReaderStatus.loading, ReaderStatus.ready])); + expect(engine.snapshot.contentReady, isTrue); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byType(PageView), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('safe-area color stays with readable pages until a new load', ( + tester, + ) async { + tester.view.padding = const FakeViewPadding(top: 24, bottom: 20); + addTearDown(tester.view.resetPadding); + final engines = []; + final controller = ReaderController( + initialPreferences: const ReaderPreferences( + brightness: Brightness.dark, + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + registry: ReaderEngineRegistry([ + ReaderEngineRegistration( + formats: const {ReaderFormat.epub}, + factory: () { + final engine = testEpubEngine(); + engines.add(engine); + return engine; + }, + ), + ]), + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: PapyrusReader( + controller: controller, + document: ReaderDocument( + id: 'insets', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub(longChapter: true), + ), + ), + ), + ); + + Color? insetColor() => tester + .widget( + find + .descendant( + of: find.byType(PapyrusReader), + matching: find.byType(Material), + ) + .first, + ) + .color; + + final hostColor = Theme.of( + tester.element(find.byType(PapyrusReader)), + ).colorScheme.surface; + expect(insetColor(), hostColor); + await tester.pumpAndSettle(); + final engine = engines.single; + expect(engine.snapshot.contentReady, isTrue); + expect(insetColor(), Colors.black); + + engine.viewportPreparing(); + expect(engine.snapshot.contentReady, isFalse); + await tester.pump(); + expect(insetColor(), Colors.black); + await tester.pumpAndSettle(); + + await controller.updatePreferences( + controller.preferences.copyWith(fontSize: 28), + ); + expect(controller.snapshot.contentReady, isFalse); + await tester.pump(); + expect(insetColor(), Colors.black); + await tester.pumpAndSettle(); + + var finished = false; + final turn = controller + .goTo( + EpubReaderLocator( + spineIndex: 1, + localProgression: 0, + totalProgression: .9, + ), + ) + .whenComplete(() => finished = true); + for (var frame = 0; frame < 100 && !finished; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(insetColor(), Colors.black); + } + expect(finished, isTrue); + await turn; + await tester.pumpAndSettle(); + expect(engine.locator.spineIndex, 1); + + final gate = Completer(); + final load = controller.load( + ReaderDocument( + id: 'replacement', + format: ReaderFormat.epub, + loadBytes: () async { + await gate.future; + return syntheticEpub(longChapter: true); + }, + ), + ); + await tester.pump(); + expect(insetColor(), hostColor); + gate.complete(); + for ( + var frame = 0; + frame < 100 && !controller.snapshot.contentReady; + frame++ + ) { + await tester.pump(const Duration(milliseconds: 16)); + if (!controller.snapshot.contentReady) { + expect(insetColor(), hostColor); + } + } + await load; + await tester.pumpAndSettle(); + expect(controller.snapshot.contentReady, isTrue); + expect(insetColor(), Colors.black); + expect(tester.takeException(), isNull); + }); + + testWidgets('reading settings update animation without moving the page', ( + tester, + ) async { + final engine = testEpubEngine(); + final changes = []; + await tester.pumpWidget( + MaterialApp( + home: PapyrusReader( + registry: ReaderEngineRegistry([ + ReaderEngineRegistration( + formats: const {ReaderFormat.epub}, + factory: () => engine, + ), + ]), + document: ReaderDocument( + id: 'settings', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub(longChapter: true), + ), + onPreferencesChanged: changes.add, + ), + ), + ); + await tester.pumpAndSettle(); + await pumpReaderCommand(tester, engine.goNext()); + final location = engine.locator; + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Reading settings')); + await tester.pumpAndSettle(); + final animation = find.byType(DropdownButtonFormField); + await tester.ensureVisible(animation); + await tester.tap(animation); + await tester.pumpAndSettle(); + await tester.tap(find.text('None').last); + await tester.pumpAndSettle(); + expect(engine.preferences.pageTurnAnimation, isFalse); + expect(changes.last.pageTurnAnimation, isFalse); + expect(engine.locator, location); + await tester.tap(animation); + await tester.pumpAndSettle(); + await tester.tap(find.text('Slide').last); + await tester.pumpAndSettle(); + expect(engine.preferences.pageTurnAnimation, isTrue); + expect(tester.takeException(), isNull); + }); +} diff --git a/tool/browser/smoke.mjs b/tool/browser/smoke.mjs index 8e717e5..a8e8996 100644 --- a/tool/browser/smoke.mjs +++ b/tool/browser/smoke.mjs @@ -174,6 +174,11 @@ try { const afterWheel = await paragraph.boundingBox(); assert.ok(beforeWheel && afterWheel && Math.abs(beforeWheel.y - afterWheel.y) < 1, 'A normal paginated column must fit without hidden vertical overflow'); + await openSettings(); + await select('Slide', 'None'); + await screenshot('desktop-instant-turns'); + await select('None', 'Slide'); + await closeSettings(); await screenshot('desktop'); await hideControls(); const focusedParagraph = await paragraph.boundingBox(); @@ -216,6 +221,17 @@ try { }; await openFile(resolve(reader, 'example/assets/reading_lab.epub')); await showInitialControls(page.getByText('At the Gate', { exact: true }).first()); + const progressSlider = page.getByRole('slider').last(); + const progressBounds = await progressSlider.boundingBox(); + assert.ok(progressBounds, 'Reading progress must remain interactive'); + const progressY = progressBounds.y + progressBounds.height / 2; + for (const fraction of [0.8, 0.15, 0.9, 1]) { + await page.mouse.move(progressBounds.x + progressBounds.width / 2, progressY); + await page.mouse.down(); + await page.mouse.move(progressBounds.x + progressBounds.width * fraction, progressY, { steps: 3 }); + await page.mouse.up(); + } + await page.waitForFunction(() => document.querySelector('[aria-label^="Chapter 2 of 2"]')); await backToLibrary(); const invalid = resolve(temp, 'invalid.epub'); await writeFile(invalid, Buffer.from([1, 2, 3]));