media-player-playlist-demo

PreviousNext
Docs
diceuiexample

Preview

Loading preview…
examples/media-player-playlist-demo.tsx
"use client";

import {
  ListMusicIcon,
  Loader2Icon,
  PauseCircleIcon,
  PlayCircleIcon,
  SkipBackIcon,
  SkipForwardIcon,
} from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import {
  MediaPlayer,
  MediaPlayerAudio,
  MediaPlayerControls,
  MediaPlayerPlay,
  MediaPlayerSeek,
  MediaPlayerTime,
  MediaPlayerTooltip,
  MediaPlayerVolume,
} from "@/registry/default/ui/media-player";

interface Track {
  id: string;
  title: string;
  artist: string;
  src: string;
  cover: string;
}

const tracks: Track[] = [
  {
    id: "1",
    title: "Medieval: Battle",
    artist: "RandomMind",
    src: "https://opengameart.org/sites/default/files/battle.mp3",
    cover: "https://picsum.photos/seed/battle/200/200",
  },
  {
    id: "2",
    title: "City Lights",
    artist: "The Lemming Shepherds",
    src: "https://www.dropbox.com/s/mvvwaw1msplnteq/City%20Lights%20-%20The%20Lemming%20Shepherds.mp3?raw=1",
    cover: "https://picsum.photos/seed/citylights/200/200",
  },
];

