@umituz/react-native-sound 1.2.2 → 1.2.4

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.2",
3
+ "version": "1.2.4",
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,144 @@
1
+ import { Audio, AVPlaybackStatus, AVPlaybackStatusSuccess } from 'expo-av';
2
+ import { useSoundStore } from './store';
3
+ import { PlaybackOptions, SoundSource } from './types';
4
+
5
+ class AudioManager {
6
+ private sound: Audio.Sound | null = null;
7
+ private currentId: string | null = null;
8
+
9
+ constructor() {
10
+ this.configureAudio();
11
+ }
12
+
13
+ private async configureAudio() {
14
+ try {
15
+ await Audio.setAudioModeAsync({
16
+ playsInSilentModeIOS: true,
17
+ staysActiveInBackground: false,
18
+ shouldDuckAndroid: true,
19
+ playThroughEarpieceAndroid: false,
20
+ });
21
+ } catch (error) {
22
+ if (__DEV__) console.warn('Failed to configure audio session', error);
23
+ }
24
+ }
25
+
26
+ private onPlaybackStatusUpdate = (status: AVPlaybackStatus) => {
27
+ const store = useSoundStore.getState();
28
+
29
+ if (!status.isLoaded) {
30
+ if (status.error) {
31
+ store.setError(status.error);
32
+ store.setPlaying(false);
33
+ }
34
+ return;
35
+ }
36
+
37
+ const s = status as AVPlaybackStatusSuccess;
38
+ store.setPlaying(s.isPlaying);
39
+ store.setBuffering(s.isBuffering);
40
+ store.setProgress(s.positionMillis, s.durationMillis || 0);
41
+
42
+ if (s.didJustFinish && !s.isLooping) {
43
+ store.setPlaying(false);
44
+ store.setProgress(s.durationMillis || 0, s.durationMillis || 0);
45
+ }
46
+ };
47
+
48
+ async play(id: string, source: SoundSource, options?: PlaybackOptions) {
49
+ const store = useSoundStore.getState();
50
+
51
+ if (__DEV__) console.log('[AudioManager] Play called with ID:', id);
52
+
53
+ // If same ID is playing/pausing
54
+ if (this.currentId === id && this.sound) {
55
+ const status = await this.sound.getStatusAsync();
56
+ if (status.isLoaded) {
57
+ if (status.isPlaying) {
58
+ return;
59
+ } else {
60
+ if (__DEV__) console.log('[AudioManager] Resuming existing sound');
61
+ await this.sound.playAsync();
62
+ return;
63
+ }
64
+ }
65
+ }
66
+
67
+ try {
68
+ // Unload previous sound
69
+ await this.unload();
70
+
71
+ this.currentId = id;
72
+ store.setCurrent(id, source);
73
+ store.setError(null);
74
+
75
+ if (__DEV__) console.log('[AudioManager] Creating sound from source:', source);
76
+
77
+ const { sound } = await Audio.Sound.createAsync(
78
+ source as any,
79
+ {
80
+ shouldPlay: true,
81
+ isLooping: options?.isLooping ?? false,
82
+ volume: options?.volume ?? 1.0,
83
+ rate: options?.rate ?? 1.0,
84
+ positionMillis: options?.positionMillis ?? 0,
85
+ },
86
+ this.onPlaybackStatusUpdate
87
+ );
88
+
89
+ this.sound = sound;
90
+ if (__DEV__) console.log('[AudioManager] Sound created and playing');
91
+ } catch (error: any) {
92
+ if (__DEV__) console.error('[AudioManager] Error playing sound:', error);
93
+ store.setError(error.message);
94
+ this.currentId = null;
95
+ throw error;
96
+ }
97
+ }
98
+
99
+ async pause() {
100
+ if (this.sound) {
101
+ await this.sound.pauseAsync();
102
+ }
103
+ }
104
+
105
+ async resume() {
106
+ if (this.sound) {
107
+ await this.sound.playAsync();
108
+ }
109
+ }
110
+
111
+ async stop() {
112
+ if (this.sound) {
113
+ await this.sound.stopAsync();
114
+ useSoundStore.getState().setPlaying(false);
115
+ useSoundStore.getState().setProgress(0, 0);
116
+ }
117
+ }
118
+
119
+ async setVolume(volume: number) {
120
+ if (this.sound) {
121
+ await this.sound.setVolumeAsync(volume);
122
+ useSoundStore.getState().setVolumeState(volume);
123
+ }
124
+ }
125
+
126
+ async unload() {
127
+ if (this.sound) {
128
+ try {
129
+ await this.sound.unloadAsync();
130
+ } catch (e) {
131
+ // Ignore unload errors
132
+ }
133
+ this.sound = null;
134
+ }
135
+ this.currentId = null;
136
+ useSoundStore.getState().reset();
137
+ }
138
+
139
+ getCurrentId() {
140
+ return this.currentId;
141
+ }
142
+ }
143
+
144
+ export const audioManager = new AudioManager();
package/src/index.ts CHANGED
@@ -1,74 +1,8 @@
1
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';
2
+ * @umituz/react-native-sound
3
+ * Universal sound playback library
9
4
  */
