xref: /expo/packages/expo-av/src/ExponentAV.web.ts (revision 7ec70e61)
1import { PermissionResponse, PermissionStatus, SyntheticPlatformEmitter } from 'expo-modules-core';
2
3import type { AVPlaybackNativeSource, AVPlaybackStatus, AVPlaybackStatusToSet } from './AV.types';
4import type { RecordingStatus } from './Audio/Recording.types';
5import { RecordingOptionsPresets } from './Audio/RecordingConstants';
6
7async function getPermissionWithQueryAsync(
8  name: PermissionNameWithAdditionalValues
9): Promise<PermissionStatus | null> {
10  if (!navigator || !navigator.permissions || !navigator.permissions.query) return null;
11
12  try {
13    const { state } = await navigator.permissions.query({ name });
14    switch (state) {
15      case 'granted':
16        return PermissionStatus.GRANTED;
17      case 'denied':
18        return PermissionStatus.DENIED;
19      default:
20        return PermissionStatus.UNDETERMINED;
21    }
22  } catch {
23    // Firefox - TypeError: 'microphone' (value of 'name' member of PermissionDescriptor) is not a valid value for enumeration PermissionName.
24    return PermissionStatus.UNDETERMINED;
25  }
26}
27
28function getUserMedia(constraints: MediaStreamConstraints): Promise<MediaStream> {
29  if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
30    return navigator.mediaDevices.getUserMedia(constraints);
31  }
32
33  // Some browsers partially implement mediaDevices. We can't just assign an object
34  // with getUserMedia as it would overwrite existing properties.
35  // Here, we will just add the getUserMedia property if it's missing.
36
37  // First get ahold of the legacy getUserMedia, if present
38  const getUserMedia =
39    // TODO: this method is deprecated, migrate to https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
40    navigator.getUserMedia ||
41    navigator.webkitGetUserMedia ||
42    navigator.mozGetUserMedia ||
43    function () {
44      const error: any = new Error('Permission unimplemented');
45      error.code = 0;
46      error.name = 'NotAllowedError';
47      throw error;
48    };
49
50  return new Promise((resolve, reject) => {
51    getUserMedia.call(navigator, constraints, resolve, reject);
52  });
53}
54
55function getStatusFromMedia(media?: HTMLMediaElement): AVPlaybackStatus {
56  if (!media) {
57    return {
58      isLoaded: false,
59      error: undefined,
60    };
61  }
62
63  const isPlaying = !!(
64    media.currentTime > 0 &&
65    !media.paused &&
66    !media.ended &&
67    media.readyState > 2
68  );
69
70  const status: AVPlaybackStatus = {
71    isLoaded: true,
72    uri: media.src,
73    progressUpdateIntervalMillis: 100, //TODO: Bacon: Add interval between calls
74    durationMillis: media.duration * 1000,
75    positionMillis: media.currentTime * 1000,
76    // playableDurationMillis: media.buffered * 1000,
77    // seekMillisToleranceBefore?: number
78    // seekMillisToleranceAfter?: number
79    shouldPlay: media.autoplay,
80    isPlaying,
81    isBuffering: false, //media.waiting,
82    rate: media.playbackRate,
83    // TODO: Bacon: This seems too complicated right now: https://webaudio.github.io/web-audio-api/#dom-biquadfilternode-frequency
84    shouldCorrectPitch: false,
85    volume: media.volume,
86    audioPan: 0,
87    isMuted: media.muted,
88    isLooping: media.loop,
89    didJustFinish: media.ended,
90  };
91
92  return status;
93}
94
95async function setStatusForMedia(
96  media: HTMLMediaElement,
97  status: AVPlaybackStatusToSet
98): Promise<AVPlaybackStatus> {
99  if (status.positionMillis !== undefined) {
100    media.currentTime = status.positionMillis / 1000;
101  }
102  // if (status.progressUpdateIntervalMillis !== undefined) {
103  //   media.progressUpdateIntervalMillis = status.progressUpdateIntervalMillis;
104  // }
105  // if (status.seekMillisToleranceBefore !== undefined) {
106  //   media.seekMillisToleranceBefore = status.seekMillisToleranceBefore;
107  // }
108  // if (status.seekMillisToleranceAfter !== undefined) {
109  //   media.seekMillisToleranceAfter = status.seekMillisToleranceAfter;
110  // }
111  // if (status.shouldCorrectPitch !== undefined) {
112  //   media.shouldCorrectPitch = status.shouldCorrectPitch;
113  // }
114  if (status.shouldPlay !== undefined) {
115    if (status.shouldPlay) {
116      await media.play();
117    } else {
118      await media.pause();
119    }
120  }
121  if (status.rate !== undefined) {
122    media.playbackRate = status.rate;
123  }
124  if (status.volume !== undefined) {
125    media.volume = status.volume;
126  }
127  if (status.isMuted !== undefined) {
128    media.muted = status.isMuted;
129  }
130  if (status.isLooping !== undefined) {
131    media.loop = status.isLooping;
132  }
133
134  return getStatusFromMedia(media);
135}
136
137let mediaRecorder: null | any /*MediaRecorder*/ = null;
138let mediaRecorderUptimeOfLastStartResume: number = 0;
139let mediaRecorderDurationAlreadyRecorded: number = 0;
140let mediaRecorderIsRecording: boolean = false;
141
142function getAudioRecorderDurationMillis() {
143  let duration = mediaRecorderDurationAlreadyRecorded;
144  if (mediaRecorderIsRecording && mediaRecorderUptimeOfLastStartResume > 0) {
145    duration += Date.now() - mediaRecorderUptimeOfLastStartResume;
146  }
147  return duration;
148}
149
150export default {
151  get name(): string {
152    return 'ExponentAV';
153  },
154  async getStatusForVideo(element: HTMLMediaElement): Promise<AVPlaybackStatus> {
155    return getStatusFromMedia(element);
156  },
157  async loadForVideo(
158    element: HTMLMediaElement,
159    nativeSource: AVPlaybackNativeSource,
160    fullInitialStatus: AVPlaybackStatusToSet
161  ): Promise<AVPlaybackStatus> {
162    return getStatusFromMedia(element);
163  },
164  async unloadForVideo(element: HTMLMediaElement): Promise<AVPlaybackStatus> {
165    return getStatusFromMedia(element);
166  },
167  async setStatusForVideo(
168    element: HTMLMediaElement,
169    status: AVPlaybackStatusToSet
170  ): Promise<AVPlaybackStatus> {
171    return setStatusForMedia(element, status);
172  },
173  async replayVideo(
174    element: HTMLMediaElement,
175    status: AVPlaybackStatusToSet
176  ): Promise<AVPlaybackStatus> {
177    return setStatusForMedia(element, status);
178  },
179  /* Audio */
180  async setAudioMode() {},
181  async setAudioIsEnabled() {},
182  async getStatusForSound(element: HTMLMediaElement) {
183    return getStatusFromMedia(element);
184  },
185  async loadForSound(
186    nativeSource: string | { uri: string; [key: string]: any },
187    fullInitialStatus: AVPlaybackStatusToSet
188  ): Promise<[HTMLMediaElement, AVPlaybackStatus]> {
189    const source = typeof nativeSource === 'string' ? nativeSource : nativeSource.uri;
190    const media = new Audio(source);
191
192    media.ontimeupdate = () => {
193      SyntheticPlatformEmitter.emit('didUpdatePlaybackStatus', {
194        key: media,
195        status: getStatusFromMedia(media),
196      });
197    };
198
199    media.onerror = () => {
200      SyntheticPlatformEmitter.emit('ExponentAV.onError', {
201        key: media,
202        error: media.error!.message,
203      });
204    };
205
206    const status = await setStatusForMedia(media, fullInitialStatus);
207
208    return [media, status];
209  },
210  async unloadForSound(element: HTMLMediaElement) {
211    element.pause();
212    element.removeAttribute('src');
213    element.load();
214    return getStatusFromMedia(element);
215  },
216  async setStatusForSound(
217    element: HTMLMediaElement,
218    status: AVPlaybackStatusToSet
219  ): Promise<AVPlaybackStatus> {
220    return setStatusForMedia(element, status);
221  },
222  async replaySound(
223    element: HTMLMediaElement,
224    status: AVPlaybackStatusToSet
225  ): Promise<AVPlaybackStatus> {
226    return setStatusForMedia(element, status);
227  },
228
229  /* Recording */
230  //   async setUnloadedCallbackForAndroidRecording() {},
231  async getAudioRecordingStatus(): Promise<RecordingStatus> {
232    return {
233      canRecord: mediaRecorder?.state === 'recording' || mediaRecorder?.state === 'inactive',
234      isRecording: mediaRecorder?.state === 'recording',
235      isDoneRecording: false,
236      durationMillis: getAudioRecorderDurationMillis(),
237      uri: null,
238    };
239  },
240  async prepareAudioRecorder(options): Promise<{
241    uri: string | null;
242    // status is of type RecordingStatus, but without the canRecord field populated
243    status: Pick<RecordingStatus, Exclude<keyof RecordingStatus, 'canRecord'>>;
244  }> {
245    if (typeof navigator !== 'undefined' && !navigator.mediaDevices) {
246      throw new Error('No media devices available');
247    }
248
249    mediaRecorderUptimeOfLastStartResume = 0;
250    mediaRecorderDurationAlreadyRecorded = 0;
251
252    const stream = await getUserMedia({ audio: true });
253
254    mediaRecorder = new (window as any).MediaRecorder(
255      stream,
256      options?.web || RecordingOptionsPresets.HIGH_QUALITY.web
257    );
258
259    mediaRecorder.addEventListener('pause', () => {
260      mediaRecorderDurationAlreadyRecorded = getAudioRecorderDurationMillis();
261      mediaRecorderIsRecording = false;
262    });
263
264    mediaRecorder.addEventListener('resume', () => {
265      mediaRecorderUptimeOfLastStartResume = Date.now();
266      mediaRecorderIsRecording = true;
267    });
268
269    mediaRecorder.addEventListener('start', () => {
270      mediaRecorderUptimeOfLastStartResume = Date.now();
271      mediaRecorderDurationAlreadyRecorded = 0;
272      mediaRecorderIsRecording = true;
273    });
274
275    mediaRecorder.addEventListener('stop', () => {
276      mediaRecorderDurationAlreadyRecorded = getAudioRecorderDurationMillis();
277      mediaRecorderIsRecording = false;
278
279      // Clears recording icon in Chrome tab
280      stream.getTracks().forEach((track) => track.stop());
281    });
282
283    const { uri, ...status } = await this.getAudioRecordingStatus();
284
285    return { uri: null, status };
286  },
287  async startAudioRecording(): Promise<RecordingStatus> {
288    if (mediaRecorder === null) {
289      throw new Error(
290        'Cannot start an audio recording without initializing a MediaRecorder. Run prepareToRecordAsync() before attempting to start an audio recording.'
291      );
292    }
293
294    if (mediaRecorder.state === 'paused') {
295      mediaRecorder.resume();
296    } else {
297      mediaRecorder.start();
298    }
299
300    return this.getAudioRecordingStatus();
301  },
302  async pauseAudioRecording(): Promise<RecordingStatus> {
303    if (mediaRecorder === null) {
304      throw new Error(
305        'Cannot start an audio recording without initializing a MediaRecorder. Run prepareToRecordAsync() before attempting to start an audio recording.'
306      );
307    }
308
309    // Set status to paused
310    mediaRecorder.pause();
311
312    return this.getAudioRecordingStatus();
313  },
314  async stopAudioRecording(): Promise<RecordingStatus> {
315    if (mediaRecorder === null) {
316      throw new Error(
317        'Cannot start an audio recording without initializing a MediaRecorder. Run prepareToRecordAsync() before attempting to start an audio recording.'
318      );
319    }
320
321    if (mediaRecorder.state === 'inactive') {
322      return this.getAudioRecordingStatus();
323    }
324
325    const dataPromise = new Promise<Blob>((resolve) =>
326      mediaRecorder.addEventListener('dataavailable', (e) => resolve(e.data))
327    );
328
329    mediaRecorder.stop();
330
331    const data = await dataPromise;
332    const url = URL.createObjectURL(data);
333
334    return {
335      ...(await this.getAudioRecordingStatus()),
336      uri: url,
337    };
338  },
339  async unloadAudioRecorder(): Promise<void> {
340    mediaRecorder = null;
341  },
342
343  async getPermissionsAsync(): Promise<PermissionResponse> {
344    const maybeStatus = await getPermissionWithQueryAsync('microphone');
345    switch (maybeStatus) {
346      case PermissionStatus.GRANTED:
347        return {
348          status: PermissionStatus.GRANTED,
349          expires: 'never',
350          canAskAgain: true,
351          granted: true,
352        };
353      case PermissionStatus.DENIED:
354        return {
355          status: PermissionStatus.DENIED,
356          expires: 'never',
357          canAskAgain: true,
358          granted: false,
359        };
360      default:
361        return await this.requestPermissionsAsync();
362    }
363  },
364  async requestPermissionsAsync(): Promise<PermissionResponse> {
365    try {
366      const stream = await getUserMedia({ audio: true });
367      stream.getTracks().forEach((track) => track.stop());
368      return {
369        status: PermissionStatus.GRANTED,
370        expires: 'never',
371        canAskAgain: true,
372        granted: true,
373      };
374    } catch {
375      return {
376        status: PermissionStatus.DENIED,
377        expires: 'never',
378        canAskAgain: true,
379        granted: false,
380      };
381    }
382  },
383};
384