# Vinyl Player

> A music card whose cover is a record half out of its sleeve, spinning while it plays and coasting to a stop.

- Category: Card
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add vinyl-player
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/vinyl-player.json
```

## Usage

```tsx
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`.

## VinylPlayerProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `tracks` | `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>` |  |  |

## 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.

## Performance

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.

## Source

`components/kinetik/vinyl-player.tsx`

```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 },
});
```
