diff --git a/Conjuga/Conjuga/Services/BookSpeechController.swift b/Conjuga/Conjuga/Services/BookSpeechController.swift index 91c9b0d..f90a5c1 100644 --- a/Conjuga/Conjuga/Services/BookSpeechController.swift +++ b/Conjuga/Conjuga/Services/BookSpeechController.swift @@ -43,6 +43,12 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate { let paragraphIndex: Int let text: String let wordRanges: [Range] + /// 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 + } } } diff --git a/Conjuga/Conjuga/Views/Practice/Books/BookReaderView.swift b/Conjuga/Conjuga/Views/Practice/Books/BookReaderView.swift index 598dc8f..4eba483 100644 --- a/Conjuga/Conjuga/Views/Practice/Books/BookReaderView.swift +++ b/Conjuga/Conjuga/Views/Practice/Books/BookReaderView.swift @@ -21,9 +21,18 @@ 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 + @AppStorage("bookReaderRate") private var storedRate: Double = 0.50 init(chapter: BookChapter) { self.chapter = chapter @@ -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 { 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 } } diff --git a/Conjuga/Conjuga/Views/Practice/Books/BookVoicePickerSheet.swift b/Conjuga/Conjuga/Views/Practice/Books/BookVoicePickerSheet.swift index 25d4e21..24da951 100644 --- a/Conjuga/Conjuga/Views/Practice/Books/BookVoicePickerSheet.swift +++ b/Conjuga/Conjuga/Views/Practice/Books/BookVoicePickerSheet.swift @@ -42,11 +42,13 @@ struct BookVoicePickerSheet: View { Form { Section("Speed") { Picker("Speed", selection: $rate) { - Text("Slow").tag(Float(0.40)) - Text("Normal").tag(Float(0.50)) - Text("Fast").tag(Float(0.55)) + Text("0.5×").tag(Float(0.30)) + Text("0.75×").tag(Float(0.40)) + 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 {