react-native-audio-api
Version:
react-native-audio-api provides system for controlling audio in React Native environment compatible with Web Audio API specification
288 lines (251 loc) • 7.43 kB
text/typescript
import {
Dispatch,
RefObject,
SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { Platform } from 'react-native';
import type BaseAudioContext from '../core/BaseAudioContext';
import { probeDuration } from '../core/AudioFileUtils';
import { NotSupportedError } from '../errors';
import { NativeAudioAPIModule } from '../specs';
import { base64ToArrayBuffer, isFfmpegEnabled } from '../utils';
import {
isRemoteHttpUrl,
loadRemoteHttpSource,
} from '../utils/remoteHttpSource';
import { AudioFileSourceNode } from './AudioFileSourceNode';
import type { AudioSource, PreloadType } from './types';
import { getSourceHeaders } from './utils';
type UseAudioSourceLoaderParams = {
context: BaseAudioContext | undefined;
path: string;
source: AudioSource;
preloadMode: PreloadType;
forceDownload: boolean;
loop: boolean;
playbackRate: number;
preservesPitch: boolean;
autoPlay: boolean;
effectiveVolumeRef: RefObject<number>;
onLoadStart: () => void;
onLoad: () => void;
onError: (error: Error) => void;
onEnded: (duration: number) => void;
onAutoPlay: () => void;
};
type UseAudioSourceLoaderResult = {
fileSourceRef: RefObject<AudioFileSourceNode | null>;
ready: boolean;
duration: number;
setDuration: Dispatch<SetStateAction<number>>;
loadForPlayback: () => Promise<boolean>;
disposeSource: () => void;
};
export function useAudioSourceLoader({
context,
path,
source,
preloadMode,
forceDownload,
loop,
playbackRate,
preservesPitch,
autoPlay,
effectiveVolumeRef,
onLoadStart,
onLoad,
onError,
onEnded,
onAutoPlay,
}: UseAudioSourceLoaderParams): UseAudioSourceLoaderResult {
const [ready, setReady] = useState(false);
const [duration, setDuration] = useState(0);
const fileSourceRef = useRef<AudioFileSourceNode>(null);
const sourceRef = useRef<ArrayBuffer | string | null>(null);
const isFetchingCancelled = useRef(false);
const fullDataFetched = useRef(false);
const onLoadStartRef = useRef(onLoadStart);
onLoadStartRef.current = onLoadStart;
const onLoadRef = useRef(onLoad);
onLoadRef.current = onLoad;
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
const onEndedRef = useRef(onEnded);
onEndedRef.current = onEnded;
const onAutoPlayRef = useRef(onAutoPlay);
onAutoPlayRef.current = onAutoPlay;
const playbackRateRef = useRef(playbackRate);
playbackRateRef.current = playbackRate;
const preservesPitchRef = useRef(preservesPitch);
preservesPitchRef.current = preservesPitch;
const disposeSource = useCallback(() => {
fileSourceRef.current?.stopPositionTracking();
fileSourceRef.current?.dispose();
sourceRef.current = null;
fullDataFetched.current = false;
}, []);
const spawnFileSource = useCallback((): boolean => {
const nextSource = sourceRef.current;
if (!context || !nextSource) {
return false;
}
fileSourceRef.current?.dispose();
const initialVolume = effectiveVolumeRef.current;
const initialPlaybackRate = playbackRateRef.current;
const initialPreservesPitch = preservesPitchRef.current;
const node = context.context.createFileSource({
source: nextSource,
headers: getSourceHeaders(source),
loop,
volume: initialVolume,
playbackRate: initialPlaybackRate,
preservesPitch: initialPreservesPitch,
});
if (!node) {
onErrorRef.current(
new NotSupportedError('This file format requires FFmpeg build')
);
return false;
}
const fileSource = new AudioFileSourceNode(context, node);
const { duration: nextDuration } = fileSource.attach({
loop,
onEnded: () => {
onEndedRef.current(nextDuration);
spawnFileSource();
},
});
fileSource.setVolume(initialVolume);
fileSource.setPlaybackRate(initialPlaybackRate);
fileSource.setPreservesPitch(initialPreservesPitch);
fileSourceRef.current = fileSource;
setDuration(nextDuration);
onLoadRef.current();
if (autoPlay) {
onAutoPlayRef.current();
}
return true;
}, [autoPlay, context, effectiveVolumeRef, loop, source]);
const loadPlaybackSource = useCallback(async (): Promise<boolean> => {
if (!path) {
return false;
}
isFetchingCancelled.current = false;
setReady(false);
onLoadStartRef.current();
const headers = getSourceHeaders(source);
try {
if (isRemoteHttpUrl(path)) {
sourceRef.current = await loadRemoteHttpSource(
path,
headers,
forceDownload
);
} else if (
Platform.OS === 'android' &&
!__DEV__ &&
!path.startsWith('file://')
) {
const base64Payload =
await NativeAudioAPIModule.readAndroidReleaseAssetBytesAsBase64(path);
sourceRef.current = base64ToArrayBuffer(base64Payload);
} else if (path.startsWith('file://')) {
sourceRef.current = path.replace('file://', '');
} else {
sourceRef.current = path;
}
fullDataFetched.current = true;
if (!isFetchingCancelled.current && spawnFileSource()) {
setReady(true);
return true;
}
return false;
} catch (error) {
if (!isFetchingCancelled.current) {
onErrorRef.current(error as Error);
}
setReady(false);
return false;
}
}, [forceDownload, path, source, spawnFileSource]);
const probeMetadataOnly = useCallback(async (): Promise<void> => {
if (!isRemoteHttpUrl(path)) {
setReady(true);
return;
}
isFetchingCancelled.current = false;
setReady(false);
onLoadStartRef.current();
const headers = getSourceHeaders(source);
try {
const probedDuration = await probeDuration(
path,
context?.sampleRate,
headers
);
if (probedDuration && !isFetchingCancelled.current) {
setDuration(probedDuration);
}
setReady(true);
} catch (error) {
if (!isFetchingCancelled.current) {
onErrorRef.current(error as Error);
}
setReady(false);
}
}, [context?.sampleRate, path, source]);
const loadForPlayback = useCallback(async (): Promise<boolean> => {
if (fullDataFetched.current) {
return fileSourceRef.current != null;
}
return loadPlaybackSource();
}, [loadPlaybackSource]);
useEffect(() => {
isFetchingCancelled.current = false;
fullDataFetched.current = false;
if (!path) {
setReady(false);
setDuration(0);
disposeSource();
return () => {
isFetchingCancelled.current = true;
disposeSource();
};
}
if (preloadMode === 'none') {
setReady(true);
return () => {
isFetchingCancelled.current = true;
disposeSource();
};
}
if (preloadMode === 'metadata') {
if (!isFfmpegEnabled()) {
setReady(true);
} else {
probeMetadataOnly();
}
return () => {
isFetchingCancelled.current = true;
disposeSource();
};
}
loadPlaybackSource();
return () => {
isFetchingCancelled.current = true;
disposeSource();
};
}, [disposeSource, loadPlaybackSource, path, preloadMode, probeMetadataOnly]);
return {
fileSourceRef,
ready,
duration,
setDuration,
loadForPlayback,
disposeSource,
};
}