From d2c2586009356d259538389da52c2d1ebe0d9197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Sat, 14 Mar 2026 05:51:40 +0100 Subject: [PATCH] rewrite macOS MacReaderView: use ReaderViewModel, full-book attributed text, reading mode picker, delete MacReadingTextView Co-Authored-By: Claude Sonnet 4.6 --- Vorleser-macOS/MacReaderView.swift | 150 +++++++++++------------- Vorleser-macOS/MacReadingTextView.swift | 66 ----------- Vorleser.xcodeproj/project.pbxproj | 71 +++++++++-- 3 files changed, 133 insertions(+), 154 deletions(-) delete mode 100644 Vorleser-macOS/MacReadingTextView.swift diff --git a/Vorleser-macOS/MacReaderView.swift b/Vorleser-macOS/MacReaderView.swift index 69a8c33..c9f0f06 100644 --- a/Vorleser-macOS/MacReaderView.swift +++ b/Vorleser-macOS/MacReaderView.swift @@ -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? { - 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..? - 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) - } - } - } -} diff --git a/Vorleser.xcodeproj/project.pbxproj b/Vorleser.xcodeproj/project.pbxproj index b84a6f6..f52411e 100644 --- a/Vorleser.xcodeproj/project.pbxproj +++ b/Vorleser.xcodeproj/project.pbxproj @@ -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 = ""; }; 032574858834489235F8E70B /* MacPlaybackControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacPlaybackControls.swift; sourceTree = ""; }; 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 = ""; }; 2F3129908D008B3F6FA0777D /* VorleserApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorleserApp.swift; sourceTree = ""; }; 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 = ""; }; 674640CCD25FD35F3A950480 /* VorleserMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorleserMacApp.swift; sourceTree = ""; }; - 76673CE8E0AFC98AB214F0AC /* MacReadingTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacReadingTextView.swift; sourceTree = ""; }; A63908D55740A037BF58A5D5 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; + D849E8DAA941F3E80C06E3E6 /* kokoro-v1_0.safetensors */ = {isa = PBXFileReference; path = "kokoro-v1_0.safetensors"; sourceTree = ""; }; 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 = ""; }; E1D2DCB6321555456B400716 /* MacLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacLibraryView.swift; sourceTree = ""; }; E67F95D90A3116255C29F5BF /* MacReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacReaderView.swift; sourceTree = ""; }; F659E46345F099B2D20F426C /* ReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderView.swift; sourceTree = ""; }; + FD5E22A3829AA6729CFA9FA8 /* BookTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookTextView.swift; sourceTree = ""; }; /* 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 = ""; + }; + 6B0EE4D1124B623F307202AD /* Models */ = { + isa = PBXGroup; + children = ( + D849E8DAA941F3E80C06E3E6 /* kokoro-v1_0.safetensors */, + ); + path = Models; + sourceTree = ""; + }; + 7BA6C802AB0C0360C1AB865A /* Voices */ = { + isa = PBXGroup; + children = ( + DD450CF6A4B5C744313FCCCC /* voices.npz */, + ); + path = Voices; + sourceTree = ""; + }; 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;