Files

612 lines
25 KiB
Swift

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
}
}