Vinyl Player
A music card whose cover is a record half out of its sleeve, spinning while it plays and coasting to a stop.
import { useEffect, useRef, useState } from 'react';
import {
Image,
Pressable,
StyleSheet,
Text,
View,
type GestureResponderEvent,
type ImageSourcePropType,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
Easing,
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
import Svg, { Path } from 'react-native-svg';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type VinylTrack = {
id: string;
title: string;
artist: string;
cover: ImageSourcePropType;
/** Length in seconds. */
duration: number;
};
export type VinylPlayerProps = {
tracks: readonly VinylTrack[];
/** Start playing at once. Defaults to false. */
autoPlay?: boolean;
onTrackChange?: (track: VinylTrack) => void;
onPlayingChange?: (playing: boolean) => void;
/** Called when the mute button is toggled. Without it there is no mute button. */
onMutedChange?: (muted: boolean) => void;
/** Colour of the progress. Defaults to the theme azure. */
accent?: string;
/** A tick on each control. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
// Player icons from Tabler Icons (MIT, https://tabler.io/icons), via iconstack.io.
const ICONS = {
play: 'M7 4v16l13 -8z',
pause:
'M6 6a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z M14 6a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z',
next: 'M3 5v14l8 -7z M14 5v14l8 -7z',
prev: 'M21 5v14l-8 -7z M10 5v14l-8 -7z',
volume:
'M15 8a5 5 0 0 1 0 8 M17.7 5a9 9 0 0 1 0 14 M6 15h-2a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h2l3.5 -4.5a.8 .8 0 0 1 1.5 .5v14a.8 .8 0 0 1 -1.5 .5l-3.5 -4.5',
muted:
'M6 15h-2a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h2l3.5 -4.5a.8 .8 0 0 1 1.5 .5v14a.8 .8 0 0 1 -1.5 .5l-3.5 -4.5 M16 10l4 4m0 -4l-4 4',
};
const DISC = 132;
/** One full turn of the record, in ms. */
const TURN = 6000;
/**
* Playback state for one track: a 0–1 progress that runs on the UI thread
* while playing, and whole seconds for the readout.
*/
function usePlayback(duration: number, playing: boolean, track: string, onEnd: () => void) {
const progress = useSharedValue(0);
const [elapsed, setElapsed] = useState(0);
const [seeks, setSeeks] = useState(0);
const ended = useRef(onEnd);
useEffect(() => {
ended.current = onEnd;
});
// A new track starts from the top: the readout resets during render, the bar in an effect.
const [current, setCurrent] = useState(track);
if (current !== track) {
setCurrent(track);
setElapsed(0);
}
useEffect(() => {
cancelAnimation(progress);
progress.set(0);
}, [track, progress]);
useEffect(() => {
if (!playing || duration <= 0) {
cancelAnimation(progress);
return;
}
const from = progress.get();
progress.set(
withTiming(1, { duration: Math.max(0, (1 - from) * duration * 1000), easing: Easing.linear }),
);
const id = setInterval(() => {
const p = progress.get();
setElapsed(Math.floor(p * duration));
if (p >= 1) ended.current();
}, 250);
return () => {
clearInterval(id);
cancelAnimation(progress);
};
}, [playing, duration, track, seeks, progress]);
const seek = (share: number) => {
const p = Math.max(0, Math.min(1, share));
cancelAnimation(progress);
progress.set(p);
setElapsed(Math.floor(p * duration));
setSeeks((n) => n + 1);
};
return { progress, elapsed, seek };
}
function mmss(seconds: number) {
const s = Math.max(0, Math.floor(seconds));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
/**
* A light music card with the cover as a record half out of its sleeve: it
* spins while the track plays and coasts to a stop on pause. The pressed
* control flashes a ring, the mute icon pops between states, and a new track
* drops its record in with a little turn.
*/
export function VinylPlayer({
tracks,
autoPlay = false,
onTrackChange,
onPlayingChange,
onMutedChange,
accent,
haptics = true,
style,
}: VinylPlayerProps) {
const { colors, radius, space, font, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const [index, setIndex] = useState(0);
const [playing, setPlaying] = useState(autoPlay);
const [muted, setMuted] = useState(false);
const [barWidth, setBarWidth] = useState(0);
const track = tracks[index];
const tone = accent ?? colors.azure;
const go = (to: number) => {
const next = (to + tracks.length) % tracks.length;
setIndex(next);
const t = tracks[next];
if (t) onTrackChange?.(t);
};
const { progress, elapsed, seek } = usePlayback(
track?.duration ?? 0,
playing,
track?.id ?? '',
() => go(index + 1),
);
// The record keeps its angle between plays, so pausing never snaps it back.
const angle = useSharedValue(0);
useEffect(() => {
if (!playing || reduced) {
const at = angle.get();
cancelAnimation(angle);
// Coast a little further, like a platter slowing down.
if (!reduced && at > 0)
angle.set(withTiming(at + 24, { duration: 700, easing: Easing.out(Easing.quad) }));
return;
}
// Each loop runs a full turn from the current angle, so the repeat never shows a jump.
const from = angle.get() % 360;
angle.set(from);
angle.set(withRepeat(withTiming(from + 360, { duration: TURN, easing: Easing.linear }), -1));
return () => cancelAnimation(angle);
}, [playing, reduced, angle]);
const drop = useSharedValue(1);
useEffect(() => {
if (reduced) return;
drop.set(withSequence(withTiming(0, { duration: 0 }), withSpring(1, spring('smooth'))));
}, [index, reduced, drop]);
const record = useAnimatedStyle(() => ({
opacity: 0.4 + drop.get() * 0.6,
transform: [
{ translateX: (1 - drop.get()) * -24 },
{ rotate: `${angle.get() - (1 - drop.get()) * 60}deg` },
],
}));
// A transform, not a width, so the bar never asks for layout while it plays.
const fill = useAnimatedStyle(() => ({ transform: [{ scaleX: progress.get() }] }));
if (!track) return null;
const press = (fn: () => void) => () => {
haptic('tick');
fn();
};
return (
<View style={[styles.host, style]}>
<View
style={[
styles.card,
{
marginLeft: DISC * 0.5,
paddingLeft: DISC * 0.5 + space[4],
paddingRight: space[4],
paddingVertical: space[4],
borderRadius: radius.xl - 4,
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
boxShadow: shadow.lg,
},
]}
>
<View style={styles.head}>
<View
accessible
accessibilityLabel={`${track.title}, ${track.artist}`}
style={styles.grow}
>
<Text
numberOfLines={1}
style={{ color: colors.text, fontSize: font.size.lg, fontWeight: font.weight.medium }}
>
{track.title}
</Text>
<Text numberOfLines={1} style={{ color: colors.textSubtle, fontSize: font.size.sm }}>
{track.artist}
</Text>
</View>
{onMutedChange ? (
<MuteButton
muted={muted}
onPress={() => {
haptic('tap');
setMuted(!muted);
onMutedChange(!muted);
}}
/>
) : null}
</View>
<Pressable
accessibilityRole="adjustable"
accessibilityLabel="Playback position"
accessibilityValue={{
min: 0,
max: track.duration,
now: elapsed,
text: `${mmss(elapsed)} of ${mmss(track.duration)}`,
}}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={(e) => {
const step = 10 / track.duration;
seek(
elapsed / track.duration + (e.nativeEvent.actionName === 'increment' ? step : -step),
);
}}
onLayout={(e) => setBarWidth(e.nativeEvent.layout.width)}
onPress={(e: GestureResponderEvent) =>
barWidth > 0 && seek(e.nativeEvent.locationX / barWidth)
}
style={[styles.barHit, { marginTop: space[3] - 14 }]}
>
<View style={[styles.bar, { backgroundColor: colors.track }]}>
<Animated.View style={[styles.barFill, { backgroundColor: tone }, fill]} />
</View>
</Pressable>
<View style={[styles.controls, { marginTop: space[2] }]}>
<RingButton
label="Previous"
icon={ICONS.prev}
onPress={press(() => (elapsed > 3 ? seek(0) : go(index - 1)))}
/>
<RingButton
label={playing ? 'Pause' : 'Play'}
icon={playing ? ICONS.pause : ICONS.play}
onPress={press(() => {
setPlaying(!playing);
onPlayingChange?.(!playing);
})}
/>
<RingButton label="Next" icon={ICONS.next} onPress={press(() => go(index + 1))} />
</View>
</View>
<View pointerEvents="none" style={[styles.discSlot, { width: DISC, height: DISC }]}>
<Animated.View
style={[
styles.disc,
{ width: DISC, height: DISC, borderRadius: DISC / 2, boxShadow: shadow.lg },
record,
]}
>
<Image
source={track.cover}
style={StyleSheet.absoluteFill}
resizeMode="cover"
accessibilityIgnoresInvertColors
/>
{/* Grooves and the spindle hole, so the turn reads even on a plain cover. */}
<View
style={[
styles.groove,
{ inset: DISC * 0.16, borderColor: withAlpha(colors.highlight, 0.18) },
]}
/>
<View
style={[
styles.groove,
{ inset: DISC * 0.3, borderColor: withAlpha(colors.highlight, 0.14) },
]}
/>
<View
style={[
styles.hole,
{ backgroundColor: colors.highlight, borderColor: withAlpha(colors.text, 0.2) },
]}
/>
</Animated.View>
</View>
</View>
);
}
/** A round control whose ring flashes out when pressed. */
function RingButton({
label,
icon,
onPress,
}: {
label: string;
icon: string;
onPress: () => void;
}) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const flash = useSharedValue(0);
const ring = useAnimatedStyle(() => ({
opacity: flash.get(),
transform: [{ scale: 0.7 + flash.get() * 0.3 }],
}));
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={() => {
if (!reduced) {
flash.set(withSequence(withTiming(1, timing('quick')), withTiming(0, timing('slow'))));
}
onPress();
}}
style={({ pressed }) => [styles.round, { transform: [{ scale: pressed ? 0.9 : 1 }] }]}
>
<Animated.View
style={[
styles.ring,
{ borderColor: colors.borderStrong, backgroundColor: withAlpha(colors.text, 0.05) },
ring,
]}
/>
<Svg width={22} height={22} viewBox="0 0 24 24">
<Path
d={icon}
fill={colors.text}
stroke={colors.text}
strokeWidth={2}
strokeLinejoin="round"
/>
</Svg>
</Pressable>
);
}
function MuteButton({ muted, onPress }: { muted: boolean; onPress: () => void }) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const pop = useSharedValue(1);
useEffect(() => {
if (!reduced)
pop.set(withSequence(withTiming(0.6, { duration: 0 }), withSpring(1, spring('bouncy'))));
}, [muted, reduced, pop]);
const style = useAnimatedStyle(() => ({ transform: [{ scale: pop.get() }] }));
return (
<Pressable
accessibilityRole="switch"
accessibilityLabel="Mute"
accessibilityState={{ checked: muted }}
onPress={onPress}
style={styles.round}
>
<Animated.View style={style}>
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
<Path
d={muted ? ICONS.muted : ICONS.volume}
stroke={colors.textSubtle}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
host: { justifyContent: 'center' },
card: { borderWidth: StyleSheet.hairlineWidth },
head: { flexDirection: 'row', alignItems: 'flex-start' },
grow: { flex: 1, minWidth: 0, paddingTop: 2 },
// 44 pt to touch, with the extra height taken back so the bar keeps its place.
barHit: { height: 44, marginVertical: -14, justifyContent: 'center' },
bar: { height: 4, borderRadius: 2, overflow: 'hidden' },
barFill: { height: 4, width: '100%', transformOrigin: 'left center' },
controls: { flexDirection: 'row', justifyContent: 'space-between', marginHorizontal: -8 },
round: { width: 48, height: 48, alignItems: 'center', justifyContent: 'center' },
ring: { position: 'absolute', width: 48, height: 48, borderRadius: 24, borderWidth: 2 },
discSlot: { position: 'absolute', left: 0, alignSelf: 'center' },
disc: { overflow: 'hidden', alignItems: 'center', justifyContent: 'center' },
groove: { position: 'absolute', borderRadius: 999, borderWidth: 1 },
hole: { width: 14, height: 14, borderRadius: 7, borderWidth: 1 },
});Installation
npx kinetik-ui add vinyl-playerpnpm dlx kinetik-ui add vinyl-playerbunx kinetik-ui add vinyl-playeryarn dlx kinetik-ui add vinyl-playerThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/vinyl-player.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-svg react-native-worklets - Copy the source files
components/kinetik/vinyl-player.tsx
Usage
import { VinylPlayer } from '@/components/kinetik/vinyl-player';
<VinylPlayer
tracks={queue.map((t) => ({
id: t.id,
title: t.title,
artist: t.artist,
cover: { uri: t.artwork },
duration: t.seconds,
}))}
onPlayingChange={(on) => (on ? player.play() : player.pause())}
onTrackChange={(t) => player.load(t.id)}
onMutedChange={player.setMuted}
/>;When to use
- A mini player with some character: a music app's home, a playlist header, a widget.
When not to use
- As the source of truth for real audio: it keeps its own clock. Keep it in step with your player through the callbacks.
Notes
- The record half sits outside the card, so leave room on the left (about 66 pt).
- It spins while playing (one turn every six seconds) and coasts a little when paused, keeping its angle.
- Previous restarts the track after three seconds; otherwise it goes back a track. The queue wraps around.
- The mute button only shows when you pass
onMutedChange.
Props
VinylPlayerProps
| Prop | Type | Default | Description |
|---|---|---|---|
tracksrequired | readonly VinylTrack[] | — | |
autoPlay | boolean | false | Start playing at once. Defaults to false. |
onTrackChange | (track: VinylTrack) => void | — | |
onPlayingChange | (playing: boolean) => void | — | |
onMutedChange | (muted: boolean) => void | — | Called when the mute button is toggled. Without it there is no mute button. |
accent | string | — | Colour of the progress. Defaults to the theme azure. |
haptics | boolean | true | A tick on each control. Defaults to true. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- While playing, the record turns with one repeating UI-thread timing and the progress with another; the time re-renders four times a second. Paused, nothing animates.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.00× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 171 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The record does not spin or drop in; controls change state without the ring flash or icon pop.
- Screen readers
- Title and artist read as one line; the progress is adjustable in ten-second steps; Mute is a switch; Previous, Play/Pause and Next are buttons.
- Touch target
- Controls and the mute switch are 48 pt; the progress bar's touch area is 44 pt tall.
Source files
components/kinetik/vinyl-player.tsx
import { useEffect, useRef, useState } from 'react';
import {
Image,
Pressable,
StyleSheet,
Text,
View,
type GestureResponderEvent,
type ImageSourcePropType,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
Easing,
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
import Svg, { Path } from 'react-native-svg';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type VinylTrack = {
id: string;
title: string;
artist: string;
cover: ImageSourcePropType;
/** Length in seconds. */
duration: number;
};
export type VinylPlayerProps = {
tracks: readonly VinylTrack[];
/** Start playing at once. Defaults to false. */
autoPlay?: boolean;
onTrackChange?: (track: VinylTrack) => void;
onPlayingChange?: (playing: boolean) => void;
/** Called when the mute button is toggled. Without it there is no mute button. */
onMutedChange?: (muted: boolean) => void;
/** Colour of the progress. Defaults to the theme azure. */
accent?: string;
/** A tick on each control. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
// Player icons from Tabler Icons (MIT, https://tabler.io/icons), via iconstack.io.
const ICONS = {
play: 'M7 4v16l13 -8z',
pause:
'M6 6a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z M14 6a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z',
next: 'M3 5v14l8 -7z M14 5v14l8 -7z',
prev: 'M21 5v14l-8 -7z M10 5v14l-8 -7z',
volume:
'M15 8a5 5 0 0 1 0 8 M17.7 5a9 9 0 0 1 0 14 M6 15h-2a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h2l3.5 -4.5a.8 .8 0 0 1 1.5 .5v14a.8 .8 0 0 1 -1.5 .5l-3.5 -4.5',
muted:
'M6 15h-2a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h2l3.5 -4.5a.8 .8 0 0 1 1.5 .5v14a.8 .8 0 0 1 -1.5 .5l-3.5 -4.5 M16 10l4 4m0 -4l-4 4',
};
const DISC = 132;
/** One full turn of the record, in ms. */
const TURN = 6000;
/**
* Playback state for one track: a 0–1 progress that runs on the UI thread
* while playing, and whole seconds for the readout.
*/
function usePlayback(duration: number, playing: boolean, track: string, onEnd: () => void) {
const progress = useSharedValue(0);
const [elapsed, setElapsed] = useState(0);
const [seeks, setSeeks] = useState(0);
const ended = useRef(onEnd);
useEffect(() => {
ended.current = onEnd;
});
// A new track starts from the top: the readout resets during render, the bar in an effect.
const [current, setCurrent] = useState(track);
if (current !== track) {
setCurrent(track);
setElapsed(0);
}
useEffect(() => {
cancelAnimation(progress);
progress.set(0);
}, [track, progress]);
useEffect(() => {
if (!playing || duration <= 0) {
cancelAnimation(progress);
return;
}
const from = progress.get();
progress.set(
withTiming(1, { duration: Math.max(0, (1 - from) * duration * 1000), easing: Easing.linear }),
);
const id = setInterval(() => {
const p = progress.get();
setElapsed(Math.floor(p * duration));
if (p >= 1) ended.current();
}, 250);
return () => {
clearInterval(id);
cancelAnimation(progress);
};
}, [playing, duration, track, seeks, progress]);
const seek = (share: number) => {
const p = Math.max(0, Math.min(1, share));
cancelAnimation(progress);
progress.set(p);
setElapsed(Math.floor(p * duration));
setSeeks((n) => n + 1);
};
return { progress, elapsed, seek };
}
function mmss(seconds: number) {
const s = Math.max(0, Math.floor(seconds));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
/**
* A light music card with the cover as a record half out of its sleeve: it
* spins while the track plays and coasts to a stop on pause. The pressed
* control flashes a ring, the mute icon pops between states, and a new track
* drops its record in with a little turn.
*/
export function VinylPlayer({
tracks,
autoPlay = false,
onTrackChange,
onPlayingChange,
onMutedChange,
accent,
haptics = true,
style,
}: VinylPlayerProps) {
const { colors, radius, space, font, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const [index, setIndex] = useState(0);
const [playing, setPlaying] = useState(autoPlay);
const [muted, setMuted] = useState(false);
const [barWidth, setBarWidth] = useState(0);
const track = tracks[index];
const tone = accent ?? colors.azure;
const go = (to: number) => {
const next = (to + tracks.length) % tracks.length;
setIndex(next);
const t = tracks[next];
if (t) onTrackChange?.(t);
};
const { progress, elapsed, seek } = usePlayback(
track?.duration ?? 0,
playing,
track?.id ?? '',
() => go(index + 1),
);
// The record keeps its angle between plays, so pausing never snaps it back.
const angle = useSharedValue(0);
useEffect(() => {
if (!playing || reduced) {
const at = angle.get();
cancelAnimation(angle);
// Coast a little further, like a platter slowing down.
if (!reduced && at > 0)
angle.set(withTiming(at + 24, { duration: 700, easing: Easing.out(Easing.quad) }));
return;
}
// Each loop runs a full turn from the current angle, so the repeat never shows a jump.
const from = angle.get() % 360;
angle.set(from);
angle.set(withRepeat(withTiming(from + 360, { duration: TURN, easing: Easing.linear }), -1));
return () => cancelAnimation(angle);
}, [playing, reduced, angle]);
const drop = useSharedValue(1);
useEffect(() => {
if (reduced) return;
drop.set(withSequence(withTiming(0, { duration: 0 }), withSpring(1, spring('smooth'))));
}, [index, reduced, drop]);
const record = useAnimatedStyle(() => ({
opacity: 0.4 + drop.get() * 0.6,
transform: [
{ translateX: (1 - drop.get()) * -24 },
{ rotate: `${angle.get() - (1 - drop.get()) * 60}deg` },
],
}));
// A transform, not a width, so the bar never asks for layout while it plays.
const fill = useAnimatedStyle(() => ({ transform: [{ scaleX: progress.get() }] }));
if (!track) return null;
const press = (fn: () => void) => () => {
haptic('tick');
fn();
};
return (
<View style={[styles.host, style]}>
<View
style={[
styles.card,
{
marginLeft: DISC * 0.5,
paddingLeft: DISC * 0.5 + space[4],
paddingRight: space[4],
paddingVertical: space[4],
borderRadius: radius.xl - 4,
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
boxShadow: shadow.lg,
},
]}
>
<View style={styles.head}>
<View
accessible
accessibilityLabel={`${track.title}, ${track.artist}`}
style={styles.grow}
>
<Text
numberOfLines={1}
style={{ color: colors.text, fontSize: font.size.lg, fontWeight: font.weight.medium }}
>
{track.title}
</Text>
<Text numberOfLines={1} style={{ color: colors.textSubtle, fontSize: font.size.sm }}>
{track.artist}
</Text>
</View>
{onMutedChange ? (
<MuteButton
muted={muted}
onPress={() => {
haptic('tap');
setMuted(!muted);
onMutedChange(!muted);
}}
/>
) : null}
</View>
<Pressable
accessibilityRole="adjustable"
accessibilityLabel="Playback position"
accessibilityValue={{
min: 0,
max: track.duration,
now: elapsed,
text: `${mmss(elapsed)} of ${mmss(track.duration)}`,
}}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={(e) => {
const step = 10 / track.duration;
seek(
elapsed / track.duration + (e.nativeEvent.actionName === 'increment' ? step : -step),
);
}}
onLayout={(e) => setBarWidth(e.nativeEvent.layout.width)}
onPress={(e: GestureResponderEvent) =>
barWidth > 0 && seek(e.nativeEvent.locationX / barWidth)
}
style={[styles.barHit, { marginTop: space[3] - 14 }]}
>
<View style={[styles.bar, { backgroundColor: colors.track }]}>
<Animated.View style={[styles.barFill, { backgroundColor: tone }, fill]} />
</View>
</Pressable>
<View style={[styles.controls, { marginTop: space[2] }]}>
<RingButton
label="Previous"
icon={ICONS.prev}
onPress={press(() => (elapsed > 3 ? seek(0) : go(index - 1)))}
/>
<RingButton
label={playing ? 'Pause' : 'Play'}
icon={playing ? ICONS.pause : ICONS.play}
onPress={press(() => {
setPlaying(!playing);
onPlayingChange?.(!playing);
})}
/>
<RingButton label="Next" icon={ICONS.next} onPress={press(() => go(index + 1))} />
</View>
</View>
<View pointerEvents="none" style={[styles.discSlot, { width: DISC, height: DISC }]}>
<Animated.View
style={[
styles.disc,
{ width: DISC, height: DISC, borderRadius: DISC / 2, boxShadow: shadow.lg },
record,
]}
>
<Image
source={track.cover}
style={StyleSheet.absoluteFill}
resizeMode="cover"
accessibilityIgnoresInvertColors
/>
{/* Grooves and the spindle hole, so the turn reads even on a plain cover. */}
<View
style={[
styles.groove,
{ inset: DISC * 0.16, borderColor: withAlpha(colors.highlight, 0.18) },
]}
/>
<View
style={[
styles.groove,
{ inset: DISC * 0.3, borderColor: withAlpha(colors.highlight, 0.14) },
]}
/>
<View
style={[
styles.hole,
{ backgroundColor: colors.highlight, borderColor: withAlpha(colors.text, 0.2) },
]}
/>
</Animated.View>
</View>
</View>
);
}
/** A round control whose ring flashes out when pressed. */
function RingButton({
label,
icon,
onPress,
}: {
label: string;
icon: string;
onPress: () => void;
}) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const flash = useSharedValue(0);
const ring = useAnimatedStyle(() => ({
opacity: flash.get(),
transform: [{ scale: 0.7 + flash.get() * 0.3 }],
}));
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={() => {
if (!reduced) {
flash.set(withSequence(withTiming(1, timing('quick')), withTiming(0, timing('slow'))));
}
onPress();
}}
style={({ pressed }) => [styles.round, { transform: [{ scale: pressed ? 0.9 : 1 }] }]}
>
<Animated.View
style={[
styles.ring,
{ borderColor: colors.borderStrong, backgroundColor: withAlpha(colors.text, 0.05) },
ring,
]}
/>
<Svg width={22} height={22} viewBox="0 0 24 24">
<Path
d={icon}
fill={colors.text}
stroke={colors.text}
strokeWidth={2}
strokeLinejoin="round"
/>
</Svg>
</Pressable>
);
}
function MuteButton({ muted, onPress }: { muted: boolean; onPress: () => void }) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const pop = useSharedValue(1);
useEffect(() => {
if (!reduced)
pop.set(withSequence(withTiming(0.6, { duration: 0 }), withSpring(1, spring('bouncy'))));
}, [muted, reduced, pop]);
const style = useAnimatedStyle(() => ({ transform: [{ scale: pop.get() }] }));
return (
<Pressable
accessibilityRole="switch"
accessibilityLabel="Mute"
accessibilityState={{ checked: muted }}
onPress={onPress}
style={styles.round}
>
<Animated.View style={style}>
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
<Path
d={muted ? ICONS.muted : ICONS.volume}
stroke={colors.textSubtle}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
host: { justifyContent: 'center' },
card: { borderWidth: StyleSheet.hairlineWidth },
head: { flexDirection: 'row', alignItems: 'flex-start' },
grow: { flex: 1, minWidth: 0, paddingTop: 2 },
// 44 pt to touch, with the extra height taken back so the bar keeps its place.
barHit: { height: 44, marginVertical: -14, justifyContent: 'center' },
bar: { height: 4, borderRadius: 2, overflow: 'hidden' },
barFill: { height: 4, width: '100%', transformOrigin: 'left center' },
controls: { flexDirection: 'row', justifyContent: 'space-between', marginHorizontal: -8 },
round: { width: 48, height: 48, alignItems: 'center', justifyContent: 'center' },
ring: { position: 'absolute', width: 48, height: 48, borderRadius: 24, borderWidth: 2 },
discSlot: { position: 'absolute', left: 0, alignSelf: 'center' },
disc: { overflow: 'hidden', alignItems: 'center', justifyContent: 'center' },
groove: { position: 'absolute', borderRadius: 999, borderWidth: 1 },
hole: { width: 14, height: 14, borderRadius: 7, borderWidth: 1 },
});