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
.git .git
.gitignore .gitignore
# Node # Node
node_modules node_modules
npm-debug.log npm-debug.log
# Python # Python
venv venv
__pycache__ __pycache__
*.pyc *.pyc
*.pyo *.pyo
*.pyd *.pyd
# Next.js # Next.js
.next .next
out out
# Docker # Docker
Dockerfile Dockerfile
.dockerignore .dockerignore
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Misc # Misc
*.log *.log

View file

@ -1,65 +1,65 @@
# --- Stage 1: Frontend Builder --- # --- Stage 1: Frontend Builder ---
FROM node:18-slim AS builder FROM node:18-slim AS builder
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend/package*.json ./ COPY frontend/package*.json ./
# Install dependencies including sharp for build # Install dependencies including sharp for build
RUN npm install --legacy-peer-deps RUN npm install --legacy-peer-deps
COPY frontend/ ./ COPY frontend/ ./
# Build with standalone output # Build with standalone output
ENV NEXT_PUBLIC_API_URL="" ENV NEXT_PUBLIC_API_URL=""
RUN npm run build RUN npm run build
# --- Stage 2: Final Runtime Image --- # --- Stage 2: Final Runtime Image ---
FROM python:3.11-slim FROM python:3.11-slim
# Install system dependencies # Install system dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
curl \ curl \
gnupg \ gnupg \
ffmpeg \ ffmpeg \
ca-certificates \ ca-certificates \
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs \ && apt-get install -y nodejs \
&& update-ca-certificates \ && update-ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# Backend Setup # Backend Setup
COPY backend/requirements.txt ./backend/requirements.txt COPY backend/requirements.txt ./backend/requirements.txt
RUN pip install --no-cache-dir -r backend/requirements.txt RUN pip install --no-cache-dir -r backend/requirements.txt
# Frontend Setup (Copy from Builder) # Frontend Setup (Copy from Builder)
# Copy the standalone server # Copy the standalone server
COPY --from=builder /app/frontend/.next/standalone /app/frontend COPY --from=builder /app/frontend/.next/standalone /app/frontend
# Explicitly install sharp in the standalone folder to ensure compatibility # Explicitly install sharp in the standalone folder to ensure compatibility
RUN cd /app/frontend && npm install sharp RUN cd /app/frontend && npm install sharp
# Copy static files (required for standalone) # Copy static files (required for standalone)
COPY --from=builder /app/frontend/.next/static /app/frontend/.next/static COPY --from=builder /app/frontend/.next/static /app/frontend/.next/static
COPY --from=builder /app/frontend/public /app/frontend/public COPY --from=builder /app/frontend/public /app/frontend/public
# Copy Backend Code # Copy Backend Code
COPY backend/ ./backend/ COPY backend/ ./backend/
# Create start script # Create start script
RUN mkdir -p backend/data_seed && cp -r backend/data/* backend/data_seed/ || true RUN mkdir -p backend/data_seed && cp -r backend/data/* backend/data_seed/ || true
# Set Environment Variables # Set Environment Variables
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
# Note: Standalone mode runs with 'node server.js' # Note: Standalone mode runs with 'node server.js'
RUN echo '#!/bin/bash\n\ RUN echo '#!/bin/bash\n\
if [ ! -f backend/data/data.json ]; then\n\ if [ ! -f backend/data/data.json ]; then\n\
echo "Data volume appears empty. Seeding with bundled data..."\n\ echo "Data volume appears empty. Seeding with bundled data..."\n\
cp -r backend/data_seed/* backend/data/\n\ cp -r backend/data_seed/* backend/data/\n\
fi\n\ fi\n\
uvicorn backend.main:app --host 0.0.0.0 --port 8000 &\n\ uvicorn backend.main:app --host 0.0.0.0 --port 8000 &\n\
cd frontend && node server.js\n\ cd frontend && node server.js\n\
' > start.sh && chmod +x start.sh ' > start.sh && chmod +x start.sh
EXPOSE 3000 8000 EXPOSE 3000 8000
CMD ["./start.sh"] 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 json
import time import time
import hashlib import hashlib
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
class CacheManager: class CacheManager:
def __init__(self, cache_dir: str = "backend/cache"): def __init__(self, cache_dir: str = "backend/cache"):
self.cache_dir = Path(cache_dir) self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(parents=True, exist_ok=True) self.cache_dir.mkdir(parents=True, exist_ok=True)
def _get_path(self, key: str) -> Path: def _get_path(self, key: str) -> Path:
# Create a safe filename from the key # Create a safe filename from the key
hashed_key = hashlib.md5(key.encode()).hexdigest() hashed_key = hashlib.md5(key.encode()).hexdigest()
return self.cache_dir / f"{hashed_key}.json" return self.cache_dir / f"{hashed_key}.json"
def get(self, key: str) -> Optional[Any]: def get(self, key: str) -> Optional[Any]:
""" """
Retrieve data from cache if it exists and hasn't expired. Retrieve data from cache if it exists and hasn't expired.
""" """
path = self._get_path(key) path = self._get_path(key)
if not path.exists(): if not path.exists():
return None return None
try: try:
with open(path, "r") as f: with open(path, "r") as f:
data = json.load(f) data = json.load(f)
# Check TTL # Check TTL
if data["expires_at"] < time.time(): if data["expires_at"] < time.time():
# Expired, delete it # Expired, delete it
path.unlink() path.unlink()
return None return None
return data["value"] return data["value"]
except (json.JSONDecodeError, KeyError, OSError): except (json.JSONDecodeError, KeyError, OSError):
return None return None
def set(self, key: str, value: Any, ttl_seconds: int = 3600): def set(self, key: str, value: Any, ttl_seconds: int = 3600):
""" """
Save data to cache with a TTL (default 1 hour). Save data to cache with a TTL (default 1 hour).
""" """
path = self._get_path(key) path = self._get_path(key)
data = { data = {
"value": value, "value": value,
"expires_at": time.time() + ttl_seconds, "expires_at": time.time() + ttl_seconds,
"key_debug": key # Store original key for debugging "key_debug": key # Store original key for debugging
} }
try: try:
with open(path, "w") as f: with open(path, "w") as f:
json.dump(data, f) json.dump(data, f)
except OSError as e: except OSError as e:
print(f"Cache Write Error: {e}") print(f"Cache Write Error: {e}")

View file

@ -1,65 +1,65 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from backend.api.routes import router as api_router from backend.api.routes import router as api_router
from backend.scheduler import start_scheduler from backend.scheduler import start_scheduler
import os import os
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup: Start scheduler # Startup: Start scheduler
scheduler = start_scheduler() scheduler = start_scheduler()
yield yield
# Shutdown: Scheduler shuts down automatically with process, or we can explicit shutdown if needed # Shutdown: Scheduler shuts down automatically with process, or we can explicit shutdown if needed
scheduler.shutdown() scheduler.shutdown()
app = FastAPI(title="Spotify Clone Backend", lifespan=lifespan) app = FastAPI(title="Spotify Clone Backend", lifespan=lifespan)
# CORS setup # CORS setup
origins = [ origins = [
"http://localhost:3000", "http://localhost:3000",
"http://127.0.0.1:3000", "http://127.0.0.1:3000",
] ]
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=origins, allow_origins=origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(api_router, prefix="/api") app.include_router(api_router, prefix="/api")
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
# Serve Static Frontend (Production Mode) # Serve Static Frontend (Production Mode)
STATIC_DIR = "static" STATIC_DIR = "static"
if os.path.exists(STATIC_DIR): if os.path.exists(STATIC_DIR):
app.mount("/_next", StaticFiles(directory=os.path.join(STATIC_DIR, "_next")), name="next_assets") 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 # Serve other static files (favicons etc) if they exist in root of static
# Or just fallback everything else to index.html for SPA # Or just fallback everything else to index.html for SPA
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
async def serve_spa(full_path: str): async def serve_spa(full_path: str):
# Check if file exists in static folder # Check if file exists in static folder
file_path = os.path.join(STATIC_DIR, full_path) file_path = os.path.join(STATIC_DIR, full_path)
if os.path.isfile(file_path): if os.path.isfile(file_path):
return FileResponse(file_path) return FileResponse(file_path)
# Otherwise return index.html # Otherwise return index.html
index_path = os.path.join(STATIC_DIR, "index.html") index_path = os.path.join(STATIC_DIR, "index.html")
if os.path.exists(index_path): if os.path.exists(index_path):
return FileResponse(index_path) return FileResponse(index_path)
return {"error": "Frontend not found"} return {"error": "Frontend not found"}
else: else:
@app.get("/") @app.get("/")
def read_root(): def read_root():
return {"message": "Spotify Clone Backend Running (Frontend not built/mounted)"} return {"message": "Spotify Clone Backend Running (Frontend not built/mounted)"}
@app.get("/health") @app.get("/health")
def health_check(): def health_check():
return {"status": "ok"} return {"status": "ok"}

View file

@ -1,88 +1,88 @@
import json import json
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional from typing import List, Dict, Optional
DATA_FILE = Path("backend/data/user_playlists.json") DATA_FILE = Path("backend/data/user_playlists.json")
class PlaylistManager: class PlaylistManager:
def __init__(self): def __init__(self):
DATA_FILE.parent.mkdir(parents=True, exist_ok=True) DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
if not DATA_FILE.exists(): if not DATA_FILE.exists():
self._save_data([]) self._save_data([])
def _load_data(self) -> List[Dict]: def _load_data(self) -> List[Dict]:
try: try:
with open(DATA_FILE, "r") as f: with open(DATA_FILE, "r") as f:
return json.load(f) return json.load(f)
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
return [] return []
def _save_data(self, data: List[Dict]): def _save_data(self, data: List[Dict]):
with open(DATA_FILE, "w") as f: with open(DATA_FILE, "w") as f:
json.dump(data, f, indent=4) json.dump(data, f, indent=4)
def get_all(self) -> List[Dict]: def get_all(self) -> List[Dict]:
return self._load_data() return self._load_data()
def get_by_id(self, playlist_id: str) -> Optional[Dict]: def get_by_id(self, playlist_id: str) -> Optional[Dict]:
playlists = self._load_data() playlists = self._load_data()
for p in playlists: for p in playlists:
if p["id"] == playlist_id: if p["id"] == playlist_id:
return p return p
return None return None
def create(self, name: str, description: str = "") -> Dict: def create(self, name: str, description: str = "") -> Dict:
playlists = self._load_data() playlists = self._load_data()
new_playlist = { new_playlist = {
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
"title": name, "title": name,
"description": description, "description": description,
"tracks": [], "tracks": [],
"cover_url": "https://placehold.co/400?text=Playlist", # Default placeholder "cover_url": "https://placehold.co/400?text=Playlist", # Default placeholder
"is_user_created": True "is_user_created": True
} }
playlists.append(new_playlist) playlists.append(new_playlist)
self._save_data(playlists) self._save_data(playlists)
return new_playlist return new_playlist
def update(self, playlist_id: str, name: str = None, description: str = None) -> Optional[Dict]: def update(self, playlist_id: str, name: str = None, description: str = None) -> Optional[Dict]:
playlists = self._load_data() playlists = self._load_data()
for p in playlists: for p in playlists:
if p["id"] == playlist_id: if p["id"] == playlist_id:
if name: p["title"] = name if name: p["title"] = name
if description: p["description"] = description if description: p["description"] = description
self._save_data(playlists) self._save_data(playlists)
return p return p
return None return None
def delete(self, playlist_id: str) -> bool: def delete(self, playlist_id: str) -> bool:
playlists = self._load_data() playlists = self._load_data()
initial_len = len(playlists) initial_len = len(playlists)
playlists = [p for p in playlists if p["id"] != playlist_id] playlists = [p for p in playlists if p["id"] != playlist_id]
if len(playlists) < initial_len: if len(playlists) < initial_len:
self._save_data(playlists) self._save_data(playlists)
return True return True
return False return False
def add_track(self, playlist_id: str, track: Dict) -> bool: def add_track(self, playlist_id: str, track: Dict) -> bool:
playlists = self._load_data() playlists = self._load_data()
for p in playlists: for p in playlists:
if p["id"] == playlist_id: if p["id"] == playlist_id:
# Check for duplicates? For now allow. # Check for duplicates? For now allow.
p["tracks"].append(track) p["tracks"].append(track)
# Update cover if it's the first track # Update cover if it's the first track
if len(p["tracks"]) == 1 and track.get("cover_url"): if len(p["tracks"]) == 1 and track.get("cover_url"):
p["cover_url"] = track["cover_url"] p["cover_url"] = track["cover_url"]
self._save_data(playlists) self._save_data(playlists)
return True return True
return False return False
def remove_track(self, playlist_id: str, track_id: str) -> bool: def remove_track(self, playlist_id: str, track_id: str) -> bool:
playlists = self._load_data() playlists = self._load_data()
for p in playlists: for p in playlists:
if p["id"] == playlist_id: if p["id"] == playlist_id:
p["tracks"] = [t for t in p["tracks"] if t.get("id") != track_id] p["tracks"] = [t for t in p["tracks"] if t.get("id") != track_id]
self._save_data(playlists) self._save_data(playlists)
return True return True
return False return False

View file

@ -1,10 +1,10 @@
fastapi==0.115.6 fastapi==0.115.6
uvicorn==0.34.0 uvicorn==0.34.0
spotdl spotdl
pydantic==2.10.4 pydantic==2.10.4
python-multipart==0.0.20 python-multipart==0.0.20
APScheduler>=3.10 APScheduler>=3.10
requests==2.32.3 requests==2.32.3
yt-dlp @ https://github.com/yt-dlp/yt-dlp/archive/master.zip yt-dlp @ https://github.com/yt-dlp/yt-dlp/archive/master.zip
ytmusicapi==1.9.1 ytmusicapi==1.9.1
syncedlyrics syncedlyrics

View file

@ -1,51 +1,51 @@
import subprocess import subprocess
import logging import logging
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
# Configure logging # Configure logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def update_ytdlp(): def update_ytdlp():
""" """
Check for and install the latest version of yt-dlp. Check for and install the latest version of yt-dlp.
""" """
logger.info("Scheduler: Checking for yt-dlp updates...") logger.info("Scheduler: Checking for yt-dlp updates...")
try: try:
# Run pip install --upgrade yt-dlp # Run pip install --upgrade yt-dlp
result = subprocess.run( result = subprocess.run(
["pip", "install", "--upgrade", "yt-dlp"], ["pip", "install", "--upgrade", "yt-dlp"],
capture_output=True, capture_output=True,
text=True, text=True,
check=True check=True
) )
logger.info(f"Scheduler: yt-dlp update completed.\n{result.stdout}") logger.info(f"Scheduler: yt-dlp update completed.\n{result.stdout}")
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(f"Scheduler: Failed to update yt-dlp.\nError: {e.stderr}") logger.error(f"Scheduler: Failed to update yt-dlp.\nError: {e.stderr}")
except Exception as e: except Exception as e:
logger.error(f"Scheduler: Unexpected error during update: {str(e)}") logger.error(f"Scheduler: Unexpected error during update: {str(e)}")
def start_scheduler(): def start_scheduler():
""" """
Initialize and start the background scheduler. Initialize and start the background scheduler.
""" """
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
# Schedule yt-dlp update every 24 hours # Schedule yt-dlp update every 24 hours
trigger = IntervalTrigger(days=1) trigger = IntervalTrigger(days=1)
scheduler.add_job( scheduler.add_job(
update_ytdlp, update_ytdlp,
trigger=trigger, trigger=trigger,
id="update_ytdlp_job", id="update_ytdlp_job",
name="Update yt-dlp daily", name="Update yt-dlp daily",
replace_existing=True replace_existing=True
) )
scheduler.start() scheduler.start()
logger.info("Scheduler: Started background scheduler. yt-dlp will update every 24 hours.") 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 # Run once on startup to ensure we are up to date immediately
# update_ytdlp() # Optional: Uncomment if we want strict enforcement on boot # update_ytdlp() # Optional: Uncomment if we want strict enforcement on boot
return scheduler 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 # Cache Service - Re-export CacheManager from backend.cache_manager
from backend.cache_manager import CacheManager from backend.cache_manager import CacheManager
__all__ = ['CacheManager'] __all__ = ['CacheManager']

View file

@ -1,19 +1,19 @@
# Spotify Service - Placeholder for YouTube Music API interactions # Spotify Service - Placeholder for YouTube Music API interactions
# Currently uses yt-dlp directly in routes.py # Currently uses yt-dlp directly in routes.py
class SpotifyService: class SpotifyService:
""" """
Placeholder service for Spotify/YouTube Music integration. Placeholder service for Spotify/YouTube Music integration.
Currently, all music operations are handled directly in routes.py using yt-dlp. Currently, all music operations are handled directly in routes.py using yt-dlp.
This class exists to satisfy imports but has minimal functionality. This class exists to satisfy imports but has minimal functionality.
""" """
def __init__(self): def __init__(self):
pass pass
def search(self, query: str, limit: int = 20): def search(self, query: str, limit: int = 20):
"""Search for music - placeholder""" """Search for music - placeholder"""
return [] return []
def get_track(self, track_id: str): def get_track(self, track_id: str):
"""Get track info - placeholder""" """Get track info - placeholder"""
return None return None

View file

@ -1,17 +1,17 @@
from ytmusicapi import YTMusic from ytmusicapi import YTMusic
import json import json
yt = YTMusic() yt = YTMusic()
seed_id = "hDrFd1W8fvU" seed_id = "hDrFd1W8fvU"
print(f"Fetching watch playlist for {seed_id}...") print(f"Fetching watch playlist for {seed_id}...")
results = yt.get_watch_playlist(videoId=seed_id, limit=5) results = yt.get_watch_playlist(videoId=seed_id, limit=5)
if 'tracks' in results: if 'tracks' in results:
print(f"Found {len(results['tracks'])} tracks.") print(f"Found {len(results['tracks'])} tracks.")
if len(results['tracks']) > 0: if len(results['tracks']) > 0:
first_track = results['tracks'][0] first_track = results['tracks'][0]
print(json.dumps(first_track, indent=2)) print(json.dumps(first_track, indent=2))
print("Keys:", first_track.keys()) print("Keys:", first_track.keys())
else: else:
print("No 'tracks' key in results") print("No 'tracks' key in results")
print(results.keys()) print(results.keys())

View file

@ -1,11 +1,11 @@
services: services:
spotify-clone: spotify-clone:
image: vndangkhoa/spotify-clone:latest image: vndangkhoa/spotify-clone:latest
container_name: spotify-clone container_name: spotify-clone
restart: always restart: always
network_mode: bridge # Synology often prefers explicit bridge or host network_mode: bridge # Synology often prefers explicit bridge or host
ports: ports:
- "3110:3000" # Web UI - "3110:3000" # Web UI
volumes: volumes:
- ./data:/app/backend/data - ./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 type { Metadata } from "next";
import { Outfit } from "next/font/google"; import { Outfit } from "next/font/google";
import "./globals.css"; import "./globals.css";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import PlayerBar from "@/components/PlayerBar"; import PlayerBar from "@/components/PlayerBar";
import MobileNav from "@/components/MobileNav"; import MobileNav from "@/components/MobileNav";
import RightSidebar from "@/components/RightSidebar"; import RightSidebar from "@/components/RightSidebar";
import { PlayerProvider } from "@/context/PlayerContext"; import { PlayerProvider } from "@/context/PlayerContext";
import { LibraryProvider } from "@/context/LibraryContext"; import { LibraryProvider } from "@/context/LibraryContext";
import ServiceWorkerRegistration from "@/components/ServiceWorkerRegistration";
const outfit = Outfit({
subsets: ["latin"], const outfit = Outfit({
variable: "--font-outfit", subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"], variable: "--font-outfit",
}); weight: ["300", "400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: "Audiophile Web Player", export const metadata: Metadata = {
description: "High-Fidelity Local-First Music Player", title: "Audiophile Web Player",
manifest: "/manifest.json", description: "High-Fidelity Local-First Music Player",
referrer: "no-referrer", manifest: "/manifest.json",
appleWebApp: { referrer: "no-referrer",
capable: true, appleWebApp: {
statusBarStyle: "black-translucent", capable: true,
title: "Audiophile Web Player", statusBarStyle: "black-translucent",
}, title: "Audiophile Web Player",
other: { },
"mobile-web-app-capable": "yes", other: {
}, "mobile-web-app-capable": "yes",
icons: { },
icon: "/icons/icon-192x192.png", icons: {
apple: "/icons/icon-512x512.png", icon: "/icons/icon-192x192.png",
}, apple: "/icons/icon-512x512.png",
}; },
};
export default function RootLayout({
children, export default function RootLayout({
}: Readonly<{ children,
children: React.ReactNode; }: Readonly<{
}>) { children: React.ReactNode;
return ( }>) {
<html lang="en"> return (
<body <html lang="en">
className={`${outfit.variable} antialiased bg-black h-screen flex flex-col overflow-hidden text-white font-sans`} <body
> className={`${outfit.variable} antialiased bg-black h-screen flex flex-col overflow-hidden text-white font-sans`}
<PlayerProvider> >
<LibraryProvider> <ServiceWorkerRegistration />
<div className="flex-1 flex overflow-hidden p-2 gap-2 mb-[64px] md:mb-0"> <PlayerProvider>
<Sidebar /> <LibraryProvider>
<main className="flex-1 bg-[#121212] rounded-lg overflow-y-auto relative no-scrollbar"> <div className="flex-1 flex overflow-hidden p-2 gap-2 mb-[64px] md:mb-0">
{children} <Sidebar />
</main> <main className="flex-1 bg-[#121212] rounded-lg overflow-y-auto relative no-scrollbar">
<RightSidebar /> {children}
</div> </main>
<PlayerBar /> <RightSidebar />
<MobileNav /> </div>
</LibraryProvider> <PlayerBar />
</PlayerProvider> <MobileNav />
</body> </LibraryProvider>
</html> </PlayerProvider>
); </body>
} </html>
);
}

View file

@ -1,156 +1,156 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { dbService } from "@/services/db"; import { dbService } from "@/services/db";
import { useLibrary } from "@/context/LibraryContext"; import { useLibrary } from "@/context/LibraryContext";
import Link from "next/link"; import Link from "next/link";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import CreatePlaylistModal from "@/components/CreatePlaylistModal"; import CreatePlaylistModal from "@/components/CreatePlaylistModal";
import CoverImage from "@/components/CoverImage"; import CoverImage from "@/components/CoverImage";
export default function LibraryPage() { export default function LibraryPage() {
const { userPlaylists: playlists, libraryItems, refreshLibrary: refresh, activeFilter: activeTab, setActiveFilter: setActiveTab } = useLibrary(); const { userPlaylists: playlists, libraryItems, refreshLibrary: refresh, activeFilter: activeTab, setActiveFilter: setActiveTab } = useLibrary();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const handleCreatePlaylist = async (name: string) => { const handleCreatePlaylist = async (name: string) => {
await dbService.createPlaylist(name); await dbService.createPlaylist(name);
refresh(); refresh();
}; };
const showPlaylists = activeTab === 'all' || activeTab === 'playlists'; const showPlaylists = activeTab === 'all' || activeTab === 'playlists';
const showAlbums = activeTab === 'all' || activeTab === 'albums'; const showAlbums = activeTab === 'all' || activeTab === 'albums';
const showArtists = activeTab === 'all' || activeTab === 'artists'; const showArtists = activeTab === 'all' || activeTab === 'artists';
// Filter items based on type // Filter items based on type
const albums = libraryItems.filter(item => item.type === 'Album'); const albums = libraryItems.filter(item => item.type === 'Album');
const artists = libraryItems.filter(item => item.type === 'Artist'); const artists = libraryItems.filter(item => item.type === 'Artist');
const browsePlaylists = libraryItems.filter(item => item.type === 'Playlist'); const browsePlaylists = libraryItems.filter(item => item.type === 'Playlist');
return ( return (
<div className="bg-gradient-to-b from-[#1e1e1e] to-black min-h-screen p-4 pb-24"> <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"> <div className="flex justify-between items-center mb-6 pt-4">
<h1 className="text-2xl font-bold text-white">Your Library</h1> <h1 className="text-2xl font-bold text-white">Your Library</h1>
<button onClick={() => setIsCreateModalOpen(true)}> <button onClick={() => setIsCreateModalOpen(true)}>
<Plus className="text-white w-6 h-6" /> <Plus className="text-white w-6 h-6" />
</button> </button>
</div> </div>
<div className="flex gap-2 mb-6 overflow-x-auto no-scrollbar"> <div className="flex gap-2 mb-6 overflow-x-auto no-scrollbar">
<button <button
onClick={() => setActiveTab('all')} 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'}`} 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 All
</button> </button>
<button <button
onClick={() => setActiveTab('playlists')} 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'}`} 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 Playlists
</button> </button>
<button <button
onClick={() => setActiveTab('albums')} 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'}`} 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 Albums
</button> </button>
<button <button
onClick={() => setActiveTab('artists')} 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'}`} 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 Artists
</button> </button>
</div> </div>
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 md:gap-4"> <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 */} {/* Playlists & Liked Songs */}
{showPlaylists && ( {showPlaylists && (
<> <>
<Link href="/collection/tracks"> <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"> <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> <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> <p className="text-white/80 text-xs z-10">Auto-generated</p>
</div> </div>
</Link> </Link>
{playlists.map((playlist) => ( {playlists.map((playlist) => (
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}> <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="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"> <div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
<CoverImage <CoverImage
src={playlist.cover_url} src={playlist.cover_url}
alt={playlist.title} alt={playlist.title}
className="w-full h-full object-cover" className="w-full h-full object-cover"
fallbackText={playlist.title?.substring(0, 2).toUpperCase()} fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
/> />
</div> </div>
<h3 className="text-white font-bold text-xs md:text-sm truncate">{playlist.title}</h3> <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> <p className="text-[#a7a7a7] text-[10px] md:text-xs">Playlist You</p>
</div> </div>
</Link> </Link>
))} ))}
{browsePlaylists.map((playlist) => ( {browsePlaylists.map((playlist) => (
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}> <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="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"> <div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
<CoverImage <CoverImage
src={playlist.cover_url} src={playlist.cover_url}
alt={playlist.title} alt={playlist.title}
className="w-full h-full object-cover" className="w-full h-full object-cover"
fallbackText={playlist.title?.substring(0, 2).toUpperCase()} fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
/> />
</div> </div>
<h3 className="text-white font-bold text-xs md:text-sm truncate">{playlist.title}</h3> <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> <p className="text-[#a7a7a7] text-[10px] md:text-xs">Playlist Made for you</p>
</div> </div>
</Link> </Link>
))} ))}
</> </>
)} )}
{/* Artists Content (Circular Images) */} {/* Artists Content (Circular Images) */}
{showArtists && artists.map((artist) => ( {showArtists && artists.map((artist) => (
<Link href={`/artist?name=${encodeURIComponent(artist.title)}`} key={artist.id}> <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="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"> <div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-full shadow-lg">
<CoverImage <CoverImage
src={artist.cover_url} src={artist.cover_url}
alt={artist.title} alt={artist.title}
className="w-full h-full object-cover rounded-full" className="w-full h-full object-cover rounded-full"
fallbackText={artist.title?.substring(0, 2).toUpperCase()} fallbackText={artist.title?.substring(0, 2).toUpperCase()}
/> />
</div> </div>
<h3 className="text-white font-bold text-xs md:text-sm truncate w-full">{artist.title}</h3> <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> <p className="text-[#a7a7a7] text-[10px] md:text-xs">Artist</p>
</div> </div>
</Link> </Link>
))} ))}
{/* Albums Content */} {/* Albums Content */}
{showAlbums && albums.map((album) => ( {showAlbums && albums.map((album) => (
<Link href={`/playlist?id=${album.id}`} key={album.id}> <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="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"> <div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
<CoverImage <CoverImage
src={album.cover_url} src={album.cover_url}
alt={album.title} alt={album.title}
className="w-full h-full object-cover" className="w-full h-full object-cover"
fallbackText={album.title?.substring(0, 2).toUpperCase()} fallbackText={album.title?.substring(0, 2).toUpperCase()}
/> />
</div> </div>
<h3 className="text-white font-bold text-xs md:text-sm truncate">{album.title}</h3> <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> <p className="text-[#a7a7a7] text-[10px] md:text-xs">Album {album.creator || 'Spotify'}</p>
</div> </div>
</Link> </Link>
))} ))}
</div> </div>
<CreatePlaylistModal <CreatePlaylistModal
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)} onClose={() => setIsCreateModalOpen(false)}
onCreate={handleCreatePlaylist} onCreate={handleCreatePlaylist}
/> />
</div> </div>
); );
} }

