130 lines
3.9 KiB
Swift
130 lines
3.9 KiB
Swift
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<Int>? {
|
|
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..<localEnd
|
|
}
|
|
}
|