Skip to content

Instantly share code, notes, and snippets.

@efstathiosntonas
Created May 17, 2024 07:40
Show Gist options
  • Save efstathiosntonas/c8ed187dfb19e5474e063c11cfef8adf to your computer and use it in GitHub Desktop.
Save efstathiosntonas/c8ed187dfb19e5474e063c11cfef8adf to your computer and use it in GitHub Desktop.
Audio Player Chat Message with react-native-awesome-slider & react-native-audio-player-recorder
import AudioRecorderPlayer, { PlayBackType } from "react-native-audio-recorder-player";
import { isIOS } from "utils/utils";
type Callback = (args: { data?: PlayBackType; status: AudioStatus }) => void;
type Path = string | undefined;
export enum AudioStatus {
PAUSED = "PAUSED",
PLAYING = "PLAYING",
RESUMED = "RESUMED",
STARTED = "STARTED",
STOPPED = "STOPPED"
}
let audioRecorderPlayer: AudioRecorderPlayer | undefined;
let currentPath: Path;
let currentCallback: Callback = () => {};
let currentPosition = 0;
export const startPlayer = async (path: string, callback: Callback) => {
if (currentPath === undefined) {
currentPath = path;
currentCallback = callback;
} else if (currentPath !== path) {
if (audioRecorderPlayer !== undefined) {
await stopPlayer();
}
currentPath = path;
currentCallback = callback;
}
if (audioRecorderPlayer === undefined) {
audioRecorderPlayer = new AudioRecorderPlayer();
await audioRecorderPlayer.setSubscriptionDuration(0.01);
}
const shouldBeResumed = currentPath === path && currentPosition > 0;
if (shouldBeResumed) {
await audioRecorderPlayer.resumePlayer();
currentCallback({
status: AudioStatus.RESUMED
});
return;
}
await audioRecorderPlayer.startPlayer(currentPath);
currentCallback({
status: AudioStatus.STARTED
});
const tolerance = 100;
audioRecorderPlayer.addPlayBackListener(async (e) => {
if (
isIOS
? e.currentPosition === e.duration || e.currentPosition >= e.duration
: e.currentPosition >= e.duration || e.duration - e.currentPosition <= tolerance
) {
await stopPlayer();
} else {
currentPosition = e.currentPosition;
currentCallback({
status: AudioStatus.PLAYING,
data: e
});
}
return;
});
};
export const seekPlayer = async (ms: number) => {
try {
if (audioRecorderPlayer) {
await audioRecorderPlayer.seekToPlayer(ms);
await audioRecorderPlayer.resumePlayer();
currentCallback({
status: AudioStatus.RESUMED,
// @ts-ignore
data: { currentPosition: ms }
});
}
} catch (e) {
console.log(`error while pausing player during seek, error: ${e}`);
}
};
export const pausePlayer = async () => {
await audioRecorderPlayer?.pausePlayer();
currentCallback({ status: AudioStatus.PAUSED });
};
export const stopPlayer = async () => {
await audioRecorderPlayer?.stopPlayer();
audioRecorderPlayer?.removePlayBackListener();
currentPosition = 0;
currentPath = undefined;
currentCallback({ status: AudioStatus.STOPPED });
audioRecorderPlayer = undefined;
};
export const msToHMS = (duration: number) => {
let seconds: string | number = Math.floor((duration / 1000) % 60),
minutes: string | number = Math.floor((duration / (1000 * 60)) % 60);
// hours = hours < 10 ? "0" + hours : hours;
minutes = minutes < 10 ? "0" + minutes : minutes;
seconds = seconds < 10 ? "0" + seconds : seconds;
return `${minutes}:${seconds}`;
};
import React, { memo, useCallback, useMemo, useState } from "react";
import { Pressable, View } from "react-native";
import dayjs from "dayjs";
import { TFunction } from "i18next";
import isEqual from "react-fast-compare";
import { Text } from "react-native-fast-text";
import { useSharedValue } from "react-native-reanimated";
import { createStyleSheet, useStyles } from "react-native-unistyles";
import FontAwesome6Pro from "react-native-vector-icons/FontAwesome6Pro";
import LoadingIndicator from "@components/LoadingIndicator";
import { textMedium } from "@utils/fonts";
import fontSize from "@utils/fontSize";
import { hitSlop } from "@utils/hitSlop";
import { breadcrumb, isIOS } from "@utils/utils";
import useStore from "@store/useStore";
import {
BorderRadiusType,
getBorderRadius,
getTopBorderRightRadius
} from "../../utils/utils";
import {
AudioStatus,
msToHMS,
pausePlayer,
startPlayer,
stopPlayer
} from "./AudioManager";
import TrackPlayerProgress from "./components/TrackPlayerProgress";
import { Chat_Message } from "@generated/graphql";
const buttonHitSlop = hitSlop();
const MessageAudio = ({
isLeft,
isMessageRepliedTo,
isTargetedMessage,
message,
nextAuthorId,
prevAuthorId,
t,
target
}: {
isLeft: boolean;
isMessageRepliedTo: boolean;
isTargetedMessage: boolean;
message: Chat_Message;
nextAuthorId?: string;
prevAuthorId?: string;
t: TFunction;
target?: string;
}) => {
const { styles, theme } = useStyles(stylesheet);
const [playing, setPlaying] = useState<boolean>(false);
const [paused, setPaused] = useState<boolean>(false);
const [playTime, setPlayTime] = useState<string>("00:00");
const progress = useSharedValue(0);
const {
setAudioPlaying,
constants: { time }
} = useStore((store) => ({
setAudioPlaying: store.setAudioPlaying,
constants: store.constants
}));
const createdAt = useMemo(
() => dayjs(message.created_at).format(time),
[message.created_at, time]
);
const playingTime = useMemo(
() =>
playTime.length === 8
? playTime.slice(0, -3)
: playTime.length === 5
? playTime
: "00:00",
[playTime]
);
const messageDuration = useMemo(
() =>
message?.audio?.time.length === 8
? msToHMS(Math.round(message?.audio?.secs / 1000) * 1000)
: "00:00",
[message?.audio?.secs, message?.audio?.time.length]
);
const playAudio = useCallback(async () => {
await startPlayer(message?.audio?.url, (res: any) => {
const { status } = res;
//console.log({ status });
switch (status) {
case AudioStatus.STARTED: {
// console.log("BEGIN AUDIO");
setPlaying(true);
setAudioPlaying(true);
break;
}
case AudioStatus.PLAYING: {
// console.log("PLAYING AUDIO");
const { currentPosition } = res.data;
progress.value = currentPosition;
setPlayTime(msToHMS(currentPosition));
break;
}
case AudioStatus.STOPPED: {
progress.value = 0;
// console.log("STOP AUDIO");
setPlaying(false);
setAudioPlaying(false);
setPaused(false);
setPlayTime("00:00:00");
break;
}
case AudioStatus.PAUSED: {
// console.log("PAUSE AUDIO");
setPaused(true);
setAudioPlaying(false);
break;
}
case AudioStatus.RESUMED: {
// console.log("RESUME AUDIO");
setPaused(false);
setAudioPlaying(true);
break;
}
default:
// console.log("REACHING F DEFAULT?");
stopPlayer().catch((e) =>
console.log(`error while stopping player on unmount, ${e}`)
);
setPlaying(false);
setAudioPlaying(false);
setPaused(false);
setPlayTime("00:00:00");
}
});
}, [message?.audio?.url, progress, setAudioPlaying]);
const pauseAudio = useCallback(async () => {
breadcrumb("User Paused Audio Message");
await pausePlayer();
}, []);
const onPressPlay = useCallback(() => {
playAudio().catch((e) => `error while play ${e}`);
}, [playAudio]);
const renderPlayButton = useCallback(() => {
return (
<Pressable
accessibilityHint={t("chat_message.accessibility.buttons.audio_player.play.hint")}
accessibilityLabel={t(
"chat_message.accessibility.buttons.audio_player.play.label"
)}
accessibilityRole="button"
hitSlop={buttonHitSlop}
onPress={onPressPlay}
style={({ pressed }) => styles.playPauseButton(pressed)}
>
<FontAwesome6Pro
color={isLeft ? theme.colors.interaction["500"] : theme.colors.neutrals["600"]}
name="circle-play"
size={fontSize(32)}
solid
/>
</Pressable>
);
}, [t, onPressPlay, isLeft, theme.colors.interaction, theme.colors.neutrals, styles]);
const onPressPause = useCallback(
() => pauseAudio().catch((e) => console.log(`error while pause ${e}`)),
[pauseAudio]
);
const renderPauseButton = useCallback(() => {
return (
<Pressable
accessibilityHint={t(
"chat_message.accessibility.buttons.audio_player.pause.hint"
)}
accessibilityLabel={t(
"chat_message.accessibility.buttons.audio_player.pause.label"
)}
accessibilityRole="button"
hitSlop={buttonHitSlop}
onPress={onPressPause}
style={({ pressed }) => styles.playPauseButton(pressed)}
>
<FontAwesome6Pro
color={isLeft ? theme.colors.interaction["500"] : theme.colors.neutrals["600"]}
name="circle-pause"
size={fontSize(32)}
solid
/>
</Pressable>
);
}, [t, onPressPause, isLeft, theme.colors.interaction, theme.colors.neutrals, styles]);
const loadingIndicator = useMemo(() => {
return playing && progress.value === 0 && !paused ? (
<LoadingIndicator
indicatorColor={
isLeft ? theme.colors.secondary["700"] : theme.colors.secondary["500"]
}
size={fontSize(32)}
style={styles.loadingIndicator(isLeft)}
/>
) : null;
}, [isLeft, paused, playing, progress.value, styles, theme.colors.secondary]);
return (
<View
accessibilityHint=""
accessibilityLabel={t(
"chat_message.accessibility.chat_message.your_voice_message {{ duration }} {{ sent_to }} {{ createdAt }}",
{
duration: messageDuration,
sent_to: target,
createdAt
}
)}
style={styles.container(
isLeft,
message.author_id,
prevAuthorId,
nextAuthorId,
isTargetedMessage,
isMessageRepliedTo
)}
>
<View style={styles.controls}>
<View style={styles.playPauseButtonContainer}>
{!playing || paused ? renderPlayButton() : null}
{playing && progress.value === 0 && !paused ? loadingIndicator : null}
{!paused && playing && progress.value > 0 ? renderPauseButton() : null}
</View>
<View style={styles.track}>
<TrackPlayerProgress
disabled={!playing}
duration={message?.audio?.secs}
isLeft={isLeft}
progress={progress}
/>
</View>
{playing ? (
<Text style={styles.messageDuration(isLeft)}>{playingTime}</Text>
) : (
<Text style={styles.messageDuration(isLeft)}>{messageDuration}</Text>
)}
</View>
<Text style={styles.createdAt(isLeft)}>{createdAt}</Text>
</View>
);
};
const stylesheet = createStyleSheet((theme) => ({
container: (
isLeft: boolean,
authorId: string,
prevAuthorId?: string,
nextAuthorId?: string,
isTargetedMessage?: boolean,
isMessageRepliedTo?: boolean
) => ({
borderWidth: isTargetedMessage || isMessageRepliedTo ? 2 : 0,
borderColor: theme.colors.secondary["500"],
backgroundColor: isLeft
? theme.colors.secondary["100"]
: theme.colors.neutrals["250"],
flexDirection: "column",
paddingVertical: theme.spacing.mvs16,
paddingHorizontal: theme.spacing.s12,
width: theme.spacing.s272,
borderTopRightRadius: getTopBorderRightRadius(
prevAuthorId,
authorId,
isLeft,
theme.spacing
),
borderBottomRightRadius: getBorderRadius(
undefined,
nextAuthorId,
authorId,
isLeft,
theme.spacing,
BorderRadiusType.BottomRight
),
borderTopLeftRadius: getBorderRadius(
undefined,
nextAuthorId,
authorId,
isLeft,
theme.spacing,
BorderRadiusType.TopLeft
),
borderBottomLeftRadius: getBorderRadius(
undefined,
nextAuthorId,
authorId,
isLeft,
theme.spacing,
BorderRadiusType.BottomLeft
),
marginTop: theme.spacing.mvs2
}),
controls: {
alignItems: "center",
flexDirection: "row",
justifyContent: "center"
},
createdAt: (isLeft: boolean) => ({
alignSelf: "flex-end",
color: isLeft ? theme.colors.secondary["500"] : theme.colors.neutrals["500"],
zIndex: 9999,
...theme.typography.paragraph.xsmall.medium
}),
loadingIndicator: (isLeft: boolean) => ({
backgroundColor: isLeft
? theme.colors.secondary["500"]
: theme.colors.neutrals["300"],
borderRadius: theme.spacing.mvs50
}),
messageDuration: (isLeft: boolean) => ({
color: isLeft ? theme.colors.secondary["800"] : theme.colors.neutrals["800"],
...theme.typography.paragraph.small.semibold,
fontFamily: isIOS ? textMedium : "monospace",
fontVariant: ["tabular-nums"]
}),
playPauseButton: (pressed: boolean) => ({
opacity: pressed ? 0.7 : 1
}),
playPauseButtonContainer: {
height: fontSize(32),
width: fontSize(32)
},
track: {
flex: 1,
marginLeft: theme.spacing.s8
}
}));
export default memo(MessageAudio, isEqual);
import React, { memo, useCallback, useMemo } from "react";
import { View } from "react-native";
import isEqual from "react-fast-compare";
import { Slider } from "react-native-awesome-slider";
import { SharedValue, useDerivedValue, useSharedValue } from "react-native-reanimated";
import { createStyleSheet, useStyles } from "react-native-unistyles";
import { s } from "@utils/size-matters";
import { breadcrumb } from "@utils/utils";
import { msToHMS, pausePlayer, seekPlayer } from "../AudioManager";
interface IProps {
disabled: boolean;
duration: number | undefined;
isLeft: boolean;
progress: SharedValue<number>;
}
const TrackPlayerProgress = ({ disabled, duration, progress, isLeft }: IProps) => {
const { styles, theme } = useStyles(stylesheet);
const isScrubbing = useSharedValue(false);
const minValue = useSharedValue(0);
const maxValue = useDerivedValue(() => {
return duration;
}, [duration]);
const onSlidingStart = useCallback(async () => {
breadcrumb("User Seeked and Paused Audio Message");
await pausePlayer();
}, []);
const onSlidingComplete = (slideValue: number) => {
seekPlayer(slideValue).then(() => {
isScrubbing.value = false;
});
};
const trackTheme = useMemo(
() => ({
minimumTrackTintColor: isLeft
? theme.colors.interaction["500"]
: theme.colors.neutrals["600"],
maximumTrackTintColor: isLeft
? theme.colors.secondary["300"]
: theme.colors.neutrals["400"],
bubbleBackgroundColor: theme.colors.neutrals["500"],
bubbleTextColor: theme.colors.neutrals["1100"]
}),
[isLeft, theme.colors.interaction, theme.colors.neutrals, theme.colors.secondary]
);
return (
<View style={styles.container}>
<View style={styles.sliderInnerContainer}>
<Slider
bubble={msToHMS}
bubbleTranslateY={-22}
containerStyle={styles.sliderContainer}
disable={disabled}
disableTrackFollow
isScrubbing={isScrubbing}
// @ts-ignore
maximumValue={maxValue}
minimumValue={minValue}
onSlidingComplete={onSlidingComplete}
onSlidingStart={onSlidingStart}
progress={progress}
sliderHeight={theme.spacing.mvs6}
theme={trackTheme}
thumbWidth={20}
/>
</View>
</View>
);
};
const stylesheet = createStyleSheet((theme) => ({
container: {
alignItems: "center",
marginLeft: s(5),
marginRight: s(15)
},
sliderInnerContainer: {
maxWidth: 500,
position: "relative",
width: "100%"
},
sliderContainer: {
borderRadius: theme.spacing.mvs20
}
}));
export default memo(TrackPlayerProgress, isEqual);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment