recording-state.tsx

record input from mic to zustand

'use client';
import { create } from 'zustand';

type RecordingStore = {
	recorder?: MediaRecorder;
	isRecording: boolean;
	isDiscarding: boolean;
	recordingTimeMs: number;
	recordingProgressToMax: number;
	recordingTimer?: NodeJS.Timeout;
	recordingChunks: Blob[];
	blobUrl?: string;
};

export const MAX_RECORDING_TIME_MS = 1000 * 60 * 30;

export const useRecordingStore = create<RecordingStore>(() => ({
	recorder: undefined,
	isRecording: false,
	isDiscarding: false,
	recordingTimeMs: 0,
	recordingProgressToMax: 0,
	recordingChunks: [],
	blobUrl: undefined,
}));

export function useIsRecording() {
	return useRecordingStore((state) => state.isRecording);
}

export function useCanSaveOrDiscardRecording() {
	const isRecording = useIsRecording();
	const chunks = useRecordingChunks();
	const canSaveOrDiscard = Boolean(chunks.length && !isRecording);
	return canSaveOrDiscard;
}

export function useRecordingTimeMs() {
	return useRecordingStore((state) => state.recordingTimeMs);
}

export function useRecordingChunks() {
	return useRecordingStore((state) => state.recordingChunks);
}

export function useRecordingProgressToMax() {
	return useRecordingStore((state) => state.recordingProgressToMax);
}

export function setRecorder(recorder: MediaRecorder) {
	useRecordingStore.setState({ recorder });
}

export function setIsRecording(isRecording: boolean) {
	useRecordingStore.setState({ isRecording });
}

export function setRecordingChunk(chunk: Blob) {
	useRecordingStore.setState((state) => {
		state.recordingChunks.push(chunk);
		return state;
	});
}

export function setRecordingTimeMs(timeMs: number) {
	useRecordingStore.setState({
		recordingTimeMs: timeMs,
		recordingProgressToMax: (timeMs / MAX_RECORDING_TIME_MS) * 100,
	});
}

export function setBlobUrl(blobUrl: string) {
	useRecordingStore.setState({ blobUrl });
}

export async function startRecording() {
	let recorder = useRecordingStore.getState().recorder;
	if (!recorder) {
		recorder = await createRecorder();
	}

	if (recorder.state === 'paused') {
		recorder.resume();
	} else {
		recorder.start(1000);
	}

	setIsRecording(true);
	const recordingTimer = setInterval(() => {
		const { recordingTimeMs } = useRecordingStore.getState();
		setRecordingTimeMs(recordingTimeMs + 1000);
	}, 1000);
	useRecordingStore.setState({ recordingTimer });
}

export function pauseRecording() {
	const { recorder } = useRecordingStore.getState();
	if (recorder) {
		recorder.pause();
	}
}

export function endRecording() {
	const { recorder } = useRecordingStore.getState();
	if (recorder) {
		recorder.stop();
	}
}

export function discardRecording() {
	const { recorder, recordingTimer } = useRecordingStore.getState();
	if (recorder) {
		clearInterval(recordingTimer);
		useRecordingStore.setState({
			isRecording: false,
			isDiscarding: true,
			recordingTimer: undefined,
			recordingTimeMs: 0,
			recordingProgressToMax: 0,
			recordingChunks: [],
			blobUrl: undefined,
		});
	}
}

export async function createRecorder() {
	const stream = await navigator.mediaDevices.getUserMedia({
		audio: true,
		video: false,
	});

	const recorder = new MediaRecorder(stream, {
		mimeType: 'audio/webm;codecs=opus',
	});

	recorder.ondataavailable = function (e) {
		// could also stream to server from here
		if (e.data.size > 0) {
			setRecordingChunk(e.data);
		}
	};

	recorder.onpause = function () {
		const { recordingTimer } = useRecordingStore.getState();
		setIsRecording(false);
		clearInterval(recordingTimer);
	};

	recorder.onstop = async function () {
		const { recordingChunks, isDiscarding } = useRecordingStore.getState();

		stream.getAudioTracks().forEach((track) => track.stop());

		if (isDiscarding) {
			useRecordingStore.setState({ isDiscarding: false });
			return;
		}

		const blobType = 'audio/webm;codecs=opus';
		const blob = new Blob(recordingChunks, { type: blobType });
		setBlobUrl(URL.createObjectURL(blob));

		try {
			const formData = new FormData();
			const fileName = `${Date.now()}-recording.webm`;
			formData.append('files', blob, fileName);

			// @todo
			// at this point:
			// 1. send to server
			// 2. save for playback maybe?
			await fetch('/api/upload', {
				method: 'POST',
				body: formData,
			});
		} catch (error) {
			window.alert('Something went wrong uploading the recording');
			console.error(error);
		}
	};

	useRecordingStore.setState({ recorder });
	return recorder;
}