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

View File

@@ -8,41 +8,47 @@
/* Begin PBXBuildFile section */
1EAA6CE8D165697C44F758E1 /* Synthesizer in Frameworks */ = {isa = PBXBuildFile; productRef = 27EA57D961AE7F78BD042C7D /* Synthesizer */; };
262DCBE03697122D885E197A /* ReadingTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 008C9796AA14615884E3F767 /* ReadingTextView.swift */; };
296593F8EAFF71BDDDD258AD /* voices.npz in Resources */ = {isa = PBXBuildFile; fileRef = DD450CF6A4B5C744313FCCCC /* voices.npz */; };
42DC7EA3FDECF1F8557ECE50 /* Storage in Frameworks */ = {isa = PBXBuildFile; productRef = CC9654FFF9E6F93F672291D1 /* Storage */; };
478DDD72F4BF97B2E5697C0F /* VorleserApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F3129908D008B3F6FA0777D /* VorleserApp.swift */; };
64BBA5CAF37B44980DB9A20B /* MacPlaybackControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032574858834489235F8E70B /* MacPlaybackControls.swift */; };
6727B5E58A877E698FEA21E3 /* MacLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D2DCB6321555456B400716 /* MacLibraryView.swift */; };
73363AA5EEAAB6E3CA2B80EA /* MacReadingTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76673CE8E0AFC98AB214F0AC /* MacReadingTextView.swift */; };
85BC42AC0D0400C20407E332 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63908D55740A037BF58A5D5 /* LibraryView.swift */; };
860AEFF3196B242AEA14453E /* VorleserMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 674640CCD25FD35F3A950480 /* VorleserMacApp.swift */; };
88DD5C3CCA3EE525BD693996 /* AudioEngine in Frameworks */ = {isa = PBXBuildFile; productRef = F220972C5B4AD4F04F23AE5E /* AudioEngine */; };
8CBED320BBF620DA94621D90 /* VorleserKit in Frameworks */ = {isa = PBXBuildFile; productRef = 84A6483078F674BB576552A5 /* VorleserKit */; };
909308D1B3D57837EBBF8364 /* kokoro-v1_0.safetensors in Resources */ = {isa = PBXBuildFile; fileRef = D849E8DAA941F3E80C06E3E6 /* kokoro-v1_0.safetensors */; };
99BDCA70C12F3565454034E7 /* Synthesizer in Frameworks */ = {isa = PBXBuildFile; productRef = 3AC54A162EC40A53151F9AD5 /* Synthesizer */; };
9F53CBEE508D8BEE26A25CD3 /* VorleserKit in Frameworks */ = {isa = PBXBuildFile; productRef = 4E0C7F2BF01F62CE94DF11D4 /* VorleserKit */; };
A0A781101253D2D13A376541 /* voices.npz in Resources */ = {isa = PBXBuildFile; fileRef = DD450CF6A4B5C744313FCCCC /* voices.npz */; };
A4C2566EFB0CD3ACB176D9CC /* ReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F659E46345F099B2D20F426C /* ReaderView.swift */; };
A914F9C0B0E6FD8E13B8F99D /* PlaybackControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5646AE29793AF3F9C5ED0C12 /* PlaybackControls.swift */; };
AFA2F66F51B5614855774208 /* AudioEngine in Frameworks */ = {isa = PBXBuildFile; productRef = 87A4DA0439F72FDBEC4FF2A1 /* AudioEngine */; };
BC5528C6EC416119A2B866E4 /* BookTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10B1CF58D0A84F564B0C2B01 /* BookTextView.swift */; };
CADD012FE11A8581C62F2EF4 /* BookTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5E22A3829AA6729CFA9FA8 /* BookTextView.swift */; };
D2EEC78469BFE5CA6C5D394B /* BookParser in Frameworks */ = {isa = PBXBuildFile; productRef = 3C7B4217E783582136864364 /* BookParser */; };
DA1B4CA118527E202EE1785E /* Storage in Frameworks */ = {isa = PBXBuildFile; productRef = 2AB6EF50B3EBA9FF66965E5A /* Storage */; };
DA229B9A3F221061FEA239EA /* BookParser in Frameworks */ = {isa = PBXBuildFile; productRef = 35907A46EC5A73A0BC1B57CE /* BookParser */; };
DF0BDDB16296D30F090B9CD1 /* MacReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E67F95D90A3116255C29F5BF /* MacReaderView.swift */; };
EDA9269A7BE1C78A4AEB8E5B /* kokoro-v1_0.safetensors in Resources */ = {isa = PBXBuildFile; fileRef = D849E8DAA941F3E80C06E3E6 /* kokoro-v1_0.safetensors */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
008C9796AA14615884E3F767 /* ReadingTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingTextView.swift; sourceTree = "<group>"; };
032574858834489235F8E70B /* MacPlaybackControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacPlaybackControls.swift; sourceTree = "<group>"; };
04977AC777181754E04C0987 /* Vorleser-iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "Vorleser-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
10B1CF58D0A84F564B0C2B01 /* BookTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookTextView.swift; sourceTree = "<group>"; };
2F3129908D008B3F6FA0777D /* VorleserApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorleserApp.swift; sourceTree = "<group>"; };
378D5AF67B54CC7ABB4FFF06 /* VorleserKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VorleserKit; path = VorleserKit; sourceTree = SOURCE_ROOT; };
5646AE29793AF3F9C5ED0C12 /* PlaybackControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackControls.swift; sourceTree = "<group>"; };
674640CCD25FD35F3A950480 /* VorleserMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorleserMacApp.swift; sourceTree = "<group>"; };
76673CE8E0AFC98AB214F0AC /* MacReadingTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacReadingTextView.swift; sourceTree = "<group>"; };
A63908D55740A037BF58A5D5 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
D849E8DAA941F3E80C06E3E6 /* kokoro-v1_0.safetensors */ = {isa = PBXFileReference; path = "kokoro-v1_0.safetensors"; sourceTree = "<group>"; };
D8BC5EDC47085426247216C2 /* Vorleser-macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Vorleser-macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
DD450CF6A4B5C744313FCCCC /* voices.npz */ = {isa = PBXFileReference; path = voices.npz; sourceTree = "<group>"; };
E1D2DCB6321555456B400716 /* MacLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacLibraryView.swift; sourceTree = "<group>"; };
E67F95D90A3116255C29F5BF /* MacReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacReaderView.swift; sourceTree = "<group>"; };
F659E46345F099B2D20F426C /* ReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderView.swift; sourceTree = "<group>"; };
FD5E22A3829AA6729CFA9FA8 /* BookTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookTextView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -73,13 +79,38 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
43CAFE937198DF320911C1C2 /* Resources */ = {
isa = PBXGroup;
children = (
6B0EE4D1124B623F307202AD /* Models */,
7BA6C802AB0C0360C1AB865A /* Voices */,
);
path = Resources;
sourceTree = "<group>";
};
6B0EE4D1124B623F307202AD /* Models */ = {
isa = PBXGroup;
children = (
D849E8DAA941F3E80C06E3E6 /* kokoro-v1_0.safetensors */,
);
path = Models;
sourceTree = "<group>";
};
7BA6C802AB0C0360C1AB865A /* Voices */ = {
isa = PBXGroup;
children = (
DD450CF6A4B5C744313FCCCC /* voices.npz */,
);
path = Voices;
sourceTree = "<group>";
};
A917AFA5DFFCF992FE4C944A /* Vorleser-macOS */ = {
isa = PBXGroup;
children = (
FD5E22A3829AA6729CFA9FA8 /* BookTextView.swift */,
E1D2DCB6321555456B400716 /* MacLibraryView.swift */,
032574858834489235F8E70B /* MacPlaybackControls.swift */,
E67F95D90A3116255C29F5BF /* MacReaderView.swift */,
76673CE8E0AFC98AB214F0AC /* MacReadingTextView.swift */,
674640CCD25FD35F3A950480 /* VorleserMacApp.swift */,
);
path = "Vorleser-macOS";
@@ -89,6 +120,7 @@
isa = PBXGroup;
children = (
B6DABD163E3B1A42BA34A10D /* Packages */,
43CAFE937198DF320911C1C2 /* Resources */,
D1D89222199EA26DFA1554B4 /* Vorleser-iOS */,
A917AFA5DFFCF992FE4C944A /* Vorleser-macOS */,
FEFF49B59E5B20C71387C257 /* Products */,
@@ -106,10 +138,10 @@
D1D89222199EA26DFA1554B4 /* Vorleser-iOS */ = {
isa = PBXGroup;
children = (
10B1CF58D0A84F564B0C2B01 /* BookTextView.swift */,
A63908D55740A037BF58A5D5 /* LibraryView.swift */,
5646AE29793AF3F9C5ED0C12 /* PlaybackControls.swift */,
F659E46345F099B2D20F426C /* ReaderView.swift */,
008C9796AA14615884E3F767 /* ReadingTextView.swift */,
2F3129908D008B3F6FA0777D /* VorleserApp.swift */,
);
path = "Vorleser-iOS";
@@ -132,6 +164,7 @@
buildConfigurationList = 5A7B9499E6F195E77338B5A7 /* Build configuration list for PBXNativeTarget "Vorleser-macOS" */;
buildPhases = (
792008017A74E2EE941F22CB /* Sources */,
E25266AE85602BDA78B4AC08 /* Resources */,
D8016C31FA773AD91EE4A577 /* Frameworks */,
);
buildRules = (
@@ -155,6 +188,7 @@
buildConfigurationList = 6E7BE8CF12C637AC6B32FAA0 /* Build configuration list for PBXNativeTarget "Vorleser-iOS" */;
buildPhases = (
5D52CD0A07D13514D97BB646 /* Sources */,
326FEC2D77A3AA4A94C49D8E /* Resources */,
E7E3FF80BEEC2524EFFB4170 /* Frameworks */,
);
buildRules = (
@@ -207,15 +241,36 @@
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
326FEC2D77A3AA4A94C49D8E /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
909308D1B3D57837EBBF8364 /* kokoro-v1_0.safetensors in Resources */,
296593F8EAFF71BDDDD258AD /* voices.npz in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
E25266AE85602BDA78B4AC08 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
EDA9269A7BE1C78A4AEB8E5B /* kokoro-v1_0.safetensors in Resources */,
A0A781101253D2D13A376541 /* voices.npz in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
5D52CD0A07D13514D97BB646 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BC5528C6EC416119A2B866E4 /* BookTextView.swift in Sources */,
85BC42AC0D0400C20407E332 /* LibraryView.swift in Sources */,
A914F9C0B0E6FD8E13B8F99D /* PlaybackControls.swift in Sources */,
A4C2566EFB0CD3ACB176D9CC /* ReaderView.swift in Sources */,
262DCBE03697122D885E197A /* ReadingTextView.swift in Sources */,
478DDD72F4BF97B2E5697C0F /* VorleserApp.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -224,10 +279,10 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CADD012FE11A8581C62F2EF4 /* BookTextView.swift in Sources */,
6727B5E58A877E698FEA21E3 /* MacLibraryView.swift in Sources */,
64BBA5CAF37B44980DB9A20B /* MacPlaybackControls.swift in Sources */,
DF0BDDB16296D30F090B9CD1 /* MacReaderView.swift in Sources */,
73363AA5EEAAB6E3CA2B80EA /* MacReadingTextView.swift in Sources */,
860AEFF3196B242AEA14453E /* VorleserMacApp.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;