VayuUI

AudioPlayer

A compound audio player with playlist, keyboard shortcuts, Media Session API, and WCAG 2.2 AA compliance.

Usage

Spotify-style Single Track

Demonstrating HLS fallback and seeded fake waveform

0:00/0:00

Full Playlist Flow

Queue management, auto-play next, and separated components

0:00/0:00
Up Next
Digital Watch Alarm
Digital Watch AlarmGoogle Sounds
Cat Purring Close
Cat Purring CloseNature Sounds
Mux Test HLS
Mux Test HLSStreaming Team
Rain on Roof
Rain on RoofNature Sounds
import { AudioPlayer } from "vayu-ui";

export default function AudioPlayerDemo() {
  return (
    <AudioPlayer.Root track={{ id: "1", src: "/song.mp3", title: "My Song", artist: "Artist" }}>
      <AudioPlayer.Audio />
      <AudioPlayer.Player variant="card">
        <AudioPlayer.Artwork size="md" />
        <AudioPlayer.TrackInfo showAlbum />
        <AudioPlayer.ProgressBar />
        <div className="flex items-center gap-2">
          <AudioPlayer.SkipBackwardButton />
          <AudioPlayer.PlayPauseButton />
          <AudioPlayer.SkipForwardButton />
        </div>
        <AudioPlayer.VolumeControl />
      </AudioPlayer.Player>
    </AudioPlayer.Root>
  );
}

Features

  • Compound component pattern - compose sub-components freely
  • Playlist with shuffle, repeat (off / all / one), prev/next
  • Progress bar with buffer indicator, hover tooltip, seek handle
  • Volume control with mute toggle and animated slider
  • Media Session API integration (lockscreen / notification controls)
  • forwardRef on all sub-components
  • useMemo on context value for performance
  • Keyboard shortcuts (container-scoped)
  • WCAG 2.2 AA compliant

Sub-components

ComponentDescription
RootContext provider + keyboard listener
AudioHidden <audio> element
PlayerVisual container (default / minimal / card)
ArtworkAlbum art with spin animation
TrackInfoTitle, artist, album text
ProgressBarSeekable slider with buffer + timestamps
PlayPauseButtonPlay / pause toggle
SkipBackwardButtonPrevious track
SkipForwardButtonNext track
VolumeControlMute + volume slider
RepeatButtonRepeat off - all - one
ShuffleButtonShuffle toggle
FavoriteButtonHeart toggle
DownloadButtonDownload current track
ShareButtonShare action
MoreButtonMore options
PlaylistButtonToggle playlist visibility
PlaylistTrack list with remove
LoadingIndicatorSpinner overlay

Props

AudioPlayer.Root

PropTypeDefaultDescription
childrenReactNode-Child components (required)
trackTrack-Initial track
playlistTrack[][]Additional tracks
autoPlaybooleanfalseAuto-play on mount
defaultVolumenumber1Initial volume (0-1)
defaultPlaybackRatenumber1Initial speed
defaultRepeatModeoff | all | one"off"Initial repeat
enableMediaSessionbooleantrueMedia Session API
onPlay() => void-Play callback
onPause() => void-Pause callback
onEnded() => void-Ended callback
onTimeUpdate(time: number) => void-Time callback
onVolumeChange(vol: number) => void-Volume callback
onTrackChange(track: Track) => void-Track change callback
classNamestring-Additional CSS classes

AudioPlayer.Player

PropTypeDefaultDescription
variantdefault | minimal | card"default"Visual style variant
childrenReactNode-Content to render
classNamestring-Additional CSS classes

AudioPlayer.Artwork

PropTypeDefaultDescription
sizesm | md | lg | xl"lg"Artwork size
animatedbooleantrueEnable spin animation when playing
classNamestring-Additional CSS classes

AudioPlayer.TrackInfo

PropTypeDefaultDescription
showArtistbooleantrueShow artist name
showAlbumbooleanfalseShow album name
classNamestring-Additional CSS classes

AudioPlayer.ProgressBar

PropTypeDefaultDescription
showBufferbooleantrueShow buffer indicator
showTimestampsbooleantrueShow time stamps
classNamestring-Additional CSS classes

AudioPlayer.PlayPauseButton

PropTypeDefaultDescription
sizesm | md | lg"md"Button size
classNamestring-Additional CSS classes

AudioPlayer.Playlist

PropTypeDefaultDescription
maxHeightstring"300px"Maximum height before scroll
classNamestring-Additional CSS classes

Keyboard Shortcuts

KeyAction
Space / KPlay / pause
ArrowLeftSeek backward 5s
ArrowRightSeek forward 5s
ArrowUpVolume +10%
ArrowDownVolume -10%
MMute toggle
NNext track
PPrevious track
RCycle repeat mode
SToggle shuffle
0 / HomeSeek to start
EndSeek to end

Accessibility

  • Full keyboard navigation support within the player region
  • Visible focus indicators on all interactive elements
  • Proper ARIA roles (region, slider, listbox, option, status)
  • Screen reader labels on all buttons
  • Respects prefers-reduced-motion for animations
  • Semantic HTML structure with proper headings

Source Code

packages/ui/src/components/ui/audioplayer.tsx
"use client";

import { clsx } from "clsx";
import {
    Download,
    Heart,
    ListMusic,
    MoreHorizontal,
    Pause,
    Play,
    Repeat,
    Repeat1,
    Share2,
    Shuffle,
    SkipBack,
    SkipForward,
    Volume2,
    VolumeX,
    X,
} from "lucide-react";
import React, {
    AudioHTMLAttributes,
    createContext,
    forwardRef,
    HTMLAttributes,
    ReactNode,
    useCallback,
    useContext,
    useEffect,
    useId,
    useImperativeHandle,
    useMemo,
    useRef,
    useState,
} from "react";

// ============================================================================
// Types
// ============================================================================

type RepeatMode = "off" | "all" | "one";

interface Track {
    id: string;
    src: string;
    title: string;
    artist?: string;
    album?: string;
    artwork?: string;
    duration?: number;
}

interface AudioPlayerContextValue {
    audioRef: React.RefObject<HTMLAudioElement | null>;
    containerRef: React.RefObject<HTMLDivElement | null>;

    isPlaying: boolean;
    currentTime: number;
    duration: number;
    buffered: number;
    isLoading: boolean;
    hasEnded: boolean;

    volume: number;
    isMuted: boolean;
    previousVolume: number;

    playbackRate: number;
    repeatMode: RepeatMode;
    isShuffled: boolean;

    currentTrack: Track | null;
    playlist: Track[];
    currentTrackIndex: number;

    showPlaylist: boolean;
    isFavorite: boolean;

    play: () => void;
    pause: () => void;
    togglePlay: () => void;
    seek: (time: number) => void;
    seekForward: (seconds?: number) => void;
    seekBackward: (seconds?: number) => void;
    setVolume: (vol: number) => void;
    toggleMute: () => void;
    setPlaybackRate: (rate: number) => void;
    toggleRepeat: () => void;
    toggleShuffle: () => void;
    toggleFavorite: () => void;
    download: () => void;

    playTrack: (index: number) => void;
    nextTrack: () => void;
    previousTrack: () => void;
    addToPlaylist: (track: Track) => void;
    removeFromPlaylist: (index: number) => void;
    clearPlaylist: () => void;
    setShowPlaylist: (show: boolean) => void;
}

// ============================================================================
// Context
// ============================================================================

const AudioPlayerContext = createContext<AudioPlayerContextValue | undefined>(
    undefined
);

const useAudioPlayer = (): AudioPlayerContextValue => {
    const ctx = useContext(AudioPlayerContext);
    if (!ctx) {
        throw new Error(
            "AudioPlayer.* components must be rendered inside <AudioPlayer.Root>."
        );
    }
    return ctx;
};

// ============================================================================
// Helpers (hoisted)
// ============================================================================

function formatTime(seconds: number): string {
    if (isNaN(seconds) || !isFinite(seconds)) return "0:00";
    const m = Math.floor(seconds / 60);
    const s = Math.floor(seconds % 60);
    return `${m}:${s.toString().padStart(2, "0")}`;
}

// ============================================================================
// Hoisted configs
// ============================================================================

const PLAY_PAUSE_SIZE = {
    sm: "w-8 h-8",
    md: "w-10 h-10",
    lg: "w-12 h-12",
} as const;

const PLAY_PAUSE_ICON_SIZE = {
    sm: "w-4 h-4",
    md: "w-5 h-5",
    lg: "w-6 h-6",
} as const;

const ARTWORK_SIZE = {
    sm: "w-12 h-12",
    md: "w-20 h-20",
    lg: "w-32 h-32",
    xl: "w-48 h-48",
} as const;

const PLAYER_VARIANT = {
    default: "rounded p-6",
    minimal: "rounded p-4",
    card: "rounded-xl shadow-outer p-6",
} as const;

const ICON_BTN =
    "p-2 rounded-full transition-colors hover:bg-ground-100 dark:hover:bg-ground-800 focus:outline-none focus:ring-2 focus:ring-primary-500";

const ICON_COLOR = "text-ground-700 dark:text-ground-300";

// ============================================================================
// Root
// ============================================================================

interface AudioPlayerRootProps extends Omit<HTMLAttributes<HTMLDivElement>, "children" | "onPlay" | "onPause" | "onEnded" | "onTimeUpdate" | "onVolumeChange"> {
    children: ReactNode;
    track?: Track;
    playlist?: Track[];
    autoPlay?: boolean;
    onPlay?: () => void;
    onPause?: () => void;
    onEnded?: () => void;
    onTimeUpdate?: (time: number) => void;
    onVolumeChange?: (volume: number) => void;
    onTrackChange?: (track: Track) => void;
    defaultVolume?: number;
    defaultPlaybackRate?: number;
    defaultRepeatMode?: RepeatMode;
    enableMediaSession?: boolean;
}

const AudioPlayerRoot = forwardRef<
    { audioElement: HTMLAudioElement | null },
    AudioPlayerRootProps
>(
    (
        {
            children,
            className,
            track,
            playlist = [],
            autoPlay = false,
            onPlay,
            onPause,
            onEnded,
            onTimeUpdate,
            onVolumeChange,
            onTrackChange,
            defaultVolume = 1,
            defaultPlaybackRate = 1,
            defaultRepeatMode = "off",
            enableMediaSession = true,
            ...divProps
        },
        ref
    ) => {
        const audioRef = useRef<HTMLAudioElement>(null);
        const containerRef = useRef<HTMLDivElement>(null);

        // Playback
        const [isPlaying, setIsPlaying] = useState(false);
        const [currentTime, setCurrentTime] = useState(0);
        const [duration, setDuration] = useState(0);
        const [buffered, setBuffered] = useState(0);
        const [isLoading, setIsLoading] = useState(false);
        const [hasEnded, setHasEnded] = useState(false);

        // Volume
        const [volume, setVolumeState] = useState(defaultVolume);
        const [isMuted, setIsMuted] = useState(false);
        const [previousVolume, setPreviousVolume] = useState(defaultVolume);

        // Playback controls
        const [playbackRate, setPlaybackRateState] =
            useState(defaultPlaybackRate);
        const [repeatMode, setRepeatMode] =
            useState<RepeatMode>(defaultRepeatMode);
        const [isShuffled, setIsShuffled] = useState(false);

        // Track / playlist
        const [currentTrack, setCurrentTrack] = useState<Track | null>(
            track ?? null
        );
        const [playlistState, setPlaylistState] = useState<Track[]>(
            track ? [track, ...playlist] : playlist
        );
        const [currentTrackIndex, setCurrentTrackIndex] = useState(0);
        const [originalPlaylistOrder, setOriginalPlaylistOrder] = useState<
            Track[]
        >([]);

        // UI
        const [showPlaylist, setShowPlaylist] = useState(false);
        const [isFavorite, setIsFavorite] = useState(false);

        const regionId = useId();

        // Expose audio element
        useImperativeHandle(ref, () => ({
            audioElement: audioRef.current,
        }));

        // ── Actions ──

        const play = useCallback(() => {
            audioRef.current?.play();
        }, []);

        const pause = useCallback(() => {
            audioRef.current?.pause();
        }, []);

        const togglePlay = useCallback(() => {
            if (isPlaying) pause();
            else play();
        }, [isPlaying, play, pause]);

        const seek = useCallback((time: number) => {
            if (audioRef.current) {
                audioRef.current.currentTime = Math.max(
                    0,
                    Math.min(time, audioRef.current.duration || 0)
                );
            }
        }, []);

        const seekForward = useCallback(
            (seconds = 10) => seek(currentTime + seconds),
            [currentTime, seek]
        );

        const seekBackward = useCallback(
            (seconds = 10) => seek(currentTime - seconds),
            [currentTime, seek]
        );

        const setVolume = useCallback(
            (vol: number) => {
                const v = Math.max(0, Math.min(1, vol));
                if (audioRef.current) {
                    audioRef.current.volume = v;
                    if (v > 0 && isMuted) audioRef.current.muted = false;
                }
            },
            [isMuted]
        );

        const toggleMute = useCallback(() => {
            if (!audioRef.current) return;
            if (isMuted) {
                audioRef.current.muted = false;
                audioRef.current.volume = previousVolume;
            } else {
                setPreviousVolume(volume);
                audioRef.current.muted = true;
            }
        }, [isMuted, volume, previousVolume]);

        const setPlaybackRate = useCallback((rate: number) => {
            if (audioRef.current) audioRef.current.playbackRate = rate;
        }, []);

        const toggleRepeat = useCallback(() => {
            setRepeatMode((p) =>
                p === "off" ? "all" : p === "all" ? "one" : "off"
            );
        }, []);

        const toggleShuffle = useCallback(() => {
            setIsShuffled((prev) => {
                if (!prev) {
                    setOriginalPlaylistOrder([...playlistState]);
                    const shuffled = [...playlistState];
                    for (let i = shuffled.length - 1; i > 0; i--) {
                        const j = Math.floor(Math.random() * (i + 1));
                        [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
                    }
                    setPlaylistState(shuffled);
                    const idx = shuffled.findIndex(
                        (t) => t.id === currentTrack?.id
                    );
                    if (idx !== -1) setCurrentTrackIndex(idx);
                } else {
                    setPlaylistState(originalPlaylistOrder);
                    const idx = originalPlaylistOrder.findIndex(
                        (t) => t.id === currentTrack?.id
                    );
                    if (idx !== -1) setCurrentTrackIndex(idx);
                }
                return !prev;
            });
        }, [playlistState, originalPlaylistOrder, currentTrack]);

        const toggleFavorite = useCallback(
            () => setIsFavorite((p) => !p),
            []
        );

        const download = useCallback(() => {
            if (!audioRef.current?.src) return;
            const a = document.createElement("a");
            a.href = audioRef.current.src;
            a.download = currentTrack?.title ?? "audio.mp3";
            a.click();
        }, [currentTrack]);

        const playTrack = useCallback(
            (index: number) => {
                if (index < 0 || index >= playlistState.length) return;
                const t = playlistState[index];
                setCurrentTrack(t);
                setCurrentTrackIndex(index);
                onTrackChange?.(t);
                setTimeout(() => {
                    if (audioRef.current) {
                        audioRef.current.load();
                        audioRef.current.play();
                    }
                }, 0);
            },
            [playlistState, onTrackChange]
        );

        const nextTrack = useCallback(() => {
            if (playlistState.length === 0) return;
            playTrack(
                (currentTrackIndex + 1) % playlistState.length
            );
        }, [currentTrackIndex, playlistState.length, playTrack]);

        const previousTrack = useCallback(() => {
            if (playlistState.length === 0) return;
            if (currentTime > 3) {
                seek(0);
                return;
            }
            playTrack(
                currentTrackIndex <= 0
                    ? playlistState.length - 1
                    : currentTrackIndex - 1
            );
        }, [
            currentTrackIndex,
            currentTime,
            playlistState.length,
            playTrack,
            seek,
        ]);

        const addToPlaylist = useCallback(
            (t: Track) => setPlaylistState((p) => [...p, t]),
            []
        );

        const removeFromPlaylist = useCallback(
            (i: number) =>
                setPlaylistState((p) => p.filter((_, idx) => idx !== i)),
            []
        );

        const clearPlaylist = useCallback(() => {
            setPlaylistState([]);
            setCurrentTrack(null);
            setCurrentTrackIndex(0);
        }, []);

        // ── Media Session API ──

        useEffect(() => {
            if (!enableMediaSession || !("mediaSession" in navigator))
                return;
            if (!currentTrack) return;

            navigator.mediaSession.metadata = new MediaMetadata({
                title: currentTrack.title,
                artist: currentTrack.artist ?? "Unknown Artist",
                album: currentTrack.album ?? "Unknown Album",
                artwork: currentTrack.artwork
                    ? [
                        {
                            src: currentTrack.artwork,
                            sizes: "512x512",
                            type: "image/png",
                        },
                    ]
                    : [],
            });

            navigator.mediaSession.setActionHandler("play", play);
            navigator.mediaSession.setActionHandler("pause", pause);
            navigator.mediaSession.setActionHandler(
                "previoustrack",
                previousTrack
            );
            navigator.mediaSession.setActionHandler("nexttrack", nextTrack);
            navigator.mediaSession.setActionHandler("seekbackward", () =>
                seekBackward(10)
            );
            navigator.mediaSession.setActionHandler("seekforward", () =>
                seekForward(10)
            );

            return () => {
                if ("mediaSession" in navigator)
                    navigator.mediaSession.metadata = null;
            };
        }, [
            currentTrack,
            enableMediaSession,
            play,
            pause,
            previousTrack,
            nextTrack,
            seekBackward,
            seekForward,
        ]);

        // ── Audio event listeners ──

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

            const onPlayEvt = () => {
                setIsPlaying(true);
                setHasEnded(false);
                onPlay?.();
            };
            const onPauseEvt = () => {
                setIsPlaying(false);
                onPause?.();
            };
            const onTimeUpdateEvt = () => {
                setCurrentTime(audio.currentTime);
                onTimeUpdate?.(audio.currentTime);
            };
            const onDuration = () => setDuration(audio.duration);
            const onProgress = () => {
                if (audio.buffered.length > 0)
                    setBuffered(
                        audio.buffered.end(audio.buffered.length - 1)
                    );
            };
            const onWaiting = () => setIsLoading(true);
            const onCanPlay = () => setIsLoading(false);
            const onEndedEvt = () => {
                setIsPlaying(false);
                setHasEnded(true);
                onEnded?.();
                if (repeatMode === "one") {
                    audio.currentTime = 0;
                    audio.play();
                } else if (
                    repeatMode === "all" ||
                    currentTrackIndex < playlistState.length - 1
                ) {
                    nextTrack();
                }
            };
            const onVolume = () => {
                setVolumeState(audio.volume);
                setIsMuted(audio.muted);
                onVolumeChange?.(audio.volume);
            };
            const onRate = () =>
                setPlaybackRateState(audio.playbackRate);

            audio.addEventListener("play", onPlayEvt);
            audio.addEventListener("pause", onPauseEvt);
            audio.addEventListener("timeupdate", onTimeUpdateEvt);
            audio.addEventListener("durationchange", onDuration);
            audio.addEventListener("progress", onProgress);
            audio.addEventListener("waiting", onWaiting);
            audio.addEventListener("canplay", onCanPlay);
            audio.addEventListener("ended", onEndedEvt);
            audio.addEventListener("volumechange", onVolume);
            audio.addEventListener("ratechange", onRate);

            return () => {
                audio.removeEventListener("play", onPlayEvt);
                audio.removeEventListener("pause", onPauseEvt);
                audio.removeEventListener("timeupdate", onTimeUpdateEvt);
                audio.removeEventListener("durationchange", onDuration);
                audio.removeEventListener("progress", onProgress);
                audio.removeEventListener("waiting", onWaiting);
                audio.removeEventListener("canplay", onCanPlay);
                audio.removeEventListener("ended", onEndedEvt);
                audio.removeEventListener("volumechange", onVolume);
                audio.removeEventListener("ratechange", onRate);
            };
            // eslint-disable-next-line react-hooks/exhaustive-deps
        }, [
            repeatMode,
            currentTrackIndex,
            playlistState.length,
            nextTrack,
        ]);

        // ── Keyboard shortcuts ──

        useEffect(() => {
            const container = containerRef.current;
            if (!container) return;

            const handler = (e: KeyboardEvent) => {
                if (
                    e.target instanceof HTMLInputElement ||
                    e.target instanceof HTMLTextAreaElement
                )
                    return;

                switch (e.key) {
                    case " ":
                    case "k":
                        e.preventDefault();
                        togglePlay();
                        break;
                    case "ArrowLeft":
                        e.preventDefault();
                        seekBackward(5);
                        break;
                    case "ArrowRight":
                        e.preventDefault();
                        seekForward(5);
                        break;
                    case "ArrowUp":
                        e.preventDefault();
                        setVolume(Math.min(1, volume + 0.1));
                        break;
                    case "ArrowDown":
                        e.preventDefault();
                        setVolume(Math.max(0, volume - 0.1));
                        break;
                    case "m":
                        e.preventDefault();
                        toggleMute();
                        break;
                    case "n":
                        e.preventDefault();
                        nextTrack();
                        break;
                    case "p":
                        e.preventDefault();
                        previousTrack();
                        break;
                    case "r":
                        e.preventDefault();
                        toggleRepeat();
                        break;
                    case "s":
                        e.preventDefault();
                        toggleShuffle();
                        break;
                    case "0":
                    case "Home":
                        e.preventDefault();
                        seek(0);
                        break;
                    case "End":
                        e.preventDefault();
                        seek(duration);
                        break;
                }
            };

            container.addEventListener("keydown", handler);
            return () => container.removeEventListener("keydown", handler);
        }, [
            togglePlay,
            seekBackward,
            seekForward,
            setVolume,
            volume,
            toggleMute,
            nextTrack,
            previousTrack,
            toggleRepeat,
            toggleShuffle,
            seek,
            duration,
        ]);

        // ── Context value (memoized) ──

        const ctx = useMemo<AudioPlayerContextValue>(
            () => ({
                audioRef,
                containerRef,
                isPlaying,
                currentTime,
                duration,
                buffered,
                isLoading,
                hasEnded,
                volume,
                isMuted,
                previousVolume,
                playbackRate,
                repeatMode,
                isShuffled,
                currentTrack,
                playlist: playlistState,
                currentTrackIndex,
                showPlaylist,
                isFavorite,
                play,
                pause,
                togglePlay,
                seek,
                seekForward,
                seekBackward,
                setVolume,
                toggleMute,
                setPlaybackRate,
                toggleRepeat,
                toggleShuffle,
                toggleFavorite,
                download,
                playTrack,
                nextTrack,
                previousTrack,
                addToPlaylist,
                removeFromPlaylist,
                clearPlaylist,
                setShowPlaylist,
            }),
            [
                isPlaying,
                currentTime,
                duration,
                buffered,
                isLoading,
                hasEnded,
                volume,
                isMuted,
                previousVolume,
                playbackRate,
                repeatMode,
                isShuffled,
                currentTrack,
                playlistState,
                currentTrackIndex,
                showPlaylist,
                isFavorite,
                play,
                pause,
                togglePlay,
                seek,
                seekForward,
                seekBackward,
                setVolume,
                toggleMute,
                setPlaybackRate,
                toggleRepeat,
                toggleShuffle,
                toggleFavorite,
                download,
                playTrack,
                nextTrack,
                previousTrack,
                addToPlaylist,
                removeFromPlaylist,
                clearPlaylist,
                setShowPlaylist,
            ]
        );

        return (
            <AudioPlayerContext.Provider value={ctx}>
                <div
                    ref={containerRef}
                    id={regionId}
                    role="region"
                    aria-label="Audio player"
                    tabIndex={0}
                    className={clsx(
                        "relative focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg",
                        className
                    )}
                    {...divProps}
                >
                    {children}
                </div>
            </AudioPlayerContext.Provider>
        );
    }
);

