feat: Add new logo, PWA service worker, and background audio support
- New modern audio wave 'A' logo (192x192 and 512x512 icons) - PWA service worker for offline support and installability - Wake Lock API for background audio on FiiO/Android devices - Visibility change handling to prevent audio pause on screen off - Updated manifest.json with music categories and proper PWA config - Media Session API lock screen controls (already present) - Renamed app to 'Audiophile Web Player'
|
|
@ -1,29 +1,29 @@
|
|||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
npm-debug.log
|
||||
|
||||
# Python
|
||||
venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# Next.js
|
||||
.next
|
||||
out
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
npm-debug.log
|
||||
|
||||
# Python
|
||||
venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# Next.js
|
||||
.next
|
||||
out
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
|
|
|
|||
130
Dockerfile
|
|
@ -1,65 +1,65 @@
|
|||
# --- Stage 1: Frontend Builder ---
|
||||
FROM node:18-slim AS builder
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
# Install dependencies including sharp for build
|
||||
RUN npm install --legacy-peer-deps
|
||||
COPY frontend/ ./
|
||||
# Build with standalone output
|
||||
ENV NEXT_PUBLIC_API_URL=""
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 2: Final Runtime Image ---
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
gnupg \
|
||||
ffmpeg \
|
||||
ca-certificates \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& update-ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Backend Setup
|
||||
COPY backend/requirements.txt ./backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||
|
||||
# Frontend Setup (Copy from Builder)
|
||||
# Copy the standalone server
|
||||
COPY --from=builder /app/frontend/.next/standalone /app/frontend
|
||||
# Explicitly install sharp in the standalone folder to ensure compatibility
|
||||
RUN cd /app/frontend && npm install sharp
|
||||
|
||||
# Copy static files (required for standalone)
|
||||
COPY --from=builder /app/frontend/.next/static /app/frontend/.next/static
|
||||
COPY --from=builder /app/frontend/public /app/frontend/public
|
||||
|
||||
# Copy Backend Code
|
||||
COPY backend/ ./backend/
|
||||
|
||||
# Create start script
|
||||
RUN mkdir -p backend/data_seed && cp -r backend/data/* backend/data_seed/ || true
|
||||
|
||||
# Set Environment Variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Note: Standalone mode runs with 'node server.js'
|
||||
RUN echo '#!/bin/bash\n\
|
||||
if [ ! -f backend/data/data.json ]; then\n\
|
||||
echo "Data volume appears empty. Seeding with bundled data..."\n\
|
||||
cp -r backend/data_seed/* backend/data/\n\
|
||||
fi\n\
|
||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000 &\n\
|
||||
cd frontend && node server.js\n\
|
||||
' > start.sh && chmod +x start.sh
|
||||
|
||||
EXPOSE 3000 8000
|
||||
|
||||
CMD ["./start.sh"]
|
||||
# --- Stage 1: Frontend Builder ---
|
||||
FROM node:18-slim AS builder
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
# Install dependencies including sharp for build
|
||||
RUN npm install --legacy-peer-deps
|
||||
COPY frontend/ ./
|
||||
# Build with standalone output
|
||||
ENV NEXT_PUBLIC_API_URL=""
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 2: Final Runtime Image ---
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
gnupg \
|
||||
ffmpeg \
|
||||
ca-certificates \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& update-ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Backend Setup
|
||||
COPY backend/requirements.txt ./backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||
|
||||
# Frontend Setup (Copy from Builder)
|
||||
# Copy the standalone server
|
||||
COPY --from=builder /app/frontend/.next/standalone /app/frontend
|
||||
# Explicitly install sharp in the standalone folder to ensure compatibility
|
||||
RUN cd /app/frontend && npm install sharp
|
||||
|
||||
# Copy static files (required for standalone)
|
||||
COPY --from=builder /app/frontend/.next/static /app/frontend/.next/static
|
||||
COPY --from=builder /app/frontend/public /app/frontend/public
|
||||
|
||||
# Copy Backend Code
|
||||
COPY backend/ ./backend/
|
||||
|
||||
# Create start script
|
||||
RUN mkdir -p backend/data_seed && cp -r backend/data/* backend/data_seed/ || true
|
||||
|
||||
# Set Environment Variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Note: Standalone mode runs with 'node server.js'
|
||||
RUN echo '#!/bin/bash\n\
|
||||
if [ ! -f backend/data/data.json ]; then\n\
|
||||
echo "Data volume appears empty. Seeding with bundled data..."\n\
|
||||
cp -r backend/data_seed/* backend/data/\n\
|
||||
fi\n\
|
||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000 &\n\
|
||||
cd frontend && node server.js\n\
|
||||
' > start.sh && chmod +x start.sh
|
||||
|
||||
EXPOSE 3000 8000
|
||||
|
||||
CMD ["./start.sh"]
|
||||
|
|
|
|||
1
backend/backend/data/user_playlists.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
[]
|
||||
|
|
@ -1,53 +1,53 @@
|
|||
import json
|
||||
import time
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
class CacheManager:
|
||||
def __init__(self, cache_dir: str = "backend/cache"):
|
||||
self.cache_dir = Path(cache_dir)
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_path(self, key: str) -> Path:
|
||||
# Create a safe filename from the key
|
||||
hashed_key = hashlib.md5(key.encode()).hexdigest()
|
||||
return self.cache_dir / f"{hashed_key}.json"
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Retrieve data from cache if it exists and hasn't expired.
|
||||
"""
|
||||
path = self._get_path(key)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check TTL
|
||||
if data["expires_at"] < time.time():
|
||||
# Expired, delete it
|
||||
path.unlink()
|
||||
return None
|
||||
|
||||
return data["value"]
|
||||
except (json.JSONDecodeError, KeyError, OSError):
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any, ttl_seconds: int = 3600):
|
||||
"""
|
||||
Save data to cache with a TTL (default 1 hour).
|
||||
"""
|
||||
path = self._get_path(key)
|
||||
data = {
|
||||
"value": value,
|
||||
"expires_at": time.time() + ttl_seconds,
|
||||
"key_debug": key # Store original key for debugging
|
||||
}
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
except OSError as e:
|
||||
print(f"Cache Write Error: {e}")
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
class CacheManager:
|
||||
def __init__(self, cache_dir: str = "backend/cache"):
|
||||
self.cache_dir = Path(cache_dir)
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_path(self, key: str) -> Path:
|
||||
# Create a safe filename from the key
|
||||
hashed_key = hashlib.md5(key.encode()).hexdigest()
|
||||
return self.cache_dir / f"{hashed_key}.json"
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Retrieve data from cache if it exists and hasn't expired.
|
||||
"""
|
||||
path = self._get_path(key)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check TTL
|
||||
if data["expires_at"] < time.time():
|
||||
# Expired, delete it
|
||||
path.unlink()
|
||||
return None
|
||||
|
||||
return data["value"]
|
||||
except (json.JSONDecodeError, KeyError, OSError):
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any, ttl_seconds: int = 3600):
|
||||
"""
|
||||
Save data to cache with a TTL (default 1 hour).
|
||||
"""
|
||||
path = self._get_path(key)
|
||||
data = {
|
||||
"value": value,
|
||||
"expires_at": time.time() + ttl_seconds,
|
||||
"key_debug": key # Store original key for debugging
|
||||
}
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
except OSError as e:
|
||||
print(f"Cache Write Error: {e}")
|
||||
|
|
|
|||
130
backend/main.py
|
|
@ -1,65 +1,65 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from backend.api.routes import router as api_router
|
||||
from backend.scheduler import start_scheduler
|
||||
import os
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: Start scheduler
|
||||
scheduler = start_scheduler()
|
||||
yield
|
||||
# Shutdown: Scheduler shuts down automatically with process, or we can explicit shutdown if needed
|
||||
scheduler.shutdown()
|
||||
|
||||
app = FastAPI(title="Spotify Clone Backend", lifespan=lifespan)
|
||||
|
||||
# CORS setup
|
||||
origins = [
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix="/api")
|
||||
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
# Serve Static Frontend (Production Mode)
|
||||
STATIC_DIR = "static"
|
||||
if os.path.exists(STATIC_DIR):
|
||||
app.mount("/_next", StaticFiles(directory=os.path.join(STATIC_DIR, "_next")), name="next_assets")
|
||||
|
||||
# Serve other static files (favicons etc) if they exist in root of static
|
||||
# Or just fallback everything else to index.html for SPA
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
# Check if file exists in static folder
|
||||
file_path = os.path.join(STATIC_DIR, full_path)
|
||||
if os.path.isfile(file_path):
|
||||
return FileResponse(file_path)
|
||||
|
||||
# Otherwise return index.html
|
||||
index_path = os.path.join(STATIC_DIR, "index.html")
|
||||
if os.path.exists(index_path):
|
||||
return FileResponse(index_path)
|
||||
|
||||
return {"error": "Frontend not found"}
|
||||
else:
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "Spotify Clone Backend Running (Frontend not built/mounted)"}
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok"}
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from backend.api.routes import router as api_router
|
||||
from backend.scheduler import start_scheduler
|
||||
import os
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: Start scheduler
|
||||
scheduler = start_scheduler()
|
||||
yield
|
||||
# Shutdown: Scheduler shuts down automatically with process, or we can explicit shutdown if needed
|
||||
scheduler.shutdown()
|
||||
|
||||
app = FastAPI(title="Spotify Clone Backend", lifespan=lifespan)
|
||||
|
||||
# CORS setup
|
||||
origins = [
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix="/api")
|
||||
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
# Serve Static Frontend (Production Mode)
|
||||
STATIC_DIR = "static"
|
||||
if os.path.exists(STATIC_DIR):
|
||||
app.mount("/_next", StaticFiles(directory=os.path.join(STATIC_DIR, "_next")), name="next_assets")
|
||||
|
||||
# Serve other static files (favicons etc) if they exist in root of static
|
||||
# Or just fallback everything else to index.html for SPA
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
# Check if file exists in static folder
|
||||
file_path = os.path.join(STATIC_DIR, full_path)
|
||||
if os.path.isfile(file_path):
|
||||
return FileResponse(file_path)
|
||||
|
||||
# Otherwise return index.html
|
||||
index_path = os.path.join(STATIC_DIR, "index.html")
|
||||
if os.path.exists(index_path):
|
||||
return FileResponse(index_path)
|
||||
|
||||
return {"error": "Frontend not found"}
|
||||
else:
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "Spotify Clone Backend Running (Frontend not built/mounted)"}
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok"}
|
||||
|
|
|
|||
|
|
@ -1,88 +1,88 @@
|
|||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
DATA_FILE = Path("backend/data/user_playlists.json")
|
||||
|
||||
class PlaylistManager:
|
||||
def __init__(self):
|
||||
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not DATA_FILE.exists():
|
||||
self._save_data([])
|
||||
|
||||
def _load_data(self) -> List[Dict]:
|
||||
try:
|
||||
with open(DATA_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
|
||||
def _save_data(self, data: List[Dict]):
|
||||
with open(DATA_FILE, "w") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
|
||||
def get_all(self) -> List[Dict]:
|
||||
return self._load_data()
|
||||
|
||||
def get_by_id(self, playlist_id: str) -> Optional[Dict]:
|
||||
playlists = self._load_data()
|
||||
for p in playlists:
|
||||
if p["id"] == playlist_id:
|
||||
return p
|
||||
return None
|
||||
|
||||
def create(self, name: str, description: str = "") -> Dict:
|
||||
playlists = self._load_data()
|
||||
new_playlist = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"title": name,
|
||||
"description": description,
|
||||
"tracks": [],
|
||||
"cover_url": "https://placehold.co/400?text=Playlist", # Default placeholder
|
||||
"is_user_created": True
|
||||
}
|
||||
playlists.append(new_playlist)
|
||||
self._save_data(playlists)
|
||||
return new_playlist
|
||||
|
||||
def update(self, playlist_id: str, name: str = None, description: str = None) -> Optional[Dict]:
|
||||
playlists = self._load_data()
|
||||
for p in playlists:
|
||||
if p["id"] == playlist_id:
|
||||
if name: p["title"] = name
|
||||
if description: p["description"] = description
|
||||
self._save_data(playlists)
|
||||
return p
|
||||
return None
|
||||
|
||||
def delete(self, playlist_id: str) -> bool:
|
||||
playlists = self._load_data()
|
||||
initial_len = len(playlists)
|
||||
playlists = [p for p in playlists if p["id"] != playlist_id]
|
||||
if len(playlists) < initial_len:
|
||||
self._save_data(playlists)
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_track(self, playlist_id: str, track: Dict) -> bool:
|
||||
playlists = self._load_data()
|
||||
for p in playlists:
|
||||
if p["id"] == playlist_id:
|
||||
# Check for duplicates? For now allow.
|
||||
p["tracks"].append(track)
|
||||
# Update cover if it's the first track
|
||||
if len(p["tracks"]) == 1 and track.get("cover_url"):
|
||||
p["cover_url"] = track["cover_url"]
|
||||
self._save_data(playlists)
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_track(self, playlist_id: str, track_id: str) -> bool:
|
||||
playlists = self._load_data()
|
||||
for p in playlists:
|
||||
if p["id"] == playlist_id:
|
||||
p["tracks"] = [t for t in p["tracks"] if t.get("id") != track_id]
|
||||
self._save_data(playlists)
|
||||
return True
|
||||
return False
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
DATA_FILE = Path("backend/data/user_playlists.json")
|
||||
|
||||
class PlaylistManager:
|
||||
def __init__(self):
|
||||
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not DATA_FILE.exists():
|
||||
self._save_data([])
|
||||
|
||||
def _load_data(self) -> List[Dict]:
|
||||
try:
|
||||
with open(DATA_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
|
||||
def _save_data(self, data: List[Dict]):
|
||||
with open(DATA_FILE, "w") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
|
||||
def get_all(self) -> List[Dict]:
|
||||
return self._load_data()
|
||||
|
||||
def get_by_id(self, playlist_id: str) -> Optional[Dict]:
|
||||
playlists = self._load_data()
|
||||
for p in playlists:
|
||||
if p["id"] == playlist_id:
|
||||
return p
|
||||
return None
|
||||
|
||||
def create(self, name: str, description: str = "") -> Dict:
|
||||
playlists = self._load_data()
|
||||
new_playlist = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"title": name,
|
||||
"description": description,
|
||||
"tracks": [],
|
||||
"cover_url": "https://placehold.co/400?text=Playlist", # Default placeholder
|
||||
"is_user_created": True
|
||||
}
|
||||
playlists.append(new_playlist)
|
||||
self._save_data(playlists)
|
||||
return new_playlist
|
||||
|
||||
def update(self, playlist_id: str, name: str = None, description: str = None) -> Optional[Dict]:
|
||||
playlists = self._load_data()
|
||||
for p in playlists:
|
||||
if p["id"] == playlist_id:
|
||||
if name: p["title"] = name
|
||||
if description: p["description"] = description
|
||||
self._save_data(playlists)
|
||||
return p
|
||||
return None
|
||||
|
||||
def delete(self, playlist_id: str) -> bool:
|
||||
playlists = self._load_data()
|
||||
initial_len = len(playlists)
|
||||
playlists = [p for p in playlists if p["id"] != playlist_id]
|
||||
if len(playlists) < initial_len:
|
||||
self._save_data(playlists)
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_track(self, playlist_id: str, track: Dict) -> bool:
|
||||
playlists = self._load_data()
|
||||
for p in playlists:
|
||||
if p["id"] == playlist_id:
|
||||
# Check for duplicates? For now allow.
|
||||
p["tracks"].append(track)
|
||||
# Update cover if it's the first track
|
||||
if len(p["tracks"]) == 1 and track.get("cover_url"):
|
||||
p["cover_url"] = track["cover_url"]
|
||||
self._save_data(playlists)
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_track(self, playlist_id: str, track_id: str) -> bool:
|
||||
playlists = self._load_data()
|
||||
for p in playlists:
|
||||
if p["id"] == playlist_id:
|
||||
p["tracks"] = [t for t in p["tracks"] if t.get("id") != track_id]
|
||||
self._save_data(playlists)
|
||||
return True
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
fastapi==0.115.6
|
||||
uvicorn==0.34.0
|
||||
spotdl
|
||||
pydantic==2.10.4
|
||||
python-multipart==0.0.20
|
||||
APScheduler>=3.10
|
||||
requests==2.32.3
|
||||
yt-dlp @ https://github.com/yt-dlp/yt-dlp/archive/master.zip
|
||||
ytmusicapi==1.9.1
|
||||
syncedlyrics
|
||||
fastapi==0.115.6
|
||||
uvicorn==0.34.0
|
||||
spotdl
|
||||
pydantic==2.10.4
|
||||
python-multipart==0.0.20
|
||||
APScheduler>=3.10
|
||||
requests==2.32.3
|
||||
yt-dlp @ https://github.com/yt-dlp/yt-dlp/archive/master.zip
|
||||
ytmusicapi==1.9.1
|
||||
syncedlyrics
|
||||
|
|
|
|||
|
|
@ -1,51 +1,51 @@
|
|||
import subprocess
|
||||
import logging
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def update_ytdlp():
|
||||
"""
|
||||
Check for and install the latest version of yt-dlp.
|
||||
"""
|
||||
logger.info("Scheduler: Checking for yt-dlp updates...")
|
||||
try:
|
||||
# Run pip install --upgrade yt-dlp
|
||||
result = subprocess.run(
|
||||
["pip", "install", "--upgrade", "yt-dlp"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
logger.info(f"Scheduler: yt-dlp update completed.\n{result.stdout}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Scheduler: Failed to update yt-dlp.\nError: {e.stderr}")
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler: Unexpected error during update: {str(e)}")
|
||||
|
||||
def start_scheduler():
|
||||
"""
|
||||
Initialize and start the background scheduler.
|
||||
"""
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
# Schedule yt-dlp update every 24 hours
|
||||
trigger = IntervalTrigger(days=1)
|
||||
scheduler.add_job(
|
||||
update_ytdlp,
|
||||
trigger=trigger,
|
||||
id="update_ytdlp_job",
|
||||
name="Update yt-dlp daily",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
scheduler.start()
|
||||
logger.info("Scheduler: Started background scheduler. yt-dlp will update every 24 hours.")
|
||||
|
||||
# Run once on startup to ensure we are up to date immediately
|
||||
# update_ytdlp() # Optional: Uncomment if we want strict enforcement on boot
|
||||
|
||||
return scheduler
|
||||
import subprocess
|
||||
import logging
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def update_ytdlp():
|
||||
"""
|
||||
Check for and install the latest version of yt-dlp.
|
||||
"""
|
||||
logger.info("Scheduler: Checking for yt-dlp updates...")
|
||||
try:
|
||||
# Run pip install --upgrade yt-dlp
|
||||
result = subprocess.run(
|
||||
["pip", "install", "--upgrade", "yt-dlp"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
logger.info(f"Scheduler: yt-dlp update completed.\n{result.stdout}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Scheduler: Failed to update yt-dlp.\nError: {e.stderr}")
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler: Unexpected error during update: {str(e)}")
|
||||
|
||||
def start_scheduler():
|
||||
"""
|
||||
Initialize and start the background scheduler.
|
||||
"""
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
# Schedule yt-dlp update every 24 hours
|
||||
trigger = IntervalTrigger(days=1)
|
||||
scheduler.add_job(
|
||||
update_ytdlp,
|
||||
trigger=trigger,
|
||||
id="update_ytdlp_job",
|
||||
name="Update yt-dlp daily",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
scheduler.start()
|
||||
logger.info("Scheduler: Started background scheduler. yt-dlp will update every 24 hours.")
|
||||
|
||||
# Run once on startup to ensure we are up to date immediately
|
||||
# update_ytdlp() # Optional: Uncomment if we want strict enforcement on boot
|
||||
|
||||
return scheduler
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
# Services Package
|
||||
# Services Package
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Cache Service - Re-export CacheManager from backend.cache_manager
|
||||
from backend.cache_manager import CacheManager
|
||||
|
||||
__all__ = ['CacheManager']
|
||||
# Cache Service - Re-export CacheManager from backend.cache_manager
|
||||
from backend.cache_manager import CacheManager
|
||||
|
||||
__all__ = ['CacheManager']
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
# Spotify Service - Placeholder for YouTube Music API interactions
|
||||
# Currently uses yt-dlp directly in routes.py
|
||||
|
||||
class SpotifyService:
|
||||
"""
|
||||
Placeholder service for Spotify/YouTube Music integration.
|
||||
Currently, all music operations are handled directly in routes.py using yt-dlp.
|
||||
This class exists to satisfy imports but has minimal functionality.
|
||||
"""
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def search(self, query: str, limit: int = 20):
|
||||
"""Search for music - placeholder"""
|
||||
return []
|
||||
|
||||
def get_track(self, track_id: str):
|
||||
"""Get track info - placeholder"""
|
||||
return None
|
||||
# Spotify Service - Placeholder for YouTube Music API interactions
|
||||
# Currently uses yt-dlp directly in routes.py
|
||||
|
||||
class SpotifyService:
|
||||
"""
|
||||
Placeholder service for Spotify/YouTube Music integration.
|
||||
Currently, all music operations are handled directly in routes.py using yt-dlp.
|
||||
This class exists to satisfy imports but has minimal functionality.
|
||||
"""
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def search(self, query: str, limit: int = 20):
|
||||
"""Search for music - placeholder"""
|
||||
return []
|
||||
|
||||
def get_track(self, track_id: str):
|
||||
"""Get track info - placeholder"""
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
from ytmusicapi import YTMusic
|
||||
import json
|
||||
|
||||
yt = YTMusic()
|
||||
seed_id = "hDrFd1W8fvU"
|
||||
print(f"Fetching watch playlist for {seed_id}...")
|
||||
results = yt.get_watch_playlist(videoId=seed_id, limit=5)
|
||||
|
||||
if 'tracks' in results:
|
||||
print(f"Found {len(results['tracks'])} tracks.")
|
||||
if len(results['tracks']) > 0:
|
||||
first_track = results['tracks'][0]
|
||||
print(json.dumps(first_track, indent=2))
|
||||
print("Keys:", first_track.keys())
|
||||
else:
|
||||
print("No 'tracks' key in results")
|
||||
print(results.keys())
|
||||
from ytmusicapi import YTMusic
|
||||
import json
|
||||
|
||||
yt = YTMusic()
|
||||
seed_id = "hDrFd1W8fvU"
|
||||
print(f"Fetching watch playlist for {seed_id}...")
|
||||
results = yt.get_watch_playlist(videoId=seed_id, limit=5)
|
||||
|
||||
if 'tracks' in results:
|
||||
print(f"Found {len(results['tracks'])} tracks.")
|
||||
if len(results['tracks']) > 0:
|
||||
first_track = results['tracks'][0]
|
||||
print(json.dumps(first_track, indent=2))
|
||||
print("Keys:", first_track.keys())
|
||||
else:
|
||||
print("No 'tracks' key in results")
|
||||
print(results.keys())
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
services:
|
||||
spotify-clone:
|
||||
image: vndangkhoa/spotify-clone:latest
|
||||
container_name: spotify-clone
|
||||
restart: always
|
||||
network_mode: bridge # Synology often prefers explicit bridge or host
|
||||
ports:
|
||||
- "3110:3000" # Web UI
|
||||
|
||||
volumes:
|
||||
- ./data:/app/backend/data
|
||||
services:
|
||||
spotify-clone:
|
||||
image: vndangkhoa/spotify-clone:latest
|
||||
container_name: spotify-clone
|
||||
restart: always
|
||||
network_mode: bridge # Synology often prefers explicit bridge or host
|
||||
ports:
|
||||
- "3110:3000" # Web UI
|
||||
|
||||
volumes:
|
||||
- ./data:/app/backend/data
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 13 KiB |
BIN
frontend/app/favicon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -1,62 +1,64 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Outfit } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import PlayerBar from "@/components/PlayerBar";
|
||||
import MobileNav from "@/components/MobileNav";
|
||||
import RightSidebar from "@/components/RightSidebar";
|
||||
import { PlayerProvider } from "@/context/PlayerContext";
|
||||
import { LibraryProvider } from "@/context/LibraryContext";
|
||||
|
||||
const outfit = Outfit({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-outfit",
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Audiophile Web Player",
|
||||
description: "High-Fidelity Local-First Music Player",
|
||||
manifest: "/manifest.json",
|
||||
referrer: "no-referrer",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
title: "Audiophile Web Player",
|
||||
},
|
||||
other: {
|
||||
"mobile-web-app-capable": "yes",
|
||||
},
|
||||
icons: {
|
||||
icon: "/icons/icon-192x192.png",
|
||||
apple: "/icons/icon-512x512.png",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${outfit.variable} antialiased bg-black h-screen flex flex-col overflow-hidden text-white font-sans`}
|
||||
>
|
||||
<PlayerProvider>
|
||||
<LibraryProvider>
|
||||
<div className="flex-1 flex overflow-hidden p-2 gap-2 mb-[64px] md:mb-0">
|
||||
<Sidebar />
|
||||
<main className="flex-1 bg-[#121212] rounded-lg overflow-y-auto relative no-scrollbar">
|
||||
{children}
|
||||
</main>
|
||||
<RightSidebar />
|
||||
</div>
|
||||
<PlayerBar />
|
||||
<MobileNav />
|
||||
</LibraryProvider>
|
||||
</PlayerProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
import type { Metadata } from "next";
|
||||
import { Outfit } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import PlayerBar from "@/components/PlayerBar";
|
||||
import MobileNav from "@/components/MobileNav";
|
||||
import RightSidebar from "@/components/RightSidebar";
|
||||
import { PlayerProvider } from "@/context/PlayerContext";
|
||||
import { LibraryProvider } from "@/context/LibraryContext";
|
||||
import ServiceWorkerRegistration from "@/components/ServiceWorkerRegistration";
|
||||
|
||||
const outfit = Outfit({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-outfit",
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Audiophile Web Player",
|
||||
description: "High-Fidelity Local-First Music Player",
|
||||
manifest: "/manifest.json",
|
||||
referrer: "no-referrer",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
title: "Audiophile Web Player",
|
||||
},
|
||||
other: {
|
||||
"mobile-web-app-capable": "yes",
|
||||
},
|
||||
icons: {
|
||||
icon: "/icons/icon-192x192.png",
|
||||
apple: "/icons/icon-512x512.png",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${outfit.variable} antialiased bg-black h-screen flex flex-col overflow-hidden text-white font-sans`}
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
<PlayerProvider>
|
||||
<LibraryProvider>
|
||||
<div className="flex-1 flex overflow-hidden p-2 gap-2 mb-[64px] md:mb-0">
|
||||
<Sidebar />
|
||||
<main className="flex-1 bg-[#121212] rounded-lg overflow-y-auto relative no-scrollbar">
|
||||
{children}
|
||||
</main>
|
||||
<RightSidebar />
|
||||
</div>
|
||||
<PlayerBar />
|
||||
<MobileNav />
|
||||
</LibraryProvider>
|
||||
</PlayerProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,156 +1,156 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { dbService } from "@/services/db";
|
||||
import { useLibrary } from "@/context/LibraryContext";
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
import CreatePlaylistModal from "@/components/CreatePlaylistModal";
|
||||
import CoverImage from "@/components/CoverImage";
|
||||
|
||||
export default function LibraryPage() {
|
||||
const { userPlaylists: playlists, libraryItems, refreshLibrary: refresh, activeFilter: activeTab, setActiveFilter: setActiveTab } = useLibrary();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
const handleCreatePlaylist = async (name: string) => {
|
||||
await dbService.createPlaylist(name);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const showPlaylists = activeTab === 'all' || activeTab === 'playlists';
|
||||
const showAlbums = activeTab === 'all' || activeTab === 'albums';
|
||||
const showArtists = activeTab === 'all' || activeTab === 'artists';
|
||||
|
||||
// Filter items based on type
|
||||
const albums = libraryItems.filter(item => item.type === 'Album');
|
||||
const artists = libraryItems.filter(item => item.type === 'Artist');
|
||||
const browsePlaylists = libraryItems.filter(item => item.type === 'Playlist');
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-b from-[#1e1e1e] to-black min-h-screen p-4 pb-24">
|
||||
<div className="flex justify-between items-center mb-6 pt-4">
|
||||
<h1 className="text-2xl font-bold text-white">Your Library</h1>
|
||||
<button onClick={() => setIsCreateModalOpen(true)}>
|
||||
<Plus className="text-white w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
onClick={() => setActiveTab('all')}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium border transition whitespace-nowrap ${activeTab === 'all' ? 'bg-[#2a2a2a] text-white border-white/10' : 'bg-[#121212] text-spotify-text-muted border-white/5'}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('playlists')}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium border transition whitespace-nowrap ${activeTab === 'playlists' ? 'bg-[#2a2a2a] text-white border-white/10' : 'bg-[#121212] text-spotify-text-muted border-white/5'}`}
|
||||
>
|
||||
Playlists
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('albums')}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium border transition whitespace-nowrap ${activeTab === 'albums' ? 'bg-[#2a2a2a] text-white border-white/10' : 'bg-[#121212] text-spotify-text-muted border-white/5'}`}
|
||||
>
|
||||
Albums
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('artists')}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium border transition whitespace-nowrap ${activeTab === 'artists' ? 'bg-[#2a2a2a] text-white border-white/10' : 'bg-[#121212] text-spotify-text-muted border-white/5'}`}
|
||||
>
|
||||
Artists
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 md:gap-4">
|
||||
{/* Playlists & Liked Songs */}
|
||||
{showPlaylists && (
|
||||
<>
|
||||
<Link href="/collection/tracks">
|
||||
<div className="bg-gradient-to-br from-indigo-700 to-blue-500 rounded-md p-4 aspect-square flex flex-col justify-end relative overflow-hidden shadow-lg">
|
||||
<h3 className="text-white font-bold text-lg z-10">Liked Songs</h3>
|
||||
<p className="text-white/80 text-xs z-10">Auto-generated</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{playlists.map((playlist) => (
|
||||
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
|
||||
<div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
|
||||
<div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-xs md:text-sm truncate">{playlist.title}</h3>
|
||||
<p className="text-[#a7a7a7] text-[10px] md:text-xs">Playlist • You</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{browsePlaylists.map((playlist) => (
|
||||
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
|
||||
<div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
|
||||
<div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-xs md:text-sm truncate">{playlist.title}</h3>
|
||||
<p className="text-[#a7a7a7] text-[10px] md:text-xs">Playlist • Made for you</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Artists Content (Circular Images) */}
|
||||
{showArtists && artists.map((artist) => (
|
||||
<Link href={`/artist?name=${encodeURIComponent(artist.title)}`} key={artist.id}>
|
||||
<div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col items-center text-center">
|
||||
<div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-full shadow-lg">
|
||||
<CoverImage
|
||||
src={artist.cover_url}
|
||||
alt={artist.title}
|
||||
className="w-full h-full object-cover rounded-full"
|
||||
fallbackText={artist.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-xs md:text-sm truncate w-full">{artist.title}</h3>
|
||||
<p className="text-[#a7a7a7] text-[10px] md:text-xs">Artist</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Albums Content */}
|
||||
{showAlbums && albums.map((album) => (
|
||||
<Link href={`/playlist?id=${album.id}`} key={album.id}>
|
||||
<div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
|
||||
<div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
|
||||
<CoverImage
|
||||
src={album.cover_url}
|
||||
alt={album.title}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackText={album.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-xs md:text-sm truncate">{album.title}</h3>
|
||||
<p className="text-[#a7a7a7] text-[10px] md:text-xs">Album • {album.creator || 'Spotify'}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CreatePlaylistModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreate={handleCreatePlaylist}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { dbService } from "@/services/db";
|
||||
import { useLibrary } from "@/context/LibraryContext";
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
import CreatePlaylistModal from "@/components/CreatePlaylistModal";
|
||||
import CoverImage from "@/components/CoverImage";
|
||||
|
||||
export default function LibraryPage() {
|
||||
const { userPlaylists: playlists, libraryItems, refreshLibrary: refresh, activeFilter: activeTab, setActiveFilter: setActiveTab } = useLibrary();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
const handleCreatePlaylist = async (name: string) => {
|
||||
await dbService.createPlaylist(name);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const showPlaylists = activeTab === 'all' || activeTab === 'playlists';
|
||||
const showAlbums = activeTab === 'all' || activeTab === 'albums';
|
||||
const showArtists = activeTab === 'all' || activeTab === 'artists';
|
||||
|
||||
// Filter items based on type
|
||||
const albums = libraryItems.filter(item => item.type === 'Album');
|
||||
const artists = libraryItems.filter(item => item.type === 'Artist');
|
||||
const browsePlaylists = libraryItems.filter(item => item.type === 'Playlist');
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-b from-[#1e1e1e] to-black min-h-screen p-4 pb-24">
|
||||
<div className="flex justify-between items-center mb-6 pt-4">
|
||||
<h1 className="text-2xl font-bold text-white">Your Library</h1>
|
||||
<button onClick={() => setIsCreateModalOpen(true)}>
|
||||
<Plus className="text-white w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
onClick={() => setActiveTab('all')}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium border transition whitespace-nowrap ${activeTab === 'all' ? 'bg-[#2a2a2a] text-white border-white/10' : 'bg-[#121212] text-spotify-text-muted border-white/5'}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('playlists')}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium border transition whitespace-nowrap ${activeTab === 'playlists' ? 'bg-[#2a2a2a] text-white border-white/10' : 'bg-[#121212] text-spotify-text-muted border-white/5'}`}
|
||||
>
|
||||
Playlists
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('albums')}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium border transition whitespace-nowrap ${activeTab === 'albums' ? 'bg-[#2a2a2a] text-white border-white/10' : 'bg-[#121212] text-spotify-text-muted border-white/5'}`}
|
||||
>
|
||||
Albums
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('artists')}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium border transition whitespace-nowrap ${activeTab === 'artists' ? 'bg-[#2a2a2a] text-white border-white/10' : 'bg-[#121212] text-spotify-text-muted border-white/5'}`}
|
||||
>
|
||||
Artists
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 md:gap-4">
|
||||
{/* Playlists & Liked Songs */}
|
||||
{showPlaylists && (
|
||||
<>
|
||||
<Link href="/collection/tracks">
|
||||
<div className="bg-gradient-to-br from-indigo-700 to-blue-500 rounded-md p-4 aspect-square flex flex-col justify-end relative overflow-hidden shadow-lg">
|
||||
<h3 className="text-white font-bold text-lg z-10">Liked Songs</h3>
|
||||
<p className="text-white/80 text-xs z-10">Auto-generated</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{playlists.map((playlist) => (
|
||||
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
|
||||
<div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
|
||||
<div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-xs md:text-sm truncate">{playlist.title}</h3>
|
||||
<p className="text-[#a7a7a7] text-[10px] md:text-xs">Playlist • You</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{browsePlaylists.map((playlist) => (
|
||||
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
|
||||
<div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
|
||||
<div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-xs md:text-sm truncate">{playlist.title}</h3>
|
||||
<p className="text-[#a7a7a7] text-[10px] md:text-xs">Playlist • Made for you</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Artists Content (Circular Images) */}
|
||||
{showArtists && artists.map((artist) => (
|
||||
<Link href={`/artist?name=${encodeURIComponent(artist.title)}`} key={artist.id}>
|
||||
<div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col items-center text-center">
|
||||
<div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-full shadow-lg">
|
||||
<CoverImage
|
||||
src={artist.cover_url}
|
||||
alt={artist.title}
|
||||
className="w-full h-full object-cover rounded-full"
|
||||
fallbackText={artist.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-xs md:text-sm truncate w-full">{artist.title}</h3>
|
||||
<p className="text-[#a7a7a7] text-[10px] md:text-xs">Artist</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Albums Content */}
|
||||
{showAlbums && albums.map((album) => (
|
||||
<Link href={`/playlist?id=${album.id}`} key={album.id}>
|
||||
<div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
|
||||
<div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
|
||||
<CoverImage
|
||||
src={album.cover_url}
|
||||
alt={album.title}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackText={album.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-xs md:text-sm truncate">{album.title}</h3>
|
||||
<p className="text-[#a7a7a7] text-[10px] md:text-xs">Album • {album.creator || 'Spotify'}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CreatePlaylistModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreate={handleCreatePlaylist}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,102 +1,102 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
|
||||
interface AddToPlaylistModalProps {
|
||||
track: any;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AddToPlaylistModal({ track, isOpen, onClose }: AddToPlaylistModalProps) {
|
||||
const [playlists, setPlaylists] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
fetch(`${apiUrl}/api/playlists`)
|
||||
.then(res => res.json())
|
||||
.then(data => setPlaylists(data))
|
||||
.catch(err => console.error(err));
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleAddToPlaylist = async (playlistId: string) => {
|
||||
try {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
await fetch(`${apiUrl}/api/playlists/${playlistId}/tracks`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(track)
|
||||
});
|
||||
alert(`Added to playlist!`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to add track:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4">
|
||||
<div className="bg-[#282828] w-full max-w-md rounded-lg shadow-2xl overflow-hidden">
|
||||
<div className="p-4 border-b border-[#3e3e3e] flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-white">Add to Playlist</h2>
|
||||
<button onClick={onClose} className="text-white hover:text-gray-300">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-2 max-h-[60vh] overflow-y-auto no-scrollbar">
|
||||
{playlists.length === 0 ? (
|
||||
<div className="p-4 text-center text-spotify-text-muted">No playlists found. Create one first!</div>
|
||||
) : (
|
||||
playlists.map((playlist) => (
|
||||
<div
|
||||
key={playlist.id}
|
||||
onClick={() => handleAddToPlaylist(playlist.id)}
|
||||
className="flex items-center gap-3 p-3 hover:bg-[#3e3e3e] rounded-md cursor-pointer transition text-white"
|
||||
>
|
||||
<div className="w-10 h-10 bg-[#121212] flex items-center justify-center rounded overflow-hidden">
|
||||
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
|
||||
<img src={playlist.cover_url} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg">🎵</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium truncate">{playlist.title}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#3e3e3e]">
|
||||
<button
|
||||
onClick={() => {
|
||||
const name = prompt("New Playlist Name");
|
||||
if (name) {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
fetch(`${apiUrl}/api/playlists`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name })
|
||||
}).then(() => {
|
||||
// Refresh list
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
fetch(`${apiUrl}/api/playlists`)
|
||||
.then(res => res.json())
|
||||
.then(data => setPlaylists(data));
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="w-full py-2 bg-white text-black font-bold rounded-full hover:scale-105 transition flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" /> New Playlist
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
|
||||
interface AddToPlaylistModalProps {
|
||||
track: any;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AddToPlaylistModal({ track, isOpen, onClose }: AddToPlaylistModalProps) {
|
||||
const [playlists, setPlaylists] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
fetch(`${apiUrl}/api/playlists`)
|
||||
.then(res => res.json())
|
||||
.then(data => setPlaylists(data))
|
||||
.catch(err => console.error(err));
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleAddToPlaylist = async (playlistId: string) => {
|
||||
try {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
await fetch(`${apiUrl}/api/playlists/${playlistId}/tracks`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(track)
|
||||
});
|
||||
alert(`Added to playlist!`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to add track:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4">
|
||||
<div className="bg-[#282828] w-full max-w-md rounded-lg shadow-2xl overflow-hidden">
|
||||
<div className="p-4 border-b border-[#3e3e3e] flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-white">Add to Playlist</h2>
|
||||
<button onClick={onClose} className="text-white hover:text-gray-300">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-2 max-h-[60vh] overflow-y-auto no-scrollbar">
|
||||
{playlists.length === 0 ? (
|
||||
<div className="p-4 text-center text-spotify-text-muted">No playlists found. Create one first!</div>
|
||||
) : (
|
||||
playlists.map((playlist) => (
|
||||
<div
|
||||
key={playlist.id}
|
||||
onClick={() => handleAddToPlaylist(playlist.id)}
|
||||
className="flex items-center gap-3 p-3 hover:bg-[#3e3e3e] rounded-md cursor-pointer transition text-white"
|
||||
>
|
||||
<div className="w-10 h-10 bg-[#121212] flex items-center justify-center rounded overflow-hidden">
|
||||
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
|
||||
<img src={playlist.cover_url} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg">🎵</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium truncate">{playlist.title}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#3e3e3e]">
|
||||
<button
|
||||
onClick={() => {
|
||||
const name = prompt("New Playlist Name");
|
||||
if (name) {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
fetch(`${apiUrl}/api/playlists`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name })
|
||||
}).then(() => {
|
||||
// Refresh list
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
fetch(`${apiUrl}/api/playlists`)
|
||||
.then(res => res.json())
|
||||
.then(data => setPlaylists(data));
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="w-full py-2 bg-white text-black font-bold rounded-full hover:scale-105 transition flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" /> New Playlist
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,150 +1,150 @@
|
|||
import React, { useEffect, useState, useRef } from 'react';
|
||||
|
||||
interface Metric {
|
||||
time: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface LyricsDetailProps {
|
||||
track: any;
|
||||
currentTime: number;
|
||||
onClose: () => void;
|
||||
onSeek?: (time: number) => void;
|
||||
isInSidebar?: boolean;
|
||||
}
|
||||
|
||||
const LyricsDetail: React.FC<LyricsDetailProps> = ({ track, currentTime, onClose, onSeek, isInSidebar = false }) => {
|
||||
const [lyrics, setLyrics] = useState<Metric[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const activeLineRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch Lyrics on Track Change
|
||||
useEffect(() => {
|
||||
const fetchLyrics = async () => {
|
||||
if (!track) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Pass title and artist for LRCLIB fallback
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const url = `${apiUrl}/api/lyrics?id=${track.id}&title=${encodeURIComponent(track.title)}&artist=${encodeURIComponent(track.artist)}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
setLyrics(data || []);
|
||||
} catch (error) {
|
||||
console.error("Error fetching lyrics:", error);
|
||||
setLyrics([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLyrics();
|
||||
}, [track?.id]);
|
||||
|
||||
// Find active line index
|
||||
const activeIndex = lyrics.findIndex((line, index) => {
|
||||
const nextLine = lyrics[index + 1];
|
||||
// Removing large offset to match music exactly.
|
||||
// using small buffer (0.05) just for rounding safety
|
||||
const timeWithOffset = currentTime + 0.05;
|
||||
return timeWithOffset >= line.time && (!nextLine || timeWithOffset < nextLine.time);
|
||||
});
|
||||
|
||||
// Auto-scroll to active line
|
||||
// Auto-scroll to active line
|
||||
useEffect(() => {
|
||||
if (activeLineRef.current && scrollContainerRef.current) {
|
||||
const container = scrollContainerRef.current;
|
||||
const activeLine = activeLineRef.current;
|
||||
|
||||
// Calculate position to center (or offset) the active line
|
||||
// Reverted to center (50%) as requested
|
||||
const containerHeight = container.clientHeight;
|
||||
const lineTop = activeLine.offsetTop;
|
||||
const lineHeight = activeLine.offsetHeight;
|
||||
|
||||
// Target scroll position:
|
||||
// Line Top - (Screen Height * 0.50) + (Half Line Height)
|
||||
const targetScrollTop = lineTop - (containerHeight * 0.50) + (lineHeight / 2);
|
||||
|
||||
container.scrollTo({
|
||||
top: targetScrollTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
if (!track) return null;
|
||||
|
||||
return (
|
||||
<div className={`${isInSidebar ? 'relative h-full' : 'absolute inset-0'} flex flex-col bg-transparent text-white`}>
|
||||
{/* Header - only show when NOT in sidebar */}
|
||||
{!isInSidebar && (
|
||||
<div className="flex items-center justify-between p-6 bg-gradient-to-b from-black/80 to-transparent z-10">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-bold truncate">Lyrics</h2>
|
||||
<p className="text-white/60 text-xs truncate uppercase tracking-widest">
|
||||
{track.artist}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 bg-white/10 rounded-full hover:bg-white/20 transition backdrop-blur-md"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lyrics Container */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto px-6 text-center space-y-6 no-scrollbar mask-linear-gradient"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-green-500"></div>
|
||||
</div>
|
||||
) : lyrics.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-white/50 text-base">
|
||||
<p className="font-bold mb-2 text-xl">Looks like we don't have lyrics for this song.</p>
|
||||
<p>Enjoy the vibe!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-[50vh] pb-[50vh] max-w-4xl mx-auto"> {/* Reverted to center padding, added max-width */}
|
||||
{lyrics.map((line, index) => {
|
||||
const isActive = index === activeIndex;
|
||||
const isPast = index < activeIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
ref={isActive ? activeLineRef : null}
|
||||
className={`
|
||||
transition-all duration-500 ease-out origin-center
|
||||
${isActive
|
||||
? "text-3xl md:text-4xl font-bold text-white scale-100 opacity-100 drop-shadow-lg py-4"
|
||||
: "text-xl md:text-2xl font-medium text-white/30 hover:text-white/60 blur-[0px] scale-95 py-2"
|
||||
}
|
||||
cursor-pointer
|
||||
`}
|
||||
onClick={() => {
|
||||
if (onSeek) onSeek(line.time);
|
||||
}}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LyricsDetail;
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
|
||||
interface Metric {
|
||||
time: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface LyricsDetailProps {
|
||||
track: any;
|
||||
currentTime: number;
|
||||
onClose: () => void;
|
||||
onSeek?: (time: number) => void;
|
||||
isInSidebar?: boolean;
|
||||
}
|
||||
|
||||
const LyricsDetail: React.FC<LyricsDetailProps> = ({ track, currentTime, onClose, onSeek, isInSidebar = false }) => {
|
||||
const [lyrics, setLyrics] = useState<Metric[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const activeLineRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch Lyrics on Track Change
|
||||
useEffect(() => {
|
||||
const fetchLyrics = async () => {
|
||||
if (!track) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Pass title and artist for LRCLIB fallback
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const url = `${apiUrl}/api/lyrics?id=${track.id}&title=${encodeURIComponent(track.title)}&artist=${encodeURIComponent(track.artist)}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
setLyrics(data || []);
|
||||
} catch (error) {
|
||||
console.error("Error fetching lyrics:", error);
|
||||
setLyrics([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLyrics();
|
||||
}, [track?.id]);
|
||||
|
||||
// Find active line index
|
||||
const activeIndex = lyrics.findIndex((line, index) => {
|
||||
const nextLine = lyrics[index + 1];
|
||||
// Removing large offset to match music exactly.
|
||||
// using small buffer (0.05) just for rounding safety
|
||||
const timeWithOffset = currentTime + 0.05;
|
||||
return timeWithOffset >= line.time && (!nextLine || timeWithOffset < nextLine.time);
|
||||
});
|
||||
|
||||
// Auto-scroll to active line
|
||||
// Auto-scroll to active line
|
||||
useEffect(() => {
|
||||
if (activeLineRef.current && scrollContainerRef.current) {
|
||||
const container = scrollContainerRef.current;
|
||||
const activeLine = activeLineRef.current;
|
||||
|
||||
// Calculate position to center (or offset) the active line
|
||||
// Reverted to center (50%) as requested
|
||||
const containerHeight = container.clientHeight;
|
||||
const lineTop = activeLine.offsetTop;
|
||||
const lineHeight = activeLine.offsetHeight;
|
||||
|
||||
// Target scroll position:
|
||||
// Line Top - (Screen Height * 0.50) + (Half Line Height)
|
||||
const targetScrollTop = lineTop - (containerHeight * 0.50) + (lineHeight / 2);
|
||||
|
||||
container.scrollTo({
|
||||
top: targetScrollTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
if (!track) return null;
|
||||
|
||||
return (
|
||||
<div className={`${isInSidebar ? 'relative h-full' : 'absolute inset-0'} flex flex-col bg-transparent text-white`}>
|
||||
{/* Header - only show when NOT in sidebar */}
|
||||
{!isInSidebar && (
|
||||
<div className="flex items-center justify-between p-6 bg-gradient-to-b from-black/80 to-transparent z-10">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-bold truncate">Lyrics</h2>
|
||||
<p className="text-white/60 text-xs truncate uppercase tracking-widest">
|
||||
{track.artist}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 bg-white/10 rounded-full hover:bg-white/20 transition backdrop-blur-md"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lyrics Container */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto px-6 text-center space-y-6 no-scrollbar mask-linear-gradient"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-green-500"></div>
|
||||
</div>
|
||||
) : lyrics.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-white/50 text-base">
|
||||
<p className="font-bold mb-2 text-xl">Looks like we don't have lyrics for this song.</p>
|
||||
<p>Enjoy the vibe!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-[50vh] pb-[50vh] max-w-4xl mx-auto"> {/* Reverted to center padding, added max-width */}
|
||||
{lyrics.map((line, index) => {
|
||||
const isActive = index === activeIndex;
|
||||
const isPast = index < activeIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
ref={isActive ? activeLineRef : null}
|
||||
className={`
|
||||
transition-all duration-500 ease-out origin-center
|
||||
${isActive
|
||||
? "text-3xl md:text-4xl font-bold text-white scale-100 opacity-100 drop-shadow-lg py-4"
|
||||
: "text-xl md:text-2xl font-medium text-white/30 hover:text-white/60 blur-[0px] scale-95 py-2"
|
||||
}
|
||||
cursor-pointer
|
||||
`}
|
||||
onClick={() => {
|
||||
if (onSeek) onSeek(line.time);
|
||||
}}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LyricsDetail;
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import { Home, Search, Library } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function MobileNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActive = (path: string) => pathname === path;
|
||||
|
||||
return (
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-black/95 backdrop-blur-md border-t border-white/10 h-[64px] flex items-center justify-around z-50 pb-safe">
|
||||
<Link href="/" className={`flex flex-col items-center gap-1 ${isActive('/') ? 'text-white' : 'text-neutral-400'}`}>
|
||||
<Home size={24} fill={isActive('/') ? "currentColor" : "none"} />
|
||||
<span className="text-[10px]">Home</span>
|
||||
</Link>
|
||||
|
||||
<Link href="/search" className={`flex flex-col items-center gap-1 ${isActive('/search') ? 'text-white' : 'text-neutral-400'}`}>
|
||||
<Search size={24} />
|
||||
<span className="text-[10px]">Search</span>
|
||||
</Link>
|
||||
|
||||
<Link href="/library" className={`flex flex-col items-center gap-1 ${isActive('/library') ? 'text-white' : 'text-neutral-400'}`}>
|
||||
<Library size={24} />
|
||||
<span className="text-[10px]">Library</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { Home, Search, Library } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function MobileNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActive = (path: string) => pathname === path;
|
||||
|
||||
return (
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-black/95 backdrop-blur-md border-t border-white/10 h-[64px] flex items-center justify-around z-50 pb-safe">
|
||||
<Link href="/" className={`flex flex-col items-center gap-1 ${isActive('/') ? 'text-white' : 'text-neutral-400'}`}>
|
||||
<Home size={24} fill={isActive('/') ? "currentColor" : "none"} />
|
||||
<span className="text-[10px]">Home</span>
|
||||
</Link>
|
||||
|
||||
<Link href="/search" className={`flex flex-col items-center gap-1 ${isActive('/search') ? 'text-white' : 'text-neutral-400'}`}>
|
||||
<Search size={24} />
|
||||
<span className="text-[10px]">Search</span>
|
||||
</Link>
|
||||
|
||||
<Link href="/library" className={`flex flex-col items-center gap-1 ${isActive('/library') ? 'text-white' : 'text-neutral-400'}`}>
|
||||
<Library size={24} />
|
||||
<span className="text-[10px]">Library</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import LyricsDetail from './LyricsDetail';
|
|||
export default function PlayerBar() {
|
||||
const { currentTrack, isPlaying, isBuffering, togglePlay, setBuffering, likedTracks, toggleLike, nextTrack, prevTrack, shuffle, toggleShuffle, repeatMode, toggleRepeat, audioQuality, isLyricsOpen, toggleLyrics } = usePlayer();
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
|
|
@ -22,6 +23,70 @@ export default function PlayerBar() {
|
|||
const [isFullScreenPlayerOpen, setIsFullScreenPlayerOpen] = useState(false);
|
||||
const [isCoverModalOpen, setIsCoverModalOpen] = useState(false);
|
||||
|
||||
// Wake Lock API - Keeps device awake during playback (for FiiO/Android)
|
||||
useEffect(() => {
|
||||
const requestWakeLock = async () => {
|
||||
if ('wakeLock' in navigator && isPlaying) {
|
||||
try {
|
||||
wakeLockRef.current = await navigator.wakeLock.request('screen');
|
||||
console.log('Wake Lock acquired for background playback');
|
||||
|
||||
wakeLockRef.current.addEventListener('release', () => {
|
||||
console.log('Wake Lock released');
|
||||
});
|
||||
} catch (err) {
|
||||
console.log('Wake Lock not available:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const releaseWakeLock = async () => {
|
||||
if (wakeLockRef.current) {
|
||||
await wakeLockRef.current.release();
|
||||
wakeLockRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (isPlaying) {
|
||||
requestWakeLock();
|
||||
} else {
|
||||
releaseWakeLock();
|
||||
}
|
||||
|
||||
// Re-acquire wake lock when page becomes visible again
|
||||
const handleVisibilityChange = async () => {
|
||||
if (document.visibilityState === 'visible' && isPlaying) {
|
||||
await requestWakeLock();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
releaseWakeLock();
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [isPlaying]);
|
||||
|
||||
// Prevent audio pause on visibility change (screen off) - Critical for FiiO
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
// When screen turns off, Android might pause audio
|
||||
// We explicitly resume if we should be playing
|
||||
if (document.visibilityState === 'hidden' && isPlaying && audioRef.current) {
|
||||
// Use setTimeout to ensure audio continues after visibility change
|
||||
setTimeout(() => {
|
||||
if (audioRef.current && audioRef.current.paused && isPlaying) {
|
||||
audioRef.current.play().catch(e => console.log('Resume on hidden:', e));
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTrack && audioRef.current && currentTrack.url) {
|
||||
// Prevent reloading if URL hasn't changed
|
||||
|
|
|
|||
20
frontend/components/ServiceWorkerRegistration.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function ServiceWorkerRegistration() {
|
||||
useEffect(() => {
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js")
|
||||
.then((registration) => {
|
||||
console.log("Service Worker registered:", registration.scope);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Service Worker registration failed:", error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,232 +1,232 @@
|
|||
"use client";
|
||||
|
||||
import { Home, Search, Library, Plus, Heart, RefreshCcw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePlayer } from "@/context/PlayerContext";
|
||||
import { useState } from "react";
|
||||
import CreatePlaylistModal from "./CreatePlaylistModal";
|
||||
import { dbService } from "@/services/db";
|
||||
import { useLibrary } from "@/context/LibraryContext";
|
||||
import Logo from "./Logo";
|
||||
import CoverImage from "./CoverImage";
|
||||
|
||||
export default function Sidebar() {
|
||||
const { likedTracks } = usePlayer();
|
||||
const { userPlaylists, libraryItems, refreshLibrary: refresh, activeFilter, setActiveFilter } = useLibrary();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updateStatus, setUpdateStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
|
||||
const handleCreatePlaylist = async (name: string) => {
|
||||
await dbService.createPlaylist(name);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleDeletePlaylist = async (e: React.MouseEvent, id: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (confirm("Delete this playlist?")) {
|
||||
await dbService.deletePlaylist(id);
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateYtdlp = async () => {
|
||||
if (isUpdating) return;
|
||||
setIsUpdating(true);
|
||||
setUpdateStatus('loading');
|
||||
try {
|
||||
const response = await fetch('/api/system/update-ytdlp', { method: 'POST' });
|
||||
if (response.ok) {
|
||||
setUpdateStatus('success');
|
||||
setTimeout(() => setUpdateStatus('idle'), 5000);
|
||||
} else {
|
||||
setUpdateStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update yt-dlp:", error);
|
||||
setUpdateStatus('error');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtering Logic
|
||||
const showPlaylists = activeFilter === 'all' || activeFilter === 'playlists';
|
||||
const showArtists = activeFilter === 'all' || activeFilter === 'artists';
|
||||
const showAlbums = activeFilter === 'all' || activeFilter === 'albums';
|
||||
|
||||
const artists = libraryItems.filter(i => i.type === 'Artist');
|
||||
const albums = libraryItems.filter(i => i.type === 'Album');
|
||||
const browsePlaylists = libraryItems.filter(i => i.type === 'Playlist');
|
||||
|
||||
return (
|
||||
<aside className="hidden md:flex flex-col w-[280px] bg-black h-full gap-2 p-2">
|
||||
<div className="bg-[#121212] rounded-lg p-4 flex flex-col gap-4">
|
||||
{/* Logo replaces Home link */}
|
||||
<Link href="/" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer">
|
||||
<Logo />
|
||||
</Link>
|
||||
<Link href="/search" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer">
|
||||
<Search className="w-6 h-6" />
|
||||
<span className="font-bold">Search</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#121212] rounded-lg flex-1 flex flex-col overflow-hidden">
|
||||
<div className="p-4 shadow-md z-10">
|
||||
<div className="flex items-center justify-between text-spotify-text-muted mb-4">
|
||||
<Link href="/library" className="flex items-center gap-2 hover:text-white transition cursor-pointer">
|
||||
<Library className="w-6 h-6" />
|
||||
<span className="font-bold">Your Library</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setIsCreateModalOpen(true)} className="hover:text-white hover:scale-110 transition">
|
||||
<Plus className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2 mt-4 overflow-x-auto no-scrollbar">
|
||||
{['Playlists', 'Artists', 'Albums'].map((filter) => {
|
||||
const key = filter.toLowerCase() as any;
|
||||
const isActive = activeFilter === key;
|
||||
return (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setActiveFilter(isActive ? 'all' : key)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition whitespace-nowrap ${isActive ? 'bg-white text-black' : 'bg-[#2a2a2a] text-white hover:bg-[#3a3a3a]'}`}
|
||||
>
|
||||
{filter}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 no-scrollbar">
|
||||
{/* Liked Songs (Always top if 'Playlists' or 'All') */}
|
||||
{showPlaylists && (
|
||||
<Link href="/collection/tracks">
|
||||
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer group mb-2">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-indigo-700 to-blue-300 rounded flex items-center justify-center">
|
||||
<Heart className="w-6 h-6 text-white fill-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-medium">Liked Songs</h3>
|
||||
<p className="text-sm text-spotify-text-muted">Playlist • {likedTracks.size} songs</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* User Playlists */}
|
||||
{showPlaylists && userPlaylists.map((playlist) => (
|
||||
<div key={playlist.id} className="group relative flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<Link href={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title || ''}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{playlist.title}</h3>
|
||||
<p className="text-sm text-spotify-text-muted truncate">Playlist • You</p>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={(e) => handleDeletePlaylist(e, playlist.id)}
|
||||
className="absolute right-2 text-zinc-400 hover:text-white opacity-0 group-hover:opacity-100 transition"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Fake/Browse Playlists */}
|
||||
{showPlaylists && browsePlaylists.map((playlist) => (
|
||||
<div key={playlist.id} className="group relative flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<Link href={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title || ''}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{playlist.title}</h3>
|
||||
<p className="text-sm text-spotify-text-muted truncate">Playlist • Made for you</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Artists */}
|
||||
{showArtists && artists.map((artist) => (
|
||||
<Link href={`/artist?name=${encodeURIComponent(artist.title)}`} key={artist.id}>
|
||||
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<CoverImage
|
||||
src={artist.cover_url}
|
||||
alt={artist.title}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
fallbackText={artist.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{artist.title}</h3>
|
||||
<p className="text-sm text-spotify-text-muted truncate">Artist</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Albums */}
|
||||
{showAlbums && albums.map((album) => (
|
||||
<Link href={`/search?q=${encodeURIComponent(album.title)}`} key={album.id}>
|
||||
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<CoverImage
|
||||
src={album.cover_url}
|
||||
alt={album.title}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
fallbackText="💿"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{album.title}</h3>
|
||||
<p className="text-sm text-spotify-text-muted truncate">Album • {album.creator || 'Spotify'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Section */}
|
||||
<div className="bg-[#121212] rounded-lg p-2 mt-auto">
|
||||
<button
|
||||
onClick={handleUpdateYtdlp}
|
||||
disabled={isUpdating}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-md transition-all duration-300 ${updateStatus === 'success' ? 'bg-green-600/20 text-green-400' :
|
||||
updateStatus === 'error' ? 'bg-red-600/20 text-red-400' :
|
||||
'text-spotify-text-muted hover:text-white hover:bg-[#1a1a1a]'
|
||||
}`}
|
||||
title="Update Core (yt-dlp) to fix playback errors"
|
||||
>
|
||||
<RefreshCcw className={`w-5 h-5 ${isUpdating ? 'animate-spin' : ''}`} />
|
||||
<span className="text-sm font-bold">
|
||||
{updateStatus === 'loading' ? 'Updating...' :
|
||||
updateStatus === 'success' ? 'Core Updated!' :
|
||||
updateStatus === 'error' ? 'Update Failed' : 'Update Core'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CreatePlaylistModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreate={handleCreatePlaylist}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
"use client";
|
||||
|
||||
import { Home, Search, Library, Plus, Heart, RefreshCcw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePlayer } from "@/context/PlayerContext";
|
||||
import { useState } from "react";
|
||||
import CreatePlaylistModal from "./CreatePlaylistModal";
|
||||
import { dbService } from "@/services/db";
|
||||
import { useLibrary } from "@/context/LibraryContext";
|
||||
import Logo from "./Logo";
|
||||
import CoverImage from "./CoverImage";
|
||||
|
||||
export default function Sidebar() {
|
||||
const { likedTracks } = usePlayer();
|
||||
const { userPlaylists, libraryItems, refreshLibrary: refresh, activeFilter, setActiveFilter } = useLibrary();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updateStatus, setUpdateStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
|
||||
const handleCreatePlaylist = async (name: string) => {
|
||||
await dbService.createPlaylist(name);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleDeletePlaylist = async (e: React.MouseEvent, id: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (confirm("Delete this playlist?")) {
|
||||
await dbService.deletePlaylist(id);
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateYtdlp = async () => {
|
||||
if (isUpdating) return;
|
||||
setIsUpdating(true);
|
||||
setUpdateStatus('loading');
|
||||
try {
|
||||
const response = await fetch('/api/system/update-ytdlp', { method: 'POST' });
|
||||
if (response.ok) {
|
||||
setUpdateStatus('success');
|
||||
setTimeout(() => setUpdateStatus('idle'), 5000);
|
||||
} else {
|
||||
setUpdateStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update yt-dlp:", error);
|
||||
setUpdateStatus('error');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtering Logic
|
||||
const showPlaylists = activeFilter === 'all' || activeFilter === 'playlists';
|
||||
const showArtists = activeFilter === 'all' || activeFilter === 'artists';
|
||||
const showAlbums = activeFilter === 'all' || activeFilter === 'albums';
|
||||
|
||||
const artists = libraryItems.filter(i => i.type === 'Artist');
|
||||
const albums = libraryItems.filter(i => i.type === 'Album');
|
||||
const browsePlaylists = libraryItems.filter(i => i.type === 'Playlist');
|
||||
|
||||
return (
|
||||
<aside className="hidden md:flex flex-col w-[280px] bg-black h-full gap-2 p-2">
|
||||
<div className="bg-[#121212] rounded-lg p-4 flex flex-col gap-4">
|
||||
{/* Logo replaces Home link */}
|
||||
<Link href="/" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer">
|
||||
<Logo />
|
||||
</Link>
|
||||
<Link href="/search" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer">
|
||||
<Search className="w-6 h-6" />
|
||||
<span className="font-bold">Search</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#121212] rounded-lg flex-1 flex flex-col overflow-hidden">
|
||||
<div className="p-4 shadow-md z-10">
|
||||
<div className="flex items-center justify-between text-spotify-text-muted mb-4">
|
||||
<Link href="/library" className="flex items-center gap-2 hover:text-white transition cursor-pointer">
|
||||
<Library className="w-6 h-6" />
|
||||
<span className="font-bold">Your Library</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setIsCreateModalOpen(true)} className="hover:text-white hover:scale-110 transition">
|
||||
<Plus className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2 mt-4 overflow-x-auto no-scrollbar">
|
||||
{['Playlists', 'Artists', 'Albums'].map((filter) => {
|
||||
const key = filter.toLowerCase() as any;
|
||||
const isActive = activeFilter === key;
|
||||
return (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setActiveFilter(isActive ? 'all' : key)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition whitespace-nowrap ${isActive ? 'bg-white text-black' : 'bg-[#2a2a2a] text-white hover:bg-[#3a3a3a]'}`}
|
||||
>
|
||||
{filter}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 no-scrollbar">
|
||||
{/* Liked Songs (Always top if 'Playlists' or 'All') */}
|
||||
{showPlaylists && (
|
||||
<Link href="/collection/tracks">
|
||||
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer group mb-2">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-indigo-700 to-blue-300 rounded flex items-center justify-center">
|
||||
<Heart className="w-6 h-6 text-white fill-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-medium">Liked Songs</h3>
|
||||
<p className="text-sm text-spotify-text-muted">Playlist • {likedTracks.size} songs</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* User Playlists */}
|
||||
{showPlaylists && userPlaylists.map((playlist) => (
|
||||
<div key={playlist.id} className="group relative flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<Link href={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title || ''}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{playlist.title}</h3>
|
||||
<p className="text-sm text-spotify-text-muted truncate">Playlist • You</p>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={(e) => handleDeletePlaylist(e, playlist.id)}
|
||||
className="absolute right-2 text-zinc-400 hover:text-white opacity-0 group-hover:opacity-100 transition"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Fake/Browse Playlists */}
|
||||
{showPlaylists && browsePlaylists.map((playlist) => (
|
||||
<div key={playlist.id} className="group relative flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<Link href={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title || ''}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{playlist.title}</h3>
|
||||
<p className="text-sm text-spotify-text-muted truncate">Playlist • Made for you</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Artists */}
|
||||
{showArtists && artists.map((artist) => (
|
||||
<Link href={`/artist?name=${encodeURIComponent(artist.title)}`} key={artist.id}>
|
||||
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<CoverImage
|
||||
src={artist.cover_url}
|
||||
alt={artist.title}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
fallbackText={artist.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{artist.title}</h3>
|
||||
<p className="text-sm text-spotify-text-muted truncate">Artist</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Albums */}
|
||||
{showAlbums && albums.map((album) => (
|
||||
<Link href={`/search?q=${encodeURIComponent(album.title)}`} key={album.id}>
|
||||
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<CoverImage
|
||||
src={album.cover_url}
|
||||
alt={album.title}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
fallbackText="💿"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{album.title}</h3>
|
||||
<p className="text-sm text-spotify-text-muted truncate">Album • {album.creator || 'Spotify'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Section */}
|
||||
<div className="bg-[#121212] rounded-lg p-2 mt-auto">
|
||||
<button
|
||||
onClick={handleUpdateYtdlp}
|
||||
disabled={isUpdating}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-md transition-all duration-300 ${updateStatus === 'success' ? 'bg-green-600/20 text-green-400' :
|
||||
updateStatus === 'error' ? 'bg-red-600/20 text-red-400' :
|
||||
'text-spotify-text-muted hover:text-white hover:bg-[#1a1a1a]'
|
||||
}`}
|
||||
title="Update Core (yt-dlp) to fix playback errors"
|
||||
>
|
||||
<RefreshCcw className={`w-5 h-5 ${isUpdating ? 'animate-spin' : ''}`} />
|
||||
<span className="text-sm font-bold">
|
||||
{updateStatus === 'loading' ? 'Updating...' :
|
||||
updateStatus === 'success' ? 'Core Updated!' :
|
||||
updateStatus === 'error' ? 'Update Failed' : 'Update Core'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CreatePlaylistModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreate={handleCreatePlaylist}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,151 +1,151 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
import { dbService, Playlist } from "@/services/db";
|
||||
import { libraryService } from "@/services/library";
|
||||
|
||||
type FilterType = 'all' | 'playlists' | 'artists' | 'albums';
|
||||
|
||||
interface LibraryContextType {
|
||||
userPlaylists: Playlist[];
|
||||
libraryItems: any[];
|
||||
activeFilter: FilterType;
|
||||
setActiveFilter: (filter: FilterType) => void;
|
||||
refreshLibrary: () => Promise<void>;
|
||||
}
|
||||
|
||||
const LibraryContext = createContext<LibraryContextType | undefined>(undefined);
|
||||
|
||||
export function LibraryProvider({ children }: { children: React.ReactNode }) {
|
||||
const [userPlaylists, setUserPlaylists] = useState<Playlist[]>([]);
|
||||
const [libraryItems, setLibraryItems] = useState<any[]>([]);
|
||||
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
|
||||
|
||||
const fetchAllData = async () => {
|
||||
try {
|
||||
// 1. User Playlists
|
||||
const playlists = await dbService.getPlaylists();
|
||||
setUserPlaylists(playlists);
|
||||
|
||||
// 2. Local/Backend Content
|
||||
const browse = await libraryService.getBrowseContent();
|
||||
// Deduplicate by ID to avoid React duplicate key warnings
|
||||
const browsePlaylistsRaw = Object.values(browse).flat();
|
||||
const seenIds = new Map();
|
||||
const browsePlaylists = browsePlaylistsRaw.filter((p: any) => {
|
||||
if (seenIds.has(p.id)) return false;
|
||||
seenIds.set(p.id, true);
|
||||
return true;
|
||||
});
|
||||
|
||||
const artistsMap = new Map();
|
||||
const albumsMap = new Map();
|
||||
const allTracks: any[] = [];
|
||||
|
||||
// 3. Extract metadata
|
||||
browsePlaylists.forEach((p: any) => {
|
||||
if (p.tracks) {
|
||||
p.tracks.forEach((t: any) => {
|
||||
allTracks.push(t);
|
||||
// Fake Artist
|
||||
if (artistsMap.size < 40 && t.artist && t.artist !== 'Unknown Artist' && t.artist !== 'Unknown') {
|
||||
if (!artistsMap.has(t.artist)) {
|
||||
artistsMap.set(t.artist, {
|
||||
id: `artist-${t.artist}`,
|
||||
title: t.artist,
|
||||
type: 'Artist',
|
||||
cover_url: t.cover_url
|
||||
});
|
||||
}
|
||||
}
|
||||
// Fake Album
|
||||
if (albumsMap.size < 40 && t.album && t.album !== 'Single' && t.album !== 'Unknown Album') {
|
||||
if (!albumsMap.has(t.album)) {
|
||||
albumsMap.set(t.album, {
|
||||
id: `album-${t.album}`,
|
||||
title: t.album,
|
||||
type: 'Album',
|
||||
creator: t.artist,
|
||||
cover_url: t.cover_url
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Generate Fake Extra Playlists (Creative Names)
|
||||
const fakePlaylists = [...browsePlaylists];
|
||||
const targetCount = 40;
|
||||
const needed = targetCount - fakePlaylists.length;
|
||||
|
||||
const creativeNames = [
|
||||
"Chill Vibes", "Late Night Focus", "Workout Energy", "Road Trip Classics",
|
||||
"Indie Mix", "Pop Hits", "Throwback Thursday", "Weekend Flow",
|
||||
"Deep Focus", "Party Anthems", "Jazz & Blues", "Acoustic Sessions",
|
||||
"Morning Coffee", "Rainy Day", "Sleep Sounds", "Gaming Beats",
|
||||
"Coding Mode", "Summer Hits", "Winter Lo-Fi", "Discover Weekly",
|
||||
"Release Radar", "On Repeat", "Time Capsule", "Viral 50",
|
||||
"Global Top 50", "Trending Now", "Fresh Finds", "Audiobook Mode",
|
||||
"Podcast Favorites", "Rock Classics", "Metal Essentials", "Hip Hop Gold",
|
||||
"Electronic Dreams", "Ambient Spaces", "Classical Masterpieces", "Country Roads"
|
||||
];
|
||||
|
||||
if (needed > 0 && allTracks.length > 0) {
|
||||
const shuffle = (array: any[]) => array.sort(() => 0.5 - Math.random());
|
||||
|
||||
for (let i = 0; i < needed; i++) {
|
||||
const shuffled = shuffle([...allTracks]);
|
||||
const selected = shuffled.slice(0, 8 + Math.floor(Math.random() * 12));
|
||||
const cover = selected[0]?.cover_url;
|
||||
const name = creativeNames[i] || `Daily Mix ${i + 1}`;
|
||||
|
||||
fakePlaylists.push({
|
||||
id: `mix-${i}`,
|
||||
title: name,
|
||||
description: `Curated just for you • ${selected.length} songs`,
|
||||
cover_url: cover,
|
||||
tracks: selected,
|
||||
type: 'Playlist'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueItems = [
|
||||
...fakePlaylists.map(p => ({ ...p, type: 'Playlist' })),
|
||||
...Array.from(artistsMap.values()),
|
||||
...Array.from(albumsMap.values())
|
||||
];
|
||||
|
||||
setLibraryItems(uniqueItems);
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LibraryContext.Provider value={{
|
||||
userPlaylists,
|
||||
libraryItems,
|
||||
activeFilter,
|
||||
setActiveFilter,
|
||||
refreshLibrary: fetchAllData
|
||||
}}>
|
||||
{children}
|
||||
</LibraryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLibrary() {
|
||||
const context = useContext(LibraryContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useLibrary must be used within a LibraryProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
import { dbService, Playlist } from "@/services/db";
|
||||
import { libraryService } from "@/services/library";
|
||||
|
||||
type FilterType = 'all' | 'playlists' | 'artists' | 'albums';
|
||||
|
||||
interface LibraryContextType {
|
||||
userPlaylists: Playlist[];
|
||||
libraryItems: any[];
|
||||
activeFilter: FilterType;
|
||||
setActiveFilter: (filter: FilterType) => void;
|
||||
refreshLibrary: () => Promise<void>;
|
||||
}
|
||||
|
||||
const LibraryContext = createContext<LibraryContextType | undefined>(undefined);
|
||||
|
||||
export function LibraryProvider({ children }: { children: React.ReactNode }) {
|
||||
const [userPlaylists, setUserPlaylists] = useState<Playlist[]>([]);
|
||||
const [libraryItems, setLibraryItems] = useState<any[]>([]);
|
||||
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
|
||||
|
||||
const fetchAllData = async () => {
|
||||
try {
|
||||
// 1. User Playlists
|
||||
const playlists = await dbService.getPlaylists();
|
||||
setUserPlaylists(playlists);
|
||||
|
||||
// 2. Local/Backend Content
|
||||
const browse = await libraryService.getBrowseContent();
|
||||
// Deduplicate by ID to avoid React duplicate key warnings
|
||||
const browsePlaylistsRaw = Object.values(browse).flat();
|
||||
const seenIds = new Map();
|
||||
const browsePlaylists = browsePlaylistsRaw.filter((p: any) => {
|
||||
if (seenIds.has(p.id)) return false;
|
||||
seenIds.set(p.id, true);
|
||||
return true;
|
||||
});
|
||||
|
||||
const artistsMap = new Map();
|
||||
const albumsMap = new Map();
|
||||
const allTracks: any[] = [];
|
||||
|
||||
// 3. Extract metadata
|
||||
browsePlaylists.forEach((p: any) => {
|
||||
if (p.tracks) {
|
||||
p.tracks.forEach((t: any) => {
|
||||
allTracks.push(t);
|
||||
// Fake Artist
|
||||
if (artistsMap.size < 40 && t.artist && t.artist !== 'Unknown Artist' && t.artist !== 'Unknown') {
|
||||
if (!artistsMap.has(t.artist)) {
|
||||
artistsMap.set(t.artist, {
|
||||
id: `artist-${t.artist}`,
|
||||
title: t.artist,
|
||||
type: 'Artist',
|
||||
cover_url: t.cover_url
|
||||
});
|
||||
}
|
||||
}
|
||||
// Fake Album
|
||||
if (albumsMap.size < 40 && t.album && t.album !== 'Single' && t.album !== 'Unknown Album') {
|
||||
if (!albumsMap.has(t.album)) {
|
||||
albumsMap.set(t.album, {
|
||||
id: `album-${t.album}`,
|
||||
title: t.album,
|
||||
type: 'Album',
|
||||
creator: t.artist,
|
||||
cover_url: t.cover_url
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Generate Fake Extra Playlists (Creative Names)
|
||||
const fakePlaylists = [...browsePlaylists];
|
||||
const targetCount = 40;
|
||||
const needed = targetCount - fakePlaylists.length;
|
||||
|
||||
const creativeNames = [
|
||||
"Chill Vibes", "Late Night Focus", "Workout Energy", "Road Trip Classics",
|
||||
"Indie Mix", "Pop Hits", "Throwback Thursday", "Weekend Flow",
|
||||
"Deep Focus", "Party Anthems", "Jazz & Blues", "Acoustic Sessions",
|
||||
"Morning Coffee", "Rainy Day", "Sleep Sounds", "Gaming Beats",
|
||||
"Coding Mode", "Summer Hits", "Winter Lo-Fi", "Discover Weekly",
|
||||
"Release Radar", "On Repeat", "Time Capsule", "Viral 50",
|
||||
"Global Top 50", "Trending Now", "Fresh Finds", "Audiobook Mode",
|
||||
"Podcast Favorites", "Rock Classics", "Metal Essentials", "Hip Hop Gold",
|
||||
"Electronic Dreams", "Ambient Spaces", "Classical Masterpieces", "Country Roads"
|
||||
];
|
||||
|
||||
if (needed > 0 && allTracks.length > 0) {
|
||||
const shuffle = (array: any[]) => array.sort(() => 0.5 - Math.random());
|
||||
|
||||
for (let i = 0; i < needed; i++) {
|
||||
const shuffled = shuffle([...allTracks]);
|
||||
const selected = shuffled.slice(0, 8 + Math.floor(Math.random() * 12));
|
||||
const cover = selected[0]?.cover_url;
|
||||
const name = creativeNames[i] || `Daily Mix ${i + 1}`;
|
||||
|
||||
fakePlaylists.push({
|
||||
id: `mix-${i}`,
|
||||
title: name,
|
||||
description: `Curated just for you • ${selected.length} songs`,
|
||||
cover_url: cover,
|
||||
tracks: selected,
|
||||
type: 'Playlist'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueItems = [
|
||||
...fakePlaylists.map(p => ({ ...p, type: 'Playlist' })),
|
||||
...Array.from(artistsMap.values()),
|
||||
...Array.from(albumsMap.values())
|
||||
];
|
||||
|
||||
setLibraryItems(uniqueItems);
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LibraryContext.Provider value={{
|
||||
userPlaylists,
|
||||
libraryItems,
|
||||
activeFilter,
|
||||
setActiveFilter,
|
||||
refreshLibrary: fetchAllData
|
||||
}}>
|
||||
{children}
|
||||
</LibraryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLibrary() {
|
||||
const context = useContext(LibraryContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useLibrary must be used within a LibraryProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,279 +1,279 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import { dbService } from "@/services/db";
|
||||
import { Track, AudioQuality } from "@/types";
|
||||
import * as mm from 'music-metadata-browser';
|
||||
|
||||
interface PlayerContextType {
|
||||
currentTrack: Track | null;
|
||||
isPlaying: boolean;
|
||||
isBuffering: boolean;
|
||||
likedTracks: Set<string>;
|
||||
likedTracksData: Track[];
|
||||
shuffle: boolean;
|
||||
repeatMode: 'none' | 'all' | 'one';
|
||||
playTrack: (track: Track, queue?: Track[]) => void;
|
||||
togglePlay: () => void;
|
||||
nextTrack: () => void;
|
||||
prevTrack: () => void;
|
||||
toggleShuffle: () => void;
|
||||
toggleRepeat: () => void;
|
||||
setBuffering: (state: boolean) => void;
|
||||
toggleLike: (track: Track) => void;
|
||||
playHistory: Track[];
|
||||
audioQuality: AudioQuality | null;
|
||||
// Lyrics panel state
|
||||
isLyricsOpen: boolean;
|
||||
toggleLyrics: () => void;
|
||||
}
|
||||
|
||||
const PlayerContext = createContext<PlayerContextType | undefined>(undefined);
|
||||
|
||||
export function PlayerProvider({ children }: { children: ReactNode }) {
|
||||
const [currentTrack, setCurrentTrack] = useState<Track | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(false);
|
||||
const [likedTracks, setLikedTracks] = useState<Set<string>>(new Set());
|
||||
const [likedTracksData, setLikedTracksData] = useState<Track[]>([]);
|
||||
|
||||
// Audio Engine State
|
||||
const [audioQuality, setAudioQuality] = useState<AudioQuality | null>(null);
|
||||
const [preloadedBlobs, setPreloadedBlobs] = useState<Map<string, string>>(new Map());
|
||||
|
||||
// Queue State
|
||||
const [queue, setQueue] = useState<Track[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(-1);
|
||||
const [shuffle, setShuffle] = useState(false);
|
||||
const [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('none');
|
||||
|
||||
// History State
|
||||
const [playHistory, setPlayHistory] = useState<Track[]>([]);
|
||||
|
||||
// Lyrics Panel State
|
||||
const [isLyricsOpen, setIsLyricsOpen] = useState(false);
|
||||
const toggleLyrics = () => setIsLyricsOpen(prev => !prev);
|
||||
|
||||
// Load Likes from DB
|
||||
useEffect(() => {
|
||||
dbService.getLikedSongs().then(tracks => {
|
||||
setLikedTracks(new Set(tracks.map(t => t.id)));
|
||||
setLikedTracksData(tracks);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load History from LocalStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('playHistory');
|
||||
if (saved) {
|
||||
setPlayHistory(JSON.parse(saved));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load history", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save History
|
||||
useEffect(() => {
|
||||
localStorage.setItem('playHistory', JSON.stringify(playHistory));
|
||||
}, [playHistory]);
|
||||
|
||||
// Metadata & Preloading Effect
|
||||
useEffect(() => {
|
||||
if (!currentTrack) return;
|
||||
|
||||
// 1. Reset Quality
|
||||
setAudioQuality(null);
|
||||
|
||||
// 2. Parse Metadata for Current Track
|
||||
const parseMetadata = async () => {
|
||||
try {
|
||||
// Skip metadata parsing for backend streams AND external URLs (YouTube) to avoid CORS/Double-fetch
|
||||
if (currentTrack.url && (currentTrack.url.includes('/api/stream') || currentTrack.url.startsWith('http'))) {
|
||||
setAudioQuality({
|
||||
format: 'WEBM/OPUS', // YT Music typically
|
||||
sampleRate: 48000,
|
||||
bitrate: 128000,
|
||||
channels: 2,
|
||||
codec: 'Opus'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTrack.url) {
|
||||
// Note: In a real scenario, we might need a proxy or CORS-enabled server.
|
||||
// music-metadata-browser fetches the file.
|
||||
const metadata = await mm.fetchFromUrl(currentTrack.url);
|
||||
setAudioQuality({
|
||||
format: metadata.format.container || 'Unknown',
|
||||
sampleRate: metadata.format.sampleRate || 44100,
|
||||
bitDepth: metadata.format.bitsPerSample,
|
||||
bitrate: metadata.format.bitrate || 0,
|
||||
channels: metadata.format.numberOfChannels || 2,
|
||||
codec: metadata.format.codec
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to parse metadata", e);
|
||||
// Fallback mock if parsing fails (likely due to CORS on sample URL)
|
||||
setAudioQuality({
|
||||
format: 'MP3',
|
||||
sampleRate: 44100,
|
||||
bitrate: 320000,
|
||||
channels: 2,
|
||||
codec: 'MPEG-1 Layer 3'
|
||||
});
|
||||
}
|
||||
};
|
||||
parseMetadata();
|
||||
|
||||
// 3. Smart Buffering (Preload Next 2 Tracks)
|
||||
const preloadNext = async () => {
|
||||
if (queue.length === 0) return;
|
||||
const index = queue.findIndex(t => t.id === currentTrack.id);
|
||||
if (index === -1) return;
|
||||
|
||||
const nextTracks = queue.slice(index + 1, index + 3);
|
||||
nextTracks.forEach(async (track) => {
|
||||
if (!preloadedBlobs.has(track.id) && track.url) {
|
||||
try {
|
||||
// Construct the correct stream URL for preloading if it's external
|
||||
const fetchUrl = track.url.startsWith('http') ? `/api/stream?id=${track.id}` : track.url;
|
||||
|
||||
const res = await fetch(fetchUrl);
|
||||
if (!res.ok) throw new Error("Fetch failed");
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
setPreloadedBlobs(prev => new Map(prev).set(track.id, blobUrl));
|
||||
console.log(`Buffered ${track.title}`);
|
||||
} catch (e) {
|
||||
// console.warn(`Failed to buffer ${track.title}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
preloadNext();
|
||||
|
||||
}, [currentTrack, queue, preloadedBlobs]);
|
||||
|
||||
|
||||
const playTrack = (track: Track, newQueue?: Track[]) => {
|
||||
if (currentTrack?.id !== track.id) {
|
||||
setIsBuffering(true);
|
||||
|
||||
// Add to History (prevent duplicates at top)
|
||||
setPlayHistory(prev => {
|
||||
const filtered = prev.filter(t => t.id !== track.id);
|
||||
return [track, ...filtered].slice(0, 20); // Keep last 20
|
||||
});
|
||||
}
|
||||
setCurrentTrack(track);
|
||||
setIsPlaying(true);
|
||||
|
||||
if (newQueue) {
|
||||
setQueue(newQueue);
|
||||
const index = newQueue.findIndex(t => t.id === track.id);
|
||||
setCurrentIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlay = () => {
|
||||
setIsPlaying((prev) => !prev);
|
||||
};
|
||||
|
||||
const nextTrack = () => {
|
||||
if (queue.length === 0) return;
|
||||
|
||||
let nextIndex = currentIndex + 1;
|
||||
if (shuffle) {
|
||||
nextIndex = Math.floor(Math.random() * queue.length);
|
||||
} else if (nextIndex >= queue.length) {
|
||||
if (repeatMode === 'all') nextIndex = 0;
|
||||
else return; // Stop if end of queue and no repeat
|
||||
}
|
||||
|
||||
playTrack(queue[nextIndex]);
|
||||
setCurrentIndex(nextIndex);
|
||||
};
|
||||
|
||||
const prevTrack = () => {
|
||||
if (queue.length === 0) return;
|
||||
let prevIndex = currentIndex - 1;
|
||||
if (prevIndex < 0) prevIndex = 0; // Or wrap around if desired
|
||||
playTrack(queue[prevIndex]);
|
||||
setCurrentIndex(prevIndex);
|
||||
};
|
||||
|
||||
const toggleShuffle = () => setShuffle(prev => !prev);
|
||||
|
||||
const toggleRepeat = () => {
|
||||
setRepeatMode(prev => {
|
||||
if (prev === 'none') return 'all';
|
||||
if (prev === 'all') return 'one';
|
||||
return 'none';
|
||||
});
|
||||
};
|
||||
|
||||
const setBuffering = (state: boolean) => setIsBuffering(state);
|
||||
|
||||
const toggleLike = async (track: Track) => {
|
||||
const isNowLiked = await dbService.toggleLike(track);
|
||||
|
||||
setLikedTracks(prev => {
|
||||
const next = new Set(prev);
|
||||
if (isNowLiked) next.add(track.id);
|
||||
else next.delete(track.id);
|
||||
return next;
|
||||
});
|
||||
|
||||
setLikedTracksData(prev => {
|
||||
if (!isNowLiked) {
|
||||
return prev.filter(t => t.id !== track.id);
|
||||
} else {
|
||||
return [...prev, track];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const effectiveCurrentTrack = currentTrack ? {
|
||||
...currentTrack,
|
||||
// improved URL logic: usage of backend API if no local blob
|
||||
url: preloadedBlobs.get(currentTrack.id) ||
|
||||
(currentTrack.url && currentTrack.url.startsWith('/') ? currentTrack.url : `/api/stream?id=${currentTrack.id}`)
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<PlayerContext.Provider value={{
|
||||
currentTrack: effectiveCurrentTrack,
|
||||
isPlaying,
|
||||
isBuffering,
|
||||
likedTracks,
|
||||
likedTracksData,
|
||||
shuffle,
|
||||
repeatMode,
|
||||
playTrack,
|
||||
togglePlay,
|
||||
nextTrack,
|
||||
prevTrack,
|
||||
toggleShuffle,
|
||||
toggleRepeat,
|
||||
setBuffering,
|
||||
toggleLike,
|
||||
playHistory,
|
||||
audioQuality,
|
||||
isLyricsOpen,
|
||||
toggleLyrics
|
||||
}}>
|
||||
{children}
|
||||
</PlayerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePlayer() {
|
||||
const context = useContext(PlayerContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("usePlayer must be used within a PlayerProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import { dbService } from "@/services/db";
|
||||
import { Track, AudioQuality } from "@/types";
|
||||
import * as mm from 'music-metadata-browser';
|
||||
|
||||
interface PlayerContextType {
|
||||
currentTrack: Track | null;
|
||||
isPlaying: boolean;
|
||||
isBuffering: boolean;
|
||||
likedTracks: Set<string>;
|
||||
likedTracksData: Track[];
|
||||
shuffle: boolean;
|
||||
repeatMode: 'none' | 'all' | 'one';
|
||||
playTrack: (track: Track, queue?: Track[]) => void;
|
||||
togglePlay: () => void;
|
||||
nextTrack: () => void;
|
||||
prevTrack: () => void;
|
||||
toggleShuffle: () => void;
|
||||
toggleRepeat: () => void;
|
||||
setBuffering: (state: boolean) => void;
|
||||
toggleLike: (track: Track) => void;
|
||||
playHistory: Track[];
|
||||
audioQuality: AudioQuality | null;
|
||||
// Lyrics panel state
|
||||
isLyricsOpen: boolean;
|
||||
toggleLyrics: () => void;
|
||||
}
|
||||
|
||||
const PlayerContext = createContext<PlayerContextType | undefined>(undefined);
|
||||
|
||||
export function PlayerProvider({ children }: { children: ReactNode }) {
|
||||
const [currentTrack, setCurrentTrack] = useState<Track | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(false);
|
||||
const [likedTracks, setLikedTracks] = useState<Set<string>>(new Set());
|
||||
const [likedTracksData, setLikedTracksData] = useState<Track[]>([]);
|
||||
|
||||
// Audio Engine State
|
||||
const [audioQuality, setAudioQuality] = useState<AudioQuality | null>(null);
|
||||
const [preloadedBlobs, setPreloadedBlobs] = useState<Map<string, string>>(new Map());
|
||||
|
||||
// Queue State
|
||||
const [queue, setQueue] = useState<Track[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(-1);
|
||||
const [shuffle, setShuffle] = useState(false);
|
||||
const [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('none');
|
||||
|
||||
// History State
|
||||
const [playHistory, setPlayHistory] = useState<Track[]>([]);
|
||||
|
||||
// Lyrics Panel State
|
||||
const [isLyricsOpen, setIsLyricsOpen] = useState(false);
|
||||
const toggleLyrics = () => setIsLyricsOpen(prev => !prev);
|
||||
|
||||
// Load Likes from DB
|
||||
useEffect(() => {
|
||||
dbService.getLikedSongs().then(tracks => {
|
||||
setLikedTracks(new Set(tracks.map(t => t.id)));
|
||||
setLikedTracksData(tracks);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load History from LocalStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('playHistory');
|
||||
if (saved) {
|
||||
setPlayHistory(JSON.parse(saved));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load history", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save History
|
||||
useEffect(() => {
|
||||
localStorage.setItem('playHistory', JSON.stringify(playHistory));
|
||||
}, [playHistory]);
|
||||
|
||||
// Metadata & Preloading Effect
|
||||
useEffect(() => {
|
||||
if (!currentTrack) return;
|
||||
|
||||
// 1. Reset Quality
|
||||
setAudioQuality(null);
|
||||
|
||||
// 2. Parse Metadata for Current Track
|
||||
const parseMetadata = async () => {
|
||||
try {
|
||||
// Skip metadata parsing for backend streams AND external URLs (YouTube) to avoid CORS/Double-fetch
|
||||
if (currentTrack.url && (currentTrack.url.includes('/api/stream') || currentTrack.url.startsWith('http'))) {
|
||||
setAudioQuality({
|
||||
format: 'WEBM/OPUS', // YT Music typically
|
||||
sampleRate: 48000,
|
||||
bitrate: 128000,
|
||||
channels: 2,
|
||||
codec: 'Opus'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTrack.url) {
|
||||
// Note: In a real scenario, we might need a proxy or CORS-enabled server.
|
||||
// music-metadata-browser fetches the file.
|
||||
const metadata = await mm.fetchFromUrl(currentTrack.url);
|
||||
setAudioQuality({
|
||||
format: metadata.format.container || 'Unknown',
|
||||
sampleRate: metadata.format.sampleRate || 44100,
|
||||
bitDepth: metadata.format.bitsPerSample,
|
||||
bitrate: metadata.format.bitrate || 0,
|
||||
channels: metadata.format.numberOfChannels || 2,
|
||||
codec: metadata.format.codec
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to parse metadata", e);
|
||||
// Fallback mock if parsing fails (likely due to CORS on sample URL)
|
||||
setAudioQuality({
|
||||
format: 'MP3',
|
||||
sampleRate: 44100,
|
||||
bitrate: 320000,
|
||||
channels: 2,
|
||||
codec: 'MPEG-1 Layer 3'
|
||||
});
|
||||
}
|
||||
};
|
||||
parseMetadata();
|
||||
|
||||
// 3. Smart Buffering (Preload Next 2 Tracks)
|
||||
const preloadNext = async () => {
|
||||
if (queue.length === 0) return;
|
||||
const index = queue.findIndex(t => t.id === currentTrack.id);
|
||||
if (index === -1) return;
|
||||
|
||||
const nextTracks = queue.slice(index + 1, index + 3);
|
||||
nextTracks.forEach(async (track) => {
|
||||
if (!preloadedBlobs.has(track.id) && track.url) {
|
||||
try {
|
||||
// Construct the correct stream URL for preloading if it's external
|
||||
const fetchUrl = track.url.startsWith('http') ? `/api/stream?id=${track.id}` : track.url;
|
||||
|
||||
const res = await fetch(fetchUrl);
|
||||
if (!res.ok) throw new Error("Fetch failed");
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
setPreloadedBlobs(prev => new Map(prev).set(track.id, blobUrl));
|
||||
console.log(`Buffered ${track.title}`);
|
||||
} catch (e) {
|
||||
// console.warn(`Failed to buffer ${track.title}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
preloadNext();
|
||||
|
||||
}, [currentTrack, queue, preloadedBlobs]);
|
||||
|
||||
|
||||
const playTrack = (track: Track, newQueue?: Track[]) => {
|
||||
if (currentTrack?.id !== track.id) {
|
||||
setIsBuffering(true);
|
||||
|
||||
// Add to History (prevent duplicates at top)
|
||||
setPlayHistory(prev => {
|
||||
const filtered = prev.filter(t => t.id !== track.id);
|
||||
return [track, ...filtered].slice(0, 20); // Keep last 20
|
||||
});
|
||||
}
|
||||
setCurrentTrack(track);
|
||||
setIsPlaying(true);
|
||||
|
||||
if (newQueue) {
|
||||
setQueue(newQueue);
|
||||
const index = newQueue.findIndex(t => t.id === track.id);
|
||||
setCurrentIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlay = () => {
|
||||
setIsPlaying((prev) => !prev);
|
||||
};
|
||||
|
||||
const nextTrack = () => {
|
||||
if (queue.length === 0) return;
|
||||
|
||||
let nextIndex = currentIndex + 1;
|
||||
if (shuffle) {
|
||||
nextIndex = Math.floor(Math.random() * queue.length);
|
||||
} else if (nextIndex >= queue.length) {
|
||||
if (repeatMode === 'all') nextIndex = 0;
|
||||
else return; // Stop if end of queue and no repeat
|
||||
}
|
||||
|
||||
playTrack(queue[nextIndex]);
|
||||
setCurrentIndex(nextIndex);
|
||||
};
|
||||
|
||||
const prevTrack = () => {
|
||||
if (queue.length === 0) return;
|
||||
let prevIndex = currentIndex - 1;
|
||||
if (prevIndex < 0) prevIndex = 0; // Or wrap around if desired
|
||||
playTrack(queue[prevIndex]);
|
||||
setCurrentIndex(prevIndex);
|
||||
};
|
||||
|
||||
const toggleShuffle = () => setShuffle(prev => !prev);
|
||||
|
||||
const toggleRepeat = () => {
|
||||
setRepeatMode(prev => {
|
||||
if (prev === 'none') return 'all';
|
||||
if (prev === 'all') return 'one';
|
||||
return 'none';
|
||||
});
|
||||
};
|
||||
|
||||
const setBuffering = (state: boolean) => setIsBuffering(state);
|
||||
|
||||
const toggleLike = async (track: Track) => {
|
||||
const isNowLiked = await dbService.toggleLike(track);
|
||||
|
||||
setLikedTracks(prev => {
|
||||
const next = new Set(prev);
|
||||
if (isNowLiked) next.add(track.id);
|
||||
else next.delete(track.id);
|
||||
return next;
|
||||
});
|
||||
|
||||
setLikedTracksData(prev => {
|
||||
if (!isNowLiked) {
|
||||
return prev.filter(t => t.id !== track.id);
|
||||
} else {
|
||||
return [...prev, track];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const effectiveCurrentTrack = currentTrack ? {
|
||||
...currentTrack,
|
||||
// improved URL logic: usage of backend API if no local blob
|
||||
url: preloadedBlobs.get(currentTrack.id) ||
|
||||
(currentTrack.url && currentTrack.url.startsWith('/') ? currentTrack.url : `/api/stream?id=${currentTrack.id}`)
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<PlayerContext.Provider value={{
|
||||
currentTrack: effectiveCurrentTrack,
|
||||
isPlaying,
|
||||
isBuffering,
|
||||
likedTracks,
|
||||
likedTracksData,
|
||||
shuffle,
|
||||
repeatMode,
|
||||
playTrack,
|
||||
togglePlay,
|
||||
nextTrack,
|
||||
prevTrack,
|
||||
toggleShuffle,
|
||||
toggleRepeat,
|
||||
setBuffering,
|
||||
toggleLike,
|
||||
playHistory,
|
||||
audioQuality,
|
||||
isLyricsOpen,
|
||||
toggleLyrics
|
||||
}}>
|
||||
{children}
|
||||
</PlayerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePlayer() {
|
||||
const context = useContext(PlayerContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("usePlayer must be used within a PlayerProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,63 +1,63 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// strict mode true is default but good to be explicit
|
||||
// strict mode true is default but good to be explicit
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
// Backend API Proxies (Specific, so we don't block NextAuth at /api/auth)
|
||||
{ source: '/api/browse/:path*', destination: 'http://127.0.0.1:8000/api/browse/:path*' },
|
||||
{ source: '/api/playlists/:path*', destination: 'http://127.0.0.1:8000/api/playlists/:path*' },
|
||||
{ source: '/api/search', destination: 'http://127.0.0.1:8000/api/search' },
|
||||
{ source: '/api/search/:path*', destination: 'http://127.0.0.1:8000/api/search/:path*' },
|
||||
{ source: '/api/artist/:path*', destination: 'http://127.0.0.1:8000/api/artist/:path*' },
|
||||
{ source: '/api/stream/:path*', destination: 'http://127.0.0.1:8000/api/stream/:path*' },
|
||||
{ source: '/api/download/:path*', destination: 'http://127.0.0.1:8000/api/download/:path*' },
|
||||
{ source: '/api/download-status/:path*', destination: 'http://127.0.0.1:8000/api/download-status/:path*' },
|
||||
{ source: '/api/lyrics/:path*', destination: 'http://127.0.0.1:8000/api/lyrics/:path*' },
|
||||
{ source: '/api/trending/:path*', destination: 'http://127.0.0.1:8000/api/trending/:path*' },
|
||||
{ source: '/api/recommendations/:path*', destination: 'http://127.0.0.1:8000/api/recommendations/:path*' },
|
||||
];
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'i.ytimg.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'lh3.googleusercontent.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'yt3.googleusercontent.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'yt3.ggpht.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'placehold.co',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'images.unsplash.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'misc.scdn.co',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// strict mode true is default but good to be explicit
|
||||
// strict mode true is default but good to be explicit
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
// Backend API Proxies (Specific, so we don't block NextAuth at /api/auth)
|
||||
{ source: '/api/browse/:path*', destination: 'http://127.0.0.1:8000/api/browse/:path*' },
|
||||
{ source: '/api/playlists/:path*', destination: 'http://127.0.0.1:8000/api/playlists/:path*' },
|
||||
{ source: '/api/search', destination: 'http://127.0.0.1:8000/api/search' },
|
||||
{ source: '/api/search/:path*', destination: 'http://127.0.0.1:8000/api/search/:path*' },
|
||||
{ source: '/api/artist/:path*', destination: 'http://127.0.0.1:8000/api/artist/:path*' },
|
||||
{ source: '/api/stream/:path*', destination: 'http://127.0.0.1:8000/api/stream/:path*' },
|
||||
{ source: '/api/download/:path*', destination: 'http://127.0.0.1:8000/api/download/:path*' },
|
||||
{ source: '/api/download-status/:path*', destination: 'http://127.0.0.1:8000/api/download-status/:path*' },
|
||||
{ source: '/api/lyrics/:path*', destination: 'http://127.0.0.1:8000/api/lyrics/:path*' },
|
||||
{ source: '/api/trending/:path*', destination: 'http://127.0.0.1:8000/api/trending/:path*' },
|
||||
{ source: '/api/recommendations/:path*', destination: 'http://127.0.0.1:8000/api/recommendations/:path*' },
|
||||
];
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'i.ytimg.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'lh3.googleusercontent.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'yt3.googleusercontent.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'yt3.ggpht.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'placehold.co',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'images.unsplash.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'misc.scdn.co',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 386 KiB After Width: | Height: | Size: 298 KiB |
|
|
@ -1,20 +1,26 @@
|
|||
{
|
||||
"name": "Spotify Clone",
|
||||
"short_name": "Spotify",
|
||||
"name": "Audiophile Web Player",
|
||||
"short_name": "Audiophile",
|
||||
"description": "High-Fidelity Local-First Music Player",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#121212",
|
||||
"theme_color": "#1DB954",
|
||||
"categories": ["music", "entertainment"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
96
frontend/public/sw.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
const CACHE_NAME = 'audiophile-v1';
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
'/icons/icon-192x192.png',
|
||||
'/icons/icon-512x512.png'
|
||||
];
|
||||
|
||||
// Install event - cache static assets
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
);
|
||||
// Activate immediately
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME)
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
})
|
||||
);
|
||||
// Take control of all pages immediately
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch event - network first for API, cache first for static
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
// Skip streaming/audio requests - let them go directly to network
|
||||
if (url.pathname.includes('/api/stream') ||
|
||||
url.pathname.includes('/api/download') ||
|
||||
event.request.headers.get('range')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// API requests - network first
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.catch(() => caches.match(event.request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets - cache first, then network
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cachedResponse) => {
|
||||
if (cachedResponse) {
|
||||
// Return cached version, but also update cache in background
|
||||
fetch(event.request).then((response) => {
|
||||
if (response.ok) {
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, response);
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// Not in cache - fetch from network
|
||||
return fetch(event.request).then((response) => {
|
||||
// Cache successful responses
|
||||
if (response.ok && response.type === 'basic') {
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseClone);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Handle background sync for offline actions (future enhancement)
|
||||
self.addEventListener('sync', (event) => {
|
||||
console.log('Background sync:', event.tag);
|
||||
});
|
||||
|
||||
// Handle push notifications (future enhancement)
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('Push received:', event);
|
||||
});
|
||||
|
|
@ -1,157 +1,157 @@
|
|||
import { openDB, DBSchema } from 'idb';
|
||||
import { Track, Playlist } from '@/types';
|
||||
|
||||
export type { Track, Playlist };
|
||||
|
||||
interface MyDB extends DBSchema {
|
||||
playlists: {
|
||||
key: string;
|
||||
value: Playlist;
|
||||
};
|
||||
likedSongs: {
|
||||
key: string; // trackId
|
||||
value: Track;
|
||||
};
|
||||
}
|
||||
|
||||
const DB_NAME = 'audiophile-db';
|
||||
const DB_VERSION = 2;
|
||||
|
||||
export const initDB = async () => {
|
||||
return openDB<MyDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db, oldVersion, newVersion, transaction) {
|
||||
// Re-create stores to clear old data
|
||||
if (db.objectStoreNames.contains('playlists')) {
|
||||
db.deleteObjectStore('playlists');
|
||||
}
|
||||
if (db.objectStoreNames.contains('likedSongs')) {
|
||||
db.deleteObjectStore('likedSongs');
|
||||
}
|
||||
db.createObjectStore('playlists', { keyPath: 'id' });
|
||||
db.createObjectStore('likedSongs', { keyPath: 'id' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const dbService = {
|
||||
async getPlaylists() {
|
||||
const db = await initDB();
|
||||
const playlists = await db.getAll('playlists');
|
||||
if (playlists.length === 0) {
|
||||
return this.seedInitialData();
|
||||
}
|
||||
return playlists;
|
||||
},
|
||||
|
||||
async seedInitialData() {
|
||||
try {
|
||||
// Fetch real data from backend to seed valid playlists
|
||||
// We use the 'api' prefix assuming this runs in browser
|
||||
const res = await fetch('/api/trending');
|
||||
if (!res.ok) return [];
|
||||
|
||||
const data = await res.json();
|
||||
const allTracks: Track[] = data.tracks || [];
|
||||
|
||||
if (allTracks.length === 0) return [];
|
||||
|
||||
const db = await initDB();
|
||||
const newPlaylists: Playlist[] = [];
|
||||
|
||||
// 1. Starter Playlist
|
||||
const favTracks = allTracks.slice(0, 8);
|
||||
if (favTracks.length > 0) {
|
||||
const p1: Playlist = {
|
||||
id: crypto.randomUUID(),
|
||||
title: "My Rotations",
|
||||
tracks: favTracks,
|
||||
createdAt: Date.now(),
|
||||
cover_url: favTracks[0].cover_url
|
||||
};
|
||||
await db.put('playlists', p1);
|
||||
newPlaylists.push(p1);
|
||||
}
|
||||
|
||||
// 2. Vibes
|
||||
const vibeTracks = allTracks.slice(8, 15);
|
||||
if (vibeTracks.length > 0) {
|
||||
const p2: Playlist = {
|
||||
id: crypto.randomUUID(),
|
||||
title: "Weekend Vibes",
|
||||
tracks: vibeTracks,
|
||||
createdAt: Date.now(),
|
||||
cover_url: vibeTracks[0].cover_url
|
||||
};
|
||||
await db.put('playlists', p2);
|
||||
newPlaylists.push(p2);
|
||||
}
|
||||
|
||||
return newPlaylists;
|
||||
|
||||
} catch (e) {
|
||||
console.error("Seeding failed", e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async getPlaylist(id: string) {
|
||||
const db = await initDB();
|
||||
return db.get('playlists', id);
|
||||
},
|
||||
async createPlaylist(name: string) {
|
||||
const db = await initDB();
|
||||
const newPlaylist: Playlist = {
|
||||
id: crypto.randomUUID(),
|
||||
title: name,
|
||||
tracks: [],
|
||||
createdAt: Date.now(),
|
||||
cover_url: "https://placehold.co/300/222/fff?text=" + encodeURIComponent(name)
|
||||
};
|
||||
await db.put('playlists', newPlaylist);
|
||||
return newPlaylist;
|
||||
},
|
||||
async deletePlaylist(id: string) {
|
||||
const db = await initDB();
|
||||
await db.delete('playlists', id);
|
||||
},
|
||||
async addToPlaylist(playlistId: string, track: Track) {
|
||||
const db = await initDB();
|
||||
const playlist = await db.get('playlists', playlistId);
|
||||
if (playlist) {
|
||||
// Auto-update cover if it's the default or empty
|
||||
if (playlist.tracks.length === 0 || playlist.cover_url?.includes("placehold")) {
|
||||
playlist.cover_url = track.cover_url;
|
||||
}
|
||||
playlist.tracks.push(track);
|
||||
await db.put('playlists', playlist);
|
||||
}
|
||||
},
|
||||
async removeFromPlaylist(playlistId: string, trackId: string) {
|
||||
const db = await initDB();
|
||||
const playlist = await db.get('playlists', playlistId);
|
||||
if (playlist) {
|
||||
playlist.tracks = playlist.tracks.filter(t => t.id !== trackId);
|
||||
await db.put('playlists', playlist);
|
||||
}
|
||||
},
|
||||
async getLikedSongs() {
|
||||
const db = await initDB();
|
||||
return db.getAll('likedSongs');
|
||||
},
|
||||
async toggleLike(track: Track) {
|
||||
const db = await initDB();
|
||||
const existing = await db.get('likedSongs', track.id);
|
||||
if (existing) {
|
||||
await db.delete('likedSongs', track.id);
|
||||
return false; // unliked
|
||||
} else {
|
||||
await db.put('likedSongs', track);
|
||||
return true; // liked
|
||||
}
|
||||
},
|
||||
async isLiked(trackId: string) {
|
||||
const db = await initDB();
|
||||
const existing = await db.get('likedSongs', trackId);
|
||||
return !!existing;
|
||||
}
|
||||
};
|
||||
import { openDB, DBSchema } from 'idb';
|
||||
import { Track, Playlist } from '@/types';
|
||||
|
||||
export type { Track, Playlist };
|
||||
|
||||
interface MyDB extends DBSchema {
|
||||
playlists: {
|
||||
key: string;
|
||||
value: Playlist;
|
||||
};
|
||||
likedSongs: {
|
||||
key: string; // trackId
|
||||
value: Track;
|
||||
};
|
||||
}
|
||||
|
||||
const DB_NAME = 'audiophile-db';
|
||||
const DB_VERSION = 2;
|
||||
|
||||
export const initDB = async () => {
|
||||
return openDB<MyDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db, oldVersion, newVersion, transaction) {
|
||||
// Re-create stores to clear old data
|
||||
if (db.objectStoreNames.contains('playlists')) {
|
||||
db.deleteObjectStore('playlists');
|
||||
}
|
||||
if (db.objectStoreNames.contains('likedSongs')) {
|
||||
db.deleteObjectStore('likedSongs');
|
||||
}
|
||||
db.createObjectStore('playlists', { keyPath: 'id' });
|
||||
db.createObjectStore('likedSongs', { keyPath: 'id' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const dbService = {
|
||||
async getPlaylists() {
|
||||
const db = await initDB();
|
||||
const playlists = await db.getAll('playlists');
|
||||
if (playlists.length === 0) {
|
||||
return this.seedInitialData();
|
||||
}
|
||||
return playlists;
|
||||
},
|
||||
|
||||
async seedInitialData() {
|
||||
try {
|
||||
// Fetch real data from backend to seed valid playlists
|
||||
// We use the 'api' prefix assuming this runs in browser
|
||||
const res = await fetch('/api/trending');
|
||||
if (!res.ok) return [];
|
||||
|
||||
const data = await res.json();
|
||||
const allTracks: Track[] = data.tracks || [];
|
||||
|
||||
if (allTracks.length === 0) return [];
|
||||
|
||||
const db = await initDB();
|
||||
const newPlaylists: Playlist[] = [];
|
||||
|
||||
// 1. Starter Playlist
|
||||
const favTracks = allTracks.slice(0, 8);
|
||||
if (favTracks.length > 0) {
|
||||
const p1: Playlist = {
|
||||
id: crypto.randomUUID(),
|
||||
title: "My Rotations",
|
||||
tracks: favTracks,
|
||||
createdAt: Date.now(),
|
||||
cover_url: favTracks[0].cover_url
|
||||
};
|
||||
await db.put('playlists', p1);
|
||||
newPlaylists.push(p1);
|
||||
}
|
||||
|
||||
// 2. Vibes
|
||||
const vibeTracks = allTracks.slice(8, 15);
|
||||
if (vibeTracks.length > 0) {
|
||||
const p2: Playlist = {
|
||||
id: crypto.randomUUID(),
|
||||
title: "Weekend Vibes",
|
||||
tracks: vibeTracks,
|
||||
createdAt: Date.now(),
|
||||
cover_url: vibeTracks[0].cover_url
|
||||
};
|
||||
await db.put('playlists', p2);
|
||||
newPlaylists.push(p2);
|
||||
}
|
||||
|
||||
return newPlaylists;
|
||||
|
||||
} catch (e) {
|
||||
console.error("Seeding failed", e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async getPlaylist(id: string) {
|
||||
const db = await initDB();
|
||||
return db.get('playlists', id);
|
||||
},
|
||||
async createPlaylist(name: string) {
|
||||
const db = await initDB();
|
||||
const newPlaylist: Playlist = {
|
||||
id: crypto.randomUUID(),
|
||||
title: name,
|
||||
tracks: [],
|
||||
createdAt: Date.now(),
|
||||
cover_url: "https://placehold.co/300/222/fff?text=" + encodeURIComponent(name)
|
||||
};
|
||||
await db.put('playlists', newPlaylist);
|
||||
return newPlaylist;
|
||||
},
|
||||
async deletePlaylist(id: string) {
|
||||
const db = await initDB();
|
||||
await db.delete('playlists', id);
|
||||
},
|
||||
async addToPlaylist(playlistId: string, track: Track) {
|
||||
const db = await initDB();
|
||||
const playlist = await db.get('playlists', playlistId);
|
||||
if (playlist) {
|
||||
// Auto-update cover if it's the default or empty
|
||||
if (playlist.tracks.length === 0 || playlist.cover_url?.includes("placehold")) {
|
||||
playlist.cover_url = track.cover_url;
|
||||
}
|
||||
playlist.tracks.push(track);
|
||||
await db.put('playlists', playlist);
|
||||
}
|
||||
},
|
||||
async removeFromPlaylist(playlistId: string, trackId: string) {
|
||||
const db = await initDB();
|
||||
const playlist = await db.get('playlists', playlistId);
|
||||
if (playlist) {
|
||||
playlist.tracks = playlist.tracks.filter(t => t.id !== trackId);
|
||||
await db.put('playlists', playlist);
|
||||
}
|
||||
},
|
||||
async getLikedSongs() {
|
||||
const db = await initDB();
|
||||
return db.getAll('likedSongs');
|
||||
},
|
||||
async toggleLike(track: Track) {
|
||||
const db = await initDB();
|
||||
const existing = await db.get('likedSongs', track.id);
|
||||
if (existing) {
|
||||
await db.delete('likedSongs', track.id);
|
||||
return false; // unliked
|
||||
} else {
|
||||
await db.put('likedSongs', track);
|
||||
return true; // liked
|
||||
}
|
||||
},
|
||||
async isLiked(trackId: string) {
|
||||
const db = await initDB();
|
||||
const existing = await db.get('likedSongs', trackId);
|
||||
return !!existing;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,76 +1,76 @@
|
|||
import { Track } from "./db";
|
||||
|
||||
export interface StaticPlaylist {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
cover_url: string;
|
||||
tracks: Track[];
|
||||
type: 'Album' | 'Artist' | 'Playlist';
|
||||
creator?: string;
|
||||
}
|
||||
|
||||
// Helper to fetch from backend
|
||||
const apiFetch = async (endpoint: string) => {
|
||||
const res = await fetch(`/api${endpoint}`);
|
||||
if (!res.ok) throw new Error(`API Error: ${res.statusText}`);
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const libraryService = {
|
||||
async getLibrary(): Promise<StaticPlaylist> {
|
||||
// Fetch "Liked Songs" or main library from backend
|
||||
// Assuming backend has an endpoint or we treat "Trending" as default
|
||||
return await apiFetch('/browse'); // Simplified fallback
|
||||
},
|
||||
|
||||
async _generateMockContent(): Promise<void> {
|
||||
// No-op in API mode
|
||||
},
|
||||
|
||||
async getBrowseContent(): Promise<Record<string, StaticPlaylist[]>> {
|
||||
return await apiFetch('/browse');
|
||||
},
|
||||
|
||||
async getPlaylist(id: string): Promise<StaticPlaylist | null> {
|
||||
try {
|
||||
return await apiFetch(`/playlists/${id}`);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch playlist", id, e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getRecommendations(seedTrackId?: string): Promise<Track[]> {
|
||||
// Use trending as recommendations for now
|
||||
const data = await apiFetch('/trending');
|
||||
return data.tracks || [];
|
||||
},
|
||||
|
||||
async getRecommendedAlbums(seedArtist?: string): Promise<StaticPlaylist[]> {
|
||||
const data = await apiFetch('/browse');
|
||||
// Flatten all albums from categories
|
||||
const albums: StaticPlaylist[] = [];
|
||||
Object.values(data).forEach((list: any) => {
|
||||
if (Array.isArray(list)) albums.push(...list);
|
||||
});
|
||||
return albums.slice(0, 8);
|
||||
},
|
||||
|
||||
async search(query: string): Promise<Track[]> {
|
||||
try {
|
||||
return await apiFetch(`/search?q=${encodeURIComponent(query)}`);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// UTILITIES FOR DYNAMIC UPDATES
|
||||
updateTrackCover(trackId: string, newUrl: string) {
|
||||
console.log("Dynamic updates not implemented in Backend Mode");
|
||||
},
|
||||
|
||||
updateAlbumCover(albumId: string, newUrl: string) {
|
||||
console.log("Dynamic updates not implemented in Backend Mode");
|
||||
}
|
||||
};
|
||||
import { Track } from "./db";
|
||||
|
||||
export interface StaticPlaylist {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
cover_url: string;
|
||||
tracks: Track[];
|
||||
type: 'Album' | 'Artist' | 'Playlist';
|
||||
creator?: string;
|
||||
}
|
||||
|
||||
// Helper to fetch from backend
|
||||
const apiFetch = async (endpoint: string) => {
|
||||
const res = await fetch(`/api${endpoint}`);
|
||||
if (!res.ok) throw new Error(`API Error: ${res.statusText}`);
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const libraryService = {
|
||||
async getLibrary(): Promise<StaticPlaylist> {
|
||||
// Fetch "Liked Songs" or main library from backend
|
||||
// Assuming backend has an endpoint or we treat "Trending" as default
|
||||
return await apiFetch('/browse'); // Simplified fallback
|
||||
},
|
||||
|
||||
async _generateMockContent(): Promise<void> {
|
||||
// No-op in API mode
|
||||
},
|
||||
|
||||
async getBrowseContent(): Promise<Record<string, StaticPlaylist[]>> {
|
||||
return await apiFetch('/browse');
|
||||
},
|
||||
|
||||
async getPlaylist(id: string): Promise<StaticPlaylist | null> {
|
||||
try {
|
||||
return await apiFetch(`/playlists/${id}`);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch playlist", id, e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getRecommendations(seedTrackId?: string): Promise<Track[]> {
|
||||
// Use trending as recommendations for now
|
||||
const data = await apiFetch('/trending');
|
||||
return data.tracks || [];
|
||||
},
|
||||
|
||||
async getRecommendedAlbums(seedArtist?: string): Promise<StaticPlaylist[]> {
|
||||
const data = await apiFetch('/browse');
|
||||
// Flatten all albums from categories
|
||||
const albums: StaticPlaylist[] = [];
|
||||
Object.values(data).forEach((list: any) => {
|
||||
if (Array.isArray(list)) albums.push(...list);
|
||||
});
|
||||
return albums.slice(0, 8);
|
||||
},
|
||||
|
||||
async search(query: string): Promise<Track[]> {
|
||||
try {
|
||||
return await apiFetch(`/search?q=${encodeURIComponent(query)}`);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// UTILITIES FOR DYNAMIC UPDATES
|
||||
updateTrackCover(trackId: string, newUrl: string) {
|
||||
console.log("Dynamic updates not implemented in Backend Mode");
|
||||
},
|
||||
|
||||
updateAlbumCover(albumId: string, newUrl: string) {
|
||||
console.log("Dynamic updates not implemented in Backend Mode");
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import yt_dlp
|
||||
import json
|
||||
|
||||
# Test video ID from our data (e.g., Khóa Ly Biệt)
|
||||
video_id = "s0OMNH-N5D8"
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
ydl_opts = {
|
||||
'format': 'bestaudio/best',
|
||||
'quiet': True,
|
||||
'noplaylist': True,
|
||||
}
|
||||
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
print(f"Title: {info.get('title')}")
|
||||
print(f"URL: {info.get('url')}") # The direct stream URL
|
||||
print("Success: Extracted audio URL")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
import yt_dlp
|
||||
import json
|
||||
|
||||
# Test video ID from our data (e.g., Khóa Ly Biệt)
|
||||
video_id = "s0OMNH-N5D8"
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
ydl_opts = {
|
||||
'format': 'bestaudio/best',
|
||||
'quiet': True,
|
||||
'noplaylist': True,
|
||||
}
|
||||
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
print(f"Title: {info.get('title')}")
|
||||
print(f"URL: {info.get('url')}") # The direct stream URL
|
||||
print("Success: Extracted audio URL")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
|
|
|||