Files
Spanish/Conjuga/Conjuga/Views/Practice/Books/BookReaderView.swift
T
Trey T b97da5e85e Book reader — choose read-aloud start + play/pause/resume
- Long-press a word to mark where read-aloud begins (session-only). A
  distinct indigo marker shows the spot; long-pressing it again clears it.
  Play honors the marker on a fresh start.
- BookSpeechController can start mid-paragraph by speaking a substring;
  a per-entry wordIndexOffset keeps word highlighting aligned to the full
  paragraph's coordinates.
- The main button is now Play / Pause / Resume — it resumes in place
  instead of restarting, so pausing, flipping to English and back, then
  resuming continues from the same word. A separate Stop button ends the
  session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:08:07 -05:00

502 lines
18 KiB
Swift

import SwiftUI
import SharedModels
import SwiftData
import FoundationModels
struct BookReaderView: View {
let chapter: BookChapter
/// The book this chapter belongs to, resolved by slug used for the
/// pre-computed glossary. A @Query is safe here because the reader is
/// built lazily by `navigationDestination`: one instance, when opened.
@Query private var bookMatches: [Book]
private var book: Book? { bookMatches.first }
@Environment(DictionaryService.self) private var dictionary
@State private var speech = BookSpeechController()
@State private var selectedWord: WordAnnotation?
@State private var showEnglish = false
@State private var showVoicePicker = false
@State private var wasReadingBeforeTap = false
@State private var lookupCache: [String: WordAnnotation] = [:]
/// The book's pre-computed glossary, decoded once on appear.
@State private var glossary: [String: WordGloss] = [:]
/// The word long-pressed as the read-aloud start point. Session-only
/// consulted when starting fresh; cleared by long-pressing it again.
@State private var startAnchor: ReadingStart?
/// A chosen read-aloud start location: a word within a paragraph.
private struct ReadingStart: Equatable {
let paragraphIndex: Int
let wordIndex: Int
}
@AppStorage("bookReaderVoiceId") private var storedVoiceId: String = ""
@AppStorage("bookReaderRate") private var storedRate: Double = 0.45
init(chapter: BookChapter) {
self.chapter = chapter
let slug = chapter.bookSlug
_bookMatches = Query(filter: #Predicate<Book> { $0.slug == slug })
}
private var paragraphsES: [String] { chapter.paragraphsES() }
private var paragraphsEN: [String] { chapter.paragraphsEN() }
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 18) {
Text(chapter.title)
.font(.title2.bold())
.padding(.bottom, 4)
.id(-1)
ForEach(Array(paragraphsES.enumerated()), id: \.offset) { index, paragraph in
paragraphView(index: index, paragraph: paragraph)
.id(index)
}
}
.padding()
.adaptiveContainer(maxWidth: 800)
}
.onChange(of: speech.currentParagraphIndex) { _, newIndex in
guard let newIndex else { return }
withAnimation(.easeInOut(duration: 0.25)) {
proxy.scrollTo(newIndex, anchor: .center)
}
}
}
.navigationTitle("Chapter \(chapter.number)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Button {
showVoicePicker = true
} label: {
Image(systemName: "waveform.circle")
.symbolRenderingMode(.hierarchical)
}
.accessibilityLabel("Voice & speed")
if speech.isReading {
Button {
speech.stop()
} label: {
Image(systemName: "stop.circle")
.symbolRenderingMode(.hierarchical)
}
.accessibilityLabel("Stop reading")
}
Button {
toggleReadAloud()
} label: {
Image(systemName: playButtonIcon)
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.indigo)
}
.accessibilityLabel(playButtonLabel)
Button {
withAnimation { showEnglish.toggle() }
} label: {
Image(systemName: showEnglish ? "character.book.closed.fill.he" : "character.book.closed")
.symbolRenderingMode(.hierarchical)
}
.accessibilityLabel(showEnglish ? "Show Spanish" : "Show English")
}
}
.sheet(item: $selectedWord, onDismiss: handleSheetDismiss) { word in
WordDetailSheet(word: word)
.presentationDetents([.height(220)])
}
.sheet(isPresented: $showVoicePicker) {
BookVoicePickerSheet(voiceIdentifier: voiceBinding, rate: rateBinding)
}
.onAppear {
speech.voiceIdentifier = storedVoiceId.isEmpty ? nil : storedVoiceId
speech.rate = Float(storedRate)
if glossary.isEmpty {
glossary = book?.glossary() ?? [:]
}
}
.onDisappear {
speech.stop()
}
.sensoryFeedback(.selection, trigger: startAnchor)
}
@ViewBuilder
private func paragraphView(index: Int, paragraph: String) -> some View {
if showEnglish {
Text(translation(for: index))
.font(.body)
.foregroundStyle(.secondary)
} else {
TappableParagraph(
text: paragraph,
highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil,
startWordIndex: startAnchor?.paragraphIndex == index ? startAnchor?.wordIndex : nil,
onTap: { word in handleTap(word: word, paragraph: paragraph) },
onLongPress: { wordIndex in setStartAnchor(paragraphIndex: index, wordIndex: wordIndex) }
)
}
}
private func translation(for index: Int) -> String {
guard index < paragraphsEN.count else { return "" }
let en = paragraphsEN[index]
return en.isEmpty ? "[translation unavailable]" : en
}
// MARK: - Read-along controls
/// Main read-aloud button: starts, pauses, or resumes it never stops, so
/// the reading position survives pausing (and flipping to the English
/// translation and back). Stopping is a separate button. A fresh start
/// honors the long-pressed `startAnchor` when one is set.
private func toggleReadAloud() {
if speech.isReading {
if speech.isPaused {
speech.resume()
} else {
speech.pause()
}
} else if let anchor = startAnchor {
speech.start(
paragraphs: paragraphsES,
fromParagraph: anchor.paragraphIndex,
word: anchor.wordIndex
)
} else {
speech.start(paragraphs: paragraphsES)
}
}
private var playButtonIcon: String {
(speech.isReading && !speech.isPaused) ? "pause.circle.fill" : "play.circle.fill"
}
private var playButtonLabel: String {
guard speech.isReading else { return "Read aloud" }
return speech.isPaused ? "Resume" : "Pause"
}
/// Long-press handler: mark this word as the start point, or clear it when
/// the already-marked word is long-pressed again. Doesn't interrupt an
/// active read-aloud the anchor is used on the next fresh start.
private func setStartAnchor(paragraphIndex: Int, wordIndex: Int) {
let candidate = ReadingStart(paragraphIndex: paragraphIndex, wordIndex: wordIndex)
startAnchor = (startAnchor == candidate) ? nil : candidate
}
private var voiceBinding: Binding<String?> {
Binding(
get: { storedVoiceId.isEmpty ? nil : storedVoiceId },
set: { newValue in
storedVoiceId = newValue ?? ""
speech.voiceIdentifier = newValue
}
)
}
private var rateBinding: Binding<Float> {
Binding(
get: { Float(storedRate) },
set: { newValue in
storedRate = Double(newValue)
speech.rate = newValue
}
)
}
// MARK: - Word tap definition
private func handleTap(word: String, paragraph: String) {
let cleaned = cleanWord(word)
if cleaned.isEmpty { return }
// If reading aloud, pause immediately. Remember so we can resume when
// the user dismisses the definition sheet.
if speech.isReading, !speech.isPaused {
speech.pause()
wasReadingBeforeTap = true
}
// Fall-through chain, best source first. Whichever resource answers,
// the popup names it so a curated glossary hit reads differently from
// a best-effort on-device LLM guess.
if let cached = lookupCache[cleaned] {
selectedWord = cached
return
}
if let gloss = glossary[cleaned] {
let annotation = WordAnnotation(
word: cleaned,
baseForm: gloss.baseForm,
english: gloss.english,
partOfSpeech: gloss.partOfSpeech,
source: "Book glossary"
)
lookupCache[cleaned] = annotation
selectedWord = annotation
return
}
if let entry = dictionary.lookup(cleaned) {
let annotation = WordAnnotation(
word: cleaned,
baseForm: entry.baseForm,
english: entry.english,
partOfSpeech: entry.partOfSpeech,
source: "Dictionary"
)
lookupCache[cleaned] = annotation
selectedWord = annotation
return
}
selectedWord = WordAnnotation(word: cleaned, baseForm: cleaned, english: "Looking up...", partOfSpeech: "")
Task {
do {
var annotation = try await WordLookup.lookup(word: cleaned, inContext: paragraph)
annotation.source = "AI guess"
lookupCache[cleaned] = annotation
selectedWord = annotation
} catch {
selectedWord = WordAnnotation(word: cleaned, baseForm: cleaned, english: "Lookup unavailable", partOfSpeech: "")
}
}
}
private func handleSheetDismiss() {
guard wasReadingBeforeTap else { return }
wasReadingBeforeTap = false
speech.resume()
}
private func cleanWord(_ word: String) -> String {
word.lowercased()
.trimmingCharacters(in: .punctuationCharacters)
.trimmingCharacters(in: .whitespaces)
}
}
// MARK: - Tappable paragraph
private struct TappableParagraph: View {
let text: String
let highlightedWordIndex: Int?
let startWordIndex: Int?
let onTap: (String) -> Void
let onLongPress: (Int) -> Void
var body: some View {
let words = text.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
FlowLayout(spacing: 0) {
ForEach(Array(words.enumerated()), id: \.offset) { idx, word in
WordButton(
word: word,
isHighlighted: idx == highlightedWordIndex,
isStartAnchor: idx == startWordIndex,
onTap: onTap,
onLongPress: { onLongPress(idx) }
)
}
}
.accessibilityElement(children: .combine)
}
}
private struct WordButton: View {
let word: String
let isHighlighted: Bool
let isStartAnchor: Bool
let onTap: (String) -> Void
let onLongPress: () -> Void
var body: some View {
Button {
onTap(word)
} label: {
Text(word + " ")
.font(.body)
.foregroundStyle(.primary)
.padding(.horizontal, (isHighlighted || isStartAnchor) ? 2 : 0)
.padding(.vertical, 1)
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 4))
.animation(.easeInOut(duration: 0.15), value: isHighlighted)
.animation(.easeInOut(duration: 0.15), value: isStartAnchor)
}
.buttonStyle(.plain)
// Long-press marks the read-aloud start; the Button's tap still defines
// the word. simultaneousGesture lets both live on the same view.
.simultaneousGesture(
LongPressGesture(minimumDuration: 0.4).onEnded { _ in onLongPress() }
)
}
/// The active spoken word (yellow) takes precedence over the start marker
/// (indigo) so a word that's both reads as "now speaking."
private var backgroundColor: Color {
if isHighlighted { return Color.yellow.opacity(0.35) }
if isStartAnchor { return Color.indigo.opacity(0.20) }
return Color.clear
}
}
// MARK: - Flow layout
private struct FlowLayout: Layout {
var spacing: CGFloat = 0
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = computeRows(proposal: proposal, subviews: subviews)
var height: CGFloat = 0
for row in rows {
height += row.map { $0.height }.max() ?? 0
}
height += CGFloat(max(0, rows.count - 1)) * spacing
return CGSize(width: proposal.width ?? 0, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let rows = computeRows(proposal: proposal, subviews: subviews)
var y = bounds.minY
var subviewIndex = 0
for row in rows {
var x = bounds.minX
let rowHeight = row.map { $0.height }.max() ?? 0
for size in row {
subviews[subviewIndex].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width
subviewIndex += 1
}
y += rowHeight + spacing
}
}
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
let maxWidth = proposal.width ?? .infinity
var rows: [[CGSize]] = [[]]
var currentWidth: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if currentWidth + size.width > maxWidth && !rows[rows.count - 1].isEmpty {
rows.append([])
currentWidth = 0
}
rows[rows.count - 1].append(size)
currentWidth += size.width
}
return rows
}
}
// MARK: - Word detail sheet
private struct WordDetailSheet: View {
let word: WordAnnotation
var body: some View {
VStack(spacing: 16) {
HStack {
Text(word.word)
.font(.title2.bold())
Spacer()
if !word.partOfSpeech.isEmpty {
Text(word.partOfSpeech)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
}
Divider()
if word.english == "Looking up..." {
HStack(spacing: 8) {
ProgressView()
Text("Looking up word...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
} else {
VStack(alignment: .leading, spacing: 8) {
if !word.baseForm.isEmpty && word.baseForm != word.word {
HStack {
Text("Base form:")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(word.baseForm)
.font(.subheadline.weight(.semibold))
.italic()
}
}
if !word.english.isEmpty {
HStack {
Text("English:")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(word.english)
.font(.subheadline.weight(.semibold))
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
Spacer()
if !word.source.isEmpty {
Text(sourceLabel)
.font(.caption2)
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding()
}
private var sourceLabel: String {
word.source == "AI guess"
? "AI guess · on-device estimate, may be approximate"
: "Source: \(word.source)"
}
}
// MARK: - On-demand word lookup (matches StoryReaderView's WordLookup)
@MainActor
private enum WordLookup {
@Generable
struct WordInfo {
@Guide(description: "The dictionary base form (infinitive for verbs, singular for nouns)")
var baseForm: String
@Guide(description: "English translation")
var english: String
@Guide(description: "Part of speech: verb, noun, adjective, adverb, preposition, conjunction, article, pronoun, or other")
var partOfSpeech: String
}
static func lookup(word: String, inContext sentence: String) async throws -> WordAnnotation {
let session = LanguageModelSession(instructions: """
You are a Spanish dictionary. Given a word and the sentence it appears in, \
provide its base form, English translation, and part of speech.
""")
let response = try await session.respond(
to: "Word: \"\(word)\" in sentence: \"\(sentence)\"",
generating: WordInfo.self
)
let info = response.content
return WordAnnotation(
word: word,
baseForm: info.baseForm,
english: info.english,
partOfSpeech: info.partOfSpeech
)
}
}