- Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
472 lines
18 KiB
Swift
472 lines
18 KiB
Swift
import UIKit
|
|
|
|
enum RDEPUBPendingPageMapUpdateKind {
|
|
case reconcileFullMap
|
|
case extendPartial(currentPageNumber: Int, currentLocation: RDEPUBLocation?)
|
|
case appendForward
|
|
}
|
|
|
|
struct RDEPUBPendingPageMapUpdate {
|
|
let pageMap: RDEPUBBookPageMap
|
|
let kind: RDEPUBPendingPageMapUpdateKind
|
|
}
|
|
|
|
final class RDEPUBPresentationRuntime {
|
|
|
|
private unowned let context: RDEPUBReaderContext
|
|
|
|
private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator
|
|
|
|
private unowned let jumpSessionManager: RDEPUBJumpSessionManager
|
|
|
|
private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
|
|
|
|
private var pendingCommitRetryWorkItem: DispatchWorkItem?
|
|
|
|
init(
|
|
context: RDEPUBReaderContext,
|
|
locationCoordinator: RDEPUBReaderLocationCoordinator,
|
|
jumpSessionManager: RDEPUBJumpSessionManager,
|
|
reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
|
|
) {
|
|
self.context = context
|
|
self.locationCoordinator = locationCoordinator
|
|
self.jumpSessionManager = jumpSessionManager
|
|
self.reconciliationCoordinator = reconciliationCoordinator
|
|
}
|
|
|
|
func applyBookPageMap(
|
|
_ bookPageMap: RDEPUBBookPageMap,
|
|
restoreLocation: RDEPUBLocation?,
|
|
finishPagination: (RDEPUBLocation?) -> Void
|
|
) {
|
|
context.textBook = nil
|
|
context.bookPageMap = bookPageMap
|
|
context.pendingPageMapUpdates.removeAll()
|
|
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
|
finishPagination(restoreLocation)
|
|
}
|
|
|
|
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
|
|
RDEPUBBackgroundTrace.log(
|
|
"Reconciliation",
|
|
"refreshBookPageMapInPlace: enqueued reconcileFullMap chapters=\(bookPageMap.totalChapters) pages=\(bookPageMap.totalPages)"
|
|
)
|
|
enqueuePendingPageMapUpdate(
|
|
RDEPUBPendingPageMapUpdate(
|
|
pageMap: bookPageMap,
|
|
kind: .reconcileFullMap
|
|
)
|
|
)
|
|
}
|
|
|
|
func commitPendingPageMapUpdateIfNeeded() {
|
|
guard let readerView = context.readerView,
|
|
let controller = context.controller else { return }
|
|
|
|
guard !context.pendingPageMapUpdates.isEmpty else {
|
|
cancelPendingCommitRetry()
|
|
return
|
|
}
|
|
|
|
guard !controller.isRepaginating else {
|
|
schedulePendingCommitRetry()
|
|
return
|
|
}
|
|
guard !readerView.isPageCurlTransitioning else {
|
|
schedulePendingCommitRetry()
|
|
return
|
|
}
|
|
|
|
cancelPendingCommitRetry()
|
|
|
|
let rankedUpdates = rankedPendingPageMapUpdates()
|
|
for (index, update) in rankedUpdates {
|
|
if commitPendingPageMapUpdate(
|
|
update,
|
|
at: index,
|
|
readerView: readerView,
|
|
controller: controller
|
|
) {
|
|
if !context.pendingPageMapUpdates.isEmpty {
|
|
schedulePendingCommitRetry()
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
if !context.pendingPageMapUpdates.isEmpty {
|
|
schedulePendingCommitRetry()
|
|
}
|
|
}
|
|
|
|
func queueExtendedPartialPageMap(
|
|
_ bookPageMap: RDEPUBBookPageMap,
|
|
currentPageNumber: Int,
|
|
currentLocation: RDEPUBLocation?
|
|
) {
|
|
enqueuePendingPageMapUpdate(
|
|
RDEPUBPendingPageMapUpdate(
|
|
pageMap: bookPageMap,
|
|
kind: .extendPartial(
|
|
currentPageNumber: currentPageNumber,
|
|
currentLocation: currentLocation
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
func applySettingsPreviewPageMap(_ bookPageMap: RDEPUBBookPageMap) {
|
|
context.bookPageMap = bookPageMap
|
|
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
|
}
|
|
|
|
func queueForwardAppendedPageMap(_ bookPageMap: RDEPUBBookPageMap) {
|
|
enqueuePendingPageMapUpdate(
|
|
RDEPUBPendingPageMapUpdate(
|
|
pageMap: bookPageMap,
|
|
kind: .appendForward
|
|
)
|
|
)
|
|
}
|
|
|
|
func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
|
|
let pages = bookPageMap.entries.flatMap { entry in
|
|
(0..<entry.pageCount).map { localPageIndex in
|
|
EPUBPage(
|
|
spineIndex: entry.spineIndex,
|
|
chapterIndex: bookPageMap.chapterIndex(forSpineIndex: entry.spineIndex) ?? 0,
|
|
pageIndexInChapter: localPageIndex,
|
|
totalPagesInChapter: entry.pageCount,
|
|
chapterTitle: entry.title,
|
|
fixedSpread: nil
|
|
)
|
|
}
|
|
}
|
|
let chapters = bookPageMap.entries.map { entry in
|
|
EPUBChapterInfo(
|
|
spineIndex: entry.spineIndex,
|
|
title: entry.title,
|
|
pageCount: entry.pageCount
|
|
)
|
|
}
|
|
return (pages, chapters)
|
|
}
|
|
|
|
private func applyFullPageMapReplacement(
|
|
_ newPageMap: RDEPUBBookPageMap,
|
|
readerView: RDEpubReaderView,
|
|
controller: RDEPUBReaderController
|
|
) {
|
|
let currentLocation = locationCoordinator.currentVisibleLocation()
|
|
let livePageBeforeApply = readerView.currentPage + 1
|
|
// Resolved against the outgoing page map. When it round-trips to the live
|
|
// page, the location faithfully describes what is on screen, so whatever
|
|
// page it resolves to in the new map is authoritative even if the two maps
|
|
// number pages differently (partial-window -> full-book takeover).
|
|
let oldResolvedPage = currentLocation.flatMap { controller.pageNumber(for: $0) }
|
|
|
|
context.textBook = nil
|
|
applyPageMapToLiveModel(newPageMap)
|
|
RDEPUBBackgroundTrace.log(
|
|
"Reconciliation",
|
|
"applyFullPageMapReplacement livePageBeforeApply=\(livePageBeforeApply) totalPages=\(newPageMap.totalPages) totalChapters=\(newPageMap.totalChapters)"
|
|
)
|
|
|
|
if let currentLocation {
|
|
let resolvedTargetPage = controller.pageNumber(for: currentLocation)
|
|
let shouldTrustResolvedLocation = shouldTrustFullReplaceResolvedPage(
|
|
resolvedTargetPage,
|
|
livePageBeforeApply: livePageBeforeApply,
|
|
locationMatchesLivePage: oldResolvedPage == livePageBeforeApply
|
|
)
|
|
RDEPUBBackgroundTrace.log(
|
|
"Reconciliation",
|
|
"applyFullPageMapReplacement decision livePage=\(livePageBeforeApply) oldResolvedPage=\(oldResolvedPage ?? -1) resolvedTargetPage=\(resolvedTargetPage ?? -1) trustResolved=\(shouldTrustResolvedLocation) href=\(currentLocation.href)"
|
|
)
|
|
|
|
if shouldTrustResolvedLocation,
|
|
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
|
|
return
|
|
}
|
|
|
|
let fallbackPage = max(livePageBeforeApply, 1)
|
|
RDEPUBBackgroundTrace.log(
|
|
"Reconciliation",
|
|
"applyFullPageMapReplacement preserveLivePage fallbackPage=\(fallbackPage) currentReaderPage=\(readerView.currentPage + 1)"
|
|
)
|
|
rebindVisiblePage(
|
|
to: fallbackPage - 1,
|
|
readerView: readerView
|
|
)
|
|
} else {
|
|
readerView.reloadPageCountOnly()
|
|
}
|
|
|
|
if let currentLocation,
|
|
context.normalizedSpineIndex(for: currentLocation) != nil,
|
|
let activeSession = jumpSessionManager.activeSession {
|
|
let candidateIndices = Set(newPageMap.entries.map { $0.spineIndex })
|
|
let protectedIndices = activeSession.protectedSpineIndices
|
|
if protectedIndices.isSubset(of: candidateIndices) {
|
|
jumpSessionManager.endSession(.coverageComplete)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func enqueuePendingPageMapUpdate(_ update: RDEPUBPendingPageMapUpdate) {
|
|
var updates = context.pendingPageMapUpdates
|
|
if let existingIndex = updates.firstIndex(where: {
|
|
pendingPageMapUpdateKindMatches($0.kind, update.kind)
|
|
}) {
|
|
let existing = updates[existingIndex]
|
|
if shouldReplacePendingPageMapUpdate(existing, with: update) {
|
|
updates[existingIndex] = update
|
|
}
|
|
} else {
|
|
updates.append(update)
|
|
}
|
|
context.pendingPageMapUpdates = updates
|
|
commitPendingPageMapUpdateIfNeeded()
|
|
}
|
|
|
|
private func rankedPendingPageMapUpdates() -> [(Int, RDEPUBPendingPageMapUpdate)] {
|
|
context.pendingPageMapUpdates.enumerated().sorted { lhs, rhs in
|
|
pendingPriority(for: lhs.element.kind) > pendingPriority(for: rhs.element.kind)
|
|
}
|
|
}
|
|
|
|
private func commitPendingPageMapUpdate(
|
|
_ update: RDEPUBPendingPageMapUpdate,
|
|
at index: Int,
|
|
readerView: RDEpubReaderView,
|
|
controller: RDEPUBReaderController
|
|
) -> Bool {
|
|
switch update.kind {
|
|
case .reconcileFullMap:
|
|
let decision = reconciliationCoordinator.evaluateTakeover(
|
|
candidatePageMap: update.pageMap,
|
|
candidateSegment: nil,
|
|
currentWindow: context.bookPageMap,
|
|
jumpSession: jumpSessionManager.activeSession
|
|
)
|
|
|
|
switch decision {
|
|
case .keepCurrentWindow:
|
|
RDEPUBBackgroundTrace.log(
|
|
"Reconciliation",
|
|
"commitPendingPageMapUpdate: keepCurrentWindow — removed pending update, totalPages=\(update.pageMap.totalPages)"
|
|
)
|
|
removePendingPageMapUpdate(at: index)
|
|
return false
|
|
|
|
case .fullReplace(let newPageMap):
|
|
removePendingPageMapUpdate(at: index)
|
|
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
|
|
return true
|
|
|
|
case .expandWindow, .segmentReplace:
|
|
removePendingPageMapUpdate(at: index)
|
|
return false
|
|
}
|
|
|
|
case .extendPartial(let capturedPageNumber, let currentLocation):
|
|
removePendingPageMapUpdate(at: index)
|
|
applyPageMapToLiveModel(update.pageMap)
|
|
let livePageNumber = max(readerView.currentPage, 0) + 1
|
|
let shouldTrustCapturedLocation = livePageNumber == max(capturedPageNumber, 1)
|
|
RDEPUBBackgroundTrace.log(
|
|
"Reconciliation",
|
|
"extendPartial commit capturedPage=\(capturedPageNumber) livePage=\(livePageNumber) trustCaptured=\(shouldTrustCapturedLocation) totalPages=\(update.pageMap.totalPages) totalChapters=\(update.pageMap.totalChapters)"
|
|
)
|
|
if shouldTrustCapturedLocation,
|
|
let currentLocation,
|
|
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
|
|
return true
|
|
}
|
|
// Prefer the live readerView page when the user has moved since the
|
|
// extension request was created; otherwise a stale captured location can
|
|
// snap pageCurl back to the previous page after the turn completes.
|
|
let livePageIndex = max(readerView.currentPage, 0)
|
|
rebindVisiblePage(
|
|
to: livePageIndex,
|
|
readerView: readerView
|
|
)
|
|
return true
|
|
|
|
case .appendForward:
|
|
removePendingPageMapUpdate(at: index)
|
|
applyPageMapToLiveModel(update.pageMap)
|
|
readerView.reloadPageCountOnly()
|
|
return true
|
|
}
|
|
}
|
|
|
|
private func rebindVisiblePage(to pageIndex: Int, readerView: RDEpubReaderView) {
|
|
if pageIndex == readerView.currentPage {
|
|
RDEPUBBackgroundTrace.log(
|
|
"Reconciliation",
|
|
"rebindVisiblePage pageUnchanged currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType)"
|
|
)
|
|
readerView.reloadPageCountOnly()
|
|
return
|
|
}
|
|
|
|
RDEPUBBackgroundTrace.log(
|
|
"Reconciliation",
|
|
"rebindVisiblePage targetPage=\(pageIndex + 1) currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType) isPageCurlTransitioning=\(readerView.isPageCurlTransitioning)"
|
|
)
|
|
if readerView.currentDisplayType == .pageCurl {
|
|
if readerView.isPageCurlTransitioning {
|
|
// Defer the transition until the current page-curl animation completes,
|
|
// and re-read the live page at that time to avoid jumping to a stale position.
|
|
DispatchQueue.main.async { [weak readerView] in
|
|
guard let readerView, !readerView.isPageCurlTransitioning else { return }
|
|
let livePageIndex = max(readerView.currentPage, 0)
|
|
RDEPUBBackgroundTrace.log(
|
|
"Reconciliation",
|
|
"rebindVisiblePage deferredTransition livePage=\(livePageIndex + 1)"
|
|
)
|
|
readerView.transitionToPage(pageNum: livePageIndex, animated: false)
|
|
}
|
|
} else {
|
|
readerView.transitionToPage(pageNum: pageIndex, animated: false)
|
|
}
|
|
} else {
|
|
readerView.reloadPageCountOnly()
|
|
if pageIndex != readerView.currentPage {
|
|
readerView.transitionToPage(pageNum: pageIndex, animated: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func rebindVisibleLocation(
|
|
_ location: RDEPUBLocation,
|
|
readerView: RDEpubReaderView,
|
|
controller: RDEPUBReaderController
|
|
) -> Bool {
|
|
guard let targetPageNumber = controller.pageNumber(for: location) else {
|
|
RDEPUBBackgroundTrace.log(
|
|
"Reconciliation",
|
|
"rebindVisibleLocation failedToResolve locationHref=\(location.href) currentPage=\(readerView.currentPage + 1)"
|
|
)
|
|
return false
|
|
}
|
|
|
|
if context.bookPageMap != nil,
|
|
context.runtime?.prepareOnDemandChapter(
|
|
forAbsolutePageNumber: targetPageNumber,
|
|
allowSynchronousLoad: false
|
|
) == false {
|
|
RDEPUBBackgroundTrace.log(
|
|
"Reconciliation",
|
|
"rebindVisibleLocation prepareOnDemandBlocked targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1)"
|
|
)
|
|
return false
|
|
}
|
|
|
|
RDEPUBBackgroundTrace.log(
|
|
"Reconciliation",
|
|
"rebindVisibleLocation targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1) href=\(location.href)"
|
|
)
|
|
|
|
rebindVisiblePage(
|
|
to: max(targetPageNumber - 1, 0),
|
|
readerView: readerView
|
|
)
|
|
return true
|
|
}
|
|
|
|
private func applyPageMapToLiveModel(_ pageMap: RDEPUBBookPageMap) {
|
|
context.bookPageMap = pageMap
|
|
context.replaceActiveSnapshot(makeSnapshot(from: pageMap))
|
|
discardSupersededPendingPageMapUpdates(afterApplying: pageMap)
|
|
}
|
|
|
|
private func removePendingPageMapUpdate(at index: Int) {
|
|
var updates = context.pendingPageMapUpdates
|
|
guard updates.indices.contains(index) else { return }
|
|
updates.remove(at: index)
|
|
context.pendingPageMapUpdates = updates
|
|
}
|
|
|
|
private func discardSupersededPendingPageMapUpdates(afterApplying liveMap: RDEPUBBookPageMap) {
|
|
let updates = context.pendingPageMapUpdates.filter { update in
|
|
update.pageMap.totalChapters > liveMap.totalChapters
|
|
|| (
|
|
update.pageMap.totalChapters == liveMap.totalChapters
|
|
&& update.pageMap.totalPages > liveMap.totalPages
|
|
)
|
|
}
|
|
context.pendingPageMapUpdates = updates
|
|
}
|
|
|
|
private func pendingPriority(for kind: RDEPUBPendingPageMapUpdateKind) -> Int {
|
|
switch kind {
|
|
case .extendPartial:
|
|
return 3
|
|
case .appendForward:
|
|
return 2
|
|
case .reconcileFullMap:
|
|
return 1
|
|
}
|
|
}
|
|
|
|
private func pendingPageMapUpdateKindMatches(
|
|
_ lhs: RDEPUBPendingPageMapUpdateKind,
|
|
_ rhs: RDEPUBPendingPageMapUpdateKind
|
|
) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.reconcileFullMap, .reconcileFullMap),
|
|
(.appendForward, .appendForward),
|
|
(.extendPartial, .extendPartial):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func shouldReplacePendingPageMapUpdate(
|
|
_ existing: RDEPUBPendingPageMapUpdate,
|
|
with candidate: RDEPUBPendingPageMapUpdate
|
|
) -> Bool {
|
|
candidate.pageMap.totalChapters > existing.pageMap.totalChapters
|
|
|| (
|
|
candidate.pageMap.totalChapters == existing.pageMap.totalChapters
|
|
&& candidate.pageMap.totalPages >= existing.pageMap.totalPages
|
|
)
|
|
}
|
|
|
|
private func shouldTrustFullReplaceResolvedPage(
|
|
_ resolvedTargetPage: Int?,
|
|
livePageBeforeApply: Int,
|
|
locationMatchesLivePage: Bool
|
|
) -> Bool {
|
|
guard let resolvedTargetPage else { return false }
|
|
if locationMatchesLivePage {
|
|
return true
|
|
}
|
|
// The location did not round-trip to the live page in the outgoing map
|
|
// (stale persisted location or mid-transition), so only follow it when it
|
|
// stays next to the page the user is actually looking at.
|
|
return abs(resolvedTargetPage - livePageBeforeApply) <= 1
|
|
}
|
|
|
|
private func schedulePendingCommitRetry() {
|
|
guard pendingCommitRetryWorkItem == nil else { return }
|
|
|
|
let workItem = DispatchWorkItem { [weak self] in
|
|
guard let self else { return }
|
|
self.pendingCommitRetryWorkItem = nil
|
|
self.commitPendingPageMapUpdateIfNeeded()
|
|
}
|
|
pendingCommitRetryWorkItem = workItem
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem)
|
|
}
|
|
|
|
private func cancelPendingCommitRetry() {
|
|
pendingCommitRetryWorkItem?.cancel()
|
|
pendingCommitRetryWorkItem = nil
|
|
}
|
|
}
|