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