MP3Player Component
The MP3Player component renders an individual podcast episode card with 3D tilt effects, play controls, favorite/listen later buttons, download functionality, and status indicators.
Location
src/components/MP3Player/MP3Player.jsx
Import
import MP3Player from "../MP3Player/MP3Player" ;
Props
The podcast episode title
The URL to the podcast audio file (MP3)
The URL to the podcast cover image
The publication date of the episode
Whether the episode is marked as favorite
Whether the episode is saved for later
Callback to toggle favorite status
Callback to toggle listen later status
Callback triggered when play button is clicked
Whether this episode is currently playing
Callback triggered when the card is clicked
Features
3D Tilt Effect
Uses Atropos library for interactive 3D parallax effect:
import Atropos from "atropos/react" ;
import "atropos/css" ;
< Atropos
activeOffset = { 40 }
shadow = { false }
highlight = { false }
onClick = { onClick }
className = { styles . atropos }
>
< img
src = { imageUrl || placeHolderImage2 }
alt = { title }
className = { styles . image }
onError = { handleImageError }
loading = "lazy"
onClick = { onClick }
/>
</ Atropos >
Status Indicators
Displays different badges based on episode state:
Shows headphones icon with playback time { isStarted && ! isCompleted && (
< BootstrapTooltip
title = {
< Typography >
{ `Empezado - ${ formatTime ( playbackTime ) } ` }
< br />
{ ! isPlaying && "Clic para eliminar el tiempo" }
</ Typography >
}
>
< Headphones
onClick = { ( e ) => handleRemoveStarted ( title , e ) }
className = { styles . headphonesIcon }
/>
</ BootstrapTooltip >
)}
Shows check circle icon { isCompleted && (
< BootstrapTooltip
title = {
< Typography >
Podcast completado
< br />
{ ! isPlaying && "Clic para eliminar de completados" }
</ Typography >
}
>
< CheckCircle
onClick = { ( e ) => {
if ( ! isPlaying ) {
handleRemoveCompleted ( title , e );
}
} }
className = { styles . completedIcon }
/>
</ BootstrapTooltip >
)}
Play/Pause Toggle playback of the episode < button onClick = { handlePlayClick } >
{ isPlaying ? < Pause /> : < PlayArrow /> }
</ button >
Download Download episode with progress indicator < button onClick = { ( e ) => {
e . stopPropagation ();
handleDownload ( url , title );
} } >
{ isLoading ? < span > { progress } % </ span > : < Download /> }
</ button >
Mark Complete Mark episode as completed or remove from completed < button onClick = { handleCompleteClick } disabled = { isPlaying } >
{ isCompleted ? < CheckCircle /> : < CheckCircleOutline /> }
</ button >
Favorite Add or remove from favorites < span onClick = { handleFavoriteClick } >
{ isFavorite ? < Favorite /> : < FavoriteBorder /> }
</ span >
Listen Later Save for later listening < span onClick = { handleListenLaterClick } >
{ isListenLater ? < WatchLater /> : < WatchLaterOutlined /> }
</ span >
Redux Integration
Accesses state for episode status:
const { playbackTimes } = useSelector (( state ) => state . audioTime );
const { completedEpisodes } = useSelector (( state ) => state . podcast );
const isStarted = playbackTimes [ title ] > 0 ;
const isCompleted = completedEpisodes . includes ( title );
const playbackTime = playbackTimes [ title ] || 0 ;
Download Integration
Uses custom download hook with progress:
import useDownload from "../../hooks/useDownload" ;
const { isLoading , handleDownload , progress } = useDownload ();
const downloadButton = (
< BootstrapTooltip title = { isLoading ? "" : "Descargar" } >
< button
onClick = { ( e ) => {
e . stopPropagation ();
handleDownload ( url , title );
} }
style = { {
backgroundColor: isLoading && "#0f3460" ,
border: isLoading && "1px solid #16db93"
} }
disabled = { isLoading }
>
{ isLoading ? (
< span style = { { color: "#16db93" } } > { progress } % </ span >
) : (
< Download />
) }
</ button >
</ BootstrapTooltip >
);
Confirmation Dialogs
Shows custom toast confirmations for destructive actions:
const showConfirmToast = ( message , onConfirm ) => {
toast . custom (
( t ) => (
< div className = { ` ${ styles . confirmToast } ${ t . visible ? "show" : "" } ` } >
< div className = { styles . confirmHeader } >
< Warning className = { styles . warningIcon } />
< h3 className = { styles . confirmTitle } > Confirmar Acción </ h3 >
</ div >
< p className = { styles . confirmMessage } > { message } </ p >
< div className = { styles . confirmButtons } >
< motion.button
className = { styles . confirmButton }
onClick = { () => {
toast . dismiss ( t . id );
onConfirm ();
} }
>
Confirmar
</ motion.button >
< motion.button
className = { styles . cancelButton }
onClick = { () => toast . dismiss ( t . id ) }
>
Cancelar
</ motion.button >
</ div >
</ div >
),
{ position: "top-center" , duration: Infinity }
);
};
Formats playback time for display:
const formatTime = ( seconds ) => {
if ( ! seconds ) return "0:00" ;
const minutes = Math . floor ( seconds / 60 );
const remainingSeconds = Math . floor ( seconds % 60 );
return ` ${ minutes } : ${ remainingSeconds . toString (). padStart ( 2 , "0" ) } ` ;
};
Event Handlers
Play Handler
Favorite Handler
Listen Later Handler
Complete Handler
const handlePlayClick = ( e ) => {
e . preventDefault ();
e . stopPropagation ();
onPlay ();
};
All handlers call e.stopPropagation() to prevent triggering the card’s onClick when clicking buttons.
Remove Handlers
Remove Started
Remove Completed
const handleRemoveStarted = ( title , e ) => {
e . stopPropagation ();
if ( ! isPlaying ) {
showConfirmToast (
"¿Estás seguro de que quieres eliminar el tiempo de reproducción guardado?" ,
() => {
dispatch ( deleteEpisode ( title ));
dispatch ( removePlaybackTime ( title ));
toast . success ( "Tiempo de reproducción eliminado" );
}
);
}
};
Image Fallback
Handles missing images with placeholder:
const placeHolderImage2 =
"https://sdmedia.playser.cadenaser.com/playser/image/20208/27/1593787718595_1598534487_square_img.png" ;
const handleImageError = ( event ) => {
event . target . src = placeHolderImage2 ;
};
< img
src = { imageUrl || placeHolderImage2 }
alt = { title }
onError = { handleImageError }
loading = "lazy"
/>
Mobile Responsiveness
Toast positions adjust for mobile:
const isMobile = useMobileDetect ();
toast . success ( "Tiempo de reproducción eliminado" , {
position: isMobile ? "bottom-center" : "bottom-left"
});
Usage Example
import MP3Player from "./components/MP3Player/MP3Player" ;
import { useDispatch , useSelector } from "react-redux" ;
import { toggleFavorite , toggleListenLater } from "./store/slices/podcastSlice" ;
import { setCurrentPodcast , togglePlay } from "./store/slices/playerSlice" ;
import { slugify } from "./utils/slugify" ;
function PodcastGrid () {
const dispatch = useDispatch ();
const navigate = useNavigate ();
const { songs , favoriteEpisodes , listenLaterEpisodes } = useSelector (
( state ) => state . podcast
);
const { currentPodcast , isPlaying } = useSelector (( state ) => state . player );
const handlePlay = ( song ) => {
dispatch ( setCurrentPodcast ( song ));
dispatch ( togglePlay ( true ));
};
return (
< div className = "grid" >
{ songs . map (( song ) => (
< MP3Player
key = { song . title }
title = { song . title }
url = { song . audio }
imageUrl = { song . image }
date = { song . pubDate }
desc = { song . description }
isFavorite = { favoriteEpisodes . includes ( song . title ) }
isListenLater = { listenLaterEpisodes . includes ( song . title ) }
toggleFavorite = { () => dispatch ( toggleFavorite ( song )) }
toggleListenLater = { () => dispatch ( toggleListenLater ( song )) }
onPlay = { () => handlePlay ( song ) }
isPlaying = { isPlaying && currentPodcast ?. title === song . title }
onClick = { () => navigate ( `/podcast/ ${ slugify ( song . title ) } ` ) }
/>
)) }
</ div >
);
}