diff --git a/frontend/app/services/audio.ts b/frontend/app/services/audio.ts index 13b8f08d6..e0bbdbf06 100644 --- a/frontend/app/services/audio.ts +++ b/frontend/app/services/audio.ts @@ -34,6 +34,7 @@ import type { Signal as SignalModel } from 'brn/schemas/signal'; import Intl from 'ember-intl/services/intl'; import { PolySynth, Synth, SynthOptions } from 'tone'; import UserDataService from './user-data'; +import StudyingTimerService from './studying-timer'; import type { Exercise } from 'brn/schemas/exercise'; type ISourceCollection = (ISource | IToneSource | null)[]; @@ -52,6 +53,7 @@ export default class AudioService extends Service { @service('stats') declare stats: StatsService; @service('intl') declare intl: Intl; @service('user-data') declare userData: UserDataService; + @service('studying-timer') declare studyingTimer: StudyingTimerService; context!: AudioContext; willDestroy(): void { @@ -218,6 +220,7 @@ export default class AudioService extends Service { @action async playAudio() { + this.studyingTimer.resetIdle(); try { if (!isTesting()) { await this.playTask.perform(); diff --git a/frontend/app/services/studying-timer.ts b/frontend/app/services/studying-timer.ts index 712a03642..d11471c81 100644 --- a/frontend/app/services/studying-timer.ts +++ b/frontend/app/services/studying-timer.ts @@ -66,14 +66,28 @@ export default class StudyingTimerService extends Service { } @action maybeIdlePause() { - // Pause cascades into audio.stop() via task-player.onPauseStateChanged, - // which would interrupt exercises whenever the user stops moving the mouse. + // resetIdle() (invoked on every playAudio) is the primary mechanism that + // keeps the watcher from firing mid-sequence. This guard is a defensive + // backstop in case a single clip ever outruns the idle window: pause + // cascades into audio.stop() via task-player.onPauseStateChanged, which + // would interrupt exercises whenever the user stops moving the mouse. if (this.audio.isPlaying) { return; } this.pause(); } @action + resetIdle() { + if (this.idleWatcher) { + try { + this.idleWatcher.stop(); + this.idleWatcher.start(); + } catch (_e) { + // idle-js may not support stop/start cycle in some edge cases + } + } + } + @action async startIdleWatcher() { if (isTesting()) { return; diff --git a/frontend/tests/unit/services/studying-timer-test.js b/frontend/tests/unit/services/studying-timer-test.js index e88eb816e..d0a567aeb 100644 --- a/frontend/tests/unit/services/studying-timer-test.js +++ b/frontend/tests/unit/services/studying-timer-test.js @@ -36,4 +36,43 @@ module('Unit | Service | studying-timer', function (hooks) { assert.true(timer.isPaused); }); }); + + module('resetIdle', function () { + test('rearms the idle watcher via stop/start', function (assert) { + const timer = this.owner.lookup('service:studying-timer'); + let stopCount = 0; + let startCount = 0; + timer.idleWatcher = { + stop() { + stopCount++; + }, + start() { + startCount++; + }, + }; + timer.resetIdle(); + assert.strictEqual(stopCount, 1, 'idleWatcher.stop called once'); + assert.strictEqual(startCount, 1, 'idleWatcher.start called once'); + }); + + test('does not toggle isPaused', function (assert) { + // Regression guard: resetIdle must not clear a user-initiated pause. + const timer = this.owner.lookup('service:studying-timer'); + timer.idleWatcher = { stop() {}, start() {} }; + timer.pause(); + timer.resetIdle(); + assert.true(timer.isPaused, 'user pause remains sticky after resetIdle'); + }); + + test('is a no-op when idleWatcher is null', function (assert) { + const timer = this.owner.lookup('service:studying-timer'); + timer.idleWatcher = null; + try { + timer.resetIdle(); + assert.ok(true, 'did not throw'); + } catch (_e) { + assert.ok(false, 'resetIdle threw with null idleWatcher'); + } + }); + }); });