export default function MediaPlayerPlaylistDemo() {
  const [currentTrackIndex, setCurrentTrackIndex] = React.useState(0);
  const [isPlaying, setIsPlaying] = React.useState(false);
  const [isLoading, setIsLoading] = React.useState(false);
  const audioRef = React.useRef<HTMLAudioElement | null>(null);
  const shouldPlayAfterLoad = React.useRef(false);

  const onPlay = React.useCallback(() => {
    setIsPlaying(true);
  }, []);

  const onPause = React.useCallback(() => {
    setIsPlaying(false);
  }, []);

  const onLoadAndPlayTrack = React.useCallback(
    async (index: number, shouldPlay = true) => {
      const trackToPlay = tracks[index];
      if (!trackToPlay) {
        toast.error("Track not found");
        return;
      }

      if (!audioRef.current) return;

      if (!audioRef.current.paused) {
        audioRef.current.pause();
      }

      setCurrentTrackIndex(index);
      setIsLoading(true);
      shouldPlayAfterLoad.current = shouldPlay;

      audioRef.current.src = trackToPlay.src;
      audioRef.current.load();
    },
    [],
  );

  const onPlayTrack = React.useCallback(
    (index: number) => {
      onLoadAndPlayTrack(index, true);
    },
    [onLoadAndPlayTrack],
  );

  const onPreviousTrack = React.useCallback(() => {
    const prevIndex = (currentTrackIndex - 1 + tracks.length) % tracks.length;
    onPlayTrack(prevIndex);
  }, [currentTrackIndex, onPlayTrack]);

  const onNextTrack = React.useCallback(() => {
    const nextIndex = (currentTrackIndex + 1) % tracks.length;
    onPlayTrack(nextIndex);
  }, [currentTrackIndex, onPlayTrack]);

  const onEnded = React.useCallback(() => {
    onNextTrack();
  }, [onNextTrack]);

  const onAudioPlay = React.useCallback(async () => {
    if (!audioRef.current) return;

    try {
      await audioRef.current.play();
      setIsPlaying(true);
    } catch (error) {
      toast.error(
        error instanceof Error ? error.message : "Failed to play track",
      );
      setIsPlaying(false);
    }
  }, []);

  const onTogglePlayPauseTrack = React.useCallback(
    (index: number) => {
      if (index === currentTrackIndex) {
        if (isPlaying) {
          audioRef.current?.pause();
        } else {
          onAudioPlay();
        }
      } else {
        onPlayTrack(index);
      }
    },
    [currentTrackIndex, isPlaying, onAudioPlay, onPlayTrack],
  );

  const currentTrack = React.useMemo(
    () => tracks[currentTrackIndex],
    [currentTrackIndex],
  );

  React.useEffect(() => {
    const audioElement = audioRef.current;
    if (!audioElement) return;

    const onCanPlay = () => {
      setIsLoading(false);
      if (shouldPlayAfterLoad.current) {
        onAudioPlay();
        shouldPlayAfterLoad.current = false;
      }
    };

    const onLoadStart = () => {
      setIsLoading(true);
    };

    const onError = () => {
      setIsLoading(false);
      setIsPlaying(false);
      toast.error("Failed to load track");
    };

    audioElement.addEventListener("canplay", onCanPlay);
    audioElement.addEventListener("loadstart", onLoadStart);
    audioElement.addEventListener("error", onError);

    return () => {
      audioElement.removeEventListener("canplay", onCanPlay);
      audioElement.removeEventListener("loadstart", onLoadStart);
      audioElement.removeEventListener("error", onError);
    };
  }, [onAudioPlay]);

  React.useEffect(() => {
    if (
      audioRef.current &&
      currentTrack &&
      audioRef.current.src !== currentTrack.src
    ) {
      onLoadAndPlayTrack(currentTrackIndex, false);
    }
  }, [currentTrack, currentTrackIndex, onLoadAndPlayTrack]);

  const onKeyDown = React.useCallback(
    (event: React.KeyboardEvent<HTMLDivElement>) => {
      const isMediaFocused = event.currentTarget.contains(
        document.activeElement,
      );

      if (!isMediaFocused) return;

      switch (event.key.toLowerCase()) {
        case "n":
          event.preventDefault();
          onNextTrack();
          break;

        case "p":
          event.preventDefault();
          onPreviousTrack();
          break;
      }
    },
    [onNextTrack, onPreviousTrack],
  );

  if (!currentTrack) return null;

  return (
    <MediaPlayer
      onPlay={onPlay}
      onPause={onPause}
      onEnded={onEnded}
      onKeyDown={onKeyDown}
      className="w-full max-w-2xl overflow-hidden rounded-lg border bg-background shadow-lg"
    >
      <MediaPlayerAudio
        ref={audioRef}
        src={currentTrack.src}
        className="sr-only"
      />
      <div className="flex w-full flex-col items-center gap-4 md:items-start">
        <div className="relative w-full overflow-hidden rounded-md rounded-b-none border-b">
          {/* biome-ignore lint/performance/noImgElement: dynamic cover URLs from playlist tracks don't work well with Next.js Image optimization */}
          <img
            src={currentTrack.cover}
            alt={currentTrack.title}
            className="h-40 w-full object-cover"
          />
          <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent" />
          <div className="absolute right-0 bottom-0 left-0 p-4">
            <h2 className="font-semibold text-2xl text-white tracking-tight drop-shadow-lg">
              {currentTrack.title}
            </h2>
            <p className="text-sm text-white/90 drop-shadow-md">
              {currentTrack.artist}
            </p>
          </div>
        </div>
        <div className="w-full">
          <div className="flex items-center border-border border-b px-4 pb-4">
            <div className="flex flex-1 items-center gap-2">
              <h3 className="font-medium text-lg tracking-tight">Playlist</h3>
              <ListMusicIcon className="size-4" />
            </div>
            <span className="text-muted-foreground text-sm">{`${currentTrackIndex + 1} / ${tracks.length}`}</span>
          </div>
          <ScrollArea className="max-h-[200px]">
            {tracks.map((track, index) => (
              <Button
                key={track.id}
                variant="ghost"
                className={cn(
                  "h-auto w-full rounded-none px-4 py-3 text-left",
                  index === currentTrackIndex && "bg-accent",
                )}
                onClick={() => onTogglePlayPauseTrack(index)}
                disabled={isLoading}
              >
                {/* biome-ignore lint/performance/noImgElement: dynamic cover URLs from playlist tracks don't work well with Next.js Image optimization */}
                <img
                  src={track.cover}
                  alt={track.title}
                  className="aspect-square size-9 rounded object-cover"
                />
                <div className="flex flex-1 flex-col">
                  <span
                    className={cn(
                      "font-medium leading-tight",
                      index === currentTrackIndex && "text-primary",
                    )}
                  >
                    {track.title}
                  </span>
                  <span className="text-muted-foreground text-sm">
                    {track.artist}
                  </span>
                </div>
                {index === currentTrackIndex && isLoading ? (
                  <Loader2Icon className="size-6 animate-spin text-primary" />
                ) : index === currentTrackIndex && isPlaying ? (
                  <PauseCircleIcon className="size-6 text-primary" />
                ) : index === currentTrackIndex && !isPlaying ? (
                  <PlayCircleIcon className="size-6 text-muted-foreground" />
                ) : (
                  <PlayCircleIcon className="size-6 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
                )}
              </Button>
            ))}
          </ScrollArea>
        </div>
        <MediaPlayerControls className="relative flex w-full flex-col gap-2.5">
          <MediaPlayerSeek />
          <div className="flex w-full items-center justify-center gap-2">
            <MediaPlayerTooltip tooltip="Previous track" shortcut="B">
              <Button
                aria-label="Previous track"
                variant="ghost"
                size="icon"
                className="size-8"
                onClick={onPreviousTrack}
                disabled={isLoading}
              >
                <SkipBackIcon />
              </Button>
            </MediaPlayerTooltip>
            <MediaPlayerPlay />
            <MediaPlayerTooltip tooltip="Next track" shortcut="N">
              <Button
                aria-label="Next track"
                variant="ghost"
                size="icon"
                className="size-8"
                onClick={onNextTrack}
                disabled={isLoading}
              >
                <SkipForwardIcon />
              </Button>
            </MediaPlayerTooltip>
            <MediaPlayerTime variant="progress" />
            <MediaPlayerVolume className="ml-auto" />
          </div>
        </MediaPlayerControls>
      </div>
    </MediaPlayer>
  );
}

Installation

npx shadcn@latest add @diceui/media-player-playlist-demo

Usage

import { MediaPlayerPlaylistDemo } from "@/components/media-player-playlist-demo"
<MediaPlayerPlaylistDemo />