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:
2026-03-14 05:51:40 +01:00
parent 70407a0c4d
commit d2c2586009
3 changed files with 133 additions and 154 deletions

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}
}