@umituz/react-native-sound 1.2.31 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-sound",
3
- "version": "1.2.31",
3
+ "version": "1.2.33",
4
4
  "description": "Universal sound playback and caching library for React Native apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Audio Service Interface
3
+ */
4
+
5
+ import type { AVPlaybackStatus } from 'expo-av';
6
+ import type { SoundSourceValue } from '../../domain/value-objects';
7
+
8
+ export interface PlaybackStatus {
9
+ isLoaded: boolean;
10
+ isPlaying: boolean;
11
+ isBuffering: boolean;
12
+ positionMillis: number;
13
+ durationMillis: number;
14
+ rate?: number;
15
+ isLooping: boolean;
16
+ didJustFinish: boolean;
17
+ error?: string;
18
+ }
19
+
20
+ export interface PlaybackOptions {
21
+ shouldPlay: boolean;
22
+ isLooping: boolean;
23
+ volume: number;
24
+ rate: number;
25
+ positionMillis?: number;
26
+ }
27
+
28
+ export interface IAudioService {
29
+ createSound(
30
+ source: SoundSourceValue,
31
+ options: PlaybackOptions,
32
+ onStatusUpdate: (status: PlaybackStatus) => void
33
+ ): Promise<{ sound: unknown }>;
34
+ play(sound: unknown): Promise<void>;
35
+ pause(sound: unknown): Promise<void>;
36
+ stop(sound: unknown): Promise<void>;
37
+ unload(sound: unknown): Promise<void>;
38
+ setVolume(sound: unknown, volume: number): Promise<void>;
39
+ setRate(sound: unknown, rate: number): Promise<void>;
40
+ setPosition(sound: unknown, positionMillis: number): Promise<void>;
41
+ getStatus(sound: unknown): Promise<PlaybackStatus | null>;
42
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Sound Cache Interface
3
+ */
4
+
5
+ import type { SoundSourceValue } from '../../domain/value-objects';
6
+
7
+ export interface CachedSound {
8
+ sound: unknown;
9
+ source: SoundSourceValue;
10
+ loadedAt: number;
11
+ }
12
+
13
+ export interface ISoundCache {
14
+ get(id: string): CachedSound | undefined;
15
+ set(id: string, cached: CachedSound): Promise<void>;
16
+ delete(id: string): Promise<void>;
17
+ has(id: string): boolean;
18
+ clear(): Promise<void>;
19
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Domain Errors for Sound Operations
3
+ */
4
+
5
+ export enum SoundErrorCode {
6
+ INVALID_SOUND_ID = 'INVALID_SOUND_ID',
7
+ INVALID_SOUND_SOURCE = 'INVALID_SOUND_SOURCE',
8
+ PLAYBACK_FAILED = 'PLAYBACK_FAILED',
9
+ SOUND_NOT_LOADED = 'SOUND_NOT_LOADED',
10
+ INVALID_POSITION = 'INVALID_POSITION',
11
+ CACHE_ERROR = 'CACHE_ERROR',
12
+ }
13
+
14
+ export class SoundError extends Error {
15
+ constructor(
16
+ public readonly code: SoundErrorCode,
17
+ message: string,
18
+ public readonly originalError?: unknown
19
+ ) {
20
+ super(message);
21
+ this.name = 'SoundError';
22
+ }
23
+
24
+ static invalidSoundId(): SoundError {
25
+ return new SoundError(SoundErrorCode.INVALID_SOUND_ID, 'Sound id must be a non-empty string');
26
+ }
27
+
28
+ static invalidSoundSource(): SoundError {
29
+ return new SoundError(SoundErrorCode.INVALID_SOUND_SOURCE, 'Sound source cannot be null or undefined');
30
+ }
31
+
32
+ static playbackFailed(error: unknown): SoundError {
33
+ const message = error instanceof Error ? error.message : 'Unknown playback error';
34
+ return new SoundError(SoundErrorCode.PLAYBACK_FAILED, message, error);
35
+ }
36
+
37
+ static soundNotLoaded(): SoundError {
38
+ return new SoundError(SoundErrorCode.SOUND_NOT_LOADED, 'Sound is not loaded');
39
+ }
40
+
41
+ static invalidPosition(): SoundError {
42
+ return new SoundError(SoundErrorCode.INVALID_POSITION, 'Position must be a finite number');
43
+ }
44
+ }