Merge pull request 'fix: batch fix all issues' (#12) from fix/all into main
Reviewed-on: https://git.example.com/jarianc/music-app/pulls/12
This commit is contained in:
commit
8bb13b4333
34
.env.example
34
.env.example
@ -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
37
README.md
Normal 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
|
||||||
@ -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,6 +84,14 @@ 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")
|
||||||
|
|||||||
@ -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]
|
||||||
|
|
||||||
|
|||||||
@ -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)):
|
||||||
@ -81,6 +84,12 @@ def send_control(data: ControlRequest, db: Session = Depends(get_db)):
|
|||||||
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})
|
||||||
@ -96,9 +105,59 @@ async def websocket_endpoint(websocket: WebSocket, room_id: str):
|
|||||||
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})
|
||||||
except WebSocketDisconnect:
|
|
||||||
|
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
|
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:
|
||||||
|
# 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]
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,14 +47,20 @@ export default function LibraryScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View className="items-center py-8">
|
||||||
|
<ActivityIndicator size="large" color="#e94560" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
<View className="flex-wrap flex-row gap-6 justify-center">
|
<View className="flex-wrap flex-row gap-6 justify-center">
|
||||||
{filtered.map((playlist) => (
|
{filtered.map((playlist: any) => (
|
||||||
<TouchableOpacity key={playlist.id} onPress={() => router.push(`/playlist?id=${playlist.id}` as any)} className="items-center">
|
<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" />
|
<View className="w-24 h-24 rounded-full bg-music-vinyl mb-2" />
|
||||||
<Text className="text-sm text-music-text">{playlist.name}</Text>
|
<Text className="text-sm text-music-text">{playlist.name}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<BottomNavBar />
|
<BottomNavBar />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -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,20 +32,35 @@ 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">
|
) : (
|
||||||
|
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-xs text-music-accent">LoFi</Text>
|
||||||
<Text className="text-sm font-medium text-music-text">{ch.name}</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>
|
</View>
|
||||||
</TouchableOpacity>
|
</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>
|
||||||
|
|||||||
@ -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,17 +54,31 @@ 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) => (
|
||||||
|
<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>
|
</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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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,24 +40,51 @@ 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>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View className="items-center py-8">
|
||||||
|
<ActivityIndicator size="large" color="#e94560" />
|
||||||
|
<Text className="text-music-muted mt-2">Loading stations...</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<View className="mb-6 p-4 rounded-2xl bg-music-card">
|
<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>
|
<Text className="text-sm font-semibold text-music-text mb-3">Browse Stations</Text>
|
||||||
<View className="h-48 rounded-xl bg-music-dark items-center justify-center">
|
<View className="flex-wrap flex-row gap-4">
|
||||||
<Text className="text-music-muted text-3xl">🗺️</Text>
|
{filtered.map((station: any) => (
|
||||||
</View>
|
<TouchableOpacity
|
||||||
</View>
|
key={station.id}
|
||||||
|
className="w-32 p-3 rounded-xl bg-music-dark items-center"
|
||||||
<View className="flex-wrap flex-row gap-4 justify-center">
|
>
|
||||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
<View className="w-16 h-16 rounded-full bg-music-vinyl mb-2 items-center justify-center">
|
||||||
<View key={i} className="w-32 h-32 rounded-xl bg-music-card items-center justify-center">
|
<Text>📻</Text>
|
||||||
<View className="w-16 h-16 rounded-full bg-music-dark mb-2" />
|
|
||||||
<Text className="text-xs text-music-muted">Station {i}</Text>
|
|
||||||
</View>
|
</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>
|
</ScrollView>
|
||||||
<BottomNavBar />
|
<BottomNavBar />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -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,22 +33,35 @@ 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 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="items-center">
|
||||||
<View className="w-16 h-16 rounded-lg bg-music-dark mb-2" />
|
<View className="w-16 h-16 rounded-lg bg-music-dark mb-2" />
|
||||||
<Text className="text-xs text-music-muted">Artist {i}</Text>
|
<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">
|
<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>
|
<Text className="text-xs text-music-text">➕ Add</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-wrap flex-row gap-2 flex-1">
|
<View className="flex-wrap flex-row gap-2 flex-1">
|
||||||
{[...Array(6)].map((_, j) => (
|
{[...Array(Math.min(6, release.tracks || 3))].map((_, j) => (
|
||||||
<View key={j} className="w-[15%] aspect-square rounded-lg bg-music-dark" />
|
<TouchableOpacity key={j} className="w-[15%] aspect-square rounded-lg bg-music-dark" />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</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>
|
||||||
|
|||||||
@ -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,15 +48,50 @@ 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>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<View className="items-center py-8">
|
||||||
|
<ActivityIndicator size="large" color="#e94560" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && results.length > 0 && (
|
||||||
|
<View className="mb-6">
|
||||||
|
<Text className="text-lg font-semibold text-music-text mb-3">Search Results</Text>
|
||||||
|
{results.map((item: any, i: number) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={i}
|
||||||
|
className="flex-row items-center gap-3 py-3 bg-music-card rounded-xl mb-2 px-3"
|
||||||
|
>
|
||||||
|
<View className="w-10 h-10 rounded-full bg-music-vinyl items-center justify-center">
|
||||||
|
<Text>🎵</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-medium text-music-text">{item.title || item.name}</Text>
|
||||||
|
<Text className="text-xs text-music-muted">{item.artist || 'Unknown'}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!query || results.length === 0) && (
|
||||||
|
<>
|
||||||
|
<Text className="text-lg font-semibold text-music-text mb-3">Browse</Text>
|
||||||
<View className="flex-wrap flex-row gap-3 mb-6">
|
<View className="flex-wrap flex-row gap-3 mb-6">
|
||||||
{features.map((f) => (
|
{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">
|
<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">
|
||||||
@ -41,23 +100,8 @@ export default function SearchScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{currentSong && (
|
{currentSong && (
|
||||||
|
|||||||
12
web/Dockerfile
Normal file
12
web/Dockerfile
Normal 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"]
|
||||||
Loading…
x
Reference in New Issue
Block a user