@umituz/react-native-sound 1.2.31 → 1.2.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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 +42 -0
- package/src/application/interfaces/ISoundCache.ts +19 -0
- package/src/domain/errors/SoundError.ts +44 -0
- package/src/domain/value-objects.ts +128 -0
- package/src/index.ts +45 -6
- package/src/infrastructure/AudioConfig.ts +24 -0
- package/src/infrastructure/AudioRepository.ts +216 -0
- package/src/infrastructure/Logger.ts +23 -0
- package/src/presentation/SoundStore.ts +156 -0
- package/src/presentation/useSound.ts +183 -0
- package/src/types.ts +5 -5
- package/src/utils.ts +173 -15
- package/src/AudioManager.ts +0 -338
- package/src/store.ts +0 -45
- package/src/useSound.ts +0 -107
|
@@ -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
|
-
|
|
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
|
+
}
|