Player Sheet
A music library whose mini player opens into a full player: tilting cover, elastic scrubber.
import { useEffect, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ElasticSlider } from '../elastic-slider';
import { MorphSheet } from '../morph-sheet';
import { PressJelly } from '../press-jelly';
import { PulseToggle } from '../pulse-toggle';
import { ScrambleText } from '../scramble-text';
import { TiltDeck, TiltLayer } from '../tilt-deck';
import { gradient } from '../../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../../lib/kinetik/tokens/tokens';
export type Track = {
id: string;
title: string;
artist: string;
/** Length in seconds. */
duration: number;
/** Cover tone. Defaults to the theme accents. */
tone?: 'ember' | 'ice';
};
export type PlayerSheetProps = {
tracks?: readonly Track[];
/** Title above the list. */
title?: string;
/** Called with the track that should start playing. Wire this to your audio player. */
onPlay?: (track: Track) => void;
onPause?: () => void;
/** Called when the user scrubs, in seconds. */
onSeek?: (seconds: number) => void;
/**
* Playback position in seconds, from your audio player. Without it the
* screen advances its own clock while playing, which is enough for a demo.
*/
position?: number;
};
const DEFAULT_TRACKS: Track[] = [
{ id: '1', title: 'Glass Harbour', artist: 'Lumen Fields', duration: 214, tone: 'ice' },
{ id: '2', title: 'Low Tide', artist: 'Lumen Fields', duration: 187, tone: 'ember' },
{ id: '3', title: 'Northbound', artist: 'Kestrel Sound', duration: 242, tone: 'ice' },
{ id: '4', title: 'Static Bloom', artist: 'Paper Moons', duration: 198, tone: 'ember' },
{ id: '5', title: 'Afterimage', artist: 'Kestrel Sound', duration: 225, tone: 'ice' },
{ id: '6', title: 'Long Exposure', artist: 'Paper Moons', duration: 263, tone: 'ember' },
];
const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
/**
* A music library with a mini player that opens into a full player sheet:
* a tilting cover, a scrambling title on track change and an elastic scrubber.
*/
export function PlayerSheet({
tracks = DEFAULT_TRACKS,
title = 'Library',
onPlay,
onPause,
onSeek,
position: positionProp,
}: PlayerSheetProps) {
const { colors, space, font, radius } = useKinetikTheme();
const insets = useSafeAreaInsets();
// Track and position change together (auto-advance), so they share one state.
const [now, setNow] = useState({ index: 0, position: 0 });
const [playing, setPlaying] = useState(false);
const [open, setOpen] = useState(false);
const [shuffle, setShuffle] = useState(false);
const { index } = now;
const position = positionProp ?? now.position;
const track = tracks[index];
// Stand-in clock for when no audio player drives `position`.
useEffect(() => {
if (!playing || positionProp !== undefined) return;
const t = setInterval(() => {
setNow((n) => {
const length = tracks[n.index]?.duration ?? 0;
return n.position + 1 < length
? { ...n, position: n.position + 1 }
: { index: (n.index + 1) % tracks.length, position: 0 };
});
}, 1000);
return () => clearInterval(t);
}, [playing, positionProp, tracks]);
if (!track) return null;
const play = (i: number) => {
const next = tracks[i];
if (!next) return;
setNow({ index: i, position: 0 });
setPlaying(true);
onPlay?.(next);
};
const toggle = () => {
if (playing) {
setPlaying(false);
onPause?.();
} else {
setPlaying(true);
onPlay?.(track);
}
};
const step = (dir: 1 | -1) => {
if (dir === -1 && position > 3) {
setNow((n) => ({ ...n, position: 0 }));
onSeek?.(0);
return;
}
const pick = shuffle
? (index + 1 + Math.floor(Math.random() * (tracks.length - 1))) % tracks.length
: (index + dir + tracks.length) % tracks.length;
play(pick);
};
const sheet = (
<View style={{ paddingHorizontal: space[6], gap: space[6], paddingBottom: space[6] }}>
<Cover track={track} large />
<View style={{ gap: space[1] }}>
<ScrambleText
text={track.title}
replayKey={track.id}
animateOnMount={false}
style={{ color: colors.text, fontSize: font.size['2xl'], fontWeight: font.weight.heavy }}
/>
<Text style={{ color: colors.textMuted, fontSize: font.size.md }}>{track.artist}</Text>
</View>
<View style={{ gap: space[1] }}>
<ElasticSlider
label="Position"
value={Math.min(position, track.duration)}
min={0}
max={track.duration}
step={1}
showLabel={false}
formatValue={clock}
onSlidingComplete={(v) => {
setNow((n) => ({ ...n, position: v }));
onSeek?.(v);
}}
/>
<View style={styles.times}>
<Text style={[styles.time, { color: colors.textSubtle, fontSize: font.size.xs }]}>
{clock(position)}
</Text>
<Text style={[styles.time, { color: colors.textSubtle, fontSize: font.size.xs }]}>
−{clock(Math.max(0, track.duration - position))}
</Text>
</View>
</View>
<View style={[styles.transport, { gap: space[3] }]}>
<PressJelly variant="surface" accessibilityLabel="Previous track" onPress={() => step(-1)}>
{'\u23EE\uFE0E'}
</PressJelly>
<View style={styles.fill}>
<PressJelly
block
size="lg"
accessibilityLabel={playing ? 'Pause' : 'Play'}
onPress={toggle}
>
{playing ? 'Pause' : 'Play'}
</PressJelly>
</View>
<PressJelly variant="surface" accessibilityLabel="Next track" onPress={() => step(1)}>
{'\u23ED\uFE0E'}
</PressJelly>
</View>
<PulseToggle
label="Shuffle"
description="Play the library in a random order"
value={shuffle}
onValueChange={setShuffle}
/>
</View>
);
return (
<MorphSheet
open={open}
onOpenChange={setOpen}
snapPoints={[0.62, 0.94]}
initialSnap={1}
accessibilityLabel="Now playing"
sheet={sheet}
>
<View style={[styles.fill, { backgroundColor: colors.background }]}>
<ScrollView
contentContainerStyle={{
paddingTop: insets.top + space[6],
paddingHorizontal: space[5],
paddingBottom: 96 + insets.bottom,
gap: space[1],
}}
>
<Text
accessibilityRole="header"
style={{
color: colors.text,
fontSize: font.size['3xl'],
fontWeight: font.weight.heavy,
letterSpacing: -1,
marginBottom: space[4],
}}
>
{title}
</Text>
{tracks.map((t, i) => {
const current = i === index;
return (
<Pressable
key={t.id}
accessibilityRole="button"
accessibilityLabel={`${t.title} by ${t.artist}, ${clock(t.duration)}`}
accessibilityState={{ selected: current }}
onPress={() => play(i)}
style={({ pressed }) => [
styles.row,
{
gap: space[3],
borderRadius: radius.md,
paddingHorizontal: space[2],
backgroundColor: pressed ? colors.surfaceSunken : 'transparent',
},
]}
>
<Cover track={t} />
<View style={styles.fill}>
<Text
numberOfLines={1}
style={{
color: current ? colors.ember : colors.text,
fontSize: font.size.md,
fontWeight: font.weight.semibold,
}}
>
{t.title}
</Text>
<Text style={{ color: colors.textMuted, fontSize: font.size.sm }}>
{t.artist}
</Text>
</View>
<Text style={[styles.time, { color: colors.textSubtle, fontSize: font.size.sm }]}>
{clock(t.duration)}
</Text>
</Pressable>
);
})}
</ScrollView>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Now playing: ${track.title} by ${track.artist}`}
accessibilityHint="Opens the player"
onPress={() => setOpen(true)}
style={[
styles.mini,
{
bottom: insets.bottom + space[3],
left: space[3],
right: space[3],
gap: space[3],
padding: space[2],
paddingRight: space[3],
borderRadius: radius.lg,
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
boxShadow: `0px 12px 32px ${withAlpha(colors.surfaceSunken, 0.6)}`,
},
]}
>
<Cover track={track} />
<View style={styles.fill}>
<Text
numberOfLines={1}
style={{
color: colors.text,
fontSize: font.size.md,
fontWeight: font.weight.semibold,
}}
>
{track.title}
</Text>
<Text numberOfLines={1} style={{ color: colors.textMuted, fontSize: font.size.sm }}>
{track.artist}
</Text>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel={playing ? 'Pause' : 'Play'}
onPress={toggle}
hitSlop={6}
style={[
styles.miniButton,
{ backgroundColor: colors.ember, borderRadius: radius.pill },
]}
>
<Text style={{ color: colors.onEmber, fontSize: font.size.md, fontWeight: '900' }}>
{playing ? '\u275A\u275A' : '\u25B6\uFE0E'}
</Text>
</Pressable>
{/* Progress hairline along the bottom edge. */}
<View style={[styles.progressTrack, { backgroundColor: colors.track }]}>
<View
style={{
width: `${Math.min(100, (position / track.duration) * 100)}%`,
height: '100%',
backgroundColor: colors.ember,
}}
/>
</View>
</Pressable>
</View>
</MorphSheet>
);
}
function Cover({ track, large = false }: { track: Track; large?: boolean }) {
const { colors, radius } = useKinetikTheme();
const tone = track.tone === 'ember' ? colors.ember : colors.ice;
const other = track.tone === 'ember' ? colors.ice : colors.ember;
const art = (
<View
style={[
large ? styles.coverLarge : styles.coverSmall,
{ borderRadius: large ? 0 : radius.sm, overflow: 'hidden' },
gradient(
`linear-gradient(150deg, ${tone} 0%, ${withAlpha(other, 0.45)} 55%, ${colors.surfaceSunken} 100%)`,
),
]}
>
{large ? (
<>
<TiltLayer
depth={-0.5}
style={[styles.orb, { backgroundColor: withAlpha(colors.highlight, 0.22) }]}
/>
<TiltLayer
depth={0.8}
style={[styles.ridge, { backgroundColor: withAlpha(colors.surfaceSunken, 0.7) }]}
/>
</>
) : null}
</View>
);
if (!large) return art;
return (
<TiltDeck accessibilityLabel={`${track.title} cover`} contentStyle={{ padding: 0 }}>
{art}
</TiltDeck>
);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
row: { flexDirection: 'row', alignItems: 'center', minHeight: 64 },
time: { fontVariant: ['tabular-nums'] },
times: { flexDirection: 'row', justifyContent: 'space-between' },
transport: { flexDirection: 'row', alignItems: 'center' },
mini: {
position: 'absolute',
flexDirection: 'row',
alignItems: 'center',
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
},
miniButton: {
width: minTouchTarget,
height: minTouchTarget,
alignItems: 'center',
justifyContent: 'center',
},
progressTrack: { position: 'absolute', left: 0, right: 0, bottom: 0, height: 2 },
coverSmall: { width: 48, height: 48 },
coverLarge: { width: '100%', aspectRatio: 1 },
orb: { position: 'absolute', width: 120, height: 120, borderRadius: 60, top: '18%', left: '16%' },
ridge: {
position: 'absolute',
width: 400,
height: 400,
borderRadius: 200,
bottom: -300,
left: -40,
transform: [{ scaleX: 1.6 }],
},
});Installation
npx kinetik-ui add player-sheetpnpm dlx kinetik-ui add player-sheetbunx kinetik-ui add player-sheetyarn dlx kinetik-ui add player-sheetThe 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/player-sheet.json
Install manually
- Install dependencies
npx expo install react-native-safe-area-context - Copy the source files
components/kinetik/screens/player-sheet.tsx
Usage
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
import { PlayerSheet } from '@/components/kinetik/screens/player-sheet';
export default function Music() {
const player = useAudioPlayer();
const status = useAudioPlayerStatus(player);
return (
<PlayerSheet
tracks={tracks}
position={status.currentTime}
onPlay={(track) => {
player.replace(track.url);
player.play();
}}
onPause={() => player.pause()}
onSeek={(seconds) => player.seekTo(seconds)}
/>
);
}Track needs id, title, artist and duration; add your own fields (like url) and read them in the callbacks.
When to use
- A list of playable items (music, podcasts, lessons) with a persistent mini player.
- When the full player is a step away from the list, not a separate route.
When not to use
- Video. The sheet covers the list; use a dedicated player screen.
- One-off audio clips in a feed. Inline play buttons are enough.
Notes
- Without
position, the screen runs its own one-second clock and advances to the next track, so the demo plays on its own. Passpositionfrom your audio player in a real app. - Previous restarts the track when more than three seconds in, like most players.
- The player opens at the tall snap point; drag down to the shorter one or close.
Props
PlayerSheetProps
| Prop | Type | Default | Description |
|---|---|---|---|
tracks | readonly Track[] | DEFAULT_TRACKS | |
title | string | 'Library' | Title above the list. |
onPlay | (track: Track) => void | — | Called with the track that should start playing. Wire this to your audio player. |
onPause | () => void | — | |
onSeek | (seconds: number) => void | — | Called when the user scrubs, in seconds. |
position | number | — | Playback position in seconds, from your audio player. Without it the screen advances its own clock while playing, which is enough for a demo. |
Performance and accessibility
Performance
- 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.
- Measured
Real-device measurement pending.
Accessibility
- Reduced motion
- 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).
- Screen readers
- 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.
- Touch target
- Rows are 64 pt; play, previous, next and the mini player button are at least 44 pt.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/screens/player-sheet.tsx
import { useEffect, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ElasticSlider } from '../elastic-slider';
import { MorphSheet } from '../morph-sheet';
import { PressJelly } from '../press-jelly';
import { PulseToggle } from '../pulse-toggle';
import { ScrambleText } from '../scramble-text';
import { TiltDeck, TiltLayer } from '../tilt-deck';
import { gradient } from '../../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../../lib/kinetik/tokens/tokens';
export type Track = {
id: string;
title: string;
artist: string;
/** Length in seconds. */
duration: number;
/** Cover tone. Defaults to the theme accents. */
tone?: 'ember' | 'ice';
};
export type PlayerSheetProps = {
tracks?: readonly Track[];
/** Title above the list. */
title?: string;
/** Called with the track that should start playing. Wire this to your audio player. */
onPlay?: (track: Track) => void;
onPause?: () => void;
/** Called when the user scrubs, in seconds. */
onSeek?: (seconds: number) => void;
/**
* Playback position in seconds, from your audio player. Without it the
* screen advances its own clock while playing, which is enough for a demo.
*/
position?: number;
};
const DEFAULT_TRACKS: Track[] = [
{ id: '1', title: 'Glass Harbour', artist: 'Lumen Fields', duration: 214, tone: 'ice' },
{ id: '2', title: 'Low Tide', artist: 'Lumen Fields', duration: 187, tone: 'ember' },
{ id: '3', title: 'Northbound', artist: 'Kestrel Sound', duration: 242, tone: 'ice' },
{ id: '4', title: 'Static Bloom', artist: 'Paper Moons', duration: 198, tone: 'ember' },
{ id: '5', title: 'Afterimage', artist: 'Kestrel Sound', duration: 225, tone: 'ice' },
{ id: '6', title: 'Long Exposure', artist: 'Paper Moons', duration: 263, tone: 'ember' },
];
const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
/**
* A music library with a mini player that opens into a full player sheet:
* a tilting cover, a scrambling title on track change and an elastic scrubber.
*/
export function PlayerSheet({
tracks = DEFAULT_TRACKS,
title = 'Library',
onPlay,
onPause,
onSeek,
position: positionProp,
}: PlayerSheetProps) {
const { colors, space, font, radius } = useKinetikTheme();
const insets = useSafeAreaInsets();
// Track and position change together (auto-advance), so they share one state.
const [now, setNow] = useState({ index: 0, position: 0 });
const [playing, setPlaying] = useState(false);
const [open, setOpen] = useState(false);
const [shuffle, setShuffle] = useState(false);
const { index } = now;
const position = positionProp ?? now.position;
const track = tracks[index];
// Stand-in clock for when no audio player drives `position`.
useEffect(() => {
if (!playing || positionProp !== undefined) return;
const t = setInterval(() => {
setNow((n) => {
const length = tracks[n.index]?.duration ?? 0;
return n.position + 1 < length
? { ...n, position: n.position + 1 }
: { index: (n.index + 1) % tracks.length, position: 0 };
});
}, 1000);
return () => clearInterval(t);
}, [playing, positionProp, tracks]);
if (!track) return null;
const play = (i: number) => {
const next = tracks[i];
if (!next) return;
setNow({ index: i, position: 0 });
setPlaying(true);
onPlay?.(next);
};
const toggle = () => {
if (playing) {
setPlaying(false);
onPause?.();
} else {
setPlaying(true);
onPlay?.(track);
}
};
const step = (dir: 1 | -1) => {
if (dir === -1 && position > 3) {
setNow((n) => ({ ...n, position: 0 }));
onSeek?.(0);
return;
}
const pick = shuffle
? (index + 1 + Math.floor(Math.random() * (tracks.length - 1))) % tracks.length
: (index + dir + tracks.length) % tracks.length;
play(pick);
};
const sheet = (
<View style={{ paddingHorizontal: space[6], gap: space[6], paddingBottom: space[6] }}>
<Cover track={track} large />
<View style={{ gap: space[1] }}>
<ScrambleText
text={track.title}
replayKey={track.id}
animateOnMount={false}
style={{ color: colors.text, fontSize: font.size['2xl'], fontWeight: font.weight.heavy }}
/>
<Text style={{ color: colors.textMuted, fontSize: font.size.md }}>{track.artist}</Text>
</View>
<View style={{ gap: space[1] }}>
<ElasticSlider
label="Position"
value={Math.min(position, track.duration)}
min={0}
max={track.duration}
step={1}
showLabel={false}
formatValue={clock}
onSlidingComplete={(v) => {
setNow((n) => ({ ...n, position: v }));
onSeek?.(v);
}}
/>
<View style={styles.times}>
<Text style={[styles.time, { color: colors.textSubtle, fontSize: font.size.xs }]}>
{clock(position)}
</Text>
<Text style={[styles.time, { color: colors.textSubtle, fontSize: font.size.xs }]}>
−{clock(Math.max(0, track.duration - position))}
</Text>
</View>
</View>
<View style={[styles.transport, { gap: space[3] }]}>
<PressJelly variant="surface" accessibilityLabel="Previous track" onPress={() => step(-1)}>
{'\u23EE\uFE0E'}
</PressJelly>
<View style={styles.fill}>
<PressJelly
block
size="lg"
accessibilityLabel={playing ? 'Pause' : 'Play'}
onPress={toggle}
>
{playing ? 'Pause' : 'Play'}
</PressJelly>
</View>
<PressJelly variant="surface" accessibilityLabel="Next track" onPress={() => step(1)}>
{'\u23ED\uFE0E'}
</PressJelly>
</View>
<PulseToggle
label="Shuffle"
description="Play the library in a random order"
value={shuffle}
onValueChange={setShuffle}
/>
</View>
);
return (
<MorphSheet
open={open}
onOpenChange={setOpen}
snapPoints={[0.62, 0.94]}
initialSnap={1}
accessibilityLabel="Now playing"
sheet={sheet}
>
<View style={[styles.fill, { backgroundColor: colors.background }]}>
<ScrollView
contentContainerStyle={{
paddingTop: insets.top + space[6],
paddingHorizontal: space[5],
paddingBottom: 96 + insets.bottom,
gap: space[1],
}}
>
<Text
accessibilityRole="header"
style={{
color: colors.text,
fontSize: font.size['3xl'],
fontWeight: font.weight.heavy,
letterSpacing: -1,
marginBottom: space[4],
}}
>
{title}
</Text>
{tracks.map((t, i) => {
const current = i === index;
return (
<Pressable
key={t.id}
accessibilityRole="button"
accessibilityLabel={`${t.title} by ${t.artist}, ${clock(t.duration)}`}
accessibilityState={{ selected: current }}
onPress={() => play(i)}
style={({ pressed }) => [
styles.row,
{
gap: space[3],
borderRadius: radius.md,
paddingHorizontal: space[2],
backgroundColor: pressed ? colors.surfaceSunken : 'transparent',
},
]}
>
<Cover track={t} />
<View style={styles.fill}>
<Text
numberOfLines={1}
style={{
color: current ? colors.ember : colors.text,
fontSize: font.size.md,
fontWeight: font.weight.semibold,
}}
>
{t.title}
</Text>
<Text style={{ color: colors.textMuted, fontSize: font.size.sm }}>
{t.artist}
</Text>
</View>
<Text style={[styles.time, { color: colors.textSubtle, fontSize: font.size.sm }]}>
{clock(t.duration)}
</Text>
</Pressable>
);
})}
</ScrollView>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Now playing: ${track.title} by ${track.artist}`}
accessibilityHint="Opens the player"
onPress={() => setOpen(true)}
style={[
styles.mini,
{
bottom: insets.bottom + space[3],
left: space[3],
right: space[3],
gap: space[3],
padding: space[2],
paddingRight: space[3],
borderRadius: radius.lg,
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
boxShadow: `0px 12px 32px ${withAlpha(colors.surfaceSunken, 0.6)}`,
},
]}
>
<Cover track={track} />
<View style={styles.fill}>
<Text
numberOfLines={1}
style={{
color: colors.text,
fontSize: font.size.md,
fontWeight: font.weight.semibold,
}}
>
{track.title}
</Text>
<Text numberOfLines={1} style={{ color: colors.textMuted, fontSize: font.size.sm }}>
{track.artist}
</Text>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel={playing ? 'Pause' : 'Play'}
onPress={toggle}
hitSlop={6}
style={[
styles.miniButton,
{ backgroundColor: colors.ember, borderRadius: radius.pill },
]}
>
<Text style={{ color: colors.onEmber, fontSize: font.size.md, fontWeight: '900' }}>
{playing ? '\u275A\u275A' : '\u25B6\uFE0E'}
</Text>
</Pressable>
{/* Progress hairline along the bottom edge. */}
<View style={[styles.progressTrack, { backgroundColor: colors.track }]}>
<View
style={{
width: `${Math.min(100, (position / track.duration) * 100)}%`,
height: '100%',
backgroundColor: colors.ember,
}}
/>
</View>
</Pressable>
</View>
</MorphSheet>
);
}
function Cover({ track, large = false }: { track: Track; large?: boolean }) {
const { colors, radius } = useKinetikTheme();
const tone = track.tone === 'ember' ? colors.ember : colors.ice;
const other = track.tone === 'ember' ? colors.ice : colors.ember;
const art = (
<View
style={[
large ? styles.coverLarge : styles.coverSmall,
{ borderRadius: large ? 0 : radius.sm, overflow: 'hidden' },
gradient(
`linear-gradient(150deg, ${tone} 0%, ${withAlpha(other, 0.45)} 55%, ${colors.surfaceSunken} 100%)`,
),
]}
>
{large ? (
<>
<TiltLayer
depth={-0.5}
style={[styles.orb, { backgroundColor: withAlpha(colors.highlight, 0.22) }]}
/>
<TiltLayer
depth={0.8}
style={[styles.ridge, { backgroundColor: withAlpha(colors.surfaceSunken, 0.7) }]}
/>
</>
) : null}
</View>
);
if (!large) return art;
return (
<TiltDeck accessibilityLabel={`${track.title} cover`} contentStyle={{ padding: 0 }}>
{art}
</TiltDeck>
);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
row: { flexDirection: 'row', alignItems: 'center', minHeight: 64 },
time: { fontVariant: ['tabular-nums'] },
times: { flexDirection: 'row', justifyContent: 'space-between' },
transport: { flexDirection: 'row', alignItems: 'center' },
mini: {
position: 'absolute',
flexDirection: 'row',
alignItems: 'center',
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
},
miniButton: {
width: minTouchTarget,
height: minTouchTarget,
alignItems: 'center',
justifyContent: 'center',
},
progressTrack: { position: 'absolute', left: 0, right: 0, bottom: 0, height: 2 },
coverSmall: { width: 48, height: 48 },
coverLarge: { width: '100%', aspectRatio: 1 },
orb: { position: 'absolute', width: 120, height: 120, borderRadius: 60, top: '18%', left: '16%' },
ridge: {
position: 'absolute',
width: 400,
height: 400,
borderRadius: 200,
bottom: -300,
left: -40,
transform: [{ scaleX: 1.6 }],
},
});