{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "player-sheet",
  "type": "registry:block",
  "title": "Player Sheet",
  "description": "A music library whose mini player opens into a full player: tilting cover, elastic scrubber.",
  "categories": [
    "screen"
  ],
  "dependencies": [
    "react-native-safe-area-context"
  ],
  "registryDependencies": [
    "https://kinetik-ui.dev/r/kinetik-tokens.json",
    "https://kinetik-ui.dev/r/elastic-slider.json",
    "https://kinetik-ui.dev/r/morph-sheet.json",
    "https://kinetik-ui.dev/r/press-jelly.json",
    "https://kinetik-ui.dev/r/pulse-toggle.json",
    "https://kinetik-ui.dev/r/scramble-text.json",
    "https://kinetik-ui.dev/r/tilt-deck.json"
  ],
  "files": [
    {
      "path": "src/components/kinetik/screens/player-sheet.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/screens/player-sheet.tsx",
      "content": "import { useEffect, useState } from 'react';\nimport { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';\nimport { useSafeAreaInsets } from 'react-native-safe-area-context';\n\nimport { ElasticSlider } from '../elastic-slider';\nimport { MorphSheet } from '../morph-sheet';\nimport { PressJelly } from '../press-jelly';\nimport { PulseToggle } from '../pulse-toggle';\nimport { ScrambleText } from '../scramble-text';\nimport { TiltDeck, TiltLayer } from '../tilt-deck';\nimport { gradient } from '../../../lib/kinetik/tokens/gradient';\nimport { useKinetikTheme } from '../../../lib/kinetik/tokens/theme';\nimport { minTouchTarget, withAlpha } from '../../../lib/kinetik/tokens/tokens';\n\nexport type Track = {\n  id: string;\n  title: string;\n  artist: string;\n  /** Length in seconds. */\n  duration: number;\n  /** Cover tone. Defaults to the theme accents. */\n  tone?: 'ember' | 'ice';\n};\n\nexport type PlayerSheetProps = {\n  tracks?: readonly Track[];\n  /** Title above the list. */\n  title?: string;\n  /** Called with the track that should start playing. Wire this to your audio player. */\n  onPlay?: (track: Track) => void;\n  onPause?: () => void;\n  /** Called when the user scrubs, in seconds. */\n  onSeek?: (seconds: number) => void;\n  /**\n   * Playback position in seconds, from your audio player. Without it the\n   * screen advances its own clock while playing, which is enough for a demo.\n   */\n  position?: number;\n};\n\nconst DEFAULT_TRACKS: Track[] = [\n  { id: '1', title: 'Glass Harbour', artist: 'Lumen Fields', duration: 214, tone: 'ice' },\n  { id: '2', title: 'Low Tide', artist: 'Lumen Fields', duration: 187, tone: 'ember' },\n  { id: '3', title: 'Northbound', artist: 'Kestrel Sound', duration: 242, tone: 'ice' },\n  { id: '4', title: 'Static Bloom', artist: 'Paper Moons', duration: 198, tone: 'ember' },\n  { id: '5', title: 'Afterimage', artist: 'Kestrel Sound', duration: 225, tone: 'ice' },\n  { id: '6', title: 'Long Exposure', artist: 'Paper Moons', duration: 263, tone: 'ember' },\n];\n\nconst clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;\n\n/**\n * A music library with a mini player that opens into a full player sheet:\n * a tilting cover, a scrambling title on track change and an elastic scrubber.\n */\nexport function PlayerSheet({\n  tracks = DEFAULT_TRACKS,\n  title = 'Library',\n  onPlay,\n  onPause,\n  onSeek,\n  position: positionProp,\n}: PlayerSheetProps) {\n  const { colors, space, font, radius } = useKinetikTheme();\n  const insets = useSafeAreaInsets();\n  // Track and position change together (auto-advance), so they share one state.\n  const [now, setNow] = useState({ index: 0, position: 0 });\n  const [playing, setPlaying] = useState(false);\n  const [open, setOpen] = useState(false);\n  const [shuffle, setShuffle] = useState(false);\n  const { index } = now;\n  const position = positionProp ?? now.position;\n  const track = tracks[index];\n\n  // Stand-in clock for when no audio player drives `position`.\n  useEffect(() => {\n    if (!playing || positionProp !== undefined) return;\n    const t = setInterval(() => {\n      setNow((n) => {\n        const length = tracks[n.index]?.duration ?? 0;\n        return n.position + 1 < length\n          ? { ...n, position: n.position + 1 }\n          : { index: (n.index + 1) % tracks.length, position: 0 };\n      });\n    }, 1000);\n    return () => clearInterval(t);\n  }, [playing, positionProp, tracks]);\n\n  if (!track) return null;\n\n  const play = (i: number) => {\n    const next = tracks[i];\n    if (!next) return;\n    setNow({ index: i, position: 0 });\n    setPlaying(true);\n    onPlay?.(next);\n  };\n  const toggle = () => {\n    if (playing) {\n      setPlaying(false);\n      onPause?.();\n    } else {\n      setPlaying(true);\n      onPlay?.(track);\n    }\n  };\n  const step = (dir: 1 | -1) => {\n    if (dir === -1 && position > 3) {\n      setNow((n) => ({ ...n, position: 0 }));\n      onSeek?.(0);\n      return;\n    }\n    const pick = shuffle\n      ? (index + 1 + Math.floor(Math.random() * (tracks.length - 1))) % tracks.length\n      : (index + dir + tracks.length) % tracks.length;\n    play(pick);\n  };\n\n  const sheet = (\n    <View style={{ paddingHorizontal: space[6], gap: space[6], paddingBottom: space[6] }}>\n      <Cover track={track} large />\n      <View style={{ gap: space[1] }}>\n        <ScrambleText\n          text={track.title}\n          replayKey={track.id}\n          animateOnMount={false}\n          style={{ color: colors.text, fontSize: font.size['2xl'], fontWeight: font.weight.heavy }}\n        />\n        <Text style={{ color: colors.textMuted, fontSize: font.size.md }}>{track.artist}</Text>\n      </View>\n      <View style={{ gap: space[1] }}>\n        <ElasticSlider\n          label=\"Position\"\n          value={Math.min(position, track.duration)}\n          min={0}\n          max={track.duration}\n          step={1}\n          showLabel={false}\n          formatValue={clock}\n          onSlidingComplete={(v) => {\n            setNow((n) => ({ ...n, position: v }));\n            onSeek?.(v);\n          }}\n        />\n        <View style={styles.times}>\n          <Text style={[styles.time, { color: colors.textSubtle, fontSize: font.size.xs }]}>\n            {clock(position)}\n          </Text>\n          <Text style={[styles.time, { color: colors.textSubtle, fontSize: font.size.xs }]}>\n            −{clock(Math.max(0, track.duration - position))}\n          </Text>\n        </View>\n      </View>\n      <View style={[styles.transport, { gap: space[3] }]}>\n        <PressJelly variant=\"surface\" accessibilityLabel=\"Previous track\" onPress={() => step(-1)}>\n          {'\\u23EE\\uFE0E'}\n        </PressJelly>\n        <View style={styles.fill}>\n          <PressJelly\n            block\n            size=\"lg\"\n            accessibilityLabel={playing ? 'Pause' : 'Play'}\n            onPress={toggle}\n          >\n            {playing ? 'Pause' : 'Play'}\n          </PressJelly>\n        </View>\n        <PressJelly variant=\"surface\" accessibilityLabel=\"Next track\" onPress={() => step(1)}>\n          {'\\u23ED\\uFE0E'}\n        </PressJelly>\n      </View>\n      <PulseToggle\n        label=\"Shuffle\"\n        description=\"Play the library in a random order\"\n        value={shuffle}\n        onValueChange={setShuffle}\n      />\n    </View>\n  );\n\n  return (\n    <MorphSheet\n      open={open}\n      onOpenChange={setOpen}\n      snapPoints={[0.62, 0.94]}\n      initialSnap={1}\n      accessibilityLabel=\"Now playing\"\n      sheet={sheet}\n    >\n      <View style={[styles.fill, { backgroundColor: colors.background }]}>\n        <ScrollView\n          contentContainerStyle={{\n            paddingTop: insets.top + space[6],\n            paddingHorizontal: space[5],\n            paddingBottom: 96 + insets.bottom,\n            gap: space[1],\n          }}\n        >\n          <Text\n            accessibilityRole=\"header\"\n            style={{\n              color: colors.text,\n              fontSize: font.size['3xl'],\n              fontWeight: font.weight.heavy,\n              letterSpacing: -1,\n              marginBottom: space[4],\n            }}\n          >\n            {title}\n          </Text>\n          {tracks.map((t, i) => {\n            const current = i === index;\n            return (\n              <Pressable\n                key={t.id}\n                accessibilityRole=\"button\"\n                accessibilityLabel={`${t.title} by ${t.artist}, ${clock(t.duration)}`}\n                accessibilityState={{ selected: current }}\n                onPress={() => play(i)}\n                style={({ pressed }) => [\n                  styles.row,\n                  {\n                    gap: space[3],\n                    borderRadius: radius.md,\n                    paddingHorizontal: space[2],\n                    backgroundColor: pressed ? colors.surfaceSunken : 'transparent',\n                  },\n                ]}\n              >\n                <Cover track={t} />\n                <View style={styles.fill}>\n                  <Text\n                    numberOfLines={1}\n                    style={{\n                      color: current ? colors.ember : colors.text,\n                      fontSize: font.size.md,\n                      fontWeight: font.weight.semibold,\n                    }}\n                  >\n                    {t.title}\n                  </Text>\n                  <Text style={{ color: colors.textMuted, fontSize: font.size.sm }}>\n                    {t.artist}\n                  </Text>\n                </View>\n                <Text style={[styles.time, { color: colors.textSubtle, fontSize: font.size.sm }]}>\n                  {clock(t.duration)}\n                </Text>\n              </Pressable>\n            );\n          })}\n        </ScrollView>\n\n        <Pressable\n          accessibilityRole=\"button\"\n          accessibilityLabel={`Now playing: ${track.title} by ${track.artist}`}\n          accessibilityHint=\"Opens the player\"\n          onPress={() => setOpen(true)}\n          style={[\n            styles.mini,\n            {\n              bottom: insets.bottom + space[3],\n              left: space[3],\n              right: space[3],\n              gap: space[3],\n              padding: space[2],\n              paddingRight: space[3],\n              borderRadius: radius.lg,\n              backgroundColor: colors.surfaceRaised,\n              borderColor: colors.border,\n              boxShadow: `0px 12px 32px ${withAlpha(colors.surfaceSunken, 0.6)}`,\n            },\n          ]}\n        >\n          <Cover track={track} />\n          <View style={styles.fill}>\n            <Text\n              numberOfLines={1}\n              style={{\n                color: colors.text,\n                fontSize: font.size.md,\n                fontWeight: font.weight.semibold,\n              }}\n            >\n              {track.title}\n            </Text>\n            <Text numberOfLines={1} style={{ color: colors.textMuted, fontSize: font.size.sm }}>\n              {track.artist}\n            </Text>\n          </View>\n          <Pressable\n            accessibilityRole=\"button\"\n            accessibilityLabel={playing ? 'Pause' : 'Play'}\n            onPress={toggle}\n            hitSlop={6}\n            style={[\n              styles.miniButton,\n              { backgroundColor: colors.ember, borderRadius: radius.pill },\n            ]}\n          >\n            <Text style={{ color: colors.onEmber, fontSize: font.size.md, fontWeight: '900' }}>\n              {playing ? '\\u275A\\u275A' : '\\u25B6\\uFE0E'}\n            </Text>\n          </Pressable>\n          {/* Progress hairline along the bottom edge. */}\n          <View style={[styles.progressTrack, { backgroundColor: colors.track }]}>\n            <View\n              style={{\n                width: `${Math.min(100, (position / track.duration) * 100)}%`,\n                height: '100%',\n                backgroundColor: colors.ember,\n              }}\n            />\n          </View>\n        </Pressable>\n      </View>\n    </MorphSheet>\n  );\n}\n\nfunction Cover({ track, large = false }: { track: Track; large?: boolean }) {\n  const { colors, radius } = useKinetikTheme();\n  const tone = track.tone === 'ember' ? colors.ember : colors.ice;\n  const other = track.tone === 'ember' ? colors.ice : colors.ember;\n  const art = (\n    <View\n      style={[\n        large ? styles.coverLarge : styles.coverSmall,\n        { borderRadius: large ? 0 : radius.sm, overflow: 'hidden' },\n        gradient(\n          `linear-gradient(150deg, ${tone} 0%, ${withAlpha(other, 0.45)} 55%, ${colors.surfaceSunken} 100%)`,\n        ),\n      ]}\n    >\n      {large ? (\n        <>\n          <TiltLayer\n            depth={-0.5}\n            style={[styles.orb, { backgroundColor: withAlpha(colors.highlight, 0.22) }]}\n          />\n          <TiltLayer\n            depth={0.8}\n            style={[styles.ridge, { backgroundColor: withAlpha(colors.surfaceSunken, 0.7) }]}\n          />\n        </>\n      ) : null}\n    </View>\n  );\n  if (!large) return art;\n  return (\n    <TiltDeck accessibilityLabel={`${track.title} cover`} contentStyle={{ padding: 0 }}>\n      {art}\n    </TiltDeck>\n  );\n}\n\nconst styles = StyleSheet.create({\n  fill: { flex: 1 },\n  row: { flexDirection: 'row', alignItems: 'center', minHeight: 64 },\n  time: { fontVariant: ['tabular-nums'] },\n  times: { flexDirection: 'row', justifyContent: 'space-between' },\n  transport: { flexDirection: 'row', alignItems: 'center' },\n  mini: {\n    position: 'absolute',\n    flexDirection: 'row',\n    alignItems: 'center',\n    borderWidth: StyleSheet.hairlineWidth,\n    overflow: 'hidden',\n  },\n  miniButton: {\n    width: minTouchTarget,\n    height: minTouchTarget,\n    alignItems: 'center',\n    justifyContent: 'center',\n  },\n  progressTrack: { position: 'absolute', left: 0, right: 0, bottom: 0, height: 2 },\n  coverSmall: { width: 48, height: 48 },\n  coverLarge: { width: '100%', aspectRatio: 1 },\n  orb: { position: 'absolute', width: 120, height: 120, borderRadius: 60, top: '18%', left: '16%' },\n  ridge: {\n    position: 'absolute',\n    width: 400,\n    height: 400,\n    borderRadius: 200,\n    bottom: -300,\n    left: -40,\n    transform: [{ scaleX: 1.6 }],\n  },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "template",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "screen",
    "web": "live",
    "perf": {
      "budget": "Nothing animates while idle. The sheet, cover tilt and scrubber are gesture-driven worklets; the title scramble runs for under a second per track change. The stand-in clock updates once a second and is replaced by your player's position.",
      "measurements": []
    },
    "a11y": {
      "reducedMotion": "The sheet opens with a short fade, the cover does not tilt, the title changes without scrambling and the scrubber does not stretch (handled by each component).",
      "screenReader": "Tracks are buttons with title, artist and length; the current one is marked selected. The mini player reads \"Now playing\" and opens the player. The sheet is modal with an escape action; the scrubber is adjustable and reads the time.",
      "touchTarget": "Rows are 64 pt; play, previous, next and the mini player button are at least 44 pt."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';\n\nimport { PlayerSheet } from '@/components/kinetik/screens/player-sheet';\n\nexport default function Music() {\n  const player = useAudioPlayer();\n  const status = useAudioPlayerStatus(player);\n  return (\n    <PlayerSheet\n      tracks={tracks}\n      position={status.currentTime}\n      onPlay={(track) => {\n        player.replace(track.url);\n        player.play();\n      }}\n      onPause={() => player.pause()}\n      onSeek={(seconds) => player.seekTo(seconds)}\n    />\n  );\n}\n```\n\n`Track` needs `id`, `title`, `artist` and `duration`; add your own fields (like `url`) and read them in the callbacks.\n\n## When to use\n\n- A list of playable items (music, podcasts, lessons) with a persistent mini player.\n- When the full player is a step away from the list, not a separate route.\n\n## When not to use\n\n- Video. The sheet covers the list; use a dedicated player screen.\n- One-off audio clips in a feed. Inline play buttons are enough.\n\n## Notes\n\n- Without `position`, the screen runs its own one-second clock and advances to the next track, so the demo plays on its own. Pass `position` from your audio player in a real app.\n- Previous restarts the track when more than three seconds in, like most players.\n- The player opens at the tall snap point; drag down to the shorter one or close.\n"
  },
  "docs": "Prefer `npx kinetik-ui add player-sheet`: it installs dependencies with expo install, so versions match your Expo SDK."
}
