Skip to content
Merged
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
49 changes: 43 additions & 6 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,8 @@ final class ReaderController extends ChangeNotifier {
bool _isDisposed = false;

ReaderSnapshot get snapshot => _snapshot;
({Object token, ReaderNavigationCause cause})? _navigationCommand;
ReaderNavigationCause navigationCause = ReaderNavigationCause.restore;

ReaderPreferences get preferences => _snapshot.preferences;

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

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

Future<void> goToProgress(double progress) async {
Expand All @@ -138,24 +146,49 @@ final class ReaderController extends ChangeNotifier {
);
}

await _requireReadyEngine().goToProgress(progress);
await _navigate(
ReaderNavigationCause.jump,
(engine) => engine.goToProgress(progress),
);
}

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

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

Future<ReaderLocator?> currentLocator() async {
return _requireReadyEngine().currentLocator();
}

Future<void> updatePreferences(ReaderPreferences preferences) async {
await _requireReadyEngine().updatePreferences(preferences);
_syncFromEngine();
await _navigate(ReaderNavigationCause.reflow, (engine) async {
await engine.updatePreferences(preferences);
if (identical(_engine, engine)) _syncFromEngine();
});
}

Future<void> _navigate(
ReaderNavigationCause cause,
Future<void> Function(ReaderEngine) action,
) async {
final engine = _requireReadyEngine();
final token = Object();
_navigationCommand = (token: token, cause: cause);
navigationCause = cause;
try {
await action(engine);
} finally {
if (identical(_navigationCommand?.token, token)) {
_navigationCommand = null;
}
}
}

void _selectEngine(ReaderEngine engine) {
Expand Down Expand Up @@ -191,6 +224,10 @@ final class ReaderController extends ChangeNotifier {

final engine = _engine;
if (engine != null) {
if (engine.snapshot.locator != _snapshot.locator) {
navigationCause =
_navigationCommand?.cause ?? ReaderNavigationCause.viewport;
}
_publish(engine.snapshot);
}
}
Expand Down
184 changes: 184 additions & 0 deletions lib/src/domain/reader_activity.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import 'package:flutter/foundation.dart';

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,
});

static const int currentVersion = 1;
final String key;
final double start;
final double end;
final int? chapterCount;
final int? pdfPageIndex;

factory ReaderContentCoverage.fromJson(Map<String, Object?> json) {
if (json['version'] != currentVersion) {
throw FormatException(
'Unsupported content coverage version: ${json['version']}.',
);
}
final key = json['key'];
final start = json['start'];
final end = json['end'];
final chapters = json['chapterCount'];
final page = json['pdfPageIndex'];
if (key is! String ||
start is! num ||
end is! num ||
(chapters != null && chapters is! int) ||
(page != null && page is! int)) {
throw const FormatException('Invalid content coverage fields.');
}
final coverage = ReaderContentCoverage(
key: key,
start: start.toDouble(),
end: end.toDouble(),
chapterCount: chapters as int?,
pdfPageIndex: page as int?,
);
coverage._validate();
return coverage;
}

Map<String, Object?> toJson() {
_validate();
return {
'version': currentVersion,
'key': key,
'start': start,
'end': end,
if (chapterCount != null) 'chapterCount': chapterCount,
if (pdfPageIndex != null) 'pdfPageIndex': pdfPageIndex,
};
}

void _validate() {
if (key.trim().isEmpty ||
!start.isFinite ||
!end.isFinite ||
start < 0 ||
end > 1 ||
end < start ||
(chapterCount != null && chapterCount! <= 0) ||
(pdfPageIndex != null && pdfPageIndex! < 0)) {
throw const FormatException(
'Invalid content coverage range or identity.',
);
}
}

@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ReaderContentCoverage &&
key == other.key &&
start == other.start &&
end == other.end &&
chapterCount == other.chapterCount &&
pdfPageIndex == other.pdfPageIndex;

@override
int get hashCode => Object.hash(key, start, end, chapterCount, pdfPageIndex);
}

/// Generic observation only. The host owns timing, persistence, and goal rules.
final class ReaderActivityEvent {
ReaderActivityEvent({
required this.ready,
required this.visible,
required this.cause,
this.locator,
List<ReaderContentCoverage> coverage = const [],
this.atEnd = false,
}) : coverage = List.unmodifiable(coverage);

static const int currentVersion = 1;
final bool ready;
final bool visible;
final ReaderNavigationCause cause;
final ReaderLocator? locator;
final List<ReaderContentCoverage> coverage;
final bool atEnd;

factory ReaderActivityEvent.fromJson(Map<String, Object?> json) {
if (json['version'] != currentVersion) {
throw FormatException(
'Unsupported reader activity version: ${json['version']}.',
);
}
final ready = json['ready'];
final visible = json['visible'];
final atEnd = json['atEnd'];
final cause = json['cause'];
final locator = json['locator'];
final coverage = json['coverage'];
if (ready is! bool ||
visible is! bool ||
atEnd is! bool ||
coverage is! List ||
(locator != null && locator is! Map<String, Object?>) ||
!ReaderNavigationCause.values.any((value) => value.name == cause) ||
coverage.any((value) => value is! Map<String, Object?>)) {
throw const FormatException('Invalid reader activity fields.');
}
return ReaderActivityEvent(
ready: ready,
visible: visible,
atEnd: atEnd,
cause: ReaderNavigationCause.values.firstWhere(
(value) => value.name == cause,
),
locator: locator == null
? null
: ReaderLocator.fromJson(locator as Map<String, Object?>),
coverage: coverage
.map(
(value) =>
ReaderContentCoverage.fromJson(value as Map<String, Object?>),
)
.toList(),
);
}

Map<String, Object?> toJson() => {
'version': currentVersion,
'ready': ready,
'visible': visible,
'atEnd': atEnd,
'cause': cause.name,
if (locator != null) 'locator': locator!.toJson(),
'coverage': coverage.map((value) => value.toJson()).toList(),
};

@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ReaderActivityEvent &&
ready == other.ready &&
visible == other.visible &&
cause == other.cause &&
locator == other.locator &&
atEnd == other.atEnd &&
listEquals(coverage, other.coverage);

@override
int get hashCode => Object.hash(
ready,
visible,
cause,
locator,
atEnd,
Object.hashAll(coverage),
);
}
16 changes: 15 additions & 1 deletion 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,21 @@ final class ReaderReadySnapshot extends ReaderSnapshot {
this.locator,
List<ReaderTocEntry> toc = const [],
this.locationLabel,
}) : toc = List.unmodifiable(toc);
List<ReaderContentCoverage> coverage = const [],
this.contentReady = true,
this.atEnd = false,
}) : toc = List.unmodifiable(toc),
coverage = List.unmodifiable(coverage);

@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
Loading
Loading