Audio Player
Transport controls and a scrubbable progress bar for a generated audio response (UC-VOICE-02 AC-1/AC-4). Native-substitute per the PRD verdict — the HTML audio element is replaced by the CALLER'S audio module (expo-audio, a native player, an <audio> element on web): state-in/callbacks-out, no audio dependency in the registry, Expo Go-clean. Upstream part set at parity: play/pause, ±seek buttons (seekOffset 10 → seekOffsetMs 10000, clamped), time/duration displays, scrubber and volume over the slider atom, ButtonGroup control bar. AudioPlayerElement dropped on the record (it IS the substitution). Unwired seams disable their controls — never pretend; AC-3 (playback survives transcript scrolling) holds by construction: nothing here plays.
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/audio-player.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/audio-player.jsonDependencies
No required npm packages — this item installs through the registry dependencies below.
https://reactnativereusables.com/r/nativewind/button.json |
https://reactnativereusables.com/r/nativewind/icon.json |
https://reactnativereusables.com/r/nativewind/text.json |
https://rnraielements.com/r/nativewind/button-group.json |
https://rnraielements.com/r/nativewind/slider.json |
https://reactnativereusables.com/r/uniwind/button.json |
https://reactnativereusables.com/r/uniwind/icon.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://rnraielements.com/r/uniwind/button-group.json |
https://rnraielements.com/r/uniwind/slider.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { Button } from '@/registry/nativewind/components/ui/button';
import { ButtonGroup } from '@/registry/nativewind/components/ui/button-group';
import { Icon } from '@/registry/nativewind/components/ui/icon';
import { Slider } from '@/registry/nativewind/components/ui/slider';
import { Text } from '@/registry/nativewind/components/ui/text';
import { cn } from '@/registry/nativewind/lib/utils';
import {
PauseIcon,
PlayIcon,
RotateCcwIcon,
RotateCwIcon,
Volume1Icon,
Volume2Icon,
VolumeXIcon,
} from 'lucide-react-native';
import * as React from 'react';
import { View, type ViewProps } from 'react-native';
import {
clampVolume,
formatPlaybackTime,
playbackProgress,
seekTarget,
} from './audio-player.logic';
/**
* AudioPlayer — transport controls and a scrubbable progress bar for a generated
* audio response (UC-VOICE-02 AC-1 "play, pause, and a scrubbable progress bar",
* AC-4 "elapsed and total duration").
*
* THE PRD VERDICT IS NATIVE-SUBSTITUTE: "Built on the HTML audio element. Replaced
* by a native audio module; transport controls and progress bar keep the same product
* surface." The transport controls and progress bar ARE this component. The audio
* module is the CALLER'S: expo-audio, a native player, or an <audio> element on web —
* the seam is state-in/callbacks-out (isPlaying/positionMs/durationMs/volume in;
* onPlay/onPause/onSeek/onMuteToggle/onVolumeChange out), the speech-input precedent
* exactly. NO audio dependency enters anyone's install graph, and the registry stays
* Expo Go-clean. This verdict does NOT prescribe a module the way web-preview's did
* ("declares react-native-webview as a peer dependency") — nothing here names one, so
* nothing here is a STOP.
*
* AC-3 ("continue playback while scrolling without audio restarting") is free BY
* CONSTRUCTION: the component holds no playback to restart — it renders the state
* the caller reports, and a scroll of the transcript does not touch it.
*
* THE UPSTREAM PART SET, from the KB (media-chrome composition), ported part for
* part: Play, SeekBackward/Forward (seekOffset 10 → seekOffsetMs 10000), TimeDisplay,
* TimeRange, DurationDisplay, Mute, VolumeRange, ControlBar. DROPPED ON THE RECORD:
* AudioPlayerElement — the HTML <audio>/data-URI half is the verdict's substitution
* itself; there is no media surface to render, only controls. The default composition
* re-lays the web's single control row into three rows a phone thumb can actually
* serve (scrub · transport · volume); ControlBar remains exported for custom rows.
*
* EVERY UNWIRED SEAM DISABLES ITS CONTROL — never pretends (speech-input law: a
* control that fakes working is the lie). Volume seam unwired → VolumeRange renders
* at its reported value but inert, MuteButton disabled.
*
* Composition (default):
* <AudioPlayer isPlaying={p} positionMs={t} durationMs={d}
* onPlay={play} onPause={pause} onSeek={seek} />
* Composition (custom, upstream shape):
* <AudioPlayer {...seam}>
* <AudioPlayerControlBar>
* <AudioPlayerPlayButton />
* <AudioPlayerSeekBackwardButton seekOffsetMs={15000} />
* <AudioPlayerTimeDisplay />
* </AudioPlayerControlBar>
* </AudioPlayer>
*/
type AudioPlayerContextValue = {
isPlaying: boolean;
positionMs: number;
durationMs: number;
muted: boolean;
/** 0..1 — the caller-reported engine volume. */
volume: number;
play: (() => void) | undefined;
pause: (() => void) | undefined;
/** Seek to an absolute position. Undefined → every seek control is disabled. */
seek: ((positionMs: number) => void) | undefined;
toggleMute: (() => void) | undefined;
changeVolume: ((volume: number) => void) | undefined;
};
const AudioPlayerContext = React.createContext<AudioPlayerContextValue | null>(null);
/**
* The web's context hook. Byte-trap parity with the house selectors: parts used
* outside the root throw with the upstream message shape.
*/
function useAudioPlayer(): AudioPlayerContextValue {
const ctx = React.useContext(AudioPlayerContext);
if (!ctx) {
throw new Error('AudioPlayer components must be used within AudioPlayer');
}
return ctx;
}
export type AudioPlayerProps = {
/** The caller's engine state, reported back on every change. */
isPlaying?: boolean;
positionMs?: number;
/** 0 until the engine knows the length — time displays hold at 0:00, never NaN. */
durationMs?: number;
muted?: boolean;
/** 0..1. Reported volumes are clamped; garbage reads as muted-silent. */
volume?: number;
onPlay?: () => void;
onPause?: () => void;
/** Absolute seek in ms — from the scrubber AND the ±offset buttons (they clamp). */
onSeek?: (positionMs: number) => void;
onMuteToggle?: () => void;
onVolumeChange?: (volume: number) => void;
/** Omit for the default three-row layout; supply for the upstream composition. */
children?: React.ReactNode;
className?: string;
};
function AudioPlayer({
isPlaying = false,
positionMs = 0,
durationMs = 0,
muted = false,
volume = 1,
onPlay,
onPause,
onSeek,
onMuteToggle,
onVolumeChange,
children,
className,
}: AudioPlayerProps) {
const contextValue = React.useMemo<AudioPlayerContextValue>(
() => ({
isPlaying,
positionMs,
durationMs,
muted,
volume: clampVolume(volume),
play: onPlay,
pause: onPause,
seek: onSeek,
toggleMute: onMuteToggle,
changeVolume: onVolumeChange,
}),
[isPlaying, positionMs, durationMs, muted, volume, onPlay, onPause, onSeek, onMuteToggle, onVolumeChange],
);
return (
<AudioPlayerContext.Provider value={contextValue}>
{children ?? (
<View className={cn('gap-4', className)}>
<View className="flex-row items-center gap-3">
<AudioPlayerTimeDisplay className="shrink-0" />
<View className="min-w-0 flex-1">
<AudioPlayerTimeRange />
</View>
<AudioPlayerDurationDisplay className="shrink-0" />
</View>
<AudioPlayerControlBar className="justify-center">
<AudioPlayerSeekBackwardButton />
<AudioPlayerPlayButton />
<AudioPlayerSeekForwardButton />
</AudioPlayerControlBar>
<View className="flex-row items-center gap-3">
<AudioPlayerMuteButton />
<View className="min-w-0 flex-1">
<AudioPlayerVolumeRange />
</View>
</View>
</View>
)}
</AudioPlayerContext.Provider>
);
}
type AudioPlayerControlBarProps = ViewProps;
/**
* The web's control row — the registered ButtonGroup atom (the upstream composition
* also styles its controls as one ButtonGroup). A toolbar role wraps it; children
* compose any AudioPlayer control parts.
*/
/**
* The transport row. DECLARED DIVERGENCE from upstream: the web's control bar is one
* segmented ButtonGroup row; here the three controls render as independent rounded
* ghost-icon buttons (ButtonGroup wraps for a11y grouping, but its segmented
* corner-rounding machinery is intentionally NOT applied — icon buttons read better
* as distinct 44pt targets at phone width than as one fused pill).
*/
function AudioPlayerControlBar({ className, ...props }: AudioPlayerControlBarProps) {
return <ButtonGroup className={cn('gap-1', className)} {...props} />;
}
type AudioPlayerPlayButtonProps = {
className?: string;
};
/**
* Play/pause — one control, two states (the web's MediaPlayButton). Unwired engine →
* DISABLED, never a button that fakes playing.
*/
function AudioPlayerPlayButton({ className }: AudioPlayerPlayButtonProps) {
const { isPlaying, play, pause } = useAudioPlayer();
const toggle = isPlaying ? pause : play;
const label = isPlaying ? 'Pause audio' : 'Play audio';
return (
<Button
variant="ghost"
size="icon"
disabled={!toggle}
onPress={toggle}
accessibilityLabel={label}
accessibilityState={{ disabled: !toggle, busy: isPlaying }}
// House formula: h-10 control + hitSlop 2/side = the 44pt minimum, zero pixels.
hitSlop={{ top: 2, bottom: 2, left: 2, right: 2 }}
className={cn(className)}
>
<Icon as={isPlaying ? PauseIcon : PlayIcon} size={20} className="text-foreground" />
</Button>
);
}
type AudioPlayerSeekButtonProps = {
/** Milliseconds (the upstream part's seekOffset is seconds, default 10 — kept in ms
* here because positionMs/durationMs are ms throughout; default 10_000 = 10s). */
seekOffsetMs?: number;
className?: string;
};
/** Seek back by seekOffsetMs — media-chrome's clamp, computed from reported state.
* The magnitude rides beside the icon (the web's −10/+10) so the offset is visible,
* not just spoken in the a11y label. */
function AudioPlayerSeekBackwardButton({ seekOffsetMs = 10_000, className }: AudioPlayerSeekButtonProps) {
const { positionMs, durationMs, seek } = useAudioPlayer();
const label = `Back ${Math.round(seekOffsetMs / 1000)} seconds`;
const seconds = Math.round(seekOffsetMs / 1000);
return (
<Button
variant="ghost"
// Not size="icon" — the magnitude label widens the pill past a square.
disabled={!seek}
onPress={seek ? () => seek(seekTarget(positionMs, -seekOffsetMs, durationMs)) : undefined}
accessibilityLabel={label}
accessibilityState={{ disabled: !seek }}
hitSlop={{ top: 2, bottom: 2, left: 2, right: 2 }}
className={cn('h-10 px-3', className)}
>
<Icon as={RotateCcwIcon} size={18} className="text-muted-foreground" />
<Text className="text-sm font-medium tabular-nums text-muted-foreground">{seconds}</Text>
</Button>
);
}
/** Seek forward by seekOffsetMs — clamps to the duration when one is known. The
* magnitude rides beside the icon, mirroring SeekBackward. */
function AudioPlayerSeekForwardButton({ seekOffsetMs = 10_000, className }: AudioPlayerSeekButtonProps) {
const { positionMs, durationMs, seek } = useAudioPlayer();
const label = `Forward ${Math.round(seekOffsetMs / 1000)} seconds`;
const seconds = Math.round(seekOffsetMs / 1000);
return (
<Button
variant="ghost"
disabled={!seek}
onPress={seek ? () => seek(seekTarget(positionMs, seekOffsetMs, durationMs)) : undefined}
accessibilityLabel={label}
accessibilityState={{ disabled: !seek }}
hitSlop={{ top: 2, bottom: 2, left: 2, right: 2 }}
className={cn('h-10 px-3', className)}
>
<Icon as={RotateCwIcon} size={18} className="text-muted-foreground" />
<Text className="text-sm font-medium tabular-nums text-muted-foreground">{seconds}</Text>
</Button>
);
}
type AudioPlayerTimeDisplayProps = {
className?: string;
};
/** Elapsed time — tabular so digits do not jitter the row as the clock ticks. */
function AudioPlayerTimeDisplay({ className }: AudioPlayerTimeDisplayProps) {
const { positionMs } = useAudioPlayer();
return (
<Text
accessibilityLabel={`Elapsed ${formatPlaybackTime(positionMs)}`}
className={cn('text-xs tabular-nums text-muted-foreground', className)}
>
{formatPlaybackTime(positionMs)}
</Text>
);
}
type AudioPlayerDurationDisplayProps = {
className?: string;
};
/** Total duration — 0:00 until the engine knows the length. */
function AudioPlayerDurationDisplay({ className }: AudioPlayerDurationDisplayProps) {
const { durationMs } = useAudioPlayer();
return (
<Text
accessibilityLabel={`Duration ${formatPlaybackTime(durationMs)}`}
className={cn('text-xs tabular-nums text-muted-foreground', className)}
>
{formatPlaybackTime(durationMs)}
</Text>
);
}
type AudioPlayerTimeRangeProps = {
className?: string;
};
/**
* The scrubber — the registered slider atom wearing the playback contract. The atom's
* value is normalized 0..1 (the wave-4 contract); the primitive reports its drags back
* in its own 0..100 scale as an ARRAY, so this part converts before seeking. Unwired
* onSeek → the slider renders its position, disabled — visible progress, no fake drag.
*/
function AudioPlayerTimeRange({ className }: AudioPlayerTimeRangeProps) {
const { positionMs, durationMs, seek } = useAudioPlayer();
return (
<Slider
value={playbackProgress(positionMs, durationMs)}
onValueChange={
seek
? (values) => {
const next = Array.isArray(values) ? values[0] : values;
const pct = clampVolume(Number(next) / 100);
seek(pct * durationMs);
}
: undefined
}
disabled={!seek}
accessibilityLabel="Audio position"
className={cn(className)}
/>
);
}
type AudioPlayerMuteButtonProps = {
className?: string;
};
/** Mute toggle — the reported muted state with the web's volume-mark ladder. */
function AudioPlayerMuteButton({ className }: AudioPlayerMuteButtonProps) {
const { muted, volume, toggleMute } = useAudioPlayer();
const icon = muted ? VolumeXIcon : volume > 0.5 ? Volume2Icon : Volume1Icon;
const label = muted ? 'Unmute audio' : 'Mute audio';
return (
<Button
variant="ghost"
size="icon"
disabled={!toggleMute}
onPress={toggleMute}
accessibilityLabel={label}
accessibilityState={{ disabled: !toggleMute, checked: muted }}
hitSlop={{ top: 2, bottom: 2, left: 2, right: 2 }}
className={cn(className)}
>
<Icon as={icon} size={16} className="text-muted-foreground" />
</Button>
);
}
type AudioPlayerVolumeRangeProps = {
className?: string;
};
/** The volume slider — 0..1 caller seam over the same slider atom as the scrubber. */
function AudioPlayerVolumeRange({ className }: AudioPlayerVolumeRangeProps) {
const { volume, changeVolume } = useAudioPlayer();
return (
<Slider
value={volume}
onValueChange={
changeVolume
? (values) => {
const next = Array.isArray(values) ? values[0] : values;
changeVolume(clampVolume(Number(next) / 100));
}
: undefined
}
disabled={!changeVolume}
accessibilityLabel="Volume"
className={cn('max-w-xs', className)}
/>
);
}
export {
AudioPlayer,
AudioPlayerControlBar,
AudioPlayerDurationDisplay,
AudioPlayerMuteButton,
AudioPlayerPlayButton,
AudioPlayerSeekBackwardButton,
AudioPlayerSeekForwardButton,
AudioPlayerTimeDisplay,
AudioPlayerTimeRange,
AudioPlayerVolumeRange,
useAudioPlayer,
};
/**
* AudioPlayer — pure logic. Zero react-native imports, so the Vitest tier owns it (see
* the header of sources.logic.ts for why component files themselves cannot load under
* Node).
*
* THE PRD VERDICT IS NATIVE-SUBSTITUTE: "Built on the HTML audio element. Replaced by a
* native audio module; transport controls and progress bar keep the same product
* surface." The substitution lands in the CONTRACT, not in a dependency — the audio
* module is the CALLER'S (expo-audio, a native player, a howler on web), the component
* renders transport controls and progress over state the caller reports back. That is
* the speech-input precedent (its verdict also demanded native modules; the port ships
* the caller seam), and unlike web-preview — whose verdict explicitly prescribed
* react-native-webview as a shipped peer dependency — nothing here names a module, so
* nothing here is a STOP.
*
* Time units are MILLISECONDS at this component's surface (the RN-native clock the
* caller's engine reports), and SECONDS only inside seekOffsetMs naming and the
* upstream part names (seekOffset 10, currentTime). The conversions live here so the
* component and its tests share one arithmetic.
*/
/**
* The transport clock: m:ss under an hour, h:mm:ss past it — the media element's own
* display convention the web original renders via media-chrome's time displays.
* Minutes are unpadded ("0:05", never "00:05"); seconds and minutes past the hour are
* padded. Garbage in, "0:00" out — a missing duration must never render as NaN.
*/
export function formatPlaybackTime(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '0:00';
const totalSeconds = Math.floor(ms / 1000);
const seconds = totalSeconds % 60;
const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
const ss = String(seconds).padStart(2, '0');
if (hours === 0) return `${minutes}:${ss}`;
return `${hours}:${String(minutes).padStart(2, '0')}:${ss}`;
}
/**
* The target the seek buttons land on: position + delta, clamped into [0, duration]
* exactly as media-chrome clamps a seek past either end. Without a known duration the
* forward clamp is unbounded — a live stream has no end to clamp to, and inventing one
* would be the lie. No-throw on garbage input: both anchors fall back to 0.
*/
export function seekTarget(positionMs: number, deltaMs: number, durationMs?: number): number {
const from = Number.isFinite(positionMs) ? Math.max(0, positionMs) : 0;
const delta = Number.isFinite(deltaMs) ? deltaMs : 0;
const target = from + delta;
if (durationMs === undefined || !Number.isFinite(durationMs)) return Math.max(0, target);
return Math.min(Math.max(0, target), Math.max(0, durationMs));
}
/**
* The scrubber's normalized value for the slider atom — the wave-4 slider value
* contract is 0..1, and playback progress is where that contract earns its keep.
* No duration (0, negative, NaN) means nothing has loaded: progress 0, never NaN —
* a NaN width/percent renders as nothing with no error.
*/
export function playbackProgress(positionMs: number, durationMs: number): number {
if (!Number.isFinite(positionMs) || !Number.isFinite(durationMs) || durationMs <= 0) return 0;
const pct = positionMs / durationMs;
return Math.min(1, Math.max(0, pct));
}
/** Volume lives in 0..1; callers report engine sliders and OS faders — clamp, no-throw. */
export function clampVolume(volume: number): number {
if (!Number.isFinite(volume)) return 0;
return Math.min(1, Math.max(0, volume));
}
Props
| Name | Type | Default | Description |
|---|---|---|---|
| isPlaying | boolean | undefined | false | The caller's engine state, reported back on every change. |
| positionMs | number | undefined | 0 | — |
| durationMs | number | undefined | 0 | 0 until the engine knows the length — time displays hold at 0:00, never NaN. |
| muted | boolean | undefined | false | — |
| volume | number | undefined | 1 | 0..1. Reported volumes are clamped; garbage reads as muted-silent. |
| onPlay | (() => void) | undefined | — | — |
| onPause | (() => void) | undefined | — | — |
| onSeek | ((positionMs: number) => void) | undefined | — | Absolute seek in ms — from the scrubber AND the ±offset buttons (they clamp). |
| onMuteToggle | (() => void) | undefined | — | — |
| onVolumeChange | ((volume: number) => void) | undefined | — | — |
| children | React.ReactNode | — | Omit for the default three-row layout; supply for the upstream composition. |
| className | string | undefined | — | — |
Theming
Themed via text-foreground, text-muted-foreground.
Usage
() => (
<AudioPlayer isPlaying={false} positionMs={41_200} durationMs={187_000} volume={0.8}>
<AudioPlayerControlBar>
<AudioPlayerSeekBackwardButton />
<AudioPlayerPlayButton />
<AudioPlayerSeekForwardButton />
<AudioPlayerMuteButton />
<AudioPlayerTimeDisplay />
<AudioPlayerDurationDisplay />
</AudioPlayerControlBar>
<AudioPlayerTimeRange />
<AudioPlayerVolumeRange />
</AudioPlayer>
)