2 Commits

Author SHA1 Message Date
Trey T 32395bac5d Book reader — speed as a 5-option dropdown with multiplier labels
Replace the 3-way segmented speed control with a dropdown menu offering
0.5× / 0.75× / 1× / 1.25× / 1.5×, with evened-out underlying AVSpeech
rates anchored at 1× = 0.50. Align the default saved rate to 0.50 so 1×
is selected on a fresh install (was 0.45, which matched no option).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:20:58 -05:00
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
3 changed files with 129 additions and 28 deletions
@@ -43,6 +43,12 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
let paragraphIndex: Int let paragraphIndex: Int
let text: String let text: String
let wordRanges: [Range<String.Index>] 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() { override init() {
@@ -55,17 +61,40 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
/// `currentParagraphIndex` are positions in the original `paragraphs` /// `currentParagraphIndex` are positions in the original `paragraphs`
/// array vocab lines are skipped internally but the visible index space /// array vocab lines are skipped internally but the visible index space
/// matches what the caller passed. /// 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() stop()
configureAudioSession() configureAudioSession()
var entries: [QueueEntry] = [] var entries: [QueueEntry] = []
for (idx, p) in paragraphs.enumerated() where idx >= startIndex { for (idx, p) in paragraphs.enumerated() where idx >= startIndex {
if Self.isVocabLine(p) { continue } 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( entries.append(QueueEntry(
paragraphIndex: idx, paragraphIndex: idx,
text: p, text: p,
wordRanges: Self.wordRanges(in: p) wordRanges: Self.wordRanges(in: p),
wordIndexOffset: 0
)) ))
} }
guard !entries.isEmpty else { return } guard !entries.isEmpty else { return }
@@ -189,8 +218,11 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
let idx = entry.wordRanges.firstIndex { let idx = entry.wordRanges.firstIndex {
$0.lowerBound <= lower && lower < $0.upperBound $0.lowerBound <= lower && lower < $0.upperBound
} }
if let idx, idx != currentWordIndex { if let idx {
currentWordIndex = idx let reported = entry.wordIndexOffset + idx
if reported != currentWordIndex {
currentWordIndex = reported
}
} }
} }
@@ -21,9 +21,18 @@ struct BookReaderView: View {
@State private var lookupCache: [String: WordAnnotation] = [:] @State private var lookupCache: [String: WordAnnotation] = [:]
/// The book's pre-computed glossary, decoded once on appear. /// The book's pre-computed glossary, decoded once on appear.
@State private var glossary: [String: WordGloss] = [:] @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("bookReaderVoiceId") private var storedVoiceId: String = ""
@AppStorage("bookReaderRate") private var storedRate: Double = 0.45 @AppStorage("bookReaderRate") private var storedRate: Double = 0.50
init(chapter: BookChapter) { init(chapter: BookChapter) {
self.chapter = chapter self.chapter = chapter
@@ -70,14 +79,24 @@ struct BookReaderView: View {
} }
.accessibilityLabel("Voice & speed") .accessibilityLabel("Voice & speed")
if speech.isReading {
Button {
speech.stop()
} label: {
Image(systemName: "stop.circle")
.symbolRenderingMode(.hierarchical)
}
.accessibilityLabel("Stop reading")
}
Button { Button {
toggleReadAloud() toggleReadAloud()
} label: { } label: {
Image(systemName: speech.isReading ? "stop.circle.fill" : "play.circle.fill") Image(systemName: playButtonIcon)
.symbolRenderingMode(.hierarchical) .symbolRenderingMode(.hierarchical)
.foregroundStyle(.indigo) .foregroundStyle(.indigo)
} }
.accessibilityLabel(speech.isReading ? "Stop reading" : "Read aloud") .accessibilityLabel(playButtonLabel)
Button { Button {
withAnimation { showEnglish.toggle() } withAnimation { showEnglish.toggle() }
@@ -105,6 +124,7 @@ struct BookReaderView: View {
.onDisappear { .onDisappear {
speech.stop() speech.stop()
} }
.sensoryFeedback(.selection, trigger: startAnchor)
} }
@ViewBuilder @ViewBuilder
@@ -116,10 +136,11 @@ struct BookReaderView: View {
} else { } else {
TappableParagraph( TappableParagraph(
text: paragraph, text: paragraph,
highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil,
) { word in startWordIndex: startAnchor?.paragraphIndex == index ? startAnchor?.wordIndex : nil,
handleTap(word: word, paragraph: paragraph) 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 // 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() { private func toggleReadAloud() {
if speech.isReading { 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 { } 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) 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?> { private var voiceBinding: Binding<String?> {
Binding( Binding(
get: { storedVoiceId.isEmpty ? nil : storedVoiceId }, get: { storedVoiceId.isEmpty ? nil : storedVoiceId },
@@ -238,13 +286,21 @@ struct BookReaderView: View {
private struct TappableParagraph: View { private struct TappableParagraph: View {
let text: String let text: String
let highlightedWordIndex: Int? let highlightedWordIndex: Int?
let startWordIndex: Int?
let onTap: (String) -> Void let onTap: (String) -> Void
let onLongPress: (Int) -> Void
var body: some View { var body: some View {
let words = text.split(separator: " ", omittingEmptySubsequences: true).map(String.init) let words = text.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
FlowLayout(spacing: 0) { FlowLayout(spacing: 0) {
ForEach(Array(words.enumerated()), id: \.offset) { idx, word in 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) .accessibilityElement(children: .combine)
@@ -254,7 +310,9 @@ private struct TappableParagraph: View {
private struct WordButton: View { private struct WordButton: View {
let word: String let word: String
let isHighlighted: Bool let isHighlighted: Bool
let isStartAnchor: Bool
let onTap: (String) -> Void let onTap: (String) -> Void
let onLongPress: () -> Void
var body: some View { var body: some View {
Button { Button {
@@ -263,17 +321,26 @@ private struct WordButton: View {
Text(word + " ") Text(word + " ")
.font(.body) .font(.body)
.foregroundStyle(.primary) .foregroundStyle(.primary)
.padding(.horizontal, isHighlighted ? 2 : 0) .padding(.horizontal, (isHighlighted || isStartAnchor) ? 2 : 0)
.padding(.vertical, 1) .padding(.vertical, 1)
.background( .background(backgroundColor, in: RoundedRectangle(cornerRadius: 4))
isHighlighted
? Color.yellow.opacity(0.35)
: Color.clear,
in: RoundedRectangle(cornerRadius: 4)
)
.animation(.easeInOut(duration: 0.15), value: isHighlighted) .animation(.easeInOut(duration: 0.15), value: isHighlighted)
.animation(.easeInOut(duration: 0.15), value: isStartAnchor)
} }
.buttonStyle(.plain) .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
} }
} }
@@ -42,11 +42,13 @@ struct BookVoicePickerSheet: View {
Form { Form {
Section("Speed") { Section("Speed") {
Picker("Speed", selection: $rate) { Picker("Speed", selection: $rate) {
Text("Slow").tag(Float(0.40)) Text("0.5×").tag(Float(0.30))
Text("Normal").tag(Float(0.50)) Text("0.75×").tag(Float(0.40))
Text("Fast").tag(Float(0.55)) Text("1×").tag(Float(0.50))
Text("1.25×").tag(Float(0.575))
Text("1.5×").tag(Float(0.65))
} }
.pickerStyle(.segmented) .pickerStyle(.menu)
} }
if groups.isEmpty { if groups.isEmpty {