commit 404fe78748e1516f6155c0fb3cfe324e9be3ef46 Author: Jarian Cottingham Date: Fri Aug 21 18:44:05 2026 +0000 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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..850a5a7 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ee72e9 --- /dev/null +++ b/README.md @@ -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 | diff --git a/mobile/app.json b/mobile/app.json new file mode 100644 index 0000000..9b0be4d --- /dev/null +++ b/mobile/app.json @@ -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" + } + } +} \ No newline at end of file diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx new file mode 100644 index 0000000..bab1e4d --- /dev/null +++ b/mobile/app/_layout.tsx @@ -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 ( + + + + + + + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/mobile/app/account.tsx b/mobile/app/account.tsx new file mode 100644 index 0000000..d2c0dc2 --- /dev/null +++ b/mobile/app/account.tsx @@ -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 ( + + + + + 👤 + + Welcome, User + + + {MENU_ITEMS.map((item) => ( + + {item.icon} + {item.label} + + ))} + + + ) +} \ No newline at end of file diff --git a/mobile/app/create.tsx b/mobile/app/create.tsx new file mode 100644 index 0000000..314892d --- /dev/null +++ b/mobile/app/create.tsx @@ -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 ( + + + + + Create + + + + + {TABS.map((tab) => ( + setActiveTab(tab.id)} + className={`px-4 py-2 rounded-full ${activeTab === tab.id ? 'bg-music-accent' : 'bg-music-card'}`} + > + + {tab.label} + + + ))} + + + + + {TABS.find(t => t.id === activeTab)?.label} + + + + + ) +} \ No newline at end of file diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx new file mode 100644 index 0000000..2d43022 --- /dev/null +++ b/mobile/app/index.tsx @@ -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 ( + + + + router.push('/account')} className="w-10 h-10 rounded-full bg-music-card items-center justify-center"> + 👤 + + + {['Music', 'New Music', 'Mood', 'LoFi'].map((tab) => ( + 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" + > + {tab} + + ))} + + + + + Now Playing + + + 🎵 + + + No song playing + Select a track + + + + + Quick Access + + router.push('/library' as any)} className="flex-1 p-4 rounded-xl bg-music-card"> + 📚 + My Music + + router.push('/releases' as any)} className="flex-1 p-4 rounded-xl bg-music-card"> + + New Releases + + + + Mood Radio + + {['Sad', 'Happy', 'Energetic', 'Focused', 'Chill', 'Romantic', 'Angry', 'Nostalgic', 'Melancholy', 'Dreamy'].map((mood) => ( + router.push('/mood' as any)} + className="px-4 py-3 rounded-xl bg-music-card" + > + {mood} + + ))} + + + + + ) +} \ No newline at end of file diff --git a/mobile/app/library.tsx b/mobile/app/library.tsx new file mode 100644 index 0000000..3347ed4 --- /dev/null +++ b/mobile/app/library.tsx @@ -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 ( + + + + router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center"> + 👤 + + Library + + 🔍 + + + + + {loading ? ( + + + + ) : ( + + {filtered.map((playlist: any) => ( + router.push(`/playlist?id=${playlist.id}` as any)} className="items-center"> + + {playlist.name} + + ))} + + )} + + + + ) +} diff --git a/mobile/app/lofi.tsx b/mobile/app/lofi.tsx new file mode 100644 index 0000000..2145c65 --- /dev/null +++ b/mobile/app/lofi.tsx @@ -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 ( + + + + router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center"> + 👤 + + LoFi Channels + + + + {loading ? ( + + + + ) : ( + channels.map((ch: any) => ( + + + 🎵 + + LoFi + {ch.name} + {ch.description} + + + + )) + )} + + {channels.length === 0 && !loading && ( + + 🌙 + No channels available + + )} + + + + ) +} diff --git a/mobile/app/mood.tsx b/mobile/app/mood.tsx new file mode 100644 index 0000000..063e71c --- /dev/null +++ b/mobile/app/mood.tsx @@ -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 ( + + + + Mood Radio + + + + + + 🎵 + + {activeMood.name} + + + + Currently Playing + ⬆️ + setActiveMood(MOODS[Math.floor(Math.random() * MOODS.length)])} + className="px-8 py-3 rounded-full bg-music-card" + > + Set the Mood + + + + ) +} \ No newline at end of file diff --git a/mobile/app/now-playing.tsx b/mobile/app/now-playing.tsx new file mode 100644 index 0000000..d0dfd71 --- /dev/null +++ b/mobile/app/now-playing.tsx @@ -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 ( + + router.back()} className="self-start"> + + + + + 🎵 + + + + {currentSong?.title || 'No song'} + {currentSong?.artist} + + + + + + + + {formatTime(progress)} + {currentSong ? formatTime(currentSong.duration) : '0:00'} + + + + + + 🔀 + + + ⏮️ + + + {isPlaying ? '⏸' : '▶️'} + + + ⏭️ + + + + + + router.push('/shareplay' as any)}> + 📢 + + Speaker + + + ) +} + +function formatTime(sec: number): string { + const m = Math.floor(sec / 60) + const s = Math.floor(sec % 60) + return `${m}:${s.toString().padStart(2, '0')}` +} \ No newline at end of file diff --git a/mobile/app/playlist.tsx b/mobile/app/playlist.tsx new file mode 100644 index 0000000..eb08ff0 --- /dev/null +++ b/mobile/app/playlist.tsx @@ -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 ( + + + router.push('/library' as any)}> + + + {playlists.map((pl: any, i: number) => ( + + ))} + + + + + + {isPlaying ? '⏸' : '▶️'} + + + + + + {playlists[0]?.name || 'Playlist'} + 📢 + + + + {loading ? ( + + ) : ( + songs.map((song: any) => ( + usePlayerStore.getState().setSong(song)} + > + + 🎵 + + {song.title} + {song.artist} + + {song.duration_sec ? `${Math.floor(song.duration_sec / 60)}:${(song.duration_sec % 60).toString().padStart(2, '0')}` : '--:--'} + + + )) + )} + + + ) +} diff --git a/mobile/app/radio.tsx b/mobile/app/radio.tsx new file mode 100644 index 0000000..0945909 --- /dev/null +++ b/mobile/app/radio.tsx @@ -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 ( + + + + router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center"> + 👤 + + Internet Radio + + + + + 🔍 + + + + {loading ? ( + + + Loading stations... + + ) : ( + <> + + Browse Stations + + {filtered.map((station: any) => ( + + + 📻 + + + {station.name || 'Station'} + + + {station.country_code || 'Local'} + + + ))} + + + {filtered.length === 0 && ( + + 📻 + No stations found + + )} + + )} + + + + ) +} diff --git a/mobile/app/releases.tsx b/mobile/app/releases.tsx new file mode 100644 index 0000000..f857133 --- /dev/null +++ b/mobile/app/releases.tsx @@ -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 ( + + + + + New Releases + + + + {loading ? ( + + + Loading releases... + + ) : releases.length > 0 ? ( + releases.map((release: any, i: number) => ( + + + + {release.artist || 'Artist'} + + ➕ Add + + + + {[...Array(Math.min(6, release.tracks || 3))].map((_, j) => ( + + ))} + + + )) + ) : ( + + 🎵 + No new releases found + + )} + + + + ) +} diff --git a/mobile/app/search.tsx b/mobile/app/search.tsx new file mode 100644 index 0000000..13fa54a --- /dev/null +++ b/mobile/app/search.tsx @@ -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 ( + + + + router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center"> + 👤 + + Search + + + + + 🔍 + handleSearch(query)} + className="text-sm text-music-text ml-2 flex-1" + numberOfLines={1} + > + {query || 'What music is calling to you?'} + + + + {loading && ( + + + + )} + + {!loading && results.length > 0 && ( + + Search Results + {results.map((item: any, i: number) => ( + + + 🎵 + + + {item.title || item.name} + {item.artist || 'Unknown'} + + + ))} + + )} + + {(!query || results.length === 0) && ( + <> + Browse + + {features.map((f) => ( + router.push(f.route as any)} className="flex-1 min-w-[30%] p-4 rounded-xl bg-music-card items-center"> + {f.icon} + {f.name} + + ))} + + + )} + + + {currentSong && ( + + + + + {currentSong.title} + {currentSong.artist} + + + + {isPlaying ? '⏸' : '▶️'} + + + )} + + + ) +} diff --git a/mobile/app/shareplay.tsx b/mobile/app/shareplay.tsx new file mode 100644 index 0000000..a3619c2 --- /dev/null +++ b/mobile/app/shareplay.tsx @@ -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 ( + + + + router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center"> + 👤 + + SharePlay + + + + + + + + 📢 + + 👤 + 1 + + + Currently Playing + + + + {currentSong?.title || 'No song'} + {currentSong?.artist} + + + + ⏮️ + + + {isPlaying ? '⏸' : '▶️'} + + + ⏭️ + + + Up Next + + + ➕ Add Song to Cue + + + + + ) +} \ No newline at end of file diff --git a/mobile/babel.config.js b/mobile/babel.config.js new file mode 100644 index 0000000..4759969 --- /dev/null +++ b/mobile/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: ['nativewind/babel', 'react-native-reanimated/plugin'], + }; +}; \ No newline at end of file diff --git a/mobile/metro.config.js b/mobile/metro.config.js new file mode 100644 index 0000000..e120693 --- /dev/null +++ b/mobile/metro.config.js @@ -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; \ No newline at end of file diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 0000000..f096da9 --- /dev/null +++ b/mobile/package.json @@ -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" + } +} \ No newline at end of file diff --git a/mobile/src/components/BottomNavBar.tsx b/mobile/src/components/BottomNavBar.tsx new file mode 100644 index 0000000..d5e8181 --- /dev/null +++ b/mobile/src/components/BottomNavBar.tsx @@ -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 ( + + {NAV_ITEMS.map((item) => { + const isActive = pathname === item.route || (item.route !== '/' && pathname.startsWith(item.route)) + return ( + router.push(item.route as any)} + className="flex-1 items-center py-2" + > + {item.icon} + + {item.label} + + + ) + })} + + ) +} \ No newline at end of file diff --git a/mobile/src/store/playerStore.ts b/mobile/src/store/playerStore.ts new file mode 100644 index 0000000..710dd50 --- /dev/null +++ b/mobile/src/store/playerStore.ts @@ -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((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] }) + } + }, +})) \ No newline at end of file diff --git a/mobile/src/styles/global.css b/mobile/src/styles/global.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/mobile/src/styles/global.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/mobile/tailwind.config.js b/mobile/tailwind.config.js new file mode 100644 index 0000000..ddc3629 --- /dev/null +++ b/mobile/tailwind.config.js @@ -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: [], +} \ No newline at end of file diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json new file mode 100644 index 0000000..d7a7291 --- /dev/null +++ b/mobile/tsconfig.json @@ -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" +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..8e2ff68 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..24c2275 --- /dev/null +++ b/shared/package.json @@ -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" + } +} \ No newline at end of file diff --git a/shared/src/api/client.ts b/shared/src/api/client.ts new file mode 100644 index 0000000..4837699 --- /dev/null +++ b/shared/src/api/client.ts @@ -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( + endpoint: string, + options: RequestInit = {} + ): Promise> { + const url = `${this.baseUrl}${endpoint}`; + const headers = { + 'Content-Type': 'application/json', + ...options.headers, + } as Record; + + 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(endpoint: string): Promise> { + return this.request(endpoint, { method: 'GET' }); + } + + async post(endpoint: string, body?: unknown): Promise> { + return this.request(endpoint, { + method: 'POST', + body: body ? JSON.stringify(body) : undefined, + }); + } + + async put(endpoint: string, body?: unknown): Promise> { + return this.request(endpoint, { + method: 'PUT', + body: body ? JSON.stringify(body) : undefined, + }); + } + + async delete(endpoint: string): Promise> { + return this.request(endpoint, { method: 'DELETE' }); + } + + async upload(endpoint: string, formData: FormData): Promise> { + 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>(`${ENDPOINTS.songs}?page=${page}&per_page=${perPage}`); + + getSong = (id: string) => this.get(ENDPOINTS.song(id)); + + deleteSong = (id: string) => this.delete(ENDPOINTS.song(id)); + + uploadSong = (file: File) => { + const formData = new FormData(); + formData.append('file', file); + return this.upload(ENDPOINTS.upload, formData); + }; + + scanSongs = (directory?: string) => + this.post(ENDPOINTS.scan, directory ? { directory } : undefined); + + // Playlists + getPlaylists = () => this.get(ENDPOINTS.playlists); + + getPlaylist = (id: string) => this.get(ENDPOINTS.playlist(id)); + + createPlaylist = (body: { name: string; description?: string; moodCategory?: string; songIds?: string[] }) => + this.post(ENDPOINTS.playlists, body); + + updatePlaylist = (id: string, body: Partial<{ name: string; description: string }>) => + this.put(ENDPOINTS.playlist(id), body); + + deletePlaylist = (id: string) => this.delete(ENDPOINTS.playlist(id)); + + addSongToPlaylist = (playlistId: string, songId: string) => + this.post(ENDPOINTS.playlistSongs(playlistId), { song_id: songId }); + + removeSongFromPlaylist = (playlistId: string, songId: string) => + this.delete(`${ENDPOINTS.playlistSongs(playlistId)}/${songId}`); + + sharePlaylist = (id: string) => this.post(ENDPOINTS.playlistShare(id)); + + getSharedPlaylist = (token: string) => this.get(ENDPOINTS.sharedPlaylist(token)); + + // Search + search = (query: string) => this.get(`${ENDPOINTS.search}?q=${encodeURIComponent(query)}`); + + // Mood + getMoodCategories = () => this.get(ENDPOINTS.moods); + + analyzeMood = (songId?: string) => this.post(ENDPOINTS.moodAnalyze, songId ? { song_id: songId } : undefined); + + getMoodPlaylist = (mood: string) => this.get(ENDPOINTS.moodPlaylist(mood)); + + saveMoodPlaylist = (mood: string, name?: string) => + this.post(ENDPOINTS.moodSave, { mood, name }); + + setMood = (mood: string) => this.post(ENDPOINTS.moodSet, { mood }); + + // Radio + getRadioStations = (country?: string, genre?: string, limit = 50) => + this.get(`${ENDPOINTS.radioStations}?limit=${limit}${country ? `&country=${country}` : ''}${genre ? `&genre=${genre}` : ''}`); + + getNearbyStations = (lat?: number, lon?: number, radius = 100) => + this.get(`${ENDPOINTS.radioNearby}${lat ? `?lat=${lat}&lon=${lon}&radius=${radius}` : ''}`); + + getRadioStation = (id: string) => this.get(ENDPOINTS.radioStation(id)); + + getRadioCurrent = () => this.get(ENDPOINTS.radioCurrent); + + // LoFi + getLofiChannels = () => this.get(ENDPOINTS.lofiChannels); + + // SharePlay + createSharePlay = () => this.post(ENDPOINTS.sharePlayCreate); + + joinSharePlay = (roomId: string) => this.post(ENDPOINTS.sharePlayJoin, { room_id: roomId }); + + leaveSharePlay = (roomId: string) => this.post(ENDPOINTS.sharePlayLeave, { room_id: roomId }); + + getCue = (roomId: string) => this.get(`${ENDPOINTS.sharePlayCue}?room_id=${roomId}`); + + addToCue = (roomId: string, songId: string) => + this.post(ENDPOINTS.sharePlayCue, { room_id: roomId, song_id: songId }); + + sendControl = (roomId: string, type: string, payload?: unknown) => + this.post(ENDPOINTS.sharePlayControl, { room_id: roomId, type, payload }); + + // Releases + getReleases = () => this.get(ENDPOINTS.releases); + + getArtistReleases = (artist: string) => this.get(ENDPOINTS.releaseArtist(artist)); + + // Events + getEvents = (lat?: number, lon?: number) => + this.get(`${ENDPOINTS.events}${lat ? `?lat=${lat}&lon=${lon}` : ''}`); + + // Settings + getSettings = () => this.get(ENDPOINTS.settings); + + updateSettings = (settings: Record) => this.put(ENDPOINTS.settings, settings); + + getServers = () => this.get(ENDPOINTS.settingsServers); + + addServer = (path: string, name?: string) => + this.post(ENDPOINTS.settingsServers, { path, name }); + + removeServer = (id: string) => this.delete(ENDPOINTS.settingsServer(id)); + + // Account + getAccountStats = () => this.get(ENDPOINTS.accountStats); + + getAccountHistory = () => this.get(ENDPOINTS.accountHistory); +} + +export const api = new ApiClient(); +export { ApiClient }; \ No newline at end of file diff --git a/shared/src/constants/api.ts b/shared/src/constants/api.ts new file mode 100644 index 0000000..6a2a506 --- /dev/null +++ b/shared/src/constants/api.ts @@ -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; \ No newline at end of file diff --git a/shared/src/constants/moods.ts b/shared/src/constants/moods.ts new file mode 100644 index 0000000..ec1134e --- /dev/null +++ b/shared/src/constants/moods.ts @@ -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 = { + 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); +} \ No newline at end of file diff --git a/shared/src/constants/navigation.ts b/shared/src/constants/navigation.ts new file mode 100644 index 0000000..3211a0f --- /dev/null +++ b/shared/src/constants/navigation.ts @@ -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; \ No newline at end of file diff --git a/shared/src/index.ts b/shared/src/index.ts new file mode 100644 index 0000000..b0dbefe --- /dev/null +++ b/shared/src/index.ts @@ -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'; \ No newline at end of file diff --git a/shared/src/types/account.ts b/shared/src/types/account.ts new file mode 100644 index 0000000..f18891f --- /dev/null +++ b/shared/src/types/account.ts @@ -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; +} \ No newline at end of file diff --git a/shared/src/types/common.ts b/shared/src/types/common.ts new file mode 100644 index 0000000..ad162d9 --- /dev/null +++ b/shared/src/types/common.ts @@ -0,0 +1,36 @@ +export interface PaginatedResponse { + items: T[]; + total: number; + page: number; + perPage: number; + totalPages: number; +} + +export interface ApiResponse { + 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; +} \ No newline at end of file diff --git a/shared/src/types/events.ts b/shared/src/types/events.ts new file mode 100644 index 0000000..7e65eb4 --- /dev/null +++ b/shared/src/types/events.ts @@ -0,0 +1,10 @@ +export interface ConcertEvent { + id: string; + name: string; + venue: string; + locationLat: number; + locationLon: number; + date: string; + description: string; + imageUrl: string | null; +} \ No newline at end of file diff --git a/shared/src/types/lofi.ts b/shared/src/types/lofi.ts new file mode 100644 index 0000000..00cc9f4 --- /dev/null +++ b/shared/src/types/lofi.ts @@ -0,0 +1,9 @@ +export interface LofiChannel { + id: string; + name: string; + streamUrl: string; + imagePath: string; + description: string; + sourcePlatform: string; + isActive: boolean; +} \ No newline at end of file diff --git a/shared/src/types/mood.ts b/shared/src/types/mood.ts new file mode 100644 index 0000000..b26492b --- /dev/null +++ b/shared/src/types/mood.ts @@ -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; +} \ No newline at end of file diff --git a/shared/src/types/playlist.ts b/shared/src/types/playlist.ts new file mode 100644 index 0000000..a065779 --- /dev/null +++ b/shared/src/types/playlist.ts @@ -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; +} \ No newline at end of file diff --git a/shared/src/types/radio.ts b/shared/src/types/radio.ts new file mode 100644 index 0000000..e027608 --- /dev/null +++ b/shared/src/types/radio.ts @@ -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; +} \ No newline at end of file diff --git a/shared/src/types/releases.ts b/shared/src/types/releases.ts new file mode 100644 index 0000000..618e55f --- /dev/null +++ b/shared/src/types/releases.ts @@ -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; +} \ No newline at end of file diff --git a/shared/src/types/search.ts b/shared/src/types/search.ts new file mode 100644 index 0000000..da221d7 --- /dev/null +++ b/shared/src/types/search.ts @@ -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; +} \ No newline at end of file diff --git a/shared/src/types/settings.ts b/shared/src/types/settings.ts new file mode 100644 index 0000000..8d3ac41 --- /dev/null +++ b/shared/src/types/settings.ts @@ -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; +} \ No newline at end of file diff --git a/shared/src/types/shareplay.ts b/shared/src/types/shareplay.ts new file mode 100644 index 0000000..b198f97 --- /dev/null +++ b/shared/src/types/shareplay.ts @@ -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; +} \ No newline at end of file diff --git a/shared/src/types/song.ts b/shared/src/types/song.ts new file mode 100644 index 0000000..88e576c --- /dev/null +++ b/shared/src/types/song.ts @@ -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; +} + +export interface SongUpload { + file: File; + metadata?: Partial; +} + +export interface ScanResult { + scanned: number; + added: number; + skipped: number; + errors: string[]; +} \ No newline at end of file diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 0000000..8d0ca93 --- /dev/null +++ b/shared/tsconfig.json @@ -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"] +} \ No newline at end of file