rewrite macOS MacReaderView: use ReaderViewModel, full-book attributed text, reading mode picker, delete MacReadingTextView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,19 +2,15 @@ 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 AudioEngine
|
||||
import VorleserKit
|
||||
|
||||
struct MacReaderView: 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
|
||||
@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 {
|
||||
@@ -26,104 +22,98 @@ struct MacReaderView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if let error {
|
||||
if let error = viewModel.error {
|
||||
ContentUnavailableView("Error", systemImage: "exclamationmark.triangle", description: Text(error))
|
||||
} else if let book {
|
||||
chapterPicker(book: book)
|
||||
readingContent(book: book)
|
||||
MacPlaybackControls(engine: engine)
|
||||
} else if let book = viewModel.book, let attrText = fullAttributedText {
|
||||
toolbar(book: book)
|
||||
readingContent(attributedText: attrText)
|
||||
MacPlaybackControls(engine: viewModel.engine)
|
||||
} else {
|
||||
ProgressView("Loading…")
|
||||
ProgressView("Loading\u{2026}")
|
||||
}
|
||||
}
|
||||
.navigationTitle(storedBook.title)
|
||||
.task { await loadBook() }
|
||||
.onDisappear {
|
||||
engine.stop()
|
||||
try? bookStore.updatePosition(storedBook, position: engine.currentPosition)
|
||||
viewModel.engine.stop()
|
||||
storedBook.readingMode = readingMode
|
||||
try? bookStore.updatePosition(storedBook, position: viewModel.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)
|
||||
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)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.padding(.horizontal)
|
||||
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(book: Book) -> some View {
|
||||
let chapter = book.chapters[selectedChapterIndex]
|
||||
let highlightRange = currentSentenceRange(in: book)
|
||||
private func readingContent(attributedText: NSAttributedString) -> some View {
|
||||
let highlightRange = viewModel.currentSentenceRange()
|
||||
|
||||
MacReadingTextView(
|
||||
text: chapter.text,
|
||||
highlightedRange: highlightRange,
|
||||
onClickCharacter: { localOffset in
|
||||
let globalOffset = globalOffset(forLocalOffset: localOffset, in: book)
|
||||
Task {
|
||||
try await startPlayback(from: globalOffset, book: book)
|
||||
}
|
||||
}
|
||||
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)
|
||||
guard bookStore.fileExists(for: storedBook) else {
|
||||
error = "Book file is missing. Please re-import."
|
||||
return
|
||||
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)
|
||||
}
|
||||
do {
|
||||
self.book = try BookParser.parse(url: fileURL)
|
||||
if let book, storedBook.lastPosition > 0 {
|
||||
if let (chIdx, _) = book.chapterAndLocalOffset(for: storedBook.lastPosition) {
|
||||
selectedChapterIndex = chIdx
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
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)
|
||||
result.append(chapterAttr)
|
||||
} else {
|
||||
error = "TTS model files not found in app bundle."
|
||||
result.append(chapter.attributedText)
|
||||
}
|
||||
} 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
|
||||
return NSAttributedString(attributedString: result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import VorleserKit
|
||||
|
||||
struct MacReadingTextView: NSViewRepresentable {
|
||||
let text: String
|
||||
let highlightedRange: Range<Int>?
|
||||
let onClickCharacter: (CharacterOffset) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let scrollView = NSTextView.scrollableTextView()
|
||||
let textView = scrollView.documentView as! NSTextView
|
||||
textView.isEditable = false
|
||||
textView.isSelectable = false
|
||||
textView.font = .preferredFont(forTextStyle: .body)
|
||||
textView.textContainerInset = NSSize(width: 16, height: 16)
|
||||
|
||||
let click = NSClickGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleClick(_:)))
|
||||
textView.addGestureRecognizer(click)
|
||||
context.coordinator.textView = textView
|
||||
|
||||
return scrollView
|
||||
}
|
||||
|
||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||
let textView = scrollView.documentView as! NSTextView
|
||||
let attributed = NSMutableAttributedString(
|
||||
string: text,
|
||||
attributes: [
|
||||
.font: NSFont.preferredFont(forTextStyle: .body),
|
||||
.foregroundColor: NSColor.textColor,
|
||||
]
|
||||
)
|
||||
|
||||
if let range = highlightedRange,
|
||||
range.lowerBound >= 0,
|
||||
range.upperBound <= text.count {
|
||||
let nsRange = NSRange(location: range.lowerBound, length: range.upperBound - range.lowerBound)
|
||||
attributed.addAttribute(.backgroundColor, value: NSColor.systemYellow.withAlphaComponent(0.3), range: nsRange)
|
||||
}
|
||||
|
||||
textView.textStorage?.setAttributedString(attributed)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onClickCharacter: onClickCharacter)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject {
|
||||
weak var textView: NSTextView?
|
||||
let onClickCharacter: (CharacterOffset) -> Void
|
||||
|
||||
init(onClickCharacter: @escaping (CharacterOffset) -> Void) {
|
||||
self.onClickCharacter = onClickCharacter
|
||||
}
|
||||
|
||||
@objc func handleClick(_ gesture: NSClickGestureRecognizer) {
|
||||
guard let textView else { return }
|
||||
let point = gesture.location(in: textView)
|
||||
let characterIndex = textView.characterIndexForInsertion(at: point)
|
||||
if characterIndex < textView.string.count {
|
||||
onClickCharacter(characterIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user