@umituz/react-native-sound 1.2.32 → 1.2.33
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/package.json +1 -1
- package/src/application/SoundCommands.ts +233 -0
- package/src/application/SoundEvents.ts +101 -0
- package/src/application/SoundService.ts +185 -0
- package/src/application/interfaces/IAudioService.ts +1 -1
- package/src/application/interfaces/ISoundCache.ts +4 -6
- package/src/domain/value-objects.ts +128 -0
- package/src/index.ts +35 -26
- package/src/infrastructure/AudioConfig.ts +0 -1
- package/src/infrastructure/AudioRepository.ts +216 -0
- package/src/presentation/SoundStore.ts +156 -0
- package/src/presentation/useSound.ts +183 -0
- package/src/types.ts +1 -2
- package/src/utils.ts +167 -5
- package/src/application/SoundServiceFacade.ts +0 -112
- package/src/application/presenters/SoundPresenter.ts +0 -78
- package/src/application/use-cases/BaseUseCase.ts +0 -36
- package/src/application/use-cases/ClearCacheUseCase.ts +0 -16
- package/src/application/use-cases/PauseSoundUseCase.ts +0 -26
- package/src/application/use-cases/PlaySoundUseCase.ts +0 -106
- package/src/application/use-cases/PreloadSoundUseCase.ts +0 -56
- package/src/application/use-cases/ResumeSoundUseCase.ts +0 -25
- package/src/application/use-cases/SeekSoundUseCase.ts +0 -26
- package/src/application/use-cases/SetRateUseCase.ts +0 -30
- package/src/application/use-cases/SetVolumeUseCase.ts +0 -30
- package/src/application/use-cases/StopSoundUseCase.ts +0 -27
- package/src/domain/entities/SoundState.ts +0 -83
- package/src/domain/value-objects/PlaybackPosition.ts +0 -30
- package/src/domain/value-objects/Rate.ts +0 -26
- package/src/domain/value-objects/SoundId.ts +0 -22
- package/src/domain/value-objects/SoundSource.ts +0 -30
- package/src/domain/value-objects/Volume.ts +0 -26
- package/src/infrastructure/ExpoAudioService.ts +0 -88
- package/src/infrastructure/SoundCache.ts +0 -70
- package/src/presentation/hooks/useSound.ts +0 -47
- package/src/presentation/store.ts +0 -58
package/package.json
CHANGED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic Command Processor - Eliminates Use Case Duplication
|
|
3
|
+
* Replaces 8 separate use case files with single command pattern
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SoundSourceValue } from '../domain/value-objects';
|
|
7
|
+
import type { IAudioService, PlaybackStatus } from './interfaces/IAudioService';
|
|
8
|
+
import type { ISoundCache } from './interfaces/ISoundCache';
|
|
9
|
+
import { SoundEvents } from './SoundEvents';
|
|
10
|
+
import { SoundError } from '../domain/errors/SoundError';
|
|
11
|
+
import { Logger } from '../infrastructure/Logger';
|
|
12
|
+
|
|
13
|
+
// ===== Command Definitions =====
|
|
14
|
+
export type SoundCommand =
|
|
15
|
+
| { type: 'PLAY'; payload: PlayCommand }
|
|
16
|
+
| { type: 'PAUSE'; payload: { sound: unknown } }
|
|
17
|
+
| { type: 'RESUME'; payload: { sound: unknown } }
|
|
18
|
+
| { type: 'STOP'; payload: { sound: unknown } }
|
|
19
|
+
| { type: 'SEEK'; payload: { sound: unknown; positionMillis: number } }
|
|
20
|
+
| { type: 'SET_VOLUME'; payload: { sound: unknown; volume: number } }
|
|
21
|
+
| { type: 'SET_RATE'; payload: { sound: unknown; rate: number } }
|
|
22
|
+
| { type: 'PRELOAD'; payload: PreloadCommand }
|
|
23
|
+
| { type: 'UNLOAD'; payload: void }
|
|
24
|
+
| { type: 'CLEAR_CACHE'; payload: void };
|
|
25
|
+
|
|
26
|
+
export interface PlayCommand {
|
|
27
|
+
id: string;
|
|
28
|
+
source: SoundSourceValue;
|
|
29
|
+
volume?: number;
|
|
30
|
+
rate?: number;
|
|
31
|
+
isLooping?: boolean;
|
|
32
|
+
positionMillis?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PreloadCommand {
|
|
36
|
+
id: string;
|
|
37
|
+
source: SoundSourceValue;
|
|
38
|
+
volume?: number;
|
|
39
|
+
rate?: number;
|
|
40
|
+
isLooping?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ===== Generic Command Processor =====
|
|
44
|
+
export class SoundCommandProcessor {
|
|
45
|
+
private currentSound: unknown | null = null;
|
|
46
|
+
private currentId: string | null = null;
|
|
47
|
+
private currentStatusUpdater: ((status: PlaybackStatus) => void) | null = null;
|
|
48
|
+
private isPlaying = false;
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
private readonly audioService: IAudioService,
|
|
52
|
+
private readonly cache: ISoundCache,
|
|
53
|
+
private readonly events: SoundEvents
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
async execute(command: SoundCommand): Promise<void> {
|
|
57
|
+
try {
|
|
58
|
+
await this.handleCommand(command);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error instanceof SoundError) throw error;
|
|
61
|
+
throw SoundError.playbackFailed(error);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async handleCommand(command: SoundCommand): Promise<void> {
|
|
66
|
+
switch (command.type) {
|
|
67
|
+
case 'PLAY':
|
|
68
|
+
await this.handlePlay(command.payload);
|
|
69
|
+
break;
|
|
70
|
+
case 'PAUSE':
|
|
71
|
+
await this.handlePause(command.payload);
|
|
72
|
+
break;
|
|
73
|
+
case 'RESUME':
|
|
74
|
+
await this.handleResume(command.payload);
|
|
75
|
+
break;
|
|
76
|
+
case 'STOP':
|
|
77
|
+
await this.handleStop(command.payload);
|
|
78
|
+
break;
|
|
79
|
+
case 'SEEK':
|
|
80
|
+
await this.handleSeek(command.payload);
|
|
81
|
+
break;
|
|
82
|
+
case 'SET_VOLUME':
|
|
83
|
+
await this.handleSetVolume(command.payload);
|
|
84
|
+
break;
|
|
85
|
+
case 'SET_RATE':
|
|
86
|
+
await this.handleSetRate(command.payload);
|
|
87
|
+
break;
|
|
88
|
+
case 'PRELOAD':
|
|
89
|
+
await this.handlePreload(command.payload);
|
|
90
|
+
break;
|
|
91
|
+
case 'UNLOAD':
|
|
92
|
+
await this.handleUnload();
|
|
93
|
+
break;
|
|
94
|
+
case 'CLEAR_CACHE':
|
|
95
|
+
await this.handleClearCache();
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ===== Command Handlers =====
|
|
101
|
+
private async handlePlay(payload: PlayCommand): Promise<void> {
|
|
102
|
+
const { id, source, volume = 1.0, rate = 1.0, isLooping = false, positionMillis } = payload;
|
|
103
|
+
|
|
104
|
+
// Toggle if same sound
|
|
105
|
+
if (this.currentId === id && this.currentSound && this.isPlaying) {
|
|
106
|
+
await this.audioService.pause(this.currentSound);
|
|
107
|
+
this.isPlaying = false;
|
|
108
|
+
this.events.emit('PLAYBACK_STOPPED');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Cleanup current
|
|
113
|
+
await this.unloadCurrent();
|
|
114
|
+
|
|
115
|
+
// Try cache first
|
|
116
|
+
const cached = this.cache.get(id);
|
|
117
|
+
if (cached) {
|
|
118
|
+
this.currentSound = cached.sound;
|
|
119
|
+
this.cache.delete(id).catch((error) => Logger.warn('Failed to delete from cache', error));
|
|
120
|
+
} else {
|
|
121
|
+
const statusUpdater = (status: PlaybackStatus) => {
|
|
122
|
+
this.events.emit('STATUS_UPDATE', status);
|
|
123
|
+
this.isPlaying = status.isPlaying;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const { sound } = await this.audioService.createSound(
|
|
127
|
+
source,
|
|
128
|
+
{ shouldPlay: true, isLooping, volume, rate, positionMillis },
|
|
129
|
+
statusUpdater
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
this.currentSound = sound;
|
|
133
|
+
this.currentStatusUpdater = statusUpdater;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
await this.audioService.play(this.currentSound);
|
|
137
|
+
this.currentId = id;
|
|
138
|
+
this.isPlaying = true;
|
|
139
|
+
|
|
140
|
+
this.events.emit('PLAYBACK_STARTED', {
|
|
141
|
+
id,
|
|
142
|
+
source,
|
|
143
|
+
volume,
|
|
144
|
+
rate,
|
|
145
|
+
isLooping,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async handlePause(payload: { sound: unknown }): Promise<void> {
|
|
150
|
+
if (!payload.sound) return;
|
|
151
|
+
await this.audioService.pause(payload.sound);
|
|
152
|
+
this.isPlaying = false;
|
|
153
|
+
this.events.emit('PLAYBACK_STOPPED');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async handleResume(payload: { sound: unknown }): Promise<void> {
|
|
157
|
+
if (!payload.sound) return;
|
|
158
|
+
await this.audioService.play(payload.sound);
|
|
159
|
+
this.isPlaying = true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async handleStop(payload: { sound: unknown }): Promise<void> {
|
|
163
|
+
if (!payload.sound) return;
|
|
164
|
+
await this.audioService.stop(payload.sound);
|
|
165
|
+
this.isPlaying = false;
|
|
166
|
+
this.events.emit('PLAYBACK_STOPPED');
|
|
167
|
+
this.events.emit('STATE_RESET');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async handleSeek(payload: { sound: unknown; positionMillis: number }): Promise<void> {
|
|
171
|
+
if (!payload.sound) return;
|
|
172
|
+
await this.audioService.setPosition(payload.sound, payload.positionMillis);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async handleSetVolume(payload: { sound: unknown; volume: number }): Promise<void> {
|
|
176
|
+
if (!payload.sound) return;
|
|
177
|
+
await this.audioService.setVolume(payload.sound, payload.volume);
|
|
178
|
+
this.events.emit('VOLUME_CHANGED', payload.volume);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async handleSetRate(payload: { sound: unknown; rate: number }): Promise<void> {
|
|
182
|
+
if (!payload.sound) return;
|
|
183
|
+
await this.audioService.setRate(payload.sound, payload.rate);
|
|
184
|
+
this.events.emit('RATE_CHANGED', payload.rate);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async handlePreload(payload: PreloadCommand): Promise<void> {
|
|
188
|
+
const { id, source, volume = 1.0, rate = 1.0, isLooping = false } = payload;
|
|
189
|
+
|
|
190
|
+
if (this.cache.has(id)) return;
|
|
191
|
+
|
|
192
|
+
const { sound } = await this.audioService.createSound(
|
|
193
|
+
source,
|
|
194
|
+
{ shouldPlay: false, isLooping, volume, rate },
|
|
195
|
+
() => {}
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
await this.cache.set(id, { sound, source, loadedAt: Date.now() });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async handleUnload(): Promise<void> {
|
|
202
|
+
await this.unloadCurrent();
|
|
203
|
+
this.events.emit('STATE_RESET');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private async handleClearCache(): Promise<void> {
|
|
207
|
+
await this.cache.clear();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ===== Private Helpers =====
|
|
211
|
+
private async unloadCurrent(): Promise<void> {
|
|
212
|
+
if (this.currentSound) {
|
|
213
|
+
try {
|
|
214
|
+
await this.audioService.unload(this.currentSound);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
Logger.warn('Failed to unload sound', error);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
this.currentStatusUpdater = null;
|
|
220
|
+
this.currentSound = null;
|
|
221
|
+
this.currentId = null;
|
|
222
|
+
this.isPlaying = false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ===== Public API =====
|
|
226
|
+
getCurrentSound(): unknown | null {
|
|
227
|
+
return this.currentSound;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
getCurrentId(): string | null {
|
|
231
|
+
return this.currentId;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event Bus - Eliminates Presenter Pattern
|
|
3
|
+
* Direct communication between commands and store
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { PlaybackStatus } from './interfaces/IAudioService';
|
|
7
|
+
import type { SoundSourceValue } from '../domain/value-objects';
|
|
8
|
+
import { Logger } from '../infrastructure/Logger';
|
|
9
|
+
|
|
10
|
+
// ===== Event Types =====
|
|
11
|
+
export type SoundEvent =
|
|
12
|
+
| { type: 'PLAYBACK_STARTED'; payload: PlaybackStartedEvent }
|
|
13
|
+
| { type: 'PLAYBACK_STOPPED' }
|
|
14
|
+
| { type: 'STATUS_UPDATE'; payload: PlaybackStatus }
|
|
15
|
+
| { type: 'VOLUME_CHANGED'; payload: number }
|
|
16
|
+
| { type: 'RATE_CHANGED'; payload: number }
|
|
17
|
+
| { type: 'STATE_RESET' }
|
|
18
|
+
| { type: 'ERROR'; payload: string };
|
|
19
|
+
|
|
20
|
+
export interface PlaybackStartedEvent {
|
|
21
|
+
id: string;
|
|
22
|
+
source: SoundSourceValue;
|
|
23
|
+
volume: number;
|
|
24
|
+
rate: number;
|
|
25
|
+
isLooping: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ===== Event Bus Implementation =====
|
|
29
|
+
type EventListener = (payload: unknown) => void;
|
|
30
|
+
|
|
31
|
+
export class SoundEvents {
|
|
32
|
+
private listeners = new Map<string, Set<EventListener>>();
|
|
33
|
+
|
|
34
|
+
on(eventType: string, listener: EventListener): () => void {
|
|
35
|
+
if (!this.listeners.has(eventType)) {
|
|
36
|
+
this.listeners.set(eventType, new Set());
|
|
37
|
+
}
|
|
38
|
+
const listeners = this.listeners.get(eventType);
|
|
39
|
+
if (listeners) {
|
|
40
|
+
listeners.add(listener);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Return unsubscribe function
|
|
44
|
+
return () => {
|
|
45
|
+
this.off(eventType, listener);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
off(eventType: string, listener: EventListener): void {
|
|
50
|
+
const eventListeners = this.listeners.get(eventType);
|
|
51
|
+
if (eventListeners) {
|
|
52
|
+
eventListeners.delete(listener);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
emit<T = unknown>(eventType: string, payload?: T): void {
|
|
57
|
+
const eventListeners = this.listeners.get(eventType);
|
|
58
|
+
if (eventListeners) {
|
|
59
|
+
eventListeners.forEach((listener) => {
|
|
60
|
+
try {
|
|
61
|
+
listener(payload as T);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
Logger.error(`Error in event listener for ${eventType}`, error);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
clear(): void {
|
|
70
|
+
this.listeners.clear();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ===== Convenience Methods =====
|
|
74
|
+
emitPlaybackStarted(event: PlaybackStartedEvent): void {
|
|
75
|
+
this.emit('PLAYBACK_STARTED', event);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
emitPlaybackStopped(): void {
|
|
79
|
+
this.emit('PLAYBACK_STOPPED');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
emitStatusUpdate(status: PlaybackStatus): void {
|
|
83
|
+
this.emit('STATUS_UPDATE', status);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
emitVolumeChanged(volume: number): void {
|
|
87
|
+
this.emit('VOLUME_CHANGED', volume);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
emitRateChanged(rate: number): void {
|
|
91
|
+
this.emit('RATE_CHANGED', rate);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
emitStateReset(): void {
|
|
95
|
+
this.emit('STATE_RESET');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
emitError(error: string): void {
|
|
99
|
+
this.emit('ERROR', error);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Sound Service - Combines Facade + Repository Pattern
|
|
3
|
+
* Single entry point for all sound operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SoundSourceValue } from '../domain/value-objects';
|
|
7
|
+
import type { PlaybackOptions } from '../types';
|
|
8
|
+
import { AudioConfig } from '../infrastructure/AudioConfig';
|
|
9
|
+
import { AudioRepository } from '../infrastructure/AudioRepository';
|
|
10
|
+
import { SoundCommandProcessor, type SoundCommand, type PlayCommand, type PreloadCommand } from './SoundCommands';
|
|
11
|
+
import { SoundEvents } from './SoundEvents';
|
|
12
|
+
import { Logger } from '../infrastructure/Logger';
|
|
13
|
+
|
|
14
|
+
export class SoundService {
|
|
15
|
+
private static instance: SoundService | null = null;
|
|
16
|
+
private repository: AudioRepository;
|
|
17
|
+
private commandProcessor: SoundCommandProcessor;
|
|
18
|
+
private events: SoundEvents;
|
|
19
|
+
|
|
20
|
+
private constructor() {
|
|
21
|
+
this.events = new SoundEvents();
|
|
22
|
+
this.repository = new AudioRepository(this.events);
|
|
23
|
+
this.commandProcessor = new SoundCommandProcessor(
|
|
24
|
+
this.repository.audioService,
|
|
25
|
+
this.repository.cache,
|
|
26
|
+
this.events
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Initialize audio config
|
|
30
|
+
AudioConfig.configure().catch((error) => Logger.warn('Failed to configure audio', error));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static getInstance(): SoundService {
|
|
34
|
+
if (!this.instance) {
|
|
35
|
+
this.instance = new SoundService();
|
|
36
|
+
}
|
|
37
|
+
return this.instance;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ===== Public API =====
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Play a sound
|
|
44
|
+
*/
|
|
45
|
+
async play(id: string, source: SoundSourceValue, options?: PlaybackOptions): Promise<void> {
|
|
46
|
+
const command: SoundCommand = {
|
|
47
|
+
type: 'PLAY',
|
|
48
|
+
payload: {
|
|
49
|
+
id,
|
|
50
|
+
source,
|
|
51
|
+
volume: options?.volume,
|
|
52
|
+
rate: options?.rate,
|
|
53
|
+
isLooping: options?.isLooping,
|
|
54
|
+
positionMillis: options?.positionMillis,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
await this.commandProcessor.execute(command);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Pause playback
|
|
62
|
+
*/
|
|
63
|
+
async pause(): Promise<void> {
|
|
64
|
+
const sound = this.commandProcessor.getCurrentSound();
|
|
65
|
+
await this.commandProcessor.execute({ type: 'PAUSE', payload: { sound } });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resume playback
|
|
70
|
+
*/
|
|
71
|
+
async resume(): Promise<void> {
|
|
72
|
+
const sound = this.commandProcessor.getCurrentSound();
|
|
73
|
+
await this.commandProcessor.execute({ type: 'RESUME', payload: { sound } });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Stop playback
|
|
78
|
+
*/
|
|
79
|
+
async stop(): Promise<void> {
|
|
80
|
+
const sound = this.commandProcessor.getCurrentSound();
|
|
81
|
+
await this.commandProcessor.execute({ type: 'STOP', payload: { sound } });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Seek to position
|
|
86
|
+
*/
|
|
87
|
+
async seek(positionMillis: number): Promise<void> {
|
|
88
|
+
const sound = this.commandProcessor.getCurrentSound();
|
|
89
|
+
await this.commandProcessor.execute({
|
|
90
|
+
type: 'SEEK',
|
|
91
|
+
payload: { sound, positionMillis },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Set volume
|
|
97
|
+
*/
|
|
98
|
+
async setVolume(volume: number): Promise<void> {
|
|
99
|
+
const sound = this.commandProcessor.getCurrentSound();
|
|
100
|
+
await this.commandProcessor.execute({
|
|
101
|
+
type: 'SET_VOLUME',
|
|
102
|
+
payload: { sound, volume },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Set rate
|
|
108
|
+
*/
|
|
109
|
+
async setRate(rate: number): Promise<void> {
|
|
110
|
+
const sound = this.commandProcessor.getCurrentSound();
|
|
111
|
+
await this.commandProcessor.execute({
|
|
112
|
+
type: 'SET_RATE',
|
|
113
|
+
payload: { sound, rate },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Preload a sound
|
|
119
|
+
*/
|
|
120
|
+
async preload(id: string, source: SoundSourceValue, options?: PlaybackOptions): Promise<void> {
|
|
121
|
+
const command: SoundCommand = {
|
|
122
|
+
type: 'PRELOAD',
|
|
123
|
+
payload: {
|
|
124
|
+
id,
|
|
125
|
+
source,
|
|
126
|
+
volume: options?.volume,
|
|
127
|
+
rate: options?.rate,
|
|
128
|
+
isLooping: options?.isLooping,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
await this.commandProcessor.execute(command);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Unload current sound
|
|
136
|
+
*/
|
|
137
|
+
async unload(): Promise<void> {
|
|
138
|
+
await this.commandProcessor.execute({ type: 'UNLOAD', payload: undefined });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Clear cache
|
|
143
|
+
*/
|
|
144
|
+
async clearCache(): Promise<void> {
|
|
145
|
+
await this.commandProcessor.execute({ type: 'CLEAR_CACHE', payload: undefined });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Check if sound is cached
|
|
150
|
+
*/
|
|
151
|
+
isCached(id: string): boolean {
|
|
152
|
+
return this.repository.cache.has(id);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get current playing id
|
|
157
|
+
*/
|
|
158
|
+
getCurrentId(): string | null {
|
|
159
|
+
return this.commandProcessor.getCurrentId();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Subscribe to events
|
|
164
|
+
*/
|
|
165
|
+
on(eventType: string, listener: (payload: unknown) => void): () => void {
|
|
166
|
+
return this.events.on(eventType, listener);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Cleanup resources
|
|
171
|
+
*/
|
|
172
|
+
async dispose(): Promise<void> {
|
|
173
|
+
await this.unload();
|
|
174
|
+
await this.clearCache();
|
|
175
|
+
this.events.clear();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get events instance (for internal use by useSound hook)
|
|
180
|
+
* @internal
|
|
181
|
+
*/
|
|
182
|
+
getEvents(): SoundEvents {
|
|
183
|
+
return this.events;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Sound Cache Interface
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { SoundSourceValue } from '../../domain/value-objects
|
|
5
|
+
import type { SoundSourceValue } from '../../domain/value-objects';
|
|
6
6
|
|
|
7
7
|
export interface CachedSound {
|
|
8
8
|
sound: unknown;
|
|
@@ -12,10 +12,8 @@ export interface CachedSound {
|
|
|
12
12
|
|
|
13
13
|
export interface ISoundCache {
|
|
14
14
|
get(id: string): CachedSound | undefined;
|
|
15
|
-
set(id: string, cached: CachedSound): void
|
|
16
|
-
delete(id: string): void
|
|
15
|
+
set(id: string, cached: CachedSound): Promise<void>;
|
|
16
|
+
delete(id: string): Promise<void>;
|
|
17
17
|
has(id: string): boolean;
|
|
18
|
-
clear(): void
|
|
19
|
-
cleanExpired(): void;
|
|
20
|
-
enforceLimit(): void;
|
|
18
|
+
clear(): Promise<void>;
|
|
21
19
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value Objects - Consolidated with Generic Base Class
|
|
3
|
+
* Eliminates duplication across multiple files
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ===== Generic Base Value Object =====
|
|
7
|
+
export abstract class ValueObject<T> {
|
|
8
|
+
protected readonly value: T;
|
|
9
|
+
|
|
10
|
+
constructor(value: T, validator?: (value: T) => void) {
|
|
11
|
+
if (validator) validator(value);
|
|
12
|
+
this.value = this.transform(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
protected abstract transform(value: T): T;
|
|
16
|
+
|
|
17
|
+
getValue(): T {
|
|
18
|
+
return this.value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
equals(other: ValueObject<T>): boolean {
|
|
22
|
+
return other instanceof this.constructor && this.value === other.value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
toString(): string {
|
|
26
|
+
return String(this.value);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ===== Sound ID =====
|
|
31
|
+
export class SoundId extends ValueObject<string> {
|
|
32
|
+
protected transform(value: string): string {
|
|
33
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
34
|
+
throw new Error('Sound id must be a non-empty string');
|
|
35
|
+
}
|
|
36
|
+
return value.trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
override toString(): string {
|
|
40
|
+
return this.value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ===== Sound Source =====
|
|
45
|
+
export type SoundSourceValue = number | { uri: string; headers?: Record<string, string> };
|
|
46
|
+
|
|
47
|
+
export class SoundSource extends ValueObject<SoundSourceValue> {
|
|
48
|
+
protected transform(value: SoundSourceValue): SoundSourceValue {
|
|
49
|
+
if (value === null || value === undefined) {
|
|
50
|
+
throw new Error('Sound source cannot be null or undefined');
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
isLocal(): boolean {
|
|
56
|
+
return typeof this.value === 'number';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
isRemote(): boolean {
|
|
60
|
+
return typeof this.value === 'object' && 'uri' in this.value;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ===== Volume (0.0 - 1.0) =====
|
|
65
|
+
export class Volume extends ValueObject<number> {
|
|
66
|
+
private readonly MIN = 0;
|
|
67
|
+
private readonly MAX = 1;
|
|
68
|
+
|
|
69
|
+
protected transform(value: number): number {
|
|
70
|
+
if (!Number.isFinite(value)) return 1.0;
|
|
71
|
+
return Math.max(this.MIN, Math.min(this.MAX, value));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ===== Playback Rate (0.5 - 2.0) =====
|
|
76
|
+
export class Rate extends ValueObject<number> {
|
|
77
|
+
private readonly MIN = 0.5;
|
|
78
|
+
private readonly MAX = 2.0;
|
|
79
|
+
|
|
80
|
+
protected transform(value: number): number {
|
|
81
|
+
if (!Number.isFinite(value)) return 1.0;
|
|
82
|
+
return Math.max(this.MIN, Math.min(this.MAX, value));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ===== Playback Position =====
|
|
87
|
+
export class PlaybackPosition extends ValueObject<number> {
|
|
88
|
+
constructor(value: number, private readonly duration?: number) {
|
|
89
|
+
super(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
protected transform(value: number): number {
|
|
93
|
+
if (!Number.isFinite(value)) {
|
|
94
|
+
throw new Error('Position must be a finite number');
|
|
95
|
+
}
|
|
96
|
+
return this.duration ? Math.max(0, Math.min(this.duration, value)) : value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
getDuration(): number | undefined {
|
|
100
|
+
return this.duration;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ===== Factory for Batch Creation =====
|
|
105
|
+
export class SoundValueObjects {
|
|
106
|
+
static create(params: {
|
|
107
|
+
id: string;
|
|
108
|
+
source: SoundSourceValue;
|
|
109
|
+
volume?: number;
|
|
110
|
+
rate?: number;
|
|
111
|
+
position?: number;
|
|
112
|
+
duration?: number;
|
|
113
|
+
}): {
|
|
114
|
+
id: SoundId;
|
|
115
|
+
source: SoundSource;
|
|
116
|
+
volume: Volume;
|
|
117
|
+
rate: Rate;
|
|
118
|
+
position?: PlaybackPosition;
|
|
119
|
+
} {
|
|
120
|
+
return {
|
|
121
|
+
id: new SoundId(params.id),
|
|
122
|
+
source: new SoundSource(params.source),
|
|
123
|
+
volume: new Volume(params.volume ?? 1.0),
|
|
124
|
+
rate: new Rate(params.rate ?? 1.0),
|
|
125
|
+
position: params.position ? new PlaybackPosition(params.position, params.duration) : undefined,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|