Audioplayer
Audio mit gezeichneter Wellenform zum Klicken, Ziehen und Durchsteppen, von der Podcast-Karte bis zur Sprachnachricht.
Eine von 5 Components in Media
Details
Was Audioplayer mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Media
- Bereich
- Elements
- Variants
- 3
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/audio-player.tsx
- Bringt mit
- utilskiticon
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 13 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle AudioPlayerProps aus components/ui/audio-player.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
srcoptionalstringAudio file. Without it the player runs as a preview and simulates playback with a timer.
durationoptionalnumberLength in seconds when there is no src (or before the metadata loads). Default 180.
variantoptionalAudioPlayerVariantcardcompactvoice
card: episode with artwork. compact: one row for articles and lists. voice: a chat bubble.
titleoptionalstringEpisode or track title (card), also used for the accessible name
subtitleoptionalstringShow or artist above the title (card)
metaoptionalReact.ReactNodeSmall line under the title (card) or next to the time (voice), e.g. a date or "09:41"
artworkoptionalReact.ReactNodeCover artwork for the card, any node
peaksoptionalnumber[]Real waveform levels from 0 to 1. Without them the bars come from the seed.
seedoptionalnumberSeed for the drawn waveform, same seed same shape
barsoptionalnumberNumber of bars. Default: card 64, compact 40, voice 32.
initialTimeoptionalnumberStart position in seconds
speedsoptionalnumber[]Speeds the speed button cycles through. Default 1, 1.5, 2.
alignoptional'start' | 'end'voice: start for incoming messages (left, muted), end for your own (right, accent)
animateoptionalbooleanThe waveform draws itself when it scrolls into view. Default true.
Eingebaute Texte
Diese Texte schreibt Audioplayer selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 13 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| player | Englisch Audio player | Deutsch Audioplayer |
| play | Englisch Play | Deutsch Abspielen |
| pause | Englisch Pause | Deutsch Pausieren |
| seek | Englisch Position | Deutsch Position |
| speed | Englisch Playback speed | Deutsch Wiedergabegeschwindigkeit |
| back | Englisch Back 15 seconds | Deutsch 15 Sekunden zurück |
| forward | Englisch Forward 15 seconds | Deutsch 15 Sekunden vor |
| of | Englisch of | Deutsch von |
| error | Englisch The audio could not be loaded. | Deutsch Die Audiodatei ließ sich nicht laden. |
| minute | Englisch minute | Deutsch Minute |
| minutes | Englisch minutes | Deutsch Minuten |
| second | Englisch second | Deutsch Sekunde |
| seconds | Englisch seconds | Deutsch Sekunden |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Audioplayer reagiert selbst auf die Tasten Pos1, Ende und Leertaste.
- Setzt aria-label, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen alert, group und slider.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 561 Zeilen
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
const TEXTS = {
en: {
player: 'Audio player',
play: 'Play',
pause: 'Pause',
seek: 'Position',
speed: 'Playback speed',
back: 'Back 15 seconds',
forward: 'Forward 15 seconds',
of: 'of',
error: 'The audio could not be loaded.',
minute: 'minute',
minutes: 'minutes',
second: 'second',
seconds: 'seconds',
},
de: {
player: 'Audioplayer',
play: 'Abspielen',
pause: 'Pausieren',
seek: 'Position',
speed: 'Wiedergabegeschwindigkeit',
back: '15 Sekunden zurück',
forward: '15 Sekunden vor',
of: 'von',
error: 'Die Audiodatei ließ sich nicht laden.',
minute: 'Minute',
minutes: 'Minuten',
second: 'Sekunde',
seconds: 'Sekunden',
},
};
type Texts = (typeof TEXTS)['en'];
const intlLocale = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
/** 65 -> "1:05", 3725 -> "1:02:05" */
function formatTime(seconds: number) {
const s = Math.max(0, Math.floor(seconds || 0));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const ss = String(s % 60).padStart(2, '0');
return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
}
function spokenTime(seconds: number, t: Texts) {
const s = Math.max(0, Math.floor(seconds || 0));
const m = Math.floor(s / 60);
const r = s % 60;
const parts: string[] = [];
if (m) parts.push(`${m} ${m === 1 ? t.minute : t.minutes}`);
if (r || !m) parts.push(`${r} ${r === 1 ? t.second : t.seconds}`);
return parts.join(' ');
}
/* ------------------------------------------------------------------ */
/* Motion pieces */
/* ------------------------------------------------------------------ */
const CSS = `
@keyframes kitfx-audio-grow{from{transform:scaleY(.06);opacity:.4}}
.kitfx-audio[data-anim=wait] .kitfx-audio-bar{transform:scaleY(.06);opacity:.4}
.kitfx-audio[data-anim=run] .kitfx-audio-bar{animation:kitfx-audio-grow .6s var(--ease-kit) both;animation-delay:var(--d,0ms)}
.kitfx-audio-morph{transition:d .32s var(--ease-kit)}
@keyframes kitfx-audio-hold{from{visibility:hidden}}
@media (scripting:enabled) and (prefers-reduced-motion:no-preference){.kitfx-audio[data-pending] .kitfx-audio-bar{animation:kitfx-audio-hold 0s 2.5s both}}
@media (prefers-reduced-motion:reduce){.kitfx-audio .kitfx-audio-bar{animation:none!important;transform:none!important;opacity:1!important}.kitfx-audio-morph{transition:none}}
`;
/* Play and pause as two shapes each with the same points, so one morphs into the other */
const SHAPES = {
play: ['M7 4.5L13.25 8.25L13.25 15.75L7 19.5Z', 'M13.25 8.25L19.5 12L19.5 12L13.25 15.75Z'],
pause: ['M6 5L10 5L10 19L6 19Z', 'M14 5L18 5L18 19L14 19Z'],
};
function PlayPause({ playing, size }: { playing: boolean; size: number }) {
const d = playing ? SHAPES.pause : SHAPES.play;
return (
<svg aria-hidden width={size} height={size} viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round">
<path className="kitfx-audio-morph" d={d[0]} />
<path className="kitfx-audio-morph" d={d[1]} />
</svg>
);
}
/**
* The waveform draws itself when it scrolls into view: 'final' on the server,
* without JavaScript and with reduced motion, 'wait' until it is in view, then 'run'.
*/
function useEntrance<T extends Element>(enabled: boolean) {
const ref = useRef<T>(null);
const [state, setState] = useState<'final' | 'wait' | 'run'>('final');
const [pending, setPending] = useState(enabled);
useEffect(() => {
setPending(false);
const el = ref.current;
if (!enabled || !el || prefersReducedMotion() || typeof IntersectionObserver === 'undefined') return;
setState('wait');
const io = new IntersectionObserver(
([e]) => {
if (!e.isIntersecting) return;
setState('run');
io.disconnect();
},
{ rootMargin: '0px 0px -6% 0px' },
);
io.observe(el);
return () => io.disconnect();
}, [enabled]);
return [ref, state === 'final' ? undefined : state, pending] as const;
}
/* ------------------------------------------------------------------ */
/* Waveform data */
/* ------------------------------------------------------------------ */
function seeded(seed: number) {
let a = (seed * 2654435761) >>> 0 || 1;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let x = a;
x = Math.imul(x ^ (x >>> 15), x | 1);
x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
};
}
/** Speech-like levels from a seed: syllables, short pauses, a softer start and end. */
function wavePeaks(seed: number, count: number) {
const rand = seeded(seed);
const raw = Array.from({ length: count }, (_, i) => {
// two slow waves make phrases, the random part makes syllables
const phrase = 0.5 + 0.5 * Math.sin(i * 0.31 + seed) * Math.cos(i * 0.093 + seed * 1.7);
const pause = rand() < 0.06 ? 0.2 : 1;
return (0.15 + rand() ** 1.4 * 0.85) * (0.2 + phrase * 0.8) * pause;
});
return raw.map((v, i) => {
const near = (raw[i - 1] ?? v) * 0.25 + v * 0.5 + (raw[i + 1] ?? v) * 0.25;
const edge = Math.min(1, (i + 2) / 5, (count - i + 1) / 5);
return near * edge;
}).map((v, _, all) => round(clamp(0.1 + (v / Math.max(...all)) * 0.9, 0.1, 1), 2));
}
/** Resample given peaks (0 to 1) to a number of bars, taking the loudest value in each slice. */
function resample(peaks: number[], count: number) {
if (!peaks.length) return Array(count).fill(0.1);
return Array.from({ length: count }, (_, i) => {
const a = Math.floor((i / count) * peaks.length);
const b = Math.max(a + 1, Math.floor(((i + 1) / count) * peaks.length));
return round(clamp(Math.max(...peaks.slice(a, b)), 0.08, 1), 2);
});
}
/* ------------------------------------------------------------------ */
/* Player */
/* ------------------------------------------------------------------ */
export type AudioPlayerVariant = 'card' | 'compact' | 'voice';
export interface AudioPlayerProps {
/** Audio file. Without it the player runs as a preview and simulates playback with a timer. */
src?: string;
/** Length in seconds when there is no src (or before the metadata loads). Default 180. */
duration?: number;
/** card: episode with artwork. compact: one row for articles and lists. voice: a chat bubble. */
variant?: AudioPlayerVariant;
/** Episode or track title (card), also used for the accessible name */
title?: string;
/** Show or artist above the title (card) */
subtitle?: string;
/** Small line under the title (card) or next to the time (voice), e.g. a date or "09:41" */
meta?: React.ReactNode;
/** Cover artwork for the card, any node */
artwork?: React.ReactNode;
/** Real waveform levels from 0 to 1. Without them the bars come from the seed. */
peaks?: number[];
/** Seed for the drawn waveform, same seed same shape */
seed?: number;
/** Number of bars. Default: card 64, compact 40, voice 32. */
bars?: number;
/** Start position in seconds */
initialTime?: number;
/** Speeds the speed button cycles through. Default 1, 1.5, 2. */
speeds?: number[];
/** voice: start for incoming messages (left, muted), end for your own (right, accent) */
align?: 'start' | 'end';
/** The waveform draws itself when it scrolls into view. Default true. */
animate?: boolean;
labels?: Partial<Texts>;
className?: string;
}
/**
* An audio player with a drawn waveform you can click, drag and step through with the keyboard.
* <AudioPlayer variant="card" src="/episode-42.mp3" title="…" subtitle="…" artwork={<img … />} />
* <AudioPlayer variant="voice" src="/note.m4a" align="end" meta="09:41" />
*/
export function AudioPlayer({
src,
duration: durationProp = 180,
variant = 'card',
title,
subtitle,
meta,
artwork,
peaks,
seed = 1,
bars,
initialTime = 0,
speeds = [1, 1.5, 2],
align = 'start',
animate = true,
labels,
className,
}: AudioPlayerProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const id = useId();
const audioRef = useRef<HTMLAudioElement>(null);
const waveRef = useRef<HTMLDivElement>(null);
const [rootRef, anim, pending] = useEntrance<HTMLDivElement>(animate);
const [playing, setPlaying] = useState(false);
const [started, setStarted] = useState(initialTime > 0);
const [time, setTime] = useState(initialTime);
const [duration, setDuration] = useState(durationProp);
const [rateIndex, setRateIndex] = useState(0);
const [failed, setFailed] = useState(false);
const [drag, setDrag] = useState(false);
const [hover, setHover] = useState<number | null>(null);
const timeRef = useRef(initialTime);
const rateRef = useRef(speeds[0] ?? 1);
const real = Boolean(src) && !failed;
const rate = speeds[rateIndex] ?? 1;
rateRef.current = rate;
const count = bars ?? (variant === 'card' ? 64 : variant === 'compact' ? 40 : 32);
const levels = useMemo(() => (peaks ? resample(peaks, count) : wavePeaks(seed, count)), [peaks, seed, count]);
/* Only one kit player plays at a time */
const pauseRef = useRef<() => void>(() => {});
useEffect(() => {
pauseRef.current = pause;
});
useEffect(() => {
const onOther = (e: Event) => {
if ((e as CustomEvent<string>).detail !== id) pauseRef.current();
};
window.addEventListener('kit-media-play', onOther);
return () => window.removeEventListener('kit-media-play', onOther);
}, [id]);
/* Preview mode: a clock instead of a file */
useEffect(() => {
if (real || !playing) return;
let raf = 0;
let last = performance.now();
let acc = 0;
const tick = (now: number) => {
const dt = Math.min(0.25, (now - last) / 1000) * rateRef.current;
last = now;
acc += dt;
const next = Math.min(duration, timeRef.current + dt);
timeRef.current = next;
if (next >= duration) {
setTime(next);
setPlaying(false);
return;
}
if (acc > 1 / 30) {
acc = 0;
setTime(next);
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [real, playing, duration]);
useEffect(() => {
if (audioRef.current) audioRef.current.playbackRate = rate;
}, [rate, real]);
function play() {
window.dispatchEvent(new CustomEvent('kit-media-play', { detail: id }));
setStarted(true);
if (timeRef.current >= duration - 0.05) seek(0);
const a = audioRef.current;
if (real && a) a.play().catch(() => setPlaying(false));
else setPlaying(true);
}
function pause() {
if (real && audioRef.current) audioRef.current.pause();
setPlaying(false);
}
const toggle = () => (playing ? pause() : play());
function seek(to: number) {
const next = clamp(to, 0, duration);
timeRef.current = next;
setTime(next);
setStarted(true);
const a = audioRef.current;
if (real && a && Number.isFinite(a.duration)) a.currentTime = next;
}
const fractionAt = (clientX: number) => {
const r = waveRef.current!.getBoundingClientRect();
return r.width ? clamp((clientX - r.left) / r.width, 0, 1) : 0;
};
const step = variant === 'voice' ? 2 : 5;
const onWaveKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
const d = { ArrowLeft: -step, ArrowDown: -step, ArrowRight: step, ArrowUp: step, PageDown: -duration / 10, PageUp: duration / 10 }[e.key];
if (d !== undefined) seek(timeRef.current + d);
else if (e.key === 'Home') seek(0);
else if (e.key === 'End') seek(duration);
else if (e.key === ' ' || e.key === 'k') toggle();
else return;
e.preventDefault();
};
const progress = duration ? clamp(time / duration, 0, 1) : 0;
const speedText = `${new Intl.NumberFormat(intlLocale(locale), { maximumFractionDigits: 2 }).format(rate)}×`;
const own = variant === 'voice' && align === 'end';
/* Bars: base, hover preview and progress on top, clipped from the right */
// Bars rise from the middle line one after another, left to right
const barRow = (tone: string, drawn = true) => (
<span className={cn('absolute inset-0 flex items-center', variant === 'card' ? 'gap-[2px] sm:gap-[3px]' : variant === 'compact' ? 'gap-[2px] max-sm:[&>span:nth-child(even)]:hidden' : 'gap-[2px]')}>
{levels.map((v, i) => (
<span
key={i}
className={cn('min-w-px flex-1 rounded-full', tone, drawn && 'kitfx-audio-bar')}
style={{ height: `${round(v * 100)}%`, ...(drawn ? ({ '--d': `${round((i / count) * 640, 0)}ms` } as React.CSSProperties) : {}) }}
/>
))}
</span>
);
const baseTone = own ? 'bg-[color-mix(in_oklch,var(--primary-foreground)_38%,transparent)]' : 'bg-[color-mix(in_oklch,var(--foreground)_17%,transparent)]';
const hoverTone = own ? 'bg-[color-mix(in_oklch,var(--primary-foreground)_62%,transparent)]' : 'bg-[color-mix(in_oklab,var(--primary)_38%,transparent)]';
const doneTone = own ? 'bg-primary-foreground' : 'bg-primary';
const wave = (
<div
ref={waveRef}
role="slider"
tabIndex={0}
aria-label={title ? `${t.seek}, ${title}` : t.seek}
aria-valuemin={0}
aria-valuemax={Math.floor(duration)}
aria-valuenow={Math.floor(time)}
aria-valuetext={`${spokenTime(time, t)} ${t.of} ${spokenTime(duration, t)}`}
onKeyDown={onWaveKey}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
setDrag(true);
seek(fractionAt(e.clientX) * duration);
}}
onPointerMove={(e) => {
if (e.pointerType === 'mouse') setHover(fractionAt(e.clientX));
if (drag) seek(fractionAt(e.clientX) * duration);
}}
onPointerUp={() => setDrag(false)}
onPointerCancel={() => setDrag(false)}
onPointerLeave={() => setHover(null)}
className={cn(
'relative min-w-0 flex-1 cursor-pointer touch-none rounded-md outline-offset-4',
variant === 'card' ? 'h-14' : variant === 'compact' ? 'h-11' : 'h-9',
)}
>
{barRow(baseTone)}
{hover !== null && (
<span aria-hidden className="absolute inset-0" style={{ clipPath: `inset(0 ${round(100 - hover * 100)}% 0 0)` }}>
{barRow(hoverTone, false)}
</span>
)}
{/* The played part fills in the accent and glides between the updates of the clock */}
<span aria-hidden className={cn('absolute inset-0', !drag && 'transition-[clip-path] duration-200 ease-linear motion-reduce:transition-none')} style={{ clipPath: `inset(0 ${round(100 - progress * 100, 3)}% 0 0)` }}>
{barRow(doneTone)}
</span>
</div>
);
const playIcon = (size: number) => <PlayPause playing={playing} size={size} />;
const motion = <style dangerouslySetInnerHTML={{ __html: CSS }} />;
const speedButton = (extra?: string) => (
<button
type="button"
onClick={() => setRateIndex((i) => (i + 1) % speeds.length)}
aria-label={`${t.speed} ${speedText}`}
className={cn(
'relative inline-flex h-11 min-w-11 shrink-0 items-center justify-center rounded-md px-2 text-[12px] font-semibold tabular-nums transition-colors duration-150 sm:h-8 sm:min-w-10 sm:after:absolute sm:after:-inset-1.5 sm:after:content-[""]',
own
? 'text-primary-foreground hover:bg-[color-mix(in_oklch,var(--primary-foreground)_14%,transparent)]'
: 'border border-border text-muted-foreground hover:bg-accent hover:text-foreground',
extra,
)}
>
{speedText}
</button>
);
const skipButton = (dir: -1 | 1) => (
<button
type="button"
onClick={() => seek(timeRef.current + dir * 15)}
aria-label={dir < 0 ? t.back : t.forward}
className="relative grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground"
>
<Icon name={dir > 0 ? 'rotate-cw' : 'rotate-ccw'} size={30} strokeWidth={1.35} />
<span aria-hidden className="absolute inset-0 grid place-items-center pt-[1px] text-[9px] font-semibold tracking-[-0.02em] tabular-nums">15</span>
</button>
);
const audio = src && !failed && (
<audio
ref={audioRef}
src={src}
preload="metadata"
onLoadedMetadata={(e) => {
const d = e.currentTarget.duration;
if (Number.isFinite(d)) setDuration(d);
if (initialTime) e.currentTarget.currentTime = initialTime;
}}
onTimeUpdate={(e) => {
timeRef.current = e.currentTarget.currentTime;
setTime(e.currentTarget.currentTime);
}}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
onError={() => {
setFailed(true);
setPlaying(false);
}}
/>
);
const error = failed && (
<p role="alert" className="mt-2 text-[12px] text-destructive">
{t.error}
</p>
);
/* ---------- Voice message bubble ---------- */
if (variant === 'voice') {
return (
<div
ref={rootRef}
data-anim={anim}
data-pending={pending ? '' : undefined}
role="group"
aria-label={title ?? t.player}
className={cn(
'kitfx-audio flex w-full max-w-[21rem] items-center gap-2.5 rounded-[20px] py-2 pl-2 pr-3',
own
? 'rounded-br-md bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.14),var(--shadow-sm)]'
: 'rounded-bl-md border border-border bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]',
className,
)}
>
{motion}
{audio}
<button
type="button"
onClick={toggle}
aria-label={playing ? t.pause : t.play}
className={cn(
'grid size-11 shrink-0 place-items-center rounded-full transition-transform duration-150 active:scale-95 motion-reduce:active:scale-100',
own ? 'bg-primary-foreground text-primary' : 'bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18)]',
)}
>
{playIcon(16)}
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center">{wave}</div>
<div className={cn('mt-0.5 flex items-center justify-between text-[11px] tabular-nums', own ? 'text-[color-mix(in_oklch,var(--primary-foreground)_72%,transparent)]' : 'text-muted-foreground')}>
<span>{formatTime(started ? time : duration)}</span>
{meta && <span className="inline-flex items-center gap-1">{meta}</span>}
</div>
{error}
</div>
{started && speedButton('h-8 min-w-10 sm:h-7 sm:min-w-9 text-[11px]')}
</div>
);
}
/* ---------- Compact row ---------- */
if (variant === 'compact') {
return (
<div ref={rootRef} data-anim={anim} data-pending={pending ? '' : undefined} role="group" aria-label={title ? `${t.player}, ${title}` : t.player} className={cn('kitfx-audio w-full', className)}>
{motion}
{audio}
<div className="flex w-full items-center gap-3">
<button
type="button"
onClick={toggle}
aria-label={playing ? t.pause : t.play}
className="grid size-11 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] transition-transform duration-150 hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] active:scale-95 motion-reduce:active:scale-100"
>
{playIcon(16)}
</button>
{wave}
<span className="w-10 shrink-0 text-right text-[12px] font-medium tabular-nums text-muted-foreground">{formatTime(started ? time : duration)}</span>
{speedButton()}
</div>
{error}
</div>
);
}
/* ---------- Card: episode with artwork ---------- */
return (
<article
ref={rootRef}
data-anim={anim}
data-pending={pending ? '' : undefined}
aria-label={title ? `${t.player}, ${title}` : t.player}
className={cn('kitfx-audio w-full rounded-2xl border border-border bg-card p-4 text-card-foreground shadow-[var(--highlight),var(--shadow-md)] sm:p-6', className)}
>
{motion}
{audio}
<div className="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-4 gap-y-5 sm:gap-x-6">
{artwork && <div data-clip-ok className="size-20 overflow-hidden rounded-xl border border-border shadow-[var(--shadow-sm)] sm:row-span-2 sm:size-40 [&>*]:size-full">{artwork}</div>}
<div className={cn('min-w-0 sm:self-end', !artwork && 'col-span-2')}>
{subtitle && <p className="truncate text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{subtitle}</p>}
{title && <h3 className="mt-1 line-clamp-3 text-balance text-[17px] font-semibold leading-snug tracking-[-0.015em] sm:text-h3">{title}</h3>}
{meta && <p className="mt-1 text-[13px] text-muted-foreground">{meta}</p>}
</div>
<div className={cn('col-span-2 min-w-0 sm:self-start', artwork && 'sm:col-span-1 sm:col-start-2')}>
<div className="flex">{wave}</div>
<div className="mt-3 flex items-center gap-1">
<button
type="button"
onClick={toggle}
aria-label={playing ? t.pause : t.play}
className="mr-1 grid size-11 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] transition-transform duration-150 hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] active:scale-95 motion-reduce:active:scale-100"
>
{playIcon(17)}
</button>
{skipButton(-1)}
{skipButton(1)}
<span className="ml-auto mr-2 whitespace-nowrap text-[12px] font-medium tabular-nums text-muted-foreground">
<span className="text-foreground">{formatTime(time)}</span>
<span className="text-subtle-foreground"> / {formatTime(duration)}</span>
</span>
{speedButton()}
</div>
{error}
</div>
</div>
</article>
);
}Häufige Fragen
Wie installiere ich Audioplayer?
Mit aiw add @ai-website/audio-player. Der Befehl legt die Datei components/ui/audio-player.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Audioplayer?
3 Varianten, Podcast-Karte, Kompakt im Text und Sprachnachricht. Du wählst sie mit der Prop variant, zum Beispiel variant="compact".
Kann ich die Texte von Audioplayer übersetzen?
Ja. Audioplayer bringt 13 eigene Texte auf Deutsch und Englisch mit, etwa „Audioplayer“, „Abspielen“ und „Pausieren“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Audioplayer einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Audioplayer mit aiw_components und installieren sie mit aiw_add @ai-website/audio-player. Nenn im Prompt die Variante, etwa variant="card", dann schreibt der Assistent deine Inhalte direkt in die Props.