mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 04:41:23 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import CoreText
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
private struct ReaderPositionPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: [Int: CGFloat] = [:]
|
||||
static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
|
||||
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
|
||||
}
|
||||
}
|
||||
|
||||
struct ReaderStylePreset: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let background: UIColor
|
||||
let foreground: UIColor
|
||||
|
||||
static let legadoDefaults: [ReaderStylePreset] = [
|
||||
.init(id: "wechat", name: "微信读书", background: UIColor(hex: 0xC0EDC6), foreground: UIColor(hex: 0x0B0B0B)),
|
||||
.init(id: "preset1", name: "预设1", background: UIColor(hex: 0xFFFFFF), foreground: UIColor(hex: 0x000000)),
|
||||
.init(id: "preset2", name: "预设2", background: UIColor(hex: 0xDDC090), foreground: UIColor(hex: 0x3E3422)),
|
||||
.init(id: "preset3", name: "预设3", background: UIColor(hex: 0xC2D8AA), foreground: UIColor(hex: 0x596C44)),
|
||||
.init(id: "preset4", name: "预设4", background: UIColor(hex: 0xDBB8E2), foreground: UIColor(hex: 0x68516C)),
|
||||
.init(id: "preset5", name: "预设5", background: UIColor(hex: 0xABCEE0), foreground: UIColor(hex: 0x3D4C54)),
|
||||
.init(id: "night", name: "夜间", background: UIColor(hex: 0x000000), foreground: UIColor(hex: 0xADADAD))
|
||||
]
|
||||
static let eInk = ReaderStylePreset(
|
||||
id: "eink",
|
||||
name: "E-Ink",
|
||||
background: UIColor(hex: 0xFFFFFF),
|
||||
foreground: UIColor(hex: 0x000000)
|
||||
)
|
||||
|
||||
static func resolve(_ id: String) -> ReaderStylePreset {
|
||||
let migratedID = switch id {
|
||||
case "dark": "night"
|
||||
case "light": "preset1"
|
||||
case "paper": "preset2"
|
||||
default: id
|
||||
}
|
||||
return legadoDefaults.first(where: { $0.id == migratedID }) ?? legadoDefaults[0]
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
convenience init(hex: UInt32) {
|
||||
self.init(
|
||||
red: CGFloat((hex >> 16) & 0xff) / 255,
|
||||
green: CGFloat((hex >> 8) & 0xff) / 255,
|
||||
blue: CGFloat(hex & 0xff) / 255,
|
||||
alpha: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum ReaderBackgroundStore {
|
||||
static var url: URL {
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Legado/reader-background.jpg")
|
||||
}
|
||||
|
||||
static func load() -> UIImage? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return UIImage(data: data)
|
||||
}
|
||||
|
||||
static func save(_ data: Data) throws -> UIImage {
|
||||
guard let image = UIImage(data: data), let encoded = image.jpegData(compressionQuality: 0.9) else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try encoded.write(to: url, options: .atomic)
|
||||
return image
|
||||
}
|
||||
|
||||
static func remove() { try? FileManager.default.removeItem(at: url) }
|
||||
}
|
||||
|
||||
enum ReaderFontStore {
|
||||
static var directory: URL {
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Legado/fonts", isDirectory: true)
|
||||
}
|
||||
|
||||
static func install(from source: URL) throws -> String {
|
||||
let accessing = source.startAccessingSecurityScopedResource()
|
||||
defer { if accessing { source.stopAccessingSecurityScopedResource() } }
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let target = directory.appendingPathComponent(source.lastPathComponent)
|
||||
if target != source {
|
||||
try? FileManager.default.removeItem(at: target)
|
||||
try FileManager.default.copyItem(at: source, to: target)
|
||||
}
|
||||
guard font(at: target.path, size: 20, weight: 0) != nil else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
return target.path
|
||||
}
|
||||
|
||||
static func font(at path: String, size: CGFloat, weight: Int) -> UIFont? {
|
||||
guard !path.isEmpty,
|
||||
let provider = CGDataProvider(url: URL(fileURLWithPath: path) as CFURL),
|
||||
let graphicsFont = CGFont(provider) else { return nil }
|
||||
CTFontManagerRegisterGraphicsFont(graphicsFont, nil)
|
||||
guard let postScriptName = graphicsFont.postScriptName else { return nil }
|
||||
let name = postScriptName as String
|
||||
guard let base = UIFont(name: name, size: size) else { return nil }
|
||||
if weight == 1,
|
||||
let descriptor = base.fontDescriptor.withSymbolicTraits(.traitBold) {
|
||||
return UIFont(descriptor: descriptor, size: size)
|
||||
}
|
||||
if weight == 2 {
|
||||
let descriptor = base.fontDescriptor.addingAttributes([
|
||||
.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue]
|
||||
])
|
||||
return UIFont(descriptor: descriptor, size: size)
|
||||
}
|
||||
return base
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct ReaderScrollView: UIViewRepresentable {
|
||||
let configuration: ReaderTextView
|
||||
let chapters: [ReaderScrollChapter]
|
||||
let resetID: Int
|
||||
let activeIndex: Int
|
||||
let loadingAhead: Bool
|
||||
let loadingBehind: Bool
|
||||
let onNeedChapters: (Int) -> Void
|
||||
let onPositionChanged: (Int, Int) -> Void
|
||||
let onRetainChapters: (Set<Int>) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||
|
||||
func makeUIView(context: Context) -> ReaderScrollCollectionView {
|
||||
context.coordinator.makeView()
|
||||
}
|
||||
|
||||
func updateUIView(_ view: ReaderScrollCollectionView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
context.coordinator.update(view)
|
||||
}
|
||||
|
||||
static func dismantleUIView(_ view: ReaderScrollCollectionView, coordinator: Coordinator) {
|
||||
coordinator.displayLink?.invalidate()
|
||||
view.viewportChanged = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegate,
|
||||
UIGestureRecognizerDelegate {
|
||||
var parent: ReaderScrollView
|
||||
private(set) var blocks: [ReaderScrollBlock] = []
|
||||
private var chapterLayouts: [Int: ReaderScrollChapterLayout] = [:]
|
||||
private weak var view: ReaderScrollCollectionView?
|
||||
var displayLink: CADisplayLink?
|
||||
private var lastTimestamp: CFTimeInterval = 0
|
||||
private var styleKey = ""
|
||||
private var viewportHeight: CGFloat = 0
|
||||
private var revision: Int?
|
||||
private var restored = false
|
||||
private var jumpKey: String?
|
||||
private var reporting = false
|
||||
private var applying = false
|
||||
private var requestedAhead: Int?
|
||||
private var requestedBehind: Int?
|
||||
private var wasLoadingAhead = false
|
||||
private var wasLoadingBehind = false
|
||||
private var lastPageKey = ""
|
||||
private var reachedEnd = false
|
||||
#if DEBUG
|
||||
private var decelerationInsertions = 0
|
||||
private var continuedDecelerations = 0
|
||||
private var interruptedDecelerations = 0
|
||||
private var awaitingDecelerationOffset: CGFloat?
|
||||
#endif
|
||||
|
||||
init(parent: ReaderScrollView) { self.parent = parent }
|
||||
|
||||
func makeView() -> ReaderScrollCollectionView {
|
||||
let collection = ReaderScrollCollectionView(frame: .zero, collectionViewLayout: ReaderContinuousLayout())
|
||||
collection.backgroundColor = parent.configuration.backgroundColor
|
||||
collection.contentInsetAdjustmentBehavior = .never
|
||||
collection.alwaysBounceVertical = true
|
||||
collection.showsVerticalScrollIndicator = false
|
||||
collection.decelerationRate = .normal
|
||||
collection.dataSource = self
|
||||
collection.delegate = self
|
||||
collection.accessibilityIdentifier = "reader.continuous"
|
||||
collection.register(ReaderScrollCell.self, forCellWithReuseIdentifier: "text")
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
|
||||
tap.delegate = self
|
||||
collection.addGestureRecognizer(tap)
|
||||
collection.viewportChanged = { [weak self, weak collection] in
|
||||
guard let self, let collection else { return }
|
||||
self.update(collection)
|
||||
}
|
||||
let link = CADisplayLink(target: self, selector: #selector(step(_:)))
|
||||
link.add(to: .main, forMode: .common)
|
||||
displayLink = link
|
||||
view = collection
|
||||
return collection
|
||||
}
|
||||
|
||||
func update(_ view: ReaderScrollCollectionView) {
|
||||
guard !applying, view.bounds.width > 0, view.bounds.height > 0 else { return }
|
||||
applying = true
|
||||
defer { applying = false }
|
||||
let config = parent.configuration
|
||||
if wasLoadingAhead, !parent.loadingAhead { requestedAhead = nil }
|
||||
if wasLoadingBehind, !parent.loadingBehind { requestedBehind = nil }
|
||||
wasLoadingAhead = parent.loadingAhead
|
||||
wasLoadingBehind = parent.loadingBehind
|
||||
view.backgroundColor = config.backgroundColor
|
||||
view.loadingLabel.textColor = config.foregroundColor.withAlphaComponent(0.6)
|
||||
view.loadingLabel.text = parent.chapters.isEmpty ? "正在加载正文…"
|
||||
: (parent.loadingAhead || parent.loadingBehind ? "正在加载后续章节…" : nil)
|
||||
view.positionLoadingLabel()
|
||||
let reset = revision != parent.resetID
|
||||
if reset {
|
||||
revision = parent.resetID
|
||||
restored = false
|
||||
jumpKey = nil
|
||||
requestedAhead = nil
|
||||
requestedBehind = nil
|
||||
lastPageKey = ""
|
||||
chapterLayouts = [:]
|
||||
}
|
||||
let anchor = reset ? nil : captureAnchor()
|
||||
let newStyleKey = config.scrollTypographyKey + "|\(view.bounds.width)"
|
||||
let styleChanged = styleKey != newStyleKey
|
||||
let contentChanged = styleChanged || viewportHeight != view.bounds.height || parent.chapters.count != chapterLayouts.count
|
||||
|| parent.chapters.contains { chapterLayouts[$0.id]?.chapter != $0 }
|
||||
if contentChanged || reset {
|
||||
let oldIDs = blocks.map(\.id)
|
||||
var layouts: [Int: ReaderScrollChapterLayout] = [:]
|
||||
for chapter in parent.chapters {
|
||||
if !styleChanged, let cached = chapterLayouts[chapter.id], cached.chapter == chapter {
|
||||
layouts[chapter.id] = cached
|
||||
} else {
|
||||
layouts[chapter.id] = ReaderScrollChapterLayout(
|
||||
chapter: chapter, configuration: config, width: view.bounds.width
|
||||
)
|
||||
}
|
||||
}
|
||||
let newBlocks = parent.chapters.flatMap { layouts[$0.id]?.blocks ?? [] }
|
||||
let layout = view.collectionViewLayout as! ReaderContinuousLayout
|
||||
let geometry = ReaderContinuousLayout.geometry(
|
||||
blocks: newBlocks, width: view.bounds.width,
|
||||
top: CGFloat(config.paddingTop), bottom: CGFloat(config.paddingBottom), viewportHeight: view.bounds.height
|
||||
)
|
||||
let targetY = anchor.flatMap { offset(for: $0, blocks: newBlocks, frames: geometry.frames) }
|
||||
let correction = targetY.map { $0 - view.contentOffset.y } ?? 0
|
||||
let newIDs = newBlocks.map(\.id)
|
||||
let oldSet = Set(oldIDs), newSet = Set(newIDs)
|
||||
let deletions = oldIDs.indices.filter { !newSet.contains(oldIDs[$0]) }.map { IndexPath(item: $0, section: 0) }
|
||||
let insertions = newIDs.indices.filter { !oldSet.contains(newIDs[$0]) }.map { IndexPath(item: $0, section: 0) }
|
||||
if !deletions.isEmpty { requestedAhead = nil; requestedBehind = nil }
|
||||
chapterLayouts = layouts
|
||||
styleKey = newStyleKey
|
||||
viewportHeight = view.bounds.height
|
||||
#if DEBUG
|
||||
let wasDecelerating = view.isDecelerating
|
||||
#endif
|
||||
UIView.performWithoutAnimation {
|
||||
view.performBatchUpdates {
|
||||
self.blocks = newBlocks
|
||||
view.deleteItems(at: deletions)
|
||||
view.insertItems(at: insertions)
|
||||
// UIKit applies the geometry and anchor compensation together.
|
||||
// Setting contentOffset here would cancel an in-flight deceleration.
|
||||
layout.install(frames: geometry.frames, size: geometry.size, correction: correction)
|
||||
}
|
||||
view.layoutIfNeeded()
|
||||
}
|
||||
#if DEBUG
|
||||
if wasDecelerating, !insertions.isEmpty {
|
||||
decelerationInsertions += 1
|
||||
if view.isDecelerating { awaitingDecelerationOffset = view.contentOffset.y }
|
||||
else { interruptedDecelerations += 1 }
|
||||
}
|
||||
#endif
|
||||
for cell in view.visibleCells {
|
||||
guard let cell = cell as? ReaderScrollCell,
|
||||
let index = view.indexPath(for: cell)?.item, blocks.indices.contains(index) else { continue }
|
||||
cell.canvas.block = blocks[index]
|
||||
}
|
||||
reachedEnd = false
|
||||
}
|
||||
if !restored, let chapter = chapterLayouts[parent.activeIndex] {
|
||||
restored = true
|
||||
seek(chapter: chapter.chapter.id, position: config.requestedPosition, includeHeading: config.requestedPosition == 0)
|
||||
}
|
||||
if let position = config.jumpPosition {
|
||||
let key = "\(parent.activeIndex):\(position)"
|
||||
if jumpKey != key {
|
||||
jumpKey = key
|
||||
seek(chapter: parent.activeIndex, position: position, includeHeading: position == 0)
|
||||
}
|
||||
} else { jumpKey = nil }
|
||||
scheduleReport()
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { blocks.count }
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "text", for: indexPath) as! ReaderScrollCell
|
||||
cell.canvas.block = blocks[indexPath.item]
|
||||
return cell
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard !applying else { return }
|
||||
#if DEBUG
|
||||
if let offset = awaitingDecelerationOffset, scrollView.isDecelerating,
|
||||
abs(scrollView.contentOffset.y - offset) > 0.5 {
|
||||
continuedDecelerations += 1
|
||||
awaitingDecelerationOffset = nil
|
||||
}
|
||||
#endif
|
||||
view?.positionLoadingLabel()
|
||||
scheduleReport()
|
||||
}
|
||||
|
||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
if !decelerate { scheduleReport() }
|
||||
}
|
||||
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { scheduleReport() }
|
||||
|
||||
private func scheduleReport() {
|
||||
guard !reporting else { return }
|
||||
reporting = true
|
||||
let scheduledRevision = parent.resetID
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.reporting = false
|
||||
guard self.parent.resetID == scheduledRevision else { self.scheduleReport(); return }
|
||||
self.reportViewport()
|
||||
}
|
||||
}
|
||||
|
||||
private func reportViewport() {
|
||||
guard let view, restored, let anchor = captureAnchor(),
|
||||
let chapter = chapterLayouts[anchor.chapter] else { return }
|
||||
parent.onPositionChanged(anchor.chapter, anchor.position)
|
||||
let config = parent.configuration
|
||||
let chapterRows = blocks.indices.filter { blocks[$0].chapter.chapter.id == anchor.chapter }
|
||||
let layout = view.collectionViewLayout as! ReaderContinuousLayout
|
||||
if let first = chapterRows.first, let last = chapterRows.last {
|
||||
let start = layout.frames[first].minY, end = layout.frames[last].maxY
|
||||
let height = max(view.bounds.height, 1)
|
||||
let total = max(Int(ceil((end - start) / height)), 1)
|
||||
let page = min(max(Int(floor((view.contentOffset.y - start) / height)) + 1, 1), total)
|
||||
if config.currentPage != page { config.currentPage = page }
|
||||
if config.totalPages != total { config.totalPages = total }
|
||||
let ranges = chapter.chapter.content.isEmpty ? [] : [NSRange(location: 0, length: chapter.chapter.content.utf16.count)]
|
||||
if config.pageRanges != ranges { config.pageRanges = ranges }
|
||||
let key = "\(anchor.chapter):\(page)"
|
||||
if lastPageKey != key { lastPageKey = key; config.onPageChanged() }
|
||||
}
|
||||
let runway = view.bounds.height * 3
|
||||
if let last = parent.chapters.last, layout.collectionViewContentSize.height - view.bounds.maxY < runway,
|
||||
requestedAhead != last.id {
|
||||
requestedAhead = last.id
|
||||
parent.onNeedChapters(1)
|
||||
}
|
||||
if let first = parent.chapters.first, view.contentOffset.y < runway, requestedBehind != first.id {
|
||||
requestedBehind = first.id
|
||||
parent.onNeedChapters(-1)
|
||||
}
|
||||
if !view.isDragging, !view.isDecelerating, parent.chapters.count > 12 {
|
||||
let retainedRect = view.bounds.insetBy(dx: 0, dy: -view.bounds.height * 6)
|
||||
let visible = blocks.indices.filter { layout.frames[$0].intersects(retainedRect) }
|
||||
let indices = Set(visible.map { blocks[$0].chapter.chapter.id })
|
||||
let ordered = parent.chapters.map(\.id)
|
||||
if let lo = ordered.firstIndex(where: { indices.contains($0) }),
|
||||
let hi = ordered.lastIndex(where: { indices.contains($0) }) {
|
||||
let keep = Set(ordered[max(lo - 2, 0)...min(hi + 2, ordered.count - 1)])
|
||||
if keep.count < ordered.count { parent.onRetainChapters(keep) }
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
view.accessibilityValue = "chapter=\(anchor.chapter) position=\(anchor.position) chapters=\(parent.chapters.count) decelInsertions=\(decelerationInsertions) continued=\(continuedDecelerations) interrupted=\(interruptedDecelerations)"
|
||||
#endif
|
||||
}
|
||||
|
||||
private struct Anchor {
|
||||
let chapter: Int
|
||||
let position: Int
|
||||
let inHeading: Bool
|
||||
let screenY: CGFloat
|
||||
}
|
||||
|
||||
private func captureAnchor() -> Anchor? {
|
||||
guard let view, !blocks.isEmpty else { return nil }
|
||||
let layout = view.collectionViewLayout as! ReaderContinuousLayout
|
||||
let y = max(view.contentOffset.y, layout.frames.first?.minY ?? 0)
|
||||
guard let row = layout.row(at: y) else { return nil }
|
||||
let block = blocks[row], chapter = block.chapter
|
||||
let localY = y - layout.frames[row].minY + block.top
|
||||
let glyph = chapter.manager.glyphIndex(for: CGPoint(x: 0, y: localY + 0.5), in: chapter.container)
|
||||
let safeGlyph = min(glyph, max(chapter.manager.numberOfGlyphs - 1, 0))
|
||||
let character = chapter.manager.characterIndexForGlyph(at: safeGlyph)
|
||||
let lineY = chapter.manager.lineFragmentRect(forGlyphAt: safeGlyph, effectiveRange: nil).minY
|
||||
return Anchor(
|
||||
chapter: chapter.chapter.id,
|
||||
position: min(max(character - chapter.bodyStart, 0), chapter.chapter.content.utf16.count),
|
||||
inHeading: character < chapter.bodyStart,
|
||||
screenY: layout.frames[row].minY + lineY - block.top - view.contentOffset.y
|
||||
)
|
||||
}
|
||||
|
||||
private func offset(for anchor: Anchor, blocks: [ReaderScrollBlock], frames: [CGRect]) -> CGFloat? {
|
||||
guard let chapter = blocks.first(where: { $0.chapter.chapter.id == anchor.chapter })?.chapter else { return nil }
|
||||
let character = anchor.inHeading ? 0 : ReaderPositionResolver.renderedLocation(
|
||||
position: anchor.position, prefixLength: chapter.bodyStart, renderedLength: chapter.storage.length
|
||||
)
|
||||
let glyph = chapter.manager.glyphIndexForCharacter(at: character)
|
||||
guard let row = blocks.firstIndex(where: {
|
||||
$0.chapter.chapter.id == anchor.chapter && NSLocationInRange(glyph, $0.glyphRange)
|
||||
}) else { return nil }
|
||||
let lineY = chapter.manager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil).minY
|
||||
return frames[row].minY + lineY - blocks[row].top - anchor.screenY
|
||||
}
|
||||
|
||||
private func seek(chapter: Int, position: Int, includeHeading: Bool) {
|
||||
guard let view else { return }
|
||||
let layout = view.collectionViewLayout as! ReaderContinuousLayout
|
||||
let anchor = Anchor(chapter: chapter, position: max(position, 0), inHeading: includeHeading, screenY: 0)
|
||||
guard let y = offset(for: anchor, blocks: blocks, frames: layout.frames) else { return }
|
||||
let maximum = max(layout.collectionViewContentSize.height - view.bounds.height, 0)
|
||||
view.setContentOffset(CGPoint(x: 0, y: min(max(y, 0), maximum)), animated: false)
|
||||
}
|
||||
|
||||
@objc private func tapped(_ gesture: UITapGestureRecognizer) {
|
||||
guard let view, !view.isDragging, !view.isDecelerating else { return }
|
||||
let point = gesture.location(in: view)
|
||||
if let row = (view.collectionViewLayout as? ReaderContinuousLayout)?.row(at: point.y),
|
||||
blocks[row].chapter.chapter.error != nil {
|
||||
parent.configuration.onTapAction(0)
|
||||
return
|
||||
}
|
||||
let x = point.x - view.bounds.minX, y = point.y - view.bounds.minY
|
||||
let column = min(max(Int(x / max(view.bounds.width / 3, 1)), 0), 2)
|
||||
let row = min(max(Int(y / max(view.bounds.height / 3, 1)), 0), 2)
|
||||
let index = row * 3 + column
|
||||
let actions = parent.configuration.clickActions
|
||||
let action = index == 4 ? 0 : (actions.indices.contains(index) ? actions[index] : 0)
|
||||
if action != 1, action != 2, action != -1 { parent.configuration.onTapAction(action) }
|
||||
}
|
||||
|
||||
@objc private func step(_ link: CADisplayLink) {
|
||||
defer { lastTimestamp = link.timestamp }
|
||||
guard let view, lastTimestamp > 0, parent.configuration.isAutoReading,
|
||||
!parent.configuration.isAutoReadPaused, !view.isDragging, !view.isDecelerating else { return }
|
||||
let elapsed = min(link.timestamp - lastTimestamp, 0.1)
|
||||
let maximum = max(view.contentSize.height - view.bounds.height, 0)
|
||||
let next = min(view.contentOffset.y + view.bounds.height / CGFloat(max(parent.configuration.secondsPerScreen, 1)) * elapsed, maximum)
|
||||
if next > view.contentOffset.y { view.setContentOffset(CGPoint(x: 0, y: next), animated: false) }
|
||||
if next >= maximum - 0.5, !reachedEnd {
|
||||
reachedEnd = true
|
||||
parent.configuration.onReachedEnd(parent.configuration.readerPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ReaderScrollCollectionView: UICollectionView {
|
||||
var viewportChanged: (() -> Void)?
|
||||
let loadingLabel = UILabel()
|
||||
private var viewportSize = CGSize.zero
|
||||
|
||||
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
|
||||
super.init(frame: frame, collectionViewLayout: layout)
|
||||
loadingLabel.font = .systemFont(ofSize: 12)
|
||||
loadingLabel.textAlignment = .center
|
||||
loadingLabel.isUserInteractionEnabled = false
|
||||
addSubview(loadingLabel)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
positionLoadingLabel()
|
||||
if viewportSize != bounds.size {
|
||||
viewportSize = bounds.size
|
||||
viewportChanged?()
|
||||
}
|
||||
}
|
||||
|
||||
func positionLoadingLabel() {
|
||||
loadingLabel.frame = CGRect(x: 0, y: bounds.maxY - 28, width: bounds.width, height: 24)
|
||||
bringSubviewToFront(loadingLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ReaderContinuousLayout: UICollectionViewLayout {
|
||||
private(set) var frames: [CGRect] = []
|
||||
private var size = CGSize.zero
|
||||
override var collectionViewContentSize: CGSize { size }
|
||||
|
||||
static func geometry(blocks: [ReaderScrollBlock], width: CGFloat, top: CGFloat, bottom: CGFloat, viewportHeight: CGFloat) -> (frames: [CGRect], size: CGSize) {
|
||||
var y = top
|
||||
let frames = blocks.map { block -> CGRect in
|
||||
let frame = CGRect(x: 0, y: y, width: width, height: block.height)
|
||||
y += block.height
|
||||
return frame
|
||||
}
|
||||
let finalChapter = blocks.last?.chapter.chapter.id
|
||||
let finalStart = blocks.indices.first(where: { blocks[$0].chapter.chapter.id == finalChapter })
|
||||
.map { frames[$0].minY } ?? top
|
||||
return (frames, CGSize(width: width, height: max(y, finalStart + viewportHeight) + bottom))
|
||||
}
|
||||
|
||||
func install(frames: [CGRect], size: CGSize, correction: CGFloat) {
|
||||
let context = UICollectionViewLayoutInvalidationContext()
|
||||
context.contentOffsetAdjustment = CGPoint(x: 0, y: correction)
|
||||
context.contentSizeAdjustment = CGSize(width: size.width - self.size.width, height: size.height - self.size.height)
|
||||
self.frames = frames
|
||||
self.size = size
|
||||
invalidateLayout(with: context)
|
||||
}
|
||||
|
||||
func row(at y: CGFloat) -> Int? {
|
||||
guard !frames.isEmpty else { return nil }
|
||||
var low = 0, high = frames.count
|
||||
while low < high {
|
||||
let middle = (low + high) / 2
|
||||
if frames[middle].maxY <= y { low = middle + 1 } else { high = middle }
|
||||
}
|
||||
return min(low, frames.count - 1)
|
||||
}
|
||||
|
||||
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
|
||||
guard let start = row(at: rect.minY) else { return [] }
|
||||
var result: [UICollectionViewLayoutAttributes] = []
|
||||
for row in start..<frames.count {
|
||||
if frames[row].minY > rect.maxY { break }
|
||||
if let attributes = layoutAttributesForItem(at: IndexPath(item: row, section: 0)) { result.append(attributes) }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
|
||||
guard frames.indices.contains(indexPath.item) else { return nil }
|
||||
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
|
||||
attributes.frame = frames[indexPath.item]
|
||||
return attributes
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ReaderScrollBlock {
|
||||
struct ID: Hashable { let chapter: Int; let firstGlyph: Int }
|
||||
let chapter: ReaderScrollChapterLayout
|
||||
let glyphRange: NSRange
|
||||
let top: CGFloat
|
||||
let height: CGFloat
|
||||
var id: ID { .init(chapter: chapter.chapter.id, firstGlyph: glyphRange.location) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ReaderScrollChapterLayout {
|
||||
let chapter: ReaderScrollChapter
|
||||
let storage: NSTextStorage
|
||||
let manager = NSLayoutManager()
|
||||
let container: NSTextContainer
|
||||
let bodyStart: Int
|
||||
let left: CGFloat
|
||||
private var fragments: [(range: NSRange, top: CGFloat, height: CGFloat)] = []
|
||||
var blocks: [ReaderScrollBlock] {
|
||||
fragments.map { .init(chapter: self, glyphRange: $0.range, top: $0.top, height: $0.height) }
|
||||
}
|
||||
|
||||
init(chapter: ReaderScrollChapter, configuration: ReaderTextView, width: CGFloat) {
|
||||
self.chapter = chapter
|
||||
let typography = ReaderTypography(configuration: configuration)
|
||||
left = CGFloat(configuration.paddingLeft) + 4
|
||||
let textWidth = max(width - left - CGFloat(configuration.paddingRight) - 4, 1)
|
||||
container = NSTextContainer(size: CGSize(width: textWidth, height: .greatestFiniteMagnitude))
|
||||
container.lineFragmentPadding = 0
|
||||
let attributed = NSMutableAttributedString(string: "")
|
||||
if configuration.titleMode != 2, !chapter.title.isEmpty {
|
||||
attributed.append(NSAttributedString(string: chapter.title + "\n", attributes: typography.headingAttributes))
|
||||
}
|
||||
bodyStart = attributed.length
|
||||
let body = chapter.error.map { "本章加载失败\n\n\($0)\n\n轻点打开阅读菜单。\n" } ?? (chapter.content.isEmpty ? "本章暂无正文\n" : chapter.content)
|
||||
attributed.append(NSAttributedString(string: body, attributes: typography.bodyAttributes))
|
||||
storage = NSTextStorage(attributedString: attributed)
|
||||
storage.addLayoutManager(manager)
|
||||
manager.addTextContainer(container)
|
||||
manager.ensureLayout(for: container)
|
||||
var startGlyph = 0
|
||||
var top: CGFloat = 0
|
||||
let count = manager.numberOfGlyphs
|
||||
manager.enumerateLineFragments(forGlyphRange: NSRange(location: 0, length: count)) { rect, _, _, range, _ in
|
||||
if rect.minY - top >= 1000, range.location > startGlyph {
|
||||
self.fragments.append((NSRange(location: startGlyph, length: range.location - startGlyph),
|
||||
top, rect.minY - top))
|
||||
startGlyph = range.location
|
||||
top = rect.minY
|
||||
}
|
||||
}
|
||||
let bottom = manager.usedRect(for: container).maxY + CGFloat(configuration.fontSize * 1.4)
|
||||
fragments.append((NSRange(location: startGlyph, length: count - startGlyph), top, max(bottom - top, 1)))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ReaderScrollCell: UICollectionViewCell {
|
||||
let canvas = ReaderScrollCanvas()
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
contentView.backgroundColor = .clear
|
||||
contentView.addSubview(canvas)
|
||||
}
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
override func layoutSubviews() { super.layoutSubviews(); canvas.frame = contentView.bounds }
|
||||
override func prepareForReuse() { super.prepareForReuse(); canvas.block = nil }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ReaderScrollCanvas: UIView {
|
||||
var block: ReaderScrollBlock? {
|
||||
didSet {
|
||||
if let block {
|
||||
let range = block.chapter.manager.characterRange(forGlyphRange: block.glyphRange, actualGlyphRange: nil)
|
||||
accessibilityLabel = block.chapter.storage.attributedSubstring(from: range).string
|
||||
} else { accessibilityLabel = nil }
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
isOpaque = false
|
||||
isAccessibilityElement = true
|
||||
accessibilityTraits = .staticText
|
||||
contentMode = .redraw
|
||||
}
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let block else { return }
|
||||
let origin = CGPoint(x: block.chapter.left, y: -block.top)
|
||||
block.chapter.manager.drawBackground(forGlyphRange: block.glyphRange, at: origin)
|
||||
block.chapter.manager.drawGlyphs(forGlyphRange: block.glyphRange, at: origin)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ReaderTypography {
|
||||
let bodyAttributes: [NSAttributedString.Key: Any]
|
||||
let headingAttributes: [NSAttributedString.Key: Any]
|
||||
|
||||
init(configuration c: ReaderTextView) {
|
||||
let weight: UIFont.Weight = switch c.fontWeight { case 1: .bold; case 2: .light; default: .regular }
|
||||
let font = ReaderFontStore.font(at: c.fontPath, size: CGFloat(c.fontSize), weight: c.fontWeight)
|
||||
?? (c.fontWeight == 0 ? (UIFont(name: "STSongti-SC-Regular", size: CGFloat(c.fontSize))
|
||||
?? UIFont.systemFont(ofSize: CGFloat(c.fontSize))) : UIFont.systemFont(ofSize: CGFloat(c.fontSize), weight: weight))
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = CGFloat(c.lineSpacing)
|
||||
paragraph.paragraphSpacing = CGFloat(c.paragraphSpacing)
|
||||
paragraph.firstLineHeadIndent = (c.paragraphIndent as NSString).size(withAttributes: [
|
||||
.font: font, .kern: CGFloat(c.letterSpacing * c.fontSize)
|
||||
]).width
|
||||
paragraph.alignment = c.fullJustify ? .justified : .natural
|
||||
var body: [NSAttributedString.Key: Any] = [
|
||||
.font: font, .kern: CGFloat(c.letterSpacing * c.fontSize),
|
||||
.foregroundColor: c.foregroundColor, .paragraphStyle: paragraph
|
||||
]
|
||||
if c.underlineMode != 0 {
|
||||
var underline = NSUnderlineStyle.single
|
||||
if c.underlineMode == 2 { underline.insert(.patternDash) }
|
||||
body[.underlineStyle] = underline.rawValue
|
||||
body[.underlineColor] = c.foregroundColor
|
||||
}
|
||||
bodyAttributes = body
|
||||
let headingFont = ReaderFontStore.font(at: c.fontPath, size: CGFloat(max(5, c.fontSize + c.titleSize)), weight: c.fontWeight)
|
||||
?? UIFont.systemFont(ofSize: CGFloat(max(5, c.fontSize + c.titleSize)), weight: weight)
|
||||
let heading = NSMutableParagraphStyle()
|
||||
heading.alignment = c.titleMode == 1 ? .center : .left
|
||||
heading.paragraphSpacingBefore = CGFloat(c.titleTopSpacing)
|
||||
heading.paragraphSpacing = CGFloat(c.titleBottomSpacing)
|
||||
headingAttributes = [.font: headingFont, .foregroundColor: c.foregroundColor, .paragraphStyle: heading]
|
||||
}
|
||||
}
|
||||
|
||||
extension ReaderTextView {
|
||||
var scrollTypographyKey: String {
|
||||
"\(fontSize)|\(fontPath)|\(letterSpacing)|\(lineSpacing)|\(paragraphSpacing)|\(paragraphIndent)|\(paddingLeft)|\(paddingRight)|\(paddingTop)|\(paddingBottom)|\(fontWeight)|\(titleMode)|\(titleSize)|\(titleTopSpacing)|\(titleBottomSpacing)|\(underlineMode)|\(fullJustify)|\(foregroundColor.hash)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
@preconcurrency import AVFoundation
|
||||
@preconcurrency import MediaPlayer
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
private final class IncrementalAudioResourceLoader: NSObject, AVAssetResourceLoaderDelegate, @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "io.legado.tts.stream-loader")
|
||||
private let contentType: String?
|
||||
private let expectedLength: Int64
|
||||
private var data = Data()
|
||||
private var pending: [AVAssetResourceLoadingRequest] = []
|
||||
private var finished = false
|
||||
private var failure: Error?
|
||||
private var consumeTask: Task<Void, Never>?
|
||||
|
||||
init(stream: HttpTTSAudioStream) {
|
||||
contentType = stream.contentType
|
||||
expectedLength = stream.expectedContentLength ?? 0
|
||||
super.init()
|
||||
consumeTask = Task { [weak self] in
|
||||
do {
|
||||
for try await chunk in stream.chunks {
|
||||
guard let self else { return }
|
||||
self.queue.async { [weak self] in
|
||||
self?.data.append(chunk)
|
||||
self?.processPendingRequests()
|
||||
}
|
||||
}
|
||||
self?.queue.async { [weak self] in
|
||||
self?.finished = true
|
||||
self?.processPendingRequests()
|
||||
}
|
||||
} catch {
|
||||
self?.queue.async { [weak self] in
|
||||
self?.failure = error
|
||||
self?.finished = true
|
||||
self?.processPendingRequests()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit { consumeTask?.cancel() }
|
||||
|
||||
func cancel() {
|
||||
consumeTask?.cancel()
|
||||
queue.async { [weak self] in
|
||||
guard let self, !finished else { return }
|
||||
failure = CancellationError()
|
||||
finished = true
|
||||
processPendingRequests()
|
||||
}
|
||||
}
|
||||
|
||||
func install(on asset: AVURLAsset) {
|
||||
asset.resourceLoader.setDelegate(self, queue: queue)
|
||||
}
|
||||
|
||||
func resourceLoader(
|
||||
_ resourceLoader: AVAssetResourceLoader,
|
||||
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
|
||||
) -> Bool {
|
||||
pending.append(loadingRequest)
|
||||
processPendingRequests()
|
||||
return true
|
||||
}
|
||||
|
||||
func resourceLoader(
|
||||
_ resourceLoader: AVAssetResourceLoader,
|
||||
didCancel loadingRequest: AVAssetResourceLoadingRequest
|
||||
) {
|
||||
pending.removeAll { $0 === loadingRequest }
|
||||
}
|
||||
|
||||
private func processPendingRequests() {
|
||||
pending.removeAll { request in
|
||||
if let info = request.contentInformationRequest {
|
||||
info.contentType = contentType.flatMap { UTType(mimeType: $0)?.identifier }
|
||||
info.contentLength = expectedLength
|
||||
info.isByteRangeAccessSupported = false
|
||||
}
|
||||
if let failure {
|
||||
request.finishLoading(with: failure)
|
||||
return true
|
||||
}
|
||||
guard let dataRequest = request.dataRequest else {
|
||||
if finished { request.finishLoading(); return true }
|
||||
return false
|
||||
}
|
||||
let offset = max(Int(dataRequest.currentOffset), Int(dataRequest.requestedOffset))
|
||||
if offset < data.count {
|
||||
let requestedEnd = Int(dataRequest.requestedOffset) + dataRequest.requestedLength
|
||||
let end = min(data.count, requestedEnd)
|
||||
if end > offset { dataRequest.respond(with: data.subdata(in: offset..<end)) }
|
||||
}
|
||||
let deliveredEnd = max(Int(dataRequest.currentOffset), Int(dataRequest.requestedOffset))
|
||||
let requestedEnd = Int(dataRequest.requestedOffset) + dataRequest.requestedLength
|
||||
if deliveredEnd >= requestedEnd || finished {
|
||||
request.finishLoading()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ReaderSpeechController: NSObject, ObservableObject, @preconcurrency AVSpeechSynthesizerDelegate, @preconcurrency AVAudioPlayerDelegate {
|
||||
@Published private(set) var isSpeaking = false
|
||||
@Published private(set) var isPaused = false
|
||||
@Published private(set) var errorMessage: String?
|
||||
@Published private(set) var completionCount = 0
|
||||
@Published private(set) var currentSegmentIndex = 0
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
private let httpService = HttpTTSService()
|
||||
private var audioPlayer: AVAudioPlayer?
|
||||
private var streamPlayer: AVPlayer?
|
||||
private var streamLoader: IncrementalAudioResourceLoader?
|
||||
private var streamCompletionToken: NSObjectProtocol?
|
||||
private var httpTask: Task<Void, Never>?
|
||||
private var httpPrefetchTasks: [Task<Void, Never>] = []
|
||||
private var httpSegments: [String] = []
|
||||
private var httpSegmentIndex = 0
|
||||
private var httpEngine: HttpTTS?
|
||||
private var systemSegments: [String] = []
|
||||
private var systemSegmentIndex = 0
|
||||
private var segmentIndexOffset = 0
|
||||
private var followsSystemRate = true
|
||||
private var speechRate = 5
|
||||
private var readByPage = false
|
||||
private var streamsHTTPAudio = true
|
||||
private var voiceIdentifier = ""
|
||||
private var continuesAcrossChapters = false
|
||||
private var notificationTokens: [NSObjectProtocol] = []
|
||||
nonisolated(unsafe) private var remoteCommandTargets: [(MPRemoteCommand, Any)] = []
|
||||
private var resumesAfterInterruption = false
|
||||
private var isChangingSystemSegment = false
|
||||
private var ignoresAudioFocus = false
|
||||
private var pausesForPhoneCalls = false
|
||||
private var mediaButtonPerNext = false
|
||||
private var bookName = ""
|
||||
private var chapterTitle = ""
|
||||
private var author = ""
|
||||
var onNextChapter: (() -> Void)?
|
||||
var onPreviousChapter: (() -> Void)?
|
||||
var onRemoteStop: (() -> Void)?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
synthesizer.delegate = self
|
||||
observeAudioSession()
|
||||
installRemoteCommands()
|
||||
}
|
||||
|
||||
deinit {
|
||||
notificationTokens.forEach(NotificationCenter.default.removeObserver)
|
||||
remoteCommandTargets.forEach { command, target in command.removeTarget(target) }
|
||||
}
|
||||
|
||||
func toggle(text: String, continuesAcrossChapters: Bool = false) {
|
||||
if isSpeaking || isPaused {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
speak(text, continuesAcrossChapters: continuesAcrossChapters)
|
||||
}
|
||||
|
||||
func toggleHTTP(engine: HttpTTS, text: String, continuesAcrossChapters: Bool = false) {
|
||||
if isSpeaking || isPaused {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
speakHTTP(engine: engine, text: text, continuesAcrossChapters: continuesAcrossChapters)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
synthesizer.stopSpeaking(at: .word)
|
||||
audioPlayer?.stop()
|
||||
audioPlayer = nil
|
||||
streamPlayer?.pause()
|
||||
streamLoader?.cancel()
|
||||
streamPlayer = nil
|
||||
streamLoader = nil
|
||||
if let streamCompletionToken {
|
||||
NotificationCenter.default.removeObserver(streamCompletionToken)
|
||||
self.streamCompletionToken = nil
|
||||
}
|
||||
httpTask?.cancel()
|
||||
httpTask = nil
|
||||
httpPrefetchTasks.forEach { $0.cancel() }
|
||||
httpPrefetchTasks.removeAll()
|
||||
httpSegments = []
|
||||
httpSegmentIndex = 0
|
||||
httpEngine = nil
|
||||
systemSegments = []
|
||||
systemSegmentIndex = 0
|
||||
segmentIndexOffset = 0
|
||||
continuesAcrossChapters = false
|
||||
isSpeaking = false
|
||||
isPaused = false
|
||||
updateNowPlaying()
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
func configure(
|
||||
followsSystemRate: Bool,
|
||||
speechRate: Int,
|
||||
readByPage: Bool,
|
||||
streamsHTTPAudio: Bool = true,
|
||||
voiceIdentifier: String = "",
|
||||
ignoresAudioFocus: Bool = false,
|
||||
pausesForPhoneCalls: Bool = false,
|
||||
mediaButtonPerNext: Bool = false,
|
||||
bookName: String = "",
|
||||
chapterTitle: String = "",
|
||||
author: String = ""
|
||||
) {
|
||||
self.followsSystemRate = followsSystemRate
|
||||
self.speechRate = min(max(speechRate, 0), 45)
|
||||
self.readByPage = readByPage
|
||||
self.streamsHTTPAudio = streamsHTTPAudio
|
||||
self.voiceIdentifier = voiceIdentifier
|
||||
self.ignoresAudioFocus = ignoresAudioFocus
|
||||
self.pausesForPhoneCalls = pausesForPhoneCalls
|
||||
self.mediaButtonPerNext = mediaButtonPerNext
|
||||
self.bookName = bookName
|
||||
self.chapterTitle = chapterTitle
|
||||
self.author = author
|
||||
}
|
||||
|
||||
func speak(_ text: String, continuesAcrossChapters: Bool = false, segmentIndexOffset: Int = 0) {
|
||||
stop()
|
||||
errorMessage = nil
|
||||
activateAudioSession()
|
||||
self.continuesAcrossChapters = continuesAcrossChapters
|
||||
if synthesizer.isSpeaking || synthesizer.isPaused {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
}
|
||||
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
systemSegments = segments(for: text)
|
||||
guard !systemSegments.isEmpty else { return }
|
||||
systemSegmentIndex = 0
|
||||
self.segmentIndexOffset = max(segmentIndexOffset, 0)
|
||||
playCurrentSystemSegment()
|
||||
}
|
||||
|
||||
func speakHTTP(
|
||||
engine: HttpTTS,
|
||||
text: String,
|
||||
continuesAcrossChapters: Bool = false,
|
||||
segmentIndexOffset: Int = 0
|
||||
) {
|
||||
stop()
|
||||
errorMessage = nil
|
||||
activateAudioSession()
|
||||
self.continuesAcrossChapters = continuesAcrossChapters
|
||||
httpEngine = engine
|
||||
httpSegments = segments(for: text)
|
||||
guard !httpSegments.isEmpty else { return }
|
||||
httpSegmentIndex = 0
|
||||
self.segmentIndexOffset = max(segmentIndexOffset, 0)
|
||||
isSpeaking = true
|
||||
playCurrentHTTPSegment()
|
||||
prefetchHTTP(engine: engine, texts: Array(httpSegments.dropFirst()))
|
||||
}
|
||||
|
||||
func prefetchHTTP(engine: HttpTTS, text: String, limit: Int = 10) {
|
||||
let values = Array(segments(for: text).prefix(max(limit, 0)))
|
||||
prefetchHTTP(engine: engine, texts: values)
|
||||
}
|
||||
|
||||
func pauseOrResume(text: String, continuesAcrossChapters: Bool = false) {
|
||||
if isPaused {
|
||||
resumePlayback()
|
||||
} else if isSpeaking {
|
||||
pausePlayback()
|
||||
} else {
|
||||
speak(text, continuesAcrossChapters: continuesAcrossChapters)
|
||||
}
|
||||
}
|
||||
|
||||
func pauseOrResumeHTTP(engine: HttpTTS, text: String, continuesAcrossChapters: Bool = false) {
|
||||
if audioPlayer != nil {
|
||||
pauseOrResume(text: text, continuesAcrossChapters: continuesAcrossChapters)
|
||||
} else if isSpeaking {
|
||||
httpTask?.cancel()
|
||||
isSpeaking = false
|
||||
isPaused = true
|
||||
} else if isPaused, httpEngine != nil {
|
||||
isPaused = false
|
||||
isSpeaking = true
|
||||
playCurrentHTTPSegment()
|
||||
} else {
|
||||
speakHTTP(engine: engine, text: text, continuesAcrossChapters: continuesAcrossChapters)
|
||||
}
|
||||
}
|
||||
|
||||
private func segments(for text: String) -> [String] {
|
||||
let values = readByPage ? [text] : text.components(separatedBy: .newlines)
|
||||
return values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private func activateAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setCategory(.playback, mode: .spokenAudio, options: ignoresAudioFocus ? [.mixWithOthers] : [])
|
||||
try? session.setActive(true)
|
||||
}
|
||||
|
||||
private func pausePlayback() {
|
||||
if let audioPlayer, audioPlayer.isPlaying {
|
||||
audioPlayer.pause()
|
||||
} else if let streamPlayer {
|
||||
streamPlayer.pause()
|
||||
} else if synthesizer.isSpeaking {
|
||||
synthesizer.pauseSpeaking(at: .word)
|
||||
} else if httpTask != nil {
|
||||
httpTask?.cancel()
|
||||
httpTask = nil
|
||||
} else {
|
||||
return
|
||||
}
|
||||
isPaused = true
|
||||
isSpeaking = false
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
private func resumePlayback() {
|
||||
activateAudioSession()
|
||||
if let audioPlayer {
|
||||
isSpeaking = audioPlayer.play()
|
||||
} else if let streamPlayer {
|
||||
streamPlayer.play()
|
||||
isSpeaking = true
|
||||
} else if synthesizer.isPaused {
|
||||
synthesizer.continueSpeaking()
|
||||
isSpeaking = true
|
||||
} else if httpEngine != nil, httpSegments.indices.contains(httpSegmentIndex) {
|
||||
isSpeaking = true
|
||||
playCurrentHTTPSegment()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
isPaused = false
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
private func observeAudioSession() {
|
||||
let center = NotificationCenter.default
|
||||
notificationTokens.append(center.addObserver(
|
||||
forName: AVAudioSession.interruptionNotification,
|
||||
object: AVAudioSession.sharedInstance(),
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
let type = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt
|
||||
let options = note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
||||
Task { @MainActor in self?.handleAudioInterruption(type: type, options: options) }
|
||||
})
|
||||
notificationTokens.append(center.addObserver(
|
||||
forName: AVAudioSession.routeChangeNotification,
|
||||
object: AVAudioSession.sharedInstance(),
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
let reason = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt
|
||||
Task { @MainActor in self?.handleRouteChange(reason: reason) }
|
||||
})
|
||||
}
|
||||
|
||||
private func handleAudioInterruption(type raw: UInt?, options rawOptions: UInt) {
|
||||
guard let raw,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
|
||||
switch type {
|
||||
case .began:
|
||||
if ignoresAudioFocus && !pausesForPhoneCalls { return }
|
||||
resumesAfterInterruption = isSpeaking
|
||||
if isSpeaking { pausePlayback() }
|
||||
case .ended:
|
||||
if ignoresAudioFocus && !pausesForPhoneCalls { return }
|
||||
let options = AVAudioSession.InterruptionOptions(rawValue: rawOptions)
|
||||
if resumesAfterInterruption, options.contains(.shouldResume) { resumePlayback() }
|
||||
resumesAfterInterruption = false
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRouteChange(reason raw: UInt?) {
|
||||
guard let raw,
|
||||
AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable else { return }
|
||||
if isSpeaking { pausePlayback() }
|
||||
}
|
||||
|
||||
private func installRemoteCommands() {
|
||||
let center = MPRemoteCommandCenter.shared()
|
||||
center.playCommand.isEnabled = true
|
||||
center.pauseCommand.isEnabled = true
|
||||
center.togglePlayPauseCommand.isEnabled = true
|
||||
center.stopCommand.isEnabled = true
|
||||
center.nextTrackCommand.isEnabled = true
|
||||
center.previousTrackCommand.isEnabled = true
|
||||
addRemoteTarget(center.playCommand) { $0.resumePlayback() }
|
||||
addRemoteTarget(center.pauseCommand) { $0.pausePlayback() }
|
||||
addRemoteTarget(center.togglePlayPauseCommand) { controller in
|
||||
controller.isPaused ? controller.resumePlayback() : controller.pausePlayback()
|
||||
}
|
||||
addRemoteTarget(center.stopCommand) { controller in
|
||||
controller.stop()
|
||||
controller.onRemoteStop?()
|
||||
}
|
||||
addRemoteTarget(center.nextTrackCommand) { $0.skipForward(usesChapterPreference: true) }
|
||||
addRemoteTarget(center.previousTrackCommand) { $0.skipBackward(usesChapterPreference: true) }
|
||||
}
|
||||
|
||||
private func addRemoteTarget(_ command: MPRemoteCommand, action: @escaping @MainActor (ReaderSpeechController) -> Void) {
|
||||
let target = command.addTarget { [weak self] _ in
|
||||
guard self != nil else { return .noSuchContent }
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
action(self)
|
||||
}
|
||||
return .success
|
||||
}
|
||||
remoteCommandTargets.append((command, target))
|
||||
}
|
||||
|
||||
func nextSegment() {
|
||||
guard isSpeaking || isPaused else { return }
|
||||
skipForward(usesChapterPreference: false)
|
||||
}
|
||||
|
||||
func previousSegment() {
|
||||
guard isSpeaking || isPaused else { return }
|
||||
skipBackward(usesChapterPreference: false)
|
||||
}
|
||||
|
||||
private func playCurrentSystemSegment() {
|
||||
guard systemSegments.indices.contains(systemSegmentIndex) else {
|
||||
finishNaturally()
|
||||
return
|
||||
}
|
||||
let utterance = AVSpeechUtterance(string: systemSegments[systemSegmentIndex])
|
||||
utterance.voice = AVSpeechSynthesisVoice(identifier: voiceIdentifier)
|
||||
?? AVSpeechSynthesisVoice(language: "zh-CN")
|
||||
let multiplier = followsSystemRate ? 1 : Float(speechRate + 5) / 10
|
||||
utterance.rate = min(
|
||||
max(AVSpeechUtteranceDefaultSpeechRate * multiplier, AVSpeechUtteranceMinimumSpeechRate),
|
||||
AVSpeechUtteranceMaximumSpeechRate
|
||||
)
|
||||
utterance.pitchMultiplier = 1
|
||||
synthesizer.speak(utterance)
|
||||
currentSegmentIndex = segmentIndexOffset + systemSegmentIndex
|
||||
isSpeaking = true
|
||||
isPaused = false
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
private func skipForward(usesChapterPreference: Bool) {
|
||||
if usesChapterPreference && mediaButtonPerNext {
|
||||
onNextChapter?()
|
||||
} else if httpEngine != nil, httpSegmentIndex + 1 < httpSegments.count {
|
||||
stopCurrentHTTPPlayback()
|
||||
httpSegmentIndex += 1
|
||||
playCurrentHTTPSegment()
|
||||
} else if !systemSegments.isEmpty, systemSegmentIndex + 1 < systemSegments.count {
|
||||
changeSystemSegment(to: systemSegmentIndex + 1)
|
||||
} else {
|
||||
onNextChapter?()
|
||||
}
|
||||
}
|
||||
|
||||
private func skipBackward(usesChapterPreference: Bool) {
|
||||
if usesChapterPreference && mediaButtonPerNext {
|
||||
onPreviousChapter?()
|
||||
} else if httpEngine != nil, httpSegmentIndex > 0 {
|
||||
stopCurrentHTTPPlayback()
|
||||
httpSegmentIndex -= 1
|
||||
playCurrentHTTPSegment()
|
||||
} else if !systemSegments.isEmpty, systemSegmentIndex > 0 {
|
||||
changeSystemSegment(to: systemSegmentIndex - 1)
|
||||
} else {
|
||||
onPreviousChapter?()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopCurrentHTTPPlayback() {
|
||||
audioPlayer?.stop()
|
||||
audioPlayer = nil
|
||||
streamPlayer?.pause()
|
||||
streamLoader?.cancel()
|
||||
streamPlayer = nil
|
||||
streamLoader = nil
|
||||
if let streamCompletionToken {
|
||||
NotificationCenter.default.removeObserver(streamCompletionToken)
|
||||
self.streamCompletionToken = nil
|
||||
}
|
||||
httpTask?.cancel()
|
||||
httpTask = nil
|
||||
}
|
||||
|
||||
private func changeSystemSegment(to index: Int) {
|
||||
isChangingSystemSegment = true
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
systemSegmentIndex = index
|
||||
playCurrentSystemSegment()
|
||||
DispatchQueue.main.async { [weak self] in self?.isChangingSystemSegment = false }
|
||||
}
|
||||
|
||||
private func updateNowPlaying() {
|
||||
guard isSpeaking || isPaused else {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
return
|
||||
}
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
|
||||
MPMediaItemPropertyTitle: chapterTitle,
|
||||
MPMediaItemPropertyAlbumTitle: bookName,
|
||||
MPMediaItemPropertyArtist: author,
|
||||
MPNowPlayingInfoPropertyPlaybackRate: isSpeaking ? 1 : 0
|
||||
]
|
||||
}
|
||||
|
||||
private func playCurrentHTTPSegment() {
|
||||
guard let engine = httpEngine, httpSegments.indices.contains(httpSegmentIndex) else {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
let text = httpSegments[httpSegmentIndex]
|
||||
currentSegmentIndex = segmentIndexOffset + httpSegmentIndex
|
||||
let rate = speechRate + 5
|
||||
httpTask?.cancel()
|
||||
httpTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
if streamsHTTPAudio {
|
||||
let stream = try await httpService.audioStream(engine: engine, text: text, speechRate: rate)
|
||||
try Task.checkCancellation()
|
||||
if stream.isIncremental, stream.expectedContentLength ?? 0 > 0 {
|
||||
playIncrementalHTTPStream(stream)
|
||||
} else {
|
||||
var data = Data()
|
||||
for try await chunk in stream.chunks { data.append(chunk) }
|
||||
try Task.checkCancellation()
|
||||
try playBufferedHTTPAudio(data)
|
||||
}
|
||||
} else {
|
||||
let audio = try await httpService.audio(engine: engine, text: text, speechRate: rate)
|
||||
try Task.checkCancellation()
|
||||
try playBufferedHTTPAudio(audio.data)
|
||||
}
|
||||
isPaused = false
|
||||
updateNowPlaying()
|
||||
if !isSpeaking { throw CocoaError(.fileReadCorruptFile) }
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func playBufferedHTTPAudio(_ data: Data) throws {
|
||||
let player = try AVAudioPlayer(data: data)
|
||||
player.delegate = self
|
||||
player.prepareToPlay()
|
||||
audioPlayer = player
|
||||
isSpeaking = player.play()
|
||||
}
|
||||
|
||||
private func playIncrementalHTTPStream(_ stream: HttpTTSAudioStream) {
|
||||
let url = URL(string: "legado-tts-stream://audio/\(UUID().uuidString)")!
|
||||
let asset = AVURLAsset(url: url)
|
||||
let loader = IncrementalAudioResourceLoader(stream: stream)
|
||||
loader.install(on: asset)
|
||||
let item = AVPlayerItem(asset: asset)
|
||||
let player = AVPlayer(playerItem: item)
|
||||
streamLoader = loader
|
||||
streamPlayer = player
|
||||
streamCompletionToken = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime,
|
||||
object: item,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in self?.incrementalPlayerDidFinish() }
|
||||
}
|
||||
player.play()
|
||||
isSpeaking = true
|
||||
}
|
||||
|
||||
private func incrementalPlayerDidFinish() {
|
||||
stopCurrentHTTPPlayback()
|
||||
httpSegmentIndex += 1
|
||||
if httpSegmentIndex < httpSegments.count { playCurrentHTTPSegment() }
|
||||
else { finishNaturally() }
|
||||
}
|
||||
|
||||
private func prefetchHTTP(engine: HttpTTS, texts: [String]) {
|
||||
guard !texts.isEmpty else { return }
|
||||
let rate = speechRate + 5
|
||||
let task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
for text in texts {
|
||||
guard !Task.isCancelled else { return }
|
||||
_ = try? await self.httpService.audio(engine: engine, text: text, speechRate: rate)
|
||||
}
|
||||
}
|
||||
httpPrefetchTasks.append(task)
|
||||
}
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
audioPlayer = nil
|
||||
guard flag else {
|
||||
errorMessage = "HTTP TTS 音频播放失败"
|
||||
stop()
|
||||
return
|
||||
}
|
||||
httpSegmentIndex += 1
|
||||
if httpSegmentIndex < httpSegments.count {
|
||||
playCurrentHTTPSegment()
|
||||
} else {
|
||||
finishNaturally()
|
||||
}
|
||||
}
|
||||
|
||||
private func finishNaturally() {
|
||||
let shouldContinue = continuesAcrossChapters
|
||||
stop()
|
||||
if shouldContinue { completionCount += 1 }
|
||||
}
|
||||
|
||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||
guard !isChangingSystemSegment else { return }
|
||||
systemSegmentIndex += 1
|
||||
if systemSegmentIndex < systemSegments.count { playCurrentSystemSegment() }
|
||||
else { finishNaturally() }
|
||||
}
|
||||
|
||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||
guard !isChangingSystemSegment else { return }
|
||||
guard !synthesizer.isSpeaking else { return }
|
||||
isSpeaking = false
|
||||
isPaused = false
|
||||
updateNowPlaying()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct ReaderInteractivePopEnabler: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: Context) -> Controller {
|
||||
Controller()
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: Controller, context: Context) {
|
||||
uiViewController.enableInteractivePopIfAvailable()
|
||||
}
|
||||
|
||||
static func dismantleUIViewController(_ uiViewController: Controller, coordinator: Void) {
|
||||
uiViewController.restoreInteractivePopDelegate()
|
||||
}
|
||||
|
||||
final class Controller: UIViewController {
|
||||
private weak var popGesture: UIGestureRecognizer?
|
||||
private var previousDelegate: (any UIGestureRecognizerDelegate)?
|
||||
private var releasedDelegate = false
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
enableInteractivePopIfAvailable()
|
||||
}
|
||||
|
||||
func enableInteractivePopIfAvailable() {
|
||||
guard let navigationController,
|
||||
navigationController.viewControllers.count > 1,
|
||||
let gesture = navigationController.interactivePopGestureRecognizer else { return }
|
||||
if gesture.delegate != nil {
|
||||
previousDelegate = gesture.delegate
|
||||
gesture.delegate = nil
|
||||
releasedDelegate = true
|
||||
}
|
||||
gesture.isEnabled = true
|
||||
popGesture = gesture
|
||||
}
|
||||
|
||||
func restoreInteractivePopDelegate() {
|
||||
guard releasedDelegate, let popGesture, popGesture.delegate == nil else { return }
|
||||
popGesture.delegate = previousDelegate
|
||||
previousDelegate = nil
|
||||
releasedDelegate = false
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct ReaderMenuDismissShape: Shape {
|
||||
var leadingExclusion: CGFloat = 24
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
Path(CGRect(
|
||||
x: min(max(leadingExclusion, 0), rect.width),
|
||||
y: 0,
|
||||
width: max(rect.width - leadingExclusion, 0),
|
||||
height: rect.height
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor enum ReaderScreenOrientation {
|
||||
static private(set) var supportedOrientations: UIInterfaceOrientationMask = .allButUpsideDown
|
||||
|
||||
static func apply(_ value: String) {
|
||||
let mask: UIInterfaceOrientationMask = switch value {
|
||||
case "1": .portrait
|
||||
case "2": .landscape
|
||||
case "3": .all
|
||||
case "4": .portraitUpsideDown
|
||||
case "5": .landscapeLeft
|
||||
default: .allButUpsideDown
|
||||
}
|
||||
supportedOrientations = mask
|
||||
guard let scene = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.first(where: { $0.activationState == .foregroundActive }) else { return }
|
||||
if #available(iOS 16.0, *) {
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { _ in }
|
||||
scene.windows.first?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
} else {
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
}
|
||||
}
|
||||
|
||||
static func restoreApplicationDefault() { apply("0") }
|
||||
}
|
||||
@@ -0,0 +1,936 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
private final class ReaderLayoutTextView: UITextView {
|
||||
var onLayout: ((UITextView) -> Void)?
|
||||
private var reportedSize = CGSize.zero
|
||||
private var reportedContentSize = CGSize.zero
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard bounds.width > 0, bounds.height > 0,
|
||||
bounds.size != reportedSize || contentSize != reportedContentSize else { return }
|
||||
reportedSize = bounds.size
|
||||
reportedContentSize = contentSize
|
||||
onLayout?(self)
|
||||
}
|
||||
}
|
||||
|
||||
struct ReaderTextView: UIViewRepresentable {
|
||||
let text: String
|
||||
let title: String
|
||||
let previousTitle: String?
|
||||
let previousText: String
|
||||
let isPreviousReady: Bool
|
||||
let continuationTitle: String?
|
||||
let continuationText: String
|
||||
let isContinuationReady: Bool
|
||||
let isVolume: Bool
|
||||
let fontSize: Double
|
||||
let fontPath: String
|
||||
let letterSpacing: Double
|
||||
let lineSpacing: Double
|
||||
let paragraphSpacing: Double
|
||||
let paragraphIndent: String
|
||||
let paddingLeft: Double
|
||||
let paddingRight: Double
|
||||
let paddingTop: Double
|
||||
let paddingBottom: Double
|
||||
let headerInset: Double
|
||||
let footerInset: Double
|
||||
let fontWeight: Int
|
||||
let titleMode: Int
|
||||
let titleSize: Double
|
||||
let titleTopSpacing: Double
|
||||
let titleBottomSpacing: Double
|
||||
let underlineMode: Int
|
||||
let isSelectable: Bool
|
||||
let fullJustify: Bool
|
||||
let pageAnimation: Int
|
||||
let isEInkMode: Bool
|
||||
let clickActions: [Int]
|
||||
let backgroundColor: UIColor
|
||||
let foregroundColor: UIColor
|
||||
let requestedPosition: Int
|
||||
let jumpPosition: Int?
|
||||
let renderRevision: Int
|
||||
@Binding var readerPosition: Int
|
||||
@Binding var pageRanges: [NSRange]
|
||||
@Binding var currentPage: Int
|
||||
@Binding var totalPages: Int
|
||||
let isAutoReading: Bool
|
||||
let isAutoReadPaused: Bool
|
||||
let isLoadingNextChapter: Bool
|
||||
let secondsPerScreen: Double
|
||||
let onReachedEnd: (Int) -> Void
|
||||
let onReachedStart: (Int) -> Void
|
||||
let onEnteredPrevious: (Int) -> Void
|
||||
let onEnteredContinuation: (Int) -> Void
|
||||
let onDeferredRender: () -> Void
|
||||
let onCancelNextLoading: () -> Void
|
||||
let onPageChanged: () -> Void
|
||||
let onTapAction: (Int) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
// Pagination uses NSLayoutManager. Start in TextKit 1 rather than
|
||||
// switching a TextKit 2 view after its content size has been computed.
|
||||
let view: ReaderLayoutTextView
|
||||
if #available(iOS 16.0, *) {
|
||||
view = ReaderLayoutTextView(usingTextLayoutManager: false)
|
||||
} else {
|
||||
view = ReaderLayoutTextView(frame: .zero)
|
||||
}
|
||||
view.onLayout = { [weak coordinator = context.coordinator] view in
|
||||
coordinator?.reportPage(in: view)
|
||||
coordinator?.reportPageRanges(in: view)
|
||||
}
|
||||
view.accessibilityIdentifier = "reader.text"
|
||||
let effectivePageAnimation = isEInkMode ? 4 : pageAnimation
|
||||
view.backgroundColor = backgroundColor
|
||||
view.isOpaque = backgroundColor != .clear
|
||||
view.delegate = context.coordinator
|
||||
view.isEditable = false
|
||||
view.isSelectable = false
|
||||
view.alwaysBounceVertical = effectivePageAnimation == 3
|
||||
view.isScrollEnabled = true
|
||||
view.panGestureRecognizer.isEnabled = effectivePageAnimation == 3
|
||||
view.textContainer.lineFragmentPadding = 4
|
||||
view.showsVerticalScrollIndicator = false
|
||||
view.isPagingEnabled = effectivePageAnimation != 3
|
||||
view.decelerationRate = effectivePageAnimation == 3 ? .normal : .fast
|
||||
context.coordinator.installDisplayLink(for: view)
|
||||
context.coordinator.installGestures(on: view)
|
||||
context.coordinator.updateInteractionMode(on: view, isScrollMode: effectivePageAnimation == 3)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: UITextView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
let viewportSizeChanged = view.bounds.size != .zero
|
||||
&& context.coordinator.lastViewportSize != view.bounds.size
|
||||
if viewportSizeChanged {
|
||||
context.coordinator.lastViewportSize = view.bounds.size
|
||||
}
|
||||
let effectivePageAnimation = isEInkMode ? 4 : pageAnimation
|
||||
view.isSelectable = false
|
||||
view.alwaysBounceVertical = effectivePageAnimation == 3
|
||||
view.isScrollEnabled = true
|
||||
view.panGestureRecognizer.isEnabled = effectivePageAnimation == 3
|
||||
view.showsVerticalScrollIndicator = false
|
||||
view.isPagingEnabled = effectivePageAnimation != 3
|
||||
view.decelerationRate = effectivePageAnimation == 3 ? .normal : .fast
|
||||
context.coordinator.updateInteractionMode(on: view, isScrollMode: effectivePageAnimation == 3)
|
||||
view.backgroundColor = backgroundColor
|
||||
view.isOpaque = backgroundColor != .clear
|
||||
view.isSelectable = false
|
||||
view.selectedRange = NSRange(location: 0, length: 0)
|
||||
view.resignFirstResponder()
|
||||
view.textContainerInset = .init(
|
||||
top: CGFloat(paddingTop + headerInset),
|
||||
left: CGFloat(paddingLeft),
|
||||
bottom: CGFloat(paddingBottom + footerInset),
|
||||
right: CGFloat(paddingRight)
|
||||
)
|
||||
let contentKey = text.hashValue
|
||||
let primaryContentKey = "\(title)|\(text)".hashValue
|
||||
let showsContinuation = effectivePageAnimation == 3 && isContinuationReady && continuationTitle != nil
|
||||
let showsPrevious = showsContinuation && isPreviousReady && previousTitle != nil
|
||||
let adjacentContentKey = "\(showsPrevious)|\(previousTitle ?? "")|\(previousText)|\(showsContinuation)|\(continuationTitle ?? "")|\(continuationText)".hashValue
|
||||
let displaysTitle = titleMode != 2 || isVolume
|
||||
let titlePrefix = displaysTitle && !title.isEmpty ? title + "\n" : ""
|
||||
let styleKey = "\(contentKey)|\(adjacentContentKey)|\(title)|\(isVolume)|\(fontSize)|\(fontPath)|\(letterSpacing)|\(lineSpacing)|\(paragraphSpacing)|\(paragraphIndent)|\(paddingLeft)|\(paddingRight)|\(paddingTop)|\(paddingBottom)|\(headerInset)|\(footerInset)|\(fontWeight)|\(titleMode)|\(titleSize)|\(titleTopSpacing)|\(titleBottomSpacing)|\(underlineMode)|\(isSelectable)|\(fullJustify)|\(effectivePageAnimation)|\(foregroundColor.hash)"
|
||||
if context.coordinator.styleKey != styleKey {
|
||||
if context.coordinator.shouldDeferRender(in: view) {
|
||||
context.coordinator.needsDeferredRender = true
|
||||
return
|
||||
}
|
||||
let primaryContentChanged = context.coordinator.primaryContentKey != primaryContentKey
|
||||
let visiblePosition = context.coordinator.contentCharacter(at: view.contentOffset, in: view)
|
||||
context.coordinator.resetAutoReadForPageChange()
|
||||
context.coordinator.resetPageRangeRetry()
|
||||
context.coordinator.contentKey = contentKey
|
||||
context.coordinator.primaryContentKey = primaryContentKey
|
||||
context.coordinator.styleKey = styleKey
|
||||
let typography = ReaderTypography(configuration: self)
|
||||
let bodyAttributes = typography.bodyAttributes
|
||||
let rendered = NSMutableAttributedString(string: "")
|
||||
context.coordinator.previousBodyStart = nil
|
||||
context.coordinator.previousBodyEnd = nil
|
||||
context.coordinator.continuationSectionStart = nil
|
||||
context.coordinator.continuationBodyStart = nil
|
||||
if showsPrevious {
|
||||
let previousHeading = displaysTitle ? (previousTitle ?? "") + "\n" : ""
|
||||
rendered.append(NSAttributedString(string: previousHeading, attributes: typography.headingAttributes))
|
||||
context.coordinator.previousBodyStart = rendered.length
|
||||
rendered.append(NSAttributedString(
|
||||
string: previousText.isEmpty ? String(repeating: "\n", count: 80) : previousText,
|
||||
attributes: bodyAttributes
|
||||
))
|
||||
context.coordinator.previousBodyEnd = rendered.length
|
||||
rendered.append(NSAttributedString(string: "\n\n", attributes: bodyAttributes))
|
||||
}
|
||||
context.coordinator.currentSectionStart = rendered.length
|
||||
if !titlePrefix.isEmpty {
|
||||
rendered.append(NSAttributedString(string: titlePrefix, attributes: typography.headingAttributes))
|
||||
}
|
||||
context.coordinator.renderedPrefixLength = rendered.length
|
||||
rendered.append(NSAttributedString(string: text, attributes: bodyAttributes))
|
||||
context.coordinator.currentBodyEnd = rendered.length
|
||||
if showsContinuation {
|
||||
rendered.append(NSAttributedString(string: "\n\n", attributes: bodyAttributes))
|
||||
context.coordinator.continuationSectionStart = rendered.length
|
||||
let continuationHeading = displaysTitle ? (continuationTitle ?? "") + "\n" : ""
|
||||
rendered.append(NSAttributedString(string: continuationHeading, attributes: typography.headingAttributes))
|
||||
context.coordinator.continuationBodyStart = rendered.length
|
||||
rendered.append(NSAttributedString(
|
||||
string: continuationText.isEmpty ? String(repeating: "\n", count: 80) : continuationText,
|
||||
attributes: bodyAttributes
|
||||
))
|
||||
}
|
||||
context.coordinator.isApplyingContent = true
|
||||
view.attributedText = rendered
|
||||
context.coordinator.isApplyingContent = false
|
||||
context.coordinator.scroll(
|
||||
to: primaryContentChanged ? requestedPosition : visiblePosition,
|
||||
in: view
|
||||
)
|
||||
let generation = context.coordinator.layoutGeneration
|
||||
DispatchQueue.main.async { [weak coordinator = context.coordinator] in
|
||||
guard coordinator?.layoutGeneration == generation else { return }
|
||||
coordinator?.reportPage(in: view)
|
||||
coordinator?.reportPageRanges(in: view)
|
||||
}
|
||||
} else if viewportSizeChanged {
|
||||
context.coordinator.scroll(to: readerPosition, in: view)
|
||||
let generation = context.coordinator.layoutGeneration
|
||||
DispatchQueue.main.async { [weak coordinator = context.coordinator] in
|
||||
guard coordinator?.layoutGeneration == generation else { return }
|
||||
coordinator?.reportPage(in: view)
|
||||
coordinator?.reportPageRanges(in: view)
|
||||
}
|
||||
}
|
||||
if let jumpPosition, context.coordinator.lastJumpPosition != jumpPosition {
|
||||
context.coordinator.lastJumpPosition = jumpPosition
|
||||
context.coordinator.scroll(to: jumpPosition, in: view)
|
||||
} else if jumpPosition == nil {
|
||||
context.coordinator.lastJumpPosition = nil
|
||||
}
|
||||
context.coordinator.updateLoadingFooter(in: view, visible: isLoadingNextChapter)
|
||||
context.coordinator.updateAutoRead(
|
||||
isRunning: isAutoReading,
|
||||
isPaused: isAutoReadPaused,
|
||||
secondsPerScreen: secondsPerScreen
|
||||
)
|
||||
// SwiftUI can reset the bindings after the chapter's TextKit layout has
|
||||
// already completed. Reconcile them even when the text/style is unchanged.
|
||||
if view.bounds.width > 0, view.bounds.height > 0 {
|
||||
context.coordinator.reportPage(in: view)
|
||||
if pageRanges.isEmpty { context.coordinator.reportPageRanges(in: view) }
|
||||
}
|
||||
}
|
||||
|
||||
static func dismantleUIView(_ view: UITextView, coordinator: Coordinator) {
|
||||
coordinator.invalidate()
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, UITextViewDelegate, UIGestureRecognizerDelegate {
|
||||
var parent: ReaderTextView
|
||||
var contentKey = 0
|
||||
var primaryContentKey = 0
|
||||
var styleKey = ""
|
||||
var renderedPrefixLength = 0
|
||||
var currentSectionStart = 0
|
||||
var currentBodyEnd = 0
|
||||
var previousBodyStart: Int?
|
||||
var previousBodyEnd: Int?
|
||||
var continuationSectionStart: Int?
|
||||
var continuationBodyStart: Int?
|
||||
var lastJumpPosition: Int?
|
||||
private(set) var layoutGeneration = 0
|
||||
var lastViewportSize = CGSize.zero
|
||||
private var isAutoReading = false
|
||||
private var isAutoReadPaused = false
|
||||
private var secondsPerScreen = 10.0
|
||||
private weak var textView: UITextView?
|
||||
private var displayLink: CADisplayLink?
|
||||
private var lastTimestamp: CFTimeInterval = 0
|
||||
private var isUserDragging = false
|
||||
private var lastDragOffsetY: CGFloat = 0
|
||||
private var dragDirection = 0
|
||||
private var didReachEnd = false
|
||||
private var didReachStart = false
|
||||
private var didEnterPrevious = false
|
||||
private var didEnterContinuation = false
|
||||
private var pendingPreviousPosition: Int?
|
||||
private var pendingContinuationPosition: Int?
|
||||
var needsDeferredRender = false
|
||||
var isApplyingContent = false
|
||||
private var isTurningPage = false
|
||||
private var isRenderingAutoPage = false
|
||||
private var lastReportedPage: Int?
|
||||
private var autoPageProgress: CGFloat = 0
|
||||
private var eInkElapsed: CFTimeInterval = 0
|
||||
private var eInkPageInterval: CFTimeInterval = 10
|
||||
private var autoPageTarget: CGFloat?
|
||||
private weak var autoPageOverlay: UIImageView?
|
||||
private var pageRangeRetryCount = 0
|
||||
private weak var nextSwipe: UISwipeGestureRecognizer?
|
||||
private weak var previousSwipe: UISwipeGestureRecognizer?
|
||||
private weak var readerTap: UITapGestureRecognizer?
|
||||
private weak var loadingFooter: UIView?
|
||||
private var didRequestLoadingCancellation = false
|
||||
|
||||
init(parent: ReaderTextView) { self.parent = parent }
|
||||
|
||||
func installDisplayLink(for view: UITextView) {
|
||||
textView = view
|
||||
let link = CADisplayLink(target: self, selector: #selector(step(_:)))
|
||||
link.add(to: .main, forMode: .common)
|
||||
displayLink = link
|
||||
}
|
||||
|
||||
func installGestures(on view: UITextView) {
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
|
||||
tap.delaysTouchesBegan = false
|
||||
tap.cancelsTouchesInView = false
|
||||
tap.delegate = self
|
||||
view.addGestureRecognizer(tap)
|
||||
readerTap = tap
|
||||
let next = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
|
||||
next.direction = .left
|
||||
view.addGestureRecognizer(next)
|
||||
let previous = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
|
||||
previous.direction = .right
|
||||
view.addGestureRecognizer(previous)
|
||||
nextSwipe = next
|
||||
previousSwipe = previous
|
||||
}
|
||||
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
gestureRecognizer === readerTap || otherGestureRecognizer === readerTap
|
||||
}
|
||||
|
||||
func updateInteractionMode(on view: UITextView, isScrollMode: Bool) {
|
||||
// The scroll reader owns the vertical pan gesture. Horizontal
|
||||
// swipe recognizers must be disabled there or UIKit can cancel the
|
||||
// pan before the user reaches the end of a long chapter.
|
||||
nextSwipe?.isEnabled = !isScrollMode
|
||||
previousSwipe?.isEnabled = !isScrollMode
|
||||
view.panGestureRecognizer.isEnabled = isScrollMode
|
||||
view.alwaysBounceVertical = isScrollMode
|
||||
view.isPagingEnabled = !isScrollMode
|
||||
view.showsVerticalScrollIndicator = false
|
||||
}
|
||||
|
||||
func updateLoadingFooter(in view: UITextView, visible: Bool) {
|
||||
if !visible {
|
||||
loadingFooter?.removeFromSuperview()
|
||||
loadingFooter = nil
|
||||
view.contentInset.bottom = 0
|
||||
didRequestLoadingCancellation = false
|
||||
return
|
||||
}
|
||||
let footer: UIView
|
||||
if let loadingFooter {
|
||||
footer = loadingFooter
|
||||
} else {
|
||||
let container = UIView()
|
||||
container.isUserInteractionEnabled = false
|
||||
let indicator = UIActivityIndicatorView(style: .medium)
|
||||
indicator.color = parent.foregroundColor.withAlphaComponent(0.65)
|
||||
indicator.startAnimating()
|
||||
let label = UILabel()
|
||||
label.text = "正在加载下一章…"
|
||||
label.textColor = parent.foregroundColor.withAlphaComponent(0.65)
|
||||
label.font = .systemFont(ofSize: 13)
|
||||
let stack = UIStackView(arrangedSubviews: [indicator, label])
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = 8
|
||||
stack.alignment = .center
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
||||
stack.centerYAnchor.constraint(equalTo: container.centerYAnchor)
|
||||
])
|
||||
view.addSubview(container)
|
||||
loadingFooter = container
|
||||
footer = container
|
||||
}
|
||||
view.contentInset.bottom = 64
|
||||
footer.frame = CGRect(x: 0, y: view.contentSize.height, width: view.bounds.width, height: 52)
|
||||
}
|
||||
|
||||
func shouldDeferRender(in view: UITextView) -> Bool {
|
||||
parent.pageAnimation == 3 && (isUserDragging || view.isDragging || view.isDecelerating)
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
layoutGeneration &+= 1
|
||||
resetAutoPage()
|
||||
displayLink?.invalidate()
|
||||
displayLink = nil
|
||||
}
|
||||
|
||||
func updateAutoRead(isRunning: Bool, isPaused: Bool, secondsPerScreen: Double) {
|
||||
let wasRunning = isAutoReading
|
||||
let wasPaused = isAutoReadPaused
|
||||
self.isAutoReading = isRunning
|
||||
isAutoReadPaused = isPaused
|
||||
self.secondsPerScreen = max(1, secondsPerScreen)
|
||||
if wasRunning && !isRunning {
|
||||
resetAutoPage()
|
||||
} else if !wasRunning && isRunning {
|
||||
didReachEnd = false
|
||||
lastTimestamp = 0
|
||||
eInkPageInterval = self.secondsPerScreen
|
||||
} else if isPaused || wasPaused != isPaused {
|
||||
lastTimestamp = 0
|
||||
}
|
||||
}
|
||||
|
||||
func resetAutoReadForPageChange() {
|
||||
didReachEnd = false
|
||||
didReachStart = false
|
||||
lastReportedPage = nil
|
||||
didEnterPrevious = false
|
||||
didEnterContinuation = false
|
||||
pendingPreviousPosition = nil
|
||||
pendingContinuationPosition = nil
|
||||
resetAutoPage()
|
||||
}
|
||||
|
||||
func scroll(to characterPosition: Int, in view: UITextView) {
|
||||
guard !view.text.isEmpty else { return }
|
||||
layoutGeneration &+= 1
|
||||
let generation = layoutGeneration
|
||||
let prefix = characterPosition == 0 && parent.pageAnimation == 3
|
||||
? currentSectionStart
|
||||
: renderedPrefixLength
|
||||
let location = ReaderPositionResolver.renderedLocation(
|
||||
position: characterPosition,
|
||||
prefixLength: prefix,
|
||||
renderedLength: view.text.utf16.count
|
||||
)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
view.layoutManager.ensureLayout(for: view.textContainer)
|
||||
let glyph = view.layoutManager.glyphIndexForCharacter(at: location)
|
||||
var point = view.layoutManager.boundingRect(
|
||||
forGlyphRange: NSRange(location: glyph, length: 1),
|
||||
in: view.textContainer
|
||||
).origin
|
||||
point.y += view.textContainerInset.top
|
||||
let limits = self.scrollLimits(for: view)
|
||||
let target = min(
|
||||
max(point.y - view.adjustedContentInset.top, limits.minimum),
|
||||
limits.maximum
|
||||
)
|
||||
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func readingViewportHeight(for view: UITextView) -> CGFloat {
|
||||
max(
|
||||
view.bounds.height
|
||||
- view.adjustedContentInset.top
|
||||
- view.adjustedContentInset.bottom
|
||||
- view.textContainerInset.top
|
||||
- view.textContainerInset.bottom,
|
||||
1
|
||||
)
|
||||
}
|
||||
|
||||
private func scrollLimits(for view: UITextView) -> (minimum: CGFloat, maximum: CGFloat) {
|
||||
let minimum = -view.adjustedContentInset.top
|
||||
let maximum = max(
|
||||
view.contentSize.height - view.bounds.height + view.adjustedContentInset.bottom,
|
||||
minimum
|
||||
)
|
||||
return (minimum, maximum)
|
||||
}
|
||||
|
||||
private func pageMetrics(for view: UITextView) -> (current: Int, total: Int) {
|
||||
let pageHeight = readingViewportHeight(for: view)
|
||||
if parent.pageAnimation == 3, currentBodyEnd > currentSectionStart {
|
||||
view.layoutManager.ensureLayout(for: view.textContainer)
|
||||
let glyphCount = view.layoutManager.numberOfGlyphs
|
||||
if glyphCount > 0 {
|
||||
let first = min(view.layoutManager.glyphIndexForCharacter(at: currentSectionStart), glyphCount - 1)
|
||||
let last = min(view.layoutManager.glyphIndexForCharacter(at: currentBodyEnd - 1), glyphCount - 1)
|
||||
let start = view.layoutManager.boundingRect(
|
||||
forGlyphRange: NSRange(location: first, length: 1), in: view.textContainer
|
||||
).minY + view.textContainerInset.top - view.adjustedContentInset.top
|
||||
let end = view.layoutManager.boundingRect(
|
||||
forGlyphRange: NSRange(location: last, length: 1), in: view.textContainer
|
||||
).maxY + view.textContainerInset.top - view.adjustedContentInset.top
|
||||
let total = max(Int(ceil((end - start) / pageHeight)), 1)
|
||||
let position = max(view.contentOffset.y - start, 0)
|
||||
return (min(Int(floor(position / pageHeight)) + 1, total), total)
|
||||
}
|
||||
}
|
||||
let limits = scrollLimits(for: view)
|
||||
let scrollRange = max(limits.maximum - limits.minimum, 0)
|
||||
let total = max(Int(ceil(scrollRange / pageHeight)) + 1, 1)
|
||||
guard total > 1 else { return (1, 1) }
|
||||
let position = min(max(view.contentOffset.y - limits.minimum, 0), scrollRange)
|
||||
let current = position >= scrollRange - 0.5
|
||||
? total
|
||||
: min(Int(floor(position / pageHeight)) + 1, total)
|
||||
return (current, total)
|
||||
}
|
||||
|
||||
private func renderedCharacter(at offset: CGPoint, in view: UITextView) -> Int {
|
||||
guard !view.text.isEmpty else { return 0 }
|
||||
let point = CGPoint(
|
||||
x: view.textContainerInset.left + 1,
|
||||
y: offset.y + view.textContainerInset.top + 1
|
||||
)
|
||||
let glyphCount = view.layoutManager.numberOfGlyphs
|
||||
guard glyphCount > 0 else { return 0 }
|
||||
let glyph = min(view.layoutManager.glyphIndex(for: point, in: view.textContainer), glyphCount - 1)
|
||||
let character = view.layoutManager.characterIndexForGlyph(at: glyph)
|
||||
return min(max(character, 0), view.text.utf16.count)
|
||||
}
|
||||
|
||||
func contentCharacter(at offset: CGPoint, in view: UITextView) -> Int {
|
||||
let character = renderedCharacter(at: offset, in: view)
|
||||
return min(max(character - renderedPrefixLength, 0), parent.text.utf16.count)
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard !isRenderingAutoPage, !isApplyingContent else { return }
|
||||
guard let view = scrollView as? UITextView, !view.text.isEmpty else { return }
|
||||
if parent.isLoadingNextChapter, !didRequestLoadingCancellation {
|
||||
let textBottom = max(
|
||||
view.contentSize.height - view.bounds.height
|
||||
+ view.adjustedContentInset.bottom - view.contentInset.bottom,
|
||||
-view.adjustedContentInset.top
|
||||
)
|
||||
if view.contentOffset.y < textBottom - 44 {
|
||||
didRequestLoadingCancellation = true
|
||||
DispatchQueue.main.async { [weak self] in self?.parent.onCancelNextLoading() }
|
||||
}
|
||||
}
|
||||
if isUserDragging {
|
||||
let delta = scrollView.contentOffset.y - lastDragOffsetY
|
||||
if abs(delta) > 0.5 { dragDirection = delta > 0 ? 1 : -1 }
|
||||
lastDragOffsetY = scrollView.contentOffset.y
|
||||
}
|
||||
let metrics = pageMetrics(for: view)
|
||||
reportPage(in: view)
|
||||
if let lastReportedPage, lastReportedPage != metrics.current {
|
||||
self.lastReportedPage = metrics.current
|
||||
let generation = layoutGeneration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.parent.onPageChanged()
|
||||
}
|
||||
} else if lastReportedPage == nil {
|
||||
lastReportedPage = metrics.current
|
||||
}
|
||||
let contentCharacter = contentCharacter(at: scrollView.contentOffset, in: view)
|
||||
let renderedCharacter = renderedCharacter(at: scrollView.contentOffset, in: view)
|
||||
if let previousBodyStart,
|
||||
let previousBodyEnd,
|
||||
renderedCharacter < currentSectionStart {
|
||||
pendingContinuationPosition = nil
|
||||
let position = min(max(renderedCharacter - previousBodyStart, 0), max(previousBodyEnd - previousBodyStart - 1, 0))
|
||||
if isUserDragging || view.isDecelerating {
|
||||
pendingPreviousPosition = position
|
||||
return
|
||||
}
|
||||
guard !didEnterPrevious else { return }
|
||||
didEnterPrevious = true
|
||||
let generation = layoutGeneration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.parent.onEnteredPrevious(position)
|
||||
}
|
||||
return
|
||||
}
|
||||
if let continuationSectionStart,
|
||||
let continuationBodyStart,
|
||||
renderedCharacter >= continuationSectionStart {
|
||||
pendingPreviousPosition = nil
|
||||
let position = max(renderedCharacter - continuationBodyStart, 0)
|
||||
if isUserDragging || view.isDecelerating {
|
||||
pendingContinuationPosition = position
|
||||
return
|
||||
}
|
||||
guard !didEnterContinuation else { return }
|
||||
didEnterContinuation = true
|
||||
let generation = layoutGeneration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.parent.onEnteredContinuation(position)
|
||||
}
|
||||
return
|
||||
}
|
||||
pendingPreviousPosition = nil
|
||||
pendingContinuationPosition = nil
|
||||
guard contentCharacter != parent.readerPosition else { return }
|
||||
let generation = layoutGeneration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.parent.readerPosition = contentCharacter
|
||||
}
|
||||
}
|
||||
|
||||
func reportPage(in view: UITextView) {
|
||||
guard view.bounds.width > 0, view.bounds.height > 0 else { return }
|
||||
let metrics = pageMetrics(for: view)
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("-uiPagingDiagnostics") {
|
||||
view.accessibilityLabel = "bounds=\(view.bounds.size) content=\(view.contentSize) used=\(view.layoutManager.usedRect(for: view.textContainer)) mode=\(parent.pageAnimation) pages=\(metrics.total)"
|
||||
}
|
||||
#endif
|
||||
guard metrics.current != parent.currentPage || metrics.total != parent.totalPages else { return }
|
||||
let generation = layoutGeneration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.parent.currentPage = metrics.current
|
||||
self.parent.totalPages = metrics.total
|
||||
}
|
||||
}
|
||||
|
||||
func reportPageRanges(in view: UITextView) {
|
||||
guard view.bounds.width > 0, view.bounds.height > 0 else { return }
|
||||
if parent.pageAnimation == 3 {
|
||||
let ranges = parent.text.isEmpty
|
||||
? []
|
||||
: [NSRange(location: 0, length: parent.text.utf16.count)]
|
||||
guard ranges != parent.pageRanges else { return }
|
||||
DispatchQueue.main.async { [weak self] in self?.parent.pageRanges = ranges }
|
||||
return
|
||||
}
|
||||
guard !view.text.isEmpty else { return }
|
||||
view.layoutManager.ensureLayout(for: view.textContainer)
|
||||
let pageHeight = readingViewportHeight(for: view)
|
||||
let limits = scrollLimits(for: view)
|
||||
let metrics = pageMetrics(for: view)
|
||||
var ranges: [NSRange] = []
|
||||
for page in 0..<metrics.total {
|
||||
let offset = page == metrics.total - 1
|
||||
? limits.maximum
|
||||
: min(limits.minimum + CGFloat(page) * pageHeight, limits.maximum)
|
||||
let rect = CGRect(
|
||||
x: 0,
|
||||
y: max(0, offset + view.textContainerInset.top),
|
||||
width: max(view.textContainer.size.width, 1),
|
||||
height: pageHeight
|
||||
)
|
||||
let glyphRange = view.layoutManager.glyphRange(
|
||||
forBoundingRect: rect,
|
||||
in: view.textContainer
|
||||
)
|
||||
guard glyphRange.length > 0 else { continue }
|
||||
let characterRange = view.layoutManager.characterRange(
|
||||
forGlyphRange: glyphRange,
|
||||
actualGlyphRange: nil
|
||||
)
|
||||
let start = max(characterRange.location - renderedPrefixLength, 0)
|
||||
let end = min(
|
||||
characterRange.location + characterRange.length - renderedPrefixLength,
|
||||
parent.text.utf16.count
|
||||
)
|
||||
guard end > start else { continue }
|
||||
let range = NSRange(location: start, length: end - start)
|
||||
if ranges.last?.location == range.location,
|
||||
ranges.last?.length == range.length { continue }
|
||||
ranges.append(range)
|
||||
}
|
||||
if ranges.isEmpty {
|
||||
guard pageRangeRetryCount < 10 else { return }
|
||||
pageRangeRetryCount += 1
|
||||
let generation = layoutGeneration
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.reportPageRanges(in: view)
|
||||
}
|
||||
return
|
||||
}
|
||||
pageRangeRetryCount = 0
|
||||
guard ranges != parent.pageRanges else { return }
|
||||
let generation = layoutGeneration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.parent.pageRanges = ranges
|
||||
}
|
||||
}
|
||||
|
||||
func resetPageRangeRetry() {
|
||||
pageRangeRetryCount = 0
|
||||
}
|
||||
|
||||
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||
isUserDragging = true
|
||||
lastDragOffsetY = scrollView.contentOffset.y
|
||||
dragDirection = 0
|
||||
didReachEnd = false
|
||||
didReachStart = false
|
||||
}
|
||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
if parent.pageAnimation == 3, let view = scrollView as? UITextView {
|
||||
triggerChapterBoundaryIfNeeded(in: view)
|
||||
}
|
||||
if !decelerate {
|
||||
isUserDragging = false
|
||||
flushPendingChapterTransition()
|
||||
requestDeferredRenderIfNeeded()
|
||||
}
|
||||
}
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
if parent.pageAnimation == 3, let view = scrollView as? UITextView {
|
||||
triggerChapterBoundaryIfNeeded(in: view)
|
||||
}
|
||||
isUserDragging = false
|
||||
flushPendingChapterTransition()
|
||||
requestDeferredRenderIfNeeded()
|
||||
}
|
||||
|
||||
private func requestDeferredRenderIfNeeded() {
|
||||
guard needsDeferredRender else { return }
|
||||
needsDeferredRender = false
|
||||
DispatchQueue.main.async { [weak self] in self?.parent.onDeferredRender() }
|
||||
}
|
||||
|
||||
private func flushPendingChapterTransition() {
|
||||
let previous = pendingPreviousPosition
|
||||
let continuation = pendingContinuationPosition
|
||||
pendingPreviousPosition = nil
|
||||
pendingContinuationPosition = nil
|
||||
let generation = layoutGeneration
|
||||
if let previous, !didEnterPrevious {
|
||||
didEnterPrevious = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.parent.onEnteredPrevious(previous)
|
||||
}
|
||||
} else if let continuation, !didEnterContinuation {
|
||||
didEnterContinuation = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.parent.onEnteredContinuation(continuation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerChapterBoundaryIfNeeded(in view: UITextView) {
|
||||
let (minimum, maximum) = scrollLimits(for: view)
|
||||
let atEnd = view.contentOffset.y >= maximum - 0.5
|
||||
let atStart = view.contentOffset.y <= minimum + 0.5
|
||||
if atEnd, dragDirection > 0, continuationSectionStart == nil, !didReachEnd {
|
||||
didReachEnd = true
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
|
||||
} else if atStart, dragDirection < 0, previousBodyStart == nil, !didReachStart {
|
||||
didReachStart = true
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
parent.onReachedStart(contentCharacter(at: view.contentOffset, in: view))
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func tapped(_ gesture: UITapGestureRecognizer) {
|
||||
guard let view = textView, gesture.state == .ended else { return }
|
||||
let point = gesture.location(in: view)
|
||||
// UIScrollView changes its bounds origin as content scrolls, and
|
||||
// gesture locations use that content coordinate space. Convert
|
||||
// back to viewport coordinates before dividing the visible screen
|
||||
// into the 3×3 tap grid.
|
||||
let viewportX = point.x - view.bounds.minX
|
||||
let viewportY = point.y - view.bounds.minY
|
||||
let column = min(max(Int(viewportX / max(view.bounds.width / 3, 1)), 0), 2)
|
||||
let row = min(max(Int(viewportY / max(view.bounds.height / 3, 1)), 0), 2)
|
||||
let index = row * 3 + column
|
||||
let action = index == 4
|
||||
? 0
|
||||
: (parent.clickActions.indices.contains(index) ? parent.clickActions[index] : 0)
|
||||
|
||||
if parent.pageAnimation == 3, action == 1 || action == 2 {
|
||||
return
|
||||
}
|
||||
switch action {
|
||||
case 1: turnPage(1)
|
||||
case 2: turnPage(-1)
|
||||
case -1: break
|
||||
default: parent.onTapAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func swiped(_ gesture: UISwipeGestureRecognizer) {
|
||||
turnPage(gesture.direction == .left ? 1 : -1)
|
||||
}
|
||||
|
||||
private func turnPage(_ direction: Int) {
|
||||
guard !isTurningPage, let view = textView else { return }
|
||||
if isAutoReading { resetAutoReadForPageChange() }
|
||||
let pageHeight = readingViewportHeight(for: view)
|
||||
let (minimum, maximum) = scrollLimits(for: view)
|
||||
let target = min(max(view.contentOffset.y + CGFloat(direction) * pageHeight, minimum), maximum)
|
||||
if direction < 0, view.contentOffset.y <= minimum + 0.5 {
|
||||
parent.onReachedStart(contentCharacter(at: view.contentOffset, in: view))
|
||||
return
|
||||
}
|
||||
if direction > 0, view.contentOffset.y >= maximum - 0.5 {
|
||||
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
|
||||
return
|
||||
}
|
||||
isTurningPage = true
|
||||
let completion: (Bool) -> Void = { [weak self] _ in self?.isTurningPage = false }
|
||||
switch parent.isEInkMode ? 4 : parent.pageAnimation {
|
||||
case 4:
|
||||
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||||
isTurningPage = false
|
||||
case 0:
|
||||
let transition = CATransition()
|
||||
transition.duration = 0.3
|
||||
transition.type = direction > 0 ? .reveal : .moveIn
|
||||
transition.subtype = direction > 0 ? .fromRight : .fromLeft
|
||||
view.layer.add(transition, forKey: "reader-cover")
|
||||
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.31) { [weak self] in self?.isTurningPage = false }
|
||||
case 1:
|
||||
let transition = CATransition()
|
||||
transition.duration = 0.3
|
||||
transition.type = .push
|
||||
transition.subtype = direction > 0 ? .fromRight : .fromLeft
|
||||
view.layer.add(transition, forKey: "reader-slide")
|
||||
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.31) { [weak self] in self?.isTurningPage = false }
|
||||
case 2:
|
||||
UIView.transition(
|
||||
with: view,
|
||||
duration: 0.38,
|
||||
options: direction > 0 ? .transitionCurlUp : .transitionCurlDown,
|
||||
animations: { view.setContentOffset(CGPoint(x: 0, y: target), animated: false) },
|
||||
completion: completion
|
||||
)
|
||||
default:
|
||||
view.setContentOffset(CGPoint(x: 0, y: target), animated: true)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) { [weak self] in self?.isTurningPage = false }
|
||||
}
|
||||
}
|
||||
|
||||
private func resetAutoPage() {
|
||||
autoPageProgress = 0
|
||||
eInkElapsed = 0
|
||||
eInkPageInterval = secondsPerScreen
|
||||
autoPageTarget = nil
|
||||
autoPageOverlay?.removeFromSuperview()
|
||||
autoPageOverlay = nil
|
||||
lastTimestamp = 0
|
||||
}
|
||||
|
||||
private func prepareAutoPage(in view: UITextView) -> Bool {
|
||||
let pageHeight = readingViewportHeight(for: view)
|
||||
let maximum = scrollLimits(for: view).maximum
|
||||
guard view.contentOffset.y < maximum - 0.5 else { return false }
|
||||
let target = min(view.contentOffset.y + pageHeight, maximum)
|
||||
let originalOffset = view.contentOffset
|
||||
isRenderingAutoPage = true
|
||||
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||||
view.layoutIfNeeded()
|
||||
let renderer = UIGraphicsImageRenderer(bounds: view.bounds)
|
||||
let image = renderer.image { context in view.layer.render(in: context.cgContext) }
|
||||
view.setContentOffset(originalOffset, animated: false)
|
||||
isRenderingAutoPage = false
|
||||
|
||||
let overlay = UIImageView(image: image)
|
||||
overlay.isUserInteractionEnabled = false
|
||||
overlay.contentMode = .top
|
||||
overlay.clipsToBounds = true
|
||||
overlay.layer.borderColor = view.tintColor.cgColor
|
||||
overlay.layer.borderWidth = 0
|
||||
overlay.frame = CGRect(x: 0, y: originalOffset.y, width: view.bounds.width, height: 0)
|
||||
view.addSubview(overlay)
|
||||
autoPageOverlay = overlay
|
||||
autoPageTarget = target
|
||||
return true
|
||||
}
|
||||
|
||||
private func advancePagedAutoRead(in view: UITextView, elapsed: CFTimeInterval) {
|
||||
if autoPageOverlay == nil, !prepareAutoPage(in: view) {
|
||||
guard !didReachEnd else { return }
|
||||
didReachEnd = true
|
||||
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
|
||||
return
|
||||
}
|
||||
didReachEnd = false
|
||||
let pageHeight = readingViewportHeight(for: view)
|
||||
autoPageProgress += pageHeight / CGFloat(secondsPerScreen) * CGFloat(elapsed)
|
||||
let revealedHeight = min(autoPageProgress, pageHeight)
|
||||
if let overlay = autoPageOverlay {
|
||||
overlay.frame = CGRect(
|
||||
x: 0,
|
||||
y: view.contentOffset.y,
|
||||
width: view.bounds.width,
|
||||
height: revealedHeight
|
||||
)
|
||||
overlay.layer.borderWidth = revealedHeight > 0 ? 0.5 : 0
|
||||
}
|
||||
guard autoPageProgress >= pageHeight, let target = autoPageTarget else { return }
|
||||
autoPageOverlay?.removeFromSuperview()
|
||||
autoPageOverlay = nil
|
||||
autoPageTarget = nil
|
||||
autoPageProgress = 0
|
||||
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||||
}
|
||||
|
||||
private func advanceEInkAutoRead(in view: UITextView, elapsed: CFTimeInterval) {
|
||||
eInkElapsed += elapsed
|
||||
guard eInkElapsed >= eInkPageInterval else { return }
|
||||
eInkElapsed = 0
|
||||
eInkPageInterval = secondsPerScreen
|
||||
let pageHeight = readingViewportHeight(for: view)
|
||||
let maximum = scrollLimits(for: view).maximum
|
||||
guard view.contentOffset.y < maximum - 0.5 else {
|
||||
guard !didReachEnd else { return }
|
||||
didReachEnd = true
|
||||
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
|
||||
return
|
||||
}
|
||||
didReachEnd = false
|
||||
let target = min(view.contentOffset.y + pageHeight, maximum)
|
||||
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||||
}
|
||||
|
||||
@objc private func step(_ link: CADisplayLink) {
|
||||
guard isAutoReading, !isAutoReadPaused, !isUserDragging, let view = textView else {
|
||||
lastTimestamp = link.timestamp
|
||||
return
|
||||
}
|
||||
guard lastTimestamp > 0 else {
|
||||
lastTimestamp = link.timestamp
|
||||
return
|
||||
}
|
||||
let elapsed = link.timestamp - lastTimestamp
|
||||
lastTimestamp = link.timestamp
|
||||
if parent.isEInkMode {
|
||||
advanceEInkAutoRead(in: view, elapsed: elapsed)
|
||||
return
|
||||
}
|
||||
if parent.pageAnimation != 3 {
|
||||
advancePagedAutoRead(in: view, elapsed: elapsed)
|
||||
return
|
||||
}
|
||||
let maximum = scrollLimits(for: view).maximum
|
||||
let next = view.contentOffset.y
|
||||
+ readingViewportHeight(for: view) / CGFloat(secondsPerScreen) * CGFloat(elapsed)
|
||||
if next >= maximum - 0.5 {
|
||||
view.setContentOffset(CGPoint(x: 0, y: maximum), animated: false)
|
||||
guard !didReachEnd else { return }
|
||||
didReachEnd = true
|
||||
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
|
||||
} else {
|
||||
didReachEnd = false
|
||||
view.setContentOffset(CGPoint(x: 0, y: next), animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,611 @@
|
||||
import SwiftUI
|
||||
#if SWIFT_PACKAGE
|
||||
import LegadoCore
|
||||
#endif
|
||||
|
||||
struct ReaderParagraph: Identifiable, Equatable {
|
||||
let id: Int
|
||||
let text: String
|
||||
}
|
||||
|
||||
struct ReaderScrollChapter: Identifiable, Equatable {
|
||||
let id: Int
|
||||
let title: String
|
||||
let content: String
|
||||
var error: String? = nil
|
||||
}
|
||||
|
||||
enum ReaderPositionResolver {
|
||||
static func renderedLocation(position: Int, prefixLength: Int, renderedLength: Int) -> Int {
|
||||
let lastLocation = max(renderedLength, 1) - 1
|
||||
let prefix = min(max(prefixLength, 0), lastLocation)
|
||||
return prefix + min(max(position, 0), lastLocation - prefix)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ReaderViewModel: ObservableObject {
|
||||
@Published private(set) var isLoading = false
|
||||
@Published private(set) var title: String
|
||||
@Published private(set) var content = ""
|
||||
@Published private(set) var diagnostics: [RuleDiagnostic] = []
|
||||
@Published private(set) var currentIndex: Int
|
||||
@Published private(set) var chapterLoadGeneration = 0
|
||||
@Published private(set) var paragraphs: [ReaderParagraph] = []
|
||||
@Published private(set) var requestedPosition: Int
|
||||
@Published private(set) var loadedFromCache = false
|
||||
@Published private(set) var hasLoadedCurrentChapter = false
|
||||
@Published private(set) var previousTitle = ""
|
||||
@Published private(set) var previousContent = ""
|
||||
@Published private(set) var continuationTitle = ""
|
||||
@Published private(set) var continuationContent = ""
|
||||
@Published private(set) var isPreviousWindowReady = false
|
||||
@Published private(set) var isContinuationWindowReady = false
|
||||
@Published private(set) var scrollChapters: [ReaderScrollChapter] = []
|
||||
@Published private(set) var scrollRevision = 0
|
||||
@Published private(set) var isLoadingScrollAhead = false
|
||||
@Published private(set) var isLoadingScrollBehind = false
|
||||
|
||||
@Published private(set) var book: Book
|
||||
private(set) var chapters: [BookChapter]
|
||||
private var source: BookSource?
|
||||
private let localService: LocalBookService
|
||||
private let sanitizer = ContentSanitizer()
|
||||
private let contentCoordinator: ChapterContentCoordinator
|
||||
private var demoChapterContent: String?
|
||||
private var previousLoadedIndex: Int?
|
||||
private var continuationLoadedIndex: Int?
|
||||
@Published private(set) var scrollRetries: Set<Int> = []
|
||||
private var sourceCacheNeedsInvalidation = false
|
||||
private var pendingSourceContent: ChapterContentReport?
|
||||
|
||||
func prepareContinuousReading(rules: [ReplaceRule]) async {
|
||||
guard hasLoadedCurrentChapter else { return }
|
||||
if scrollChapters.isEmpty {
|
||||
scrollChapters = [scrollChapterForCurrentContent()]
|
||||
}
|
||||
async let ahead: Void = extendContinuousReading(direction: 1, rules: rules)
|
||||
async let behind: Void = extendContinuousReading(direction: -1, rules: rules)
|
||||
_ = await (ahead, behind)
|
||||
}
|
||||
|
||||
func extendContinuousReading(direction: Int, rules: [ReplaceRule]) async {
|
||||
let initialEdge = direction > 0 ? scrollChapters.last?.id : scrollChapters.first?.id
|
||||
guard let initialEdge, readableIndex(from: initialEdge, direction: direction) != nil else { return }
|
||||
if direction > 0 {
|
||||
guard !isLoadingScrollAhead else { return }
|
||||
isLoadingScrollAhead = true
|
||||
} else {
|
||||
guard !isLoadingScrollBehind else { return }
|
||||
isLoadingScrollBehind = true
|
||||
}
|
||||
let revision = scrollRevision
|
||||
defer {
|
||||
if revision == scrollRevision {
|
||||
if direction > 0 { isLoadingScrollAhead = false }
|
||||
else { isLoadingScrollBehind = false }
|
||||
}
|
||||
}
|
||||
for _ in 0..<2 {
|
||||
let edge = direction > 0 ? scrollChapters.last?.id : scrollChapters.first?.id
|
||||
guard let edge, let index = readableIndex(from: edge, direction: direction) else { return }
|
||||
let value = await loadAdjacentChapter(chapters[index], rules: rules)
|
||||
guard revision == scrollRevision, !Task.isCancelled else { return }
|
||||
// The viewport may have recycled this edge while the request was
|
||||
// in flight. Installing its result now would create a chapter gap.
|
||||
guard scrollChapters.contains(where: { $0.id == edge }) else { return }
|
||||
let chapter = ReaderScrollChapter(
|
||||
id: index, title: value.title, content: value.content, error: value.error
|
||||
)
|
||||
if !scrollChapters.contains(where: { $0.id == index }) {
|
||||
scrollChapters.append(chapter)
|
||||
scrollChapters.sort { $0.id < $1.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func retryContinuousChapter(index: Int, rules: [ReplaceRule]) async {
|
||||
guard chapters.indices.contains(index), !scrollRetries.contains(index) else { return }
|
||||
let revision = scrollRevision
|
||||
scrollRetries.insert(index)
|
||||
defer { if revision == scrollRevision { scrollRetries.remove(index) } }
|
||||
let value = await loadAdjacentChapter(chapters[index], rules: rules, forceRefresh: true)
|
||||
guard revision == scrollRevision, !Task.isCancelled,
|
||||
let slot = scrollChapters.firstIndex(where: { $0.id == index }) else { return }
|
||||
scrollChapters[slot] = .init(id: index, title: value.title, content: value.content, error: value.error)
|
||||
if currentIndex == index { setContinuousChapter(scrollChapters[slot]) }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateContinuousPosition(chapter index: Int) -> Bool {
|
||||
guard index != currentIndex,
|
||||
let chapter = scrollChapters.first(where: { $0.id == index }) else { return false }
|
||||
setContinuousChapter(chapter)
|
||||
return true
|
||||
}
|
||||
|
||||
func retainContinuousChapters(_ indices: Set<Int>) {
|
||||
guard indices.contains(currentIndex) else { return }
|
||||
scrollChapters.removeAll { !indices.contains($0.id) }
|
||||
}
|
||||
|
||||
private func setContinuousChapter(_ chapter: ReaderScrollChapter) {
|
||||
currentIndex = chapter.id
|
||||
title = chapter.title
|
||||
content = chapter.content
|
||||
paragraphs = Self.makeParagraphs(chapter.content)
|
||||
diagnostics = []
|
||||
hasLoadedCurrentChapter = true
|
||||
}
|
||||
|
||||
private func scrollChapterForCurrentContent() -> ReaderScrollChapter {
|
||||
.init(id: currentIndex, title: title, content: content,
|
||||
error: currentChapterError)
|
||||
}
|
||||
|
||||
private func clearContinuousReading() {
|
||||
scrollRevision &+= 1
|
||||
scrollChapters = []
|
||||
isLoadingScrollAhead = false
|
||||
isLoadingScrollBehind = false
|
||||
scrollRetries = []
|
||||
}
|
||||
|
||||
init(
|
||||
book: Book,
|
||||
chapters: [BookChapter],
|
||||
initialIndex: Int,
|
||||
source: BookSource,
|
||||
service: any ChapterContentLoading = NetworkBookService(),
|
||||
cache: ChapterContentCache = .live()
|
||||
) {
|
||||
let resolvedIndex = Self.validIndex(initialIndex, chapters: chapters)
|
||||
self.book = book
|
||||
self.chapters = chapters
|
||||
currentIndex = resolvedIndex
|
||||
self.source = source
|
||||
localService = .init()
|
||||
contentCoordinator = .init(loader: service, cache: cache)
|
||||
requestedPosition = resolvedIndex == book.durChapterIndex ? max(book.durChapterPos, 0) : 0
|
||||
title = chapters.indices.contains(resolvedIndex) ? chapters[resolvedIndex].title : book.name
|
||||
}
|
||||
|
||||
convenience init(demoBook book: Book, chapters: [BookChapter], initialIndex: Int, content: String) {
|
||||
self.init(book: book, chapters: chapters, initialIndex: initialIndex)
|
||||
self.content = content
|
||||
demoChapterContent = content
|
||||
paragraphs = Self.makeParagraphs(content)
|
||||
hasLoadedCurrentChapter = true
|
||||
}
|
||||
|
||||
init(
|
||||
book: Book,
|
||||
chapters: [BookChapter],
|
||||
initialIndex: Int,
|
||||
localService: LocalBookService = .init(),
|
||||
cache: ChapterContentCache = .live()
|
||||
) {
|
||||
let resolvedIndex = Self.validIndex(initialIndex, chapters: chapters)
|
||||
self.book = book
|
||||
self.chapters = chapters
|
||||
currentIndex = resolvedIndex
|
||||
source = nil
|
||||
self.localService = localService
|
||||
contentCoordinator = .init(cache: cache)
|
||||
requestedPosition = resolvedIndex == book.durChapterIndex ? max(book.durChapterPos, 0) : 0
|
||||
title = chapters.indices.contains(resolvedIndex) ? chapters[resolvedIndex].title : book.name
|
||||
}
|
||||
|
||||
var currentChapter: BookChapter? { chapters.indices.contains(currentIndex) ? chapters[currentIndex] : nil }
|
||||
var nextChapter: BookChapter? { nextReadableChapter(after: currentIndex) }
|
||||
var canGoPrevious: Bool { navigationIndex(direction: -1) != nil }
|
||||
var canGoNext: Bool { navigationIndex(direction: 1) != nil }
|
||||
var hasPreviousWindow: Bool { previousLoadedIndex != nil }
|
||||
var hasContinuationWindow: Bool { continuationLoadedIndex != nil }
|
||||
var canEditContent: Bool { source != nil }
|
||||
var usesReplaceRules: Bool { book.readConfig?.useReplaceRule ?? true }
|
||||
var currentChapterError: String? {
|
||||
guard hasLoadedCurrentChapter, !isLoading,
|
||||
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
currentChapter?.isVolume != true else { return nil }
|
||||
return scrollChapters.first(where: { $0.id == currentIndex })?.error
|
||||
?? diagnostics.first(where: { $0.severity == .error })?.message
|
||||
?? (source != nil ? "当前书源没有返回正文。" : nil)
|
||||
}
|
||||
|
||||
func bookForSourceSwitch(position: Int) -> Book {
|
||||
var current = book
|
||||
current.durChapterIndex = currentIndex
|
||||
current.durChapterTitle = currentChapter?.title
|
||||
current.durChapterPos = max(position, 0)
|
||||
current.totalChapterNum = chapters.count
|
||||
return current
|
||||
}
|
||||
|
||||
func applySourceSwitch(_ report: BookSourceSwitchReport, source: BookSource) {
|
||||
book = report.book
|
||||
chapters = report.chapters
|
||||
self.source = source
|
||||
demoChapterContent = nil
|
||||
sourceCacheNeedsInvalidation = true
|
||||
select(index: Self.validIndex(report.book.durChapterIndex, chapters: chapters), position: report.book.durChapterPos)
|
||||
pendingSourceContent = report.verifiedContent
|
||||
}
|
||||
|
||||
func load(rules: [ReplaceRule], forceRefresh: Bool = false, prefetchNextChapter: Bool = true) async {
|
||||
guard (content.isEmpty || forceRefresh), !isLoading else { return }
|
||||
guard let chapter = currentChapter else { return }
|
||||
let requestedIndex = currentIndex
|
||||
let requestedGeneration = chapterLoadGeneration
|
||||
if forceRefresh {
|
||||
pendingSourceContent = nil
|
||||
clearContinuousReading()
|
||||
content = ""
|
||||
paragraphs = []
|
||||
diagnostics = []
|
||||
hasLoadedCurrentChapter = false
|
||||
}
|
||||
isLoading = true
|
||||
defer {
|
||||
if requestedGeneration == chapterLoadGeneration { isLoading = false }
|
||||
}
|
||||
let report: ChapterContentReport
|
||||
let fromCache: Bool
|
||||
if sourceCacheNeedsInvalidation {
|
||||
await contentCoordinator.invalidate(bookURL: book.bookUrl)
|
||||
guard requestedGeneration == chapterLoadGeneration, !Task.isCancelled else { return }
|
||||
sourceCacheNeedsInvalidation = false
|
||||
}
|
||||
if let demoChapterContent {
|
||||
fromCache = true
|
||||
report = .init(title: chapter.title, content: demoChapterContent)
|
||||
} else if let verified = pendingSourceContent, let source, !forceRefresh {
|
||||
pendingSourceContent = nil
|
||||
fromCache = false
|
||||
report = verified
|
||||
await contentCoordinator.saveContent(verified, book: book, chapter: chapter, source: source)
|
||||
} else if let source {
|
||||
let nextURL = nextReadableChapter(after: requestedIndex)?.url
|
||||
let result = await contentCoordinator.load(
|
||||
book: book,
|
||||
chapter: chapter,
|
||||
source: source,
|
||||
nextChapterURL: nextURL,
|
||||
forceRefresh: forceRefresh
|
||||
)
|
||||
fromCache = result.loadedFromCache
|
||||
report = result.report
|
||||
} else {
|
||||
fromCache = false
|
||||
do {
|
||||
report = try await Task.detached { [localService, book, chapter] in
|
||||
try localService.loadContent(book: book, chapter: chapter)
|
||||
}.value
|
||||
} catch {
|
||||
report = .init(diagnostics: [
|
||||
.init(severity: .error, code: "local.read", message: error.localizedDescription, rule: book.bookUrl)
|
||||
])
|
||||
}
|
||||
}
|
||||
guard requestedGeneration == chapterLoadGeneration, !Task.isCancelled else { return }
|
||||
loadedFromCache = fromCache
|
||||
if prefetchNextChapter, !fromCache, let source { schedulePreload(after: requestedIndex, source: source) }
|
||||
hasLoadedCurrentChapter = true
|
||||
var effectiveChapter = chapter
|
||||
if let resolvedTitle = report.title { effectiveChapter.title = resolvedTitle }
|
||||
let processed = sanitizer.process(
|
||||
book: book,
|
||||
chapter: effectiveChapter,
|
||||
content: report.content,
|
||||
rules: rules
|
||||
)
|
||||
title = processed.title
|
||||
content = processed.content
|
||||
paragraphs = Self.makeParagraphs(processed.content)
|
||||
diagnostics = report.diagnostics + processed.diagnostics
|
||||
}
|
||||
|
||||
func prepareAdjacentWindows() {
|
||||
let previousIndex = navigationIndex(direction: -1)
|
||||
if previousLoadedIndex != previousIndex {
|
||||
previousLoadedIndex = previousIndex
|
||||
previousTitle = previousIndex.map { chapters[$0].title } ?? ""
|
||||
previousContent = ""
|
||||
isPreviousWindowReady = false
|
||||
}
|
||||
let nextIndex = navigationIndex(direction: 1)
|
||||
if continuationLoadedIndex != nextIndex {
|
||||
continuationLoadedIndex = nextIndex
|
||||
continuationTitle = nextIndex.map { chapters[$0].title } ?? ""
|
||||
continuationContent = ""
|
||||
isContinuationWindowReady = false
|
||||
}
|
||||
}
|
||||
|
||||
func loadAdjacentChapters(rules: [ReplaceRule]) async {
|
||||
prepareAdjacentWindows()
|
||||
let requestedIndex = currentIndex
|
||||
let requestedGeneration = chapterLoadGeneration
|
||||
let previousIndex = previousLoadedIndex
|
||||
let nextIndex = continuationLoadedIndex
|
||||
let needsPrevious = previousIndex != nil && !isPreviousWindowReady
|
||||
let needsNext = nextIndex != nil && !isContinuationWindowReady
|
||||
|
||||
async let previousValue: (title: String, content: String, error: String?)? = needsPrevious
|
||||
? loadAdjacentChapter(chapters[previousIndex!], rules: rules)
|
||||
: nil
|
||||
async let nextValue: (title: String, content: String, error: String?)? = needsNext
|
||||
? loadAdjacentChapter(chapters[nextIndex!], rules: rules)
|
||||
: nil
|
||||
let resolvedNext = await nextValue
|
||||
guard requestedGeneration == chapterLoadGeneration, requestedIndex == currentIndex, !Task.isCancelled else { return }
|
||||
if continuationLoadedIndex == nextIndex, needsNext {
|
||||
continuationTitle = resolvedNext?.title ?? nextIndex.map { chapters[$0].title } ?? ""
|
||||
continuationContent = resolvedNext?.content ?? ""
|
||||
isContinuationWindowReady = true
|
||||
}
|
||||
let resolvedPrevious = await previousValue
|
||||
guard requestedGeneration == chapterLoadGeneration, requestedIndex == currentIndex, !Task.isCancelled else { return }
|
||||
if previousLoadedIndex == previousIndex, needsPrevious {
|
||||
previousTitle = resolvedPrevious?.title ?? previousIndex.map { chapters[$0].title } ?? ""
|
||||
previousContent = resolvedPrevious?.content ?? ""
|
||||
isPreviousWindowReady = true
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAdjacentChapter(
|
||||
_ chapter: BookChapter,
|
||||
rules: [ReplaceRule],
|
||||
forceRefresh: Bool = false
|
||||
) async -> (title: String, content: String, error: String?) {
|
||||
let report: ChapterContentReport
|
||||
if let demoChapterContent {
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("-uiScrollRegression") {
|
||||
try? await Task.sleep(nanoseconds: 80_000_000)
|
||||
}
|
||||
#endif
|
||||
report = .init(title: chapter.title, content: demoChapterContent)
|
||||
} else if let source {
|
||||
report = await contentCoordinator.load(
|
||||
book: book,
|
||||
chapter: chapter,
|
||||
source: source,
|
||||
nextChapterURL: nextReadableChapter(after: chapter.index)?.url,
|
||||
forceRefresh: forceRefresh
|
||||
).report
|
||||
} else {
|
||||
do {
|
||||
report = try await Task.detached { [localService, book, chapter] in
|
||||
try localService.loadContent(book: book, chapter: chapter)
|
||||
}.value
|
||||
} catch {
|
||||
return (chapter.title, "", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
var effectiveChapter = chapter
|
||||
if let resolvedTitle = report.title { effectiveChapter.title = resolvedTitle }
|
||||
let processed = sanitizer.process(
|
||||
book: book,
|
||||
chapter: effectiveChapter,
|
||||
content: report.content,
|
||||
rules: rules
|
||||
)
|
||||
let error = processed.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? (report.diagnostics + processed.diagnostics).first(where: { $0.severity == .error })?.message
|
||||
?? (source != nil && !chapter.isVolume ? "当前书源没有返回正文。" : nil)
|
||||
: nil
|
||||
return (processed.title, processed.content, error)
|
||||
}
|
||||
|
||||
func saveEditedContent(_ value: String) async throws {
|
||||
guard let chapter = currentChapter, source != nil else { return }
|
||||
try await contentCoordinator.saveEditedContent(book: book, chapter: chapter, title: title, content: value)
|
||||
content = value
|
||||
paragraphs = Self.makeParagraphs(value)
|
||||
loadedFromCache = true
|
||||
if let slot = scrollChapters.firstIndex(where: { $0.id == currentIndex }) {
|
||||
scrollChapters[slot] = scrollChapterForCurrentContent()
|
||||
}
|
||||
}
|
||||
|
||||
func nextSpeechContent(rules: [ReplaceRule]) async -> String? {
|
||||
guard let chapter = nextReadableChapter(after: currentIndex) else { return nil }
|
||||
let report: ChapterContentReport
|
||||
if let source {
|
||||
report = await contentCoordinator.load(
|
||||
book: book,
|
||||
chapter: chapter,
|
||||
source: source,
|
||||
nextChapterURL: nextReadableChapter(after: chapter.index)?.url
|
||||
).report
|
||||
} else {
|
||||
do {
|
||||
report = try await Task.detached { [localService, book, chapter] in
|
||||
try localService.loadContent(book: book, chapter: chapter)
|
||||
}.value
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var effectiveChapter = chapter
|
||||
if let resolvedTitle = report.title { effectiveChapter.title = resolvedTitle }
|
||||
let value = sanitizer.process(
|
||||
book: book,
|
||||
chapter: effectiveChapter,
|
||||
content: report.content,
|
||||
rules: rules
|
||||
).content
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleReplaceRules() -> Book {
|
||||
var config = book.readConfig ?? BookReadConfig()
|
||||
config.useReplaceRule = !usesReplaceRules
|
||||
book.readConfig = config
|
||||
content = ""
|
||||
pendingSourceContent = nil
|
||||
paragraphs = []
|
||||
diagnostics = []
|
||||
loadedFromCache = false
|
||||
hasLoadedCurrentChapter = false
|
||||
clearAdjacentChapters()
|
||||
return book
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func useGlobalPageAnimation() -> Book {
|
||||
var config = book.readConfig ?? BookReadConfig()
|
||||
config.pageAnim = -1
|
||||
book.readConfig = config
|
||||
return book
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setTTSEngine(_ value: String?) -> Book {
|
||||
var config = book.readConfig ?? BookReadConfig()
|
||||
config.ttsEngine = value
|
||||
book.readConfig = config
|
||||
return book
|
||||
}
|
||||
|
||||
func goPrevious(position: Int = 0) { if let value = navigationIndex(direction: -1) { select(index: value, position: position) } }
|
||||
func goNext(position: Int = 0) { if let value = navigationIndex(direction: 1) { select(index: value, position: position) } }
|
||||
|
||||
func promoteContinuation(position: Int) {
|
||||
guard let value = navigationIndex(direction: 1), hasContinuationWindow else {
|
||||
return
|
||||
}
|
||||
let oldIndex = currentIndex
|
||||
let oldTitle = title
|
||||
let oldContent = content
|
||||
let promotedContentResolved = isContinuationWindowReady
|
||||
currentIndex = value
|
||||
title = continuationTitle.isEmpty ? chapters[value].title : continuationTitle
|
||||
content = continuationContent
|
||||
paragraphs = Self.makeParagraphs(continuationContent)
|
||||
diagnostics = []
|
||||
requestedPosition = max(position, 0)
|
||||
loadedFromCache = promotedContentResolved
|
||||
hasLoadedCurrentChapter = promotedContentResolved
|
||||
previousLoadedIndex = oldIndex
|
||||
previousTitle = oldTitle
|
||||
previousContent = oldContent
|
||||
isPreviousWindowReady = true
|
||||
continuationLoadedIndex = nil
|
||||
continuationTitle = ""
|
||||
continuationContent = ""
|
||||
isContinuationWindowReady = false
|
||||
isLoading = false
|
||||
chapterLoadGeneration &+= 1
|
||||
}
|
||||
|
||||
func promotePrevious(position: Int) {
|
||||
guard let value = navigationIndex(direction: -1), hasPreviousWindow else { return }
|
||||
let oldIndex = currentIndex
|
||||
let oldTitle = title
|
||||
let oldContent = content
|
||||
let promotedContentResolved = isPreviousWindowReady
|
||||
currentIndex = value
|
||||
title = previousTitle.isEmpty ? chapters[value].title : previousTitle
|
||||
content = previousContent
|
||||
paragraphs = Self.makeParagraphs(previousContent)
|
||||
diagnostics = []
|
||||
requestedPosition = max(position, 0)
|
||||
loadedFromCache = promotedContentResolved
|
||||
hasLoadedCurrentChapter = promotedContentResolved
|
||||
continuationLoadedIndex = oldIndex
|
||||
continuationTitle = oldTitle
|
||||
continuationContent = oldContent
|
||||
isContinuationWindowReady = true
|
||||
previousLoadedIndex = nil
|
||||
previousTitle = ""
|
||||
previousContent = ""
|
||||
isPreviousWindowReady = false
|
||||
isLoading = false
|
||||
chapterLoadGeneration &+= 1
|
||||
}
|
||||
|
||||
func select(index: Int, position: Int = 0) {
|
||||
guard chapters.indices.contains(index), !chapters[index].isVolume else { return }
|
||||
currentIndex = index
|
||||
pendingSourceContent = nil
|
||||
title = chapters[index].title
|
||||
content = ""
|
||||
paragraphs = []
|
||||
diagnostics = []
|
||||
requestedPosition = max(position, 0)
|
||||
loadedFromCache = false
|
||||
hasLoadedCurrentChapter = false
|
||||
clearAdjacentChapters()
|
||||
isLoading = false
|
||||
chapterLoadGeneration &+= 1
|
||||
}
|
||||
|
||||
private func clearAdjacentChapters() {
|
||||
clearContinuousReading()
|
||||
previousLoadedIndex = nil
|
||||
previousTitle = ""
|
||||
previousContent = ""
|
||||
isPreviousWindowReady = false
|
||||
continuationLoadedIndex = nil
|
||||
continuationTitle = ""
|
||||
continuationContent = ""
|
||||
isContinuationWindowReady = false
|
||||
}
|
||||
|
||||
func paragraphID(at position: Int) -> Int? {
|
||||
paragraphs.last(where: { $0.id <= position })?.id ?? paragraphs.first?.id
|
||||
}
|
||||
|
||||
private func navigationIndex(direction: Int) -> Int? {
|
||||
readableIndex(from: currentIndex, direction: direction)
|
||||
}
|
||||
|
||||
private func readableIndex(from index: Int, direction: Int) -> Int? {
|
||||
var value = index + direction
|
||||
while chapters.indices.contains(value) {
|
||||
if !chapters[value].isVolume { return value }
|
||||
value += direction
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func nextReadableChapter(after index: Int) -> BookChapter? {
|
||||
var value = index + 1
|
||||
while chapters.indices.contains(value) {
|
||||
if !chapters[value].isVolume { return chapters[value] }
|
||||
value += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func schedulePreload(after index: Int, source: BookSource) {
|
||||
let count = ApplicationSettingsStore.shared.settings.prefetchChapters
|
||||
let jobs = ChapterCachePlanner.plan(chapters: chapters, startIndex: index + 1, limit: count)
|
||||
guard index + 1 < chapters.count, !jobs.isEmpty else { return }
|
||||
Task { [book, contentCoordinator] in
|
||||
for job in jobs {
|
||||
guard !Task.isCancelled else { return }
|
||||
await contentCoordinator.prefetch(book: book, chapter: job.chapter, source: source, nextChapterURL: job.nextChapterURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeParagraphs(_ content: String) -> [ReaderParagraph] {
|
||||
var position = 0
|
||||
var output: [ReaderParagraph] = []
|
||||
for line in content.components(separatedBy: .newlines) {
|
||||
if !line.isEmpty { output.append(.init(id: position, text: line)) }
|
||||
position += line.utf16.count + 1
|
||||
}
|
||||
if output.isEmpty, !content.isEmpty { output.append(.init(id: 0, text: content)) }
|
||||
return output
|
||||
}
|
||||
|
||||
private static func validIndex(_ requested: Int, chapters: [BookChapter]) -> Int {
|
||||
if chapters.indices.contains(requested), !chapters[requested].isVolume { return requested }
|
||||
return chapters.firstIndex(where: { !$0.isVolume }) ?? 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user