Preview the next exercise and count into each set during rests
The run flow's between-exercise rest now previews the next exercise — its
figure and a large "Coming up" name — and the hands-free narration gains a
per-second count-in ("<Exercise>, in 1, 2, 3, GO!") spoken before every set,
plus a "Coming up" announcement as a between-exercise rest begins. A new
Settings > Narration picker chooses the count-in cues, the setup/form read,
or both.
Also fixes spoken cues going silent after the first exercise in a flow split:
the stop-on-teardown moved from the per-exercise view (rebuilt on every
hand-off) to the run host, which stays mounted for the whole run. The audio
session now holds its duck across the per-second count so background music
doesn't pulse between words.
Claude-Session: https://claude.ai/code/session_01BQcEWmAPA78338QuEwRkAh
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
**July 2026**
|
||||
|
||||
While you rest between exercises, the workout screen now previews the next exercise's figure and name so you can see what's coming up.
|
||||
|
||||
Spoken cues can now announce the next exercise during a rest and count you into every set with "in 1, 2, 3, GO!".
|
||||
|
||||
A new Narration setting picks whether spoken cues give the coming-up countdown, the full setup and form read, or both.
|
||||
|
||||
Spoken cues no longer stop after the first exercise in an auto-advancing workout.
|
||||
|
||||
The watch now starts recording on its own whenever a workout is active, so a missed hand-off from the iPhone no longer leaves a session without heart rate or Health credit.
|
||||
|
||||
A workout recording now survives the watch app crashing or the watch restarting, reconnecting to the in-progress session instead of losing it.
|
||||
|
||||
@@ -44,14 +44,19 @@ your own iCloud Drive.
|
||||
Library's rig data with the working limbs highlighted — the camera slowly
|
||||
orbits every exercise while it moves, machine equipment (seats, bars, cables,
|
||||
roller pads) has world-space 3D form that turns with the figure, and face-on
|
||||
machines show true hip abduction and torso rotation.
|
||||
machines show true hip abduction and torso rotation. While you rest between
|
||||
exercises in an auto-advancing split, the figure previews the next exercise under
|
||||
a **Coming up** label, so you see what's next before it starts.
|
||||
- **Spoken instructions** — tap the speaker in any library exercise to hear its
|
||||
full reference read aloud, and turn on **Speak Exercise Cues** in Settings to
|
||||
have the setup and form cues spoken automatically when you start an exercise, so
|
||||
you can follow along hands-free. On-device speech that ducks — never stops — your
|
||||
music. A **Voice** section in Settings picks the voice (preferring any enhanced or
|
||||
premium voices you've installed) and tunes its speed, pitch, and volume, with a
|
||||
live preview.
|
||||
narrate a running workout hands-free. A **Narration** setting chooses the style:
|
||||
*Coming Up & Countdown* announces the next exercise during a rest and counts you
|
||||
into every set (the upcoming exercise, then "in 1, 2, 3, GO!", one word per beat),
|
||||
*Setup & Form Read* speaks each exercise's cues as it starts, or *Both*. On-device
|
||||
speech that ducks — never stops — your music, holding the duck across the count so
|
||||
it doesn't pulse between words. A **Voice** section in Settings picks the voice
|
||||
(preferring any enhanced or premium voices you've installed) and tunes its speed,
|
||||
pitch, and volume, with a live preview.
|
||||
- **Machine comfort settings** — machine-based exercises remember your setup
|
||||
(seat height, back-rest position, pin position, …), shown on the workout
|
||||
screen and editable mid-workout; changes save back to the split for next time.
|
||||
|
||||
@@ -26,16 +26,25 @@ final class SpeechAnnouncer: NSObject, AVSpeechSynthesizerDelegate {
|
||||
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
|
||||
/// While true, `deactivateSession` won't hand audio focus back when an utterance ends —
|
||||
/// so a per-second spoken countdown ("in 1… 2… 3… Go") stays ducked throughout instead
|
||||
/// of pulsing other audio back to full in the gaps between counts. Cleared by the final
|
||||
/// (non-holding) utterance of a run and by `stop`.
|
||||
private var holdsSession = false
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
synthesizer.delegate = self
|
||||
}
|
||||
|
||||
/// Speak `text`, replacing anything currently playing. Empty/blank text is a no-op.
|
||||
func speak(_ text: String) {
|
||||
/// Pass `holdSession: true` for all but the last of a rapid back-to-back sequence so
|
||||
/// other audio stays ducked between the utterances rather than un-ducking in each gap.
|
||||
func speak(_ text: String, holdSession: Bool = false) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
holdsSession = holdSession
|
||||
activateSession()
|
||||
// Replace, don't queue: a new exercise's cue supersedes the previous one.
|
||||
if synthesizer.isSpeaking {
|
||||
@@ -56,6 +65,7 @@ final class SpeechAnnouncer: NSObject, AVSpeechSynthesizerDelegate {
|
||||
|
||||
/// Stop immediately and hand audio focus back to other apps.
|
||||
func stop() {
|
||||
holdsSession = false
|
||||
if synthesizer.isSpeaking {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
}
|
||||
@@ -73,10 +83,11 @@ final class SpeechAnnouncer: NSObject, AVSpeechSynthesizerDelegate {
|
||||
try? session.setActive(true)
|
||||
}
|
||||
|
||||
/// Return other audio to full volume. Skipped while something is still speaking, so a
|
||||
/// cancel-then-speak (new exercise) doesn't briefly un-duck between utterances.
|
||||
/// Return other audio to full volume. Skipped while something is still speaking, or while
|
||||
/// a countdown holds the session, so a cancel-then-speak (new exercise) or a per-second
|
||||
/// count doesn't briefly un-duck between utterances.
|
||||
private func deactivateSession() {
|
||||
guard !synthesizer.isSpeaking else { return }
|
||||
guard !synthesizer.isSpeaking, !holdsSession else { return }
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,35 @@ enum SpeechSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the automatic workout narration says while a flow-mode split runs, chosen in
|
||||
/// Settings (only meaningful when "Speak Exercise Cues" is on). Stored as its raw string
|
||||
/// under `spokenCueStyle`, read by both the Settings picker and the run flow.
|
||||
enum SpokenCueStyle: String, CaseIterable, Identifiable {
|
||||
/// "Coming up, <exercise>" during the rest, then "in 1, 2, 3, GO!" before each set.
|
||||
case cues
|
||||
/// Reads each exercise's setup & form script aloud when it starts (the original behavior).
|
||||
case instructions
|
||||
/// Both — the concise coming-up/countdown cues plus the setup & form read.
|
||||
case both
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Picker label.
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .cues: "Coming Up & Countdown"
|
||||
case .instructions: "Setup & Form Read"
|
||||
case .both: "Both"
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this style speaks the concise "coming up" / "1, 2, 3, GO!" cues.
|
||||
var speaksCues: Bool { self == .cues || self == .both }
|
||||
|
||||
/// Whether this style reads each exercise's setup & form script on start.
|
||||
var readsInstructions: Bool { self == .instructions || self == .both }
|
||||
}
|
||||
|
||||
private extension AVSpeechSynthesisVoiceQuality {
|
||||
/// Orders the quality tiers for "best available" selection and picker sorting.
|
||||
var rank: Int {
|
||||
|
||||
@@ -22,6 +22,7 @@ struct SettingsView: View {
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
@AppStorage("figureProfile") private var figureProfile: FigureProfile = .neutral
|
||||
@AppStorage("speakExerciseCues") private var speakExerciseCues = false
|
||||
@AppStorage("spokenCueStyle") private var spokenCueStyle: SpokenCueStyle = .cues
|
||||
@AppStorage(SpeechSettings.voiceIdentifierKey) private var speechVoiceIdentifier = ""
|
||||
|
||||
@State private var isRestoringSeeds = false
|
||||
@@ -222,6 +223,14 @@ struct SettingsView: View {
|
||||
|
||||
Toggle("Speak Exercise Cues", isOn: $speakExerciseCues)
|
||||
|
||||
if speakExerciseCues {
|
||||
Picker("Narration", selection: $spokenCueStyle) {
|
||||
ForEach(SpokenCueStyle.allCases) { style in
|
||||
Text(style.displayName).tag(style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Voice", selection: $speechVoiceIdentifier) {
|
||||
Text("Automatic").tag("")
|
||||
ForEach(installedVoices, id: \.identifier) { voice in
|
||||
@@ -257,7 +266,7 @@ struct SettingsView: View {
|
||||
} header: {
|
||||
Text("Voice")
|
||||
} footer: {
|
||||
Text("Speak Exercise Cues reads each exercise's setup and form cues aloud when you start it; tap a library exercise's speaker to hear its full instructions anytime.")
|
||||
Text("Speak Exercise Cues narrates a running workout hands-free. Coming Up & Countdown announces the next exercise during a rest and counts “in 1, 2, 3, GO!” before each set; Setup & Form Read reads each exercise's cues aloud when it starts. Tap a library exercise's speaker to hear its full instructions anytime.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,10 @@ struct ExerciseProgressView: View {
|
||||
/// always available.
|
||||
@AppStorage("speakExerciseCues") private var speakExerciseCues = false
|
||||
|
||||
/// What the automatic narration says (only when `speakExerciseCues` is on): the concise
|
||||
/// coming-up/countdown cues, the setup & form read, or both.
|
||||
@AppStorage("spokenCueStyle") private var spokenCueStyle: SpokenCueStyle = .cues
|
||||
|
||||
/// Auto-Done countdown on the Finish page — read so a broadcast frame carries the same
|
||||
/// end anchor the watch's mirror counts off.
|
||||
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||
@@ -211,6 +215,27 @@ struct ExerciseProgressView: View {
|
||||
}?.id
|
||||
}
|
||||
|
||||
/// Name of the next not-yet-resolved exercise (the flow hand-off target), or nil when
|
||||
/// this is the last one. Drives the between-exercise rest's "Coming up …" preview.
|
||||
private var nextExerciseName: String? {
|
||||
guard let id = nextUnfinishedLogID else { return nil }
|
||||
return doc.logs.first { $0.id == id }?.exerciseName
|
||||
}
|
||||
|
||||
/// The between-exercise rest — flow mode's terminal page when another exercise follows.
|
||||
/// This is the only rest whose "next" is a *different* exercise, so it's the one that
|
||||
/// previews the next figure and speaks "Coming up …".
|
||||
private var isOnBetweenExerciseRest: Bool {
|
||||
autoAdvance && nextUnfinishedLogID != nil && currentPage == base + cycleCount
|
||||
}
|
||||
|
||||
/// Which exercise the bottom-half figure should show: the *next* one while resting
|
||||
/// between exercises (so you see what's coming up), otherwise this exercise.
|
||||
private var figureExerciseName: String {
|
||||
if isOnBetweenExerciseRest, let next = nextExerciseName { return next }
|
||||
return log?.exerciseName ?? ""
|
||||
}
|
||||
|
||||
/// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`.
|
||||
private var base: Int { showsReady ? 1 : 0 }
|
||||
|
||||
@@ -328,9 +353,11 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom half: the looping form-guide figure when a bundled motion
|
||||
// matches this exercise; empty space otherwise.
|
||||
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
|
||||
// Bottom half: the looping form-guide figure. Shows the *next* exercise while
|
||||
// resting between exercises (a live preview of what's coming up), otherwise this
|
||||
// one. The name swap re-loads the figure and carries through the hand-off, so it
|
||||
// stays put as the next exercise takes over.
|
||||
ExerciseFigureSlot(exerciseName: figureExerciseName)
|
||||
}
|
||||
.navigationTitle(log?.exerciseName ?? "")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -409,10 +436,12 @@ struct ExerciseProgressView: View {
|
||||
.onDisappear {
|
||||
// Leaving the flow (back / done) — stop the watch from following and dismiss the
|
||||
// Live Activity (it lingers briefly on a completed run before auto-dismissing).
|
||||
// Speech is *not* stopped here: this view is torn down and rebuilt on every
|
||||
// flow hand-off (`.id(currentLogID)`), so stopping would cut the incoming
|
||||
// exercise's cue. The run host (`RunFlowView`) silences narration when the
|
||||
// whole run leaves instead.
|
||||
onLiveEnded()
|
||||
onActivityEnded()
|
||||
// Don't keep narrating a screen the user has left.
|
||||
speechAnnouncer?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,7 +615,10 @@ struct ExerciseProgressView: View {
|
||||
seconds: effectiveRest,
|
||||
isActive: isActive,
|
||||
anchorStart: anchorStart,
|
||||
anchorEnd: anchorEnd
|
||||
anchorEnd: anchorEnd,
|
||||
comingUpName: nextExerciseName,
|
||||
onActivate: announceComingUp,
|
||||
onCountdownBeat: announceCountdownBeat
|
||||
) { _ in
|
||||
completeExercise()
|
||||
// Re-resolve at fire time — the next exercise may have been
|
||||
@@ -648,7 +680,8 @@ struct ExerciseProgressView: View {
|
||||
seconds: effectiveRest,
|
||||
isActive: isActive,
|
||||
anchorStart: anchorStart,
|
||||
anchorEnd: anchorEnd
|
||||
anchorEnd: anchorEnd,
|
||||
onCountdownBeat: announceCountdownBeat
|
||||
) { end in
|
||||
withAnimation { advance(from: index, phaseEndedAt: end) }
|
||||
}
|
||||
@@ -720,14 +753,51 @@ struct ExerciseProgressView: View {
|
||||
withAnimation { currentPage = base }
|
||||
}
|
||||
|
||||
/// Speak the exercise's setup/form cues when it starts, if the user opted in. Silent
|
||||
/// when the announcer isn't wired (screenshot host) or the exercise has no bundled info.
|
||||
/// True when narration is on and the chosen style speaks the concise coming-up/countdown
|
||||
/// cues. Also requires a wired announcer (nil in the screenshot host).
|
||||
private var speaksCues: Bool {
|
||||
speakExerciseCues && spokenCueStyle.speaksCues && speechAnnouncer != nil
|
||||
}
|
||||
|
||||
/// Speak the exercise's setup/form cues when it starts, if the user opted into a style
|
||||
/// that reads them. Silent when the announcer isn't wired (screenshot host) or the
|
||||
/// exercise has no bundled info.
|
||||
private func announceCue() {
|
||||
guard speakExerciseCues, let announcer = speechAnnouncer, let log else { return }
|
||||
guard speakExerciseCues, spokenCueStyle.readsInstructions,
|
||||
let announcer = speechAnnouncer, let log else { return }
|
||||
guard let info = ExerciseInfoLibrary.info(for: log.exerciseName) else { return }
|
||||
announcer.speak(info.spokenScript(name: log.exerciseName, brief: true))
|
||||
}
|
||||
|
||||
/// Announce the exercise coming up after a between-exercise rest ("Coming up, Pull Ups").
|
||||
/// Spoken as the rest begins, so it lands well before the next exercise starts.
|
||||
private func announceComingUp() {
|
||||
guard speaksCues, let announcer = speechAnnouncer, let next = nextExerciseName else { return }
|
||||
announcer.speak("Coming up. \(next).")
|
||||
}
|
||||
|
||||
/// Count into the next work phase, one word per second synced to the countdown's final
|
||||
/// beats: the upcoming exercise's name (4s left) → "in one" (3s) → "two" → "three" →
|
||||
/// "Go!" as the timer reaches zero — e.g. "Torso Twist … in one, two, three, GO". The
|
||||
/// name gets its own beat so even a long one is heard before the count. Every beat but
|
||||
/// "Go" holds the audio session so background music doesn't pulse between them; "Go"
|
||||
/// releases it, and (like any cue) plays to completion even if the rest ends mid-word.
|
||||
/// `remaining` is the whole seconds left (0 at the boundary).
|
||||
private func announceCountdownBeat(_ remaining: Int) {
|
||||
guard speaksCues, let announcer = speechAnnouncer else { return }
|
||||
switch remaining {
|
||||
case 4:
|
||||
// The exercise about to be worked: the next one across a between-exercise rest,
|
||||
// otherwise this one. Skipped when unknown (never blocks the count that follows).
|
||||
let name = figureExerciseName
|
||||
if !name.isEmpty { announcer.speak(name + ".", holdSession: true) }
|
||||
case 3: announcer.speak("In one.", holdSession: true)
|
||||
case 2: announcer.speak("Two.", holdSession: true)
|
||||
case 1: announcer.speak("Three.", holdSession: true)
|
||||
default: announcer.speak("Go!") // the boundary — release the duck for the work phase
|
||||
}
|
||||
}
|
||||
|
||||
/// Programmatically move one page right when a countdown phase ends, guarding against
|
||||
/// overrun if the user swiped away in the meantime. Tagged `.auto` so the page observer
|
||||
/// records progress but doesn't broadcast it (the watch auto-advances too).
|
||||
@@ -1021,17 +1091,35 @@ private struct PhaseTimerLayout<Content: View>: View {
|
||||
let header: String
|
||||
let footer: String
|
||||
let tint: Color
|
||||
/// When set, the header row becomes a two-line "Coming up" / exercise-name preview,
|
||||
/// with the name in larger type — used by the between-exercise rest.
|
||||
var comingUpName: String? = nil
|
||||
@ViewBuilder var timer: Content
|
||||
|
||||
/// Scales the big timer digits with Dynamic Type (falls back to `.minimumScaleFactor`
|
||||
/// so the largest sizes never overflow the top half).
|
||||
@ScaledMetric(relativeTo: .largeTitle) private var timerFontSize: CGFloat = 108
|
||||
/// Scales the "coming up" exercise name — large, but well under the timer.
|
||||
@ScaledMetric(relativeTo: .largeTitle) private var comingUpFontSize: CGFloat = 34
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Text(header)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
if let comingUpName {
|
||||
VStack(spacing: 2) {
|
||||
Text("Coming up")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(comingUpName)
|
||||
.font(.system(size: comingUpFontSize, weight: .bold, design: .rounded))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
} else {
|
||||
Text(header)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
timer
|
||||
.font(.system(size: timerFontSize, weight: .bold, design: .rounded))
|
||||
@@ -1284,6 +1372,15 @@ private struct CountdownPhaseView: View {
|
||||
/// so the remaining time — and the auto-advance at zero — line up across both devices.
|
||||
var anchorStart: Date? = nil
|
||||
var anchorEnd: Date? = nil
|
||||
/// When set, the header becomes a "Coming up / <name>" preview (between-exercise rest).
|
||||
var comingUpName: String? = nil
|
||||
/// Fired once when the countdown genuinely begins (not a slept-through catch-up) — the
|
||||
/// spoken "Coming up …" cue hangs off this.
|
||||
var onActivate: (() -> Void)? = nil
|
||||
/// Fired once per second through the count-in — the seconds remaining (4 for the exercise
|
||||
/// name, then 3, 2, 1), then 0 at the boundary — so a spoken count ("Torso Twist… in one…
|
||||
/// two… three… Go!") lands one word per beat, in step with the haptics. Nil skips it.
|
||||
var onCountdownBeat: ((Int) -> Void)? = nil
|
||||
/// Invoked once the countdown reaches zero (auto-advance to the next page), passing the
|
||||
/// phase's *computed* end so the next phase can anchor at the boundary itself — not at
|
||||
/// whenever this tick got runtime (a device asleep at the boundary ticks late).
|
||||
@@ -1302,7 +1399,7 @@ private struct CountdownPhaseView: View {
|
||||
private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
PhaseTimerLayout(header: header, footer: footer, tint: tint) {
|
||||
PhaseTimerLayout(header: header, footer: footer, tint: tint, comingUpName: comingUpName) {
|
||||
Text(timerInterval: startDate...endDate, countsDown: true)
|
||||
}
|
||||
.onAppear { if isActive { start(haptic: true) } }
|
||||
@@ -1317,9 +1414,12 @@ private struct CountdownPhaseView: View {
|
||||
endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds)))
|
||||
lastPingSecond = Int.max
|
||||
didFinish = false
|
||||
// No buzz for a chained catch-up page whose whole window already elapsed while
|
||||
// the device slept — it advances again on its next tick.
|
||||
if haptic, endDate > Date() { WorkoutHaptic.start.play() }
|
||||
// No buzz — or spoken cue — for a chained catch-up page whose whole window already
|
||||
// elapsed while the device slept; it advances again on its next tick.
|
||||
if haptic, endDate > Date() {
|
||||
WorkoutHaptic.start.play()
|
||||
onActivate?()
|
||||
}
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
@@ -1329,14 +1429,19 @@ private struct CountdownPhaseView: View {
|
||||
|
||||
if remaining <= 0 {
|
||||
didFinish = true
|
||||
// Buzz only for a boundary that just happened — fast-forwarding through
|
||||
// boundaries that passed while the device slept stays silent.
|
||||
if Date().timeIntervalSince(endDate) < 3 { WorkoutHaptic.stop.play() }
|
||||
// Buzz and speak the final "Go" only for a boundary that just happened —
|
||||
// fast-forwarding through boundaries that passed while the device slept stays silent.
|
||||
if Date().timeIntervalSince(endDate) < 3 {
|
||||
WorkoutHaptic.stop.play()
|
||||
onCountdownBeat?(0)
|
||||
}
|
||||
onFinished(endDate)
|
||||
} else if remaining <= 3 && remaining < lastPingSecond {
|
||||
// Once-per-second countdown ping for the final three seconds.
|
||||
} else if remaining <= 4 && remaining < lastPingSecond {
|
||||
// One spoken beat per second through the count-in: the exercise name at 4s, then
|
||||
// the count. The haptic ping stays on the final three seconds only.
|
||||
lastPingSecond = remaining
|
||||
WorkoutHaptic.tick.play()
|
||||
if remaining <= 3 { WorkoutHaptic.tick.play() }
|
||||
onCountdownBeat?(remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,13 @@ struct RunFlowView: View {
|
||||
.id(currentLogID)
|
||||
}
|
||||
.onAppear { liveRun.navigatedRunID = currentLogID }
|
||||
.onDisappear { if liveRun.navigatedRunID == currentLogID { liveRun.navigatedRunID = nil } }
|
||||
.onDisappear {
|
||||
if liveRun.navigatedRunID == currentLogID { liveRun.navigatedRunID = nil }
|
||||
// Silence narration only when the *whole run* leaves. This host stays mounted
|
||||
// across per-exercise hand-offs (the child re-ids), so stopping here — rather
|
||||
// than in the child's onDisappear — no longer cuts off the next exercise's cue.
|
||||
speechAnnouncer?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand off to the next exercise. Suppresses the leaving exercise's live-ended/activity
|
||||
|
||||
Reference in New Issue
Block a user