Skip to content

Commit 92b150e

Browse files
authored
Merge pull request #2889 from Brain-up/feat/exercise-ux-fixes-2885
Exercise UX fixes (issue #2885 items 4, audio freeze, page update)
2 parents 27cb347 + ae0d611 commit 92b150e

10 files changed

Lines changed: 387 additions & 50 deletions

File tree

frontend/app/components/exercise-steps/index.gts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface ExerciseStepsSignature {
2222
Args: {
2323
activeStep: Mode;
2424
visible: boolean;
25+
interactReady?: boolean;
2526
onClick: (key: string) => unknown;
2627
};
2728
Element: HTMLElement;
@@ -86,7 +87,7 @@ export default class ExerciseStepsComponent extends Component<ExerciseStepsSigna
8687
const base = ExerciseStepsComponent.BASE_BTN;
8788
if (this.modeForTask === BUTTONS.ACTIVE) return `${base} ${ExerciseStepsComponent.STATE_ACTIVE}`;
8889
if (this.modeForTask === BUTTONS.DISABLED) return `${base} ${ExerciseStepsComponent.STATE_LOCKED}`;
89-
if (this.isInteractCompleted) return `${base} ${ExerciseStepsComponent.STATE_NEXT}`;
90+
if (this.isInteractCompleted || this.args.interactReady) return `${base} ${ExerciseStepsComponent.STATE_NEXT}`;
9091
return `${base} ${ExerciseStepsComponent.STATE_DEFAULT}`;
9192
}
9293

@@ -171,6 +172,7 @@ export default class ExerciseStepsComponent extends Component<ExerciseStepsSigna
171172
type="button"
172173
class={{this.taskBtnClass}}
173174
aria-label={{t "control_exercises.solve"}}
175+
title={{t "control_exercises.solve_hint"}}
174176
disabled={{eq this.modeForTask "disabled"}}
175177
{{on "click" (fn this.onClick this.MODES.TASK this.modeForTask)}}
176178
>

frontend/app/components/task-player/index.gts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,7 @@ export default class TaskPlayerComponent extends Component<TaskPlayerSignature>
126126
} catch (_e) {
127127
// Interact was interrupted
128128
}
129-
try {
130-
await this.setMode(MODES.TASK);
131-
} catch (_e) {
132-
// Task mode interrupted
133-
}
129+
// Solve (TASK) is entered manually — users found the auto-jump abrupt.
134130
});
135131

136132
@action
@@ -439,6 +435,7 @@ export default class TaskPlayerComponent extends Component<TaskPlayerSignature>
439435
<ExerciseSteps
440436
@visible={{not this.justEnteredTask}}
441437
@activeStep={{this.mode}}
438+
@interactReady={{this.allOptionsHeard}}
442439
@onClick={{this.onModeChange}}
443440
class="sm:ml-2 flex mb-3 mr-2"
444441
/>

