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>
This commit is contained in:
Trey T
2026-06-04 23:08:07 -05:00
parent aab64116b3
commit b97da5e85e
2 changed files with 122 additions and 23 deletions
@@ -43,6 +43,12 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
let paragraphIndex: Int
let text: String
let wordRanges: [Range<String.Index>]
/// Words skipped at the front of this paragraph when the caller asked to
/// start mid-paragraph. The utterance is built from a substring, so the
/// synth's word indices are local to that substring; add this offset to
/// report a word index in the full paragraph's coordinate space (which
/// is what the view highlights against). 0 for whole-paragraph entries.
let wordIndexOffset: Int
}
override init() {
@@ -55,17 +61,40 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
/// `currentParagraphIndex` are positions in the original `paragraphs`
/// array vocab lines are skipped internally but the visible index space
/// matches what the caller passed.
func start(paragraphs: [String], from startIndex: Int = 0) {
func start(paragraphs: [String], fromParagraph startIndex: Int = 0, word startWordIndex: Int? = nil) {
stop()
configureAudioSession()
var entries: [QueueEntry] = []
for (idx, p) in paragraphs.enumerated() where idx >= startIndex {
if Self.isVocabLine(p) { continue }
// Apply the word offset only to the first paragraph actually read,
// and only when it's the paragraph the caller pointed at. A skipped
// vocab line at startIndex, word 0, or an out-of-range index all
// fall through to reading the whole paragraph.
if entries.isEmpty,
idx == startIndex,
let startWord = startWordIndex,
startWord > 0 {
let fullRanges = Self.wordRanges(in: p)
if startWord < fullRanges.count {
let substring = String(p[fullRanges[startWord].lowerBound...])
entries.append(QueueEntry(
paragraphIndex: idx,
text: substring,
wordRanges: Self.wordRanges(in: substring),
wordIndexOffset: startWord
))
continue
}
}
entries.append(QueueEntry(
paragraphIndex: idx,
text: p,
wordRanges: Self.wordRanges(in: p)
wordRanges: Self.wordRanges(in: p),
wordIndexOffset: 0
))
}
guard !entries.isEmpty else { return }
@@ -189,8 +218,11 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
let idx = entry.wordRanges.firstIndex {
$0.lowerBound <= lower && lower < $0.upperBound
}
if let idx, idx != currentWordIndex {
currentWordIndex = idx
if let idx {
let reported = entry.wordIndexOffset + idx
if reported != currentWordIndex {
currentWordIndex = reported
}
}
}
@@ -21,6 +21,15 @@ struct BookReaderView: View {
@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
@@ -70,14 +79,24 @@ struct BookReaderView: View {
}
.accessibilityLabel("Voice & speed")
if speech.isReading {
Button {
speech.stop()
} label: {
Image(systemName: "stop.circle")
.symbolRenderingMode(.hierarchical)
}
.accessibilityLabel("Stop reading")
}
Button {
toggleReadAloud()
} label: {
Image(systemName: speech.isReading ? "stop.circle.fill" : "play.circle.fill")
Image(systemName: playButtonIcon)
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.indigo)
}
.accessibilityLabel(speech.isReading ? "Stop reading" : "Read aloud")
.accessibilityLabel(playButtonLabel)
Button {
withAnimation { showEnglish.toggle() }
@@ -105,6 +124,7 @@ struct BookReaderView: View {
.onDisappear {
speech.stop()
}
.sensoryFeedback(.selection, trigger: startAnchor)
}
@ViewBuilder
@@ -116,10 +136,11 @@ struct BookReaderView: View {
} else {
TappableParagraph(
text: paragraph,
highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil
) { word in
handleTap(word: word, paragraph: 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) }
)
}
}
@@ -131,18 +152,45 @@ struct BookReaderView: View {
// 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 {
speech.stop()
if speech.isPaused {
speech.resume()
} else {
speech.pause()
}
} else if let anchor = startAnchor {
speech.start(
paragraphs: paragraphsES,
fromParagraph: anchor.paragraphIndex,
word: anchor.wordIndex
)
} else {
// Start from the first non-vocab paragraph at or after the topmost
// visible one. For V1 we start from the chapter top adding
// "start from visible paragraph" would need a scroll-position
// observer, which isn't worth the complexity yet.
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 },
@@ -238,13 +286,21 @@ struct BookReaderView: View {
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, onTap: onTap)
WordButton(
word: word,
isHighlighted: idx == highlightedWordIndex,
isStartAnchor: idx == startWordIndex,
onTap: onTap,
onLongPress: { onLongPress(idx) }
)
}
}
.accessibilityElement(children: .combine)
@@ -254,7 +310,9 @@ private struct TappableParagraph: View {
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 {
@@ -263,17 +321,26 @@ private struct WordButton: View {
Text(word + " ")
.font(.body)
.foregroundStyle(.primary)
.padding(.horizontal, isHighlighted ? 2 : 0)
.padding(.horizontal, (isHighlighted || isStartAnchor) ? 2 : 0)
.padding(.vertical, 1)
.background(
isHighlighted
? Color.yellow.opacity(0.35)
: Color.clear,
in: RoundedRectangle(cornerRadius: 4)
)
.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
}
}