@tuturuuu/ui 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/package.json +5 -5
- package/src/components/ui/custom/settings/task-settings.tsx +76 -0
- package/src/components/ui/custom/settings/task-sound-settings.test.tsx +126 -0
- package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +29 -18
- package/src/components/ui/finance/transactions/transaction-card.tsx +75 -43
- package/src/components/ui/finance/transactions/transfer-merge.test.ts +90 -0
- package/src/components/ui/finance/transactions/transfer-merge.ts +52 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +172 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-amount.tsx +32 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-delete-dialog.tsx +50 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-dialog.tsx +138 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +196 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +201 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +277 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +189 -0
- package/src/components/ui/finance/wallets/query-invalidation.ts +2 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +7 -3
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +10 -0
- package/src/components/ui/finance/wallets/wallets-page.test.tsx +7 -0
- package/src/components/ui/finance/wallets/wallets-page.tsx +21 -5
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +14 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +29 -0
- package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +11 -0
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +10 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +141 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +208 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +26 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +36 -20
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +24 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +18 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.test.ts +84 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +5 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +300 -172
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +411 -323
- package/src/components/ui/tu-do/shared/task-sound-effects.test.ts +189 -0
- package/src/components/ui/tu-do/shared/task-sound-effects.tsx +468 -0
- package/src/hooks/__tests__/use-task-actions.test.tsx +61 -0
- package/src/hooks/use-task-actions.ts +45 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
__resetTaskSoundEffectsForTests,
|
|
8
|
+
configureTaskSoundEffects,
|
|
9
|
+
dispatchTaskSoundCue,
|
|
10
|
+
playTaskSoundCue,
|
|
11
|
+
} from './task-sound-effects';
|
|
12
|
+
|
|
13
|
+
class MockAudioParam {
|
|
14
|
+
value = 0;
|
|
15
|
+
setValueAtTime = vi.fn();
|
|
16
|
+
linearRampToValueAtTime = vi.fn();
|
|
17
|
+
exponentialRampToValueAtTime = vi.fn();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class MockOscillatorNode {
|
|
21
|
+
type: OscillatorType = 'sine';
|
|
22
|
+
frequency = new MockAudioParam();
|
|
23
|
+
connect = vi.fn();
|
|
24
|
+
start = vi.fn();
|
|
25
|
+
stop = vi.fn();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class MockGainNode {
|
|
29
|
+
gain = new MockAudioParam();
|
|
30
|
+
connect = vi.fn();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class MockAudioBuffer {
|
|
34
|
+
private readonly data: Float32Array;
|
|
35
|
+
|
|
36
|
+
constructor(frameCount: number) {
|
|
37
|
+
this.data = new Float32Array(frameCount);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getChannelData = vi.fn(() => this.data);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class MockAudioBufferSourceNode {
|
|
44
|
+
buffer: AudioBuffer | null = null;
|
|
45
|
+
connect = vi.fn();
|
|
46
|
+
start = vi.fn();
|
|
47
|
+
stop = vi.fn();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class MockAudioContext {
|
|
51
|
+
static instances: MockAudioContext[] = [];
|
|
52
|
+
|
|
53
|
+
currentTime = 1;
|
|
54
|
+
destination = {} as AudioDestinationNode;
|
|
55
|
+
sampleRate = 44_100;
|
|
56
|
+
state: AudioContextState = 'running';
|
|
57
|
+
oscillators: MockOscillatorNode[] = [];
|
|
58
|
+
gains: MockGainNode[] = [];
|
|
59
|
+
bufferSources: MockAudioBufferSourceNode[] = [];
|
|
60
|
+
buffers: MockAudioBuffer[] = [];
|
|
61
|
+
resume = vi.fn(() => Promise.resolve());
|
|
62
|
+
|
|
63
|
+
constructor() {
|
|
64
|
+
MockAudioContext.instances.push(this);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
createOscillator() {
|
|
68
|
+
const oscillator = new MockOscillatorNode();
|
|
69
|
+
this.oscillators.push(oscillator);
|
|
70
|
+
return oscillator as unknown as OscillatorNode;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
createGain() {
|
|
74
|
+
const gain = new MockGainNode();
|
|
75
|
+
this.gains.push(gain);
|
|
76
|
+
return gain as unknown as GainNode;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
createBuffer(_channels: number, frameCount: number) {
|
|
80
|
+
const buffer = new MockAudioBuffer(frameCount);
|
|
81
|
+
this.buffers.push(buffer);
|
|
82
|
+
return buffer as unknown as AudioBuffer;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
createBufferSource() {
|
|
86
|
+
const source = new MockAudioBufferSourceNode();
|
|
87
|
+
this.bufferSources.push(source);
|
|
88
|
+
return source as unknown as AudioBufferSourceNode;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function installMockAudioContext() {
|
|
93
|
+
Object.defineProperty(window, 'AudioContext', {
|
|
94
|
+
configurable: true,
|
|
95
|
+
value: MockAudioContext,
|
|
96
|
+
});
|
|
97
|
+
Object.defineProperty(window, 'webkitAudioContext', {
|
|
98
|
+
configurable: true,
|
|
99
|
+
value: undefined,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe('task sound effects', () => {
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
MockAudioContext.instances = [];
|
|
106
|
+
__resetTaskSoundEffectsForTests();
|
|
107
|
+
installMockAudioContext();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
vi.unstubAllGlobals();
|
|
112
|
+
__resetTaskSoundEffectsForTests();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('no-ops without window', () => {
|
|
116
|
+
vi.stubGlobal('window', undefined);
|
|
117
|
+
|
|
118
|
+
expect(() => dispatchTaskSoundCue('create')).not.toThrow();
|
|
119
|
+
expect(() => playTaskSoundCue('create')).not.toThrow();
|
|
120
|
+
expect(MockAudioContext.instances).toHaveLength(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('no-ops when Web Audio is unavailable', () => {
|
|
124
|
+
Object.defineProperty(window, 'AudioContext', {
|
|
125
|
+
configurable: true,
|
|
126
|
+
value: undefined,
|
|
127
|
+
});
|
|
128
|
+
Object.defineProperty(window, 'webkitAudioContext', {
|
|
129
|
+
configurable: true,
|
|
130
|
+
value: undefined,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(() => playTaskSoundCue('create')).not.toThrow();
|
|
134
|
+
expect(MockAudioContext.instances).toHaveLength(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('creates the audio context lazily on the first cue', () => {
|
|
138
|
+
configureTaskSoundEffects({ enabled: true, volume: 35 });
|
|
139
|
+
|
|
140
|
+
expect(MockAudioContext.instances).toHaveLength(0);
|
|
141
|
+
|
|
142
|
+
playTaskSoundCue('update');
|
|
143
|
+
|
|
144
|
+
expect(MockAudioContext.instances).toHaveLength(1);
|
|
145
|
+
expect(MockAudioContext.instances[0]?.oscillators.length).toBeGreaterThan(
|
|
146
|
+
0
|
|
147
|
+
);
|
|
148
|
+
expect(MockAudioContext.instances[0]?.gains.length).toBeGreaterThan(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('respects disabled sound effects and zero volume', () => {
|
|
152
|
+
configureTaskSoundEffects({ enabled: false, volume: 35 });
|
|
153
|
+
playTaskSoundCue('complete');
|
|
154
|
+
expect(MockAudioContext.instances).toHaveLength(0);
|
|
155
|
+
|
|
156
|
+
configureTaskSoundEffects({ enabled: true, volume: 0 });
|
|
157
|
+
playTaskSoundCue('complete');
|
|
158
|
+
expect(MockAudioContext.instances).toHaveLength(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('clamps volume and intensity while reusing one context', () => {
|
|
162
|
+
configureTaskSoundEffects({ enabled: true, volume: 500 });
|
|
163
|
+
|
|
164
|
+
playTaskSoundCue({ cue: 'complete', count: 999, intensity: 99 });
|
|
165
|
+
playTaskSoundCue({ cue: 'move', count: 1, intensity: 1 });
|
|
166
|
+
|
|
167
|
+
expect(MockAudioContext.instances).toHaveLength(1);
|
|
168
|
+
const context = MockAudioContext.instances[0];
|
|
169
|
+
const firstMasterGain = context?.gains[0];
|
|
170
|
+
|
|
171
|
+
expect(firstMasterGain?.gain.linearRampToValueAtTime).toHaveBeenCalledWith(
|
|
172
|
+
1,
|
|
173
|
+
expect.any(Number)
|
|
174
|
+
);
|
|
175
|
+
expect(context?.oscillators.length).toBeGreaterThanOrEqual(6);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('uses one richer aggregate playback path for bulk complete cues', () => {
|
|
179
|
+
configureTaskSoundEffects({ enabled: true, volume: 35 });
|
|
180
|
+
|
|
181
|
+
playTaskSoundCue({ cue: 'complete', count: 8 });
|
|
182
|
+
|
|
183
|
+
expect(MockAudioContext.instances).toHaveLength(1);
|
|
184
|
+
const context = MockAudioContext.instances[0];
|
|
185
|
+
|
|
186
|
+
expect(context?.oscillators).toHaveLength(5);
|
|
187
|
+
expect(context?.gains).toHaveLength(6);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useUserBooleanConfig,
|
|
5
|
+
useUserConfig,
|
|
6
|
+
} from '@tuturuuu/ui/hooks/use-user-config';
|
|
7
|
+
import { useEffect } from 'react';
|
|
8
|
+
|
|
9
|
+
export const TASK_SOUND_EFFECTS_ENABLED_CONFIG_ID =
|
|
10
|
+
'TASK_SOUND_EFFECTS_ENABLED';
|
|
11
|
+
export const TASK_SOUND_EFFECTS_VOLUME_CONFIG_ID = 'TASK_SOUND_EFFECTS_VOLUME';
|
|
12
|
+
export const DEFAULT_TASK_SOUND_EFFECTS_VOLUME = 35;
|
|
13
|
+
|
|
14
|
+
const TASK_SOUND_EFFECT_EVENT = 'tuturuuu:task-sound-effect';
|
|
15
|
+
const VALID_TASK_SOUND_CUES = new Set([
|
|
16
|
+
'create',
|
|
17
|
+
'complete',
|
|
18
|
+
'move',
|
|
19
|
+
'update',
|
|
20
|
+
'delete',
|
|
21
|
+
] as const);
|
|
22
|
+
|
|
23
|
+
export type TaskSoundCue = 'create' | 'complete' | 'move' | 'update' | 'delete';
|
|
24
|
+
|
|
25
|
+
export interface TaskSoundCueOptions {
|
|
26
|
+
cue: TaskSoundCue;
|
|
27
|
+
count?: number;
|
|
28
|
+
intensity?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface TaskSoundPreferences {
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
volume: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface NormalizedTaskSoundCueOptions {
|
|
37
|
+
cue: TaskSoundCue;
|
|
38
|
+
count: number;
|
|
39
|
+
intensity: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type AudioContextConstructor = new () => AudioContext;
|
|
43
|
+
|
|
44
|
+
let audioContext: AudioContext | null = null;
|
|
45
|
+
let preferences: TaskSoundPreferences = {
|
|
46
|
+
enabled: true,
|
|
47
|
+
volume: DEFAULT_TASK_SOUND_EFFECTS_VOLUME,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function clamp(value: number, min: number, max: number) {
|
|
51
|
+
return Math.min(max, Math.max(min, value));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function clampTaskSoundEffectsVolume(
|
|
55
|
+
value: number | string | null | undefined
|
|
56
|
+
) {
|
|
57
|
+
const parsed = typeof value === 'number' ? value : Number(value);
|
|
58
|
+
if (!Number.isFinite(parsed)) return DEFAULT_TASK_SOUND_EFFECTS_VOLUME;
|
|
59
|
+
return Math.round(clamp(parsed, 0, 100));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeTaskSoundCue(
|
|
63
|
+
input: TaskSoundCue | TaskSoundCueOptions | null | undefined
|
|
64
|
+
): NormalizedTaskSoundCueOptions | null {
|
|
65
|
+
const cue = typeof input === 'string' ? input : input?.cue;
|
|
66
|
+
|
|
67
|
+
if (!cue || !VALID_TASK_SOUND_CUES.has(cue)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const cueOptions =
|
|
72
|
+
input && typeof input === 'object' ? input : ({} as TaskSoundCueOptions);
|
|
73
|
+
const rawCount = cueOptions.count;
|
|
74
|
+
const rawIntensity = cueOptions.intensity;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
cue,
|
|
78
|
+
count: Math.round(clamp(Number(rawCount) || 1, 1, 24)),
|
|
79
|
+
intensity: clamp(Number(rawIntensity) || 1, 0.35, 2),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function configureTaskSoundEffects({
|
|
84
|
+
enabled,
|
|
85
|
+
volume,
|
|
86
|
+
}: {
|
|
87
|
+
enabled?: boolean;
|
|
88
|
+
volume?: number | string | null;
|
|
89
|
+
}) {
|
|
90
|
+
preferences = {
|
|
91
|
+
enabled: enabled ?? preferences.enabled,
|
|
92
|
+
volume:
|
|
93
|
+
volume === undefined
|
|
94
|
+
? preferences.volume
|
|
95
|
+
: clampTaskSoundEffectsVolume(volume),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getAudioContextConstructor(): AudioContextConstructor | null {
|
|
100
|
+
if (typeof window === 'undefined') {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const browserWindow = window as typeof window & {
|
|
105
|
+
webkitAudioContext?: AudioContextConstructor;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return window.AudioContext ?? browserWindow.webkitAudioContext ?? null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getAudioContext() {
|
|
112
|
+
const AudioContextCtor = getAudioContextConstructor();
|
|
113
|
+
if (!AudioContextCtor) return null;
|
|
114
|
+
|
|
115
|
+
if (audioContext && audioContext.state !== 'closed') {
|
|
116
|
+
return audioContext;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
audioContext = new AudioContextCtor();
|
|
121
|
+
return audioContext;
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resumeAudioContext(context: AudioContext) {
|
|
128
|
+
if (context.state !== 'suspended') return;
|
|
129
|
+
void context.resume().catch(() => undefined);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function scheduleGainEnvelope(
|
|
133
|
+
gain: AudioParam,
|
|
134
|
+
startAt: number,
|
|
135
|
+
duration: number,
|
|
136
|
+
peakGain: number,
|
|
137
|
+
attack = 0.01
|
|
138
|
+
) {
|
|
139
|
+
const safeGain = Math.max(0.0001, peakGain);
|
|
140
|
+
|
|
141
|
+
gain.setValueAtTime(0.0001, startAt);
|
|
142
|
+
gain.exponentialRampToValueAtTime(safeGain, startAt + attack);
|
|
143
|
+
gain.exponentialRampToValueAtTime(0.0001, startAt + duration);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function scheduleOscillator({
|
|
147
|
+
context,
|
|
148
|
+
destination,
|
|
149
|
+
type,
|
|
150
|
+
frequency,
|
|
151
|
+
endFrequency,
|
|
152
|
+
startAt,
|
|
153
|
+
duration,
|
|
154
|
+
gain,
|
|
155
|
+
}: {
|
|
156
|
+
context: AudioContext;
|
|
157
|
+
destination: AudioNode;
|
|
158
|
+
type: OscillatorType;
|
|
159
|
+
frequency: number;
|
|
160
|
+
endFrequency?: number;
|
|
161
|
+
startAt: number;
|
|
162
|
+
duration: number;
|
|
163
|
+
gain: number;
|
|
164
|
+
}) {
|
|
165
|
+
const oscillator = context.createOscillator();
|
|
166
|
+
const amp = context.createGain();
|
|
167
|
+
const finalFrequency = endFrequency ?? frequency;
|
|
168
|
+
|
|
169
|
+
oscillator.type = type;
|
|
170
|
+
oscillator.frequency.setValueAtTime(Math.max(20, frequency), startAt);
|
|
171
|
+
|
|
172
|
+
if (finalFrequency !== frequency) {
|
|
173
|
+
oscillator.frequency.exponentialRampToValueAtTime(
|
|
174
|
+
Math.max(20, finalFrequency),
|
|
175
|
+
startAt + duration
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
scheduleGainEnvelope(amp.gain, startAt, duration, gain);
|
|
180
|
+
oscillator.connect(amp);
|
|
181
|
+
amp.connect(destination);
|
|
182
|
+
oscillator.start(startAt);
|
|
183
|
+
oscillator.stop(startAt + duration + 0.02);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function scheduleNoiseBurst({
|
|
187
|
+
context,
|
|
188
|
+
destination,
|
|
189
|
+
startAt,
|
|
190
|
+
duration,
|
|
191
|
+
gain,
|
|
192
|
+
}: {
|
|
193
|
+
context: AudioContext;
|
|
194
|
+
destination: AudioNode;
|
|
195
|
+
startAt: number;
|
|
196
|
+
duration: number;
|
|
197
|
+
gain: number;
|
|
198
|
+
}) {
|
|
199
|
+
const sampleRate = context.sampleRate || 44_100;
|
|
200
|
+
const frameCount = Math.max(1, Math.floor(sampleRate * duration));
|
|
201
|
+
const buffer = context.createBuffer(1, frameCount, sampleRate);
|
|
202
|
+
const data = buffer.getChannelData(0);
|
|
203
|
+
|
|
204
|
+
for (let i = 0; i < frameCount; i += 1) {
|
|
205
|
+
const progress = i / frameCount;
|
|
206
|
+
data[i] = (Math.random() * 2 - 1) * (1 - progress) ** 2;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const source = context.createBufferSource();
|
|
210
|
+
const amp = context.createGain();
|
|
211
|
+
|
|
212
|
+
source.buffer = buffer;
|
|
213
|
+
scheduleGainEnvelope(amp.gain, startAt, duration, gain, 0.004);
|
|
214
|
+
source.connect(amp);
|
|
215
|
+
amp.connect(destination);
|
|
216
|
+
source.start(startAt);
|
|
217
|
+
source.stop(startAt + duration + 0.01);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function scheduleCreateCue(
|
|
221
|
+
context: AudioContext,
|
|
222
|
+
destination: AudioNode,
|
|
223
|
+
startAt: number,
|
|
224
|
+
scale: number
|
|
225
|
+
) {
|
|
226
|
+
scheduleNoiseBurst({
|
|
227
|
+
context,
|
|
228
|
+
destination,
|
|
229
|
+
startAt,
|
|
230
|
+
duration: 0.022,
|
|
231
|
+
gain: 0.04 * scale,
|
|
232
|
+
});
|
|
233
|
+
scheduleOscillator({
|
|
234
|
+
context,
|
|
235
|
+
destination,
|
|
236
|
+
type: 'triangle',
|
|
237
|
+
frequency: 560,
|
|
238
|
+
endFrequency: 980,
|
|
239
|
+
startAt,
|
|
240
|
+
duration: 0.1,
|
|
241
|
+
gain: 0.11 * scale,
|
|
242
|
+
});
|
|
243
|
+
scheduleOscillator({
|
|
244
|
+
context,
|
|
245
|
+
destination,
|
|
246
|
+
type: 'sine',
|
|
247
|
+
frequency: 1180,
|
|
248
|
+
endFrequency: 1560,
|
|
249
|
+
startAt: startAt + 0.045,
|
|
250
|
+
duration: 0.15,
|
|
251
|
+
gain: 0.045 * scale,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function scheduleCompleteCue(
|
|
256
|
+
context: AudioContext,
|
|
257
|
+
destination: AudioNode,
|
|
258
|
+
startAt: number,
|
|
259
|
+
scale: number,
|
|
260
|
+
count: number
|
|
261
|
+
) {
|
|
262
|
+
const isBulk = count > 1;
|
|
263
|
+
const baseDuration = isBulk ? 0.42 : 0.32;
|
|
264
|
+
|
|
265
|
+
for (const [index, frequency] of [392, 523.25, 659.25].entries()) {
|
|
266
|
+
scheduleOscillator({
|
|
267
|
+
context,
|
|
268
|
+
destination,
|
|
269
|
+
type: index === 0 ? 'triangle' : 'sine',
|
|
270
|
+
frequency,
|
|
271
|
+
endFrequency: frequency * 1.035,
|
|
272
|
+
startAt: startAt + index * 0.018,
|
|
273
|
+
duration: baseDuration,
|
|
274
|
+
gain: (index === 0 ? 0.065 : 0.045) * scale,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (isBulk) {
|
|
279
|
+
for (const [index, frequency] of [783.99, 1046.5].entries()) {
|
|
280
|
+
scheduleOscillator({
|
|
281
|
+
context,
|
|
282
|
+
destination,
|
|
283
|
+
type: 'sine',
|
|
284
|
+
frequency,
|
|
285
|
+
endFrequency: frequency * 1.025,
|
|
286
|
+
startAt: startAt + 0.11 + index * 0.018,
|
|
287
|
+
duration: 0.32,
|
|
288
|
+
gain: 0.032 * scale,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function scheduleMoveCue(
|
|
295
|
+
context: AudioContext,
|
|
296
|
+
destination: AudioNode,
|
|
297
|
+
startAt: number,
|
|
298
|
+
scale: number
|
|
299
|
+
) {
|
|
300
|
+
scheduleOscillator({
|
|
301
|
+
context,
|
|
302
|
+
destination,
|
|
303
|
+
type: 'triangle',
|
|
304
|
+
frequency: 420,
|
|
305
|
+
endFrequency: 610,
|
|
306
|
+
startAt,
|
|
307
|
+
duration: 0.08,
|
|
308
|
+
gain: 0.075 * scale,
|
|
309
|
+
});
|
|
310
|
+
scheduleNoiseBurst({
|
|
311
|
+
context,
|
|
312
|
+
destination,
|
|
313
|
+
startAt: startAt + 0.055,
|
|
314
|
+
duration: 0.018,
|
|
315
|
+
gain: 0.025 * scale,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function scheduleUpdateCue(
|
|
320
|
+
context: AudioContext,
|
|
321
|
+
destination: AudioNode,
|
|
322
|
+
startAt: number,
|
|
323
|
+
scale: number
|
|
324
|
+
) {
|
|
325
|
+
scheduleOscillator({
|
|
326
|
+
context,
|
|
327
|
+
destination,
|
|
328
|
+
type: 'sine',
|
|
329
|
+
frequency: 650,
|
|
330
|
+
endFrequency: 760,
|
|
331
|
+
startAt,
|
|
332
|
+
duration: 0.085,
|
|
333
|
+
gain: 0.065 * scale,
|
|
334
|
+
});
|
|
335
|
+
scheduleOscillator({
|
|
336
|
+
context,
|
|
337
|
+
destination,
|
|
338
|
+
type: 'triangle',
|
|
339
|
+
frequency: 980,
|
|
340
|
+
startAt: startAt + 0.035,
|
|
341
|
+
duration: 0.07,
|
|
342
|
+
gain: 0.022 * scale,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function scheduleDeleteCue(
|
|
347
|
+
context: AudioContext,
|
|
348
|
+
destination: AudioNode,
|
|
349
|
+
startAt: number,
|
|
350
|
+
scale: number
|
|
351
|
+
) {
|
|
352
|
+
scheduleOscillator({
|
|
353
|
+
context,
|
|
354
|
+
destination,
|
|
355
|
+
type: 'triangle',
|
|
356
|
+
frequency: 250,
|
|
357
|
+
endFrequency: 170,
|
|
358
|
+
startAt,
|
|
359
|
+
duration: 0.16,
|
|
360
|
+
gain: 0.075 * scale,
|
|
361
|
+
});
|
|
362
|
+
scheduleNoiseBurst({
|
|
363
|
+
context,
|
|
364
|
+
destination,
|
|
365
|
+
startAt: startAt + 0.015,
|
|
366
|
+
duration: 0.035,
|
|
367
|
+
gain: 0.018 * scale,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function playTaskSoundCue(
|
|
372
|
+
input: TaskSoundCue | TaskSoundCueOptions | null | undefined
|
|
373
|
+
) {
|
|
374
|
+
const cue = normalizeTaskSoundCue(input);
|
|
375
|
+
const volume = clampTaskSoundEffectsVolume(preferences.volume);
|
|
376
|
+
|
|
377
|
+
if (!cue || !preferences.enabled || volume <= 0) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const context = getAudioContext();
|
|
382
|
+
if (!context) return;
|
|
383
|
+
|
|
384
|
+
resumeAudioContext(context);
|
|
385
|
+
|
|
386
|
+
const startAt = context.currentTime + 0.006;
|
|
387
|
+
const masterGain = context.createGain();
|
|
388
|
+
const bulkScale = cue.count > 1 ? Math.min(1.55, 1 + cue.count * 0.06) : 1;
|
|
389
|
+
const scale = clamp((volume / 100) * cue.intensity * bulkScale, 0.05, 1);
|
|
390
|
+
|
|
391
|
+
masterGain.gain.setValueAtTime(0.0001, startAt);
|
|
392
|
+
masterGain.gain.linearRampToValueAtTime(scale, startAt + 0.01);
|
|
393
|
+
masterGain.gain.exponentialRampToValueAtTime(0.0001, startAt + 0.65);
|
|
394
|
+
masterGain.connect(context.destination);
|
|
395
|
+
|
|
396
|
+
switch (cue.cue) {
|
|
397
|
+
case 'create':
|
|
398
|
+
scheduleCreateCue(context, masterGain, startAt, scale);
|
|
399
|
+
break;
|
|
400
|
+
case 'complete':
|
|
401
|
+
scheduleCompleteCue(context, masterGain, startAt, scale, cue.count);
|
|
402
|
+
break;
|
|
403
|
+
case 'move':
|
|
404
|
+
scheduleMoveCue(context, masterGain, startAt, scale);
|
|
405
|
+
break;
|
|
406
|
+
case 'update':
|
|
407
|
+
scheduleUpdateCue(context, masterGain, startAt, scale);
|
|
408
|
+
break;
|
|
409
|
+
case 'delete':
|
|
410
|
+
scheduleDeleteCue(context, masterGain, startAt, scale);
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function dispatchTaskSoundCue(
|
|
416
|
+
input: TaskSoundCue | TaskSoundCueOptions
|
|
417
|
+
) {
|
|
418
|
+
if (typeof window === 'undefined') return;
|
|
419
|
+
|
|
420
|
+
const cue = normalizeTaskSoundCue(input);
|
|
421
|
+
if (!cue) return;
|
|
422
|
+
|
|
423
|
+
window.dispatchEvent(
|
|
424
|
+
new CustomEvent<TaskSoundCueOptions>(TASK_SOUND_EFFECT_EVENT, {
|
|
425
|
+
detail: cue,
|
|
426
|
+
})
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function TaskSoundEffectsInitializer() {
|
|
431
|
+
const { value: enabled } = useUserBooleanConfig(
|
|
432
|
+
TASK_SOUND_EFFECTS_ENABLED_CONFIG_ID,
|
|
433
|
+
true
|
|
434
|
+
);
|
|
435
|
+
const { data: volume } = useUserConfig(
|
|
436
|
+
TASK_SOUND_EFFECTS_VOLUME_CONFIG_ID,
|
|
437
|
+
String(DEFAULT_TASK_SOUND_EFFECTS_VOLUME)
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
useEffect(() => {
|
|
441
|
+
configureTaskSoundEffects({ enabled, volume });
|
|
442
|
+
}, [enabled, volume]);
|
|
443
|
+
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
if (typeof window === 'undefined') return;
|
|
446
|
+
|
|
447
|
+
const handleSoundCue = (event: Event) => {
|
|
448
|
+
playTaskSoundCue(
|
|
449
|
+
(event as CustomEvent<TaskSoundCueOptions>).detail ?? null
|
|
450
|
+
);
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
window.addEventListener(TASK_SOUND_EFFECT_EVENT, handleSoundCue);
|
|
454
|
+
return () => {
|
|
455
|
+
window.removeEventListener(TASK_SOUND_EFFECT_EVENT, handleSoundCue);
|
|
456
|
+
};
|
|
457
|
+
}, []);
|
|
458
|
+
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function __resetTaskSoundEffectsForTests() {
|
|
463
|
+
audioContext = null;
|
|
464
|
+
preferences = {
|
|
465
|
+
enabled: true,
|
|
466
|
+
volume: DEFAULT_TASK_SOUND_EFFECTS_VOLUME,
|
|
467
|
+
};
|
|
468
|
+
}
|