77 lines
2.3 KiB
Swift
77 lines
2.3 KiB
Swift
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<CharacterOffset>? {
|
|
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
|
|
}
|
|
}
|