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
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# Install Node.js
|
# Install system dependencies
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
curl \
|
curl \
|
||||||
gnupg \
|
gnupg \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
|
ca-certificates \
|
||||||
&& apt-get install -y nodejs \
|
nodejs \
|
||||||
|
npm \
|
||||||
|
&& update-ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# --- Backend Setup ---
|
# Backend Setup
|
||||||
COPY backend/requirements.txt ./backend/requirements.txt
|
COPY backend/requirements.txt ./backend/requirements.txt
|
||||||
RUN pip install --no-cache-dir -r backend/requirements.txt
|
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||||
|
|
||||||
# --- Frontend Setup ---
|
# Frontend Setup (Copy from Builder)
|
||||||
COPY frontend/package*.json ./frontend/
|
# Copy the standalone server
|
||||||
WORKDIR /app/frontend
|
COPY --from=builder /app/frontend/.next/standalone /app/frontend
|
||||||
# Install dependencies (ignoring peer deps conflicts)
|
# Copy static files (required for standalone)
|
||||||
RUN npm install --legacy-peer-deps
|
COPY --from=builder /app/frontend/.next/static /app/frontend/.next/static
|
||||||
|
COPY --from=builder /app/frontend/public /app/frontend/public
|
||||||
|
|
||||||
COPY frontend/ .
|
# Copy Backend Code
|
||||||
# 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/ ./backend/
|
COPY backend/ ./backend/
|
||||||
|
|
||||||
# Create a start script
|
# Create start script
|
||||||
# We also implement a "seed data" check.
|
|
||||||
# If the volume mount is empty (missing data.json), we copy from our backup.
|
|
||||||
RUN mkdir -p backend/data_seed && cp -r backend/data/* backend/data_seed/ || true
|
RUN 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\
|
RUN echo '#!/bin/bash\n\
|
||||||
if [ ! -f backend/data/data.json ]; then\n\
|
if [ ! -f backend/data/data.json ]; then\n\
|
||||||
echo "Data volume appears empty. Seeding with bundled data..."\n\
|
echo "Data volume appears empty. Seeding with bundled data..."\n\
|
||||||
cp -r backend/data_seed/* backend/data/\n\
|
cp -r backend/data_seed/* backend/data/\n\
|
||||||
fi\n\
|
fi\n\
|
||||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000 &\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
|
' > start.sh && chmod +x start.sh
|
||||||
|
|
||||||
EXPOSE 3000 8000
|
EXPOSE 3000 8000
|
||||||
|
|
|
||||||
|
|
@ -317,14 +317,18 @@ async def get_playlist(id: str):
|
||||||
# Safely extract album
|
# Safely extract album
|
||||||
album_name = extract_album_name(track, playlist_data.get('title', 'Single'))
|
album_name = extract_album_name(track, playlist_data.get('title', 'Single'))
|
||||||
|
|
||||||
|
video_id = track.get('videoId')
|
||||||
|
if not video_id:
|
||||||
|
continue
|
||||||
|
|
||||||
formatted_tracks.append({
|
formatted_tracks.append({
|
||||||
"title": track.get('title', 'Unknown Title'),
|
"title": track.get('title', 'Unknown Title'),
|
||||||
"artist": artist_names,
|
"artist": artist_names,
|
||||||
"album": album_name,
|
"album": album_name,
|
||||||
"duration": track.get('duration_seconds', track.get('length_seconds', 0)),
|
"duration": track.get('duration_seconds', track.get('length_seconds', 0)),
|
||||||
"cover_url": cover_url,
|
"cover_url": cover_url,
|
||||||
"id": track.get('videoId'),
|
"id": video_id,
|
||||||
"url": f"https://music.youtube.com/watch?v={track.get('videoId')}"
|
"url": f"https://music.youtube.com/watch?v={video_id}"
|
||||||
})
|
})
|
||||||
|
|
||||||
# Get Playlist Cover (usually highest res)
|
# Get Playlist Cover (usually highest res)
|
||||||
|
|
@ -604,54 +608,87 @@ async def stream_audio(id: str):
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check Cache for stream URL
|
# Check Cache for stream URL
|
||||||
cache_key = f"stream:{id}"
|
cache_key = f"v2:stream:{id}" # v2 cache key for new format
|
||||||
cached_url = cache.get(cache_key)
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
stream_url = None
|
stream_url = None
|
||||||
if cached_url:
|
mime_type = "audio/mp4"
|
||||||
print(f"DEBUG: Using cached stream URL for '{id}'")
|
|
||||||
stream_url = cached_url
|
if cached_data:
|
||||||
else:
|
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}'")
|
print(f"DEBUG: Fetching new stream URL for '{id}'")
|
||||||
url = f"https://www.youtube.com/watch?v={id}"
|
url = f"https://www.youtube.com/watch?v={id}"
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'format': 'bestaudio[ext=m4a]/best[ext=mp4]/best', # Prefer m4a/aac for iOS
|
'format': 'bestaudio[ext=m4a]/bestaudio/best',
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
'noplaylist': 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
|
try:
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(url, download=False)
|
info = ydl.extract_info(url, download=False)
|
||||||
stream_url = info.get('url')
|
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:
|
if stream_url:
|
||||||
# Cache for 1 hour (3600 seconds) - URLs expire
|
cache_data = {"url": stream_url, "mime": mime_type}
|
||||||
cache.set(cache_key, stream_url, ttl_seconds=3600)
|
cache.set(cache_key, cache_data, ttl_seconds=3600)
|
||||||
|
|
||||||
if not stream_url:
|
if not stream_url:
|
||||||
raise HTTPException(status_code=404, detail="Audio stream not found")
|
raise HTTPException(status_code=404, detail="Audio stream not found")
|
||||||
|
|
||||||
# Stream the content
|
print(f"Streaming {id} with Content-Type: {mime_type}")
|
||||||
|
|
||||||
def iterfile():
|
def iterfile():
|
||||||
# Verify if URL is still valid by making a HEAD request or handling stream error
|
try:
|
||||||
# For simplicity, we just try to stream. If 403, we might need to invalidate,
|
with requests.get(stream_url, stream=True, timeout=30) as r:
|
||||||
# but that logic is complex for this method.
|
r.raise_for_status()
|
||||||
with requests.get(stream_url, stream=True) as r:
|
for chunk in r.iter_content(chunk_size=64*1024):
|
||||||
r.raise_for_status() # Check for 403
|
yield chunk
|
||||||
# Use smaller chunks (64KB) for better TTFB (Time To First Byte)
|
except requests.exceptions.HTTPError as http_err:
|
||||||
for chunk in r.iter_content(chunk_size=64*1024):
|
print(f"DEBUG: Stream HTTP Error: {http_err}")
|
||||||
yield chunk
|
if http_err.response.status_code == 403:
|
||||||
|
cache.delete(cache_key)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"DEBUG: Stream Iterator Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
# Note: We return audio/mpeg, but it might be opus/webm.
|
return StreamingResponse(iterfile(), media_type=mime_type)
|
||||||
# Browsers are usually smart enough to sniff.
|
|
||||||
return StreamingResponse(iterfile(), media_type="audio/mpeg")
|
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Stream Error: {e}")
|
import traceback
|
||||||
# If cached URL failed (likely 403), we could try to invalidate here,
|
print(f"Stream Error for ID '{id}': {type(e).__name__}: {str(e)}")
|
||||||
# but for now we just return error.
|
print(traceback.format_exc())
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=f"Stream error: {type(e).__name__}: {str(e)}")
|
||||||
|
|
||||||
@router.get("/download")
|
@router.get("/download")
|
||||||
async def download_audio(id: str, title: str = "audio"):
|
async def download_audio(id: str, title: str = "audio"):
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ export const metadata: Metadata = {
|
||||||
statusBarStyle: "black-translucent",
|
statusBarStyle: "black-translucent",
|
||||||
title: "Audiophile Web Player",
|
title: "Audiophile Web Player",
|
||||||
},
|
},
|
||||||
|
other: {
|
||||||
|
"mobile-web-app-capable": "yes",
|
||||||
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/icons/icon-192x192.png",
|
icon: "/icons/icon-192x192.png",
|
||||||
apple: "/icons/icon-512x512.png",
|
apple: "/icons/icon-512x512.png",
|
||||||
|
|
|
||||||
|
|
@ -180,25 +180,25 @@ export default function Home() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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) => (
|
{sortPlaylists(playlists).slice(0, 5).map((playlist: any) => (
|
||||||
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
|
<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="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-4">
|
<div className="relative mb-2 md:mb-4">
|
||||||
<CoverImage
|
<CoverImage
|
||||||
src={playlist.cover_url}
|
src={playlist.cover_url}
|
||||||
alt={playlist.title}
|
alt={playlist.title}
|
||||||
className="w-full aspect-square object-cover rounded-md shadow-lg"
|
className="w-full aspect-square object-cover rounded-md shadow-lg"
|
||||||
fallbackText={playlist.title.substring(0, 2).toUpperCase()}
|
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="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-12 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-1" />
|
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-bold mb-1 truncate">{playlist.title}</h3>
|
<h3 className="font-bold mb-0.5 md:mb-1 truncate text-xs md:text-base">{playlist.title}</h3>
|
||||||
<p className="text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p>
|
<p className="text-[10px] md:text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
@ -314,24 +314,24 @@ function MadeForYouSection() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</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) => (
|
{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 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-4">
|
<div className="relative mb-2 md:mb-4">
|
||||||
<CoverImage
|
<CoverImage
|
||||||
src={track.cover_url}
|
src={track.cover_url}
|
||||||
alt={track.title}
|
alt={track.title}
|
||||||
className="w-full aspect-square object-cover rounded-md shadow-lg"
|
className="w-full aspect-square object-cover rounded-md shadow-lg"
|
||||||
fallbackText={track.title?.substring(0, 2).toUpperCase()}
|
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="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-12 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-1" />
|
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-bold mb-1 truncate">{track.title}</h3>
|
<h3 className="font-bold mb-0.5 md:mb-1 truncate text-xs md:text-base">{track.title}</h3>
|
||||||
<p className="text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p>
|
<p className="text-[10px] md:text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -388,24 +388,24 @@ function RecommendedAlbumsSection() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</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) => (
|
{albums.slice(0, 5).map((album, i) => (
|
||||||
<Link href={`/playlist?id=${album.id}`} key={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="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-4">
|
<div className="relative mb-2 md:mb-4">
|
||||||
<CoverImage
|
<CoverImage
|
||||||
src={album.cover_url}
|
src={album.cover_url}
|
||||||
alt={album.title}
|
alt={album.title}
|
||||||
className="w-full aspect-square object-cover rounded-md shadow-lg"
|
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="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-12 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-1" />
|
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-bold mb-1 truncate">{album.title}</h3>
|
<h3 className="font-bold mb-0.5 md:mb-1 truncate text-xs md:text-base">{album.title}</h3>
|
||||||
<p className="text-sm text-[#a7a7a7] line-clamp-2">{album.description}</p>
|
<p className="text-[10px] md:text-sm text-[#a7a7a7] line-clamp-2">{album.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
import { usePlayer } from "@/context/PlayerContext";
|
import { usePlayer } from "@/context/PlayerContext";
|
||||||
import { Play, Pause, Clock, Heart, MoreHorizontal, Plus } from "lucide-react";
|
import { Play, Pause, Clock, Heart, MoreHorizontal, Plus } from "lucide-react";
|
||||||
import { useEffect, useState, Suspense } from "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]">
|
<div className="h-full overflow-y-auto no-scrollbar bg-gradient-to-b from-gray-900 via-[#121212] to-[#121212]">
|
||||||
{/* Header */}
|
{/* 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">
|
<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">
|
<div className="flex flex-col gap-2 w-full md:w-auto">
|
||||||
<span className="text-sm font-bold uppercase hidden md:block">Playlist</span>
|
<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>
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<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 isCurrent = currentTrack?.id === track.id;
|
||||||
const isLiked = likedTracks.has(track.id);
|
const isLiked = likedTracks.has(track.id);
|
||||||
return (
|
return (
|
||||||
|
|
@ -198,7 +209,15 @@ function PlaylistContent() {
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 min-w-0 overflow-hidden">
|
<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">
|
<div className="flex flex-col min-w-0 pr-2">
|
||||||
{/* Changed from truncate to line-clamp-2 for readability */}
|
{/* Changed from truncate to line-clamp-2 for readability */}
|
||||||
<span className={`font-semibold text-base leading-tight line-clamp-2 break-words ${isCurrent ? 'text-green-500' : 'text-white'}`}>{track.title}</span>
|
<span className={`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];
|
return gradients[Math.abs(hash) % gradients.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
export default function CoverImage({ src, alt, className = "", fallbackText }: CoverImageProps) {
|
export default function CoverImage({ src, alt, className = "", fallbackText }: CoverImageProps) {
|
||||||
const [hasError, setHasError] = useState(false);
|
const [hasError, setHasError] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
@ -53,24 +55,26 @@ export default function CoverImage({ src, alt, className = "", fallbackText }: C
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={`relative overflow-hidden ${className} bg-[#282828]`}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div
|
<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>
|
<span className="opacity-50">♪</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<img
|
<Image
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
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)}
|
onLoad={() => setIsLoading(false)}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -161,15 +161,14 @@ export default function Sidebar() {
|
||||||
|
|
||||||
{/* Albums */}
|
{/* Albums */}
|
||||||
{showAlbums && albums.map((album) => (
|
{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="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">
|
<CoverImage
|
||||||
{album.cover_url ? (
|
src={album.cover_url}
|
||||||
<img src={album.cover_url} alt="" className="w-full h-full object-cover" onError={(e) => e.currentTarget.style.display = 'none'} />
|
alt={album.title}
|
||||||
) : (
|
className="w-12 h-12 rounded object-cover"
|
||||||
<span className="text-xl">💿</span>
|
fallbackText="💿"
|
||||||
)}
|
/>
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-white font-medium truncate">{album.title}</h3>
|
<h3 className="text-white font-medium truncate">{album.title}</h3>
|
||||||
<p className="text-sm text-spotify-text-muted truncate">Album • {album.creator || 'Spotify'}</p>
|
<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
|
// 2. Local/Backend Content
|
||||||
const browse = await libraryService.getBrowseContent();
|
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 artistsMap = new Map();
|
||||||
const albumsMap = new Map();
|
const albumsMap = new Map();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
// strict mode true is default but good to be explicit
|
||||||
// strict mode true is default but good to be explicit
|
// strict mode true is default but good to be explicit
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
output: "standalone",
|
||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true,
|
ignoreDuringBuilds: true,
|
||||||
},
|
},
|
||||||
|
|
@ -34,6 +36,14 @@ const nextConfig = {
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: 'lh3.googleusercontent.com',
|
hostname: 'lh3.googleusercontent.com',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'yt3.googleusercontent.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'yt3.ggpht.com',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: 'placehold.co',
|
hostname: 'placehold.co',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue