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
This commit is contained in:
John Costa 2026-06-30 04:52:19 -07:00 committed by GitHub
parent ba2f77804b
commit 1a13bef5e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 451 additions and 15 deletions

View file

@ -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,
});
});
});

View file

@ -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<FocusModeLocalConfig> = {
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<Required<FocusModeLocalConfig>>(
this._loadFromStorage(),
);
readonly settings = this._settings.asReadonly();
update(partial: Partial<FocusModeLocalConfig>): void {
const next: Required<FocusModeLocalConfig> = { ...this._settings(), ...partial };
this._settings.set(next);
this._persistToStorage(next);
}
private _loadFromStorage(): Required<FocusModeLocalConfig> {
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<FocusModeLocalConfig>;
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<FocusModeLocalConfig>): 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);
}
}
}

View file

@ -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<FocusModeLocalConfig> = {
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<FocusModeLocalConfig>[],
};

View file

@ -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);

View file

@ -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<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export interface LimitedFormlyFieldConfig<FormModel> extends Omit<

View file

@ -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);
}

View file

@ -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<FocusModeLocalConfig>);
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<string, GlobalSectionConfig>)[sectionKey];
}

View file

@ -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',

View file

@ -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();
});
});

View file

@ -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<typeof setTimeout> | 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<void> => {
// 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();
};

View file

@ -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",