Repository: saulamsal/apple-music-sheet-ui
Branch: main
Commit: df59ca6ae24e
Files: 36
Total size: 95.2 KB
Directory structure:
gitextract_jvtnjuuu/
├── .gitignore
├── README.md
├── app/
│ ├── (tabs)/
│ │ ├── _layout.tsx
│ │ ├── index.tsx
│ │ ├── library.tsx
│ │ ├── new.tsx
│ │ ├── radio.tsx
│ │ └── search/
│ │ ├── _layout.tsx
│ │ └── index.tsx
│ ├── +html.tsx
│ ├── +not-found.tsx
│ ├── _layout.tsx
│ └── music/
│ └── [id].tsx
├── app.json
├── babel.config.js
├── components/
│ ├── BottomSheet/
│ │ ├── ExpandedPlayer.tsx
│ │ └── MiniPlayer.tsx
│ ├── CategoryCard.tsx
│ ├── MusicVisualizer.tsx
│ ├── Overlay/
│ │ ├── OverlayContext.tsx
│ │ └── OverlayProvider.tsx
│ ├── ParallaxScrollView.tsx
│ ├── ThemedText.tsx
│ ├── ThemedView.tsx
│ └── navigation/
│ └── TabBarIcon.tsx
├── constants/
│ └── Colors.ts
├── contexts/
│ ├── AudioContext.tsx
│ └── RootScaleContext.tsx
├── data/
│ └── songs.json
├── hooks/
│ ├── useColorScheme.ts
│ ├── useColorScheme.web.ts
│ ├── useOverlayView.ts
│ └── useThemeColor.ts
├── package.json
├── scripts/
│ └── reset-project.js
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# macOS
.DS_Store
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli
================================================
FILE: README.md
================================================
# Apple Music Sheet UI Demo with Expo
This project demonstrates an implementation of the Apple Music player UI in React Native using Expo, with a focus on replicating the smooth sheet transitions and scaling animations.

