UNPKG

8.46 kBPlain TextView Raw
1import { EventEmitter } from '@unimodules/core';
2
3import {
4 Playback,
5 PlaybackMixin,
6 AVPlaybackSource,
7 AVPlaybackStatus,
8 AVPlaybackStatusToSet,
9 assertStatusValuesInBounds,
10 getNativeSourceAndFullInitialStatusForLoadAsync,
11 getUnloadedStatus,
12} from '../AV';
13import { PitchCorrectionQuality } from '../Audio';
14import ExponentAV from '../ExponentAV';
15import { throwIfAudioIsDisabled } from './AudioAvailability';
16
17type AudioInstance = number | HTMLMediaElement | null;
18export class Sound implements Playback {
19 _loaded: boolean = false;
20 _loading: boolean = false;
21 _key: AudioInstance = null;
22 _lastStatusUpdate: string | null = null;
23 _lastStatusUpdateTime: Date | null = null;
24 _subscriptions: { remove: () => void }[] = [];
25 _eventEmitter: EventEmitter = new EventEmitter(ExponentAV);
26 _coalesceStatusUpdatesInMillis: number = 100;
27 _onPlaybackStatusUpdate: ((status: AVPlaybackStatus) => void) | null = null;
28
29 static create = async (
30 source: AVPlaybackSource,
31 initialStatus: AVPlaybackStatusToSet = {},
32 onPlaybackStatusUpdate: ((status: AVPlaybackStatus) => void) | null = null,
33 downloadFirst: boolean = true
34 ): Promise<{ sound: Sound; status: AVPlaybackStatus }> => {
35 console.warn(
36 `Sound.create is deprecated in favor of Sound.createAsync with the same API except for the new method name`
37 );
38 return Sound.createAsync(source, initialStatus, onPlaybackStatusUpdate, downloadFirst);
39 };
40
41 static createAsync = async (
42 source: AVPlaybackSource,
43 initialStatus: AVPlaybackStatusToSet = {},
44 onPlaybackStatusUpdate: ((status: AVPlaybackStatus) => void) | null = null,
45 downloadFirst: boolean = true
46 ): Promise<{ sound: Sound; status: AVPlaybackStatus }> => {
47 const sound: Sound = new Sound();
48 sound.setOnPlaybackStatusUpdate(onPlaybackStatusUpdate);
49 const status: AVPlaybackStatus = await sound.loadAsync(source, initialStatus, downloadFirst);
50 return { sound, status };
51 };
52
53 // Internal methods
54
55 _callOnPlaybackStatusUpdateForNewStatus(status: AVPlaybackStatus) {
56 const shouldDismissBasedOnCoalescing =
57 this._lastStatusUpdateTime &&
58 JSON.stringify(status) === this._lastStatusUpdate &&
59 Date.now() - this._lastStatusUpdateTime.getTime() < this._coalesceStatusUpdatesInMillis;
60
61 if (this._onPlaybackStatusUpdate != null && !shouldDismissBasedOnCoalescing) {
62 this._onPlaybackStatusUpdate(status);
63 this._lastStatusUpdateTime = new Date();
64 this._lastStatusUpdate = JSON.stringify(status);
65 }
66 }
67
68 async _performOperationAndHandleStatusAsync(
69 operation: () => Promise<AVPlaybackStatus>
70 ): Promise<AVPlaybackStatus> {
71 throwIfAudioIsDisabled();
72 if (this._loaded) {
73 const status = await operation();
74 this._callOnPlaybackStatusUpdateForNewStatus(status);
75 return status;
76 } else {
77 throw new Error('Cannot complete operation because sound is not loaded.');
78 }
79 }
80
81 _internalStatusUpdateCallback = ({
82 key,
83 status,
84 }: {
85 key: AudioInstance;
86 status: AVPlaybackStatus;
87 }) => {
88 if (this._key === key) {
89 this._callOnPlaybackStatusUpdateForNewStatus(status);
90 }
91 };
92
93 _internalErrorCallback = ({ key, error }: { key: AudioInstance; error: string }) => {
94 if (this._key === key) {
95 this._errorCallback(error);
96 }
97 };
98
99 // TODO: We can optimize by only using time observer on native if (this._onPlaybackStatusUpdate).
100 _subscribeToNativeEvents() {
101 if (this._loaded) {
102 this._subscriptions.push(
103 this._eventEmitter.addListener(
104 'didUpdatePlaybackStatus',
105 this._internalStatusUpdateCallback
106 )
107 );
108
109 this._subscriptions.push(
110 this._eventEmitter.addListener('ExponentAV.onError', this._internalErrorCallback)
111 );
112 }
113 }
114
115 _clearSubscriptions() {
116 this._subscriptions.forEach(e => e.remove());
117 this._subscriptions = [];
118 }
119
120 _errorCallback = (error: string) => {
121 this._clearSubscriptions();
122 this._loaded = false;
123 this._key = null;
124 this._callOnPlaybackStatusUpdateForNewStatus(getUnloadedStatus(error));
125 };
126
127 // ### Unified playback API ### (consistent with Video.js)
128 // All calls automatically call onPlaybackStatusUpdate as a side effect.
129
130 // Get status API
131
132 getStatusAsync = async (): Promise<AVPlaybackStatus> => {
133 if (this._loaded) {
134 return this._performOperationAndHandleStatusAsync(() =>
135 ExponentAV.getStatusForSound(this._key)
136 );
137 }
138 const status: AVPlaybackStatus = getUnloadedStatus();
139 this._callOnPlaybackStatusUpdateForNewStatus(status);
140 return status;
141 };
142
143 setOnPlaybackStatusUpdate(onPlaybackStatusUpdate: ((status: AVPlaybackStatus) => void) | null) {
144 this._onPlaybackStatusUpdate = onPlaybackStatusUpdate;
145 this.getStatusAsync();
146 }
147
148 // Loading / unloading API
149
150 async loadAsync(
151 source: AVPlaybackSource,
152 initialStatus: AVPlaybackStatusToSet = {},
153 downloadFirst: boolean = true
154 ): Promise<AVPlaybackStatus> {
155 throwIfAudioIsDisabled();
156 if (this._loading) {
157 throw new Error('The Sound is already loading.');
158 }
159 if (!this._loaded) {
160 this._loading = true;
161
162 const {
163 nativeSource,
164 fullInitialStatus,
165 } = await getNativeSourceAndFullInitialStatusForLoadAsync(
166 source,
167 initialStatus,
168 downloadFirst
169 );
170
171 // This is a workaround, since using load with resolve / reject seems to not work.
172 return new Promise<AVPlaybackStatus>((resolve, reject) => {
173 const loadSuccess = (result: [AudioInstance, AVPlaybackStatus]) => {
174 const [key, status] = result;
175 this._key = key;
176 this._loaded = true;
177 this._loading = false;
178 this._subscribeToNativeEvents();
179 this._callOnPlaybackStatusUpdateForNewStatus(status);
180 resolve(status);
181 };
182
183 const loadError = (error: Error) => {
184 this._loading = false;
185 reject(error);
186 };
187
188 ExponentAV.loadForSound(nativeSource, fullInitialStatus)
189 .then(loadSuccess)
190 .catch(loadError);
191 });
192 } else {
193 throw new Error('The Sound is already loaded.');
194 }
195 }
196
197 async unloadAsync(): Promise<AVPlaybackStatus> {
198 if (this._loaded) {
199 this._loaded = false;
200 const key = this._key;
201 this._key = null;
202 const status = await ExponentAV.unloadForSound(key);
203 this._callOnPlaybackStatusUpdateForNewStatus(status);
204 this._clearSubscriptions();
205 return status;
206 } else {
207 return this.getStatusAsync(); // Automatically calls onPlaybackStatusUpdate.
208 }
209 }
210
211 // Set status API (only available while isLoaded = true)
212
213 async setStatusAsync(status: AVPlaybackStatusToSet): Promise<AVPlaybackStatus> {
214 assertStatusValuesInBounds(status);
215 return this._performOperationAndHandleStatusAsync(() =>
216 ExponentAV.setStatusForSound(this._key, status)
217 );
218 }
219
220 async replayAsync(status: AVPlaybackStatusToSet = {}): Promise<AVPlaybackStatus> {
221 if (status.positionMillis && status.positionMillis !== 0) {
222 throw new Error('Requested position after replay has to be 0.');
223 }
224
225 return this._performOperationAndHandleStatusAsync(() =>
226 ExponentAV.replaySound(this._key, {
227 ...status,
228 positionMillis: 0,
229 shouldPlay: true,
230 })
231 );
232 }
233
234 // Methods of the Playback interface that are set via PlaybackMixin
235 playAsync!: () => Promise<AVPlaybackStatus>;
236 playFromPositionAsync!: (
237 positionMillis: number,
238 tolerances?: { toleranceMillisBefore?: number; toleranceMillisAfter?: number }
239 ) => Promise<AVPlaybackStatus>;
240 pauseAsync!: () => Promise<AVPlaybackStatus>;
241 stopAsync!: () => Promise<AVPlaybackStatus>;
242 setPositionAsync!: (
243 positionMillis: number,
244 tolerances?: { toleranceMillisBefore?: number; toleranceMillisAfter?: number }
245 ) => Promise<AVPlaybackStatus>;
246 setRateAsync!: (
247 rate: number,
248 shouldCorrectPitch: boolean,
249 pitchCorrectionQuality?: PitchCorrectionQuality
250 ) => Promise<AVPlaybackStatus>;
251 setVolumeAsync!: (volume: number) => Promise<AVPlaybackStatus>;
252 setIsMutedAsync!: (isMuted: boolean) => Promise<AVPlaybackStatus>;
253 setIsLoopingAsync!: (isLooping: boolean) => Promise<AVPlaybackStatus>;
254 setProgressUpdateIntervalAsync!: (
255 progressUpdateIntervalMillis: number
256 ) => Promise<AVPlaybackStatus>;
257}
258
259Object.assign(Sound.prototype, PlaybackMixin);