""" app.py — Neon Wave Music Player (Gradio / Hugging Face Spaces edition) A full-featured music player built with Gradio that mirrors the PHP version. Run locally: python app.py Deploy on HF Spaces: Push this folder to a Gradio Space — no additional config required. Compatible with Gradio 4.44+ and Gradio 6.x. """ import os import secrets import shutil from pathlib import Path import gradio as gr from database import fetch_songs, init_db, insert_song # ── Paths ────────────────────────────────────────────────────────────────────── BASE_DIR = Path(__file__).parent UPLOAD_DIR = BASE_DIR / "uploads" UPLOAD_DIR.mkdir(exist_ok=True) # Initialise database on startup init_db() # ── Gradio theme (neon-wave dark) ────────────────────────────────────────────── # In Gradio 6.x, theme and css are passed to demo.launch() instead of gr.Blocks(). _theme = gr.themes.Base( primary_hue=gr.themes.colors.sky, secondary_hue=gr.themes.colors.purple, neutral_hue=gr.themes.colors.zinc, font=gr.themes.GoogleFont("Space Mono"), font_mono=gr.themes.GoogleFont("Space Mono"), ).set( body_background_fill="linear-gradient(135deg, hsl(220,13%,18%), hsl(220,13%,25%))", body_text_color="#ffffff", block_background_fill="rgba(255,255,255,0.08)", block_border_color="rgba(255,255,255,0.10)", block_border_width="1px", block_radius="1rem", block_shadow="0 8px 32px rgba(0,0,0,0.25), 0 0 20px rgba(64,160,212,0.12)", block_title_text_color="rgba(255,255,255,0.90)", block_label_text_color="rgba(255,255,255,0.70)", input_background_fill="rgba(255,255,255,0.10)", input_border_color="rgba(255,255,255,0.20)", input_placeholder_color="rgba(255,255,255,0.30)", input_shadow="none", button_primary_background_fill="linear-gradient(90deg, hsl(201,63%,54%), hsl(261,77%,57%))", button_primary_background_fill_hover="linear-gradient(90deg, hsl(201,63%,60%), hsl(261,77%,63%))", button_primary_text_color="#ffffff", button_primary_shadow="0 4px 15px rgba(64,160,212,0.35)", button_secondary_background_fill="rgba(255,255,255,0.10)", button_secondary_background_fill_hover="rgba(255,255,255,0.18)", button_secondary_border_color="rgba(255,255,255,0.20)", button_secondary_text_color="#ffffff", checkbox_background_color="rgba(255,255,255,0.10)", table_even_background_fill="rgba(255,255,255,0.04)", table_odd_background_fill="rgba(255,255,255,0.02)", table_row_focus="rgba(64,160,212,0.15)", ) # ── Extra CSS (neon accents, cover art, etc.) ────────────────────────────────── _CSS = """ /* Remove default white Gradio container background */ .gradio-container { background: transparent !important; } footer { display: none !important; } /* Header */ #app-header { text-align: center; padding: 1.5rem 0 0.5rem; } #app-header h1 { font-size: 2rem !important; font-weight: 700 !important; background: linear-gradient(135deg, hsl(201,63%,65%), hsl(261,77%,70%)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; text-shadow: none !important; } #app-header p { color: rgba(255,255,255,0.55) !important; font-size: 0.85rem; } /* Now-playing title */ #now-playing-title textarea, #now-playing-title input { font-size: 1.3rem !important; font-weight: 700 !important; color: #ffffff !important; text-shadow: 0 0 10px rgba(64,160,212,0.6); background: transparent !important; border: none !important; padding: 0 !important; } #now-playing-artist textarea, #now-playing-artist input { color: rgba(255,255,255,0.65) !important; background: transparent !important; border: none !important; padding: 0 !important; } /* Cover image */ #cover-art img { border-radius: 0.75rem !important; box-shadow: 0 0 25px rgba(64,160,212,0.40) !important; object-fit: cover !important; } #cover-art .image-container { background: rgba(44,62,80,0.9) !important; border-radius: 0.75rem; } /* Playlist table */ #playlist-table table { font-size: 0.85rem !important; } #playlist-table thead { background: rgba(64,160,212,0.18) !important; } #playlist-table th { color: rgba(255,255,255,0.80) !important; } #playlist-table td { color: rgba(255,255,255,0.90) !important; cursor: pointer; } #playlist-table tbody tr:hover { background: rgba(255,255,255,0.09) !important; } /* Lyrics */ #lyrics-display textarea { line-height: 1.8 !important; text-align: center !important; color: rgba(255,255,255,0.80) !important; background: rgba(255,255,255,0.04) !important; font-size: 0.95rem !important; } /* Upload panel */ #upload-panel { border: 1px dashed rgba(255,255,255,0.20) !important; border-radius: 1rem !important; padding: 0.5rem !important; } /* Status messages */ #upload-status textarea { font-size: 0.85rem !important; } /* Footer */ .footer-credit { text-align: center; color: rgba(255,255,255,0.35); font-size: 0.78rem; padding: 1rem 0; } .footer-credit span { color: #f87171; } """ # ── Handlers ─────────────────────────────────────────────────────────────────── def load_playlist() -> tuple[list[dict], list[list[str]]]: """Fetch songs from DB; return (state, dataframe rows).""" songs = fetch_songs() table = [[s["title"], s["artist"]] for s in songs] return songs, table def on_song_select( songs: list[dict], evt: gr.SelectData ) -> tuple: """Called when the user clicks a row in the playlist table.""" row = evt.index[0] if not songs or row >= len(songs): return None, "Select a song", "—", "No lyrics available.", None song = songs[row] audio = song["file"] if os.path.isfile(song["file"]) else None cover = song["cover"] if song.get("cover") and os.path.isfile(song["cover"]) else None lyrics = song.get("lyrics") or "No lyrics available for this song." return audio, song["title"], song["artist"], lyrics, cover def upload_song( song_file, cover_file, title: str, artist: str, lyrics: str, ) -> tuple: """Handle song upload: validate, save files, insert into DB, refresh list.""" if song_file is None: return "❌ Please select an MP3 file.", gr.update(), gr.update() src_path = Path(song_file.name) if src_path.suffix.lower() != ".mp3": return "❌ Only MP3 files are allowed.", gr.update(), gr.update() # ── Save audio ────────────────────────────────────────────────────────── dest_name = secrets.token_hex(16) + ".mp3" dest_path = UPLOAD_DIR / dest_name shutil.copy2(src_path, dest_path) # ── Save optional cover ───────────────────────────────────────────────── cover_path: str | None = None if cover_file is not None: cover_src = Path(cover_file.name) if cover_src.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}: cover_name = secrets.token_hex(16) + cover_src.suffix.lower() cover_dest = UPLOAD_DIR / cover_name shutil.copy2(cover_src, cover_dest) cover_path = str(cover_dest) clean_title = title.strip() or src_path.stem clean_artist = artist.strip() or "Unknown Artist" clean_lyrics = lyrics.strip() insert_song(clean_title, clean_artist, str(dest_path), cover_path, clean_lyrics) songs = fetch_songs() table = [[s["title"], s["artist"]] for s in songs] return f"✅ '{clean_title}' added to the playlist!", songs, table # ── UI ───────────────────────────────────────────────────────────────────────── with gr.Blocks(title="Neon Wave Music Player") as demo: # ── Header ────────────────────────────────────────────────────────────── gr.HTML("""
Browse · Upload · Play