Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,19 @@ loads the worker through `rootBundle` and creates a local Blob URL. A restrictiv
Content Security Policy must allow `worker-src blob:`; do not fall back silently
to main-thread parsing if worker initialization fails. Offline deployment must
cache the packaged worker alongside other Flutter assets.

## Optional activity observations

`PapyrusReader(onActivity: ...)` reports `ReaderActivityEvent` readiness, visibility,
locator, navigation cause, exposed stable coverage, and end-of-document state.
The callback is optional and independent of Goals. Existing locator callbacks and
version-1 locators remain unchanged. Hosts own clocks, foreground lifecycle,
completion confirmation, persistence, and aggregation.

Settings and contents panels mark content obscured. EPUB coverage uses normalized
chapter UTF-16 content extents, with spine count for host calibration; PDF coverage
lists actually exposed page indices, including spreads. Jumps never expose skipped
pages. Engines without page metrics retain time tracking through ready snapshots.
Parsing remains in the existing worker/isolate transports; activity reporting does
not require whole-book layout. Reflow reports coverage without changing the
restored locator's precise content offset.
1 change: 1 addition & 0 deletions lib/papyrus_reader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ library;

export 'src/controller/reader_controller.dart';
export 'src/domain/reader_capabilities.dart';
export 'src/domain/reader_activity.dart';
export 'src/domain/reader_document.dart';
export 'src/domain/reader_exception.dart';
export 'src/domain/reader_locator.dart';
Expand Down
8 changes: 8 additions & 0 deletions lib/src/controller/reader_controller.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart';

import '../domain/reader_document.dart';
import '../domain/reader_activity.dart';
import '../domain/reader_exception.dart';
import '../domain/reader_locator.dart';
import '../domain/reader_preferences.dart';
Expand Down Expand Up @@ -32,6 +33,7 @@ final class ReaderController extends ChangeNotifier {
bool _isDisposed = false;

ReaderSnapshot get snapshot => _snapshot;
ReaderNavigationCause navigationCause = ReaderNavigationCause.restore;

ReaderPreferences get preferences => _snapshot.preferences;

Expand All @@ -57,6 +59,7 @@ final class ReaderController extends ChangeNotifier {
candidate.dispose();
}
_loadingEngines.clear();
navigationCause = ReaderNavigationCause.restore;
final request = _ReaderLoadRequest(
id: ++_latestLoadId,
document: document,
Expand Down Expand Up @@ -126,6 +129,7 @@ final class ReaderController extends ChangeNotifier {
}

Future<void> goTo(ReaderLocator locator) async {
navigationCause = ReaderNavigationCause.jump;
await _requireReadyEngine().goTo(locator);
}

Expand All @@ -138,14 +142,17 @@ final class ReaderController extends ChangeNotifier {
);
}

navigationCause = ReaderNavigationCause.jump;
await _requireReadyEngine().goToProgress(progress);
}

Future<void> goNext() async {
navigationCause = ReaderNavigationCause.turn;
await _requireReadyEngine().goNext();
}

Future<void> goPrevious() async {
navigationCause = ReaderNavigationCause.turn;
await _requireReadyEngine().goPrevious();
}

Expand All @@ -154,6 +161,7 @@ final class ReaderController extends ChangeNotifier {
}

Future<void> updatePreferences(ReaderPreferences preferences) async {
navigationCause = ReaderNavigationCause.reflow;
await _requireReadyEngine().updatePreferences(preferences);
_syncFromEngine();
}
Expand Down
38 changes: 38 additions & 0 deletions lib/src/domain/reader_activity.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'reader_locator.dart';

/// Navigation is descriptive; hosts must not count skipped locator distances.
enum ReaderNavigationCause { restore, turn, jump, reflow, viewport }

/// Stable visible content. EPUB extents use normalized chapter UTF-16 offsets.
final class ReaderContentCoverage {
const ReaderContentCoverage({
required this.key,
required this.start,
required this.end,
this.chapterCount,
this.pdfPageIndex,
});
final String key;
final double start;
final double end;
final int? chapterCount;
final int? pdfPageIndex;
}

/// Generic observation only. The host owns timing, persistence, and goal rules.
final class ReaderActivityEvent {
const ReaderActivityEvent({
Comment on lines +23 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make the new activity domain types serializable

The newly exported ReaderActivityEvent and its nested ReaderContentCoverage provide no toJson/fromJson representation, so hosts cannot serialize the new host-facing observations through the package's domain API and must invent incompatible enum and field encodings. Add a stable versioned serialization format for both types.

AGENTS.md reference: AGENTS.md:L7-L7

Useful? React with 👍 / 👎.

required this.ready,
required this.visible,
required this.cause,
this.locator,
this.coverage = const [],
this.atEnd = false,
});
final bool ready;
final bool visible;
final ReaderNavigationCause cause;
final ReaderLocator? locator;
final List<ReaderContentCoverage> coverage;
final bool atEnd;
}
13 changes: 13 additions & 0 deletions lib/src/domain/reader_snapshot.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'reader_capabilities.dart';
import 'reader_activity.dart';
import 'reader_document.dart';
import 'reader_exception.dart';
import 'reader_locator.dart';
Expand All @@ -24,6 +25,9 @@ sealed class ReaderSnapshot {
ReaderException? get error;

String? get locationLabel => null;
List<ReaderContentCoverage> get coverage => const [];
bool get contentReady => status == ReaderStatus.ready;
bool get atEnd => false;
}

