@umituz/react-native-sound 1.2.31 → 1.2.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Value Objects - Consolidated with Generic Base Class
3
+ * Eliminates duplication across multiple files
4
+ */
5
+
6
+ // ===== Generic Base Value Object =====
7
+ export abstract class ValueObject<T> {
8
+ protected readonly value: T;
9
+
10
+ constructor(value: T, validator?: (value: T) => void) {
11
+ if (validator) validator(value);
12
+ this.value = this.transform(value);
13
+ }
14
+
15
+ protected abstract transform(value: T): T;
16
+
17
+ getValue(): T {
18
+ return this.value;
19
+ }
20
+
21
+ equals(other: ValueObject<T>): boolean {
22
+ return other instanceof this.constructor && this.value === other.value;
23
+ }
24
+
25
+ toString(): string {
26
+ return String(this.value);
27
+ }
28
+ }
29
+
30
+ // ===== Sound ID =====
31
+ export class SoundId extends ValueObject<string> {
32
+ protected transform(value: string): string {
33
+ if (typeof value !== 'string' || value.trim().length === 0) {
34
+ throw new Error('Sound id must be a non-empty string');
35
+ }
36
+ return value.trim();
37
+ }
38
+
39
+ override toString(): string {
40
+ return this.value;
41
+ }
42
+ }
43
+
44
+ // ===== Sound Source =====
45
+ export type SoundSourceValue = number | { uri: string; headers?: Record<string, string> };
46
+
47
+ export class SoundSource extends ValueObject<SoundSourceValue> {
48
+ protected transform(value: SoundSourceValue): SoundSourceValue {
49
+ if (value === null || value === undefined) {
50
+ throw new Error('Sound source cannot be null or undefined');
51
+ }
52
+ return value;
53
+ }
54
+
55
+ isLocal(): boolean {
56
+ return typeof this.value === 'number';
57
+ }
58
+
59
+ isRemote(): boolean {
60
+ return typeof this.value === 'object' && 'uri' in this.value;
61
+ }
62
+ }
63
+
64
+ // ===== Volume (0.0 - 1.0) =====
65
+ export class Volume extends ValueObject<number> {
66
+ private readonly MIN = 0;
67
+ private readonly MAX = 1;
68
+
69
+ protected transform(value: number): number {
70
+ if (!Number.isFinite(value)) return 1.0;
71
+ return Math.max(this.MIN, Math.min(this.MAX, value));
72
+ }
73
+ }
74
+
75
+ // ===== Playback Rate (0.5 - 2.0) =====
76
+ export class Rate extends ValueObject<number> {
77
+ private readonly MIN = 0.5;
78
+ private readonly MAX = 2.0;
79
+
80
+ protected transform(value: number): number {
81
+ if (!Number.isFinite(value)) return 1.0;
82
+ return Math.max(this.MIN, Math.min(this.MAX, value));
83
+ }
84
+ }
85
+
86
+ // ===== Playback Position =====
87
+ export class PlaybackPosition extends ValueObject<number> {
88
+ constructor(value: number, private readonly duration?: number) {
89
+ super(value);
90
+ }
91
+
92
+ protected transform(value: number): number {
93
+ if (!Number.isFinite(value)) {
94
+ throw new Error('Position must be a finite number');
95
+ }
96
+ return this.duration ? Math.max(0, Math.min(this.duration, value)) : value;
97
+ }
98
+
99
+ getDuration(): number | undefined {
100
+ return this.duration;
101
+ }
102
+ }
103
+
104
+ // ===== Factory for Batch Creation =====
105
+ export class SoundValueObjects {
106
+ static create(params: {
107
+ id: string;
108
+ source: SoundSourceValue;
109
+ volume?: number;
110
+ rate?: number;
111
+ position?: number;
112
+ duration?: number;
113
+ }): {
114
+ id: SoundId;
115
+ source: SoundSource;
116
+ volume: Volume;
117
+ rate: Rate;
118
+ position?: PlaybackPosition;
119
+ } {
120
+ return {
121
+ id: new SoundId(params.id),
122
+ source: new SoundSource(params.source),
123
+ volume: new Volume(params.volume ?? 1.0),
124
+ rate: new Rate(params.rate ?? 1.0),
125
+ position: params.position ? new PlaybackPosition(params.position, params.duration) : undefined,
126
+ };
127
+ }
128
+ }
package/src/index.ts CHANGED
@@ -1,11 +1,50 @@
1
- export type { SoundSource, PlaybackOptions, SoundState } from './types';
1
+ /**
2
+ * @umituz/react-native-sound
3
+ * DDD-based audio management for React Native
4
+ * Optimized architecture with minimal code duplication
5
+ */
6
+
7
+ // ===== Public API =====
8
+ export { useSound } from './presentation/useSound';
9
+ export { useSoundStore, setupEventListeners } from './presentation/SoundStore';
10
+
11
+ // ===== Types =====
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';
15
+
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 =====
2
39
  export {
3
- isPlaybackStatusSuccess,
4
- isSoundSourceValid,
5
40
  clampVolume,
6
41
  clampRate,
7
42
  validateSoundId,
43
+ isSoundSourceValid,
44
+ isPlaybackStatusSuccess,
45
+ debounce,
46
+ throttle,
47
+ RateLimiter,
48
+ PeriodicTask,
49
+ AutoGCWeakCache,
8
50
  } from './utils';
9
- export { audioManager } from './AudioManager';
10
- export { useSound } from './useSound';
11
- export { useSoundStore } from './store';
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Audio Configuration Service
3
+ */
4
+
5
+ import { Audio, InterruptionModeIOS, InterruptionModeAndroid } from 'expo-av';
6
+ import { Logger } from './Logger';
7
+
8
+ export class AudioConfig {
9
+ static async configure(): Promise<void> {
10
+ try {
11
+ await Audio.setAudioModeAsync({
12
+ playsInSilentModeIOS: true,
13
+ staysActiveInBackground: false,
14
+ shouldDuckAndroid: true,
15
+ playThroughEarpieceAndroid: false,
16
+ interruptionModeIOS: InterruptionModeIOS.DuckOthers,
17
+ interruptionModeAndroid: InterruptionModeAndroid.DuckOthers,
18
+ });
19
+ Logger.debug('Audio configured successfully');
20
+ } catch (error) {
21
+ Logger.warn('Failed to configure audio session', error);
22
+ }
23
+ }
24
+ }
@@ -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,23 @@
1
+ /**
2
+ * Logging Service
3
+ */
4
+
5
+ export class Logger {
6
+ static debug(message: string, ...args: unknown[]): void {
7
+ if (__DEV__) {
8
+ console.log(`[Sound] ${message}`, ...args);
9
+ }
10
+ }
11
+
12
+ static warn(message: string, ...args: unknown[]): void {
13
+ if (__DEV__) {
14
+ console.warn(`[Sound] ${message}`, ...args);
15
+ }
16
+ }
17
+
18
+ static error(message: string, error?: unknown): void {
19
+ if (__DEV__) {
20
+ console.error(`[Sound] ${message}`, error);
21
+ }
22
+ }
23
+ }
@@ -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
+ }