feat: open-source 小浣熊阅读 for iOS

This commit is contained in:
Aminark
2026-10-14 11:37:36 +08:00
commit ffc9e9d91d
193 changed files with 41382 additions and 0 deletions
+120
View File
@@ -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
}
}
+579
View File
@@ -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()
}
}
+88
View File
@@ -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") }
}
+936
View File
@@ -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
+611
View File
@@ -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
}
}