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 = [] 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) { 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 } }