frontend/app/controllers/group/series/subgroup/exercise.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,17 @@ export default class GroupSeriesSubgroupExerciseController extends Controller {
9494
const nextIndex = index + 1;
9595
model.isManuallyCompleted = true;
9696

97+
// Persist the completion in tasksManager so the subgroup view still
98+
// shows this exercise as completed after navigating away and back —
99+
// the server history is only re-read on login/app start, so without
100+
// this the green check disappears on the next visit.
101+
if (model.id != null) {
102+
this.tasksManager.completedExerciseIds = new Set([
103+
...this.tasksManager.completedExerciseIds,
104+
String(model.id),
105+
]);
106+
}
107+
97108
if (children[nextIndex]) {
98109
children[nextIndex].available = true;
99110
}

frontend/app/services/audio.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -422,10 +422,41 @@ export default class AudioService extends Service {
422422
index++;
423423
if (item) {
424424
if (item.source.buffer) {
425+
// Browsers (Safari, and Chrome under throttling) suspend idle
426+
// AudioContexts. source.start(0) on a suspended context queues
427+
// playback instead of playing it — without this resume, later
428+
// words fall silent until a user gesture wakes the context.
429+
if (this.context.state === 'suspended' && !isTesting()) {
430+
await this.context.resume();
431+
}
425432
const duration = toMilliseconds(item.source.buffer.duration);
426-
item.source.start(0);
427-
startedSources.push(item);
428-
await timeout(duration);
433+
// Prefer onended over wall-clock timeout: setTimeout keeps
434+
// ticking when the context suspends mid-clip, so a timer-only
435+
// loop would advance over silent words instead of waiting
436+
// for real playback to finish.
437+
const rawSource = item.source as unknown;
438+
const ended = rawSource instanceof AudioBufferSourceNode
439+
? new Promise<void>((resolve) => {
440+
rawSource.onended = () => resolve();
441+
})
442+
: null;
443+
let startFailed = false;
444+
try {
445+
item.source.start(0);
446+
startedSources.push(item);
447+
} catch (e) {
448+
// A sync throw (closed context, source already started, etc.)
449+
// would otherwise strand the loop in the safety-net timeout.
450+
startFailed = true;
451+
console.error('source.start failed', e);
452+
}
453+
if (startFailed) {
454+
// nothing playing — move on immediately
455+
} else if (ended) {
456+
await Promise.race([ended, timeout(duration + 1000)]);
457+
} else {
458+
await timeout(duration);
459+
}
429460
} else {
430461
console.error('there is no buffer for source');
431462
}
Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,94 @@
11
import { module, test } from 'qunit';
22
import { setupIntl } from 'ember-intl/test-support';
33
import { setupRenderingTest } from 'ember-qunit';
4-
import { render } from '@ember/test-helpers';
4+
import { render, settled } from '@ember/test-helpers';
5+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
6+
import { tracked } from '@glimmer/tracking';
57
import ExerciseSteps from 'brn/components/exercise-steps';
68

9+
// Helper: walk through LISTEN → INTERACT so `setLastMode` populates
10+
// the component's internal `modes` array with both entries. Without
11+
// this, modeForTask stays DISABLED regardless of interactReady and
12+
// STATE_LOCKED wins in taskBtnClass.
13+
class StepState {
14+
@tracked step = 'listen';
15+
@tracked ready = false;
16+
}
17+
18+
async function walkToInteract(state) {
19+
state.step = 'interact';
20+
await settled();
21+
}
22+
723
module('Integration | Component | exercise-steps', function (hooks) {
824
setupRenderingTest(hooks);
925
setupIntl(hooks, 'en-us');
1026

11-
test('it renders', async function (assert) {
12-
// Set any properties with this.set('myProperty', 'value');
13-
// Handle any actions with this.set('myAction', function(val) { ... });
14-
27+
test('it renders three step buttons', async function (assert) {
1528
await render(<template><ExerciseSteps /></template>);
1629

1730
assert.dom('button').exists({ count: 3 });
1831
});
32+
33+
test('solve button gets the "next" style once interactReady is true during Interact', async function (assert) {
34+
const state = new StepState();
35+
await render(
36+
<template>
37+
<ExerciseSteps
38+
@activeStep={{state.step}}
39+
@visible={{true}}
40+
@interactReady={{state.ready}}
41+
/>
42+
</template>,
43+
);
44+
45+
await walkToInteract(state);
46+
state.ready = true;
47+
await settled();
48+
49+
assert
50+
.dom('button:nth-of-type(3)')
51+
.hasClass('exercise-step-btn--next', 'solve button lights up as next step');
52+
});
53+
54+
test('solve button stays default when interactReady is false during Interact', async function (assert) {
55+
const state = new StepState();
56+
await render(
57+
<template>
58+
<ExerciseSteps
59+
@activeStep={{state.step}}
60+
@visible={{true}}
61+
@interactReady={{state.ready}}
62+
/>
63+
</template>,
64+
);
65+
66+
await walkToInteract(state);
67+
68+
assert
69+
.dom('button:nth-of-type(3)')
70+
.doesNotHaveClass(
71+
'exercise-step-btn--next',
72+
'solve button is not highlighted yet',
73+
);
74+
});
75+
76+
test('solve button exposes the ready-when-you-are hint via title', async function (assert) {
77+
// ember-intl in this test env returns the `t:<key>` placeholder when
78+
// the translation is not loaded (see doctor-feedback/component-test.gjs
79+
// for the same pattern). We just verify the hint key is wired up.
80+
await render(
81+
<template>
82+
<ExerciseSteps @activeStep="interact" @visible={{true}} />
83+
</template>,
84+
);
85+
86+
assert
87+
.dom('button:nth-of-type(3)')
88+
.hasAttribute(
89+
'title',
90+
't:control_exercises.solve_hint',
91+
'solve button binds the hint translation key',
92+
);
93+
});
1994
});

frontend/tests/unit/components/task-player/heard-words-test.js

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { module, test } from 'qunit';
2-
import { MODES } from 'brn/utils/task-modes';
32

43
module('Unit | Component | task-player | heardWords tracking', function () {
54
// Test the heardWords and allOptionsHeard logic by replicating the
@@ -182,40 +181,7 @@ module('Unit | Component | task-player | interactModeTask heardWords accumulatio
182181
});
183182
});
184183

185-
module('Unit | Component | task-player | exerciseSequenceTask with auto-transition', function () {
186-
// Test that the exercise sequence flows through listen -> interact -> task,
187-
// and that interactModeTask returns (completing interact) when allOptionsHeard is true.
188-
189-
async function runExerciseSequence(setMode) {
190-
try {
191-
await setMode(MODES.LISTEN);
192-
} catch (_e) {
193-
return;
194-
}
195-
try {
196-
await setMode(MODES.INTERACT);
197-
} catch (_e) {
198-
// Interact was interrupted
199-
}
200-
try {
201-
await setMode(MODES.TASK);
202-
} catch (_e) {
203-
// Task mode interrupted
204-
}
205-
}
206-
207-
test('transitions through listen -> interact -> task in full sequence', async function (assert) {
208-
const calls = [];
209-
await runExerciseSequence(async (mode) => {
210-
calls.push(mode);
211-
});
212-
213-
assert.strictEqual(calls.length, 3, 'setMode called three times');
214-
assert.strictEqual(calls[0], MODES.LISTEN, 'first call is LISTEN');
215-
assert.strictEqual(calls[1], MODES.INTERACT, 'second call is INTERACT');
216-
assert.strictEqual(calls[2], MODES.TASK, 'third call is TASK');
217-
});
218-
184+
module('Unit | Component | task-player | allOptionsHeard break condition', function () {
219185
test('allOptionsHeard check causes loop exit after all options played (simulated logic)', async function (assert) {
220186
// Simulate the interactModeTask loop logic (sync) to verify the break condition
221187
let heardWords = new Set();

0 commit comments

Comments
 (0)