Now Playing
The full player with shrinking artwork.
Installation
npx shadcn@latest add https://cupertino-ui.baltoon.jp/r/now-playing.jsonRequires the theme tokens — see Installation if this is your first component.
Source
"use client";
import * as React from "react";
import {
PauseIcon,
PlayIcon,
SkipBackIcon,
SkipForwardIcon,
Volume1Icon,
Volume2Icon,
} from "lucide-react";
import { formatTime, useAudioPlayer } from "@/components/ui/audio-player";
import { cn } from "@/lib/utils";
/**
* The iOS Now Playing screen: large artwork that shrinks while
* paused, scrubber with elapsed/remaining time, transport controls,
* and a volume slider. Render inside a sheet or a full-screen view.
*/
function NowPlaying({ className, ...props }: React.ComponentProps<"div">) {
const {
track,
playing,
toggle,
next,
prev,
currentTime,
duration,
seek,
volume,
setVolume,
} = useAudioPlayer();
const [scrubbing, setScrubbing] = React.useState<number | null>(null);
if (!track) return null;
const total = duration || track.duration || 0;
const shown = scrubbing ?? currentTime;
return (
<div
data-slot="now-playing"
className={cn(
"flex w-full flex-col items-center gap-7 px-7 pb-8 pt-4",
className
)}
{...props}
>
{/* Artwork — full size while playing, shrunk while paused (iOS behavior) */}
<div className="flex aspect-square w-full max-w-[320px] items-center justify-center">
<span
className={cn(
"block w-full overflow-hidden rounded-[10px] shadow-[0_0_0_0.5px_var(--separator),0_18px_50px_rgba(0,0,0,0.35)] transition-transform duration-350 ease-[cubic-bezier(0.32,0.72,0,1)]",
!playing && "scale-[0.78] shadow-[0_0_0_0.5px_var(--separator),0_8px_24px_rgba(0,0,0,0.25)]"
)}
>
{track.artworkUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={track.artworkUrl} alt="" className="aspect-square size-full object-cover" />
) : (
<span className="block aspect-square bg-fill-tertiary" />
)}
</span>
</div>
<div className="flex w-full max-w-[320px] flex-col gap-5">
<div className="min-w-0">
<p className="truncate text-title-3 text-label">{track.title}</p>
<p className="truncate text-title-3 font-normal text-secondary-label">
{track.artist}
</p>
</div>
{/* Scrubber */}
<div className="flex flex-col gap-1">
<input
type="range"
aria-label="Playback position"
min={0}
max={total || 1}
step={0.1}
value={Math.min(shown, total || 0)}
onChange={(e) => setScrubbing(Number(e.target.value))}
onPointerUp={() => {
if (scrubbing !== null) seek(scrubbing);
setScrubbing(null);
}}
onKeyUp={() => {
if (scrubbing !== null) seek(scrubbing);
setScrubbing(null);
}}
className="h-1.5 w-full cursor-default appearance-none rounded-full bg-fill outline-none focus-visible:ring-[3px] focus-visible:ring-blue/40 [&::-webkit-slider-thumb]:size-1.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-transparent"
style={{
background: `linear-gradient(to right, var(--secondary-label) ${total ? (shown / total) * 100 : 0}%, var(--system-fill) 0%)`,
}}
/>
<div className="flex justify-between text-caption-1 tabular-nums text-tertiary-label">
<span>{formatTime(shown)}</span>
<span>-{formatTime(Math.max(0, total - shown))}</span>
</div>
</div>
{/* Transport */}
<div className="flex items-center justify-center gap-12">
<button
type="button"
aria-label="Previous"
onClick={prev}
className="cursor-default text-label outline-none focus-visible:ring-[3px] focus-visible:ring-blue/40 active:opacity-60 [&_svg]:size-8 [&_svg]:fill-current"
>
<SkipBackIcon />
</button>
<button
type="button"
aria-label={playing ? "Pause" : "Play"}
onClick={toggle}
className="cursor-default text-label outline-none transition-transform focus-visible:ring-[3px] focus-visible:ring-blue/40 active:scale-90 [&_svg]:size-11 [&_svg]:fill-current"
>
{playing ? <PauseIcon /> : <PlayIcon />}
</button>
<button
type="button"
aria-label="Next"
onClick={next}
className="cursor-default text-label outline-none focus-visible:ring-[3px] focus-visible:ring-blue/40 active:opacity-60 [&_svg]:size-8 [&_svg]:fill-current"
>
<SkipForwardIcon />
</button>
</div>
{/* Volume */}
<div className="flex items-center gap-3 text-secondary-label">
<Volume1Icon className="size-4 shrink-0" />
<input
type="range"
aria-label="Volume"
min={0}
max={1}
step={0.01}
value={volume}
onChange={(e) => setVolume(Number(e.target.value))}
className="h-1.5 w-full cursor-default appearance-none rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-blue/40 [&::-webkit-slider-thumb]:size-1.5 [&::-webkit-slider-thumb]:appearance-none"
style={{
background: `linear-gradient(to right, var(--secondary-label) ${volume * 100}%, var(--system-fill) 0%)`,
}}
/>
<Volume2Icon className="size-4 shrink-0" />
</div>
</div>
</div>
);
}
export { NowPlaying };