## Features
- 🎵 Full-screen music player modal with gesture controls
- 🔄 Smooth scaling animations of the root content
- 👆 Interactive pan gesture handling
- 📱 iOS-style sheet presentation
- 🎨 Dynamic border radius animations
- 🌟 Visual audio visualizer
- 💫 Haptic feedback on modal interactions
- 🖼️ Blur effects and backdrop filters
- 📱 Sticky mini-player navigation
- 📋 Apple Music style track listing
- ⚡ Gesture handling with drag thresholds
- 🔄 Horizontal swipe to dismiss
## Tech Stack
- [Expo](https://expo.dev) - React Native development platform
- [Expo Router](https://docs.expo.dev/router/introduction) - File-based routing
- [React Native Reanimated](https://docs.swmansion.com/react-native-reanimated/) - Smooth animations
- [React Native Gesture Handler](https://docs.swmansion.com/react-native-gesture-handler/) - Native-driven gesture handling
## Getting Started
1. Install dependencies:
```bash
npm install
```
2. Start the development server:
```bash
npx expo start
```
3. Open in iOS Simulator or Android Emulator:
- Press `i` for iOS
- Press `a` for Android
## Implementation Details
The project showcases several key features of modern React Native development:
- Shared element transitions between mini and full player
- Gesture-based interactions with multi-axis support
- Context-based animation state management
- Worklet-based animations for optimal performance
### Known Issues
- Horizontal drag gesture conflicts with content scrolling when the modal is partially scrolled, causing flickering. This needs to be addressed by properly managing gesture priorities and scroll state.
## Project Structure
```
project-root/
├── app/
│ ├── (tabs)/
│ │ ├── search/ # Search and library screens
│ │ │ ├── _layout.tsx
│ │ │ ├── library.tsx
│ │ │ ├── new.tsx
│ │ │ └── radio.tsx
│ │ ├── music/ # Music player routes
│ │ │ ├── [id].tsx
│ │ │ └── _layout.tsx
│ │ └── _layout.tsx # Tab navigation layout
├── components/
│ ├── navigation/
│ │ └── TabBarIcon.tsx # Tab bar icons
│ ├── Overlay/ # Sheet UI components
│ │ ├── OverlayContext.tsx
│ │ ├── OverlayProvider.tsx
│ │ └── ThemedView.tsx
│ └── ThemedText.tsx
├── contexts/
│ ├── AudioContext.tsx # Audio playback state
│ └── RootScaleContext.tsx # Scale animation state
├── constants/
│ └── Colors.ts # Theme colors
└── hooks/ # Custom React hooks
├── useColorScheme.ts
├── useThemeColor.ts
└── useColorScheme.web.ts
```
## Contributing
Feel free to contribute to this project by:
1. Forking the repository
2. Creating a feature branch
3. Submitting a pull request
## License
This project is open source and available under the MIT License.
================================================
FILE: app/(tabs)/_layout.tsx
================================================
import { Tabs } from 'expo-router';
import React from 'react';
import { TabBarIcon } from '@/components/navigation/TabBarIcon';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { BlurView } from 'expo-blur';
import { Platform, StyleSheet } from 'react-native';
import { SymbolView } from 'expo-symbols';
// Helper component for cross-platform icons
function TabIcon({ sfSymbol, ionIcon, color }: { sfSymbol: string; ionIcon: string; color: string }) {
// if (Platform.OS === 'ios') {
// return (
// <SymbolView
// name={sfSymbol}
// size={24}
// tintColor={color}
// fallback={<TabBarIcon name={ionIcon} color={color} />}
// />
// );
// }
return <TabBarIcon name={ionIcon} color={color} />;
}
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#FA2D48',
headerShown: false,
tabBarStyle: {
position: 'absolute',
backgroundColor: Platform.select({
ios: 'transparent',
android: 'rgba(255, 255, 255, 0.8)', // Fallback for Android
}),
borderTopWidth: 0,
elevation: 0,
height: 94,
paddingTop: 0,
paddingBottom: 40,
},
tabBarBackground: () => (
Platform.OS === 'ios' ? (
<BlurView
tint={colorScheme === 'dark' ? 'systemThickMaterialDark' : 'systemThickMaterialLight'}
intensity={80}
style={StyleSheet.absoluteFill}
/>
) : null
),
}}>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color }) => (
<TabIcon
sfSymbol="music.note.house"
ionIcon="home-sharp"
color={color}
/>
),
}}
/>
<Tabs.Screen
name="new"
options={{
title: 'New',
tabBarIcon: ({ color }) => (
<TabIcon
sfSymbol="square.grid.2x2.fill"
ionIcon="apps-sharp"
color={color}
/>
),
}}
/>
<Tabs.Screen
name="radio"
options={{
title: 'Radio',
tabBarIcon: ({ color }) => (
<TabIcon
sfSymbol="dot.radiowaves.left.and.right"
ionIcon="radio-outline"
color={color}
/>
),
}}
/>
<Tabs.Screen
name="library"
options={{
title: 'Library',
tabBarIcon: ({ color }) => (
<TabIcon
sfSymbol="music.note.list"
ionIcon="musical-notes"
color={color}
/>
),
}}
/>
<Tabs.Screen
name="search"
options={{
title: 'Search',
tabBarIcon: ({ color }) => (
<TabIcon
sfSymbol="magnifyingglass"
ionIcon="search"
color={color}
/>
),
}}
/>
</Tabs>
);
}
================================================
FILE: app/(tabs)/index.tsx
================================================
import { Text, Image, View, StyleSheet, Platform, Pressable, FlatList } from 'react-native';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { MaterialIcons, Ionicons } from '@expo/vector-icons';
import ParallaxScrollView from '@/components/ParallaxScrollView';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { songs } from '@/data/songs.json';
import { useAudio } from '@/contexts/AudioContext';
import { MusicVisualizer } from '@/components/MusicVisualizer';
import { useColorScheme } from '@/hooks/useColorScheme';
interface Song {
id: string;
title: string;
artist: string;
artwork: string;
}
export default function HomeScreen() {
const router = useRouter();
const { currentSong, playSound, isPlaying, togglePlayPause } = useAudio();
const colorScheme = useColorScheme();
const handlePlayFirst = () => {
playSound(songs[0]);
};
const handleShuffle = () => {
const randomSong = songs[Math.floor(Math.random() * songs.length)];
playSound(randomSong);
};
const renderSongItem = ({ item }: { item: Song }) => (
<Pressable
onPress={() => {
playSound(item);
// router.push(`/music/${item.id}`);
}}
style={styles.songItem}
>
<View style={styles.artworkContainer}>
<Image source={{ uri: item.artwork }} style={styles.songArtwork} />
{item.id === currentSong?.id && (
<MusicVisualizer isPlaying={isPlaying} />
)}
</View>
<ThemedView
style={[
styles.songInfoContainer,
{ borderBottomColor: colorScheme === 'light' ? '#ababab' : '#535353' },
]}
>
<ThemedView style={styles.songInfo}>
<ThemedText type="defaultSemiBold" numberOfLines={1} style={styles.songTitle}>
{item.title}
</ThemedText>
<ThemedView style={styles.artistRow}>
{item.id === currentSong?.id && (
<Ionicons name="musical-note" size={12} color="#FA2D48" />
)}
<ThemedText type="subtitle" numberOfLines={1} style={styles.songArtist}>
{item.artist}
</ThemedText>
</ThemedView>
</ThemedView>
<Pressable style={styles.moreButton}>
<MaterialIcons name="more-horiz" size={20} color="#222222" />
</Pressable>
</ThemedView>
</Pressable>
);
return (
<ThemedView style={styles.container}>
<ParallaxScrollView
headerBackgroundColor={{ light: '#f57a8a', dark: '#FA2D48' }}
headerImage={
<ThemedView style={{
flex: 1, width: '100%', height: '100%', position: 'absolute', top: 0, left: 0,
alignItems: 'center',
}}>
<Image
source={{
uri: 'https://9to5mac.com/wp-content/uploads/sites/6/2021/08/apple-music-logo-2021-9to5mac.jpg?quality=82&strip=all&w=1024'
}}
style={{
position: 'absolute',
width: '100%',
height: '100%'
}}
/>
<Text style={{
fontSize: 18,
letterSpacing: -0.5,
alignSelf: 'center',
position: 'absolute',
top: 80,
color: '#fff' // Added white color for better visibility
}}>
Built with Expo
</Text>
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', gap: 10 }}>
<View style={styles.headerButtons}>
<Pressable
style={styles.headerButton}
onPress={handlePlayFirst}
>
<Ionicons name="play" size={24} color="#fff" />
<Text style={styles.headerButtonText}>Play</Text>
</Pressable>
<Pressable
style={styles.headerButton}
onPress={handleShuffle}
>
<Ionicons name="shuffle" size={24} color="#fff" />
<Text style={styles.headerButtonText}>Shuffle</Text>
</Pressable>
</View>
</View>
</ThemedView>
}
contentContainerStyle={styles.scrollView}
>
<ThemedView style={styles.titleContainer}>
<ThemedView style={styles.titleRow}>
<ThemedText type="title">Billboard Top 20</ThemedText>
</ThemedView>
<ThemedText type="subtitle">
{new Date().toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})}
</ThemedText>
</ThemedView>
<FlatList
data={songs}
renderItem={renderSongItem}
keyExtractor={item => item.id}
scrollEnabled={false}
/>
</ParallaxScrollView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
titleContainer: {
flexDirection: 'column',
// marginBottom: 20,
paddingHorizontal: 16,
paddingVertical: 16
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
reactLogo: {
height: 50,
width: 210,
bottom: 0,
top: 100,
},
songItem: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 6,
gap: 12,
paddingLeft: 16
},
artworkContainer: {
position: 'relative',
width: 50,
height: 50,
},
songArtwork: {
width: '100%',
height: '100%',
borderRadius: 4,
},
songInfo: {
flex: 1,
gap: 4,
backgroundColor: 'transparent'
},
artistRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: 'transparent'
},
songTitle: {
fontSize: 15,
fontWeight: '400',
},
songArtist: {
fontSize: 14,
fontWeight: '400',
opacity: 0.6,
marginTop: -4
},
moreButton: {
padding: 8,
},
headerButtons: {
flexDirection: 'row',
justifyContent: 'center',
gap: 20,
position: 'absolute',
bottom: 30,
// width: '100%',
marginHorizontal: 20,
},
headerButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.1)',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 10,
gap: 8,
flex: 1,
justifyContent: 'center',
},
headerButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
songInfoContainer: {
flex: 1,
gap: 4,
flexDirection: 'row',
borderBottomWidth: StyleSheet.hairlineWidth,
paddingBottom: 14,
paddingRight: 14
},
});
================================================
FILE: app/(tabs)/library.tsx
================================================
import { View, Text } from 'react-native';
import React from 'react';
export default function LibraryScreen() {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Library Screen</Text>
</View>
);
}
================================================
FILE: app/(tabs)/new.tsx
================================================
import { View, Text } from 'react-native';
import React from 'react';
export default function NewScreen() {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>New Screen</Text>
</View>
);
}
================================================
FILE: app/(tabs)/radio.tsx
================================================
import { View, Text } from 'react-native';
import React from 'react';
export default function RadioScreen() {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Radio Screen</Text>
</View>
);
}
================================================
FILE: app/(tabs)/search/_layout.tsx
================================================
import { Stack } from 'expo-router';
import { router } from 'expo-router';
export default function SearchStack() {
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerLargeTitle: true,
headerLargeTitleStyle: { fontWeight: 'bold' },
title: "Search",
headerShadowVisible: false,
headerStyle: {
},
headerSearchBarOptions: {
placeholder: "Artists, Songs, Lyrics, and More",
onFocus: () => {
router.push("/search/search" as any);
},
}
}}
/>
</Stack>
);
}
================================================
FILE: app/(tabs)/search/index.tsx
================================================
import { View, Text, ScrollView, TextInput, StyleSheet } from 'react-native';
import React from 'react';
import { CategoryCard } from '@/components/CategoryCard';
import { Ionicons } from '@expo/vector-icons';
import { ThemedText } from '@/components/ThemedText';
const categories = [
{
"artworkBgColor": "#031312",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features114/v4/71/cb/37/71cb3751-1b55-41ae-9993-68f0682189fc/U0gtTVMtV1ctSGFsbG93ZWVuLU92ZXJhcmNoaW5nLnBuZw.png/1040x586sr.webp",
"title": "Halloween"
},
{
"artworkBgColor": "#f83046",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features221/v4/cc/54/da/cc54dac4-4972-69d2-9a5b-462d2d1ee8a6/c940e51b-6644-4b17-a714-1c898f669fb5.png/1040x586sr.webp",
"title": "Spatial Audio"
},
{
"artworkBgColor": "#6b8ce9",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features221/v4/fc/16/77/fc16775e-522b-d5c7-16ea-f5a7947ba9e2/1a7e6d73-b9c0-4ed2-a6a2-942900d1ce26.png/1040x586sr.webp",
"title": "Hip-Hop"
},
{
"artworkBgColor": "#d18937",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features221/v4/08/37/a9/0837a924-1d3a-7987-b179-b61c14222e5e/d27004f3-3a1f-4d0c-ae9a-d12425eec39e.png/1040x586sr.webp",
"title": "Country"
},
{
"artworkBgColor": "#e46689",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features221/v4/1d/43/86/1d438675-0ec5-b7c3-84aa-ab011159b921/db3b6833-715a-4119-9706-f8c51b6cf0c0.png/1040x586sr.webp",
"title": "Pop"
},
{
"artworkBgColor": "#ebbada",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/e3/01/4e/e3014ee5-004a-f936-03a2-f8b4f4904666/64857193-8605-490a-9699-04f4e6638719.png/1040x586sr.webp",
"title": "Apple Music Live"
},
{
"artworkBgColor": "#4e246e",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/66/44/63/6644636a-6134-e464-32e6-a7900d583ce8/bcb74429-1303-4fb0-9c3f-a6f0b87eb86e.png/1040x586sr.webp",
"title": "Sleep"
},
{
"artworkBgColor": "#595920",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/4c/59/c8/4c59c8c1-d144-dcef-b895-09204781995a/2362fc03-e10d-4b56-aca1-015246a9229d.png/1040x586sr.webp",
"title": "Charts"
},
{
"artworkBgColor": "#28585e",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features221/v4/0f/17/d5/0f17d5a3-6774-1ae1-4530-2b694d8fb6bf/d7944211-2928-4ccc-b382-f0564bcf00b2.png/1040x586sr.webp",
"title": "Chill"
},
{
"artworkBgColor": "#8e75d8",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features221/v4/58/1d/ff/581dfff1-b631-4302-fba7-8e2364ace98d/f2bec4ab-0d3c-46e1-98dd-a0a1588dae30.png/1040x586sr.webp",
"title": "R&B"
},
{
"artworkBgColor": "#dd4a82",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features/v4/08/a3/d4/08a3d409-a1d4-8d3d-bd44-92f6b211a7c3/2b0a4241-2168-43a7-8ae6-5e54696e5ec2.png/1040x586sr.webp",
"title": "Latin"
},
{
"artworkBgColor": "#3cbb7d",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/e9/40/4b/e9404be8-f887-2573-06e1-7a259af3c6a9/1c1dc89d-8fe5-4554-b2f0-963f375b58c0.png/1040x586sr.webp",
"title": "Dance"
},
{
"artworkBgColor": "#fa3348",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features221/v4/12/de/da/12deda1c-4c53-3bef-91d7-c1c7ec3725f2/a0e305b8-db6a-4c6e-819a-d45936097194.png/1040x586sr.webp",
"title": "DJ Mixes"
},
{
"artworkBgColor": "#ddb71e",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features/v4/ea/c3/b1/eac3b150-ad9e-1086-4284-b4e6b43757d7/2520a131-0639-423c-b167-9b73931e5cb0.png/1040x586sr.webp",
"title": "Hits"
},
{
"artworkBgColor": "#808a20",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features221/v4/20/fe/98/20fe9879-9f3c-67d7-9a29-c26ea4624498/a023fa37-e402-416b-a034-47dbc3c0003f.png/1040x586sr.webp",
"title": "Fitness"
},
{
"artworkBgColor": "#70441b",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/30/e6/42/30e64278-008f-b3df-6790-bdd7fe382360/f3639799-0252-4a92-b3d4-3df02f586e29.png/1040x586sr.webp",
"title": "Feel Good"
},
{
"artworkBgColor": "#9f3873",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/13/12/7d/13127dc4-9c81-099c-31a7-afb849518840/06e568d2-c588-448f-8721-1739e6ac2f2f.png/1040x586sr.webp",
"title": "Party"
},
{
"artworkBgColor": "#c4bc1e",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/34/7a/f4/347af4f3-a90b-e6a6-0244-f03ae159cf21/27107b4f-fb04-43d9-beed-8f315445fce9.png/1040x586sr.webp",
"title": "Alternative"
},
{
"artworkBgColor": "#db6646",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/8a/b7/2d/8ab72d43-2134-f98f-84a8-301c5653d07f/4b68c547-2be2-4bdd-974b-36d1a3e1bf3a.png/1040x586sr.webp",
"title": "Rock"
},
{
"artworkBgColor": "#da5c39",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features126/v4/03/53/b9/0353b95e-089e-63d9-5f1a-e07dbe85ae14/62e5b81d-fef3-46d0-b33b-537d1e85f24f.png/1040x586sr.webp",
"title": "Classic Rock"
},
{
"artworkBgColor": "#744808",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/32/52/4d/32524d36-af28-8bfe-ac7c-66494ee10362/8540fcf4-af4a-4943-af34-1355797f90e1.png/1040x586sr.webp",
"title": "Focus"
},
{
"artworkBgColor": "#351433",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/e1/3c/80/e13c8044-bd0b-bb30-61b0-439123c83af7/6fd5f161-9e42-42ee-9257-f17dd321d34f.png/1040x586sr.webp",
"title": "Essentials"
},
{
"artworkBgColor": "#2caaaf",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/ef/32/2c/ef322c4f-9ee2-b57e-ce35-d0c3adc2dce5/1b3cfebe-2ff7-49d3-bf4c-1549729faf06.png/1040x586sr.webp",
"title": "Christian"
},
{
"artworkBgColor": "#63377b",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features116/v4/e2/28/4f/e2284f11-e4a5-ce12-3281-6cfa993928e5/329b7acb-044a-414a-8227-78750bdfb511.png/1040x586sr.webp",
"title": "Classical"
},
{
"artworkBgColor": "#c48820",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features221/v4/f5/6e/46/f56e4626-b04b-b8e2-3c31-42d3e671df9c/b9972b6d-009d-4d6e-81f6-5254a77eea89.png/1040x586sr.webp",
"title": "Música Mexicana"
},
{
"artworkBgColor": "#da5c39",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features/v4/0f/1f/cd/0f1fcd6d-a660-259f-d2c4-319761070011/861cb0eb-168a-46cd-8585-c2e3f316f55b.png/1040x586sr.webp",
"title": "Hard Rock"
},
{
"artworkBgColor": "#af2d5f",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/d0/b6/dc/d0b6dc3a-8063-f2a3-152f-afd8c36b22b9/2d56098f-42f6-41fb-92dd-13bb5a2ee7db.png/1040x586sr.webp",
"title": "Urbano Latino"
},
{
"artworkBgColor": "#e46689",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/a2/da/d8/a2dad825-c24c-6cf6-0266-a8f4ac6c2ef8/daf15085-ea0a-4665-ac79-c9e2e94dfe7d.png/1040x586sr.webp",
"title": "K-Pop"
},
{
"artworkBgColor": "#58b556",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/bb/0e/29/bb0e29d9-7645-7ab0-f2c9-540c2f4eec2f/ca0818f2-a6db-4f9d-a1ae-3fef4d9ea10d.png/1040x586sr.webp",
"title": "Kids"
},
{
"artworkBgColor": "#4aaf49",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features/v4/ac/1b/f8/ac1bf8a7-c4fb-a1b0-90aa-3c9a7afd5f28/aadcdfed-207b-4961-9884-a37a64eb9bcd.png/1040x586sr.webp",
"title": "Family"
},
{
"artworkBgColor": "#21265c",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features126/v4/c9/ae/47/c9ae4709-c461-f729-23ef-2c68ad37341e/e5bf281e-54e4-46dc-b9a2-ca961cc99f81.png/1040x586sr.webp",
"title": "Music Videos"
},
{
"artworkBgColor": "#f83046",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/4f/db/a1/4fdba177-3af6-6bb7-2f43-7938e1f227a6/74e5adc8-240b-40e6-ad09-c54f426283bd.png/1040x586sr.webp",
"title": "Up Next"
},
{
"artworkBgColor": "#755323",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/56/76/7d/56767d51-89b9-e6ce-2e7b-c8c3cf954f47/e23544e8-6aad-45e7-b555-5695fd5f883a.png/1040x586sr.webp",
"title": "Decades"
},
{
"artworkBgColor": "#e25a80",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features/v4/75/6e/5e/756e5e72-22bc-57de-7f2b-c6d042417879/00eb9147-24ac-4223-842e-519e19f36f7f.png/1040x586sr.webp",
"title": "Pop Latino"
},
{
"artworkBgColor": "#ae2b29",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features126/v4/b5/c6/d2/b5c6d2ad-0e69-1d37-d7a0-3699d4f4909b/2b419129-d471-44fd-bbfa-2d36d07a6787.png/1040x586sr.webp",
"title": "Metal"
},
{
"artworkBgColor": "#dab10d",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features126/v4/e9/73/69/e97369b3-c68c-1047-6086-08ae0c747469/9d4f61ac-4d2f-4546-98b3-4db3b1ccb01b.png/1040x586sr.webp",
"title": "2000s"
},
{
"artworkBgColor": "#34b799",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/72/0f/7b/720f7bc9-97dd-9665-8394-aee9f279fa2a/9222ca98-8663-4ae9-889b-79eb10fe5acb.png/1040x586sr.webp",
"title": "Indie"
},
{
"artworkBgColor": "#3cbb7d",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features221/v4/63/a2/0b/63a20b1e-f138-8ba9-5c1f-b1533907ce42/ed74c8f0-2343-4c38-b3e3-e29cb75afbe4.png/1040x586sr.webp",
"title": "Electronic"
},
{
"artworkBgColor": "#363110",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features221/v4/ad/69/1c/ad691cf0-f626-6050-3361-28e0995849bf/f964cbb9-d787-4faf-97b8-73958872c4c1.png/1040x586sr.webp",
"title": "Behind the Songs"
},
{
"artworkBgColor": "#29a5c9",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/64/34/e6/6434e622-46be-5e71-74ba-44b74e98c3a3/a16e4f0f-9eed-4f8d-8d14-b960760dccf5.png/1040x586sr.webp",
"title": "Jazz"
},
{
"artworkBgColor": "#7ab539",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features211/v4/5b/90/cd/5b90cd44-2a7d-39aa-2992-f3af5b9f98b1/335ccbc3-fcdd-4082-8367-fe8df5ffd9ad.png/1040x586sr.webp",
"title": "Reggae"
},
{
"artworkBgColor": "#0a070f",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features116/v4/d0/69/d3/d069d3d0-b4ea-07a3-a95c-7f025adf8f60/4e941745-d668-49d1-90c3-c2a53340097f.png/1040x586sr.webp",
"title": "Film, TV & Stage"
},
{
"artworkBgColor": "#881834",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features221/v4/01/42/38/0142387d-608e-45d2-c839-fb534d5d4259/1e691432-e96a-4042-aea5-79c6d0bbb9af.png/1040x586sr.webp",
"title": "Motivation"
},
{
"artworkBgColor": "#8280e7",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features221/v4/46/52/f7/4652f73c-1785-4273-3282-2c4b116597d6/9a4c5c53-a845-48b2-a1a6-762f4e688df8.png/1040x586sr.webp",
"title": "Soul/Funk"
},
{
"artworkBgColor": "#53b69e",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features221/v4/e2/e4/ef/e2e4ef7e-2611-91a2-bd17-faefa1ac23a7/009cfa78-893d-4e6a-8c8d-2592de8b83f7.png/1040x586sr.webp",
"title": "Wellbeing"
},
{
"artworkBgColor": "#dab10d",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features116/v4/17/9d/5e/179d5e31-c15a-08f8-78eb-a7e83ecbf2f0/d676ae15-36eb-4748-9a67-1bdb5ba8efaa.png/1040x586sr.webp",
"title": "2010s"
},
{
"artworkBgColor": "#dab10d",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features126/v4/73/c5/16/73c51608-bb46-c3f4-ee8f-44f534829ca4/8d7abe21-9bc8-49bc-b8ff-8e948ec081b3.png/1040x586sr.webp",
"title": "’60s"
},
{
"artworkBgColor": "#cd8028",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features116/v4/b1/86/89/b1868989-f8c4-2e7a-b3ea-317ae3b72aa1/6f017db2-efe7-4c64-bddf-2d909e2f4ae2.png/1040x586sr.webp",
"title": "Americana"
},
{
"artworkBgColor": "#4997d5",
"artworkImage": "https://is1-ssl.mzstatic.com/image/thumb/Features221/v4/5d/f0/0d/5df00dca-a4b7-2da5-026c-84dd8520de75/14a698b9-248f-4ea9-8f02-1a341c401735.png/1040x586sr.webp",
"title": "Blues"
}
]
export default function SearchScreen() {
return (
<ScrollView
style={styles.container}
contentInsetAdjustmentBehavior="automatic"
>
<ThemedText style={styles.title}>Browse Categories</ThemedText>
<View style={styles.categoriesContainer}>
{categories.map((category, index) => (
<View key={index} style={styles.categoryWrapper}>
<CategoryCard
title={category.title}
backgroundColor={category.artworkBgColor}
imageUrl={category.artworkImage}
/>
</View>
))}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
title: {
fontSize: 24,
fontWeight: 'bold',
marginHorizontal: 16,
marginTop: 16,
},
container: {
flex: 1,
// backgroundColor: '#fff',
},
categoriesContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
padding: 16,
},
categoryWrapper: {
width: '48%',
},
});
================================================
FILE: app/+html.tsx
================================================
import { ScrollViewStyleReset } from 'expo-router/html';
import { type PropsWithChildren } from 'react';
/**
* This file is web-only and used to configure the root HTML for every web page during static rendering.
* The contents of this function only run in Node.js environments and do not have access to the DOM or browser APIs.
*/
export default function Root({ children }: PropsWithChildren) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;
================================================
FILE: app/+not-found.tsx
================================================
import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<ThemedView style={styles.container}>
<ThemedText type="title">This screen doesn't exist.</ThemedText>
<Link href="/" style={styles.link}>
<ThemedText type="link">Go to home screen!</ThemedText>
</Link>
</ThemedView>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
link: {
marginTop: 15,
paddingVertical: 15,
},
});
================================================
FILE: app/_layout.tsx
================================================
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import { StyleSheet, useColorScheme, View } from 'react-native';
import { RootScaleProvider } from '@/contexts/RootScaleContext';
import { useRootScale } from '@/contexts/RootScaleContext';
import Animated, { useAnimatedStyle } from 'react-native-reanimated';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { OverlayProvider } from '@/components/Overlay/OverlayProvider';
import { AudioProvider } from '@/contexts/AudioContext';
import { MiniPlayer } from '@/components/BottomSheet/MiniPlayer';
import { useRouter } from 'expo-router';
import { useAudio } from '@/contexts/AudioContext';
function AnimatedStack() {
const { scale } = useRootScale();
const router = useRouter();
const { currentSong, isPlaying, togglePlayPause } = useAudio();
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [
{ scale: scale.value },
{
translateY: (1 - scale.value) * -150,
},
],
};
});
return (
<View style={{ flex: 1 }}>
<Animated.View style={[styles.stackContainer, animatedStyle]}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="music/[id]"
options={{
presentation: 'transparentModal',
headerShown: false,
contentStyle: {
backgroundColor: 'transparent',
},
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
{currentSong && (
<MiniPlayer
song={currentSong}
isPlaying={isPlaying}
onPlayPause={togglePlayPause}
onPress={() => router.push(`/music/${currentSong.id}`)}
/>
)}
</Animated.View>
{/* putting anything here is not scalled down upon modal open */}
</View>
);
}
export default function RootLayout() {
const colorScheme = useColorScheme();
useEffect(() => {
SplashScreen.hideAsync();
}, []);
return (
<GestureHandlerRootView style={styles.container}>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<RootScaleProvider>
<AudioProvider>
<OverlayProvider>
<AnimatedStack />
</OverlayProvider>
</AudioProvider>
</RootScaleProvider>
</ThemeProvider>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
stackContainer: {
flex: 1,
overflow: 'hidden',
borderRadius: 50,
},
});
================================================
FILE: app/music/[id].tsx
================================================
import { useLocalSearchParams, useRouter } from 'expo-router';
import { StyleSheet, Dimensions } from 'react-native';
import { useEffect, useCallback, useRef } from 'react';
import { StatusBar } from 'expo-status-bar';
import { ThemedView } from '@/components/ThemedView';
import { ExpandedPlayer } from '@/components/BottomSheet/ExpandedPlayer';
import { useRootScale } from '@/contexts/RootScaleContext';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
} from 'react-native-reanimated';
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
import { songs } from '@/data/songs.json';
import * as Haptics from 'expo-haptics';
const SCALE_FACTOR = 0.83;
const DRAG_THRESHOLD = Math.min(Dimensions.get('window').height * 0.20, 150);
const HORIZONTAL_DRAG_THRESHOLD = Math.min(Dimensions.get('window').width * 0.51, 80);
const DIRECTION_LOCK_ANGLE = 45; // Angle in degrees to determine horizontal vs vertical movement
const ENABLE_HORIZONTAL_DRAG_CLOSE = false;
export default function MusicScreen() {
const { id } = useLocalSearchParams();
const router = useRouter();
const { setScale } = useRootScale();
const translateY = useSharedValue(0);
const isClosing = useRef(false);
const statusBarStyle = useSharedValue<'light' | 'dark'>('light');
const scrollOffset = useSharedValue(0);
const isDragging = useSharedValue(false);
const translateX = useSharedValue(0);
const initialGestureX = useSharedValue(0);
const initialGestureY = useSharedValue(0);
const isHorizontalGesture = useSharedValue(false);
const isScrolling = useSharedValue(false);
const numericId = typeof id === 'string' ? parseInt(id, 10) : Array.isArray(id) ? parseInt(id[0], 10) : 0;
const song = songs.find(s => s.id === numericId) || songs[0];
const handleHapticFeedback = useCallback(() => {
try {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
} catch (error) {
console.log('Haptics not available:', error);
}
}, []);
const goBack = useCallback(() => {
if (!isClosing.current) {
isClosing.current = true;
handleHapticFeedback();
requestAnimationFrame(() => {
router.back();
});
}
}, [router, handleHapticFeedback]);
const handleScale = useCallback((newScale: number) => {
try {
setScale(newScale);
} catch (error) {
console.log('Scale error:', error);
}
}, [setScale]);
const calculateGestureAngle = (x: number, y: number) => {
'worklet';
const angle = Math.abs(Math.atan2(y, x) * (180 / Math.PI));
return angle;
};
const panGesture = Gesture.Pan()
.onStart((event) => {
'worklet';
initialGestureX.value = event.x;
initialGestureY.value = event.y;
isHorizontalGesture.value = false;
if (scrollOffset.value <= 0) {
isDragging.value = true;
translateY.value = 0;
}
})
.onUpdate((event) => {
'worklet';
const dx = event.translationX;
const dy = event.translationY;
const angle = calculateGestureAngle(dx, dy);
// Only check for horizontal gesture if enabled
if (ENABLE_HORIZONTAL_DRAG_CLOSE && !isHorizontalGesture.value && !isScrolling.value) {
if (Math.abs(dx) > 10) {
if (angle < DIRECTION_LOCK_ANGLE) {
isHorizontalGesture.value = true;
}
}
}
// Handle horizontal gesture only if enabled
if (ENABLE_HORIZONTAL_DRAG_CLOSE && isHorizontalGesture.value) {
translateX.value = dx;
translateY.value = dy;
const totalDistance = Math.sqrt(dx * dx + dy * dy);
const progress = Math.min(totalDistance / 300, 1);
const newScale = SCALE_FACTOR + (progress * (1 - SCALE_FACTOR));
runOnJS(handleScale)(newScale);
if (progress > 0.2) {
statusBarStyle.value = 'dark';
} else {
statusBarStyle.value = 'light';
}
}
// Handle vertical-only gesture
else if (scrollOffset.value <= 0 && isDragging.value) {
translateY.value = Math.max(0, dy);
const progress = Math.min(dy / 600, 1);
const newScale = SCALE_FACTOR + (progress * (1 - SCALE_FACTOR));
runOnJS(handleScale)(newScale);
if (progress > 0.5) {
statusBarStyle.value = 'dark';
} else {
statusBarStyle.value = 'light';
}
}
})
.onEnd((event) => {
'worklet';
isDragging.value = false;
// Handle horizontal gesture end only if enabled
if (ENABLE_HORIZONTAL_DRAG_CLOSE && isHorizontalGesture.value) {
const dx = event.translationX;
const dy = event.translationY;
const totalDistance = Math.sqrt(dx * dx + dy * dy);
const shouldClose = totalDistance > HORIZONTAL_DRAG_THRESHOLD;
if (shouldClose) {
// Calculate the exit direction based on the gesture
const exitX = dx * 2;
const exitY = dy * 2;
translateX.value = withTiming(exitX, { duration: 300 });
translateY.value = withTiming(exitY, { duration: 300 });
runOnJS(handleScale)(1);
runOnJS(handleHapticFeedback)();
runOnJS(goBack)();
} else {
// Spring back to original position
translateX.value = withSpring(0, {
damping: 15,
stiffness: 150,
});
translateY.value = withSpring(0, {
damping: 15,
stiffness: 150,
});
runOnJS(handleScale)(SCALE_FACTOR);
}
}
// Handle vertical gesture end
else if (scrollOffset.value <= 0) {
const shouldClose = event.translationY > DRAG_THRESHOLD;
if (shouldClose) {
translateY.value = withTiming(event.translationY + 100, {
duration: 300,
});
runOnJS(handleScale)(1);
runOnJS(handleHapticFeedback)();
runOnJS(goBack)();
} else {
translateY.value = withSpring(0, {
damping: 15,
stiffness: 150,
});
runOnJS(handleScale)(SCALE_FACTOR);
}
}
})
.onFinalize(() => {
'worklet';
isDragging.value = false;
isHorizontalGesture.value = false;
});
const scrollGesture = Gesture.Native()
.onBegin(() => {
'worklet';
isScrolling.value = true;
if (!isDragging.value) {
translateY.value = 0;
}
})
.onEnd(() => {
'worklet';
isScrolling.value = false;
});
const composedGestures = Gesture.Simultaneous(panGesture, scrollGesture);
const ScrollComponent = useCallback((props: any) => {
return (
<GestureDetector gesture={composedGestures}>
<Animated.ScrollView
{...props}
onScroll={(event) => {
'worklet';
scrollOffset.value = event.nativeEvent.contentOffset.y;
if (!isDragging.value && translateY.value !== 0) {
translateY.value = 0;
}
props.onScroll?.(event);
}}
scrollEventThrottle={16}
// bounces={scrollOffset.value >= 0 && !isDragging.value}
bounces={false}
/>
</GestureDetector>
);
}, [composedGestures]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateY: translateY.value },
{ translateX: translateX.value }
],
opacity: withSpring(1),
}));
useEffect(() => {
const timeout = setTimeout(() => {
try {
setScale(SCALE_FACTOR);
} catch (error) {
console.log('Initial scale error:', error);
}
}, 0);
return () => {
clearTimeout(timeout);
try {
setScale(1);
} catch (error) {
console.log('Cleanup scale error:', error);
}
};
}, []);
return (
<ThemedView style={styles.container}>
<StatusBar animated={true} style={statusBarStyle.value} />
<Animated.View style={[styles.modalContent, animatedStyle]}>
<ExpandedPlayer scrollComponent={ScrollComponent} />
</Animated.View>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'transparent',
},
modalContent: {
flex: 1,
backgroundColor: 'transparent',
},
});
================================================
FILE: app.json
================================================
{
"expo": {
"name": "apple-music-sheet-ui",
"slug": "apple-music-sheet-ui",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "myapp",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": ["expo-router"],
"experiments": {
"typedRoutes": true
}
}
}
================================================
FILE: babel.config.js
================================================
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
================================================
FILE: components/BottomSheet/ExpandedPlayer.tsx
================================================
import { View as ThemedView, StyleSheet, Image, Pressable, Dimensions, ScrollView } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { ThemedText } from '@/components/ThemedText';
// import { ThemedView } from '@/components/ThemedView';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { Audio } from 'expo-av';
import { useEffect, useState, useCallback } from 'react';
import { useAudio } from '@/contexts/AudioContext';
const { width } = Dimensions.get('window');
import {
useSafeAreaInsets,
} from 'react-native-safe-area-context';
function shadeColor(color: string, percent: number): string {
const R = parseInt(color.substring(1, 3), 16);
const G = parseInt(color.substring(3, 5), 16);
const B = parseInt(color.substring(5, 7), 16);
let newR = Math.round((R * (100 + percent)) / 100);
let newG = Math.round((G * (100 + percent)) / 100);
let newB = Math.round((B * (100 + percent)) / 100);
newR = newR < 255 ? newR : 255;
newG = newG < 255 ? newG : 255;
newB = newB < 255 ? newB : 255;
const RR = ((newR.toString(16).length === 1) ? "0" + newR.toString(16) : newR.toString(16));
const GG = ((newG.toString(16).length === 1) ? "0" + newG.toString(16) : newG.toString(16));
const BB = ((newB.toString(16).length === 1) ? "0" + newB.toString(16) : newB.toString(16));
return "#" + RR + GG + BB;
}
interface ExpandedPlayerProps {
scrollComponent?: (props: any) => React.ReactElement;
}
export function ExpandedPlayer({ scrollComponent }: ExpandedPlayerProps) {
const ScrollComponentToUse = scrollComponent || ScrollView;
const {
isPlaying,
position,
duration,
togglePlayPause,
sound,
currentSong,
playNextSong,
playPreviousSong
} = useAudio();
const insets = useSafeAreaInsets();
const colorToUse = currentSong?.artwork_bg_color || "#000000";
const colors = [colorToUse, shadeColor(colorToUse, -50)];
const handleSkipForward = async () => {
if (sound) {
await sound.setPositionAsync(Math.min(duration, position + 10000));
}
};
const handleSkipBackward = async () => {
if (sound) {
await sound.setPositionAsync(Math.max(0, position - 10000));
}
};
const formatTime = (millis: number) => {
const minutes = Math.floor(millis / 60000);
const seconds = ((millis % 60000) / 1000).toFixed(0);
return `${minutes}:${Number(seconds) < 10 ? '0' : ''}${seconds}`;
};
const progress = duration > 0 ? (position / duration) * 100 : 0;
// Add sample lyrics (you should get this from your song data)
const lyrics = [
"Verse 1",
"First line of the song",
"Second line of the song",
"Third line goes here",
"",
"Chorus",
"This is the chorus",
"Another chorus line",
"Final chorus line",
"",
"Verse 2",
"Back to the verses",
"More lyrics here",
"And here as well",
// Add more lyrics as needed
];
return (
<LinearGradient
colors={colors}
style={[styles.rootContainer, { paddingTop: insets.top }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<ThemedView style={styles.dragHandleContainer}>
<ThemedView style={styles.dragHandle} />
</ThemedView>
<ScrollComponentToUse
style={styles.scrollView}
showsVerticalScrollIndicator={false}
>
<ThemedView style={styles.container}>
<ThemedView style={styles.artworkContainer}>
<Image
source={{ uri: currentSong?.artwork }}
style={styles.artwork}
/>
</ThemedView>
<ThemedView style={styles.controls}>
<ThemedView style={styles.titleContainer}>
<ThemedView style={styles.titleRow}>
<ThemedView style={styles.titleMain}>
<ThemedText type="title" style={styles.title}>
{currentSong?.title}
</ThemedText>
<ThemedText style={styles.artist}>
{currentSong?.artist}
</ThemedText>
</ThemedView>
<ThemedView style={styles.titleIcons}>
<Pressable style={styles.iconButton}>
<Ionicons name="star-outline" size={18} color="#fff" />
</Pressable>
<Pressable style={styles.iconButton}>
<Ionicons name="ellipsis-horizontal" size={18} color="#fff" />
</Pressable>
</ThemedView>
</ThemedView>
<ThemedView style={styles.progressBar}>
<ThemedView
style={[
styles.progress,
{ width: `${progress}%` }
]}
/>
</ThemedView>
<ThemedView style={styles.timeContainer}>
<ThemedText style={styles.timeText}>
{formatTime(position)}
</ThemedText>
<ThemedText style={styles.timeText}>
-{formatTime(Math.max(0, duration - position))}
</ThemedText>
</ThemedView>
<ThemedView style={styles.buttonContainer}>
<Pressable style={styles.button} onPress={playPreviousSong}>
<Ionicons name="play-skip-back" size={35} color="#fff" />
</Pressable>
<Pressable style={[styles.button, styles.playButton]} onPress={togglePlayPause}>
<Ionicons name={isPlaying ? "pause" : "play"} size={45} color="#fff" />
</Pressable>
<Pressable style={styles.button} onPress={playNextSong}>
<Ionicons name="play-skip-forward" size={35} color="#fff" />
</Pressable>
</ThemedView>
</ThemedView>
<ThemedView>
<ThemedView style={styles.volumeControl}>
<Ionicons name="volume-off" size={24} color="#fff" />
<ThemedView style={styles.volumeBar}>
<ThemedView style={styles.volumeProgress} />
</ThemedView>
<Ionicons name="volume-high" size={24} color="#fff" />
</ThemedView>
<ThemedView style={styles.extraControls}>
<Pressable style={styles.extraControlButton}>
<Ionicons name="chatbubble-outline" size={24} color="#fff" />
</Pressable>
<Pressable style={styles.extraControlButton}>
<ThemedView style={styles.extraControlIcons}>
<Ionicons name="volume-off" size={26} color="#fff" marginRight={-6} />
<Ionicons name="bluetooth" size={24} color="#fff" />
</ThemedView>
<ThemedText style={styles.extraControlText}>Px8</ThemedText>
</Pressable>
<Pressable style={styles.extraControlButton}>
<Ionicons name="list-outline" size={24} color="#fff" />
</Pressable>
</ThemedView>
</ThemedView>
</ThemedView>
{/* Add lyrics section after the controls */}
<ThemedView style={styles.lyricsContainer}>
{lyrics.map((line, index) => (
<ThemedText
key={index}
style={[
styles.lyricsText,
line === "" && styles.lyricsSpacing
]}
>
{line}
</ThemedText>
))}
</ThemedView>
</ThemedView>
</ScrollComponentToUse>
</LinearGradient>
);
}
const styles = StyleSheet.create({
rootContainer: {
flex: 1,
height: '100%',
width: '100%',
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
},
dragHandle: {
width: 40,
height: 5,
backgroundColor: 'rgba(255, 255, 255, 0.445)',
borderRadius: 5,
alignSelf: 'center',
marginTop: 10,
},
container: {
flex: 1,
alignItems: 'center',
padding: 20,
paddingTop: 30,
backgroundColor: 'transparent',
justifyContent: 'space-between',
},
artworkContainer: {
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 8,
},
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 12,
backgroundColor: 'transparent', // Required for Android shadows
marginBottom: 34,
},
artwork: {
width: width - 52,
height: width - 52,
borderRadius: 8,
},
controls: {
width: '100%',
backgroundColor: 'transparent',
flex: 1,
justifyContent: 'space-between',
},
titleContainer: {
// marginBottom: -30,
backgroundColor: 'transparent',
width: '100%',
marginTop: 12
},
titleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
},
titleMain: {
flex: 1,
},
titleIcons: {
flexDirection: 'row',
gap: 15,
},
title: {
fontSize: 21,
// marginBottom: 8,
marginBottom: -4,
color: '#fff',
},
artist: {
fontSize: 19,
opacity: 0.7,
color: '#fff',
},
progressBar: {
height: 6,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
borderRadius: 5,
marginBottom: 10,
marginTop: 30,
},
progress: {
width: '30%',
height: '100%',
backgroundColor: '#ffffff6a',
borderRadius: 5,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
timeContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 20,
backgroundColor: 'transparent',
},
timeText: {
fontSize: 12,
opacity: 0.6,
color: '#fff',
},
buttonContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 50,
backgroundColor: 'transparent',
marginTop: 10,
},
button: {
padding: 10,
},
playButton: {
transform: [{ scale: 1.2 }],
},
volumeControl: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingHorizontal: 10,
},
volumeBar: {
flex: 1,
height: 6,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
borderRadius: 20,
},
volumeProgress: {
width: '70%',
height: '100%',
backgroundColor: '#fff',
borderRadius: 10,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
iconButton: {
width: 32,
height: 32,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
},
extraControls: {
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
width: '100%',
paddingHorizontal: 20,
marginTop: 26,
backgroundColor: 'transparent',
},
extraControlButton: {
alignItems: 'center',
// justifyContent: 'center',
opacity: 0.8,
height: 60,
},
extraControlText: {
color: '#fff',
fontSize: 13,
marginTop: 6,
opacity: 0.7,
fontWeight: '600',
},
extraControlIcons: {
flexDirection: 'row',
},
scrollView: {
flex: 1,
width: '100%',
},
lyricsContainer: {
paddingHorizontal: 20,
paddingVertical: 30,
width: '100%',
alignItems: 'center',
},
lyricsText: {
color: '#fff',
fontSize: 16,
lineHeight: 24,
textAlign: 'center',
opacity: 0.8,
marginVertical: 2,
},
lyricsSpacing: {
marginVertical: 10,
},
dragHandleContainer: {
paddingBottom: 14,
},
});
================================================
FILE: components/BottomSheet/MiniPlayer.tsx
================================================
import { StyleSheet, Pressable, Image, Platform } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { Ionicons } from '@expo/vector-icons';
import { Audio } from 'expo-av';
import { useState, useEffect } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BlurView } from 'expo-blur';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useAudio } from '@/contexts/AudioContext';
export function MiniPlayer({ onPress, song, isPlaying, onPlayPause }: MiniPlayerProps) {
const insets = useSafeAreaInsets();
const colorScheme = useColorScheme();
// Calculate bottom position considering tab bar height
const bottomPosition = Platform.OS === 'ios' ? insets.bottom + 57 : 60;
return (
<Pressable onPress={onPress} style={[
styles.container,
{ bottom: bottomPosition }
]}>
{Platform.OS === 'ios' ? (
<BlurView
tint={colorScheme === 'dark' ? 'systemThickMaterialDark' : 'systemThickMaterialLight'}
intensity={80}
style={[styles.content, styles.blurContainer]}>
<MiniPlayerContent song={song} isPlaying={isPlaying} onPlayPause={onPlayPause} />
</BlurView>
) : (
<ThemedView style={[styles.content, styles.androidContainer]}>
<MiniPlayerContent song={song} isPlaying={isPlaying} onPlayPause={onPlayPause} />
</ThemedView>
)}
</Pressable>
);
}
// Extract the content into a separate component for reusability
function MiniPlayerContent({ song, isPlaying, onPlayPause }: {
song: any;
isPlaying: boolean;
onPlayPause: () => void;
}) {
const colorScheme = useColorScheme();
const { playNextSong } = useAudio();
return (
<ThemedView style={[styles.miniPlayerContent, { backgroundColor: colorScheme === 'light' ? '#ffffffa4' : 'transparent' }]}>
<Image
source={{ uri: song.artwork }}
style={styles.artwork}
/>
<ThemedView style={styles.textContainer}>
<ThemedText style={styles.title}>{song.title}</ThemedText>
</ThemedView>
<ThemedView style={styles.controls}>
<Pressable style={styles.controlButton} onPress={onPlayPause}>
<Ionicons name={isPlaying ? "pause" : "play"} size={24} color={colorScheme === 'light' ? '#000' : '#fff'} />
</Pressable>
<Pressable style={styles.controlButton} onPress={playNextSong}>
<Ionicons name="play-forward" size={24} color={colorScheme === 'light' ? '#000' : '#fff'} />
</Pressable>
</ThemedView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
left: 0,
right: 0,
height: 56,
zIndex: 1000,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
},
content: {
flexDirection: 'row',
alignItems: 'center',
// height: 40,
marginHorizontal: 10,
borderRadius: 12,
overflow: 'hidden',
zIndex: 1000,
flex: 1,
paddingVertical: 0,
},
miniPlayerContent: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
height: '100%',
paddingHorizontal: 10,
// backgroundColor: '#ffffffa4',
},
blurContainer: {
// backgroundColor: '#00000000',
},
androidContainer: {
},
title: {
fontWeight: '500',
},
artwork: {
width: 40,
height: 40,
borderRadius: 8,
},
textContainer: {
flex: 1,
marginLeft: 12,
backgroundColor: 'transparent',
},
controls: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginRight: 4,
backgroundColor: 'transparent',
},
controlButton: {
padding: 8,
},
});
interface MiniPlayerProps {
onPress: () => void;
song: any;
sound?: Audio.Sound | null;
isPlaying: boolean;
onPlayPause: () => void;
}
================================================
FILE: components/CategoryCard.tsx
================================================
import { View, Text, Pressable, ImageBackground } from 'react-native';
import React from 'react';
import { Link } from 'expo-router';
type CategoryCardProps = {
title: string;
backgroundColor: string;
imageUrl?: string;
size?: 'large' | 'small';
};
export function CategoryCard({ title, backgroundColor, imageUrl, size = 'small' }: CategoryCardProps) {
return (
<Link href={`/category/${title.toLowerCase()}`} asChild>
<Pressable>
<View
style={{
width: '100%',
aspectRatio: 1.5,
backgroundColor,
borderRadius: 10,
overflow: 'hidden',
}}>
{imageUrl && (
<ImageBackground
source={{ uri: imageUrl }}
style={{
width: '100%',
height: '100%',
justifyContent: 'flex-end',
}}>
<View style={{ padding: 16 }}>
<Text
style={{
color: 'white',
fontSize: 24,
fontWeight: '600',
}}>
{title}
</Text>
</View>
</ImageBackground>
)}
{!imageUrl && (
<View style={{
padding: 16,
flex: 1,
justifyContent: 'flex-end'
}}>
<Text
style={{
color: 'white',
fontSize: 24,
fontWeight: '600',
}}>
{title}
</Text>
</View>
)}
</View>
</Pressable>
</Link>
);
}
================================================
FILE: components/MusicVisualizer.tsx
================================================
import { useEffect, useRef, useState } from 'react';
import { View, Animated, StyleSheet } from 'react-native';
interface Props {
isPlaying: boolean;
}
const BAR_COUNT = 5;
const ANIMATION_DURATION = 300;
export function MusicVisualizer({ isPlaying }: Props) {
const animatedValues = useRef(
Array(BAR_COUNT).fill(0).map(() => new Animated.Value(0))
).current;
const [prominentBar, setProminentBar] = useState(0);
const randomScales = useRef(Array(BAR_COUNT).fill(0).map(() => 0.3 + Math.random() * 0.4)).current;
useEffect(() => {
let prominentInterval: NodeJS.Timeout;
if (isPlaying) {
prominentInterval = setInterval(() => {
setProminentBar(prev => (prev + 1) % BAR_COUNT);
randomScales.forEach((_, i) => {
randomScales[i] = 0.3 + Math.random() * 0.4;
});
}, 250);
const animations = animatedValues.map((value, index) => {
return Animated.sequence([
Animated.timing(value, {
toValue: 1,
duration: ANIMATION_DURATION * (0.2 + Math.random() * 0.3),
useNativeDriver: true,
}),
Animated.timing(value, {
toValue: 0,
duration: ANIMATION_DURATION * (0.2 + Math.random() * 0.3),
useNativeDriver: true,
}),
]);
});
const loop = Animated.loop(Animated.parallel(animations));
loop.start();
return () => {
loop.stop();
clearInterval(prominentInterval);
};
} else {
animatedValues.forEach(value => value.setValue(0));
}
}, [isPlaying]);
if (!isPlaying) return null;
return (
<View style={styles.container}>
{animatedValues.map((value, index) => (
<Animated.View
key={index}
style={[
styles.bar,
{
transform: [
{
scaleY: value.interpolate({
inputRange: [0, 1],
outputRange: [0.2, index === prominentBar ? 1.4 : randomScales[index]],
}),
},
],
},
]}
/>
))}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.3)',
},
bar: {
width: 2.5,
height: 16,
backgroundColor: '#fff',
borderRadius: 1,
},
});
================================================
FILE: components/Overlay/OverlayContext.tsx
================================================
import { createContext, useContext } from 'react';
import { ViewStyle } from 'react-native';
export interface OverlayView {
id: string;
component: React.ReactNode;
style?: ViewStyle;
}
interface OverlayContextType {
views: OverlayView[];
addOverlay: (view: Omit<OverlayView, 'id'>) => string;
removeOverlay: (id: string) => void;
}
export const OverlayContext = createContext<OverlayContextType>({
views: [],
addOverlay: () => '',
removeOverlay: () => { },
});
export const useOverlay = () => useContext(OverlayContext);
================================================
FILE: components/Overlay/OverlayProvider.tsx
================================================
import React, { useState, useCallback } from 'react';
import { StyleSheet, View } from 'react-native';
import { OverlayContext, OverlayView } from './OverlayContext';
export const OverlayProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [views, setViews] = useState<OverlayView[]>([]);
const addOverlay = useCallback((view: Omit<OverlayView, 'id'>) => {
const id = Math.random().toString(36).substr(2, 9);
setViews(prev => [...prev, { ...view, id }]);
return id;
}, []);
const removeOverlay = useCallback((id: string) => {
setViews(prev => prev.filter(view => view.id !== id));
}, []);
return (
<OverlayContext.Provider value={{ views, addOverlay, removeOverlay }}>
<View style={styles.container}>
{children}
{views.map(view => (
<View key={view.id} style={[styles.overlay, view.style]}>
{view.component}
</View>
))}
</View>
</OverlayContext.Provider>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'transparent',
},
});
================================================
FILE: components/ParallaxScrollView.tsx
================================================
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet, useColorScheme } from 'react-native';
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollViewOffset,
} from 'react-native-reanimated';
import { ThemedView } from '@/components/ThemedView';
const HEADER_HEIGHT = 300;
type Props = PropsWithChildren<{
headerImage: ReactElement;
headerBackgroundColor: { dark: string; light: string };
}>;
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const colorScheme = useColorScheme() ?? 'light';
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(scrollRef);
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
};
});
return (
<ThemedView style={styles.container}>
<Animated.ScrollView ref={scrollRef} scrollEventThrottle={16}>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: 280,
overflow: 'hidden',
},
content: {
flex: 1,
// padding: 20,
gap: 16,
overflow: 'hidden',
},
});
================================================
FILE: components/ThemedText.tsx
================================================
import { Text, type TextProps, StyleSheet } from 'react-native';
import { useThemeColor } from '@/hooks/useThemeColor';
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
};
export function ThemedText({
style,
lightColor,
darkColor,
type = 'default',
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return (
<Text
style={[
{ color },
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
});
================================================
FILE: components/ThemedView.tsx
================================================
import { View, type ViewProps } from 'react-native';
import { useThemeColor } from '@/hooks/useThemeColor';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
}
================================================
FILE: components/navigation/TabBarIcon.tsx
================================================
import Ionicons from '@expo/vector-icons/Ionicons';
import { type IconProps } from '@expo/vector-icons/build/createIconSet';
import { type ComponentProps } from 'react';
export function TabBarIcon({ style, ...rest }: IconProps<ComponentProps<typeof Ionicons>['name']>) {
return <Ionicons size={28} style={[{ marginBottom: -3 }, style]} {...rest} />;
}
================================================
FILE: constants/Colors.ts
================================================
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
const tintColorLight = '#0a7ea4';
const tintColorDark = '#fff';
export const Colors = {
light: {
text: '#11181C',
background: '#fff',
tint: tintColorLight,
icon: '#687076',
tabIconDefault: '#687076',
tabIconSelected: tintColorLight,
},
dark: {
text: '#ECEDEE',
background: '#151718',
tint: tintColorDark,
icon: '#9BA1A6',
tabIconDefault: '#9BA1A6',
tabIconSelected: tintColorDark,
},
};
================================================
FILE: contexts/AudioContext.tsx
================================================
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { Audio } from 'expo-av';
import { songs } from '@/data/songs.json';
interface Song {
id: number;
title: string;
artist: string;
artwork: string;
artwork_bg_color?: string;
mp4_link?: string;
}
interface AudioContextType {
sound: Audio.Sound | null;
isPlaying: boolean;
currentSong: Song | null;
position: number;
duration: number;
setSound: (sound: Audio.Sound | null) => void;
setIsPlaying: (isPlaying: boolean) => void;
setCurrentSong: (song: Song) => void;
playSound: (song: Song) => Promise<void>;
pauseSound: () => Promise<void>;
togglePlayPause: () => Promise<void>;
playNextSong: () => Promise<void>;
playPreviousSong: () => Promise<void>;
}
const AudioContext = createContext<AudioContextType | undefined>(undefined);
export function AudioProvider({ children }: { children: React.ReactNode }) {
const [sound, setSound] = useState<Audio.Sound | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentSong, setCurrentSong] = useState<Song | null>(null);
const [position, setPosition] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
return () => {
if (sound) {
sound.unloadAsync();
}
};
}, []);
useEffect(() => {
const setupAudio = async () => {
try {
await Audio.setAudioModeAsync({
playsInSilentModeIOS: true,
staysActiveInBackground: true,
shouldDuckAndroid: true,
});
} catch (error) {
console.error('Error setting up audio mode:', error);
}
};
setupAudio();
}, []);
const playSound = async (song: Song) => {
try {
// If there's already a sound playing, stop it
if (sound) {
await sound.unloadAsync();
}
const { sound: newSound } = await Audio.Sound.createAsync(
{ uri: song.mp4_link },
{ shouldPlay: true },
onPlaybackStatusUpdate
);
setSound(newSound);
setCurrentSong(song);
setIsPlaying(true);
await newSound.playAsync();
} catch (error) {
console.error('Error playing sound:', error);
}
};
const pauseSound = async () => {
if (sound) {
await sound.pauseAsync();
setIsPlaying(false);
}
};
const togglePlayPause = async () => {
if (!sound || !currentSong) return;
if (isPlaying) {
await pauseSound();
} else {
await sound.playAsync();
setIsPlaying(true);
}
};
const onPlaybackStatusUpdate = useCallback(async (status: Audio.PlaybackStatus) => {
if (!status.isLoaded) return;
setPosition(status.positionMillis);
setDuration(status.durationMillis || 0);
setIsPlaying(status.isPlaying);
// Check if the song has finished and isn't already loading the next song
if (status.didJustFinish && !status.isPlaying) {
console.log('Song finished, playing next song'); // Debug log
await playNextSong();
}
}, [playNextSong]);
const playNextSong = useCallback(async () => {
const currentIndex = songs.findIndex(song => song.id === currentSong?.id);
if (currentIndex === -1) return;
const nextSong = songs[(currentIndex + 1) % songs.length];
await playSound(nextSong);
}, [currentSong, songs]);
const playPreviousSong = useCallback(async () => {
const currentIndex = songs.findIndex(song => song.id === currentSong?.id);
if (currentIndex === -1) return;
const previousIndex = currentIndex === 0 ? songs.length - 1 : currentIndex - 1;
const previousSong = songs[previousIndex];
await playSound(previousSong);
}, [currentSong, songs]);
return (
<AudioContext.Provider value={{
sound,
isPlaying,
currentSong,
position,
duration,
setSound,
setIsPlaying,
setCurrentSong,
playSound,
pauseSound,
togglePlayPause,
playNextSong,
playPreviousSong,
}}>
{children}
</AudioContext.Provider>
);
}
export function useAudio() {
const context = useContext(AudioContext);
if (context === undefined) {
throw new Error('useAudio must be used within an AudioProvider');
}
return context;
}
================================================
FILE: contexts/RootScaleContext.tsx
================================================
import React, { createContext, useContext } from 'react';
import { SharedValue, useSharedValue, withSpring } from 'react-native-reanimated';
interface RootScaleContextType {
scale: SharedValue<number>;
setScale: (value: number) => void;
}
const RootScaleContext = createContext<RootScaleContextType | null>(null);
export function RootScaleProvider({ children }: { children: React.ReactNode }) {
const scale = useSharedValue(1);
const setScale = (value: number) => {
'worklet';
try {
scale.value = withSpring(value, {
damping: 15,
stiffness: 150,
mass: 0.5, // Added for smoother animation
});
} catch (error) {
console.warn('Scale animation error:', error);
scale.value = value;
}
};
return (
<RootScaleContext.Provider value={{ scale, setScale }}>
{children}
</RootScaleContext.Provider>
);
}
export const useRootScale = () => {
const context = useContext(RootScaleContext);
if (!context) {
throw new Error('useRootScale must be used within a RootScaleProvider');
}
return context;
};
================================================
FILE: data/songs.json
================================================
{
"songs": [
{
"id": 0,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview221/v4/72/53/c7/7253c7f2-b61f-baf3-3b4e-08171e35cfea/mzaf_921634286400386475.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/54/50/75/54507577-9e1d-c2c0-79dd-81e65aa7fb9d/197342550925_cover.jpg/247x247bb.jpg",
"title": "A Bar Song (Tipsy)",
"artist": "Shaboozey",
"artwork_bg_color": "#8B4513"
},
{
"id": 2,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview211/v4/ca/08/ce/ca08ce69-fa1f-e9bf-16da-dfd34aa80b49/mzaf_4558229648643978920.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music211/v4/92/9f/69/929f69f1-9977-3a44-d674-11f70c852d1b/24UMGIM36186.rgb.jpg/247x247bb.jpg",
"title": "Birds Of A Feather",
"artist": "Billie Eilish",
"artwork_bg_color": "#2F4F4F"
},
{
"id": 3,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview211/v4/97/a2/b8/97a2b874-e3de-5658-f889-5d0eedb72d3f/mzaf_13627043593740003520.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music211/v4/cb/64/8c/cb648cf7-e7bb-bd00-efe0-d744312c29a8/24UMGIM32304.rgb.jpg/247x247bb.jpg",
"title": "Espresso",
"artist": "Sabrina Carpenter",
"artwork_bg_color": "#CD853F"
},
{
"id": 4,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview211/v4/e9/d1/46/e9d14699-9505-493e-cd27-a501095c81ff/mzaf_7283388936457278756.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/11/ae/f2/11aef294-f57c-bab9-c9fc-529162984e62/24UMGIM85348.rgb.jpg/247x247bb.jpg",
"title": "Die With A Smile",
"artist": "Lady Gaga & Bruno Mars",
"artwork_bg_color": "#800080"
},
{
"id": 5,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview211/v4/c4/fa/e2/c4fae20a-494c-e06f-99e3-b6e820f8cc87/mzaf_7882276521788841354.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/e1/b7/09/e1b7098f-d6e1-2b18-fd6f-8390110908eb/24UMGIM50612.rgb.jpg/247x247bb.jpg",
"title": "I Had Some Help",
"artist": "Post Malone Featuring Morgan Wallen",
"artwork_bg_color": "#4B0082"
},
{
"id": 6,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview116/v4/3a/d0/30/3ad03076-a30e-9a8d-46bf-3cf3e36e31c9/mzaf_15630973341141916680.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/36/19/66/36196640-1561-dc5e-c6bc-1e5f4befa583/093624856771.jpg/247x247bb.jpg",
"title": "Lose Control",
"artist": "Teddy Swims",
"artwork_bg_color": "#FF4500"
},
{
"id": 7,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview211/v4/ae/57/5d/ae575db4-68db-508a-c012-421ee79bfa62/mzaf_5112451481032254728.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/29/a7/c4/29a7c478-351d-25eb-a116-3e68118cdab8/24UMGIM31246.rgb.jpg/247x247bb.jpg",
"title": "Good Luck, Babe!",
"artist": "Chappell Roan",
"artwork_bg_color": "#a55f47"
},
{
"id": 8,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview112/v4/bb/6b/c8/bb6bc802-78d2-c0b9-1434-cdcd7c6c7eb3/mzaf_5610996391320604947.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music115/v4/a5/c4/24/a5c424a5-8084-47ab-3fa8-e82f00faf1bf/888915632314_cover.jpg/247x247bb.jpg",
"title": "Taste",
"artist": "Sabrina Carpenter",
"artwork_bg_color": "#FF1493"
},
{
"id": 9,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview126/v4/2d/4e/7b/2d4e7b94-5521-568f-f269-c8643001d32b/mzaf_6034909346296341668.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music116/v4/54/f4/92/54f49210-e260-b519-ebbd-f4f40ee710cd/054391342751.jpg/247x247bb.jpg",
"title": "Beautiful Things",
"artist": "Benson Boone",
"artwork_bg_color": "#4682B4"
},
{
"id": 10,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview221/v4/78/6b/15/786b15ad-8958-8019-28e2-a29f1c64f9d4/mzaf_9652925359746163912.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music211/v4/f6/97/58/f69758d6-24d8-6e44-be5b-26819921bfc7/24UMGIM61704.rgb.jpg/247x247bb.jpg",
"title": "Please Please Please",
"artist": "Sabrina Carpenter",
"artwork_bg_color": "#DA70D6"
},
{
"id": 12,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview221/v4/df/05/e7/df05e74c-c4a1-b6ca-61b0-e2c36df3ebfd/mzaf_9714719170473697657.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/d0/ef/b6/d0efb685-73be-fdee-58c9-be655f4cd4fd/24UMGIM51924.rgb.jpg/247x247bb.jpg",
"title": "Not Like Us",
"artist": "Kendrick Lamar",
"artwork_bg_color": "#483D8B"
},
{
"id": 13,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview211/v4/58/77/2b/58772b8f-f76e-faaf-31dd-db5d8c57376e/mzaf_7312578909270296724.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/75/43/b9/7543b9da-b57f-4378-7eaf-1e02b49f5cc5/24UMGIM21983.rgb.jpg/247x247bb.jpg",
"title": "Too Sweet",
"artist": "Hozier",
"artwork_bg_color": "#8B4513"
},
{
"id": 14,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview211/v4/dd/4e/e4/dd4ee44f-4c61-68f9-d8e1-3bf5e87dc0d9/mzaf_2623955613726623758.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/ef/14/11/ef14117b-6681-088b-3c74-e127ca3b46ed/24UM1IM04061.rgb.jpg/247x247bb.jpg",
"title": "Timeless",
"artist": "The Weeknd & Playboi Carti",
"artwork_bg_color": "#000000"
},
{
"id": 15,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview221/v4/b1/bf/90/b1bf9092-a386-33a9-8312-ce9ee450d317/mzaf_4279519707152699387.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/95/b9/ca/95b9ca00-29cb-8edc-1ecb-5bda742f3177/24UMGIM62166.rgb.jpg/247x247bb.jpg",
"title": "I Am Not Okay",
"artist": "Jelly Roll",
"artwork_bg_color": "#8B0000"
},
{
"id": 16,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview211/v4/ef/96/da/ef96da4c-8118-a432-bdc2-5ac2c05627a1/mzaf_5100727894092760169.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music211/v4/35/01/1b/35011b7f-f9d2-5ddc-3759-55076a59aaa7/193436381567_mdbcln.jpg/247x247bb.jpg",
"title": "Million Dollar Baby",
"artist": "Tommy Richman",
"artwork_bg_color": "#FFD700"
},
{
"id": 17,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview126/v4/b3/be/aa/b3beaa10-2425-d26b-7036-4dfa66fd96e0/mzaf_9494109365642641249.plus.aac.p.m4a",
"artwork": "https://is3-ssl.mzstatic.com/image/thumb/Music116/v4/89/e0/59/89e0595b-6cfb-ee43-eb31-89e5eb8c3a69/634904074050.png/247x247bb.jpg",
"title": "25",
"artist": "Rod Wave",
"artwork_bg_color": "#4169E1"
},
{
"id": 18,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview211/v4/1a/ba/8f/1aba8f84-d6d7-ba3f-222a-1bab26cee4c6/mzaf_7418060965643085123.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/e2/53/2f/e2532f80-b877-c273-938d-85565456cba4/24UMGIM69835.rgb.jpg/247x247bb.jpg",
"title": "Lies Lies Lies",
"artist": "Morgan Wallen",
"artwork_bg_color": "#CD853F"
},
{
"id": 19,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview126/v4/69/1f/70/691f70d8-3dd2-97e3-2edb-9f8cbe90cb16/mzaf_18194676297747432902.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/42/a0/c5/42a0c5e6-6b98-f1f9-7d6b-6d6c61aba562/23UMGIM84225.rgb.jpg/247x247bb.jpg",
"title": "Hot To Go!",
"artist": "Chappell Roan",
"artwork_bg_color": "#FF69B4"
},
{
"id": 20,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview221/v4/c8/ef/8c/c8ef8c39-9e98-f2cc-6c6f-5aeb0df0b64f/mzaf_10384034394539215321.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/52/9a/a7/529aa76f-5d60-cd81-9eb0-0eb521de861d/24UMGIM43968.rgb.jpg/247x247bb.jpg",
"title": "I Love You, I'm Sorry",
"artist": "Gracie Abrams",
"artwork_bg_color": "#B0C4DE"
},
{
"id": 21,
"mp4_link": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview211/v4/71/e0/c7/71e0c7d0-2123-77bd-2322-e9a8d11333e5/mzaf_12415459325798775890.plus.aac.p.m4a",
"artwork": "https://is1-ssl.mzstatic.com/image/thumb/Music211/v4/16/5e/2d/165e2ddb-c596-8565-d86a-6231b4a911a6/196872075496.jpg/247x247bb.jpg",
"title": "Miles On It",
"artist": "Marshmello & Kane Brown",
"artwork_bg_color": "#F0F8FF"
}
]
}
================================================
FILE: hooks/useColorScheme.ts
================================================
export { useColorScheme } from 'react-native';
================================================
FILE: hooks/useColorScheme.web.ts
================================================
// NOTE: The default React Native styling doesn't support server rendering.
// Server rendered styles should not change between the first render of the HTML
// and the first render on the client. Typically, web developers will use CSS media queries
// to render different styles on the client and server, these aren't directly supported in React Native
// but can be achieved using a styling library like Nativewind.
export function useColorScheme() {
return 'light';
}
================================================
FILE: hooks/useOverlayView.ts
================================================
import { useCallback, useEffect, useRef } from 'react';
import { useOverlay } from '@/components/Overlay/OverlayContext';
export const useOverlayView = () => {
const { addOverlay, removeOverlay } = useOverlay();
const overlayIdRef = useRef<string | null>(null);
const show = useCallback((component: React.ReactNode) => {
if (overlayIdRef.current) {
removeOverlay(overlayIdRef.current);
}
overlayIdRef.current = addOverlay({ component });
}, [addOverlay, removeOverlay]);
const hide = useCallback(() => {
if (overlayIdRef.current) {
removeOverlay(overlayIdRef.current);
overlayIdRef.current = null;
}
}, [removeOverlay]);
useEffect(() => {
return () => {
if (overlayIdRef.current) {
removeOverlay(overlayIdRef.current);
}
};
}, [removeOverlay]);
return { show, hide };
};
================================================
FILE: hooks/useThemeColor.ts
================================================
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { useColorScheme } from 'react-native';
import { Colors } from '@/constants/Colors';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
================================================
FILE: package.json
================================================
{
"name": "apple-music-sheet-ui",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"test": "jest --watchAll",
"lint": "expo lint"
},
"jest": {
"preset": "jest-expo"
},
"dependencies": {
"@expo/vector-icons": "^14.0.2",
"@react-navigation/native": "^6.0.2",
"expo": "~51.0.28",
"expo-constants": "~16.0.2",
"expo-font": "~12.0.9",
"expo-linking": "~6.3.1",
"expo-router": "~3.5.23",
"expo-splash-screen": "~0.27.5",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"expo-web-browser": "~13.0.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.74.5",
"react-native-gesture-handler": "~2.16.1",
"react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1",
"react-native-web": "~0.19.10",
"expo-linear-gradient": "~13.0.2",
"expo-av": "~14.0.7",
"expo-blur": "~13.0.2",
"expo-haptics": "~13.0.1"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/jest": "^29.5.12",
"@types/react": "~18.2.45",
"@types/react-test-renderer": "^18.0.7",
"jest": "^29.2.1",
"jest-expo": "~51.0.3",
"react-test-renderer": "18.2.0",
"typescript": "~5.3.3"
},
"private": true
}
================================================
FILE: scripts/reset-project.js
================================================
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It moves the /app directory to /app-example and creates a new /app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require('fs');
const path = require('path');
const root = process.cwd();
const oldDirPath = path.join(root, 'app');
const newDirPath = path.join(root, 'app-example');
const newAppDirPath = path.join(root, 'app');
const indexContent = `import { Text, View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Edit app/index.tsx to edit this screen.</Text>
</View>
);
}
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="index" />
</Stack>
);
}
`;
fs.rename(oldDirPath, newDirPath, (error) => {
if (error) {
return console.error(`Error renaming directory: ${error}`);
}
console.log('/app moved to /app-example.');
fs.mkdir(newAppDirPath, { recursive: true }, (error) => {
if (error) {
return console.error(`Error creating new app directory: ${error}`);
}
console.log('New /app directory created.');
const indexPath = path.join(newAppDirPath, 'index.tsx');
fs.writeFile(indexPath, indexContent, (error) => {
if (error) {
return console.error(`Error creating index.tsx: ${error}`);
}
console.log('app/index.tsx created.');
const layoutPath = path.join(newAppDirPath, '_layout.tsx');
fs.writeFile(layoutPath, layoutContent, (error) => {
if (error) {
return console.error(`Error creating _layout.tsx: ${error}`);
}
console.log('app/_layout.tsx created.');
});
});
});
});
================================================
FILE: tsconfig.json
================================================
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}
gitextract_jvtnjuuu/ ├── .gitignore ├── README.md ├── app/ │ ├── (tabs)/ │ │ ├── _layout.tsx │ │ ├── index.tsx │ │ ├── library.tsx │ │ ├── new.tsx │ │ ├── radio.tsx │ │ └── search/ │ │ ├── _layout.tsx │ │ └── index.tsx │ ├── +html.tsx │ ├── +not-found.tsx │ ├── _layout.tsx │ └── music/ │ └── [id].tsx ├── app.json ├── babel.config.js ├── components/ │ ├── BottomSheet/ │ │ ├── ExpandedPlayer.tsx │ │ └── MiniPlayer.tsx │ ├── CategoryCard.tsx │ ├── MusicVisualizer.tsx │ ├── Overlay/ │ │ ├── OverlayContext.tsx │ │ └── OverlayProvider.tsx │ ├── ParallaxScrollView.tsx │ ├── ThemedText.tsx │ ├── ThemedView.tsx │ └── navigation/ │ └── TabBarIcon.tsx ├── constants/ │ └── Colors.ts ├── contexts/ │ ├── AudioContext.tsx │ └── RootScaleContext.tsx ├── data/ │ └── songs.json ├── hooks/ │ ├── useColorScheme.ts │ ├── useColorScheme.web.ts │ ├── useOverlayView.ts │ └── useThemeColor.ts ├── package.json ├── scripts/ │ └── reset-project.js └── tsconfig.json
SYMBOL INDEX (49 symbols across 24 files)
FILE: app/(tabs)/_layout.tsx
function TabIcon (line 11) | function TabIcon({ sfSymbol, ionIcon, color }: { sfSymbol: string; ionIc...
function TabLayout (line 25) | function TabLayout() {
FILE: app/(tabs)/index.tsx
type Song (line 14) | interface Song {
function HomeScreen (line 21) | function HomeScreen() {
FILE: app/(tabs)/library.tsx
function LibraryScreen (line 4) | function LibraryScreen() {
FILE: app/(tabs)/new.tsx
function NewScreen (line 4) | function NewScreen() {
FILE: app/(tabs)/radio.tsx
function RadioScreen (line 4) | function RadioScreen() {
FILE: app/(tabs)/search/_layout.tsx
function SearchStack (line 4) | function SearchStack() {
FILE: app/(tabs)/search/index.tsx
function SearchScreen (line 255) | function SearchScreen() {
FILE: app/+html.tsx
function Root (line 8) | function Root({ children }: PropsWithChildren) {
FILE: app/+not-found.tsx
function NotFoundScreen (line 7) | function NotFoundScreen() {
FILE: app/_layout.tsx
function AnimatedStack (line 16) | function AnimatedStack() {
function RootLayout (line 68) | function RootLayout() {
FILE: app/music/[id].tsx
constant SCALE_FACTOR (line 19) | const SCALE_FACTOR = 0.83;
constant DRAG_THRESHOLD (line 20) | const DRAG_THRESHOLD = Math.min(Dimensions.get('window').height * 0.20, ...
constant HORIZONTAL_DRAG_THRESHOLD (line 21) | const HORIZONTAL_DRAG_THRESHOLD = Math.min(Dimensions.get('window').widt...
constant DIRECTION_LOCK_ANGLE (line 22) | const DIRECTION_LOCK_ANGLE = 45;
constant ENABLE_HORIZONTAL_DRAG_CLOSE (line 23) | const ENABLE_HORIZONTAL_DRAG_CLOSE = false;
function MusicScreen (line 25) | function MusicScreen() {
FILE: components/BottomSheet/ExpandedPlayer.tsx
function shadeColor (line 15) | function shadeColor(color: string, percent: number): string {
type ExpandedPlayerProps (line 35) | interface ExpandedPlayerProps {
function ExpandedPlayer (line 39) | function ExpandedPlayer({ scrollComponent }: ExpandedPlayerProps) {
FILE: components/BottomSheet/MiniPlayer.tsx
function MiniPlayer (line 12) | function MiniPlayer({ onPress, song, isPlaying, onPlayPause }: MiniPlaye...
function MiniPlayerContent (line 41) | function MiniPlayerContent({ song, isPlaying, onPlayPause }: {
type MiniPlayerProps (line 140) | interface MiniPlayerProps {
FILE: components/CategoryCard.tsx
type CategoryCardProps (line 5) | type CategoryCardProps = {
function CategoryCard (line 12) | function CategoryCard({ title, backgroundColor, imageUrl, size = 'small'...
FILE: components/MusicVisualizer.tsx
type Props (line 4) | interface Props {
constant BAR_COUNT (line 8) | const BAR_COUNT = 5;
constant ANIMATION_DURATION (line 9) | const ANIMATION_DURATION = 300;
function MusicVisualizer (line 11) | function MusicVisualizer({ isPlaying }: Props) {
FILE: components/Overlay/OverlayContext.tsx
type OverlayView (line 4) | interface OverlayView {
type OverlayContextType (line 10) | interface OverlayContextType {
FILE: components/ParallaxScrollView.tsx
constant HEADER_HEIGHT (line 12) | const HEADER_HEIGHT = 300;
type Props (line 14) | type Props = PropsWithChildren<{
function ParallaxScrollView (line 19) | function ParallaxScrollView({
FILE: components/ThemedText.tsx
type ThemedTextProps (line 5) | type ThemedTextProps = TextProps & {
function ThemedText (line 11) | function ThemedText({
FILE: components/ThemedView.tsx
type ThemedViewProps (line 5) | type ThemedViewProps = ViewProps & {
function ThemedView (line 10) | function ThemedView({ style, lightColor, darkColor, ...otherProps }: The...
FILE: components/navigation/TabBarIcon.tsx
function TabBarIcon (line 5) | function TabBarIcon({ style, ...rest }: IconProps<ComponentProps<typeof ...
FILE: contexts/AudioContext.tsx
type Song (line 5) | interface Song {
type AudioContextType (line 14) | interface AudioContextType {
function AudioProvider (line 32) | function AudioProvider({ children }: { children: React.ReactNode }) {
function useAudio (line 155) | function useAudio() {
FILE: contexts/RootScaleContext.tsx
type RootScaleContextType (line 4) | interface RootScaleContextType {
function RootScaleProvider (line 11) | function RootScaleProvider({ children }: { children: React.ReactNode }) {
FILE: hooks/useColorScheme.web.ts
function useColorScheme (line 6) | function useColorScheme() {
FILE: hooks/useThemeColor.ts
function useThemeColor (line 10) | function useThemeColor(
Condensed preview — 36 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (104K chars).
[
{
"path": ".gitignore",
"chars": 270,
"preview": "node_modules/\n.expo/\ndist/\nnpm-debug.*\n*.jks\n*.p8\n*.p12\n*.key\n*.mobileprovision\n*.orig.*\nweb-build/\n\n# macOS\n.DS_Store\n\n"
},
{
"path": "README.md",
"chars": 3086,
"preview": "# Apple Music Sheet UI Demo with Expo\n\nThis project demonstrates an implementation of the Apple Music player UI in React"
},
{
"path": "app/(tabs)/_layout.tsx",
"chars": 3215,
"preview": "import { Tabs } from 'expo-router';\nimport React from 'react';\nimport { TabBarIcon } from '@/components/navigation/TabBa"
},
{
"path": "app/(tabs)/index.tsx",
"chars": 6783,
"preview": "import { Text, Image, View, StyleSheet, Platform, Pressable, FlatList } from 'react-native';\nimport { useRouter } from '"
},
{
"path": "app/(tabs)/library.tsx",
"chars": 274,
"preview": "import { View, Text } from 'react-native';\nimport React from 'react';\n\nexport default function LibraryScreen() {\n ret"
},
{
"path": "app/(tabs)/new.tsx",
"chars": 266,
"preview": "import { View, Text } from 'react-native';\nimport React from 'react';\n\nexport default function NewScreen() {\n return "
},
{
"path": "app/(tabs)/radio.tsx",
"chars": 270,
"preview": "import { View, Text } from 'react-native';\nimport React from 'react';\n\nexport default function RadioScreen() {\n retur"
},
{
"path": "app/(tabs)/search/_layout.tsx",
"chars": 816,
"preview": "import { Stack } from 'expo-router';\nimport { router } from 'expo-router';\n\nexport default function SearchStack() {\n "
},
{
"path": "app/(tabs)/search/index.tsx",
"chars": 14407,
"preview": "import { View, Text, ScrollView, TextInput, StyleSheet } from 'react-native';\nimport React from 'react';\nimport { Catego"
},
{
"path": "app/+html.tsx",
"chars": 1425,
"preview": "import { ScrollViewStyleReset } from 'expo-router/html';\nimport { type PropsWithChildren } from 'react';\n\n/**\n * This fi"
},
{
"path": "app/+not-found.tsx",
"chars": 792,
"preview": "import { Link, Stack } from 'expo-router';\nimport { StyleSheet } from 'react-native';\n\nimport { ThemedText } from '@/com"
},
{
"path": "app/_layout.tsx",
"chars": 2839,
"preview": "import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';\nimport { Stack } from 'expo-router';\n"
},
{
"path": "app/music/[id].tsx",
"chars": 9762,
"preview": "import { useLocalSearchParams, useRouter } from 'expo-router';\nimport { StyleSheet, Dimensions } from 'react-native';\nim"
},
{
"path": "app.json",
"chars": 793,
"preview": "{\n \"expo\": {\n \"name\": \"apple-music-sheet-ui\",\n \"slug\": \"apple-music-sheet-ui\",\n \"version\": \"1.0.0\",\n \"orien"
},
{
"path": "babel.config.js",
"chars": 108,
"preview": "module.exports = function (api) {\n api.cache(true);\n return {\n presets: ['babel-preset-expo'],\n };\n};\n"
},
{
"path": "components/BottomSheet/ExpandedPlayer.tsx",
"chars": 13992,
"preview": "import { View as ThemedView, StyleSheet, Image, Pressable, Dimensions, ScrollView } from 'react-native';\nimport { Status"
},
{
"path": "components/BottomSheet/MiniPlayer.tsx",
"chars": 4450,
"preview": "import { StyleSheet, Pressable, Image, Platform } from 'react-native';\nimport { ThemedText } from '@/components/ThemedTe"
},
{
"path": "components/CategoryCard.tsx",
"chars": 2357,
"preview": "import { View, Text, Pressable, ImageBackground } from 'react-native';\nimport React from 'react';\nimport { Link } from '"
},
{
"path": "components/MusicVisualizer.tsx",
"chars": 3124,
"preview": "import { useEffect, useRef, useState } from 'react';\nimport { View, Animated, StyleSheet } from 'react-native';\n\ninterfa"
},
{
"path": "components/Overlay/OverlayContext.tsx",
"chars": 561,
"preview": "import { createContext, useContext } from 'react';\nimport { ViewStyle } from 'react-native';\n\nexport interface OverlayVi"
},
{
"path": "components/Overlay/OverlayProvider.tsx",
"chars": 1287,
"preview": "import React, { useState, useCallback } from 'react';\nimport { StyleSheet, View } from 'react-native';\nimport { OverlayC"
},
{
"path": "components/ParallaxScrollView.tsx",
"chars": 1865,
"preview": "import type { PropsWithChildren, ReactElement } from 'react';\nimport { StyleSheet, useColorScheme } from 'react-native';"
},
{
"path": "components/ThemedText.tsx",
"chars": 1283,
"preview": "import { Text, type TextProps, StyleSheet } from 'react-native';\n\nimport { useThemeColor } from '@/hooks/useThemeColor';"
},
{
"path": "components/ThemedView.tsx",
"chars": 468,
"preview": "import { View, type ViewProps } from 'react-native';\n\nimport { useThemeColor } from '@/hooks/useThemeColor';\n\nexport typ"
},
{
"path": "components/navigation/TabBarIcon.tsx",
"chars": 355,
"preview": "import Ionicons from '@expo/vector-icons/Ionicons';\nimport { type IconProps } from '@expo/vector-icons/build/createIconS"
},
{
"path": "constants/Colors.ts",
"chars": 750,
"preview": "/**\n * Below are the colors that are used in the app. The colors are defined in the light and dark mode.\n * There are ma"
},
{
"path": "contexts/AudioContext.tsx",
"chars": 4809,
"preview": "import { createContext, useContext, useState, useEffect, useCallback } from 'react';\nimport { Audio } from 'expo-av';\nim"
},
{
"path": "contexts/RootScaleContext.tsx",
"chars": 1200,
"preview": "import React, { createContext, useContext } from 'react';\nimport { SharedValue, useSharedValue, withSpring } from 'react"
},
{
"path": "data/songs.json",
"chars": 10116,
"preview": "{\n \"songs\": [\n {\n \"id\": 0,\n \"mp4_link\": \"https://audio-ssl.itunes.apple.com/itunes-asset"
},
{
"path": "hooks/useColorScheme.ts",
"chars": 47,
"preview": "export { useColorScheme } from 'react-native';\n"
},
{
"path": "hooks/useColorScheme.web.ts",
"chars": 472,
"preview": "// NOTE: The default React Native styling doesn't support server rendering.\n// Server rendered styles should not change "
},
{
"path": "hooks/useOverlayView.ts",
"chars": 944,
"preview": "import { useCallback, useEffect, useRef } from 'react';\nimport { useOverlay } from '@/components/Overlay/OverlayContext'"
},
{
"path": "hooks/useThemeColor.ts",
"chars": 527,
"preview": "/**\n * Learn more about light and dark modes:\n * https://docs.expo.dev/guides/color-schemes/\n */\n\nimport { useColorSchem"
},
{
"path": "package.json",
"chars": 1493,
"preview": "{\n \"name\": \"apple-music-sheet-ui\",\n \"main\": \"expo-router/entry\",\n \"version\": \"1.0.0\",\n \"scripts\": {\n \"start\": \"ex"
},
{
"path": "scripts/reset-project.js",
"chars": 1989,
"preview": "#!/usr/bin/env node\n\n/**\n * This script is used to reset the project to a blank state.\n * It moves the /app directory to"
},
{
"path": "tsconfig.json",
"chars": 242,
"preview": "{\n \"extends\": \"expo/tsconfig.base\",\n \"compilerOptions\": {\n \"strict\": true,\n \"paths\": {\n \"@/*\": [\n \"."
}
]
About this extraction
This page contains the full source code of the saulamsal/apple-music-sheet-ui GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 36 files (95.2 KB), approximately 27.2k tokens, and a symbol index with 49 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.