Cassette Audio Player
Un reproductor de audio construido dentro de un casete.
Archive 11One Small Step
Side A200769
Elapsed time Total time
Audio de la NASA, dominio público: Apolo 11, 20 de julio de 1969.
"use client"
import * as React from "react"
import * as Slider from "@radix-ui/react-slider"
import { Pause, Play, RotateCcw, Volume2, VolumeX } from "lucide-react"
import { cn } from "@/lib/utils"
/**
* An audio player built into a compact cassette.
*
* The reels turn with the tape, the spools trade diameter as it winds across,
* and rewinding runs the whole thing backwards rather than jumping to zero.
* All of that is driven by three CSS variables set from a rAF loop, so the
* animation never passes through React.
*/
const DEFAULT_AUDIO = "/crafts/cassette/one-small-step.mp3"
const DEFAULT_TITLE = "One Small Step"
const DEFAULT_VOLUME = 0.78
/** Six teeth on the hub, evenly spaced. */
const REEL_SPOKES = [0, 60, 120, 180, 240, 300] as const
/** The ribs across the tape window. */
const WINDOW_RIBS = [0, 1, 2, 3, 4] as const
/** Rewind takes longer the further back it has to go, within these bounds. */
const MIN_REWIND_MS = 220
const MAX_REWIND_MS = 1000
/**
* Grain over the plastic. Inline SVG turbulence rather than an image: it is a
* few hundred bytes, it never blurs, and it costs no request.
*/
const TEXTURE =
"url(\"data:image/svg+xml,%3Csvg width='180' height='180' viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")"
const BUTTON =
"grid aspect-square cursor-pointer place-items-center rounded-full border text-[#fdfdfc] transition-[background-color,opacity,transform] duration-150 ease-out active:scale-[0.96] focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-white disabled:cursor-not-allowed disabled:opacity-45 disabled:active:scale-100 motion-reduce:duration-[0.01ms]"
export type CassetteCaptionTrack = {
default?: boolean
label: string
src: string
srcLang: string
}
export type CassettePlayerProps = Omit<React.ComponentPropsWithRef<"section">, "children"> & {
/** Small caps line above the title. */
archiveLabel?: string
audioSrc?: string
captionTracks?: readonly CassetteCaptionTrack[]
/** Printed small on the label, under the side badge. */
catalogueNumber?: string
initialVolume?: number
loop?: boolean
onPlaybackChange?: (isPlaying: boolean) => void
onPlaybackError?: (error: unknown) => void
preload?: "auto" | "metadata" | "none"
sideLabel?: string
trackTitle?: string
}
function easeInOutCubic(t: number) {
return t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2
}
function formatTime(seconds: number) {
if (!Number.isFinite(seconds)) return "0:00"
const minutes = Math.floor(seconds / 60)
const rest = Math.floor(seconds % 60)
return `${minutes}:${rest.toString().padStart(2, "0")}`
}
/** The `datetime` form, which wants a duration and not a clock reading. */
function formatDuration(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) return "PT0S"
return `PT${Math.floor(seconds)}S`
}
function clampVolume(volume: number) {
return Number.isFinite(volume) ? Math.min(Math.max(volume, 0), 1) : DEFAULT_VOLUME
}
function Reel({ className }: { className: string }) {
return (
<div
className={cn(
"absolute top-1/2 z-3 aspect-square w-[78cqh] -translate-x-1/2 -translate-y-1/2",
className
)}
>
<svg
aria-hidden
viewBox="0 0 100 100"
className="absolute inset-0 origin-center rotate-[var(--reel-rotation)] rounded-full will-change-transform motion-reduce:rotate-0!"
>
<circle cx="50" cy="50" r="48" className="fill-[#fdfdfc]" />
{REEL_SPOKES.map((angle) => (
<path
key={angle}
d="M46 3h8v9a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2z"
transform={`rotate(${angle} 50 50)`}
className="fill-[var(--reel-teeth)] stroke-[var(--reel-tooth-stroke)] [stroke-linejoin:round] [stroke-width:1.25] [filter:drop-shadow(0_1px_1px_rgba(0,0,0,0.32))]"
/>
))}
<circle
cx="50"
cy="50"
r="48"
className="fill-none stroke-[var(--reel-window)] [stroke-width:3]"
/>
</svg>
</div>
)
}
function Screw({ className }: { className: string }) {
const slot =
"absolute top-1/2 right-[18%] left-[18%] h-[14%] -translate-y-1/2 rounded-full bg-[#1d1d1b] shadow-[inset_0_1px_1px_rgba(0,0,0,0.82),0_1px_rgba(255,255,255,0.1)]"
return (
<div
aria-hidden
className={cn(
"absolute z-3 aspect-square w-[3.3%] rounded-full border border-[#060606] bg-[radial-gradient(circle_at_36%_30%,#5f5f5c,#30302e_48%,#171716_78%)] shadow-[inset_0_1px_1px_rgba(255,255,255,0.26),0_1px_1px_rgba(0,0,0,0.38)]",
className
)}
>
<span className={cn(slot, "rotate-45")} />
<span className={cn(slot, "-rotate-45")} />
</div>
)
}
export function CassettePlayer({
archiveLabel = "Archive 11",
audioSrc = DEFAULT_AUDIO,
captionTracks = [],
catalogueNumber = "200769",
className,
initialVolume = DEFAULT_VOLUME,
loop = true,
onPlaybackChange,
onPlaybackError,
preload = "metadata",
ref,
sideLabel = "Side A",
trackTitle = DEFAULT_TITLE,
...sectionProps
}: CassettePlayerProps) {
const audioRef = React.useRef<HTMLAudioElement>(null)
const cassetteRef = React.useRef<HTMLDivElement>(null)
const durationRef = React.useRef(0)
const playingRef = React.useRef(false)
const sourceRef = React.useRef(audioSrc)
const rewindRef = React.useRef<number | null>(null)
const resumeAfterRewind = React.useRef(false)
const resumeAfterScrub = React.useRef(false)
const [currentTime, setCurrentTime] = React.useState(0)
const [duration, setDuration] = React.useState(0)
const [isPlaying, setIsPlaying] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const [volume, setVolume] = React.useState(() => clampVolume(initialVolume))
const [lastVolume, setLastVolume] = React.useState(() => clampVolume(initialVolume))
/**
* Everything that moves, written straight to the DOM.
*
* These run at sixty frames a second. Through state they would re-render the
* whole cassette that often, and the reels do not need React's opinion on
* what angle they are at.
*/
const paint = React.useCallback((time: number) => {
const cassette = cassetteRef.current
if (!cassette) return
const total = durationRef.current
const progress = total > 0 ? Math.min(Math.max(time / total, 0), 1) : 0
cassette.style.setProperty("--reel-rotation", `${(time * 300) % 360}deg`)
/* The spools trade diameter: the left one empties as the right fills. */
cassette.style.setProperty("--left-tape-scale", `${1 - progress * 0.4}`)
cassette.style.setProperty("--right-tape-scale", `${0.6 + progress * 0.4}`)
}, [])
const fail = React.useCallback(
(cause: unknown, message: string) => {
setError(message)
onPlaybackError?.(cause)
},
[onPlaybackError]
)
const setPlaying = React.useCallback(
(next: boolean) => {
if (playingRef.current === next) return
playingRef.current = next
setIsPlaying(next)
onPlaybackChange?.(next)
},
[onPlaybackChange]
)
const readDuration = React.useCallback(
(audio: HTMLAudioElement) => {
const next = Number.isFinite(audio.duration) ? audio.duration : 0
durationRef.current = next
setDuration(next)
paint(audio.currentTime)
},
[paint]
)
React.useEffect(() => {
const audio = audioRef.current
if (audio) audio.volume = volume
}, [volume])
/* Metadata may already have arrived before this mounted. */
React.useEffect(() => {
const audio = audioRef.current
if (audio && audio.readyState >= 1) readDuration(audio)
}, [readDuration])
/* A new source resets everything, including a rewind mid-flight. */
React.useEffect(() => {
if (sourceRef.current === audioSrc) return
sourceRef.current = audioSrc
if (rewindRef.current !== null) {
cancelAnimationFrame(rewindRef.current)
rewindRef.current = null
}
const audio = audioRef.current
audio?.pause()
audio?.load()
durationRef.current = 0
resumeAfterRewind.current = false
resumeAfterScrub.current = false
setCurrentTime(0)
setDuration(0)
setPlaying(false)
setError(null)
paint(0)
}, [audioSrc, setPlaying, paint])
/* `timeupdate` fires about four times a second, which is fine for the clock
and far too coarse for the reels. */
React.useEffect(() => {
if (!isPlaying) return
let frame = 0
const step = () => {
const audio = audioRef.current
if (!audio || audio.paused) return
paint(audio.currentTime)
setCurrentTime(audio.currentTime)
frame = requestAnimationFrame(step)
}
frame = requestAnimationFrame(step)
return () => cancelAnimationFrame(frame)
}, [isPlaying, paint])
React.useEffect(
() => () => {
if (rewindRef.current !== null) cancelAnimationFrame(rewindRef.current)
},
[]
)
function cancelRewind() {
if (rewindRef.current === null) return
cancelAnimationFrame(rewindRef.current)
rewindRef.current = null
resumeAfterRewind.current = false
const audio = audioRef.current
if (audio) {
audio.currentTime = currentTime
paint(currentTime)
}
}
async function togglePlayback() {
const audio = audioRef.current
if (!audio) return
cancelRewind()
if (!audio.paused) {
audio.pause()
return
}
try {
await audio.play()
} catch (cause) {
setPlaying(false)
fail(cause, "Playback could not start. Check the audio source and try again.")
}
}
/**
* Winds back rather than jumping. A cassette cannot seek, and the whole point
* of putting a player in one is that it behaves like the object.
*/
function restart() {
const audio = audioRef.current
if (!audio) return
if (rewindRef.current !== null) cancelAnimationFrame(rewindRef.current)
resumeAfterRewind.current = resumeAfterRewind.current || !audio.paused
if (!audio.paused) audio.pause()
const from = currentTime
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
const finish = () => {
rewindRef.current = null
audio.currentTime = 0
setCurrentTime(0)
paint(0)
const resume = resumeAfterRewind.current
resumeAfterRewind.current = false
if (!resume) return
audio.play().catch((cause) => {
setPlaying(false)
fail(cause, "Playback could not resume after restarting the track.")
})
}
if (from <= 0 || reduced) {
finish()
return
}
const share = duration > 0 ? Math.min(Math.max(from / duration, 0), 1) : 1
const span = MIN_REWIND_MS + (MAX_REWIND_MS - MIN_REWIND_MS) * share
const startedAt = performance.now()
const wind = (now: number) => {
const linear = Math.min((now - startedAt) / span, 1)
const next = from * (1 - easeInOutCubic(linear))
paint(next)
setCurrentTime(next)
if (linear < 1) {
rewindRef.current = requestAnimationFrame(wind)
return
}
finish()
}
rewindRef.current = requestAnimationFrame(wind)
}
function seek(next: number) {
const audio = audioRef.current
if (!audio) return
cancelRewind()
const clamped = Math.min(Math.max(next, 0), durationRef.current)
audio.currentTime = clamped
setCurrentTime(clamped)
paint(clamped)
}
/* Paused while the thumb is down, so scrubbing does not stutter the audio. */
function startScrubbing() {
const audio = audioRef.current
if (!audio) return
cancelRewind()
resumeAfterScrub.current = !audio.paused
if (!audio.paused) audio.pause()
}
async function endScrubbing() {
const audio = audioRef.current
const resume = resumeAfterScrub.current
resumeAfterScrub.current = false
if (!audio || !resume) return
try {
await audio.play()
} catch (cause) {
setPlaying(false)
fail(cause, "Playback could not resume after seeking the track.")
}
}
function changeVolume(next: number) {
const audio = audioRef.current
if (!audio) return
const clamped = clampVolume(next)
audio.volume = clamped
setVolume(clamped)
/* Remembered so unmuting returns to where it was, not to a default. */
if (clamped > 0) setLastVolume(clamped)
}
const muted = volume === 0
return (
<section
aria-label={`${trackTitle} audio player`}
{...sectionProps}
ref={ref}
className={cn(
"grid w-full place-items-center overflow-hidden rounded-xl bg-preview-bg px-8 py-16 [container-type:inline-size] max-[560px]:px-3.5 max-[560px]:py-10",
/* Reel colours, so the cassette reads as moulded plastic in both
themes rather than as the same photograph twice. */
"[--reel-teeth-stroke:#11100f] [--reel-teeth:#1b1a18]",
"dark:[--reel-teeth-stroke:var(--color-gray-500)] dark:[--reel-teeth:var(--color-gray-300)]",
/* The paper label: cream in the light theme, red in the dark one, the
way a second pressing of the same tape would be. */
"[--label-bg:#fdfdfc] [--label-ink:var(--color-gray-1200)]",
"[--label-border:color-mix(in_srgb,#fdfdfc_50%,transparent)]",
"[--label-kicker:color-mix(in_srgb,var(--color-gray-1200)_85%,transparent)]",
"[--label-catalogue:color-mix(in_srgb,var(--color-gray-1200)_70%,transparent)]",
"[--stripe-one:#16a34a] [--stripe-two:#0d9488] [--stripe-three:#2563eb]",
"[--thumb-ring:#fdfdfc]",
"dark:[--label-bg:#dc2626] dark:[--label-ink:#fff]",
"dark:[--label-border:color-mix(in_srgb,#000_15%,transparent)]",
"dark:[--label-kicker:color-mix(in_srgb,#fff_85%,transparent)]",
"dark:[--label-catalogue:color-mix(in_srgb,#fff_70%,transparent)]",
"dark:[--stripe-one:#fff] dark:[--stripe-two:#fff] dark:[--stripe-three:#fff]",
"dark:[--thumb-ring:#7f1d1d]",
className
)}
>
{/* Captions come in through `captionTracks`; there is no track to
hard-code, because the component does not own the audio. */}
<audio
ref={audioRef}
src={audioSrc}
loop={loop}
preload={preload}
onDurationChange={(event) => readDuration(event.currentTarget)}
onEnded={() => setPlaying(false)}
onError={(event) =>
fail(event.currentTarget.error, "This audio track could not be loaded.")
}
onLoadedMetadata={(event) => {
readDuration(event.currentTarget)
setError(null)
}}
onPause={() => setPlaying(false)}
onPlay={() => {
setPlaying(true)
setError(null)
}}
onTimeUpdate={(event) => {
setCurrentTime(event.currentTarget.currentTime)
paint(event.currentTarget.currentTime)
}}
>
{captionTracks.map((track) => (
<track
key={`${track.srcLang}-${track.src}`}
default={track.default}
kind="captions"
label={track.label}
src={track.src}
srcLang={track.srcLang}
/>
))}
</audio>
<div className="w-full max-w-[530px]">
{/*
`dark` on the shell, not on the page: the cassette body is dark
plastic in both themes, and this lets the parts inside it keep using
the same dark: variants they would anywhere else.
*/}
<div
ref={cassetteRef}
className="dark relative aspect-[1.58] w-full overflow-hidden rounded-[18px] border border-[#050505] bg-[linear-gradient(165deg,#373735_0%,#20201f_52%,#0e0e0d_100%)] shadow-[0_28px_48px_rgba(0,0,0,0.24),0_8px_16px_rgba(0,0,0,0.18),inset_0_2px_1px_rgba(255,255,255,0.2),inset_0_-3px_3px_rgba(0,0,0,0.74)] [--left-tape-scale:1] [--reel-rotation:0deg] [--right-tape-scale:0.6] max-[560px]:rounded-xl"
>
<div
aria-hidden
className="pointer-events-none absolute inset-1.5 rounded-[13px] border border-white/[0.12] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.62)]"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 mix-blend-multiply"
style={{ backgroundImage: TEXTURE }}
/>
<Screw className="top-[4%] left-[2.53%]" />
<Screw className="top-[4%] right-[2.53%]" />
<Screw className="bottom-[4%] left-[2.53%]" />
<Screw className="right-[2.53%] bottom-[4%]" />
<div className="absolute top-[9.5%] right-[8.5%] bottom-[24%] left-[8.5%] z-1 overflow-clip rounded-[9px] border-4 border-transparent bg-[var(--label-bg)] text-[var(--label-ink)] shadow-[inset_0_0_12px_rgba(92,74,49,0.12)] [container-type:inline-size] [overflow-clip-margin:border-box] max-[560px]:rounded-md">
<div className="relative z-2 mx-4 mt-4 flex items-stretch justify-between">
<div className="grid min-w-0 content-between gap-y-2">
<span className="relative z-2 font-mono text-[clamp(8px,2.5cqw,11px)] leading-none font-bold tracking-[0.12em] text-[var(--label-kicker)] uppercase">
{archiveLabel}
</span>
<span className="relative z-2 block truncate font-sans text-[clamp(12px,4.6cqw,20px)] leading-none font-semibold tracking-[-0.04em]">
{trackTitle}
</span>
</div>
<div className="ml-2 grid shrink-0 content-between justify-items-end gap-y-2 font-mono leading-none font-bold uppercase">
<span className="rounded-full border border-[var(--label-ink)] bg-[var(--label-ink)] px-[7px] py-1 text-[clamp(8px,2.5cqw,11px)] tracking-[0.08em] text-[var(--label-bg)]">
{sideLabel}
</span>
<span className="font-mono text-[clamp(7px,2.2cqw,10px)] tracking-[0.08em] text-[var(--label-catalogue)]">
{catalogueNumber}
</span>
</div>
</div>
<div className="relative mt-4 h-[34%] max-[560px]:h-[31%]">
<div
aria-hidden
className="absolute top-1/2 -inset-x-1 grid h-[58%] -translate-y-1/2 grid-rows-3 gap-y-1"
>
<span className="bg-[var(--stripe-one)]" />
<span className="bg-[var(--stripe-two)]" />
<span className="bg-[var(--stripe-three)]" />
</div>
<div className="absolute inset-x-[17.5%] inset-y-0 z-3 overflow-hidden rounded-full bg-[#1b1a18] bg-[linear-gradient(rgba(255,255,255,0.13),transparent_45%)] shadow-[0_0_0_4px_var(--label-border),inset_0_3px_8px_rgba(0,0,0,0.58)] [--reel-window:#1b1a18] [container-type:size]">
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-25 mix-blend-multiply"
style={{ backgroundImage: TEXTURE }}
/>
<div
aria-hidden
className="absolute top-[12%] right-[28%] bottom-[12%] left-[28%] z-2 flex items-center justify-evenly overflow-hidden rounded-[3px] border-2 border-[#11100f] bg-[#393631] bg-[linear-gradient(to_bottom,rgba(255,255,255,0.1),transparent_42%)] shadow-[inset_0_3px_6px_rgba(0,0,0,0.72),0_0_0_2px_rgba(255,255,255,0.08)]"
>
{/* The wound tape itself. `cqh`/`cqw` keep the two spools on
the reel centres at any width the cassette is drawn at. */}
<span className="absolute top-1/2 left-[calc(50cqh-28cqw)] aspect-square h-[360%] -translate-x-1/2 -translate-y-1/2 scale-[var(--left-tape-scale)] rounded-full border border-[#0d0a08] bg-[repeating-radial-gradient(circle,#050505_0_2px,#171717_2px_4px)] shadow-[inset_0_0_5px_rgba(0,0,0,0.7),0_1px_2px_rgba(0,0,0,0.5)] will-change-transform" />
<span className="absolute top-1/2 left-[calc(72cqw-50cqh)] aspect-square h-[360%] -translate-x-1/2 -translate-y-1/2 scale-[var(--right-tape-scale)] rounded-full border border-[#0d0a08] bg-[repeating-radial-gradient(circle,#050505_0_2px,#171717_2px_4px)] shadow-[inset_0_0_5px_rgba(0,0,0,0.7),0_1px_2px_rgba(0,0,0,0.5)] will-change-transform" />
{WINDOW_RIBS.map((rib) => (
<span
key={rib}
className="relative z-1 h-[42%] w-0.5 bg-[rgba(224,215,195,0.28)]"
/>
))}
</div>
<Reel className="left-[50cqh]" />
<Reel className="left-[calc(100%-50cqh)]" />
</div>
</div>
<div className="absolute right-4 bottom-4 left-4 z-5 grid gap-y-1.5">
<Slider.Root
min={0}
max={Math.max(duration, 0.01)}
step={0.01}
disabled={duration <= 0}
value={[Math.min(currentTime, Math.max(duration, 0.01))]}
onValueChange={([next]) => seek(next)}
onValueCommit={endScrubbing}
onPointerDown={startScrubbing}
className="relative flex h-6 w-full touch-none items-center select-none data-[disabled]:cursor-default"
aria-label={`Seek through ${trackTitle}`}
>
<Slider.Track className="relative h-[3px] w-full grow translate-y-0.5 rounded-full bg-[color-mix(in_srgb,var(--label-ink)_28%,transparent)]">
<Slider.Range className="absolute h-full rounded-full bg-[var(--label-ink)]" />
</Slider.Track>
<Slider.Thumb
aria-label={`Seek through ${trackTitle}`}
aria-valuetext={`${formatTime(currentTime)} of ${formatTime(duration)}`}
className="block size-[13px] translate-y-0.5 cursor-pointer rounded-full border-2 border-[var(--thumb-ring)] bg-white shadow-[0_1px_4px_rgba(37,33,29,0.38)] outline-none focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-[var(--label-ink)]"
/>
</Slider.Root>
<div className="relative z-2 flex items-baseline justify-between font-sans text-xs leading-4 font-normal tabular-nums">
<span>
<span className="sr-only">Elapsed time </span>
<time dateTime={formatDuration(currentTime)}>{formatTime(currentTime)}</time>
</span>
<span>
<span className="sr-only">Total time </span>
<time dateTime={formatDuration(duration)}>{formatTime(duration)}</time>
</span>
</div>
</div>
<div
aria-hidden
className="pointer-events-none absolute -inset-1 z-10 rounded-[inherit] border-4 border-[var(--label-border)]"
/>
</div>
{/* The moulded shelf the buttons sit in, tapered at both ends. */}
<div className="absolute right-[27%] bottom-[3.5%] left-[27%] z-4 grid h-[16%] grid-cols-[1fr_auto_1fr] place-items-center gap-x-[clamp(6px,1.5cqw,10px)] bg-[color-mix(in_srgb,#63635e_20%,transparent)] px-[12%] shadow-[inset_0_3px_8px_rgba(0,0,0,0.5)] [clip-path:polygon(13%_0,87%_0,100%_100%,0_100%)]">
<button
type="button"
onClick={restart}
disabled={currentTime <= 0}
aria-label="Restart track"
className={cn(
BUTTON,
"w-[clamp(24px,6.5cqw,32px)] border-[#82827c] bg-[#63635e] shadow-[0_2px_5px_rgba(0,0,0,0.28),inset_0_1px_0_rgba(255,255,255,0.2)] hover:bg-[#7c7b74]"
)}
>
<RotateCcw aria-hidden className="size-4" strokeWidth={2.5} />
</button>
<button
type="button"
onClick={togglePlayback}
aria-label={isPlaying ? `Pause ${trackTitle}` : `Play ${trackTitle}`}
className={cn(
BUTTON,
"w-[clamp(30px,8.2cqw,43px)] border-[#bcbbb5]/50 bg-[#8d8d86] shadow-[0_3px_8px_rgba(0,0,0,0.32),inset_0_1px_0_rgba(255,255,255,0.25)] hover:bg-[#82827c]"
)}
>
{isPlaying ? (
<Pause aria-hidden className="size-[18px] fill-current" strokeWidth={0} />
) : (
<Play aria-hidden className="size-[18px] translate-x-px fill-current" strokeWidth={0} />
)}
</button>
<button
type="button"
onClick={() => changeVolume(muted ? lastVolume || DEFAULT_VOLUME : 0)}
aria-label={muted ? "Unmute" : "Mute"}
className={cn(
BUTTON,
"w-[clamp(24px,6.5cqw,32px)] border-[#82827c] bg-[#63635e] shadow-[0_2px_5px_rgba(0,0,0,0.28),inset_0_1px_0_rgba(255,255,255,0.2)] hover:bg-[#7c7b74]"
)}
>
{muted ? (
<VolumeX aria-hidden className="size-4" strokeWidth={2.25} />
) : (
<Volume2 aria-hidden className="size-4" strokeWidth={2.25} />
)}
</button>
</div>
</div>
{error ? (
<p role="alert" className="mt-3 text-center text-sm text-red-700 dark:text-red-400">
{error}
</p>
) : null}
</div>
</section>
)
}import { CassettePlayer } from "@/components/crafts/cassette-player"
export function AudioPlayer() {
return (
<CassettePlayer
audioSrc="/audio/one-small-step.mp3"
trackTitle="One Small Step"
archiveLabel="Archive 11"
catalogueNumber="200769"
/>
)
}