10
5
 
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
-
74
-
6
+ export * from './types';
7
+ export * from './useSound';
8
+ export { audioManager } from './AudioManager';
package/src/store.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { create } from 'zustand';
2
+ import { SoundState } from './types';
3
+
4
+ interface SoundStore extends SoundState {
5
+ setPlaying: (isPlaying: boolean) => void;
6
+ setBuffering: (isBuffering: boolean) => void;
7
+ setProgress: (position: number, duration: number) => void;
8
+ setError: (error: string | null) => void;
9
+ setCurrent: (id: string | null, source: any) => void;
10
+ setVolumeState: (volume: number) => void;
11
+ reset: () => void;
12
+ }
13
+
14
+ export const useSoundStore = create<SoundStore>((set) => ({
15
+ isPlaying: false,
16
+ isBuffering: false,
17
+ positionMillis: 0,
18
+ durationMillis: 0,
19
+ volume: 1.0,
20
+ rate: 1.0,
21
+ error: null,
22
+ currentSource: null,
23
+ currentId: null,
24
+
25
+ setPlaying: (isPlaying) => set({ isPlaying }),
26
+ setBuffering: (isBuffering) => set({ isBuffering }),
27
+ setProgress: (position, duration) =>
28
+ set({ positionMillis: position, durationMillis: duration }),
29
+ setError: (error) => set({ error }),
30
+ setCurrent: (id, source) => set({ currentId: id, currentSource: source }),
31
+ setVolumeState: (volume) => set({ volume }),
32
+ reset: () =>
33
+ set({
34
+ isPlaying: false,
35
+ isBuffering: false,
36
+ positionMillis: 0,
37
+ durationMillis: 0,
38
+ error: null,
39
+ currentSource: null,
40
+ currentId: null,
41
+ }),
42
+ }));
package/src/types.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @umituz/react-native-sound Types
3
+ */
4
+
5
+ import { AVPlaybackStatus } from 'expo-av';
6
+
7
+ export type SoundSource = number | { uri: string; headers?: Record<string, string> } | null;
8
+
9
+ export interface PlaybackOptions {
10
+ shouldPlay?: boolean;
11
+ isLooping?: boolean;
12
+ volume?: number;
13
+ rate?: number;
14
+ positionMillis?: number;
15
+ }
16
+
17
+ export interface SoundState {
18
+ isPlaying: boolean;
19
+ isBuffering: boolean;
20
+ positionMillis: number;
21
+ durationMillis: number;
22
+ volume: number;
23
+ rate: number;
24
+ error: string | null;
25
+ currentSource: SoundSource | null;
26
+ currentId: string | null;
27
+ }
28
+
29
+ export type PlaybackStatus = AVPlaybackStatus;
@@ -0,0 +1,61 @@
1
+ import { useCallback } from 'react';
2
+ import { useSoundStore } from './store';
3
+ import { audioManager } from './AudioManager';
4
+ import { PlaybackOptions, SoundSource } from './types';
5
+
6
+ export const useSound = () => {
7
+ const isPlaying = useSoundStore((state) => state.isPlaying);
8
+ const isBuffering = useSoundStore((state) => state.isBuffering);
9
+ const position = useSoundStore((state) => state.positionMillis);
10
+ const duration = useSoundStore((state) => state.durationMillis);
11
+ const currentId = useSoundStore((state) => state.currentId);
12
+ const error = useSoundStore((state) => state.error);
13
+ const volume = useSoundStore((state) => state.volume);
14
+
15
+ const play = useCallback(
16
+ async (id: string, source: SoundSource, options?: PlaybackOptions) => {
17
+ // Toggle logic: If same ID, toggle pause/resume
18
+ if (currentId === id) {
19
+ if (isPlaying) {
20
+ await audioManager.pause();
21
+ } else {
22
+ await audioManager.resume();
23
+ }
24
+ } else {
25
+ await audioManager.play(id, source, options);
26
+ }
27
+ },
28
+ [currentId, isPlaying]
29
+ );
30
+
31
+ const pause = useCallback(async () => {
32
+ await audioManager.pause();
33
+ }, []);
34
+
35
+ const stop = useCallback(async () => {
36
+ await audioManager.stop();
37
+ }, []);
38
+
39
+ const setVolume = useCallback(async (vol: number) => {
40
+ await audioManager.setVolume(vol);
41
+ }, []);
42
+
43
+ const unload = useCallback(async () => {
44
+ await audioManager.unload();
45
+ }, []);
46
+
47
+ return {
48
+ play,
49
+ pause,
50
+ stop,
51
+ unload,
52
+ setVolume,
53
+ isPlaying,
54
+ isBuffering,
55
+ position,
56
+ duration,
57
+ currentId,
58
+ error,
59
+ volume,
60
+ };
61
+ };
@@ -1,79 +0,0 @@
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
-
79
-
@@ -1,20 +0,0 @@
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
-
20
-
@@ -1,106 +0,0 @@
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
-
106
-
@@ -1,147 +0,0 @@
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
-
147
-
@@ -1,98 +0,0 @@
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
-
98
-
@@ -1,241 +0,0 @@
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
-
@@ -1,114 +0,0 @@
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 getCacheSize = useCallback(async (): Promise<number> => {
62
- return soundCacheService.getCacheSize();
63
- }, []);
64
-
65
- const isCached = useCallback(
66
- async (sound: Sound): Promise<boolean> => {
67
- if (!sound.filename) {
68
- return false;
69
- }
70
- const cachedUri = await soundCacheService.getCachedSound(sound.filename);
71
- return cachedUri !== null;
72
- },
73
- []
74
- );
75
-
76
- const getCachedUri = useCallback(
77
- async (sound: Sound): Promise<string | null> => {
78
- if (!sound.filename) {
79
- return null;
80
- }
81
- return soundCacheService.getCachedSound(sound.filename);
82
- },
83
- []
84
- );
85
-
86
- const clearCache = useCallback(async () => {
87
- await soundCacheService.clearCache();
88
- await refreshCacheSize();
89
- }, [refreshCacheSize]);
90
-
91
- const deleteCachedSound = useCallback(
92
- async (sound: Sound) => {
93
- if (!sound.filename) {
94
- return;
95
- }
96
- await soundCacheService.deleteCachedSound(sound.filename);
97
- await refreshCacheSize();
98
- },
99
- [refreshCacheSize]
100
- );
101
-
102
- return {
103
- isCached,
104
- getCachedUri,
105
- clearCache,
106
- getCacheSize,
107
- deleteCachedSound,
108
- cacheSize,
109
- isLoadingCacheSize,
110
- refreshCacheSize,
111
- };
112
- }
113
-
114
-
@@ -1,124 +0,0 @@
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, useCallback } 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 = useCallback(
55
- async (
56
- sound: Sound,
57
- playbackOptions?: SoundPlaybackOptions,
58
- onDownloadProgress?: (progress: number) => void
59
- ) => {
60
- // Create resolver with storage service
61
- const resolver = new SoundSourceResolver(options.storageService);
62
-
63
- // Progress callback wrapper
64
- const progressCallback = onDownloadProgress
65
- ? (progress: number) => {
66
- useSoundPlaybackStore.setState({
67
- downloadingSoundId: sound.id,
68
- downloadProgress: Math.round(progress * 100),
69
- });
70
- onDownloadProgress(progress);
71
- }
72
- : undefined;
73
-
74
- // Resolve source
75
- const resolved = await resolver.resolve(sound, progressCallback);
76
- if (!resolved) {
77
- throw new Error(`Cannot resolve audio source for sound: ${sound.id}`);
78
- }
79
-
80
- // Pass resolved source to store via metadata
81
- const soundWithSource: Sound = {
82
- ...sound,
83
- metadata: {
84
- ...sound.metadata,
85
- _resolvedSource: resolved.source,
86
- _isStreaming: resolved.isStreaming,
87
- },
88
- };
89
-
90
- await store.playSound(soundWithSource, playbackOptions, onDownloadProgress);
91
- },
92
- [options.storageService, store]
93
- );
94
-
95
- return {
96
- /** Play a sound */
97
- playSound,
98
-
99
- /** Stop current sound */
100
- stopSound: store.stopSound,
101
-
102
- /** Pause current sound */
103
- pauseSound: store.pauseSound,
104
-
105
- /** Resume paused sound */
106
- resumeSound: store.resumeSound,
107
-
108
- /** Check if specific sound is playing */
109
- isPlaying: store.isPlaying,
110
-
111
- /** Currently playing sound ID */
112
- playingSoundId: store.playingSoundId,
113
-
114
- /** Currently downloading sound ID */
115
- downloadingSoundId: store.downloadingSoundId,
116
-
117
- /** Download progress (0-100) */
118
- downloadProgress: store.downloadProgress,
119
-
120
- /** Whether sound is streaming */
121
- isStreaming: store.isStreaming,
122
- };
123
- }
124
-