Add Bandcamp search and Listening Room page
Implement Bandcamp search service with autocomplete API and HTML scraping fallback. Add /api/bandcamp/search and /api/bandcamp/embed endpoints. Create Listening Room page with search, embedded player, and queue management. Add navigation entry and Bandcamp link on recommendation cards.
This commit is contained in:
58
backend/app/api/endpoints/bandcamp.py
Normal file
58
backend/app/api/endpoints/bandcamp.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.bandcamp import search_bandcamp, get_embed_data
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/bandcamp", tags=["bandcamp"])
|
||||||
|
|
||||||
|
|
||||||
|
class BandcampResult(BaseModel):
|
||||||
|
title: str
|
||||||
|
artist: str
|
||||||
|
art_url: str | None = None
|
||||||
|
bandcamp_url: str
|
||||||
|
item_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class BandcampEmbedResponse(BaseModel):
|
||||||
|
embed_url: str
|
||||||
|
title: str
|
||||||
|
artist: str
|
||||||
|
art_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_model=list[BandcampResult])
|
||||||
|
async def bandcamp_search(
|
||||||
|
q: str = Query(..., min_length=1),
|
||||||
|
type: str = Query("t", pattern="^[tab]$"),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Search Bandcamp for tracks, albums, or artists."""
|
||||||
|
results = await search_bandcamp(q.strip(), item_type=type)
|
||||||
|
return [BandcampResult(**r) for r in results]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/embed", response_model=BandcampEmbedResponse)
|
||||||
|
async def bandcamp_embed(
|
||||||
|
url: str = Query(..., min_length=1),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get embed data for a Bandcamp URL."""
|
||||||
|
if "bandcamp.com" not in url:
|
||||||
|
raise HTTPException(status_code=400, detail="Not a valid Bandcamp URL")
|
||||||
|
|
||||||
|
data = await get_embed_data(url.strip())
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Could not extract embed data from this Bandcamp page",
|
||||||
|
)
|
||||||
|
|
||||||
|
return BandcampEmbedResponse(
|
||||||
|
embed_url=data["embed_url"],
|
||||||
|
title=data["title"],
|
||||||
|
artist=data["artist"],
|
||||||
|
art_url=data.get("art_url"),
|
||||||
|
)
|
||||||
@@ -2,7 +2,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.api.endpoints import auth, billing, lastfm, manual_import, playlists, recommendations, youtube_music
|
from app.api.endpoints import auth, bandcamp, billing, lastfm, manual_import, playlists, recommendations, youtube_music
|
||||||
|
|
||||||
app = FastAPI(title="Vynl API", version="1.0.0")
|
app = FastAPI(title="Vynl API", version="1.0.0")
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ app.include_router(recommendations.router, prefix="/api")
|
|||||||
app.include_router(youtube_music.router, prefix="/api")
|
app.include_router(youtube_music.router, prefix="/api")
|
||||||
app.include_router(manual_import.router, prefix="/api")
|
app.include_router(manual_import.router, prefix="/api")
|
||||||
app.include_router(lastfm.router, prefix="/api")
|
app.include_router(lastfm.router, prefix="/api")
|
||||||
|
app.include_router(bandcamp.router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
218
backend/app/services/bandcamp.py
Normal file
218
backend/app/services/bandcamp.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
AUTOCOMPLETE_URL = "https://bandcamp.com/api/fuzzysearch/2/autocomplete"
|
||||||
|
SEARCH_URL = "https://bandcamp.com/search"
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def search_bandcamp(query: str, item_type: str = "t") -> list[dict]:
|
||||||
|
"""Search Bandcamp for tracks, albums, or artists.
|
||||||
|
|
||||||
|
item_type: 't' for tracks, 'a' for albums, 'b' for bands/artists.
|
||||||
|
"""
|
||||||
|
# Try autocomplete API first
|
||||||
|
results = await _search_autocomplete(query, item_type)
|
||||||
|
if results:
|
||||||
|
return results
|
||||||
|
|
||||||
|
# Fall back to HTML scraping
|
||||||
|
return await _search_html(query, item_type)
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_autocomplete(query: str, item_type: str) -> list[dict]:
|
||||||
|
"""Try the undocumented Bandcamp autocomplete API."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10, headers=HEADERS) as client:
|
||||||
|
resp = await client.get(AUTOCOMPLETE_URL, params={"q": query})
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return []
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# The autocomplete API returns results grouped by type
|
||||||
|
auto_results = data.get("results", [])
|
||||||
|
for item in auto_results:
|
||||||
|
result_type = item.get("type", "")
|
||||||
|
|
||||||
|
# Map autocomplete types to our item_type filter
|
||||||
|
if item_type == "t" and result_type != "t":
|
||||||
|
continue
|
||||||
|
if item_type == "a" and result_type != "a":
|
||||||
|
continue
|
||||||
|
if item_type == "b" and result_type != "b":
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"title": item.get("name", ""),
|
||||||
|
"artist": item.get("band_name", ""),
|
||||||
|
"art_url": item.get("img", item.get("art_id", None)),
|
||||||
|
"bandcamp_url": item.get("url", ""),
|
||||||
|
"item_type": result_type,
|
||||||
|
})
|
||||||
|
|
||||||
|
return results[:20]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_html(query: str, item_type: str) -> list[dict]:
|
||||||
|
"""Fall back to scraping Bandcamp search results HTML."""
|
||||||
|
params = {"q": query, "item_type": item_type}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15, headers=HEADERS, follow_redirects=True) as client:
|
||||||
|
resp = await client.get(SEARCH_URL, params=params)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return []
|
||||||
|
|
||||||
|
html = resp.text
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Split by search result items
|
||||||
|
items = re.split(r'<li\s+class="searchresult\s', html)
|
||||||
|
for item_html in items[1:]: # skip first split (before first result)
|
||||||
|
# Extract title and URL from heading link
|
||||||
|
heading_match = re.search(
|
||||||
|
r'class="heading">\s*<a\s+href="([^"]+)"[^>]*>\s*([^<]+)',
|
||||||
|
item_html,
|
||||||
|
)
|
||||||
|
if not heading_match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
url = heading_match.group(1).strip()
|
||||||
|
title = heading_match.group(2).strip()
|
||||||
|
|
||||||
|
# Extract artist/subhead info
|
||||||
|
subhead_match = re.search(
|
||||||
|
r'class="subhead">\s*([^<]+)', item_html
|
||||||
|
)
|
||||||
|
artist = ""
|
||||||
|
if subhead_match:
|
||||||
|
subhead = subhead_match.group(1).strip()
|
||||||
|
# Subhead format varies: "by Artist" or "from Album by Artist"
|
||||||
|
by_match = re.search(r'by\s+(.+)', subhead)
|
||||||
|
if by_match:
|
||||||
|
artist = by_match.group(1).strip()
|
||||||
|
else:
|
||||||
|
artist = subhead
|
||||||
|
|
||||||
|
# Extract album art URL
|
||||||
|
art_match = re.search(
|
||||||
|
r'class="art">\s*<img\s+src="([^"]+)"', item_html
|
||||||
|
)
|
||||||
|
art_url = art_match.group(1).strip() if art_match else None
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"title": title,
|
||||||
|
"artist": artist,
|
||||||
|
"art_url": art_url,
|
||||||
|
"bandcamp_url": url,
|
||||||
|
"item_type": item_type,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(results) >= 20:
|
||||||
|
break
|
||||||
|
|
||||||
|
return results
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def get_embed_data(bandcamp_url: str) -> dict | None:
|
||||||
|
"""Get embed info for a Bandcamp URL.
|
||||||
|
|
||||||
|
Fetches the page HTML, extracts the track/album ID, and returns
|
||||||
|
the embed iframe URL along with metadata.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15, headers=HEADERS, follow_redirects=True) as client:
|
||||||
|
resp = await client.get(bandcamp_url)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
html = resp.text
|
||||||
|
|
||||||
|
# Determine if this is a track or album URL
|
||||||
|
is_track = "/track/" in bandcamp_url
|
||||||
|
|
||||||
|
# Try to extract the ID from meta tags or data attributes
|
||||||
|
# Look for: <meta property="og:video" content="...album=12345..." />
|
||||||
|
# or data-tralbum-id="12345"
|
||||||
|
item_id = None
|
||||||
|
|
||||||
|
tralbum_match = re.search(r'data-tralbum-id="(\d+)"', html)
|
||||||
|
if tralbum_match:
|
||||||
|
item_id = tralbum_match.group(1)
|
||||||
|
|
||||||
|
if not item_id:
|
||||||
|
# Try og:video meta tag which contains embed URL with ID
|
||||||
|
og_match = re.search(
|
||||||
|
r'<meta\s+property="og:video"\s+content="[^"]*(?:album|track)=(\d+)',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
if og_match:
|
||||||
|
item_id = og_match.group(1)
|
||||||
|
|
||||||
|
if not item_id:
|
||||||
|
# Try the embedded player link in the page
|
||||||
|
embed_match = re.search(
|
||||||
|
r'EmbeddedPlayer/(?:album|track)=(\d+)', html
|
||||||
|
)
|
||||||
|
if embed_match:
|
||||||
|
item_id = embed_match.group(1)
|
||||||
|
|
||||||
|
if not item_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Build embed URL
|
||||||
|
id_type = "track" if is_track else "album"
|
||||||
|
embed_url = (
|
||||||
|
f"https://bandcamp.com/EmbeddedPlayer/"
|
||||||
|
f"{id_type}={item_id}/size=large/"
|
||||||
|
f"bgcol=1C1917/linkcol=7C3AED/"
|
||||||
|
f"tracklist=false/transparent=true/"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract title from og:title
|
||||||
|
title = ""
|
||||||
|
title_match = re.search(
|
||||||
|
r'<meta\s+property="og:title"\s+content="([^"]+)"', html
|
||||||
|
)
|
||||||
|
if title_match:
|
||||||
|
title = title_match.group(1).strip()
|
||||||
|
|
||||||
|
# Extract artist
|
||||||
|
artist = ""
|
||||||
|
artist_match = re.search(
|
||||||
|
r'<meta\s+property="og:site_name"\s+content="([^"]+)"', html
|
||||||
|
)
|
||||||
|
if artist_match:
|
||||||
|
artist = artist_match.group(1).strip()
|
||||||
|
|
||||||
|
# Extract art
|
||||||
|
art_url = None
|
||||||
|
art_match = re.search(
|
||||||
|
r'<meta\s+property="og:image"\s+content="([^"]+)"', html
|
||||||
|
)
|
||||||
|
if art_match:
|
||||||
|
art_url = art_match.group(1).strip()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"embed_url": embed_url,
|
||||||
|
"title": title,
|
||||||
|
"artist": artist,
|
||||||
|
"art_url": art_url,
|
||||||
|
"item_id": item_id,
|
||||||
|
"item_type": id_type,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
@@ -11,6 +11,7 @@ import PlaylistDetail from './pages/PlaylistDetail'
|
|||||||
import Discover from './pages/Discover'
|
import Discover from './pages/Discover'
|
||||||
import Recommendations from './pages/Recommendations'
|
import Recommendations from './pages/Recommendations'
|
||||||
import Billing from './pages/Billing'
|
import Billing from './pages/Billing'
|
||||||
|
import ListeningRoom from './pages/ListeningRoom'
|
||||||
|
|
||||||
function RootRedirect() {
|
function RootRedirect() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
@@ -83,6 +84,16 @@ function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/listen"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<ListeningRoom />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/billing"
|
path="/billing"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { Disc3, LayoutDashboard, ListMusic, Compass, Heart, Crown, Menu, X, LogOut, User } from 'lucide-react'
|
import { Disc3, LayoutDashboard, ListMusic, Compass, Heart, Crown, Headphones, Menu, X, LogOut, User } from 'lucide-react'
|
||||||
import { useAuth } from '../lib/auth'
|
import { useAuth } from '../lib/auth'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
|
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
|
||||||
{ path: '/discover', label: 'Discover', icon: Compass },
|
{ path: '/discover', label: 'Discover', icon: Compass },
|
||||||
|
{ path: '/listen', label: 'Listen', icon: Headphones },
|
||||||
{ path: '/saved', label: 'Saved', icon: Heart },
|
{ path: '/saved', label: 'Saved', icon: Heart },
|
||||||
{ path: '/billing', label: 'Pro', icon: Crown },
|
{ path: '/billing', label: 'Pro', icon: Crown },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Heart, ExternalLink, Music } from 'lucide-react'
|
import { Heart, ExternalLink, Music, Headphones } from 'lucide-react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import type { RecommendationItem } from '../lib/api'
|
import type { RecommendationItem } from '../lib/api'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -71,6 +72,14 @@ export default function RecommendationCard({ recommendation, onToggleSave, savin
|
|||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={`/listen?q=${encodeURIComponent(`${recommendation.artist} ${recommendation.title}`)}`}
|
||||||
|
className="p-2 rounded-full bg-purple-50 text-purple-400 hover:bg-purple-100 hover:text-purple transition-colors"
|
||||||
|
title="Find on Bandcamp"
|
||||||
|
>
|
||||||
|
<Headphones className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -211,4 +211,30 @@ export const createBillingPortal = () =>
|
|||||||
export const getBillingStatus = () =>
|
export const getBillingStatus = () =>
|
||||||
api.get<BillingStatusResponse>('/billing/status').then((r) => r.data)
|
api.get<BillingStatusResponse>('/billing/status').then((r) => r.data)
|
||||||
|
|
||||||
|
// Bandcamp
|
||||||
|
export interface BandcampResult {
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
art_url: string | null
|
||||||
|
bandcamp_url: string
|
||||||
|
item_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BandcampEmbed {
|
||||||
|
embed_url: string
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
art_url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchBandcamp(query: string, type: string = 't'): Promise<BandcampResult[]> {
|
||||||
|
const { data } = await api.get('/bandcamp/search', { params: { q: query, type } })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBandcampEmbed(url: string): Promise<BandcampEmbed> {
|
||||||
|
const { data } = await api.get('/bandcamp/embed', { params: { url } })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
346
frontend/src/pages/ListeningRoom.tsx
Normal file
346
frontend/src/pages/ListeningRoom.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { Search, Headphones, ExternalLink, Play, X, Music, Disc3 } from 'lucide-react'
|
||||||
|
import { searchBandcamp, getBandcampEmbed } from '../lib/api'
|
||||||
|
import type { BandcampResult, BandcampEmbed } from '../lib/api'
|
||||||
|
|
||||||
|
type SearchType = 't' | 'a' | 'b'
|
||||||
|
|
||||||
|
interface QueueItem {
|
||||||
|
result: BandcampResult
|
||||||
|
embed: BandcampEmbed | null
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ListeningRoom() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const [query, setQuery] = useState(searchParams.get('q') || '')
|
||||||
|
const [searchType, setSearchType] = useState<SearchType>('t')
|
||||||
|
const [results, setResults] = useState<BandcampResult[]>([])
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [searchError, setSearchError] = useState('')
|
||||||
|
const [currentPlayer, setCurrentPlayer] = useState<QueueItem | null>(null)
|
||||||
|
const [queue, setQueue] = useState<QueueItem[]>([])
|
||||||
|
|
||||||
|
// Auto-search if query param is provided
|
||||||
|
useEffect(() => {
|
||||||
|
const q = searchParams.get('q')
|
||||||
|
if (q) {
|
||||||
|
setQuery(q)
|
||||||
|
handleSearch(q)
|
||||||
|
}
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
async function handleSearch(searchQuery?: string) {
|
||||||
|
const q = (searchQuery || query).trim()
|
||||||
|
if (!q) return
|
||||||
|
|
||||||
|
setSearching(true)
|
||||||
|
setSearchError('')
|
||||||
|
try {
|
||||||
|
const data = await searchBandcamp(q, searchType)
|
||||||
|
setResults(data)
|
||||||
|
if (data.length === 0) {
|
||||||
|
setSearchError('No results found. Try a different search term.')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSearchError('Search failed. Please try again.')
|
||||||
|
setResults([])
|
||||||
|
} finally {
|
||||||
|
setSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleListen(result: BandcampResult) {
|
||||||
|
const item: QueueItem = { result, embed: null, loading: true }
|
||||||
|
|
||||||
|
// Set as current player immediately
|
||||||
|
setCurrentPlayer(item)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const embed = await getBandcampEmbed(result.bandcamp_url)
|
||||||
|
const loaded: QueueItem = { result, embed, loading: false }
|
||||||
|
setCurrentPlayer(loaded)
|
||||||
|
} catch {
|
||||||
|
setCurrentPlayer({ result, embed: null, loading: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToQueue(result: BandcampResult) {
|
||||||
|
// Don't add duplicates
|
||||||
|
if (queue.some((q) => q.result.bandcamp_url === result.bandcamp_url)) return
|
||||||
|
setQueue((prev) => [...prev, { result, embed: null, loading: false }])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playFromQueue(index: number) {
|
||||||
|
const item = queue[index]
|
||||||
|
setQueue((prev) => prev.filter((_, i) => i !== index))
|
||||||
|
|
||||||
|
setCurrentPlayer({ ...item, loading: true })
|
||||||
|
try {
|
||||||
|
const embed = await getBandcampEmbed(item.result.bandcamp_url)
|
||||||
|
setCurrentPlayer({ result: item.result, embed, loading: false })
|
||||||
|
} catch {
|
||||||
|
setCurrentPlayer({ result: item.result, embed: null, loading: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromQueue(index: number) {
|
||||||
|
setQueue((prev) => prev.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAlbum = currentPlayer?.result.item_type === 'a'
|
||||||
|
const embedHeight = isAlbum ? 470 : 120
|
||||||
|
|
||||||
|
const typeLabels: Record<SearchType, string> = { t: 'Tracks', a: 'Albums', b: 'Artists' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-purple flex items-center justify-center">
|
||||||
|
<Headphones className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-charcoal">Listening Room</h1>
|
||||||
|
<p className="text-charcoal-muted text-sm">
|
||||||
|
Discover and listen to music on Bandcamp
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Section */}
|
||||||
|
<div className="bg-white rounded-2xl border border-purple-100 shadow-sm p-5">
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSearch()
|
||||||
|
}}
|
||||||
|
className="flex gap-3"
|
||||||
|
>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-charcoal-muted" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search Bandcamp..."
|
||||||
|
className="w-full pl-11 pr-4 py-3 rounded-xl border border-purple-100 focus:border-purple focus:ring-2 focus:ring-purple/20 outline-none text-charcoal placeholder:text-charcoal-muted/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={searching || !query.trim()}
|
||||||
|
className="px-6 py-3 bg-purple text-white rounded-xl font-medium hover:bg-purple-700 transition-colors cursor-pointer border-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{searching ? 'Searching...' : 'Search'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Type Toggle */}
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
{(Object.entries(typeLabels) as [SearchType, string][]).map(([type, label]) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => setSearchType(type)}
|
||||||
|
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer border-none ${
|
||||||
|
searchType === type
|
||||||
|
? 'bg-purple text-white'
|
||||||
|
: 'bg-purple-50 text-purple hover:bg-purple-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Results */}
|
||||||
|
{searchError && (
|
||||||
|
<div className="text-center py-8 text-charcoal-muted">
|
||||||
|
<Music className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>{searchError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-charcoal mb-4">
|
||||||
|
Results ({results.length})
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{results.map((result, i) => (
|
||||||
|
<div
|
||||||
|
key={`${result.bandcamp_url}-${i}`}
|
||||||
|
className="bg-white rounded-2xl border border-purple-100 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 p-4">
|
||||||
|
{/* Art */}
|
||||||
|
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-purple-200 to-purple-400 flex-shrink-0 flex items-center justify-center overflow-hidden">
|
||||||
|
{result.art_url ? (
|
||||||
|
<img
|
||||||
|
src={result.art_url}
|
||||||
|
alt={result.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Disc3 className="w-7 h-7 text-white/80" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-charcoal text-sm truncate">
|
||||||
|
{result.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-charcoal-muted text-xs truncate">
|
||||||
|
{result.artist}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleListen(result)}
|
||||||
|
className="flex items-center gap-1 px-3 py-1 bg-purple text-white rounded-lg text-xs font-medium hover:bg-purple-700 transition-colors cursor-pointer border-none"
|
||||||
|
>
|
||||||
|
<Play className="w-3 h-3" />
|
||||||
|
Listen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => addToQueue(result)}
|
||||||
|
className="px-3 py-1 bg-purple-50 text-purple rounded-lg text-xs font-medium hover:bg-purple-100 transition-colors cursor-pointer border-none"
|
||||||
|
>
|
||||||
|
+ Queue
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={result.bandcamp_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-1 rounded-lg text-charcoal-muted hover:text-purple hover:bg-purple-50 transition-colors"
|
||||||
|
title="Open on Bandcamp"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Player Section */}
|
||||||
|
{(currentPlayer || queue.length > 0) && (
|
||||||
|
<div className="bg-charcoal rounded-2xl shadow-lg overflow-hidden">
|
||||||
|
{/* Now Playing */}
|
||||||
|
{currentPlayer && (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Headphones className="w-5 h-5 text-purple" />
|
||||||
|
<h2 className="text-lg font-semibold text-white">Now Playing</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentPlayer.loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="w-10 h-10 border-4 border-purple border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : currentPlayer.embed ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
{currentPlayer.embed.art_url && (
|
||||||
|
<img
|
||||||
|
src={currentPlayer.embed.art_url}
|
||||||
|
alt={currentPlayer.embed.title}
|
||||||
|
className="w-16 h-16 rounded-xl object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-semibold text-white truncate">
|
||||||
|
{currentPlayer.embed.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-purple-200 text-sm truncate">
|
||||||
|
{currentPlayer.embed.artist}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={currentPlayer.result.bandcamp_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="ml-auto flex items-center gap-1 px-3 py-1.5 bg-purple/20 text-purple-200 rounded-lg text-xs hover:bg-purple/30 transition-colors no-underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
Bandcamp
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<iframe
|
||||||
|
style={{ border: 0, width: '100%', height: `${embedHeight}px` }}
|
||||||
|
src={currentPlayer.embed.embed_url}
|
||||||
|
seamless
|
||||||
|
title={`${currentPlayer.embed.title} by ${currentPlayer.embed.artist}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-purple-200/60">
|
||||||
|
<p>Could not load player. Try opening on Bandcamp directly.</p>
|
||||||
|
<a
|
||||||
|
href={currentPlayer.result.bandcamp_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 mt-2 text-purple-200 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
Open on Bandcamp
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Queue */}
|
||||||
|
{queue.length > 0 && (
|
||||||
|
<div className={`border-t border-white/10 p-6 ${!currentPlayer ? 'pt-6' : ''}`}>
|
||||||
|
<h3 className="text-sm font-semibold text-purple-200 uppercase tracking-wide mb-3">
|
||||||
|
Up Next ({queue.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{queue.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={`${item.result.bandcamp_url}-${i}`}
|
||||||
|
className="flex items-center gap-3 bg-white/5 rounded-xl px-4 py-3 hover:bg-white/10 transition-colors group"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-purple-200/50 w-5 text-center">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white text-sm font-medium truncate">
|
||||||
|
{item.result.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-purple-200/60 text-xs truncate">
|
||||||
|
{item.result.artist}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => playFromQueue(i)}
|
||||||
|
className="p-1.5 rounded-lg text-purple-200 hover:text-white hover:bg-purple/30 transition-colors cursor-pointer bg-transparent border-none opacity-0 group-hover:opacity-100"
|
||||||
|
title="Play now"
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeFromQueue(i)}
|
||||||
|
className="p-1.5 rounded-lg text-purple-200/50 hover:text-red-400 hover:bg-red-400/10 transition-colors cursor-pointer bg-transparent border-none opacity-0 group-hover:opacity-100"
|
||||||
|
title="Remove from queue"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user