final class ReaderIdleSnapshot extends ReaderSnapshot {
Expand Down Expand Up @@ -88,11 +92,20 @@ final class ReaderReadySnapshot extends ReaderSnapshot {
this.locator,
List<ReaderTocEntry> toc = const [],
this.locationLabel,
this.coverage = const [],
this.contentReady = true,
this.atEnd = false,
}) : toc = List.unmodifiable(toc);
Comment on lines 93 to 98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve snapshot immutability for coverage

When a host modifies snapshot.coverage or the same list received through ReaderActivityEvent, this constructor has retained the caller's mutable list rather than copying it as it does for toc. For EPUB this can also mutate the engine's _coverage list directly, silently changing the published snapshot without notification and corrupting subsequent activity observations; wrap coverage with List.unmodifiable in the initializer.

Useful? React with 👍 / 👎.


@override
ReaderStatus get status => ReaderStatus.ready;

@override
final List<ReaderContentCoverage> coverage;
@override
final bool contentReady;
@override
final bool atEnd;
@override
final String? locationLabel;

Expand Down
50 changes: 50 additions & 0 deletions lib/src/engine/epub/epub_reader_engine.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart';

import '../../domain/reader_capabilities.dart';
import '../../domain/reader_activity.dart';
import '../../domain/reader_document.dart';
import '../../domain/reader_exception.dart';
import '../../domain/reader_locator.dart';
Expand Down Expand Up @@ -45,6 +46,9 @@ final class EpubReaderEngine extends ReaderEngine {
bool _disposed = false;
int _visiblePage = 1;
int _pageCount = 0;
List<ReaderContentCoverage> _coverage = const [];
bool _viewportReady = false;
bool _atEnd = false;

/// The mounted viewport handles local page/scroll turns. The engine crosses
/// the spine only when that viewport reports a boundary.
Expand Down Expand Up @@ -169,6 +173,9 @@ final class EpubReaderEngine extends ReaderEngine {
}

void _setChapter(Map<String, Object?> chapter, EpubReaderLocator target) {
_coverage = const [];
_viewportReady = false;
_atEnd = false;
_currentChapterHtml = chapter['html'] as String;
_blocks = [
for (final block in chapter['blocks'] as List)
Expand Down Expand Up @@ -212,6 +219,9 @@ final class EpubReaderEngine extends ReaderEngine {
);
}
final generation = ++_navigationGeneration;
_coverage = const [];
_viewportReady = false;
_atEnd = false;
if (locator.spineIndex == _locator?.spineIndex) {
final offset = locator.anchor == null
? locator.textOffset
Expand Down Expand Up @@ -291,6 +301,40 @@ final class EpubReaderEngine extends ReaderEngine {
_publish();
}

void viewportCoverageChanged(int start, int end, {bool atEnd = false}) {
if (_disposed || _locator == null) return;
final coverage = _contentLength > 0 && end > start
? ReaderContentCoverage(
key: 'epub:${_locator!.spineIndex}:$_contentLength',
start: start / _contentLength,
end: (end / _contentLength).clamp(0, 1),
chapterCount: _chapterCount,
)
: null;
final nextEnd = atEnd && _locator!.spineIndex == _chapterCount - 1;
if (_viewportReady &&
_atEnd == nextEnd &&
(coverage == null
? _coverage.isEmpty
: _coverage.length == 1 &&
_coverage.first.key == coverage.key &&
_coverage.first.start == coverage.start &&
_coverage.first.end == coverage.end)) {
return;
}
_viewportReady = true;
_coverage = coverage == null ? const [] : [coverage];
_atEnd = nextEnd;
_publish();
}

void viewportPreparing() {
if (_disposed || !_viewportReady) return;
_viewportReady = false;
_coverage = const [];
_publish();
}

void viewportPaginationChanged(int count, int visiblePage) {
if (_disposed || (_pageCount == count && _visiblePage == visiblePage)) {
return;
Expand All @@ -312,6 +356,9 @@ final class EpubReaderEngine extends ReaderEngine {
}
if (_preferences == preferences) return;
_preferences = preferences;
_coverage = const [];
_viewportReady = false;
_atEnd = false;
_restorationRevision++;
_publish();
}
Expand All @@ -320,6 +367,9 @@ final class EpubReaderEngine extends ReaderEngine {
if (_disposed) return;
snapshot = ReaderReadySnapshot(
document: _document!,
coverage: _coverage,
contentReady: _viewportReady,
atEnd: _atEnd,
preferences: _preferences,
capabilities: capabilities,
locator: _locator,
Expand Down
40 changes: 40 additions & 0 deletions lib/src/engine/epub/epub_viewport.dart
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ final class _EpubViewportState extends State<EpubViewport> {
_busy = false;
if (_layoutReady?.isCompleted == false) _layoutReady!.complete();
widget.engine.viewportPaginationChanged(pages.length, target + 1);
if (widget.engine.preferences.layoutMode == ReaderLayoutMode.paginated) {
_publishCoverage(_pageIndex * _columns);
} else {
_scrollChanged();
}
});
}

Expand Down Expand Up @@ -159,6 +164,18 @@ final class _EpubViewportState extends State<EpubViewport> {
textOffset: _pages[page].offset,
pageNumber: page + 1,
);
_publishCoverage(page);
}

void _publishCoverage(int page) {
final after = math.min(page + _columns, _pages.length);
widget.engine.viewportCoverageChanged(
_pages[page].offset,
after < _pages.length
? _pages[after].offset
: widget.engine.contentLength,
atEnd: after == _pages.length,
);
}

void _scrollChanged() {
Expand All @@ -185,6 +202,21 @@ final class _EpubViewportState extends State<EpubViewport> {
textOffset: offset,
pageNumber: index + 1,
);
final bottom = pixels + _scrollController.position.viewportDimension;
var lastOffset = offset;
for (var i = index; i < _pages.length && _scrollOffsets[i] < bottom; i++) {
var top = _scrollOffsets[i] + widget.engine.preferences.pageMargins.top;
for (final fragment in _pages[i].fragments) {
if (top >= bottom) break;
lastOffset = fragment.block.offset + fragment.end;
top += fragment.height + fragment.spacing(widget.engine.preferences);
}
}
widget.engine.viewportCoverageChanged(
offset,
lastOffset.clamp(0, widget.engine.contentLength),
atEnd: pixels >= max - 1,
);
}

void _beyondChapter(int direction) {
Expand Down Expand Up @@ -219,6 +251,9 @@ final class _EpubViewportState extends State<EpubViewport> {
final preferences = widget.engine.preferences;
if (preferences.layoutMode == ReaderLayoutMode.scroll &&
widget.engine.renderer != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.engine.viewportCoverageChanged(0, 0);
});
return EpubScrollViewport(
xhtml: widget.engine.currentChapterHtml,
preferences: preferences,
Expand Down Expand Up @@ -265,6 +300,11 @@ final class _EpubViewportState extends State<EpubViewport> {
);
if (_layoutKey != key) {
_layoutKey = key;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _layoutKey == key && _busy) {
widget.engine.viewportPreparing();
}
});
_restoration = -1;
_restoring = true;
final generation = ++_layoutGeneration;
Expand Down
14 changes: 14 additions & 0 deletions lib/src/engine/pdf/pdf_facade.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ final class PdfViewportConfiguration {
required this.layoutMode,
required this.onPageChanged,
this.onPositionChanged,
this.onVisiblePagesChanged,
});

final int pageIndex;
Expand All @@ -47,6 +48,7 @@ final class PdfViewportConfiguration {
final ReaderLayoutMode layoutMode;
final ValueChanged<int> onPageChanged;
final void Function(int pageIndex, double pageOffset)? onPositionChanged;
final ValueChanged<List<int>>? onVisiblePagesChanged;
}

abstract interface class PdfFacade {
Expand Down Expand Up @@ -373,6 +375,18 @@ final class _PdfrxFacade implements PdfFacade, DisposablePdfFacade {
0.0,
1.0,
);
final visible = _controller.visibleRect;
final exposed = <int>[];
for (var i = 0; i < _controller.layout.pageLayouts.length; i++) {
final rect = _controller.layout.pageLayouts[i];
if (!rect.overlaps(visible)) continue;
final intersection = rect.intersect(visible);
if (intersection.width * intersection.height >=
rect.width * rect.height * .25) {
exposed.add(i);
Comment on lines +383 to +386

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep zoomed PDF content ready while it is visible

When a PDF page is zoomed enough that the viewport covers less than 25% of the page, this filter returns no exposed pages even though the user is actively reading a visible portion. PdfReaderEngine then derives contentReady: false, which also forces activity visible to false and stops host time tracking throughout zoomed reading; readiness should remain true after the viewer is ready even if the stable-coverage threshold excludes the current fragment.

Useful? React with 👍 / 👎.

}
}
config?.onVisiblePagesChanged?.call(exposed);
_pageIndex = index;
_pageOffset = offset;
if (config?.onPositionChanged != null) {
Expand Down
Loading
Loading