Skip to main content

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

title
string
required
The podcast episode title
url
string
required
The URL to the podcast audio file (MP3)
imageUrl
string
required
The URL to the podcast cover image
date
string
required
The publication date of the episode
desc
string
required
The episode description
isFavorite
boolean
required
Whether the episode is marked as favorite
isListenLater
boolean
required
Whether the episode is saved for later
toggleFavorite
function
required
Callback to toggle favorite status
() => void
toggleListenLater
function
required
Callback to toggle listen later status
() => void
onPlay
function
required
Callback triggered when play button is clicked
() => void
isPlaying
boolean
required
Whether this episode is currently playing
onClick
function
required
Callback triggered when the card is clicked
() => void

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>
)}

Control Buttons

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 }
  );
};

Time Formatting

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

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

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>
  );
}

Build docs developers (and LLMs) love