Initial commit: mobile app
React Native app for the self-hosted music streaming project, carved out from the music-app monorepo. Uses @music-app/shared for shared types and utilities.
This commit is contained in:
commit
404fe78748
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Jarian Cottingham
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
32
README.md
Normal file
32
README.md
Normal file
@ -0,0 +1,32 @@
|
||||
# Music App — Mobile
|
||||
|
||||
React Native mobile client for the Music App streaming project: player, mood radio, lofi channels, SharePlay, and account settings. Built with React Native + Expo and Tailwind.
|
||||
|
||||
Part of the Music App project:
|
||||
|
||||
| Repo | What it is |
|
||||
|------|------------|
|
||||
| [music-app](https://git.jarianc.com/jarianc/music-app) | FastAPI backend (server) |
|
||||
| [music-web](https://git.jarianc.com/jarianc/music-web) | React web client (Vite + Tailwind) |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Runs the Expo dev server. Configure the API base URL in the app to point at a running music-app server (default `http://localhost:8000`).
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `mobile/` — React Native app (Expo): screens, player, SharePlay UI
|
||||
- `shared/` — Shared TypeScript types and API client (`@music-app/shared` workspace package)
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `npm run dev` | Expo dev server |
|
||||
| `npm run typecheck` | `tsc --noEmit` across workspaces |
|
||||
| `npm run lint` | ESLint across workspaces |
|
||||
27
mobile/app.json
Normal file
27
mobile/app.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Music App",
|
||||
"slug": "music-app",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"scheme": "musicapp",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#0a0a0a"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.musicapp.mobile"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#0a0a0a"
|
||||
},
|
||||
"package": "com.musicapp.mobile"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro"
|
||||
}
|
||||
}
|
||||
}
|
||||
24
mobile/app/_layout.tsx
Normal file
24
mobile/app/_layout.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { Stack } from 'expo-router'
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
||||
import '../src/styles/global.css'
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<Stack screenOptions={{ headerShown: false, contentBackgroundColor: '#0a0a0a' }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="library" />
|
||||
<Stack.Screen name="create" />
|
||||
<Stack.Screen name="radio" />
|
||||
<Stack.Screen name="search" />
|
||||
<Stack.Screen name="account" />
|
||||
<Stack.Screen name="now-playing" />
|
||||
<Stack.Screen name="shareplay" />
|
||||
<Stack.Screen name="mood" />
|
||||
<Stack.Screen name="playlist" />
|
||||
<Stack.Screen name="releases" />
|
||||
<Stack.Screen name="lofi" />
|
||||
</Stack>
|
||||
</SafeAreaProvider>
|
||||
)
|
||||
}
|
||||
35
mobile/app/account.tsx
Normal file
35
mobile/app/account.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { View, Text, ScrollView, TouchableOpacity } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{ icon: '🔌', label: 'Plugins' },
|
||||
{ icon: '🖥️', label: 'Servers' },
|
||||
{ icon: '👤', label: 'About You' },
|
||||
{ icon: '📻', label: 'Internet Radio' },
|
||||
{ icon: '🔄', label: 'Updates' },
|
||||
{ icon: '⚙️', label: 'Settings & Privacy' },
|
||||
]
|
||||
|
||||
export default function AccountScreen() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-music-black">
|
||||
<ScrollView className="flex-1 px-4 pt-4">
|
||||
<View className="flex-row items-center gap-4 mb-8">
|
||||
<View className="w-16 h-16 rounded-full bg-music-card items-center justify-center">
|
||||
<Text className="text-3xl">👤</Text>
|
||||
</View>
|
||||
<Text className="text-2xl font-semibold text-music-text">Welcome, User</Text>
|
||||
</View>
|
||||
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<TouchableOpacity key={item.label} className="flex-row items-center gap-4 py-4">
|
||||
<Text className="text-xl">{item.icon}</Text>
|
||||
<Text className="text-music-text font-medium">{item.label}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
47
mobile/app/create.tsx
Normal file
47
mobile/app/create.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { View, Text, TouchableOpacity, ScrollView } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { BottomNavBar } from '../src/components/BottomNavBar'
|
||||
import { useState } from 'react'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'playlist', label: 'Playlist' },
|
||||
{ id: 'mood-playlist', label: 'Mood Playlist' },
|
||||
{ id: 'radio', label: 'Radio' },
|
||||
{ id: 'collab', label: 'Collab' },
|
||||
]
|
||||
|
||||
export default function CreateScreen() {
|
||||
const [activeTab, setActiveTab] = useState(TABS[0].id)
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-music-black">
|
||||
<ScrollView className="flex-1 px-4 pt-4">
|
||||
<View className="flex-row items-center justify-between mb-6">
|
||||
<View className="w-10" />
|
||||
<Text className="text-xl font-semibold text-music-text">Create</Text>
|
||||
<View className="w-10" />
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-2 mb-6">
|
||||
{TABS.map((tab) => (
|
||||
<TouchableOpacity
|
||||
key={tab.id}
|
||||
onPress={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 rounded-full ${activeTab === tab.id ? 'bg-music-accent' : 'bg-music-card'}`}
|
||||
>
|
||||
<Text className={`text-sm ${activeTab === tab.id ? 'text-music-black font-medium' : 'text-music-muted'}`}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className="p-8 rounded-2xl bg-music-card items-center">
|
||||
<Text className="text-4xl mb-3">➕</Text>
|
||||
<Text className="text-lg font-semibold text-music-text">{TABS.find(t => t.id === activeTab)?.label}</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
<BottomNavBar />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
71
mobile/app/index.tsx
Normal file
71
mobile/app/index.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { View, Text, ScrollView, TouchableOpacity } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { BottomNavBar } from '../src/components/BottomNavBar'
|
||||
|
||||
export default function HomeScreen() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-music-black">
|
||||
<ScrollView className="flex-1 px-4 pt-4">
|
||||
<View className="flex-row items-center justify-between mb-6">
|
||||
<TouchableOpacity onPress={() => router.push('/account')} className="w-10 h-10 rounded-full bg-music-card items-center justify-center">
|
||||
<Text className="text-music-muted text-xl">👤</Text>
|
||||
</TouchableOpacity>
|
||||
<View className="flex-row gap-2">
|
||||
{['Music', 'New Music', 'Mood', 'LoFi'].map((tab) => (
|
||||
<TouchableOpacity
|
||||
key={tab}
|
||||
onPress={() => router.push({
|
||||
pathname: tab === 'Music' ? '/library' : tab === 'New Music' ? '/releases' : tab === 'Mood' ? '/mood' : '/lofi'
|
||||
} as any)}
|
||||
className="px-4 py-2 rounded-full bg-music-card"
|
||||
>
|
||||
<Text className="text-sm text-music-text">{tab}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mb-6 p-4 rounded-2xl bg-music-card">
|
||||
<Text className="text-xs text-music-muted uppercase tracking-wider mb-3">Now Playing</Text>
|
||||
<View className="flex-row items-center gap-4">
|
||||
<View className="w-16 h-16 rounded-lg bg-music-vinyl items-center justify-center">
|
||||
<Text>🎵</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="font-semibold text-music-text">No song playing</Text>
|
||||
<Text className="text-sm text-music-muted">Select a track</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text className="text-lg font-semibold text-music-text mb-3">Quick Access</Text>
|
||||
<View className="flex-row gap-3 mb-6">
|
||||
<TouchableOpacity onPress={() => router.push('/library' as any)} className="flex-1 p-4 rounded-xl bg-music-card">
|
||||
<Text className="text-music-accent mb-2">📚</Text>
|
||||
<Text className="font-medium text-music-text">My Music</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => router.push('/releases' as any)} className="flex-1 p-4 rounded-xl bg-music-card">
|
||||
<Text className="text-mood-happy mb-2">✨</Text>
|
||||
<Text className="font-medium text-music-text">New Releases</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text className="text-lg font-semibold text-music-text mb-3">Mood Radio</Text>
|
||||
<View className="flex-wrap flex-row gap-3">
|
||||
{['Sad', 'Happy', 'Energetic', 'Focused', 'Chill', 'Romantic', 'Angry', 'Nostalgic', 'Melancholy', 'Dreamy'].map((mood) => (
|
||||
<TouchableOpacity
|
||||
key={mood}
|
||||
onPress={() => router.push('/mood' as any)}
|
||||
className="px-4 py-3 rounded-xl bg-music-card"
|
||||
>
|
||||
<Text className="text-sm text-music-text">{mood}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<BottomNavBar />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
68
mobile/app/library.tsx
Normal file
68
mobile/app/library.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { View, Text, ScrollView, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { BottomNavBar } from '../src/components/BottomNavBar'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function LibraryScreen() {
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = useState('')
|
||||
const [playlists, setPlaylists] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylists()
|
||||
}, [])
|
||||
|
||||
const fetchPlaylists = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/playlists')
|
||||
const data = await res.json()
|
||||
setPlaylists(data.items || data || [])
|
||||
} catch {
|
||||
setPlaylists([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = playlists.filter(p => p.name?.toLowerCase().includes(search.toLowerCase()))
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-music-black">
|
||||
<ScrollView className="flex-1 px-4 pt-4">
|
||||
<View className="flex-row items-center justify-between mb-6">
|
||||
<TouchableOpacity onPress={() => router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center">
|
||||
<Text className="text-music-muted">👤</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-xl font-semibold text-music-text">Library</Text>
|
||||
<View className="flex-row items-center bg-music-card rounded-full px-4 py-2">
|
||||
<Text className="text-music-muted">🔍</Text>
|
||||
<TextInput
|
||||
placeholder="Search playlists..."
|
||||
placeholderTextColor="#888"
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
className="text-sm text-music-text ml-2 w-48"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View className="items-center py-8">
|
||||
<ActivityIndicator size="large" color="#e94560" />
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-wrap flex-row gap-6 justify-center">
|
||||
{filtered.map((playlist: any) => (
|
||||
<TouchableOpacity key={playlist.id} onPress={() => router.push(`/playlist?id=${playlist.id}` as any)} className="items-center">
|
||||
<View className="w-24 h-24 rounded-full bg-music-vinyl mb-2" />
|
||||
<Text className="text-sm text-music-text">{playlist.name}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
<BottomNavBar />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
68
mobile/app/lofi.tsx
Normal file
68
mobile/app/lofi.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { BottomNavBar } from '../src/components/BottomNavBar'
|
||||
import { usePlayerStore } from '../src/store/playerStore'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function LofiScreen() {
|
||||
const router = useRouter()
|
||||
const [channels, setChannels] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchChannels()
|
||||
}, [])
|
||||
|
||||
const fetchChannels = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/lofi/channels')
|
||||
const data = await res.json()
|
||||
setChannels(data || [])
|
||||
} catch {
|
||||
setChannels([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-music-black">
|
||||
<ScrollView className="flex-1 px-4 pt-4">
|
||||
<View className="flex-row items-center justify-between mb-6">
|
||||
<TouchableOpacity onPress={() => router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center">
|
||||
<Text className="text-music-muted">👤</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-xl font-semibold text-music-text">LoFi Channels</Text>
|
||||
<View className="w-10" />
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View className="items-center py-8">
|
||||
<ActivityIndicator size="large" color="#e94560" />
|
||||
</View>
|
||||
) : (
|
||||
channels.map((ch: any) => (
|
||||
<TouchableOpacity key={ch.id} className="mb-4 rounded-2xl bg-music-card overflow-hidden">
|
||||
<View className="h-40 justify-center items-center bg-music-vinyl relative">
|
||||
<Text className="text-5xl">🎵</Text>
|
||||
<View className="p-4 bg-black/80 absolute bottom-0 left-0 right-0 rounded-b-2xl">
|
||||
<Text className="text-xs text-music-accent">LoFi</Text>
|
||||
<Text className="text-sm font-medium text-music-text">{ch.name}</Text>
|
||||
<Text className="text-xs text-music-muted mt-1">{ch.description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
|
||||
{channels.length === 0 && !loading && (
|
||||
<View className="items-center py-12">
|
||||
<Text className="text-4xl mb-2">🌙</Text>
|
||||
<Text className="text-music-muted">No channels available</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
<BottomNavBar />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
51
mobile/app/mood.tsx
Normal file
51
mobile/app/mood.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { View, Text, TouchableOpacity, ScrollView } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useState } from 'react'
|
||||
import { usePlayerStore } from '../src/store/playerStore'
|
||||
|
||||
const MOODS = [
|
||||
{ id: 'sad', name: 'Sad', color: '#1a2a4a' },
|
||||
{ id: 'happy', name: 'Happy', color: '#f5c542' },
|
||||
{ id: 'energetic', name: 'Energetic', color: '#e63946' },
|
||||
{ id: 'focused', name: 'Focused', color: '#2d6a4f' },
|
||||
{ id: 'chill', name: 'Chill', color: '#48957e' },
|
||||
{ id: 'romantic', name: 'Romantic', color: '#bc6a7e' },
|
||||
{ id: 'angry', name: 'Angry', color: '#9d0208' },
|
||||
{ id: 'nostalgic', name: 'Nostalgic', color: '#a67c52' },
|
||||
{ id: 'melancholy', name: 'Melancholy', color: '#5a189c' },
|
||||
{ id: 'dreamy', name: 'Dreamy', color: '#9b5de5' },
|
||||
]
|
||||
|
||||
export default function MoodScreen() {
|
||||
const [activeMood, setActiveMood] = useState(MOODS[0])
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying)
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay)
|
||||
|
||||
return (
|
||||
<View className="flex-1 justify-between py-8 px-6" style={{ backgroundColor: activeMood.color + '20' }}>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="w-10" />
|
||||
<Text className="text-xl font-semibold text-music-text">Mood Radio</Text>
|
||||
<View className="w-10" />
|
||||
</View>
|
||||
|
||||
<View className="items-center">
|
||||
<View className="w-48 h-48 rounded-full items-center justify-center" style={{ backgroundColor: activeMood.color + '40' }}>
|
||||
<Text className="text-6xl">🎵</Text>
|
||||
</View>
|
||||
<Text className="text-2xl font-semibold text-music-text mt-6">{activeMood.name}</Text>
|
||||
</View>
|
||||
|
||||
<View className="items-center gap-3">
|
||||
<Text className="text-xs text-music-muted uppercase tracking-wider">Currently Playing</Text>
|
||||
<Text className="text-music-muted text-xl">⬆️</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setActiveMood(MOODS[Math.floor(Math.random() * MOODS.length)])}
|
||||
className="px-8 py-3 rounded-full bg-music-card"
|
||||
>
|
||||
<Text className="text-sm font-medium text-music-text">Set the Mood</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
86
mobile/app/now-playing.tsx
Normal file
86
mobile/app/now-playing.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { View, Text, TouchableOpacity, PanResponder } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { usePlayerStore } from '../src/store/playerStore'
|
||||
|
||||
export default function NowPlayingScreen() {
|
||||
const router = useRouter()
|
||||
const currentSong = usePlayerStore(s => s.currentSong)
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying)
|
||||
const progress = usePlayerStore(s => s.progress)
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay)
|
||||
const toggleShuffle = usePlayerStore(s => s.toggleShuffle)
|
||||
const next = usePlayerStore(s => s.next)
|
||||
const previous = usePlayerStore(s => s.previous)
|
||||
const seek = usePlayerStore(s => s.seek)
|
||||
const shuffle = usePlayerStore(s => s.shuffle)
|
||||
|
||||
const progressPercent = currentSong ? (progress / currentSong.duration) * 100 : 0
|
||||
|
||||
const panResponder = PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderMove: (evt) => {
|
||||
if (!currentSong) return
|
||||
const x = evt.nativeEvent.locationX
|
||||
const ratio = Math.max(0, Math.min(x / 375, 1))
|
||||
seek(currentSong.duration * ratio)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-music-black justify-between py-8 px-6" {...panResponder.panHandlers}>
|
||||
<TouchableOpacity onPress={() => router.back()} className="self-start">
|
||||
<Text className="text-music-muted text-2xl">←</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className="w-64 h-64 rounded-2xl bg-music-vinyl items-center justify-center self-center">
|
||||
<Text className="text-6xl">🎵</Text>
|
||||
</View>
|
||||
|
||||
<View className="items-center">
|
||||
<Text className="text-2xl font-semibold text-music-text">{currentSong?.title || 'No song'}</Text>
|
||||
<Text className="text-music-muted mt-1">{currentSong?.artist}</Text>
|
||||
</View>
|
||||
|
||||
<View className="w-full">
|
||||
<View className="h-1 bg-music-border rounded-full">
|
||||
<View className="h-full bg-music-accent rounded-full" style={{ width: `${progressPercent}%` }} />
|
||||
</View>
|
||||
<View className="flex-row justify-between mt-2">
|
||||
<Text className="text-xs text-music-muted">{formatTime(progress)}</Text>
|
||||
<Text className="text-xs text-music-muted">{currentSong ? formatTime(currentSong.duration) : '0:00'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center justify-center gap-6">
|
||||
<TouchableOpacity onPress={toggleShuffle}>
|
||||
<Text className={`${shuffle ? 'text-music-accent' : 'text-music-muted'} text-2xl`}>🔀</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={previous}>
|
||||
<Text className="text-3xl">⏮️</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={togglePlay}>
|
||||
<Text className="text-5xl text-music-accent">{isPlaying ? '⏸' : '▶️'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={next}>
|
||||
<Text className="text-3xl">⏭️</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-music-muted text-2xl">➕</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center justify-center gap-2">
|
||||
<TouchableOpacity onPress={() => router.push('/shareplay' as any)}>
|
||||
<Text className="text-music-muted">📢</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-xs text-music-muted">Speaker</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTime(sec: number): string {
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
85
mobile/app/playlist.tsx
Normal file
85
mobile/app/playlist.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { View, Text, ScrollView, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { usePlayerStore } from '../src/store/playerStore'
|
||||
import { BottomNavBar } from '../src/components/BottomNavBar'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function PlaylistScreen() {
|
||||
const router = useRouter()
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying)
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay)
|
||||
const [songs, setSongs] = useState([])
|
||||
const [playlists, setPlaylists] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [plRes, songRes] = await Promise.all([
|
||||
fetch('/api/playlists'),
|
||||
fetch('/api/songs?page=1&per_page=50'),
|
||||
])
|
||||
const plData = await plRes.json()
|
||||
const songData = await songRes.json()
|
||||
setPlaylists(plData.items || plData || [])
|
||||
setSongs(songData.items || [])
|
||||
} catch {
|
||||
setPlaylists([])
|
||||
setSongs([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-music-black flex-row">
|
||||
<View className="w-20 bg-music-dark border-r border-music-border items-center py-4 gap-3">
|
||||
<TouchableOpacity onPress={() => router.push('/library' as any)}>
|
||||
<Text className="text-music-muted text-xl">←</Text>
|
||||
</TouchableOpacity>
|
||||
{playlists.map((pl: any, i: number) => (
|
||||
<TouchableOpacity key={pl.id || i} className={`w-14 h-14 rounded-lg bg-music-card ${i === 0 ? 'border-2 border-music-accent' : ''}`} />
|
||||
))}
|
||||
<View className="flex-1 w-1 bg-music-border rounded-full relative">
|
||||
<View className="absolute bottom-0 w-full bg-music-accent rounded-full" style={{ height: '35%' }} />
|
||||
</View>
|
||||
<Text className="text-music-muted text-sm">⭐</Text>
|
||||
<TouchableOpacity onPress={togglePlay}>
|
||||
<Text className="text-music-accent text-xl">{isPlaying ? '⏸' : '▶️'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView className="flex-1 px-6 py-4">
|
||||
<View className="flex-row items-center justify-between mb-4">
|
||||
<Text className="text-2xl font-semibold text-music-text">{playlists[0]?.name || 'Playlist'}</Text>
|
||||
<Text className="text-music-muted">📢</Text>
|
||||
</View>
|
||||
<View className="w-48 h-48 rounded-xl bg-music-card mb-6" />
|
||||
|
||||
{loading ? (
|
||||
<ActivityIndicator size="large" color="#e94560" />
|
||||
) : (
|
||||
songs.map((song: any) => (
|
||||
<TouchableOpacity
|
||||
key={song.id}
|
||||
className="flex-row items-center gap-3 py-2"
|
||||
onPress={() => usePlayerStore.getState().setSong(song)}
|
||||
>
|
||||
<View className="w-8 h-8 rounded-full bg-music-vinyl items-center justify-center">
|
||||
<Text>🎵</Text>
|
||||
</View>
|
||||
<Text className="flex-1 text-sm text-music-text">{song.title}</Text>
|
||||
<Text className="text-xs text-music-muted">{song.artist}</Text>
|
||||
<Text className="text-xs text-music-muted">
|
||||
{song.duration_sec ? `${Math.floor(song.duration_sec / 60)}:${(song.duration_sec % 60).toString().padStart(2, '0')}` : '--:--'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
92
mobile/app/radio.tsx
Normal file
92
mobile/app/radio.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { View, Text, ScrollView, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { BottomNavBar } from '../src/components/BottomNavBar'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function RadioScreen() {
|
||||
const router = useRouter()
|
||||
const [stations, setStations] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchStations()
|
||||
}, [])
|
||||
|
||||
const fetchStations = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/radio/stations?limit=20')
|
||||
const data = await res.json()
|
||||
setStations(data.items || data || [])
|
||||
} catch {
|
||||
setStations([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = stations.filter(s => s.name?.toLowerCase().includes(search.toLowerCase()))
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-music-black">
|
||||
<ScrollView className="flex-1 px-4 pt-4">
|
||||
<View className="flex-row items-center justify-between mb-6">
|
||||
<TouchableOpacity onPress={() => router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center">
|
||||
<Text className="text-music-muted">👤</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-xl font-semibold text-music-text">Internet Radio</Text>
|
||||
<View className="w-10" />
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center bg-music-card rounded-full px-4 py-3 mb-6">
|
||||
<Text className="text-music-muted">🔍</Text>
|
||||
<TextInput
|
||||
placeholder="Search stations..."
|
||||
placeholderTextColor="#888"
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
className="text-sm text-music-text ml-2 flex-1"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View className="items-center py-8">
|
||||
<ActivityIndicator size="large" color="#e94560" />
|
||||
<Text className="text-music-muted mt-2">Loading stations...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View className="mb-6 p-4 rounded-2xl bg-music-card">
|
||||
<Text className="text-sm font-semibold text-music-text mb-3">Browse Stations</Text>
|
||||
<View className="flex-wrap flex-row gap-4">
|
||||
{filtered.map((station: any) => (
|
||||
<TouchableOpacity
|
||||
key={station.id}
|
||||
className="w-32 p-3 rounded-xl bg-music-dark items-center"
|
||||
>
|
||||
<View className="w-16 h-16 rounded-full bg-music-vinyl mb-2 items-center justify-center">
|
||||
<Text>📻</Text>
|
||||
</View>
|
||||
<Text className="text-xs text-music-text text-center" numberOfLines={2}>
|
||||
{station.name || 'Station'}
|
||||
</Text>
|
||||
<Text className="text-xs text-music-muted mt-1">
|
||||
{station.country_code || 'Local'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
{filtered.length === 0 && (
|
||||
<View className="items-center py-8">
|
||||
<Text className="text-3xl mb-2">📻</Text>
|
||||
<Text className="text-music-muted">No stations found</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
<BottomNavBar />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
69
mobile/app/releases.tsx
Normal file
69
mobile/app/releases.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { View, Text, ScrollView, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { BottomNavBar } from '../src/components/BottomNavBar'
|
||||
import { usePlayerStore } from '../src/store/playerStore'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function ReleasesScreen() {
|
||||
const router = useRouter()
|
||||
const [releases, setReleases] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchReleases()
|
||||
}, [])
|
||||
|
||||
const fetchReleases = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/releases?limit=20')
|
||||
const data = await res.json()
|
||||
setReleases(data.items || [])
|
||||
} catch {
|
||||
setReleases([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-music-black">
|
||||
<ScrollView className="flex-1 px-4 pt-4">
|
||||
<View className="flex-row items-center justify-between mb-6">
|
||||
<View className="w-10" />
|
||||
<Text className="text-xl font-semibold text-music-text">New Releases</Text>
|
||||
<View className="w-10" />
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View className="items-center py-12">
|
||||
<ActivityIndicator size="large" color="#e94560" />
|
||||
<Text className="text-music-muted mt-2">Loading releases...</Text>
|
||||
</View>
|
||||
) : releases.length > 0 ? (
|
||||
releases.map((release: any, i: number) => (
|
||||
<View key={release.id || i} className="flex-row gap-4 p-4 rounded-2xl bg-music-card mb-4">
|
||||
<View className="items-center">
|
||||
<View className="w-16 h-16 rounded-lg bg-music-dark mb-2" />
|
||||
<Text className="text-xs text-music-muted">{release.artist || 'Artist'}</Text>
|
||||
<TouchableOpacity className="flex-row items-center px-3 py-1 rounded-full bg-music-dark mt-2">
|
||||
<Text className="text-xs text-music-text">➕ Add</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className="flex-wrap flex-row gap-2 flex-1">
|
||||
{[...Array(Math.min(6, release.tracks || 3))].map((_, j) => (
|
||||
<TouchableOpacity key={j} className="w-[15%] aspect-square rounded-lg bg-music-dark" />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View className="items-center py-12">
|
||||
<Text className="text-4xl mb-2">🎵</Text>
|
||||
<Text className="text-music-muted">No new releases found</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
<BottomNavBar />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
124
mobile/app/search.tsx
Normal file
124
mobile/app/search.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import { View, Text, ScrollView, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { BottomNavBar } from '../src/components/BottomNavBar'
|
||||
import { usePlayerStore } from '../src/store/playerStore'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function SearchScreen() {
|
||||
const router = useRouter()
|
||||
const currentSong = usePlayerStore(s => s.currentSong)
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying)
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay)
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const features = [
|
||||
{ id: 'music', name: 'Music', icon: '🎵', route: '/library' },
|
||||
{ id: 'new', name: 'New Music', icon: '✨', route: '/releases' },
|
||||
{ id: 'events', name: 'Live Events', icon: '🎪', route: '/' },
|
||||
{ id: 'radio', name: 'Internet Radio', icon: '📻', route: '/radio' },
|
||||
{ id: 'mood', name: 'Mood Radio', icon: '😊', route: '/mood' },
|
||||
{ id: 'lofi', name: 'LoFi', icon: '🌙', route: '/lofi' },
|
||||
{ id: 'shareplay', name: 'SharePlay', icon: '📢', route: '/shareplay' },
|
||||
]
|
||||
|
||||
const handleSearch = async (text: string) => {
|
||||
setQuery(text)
|
||||
if (!text) {
|
||||
setResults([])
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(text)}`)
|
||||
const data = await res.json()
|
||||
setResults(data.items || [])
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-music-black">
|
||||
<ScrollView className="flex-1 px-4 pt-4">
|
||||
<View className="flex-row items-center justify-between mb-6">
|
||||
<TouchableOpacity onPress={() => router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center">
|
||||
<Text className="text-music-muted">👤</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-xl font-semibold text-music-text">Search</Text>
|
||||
<View className="w-10" />
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center bg-music-card rounded-full px-4 py-3 mb-6">
|
||||
<Text className="text-music-muted">🔍</Text>
|
||||
<Text
|
||||
onPress={() => handleSearch(query)}
|
||||
className="text-sm text-music-text ml-2 flex-1"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{query || 'What music is calling to you?'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{loading && (
|
||||
<View className="items-center py-8">
|
||||
<ActivityIndicator size="large" color="#e94560" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!loading && results.length > 0 && (
|
||||
<View className="mb-6">
|
||||
<Text className="text-lg font-semibold text-music-text mb-3">Search Results</Text>
|
||||
{results.map((item: any, i: number) => (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
className="flex-row items-center gap-3 py-3 bg-music-card rounded-xl mb-2 px-3"
|
||||
>
|
||||
<View className="w-10 h-10 rounded-full bg-music-vinyl items-center justify-center">
|
||||
<Text>🎵</Text>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-sm font-medium text-music-text">{item.title || item.name}</Text>
|
||||
<Text className="text-xs text-music-muted">{item.artist || 'Unknown'}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(!query || results.length === 0) && (
|
||||
<>
|
||||
<Text className="text-lg font-semibold text-music-text mb-3">Browse</Text>
|
||||
<View className="flex-wrap flex-row gap-3 mb-6">
|
||||
{features.map((f) => (
|
||||
<TouchableOpacity key={f.id} onPress={() => router.push(f.route as any)} className="flex-1 min-w-[30%] p-4 rounded-xl bg-music-card items-center">
|
||||
<Text className="text-2xl mb-1">{f.icon}</Text>
|
||||
<Text className="text-xs text-music-text">{f.name}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{currentSong && (
|
||||
<View className="bg-music-card border-t border-music-border px-4 py-2 flex-row items-center justify-between">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View className="w-10 h-10 rounded-full bg-music-vinyl" />
|
||||
<View>
|
||||
<Text className="text-sm font-medium text-music-text">{currentSong.title}</Text>
|
||||
<Text className="text-xs text-music-muted">{currentSong.artist}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity onPress={togglePlay}>
|
||||
<Text className="text-music-accent text-2xl">{isPlaying ? '⏸' : '▶️'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
<BottomNavBar />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
60
mobile/app/shareplay.tsx
Normal file
60
mobile/app/shareplay.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { View, Text, TouchableOpacity, ScrollView } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { usePlayerStore } from '../src/store/playerStore'
|
||||
import { BottomNavBar } from '../src/components/BottomNavBar'
|
||||
|
||||
export default function SharePlayScreen() {
|
||||
const router = useRouter()
|
||||
const currentSong = usePlayerStore(s => s.currentSong)
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying)
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay)
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-music-black">
|
||||
<ScrollView className="flex-1 px-4 pt-4">
|
||||
<View className="flex-row items-center justify-between mb-6">
|
||||
<TouchableOpacity onPress={() => router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center">
|
||||
<Text className="text-music-muted">👤</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-xl font-semibold text-music-text">SharePlay</Text>
|
||||
<View className="w-10" />
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View className="bg-music-card rounded-t-2xl p-4 mx-4">
|
||||
<View className="w-12 h-1 bg-music-border rounded-full mx-auto mb-4" />
|
||||
<View className="flex-row items-center justify-between mb-3">
|
||||
<Text className="text-music-accent">📢</Text>
|
||||
<View className="flex-row items-center gap-1">
|
||||
<Text className="text-music-muted">👤</Text>
|
||||
<Text className="text-sm text-music-muted">1</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-xs text-music-muted mb-1">Currently Playing</Text>
|
||||
<View className="flex-row items-center gap-3 mb-3">
|
||||
<View className="w-12 h-12 rounded-full bg-music-vinyl" />
|
||||
<View>
|
||||
<Text className="font-medium text-sm text-music-text">{currentSong?.title || 'No song'}</Text>
|
||||
<Text className="text-xs text-music-muted">{currentSong?.artist}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-row items-center justify-center gap-4">
|
||||
<Text className="text-music-muted">⏮️</Text>
|
||||
<Text className="text-music-muted">⏪</Text>
|
||||
<TouchableOpacity onPress={togglePlay}>
|
||||
<Text className="text-music-accent text-2xl">{isPlaying ? '⏸' : '▶️'}</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-music-muted">⏩</Text>
|
||||
<Text className="text-music-muted">⏭️</Text>
|
||||
</View>
|
||||
<View className="mt-3 pt-3 border-t border-music-border">
|
||||
<Text className="text-xs text-music-muted">Up Next</Text>
|
||||
</View>
|
||||
<TouchableOpacity className="mt-3 py-2 rounded-full bg-music-dark items-center">
|
||||
<Text className="text-music-text text-sm flex-row">➕ Add Song to Cue</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<BottomNavBar />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
7
mobile/babel.config.js
Normal file
7
mobile/babel.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: ['nativewind/babel', 'react-native-reanimated/plugin'],
|
||||
};
|
||||
};
|
||||
17
mobile/metro.config.js
Normal file
17
mobile/metro.config.js
Normal file
@ -0,0 +1,17 @@
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const path = require('path');
|
||||
|
||||
const projectRoot = __dirname;
|
||||
const workspaceRoot = path.resolve(projectRoot, '../');
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
config.watchFolders = [workspaceRoot];
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, 'node_modules'),
|
||||
path.resolve(workspaceRoot, 'node_modules'),
|
||||
];
|
||||
|
||||
config.resolver.disableHierarchicalLookup = true;
|
||||
|
||||
module.exports = config;
|
||||
39
mobile/package.json
Normal file
39
mobile/package.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@music-app/mobile",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"build": "expo build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"expo": "~50.0.0",
|
||||
"expo-router": "~3.4.0",
|
||||
"expo-status-bar": "~1.11.1",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.73.4",
|
||||
"react-native-web": "^0.19.6",
|
||||
"react-native-safe-area-context": "4.8.2",
|
||||
"react-native-screens": "~3.29.0",
|
||||
"react-native-track-player": "^4.1.1",
|
||||
"zustand": "^4.4.7",
|
||||
"nativewind": "^2.0.11",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"@music-app/shared": "0.1.0",
|
||||
"expo-av": "~13.10.0",
|
||||
"expo-file-system": "~16.0.0",
|
||||
"expo-media-library": "~15.8.0",
|
||||
"react-native-gesture-handler": "~2.14.0",
|
||||
"react-native-reanimated": "~3.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@babel/core": "^7.20.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
36
mobile/src/components/BottomNavBar.tsx
Normal file
36
mobile/src/components/BottomNavBar.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import React from 'react'
|
||||
import { View, Text, TouchableOpacity } from 'react-native'
|
||||
import { useRouter, usePathname } from 'expo-router'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'index', label: 'Home', icon: '🏠', route: '/' },
|
||||
{ id: 'playlist', label: 'Playlist', icon: '📀', route: '/playlist' },
|
||||
{ id: 'search', label: 'Search', icon: '🔍', route: '/search' },
|
||||
{ id: 'radio', label: 'Radio', icon: '📻', route: '/radio' },
|
||||
{ id: 'create', label: 'Create', icon: '➕', route: '/create' },
|
||||
]
|
||||
|
||||
export function BottomNavBar() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<View className="flex-row bg-music-dark border-t border-music-border pb-2">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = pathname === item.route || (item.route !== '/' && pathname.startsWith(item.route))
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
onPress={() => router.push(item.route as any)}
|
||||
className="flex-1 items-center py-2"
|
||||
>
|
||||
<Text className="text-xl">{item.icon}</Text>
|
||||
<Text className={`text-xs mt-0.5 ${isActive ? 'text-music-accent' : 'text-music-muted'}`}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
78
mobile/src/store/playerStore.ts
Normal file
78
mobile/src/store/playerStore.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface SongState {
|
||||
id: string
|
||||
title: string
|
||||
artist: string
|
||||
album: string
|
||||
albumArt: string | null
|
||||
duration: number
|
||||
}
|
||||
|
||||
interface PlayerState {
|
||||
currentSong: SongState | null
|
||||
isPlaying: boolean
|
||||
progress: number
|
||||
volume: number
|
||||
shuffle: boolean
|
||||
repeat: boolean
|
||||
playlist: SongState[]
|
||||
currentIndex: number
|
||||
|
||||
setSong: (song: SongState) => void
|
||||
play: () => void
|
||||
pause: () => void
|
||||
togglePlay: () => void
|
||||
seek: (progress: number) => void
|
||||
setVolume: (volume: number) => void
|
||||
toggleShuffle: () => void
|
||||
toggleRepeat: () => void
|
||||
next: () => void
|
||||
previous: () => void
|
||||
setPlaylist: (songs: SongState[], startIndex?: number) => void
|
||||
}
|
||||
|
||||
export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
currentSong: null,
|
||||
isPlaying: false,
|
||||
progress: 0,
|
||||
volume: 0.8,
|
||||
shuffle: false,
|
||||
repeat: false,
|
||||
playlist: [],
|
||||
currentIndex: -1,
|
||||
|
||||
setSong: (song) => set({ currentSong: song }),
|
||||
play: () => set({ isPlaying: true }),
|
||||
pause: () => set({ isPlaying: false }),
|
||||
togglePlay: () => set((s) => ({ isPlaying: !s.isPlaying })),
|
||||
seek: (progress) => set({ progress }),
|
||||
setVolume: (volume) => set({ volume }),
|
||||
toggleShuffle: () => set((s) => ({ shuffle: !s.shuffle })),
|
||||
toggleRepeat: () => set((s) => ({ repeat: !s.repeat })),
|
||||
next: () => {
|
||||
const { playlist, currentIndex, repeat } = get()
|
||||
const nextIndex = currentIndex + 1
|
||||
if (nextIndex >= playlist.length) {
|
||||
if (repeat) {
|
||||
set({ currentIndex: 0 })
|
||||
set({ currentSong: playlist[0] })
|
||||
}
|
||||
return
|
||||
}
|
||||
set({ currentIndex: nextIndex })
|
||||
set({ currentSong: playlist[nextIndex] })
|
||||
},
|
||||
previous: () => {
|
||||
const { playlist, currentIndex } = get()
|
||||
const prevIndex = currentIndex <= 0 ? playlist.length - 1 : currentIndex - 1
|
||||
set({ currentIndex: prevIndex })
|
||||
set({ currentSong: playlist[prevIndex] })
|
||||
},
|
||||
setPlaylist: (songs, startIndex = 0) => {
|
||||
set({ playlist: songs, currentIndex: startIndex })
|
||||
if (songs.length > 0) {
|
||||
set({ currentSong: songs[startIndex] })
|
||||
}
|
||||
},
|
||||
}))
|
||||
3
mobile/src/styles/global.css
Normal file
3
mobile/src/styles/global.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
31
mobile/tailwind.config.js
Normal file
31
mobile/tailwind.config.js
Normal file
@ -0,0 +1,31 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{js,jsx,ts,tsx}', './app/**/*.{js,jsx,ts,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'music-black': '#0a0a0a',
|
||||
'music-dark': '#121212',
|
||||
'music-card': '#1a1a1a',
|
||||
'music-border': '#2a2a2a',
|
||||
'music-muted': '#888888',
|
||||
'music-text': '#e0e0e0',
|
||||
'music-text-dim': '#999999',
|
||||
'music-accent': '#f5c542',
|
||||
'music-vinyl': '#1a1a2e',
|
||||
'mood-sad': '#1a2a4a',
|
||||
'mood-happy': '#f5c542',
|
||||
'mood-energetic': '#e63946',
|
||||
'mood-focused': '#2d6a4f',
|
||||
'mood-chill': '#48957e',
|
||||
'mood-romantic': '#bc6a7e',
|
||||
'mood-angry': '#9d0208',
|
||||
'mood-nostalgic': '#a67c52',
|
||||
'mood-melancholy': '#5a189c',
|
||||
'mood-dreamy': '#9b5de5',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
21
mobile/tsconfig.json
Normal file
21
mobile/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020"],
|
||||
"jsx": "react-native-jsx",
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "app/**/*"],
|
||||
"extends": "expo/tsconfig.base"
|
||||
}
|
||||
14
package.json
Normal file
14
package.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "music-mobile",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"shared",
|
||||
"mobile"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "npm run dev:mobile",
|
||||
"dev:mobile": "npm run start --workspace=mobile",
|
||||
"lint": "npm run lint --workspaces --if-present",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present"
|
||||
}
|
||||
}
|
||||
15
shared/package.json
Normal file
15
shared/package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@music-app/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"@types/node": "^20.10.0"
|
||||
}
|
||||
}
|
||||
193
shared/src/api/client.ts
Normal file
193
shared/src/api/client.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import { API_BASE, ENDPOINTS } from '../constants/api';
|
||||
import { PaginatedResponse, ApiResponse } from '../types/common';
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl || API_BASE;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
} as Record<string, string>;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: data.detail || data.error || 'Request failed' };
|
||||
}
|
||||
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Network error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async get<T>(endpoint: string): Promise<ApiResponse<T>> {
|
||||
return this.request<T>(endpoint, { method: 'GET' });
|
||||
}
|
||||
|
||||
async post<T>(endpoint: string, body?: unknown): Promise<ApiResponse<T>> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async put<T>(endpoint: string, body?: unknown): Promise<ApiResponse<T>> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
|
||||
return this.request<T>(endpoint, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async upload<T>(endpoint: string, formData: FormData): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: data.detail || data.error || 'Upload failed' };
|
||||
}
|
||||
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Upload error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Songs
|
||||
getSongs = (page = 1, perPage = 50) =>
|
||||
this.get<PaginatedResponse<unknown>>(`${ENDPOINTS.songs}?page=${page}&per_page=${perPage}`);
|
||||
|
||||
getSong = (id: string) => this.get<unknown>(ENDPOINTS.song(id));
|
||||
|
||||
deleteSong = (id: string) => this.delete<unknown>(ENDPOINTS.song(id));
|
||||
|
||||
uploadSong = (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return this.upload<unknown>(ENDPOINTS.upload, formData);
|
||||
};
|
||||
|
||||
scanSongs = (directory?: string) =>
|
||||
this.post<unknown>(ENDPOINTS.scan, directory ? { directory } : undefined);
|
||||
|
||||
// Playlists
|
||||
getPlaylists = () => this.get<unknown[]>(ENDPOINTS.playlists);
|
||||
|
||||
getPlaylist = (id: string) => this.get<unknown>(ENDPOINTS.playlist(id));
|
||||
|
||||
createPlaylist = (body: { name: string; description?: string; moodCategory?: string; songIds?: string[] }) =>
|
||||
this.post<unknown>(ENDPOINTS.playlists, body);
|
||||
|
||||
updatePlaylist = (id: string, body: Partial<{ name: string; description: string }>) =>
|
||||
this.put<unknown>(ENDPOINTS.playlist(id), body);
|
||||
|
||||
deletePlaylist = (id: string) => this.delete<unknown>(ENDPOINTS.playlist(id));
|
||||
|
||||
addSongToPlaylist = (playlistId: string, songId: string) =>
|
||||
this.post<unknown>(ENDPOINTS.playlistSongs(playlistId), { song_id: songId });
|
||||
|
||||
removeSongFromPlaylist = (playlistId: string, songId: string) =>
|
||||
this.delete<unknown>(`${ENDPOINTS.playlistSongs(playlistId)}/${songId}`);
|
||||
|
||||
sharePlaylist = (id: string) => this.post<unknown>(ENDPOINTS.playlistShare(id));
|
||||
|
||||
getSharedPlaylist = (token: string) => this.get<unknown>(ENDPOINTS.sharedPlaylist(token));
|
||||
|
||||
// Search
|
||||
search = (query: string) => this.get<unknown>(`${ENDPOINTS.search}?q=${encodeURIComponent(query)}`);
|
||||
|
||||
// Mood
|
||||
getMoodCategories = () => this.get<unknown[]>(ENDPOINTS.moods);
|
||||
|
||||
analyzeMood = (songId?: string) => this.post<unknown>(ENDPOINTS.moodAnalyze, songId ? { song_id: songId } : undefined);
|
||||
|
||||
getMoodPlaylist = (mood: string) => this.get<unknown>(ENDPOINTS.moodPlaylist(mood));
|
||||
|
||||
saveMoodPlaylist = (mood: string, name?: string) =>
|
||||
this.post<unknown>(ENDPOINTS.moodSave, { mood, name });
|
||||
|
||||
setMood = (mood: string) => this.post<unknown>(ENDPOINTS.moodSet, { mood });
|
||||
|
||||
// Radio
|
||||
getRadioStations = (country?: string, genre?: string, limit = 50) =>
|
||||
this.get<unknown[]>(`${ENDPOINTS.radioStations}?limit=${limit}${country ? `&country=${country}` : ''}${genre ? `&genre=${genre}` : ''}`);
|
||||
|
||||
getNearbyStations = (lat?: number, lon?: number, radius = 100) =>
|
||||
this.get<unknown[]>(`${ENDPOINTS.radioNearby}${lat ? `?lat=${lat}&lon=${lon}&radius=${radius}` : ''}`);
|
||||
|
||||
getRadioStation = (id: string) => this.get<unknown>(ENDPOINTS.radioStation(id));
|
||||
|
||||
getRadioCurrent = () => this.get<unknown>(ENDPOINTS.radioCurrent);
|
||||
|
||||
// LoFi
|
||||
getLofiChannels = () => this.get<unknown[]>(ENDPOINTS.lofiChannels);
|
||||
|
||||
// SharePlay
|
||||
createSharePlay = () => this.post<unknown>(ENDPOINTS.sharePlayCreate);
|
||||
|
||||
joinSharePlay = (roomId: string) => this.post<unknown>(ENDPOINTS.sharePlayJoin, { room_id: roomId });
|
||||
|
||||
leaveSharePlay = (roomId: string) => this.post<unknown>(ENDPOINTS.sharePlayLeave, { room_id: roomId });
|
||||
|
||||
getCue = (roomId: string) => this.get<unknown>(`${ENDPOINTS.sharePlayCue}?room_id=${roomId}`);
|
||||
|
||||
addToCue = (roomId: string, songId: string) =>
|
||||
this.post<unknown>(ENDPOINTS.sharePlayCue, { room_id: roomId, song_id: songId });
|
||||
|
||||
sendControl = (roomId: string, type: string, payload?: unknown) =>
|
||||
this.post<unknown>(ENDPOINTS.sharePlayControl, { room_id: roomId, type, payload });
|
||||
|
||||
// Releases
|
||||
getReleases = () => this.get<unknown[]>(ENDPOINTS.releases);
|
||||
|
||||
getArtistReleases = (artist: string) => this.get<unknown>(ENDPOINTS.releaseArtist(artist));
|
||||
|
||||
// Events
|
||||
getEvents = (lat?: number, lon?: number) =>
|
||||
this.get<unknown[]>(`${ENDPOINTS.events}${lat ? `?lat=${lat}&lon=${lon}` : ''}`);
|
||||
|
||||
// Settings
|
||||
getSettings = () => this.get<unknown>(ENDPOINTS.settings);
|
||||
|
||||
updateSettings = (settings: Record<string, unknown>) => this.put<unknown>(ENDPOINTS.settings, settings);
|
||||
|
||||
getServers = () => this.get<unknown[]>(ENDPOINTS.settingsServers);
|
||||
|
||||
addServer = (path: string, name?: string) =>
|
||||
this.post<unknown>(ENDPOINTS.settingsServers, { path, name });
|
||||
|
||||
removeServer = (id: string) => this.delete<unknown>(ENDPOINTS.settingsServer(id));
|
||||
|
||||
// Account
|
||||
getAccountStats = () => this.get<unknown>(ENDPOINTS.accountStats);
|
||||
|
||||
getAccountHistory = () => this.get<unknown[]>(ENDPOINTS.accountHistory);
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
export { ApiClient };
|
||||
65
shared/src/constants/api.ts
Normal file
65
shared/src/constants/api.ts
Normal file
@ -0,0 +1,65 @@
|
||||
export const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
|
||||
export const ENDPOINTS = {
|
||||
// Songs
|
||||
songs: '/api/songs',
|
||||
song: (id: string) => `/api/songs/${id}`,
|
||||
songStream: (id: string) => `/api/songs/${id}/stream`,
|
||||
songLyrics: (id: string) => `/api/songs/${id}/lyrics`,
|
||||
upload: '/api/songs/upload',
|
||||
scan: '/api/songs/scan',
|
||||
|
||||
// Playlists
|
||||
playlists: '/api/playlists',
|
||||
playlist: (id: string) => `/api/playlists/${id}`,
|
||||
playlistSongs: (id: string) => `/api/playlists/${id}/songs`,
|
||||
playlistShare: (id: string) => `/api/playlists/${id}/share`,
|
||||
sharedPlaylist: (token: string) => `/api/playlists/shared/${token}`,
|
||||
|
||||
// Search
|
||||
search: '/api/search',
|
||||
|
||||
// Mood
|
||||
moods: '/api/mood/categories',
|
||||
moodAnalyze: '/api/mood/analyze',
|
||||
moodPlaylist: (mood: string) => `/api/mood/${mood}/playlist`,
|
||||
moodSave: '/api/mood/save',
|
||||
moodSet: '/api/mood/set',
|
||||
|
||||
// Radio
|
||||
radioStations: '/api/radio/stations',
|
||||
radioNearby: '/api/radio/nearby',
|
||||
radioStation: (id: string) => `/api/radio/stations/${id}`,
|
||||
radioStream: (id: string) => `/api/radio/stream/${id}`,
|
||||
radioCurrent: '/api/radio/current',
|
||||
|
||||
// LoFi
|
||||
lofiChannels: '/api/lofi/channels',
|
||||
lofiStream: (id: string) => `/api/lofi/stream/${id}`,
|
||||
lofiAdd: '/api/lofi/add',
|
||||
|
||||
// SharePlay
|
||||
sharePlayCreate: '/api/shareplay/create',
|
||||
sharePlayJoin: '/api/shareplay/join',
|
||||
sharePlayLeave: '/api/shareplay/leave',
|
||||
sharePlayCue: '/api/shareplay/cue',
|
||||
sharePlayControl: '/api/shareplay/control',
|
||||
sharePlayWS: (roomId: string) => `/ws/shareplay/${roomId}`,
|
||||
|
||||
// Releases
|
||||
releases: '/api/releases',
|
||||
releaseArtist: (artist: string) => `/api/releases/${artist}`,
|
||||
releaseAdd: '/api/releases/add',
|
||||
|
||||
// Events
|
||||
events: '/api/events',
|
||||
|
||||
// Settings
|
||||
settings: '/api/settings',
|
||||
settingsServers: '/api/settings/servers',
|
||||
settingsServer: (id: string) => `/api/settings/servers/${id}`,
|
||||
|
||||
// Account
|
||||
accountStats: '/api/account/stats',
|
||||
accountHistory: '/api/account/history',
|
||||
} as const;
|
||||
97
shared/src/constants/moods.ts
Normal file
97
shared/src/constants/moods.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { MoodCategory, MoodKeyword } from '../types/mood';
|
||||
|
||||
export const MOOD_CATEGORIES: MoodCategory[] = [
|
||||
{ id: 'sad', name: 'Sad', colorHex: '#1a2a4a', description: 'Melancholic and reflective tracks', backgroundImage: '/moods/sad.jpg', iconPath: '/icons/mood-sad.svg' },
|
||||
{ id: 'happy', name: 'Happy', colorHex: '#f5c542', description: 'Uplifting and cheerful tunes', backgroundImage: '/moods/happy.jpg', iconPath: '/icons/mood-happy.svg' },
|
||||
{ id: 'energetic', name: 'Energetic', colorHex: '#e63946', description: 'High-energy and driving beats', backgroundImage: '/moods/energetic.jpg', iconPath: '/icons/mood-energetic.svg' },
|
||||
{ id: 'focused', name: 'Focused', colorHex: '#2d6a4f', description: 'Concentration and productivity music', backgroundImage: '/moods/focused.jpg', iconPath: '/icons/mood-focused.svg' },
|
||||
{ id: 'chill', name: 'Chill', colorHex: '#48957e', description: 'Relaxed and smooth vibes', backgroundImage: '/moods/chill.jpg', iconPath: '/icons/mood-chill.svg' },
|
||||
{ id: 'romantic', name: 'Romantic', colorHex: '#bc6a7e', description: 'Love songs and intimate melodies', backgroundImage: '/moods/romantic.jpg', iconPath: '/icons/mood-romantic.svg' },
|
||||
{ id: 'angry', name: 'Angry', colorHex: '#9d0208', description: 'Intense and powerful tracks', backgroundImage: '/moods/angry.jpg', iconPath: '/icons/mood-angry.svg' },
|
||||
{ id: 'nostalgic', name: 'Nostalgic', colorHex: '#a67c52', description: 'Throwback and sentimental favorites', backgroundImage: '/moods/nostalgic.jpg', iconPath: '/icons/mood-nostalgic.svg' },
|
||||
{ id: 'melancholy', name: 'Melancholy', colorHex: '#5a189c', description: 'Deep and contemplative soundscapes', backgroundImage: '/moods/melancholy.jpg', iconPath: '/icons/mood-melancholy.svg' },
|
||||
{ id: 'dreamy', name: 'Dreamy', colorHex: '#9b5de5', description: 'Ethereal and atmospheric music', backgroundImage: '/moods/dreamy.jpg', iconPath: '/icons/mood-dreamy.svg' },
|
||||
];
|
||||
|
||||
export const MOOD_KEYWORDS: Record<string, MoodKeyword[]> = {
|
||||
Sad: [
|
||||
{ word: 'cry', weight: 3 }, { word: 'alone', weight: 3 }, { word: 'tears', weight: 3 },
|
||||
{ word: 'hurt', weight: 2 }, { word: 'lonely', weight: 3 }, { word: 'heartbreak', weight: 3 },
|
||||
{ word: 'pain', weight: 2 }, { word: 'lost', weight: 2 }, { word: 'goodbye', weight: 2 },
|
||||
{ word: 'miss', weight: 2 }, { word: 'broken', weight: 3 }, { word: 'empty', weight: 2 },
|
||||
{ word: 'dark', weight: 1 }, { word: 'rain', weight: 2 }, { word: 'fall', weight: 1 },
|
||||
],
|
||||
Happy: [
|
||||
{ word: 'happy', weight: 3 }, { word: 'joy', weight: 3 }, { word: 'smile', weight: 2 },
|
||||
{ word: 'sunshine', weight: 2 }, { word: 'dance', weight: 2 }, { word: 'celebrate', weight: 2 },
|
||||
{ word: 'laugh', weight: 2 }, { word: 'bright', weight: 2 }, { word: 'free', weight: 2 },
|
||||
{ word: 'light', weight: 1 }, { word: 'party', weight: 2 }, { word: 'fun', weight: 2 },
|
||||
{ word: 'good', weight: 1 }, { word: 'wonderful', weight: 2 }, { word: 'beautiful', weight: 1 },
|
||||
],
|
||||
Energetic: [
|
||||
{ word: 'fire', weight: 3 }, { word: 'power', weight: 3 }, { word: 'strong', weight: 2 },
|
||||
{ word: 'fight', weight: 2 }, { word: 'run', weight: 2 }, { word: 'fast', weight: 2 },
|
||||
{ word: 'beat', weight: 2 }, { word: 'rise', weight: 2 }, { word: 'burn', weight: 2 },
|
||||
{ word: 'wild', weight: 2 }, { word: 'storm', weight: 2 }, { word: 'thunder', weight: 2 },
|
||||
{ word: 'war', weight: 2 }, { word: 'crash', weight: 2 }, { word: 'break', weight: 1 },
|
||||
],
|
||||
Focused: [
|
||||
{ word: 'think', weight: 3 }, { word: 'mind', weight: 2 }, { word: 'clear', weight: 2 },
|
||||
{ word: 'flow', weight: 2 }, { word: 'calm', weight: 2 }, { word: 'deep', weight: 2 },
|
||||
{ word: 'still', weight: 2 }, { word: 'quiet', weight: 2 }, { word: 'concentrate', weight: 3 },
|
||||
{ word: 'focus', weight: 3 }, { word: 'work', weight: 1 }, { word: 'study', weight: 2 },
|
||||
{ word: 'peace', weight: 2 }, { word: 'steady', weight: 2 }, { word: 'control', weight: 2 },
|
||||
],
|
||||
Chill: [
|
||||
{ word: 'relax', weight: 3 }, { word: 'chill', weight: 3 }, { word: 'smooth', weight: 2 },
|
||||
{ word: 'easy', weight: 2 }, { word: 'vibes', weight: 2 }, { word: 'groove', weight: 2 },
|
||||
{ word: 'lazy', weight: 2 }, { word: 'slow', weight: 2 }, { word: 'soft', weight: 2 },
|
||||
{ word: 'gentle', weight: 2 }, { word: 'mellow', weight: 3 }, { word: 'unwind', weight: 2 },
|
||||
{ word: 'breeze', weight: 2 }, { word: 'cloud', weight: 1 }, { word: 'drift', weight: 2 },
|
||||
],
|
||||
Romantic: [
|
||||
{ word: 'love', weight: 3 }, { word: 'heart', weight: 3 }, { word: 'kiss', weight: 2 },
|
||||
{ word: 'baby', weight: 2 }, { word: 'desire', weight: 2 }, { word: 'passion', weight: 3 },
|
||||
{ word: 'touch', weight: 2 }, { word: 'embrace', weight: 2 }, { word: 'forever', weight: 2 },
|
||||
{ word: 'sweetheart', weight: 2 }, { word: 'romance', weight: 3 }, { word: 'lover', weight: 2 },
|
||||
{ word: 'darling', weight: 2 }, { word: 'soul', weight: 1 }, { word: 'together', weight: 2 },
|
||||
],
|
||||
Angry: [
|
||||
{ word: 'anger', weight: 3 }, { word: 'hate', weight: 3 }, { word: 'fury', weight: 3 },
|
||||
{ word: 'rage', weight: 3 }, { word: 'scream', weight: 2 }, { word: 'destroy', weight: 2 },
|
||||
{ word: 'enemy', weight: 2 }, { word: 'betray', weight: 2 }, { word: 'lie', weight: 2 },
|
||||
{ word: 'fight', weight: 2 }, { word: 'burn', weight: 2 }, { word: 'kill', weight: 3 },
|
||||
{ word: 'war', weight: 2 }, { word: 'hell', weight: 2 }, { word: 'damn', weight: 2 },
|
||||
],
|
||||
Nostalgic: [
|
||||
{ word: 'memory', weight: 3 }, { word: 'remember', weight: 3 }, { word: 'past', weight: 3 },
|
||||
{ word: 'yesterday', weight: 3 }, { word: 'old', weight: 2 }, { word: 'back', weight: 2 },
|
||||
{ word: 'days', weight: 2 }, { word: 'childhood', weight: 2 }, { word: 'home', weight: 2 },
|
||||
{ word: 'then', weight: 2 }, { word: 'once', weight: 2 }, { word: 'before', weight: 2 },
|
||||
{ word: 'gone', weight: 2 }, { word: 'time', weight: 1 }, { word: 'golden', weight: 2 },
|
||||
],
|
||||
Melancholy: [
|
||||
{ word: 'sorrow', weight: 3 }, { word: 'grief', weight: 3 }, { word: 'blue', weight: 2 },
|
||||
{ word: 'fade', weight: 2 }, { word: 'shadow', weight: 2 }, { word: 'silence', weight: 2 },
|
||||
{ word: 'void', weight: 2 }, { word: 'night', weight: 2 }, { word: 'cold', weight: 2 },
|
||||
{ word: 'end', weight: 2 }, { word: 'dying', weight: 2 }, { word: 'falling', weight: 2 },
|
||||
{ word: 'heavy', weight: 2 }, { word: 'darkness', weight: 2 }, { word: 'whisper', weight: 1 },
|
||||
],
|
||||
Dreamy: [
|
||||
{ word: 'dream', weight: 3 }, { word: 'sky', weight: 2 }, { word: 'cloud', weight: 2 },
|
||||
{ word: 'float', weight: 2 }, { word: 'star', weight: 2 }, { word: 'moon', weight: 2 },
|
||||
{ word: 'space', weight: 2 }, { word: 'cosmos', weight: 2 }, { word: 'ethereal', weight: 3 },
|
||||
{ word: 'magic', weight: 2 }, { word: 'fantasy', weight: 2 }, { word: 'wonder', weight: 2 },
|
||||
{ word: 'shimmer', weight: 2 }, { word: 'glow', weight: 2 }, { word: 'haze', weight: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
export const MOOD_NAMES = MOOD_CATEGORIES.map(m => m.name);
|
||||
|
||||
export function getMoodById(id: string): MoodCategory | undefined {
|
||||
return MOOD_CATEGORIES.find(m => m.id === id);
|
||||
}
|
||||
|
||||
export function getMoodByName(name: string): MoodCategory | undefined {
|
||||
return MOOD_CATEGORIES.find(m => m.name === name);
|
||||
}
|
||||
29
shared/src/constants/navigation.ts
Normal file
29
shared/src/constants/navigation.ts
Normal file
@ -0,0 +1,29 @@
|
||||
export const BOTTOM_NAV_ITEMS = [
|
||||
{ id: 'home', label: 'Home', icon: 'home', route: '/' },
|
||||
{ id: 'playlist', label: 'Playlist', icon: 'playlist', route: '/playlist' },
|
||||
{ id: 'search', label: 'Search', icon: 'search', route: '/search' },
|
||||
{ id: 'radio', label: 'Internet Radio', icon: 'radio', route: '/radio' },
|
||||
{ id: 'create', label: 'Create', icon: 'add', route: '/create' },
|
||||
] as const;
|
||||
|
||||
export const NAV_HUB_FEATURES = [
|
||||
{ id: 'music', label: 'Music', route: '/music' },
|
||||
{ id: 'new-music', label: 'New Music', route: '/releases' },
|
||||
{ id: 'mood', label: 'Mood', route: '/mood' },
|
||||
{ id: 'lofi', label: 'LoFi Channel', route: '/lofi' },
|
||||
] as const;
|
||||
|
||||
export const CREATE_TABS = [
|
||||
{ id: 'playlist', label: 'Playlist', description: 'Create a playlist with songs' },
|
||||
{ id: 'mood-playlist', label: 'Mood Playlist', description: 'Create based on your mood' },
|
||||
{ id: 'radio', label: 'Radio', description: 'Randomized songs with DJ' },
|
||||
{ id: 'collab', label: 'Collab', description: 'Play friends playlists' },
|
||||
] as const;
|
||||
|
||||
export const SEARCH_FEATURES = [
|
||||
{ id: 'music', name: 'Music', color: '#e63946', icon: 'music', route: '/music' },
|
||||
{ id: 'new-music', name: 'New Music', color: '#f5c542', icon: 'star', route: '/releases' },
|
||||
{ id: 'live-events', name: 'Live Events', color: '#9b5de5', icon: 'event', route: '/events' },
|
||||
{ id: 'internet-radio', name: 'Internet Radio', color: '#48957e', icon: 'radio', route: '/radio' },
|
||||
{ id: 'mood-radio', name: 'Mood Radio', color: '#bc6a7e', icon: 'mood', route: '/mood' },
|
||||
] as const;
|
||||
16
shared/src/index.ts
Normal file
16
shared/src/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export * from './types/song';
|
||||
export * from './types/playlist';
|
||||
export * from './types/mood';
|
||||
export * from './types/radio';
|
||||
export * from './types/lofi';
|
||||
export * from './types/shareplay';
|
||||
export * from './types/search';
|
||||
export * from './types/settings';
|
||||
export * from './types/account';
|
||||
export * from './types/releases';
|
||||
export * from './types/events';
|
||||
export * from './types/common';
|
||||
export * from './api/client';
|
||||
export * from './constants/moods';
|
||||
export * from './constants/navigation';
|
||||
export * from './constants/api';
|
||||
16
shared/src/types/account.ts
Normal file
16
shared/src/types/account.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export interface AccountStats {
|
||||
totalSongs: number;
|
||||
totalPlaylists: number;
|
||||
totalListeningTime: number;
|
||||
topArtists: { name: string; count: number }[];
|
||||
topGenres: { name: string; count: number }[];
|
||||
topMoods: { name: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface ListeningHistoryItem {
|
||||
songId: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
playedAt: string;
|
||||
durationPlayed: number;
|
||||
}
|
||||
36
shared/src/types/common.ts
Normal file
36
shared/src/types/common.ts
Normal file
@ -0,0 +1,36 @@
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TimeDisplay {
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
raw: number;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
export interface Duration {
|
||||
totalSeconds: number;
|
||||
formatted: string;
|
||||
}
|
||||
|
||||
export interface ImageAsset {
|
||||
url: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
blurHash?: string;
|
||||
}
|
||||
10
shared/src/types/events.ts
Normal file
10
shared/src/types/events.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export interface ConcertEvent {
|
||||
id: string;
|
||||
name: string;
|
||||
venue: string;
|
||||
locationLat: number;
|
||||
locationLon: number;
|
||||
date: string;
|
||||
description: string;
|
||||
imageUrl: string | null;
|
||||
}
|
||||
9
shared/src/types/lofi.ts
Normal file
9
shared/src/types/lofi.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export interface LofiChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
streamUrl: string;
|
||||
imagePath: string;
|
||||
description: string;
|
||||
sourcePlatform: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
47
shared/src/types/mood.ts
Normal file
47
shared/src/types/mood.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { Song } from './song';
|
||||
|
||||
export type MoodName =
|
||||
| 'Sad'
|
||||
| 'Happy'
|
||||
| 'Energetic'
|
||||
| 'Focused'
|
||||
| 'Chill'
|
||||
| 'Romantic'
|
||||
| 'Angry'
|
||||
| 'Nostalgic'
|
||||
| 'Melancholy'
|
||||
| 'Dreamy';
|
||||
|
||||
export interface MoodCategory {
|
||||
id: string;
|
||||
name: MoodName;
|
||||
colorHex: string;
|
||||
description: string;
|
||||
backgroundImage: string;
|
||||
iconPath: string;
|
||||
}
|
||||
|
||||
export interface MoodScore {
|
||||
mood: MoodName;
|
||||
score: number;
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
export interface MoodAnalysis {
|
||||
songId: string;
|
||||
scores: MoodScore[];
|
||||
topMood: MoodName;
|
||||
confidence: number;
|
||||
analyzedAt: string;
|
||||
}
|
||||
|
||||
export interface MoodPlaylist {
|
||||
mood: MoodName;
|
||||
songs: Song[];
|
||||
totalSongs: number;
|
||||
}
|
||||
|
||||
export interface MoodKeyword {
|
||||
word: string;
|
||||
weight: number;
|
||||
}
|
||||
30
shared/src/types/playlist.ts
Normal file
30
shared/src/types/playlist.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Song } from './song';
|
||||
|
||||
export interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
coverArt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
moodCategory: string | null;
|
||||
isShared: boolean;
|
||||
shareToken: string | null;
|
||||
songCount: number;
|
||||
}
|
||||
|
||||
export interface PlaylistWithSongs extends Playlist {
|
||||
songs: (Song & { position: number })[];
|
||||
}
|
||||
|
||||
export interface PlaylistCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
moodCategory?: string;
|
||||
songIds?: string[];
|
||||
}
|
||||
|
||||
export interface ShareLink {
|
||||
token: string;
|
||||
url: string;
|
||||
}
|
||||
29
shared/src/types/radio.ts
Normal file
29
shared/src/types/radio.ts
Normal file
@ -0,0 +1,29 @@
|
||||
export interface RadioStation {
|
||||
id: string;
|
||||
name: string;
|
||||
frequency: string;
|
||||
streamUrl: string;
|
||||
locationLat: number;
|
||||
locationLon: number;
|
||||
genre: string;
|
||||
country: string;
|
||||
language: string;
|
||||
bitrate: number;
|
||||
tags: string[];
|
||||
votes: number;
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
export interface RadioCurrent {
|
||||
station: RadioStation;
|
||||
songName: string | null;
|
||||
artistName: string | null;
|
||||
isPlaying: boolean;
|
||||
}
|
||||
|
||||
export interface RadioSearch {
|
||||
query: string;
|
||||
country?: string;
|
||||
genre?: string;
|
||||
limit?: number;
|
||||
}
|
||||
26
shared/src/types/releases.ts
Normal file
26
shared/src/types/releases.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export interface NewRelease {
|
||||
artistName: string;
|
||||
artistImage: string | null;
|
||||
albums: ReleaseAlbum[];
|
||||
lastChecked: string;
|
||||
}
|
||||
|
||||
export interface ReleaseAlbum {
|
||||
id: string;
|
||||
title: string;
|
||||
coverArt: string | null;
|
||||
releaseDate: string;
|
||||
tracks: ReleaseTrack[];
|
||||
}
|
||||
|
||||
export interface ReleaseTrack {
|
||||
title: string;
|
||||
duration: number;
|
||||
durationFormatted: string;
|
||||
}
|
||||
|
||||
export interface ReleaseCheckResult {
|
||||
artistName: string;
|
||||
newAlbums: number;
|
||||
checkedAt: string;
|
||||
}
|
||||
22
shared/src/types/search.ts
Normal file
22
shared/src/types/search.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Song } from './song';
|
||||
import { Playlist } from './playlist';
|
||||
|
||||
export interface SearchResult {
|
||||
songs: Song[];
|
||||
playlists: Playlist[];
|
||||
query: string;
|
||||
totalResults: number;
|
||||
}
|
||||
|
||||
export interface MusicSuggestion {
|
||||
song: Song;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface FeatureGridItem {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
route: string;
|
||||
}
|
||||
18
shared/src/types/settings.ts
Normal file
18
shared/src/types/settings.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export interface UserSettings {
|
||||
audioQuality: 'low' | 'medium' | 'high';
|
||||
theme: 'dark' | 'light' | 'auto';
|
||||
scanDirectories: string[];
|
||||
userName: string;
|
||||
userAvatar: string | null;
|
||||
radioBrowserInstance: string;
|
||||
autoTranscode: boolean;
|
||||
defaultMood: string | null;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
lastScanned: string | null;
|
||||
songCount: number;
|
||||
}
|
||||
43
shared/src/types/shareplay.ts
Normal file
43
shared/src/types/shareplay.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Song } from './song';
|
||||
|
||||
export interface SharePlayRoom {
|
||||
id: string;
|
||||
creatorUser: string;
|
||||
createdAt: string;
|
||||
currentSongId: string | null;
|
||||
positionSec: number;
|
||||
isPlaying: boolean;
|
||||
shuffleMode: boolean;
|
||||
activeConnections: number;
|
||||
}
|
||||
|
||||
export interface SharePlayState {
|
||||
songId: string | null;
|
||||
position: number;
|
||||
isPlaying: boolean;
|
||||
shuffle: boolean;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface SharePlayCommand {
|
||||
type: 'play' | 'pause' | 'skip_back' | 'skip_forward' | 'rewind' | 'fast_forward' | 'seek' | 'shuffle' | 'volume';
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
export interface SharePlayCueItem {
|
||||
song: Song;
|
||||
position: number;
|
||||
addedBy: string;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export interface SharePlayCue {
|
||||
items: SharePlayCueItem[];
|
||||
nextSong: Song | null;
|
||||
}
|
||||
|
||||
export interface SharePlayPresence {
|
||||
userId: string;
|
||||
deviceName: string;
|
||||
joinedAt: string;
|
||||
}
|
||||
32
shared/src/types/song.ts
Normal file
32
shared/src/types/song.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { ImageAsset } from './common';
|
||||
|
||||
export interface Song {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
durationSec: number;
|
||||
genre: string;
|
||||
filePath: string;
|
||||
albumArt: ImageAsset | null;
|
||||
addedAt: string;
|
||||
fileFormat: string;
|
||||
fileSizeBytes: number;
|
||||
}
|
||||
|
||||
export interface SongWithLyrics extends Song {
|
||||
lyrics: string | null;
|
||||
moodTags: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface SongUpload {
|
||||
file: File;
|
||||
metadata?: Partial<Song>;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
scanned: number;
|
||||
added: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
}
|
||||
21
shared/tsconfig.json
Normal file
21
shared/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user