Files
vorleser/Vorleser-iOS/ReaderView.swift
2026-03-14 07:46:50 +01:00

131 lines
3.9 KiB
Swift

import SwiftUI
import SwiftData
import Storage
import BookParser
import AudioEngine
import VorleserKit
struct ReaderView: 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)
PlaybackControls(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,
onTapCharacter: { offset in
Task { try await viewModel.startPlayback(from: offset) }
},
scrollToOffset: scrollToChapterOffset
)
} else {
BookTextView(
attributedText: attributedText,
highlightRange: highlightRange,
onTapCharacter: { 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)
}
}