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
# Music directory for scanning
MUSIC_DIR=./music
# Upload directory
UPLOAD_DIR=./uploads
# Static files directory
STATIC_DIR=./static
TRANSCODE_FORMAT=ogg
TRANSCODE_BITRATE=192k
# APIs
GENIUS_API_KEY=your_genius_api_key
RADIO_BROWSER_INSTANCE=https://de1.api.radio-browser.info
MUSICBRAINZ_USER_AGENT=MusicApp/1.0
# CORS origins (comma-separated)
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
# Server
BACKEND_HOST=0.0.0.0
BACKEND_PORT=8000
CORS_ORIGINS=http://localhost:5173,http://localhost:8081
# API Key for protecting API routes (set a strong value in production)
API_KEY=your-secret-api-key-here
# LoFi Streams
LOFI_GIRL_URL=https://www.youtube.com/live/jfKfPfyJRdk
# Genius API key for lyrics fetching (get one at https://genius.com/api)
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.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
import os
from .db.database import init_db
@ -31,6 +34,33 @@ app.add_middleware(
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_dir = os.getenv("STATIC_DIR", "./static")
os.makedirs(static_dir, exist_ok=True)
@ -54,8 +84,16 @@ app.include_router(account.router)
@app.on_event("startup")
def startup():
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")
def health_check():
return {"status": "ok"}
return {"status": "ok"}

View File

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

View File

@ -21,6 +21,9 @@ class ControlRequest(BaseModel):
router = APIRouter(prefix="/api/shareplay", tags=["shareplay"])
manager = SharePlayManager()
# Track active WebSocket connections per room
active_connections: dict[str, list[WebSocket]] = {}
@router.post("/create")
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}")
async def websocket_endpoint(websocket: WebSocket, room_id: str):
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)
if state:
await websocket.send_json({"type": "state", "data": state})
try:
while True:
data = await websocket.receive_text()
import json
message = json.loads(data)
if message.get("type") == "control":
cmd = message.get("payload", {})
state = manager.get_state(room_id)
if state and cmd.get("type") == "play":
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":
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})
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:
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}"
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:
content = await file.read()
await f.write(content)
while True:
chunk = await file.read(chunk_size)
if not chunk:
break
await f.write(chunk)
file_size += len(chunk)
metadata = extract_metadata(file_path)
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,
album_art_path=metadata.get("album_art_path"),
file_format=ext.replace(".", ""),
file_size_bytes=len(content),
file_size_bytes=file_size,
)
db.add(song)
@ -156,4 +163,4 @@ def delete_song(song_id: str, db: Session = Depends(get_db)):
delete_song_service(song)
db.delete(song)
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 { BottomNavBar } from '../src/components/BottomNavBar'
import { useState } from 'react'
const PLAYLISTS = [
{ id: '1', name: 'Chill Vibes' },
{ id: '2', name: 'Workout Mix' },
{ id: '3', name: 'Road Trip' },
{ id: '4', name: 'Late Night' },
]
import { useState, useEffect } from 'react'
export default function LibraryScreen() {
const router = useRouter()
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 (
<View className="flex-1 bg-music-black">
@ -35,16 +47,22 @@ export default function LibraryScreen() {
</View>
</View>
<View className="flex-wrap flex-row gap-6 justify-center">
{filtered.map((playlist) => (
<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>
{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>
)
}
}

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 { BottomNavBar } from '../src/components/BottomNavBar'
const CHANNELS = [
{ id: '1', name: 'Lofi Girl - beats to relax/study to' },
{ id: '2', name: 'Chillhop Radio' },
{ id: '3', name: 'Lofi Hip Hop' },
]
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">
@ -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">
<Text className="text-music-muted">👤</Text>
</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>
{CHANNELS.map((ch) => (
<TouchableOpacity key={ch.id} className="mb-4 rounded-2xl bg-music-card overflow-hidden" style={{ aspectRatio: 16 / 9 }}>
<View className="flex-1 justify-center items-center bg-music-vinyl">
<Text className="text-5xl">🎵</Text>
</View>
<View className="p-4 bg-black/80 absolute bottom-0 left-0 right-0">
<Text className="text-xs text-music-accent">LoFi</Text>
<Text className="text-sm font-medium text-music-text">{ch.name}</Text>
</View>
</TouchableOpacity>
))}
{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>
)
}
}

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 { usePlayerStore } from '../src/store/playerStore'
import { BottomNavBar } from '../src/components/BottomNavBar'
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' },
]
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">
@ -30,8 +40,8 @@ export default function PlaylistScreen() {
<TouchableOpacity onPress={() => router.push('/library' as any)}>
<Text className="text-music-muted text-xl"></Text>
</TouchableOpacity>
{PLAYLISTS.map((pl, i) => (
<TouchableOpacity key={pl.id} className={`w-14 h-14 rounded-lg bg-music-card ${i === 0 ? 'border-2 border-music-accent' : ''}`} />
{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%' }} />
@ -44,18 +54,32 @@ export default function PlaylistScreen() {
<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">Chill Vibes</Text>
<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" />
{SONGS.map((song) => (
<View key={song.id} className="flex-row items-center gap-3 py-2">
<View className="w-8 h-8 rounded-full bg-music-vinyl" />
<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>
</View>
))}
{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>
)
}
}

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 { 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">
@ -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">
<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 className="mb-6 p-4 rounded-2xl bg-music-card">
<Text className="text-sm font-semibold text-music-text mb-3">Currently Airing Near You</Text>
<View className="h-48 rounded-xl bg-music-dark items-center justify-center">
<Text className="text-music-muted text-3xl">🗺</Text>
{loading ? (
<View className="items-center py-8">
<ActivityIndicator size="large" color="#e94560" />
<Text className="text-music-muted mt-2">Loading stations...</Text>
</View>
</View>
<View className="flex-wrap flex-row gap-4 justify-center">
{[1, 2, 3, 4, 5, 6].map((i) => (
<View key={i} className="w-32 h-32 rounded-xl bg-music-card items-center justify-center">
<View className="w-16 h-16 rounded-full bg-music-dark mb-2" />
<Text className="text-xs text-music-muted">Station {i}</Text>
) : (
<>
<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>
))}
</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>
)
}
}

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 { 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">
@ -13,24 +33,37 @@ export default function ReleasesScreen() {
<Text className="text-xl font-semibold text-music-text">New Releases</Text>
<View className="w-10" />
</View>
{[1, 2, 3].map((i) => (
<View key={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">Artist {i}</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>
{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>
)
}
}

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 { 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' },
@ -15,8 +19,28 @@ export default function SearchScreen() {
{ 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">
@ -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">
<Text className="text-music-muted">👤</Text>
</TouchableOpacity>
<View className="w-10" />
<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>
<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 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>
{loading && (
<View className="items-center py-8">
<ActivityIndicator size="large" color="#e94560" />
</View>
)}
<Text className="text-lg font-semibold text-music-text mb-3">Music Suggestions</Text>
<View className="flex-wrap flex-row gap-3 mb-6">
{[...Array(10)].map((_, i) => (
<View key={i} className="w-[18%] aspect-square rounded-lg bg-music-card" />
))}
</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>
)}
<Text className="text-lg font-semibold text-music-text mb-3">Your Library</Text>
<View className="flex-wrap flex-row gap-4">
{[1, 2, 3, 4].map((i) => (
<View key={i}>
<View className="w-20 h-20 rounded-lg bg-music-card" />
<Text className="text-sm text-music-text mt-2">Playlist {i}</Text>
{(!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>
))}
</View>
</>
)}
</ScrollView>
{currentSong && (
@ -77,4 +121,4 @@ export default function SearchScreen() {
<BottomNavBar />
</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"]