add iOS app shell with library, reader, tap-to-play, playback controls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 22:25:36 +01:00
parent 6a8a290672
commit b52bf532f3
5 changed files with 325 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
import SwiftUI
import UIKit
import VorleserKit
struct ReadingTextView: UIViewRepresentable {
let text: String
let highlightedRange: Range<Int>?
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)
}
}
}
}

View File

@@ -0,0 +1,13 @@
import SwiftUI
import SwiftData
import Storage
@main
struct VorleserApp: App {
var body: some Scene {
WindowGroup {
LibraryView()
}
.modelContainer(for: StoredBook.self)
}
}