File diff suppressed because it is too large Load diff

View file

@ -1,102 +1,102 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Plus, X } from "lucide-react"; import { Plus, X } from "lucide-react";
interface AddToPlaylistModalProps { interface AddToPlaylistModalProps {
track: any; track: any;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
} }
export default function AddToPlaylistModal({ track, isOpen, onClose }: AddToPlaylistModalProps) { export default function AddToPlaylistModal({ track, isOpen, onClose }: AddToPlaylistModalProps) {
const [playlists, setPlaylists] = useState<any[]>([]); const [playlists, setPlaylists] = useState<any[]>([]);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
fetch(`${apiUrl}/api/playlists`) fetch(`${apiUrl}/api/playlists`)
.then(res => res.json()) .then(res => res.json())
.then(data => setPlaylists(data)) .then(data => setPlaylists(data))
.catch(err => console.error(err)); .catch(err => console.error(err));
} }
}, [isOpen]); }, [isOpen]);
const handleAddToPlaylist = async (playlistId: string) => { const handleAddToPlaylist = async (playlistId: string) => {
try { try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
await fetch(`${apiUrl}/api/playlists/${playlistId}/tracks`, { await fetch(`${apiUrl}/api/playlists/${playlistId}/tracks`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(track) body: JSON.stringify(track)
}); });
alert(`Added to playlist!`); alert(`Added to playlist!`);
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("Failed to add track:", error); console.error("Failed to add track:", error);
} }
}; };
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4"> <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="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"> <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> <h2 className="text-xl font-bold text-white">Add to Playlist</h2>
<button onClick={onClose} className="text-white hover:text-gray-300"> <button onClick={onClose} className="text-white hover:text-gray-300">
<X className="w-6 h-6" /> <X className="w-6 h-6" />
</button> </button>
</div> </div>
<div className="p-2 max-h-[60vh] overflow-y-auto no-scrollbar"> <div className="p-2 max-h-[60vh] overflow-y-auto no-scrollbar">
{playlists.length === 0 ? ( {playlists.length === 0 ? (
<div className="p-4 text-center text-spotify-text-muted">No playlists found. Create one first!</div> <div className="p-4 text-center text-spotify-text-muted">No playlists found. Create one first!</div>
) : ( ) : (
playlists.map((playlist) => ( playlists.map((playlist) => (
<div <div
key={playlist.id} key={playlist.id}
onClick={() => handleAddToPlaylist(playlist.id)} onClick={() => handleAddToPlaylist(playlist.id)}
className="flex items-center gap-3 p-3 hover:bg-[#3e3e3e] rounded-md cursor-pointer transition text-white" 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"> <div className="w-10 h-10 bg-[#121212] flex items-center justify-center rounded overflow-hidden">
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? ( {playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
<img src={playlist.cover_url} alt="" className="w-full h-full object-cover" /> <img src={playlist.cover_url} alt="" className="w-full h-full object-cover" />
) : ( ) : (
<span className="text-lg">🎵</span> <span className="text-lg">🎵</span>
)} )}
</div> </div>
<span className="font-medium truncate">{playlist.title}</span> <span className="font-medium truncate">{playlist.title}</span>
</div> </div>
)) ))
)} )}
</div> </div>
<div className="p-4 border-t border-[#3e3e3e]"> <div className="p-4 border-t border-[#3e3e3e]">
<button <button
onClick={() => { onClick={() => {
const name = prompt("New Playlist Name"); const name = prompt("New Playlist Name");
if (name) { if (name) {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
fetch(`${apiUrl}/api/playlists`, { fetch(`${apiUrl}/api/playlists`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }) body: JSON.stringify({ name })
}).then(() => { }).then(() => {
// Refresh list // Refresh list
const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
fetch(`${apiUrl}/api/playlists`) fetch(`${apiUrl}/api/playlists`)
.then(res => res.json()) .then(res => res.json())
.then(data => setPlaylists(data)); .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" 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 <Plus className="w-5 h-5" /> New Playlist
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View file

@ -1,150 +1,150 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
interface Metric { interface Metric {
time: number; time: number;
text: string; text: string;
} }
interface LyricsDetailProps { interface LyricsDetailProps {
track: any; track: any;
currentTime: number; currentTime: number;
onClose: () => void; onClose: () => void;
onSeek?: (time: number) => void; onSeek?: (time: number) => void;
isInSidebar?: boolean; isInSidebar?: boolean;
} }
const LyricsDetail: React.FC<LyricsDetailProps> = ({ track, currentTime, onClose, onSeek, isInSidebar = false }) => { const LyricsDetail: React.FC<LyricsDetailProps> = ({ track, currentTime, onClose, onSeek, isInSidebar = false }) => {
const [lyrics, setLyrics] = useState<Metric[]>([]); const [lyrics, setLyrics] = useState<Metric[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const activeLineRef = useRef<HTMLDivElement>(null); const activeLineRef = useRef<HTMLDivElement>(null);
// Fetch Lyrics on Track Change // Fetch Lyrics on Track Change
useEffect(() => { useEffect(() => {
const fetchLyrics = async () => { const fetchLyrics = async () => {
if (!track) return; if (!track) return;
setIsLoading(true); setIsLoading(true);
try { try {
// Pass title and artist for LRCLIB fallback // Pass title and artist for LRCLIB fallback
const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; 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 url = `${apiUrl}/api/lyrics?id=${track.id}&title=${encodeURIComponent(track.title)}&artist=${encodeURIComponent(track.artist)}`;
const res = await fetch(url); const res = await fetch(url);
const data = await res.json(); const data = await res.json();
setLyrics(data || []); setLyrics(data || []);
} catch (error) { } catch (error) {
console.error("Error fetching lyrics:", error); console.error("Error fetching lyrics:", error);
setLyrics([]); setLyrics([]);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
fetchLyrics(); fetchLyrics();
}, [track?.id]); }, [track?.id]);
// Find active line index // Find active line index
const activeIndex = lyrics.findIndex((line, index) => { const activeIndex = lyrics.findIndex((line, index) => {
const nextLine = lyrics[index + 1]; const nextLine = lyrics[index + 1];
// Removing large offset to match music exactly. // Removing large offset to match music exactly.
// using small buffer (0.05) just for rounding safety // using small buffer (0.05) just for rounding safety
const timeWithOffset = currentTime + 0.05; const timeWithOffset = currentTime + 0.05;
return timeWithOffset >= line.time && (!nextLine || timeWithOffset < nextLine.time); return timeWithOffset >= line.time && (!nextLine || timeWithOffset < nextLine.time);
}); });
// Auto-scroll to active line // Auto-scroll to active line
// Auto-scroll to active line // Auto-scroll to active line
useEffect(() => { useEffect(() => {
if (activeLineRef.current && scrollContainerRef.current) { if (activeLineRef.current && scrollContainerRef.current) {
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
const activeLine = activeLineRef.current; const activeLine = activeLineRef.current;
// Calculate position to center (or offset) the active line // Calculate position to center (or offset) the active line
// Reverted to center (50%) as requested // Reverted to center (50%) as requested
const containerHeight = container.clientHeight; const containerHeight = container.clientHeight;
const lineTop = activeLine.offsetTop; const lineTop = activeLine.offsetTop;
const lineHeight = activeLine.offsetHeight; const lineHeight = activeLine.offsetHeight;
// Target scroll position: // Target scroll position:
// Line Top - (Screen Height * 0.50) + (Half Line Height) // Line Top - (Screen Height * 0.50) + (Half Line Height)
const targetScrollTop = lineTop - (containerHeight * 0.50) + (lineHeight / 2); const targetScrollTop = lineTop - (containerHeight * 0.50) + (lineHeight / 2);
container.scrollTo({ container.scrollTo({
top: targetScrollTop, top: targetScrollTop,
behavior: 'smooth' behavior: 'smooth'
}); });
} }
}, [activeIndex]); }, [activeIndex]);
if (!track) return null; if (!track) return null;
return ( return (
<div className={`${isInSidebar ? 'relative h-full' : 'absolute inset-0'} flex flex-col bg-transparent text-white`}> <div className={`${isInSidebar ? 'relative h-full' : 'absolute inset-0'} flex flex-col bg-transparent text-white`}>
{/* Header - only show when NOT in sidebar */} {/* Header - only show when NOT in sidebar */}
{!isInSidebar && ( {!isInSidebar && (
<div className="flex items-center justify-between p-6 bg-gradient-to-b from-black/80 to-transparent z-10"> <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"> <div className="flex-1 min-w-0">
<h2 className="text-xl font-bold truncate">Lyrics</h2> <h2 className="text-xl font-bold truncate">Lyrics</h2>
<p className="text-white/60 text-xs truncate uppercase tracking-widest"> <p className="text-white/60 text-xs truncate uppercase tracking-widest">
{track.artist} {track.artist}
</p> </p>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="p-2 bg-white/10 rounded-full hover:bg-white/20 transition backdrop-blur-md" 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"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" /> <path d="M18 6L6 18M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </div>
)} )}
{/* Lyrics Container */} {/* Lyrics Container */}
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
className="flex-1 overflow-y-auto px-6 text-center space-y-6 no-scrollbar mask-linear-gradient" className="flex-1 overflow-y-auto px-6 text-center space-y-6 no-scrollbar mask-linear-gradient"
> >
{isLoading ? ( {isLoading ? (
<div className="h-full flex items-center justify-center"> <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 className="animate-spin rounded-full h-10 w-10 border-b-2 border-green-500"></div>
</div> </div>
) : lyrics.length === 0 ? ( ) : lyrics.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-white/50 text-base"> <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 className="font-bold mb-2 text-xl">Looks like we don't have lyrics for this song.</p>
<p>Enjoy the vibe!</p> <p>Enjoy the vibe!</p>
</div> </div>
) : ( ) : (
<div className="pt-[50vh] pb-[50vh] max-w-4xl mx-auto"> {/* Reverted to center padding, added max-width */} <div className="pt-[50vh] pb-[50vh] max-w-4xl mx-auto"> {/* Reverted to center padding, added max-width */}
{lyrics.map((line, index) => { {lyrics.map((line, index) => {
const isActive = index === activeIndex; const isActive = index === activeIndex;
const isPast = index < activeIndex; const isPast = index < activeIndex;
return ( return (
<div <div
key={index} key={index}
ref={isActive ? activeLineRef : null} ref={isActive ? activeLineRef : null}
className={` className={`
transition-all duration-500 ease-out origin-center transition-all duration-500 ease-out origin-center
${isActive ${isActive
? "text-3xl md:text-4xl font-bold text-white scale-100 opacity-100 drop-shadow-lg py-4" ? "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" : "text-xl md:text-2xl font-medium text-white/30 hover:text-white/60 blur-[0px] scale-95 py-2"
} }
cursor-pointer cursor-pointer
`} `}
onClick={() => { onClick={() => {
if (onSeek) onSeek(line.time); if (onSeek) onSeek(line.time);
}} }}
> >
{line.text} {line.text}
</div> </div>
); );
})} })}
</div> </div>
)} )}
</div> </div>
</div> </div>
); );
}; };
export default LyricsDetail; export default LyricsDetail;

View file

@ -1,30 +1,30 @@
"use client"; "use client";
import { Home, Search, Library } from "lucide-react"; import { Home, Search, Library } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
export default function MobileNav() { export default function MobileNav() {
const pathname = usePathname(); const pathname = usePathname();
const isActive = (path: string) => pathname === path; const isActive = (path: string) => pathname === path;
return ( 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"> <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'}`}> <Link href="/" className={`flex flex-col items-center gap-1 ${isActive('/') ? 'text-white' : 'text-neutral-400'}`}>
<Home size={24} fill={isActive('/') ? "currentColor" : "none"} /> <Home size={24} fill={isActive('/') ? "currentColor" : "none"} />
<span className="text-[10px]">Home</span> <span className="text-[10px]">Home</span>
</Link> </Link>
<Link href="/search" className={`flex flex-col items-center gap-1 ${isActive('/search') ? 'text-white' : 'text-neutral-400'}`}> <Link href="/search" className={`flex flex-col items-center gap-1 ${isActive('/search') ? 'text-white' : 'text-neutral-400'}`}>
<Search size={24} /> <Search size={24} />
<span className="text-[10px]">Search</span> <span className="text-[10px]">Search</span>
</Link> </Link>
<Link href="/library" className={`flex flex-col items-center gap-1 ${isActive('/library') ? 'text-white' : 'text-neutral-400'}`}> <Link href="/library" className={`flex flex-col items-center gap-1 ${isActive('/library') ? 'text-white' : 'text-neutral-400'}`}>
<Library size={24} /> <Library size={24} />
<span className="text-[10px]">Library</span> <span className="text-[10px]">Library</span>
</Link> </Link>
</div> </div>
); );
} }

View file

@ -11,6 +11,7 @@ import LyricsDetail from './LyricsDetail';
export default function PlayerBar() { export default function PlayerBar() {
const { currentTrack, isPlaying, isBuffering, togglePlay, setBuffering, likedTracks, toggleLike, nextTrack, prevTrack, shuffle, toggleShuffle, repeatMode, toggleRepeat, audioQuality, isLyricsOpen, toggleLyrics } = usePlayer(); 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 audioRef = useRef<HTMLAudioElement | null>(null);
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1); const [volume, setVolume] = useState(1);
@ -22,6 +23,70 @@ export default function PlayerBar() {
const [isFullScreenPlayerOpen, setIsFullScreenPlayerOpen] = useState(false); const [isFullScreenPlayerOpen, setIsFullScreenPlayerOpen] = useState(false);
const [isCoverModalOpen, setIsCoverModalOpen] = 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(() => { useEffect(() => {
if (currentTrack && audioRef.current && currentTrack.url) { if (currentTrack && audioRef.current && currentTrack.url) {
// Prevent reloading if URL hasn't changed // 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"; "use client";
import { Home, Search, Library, Plus, Heart, RefreshCcw } from "lucide-react"; import { Home, Search, Library, Plus, Heart, RefreshCcw } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePlayer } from "@/context/PlayerContext"; import { usePlayer } from "@/context/PlayerContext";
import { useState } from "react"; import { useState } from "react";
import CreatePlaylistModal from "./CreatePlaylistModal"; import CreatePlaylistModal from "./CreatePlaylistModal";
import { dbService } from "@/services/db"; import { dbService } from "@/services/db";
import { useLibrary } from "@/context/LibraryContext"; import { useLibrary } from "@/context/LibraryContext";
import Logo from "./Logo"; import Logo from "./Logo";
import CoverImage from "./CoverImage"; import CoverImage from "./CoverImage";
export default function Sidebar() { export default function Sidebar() {
const { likedTracks } = usePlayer(); const { likedTracks } = usePlayer();
const { userPlaylists, libraryItems, refreshLibrary: refresh, activeFilter, setActiveFilter } = useLibrary(); const { userPlaylists, libraryItems, refreshLibrary: refresh, activeFilter, setActiveFilter } = useLibrary();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [updateStatus, setUpdateStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); const [updateStatus, setUpdateStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const handleCreatePlaylist = async (name: string) => { const handleCreatePlaylist = async (name: string) => {
await dbService.createPlaylist(name); await dbService.createPlaylist(name);
refresh(); refresh();
}; };
const handleDeletePlaylist = async (e: React.MouseEvent, id: string) => { const handleDeletePlaylist = async (e: React.MouseEvent, id: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (confirm("Delete this playlist?")) { if (confirm("Delete this playlist?")) {
await dbService.deletePlaylist(id); await dbService.deletePlaylist(id);
refresh(); refresh();
} }
}; };
const handleUpdateYtdlp = async () => { const handleUpdateYtdlp = async () => {
if (isUpdating) return; if (isUpdating) return;
setIsUpdating(true); setIsUpdating(true);
setUpdateStatus('loading'); setUpdateStatus('loading');
try { try {
const response = await fetch('/api/system/update-ytdlp', { method: 'POST' }); const response = await fetch('/api/system/update-ytdlp', { method: 'POST' });
if (response.ok) { if (response.ok) {
setUpdateStatus('success'); setUpdateStatus('success');
setTimeout(() => setUpdateStatus('idle'), 5000); setTimeout(() => setUpdateStatus('idle'), 5000);
} else { } else {
setUpdateStatus('error'); setUpdateStatus('error');
} }
} catch (error) { } catch (error) {
console.error("Failed to update yt-dlp:", error); console.error("Failed to update yt-dlp:", error);
setUpdateStatus('error'); setUpdateStatus('error');
} finally { } finally {
setIsUpdating(false); setIsUpdating(false);
} }
}; };
// Filtering Logic // Filtering Logic
const showPlaylists = activeFilter === 'all' || activeFilter === 'playlists'; const showPlaylists = activeFilter === 'all' || activeFilter === 'playlists';
const showArtists = activeFilter === 'all' || activeFilter === 'artists'; const showArtists = activeFilter === 'all' || activeFilter === 'artists';
const showAlbums = activeFilter === 'all' || activeFilter === 'albums'; const showAlbums = activeFilter === 'all' || activeFilter === 'albums';
const artists = libraryItems.filter(i => i.type === 'Artist'); const artists = libraryItems.filter(i => i.type === 'Artist');
const albums = libraryItems.filter(i => i.type === 'Album'); const albums = libraryItems.filter(i => i.type === 'Album');
const browsePlaylists = libraryItems.filter(i => i.type === 'Playlist'); const browsePlaylists = libraryItems.filter(i => i.type === 'Playlist');
return ( return (
<aside className="hidden md:flex flex-col w-[280px] bg-black h-full gap-2 p-2"> <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"> <div className="bg-[#121212] rounded-lg p-4 flex flex-col gap-4">
{/* Logo replaces Home link */} {/* Logo replaces Home link */}
<Link href="/" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer"> <Link href="/" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer">
<Logo /> <Logo />
</Link> </Link>
<Link href="/search" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer"> <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" /> <Search className="w-6 h-6" />
<span className="font-bold">Search</span> <span className="font-bold">Search</span>
</Link> </Link>
</div> </div>
<div className="bg-[#121212] rounded-lg flex-1 flex flex-col overflow-hidden"> <div className="bg-[#121212] rounded-lg flex-1 flex flex-col overflow-hidden">
<div className="p-4 shadow-md z-10"> <div className="p-4 shadow-md z-10">
<div className="flex items-center justify-between text-spotify-text-muted mb-4"> <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"> <Link href="/library" className="flex items-center gap-2 hover:text-white transition cursor-pointer">
<Library className="w-6 h-6" /> <Library className="w-6 h-6" />
<span className="font-bold">Your Library</span> <span className="font-bold">Your Library</span>
</Link> </Link>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={() => setIsCreateModalOpen(true)} className="hover:text-white hover:scale-110 transition"> <button onClick={() => setIsCreateModalOpen(true)} className="hover:text-white hover:scale-110 transition">
<Plus className="w-6 h-6" /> <Plus className="w-6 h-6" />
</button> </button>
</div> </div>
</div> </div>
{/* Filters */} {/* Filters */}
<div className="flex gap-2 mt-4 overflow-x-auto no-scrollbar"> <div className="flex gap-2 mt-4 overflow-x-auto no-scrollbar">
{['Playlists', 'Artists', 'Albums'].map((filter) => { {['Playlists', 'Artists', 'Albums'].map((filter) => {
const key = filter.toLowerCase() as any; const key = filter.toLowerCase() as any;
const isActive = activeFilter === key; const isActive = activeFilter === key;
return ( return (
<button <button
key={filter} key={filter}
onClick={() => setActiveFilter(isActive ? 'all' : key)} 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]'}`} 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} {filter}
</button> </button>
); );
})} })}
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto px-2 no-scrollbar"> <div className="flex-1 overflow-y-auto px-2 no-scrollbar">
{/* Liked Songs (Always top if 'Playlists' or 'All') */} {/* Liked Songs (Always top if 'Playlists' or 'All') */}
{showPlaylists && ( {showPlaylists && (
<Link href="/collection/tracks"> <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="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"> <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" /> <Heart className="w-6 h-6 text-white fill-white" />
</div> </div>
<div> <div>
<h3 className="text-white font-medium">Liked Songs</h3> <h3 className="text-white font-medium">Liked Songs</h3>
<p className="text-sm text-spotify-text-muted">Playlist {likedTracks.size} songs</p> <p className="text-sm text-spotify-text-muted">Playlist {likedTracks.size} songs</p>
</div> </div>
</div> </div>
</Link> </Link>
)} )}
{/* User Playlists */} {/* User Playlists */}
{showPlaylists && userPlaylists.map((playlist) => ( {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"> <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"> <Link href={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3">
<CoverImage <CoverImage
src={playlist.cover_url} src={playlist.cover_url}
alt={playlist.title || ''} alt={playlist.title || ''}
className="w-12 h-12 rounded object-cover" className="w-12 h-12 rounded object-cover"
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-white font-medium truncate">{playlist.title}</h3> <h3 className="text-white font-medium truncate">{playlist.title}</h3>
<p className="text-sm text-spotify-text-muted truncate">Playlist You</p> <p className="text-sm text-spotify-text-muted truncate">Playlist You</p>
</div> </div>
</Link> </Link>
<button <button
onClick={(e) => handleDeletePlaylist(e, playlist.id)} onClick={(e) => handleDeletePlaylist(e, playlist.id)}
className="absolute right-2 text-zinc-400 hover:text-white opacity-0 group-hover:opacity-100 transition" 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"> <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="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line> <line x1="6" y1="6" x2="18" y2="18"></line>
</svg> </svg>
</button> </button>
</div> </div>
))} ))}
{/* Fake/Browse Playlists */} {/* Fake/Browse Playlists */}
{showPlaylists && browsePlaylists.map((playlist) => ( {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"> <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"> <Link href={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3">
<CoverImage <CoverImage
src={playlist.cover_url} src={playlist.cover_url}
alt={playlist.title || ''} alt={playlist.title || ''}
className="w-12 h-12 rounded object-cover" className="w-12 h-12 rounded object-cover"
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-white font-medium truncate">{playlist.title}</h3> <h3 className="text-white font-medium truncate">{playlist.title}</h3>
<p className="text-sm text-spotify-text-muted truncate">Playlist Made for you</p> <p className="text-sm text-spotify-text-muted truncate">Playlist Made for you</p>
</div> </div>
</Link> </Link>
</div> </div>
))} ))}
{/* Artists */} {/* Artists */}
{showArtists && artists.map((artist) => ( {showArtists && artists.map((artist) => (
<Link href={`/artist?name=${encodeURIComponent(artist.title)}`} key={artist.id}> <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"> <div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
<CoverImage <CoverImage
src={artist.cover_url} src={artist.cover_url}
alt={artist.title} alt={artist.title}
className="w-12 h-12 rounded-full object-cover" className="w-12 h-12 rounded-full object-cover"
fallbackText={artist.title?.substring(0, 2).toUpperCase()} fallbackText={artist.title?.substring(0, 2).toUpperCase()}
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-white font-medium truncate">{artist.title}</h3> <h3 className="text-white font-medium truncate">{artist.title}</h3>
<p className="text-sm text-spotify-text-muted truncate">Artist</p> <p className="text-sm text-spotify-text-muted truncate">Artist</p>
</div> </div>
</div> </div>
</Link> </Link>
))} ))}
{/* Albums */} {/* Albums */}
{showAlbums && albums.map((album) => ( {showAlbums && albums.map((album) => (
<Link href={`/search?q=${encodeURIComponent(album.title)}`} key={album.id}> <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"> <div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
<CoverImage <CoverImage
src={album.cover_url} src={album.cover_url}
alt={album.title} alt={album.title}
className="w-12 h-12 rounded object-cover" className="w-12 h-12 rounded object-cover"
fallbackText="💿" fallbackText="💿"
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-white font-medium truncate">{album.title}</h3> <h3 className="text-white font-medium truncate">{album.title}</h3>
<p className="text-sm text-spotify-text-muted truncate">Album {album.creator || 'Spotify'}</p> <p className="text-sm text-spotify-text-muted truncate">Album {album.creator || 'Spotify'}</p>
</div> </div>
</div> </div>
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
{/* System Section */} {/* System Section */}
<div className="bg-[#121212] rounded-lg p-2 mt-auto"> <div className="bg-[#121212] rounded-lg p-2 mt-auto">
<button <button
onClick={handleUpdateYtdlp} onClick={handleUpdateYtdlp}
disabled={isUpdating} 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' : 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' : updateStatus === 'error' ? 'bg-red-600/20 text-red-400' :
'text-spotify-text-muted hover:text-white hover:bg-[#1a1a1a]' 'text-spotify-text-muted hover:text-white hover:bg-[#1a1a1a]'
}`} }`}
title="Update Core (yt-dlp) to fix playback errors" title="Update Core (yt-dlp) to fix playback errors"
> >
<RefreshCcw className={`w-5 h-5 ${isUpdating ? 'animate-spin' : ''}`} /> <RefreshCcw className={`w-5 h-5 ${isUpdating ? 'animate-spin' : ''}`} />
<span className="text-sm font-bold"> <span className="text-sm font-bold">
{updateStatus === 'loading' ? 'Updating...' : {updateStatus === 'loading' ? 'Updating...' :
updateStatus === 'success' ? 'Core Updated!' : updateStatus === 'success' ? 'Core Updated!' :
updateStatus === 'error' ? 'Update Failed' : 'Update Core'} updateStatus === 'error' ? 'Update Failed' : 'Update Core'}
</span> </span>
</button> </button>
</div> </div>
<CreatePlaylistModal <CreatePlaylistModal
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)} onClose={() => setIsCreateModalOpen(false)}
onCreate={handleCreatePlaylist} onCreate={handleCreatePlaylist}
/> />
</aside> </aside>
); );
} }

View file

@ -1,151 +1,151 @@
"use client"; "use client";
import React, { createContext, useContext, useState, useEffect } from "react"; import React, { createContext, useContext, useState, useEffect } from "react";
import { dbService, Playlist } from "@/services/db"; import { dbService, Playlist } from "@/services/db";
import { libraryService } from "@/services/library"; import { libraryService } from "@/services/library";
type FilterType = 'all' | 'playlists' | 'artists' | 'albums'; type FilterType = 'all' | 'playlists' | 'artists' | 'albums';
interface LibraryContextType { interface LibraryContextType {
userPlaylists: Playlist[]; userPlaylists: Playlist[];
libraryItems: any[]; libraryItems: any[];
activeFilter: FilterType; activeFilter: FilterType;
setActiveFilter: (filter: FilterType) => void; setActiveFilter: (filter: FilterType) => void;
refreshLibrary: () => Promise<void>; refreshLibrary: () => Promise<void>;
} }
const LibraryContext = createContext<LibraryContextType | undefined>(undefined); const LibraryContext = createContext<LibraryContextType | undefined>(undefined);
export function LibraryProvider({ children }: { children: React.ReactNode }) { export function LibraryProvider({ children }: { children: React.ReactNode }) {
const [userPlaylists, setUserPlaylists] = useState<Playlist[]>([]); const [userPlaylists, setUserPlaylists] = useState<Playlist[]>([]);
const [libraryItems, setLibraryItems] = useState<any[]>([]); const [libraryItems, setLibraryItems] = useState<any[]>([]);
const [activeFilter, setActiveFilter] = useState<FilterType>('all'); const [activeFilter, setActiveFilter] = useState<FilterType>('all');
const fetchAllData = async () => { const fetchAllData = async () => {
try { try {
// 1. User Playlists // 1. User Playlists
const playlists = await dbService.getPlaylists(); const playlists = await dbService.getPlaylists();
setUserPlaylists(playlists); setUserPlaylists(playlists);
// 2. Local/Backend Content // 2. Local/Backend Content
const browse = await libraryService.getBrowseContent(); const browse = await libraryService.getBrowseContent();
// Deduplicate by ID to avoid React duplicate key warnings // Deduplicate by ID to avoid React duplicate key warnings
const browsePlaylistsRaw = Object.values(browse).flat(); const browsePlaylistsRaw = Object.values(browse).flat();
const seenIds = new Map(); const seenIds = new Map();
const browsePlaylists = browsePlaylistsRaw.filter((p: any) => { const browsePlaylists = browsePlaylistsRaw.filter((p: any) => {
if (seenIds.has(p.id)) return false; if (seenIds.has(p.id)) return false;
seenIds.set(p.id, true); seenIds.set(p.id, true);
return true; return true;
}); });
const artistsMap = new Map(); const artistsMap = new Map();
const albumsMap = new Map(); const albumsMap = new Map();
const allTracks: any[] = []; const allTracks: any[] = [];
// 3. Extract metadata // 3. Extract metadata
browsePlaylists.forEach((p: any) => { browsePlaylists.forEach((p: any) => {
if (p.tracks) { if (p.tracks) {
p.tracks.forEach((t: any) => { p.tracks.forEach((t: any) => {
allTracks.push(t); allTracks.push(t);
// Fake Artist // Fake Artist
if (artistsMap.size < 40 && t.artist && t.artist !== 'Unknown Artist' && t.artist !== 'Unknown') { if (artistsMap.size < 40 && t.artist && t.artist !== 'Unknown Artist' && t.artist !== 'Unknown') {
if (!artistsMap.has(t.artist)) { if (!artistsMap.has(t.artist)) {
artistsMap.set(t.artist, { artistsMap.set(t.artist, {
id: `artist-${t.artist}`, id: `artist-${t.artist}`,
title: t.artist, title: t.artist,
type: 'Artist', type: 'Artist',
cover_url: t.cover_url cover_url: t.cover_url
}); });
} }
} }
// Fake Album // Fake Album
if (albumsMap.size < 40 && t.album && t.album !== 'Single' && t.album !== 'Unknown Album') { if (albumsMap.size < 40 && t.album && t.album !== 'Single' && t.album !== 'Unknown Album') {
if (!albumsMap.has(t.album)) { if (!albumsMap.has(t.album)) {
albumsMap.set(t.album, { albumsMap.set(t.album, {
id: `album-${t.album}`, id: `album-${t.album}`,
title: t.album, title: t.album,
type: 'Album', type: 'Album',
creator: t.artist, creator: t.artist,
cover_url: t.cover_url cover_url: t.cover_url
}); });
} }
} }
}); });
} }
}); });
// 4. Generate Fake Extra Playlists (Creative Names) // 4. Generate Fake Extra Playlists (Creative Names)
const fakePlaylists = [...browsePlaylists]; const fakePlaylists = [...browsePlaylists];
const targetCount = 40; const targetCount = 40;
const needed = targetCount - fakePlaylists.length; const needed = targetCount - fakePlaylists.length;
const creativeNames = [ const creativeNames = [
"Chill Vibes", "Late Night Focus", "Workout Energy", "Road Trip Classics", "Chill Vibes", "Late Night Focus", "Workout Energy", "Road Trip Classics",
"Indie Mix", "Pop Hits", "Throwback Thursday", "Weekend Flow", "Indie Mix", "Pop Hits", "Throwback Thursday", "Weekend Flow",
"Deep Focus", "Party Anthems", "Jazz & Blues", "Acoustic Sessions", "Deep Focus", "Party Anthems", "Jazz & Blues", "Acoustic Sessions",
"Morning Coffee", "Rainy Day", "Sleep Sounds", "Gaming Beats", "Morning Coffee", "Rainy Day", "Sleep Sounds", "Gaming Beats",
"Coding Mode", "Summer Hits", "Winter Lo-Fi", "Discover Weekly", "Coding Mode", "Summer Hits", "Winter Lo-Fi", "Discover Weekly",
"Release Radar", "On Repeat", "Time Capsule", "Viral 50", "Release Radar", "On Repeat", "Time Capsule", "Viral 50",
"Global Top 50", "Trending Now", "Fresh Finds", "Audiobook Mode", "Global Top 50", "Trending Now", "Fresh Finds", "Audiobook Mode",
"Podcast Favorites", "Rock Classics", "Metal Essentials", "Hip Hop Gold", "Podcast Favorites", "Rock Classics", "Metal Essentials", "Hip Hop Gold",
"Electronic Dreams", "Ambient Spaces", "Classical Masterpieces", "Country Roads" "Electronic Dreams", "Ambient Spaces", "Classical Masterpieces", "Country Roads"
]; ];
if (needed > 0 && allTracks.length > 0) { if (needed > 0 && allTracks.length > 0) {
const shuffle = (array: any[]) => array.sort(() => 0.5 - Math.random()); const shuffle = (array: any[]) => array.sort(() => 0.5 - Math.random());
for (let i = 0; i < needed; i++) { for (let i = 0; i < needed; i++) {
const shuffled = shuffle([...allTracks]); const shuffled = shuffle([...allTracks]);
const selected = shuffled.slice(0, 8 + Math.floor(Math.random() * 12)); const selected = shuffled.slice(0, 8 + Math.floor(Math.random() * 12));
const cover = selected[0]?.cover_url; const cover = selected[0]?.cover_url;
const name = creativeNames[i] || `Daily Mix ${i + 1}`; const name = creativeNames[i] || `Daily Mix ${i + 1}`;
fakePlaylists.push({ fakePlaylists.push({
id: `mix-${i}`, id: `mix-${i}`,
title: name, title: name,
description: `Curated just for you • ${selected.length} songs`, description: `Curated just for you • ${selected.length} songs`,
cover_url: cover, cover_url: cover,
tracks: selected, tracks: selected,
type: 'Playlist' type: 'Playlist'
}); });
} }
} }
const uniqueItems = [ const uniqueItems = [
...fakePlaylists.map(p => ({ ...p, type: 'Playlist' })), ...fakePlaylists.map(p => ({ ...p, type: 'Playlist' })),
...Array.from(artistsMap.values()), ...Array.from(artistsMap.values()),
...Array.from(albumsMap.values()) ...Array.from(albumsMap.values())
]; ];
setLibraryItems(uniqueItems); setLibraryItems(uniqueItems);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
}; };
useEffect(() => { useEffect(() => {
fetchAllData(); fetchAllData();
}, []); }, []);
return ( return (
<LibraryContext.Provider value={{ <LibraryContext.Provider value={{
userPlaylists, userPlaylists,
libraryItems, libraryItems,
activeFilter, activeFilter,
setActiveFilter, setActiveFilter,
refreshLibrary: fetchAllData refreshLibrary: fetchAllData
}}> }}>
{children} {children}
</LibraryContext.Provider> </LibraryContext.Provider>
); );
} }
export function useLibrary() { export function useLibrary() {
const context = useContext(LibraryContext); const context = useContext(LibraryContext);
if (context === undefined) { if (context === undefined) {
throw new Error("useLibrary must be used within a LibraryProvider"); throw new Error("useLibrary must be used within a LibraryProvider");
} }
return context; return context;
} }

View file

@ -1,279 +1,279 @@
"use client"; "use client";
import { createContext, useContext, useState, useEffect, ReactNode } from "react"; import { createContext, useContext, useState, useEffect, ReactNode } from "react";
import { dbService } from "@/services/db"; import { dbService } from "@/services/db";
import { Track, AudioQuality } from "@/types"; import { Track, AudioQuality } from "@/types";
import * as mm from 'music-metadata-browser'; import * as mm from 'music-metadata-browser';
interface PlayerContextType { interface PlayerContextType {
currentTrack: Track | null; currentTrack: Track | null;
isPlaying: boolean; isPlaying: boolean;
isBuffering: boolean; isBuffering: boolean;
likedTracks: Set<string>; likedTracks: Set<string>;
likedTracksData: Track[]; likedTracksData: Track[];
shuffle: boolean; shuffle: boolean;
repeatMode: 'none' | 'all' | 'one'; repeatMode: 'none' | 'all' | 'one';
playTrack: (track: Track, queue?: Track[]) => void; playTrack: (track: Track, queue?: Track[]) => void;
togglePlay: () => void; togglePlay: () => void;
nextTrack: () => void; nextTrack: () => void;
prevTrack: () => void; prevTrack: () => void;
toggleShuffle: () => void; toggleShuffle: () => void;
toggleRepeat: () => void; toggleRepeat: () => void;
setBuffering: (state: boolean) => void; setBuffering: (state: boolean) => void;
toggleLike: (track: Track) => void; toggleLike: (track: Track) => void;
playHistory: Track[]; playHistory: Track[];
audioQuality: AudioQuality | null; audioQuality: AudioQuality | null;
// Lyrics panel state // Lyrics panel state
isLyricsOpen: boolean; isLyricsOpen: boolean;
toggleLyrics: () => void; toggleLyrics: () => void;
} }
const PlayerContext = createContext<PlayerContextType | undefined>(undefined); const PlayerContext = createContext<PlayerContextType | undefined>(undefined);
export function PlayerProvider({ children }: { children: ReactNode }) { export function PlayerProvider({ children }: { children: ReactNode }) {
const [currentTrack, setCurrentTrack] = useState<Track | null>(null); const [currentTrack, setCurrentTrack] = useState<Track | null>(null);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(false); const [isBuffering, setIsBuffering] = useState(false);
const [likedTracks, setLikedTracks] = useState<Set<string>>(new Set()); const [likedTracks, setLikedTracks] = useState<Set<string>>(new Set());
const [likedTracksData, setLikedTracksData] = useState<Track[]>([]); const [likedTracksData, setLikedTracksData] = useState<Track[]>([]);
// Audio Engine State // Audio Engine State
const [audioQuality, setAudioQuality] = useState<AudioQuality | null>(null); const [audioQuality, setAudioQuality] = useState<AudioQuality | null>(null);
const [preloadedBlobs, setPreloadedBlobs] = useState<Map<string, string>>(new Map()); const [preloadedBlobs, setPreloadedBlobs] = useState<Map<string, string>>(new Map());
// Queue State // Queue State
const [queue, setQueue] = useState<Track[]>([]); const [queue, setQueue] = useState<Track[]>([]);
const [currentIndex, setCurrentIndex] = useState(-1); const [currentIndex, setCurrentIndex] = useState(-1);
const [shuffle, setShuffle] = useState(false); const [shuffle, setShuffle] = useState(false);
const [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('none'); const [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('none');
// History State // History State
const [playHistory, setPlayHistory] = useState<Track[]>([]); const [playHistory, setPlayHistory] = useState<Track[]>([]);
// Lyrics Panel State // Lyrics Panel State
const [isLyricsOpen, setIsLyricsOpen] = useState(false); const [isLyricsOpen, setIsLyricsOpen] = useState(false);
const toggleLyrics = () => setIsLyricsOpen(prev => !prev); const toggleLyrics = () => setIsLyricsOpen(prev => !prev);
// Load Likes from DB // Load Likes from DB
useEffect(() => { useEffect(() => {
dbService.getLikedSongs().then(tracks => { dbService.getLikedSongs().then(tracks => {
setLikedTracks(new Set(tracks.map(t => t.id))); setLikedTracks(new Set(tracks.map(t => t.id)));
setLikedTracksData(tracks); setLikedTracksData(tracks);
}); });
}, []); }, []);
// Load History from LocalStorage // Load History from LocalStorage
useEffect(() => { useEffect(() => {
try { try {
const saved = localStorage.getItem('playHistory'); const saved = localStorage.getItem('playHistory');
if (saved) { if (saved) {
setPlayHistory(JSON.parse(saved)); setPlayHistory(JSON.parse(saved));
} }
} catch (e) { } catch (e) {
console.error("Failed to load history", e); console.error("Failed to load history", e);
} }
}, []); }, []);
// Save History // Save History
useEffect(() => { useEffect(() => {
localStorage.setItem('playHistory', JSON.stringify(playHistory)); localStorage.setItem('playHistory', JSON.stringify(playHistory));
}, [playHistory]); }, [playHistory]);
// Metadata & Preloading Effect // Metadata & Preloading Effect
useEffect(() => { useEffect(() => {
if (!currentTrack) return; if (!currentTrack) return;
// 1. Reset Quality // 1. Reset Quality
setAudioQuality(null); setAudioQuality(null);
// 2. Parse Metadata for Current Track // 2. Parse Metadata for Current Track
const parseMetadata = async () => { const parseMetadata = async () => {
try { try {
// Skip metadata parsing for backend streams AND external URLs (YouTube) to avoid CORS/Double-fetch // 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'))) { if (currentTrack.url && (currentTrack.url.includes('/api/stream') || currentTrack.url.startsWith('http'))) {
setAudioQuality({ setAudioQuality({
format: 'WEBM/OPUS', // YT Music typically format: 'WEBM/OPUS', // YT Music typically
sampleRate: 48000, sampleRate: 48000,
bitrate: 128000, bitrate: 128000,
channels: 2, channels: 2,
codec: 'Opus' codec: 'Opus'
}); });
return; return;
} }
if (currentTrack.url) { if (currentTrack.url) {
// Note: In a real scenario, we might need a proxy or CORS-enabled server. // Note: In a real scenario, we might need a proxy or CORS-enabled server.
// music-metadata-browser fetches the file. // music-metadata-browser fetches the file.
const metadata = await mm.fetchFromUrl(currentTrack.url); const metadata = await mm.fetchFromUrl(currentTrack.url);
setAudioQuality({ setAudioQuality({
format: metadata.format.container || 'Unknown', format: metadata.format.container || 'Unknown',
sampleRate: metadata.format.sampleRate || 44100, sampleRate: metadata.format.sampleRate || 44100,
bitDepth: metadata.format.bitsPerSample, bitDepth: metadata.format.bitsPerSample,
bitrate: metadata.format.bitrate || 0, bitrate: metadata.format.bitrate || 0,
channels: metadata.format.numberOfChannels || 2, channels: metadata.format.numberOfChannels || 2,
codec: metadata.format.codec codec: metadata.format.codec
}); });
} }
} catch (e) { } catch (e) {
console.warn("Failed to parse metadata", e); console.warn("Failed to parse metadata", e);
// Fallback mock if parsing fails (likely due to CORS on sample URL) // Fallback mock if parsing fails (likely due to CORS on sample URL)
setAudioQuality({ setAudioQuality({
format: 'MP3', format: 'MP3',
sampleRate: 44100, sampleRate: 44100,
bitrate: 320000, bitrate: 320000,
channels: 2, channels: 2,
codec: 'MPEG-1 Layer 3' codec: 'MPEG-1 Layer 3'
}); });
} }
}; };
parseMetadata(); parseMetadata();
// 3. Smart Buffering (Preload Next 2 Tracks) // 3. Smart Buffering (Preload Next 2 Tracks)
const preloadNext = async () => { const preloadNext = async () => {
if (queue.length === 0) return; if (queue.length === 0) return;
const index = queue.findIndex(t => t.id === currentTrack.id); const index = queue.findIndex(t => t.id === currentTrack.id);
if (index === -1) return; if (index === -1) return;
const nextTracks = queue.slice(index + 1, index + 3); const nextTracks = queue.slice(index + 1, index + 3);
nextTracks.forEach(async (track) => { nextTracks.forEach(async (track) => {
if (!preloadedBlobs.has(track.id) && track.url) { if (!preloadedBlobs.has(track.id) && track.url) {
try { try {
// Construct the correct stream URL for preloading if it's external // 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 fetchUrl = track.url.startsWith('http') ? `/api/stream?id=${track.id}` : track.url;
const res = await fetch(fetchUrl); const res = await fetch(fetchUrl);
if (!res.ok) throw new Error("Fetch failed"); if (!res.ok) throw new Error("Fetch failed");
const blob = await res.blob(); const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
setPreloadedBlobs(prev => new Map(prev).set(track.id, blobUrl)); setPreloadedBlobs(prev => new Map(prev).set(track.id, blobUrl));
console.log(`Buffered ${track.title}`); console.log(`Buffered ${track.title}`);
} catch (e) { } catch (e) {
// console.warn(`Failed to buffer ${track.title}`); // console.warn(`Failed to buffer ${track.title}`);
} }
} }
}); });
}; };
preloadNext(); preloadNext();
}, [currentTrack, queue, preloadedBlobs]); }, [currentTrack, queue, preloadedBlobs]);
const playTrack = (track: Track, newQueue?: Track[]) => { const playTrack = (track: Track, newQueue?: Track[]) => {
if (currentTrack?.id !== track.id) { if (currentTrack?.id !== track.id) {
setIsBuffering(true); setIsBuffering(true);
// Add to History (prevent duplicates at top) // Add to History (prevent duplicates at top)
setPlayHistory(prev => { setPlayHistory(prev => {
const filtered = prev.filter(t => t.id !== track.id); const filtered = prev.filter(t => t.id !== track.id);
return [track, ...filtered].slice(0, 20); // Keep last 20 return [track, ...filtered].slice(0, 20); // Keep last 20
}); });
} }
setCurrentTrack(track); setCurrentTrack(track);
setIsPlaying(true); setIsPlaying(true);
if (newQueue) { if (newQueue) {
setQueue(newQueue); setQueue(newQueue);
const index = newQueue.findIndex(t => t.id === track.id); const index = newQueue.findIndex(t => t.id === track.id);
setCurrentIndex(index); setCurrentIndex(index);
} }
}; };
const togglePlay = () => { const togglePlay = () => {
setIsPlaying((prev) => !prev); setIsPlaying((prev) => !prev);
}; };
const nextTrack = () => { const nextTrack = () => {
if (queue.length === 0) return; if (queue.length === 0) return;
let nextIndex = currentIndex + 1; let nextIndex = currentIndex + 1;
if (shuffle) { if (shuffle) {
nextIndex = Math.floor(Math.random() * queue.length); nextIndex = Math.floor(Math.random() * queue.length);
} else if (nextIndex >= queue.length) { } else if (nextIndex >= queue.length) {
if (repeatMode === 'all') nextIndex = 0; if (repeatMode === 'all') nextIndex = 0;
else return; // Stop if end of queue and no repeat else return; // Stop if end of queue and no repeat
} }
playTrack(queue[nextIndex]); playTrack(queue[nextIndex]);
setCurrentIndex(nextIndex); setCurrentIndex(nextIndex);
}; };
const prevTrack = () => { const prevTrack = () => {
if (queue.length === 0) return; if (queue.length === 0) return;
let prevIndex = currentIndex - 1; let prevIndex = currentIndex - 1;
if (prevIndex < 0) prevIndex = 0; // Or wrap around if desired if (prevIndex < 0) prevIndex = 0; // Or wrap around if desired
playTrack(queue[prevIndex]); playTrack(queue[prevIndex]);
setCurrentIndex(prevIndex); setCurrentIndex(prevIndex);
}; };
const toggleShuffle = () => setShuffle(prev => !prev); const toggleShuffle = () => setShuffle(prev => !prev);
const toggleRepeat = () => { const toggleRepeat = () => {
setRepeatMode(prev => { setRepeatMode(prev => {
if (prev === 'none') return 'all'; if (prev === 'none') return 'all';
if (prev === 'all') return 'one'; if (prev === 'all') return 'one';
return 'none'; return 'none';
}); });
}; };
const setBuffering = (state: boolean) => setIsBuffering(state); const setBuffering = (state: boolean) => setIsBuffering(state);
const toggleLike = async (track: Track) => { const toggleLike = async (track: Track) => {
const isNowLiked = await dbService.toggleLike(track); const isNowLiked = await dbService.toggleLike(track);
setLikedTracks(prev => { setLikedTracks(prev => {
const next = new Set(prev); const next = new Set(prev);
if (isNowLiked) next.add(track.id); if (isNowLiked) next.add(track.id);
else next.delete(track.id); else next.delete(track.id);
return next; return next;
}); });
setLikedTracksData(prev => { setLikedTracksData(prev => {
if (!isNowLiked) { if (!isNowLiked) {
return prev.filter(t => t.id !== track.id); return prev.filter(t => t.id !== track.id);
} else { } else {
return [...prev, track]; return [...prev, track];
} }
}); });
}; };
const effectiveCurrentTrack = currentTrack ? { const effectiveCurrentTrack = currentTrack ? {
...currentTrack, ...currentTrack,
// improved URL logic: usage of backend API if no local blob // improved URL logic: usage of backend API if no local blob
url: preloadedBlobs.get(currentTrack.id) || url: preloadedBlobs.get(currentTrack.id) ||
(currentTrack.url && currentTrack.url.startsWith('/') ? currentTrack.url : `/api/stream?id=${currentTrack.id}`) (currentTrack.url && currentTrack.url.startsWith('/') ? currentTrack.url : `/api/stream?id=${currentTrack.id}`)
} : null; } : null;
return ( return (
<PlayerContext.Provider value={{ <PlayerContext.Provider value={{
currentTrack: effectiveCurrentTrack, currentTrack: effectiveCurrentTrack,
isPlaying, isPlaying,
isBuffering, isBuffering,
likedTracks, likedTracks,
likedTracksData, likedTracksData,
shuffle, shuffle,
repeatMode, repeatMode,
playTrack, playTrack,
togglePlay, togglePlay,
nextTrack, nextTrack,
prevTrack, prevTrack,
toggleShuffle, toggleShuffle,
toggleRepeat, toggleRepeat,
setBuffering, setBuffering,
toggleLike, toggleLike,
playHistory, playHistory,
audioQuality, audioQuality,
isLyricsOpen, isLyricsOpen,
toggleLyrics toggleLyrics
}}> }}>
{children} {children}
</PlayerContext.Provider> </PlayerContext.Provider>
); );
} }
export function usePlayer() { export function usePlayer() {
const context = useContext(PlayerContext); const context = useContext(PlayerContext);
if (context === undefined) { if (context === undefined) {
throw new Error("usePlayer must be used within a PlayerProvider"); throw new Error("usePlayer must be used within a PlayerProvider");
} }
return context; return context;
} }

View file

@ -1,63 +1,63 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
// strict mode true is default but good to be explicit // strict mode true is default but good to be explicit
// strict mode true is default but good to be explicit // strict mode true is default but good to be explicit
reactStrictMode: true, reactStrictMode: true,
output: "standalone", output: "standalone",
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
async rewrites() { async rewrites() {
return [ return [
// Backend API Proxies (Specific, so we don't block NextAuth at /api/auth) // 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/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/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', 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/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/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/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/: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/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/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/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*' }, { source: '/api/recommendations/:path*', destination: 'http://127.0.0.1:8000/api/recommendations/:path*' },
]; ];
}, },
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
protocol: 'https', protocol: 'https',
hostname: 'i.ytimg.com', hostname: 'i.ytimg.com',
}, },
{ {
protocol: 'https', protocol: 'https',
hostname: 'lh3.googleusercontent.com', hostname: 'lh3.googleusercontent.com',
}, },
{ {
protocol: 'https', protocol: 'https',
hostname: 'yt3.googleusercontent.com', hostname: 'yt3.googleusercontent.com',
}, },
{ {
protocol: 'https', protocol: 'https',
hostname: 'yt3.ggpht.com', hostname: 'yt3.ggpht.com',
}, },
{ {
protocol: 'https', protocol: 'https',
hostname: 'placehold.co', hostname: 'placehold.co',
}, },
{ {
protocol: 'https', protocol: 'https',
hostname: 'images.unsplash.com', hostname: 'images.unsplash.com',
}, },
{ {
protocol: 'https', protocol: 'https',
hostname: 'misc.scdn.co', hostname: 'misc.scdn.co',
}, },
], ],
}, },
}; };
export default nextConfig; 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", "name": "Audiophile Web Player",
"short_name": "Spotify", "short_name": "Audiophile",
"description": "High-Fidelity Local-First Music Player",
"start_url": "/", "start_url": "/",
"scope": "/",
"display": "standalone", "display": "standalone",
"orientation": "portrait",
"background_color": "#121212", "background_color": "#121212",
"theme_color": "#1DB954", "theme_color": "#1DB954",
"categories": ["music", "entertainment"],
"icons": [ "icons": [
{ {
"src": "/icons/icon-192x192.png", "src": "/icons/icon-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png",
"purpose": "any maskable"
}, },
{ {
"src": "/icons/icon-512x512.png", "src": "/icons/icon-512x512.png",
"sizes": "512x512", "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 { openDB, DBSchema } from 'idb';
import { Track, Playlist } from '@/types'; import { Track, Playlist } from '@/types';
export type { Track, Playlist }; export type { Track, Playlist };
interface MyDB extends DBSchema { interface MyDB extends DBSchema {
playlists: { playlists: {
key: string; key: string;
value: Playlist; value: Playlist;
}; };
likedSongs: { likedSongs: {
key: string; // trackId key: string; // trackId
value: Track; value: Track;
}; };
} }
const DB_NAME = 'audiophile-db'; const DB_NAME = 'audiophile-db';
const DB_VERSION = 2; const DB_VERSION = 2;
export const initDB = async () => { export const initDB = async () => {
return openDB<MyDB>(DB_NAME, DB_VERSION, { return openDB<MyDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion, newVersion, transaction) { upgrade(db, oldVersion, newVersion, transaction) {
// Re-create stores to clear old data // Re-create stores to clear old data
if (db.objectStoreNames.contains('playlists')) { if (db.objectStoreNames.contains('playlists')) {
db.deleteObjectStore('playlists'); db.deleteObjectStore('playlists');
} }
if (db.objectStoreNames.contains('likedSongs')) { if (db.objectStoreNames.contains('likedSongs')) {
db.deleteObjectStore('likedSongs'); db.deleteObjectStore('likedSongs');
} }
db.createObjectStore('playlists', { keyPath: 'id' }); db.createObjectStore('playlists', { keyPath: 'id' });
db.createObjectStore('likedSongs', { keyPath: 'id' }); db.createObjectStore('likedSongs', { keyPath: 'id' });
}, },
}); });
}; };
export const dbService = { export const dbService = {
async getPlaylists() { async getPlaylists() {
const db = await initDB(); const db = await initDB();
const playlists = await db.getAll('playlists'); const playlists = await db.getAll('playlists');
if (playlists.length === 0) { if (playlists.length === 0) {
return this.seedInitialData(); return this.seedInitialData();
} }
return playlists; return playlists;
}, },
async seedInitialData() { async seedInitialData() {
try { try {
// Fetch real data from backend to seed valid playlists // Fetch real data from backend to seed valid playlists
// We use the 'api' prefix assuming this runs in browser // We use the 'api' prefix assuming this runs in browser
const res = await fetch('/api/trending'); const res = await fetch('/api/trending');
if (!res.ok) return []; if (!res.ok) return [];
const data = await res.json(); const data = await res.json();
const allTracks: Track[] = data.tracks || []; const allTracks: Track[] = data.tracks || [];
if (allTracks.length === 0) return []; if (allTracks.length === 0) return [];
const db = await initDB(); const db = await initDB();
const newPlaylists: Playlist[] = []; const newPlaylists: Playlist[] = [];
// 1. Starter Playlist // 1. Starter Playlist
const favTracks = allTracks.slice(0, 8); const favTracks = allTracks.slice(0, 8);
if (favTracks.length > 0) { if (favTracks.length > 0) {
const p1: Playlist = { const p1: Playlist = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
title: "My Rotations", title: "My Rotations",
tracks: favTracks, tracks: favTracks,
createdAt: Date.now(), createdAt: Date.now(),
cover_url: favTracks[0].cover_url cover_url: favTracks[0].cover_url
}; };
await db.put('playlists', p1); await db.put('playlists', p1);
newPlaylists.push(p1); newPlaylists.push(p1);
} }
// 2. Vibes // 2. Vibes
const vibeTracks = allTracks.slice(8, 15); const vibeTracks = allTracks.slice(8, 15);
if (vibeTracks.length > 0) { if (vibeTracks.length > 0) {
const p2: Playlist = { const p2: Playlist = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
title: "Weekend Vibes", title: "Weekend Vibes",
tracks: vibeTracks, tracks: vibeTracks,
createdAt: Date.now(), createdAt: Date.now(),
cover_url: vibeTracks[0].cover_url cover_url: vibeTracks[0].cover_url
}; };
await db.put('playlists', p2); await db.put('playlists', p2);
newPlaylists.push(p2); newPlaylists.push(p2);
} }
return newPlaylists; return newPlaylists;
} catch (e) { } catch (e) {
console.error("Seeding failed", e); console.error("Seeding failed", e);
return []; return [];
} }
}, },
async getPlaylist(id: string) { async getPlaylist(id: string) {
const db = await initDB(); const db = await initDB();
return db.get('playlists', id); return db.get('playlists', id);
}, },
async createPlaylist(name: string) { async createPlaylist(name: string) {
const db = await initDB(); const db = await initDB();
const newPlaylist: Playlist = { const newPlaylist: Playlist = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
title: name, title: name,
tracks: [], tracks: [],
createdAt: Date.now(), createdAt: Date.now(),
cover_url: "https://placehold.co/300/222/fff?text=" + encodeURIComponent(name) cover_url: "https://placehold.co/300/222/fff?text=" + encodeURIComponent(name)
}; };
await db.put('playlists', newPlaylist); await db.put('playlists', newPlaylist);
return newPlaylist; return newPlaylist;
}, },
async deletePlaylist(id: string) { async deletePlaylist(id: string) {
const db = await initDB(); const db = await initDB();
await db.delete('playlists', id); await db.delete('playlists', id);
}, },
async addToPlaylist(playlistId: string, track: Track) { async addToPlaylist(playlistId: string, track: Track) {
const db = await initDB(); const db = await initDB();
const playlist = await db.get('playlists', playlistId); const playlist = await db.get('playlists', playlistId);
if (playlist) { if (playlist) {
// Auto-update cover if it's the default or empty // Auto-update cover if it's the default or empty
if (playlist.tracks.length === 0 || playlist.cover_url?.includes("placehold")) { if (playlist.tracks.length === 0 || playlist.cover_url?.includes("placehold")) {
playlist.cover_url = track.cover_url; playlist.cover_url = track.cover_url;
} }
playlist.tracks.push(track); playlist.tracks.push(track);
await db.put('playlists', playlist); await db.put('playlists', playlist);
} }
}, },
async removeFromPlaylist(playlistId: string, trackId: string) { async removeFromPlaylist(playlistId: string, trackId: string) {
const db = await initDB(); const db = await initDB();
const playlist = await db.get('playlists', playlistId); const playlist = await db.get('playlists', playlistId);
if (playlist) { if (playlist) {
playlist.tracks = playlist.tracks.filter(t => t.id !== trackId); playlist.tracks = playlist.tracks.filter(t => t.id !== trackId);
await db.put('playlists', playlist); await db.put('playlists', playlist);
} }
}, },
async getLikedSongs() { async getLikedSongs() {
const db = await initDB(); const db = await initDB();
return db.getAll('likedSongs'); return db.getAll('likedSongs');
}, },
async toggleLike(track: Track) { async toggleLike(track: Track) {
const db = await initDB(); const db = await initDB();
const existing = await db.get('likedSongs', track.id); const existing = await db.get('likedSongs', track.id);
if (existing) { if (existing) {
await db.delete('likedSongs', track.id); await db.delete('likedSongs', track.id);
return false; // unliked return false; // unliked
} else { } else {
await db.put('likedSongs', track); await db.put('likedSongs', track);
return true; // liked return true; // liked
} }
}, },
async isLiked(trackId: string) { async isLiked(trackId: string) {
const db = await initDB(); const db = await initDB();
const existing = await db.get('likedSongs', trackId); const existing = await db.get('likedSongs', trackId);
return !!existing; return !!existing;
} }
}; };

View file

@ -1,76 +1,76 @@
import { Track } from "./db"; import { Track } from "./db";
export interface StaticPlaylist { export interface StaticPlaylist {
id: string; id: string;
title: string; title: string;
description: string; description: string;
cover_url: string; cover_url: string;
tracks: Track[]; tracks: Track[];
type: 'Album' | 'Artist' | 'Playlist'; type: 'Album' | 'Artist' | 'Playlist';
creator?: string; creator?: string;
} }
// Helper to fetch from backend // Helper to fetch from backend
const apiFetch = async (endpoint: string) => { const apiFetch = async (endpoint: string) => {
const res = await fetch(`/api${endpoint}`); const res = await fetch(`/api${endpoint}`);
if (!res.ok) throw new Error(`API Error: ${res.statusText}`); if (!res.ok) throw new Error(`API Error: ${res.statusText}`);
return res.json(); return res.json();
}; };
export const libraryService = { export const libraryService = {
async getLibrary(): Promise<StaticPlaylist> { async getLibrary(): Promise<StaticPlaylist> {
// Fetch "Liked Songs" or main library from backend // Fetch "Liked Songs" or main library from backend
// Assuming backend has an endpoint or we treat "Trending" as default // Assuming backend has an endpoint or we treat "Trending" as default
return await apiFetch('/browse'); // Simplified fallback return await apiFetch('/browse'); // Simplified fallback
}, },
async _generateMockContent(): Promise<void> { async _generateMockContent(): Promise<void> {
// No-op in API mode // No-op in API mode
}, },
async getBrowseContent(): Promise<Record<string, StaticPlaylist[]>> { async getBrowseContent(): Promise<Record<string, StaticPlaylist[]>> {
return await apiFetch('/browse'); return await apiFetch('/browse');
}, },
async getPlaylist(id: string): Promise<StaticPlaylist | null> { async getPlaylist(id: string): Promise<StaticPlaylist | null> {
try { try {
return await apiFetch(`/playlists/${id}`); return await apiFetch(`/playlists/${id}`);
} catch (e) { } catch (e) {
console.error("Failed to fetch playlist", id, e); console.error("Failed to fetch playlist", id, e);
return null; return null;
} }
}, },
async getRecommendations(seedTrackId?: string): Promise<Track[]> { async getRecommendations(seedTrackId?: string): Promise<Track[]> {
// Use trending as recommendations for now // Use trending as recommendations for now
const data = await apiFetch('/trending'); const data = await apiFetch('/trending');
return data.tracks || []; return data.tracks || [];
}, },
async getRecommendedAlbums(seedArtist?: string): Promise<StaticPlaylist[]> { async getRecommendedAlbums(seedArtist?: string): Promise<StaticPlaylist[]> {
const data = await apiFetch('/browse'); const data = await apiFetch('/browse');
// Flatten all albums from categories // Flatten all albums from categories
const albums: StaticPlaylist[] = []; const albums: StaticPlaylist[] = [];
Object.values(data).forEach((list: any) => { Object.values(data).forEach((list: any) => {
if (Array.isArray(list)) albums.push(...list); if (Array.isArray(list)) albums.push(...list);
}); });
return albums.slice(0, 8); return albums.slice(0, 8);
}, },
async search(query: string): Promise<Track[]> { async search(query: string): Promise<Track[]> {
try { try {
return await apiFetch(`/search?q=${encodeURIComponent(query)}`); return await apiFetch(`/search?q=${encodeURIComponent(query)}`);
} catch (e) { } catch (e) {
return []; return [];
} }
}, },
// UTILITIES FOR DYNAMIC UPDATES // UTILITIES FOR DYNAMIC UPDATES
updateTrackCover(trackId: string, newUrl: string) { updateTrackCover(trackId: string, newUrl: string) {
console.log("Dynamic updates not implemented in Backend Mode"); console.log("Dynamic updates not implemented in Backend Mode");
}, },
updateAlbumCover(albumId: string, newUrl: string) { updateAlbumCover(albumId: string, newUrl: string) {
console.log("Dynamic updates not implemented in Backend Mode"); console.log("Dynamic updates not implemented in Backend Mode");
} }
}; };

View file

@ -1,21 +1,21 @@
import yt_dlp import yt_dlp
import json import json
# Test video ID from our data (e.g., Khóa Ly Biệt) # Test video ID from our data (e.g., Khóa Ly Biệt)
video_id = "s0OMNH-N5D8" video_id = "s0OMNH-N5D8"
url = f"https://www.youtube.com/watch?v={video_id}" url = f"https://www.youtube.com/watch?v={video_id}"
ydl_opts = { ydl_opts = {
'format': 'bestaudio/best', 'format': 'bestaudio/best',
'quiet': True, 'quiet': True,
'noplaylist': True, 'noplaylist': True,
} }
try: try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False) info = ydl.extract_info(url, download=False)
print(f"Title: {info.get('title')}") print(f"Title: {info.get('title')}")
print(f"URL: {info.get('url')}") # The direct stream URL print(f"URL: {info.get('url')}") # The direct stream URL
print("Success: Extracted audio URL") print("Success: Extracted audio URL")
except Exception as e: except Exception as e:
print(f"Error: {e}") print(f"Error: {e}")