From b52bf532f3b226ce918b48c3134733ff60df652a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Fri, 13 Mar 2026 22:25:36 +0100 Subject: [PATCH] add iOS app shell with library, reader, tap-to-play, playback controls Co-Authored-By: Claude Opus 4.6 --- Vorleser-iOS/LibraryView.swift | 75 ++++++++++++++++ Vorleser-iOS/PlaybackControls.swift | 45 ++++++++++ Vorleser-iOS/ReaderView.swift | 127 ++++++++++++++++++++++++++++ Vorleser-iOS/ReadingTextView.swift | 65 ++++++++++++++ Vorleser-iOS/VorleserApp.swift | 13 +++ 5 files changed, 325 insertions(+) create mode 100644 Vorleser-iOS/LibraryView.swift create mode 100644 Vorleser-iOS/PlaybackControls.swift create mode 100644 Vorleser-iOS/ReaderView.swift create mode 100644 Vorleser-iOS/ReadingTextView.swift create mode 100644 Vorleser-iOS/VorleserApp.swift diff --git a/Vorleser-iOS/LibraryView.swift b/Vorleser-iOS/LibraryView.swift new file mode 100644 index 0000000..60e2922 --- /dev/null +++ b/Vorleser-iOS/LibraryView.swift @@ -0,0 +1,75 @@ +import SwiftUI +import SwiftData +import Storage +import BookParser + +struct LibraryView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \StoredBook.lastRead, order: .reverse) private var books: [StoredBook] + @State private var showFileImporter = false + + private var bookStore: BookStore { + BookStore( + modelContainer: modelContext.container, + documentsDirectory: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + ) + } + + var body: some View { + NavigationStack { + List { + ForEach(books) { book in + NavigationLink(value: book) { + VStack(alignment: .leading) { + Text(book.title) + .font(.headline) + if let author = book.author { + Text(author) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + } + .onDelete(perform: deleteBooks) + } + .navigationTitle("Library") + .navigationDestination(for: StoredBook.self) { storedBook in + ReaderView(storedBook: storedBook) + } + .toolbar { + Button("Import", systemImage: "plus") { + showFileImporter = true + } + } + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.epub, .plainText], + allowsMultipleSelection: false + ) { result in + handleImport(result) + } + } + } + + private func handleImport(_ result: Result<[URL], Error>) { + guard case .success(let urls) = result, let url = urls.first else { return } + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + + Task { + do { + let parsed = try BookParser.parse(url: url) + try bookStore.importBook(from: url, title: parsed.title, author: parsed.author) + } catch { + print("Import failed: \(error)") + } + } + } + + private func deleteBooks(at offsets: IndexSet) { + for index in offsets { + try? bookStore.deleteBook(books[index]) + } + } +} diff --git a/Vorleser-iOS/PlaybackControls.swift b/Vorleser-iOS/PlaybackControls.swift new file mode 100644 index 0000000..337c849 --- /dev/null +++ b/Vorleser-iOS/PlaybackControls.swift @@ -0,0 +1,45 @@ +import SwiftUI +import AudioEngine + +struct PlaybackControls: View { + @Bindable var engine: AudioEngine + + var body: some View { + HStack(spacing: 32) { + Button(action: { engine.skipBackward() }) { + Image(systemName: "backward.fill") + .font(.title2) + } + .disabled(engine.state == .idle) + + Button(action: togglePlayback) { + Image(systemName: playButtonIcon) + .font(.title) + } + + Button(action: { engine.skipForward() }) { + Image(systemName: "forward.fill") + .font(.title2) + } + .disabled(engine.state == .idle) + } + .padding() + } + + private var playButtonIcon: String { + switch engine.state { + case .playing: "pause.fill" + case .synthesizing: "hourglass" + case .paused: "play.fill" + case .idle: "play.fill" + } + } + + private func togglePlayback() { + switch engine.state { + case .playing: engine.pause() + case .paused: engine.resume() + default: break + } + } +} diff --git a/Vorleser-iOS/ReaderView.swift b/Vorleser-iOS/ReaderView.swift new file mode 100644 index 0000000..b9e2ced --- /dev/null +++ b/Vorleser-iOS/ReaderView.swift @@ -0,0 +1,127 @@ +import SwiftUI +import SwiftData +import Storage +import BookParser +import AudioEngine as AudioEngineModule +import Synthesizer as SynthesizerModule +import VorleserKit + +struct ReaderView: View { + let storedBook: StoredBook + @State private var book: Book? + @State private var error: String? + @State private var engine = AudioEngineModule.AudioEngine() + @State private var synthesizer: SynthesizerModule.Synthesizer? + @State private var selectedChapterIndex: Int = 0 + @Environment(\.modelContext) private var modelContext + + private var bookStore: BookStore { + BookStore( + modelContainer: modelContext.container, + documentsDirectory: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + ) + } + + var body: some View { + VStack { + if let error { + ContentUnavailableView("Error", systemImage: "exclamationmark.triangle", description: Text(error)) + } else if let book { + chapterPicker(book: book) + readingContent(book: book) + PlaybackControls(engine: engine) + } else { + ProgressView("Loading\u{2026}") + } + } + .navigationTitle(storedBook.title) + .task { await loadBook() } + .onDisappear { + engine.stop() + try? bookStore.updatePosition(storedBook, position: 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) + } + } + .pickerStyle(.menu) + .padding(.horizontal) + } + } + + @ViewBuilder + private func readingContent(book: Book) -> some View { + let chapter = book.chapters[selectedChapterIndex] + let highlightRange = currentSentenceRange(in: book) + + ReadingTextView( + text: chapter.text, + highlightedRange: highlightRange, + onTapCharacter: { localOffset in + let globalOffset = globalOffset(forLocalOffset: localOffset, in: book) + Task { + try await startPlayback(from: globalOffset, book: book) + } + } + ) + } + + private func loadBook() async { + let fileURL = bookStore.fileURL(for: storedBook) + guard bookStore.fileExists(for: storedBook) else { + error = "Book file is missing. Please re-import." + return + } + do { + self.book = try BookParser.parse(url: fileURL) + if let book, storedBook.lastPosition > 0 { + if let (chIdx, _) = book.chapterAndLocalOffset(for: storedBook.lastPosition) { + selectedChapterIndex = chIdx + } + } + 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 SynthesizerModule.Synthesizer(voice: voice, modelURL: modelURL, voicesURL: voicesURL) + } else { + error = "TTS model files not found in app bundle." + } + } 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 onTapCharacter: (CharacterOffset) -> Void + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.isEditable = false + textView.isSelectable = false + textView.font = .preferredFont(forTextStyle: .body) + textView.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + + let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:))) + textView.addGestureRecognizer(tap) + + return textView + } + + func updateUIView(_ textView: UITextView, context: Context) { + let attributed = NSMutableAttributedString( + string: text, + attributes: [ + .font: UIFont.preferredFont(forTextStyle: .body), + .foregroundColor: UIColor.label, + ] + ) + + 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: UIColor.systemYellow.withAlphaComponent(0.3), range: nsRange) + } + + textView.attributedText = attributed + } + + func makeCoordinator() -> Coordinator { + Coordinator(onTapCharacter: onTapCharacter) + } + + class Coordinator: NSObject { + let onTapCharacter: (CharacterOffset) -> Void + + init(onTapCharacter: @escaping (CharacterOffset) -> Void) { + self.onTapCharacter = onTapCharacter + } + + @objc func handleTap(_ gesture: UITapGestureRecognizer) { + guard let textView = gesture.view as? UITextView else { return } + let point = gesture.location(in: textView) + let characterIndex = textView.offset( + from: textView.beginningOfDocument, + to: textView.closestPosition(to: point) ?? textView.beginningOfDocument + ) + if characterIndex < textView.text.count { + onTapCharacter(characterIndex) + } + } + } +} diff --git a/Vorleser-iOS/VorleserApp.swift b/Vorleser-iOS/VorleserApp.swift new file mode 100644 index 0000000..803d77b --- /dev/null +++ b/Vorleser-iOS/VorleserApp.swift @@ -0,0 +1,13 @@ +import SwiftUI +import SwiftData +import Storage + +@main +struct VorleserApp: App { + var body: some Scene { + WindowGroup { + LibraryView() + } + .modelContainer(for: StoredBook.self) + } +}