Compare commits
8 commits
2a893f89d6
...
7bc230a8fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bc230a8fd | ||
|
|
f073846d4b | ||
|
|
b75b6b1aa6 | ||
|
|
7a58c2357d | ||
|
|
d50c2721d2 | ||
|
|
94f046f0d6 | ||
|
|
7bb58693dd | ||
|
|
50248cf165 |
10 changed files with 214 additions and 102 deletions
29
.dockerignore
Normal file
29
.dockerignore
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
npm-debug.log
|
||||
|
||||
# Python
|
||||
venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# Next.js
|
||||
.next
|
||||
out
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
62
Dockerfile
62
Dockerfile
|
|
@ -1,56 +1,60 @@
|
|||
# --- Stage 1: Frontend Builder ---
|
||||
FROM node:18-alpine AS builder
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
# Install dependencies including devDependencies for build
|
||||
RUN npm install --legacy-peer-deps
|
||||
COPY frontend/ ./
|
||||
# Build with standalone output
|
||||
ENV NEXT_PUBLIC_API_URL=""
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 2: Final Runtime Image ---
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install Node.js
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
gnupg \
|
||||
ffmpeg \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
ca-certificates \
|
||||
nodejs \
|
||||
npm \
|
||||
&& update-ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# --- Backend Setup ---
|
||||
# Backend Setup
|
||||
COPY backend/requirements.txt ./backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||
|
||||
# --- Frontend Setup ---
|
||||
COPY frontend/package*.json ./frontend/
|
||||
WORKDIR /app/frontend
|
||||
# Install dependencies (ignoring peer deps conflicts)
|
||||
RUN npm install --legacy-peer-deps
|
||||
# Frontend Setup (Copy from Builder)
|
||||
# Copy the standalone server
|
||||
COPY --from=builder /app/frontend/.next/standalone /app/frontend
|
||||
# Copy static files (required for standalone)
|
||||
COPY --from=builder /app/frontend/.next/static /app/frontend/.next/static
|
||||
COPY --from=builder /app/frontend/public /app/frontend/public
|
||||
|
||||
COPY frontend/ .
|
||||
# Build Next.js (with ignore-lint already set in next.config.mjs)
|
||||
# We set API URL to http://localhost:8000 because in this container strategy,
|
||||
# the browser will access the backend directly.
|
||||
# Wait, for client-side fetches, "localhost" refers to the user's machine.
|
||||
# If we run this container on port 3000 and 8000, localhost:8000 works internally via Rewrites.
|
||||
# ENV NEXT_PUBLIC_API_URL="http://localhost:8000" Removed to use relative path proxying
|
||||
# Build Next.js
|
||||
ENV NEXTAUTH_URL=http://localhost:3000
|
||||
# Secret should be provided at runtime via docker run -e or docker-compose
|
||||
ARG NEXTAUTH_SECRET_ARG=default_dev_secret_change_in_production
|
||||
ENV NEXTAUTH_SECRET=${NEXTAUTH_SECRET_ARG}
|
||||
RUN npm run build
|
||||
|
||||
# --- Final Setup ---
|
||||
WORKDIR /app
|
||||
# Copy Backend Code
|
||||
COPY backend/ ./backend/
|
||||
|
||||
# Create a start script
|
||||
# We also implement a "seed data" check.
|
||||
# If the volume mount is empty (missing data.json), we copy from our backup.
|
||||
# Create start script
|
||||
RUN mkdir -p backend/data_seed && cp -r backend/data/* backend/data_seed/ || true
|
||||
|
||||
# Set Environment Variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Note: Standalone mode runs with 'node server.js'
|
||||
RUN echo '#!/bin/bash\n\
|
||||
if [ ! -f backend/data/data.json ]; then\n\
|
||||
echo "Data volume appears empty. Seeding with bundled data..."\n\
|
||||
cp -r backend/data_seed/* backend/data/\n\
|
||||
fi\n\
|
||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000 &\n\
|
||||
cd frontend && npm start -- -p 3000\n\
|
||||
cd frontend && node server.js\n\
|
||||
' > start.sh && chmod +x start.sh
|
||||
|
||||
EXPOSE 3000 8000
|
||||
|
|
|
|||
|
|
@ -317,14 +317,18 @@ async def get_playlist(id: str):
|
|||
# Safely extract album
|
||||
album_name = extract_album_name(track, playlist_data.get('title', 'Single'))
|
||||
|
||||
video_id = track.get('videoId')
|
||||
if not video_id:
|
||||
continue
|
||||
|
||||
formatted_tracks.append({
|
||||
"title": track.get('title', 'Unknown Title'),
|
||||
"artist": artist_names,
|
||||
"album": album_name,
|
||||
"duration": track.get('duration_seconds', track.get('length_seconds', 0)),
|
||||
"cover_url": cover_url,
|
||||
"id": track.get('videoId'),
|
||||
"url": f"https://music.youtube.com/watch?v={track.get('videoId')}"
|
||||
"id": video_id,
|
||||
"url": f"https://music.youtube.com/watch?v={video_id}"
|
||||
})
|
||||
|
||||
# Get Playlist Cover (usually highest res)
|
||||
|
|
@ -604,54 +608,87 @@ async def stream_audio(id: str):
|
|||
"""
|
||||
try:
|
||||
# Check Cache for stream URL
|
||||
cache_key = f"stream:{id}"
|
||||
cached_url = cache.get(cache_key)
|
||||
cache_key = f"v2:stream:{id}" # v2 cache key for new format
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
stream_url = None
|
||||
if cached_url:
|
||||
print(f"DEBUG: Using cached stream URL for '{id}'")
|
||||
stream_url = cached_url
|
||||
else:
|
||||
mime_type = "audio/mp4"
|
||||
|
||||
if cached_data:
|
||||
print(f"DEBUG: Using cached stream data for '{id}'")
|
||||
if isinstance(cached_data, dict):
|
||||
stream_url = cached_data.get('url')
|
||||
mime_type = cached_data.get('mime', 'audio/mp4')
|
||||
else:
|
||||
stream_url = cached_data # Legacy fallback
|
||||
|
||||
if not stream_url:
|
||||
print(f"DEBUG: Fetching new stream URL for '{id}'")
|
||||
url = f"https://www.youtube.com/watch?v={id}"
|
||||
ydl_opts = {
|
||||
'format': 'bestaudio[ext=m4a]/best[ext=mp4]/best', # Prefer m4a/aac for iOS
|
||||
'format': 'bestaudio[ext=m4a]/bestaudio/best',
|
||||
'quiet': True,
|
||||
'noplaylist': True,
|
||||
'nocheckcertificate': True,
|
||||
'geo_bypass': True,
|
||||
'socket_timeout': 30,
|
||||
'retries': 3,
|
||||
'force_ipv4': True,
|
||||
'extractor_args': {'youtube': {'player_client': ['ios', 'android']}},
|
||||
}
|
||||
|
||||
# Extract direct URL
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
stream_url = info.get('url')
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
stream_url = info.get('url')
|
||||
ext = info.get('ext')
|
||||
|
||||
# Determine MIME type
|
||||
if ext == 'm4a':
|
||||
mime_type = "audio/mp4"
|
||||
elif ext == 'webm':
|
||||
mime_type = "audio/webm"
|
||||
else:
|
||||
mime_type = "audio/mpeg" # Fallback
|
||||
|
||||
print(f"DEBUG: Got stream URL format: {info.get('format')}, ext: {ext}, mime: {mime_type}")
|
||||
except Exception as ydl_error:
|
||||
print(f"DEBUG: yt-dlp extraction error: {type(ydl_error).__name__}: {str(ydl_error)}")
|
||||
raise ydl_error
|
||||
|
||||
if stream_url:
|
||||
# Cache for 1 hour (3600 seconds) - URLs expire
|
||||
cache.set(cache_key, stream_url, ttl_seconds=3600)
|
||||
cache_data = {"url": stream_url, "mime": mime_type}
|
||||
cache.set(cache_key, cache_data, ttl_seconds=3600)
|
||||
|
||||
if not stream_url:
|
||||
raise HTTPException(status_code=404, detail="Audio stream not found")
|
||||
|
||||
# Stream the content
|
||||
def iterfile():
|
||||
# Verify if URL is still valid by making a HEAD request or handling stream error
|
||||
# For simplicity, we just try to stream. If 403, we might need to invalidate,
|
||||
# but that logic is complex for this method.
|
||||
with requests.get(stream_url, stream=True) as r:
|
||||
r.raise_for_status() # Check for 403
|
||||
# Use smaller chunks (64KB) for better TTFB (Time To First Byte)
|
||||
for chunk in r.iter_content(chunk_size=64*1024):
|
||||
yield chunk
|
||||
print(f"Streaming {id} with Content-Type: {mime_type}")
|
||||
|
||||
# Note: We return audio/mpeg, but it might be opus/webm.
|
||||
# Browsers are usually smart enough to sniff.
|
||||
return StreamingResponse(iterfile(), media_type="audio/mpeg")
|
||||
def iterfile():
|
||||
try:
|
||||
with requests.get(stream_url, stream=True, timeout=30) as r:
|
||||
r.raise_for_status()
|
||||
for chunk in r.iter_content(chunk_size=64*1024):
|
||||
yield chunk
|
||||
except requests.exceptions.HTTPError as http_err:
|
||||
print(f"DEBUG: Stream HTTP Error: {http_err}")
|
||||
if http_err.response.status_code == 403:
|
||||
cache.delete(cache_key)
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Stream Iterator Error: {e}")
|
||||
raise
|
||||
|
||||
return StreamingResponse(iterfile(), media_type=mime_type)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Stream Error: {e}")
|
||||
# If cached URL failed (likely 403), we could try to invalidate here,
|
||||
# but for now we just return error.
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
import traceback
|
||||
print(f"Stream Error for ID '{id}': {type(e).__name__}: {str(e)}")
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=f"Stream error: {type(e).__name__}: {str(e)}")
|
||||
|
||||
@router.get("/download")
|
||||
async def download_audio(id: str, title: str = "audio"):
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ export const metadata: Metadata = {
|
|||
statusBarStyle: "black-translucent",
|
||||
title: "Audiophile Web Player",
|
||||
},
|
||||
other: {
|
||||
"mobile-web-app-capable": "yes",
|
||||
},
|
||||
icons: {
|
||||
icon: "/icons/icon-192x192.png",
|
||||
apple: "/icons/icon-512x512.png",
|
||||
|
|
|
|||
|
|
@ -180,25 +180,25 @@ export default function Home() {
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 md:gap-6">
|
||||
{sortPlaylists(playlists).slice(0, 5).map((playlist: any) => (
|
||||
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
|
||||
<div className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
|
||||
<div className="relative mb-4">
|
||||
<div className="bg-[#181818] p-2 md:p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
|
||||
<div className="relative mb-2 md:mb-4">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title}
|
||||
className="w-full aspect-square object-cover rounded-md shadow-lg"
|
||||
fallbackText={playlist.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 className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
|
||||
<Play className="fill-black text-black ml-1" />
|
||||
<div 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-8 h-8 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
|
||||
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-bold mb-1 truncate">{playlist.title}</h3>
|
||||
<p className="text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p>
|
||||
<h3 className="font-bold mb-0.5 md:mb-1 truncate text-xs md:text-base">{playlist.title}</h3>
|
||||
<p className="text-[10px] md:text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
|
@ -314,24 +314,24 @@ function MadeForYouSection() {
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 md:gap-6">
|
||||
{recommendations.slice(0, 5).map((track, i) => (
|
||||
<div key={i} onClick={() => playTrack(track, recommendations)} className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
|
||||
<div className="relative mb-4">
|
||||
<div key={i} onClick={() => playTrack(track, recommendations)} className="bg-[#181818] p-2 md:p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
|
||||
<div className="relative mb-2 md:mb-4">
|
||||
<CoverImage
|
||||
src={track.cover_url}
|
||||
alt={track.title}
|
||||
className="w-full aspect-square object-cover rounded-md shadow-lg"
|
||||
fallbackText={track.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 className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
|
||||
<Play className="fill-black text-black ml-1" />
|
||||
<div 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-8 h-8 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
|
||||
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-bold mb-1 truncate">{track.title}</h3>
|
||||
<p className="text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p>
|
||||
<h3 className="font-bold mb-0.5 md:mb-1 truncate text-xs md:text-base">{track.title}</h3>
|
||||
<p className="text-[10px] md:text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -388,24 +388,24 @@ function RecommendedAlbumsSection() {
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-6">
|
||||
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 md:gap-6">
|
||||
{albums.slice(0, 5).map((album, i) => (
|
||||
<Link href={`/playlist?id=${album.id}`} key={i}>
|
||||
<div className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
|
||||
<div className="relative mb-4">
|
||||
<div className="bg-[#181818] p-2 md:p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
|
||||
<div className="relative mb-2 md:mb-4">
|
||||
<CoverImage
|
||||
src={album.cover_url}
|
||||
alt={album.title}
|
||||
className="w-full aspect-square object-cover rounded-md shadow-lg"
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
|
||||
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
|
||||
<Play className="fill-black text-black ml-1" />
|
||||
<div 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-8 h-8 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
|
||||
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-bold mb-1 truncate">{album.title}</h3>
|
||||
<p className="text-sm text-[#a7a7a7] line-clamp-2">{album.description}</p>
|
||||
<h3 className="font-bold mb-0.5 md:mb-1 truncate text-xs md:text-base">{album.title}</h3>
|
||||
<p className="text-[10px] md:text-sm text-[#a7a7a7] line-clamp-2">{album.description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { usePlayer } from "@/context/PlayerContext";
|
||||
import { Play, Pause, Clock, Heart, MoreHorizontal, Plus } from "lucide-react";
|
||||
import { useEffect, useState, Suspense } from "react";
|
||||
|
|
@ -127,7 +129,16 @@ function PlaylistContent() {
|
|||
<div className="h-full overflow-y-auto no-scrollbar bg-gradient-to-b from-gray-900 via-[#121212] to-[#121212]">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row items-center md:items-end gap-6 p-8 bg-gradient-to-b from-transparent to-black/20 pt-20 text-center md:text-left">
|
||||
<img src={playlist.cover_url} alt={playlist.title} className="w-52 h-52 md:w-60 md:h-60 shadow-2xl rounded-md object-cover" />
|
||||
<div className="relative w-52 h-52 md:w-60 md:h-60 shadow-2xl rounded-md overflow-hidden shrink-0">
|
||||
<Image
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 208px, 240px"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full md:w-auto">
|
||||
<span className="text-sm font-bold uppercase hidden md:block">Playlist</span>
|
||||
<h1 className="text-2xl md:text-6xl font-black tracking-tight text-white mb-2 md:mb-4 line-clamp-2 leading-tight">{playlist.title}</h1>
|
||||
|
|
@ -179,7 +190,7 @@ function PlaylistContent() {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
{playlist.tracks.map((track, i) => {
|
||||
{playlist.tracks.filter(t => t.id).map((track, i) => {
|
||||
const isCurrent = currentTrack?.id === track.id;
|
||||
const isLiked = likedTracks.has(track.id);
|
||||
return (
|
||||
|
|
@ -198,7 +209,15 @@ function PlaylistContent() {
|
|||
</span>
|
||||
|
||||
<div className="flex items-center gap-3 min-w-0 overflow-hidden">
|
||||
<img src={track.cover_url} className="w-10 h-10 rounded shadow-sm object-cover shrink-0" alt="" />
|
||||
<div className="relative w-10 h-10 rounded shadow-sm overflow-hidden shrink-0">
|
||||
<Image
|
||||
src={track.cover_url}
|
||||
alt={track.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="40px"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 pr-2">
|
||||
{/* Changed from truncate to line-clamp-2 for readability */}
|
||||
<span className={`font-semibold text-base leading-tight line-clamp-2 break-words ${isCurrent ? 'text-green-500' : 'text-white'}`}>{track.title}</span>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ function getGradient(text: string): string {
|
|||
return gradients[Math.abs(hash) % gradients.length];
|
||||
}
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
export default function CoverImage({ src, alt, className = "", fallbackText }: CoverImageProps) {
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
@ -53,24 +55,26 @@ export default function CoverImage({ src, alt, className = "", fallbackText }: C
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`relative overflow-hidden ${className} bg-[#282828]`}>
|
||||
{isLoading && (
|
||||
<div
|
||||
className={`bg-gradient-to-br ${gradient} flex items-center justify-center text-white animate-pulse ${className}`}
|
||||
className={`absolute inset-0 bg-gradient-to-br ${gradient} flex items-center justify-center text-white animate-pulse z-10`}
|
||||
>
|
||||
<span className="opacity-50">♪</span>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`${className} ${isLoading ? 'hidden' : ''}`}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
className={`object-cover ${isLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
onError={() => {
|
||||
setHasError(true);
|
||||
setIsLoading(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,15 +161,14 @@ export default function Sidebar() {
|
|||
|
||||
{/* Albums */}
|
||||
{showAlbums && albums.map((album) => (
|
||||
<Link href={`/ search ? q = ${encodeURIComponent(album.title)} `} key={album.id}>
|
||||
<Link href={`/search?q=${encodeURIComponent(album.title)}`} key={album.id}>
|
||||
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<div className="w-12 h-12 bg-[#282828] rounded flex items-center justify-center overflow-hidden relative">
|
||||
{album.cover_url ? (
|
||||
<img src={album.cover_url} alt="" className="w-full h-full object-cover" onError={(e) => e.currentTarget.style.display = 'none'} />
|
||||
) : (
|
||||
<span className="text-xl">💿</span>
|
||||
)}
|
||||
</div>
|
||||
<CoverImage
|
||||
src={album.cover_url}
|
||||
alt={album.title}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
fallbackText="💿"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{album.title}</h3>
|
||||
<p className="text-sm text-spotify-text-muted truncate">Album • {album.creator || 'Spotify'}</p>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,14 @@ export function LibraryProvider({ children }: { children: React.ReactNode }) {
|
|||
|
||||
// 2. Local/Backend Content
|
||||
const browse = await libraryService.getBrowseContent();
|
||||
const browsePlaylists = Object.values(browse).flat();
|
||||
// Deduplicate by ID to avoid React duplicate key warnings
|
||||
const browsePlaylistsRaw = Object.values(browse).flat();
|
||||
const seenIds = new Map();
|
||||
const browsePlaylists = browsePlaylistsRaw.filter((p: any) => {
|
||||
if (seenIds.has(p.id)) return false;
|
||||
seenIds.set(p.id, true);
|
||||
return true;
|
||||
});
|
||||
|
||||
const artistsMap = new Map();
|
||||
const albumsMap = new Map();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// strict mode true is default but good to be explicit
|
||||
// strict mode true is default but good to be explicit
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
|
|
@ -34,6 +36,14 @@ const nextConfig = {
|
|||
protocol: 'https',
|
||||
hostname: 'lh3.googleusercontent.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'yt3.googleusercontent.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'yt3.ggpht.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'placehold.co',
|
||||
|
|
|
|||
Loading…
Reference in a new issue