UNPKG

one

Version:

One is a React framework for web, iOS, and Android apps.

442 lines (439 loc) • 15.8 kB
import { Audio as unavailable } from './unavailable' import { validateCallback } from '../validateCallback' import type { AudioPlaybackStatus, AudioRecordingPermission, AudioRecordingResult, AudioRecordingStatus, AudioNowPlayingInfo, AudioRemoteCommandEvent, } from '../specs/OneAudio.nitro' import { AudioPlayer, createPlayer } from './player' export type { PitchCorrectionQuality } from '../specs/OneAudio.nitro' export type * from './types' export type * from './unavailable' let player: HTMLAudioElement | undefined let playbackError: string | undefined let permission: AudioRecordingPermission = 'undetermined' let openingRecording = false let recording: | { recorder: MediaRecorder stream: MediaStream chunks: Blob[] duration: number since: number stopping: boolean error?: Error } | undefined const remoteListeners = new Set<(event: AudioRemoteCommandEvent) => void>() let remoteInstalled = false async function getRecordingPermissionStatus(): Promise<AudioRecordingPermission> { if (typeof window === 'undefined' || !navigator.mediaDevices?.getUserMedia) return 'denied' try { const status = await navigator.permissions.query({ name: 'microphone' as PermissionName, }) permission = status.state === 'prompt' ? 'undetermined' : status.state } catch { /* safari may not expose microphone permission queries. */ } return permission } async function microphone(): Promise<MediaStream> { if (!navigator.mediaDevices?.getUserMedia) throw new Error('Audio: microphone recording is unavailable') try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) permission = 'granted' return stream } catch (error) { if ((error as DOMException).name === 'NotAllowedError') permission = 'denied' throw error } } async function requestRecordingPermission(): Promise<AudioRecordingPermission> { if (typeof window === 'undefined' || !navigator.mediaDevices?.getUserMedia) return 'denied' try { const stream = await microphone() stream.getTracks().forEach((track) => track.stop()) } catch (error) { if ((error as DOMException).name !== 'NotAllowedError') throw error } return permission } function playbackStatus(): AudioPlaybackStatus { if (!player) return { state: 'idle', positionMs: 0 } return { state: player.error || playbackError ? 'failed' : player.ended ? 'ended' : player.readyState < 2 ? 'loading' : player.paused ? 'paused' : 'playing', uri: player.src, positionMs: player.currentTime * 1000, ...(Number.isFinite(player.duration) && { durationMs: player.duration * 1000 }), ...(playbackError && { error: playbackError }), } } function requirePlayer(): HTMLAudioElement { if (!player) throw new Error('Audio: the playback operation is not ready') return player } function clearPlayer(): void { clearNowPlaying() if (player) { player.pause() player.removeAttribute('src') player.load() player = undefined } playbackError = undefined } function recordingDuration(): number { if (!recording) return 0 return ( recording.duration + (recording.recorder.state === 'recording' ? performance.now() - recording.since : 0) ) } function recordingStatus(): AudioRecordingStatus { if (!recording) return { state: 'idle', durationMs: 0 } if (recording.error) throw recording.error return { state: recording.recorder.state === 'paused' ? 'paused' : recording.recorder.state === 'recording' ? 'recording' : 'idle', durationMs: recordingDuration(), } } function requireRecorder() { if (!recording || recording.stopping || recording.recorder.state === 'inactive') throw recording?.error ?? new Error('Audio: the recording operation is not ready') return recording } function recorderChange(event: 'pause' | 'resume'): Promise<AudioRecordingStatus> { const session = requireRecorder() const target = event === 'pause' ? 'paused' : 'recording' if (session.recorder.state === target) return Promise.resolve(recordingStatus()) return new Promise((resolve, reject) => { const cleanup = () => { session.recorder.removeEventListener(event, done) session.recorder.removeEventListener('error', failed) } const done = () => { cleanup() resolve(recordingStatus()) } const failed = () => { cleanup() reject(session.error ?? new Error('Audio: recording failed')) } session.recorder.addEventListener(event, done, { once: true }) session.recorder.addEventListener('error', failed, { once: true }) if (event === 'pause') { session.duration += performance.now() - session.since session.recorder.pause() } else { session.since = performance.now() session.recorder.resume() } }) } function mediaSession(): MediaSession { if (!navigator.mediaSession) throw new Error('Audio: Media Session is unavailable') return navigator.mediaSession } function clearNowPlaying(): void { if (typeof window === 'undefined' || !navigator.mediaSession) return const session = navigator.mediaSession if (remoteInstalled) for (const action of ['play', 'pause', 'seekto'] as const) session.setActionHandler(action, null) remoteInstalled = false session.metadata = null session.playbackState = 'none' } function syncNowPlaying(): void { if (!navigator.mediaSession?.metadata || !player) return navigator.mediaSession.playbackState = player.paused || player.ended ? 'paused' : 'playing' } async function remote(event: AudioRemoteCommandEvent): Promise<void> { if (!player || !navigator.mediaSession?.metadata) return const audio = player try { if (event.type === 'play') await Audio.resume() else if (event.type === 'pause') await Audio.pause() else await Audio.seek(event.positionMs!) } catch { // the browser callback has no error channel; failed work emits no success. return } if (player !== audio) return syncNowPlaying() for (const listener of [...remoteListeners]) listener(event) } function installRemote(): void { if (remoteInstalled) return const session = mediaSession() session.setActionHandler('play', () => remote({ type: 'play' })) session.setActionHandler('pause', () => remote({ type: 'pause' })) session.setActionHandler('seekto', (event) => remote({ type: 'seek', positionMs: (event.seekTime ?? 0) * 1000 }) ) remoteInstalled = true } export const Audio = Object.freeze({ getRecordingPermissionStatus, requestRecordingPermission, play: async (uri: string): Promise<AudioPlaybackStatus> => { if (typeof window === 'undefined') return unavailable.play(uri) if (openingRecording || recording) throw new Error('Audio.play: stop the current recording first') const url = new URL(uri, document.baseURI) if (!['https:', 'http:', 'blob:', 'data:'].includes(url.protocol)) throw new Error('Audio.play: invalid browser audio URI') clearPlayer() const audio = new window.Audio(url.href) player = audio for (const event of ['play', 'pause', 'ended']) audio.addEventListener(event, syncNowPlaying) audio.addEventListener('error', () => { if (player === audio) playbackError = audio.error?.message ?? 'Audio.play: decoding failed' }) try { await audio.play() } catch (error) { if (player === audio) playbackError = String(error) throw error } if (player !== audio) throw new Error('Audio.play: playback was replaced') return playbackStatus() }, getPlaybackStatus: async (): Promise<AudioPlaybackStatus> => playbackStatus(), pause: async (): Promise<AudioPlaybackStatus> => { if (typeof window === 'undefined') return unavailable.pause() const audio = requirePlayer() if (!audio.paused) await new Promise<void>((resolve) => { audio.addEventListener('pause', () => resolve(), { once: true }) audio.pause() }) return playbackStatus() }, resume: async (): Promise<AudioPlaybackStatus> => { if (typeof window === 'undefined') return unavailable.resume() const audio = requirePlayer() if (audio.ended) audio.currentTime = 0 await audio.play() if (player !== audio) throw new Error('Audio.resume: playback was replaced') return playbackStatus() }, seek: async (positionMs: number): Promise<AudioPlaybackStatus> => { if (typeof window === 'undefined') return unavailable.seek(positionMs) if (!Number.isFinite(positionMs) || positionMs < 0) throw new Error('Audio.seek: position must be finite and nonnegative') const audio = requirePlayer() if (audio.readyState < 1) throw new Error('Audio.seek: playback is not ready') const seconds = Math.min( positionMs / 1000, Number.isFinite(audio.duration) ? audio.duration : Infinity ) if (audio.currentTime === seconds) return playbackStatus() await new Promise<void>((resolve, reject) => { const cleanup = () => { audio.removeEventListener('seeked', done) audio.removeEventListener('error', failed) audio.removeEventListener('emptied', failed) } const done = () => { cleanup() player === audio ? resolve() : reject(new Error('Audio.seek: playback was replaced')) } const failed = () => { cleanup() reject(new Error('Audio.seek: playback failed or stopped')) } audio.addEventListener('seeked', done, { once: true }) audio.addEventListener('error', failed, { once: true }) audio.addEventListener('emptied', failed, { once: true }) audio.currentTime = seconds }) return playbackStatus() }, stop: async (): Promise<void> => { clearPlayer() }, startRecording: async (): Promise<AudioRecordingStatus> => { if (typeof window === 'undefined') return unavailable.startRecording() if (player?.ended || player?.error || playbackError) clearPlayer() if (player || recording || openingRecording) throw new Error('Audio.startRecording: stop the current audio operation first') if (!window.MediaRecorder) throw new Error('Audio.startRecording: MediaRecorder is unavailable') openingRecording = true let stream: MediaStream | undefined try { stream = await microphone() const recorder = new MediaRecorder(stream) const session = { recorder, stream, chunks: [] as Blob[], duration: 0, since: performance.now(), stopping: false, error: undefined as Error | undefined, } recording = session recorder.addEventListener('dataavailable', (event) => { if (event.data.size) session.chunks.push(event.data) }) recorder.addEventListener('error', (event) => { session.error = (event as Event & { error?: Error }).error ?? new Error('Audio: recording failed') stream!.getTracks().forEach((track) => track.stop()) }) recorder.start(250) return recordingStatus() } catch (error) { stream?.getTracks().forEach((track) => track.stop()) recording = undefined throw error } finally { openingRecording = false } }, getRecordingStatus: async (): Promise<AudioRecordingStatus> => recordingStatus(), pauseRecording: async (): Promise<AudioRecordingStatus> => typeof window === 'undefined' ? unavailable.pauseRecording() : recorderChange('pause'), resumeRecording: async (): Promise<AudioRecordingStatus> => typeof window === 'undefined' ? unavailable.resumeRecording() : recorderChange('resume'), stopRecording: async (): Promise<AudioRecordingResult> => { if (typeof window === 'undefined') return unavailable.stopRecording() if (!recording) throw new Error('Audio.stopRecording: the recording operation is not ready') const session = recording if (session.stopping) throw new Error('Audio.stopRecording: recording is already stopping') const durationMs = recordingDuration() session.stopping = true try { if (session.recorder.state !== 'inactive') await new Promise<void>((resolve, reject) => { const cleanup = () => { session.recorder.removeEventListener('stop', done) session.recorder.removeEventListener('error', failed) } const done = () => { cleanup() resolve() } const failed = () => { cleanup() reject(session.error ?? new Error('Audio: recording failed')) } session.recorder.addEventListener('stop', done, { once: true }) session.recorder.addEventListener('error', failed, { once: true }) session.recorder.stop() }) if (session.error) throw session.error const blob = new Blob(session.chunks, { type: session.recorder.mimeType }) if (!blob.size) throw new Error('Audio.stopRecording: recording produced an empty file') const uri = URL.createObjectURL(blob) const audio = new window.Audio() try { await new Promise<void>((resolve, reject) => { const cleanup = () => { clearTimeout(deadline) audio.onloadedmetadata = null audio.onerror = null } const deadline = setTimeout(() => { cleanup() reject(new Error('Audio.stopRecording: recording did not decode')) }, 10_000) audio.onloadedmetadata = () => { cleanup() resolve() } audio.onerror = () => { cleanup() reject(new Error('Audio.stopRecording: recording produced no playable audio')) } audio.src = uri audio.load() }) return { uri, durationMs, size: blob.size } } catch (error) { URL.revokeObjectURL(uri) throw error } finally { audio.removeAttribute('src') audio.load() } } finally { session.stream.getTracks().forEach((track) => track.stop()) recording = undefined } }, watchInterruptions: unavailable.watchInterruptions, setNowPlayingInfo: async (info: AudioNowPlayingInfo): Promise<void> => { if (typeof window === 'undefined') return unavailable.setNowPlayingInfo(info) if (!info.title.trim()) throw new Error('Audio.setNowPlayingInfo: title must not be empty') const audio = requirePlayer() if (info.artworkUri) { const artwork = new window.Image() artwork.src = info.artworkUri await artwork.decode() if (player !== audio) throw new Error('Audio.setNowPlayingInfo: playback was replaced') } mediaSession().metadata = new MediaMetadata({ title: info.title, artist: info.artist, album: info.albumTitle, artwork: info.artworkUri ? [{ src: info.artworkUri }] : [], }) installRemote() syncNowPlaying() }, clearNowPlayingInfo: async (): Promise<void> => { if (typeof window === 'undefined') return unavailable.clearNowPlayingInfo() clearNowPlaying() }, watchRemoteCommands: ( onEvent: (event: AudioRemoteCommandEvent) => void ): (() => void) => { validateCallback(onEvent, 'Audio.watchRemoteCommands: onEvent must be a function') if (typeof window === 'undefined' || !navigator.mediaSession) return unavailable.watchRemoteCommands(onEvent) remoteListeners.add(onEvent) return () => { remoteListeners.delete(onEvent) } }, AudioPlayer, createPlayer, })