fix: batch fix all issues

- Add auth middleware (API key) to protect API routes (#5, #7)
- Add WebSocket handlers and cleanup on disconnect (#3, #8)
- Add web Dockerfile (#1)
- Fix memory upload with streaming chunks (#10)
- Move lofi seed to startup, remove per-request seeding (#9)
- Document ffmpeg dependency in README and .env.example (#6)
- Fill in mobile app with API-connected UI (#4)
- Set GENIUS_API_KEY from env var with documentation (#2)
This commit is contained in:
Jarian Cottingham 2026-07-05 21:36:59 +00:00
parent 8d9a092b32
commit 7e09799859
13 changed files with 509 additions and 152 deletions

View File

@ -1,20 +1,28 @@
# Backend # Music App Environment Configuration
# Database
DATABASE_URL=sqlite:///./app.db DATABASE_URL=sqlite:///./app.db
# Music directory for scanning
MUSIC_DIR=./music MUSIC_DIR=./music
# Upload directory
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
# Static files directory
STATIC_DIR=./static STATIC_DIR=./static
TRANSCODE_FORMAT=ogg
TRANSCODE_BITRATE=192k
# APIs # CORS origins (comma-separated)
GENIUS_API_KEY=your_genius_api_key CORS_ORIGINS=http://localhost:5173,http://localhost:3000
RADIO_BROWSER_INSTANCE=https://de1.api.radio-browser.info
MUSICBRAINZ_USER_AGENT=MusicApp/1.0
# Server # API Key for protecting API routes (set a strong value in production)
BACKEND_HOST=0.0.0.0 API_KEY=your-secret-api-key-here
BACKEND_PORT=8000
CORS_ORIGINS=http://localhost:5173,http://localhost:8081
# LoFi Streams # Genius API key for lyrics fetching (get one at https://genius.com/api)
LOFI_GIRL_URL=https://www.youtube.com/live/jfKfPfyJRdk GENIUS_API_KEY=your-genius-api-key-here
# System dependencies:
# - ffmpeg: required for audio transcoding (OGG conversion) and metadata extraction
# Ubuntu/Debian: apt-get install ffmpeg
# macOS: brew install ffmpeg
# Windows: choco install ffmpeg or download from https://ffmpeg.org/download.html

37
README.md Normal file
View File

@ -0,0 +1,37 @@
# Music App
Self-hosted music streaming application with mood radio, lofi channels, SharePlay, and internet radio.
## Quick Start
```bash
docker-compose up --build
```
## System Dependencies
- **ffmpeg** — required for audio transcoding (OGG conversion) and metadata extraction
- Debian/Ubuntu: `sudo apt-get install ffmpeg`
- macOS: `brew install ffmpeg`
- Windows: download from https://ffmpeg.org/download.html
## Configuration
Copy `.env.example` to `.env` and set your values:
```bash
cp .env.example .env
```
Key settings:
- `API_KEY` — API key for authentication (required in production)
- `GENIUS_API_KEY` — Genius.com API key for lyrics (get at https://genius.com/api)
- `MUSIC_DIR` — path to local music files
- `DATABASE_URL` — database connection string
## Project Structure
- `backend/` — FastAPI backend (Python)
- `web/` — React web frontend (Vite + Tailwind)
- `mobile/` — React Native mobile app (Expo)
- `shared/` — Shared TypeScript types and API client

View File

@ -1,6 +1,9 @@
from fastapi import FastAPI import os
import functools
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
import os import os
from .db.database import init_db from .db.database import init_db
@ -31,6 +34,33 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Auth middleware
API_KEY = os.getenv("API_KEY", "")
class APIKeyMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if not API_KEY:
return await call_next(request)
path = request.url.path
if path in ("/health", "/static") or path.startswith("/static"):
return await call_next(request)
header_key = request.headers.get("x-api-key", "")
query_key = request.query_params.get("api_key", "")
if header_key != API_KEY and query_key != API_KEY:
raise HTTPException(status_code=401, detail="Invalid or missing API key")
return await call_next(request)
app.add_middleware(APIKeyMiddleware)
def require_api_key(api_key: str = Depends(lambda: None)):
if not API_KEY:
return
raise HTTPException(status_code=401, detail="API key required")
# Static files # Static files
static_dir = os.getenv("STATIC_DIR", "./static") static_dir = os.getenv("STATIC_DIR", "./static")
os.makedirs(static_dir, exist_ok=True) os.makedirs(static_dir, exist_ok=True)
@ -54,8 +84,16 @@ app.include_router(account.router)
@app.on_event("startup") @app.on_event("startup")
def startup(): def startup():
init_db() init_db()
from sqlalchemy.orm import Session
from .db.database import SessionLocal
db = SessionLocal()
try:
from .routers.lofi import _seed_channels
_seed_channels(db)
finally:
db.close()
@app.get("/health") @app.get("/health")
def health_check(): def health_check():
return {"status": "ok"} return {"status": "ok"}

View File

@ -25,7 +25,6 @@ DEFAULT_CHANNELS = [
@router.get("/channels", response_model=List[LofiChannelResponse]) @router.get("/channels", response_model=List[LofiChannelResponse])
def list_channels(db: Session = Depends(get_db)): def list_channels(db: Session = Depends(get_db)):
_seed_channels(db)
channels = db.query(LofiChannel).filter(LofiChannel.is_active == True).all() channels = db.query(LofiChannel).filter(LofiChannel.is_active == True).all()
return [LofiChannelResponse.model_validate(c) for c in channels] return [LofiChannelResponse.model_validate(c) for c in channels]
@ -59,4 +58,4 @@ def _seed_channels(db: Session):
if not existing: if not existing:
channel = LofiChannel(**ch) channel = LofiChannel(**ch)
db.add(channel) db.add(channel)
db.commit() db.commit()

View File

@ -21,6 +21,9 @@ class ControlRequest(BaseModel):
router = APIRouter(prefix="/api/shareplay", tags=["shareplay"]) router = APIRouter(prefix="/api/shareplay", tags=["shareplay"])
manager = SharePlayManager() manager = SharePlayManager()
# Track active WebSocket connections per room
active_connections: dict[str, list[WebSocket]] = {}
@router.post("/create") @router.post("/create")
def create_room(db: Session = Depends(get_db)): def create_room(db: Session = Depends(get_db)):
@ -80,25 +83,81 @@ def send_control(data: ControlRequest, db: Session = Depends(get_db)):
@router.websocket("/ws/{room_id}") @router.websocket("/ws/{room_id}")
async def websocket_endpoint(websocket: WebSocket, room_id: str): async def websocket_endpoint(websocket: WebSocket, room_id: str):
await websocket.accept() await websocket.accept()
# Register connection
if room_id not in active_connections:
active_connections[room_id] = []
active_connections[room_id].append(websocket)
# Send initial state
state = manager.get_state(room_id) state = manager.get_state(room_id)
if state: if state:
await websocket.send_json({"type": "state", "data": state}) await websocket.send_json({"type": "state", "data": state})
try: try:
while True: while True:
data = await websocket.receive_text() data = await websocket.receive_text()
import json import json
message = json.loads(data) message = json.loads(data)
if message.get("type") == "control": if message.get("type") == "control":
cmd = message.get("payload", {}) cmd = message.get("payload", {})
state = manager.get_state(room_id) state = manager.get_state(room_id)
if state and cmd.get("type") == "play": if state and cmd.get("type") == "play":
manager.update_state(room_id, is_playing=True) manager.update_state(room_id, is_playing=True)
# Broadcast to all connections in room
for conn in active_connections.get(room_id, []):
try:
await conn.send_json({
"type": "playback_update",
"data": {"is_playing": True}
})
except:
pass
elif state and cmd.get("type") == "pause": elif state and cmd.get("type") == "pause":
manager.update_state(room_id, is_playing=False) manager.update_state(room_id, is_playing=False)
for conn in active_connections.get(room_id, []):
try:
await conn.send_json({
"type": "playback_update",
"data": {"is_playing": False}
})
except:
pass
await websocket.send_json({"type": "ack", "data": message}) await websocket.send_json({"type": "ack", "data": message})
elif message.get("type") == "chat":
# Broadcast chat message to all room members
chat_msg = message.get("payload", {})
for conn in active_connections.get(room_id, []):
try:
await conn.send_json({
"type": "chat",
"data": chat_msg
})
except:
pass
elif message.get("type") == "seek":
pos = message.get("payload", {}).get("position", 0)
manager.update_state(room_id, position=pos)
for conn in active_connections.get(room_id, []):
try:
await conn.send_json({
"type": "seek_update",
"data": {"position": pos}
})
except:
pass
except WebSocketDisconnect: except WebSocketDisconnect:
pass # Clean up disconnected client
if room_id in active_connections:
try:
active_connections[room_id].remove(websocket)
except ValueError:
pass
# Remove room entry if no connections left
if not active_connections[room_id]:
del active_connections[room_id]

View File

@ -114,9 +114,16 @@ async def upload_song(file: UploadFile = File(...), db: Session = Depends(get_db
saved_filename = f"{file_id}_{uuid.uuid4().hex[:8]}{ext}" saved_filename = f"{file_id}_{uuid.uuid4().hex[:8]}{ext}"
file_path = os.path.join(upload_dir, saved_filename) file_path = os.path.join(upload_dir, saved_filename)
# Stream file in chunks instead of loading entire file into memory
file_size = 0
chunk_size = 8192
async with aiofiles.open(file_path, "wb") as f: async with aiofiles.open(file_path, "wb") as f:
content = await file.read() while True:
await f.write(content) chunk = await file.read(chunk_size)
if not chunk:
break
await f.write(chunk)
file_size += len(chunk)
metadata = extract_metadata(file_path) metadata = extract_metadata(file_path)
transcoded_path = transcode_to_ogg(file_path, upload_dir) transcoded_path = transcode_to_ogg(file_path, upload_dir)
@ -132,7 +139,7 @@ async def upload_song(file: UploadFile = File(...), db: Session = Depends(get_db
transcoded_path=transcoded_path, transcoded_path=transcoded_path,
album_art_path=metadata.get("album_art_path"), album_art_path=metadata.get("album_art_path"),
file_format=ext.replace(".", ""), file_format=ext.replace(".", ""),
file_size_bytes=len(content), file_size_bytes=file_size,
) )
db.add(song) db.add(song)
@ -156,4 +163,4 @@ def delete_song(song_id: str, db: Session = Depends(get_db)):
delete_song_service(song) delete_song_service(song)
db.delete(song) db.delete(song)
db.commit() db.commit()
return {"message": "Song deleted"} return {"message": "Song deleted"}

View File

@ -1,19 +1,31 @@
import { View, Text, ScrollView, TouchableOpacity, TextInput } from 'react-native' import { View, Text, ScrollView, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router' import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar' import { BottomNavBar } from '../src/components/BottomNavBar'
import { useState } from 'react' import { useState, useEffect } from 'react'
const PLAYLISTS = [
{ id: '1', name: 'Chill Vibes' },
{ id: '2', name: 'Workout Mix' },
{ id: '3', name: 'Road Trip' },
{ id: '4', name: 'Late Night' },
]
export default function LibraryScreen() { export default function LibraryScreen() {
const router = useRouter() const router = useRouter()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const filtered = PLAYLISTS.filter(p => p.name.toLowerCase().includes(search.toLowerCase())) 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 ( return (
<View className="flex-1 bg-music-black"> <View className="flex-1 bg-music-black">
@ -35,16 +47,22 @@ export default function LibraryScreen() {
</View> </View>
</View> </View>
<View className="flex-wrap flex-row gap-6 justify-center"> {loading ? (
{filtered.map((playlist) => ( <View className="items-center py-8">
<TouchableOpacity key={playlist.id} onPress={() => router.push(`/playlist?id=${playlist.id}` as any)} className="items-center"> <ActivityIndicator size="large" color="#e94560" />
<View className="w-24 h-24 rounded-full bg-music-vinyl mb-2" /> </View>
<Text className="text-sm text-music-text">{playlist.name}</Text> ) : (
</TouchableOpacity> <View className="flex-wrap flex-row gap-6 justify-center">
))} {filtered.map((playlist: any) => (
</View> <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> </ScrollView>
<BottomNavBar /> <BottomNavBar />
</View> </View>
) )
} }

View File

@ -1,15 +1,29 @@
import { View, Text, ScrollView, TouchableOpacity } from 'react-native' import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router' import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar' import { BottomNavBar } from '../src/components/BottomNavBar'
import { usePlayerStore } from '../src/store/playerStore'
const CHANNELS = [ import { useState, useEffect } from 'react'
{ id: '1', name: 'Lofi Girl - beats to relax/study to' },
{ id: '2', name: 'Chillhop Radio' },
{ id: '3', name: 'Lofi Hip Hop' },
]
export default function LofiScreen() { export default function LofiScreen() {
const router = useRouter() 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 ( return (
<View className="flex-1 bg-music-black"> <View className="flex-1 bg-music-black">
@ -18,22 +32,37 @@ export default function LofiScreen() {
<TouchableOpacity onPress={() => router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center"> <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> <Text className="text-music-muted">👤</Text>
</TouchableOpacity> </TouchableOpacity>
<Text className="text-xl font-semibold text-music-text">LoFi Channel</Text> <Text className="text-xl font-semibold text-music-text">LoFi Channels</Text>
<View className="w-10" /> <View className="w-10" />
</View> </View>
{CHANNELS.map((ch) => (
<TouchableOpacity key={ch.id} className="mb-4 rounded-2xl bg-music-card overflow-hidden" style={{ aspectRatio: 16 / 9 }}> {loading ? (
<View className="flex-1 justify-center items-center bg-music-vinyl"> <View className="items-center py-8">
<Text className="text-5xl">🎵</Text> <ActivityIndicator size="large" color="#e94560" />
</View> </View>
<View className="p-4 bg-black/80 absolute bottom-0 left-0 right-0"> ) : (
<Text className="text-xs text-music-accent">LoFi</Text> channels.map((ch: any) => (
<Text className="text-sm font-medium text-music-text">{ch.name}</Text> <TouchableOpacity key={ch.id} className="mb-4 rounded-2xl bg-music-card overflow-hidden">
</View> <View className="h-40 justify-center items-center bg-music-vinyl relative">
</TouchableOpacity> <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> </ScrollView>
<BottomNavBar /> <BottomNavBar />
</View> </View>
) )
} }

View File

@ -1,28 +1,38 @@
import { View, Text, ScrollView, TouchableOpacity, FlatList } from 'react-native' import { View, Text, ScrollView, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router' import { useRouter } from 'expo-router'
import { usePlayerStore } from '../src/store/playerStore' import { usePlayerStore } from '../src/store/playerStore'
import { BottomNavBar } from '../src/components/BottomNavBar' import { BottomNavBar } from '../src/components/BottomNavBar'
import { useState, useEffect } from 'react'
const SONGS = [
{ id: '1', title: 'Song One', duration: 234 },
{ id: '2', title: 'Song Two', duration: 198 },
{ id: '3', title: 'Song Three', duration: 267 },
{ id: '4', title: 'Song Four', duration: 312 },
{ id: '5', title: 'Song Five', duration: 189 },
]
const PLAYLISTS = [
{ id: '1', name: 'Chill Vibes' },
{ id: '2', name: 'Workout' },
{ id: '3', name: 'Road Trip' },
{ id: '4', name: 'Late Night' },
{ id: '5', name: 'Focus' },
]
export default function PlaylistScreen() { export default function PlaylistScreen() {
const router = useRouter() const router = useRouter()
const isPlaying = usePlayerStore(s => s.isPlaying) const isPlaying = usePlayerStore(s => s.isPlaying)
const togglePlay = usePlayerStore(s => s.togglePlay) 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 ( return (
<View className="flex-1 bg-music-black flex-row"> <View className="flex-1 bg-music-black flex-row">
@ -30,8 +40,8 @@ export default function PlaylistScreen() {
<TouchableOpacity onPress={() => router.push('/library' as any)}> <TouchableOpacity onPress={() => router.push('/library' as any)}>
<Text className="text-music-muted text-xl"></Text> <Text className="text-music-muted text-xl"></Text>
</TouchableOpacity> </TouchableOpacity>
{PLAYLISTS.map((pl, i) => ( {playlists.map((pl: any, i: number) => (
<TouchableOpacity key={pl.id} className={`w-14 h-14 rounded-lg bg-music-card ${i === 0 ? 'border-2 border-music-accent' : ''}`} /> <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="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 className="absolute bottom-0 w-full bg-music-accent rounded-full" style={{ height: '35%' }} />
@ -44,18 +54,32 @@ export default function PlaylistScreen() {
<ScrollView className="flex-1 px-6 py-4"> <ScrollView className="flex-1 px-6 py-4">
<View className="flex-row items-center justify-between mb-4"> <View className="flex-row items-center justify-between mb-4">
<Text className="text-2xl font-semibold text-music-text">Chill Vibes</Text> <Text className="text-2xl font-semibold text-music-text">{playlists[0]?.name || 'Playlist'}</Text>
<Text className="text-music-muted">📢</Text> <Text className="text-music-muted">📢</Text>
</View> </View>
<View className="w-48 h-48 rounded-xl bg-music-card mb-6" /> <View className="w-48 h-48 rounded-xl bg-music-card mb-6" />
{SONGS.map((song) => (
<View key={song.id} className="flex-row items-center gap-3 py-2"> {loading ? (
<View className="w-8 h-8 rounded-full bg-music-vinyl" /> <ActivityIndicator size="large" color="#e94560" />
<Text className="flex-1 text-sm text-music-text">{song.title}</Text> ) : (
<Text className="text-xs text-music-muted">{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}</Text> songs.map((song: any) => (
</View> <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> </ScrollView>
</View> </View>
) )
} }

View File

@ -1,9 +1,31 @@
import { View, Text, ScrollView, TouchableOpacity, TextInput } from 'react-native' import { View, Text, ScrollView, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router' import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar' import { BottomNavBar } from '../src/components/BottomNavBar'
import { useState, useEffect } from 'react'
export default function RadioScreen() { export default function RadioScreen() {
const router = useRouter() 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 ( return (
<View className="flex-1 bg-music-black"> <View className="flex-1 bg-music-black">
@ -18,26 +40,53 @@ export default function RadioScreen() {
<View className="flex-row items-center bg-music-card rounded-full px-4 py-3 mb-6"> <View className="flex-row items-center bg-music-card rounded-full px-4 py-3 mb-6">
<Text className="text-music-muted">🔍</Text> <Text className="text-music-muted">🔍</Text>
<TextInput placeholder="What music is calling to you?" placeholderTextColor="#888" className="text-sm text-music-text ml-2 flex-1" /> <TextInput
placeholder="Search stations..."
placeholderTextColor="#888"
value={search}
onChangeText={setSearch}
className="text-sm text-music-text ml-2 flex-1"
/>
</View> </View>
<View className="mb-6 p-4 rounded-2xl bg-music-card"> {loading ? (
<Text className="text-sm font-semibold text-music-text mb-3">Currently Airing Near You</Text> <View className="items-center py-8">
<View className="h-48 rounded-xl bg-music-dark items-center justify-center"> <ActivityIndicator size="large" color="#e94560" />
<Text className="text-music-muted text-3xl">🗺</Text> <Text className="text-music-muted mt-2">Loading stations...</Text>
</View> </View>
</View> ) : (
<>
<View className="flex-wrap flex-row gap-4 justify-center"> <View className="mb-6 p-4 rounded-2xl bg-music-card">
{[1, 2, 3, 4, 5, 6].map((i) => ( <Text className="text-sm font-semibold text-music-text mb-3">Browse Stations</Text>
<View key={i} className="w-32 h-32 rounded-xl bg-music-card items-center justify-center"> <View className="flex-wrap flex-row gap-4">
<View className="w-16 h-16 rounded-full bg-music-dark mb-2" /> {filtered.map((station: any) => (
<Text className="text-xs text-music-muted">Station {i}</Text> <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> </View>
))} {filtered.length === 0 && (
</View> <View className="items-center py-8">
<Text className="text-3xl mb-2">📻</Text>
<Text className="text-music-muted">No stations found</Text>
</View>
)}
</>
)}
</ScrollView> </ScrollView>
<BottomNavBar /> <BottomNavBar />
</View> </View>
) )
} }

View File

@ -1,9 +1,29 @@
import { View, Text, ScrollView, TouchableOpacity } from 'react-native' import { View, Text, ScrollView, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router' import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar' import { BottomNavBar } from '../src/components/BottomNavBar'
import { usePlayerStore } from '../src/store/playerStore'
import { useState, useEffect } from 'react'
export default function ReleasesScreen() { export default function ReleasesScreen() {
const router = useRouter() 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 ( return (
<View className="flex-1 bg-music-black"> <View className="flex-1 bg-music-black">
@ -13,24 +33,37 @@ export default function ReleasesScreen() {
<Text className="text-xl font-semibold text-music-text">New Releases</Text> <Text className="text-xl font-semibold text-music-text">New Releases</Text>
<View className="w-10" /> <View className="w-10" />
</View> </View>
{[1, 2, 3].map((i) => (
<View key={i} className="flex-row gap-4 p-4 rounded-2xl bg-music-card mb-4"> {loading ? (
<View className="items-center"> <View className="items-center py-12">
<View className="w-16 h-16 rounded-lg bg-music-dark mb-2" /> <ActivityIndicator size="large" color="#e94560" />
<Text className="text-xs text-music-muted">Artist {i}</Text> <Text className="text-music-muted mt-2">Loading releases...</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(6)].map((_, j) => (
<View key={j} className="w-[15%] aspect-square rounded-lg bg-music-dark" />
))}
</View>
</View> </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> </ScrollView>
<BottomNavBar /> <BottomNavBar />
</View> </View>
) )
} }

View File

@ -1,13 +1,17 @@
import { View, Text, ScrollView, TouchableOpacity, TextInput } from 'react-native' import { View, Text, ScrollView, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router' import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar' import { BottomNavBar } from '../src/components/BottomNavBar'
import { usePlayerStore } from '../src/store/playerStore' import { usePlayerStore } from '../src/store/playerStore'
import { useState, useEffect } from 'react'
export default function SearchScreen() { export default function SearchScreen() {
const router = useRouter() const router = useRouter()
const currentSong = usePlayerStore(s => s.currentSong) const currentSong = usePlayerStore(s => s.currentSong)
const isPlaying = usePlayerStore(s => s.isPlaying) const isPlaying = usePlayerStore(s => s.isPlaying)
const togglePlay = usePlayerStore(s => s.togglePlay) const togglePlay = usePlayerStore(s => s.togglePlay)
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const features = [ const features = [
{ id: 'music', name: 'Music', icon: '🎵', route: '/library' }, { id: 'music', name: 'Music', icon: '🎵', route: '/library' },
@ -15,8 +19,28 @@ export default function SearchScreen() {
{ id: 'events', name: 'Live Events', icon: '🎪', route: '/' }, { id: 'events', name: 'Live Events', icon: '🎪', route: '/' },
{ id: 'radio', name: 'Internet Radio', icon: '📻', route: '/radio' }, { id: 'radio', name: 'Internet Radio', icon: '📻', route: '/radio' },
{ id: 'mood', name: 'Mood Radio', icon: '😊', route: '/mood' }, { 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 ( return (
<View className="flex-1 bg-music-black"> <View className="flex-1 bg-music-black">
<ScrollView className="flex-1 px-4 pt-4"> <ScrollView className="flex-1 px-4 pt-4">
@ -24,40 +48,60 @@ export default function SearchScreen() {
<TouchableOpacity onPress={() => router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center"> <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> <Text className="text-music-muted">👤</Text>
</TouchableOpacity> </TouchableOpacity>
<View className="w-10" /> <Text className="text-xl font-semibold text-music-text">Search</Text>
<View className="w-10" /> <View className="w-10" />
</View> </View>
<View className="flex-row items-center bg-music-card rounded-full px-4 py-3 mb-6"> <View className="flex-row items-center bg-music-card rounded-full px-4 py-3 mb-6">
<Text className="text-music-muted">🔍</Text> <Text className="text-music-muted">🔍</Text>
<TextInput placeholder="What music is calling to you?" placeholderTextColor="#888" className="text-sm text-music-text ml-2 flex-1" /> <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> </View>
<View className="flex-wrap flex-row gap-3 mb-6"> {loading && (
{features.map((f) => ( <View className="items-center py-8">
<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"> <ActivityIndicator size="large" color="#e94560" />
<Text className="text-2xl mb-1">{f.icon}</Text> </View>
<Text className="text-xs text-music-text">{f.name}</Text> )}
</TouchableOpacity>
))}
</View>
<Text className="text-lg font-semibold text-music-text mb-3">Music Suggestions</Text> {!loading && results.length > 0 && (
<View className="flex-wrap flex-row gap-3 mb-6"> <View className="mb-6">
{[...Array(10)].map((_, i) => ( <Text className="text-lg font-semibold text-music-text mb-3">Search Results</Text>
<View key={i} className="w-[18%] aspect-square rounded-lg bg-music-card" /> {results.map((item: any, i: number) => (
))} <TouchableOpacity
</View> 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>
)}
<Text className="text-lg font-semibold text-music-text mb-3">Your Library</Text> {(!query || results.length === 0) && (
<View className="flex-wrap flex-row gap-4"> <>
{[1, 2, 3, 4].map((i) => ( <Text className="text-lg font-semibold text-music-text mb-3">Browse</Text>
<View key={i}> <View className="flex-wrap flex-row gap-3 mb-6">
<View className="w-20 h-20 rounded-lg bg-music-card" /> {features.map((f) => (
<Text className="text-sm text-music-text mt-2">Playlist {i}</Text> <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> </View>
))} </>
</View> )}
</ScrollView> </ScrollView>
{currentSong && ( {currentSong && (
@ -77,4 +121,4 @@ export default function SearchScreen() {
<BottomNavBar /> <BottomNavBar />
</View> </View>
) )
} }

12
web/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]