mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
Add reminder notification sound setting
This commit is contained in:
parent
e079c9dd60
commit
bc3e6cc454
8 changed files with 267 additions and 1 deletions
|
|
@ -75,6 +75,7 @@ Example:
|
|||
- ([#1655](https://github.com/callumalpass/tasknotes/issues/1655)) Added live elapsed time to the optional active time tracking status bar item. Thanks to @connradolisboa for suggesting this.
|
||||
- ([#1622](https://github.com/callumalpass/tasknotes/issues/1622)) Allowed multiple comma-separated default reminder offsets for timed Google Calendar task exports. Thanks to @solidabstract for suggesting this.
|
||||
- ([#706](https://github.com/callumalpass/tasknotes/issues/706), [#820](https://github.com/callumalpass/tasknotes/issues/820), [#1040](https://github.com/callumalpass/tasknotes/issues/1040)) Added direct Pomodoro duration editing, hour-aware timer formatting, clearer one-minute controls, and an optional active Pomodoro status bar countdown. Thanks to @SublunarSage, @lj1446615403-cloud, @0-BSCode, and @thestrike72 for the suggestions and feedback.
|
||||
- ([#873](https://github.com/callumalpass/tasknotes/issues/873)) Added an optional built-in sound for task reminder notifications, with volume and preview controls in Settings. Thanks to @berzernberg for suggesting this.
|
||||
- Added a generated Pomodoro statistics Base with daily and monthly summary views for Pomodoro history stored in daily notes.
|
||||
|
||||
## Changed
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ Pomodoro settings control interval lengths, long-break cadence, optional auto-st
|
|||
|
||||
## Notifications
|
||||
|
||||
Use this section to enable reminders globally and choose whether notices are shown in-app or through system notifications.
|
||||
Use this section to enable reminders globally, choose whether notices are shown in-app or through system notifications, and optionally play a built-in reminder sound with its own volume control.
|
||||
|
||||
## Performance & Behavior
|
||||
|
||||
|
|
|
|||
|
|
@ -637,6 +637,13 @@ export const en: TranslationTree = {
|
|||
typeDesc: "Type of notifications to show",
|
||||
systemLabel: "System notifications",
|
||||
inAppLabel: "In-app notifications",
|
||||
soundEnabledName: "Notification sound",
|
||||
soundEnabledDesc: "Play a sound when task reminders trigger",
|
||||
soundVolumeName: "Sound volume",
|
||||
soundVolumeDesc: "Volume for task reminder sounds (0-100)",
|
||||
soundPreviewName: "Preview notification sound",
|
||||
soundPreviewDesc: "Play the configured task reminder sound",
|
||||
soundPreviewButton: "Preview",
|
||||
},
|
||||
overdue: {
|
||||
hideCompletedName: "Hide completed tasks from overdue",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export class NotificationService {
|
|||
private processedReminders: Set<string> = new Set(); // Track processed reminders to avoid duplicates
|
||||
private taskUpdateListener?: EventRef;
|
||||
private fileUpdateListener?: EventRef;
|
||||
private activeAudioContexts: Set<AudioContext> = new Set();
|
||||
private audioCleanupTimeouts: Set<number> = new Set();
|
||||
private lastBroadScanTime: number = Date.now();
|
||||
private lastQuickCheckTime: number = Date.now();
|
||||
|
||||
|
|
@ -66,6 +68,16 @@ export class NotificationService {
|
|||
if (this.fileUpdateListener) {
|
||||
this.plugin.emitter.offref(this.fileUpdateListener);
|
||||
}
|
||||
for (const timeout of this.audioCleanupTimeouts) {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
this.audioCleanupTimeouts.clear();
|
||||
for (const audioContext of this.activeAudioContexts) {
|
||||
if (audioContext.state !== "closed") {
|
||||
audioContext.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
this.activeAudioContexts.clear();
|
||||
this.notificationQueue = [];
|
||||
this.processedReminders.clear();
|
||||
}
|
||||
|
|
@ -259,6 +271,8 @@ export class NotificationService {
|
|||
const message =
|
||||
item.reminder.description || this.generateDefaultMessage(task, item.reminder);
|
||||
|
||||
this.playNotificationSound();
|
||||
|
||||
if (this.plugin.settings.notificationType === "system") {
|
||||
// System notification
|
||||
if ("Notification" in window && Notification.permission === "granted") {
|
||||
|
|
@ -293,6 +307,64 @@ export class NotificationService {
|
|||
}
|
||||
}
|
||||
|
||||
playNotificationSound(): void {
|
||||
if (!this.plugin.settings.notificationSoundEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const AudioContextConstructor =
|
||||
window.AudioContext ||
|
||||
(window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
|
||||
if (!AudioContextConstructor) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const audioContext = new AudioContextConstructor();
|
||||
const gainNode = audioContext.createGain();
|
||||
const volume = Math.max(
|
||||
0,
|
||||
Math.min(1, this.plugin.settings.notificationSoundVolume / 100)
|
||||
);
|
||||
|
||||
gainNode.gain.value = volume * 0.3;
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
const playTone = (frequency: number, durationSeconds: number) => {
|
||||
const oscillator = audioContext.createOscillator();
|
||||
oscillator.connect(gainNode);
|
||||
oscillator.frequency.value = frequency;
|
||||
oscillator.type = "sine";
|
||||
oscillator.start();
|
||||
oscillator.stop(audioContext.currentTime + durationSeconds);
|
||||
};
|
||||
|
||||
playTone(880, 0.12);
|
||||
|
||||
this.activeAudioContexts.add(audioContext);
|
||||
|
||||
const secondToneTimeout = window.setTimeout(() => {
|
||||
try {
|
||||
playTone(1175, 0.12);
|
||||
} catch (error) {
|
||||
console.error("Failed to play notification sound tone:", error);
|
||||
}
|
||||
}, 140);
|
||||
this.audioCleanupTimeouts.add(secondToneTimeout);
|
||||
|
||||
const cleanupTimeout = window.setTimeout(() => {
|
||||
this.activeAudioContexts.delete(audioContext);
|
||||
this.audioCleanupTimeouts.delete(secondToneTimeout);
|
||||
this.audioCleanupTimeouts.delete(cleanupTimeout);
|
||||
audioContext.close().catch(() => {});
|
||||
}, 320);
|
||||
this.audioCleanupTimeouts.add(cleanupTimeout);
|
||||
} catch (error) {
|
||||
console.error("Failed to play notification sound:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private showInAppNotice(message: string, taskPath: string): void {
|
||||
const notice = new Notice(message, 0); // 0 = persistent until clicked
|
||||
const noticeEl = (notice as unknown as { noticeEl: HTMLElement }).noticeEl;
|
||||
|
|
|
|||
|
|
@ -362,6 +362,8 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = {
|
|||
// Notification defaults
|
||||
enableNotifications: true,
|
||||
notificationType: "system",
|
||||
notificationSoundEnabled: false,
|
||||
notificationSoundVolume: 50,
|
||||
// HTTP API defaults
|
||||
enableAPI: false,
|
||||
apiPort: 8080,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
configureToggleSetting,
|
||||
configureDropdownSetting,
|
||||
configureNumberSetting,
|
||||
configureButtonSetting,
|
||||
} from "../components/settingHelpers";
|
||||
import { showStorageLocationConfirmationModal } from "../../modals/StorageLocationConfirmationModal";
|
||||
import { getAvailableLanguages } from "../../locales";
|
||||
|
|
@ -567,6 +568,64 @@ export function renderFeaturesTab(
|
|||
},
|
||||
})
|
||||
);
|
||||
|
||||
group.addSetting(
|
||||
(setting) =>
|
||||
void configureToggleSetting(setting, {
|
||||
name: translate(
|
||||
"settings.features.notifications.soundEnabledName"
|
||||
),
|
||||
desc: translate(
|
||||
"settings.features.notifications.soundEnabledDesc"
|
||||
),
|
||||
getValue: () => plugin.settings.notificationSoundEnabled,
|
||||
setValue: async (value: boolean) => {
|
||||
plugin.settings.notificationSoundEnabled = value;
|
||||
save();
|
||||
renderFeaturesTab(container, plugin, save);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
if (plugin.settings.notificationSoundEnabled) {
|
||||
group.addSetting(
|
||||
(setting) =>
|
||||
void configureNumberSetting(setting, {
|
||||
name: translate(
|
||||
"settings.features.notifications.soundVolumeName"
|
||||
),
|
||||
desc: translate(
|
||||
"settings.features.notifications.soundVolumeDesc"
|
||||
),
|
||||
placeholder: "50",
|
||||
min: 0,
|
||||
max: 100,
|
||||
getValue: () => plugin.settings.notificationSoundVolume,
|
||||
setValue: async (value: number) => {
|
||||
plugin.settings.notificationSoundVolume = value;
|
||||
save();
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
group.addSetting(
|
||||
(setting) =>
|
||||
void configureButtonSetting(setting, {
|
||||
name: translate(
|
||||
"settings.features.notifications.soundPreviewName"
|
||||
),
|
||||
desc: translate(
|
||||
"settings.features.notifications.soundPreviewDesc"
|
||||
),
|
||||
buttonText: translate(
|
||||
"settings.features.notifications.soundPreviewButton"
|
||||
),
|
||||
onClick: () => {
|
||||
plugin.notificationService?.playNotificationSound();
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -194,6 +194,8 @@ export interface TaskNotesSettings {
|
|||
// Notification settings
|
||||
enableNotifications: boolean;
|
||||
notificationType: "in-app" | "system";
|
||||
notificationSoundEnabled: boolean;
|
||||
notificationSoundVolume: number; // 0-100
|
||||
// HTTP API settings
|
||||
enableAPI: boolean;
|
||||
apiPort: number;
|
||||
|
|
|
|||
123
tests/unit/issues/issue-873-notification-sound.test.ts
Normal file
123
tests/unit/issues/issue-873-notification-sound.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { NotificationService } from "../../../src/services/NotificationService";
|
||||
|
||||
class MockOscillator {
|
||||
frequency = { value: 0 };
|
||||
type: OscillatorType = "sine";
|
||||
connect = jest.fn();
|
||||
start = jest.fn();
|
||||
stop = jest.fn();
|
||||
}
|
||||
|
||||
class MockGain {
|
||||
gain = { value: 0 };
|
||||
connect = jest.fn();
|
||||
}
|
||||
|
||||
class MockAudioContext {
|
||||
currentTime = 10;
|
||||
destination = {};
|
||||
state: AudioContextState = "running";
|
||||
oscillators: MockOscillator[] = [];
|
||||
gains: MockGain[] = [];
|
||||
close = jest.fn<Promise<void>, []>().mockResolvedValue(undefined);
|
||||
|
||||
createOscillator(): OscillatorNode {
|
||||
const oscillator = new MockOscillator();
|
||||
this.oscillators.push(oscillator);
|
||||
return oscillator as unknown as OscillatorNode;
|
||||
}
|
||||
|
||||
createGain(): GainNode {
|
||||
const gain = new MockGain();
|
||||
this.gains.push(gain);
|
||||
return gain as unknown as GainNode;
|
||||
}
|
||||
}
|
||||
|
||||
describe("Issue #873: notification reminder sound", () => {
|
||||
let originalAudioContext: typeof window.AudioContext | undefined;
|
||||
let contexts: MockAudioContext[];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
originalAudioContext = window.AudioContext;
|
||||
contexts = [];
|
||||
|
||||
const AudioContextMock = jest.fn(() => {
|
||||
const context = new MockAudioContext();
|
||||
contexts.push(context);
|
||||
return context;
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "AudioContext", {
|
||||
configurable: true,
|
||||
value: AudioContextMock as unknown as typeof AudioContext,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "AudioContext", {
|
||||
configurable: true,
|
||||
value: originalAudioContext,
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function createService(settings: {
|
||||
notificationSoundEnabled: boolean;
|
||||
notificationSoundVolume: number;
|
||||
}): NotificationService {
|
||||
return new NotificationService({
|
||||
settings,
|
||||
} as unknown as ConstructorParameters<typeof NotificationService>[0]);
|
||||
}
|
||||
|
||||
it("plays a two-tone reminder sound using the configured volume", () => {
|
||||
const service = createService({
|
||||
notificationSoundEnabled: true,
|
||||
notificationSoundVolume: 50,
|
||||
});
|
||||
|
||||
service.playNotificationSound();
|
||||
|
||||
expect(contexts).toHaveLength(1);
|
||||
expect(contexts[0].gains[0].gain.value).toBeCloseTo(0.15);
|
||||
expect(contexts[0].oscillators[0].frequency.value).toBe(880);
|
||||
expect(contexts[0].oscillators[0].stop).toHaveBeenCalledWith(10.12);
|
||||
|
||||
jest.advanceTimersByTime(140);
|
||||
|
||||
expect(contexts[0].oscillators).toHaveLength(2);
|
||||
expect(contexts[0].oscillators[1].frequency.value).toBe(1175);
|
||||
expect(contexts[0].oscillators[1].stop).toHaveBeenCalledWith(10.12);
|
||||
|
||||
jest.advanceTimersByTime(180);
|
||||
|
||||
expect(contexts[0].close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not create audio when notification sounds are disabled", () => {
|
||||
const service = createService({
|
||||
notificationSoundEnabled: false,
|
||||
notificationSoundVolume: 50,
|
||||
});
|
||||
|
||||
service.playNotificationSound();
|
||||
|
||||
expect(contexts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("cleans up active notification audio when the service is destroyed", () => {
|
||||
const service = createService({
|
||||
notificationSoundEnabled: true,
|
||||
notificationSoundVolume: 100,
|
||||
});
|
||||
|
||||
service.playNotificationSound();
|
||||
service.destroy();
|
||||
|
||||
expect(contexts[0].close).toHaveBeenCalledTimes(1);
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(contexts[0].close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue