tiny-music

Installation

HamsterHamster

2024-03-11

This is a copy-and-paste component, so you just need to copy the component code into your project to start using it!

Usage

<TinyMusic trackTitle={"Easy"} artworkUrl={"https://img.buycoffee.tech/slow-down-small.jpeg"} playerStatus={true} playPercent={55} />

Requirements

  • tailwindcss
  • framer-motion (10.16.8)
  • clsx

1. Add Tailwind CSS

Components are styled using Tailwind CSS. You need to install Tailwind CSS in your project.

2. Add dependencies

npm install framer-motion@10.16.8 tailwindcss-animate class-variance-authority clsx tailwind-merge

3. Add dependencies

Here is my tailwind.config.mjs file:

const { fontFamily } = require("tailwindcss/defaultTheme")

/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: ["class"],
  content: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"],
  theme: {
    container: {
      center: true,
      padding: "2rem",
      screens: {
        "2xl": "1400px",
      },
    },
    extend: {
      colors: {
        border: "hsl(var(--border))",
        input: "hsl(var(--input))",
        ring: "hsl(var(--ring))",
        background: "hsl(var(--background))",
        foreground: "hsl(var(--foreground))",
        primary: {
          DEFAULT: "hsl(var(--primary))",
          foreground: "hsl(var(--primary-foreground))",
        },
        secondary: {
          DEFAULT: "hsl(var(--secondary))",
          foreground: "hsl(var(--secondary-foreground))",
        },
        destructive: {
          DEFAULT: "hsl(var(--destructive))",
          foreground: "hsl(var(--destructive-foreground))",
        },
        muted: {
          DEFAULT: "hsl(var(--muted))",
          foreground: "hsl(var(--muted-foreground))",
        },
        accent: {
          DEFAULT: "hsl(var(--accent))",
          foreground: "hsl(var(--accent-foreground))",
        },
        popover: {
          DEFAULT: "hsl(var(--popover))",
          foreground: "hsl(var(--popover-foreground))",
        },
        card: {
          DEFAULT: "hsl(var(--card))",
          foreground: "hsl(var(--card-foreground))",
        },
      },
      borderRadius: {
        lg: `var(--radius)`,
        md: `calc(var(--radius) - 2px)`,
        sm: "calc(var(--radius) - 4px)",
      },
      fontFamily: {
        sans: ["var(--font-sans)", ...fontFamily.sans],
      },
      keyframes: {
        "accordion-down": {
          from: { height: "0" },
          to: { height: "var(--radix-accordion-content-height)" },
        },
        "accordion-up": {
          from: { height: "var(--radix-accordion-content-height)" },
          to: { height: "0" },
        },
      },
      animation: {
        "accordion-down": "accordion-down 0.2s ease-out",
        "accordion-up": "accordion-up 0.2s ease-out",
      },
    },
  },
  plugins: [require("tailwindcss-animate")],
}

4. Add a cn helper

use shadcn/cn to conditionally add Tailwind CSS classes.
Define it in lib/utils.ts

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Add the component

Copy the component code into your project.\

"use client";
import clsx from 'clsx'
import { AnimatePresence, motion } from 'framer-motion'
import React, {useCallback, useEffect, useMemo, useState} from 'react'
import {cn} from "@/lib/utils";


/**
 * MusicData represents the data needed to display a music track in the UI.
 */
interface MusicData {
    /**
     * Optional CSS class name to apply to the music track element.
     */
    className?: string

    /**
     * The title of the music track.
     */
    trackTitle: string

    /**
     * The URL of the artwork for the music track.
     */
    artworkUrl: string

    /**
     * The current status of the music player.
     */
    playerStatus: boolean

    /**
     * The current progress of the music track, represented as a percentage.
     */
    playPercent: number
}


const anim = {
    initial: { opacity: 1, scale: 0.3, transition: { delay: 1 } },
    open: {
        scale: 1,
        y: 80,
        filter: ['blur(5px)', 'blur(0px)'],
        opacity: [0.1, 1],
        transition: { duration: 0.4, ease: [0.23, 1, 0.32, 1] },
    },
    closed: { scale: 1, y: 0, filter: ['blur(5px)', 'blur(0px)'] },
}


function TinyMusic({className,trackTitle,artworkUrl,playerStatus,playPercent}:MusicData){
    const [isActive, setIsActive] = useState(false)
    const [imgColor, setImgColor] = useState({ r: 0, g: 0, b: 0 })
    const [isMounted, setIsMounted] = useState(false)
    const extractAverageColor = useCallback((img: HTMLImageElement) => {
        const canvas = document.createElement('canvas')
        const context = canvas.getContext('2d')
        if (!context) {
            return { r: 0, g: 0, b: 0 } // Fallback color
        }

        canvas.width = img.width
        canvas.height = img.height
        context.drawImage(img, 0, 0, img.width, img.height)

        const data = context.getImageData(0, 0, img.width, img.height).data
        let r = 0,
            g = 0,
            b = 0,
            count = 0

        for (let i = 0; i < data.length; i += 4) {
            r += data[i]!
            g += data[i + 1]!
            b += data[i + 2]!
            count++
        }

        return {
            r: Math.round(r / count),
            g: Math.round(g / count),
            b: Math.round(b / count),
        }
    }, [])


    useEffect(() => {
        let isCancelled = false;

        const fetchImage = async () => {
            const res = await fetch(artworkUrl);
            const blob = await res.blob();
            const img = new Image();
            img.src = URL.createObjectURL(blob);
            img.onload = () => {
                if (!isCancelled) {
                    setImgColor(extractAverageColor(img));
                }
            };
        };

        void fetchImage();

        return () => { isCancelled = true; };
    }, [artworkUrl, extractAverageColor]);


    useEffect(() => {
        const timer = setTimeout(() => setIsMounted(true), 1000);
        return () => clearTimeout(timer);
    }, []);


    return (
        <div
            className={cn("hit-area  z-50 pointer-events-auto relative py-4", className)}
            onMouseEnter={() => setIsActive(true)}
            onMouseLeave={() => setIsActive(false)}
        >
            <AnimatePresence mode={'wait'}>
                {isMounted && (
                    <motion.div
                        key="music-widget"
                        variants={anim}
                        initial="initial"
                        animate={isActive ? 'open' : 'closed'}
                        className={clsx('pointer-events-auto relative  mr-4 flex items-center rounded-xl backdrop-blur-lg', {
                            'bg-white dark:bg-black': !isActive,
                            'bg-opacity-10 dark:bg-opacity-10': !isActive,
                            'px-1.5 py-1.5 gap-1 ring-1 ring-zinc-900/5 dark:ring-white/10': !isActive,
                            'bg-opacity-0 dark:bg-opacity-0 px-2 py-2': isActive,
                        })}
                    >
                            <img
                                className={clsx(' relative z-50', {
                                    'w-8 rounded': !isActive,
                                    'w-32 rounded-md': isActive,
                                })}
                                src={artworkUrl}
                                alt="Album Art"
                                style={{
                                    boxShadow: !isActive
                                        ? `0 0 10px 1px rgb(${imgColor?.r}, ${imgColor?.g}, ${imgColor?.b},0.6)`
                                        : `0 10px 50px 5px rgb(${imgColor?.r}, ${imgColor?.g}, ${imgColor?.b},0.3)`,
                                }}
                            />
                        {/* background */}
                        <div
                            style={{
                                backgroundColor: `rgba(${imgColor?.r}, ${imgColor?.g}, ${imgColor?.b},0.05)`,
                            }}
                            className="absolute bottom-0 left-0 right-0 top-0 rounded-xl"
                        ></div>
                        {/* progress bar */}
                        <div className="absolute bottom-0 left-0 right-0 top-0 overflow-hidden rounded-xl">
                            <div
                                style={{
                                    backgroundImage: `linear-gradient(90deg, rgba(${imgColor?.r}, ${imgColor?.g}, ${imgColor?.b},0.05), rgba(${imgColor?.r}, ${imgColor?.g}, ${imgColor?.b},0.2))`,
                                    width: `${playPercent}%`,
                                    transition: 'width 0.5s ease-in-out',
                                }}
                                className="h-full"
                            ></div>
                        </div>

                        <div className="relative z-50 flex-col items-start transition-all">
                            {!isActive && (
                                <>
                                    <div className={'text-[10px] font-semibold opacity-50 '}>
                                        {playerStatus ? 'Now Playing' : 'Paused'}
                                    </div>
                                    <div className={'text-[12px] font-semibold opacity-80 '}>
                                        {trackTitle && trackTitle.length >= 10 ? (
                                            <div className="overflow-hidden whitespace-nowrap  w-16">
                                                <div className="inline-block pl-[30%] animate-marquee">{trackTitle}</div>
                                            </div>
                                        ) : (
                                            trackTitle
                                        )}
                                    </div>
                                </>
                            )}
                        </div>
                    </motion.div>
                )}
            </AnimatePresence>
        </div>
    )
}

export default TinyMusic

Use It!