AudioPlayerRoot.displayName = "AudioPlayer.Root";

// ============================================================================
// Audio
// ============================================================================

interface AudioProps extends AudioHTMLAttributes<HTMLAudioElement> {
    src?: string;
}

const Audio = forwardRef<HTMLAudioElement, AudioProps>(
    ({ src, className, ...props }, _ref) => {
        const { audioRef, currentTrack } = useAudioPlayer();

        return (
            <audio
                ref={audioRef}
                src={src ?? currentTrack?.src}
                className={clsx("sr-only", className)}
                {...props}
            >
                Your browser does not support the audio element.
            </audio>
        );
    }
);

Audio.displayName = "AudioPlayer.Audio";

// ============================================================================
// Player container
// ============================================================================

interface PlayerProps extends HTMLAttributes<HTMLDivElement> {
    variant?: "default" | "minimal" | "card";
}

const Player = forwardRef<HTMLDivElement, PlayerProps>(
    ({ variant = "default", className, children, ...props }, ref) => (
        <div
            ref={ref}
            className={clsx(
                "bg-ground-50 dark:bg-ground-950 border border-ground-200 dark:border-ground-700",
                PLAYER_VARIANT[variant],
                className
            )}
            {...props}
        >
            {children}
        </div>
    )
);

Player.displayName = "AudioPlayer.Player";

// ============================================================================
// Artwork
// ============================================================================

interface ArtworkProps extends HTMLAttributes<HTMLDivElement> {
    size?: "sm" | "md" | "lg" | "xl";
    animated?: boolean;
}

const Artwork = forwardRef<HTMLDivElement, ArtworkProps>(
    ({ size = "lg", animated = true, className, ...props }, ref) => {
        const { currentTrack, isPlaying } = useAudioPlayer();

        return (
            <div
                ref={ref}
                className={clsx(
                    ARTWORK_SIZE[size],
                    "rounded overflow-hidden bg-ground-200 dark:bg-ground-800 shrink-0",
                    className
                )}
                {...props}
            >
                {currentTrack?.artwork ? (
                    <img
                        src={currentTrack.artwork}
                        alt={`${currentTrack.title} artwork`}
                        className={clsx(
                            "w-full h-full object-cover",
                            animated && isPlaying && "animate-spin-slow"
                        )}
                    />
                ) : (
                    <div className="w-full h-full flex items-center justify-center">
                        <ListMusic
                            className="w-1/2 h-1/2 text-ground-400"
                            aria-hidden="true"
                        />
                    </div>
                )}
            </div>
        );
    }
);

Artwork.displayName = "AudioPlayer.Artwork";

// ============================================================================
// Track info
// ============================================================================

interface TrackInfoProps extends HTMLAttributes<HTMLDivElement> {
    showArtist?: boolean;
    showAlbum?: boolean;
}

const TrackInfo = forwardRef<HTMLDivElement, TrackInfoProps>(
    (
        { showArtist = true, showAlbum = false, className, ...props },
        ref
    ) => {
        const { currentTrack } = useAudioPlayer();

        if (!currentTrack) {
            return (
                <div ref={ref} className={className} {...props}>
                    <span className="text-ground-400 dark:text-ground-500">
                        No track selected
                    </span>
                </div>
            );
        }

        return (
            <div
                ref={ref}
                className={clsx("min-w-0", className)}
                {...props}
            >
                <h3 className="font-secondary font-semibold text-ground-900 dark:text-ground-100 truncate">
                    {currentTrack.title}
                </h3>
                {showArtist && currentTrack.artist && (
                    <p className="text-sm font-secondary text-ground-600 dark:text-ground-400 truncate">
                        {currentTrack.artist}
                    </p>
                )}
                {showAlbum && currentTrack.album && (
                    <p className="text-xs font-secondary text-ground-500 truncate">
                        {currentTrack.album}
                    </p>
                )}
            </div>
        );
    }
);

TrackInfo.displayName = "AudioPlayer.TrackInfo";

// ============================================================================
// Progress bar
// ============================================================================

interface ProgressBarProps extends HTMLAttributes<HTMLDivElement> {
    showBuffer?: boolean;
    showTimestamps?: boolean;
}

const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(
    (
        {
            showBuffer = true,
            showTimestamps = true,
            className,
            ...props
        },
        ref
    ) => {
        const { currentTime, duration, buffered, seek } =
            useAudioPlayer();
        const [isSeeking, setIsSeeking] = useState(false);
        const [hoverTime, setHoverTime] = useState<number | null>(null);
        const progressRef = useRef<HTMLDivElement>(null);

        const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
        const bufferProgress =
            duration > 0 ? (buffered / duration) * 100 : 0;

        const updateSeek = useCallback(
            (clientX: number) => {
                const rect =
                    progressRef.current?.getBoundingClientRect();
                if (!rect) return;
                const pos = Math.max(
                    0,
                    Math.min(1, (clientX - rect.left) / rect.width)
                );
                seek(pos * duration);
            },
            [seek, duration]
        );

        const handleMouseDown = useCallback(
            (e: React.MouseEvent) => {
                setIsSeeking(true);
                updateSeek(e.clientX);
            },
            [updateSeek]
        );

        const handleMouseMove = useCallback(
            (e: React.MouseEvent) => {
                const rect =
                    progressRef.current?.getBoundingClientRect();
                if (rect) {
                    const pos =
                        (e.clientX - rect.left) / rect.width;
                    setHoverTime(pos * duration);
                }
                if (isSeeking) updateSeek(e.clientX);
            },
            [isSeeking, updateSeek, duration]
        );

        const handleMouseLeave = useCallback(
            () => setHoverTime(null),
            []
        );

        useEffect(() => {
            if (!isSeeking) return;
            const up = () => setIsSeeking(false);
            document.addEventListener("mouseup", up);
            return () => document.removeEventListener("mouseup", up);
        }, [isSeeking]);

        return (
            <div
                ref={ref}
                className={clsx("space-y-2", className)}
                {...props}
            >
                <div className="relative group/progress">
                    <div
                        ref={progressRef}
                        role="slider"
                        tabIndex={0}
                        aria-label="Seek audio"
                        aria-valuemin={0}
                        aria-valuemax={duration}
                        aria-valuenow={currentTime}
                        aria-valuetext={`${formatTime(currentTime)} of ${formatTime(duration)}`}
                        className="relative h-1.5 bg-ground-200 dark:bg-ground-700 rounded-full cursor-pointer group-hover/progress:h-2 transition-all"
                        onMouseDown={handleMouseDown}
                        onMouseMove={handleMouseMove}
                        onMouseLeave={handleMouseLeave}
                    >
                        {showBuffer && (
                            <div
                                className="absolute inset-y-0 left-0 bg-ground-300 dark:bg-ground-600 rounded-full transition-all"
                                style={{ width: `${bufferProgress}%` }}
                                aria-hidden="true"
                            />
                        )}

                        <div
                            className="absolute inset-y-0 left-0 bg-primary-500 rounded-full transition-all"
                            style={{ width: `${progress}%` }}
                            aria-hidden="true"
                        />

                        <div
                            className="absolute top-1/2 -translate-y-1/2 bg-ground-50 border-2 border-primary-500 rounded-full shadow-outer opacity-0 group-hover/progress:opacity-100 transition-all"
                            style={{
                                left: `${progress}%`,
                                width: "12px",
                                height: "12px",
                                transform: "translate(-50%, -50%)",
                            }}
                            aria-hidden="true"
                        />

                        {hoverTime !== null && (
                            <div
                                className="absolute -top-8 -translate-x-1/2 bg-ground-800 dark:bg-ground-100 text-ground-100 dark:text-ground-800 text-xs font-secondary px-2 py-1 rounded pointer-events-none"
                                style={{
                                    left: `${(hoverTime / duration) * 100}%`,
                                }}
                                aria-hidden="true"
                            >
                                {formatTime(hoverTime)}
                            </div>
                        )}
                    </div>
                </div>

                {showTimestamps && (
                    <div className="flex items-center justify-between text-xs font-secondary text-ground-600 dark:text-ground-400">
                        <time
                            dateTime={`PT${Math.floor(currentTime)}S`}
                        >
                            {formatTime(currentTime)}
                        </time>
                        <time dateTime={`PT${Math.floor(duration)}S`}>
                            {formatTime(duration)}
                        </time>
                    </div>
                )}
            </div>
        );
    }
);

ProgressBar.displayName = "AudioPlayer.ProgressBar";

// ============================================================================
// Control buttons
// ============================================================================

interface IconButtonProps extends HTMLAttributes<HTMLButtonElement> {
    label: string;
    pressed?: boolean;
}

const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
    ({ label, pressed, className, children, ...props }, ref) => (
        <button
            ref={ref}
            type="button"
            aria-label={label}
            aria-pressed={pressed}
            className={clsx(ICON_BTN, className)}
            {...props}
        >
            {children}
        </button>
    )
);

IconButton.displayName = "IconButton";

// ── Play / Pause ──

interface PlayPauseButtonProps
    extends HTMLAttributes<HTMLButtonElement> {
    size?: "sm" | "md" | "lg";
}

const PlayPauseButton = forwardRef<
    HTMLButtonElement,
    PlayPauseButtonProps
>(({ size = "md", className, ...props }, ref) => {
    const { isPlaying, togglePlay } = useAudioPlayer();

    return (
        <button
            ref={ref}
            type="button"
            onClick={togglePlay}
            aria-label={isPlaying ? "Pause" : "Play"}
            className={clsx(
                PLAY_PAUSE_SIZE[size],
                "flex items-center justify-center rounded-full bg-primary-600 hover:bg-primary-700 text-white transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2",
                className
            )}
            {...props}
        >
            {isPlaying ? (
                <Pause
                    className={PLAY_PAUSE_ICON_SIZE[size]}
                    fill="currentColor"
                    aria-hidden="true"
                />
            ) : (
                <Play
                    className={clsx(
                        PLAY_PAUSE_ICON_SIZE[size],
                        "ml-0.5"
                    )}
                    fill="currentColor"
                    aria-hidden="true"
                />
            )}
        </button>
    );
});

PlayPauseButton.displayName = "AudioPlayer.PlayPauseButton";

// ── Skip buttons ──

const SkipBackwardButton = forwardRef<
    HTMLButtonElement,
    HTMLAttributes<HTMLButtonElement>
>(({ className, ...props }, ref) => {
    const { previousTrack } = useAudioPlayer();

    return (
        <IconButton
            ref={ref}
            label="Previous track"
            onClick={previousTrack}
            className={className}
            {...props}
        >
            <SkipBack
                className={clsx("w-5 h-5", ICON_COLOR)}
                aria-hidden="true"
            />
        </IconButton>
    );
});

SkipBackwardButton.displayName = "AudioPlayer.SkipBackwardButton";

const SkipForwardButton = forwardRef<
    HTMLButtonElement,
    HTMLAttributes<HTMLButtonElement>
>(({ className, ...props }, ref) => {
    const { nextTrack } = useAudioPlayer();

    return (
        <IconButton
            ref={ref}
            label="Next track"
            onClick={nextTrack}
            className={className}
            {...props}
        >
            <SkipForward
                className={clsx("w-5 h-5", ICON_COLOR)}
                aria-hidden="true"
            />
        </IconButton>
    );
});

SkipForwardButton.displayName = "AudioPlayer.SkipForwardButton";

// ── Volume ──

const VolumeControl = forwardRef<
    HTMLDivElement,
    HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
    const { volume, isMuted, setVolume, toggleMute } = useAudioPlayer();
    const [showSlider, setShowSlider] = useState(false);
    const volumeId = useId();

    return (
        <div
            ref={ref}
            className={clsx("flex items-center gap-2", className)}
            onMouseEnter={() => setShowSlider(true)}
            onMouseLeave={() => setShowSlider(false)}
            {...props}
        >
            <IconButton
                label={isMuted ? "Unmute" : "Mute"}
                onClick={toggleMute}
            >
                {isMuted || volume === 0 ? (
                    <VolumeX
                        className={clsx("w-5 h-5", ICON_COLOR)}
                        aria-hidden="true"
                    />
                ) : (
                    <Volume2
                        className={clsx("w-5 h-5", ICON_COLOR)}
                        aria-hidden="true"
                    />
                )}
            </IconButton>

            <div
                className={clsx(
                    "overflow-hidden transition-all duration-200",
                    showSlider
                        ? "w-20 opacity-100"
                        : "w-0 opacity-0"
                )}
            >
                <input
                    id={volumeId}
                    type="range"
                    min="0"
                    max="1"
                    step="0.01"
                    value={isMuted ? 0 : volume}
                    onChange={(e) =>
                        setVolume(parseFloat(e.target.value))
                    }
                    aria-label="Volume"
                    className="w-full h-1 bg-ground-200 dark:bg-ground-700 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-primary-500 [&::-webkit-slider-thumb]:rounded-full"
                />
            </div>
        </div>
    );
});

VolumeControl.displayName = "AudioPlayer.VolumeControl";

// ── Repeat ──

const RepeatButton = forwardRef<
    HTMLButtonElement,
    HTMLAttributes<HTMLButtonElement>
>(({ className, ...props }, ref) => {
    const { repeatMode, toggleRepeat } = useAudioPlayer();

    return (
        <IconButton
            ref={ref}
            label={`Repeat: ${repeatMode}`}
            pressed={repeatMode !== "off"}
            onClick={toggleRepeat}
            className={clsx(
                repeatMode !== "off"
                    ? "text-primary-600 dark:text-primary-400"
                    : ICON_COLOR,
                className
            )}
            {...props}
        >
            {repeatMode === "one" ? (
                <Repeat1 className="w-5 h-5" aria-hidden="true" />
            ) : (
                <Repeat className="w-5 h-5" aria-hidden="true" />
            )}
        </IconButton>
    );
});

RepeatButton.displayName = "AudioPlayer.RepeatButton";

// ── Shuffle ──

const ShuffleButton = forwardRef<
    HTMLButtonElement,
    HTMLAttributes<HTMLButtonElement>
>(({ className, ...props }, ref) => {
    const { isShuffled, toggleShuffle } = useAudioPlayer();

    return (
        <IconButton
            ref={ref}
            label={isShuffled ? "Shuffle on" : "Shuffle off"}
            pressed={isShuffled}
            onClick={toggleShuffle}
            className={clsx(
                isShuffled
                    ? "text-primary-600 dark:text-primary-400"
                    : ICON_COLOR,
                className
            )}
            {...props}
        >
            <Shuffle className="w-5 h-5" aria-hidden="true" />
        </IconButton>
    );
});

ShuffleButton.displayName = "AudioPlayer.ShuffleButton";

// ── Favorite ──

const FavoriteButton = forwardRef<
    HTMLButtonElement,
    HTMLAttributes<HTMLButtonElement>
>(({ className, ...props }, ref) => {
    const { isFavorite, toggleFavorite } = useAudioPlayer();

    return (
        <IconButton
            ref={ref}
            label={
                isFavorite
                    ? "Remove from favorites"
                    : "Add to favorites"
            }
            pressed={isFavorite}
            onClick={toggleFavorite}
            className={className}
            {...props}
        >
            <Heart
                className={clsx(
                    "w-5 h-5",
                    isFavorite
                        ? "fill-error-500 text-error-500"
                        : ICON_COLOR
                )}
                aria-hidden="true"
            />
        </IconButton>
    );
});

FavoriteButton.displayName = "AudioPlayer.FavoriteButton";

// ── Download ──

const DownloadButton = forwardRef<
    HTMLButtonElement,
    HTMLAttributes<HTMLButtonElement>
>(({ className, ...props }, ref) => {
    const { download } = useAudioPlayer();

    return (
        <IconButton
            ref={ref}
            label="Download audio"
            onClick={download}
            className={className}
            {...props}
        >
            <Download
                className={clsx("w-5 h-5", ICON_COLOR)}
                aria-hidden="true"
            />
        </IconButton>
    );
});

DownloadButton.displayName = "AudioPlayer.DownloadButton";

// ── Share ──

const ShareButton = forwardRef<
    HTMLButtonElement,
    HTMLAttributes<HTMLButtonElement>
>(({ className, ...props }, ref) => (
    <IconButton
        ref={ref}
        label="Share"
        className={className}
        {...props}
    >
        <Share2
            className={clsx("w-5 h-5", ICON_COLOR)}
            aria-hidden="true"
        />
    </IconButton>
);

ShareButton.displayName = "AudioPlayer.ShareButton";

// ── More ──

const MoreButton = forwardRef<
    HTMLButtonElement,
    HTMLAttributes<HTMLButtonElement>
>(({ className, ...ps }, ref) => (
    <IconButton
        ref={ref}
        label="More options"
        className={className}
        {...ps}
    >
        <MoreHorizontal
            className={clsx("w-5 h-5", ICON_COLOR)}
            aria-hidden="true"
        />
    </IconButton>
);

MoreButton.displayName = "AudioPlayer.MoreButton";

// ── Playlist toggle ──

const PlaylistButton = forwardRef<
    HTMLButtonElement,
    HTMLAttributes<HTMLButtonElement>
>(({ className, ...props }, ref) => {
    const { showPlaylist, setShowPlaylist } = useAudioPlayer();

    return (
        <IconButton
            ref={ref}
            label="Toggle playlist"
            pressed={showPlaylist}
            onClick={() => setShowPlaylist(!showPlaylist)}
            className={clsx(
                showPlaylist &&
                "bg-ground-100 dark:bg-ground-800",
                className
            )}
            {...props}
        >
            <ListMusic
                className={clsx("w-5 h-5", ICON_COLOR)}
                aria-hidden="true"
            />
        </IconButton>
    );
});

PlaylistButton.displayName = "AudioPlayer.PlaylistButton";

// ============================================================================
// Playlist
// ============================================================================

interface PlaylistProps extends HTMLAttributes<HTMLDivElement> {
    maxHeight?: string;
}

const PlaylistComp = forwardRef<HTMLDivElement, PlaylistProps>(
    ({ maxHeight = "300px", className, ...props }, ref) => {
        const {
            playlist,
            currentTrackIndex,
            playTrack,
            removeFromPlaylist,
        } = useAudioPlayer();

        if (playlist.length === 0) {
            return (
                <div
                    ref={ref}
                    className={clsx(
                        "text-center font-secondary text-ground-500 dark:text-ground-400 py-8",
                        className
                    )}
                    {...props}
                >
                    No tracks in playlist
                </div>
            );
        }

        return (
            <ul
                ref={ref as React.Ref<HTMLUListElement>}
                role="listbox"
                aria-label="Playlist"
                className={clsx("space-y-1", className)}
                style={{ maxHeight, overflowY: "auto" }}
                {...(props as HTMLAttributes<HTMLUListElement>)}
            >
                {playlist.map((track, index) => (
                    <li
                        key={track.id}
                        role="option"
                        aria-selected={index === currentTrackIndex}
                        className={clsx(
                            "flex items-center gap-3 p-3 rounded cursor-pointer transition-colors",
                            index === currentTrackIndex
                                ? "bg-primary-100 dark:bg-primary-900/20"
                                : "hover:bg-ground-50 dark:hover:bg-ground-800"
                        )}
                        onClick={() => playTrack(index)}
                    >
                        <div className="shrink-0 w-10 h-10 rounded bg-ground-200 dark:bg-ground-700 flex items-center justify-center overflow-hidden">
                            {track.artwork ? (
                                <img
                                    src={track.artwork}
                                    alt={`${track.title} artwork`}
                                    className="w-full h-full object-cover"
                                />
                            ) : (
                                <ListMusic
                                    className="w-5 h-5 text-ground-400"
                                    aria-hidden="true"
                                />
                            )}
                        </div>

                        <div className="flex-1 min-w-0">
                            <div
                                className={clsx(
                                    "font-secondary font-medium text-sm truncate",
                                    index === currentTrackIndex
                                        ? "text-primary-600 dark:text-primary-400"
                                        : "text-ground-900 dark:text-ground-100"
                                )}
                            >
                                {track.title}
                            </div>
                            {track.artist && (
                                <div className="text-xs font-secondary text-ground-600 dark:text-ground-400 truncate">
                                    {track.artist}
                                </div>
                            )}
                        </div>

                        <button
                            type="button"
                            onClick={(e) => {
                                e.stopPropagation();
                                removeFromPlaylist(index);
                            }}
                            className="p-1 hover:bg-ground-200 dark:hover:bg-ground-600 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500"
                            aria-label={`Remove ${track.title} from playlist`}
                        >
                            <X
                                className="w-4 h-4 text-ground-500"
                                aria-hidden="true"
                            />
                        </button>
                    </li>
                ))}
            </ul>
        );
    }
);

PlaylistComp.displayName = "AudioPlayer.Playlist";

// ============================================================================
// Loading indicator
// ============================================================================

const LoadingIndicator = forwardRef<
    HTMLDivElement,
    HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
    const { isLoading } = useAudioPlayer();
    if (!isLoading) return null;

    return (
        <div
            ref={ref}
            role="status"
            aria-label="Loading audio"
            className={clsx(
                "absolute inset-0 flex items-center justify-center bg-ground-900/10 dark:bg-ground-100/10 pointer-events-none rounded-lg",
                className
            )}
            {...props}
        >
            <div
                className="w-8 h-8 border-4 border-primary-200 border-t-primary-600 rounded-full animate-spin"
                aria-hidden="true"
            />
            <span className="sr-only">Loading…</span>
        </div>
    );
});

LoadingIndicator.displayName = "AudioPlayer.LoadingIndicator";

// ============================================================================
// Exports
// ============================================================================

const AudioPlayer = {
    Root: AudioPlayerRoot,
    Audio,
    Player,
    Artwork,
    TrackInfo,
    ProgressBar,
    PlayPauseButton,
    SkipBackwardButton,
    SkipForwardButton,
    VolumeControl,
    RepeatButton,
    ShuffleButton,
    FavoriteButton,
    DownloadButton,
    ShareButton,
    MoreButton,
    PlaylistButton,
    Playlist: PlaylistComp,
    LoadingIndicator,
};

export { AudioPlayer, useAudioPlayer };
export type { AudioPlayerContextValue, RepeatMode, Track };

On this page