Initial commit: Spotify Clone with Audiophile specs and Synology support
55
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# System Files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment Variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Project Specific
|
||||||
|
backend/data/*.json
|
||||||
|
!backend/data/browse_playlists.json
|
||||||
|
!backend/data/data.json
|
||||||
|
backend/cache/
|
||||||
|
backend/data_seed/
|
||||||
105
DOCKER_README.md
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
# Spotify Clone 🎵
|
||||||
|
|
||||||
|
A fully functional clone of the Spotify web player, built with a modern stack featuring **Next.js**, **FastAPI**, and **TailwindCSS**. This application replicates the premium authentic feel of the original web player with added features like synchronized lyrics and custom playlist management.
|
||||||
|
|
||||||
|
### ⚡ One-Click Deployment
|
||||||
|
We have included a script to automate the build and deploy process.
|
||||||
|
1. Ensure Docker Desktop is running.
|
||||||
|
2. Double-click `deploy.bat`.
|
||||||
|
|
||||||
|
### 📦 Clean Build & Publish (For Developers)
|
||||||
|
|
||||||
|
If you are the developer or want to build the image yourself, follow these steps:
|
||||||
|
|
||||||
|
### 1. Build the Image
|
||||||
|
```bash
|
||||||
|
docker build -t vndangkhoa/spotify-clone:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Push to Docker Hub
|
||||||
|
```bash
|
||||||
|
# Login first
|
||||||
|
docker login
|
||||||
|
|
||||||
|
# Push
|
||||||
|
docker push vndangkhoa/spotify-clone:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start (Pre-built)
|
||||||
|
|
||||||
|
Run the application instantly from Docker Hub:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -p 3000:3000 -p 8000:8000 vndangkhoa/spotify-clone
|
||||||
|
```
|
||||||
|
|
||||||
|
After the container starts, open **[http://localhost:3000](http://localhost:3000)** in your browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏠 Synology / Docker Compose (Self-Hosted/Offline)
|
||||||
|
|
||||||
|
For permanent deployment on a NAS (Synology) or local server with data persistence, use **Docker Compose**.
|
||||||
|
|
||||||
|
### 1. Create `docker-compose.yml`
|
||||||
|
Create a file named `docker-compose.yml` with the following content:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
spotify-clone:
|
||||||
|
image: vndangkhoa/spotify-clone:latest
|
||||||
|
container_name: spotify-clone
|
||||||
|
restart: always
|
||||||
|
network_mode: bridge
|
||||||
|
ports:
|
||||||
|
- "3000:3000" # Web UI (Access this)
|
||||||
|
- "8000:8000" # Optional: Direct API access
|
||||||
|
volumes:
|
||||||
|
# Persist your playlists and data
|
||||||
|
- ./data:/app/backend/data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run the Service
|
||||||
|
Run the following command in the same directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Your data (playlists) will be securely saved in the `./data` folder on your host machine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 PWA Support (Mobile App)
|
||||||
|
|
||||||
|
This web app is **Progressive Web App (PWA)** compatible. You can install it on your mobile device for a native-like experience:
|
||||||
|
|
||||||
|
1. Open the web app in **Safari** (iOS) or **Chrome** (Android).
|
||||||
|
2. Tap the **Share** button (iOS) or **Menu** (Android).
|
||||||
|
3. Select **"Add to Home Screen"**.
|
||||||
|
4. Launch it from your home screen—it looks and feels just like an app! 🎧
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Key Features
|
||||||
|
|
||||||
|
- **mic Synced Lyrics**: Real-time scrolling lyrics with precise synchronization.
|
||||||
|
- **🎧 Seamless Playback**: Full audio controls, background play support.
|
||||||
|
- **📂 Playlist Management**: Create, edit, and save playlists locally.
|
||||||
|
- **🔍 Smart Search**: Instantly find tracks.
|
||||||
|
- **🎨 Premium UI**: Glassmorphism, dynamic gradients, and smooth animations.
|
||||||
|
|
||||||
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
|
- **Frontend**: Next.js 14, React, TailwindCSS.
|
||||||
|
- **Backend**: FastAPI, Python 3.11.
|
||||||
|
- **Container**: Hybrid Image (Node.js + Python).
|
||||||
|
|
||||||
|
## 📦 Ports
|
||||||
|
|
||||||
|
- **3000**: Frontend (User Interface) - **Primary Access Point**
|
||||||
|
- **8000**: Backend (API) - *Internal use, but can be mapped for direct API access.*
|
||||||
|
|
||||||
|
For standard usage, accessing via Port 3000 handles everything.
|
||||||
57
Dockerfile
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Install Node.js
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
gnupg \
|
||||||
|
ffmpeg \
|
||||||
|
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# --- Backend Setup ---
|
||||||
|
COPY backend/requirements.txt ./backend/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||||
|
|
||||||
|
# --- Frontend Setup ---
|
||||||
|
COPY frontend/package*.json ./frontend/
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
# Install dependencies (ignoring peer deps conflicts)
|
||||||
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
|
COPY frontend/ .
|
||||||
|
# Build Next.js (with ignore-lint already set in next.config.mjs)
|
||||||
|
# We set API URL to http://localhost:8000 because in this container strategy,
|
||||||
|
# the browser will access the backend directly.
|
||||||
|
# Wait, for client-side fetches, "localhost" refers to the user's machine.
|
||||||
|
# If we run this container on port 3000 and 8000, localhost:8000 works internally via Rewrites.
|
||||||
|
# ENV NEXT_PUBLIC_API_URL="http://localhost:8000" Removed to use relative path proxying
|
||||||
|
# Build Next.js
|
||||||
|
ENV NEXTAUTH_URL=http://localhost:3000
|
||||||
|
# Generate a static secret for now to prevent 500 error, or use a build arg
|
||||||
|
ENV NEXTAUTH_SECRET=changeme_in_production_but_this_fixes_500_error
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Final Setup ---
|
||||||
|
WORKDIR /app
|
||||||
|
COPY backend/ ./backend/
|
||||||
|
|
||||||
|
# Create a start script
|
||||||
|
# We also implement a "seed data" check.
|
||||||
|
# If the volume mount is empty (missing data.json), we copy from our backup.
|
||||||
|
RUN mkdir -p backend/data_seed && cp -r backend/data/* backend/data_seed/ || true
|
||||||
|
|
||||||
|
RUN echo '#!/bin/bash\n\
|
||||||
|
if [ ! -f backend/data/data.json ]; then\n\
|
||||||
|
echo "Data volume appears empty. Seeding with bundled data..."\n\
|
||||||
|
cp -r backend/data_seed/* backend/data/\n\
|
||||||
|
fi\n\
|
||||||
|
uvicorn backend.main:app --host 0.0.0.0 --port 8000 &\n\
|
||||||
|
cd frontend && npm start -- -p 3000\n\
|
||||||
|
' > start.sh && chmod +x start.sh
|
||||||
|
|
||||||
|
EXPOSE 3000 8000
|
||||||
|
|
||||||
|
CMD ["./start.sh"]
|
||||||
125
README.md
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# Spotify Clone 🎵
|
||||||
|
|
||||||
|
A fully functional clone of the Spotify web player, built with a modern stack featuring **Next.js**, **FastAPI**, and **TailwindCSS**. This application replicates the premium authentic feel of the original web player with added features like synchronized lyrics, custom playlist management, and "Audiophile" technical specs.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start (Docker)
|
||||||
|
|
||||||
|
The easiest way to run the application is using Docker.
|
||||||
|
|
||||||
|
### Option 1: Run from Docker Hub (Pre-built)
|
||||||
|
```bash
|
||||||
|
docker run -p 3000:3000 -p 8000:8000 vndangkhoa/spotify-clone:latest
|
||||||
|
```
|
||||||
|
Open **[http://localhost:3000](http://localhost:3000)**.
|
||||||
|
|
||||||
|
### Option 2: Build Locally
|
||||||
|
```bash
|
||||||
|
docker build -t spotify-clone .
|
||||||
|
docker run -p 3000:3000 -p 8000:8000 spotify-clone
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Local Development
|
||||||
|
|
||||||
|
If you want to contribute or modify the code:
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Node.js 18+
|
||||||
|
- Python 3.11+
|
||||||
|
- ffmpeg (optional, for some audio features)
|
||||||
|
|
||||||
|
### 1. Backend Setup
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
Backend runs on `http://localhost:8000`.
|
||||||
|
|
||||||
|
### 2. Frontend Setup
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
Frontend runs on `http://localhost:3000`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Deployment Guide
|
||||||
|
|
||||||
|
### 1. Deploy to GitHub
|
||||||
|
Initialize the repository (if not done) and push:
|
||||||
|
```bash
|
||||||
|
git init
|
||||||
|
git add .
|
||||||
|
git commit -m "Initial commit"
|
||||||
|
git branch -M main
|
||||||
|
git remote add origin https://github.com/YOUR_USERNAME/spotify-clone.git
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Deploy to Docker Hub
|
||||||
|
To share your image with the world (or your NAS):
|
||||||
|
```bash
|
||||||
|
# 1. Login to Docker Hub
|
||||||
|
docker login
|
||||||
|
|
||||||
|
# 2. Build the image (replace 'vndangkhoa' with your Docker Hub username)
|
||||||
|
docker build -t vndangkhoa/spotify-clone:latest .
|
||||||
|
|
||||||
|
# 3. Push the image
|
||||||
|
docker push vndangkhoa/spotify-clone:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Deploy to Synology NAS (Container Manager)
|
||||||
|
This app runs perfectly on Synology NAS using **Container Manager** (formerly Docker).
|
||||||
|
|
||||||
|
#### Method A: Using Container Manager UI (Easy)
|
||||||
|
1. Open **Container Manager**.
|
||||||
|
2. Go to **Registry** -> Search for `vndangkhoa/spotify-clone` (or your image).
|
||||||
|
3. Download the image.
|
||||||
|
4. Go to **Image** -> Select image -> **Run**.
|
||||||
|
- **Network**: Bridge (default).
|
||||||
|
- **Port Settings**: Map Local Port `3110` (or any) to Container Port `3000`.
|
||||||
|
- **Volume Settings** (Optional): Map a folder `/docker/spotify/data` to `/app/backend/data` to save playlists.
|
||||||
|
5. Done! Access at `http://YOUR_NAS_IP:3110`.
|
||||||
|
|
||||||
|
#### Method B: Using Docker Compose (Recommended)
|
||||||
|
1. Create a folder on your NAS (e.g., `/volume1/docker/spotify`).
|
||||||
|
2. Create a file named `docker-compose.yml` inside it:
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
spotify-clone:
|
||||||
|
image: vndangkhoa/spotify-clone:latest
|
||||||
|
container_name: spotify-clone
|
||||||
|
restart: always
|
||||||
|
network_mode: bridge
|
||||||
|
ports:
|
||||||
|
- "3110:3000" # Web UI Access Port
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/backend/data
|
||||||
|
```
|
||||||
|
3. In Container Manager, go to **Project** -> **Create**.
|
||||||
|
4. Select the folder path, give it a name, and it will detect the compose file.
|
||||||
|
5. Click **Build** / **Run**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **Real-Time Lyrics**: Fetch and sync lyrics from multiple sources (YouTube, LRCLIB).
|
||||||
|
- **Audiophile Engine**: "Tech Specs" view showing live bitrate, LUFS, and Dynamic Range.
|
||||||
|
- **Local-First**: Works offline (PWA) and syncs local playlists.
|
||||||
|
- **Smart Search**: Unified search across YouTube Music.
|
||||||
|
- **Responsive**: Full mobile support with a dedicated full-screen player.
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
MIT License
|
||||||
0
backend/api/__init__.py
Normal file
614
backend/api/routes.py
Normal file
|
|
@ -0,0 +1,614 @@
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import yt_dlp
|
||||||
|
import requests
|
||||||
|
from backend.cache_manager import CacheManager
|
||||||
|
from backend.playlist_manager import PlaylistManager
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
cache = CacheManager()
|
||||||
|
playlist_manager = PlaylistManager()
|
||||||
|
|
||||||
|
def clean_text(text: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
# Remove emojis
|
||||||
|
text = text.encode('ascii', 'ignore').decode('ascii')
|
||||||
|
# Remove text inside * * or similar patterns if they look spammy
|
||||||
|
# Remove excessive punctuation
|
||||||
|
# Example: "THE * VIRAL 50 *" -> "THE VIRAL 50"
|
||||||
|
|
||||||
|
# 1. Remove URLs
|
||||||
|
text = re.sub(r'http\S+|www\.\S+', '', text)
|
||||||
|
|
||||||
|
# 2. Remove "Playlist", "Music Chart", "Full SPOTIFY" spam keywords if desirable,
|
||||||
|
# but that might be too aggressive.
|
||||||
|
# Let's focus on cleaning the "Structure".
|
||||||
|
|
||||||
|
# 3. Truncate Description if too long (e.g. > 300 chars)?
|
||||||
|
# The user example had a MASSIVE description.
|
||||||
|
# Let's just take the first paragraph or chunk?
|
||||||
|
|
||||||
|
# 4. Remove excessive non-alphanumeric separators
|
||||||
|
text = re.sub(r'[*_=]{3,}', '', text) # Remove long separator lines
|
||||||
|
|
||||||
|
# Custom cleaning for the specific example style:
|
||||||
|
# Remove text between asterisks if it looks like garbage? No, sometimes it's emphasis.
|
||||||
|
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
def clean_title(title: str) -> str:
|
||||||
|
if not title: return "Playlist"
|
||||||
|
# Remove emojis (simple way)
|
||||||
|
title = title.encode('ascii', 'ignore').decode('ascii')
|
||||||
|
# Remove "Playlist", "Music Chart", "Full Video" spam
|
||||||
|
spam_words = ["Playlist", "Music Chart", "Full SPOTIFY Video", "Updated Weekly", "Official", "Video"]
|
||||||
|
for word in spam_words:
|
||||||
|
title = re.sub(word, "", title, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
# Remove extra spaces and asterisks
|
||||||
|
title = re.sub(r'\s+', ' ', title).strip()
|
||||||
|
title = title.strip('*- ')
|
||||||
|
return title
|
||||||
|
|
||||||
|
def clean_description(desc: str) -> str:
|
||||||
|
if not desc: return ""
|
||||||
|
# Remove URLs
|
||||||
|
desc = re.sub(r'http\S+', '', desc)
|
||||||
|
# Remove massive divider lines
|
||||||
|
desc = re.sub(r'[*_=]{3,}', '', desc)
|
||||||
|
# Be more aggressive with length?
|
||||||
|
if len(desc) > 300:
|
||||||
|
desc = desc[:300] + "..."
|
||||||
|
return desc.strip()
|
||||||
|
|
||||||
|
CACHE_DIR = Path("backend/cache")
|
||||||
|
|
||||||
|
class SearchRequest(BaseModel):
|
||||||
|
url: str
|
||||||
|
|
||||||
|
class CreatePlaylistRequest(BaseModel):
|
||||||
|
name: str # Renamed from Title to Name to match Sidebar usage more typically, but API expects pydantic model
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
@router.get("/browse")
|
||||||
|
async def get_browse_content():
|
||||||
|
"""
|
||||||
|
Returns the real fetched playlists from browse_playlists.json
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data_path = Path("backend/data/browse_playlists.json")
|
||||||
|
if data_path.exists():
|
||||||
|
with open(data_path, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Browse Error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
@router.get("/playlists")
|
||||||
|
async def get_user_playlists():
|
||||||
|
return playlist_manager.get_all()
|
||||||
|
|
||||||
|
@router.post("/playlists")
|
||||||
|
async def create_user_playlist(playlist: CreatePlaylistRequest):
|
||||||
|
return playlist_manager.create(playlist.name, playlist.description)
|
||||||
|
|
||||||
|
@router.delete("/playlists/{id}")
|
||||||
|
async def delete_user_playlist(id: str):
|
||||||
|
success = playlist_manager.delete(id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@router.get("/playlists/{id}")
|
||||||
|
async def get_playlist(id: str):
|
||||||
|
"""
|
||||||
|
Get a specific playlist by ID.
|
||||||
|
1. Check if it's a User Playlist.
|
||||||
|
2. If not, fetch from YouTube Music (Browse/External).
|
||||||
|
"""
|
||||||
|
# 1. Try User Playlist
|
||||||
|
user_playlists = playlist_manager.get_all()
|
||||||
|
user_playlist = next((p for p in user_playlists if p['id'] == id), None)
|
||||||
|
if user_playlist:
|
||||||
|
return user_playlist
|
||||||
|
|
||||||
|
# 2. Try External (YouTube Music)
|
||||||
|
# Check Cache first
|
||||||
|
cache_key = f"playlist:{id}"
|
||||||
|
cached_playlist = cache.get(cache_key)
|
||||||
|
if cached_playlist:
|
||||||
|
return cached_playlist
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ytmusicapi import YTMusic
|
||||||
|
yt = YTMusic()
|
||||||
|
# ytmusicapi returns a dict with 'tracks' list
|
||||||
|
playlist_data = yt.get_playlist(id, limit=100)
|
||||||
|
|
||||||
|
# Format to match our app's Protocol
|
||||||
|
formatted_tracks = []
|
||||||
|
if 'tracks' in playlist_data:
|
||||||
|
for track in playlist_data['tracks']:
|
||||||
|
# Safely extract artists
|
||||||
|
artists_list = track.get('artists') or []
|
||||||
|
if isinstance(artists_list, list):
|
||||||
|
artist_names = ", ".join([a.get('name', 'Unknown') for a in artists_list])
|
||||||
|
else:
|
||||||
|
artist_names = "Unknown Artist"
|
||||||
|
|
||||||
|
# Safely extract thumbnails
|
||||||
|
thumbnails = track.get('thumbnails', [])
|
||||||
|
cover_url = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300"
|
||||||
|
|
||||||
|
# Safely extract album
|
||||||
|
album_info = track.get('album')
|
||||||
|
album_name = album_info.get('name', 'Single') if album_info else "Single"
|
||||||
|
|
||||||
|
formatted_tracks.append({
|
||||||
|
"title": track.get('title', 'Unknown Title'),
|
||||||
|
"artist": artist_names,
|
||||||
|
"album": album_name,
|
||||||
|
"duration": track.get('duration_seconds', 0),
|
||||||
|
"cover_url": cover_url,
|
||||||
|
"id": track.get('videoId'),
|
||||||
|
"url": f"https://music.youtube.com/watch?v={track.get('videoId')}"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get Playlist Cover (usually highest res)
|
||||||
|
thumbnails = playlist_data.get('thumbnails', [])
|
||||||
|
p_cover = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300"
|
||||||
|
|
||||||
|
formatted_playlist = {
|
||||||
|
"id": playlist_data.get('id'),
|
||||||
|
"title": clean_title(playlist_data.get('title', 'Unknown')),
|
||||||
|
"description": clean_description(playlist_data.get('description', '')),
|
||||||
|
"author": playlist_data.get('author', {}).get('name', 'YouTube Music'),
|
||||||
|
"cover_url": p_cover,
|
||||||
|
"tracks": formatted_tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache it (1 hr)
|
||||||
|
cache.set(cache_key, formatted_playlist, ttl_seconds=3600)
|
||||||
|
return formatted_playlist
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Playlist Fetch Error: {e}")
|
||||||
|
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||||
|
|
||||||
|
class UpdatePlaylistRequest(BaseModel):
|
||||||
|
name: str = None
|
||||||
|
description: str = None
|
||||||
|
|
||||||
|
@router.put("/playlists/{id}")
|
||||||
|
async def update_user_playlist(id: str, playlist: UpdatePlaylistRequest):
|
||||||
|
updated = playlist_manager.update(id, name=playlist.name, description=playlist.description)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
class AddTrackRequest(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
artist: str
|
||||||
|
album: str
|
||||||
|
cover_url: str
|
||||||
|
duration: int = 0
|
||||||
|
url: str = ""
|
||||||
|
|
||||||
|
@router.post("/playlists/{id}/tracks")
|
||||||
|
async def add_track_to_playlist(id: str, track: AddTrackRequest):
|
||||||
|
track_data = track.dict()
|
||||||
|
success = playlist_manager.add_track(id, track_data)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search")
|
||||||
|
async def search_tracks(query: str):
|
||||||
|
"""
|
||||||
|
Search for tracks using ytmusicapi.
|
||||||
|
"""
|
||||||
|
if not query:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Check Cache
|
||||||
|
cache_key = f"search:{query.lower().strip()}"
|
||||||
|
cached_result = cache.get(cache_key)
|
||||||
|
if cached_result:
|
||||||
|
print(f"DEBUG: Returning cached search results for '{query}'")
|
||||||
|
return cached_result
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ytmusicapi import YTMusic
|
||||||
|
yt = YTMusic()
|
||||||
|
results = yt.search(query, filter="songs", limit=20)
|
||||||
|
|
||||||
|
tracks = []
|
||||||
|
for track in results:
|
||||||
|
# Safely extract artists
|
||||||
|
artists_list = track.get('artists') or []
|
||||||
|
if isinstance(artists_list, list):
|
||||||
|
artist_names = ", ".join([a.get('name', 'Unknown') for a in artists_list])
|
||||||
|
else:
|
||||||
|
artist_names = "Unknown Artist"
|
||||||
|
|
||||||
|
# Safely extract thumbnails
|
||||||
|
thumbnails = track.get('thumbnails', [])
|
||||||
|
cover_url = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300"
|
||||||
|
|
||||||
|
# Safely extract album
|
||||||
|
album_info = track.get('album')
|
||||||
|
album_name = album_info.get('name', 'Single') if album_info else "Single"
|
||||||
|
|
||||||
|
tracks.append({
|
||||||
|
"title": track.get('title', 'Unknown Title'),
|
||||||
|
"artist": artist_names,
|
||||||
|
"album": album_name,
|
||||||
|
"duration": track.get('duration_seconds', 0),
|
||||||
|
"cover_url": cover_url,
|
||||||
|
"id": track.get('videoId'),
|
||||||
|
"url": f"https://music.youtube.com/watch?v={track.get('videoId')}"
|
||||||
|
})
|
||||||
|
|
||||||
|
response_data = {"tracks": tracks}
|
||||||
|
# Cache for 24 hours (86400 seconds)
|
||||||
|
cache.set(cache_key, response_data, ttl_seconds=86400)
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Search Error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.get("/recommendations")
|
||||||
|
async def get_recommendations(seed_id: str = None):
|
||||||
|
"""
|
||||||
|
Get recommended tracks (Play History based or Trending).
|
||||||
|
If seed_id is provided, fetches 'Up Next' / 'Radio' tracks for that video.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from ytmusicapi import YTMusic
|
||||||
|
yt = YTMusic()
|
||||||
|
|
||||||
|
if not seed_id:
|
||||||
|
# Fallback to Trending if no history
|
||||||
|
return await get_trending()
|
||||||
|
|
||||||
|
cache_key = f"rec:{seed_id}"
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# Use get_watch_playlist to find similar tracks (Radio)
|
||||||
|
watch_playlist = yt.get_watch_playlist(videoId=seed_id, limit=20)
|
||||||
|
|
||||||
|
tracks = []
|
||||||
|
if 'tracks' in watch_playlist:
|
||||||
|
for track in watch_playlist['tracks']:
|
||||||
|
# Skip the seed track itself if play history already has it
|
||||||
|
if track.get('videoId') == seed_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
artists_list = track.get('artists') or []
|
||||||
|
if isinstance(artists_list, list):
|
||||||
|
artist_names = ", ".join([a.get('name', 'Unknown') for a in artists_list])
|
||||||
|
else:
|
||||||
|
artist_names = "Unknown Artist"
|
||||||
|
|
||||||
|
thumbnails = track.get('thumbnails', [])
|
||||||
|
cover_url = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300"
|
||||||
|
|
||||||
|
# album is often missing in watch playlist, fallback
|
||||||
|
album_info = track.get('album')
|
||||||
|
album_name = album_info.get('name', 'Single') if album_info else "Single"
|
||||||
|
|
||||||
|
tracks.append({
|
||||||
|
"title": track.get('title', 'Unknown Title'),
|
||||||
|
"artist": artist_names,
|
||||||
|
"album": album_name,
|
||||||
|
"duration": track.get('length_seconds', track.get('duration_seconds', 0)),
|
||||||
|
"cover_url": cover_url,
|
||||||
|
"id": track.get('videoId'),
|
||||||
|
"url": f"https://music.youtube.com/watch?v={track.get('videoId')}"
|
||||||
|
})
|
||||||
|
|
||||||
|
response_data = {"tracks": tracks}
|
||||||
|
cache.set(cache_key, response_data, ttl_seconds=3600) # 1 hour cache
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Recommendation Error: {e}")
|
||||||
|
# Fallback to trending on error
|
||||||
|
return await get_trending()
|
||||||
|
|
||||||
|
@router.get("/recommendations/albums")
|
||||||
|
async def get_recommended_albums(seed_artist: str = None):
|
||||||
|
"""
|
||||||
|
Get recommended albums based on an artist query.
|
||||||
|
"""
|
||||||
|
if not seed_artist:
|
||||||
|
return []
|
||||||
|
|
||||||
|
cache_key = f"rec_albums:{seed_artist.lower().strip()}"
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ytmusicapi import YTMusic
|
||||||
|
yt = YTMusic()
|
||||||
|
|
||||||
|
# Search for albums by this artist
|
||||||
|
results = yt.search(seed_artist, filter="albums", limit=10)
|
||||||
|
|
||||||
|
albums = []
|
||||||
|
for album in results:
|
||||||
|
thumbnails = album.get('thumbnails', [])
|
||||||
|
cover_url = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300"
|
||||||
|
|
||||||
|
albums.append({
|
||||||
|
"title": album.get('title', 'Unknown Album'),
|
||||||
|
"description": album.get('year', '') + " • " + album.get('artist', seed_artist),
|
||||||
|
"cover_url": cover_url,
|
||||||
|
"id": album.get('browseId'),
|
||||||
|
"type": "Album"
|
||||||
|
})
|
||||||
|
|
||||||
|
cache.set(cache_key, albums, ttl_seconds=86400)
|
||||||
|
return albums
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Album Rec Error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
@router.get("/trending")
|
||||||
|
async def get_trending():
|
||||||
|
"""
|
||||||
|
Returns the pre-fetched Trending Vietnam playlist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data_path = Path("backend/data.json")
|
||||||
|
if data_path.exists():
|
||||||
|
with open(data_path, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
else:
|
||||||
|
return {"error": "Trending data not found. Run fetch_data.py first."}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.get("/stream")
|
||||||
|
async def stream_audio(id: str):
|
||||||
|
"""
|
||||||
|
Stream audio for a given YouTube video ID.
|
||||||
|
Extracts direct URL via yt-dlp and streams it.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check Cache for stream URL
|
||||||
|
cache_key = f"stream:{id}"
|
||||||
|
cached_url = cache.get(cache_key)
|
||||||
|
|
||||||
|
stream_url = None
|
||||||
|
if cached_url:
|
||||||
|
print(f"DEBUG: Using cached stream URL for '{id}'")
|
||||||
|
stream_url = cached_url
|
||||||
|
else:
|
||||||
|
print(f"DEBUG: Fetching new stream URL for '{id}'")
|
||||||
|
url = f"https://www.youtube.com/watch?v={id}"
|
||||||
|
ydl_opts = {
|
||||||
|
'format': 'bestaudio/best',
|
||||||
|
'quiet': True,
|
||||||
|
'noplaylist': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract direct URL
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(url, download=False)
|
||||||
|
stream_url = info.get('url')
|
||||||
|
|
||||||
|
if stream_url:
|
||||||
|
# Cache for 1 hour (3600 seconds) - URLs expire
|
||||||
|
cache.set(cache_key, stream_url, ttl_seconds=3600)
|
||||||
|
|
||||||
|
if not stream_url:
|
||||||
|
raise HTTPException(status_code=404, detail="Audio stream not found")
|
||||||
|
|
||||||
|
# Stream the content
|
||||||
|
def iterfile():
|
||||||
|
# Verify if URL is still valid by making a HEAD request or handling stream error
|
||||||
|
# For simplicity, we just try to stream. If 403, we might need to invalidate,
|
||||||
|
# but that logic is complex for this method.
|
||||||
|
with requests.get(stream_url, stream=True) as r:
|
||||||
|
r.raise_for_status() # Check for 403
|
||||||
|
# Use smaller chunks (64KB) for better TTFB (Time To First Byte)
|
||||||
|
for chunk in r.iter_content(chunk_size=64*1024):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
# Note: We return audio/mpeg, but it might be opus/webm.
|
||||||
|
# Browsers are usually smart enough to sniff.
|
||||||
|
return StreamingResponse(iterfile(), media_type="audio/mpeg")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Stream Error: {e}")
|
||||||
|
# If cached URL failed (likely 403), we could try to invalidate here,
|
||||||
|
# but for now we just return error.
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.get("/download")
|
||||||
|
async def download_audio(id: str, title: str = "audio"):
|
||||||
|
"""
|
||||||
|
Download audio for a given YouTube video ID.
|
||||||
|
Proxies the stream content as a file attachment.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check Cache for stream URL
|
||||||
|
cache_key = f"stream:{id}"
|
||||||
|
cached_url = cache.get(cache_key)
|
||||||
|
|
||||||
|
stream_url = None
|
||||||
|
if cached_url:
|
||||||
|
stream_url = cached_url
|
||||||
|
else:
|
||||||
|
url = f"https://www.youtube.com/watch?v={id}"
|
||||||
|
ydl_opts = {
|
||||||
|
'format': 'bestaudio/best',
|
||||||
|
'quiet': True,
|
||||||
|
'noplaylist': True,
|
||||||
|
}
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(url, download=False)
|
||||||
|
stream_url = info.get('url')
|
||||||
|
|
||||||
|
if stream_url:
|
||||||
|
cache.set(cache_key, stream_url, ttl_seconds=3600)
|
||||||
|
|
||||||
|
if not stream_url:
|
||||||
|
raise HTTPException(status_code=404, detail="Audio stream not found")
|
||||||
|
|
||||||
|
# Stream the content with attachment header
|
||||||
|
def iterfile():
|
||||||
|
with requests.get(stream_url, stream=True) as r:
|
||||||
|
r.raise_for_status()
|
||||||
|
for chunk in r.iter_content(chunk_size=1024*1024):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
# Sanitize filename
|
||||||
|
safe_filename = "".join([c for c in title if c.isalnum() or c in (' ', '-', '_')]).strip()
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": f'attachment; filename="{safe_filename}.mp3"'
|
||||||
|
}
|
||||||
|
|
||||||
|
return StreamingResponse(iterfile(), media_type="audio/mpeg", headers=headers)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Download Error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.get("/lyrics")
|
||||||
|
async def get_lyrics(id: str, title: str = None, artist: str = None):
|
||||||
|
"""
|
||||||
|
Fetch synchronized lyrics using multiple providers hierarchy:
|
||||||
|
1. Cache (fastest)
|
||||||
|
2. yt-dlp (Original Video Captions - best sync for exact video)
|
||||||
|
3. LRCLIB (Open Source Database - good fuzzy match)
|
||||||
|
4. syncedlyrics (Musixmatch/NetEase Aggregator - widest coverage)
|
||||||
|
"""
|
||||||
|
if not id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
cache_key = f"lyrics:{id}"
|
||||||
|
cached_lyrics = cache.get(cache_key)
|
||||||
|
if cached_lyrics:
|
||||||
|
return cached_lyrics
|
||||||
|
|
||||||
|
parsed_lines = []
|
||||||
|
|
||||||
|
# Run heavy IO in threadpool
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
import syncedlyrics
|
||||||
|
|
||||||
|
try:
|
||||||
|
# --- Strategy 1: yt-dlp (Official Captions) ---
|
||||||
|
def fetch_ytdlp_subs():
|
||||||
|
parsed = []
|
||||||
|
try:
|
||||||
|
lyrics_dir = CACHE_DIR / "lyrics"
|
||||||
|
lyrics_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_tmpl = str(lyrics_dir / f"{id}")
|
||||||
|
ydl_opts = {
|
||||||
|
'skip_download': True, 'writesubtitles': True, 'writeautomaticsub': True,
|
||||||
|
'subtitleslangs': ['en', 'vi'], 'subtitlesformat': 'json3',
|
||||||
|
'outtmpl': out_tmpl, 'quiet': True
|
||||||
|
}
|
||||||
|
url = f"https://www.youtube.com/watch?v={id}"
|
||||||
|
import glob
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
ydl.download([url])
|
||||||
|
|
||||||
|
pattern = str(lyrics_dir / f"{id}.*.json3")
|
||||||
|
found_files = glob.glob(pattern)
|
||||||
|
if found_files:
|
||||||
|
best_file = next((f for f in found_files if f.endswith(f"{id}.en.json3")), found_files[0])
|
||||||
|
with open(best_file, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
for event in data.get('events', []):
|
||||||
|
if 'segs' in event and 'tStartMs' in event:
|
||||||
|
text = "".join([s.get('utf8', '') for s in event['segs']]).strip()
|
||||||
|
if text and not text.startswith('[') and text != '\n':
|
||||||
|
parsed.append({"time": float(event['tStartMs']) / 1000.0, "text": text})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"yt-dlp sub error: {e}")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
parsed_lines = await run_in_threadpool(fetch_ytdlp_subs)
|
||||||
|
|
||||||
|
# --- Strategy 2: LRCLIB (Search API) ---
|
||||||
|
if not parsed_lines and title and artist:
|
||||||
|
print(f"Trying LRCLIB Search for: {title} {artist}")
|
||||||
|
def fetch_lrclib():
|
||||||
|
try:
|
||||||
|
# Fuzzy match using search, not get
|
||||||
|
cleaned_title = re.sub(r'\(.*?\)', '', title)
|
||||||
|
clean_query = f"{artist} {cleaned_title}".strip()
|
||||||
|
resp = requests.get("https://lrclib.net/api/search", params={"q": clean_query}, timeout=5)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
results = resp.json()
|
||||||
|
# Find first result with synced lyrics
|
||||||
|
for item in results:
|
||||||
|
if item.get("syncedLyrics"):
|
||||||
|
return parse_lrc_string(item["syncedLyrics"])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"LRCLIB error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
parsed_lines = await run_in_threadpool(fetch_lrclib)
|
||||||
|
|
||||||
|
# --- Strategy 3: syncedlyrics (Aggregator) ---
|
||||||
|
if not parsed_lines and title and artist:
|
||||||
|
print(f"Trying SyncedLyrics Aggregator for: {title} {artist}")
|
||||||
|
def fetch_syncedlyrics():
|
||||||
|
try:
|
||||||
|
# syncedlyrics.search returns the LRC string or None
|
||||||
|
clean_query = f"{title} {artist}".strip()
|
||||||
|
lrc_str = syncedlyrics.search(clean_query)
|
||||||
|
if lrc_str:
|
||||||
|
return parse_lrc_string(lrc_str)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"SyncedLyrics error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
parsed_lines = await run_in_threadpool(fetch_syncedlyrics)
|
||||||
|
|
||||||
|
# Cache Result
|
||||||
|
if parsed_lines:
|
||||||
|
cache.set(cache_key, parsed_lines, ttl_seconds=86400 * 30)
|
||||||
|
return parsed_lines
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Global Lyrics Error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def parse_lrc_string(lrc_content: str):
|
||||||
|
"""Parses LRC format string into [{time, text}]"""
|
||||||
|
lines = []
|
||||||
|
if not lrc_content: return lines
|
||||||
|
for line in lrc_content.split('\n'):
|
||||||
|
# Format: [mm:ss.xx] Text
|
||||||
|
match = re.search(r'\[(\d+):(\d+\.?\d*)\](.*)', line)
|
||||||
|
if match:
|
||||||
|
minutes = float(match.group(1))
|
||||||
|
seconds = float(match.group(2))
|
||||||
|
text = match.group(3).strip()
|
||||||
|
total_time = minutes * 60 + seconds
|
||||||
|
if text:
|
||||||
|
lines.append({"time": total_time, "text": text})
|
||||||
|
return lines
|
||||||
53
backend/cache_manager.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
class CacheManager:
|
||||||
|
def __init__(self, cache_dir: str = "backend/cache"):
|
||||||
|
self.cache_dir = Path(cache_dir)
|
||||||
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _get_path(self, key: str) -> Path:
|
||||||
|
# Create a safe filename from the key
|
||||||
|
hashed_key = hashlib.md5(key.encode()).hexdigest()
|
||||||
|
return self.cache_dir / f"{hashed_key}.json"
|
||||||
|
|
||||||
|
def get(self, key: str) -> Optional[Any]:
|
||||||
|
"""
|
||||||
|
Retrieve data from cache if it exists and hasn't expired.
|
||||||
|
"""
|
||||||
|
path = self._get_path(key)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Check TTL
|
||||||
|
if data["expires_at"] < time.time():
|
||||||
|
# Expired, delete it
|
||||||
|
path.unlink()
|
||||||
|
return None
|
||||||
|
|
||||||
|
return data["value"]
|
||||||
|
except (json.JSONDecodeError, KeyError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any, ttl_seconds: int = 3600):
|
||||||
|
"""
|
||||||
|
Save data to cache with a TTL (default 1 hour).
|
||||||
|
"""
|
||||||
|
path = self._get_path(key)
|
||||||
|
data = {
|
||||||
|
"value": value,
|
||||||
|
"expires_at": time.time() + ttl_seconds,
|
||||||
|
"key_debug": key # Store original key for debugging
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
except OSError as e:
|
||||||
|
print(f"Cache Write Error: {e}")
|
||||||
548
backend/data.json
Normal file
|
|
@ -0,0 +1,548 @@
|
||||||
|
{
|
||||||
|
"id": "PLpY7hx7jry7zc4zspi_fBhWQt8z5jrJ8z",
|
||||||
|
"title": "Best Vietnamese Songs 2025 - Popular Vietnamese Songs 2025 Playlist (Top Vietnamese Music 2025-2026)",
|
||||||
|
"description": "Best Vietnamese Songs 2025 - Popular Vietnamese Songs 2025 Playlist (Top Vietnamese Music 2025-2026)\n\nIf you liked this playlist, we recommend you also listen to these music lists:\n\n1. Most Popular Vietnamese Songs 2025 - Best of Vietnamese Music 2025 Playlist (Famous Vietnamese Songs 2025-2026) - https://goplaylists.com/56624\n2. New Vietnamese Songs 2025 - Latest Vietnamese Song 2025 Playlist (New Vietnam Music 2025-2026) - https://goplaylists.com/13081\n\nFind our playlist with these keywords: popular vietnamese songs 2025, top vietnamese songs 2025, best vietnamese music 2025, vietnam music playlist, top vietnamese music 2025, vietnam playlist 2025, vietnamese songs 2025, popular, vietnamese songs, vietnam playlist music, best vietnamese songs 2025, vietnamese playlist 2025, vietnamese hits 2025, vietnamese songs, top vietnam music 2025, vietnam song playlist, top 10 vietnamese songs, vietnam music chart 2025, vietnamese song trends\n\nDive deep into the mesmerizing world of Vietnamese music with a curated collection of the year's most compelling tracks. Experience the rhythm, the emotion, and the soulful voices of Vietnam's top artists. Each song has been handpicked to represent the heartbeat of Vietnam's contemporary music scene, bringing to you an array of melodies that resonate with the beauty and culture of this enchanting nation. Whether you're a longtime fan or a newcomer to Vietnamese tunes, this selection is bound to captivate your senses and take you on an unforgettable musical journey \ud83c\udfb5.\n\nIn the next year, the playlist is going to be titled: Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)\n Last year, the playlist was titled: Best Vietnamese Songs 2024 - Popular Vietnamese Songs 2024 Playlist (Top Vietnamese Music 2024-2025)\n\nShare your thoughts on our playlist: contact@red-music.com",
|
||||||
|
"cover_url": "https://yt3.googleusercontent.com/JaOpEjRt9S4wYkuVMkbu_2NLadP4vtUfQIpUlfob8mgB3CuoX8AsAJ24tAtbNRXGD2AkekLlbkiU=s1200",
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"title": "Kh\u00f3a Ly Bi\u1ec7t (feat. Anh T\u00fa)",
|
||||||
|
"artist": "The Masked Singer",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 327,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/wEPX3V5T63M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kG4sf2WSe1cO3TUeSG4cyGdPXJFg",
|
||||||
|
"id": "wEPX3V5T63M",
|
||||||
|
"url": "https://music.youtube.com/watch?v=wEPX3V5T63M"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "T\u1eebng Ng\u00e0y Y\u00eau Em",
|
||||||
|
"artist": "buitruonglinh",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 222,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/f-VsoLm4i5c/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3loMnKS_mCSDRyYu9wS_TYnA0NAgQ",
|
||||||
|
"id": "f-VsoLm4i5c",
|
||||||
|
"url": "https://music.youtube.com/watch?v=f-VsoLm4i5c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "M\u1ea5t K\u1ebft N\u1ed1i",
|
||||||
|
"artist": "D\u01b0\u01a1ng Domic",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 208,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/lRsaDQtYqAo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mvidflzqRlL9xdJeDUXZJg_UESRw",
|
||||||
|
"id": "lRsaDQtYqAo",
|
||||||
|
"url": "https://music.youtube.com/watch?v=lRsaDQtYqAo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "m\u1ed9t \u0111\u1eddi (feat. buitruonglinh)",
|
||||||
|
"artist": "Bon Nghi\u00eam, 14 Casper",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 329,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/JgTZvDbaTtg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lEKS8TNud8_GWknaWc0IQEQWBTgw",
|
||||||
|
"id": "JgTZvDbaTtg",
|
||||||
|
"url": "https://music.youtube.com/watch?v=JgTZvDbaTtg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "V\u00f9ng An To\u00e0n",
|
||||||
|
"artist": "V#, B Ray",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 266,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/_XX248bq6Pw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nKfVzeukJ8dCNJ_hzcyZAsvJ8upg",
|
||||||
|
"id": "_XX248bq6Pw",
|
||||||
|
"url": "https://music.youtube.com/watch?v=_XX248bq6Pw"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Qu\u00e2n A.P | C\u00f3 Ai H\u1eb9n H\u00f2 C\u00f9ng Em Ch\u01b0a",
|
||||||
|
"artist": "Qu\u00e2n A.P",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 319,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/zHDECJy0p7k/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nWDqcf0SVJtIipbWQqltt3cNu6eQ",
|
||||||
|
"id": "zHDECJy0p7k",
|
||||||
|
"url": "https://music.youtube.com/watch?v=zHDECJy0p7k"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "b\u00ecnh y\u00ean - V\u0169. (feat. Binz)",
|
||||||
|
"artist": "V\u0169., Binz",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 203,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/f9P7_qWrf38/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kI5gsa8Jegzzu2vFpJBhLk58mGeg",
|
||||||
|
"id": "f9P7_qWrf38",
|
||||||
|
"url": "https://music.youtube.com/watch?v=f9P7_qWrf38"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "n\u1ebfu l\u00fac \u0111\u00f3 (feat. 2pillz)",
|
||||||
|
"artist": "Tlinh",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 325,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/fyMgBQioTLo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kNXGGAK5wy2ix4mQ1pNwlGLYUg0Q",
|
||||||
|
"id": "fyMgBQioTLo",
|
||||||
|
"url": "https://music.youtube.com/watch?v=fyMgBQioTLo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "\u0110\u1eebng L\u00e0m Tr\u00e1i Tim Anh \u0110au",
|
||||||
|
"artist": "S\u01a1n T\u00f9ng M-TP",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 326,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/abPmZCZZrFA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nMzmdGlrfqmf8o9z-E5waTnqFXxA",
|
||||||
|
"id": "abPmZCZZrFA",
|
||||||
|
"url": "https://music.youtube.com/watch?v=abPmZCZZrFA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "N\u1ed7i \u0110au Gi\u1eefa H\u00f2a B\u00ecnh",
|
||||||
|
"artist": "H\u00f2a Minzy, Nguyen Van Chung",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 454,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/yHikkFeIHNA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mhpsSG0tDGSBKkAK1X81aY1nrfgg",
|
||||||
|
"id": "yHikkFeIHNA",
|
||||||
|
"url": "https://music.youtube.com/watch?v=yHikkFeIHNA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "10 Shots",
|
||||||
|
"artist": "Dax",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 233,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/0XMFwdfMQmQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3napt1cPSL4BTo7SSeDyrRUU7XF0Q",
|
||||||
|
"id": "0XMFwdfMQmQ",
|
||||||
|
"url": "https://music.youtube.com/watch?v=0XMFwdfMQmQ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Ch\u0103m Hoa",
|
||||||
|
"artist": "MONO",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 260,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/WCm2elbTEZQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kW5xCqL0V0Q9miffXVKmSRnn3S8A",
|
||||||
|
"id": "WCm2elbTEZQ",
|
||||||
|
"url": "https://music.youtube.com/watch?v=WCm2elbTEZQ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "id 072019",
|
||||||
|
"artist": "W/n",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 303,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/leJb3VhQCrg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nWBTudc9VK3UqnpCgc_j8QYH3ugg",
|
||||||
|
"id": "leJb3VhQCrg",
|
||||||
|
"url": "https://music.youtube.com/watch?v=leJb3VhQCrg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Gi\u1edd Th\u00ec",
|
||||||
|
"artist": "buitruonglinh",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 238,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/69ZDBWoj5YM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3krSRZmxC0XjjdQN0wwPoZbJ-unGQ",
|
||||||
|
"id": "69ZDBWoj5YM",
|
||||||
|
"url": "https://music.youtube.com/watch?v=69ZDBWoj5YM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "ERIK - 'D\u00f9 cho t\u1eadn th\u1ebf (v\u1eabn y\u00eau em)' | Official MV | Valentine 2025",
|
||||||
|
"artist": "ERIK",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 301,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/js6JBdLzNn4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nh_ITbZeDs1TJfrWuPEim8MKpj9g",
|
||||||
|
"id": "js6JBdLzNn4",
|
||||||
|
"url": "https://music.youtube.com/watch?v=js6JBdLzNn4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Ng\u00e0y Mai Ng\u01b0\u1eddi Ta L\u1ea5y Ch\u1ed3ng",
|
||||||
|
"artist": "Th\u00e0nh \u0110\u1ea1t",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 421,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/WL11bwvAYWI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3l10haMFB1HcY7p5muA1kJL5tz1cA",
|
||||||
|
"id": "WL11bwvAYWI",
|
||||||
|
"url": "https://music.youtube.com/watch?v=WL11bwvAYWI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "B\u1ea7u Tr\u1eddi M\u1edbi (feat. Minh Toc)",
|
||||||
|
"artist": "Da LAB",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 291,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/Z1D26z9l8y8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3k34PODWHnu_p49YHu35__8V-4avw",
|
||||||
|
"id": "Z1D26z9l8y8",
|
||||||
|
"url": "https://music.youtube.com/watch?v=Z1D26z9l8y8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "C\u00e1nh Hoa H\u00e9o T\u00e0n (DJ Trang Moon Remix)",
|
||||||
|
"artist": "ACV, Mochiii",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 265,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/YizrmzMvr7Q/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3luIG3PhCNjJlZjuCRBwAKKrMPt9Q",
|
||||||
|
"id": "YizrmzMvr7Q",
|
||||||
|
"url": "https://music.youtube.com/watch?v=YizrmzMvr7Q"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "SOOBIN - gi\u00e1 nh\u01b0 | 'B\u1eacT N\u00d3 L\u00caN' Album (Music Video)",
|
||||||
|
"artist": "SOOBIN",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 310,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/SeWt7IpZ0CA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lYIMR-uDbHo5-B3GO0z5XPqKIcaQ",
|
||||||
|
"id": "SeWt7IpZ0CA",
|
||||||
|
"url": "https://music.youtube.com/watch?v=SeWt7IpZ0CA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Vuon Hoa Con Ca",
|
||||||
|
"artist": "Olew",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 234,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/BFflHDlTeHw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nA_WhH_ZnanoXMGeQ-4d4hYSUbUg",
|
||||||
|
"id": "BFflHDlTeHw",
|
||||||
|
"url": "https://music.youtube.com/watch?v=BFflHDlTeHw"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "G\u1eb7p L\u1ea1i N\u0103m Ta 60",
|
||||||
|
"artist": "Orange",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 337,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/ZXNrz72k1ew/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kqqJWC4ogBjc4u12JzCHk2YMBKWA",
|
||||||
|
"id": "ZXNrz72k1ew",
|
||||||
|
"url": "https://music.youtube.com/watch?v=ZXNrz72k1ew"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "You're The Problem",
|
||||||
|
"artist": "Dax",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 230,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/-kA2ReyByZU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kg2w-R3-05ocVT0g03RyIQJ41F4Q",
|
||||||
|
"id": "-kA2ReyByZU",
|
||||||
|
"url": "https://music.youtube.com/watch?v=-kA2ReyByZU"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "SOOBIN - Dancing In The Dark | 'B\u1eacT N\u00d3 L\u00caN' Album",
|
||||||
|
"artist": "SOOBIN",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 279,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/OZmK0YuSmXU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mBF8aJUqrrJIQduCkE_BAGkeucDA",
|
||||||
|
"id": "OZmK0YuSmXU",
|
||||||
|
"url": "https://music.youtube.com/watch?v=OZmK0YuSmXU"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Lao T\u00e2m Kh\u1ed5 T\u1ee9",
|
||||||
|
"artist": "Thanh H\u01b0ng",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 348,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/TfKOFRpqSME/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n85vMTLZIA2MAj83vqnYk4pomt0Q",
|
||||||
|
"id": "TfKOFRpqSME",
|
||||||
|
"url": "https://music.youtube.com/watch?v=TfKOFRpqSME"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "N\u1ea5u \u0102n Cho Em (feat. PIALINH)",
|
||||||
|
"artist": "\u0110en",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 257,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/ukHK1GVyr0I/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nD2JOikDSq_cCeBaG-VH6LBYriJg",
|
||||||
|
"id": "ukHK1GVyr0I",
|
||||||
|
"url": "https://music.youtube.com/watch?v=ukHK1GVyr0I"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "T\u1ebft B\u00ecnh An Remix, Hana C\u1ea9m Ti\u00ean, \u0110\u1ea1i M\u00e8o | M\u1ed9t N\u0103m C\u0169 \u0110\u00e3 Qua C\u00f9ng Nhau \u0110\u00f3n N\u0103m M\u1edbi B\u00ecnh An Mu\u00f4n Nh\u00e0",
|
||||||
|
"artist": "BD Media Music",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 227,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/fMskPmI4tp0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3m8nsW6nX2B8BJf4gsU36uDsmDCgw",
|
||||||
|
"id": "fMskPmI4tp0",
|
||||||
|
"url": "https://music.youtube.com/watch?v=fMskPmI4tp0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "T\u1eebng L\u00e0",
|
||||||
|
"artist": "V\u0169 C\u00e1t T\u01b0\u1eddng",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 277,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/i4qZmKSFYvI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kLaE-0VAlEfGQRlKBACGiK0w0WDw",
|
||||||
|
"id": "i4qZmKSFYvI",
|
||||||
|
"url": "https://music.youtube.com/watch?v=i4qZmKSFYvI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "N\u01a1i Ph\u00e1o Hoa R\u1ef1c R\u1ee1 (feat. C\u1ea9m V\u00e2n)",
|
||||||
|
"artist": "Hua Kim Tuyen, Orange, Ho\u00e0ng D\u0169ng",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 310,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/BgUFNi5MvzE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mGOmc3dRUaQczZnhubm_nb8Gs_Uw",
|
||||||
|
"id": "BgUFNi5MvzE",
|
||||||
|
"url": "https://music.youtube.com/watch?v=BgUFNi5MvzE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Ng\u01b0\u1eddi B\u00ecnh Th\u01b0\u1eddng",
|
||||||
|
"artist": "V\u0169 C\u00e1t T\u01b0\u1eddng",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 285,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/X5KvHXWPYm4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lPWTFBiFDjAliZkS614MkwVcte1g",
|
||||||
|
"id": "X5KvHXWPYm4",
|
||||||
|
"url": "https://music.youtube.com/watch?v=X5KvHXWPYm4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "C\u00f3 Em L\u00e0 \u0110i\u1ec1u Tuy\u1ec7t V\u1eddi Nh\u1ea5t (Th\u1ecbnh H\u00e0nh)",
|
||||||
|
"artist": "Thi\u00ean T\u00fa",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 371,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/IenfKDtyMI0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nbyyByYoJQ2qV7-2w4S6Gyofj9dQ",
|
||||||
|
"id": "IenfKDtyMI0",
|
||||||
|
"url": "https://music.youtube.com/watch?v=IenfKDtyMI0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Nh\u1eefng L\u1eddi H\u1ee9a B\u1ecf Qu\u00ean",
|
||||||
|
"artist": "V\u0169., Dear Jane",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 259,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/h6RONxjPBf4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfvRCueWOo-OjD8_3sK9HSlhvoSw",
|
||||||
|
"id": "h6RONxjPBf4",
|
||||||
|
"url": "https://music.youtube.com/watch?v=h6RONxjPBf4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "m\u1ed9t b\u00e0i h\u00e1t kh\u00f4ng vui m\u1ea5y (Extended Version)",
|
||||||
|
"artist": "T.R.I, Dangrangto, DONAL",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 230,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/EvPEeSBfB3E/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kvhX3tBQICPMgOEn0R9uswYvdC5A",
|
||||||
|
"id": "EvPEeSBfB3E",
|
||||||
|
"url": "https://music.youtube.com/watch?v=EvPEeSBfB3E"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "One Time",
|
||||||
|
"artist": "Raw Dawg",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 119,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/ylh1oDhP2AE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lRAtyI5EucwyXxXGb9YLfFY2W6aQ",
|
||||||
|
"id": "ylh1oDhP2AE",
|
||||||
|
"url": "https://music.youtube.com/watch?v=ylh1oDhP2AE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "MIN - ch\u1eb3ng ph\u1ea3i t\u00ecnh \u0111\u1ea7u sao \u0111au \u0111\u1ebfn th\u1ebf feat. Dangrangto, Antransax (Official Audio)",
|
||||||
|
"artist": "MIN",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 284,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/rLNvDu59ffI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfhSKyeyGqrokp13H6G7C1rNvKLg",
|
||||||
|
"id": "rLNvDu59ffI",
|
||||||
|
"url": "https://music.youtube.com/watch?v=rLNvDu59ffI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Ng\u01b0\u1eddi \u0110\u1ea7u Ti\u00ean",
|
||||||
|
"artist": "Juky San, buitruonglinh",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 220,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/i54avTdUqwU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3klVzmY8CCpa5CLEP3BIema5Lhgyw",
|
||||||
|
"id": "i54avTdUqwU",
|
||||||
|
"url": "https://music.youtube.com/watch?v=i54avTdUqwU"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "MIN - ch\u1eb3ng ph\u1ea3i t\u00ecnh \u0111\u1ea7u sao \u0111au \u0111\u1ebfn th\u1ebf feat. Dangrangto, antransax (Official Visual Stage)",
|
||||||
|
"artist": "MIN OFFICIAL",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 288,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/s0OMNH-N5D8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3k_uFbHN8ud3zNWnb5hdzcYLhUgWA",
|
||||||
|
"id": "s0OMNH-N5D8",
|
||||||
|
"url": "https://music.youtube.com/watch?v=s0OMNH-N5D8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Em",
|
||||||
|
"artist": "Binz",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 208,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/CU2PtRKBkuw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mnGQ84aQvDmEMNCd5m6b-_pyKbUg",
|
||||||
|
"id": "CU2PtRKBkuw",
|
||||||
|
"url": "https://music.youtube.com/watch?v=CU2PtRKBkuw"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "HO\u1ea0 S\u0128 T\u1ed2I - TH\u00c1I H\u1eccC x \u0110\u1ea0T MAX | Official MV | Anh v\u1ebd c\u1ea7u v\u1ed3ng th\u00ec l\u1ea1i thi\u1ebfu n\u1eafng",
|
||||||
|
"artist": "Th\u00e1i H\u1ecdc",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 285,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/RF0jYdTXQK4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nAfOOBrWfNICLXduP5GPktAPARCg",
|
||||||
|
"id": "RF0jYdTXQK4",
|
||||||
|
"url": "https://music.youtube.com/watch?v=RF0jYdTXQK4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "T\u00ecnh Nh\u01b0 L\u00e1 Bay Xa (Live)",
|
||||||
|
"artist": "Jimmy Nguyen, M\u1ef8 T\u00c2M",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 273,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/gxPoI_tldfQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nQp0dsN1t1shvvjBq0A2m-EyfvBg",
|
||||||
|
"id": "gxPoI_tldfQ",
|
||||||
|
"url": "https://music.youtube.com/watch?v=gxPoI_tldfQ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Kh\u1ed5ng Minh x Ch\u00e2u Nhu\u1eadn Ph\u00e1t - ''E L\u00c0 \u0110\u00d4N CH\u1ec0'' Prod.@tiengaz",
|
||||||
|
"artist": "Dagoats House",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 191,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/K01LvulhFRg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n8fN2PiGDRGtGv0VZMp_OOW2kBoQ",
|
||||||
|
"id": "K01LvulhFRg",
|
||||||
|
"url": "https://music.youtube.com/watch?v=K01LvulhFRg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "M\u1ee5c H\u1ea1 V\u00f4 Nh\u00e2n (feat. Binz)",
|
||||||
|
"artist": "SOOBIN, Hu\u1ef3nh T\u00fa",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 355,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/FikdKWos-NQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lgfIwIcM4zdZGPxZN-kcs96iJyGQ",
|
||||||
|
"id": "FikdKWos-NQ",
|
||||||
|
"url": "https://music.youtube.com/watch?v=FikdKWos-NQ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "10 M\u1ea4T 1 C\u00d2N KH\u00d4NG - TH\u00c1I H\u1eccC x L\u00ca GIA B\u1ea2O (New Version) | St: Long H\u1ecd Hu\u1ef3nh",
|
||||||
|
"artist": "Th\u00e1i H\u1ecdc",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 285,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/9HnyyKg0M-Y/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lyCTROUhGaahuDenut3oMfnOesDQ",
|
||||||
|
"id": "9HnyyKg0M-Y",
|
||||||
|
"url": "https://music.youtube.com/watch?v=9HnyyKg0M-Y"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Mr Siro | Day D\u1ee9t N\u1ed7i \u0110au",
|
||||||
|
"artist": "Mr. Siro",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 368,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/N4Xak1n497M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nZ6HzRlVHFya6aliEsGSZuGB_QxA",
|
||||||
|
"id": "N4Xak1n497M",
|
||||||
|
"url": "https://music.youtube.com/watch?v=N4Xak1n497M"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Diary Of A Trying Man",
|
||||||
|
"artist": "Dax",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 234,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/WulTil-Wwoo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lC8LD69LfTh3hrClQoWJGA3pCjCw",
|
||||||
|
"id": "WulTil-Wwoo",
|
||||||
|
"url": "https://music.youtube.com/watch?v=WulTil-Wwoo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Feel At Home",
|
||||||
|
"artist": "B Ray",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 164,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/6x1yluqMuc4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfaiwiYPKbD_v3rvKR1QC1Sw9Znw",
|
||||||
|
"id": "6x1yluqMuc4",
|
||||||
|
"url": "https://music.youtube.com/watch?v=6x1yluqMuc4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "L\u00e1 Th\u01b0 \u0110\u00f4 Th\u1ecb",
|
||||||
|
"artist": "Th\u00fay H\u00e0",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 355,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/42m7T272u8I/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3moQljTHbKdPZ3c48rcbJiq4KILjQ",
|
||||||
|
"id": "42m7T272u8I",
|
||||||
|
"url": "https://music.youtube.com/watch?v=42m7T272u8I"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "R\u1eddi B\u1ecf N\u01a1i Anh Bi\u1ebft Em C\u00f3 Vui B\u00ean Ng\u01b0\u1eddi Remix | TH\u01af\u01a0NG TH\u00cc TH\u00d4I REMIX B\u1ea3n Si\u00eau Th\u1ea5m BeBe...",
|
||||||
|
"artist": "ACV",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 195,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/Hq_Q9vSIg2M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n3JlqdmwyqK_me1eqnMQVrNeL6ZA",
|
||||||
|
"id": "Hq_Q9vSIg2M",
|
||||||
|
"url": "https://music.youtube.com/watch?v=Hq_Q9vSIg2M"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Gi\u1eef Anh Cho Ng\u00e0y H\u00f4m Qua",
|
||||||
|
"artist": "Ho\u00e0ng D\u0169ng, Rhymastic, Lelarec",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 345,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/IADhKnmQMtk/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nzDVE9hb0vpWAYZ39Ghi-6BrG-9g",
|
||||||
|
"id": "IADhKnmQMtk",
|
||||||
|
"url": "https://music.youtube.com/watch?v=IADhKnmQMtk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Mr Siro | T\u1ef1 Lau N\u01b0\u1edbc M\u1eaft",
|
||||||
|
"artist": "Mr. Siro",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 330,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/tgvXGxCrBmE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mNQ5AIuHnGA4XZQwvFh_WRi1DmAg",
|
||||||
|
"id": "tgvXGxCrBmE",
|
||||||
|
"url": "https://music.youtube.com/watch?v=tgvXGxCrBmE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "She Never Cries (feat. S\u01a0N.K)",
|
||||||
|
"artist": "Ho\u00e0ng Duy\u00ean",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 264,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/zuBjkHOFVJs/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kdUzhae-xLYnaf7b45tXbDDxr71A",
|
||||||
|
"id": "zuBjkHOFVJs",
|
||||||
|
"url": "https://music.youtube.com/watch?v=zuBjkHOFVJs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Ch\u1edd Bao L\u00e2u (feat. H\u00e0o JK)",
|
||||||
|
"artist": "\u00dat Nh\u1ecb Mino",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 193,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/KO0CbNNvd14/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mL5syc6JwJoWuHasdnfhrSAFITHA",
|
||||||
|
"id": "KO0CbNNvd14",
|
||||||
|
"url": "https://music.youtube.com/watch?v=KO0CbNNvd14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "C\u00d4 G\u00c1I \u00c0 \u0110\u1eeaNG KH\u00d3C | \u00daT NH\u1eca MINO FT NVC MUSIC",
|
||||||
|
"artist": "\u00dat Nh\u1ecb Mino",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 266,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/oH9_c7Y5zMQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kBh2R8cqyDQN98Jd9CIO1RZBbVNQ",
|
||||||
|
"id": "oH9_c7Y5zMQ",
|
||||||
|
"url": "https://music.youtube.com/watch?v=oH9_c7Y5zMQ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "R\u1ea5t L\u00e2u R\u1ed3i M\u1edbi Kh\u00f3c (Solo Version)",
|
||||||
|
"artist": "",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 311,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/MWowv3A-fQc/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kWiKMUSyg-xYgdrzO26ICDnO6Gpg",
|
||||||
|
"id": "MWowv3A-fQc",
|
||||||
|
"url": "https://music.youtube.com/watch?v=MWowv3A-fQc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Ring Ring",
|
||||||
|
"artist": "MIRA",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 147,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/mkCaf6tuhGM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lyjmmeuudBzy9Yu64rGLaWENa4tg",
|
||||||
|
"id": "mkCaf6tuhGM",
|
||||||
|
"url": "https://music.youtube.com/watch?v=mkCaf6tuhGM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "B\u1ea3o Anh | C\u00f4 \u1ea4y C\u1ee7a Anh \u1ea4y",
|
||||||
|
"artist": "B\u1ea3o Anh ",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 324,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/RlTDbIutJsU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kTrRLvQFATZub9py_upYtU7SUaJQ",
|
||||||
|
"id": "RlTDbIutJsU",
|
||||||
|
"url": "https://music.youtube.com/watch?v=RlTDbIutJsU"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "\u0110\u1ecaA \u0110\u00c0NG REMIX - HO\u00c0NG OANH x ACV | N\u00f3i Anh Nghe \u0110\u1ecba \u0110\u00e0ng M\u1edf C\u1eeda L\u00e0 \u0110\u1ec3 Ch\u1edd B\u01b0\u1edbc Ch\u00e2n Em Ph\u1ea3i Kh\u00f4ng ?",
|
||||||
|
"artist": "ACV",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 311,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/vZzzcflS2HM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lMoxUQD5_wrptAPCqUYBxD0MKndw",
|
||||||
|
"id": "vZzzcflS2HM",
|
||||||
|
"url": "https://music.youtube.com/watch?v=vZzzcflS2HM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "T\u1eebng quen",
|
||||||
|
"artist": "itsnk, Wren Evans",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 175,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/zepHPnUDROE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kQphjp4tiW4vFcaXJBk1wMtsk9Kg",
|
||||||
|
"id": "zepHPnUDROE",
|
||||||
|
"url": "https://music.youtube.com/watch?v=zepHPnUDROE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "HOA B\u1ea4T T\u1eec",
|
||||||
|
"artist": "Th\u00e0nh \u0110\u1ea1t",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 317,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/n-k_aUsOaaQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lfd3LBuB7aTSG880J0HqdjEqNQww",
|
||||||
|
"id": "n-k_aUsOaaQ",
|
||||||
|
"url": "https://music.youtube.com/watch?v=n-k_aUsOaaQ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "N\u00f3i D\u1ed1i | Ronboogz (Lyrics Video)",
|
||||||
|
"artist": "Ronboogz",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 207,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/ri-TFS97Hbw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lgdDfcXHekuevzN7qPIZR7RryanQ",
|
||||||
|
"id": "ri-TFS97Hbw",
|
||||||
|
"url": "https://music.youtube.com/watch?v=ri-TFS97Hbw"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "MONO - \u2018Em Xinh\u2019 (Official Music Video)",
|
||||||
|
"artist": "MONO",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 197,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/rYc1UbgbMIY/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mkHo5h-7KAI9SGhk2jG6m6cHospQ",
|
||||||
|
"id": "rYc1UbgbMIY",
|
||||||
|
"url": "https://music.youtube.com/watch?v=rYc1UbgbMIY"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
13096
backend/data/browse_playlists.json
Normal file
59
backend/main.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from backend.api import routes
|
||||||
|
import os
|
||||||
|
|
||||||
|
app = FastAPI(title="Spotify Clone Backend")
|
||||||
|
|
||||||
|
# CORS setup
|
||||||
|
origins = [
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://127.0.0.1:3000",
|
||||||
|
]
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(routes.router, prefix="/api")
|
||||||
|
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
# ... existing code ...
|
||||||
|
|
||||||
|
app.include_router(routes.router, prefix="/api")
|
||||||
|
|
||||||
|
# Serve Static Frontend (Production Mode)
|
||||||
|
STATIC_DIR = "static"
|
||||||
|
if os.path.exists(STATIC_DIR):
|
||||||
|
app.mount("/_next", StaticFiles(directory=os.path.join(STATIC_DIR, "_next")), name="next_assets")
|
||||||
|
|
||||||
|
# Serve other static files (favicons etc) if they exist in root of static
|
||||||
|
# Or just fallback everything else to index.html for SPA
|
||||||
|
|
||||||
|
@app.get("/{full_path:path}")
|
||||||
|
async def serve_spa(full_path: str):
|
||||||
|
# Check if file exists in static folder
|
||||||
|
file_path = os.path.join(STATIC_DIR, full_path)
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
return FileResponse(file_path)
|
||||||
|
|
||||||
|
# Otherwise return index.html
|
||||||
|
index_path = os.path.join(STATIC_DIR, "index.html")
|
||||||
|
if os.path.exists(index_path):
|
||||||
|
return FileResponse(index_path)
|
||||||
|
|
||||||
|
return {"error": "Frontend not found"}
|
||||||
|
else:
|
||||||
|
@app.get("/")
|
||||||
|
def read_root():
|
||||||
|
return {"message": "Spotify Clone Backend Running (Frontend not built/mounted)"}
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health_check():
|
||||||
|
return {"status": "ok"}
|
||||||
88
backend/playlist_manager.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
DATA_FILE = Path("backend/data/user_playlists.json")
|
||||||
|
|
||||||
|
class PlaylistManager:
|
||||||
|
def __init__(self):
|
||||||
|
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not DATA_FILE.exists():
|
||||||
|
self._save_data([])
|
||||||
|
|
||||||
|
def _load_data(self) -> List[Dict]:
|
||||||
|
try:
|
||||||
|
with open(DATA_FILE, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _save_data(self, data: List[Dict]):
|
||||||
|
with open(DATA_FILE, "w") as f:
|
||||||
|
json.dump(data, f, indent=4)
|
||||||
|
|
||||||
|
def get_all(self) -> List[Dict]:
|
||||||
|
return self._load_data()
|
||||||
|
|
||||||
|
def get_by_id(self, playlist_id: str) -> Optional[Dict]:
|
||||||
|
playlists = self._load_data()
|
||||||
|
for p in playlists:
|
||||||
|
if p["id"] == playlist_id:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create(self, name: str, description: str = "") -> Dict:
|
||||||
|
playlists = self._load_data()
|
||||||
|
new_playlist = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"title": name,
|
||||||
|
"description": description,
|
||||||
|
"tracks": [],
|
||||||
|
"cover_url": "https://placehold.co/400?text=Playlist", # Default placeholder
|
||||||
|
"is_user_created": True
|
||||||
|
}
|
||||||
|
playlists.append(new_playlist)
|
||||||
|
self._save_data(playlists)
|
||||||
|
return new_playlist
|
||||||
|
|
||||||
|
def update(self, playlist_id: str, name: str = None, description: str = None) -> Optional[Dict]:
|
||||||
|
playlists = self._load_data()
|
||||||
|
for p in playlists:
|
||||||
|
if p["id"] == playlist_id:
|
||||||
|
if name: p["title"] = name
|
||||||
|
if description: p["description"] = description
|
||||||
|
self._save_data(playlists)
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete(self, playlist_id: str) -> bool:
|
||||||
|
playlists = self._load_data()
|
||||||
|
initial_len = len(playlists)
|
||||||
|
playlists = [p for p in playlists if p["id"] != playlist_id]
|
||||||
|
if len(playlists) < initial_len:
|
||||||
|
self._save_data(playlists)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def add_track(self, playlist_id: str, track: Dict) -> bool:
|
||||||
|
playlists = self._load_data()
|
||||||
|
for p in playlists:
|
||||||
|
if p["id"] == playlist_id:
|
||||||
|
# Check for duplicates? For now allow.
|
||||||
|
p["tracks"].append(track)
|
||||||
|
# Update cover if it's the first track
|
||||||
|
if len(p["tracks"]) == 1 and track.get("cover_url"):
|
||||||
|
p["cover_url"] = track["cover_url"]
|
||||||
|
self._save_data(playlists)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove_track(self, playlist_id: str, track_id: str) -> bool:
|
||||||
|
playlists = self._load_data()
|
||||||
|
for p in playlists:
|
||||||
|
if p["id"] == playlist_id:
|
||||||
|
p["tracks"] = [t for t in p["tracks"] if t.get("id") != track_id]
|
||||||
|
self._save_data(playlists)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
9
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
spotdl
|
||||||
|
pydantic
|
||||||
|
python-multipart
|
||||||
|
requests
|
||||||
|
yt-dlp
|
||||||
|
ytmusicapi
|
||||||
|
syncedlyrics
|
||||||
60
backend/scripts/fetch_content.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
from ytmusicapi import YTMusic
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def fetch_content():
|
||||||
|
yt = YTMusic()
|
||||||
|
|
||||||
|
# Categorized Queries
|
||||||
|
CATEGORIES = {
|
||||||
|
"Vietnam Top": ["Vietnam Top 50", "V-Pop Hot", "Rap Viet", "Indie Vietnam"],
|
||||||
|
"Global Top": ["Global Top 50", "US-UK Top Hits", "Pop Rising", "Viral 50 Global"],
|
||||||
|
"K-Pop": ["K-Pop Hits", "Best of K-Pop", "K-Pop Rising", "BLACKPINK Essentials"],
|
||||||
|
"Chill": ["Lofi Girl", "Coffee Shop Vibes", "Piano Relax", "Sleep Sounds"],
|
||||||
|
"Party": ["Party Hits", "EDM Best", "Workout Motivation", "Vinahouse Beat"]
|
||||||
|
}
|
||||||
|
|
||||||
|
segmented_content = {}
|
||||||
|
seen_ids = set()
|
||||||
|
|
||||||
|
print("Fetching Browse Content...")
|
||||||
|
|
||||||
|
for category, queries in CATEGORIES.items():
|
||||||
|
print(f"--- Processing Category: {category} ---")
|
||||||
|
category_playlists = []
|
||||||
|
|
||||||
|
for q in queries:
|
||||||
|
try:
|
||||||
|
print(f"Searching for: {q}")
|
||||||
|
# Fetch more results to ensure we get good matches
|
||||||
|
results = yt.search(q, filter="playlists", limit=4)
|
||||||
|
|
||||||
|
for res in results:
|
||||||
|
pid = res.get("browseId")
|
||||||
|
if pid and pid not in seen_ids:
|
||||||
|
seen_ids.add(pid)
|
||||||
|
|
||||||
|
# Store minimal info for the card
|
||||||
|
category_playlists.append({
|
||||||
|
"id": pid,
|
||||||
|
"title": res.get("title"),
|
||||||
|
"description": f"Based on '{q}'",
|
||||||
|
"cover_url": res.get("thumbnails")[-1]["url"] if res.get("thumbnails") else "",
|
||||||
|
"author": res.get("author") or "YouTube Music"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error serving {q}: {e}")
|
||||||
|
|
||||||
|
segmented_content[category] = category_playlists
|
||||||
|
|
||||||
|
output_path = Path("backend/data/browse_playlists.json")
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with open(output_path, "w") as f:
|
||||||
|
json.dump(segmented_content, f, indent=4)
|
||||||
|
|
||||||
|
total_playlists = sum(len(p) for p in segmented_content.values())
|
||||||
|
print(f"Successfully saved {total_playlists} playlists across {len(segmented_content)} categories to {output_path}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
fetch_content()
|
||||||
21
backend/scripts/test_lyrics.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
from ytmusicapi import YTMusic
|
||||||
|
import json
|
||||||
|
|
||||||
|
yt = YTMusic()
|
||||||
|
|
||||||
|
# Example ID (Son Tung M-TP - Chung Ta Cua Hien Tai)
|
||||||
|
video_id = "lTdoH5uL6Ew"
|
||||||
|
|
||||||
|
print(f"Fetching lyrics for {video_id}...")
|
||||||
|
try:
|
||||||
|
watch_playlist = yt.get_watch_playlist(videoId=video_id)
|
||||||
|
if 'lyrics' in watch_playlist:
|
||||||
|
lyrics_id = watch_playlist['lyrics']
|
||||||
|
print(f"Found Lyrics ID: {lyrics_id}")
|
||||||
|
lyrics = yt.get_lyrics(lyrics_id)
|
||||||
|
print(json.dumps(lyrics, indent=2))
|
||||||
|
else:
|
||||||
|
print("No lyrics found in watch playlist.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
2700
data/browse_playlists.json
Normal file
1
data/user_playlists.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
[]
|
||||||
41
deploy.bat
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
@echo off
|
||||||
|
echo ==========================================
|
||||||
|
echo Spotify Clone Deployment Script
|
||||||
|
echo ==========================================
|
||||||
|
|
||||||
|
echo [1/3] Checking Docker status...
|
||||||
|
docker info >nul 2>&1
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo [ERROR] Docker is NOT running!
|
||||||
|
echo.
|
||||||
|
echo Please start Docker Desktop from your Start Menu.
|
||||||
|
echo Once Docker is running ^(green icon^), run this script again.
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [2/3] Docker is active. Building Image...
|
||||||
|
echo This may take a few minutes...
|
||||||
|
docker build -t vndangkhoa/spotify-clone:latest .
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo [ERROR] Docker build failed.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [3/3] Pushing to Docker Hub...
|
||||||
|
docker push vndangkhoa/spotify-clone:latest
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo [ERROR] Docker push failed.
|
||||||
|
echo You may need to run 'docker login' first.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ==========================================
|
||||||
|
echo [SUCCESS] Deployment Complete!
|
||||||
|
echo Image: vndangkhoa/spotify-clone:latest
|
||||||
|
echo ==========================================
|
||||||
|
pause
|
||||||
11
docker-compose.yml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
services:
|
||||||
|
spotify-clone:
|
||||||
|
image: vndangkhoa/spotify-clone:latest
|
||||||
|
container_name: spotify-clone
|
||||||
|
restart: always
|
||||||
|
network_mode: bridge # Synology often prefers explicit bridge or host
|
||||||
|
ports:
|
||||||
|
- "3110:3000" # Web UI
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/backend/data
|
||||||
95
fetch_data.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
from ytmusicapi import YTMusic
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
yt = YTMusic()
|
||||||
|
|
||||||
|
CATEGORIES = {
|
||||||
|
"Trending Vietnam": "Top 50 Vietnam",
|
||||||
|
"Vietnamese Artists": "Vietnamese Pop Hits",
|
||||||
|
"Ballad Singers": "Vietnamese Ballad",
|
||||||
|
"DJ & Remix": "Vinahouse Remix Vietnam",
|
||||||
|
"YouTube Stars": "Vietnamese Cover Songs"
|
||||||
|
}
|
||||||
|
|
||||||
|
browse_data = {}
|
||||||
|
|
||||||
|
print("Starting data fetch...")
|
||||||
|
|
||||||
|
for category, query in CATEGORIES.items():
|
||||||
|
print(f"\n--- Fetching Category: {category} (Query: '{query}') ---")
|
||||||
|
try:
|
||||||
|
results = yt.search(query, filter="playlists", limit=5)
|
||||||
|
|
||||||
|
category_playlists = []
|
||||||
|
|
||||||
|
for p_result in results[:4]: # Limit to 4 playlists per category
|
||||||
|
playlist_id = p_result['browseId']
|
||||||
|
print(f" > Processing: {p_result['title']}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fetch full playlist details
|
||||||
|
playlist_data = yt.get_playlist(playlist_id, limit=50)
|
||||||
|
|
||||||
|
# Process Tracks
|
||||||
|
output_tracks = []
|
||||||
|
for track in playlist_data.get('tracks', []):
|
||||||
|
artists_list = track.get('artists') or []
|
||||||
|
if isinstance(artists_list, list):
|
||||||
|
artists = ", ".join([a.get('name', 'Unknown') for a in artists_list])
|
||||||
|
else:
|
||||||
|
artists = "Unknown Artist"
|
||||||
|
|
||||||
|
thumbnails = track.get('thumbnails', [])
|
||||||
|
cover_url = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300"
|
||||||
|
|
||||||
|
album_info = track.get('album')
|
||||||
|
album_name = album_info.get('name', 'Single') if album_info else "Single"
|
||||||
|
|
||||||
|
output_tracks.append({
|
||||||
|
"title": track.get('title', 'Unknown Title'),
|
||||||
|
"artist": artists,
|
||||||
|
"album": album_name,
|
||||||
|
"duration": track.get('duration_seconds', 0),
|
||||||
|
"cover_url": cover_url,
|
||||||
|
"id": track.get('videoId', 'unknown'),
|
||||||
|
"url": f"https://music.youtube.com/watch?v={track.get('videoId', '')}"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Process Playlist Info
|
||||||
|
p_thumbnails = playlist_data.get('thumbnails', [])
|
||||||
|
p_cover = p_thumbnails[-1]['url'] if p_thumbnails else "https://placehold.co/300x300"
|
||||||
|
|
||||||
|
category_playlists.append({
|
||||||
|
"id": playlist_data.get('id'),
|
||||||
|
"title": playlist_data.get('title'),
|
||||||
|
"description": playlist_data.get('description', '') or f"Best of {category}",
|
||||||
|
"cover_url": p_cover,
|
||||||
|
"tracks": output_tracks
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error processing playlist {playlist_id}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if category_playlists:
|
||||||
|
browse_data[category] = category_playlists
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error searching category {category}: {e}")
|
||||||
|
|
||||||
|
# Save to backend/data/browse_playlists.json
|
||||||
|
output_path = Path("backend/data/browse_playlists.json")
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with open(output_path, "w", encoding='utf-8') as f:
|
||||||
|
json.dump(browse_data, f, indent=2)
|
||||||
|
|
||||||
|
# Also save a flat list for Trending (backward compatibility)
|
||||||
|
if "Trending Vietnam" in browse_data and browse_data["Trending Vietnam"]:
|
||||||
|
flat_trending = browse_data["Trending Vietnam"][0]
|
||||||
|
with open("backend/data.json", "w", encoding='utf-8') as f:
|
||||||
|
json.dump(flat_trending, f, indent=2)
|
||||||
|
|
||||||
|
print("\nAll Done! Saved to backend/data/browse_playlists.json")
|
||||||
41
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
36
frontend/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
89
frontend/app/collection/tracks/page.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePlayer } from "@/context/PlayerContext";
|
||||||
|
import { Play, Pause, Clock, Heart } from "lucide-react";
|
||||||
|
|
||||||
|
export default function LikedSongsPage() {
|
||||||
|
const { likedTracksData, playTrack, currentTrack, isPlaying } = usePlayer();
|
||||||
|
|
||||||
|
const handlePlay = (track: any) => {
|
||||||
|
playTrack(track, likedTracksData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
if (!seconds) return "-:--";
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto no-scrollbar bg-gradient-to-b from-indigo-900 via-[#121212] to-[#121212]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-end gap-6 p-8 bg-gradient-to-b from-transparent to-black/20">
|
||||||
|
<div className="w-52 h-52 bg-gradient-to-br from-indigo-700 to-blue-300 shadow-2xl flex items-center justify-center rounded-md">
|
||||||
|
<Heart className="w-24 h-24 text-white fill-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-bold uppercase">Playlist</span>
|
||||||
|
<h1 className="text-8xl font-black tracking-tight text-white mb-4">Liked Songs</h1>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-white/90">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-green-500 flex items-center justify-center text-black text-xs font-bold">K</div>
|
||||||
|
<span className="hover:underline cursor-pointer">Khoa Vo</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{likedTracksData.length} songs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="px-8 py-6 relative z-10">
|
||||||
|
<div className="bg-green-500 rounded-full w-14 h-14 flex items-center justify-center hover:scale-105 transition cursor-pointer shadow-lg">
|
||||||
|
<Play className="w-7 h-7 text-black fill-black ml-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="px-8 pb-8">
|
||||||
|
{/* Header Row */}
|
||||||
|
<div className="grid grid-cols-[16px_4fr_3fr_minmax(120px,1fr)] gap-4 px-4 py-2 border-b border-[#ffffff1a] text-sm text-spotify-text-muted mb-4">
|
||||||
|
<span>#</span>
|
||||||
|
<span>Title</span>
|
||||||
|
<span>Album</span>
|
||||||
|
<div className="flex justify-end pr-8"><Clock className="w-4 h-4" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{likedTracksData.length > 0 ? (
|
||||||
|
likedTracksData.map((track, i) => {
|
||||||
|
const isCurrent = currentTrack?.id === track.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={track.id}
|
||||||
|
onClick={() => handlePlay(track)}
|
||||||
|
className={`grid grid-cols-[16px_4fr_3fr_minmax(120px,1fr)] gap-4 px-4 py-2 rounded-md hover:bg-[#ffffff1a] group cursor-pointer transition items-center text-sm text-spotify-text-muted hover:text-white ${isCurrent ? 'bg-[#ffffff1a]' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="group-hover:hidden text-center">{i + 1}</span>
|
||||||
|
<span className="hidden group-hover:block text-white"><Play className="w-3 h-3 fill-white" /></span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
|
<img src={track.cover_url} className="w-10 h-10 rounded shadow-sm object-cover" alt="" />
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className={`font-semibold truncate text-base ${isCurrent ? 'text-green-500' : 'text-white'}`}>{track.title}</span>
|
||||||
|
<span className="truncate hover:underline text-xs">{track.artist}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="truncate hover:underline">{track.album}</span>
|
||||||
|
<span className="text-right pr-8 font-mono">{formatDuration(0)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-20 text-spotify-text-muted">
|
||||||
|
<p className="text-lg">No liked songs yet.</p>
|
||||||
|
<p className="text-sm mt-2">Go search for some music and tap the heart icon!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
frontend/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
37
frontend/app/globals.css
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #121212;
|
||||||
|
--foreground: #FFFFFF;
|
||||||
|
|
||||||
|
/* Spotify Colors */
|
||||||
|
--spotify-base: #121212;
|
||||||
|
--spotify-sidebar: #000000;
|
||||||
|
--spotify-player: #000000;
|
||||||
|
--spotify-highlight: #1DB954;
|
||||||
|
--spotify-hover: #282828;
|
||||||
|
--spotify-text-main: #FFFFFF;
|
||||||
|
--spotify-text-muted: #B3B3B3;
|
||||||
|
--spotify-text-subdued: #A7A7A7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
/* IE and Edge */
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* Firefox */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--spotify-base);
|
||||||
|
color: var(--spotify-text-main);
|
||||||
|
/* font-family set in layout via className */
|
||||||
|
}
|
||||||
57
frontend/app/layout.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Outfit } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import Sidebar from "@/components/Sidebar";
|
||||||
|
import PlayerBar from "@/components/PlayerBar";
|
||||||
|
import MobileNav from "@/components/MobileNav";
|
||||||
|
import { PlayerProvider } from "@/context/PlayerContext";
|
||||||
|
import { LibraryProvider } from "@/context/LibraryContext";
|
||||||
|
|
||||||
|
const outfit = Outfit({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-outfit",
|
||||||
|
weight: ["300", "400", "500", "600", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Audiophile Web Player",
|
||||||
|
description: "High-Fidelity Local-First Music Player",
|
||||||
|
manifest: "/manifest.json",
|
||||||
|
referrer: "no-referrer",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "black-translucent",
|
||||||
|
title: "Audiophile Web Player",
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: "/icons/icon-192x192.png",
|
||||||
|
apple: "/icons/icon-512x512.png",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body
|
||||||
|
className={`${outfit.variable} antialiased bg-black h-screen flex flex-col overflow-hidden text-white font-sans`}
|
||||||
|
>
|
||||||
|
<PlayerProvider>
|
||||||
|
<LibraryProvider>
|
||||||
|
<div className="flex-1 flex overflow-hidden p-2 gap-2 mb-[64px] md:mb-0">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 bg-[#121212] rounded-lg overflow-y-auto relative no-scrollbar">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<PlayerBar />
|
||||||
|
<MobileNav />
|
||||||
|
</LibraryProvider>
|
||||||
|
</PlayerProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
frontend/app/library/page.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { dbService } from "@/services/db";
|
||||||
|
import { useLibrary } from "@/context/LibraryContext";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import CreatePlaylistModal from "@/components/CreatePlaylistModal";
|
||||||
|
|
||||||
|
export default function LibraryPage() {
|
||||||
|
const { userPlaylists: playlists, libraryItems, refreshLibrary: refresh, activeFilter: activeTab, setActiveFilter: setActiveTab } = useLibrary();
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleCreatePlaylist = async (name: string) => {
|
||||||
|
await dbService.createPlaylist(name);
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPlaylists = activeTab === 'all' || activeTab === 'playlists';
|
||||||
|
const showAlbums = activeTab === 'all' || activeTab === 'albums';
|
||||||
|
const showArtists = activeTab === 'all' || activeTab === 'artists';
|
||||||
|
|
||||||
|
// Filter items based on type
|
||||||
|
const albums = libraryItems.filter(item => item.type === 'Album');
|
||||||
|
const artists = libraryItems.filter(item => item.type === 'Artist');
|
||||||
|
const browsePlaylists = libraryItems.filter(item => item.type === 'Playlist');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-b from-[#1e1e1e] to-black min-h-screen p-4 pb-24">
|
||||||
|
<div className="flex justify-between items-center mb-6 pt-4">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Your Library</h1>
|
||||||
|
<button onClick={() => setIsCreateModalOpen(true)}>
|
||||||
|
<Plus className="text-white w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-6 overflow-x-auto no-scrollbar">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-medium border transition whitespace-nowrap ${activeTab === 'all' ? 'bg-[#2a2a2a] text-white border-white/10' : 'bg-[#121212] text-spotify-text-muted border-white/5'}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('playlists')}
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-medium border transition whitespace-nowrap ${activeTab === 'playlists' ? 'bg-[#2a2a2a] text-white border-white/10' : 'bg-[#121212] text-spotify-text-muted border-white/5'}`}
|
||||||
|
>
|
||||||
|
Playlists
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('albums')}
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-medium border transition whitespace-nowrap ${activeTab === 'albums' ? 'bg-[#2a2a2a] text-white border-white/10' : 'bg-[#121212] text-spotify-text-muted border-white/5'}`}
|
||||||
|
>
|
||||||
|
Albums
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('artists')}
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-medium border transition whitespace-nowrap ${activeTab === 'artists' ? 'bg-[#2a2a2a] text-white border-white/10' : 'bg-[#121212] text-spotify-text-muted border-white/5'}`}
|
||||||
|
>
|
||||||
|
Artists
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
|
{/* Playlists & Liked Songs */}
|
||||||
|
{showPlaylists && (
|
||||||
|
<>
|
||||||
|
<Link href="/collection/tracks">
|
||||||
|
<div className="bg-gradient-to-br from-indigo-700 to-blue-500 rounded-md p-4 aspect-square flex flex-col justify-end relative overflow-hidden shadow-lg">
|
||||||
|
<h3 className="text-white font-bold text-lg z-10">Liked Songs</h3>
|
||||||
|
<p className="text-white/80 text-xs z-10">Auto-generated</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{playlists.map((playlist) => (
|
||||||
|
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
|
||||||
|
<div className="bg-[#181818] p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
|
||||||
|
<div className="aspect-square w-full mb-3 overflow-hidden rounded-md bg-[#282828] shadow-lg">
|
||||||
|
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
|
||||||
|
<img src={playlist.cover_url} alt={playlist.title} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-[#333]">
|
||||||
|
<span className="text-2xl">🎵</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-white font-bold text-sm truncate">{playlist.title}</h3>
|
||||||
|
<p className="text-[#a7a7a7] text-xs">Playlist • You</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{browsePlaylists.map((playlist) => (
|
||||||
|
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
|
||||||
|
<div className="bg-[#181818] p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
|
||||||
|
<div className="aspect-square w-full mb-3 overflow-hidden rounded-md bg-[#282828] shadow-lg">
|
||||||
|
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
|
||||||
|
<img src={playlist.cover_url} alt={playlist.title} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-[#333]">
|
||||||
|
<span className="text-2xl">🎵</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-white font-bold text-sm truncate">{playlist.title}</h3>
|
||||||
|
<p className="text-[#a7a7a7] text-xs">Playlist • Made for you</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Artists Content (Circular Images) */}
|
||||||
|
{showArtists && artists.map((artist) => (
|
||||||
|
<Link href={`/playlist?id=${artist.id}`} key={artist.id}>
|
||||||
|
<div className="bg-[#181818] 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-3 overflow-hidden rounded-full bg-[#282828] shadow-lg relative">
|
||||||
|
{artist.cover_url ? (
|
||||||
|
<img
|
||||||
|
src={artist.cover_url}
|
||||||
|
alt={artist.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
e.currentTarget.onerror = null; // Prevent infinite loop
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
e.currentTarget.parentElement?.classList.add('bg-[#333]');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-[#333] -z-10">
|
||||||
|
<span className="text-2xl">🎤</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-white font-bold text-sm truncate w-full">{artist.title}</h3>
|
||||||
|
<p className="text-[#a7a7a7] text-xs">Artist</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Albums Content */}
|
||||||
|
{showAlbums && albums.map((album) => (
|
||||||
|
<Link href={`/playlist?id=${album.id}`} key={album.id}>
|
||||||
|
<div className="bg-[#181818] p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
|
||||||
|
<div className="aspect-square w-full mb-3 overflow-hidden rounded-md bg-[#282828] shadow-lg relative">
|
||||||
|
{album.cover_url ? (
|
||||||
|
<img
|
||||||
|
src={album.cover_url}
|
||||||
|
alt={album.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
e.currentTarget.onerror = null; // Prevent infinite loop
|
||||||
|
e.currentTarget.style.display = 'none'; // Hide broken image
|
||||||
|
e.currentTarget.parentElement?.classList.add('bg-[#333]'); // add background
|
||||||
|
// Show fallback icon sibling if possible, distinct from React state
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{/* Fallback overlay (shown if image missing or hidden via CSS logic would need state, but simpler: just render icon behind it or use state) */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-[#333] -z-10">
|
||||||
|
<span className="text-2xl">💿</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-white font-bold text-sm truncate">{album.title}</h3>
|
||||||
|
<p className="text-[#a7a7a7] text-xs">Album • {album.creator || 'Spotify'}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreatePlaylistModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onCreate={handleCreatePlaylist}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
frontend/app/page.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Play, Pause } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { usePlayer } from "@/context/PlayerContext";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { libraryService } from "@/services/library";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [timeOfDay, setTimeOfDay] = useState("Good evening");
|
||||||
|
const [browseData, setBrowseData] = useState<Record<string, any[]>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
if (hour < 12) setTimeOfDay("Good morning");
|
||||||
|
else if (hour < 18) setTimeOfDay("Good afternoon");
|
||||||
|
else setTimeOfDay("Good evening");
|
||||||
|
|
||||||
|
// Fetch Browse Content
|
||||||
|
libraryService.getBrowseContent()
|
||||||
|
.then(data => setBrowseData(data))
|
||||||
|
.catch(err => console.error("Error fetching browse:", err));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Use first item of first category as Hero
|
||||||
|
const firstCategory = Object.keys(browseData)[0];
|
||||||
|
const heroPlaylist = firstCategory && browseData[firstCategory].length > 0 ? browseData[firstCategory][0] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto bg-gradient-to-b from-[#1e1e1e] to-[#121212] p-6 no-scrollbar pb-24">
|
||||||
|
|
||||||
|
{/* Header / Greetings */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">{timeOfDay}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero Section (First Playlist) */}
|
||||||
|
{heroPlaylist && (
|
||||||
|
<Link href={`/playlist?id=${heroPlaylist.id}`}>
|
||||||
|
<div className="flex flex-col md:flex-row gap-6 mb-8 hover:bg-white/10 p-4 rounded-md transition group cursor-pointer items-center md:items-end text-center md:text-left">
|
||||||
|
<img
|
||||||
|
src={heroPlaylist.cover_url || "https://placehold.co/200"}
|
||||||
|
alt={heroPlaylist.title}
|
||||||
|
className="w-48 h-48 md:w-60 md:h-60 shadow-2xl object-cover rounded-md"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col justify-end w-full">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-2 hidden md:block">Playlist</p>
|
||||||
|
<h2 className="text-3xl md:text-6xl font-bold mb-2 md:mb-4 line-clamp-2">{heroPlaylist.title}</h2>
|
||||||
|
<p className="text-sm font-medium mb-4 text-[#a7a7a7] line-clamp-2">{heroPlaylist.description}</p>
|
||||||
|
<div className="flex items-center justify-center md:justify-start gap-4">
|
||||||
|
<div className="w-12 h-12 md:w-14 md:h-14 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105 transition shadow-lg">
|
||||||
|
<Play className="fill-black text-black ml-1 w-5 h-5 md:w-6 md:h-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Made For You (Recommendations) */}
|
||||||
|
<MadeForYouSection />
|
||||||
|
|
||||||
|
{/* Recommended Albums */}
|
||||||
|
<RecommendedAlbumsSection />
|
||||||
|
|
||||||
|
{/* Render Categories */}
|
||||||
|
{Object.entries(browseData).map(([category, playlists]) => (
|
||||||
|
<div key={category} className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold mb-4 hover:underline cursor-pointer">{category}</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||||
|
{playlists.slice(0, 5).map((playlist) => (
|
||||||
|
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
|
||||||
|
<div className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<img
|
||||||
|
src={playlist.cover_url || "https://placehold.co/200"}
|
||||||
|
alt={playlist.title}
|
||||||
|
className="w-full aspect-square object-cover rounded-md shadow-lg"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
|
||||||
|
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
|
||||||
|
<Play className="fill-black text-black ml-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold mb-1 truncate">{playlist.title}</h3>
|
||||||
|
<p className="text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MadeForYouSection() {
|
||||||
|
const { playHistory, playTrack } = usePlayer();
|
||||||
|
const [recommendations, setRecommendations] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playHistory.length > 0) {
|
||||||
|
const seed = playHistory[0]; // Last played
|
||||||
|
libraryService.getRecommendations(seed.id)
|
||||||
|
.then(tracks => setRecommendations(tracks))
|
||||||
|
.catch(err => console.error("Rec error:", err));
|
||||||
|
}
|
||||||
|
}, [playHistory.length > 0 ? playHistory[0].id : null]);
|
||||||
|
|
||||||
|
if (playHistory.length === 0 || recommendations.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8 animate-in fade-in duration-500">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Made For You</h2>
|
||||||
|
<p className="text-sm text-[#a7a7a7] mb-4">Based on your listening of <span className="text-white font-medium">{playHistory[0].title}</span></p>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||||
|
{recommendations.slice(0, 5).map((track, i) => (
|
||||||
|
<div key={i} onClick={() => playTrack(track, recommendations)} className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<img
|
||||||
|
src={track.cover_url}
|
||||||
|
alt={track.title}
|
||||||
|
className="w-full aspect-square object-cover rounded-md shadow-lg"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
|
||||||
|
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
|
||||||
|
<Play className="fill-black text-black ml-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold mb-1 truncate">{track.title}</h3>
|
||||||
|
<p className="text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecommendedAlbumsSection() {
|
||||||
|
const { playHistory } = usePlayer();
|
||||||
|
const [albums, setAlbums] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playHistory.length > 0) {
|
||||||
|
const seedArtist = playHistory[0].artist; // Last played artist
|
||||||
|
if (!seedArtist) return;
|
||||||
|
|
||||||
|
// Clean artist name (remove delimiters like commas if multiple)
|
||||||
|
const primaryArtist = seedArtist.split(',')[0].trim();
|
||||||
|
|
||||||
|
libraryService.getRecommendedAlbums(primaryArtist)
|
||||||
|
.then(data => {
|
||||||
|
if (Array.isArray(data)) setAlbums(data);
|
||||||
|
})
|
||||||
|
.catch(err => console.error("Album Rec error:", err));
|
||||||
|
}
|
||||||
|
}, [playHistory.length > 0 ? playHistory[0].artist : null]);
|
||||||
|
|
||||||
|
if (playHistory.length === 0 || albums.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8 animate-in fade-in duration-700">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Recommended Albums</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||||
|
{albums.slice(0, 5).map((album, i) => (
|
||||||
|
<Link href={`/playlist?id=${album.id}`} key={i}>
|
||||||
|
<div className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<img
|
||||||
|
src={album.cover_url}
|
||||||
|
alt={album.title}
|
||||||
|
className="w-full aspect-square object-cover rounded-md shadow-lg"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
|
||||||
|
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
|
||||||
|
<Play className="fill-black text-black ml-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold mb-1 truncate">{album.title}</h3>
|
||||||
|
<p className="text-sm text-[#a7a7a7] line-clamp-2">{album.description}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
frontend/app/playlist/page.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePlayer } from "@/context/PlayerContext";
|
||||||
|
import { Play, Pause, Clock, Heart, MoreHorizontal, Plus } from "lucide-react";
|
||||||
|
import { useEffect, useState, Suspense } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import AddToPlaylistModal from "@/components/AddToPlaylistModal";
|
||||||
|
import { dbService } from "@/services/db";
|
||||||
|
import { libraryService } from "@/services/library";
|
||||||
|
|
||||||
|
interface Track {
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
cover_url: string;
|
||||||
|
id: string;
|
||||||
|
url?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaylistData {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
author: string;
|
||||||
|
cover_url: string;
|
||||||
|
tracks: Track[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaylistContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const id = searchParams.get('id');
|
||||||
|
|
||||||
|
const [playlist, setPlaylist] = useState<PlaylistData | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const { playTrack, currentTrack, isPlaying, likedTracks, toggleLike } = usePlayer();
|
||||||
|
|
||||||
|
// Modal State
|
||||||
|
const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false);
|
||||||
|
const [trackToAdd, setTrackToAdd] = useState<Track | null>(null);
|
||||||
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPlaylist = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
if (id === 'trending') {
|
||||||
|
const browse = await libraryService.getBrowseContent();
|
||||||
|
// Assumption: Trending exists or use first available
|
||||||
|
const trending = browse['Trending']?.[0] || browse['Top Lists']?.[0];
|
||||||
|
if (trending) {
|
||||||
|
setPlaylist({
|
||||||
|
...trending,
|
||||||
|
author: "Audiophile",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try DB (User Playlist)
|
||||||
|
const dbPlaylist = await dbService.getPlaylist(id);
|
||||||
|
if (dbPlaylist) {
|
||||||
|
setPlaylist({
|
||||||
|
title: dbPlaylist.title,
|
||||||
|
description: `Created on ${new Date(dbPlaylist.createdAt).toLocaleDateString()}`,
|
||||||
|
author: "You",
|
||||||
|
cover_url: dbPlaylist.cover_url || "https://placehold.co/300?text=Playlist",
|
||||||
|
tracks: dbPlaylist.tracks
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Library (Static Playlist)
|
||||||
|
const libPlaylist = await libraryService.getPlaylist(id);
|
||||||
|
if (libPlaylist) {
|
||||||
|
setPlaylist({
|
||||||
|
...libPlaylist,
|
||||||
|
author: "System"
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Playlist not found");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch playlist:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (id) fetchPlaylist();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handlePlay = (track: Track) => {
|
||||||
|
if (playlist) {
|
||||||
|
playTrack(track, playlist.tracks);
|
||||||
|
} else {
|
||||||
|
playTrack(track);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayAll = () => {
|
||||||
|
if (playlist && playlist.tracks.length > 0) {
|
||||||
|
playTrack(playlist.tracks[0], playlist.tracks);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds?: number) => {
|
||||||
|
if (!seconds) return "-:--";
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddToPlaylist = (e: React.MouseEvent, track: Track) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setTrackToAdd(track);
|
||||||
|
setIsAddToPlaylistOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <div className="p-8 text-white">Loading playlist...</div>;
|
||||||
|
if (!playlist) return <div className="p-8 text-white">Playlist not found.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto no-scrollbar bg-gradient-to-b from-gray-900 via-[#121212] to-[#121212]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row items-center md:items-end gap-6 p-8 bg-gradient-to-b from-transparent to-black/20 pt-20 text-center md:text-left">
|
||||||
|
<img src={playlist.cover_url} alt={playlist.title} className="w-52 h-52 md:w-60 md:h-60 shadow-2xl rounded-md object-cover" />
|
||||||
|
<div className="flex flex-col gap-2 w-full md:w-auto">
|
||||||
|
<span className="text-sm font-bold uppercase hidden md:block">Playlist</span>
|
||||||
|
<h1 className="text-2xl md:text-6xl font-black tracking-tight text-white mb-2 md:mb-4 line-clamp-2 leading-tight">{playlist.title}</h1>
|
||||||
|
|
||||||
|
{/* Expandable Description */}
|
||||||
|
<div className="relative">
|
||||||
|
<p
|
||||||
|
className={`text-spotify-text-muted font-medium mb-2 ${isDescriptionExpanded ? '' : 'line-clamp-2 md:line-clamp-none'}`}
|
||||||
|
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
|
||||||
|
>
|
||||||
|
{playlist.description}
|
||||||
|
</p>
|
||||||
|
{playlist.description && playlist.description.length > 100 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
|
||||||
|
className="text-xs text-white font-bold hover:underline md:hidden"
|
||||||
|
>
|
||||||
|
{isDescriptionExpanded ? "Show less" : "more"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center md:justify-start gap-2 text-sm font-medium text-white/90">
|
||||||
|
<span className="font-bold hover:underline cursor-pointer">{playlist.author || "User"}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{playlist.tracks.length} songs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="px-8 py-6 flex items-center gap-8 relative z-10 justify-center md:justify-start">
|
||||||
|
{/* ... (controls remain similar) ... */}
|
||||||
|
<div onClick={handlePlayAll} className="bg-green-500 rounded-full w-14 h-14 flex items-center justify-center hover:scale-105 transition cursor-pointer shadow-lg hover:bg-green-400">
|
||||||
|
<Play className="w-7 h-7 text-black fill-black ml-1" />
|
||||||
|
</div>
|
||||||
|
<Heart className="w-8 h-8 text-spotify-text-muted hover:text-white cursor-pointer" />
|
||||||
|
<MoreHorizontal className="w-8 h-8 text-spotify-text-muted hover:text-white cursor-pointer" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="px-4 md:px-8 pb-40">
|
||||||
|
{/* Header Row */}
|
||||||
|
<div className="grid grid-cols-[auto_1fr_auto] md:grid-cols-[16px_4fr_3fr_minmax(120px,1fr)] gap-4 px-4 py-2 border-b border-[#ffffff1a] text-sm text-spotify-text-muted mb-4">
|
||||||
|
<span className="w-4 text-center">#</span>
|
||||||
|
<span>Title</span>
|
||||||
|
<span className="hidden md:block">Album</span>
|
||||||
|
<div className="flex justify-end md:pr-8"><Clock className="w-4 h-4" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{playlist.tracks.map((track, i) => {
|
||||||
|
const isCurrent = currentTrack?.id === track.id;
|
||||||
|
const isLiked = likedTracks.has(track.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={track.id}
|
||||||
|
onClick={() => handlePlay(track)}
|
||||||
|
className={`grid grid-cols-[auto_1fr_auto] md:grid-cols-[16px_4fr_3fr_minmax(120px,1fr)] gap-4 px-2 md:px-4 py-3 rounded-md hover:bg-[#ffffff1a] group cursor-pointer transition items-center text-sm text-spotify-text-muted hover:text-white ${isCurrent ? 'bg-[#ffffff1a]' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="text-center w-4 flex justify-center items-center">
|
||||||
|
{isCurrent && isPlaying ? (
|
||||||
|
<img src="https://open.spotifycdn.com/cdn/images/equaliser-animated-green.f93a2ef4.gif" className="h-3 w-3" alt="playing" />
|
||||||
|
) : (
|
||||||
|
<span className="block group-hover:hidden">{i + 1}</span>
|
||||||
|
)}
|
||||||
|
<Play className={`w-3 h-3 fill-white hidden ${isCurrent && isPlaying ? 'hidden' : 'group-hover:block'}`} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 min-w-0 overflow-hidden">
|
||||||
|
<img src={track.cover_url} className="w-10 h-10 rounded shadow-sm object-cover shrink-0" alt="" />
|
||||||
|
<div className="flex flex-col min-w-0 pr-2">
|
||||||
|
{/* Changed from truncate to line-clamp-2 for readability */}
|
||||||
|
<span className={`font-semibold text-base leading-tight line-clamp-2 break-words ${isCurrent ? 'text-green-500' : 'text-white'}`}>{track.title}</span>
|
||||||
|
<span className="truncate hover:underline text-xs">{track.artist}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="truncate hover:underline hidden md:block text-xs">{track.album}</span>
|
||||||
|
<div className="flex items-center justify-end gap-3 md:gap-4 md:pr-4">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); toggleLike(track); }}
|
||||||
|
className={`hidden md:block ${isLiked ? 'text-green-500 visible' : 'invisible group-hover:visible hover:text-white'}`}
|
||||||
|
>
|
||||||
|
<Heart className={`w-4 h-4 ${isLiked ? 'fill-green-500' : ''}`} />
|
||||||
|
</button>
|
||||||
|
<span className="font-mono text-xs md:text-sm">{formatDuration(track.duration)}</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => openAddToPlaylist(e, track)}
|
||||||
|
className="md:invisible group-hover:visible hover:text-white text-spotify-text-muted"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="md:hidden w-5 h-5" />
|
||||||
|
<Plus className="hidden md:block w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddToPlaylistModal
|
||||||
|
track={trackToAdd}
|
||||||
|
isOpen={isAddToPlaylistOpen}
|
||||||
|
onClose={() => setIsAddToPlaylistOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlaylistPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="p-8 text-white">Loading...</div>}>
|
||||||
|
<PlaylistContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
frontend/app/search/page.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Search as SearchIcon, Play, Pause, X } from "lucide-react";
|
||||||
|
import { usePlayer } from "@/context/PlayerContext";
|
||||||
|
|
||||||
|
interface Track {
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
cover_url: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
import AddToPlaylistModal from "@/components/AddToPlaylistModal";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<Track[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const { playTrack, currentTrack, isPlaying } = usePlayer();
|
||||||
|
|
||||||
|
// Modal State
|
||||||
|
const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false);
|
||||||
|
const [trackToAdd, setTrackToAdd] = useState<Track | null>(null);
|
||||||
|
|
||||||
|
const handleSearch = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!query.trim()) return;
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||||
|
const res = await fetch(`${apiUrl}/api/search?query=${encodeURIComponent(query)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setResults(data.tracks || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Search failed:", error);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = (track: Track) => {
|
||||||
|
// Create a temporary queue from the search results
|
||||||
|
playTrack(track, results);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddToPlaylist = (e: React.MouseEvent, track: Track) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setTrackToAdd(track);
|
||||||
|
setIsAddToPlaylistOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto bg-gradient-to-b from-[#1e1e1e] to-[#121212] p-6 no-scrollbar">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="mb-8 sticky top-0 z-20 py-4 -mt-4 flex justify-center">
|
||||||
|
<form onSubmit={handleSearch} className="relative w-full max-w-[400px]">
|
||||||
|
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-black w-5 h-5 z-10" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="What do you want to play?"
|
||||||
|
className="w-full h-12 rounded-full pl-10 pr-4 bg-white text-black font-medium focus:outline-none focus:ring-2 focus:ring-white placeholder-gray-500"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuery("")}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-black hover:scale-110 transition"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div>
|
||||||
|
{isSearching ? (
|
||||||
|
<div className="text-white">Searching...</div>
|
||||||
|
) : results.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Top Results</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
||||||
|
{results.map((track, i) => {
|
||||||
|
const isCurrent = currentTrack?.id === track.id;
|
||||||
|
return (
|
||||||
|
<div key={i} onClick={() => handlePlay(track)} className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition cursor-pointer group relative">
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<img src={track.cover_url} className="w-full aspect-square rounded-md object-cover shadow-lg" alt={track.title} />
|
||||||
|
<div className={`absolute bottom-2 right-2 transition-all translate-y-2 group-hover:translate-y-0 shadow-xl bg-green-500 rounded-full p-3 ${isCurrent ? 'opacity-100 translate-y-0' : 'opacity-0'}`}>
|
||||||
|
{isCurrent && isPlaying ? (
|
||||||
|
<Pause className="w-5 h-5 text-black fill-black ml-0.5" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-5 h-5 text-black fill-black ml-0.5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className={`font-bold mb-1 truncate ${isCurrent ? "text-green-500" : "text-white"}`}>{track.title}</h3>
|
||||||
|
<p className="text-sm text-spotify-text-muted line-clamp-2">{track.artist}</p>
|
||||||
|
|
||||||
|
{/* Add to Playlist Button (Absolute Top Right of Card) */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => openAddToPlaylist(e, track)}
|
||||||
|
className="absolute top-2 right-2 p-2 bg-black/50 hover:bg-black/80 rounded-full opacity-0 group-hover:opacity-100 transition text-white"
|
||||||
|
title="Add to Playlist"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[50vh] text-center">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Play what you love</h2>
|
||||||
|
<p className="text-spotify-text-muted">Search for artists, songs, podcasts, and more.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddToPlaylistModal
|
||||||
|
track={trackToAdd}
|
||||||
|
isOpen={isAddToPlaylistOpen}
|
||||||
|
onClose={() => setIsAddToPlaylistOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/build_log.txt
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
|
||||||
|
> frontend@0.1.0 build
|
||||||
|
> next build
|
||||||
|
|
||||||
|
▲ Next.js 14.2.0
|
||||||
|
|
||||||
|
Creating an optimized production build ...
|
||||||
|
✓ Compiled successfully
|
||||||
|
Skipping validation of types
|
||||||
|
Skipping linting
|
||||||
|
Collecting page data ...
|
||||||
|
|
||||||
|
> Build error occurred
|
||||||
|
Error: Page "/playlist/[id]" is missing "generateStaticParams()" so it cannot be used with "output: export" config.
|
||||||
|
at /Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/build/index.js:1294:59
|
||||||
|
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
|
||||||
|
at async Span.traceAsyncFn (/Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/trace/trace.js:154:20)
|
||||||
|
at async Promise.all (index 8)
|
||||||
|
at async /Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/build/index.js:1172:17
|
||||||
|
at async Span.traceAsyncFn (/Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/trace/trace.js:154:20)
|
||||||
|
at async /Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/build/index.js:1095:124
|
||||||
|
at async Span.traceAsyncFn (/Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/trace/trace.js:154:20)
|
||||||
|
at async build (/Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/build/index.js:366:9)
|
||||||
23
frontend/build_log_2.txt
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
|
||||||
|
> frontend@0.1.0 build
|
||||||
|
> next build
|
||||||
|
|
||||||
|
▲ Next.js 14.2.0
|
||||||
|
|
||||||
|
Creating an optimized production build ...
|
||||||
|
✓ Compiled successfully
|
||||||
|
Skipping validation of types
|
||||||
|
Skipping linting
|
||||||
|
Collecting page data ...
|
||||||
|
|
||||||
|
> Build error occurred
|
||||||
|
Error: Page "/api/auth/[...nextauth]" is missing "generateStaticParams()" so it cannot be used with "output: export" config.
|
||||||
|
at /Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/build/index.js:1294:59
|
||||||
|
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
|
||||||
|
at async Span.traceAsyncFn (/Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/trace/trace.js:154:20)
|
||||||
|
at async Promise.all (index 4)
|
||||||
|
at async /Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/build/index.js:1172:17
|
||||||
|
at async Span.traceAsyncFn (/Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/trace/trace.js:154:20)
|
||||||
|
at async /Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/build/index.js:1095:124
|
||||||
|
at async Span.traceAsyncFn (/Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/trace/trace.js:154:20)
|
||||||
|
at async build (/Users/khoa.vo/Desktop/khoavo-spotify/frontend/node_modules/next/dist/build/index.js:366:9)
|
||||||
102
frontend/components/AddToPlaylistModal.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface AddToPlaylistModalProps {
|
||||||
|
track: any;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddToPlaylistModal({ track, isOpen, onClose }: AddToPlaylistModalProps) {
|
||||||
|
const [playlists, setPlaylists] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||||
|
fetch(`${apiUrl}/api/playlists`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setPlaylists(data))
|
||||||
|
.catch(err => console.error(err));
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleAddToPlaylist = async (playlistId: string) => {
|
||||||
|
try {
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||||
|
await fetch(`${apiUrl}/api/playlists/${playlistId}/tracks`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(track)
|
||||||
|
});
|
||||||
|
alert(`Added to playlist!`);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to add track:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4">
|
||||||
|
<div className="bg-[#282828] w-full max-w-md rounded-lg shadow-2xl overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-[#3e3e3e] flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-white">Add to Playlist</h2>
|
||||||
|
<button onClick={onClose} className="text-white hover:text-gray-300">
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-2 max-h-[60vh] overflow-y-auto no-scrollbar">
|
||||||
|
{playlists.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-spotify-text-muted">No playlists found. Create one first!</div>
|
||||||
|
) : (
|
||||||
|
playlists.map((playlist) => (
|
||||||
|
<div
|
||||||
|
key={playlist.id}
|
||||||
|
onClick={() => handleAddToPlaylist(playlist.id)}
|
||||||
|
className="flex items-center gap-3 p-3 hover:bg-[#3e3e3e] rounded-md cursor-pointer transition text-white"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 bg-[#121212] flex items-center justify-center rounded overflow-hidden">
|
||||||
|
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
|
||||||
|
<img src={playlist.cover_url} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-lg">🎵</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium truncate">{playlist.title}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-[#3e3e3e]">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const name = prompt("New Playlist Name");
|
||||||
|
if (name) {
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||||
|
fetch(`${apiUrl}/api/playlists`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
}).then(() => {
|
||||||
|
// Refresh list
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||||
|
fetch(`${apiUrl}/api/playlists`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setPlaylists(data));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full py-2 bg-white text-black font-bold rounded-full hover:scale-105 transition flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" /> New Playlist
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
frontend/components/CreatePlaylistModal.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { X, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
interface CreatePlaylistModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreatePlaylistModal({ isOpen, onClose, onCreate }: CreatePlaylistModalProps) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (name.trim()) {
|
||||||
|
onCreate(name);
|
||||||
|
setName("");
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4 animate-in fade-in duration-200">
|
||||||
|
<div className="bg-[#282828] w-full max-w-sm rounded-lg shadow-2xl overflow-hidden p-6 animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-white">Create Playlist</h2>
|
||||||
|
<button onClick={onClose} className="text-zinc-400 hover:text-white">
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="My Awesome Playlist"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full bg-[#3e3e3e] text-white p-3 rounded-md border border-transparent focus:border-green-500 focus:outline-none transition"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!name.trim()}
|
||||||
|
className="bg-green-500 text-black font-bold py-3 rounded-full hover:scale-105 hover:bg-green-400 transition disabled:opacity-50 disabled:hover:scale-100 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
frontend/components/LyricsDetail.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
|
||||||
|
interface Metric {
|
||||||
|
time: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LyricsDetailProps {
|
||||||
|
track: any;
|
||||||
|
currentTime: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onSeek?: (time: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LyricsDetail: React.FC<LyricsDetailProps> = ({ track, currentTime, onClose, onSeek }) => {
|
||||||
|
const [lyrics, setLyrics] = useState<Metric[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const activeLineRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Fetch Lyrics on Track Change
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchLyrics = async () => {
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Pass title and artist for LRCLIB fallback
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||||
|
const url = `${apiUrl}/api/lyrics?id=${track.id}&title=${encodeURIComponent(track.title)}&artist=${encodeURIComponent(track.artist)}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
setLyrics(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching lyrics:", error);
|
||||||
|
setLyrics([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchLyrics();
|
||||||
|
}, [track?.id]);
|
||||||
|
|
||||||
|
// Find active line index
|
||||||
|
const activeIndex = lyrics.findIndex((line, index) => {
|
||||||
|
const nextLine = lyrics[index + 1];
|
||||||
|
// Removing large offset to match music exactly.
|
||||||
|
// using small buffer (0.05) just for rounding safety
|
||||||
|
const timeWithOffset = currentTime + 0.05;
|
||||||
|
return timeWithOffset >= line.time && (!nextLine || timeWithOffset < nextLine.time);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-scroll to active line
|
||||||
|
// Auto-scroll to active line
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeLineRef.current && scrollContainerRef.current) {
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
const activeLine = activeLineRef.current;
|
||||||
|
|
||||||
|
// Calculate position to center (or offset) the active line
|
||||||
|
// Reverted to center (50%) as requested
|
||||||
|
const containerHeight = container.clientHeight;
|
||||||
|
const lineTop = activeLine.offsetTop;
|
||||||
|
const lineHeight = activeLine.offsetHeight;
|
||||||
|
|
||||||
|
// Target scroll position:
|
||||||
|
// Line Top - (Screen Height * 0.50) + (Half Line Height)
|
||||||
|
const targetScrollTop = lineTop - (containerHeight * 0.50) + (lineHeight / 2);
|
||||||
|
|
||||||
|
container.scrollTo({
|
||||||
|
top: targetScrollTop,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [activeIndex]);
|
||||||
|
|
||||||
|
if (!track) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`absolute inset-0 flex flex-col bg-transparent text-white`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 bg-gradient-to-b from-black/80 to-transparent z-10">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="text-xl font-bold truncate">Lyrics</h2>
|
||||||
|
<p className="text-white/60 text-xs truncate uppercase tracking-widest">
|
||||||
|
{track.artist}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 bg-white/10 rounded-full hover:bg-white/20 transition backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lyrics Container */}
|
||||||
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className="flex-1 overflow-y-auto px-6 text-center space-y-6 no-scrollbar mask-linear-gradient"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-green-500"></div>
|
||||||
|
</div>
|
||||||
|
) : lyrics.length === 0 ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-white/50 text-base">
|
||||||
|
<p className="font-bold mb-2 text-xl">Looks like we don't have lyrics for this song.</p>
|
||||||
|
<p>Enjoy the vibe!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pt-[50vh] pb-[50vh] max-w-4xl mx-auto"> {/* Reverted to center padding, added max-width */}
|
||||||
|
{lyrics.map((line, index) => {
|
||||||
|
const isActive = index === activeIndex;
|
||||||
|
const isPast = index < activeIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
ref={isActive ? activeLineRef : null}
|
||||||
|
className={`
|
||||||
|
transition-all duration-500 ease-out origin-center
|
||||||
|
${isActive
|
||||||
|
? "text-3xl md:text-4xl font-bold text-white scale-100 opacity-100 drop-shadow-lg py-4"
|
||||||
|
: "text-xl md:text-2xl font-medium text-white/30 hover:text-white/60 blur-[0px] scale-95 py-2"
|
||||||
|
}
|
||||||
|
cursor-pointer
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
if (onSeek) onSeek(line.time);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line.text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LyricsDetail;
|
||||||
30
frontend/components/MobileNav.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Home, Search, Library } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
export default function MobileNav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const isActive = (path: string) => pathname === path;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-black/95 backdrop-blur-md border-t border-white/10 h-[64px] flex items-center justify-around z-50 pb-safe">
|
||||||
|
<Link href="/" className={`flex flex-col items-center gap-1 ${isActive('/') ? 'text-white' : 'text-neutral-400'}`}>
|
||||||
|
<Home size={24} fill={isActive('/') ? "currentColor" : "none"} />
|
||||||
|
<span className="text-[10px]">Home</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/search" className={`flex flex-col items-center gap-1 ${isActive('/search') ? 'text-white' : 'text-neutral-400'}`}>
|
||||||
|
<Search size={24} />
|
||||||
|
<span className="text-[10px]">Search</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/library" className={`flex flex-col items-center gap-1 ${isActive('/library') ? 'text-white' : 'text-neutral-400'}`}>
|
||||||
|
<Library size={24} />
|
||||||
|
<span className="text-[10px]">Library</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
437
frontend/components/PlayerBar.tsx
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Play, Pause, SkipBack, SkipForward, Repeat, Shuffle, Volume2, VolumeX, Download, Disc, PlusCircle, Mic2, Heart, Loader2, ListMusic, MonitorSpeaker, Maximize2 } from 'lucide-react';
|
||||||
|
import { usePlayer } from "@/context/PlayerContext";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import TechSpecs from './TechSpecs';
|
||||||
|
import AddToPlaylistModal from "@/components/AddToPlaylistModal";
|
||||||
|
import LyricsDetail from './LyricsDetail';
|
||||||
|
|
||||||
|
export default function PlayerBar() {
|
||||||
|
const { currentTrack, isPlaying, isBuffering, togglePlay, setBuffering, likedTracks, toggleLike, nextTrack, prevTrack, shuffle, toggleShuffle, repeatMode, toggleRepeat, audioQuality } = usePlayer();
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [volume, setVolume] = useState(1);
|
||||||
|
|
||||||
|
// Modal State
|
||||||
|
const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false);
|
||||||
|
const [isLyricsOpen, setIsLyricsOpen] = useState(false);
|
||||||
|
const [isTechSpecsOpen, setIsTechSpecsOpen] = useState(false);
|
||||||
|
const [isFullScreenPlayerOpen, setIsFullScreenPlayerOpen] = useState(false);
|
||||||
|
const [isCoverModalOpen, setIsCoverModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentTrack && audioRef.current && currentTrack.url) {
|
||||||
|
// Prevent reloading if URL hasn't changed
|
||||||
|
const isSameUrl = audioRef.current.src === currentTrack.url ||
|
||||||
|
(currentTrack.url.startsWith('/') && audioRef.current.src.endsWith(currentTrack.url)) ||
|
||||||
|
(audioRef.current.src.includes(currentTrack.id)); // Fallback for stream IDs
|
||||||
|
|
||||||
|
if (isSameUrl) return;
|
||||||
|
|
||||||
|
audioRef.current.src = currentTrack.url;
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.play().catch(e => console.error("Play error:", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentTrack?.url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
if (isPlaying) audioRef.current.play().catch(e => console.error("Play error:", e));
|
||||||
|
else audioRef.current.pause();
|
||||||
|
}
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
// Volume Effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.volume = volume;
|
||||||
|
}
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
setProgress(audioRef.current.currentTime);
|
||||||
|
if (!isNaN(audioRef.current.duration)) {
|
||||||
|
setDuration(audioRef.current.duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const time = parseFloat(e.target.value);
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = time;
|
||||||
|
setProgress(time);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const vol = parseFloat(e.target.value);
|
||||||
|
setVolume(vol);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (!currentTrack) return;
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||||
|
const url = `${apiUrl}/api/download?id=${currentTrack.id}&title=${encodeURIComponent(currentTrack.title)}`;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (time: number) => {
|
||||||
|
if (isNaN(time)) return "0:00";
|
||||||
|
const minutes = Math.floor(time / 60);
|
||||||
|
const seconds = Math.floor(time % 60);
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
className="fixed bottom-[64px] left-2 right-2 md:left-0 md:right-0 md:bottom-0 h-14 md:h-[90px] bg-[#2E2E2E] md:bg-black border-t-0 md:border-t border-[#282828] flex items-center justify-between z-[60] rounded-lg md:rounded-none shadow-xl md:shadow-none transition-all duration-300"
|
||||||
|
onClick={(e) => {
|
||||||
|
// Mobile: Open Full Screen Player
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
setIsFullScreenPlayerOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
preload="auto"
|
||||||
|
onEnded={nextTrack}
|
||||||
|
onWaiting={() => setBuffering(true)}
|
||||||
|
onPlaying={() => setBuffering(false)}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Mobile Progress Bar (Mini Player) */}
|
||||||
|
<div className="absolute bottom-0 left-1 right-1 h-[2px] md:hidden">
|
||||||
|
{/* Visual Bar */}
|
||||||
|
<div className="absolute inset-0 bg-white/20 rounded-full overflow-hidden pointer-events-none">
|
||||||
|
<div
|
||||||
|
className="h-full bg-white rounded-full transition-all duration-300 ease-linear"
|
||||||
|
style={{ width: `${(progress / (duration || 1)) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Interactive Slider Overlay */}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={duration || 100}
|
||||||
|
value={progress}
|
||||||
|
onChange={(e) => { e.stopPropagation(); handleSeek(e); }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="absolute -bottom-1 -left-1 -right-1 h-4 w-[calc(100%+8px)] opacity-0 cursor-pointer z-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Left: Now Playing */}
|
||||||
|
<div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0 md:w-[30%] text-white md:pl-4">
|
||||||
|
{currentTrack ? (
|
||||||
|
<>
|
||||||
|
{/* Artwork */}
|
||||||
|
<img
|
||||||
|
src={currentTrack.cover_url}
|
||||||
|
alt="Cover"
|
||||||
|
className="h-10 w-10 md:h-14 md:w-14 rounded md:rounded-md object-cover ml-1 md:ml-0 cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (window.innerWidth >= 768) setIsCoverModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-center overflow-hidden min-w-0">
|
||||||
|
<span className="text-sm font-medium truncate leading-tight hover:underline cursor-pointer">{currentTrack.title}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-neutral-400 truncate leading-tight hover:underline cursor-pointer">{currentTrack.artist}</span>
|
||||||
|
{audioQuality && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsTechSpecsOpen(true)}
|
||||||
|
className="text-[10px] bg-white/10 px-1 rounded text-green-400 font-bold hover:bg-white/20 transition border border-green-400/20"
|
||||||
|
>
|
||||||
|
HI-RES
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Heart (Inline) */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); currentTrack && toggleLike(currentTrack); }}
|
||||||
|
className={`md:hidden ml-2 ${likedTracks.has(currentTrack.id) ? 'text-green-500' : 'text-neutral-400'}`}
|
||||||
|
>
|
||||||
|
<Heart size={20} fill={likedTracks.has(currentTrack.id) ? "currentColor" : "none"} />
|
||||||
|
</button>
|
||||||
|
{/* Desktop Heart */}
|
||||||
|
<button
|
||||||
|
onClick={() => currentTrack && toggleLike(currentTrack)}
|
||||||
|
className={`hidden md:block hover:scale-110 transition ${likedTracks.has(currentTrack.id) ? 'text-green-500' : 'text-spotify-text-muted hover:text-white'}`}
|
||||||
|
>
|
||||||
|
<Heart className={`w-5 h-5 ${likedTracks.has(currentTrack.id) ? 'fill-green-500' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Add to Playlist Button (Desktop) */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAddToPlaylistOpen(true)}
|
||||||
|
className="hidden md:block text-spotify-text-muted hover:text-white hover:scale-110 transition"
|
||||||
|
title="Add to Playlist"
|
||||||
|
>
|
||||||
|
<PlusCircle className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="h-10 w-10 md:h-14 md:w-14 bg-transparent rounded md:rounded-md" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center: Controls (Desktop) | Right: Controls (Mobile) */}
|
||||||
|
<div className="flex md:flex-col items-center justify-end md:justify-center md:max-w-[40%] w-auto md:w-full gap-2 pr-3 md:pr-0">
|
||||||
|
|
||||||
|
{/* Mobile: Play/Pause Only (and Lyrics) */}
|
||||||
|
<div className="flex items-center gap-3 md:hidden">
|
||||||
|
{/* Mobile Lyrics Button */}
|
||||||
|
<button
|
||||||
|
className={`transition ${isLyricsOpen ? 'text-green-500' : 'text-neutral-300'}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setIsLyricsOpen(!isLyricsOpen); }}
|
||||||
|
>
|
||||||
|
<Mic2 size={22} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); togglePlay(); }}
|
||||||
|
className="text-white"
|
||||||
|
>
|
||||||
|
{isBuffering ? <Loader2 size={24} className="animate-spin" /> : (isPlaying ? <Pause size={24} fill="currentColor" /> : <Play size={24} fill="currentColor" />)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: Full Controls */}
|
||||||
|
<div className="hidden md:flex items-center gap-6">
|
||||||
|
<button
|
||||||
|
onClick={toggleShuffle}
|
||||||
|
className={`transition ${shuffle ? 'text-green-500' : 'text-spotify-text-muted hover:text-white'}`}>
|
||||||
|
<Shuffle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={prevTrack} className="text-spotify-text-muted hover:text-white transition"><SkipBack className="w-5 h-5 fill-current" /></button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="w-8 h-8 bg-white rounded-full flex items-center justify-center hover:scale-105 transition">
|
||||||
|
{isBuffering ? (
|
||||||
|
<Loader2 className="w-4 h-4 text-black animate-spin" />
|
||||||
|
) : isPlaying ? (
|
||||||
|
<Pause className="w-4 h-4 text-black fill-black" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-4 h-4 text-black fill-black ml-0.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={nextTrack} className="text-spotify-text-muted hover:text-white transition"><SkipForward className="w-5 h-5 fill-current" /></button>
|
||||||
|
<button
|
||||||
|
onClick={toggleRepeat}
|
||||||
|
className={`transition ${repeatMode !== 'none' ? 'text-green-500' : 'text-spotify-text-muted hover:text-white'} relative`}>
|
||||||
|
<Repeat className="w-4 h-4" />
|
||||||
|
{repeatMode === 'one' && <span className="absolute -top-1 -right-1 text-[8px] font-bold text-black bg-green-500 rounded-full w-3 h-3 flex items-center justify-center">1</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: Seek Bar */}
|
||||||
|
<div className="hidden md:flex items-center gap-2 w-full text-xs text-spotify-text-muted">
|
||||||
|
<span>{formatTime(progress)}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={duration || 100}
|
||||||
|
value={progress}
|
||||||
|
onChange={handleSeek}
|
||||||
|
className="w-full h-1 bg-[#4d4d4d] rounded-lg appearance-none cursor-pointer accent-white hover:accent-green-500"
|
||||||
|
/>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Volume & Extras (Desktop Only) */}
|
||||||
|
<div className="hidden md:flex items-center justify-end gap-3 w-[30%] text-spotify-text-muted pr-4">
|
||||||
|
{/* Right Controls */}
|
||||||
|
<div className="flex items-center justify-end space-x-2 md:space-x-4">
|
||||||
|
<button
|
||||||
|
className={`text-zinc-400 hover:text-white transition ${isLyricsOpen ? 'text-green-500' : ''}`}
|
||||||
|
onClick={() => setIsLyricsOpen(!isLyricsOpen)}
|
||||||
|
title="Lyrics"
|
||||||
|
>
|
||||||
|
<Mic2 size={20} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="hidden md:block text-zinc-400 hover:text-white transition"
|
||||||
|
onClick={handleDownload}
|
||||||
|
title="Download MP3"
|
||||||
|
>
|
||||||
|
<Download size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ListMusic className="hidden md:block w-4 h-4 hover:text-white cursor-pointer" />
|
||||||
|
<MonitorSpeaker className="hidden md:block w-4 h-4 hover:text-white cursor-pointer" />
|
||||||
|
<div className="hidden md:flex items-center gap-2 w-24 group">
|
||||||
|
{/* Volume Controls (Desktop Only) */}
|
||||||
|
<Volume2 className="w-4 h-4 group-hover:text-white" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={volume}
|
||||||
|
onChange={handleVolume}
|
||||||
|
className="w-full h-1 bg-[#4d4d4d] rounded-lg appearance-none cursor-pointer accent-white group-hover:accent-green-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Maximize2 className="hidden md:block w-4 h-4 hover:text-white cursor-pointer" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- OVERLAYS --- */}
|
||||||
|
|
||||||
|
{/* Mobile Full Screen Player */}
|
||||||
|
{isFullScreenPlayerOpen && currentTrack && (
|
||||||
|
<div className="fixed inset-0 z-[65] h-[100dvh] bg-gradient-to-b from-[#404040] via-[#121212] to-black flex flex-col px-6 pt-12 pb-[calc(2rem+env(safe-area-inset-bottom))] md:hidden animate-in slide-in-from-bottom duration-300">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8 shrink-0">
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); setIsFullScreenPlayerOpen(false); }} className="text-white">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 9l-7 7-7-7" /></svg>
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-bold tracking-widest uppercase text-white/80">Now Playing</span>
|
||||||
|
<button className="text-white">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Artwork */}
|
||||||
|
<div className="flex-1 flex items-center justify-center w-full mb-8 min-h-0">
|
||||||
|
<img src={currentTrack.cover_url} alt="Cover" className="w-full max-h-[45vh] aspect-square object-contain rounded-lg shadow-2xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex items-center justify-between mb-6 shrink-0">
|
||||||
|
<div className="flex flex-col gap-1 pr-4">
|
||||||
|
<div className="relative overflow-hidden w-full">
|
||||||
|
<h2 className="text-2xl font-bold text-white leading-tight line-clamp-1">{currentTrack.title}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg text-gray-400 line-clamp-1">{currentTrack.artist}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); toggleLike(currentTrack); }} className={`${likedTracks.has(currentTrack.id) ? 'text-green-500' : 'text-white'}`}>
|
||||||
|
<Heart size={28} fill={likedTracks.has(currentTrack.id) ? "currentColor" : "none"} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="mb-4 shrink-0">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={duration || 100}
|
||||||
|
value={progress}
|
||||||
|
onChange={handleSeek}
|
||||||
|
className="w-full h-1 bg-white/20 rounded-lg appearance-none cursor-pointer accent-white hover:accent-green-500 mb-2"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 font-medium">
|
||||||
|
<span>{formatTime(progress)}</span>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center justify-between mb-8 px-2 shrink-0">
|
||||||
|
<button onClick={toggleShuffle} className={`${shuffle ? 'text-green-500' : 'text-zinc-400'} hover:text-white transition`}>
|
||||||
|
<Shuffle size={24} />
|
||||||
|
</button>
|
||||||
|
<button onClick={prevTrack} className="text-white hover:scale-110 transition">
|
||||||
|
<SkipBack size={36} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); togglePlay(); }}
|
||||||
|
className="w-16 h-16 bg-white rounded-full flex items-center justify-center hover:scale-105 transition active:scale-95 text-black shadow-lg"
|
||||||
|
>
|
||||||
|
{isBuffering ? <Loader2 className="w-8 h-8 text-black animate-spin" /> : (isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" className="ml-1" />)}
|
||||||
|
</button>
|
||||||
|
<button onClick={nextTrack} className="text-white hover:scale-110 transition">
|
||||||
|
<SkipForward size={36} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
<button onClick={toggleRepeat} className={`${repeatMode !== 'none' ? 'text-green-500' : 'text-zinc-400'} hover:text-white transition relative`}>
|
||||||
|
<Repeat size={24} />
|
||||||
|
{repeatMode === 'one' && <span className="absolute -top-1 -right-1 text-[8px] bg-green-500 text-black px-1 rounded-full">1</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="flex items-center justify-between px-2 shrink-0">
|
||||||
|
<button onClick={() => setIsTechSpecsOpen(true)} className="text-green-500 hover:text-white transition">
|
||||||
|
<MonitorSpeaker size={20} />
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-6 items-center">
|
||||||
|
<button className="text-zinc-400 hover:text-white">
|
||||||
|
<PlusCircle size={24} onClick={() => setIsAddToPlaylistOpen(true)} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`transition ${isLyricsOpen ? 'text-green-500' : 'text-zinc-400'} hover:text-white`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setIsLyricsOpen(!isLyricsOpen); }}
|
||||||
|
>
|
||||||
|
<Mic2 size={24} />
|
||||||
|
</button>
|
||||||
|
<button className="text-zinc-400 hover:text-white">
|
||||||
|
<ListMusic size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop Full Cover Modal */}
|
||||||
|
{isCoverModalOpen && currentTrack && (
|
||||||
|
<div className="fixed inset-0 z-[65] bg-black/80 backdrop-blur-sm flex items-center justify-center p-8 animate-in fade-in duration-200" onClick={() => setIsCoverModalOpen(false)}>
|
||||||
|
<img src={currentTrack.cover_url} alt="Cover Full" className="max-h-[80vh] max-w-[80vw] object-contain shadow-2xl rounded-lg scale-100" onClick={(e) => e.stopPropagation()} />
|
||||||
|
<button onClick={() => setIsCoverModalOpen(false)} className="absolute top-8 right-8 text-white/50 hover:text-white"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6L6 18M6 6l12 12" /></svg></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<TechSpecs
|
||||||
|
isOpen={isTechSpecsOpen}
|
||||||
|
onClose={() => setIsTechSpecsOpen(false)}
|
||||||
|
quality={audioQuality}
|
||||||
|
trackTitle={currentTrack?.title || ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isAddToPlaylistOpen && currentTrack && (
|
||||||
|
<AddToPlaylistModal
|
||||||
|
track={currentTrack}
|
||||||
|
isOpen={true}
|
||||||
|
onClose={() => setIsAddToPlaylistOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lyrics Sheet (Responsive) */}
|
||||||
|
{isLyricsOpen && currentTrack && (
|
||||||
|
<div className="fixed inset-0 md:inset-auto md:bottom-[100px] md:left-1/2 md:-translate-x-1/2 h-[100dvh] md:h-[500px] md:w-[600px] z-[70] bg-[#121212] md:bg-black/80 backdrop-blur-xl border-t md:border border-white/10 md:rounded-2xl shadow-2xl animate-in slide-in-from-bottom-full duration-500 overflow-hidden flex flex-col">
|
||||||
|
{/* Mobile Drag Handle Visual - Removed for full screen immersion */}
|
||||||
|
{/* <div
|
||||||
|
className="w-12 h-1 bg-white/20 rounded-full mx-auto mt-3 mb-1 md:hidden cursor-pointer"
|
||||||
|
onClick={() => setIsLyricsOpen(false)}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
<LyricsDetail
|
||||||
|
track={currentTrack}
|
||||||
|
currentTime={audioRef.current ? audioRef.current.currentTime : 0}
|
||||||
|
onClose={() => setIsLyricsOpen(false)}
|
||||||
|
onSeek={(time) => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = time;
|
||||||
|
setProgress(time);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
frontend/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Home, Search, Library, Plus, Heart } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePlayer } from "@/context/PlayerContext";
|
||||||
|
import { useState } from "react";
|
||||||
|
import CreatePlaylistModal from "./CreatePlaylistModal";
|
||||||
|
import { dbService } from "@/services/db";
|
||||||
|
import { useLibrary } from "@/context/LibraryContext";
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const { likedTracks } = usePlayer();
|
||||||
|
const { userPlaylists, libraryItems, refreshLibrary: refresh, activeFilter, setActiveFilter } = useLibrary();
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleCreatePlaylist = async (name: string) => {
|
||||||
|
await dbService.createPlaylist(name);
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePlaylist = async (e: React.MouseEvent, id: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (confirm("Delete this playlist?")) {
|
||||||
|
await dbService.deletePlaylist(id);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtering Logic
|
||||||
|
const showPlaylists = activeFilter === 'all' || activeFilter === 'playlists';
|
||||||
|
const showArtists = activeFilter === 'all' || activeFilter === 'artists';
|
||||||
|
const showAlbums = activeFilter === 'all' || activeFilter === 'albums';
|
||||||
|
|
||||||
|
const artists = libraryItems.filter(i => i.type === 'Artist');
|
||||||
|
const albums = libraryItems.filter(i => i.type === 'Album');
|
||||||
|
const browsePlaylists = libraryItems.filter(i => i.type === 'Playlist');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="hidden md:flex flex-col w-[280px] bg-black h-full gap-2 p-2">
|
||||||
|
<div className="bg-[#121212] rounded-lg p-4 flex flex-col gap-4">
|
||||||
|
<Link href="/" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer">
|
||||||
|
<Home className="w-6 h-6" />
|
||||||
|
<span className="font-bold">Home</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/search" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer">
|
||||||
|
<Search className="w-6 h-6" />
|
||||||
|
<span className="font-bold">Search</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#121212] rounded-lg flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div className="p-4 shadow-md z-10">
|
||||||
|
<div className="flex items-center justify-between text-spotify-text-muted mb-4">
|
||||||
|
<Link href="/library" className="flex items-center gap-2 hover:text-white transition cursor-pointer">
|
||||||
|
<Library className="w-6 h-6" />
|
||||||
|
<span className="font-bold">Your Library</span>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => setIsCreateModalOpen(true)} className="hover:text-white hover:scale-110 transition">
|
||||||
|
<Plus className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-2 mt-4 overflow-x-auto no-scrollbar">
|
||||||
|
{['Playlists', 'Artists', 'Albums'].map((filter) => {
|
||||||
|
const key = filter.toLowerCase() as any;
|
||||||
|
const isActive = activeFilter === key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={filter}
|
||||||
|
onClick={() => setActiveFilter(isActive ? 'all' : key)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm font-medium transition whitespace-nowrap ${isActive ? 'bg-white text-black' : 'bg-[#2a2a2a] text-white hover:bg-[#3a3a3a]'}`}
|
||||||
|
>
|
||||||
|
{filter}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 no-scrollbar">
|
||||||
|
{/* Liked Songs (Always top if 'Playlists' or 'All') */}
|
||||||
|
{showPlaylists && (
|
||||||
|
<Link href="/collection/tracks">
|
||||||
|
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer group mb-2">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-indigo-700 to-blue-300 rounded flex items-center justify-center">
|
||||||
|
<Heart className="w-6 h-6 text-white fill-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-medium">Liked Songs</h3>
|
||||||
|
<p className="text-sm text-spotify-text-muted">Playlist • {likedTracks.size} songs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Playlists */}
|
||||||
|
{showPlaylists && userPlaylists.map((playlist) => (
|
||||||
|
<div key={playlist.id} className="group relative flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||||
|
<Link href={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-[#282828] rounded flex items-center justify-center overflow-hidden">
|
||||||
|
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
|
||||||
|
<img src={playlist.cover_url} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xl">🎵</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-white font-medium truncate">{playlist.title}</h3>
|
||||||
|
<p className="text-sm text-spotify-text-muted truncate">Playlist • You</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDeletePlaylist(e, playlist.id)}
|
||||||
|
className="absolute right-2 text-zinc-400 hover:text-white opacity-0 group-hover:opacity-100 transition"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Fake/Browse Playlists */}
|
||||||
|
{showPlaylists && browsePlaylists.map((playlist) => (
|
||||||
|
<div key={playlist.id} className="group relative flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||||
|
<Link href={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-[#282828] rounded flex items-center justify-center overflow-hidden">
|
||||||
|
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
|
||||||
|
<img src={playlist.cover_url} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xl">🎵</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-white font-medium truncate">{playlist.title}</h3>
|
||||||
|
<p className="text-sm text-spotify-text-muted truncate">Playlist • Made for you</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Artists */}
|
||||||
|
{showArtists && artists.map((artist) => (
|
||||||
|
<Link href={`/search?q=${encodeURIComponent(artist.title)}`} key={artist.id}>
|
||||||
|
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||||
|
<div className="w-12 h-12 bg-[#282828] rounded-full flex items-center justify-center overflow-hidden relative">
|
||||||
|
{artist.cover_url ? (
|
||||||
|
<img src={artist.cover_url} alt="" className="w-full h-full object-cover" onError={(e) => e.currentTarget.style.display = 'none'} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-white font-medium truncate">{artist.title}</h3>
|
||||||
|
<p className="text-sm text-spotify-text-muted truncate">Artist</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Albums */}
|
||||||
|
{showAlbums && albums.map((album) => (
|
||||||
|
<Link href={`/ search ? q = ${encodeURIComponent(album.title)} `} key={album.id}>
|
||||||
|
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||||
|
<div className="w-12 h-12 bg-[#282828] rounded flex items-center justify-center overflow-hidden relative">
|
||||||
|
{album.cover_url ? (
|
||||||
|
<img src={album.cover_url} alt="" className="w-full h-full object-cover" onError={(e) => e.currentTarget.style.display = 'none'} />
|
||||||
|
) : (
|
||||||
|
<span className="text-xl">💿</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-white font-medium truncate">{album.title}</h3>
|
||||||
|
<p className="text-sm text-spotify-text-muted truncate">Album • {album.creator || 'Spotify'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreatePlaylistModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onCreate={handleCreatePlaylist}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
247
frontend/components/TechSpecs.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
import { X, Activity, Zap, Server, ShieldCheck, Waves, Wifi, ArrowDown, ArrowUp } from "lucide-react";
|
||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
|
||||||
|
interface AudioQuality {
|
||||||
|
format: string;
|
||||||
|
sampleRate: number;
|
||||||
|
bitDepth?: number;
|
||||||
|
bitrate: number;
|
||||||
|
channels: number;
|
||||||
|
codec?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TechSpecsProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
quality: AudioQuality | null;
|
||||||
|
trackTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TechSpecs({ isOpen, onClose, quality, trackTitle }: TechSpecsProps) {
|
||||||
|
const [bitrateHistory, setBitrateHistory] = useState<number[]>(new Array(40).fill(0));
|
||||||
|
const [currentKbps, setCurrentKbps] = useState(0);
|
||||||
|
const [bufferHealth, setBufferHealth] = useState(100);
|
||||||
|
const [networkStats, setNetworkStats] = useState({ download: 0, upload: 0 });
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
// Simulate live bitrate fluctuation around the target bitrate
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !quality) return;
|
||||||
|
|
||||||
|
const baseBitrate = quality.bitrate / 1000; // kbps
|
||||||
|
setCurrentKbps(baseBitrate);
|
||||||
|
setBitrateHistory(new Array(40).fill(baseBitrate));
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
// Fluctuate +/- 5%
|
||||||
|
const fluctuation = baseBitrate * 0.05 * (Math.random() - 0.5);
|
||||||
|
const newValue = baseBitrate + fluctuation;
|
||||||
|
|
||||||
|
setCurrentKbps(prev => newValue);
|
||||||
|
setBitrateHistory(prev => [...prev.slice(1), newValue]);
|
||||||
|
|
||||||
|
// Random buffer fluctuation
|
||||||
|
setBufferHealth(prev => Math.min(100, Math.max(98, prev + (Math.random() - 0.5))));
|
||||||
|
|
||||||
|
// Simulate Network Traffic (Bursty Download, Consistent Upload)
|
||||||
|
setNetworkStats({
|
||||||
|
download: Math.random() > 0.6 ? Math.floor(Math.random() * 2500) + 800 : 0, // kbps
|
||||||
|
upload: Math.floor(Math.random() * 40) + 10 // kbps
|
||||||
|
});
|
||||||
|
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isOpen, quality]);
|
||||||
|
|
||||||
|
// Draw Graph
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || !quality) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const width = canvas.width;
|
||||||
|
const height = canvas.height;
|
||||||
|
const baseBitrate = quality.bitrate / 1000;
|
||||||
|
const range = baseBitrate * 0.2; // Zoom in range
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Gradient
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||||
|
gradient.addColorStop(0, 'rgba(74, 222, 128, 0.5)'); // Green-400
|
||||||
|
gradient.addColorStop(1, 'rgba(74, 222, 128, 0)');
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, height);
|
||||||
|
|
||||||
|
bitrateHistory.forEach((val, i) => {
|
||||||
|
const x = (i / (bitrateHistory.length - 1)) * width;
|
||||||
|
// Map value to height (inverted)
|
||||||
|
// min = base - range, max = base + range
|
||||||
|
const normalized = (val - (baseBitrate - range)) / (range * 2);
|
||||||
|
const y = height - (normalized * height);
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.lineTo(width, height);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Line
|
||||||
|
ctx.beginPath();
|
||||||
|
bitrateHistory.forEach((val, i) => {
|
||||||
|
const x = (i / (bitrateHistory.length - 1)) * width;
|
||||||
|
const normalized = (val - (baseBitrate - range)) / (range * 2);
|
||||||
|
const y = height - (normalized * height);
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
ctx.strokeStyle = '#4ade80';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
}, [bitrateHistory, quality]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-in fade-in duration-200">
|
||||||
|
<div className="bg-[#121212] border border-white/10 rounded-xl w-full max-w-md relative shadow-2xl flex flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 border-b border-white/5 flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Activity className="text-green-500 w-5 h-5" />
|
||||||
|
<h3 className="text-lg font-bold text-white tracking-wide">AUDIO ENGINE</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#a7a7a7] uppercase tracking-wider font-mono">
|
||||||
|
{quality ? 'DIRECT SOUND • EXCLUSIVE MODE' : 'INITIALIZING...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white/50 hover:text-white transition"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{quality ? (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Live Monitor */}
|
||||||
|
<div className="bg-black/40 rounded-lg p-4 border border-white/5">
|
||||||
|
<div className="flex justify-between items-end mb-2">
|
||||||
|
<span className="text-xs text-gray-400 font-mono uppercase">Live Bitrate</span>
|
||||||
|
<span className="text-xl font-mono font-bold text-green-400">
|
||||||
|
{currentKbps.toFixed(0)} <span className="text-xs text-gray-500">kbps</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<canvas ref={canvasRef} width={300} height={60} className="w-full h-[60px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Signal Path / Stats Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Source Stats */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs text-gray-500 uppercase tracking-widest font-bold mb-2">Source</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-gray-400">Codec</span>
|
||||||
|
<span className="text-white font-mono">{quality.codec || quality.format}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-gray-400">Sample Rate</span>
|
||||||
|
<span className="text-white font-mono">{quality.sampleRate / 1000} kHz</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-gray-400">Bit Depth</span>
|
||||||
|
<span className="text-white font-mono">{quality.bitDepth || 24}-bit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Processing Stats */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs text-gray-500 uppercase tracking-widest font-bold mb-2">Processing</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-gray-400">Integrity</span>
|
||||||
|
<span className="text-green-400 font-mono flex items-center gap-1">
|
||||||
|
<ShieldCheck className="w-3 h-3" /> Bit-Perfect
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-gray-400">Dynamic Range</span>
|
||||||
|
<span className="text-white font-mono">14 dB (Est)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-gray-400">Loudness</span>
|
||||||
|
<span className="text-white font-mono">-14.2 LUFS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network & Buffer */}
|
||||||
|
<div className="space-y-2 pt-2 border-t border-white/5">
|
||||||
|
{/* Network Stream */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-400 mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wifi className="w-3 h-3" />
|
||||||
|
<span>Network Stream</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 font-mono">
|
||||||
|
<span className="text-white flex items-center gap-1">
|
||||||
|
<ArrowDown className="w-3 h-3 text-blue-400" />
|
||||||
|
{networkStats.download > 0 ? (networkStats.download / 1000).toFixed(1) : '0.0'} <span className="text-gray-600">MB/s</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-white/60 flex items-center gap-1">
|
||||||
|
<ArrowUp className="w-3 h-3 text-orange-400" />
|
||||||
|
{networkStats.upload} <span className="text-gray-600">kbps</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-400">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Server className="w-3 h-3" />
|
||||||
|
<span>Stream Buffer</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-white">{bufferHealth.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 w-full bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-green-500 transition-all duration-300"
|
||||||
|
style={{ width: `${bufferHealth}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-400 mt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
<span>Output Device</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-white">Default Audio Interface</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-12 text-center text-gray-500 animate-pulse font-mono text-sm">
|
||||||
|
HANDSHAKING WITH AUDIO CORE...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="bg-white/5 p-3 text-[10px] text-center text-gray-600 font-mono uppercase tracking-widest">
|
||||||
|
Quantum Audio Engine v2.4.0 • 64-bit Floating Point
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
frontend/context/LibraryContext.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||||
|
import { dbService, Playlist } from "@/services/db";
|
||||||
|
import { libraryService } from "@/services/library";
|
||||||
|
|
||||||
|
type FilterType = 'all' | 'playlists' | 'artists' | 'albums';
|
||||||
|
|
||||||
|
interface LibraryContextType {
|
||||||
|
userPlaylists: Playlist[];
|
||||||
|
libraryItems: any[];
|
||||||
|
activeFilter: FilterType;
|
||||||
|
setActiveFilter: (filter: FilterType) => void;
|
||||||
|
refreshLibrary: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LibraryContext = createContext<LibraryContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function LibraryProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [userPlaylists, setUserPlaylists] = useState<Playlist[]>([]);
|
||||||
|
const [libraryItems, setLibraryItems] = useState<any[]>([]);
|
||||||
|
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
|
||||||
|
|
||||||
|
const fetchAllData = async () => {
|
||||||
|
try {
|
||||||
|
// 1. User Playlists
|
||||||
|
const playlists = await dbService.getPlaylists();
|
||||||
|
setUserPlaylists(playlists);
|
||||||
|
|
||||||
|
// 2. Local/Backend Content
|
||||||
|
const browse = await libraryService.getBrowseContent();
|
||||||
|
const browsePlaylists = Object.values(browse).flat();
|
||||||
|
|
||||||
|
const artistsMap = new Map();
|
||||||
|
const albumsMap = new Map();
|
||||||
|
const allTracks: any[] = [];
|
||||||
|
|
||||||
|
// 3. Extract metadata
|
||||||
|
browsePlaylists.forEach((p: any) => {
|
||||||
|
if (p.tracks) {
|
||||||
|
p.tracks.forEach((t: any) => {
|
||||||
|
allTracks.push(t);
|
||||||
|
// Fake Artist
|
||||||
|
if (artistsMap.size < 40 && t.artist && t.artist !== 'Unknown Artist' && t.artist !== 'Unknown') {
|
||||||
|
if (!artistsMap.has(t.artist)) {
|
||||||
|
artistsMap.set(t.artist, {
|
||||||
|
id: `artist-${t.artist}`,
|
||||||
|
title: t.artist,
|
||||||
|
type: 'Artist',
|
||||||
|
cover_url: t.cover_url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fake Album
|
||||||
|
if (albumsMap.size < 40 && t.album && t.album !== 'Single' && t.album !== 'Unknown Album') {
|
||||||
|
if (!albumsMap.has(t.album)) {
|
||||||
|
albumsMap.set(t.album, {
|
||||||
|
id: `album-${t.album}`,
|
||||||
|
title: t.album,
|
||||||
|
type: 'Album',
|
||||||
|
creator: t.artist,
|
||||||
|
cover_url: t.cover_url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Generate Fake Extra Playlists (Creative Names)
|
||||||
|
const fakePlaylists = [...browsePlaylists];
|
||||||
|
const targetCount = 40;
|
||||||
|
const needed = targetCount - fakePlaylists.length;
|
||||||
|
|
||||||
|
const creativeNames = [
|
||||||
|
"Chill Vibes", "Late Night Focus", "Workout Energy", "Road Trip Classics",
|
||||||
|
"Indie Mix", "Pop Hits", "Throwback Thursday", "Weekend Flow",
|
||||||
|
"Deep Focus", "Party Anthems", "Jazz & Blues", "Acoustic Sessions",
|
||||||
|
"Morning Coffee", "Rainy Day", "Sleep Sounds", "Gaming Beats",
|
||||||
|
"Coding Mode", "Summer Hits", "Winter Lo-Fi", "Discover Weekly",
|
||||||
|
"Release Radar", "On Repeat", "Time Capsule", "Viral 50",
|
||||||
|
"Global Top 50", "Trending Now", "Fresh Finds", "Audiobook Mode",
|
||||||
|
"Podcast Favorites", "Rock Classics", "Metal Essentials", "Hip Hop Gold",
|
||||||
|
"Electronic Dreams", "Ambient Spaces", "Classical Masterpieces", "Country Roads"
|
||||||
|
];
|
||||||
|
|
||||||
|
if (needed > 0 && allTracks.length > 0) {
|
||||||
|
const shuffle = (array: any[]) => array.sort(() => 0.5 - Math.random());
|
||||||
|
|
||||||
|
for (let i = 0; i < needed; i++) {
|
||||||
|
const shuffled = shuffle([...allTracks]);
|
||||||
|
const selected = shuffled.slice(0, 8 + Math.floor(Math.random() * 12));
|
||||||
|
const cover = selected[0]?.cover_url;
|
||||||
|
const name = creativeNames[i] || `Daily Mix ${i + 1}`;
|
||||||
|
|
||||||
|
fakePlaylists.push({
|
||||||
|
id: `mix-${i}`,
|
||||||
|
title: name,
|
||||||
|
description: `Curated just for you • ${selected.length} songs`,
|
||||||
|
cover_url: cover,
|
||||||
|
tracks: selected,
|
||||||
|
type: 'Playlist'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueItems = [
|
||||||
|
...fakePlaylists.map(p => ({ ...p, type: 'Playlist' })),
|
||||||
|
...Array.from(artistsMap.values()),
|
||||||
|
...Array.from(albumsMap.values())
|
||||||
|
];
|
||||||
|
|
||||||
|
setLibraryItems(uniqueItems);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAllData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LibraryContext.Provider value={{
|
||||||
|
userPlaylists,
|
||||||
|
libraryItems,
|
||||||
|
activeFilter,
|
||||||
|
setActiveFilter,
|
||||||
|
refreshLibrary: fetchAllData
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</LibraryContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLibrary() {
|
||||||
|
const context = useContext(LibraryContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useLibrary must be used within a LibraryProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
288
frontend/context/PlayerContext.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||||
|
import { dbService } from "@/services/db";
|
||||||
|
|
||||||
|
interface Track {
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
cover_url: string;
|
||||||
|
id: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
import * as mm from 'music-metadata-browser';
|
||||||
|
|
||||||
|
interface AudioQuality {
|
||||||
|
format: string;
|
||||||
|
sampleRate: number;
|
||||||
|
bitDepth?: number;
|
||||||
|
bitrate: number;
|
||||||
|
channels: number;
|
||||||
|
codec?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerContextType {
|
||||||
|
currentTrack: Track | null;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isBuffering: boolean;
|
||||||
|
likedTracks: Set<string>;
|
||||||
|
likedTracksData: Track[];
|
||||||
|
shuffle: boolean;
|
||||||
|
repeatMode: 'none' | 'all' | 'one';
|
||||||
|
playTrack: (track: Track, queue?: Track[]) => void;
|
||||||
|
togglePlay: () => void;
|
||||||
|
nextTrack: () => void;
|
||||||
|
prevTrack: () => void;
|
||||||
|
toggleShuffle: () => void;
|
||||||
|
toggleRepeat: () => void;
|
||||||
|
setBuffering: (state: boolean) => void;
|
||||||
|
toggleLike: (track: Track) => void;
|
||||||
|
playHistory: Track[];
|
||||||
|
audioQuality: AudioQuality | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayerContext = createContext<PlayerContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function PlayerProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [currentTrack, setCurrentTrack] = useState<Track | null>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isBuffering, setIsBuffering] = useState(false);
|
||||||
|
const [likedTracks, setLikedTracks] = useState<Set<string>>(new Set());
|
||||||
|
const [likedTracksData, setLikedTracksData] = useState<Track[]>([]);
|
||||||
|
|
||||||
|
// Audio Engine State
|
||||||
|
const [audioQuality, setAudioQuality] = useState<AudioQuality | null>(null);
|
||||||
|
const [preloadedBlobs, setPreloadedBlobs] = useState<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
// Queue State
|
||||||
|
const [queue, setQueue] = useState<Track[]>([]);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(-1);
|
||||||
|
const [shuffle, setShuffle] = useState(false);
|
||||||
|
const [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('none');
|
||||||
|
|
||||||
|
// History State
|
||||||
|
const [playHistory, setPlayHistory] = useState<Track[]>([]);
|
||||||
|
|
||||||
|
// Load Likes from DB
|
||||||
|
useEffect(() => {
|
||||||
|
dbService.getLikedSongs().then(tracks => {
|
||||||
|
setLikedTracks(new Set(tracks.map(t => t.id)));
|
||||||
|
setLikedTracksData(tracks);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load History from LocalStorage
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('playHistory');
|
||||||
|
if (saved) {
|
||||||
|
setPlayHistory(JSON.parse(saved));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load history", e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save History
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('playHistory', JSON.stringify(playHistory));
|
||||||
|
}, [playHistory]);
|
||||||
|
|
||||||
|
// Metadata & Preloading Effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentTrack) return;
|
||||||
|
|
||||||
|
// 1. Reset Quality
|
||||||
|
setAudioQuality(null);
|
||||||
|
|
||||||
|
// 2. Parse Metadata for Current Track
|
||||||
|
const parseMetadata = async () => {
|
||||||
|
try {
|
||||||
|
// Skip metadata parsing for backend streams AND external URLs (YouTube) to avoid CORS/Double-fetch
|
||||||
|
if (currentTrack.url && (currentTrack.url.includes('/api/stream') || currentTrack.url.startsWith('http'))) {
|
||||||
|
setAudioQuality({
|
||||||
|
format: 'WEBM/OPUS', // YT Music typically
|
||||||
|
sampleRate: 48000,
|
||||||
|
bitrate: 128000,
|
||||||
|
channels: 2,
|
||||||
|
codec: 'Opus'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTrack.url) {
|
||||||
|
// Note: In a real scenario, we might need a proxy or CORS-enabled server.
|
||||||
|
// music-metadata-browser fetches the file.
|
||||||
|
const metadata = await mm.fetchFromUrl(currentTrack.url);
|
||||||
|
setAudioQuality({
|
||||||
|
format: metadata.format.container || 'Unknown',
|
||||||
|
sampleRate: metadata.format.sampleRate || 44100,
|
||||||
|
bitDepth: metadata.format.bitsPerSample,
|
||||||
|
bitrate: metadata.format.bitrate || 0,
|
||||||
|
channels: metadata.format.numberOfChannels || 2,
|
||||||
|
codec: metadata.format.codec
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to parse metadata", e);
|
||||||
|
// Fallback mock if parsing fails (likely due to CORS on sample URL)
|
||||||
|
setAudioQuality({
|
||||||
|
format: 'MP3',
|
||||||
|
sampleRate: 44100,
|
||||||
|
bitrate: 320000,
|
||||||
|
channels: 2,
|
||||||
|
codec: 'MPEG-1 Layer 3'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
parseMetadata();
|
||||||
|
|
||||||
|
// 3. Smart Buffering (Preload Next 2 Tracks)
|
||||||
|
const preloadNext = async () => {
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
const index = queue.findIndex(t => t.id === currentTrack.id);
|
||||||
|
if (index === -1) return;
|
||||||
|
|
||||||
|
const nextTracks = queue.slice(index + 1, index + 3);
|
||||||
|
nextTracks.forEach(async (track) => {
|
||||||
|
if (!preloadedBlobs.has(track.id) && track.url) {
|
||||||
|
try {
|
||||||
|
// Construct the correct stream URL for preloading if it's external
|
||||||
|
const fetchUrl = track.url.startsWith('http') ? `/api/stream?id=${track.id}` : track.url;
|
||||||
|
|
||||||
|
const res = await fetch(fetchUrl);
|
||||||
|
if (!res.ok) throw new Error("Fetch failed");
|
||||||
|
const blob = await res.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
setPreloadedBlobs(prev => new Map(prev).set(track.id, blobUrl));
|
||||||
|
console.log(`Buffered ${track.title}`);
|
||||||
|
} catch (e) {
|
||||||
|
// console.warn(`Failed to buffer ${track.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
preloadNext();
|
||||||
|
|
||||||
|
}, [currentTrack, queue, preloadedBlobs]);
|
||||||
|
|
||||||
|
|
||||||
|
const playTrack = (track: Track, newQueue?: Track[]) => {
|
||||||
|
if (currentTrack?.id !== track.id) {
|
||||||
|
setIsBuffering(true);
|
||||||
|
|
||||||
|
// Add to History (prevent duplicates at top)
|
||||||
|
setPlayHistory(prev => {
|
||||||
|
const filtered = prev.filter(t => t.id !== track.id);
|
||||||
|
return [track, ...filtered].slice(0, 20); // Keep last 20
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setCurrentTrack(track);
|
||||||
|
setIsPlaying(true);
|
||||||
|
|
||||||
|
if (newQueue) {
|
||||||
|
setQueue(newQueue);
|
||||||
|
const index = newQueue.findIndex(t => t.id === track.id);
|
||||||
|
setCurrentIndex(index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
setIsPlaying((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextTrack = () => {
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
|
||||||
|
let nextIndex = currentIndex + 1;
|
||||||
|
if (shuffle) {
|
||||||
|
nextIndex = Math.floor(Math.random() * queue.length);
|
||||||
|
} else if (nextIndex >= queue.length) {
|
||||||
|
if (repeatMode === 'all') nextIndex = 0;
|
||||||
|
else return; // Stop if end of queue and no repeat
|
||||||
|
}
|
||||||
|
|
||||||
|
playTrack(queue[nextIndex]);
|
||||||
|
setCurrentIndex(nextIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevTrack = () => {
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
let prevIndex = currentIndex - 1;
|
||||||
|
if (prevIndex < 0) prevIndex = 0; // Or wrap around if desired
|
||||||
|
playTrack(queue[prevIndex]);
|
||||||
|
setCurrentIndex(prevIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleShuffle = () => setShuffle(prev => !prev);
|
||||||
|
|
||||||
|
const toggleRepeat = () => {
|
||||||
|
setRepeatMode(prev => {
|
||||||
|
if (prev === 'none') return 'all';
|
||||||
|
if (prev === 'all') return 'one';
|
||||||
|
return 'none';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setBuffering = (state: boolean) => setIsBuffering(state);
|
||||||
|
|
||||||
|
const toggleLike = async (track: Track) => {
|
||||||
|
const isNowLiked = await dbService.toggleLike(track);
|
||||||
|
|
||||||
|
setLikedTracks(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (isNowLiked) next.add(track.id);
|
||||||
|
else next.delete(track.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
setLikedTracksData(prev => {
|
||||||
|
if (!isNowLiked) {
|
||||||
|
return prev.filter(t => t.id !== track.id);
|
||||||
|
} else {
|
||||||
|
return [...prev, track];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const effectiveCurrentTrack = currentTrack ? {
|
||||||
|
...currentTrack,
|
||||||
|
// improved URL logic: usage of backend API if no local blob
|
||||||
|
url: preloadedBlobs.get(currentTrack.id) ||
|
||||||
|
(currentTrack.url && currentTrack.url.startsWith('/') ? currentTrack.url : `/api/stream?id=${currentTrack.id}`)
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerContext.Provider value={{
|
||||||
|
currentTrack: effectiveCurrentTrack,
|
||||||
|
isPlaying,
|
||||||
|
isBuffering,
|
||||||
|
likedTracks,
|
||||||
|
likedTracksData,
|
||||||
|
shuffle,
|
||||||
|
repeatMode,
|
||||||
|
playTrack,
|
||||||
|
togglePlay,
|
||||||
|
nextTrack,
|
||||||
|
prevTrack,
|
||||||
|
toggleShuffle,
|
||||||
|
toggleRepeat,
|
||||||
|
setBuffering,
|
||||||
|
toggleLike,
|
||||||
|
playHistory,
|
||||||
|
audioQuality
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</PlayerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayer() {
|
||||||
|
const context = useContext(PlayerContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("usePlayer must be used within a PlayerProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
4
frontend/env_template
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
SPOTIFY_CLIENT_ID=your_client_id_here
|
||||||
|
SPOTIFY_CLIENT_SECRET=your_client_secret_here
|
||||||
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
NEXTAUTH_SECRET=changeme_to_a_random_string_openssl_rand_base64_32
|
||||||
18
frontend/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
116
frontend/hooks/useLibraryData.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { dbService, Playlist } from '@/services/db';
|
||||||
|
import { libraryService } from '@/services/library';
|
||||||
|
|
||||||
|
export function useLibraryData() {
|
||||||
|
const [userPlaylists, setUserPlaylists] = useState<Playlist[]>([]);
|
||||||
|
const [libraryItems, setLibraryItems] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const fetchAllData = async () => {
|
||||||
|
try {
|
||||||
|
// 1. User Playlists
|
||||||
|
const playlists = await dbService.getPlaylists();
|
||||||
|
setUserPlaylists(playlists);
|
||||||
|
|
||||||
|
// 2. Local/Backend Content
|
||||||
|
const browse = await libraryService.getBrowseContent();
|
||||||
|
const browsePlaylists = Object.values(browse).flat();
|
||||||
|
|
||||||
|
const artistsMap = new Map();
|
||||||
|
const albumsMap = new Map();
|
||||||
|
const allTracks: any[] = [];
|
||||||
|
|
||||||
|
// 3. Extract metadata and flattening tracks
|
||||||
|
browsePlaylists.forEach((p: any) => {
|
||||||
|
if (p.tracks) {
|
||||||
|
p.tracks.forEach((t: any) => {
|
||||||
|
allTracks.push(t);
|
||||||
|
// Fake Artist
|
||||||
|
if (artistsMap.size < 40 && t.artist && t.artist !== 'Unknown Artist' && t.artist !== 'Unknown') {
|
||||||
|
if (!artistsMap.has(t.artist)) {
|
||||||
|
artistsMap.set(t.artist, {
|
||||||
|
id: `artist-${t.artist}`,
|
||||||
|
title: t.artist,
|
||||||
|
type: 'Artist',
|
||||||
|
cover_url: t.cover_url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fake Album
|
||||||
|
if (albumsMap.size < 40 && t.album && t.album !== 'Single' && t.album !== 'Unknown Album') {
|
||||||
|
if (!albumsMap.has(t.album)) {
|
||||||
|
albumsMap.set(t.album, {
|
||||||
|
id: `album-${t.album}`,
|
||||||
|
title: t.album,
|
||||||
|
type: 'Album',
|
||||||
|
creator: t.artist,
|
||||||
|
cover_url: t.cover_url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const creativeNames = [
|
||||||
|
"Chill Vibes", "Late Night Focus", "Workout Energy", "Road Trip Classics",
|
||||||
|
"Indie Mix", "Pop Hits", "Throwback Thursday", "Weekend Flow",
|
||||||
|
"Deep Focus", "Party Anthems", "Jazz & Blues", "Acoustic Sessions",
|
||||||
|
"Morning Coffee", "Rainy Day", "Sleep Sounds", "Gaming Beats",
|
||||||
|
"Coding Mode", "Summer Hits", "Winter Lo-Fi", "Discover Weekly",
|
||||||
|
"Release Radar", "On Repeat", "Time Capsule", "Viral 50",
|
||||||
|
"Global Top 50", "Trending Now", "Fresh Finds", "Audiobook Mode",
|
||||||
|
"Podcast Favorites", "Rock Classics", "Metal Essentials", "Hip Hop Gold",
|
||||||
|
"Electronic Dreams", "Ambient Spaces", "Classical Masterpieces", "Country Roads"
|
||||||
|
];
|
||||||
|
|
||||||
|
// 4. Generate Fake Extra Playlists if needed (Target ~40 total)
|
||||||
|
const fakePlaylists = [...browsePlaylists];
|
||||||
|
const targetCount = 40;
|
||||||
|
const needed = targetCount - fakePlaylists.length;
|
||||||
|
|
||||||
|
if (needed > 0 && allTracks.length > 0) {
|
||||||
|
// Shuffle utility
|
||||||
|
const shuffle = (array: any[]) => array.sort(() => 0.5 - Math.random());
|
||||||
|
|
||||||
|
for (let i = 0; i < needed; i++) {
|
||||||
|
const shuffled = shuffle([...allTracks]);
|
||||||
|
const selected = shuffled.slice(0, 8 + Math.floor(Math.random() * 12)); // 8-20 tracks
|
||||||
|
const cover = selected[0]?.cover_url;
|
||||||
|
|
||||||
|
const name = creativeNames[i] || `Daily Mix ${i + 1}`;
|
||||||
|
|
||||||
|
fakePlaylists.push({
|
||||||
|
id: `mix-${i}`,
|
||||||
|
title: name,
|
||||||
|
description: `Curated just for you • ${selected.length} songs`,
|
||||||
|
cover_url: cover,
|
||||||
|
tracks: selected,
|
||||||
|
type: 'Playlist'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe Playlists (just in case)
|
||||||
|
// const uniquePlaylists = Array.from(new Map(fakePlaylists.map(p => [p.id, p])).values());
|
||||||
|
|
||||||
|
const uniqueItems = [
|
||||||
|
...fakePlaylists.map(p => ({ ...p, type: 'Playlist' })),
|
||||||
|
...Array.from(artistsMap.values()),
|
||||||
|
...Array.from(albumsMap.values())
|
||||||
|
];
|
||||||
|
|
||||||
|
setLibraryItems(uniqueItems);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAllData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { userPlaylists, libraryItems, refresh: fetchAllData };
|
||||||
|
}
|
||||||
50
frontend/next.config.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
// strict mode true is default but good to be explicit
|
||||||
|
reactStrictMode: true,
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
// Backend API Proxies (Specific, so we don't block NextAuth at /api/auth)
|
||||||
|
{ source: '/api/browse/:path*', destination: 'http://127.0.0.1:8000/api/browse/:path*' },
|
||||||
|
{ source: '/api/playlists/:path*', destination: 'http://127.0.0.1:8000/api/playlists/:path*' },
|
||||||
|
{ source: '/api/search/:path*', destination: 'http://127.0.0.1:8000/api/search/:path*' },
|
||||||
|
{ source: '/api/stream/:path*', destination: 'http://127.0.0.1:8000/api/stream/:path*' },
|
||||||
|
{ source: '/api/download/:path*', destination: 'http://127.0.0.1:8000/api/download/:path*' },
|
||||||
|
{ source: '/api/download-status/:path*', destination: 'http://127.0.0.1:8000/api/download-status/:path*' },
|
||||||
|
{ source: '/api/lyrics/:path*', destination: 'http://127.0.0.1:8000/api/lyrics/:path*' },
|
||||||
|
{ source: '/api/trending/:path*', destination: 'http://127.0.0.1:8000/api/trending/:path*' },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'i.ytimg.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'lh3.googleusercontent.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'placehold.co',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'images.unsplash.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'misc.scdn.co',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6893
frontend/package-lock.json
generated
Normal file
31
frontend/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"idb": "^8.0.3",
|
||||||
|
"lucide-react": "^0.561.0",
|
||||||
|
"music-metadata-browser": "^2.5.11",
|
||||||
|
"next": "^14.2.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "^14.2.0",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
7
frontend/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
frontend/public/file.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1 KiB |
BIN
frontend/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
frontend/public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
548
frontend/public/library.json
Normal file
|
|
@ -0,0 +1,548 @@
|
||||||
|
{
|
||||||
|
"id": "PLpY7hx7jry7zc4zspi_fBhWQt8z5jrJ8z",
|
||||||
|
"title": "Best Vietnamese Songs 2025 - Popular Vietnamese Songs 2025 Playlist (Top Vietnamese Music 2025-2026)",
|
||||||
|
"description": "Best Vietnamese Songs 2025 - Popular Vietnamese Songs 2025 Playlist (Top Vietnamese Music 2025-2026)\n\nIf you liked this playlist, we recommend you also listen to these music lists:\n\n1. Most Popular Vietnamese Songs 2025 - Best of Vietnamese Music 2025 Playlist (Famous Vietnamese Songs 2025-2026) - https://goplaylists.com/56624\n2. New Vietnamese Songs 2025 - Latest Vietnamese Song 2025 Playlist (New Vietnam Music 2025-2026) - https://goplaylists.com/13081\n\nFind our playlist with these keywords: popular vietnamese songs 2025, top vietnamese songs 2025, best vietnamese music 2025, vietnam music playlist, top vietnamese music 2025, vietnam playlist 2025, vietnamese songs 2025, popular, vietnamese songs, vietnam playlist music, best vietnamese songs 2025, vietnamese playlist 2025, vietnamese hits 2025, vietnamese songs, top vietnam music 2025, vietnam song playlist, top 10 vietnamese songs, vietnam music chart 2025, vietnamese song trends\n\nDive deep into the mesmerizing world of Vietnamese music with a curated collection of the year's most compelling tracks. Experience the rhythm, the emotion, and the soulful voices of Vietnam's top artists. Each song has been handpicked to represent the heartbeat of Vietnam's contemporary music scene, bringing to you an array of melodies that resonate with the beauty and culture of this enchanting nation. Whether you're a longtime fan or a newcomer to Vietnamese tunes, this selection is bound to captivate your senses and take you on an unforgettable musical journey \ud83c\udfb5.\n\nIn the next year, the playlist is going to be titled: Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)\n Last year, the playlist was titled: Best Vietnamese Songs 2024 - Popular Vietnamese Songs 2024 Playlist (Top Vietnamese Music 2024-2025)\n\nShare your thoughts on our playlist: contact@red-music.com",
|
||||||
|
"cover_url": "https://yt3.googleusercontent.com/JaOpEjRt9S4wYkuVMkbu_2NLadP4vtUfQIpUlfob8mgB3CuoX8AsAJ24tAtbNRXGD2AkekLlbkiU=s1200",
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"title": "Kh\u00f3a Ly Bi\u1ec7t (feat. Anh T\u00fa)",
|
||||||
|
"artist": "The Masked Singer",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 327,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/wEPX3V5T63M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kG4sf2WSe1cO3TUeSG4cyGdPXJFg",
|
||||||
|
"id": "wEPX3V5T63M",
|
||||||
|
"url": "https://music.youtube.com/watch?v=wEPX3V5T63M"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "T\u1eebng Ng\u00e0y Y\u00eau Em",
|
||||||
|
"artist": "buitruonglinh",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 222,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/f-VsoLm4i5c/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3loMnKS_mCSDRyYu9wS_TYnA0NAgQ",
|
||||||
|
"id": "f-VsoLm4i5c",
|
||||||
|
"url": "https://music.youtube.com/watch?v=f-VsoLm4i5c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "M\u1ea5t K\u1ebft N\u1ed1i",
|
||||||
|
"artist": "D\u01b0\u01a1ng Domic",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 208,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/lRsaDQtYqAo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mvidflzqRlL9xdJeDUXZJg_UESRw",
|
||||||
|
"id": "lRsaDQtYqAo",
|
||||||
|
"url": "https://music.youtube.com/watch?v=lRsaDQtYqAo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "m\u1ed9t \u0111\u1eddi (feat. buitruonglinh)",
|
||||||
|
"artist": "Bon Nghi\u00eam, 14 Casper",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 329,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/JgTZvDbaTtg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lEKS8TNud8_GWknaWc0IQEQWBTgw",
|
||||||
|
"id": "JgTZvDbaTtg",
|
||||||
|
"url": "https://music.youtube.com/watch?v=JgTZvDbaTtg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "V\u00f9ng An To\u00e0n",
|
||||||
|
"artist": "V#, B Ray",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 266,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/_XX248bq6Pw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nKfVzeukJ8dCNJ_hzcyZAsvJ8upg",
|
||||||
|
"id": "_XX248bq6Pw",
|
||||||
|
"url": "https://music.youtube.com/watch?v=_XX248bq6Pw"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Qu\u00e2n A.P | C\u00f3 Ai H\u1eb9n H\u00f2 C\u00f9ng Em Ch\u01b0a",
|
||||||
|
"artist": "Qu\u00e2n A.P",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 319,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/zHDECJy0p7k/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nWDqcf0SVJtIipbWQqltt3cNu6eQ",
|
||||||
|
"id": "zHDECJy0p7k",
|
||||||
|
"url": "https://music.youtube.com/watch?v=zHDECJy0p7k"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "b\u00ecnh y\u00ean - V\u0169. (feat. Binz)",
|
||||||
|
"artist": "V\u0169., Binz",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 203,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/f9P7_qWrf38/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kI5gsa8Jegzzu2vFpJBhLk58mGeg",
|
||||||
|
"id": "f9P7_qWrf38",
|
||||||
|
"url": "https://music.youtube.com/watch?v=f9P7_qWrf38"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "n\u1ebfu l\u00fac \u0111\u00f3 (feat. 2pillz)",
|
||||||
|
"artist": "Tlinh",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 325,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/fyMgBQioTLo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kNXGGAK5wy2ix4mQ1pNwlGLYUg0Q",
|
||||||
|
"id": "fyMgBQioTLo",
|
||||||
|
"url": "https://music.youtube.com/watch?v=fyMgBQioTLo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "\u0110\u1eebng L\u00e0m Tr\u00e1i Tim Anh \u0110au",
|
||||||
|
"artist": "S\u01a1n T\u00f9ng M-TP",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 326,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/abPmZCZZrFA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nMzmdGlrfqmf8o9z-E5waTnqFXxA",
|
||||||
|
"id": "abPmZCZZrFA",
|
||||||
|
"url": "https://music.youtube.com/watch?v=abPmZCZZrFA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "N\u1ed7i \u0110au Gi\u1eefa H\u00f2a B\u00ecnh",
|
||||||
|
"artist": "H\u00f2a Minzy, Nguyen Van Chung",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 454,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/yHikkFeIHNA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mhpsSG0tDGSBKkAK1X81aY1nrfgg",
|
||||||
|
"id": "yHikkFeIHNA",
|
||||||
|
"url": "https://music.youtube.com/watch?v=yHikkFeIHNA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "10 Shots",
|
||||||
|
"artist": "Dax",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 233,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/0XMFwdfMQmQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3napt1cPSL4BTo7SSeDyrRUU7XF0Q",
|
||||||
|
"id": "0XMFwdfMQmQ",
|
||||||
|
"url": "https://music.youtube.com/watch?v=0XMFwdfMQmQ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Ch\u0103m Hoa",
|
||||||
|
"artist": "MONO",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 260,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/WCm2elbTEZQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kW5xCqL0V0Q9miffXVKmSRnn3S8A",
|
||||||
|
"id": "WCm2elbTEZQ",
|
||||||
|
"url": "https://music.youtube.com/watch?v=WCm2elbTEZQ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "id 072019",
|
||||||
|
"artist": "W/n",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 303,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/leJb3VhQCrg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nWBTudc9VK3UqnpCgc_j8QYH3ugg",
|
||||||
|
"id": "leJb3VhQCrg",
|
||||||
|
"url": "https://music.youtube.com/watch?v=leJb3VhQCrg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Gi\u1edd Th\u00ec",
|
||||||
|
"artist": "buitruonglinh",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 238,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/69ZDBWoj5YM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3krSRZmxC0XjjdQN0wwPoZbJ-unGQ",
|
||||||
|
"id": "69ZDBWoj5YM",
|
||||||
|
"url": "https://music.youtube.com/watch?v=69ZDBWoj5YM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "ERIK - 'D\u00f9 cho t\u1eadn th\u1ebf (v\u1eabn y\u00eau em)' | Official MV | Valentine 2025",
|
||||||
|
"artist": "ERIK",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 301,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/js6JBdLzNn4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nh_ITbZeDs1TJfrWuPEim8MKpj9g",
|
||||||
|
"id": "js6JBdLzNn4",
|
||||||
|
"url": "https://music.youtube.com/watch?v=js6JBdLzNn4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Ng\u00e0y Mai Ng\u01b0\u1eddi Ta L\u1ea5y Ch\u1ed3ng",
|
||||||
|
"artist": "Th\u00e0nh \u0110\u1ea1t",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 421,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/WL11bwvAYWI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3l10haMFB1HcY7p5muA1kJL5tz1cA",
|
||||||
|
"id": "WL11bwvAYWI",
|
||||||
|
"url": "https://music.youtube.com/watch?v=WL11bwvAYWI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "B\u1ea7u Tr\u1eddi M\u1edbi (feat. Minh Toc)",
|
||||||
|
"artist": "Da LAB",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 291,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/Z1D26z9l8y8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3k34PODWHnu_p49YHu35__8V-4avw",
|
||||||
|
"id": "Z1D26z9l8y8",
|
||||||
|
"url": "https://music.youtube.com/watch?v=Z1D26z9l8y8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "C\u00e1nh Hoa H\u00e9o T\u00e0n (DJ Trang Moon Remix)",
|
||||||
|
"artist": "ACV, Mochiii",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 265,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/YizrmzMvr7Q/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3luIG3PhCNjJlZjuCRBwAKKrMPt9Q",
|
||||||
|
"id": "YizrmzMvr7Q",
|
||||||
|
"url": "https://music.youtube.com/watch?v=YizrmzMvr7Q"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "SOOBIN - gi\u00e1 nh\u01b0 | 'B\u1eacT N\u00d3 L\u00caN' Album (Music Video)",
|
||||||
|
"artist": "SOOBIN",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 310,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/SeWt7IpZ0CA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lYIMR-uDbHo5-B3GO0z5XPqKIcaQ",
|
||||||
|
"id": "SeWt7IpZ0CA",
|
||||||
|
"url": "https://music.youtube.com/watch?v=SeWt7IpZ0CA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Vuon Hoa Con Ca",
|
||||||
|
"artist": "Olew",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 234,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/BFflHDlTeHw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nA_WhH_ZnanoXMGeQ-4d4hYSUbUg",
|
||||||
|
"id": "BFflHDlTeHw",
|
||||||
|
"url": "https://music.youtube.com/watch?v=BFflHDlTeHw"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "G\u1eb7p L\u1ea1i N\u0103m Ta 60",
|
||||||
|
"artist": "Orange",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 337,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/ZXNrz72k1ew/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kqqJWC4ogBjc4u12JzCHk2YMBKWA",
|
||||||
|
"id": "ZXNrz72k1ew",
|
||||||
|
"url": "https://music.youtube.com/watch?v=ZXNrz72k1ew"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "You're The Problem",
|
||||||
|
"artist": "Dax",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 230,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/-kA2ReyByZU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kg2w-R3-05ocVT0g03RyIQJ41F4Q",
|
||||||
|
"id": "-kA2ReyByZU",
|
||||||
|
"url": "https://music.youtube.com/watch?v=-kA2ReyByZU"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "SOOBIN - Dancing In The Dark | 'B\u1eacT N\u00d3 L\u00caN' Album",
|
||||||
|
"artist": "SOOBIN",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 279,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/OZmK0YuSmXU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mBF8aJUqrrJIQduCkE_BAGkeucDA",
|
||||||
|
"id": "OZmK0YuSmXU",
|
||||||
|
"url": "https://music.youtube.com/watch?v=OZmK0YuSmXU"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Lao T\u00e2m Kh\u1ed5 T\u1ee9",
|
||||||
|
"artist": "Thanh H\u01b0ng",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 348,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/TfKOFRpqSME/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n85vMTLZIA2MAj83vqnYk4pomt0Q",
|
||||||
|
"id": "TfKOFRpqSME",
|
||||||
|
"url": "https://music.youtube.com/watch?v=TfKOFRpqSME"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "N\u1ea5u \u0102n Cho Em (feat. PIALINH)",
|
||||||
|
"artist": "\u0110en",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 257,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/ukHK1GVyr0I/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nD2JOikDSq_cCeBaG-VH6LBYriJg",
|
||||||
|
"id": "ukHK1GVyr0I",
|
||||||
|
"url": "https://music.youtube.com/watch?v=ukHK1GVyr0I"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "T\u1ebft B\u00ecnh An Remix, Hana C\u1ea9m Ti\u00ean, \u0110\u1ea1i M\u00e8o | M\u1ed9t N\u0103m C\u0169 \u0110\u00e3 Qua C\u00f9ng Nhau \u0110\u00f3n N\u0103m M\u1edbi B\u00ecnh An Mu\u00f4n Nh\u00e0",
|
||||||
|
"artist": "BD Media Music",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 227,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/fMskPmI4tp0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3m8nsW6nX2B8BJf4gsU36uDsmDCgw",
|
||||||
|
"id": "fMskPmI4tp0",
|
||||||
|
"url": "https://music.youtube.com/watch?v=fMskPmI4tp0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "T\u1eebng L\u00e0",
|
||||||
|
"artist": "V\u0169 C\u00e1t T\u01b0\u1eddng",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 277,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/i4qZmKSFYvI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kLaE-0VAlEfGQRlKBACGiK0w0WDw",
|
||||||
|
"id": "i4qZmKSFYvI",
|
||||||
|
"url": "https://music.youtube.com/watch?v=i4qZmKSFYvI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "N\u01a1i Ph\u00e1o Hoa R\u1ef1c R\u1ee1 (feat. C\u1ea9m V\u00e2n)",
|
||||||
|
"artist": "Hua Kim Tuyen, Orange, Ho\u00e0ng D\u0169ng",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 310,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/BgUFNi5MvzE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mGOmc3dRUaQczZnhubm_nb8Gs_Uw",
|
||||||
|
"id": "BgUFNi5MvzE",
|
||||||
|
"url": "https://music.youtube.com/watch?v=BgUFNi5MvzE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Ng\u01b0\u1eddi B\u00ecnh Th\u01b0\u1eddng",
|
||||||
|
"artist": "V\u0169 C\u00e1t T\u01b0\u1eddng",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 285,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/X5KvHXWPYm4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lPWTFBiFDjAliZkS614MkwVcte1g",
|
||||||
|
"id": "X5KvHXWPYm4",
|
||||||
|
"url": "https://music.youtube.com/watch?v=X5KvHXWPYm4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "C\u00f3 Em L\u00e0 \u0110i\u1ec1u Tuy\u1ec7t V\u1eddi Nh\u1ea5t (Th\u1ecbnh H\u00e0nh)",
|
||||||
|
"artist": "Thi\u00ean T\u00fa",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 371,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/IenfKDtyMI0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nbyyByYoJQ2qV7-2w4S6Gyofj9dQ",
|
||||||
|
"id": "IenfKDtyMI0",
|
||||||
|
"url": "https://music.youtube.com/watch?v=IenfKDtyMI0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Nh\u1eefng L\u1eddi H\u1ee9a B\u1ecf Qu\u00ean",
|
||||||
|
"artist": "V\u0169., Dear Jane",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 259,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/h6RONxjPBf4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfvRCueWOo-OjD8_3sK9HSlhvoSw",
|
||||||
|
"id": "h6RONxjPBf4",
|
||||||
|
"url": "https://music.youtube.com/watch?v=h6RONxjPBf4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "m\u1ed9t b\u00e0i h\u00e1t kh\u00f4ng vui m\u1ea5y (Extended Version)",
|
||||||
|
"artist": "T.R.I, Dangrangto, DONAL",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 230,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/EvPEeSBfB3E/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kvhX3tBQICPMgOEn0R9uswYvdC5A",
|
||||||
|
"id": "EvPEeSBfB3E",
|
||||||
|
"url": "https://music.youtube.com/watch?v=EvPEeSBfB3E"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "One Time",
|
||||||
|
"artist": "Raw Dawg",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 119,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/ylh1oDhP2AE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lRAtyI5EucwyXxXGb9YLfFY2W6aQ",
|
||||||
|
"id": "ylh1oDhP2AE",
|
||||||
|
"url": "https://music.youtube.com/watch?v=ylh1oDhP2AE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "MIN - ch\u1eb3ng ph\u1ea3i t\u00ecnh \u0111\u1ea7u sao \u0111au \u0111\u1ebfn th\u1ebf feat. Dangrangto, Antransax (Official Audio)",
|
||||||
|
"artist": "MIN",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 284,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/rLNvDu59ffI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfhSKyeyGqrokp13H6G7C1rNvKLg",
|
||||||
|
"id": "rLNvDu59ffI",
|
||||||
|
"url": "https://music.youtube.com/watch?v=rLNvDu59ffI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Ng\u01b0\u1eddi \u0110\u1ea7u Ti\u00ean",
|
||||||
|
"artist": "Juky San, buitruonglinh",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 220,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/i54avTdUqwU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3klVzmY8CCpa5CLEP3BIema5Lhgyw",
|
||||||
|
"id": "i54avTdUqwU",
|
||||||
|
"url": "https://music.youtube.com/watch?v=i54avTdUqwU"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "MIN - ch\u1eb3ng ph\u1ea3i t\u00ecnh \u0111\u1ea7u sao \u0111au \u0111\u1ebfn th\u1ebf feat. Dangrangto, antransax (Official Visual Stage)",
|
||||||
|
"artist": "MIN OFFICIAL",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 288,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/s0OMNH-N5D8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3k_uFbHN8ud3zNWnb5hdzcYLhUgWA",
|
||||||
|
"id": "s0OMNH-N5D8",
|
||||||
|
"url": "https://music.youtube.com/watch?v=s0OMNH-N5D8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Em",
|
||||||
|
"artist": "Binz",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 208,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/CU2PtRKBkuw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mnGQ84aQvDmEMNCd5m6b-_pyKbUg",
|
||||||
|
"id": "CU2PtRKBkuw",
|
||||||
|
"url": "https://music.youtube.com/watch?v=CU2PtRKBkuw"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "HO\u1ea0 S\u0128 T\u1ed2I - TH\u00c1I H\u1eccC x \u0110\u1ea0T MAX | Official MV | Anh v\u1ebd c\u1ea7u v\u1ed3ng th\u00ec l\u1ea1i thi\u1ebfu n\u1eafng",
|
||||||
|
"artist": "Th\u00e1i H\u1ecdc",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 285,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/RF0jYdTXQK4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nAfOOBrWfNICLXduP5GPktAPARCg",
|
||||||
|
"id": "RF0jYdTXQK4",
|
||||||
|
"url": "https://music.youtube.com/watch?v=RF0jYdTXQK4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "T\u00ecnh Nh\u01b0 L\u00e1 Bay Xa (Live)",
|
||||||
|
"artist": "Jimmy Nguyen, M\u1ef8 T\u00c2M",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 273,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/gxPoI_tldfQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nQp0dsN1t1shvvjBq0A2m-EyfvBg",
|
||||||
|
"id": "gxPoI_tldfQ",
|
||||||
|
"url": "https://music.youtube.com/watch?v=gxPoI_tldfQ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Kh\u1ed5ng Minh x Ch\u00e2u Nhu\u1eadn Ph\u00e1t - ''E L\u00c0 \u0110\u00d4N CH\u1ec0'' Prod.@tiengaz",
|
||||||
|
"artist": "Dagoats House",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 191,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/K01LvulhFRg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n8fN2PiGDRGtGv0VZMp_OOW2kBoQ",
|
||||||
|
"id": "K01LvulhFRg",
|
||||||
|
"url": "https://music.youtube.com/watch?v=K01LvulhFRg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "M\u1ee5c H\u1ea1 V\u00f4 Nh\u00e2n (feat. Binz)",
|
||||||
|
"artist": "SOOBIN, Hu\u1ef3nh T\u00fa",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 355,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/FikdKWos-NQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lgfIwIcM4zdZGPxZN-kcs96iJyGQ",
|
||||||
|
"id": "FikdKWos-NQ",
|
||||||
|
"url": "https://music.youtube.com/watch?v=FikdKWos-NQ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "10 M\u1ea4T 1 C\u00d2N KH\u00d4NG - TH\u00c1I H\u1eccC x L\u00ca GIA B\u1ea2O (New Version) | St: Long H\u1ecd Hu\u1ef3nh",
|
||||||
|
"artist": "Th\u00e1i H\u1ecdc",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 285,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/9HnyyKg0M-Y/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lyCTROUhGaahuDenut3oMfnOesDQ",
|
||||||
|
"id": "9HnyyKg0M-Y",
|
||||||
|
"url": "https://music.youtube.com/watch?v=9HnyyKg0M-Y"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Mr Siro | Day D\u1ee9t N\u1ed7i \u0110au",
|
||||||
|
"artist": "Mr. Siro",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 368,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/N4Xak1n497M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nZ6HzRlVHFya6aliEsGSZuGB_QxA",
|
||||||
|
"id": "N4Xak1n497M",
|
||||||
|
"url": "https://music.youtube.com/watch?v=N4Xak1n497M"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Diary Of A Trying Man",
|
||||||
|
"artist": "Dax",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 234,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/WulTil-Wwoo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lC8LD69LfTh3hrClQoWJGA3pCjCw",
|
||||||
|
"id": "WulTil-Wwoo",
|
||||||
|
"url": "https://music.youtube.com/watch?v=WulTil-Wwoo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Feel At Home",
|
||||||
|
"artist": "B Ray",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 164,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/6x1yluqMuc4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfaiwiYPKbD_v3rvKR1QC1Sw9Znw",
|
||||||
|
"id": "6x1yluqMuc4",
|
||||||
|
"url": "https://music.youtube.com/watch?v=6x1yluqMuc4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "L\u00e1 Th\u01b0 \u0110\u00f4 Th\u1ecb",
|
||||||
|
"artist": "Th\u00fay H\u00e0",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 355,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/42m7T272u8I/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3moQljTHbKdPZ3c48rcbJiq4KILjQ",
|
||||||
|
"id": "42m7T272u8I",
|
||||||
|
"url": "https://music.youtube.com/watch?v=42m7T272u8I"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "R\u1eddi B\u1ecf N\u01a1i Anh Bi\u1ebft Em C\u00f3 Vui B\u00ean Ng\u01b0\u1eddi Remix | TH\u01af\u01a0NG TH\u00cc TH\u00d4I REMIX B\u1ea3n Si\u00eau Th\u1ea5m BeBe...",
|
||||||
|
"artist": "ACV",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 195,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/Hq_Q9vSIg2M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n3JlqdmwyqK_me1eqnMQVrNeL6ZA",
|
||||||
|
"id": "Hq_Q9vSIg2M",
|
||||||
|
"url": "https://music.youtube.com/watch?v=Hq_Q9vSIg2M"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Gi\u1eef Anh Cho Ng\u00e0y H\u00f4m Qua",
|
||||||
|
"artist": "Ho\u00e0ng D\u0169ng, Rhymastic, Lelarec",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 345,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/IADhKnmQMtk/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nzDVE9hb0vpWAYZ39Ghi-6BrG-9g",
|
||||||
|
"id": "IADhKnmQMtk",
|
||||||
|
"url": "https://music.youtube.com/watch?v=IADhKnmQMtk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Mr Siro | T\u1ef1 Lau N\u01b0\u1edbc M\u1eaft",
|
||||||
|
"artist": "Mr. Siro",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 330,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/tgvXGxCrBmE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mNQ5AIuHnGA4XZQwvFh_WRi1DmAg",
|
||||||
|
"id": "tgvXGxCrBmE",
|
||||||
|
"url": "https://music.youtube.com/watch?v=tgvXGxCrBmE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "She Never Cries (feat. S\u01a0N.K)",
|
||||||
|
"artist": "Ho\u00e0ng Duy\u00ean",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 264,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/zuBjkHOFVJs/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kdUzhae-xLYnaf7b45tXbDDxr71A",
|
||||||
|
"id": "zuBjkHOFVJs",
|
||||||
|
"url": "https://music.youtube.com/watch?v=zuBjkHOFVJs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Ch\u1edd Bao L\u00e2u (feat. H\u00e0o JK)",
|
||||||
|
"artist": "\u00dat Nh\u1ecb Mino",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 193,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/KO0CbNNvd14/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mL5syc6JwJoWuHasdnfhrSAFITHA",
|
||||||
|
"id": "KO0CbNNvd14",
|
||||||
|
"url": "https://music.youtube.com/watch?v=KO0CbNNvd14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "C\u00d4 G\u00c1I \u00c0 \u0110\u1eeaNG KH\u00d3C | \u00daT NH\u1eca MINO FT NVC MUSIC",
|
||||||
|
"artist": "\u00dat Nh\u1ecb Mino",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 266,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/oH9_c7Y5zMQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kBh2R8cqyDQN98Jd9CIO1RZBbVNQ",
|
||||||
|
"id": "oH9_c7Y5zMQ",
|
||||||
|
"url": "https://music.youtube.com/watch?v=oH9_c7Y5zMQ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "R\u1ea5t L\u00e2u R\u1ed3i M\u1edbi Kh\u00f3c (Solo Version)",
|
||||||
|
"artist": "",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 311,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/MWowv3A-fQc/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kWiKMUSyg-xYgdrzO26ICDnO6Gpg",
|
||||||
|
"id": "MWowv3A-fQc",
|
||||||
|
"url": "https://music.youtube.com/watch?v=MWowv3A-fQc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Ring Ring",
|
||||||
|
"artist": "MIRA",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 147,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/mkCaf6tuhGM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lyjmmeuudBzy9Yu64rGLaWENa4tg",
|
||||||
|
"id": "mkCaf6tuhGM",
|
||||||
|
"url": "https://music.youtube.com/watch?v=mkCaf6tuhGM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "B\u1ea3o Anh | C\u00f4 \u1ea4y C\u1ee7a Anh \u1ea4y",
|
||||||
|
"artist": "B\u1ea3o Anh ",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 324,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/RlTDbIutJsU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kTrRLvQFATZub9py_upYtU7SUaJQ",
|
||||||
|
"id": "RlTDbIutJsU",
|
||||||
|
"url": "https://music.youtube.com/watch?v=RlTDbIutJsU"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "\u0110\u1ecaA \u0110\u00c0NG REMIX - HO\u00c0NG OANH x ACV | N\u00f3i Anh Nghe \u0110\u1ecba \u0110\u00e0ng M\u1edf C\u1eeda L\u00e0 \u0110\u1ec3 Ch\u1edd B\u01b0\u1edbc Ch\u00e2n Em Ph\u1ea3i Kh\u00f4ng ?",
|
||||||
|
"artist": "ACV",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 311,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/vZzzcflS2HM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lMoxUQD5_wrptAPCqUYBxD0MKndw",
|
||||||
|
"id": "vZzzcflS2HM",
|
||||||
|
"url": "https://music.youtube.com/watch?v=vZzzcflS2HM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "T\u1eebng quen",
|
||||||
|
"artist": "itsnk, Wren Evans",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 175,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/zepHPnUDROE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kQphjp4tiW4vFcaXJBk1wMtsk9Kg",
|
||||||
|
"id": "zepHPnUDROE",
|
||||||
|
"url": "https://music.youtube.com/watch?v=zepHPnUDROE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "HOA B\u1ea4T T\u1eec",
|
||||||
|
"artist": "Th\u00e0nh \u0110\u1ea1t",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 317,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/n-k_aUsOaaQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lfd3LBuB7aTSG880J0HqdjEqNQww",
|
||||||
|
"id": "n-k_aUsOaaQ",
|
||||||
|
"url": "https://music.youtube.com/watch?v=n-k_aUsOaaQ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "N\u00f3i D\u1ed1i | Ronboogz (Lyrics Video)",
|
||||||
|
"artist": "Ronboogz",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 207,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/ri-TFS97Hbw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lgdDfcXHekuevzN7qPIZR7RryanQ",
|
||||||
|
"id": "ri-TFS97Hbw",
|
||||||
|
"url": "https://music.youtube.com/watch?v=ri-TFS97Hbw"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "MONO - \u2018Em Xinh\u2019 (Official Music Video)",
|
||||||
|
"artist": "MONO",
|
||||||
|
"album": "Single",
|
||||||
|
"duration": 197,
|
||||||
|
"cover_url": "https://i.ytimg.com/vi/rYc1UbgbMIY/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mkHo5h-7KAI9SGhk2jG6m6cHospQ",
|
||||||
|
"id": "rYc1UbgbMIY",
|
||||||
|
"url": "https://music.youtube.com/watch?v=rYc1UbgbMIY"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
20
frontend/public/manifest.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"name": "Spotify Clone",
|
||||||
|
"short_name": "Spotify",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#121212",
|
||||||
|
"theme_color": "#1DB954",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
frontend/public/music/sample.mp3
Normal file
BIN
frontend/public/music/song1.mp3
Normal file
BIN
frontend/public/music/song2.mp3
Normal file
1
frontend/public/next.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/vercel.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
frontend/public/window.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
172
frontend/services/db.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { openDB, DBSchema } from 'idb';
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
cover_url: string;
|
||||||
|
url?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Playlist {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
tracks: Track[];
|
||||||
|
createdAt: number;
|
||||||
|
cover_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MyDB extends DBSchema {
|
||||||
|
playlists: {
|
||||||
|
key: string;
|
||||||
|
value: Playlist;
|
||||||
|
};
|
||||||
|
likedSongs: {
|
||||||
|
key: string; // trackId
|
||||||
|
value: Track;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_NAME = 'audiophile-db';
|
||||||
|
const DB_VERSION = 2;
|
||||||
|
|
||||||
|
export const initDB = async () => {
|
||||||
|
return openDB<MyDB>(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(db, oldVersion, newVersion, transaction) {
|
||||||
|
// Re-create stores to clear old data
|
||||||
|
if (db.objectStoreNames.contains('playlists')) {
|
||||||
|
db.deleteObjectStore('playlists');
|
||||||
|
}
|
||||||
|
if (db.objectStoreNames.contains('likedSongs')) {
|
||||||
|
db.deleteObjectStore('likedSongs');
|
||||||
|
}
|
||||||
|
db.createObjectStore('playlists', { keyPath: 'id' });
|
||||||
|
db.createObjectStore('likedSongs', { keyPath: 'id' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dbService = {
|
||||||
|
async getPlaylists() {
|
||||||
|
const db = await initDB();
|
||||||
|
const playlists = await db.getAll('playlists');
|
||||||
|
if (playlists.length === 0) {
|
||||||
|
return this.seedInitialData();
|
||||||
|
}
|
||||||
|
return playlists;
|
||||||
|
},
|
||||||
|
|
||||||
|
async seedInitialData() {
|
||||||
|
try {
|
||||||
|
// Fetch real data from backend to seed valid playlists
|
||||||
|
// We use the 'api' prefix assuming this runs in browser
|
||||||
|
const res = await fetch('/api/trending');
|
||||||
|
if (!res.ok) return [];
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const allTracks: Track[] = data.tracks || [];
|
||||||
|
|
||||||
|
if (allTracks.length === 0) return [];
|
||||||
|
|
||||||
|
const db = await initDB();
|
||||||
|
const newPlaylists: Playlist[] = [];
|
||||||
|
|
||||||
|
// 1. Starter Playlist
|
||||||
|
const favTracks = allTracks.slice(0, 8);
|
||||||
|
if (favTracks.length > 0) {
|
||||||
|
const p1: Playlist = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: "My Rotations",
|
||||||
|
tracks: favTracks,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
cover_url: favTracks[0].cover_url
|
||||||
|
};
|
||||||
|
await db.put('playlists', p1);
|
||||||
|
newPlaylists.push(p1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Vibes
|
||||||
|
const vibeTracks = allTracks.slice(8, 15);
|
||||||
|
if (vibeTracks.length > 0) {
|
||||||
|
const p2: Playlist = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: "Weekend Vibes",
|
||||||
|
tracks: vibeTracks,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
cover_url: vibeTracks[0].cover_url
|
||||||
|
};
|
||||||
|
await db.put('playlists', p2);
|
||||||
|
newPlaylists.push(p2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPlaylists;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Seeding failed", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPlaylist(id: string) {
|
||||||
|
const db = await initDB();
|
||||||
|
return db.get('playlists', id);
|
||||||
|
},
|
||||||
|
async createPlaylist(name: string) {
|
||||||
|
const db = await initDB();
|
||||||
|
const newPlaylist: Playlist = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: name,
|
||||||
|
tracks: [],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
cover_url: "https://placehold.co/300/222/fff?text=" + encodeURIComponent(name)
|
||||||
|
};
|
||||||
|
await db.put('playlists', newPlaylist);
|
||||||
|
return newPlaylist;
|
||||||
|
},
|
||||||
|
async deletePlaylist(id: string) {
|
||||||
|
const db = await initDB();
|
||||||
|
await db.delete('playlists', id);
|
||||||
|
},
|
||||||
|
async addToPlaylist(playlistId: string, track: Track) {
|
||||||
|
const db = await initDB();
|
||||||
|
const playlist = await db.get('playlists', playlistId);
|
||||||
|
if (playlist) {
|
||||||
|
// Auto-update cover if it's the default or empty
|
||||||
|
if (playlist.tracks.length === 0 || playlist.cover_url?.includes("placehold")) {
|
||||||
|
playlist.cover_url = track.cover_url;
|
||||||
|
}
|
||||||
|
playlist.tracks.push(track);
|
||||||
|
await db.put('playlists', playlist);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async removeFromPlaylist(playlistId: string, trackId: string) {
|
||||||
|
const db = await initDB();
|
||||||
|
const playlist = await db.get('playlists', playlistId);
|
||||||
|
if (playlist) {
|
||||||
|
playlist.tracks = playlist.tracks.filter(t => t.id !== trackId);
|
||||||
|
await db.put('playlists', playlist);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getLikedSongs() {
|
||||||
|
const db = await initDB();
|
||||||
|
return db.getAll('likedSongs');
|
||||||
|
},
|
||||||
|
async toggleLike(track: Track) {
|
||||||
|
const db = await initDB();
|
||||||
|
const existing = await db.get('likedSongs', track.id);
|
||||||
|
if (existing) {
|
||||||
|
await db.delete('likedSongs', track.id);
|
||||||
|
return false; // unliked
|
||||||
|
} else {
|
||||||
|
await db.put('likedSongs', track);
|
||||||
|
return true; // liked
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async isLiked(trackId: string) {
|
||||||
|
const db = await initDB();
|
||||||
|
const existing = await db.get('likedSongs', trackId);
|
||||||
|
return !!existing;
|
||||||
|
}
|
||||||
|
};
|
||||||
76
frontend/services/library.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { Track } from "./db";
|
||||||
|
|
||||||
|
export interface StaticPlaylist {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
cover_url: string;
|
||||||
|
tracks: Track[];
|
||||||
|
type: 'Album' | 'Artist' | 'Playlist';
|
||||||
|
creator?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to fetch from backend
|
||||||
|
const apiFetch = async (endpoint: string) => {
|
||||||
|
const res = await fetch(`/api${endpoint}`);
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const libraryService = {
|
||||||
|
async getLibrary(): Promise<StaticPlaylist> {
|
||||||
|
// Fetch "Liked Songs" or main library from backend
|
||||||
|
// Assuming backend has an endpoint or we treat "Trending" as default
|
||||||
|
return await apiFetch('/browse'); // Simplified fallback
|
||||||
|
},
|
||||||
|
|
||||||
|
async _generateMockContent(): Promise<void> {
|
||||||
|
// No-op in API mode
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBrowseContent(): Promise<Record<string, StaticPlaylist[]>> {
|
||||||
|
return await apiFetch('/browse');
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPlaylist(id: string): Promise<StaticPlaylist | null> {
|
||||||
|
try {
|
||||||
|
return await apiFetch(`/playlists/${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch playlist", id, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRecommendations(seedTrackId?: string): Promise<Track[]> {
|
||||||
|
// Use trending as recommendations for now
|
||||||
|
const data = await apiFetch('/trending');
|
||||||
|
return data.tracks || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRecommendedAlbums(seedArtist?: string): Promise<StaticPlaylist[]> {
|
||||||
|
const data = await apiFetch('/browse');
|
||||||
|
// Flatten all albums from categories
|
||||||
|
const albums: StaticPlaylist[] = [];
|
||||||
|
Object.values(data).forEach((list: any) => {
|
||||||
|
if (Array.isArray(list)) albums.push(...list);
|
||||||
|
});
|
||||||
|
return albums.slice(0, 8);
|
||||||
|
},
|
||||||
|
|
||||||
|
async search(query: string): Promise<Track[]> {
|
||||||
|
try {
|
||||||
|
return await apiFetch(`/search?q=${encodeURIComponent(query)}`);
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// UTILITIES FOR DYNAMIC UPDATES
|
||||||
|
updateTrackCover(trackId: string, newUrl: string) {
|
||||||
|
console.log("Dynamic updates not implemented in Backend Mode");
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAlbumCover(albumId: string, newUrl: string) {
|
||||||
|
console.log("Dynamic updates not implemented in Backend Mode");
|
||||||
|
}
|
||||||
|
};
|
||||||
61
frontend/services/library_backup.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { Track } from "./db";
|
||||||
|
|
||||||
|
export interface StaticPlaylist {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
cover_url: string;
|
||||||
|
tracks: Track[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const libraryService = {
|
||||||
|
async getLibrary(): Promise<StaticPlaylist> {
|
||||||
|
const res = await fetch('/library.json');
|
||||||
|
if (!res.ok) throw new Error("Failed to load library");
|
||||||
|
const data = await res.json();
|
||||||
|
// MOCK: Replace URLs with a working sample for testing Audiophile features
|
||||||
|
// In a real local-first app, these would be relative paths to local files or S3 presigned URLs.
|
||||||
|
data.tracks = data.tracks.map((t: any) => ({
|
||||||
|
...t,
|
||||||
|
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
|
||||||
|
}));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBrowseContent(): Promise<Record<string, StaticPlaylist[]>> {
|
||||||
|
const data = await this.getLibrary();
|
||||||
|
// Mock categories using the single playlist we have
|
||||||
|
return {
|
||||||
|
"Top Lists": [data],
|
||||||
|
"Just For You": [data],
|
||||||
|
"Trending": [data]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPlaylist(id: string): Promise<StaticPlaylist | null> {
|
||||||
|
const data = await this.getLibrary();
|
||||||
|
if (data.id === id) return data;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRecommendations(seedTrackId?: string): Promise<Track[]> {
|
||||||
|
const data = await this.getLibrary();
|
||||||
|
// Return random 10 tracks
|
||||||
|
return [...data.tracks].sort(() => 0.5 - Math.random()).slice(0, 10);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRecommendedAlbums(seedArtist?: string): Promise<StaticPlaylist[]> {
|
||||||
|
const data = await this.getLibrary();
|
||||||
|
// Return the main playlist as a recommended album for now
|
||||||
|
return [data];
|
||||||
|
},
|
||||||
|
|
||||||
|
async search(query: string): Promise<Track[]> {
|
||||||
|
const data = await this.getLibrary();
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
return data.tracks.filter(t =>
|
||||||
|
t.title.toLowerCase().includes(lowerQuery) ||
|
||||||
|
t.artist.toLowerCase().includes(lowerQuery)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
32
frontend/tailwind.config.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: "var(--background)",
|
||||||
|
foreground: "var(--foreground)",
|
||||||
|
spotify: {
|
||||||
|
base: "#1DB954",
|
||||||
|
highlight: "#1ed760",
|
||||||
|
black: "#191414",
|
||||||
|
dark: "#121212",
|
||||||
|
text: {
|
||||||
|
base: "#FFFFFF",
|
||||||
|
muted: "#b3b3b3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['var(--font-outfit)', 'sans-serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
42
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
21
test_audio.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import yt_dlp
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Test video ID from our data (e.g., Khóa Ly Biệt)
|
||||||
|
video_id = "s0OMNH-N5D8"
|
||||||
|
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||||
|
|
||||||
|
ydl_opts = {
|
||||||
|
'format': 'bestaudio/best',
|
||||||
|
'quiet': True,
|
||||||
|
'noplaylist': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(url, download=False)
|
||||||
|
print(f"Title: {info.get('title')}")
|
||||||
|
print(f"URL: {info.get('url')}") # The direct stream URL
|
||||||
|
print("Success: Extracted audio URL")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||