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'
This commit is contained in:
Khoa Vo 2026-01-14 10:27:29 +07:00
parent 8e17986c95
commit dd788db786
35 changed files with 3518 additions and 3328 deletions

View file

@ -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

View file

@ -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"]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
[]

View file

@ -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}")

View file

@ -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"}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1 +1 @@
# Services Package
# Services Package

View file

@ -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']

View file

@ -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

View file

@ -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())

View file

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 13 KiB

BIN
frontend/app/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -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>
);
}

View file

@ -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>
);
}

File diff suppressed because it is too large Load diff

View file

@ -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>
);
}

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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

View 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;
}

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

After

Width:  |  Height:  |  Size: 298 KiB

View file

@ -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
View 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);
});

View file

@ -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;
}
};

View file

@ -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");
}
};

View file

@ -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}")