refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- 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
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user