refactor: Migrate backend from Go to Rust (Axum) and update Docker config
This commit is contained in:
parent
a82b6cd418
commit
d1bc4324b4
31 changed files with 3443 additions and 1484 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -47,6 +47,10 @@ wheels/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# Rust
|
||||||
|
backend-rust/target/
|
||||||
|
backend-go/target/
|
||||||
|
|
||||||
# Project Specific
|
# Project Specific
|
||||||
backend/data/*.json
|
backend/data/*.json
|
||||||
!backend/data/browse_playlists.json
|
!backend/data/browse_playlists.json
|
||||||
|
|
|
||||||
123
Dockerfile
123
Dockerfile
|
|
@ -1,70 +1,53 @@
|
||||||
|
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
# Stage 1: Build Frontend
|
# Stage 1: Build Frontend
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
FROM node:20-alpine AS frontend-builder
|
FROM node:20-alpine AS frontend-builder
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
COPY frontend-vite/package*.json ./
|
COPY frontend-vite/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY frontend-vite/ .
|
COPY frontend-vite/ .
|
||||||
# Ensure production build
|
ENV NODE_ENV=production
|
||||||
ENV NODE_ENV=production
|
RUN npm run build
|
||||||
RUN npm run build
|
|
||||||
|
# ---------------------------
|
||||||
# ---------------------------
|
# Stage 2: Build Backend (Rust)
|
||||||
# Stage 2: Build Backend
|
# ---------------------------
|
||||||
# ---------------------------
|
FROM rust:1.75-bookworm AS backend-builder
|
||||||
FROM golang:1.24-alpine AS backend-builder
|
WORKDIR /app/backend
|
||||||
WORKDIR /app/backend
|
|
||||||
|
COPY backend-rust/Cargo.toml ./
|
||||||
# Install build deps if needed (e.g. gcc for cgo, though we try to avoid it)
|
RUN mkdir src && echo "fn main() {}" > src/main.rs
|
||||||
RUN apk add --no-cache git
|
RUN cargo build --release && rm -rf src
|
||||||
|
|
||||||
COPY backend-go/go.mod backend-go/go.sum ./
|
COPY backend-rust/src ./src
|
||||||
RUN go mod download
|
RUN cargo build --release
|
||||||
|
|
||||||
COPY backend-go/ .
|
# ---------------------------
|
||||||
# Build static binary for linux/amd64
|
# Stage 3: Final Runtime
|
||||||
ENV CGO_ENABLED=0
|
# ---------------------------
|
||||||
ENV GOOS=linux
|
FROM python:3.11-slim-bookworm
|
||||||
ENV GOARCH=amd64
|
|
||||||
RUN go build -o server cmd/server/main.go
|
WORKDIR /app
|
||||||
|
|
||||||
# ---------------------------
|
RUN apt-get update && apt-get install -y \
|
||||||
# Stage 3: Final Runtime
|
ffmpeg \
|
||||||
# ---------------------------
|
ca-certificates \
|
||||||
# We use python:3.11-slim because yt-dlp requires Python.
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
# 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.
|
COPY --from=backend-builder /app/backend/target/release/backend-rust /app/server
|
||||||
FROM python:3.11-slim-bookworm
|
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist /app/static
|
||||||
WORKDIR /app
|
|
||||||
|
RUN mkdir -p /tmp/spotify-clone-cache && chmod 777 /tmp/spotify-clone-cache
|
||||||
# Install runtime dependencies for yt-dlp (ffmpeg is crucial)
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN pip install --no-cache-dir -U "yt-dlp[default]"
|
||||||
ffmpeg \
|
|
||||||
ca-certificates \
|
ENV PORT=8080
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
ENV RUST_ENV=production
|
||||||
|
|
||||||
# Copy backend binary
|
EXPOSE 8080
|
||||||
COPY --from=backend-builder /app/backend/server /app/server
|
|
||||||
|
CMD ["/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
|
|
||||||
RUN pip install --no-cache-dir -U "yt-dlp[default]"
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
ENV PORT=8080
|
|
||||||
ENV GIN_MODE=release
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
CMD ["/app/server"]
|
|
||||||
|
|
|
||||||
216
README.md
216
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Spotify Clone 🎵
|
# 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.
|
A fully functional clone of the Spotify web player, built with **React**, **Rust (Axum)**, 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
@ -8,130 +8,158 @@ A fully functional clone of the Spotify web player, built with a modern stack fe
|
||||||
|
|
||||||
## 🚀 Quick Start (Docker)
|
## 🚀 Quick Start (Docker)
|
||||||
|
|
||||||
The easiest way to run the application is using Docker.
|
### Option 1: Pull from Registry
|
||||||
|
|
||||||
### Option 1: Run from Docker Hub (Pre-built)
|
|
||||||
```bash
|
```bash
|
||||||
docker run -p 3000:3000 -p 8000:8000 vndangkhoa/spotify-clone:latest
|
docker pull git.khoavo.myds.me/vndangkhoa/spotify-clone:v3
|
||||||
|
docker run -d -p 3000:8080 --name spotify-clone \
|
||||||
|
-v ./data:/app/data \
|
||||||
|
-v ./cache:/tmp/spotify-clone-cache \
|
||||||
|
--restart unless-stopped \
|
||||||
|
git.khoavo.myds.me/vndangkhoa/spotify-clone:v3
|
||||||
```
|
```
|
||||||
Open **[http://localhost:3000](http://localhost:3000)**.
|
|
||||||
|
|
||||||
### Option 2: Build Locally
|
### Option 2: Build Locally
|
||||||
```bash
|
```bash
|
||||||
docker build -t spotify-clone .
|
docker build -t spotify-clone:v3 .
|
||||||
docker run -p 3000:3000 -p 8000:8000 spotify-clone
|
docker run -d -p 3000:8080 --name spotify-clone \
|
||||||
|
-v ./data:/app/data \
|
||||||
|
-v ./cache:/tmp/spotify-clone-cache \
|
||||||
|
--restart unless-stopped \
|
||||||
|
spotify-clone:v3
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Open **[http://localhost:3000](http://localhost:3000)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 Docker Deployment
|
||||||
|
|
||||||
|
### Building the Image
|
||||||
|
```bash
|
||||||
|
# Build for linux/amd64 (Synology NAS)
|
||||||
|
docker build -t git.khoavo.myds.me/vndangkhoa/spotify-clone:v3 .
|
||||||
|
|
||||||
|
# Push to registry
|
||||||
|
docker push git.khoavo.myds.me/vndangkhoa/spotify-clone:v3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose (Recommended)
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
spotify-clone:
|
||||||
|
image: git.khoavo.myds.me/vndangkhoa/spotify-clone:v3
|
||||||
|
container_name: spotify-clone
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:8080"
|
||||||
|
environment:
|
||||||
|
- PORT=8080
|
||||||
|
- RUST_ENV=production
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./cache:/tmp/spotify-clone-cache
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Synology NAS Deployment
|
||||||
|
|
||||||
|
### Method A: Container Manager UI (GUI)
|
||||||
|
|
||||||
|
1. **Open Container Manager** on your Synology NAS.
|
||||||
|
2. Go to **Registry** and click **Add**:
|
||||||
|
- Registry URL: `https://git.khoavo.myds.me`
|
||||||
|
- Enter credentials if prompted.
|
||||||
|
3. Search for `vndangkhoa/spotify-clone` and download the `v3` tag.
|
||||||
|
4. Go to **Image**, select the downloaded image, and click **Run**.
|
||||||
|
5. Configure:
|
||||||
|
- **Container Name**: `spotify-clone`
|
||||||
|
- **Port Settings**:
|
||||||
|
- Local Port: `3000` (or any available port)
|
||||||
|
- Container Port: `8080`
|
||||||
|
- **Volume Settings**:
|
||||||
|
- Add folder: `docker/spotify-clone/data` → `/app/data`
|
||||||
|
- Add folder: `docker/spotify-clone/cache` → `/tmp/spotify-clone-cache`
|
||||||
|
- **Restart Policy**: `unless-stopped`
|
||||||
|
6. Click **Done** and access at `http://YOUR_NAS_IP:3000`.
|
||||||
|
|
||||||
|
### Method B: Docker Compose (CLI)
|
||||||
|
|
||||||
|
1. SSH into your Synology NAS or use the built-in terminal.
|
||||||
|
2. Create a folder:
|
||||||
|
```bash
|
||||||
|
mkdir -p /volume1/docker/spotify-clone
|
||||||
|
cd /volume1/docker/spotify-clone
|
||||||
|
```
|
||||||
|
3. Create `docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
spotify-clone:
|
||||||
|
image: git.khoavo.myds.me/vndangkhoa/spotify-clone:v3
|
||||||
|
container_name: spotify-clone
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:8080"
|
||||||
|
environment:
|
||||||
|
- PORT=8080
|
||||||
|
- RUST_ENV=production
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./cache:/tmp/spotify-clone-cache
|
||||||
|
```
|
||||||
|
4. Run:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Synology-Specific Notes
|
||||||
|
|
||||||
|
- **Architecture**: This image is built for `linux/amd64` (compatible with most Intel-based Synology NAS).
|
||||||
|
- **DSM 7+**: Use Container Manager (Docker GUI replacement).
|
||||||
|
- **Data Persistence**: The `./data` volume stores playlists and application data. Backup this folder to preserve your data.
|
||||||
|
- **Updating**: Pull the latest image and recreate the container, or use Watchtower for auto-updates.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ Local Development
|
## 🛠️ Local Development
|
||||||
|
|
||||||
If you want to contribute or modify the code:
|
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Node.js 18+
|
- Node.js 20+
|
||||||
|
- Rust 1.75+
|
||||||
- Python 3.11+
|
- Python 3.11+
|
||||||
- ffmpeg (optional, for some audio features)
|
- ffmpeg
|
||||||
|
|
||||||
### 1. Backend Setup
|
### 1. Backend Setup (Rust)
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend-rust
|
||||||
python -m venv venv
|
cargo run
|
||||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
python main.py
|
|
||||||
```
|
```
|
||||||
Backend runs on `http://localhost:8000`.
|
Backend runs on `http://localhost:8080`.
|
||||||
|
|
||||||
### 2. Frontend Setup
|
### 2. Frontend Setup
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend-vite
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
Frontend runs on `http://localhost:3000`.
|
Frontend runs on `http://localhost:5173`.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 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
|
## ✨ Features
|
||||||
|
|
||||||
- **Real-Time Lyrics**: Fetch and sync lyrics from multiple sources (YouTube, LRCLIB).
|
- **Real-Time Lyrics**: Fetch and sync lyrics from multiple sources.
|
||||||
- **Audiophile Engine**: "Tech Specs" view showing live bitrate, LUFS, and Dynamic Range.
|
- **Audiophile Engine**: "Tech Specs" view showing live bitrate, LUFS, and Dynamic Range.
|
||||||
- **Local-First**: Works offline (PWA) and syncs local playlists.
|
- **Local-First**: Works offline (PWA) and syncs local playlists.
|
||||||
- **Smart Search**: Unified search across YouTube Music.
|
- **Smart Search**: Unified search across YouTube Music.
|
||||||
- **Responsive**: Full mobile support with a dedicated full-screen player.
|
- **Responsive**: Full mobile support with dedicated full-screen player.
|
||||||
- **Smooth Loading**: Skeleton animations for seamless data fetching.
|
- **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.
|
|
||||||
|
|
||||||
## 📝 License
|
## 📝 License
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
# Build Stage
|
|
||||||
FROM golang:1.21-alpine AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
# Go mod tidy created go.sum? If not, we run it here.
|
|
||||||
# Copy source
|
|
||||||
COPY . .
|
|
||||||
# Build
|
|
||||||
RUN go mod tidy
|
|
||||||
RUN go build -o server cmd/server/main.go
|
|
||||||
|
|
||||||
# Runtime Stage
|
|
||||||
FROM python:3.11-alpine
|
|
||||||
# We need python for yt-dlp
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install dependencies (ffmpeg, yt-dlp)
|
|
||||||
RUN apk add --no-cache ffmpeg curl
|
|
||||||
# Install yt-dlp via pip (often fresher) or use the binary
|
|
||||||
RUN pip install yt-dlp
|
|
||||||
|
|
||||||
COPY --from=builder /app/server .
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
CMD ["./server"]
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"spotify-clone-backend/internal/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
port := os.Getenv("PORT")
|
|
||||||
if port == "" {
|
|
||||||
port = "8080"
|
|
||||||
}
|
|
||||||
|
|
||||||
router := api.NewRouter()
|
|
||||||
|
|
||||||
fmt.Printf("Server starting on port %s...\n", port)
|
|
||||||
if err := http.ListenAndServe(":"+port, router); err != nil {
|
|
||||||
log.Fatalf("Server failed to start: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
module spotify-clone-backend
|
|
||||||
|
|
||||||
go 1.21
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/go-chi/chi/v5 v5.0.10
|
|
||||||
github.com/go-chi/cors v1.2.1
|
|
||||||
)
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
|
|
||||||
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
|
||||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
|
||||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestHealthCheck(t *testing.T) {
|
|
||||||
// Create a request to pass to our handler.
|
|
||||||
req, err := http.NewRequest("GET", "/api/health", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response.
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Initialize Router
|
|
||||||
router := NewRouter()
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check the response body is what we expect.
|
|
||||||
expected := "ok"
|
|
||||||
if rr.Body.String() != expected {
|
|
||||||
t.Errorf("handler returned unexpected body: got %v want %v",
|
|
||||||
rr.Body.String(), expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSearchValidation(t *testing.T) {
|
|
||||||
// Test missing query parameter
|
|
||||||
req, err := http.NewRequest("GET", "/api/search", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handler := http.HandlerFunc(SearchTracks)
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if status := rr.Code; status != http.StatusBadRequest {
|
|
||||||
t.Errorf("handler returned wrong status code for missing query: got %v want %v",
|
|
||||||
status, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"spotify-clone-backend/internal/spotdl"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
var spotdlService = spotdl.NewService()
|
|
||||||
|
|
||||||
func SearchTracks(w http.ResponseWriter, r *http.Request) {
|
|
||||||
query := r.URL.Query().Get("q")
|
|
||||||
if query == "" {
|
|
||||||
http.Error(w, "query parameter required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks, err := spotdlService.SearchTracks(query)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{"tracks": tracks})
|
|
||||||
}
|
|
||||||
|
|
||||||
func StreamAudio(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
if id == "" {
|
|
||||||
http.Error(w, "id required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
path, err := spotdlService.GetStreamURL(id)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set headers for streaming based on extension
|
|
||||||
ext := filepath.Ext(path)
|
|
||||||
contentType := "audio/mpeg" // default
|
|
||||||
if ext == ".m4a" {
|
|
||||||
contentType = "audio/mp4"
|
|
||||||
} else if ext == ".webm" {
|
|
||||||
contentType = "audio/webm"
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", contentType)
|
|
||||||
w.Header().Set("Transfer-Encoding", "chunked")
|
|
||||||
|
|
||||||
// Flush headers immediately
|
|
||||||
if f, ok := w.(http.Flusher); ok {
|
|
||||||
f.Flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now stream it
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
io.Copy(w, bufio.NewReader(f))
|
|
||||||
}
|
|
||||||
|
|
||||||
func DownloadTrack(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var body struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if body.URL == "" {
|
|
||||||
http.Error(w, "url required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
path, err := spotdlService.DownloadTrack(body.URL)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{"path": path, "status": "downloaded"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetArtistImage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
query := r.URL.Query().Get("q")
|
|
||||||
if query == "" {
|
|
||||||
http.Error(w, "query required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
imageURL, err := spotdlService.SearchArtist(query)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "artist not found", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"url": imageURL})
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateSimBinary(w http.ResponseWriter, r *http.Request) {
|
|
||||||
output, err := spotdlService.UpdateBinary()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "updated", "output": output})
|
|
||||||
}
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LyricsResponse struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
TrackName string `json:"trackName"`
|
|
||||||
ArtistName string `json:"artistName"`
|
|
||||||
PlainLyrics string `json:"plainLyrics"`
|
|
||||||
SyncedLyrics string `json:"syncedLyrics"` // Time-synced lyrics [mm:ss.xx] text
|
|
||||||
Duration float64 `json:"duration"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type OvhResponse struct {
|
|
||||||
Lyrics string `json:"lyrics"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var httpClient = &http.Client{Timeout: 5 * time.Second}
|
|
||||||
|
|
||||||
// cleanVideoTitle attempts to extract the actual song title from a YouTube video title
|
|
||||||
func cleanVideoTitle(videoTitle, artistName string) string {
|
|
||||||
// 1. If strict "Artist - Title" format matches, take the title part
|
|
||||||
// Case-insensitive check
|
|
||||||
lowerTitle := strings.ToLower(videoTitle)
|
|
||||||
lowerArtist := strings.ToLower(artistName)
|
|
||||||
|
|
||||||
if strings.Contains(lowerTitle, " - ") {
|
|
||||||
parts := strings.Split(videoTitle, " - ")
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
// Check if first part is artist
|
|
||||||
if strings.Contains(strings.ToLower(parts[0]), lowerArtist) {
|
|
||||||
return cleanMetadata(parts[1])
|
|
||||||
}
|
|
||||||
// Check if second part is artist
|
|
||||||
if strings.Contains(strings.ToLower(parts[1]), lowerArtist) {
|
|
||||||
return cleanMetadata(parts[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Separator Strategy ( |, //, -, :, feat. )
|
|
||||||
// Normalize separators to |
|
|
||||||
simplified := videoTitle
|
|
||||||
for _, sep := range []string{"//", " - ", ":", "feat.", "ft.", "|"} {
|
|
||||||
simplified = strings.ReplaceAll(simplified, sep, "|")
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(simplified, "|") {
|
|
||||||
parts := strings.Split(simplified, "|")
|
|
||||||
// Filter parts
|
|
||||||
var candidates []string
|
|
||||||
for _, p := range parts {
|
|
||||||
p = strings.TrimSpace(p)
|
|
||||||
pLower := strings.ToLower(p)
|
|
||||||
if p == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip "Official Video", "MV", "Artist Name"
|
|
||||||
if strings.Contains(pLower, "official") || strings.Contains(pLower, "mv") || strings.Contains(pLower, "music video") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Skip if it is contained in artist name (e.g. "Min" in "Min Official")
|
|
||||||
if pLower == lowerArtist || strings.Contains(lowerArtist, pLower) || strings.Contains(pLower, lowerArtist) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
candidates = append(candidates, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heuristic: The Title is usually the FIRST valid part remaining.
|
|
||||||
// However, if we have multiple, and one is very short (< 4 chars) and one is long, pick the long one?
|
|
||||||
// Actually, let's look for the one that looks most like a title.
|
|
||||||
// For now, if we have multiple candidates, let's pick the longest one if the first one is tiny.
|
|
||||||
if len(candidates) > 0 {
|
|
||||||
best := candidates[0]
|
|
||||||
// If first candidate is super short (e.g. "HD"), look for a better one
|
|
||||||
if len(best) < 4 && len(candidates) > 1 {
|
|
||||||
for _, c := range candidates[1:] {
|
|
||||||
if len(c) > len(best) {
|
|
||||||
best = c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cleanMetadata(best)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cleanMetadata(videoTitle)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanMetadata(title string) string {
|
|
||||||
// Remove parenthetical noise like (feat. X), (Official)
|
|
||||||
// Also remove unparenthesized "feat. X" or "ft. X" at the end of the string
|
|
||||||
re := regexp.MustCompile(`(?i)(\(feat\..*?\)|\[feat\..*?\]|\(remaster.*?\)|- remaster.*| - live.*|\(official.*?\)|\[official.*?\]| - official.*|\sfeat\..*|\sft\..*)`)
|
|
||||||
clean := re.ReplaceAllString(title, "")
|
|
||||||
return strings.TrimSpace(clean)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanArtist(artist string) string {
|
|
||||||
// Remove " - Topic", " Official", "VEVO"
|
|
||||||
re := regexp.MustCompile(`(?i)( - topic| official| channel| vevo)`)
|
|
||||||
return strings.TrimSpace(re.ReplaceAllString(artist, ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchFromLRCLIB(artist, track string) (*LyricsResponse, error) {
|
|
||||||
// 1. Try Specific Get
|
|
||||||
targetURL := fmt.Sprintf("https://lrclib.net/api/get?artist_name=%s&track_name=%s", url.QueryEscape(artist), url.QueryEscape(track))
|
|
||||||
resp, err := httpClient.Get(targetURL)
|
|
||||||
if err == nil && resp.StatusCode == 200 {
|
|
||||||
var lyrics LyricsResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&lyrics); err == nil && (lyrics.PlainLyrics != "" || lyrics.SyncedLyrics != "") {
|
|
||||||
resp.Body.Close()
|
|
||||||
return &lyrics, nil
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Try Search (Best Match)
|
|
||||||
searchURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s %s", url.QueryEscape(artist), url.QueryEscape(track))
|
|
||||||
resp2, err := httpClient.Get(searchURL)
|
|
||||||
if err == nil && resp2.StatusCode == 200 {
|
|
||||||
var results []LyricsResponse
|
|
||||||
if err := json.NewDecoder(resp2.Body).Decode(&results); err == nil && len(results) > 0 {
|
|
||||||
resp2.Body.Close()
|
|
||||||
return &results[0], nil
|
|
||||||
}
|
|
||||||
resp2.Body.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("not found in lrclib")
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchFromOVH(artist, track string) (*LyricsResponse, error) {
|
|
||||||
// OVH API: https://api.lyrics.ovh/v1/artist/title
|
|
||||||
targetURL := fmt.Sprintf("https://api.lyrics.ovh/v1/%s/%s", url.QueryEscape(artist), url.QueryEscape(track))
|
|
||||||
resp, err := httpClient.Get(targetURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == 200 {
|
|
||||||
var ovh OvhResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&ovh); err == nil && ovh.Lyrics != "" {
|
|
||||||
return &LyricsResponse{
|
|
||||||
TrackName: track,
|
|
||||||
ArtistName: artist,
|
|
||||||
PlainLyrics: ovh.Lyrics,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("not found in ovh")
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchFromLyrist(track string) (*LyricsResponse, error) {
|
|
||||||
// API: https://lyrist.vercel.app/api/:query
|
|
||||||
// Simple free API wrapper
|
|
||||||
targetURL := fmt.Sprintf("https://lyrist.vercel.app/api/%s", url.QueryEscape(track))
|
|
||||||
resp, err := httpClient.Get(targetURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == 200 {
|
|
||||||
var res struct {
|
|
||||||
Lyrics string `json:"lyrics"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Artist string `json:"artist"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&res); err == nil && res.Lyrics != "" {
|
|
||||||
return &LyricsResponse{
|
|
||||||
TrackName: res.Title,
|
|
||||||
ArtistName: res.Artist,
|
|
||||||
PlainLyrics: res.Lyrics,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("not found in lyrist")
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetLyrics(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Allow CORS
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
|
|
||||||
rawArtist := r.URL.Query().Get("artist")
|
|
||||||
rawTrack := r.URL.Query().Get("track")
|
|
||||||
|
|
||||||
if rawTrack == "" {
|
|
||||||
http.Error(w, "track required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Clean Inputs
|
|
||||||
artist := cleanArtist(rawArtist)
|
|
||||||
smartTitle := cleanVideoTitle(rawTrack, artist) // Heuristic extraction
|
|
||||||
dumbTitle := cleanMetadata(rawTrack) // Simple regex cleaning
|
|
||||||
|
|
||||||
fmt.Printf("[Lyrics] Request: %s | %s\n", rawArtist, rawTrack)
|
|
||||||
fmt.Printf("[Lyrics] Cleaned: %s | %s\n", artist, smartTitle)
|
|
||||||
|
|
||||||
// Strategy 1: LRCLIB (Exact Smart)
|
|
||||||
if lyrics, err := fetchFromLRCLIB(artist, smartTitle); err == nil {
|
|
||||||
fmt.Println("[Lyrics] Strategy 1 (Exact Smart) Hit")
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(lyrics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 2: LRCLIB (Exact Dumb) - Fallback if our smart extraction failed
|
|
||||||
if smartTitle != dumbTitle {
|
|
||||||
if lyrics, err := fetchFromLRCLIB(artist, dumbTitle); err == nil {
|
|
||||||
fmt.Println("[Lyrics] Strategy 2 (Exact Dumb) Hit")
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(lyrics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 3: Lyrist (Smart Search)
|
|
||||||
if lyrics, err := fetchFromLyrist(fmt.Sprintf("%s %s", artist, smartTitle)); err == nil {
|
|
||||||
fmt.Println("[Lyrics] Strategy 3 (Lyrist) Hit")
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(lyrics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 4: OVH (Last Resort)
|
|
||||||
if lyrics, err := fetchFromOVH(artist, smartTitle); err == nil {
|
|
||||||
fmt.Println("[Lyrics] Strategy 4 (OVH) Hit")
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(lyrics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 5: Hail Mary Search (Raw-ish)
|
|
||||||
if lyrics, err := fetchFromLRCLIB("", fmt.Sprintf("%s %s", artist, smartTitle)); err == nil {
|
|
||||||
fmt.Println("[Lyrics] Strategy 5 (Hail Mary) Hit")
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(lyrics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 6: Title Only (Ignore Artist)
|
|
||||||
// Sometimes artist name is completely different in DB
|
|
||||||
if lyrics, err := fetchFromLRCLIB("", smartTitle); err == nil {
|
|
||||||
fmt.Println("[Lyrics] Strategy 6 (Title Only) Hit")
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(lyrics)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("[Lyrics] Failed to find lyrics")
|
|
||||||
http.Error(w, "lyrics not found", http.StatusNotFound)
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
|
||||||
"github.com/go-chi/cors"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewRouter() http.Handler {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
|
|
||||||
// Middleware
|
|
||||||
r.Use(middleware.Logger)
|
|
||||||
r.Use(middleware.Recoverer)
|
|
||||||
r.Use(middleware.Timeout(60 * time.Second))
|
|
||||||
|
|
||||||
// CORS
|
|
||||||
r.Use(cors.Handler(cors.Options{
|
|
||||||
AllowedOrigins: []string{"http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:5173"}, // Added Vite default port
|
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
|
||||||
ExposedHeaders: []string{"Link"},
|
|
||||||
AllowCredentials: true,
|
|
||||||
MaxAge: 300,
|
|
||||||
}))
|
|
||||||
|
|
||||||
r.Route("/api", func(r chi.Router) {
|
|
||||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Get("/search", SearchTracks)
|
|
||||||
r.Get("/stream/{id}", StreamAudio)
|
|
||||||
r.Get("/lyrics", GetLyrics)
|
|
||||||
r.Get("/artist-image", GetArtistImage)
|
|
||||||
r.Post("/download", DownloadTrack)
|
|
||||||
r.Post("/settings/update-ytdlp", UpdateSimBinary)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Serve Static Files (SPA)
|
|
||||||
workDir, _ := os.Getwd()
|
|
||||||
filesDir := http.Dir(filepath.Join(workDir, "static"))
|
|
||||||
FileServer(r, "/", filesDir)
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileServer conveniently sets up a http.FileServer handler to serve
|
|
||||||
// static files from a http.FileSystem.
|
|
||||||
func FileServer(r chi.Router, path string, root http.FileSystem) {
|
|
||||||
if strings.ContainsAny(path, "{}*") {
|
|
||||||
panic("FileServer does not permit any URL parameters.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if path != "/" && path[len(path)-1] != '/' {
|
|
||||||
r.Get(path, http.RedirectHandler(path+"/", 301).ServeHTTP)
|
|
||||||
path += "/"
|
|
||||||
}
|
|
||||||
path += "*"
|
|
||||||
|
|
||||||
r.Get(path, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
rctx := chi.RouteContext(r.Context())
|
|
||||||
pathPrefix := strings.TrimSuffix(rctx.RoutePattern(), "/*")
|
|
||||||
fs := http.StripPrefix(pathPrefix, http.FileServer(root))
|
|
||||||
|
|
||||||
// Check if file exists, otherwise serve index.html (SPA)
|
|
||||||
f, err := root.Open(r.URL.Path)
|
|
||||||
if err != nil && os.IsNotExist(err) {
|
|
||||||
http.ServeFile(w, r, filepath.Join("static", "index.html"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if f != nil {
|
|
||||||
f.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
package models
|
|
||||||
|
|
||||||
type Track struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Artist string `json:"artist"`
|
|
||||||
Album string `json:"album"`
|
|
||||||
Duration int `json:"duration"`
|
|
||||||
CoverURL string `json:"cover_url"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Playlist struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Author string `json:"author"`
|
|
||||||
CoverURL string `json:"cover_url"`
|
|
||||||
Tracks []Track `json:"tracks"`
|
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SearchResponse struct {
|
|
||||||
Tracks []Track `json:"tracks"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,410 +0,0 @@
|
||||||
package spotdl
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"spotify-clone-backend/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ytDlpPath finds the yt-dlp executable
|
|
||||||
func ytDlpPath() string {
|
|
||||||
// Check for local yt-dlp.exe
|
|
||||||
exe, err := os.Executable()
|
|
||||||
if err == nil {
|
|
||||||
localPath := filepath.Join(filepath.Dir(exe), "yt-dlp.exe")
|
|
||||||
if _, err := os.Stat(localPath); err == nil {
|
|
||||||
return localPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check in working directory
|
|
||||||
if _, err := os.Stat("yt-dlp.exe"); err == nil {
|
|
||||||
return "./yt-dlp.exe"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Python Scripts directory
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
if err == nil {
|
|
||||||
pythonScriptsPath := filepath.Join(homeDir, "AppData", "Local", "Programs", "Python", "Python312", "Scripts", "yt-dlp.exe")
|
|
||||||
if _, err := os.Stat(pythonScriptsPath); err == nil {
|
|
||||||
return pythonScriptsPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "yt-dlp"
|
|
||||||
}
|
|
||||||
|
|
||||||
type CacheItem struct {
|
|
||||||
Tracks []models.Track
|
|
||||||
Timestamp time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type Service struct {
|
|
||||||
downloadDir string
|
|
||||||
searchCache map[string]CacheItem
|
|
||||||
cacheMutex sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewService() *Service {
|
|
||||||
downloadDir := filepath.Join(os.TempDir(), "spotify-clone-cache")
|
|
||||||
os.MkdirAll(downloadDir, 0755)
|
|
||||||
return &Service{
|
|
||||||
downloadDir: downloadDir,
|
|
||||||
searchCache: make(map[string]CacheItem),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// YTResult represents yt-dlp JSON output
|
|
||||||
type YTResult struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Uploader string `json:"uploader"`
|
|
||||||
Duration float64 `json:"duration"`
|
|
||||||
Webpage string `json:"webpage_url"`
|
|
||||||
Thumbnails []struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Height int `json:"height"`
|
|
||||||
Width int `json:"width"`
|
|
||||||
} `json:"thumbnails"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchTracks uses yt-dlp to search YouTube
|
|
||||||
func (s *Service) SearchTracks(query string) ([]models.Track, error) {
|
|
||||||
// 1. Check Cache
|
|
||||||
s.cacheMutex.RLock()
|
|
||||||
if item, found := s.searchCache[query]; found {
|
|
||||||
if time.Since(item.Timestamp) < 1*time.Hour { // 1 Hour Cache
|
|
||||||
s.cacheMutex.RUnlock()
|
|
||||||
fmt.Printf("Cache Hit: %s\n", query)
|
|
||||||
return item.Tracks, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.cacheMutex.RUnlock()
|
|
||||||
|
|
||||||
// yt-dlp "ytsearch20:<query>" --dump-json --no-playlist --flat-playlist
|
|
||||||
path := ytDlpPath()
|
|
||||||
searchQuery := fmt.Sprintf("ytsearch20:%s", query)
|
|
||||||
|
|
||||||
// Using --flat-playlist is fast but sometimes lacks thumbnails/details in some versions.
|
|
||||||
// However, the JSON output above showed thumbnails present even with --flat-playlist (or maybe I removed it in previous step? No I added it).
|
|
||||||
// Let's stick to the current command which provided results, just parse better.
|
|
||||||
cmd := exec.Command(path, searchQuery, "--dump-json", "--no-playlist", "--flat-playlist")
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
cmd.Stdout = &stdout
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
fmt.Printf("Executing: %s %s --dump-json\n", path, searchQuery)
|
|
||||||
err := cmd.Run()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("search failed: %w, stderr: %s", err, stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var tracks []models.Track
|
|
||||||
scanner := bufio.NewScanner(&stdout)
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Bytes()
|
|
||||||
var res YTResult
|
|
||||||
if err := json.Unmarshal(line, &res); err == nil {
|
|
||||||
// FILTER: Skip channels and playlists
|
|
||||||
// Channels usually start with UC, Playlists with PL (though IDs varies, channels are distinct)
|
|
||||||
// A safest check is duration > 0, channels have 0 duration in flat sort usually?
|
|
||||||
// Or check ID pattern.
|
|
||||||
if strings.HasPrefix(res.ID, "UC") || strings.HasPrefix(res.ID, "PL") || res.Duration == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean artist name (remove " - Topic")
|
|
||||||
artist := strings.Replace(res.Uploader, " - Topic", "", -1)
|
|
||||||
|
|
||||||
// Select best thumbnail (Highest resolution)
|
|
||||||
coverURL := ""
|
|
||||||
// maxArea := 0 // Removed unused variable
|
|
||||||
|
|
||||||
if len(res.Thumbnails) > 0 {
|
|
||||||
bestScore := -1.0
|
|
||||||
|
|
||||||
for _, thumb := range res.Thumbnails {
|
|
||||||
// Calculate score: Area * AspectRatioPenalty
|
|
||||||
// We want square (1.0).
|
|
||||||
// Penalty = 1 / (1 + abs(ratio - 1))
|
|
||||||
|
|
||||||
w := float64(thumb.Width)
|
|
||||||
h := float64(thumb.Height)
|
|
||||||
if w == 0 || h == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ratio := w / h
|
|
||||||
diff := ratio - 1.0
|
|
||||||
if diff < 0 {
|
|
||||||
diff = -diff
|
|
||||||
}
|
|
||||||
|
|
||||||
// If strictly square (usually YTM), give huge bonus
|
|
||||||
// YouTube Music covers are often 1:1
|
|
||||||
score := w * h
|
|
||||||
|
|
||||||
if diff < 0.1 { // Close to square
|
|
||||||
score = score * 10 // Boost square images significantly
|
|
||||||
}
|
|
||||||
|
|
||||||
if score > bestScore {
|
|
||||||
bestScore = score
|
|
||||||
coverURL = thumb.URL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If specific check failed (e.g. 0 dimensions), fallback to last
|
|
||||||
if coverURL == "" {
|
|
||||||
coverURL = res.Thumbnails[len(res.Thumbnails)-1].URL
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback construction - favor maxres, but hq is safer.
|
|
||||||
// Let's use hqdefault which is effectively standard high quality.
|
|
||||||
coverURL = fmt.Sprintf("https://i.ytimg.com/vi/%s/hqdefault.jpg", res.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks = append(tracks, models.Track{
|
|
||||||
ID: res.ID,
|
|
||||||
Title: res.Title,
|
|
||||||
Artist: artist,
|
|
||||||
Album: "YouTube Music",
|
|
||||||
Duration: int(res.Duration),
|
|
||||||
CoverURL: coverURL,
|
|
||||||
URL: fmt.Sprintf("/api/stream/%s", res.ID), // Use backend stream endpoint
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Save to Cache
|
|
||||||
if len(tracks) > 0 {
|
|
||||||
s.cacheMutex.Lock()
|
|
||||||
s.searchCache[query] = CacheItem{
|
|
||||||
Tracks: tracks,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
}
|
|
||||||
s.cacheMutex.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
return tracks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchArtist searches for a channel/artist to get their thumbnail
|
|
||||||
func (s *Service) SearchArtist(query string) (string, error) {
|
|
||||||
// Search for the artist channel specifically
|
|
||||||
path := ytDlpPath()
|
|
||||||
// Increase to 3 results to increase chance of finding the actual channel if a video comes first
|
|
||||||
searchQuery := fmt.Sprintf("ytsearch3:%s official channel", query)
|
|
||||||
|
|
||||||
// Remove --no-playlist to allow channel results
|
|
||||||
cmd := exec.Command(path, searchQuery, "--dump-json", "--flat-playlist")
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
|
||||||
cmd.Stdout = &stdout
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var bestThumbnail string
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(&stdout)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Bytes()
|
|
||||||
var res struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Thumbnails []struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"thumbnails"`
|
|
||||||
ChannelThumbnail string `json:"channel_thumbnail"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(line, &res); err == nil {
|
|
||||||
// Check if this is a channel (ID starts with UC)
|
|
||||||
if strings.HasPrefix(res.ID, "UC") {
|
|
||||||
if len(res.Thumbnails) > 0 {
|
|
||||||
// Return immediately if we found a channel
|
|
||||||
// OPTIMIZATION: User requested faster loading/lower quality.
|
|
||||||
// Instead of taking the last (largest), find one that is "good enough" (>= 150px)
|
|
||||||
// Channels usually have s88, s176, s240, s800 etc. s176 is perfect for local 144px display.
|
|
||||||
|
|
||||||
selected := res.Thumbnails[len(res.Thumbnails)-1].URL // Default to largest
|
|
||||||
|
|
||||||
// Simple logic: If we have multiple, pick the second to last? Or just hardcode a preference?
|
|
||||||
// Let's assume the list is sorted size ascending.
|
|
||||||
if len(res.Thumbnails) >= 2 {
|
|
||||||
// Usually [small, medium, large, max].
|
|
||||||
// If > 3 items, pick the one at index 1 or 2.
|
|
||||||
// Let's aim for index 1 (usually ~176px or 300px)
|
|
||||||
idx := 1
|
|
||||||
if idx >= len(res.Thumbnails) {
|
|
||||||
idx = len(res.Thumbnails) - 1
|
|
||||||
}
|
|
||||||
selected = res.Thumbnails[idx].URL
|
|
||||||
}
|
|
||||||
|
|
||||||
return selected, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep track of the first valid thumbnail as fallback
|
|
||||||
if bestThumbnail == "" && len(res.Thumbnails) > 0 {
|
|
||||||
// Same logic for fallback
|
|
||||||
idx := 1
|
|
||||||
if len(res.Thumbnails) < 2 {
|
|
||||||
idx = 0
|
|
||||||
}
|
|
||||||
bestThumbnail = res.Thumbnails[idx].URL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if bestThumbnail != "" {
|
|
||||||
return bestThumbnail, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("artist not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStreamURL downloads the track and returns the local file path
|
|
||||||
func (s *Service) GetStreamURL(videoURL string) (string, error) {
|
|
||||||
// If it's a Spotify URL, we can't handle it directly with yt-dlp in this mode easily
|
|
||||||
// without search. But the frontend now sends YouTube URLs (from SearchTracks).
|
|
||||||
// If ID is passed, construct YouTube URL.
|
|
||||||
|
|
||||||
var targetURL string
|
|
||||||
if strings.HasPrefix(videoURL, "http") {
|
|
||||||
targetURL = videoURL
|
|
||||||
} else {
|
|
||||||
// Assume ID
|
|
||||||
targetURL = "https://www.youtube.com/watch?v=" + videoURL
|
|
||||||
}
|
|
||||||
|
|
||||||
videoID := extractVideoID(targetURL)
|
|
||||||
// Check if already downloaded (check for any audio format)
|
|
||||||
// We prefer m4a or webm since we don't have ffmpeg for mp3 conversion
|
|
||||||
pattern := filepath.Join(s.downloadDir, videoID+".*")
|
|
||||||
matches, _ := filepath.Glob(pattern)
|
|
||||||
if len(matches) > 0 {
|
|
||||||
return matches[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download: yt-dlp -f "bestaudio[ext=m4a]/bestaudio" -o <id>.%(ext)s <url>
|
|
||||||
// This avoids needing ffmpeg for conversion
|
|
||||||
cmd := exec.Command(ytDlpPath(), "-f", "bestaudio[ext=m4a]/bestaudio", "--output", videoID+".%(ext)s", targetURL)
|
|
||||||
cmd.Dir = s.downloadDir
|
|
||||||
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return "", fmt.Errorf("download failed: %w, stderr: %s", err, stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the downloaded file again
|
|
||||||
matches, _ = filepath.Glob(pattern)
|
|
||||||
if len(matches) > 0 {
|
|
||||||
return matches[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("downloaded file not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamAudioToWriter streams audio to http writer
|
|
||||||
func (s *Service) StreamAudioToWriter(id string, w io.Writer) error {
|
|
||||||
filePath, err := s.GetStreamURL(id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(w, file)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) DownloadTrack(url string) (string, error) {
|
|
||||||
return s.GetStreamURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractVideoID(url string) string {
|
|
||||||
// Basic extraction for https://www.youtube.com/watch?v=ID
|
|
||||||
if strings.Contains(url, "v=") {
|
|
||||||
parts := strings.Split(url, "v=")
|
|
||||||
if len(parts) > 1 {
|
|
||||||
return strings.Split(parts[1], "&")[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return url // fallback to assume it's an ID or full URL if unique enough
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateBinary updates yt-dlp to the latest nightly version
|
|
||||||
func (s *Service) UpdateBinary() (string, error) {
|
|
||||||
path := ytDlpPath()
|
|
||||||
// Command: yt-dlp --update-to nightly
|
|
||||||
cmd := exec.Command(path, "--update-to", "nightly")
|
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
cmd.Stdout = &stdout
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
err := cmd.Run()
|
|
||||||
if err == nil {
|
|
||||||
return stdout.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
errStr := stderr.String()
|
|
||||||
// 2. Handle Pip Install Error
|
|
||||||
// "ERROR: You installed yt-dlp with pip or using the wheel from PyPi; Use that to update"
|
|
||||||
if strings.Contains(errStr, "pip") || strings.Contains(errStr, "wheel") {
|
|
||||||
// Try to find pip in the same directory as yt-dlp
|
|
||||||
dir := filepath.Dir(path)
|
|
||||||
pipPath := filepath.Join(dir, "pip.exe")
|
|
||||||
|
|
||||||
// If not found, try "pip" from PATH? But earlier checkout failed.
|
|
||||||
// Let's rely on relative path first.
|
|
||||||
if _, statErr := os.Stat(pipPath); statErr != nil {
|
|
||||||
// Try "python -m pip" if python is there?
|
|
||||||
// Or maybe "pip3.exe"
|
|
||||||
pipPath = filepath.Join(dir, "pip3.exe")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, statErr := os.Stat(pipPath); statErr == nil {
|
|
||||||
// Found pip, try updating via pip
|
|
||||||
// pip install -U --pre "yt-dlp[default]"
|
|
||||||
// We use --pre to get nightly/pre-release builds which user requested
|
|
||||||
pipCmd := exec.Command(pipPath, "install", "--upgrade", "--pre", "yt-dlp[default]")
|
|
||||||
|
|
||||||
// Capture new output
|
|
||||||
pipStdout := &bytes.Buffer{}
|
|
||||||
pipStderr := &bytes.Buffer{}
|
|
||||||
pipCmd.Stdout = pipStdout
|
|
||||||
pipCmd.Stderr = pipStderr
|
|
||||||
|
|
||||||
if pipErr := pipCmd.Run(); pipErr == nil {
|
|
||||||
return fmt.Sprintf("Updated via pip (%s):\n%s", pipPath, pipStdout.String()), nil
|
|
||||||
} else {
|
|
||||||
// Pip failed too
|
|
||||||
return "", fmt.Errorf("pip update failed: %w, stderr: %s", pipErr, pipStderr.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("update failed: %w, stderr: %s", err, errStr)
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
2013
backend-rust/Cargo.lock
generated
Normal file
2013
backend-rust/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
14
backend-rust/Cargo.toml
Normal file
14
backend-rust/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
[package]
|
||||||
|
name = "backend-rust"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.8.8"
|
||||||
|
reqwest = { version = "0.13.2", features = ["json"] }
|
||||||
|
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"] }
|
||||||
82
backend-rust/src/api.rs
Normal file
82
backend-rust/src/api.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::IntoResponse,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::fs::File;
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
|
use crate::spotdl::SpotdlService;
|
||||||
|
use crate::models::{Playlist, Track};
|
||||||
|
|
||||||
|
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"})));
|
||||||
|
}
|
||||||
|
|
||||||
|
match state.spotdl.search_artist(query) {
|
||||||
|
Ok(img) => (StatusCode::OK, Json(serde_json::json!({"image": img}))),
|
||||||
|
Err(e) => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": e}))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()))
|
||||||
|
}
|
||||||
41
backend-rust/src/main.rs
Normal file
41
backend-rust/src/main.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
mod api;
|
||||||
|
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))
|
||||||
|
.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();
|
||||||
|
}
|
||||||
54
backend-rust/src/models.rs
Normal file
54
backend-rust/src/models.rs
Normal 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>,
|
||||||
|
}
|
||||||
361
backend-rust/src/spotdl.rs
Normal file
361
backend-rust/src/spotdl.rs
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
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 crate::models::{Track, YTResult, StaticPlaylist};
|
||||||
|
|
||||||
|
struct CacheItem {
|
||||||
|
tracks: Vec<Track>,
|
||||||
|
timestamp: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SpotdlService {
|
||||||
|
download_dir: PathBuf,
|
||||||
|
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);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
download_dir,
|
||||||
|
search_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
browse_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yt_dlp_path() -> String {
|
||||||
|
// Try local
|
||||||
|
if let Ok(exe_path) = env::current_exe() {
|
||||||
|
if let Some(dir) = exe_path.parent() {
|
||||||
|
let local = dir.join("yt-dlp.exe");
|
||||||
|
if local.exists() {
|
||||||
|
return local.to_string_lossy().into_owned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try working dir
|
||||||
|
if Path::new("yt-dlp.exe").exists() {
|
||||||
|
return "./yt-dlp.exe".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Python
|
||||||
|
if let Ok(home) = env::var("USERPROFILE") {
|
||||||
|
let py_path = Path::new(&home).join("AppData").join("Local").join("Programs").join("Python").join("Python312").join("Scripts").join("yt-dlp.exe");
|
||||||
|
if py_path.exists() {
|
||||||
|
return py_path.to_string_lossy().into_owned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"yt-dlp".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_background_preload(&self) {
|
||||||
|
let cache_arc = self.browse_cache.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
println!("Background preloader started... fetching Top Albums & Playlists");
|
||||||
|
let queries = vec![
|
||||||
|
("Top Albums", "ytsearch50:Top Albums Vietnam audio"),
|
||||||
|
("Viral Hits", "ytsearch30:Viral Hits Vietnam audio"),
|
||||||
|
("Lofi Chill", "ytsearch30:Lofi Chill Vietnam audio"),
|
||||||
|
("US UK Top Hits", "ytsearch30:US UK Billboard Hot 100 audio"),
|
||||||
|
("K-Pop", "ytsearch30:K-Pop Top Hits 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(&[&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 = "ytsearch30:V-Pop Official Channel";
|
||||||
|
if let Ok(o) = Command::new(&path)
|
||||||
|
.args(&[&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(&[&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(&["-f", "bestaudio[ext=m4a]/bestaudio", "--output", &format!("{}.%(ext)s", video_id), &target_url])
|
||||||
|
.output() {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => return Err(format!("Download spawn failed: {}", e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!("Download failed. stderr: {}", String::from_utf8_lossy(&output.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 fn search_artist(&self, query: &str) -> Result<String, String> {
|
||||||
|
let path = Self::yt_dlp_path();
|
||||||
|
|
||||||
|
// Search specifically for official channel to get the avatar
|
||||||
|
let search_query = format!("ytsearch1:{} official channel", query);
|
||||||
|
|
||||||
|
let output = match Command::new(&path)
|
||||||
|
.args(&[&search_query, "--dump-json", "--flat-playlist"])
|
||||||
|
.output() {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(_) => return Err("Search failed to execute".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct SimpleYT {
|
||||||
|
id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
thumbnails: Vec<crate::models::YTThumbnail>,
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in stdout.lines() {
|
||||||
|
if let Ok(res) = serde_json::from_str::<SimpleYT>(line) {
|
||||||
|
// If it's a channel (starts with UC), use its avatar
|
||||||
|
if res.id.starts_with("UC") {
|
||||||
|
let best_thumb = res.thumbnails.iter().max_by_key(|t| {
|
||||||
|
let w = t.width.unwrap_or(0);
|
||||||
|
let h = t.height.unwrap_or(0);
|
||||||
|
w * h
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(thumb) = best_thumb {
|
||||||
|
return Ok(thumb.url.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: If no channel found, try searching normally but stay alert for channel icons
|
||||||
|
Err("No authentic channel photo found for artist".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_id(url: &str) -> String {
|
||||||
|
if url.contains("v=") {
|
||||||
|
let parts: Vec<&str> = url.split("v=").collect();
|
||||||
|
if parts.len() > 1 {
|
||||||
|
let sub_parts: Vec<&str> = parts[1].split('&').collect();
|
||||||
|
return sub_parts[0].to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
services:
|
services:
|
||||||
spotify-clone:
|
spotify-clone:
|
||||||
image: vndangkhoa/spotify-clone:latest
|
image: git.khoavo.myds.me/vndangkhoa/spotify-clone:v3
|
||||||
container_name: spotify-clone
|
container_name: spotify-clone
|
||||||
restart: always
|
restart: unless-stopped
|
||||||
network_mode: bridge # Synology often prefers explicit bridge or host
|
ports:
|
||||||
ports:
|
- "3000:8080"
|
||||||
- "3110:3000" # Web UI
|
environment:
|
||||||
|
- PORT=8080
|
||||||
volumes:
|
- RUST_ENV=production
|
||||||
- ./data:/app/backend/data
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./cache:/tmp/spotify-clone-cache
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Play, Pause, SkipBack, SkipForward, Repeat, Shuffle, Volume2, Download, PlusCircle, Mic2, Heart, Loader2, ListMusic, MonitorSpeaker, Maximize2, MoreHorizontal, Info, ChevronUp } from 'lucide-react';
|
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 { usePlayer } from "../context/PlayerContext";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import TechSpecs from './TechSpecs';
|
import TechSpecs from './TechSpecs';
|
||||||
import AddToPlaylistModal from "./AddToPlaylistModal";
|
import AddToPlaylistModal from "./AddToPlaylistModal";
|
||||||
import Lyrics from './Lyrics';
|
import Lyrics from './Lyrics';
|
||||||
|
|
@ -13,7 +13,8 @@ export default function PlayerBar() {
|
||||||
const {
|
const {
|
||||||
currentTrack, isPlaying, isBuffering, togglePlay, setBuffering,
|
currentTrack, isPlaying, isBuffering, togglePlay, setBuffering,
|
||||||
likedTracks, toggleLike, nextTrack, prevTrack, shuffle, toggleShuffle,
|
likedTracks, toggleLike, nextTrack, prevTrack, shuffle, toggleShuffle,
|
||||||
repeatMode, toggleRepeat, audioQuality, isLyricsOpen, toggleLyrics, closeLyrics, openLyrics
|
repeatMode, toggleRepeat, audioQuality, isLyricsOpen, toggleLyrics, closeLyrics, openLyrics,
|
||||||
|
isFullScreenOpen, setIsFullScreenOpen
|
||||||
} = usePlayer();
|
} = usePlayer();
|
||||||
|
|
||||||
const dominantColor = useDominantColor(currentTrack?.cover_url);
|
const dominantColor = useDominantColor(currentTrack?.cover_url);
|
||||||
|
|
@ -54,39 +55,102 @@ export default function PlayerBar() {
|
||||||
|
|
||||||
const [volume, setVolume] = useState(1);
|
const [volume, setVolume] = useState(1);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
// Modal State
|
// Modal State
|
||||||
const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false);
|
const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false);
|
||||||
const [isTechSpecsOpen, setIsTechSpecsOpen] = useState(false);
|
const [isTechSpecsOpen, setIsTechSpecsOpen] = useState(false);
|
||||||
const [isFullScreenPlayerOpen, setIsFullScreenPlayerOpen] = useState(false);
|
|
||||||
const [isCoverModalOpen, setIsCoverModalOpen] = useState(false);
|
|
||||||
const [isQueueOpen, setIsQueueOpen] = useState(false);
|
const [isQueueOpen, setIsQueueOpen] = useState(false);
|
||||||
const [isInfoOpen, setIsInfoOpen] = useState(false);
|
const [isInfoOpen, setIsInfoOpen] = useState(false);
|
||||||
const [playerMode, setPlayerMode] = useState<'audio' | 'video'>('audio');
|
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")
|
// Force close lyrics on mount (Defensive fix for "Open on first play")
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
closeLyrics();
|
closeLyrics();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Auto-close fullscreen player on navigation
|
||||||
|
useEffect(() => {
|
||||||
|
setPlayerMode('audio');
|
||||||
|
setIsFullScreenOpen(false);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
// Reset to audio mode when track changes
|
// Reset to audio mode when track changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPlayerMode('audio');
|
setPlayerMode('audio');
|
||||||
|
setIsIdle(false);
|
||||||
|
setIsVideoReady(false);
|
||||||
}, [currentTrack?.id]);
|
}, [currentTrack?.id]);
|
||||||
|
|
||||||
|
// Handle idle timer when playing video
|
||||||
|
useEffect(() => {
|
||||||
|
resetIdleTimer();
|
||||||
|
return () => {
|
||||||
|
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [isPlaying, playerMode]);
|
||||||
|
|
||||||
// Handle audio/video mode switching
|
// Handle audio/video mode switching
|
||||||
const handleModeSwitch = (mode: 'audio' | 'video') => {
|
const handleModeSwitch = (mode: 'audio' | 'video') => {
|
||||||
if (mode === '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();
|
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 {
|
} else {
|
||||||
// Switching back to audio
|
// Switching back to audio
|
||||||
// Optionally sync time if we could get it from video, but for now just resume
|
if (isPlaying) {
|
||||||
if (!isPlaying) togglePlay();
|
audioRef.current?.play().catch(() => { });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setPlayerMode(mode);
|
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)
|
// ... (rest of useEffects)
|
||||||
|
|
||||||
// ... inside return ...
|
// ... inside return ...
|
||||||
|
|
@ -112,8 +176,10 @@ export default function PlayerBar() {
|
||||||
}
|
}
|
||||||
}, [currentTrack?.url]);
|
}, [currentTrack?.url]);
|
||||||
|
|
||||||
// Play/Pause effect
|
// Play/Pause effect - skip when in video mode (YouTube controls playback)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (playerMode === 'video') return; // Skip audio control in video mode
|
||||||
|
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
audioRef.current.play().catch(e => {
|
audioRef.current.play().catch(e => {
|
||||||
|
|
@ -125,7 +191,7 @@ export default function PlayerBar() {
|
||||||
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = "paused";
|
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = "paused";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isPlaying]);
|
}, [isPlaying, playerMode]);
|
||||||
|
|
||||||
// Volume Effect
|
// Volume Effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -134,16 +200,8 @@ export default function PlayerBar() {
|
||||||
}
|
}
|
||||||
}, [volume]);
|
}, [volume]);
|
||||||
|
|
||||||
// Sync Play/Pause with YouTube Iframe
|
// Note: YouTube iframe play/pause sync is handled via URL autoplay parameter
|
||||||
useEffect(() => {
|
// Cross-origin restrictions prevent reliable postMessage control
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleTimeUpdate = () => {
|
const handleTimeUpdate = () => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
|
|
@ -211,7 +269,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"
|
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={() => {
|
onClick={() => {
|
||||||
if (window.innerWidth < 1024) {
|
if (window.innerWidth < 1024) {
|
||||||
setIsFullScreenPlayerOpen(true);
|
setIsFullScreenOpen(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -254,8 +312,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"
|
className="h-14 w-14 fold:h-14 fold:w-14 rounded-xl object-cover ml-1 fold:ml-0 cursor-pointer"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (window.innerWidth >= 700) setIsCoverModalOpen(true);
|
setIsFullScreenOpen(true);
|
||||||
else setIsFullScreenPlayerOpen(true);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -413,143 +470,144 @@ export default function PlayerBar() {
|
||||||
|
|
||||||
{/* Mobile Full Screen Player Overlay */}
|
{/* Mobile Full Screen Player Overlay */}
|
||||||
<div
|
<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)` }}
|
style={{ background: `linear-gradient(to bottom, ${dominantColor}, #121212)` }}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
{/* Header / Close */}
|
{/* Header / Close */}
|
||||||
<div className="flex items-center justify-between p-4 pt-8">
|
<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={() => setIsFullScreenPlayerOpen(false)} className="text-white">
|
<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">
|
<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" />
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Song / Video Toggle */}
|
{/* 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
|
<button
|
||||||
onClick={() => handleModeSwitch('audio')}
|
onClick={(e) => { e.stopPropagation(); handleModeSwitch('audio'); }}
|
||||||
className={`px-6 py-1 rounded-full text-xs font-bold transition ${playerMode === 'audio' ? 'bg-[#333] text-white' : 'text-neutral-500'}`}
|
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
|
Song
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleModeSwitch('video')}
|
onClick={(e) => { e.stopPropagation(); handleModeSwitch('video'); }}
|
||||||
className={`px-6 py-1 rounded-full text-xs font-bold transition ${playerMode === 'video' ? 'bg-[#333] text-white' : 'text-neutral-500'}`}
|
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
|
Video
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-6" />
|
<div className="w-10" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Responsive Split View Container */}
|
{/* Content Area */}
|
||||||
<div className="flex-1 flex flex-col md:flex-row w-full overflow-hidden">
|
<div
|
||||||
{/* Left/Top: Art or Video */}
|
className="flex-1 relative overflow-hidden group"
|
||||||
<div className="flex-1 flex items-center justify-center p-8 md:p-12">
|
onMouseMove={resetIdleTimer}
|
||||||
{playerMode === 'audio' ? (
|
onTouchStart={resetIdleTimer}
|
||||||
<img
|
>
|
||||||
src={currentTrack.cover_url}
|
{playerMode === 'video' ? (
|
||||||
alt={currentTrack.title}
|
/* CINEMATIC VIDEO MODE: Full Background Video */
|
||||||
className="w-full aspect-square object-cover rounded-3xl shadow-2xl max-h-[50vh] md:max-h-none"
|
<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="w-full aspect-video rounded-3xl overflow-hidden shadow-2xl bg-black">
|
<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
|
<iframe
|
||||||
|
key={`${currentTrack.id}-${playerMode}`}
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
width="100%"
|
width="100%"
|
||||||
height="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"
|
title="YouTube video player"
|
||||||
frameBorder="0"
|
frameBorder="0"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
|
className={`pointer-events-auto transition-opacity duration-500 ${isVideoReady ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
onLoad={() => setIsVideoReady(true)}
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{/* Overlay Gradient for cinematic feel */}
|
||||||
</div>
|
<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-3xl 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 */}
|
{/* Controls Overlay (Bottom) */}
|
||||||
<div className="flex-1 flex flex-col justify-center px-8 pb-12 md:pb-0 md:pr-12 overflow-y-auto no-scrollbar">
|
<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'}`}>
|
||||||
{/* Track Info */}
|
<div className="max-w-screen-xl mx-auto flex flex-col md:flex-row md:items-end gap-8">
|
||||||
<div className="flex items-center justify-between mb-6">
|
{/* Metadata */}
|
||||||
<div className="flex-1 mr-4">
|
<div className="flex-1">
|
||||||
<h2 className="text-2xl md:text-4xl font-bold text-white line-clamp-2 md:mb-2">{currentTrack.title}</h2>
|
<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
|
<p
|
||||||
onClick={() => { setIsFullScreenPlayerOpen(false); navigate(`/artist/${encodeURIComponent(currentTrack.artist)}`); }}
|
onClick={() => { setPlayerMode('audio'); setIsFullScreenOpen(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"
|
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}
|
{currentTrack.artist}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<button onClick={() => toggleLike(currentTrack)} className={likedTracks.has(currentTrack.id) ? 'text-green-500' : 'text-neutral-400'}>
|
{/* Secondary Actions */}
|
||||||
<Heart size={28} fill={likedTracks.has(currentTrack.id) ? "currentColor" : "none"} />
|
<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>
|
||||||
<button onClick={() => setIsInfoOpen(true)} className="text-neutral-400 hover:text-white">
|
<button onClick={() => setIsInfoOpen(true)} className="p-3 rounded-full hover:bg-white/10 transition text-white/60 hover:text-white">
|
||||||
<Info size={24} />
|
<Info size={28} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress */}
|
{/* Scrubber & Controls */}
|
||||||
<div className="mb-8">
|
<div className="max-w-screen-md mx-auto mt-8">
|
||||||
<input
|
{/* Scrubber */}
|
||||||
type="range"
|
<div className="mb-8">
|
||||||
min={0}
|
<input
|
||||||
max={duration || 100}
|
type="range"
|
||||||
value={progress}
|
min={0}
|
||||||
onChange={handleSeek}
|
max={duration || 100}
|
||||||
onMouseUp={handleSeekCommit}
|
value={progress}
|
||||||
onTouchEnd={handleSeekCommit}
|
onChange={handleSeek}
|
||||||
className="w-full h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-white mb-2"
|
onMouseUp={handleSeekCommit}
|
||||||
/>
|
onTouchEnd={handleSeekCommit}
|
||||||
<div className="flex justify-between text-xs text-neutral-400 font-medium font-mono">
|
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"
|
||||||
<span>{formatTime(progress)}</span>
|
/>
|
||||||
<span>{formatTime(duration)}</span>
|
<div className="flex justify-between text-[10px] md:text-xs text-white/50 font-bold uppercase tracking-widest font-mono">
|
||||||
</div>
|
<span>{formatTime(progress)}</span>
|
||||||
</div>
|
<span>{formatTime(duration)}</span>
|
||||||
|
|
||||||
{/* 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'}>
|
|
||||||
<Shuffle size={24} />
|
|
||||||
</button>
|
|
||||||
<button onClick={prevTrack} className="text-white hover:text-neutral-300 transition">
|
|
||||||
<SkipBack size={32} 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>
|
|
||||||
<button onClick={nextTrack} className="text-white hover:text-neutral-300 transition">
|
|
||||||
<SkipForward size={32} fill="currentColor" />
|
|
||||||
</button>
|
|
||||||
<button onClick={toggleRepeat} className={repeatMode !== 'none' ? 'text-green-500' : 'text-neutral-400'}>
|
|
||||||
<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>
|
||||||
|
|
||||||
|
{/* 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:scale-110 active:scale-95 transition">
|
||||||
|
<SkipBack size={42} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
<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:scale-110 active:scale-95 transition">
|
||||||
|
<SkipForward size={42} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -574,7 +632,7 @@ export default function PlayerBar() {
|
||||||
<p className="text-sm text-neutral-400">Artist</p>
|
<p className="text-sm text-neutral-400">Artist</p>
|
||||||
<p
|
<p
|
||||||
className="font-medium text-lg text-spotify-highlight cursor-pointer hover:underline"
|
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}
|
{currentTrack.artist}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -590,9 +648,9 @@ export default function PlayerBar() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<QueueModal
|
<QueueModal
|
||||||
isOpen={isQueueOpen}
|
isOpen={isQueueOpen}
|
||||||
onClose={() => setIsQueueOpen(false)}
|
onClose={() => setIsQueueOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
|
||||||
import { useState } from 'react';
|
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 { useTheme } from '../context/ThemeContext';
|
||||||
|
import { usePlayer } from '../context/PlayerContext';
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -10,9 +11,11 @@ interface SettingsModalProps {
|
||||||
|
|
||||||
export default function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
export default function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const { qualityPreference, setQualityPreference } = usePlayer();
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const [updateStatus, setUpdateStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
const [updateStatus, setUpdateStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||||
const [updateLog, setUpdateLog] = useState<string>('');
|
const [updateLog, setUpdateLog] = useState<string>('');
|
||||||
|
const [isClearingCache, setIsClearingCache] = useState(false);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
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_v6');
|
||||||
|
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 (
|
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
|
<div
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
className="absolute inset-0 bg-black/70 backdrop-blur-md"
|
||||||
onClick={onClose}
|
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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
<div className={`flex items-center justify-between p-5 border-b ${isApple ? 'border-white/10' : 'border-[#282828]'}`}>
|
||||||
<h2 className="text-xl font-bold">Settings</h2>
|
<div className="flex items-center gap-3">
|
||||||
<button onClick={onClose} className="p-2 rounded-full hover:bg-white/10 transition">
|
<div className={`p-2 rounded-xl ${isApple ? 'bg-[#fa2d48]/20 text-[#fa2d48]' : 'bg-green-500/20 text-green-500'}`}>
|
||||||
<X className="w-5 h-5" />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Scrollable Content */}
|
||||||
<div className="p-4 space-y-6 max-h-[70vh] overflow-y-auto no-scrollbar">
|
<div className="flex-1 overflow-y-auto p-5 space-y-8 no-scrollbar pb-10">
|
||||||
|
|
||||||
{/* Appearance Section */}
|
{/* Appearance Section */}
|
||||||
<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">
|
<div className="grid grid-cols-1 xs:grid-cols-2 gap-4">
|
||||||
{/* Spotify Theme Option */}
|
{/* Spotify Theme */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleTheme('spotify')}
|
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="flex items-center justify-between w-full">
|
||||||
<div className="w-5 h-5 rounded-full bg-green-500" />
|
<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>
|
||||||
|
{theme === 'spotify' && <CheckCircle2 className="w-6 h-6 text-green-500" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div>
|
||||||
<div className="font-semibold text-base">Spotify</div>
|
<div className="font-bold text-lg">Spotify</div>
|
||||||
<div className="text-xs text-neutral-400">Classic Dark Mode</div>
|
<div className="text-xs text-neutral-400">Classic immersive dark mode</div>
|
||||||
</div>
|
</div>
|
||||||
{theme === 'spotify' && <CheckCircle2 className="w-5 h-5 text-green-500" />}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Apple Music Theme Option */}
|
{/* Apple Music Theme */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleTheme('apple')}
|
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="flex items-center justify-between w-full">
|
||||||
<div className="w-5 h-5 text-white"></div>
|
<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>
|
||||||
|
{theme === 'apple' && <CheckCircle2 className="w-6 h-6 text-[#fa2d48]" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div>
|
||||||
<div className="font-semibold text-base">Apple Music</div>
|
<div className="font-bold text-lg">Apple Music</div>
|
||||||
<div className="text-xs text-neutral-400">Liquid Glass & Blur</div>
|
<div className="text-xs text-neutral-400">Liquid glass & vibrant blurs</div>
|
||||||
</div>
|
</div>
|
||||||
{theme === 'apple' && <CheckCircle2 className="w-5 h-5 text-[#fa2d48]" />}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 */}
|
{/* System Section */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold mb-3 text-neutral-400 uppercase tracking-wider text-xs">System</h3>
|
<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>
|
||||||
|
|
||||||
<div className={`p-4 rounded-xl border ${theme === 'apple' ? 'bg-[#2c2c2e] border-white/5' : 'bg-[#181818] border-[#282828]'}`}>
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between mb-3">
|
{/* Core Update */}
|
||||||
<div>
|
<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="font-semibold text-base flex items-center gap-2">
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-bold text-sm flex items-center gap-2 mb-1">
|
||||||
Core Update
|
Core Update
|
||||||
<span className="text-[10px] bg-white/10 px-1.5 py-0.5 rounded text-neutral-400">yt-dlp nightly</span>
|
<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>
|
</div>
|
||||||
<p className="text-xs text-neutral-400 mt-1">Updates the underlying download engine.</p>
|
<p className="text-[10px] md:text-[11px] text-neutral-400">Keep the extraction engine fresh for new content.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleUpdateYtdlp}
|
onClick={handleUpdateYtdlp}
|
||||||
disabled={isUpdating}
|
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' : ''}`} />
|
<RefreshCcw className={`w-3.5 h-3.5 ${isUpdating ? 'animate-spin' : ''}`} />
|
||||||
{isUpdating ? 'Updating...' : 'Update'}
|
{isUpdating ? 'Executing...' : 'Update Engine'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logs */}
|
{/* Clear Cache */}
|
||||||
{(updateStatus !== 'idle' || updateLog) && (
|
<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="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">
|
<div className="flex-1 min-w-0">
|
||||||
{updateStatus === 'loading' && <span className="text-blue-400">Executing update command...{'\n'}</span>}
|
<div className="font-bold text-sm mb-1">Clear Local Cache</div>
|
||||||
{updateLog}
|
<p className="text-[10px] md:text-[11px] text-neutral-400">Wipe browse and image caches if data feels stale.</p>
|
||||||
{updateStatus === 'success' && <span className="text-green-400">{'\n'}Done!</span>}
|
|
||||||
{updateStatus === 'error' && <span className="text-red-400">{'\n'}Error Occurred.</span>}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
{/* Logs Reveal */}
|
||||||
|
{(updateStatus !== 'idle' || updateLog) && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="text-center text-[10px] text-neutral-500 pt-4">
|
<div className="pt-6 flex flex-col items-center gap-2 opacity-30 group cursor-default">
|
||||||
KV Spotify Clone v1.0.0
|
<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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,14 @@ interface PlayerContextType {
|
||||||
toggleLike: (track: Track) => void;
|
toggleLike: (track: Track) => void;
|
||||||
playHistory: Track[];
|
playHistory: Track[];
|
||||||
audioQuality: AudioQuality | null;
|
audioQuality: AudioQuality | null;
|
||||||
|
qualityPreference: 'auto' | 'high' | 'normal' | 'low';
|
||||||
|
setQualityPreference: (quality: 'auto' | 'high' | 'normal' | 'low') => void;
|
||||||
isLyricsOpen: boolean;
|
isLyricsOpen: boolean;
|
||||||
toggleLyrics: () => void;
|
toggleLyrics: () => void;
|
||||||
closeLyrics: () => void;
|
closeLyrics: () => void;
|
||||||
openLyrics: () => void;
|
openLyrics: () => void;
|
||||||
|
isFullScreenOpen: boolean;
|
||||||
|
setIsFullScreenOpen: (open: boolean) => void;
|
||||||
queue: Track[];
|
queue: Track[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,6 +42,13 @@ export function PlayerProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
// Audio Engine State
|
// Audio Engine State
|
||||||
const [audioQuality, setAudioQuality] = useState<AudioQuality | null>(null);
|
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
|
// Queue State
|
||||||
const [queue, setQueue] = useState<Track[]>([]);
|
const [queue, setQueue] = useState<Track[]>([]);
|
||||||
|
|
@ -54,6 +65,9 @@ export function PlayerProvider({ children }: { children: ReactNode }) {
|
||||||
const closeLyrics = () => setIsLyricsOpen(false);
|
const closeLyrics = () => setIsLyricsOpen(false);
|
||||||
const openLyrics = () => setIsLyricsOpen(true);
|
const openLyrics = () => setIsLyricsOpen(true);
|
||||||
|
|
||||||
|
// Full Screen Player State
|
||||||
|
const [isFullScreenOpen, setIsFullScreenOpen] = useState(false);
|
||||||
|
|
||||||
// Load Likes from DB
|
// Load Likes from DB
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dbService.getLikedSongs().then(tracks => {
|
dbService.getLikedSongs().then(tracks => {
|
||||||
|
|
@ -200,10 +214,14 @@ export function PlayerProvider({ children }: { children: ReactNode }) {
|
||||||
toggleLike,
|
toggleLike,
|
||||||
playHistory,
|
playHistory,
|
||||||
audioQuality,
|
audioQuality,
|
||||||
|
qualityPreference,
|
||||||
|
setQualityPreference,
|
||||||
isLyricsOpen,
|
isLyricsOpen,
|
||||||
toggleLyrics,
|
toggleLyrics,
|
||||||
closeLyrics,
|
closeLyrics,
|
||||||
openLyrics,
|
openLyrics,
|
||||||
|
isFullScreenOpen,
|
||||||
|
setIsFullScreenOpen,
|
||||||
queue
|
queue
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { libraryService } from '../services/library';
|
import { libraryService } from '../services/library';
|
||||||
import { usePlayer } from '../context/PlayerContext';
|
import { usePlayer } from '../context/PlayerContext';
|
||||||
import { Play, Shuffle, Heart, Clock, ListPlus, Download } from 'lucide-react';
|
import { Play, Shuffle, Heart, Clock, ListPlus, Download } from 'lucide-react';
|
||||||
|
|
@ -7,31 +7,47 @@ import { Track } from '../types';
|
||||||
|
|
||||||
export default function Album() {
|
export default function Album() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { playTrack, toggleLike, likedTracks } = usePlayer();
|
const { playTrack, toggleLike, likedTracks, setIsFullScreenOpen, currentTrack } = usePlayer();
|
||||||
const [tracks, setTracks] = useState<Track[]>([]);
|
const [tracks, setTracks] = useState<Track[]>([]);
|
||||||
const [albumInfo, setAlbumInfo] = useState<{ title: string, artist: string, cover?: string, year?: string } | null>(null);
|
const [albumInfo, setAlbumInfo] = useState<{ title: string, artist: string, cover?: string, year?: string } | null>(null);
|
||||||
|
const [moreByArtist, setMoreByArtist] = useState<Track[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// If ID is from YTM, ideally we fetch album.
|
|
||||||
// If logic is "Search Album", we do that.
|
|
||||||
|
|
||||||
const fetchAlbum = async () => {
|
const fetchAlbum = async () => {
|
||||||
// For now, assume ID is search query or we query "Album"
|
const queryId = decodeURIComponent(id);
|
||||||
// In this reskin, we usually pass Name as ID due to router setup in Home.
|
|
||||||
|
|
||||||
const query = decodeURIComponent(id);
|
|
||||||
try {
|
try {
|
||||||
const results = await libraryService.search(query);
|
const album = await libraryService.getAlbum(queryId);
|
||||||
if (results.length > 0) {
|
|
||||||
|
if (album) {
|
||||||
|
setTracks(album.tracks);
|
||||||
|
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(album.tracks.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);
|
setTracks(results);
|
||||||
setAlbumInfo({
|
setAlbumInfo({
|
||||||
title: query.replace(/^search-|^album-/, '').replace(/-/g, ' '), // Clean up slug
|
title: cleanTitle,
|
||||||
artist: results[0].artist,
|
artist: results.length > 0 ? results[0].artist : "Unknown Artist",
|
||||||
cover: results[0].cover_url,
|
cover: results.length > 0 ? results[0].cover_url : undefined,
|
||||||
year: '2024' // Mock or fetch
|
year: '2024'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -49,17 +65,42 @@ export default function Album() {
|
||||||
const formattedDuration = `${Math.floor(totalDuration / 60)} minutes`;
|
const formattedDuration = `${Math.floor(totalDuration / 60)} minutes`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto bg-gradient-to-b from-[#2e2e2e] to-black pb-32">
|
<div className="flex-1 overflow-y-auto bg-[#121212] no-scrollbar pb-32 relative">
|
||||||
<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">
|
{/* 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 */}
|
{/* 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">
|
<div
|
||||||
<img src={albumInfo.cover} alt={albumInfo.title} className="w-full h-full object-cover" />
|
className="w-48 h-48 md:w-64 md:h-64 shadow-[0_20px_50px_rgba(0,0,0,0.5)] rounded-lg 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>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="flex flex-col items-center md:items-start text-center md:text-left gap-2 md:gap-4 flex-1">
|
<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>
|
<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">
|
<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" />
|
<img src={albumInfo.cover} className="w-6 h-6 rounded-full" />
|
||||||
<span className="hover:underline cursor-pointer">{albumInfo.artist}</span>
|
<span className="hover:underline cursor-pointer">{albumInfo.artist}</span>
|
||||||
|
|
@ -131,6 +172,40 @@ export default function Album() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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-md 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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ interface ArtistData {
|
||||||
export default function Artist() {
|
export default function Artist() {
|
||||||
const { id } = useParams(); // Start with name or id
|
const { id } = useParams(); // Start with name or id
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { playTrack, toggleLike, likedTracks } = usePlayer();
|
const { playTrack, toggleLike, likedTracks, setIsFullScreenOpen } = usePlayer();
|
||||||
|
|
||||||
const [artist, setArtist] = useState<ArtistData | null>(null);
|
const [artist, setArtist] = useState<ArtistData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -104,7 +104,11 @@ export default function Artist() {
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<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"
|
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} />
|
<Play fill="currentColor" size={20} />
|
||||||
|
|
@ -183,7 +187,9 @@ export default function Artist() {
|
||||||
<div
|
<div
|
||||||
key={track.id}
|
key={track.id}
|
||||||
className="group cursor-pointer"
|
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-lg overflow-hidden mb-3 relative">
|
||||||
<img src={track.cover_url} className="w-full h-full object-cover transition duration-300 group-hover:scale-105" />
|
<img src={track.cover_url} className="w-full h-full object-cover transition duration-300 group-hover:scale-105" />
|
||||||
|
|
@ -211,7 +217,9 @@ export default function Artist() {
|
||||||
<div
|
<div
|
||||||
key={track.id}
|
key={track.id}
|
||||||
className="group cursor-pointer"
|
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-xl 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" />
|
<img src={track.cover_url} className="w-full h-full object-cover transition duration-300 group-hover:scale-105" />
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ export default function Home() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [sortBy, setSortBy] = useState<SortOption>('recent');
|
const [sortBy, setSortBy] = useState<SortOption>('recent');
|
||||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||||
const { playTrack, playHistory } = usePlayer();
|
const [heroPlaylist, setHeroPlaylist] = useState<StaticPlaylist | null>(null);
|
||||||
|
const { playTrack, playHistory, setIsFullScreenOpen, currentTrack } = usePlayer();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
|
|
@ -24,7 +25,7 @@ export default function Home() {
|
||||||
else setTimeOfDay("Good evening");
|
else setTimeOfDay("Good evening");
|
||||||
|
|
||||||
// Cache First Strategy for "Super Fast" loading
|
// Cache First Strategy for "Super Fast" loading
|
||||||
const cached = localStorage.getItem('ytm_browse_cache_v4');
|
const cached = localStorage.getItem('ytm_browse_cache_v6');
|
||||||
if (cached) {
|
if (cached) {
|
||||||
setBrowseData(JSON.parse(cached));
|
setBrowseData(JSON.parse(cached));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -36,7 +37,14 @@ export default function Home() {
|
||||||
setBrowseData(data);
|
setBrowseData(data);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
// Update Cache
|
// Update Cache
|
||||||
localStorage.setItem('ytm_browse_cache_v4', JSON.stringify(data));
|
localStorage.setItem('ytm_browse_cache_v6', 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 => {
|
.catch(err => {
|
||||||
console.error("Error fetching browse:", err);
|
console.error("Error fetching browse:", err);
|
||||||
|
|
@ -59,9 +67,6 @@ export default function Home() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const firstCategory = Object.keys(browseData)[0];
|
|
||||||
const heroPlaylist = firstCategory && browseData[firstCategory].length > 0 ? browseData[firstCategory][0] : null;
|
|
||||||
|
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ value: 'recent', label: 'Recently Added', icon: Clock },
|
{ value: 'recent', label: 'Recently Added', icon: Clock },
|
||||||
{ value: 'alpha-asc', label: 'Alphabetical (A-Z)', icon: ArrowUpDown },
|
{ value: 'alpha-asc', label: 'Alphabetical (A-Z)', icon: ArrowUpDown },
|
||||||
|
|
@ -129,9 +134,9 @@ export default function Home() {
|
||||||
fallbackText="VB"
|
fallbackText="VB"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<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">
|
<p className="text-[#a7a7a7] text-sm md:text-base line-clamp-2 md:line-clamp-3 max-w-2xl mb-6">
|
||||||
{heroPlaylist.description}
|
{heroPlaylist.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -166,13 +171,20 @@ export default function Home() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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="mb-8">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-2xl font-bold capitalize hover:underline cursor-pointer">Top Albums</h2>
|
<h2 className="text-2xl font-bold capitalize hover:underline cursor-pointer">Top Albums</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-4">
|
<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) => (
|
{uniqueAlbums.slice(0, 15).map((album) => (
|
||||||
<Link to={`/album/${album.id}`} key={album.id}>
|
<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="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">
|
<div className="relative mb-2 md:mb-3">
|
||||||
|
|
@ -182,7 +194,14 @@ export default function Home() {
|
||||||
className="w-full aspect-square rounded-xl shadow-lg"
|
className="w-full aspect-square rounded-xl shadow-lg"
|
||||||
fallbackText={album.title?.substring(0, 2).toUpperCase()}
|
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">
|
<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" />
|
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-5 md:h-5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -195,7 +214,8 @@ export default function Home() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Browse Lists */}
|
{/* Browse Lists */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -217,8 +237,15 @@ export default function Home() {
|
||||||
</div>
|
</div>
|
||||||
) : Object.keys(browseData).length > 0 ? (
|
) : Object.keys(browseData).length > 0 ? (
|
||||||
Object.entries(browseData)
|
Object.entries(browseData)
|
||||||
.filter(([category]) => category !== "Top Albums") // Filter out albums since we showed them above
|
.filter(([category, items]) => category !== "Top Albums" && (items as any[]).length > 0)
|
||||||
.map(([category, playlists]) => (
|
.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 key={category} className="mb-8">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-2xl font-bold capitalize hover:underline cursor-pointer">{category}</h2>
|
<h2 className="text-2xl font-bold capitalize hover:underline cursor-pointer">{category}</h2>
|
||||||
|
|
@ -229,7 +256,7 @@ export default function Home() {
|
||||||
|
|
||||||
{/* USER REQUEST: Bigger Grid, Smaller Text, Smaller Gap */}
|
{/* 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">
|
<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) => (
|
{sortPlaylists(uniquePlaylists).slice(0, 15).map((playlist) => (
|
||||||
<Link to={`/playlist/${playlist.id}`} key={playlist.id}>
|
<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="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">
|
<div className="relative mb-2 md:mb-4">
|
||||||
|
|
@ -239,7 +266,14 @@ export default function Home() {
|
||||||
className="w-full aspect-square rounded-xl shadow-lg"
|
className="w-full aspect-square rounded-xl shadow-lg"
|
||||||
fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
|
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">
|
<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" />
|
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -252,7 +286,8 @@ export default function Home() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
<h2 className="text-xl font-bold mb-4">Ready to explore?</h2>
|
<h2 className="text-xl font-bold mb-4">Ready to explore?</h2>
|
||||||
|
|
@ -265,6 +300,7 @@ export default function Home() {
|
||||||
|
|
||||||
// Recently Listened Section
|
// Recently Listened Section
|
||||||
function RecentlyListenedSection({ playHistory, playTrack }: { playHistory: Track[], playTrack: (track: Track, queue?: Track[]) => void }) {
|
function RecentlyListenedSection({ playHistory, playTrack }: { playHistory: Track[], playTrack: (track: Track, queue?: Track[]) => void }) {
|
||||||
|
const { setIsFullScreenOpen, currentTrack } = usePlayer();
|
||||||
if (playHistory.length === 0) return null;
|
if (playHistory.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -278,7 +314,9 @@ function RecentlyListenedSection({ playHistory, playTrack }: { playHistory: Trac
|
||||||
{playHistory.slice(0, 10).map((track, i) => (
|
{playHistory.slice(0, 10).map((track, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${track.id}-${i}`}
|
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"
|
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">
|
<div className="relative">
|
||||||
|
|
@ -307,7 +345,7 @@ function RecentlyListenedSection({ playHistory, playTrack }: { playHistory: Trac
|
||||||
|
|
||||||
// Made For You Section
|
// Made For You Section
|
||||||
function MadeForYouSection() {
|
function MadeForYouSection() {
|
||||||
const { playHistory, playTrack } = usePlayer();
|
const { playHistory, playTrack, setIsFullScreenOpen, currentTrack } = usePlayer();
|
||||||
const [recommendations, setRecommendations] = useState<Track[]>([]);
|
const [recommendations, setRecommendations] = useState<Track[]>([]);
|
||||||
const [seedTrack, setSeedTrack] = useState<Track | null>(null);
|
const [seedTrack, setSeedTrack] = useState<Track | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -353,7 +391,9 @@ function MadeForYouSection() {
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-6">
|
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-6">
|
||||||
{recommendations.slice(0, 10).map((track, i) => (
|
{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">
|
<div className="relative mb-2 md:mb-4">
|
||||||
<CoverImage
|
<CoverImage
|
||||||
src={track.cover_url}
|
src={track.cover_url}
|
||||||
|
|
@ -417,8 +457,8 @@ function ArtistVietnamSection() {
|
||||||
|
|
||||||
// 2. Load Photos (Cache First Strategy)
|
// 2. Load Photos (Cache First Strategy)
|
||||||
const loadPhotos = async () => {
|
const loadPhotos = async () => {
|
||||||
// v3: Progressive Loading + Smaller Thumbnails
|
// v5: Force refresh for authentic channel avatars
|
||||||
const cacheKey = 'artist_photos_cache_v3';
|
const cacheKey = 'artist_photos_cache_v6';
|
||||||
const cached = JSON.parse(localStorage.getItem(cacheKey) || '{}');
|
const cached = JSON.parse(localStorage.getItem(cacheKey) || '{}');
|
||||||
|
|
||||||
// Initialize with cache immediately
|
// Initialize with cache immediately
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from 'react';
|
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 { Play, Pause, Clock, Heart, PlusCircle, Shuffle, Trash2, ArrowLeft } from 'lucide-react';
|
||||||
import { usePlayer } from '../context/PlayerContext';
|
import { usePlayer } from '../context/PlayerContext';
|
||||||
import { useLibrary } from '../context/LibraryContext';
|
import { useLibrary } from '../context/LibraryContext';
|
||||||
|
|
@ -15,13 +15,15 @@ type PlaylistData = PlaylistType | StaticPlaylist;
|
||||||
|
|
||||||
export default function Playlist() {
|
export default function Playlist() {
|
||||||
const { id: playlistId } = useParams<{ id: string }>();
|
const { id: playlistId } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [playlist, setPlaylist] = useState<PlaylistData | null>(null);
|
const [playlist, setPlaylist] = useState<PlaylistData | null>(null);
|
||||||
const [loading, setLoading] = useState(true); // Full page loading
|
const [loading, setLoading] = useState(true); // Full page loading
|
||||||
const [loadingTracks, setLoadingTracks] = useState(false); // background track loading
|
const [loadingTracks, setLoadingTracks] = useState(false); // background track loading
|
||||||
const [selectedTrack, setSelectedTrack] = useState<Track | null>(null);
|
const [selectedTrack, setSelectedTrack] = useState<Track | null>(null);
|
||||||
const [isUserPlaylist, setIsUserPlaylist] = useState(false);
|
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();
|
const { libraryItems, userPlaylists, refreshLibrary } = useLibrary();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -74,15 +76,33 @@ export default function Playlist() {
|
||||||
setIsUserPlaylist(true);
|
setIsUserPlaylist(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setLoadingTracks(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 {
|
} else {
|
||||||
// Check API / Library Service (Hydration happens here)
|
// Check API / Library Service (Hydration happens here)
|
||||||
console.log("Fetching from Library Service (Hydrating)...");
|
console.log("Fetching from Library Service (Hydrating)...");
|
||||||
const apiPlaylist = await libraryService.getPlaylist(playlistId);
|
const apiPlaylist = await libraryService.getPlaylist(playlistId);
|
||||||
if (apiPlaylist) {
|
if (apiPlaylist && apiPlaylist.tracks.length > 0) {
|
||||||
setPlaylist(apiPlaylist);
|
setPlaylist(apiPlaylist);
|
||||||
setIsUserPlaylist(false);
|
setIsUserPlaylist(false);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
// Fetch suggestions
|
||||||
|
try {
|
||||||
|
const query = apiPlaylist.title.replace(' Mix', '');
|
||||||
|
const recs = await libraryService.search(query);
|
||||||
|
const currentIds = new Set(apiPlaylist.tracks.map(t => 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 });
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
setLoadingTracks(false);
|
setLoadingTracks(false);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -105,7 +125,7 @@ export default function Playlist() {
|
||||||
if (!playlist || !isUserPlaylist) return;
|
if (!playlist || !isUserPlaylist) return;
|
||||||
await dbService.removeFromPlaylist(playlist.id, trackId);
|
await dbService.removeFromPlaylist(playlist.id, trackId);
|
||||||
await refreshLibrary();
|
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) => {
|
const formatDuration = (seconds?: number) => {
|
||||||
|
|
@ -115,7 +135,7 @@ export default function Playlist() {
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
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)
|
// FULL PAGE SPINNER (Only if we have NO metadata at all)
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -145,29 +165,63 @@ export default function Playlist() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto no-scrollbar pb-24">
|
<div className="flex-1 overflow-y-auto bg-[#121212] no-scrollbar pb-32 relative">
|
||||||
{/* Hero Header (Always visible if playlist exists) */}
|
{/* Banner Background */}
|
||||||
<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">
|
{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">
|
<Link to="/library" className="absolute top-4 left-4 md:hidden">
|
||||||
<ArrowLeft className="w-6 h-6" />
|
<ArrowLeft className="w-6 h-6" />
|
||||||
</Link>
|
</Link>
|
||||||
<CoverImage
|
<div
|
||||||
src={playlist.cover_url ?? undefined}
|
className="w-48 h-48 md:w-64 md:h-64 shadow-[0_20px_50px_rgba(0,0,0,0.5)] rounded-lg overflow-hidden shrink-0 mt-8 md:mt-0 cursor-pointer group/cover relative"
|
||||||
alt={playlist.title}
|
onClick={() => {
|
||||||
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"
|
if (playlist && playlist.tracks.length > 0) {
|
||||||
fallbackText={playlist.title.substring(0, 2).toUpperCase()}
|
playTrack(playlist.tracks[0], playlist.tracks);
|
||||||
/>
|
setIsFullScreenOpen(true);
|
||||||
<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>
|
>
|
||||||
{'description' in playlist && playlist.description && (
|
<CoverImage
|
||||||
<p className="text-sm text-neutral-300 mb-2 line-clamp-2">{playlist.description}</p>
|
src={playlist.cover_url ?? undefined}
|
||||||
)}
|
alt={playlist.title}
|
||||||
<p className="text-sm text-neutral-400">
|
className="w-full h-full object-cover transition-transform duration-700 group-hover/cover:scale-110"
|
||||||
{/* Show 'Loading...' if caching tracks, otherwise count */}
|
fallbackText={playlist.title.substring(0, 2).toUpperCase()}
|
||||||
{loadingTracks ? 'Updating...' : `${playlist.tracks.length} songs`}
|
/>
|
||||||
{totalDuration > 0 && ` • ${Math.floor(totalDuration / 60)} min`}
|
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover/cover:opacity-100 transition flex items-center justify-center">
|
||||||
</p>
|
<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 && (
|
||||||
|
<span className="text-neutral-300">{playlist.description}</span>
|
||||||
|
)}
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-white">
|
||||||
|
{loadingTracks ? 'Updating...' : `${playlist.tracks.length} songs`}
|
||||||
|
</span>
|
||||||
|
{totalDuration > 0 && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{Math.floor(totalDuration / 60)} min</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -296,6 +350,37 @@ export default function Playlist() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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-md 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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Add to Playlist Modal */}
|
{/* Add to Playlist Modal */}
|
||||||
{selectedTrack && (
|
{selectedTrack && (
|
||||||
<AddToPlaylistModal
|
<AddToPlaylistModal
|
||||||
|
|
|
||||||
|
|
@ -68,14 +68,14 @@ export const dbService = {
|
||||||
// ALWAYS use fallback if API returned 0 tracks
|
// ALWAYS use fallback if API returned 0 tracks
|
||||||
if (allTracks.length === 0) {
|
if (allTracks.length === 0) {
|
||||||
console.log("Using Mock Data for DB Seeding");
|
console.log("Using Mock Data for DB Seeding");
|
||||||
const highResTracks = [
|
allTracks = [
|
||||||
{
|
{
|
||||||
id: "fb-1",
|
id: "fb-1",
|
||||||
title: "Shape of You (Demo)",
|
title: "Shape of You (Demo)",
|
||||||
artist: "Ed Sheeran",
|
artist: "Ed Sheeran",
|
||||||
album: "Divide",
|
album: "Divide",
|
||||||
duration: 233,
|
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"
|
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",
|
artist: "The Weeknd",
|
||||||
album: "After Hours",
|
album: "After Hours",
|
||||||
duration: 200,
|
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"
|
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",
|
artist: "Dua Lipa",
|
||||||
album: "Future Nostalgia",
|
album: "Future Nostalgia",
|
||||||
duration: 203,
|
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"
|
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',
|
artist: 'The Kid LAROI',
|
||||||
album: 'F*CK LOVE 3',
|
album: 'F*CK LOVE 3',
|
||||||
duration: 141,
|
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'
|
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',
|
artist: 'Lil Nas X',
|
||||||
album: 'Montero',
|
album: 'Montero',
|
||||||
duration: 137,
|
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'
|
url: 'https://cdn.pixabay.com/download/audio/2023/04/12/audio_496677f54c.mp3?filename=hip-hop-trap-145638.mp3'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -33,17 +33,24 @@ export const libraryService = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async getBrowseContent(): Promise<Record<string, StaticPlaylist[]>> {
|
async getBrowseContent(): Promise<Record<string, StaticPlaylist[]>> {
|
||||||
// Return structured content from Seed Data
|
// Fetch dynamic preloaded content from backend
|
||||||
// We simulate a "fetch" but it's instant
|
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 playlists = Object.values(GENERATED_CONTENT).filter(p => p.type === 'Playlist');
|
||||||
const albums = Object.values(GENERATED_CONTENT).filter(p => p.type === 'Album');
|
const albums = Object.values(GENERATED_CONTENT).filter(p => p.type === 'Album');
|
||||||
const artists = Object.values(GENERATED_CONTENT).filter(p => p.type === 'Artist');
|
const artists = Object.values(GENERATED_CONTENT).filter(p => p.type === 'Artist');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'Top Playlists': playlists.slice(0, 100),
|
'Top Playlists': playlists.slice(0, 50),
|
||||||
'Top Albums': albums.slice(0, 100),
|
'Top Albums': albums.slice(0, 50),
|
||||||
'Popular Artists': artists.slice(0, 100)
|
'Popular Artists': artists.slice(0, 50)
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -73,44 +80,54 @@ export const libraryService = {
|
||||||
|
|
||||||
async getPlaylist(id: string): Promise<StaticPlaylist | null> {
|
async getPlaylist(id: string): Promise<StaticPlaylist | null> {
|
||||||
// 1. Try to find in GENERATED_CONTENT first (Fast/Instant)
|
// 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);
|
const found = Object.values(GENERATED_CONTENT).find(p => p.id === id);
|
||||||
|
|
||||||
if (found) {
|
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) {
|
if (found.tracks.length === 0) {
|
||||||
// Try to fetch tracks in background.
|
|
||||||
// We use multiple fallbacks to ensure we get results.
|
|
||||||
const queries = [
|
const queries = [
|
||||||
found.title, // Exact title
|
found.title,
|
||||||
`${found.title} songs`, // Explicit songs
|
`${found.title} songs`,
|
||||||
`${found.title} playlist`, // Might find mixes
|
`${found.title} playlist`,
|
||||||
"Vietnam Top Hits" // Ultimate fallback
|
"Vietnam Top Hits"
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const q of queries) {
|
for (const q of queries) {
|
||||||
try {
|
try {
|
||||||
console.log(`[Hydration] Searching: ${q}`);
|
|
||||||
const tracks = await this.search(q);
|
const tracks = await this.search(q);
|
||||||
if (tracks.length > 0) {
|
if (tracks.length > 0) {
|
||||||
console.log(`[Hydration] Found ${tracks.length} tracks for ${found.title}`);
|
|
||||||
found.tracks = tracks;
|
found.tracks = tracks;
|
||||||
return { ...found, tracks };
|
return { ...found, tracks };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Hydration search failed for", q, e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallback: Search by ID string parsing (Slow/Legacy)
|
// 2. Try to find in dynamic backend browse cache
|
||||||
const query = id.replace('playlist-', '').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} 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);
|
const tracks = await this.search(query);
|
||||||
if (tracks.length > 0) {
|
if (tracks.length > 0) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -142,7 +159,25 @@ export const libraryService = {
|
||||||
return found;
|
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);
|
const tracks = await this.search(query);
|
||||||
if (tracks.length > 0) {
|
if (tracks.length > 0) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -160,9 +195,9 @@ export const libraryService = {
|
||||||
async getArtistInfo(artistName: string): Promise<{ bio?: string; photo?: string }> {
|
async getArtistInfo(artistName: string): Promise<{ bio?: string; photo?: string }> {
|
||||||
// Try specific API for image
|
// Try specific API for image
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(`/artist-image?q=${encodeURIComponent(artistName)}`);
|
const res = await apiFetch(`/artist/info?q=${encodeURIComponent(artistName)}`);
|
||||||
if (res && res.url) {
|
if (res && res.image) {
|
||||||
return { photo: res.url };
|
return { photo: res.image };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// fall through
|
// fall through
|
||||||
|
|
@ -209,7 +244,7 @@ export const libraryService = {
|
||||||
if (t.album && !seen.has(`album-${t.album}`)) {
|
if (t.album && !seen.has(`album-${t.album}`)) {
|
||||||
seen.add(`album-${t.album}`);
|
seen.add(`album-${t.album}`);
|
||||||
results.push({
|
results.push({
|
||||||
id: `discovery-album-${Math.random().toString(36).substr(2, 9)}`,
|
id: `discovery-album-${t.album.replace(/\s+/g, '-')}`,
|
||||||
title: t.album,
|
title: t.album,
|
||||||
creator: t.artist,
|
creator: t.artist,
|
||||||
cover_url: t.cover_url,
|
cover_url: t.cover_url,
|
||||||
|
|
@ -224,10 +259,10 @@ export const libraryService = {
|
||||||
if (t.artist && !seen.has(`artist-${t.artist}`)) {
|
if (t.artist && !seen.has(`artist-${t.artist}`)) {
|
||||||
seen.add(`artist-${t.artist}`);
|
seen.add(`artist-${t.artist}`);
|
||||||
results.push({
|
results.push({
|
||||||
id: `discovery-artist-${Math.random().toString(36).substr(2, 9)}`,
|
id: `discovery-artist-${t.artist.replace(/\s+/g, '-')}`,
|
||||||
title: t.artist,
|
title: t.artist,
|
||||||
creator: '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'
|
type: 'Artist'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -235,10 +270,9 @@ export const libraryService = {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'playlists' || type === 'all') {
|
if (type === 'playlists' || type === 'all') {
|
||||||
// Generate some "Mix" playlists from the tracks
|
|
||||||
if (tracks.length > 5) {
|
if (tracks.length > 5) {
|
||||||
results.push({
|
results.push({
|
||||||
id: `discovery-playlist-${Math.random().toString(36).substr(2, 9)}`,
|
id: `discovery-playlist-${randomQuery.replace(/\s+/g, '-')}-Mix`,
|
||||||
title: `${randomQuery} Mix`,
|
title: `${randomQuery} Mix`,
|
||||||
creator: 'Spotify Clone',
|
creator: 'Spotify Clone',
|
||||||
cover_url: tracks[0].cover_url,
|
cover_url: tracks[0].cover_url,
|
||||||
|
|
@ -257,25 +291,16 @@ export const libraryService = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pool of high-quality artist/music abstract images
|
// Dynamic Placeholders for artists/covers without an image
|
||||||
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
|
|
||||||
];
|
|
||||||
|
|
||||||
function getUnsplashImage(seed: string): string {
|
function getUnsplashImage(seed: string): string {
|
||||||
|
const initials = seed.substring(0, 2).toUpperCase();
|
||||||
|
const colors = ["1DB954", "FF6B6B", "4ECDC4", "45B7D1", "6C5CE7", "FDCB6E"];
|
||||||
|
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < seed.length; i++) {
|
for (let i = 0; i < seed.length; i++) {
|
||||||
hash = seed.charCodeAt(i) + ((hash << 5) - hash);
|
hash = seed.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
}
|
}
|
||||||
const index = Math.abs(hash) % ARTIST_IMAGES.length;
|
const color = colors[Math.abs(hash) % colors.length];
|
||||||
return `https://images.unsplash.com/${ARTIST_IMAGES[index]}?w=400&h=400&fit=crop&q=80`;
|
|
||||||
|
return `https://placehold.co/400x400/${color}/FFFFFF?text=${encodeURIComponent(initials)}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue