refactor: Migrate backend from Go to Rust (Axum) and update Docker config

This commit is contained in:
Khoa Vo 2026-03-15 06:54:44 +07:00
parent a82b6cd418
commit d1bc4324b4
31 changed files with 3443 additions and 1484 deletions

4
.gitignore vendored
View file

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

View file

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

@ -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.
![Preview](https://opengraph.githubassets.com/1/vndangkhoa/spotify-clone) ![Preview](https://opengraph.githubassets.com/1/vndangkhoa/spotify-clone)
@ -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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

14
backend-rust/Cargo.toml Normal file
View 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
View 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
View 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();
}

View file

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

361
backend-rust/src/spotdl.rs Normal file
View 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()
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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