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:
Jarian Cottingham 2026-08-21 18:44:05 +00:00
commit 404fe78748
44 changed files with 1960 additions and 0 deletions

21
LICENSE Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
}
}

View 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>
)
}

View 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] })
}
},
}))

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

31
mobile/tailwind.config.js Normal file
View 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
View 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
View 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
View 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
View 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 };

View 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;

View 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);
}

View 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
View 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';

View 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;
}

View 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;
}

View 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
View 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
View 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;
}

View 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
View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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
View 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
View 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"]
}