import SwiftUI import SwiftData import Storage import BookParser import class AudioEngine.AudioEngine import enum AudioEngine.PlaybackState import class Synthesizer.Synthesizer import struct Synthesizer.VoicePack import VorleserKit struct ReaderView: View { let storedBook: StoredBook @State private var book: Book? @State private var error: String? @State private var engine = AudioEngine() @State private var synthesizer: Synthesizer? @State private var selectedChapterIndex: Int = 0 @Environment(\.modelContext) private var modelContext private var bookStore: BookStore { BookStore( modelContainer: modelContext.container, documentsDirectory: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] ) } var body: some View { VStack { if let error { ContentUnavailableView("Error", systemImage: "exclamationmark.triangle", description: Text(error)) } else if let book { chapterPicker(book: book) readingContent(book: book) PlaybackControls(engine: engine) } else { ProgressView("Loading\u{2026}") } } .navigationTitle(storedBook.title) .task { await loadBook() } .onDisappear { engine.stop() try? bookStore.updatePosition(storedBook, position: engine.currentPosition) } } @ViewBuilder private func chapterPicker(book: Book) -> some View { if book.chapters.count > 1 { Picker("Chapter", selection: $selectedChapterIndex) { ForEach(book.chapters, id: \.index) { chapter in Text(chapter.title).tag(chapter.index) } } .pickerStyle(.menu) .padding(.horizontal) } } @ViewBuilder private func readingContent(book: Book) -> some View { let chapter = book.chapters[selectedChapterIndex] let highlightRange = currentSentenceRange(in: book) ReadingTextView( text: chapter.text, highlightedRange: highlightRange, onTapCharacter: { localOffset in let globalOffset = globalOffset(forLocalOffset: localOffset, in: book) Task { try await startPlayback(from: globalOffset, book: book) } } ) } private func loadBook() async { let fileURL = bookStore.fileURL(for: storedBook) guard bookStore.fileExists(for: storedBook) else { error = "Book file is missing. Please re-import." return } do { self.book = try BookParser.parse(url: fileURL) if let book, storedBook.lastPosition > 0 { if let (chIdx, _) = book.chapterAndLocalOffset(for: storedBook.lastPosition) { selectedChapterIndex = chIdx } } if let modelURL = Bundle.main.url(forResource: "kokoro-v1_0", withExtension: "safetensors"), let voicesURL = Bundle.main.url(forResource: "voices", withExtension: "npz") { let voice = VoicePack.curated.first! self.synthesizer = try Synthesizer(voice: voice, modelURL: modelURL, voicesURL: voicesURL) } else { error = "TTS model files not found in app bundle." } } catch { self.error = "Failed to load book: \(error)" } } private func startPlayback(from offset: CharacterOffset, book: Book) async throws { guard let synthesizer else { return } try await engine.play(book: book, from: offset, using: synthesizer) } private func globalOffset(forLocalOffset local: Int, in book: Book) -> CharacterOffset { var offset = 0 for chapter in book.chapters where chapter.index < selectedChapterIndex { offset += chapter.text.count } return offset + local } private func currentSentenceRange(in book: Book) -> Range? { guard engine.state == .playing || engine.state == .synthesizing else { return nil } let sentences = book.sentences guard let idx = book.sentenceIndex(containing: engine.currentPosition) else { return nil } let sentence = sentences[idx] var chapterStart = 0 for chapter in book.chapters where chapter.index < selectedChapterIndex { chapterStart += chapter.text.count } let localStart = sentence.range.lowerBound - chapterStart let localEnd = sentence.range.upperBound - chapterStart guard localStart >= 0 else { return nil } return localStart..