import SwiftUI import SwiftData import Storage import BookParser import AudioEngine import VorleserKit struct MacReaderView: View { let storedBook: StoredBook @State private var viewModel = ReaderViewModel() @State private var scrollToChapterOffset: CharacterOffset? @State private var fullAttributedText: NSAttributedString? @State private var readingMode: String = "scroll" @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 = viewModel.error { ContentUnavailableView("Error", systemImage: "exclamationmark.triangle", description: Text(error)) } else if let book = viewModel.book, let attrText = fullAttributedText { toolbar(book: book) readingContent(attributedText: attrText) MacPlaybackControls(engine: viewModel.engine) } else { ProgressView("Loading\u{2026}") } } .navigationTitle(storedBook.title) .task { await loadBook() } .onDisappear { viewModel.engine.stop() storedBook.readingMode = readingMode try? bookStore.updatePosition(storedBook, position: viewModel.engine.currentPosition) } } @ViewBuilder private func toolbar(book: Book) -> some View { HStack { if book.chapters.count > 1 { Picker("Chapter", selection: Binding( get: { viewModel.selectedChapterIndex }, set: { newIndex in viewModel.selectedChapterIndex = newIndex scrollToChapterOffset = viewModel.chapterOffset(for: newIndex) } )) { ForEach(book.chapters, id: \.index) { chapter in Text(chapter.title).tag(chapter.index) } } .pickerStyle(.menu) } Picker("Mode", selection: $readingMode) { Label("Scroll", systemImage: "scroll").tag("scroll") Label("Book", systemImage: "book").tag("book") } .pickerStyle(.segmented) .frame(maxWidth: 200) } .padding(.horizontal) } @ViewBuilder private func readingContent(attributedText: NSAttributedString) -> some View { let highlightRange = viewModel.currentSentenceRange() if readingMode == "book" { PagedBookView( attributedText: attributedText, highlightRange: highlightRange, onClickCharacter: { offset in Task { try await viewModel.startPlayback(from: offset) } }, scrollToOffset: scrollToChapterOffset ) } else { BookTextView( attributedText: attributedText, highlightRange: highlightRange, onClickCharacter: { offset in Task { try await viewModel.startPlayback(from: offset) } }, scrollToOffset: scrollToChapterOffset ) } } private func loadBook() async { readingMode = storedBook.readingMode ?? "scroll" let fileURL = bookStore.fileURL(for: storedBook) let modelURL = Bundle.main.url(forResource: "kokoro-v1_0", withExtension: "safetensors") let voicesURL = Bundle.main.url(forResource: "voices", withExtension: "npz") await viewModel.loadBook( fileURL: fileURL, fileExists: bookStore.fileExists(for: storedBook), lastPosition: storedBook.lastPosition, modelURL: modelURL, voicesURL: voicesURL ) if let book = viewModel.book { fullAttributedText = Self.buildFullAttributedText(from: book) } } private static func buildFullAttributedText(from book: Book) -> NSAttributedString { let result = NSMutableAttributedString() for (i, chapter) in book.chapters.enumerated() { if i > 0 { let chapterAttr = NSMutableAttributedString(attributedString: chapter.attributedText) if chapterAttr.length > 0 { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.paragraphSpacingBefore = 32 chapterAttr.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: 1)) } result.append(chapterAttr) } else { result.append(chapter.attributedText) } } return NSAttributedString(attributedString: result) } }