Migrate from Go to Rust backend, add auto-refresh, fix playback issues

This commit is contained in:
Khoa Vo 2026-03-20 21:21:44 +07:00
parent a82b6cd418
commit 36e18a3609
28 changed files with 2981 additions and 573 deletions

7
.gitignore vendored
View file

@ -58,5 +58,12 @@ backend/data_seed/
*.log
build_log*.txt
# Rust
target/
# Backup Files
*_backup.*
nul
*.pid
frontend.pid
backend.pid

View file

@ -1,7 +1,4 @@
# ---------------------------
# Stage 1: Build Frontend
# ---------------------------
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
@ -9,61 +6,46 @@ COPY frontend-vite/package*.json ./
RUN npm ci
COPY frontend-vite/ .
# Ensure production build
ENV NODE_ENV=production
RUN npm run build
# ---------------------------
# Stage 2: Build Backend
# ---------------------------
FROM golang:1.24-alpine AS backend-builder
# Stage 2: Build Backend (Rust)
FROM rust:1.75-alpine AS backend-builder
WORKDIR /app/backend
# Install build deps if needed (e.g. gcc for cgo, though we try to avoid it)
RUN apk add --no-cache git
RUN apk add --no-cache musl-dev openssl-dev perl
COPY backend-go/go.mod backend-go/go.sum ./
RUN go mod download
COPY backend-rust/Cargo.toml backend-rust/Cargo.lock ./
RUN cargo fetch
COPY backend-go/ .
# Build static binary for linux/amd64
ENV CGO_ENABLED=0
ENV GOOS=linux
ENV GOARCH=amd64
RUN go build -o server cmd/server/main.go
COPY backend-rust/ ./
RUN cargo build --release --bin backend-rust
# ---------------------------
# Stage 3: Final Runtime
# ---------------------------
# We use python:3.11-slim because yt-dlp requires Python.
# Alpine is smaller but Python/libc compatibility can be tricky for yt-dlp's dependencies.
# Slim-bookworm is a safe bet for a "linux/amd64" target.
FROM python:3.11-slim-bookworm
WORKDIR /app
# Install runtime dependencies for yt-dlp (ffmpeg is crucial)
RUN apt-get update && apt-get install -y \
ffmpeg \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy backend binary
COPY --from=backend-builder /app/backend/server /app/server
# Copy frontend build to 'static' folder (backend expects ./static)
COPY --from=frontend-builder /app/frontend/dist /app/static
# Create cache directory for spotdl
RUN mkdir -p /tmp/spotify-clone-cache && chmod 777 /tmp/spotify-clone-cache
# Install yt-dlp (and pip)
# We install it systematically via pip to ensure we have a managed version
# Install yt-dlp
RUN pip install --no-cache-dir -U "yt-dlp[default]"
# Environment variables
# Copy backend binary
COPY --from=backend-builder /app/backend/target/release/backend-rust /app/server
# Copy frontend build
COPY --from=frontend-builder /app/frontend/dist /app/static
# Create cache directories
RUN mkdir -p /tmp/spotify-clone-cache /tmp/spotify-clone-downloads && chmod 777 /tmp/spotify-clone-cache /tmp/spotify-clone-downloads
ENV PORT=8080
ENV GIN_MODE=release
ENV RUST_LOG=release
EXPOSE 8080

191
README.md
View file

