import Foundation import Observation import BookParser import VorleserKit import class Synthesizer.Synthesizer import struct Synthesizer.VoicePack @Observable @MainActor public final class ReaderViewModel { public private(set) var book: Book? public private(set) var error: String? public private(set) var synthesizer: Synthesizer? public var selectedChapterIndex: Int = 0 public let engine = AudioEngine() /// Character offset in the full book text where each chapter starts. public private(set) var chapterOffsets: [Int] = [] public init() {} public func loadBook(fileURL: URL, fileExists: Bool, lastPosition: Int, modelURL: URL?, voicesURL: URL?) async { guard fileExists else { self.error = "Book file is missing. Please re-import." return } do { let parsed = try BookParser.parse(url: fileURL) self.book = parsed // Build chapter offset table var offsets: [Int] = [] var offset = 0 for chapter in parsed.chapters { offsets.append(offset) offset += chapter.text.count } self.chapterOffsets = offsets // Restore chapter position if lastPosition > 0, let (chIdx, _) = parsed.chapterAndLocalOffset(for: lastPosition) { selectedChapterIndex = chIdx } // Init synthesizer guard let modelURL, let voicesURL else { error = "TTS model files not found in app bundle." return } let voice = VoicePack.curated.first! self.synthesizer = try Synthesizer(voice: voice, modelURL: modelURL, voicesURL: voicesURL) } catch { self.error = "Failed to load book: \(error)" } } public func startPlayback(from offset: CharacterOffset) async throws { guard let book, let synthesizer else { return } try await engine.play(book: book, from: offset, using: synthesizer) } /// Returns the global character offset for a chapter index. public func chapterOffset(for chapterIndex: Int) -> CharacterOffset { guard chapterIndex < chapterOffsets.count else { return 0 } return chapterOffsets[chapterIndex] } /// Returns the current sentence range in global character offsets, or nil if not playing. public func currentSentenceRange() -> Range? { guard engine.state == .playing || engine.state == .synthesizing, let book else { return nil } guard let idx = book.sentenceIndex(containing: engine.currentPosition) else { return nil } return book.sentences[idx].range } }