@umituz/react-native-sound 1.1.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ümit UZ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,282 @@
1
+ # @umituz/react-native-sound
2
+
3
+ Universal sound playback and caching library for React Native apps. Supports local assets, remote URLs, and automatic caching with a simple, clean API.
4
+
5
+ ## Features
6
+
7
+ - 🎡 **Universal Sound Playback** - Play sounds from local assets or remote URLs
8
+ - πŸ’Ύ **Automatic Caching** - Cache remote sounds locally for offline playback
9
+ - πŸ”„ **Streaming Support** - Stream sounds while downloading in background
10
+ - πŸŽ›οΈ **Playback Controls** - Play, pause, stop, volume, and rate control
11
+ - 🎯 **Singleton Pattern** - Only one sound plays at a time across the app
12
+ - πŸ”Œ **Storage Abstraction** - Works with any storage provider (Firebase, AWS S3, etc.)
13
+ - πŸ“¦ **Zero Dependencies** - Only requires `expo-av` and `expo-file-system`
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install @umituz/react-native-sound
19
+ ```
20
+
21
+ ## Peer Dependencies
22
+
23
+ ```bash
24
+ npm install expo-av expo-file-system
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ### Basic Usage
30
+
31
+ ```typescript
32
+ import { useSoundPlayback, Sound } from '@umituz/react-native-sound';
33
+
34
+ function MyComponent() {
35
+ const { playSound, stopSound, isPlaying } = useSoundPlayback();
36
+
37
+ const sound: Sound = {
38
+ id: 'ocean-waves',
39
+ name: 'Ocean Waves',
40
+ filename: 'ocean-waves.mp3',
41
+ storageUrl: 'sounds/ocean-waves.mp3', // Or full URL
42
+ };
43
+
44
+ return (
45
+ <Button
46
+ onPress={() => {
47
+ if (isPlaying(sound.id)) {
48
+ stopSound();
49
+ } else {
50
+ playSound(sound);
51
+ }
52
+ }}
53
+ >
54
+ {isPlaying(sound.id) ? 'Stop' : 'Play'}
55
+ </Button>
56
+ );
57
+ }
58
+ ```
59
+
60
+ ### With Storage Service (Firebase, AWS S3, etc.)
61
+
62
+ ```typescript
63
+ import { useSoundPlayback, IStorageService } from '@umituz/react-native-sound';
64
+ import { getStorage } from 'firebase/storage';
65
+ import { ref, getDownloadURL } from 'firebase/storage';
66
+
67
+ // Implement storage service
68
+ class FirebaseStorageService implements IStorageService {
69
+ async getDownloadUrl(storagePath: string): Promise<string> {
70
+ const storage = getStorage();
71
+ const storageRef = ref(storage, storagePath);
72
+ return getDownloadURL(storageRef);
73
+ }
74
+ }
75
+
76
+ function MyComponent() {
77
+ const storageService = new FirebaseStorageService();
78
+ const { playSound, stopSound } = useSoundPlayback({
79
+ storageService,
80
+ });
81
+
82
+ // ... rest of component
83
+ }
84
+ ```
85
+
86
+ ### Local Assets
87
+
88
+ ```typescript
89
+ import { useSoundPlayback, Sound } from '@umituz/react-native-sound';
90
+ import oceanWavesSound from './assets/sounds/ocean-waves.mp3';
91
+
92
+ function MyComponent() {
93
+ const { playSound } = useSoundPlayback();
94
+
95
+ const sound: Sound = {
96
+ id: 'ocean-waves',
97
+ name: 'Ocean Waves',
98
+ localAsset: oceanWavesSound, // Bundled asset
99
+ };
100
+
101
+ return <Button onPress={() => playSound(sound)}>Play</Button>;
102
+ }
103
+ ```
104
+
105
+ ### Cache Management
106
+
107
+ ```typescript
108
+ import { useSoundCache } from '@umituz/react-native-sound';
109
+
110
+ function CacheSettings() {
111
+ const {
112
+ cacheSize,
113
+ clearCache,
114
+ isCached,
115
+ deleteCachedSound,
116
+ } = useSoundCache();
117
+
118
+ return (
119
+ <View>
120
+ <Text>Cache Size: {cacheSize.toFixed(2)} MB</Text>
121
+ <Button onPress={clearCache}>Clear Cache</Button>
122
+ </View>
123
+ );
124
+ }
125
+ ```
126
+
127
+ ## API Reference
128
+
129
+ ### `useSoundPlayback(options?)`
130
+
131
+ Main hook for sound playback.
132
+
133
+ #### Options
134
+
135
+ - `storageService?: IStorageService` - Storage service for remote URLs
136
+ - `autoConfigureAudioSession?: boolean` - Auto-configure audio session (default: true)
137
+ - `audioSessionOptions?: {...}` - Audio session configuration
138
+
139
+ #### Returns
140
+
141
+ - `playSound(sound, options?, onProgress?)` - Play a sound
142
+ - `stopSound()` - Stop current sound
143
+ - `pauseSound()` - Pause current sound
144
+ - `resumeSound()` - Resume paused sound
145
+ - `isPlaying(soundId)` - Check if sound is playing
146
+ - `playingSoundId` - Currently playing sound ID
147
+ - `downloadingSoundId` - Currently downloading sound ID
148
+ - `downloadProgress` - Download progress (0-100)
149
+ - `isStreaming` - Whether sound is streaming
150
+
151
+ ### `useSoundCache()`
152
+
153
+ Hook for cache management.
154
+
155
+ #### Returns
156
+
157
+ - `isCached(sound)` - Check if sound is cached
158
+ - `getCachedUri(sound)` - Get cached file URI
159
+ - `clearCache()` - Clear all cache
160
+ - `getCacheSize()` - Get cache size in MB
161
+ - `deleteCachedSound(sound)` - Delete specific cached sound
162
+ - `cacheSize` - Current cache size in MB
163
+ - `isLoadingCacheSize` - Whether cache size is loading
164
+ - `refreshCacheSize()` - Refresh cache size
165
+
166
+ ### `Sound` Entity
167
+
168
+ ```typescript
169
+ interface Sound {
170
+ id: string; // Required: Unique identifier
171
+ name: string; // Required: Display name
172
+ description?: string; // Optional: Description
173
+ filename?: string; // Optional: Filename for caching
174
+ storageUrl?: string; // Optional: Remote storage path or URL
175
+ localAsset?: number; // Optional: Local asset reference
176
+ durationSeconds?: number; // Optional: Duration in seconds
177
+ isPremium?: boolean; // Optional: Premium flag
178
+ category?: string; // Optional: Category
179
+ tags?: string[]; // Optional: Tags
180
+ metadata?: Record<string, unknown>; // Optional: App-specific metadata
181
+ }
182
+ ```
183
+
184
+ ### `IStorageService` Interface
185
+
186
+ ```typescript
187
+ interface IStorageService {
188
+ getDownloadUrl(storagePath: string): Promise<string>;
189
+ }
190
+ ```
191
+
192
+ Implement this interface for your storage provider (Firebase Storage, AWS S3, etc.).
193
+
194
+ ## Architecture
195
+
196
+ This package follows Domain-Driven Design (DDD) principles:
197
+
198
+ - **Domain Layer**: Entities and interfaces
199
+ - **Infrastructure Layer**: Services and storage implementations
200
+ - **Presentation Layer**: React hooks
201
+
202
+ ### SOLID Principles
203
+
204
+ - **Single Responsibility**: Each service has one clear purpose
205
+ - **Open/Closed**: Extensible through interfaces
206
+ - **Liskov Substitution**: Storage services are interchangeable
207
+ - **Interface Segregation**: Small, focused interfaces
208
+ - **Dependency Inversion**: Depends on abstractions, not concretions
209
+
210
+ ## Examples
211
+
212
+ ### Multiple Sounds
213
+
214
+ ```typescript
215
+ const sounds: Sound[] = [
216
+ {
217
+ id: 'ocean',
218
+ name: 'Ocean Waves',
219
+ filename: 'ocean.mp3',
220
+ storageUrl: 'sounds/ocean.mp3',
221
+ },
222
+ {
223
+ id: 'rain',
224
+ name: 'Rain',
225
+ filename: 'rain.mp3',
226
+ storageUrl: 'sounds/rain.mp3',
227
+ },
228
+ ];
229
+
230
+ function SoundList() {
231
+ const { playSound, isPlaying } = useSoundPlayback();
232
+
233
+ return (
234
+ <View>
235
+ {sounds.map(sound => (
236
+ <Button
237
+ key={sound.id}
238
+ onPress={() => playSound(sound)}
239
+ disabled={isPlaying(sound.id)}
240
+ >
241
+ {sound.name}
242
+ </Button>
243
+ ))}
244
+ </View>
245
+ );
246
+ }
247
+ ```
248
+
249
+ ### Download Progress
250
+
251
+ ```typescript
252
+ function SoundPlayer() {
253
+ const { playSound, downloadProgress, downloadingSoundId } = useSoundPlayback();
254
+
255
+ const handlePlay = (sound: Sound) => {
256
+ playSound(
257
+ sound,
258
+ { isLooping: true },
259
+ (progress) => {
260
+ console.log(`Download: ${Math.round(progress * 100)}%`);
261
+ }
262
+ );
263
+ };
264
+
265
+ return (
266
+ <View>
267
+ {downloadingSoundId && (
268
+ <ProgressBar value={downloadProgress} />
269
+ )}
270
+ </View>
271
+ );
272
+ }
273
+ ```
274
+
275
+ ## License
276
+
277
+ MIT
278
+
279
+ ## Author
280
+
281
+ Ümit UZ <umit@umituz.com>
282
+
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@umituz/react-native-sound",
3
+ "version": "1.1.0",
4
+ "description": "Universal sound playback and caching library for React Native apps",
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "scripts": {
8
+ "typecheck": "tsc --noEmit",
9
+ "lint": "tsc --noEmit",
10
+ "version:minor": "npm version minor -m 'chore: release v%s'",
11
+ "version:major": "npm version major -m 'chore: release v%s'"
12
+ },
13
+ "keywords": [
14
+ "react-native",
15
+ "sound",
16
+ "audio",
17
+ "playback",
18
+ "caching",
19
+ "expo-av"
20
+ ],
21
+ "author": "Ümit UZ <umit@umituz.com>",
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/umituz/react-native-sound"
26
+ },
27
+ "peerDependencies": {
28
+ "react": ">=18.2.0",
29
+ "react-native": ">=0.74.0",
30
+ "expo-av": ">=16.0.0"
31
+ },
32
+ "dependencies": {
33
+ "expo-file-system": "~18.0.0",
34
+ "zustand": "^5.0.2"
35
+ },
36
+ "devDependencies": {
37
+ "@types/react": "^18.2.45",
38
+ "@types/react-native": "^0.73.0",
39
+ "react": "^18.2.0",
40
+ "react-native": "^0.74.0",
41
+ "typescript": "^5.3.3",
42
+ "expo-av": "^16.0.0"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "files": [
48
+ "src",
49
+ "README.md",
50
+ "LICENSE"
51
+ ]
52
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Sound Entity
3
+ *
4
+ * Generic sound entity that can represent any audio file.
5
+ * SOLID: Single Responsibility - Only defines sound data structure
6
+ * KISS: Simple interface with essential properties
7
+ */
8
+
9
+ export interface Sound {
10
+ /** Unique identifier for the sound */
11
+ id: string;
12
+
13
+ /** Display name of the sound */
14
+ name: string;
15
+
16
+ /** Optional description */
17
+ description?: string;
18
+
19
+ /** Filename for local caching (e.g., "ocean-waves.mp3") */
20
+ filename?: string;
21
+
22
+ /** Remote storage path or URL (e.g., "sounds/ocean-waves.mp3" or "https://...") */
23
+ storageUrl?: string;
24
+
25
+ /** Local asset reference (for bundled sounds) */
26
+ localAsset?: number;
27
+
28
+ /** Duration in seconds (optional) */
29
+ durationSeconds?: number;
30
+
31
+ /** Whether sound is premium/paid */
32
+ isPremium?: boolean;
33
+
34
+ /** Category or tags for organization */
35
+ category?: string;
36
+ tags?: string[];
37
+
38
+ /** Metadata for app-specific use */
39
+ metadata?: Record<string, unknown>;
40
+ }
41
+
42
+ /**
43
+ * Audio Source Type
44
+ * Can be a local asset (number) or remote URI (string)
45
+ */
46
+ export type AudioSource = number | { uri: string };
47
+
48
+ /**
49
+ * Sound Playback Options
50
+ */
51
+ export interface SoundPlaybackOptions {
52
+ /** Whether to loop the sound */
53
+ isLooping?: boolean;
54
+
55
+ /** Initial volume (0.0 to 1.0) */
56
+ volume?: number;
57
+
58
+ /** Whether to play immediately */
59
+ shouldPlay?: boolean;
60
+
61
+ /** Playback rate (1.0 = normal speed) */
62
+ rate?: number;
63
+ }
64
+
65
+ /**
66
+ * Sound Cache Info
67
+ */
68
+ export interface SoundCacheInfo {
69
+ /** Local file URI if cached */
70
+ cachedUri: string | null;
71
+
72
+ /** Whether sound is currently cached */
73
+ isCached: boolean;
74
+
75
+ /** Cache size in bytes */
76
+ cacheSize?: number;
77
+ }
78
+
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Storage Service Interface
3
+ *
4
+ * Abstraction for remote storage providers (Firebase Storage, AWS S3, etc.)
5
+ * SOLID: Interface Segregation - Only storage-related methods
6
+ * DRY: Single interface for all storage providers
7
+ *
8
+ * Apps can implement this interface for their specific storage provider
9
+ */
10
+
11
+ export interface IStorageService {
12
+ /**
13
+ * Get download URL for a storage path
14
+ * @param storagePath - Path in storage (e.g., "sounds/ocean-waves.mp3")
15
+ * @returns Promise resolving to download URL
16
+ */
17
+ getDownloadUrl(storagePath: string): Promise<string>;
18
+ }
19
+
package/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @umituz/react-native-sound - Public API
3
+ *
4
+ * Universal sound playback and caching library for React Native apps.
5
+ * Supports local assets, remote URLs, and automatic caching.
6
+ *
7
+ * Usage:
8
+ * import { useSoundPlayback, useSoundCache, Sound } from '@umituz/react-native-sound';
9
+ */
10
+
11
+ // =============================================================================
12
+ // DOMAIN LAYER - Entities
13
+ // =============================================================================
14
+
15
+ export type {
16
+ Sound,
17
+ AudioSource,
18
+ SoundPlaybackOptions,
19
+ SoundCacheInfo,
20
+ } from './domain/entities/Sound.entity';
21
+
22
+ // =============================================================================
23
+ // DOMAIN LAYER - Repository Interface
24
+ // =============================================================================
25
+
26
+ export type { IStorageService } from './domain/repositories/IStorageService';
27
+
28
+ // =============================================================================
29
+ // INFRASTRUCTURE LAYER - Services
30
+ // =============================================================================
31
+
32
+ export {
33
+ SoundCacheService,
34
+ soundCacheService,
35
+ } from './infrastructure/services/SoundCacheService';
36
+
37
+ export {
38
+ AudioPlaybackService,
39
+ audioPlaybackService,
40
+ } from './infrastructure/services/AudioPlaybackService';
41
+
42
+ export {
43
+ SoundSourceResolver,
44
+ } from './infrastructure/services/SoundSourceResolver';
45
+
46
+ export type { ResolvedAudioSource } from './infrastructure/services/SoundSourceResolver';
47
+
48
+ // =============================================================================
49
+ // INFRASTRUCTURE LAYER - Storage
50
+ // =============================================================================
51
+
52
+ export {
53
+ useSoundPlaybackStore,
54
+ } from './infrastructure/storage/SoundPlaybackStore';
55
+
56
+ export type { SoundPlaybackState } from './infrastructure/storage/SoundPlaybackStore';
57
+
58
+ // =============================================================================
59
+ // PRESENTATION LAYER - Hooks
60
+ // =============================================================================
61
+
62
+ export {
63
+ useSoundPlayback,
64
+ } from './presentation/hooks/useSoundPlayback';
65
+
66
+ export type { UseSoundPlaybackOptions } from './presentation/hooks/useSoundPlayback';
67
+
68
+ export {
69
+ useSoundCache,
70
+ } from './presentation/hooks/useSoundCache';
71
+
72
+ export type { UseSoundCacheResult } from './presentation/hooks/useSoundCache';
73
+
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Audio Playback Service
3
+ *
4
+ * Handles audio playback using expo-av.
5
+ * SOLID: Single Responsibility - Only handles audio playback
6
+ * KISS: Simple wrapper around expo-av
7
+ */
8
+
9
+ import { Audio } from 'expo-av';
10
+ import type { AudioSource, SoundPlaybackOptions } from '../../domain/entities/Sound.entity';
11
+
12
+ export interface AudioPlaybackResult {
13
+ sound: Audio.Sound;
14
+ isStreaming: boolean;
15
+ }
16
+
17
+ export class AudioPlaybackService {
18
+ /**
19
+ * Configure audio session for playback
20
+ * KISS: Simple configuration wrapper
21
+ *
22
+ * @param options - Audio session options
23
+ */
24
+ async configureAudioSession(options?: {
25
+ playsInSilentModeIOS?: boolean;
26
+ allowsRecordingIOS?: boolean;
27
+ staysActiveInBackground?: boolean;
28
+ }): Promise<void> {
29
+ await Audio.setAudioModeAsync({
30
+ playsInSilentModeIOS: options?.playsInSilentModeIOS ?? true,
31
+ allowsRecordingIOS: options?.allowsRecordingIOS ?? false,
32
+ interruptionModeIOS: 2, // DuckOthers
33
+ interruptionModeAndroid: 1, // DuckOthers
34
+ shouldDuckAndroid: true,
35
+ playThroughEarpieceAndroid: false,
36
+ staysActiveInBackground: options?.staysActiveInBackground ?? false,
37
+ });
38
+ }
39
+
40
+ /**
41
+ * Create and load audio sound
42
+ * @param source - Audio source (local asset or URI)
43
+ * @param options - Playback options
44
+ * @returns Audio sound instance
45
+ */
46
+ async loadSound(
47
+ source: AudioSource,
48
+ options: SoundPlaybackOptions = {}
49
+ ): Promise<Audio.Sound> {
50
+ const { sound } = await Audio.Sound.createAsync(source, {
51
+ shouldPlay: options.shouldPlay ?? false,
52
+ isLooping: options.isLooping ?? false,
53
+ volume: options.volume ?? 1.0,
54
+ rate: options.rate ?? 1.0,
55
+ });
56
+
57
+ return sound;
58
+ }
59
+
60
+ /**
61
+ * Play audio sound
62
+ * @param sound - Audio sound instance
63
+ */
64
+ async playSound(sound: Audio.Sound): Promise<void> {
65
+ await sound.playAsync();
66
+ }
67
+
68
+ /**
69
+ * Pause audio sound
70
+ * @param sound - Audio sound instance
71
+ */
72
+ async pauseSound(sound: Audio.Sound): Promise<void> {
73
+ await sound.pauseAsync();
74
+ }
75
+
76
+ /**
77
+ * Stop and unload audio sound
78
+ * @param sound - Audio sound instance
79
+ */
80
+ async stopSound(sound: Audio.Sound): Promise<void> {
81
+ await sound.unloadAsync();
82
+ }
83
+
84
+ /**
85
+ * Set volume
86
+ * @param sound - Audio sound instance
87
+ * @param volume - Volume (0.0 to 1.0)
88
+ */
89
+ async setVolume(sound: Audio.Sound, volume: number): Promise<void> {
90
+ await sound.setVolumeAsync(volume);
91
+ }
92
+
93
+ /**
94
+ * Set playback rate
95
+ * @param sound - Audio sound instance
96
+ * @param rate - Playback rate (1.0 = normal speed)
97
+ */
98
+ async setRate(sound: Audio.Sound, rate: number): Promise<void> {
99
+ await sound.setRateAsync(rate, true);
100
+ }
101
+ }
102
+
103
+ // Export singleton instance
104
+ export const audioPlaybackService = new AudioPlaybackService();
105
+
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Sound Cache Service
3
+ *
4
+ * Handles local file caching for remote sounds.
5
+ * SOLID: Single Responsibility - Only handles file caching
6
+ * DRY: Centralized caching logic
7
+ * KISS: Simple file operations
8
+ */
9
+
10
+ import * as FileSystem from 'expo-file-system/legacy';
11
+
12
+ const CACHE_DIR = `${FileSystem.cacheDirectory}sounds/`;
13
+
14
+ export class SoundCacheService {
15
+ /**
16
+ * Ensure cache directory exists
17
+ * KISS: Simple directory creation
18
+ */
19
+ async ensureCacheDir(): Promise<void> {
20
+ const dirInfo = await FileSystem.getInfoAsync(CACHE_DIR);
21
+ if (!dirInfo.exists) {
22
+ await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Get cached sound file URI
28
+ * @param filename - Sound filename (e.g., "ocean-waves.mp3")
29
+ * @returns Local file URI if cached, null otherwise
30
+ */
31
+ async getCachedSound(filename: string): Promise<string | null> {
32
+ await this.ensureCacheDir();
33
+
34
+ const localUri = `${CACHE_DIR}${filename}`;
35
+ const fileInfo = await FileSystem.getInfoAsync(localUri);
36
+
37
+ if (fileInfo.exists) {
38
+ return localUri;
39
+ }
40
+
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Download and cache sound from remote URL
46
+ * @param remoteUrl - Remote download URL
47
+ * @param filename - Local filename for caching
48
+ * @param onProgress - Optional progress callback (0-1)
49
+ * @returns Local file URI of cached sound
50
+ */
51
+ async downloadAndCache(
52
+ remoteUrl: string,
53
+ filename: string,
54
+ onProgress?: (progress: number) => void
55
+ ): Promise<string> {
56
+ await this.ensureCacheDir();
57
+
58
+ // Check cache first (DRY: Reuse existing function)
59
+ const cached = await this.getCachedSound(filename);
60
+ if (cached) {
61
+ return cached;
62
+ }
63
+
64
+ const localUri = `${CACHE_DIR}${filename}`;
65
+
66
+ // Download with progress tracking
67
+ const downloadResumable = FileSystem.createDownloadResumable(
68
+ remoteUrl,
69
+ localUri,
70
+ {},
71
+ downloadProgress => {
72
+ const progress =
73
+ downloadProgress.totalBytesWritten /
74
+ downloadProgress.totalBytesExpectedToWrite;
75
+
76
+ onProgress?.(progress);
77
+ }
78
+ );
79
+
80
+ const result = await downloadResumable.downloadAsync();
81
+
82
+ if (!result) {
83
+ throw new Error('Download failed');
84
+ }
85
+
86
+ return result.uri;
87
+ }
88
+
89
+ /**
90
+ * Clear all cached sounds
91
+ * Useful for cleanup or cache management
92
+ */
93
+ async clearCache(): Promise<void> {
94
+ try {
95
+ await FileSystem.deleteAsync(CACHE_DIR, { idempotent: true });
96
+ await this.ensureCacheDir();
97
+ } catch {
98
+ // Silently fail - cache clearing is non-critical
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Get cache size in MB
104
+ * @returns Cache size in megabytes
105
+ */
106
+ async getCacheSize(): Promise<number> {
107
+ try {
108
+ const dirInfo = await FileSystem.getInfoAsync(CACHE_DIR);
109
+ if (!dirInfo.exists) {
110
+ return 0;
111
+ }
112
+
113
+ const files = await FileSystem.readDirectoryAsync(CACHE_DIR);
114
+ let totalSize = 0;
115
+
116
+ for (const file of files) {
117
+ const fileInfo = await FileSystem.getInfoAsync(`${CACHE_DIR}${file}`);
118
+ if (fileInfo.exists && 'size' in fileInfo) {
119
+ totalSize += fileInfo.size || 0;
120
+ }
121
+ }
122
+
123
+ // Convert to MB
124
+ return totalSize / (1024 * 1024);
125
+ } catch {
126
+ return 0;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Delete specific cached sound
132
+ * @param filename - Filename to delete
133
+ */
134
+ async deleteCachedSound(filename: string): Promise<void> {
135
+ try {
136
+ const localUri = `${CACHE_DIR}${filename}`;
137
+ await FileSystem.deleteAsync(localUri, { idempotent: true });
138
+ } catch {
139
+ // Silently fail
140
+ }
141
+ }
142
+ }
143
+
144
+ // Export singleton instance
145
+ export const soundCacheService = new SoundCacheService();
146
+
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Sound Source Resolver
3
+ *
4
+ * Resolves sound source from Sound entity to AudioSource.
5
+ * Handles local assets, cached files, and remote URLs.
6
+ * SOLID: Single Responsibility - Only resolves audio sources
7
+ * DRY: Centralized source resolution logic
8
+ */
9
+
10
+ import type { Sound, AudioSource } from '../../domain/entities/Sound.entity';
11
+ import type { IStorageService } from '../../domain/repositories/IStorageService';
12
+ import { soundCacheService } from './SoundCacheService';
13
+
14
+ export interface ResolvedAudioSource {
15
+ source: AudioSource;
16
+ isStreaming: boolean;
17
+ }
18
+
19
+ export class SoundSourceResolver {
20
+ constructor(private storageService?: IStorageService) {}
21
+
22
+ /**
23
+ * Resolve sound to audio source
24
+ * Handles: local assets, cached files, remote URLs
25
+ *
26
+ * @param sound - Sound entity
27
+ * @param onDownloadProgress - Optional download progress callback
28
+ * @returns Resolved audio source or null if cannot resolve
29
+ */
30
+ async resolve(
31
+ sound: Sound,
32
+ onDownloadProgress?: (progress: number) => void
33
+ ): Promise<ResolvedAudioSource | null> {
34
+ // 1. Local asset (bundled in app)
35
+ if (sound.localAsset !== undefined) {
36
+ return {
37
+ source: sound.localAsset,
38
+ isStreaming: false,
39
+ };
40
+ }
41
+
42
+ // 2. Remote storage URL
43
+ if (sound.storageUrl && sound.filename) {
44
+ // Check cache first
45
+ const cachedUri = await soundCacheService.getCachedSound(sound.filename);
46
+ if (cachedUri) {
47
+ return {
48
+ source: { uri: cachedUri },
49
+ isStreaming: false,
50
+ };
51
+ }
52
+
53
+ // Get download URL from storage service
54
+ if (this.storageService) {
55
+ try {
56
+ const downloadUrl = await this.storageService.getDownloadUrl(
57
+ sound.storageUrl
58
+ );
59
+
60
+ // Start background download
61
+ soundCacheService
62
+ .downloadAndCache(downloadUrl, sound.filename, onDownloadProgress)
63
+ .catch(() => {
64
+ // Silent fail - background download
65
+ });
66
+
67
+ // Return streaming URL immediately
68
+ return {
69
+ source: { uri: downloadUrl },
70
+ isStreaming: true,
71
+ };
72
+ } catch {
73
+ // Storage service error - return null
74
+ return null;
75
+ }
76
+ }
77
+
78
+ // No storage service - assume storageUrl is direct URL
79
+ return {
80
+ source: { uri: sound.storageUrl },
81
+ isStreaming: true,
82
+ };
83
+ }
84
+
85
+ // 3. Direct URI (if storageUrl is already a full URL)
86
+ if (sound.storageUrl && sound.storageUrl.startsWith('http')) {
87
+ return {
88
+ source: { uri: sound.storageUrl },
89
+ isStreaming: true,
90
+ };
91
+ }
92
+
93
+ // Cannot resolve
94
+ return null;
95
+ }
96
+ }
97
+
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Sound Playback Store
3
+ *
4
+ * Global state management for sound playback.
5
+ * Ensures only one sound plays at a time (singleton pattern).
6
+ * SOLID: Single Responsibility - Only manages playback state
7
+ * KISS: Simple Zustand store
8
+ */
9
+
10
+ import { create } from 'zustand';
11
+ import { Audio } from 'expo-av';
12
+ import type {
13
+ Sound,
14
+ SoundPlaybackOptions,
15
+ AudioSource,
16
+ } from '../../domain/entities/Sound.entity';
17
+
18
+ export interface SoundPlaybackState {
19
+ /** Currently playing sound ID */
20
+ playingSoundId: string | null;
21
+
22
+ /** Currently downloading sound ID */
23
+ downloadingSoundId: string | null;
24
+
25
+ /** Download progress (0-100) */
26
+ downloadProgress: number;
27
+
28
+ /** Whether sound is streaming */
29
+ isStreaming: boolean;
30
+
31
+ /** Internal Audio.Sound instance */
32
+ _sound: Audio.Sound | null;
33
+
34
+ /** Play a sound */
35
+ playSound: (
36
+ sound: Sound,
37
+ options?: SoundPlaybackOptions,
38
+ onDownloadProgress?: (progress: number) => void
39
+ ) => Promise<void>;
40
+
41
+ /** Stop current sound */
42
+ stopSound: () => Promise<void>;
43
+
44
+ /** Pause current sound */
45
+ pauseSound: () => Promise<void>;
46
+
47
+ /** Resume paused sound */
48
+ resumeSound: () => Promise<void>;
49
+
50
+ /** Check if specific sound is playing */
51
+ isPlaying: (soundId: string) => boolean;
52
+
53
+ /** Cleanup all resources */
54
+ cleanup: () => Promise<void>;
55
+ }
56
+
57
+ export const useSoundPlaybackStore = create<SoundPlaybackState>((set, get) => ({
58
+ // Initial state
59
+ playingSoundId: null,
60
+ downloadingSoundId: null,
61
+ downloadProgress: 0,
62
+ isStreaming: false,
63
+ _sound: null,
64
+
65
+ // Play sound
66
+ playSound: async (
67
+ sound: Sound,
68
+ options: SoundPlaybackOptions = {},
69
+ onDownloadProgress?: (progress: number) => void
70
+ ) => {
71
+ const state = get();
72
+
73
+ // Toggle: If same sound is playing, stop it
74
+ if (state.playingSoundId === sound.id && state._sound) {
75
+ await get().stopSound();
76
+ return;
77
+ }
78
+
79
+ try {
80
+ // Stop any existing sound
81
+ if (state._sound) {
82
+ await state._sound.unloadAsync();
83
+ set({ _sound: null });
84
+ }
85
+
86
+ // Optimistic update
87
+ set({ playingSoundId: sound.id });
88
+
89
+ // Check if source is pre-resolved (from hook)
90
+ let resolvedSource: { source: AudioSource; isStreaming: boolean } | null =
91
+ null;
92
+
93
+ if (
94
+ sound.metadata &&
95
+ '_resolvedSource' in sound.metadata &&
96
+ '_isStreaming' in sound.metadata
97
+ ) {
98
+ // Use pre-resolved source from hook
99
+ resolvedSource = {
100
+ source: sound.metadata._resolvedSource as AudioSource,
101
+ isStreaming: sound.metadata._isStreaming as boolean,
102
+ };
103
+ } else {
104
+ // Import services dynamically to avoid circular dependencies
105
+ const { SoundSourceResolver } = await import(
106
+ '../services/SoundSourceResolver'
107
+ );
108
+
109
+ // Create resolver (no storage service in store - app should use hook)
110
+ const resolver = new SoundSourceResolver();
111
+
112
+ // Resolve audio source
113
+ resolvedSource = await resolver.resolve(sound, progress => {
114
+ set({
115
+ downloadingSoundId: sound.id,
116
+ downloadProgress: Math.round(progress * 100),
117
+ });
118
+ onDownloadProgress?.(progress);
119
+ });
120
+ }
121
+
122
+ if (!resolvedSource) {
123
+ throw new Error(`Cannot resolve audio source for sound: ${sound.id}`);
124
+ }
125
+
126
+ set({
127
+ isStreaming: resolvedSource.isStreaming,
128
+ downloadingSoundId: null,
129
+ downloadProgress: 0,
130
+ });
131
+
132
+ // Import audio service
133
+ const { audioPlaybackService } = await import(
134
+ '../services/AudioPlaybackService'
135
+ );
136
+
137
+ // Load and play sound
138
+ const audioSound = await audioPlaybackService.loadSound(
139
+ resolvedSource.source,
140
+ {
141
+ ...options,
142
+ shouldPlay: true,
143
+ }
144
+ );
145
+
146
+ set({ _sound: audioSound });
147
+ } catch (error) {
148
+ // Rollback on error
149
+ set({
150
+ playingSoundId: null,
151
+ _sound: null,
152
+ downloadingSoundId: null,
153
+ downloadProgress: 0,
154
+ isStreaming: false,
155
+ });
156
+ throw error;
157
+ }
158
+ },
159
+
160
+ // Stop sound
161
+ stopSound: async () => {
162
+ const state = get();
163
+
164
+ if (!state._sound && !state.playingSoundId) {
165
+ return;
166
+ }
167
+
168
+ set({
169
+ playingSoundId: null,
170
+ downloadingSoundId: null,
171
+ downloadProgress: 0,
172
+ isStreaming: false,
173
+ });
174
+
175
+ if (state._sound) {
176
+ try {
177
+ await state._sound.unloadAsync();
178
+ } catch {
179
+ // Silently fail
180
+ }
181
+ set({ _sound: null });
182
+ }
183
+ },
184
+
185
+ // Pause sound
186
+ pauseSound: async () => {
187
+ const state = get();
188
+
189
+ if (!state._sound) {
190
+ return;
191
+ }
192
+
193
+ try {
194
+ await state._sound.pauseAsync();
195
+ } catch {
196
+ // Silently fail
197
+ }
198
+ },
199
+
200
+ // Resume sound
201
+ resumeSound: async () => {
202
+ const state = get();
203
+
204
+ if (!state._sound) {
205
+ return;
206
+ }
207
+
208
+ try {
209
+ await state._sound.playAsync();
210
+ } catch {
211
+ // Silently fail
212
+ }
213
+ },
214
+
215
+ // Check if playing
216
+ isPlaying: (soundId: string) => {
217
+ return get().playingSoundId === soundId;
218
+ },
219
+
220
+ // Cleanup
221
+ cleanup: async () => {
222
+ const state = get();
223
+
224
+ try {
225
+ if (state._sound) {
226
+ await state._sound.unloadAsync();
227
+ }
228
+
229
+ set({
230
+ playingSoundId: null,
231
+ downloadingSoundId: null,
232
+ downloadProgress: 0,
233
+ isStreaming: false,
234
+ _sound: null,
235
+ });
236
+ } catch {
237
+ // Silently fail
238
+ }
239
+ },
240
+ }));
241
+
@@ -0,0 +1,109 @@
1
+ /**
2
+ * useSoundCache Hook
3
+ *
4
+ * React hook for sound cache management.
5
+ * Provides cache operations and statistics.
6
+ * SOLID: Single Responsibility - Only handles cache operations
7
+ * KISS: Simple hook wrapper around cache service
8
+ */
9
+
10
+ import { useState, useEffect, useCallback } from 'react';
11
+ import { soundCacheService } from '../../infrastructure/services/SoundCacheService';
12
+ import type { Sound } from '../../domain/entities/Sound.entity';
13
+
14
+ export interface UseSoundCacheResult {
15
+ /** Check if sound is cached */
16
+ isCached: (sound: Sound) => Promise<boolean>;
17
+
18
+ /** Get cached sound URI */
19
+ getCachedUri: (sound: Sound) => Promise<string | null>;
20
+
21
+ /** Clear all cache */
22
+ clearCache: () => Promise<void>;
23
+
24
+ /** Get cache size in MB */
25
+ getCacheSize: () => Promise<number>;
26
+
27
+ /** Delete specific cached sound */
28
+ deleteCachedSound: (sound: Sound) => Promise<void>;
29
+
30
+ /** Current cache size in MB */
31
+ cacheSize: number;
32
+
33
+ /** Whether cache size is loading */
34
+ isLoadingCacheSize: boolean;
35
+
36
+ /** Refresh cache size */
37
+ refreshCacheSize: () => Promise<void>;
38
+ }
39
+
40
+ export function useSoundCache(): UseSoundCacheResult {
41
+ const [cacheSize, setCacheSize] = useState<number>(0);
42
+ const [isLoadingCacheSize, setIsLoadingCacheSize] = useState(false);
43
+
44
+ // Load cache size on mount
45
+ useEffect(() => {
46
+ refreshCacheSize();
47
+ }, []);
48
+
49
+ const refreshCacheSize = useCallback(async () => {
50
+ setIsLoadingCacheSize(true);
51
+ try {
52
+ const size = await soundCacheService.getCacheSize();
53
+ setCacheSize(size);
54
+ } catch {
55
+ // Silently fail
56
+ } finally {
57
+ setIsLoadingCacheSize(false);
58
+ }
59
+ }, []);
60
+
61
+ const isCached = useCallback(
62
+ async (sound: Sound): Promise<boolean> => {
63
+ if (!sound.filename) {
64
+ return false;
65
+ }
66
+ const cachedUri = await soundCacheService.getCachedSound(sound.filename);
67
+ return cachedUri !== null;
68
+ },
69
+ []
70
+ );
71
+
72
+ const getCachedUri = useCallback(
73
+ async (sound: Sound): Promise<string | null> => {
74
+ if (!sound.filename) {
75
+ return null;
76
+ }
77
+ return soundCacheService.getCachedSound(sound.filename);
78
+ },
79
+ []
80
+ );
81
+
82
+ const clearCache = useCallback(async () => {
83
+ await soundCacheService.clearCache();
84
+ await refreshCacheSize();
85
+ }, [refreshCacheSize]);
86
+
87
+ const deleteCachedSound = useCallback(
88
+ async (sound: Sound) => {
89
+ if (!sound.filename) {
90
+ return;
91
+ }
92
+ await soundCacheService.deleteCachedSound(sound.filename);
93
+ await refreshCacheSize();
94
+ },
95
+ [refreshCacheSize]
96
+ );
97
+
98
+ return {
99
+ isCached,
100
+ getCachedUri,
101
+ clearCache,
102
+ getCacheSize: refreshCacheSize,
103
+ deleteCachedSound,
104
+ cacheSize,
105
+ isLoadingCacheSize,
106
+ refreshCacheSize,
107
+ };
108
+ }
109
+
@@ -0,0 +1,121 @@
1
+ /**
2
+ * useSoundPlayback Hook
3
+ *
4
+ * React hook for sound playback.
5
+ * Provides easy access to sound playback functionality.
6
+ * SOLID: Single Responsibility - Only provides playback interface
7
+ * KISS: Simple hook wrapper around store
8
+ */
9
+
10
+ import { useEffect } from 'react';
11
+ import { useSoundPlaybackStore } from '../../infrastructure/storage/SoundPlaybackStore';
12
+ import { audioPlaybackService } from '../../infrastructure/services/AudioPlaybackService';
13
+ import type { Sound, SoundPlaybackOptions } from '../../domain/entities/Sound.entity';
14
+ import type { IStorageService } from '../../domain/repositories/IStorageService';
15
+ import { SoundSourceResolver } from '../../infrastructure/services/SoundSourceResolver';
16
+
17
+ export interface UseSoundPlaybackOptions {
18
+ /** Storage service for remote sound URLs (optional) */
19
+ storageService?: IStorageService;
20
+
21
+ /** Auto-configure audio session on mount */
22
+ autoConfigureAudioSession?: boolean;
23
+
24
+ /** Audio session configuration options */
25
+ audioSessionOptions?: {
26
+ playsInSilentModeIOS?: boolean;
27
+ allowsRecordingIOS?: boolean;
28
+ staysActiveInBackground?: boolean;
29
+ };
30
+ }
31
+
32
+ export function useSoundPlayback(options: UseSoundPlaybackOptions = {}) {
33
+ const store = useSoundPlaybackStore();
34
+
35
+ // Auto-configure audio session
36
+ useEffect(() => {
37
+ if (options.autoConfigureAudioSession !== false) {
38
+ audioPlaybackService.configureAudioSession(
39
+ options.audioSessionOptions
40
+ );
41
+ }
42
+ }, [options.autoConfigureAudioSession, options.audioSessionOptions]);
43
+
44
+ // Cleanup on unmount
45
+ useEffect(() => {
46
+ return () => {
47
+ store.cleanup();
48
+ };
49
+ }, [store]);
50
+
51
+ /**
52
+ * Play a sound
53
+ */
54
+ const playSound = async (
55
+ sound: Sound,
56
+ playbackOptions?: SoundPlaybackOptions,
57
+ onDownloadProgress?: (progress: number) => void
58
+ ) => {
59
+ // Create resolver with storage service
60
+ const resolver = new SoundSourceResolver(options.storageService);
61
+
62
+ // Progress callback wrapper
63
+ const progressCallback = onDownloadProgress
64
+ ? (progress: number) => {
65
+ useSoundPlaybackStore.setState({
66
+ downloadingSoundId: sound.id,
67
+ downloadProgress: Math.round(progress * 100),
68
+ });
69
+ onDownloadProgress(progress);
70
+ }
71
+ : undefined;
72
+
73
+ // Resolve source
74
+ const resolved = await resolver.resolve(sound, progressCallback);
75
+ if (!resolved) {
76
+ throw new Error(`Cannot resolve audio source for sound: ${sound.id}`);
77
+ }
78
+
79
+ // Pass resolved source to store via metadata
80
+ const soundWithSource: Sound = {
81
+ ...sound,
82
+ metadata: {
83
+ ...sound.metadata,
84
+ _resolvedSource: resolved.source,
85
+ _isStreaming: resolved.isStreaming,
86
+ },
87
+ };
88
+
89
+ await store.playSound(soundWithSource, playbackOptions, onDownloadProgress);
90
+ };
91
+
92
+ return {
93
+ /** Play a sound */
94
+ playSound,
95
+
96
+ /** Stop current sound */
97
+ stopSound: store.stopSound,
98
+
99
+ /** Pause current sound */
100
+ pauseSound: store.pauseSound,
101
+
102
+ /** Resume paused sound */
103
+ resumeSound: store.resumeSound,
104
+
105
+ /** Check if specific sound is playing */
106
+ isPlaying: store.isPlaying,
107
+
108
+ /** Currently playing sound ID */
109
+ playingSoundId: store.playingSoundId,
110
+
111
+ /** Currently downloading sound ID */
112
+ downloadingSoundId: store.downloadingSoundId,
113
+
114
+ /** Download progress (0-100) */
115
+ downloadProgress: store.downloadProgress,
116
+
117
+ /** Whether sound is streaming */
118
+ isStreaming: store.isStreaming,
119
+ };
120
+ }
121
+