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:
75
Vorleser-iOS/LibraryView.swift
Normal file
75
Vorleser-iOS/LibraryView.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
Vorleser-iOS/PlaybackControls.swift
Normal file
45
Vorleser-iOS/PlaybackControls.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
127
Vorleser-iOS/ReaderView.swift
Normal file
127
Vorleser-iOS/ReaderView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
65
Vorleser-iOS/ReadingTextView.swift
Normal file
65
Vorleser-iOS/ReadingTextView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Vorleser-iOS/VorleserApp.swift
Normal file
13
Vorleser-iOS/VorleserApp.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user