mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
258 lines
9.4 KiB
Swift
258 lines
9.4 KiB
Swift
import SwiftUI
|
|
#if SWIFT_PACKAGE
|
|
import LegadoCore
|
|
#endif
|
|
|
|
@MainActor
|
|
final class BookDetailViewModel: ObservableObject {
|
|
enum State: Equatable { case idle, loading, findingSource, ready, failed }
|
|
|
|
@Published private(set) var state: State = .idle
|
|
@Published private(set) var book: Book
|
|
@Published private(set) var chapters: [BookChapter] = []
|
|
@Published private(set) var diagnostics: [RuleDiagnostic] = []
|
|
@Published private(set) var isCaching = false
|
|
@Published private(set) var cacheCompleted = 0
|
|
@Published private(set) var cacheTotal = 0
|
|
|
|
@Published private(set) var source: BookSource
|
|
private var searchBook: SearchBook?
|
|
private let service: NetworkBookService
|
|
private let updateCoordinator: BookUpdateCoordinator
|
|
private let contentCoordinator: ChapterContentCoordinator
|
|
private let sourceSwitchService: BookSourceSwitchService
|
|
private var cacheTask: Task<Void, Never>?
|
|
|
|
init(
|
|
searchBook: SearchBook,
|
|
source: BookSource,
|
|
service: NetworkBookService = .init(),
|
|
chapterRepository: any BookChapterRepository = AppBookChapterRepository.live()
|
|
) {
|
|
self.searchBook = searchBook
|
|
self.source = source
|
|
self.service = service
|
|
let chapters = chapterRepository
|
|
updateCoordinator = BookUpdateCoordinator(repository: chapters, loader: service)
|
|
contentCoordinator = ChapterContentCoordinator(loader: service)
|
|
sourceSwitchService = BookSourceSwitchService(
|
|
detailLoader: service,
|
|
chapterLoader: service,
|
|
contentLoader: service,
|
|
chapterRepository: chapters
|
|
)
|
|
var book = Book(
|
|
bookUrl: searchBook.bookURL,
|
|
name: searchBook.name,
|
|
author: searchBook.author,
|
|
origin: source.bookSourceUrl
|
|
)
|
|
book.originName = source.bookSourceName
|
|
book.coverUrl = searchBook.coverURL
|
|
book.intro = searchBook.intro
|
|
book.kind = searchBook.kind
|
|
self.book = book
|
|
}
|
|
|
|
init(book: Book, source: BookSource, service: NetworkBookService = .init()) {
|
|
searchBook = nil
|
|
self.book = book
|
|
self.source = source
|
|
self.service = service
|
|
let chapters = AppBookChapterRepository.live()
|
|
updateCoordinator = BookUpdateCoordinator(repository: chapters, loader: service)
|
|
contentCoordinator = ChapterContentCoordinator(loader: service)
|
|
sourceSwitchService = BookSourceSwitchService(
|
|
detailLoader: service,
|
|
chapterLoader: service,
|
|
contentLoader: service,
|
|
chapterRepository: chapters
|
|
)
|
|
}
|
|
|
|
init(demoBook: Book, source: BookSource, chapters: [BookChapter]) {
|
|
searchBook = nil
|
|
book = demoBook
|
|
self.source = source
|
|
service = .init()
|
|
updateCoordinator = .init()
|
|
contentCoordinator = .init()
|
|
sourceSwitchService = .init()
|
|
self.chapters = chapters
|
|
state = .ready
|
|
isCaching = true
|
|
cacheCompleted = 2
|
|
cacheTotal = chapters.filter { !$0.isVolume }.count
|
|
}
|
|
|
|
func load(sources: [BookSource] = []) async {
|
|
guard state == .idle else { return }
|
|
state = .loading
|
|
if searchBook == nil {
|
|
let stored = await updateCoordinator.storedChapters(bookURL: book.bookUrl)
|
|
if !stored.isEmpty {
|
|
chapters = stored
|
|
state = .ready
|
|
}
|
|
}
|
|
if let searchBook {
|
|
let detail = await service.loadDetail(searchBook: searchBook, source: source)
|
|
let details = detail.book
|
|
book.name = details.name
|
|
book.author = details.author
|
|
book.originName = details.originName
|
|
if !details.tocUrl.isEmpty { book.tocUrl = details.tocUrl }
|
|
book.type = details.type
|
|
book.kind = details.kind ?? book.kind
|
|
book.coverUrl = details.coverUrl ?? book.coverUrl
|
|
book.intro = details.intro ?? book.intro
|
|
book.charset = details.charset ?? book.charset
|
|
book.wordCount = details.wordCount ?? book.wordCount
|
|
book.latestChapterTitle = details.latestChapterTitle ?? book.latestChapterTitle
|
|
book.originOrder = details.originOrder
|
|
book.variable = details.variable ?? book.variable
|
|
diagnostics += detail.diagnostics
|
|
}
|
|
let toc = await updateCoordinator.refresh(book: book, source: source)
|
|
if !toc.chapters.isEmpty { chapters = toc.chapters }
|
|
diagnostics += toc.diagnostics
|
|
book = toc.book
|
|
if !chapters.isEmpty {
|
|
state = .ready
|
|
return
|
|
}
|
|
|
|
state = .findingSource
|
|
if let available = await sourceSwitchService.firstAvailableSource(for: book, sources: sources) {
|
|
applySourceSwitch(available.report, source: available.source)
|
|
} else {
|
|
state = .failed
|
|
}
|
|
}
|
|
|
|
func cacheNextTen() { startCaching(limit: 10) }
|
|
|
|
func cacheRemaining() { startCaching(limit: nil) }
|
|
|
|
func cancelCaching() {
|
|
cacheTask?.cancel()
|
|
cacheTask = nil
|
|
isCaching = false
|
|
}
|
|
|
|
func applySourceSwitch(_ report: BookSourceSwitchReport, source: BookSource) {
|
|
cancelCaching()
|
|
searchBook = nil
|
|
book = report.book
|
|
chapters = report.chapters
|
|
diagnostics = report.diagnostics
|
|
self.source = source
|
|
state = .ready
|
|
}
|
|
|
|
func toggleCanUpdate() { book.canUpdate.toggle() }
|
|
|
|
func restoreLibraryBook(from stored: Book, sources: [BookSource] = []) {
|
|
guard state == .idle, stored.bookUrl == book.bookUrl else { return }
|
|
if stored.origin != source.id,
|
|
let resolved = sources.first(where: { $0.id == stored.origin }) {
|
|
source = resolved
|
|
searchBook = nil
|
|
}
|
|
book = stored
|
|
book.origin = source.id
|
|
book.originName = source.bookSourceName
|
|
}
|
|
|
|
func restoreSourceAfterReading(from books: [Book], sources: [BookSource]) {
|
|
guard state == .ready,
|
|
let stored = books.first(where: { $0.bookUrl == book.bookUrl })
|
|
?? books.first(where: { $0.name == book.name && $0.author == book.author }),
|
|
stored.bookUrl != book.bookUrl || stored.origin != book.origin,
|
|
let source = sources.first(where: { $0.id == stored.origin }) else { return }
|
|
book = stored
|
|
self.source = source
|
|
searchBook = nil
|
|
chapters = []
|
|
diagnostics = []
|
|
state = .idle
|
|
}
|
|
|
|
func restoreUserSettings(from stored: Book) {
|
|
guard stored.bookUrl == book.bookUrl else { return }
|
|
book.group = stored.group
|
|
book.order = stored.order
|
|
book.customTag = stored.customTag
|
|
book.customCoverUrl = stored.customCoverUrl
|
|
book.customIntro = stored.customIntro
|
|
book.canUpdate = stored.canUpdate
|
|
book.readConfig = stored.readConfig
|
|
book.syncTime = stored.syncTime
|
|
}
|
|
|
|
func restoreReadingProgress(from stored: Book) {
|
|
guard stored.bookUrl == book.bookUrl, stored.durChapterTime > book.durChapterTime else { return }
|
|
let chapterIndex = stored.durChapterTitle.flatMap { title in
|
|
chapters.firstIndex { !$0.isVolume && $0.title == title }
|
|
} ?? stored.durChapterIndex
|
|
guard chapters.indices.contains(chapterIndex), !chapters[chapterIndex].isVolume else { return }
|
|
book.durChapterIndex = chapterIndex
|
|
book.durChapterTitle = chapters[chapterIndex].title
|
|
book.durChapterPos = stored.durChapterPos
|
|
book.durChapterTime = stored.durChapterTime
|
|
}
|
|
|
|
func refresh(sources: [BookSource] = []) async {
|
|
cancelCaching()
|
|
chapters = []
|
|
diagnostics = []
|
|
state = .idle
|
|
await load(sources: sources)
|
|
}
|
|
|
|
private func startCaching(limit: Int?) {
|
|
cancelCaching()
|
|
let start = min(max(book.durChapterIndex, 0), max(chapters.count - 1, 0))
|
|
let jobs = ChapterCachePlanner.plan(chapters: chapters, startIndex: start, limit: limit)
|
|
guard !jobs.isEmpty else { return }
|
|
|
|
isCaching = true
|
|
cacheCompleted = 0
|
|
cacheTotal = jobs.count
|
|
let coordinator = contentCoordinator
|
|
let currentBook = book
|
|
let currentSource = source
|
|
cacheTask = Task { [weak self] in
|
|
await withTaskGroup(of: Bool.self) { group in
|
|
var next = 0
|
|
func enqueue() {
|
|
guard next < jobs.count else { return }
|
|
let job = jobs[next]
|
|
next += 1
|
|
group.addTask {
|
|
guard !Task.isCancelled else { return false }
|
|
return await coordinator.prefetch(
|
|
book: currentBook,
|
|
chapter: job.chapter,
|
|
source: currentSource,
|
|
nextChapterURL: job.nextChapterURL
|
|
)
|
|
}
|
|
}
|
|
for _ in 0..<min(3, jobs.count) { enqueue() }
|
|
while await group.next() != nil {
|
|
guard !Task.isCancelled else {
|
|
group.cancelAll()
|
|
break
|
|
}
|
|
self?.cacheCompleted += 1
|
|
enqueue()
|
|
}
|
|
}
|
|
guard !Task.isCancelled else { return }
|
|
self?.isCaching = false
|
|
self?.cacheTask = nil
|
|
}
|
|
}
|
|
}
|