From 1a13bef5e72c93d7ee87af34ae50fc28cb9aca7c Mon Sep 17 00:00:00 2001 From: John Costa Date: Tue, 30 Jun 2026 04:52:19 -0700 Subject: [PATCH] feat(focus-mode): loop the break-end sound until the break is dismissed (#8608) Adds an opt-in, default-off setting that keeps the focus-mode break-end sound looping until the break is dismissed, instead of firing once. Useful when you step away from your desk and want a persistent cue that the break is over. Scope and decisions (per the discussion on #8593): - One toggle only. Reuses the existing break-end sound (positive.mp3) and the global sound volume. No new sound picker or per-feature volume. - Stored per-device in localStorage, not the synced config, mirroring TaskWidgetSettingsService. Looping audio behaves differently across platforms (desktop keeps the AudioContext running while the window is unfocused; mobile suspends it on app-background, #8243), so a single synced value would behave differently per device. The setting is labelled as local to the device. - Limited to the focus-mode break timer reaching zero (detectBreakTimeUp$). - A single selector-based effect owns the loop lifecycle (mirrors whiteNoiseSound$), so the loop starts once and stops on any leave-break transition. A hard 10-minute ceiling stops it regardless if the user truly walked away. The audio primitive stops any previous source before starting and uses a monotonic start-token, so a restart can never leak a second loop. Closes #8593 --- .../focus-mode-local-settings.service.spec.ts | 56 +++++++++ .../focus-mode-local-settings.service.ts | 56 +++++++++ .../form-cfgs/focus-mode-local-form.const.ts | 21 ++++ .../config/global-config-form-config.const.ts | 3 + .../features/config/global-config.model.ts | 12 +- .../focus-mode/store/focus-mode.effects.ts | 71 +++++++++-- .../config-page/config-page.component.ts | 16 ++- src/app/t.const.ts | 5 + src/app/util/break-end-alarm.spec.ts | 115 ++++++++++++++++++ src/app/util/break-end-alarm.ts | 106 ++++++++++++++++ src/assets/i18n/en.json | 5 + 11 files changed, 451 insertions(+), 15 deletions(-) create mode 100644 src/app/features/config/focus-mode-local-settings.service.spec.ts create mode 100644 src/app/features/config/focus-mode-local-settings.service.ts create mode 100644 src/app/features/config/form-cfgs/focus-mode-local-form.const.ts create mode 100644 src/app/util/break-end-alarm.spec.ts create mode 100644 src/app/util/break-end-alarm.ts diff --git a/src/app/features/config/focus-mode-local-settings.service.spec.ts b/src/app/features/config/focus-mode-local-settings.service.spec.ts new file mode 100644 index 0000000000..0c78f6ddaa --- /dev/null +++ b/src/app/features/config/focus-mode-local-settings.service.spec.ts @@ -0,0 +1,56 @@ +import { TestBed } from '@angular/core/testing'; +import { FocusModeLocalSettingsService } from './focus-mode-local-settings.service'; + +const STORAGE_KEY = 'sp_focus_mode_local_settings'; + +describe('FocusModeLocalSettingsService', () => { + beforeEach(() => { + localStorage.removeItem(STORAGE_KEY); + TestBed.configureTestingModule({}); + }); + + afterEach(() => { + localStorage.removeItem(STORAGE_KEY); + }); + + it('returns default settings when localStorage is empty', () => { + const service = TestBed.inject(FocusModeLocalSettingsService); + + expect(service.settings()).toEqual({ + isLoopBreakEndAlarm: false, + }); + }); + + it('loads existing settings from localStorage', () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ isLoopBreakEndAlarm: true })); + + const service = TestBed.inject(FocusModeLocalSettingsService); + + expect(service.settings()).toEqual({ + isLoopBreakEndAlarm: true, + }); + }); + + it('merges partial updates and persists them', () => { + const service = TestBed.inject(FocusModeLocalSettingsService); + + service.update({ isLoopBreakEndAlarm: true }); + + expect(service.settings()).toEqual({ + isLoopBreakEndAlarm: true, + }); + expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')).toEqual({ + isLoopBreakEndAlarm: true, + }); + }); + + it('falls back to defaults if stored JSON is corrupt', () => { + localStorage.setItem(STORAGE_KEY, '{not-json'); + + const service = TestBed.inject(FocusModeLocalSettingsService); + + expect(service.settings()).toEqual({ + isLoopBreakEndAlarm: false, + }); + }); +}); diff --git a/src/app/features/config/focus-mode-local-settings.service.ts b/src/app/features/config/focus-mode-local-settings.service.ts new file mode 100644 index 0000000000..0ad1b705c6 --- /dev/null +++ b/src/app/features/config/focus-mode-local-settings.service.ts @@ -0,0 +1,56 @@ +import { Injectable, signal } from '@angular/core'; +import { FocusModeLocalConfig } from './global-config.model'; +import { Log } from '../../core/log'; + +const STORAGE_KEY = 'sp_focus_mode_local_settings'; + +const DEFAULT_FOCUS_MODE_LOCAL_CONFIG: Required = { + isLoopBreakEndAlarm: false, +}; + +/** + * Focus-mode settings that intentionally live in localStorage rather than the + * synced global config, because they control audio behavior that differs + * across platforms. The looping break-end alarm keeps the break-end sound + * playing until the break is dismissed; on desktop the AudioContext keeps + * running while the window is unfocused, but on mobile it is suspended on + * app-background (#8243), so a single value synced across devices would behave + * differently per device. Mirrors TaskWidgetSettingsService. See #8593. + */ +@Injectable({ providedIn: 'root' }) +export class FocusModeLocalSettingsService { + private readonly _settings = signal>( + this._loadFromStorage(), + ); + readonly settings = this._settings.asReadonly(); + + update(partial: Partial): void { + const next: Required = { ...this._settings(), ...partial }; + this._settings.set(next); + this._persistToStorage(next); + } + + private _loadFromStorage(): Required { + if (typeof localStorage === 'undefined') { + return { ...DEFAULT_FOCUS_MODE_LOCAL_CONFIG }; + } + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { ...DEFAULT_FOCUS_MODE_LOCAL_CONFIG }; + const parsed = JSON.parse(raw) as Partial; + return { ...DEFAULT_FOCUS_MODE_LOCAL_CONFIG, ...parsed }; + } catch (e) { + Log.err('Failed to read focus mode local settings from localStorage', e); + return { ...DEFAULT_FOCUS_MODE_LOCAL_CONFIG }; + } + } + + private _persistToStorage(value: Required): void { + if (typeof localStorage === 'undefined') return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); + } catch (e) { + Log.err('Failed to persist focus mode local settings to localStorage', e); + } + } +} diff --git a/src/app/features/config/form-cfgs/focus-mode-local-form.const.ts b/src/app/features/config/form-cfgs/focus-mode-local-form.const.ts new file mode 100644 index 0000000000..dcb04592d1 --- /dev/null +++ b/src/app/features/config/form-cfgs/focus-mode-local-form.const.ts @@ -0,0 +1,21 @@ +import { + ConfigFormSection, + FocusModeLocalConfig, + LimitedFormlyFieldConfig, +} from '../global-config.model'; +import { T } from '../../../t.const'; + +export const FOCUS_MODE_LOCAL_FORM_CFG: ConfigFormSection = { + title: T.GCF.FOCUS_MODE_LOCAL.TITLE, + key: 'focusModeLocal', + help: T.GCF.FOCUS_MODE_LOCAL.HELP, + items: [ + { + key: 'isLoopBreakEndAlarm', + type: 'checkbox', + templateOptions: { + label: T.GCF.FOCUS_MODE_LOCAL.L_IS_LOOP_BREAK_END_ALARM, + }, + }, + ] as LimitedFormlyFieldConfig[], +}; diff --git a/src/app/features/config/global-config-form-config.const.ts b/src/app/features/config/global-config-form-config.const.ts index ccd3b1b3ec..ea56aee1c8 100644 --- a/src/app/features/config/global-config-form-config.const.ts +++ b/src/app/features/config/global-config-form-config.const.ts @@ -12,6 +12,8 @@ import { IS_ELECTRON } from '../../app.constants'; import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; import { SCHEDULE_FORM_CFG } from './form-cfgs/schedule-form.const'; import { FOCUS_MODE_FORM_CFG } from './form-cfgs/focus-mode-form.const'; +import { FOCUS_MODE_LOCAL_FORM_CFG } from './form-cfgs/focus-mode-local-form.const'; +import { IS_NATIVE_PLATFORM } from '../../util/is-native-platform'; import { REMINDER_FORM_CFG } from './form-cfgs/reminder-form.const'; import { SHORT_SYNTAX_FORM_CFG } from './form-cfgs/short-syntax-form.const'; import { CLIPBOARD_IMAGES_FORM } from './form-cfgs/clipboard-images-form.const'; @@ -55,6 +57,7 @@ export const GLOBAL_IMEX_FORM_CONFIG: ConfigFormConfig = [ export const GLOBAL_PRODUCTIVITY_FORM_CONFIG: ConfigFormConfig = [ FOCUS_MODE_FORM_CFG, + ...(IS_NATIVE_PLATFORM ? [] : [FOCUS_MODE_LOCAL_FORM_CFG]), TAKE_A_BREAK_FORM_CFG, EVALUATION_SETTINGS_FORM_CFG, ].filter(filterGlobalConfigForm); diff --git a/src/app/features/config/global-config.model.ts b/src/app/features/config/global-config.model.ts index f61c7643e2..5aac140002 100644 --- a/src/app/features/config/global-config.model.ts +++ b/src/app/features/config/global-config.model.ts @@ -302,6 +302,10 @@ export type TaskWidgetConfig = Readonly<{ opacity?: number; }>; +export type FocusModeLocalConfig = Readonly<{ + isLoopBreakEndAlarm?: boolean; +}>; + export type ClipboardImagesConfig = Readonly<{ imagePath?: string | null; }>; @@ -348,7 +352,10 @@ export type GlobalConfigSectionKey = keyof GlobalConfigState | 'EMPTY'; // handler. Kept separate from `GlobalConfigSectionKey` so it cannot leak into // `updateGlobalConfigSection` action payloads (which would create phantom ops // in the sync log). -export type GlobalConfigFormSectionKey = GlobalConfigSectionKey | 'taskWidget'; +export type GlobalConfigFormSectionKey = + | GlobalConfigSectionKey + | 'taskWidget' + | 'focusModeLocal'; export type GlobalSectionConfig = | MiscConfig @@ -361,7 +368,8 @@ export type GlobalSectionConfig = | DailySummaryNote | SyncConfig | ClipboardImagesConfig - | TaskWidgetConfig; + | TaskWidgetConfig + | FocusModeLocalConfig; type Omit = Pick>; export interface LimitedFormlyFieldConfig extends Omit< diff --git a/src/app/features/focus-mode/store/focus-mode.effects.ts b/src/app/features/focus-mode/store/focus-mode.effects.ts index e906f2d37e..05c230cd01 100644 --- a/src/app/features/focus-mode/store/focus-mode.effects.ts +++ b/src/app/features/focus-mode/store/focus-mode.effects.ts @@ -22,6 +22,8 @@ import { GlobalConfigService } from '../../config/global-config.service'; import { TaskService } from '../../tasks/task.service'; import { playSound } from '../../../util/play-sound'; import { startWhiteNoise, stopWhiteNoise } from '../../../util/white-noise'; +import { startBreakEndAlarm, stopBreakEndAlarm } from '../../../util/break-end-alarm'; +import { FocusModeLocalSettingsService } from '../../config/focus-mode-local-settings.service'; import { IS_ELECTRON } from '../../../app.constants'; import { setCurrentTask, unsetCurrentTask } from '../../tasks/store/task.actions'; import { selectLastCurrentTask, selectTaskById } from '../../tasks/store/task.selectors'; @@ -33,7 +35,12 @@ import { selectPomodoroConfig, } from '../../config/store/global-config.reducer'; import { updateGlobalConfigSection } from '../../config/store/global-config.actions'; -import { FocusModeMode, FocusScreen, getBreakCycle } from '../focus-mode.model'; +import { + FocusModeMode, + FocusScreen, + getBreakCycle, + TimerState, +} from '../focus-mode.model'; import { MetricService } from '../../metric/metric.service'; import { FocusModeStorageService } from '../focus-mode-storage.service'; import { TakeABreakService } from '../../take-a-break/take-a-break.service'; @@ -62,6 +69,7 @@ export class FocusModeEffects { private notifyService = inject(NotifyService); private bannerService = inject(BannerService); private isAndroidWebView = inject(IS_ANDROID_WEB_VIEW_TOKEN); + private focusModeLocalSettingsService = inject(FocusModeLocalSettingsService); // Sync: When tracking starts → resume/skip-break or auto-spawn a new session. // @@ -292,19 +300,42 @@ export class FocusModeEffects { () => this.store.select(selectors.selectTimer).pipe( skipWhileApplyingRemoteOps(), - filter( - (timer) => - timer.purpose === 'break' && - !timer.isRunning && - timer.startedAt !== null && - timer.elapsed >= timer.duration, - ), + filter((timer) => this._isBreakTimeUp(timer)), distinctUntilChanged( (prev, curr) => prev.elapsed === curr.elapsed && prev.startedAt === curr.startedAt, ), tap(() => { - this._notifyUser(); + // When the looping break-end alarm is enabled it owns the break-end + // sound (breakEndAlarmSound$); skip the one-shot here so they don't + // double up. The window focus/flash still fires. + this._notifyUser(false, this._isLoopBreakEndAlarmOn()); + }), + ), + { dispatch: false }, + ); + + // Loop the break-end sound until the break is dismissed, when the user has + // opted in (per-device setting — see FocusModeLocalSettingsService / #8593). + // Mirrors whiteNoiseSound$: a single selector-based effect owns the loop + // lifecycle, so any leave-break transition (completeBreak / skipBreak / + // starting the next session — all of which flip timer.purpose away from + // 'break' or set it running again) naturally stops the loop via + // distinctUntilChanged. A hard safety ceiling inside startBreakEndAlarm() + // stops it regardless if the user truly walked away. + breakEndAlarmSound$ = createEffect( + () => + this.store.select(selectors.selectTimer).pipe( + skipWhileApplyingRemoteOps(), + map((timer) => this._isBreakTimeUp(timer) && this._isLoopBreakEndAlarmOn()), + distinctUntilChanged(), + tap((shouldAlarm) => { + if (shouldAlarm) { + const soundVolume = this.globalConfigService.sound()?.volume || 0; + startBreakEndAlarm(SESSION_DONE_SOUND, soundVolume); + } else { + stopBreakEndAlarm(); + } }), ), { dispatch: false }, @@ -920,11 +951,27 @@ export class FocusModeEffects { { dispatch: false }, ); - private _notifyUser(isHideBar = false): void { + private _isBreakTimeUp(timer: TimerState): boolean { + return ( + timer.purpose === 'break' && + !timer.isRunning && + timer.startedAt !== null && + timer.elapsed >= timer.duration + ); + } + + private _isLoopBreakEndAlarmOn(): boolean { + return ( + this.focusModeLocalSettingsService.settings().isLoopBreakEndAlarm && + (this.globalConfigService.sound()?.volume || 0) > 0 + ); + } + + private _notifyUser(isHideBar = false, isSkipSound = false): void { const soundVolume = this.globalConfigService.sound()?.volume || 0; - // Play sound if enabled - if (soundVolume > 0) { + // Play sound if enabled (skipped when the looping break-end alarm owns it) + if (!isSkipSound && soundVolume > 0) { playSound(SESSION_DONE_SOUND, soundVolume); } diff --git a/src/app/pages/config-page/config-page.component.ts b/src/app/pages/config-page/config-page.component.ts index d957934ce0..4a7a1143a8 100644 --- a/src/app/pages/config-page/config-page.component.ts +++ b/src/app/pages/config-page/config-page.component.ts @@ -10,7 +10,11 @@ import { import { ActivatedRoute } from '@angular/router'; import { GlobalConfigService } from '../../features/config/global-config.service'; import { TaskWidgetSettingsService } from '../../features/config/task-widget-settings.service'; -import { TaskWidgetConfig } from '../../features/config/global-config.model'; +import { FocusModeLocalSettingsService } from '../../features/config/focus-mode-local-settings.service'; +import { + FocusModeLocalConfig, + TaskWidgetConfig, +} from '../../features/config/global-config.model'; import { GLOBAL_GENERAL_FORM_CONFIG, GLOBAL_IMEX_FORM_CONFIG, @@ -99,6 +103,7 @@ export class ConfigPageComponent implements OnInit { readonly configService = inject(GlobalConfigService); readonly syncSettingsService = inject(SyncConfigService); readonly taskWidgetSettingsService = inject(TaskWidgetSettingsService); + readonly focusModeLocalSettingsService = inject(FocusModeLocalSettingsService); T: typeof T = T; @@ -326,6 +331,12 @@ export class ConfigPageComponent implements OnInit { return; } + // focusModeLocal is per-instance (not synced) — handled by a dedicated service + if (formSectionKey === 'focusModeLocal') { + this.focusModeLocalSettingsService.update(config as Partial); + return; + } + // From here on we know it's a real GlobalConfigState section. const sectionKey = formSectionKey as GlobalConfigSectionKey; @@ -410,6 +421,9 @@ export class ConfigPageComponent implements OnInit { if (sectionKey === 'taskWidget') { return this.taskWidgetSettingsService.settings() as GlobalSectionConfig; } + if (sectionKey === 'focusModeLocal') { + return this.focusModeLocalSettingsService.settings() as GlobalSectionConfig; + } return (this.globalCfg as unknown as Record)[sectionKey]; } diff --git a/src/app/t.const.ts b/src/app/t.const.ts index aac4f15cbd..9f2e350b0f 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -2400,6 +2400,11 @@ const T = { L_SHOW_PREPARATION_SCREEN: 'GCF.FOCUS_MODE.L_SHOW_PREPARATION_SCREEN', TITLE: 'GCF.FOCUS_MODE.TITLE', }, + FOCUS_MODE_LOCAL: { + HELP: 'GCF.FOCUS_MODE_LOCAL.HELP', + L_IS_LOOP_BREAK_END_ALARM: 'GCF.FOCUS_MODE_LOCAL.L_IS_LOOP_BREAK_END_ALARM', + TITLE: 'GCF.FOCUS_MODE_LOCAL.TITLE', + }, TASK_WIDGET: { TITLE: 'GCF.TASK_WIDGET.TITLE', IS_ENABLED: 'GCF.TASK_WIDGET.IS_ENABLED', diff --git a/src/app/util/break-end-alarm.spec.ts b/src/app/util/break-end-alarm.spec.ts new file mode 100644 index 0000000000..065b59f974 --- /dev/null +++ b/src/app/util/break-end-alarm.spec.ts @@ -0,0 +1,115 @@ +import { startBreakEndAlarm, stopBreakEndAlarm } from './break-end-alarm'; +import { closeAudioContext } from './audio-context'; + +const SOUND = 'positive.mp3'; + +describe('break-end-alarm', () => { + let originalAudioContext: typeof AudioContext; + let originalFetch: typeof window.fetch; + let mockContext: any; + let createdSources: any[]; + + const makeSource = (): any => ({ + connect: jasmine.createSpy('connect'), + disconnect: jasmine.createSpy('disconnect'), + start: jasmine.createSpy('start'), + stop: jasmine.createSpy('stop'), + buffer: null, + loop: false, + }); + + beforeEach(() => { + originalAudioContext = (window as any).AudioContext; + originalFetch = window.fetch; + createdSources = []; + + const mockGainNode = { + connect: jasmine.createSpy('connect'), + disconnect: jasmine.createSpy('disconnect'), + gain: { value: 1 }, + }; + mockContext = { + state: 'running', + resume: jasmine.createSpy('resume').and.returnValue(Promise.resolve()), + close: jasmine.createSpy('close'), + createBufferSource: jasmine.createSpy('createBufferSource').and.callFake(() => { + const s = makeSource(); + createdSources.push(s); + return s; + }), + createGain: jasmine.createSpy('createGain').and.returnValue(mockGainNode), + decodeAudioData: jasmine + .createSpy('decodeAudioData') + .and.returnValue(Promise.resolve({} as AudioBuffer)), + destination: {} as AudioDestinationNode, + }; + (window as any).AudioContext = jasmine + .createSpy('AudioContext') + .and.returnValue(mockContext); + (window as any).fetch = jasmine.createSpy('fetch').and.returnValue( + Promise.resolve({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + }), + ); + + // Reset module-level audio-context state (context + buffer cache). + closeAudioContext(); + }); + + afterEach(() => { + stopBreakEndAlarm(); + (window as any).AudioContext = originalAudioContext; + (window as any).fetch = originalFetch; + }); + + it('starts a looping source at the given volume', async () => { + await startBreakEndAlarm(SOUND, 50); + + expect(createdSources.length).toBe(1); + const source = createdSources[0]; + expect(source.loop).toBe(true); + expect(source.start).toHaveBeenCalledWith(0); + expect(mockContext.createGain).toHaveBeenCalled(); + }); + + it('stops and disconnects the active source on stop', async () => { + await startBreakEndAlarm(SOUND, 100); + const source = createdSources[0]; + + stopBreakEndAlarm(); + + expect(source.stop).toHaveBeenCalled(); + expect(source.disconnect).toHaveBeenCalled(); + }); + + it('stops the previous source when restarted, so no loop can leak', async () => { + await startBreakEndAlarm(SOUND, 100); + const first = createdSources[0]; + + await startBreakEndAlarm(SOUND, 100); + + expect(first.stop).toHaveBeenCalled(); + expect(createdSources.length).toBe(2); + }); + + it('auto-stops after the hard safety ceiling', async () => { + const TEN_MINUTES_MS = 10 * 60 * 1000; + jasmine.clock().install(); + try { + await startBreakEndAlarm(SOUND, 100); + const source = createdSources[0]; + expect(source.stop).not.toHaveBeenCalled(); + + jasmine.clock().tick(TEN_MINUTES_MS + 1); + + expect(source.stop).toHaveBeenCalled(); + } finally { + jasmine.clock().uninstall(); + } + }); + + it('does not throw when stop is called with no active alarm', () => { + expect(() => stopBreakEndAlarm()).not.toThrow(); + }); +}); diff --git a/src/app/util/break-end-alarm.ts b/src/app/util/break-end-alarm.ts new file mode 100644 index 0000000000..c63b068834 --- /dev/null +++ b/src/app/util/break-end-alarm.ts @@ -0,0 +1,106 @@ +import { ensureAudioContextRunning, getAudioBuffer } from './audio-context'; +import { Log } from '../core/log'; + +const BASE = './assets/snd'; + +/** + * Hard safety ceiling for the looping break-end alarm. If the user genuinely + * walked away, the alarm must not sound forever into an empty room — it stops + * itself after this long even if no break-leave transition ever arrives. + * Intentionally non-configurable to keep the feature to a single toggle (#8593). + */ +const MAX_ALARM_DURATION = 10 * 60 * 1000; // 10 minutes + +let activeSource: AudioBufferSourceNode | null = null; +let activeGain: GainNode | null = null; +let capTimeoutId: ReturnType | null = null; +// Monotonic generation counter. Every start() and stop() bumps it, so an +// in-flight start() whose awaits resolve late can detect that a newer start() +// or a stop() has superseded it and bail before creating a second source. This +// guarantees a restart can never leak an orphaned, unstoppable loop — even if +// two starts overlap (the single caller never overlaps them, but the primitive +// is self-contained, so it does not rely on that). +let startToken = 0; + +const _teardownNodes = (): void => { + if (capTimeoutId !== null) { + clearTimeout(capTimeoutId); + capTimeoutId = null; + } + if (activeSource) { + try { + activeSource.stop(); + } catch (_) { + // source may already be stopped + } + activeSource.disconnect(); + activeSource = null; + } + if (activeGain) { + activeGain.disconnect(); + activeGain = null; + } +}; + +/** + * Loops the given sound at the specified volume until stopBreakEndAlarm() is + * called (or the hard safety ceiling elapses). Any sound already looping is + * stopped first, so a restart can never leak a second, unstoppable source. + * + * NOTE: unlike startWhiteNoise(), this deliberately does NOT call + * setAudioContextKeepAwake(). The alarm is desktop-oriented — there the + * AudioContext keeps running while the window is unfocused, which is the whole + * point (you stepped away from your desk) — and keepAwake is a single-owner + * flag that white noise relies on. On mobile the context is suspended on + * app-background (#8243), so the loop stops there by design; the setting is + * per-device precisely because this behavior differs across platforms. + * + * @param filePath - Path to the sound file relative to assets/snd + * @param volume - Volume level from 0 to 100 + */ +export const startBreakEndAlarm = async ( + filePath: string, + volume: number, +): Promise => { + // Claim a new generation and stop any current/previous loop synchronously, + // before the first await, so a rapid start → start can never leave an + // orphaned source running. + const myToken = ++startToken; + _teardownNodes(); + + try { + const ctx = await ensureAudioContextRunning(); + const buffer = await getAudioBuffer(`${BASE}/${filePath}`); + // A newer start() or a stop() may have superseded us while we awaited. + if (myToken !== startToken) { + return; + } + + const source = ctx.createBufferSource(); + source.buffer = buffer; + source.loop = true; + + const gain = ctx.createGain(); + gain.gain.value = volume / 100; + source.connect(gain); + gain.connect(ctx.destination); + + source.start(0); + activeSource = source; + activeGain = gain; + + capTimeoutId = setTimeout(() => stopBreakEndAlarm(), MAX_ALARM_DURATION); + } catch (e) { + Log.err('Error starting looping break-end alarm:', e); + if (myToken === startToken) { + stopBreakEndAlarm(); + } + } +}; + +export const stopBreakEndAlarm = (): void => { + // Bump the generation so any in-flight start() bails instead of creating a + // source after we have torn everything down. + startToken++; + _teardownNodes(); +}; diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 218c24164d..3547c042bd 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2341,6 +2341,11 @@ "L_SHOW_PREPARATION_SCREEN": "Show preparation screen before each session (rocket countdown)", "TITLE": "Focus Mode" }, + "FOCUS_MODE_LOCAL": { + "HELP": "These settings apply only to this device and are not synced.", + "L_IS_LOOP_BREAK_END_ALARM": "Loop the break-end sound until I dismiss the break (this device only)", + "TITLE": "Focus Mode (this device)" + }, "TASK_WIDGET": { "TITLE": "Task Widget", "IS_ENABLED": "Enable task widget",