mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
parent
ba2f77804b
commit
1a13bef5e7
11 changed files with 451 additions and 15 deletions
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
56
src/app/features/config/focus-mode-local-settings.service.ts
Normal file
56
src/app/features/config/focus-mode-local-settings.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>[],
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
115
src/app/util/break-end-alarm.spec.ts
Normal file
115
src/app/util/break-end-alarm.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
106
src/app/util/break-end-alarm.ts
Normal file
106
src/app/util/break-end-alarm.ts
Normal 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();
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue