From 22936d456ffe9cf314367e4ca6055dbd11488d1e Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Thu, 28 May 2026 10:16:54 +0000 Subject: [PATCH] Reset idle watcher on every audio playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix (#2893) only guarded onIdle against firing while audio was actively playing, but the LISTEN sequence sets isPlaying=false during the 1500ms gap between clips. With idleTimeout=10000ms, onIdle landed inside the gap after ~3 clips and paused the exercise, deadlocking listenModeTask in waitWhilePaused() (IdleJs doesn't reschedule onIdle without a user input event). Add studyingTimer.resetIdle() that rearms the watcher via stop().start(), called from audio.playAudio() — the single funnel for all three audio entry points (startPlayTask, listenModeTask, interactModeTask). Each clip resets the 10s countdown, so the watcher only fires when there is genuinely no audio activity (e.g. INTERACT mode awaiting user clicks). resetIdle() deliberately does not touch isPaused — user-initiated pause must remain sticky (preserved by the regression test from f9c43aa8). Verified in browser: LISTEN played all 3 clips with mouse still, timer counted 00:00→00:18 with isPaused=false throughout, transitioned to INTERACT, then idle correctly paused at ~10s of inactivity. Click on a word resumed normally. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/app/services/audio.ts | 3 ++ frontend/app/services/studying-timer.ts | 18 ++++++++- .../unit/services/studying-timer-test.js | 39 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) 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'); + } + }); + }); });