Compare commits
3 Commits
0ad448a600
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b8c966685 | |||
| 32395bac5d | |||
| b97da5e85e |
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user