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 (
// }
// />
// );
// }
return ;
}
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
(
Platform.OS === 'ios' ? (
) : null
),
}}>
(
),
}}
/>
(
),
}}
/>
(
),
}}
/>
(
),
}}
/>
(
),
}}
/>
);
}
================================================
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 }) => (
{
playSound(item);
// router.push(`/music/${item.id}`);
}}
style={styles.songItem}
>
{item.id === currentSong?.id && (
)}
{item.title}
{item.id === currentSong?.id && (
)}
{item.artist}
);
return (
Built with Expo
Play
Shuffle
}
contentContainerStyle={styles.scrollView}
>
Billboard Top 20
{new Date().toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})}
item.id}
scrollEnabled={false}
/>
);
}
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 (
Library Screen
);
}
================================================
FILE: app/(tabs)/new.tsx
================================================
import { View, Text } from 'react-native';
import React from 'react';
export default function NewScreen() {
return (
New Screen
);
}
================================================
FILE: app/(tabs)/radio.tsx
================================================
import { View, Text } from 'react-native';
import React from 'react';
export default function RadioScreen() {
return (
Radio Screen
);
}
================================================
FILE: app/(tabs)/search/_layout.tsx
================================================
import { Stack } from 'expo-router';
import { router } from 'expo-router';
export default function SearchStack() {
return (
{
router.push("/search/search" as any);
},
}
}}
/>
);
}
================================================
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 (
Browse Categories
{categories.map((category, index) => (
))}
);
}
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 (
{/*
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.
*/}
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
{/* Add any additional elements that you want globally available on web... */}
{children}
);
}
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 (
<>
This screen doesn't exist.
Go to home screen!
>
);
}
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 (
{currentSong && (
router.push(`/music/${currentSong.id}`)}
/>
)}
{/* putting anything here is not scalled down upon modal open */}
);
}
export default function RootLayout() {
const colorScheme = useColorScheme();
useEffect(() => {
SplashScreen.hideAsync();
}, []);
return (
);
}
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 (
{
'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}
/>
);
}, [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 (
);
}
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 (
{currentSong?.title}
{currentSong?.artist}
{formatTime(position)}
-{formatTime(Math.max(0, duration - position))}
Px8
{/* Add lyrics section after the controls */}
{lyrics.map((line, index) => (
{line}
))}
);
}
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 (
{Platform.OS === 'ios' ? (
) : (
)}
);
}
// 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 (
{song.title}
);
}
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 (
{imageUrl && (
{title}
)}
{!imageUrl && (
{title}
)}
);
}
================================================
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 (
{animatedValues.map((value, index) => (
))}
);
}
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) => string;
removeOverlay: (id: string) => void;
}
export const OverlayContext = createContext({
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([]);
const addOverlay = useCallback((view: Omit) => {
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 (
{children}
{views.map(view => (
{view.component}
))}
);
};
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();
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 (
{headerImage}
{children}
);
}
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 (
);
}
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 ;
}
================================================
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['name']>) {
return ;
}
================================================
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;
pauseSound: () => Promise;
togglePlayPause: () => Promise;
playNextSong: () => Promise;
playPreviousSong: () => Promise;
}
const AudioContext = createContext(undefined);
export function AudioProvider({ children }: { children: React.ReactNode }) {
const [sound, setSound] = useState(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentSong, setCurrentSong] = useState(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 (
{children}
);
}
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;
setScale: (value: number) => void;
}
const RootScaleContext = createContext(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 (
{children}
);
}
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(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 (
Edit app/index.tsx to edit this screen.
);
}
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return (
);
}
`;
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"
]
}