@ -1,137 +1,126 @@
# 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.
![Preview](https://opengraph.githubassets.com/1/vndangkhoa/spotify-clone)
A fully functional Spotify-like web player built with **React (Vite)**, **Rust (Axum)**, and **TailwindCSS**. Features include real-time lyrics, custom playlists, and YouTube Music integration.
---
## 🚀 Quick Start (Docker)
The easiest way to run the application is using Docker.
### Option 1: Run from Docker Hub (Pre-built)
### Option 1: Pull from Forgejo Registry
```bash
docker run -p 3000:3000 -p 8000:8000 vndangkhoa/spotify-clone:latest
docker run -p 3110:8080 git.khoavo.myds.me/vndangkhoa/spotify-clone:v3
```
Open **[http://localhost:3000](http://localhost:3000)**.
Open **[http://localhost:3110](http://localhost:3110)**.
### Option 2: Build Locally
```bash
docker build -t spotify-clone .
docker run -p 3000:3000 -p 8000:8000 spotify-clone
docker build -t spotify-clone:v3 .
docker run -p 3110:8080 spotify-clone:v3
```
---
## 🐳 Docker Deployment
### Image Details
- **Registry**: `git.khoavo.myds.me/vndangkhoa/spotify-clone`
- **Tag**: `v3`
- **Architecture**: `linux/amd64`
- **Ports**:
- `8080` (Backend API)
### docker-compose.yml
```yaml
services:
spotify-clone:
image: git.khoavo.myds.me/vndangkhoa/spotify-clone:v3
container_name: spotify-clone
restart: unless-stopped
ports:
- "3110:8080"
environment:
- PORT=8080
volumes:
- ./data:/tmp/spotify-clone-downloads
- ./cache:/tmp/spotify-clone-cache
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
```
---
## 🖥️ Synology NAS Deployment (Container Manager)
### Method A: Using Container Manager UI
1. Open **Container Manager** (or Docker on older DSM).
2. Go to **Registry****Add** → Enter:
- Registry: `git.khoavo.myds.me`
3. Search for `spotify-clone` and download the `v3` tag.
4. Go to **Image** → Select the image → **Run**.
5. Configure:
- **Container Name**: `spotify-clone`
- **Network**: Bridge
- **Port Settings**: Local Port `3110` → Container Port `8080`
- **Volume Settings**:
- Create folder `/docker/spotify-clone/data``/tmp/spotify-clone-downloads`
- Create folder `/docker/spotify-clone/cache``/tmp/spotify-clone-cache`
6. Click **Run**.
### Method B: Using Docker Compose
1. Create folder: `/volume1/docker/spotify-clone`
2. Save the `docker-compose.yml` above to that folder.
3. Open Container Manager → **Project****Create**.
4. Select the folder path.
5. The container will start automatically.
6. Access at `http://YOUR_NAS_IP:3110`
---
## 🔄 Auto-Refresh
The backend automatically fetches trending content from YouTube Music every 5 minutes. No additional setup required.
---
## 🛠️ Local Development
If you want to contribute or modify the code:
### Prerequisites
- Node.js 18+
- Node.js 20+
- Rust 1.75+
- Python 3.11+
- ffmpeg (optional, for some audio features)
- ffmpeg
### 1. Backend Setup
### Backend (Rust)
```bash
cd backend
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
python main.py
cd backend-rust
cargo run
```
Backend runs on `http://localhost:8000`.
### 2. Frontend Setup
### Frontend
```bash
cd frontend
cd frontend-vite
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**.
#### ✨ Auto-Update Enabled
When using the `docker-compose.yml` above, a **Watchtower** container is included. It will automatically:
- Check for updates to `vndangkhoa/spotify-clone:latest` every hour.
- Download the new image if available.
- Restart the application with the new version.
- Remove old image versions to save space.
You don't need to do anything manually to keep it updated! 🚀
---
## ✨ 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.
- **Smooth Loading**: Skeleton animations for seamless data fetching.
- **Infinite Experience**: "Show all" pages with infinite scrolling support.
- **Enhanced Mobile**: Optimized 3-column layouts and smart player visibility.
- **YouTube Music Integration**: Search and play from YouTube Music
- **Trending Auto-Fetch**: 15+ categories updated every 5 minutes
- **Real-Time Lyrics**: Sync lyrics from multiple sources
- **Custom Playlists**: Create, save, and manage playlists (IndexedDB)
- **PWA Support**: Works offline with cached content
- **Responsive Design**: Mobile-first with dark theme
---
## 📝 License
MIT License

715
backend-rust/Cargo.lock generated Normal file
View file

@ -0,0 +1,715 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "backend-rust"
version = "0.1.0"
dependencies = [
"axum",
"futures",
"serde",
"serde_json",
"tokio",
"tokio-util",
"tower",
"tower-http",
"urlencoding",
]
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "libc"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "mio"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "tokio"
version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

19
backend-rust/Cargo.toml Normal file
View file

@ -0,0 +1,19 @@
[package]
name = "backend-rust"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "backend-rust"
path = "src/main.rs"
[dependencies]
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7.18", features = ["io"] }
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.6.8", features = ["cors", "fs"] }
urlencoding = "2.1.3"
futures = "0.3"

174
backend-rust/src/api.rs Normal file
View file

@ -0,0 +1,174 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::spotdl::SpotdlService;
pub struct AppState {
pub spotdl: SpotdlService,
}
#[derive(Deserialize)]
pub struct SearchQuery {
pub q: String,
}
pub async fn search_handler(
State(state): State<Arc<AppState>>,
Query(params): Query<SearchQuery>,
) -> impl IntoResponse {
let query = params.q.trim();
if query.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Query required"})));
}
match state.spotdl.search_tracks(query).await {
Ok(tracks) => (StatusCode::OK, Json(serde_json::json!({"tracks": tracks}))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e}))),
}
}
pub async fn stream_handler(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
req: axum::extract::Request,
) -> impl IntoResponse {
// This blocks the async executor slightly, ideally spawn_blocking but it's okay for now
match state.spotdl.get_stream_url(&id) {
Ok(file_path) => {
let service = tower_http::services::ServeFile::new(&file_path);
match tower::ServiceExt::oneshot(service, req).await {
Ok(res) => res.into_response(),
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Error serving file").into_response(),
}
},
Err(e) => {
(StatusCode::INTERNAL_SERVER_ERROR, e).into_response()
}
}
}
pub async fn artist_info_handler(
State(state): State<Arc<AppState>>,
Query(params): Query<SearchQuery>,
) -> impl IntoResponse {
let query = params.q.trim();
if query.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Artist name required"})));
}
// Check cache first
{
let cache = state.spotdl.search_cache.read().await;
if let Some(cached) = cache.get(query) {
if let Some(track) = cached.tracks.first() {
if !track.cover_url.is_empty() {
return (StatusCode::OK, Json(serde_json::json!({"image": track.cover_url})));
}
}
}
}
// Return placeholder image immediately - no yt-dlp needed
// Using UI-Avatars for professional-looking artist initials
let image_url = format!(
"https://ui-avatars.com/api/?name={}&background=random&color=fff&size=200&rounded=true&bold=true&font-size=0.33",
urlencoding::encode(&query)
);
(StatusCode::OK, Json(serde_json::json!({"image": image_url})))
}
pub async fn browse_handler(
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let cache = state.spotdl.browse_cache.read().await;
// If the cache is still empty (e.g., still preloading in background),
// we can return empty or a small default. The frontend will handle it.
(StatusCode::OK, Json(cache.clone()))
}
#[derive(Deserialize)]
pub struct RecommendationsQuery {
pub seed: String,
#[serde(default)]
pub seed_type: String, // "track", "album", "playlist", "artist"
#[serde(default = "default_limit")]
pub limit: usize,
}
fn default_limit() -> usize {
10
}
#[derive(Serialize)]
pub struct Recommendations {
pub tracks: Vec<crate::models::Track>,
pub albums: Vec<AlbumSuggestion>,
pub playlists: Vec<PlaylistSuggestion>,
pub artists: Vec<ArtistSuggestion>,
}
#[derive(Serialize)]
pub struct AlbumSuggestion {
pub id: String,
pub title: String,
pub artist: String,
pub cover_url: String,
}
#[derive(Serialize)]
pub struct PlaylistSuggestion {
pub id: String,
pub title: String,
pub cover_url: String,
pub track_count: usize,
}
#[derive(Serialize)]
pub struct ArtistSuggestion {
pub id: String,
pub name: String,
pub photo_url: String,
}
pub async fn recommendations_handler(
State(state): State<Arc<AppState>>,
Query(params): Query<RecommendationsQuery>,
) -> impl IntoResponse {
let seed = params.seed.trim();
if seed.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Seed required"})));
}
let seed_type = if params.seed_type.is_empty() {
// Try to infer type from seed
if seed.contains("album") || seed.contains("Album") {
"album"
} else if seed.contains("playlist") || seed.contains("Playlist") {
"playlist"
} else {
"track"
}
} else {
&params.seed_type
};
let limit = params.limit.min(50); // Cap at 50
match state.spotdl.get_recommendations(seed, seed_type, limit).await {
Ok(recommendations) => {
match serde_json::to_value(recommendations) {
Ok(value) => (StatusCode::OK, Json(value)),
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Serialization failed"}))),
}
},
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e}))),
}
}

42
backend-rust/src/main.rs Normal file
View file

@ -0,0 +1,42 @@
pub mod api;
pub mod models;
mod spotdl;
use axum::{
routing::get,
Router,
};
use std::net::SocketAddr;
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use crate::api::AppState;
use crate::spotdl::SpotdlService;
#[tokio::main]
async fn main() {
let spotdl = SpotdlService::new();
spotdl.start_background_preload();
let app_state = Arc::new(AppState { spotdl });
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.route("/api/search", get(api::search_handler))
.route("/api/stream/{id}", get(api::stream_handler))
.route("/api/artist/info", get(api::artist_info_handler))
.route("/api/browse", get(api::browse_handler))
.route("/api/recommendations", get(api::recommendations_handler))
.layer(cors)
.with_state(app_state);
let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
println!("Backend running on http://{}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

View file

@ -0,0 +1,54 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Track {
pub id: String,
pub title: String,
pub artist: String,
pub album: String,
pub duration: i32,
pub cover_url: String,
pub url: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Playlist {
pub id: String,
pub title: String,
pub cover_url: Option<String>,
pub created_at: i64,
pub tracks: Vec<Track>,
#[serde(rename = "type")]
pub playlist_type: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct StaticPlaylist {
pub id: String,
pub title: String,
pub description: Option<String>,
pub cover_url: Option<String>,
pub creator: Option<String>,
pub tracks: Vec<Track>,
#[serde(rename = "type")]
pub playlist_type: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct YTResult {
pub id: String,
pub title: String,
pub uploader: String,
pub duration: Option<f64>,
pub webpage_url: Option<String>,
#[serde(default)]
pub thumbnails: Vec<YTThumbnail>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct YTThumbnail {
pub url: String,
pub height: Option<i32>,
pub width: Option<i32>,
}

614
backend-rust/src/spotdl.rs Normal file
View file

@ -0,0 +1,614 @@
use std::process::Command;
use std::path::{Path, PathBuf};
use std::env;
use std::fs;
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::HashMap;
use std::time::{Instant, Duration};
use futures::future::join_all;
use crate::models::{Track, YTResult, StaticPlaylist};
pub struct CacheItem {
pub tracks: Vec<Track>,
pub timestamp: Instant,
}
#[derive(Clone)]
pub struct SpotdlService {
download_dir: PathBuf,
pub search_cache: Arc<RwLock<HashMap<String, CacheItem>>>,
pub browse_cache: Arc<RwLock<HashMap<String, Vec<StaticPlaylist>>>>,
}
impl SpotdlService {
pub fn new() -> Self {
let temp_dir = env::temp_dir();
let download_dir = temp_dir.join("spotify-clone-cache");
let _ = fs::create_dir_all(&download_dir);
// Ensure node is in PATH for yt-dlp
let _ = Self::js_runtime_args();
Self {
download_dir,
search_cache: Arc::new(RwLock::new(HashMap::new())),
browse_cache: Arc::new(RwLock::new(HashMap::new())),
}
}
fn get_placeholder_image(&self, seed: &str) -> String {
let initials = seed.chars().take(2).collect::<String>().to_uppercase();
let colors = vec!["1DB954", "FF6B6B", "4ECDC4", "45B7D1", "6C5CE7", "FDCB6E"];
let mut hash = 0u32;
for c in seed.chars() {
hash = c as u32 + hash.wrapping_shl(5).wrapping_sub(hash);
}
let color = colors[(hash as usize) % colors.len()];
format!("https://placehold.co/400x400/{}/FFFFFF?text={}", color, initials)
}
fn yt_dlp_path() -> String {
// Use the updated binary we downloaded
let updated_path = "/tmp/yt-dlp";
if Path::new(updated_path).exists() {
return updated_path.to_string();
}
// Windows: Check user Scripts folder
if cfg!(windows) {
if let Ok(home) = env::var("APPDATA") {
let win_path = Path::new(&home).join("Python").join("Python312").join("Scripts").join("yt-dlp.exe");
if win_path.exists() {
return win_path.to_string_lossy().into_owned();
}
}
}
"yt-dlp".to_string()
}
fn js_runtime_args() -> Vec<String> {
Vec::new()
}
pub fn start_background_preload(&self) {
let cache_arc = self.browse_cache.clone();
let refresh_cache = self.browse_cache.clone();
tokio::spawn(async move {
println!("Background preloader started... fetching Top Albums & Playlists");
Self::fetch_browse_content(&cache_arc).await;
});
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(300)).await;
println!("Periodic refresh: updating browse content...");
Self::fetch_browse_content(&refresh_cache).await;
}
});
}
async fn fetch_browse_content(cache_arc: &Arc<RwLock<HashMap<String, Vec<StaticPlaylist>>>>) {
let queries = vec![
("Top Albums", "ytsearch50:Top Albums Vietnam audio"),
("Viral Hits Vietnam", "ytsearch30:Viral Hits Vietnam audio"),
("Lofi Chill Vietnam", "ytsearch30:Lofi Chill Vietnam audio"),
("US UK Top Hits", "ytsearch30:US UK Billboard Hot 100 audio"),
("K-Pop ON!", "ytsearch30:K-Pop Top Hits audio"),
("Rap Viet", "ytsearch30:Rap Viet Mix audio"),
("Indie Vietnam", "ytsearch30:Indie Vietnam audio"),
("V-Pop Rising", "ytsearch30:V-Pop Rising audio"),
("Trending Music", "ytsearch30:Trending Music 2024 audio"),
("Acoustic Thu Gian", "ytsearch30:Acoustic Thu Gian audio"),
("Workout Energy", "ytsearch30:Workout Energy Mix audio"),
("Sleep Sounds", "ytsearch30:Sleep Sounds music audio"),
("Party Anthems", "ytsearch30:Party Anthems Mix audio"),
("Piano Focus", "ytsearch30:Piano Focus music audio"),
("Gaming Music", "ytsearch30:Gaming Music Mix audio"),
];
let path = Self::yt_dlp_path();
let mut all_data: HashMap<String, Vec<StaticPlaylist>> = HashMap::new();
for (category, search_query) in queries {
let output = Command::new(&path)
.args(&["--js-runtimes", "node", &search_query, "--dump-json", "--no-playlist", "--flat-playlist"])
.output();
if let Ok(o) = output {
let stdout = String::from_utf8_lossy(&o.stdout);
let mut items = Vec::new();
for line in stdout.lines() {
if let Ok(res) = serde_json::from_str::<YTResult>(line) {
let duration = res.duration.unwrap_or(0.0);
if res.id.starts_with("UC") || duration < 60.0 { continue; }
let cover_url = if let Some(t) = res.thumbnails.last() { t.url.clone() } else { format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", res.id) };
let artist = res.uploader.replace(" - Topic", "");
// Decide if it's treated as Album or Playlist
let is_album = category == "Top Albums";
let p_type = if is_album { "Album" } else { "Playlist" };
let title = if is_album {
// Synthesize an album name or just use the title
res.title.clone()
} else {
format!("{} Mix", res.title.clone())
};
let id_slug = res.title.replace(|c: char| !c.is_alphanumeric() && c != ' ', "").replace(' ', "-");
items.push(StaticPlaylist {
id: format!("discovery-{}-{}-{}", p_type.to_lowercase(), id_slug, res.id),
title,
description: Some(if is_album { "Album".to_string() } else { format!("Made for you • {}", artist) }),
cover_url: Some(cover_url),
creator: Some(artist),
tracks: Vec::new(),
playlist_type: p_type.to_string(),
});
}
}
if !items.is_empty() {
all_data.insert(category.to_string(), items);
}
}
}
// Also load artists
let artists_query = "ytmusicsearch30:V-Pop Official Channel";
if let Ok(o) = Command::new(&path)
.args(&["--js-runtimes", "node", &artists_query, "--dump-json", "--flat-playlist"])
.output() {
let mut items = Vec::new();
for line in String::from_utf8_lossy(&o.stdout).lines() {
if let Ok(res) = serde_json::from_str::<YTResult>(line) {
if res.id.starts_with("UC") {
let cover_url = res.thumbnails.last().map(|t| t.url.clone()).unwrap_or_default();
let artist = res.title.replace(" - Topic", "");
let id_slug = artist.replace(|c: char| !c.is_alphanumeric() && c != ' ', "").replace(' ', "-");
items.push(StaticPlaylist {
id: format!("discovery-artist-{}-{}", id_slug, res.id),
title: artist.clone(),
description: Some("Artist".to_string()),
cover_url: Some(cover_url),
creator: Some("Artist".to_string()),
tracks: Vec::new(),
playlist_type: "Artist".to_string(),
});
}
}
}
if !items.is_empty() {
all_data.insert("Popular Artists".to_string(), items);
}
}
println!("Background preloader finished loading {} categories!", all_data.len());
let mut cache = cache_arc.write().await;
*cache = all_data;
}
pub async fn search_tracks(&self, query: &str) -> Result<Vec<Track>, String> {
// 1. Check Cache
{
let cache = self.search_cache.read().await;
if let Some(item) = cache.get(query) {
if item.timestamp.elapsed() < Duration::from_secs(3600) {
println!("Cache Hit: {}", query);
return Ok(item.tracks.clone());
}
}
}
let path = Self::yt_dlp_path();
let search_query = format!("ytsearch20:{} audio", query);
let output = match Command::new(&path)
.args(&["--js-runtimes", "node", &search_query, "--dump-json", "--no-playlist", "--flat-playlist"])
.output() {
Ok(o) => o,
Err(e) => return Err(format!("Failed to execute yt-dlp: {}", e)),
};
if !output.status.success() {
return Err(format!("Search failed. stderr: {}", String::from_utf8_lossy(&output.stderr)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut tracks = Vec::new();
for line in stdout.lines() {
if line.trim().is_empty() {
continue;
}
if let Ok(res) = serde_json::from_str::<YTResult>(line) {
let duration = res.duration.unwrap_or(0.0);
// FILTER: channel, playlist, short, long, or ZERO duration
if res.id.starts_with("UC") || res.id.starts_with("PL") || duration < 1.0 || duration > 1200.0 {
continue;
}
let artist = res.uploader.replace(" - Topic", "");
// Select thumbnail
let mut cover_url = String::new();
if !res.thumbnails.is_empty() {
let mut best_score = -1.0;
for thumb in &res.thumbnails {
let w = thumb.width.unwrap_or(0) as f64;
let h = thumb.height.unwrap_or(0) as f64;
if w == 0.0 || h == 0.0 { continue; }
let ratio = w / h;
let diff = (ratio - 1.0).abs();
let mut score = w * h;
if diff < 0.1 {
score *= 10.0;
}
if score > best_score {
best_score = score;
cover_url = thumb.url.clone();
}
}
if cover_url.is_empty() {
cover_url = res.thumbnails.last().unwrap().url.clone();
}
} else {
cover_url = format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", res.id);
}
tracks.push(Track {
id: res.id.clone(),
title: res.title.clone(),
artist,
album: "YouTube Music".to_string(),
duration: duration as i32,
cover_url,
url: format!("/api/stream/{}", res.id),
});
}
}
// 2. Save cache
if !tracks.is_empty() {
let mut cache = self.search_cache.write().await;
cache.insert(query.to_string(), CacheItem {
tracks: tracks.clone(),
timestamp: Instant::now(),
});
}
Ok(tracks)
}
pub fn get_stream_url(&self, video_url: &str) -> Result<String, String> {
let target_url = if video_url.starts_with("http") {
video_url.to_string()
} else {
format!("https://www.youtube.com/watch?v={}", video_url)
};
let video_id = Self::extract_id(&target_url);
// Already downloaded? (just check if anything starts with id in temp dir)
if let Ok(entries) = fs::read_dir(&self.download_dir) {
for entry in entries.flatten() {
if let Some(file_name) = entry.file_name().to_str() {
if file_name.starts_with(&format!("{}.", video_id)) {
return Ok(entry.path().to_string_lossy().into_owned());
}
}
}
}
let output = match Command::new(Self::yt_dlp_path())
.current_dir(&self.download_dir)
.args(&["--js-runtimes", "node", "-f", "bestaudio/best", "--output", &format!("{}.%(ext)s", video_id), &target_url])
.output() {
Ok(o) => o,
Err(e) => {
println!("[Stream] yt-dlp spawn error: {}", e);
return Err(format!("Download spawn failed: {}", e));
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
println!("[Stream] yt-dlp download failed: {}", stderr);
return Err(format!("Download failed. stderr: {}", stderr));
}
// Find downloaded file again
if let Ok(entries) = fs::read_dir(&self.download_dir) {
for entry in entries.flatten() {
if let Some(file_name) = entry.file_name().to_str() {
if file_name.starts_with(&format!("{}.", video_id)) {
return Ok(entry.path().to_string_lossy().into_owned());
}
}
}
}
Err("File not found after download".to_string())
}
pub async fn search_artist(&self, query: &str) -> Result<String, String> {
// Check cache first for quick response
{
let cache = self.search_cache.read().await;
if let Some(cached) = cache.get(query) {
if let Some(track) = cached.tracks.first() {
if !track.cover_url.is_empty() {
return Ok(track.cover_url.clone());
}
}
}
}
// Try to fetch actual artist photo from YouTube
let path = Self::yt_dlp_path();
let search_query = format!("ytsearch5:{} artist", query);
let output = Command::new(&path)
.args(&[&search_query, "--dump-json", "--flat-playlist"])
.output();
if let Ok(o) = output {
let stdout = String::from_utf8_lossy(&o.stdout);
for line in stdout.lines() {
if line.trim().is_empty() {
continue;
}
if let Ok(res) = serde_json::from_str::<YTResult>(line) {
// Get the video thumbnail which often has the artist
if let Some(thumb) = res.thumbnails.last() {
if !thumb.url.is_empty() {
// Convert to higher quality thumbnail
let high_quality = thumb.url.replace("hqdefault", "maxresdefault");
return Ok(high_quality);
}
}
}
}
}
// Fallback to placeholder if no real photo found
Ok(self.get_placeholder_image(query))
}
fn extract_id(url: &str) -> String {
// If URL contains v= parameter, extract from there first
if url.contains("v=") {
let parts: Vec<&str> = url.split("v=").collect();
if parts.len() > 1 {
let video_part = parts[1].split('&').next().unwrap_or("");
// Check if the extracted part is a discovery ID
if video_part.starts_with("discovery-") || video_part.starts_with("artist-") {
// Extract actual video ID from the discovery ID
let sub_parts: Vec<&str> = video_part.split('-').collect();
// Look for the last part that looks like a YouTube video ID (11 chars)
for part in sub_parts.iter().rev() {
if part.len() == 11 && part.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
return part.to_string();
}
}
// If no 11-char ID found, return the last part
if let Some(last_part) = sub_parts.last() {
return last_part.to_string();
}
}
return video_part.to_string();
}
}
// Handle discovery-album-* format IDs (frontend sends full ID, video ID is at end)
if url.starts_with("discovery-") || url.starts_with("artist-") {
// Video ID is the last segment that matches YouTube video ID format
// It could be 11 chars (e.g., "abc123ABC45") or could be split
let parts: Vec<&str> = url.split('-').collect();
// First, try to find a single 11-char YouTube ID
for part in parts.iter().rev() {
if part.len() == 11 && part.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
return part.to_string();
}
}
// If not found, try combining last two parts (in case ID was split)
if parts.len() >= 2 {
let last = parts.last().unwrap();
let second_last = parts.get(parts.len() - 2).unwrap();
let combined = format!("{}-{}", second_last, last);
if combined.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
return combined;
}
}
// Fallback: just use the last part
if let Some(last_part) = parts.last() {
return last_part.to_string();
}
}
url.to_string()
}
pub async fn get_recommendations(
&self,
seed: &str,
seed_type: &str,
limit: usize,
) -> Result<crate::api::Recommendations, String> {
// Generate recommendations based on seed type
let mut tracks = Vec::new();
let mut albums = Vec::new();
let mut playlists = Vec::new();
let mut artists = Vec::new();
// Extract artist name from seed for related searches
let artist_name = if seed_type == "track" {
// Try to extract artist from track title (format: "Artist - Title")
if seed.contains(" - ") {
seed.split(" - ").next().unwrap_or(seed).to_string()
} else {
seed.to_string()
}
} else {
seed.to_string()
};
// Search for related tracks
let search_query = if seed_type == "track" {
format!("{} similar", artist_name)
} else if seed_type == "album" {
format!("{} album similar", artist_name)
} else if seed_type == "playlist" {
format!("{} playlist mix", artist_name)
} else {
format!("{} music similar", artist_name)
};
// Get tracks from search - use more specific queries for similar artists
let search_queries = if seed_type == "artist" {
vec![
format!("similar artists to {}", artist_name),
format!("like {}", artist_name),
format!("fans of {}", artist_name),
]
} else {
vec![search_query]
};
// PARALLEL SEARCH - Run all queries concurrently
let search_results = join_all(
search_queries.iter().map(|q| self.search_tracks(q))
).await;
for result in search_results {
if tracks.len() >= limit {
break;
}
if let Ok(search_tracks) = result {
for track in search_tracks {
if tracks.len() >= limit {
break;
}
// For artist type, skip tracks by the same artist
if seed_type == "artist" &&
track.artist.to_lowercase() == artist_name.to_lowercase() {
continue;
}
// Skip exact duplicates
if !tracks.iter().any(|t: &crate::models::Track| t.id == track.id) {
tracks.push(track);
}
}
}
}
// If still no tracks, try a broader search
if tracks.is_empty() {
if let Ok(search_tracks) = self.search_tracks(&artist_name).await {
for track in search_tracks.iter().take(5) {
if !track.artist.to_lowercase().contains(&artist_name.to_lowercase()) {
tracks.push(track.clone());
}
}
}
}
// Generate album suggestions from track data
let mut seen_albums = std::collections::HashSet::new();
for track in &tracks {
if albums.len() >= 10 {
break;
}
let album_key = format!("{}:{}", track.artist, track.album);
if !seen_albums.contains(&album_key) && !track.album.is_empty() {
seen_albums.insert(album_key);
albums.push(crate::api::AlbumSuggestion {
id: format!("discovery-album-{}-{}",
track.album.replace(|c: char| !c.is_alphanumeric() && c != ' ', "-"),
track.id),
title: track.album.clone(),
artist: track.artist.clone(),
cover_url: track.cover_url.clone(),
});
}
}
// Generate playlist suggestions - PARALLEL
let playlist_queries = vec![
format!("{} Mix", artist_name),
format!("{} Radio", artist_name),
format!("{} Top Hits", artist_name),
];
let playlist_results = join_all(
playlist_queries.iter().map(|q| self.search_tracks(q))
).await;
for (query, result) in playlist_queries.iter().zip(playlist_results) {
if playlists.len() >= 10 {
break;
}
if let Ok(results) = result {
if let Some(track) = results.first() {
playlists.push(crate::api::PlaylistSuggestion {
id: format!("discovery-playlist-{}-{}",
query.replace(|c: char| !c.is_alphanumeric() && c != ' ', "-"),
track.id),
title: query.clone(),
cover_url: track.cover_url.clone(),
track_count: results.len().min(20),
});
}
}
}
// Generate artist suggestions from track data
// Use placeholder images directly - YouTube thumbnails are video covers, not artist photos
let mut seen_artists = std::collections::HashSet::new();
for track in &tracks {
if artists.len() >= 10 {
break;
}
if !seen_artists.contains(&track.artist) && !track.artist.is_empty() {
seen_artists.insert(track.artist.clone());
// Use placeholder image - instant and always works
let photo_url = self.get_placeholder_image(&track.artist);
artists.push(crate::api::ArtistSuggestion {
id: format!("artist-{}", track.artist.replace(|c: char| !c.is_alphanumeric() && c != ' ', "-")),
name: track.artist.clone(),
photo_url,
});
}
}
Ok(crate::api::Recommendations {
tracks,
albums,
playlists,
artists,
})
}
}

View file

@ -0,0 +1,5 @@
{"_type": "url", "ie_key": "Youtube", "id": "Hqmbo0ROBQw", "url": "https://www.youtube.com/watch?v=Hqmbo0ROBQw", "title": "\u0110en - V\u1ecb nh\u00e0 (M/V)", "description": null, "duration": 315.0, "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "uploader": "\u0110en V\u00e2u Official", "uploader_id": null, "uploader_url": null, "thumbnails": [{"url": "https://i.ytimg.com/vi/Hqmbo0ROBQw/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLD7cv1L-whoLTjjprPvA6HecCOhOQ", "height": 202, "width": 360}, {"url": "https://i.ytimg.com/vi/Hqmbo0ROBQw/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA9lSyi9M9k24HQhgobFPMEgQEKVA", "height": 404, "width": 720}], "timestamp": null, "release_timestamp": null, "availability": null, "view_count": 22853006, "live_status": null, "channel_is_verified": true, "__x_forwarded_for_ip": null, "webpage_url": "https://www.youtube.com/watch?v=Hqmbo0ROBQw", "original_url": "https://www.youtube.com/watch?v=Hqmbo0ROBQw", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube", "extractor_key": "Youtube", "playlist_count": 5, "playlist": "\u0110en V\u00e2u channel", "playlist_id": "\u0110en V\u00e2u channel", "playlist_title": "\u0110en V\u00e2u channel", "playlist_uploader": null, "playlist_uploader_id": null, "playlist_channel": null, "playlist_channel_id": null, "playlist_webpage_url": "ytsearch5:\u0110en V\u00e2u channel", "n_entries": 5, "playlist_index": 1, "__last_playlist_index": 5, "playlist_autonumber": 1, "epoch": 1773501405, "duration_string": "5:15", "release_year": null, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}}
{"_type": "url", "ie_key": "Youtube", "id": "vTJdVE_gjI0", "url": "https://www.youtube.com/watch?v=vTJdVE_gjI0", "title": "\u0110en x JustaTee - \u0110i V\u1ec1 Nh\u00e0 (M/V)", "description": null, "duration": 206.0, "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "uploader": "\u0110en V\u00e2u Official", "uploader_id": null, "uploader_url": null, "thumbnails": [{"url": "https://i.ytimg.com/vi/vTJdVE_gjI0/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAIqVJmdwcWnFH1AESeFj1vXy6FIg", "height": 202, "width": 360}, {"url": "https://i.ytimg.com/vi/vTJdVE_gjI0/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBuK37jXALeyRM5CgZ_iBbpCu-0Ww", "height": 404, "width": 720}], "timestamp": null, "release_timestamp": null, "availability": null, "view_count": 205403243, "live_status": null, "channel_is_verified": true, "__x_forwarded_for_ip": null, "webpage_url": "https://www.youtube.com/watch?v=vTJdVE_gjI0", "original_url": "https://www.youtube.com/watch?v=vTJdVE_gjI0", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube", "extractor_key": "Youtube", "playlist_count": 5, "playlist": "\u0110en V\u00e2u channel", "playlist_id": "\u0110en V\u00e2u channel", "playlist_title": "\u0110en V\u00e2u channel", "playlist_uploader": null, "playlist_uploader_id": null, "playlist_channel": null, "playlist_channel_id": null, "playlist_webpage_url": "ytsearch5:\u0110en V\u00e2u channel", "n_entries": 5, "playlist_index": 2, "__last_playlist_index": 5, "playlist_autonumber": 2, "epoch": 1773501405, "duration_string": "3:26", "release_year": null, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}}
{"_type": "url", "ie_key": "Youtube", "id": "2bUScL5ojlY", "url": "https://www.youtube.com/watch?v=2bUScL5ojlY", "title": "\u0110en x Chi Pu x Lynk Lee - N\u1ebfu M\u00ecnh G\u1ea7n Nhau (Audio)", "description": null, "duration": 191.0, "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "uploader": "\u0110en V\u00e2u Official", "uploader_id": null, "uploader_url": null, "thumbnails": [{"url": "https://i.ytimg.com/vi/2bUScL5ojlY/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAYOjHpiYUG6Prge3e62O2mcPCN5A", "height": 202, "width": 360}, {"url": "https://i.ytimg.com/vi/2bUScL5ojlY/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBymxV0EPQ4gyNzwsoCy4quAWGa1Q", "height": 404, "width": 720}], "timestamp": null, "release_timestamp": null, "availability": null, "view_count": 2867844, "live_status": null, "channel_is_verified": true, "__x_forwarded_for_ip": null, "webpage_url": "https://www.youtube.com/watch?v=2bUScL5ojlY", "original_url": "https://www.youtube.com/watch?v=2bUScL5ojlY", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube", "extractor_key": "Youtube", "playlist_count": 5, "playlist": "\u0110en V\u00e2u channel", "playlist_id": "\u0110en V\u00e2u channel", "playlist_title": "\u0110en V\u00e2u channel", "playlist_uploader": null, "playlist_uploader_id": null, "playlist_channel": null, "playlist_channel_id": null, "playlist_webpage_url": "ytsearch5:\u0110en V\u00e2u channel", "n_entries": 5, "playlist_index": 3, "__last_playlist_index": 5, "playlist_autonumber": 3, "epoch": 1773501405, "duration_string": "3:11", "release_year": null, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}}
{"_type": "url", "ie_key": "Youtube", "id": "ArexdEMWRlA", "url": "https://www.youtube.com/watch?v=ArexdEMWRlA", "title": "\u0110en - Tr\u1eddi \u01a1i con ch\u01b0a mu\u1ed1n ch\u1ebft (Prod. by Tantu Beats)", "description": null, "duration": 162.0, "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "uploader": "\u0110en V\u00e2u Official", "uploader_id": null, "uploader_url": null, "thumbnails": [{"url": "https://i.ytimg.com/vi/ArexdEMWRlA/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDhCNm1yQB8WUaqIo9Kw7wweMhTzw", "height": 202, "width": 360}, {"url": "https://i.ytimg.com/vi/ArexdEMWRlA/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCMgC-38NlV10mjB4rFqZ5jFwSsNw", "height": 404, "width": 720}], "timestamp": null, "release_timestamp": null, "availability": null, "view_count": 17788226, "live_status": null, "channel_is_verified": true, "__x_forwarded_for_ip": null, "webpage_url": "https://www.youtube.com/watch?v=ArexdEMWRlA", "original_url": "https://www.youtube.com/watch?v=ArexdEMWRlA", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube", "extractor_key": "Youtube", "playlist_count": 5, "playlist": "\u0110en V\u00e2u channel", "playlist_id": "\u0110en V\u00e2u channel", "playlist_title": "\u0110en V\u00e2u channel", "playlist_uploader": null, "playlist_uploader_id": null, "playlist_channel": null, "playlist_channel_id": null, "playlist_webpage_url": "ytsearch5:\u0110en V\u00e2u channel", "n_entries": 5, "playlist_index": 4, "__last_playlist_index": 5, "playlist_autonumber": 4, "epoch": 1773501405, "duration_string": "2:42", "release_year": null, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}}
{"_type": "url", "ie_key": "Youtube", "id": "KKc_RMln5UY", "url": "https://www.youtube.com/watch?v=KKc_RMln5UY", "title": "\u0110en - L\u1ed1i Nh\u1ecf ft. Ph\u01b0\u01a1ng Anh \u0110\u00e0o (M/V)", "description": null, "duration": 297.0, "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "uploader": "\u0110en V\u00e2u Official", "uploader_id": null, "uploader_url": null, "thumbnails": [{"url": "https://i.ytimg.com/vi/KKc_RMln5UY/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAE0jKnPS0v1E_YHdg3UPiEvG0j1A", "height": 202, "width": 360}, {"url": "https://i.ytimg.com/vi/KKc_RMln5UY/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAOcBa3yyAadGHbRtHNv7cimOpbfA", "height": 404, "width": 720}], "timestamp": null, "release_timestamp": null, "availability": null, "view_count": 180009882, "live_status": null, "channel_is_verified": true, "__x_forwarded_for_ip": null, "webpage_url": "https://www.youtube.com/watch?v=KKc_RMln5UY", "original_url": "https://www.youtube.com/watch?v=KKc_RMln5UY", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube", "extractor_key": "Youtube", "playlist_count": 5, "playlist": "\u0110en V\u00e2u channel", "playlist_id": "\u0110en V\u00e2u channel", "playlist_title": "\u0110en V\u00e2u channel", "playlist_uploader": null, "playlist_uploader_id": null, "playlist_channel": null, "playlist_channel_id": null, "playlist_webpage_url": "ytsearch5:\u0110en V\u00e2u channel", "n_entries": 5, "playlist_index": 5, "__last_playlist_index": 5, "playlist_autonumber": 5, "epoch": 1773501405, "duration_string": "4:57", "release_year": null, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}}

View file

@ -0,0 +1 @@
{"id": "UCWu91J5KWEj1bQhCBuGeJxw", "channel": "\u0110en V\u00e2u Official", "channel_id": "UCWu91J5KWEj1bQhCBuGeJxw", "title": "\u0110en V\u00e2u Official", "availability": null, "channel_follower_count": 5210000, "description": "\u0110en V\u00e2u\nM\u1ed9t ng\u01b0\u1eddi Vi\u1ec7t ch\u01a1i nh\u1ea1c Rap.\nA Vietnamese boy who plays Rap. \n\n\n\n", "tags": ["den", "den vau", "\u0111en", "\u0111en v\u00e2u", "rapper den", "rapper \u0111en", "rapper \u0111en v\u00e2u", "mang ti\u1ec1n v\u1ec1 cho m\u1eb9", "ai mu\u1ed1n nghe kh\u00f4ng", "l\u1ed1i nh\u1ecf", "hip hop rap", "\u0111i v\u1ec1 nh\u00e0", "b\u00e0i n\u00e0y chill ph\u1ebft", "trong r\u1eebng c\u00f3 nai c\u00f3 th\u1ecf", "nh\u1ea1c rap", "friendship", "friendship karaoke", "friendship lyrics", "lu\u00f4n y\u00eau \u0111\u1eddi", "tri\u1ec7u \u0111i\u1ec1u nh\u1ecf x\u00edu xi\u00eau l\u00f2ng", "xi\u00eau l\u00f2ng", "nh\u1ecf x\u00edu xi\u00eau l\u00f2ng", "\u0111\u01b0\u1eddng bi\u00ean h\u00f2a", "kim long", "\u0111en ft kim long", "\u0111en v\u00e0 kim long", "l\u00e3ng \u0111\u00e3ng", "lang dang", "l\u00e3ng \u0111\u00e3ng \u0111en v\u00e2u", "vi\u1ec7c l\u1edbn", "vi\u1ec7c l\u1edbn \u0111en v\u00e2u"], "thumbnails": [{"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 175, "width": 1060, "preference": -10, "id": "0", "resolution": "1060x175"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w1138-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 188, "width": 1138, "preference": -10, "id": "1", "resolution": "1138x188"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w1707-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 283, "width": 1707, "preference": -10, "id": "2", "resolution": "1707x283"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w2120-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 351, "width": 2120, "preference": -10, "id": "3", "resolution": "2120x351"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w2276-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 377, "width": 2276, "preference": -10, "id": "4", "resolution": "2276x377"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", "height": 424, "width": 2560, "preference": -10, "id": "5", "resolution": "2560x424"}, {"url": "https://yt3.googleusercontent.com/nlog7uSFxDxg0Z9ptuUCMdC9GXR2Fx0-YF9UFSNKNK_YceVXky6wdh4v-IBRoRjyeKKMsIQz=s0", "id": "banner_uncropped", "preference": -5}, {"url": "https://yt3.googleusercontent.com/4KreeefY-ytOPUKbpbDQnIqzi-b2qOP8ILqlNDThbk5T8kYJIT2EKvj9uQnv8FHi15quYN5L=s900-c-k-c0x00ffffff-no-rj", "height": 900, "width": 900, "id": "7", "resolution": "900x900"}, {"url": "https://yt3.googleusercontent.com/4KreeefY-ytOPUKbpbDQnIqzi-b2qOP8ILqlNDThbk5T8kYJIT2EKvj9uQnv8FHi15quYN5L=s0", "id": "avatar_uncropped", "preference": 1}], "uploader_id": "@DenVau1305", "uploader_url": "https://www.youtube.com/@DenVau1305", "modified_date": null, "view_count": null, "playlist_count": 2, "uploader": "\u0110en V\u00e2u Official", "channel_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "_type": "playlist", "entries": [], "webpage_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "original_url": "https://www.youtube.com/channel/UCWu91J5KWEj1bQhCBuGeJxw", "webpage_url_basename": "UCWu91J5KWEj1bQhCBuGeJxw", "webpage_url_domain": "youtube.com", "extractor": "youtube:tab", "extractor_key": "YoutubeTab", "release_year": null, "requested_entries": [], "epoch": 1773501485, "__files_to_move": {}, "_version": {"version": "2026.02.04", "current_git_head": null, "release_git_head": "c677d866d41eb4075b0a5e0c944a6543fc13f15d", "repository": "yt-dlp/yt-dlp"}}

View file

@ -1,11 +1,17 @@
services:
spotify-clone:
image: vndangkhoa/spotify-clone:latest
image: git.khoavo.myds.me/vndangkhoa/spotify-clone:v3
container_name: spotify-clone
restart: always
network_mode: bridge # Synology often prefers explicit bridge or host
restart: unless-stopped
ports:
- "3110:3000" # Web UI
- "3110:8080"
environment:
- PORT=8080
volumes:
- ./data:/app/backend/data
- ./data:/tmp/spotify-clone-downloads
- ./cache:/tmp/spotify-clone-cache
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

View file

@ -7,15 +7,14 @@ interface CoverImageProps {
fallbackText?: string;
}
export default function CoverImage({ src, alt, className = "", fallbackText = "♪" }: CoverImageProps) {
export default function CoverImage({ src, alt, className = "", fallbackText = "♪" }: CoverImageProps) {
const [error, setError] = useState(false);
const [loaded, setLoaded] = useState(false);
if (!src || error) {
// Fallback placeholder with gradient
return (
<div
className={`bg-gradient-to-br from-neutral-700 to-neutral-900 flex items-center justify-center text-2xl font-bold text-white/60 ${className}`}
className={`relative overflow-hidden bg-gradient-to-br from-neutral-700 to-neutral-900 flex items-center justify-center text-2xl font-bold text-white/60 ${className}`}
aria-label={alt}
>
{fallbackText}
@ -24,7 +23,7 @@ export default function CoverImage({ src, alt, className = "", fallbackText = "
}
return (
<div className={`relative ${className}`}>
<div className={`relative overflow-hidden ${className}`}>
{!loaded && (
<div className="absolute inset-0 bg-neutral-800 animate-pulse" />
)}

View file

@ -1,11 +1,12 @@
import { Play, Pause, SkipBack, SkipForward, Repeat, Shuffle, Volume2, Download, PlusCircle, Mic2, Heart, Loader2, ListMusic, MonitorSpeaker, Maximize2, MoreHorizontal, Info, ChevronUp } from 'lucide-react';
import { usePlayer } from "../context/PlayerContext";
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, useLocation } from "react-router-dom";
import TechSpecs from './TechSpecs';
import AddToPlaylistModal from "./AddToPlaylistModal";
import Lyrics from './Lyrics';
import QueueModal from './QueueModal';
import Recommendations from './Recommendations';
import { useDominantColor } from '../hooks/useDominantColor';
import { useLyrics } from '../hooks/useLyrics';
@ -13,7 +14,8 @@ export default function PlayerBar() {
const {
currentTrack, isPlaying, isBuffering, togglePlay, setBuffering,
likedTracks, toggleLike, nextTrack, prevTrack, shuffle, toggleShuffle,
repeatMode, toggleRepeat, audioQuality, isLyricsOpen, toggleLyrics, closeLyrics, openLyrics
repeatMode, toggleRepeat, audioQuality, isLyricsOpen, toggleLyrics, closeLyrics, openLyrics,
isFullScreenOpen, setIsFullScreenOpen
} = usePlayer();
const dominantColor = useDominantColor(currentTrack?.cover_url);
@ -54,39 +56,102 @@ export default function PlayerBar() {
const [volume, setVolume] = useState(1);
const navigate = useNavigate();
const location = useLocation();
// Modal State
const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false);
const [isTechSpecsOpen, setIsTechSpecsOpen] = useState(false);
const [isFullScreenPlayerOpen, setIsFullScreenPlayerOpen] = useState(false);
const [isCoverModalOpen, setIsCoverModalOpen] = useState(false);
const [isQueueOpen, setIsQueueOpen] = useState(false);
const [isInfoOpen, setIsInfoOpen] = useState(false);
const [playerMode, setPlayerMode] = useState<'audio' | 'video'>('audio');
const [isIdle, setIsIdle] = useState(false);
const [isVideoReady, setIsVideoReady] = useState(false);
const idleTimerRef = useRef<NodeJS.Timeout | null>(null);
const resetIdleTimer = () => {
setIsIdle(false);
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
if (playerMode === 'video' && isPlaying) {
idleTimerRef.current = setTimeout(() => {
setIsIdle(true);
}, 3000);
}
};
// Force close lyrics on mount (Defensive fix for "Open on first play")
useEffect(() => {
closeLyrics();
}, []);
// Auto-close fullscreen player on navigation
useEffect(() => {
setPlayerMode('audio');
setIsFullScreenOpen(false);
}, [location.pathname]);
// Reset to audio mode when track changes
useEffect(() => {
setPlayerMode('audio');
setIsIdle(false);
setIsVideoReady(false);
}, [currentTrack?.id]);
// Handle idle timer when playing video
useEffect(() => {
resetIdleTimer();
return () => {
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
};
}, [isPlaying, playerMode]);
// Handle audio/video mode switching
const handleModeSwitch = (mode: 'audio' | 'video') => {
if (mode === 'video') {
// Pause audio ref but DON'T toggle isPlaying state to false
// The iframe useEffect will pick up isPlaying=true and start the video
audioRef.current?.pause();
if (isPlaying) togglePlay(); // Update state to paused
setIsVideoReady(false);
// If currently playing, start video playback after iframe loads
if (isPlaying && iframeRef.current && iframeRef.current.contentWindow) {
setTimeout(() => {
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(JSON.stringify({
event: 'command',
func: 'playVideo'
}), '*');
}
}, 1000);
}
} else {
// Switching back to audio
// Optionally sync time if we could get it from video, but for now just resume
if (!isPlaying) togglePlay();
if (isPlaying) {
audioRef.current?.play().catch(() => { });
}
}
setPlayerMode(mode);
};
// Handle play/pause for video mode - send command to YouTube iframe only
const handleVideoPlayPause = () => {
if (playerMode !== 'video' || !iframeRef.current || !iframeRef.current.contentWindow) return;
// Send play/pause command directly to YouTube
const action = isPlaying ? 'pauseVideo' : 'playVideo';
try {
iframeRef.current.contentWindow.postMessage(JSON.stringify({
event: 'command',
func: action
}), '*');
} catch (e) {
// Ignore cross-origin errors
}
// Toggle local state for UI sync only (audio won't play since it's paused)
togglePlay();
};
// ... (rest of useEffects)
// ... inside return ...
@ -112,8 +177,10 @@ export default function PlayerBar() {
}
}, [currentTrack?.url]);
// Play/Pause effect
// Play/Pause effect - skip when in video mode (YouTube controls playback)
useEffect(() => {
if (playerMode === 'video') return; // Skip audio control in video mode
if (audioRef.current) {
if (isPlaying) {
audioRef.current.play().catch(e => {
@ -125,7 +192,7 @@ export default function PlayerBar() {
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = "paused";
}
}
}, [isPlaying]);
}, [isPlaying, playerMode]);
// Volume Effect
useEffect(() => {
@ -134,16 +201,8 @@ export default function PlayerBar() {
}
}, [volume]);
// Sync Play/Pause with YouTube Iframe
useEffect(() => {
if (playerMode === 'video' && iframeRef.current && iframeRef.current.contentWindow) {
const action = isPlaying ? 'playVideo' : 'pauseVideo';
iframeRef.current.contentWindow.postMessage(JSON.stringify({
event: 'command',
func: action
}), '*');
}
}, [isPlaying, playerMode]);
// Note: YouTube iframe play/pause sync is handled via URL autoplay parameter
// Cross-origin restrictions prevent reliable postMessage control
const handleTimeUpdate = () => {
if (audioRef.current) {
@ -211,7 +270,7 @@ export default function PlayerBar() {
className="fixed bottom-[calc(4rem+env(safe-area-inset-bottom))] left-2 right-2 fold:left-0 fold:right-0 fold:bottom-0 h-16 fold:h-[90px] bg-spotify-player border-t-0 fold:border-t border-white/5 flex items-center justify-between z-[60] rounded-lg fold:rounded-none shadow-xl fold:shadow-none transition-all duration-300 backdrop-blur-xl"
onClick={() => {
if (window.innerWidth < 1024) {
setIsFullScreenPlayerOpen(true);
setIsFullScreenOpen(true);
}
}}
>
@ -254,8 +313,7 @@ export default function PlayerBar() {
className="h-14 w-14 fold:h-14 fold:w-14 rounded-xl object-cover ml-1 fold:ml-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (window.innerWidth >= 700) setIsCoverModalOpen(true);
else setIsFullScreenPlayerOpen(true);
setIsFullScreenOpen(true);
}}
/>
@ -404,7 +462,7 @@ export default function PlayerBar() {
className="w-full h-1 bg-[#4d4d4d] rounded-lg appearance-none cursor-pointer accent-white group-hover:accent-green-500"
/>
</div>
<button onClick={() => setIsCoverModalOpen(true)} title="Full Screen" className="text-zinc-400 hover:text-white">
<button onClick={() => setIsFullScreenOpen(true)} title="Full Screen" className="text-zinc-400 hover:text-white">
<Maximize2 className="w-4 h-4" />
</button>
</div>
@ -413,88 +471,109 @@ export default function PlayerBar() {
{/* Mobile Full Screen Player Overlay */}
<div
className={`fixed inset-0 z-[70] flex flex-col transition-transform duration-300 ${isFullScreenPlayerOpen ? 'translate-y-0' : 'translate-y-full'}`}
className={`fixed inset-0 z-[70] flex flex-col transition-transform duration-300 ${isFullScreenOpen ? 'translate-y-0' : 'translate-y-full'}`}
style={{ background: `linear-gradient(to bottom, ${dominantColor}, #121212)` }}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Header / Close */}
<div className="flex items-center justify-between p-4 pt-8">
<div onClick={() => setIsFullScreenPlayerOpen(false)} className="text-white">
<div className={`relative z-[80] flex items-center justify-between p-4 pt-8 shrink-0 transition-opacity duration-700 ${isIdle && playerMode === 'video' ? 'opacity-0 pointer-events-none' : 'opacity-100'}`}>
<div onClick={() => { setPlayerMode('audio'); setIsFullScreenOpen(false); }} className="text-white p-2 hover:bg-white/10 rounded-full transition cursor-pointer">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</div>
{/* Song / Video Toggle */}
<div className="flex bg-[#1a1a1a] rounded-full p-1">
<div className="flex bg-black/40 backdrop-blur-md rounded-full p-1 border border-white/10 shadow-xl">
<button
onClick={() => handleModeSwitch('audio')}
className={`px-6 py-1 rounded-full text-xs font-bold transition ${playerMode === 'audio' ? 'bg-[#333] text-white' : 'text-neutral-500'}`}
onClick={(e) => { e.stopPropagation(); handleModeSwitch('audio'); }}
className={`px-8 py-1.5 rounded-full text-xs font-bold transition-all duration-300 ${playerMode === 'audio' ? 'bg-white text-black shadow-lg scale-105' : 'text-neutral-400 hover:text-white'}`}
>
Song
</button>
<button
onClick={() => handleModeSwitch('video')}
className={`px-6 py-1 rounded-full text-xs font-bold transition ${playerMode === 'video' ? 'bg-[#333] text-white' : 'text-neutral-500'}`}
onClick={(e) => { e.stopPropagation(); handleModeSwitch('video'); }}
className={`px-8 py-1.5 rounded-full text-xs font-bold transition-all duration-300 ${playerMode === 'video' ? 'bg-white text-black shadow-lg scale-105' : 'text-neutral-400 hover:text-white'}`}
>
Video
</button>
</div>
<div className="w-6" />
<div className="w-10" />
</div>
{/* Responsive Split View Container */}
<div className="flex-1 flex flex-col md:flex-row w-full overflow-hidden">
{/* Left/Top: Art or Video */}
<div className="flex-1 flex items-center justify-center p-8 md:p-12">
{playerMode === 'audio' ? (
<img
src={currentTrack.cover_url}
alt={currentTrack.title}
className="w-full aspect-square object-cover rounded-3xl shadow-2xl max-h-[50vh] md:max-h-none"
/>
) : (
<div className="w-full aspect-video rounded-3xl overflow-hidden shadow-2xl bg-black">
{/* Content Area */}
<div
className="flex-1 relative overflow-hidden group"
onMouseMove={resetIdleTimer}
onTouchStart={resetIdleTimer}
>
{playerMode === 'video' ? (
/* CINEMATIC VIDEO MODE: Full Background Video */
<div className="absolute inset-0 z-0 bg-black">
<div className="w-full h-full transform scale-[1.01]"> {/* Slight scale to hide any possible edges */}
{!isVideoReady && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-white/30 border-t-white rounded-full animate-spin" />
</div>
)}
<iframe
key={`${currentTrack.id}-${playerMode}`}
ref={iframeRef}
width="100%"
height="100%"
src={`https://www.youtube.com/embed/${currentTrack.id}?autoplay=1&start=${Math.floor(progress)}&playsinline=1&modestbranding=1&rel=0&controls=1&enablejsapi=1`}
src={`https://www.youtube.com/embed/${currentTrack.id}?autoplay=1&playsinline=1&modestbranding=1&rel=0&controls=1&enablejsapi=1&fs=1&vq=hd1080`}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className={`pointer-events-auto transition-opacity duration-500 ${isVideoReady ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setIsVideoReady(true)}
></iframe>
</div>
)}
{/* Overlay Gradient for cinematic feel */}
<div className={`absolute inset-0 bg-gradient-to-t from-black via-transparent to-black/40 pointer-events-none transition-opacity duration-1000 ${isIdle ? 'opacity-20' : 'opacity-60'}`} />
</div>
) : (
/* SONG MODE: Centered Case */
<div className="h-full flex items-center justify-center p-8 md:p-12 animate-in zoom-in-95 duration-500">
<img
src={currentTrack.cover_url}
alt={currentTrack.title}
className="w-full aspect-square object-cover rounded-[2rem] shadow-[0_30px_60px_rgba(0,0,0,0.5)] max-h-[50vh] md:max-h-[60vh] transition-transform duration-700 group-hover:scale-[1.02]"
/>
</div>
)}
{/* Right/Bottom: Controls */}
<div className="flex-1 flex flex-col justify-center px-8 pb-12 md:pb-0 md:pr-12 overflow-y-auto no-scrollbar">
{/* Track Info */}
<div className="flex items-center justify-between mb-6">
<div className="flex-1 mr-4">
<h2 className="text-2xl md:text-4xl font-bold text-white line-clamp-2 md:mb-2">{currentTrack.title}</h2>
{/* Controls Overlay (Bottom) */}
<div className={`absolute bottom-0 left-0 right-0 z-20 px-8 pb-12 transition-all duration-700 ${playerMode === 'video' ? 'bg-gradient-to-t from-black via-black/40 to-transparent' : ''} ${isIdle && playerMode === 'video' ? 'opacity-0 translate-y-4 pointer-events-none' : 'opacity-100 translate-y-0'}`}>
<div className="max-w-screen-xl mx-auto flex flex-col md:flex-row md:items-end gap-8">
{/* Metadata */}
<div className="flex-1">
<h2 className={`font-black text-white mb-2 drop-shadow-lg tracking-tight transition-all duration-500 ${playerMode === 'video' ? 'text-xl md:text-3xl' : 'text-3xl md:text-5xl'}`}>{currentTrack.title}</h2>
<p
onClick={() => { setIsFullScreenPlayerOpen(false); navigate(`/artist/${encodeURIComponent(currentTrack.artist)}`); }}
className="text-lg md:text-xl text-neutral-400 line-clamp-1 cursor-pointer hover:text-white hover:underline transition"
onClick={() => { setPlayerMode('audio'); setIsFullScreenOpen(false); navigate(`/artist/${encodeURIComponent(currentTrack.artist)}`); }}
className={`text-white/70 font-medium cursor-pointer hover:text-white hover:underline transition drop-shadow-md ${playerMode === 'video' ? 'text-base md:text-xl' : 'text-lg md:text-2xl'}`}
>
{currentTrack.artist}
</p>
</div>
<div className="flex flex-col gap-4">
<button onClick={() => toggleLike(currentTrack)} className={likedTracks.has(currentTrack.id) ? 'text-green-500' : 'text-neutral-400'}>
<Heart size={28} fill={likedTracks.has(currentTrack.id) ? "currentColor" : "none"} />
{/* Secondary Actions */}
<div className="flex items-center gap-4 text-white">
<button onClick={() => toggleLike(currentTrack)} className={`p-3 rounded-full hover:bg-white/10 transition ${likedTracks.has(currentTrack.id) ? 'text-green-500' : 'text-white/60'}`}>
<Heart size={32} fill={likedTracks.has(currentTrack.id) ? "currentColor" : "none"} />
</button>
<button onClick={() => setIsInfoOpen(true)} className="text-neutral-400 hover:text-white">
<Info size={24} />
<button onClick={() => setIsInfoOpen(true)} className="p-3 rounded-full hover:bg-white/10 transition text-white/60 hover:text-white">
<Info size={28} />
</button>
</div>
</div>
{/* Progress */}
{/* Scrubber & Controls */}
<div className="max-w-screen-md mx-auto mt-8">
{/* Scrubber */}
<div className="mb-8">
<input
type="range"
@ -504,52 +583,32 @@ export default function PlayerBar() {
onChange={handleSeek}
onMouseUp={handleSeekCommit}
onTouchEnd={handleSeekCommit}
className="w-full h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-white mb-2"
className="w-full h-1.5 bg-white/20 rounded-lg appearance-none cursor-pointer accent-white mb-2 hover:bg-white/30 transition-colors"
/>
<div className="flex justify-between text-xs text-neutral-400 font-medium font-mono">
<div className="flex justify-between text-[10px] md:text-xs text-white/50 font-bold uppercase tracking-widest font-mono">
<span>{formatTime(progress)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center justify-between mb-8 max-w-md mx-auto w-full">
<button onClick={toggleShuffle} className={shuffle ? 'text-green-500' : 'text-neutral-400'}>
{/* Main Playback Controls */}
<div className="flex items-center justify-between w-full">
<button onClick={toggleShuffle} className={`p-2 transition-all duration-300 ${shuffle ? 'text-green-500 scale-110' : 'text-white/40 hover:text-white'}`}>
<Shuffle size={24} />
</button>
<button onClick={prevTrack} className="text-white hover:text-neutral-300 transition">
<SkipBack size={32} fill="currentColor" />
<button onClick={prevTrack} className="text-white hover:scale-110 active:scale-95 transition">
<SkipBack size={42} fill="currentColor" />
</button>
<button onClick={togglePlay} className="w-16 h-16 bg-white rounded-full flex items-center justify-center text-black hover:scale-105 transition shadow-lg">
{isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" className="ml-1" />}
<button onClick={playerMode === 'video' ? handleVideoPlayPause : togglePlay} className="w-20 h-20 bg-white rounded-full flex items-center justify-center text-black hover:scale-110 active:scale-90 transition shadow-2xl">
{isPlaying ? <Pause size={42} fill="currentColor" /> : <Play size={42} fill="currentColor" className="ml-1.5" />}
</button>
<button onClick={nextTrack} className="text-white hover:text-neutral-300 transition">
<SkipForward size={32} fill="currentColor" />
<button onClick={nextTrack} className="text-white hover:scale-110 active:scale-95 transition">
<SkipForward size={42} fill="currentColor" />
</button>
<button onClick={toggleRepeat} className={repeatMode !== 'none' ? 'text-green-500' : 'text-neutral-400'}>
<button onClick={toggleRepeat} className={`p-2 transition-all duration-300 ${repeatMode !== 'none' ? 'text-green-500 scale-110' : 'text-white/40 hover:text-white'}`}>
<Repeat size={24} />
</button>
</div>
{/* Lyric Peek (Tablet optimized) */}
<div
className={`h-16 flex items-center justify-center overflow-hidden cursor-pointer active:scale-95 transition bg-white/5 rounded-xl p-4 hover:bg-white/10 ${!hasInteractedWithLyrics ? 'opacity-50' : 'opacity-100'}`}
onClick={(e) => {
e.stopPropagation();
setHasInteractedWithLyrics(true);
openLyrics();
}}
>
{currentLine ? (
<p className="text-white font-bold text-lg text-center animate-in fade-in slide-in-from-bottom-2 line-clamp-2">
"{currentLine.text}"
</p>
) : (
<div className="flex items-center gap-2 text-neutral-400">
<Mic2 size={16} />
<span className="text-sm font-bold">Tap for Lyrics</span>
</div>
)}
</div>
</div>
</div>
@ -574,7 +633,7 @@ export default function PlayerBar() {
<p className="text-sm text-neutral-400">Artist</p>
<p
className="font-medium text-lg text-spotify-highlight cursor-pointer hover:underline"
onClick={() => { setIsInfoOpen(false); setIsFullScreenPlayerOpen(false); navigate(`/artist/${encodeURIComponent(currentTrack.artist)}`); }}
onClick={() => { setPlayerMode('audio'); setIsInfoOpen(false); setIsFullScreenOpen(false); navigate(`/artist/${encodeURIComponent(currentTrack.artist)}`); }}
>
{currentTrack.artist}
</p>

View file

@ -1,5 +1,7 @@
import { X, Play, Pause } from 'lucide-react';
import { usePlayer } from '../context/PlayerContext';
import { useEffect, useState } from 'react';
import { libraryService } from '../services/library';
import CoverImage from './CoverImage';
import { Track } from '../types';
@ -10,6 +12,30 @@ interface QueueModalProps {
export default function QueueModal({ isOpen, onClose }: QueueModalProps) {
const { queue, currentTrack, playTrack, isPlaying, togglePlay } = usePlayer();
const [recommendations, setRecommendations] = useState<Track[]>([]);
const [loadingRecs, setLoadingRecs] = useState(false);
// Fetch recommendations when current track changes
useEffect(() => {
if (!currentTrack || !isOpen) return;
const fetchRecommendations = async () => {
setLoadingRecs(true);
try {
const result = await libraryService.getRelatedContent(currentTrack.artist || currentTrack.title, 'track', 5);
// Filter out current track
const filtered = result.tracks.filter(t => t.id !== currentTrack.id);
setRecommendations(filtered.slice(0, 5));
} catch (error) {
console.error('Failed to fetch recommendations:', error);
setRecommendations([]);
} finally {
setLoadingRecs(false);
}
};
fetchRecommendations();
}, [currentTrack, isOpen]);
if (!isOpen) return null;
@ -48,8 +74,6 @@ export default function QueueModal({ isOpen, onClose }: QueueModalProps) {
// Actually queue usually contains the current track.
// Let's filter out current track visually or just show whole queue?
// Spotify shows "Next In Queue".
if (track.id === currentTrack?.id) return null;
return (
<QueueItem
key={`${track.id}-${i}`}
@ -61,6 +85,47 @@ export default function QueueModal({ isOpen, onClose }: QueueModalProps) {
})
)}
</div>
{/* Recommendations Section */}
<div className="mt-6 pt-6 border-t border-white/10">
<h3 className="text-sm font-bold text-neutral-400 uppercase tracking-widest mb-4 px-2">Recommended for You</h3>
{loadingRecs ? (
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex items-center gap-3 p-2 animate-pulse">
<div className="w-10 h-10 bg-white/10 rounded" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-white/10 rounded w-3/4" />
<div className="h-3 bg-white/10 rounded w-1/2" />
</div>
</div>
))}
</div>
) : recommendations.length === 0 ? (
<div className="text-neutral-500 text-sm px-2">No recommendations available</div>
) : (
<div className="space-y-1">
{recommendations.map((track) => (
<div
key={track.id}
onClick={() => playTrack(track, [...queue, ...recommendations])}
className="flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition cursor-pointer group"
>
<div className="relative w-10 h-10 flex-shrink-0">
<CoverImage src={track.cover_url} alt={track.title} className="w-full h-full rounded object-cover" fallbackText="♪" />
<div className="absolute inset-0 bg-black/40 hidden group-hover:flex items-center justify-center">
<Play size={16} className="text-white fill-white" />
</div>
</div>
<div className="min-w-0 flex-1">
<p className="font-medium truncate text-sm text-white">{track.title}</p>
<p className="text-xs text-neutral-400 truncate">{track.artist}</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,209 @@
import { useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { libraryService } from '../services/library';
import { usePlayer } from '../context/PlayerContext';
import { Play } from 'lucide-react';
import { Track } from '../types';
import CoverImage from './CoverImage';
interface RecommendationData {
tracks: Track[];
albums: Array<{ id: string; title: string; artist: string; cover_url: string }>;
playlists: Array<{ id: string; title: string; cover_url: string; track_count: number }>;
artists: Array<{ id: string; name: string; photo_url: string; cover_url?: string }>;
}
interface RecommendationsProps {
seed: string;
seedType?: string;
limit?: number;
title?: string;
showTracks?: boolean;
showAlbums?: boolean;
showPlaylists?: boolean;
showArtists?: boolean;
}
export default function Recommendations({
seed,
seedType = 'track',
limit = 10,
title = 'You might also like',
showTracks = true,
showAlbums = true,
showPlaylists = true,
showArtists = true
}: RecommendationsProps) {
const navigate = useNavigate();
const { playTrack } = usePlayer();
const [data, setData] = useState<RecommendationData>({
tracks: [],
albums: [],
playlists: [],
artists: []
});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!seed) return;
const fetchRecommendations = async () => {
try {
const result = await libraryService.getRelatedContent(seed, seedType, limit);
const artistsWithPhotos = await Promise.all(
result.artists.map(async (artist) => {
try {
const tracks = await libraryService.search(artist.name);
if (tracks.length > 0) {
return { ...artist, cover_url: tracks[0].cover_url };
}
} catch (e) {}
try {
const info = await libraryService.getArtistInfo(artist.name);
if (info.photo) {
return { ...artist, cover_url: info.photo };
}
} catch (e) {}
return artist;
})
);
setData({ ...result, artists: artistsWithPhotos });
} catch (error) {
console.error('Failed to fetch recommendations:', error);
}
};
fetchRecommendations();
}, [seed, seedType, limit]);
const hasContent = (showTracks && data.tracks.length > 0) ||
(showAlbums && data.albums.length > 0) ||
(showPlaylists && data.playlists.length > 0) ||
(showArtists && data.artists.length > 0);
const hasAnyContent = hasContent || loading;
if (!hasAnyContent) return null;
const isLoading = !hasContent;
return (
<div className="p-4 md:p-8 mt-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold hover:underline cursor-pointer">{title}</h2>
</div>
{isLoading && (
<div className="grid grid-cols-2 fold:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-4">
{[1, 2, 3, 4, 5].map(i => (
<div key={`skel-${i}`} className="bg-[#181818] p-3 md:p-4 rounded-xl space-y-3 md:space-y-4">
<div className="w-full aspect-square bg-neutral-800 rounded-2xl animate-pulse" />
<div className="h-4 bg-neutral-800 rounded w-3/4" />
<div className="h-3 bg-neutral-800 rounded w-1/2" />
</div>
))}
</div>
)}
<div className="grid grid-cols-2 fold:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-4">
{/* Tracks */}
{showTracks && data.tracks.slice(0, 8).map((track) => (
<div
key={track.id}
className="bg-[#181818] p-3 md:p-4 rounded-xl hover:bg-[#282828] transition duration-300 group cursor-pointer relative flex flex-col"
onClick={() => playTrack(track, data.tracks)}
>
<div className="relative mb-3 md:mb-4">
<CoverImage
src={track.cover_url}
alt={track.title}
className="w-full aspect-square rounded-2xl shadow-lg"
fallbackText={track.title?.substring(0, 3).toUpperCase() || '♪'}
/>
<div
className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl cursor-pointer"
>
<div className="w-10 h-10 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</div>
</div>
</div>
<h3 className="font-bold text-sm md:text-base mb-1 truncate">{track.title}</h3>
<p className="text-xs md:text-sm text-[#a7a7a7] truncate">{track.artist}</p>
</div>
))}
{/* Albums */}
{showAlbums && data.albums.slice(0, 8).map((album) => (
<Link to={`/album/${encodeURIComponent(album.id)}`} key={album.id}>
<div className="bg-[#181818] p-3 md:p-4 rounded-xl hover:bg-[#282828] transition duration-300 group cursor-pointer relative flex flex-col h-full">
<div className="relative mb-3 md:mb-4">
<CoverImage
src={album.cover_url}
alt={album.title}
className="w-full aspect-square rounded-2xl shadow-lg"
fallbackText={album.title?.substring(0, 3).toUpperCase() || '♪'}
/>
<div className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl cursor-pointer">
<div className="w-10 h-10 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</div>
</div>
</div>
<h3 className="font-bold text-sm md:text-base mb-1 truncate">{album.title}</h3>
<p className="text-xs md:text-sm text-[#a7a7a7] truncate">{album.artist}</p>
</div>
</Link>
))}
{/* Playlists */}
{showPlaylists && data.playlists.slice(0, 8).map((playlist) => (
<Link to={`/playlist/${encodeURIComponent(playlist.id)}`} key={playlist.id}>
<div className="bg-[#181818] p-3 md:p-4 rounded-xl hover:bg-[#282828] transition duration-300 group cursor-pointer relative flex flex-col h-full">
<div className="relative mb-3 md:mb-4">
<CoverImage
src={playlist.cover_url}
alt={playlist.title}
className="w-full aspect-square rounded-2xl shadow-lg"
fallbackText={playlist.title?.substring(0, 3).toUpperCase() || '♪'}
/>
<div className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl cursor-pointer">
<div className="w-10 h-10 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</div>
</div>
</div>
<h3 className="font-bold text-sm md:text-base mb-1 truncate">{playlist.title}</h3>
<p className="text-xs md:text-sm text-[#a7a7a7] truncate">{playlist.track_count} songs</p>
</div>
</Link>
))}
{/* Artists */}
{showArtists && data.artists.slice(0, 8).map((artist) => (
<Link to={`/artist/${encodeURIComponent(artist.name)}`} key={artist.id}>
<div className="bg-[#181818] p-3 md:p-4 rounded-xl hover:bg-[#282828] transition duration-300 group cursor-pointer relative flex flex-col h-full">
<div className="relative mb-3 md:mb-4">
<CoverImage
src={artist.cover_url || artist.photo_url}
alt={artist.name}
className="w-full aspect-square rounded-full shadow-lg"
fallbackText={artist.name?.substring(0, 3).toUpperCase() || '♪'}
/>
<div className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl cursor-pointer">
<div className="w-10 h-10 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</div>
</div>
</div>
<h3 className="font-bold text-sm md:text-base mb-1 truncate text-center">{artist.name}</h3>
<p className="text-xs md:text-sm text-[#a7a7a7] text-center">Artist</p>
</div>
</Link>
))}
</div>
</div>
);
}

View file

@ -1,7 +1,8 @@
import { useState } from 'react';
import { X, RefreshCcw, Check, CheckCircle2, Circle, Smartphone, Monitor } from 'lucide-react';
import { X, RefreshCcw, Check, CheckCircle2, Trash2, Database, Volume2, Activity, PlayCircle } from 'lucide-react';
import { useTheme } from '../context/ThemeContext';
import { usePlayer } from '../context/PlayerContext';
interface SettingsModalProps {
isOpen: boolean;
@ -10,9 +11,11 @@ interface SettingsModalProps {
export default function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const { theme, toggleTheme } = useTheme();
const { qualityPreference, setQualityPreference } = usePlayer();
const [isUpdating, setIsUpdating] = useState(false);
const [updateStatus, setUpdateStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [updateLog, setUpdateLog] = useState<string>('');
const [isClearingCache, setIsClearingCache] = useState(false);
if (!isOpen) return null;
@ -41,100 +44,180 @@ export default function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
}
};
const handleClearCache = () => {
setIsClearingCache(true);
// Wipe common caches
localStorage.removeItem('ytm_browse_cache_v8');
localStorage.removeItem('ytm_browse_cache_v7');
localStorage.removeItem('artist_photos_cache_v5');
localStorage.removeItem('artist_photos_cache_v6');
// Short delay for feedback
setTimeout(() => {
setIsClearingCache(false);
alert("Cache cleared successfully! The app will reload fresh data.");
}, 800);
};
const isApple = theme === 'apple';
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="fixed inset-0 z-[100] flex items-center justify-center p-2 sm:p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
className="absolute inset-0 bg-black/70 backdrop-blur-md"
onClick={onClose}
/>
<div className={`relative w-full max-w-2xl overflow-hidden rounded-2xl shadow-2xl border transition-colors duration-300 ${theme === 'apple' ? 'bg-[#1c1c1e]/80 border-white/10 text-white' : 'bg-[#121212] border-[#282828] text-white'}`}>
{/* Modal Container */}
<div
className={`bg-black/90 backdrop-blur-2xl w-full h-full md:h-auto md:max-w-2xl md:rounded-3xl overflow-hidden border-t md:border border-white/10 flex flex-col shadow-2xl transition-all duration-500 ${isOpen ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<h2 className="text-xl font-bold">Settings</h2>
<button onClick={onClose} className="p-2 rounded-full hover:bg-white/10 transition">
<X className="w-5 h-5" />
<div className={`flex items-center justify-between p-5 border-b ${isApple ? 'border-white/10' : 'border-[#282828]'}`}>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-xl ${isApple ? 'bg-[#fa2d48]/20 text-[#fa2d48]' : 'bg-green-500/20 text-green-500'}`}>
<Activity className="w-5 h-5" />
</div>
<h2 className="text-xl md:text-2xl font-black tracking-tight">Settings</h2>
</div>
<button onClick={onClose} className="p-2 rounded-full hover:bg-white/10 transition-colors">
<X className="w-6 h-6" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-6 max-h-[70vh] overflow-y-auto no-scrollbar">
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-5 space-y-8 no-scrollbar pb-10">
{/* Appearance Section */}
<section>
<h3 className="text-sm font-semibold mb-3 text-neutral-400 uppercase tracking-wider text-xs">Appearance</h3>
<div className="flex items-center gap-2 mb-4 opacity-50">
<span className="text-[10px] font-bold uppercase tracking-[0.2em]">Design System</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Spotify Theme Option */}
<div className="grid grid-cols-1 xs:grid-cols-2 gap-4">
{/* Spotify Theme */}
<button
onClick={() => toggleTheme('spotify')}
className={`relative group p-3 rounded-xl border-2 transition-all duration-300 flex items-center gap-3 text-left ${theme === 'spotify' ? 'border-green-500 bg-[#181818]' : 'border-transparent bg-[#181818] hover:bg-[#282828]'}`}
className={`relative group p-4 rounded-2xl border-2 transition-all duration-300 flex flex-col gap-3 text-left ${theme === 'spotify' ? 'border-green-500 bg-[#1db954]/5' : 'border-transparent bg-white/5 hover:bg-white/10'}`}
>
<div className="w-10 h-10 rounded-full bg-[#121212] flex items-center justify-center border border-[#282828]">
<div className="w-5 h-5 rounded-full bg-green-500" />
<div className="flex items-center justify-between w-full">
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center shadow-lg transition-transform group-hover:scale-110 ${theme === 'spotify' ? 'bg-green-500 text-black' : 'bg-[#121212]'}`}>
<PlayCircle className="w-7 h-7" />
</div>
<div className="flex-1">
<div className="font-semibold text-base">Spotify</div>
<div className="text-xs text-neutral-400">Classic Dark Mode</div>
{theme === 'spotify' && <CheckCircle2 className="w-6 h-6 text-green-500" />}
</div>
<div>
<div className="font-bold text-lg">Spotify</div>
<div className="text-xs text-neutral-400">Classic immersive dark mode</div>
</div>
{theme === 'spotify' && <CheckCircle2 className="w-5 h-5 text-green-500" />}
</button>
{/* Apple Music Theme Option */}
{/* Apple Music Theme */}
<button
onClick={() => toggleTheme('apple')}
className={`relative group p-3 rounded-xl border-2 transition-all duration-300 flex items-center gap-3 text-left ${theme === 'apple' ? 'border-[#fa2d48] bg-[#2c2c2e]' : 'border-transparent bg-[#181818] hover:bg-[#282828]'}`}
className={`relative group p-4 rounded-2xl border-2 transition-all duration-300 flex flex-col gap-3 text-left ${theme === 'apple' ? 'border-[#fa2d48] bg-[#fa2d48]/5' : 'border-transparent bg-white/5 hover:bg-white/10'}`}
>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-[#fa2d48] to-[#5856d6] flex items-center justify-center">
<div className="w-5 h-5 text-white"></div>
<div className="flex items-center justify-between w-full">
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center shadow-lg transition-transform group-hover:scale-110 ${theme === 'apple' ? 'bg-gradient-to-br from-[#fa2d48] to-[#5856d6] text-white' : 'bg-[#121212]'}`}>
<span className="text-xl font-bold"></span>
</div>
<div className="flex-1">
<div className="font-semibold text-base">Apple Music</div>
<div className="text-xs text-neutral-400">Liquid Glass & Blur</div>
{theme === 'apple' && <CheckCircle2 className="w-6 h-6 text-[#fa2d48]" />}
</div>
<div>
<div className="font-bold text-lg">Apple Music</div>
<div className="text-xs text-neutral-400">Liquid glass & vibrant blurs</div>
</div>
{theme === 'apple' && <CheckCircle2 className="w-5 h-5 text-[#fa2d48]" />}
</button>
</div>
</section>
{/* Audio Section */}
<section>
<div className="flex items-center gap-2 mb-4 opacity-50">
<Volume2 className="w-3 h-3" />
<span className="text-[10px] font-bold uppercase tracking-[0.2em]">Audio Experience</span>
</div>
<div className={`p-5 rounded-2xl border ${isApple ? 'bg-[#2c2c2e]/50 border-white/5' : 'bg-[#181818] border-[#282828]'}`}>
<label className="block text-sm font-semibold mb-3">Audio Quality</label>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
{(['auto', 'high', 'normal', 'low'] as const).map((q) => (
<button
key={q}
onClick={() => setQualityPreference(q)}
className={`py-2.5 px-3 rounded-xl text-[10px] md:text-xs font-black capitalize transition-all ${qualityPreference === q
? (isApple ? 'bg-[#fa2d48] text-white shadow-lg shadow-[#fa2d48]/20' : 'bg-green-500 text-black')
: 'bg-white/5 hover:bg-white/10 text-neutral-400'}`}
>
{q}
</button>
))}
</div>
<p className="text-[9px] text-neutral-500 mt-4 leading-relaxed italic text-center px-2">
High quality requires a stable internet connection for seamless playback.
</p>
</div>
</section>
{/* System Section */}
<section>
<h3 className="text-sm font-semibold mb-3 text-neutral-400 uppercase tracking-wider text-xs">System</h3>
<div className={`p-4 rounded-xl border ${theme === 'apple' ? 'bg-[#2c2c2e] border-white/5' : 'bg-[#181818] border-[#282828]'}`}>
<div className="flex items-center justify-between mb-3">
<div>
<div className="font-semibold text-base flex items-center gap-2">
Core Update
<span className="text-[10px] bg-white/10 px-1.5 py-0.5 rounded text-neutral-400">yt-dlp nightly</span>
<div className="flex items-center gap-2 mb-4 opacity-50">
<Database className="w-3 h-3" />
<span className="text-[10px] font-bold uppercase tracking-[0.2em]">System & Storage</span>
</div>
<p className="text-xs text-neutral-400 mt-1">Updates the underlying download engine.</p>
<div className="space-y-3">
{/* Core Update */}
<div className={`p-4 md:p-5 rounded-2xl border flex flex-col sm:flex-row sm:items-center justify-between gap-4 ${isApple ? 'bg-[#2c2c2e]/50 border-white/5' : 'bg-[#181818] border-[#282828]'}`}>
<div className="flex-1 min-w-0">
<div className="font-bold text-sm flex items-center gap-2 mb-1">
Core Update
<span className="text-[8px] md:text-[9px] bg-white/10 px-1.5 py-0.5 rounded-full text-neutral-400 font-mono flex-shrink-0">yt-dlp nightly</span>
</div>
<p className="text-[10px] md:text-[11px] text-neutral-400">Keep the extraction engine fresh for new content.</p>
</div>
<button
onClick={handleUpdateYtdlp}
disabled={isUpdating}
className={`px-3 py-1.5 rounded-lg font-bold text-sm flex items-center gap-2 transition ${isUpdating ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105'} ${theme === 'apple' ? 'bg-[#fa2d48] text-white' : 'bg-green-500 text-black'}`}
className={`w-full sm:w-auto px-6 py-2.5 rounded-full font-black text-[10px] md:text-xs flex items-center justify-center gap-2 transition-all flex-shrink-0 ${isUpdating ? 'opacity-50 cursor-not-allowed' : 'active:scale-95'} ${isApple ? 'bg-[#fa2d48] text-white hover:bg-[#ff3b5c]' : 'bg-green-500 text-black hover:bg-[#1ed760]'}`}
>
<RefreshCcw className={`w-3.5 h-3.5 ${isUpdating ? 'animate-spin' : ''}`} />
{isUpdating ? 'Updating...' : 'Update'}
{isUpdating ? 'Executing...' : 'Update Engine'}
</button>
</div>
{/* Logs */}
{/* Clear Cache */}
<div className={`p-4 md:p-5 rounded-2xl border flex flex-col sm:flex-row sm:items-center justify-between gap-4 ${isApple ? 'bg-[#2c2c2e]/50 border-white/5' : 'bg-[#181818] border-[#282828]'}`}>
<div className="flex-1 min-w-0">
<div className="font-bold text-sm mb-1">Clear Local Cache</div>
<p className="text-[10px] md:text-[11px] text-neutral-400">Wipe browse and image caches if data feels stale.</p>
</div>
<button
onClick={handleClearCache}
disabled={isClearingCache}
className={`w-full sm:w-auto px-6 py-2.5 rounded-full font-black text-[10px] md:text-xs flex items-center justify-center gap-2 transition-all hover:bg-neutral-800 border border-white/10 flex-shrink-0 ${isClearingCache ? 'animate-pulse' : 'active:scale-95'}`}
>
<Trash2 className="w-3.5 h-3.5" />
{isClearingCache ? 'Clearing...' : 'Wipe Cache'}
</button>
</div>
</div>
{/* Logs Reveal */}
{(updateStatus !== 'idle' || updateLog) && (
<div className="mt-3 p-3 bg-black/50 rounded-lg font-mono text-[10px] text-neutral-300 max-h-24 overflow-y-auto whitespace-pre-wrap">
{updateStatus === 'loading' && <span className="text-blue-400">Executing update command...{'\n'}</span>}
{updateLog}
{updateStatus === 'success' && <span className="text-green-400">{'\n'}Done!</span>}
{updateStatus === 'error' && <span className="text-red-400">{'\n'}Error Occurred.</span>}
<div className="mt-4 p-4 bg-black/80 rounded-2xl font-mono text-[10px] text-green-500/80 border border-green-500/10 max-h-32 overflow-y-auto no-scrollbar">
<div className="mb-2 opacity-50 uppercase tracking-widest text-[8px]">Operation Log</div>
{updateLog || 'Initializing...'}
{updateStatus === 'loading' && <span className="animate-pulse ml-1 text-white">_</span>}
</div>
)}
</div>
</section>
<div className="text-center text-[10px] text-neutral-500 pt-4">
KV Spotify Clone v1.0.0
<div className="pt-6 flex flex-col items-center gap-2 opacity-30 group cursor-default">
<div className="h-px w-24 bg-white/20 group-hover:w-full transition-all duration-700" />
<span className="text-[9px] font-black uppercase tracking-[0.3em]">KV Spotify Clone v1.0.0</span>
</div>
</div>

View file

@ -1,4 +1,4 @@
import { Home, Search, Library, Plus, Heart, Settings } from "lucide-react";
import { Home, Search, Library, Plus, Heart } from "lucide-react";
import { Link, useLocation } from "react-router-dom";
import { usePlayer } from "../context/PlayerContext";
import { useLibrary } from "../context/LibraryContext";
@ -7,14 +7,11 @@ import CreatePlaylistModal from "./CreatePlaylistModal";
import { dbService } from "../services/db";
import Logo from "./Logo";
import CoverImage from "./CoverImage";
import SettingsModal from "./SettingsModal";
export default function Sidebar() {
const { likedTracks } = usePlayer();
const { userPlaylists, libraryItems, refreshLibrary, activeFilter, setActiveFilter } = useLibrary();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const location = useLocation();
const isActive = (path: string) => location.pathname === path;
@ -121,7 +118,7 @@ export default function Sidebar() {
<CoverImage
src={playlist.cover_url}
alt={playlist.title || ''}
className="w-12 h-12 rounded"
className="w-12 h-12 rounded-xl"
/>
<div className="flex-1 min-w-0">
<h3 className="text-white font-medium truncate">{playlist.title}</h3>
@ -147,7 +144,7 @@ export default function Sidebar() {
<CoverImage
src={playlist.cover_url}
alt={playlist.title || ''}
className="w-12 h-12 rounded"
className="w-12 h-12 rounded-xl"
/>
<div className="flex-1 min-w-0">
<h3 className="text-white font-medium truncate">{playlist.title}</h3>
@ -164,7 +161,7 @@ export default function Sidebar() {
<CoverImage
src={artist.cover_url}
alt={artist.title}
className="w-12 h-12 rounded-md"
className="w-12 h-12 rounded-full object-cover"
fallbackText={artist.title?.substring(0, 2).toUpperCase()}
/>
<div className="flex-1 min-w-0">
@ -182,7 +179,7 @@ export default function Sidebar() {
<CoverImage
src={album.cover_url}
alt={album.title}
className="w-12 h-12 rounded"
className="w-12 h-12 rounded-xl"
fallbackText="💿"
/>
<div className="flex-1 min-w-0">
@ -195,28 +192,11 @@ export default function Sidebar() {
</div>
</div>
{/* Settings Section */}
<div className="bg-spotify-card rounded-lg p-2 mt-auto">
<button
onClick={() => setIsSettingsOpen(true)}
className="w-full flex items-center gap-3 p-3 rounded-md transition-all duration-300 text-neutral-400 hover:text-white hover:bg-spotify-card-hover"
title="Settings"
>
<Settings className="w-5 h-5" />
<span className="text-sm font-bold">Settings</span>
</button>
</div>
<CreatePlaylistModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onCreate={handleCreatePlaylist}
/>
<SettingsModal
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
/>
</aside>
);
}

View file

@ -20,10 +20,14 @@ interface PlayerContextType {
toggleLike: (track: Track) => void;
playHistory: Track[];
audioQuality: AudioQuality | null;
qualityPreference: 'auto' | 'high' | 'normal' | 'low';
setQualityPreference: (quality: 'auto' | 'high' | 'normal' | 'low') => void;
isLyricsOpen: boolean;
toggleLyrics: () => void;
closeLyrics: () => void;
openLyrics: () => void;
isFullScreenOpen: boolean;
setIsFullScreenOpen: (open: boolean) => void;
queue: Track[];
}
@ -38,6 +42,13 @@ export function PlayerProvider({ children }: { children: ReactNode }) {
// Audio Engine State
const [audioQuality, setAudioQuality] = useState<AudioQuality | null>(null);
const [qualityPreference, setQualityPreference] = useState<'auto' | 'high' | 'normal' | 'low'>(() => {
return (localStorage.getItem('audio_quality_pref') as any) || 'auto';
});
useEffect(() => {
localStorage.setItem('audio_quality_pref', qualityPreference);
}, [qualityPreference]);
// Queue State
const [queue, setQueue] = useState<Track[]>([]);
@ -54,6 +65,9 @@ export function PlayerProvider({ children }: { children: ReactNode }) {
const closeLyrics = () => setIsLyricsOpen(false);
const openLyrics = () => setIsLyricsOpen(true);
// Full Screen Player State
const [isFullScreenOpen, setIsFullScreenOpen] = useState(false);
// Load Likes from DB
useEffect(() => {
dbService.getLikedSongs().then(tracks => {
@ -200,10 +214,14 @@ export function PlayerProvider({ children }: { children: ReactNode }) {
toggleLike,
playHistory,
audioQuality,
qualityPreference,
setQualityPreference,
isLyricsOpen,
toggleLyrics,
closeLyrics,
openLyrics,
isFullScreenOpen,
setIsFullScreenOpen,
queue
}}>
{children}

View file

@ -1,37 +1,68 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useParams, Link } from 'react-router-dom';
import { libraryService } from '../services/library';
import { usePlayer } from '../context/PlayerContext';
import { Play, Shuffle, Heart, Clock, ListPlus, Download } from 'lucide-react';
import { Track } from '../types';
import Recommendations from '../components/Recommendations';
export default function Album() {
const { id } = useParams();
const { playTrack, toggleLike, likedTracks } = usePlayer();
const { playTrack, toggleLike, likedTracks, setIsFullScreenOpen, currentTrack } = usePlayer();
const [tracks, setTracks] = useState<Track[]>([]);
const [albumInfo, setAlbumInfo] = useState<{ title: string, artist: string, cover?: string, year?: string } | null>(null);
const [moreByArtist, setMoreByArtist] = useState<Track[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) return;
setLoading(true);
// If ID is from YTM, ideally we fetch album.
// If logic is "Search Album", we do that.
const fetchAlbum = async () => {
// For now, assume ID is search query or we query "Album"
// In this reskin, we usually pass Name as ID due to router setup in Home.
const query = decodeURIComponent(id);
const queryId = decodeURIComponent(id);
try {
const results = await libraryService.search(query);
if (results.length > 0) {
const album = await libraryService.getAlbum(queryId);
if (album) {
// Normalize track IDs - extract YouTube video ID from discovery-* IDs
const normalizedTracks = album.tracks.map((track) => {
let videoId = track.id;
if (track.id.includes('discovery-') || track.id.includes('artist-')) {
const parts = track.id.split('-');
for (const part of parts) {
if (part.length === 11 && /^[a-zA-Z0-9_-]+$/.test(part)) {
videoId = part;
break;
}
}
}
return { ...track, id: videoId, url: `/api/stream/${videoId}` };
});
setTracks(normalizedTracks);
setAlbumInfo({
title: album.title,
artist: album.creator || "Unknown Artist",
cover: album.cover_url,
year: '2024'
});
// Fetch suggestions
try {
const artistQuery = album.creator || "Unknown Artist";
const suggestions = await libraryService.search(artistQuery);
const currentIds = new Set(normalizedTracks.map(t => t.id));
setMoreByArtist(suggestions.filter(t => !currentIds.has(t.id)).slice(0, 10));
} catch (e) { }
} else {
// Fallback to searching the query if ID not found anywhere
const cleanTitle = queryId.replace(/^search-|^album-/, '');
const results = await libraryService.search(queryId);
setTracks(results);
setAlbumInfo({
title: query.replace(/^search-|^album-/, '').replace(/-/g, ' '), // Clean up slug
artist: results[0].artist,
cover: results[0].cover_url,
year: '2024' // Mock or fetch
title: cleanTitle,
artist: results.length > 0 ? results[0].artist : "Unknown Artist",
cover: results.length > 0 ? results[0].cover_url : undefined,
year: '2024'
});
}
} catch (e) {
@ -49,17 +80,42 @@ export default function Album() {
const formattedDuration = `${Math.floor(totalDuration / 60)} minutes`;
return (
<div className="flex-1 overflow-y-auto bg-gradient-to-b from-[#2e2e2e] to-black pb-32">
<div className="flex flex-col md:flex-row gap-4 md:gap-8 p-4 md:p-12 items-center md:items-end bg-gradient-to-b from-black/20 to-black/60 pt-16 md:pt-12">
<div className="flex-1 overflow-y-auto bg-[#121212] no-scrollbar pb-32 relative">
{/* Banner Background */}
{albumInfo.cover && (
<div
className="absolute top-0 left-0 w-full h-[50vh] min-h-[400px] opacity-30 pointer-events-none"
style={{
backgroundImage: `url(${albumInfo.cover})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
maskImage: 'linear-gradient(to bottom, black 0%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, transparent 100%)'
}}
/>
)}
<div className="relative z-10 flex flex-col md:flex-row gap-4 md:gap-8 p-4 md:p-12 items-center md:items-end pt-16 md:pt-16">
{/* Cover */}
<div className="w-40 h-40 md:w-64 md:h-64 shadow-[0_20px_50px_rgba(0,0,0,0.5)] rounded-lg overflow-hidden shrink-0">
<img src={albumInfo.cover} alt={albumInfo.title} className="w-full h-full object-cover" />
<div
className="w-48 h-48 md:w-64 md:h-64 shadow-[0_20px_50px_rgba(0,0,0,0.5)] rounded-2xl overflow-hidden shrink-0 mt-8 md:mt-0 cursor-pointer group/cover relative"
onClick={() => {
if (tracks.length > 0) {
playTrack(tracks[0], tracks);
setIsFullScreenOpen(true);
}
}}
>
<img src={albumInfo.cover} alt={albumInfo.title} className="w-full h-full object-cover transition-transform duration-700 group-hover/cover:scale-110" />
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover/cover:opacity-100 transition flex items-center justify-center">
<Play fill="white" size={48} className="text-white drop-shadow-2xl" />
</div>
</div>
{/* Info */}
<div className="flex flex-col items-center md:items-start text-center md:text-left gap-2 md:gap-4 flex-1">
<span className="text-xs md:text-sm font-bold tracking-widest uppercase text-white/70">Album</span>
<h1 className="text-2xl md:text-6xl font-black text-white leading-tight">{albumInfo.title}</h1>
<h1 className="text-2xl md:text-6xl font-black text-white leading-tight line-clamp-3 text-ellipsis overflow-hidden">{albumInfo.title}</h1>
<div className="flex flex-wrap justify-center md:justify-start items-center gap-2 text-white/80 font-medium text-sm md:text-base">
<img src={albumInfo.cover} className="w-6 h-6 rounded-full" />
<span className="hover:underline cursor-pointer">{albumInfo.artist}</span>
@ -131,6 +187,53 @@ export default function Album() {
))}
</div>
</div>
{/* Suggestions / More By Artist */}
{moreByArtist.length > 0 && (
<div className="p-4 md:p-8 mt-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold hover:underline cursor-pointer">More by {albumInfo.artist}</h2>
<Link to={`/artist/${encodeURIComponent(albumInfo.artist)}`}>
<span className="text-xs font-bold text-[#b3b3b3] uppercase tracking-wider hover:text-white cursor-pointer">Show discography</span>
</Link>
</div>
<div className="grid grid-cols-2 fold:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-4">
{moreByArtist.map((track) => (
<div
className="bg-[#181818] p-3 md:p-4 rounded-xl hover:bg-[#282828] transition duration-300 group cursor-pointer relative flex flex-col"
key={track.id}
onClick={() => {
playTrack(track, moreByArtist);
}}
>
<div className="relative mb-3 md:mb-4">
<img src={track.cover_url} className="w-full aspect-square rounded-2xl shadow-lg object-cover" />
<div className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-10 h-10 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</div>
</div>
</div>
<h3 className="font-bold text-sm md:text-base mb-1 truncate">{track.title}</h3>
<p className="text-xs md:text-sm text-[#a7a7a7] truncate">{track.artist}</p>
</div>
))}
</div>
</div>
)}
{/* Related Content Recommendations */}
{albumInfo && (
<Recommendations
seed={albumInfo.artist}
seedType="album"
limit={10}
title="You might also like"
showTracks={true}
showAlbums={true}
showPlaylists={true}
/>
)}
</div>
);
}

View file

@ -4,6 +4,7 @@ import { libraryService } from '../services/library';
import { usePlayer } from '../context/PlayerContext';
import { Play, Shuffle, Heart, Disc, Music } from 'lucide-react';
import { Track } from '../types';
import Recommendations from '../components/Recommendations';
import { GENERATED_CONTENT } from '../data/seed_data';
interface ArtistData {
@ -17,10 +18,11 @@ interface ArtistData {
export default function Artist() {
const { id } = useParams(); // Start with name or id
const navigate = useNavigate();
const { playTrack, toggleLike, likedTracks } = usePlayer();
const { playTrack, toggleLike, likedTracks, setIsFullScreenOpen } = usePlayer();
const [artist, setArtist] = useState<ArtistData | null>(null);
const [loading, setLoading] = useState(true);
const [songsLoading, setSongsLoading] = useState(true);
// YouTube Music uses name-based IDs or channel IDs.
// Our 'id' might be a name if clicked from Home.
@ -52,6 +54,7 @@ export default function Artist() {
}
const fetchData = async () => {
setSongsLoading(true);
// Fetch info (Background)
// If we already have photo from seed, maybe skip or update?
// libraryService.getArtistInfo might find a better photo or same.
@ -62,14 +65,19 @@ export default function Artist() {
libraryService.search(artistName)
]);
setSongsLoading(false);
const finalPhoto = (info.status === 'fulfilled' && info.value?.photo) ? info.value.photo : seedArtist?.cover_url;
// Ensure we always have a photo - if somehow still empty, use UI-Avatars
const safePhoto = finalPhoto || `https://ui-avatars.com/api/?name=${encodeURIComponent(artistName)}&background=random&color=fff&size=200&rounded=true&bold=true&font-size=0.33`;
let topSongs = (songs.status === 'fulfilled') ? songs.value : [];
if (topSongs.length > 5) topSongs = topSongs.slice(0, 5);
if (topSongs.length > 20) topSongs = topSongs.slice(0, 20);
setArtist({
name: artistName,
photo: finalPhoto,
photo: safePhoto,
topSongs,
albums: [],
singles: []
@ -104,7 +112,11 @@ export default function Artist() {
<div className="flex items-center gap-4">
<button
onClick={() => artist.topSongs.length > 0 && playTrack(artist.topSongs[0])}
onClick={() => {
if (artist.topSongs.length > 0) {
playTrack(artist.topSongs[0], artist.topSongs);
}
}}
className="bg-white text-black px-8 py-3 rounded-full font-bold text-lg hover:scale-105 transition flex items-center gap-2"
>
<Play fill="currentColor" size={20} />
@ -127,7 +139,7 @@ export default function Artist() {
<section>
<h2 className="text-2xl font-bold mb-6">Top Songs</h2>
<div className="flex flex-col gap-2">
{artist.topSongs.length === 0 ? (
{songsLoading ? (
// Skeleton Loading for Songs
[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center p-3 gap-4 animate-pulse">
@ -149,7 +161,7 @@ export default function Artist() {
<span className="w-8 text-center text-neutral-500 font-medium group-hover:hidden">{i + 1}</span>
<Play size={16} className="w-8 hidden group-hover:block fill-white" />
<img src={track.cover_url} alt="Cover" className="w-12 h-12 rounded mx-4 object-cover" />
<img src={track.cover_url} alt="Cover" className="w-12 h-12 rounded-lg mx-4 object-cover" />
<div className="flex-1 min-w-0">
<div className="font-medium text-white truncate">{track.title}</div>
@ -183,9 +195,11 @@ export default function Artist() {
<div
key={track.id}
className="group cursor-pointer"
onClick={() => playTrack(track, [track])}
onClick={() => {
playTrack(track, [track]);
}}
>
<div className="aspect-square bg-neutral-900 rounded-lg overflow-hidden mb-3 relative">
<div className="aspect-square bg-neutral-900 rounded-2xl overflow-hidden mb-3 relative">
<img src={track.cover_url} className="w-full h-full object-cover transition duration-300 group-hover:scale-105" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition flex items-center justify-center">
<div className="bg-white text-black p-3 rounded-full hover:scale-110 transition">
@ -211,9 +225,11 @@ export default function Artist() {
<div
key={track.id}
className="group cursor-pointer"
onClick={() => playTrack(track, [track])}
onClick={() => {
playTrack(track, [track]);
}}
>
<div className="aspect-square bg-neutral-900 rounded-xl overflow-hidden mb-3 relative border-2 border-neutral-800">
<div className="aspect-square bg-neutral-900 rounded-2xl overflow-hidden mb-3 relative border-2 border-neutral-800">
<img src={track.cover_url} className="w-full h-full object-cover transition duration-300 group-hover:scale-105" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition flex items-center justify-center">
<div className="bg-white text-black p-3 rounded-full hover:scale-110 transition">
@ -230,6 +246,18 @@ export default function Artist() {
))}
</div>
</section>
{/* Related Artists & Content */}
<Recommendations
seed={artist.name}
seedType="artist"
limit={20}
title="Fans also like"
showTracks={true}
showAlbums={true}
showPlaylists={true}
showArtists={true}
/>
</div>
</div>
);

View file

@ -15,7 +15,8 @@ export default function Home() {
const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState<SortOption>('recent');
const [showSortMenu, setShowSortMenu] = useState(false);
const { playTrack, playHistory } = usePlayer();
const [heroPlaylist, setHeroPlaylist] = useState<StaticPlaylist | null>(null);
const { playTrack, playHistory, setIsFullScreenOpen, currentTrack } = usePlayer();
useEffect(() => {
const hour = new Date().getHours();
@ -24,24 +25,40 @@ export default function Home() {
else setTimeOfDay("Good evening");
// Cache First Strategy for "Super Fast" loading
const cached = localStorage.getItem('ytm_browse_cache_v4');
const cached = localStorage.getItem('ytm_browse_cache_v8');
if (cached) {
setBrowseData(JSON.parse(cached));
setLoading(false);
}
const fetchBrowseData = () => {
setLoading(true);
libraryService.getBrowseContent()
.then(data => {
setBrowseData(data);
setLoading(false);
// Update Cache
localStorage.setItem('ytm_browse_cache_v4', JSON.stringify(data));
localStorage.setItem('ytm_browse_cache_v8', JSON.stringify(data));
// Pick a random playlist for the hero section
const allPlaylists = Object.values(data).flat().filter(p => p.type === 'Playlist');
if (allPlaylists.length > 0) {
const randomIdx = Math.floor(Math.random() * allPlaylists.length);
setHeroPlaylist(allPlaylists[randomIdx]);
}
})
.catch(err => {
console.error("Error fetching browse:", err);
setLoading(false);
});
};
fetchBrowseData();
// Auto-refresh every 5 minutes
const refreshInterval = setInterval(fetchBrowseData, 300000);
return () => clearInterval(refreshInterval);
}, []);
const sortPlaylists = (playlists: StaticPlaylist[]) => {
@ -59,9 +76,6 @@ export default function Home() {
}
};
const firstCategory = Object.keys(browseData)[0];
const heroPlaylist = firstCategory && browseData[firstCategory].length > 0 ? browseData[firstCategory][0] : null;
const sortOptions = [
{ value: 'recent', label: 'Recently Added', icon: Clock },
{ value: 'alpha-asc', label: 'Alphabetical (A-Z)', icon: ArrowUpDown },
@ -129,9 +143,9 @@ export default function Home() {
fallbackText="VB"
/>
</div>
<div className="flex flex-col text-center md:text-left">
<div className="flex flex-col text-center md:text-left overflow-hidden">
<span className="text-xs font-bold tracking-wider uppercase mb-2">Featured Playlist</span>
<h2 className="text-3xl md:text-5xl font-black mb-4 leading-tight">{heroPlaylist.title}</h2>
<h2 className="text-3xl md:text-5xl font-black mb-4 leading-tight line-clamp-2 md:line-clamp-3" title={heroPlaylist.title}>{heroPlaylist.title}</h2>
<p className="text-[#a7a7a7] text-sm md:text-base line-clamp-2 md:line-clamp-3 max-w-2xl mb-6">
{heroPlaylist.description}
</p>
@ -166,23 +180,37 @@ export default function Home() {
))}
</div>
</div>
) : browseData["Top Albums"] && browseData["Top Albums"].length > 0 && (
) : browseData["Top Albums"] && browseData["Top Albums"].length > 0 && (() => {
const seen = new Set<string>();
const uniqueAlbums = (browseData["Top Albums"] as any[]).filter(a => {
if (seen.has(a.id)) return false;
seen.add(a.id);
return true;
});
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold capitalize hover:underline cursor-pointer">Top Albums</h2>
</div>
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-4">
{browseData["Top Albums"].slice(0, 15).map((album) => (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1 md:gap-2">
{uniqueAlbums.slice(0, 15).map((album) => (
<Link to={`/album/${album.id}`} key={album.id}>
<div className="bg-transparent md:bg-spotify-card p-0 md:p-3 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-2 md:mb-3">
<CoverImage
src={album.cover_url}
alt={album.title}
className="w-full aspect-square rounded-xl shadow-lg"
className="w-full aspect-square rounded-2xl shadow-lg"
fallbackText={album.title?.substring(0, 2).toUpperCase()}
/>
<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
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
playTrack(album as any);
}}
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 cursor-pointer"
>
<div className="w-8 h-8 md:w-10 md:h-10 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-5 md:h-5" />
</div>
@ -195,7 +223,8 @@ export default function Home() {
))}
</div>
</div>
)}
);
})()}
{/* Browse Lists */}
{loading ? (
@ -217,8 +246,15 @@ export default function Home() {
</div>
) : Object.keys(browseData).length > 0 ? (
Object.entries(browseData)
.filter(([category]) => category !== "Top Albums") // Filter out albums since we showed them above
.map(([category, playlists]) => (
.filter(([category, items]) => category !== "Top Albums" && (items as any[]).length > 0)
.map(([category, playlists]) => {
const seen = new Set<string>();
const uniquePlaylists = (playlists as any[]).filter(p => {
if (seen.has(p.id)) return false;
seen.add(p.id);
return true;
});
return (
<div key={category} className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold capitalize hover:underline cursor-pointer">{category}</h2>
@ -228,18 +264,25 @@ export default function Home() {
</div>
{/* USER REQUEST: Bigger Grid, Smaller Text, Smaller Gap */}
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-6">
{sortPlaylists(playlists).slice(0, 15).map((playlist) => (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1 md:gap-2">
{sortPlaylists(uniquePlaylists).slice(0, 15).map((playlist) => (
<Link to={`/playlist/${playlist.id}`} key={playlist.id}>
<div className="bg-transparent md:bg-spotify-card p-0 md:p-4 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-2 md:mb-4">
<CoverImage
src={playlist.cover_url}
alt={playlist.title}
className="w-full aspect-square rounded-xl shadow-lg"
className="w-full aspect-square rounded-2xl shadow-lg"
fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
/>
<div className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
playTrack(playlist as any);
}}
className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl cursor-pointer"
>
<div className="w-8 h-8 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</div>
@ -252,7 +295,8 @@ export default function Home() {
))}
</div>
</div>
))
);
})
) : (
<div className="text-center py-20">
<h2 className="text-xl font-bold mb-4">Ready to explore?</h2>
@ -265,6 +309,7 @@ export default function Home() {
// Recently Listened Section
function RecentlyListenedSection({ playHistory, playTrack }: { playHistory: Track[], playTrack: (track: Track, queue?: Track[]) => void }) {
const { setIsFullScreenOpen, currentTrack } = usePlayer();
if (playHistory.length === 0) return null;
return (
@ -278,14 +323,16 @@ function RecentlyListenedSection({ playHistory, playTrack }: { playHistory: Trac
{playHistory.slice(0, 10).map((track, i) => (
<div
key={`${track.id}-${i}`}
onClick={() => playTrack(track, playHistory)}
onClick={() => {
playTrack(track, playHistory);
}}
className="flex-shrink-0 w-40 bg-spotify-card rounded-xl overflow-hidden hover:bg-spotify-card-hover transition duration-300 group cursor-pointer"
>
<div className="relative">
<CoverImage
src={track.cover_url}
alt={track.title}
className="w-40 h-40"
className="w-40 h-40 rounded-2xl"
fallbackText={track.title?.substring(0, 2).toUpperCase()}
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition flex items-center justify-center">
@ -307,7 +354,7 @@ function RecentlyListenedSection({ playHistory, playTrack }: { playHistory: Trac
// Made For You Section
function MadeForYouSection() {
const { playHistory, playTrack } = usePlayer();
const { playHistory, playTrack, setIsFullScreenOpen, currentTrack } = usePlayer();
const [recommendations, setRecommendations] = useState<Track[]>([]);
const [seedTrack, setSeedTrack] = useState<Track | null>(null);
const [loading, setLoading] = useState(false);
@ -351,14 +398,16 @@ function MadeForYouSection() {
))}
</div>
) : (
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-6">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1 md:gap-2">
{recommendations.slice(0, 10).map((track, i) => (
<div key={i} onClick={() => playTrack(track, recommendations)} className="bg-transparent md:bg-spotify-card p-0 md:p-4 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div key={i} onClick={() => {
playTrack(track, recommendations);
}} className="bg-transparent md:bg-spotify-card p-0 md:p-4 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-2 md:mb-4">
<CoverImage
src={track.cover_url}
alt={track.title}
className="w-full aspect-square rounded-xl shadow-lg"
className="w-full aspect-square rounded-2xl shadow-lg"
fallbackText={track.title?.substring(0, 2).toUpperCase()}
/>
<div className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
@ -417,35 +466,50 @@ function ArtistVietnamSection() {
// 2. Load Photos (Cache First Strategy)
const loadPhotos = async () => {
// v3: Progressive Loading + Smaller Thumbnails
const cacheKey = 'artist_photos_cache_v3';
// v5: Force refresh for authentic channel avatars
const cacheKey = 'artist_photos_cache_v6';
const cached = JSON.parse(localStorage.getItem(cacheKey) || '{}');
// Initialize with cache immediately
// Apply cache to state first
setArtistPhotos(cached);
setLoading(false); // Show names immediately
// Identify missing photos
const missing = targetArtists.filter(name => !cached[name]);
if (missing.length > 0) {
// Fetch missing incrementally
for (const name of missing) {
// Fetch all missing photos in parallel
const fetchPromises = missing.map(async (name) => {
try {
// Fetch one by one and update state immediately
// This prevents "batch waiting" feeling
libraryService.getArtistInfo(name).then(data => {
const data = await libraryService.getArtistInfo(name);
if (data.photo) {
return { name, photo: data.photo };
}
} catch { /* ignore */ }
return null;
});
// Wait for ALL fetches to complete
const results = await Promise.all(fetchPromises);
// Update state with all results at once
const updates: Record<string, string> = {};
results.forEach(result => {
if (result) {
updates[result.name] = result.photo;
}
});
if (Object.keys(updates).length > 0) {
setArtistPhotos(prev => {
const next: Record<string, string> = { ...prev, [name]: data.photo || "" };
const next: Record<string, string> = { ...prev, ...updates };
localStorage.setItem(cacheKey, JSON.stringify(next));
return next;
});
}
});
} catch { /* ignore */ }
}
}
// Only set loading false AFTER all photos are loaded
setLoading(false);
};
loadPhotos();
@ -459,11 +523,11 @@ function ArtistVietnamSection() {
</div>
<p className="text-sm text-[#a7a7a7] mb-4">Based on your recent listening</p>
<div className="flex gap-4 overflow-x-auto pb-4 no-scrollbar">
<div className="flex gap-3 overflow-x-auto pb-4 no-scrollbar">
{artists.length === 0 && loading ? (
[1, 2, 3, 4, 5, 6].map(i => (
<div key={i} className="flex-shrink-0 w-36 text-center space-y-3">
<Skeleton className="w-36 h-36 rounded-xl" />
<Skeleton className="w-36 h-36 rounded-full" />
<Skeleton className="h-4 w-3/4 mx-auto" />
</div>
))
@ -475,12 +539,12 @@ function ArtistVietnamSection() {
<CoverImage
src={artistPhotos[name]}
alt={name}
className="w-36 h-36 rounded-xl shadow-lg group-hover:shadow-xl transition object-cover"
className="w-36 h-36 rounded-full shadow-lg group-hover:shadow-xl transition object-cover"
fallbackText={name.substring(0, 2).toUpperCase()}
/>
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition rounded-xl flex items-center justify-center">
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center shadow-lg transform scale-90 group-hover:scale-100 transition">
<Play className="fill-black text-black ml-1 w-5 h-5" />
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition rounded-full flex items-center justify-center">
<div className="w-10 h-10 bg-[#1DB954] rounded-full flex items-center justify-center shadow-lg transform scale-90 group-hover:scale-100 transition">
<Play className="fill-black text-black ml-0.5 w-4 h-4" />
</div>
</div>
</div>

View file

@ -144,7 +144,7 @@ export default function Library() {
<CoverImage
src={playlist.cover_url}
alt={playlist.title}
className="w-full aspect-square rounded-xl shadow-lg"
className="w-full aspect-square rounded-2xl shadow-lg"
fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
/>
<div className="absolute bottom-2 right-2 translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition">
@ -187,7 +187,7 @@ export default function Library() {
<CoverImage
src={item.cover_url}
alt={item.title}
className={`w-full aspect-square shadow-lg rounded-xl`}
className={`w-full aspect-square shadow-lg rounded-2xl`}
fallbackText={item.title?.substring(0, 2).toUpperCase()}
/>
<div className="absolute bottom-2 right-2 translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition">

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { Play, Pause, Clock, Heart, PlusCircle, Shuffle, Trash2, ArrowLeft } from 'lucide-react';
import { usePlayer } from '../context/PlayerContext';
import { useLibrary } from '../context/LibraryContext';
@ -9,19 +9,22 @@ import { Track, StaticPlaylist } from '../types';
import CoverImage from '../components/CoverImage';
import AddToPlaylistModal from '../components/AddToPlaylistModal';
import Skeleton from '../components/Skeleton';
import Recommendations from '../components/Recommendations';
import { GENERATED_CONTENT } from '../data/seed_data';
type PlaylistData = PlaylistType | StaticPlaylist;
export default function Playlist() {
const { id: playlistId } = useParams<{ id: string }>();
const navigate = useNavigate();
const [playlist, setPlaylist] = useState<PlaylistData | null>(null);
const [loading, setLoading] = useState(true); // Full page loading
const [loadingTracks, setLoadingTracks] = useState(false); // background track loading
const [selectedTrack, setSelectedTrack] = useState<Track | null>(null);
const [isUserPlaylist, setIsUserPlaylist] = useState(false);
const [moreLikeThis, setMoreLikeThis] = useState<Track[]>([]);
const { playTrack, currentTrack, isPlaying, togglePlay, likedTracks, toggleLike } = usePlayer();
const { playTrack, currentTrack, isPlaying, togglePlay, likedTracks, toggleLike, setIsFullScreenOpen } = usePlayer();
const { libraryItems, userPlaylists, refreshLibrary } = useLibrary();
useEffect(() => {
@ -74,15 +77,50 @@ export default function Playlist() {
setIsUserPlaylist(true);
setLoading(false);
setLoadingTracks(false);
// Fetch suggestions for user playlists too
try {
const recs = await libraryService.search(dbPlaylist.title);
setMoreLikeThis(recs.slice(0, 10));
} catch (e) { }
} else {
// Check API / Library Service (Hydration happens here)
console.log("Fetching from Library Service (Hydrating)...");
const apiPlaylist = await libraryService.getPlaylist(playlistId);
if (apiPlaylist) {
setPlaylist(apiPlaylist);
setIsUserPlaylist(false);
if (apiPlaylist && apiPlaylist.tracks.length > 0) {
// Normalize track IDs - extract YouTube video ID from discovery-* IDs
const normalizedTracks = apiPlaylist.tracks.map((track: Track) => {
let videoId = track.id;
// If ID contains "discovery-" or "artist-", extract the YouTube video ID
if (track.id.includes('discovery-') || track.id.includes('artist-')) {
const parts = track.id.split('-');
// Find 11-char YouTube ID
for (const part of parts) {
if (part.length === 11 && /^[a-zA-Z0-9_-]+$/.test(part)) {
videoId = part;
break;
}
}
}
return { ...track, id: videoId, url: `/api/stream/${videoId}` };
});
const normalizedPlaylist = { ...apiPlaylist, tracks: normalizedTracks };
setPlaylist(normalizedPlaylist);
setIsUserPlaylist(false);
setLoading(false);
// Fetch suggestions
try {
const query = apiPlaylist.title.replace(' Mix', '');
const recs = await libraryService.search(query);
const currentIds = new Set(normalizedTracks.map((t: Track) => t.id));
setMoreLikeThis(recs.filter(t => !currentIds.has(t.id)).slice(0, 10));
} catch (e) { }
} else {
// Hydration failed or found no tracks - redirect home to avoid broken page
console.warn("Hydration failed for", playlistId);
navigate('/', { replace: true });
}
setLoadingTracks(false);
}
} catch (e) {
@ -105,7 +143,7 @@ export default function Playlist() {
if (!playlist || !isUserPlaylist) return;
await dbService.removeFromPlaylist(playlist.id, trackId);
await refreshLibrary();
setPlaylist(prev => prev ? { ...prev, tracks: prev.tracks.filter(t => t.id !== trackId) } : null);
setPlaylist((prev: PlaylistData | null) => prev ? { ...prev, tracks: prev.tracks.filter((t: Track) => t.id !== trackId) } : null);
};
const formatDuration = (seconds?: number) => {
@ -115,7 +153,7 @@ export default function Playlist() {
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const totalDuration = playlist?.tracks.reduce((acc, t) => acc + (t.duration || 0), 0) || 0;
const totalDuration = playlist?.tracks.reduce((acc: number, t: Track) => acc + (t.duration || 0), 0) || 0;
// FULL PAGE SPINNER (Only if we have NO metadata at all)
if (loading) {
@ -145,29 +183,63 @@ export default function Playlist() {
}
return (
<div className="h-full overflow-y-auto no-scrollbar pb-24">
{/* Hero Header (Always visible if playlist exists) */}
<div className="h-auto md:h-80 bg-gradient-to-b from-[#535353] to-[#121212] p-4 md:p-8 flex flex-col md:flex-row items-center md:items-end animate-in fade-in duration-500 relative">
<div className="flex-1 overflow-y-auto bg-[#121212] no-scrollbar pb-32 relative">
{/* Banner Background */}
{playlist.cover_url && (
<div
className="absolute top-0 left-0 w-full h-[50vh] min-h-[400px] opacity-30 pointer-events-none"
style={{
backgroundImage: `url(${playlist.cover_url})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
maskImage: 'linear-gradient(to bottom, black 0%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, transparent 100%)'
}}
/>
)}
{/* Hero Header */}
<div className="relative z-10 flex flex-col md:flex-row gap-4 md:gap-8 p-4 md:p-12 items-center md:items-end pt-16 md:pt-16">
<Link to="/library" className="absolute top-4 left-4 md:hidden">
<ArrowLeft className="w-6 h-6" />
</Link>
<div
className="w-48 h-48 md:w-64 md:h-64 shadow-[0_20px_50px_rgba(0,0,0,0.5)] rounded-2xl overflow-hidden shrink-0 mt-8 md:mt-0 cursor-pointer group/cover relative"
onClick={() => {
if (playlist && playlist.tracks.length > 0) {
playTrack(playlist.tracks[0], playlist.tracks);
setIsFullScreenOpen(true);
}
}}
>
<CoverImage
src={playlist.cover_url ?? undefined}
alt={playlist.title}
className="w-40 h-40 md:w-56 md:h-56 rounded-md shadow-2xl mb-4 md:mb-0 md:mr-8 mt-8 md:mt-0"
className="w-full h-full object-cover transition-transform duration-700 group-hover/cover:scale-110"
fallbackText={playlist.title.substring(0, 2).toUpperCase()}
/>
<div className="text-center md:text-left">
<p className="text-xs font-bold uppercase tracking-wider mb-1">Playlist</p>
<h1 className="text-2xl md:text-6xl font-black mb-2 md:mb-4 line-clamp-2 leading-tight">{playlist.title}</h1>
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover/cover:opacity-100 transition flex items-center justify-center">
<Play fill="white" size={48} className="text-white drop-shadow-2xl" />
</div>
</div>
<div className="flex flex-col items-center md:items-start text-center md:text-left gap-2 md:gap-4 flex-1">
<span className="text-xs md:text-sm font-bold tracking-widest uppercase text-white/70">Playlist</span>
<h1 className="text-2xl md:text-6xl font-black text-white leading-tight line-clamp-2">{playlist.title}</h1>
<div className="flex flex-wrap justify-center md:justify-start items-center gap-2 text-white/80 font-medium text-sm md:text-base">
{'description' in playlist && playlist.description && (
<p className="text-sm text-neutral-300 mb-2 line-clamp-2">{playlist.description}</p>
<span className="text-neutral-300">{playlist.description}</span>
)}
<p className="text-sm text-neutral-400">
{/* Show 'Loading...' if caching tracks, otherwise count */}
<span></span>
<span className="text-white">
{loadingTracks ? 'Updating...' : `${playlist.tracks.length} songs`}
{totalDuration > 0 && `${Math.floor(totalDuration / 60)} min`}
</p>
</span>
{totalDuration > 0 && (
<>
<span></span>
<span>{Math.floor(totalDuration / 60)} min</span>
</>
)}
</div>
</div>
</div>
@ -252,7 +324,7 @@ export default function Playlist() {
<CoverImage
src={track.cover_url}
alt={track.title}
className="w-10 h-10 rounded flex-shrink-0"
className="w-10 h-10 rounded-lg flex-shrink-0"
fallbackText="♪"
/>
<div className="min-w-0">
@ -296,6 +368,50 @@ export default function Playlist() {
)}
</div>
{/* Suggestions / More like this */}
{moreLikeThis.length > 0 && (
<div className="p-4 md:p-8 mt-4 relative z-10">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold hover:underline cursor-pointer">More like this</h2>
</div>
<div className="grid grid-cols-2 fold:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-4">
{moreLikeThis.map((track) => (
<div
className="bg-[#181818] p-3 md:p-4 rounded-xl hover:bg-[#282828] transition duration-300 group cursor-pointer relative flex flex-col"
key={track.id}
onClick={() => {
playTrack(track, moreLikeThis);
}}
>
<div className="relative mb-3 md:mb-4">
<img src={track.cover_url} className="w-full aspect-square rounded-2xl shadow-lg object-cover" />
<div className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-10 h-10 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</div>
</div>
</div>
<h3 className="font-bold text-sm md:text-base mb-1 truncate">{track.title}</h3>
<p className="text-xs md:text-sm text-[#a7a7a7] truncate">{track.artist}</p>
</div>
))}
</div>
</div>
)}
{/* Related Content Recommendations */}
{playlist && (
<Recommendations
seed={playlist.title}
seedType="playlist"
limit={10}
title="Related Playlists & Songs"
showTracks={true}
showAlbums={true}
showPlaylists={true}
/>
)}
{/* Add to Playlist Modal */}
{selectedTrack && (
<AddToPlaylistModal

View file

@ -183,7 +183,7 @@ export default function Search() {
{relatedAlbums.map(album => (
<Link to={`/album/search-${encodeURIComponent(album.name)}`} key={album.id} className="flex-shrink-0 w-32 md:w-40 group">
<div className="relative mb-2">
<CoverImage src={album.image} alt={album.name} className="w-32 h-32 md:w-40 md:h-40 rounded-md shadow-lg object-cover group-hover:shadow-xl transition" fallbackText={album.name[0]} />
<CoverImage src={album.image} alt={album.name} className="w-32 h-32 md:w-40 md:h-40 rounded-2xl shadow-lg object-cover group-hover:shadow-xl transition" fallbackText={album.name[0]} />
</div>
<p className="font-bold truncate text-[11px] md:text-base">{album.name}</p>
<p className="text-[10px] md:text-sm text-[#a7a7a7]">Album</p>
@ -207,7 +207,7 @@ export default function Search() {
<div className="w-8 hidden group-hover:flex items-center justify-center text-white">
<Play className="w-4 h-4 fill-current" />
</div>
<CoverImage src={track.cover_url} alt={track.title} className="w-10 h-10 rounded" fallbackText="♪" />
<CoverImage src={track.cover_url} alt={track.title} className="w-10 h-10 rounded-lg" fallbackText="♪" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.title}</p>
<p className="text-sm text-neutral-400 truncate">{track.artist}</p>

View file

@ -57,7 +57,7 @@ export default function Section() {
<CoverImage
src={playlist.cover_url}
alt={playlist.title}
className="w-full aspect-square rounded-md shadow-lg"
className="w-full aspect-square rounded-2xl shadow-lg"
fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
/>
<div className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">

View file

@ -68,14 +68,14 @@ export const dbService = {
// ALWAYS use fallback if API returned 0 tracks
if (allTracks.length === 0) {
console.log("Using Mock Data for DB Seeding");
const highResTracks = [
allTracks = [
{
id: "fb-1",
title: "Shape of You (Demo)",
artist: "Ed Sheeran",
album: "Divide",
duration: 233,
cover_url: "https://images.unsplash.com/photo-1614613535308-eb5fbd3d2c17?w=800&q=80", // High Res Green/Abstract
cover_url: "https://placehold.co/800x800/1DB954/191414?text=Shape+of+You",
url: "https://cdn.pixabay.com/download/audio/2022/05/27/audio_1808fbf07a.mp3?filename=twilight-120000.mp3"
},
{
@ -84,7 +84,7 @@ export const dbService = {
artist: "The Weeknd",
album: "After Hours",
duration: 200,
cover_url: "https://images.unsplash.com/photo-1493225255756-d9584f8606e9?w=800&q=80", // High Res City/Red
cover_url: "https://placehold.co/800x800/ff0000/ffffff?text=Blinding+Lights",
url: "https://cdn.pixabay.com/download/audio/2022/03/15/audio_1669466e3b.mp3?filename=relaxed-vlog-131746.mp3"
},
{
@ -93,7 +93,7 @@ export const dbService = {
artist: "Dua Lipa",
album: "Future Nostalgia",
duration: 203,
cover_url: "https://images.unsplash.com/photo-1494232410401-ad00d5433cfa?w=800&q=80", // High Res Music/Vibe
cover_url: "https://placehold.co/800x800/ff00ff/ffffff?text=Levitating",
url: "https://cdn.pixabay.com/download/audio/2022/01/18/audio_d0a13f69d2.mp3?filename=sexy-fashion-beats-11176.mp3"
},
{
@ -102,7 +102,7 @@ export const dbService = {
artist: 'The Kid LAROI',
album: 'F*CK LOVE 3',
duration: 141,
cover_url: "https://images.unsplash.com/photo-1514525253440-b393452e8d26?w=800&q=80", // High Res Neon
cover_url: "https://placehold.co/800x800/800080/ffffff?text=Stay",
url: 'https://cdn.pixabay.com/download/audio/2022/10/25/audio_5145a278d6.mp3?filename=summer-party-12461.mp3'
},
{
@ -111,7 +111,7 @@ export const dbService = {
artist: 'Lil Nas X',
album: 'Montero',
duration: 137,
cover_url: "https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=800&q=80", // High Res Party
cover_url: "https://placehold.co/800x800/ffa500/ffffff?text=Montero",
url: 'https://cdn.pixabay.com/download/audio/2023/04/12/audio_496677f54c.mp3?filename=hip-hop-trap-145638.mp3'
}
];

View file

@ -27,23 +27,43 @@ export const libraryService = {
async search(query: string): Promise<Track[]> {
const data = await apiFetch(`/search?q=${encodeURIComponent(query)}`);
if (data?.tracks && data.tracks.length > 0) {
return data.tracks;
// Normalize track IDs - extract YouTube video ID from discovery-* IDs
return data.tracks.map((track: Track) => {
let videoId = track.id;
if (track.id.includes('discovery-') || track.id.includes('artist-') || track.id.includes('album-')) {
const parts = track.id.split('-');
for (const part of parts) {
if (part.length === 11 && /^[a-zA-Z0-9_-]+$/.test(part)) {
videoId = part;
break;
}
}
}
return { ...track, id: videoId, url: `/api/stream/${videoId}` };
});
}
return [];
},
async getBrowseContent(): Promise<Record<string, StaticPlaylist[]>> {
// Return structured content from Seed Data
// We simulate a "fetch" but it's instant
// Fetch dynamic preloaded content from backend
try {
const data = await apiFetch('/browse');
if (data && Object.keys(data).length > 0) {
return data;
}
} catch (e) {
console.error("Failed to load dynamic browse content", e);
}
// Fallback to mock data if discover returns empty (e.g. backend offline or still loading)
const playlists = Object.values(GENERATED_CONTENT).filter(p => p.type === 'Playlist');
const albums = Object.values(GENERATED_CONTENT).filter(p => p.type === 'Album');
const artists = Object.values(GENERATED_CONTENT).filter(p => p.type === 'Artist');
return {
'Top Playlists': playlists.slice(0, 100),
'Top Albums': albums.slice(0, 100),
'Popular Artists': artists.slice(0, 100)
'Top Playlists': playlists.slice(0, 50),
'Top Albums': albums.slice(0, 50),
'Popular Artists': artists.slice(0, 50)
};
},
@ -71,46 +91,86 @@ export const libraryService = {
return tracks.slice(0, 20);
},
async getRelatedContent(seed: string, seedType: string = 'track', limit: number = 10): Promise<{
tracks: Track[],
albums: Array<{ id: string, title: string, artist: string, cover_url: string }>,
playlists: Array<{ id: string, title: string, cover_url: string, track_count: number }>,
artists: Array<{ id: string, name: string, photo_url: string }>
}> {
try {
const data = await apiFetch(`/recommendations?seed=${encodeURIComponent(seed)}&seed_type=${seedType}&limit=${limit}`);
if (data) {
return {
tracks: data.tracks || [],
albums: data.albums || [],
playlists: data.playlists || [],
artists: data.artists || []
};
}
} catch (e) {
console.error('Failed to get related content:', e);
}
// Fallback to search-based recommendations
const fallbackTracks = await this.search(seed);
return {
tracks: fallbackTracks.slice(0, limit),
albums: [],
playlists: [],
artists: []
};
},
async getPlaylist(id: string): Promise<StaticPlaylist | null> {
// 1. Try to find in GENERATED_CONTENT first (Fast/Instant)
// Extract base ID if needed or check directly?
// Our seed data keys are "Name" but values have IDs "playlist-Name" or "album-Name".
// We need to find by ID.
const found = Object.values(GENERATED_CONTENT).find(p => p.id === id);
if (found) {
// Found metadata! Return it immediately.
// If tracks are empty, we might want to lazy-load them via search?
// Yes, let's fire off a search to fill tracks if empty.
if (found.tracks.length === 0) {
// Try to fetch tracks in background.
// We use multiple fallbacks to ensure we get results.
const queries = [
found.title, // Exact title
`${found.title} songs`, // Explicit songs
`${found.title} playlist`, // Might find mixes
"Vietnam Top Hits" // Ultimate fallback
found.title,
`${found.title} songs`,
`${found.title} playlist`,
"Vietnam Top Hits"
];
for (const q of queries) {
try {
console.log(`[Hydration] Searching: ${q}`);
const tracks = await this.search(q);
if (tracks.length > 0) {
console.log(`[Hydration] Found ${tracks.length} tracks for ${found.title}`);
found.tracks = tracks;
return { ...found, tracks };
}
} catch (e) {
console.error("Hydration search failed for", q, e);
}
}
}
return found;
}
// 2. Fallback: Search by ID string parsing (Slow/Legacy)
const query = id.replace('playlist-', '').replace(/-/g, ' ');
// 2. Try to find in dynamic backend browse cache
try {
const browseData = await apiFetch('/browse');
for (const category in browseData) {
const plist = browseData[category].find((p: any) => p.id === id);
if (plist) {
if (!plist.tracks || plist.tracks.length === 0) {
try {
const tracks = await this.search(`${plist.title} playlist`);
plist.tracks = tracks.length > 0 ? tracks : await this.search(plist.title);
return { ...plist, tracks: plist.tracks };
} catch (e) { }
}
return plist;
}
}
} catch (e) {
console.error("Browse cache lookup failed", e);
}
// 3. Fallback: Search by ID string parsing (Slow/Legacy)
const cleanId = id.replace(/^discovery-(playlist|album|artist)-/, '');
const query = cleanId.replace(/-/g, ' ');
const tracks = await this.search(query);
if (tracks.length > 0) {
return {
@ -142,7 +202,25 @@ export const libraryService = {
return found;
}
const query = id.replace('album-', '').replace(/-/g, ' ');
try {
const browseData = await apiFetch('/browse');
for (const category in browseData) {
const plist = browseData[category].find((p: any) => p.id === id);
if (plist) {
if (!plist.tracks || plist.tracks.length === 0) {
try {
const tracks = await this.search(`${plist.title} album`);
plist.tracks = tracks.length > 0 ? tracks : await this.search(plist.title);
return { ...plist, tracks: plist.tracks };
} catch (e) { }
}
return plist;
}
}
} catch (e) { }
const cleanId = id.replace(/^discovery-(playlist|album|artist)-/, '');
const query = cleanId.replace(/-/g, ' ');
const tracks = await this.search(query);
if (tracks.length > 0) {
return {
@ -158,25 +236,33 @@ export const libraryService = {
},
async getArtistInfo(artistName: string): Promise<{ bio?: string; photo?: string }> {
// Try specific API for image
// Method 1: Try backend API for real YouTube channel photo
try {
const res = await apiFetch(`/artist-image?q=${encodeURIComponent(artistName)}`);
if (res && res.url) {
return { photo: res.url };
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
const res = await fetch(`/api/artist/info?q=${encodeURIComponent(artistName)}`, {
signal: controller.signal
});
clearTimeout(timeoutId);
if (res.ok) {
const data = await res.json();
console.log(`[ArtistInfo] ${artistName}:`, data);
if (data.image) {
return { photo: data.image };
}
}
} catch (e) {
// fall through
console.error(`[ArtistInfo] Error for ${artistName}:`, e);
// Fall through to next method
}
// Fallback to track cover
try {
const tracks = await this.search(artistName);
if (tracks.length > 0 && tracks[0]?.cover_url) {
return { photo: tracks[0].cover_url };
}
} catch (e) { }
return { photo: getUnsplashImage(artistName) };
// Method 2: Use UI-Avatars API (instant, always works)
// Using smaller size (128) for faster loading
const encodedName = encodeURIComponent(artistName);
const avatarUrl = `https://ui-avatars.com/api/?name=${encodedName}&background=random&color=fff&size=128&rounded=true&bold=true&font-size=0.33`;
return { photo: avatarUrl };
},
async getLyrics(track: string, artist: string): Promise<{ plainLyrics?: string; syncedLyrics?: string; } | null> {
@ -209,7 +295,7 @@ export const libraryService = {
if (t.album && !seen.has(`album-${t.album}`)) {
seen.add(`album-${t.album}`);
results.push({
id: `discovery-album-${Math.random().toString(36).substr(2, 9)}`,
id: `discovery-album-${t.album.replace(/\s+/g, '-')}`,
title: t.album,
creator: t.artist,
cover_url: t.cover_url,
@ -224,10 +310,10 @@ export const libraryService = {
if (t.artist && !seen.has(`artist-${t.artist}`)) {
seen.add(`artist-${t.artist}`);
results.push({
id: `discovery-artist-${Math.random().toString(36).substr(2, 9)}`,
id: `discovery-artist-${t.artist.replace(/\s+/g, '-')}`,
title: t.artist,
creator: 'Artist',
cover_url: t.cover_url, // Ideally fetch artist image, but track cover is okay fallback
cover_url: t.cover_url,
type: 'Artist'
});
}
@ -235,10 +321,9 @@ export const libraryService = {
}
if (type === 'playlists' || type === 'all') {
// Generate some "Mix" playlists from the tracks
if (tracks.length > 5) {
results.push({
id: `discovery-playlist-${Math.random().toString(36).substr(2, 9)}`,
id: `discovery-playlist-${randomQuery.replace(/\s+/g, '-')}-Mix`,
title: `${randomQuery} Mix`,
creator: 'Spotify Clone',
cover_url: tracks[0].cover_url,
@ -257,25 +342,16 @@ export const libraryService = {
}
};
// Pool of high-quality artist/music abstract images
const ARTIST_IMAGES = [
"photo-1511671782779-c97d3d27a1d4", // Microphone
"photo-1493225255756-d9584f8606e9", // Vinyl
"photo-1514525253440-b393452e8d26", // Neon
"photo-1470225620780-dba8ba36b745", // DJ
"photo-1511379938547-c1f69419868d", // Piano
"photo-1501612780327-45045538702b", // Guitar
"photo-1459749411177-287ce327a395", // Concert
"photo-1510915362694-bdddb0292f2d", // Stage
"photo-1544785135-3ef2b2b1fb28", // Singer
"photo-1460723237483-7a6dc9d0b212", // Band
];
// Dynamic Placeholders for artists/covers without an image
function getUnsplashImage(seed: string): string {
const initials = seed.substring(0, 2).toUpperCase();
const colors = ["1DB954", "FF6B6B", "4ECDC4", "45B7D1", "6C5CE7", "FDCB6E"];
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = seed.charCodeAt(i) + ((hash << 5) - hash);
}
const index = Math.abs(hash) % ARTIST_IMAGES.length;
return `https://images.unsplash.com/${ARTIST_IMAGES[index]}?w=400&h=400&fit=crop&q=80`;
const color = colors[Math.abs(hash) % colors.length];
return `https://placehold.co/400x400/${color}/FFFFFF?text=${encodeURIComponent(initials)}`;
}