Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0efe3f2cc | ||
|
|
ca408c20ab | ||
|
|
5ae7823ef8 | ||
|
|
bd6e915fbd | ||
|
|
7132e4952b | ||
|
|
f796823db8 | ||
|
|
47fe2dc450 | ||
|
|
9e91207011 |
+1505
-1501
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
|
|||||||
pagesPerScreen: Int = 1,
|
pagesPerScreen: Int = 1,
|
||||||
safeAreaInsets: UIEdgeInsets = .zero,
|
safeAreaInsets: UIEdgeInsets = .zero,
|
||||||
userInterfaceIdiom: UIUserInterfaceIdiom = .phone,
|
userInterfaceIdiom: UIUserInterfaceIdiom = .phone,
|
||||||
reflowableContentInsets: UIEdgeInsets = UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16)
|
reflowableContentInsets: UIEdgeInsets = RDEPUBSafeArea.defaultReflowableContentInsets()
|
||||||
) {
|
) {
|
||||||
self.containerSize = containerSize
|
self.containerSize = containerSize
|
||||||
self.pagesPerScreen = max(1, pagesPerScreen)
|
self.pagesPerScreen = max(1, pagesPerScreen)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Device-level safe area lookup modeled after GKNavigationBarSwift.
|
||||||
|
/// Reads the real safe area from the key window so correct values are
|
||||||
|
/// available even before a view has been laid out in the hierarchy.
|
||||||
|
/// Must be called on the main thread.
|
||||||
|
public enum RDEPUBSafeArea {
|
||||||
|
|
||||||
|
/// Minimum text margins applied when the device safe area on an edge is
|
||||||
|
/// smaller (e.g. no notch / no home indicator). These are aesthetic
|
||||||
|
/// paddings, not approximations of the safe area itself.
|
||||||
|
public static let minimumVerticalTextMargin: CGFloat = 20
|
||||||
|
|
||||||
|
public static let minimumHorizontalTextMargin: CGFloat = 16
|
||||||
|
|
||||||
|
public static func keyWindow() -> UIWindow? {
|
||||||
|
let scenes = UIApplication.shared.connectedScenes
|
||||||
|
.compactMap { $0 as? UIWindowScene }
|
||||||
|
if let window = scenes
|
||||||
|
.filter({ $0.activationState == .foregroundActive })
|
||||||
|
.flatMap({ $0.windows })
|
||||||
|
.first(where: { $0.isKeyWindow }) {
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
if let window = scenes
|
||||||
|
.flatMap({ $0.windows })
|
||||||
|
.first(where: { $0.isKeyWindow }) {
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
return UIApplication.shared.delegate?.window ?? nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func insets() -> UIEdgeInsets {
|
||||||
|
if let window = keyWindow() {
|
||||||
|
return window.safeAreaInsets
|
||||||
|
}
|
||||||
|
// No key window yet (early launch): create a detached window to read
|
||||||
|
// the device safe area, same fallback as GKNavigationBarSwift.
|
||||||
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||||
|
if window.safeAreaInsets.bottom <= 0 {
|
||||||
|
window.rootViewController = UIViewController()
|
||||||
|
}
|
||||||
|
return window.safeAreaInsets
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefers insets measured from a view already installed in the hierarchy;
|
||||||
|
/// falls back to the key-window insets when the view is not laid out yet
|
||||||
|
/// and reports .zero.
|
||||||
|
public static func resolve(_ measuredInsets: UIEdgeInsets?) -> UIEdgeInsets {
|
||||||
|
if let measuredInsets, measuredInsets != .zero {
|
||||||
|
return measuredInsets
|
||||||
|
}
|
||||||
|
return insets()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default reflowable content insets derived from the live device safe
|
||||||
|
/// area instead of hard-coded heights.
|
||||||
|
public static func defaultReflowableContentInsets() -> UIEdgeInsets {
|
||||||
|
let safe = insets()
|
||||||
|
return UIEdgeInsets(
|
||||||
|
top: max(safe.top, minimumVerticalTextMargin),
|
||||||
|
left: max(safe.left, minimumHorizontalTextMargin),
|
||||||
|
bottom: max(safe.bottom, minimumVerticalTextMargin),
|
||||||
|
right: max(safe.right, minimumHorizontalTextMargin)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,7 +137,7 @@ public final class RDEPUBTextBookCache {
|
|||||||
let fileURL = cacheDirectory.appendingPathComponent(key)
|
let fileURL = cacheDirectory.appendingPathComponent(key)
|
||||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[Cache] load MISS key=\(key)")
|
// print("[Cache] load MISS key=\(key)")
|
||||||
#endif
|
#endif
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@ public final class RDEPUBTextBookCache {
|
|||||||
from: data
|
from: data
|
||||||
) else {
|
) else {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[Cache] load MISS key=\(key) (unarchive returned nil)")
|
// print("[Cache] load MISS key=\(key) (unarchive returned nil)")
|
||||||
#endif
|
#endif
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -157,12 +157,12 @@ public final class RDEPUBTextBookCache {
|
|||||||
result[chapter.href] = chapter.toCache()
|
result[chapter.href] = chapter.toCache()
|
||||||
}
|
}
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[Cache] load HIT key=\(key) chapters=\(result.count)")
|
// print("[Cache] load HIT key=\(key) chapters=\(result.count)")
|
||||||
#endif
|
#endif
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[Cache] load MISS key=\(key) error=\(error)")
|
// print("[Cache] load MISS key=\(key) error=\(error)")
|
||||||
#endif
|
#endif
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,8 +136,11 @@ struct RDEPUBAttachmentNormalizer {
|
|||||||
private static func isFootnoteAttachment(_ attachment: DTTextAttachment) -> Bool {
|
private static func isFootnoteAttachment(_ attachment: DTTextAttachment) -> Bool {
|
||||||
|
|
||||||
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
|
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
|
||||||
|
if lowercasedClasses.contains("qqreader-footnote") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
let altText = attachment.attributes["alt"] as? String
|
let altText = attachment.attributes["alt"] as? String
|
||||||
return lowercasedClasses.contains("qqreader-footnote") || hasFootnoteAltText(altText)
|
return hasFootnoteAltText(altText) && isFootnoteSizedImage(attachment.originalSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
|
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
|
||||||
@@ -224,10 +227,31 @@ struct RDEPUBAttachmentNormalizer {
|
|||||||
}
|
}
|
||||||
let label = fileAttachment.accessibilityLabel
|
let label = fileAttachment.accessibilityLabel
|
||||||
let lowercasedLabel = (label ?? "").lowercased()
|
let lowercasedLabel = (label ?? "").lowercased()
|
||||||
return lowercasedLabel.contains("qqreader-footnote") || hasFootnoteAltText(label)
|
if lowercasedLabel.contains("qqreader-footnote") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let imageSize = fileAttachment.image?.size ?? fileAttachment.bounds.size
|
||||||
|
return hasFootnoteAltText(label) && isFootnoteSizedImage(imageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Footnote images without the qqreader-footnote class are recognized by their
|
||||||
|
// alt text carrying the note body. Short alts ("logo", "图1") are ordinary
|
||||||
|
// accessibility descriptions, and note markers are small inline icons, so both
|
||||||
|
// conditions must hold before an image is shrunk to footnote size.
|
||||||
|
private static let minimumFootnoteAltTextLength = 8
|
||||||
|
|
||||||
|
private static let maximumFootnoteImageDimension: CGFloat = 50
|
||||||
|
|
||||||
private static func hasFootnoteAltText(_ text: String?) -> Bool {
|
private static func hasFootnoteAltText(_ text: String?) -> Bool {
|
||||||
text?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
guard let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return trimmed.count >= minimumFootnoteAltTextLength
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isFootnoteSizedImage(_ size: CGSize) -> Bool {
|
||||||
|
guard size.width > 0, size.height > 0 else { return false }
|
||||||
|
return size.width <= maximumFootnoteImageDimension
|
||||||
|
&& size.height <= maximumFootnoteImageDimension
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ public final class RDURLReaderController: UIViewController {
|
|||||||
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
|
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
|
||||||
let pageSize = currentTextPageSize()
|
let pageSize = currentTextPageSize()
|
||||||
let renderStyle = currentTextRenderStyle()
|
let renderStyle = currentTextRenderStyle()
|
||||||
let safeInsets = view.safeAreaInsets
|
let safeInsets = RDEPUBSafeArea.resolve(view.safeAreaInsets)
|
||||||
let edgeInsets = UIEdgeInsets(
|
let edgeInsets = UIEdgeInsets(
|
||||||
top: max(epubConfiguration.reflowableContentInsets.top, safeInsets.top),
|
top: max(epubConfiguration.reflowableContentInsets.top, safeInsets.top),
|
||||||
left: max(epubConfiguration.reflowableContentInsets.left, safeInsets.left),
|
left: max(epubConfiguration.reflowableContentInsets.left, safeInsets.left),
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ final class RDEPUBChapterLoader {
|
|||||||
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||||
) {
|
) {
|
||||||
if let cached = store.chapterData(for: spineIndex) {
|
if let cached = store.chapterData(for: spineIndex) {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"ChapterLoad",
|
||||||
|
"memory HIT spine=\(spineIndex) pages=\(cached.pages.count) priority=\(priority)"
|
||||||
|
)
|
||||||
if let context {
|
if let context {
|
||||||
scheduleDeferredCFIMapBuildIfNeeded(
|
scheduleDeferredCFIMapBuildIfNeeded(
|
||||||
for: cached,
|
for: cached,
|
||||||
@@ -92,6 +96,10 @@ final class RDEPUBChapterLoader {
|
|||||||
|
|
||||||
switch registration {
|
switch registration {
|
||||||
case .joined(let existingPriority, let effectivePriority):
|
case .joined(let existingPriority, let effectivePriority):
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"ChapterLoad",
|
||||||
|
"join pendingLoad spine=\(spineIndex) existing=\(existingPriority) effective=\(effectivePriority)"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
case .created:
|
case .created:
|
||||||
break
|
break
|
||||||
@@ -120,6 +128,20 @@ final class RDEPUBChapterLoader {
|
|||||||
let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange }
|
let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange }
|
||||||
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
|
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
|
||||||
|
|
||||||
|
let pageRangeSource: String
|
||||||
|
if precomputedPageRanges != nil {
|
||||||
|
pageRangeSource = "HIT(memoryPageCount)"
|
||||||
|
} else if diskPageRanges != nil {
|
||||||
|
pageRangeSource = "HIT(diskSummary)"
|
||||||
|
} else {
|
||||||
|
pageRangeSource = "MISS(fullRender)"
|
||||||
|
}
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"ChapterLoad",
|
||||||
|
"build start spine=\(spineIndex) priority=\(queuePriority) pageRanges=\(pageRangeSource)"
|
||||||
|
)
|
||||||
|
let buildStart = CFAbsoluteTimeGetCurrent()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
|
||||||
let chapter = try self.buildChapter(
|
let chapter = try self.buildChapter(
|
||||||
@@ -130,6 +152,10 @@ final class RDEPUBChapterLoader {
|
|||||||
layoutSnapshot: layoutSnapshot
|
layoutSnapshot: layoutSnapshot
|
||||||
)
|
)
|
||||||
|
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"ChapterLoad",
|
||||||
|
"build done spine=\(spineIndex) pages=\(chapter.pages.count) elapsedMs=\(Int((CFAbsoluteTimeGetCurrent() - buildStart) * 1000)) pageRanges=\(pageRangeSource)"
|
||||||
|
)
|
||||||
store.insertChapter(chapter)
|
store.insertChapter(chapter)
|
||||||
let pc = RDEPUBRuntimePageCount(
|
let pc = RDEPUBRuntimePageCount(
|
||||||
cacheKey: cacheKey,
|
cacheKey: cacheKey,
|
||||||
@@ -172,6 +198,10 @@ final class RDEPUBChapterLoader {
|
|||||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"ChapterLoad",
|
||||||
|
"build failed spine=\(spineIndex) pageRanges=\(pageRangeSource) error=\(String(describing: error))"
|
||||||
|
)
|
||||||
store.endPendingChapterLoad(for: spineIndex)
|
store.endPendingChapterLoad(for: spineIndex)
|
||||||
store.markBuilding(false)
|
store.markBuilding(false)
|
||||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error))
|
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error))
|
||||||
|
|||||||
+14
-2
@@ -32,6 +32,8 @@ final class RDEPUBChapterWarmupOrchestrator {
|
|||||||
|
|
||||||
private let prepareRequestDebounceInterval: CFTimeInterval = 0.15
|
private let prepareRequestDebounceInterval: CFTimeInterval = 0.15
|
||||||
|
|
||||||
|
private static let upcomingChapterLookaheadCount = 2
|
||||||
|
|
||||||
init(
|
init(
|
||||||
context: RDEPUBReaderContext,
|
context: RDEPUBReaderContext,
|
||||||
store: RDEPUBChapterRuntimeStore,
|
store: RDEPUBChapterRuntimeStore,
|
||||||
@@ -392,7 +394,10 @@ final class RDEPUBChapterWarmupOrchestrator {
|
|||||||
windowRadius: context.configuration.chapterWindowRadius
|
windowRadius: context.configuration.chapterWindowRadius
|
||||||
)
|
)
|
||||||
|
|
||||||
for evictable in store.evictableSpineIndices() {
|
// Keep chapters that maybePrefetchUpcomingChapters is responsible for,
|
||||||
|
// otherwise the two policies evict/rebuild the same chapter in a loop.
|
||||||
|
let retainedLookaheadIndices = upcomingLookaheadSpineIndices(after: spineIndex)
|
||||||
|
for evictable in store.evictableSpineIndices() where !retainedLookaheadIndices.contains(evictable) {
|
||||||
store.evict(spineIndex: evictable)
|
store.evict(spineIndex: evictable)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,11 +415,18 @@ final class RDEPUBChapterWarmupOrchestrator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func upcomingLookaheadSpineIndices(after spineIndex: Int) -> Set<Int> {
|
||||||
|
guard let publication = context.publication else { return [] }
|
||||||
|
let buildableIndices = buildableSpineIndices(in: publication)
|
||||||
|
guard let currentPosition = buildableIndices.firstIndex(of: spineIndex) else { return [] }
|
||||||
|
return Set(buildableIndices.dropFirst(currentPosition + 1).prefix(Self.upcomingChapterLookaheadCount))
|
||||||
|
}
|
||||||
|
|
||||||
private func maybePrefetchUpcomingChapters(
|
private func maybePrefetchUpcomingChapters(
|
||||||
aroundAbsolutePageNumber pageNumber: Int,
|
aroundAbsolutePageNumber pageNumber: Int,
|
||||||
in bookPageMap: RDEPUBBookPageMap,
|
in bookPageMap: RDEPUBBookPageMap,
|
||||||
threshold: Int = 3,
|
threshold: Int = 3,
|
||||||
lookaheadChapterCount: Int = 2
|
lookaheadChapterCount: Int = RDEPUBChapterWarmupOrchestrator.upcomingChapterLookaheadCount
|
||||||
) {
|
) {
|
||||||
guard let publication = context.publication else { return }
|
guard let publication = context.publication else { return }
|
||||||
let absolutePageIndex = pageNumber - 1
|
let absolutePageIndex = pageNumber - 1
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
|
|
||||||
private let catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]
|
private let catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]
|
||||||
|
|
||||||
|
private let progressLogStride = 4
|
||||||
|
|
||||||
init(
|
init(
|
||||||
context: RDEPUBReaderContext,
|
context: RDEPUBReaderContext,
|
||||||
cancellationController: RDEPUBMetadataParseCancellationController,
|
cancellationController: RDEPUBMetadataParseCancellationController,
|
||||||
@@ -116,19 +118,38 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
func start(token: UUID, restoreLocation: RDEPUBLocation?) {
|
func start(token: UUID, restoreLocation: RDEPUBLocation?) {
|
||||||
let cancellationController = self.cancellationController
|
let cancellationController = self.cancellationController
|
||||||
|
|
||||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
DispatchQueue.global(qos: .utility).async { [self] in
|
||||||
guard let self else { return }
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"workerDispatched token=\(token.uuidString)"
|
||||||
|
)
|
||||||
let context = self.context
|
let context = self.context
|
||||||
guard let context,
|
guard let context,
|
||||||
context.controller != nil,
|
context.controller != nil,
|
||||||
!cancellationController.isCancelled,
|
!cancellationController.isCancelled,
|
||||||
context.paginationToken == token else { return }
|
context.paginationToken == token else {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"workerAbortedBeforeStart reason=contextUnavailableOrTokenMismatch"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
defer { context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) }
|
defer { context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) }
|
||||||
guard context.controller != nil,
|
guard context.controller != nil,
|
||||||
!cancellationController.isCancelled,
|
!cancellationController.isCancelled,
|
||||||
context.paginationToken == token else { return }
|
context.paginationToken == token else {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"workerAbortedAfterStart reason=contextUnavailableOrTokenMismatch"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if let restoredPageMap = self.restoreBookPageMapIfPossible() {
|
if let restoredPageMap = self.restoreBookPageMapIfPossible() {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"restoreBookPageMapIfPossible hit totalChapters=\(restoredPageMap.totalChapters) totalPages=\(restoredPageMap.totalPages)"
|
||||||
|
)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard context.paginationToken == token,
|
guard context.paginationToken == token,
|
||||||
context.controller != nil,
|
context.controller != nil,
|
||||||
@@ -175,13 +196,26 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
|
|
||||||
let uncachedSpineIndices = prioritizedSpineIndices
|
let uncachedSpineIndices = prioritizedSpineIndices
|
||||||
|
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"waitingForInteractionCooldown elapsedSinceNavigation=\(String(format: "%.2f", context.secondsSinceLastUserNavigation())) uncached=\(uncachedSpineIndices.count)"
|
||||||
|
)
|
||||||
self.waitForReadingInteractionToSettle(cancellationController: cancellationController)
|
self.waitForReadingInteractionToSettle(cancellationController: cancellationController)
|
||||||
guard !cancellationController.isCancelled,
|
guard !cancellationController.isCancelled,
|
||||||
context.controller != nil,
|
context.controller != nil,
|
||||||
context.paginationToken == token else {
|
context.paginationToken == token else {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"workerAbortedDuringCooldown"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"start totalBuildable=\(self.allBuildableIndices.count) cached=\(cachedSpineIndices.count) uncached=\(uncachedSpineIndices.count) concurrency=\(self.workerCount) refreshInterval=\(self.pageMapRefreshInterval)"
|
||||||
|
)
|
||||||
|
|
||||||
let wallClockStart = CFAbsoluteTimeGetCurrent()
|
let wallClockStart = CFAbsoluteTimeGetCurrent()
|
||||||
var totalRenderMs: Double = 0
|
var totalRenderMs: Double = 0
|
||||||
var totalWriteMs: Double = 0
|
var totalWriteMs: Double = 0
|
||||||
@@ -283,6 +317,10 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
resultLock.lock()
|
resultLock.lock()
|
||||||
parseState.summariesBySpineIndex[spineIndex] = renderResult
|
parseState.summariesBySpineIndex[spineIndex] = renderResult
|
||||||
parseState.totalResolvedCount += 1
|
parseState.totalResolvedCount += 1
|
||||||
|
let resolvedCount = parseState.totalResolvedCount
|
||||||
|
let shouldLogProgress = resolvedCount == self.allBuildableIndices.count
|
||||||
|
|| resolvedCount == cachedSpineIndices.count + 1
|
||||||
|
|| resolvedCount % self.progressLogStride == 0
|
||||||
if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|
if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|
||||||
|| parseState.totalResolvedCount == self.allBuildableIndices.count {
|
|| parseState.totalResolvedCount == self.allBuildableIndices.count {
|
||||||
parseState.lastAppliedCount = parseState.totalResolvedCount
|
parseState.lastAppliedCount = parseState.totalResolvedCount
|
||||||
@@ -290,6 +328,17 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
}
|
}
|
||||||
resultLock.unlock()
|
resultLock.unlock()
|
||||||
|
|
||||||
|
if shouldLogProgress {
|
||||||
|
let progressPercent = Self.progressPercent(
|
||||||
|
resolved: resolvedCount,
|
||||||
|
total: self.allBuildableIndices.count
|
||||||
|
)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"chapterReady spine=\(spineIndex) resolved=\(resolvedCount)/\(self.allBuildableIndices.count) progress=\(progressPercent)% pageCount=\(renderResult.pageCount)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if let snapshot {
|
if let snapshot {
|
||||||
let mergeStart = CFAbsoluteTimeGetCurrent()
|
let mergeStart = CFAbsoluteTimeGetCurrent()
|
||||||
let partialMap = self.buildPageMap(summaries: snapshot)
|
let partialMap = self.buildPageMap(summaries: snapshot)
|
||||||
@@ -297,6 +346,10 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
timingLock.lock()
|
timingLock.lock()
|
||||||
totalMergeMs += mergeElapsed
|
totalMergeMs += mergeElapsed
|
||||||
timingLock.unlock()
|
timingLock.unlock()
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"partialMap resolvedChapters=\(snapshot.count) totalPages=\(partialMap.totalPages) mergeMs=\(Int(mergeElapsed))"
|
||||||
|
)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard context.paginationToken == token,
|
guard context.paginationToken == token,
|
||||||
context.controller != nil,
|
context.controller != nil,
|
||||||
@@ -314,6 +367,10 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
timingLock.lock()
|
timingLock.lock()
|
||||||
failedChapters += 1
|
failedChapters += 1
|
||||||
timingLock.unlock()
|
timingLock.unlock()
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"chapterFailed spine=\(spineIndex) retryScheduled=true error=\(String(describing: error))"
|
||||||
|
)
|
||||||
|
|
||||||
self.scheduleRetry(
|
self.scheduleRetry(
|
||||||
spineIndex: spineIndex,
|
spineIndex: spineIndex,
|
||||||
@@ -354,6 +411,10 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
let finalMergeStart = CFAbsoluteTimeGetCurrent()
|
let finalMergeStart = CFAbsoluteTimeGetCurrent()
|
||||||
let pageMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
|
let pageMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
|
||||||
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
|
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"finish resolved=\(parseState.summariesBySpineIndex.count)/\(self.allBuildableIndices.count) totalPages=\(pageMap.totalPages) elapsedMs=\(wallClockMs) renderMs=\(renderTotal) writeMs=\(writeTotal) mergeMs=\(mergeTotal + finalMergeMs) failed=\(failed)"
|
||||||
|
)
|
||||||
|
|
||||||
if let coverageStore = context.runtime?.backgroundCoverageStore {
|
if let coverageStore = context.runtime?.backgroundCoverageStore {
|
||||||
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
|
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
|
||||||
@@ -407,10 +468,18 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
cancellationController: RDEPUBMetadataParseCancellationController
|
cancellationController: RDEPUBMetadataParseCancellationController
|
||||||
) {
|
) {
|
||||||
guard retryCount < Self.maxRetryCount else {
|
guard retryCount < Self.maxRetryCount else {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"retryAborted spine=\(spineIndex) retryCount=\(retryCount)"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
|
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"retryScheduled spine=\(spineIndex) retryCount=\(retryCount + 1) delayMs=\(Int(delay * 1000))"
|
||||||
|
)
|
||||||
|
|
||||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
|
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||||
guard let self, let context = self.context else { return }
|
guard let self, let context = self.context else { return }
|
||||||
@@ -474,6 +543,10 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
|
|
||||||
if shouldRefresh {
|
if shouldRefresh {
|
||||||
let partialMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
|
let partialMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"retryPartialMap resolvedChapters=\(parseState.summariesBySpineIndex.count) totalPages=\(partialMap.totalPages)"
|
||||||
|
)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard context.paginationToken == self.token,
|
guard context.paginationToken == self.token,
|
||||||
context.controller != nil,
|
context.controller != nil,
|
||||||
@@ -495,6 +568,11 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func progressPercent(resolved: Int, total: Int) -> Int {
|
||||||
|
guard total > 0 else { return 0 }
|
||||||
|
return Int((Double(resolved) / Double(total) * 100.0).rounded())
|
||||||
|
}
|
||||||
|
|
||||||
private func restoreBookPageMapIfPossible() -> RDEPUBBookPageMap? {
|
private func restoreBookPageMapIfPossible() -> RDEPUBBookPageMap? {
|
||||||
guard let summaryDiskCache else { return nil }
|
guard let summaryDiskCache else { return nil }
|
||||||
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
|
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
|
||||||
|
|||||||
+7
-2
@@ -164,6 +164,7 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
|||||||
lastBuildableSpineIndex: Int
|
lastBuildableSpineIndex: Int
|
||||||
) -> RDEPUBPageMapTakeoverDecision {
|
) -> RDEPUBPageMapTakeoverDecision {
|
||||||
let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex })
|
let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex })
|
||||||
|
let currentIndices = Set(currentWindow.entries.map { $0.spineIndex })
|
||||||
let currentEntries = currentWindow.entries.count
|
let currentEntries = currentWindow.entries.count
|
||||||
|
|
||||||
if let currentSpineIndex {
|
if let currentSpineIndex {
|
||||||
@@ -189,8 +190,8 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let isComplete = candidateIndices.count >= currentEntries
|
let coversCurrentWindow = currentIndices.isSubset(of: candidateIndices)
|
||||||
if isComplete {
|
if coversCurrentWindow {
|
||||||
RDEPUBBackgroundTrace.log(
|
RDEPUBBackgroundTrace.log(
|
||||||
"Reconciliation",
|
"Reconciliation",
|
||||||
"evaluateFullPageMapTakeover: fullReplace — candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries) candidatePages=\(candidatePageMap.totalPages) currentPages=\(currentWindow.totalPages)"
|
"evaluateFullPageMapTakeover: fullReplace — candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries) candidatePages=\(candidatePageMap.totalPages) currentPages=\(currentWindow.totalPages)"
|
||||||
@@ -198,6 +199,10 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
|||||||
return .fullReplace(candidatePageMap)
|
return .fullReplace(candidatePageMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"evaluateFullPageMapTakeover: keepCurrentWindow — candidate does not cover currentWindow candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries)"
|
||||||
|
)
|
||||||
return .keepCurrentWindow
|
return .keepCurrentWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ final class RDEPUBPresentationRuntime {
|
|||||||
|
|
||||||
private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
|
private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
|
||||||
|
|
||||||
|
private var pendingCommitRetryWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
context: RDEPUBReaderContext,
|
context: RDEPUBReaderContext,
|
||||||
locationCoordinator: RDEPUBReaderLocationCoordinator,
|
locationCoordinator: RDEPUBReaderLocationCoordinator,
|
||||||
@@ -62,11 +64,22 @@ final class RDEPUBPresentationRuntime {
|
|||||||
guard let readerView = context.readerView,
|
guard let readerView = context.readerView,
|
||||||
let controller = context.controller else { return }
|
let controller = context.controller else { return }
|
||||||
|
|
||||||
guard !controller.isRepaginating else { return }
|
guard !context.pendingPageMapUpdates.isEmpty else {
|
||||||
guard !readerView.isPageCurlTransitioning else {
|
cancelPendingCommitRetry()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard !controller.isRepaginating else {
|
||||||
|
schedulePendingCommitRetry()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !readerView.isPageCurlTransitioning else {
|
||||||
|
schedulePendingCommitRetry()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPendingCommitRetry()
|
||||||
|
|
||||||
let rankedUpdates = rankedPendingPageMapUpdates()
|
let rankedUpdates = rankedPendingPageMapUpdates()
|
||||||
for (index, update) in rankedUpdates {
|
for (index, update) in rankedUpdates {
|
||||||
if commitPendingPageMapUpdate(
|
if commitPendingPageMapUpdate(
|
||||||
@@ -75,9 +88,16 @@ final class RDEPUBPresentationRuntime {
|
|||||||
readerView: readerView,
|
readerView: readerView,
|
||||||
controller: controller
|
controller: controller
|
||||||
) {
|
) {
|
||||||
|
if !context.pendingPageMapUpdates.isEmpty {
|
||||||
|
schedulePendingCommitRetry()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !context.pendingPageMapUpdates.isEmpty {
|
||||||
|
schedulePendingCommitRetry()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func queueExtendedPartialPageMap(
|
func queueExtendedPartialPageMap(
|
||||||
@@ -139,19 +159,46 @@ final class RDEPUBPresentationRuntime {
|
|||||||
controller: RDEPUBReaderController
|
controller: RDEPUBReaderController
|
||||||
) {
|
) {
|
||||||
let currentLocation = locationCoordinator.currentVisibleLocation()
|
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
|
context.textBook = nil
|
||||||
applyPageMapToLiveModel(newPageMap)
|
applyPageMapToLiveModel(newPageMap)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"applyFullPageMapReplacement livePageBeforeApply=\(livePageBeforeApply) totalPages=\(newPageMap.totalPages) totalChapters=\(newPageMap.totalChapters)"
|
||||||
|
)
|
||||||
|
|
||||||
if let currentLocation {
|
if let currentLocation {
|
||||||
if rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) == false {
|
let resolvedTargetPage = controller.pageNumber(for: currentLocation)
|
||||||
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
|
let shouldTrustResolvedLocation = shouldTrustFullReplaceResolvedPage(
|
||||||
let newPage = max(0, newPageNumber - 1)
|
resolvedTargetPage,
|
||||||
rebindVisiblePage(
|
livePageBeforeApply: livePageBeforeApply,
|
||||||
to: newPage,
|
locationMatchesLivePage: oldResolvedPage == livePageBeforeApply
|
||||||
readerView: readerView
|
)
|
||||||
)
|
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 {
|
} else {
|
||||||
readerView.reloadPageCountOnly()
|
readerView.reloadPageCountOnly()
|
||||||
}
|
}
|
||||||
@@ -223,17 +270,23 @@ final class RDEPUBPresentationRuntime {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
case .extendPartial(_, let currentLocation):
|
case .extendPartial(let capturedPageNumber, let currentLocation):
|
||||||
removePendingPageMapUpdate(at: index)
|
removePendingPageMapUpdate(at: index)
|
||||||
applyPageMapToLiveModel(update.pageMap)
|
applyPageMapToLiveModel(update.pageMap)
|
||||||
if let currentLocation,
|
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) {
|
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Fallback: prefer the live readerView.currentPage over the stale captured
|
// Prefer the live readerView page when the user has moved since the
|
||||||
// currentPageNumber, which may be outdated by the time this commit runs
|
// extension request was created; otherwise a stale captured location can
|
||||||
// (especially in pageCurl mode where the user may have turned several pages
|
// snap pageCurl back to the previous page after the turn completes.
|
||||||
// since the extension was initiated).
|
|
||||||
let livePageIndex = max(readerView.currentPage, 0)
|
let livePageIndex = max(readerView.currentPage, 0)
|
||||||
rebindVisiblePage(
|
rebindVisiblePage(
|
||||||
to: livePageIndex,
|
to: livePageIndex,
|
||||||
@@ -250,6 +303,19 @@ final class RDEPUBPresentationRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func rebindVisiblePage(to pageIndex: Int, readerView: RDReaderView) {
|
private func rebindVisiblePage(to pageIndex: Int, readerView: RDReaderView) {
|
||||||
|
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.currentDisplayType == .pageCurl {
|
||||||
if readerView.isPageCurlTransitioning {
|
if readerView.isPageCurlTransitioning {
|
||||||
// Defer the transition until the current page-curl animation completes,
|
// Defer the transition until the current page-curl animation completes,
|
||||||
@@ -257,6 +323,10 @@ final class RDEPUBPresentationRuntime {
|
|||||||
DispatchQueue.main.async { [weak readerView] in
|
DispatchQueue.main.async { [weak readerView] in
|
||||||
guard let readerView, !readerView.isPageCurlTransitioning else { return }
|
guard let readerView, !readerView.isPageCurlTransitioning else { return }
|
||||||
let livePageIndex = max(readerView.currentPage, 0)
|
let livePageIndex = max(readerView.currentPage, 0)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"rebindVisiblePage deferredTransition livePage=\(livePageIndex + 1)"
|
||||||
|
)
|
||||||
readerView.transitionToPage(pageNum: livePageIndex, animated: false)
|
readerView.transitionToPage(pageNum: livePageIndex, animated: false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -276,6 +346,10 @@ final class RDEPUBPresentationRuntime {
|
|||||||
controller: RDEPUBReaderController
|
controller: RDEPUBReaderController
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
guard let targetPageNumber = controller.pageNumber(for: location) else {
|
guard let targetPageNumber = controller.pageNumber(for: location) else {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"rebindVisibleLocation failedToResolve locationHref=\(location.href) currentPage=\(readerView.currentPage + 1)"
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,9 +358,18 @@ final class RDEPUBPresentationRuntime {
|
|||||||
forAbsolutePageNumber: targetPageNumber,
|
forAbsolutePageNumber: targetPageNumber,
|
||||||
allowSynchronousLoad: false
|
allowSynchronousLoad: false
|
||||||
) == false {
|
) == false {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"rebindVisibleLocation prepareOnDemandBlocked targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1)"
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"rebindVisibleLocation targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1) href=\(location.href)"
|
||||||
|
)
|
||||||
|
|
||||||
rebindVisiblePage(
|
rebindVisiblePage(
|
||||||
to: max(targetPageNumber - 1, 0),
|
to: max(targetPageNumber - 1, 0),
|
||||||
readerView: readerView
|
readerView: readerView
|
||||||
@@ -353,4 +436,36 @@ final class RDEPUBPresentationRuntime {
|
|||||||
&& candidate.pageMap.totalPages >= existing.pageMap.totalPages
|
&& 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ final class RDEPUBReaderContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func currentPreferences() -> RDEPUBPreferences {
|
func currentPreferences() -> RDEPUBPreferences {
|
||||||
let safeInsets = controller?.view.safeAreaInsets ?? .zero
|
let safeInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
|
||||||
return configuration.makePreferences(safeAreaInsets: safeInsets)
|
return configuration.makePreferences(safeAreaInsets: safeInsets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
enum RDEPUBTextPageLayoutMetrics {
|
||||||
|
|
||||||
|
static let pageNumberTrailingPadding: CGFloat = 4
|
||||||
|
|
||||||
|
static let pageNumberFooterPadding: CGFloat = 8
|
||||||
|
|
||||||
|
static let pageNumberReservedHeight: CGFloat = ceil(UIFont.systemFont(ofSize: 13).lineHeight) + pageNumberFooterPadding
|
||||||
|
|
||||||
|
static func contentInsets(
|
||||||
|
configuration: RDEPUBReaderConfiguration,
|
||||||
|
safeAreaInsets: UIEdgeInsets
|
||||||
|
) -> UIEdgeInsets {
|
||||||
|
let configInsets = configuration.reflowableContentInsets
|
||||||
|
return UIEdgeInsets(
|
||||||
|
top: max(configInsets.top, safeAreaInsets.top),
|
||||||
|
left: max(configInsets.left, safeAreaInsets.left),
|
||||||
|
bottom: max(configInsets.bottom, safeAreaInsets.bottom) + pageNumberReservedHeight,
|
||||||
|
right: max(configInsets.right, safeAreaInsets.right)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class RDEPUBReaderEnvironment {
|
final class RDEPUBReaderEnvironment {
|
||||||
|
|
||||||
weak var controller: RDEPUBReaderController?
|
weak var controller: RDEPUBReaderController?
|
||||||
@@ -25,7 +47,7 @@ final class RDEPUBReaderEnvironment {
|
|||||||
return RDEPUBNavigatorLayoutContext(
|
return RDEPUBNavigatorLayoutContext(
|
||||||
containerSize: resolvedSize,
|
containerSize: resolvedSize,
|
||||||
pagesPerScreen: readerView?.pagesPerScreen ?? 1,
|
pagesPerScreen: readerView?.pagesPerScreen ?? 1,
|
||||||
safeAreaInsets: controller?.view.safeAreaInsets ?? .zero,
|
safeAreaInsets: RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets),
|
||||||
userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone,
|
userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone,
|
||||||
reflowableContentInsets: configuration.reflowableContentInsets
|
reflowableContentInsets: configuration.reflowableContentInsets
|
||||||
)
|
)
|
||||||
@@ -46,11 +68,14 @@ final class RDEPUBReaderEnvironment {
|
|||||||
configuration: RDEPUBReaderConfiguration,
|
configuration: RDEPUBReaderConfiguration,
|
||||||
pageSize: CGSize
|
pageSize: CGSize
|
||||||
) -> RDEPUBTextLayoutConfig {
|
) -> RDEPUBTextLayoutConfig {
|
||||||
let layoutContext = currentLayoutContext(configuration: configuration)
|
let safeAreaInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
|
||||||
return RDEPUBTextLayoutConfig(
|
return RDEPUBTextLayoutConfig(
|
||||||
frameWidth: max(pageSize.width, 1),
|
frameWidth: max(pageSize.width, 1),
|
||||||
frameHeight: max(pageSize.height, 1),
|
frameHeight: max(pageSize.height, 1),
|
||||||
edgeInsets: layoutContext.safeReflowableContentInsets,
|
edgeInsets: RDEPUBTextPageLayoutMetrics.contentInsets(
|
||||||
|
configuration: configuration,
|
||||||
|
safeAreaInsets: safeAreaInsets
|
||||||
|
),
|
||||||
numberOfColumns: configuration.numberOfColumns,
|
numberOfColumns: configuration.numberOfColumns,
|
||||||
columnGap: configuration.columnGap,
|
columnGap: configuration.columnGap,
|
||||||
avoidOrphans: false,
|
avoidOrphans: false,
|
||||||
|
|||||||
@@ -191,12 +191,21 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
runtime: runtime,
|
runtime: runtime,
|
||||||
layoutSnapshot: layoutSnapshot
|
layoutSnapshot: layoutSnapshot
|
||||||
)
|
)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Pagination",
|
||||||
|
"anchorChapterReady spine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)"
|
||||||
|
)
|
||||||
let initialChapters = self.loadInitialInteractiveRuntimeChapters(
|
let initialChapters = self.loadInitialInteractiveRuntimeChapters(
|
||||||
anchorChapter: runtimeChapter,
|
anchorChapter: runtimeChapter,
|
||||||
publication: publication,
|
publication: publication,
|
||||||
runtime: runtime,
|
runtime: runtime,
|
||||||
layoutSnapshot: layoutSnapshot
|
layoutSnapshot: layoutSnapshot
|
||||||
)
|
)
|
||||||
|
let initialPageCount = initialChapters.reduce(0) { $0 + $1.pages.count }
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Pagination",
|
||||||
|
"initialInteractiveChapters ready count=\(initialChapters.count) pages=\(initialPageCount) spines=\(initialChapters.map(\.spineIndex))"
|
||||||
|
)
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard context.paginationToken == token,
|
guard context.paginationToken == token,
|
||||||
@@ -220,9 +229,17 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
parser: parser,
|
parser: parser,
|
||||||
publication: publication
|
publication: publication
|
||||||
)
|
)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Pagination",
|
||||||
|
"startingMetadataWorker token=\(token.uuidString) anchorSpine=\(runtimeChapter.spineIndex) partialPages=\(partialMap.totalPages) partialChapters=\(partialMap.totalChapters)"
|
||||||
|
)
|
||||||
worker.start(token: token, restoreLocation: restoreLocation)
|
worker.start(token: token, restoreLocation: restoreLocation)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Pagination",
|
||||||
|
"initialPaginationFailed error=\(String(describing: error)) prioritizedCandidates=\(prioritizedCandidates)"
|
||||||
|
)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard context.paginationToken == token,
|
guard context.paginationToken == token,
|
||||||
context.controller != nil else { return }
|
context.controller != nil else { return }
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
|||||||
showsTableOfContents: Bool = true,
|
showsTableOfContents: Bool = true,
|
||||||
allowsHighlights: Bool = true,
|
allowsHighlights: Bool = true,
|
||||||
showsSettingsPanel: Bool = true,
|
showsSettingsPanel: Bool = true,
|
||||||
reflowableContentInsets: UIEdgeInsets = UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16),
|
reflowableContentInsets: UIEdgeInsets = RDEPUBSafeArea.defaultReflowableContentInsets(),
|
||||||
fixedContentInset: UIEdgeInsets = .zero,
|
fixedContentInset: UIEdgeInsets = .zero,
|
||||||
theme: RDEPUBReaderTheme = .light,
|
theme: RDEPUBReaderTheme = .light,
|
||||||
darkImageAdjustmentEnabled: Bool = true,
|
darkImageAdjustmentEnabled: Bool = true,
|
||||||
@@ -172,11 +172,13 @@ extension RDEPUBReaderConfiguration {
|
|||||||
func makePreferences(safeAreaInsets: UIEdgeInsets = .zero) -> RDEPUBPreferences {
|
func makePreferences(safeAreaInsets: UIEdgeInsets = .zero) -> RDEPUBPreferences {
|
||||||
// Use the larger of reflowableContentInsets and safeAreaInsets for each edge
|
// Use the larger of reflowableContentInsets and safeAreaInsets for each edge
|
||||||
// to prevent content from being hidden under Dynamic Island / home indicator.
|
// to prevent content from being hidden under Dynamic Island / home indicator.
|
||||||
|
// Falls back to the key-window safe area when the caller has no laid-out view.
|
||||||
|
let resolvedSafeAreaInsets = RDEPUBSafeArea.resolve(safeAreaInsets)
|
||||||
let safeInsets = UIEdgeInsets(
|
let safeInsets = UIEdgeInsets(
|
||||||
top: max(reflowableContentInsets.top, safeAreaInsets.top),
|
top: max(reflowableContentInsets.top, resolvedSafeAreaInsets.top),
|
||||||
left: max(reflowableContentInsets.left, safeAreaInsets.left),
|
left: max(reflowableContentInsets.left, resolvedSafeAreaInsets.left),
|
||||||
bottom: max(reflowableContentInsets.bottom, safeAreaInsets.bottom),
|
bottom: max(reflowableContentInsets.bottom, resolvedSafeAreaInsets.bottom),
|
||||||
right: max(reflowableContentInsets.right, safeAreaInsets.right)
|
right: max(reflowableContentInsets.right, resolvedSafeAreaInsets.right)
|
||||||
)
|
)
|
||||||
return RDEPUBPreferences(
|
return RDEPUBPreferences(
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
|
|||||||
@@ -126,6 +126,19 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
return spinner
|
return spinner
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// Debug-only outline of the readable content area (bounds inset by contentInsets).
|
||||||
|
private let debugContentAreaBorderView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.isUserInteractionEnabled = false
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
view.layer.borderColor = UIColor.systemRed.withAlphaComponent(0.6).cgColor
|
||||||
|
view.layer.borderWidth = 1
|
||||||
|
view.accessibilityIdentifier = "epub.reader.debug.contentAreaBorder"
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
#endif
|
||||||
|
|
||||||
private lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = {
|
private lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = {
|
||||||
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
||||||
gesture.minimumPressDuration = 0.5
|
gesture.minimumPressDuration = 0.5
|
||||||
@@ -243,6 +256,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
addSubview(pageNumberLabel)
|
addSubview(pageNumberLabel)
|
||||||
addSubview(loadingSpinner)
|
addSubview(loadingSpinner)
|
||||||
addSubview(selectionLoupeView)
|
addSubview(selectionLoupeView)
|
||||||
|
#if DEBUG
|
||||||
|
addSubview(debugContentAreaBorderView)
|
||||||
|
#endif
|
||||||
addGestureRecognizer(longPressGestureRecognizer)
|
addGestureRecognizer(longPressGestureRecognizer)
|
||||||
addGestureRecognizer(panGestureRecognizer)
|
addGestureRecognizer(panGestureRecognizer)
|
||||||
addGestureRecognizer(tapGestureRecognizer)
|
addGestureRecognizer(tapGestureRecognizer)
|
||||||
@@ -338,6 +354,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
coverImageView.frame = bounds.inset(by: contentInsets)
|
coverImageView.frame = bounds.inset(by: contentInsets)
|
||||||
|
|
||||||
let contentRect = bounds.inset(by: contentInsets)
|
let contentRect = bounds.inset(by: contentInsets)
|
||||||
|
#if DEBUG
|
||||||
|
debugContentAreaBorderView.frame = contentRect
|
||||||
|
#endif
|
||||||
let labelSize = pageNumberLabel.sizeThatFits(
|
let labelSize = pageNumberLabel.sizeThatFits(
|
||||||
CGSize(width: contentRect.width, height: Self.pageNumberReservedHeight)
|
CGSize(width: contentRect.width, height: Self.pageNumberReservedHeight)
|
||||||
)
|
)
|
||||||
@@ -374,7 +393,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||||
contentInsets = Self.safeContentInsets(
|
contentInsets = Self.safeContentInsets(
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
safeAreaInsets: safeAreaInsets
|
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
|
||||||
)
|
)
|
||||||
backgroundColor = configuration.theme.contentBackgroundColor
|
backgroundColor = configuration.theme.contentBackgroundColor
|
||||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||||
@@ -459,7 +478,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||||
contentInsets = Self.safeContentInsets(
|
contentInsets = Self.safeContentInsets(
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
safeAreaInsets: safeAreaInsets
|
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
|
||||||
)
|
)
|
||||||
backgroundColor = configuration.theme.contentBackgroundColor
|
backgroundColor = configuration.theme.contentBackgroundColor
|
||||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ final class RDReaderPreloadController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func invalidate(environment: Environment) {
|
func invalidate(environment: Environment) {
|
||||||
|
RDReaderTapDebug.log(
|
||||||
|
"PreloadController.invalidate",
|
||||||
|
"clearing pageCurlCached=\(pageCurlCachedViews.keys.sorted()) preloaded=\(preloadedPageViews.keys.sorted())"
|
||||||
|
)
|
||||||
pageCurlCachedViews.values.forEach { $0.removeFromSuperview() }
|
pageCurlCachedViews.values.forEach { $0.removeFromSuperview() }
|
||||||
preloadedPageViews.values.forEach { $0.removeFromSuperview() }
|
preloadedPageViews.values.forEach { $0.removeFromSuperview() }
|
||||||
pageCurlCachedViews.removeAll()
|
pageCurlCachedViews.removeAll()
|
||||||
@@ -78,8 +82,20 @@ final class RDReaderPreloadController {
|
|||||||
environment: Environment,
|
environment: Environment,
|
||||||
contentViewProvider: (Int, UIView?) -> UIView?
|
contentViewProvider: (Int, UIView?) -> UIView?
|
||||||
) -> UIView {
|
) -> UIView {
|
||||||
let reusableView = detachedReusablePageView(for: pageNum)
|
let view: UIView
|
||||||
let view = contentViewProvider(pageNum, reusableView) ?? reusableView ?? UIView()
|
if let reusableView = detachedReusablePageView(for: pageNum) {
|
||||||
|
view = reusableView
|
||||||
|
RDReaderTapDebug.log(
|
||||||
|
"PreloadController.pageViewForDisplay",
|
||||||
|
"cache HIT page=\(pageNum) view=\(RDReaderTapDebug.describe(reusableView))"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
view = contentViewProvider(pageNum, nil) ?? UIView()
|
||||||
|
RDReaderTapDebug.log(
|
||||||
|
"PreloadController.pageViewForDisplay",
|
||||||
|
"cache MISS page=\(pageNum) created=\(RDReaderTapDebug.describe(view))"
|
||||||
|
)
|
||||||
|
}
|
||||||
if shouldCache(view: view, for: pageNum, environment: environment) {
|
if shouldCache(view: view, for: pageNum, environment: environment) {
|
||||||
pageCurlCachedViews[pageNum] = view
|
pageCurlCachedViews[pageNum] = view
|
||||||
} else {
|
} else {
|
||||||
@@ -91,6 +107,10 @@ final class RDReaderPreloadController {
|
|||||||
func takePreloadedView(for pageNum: Int) -> UIView? {
|
func takePreloadedView(for pageNum: Int) -> UIView? {
|
||||||
let preloaded = preloadedPageViews.removeValue(forKey: pageNum)
|
let preloaded = preloadedPageViews.removeValue(forKey: pageNum)
|
||||||
preloaded?.removeFromSuperview()
|
preloaded?.removeFromSuperview()
|
||||||
|
RDReaderTapDebug.log(
|
||||||
|
"PreloadController.takePreloadedView",
|
||||||
|
"cache \(preloaded == nil ? "MISS" : "HIT") page=\(pageNum) view=\(RDReaderTapDebug.describe(preloaded))"
|
||||||
|
)
|
||||||
return preloaded
|
return preloaded
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,8 +131,26 @@ final class RDReaderPreloadController {
|
|||||||
preloadHostView.frame = parentView.bounds
|
preloadHostView.frame = parentView.bounds
|
||||||
|
|
||||||
for targetPage in targets {
|
for targetPage in targets {
|
||||||
let existing = detachedReusablePageView(for: targetPage)
|
let contentView: UIView
|
||||||
let contentView = contentViewProvider(targetPage, existing) ?? existing ?? UIView()
|
if let existing = preloadedPageViews[targetPage], existing.superview === preloadHostView {
|
||||||
|
contentView = existing
|
||||||
|
RDReaderTapDebug.log(
|
||||||
|
"PreloadController.prime",
|
||||||
|
"cache HIT(preloaded) page=\(targetPage) view=\(RDReaderTapDebug.describe(existing))"
|
||||||
|
)
|
||||||
|
} else if let cached = pageCurlCachedViews.removeValue(forKey: targetPage), cached.superview == nil {
|
||||||
|
contentView = cached
|
||||||
|
RDReaderTapDebug.log(
|
||||||
|
"PreloadController.prime",
|
||||||
|
"cache HIT(pageCurl) page=\(targetPage) view=\(RDReaderTapDebug.describe(cached))"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
contentView = contentViewProvider(targetPage, nil) ?? UIView()
|
||||||
|
RDReaderTapDebug.log(
|
||||||
|
"PreloadController.prime",
|
||||||
|
"cache MISS page=\(targetPage) created=\(RDReaderTapDebug.describe(contentView))"
|
||||||
|
)
|
||||||
|
}
|
||||||
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
|
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
|
||||||
if shouldCacheContentView {
|
if shouldCacheContentView {
|
||||||
preloadedPageViews[targetPage] = contentView
|
preloadedPageViews[targetPage] = contentView
|
||||||
|
|||||||
@@ -763,6 +763,14 @@ public class RDReaderView: UIView {
|
|||||||
guard let safePageNum = clampedPageNumber(pageNum) else { return }
|
guard let safePageNum = clampedPageNumber(pageNum) else { return }
|
||||||
switch currentDisplayType {
|
switch currentDisplayType {
|
||||||
case .pageCurl:
|
case .pageCurl:
|
||||||
|
if !animated, safePageNum == currentPage, !detectPageViewControllerFault(pageViewController) {
|
||||||
|
RDReaderTapDebug.log(
|
||||||
|
"ReaderView.transitionToPage",
|
||||||
|
"skip same page page=\(safePageNum) animated=\(animated)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let request = PageTransitionRequest(pageNum: safePageNum, animated: animated)
|
let request = PageTransitionRequest(pageNum: safePageNum, animated: animated)
|
||||||
if shouldQueuePageTransition(request) {
|
if shouldQueuePageTransition(request) {
|
||||||
return
|
return
|
||||||
@@ -823,6 +831,7 @@ public class RDReaderView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func reloadData() {
|
public func reloadData() {
|
||||||
|
invalidatePageCaches()
|
||||||
switchReaderDisplayType(currentDisplayType)
|
switchReaderDisplayType(currentDisplayType)
|
||||||
topToolView = resolvedTopChromeView()
|
topToolView = resolvedTopChromeView()
|
||||||
bottomToolView = resolvedBottomChromeView()
|
bottomToolView = resolvedBottomChromeView()
|
||||||
@@ -830,8 +839,9 @@ public class RDReaderView: UIView {
|
|||||||
|
|
||||||
public func reloadPageCountOnly() {
|
public func reloadPageCountOnly() {
|
||||||
if currentDisplayType == .pageCurl {
|
if currentDisplayType == .pageCurl {
|
||||||
if !isPageCurlTransitioning, currentPage >= 0, numberOfPages() > 0 {
|
let totalPages = numberOfPages()
|
||||||
transitionToPage(pageNum: currentPage, animated: false)
|
if !isPageCurlTransitioning, currentPage >= totalPages, totalPages > 0 {
|
||||||
|
transitionToPage(pageNum: totalPages - 1, animated: false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
collectionView.reloadData()
|
collectionView.reloadData()
|
||||||
|
|||||||
Reference in New Issue
Block a user