@umituz/react-native-sound 1.2.32 → 1.2.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/application/SoundCommands.ts +233 -0
- package/src/application/SoundEvents.ts +101 -0
- package/src/application/SoundService.ts +185 -0
- package/src/application/interfaces/IAudioService.ts +1 -1
- package/src/application/interfaces/ISoundCache.ts +4 -6
- package/src/domain/value-objects.ts +128 -0
- package/src/index.ts +35 -26
- package/src/infrastructure/AudioConfig.ts +0 -1
- package/src/infrastructure/AudioRepository.ts +216 -0
- package/src/presentation/SoundStore.ts +156 -0
- package/src/presentation/useSound.ts +183 -0
- package/src/types.ts +1 -2
- package/src/utils.ts +167 -5
- package/src/application/SoundServiceFacade.ts +0 -112
- package/src/application/presenters/SoundPresenter.ts +0 -78
- package/src/application/use-cases/BaseUseCase.ts +0 -36
- package/src/application/use-cases/ClearCacheUseCase.ts +0 -16
- package/src/application/use-cases/PauseSoundUseCase.ts +0 -26
- package/src/application/use-cases/PlaySoundUseCase.ts +0 -106
- package/src/application/use-cases/PreloadSoundUseCase.ts +0 -56
- package/src/application/use-cases/ResumeSoundUseCase.ts +0 -25
- package/src/application/use-cases/SeekSoundUseCase.ts +0 -26
- package/src/application/use-cases/SetRateUseCase.ts +0 -30
- package/src/application/use-cases/SetVolumeUseCase.ts +0 -30
- package/src/application/use-cases/StopSoundUseCase.ts +0 -27
- package/src/domain/entities/SoundState.ts +0 -83
- package/src/domain/value-objects/PlaybackPosition.ts +0 -30
- package/src/domain/value-objects/Rate.ts +0 -26
- package/src/domain/value-objects/SoundId.ts +0 -22
- package/src/domain/value-objects/SoundSource.ts +0 -30
- package/src/domain/value-objects/Volume.ts +0 -26
- package/src/infrastructure/ExpoAudioService.ts +0 -88
- package/src/infrastructure/SoundCache.ts +0 -70
- package/src/presentation/hooks/useSound.ts +0 -47
- package/src/presentation/store.ts +0 -58
package/src/index.ts
CHANGED
|
@@ -1,41 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @umituz/react-native-sound
|
|
3
3
|
* DDD-based audio management for React Native
|
|
4
|
+
* Optimized architecture with minimal code duplication
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
// Public API
|
|
7
|
-
export { useSound } from './presentation/
|
|
8
|
-
export { useSoundStore } from './presentation/
|
|
7
|
+
// ===== Public API =====
|
|
8
|
+
export { useSound } from './presentation/useSound';
|
|
9
|
+
export { useSoundStore, setupEventListeners } from './presentation/SoundStore';
|
|
9
10
|
|
|
10
|
-
// Types
|
|
11
|
+
// ===== Types =====
|
|
11
12
|
export type { PlaybackOptions, SoundState, SoundSource } from './types';
|
|
13
|
+
export type { PlaybackStatus, PlaybackOptions as ServicePlaybackOptions } from './application/interfaces/IAudioService';
|
|
14
|
+
export type { CachedSound, ISoundCache } from './application/interfaces/ISoundCache';
|
|
12
15
|
|
|
13
|
-
//
|
|
16
|
+
// ===== Domain (Advanced Usage) =====
|
|
17
|
+
export { SoundError, SoundErrorCode } from './domain/errors/SoundError';
|
|
18
|
+
export {
|
|
19
|
+
SoundId,
|
|
20
|
+
SoundSource as SoundSourceVO,
|
|
21
|
+
Volume,
|
|
22
|
+
Rate,
|
|
23
|
+
PlaybackPosition,
|
|
24
|
+
SoundValueObjects,
|
|
25
|
+
type SoundSourceValue,
|
|
26
|
+
} from './domain/value-objects';
|
|
27
|
+
|
|
28
|
+
// ===== Infrastructure (Testing/Customization) =====
|
|
29
|
+
export { Logger } from './infrastructure/Logger';
|
|
30
|
+
export { AudioConfig } from './infrastructure/AudioConfig';
|
|
31
|
+
export { AudioRepository } from './infrastructure/AudioRepository';
|
|
32
|
+
|
|
33
|
+
// ===== Application (Testing/Customization) =====
|
|
34
|
+
export { SoundService } from './application/SoundService';
|
|
35
|
+
export { SoundCommandProcessor, type SoundCommand, type PlayCommand, type PreloadCommand } from './application/SoundCommands';
|
|
36
|
+
export { SoundEvents, type SoundEvent, type PlaybackStartedEvent } from './application/SoundEvents';
|
|
37
|
+
|
|
38
|
+
// ===== Utilities =====
|
|
14
39
|
export {
|
|
15
40
|
clampVolume,
|
|
16
41
|
clampRate,
|
|
17
42
|
validateSoundId,
|
|
18
43
|
isSoundSourceValid,
|
|
19
44
|
isPlaybackStatusSuccess,
|
|
45
|
+
debounce,
|
|
46
|
+
throttle,
|
|
47
|
+
RateLimiter,
|
|
48
|
+
PeriodicTask,
|
|
49
|
+
AutoGCWeakCache,
|
|
20
50
|
} from './utils';
|
|
21
|
-
|
|
22
|
-
// Domain exports (for advanced usage)
|
|
23
|
-
export { SoundError } from './domain/errors/SoundError';
|
|
24
|
-
export { SoundId } from './domain/value-objects/SoundId';
|
|
25
|
-
export { SoundSource as SoundSourceVO } from './domain/value-objects/SoundSource';
|
|
26
|
-
export { Volume } from './domain/value-objects/Volume';
|
|
27
|
-
export { Rate } from './domain/value-objects/Rate';
|
|
28
|
-
export { PlaybackPosition } from './domain/value-objects/PlaybackPosition';
|
|
29
|
-
export { SoundState as SoundStateEntity } from './domain/entities/SoundState';
|
|
30
|
-
|
|
31
|
-
// Infrastructure exports (for testing/customization)
|
|
32
|
-
export { Logger } from './infrastructure/Logger';
|
|
33
|
-
export { AudioConfig } from './infrastructure/AudioConfig';
|
|
34
|
-
export { ExpoAudioService } from './infrastructure/ExpoAudioService';
|
|
35
|
-
export { SoundCache } from './infrastructure/SoundCache';
|
|
36
|
-
|
|
37
|
-
// Application exports (for testing/customization)
|
|
38
|
-
export { SoundServiceFacade } from './application/SoundServiceFacade';
|
|
39
|
-
export type { IAudioService, PlaybackStatus } from './application/interfaces/IAudioService';
|
|
40
|
-
export type { ISoundCache, CachedSound } from './application/interfaces/ISoundCache';
|
|
41
|
-
export { SoundPresenter } from './application/presenters/SoundPresenter';
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio Repository - Combines Cache + Audio Service
|
|
3
|
+
* Single responsibility: Audio resource management
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Audio } from 'expo-av';
|
|
7
|
+
import type { AVPlaybackStatus } from 'expo-av';
|
|
8
|
+
import type { IAudioService, PlaybackStatus, PlaybackOptions } from '../application/interfaces/IAudioService';
|
|
9
|
+
import type { ISoundCache, CachedSound } from '../application/interfaces/ISoundCache';
|
|
10
|
+
import type { SoundSourceValue } from '../domain/value-objects';
|
|
11
|
+
import { SoundEvents } from '../application/SoundEvents';
|
|
12
|
+
import { Logger } from './Logger';
|
|
13
|
+
import { RateLimiter } from '../utils';
|
|
14
|
+
|
|
15
|
+
// ===== Audio Service Implementation =====
|
|
16
|
+
class ExpoAudioService implements IAudioService {
|
|
17
|
+
async createSound(
|
|
18
|
+
source: SoundSourceValue,
|
|
19
|
+
options: PlaybackOptions,
|
|
20
|
+
onStatusUpdate: (status: PlaybackStatus) => void
|
|
21
|
+
): Promise<{ sound: Audio.Sound }> {
|
|
22
|
+
const { sound } = await Audio.Sound.createAsync(
|
|
23
|
+
source,
|
|
24
|
+
{
|
|
25
|
+
shouldPlay: options.shouldPlay,
|
|
26
|
+
isLooping: options.isLooping,
|
|
27
|
+
volume: options.volume,
|
|
28
|
+
rate: options.rate,
|
|
29
|
+
positionMillis: options.positionMillis,
|
|
30
|
+
},
|
|
31
|
+
(status) => onStatusUpdate(this.mapStatus(status))
|
|
32
|
+
);
|
|
33
|
+
Logger.debug('Sound created');
|
|
34
|
+
return { sound };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async play(sound: unknown): Promise<void> {
|
|
38
|
+
await (sound as Audio.Sound).playAsync();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async pause(sound: unknown): Promise<void> {
|
|
42
|
+
await (sound as Audio.Sound).pauseAsync();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async stop(sound: unknown): Promise<void> {
|
|
46
|
+
await (sound as Audio.Sound).stopAsync();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async unload(sound: unknown): Promise<void> {
|
|
50
|
+
await (sound as Audio.Sound).unloadAsync();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async setVolume(sound: unknown, volume: number): Promise<void> {
|
|
54
|
+
await (sound as Audio.Sound).setVolumeAsync(volume);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async setRate(sound: unknown, rate: number): Promise<void> {
|
|
58
|
+
await (sound as Audio.Sound).setRateAsync(rate, false);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async setPosition(sound: unknown, positionMillis: number): Promise<void> {
|
|
62
|
+
await (sound as Audio.Sound).setStatusAsync({ positionMillis });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async getStatus(sound: unknown): Promise<PlaybackStatus | null> {
|
|
66
|
+
const status = await (sound as Audio.Sound).getStatusAsync();
|
|
67
|
+
return status.isLoaded ? this.mapStatus(status) : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private mapStatus(status: AVPlaybackStatus): PlaybackStatus {
|
|
71
|
+
if (!status.isLoaded) {
|
|
72
|
+
const statusRecord = status as Record<string, unknown>;
|
|
73
|
+
const hasError = 'error' in statusRecord;
|
|
74
|
+
const error = hasError && statusRecord.error instanceof Error ? statusRecord.error.message : undefined;
|
|
75
|
+
return {
|
|
76
|
+
isLoaded: false,
|
|
77
|
+
isPlaying: false,
|
|
78
|
+
isBuffering: false,
|
|
79
|
+
positionMillis: 0,
|
|
80
|
+
durationMillis: 0,
|
|
81
|
+
isLooping: false,
|
|
82
|
+
didJustFinish: false,
|
|
83
|
+
error,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
isLoaded: true,
|
|
88
|
+
isPlaying: status.isPlaying,
|
|
89
|
+
isBuffering: status.isBuffering,
|
|
90
|
+
positionMillis: status.positionMillis,
|
|
91
|
+
durationMillis: status.durationMillis || 0,
|
|
92
|
+
rate: status.rate,
|
|
93
|
+
isLooping: status.isLooping,
|
|
94
|
+
didJustFinish: status.didJustFinish,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ===== Sound Cache Configuration =====
|
|
100
|
+
const CACHE_CONFIG = {
|
|
101
|
+
MAX_SIZE: 3,
|
|
102
|
+
EXPIRE_MS: 5 * 60 * 1000, // 5 minutes
|
|
103
|
+
CLEAN_INTERVAL_MS: 30 * 1000, // 30 seconds
|
|
104
|
+
} as const;
|
|
105
|
+
|
|
106
|
+
// ===== Sound Cache Implementation =====
|
|
107
|
+
class SoundCache implements ISoundCache {
|
|
108
|
+
private cache = new Map<string, CachedSound>();
|
|
109
|
+
private accessOrder: string[] = [];
|
|
110
|
+
private readonly maxSize = CACHE_CONFIG.MAX_SIZE;
|
|
111
|
+
private readonly expireMs = CACHE_CONFIG.EXPIRE_MS;
|
|
112
|
+
private lastCleanTime = 0;
|
|
113
|
+
private readonly cleanIntervalMs = CACHE_CONFIG.CLEAN_INTERVAL_MS;
|
|
114
|
+
private cleanLimiter = new RateLimiter(this.cleanIntervalMs);
|
|
115
|
+
|
|
116
|
+
constructor(
|
|
117
|
+
private readonly audioService: IAudioService,
|
|
118
|
+
private readonly events: SoundEvents
|
|
119
|
+
) {}
|
|
120
|
+
|
|
121
|
+
get(id: string): CachedSound | undefined {
|
|
122
|
+
const cached = this.cache.get(id);
|
|
123
|
+
if (cached) {
|
|
124
|
+
this.updateAccessOrder(id);
|
|
125
|
+
}
|
|
126
|
+
return cached;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async set(id: string, cached: CachedSound): Promise<void> {
|
|
130
|
+
await this.cleanExpiredThrottled();
|
|
131
|
+
await this.enforceLimit();
|
|
132
|
+
this.cache.set(id, cached);
|
|
133
|
+
this.updateAccessOrder(id);
|
|
134
|
+
Logger.debug(`Cached: ${id}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async delete(id: string): Promise<void> {
|
|
138
|
+
const cached = this.cache.get(id);
|
|
139
|
+
if (cached) {
|
|
140
|
+
try {
|
|
141
|
+
await this.audioService.unload(cached.sound);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
Logger.warn(`Failed to unload: ${id}`, error);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
this.cache.delete(id);
|
|
147
|
+
this.removeFromAccessOrder(id);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
has(id: string): boolean {
|
|
151
|
+
return this.cache.has(id);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async clear(): Promise<void> {
|
|
155
|
+
const unloadPromises = Array.from(this.cache.values()).map((cached) =>
|
|
156
|
+
this.audioService.unload(cached.sound).catch((error) => {
|
|
157
|
+
Logger.warn('Failed to unload during clear', error);
|
|
158
|
+
})
|
|
159
|
+
);
|
|
160
|
+
await Promise.all(unloadPromises);
|
|
161
|
+
this.cache.clear();
|
|
162
|
+
this.accessOrder = [];
|
|
163
|
+
Logger.debug('Cache cleared');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async cleanExpiredThrottled(): Promise<void> {
|
|
167
|
+
await this.cleanLimiter.execute(async () => {
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
const expiredIds: string[] = [];
|
|
170
|
+
for (const [id, cached] of this.cache) {
|
|
171
|
+
if (now - cached.loadedAt > this.expireMs) {
|
|
172
|
+
expiredIds.push(id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const id of expiredIds) {
|
|
176
|
+
await this.delete(id);
|
|
177
|
+
Logger.debug(`Expired cache removed: ${id}`);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private async enforceLimit(): Promise<void> {
|
|
183
|
+
while (this.cache.size >= this.maxSize && this.accessOrder.length > 0) {
|
|
184
|
+
const lruId = this.accessOrder[0];
|
|
185
|
+
if (lruId && this.cache.has(lruId)) {
|
|
186
|
+
await this.delete(lruId);
|
|
187
|
+
Logger.debug(`LRU enforced, removed: ${lruId}`);
|
|
188
|
+
} else {
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private updateAccessOrder(id: string): void {
|
|
195
|
+
this.removeFromAccessOrder(id);
|
|
196
|
+
this.accessOrder.push(id);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private removeFromAccessOrder(id: string): void {
|
|
200
|
+
const index = this.accessOrder.indexOf(id);
|
|
201
|
+
if (index > -1) {
|
|
202
|
+
this.accessOrder.splice(index, 1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ===== Repository Export =====
|
|
208
|
+
export class AudioRepository {
|
|
209
|
+
readonly audioService: IAudioService;
|
|
210
|
+
readonly cache: ISoundCache;
|
|
211
|
+
|
|
212
|
+
constructor(events: SoundEvents) {
|
|
213
|
+
this.audioService = new ExpoAudioService();
|
|
214
|
+
this.cache = new SoundCache(this.audioService, events);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event-Driven Sound Store - Eliminates Presenter Pattern
|
|
3
|
+
* Direct event subscription from SoundService
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createStore } from '@umituz/react-native-design-system/storage';
|
|
7
|
+
import type { PlaybackStatus } from '../application/interfaces/IAudioService';
|
|
8
|
+
import type { SoundSourceValue } from '../domain/value-objects';
|
|
9
|
+
import type { SoundEvents } from '../application/SoundEvents';
|
|
10
|
+
|
|
11
|
+
// ===== Store State =====
|
|
12
|
+
interface SoundState {
|
|
13
|
+
isPlaying: boolean;
|
|
14
|
+
isBuffering: boolean;
|
|
15
|
+
positionMillis: number;
|
|
16
|
+
durationMillis: number;
|
|
17
|
+
volume: number;
|
|
18
|
+
rate: number;
|
|
19
|
+
isLooping: boolean;
|
|
20
|
+
error: string | null;
|
|
21
|
+
currentId: string | null;
|
|
22
|
+
currentSource: SoundSourceValue | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SoundActions {
|
|
26
|
+
setPlaying: (isPlaying: boolean) => void;
|
|
27
|
+
setBuffering: (isBuffering: boolean) => void;
|
|
28
|
+
setProgress: (position: number, duration: number) => void;
|
|
29
|
+
setError: (error: string | null) => void;
|
|
30
|
+
setCurrent: (id: string | null, source: SoundSourceValue | null) => void;
|
|
31
|
+
setVolumeState: (volume: number) => void;
|
|
32
|
+
setRateState: (rate: number) => void;
|
|
33
|
+
setLooping: (isLooping: boolean) => void;
|
|
34
|
+
reset: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const initialState: SoundState = {
|
|
38
|
+
isPlaying: false,
|
|
39
|
+
isBuffering: false,
|
|
40
|
+
positionMillis: 0,
|
|
41
|
+
durationMillis: 0,
|
|
42
|
+
volume: 1.0,
|
|
43
|
+
rate: 1.0,
|
|
44
|
+
isLooping: false,
|
|
45
|
+
error: null,
|
|
46
|
+
currentId: null,
|
|
47
|
+
currentSource: null,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ===== Store Creation =====
|
|
51
|
+
export const useSoundStore = createStore<SoundState, SoundActions>({
|
|
52
|
+
name: 'sound-store',
|
|
53
|
+
initialState,
|
|
54
|
+
persist: false,
|
|
55
|
+
actions: (set) => ({
|
|
56
|
+
setPlaying: (isPlaying) => set({ isPlaying }),
|
|
57
|
+
setBuffering: (isBuffering) => set({ isBuffering }),
|
|
58
|
+
setProgress: (position, duration) => set({ positionMillis: position, durationMillis: duration }),
|
|
59
|
+
setError: (error) => set({ error }),
|
|
60
|
+
setCurrent: (currentId, currentSource) => set({ currentId, currentSource }),
|
|
61
|
+
setVolumeState: (volume) => set({ volume }),
|
|
62
|
+
setRateState: (rate) => set({ rate }),
|
|
63
|
+
setLooping: (isLooping) => set({ isLooping }),
|
|
64
|
+
reset: () => set(initialState),
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ===== Event Integration =====
|
|
69
|
+
export function setupEventListeners(events: SoundEvents): () => void {
|
|
70
|
+
const unsubscribers: Array<() => void> = [];
|
|
71
|
+
|
|
72
|
+
// Playback started
|
|
73
|
+
unsubscribers.push(
|
|
74
|
+
events.on('PLAYBACK_STARTED', (payload) => {
|
|
75
|
+
const p = payload as { id: string; source: SoundSourceValue; volume: number; rate: number; isLooping: boolean };
|
|
76
|
+
const store = useSoundStore.getState();
|
|
77
|
+
store.setCurrent(p.id, p.source);
|
|
78
|
+
store.setError(null);
|
|
79
|
+
store.setVolumeState(p.volume);
|
|
80
|
+
store.setRateState(p.rate);
|
|
81
|
+
store.setLooping(p.isLooping);
|
|
82
|
+
store.setPlaying(true);
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Playback stopped
|
|
87
|
+
unsubscribers.push(
|
|
88
|
+
events.on('PLAYBACK_STOPPED', () => {
|
|
89
|
+
const store = useSoundStore.getState();
|
|
90
|
+
store.setPlaying(false);
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Status update
|
|
95
|
+
unsubscribers.push(
|
|
96
|
+
events.on('STATUS_UPDATE', (payload) => {
|
|
97
|
+
const status = payload as PlaybackStatus;
|
|
98
|
+
const store = useSoundStore.getState();
|
|
99
|
+
if (status.isLoaded) {
|
|
100
|
+
store.setPlaying(status.isPlaying);
|
|
101
|
+
store.setBuffering(status.isBuffering);
|
|
102
|
+
store.setProgress(status.positionMillis, status.durationMillis);
|
|
103
|
+
|
|
104
|
+
if (status.rate !== undefined) {
|
|
105
|
+
store.setRateState(status.rate);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (status.didJustFinish && !status.isLooping) {
|
|
109
|
+
store.setPlaying(false);
|
|
110
|
+
store.setProgress(status.durationMillis, status.durationMillis);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (status.error) {
|
|
115
|
+
store.setError(status.error);
|
|
116
|
+
store.setPlaying(false);
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Volume changed
|
|
122
|
+
unsubscribers.push(
|
|
123
|
+
events.on('VOLUME_CHANGED', (payload) => {
|
|
124
|
+
useSoundStore.getState().setVolumeState(payload as number);
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Rate changed
|
|
129
|
+
unsubscribers.push(
|
|
130
|
+
events.on('RATE_CHANGED', (payload) => {
|
|
131
|
+
useSoundStore.getState().setRateState(payload as number);
|
|
132
|
+
})
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// State reset
|
|
136
|
+
unsubscribers.push(
|
|
137
|
+
events.on('STATE_RESET', () => {
|
|
138
|
+
useSoundStore.getState().reset();
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Error
|
|
143
|
+
unsubscribers.push(
|
|
144
|
+
events.on('ERROR', (payload) => {
|
|
145
|
+
const error = payload as string;
|
|
146
|
+
const store = useSoundStore.getState();
|
|
147
|
+
store.setError(error);
|
|
148
|
+
store.setPlaying(false);
|
|
149
|
+
})
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Return cleanup function
|
|
153
|
+
return () => {
|
|
154
|
+
unsubscribers.forEach((unsub) => unsub());
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSound Hook - Event-Driven with Proper Lifecycle Management
|
|
3
|
+
* Single hook for all sound functionality
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect, useCallback, useRef } from 'react';
|
|
7
|
+
import { useSoundStore, setupEventListeners } from './SoundStore';
|
|
8
|
+
import { SoundService } from '../application/SoundService';
|
|
9
|
+
import type { SoundSourceValue } from '../domain/value-objects';
|
|
10
|
+
import type { PlaybackOptions } from '../types';
|
|
11
|
+
import { Logger } from '../infrastructure/Logger';
|
|
12
|
+
|
|
13
|
+
let serviceInstance: SoundService | null = null;
|
|
14
|
+
let serviceRefCount = 0;
|
|
15
|
+
let cleanupEventListeners: (() => void) | null = null;
|
|
16
|
+
|
|
17
|
+
const getService = (): SoundService => {
|
|
18
|
+
if (!serviceInstance) {
|
|
19
|
+
serviceInstance = SoundService.getInstance();
|
|
20
|
+
// Access events through public API
|
|
21
|
+
cleanupEventListeners = setupEventListeners(serviceInstance.getEvents());
|
|
22
|
+
}
|
|
23
|
+
serviceRefCount++;
|
|
24
|
+
return serviceInstance;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const releaseService = async (): Promise<void> => {
|
|
28
|
+
serviceRefCount--;
|
|
29
|
+
if (serviceRefCount <= 0 && serviceInstance) {
|
|
30
|
+
try {
|
|
31
|
+
await serviceInstance.dispose();
|
|
32
|
+
} catch (error) {
|
|
33
|
+
Logger.warn('Error during service cleanup', error);
|
|
34
|
+
}
|
|
35
|
+
cleanupEventListeners?.();
|
|
36
|
+
cleanupEventListeners = null;
|
|
37
|
+
serviceInstance = null;
|
|
38
|
+
serviceRefCount = 0;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const useSound = () => {
|
|
43
|
+
const state = useSoundStore();
|
|
44
|
+
const serviceRef = useRef<SoundService | null>(null);
|
|
45
|
+
const isMountedRef = useRef(true);
|
|
46
|
+
|
|
47
|
+
// Initialize service on mount
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
serviceRef.current = getService();
|
|
50
|
+
isMountedRef.current = true;
|
|
51
|
+
|
|
52
|
+
return () => {
|
|
53
|
+
isMountedRef.current = false;
|
|
54
|
+
// Don't await in cleanup - fire and forget
|
|
55
|
+
releaseService().catch((error) => Logger.warn('Error in useSound cleanup', error));
|
|
56
|
+
};
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
// Memoize all functions to prevent re-renders
|
|
60
|
+
const play = useCallback(
|
|
61
|
+
(id: string, source: SoundSourceValue, options?: PlaybackOptions) => {
|
|
62
|
+
if (isMountedRef.current && serviceRef.current) {
|
|
63
|
+
return serviceRef.current.play(id, source, options);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
[]
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const pause = useCallback(() => {
|
|
70
|
+
if (isMountedRef.current && serviceRef.current) {
|
|
71
|
+
return serviceRef.current.pause();
|
|
72
|
+
}
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
const resume = useCallback(() => {
|
|
76
|
+
if (isMountedRef.current && serviceRef.current) {
|
|
77
|
+
return serviceRef.current.resume();
|
|
78
|
+
}
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
const stop = useCallback(() => {
|
|
82
|
+
if (isMountedRef.current && serviceRef.current) {
|
|
83
|
+
return serviceRef.current.stop();
|
|
84
|
+
}
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const seek = useCallback(
|
|
88
|
+
(positionMillis: number) => {
|
|
89
|
+
if (isMountedRef.current && serviceRef.current) {
|
|
90
|
+
return serviceRef.current.seek(positionMillis);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
[]
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const setVolume = useCallback(
|
|
97
|
+
(volume: number) => {
|
|
98
|
+
if (isMountedRef.current && serviceRef.current) {
|
|
99
|
+
return serviceRef.current.setVolume(volume);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
[]
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const setRate = useCallback(
|
|
106
|
+
(rate: number) => {
|
|
107
|
+
if (isMountedRef.current && serviceRef.current) {
|
|
108
|
+
return serviceRef.current.setRate(rate);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
[]
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const preload = useCallback(
|
|
115
|
+
(id: string, source: SoundSourceValue, options?: PlaybackOptions) => {
|
|
116
|
+
if (isMountedRef.current && serviceRef.current) {
|
|
117
|
+
return serviceRef.current.preload(id, source, options);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
[]
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const unload = useCallback(() => {
|
|
124
|
+
if (isMountedRef.current && serviceRef.current) {
|
|
125
|
+
return serviceRef.current.unload();
|
|
126
|
+
}
|
|
127
|
+
}, []);
|
|
128
|
+
|
|
129
|
+
const clearCache = useCallback(async () => {
|
|
130
|
+
if (isMountedRef.current && serviceRef.current) {
|
|
131
|
+
await serviceRef.current.clearCache();
|
|
132
|
+
}
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
const isCached = useCallback(
|
|
136
|
+
(id: string) => {
|
|
137
|
+
if (serviceRef.current) {
|
|
138
|
+
return serviceRef.current.isCached(id);
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
},
|
|
142
|
+
[]
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const subscribe = useCallback(
|
|
146
|
+
(eventType: string, listener: (payload: unknown) => void) => {
|
|
147
|
+
if (serviceRef.current) {
|
|
148
|
+
return serviceRef.current.on(eventType, listener);
|
|
149
|
+
}
|
|
150
|
+
return () => {};
|
|
151
|
+
},
|
|
152
|
+
[]
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
// State
|
|
157
|
+
isPlaying: state.isPlaying,
|
|
158
|
+
isBuffering: state.isBuffering,
|
|
159
|
+
isLooping: state.isLooping,
|
|
160
|
+
position: state.positionMillis,
|
|
161
|
+
duration: state.durationMillis,
|
|
162
|
+
currentId: state.currentId,
|
|
163
|
+
error: state.error,
|
|
164
|
+
volume: state.volume,
|
|
165
|
+
rate: state.rate,
|
|
166
|
+
|
|
167
|
+
// Actions (memoized)
|
|
168
|
+
play,
|
|
169
|
+
pause,
|
|
170
|
+
resume,
|
|
171
|
+
stop,
|
|
172
|
+
seek,
|
|
173
|
+
setVolume,
|
|
174
|
+
setRate,
|
|
175
|
+
preload,
|
|
176
|
+
unload,
|
|
177
|
+
clearCache,
|
|
178
|
+
isCached,
|
|
179
|
+
|
|
180
|
+
// Advanced: Event subscription
|
|
181
|
+
on: subscribe,
|
|
182
|
+
};
|
|
183
|
+
};
|
package/src/types.ts
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Public Types
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
export type { SoundSourceValue } from './domain/value-objects
|
|
6
|
-
export type { SoundStateData } from './domain/entities/SoundState';
|
|
5
|
+
export type { SoundSourceValue } from './domain/value-objects';
|
|
7
6
|
|
|
8
7
|
export interface PlaybackOptions {
|
|
9
8
|
isLooping?: boolean;
|