main
mula.liu 2026-01-18 15:52:00 +08:00
commit df7ba0b471
15 changed files with 1194 additions and 0 deletions

24
.gitignore vendored 100644
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

249
App.tsx 100644
View File

@ -0,0 +1,249 @@
import React, { useState, useRef, useEffect } from 'react';
import Turntable from './components/Turntable';
import LyricsPanel from './components/LyricsPanel';
import Controls from './components/Controls';
import UploadOverlay from './components/UploadOverlay';
import { TrackData, PlaybackMode } from './types';
import { fetchPlaylist, parseLrc } from './utils/parsers';
import { Disc } from 'lucide-react';
// Use HTTP. The bucket likely does not support HTTPS.
// The proxy in utils/parsers.ts will handle the fetching securely.
const DEFAULT_PLAYLIST_URL = "http://t91rqdjhx.hn-bkt.clouddn.com/playlist.json";
// Placeholder track while loading
const INITIAL_TRACK: TrackData = {
title: "Waiting for vinyl...",
artist: "Vinyl Vibes",
coverUrl: "https://images.unsplash.com/photo-1605559911160-a60e5a88948c?q=80&w=1000&auto=format&fit=crop",
audioUrl: "",
lyrics: []
};
const App: React.FC = () => {
const audioRef = useRef<HTMLAudioElement>(null);
// Player State
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
// Playlist State
const [playlist, setPlaylist] = useState<TrackData[]>([INITIAL_TRACK]);
const [currentIndex, setCurrentIndex] = useState(0);
const [playbackMode, setPlaybackMode] = useState<PlaybackMode>('SEQUENCE');
const [playlistUrl, setPlaylistUrl] = useState(DEFAULT_PLAYLIST_URL);
// UI State
const [isUploadOpen, setIsUploadOpen] = useState(false);
// Initialize Default Playlist
useEffect(() => {
const init = async () => {
const tracks = await fetchPlaylist(DEFAULT_PLAYLIST_URL);
if (tracks.length > 0) {
setPlaylist(tracks);
setCurrentIndex(0);
}
};
init();
}, []);
// Derived current track
const currentTrack = playlist[currentIndex] || INITIAL_TRACK;
// Effect: Handle Track Change (Load Lyrics if needed)
useEffect(() => {
const loadTrackDetails = async () => {
const track = playlist[currentIndex];
if (!track) return;
// If lyrics are missing but source exists, fetch them
if (track.lyrics.length === 0 && track.lyricsSource) {
try {
let lrcContent = "";
// Check if it's a URL
if (track.lyricsSource.startsWith('http')) {
// Try to fetch lyrics, might need proxy if CORS fails
try {
const res = await fetch(track.lyricsSource);
if (!res.ok) throw new Error("Direct lyrics fetch failed");
lrcContent = await res.text();
} catch (e) {
// Simple proxy fallback for lyrics text
const proxyUrl = `https://api.allorigins.win/raw?url=${encodeURIComponent(track.lyricsSource)}`;
const res = await fetch(proxyUrl);
lrcContent = await res.text();
}
} else {
// Assume it's raw text
lrcContent = track.lyricsSource;
}
const parsedLyrics = parseLrc(lrcContent);
// Update playlist with parsed lyrics to avoid re-fetching
setPlaylist(prev => {
const newPlaylist = [...prev];
newPlaylist[currentIndex] = { ...track, lyrics: parsedLyrics };
return newPlaylist;
});
} catch (e) {
console.error("Failed to load lyrics", e);
}
}
// Reset audio if URL changed (implicitly handled by audio tag src prop, but explicit load is safer)
if (audioRef.current) {
// Only auto-play if we have a valid audio URL and it's not the initial placeholder
if (track.audioUrl) {
audioRef.current.load();
// If we were playing, keep playing. If this is the very first load, maybe wait.
// Let's auto-play if the user has interacted (isPlaying was true) OR if it's a playlist navigation.
// For simplicity, we try to play.
audioRef.current.play()
.then(() => setIsPlaying(true))
.catch(() => setIsPlaying(false)); // Autoplay might be blocked
}
}
};
loadTrackDetails();
}, [currentIndex, playlist.length]); // Depend on index and playlist length (initial load)
const togglePlay = () => {
if (audioRef.current && currentTrack.audioUrl) {
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play().catch(console.error);
}
setIsPlaying(!isPlaying);
}
};
const playNext = () => {
if (playlist.length <= 1) return;
let nextIndex = 0;
if (playbackMode === 'SHUFFLE') {
// Simple random excluding current if possible
do {
nextIndex = Math.floor(Math.random() * playlist.length);
} while (nextIndex === currentIndex && playlist.length > 1);
} else {
nextIndex = (currentIndex + 1) % playlist.length;
}
setCurrentIndex(nextIndex);
};
const playPrev = () => {
if (playlist.length <= 1) return;
let prevIndex = currentIndex - 1;
if (prevIndex < 0) prevIndex = playlist.length - 1;
setCurrentIndex(prevIndex);
};
const handleTrackEnded = () => {
playNext();
};
const handleAudioError = (e: any) => {
console.error("Audio playback error", e);
// Fallback logic could go here, but for now we just log it.
// E.g., if HTTPS upgrade failed, maybe try proxy?
// Hard to do without state loop, so we stick to logging.
setIsPlaying(false);
};
// Handle single local track load
const handleLocalTrackLoaded = (track: TrackData) => {
// Replace playlist with single track
setPlaylist([track]);
setCurrentIndex(0);
};
// Handle playlist load
const handlePlaylistLoaded = (tracks: TrackData[]) => {
setPlaylist(tracks);
setCurrentIndex(0);
};
return (
<div className="relative w-full h-screen bg-neutral-950 flex flex-col overflow-hidden">
{/* Dynamic Background */}
<div
className="absolute inset-0 z-0 bg-cover bg-center transition-all duration-1000 blur-[80px] opacity-40 scale-110"
style={{ backgroundImage: `url(${currentTrack.coverUrl || INITIAL_TRACK.coverUrl})` }}
></div>
<div className="absolute inset-0 z-0 bg-neutral-950/40 backdrop-blur-sm"></div>
{/* Main Content Area */}
<div className="relative z-10 flex-1 flex flex-col md:flex-row overflow-hidden">
{/* Left/Top: Turntable Area */}
<div className="flex-1 flex items-center justify-center p-8 md:p-12 lg:p-16 relative">
{/* Header branding */}
<div className="absolute top-6 left-6 md:top-8 md:left-8 flex items-center gap-2 text-white/80">
<Disc className="animate-spin-slow" />
<span className="font-bold tracking-widest text-sm uppercase">Vinyl Vibes</span>
</div>
<Turntable isPlaying={isPlaying} coverUrl={currentTrack.coverUrl || INITIAL_TRACK.coverUrl} />
</div>
{/* Right/Bottom: Lyrics Area */}
<div className="flex-1 md:h-full h-[30vh] border-t md:border-t-0 md:border-l border-white/5 bg-black/20 backdrop-blur-md relative">
<LyricsPanel lyrics={currentTrack.lyrics} currentTime={currentTime} />
{/* Gradient masks for lyrics fade effect */}
<div className="absolute top-0 left-0 right-0 h-24 bg-gradient-to-b from-neutral-950/80 to-transparent pointer-events-none"></div>
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-neutral-950/80 to-transparent pointer-events-none"></div>
</div>
</div>
{/* Controls */}
<Controls
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
onPlayPause={togglePlay}
onSeek={(time) => {
if (audioRef.current) {
audioRef.current.currentTime = time;
setCurrentTime(time);
}
}}
onNext={playNext}
onPrev={playPrev}
title={currentTrack.title}
artist={currentTrack.artist}
onUploadClick={() => setIsUploadOpen(true)}
playbackMode={playbackMode}
onToggleMode={() => setPlaybackMode(prev => prev === 'SEQUENCE' ? 'SHUFFLE' : 'SEQUENCE')}
/>
{/* Hidden Audio Element */}
<audio
ref={audioRef}
src={currentTrack.audioUrl}
onTimeUpdate={() => audioRef.current && setCurrentTime(audioRef.current.currentTime)}
onLoadedMetadata={() => audioRef.current && setDuration(audioRef.current.duration)}
onEnded={handleTrackEnded}
onError={handleAudioError}
crossOrigin="anonymous"
/>
{/* Upload/Settings Modal */}
<UploadOverlay
isOpen={isUploadOpen}
onClose={() => setIsUploadOpen(false)}
onTrackLoaded={handleLocalTrackLoaded}
onPlaylistLoaded={handlePlaylistLoaded}
currentPlaylistUrl={playlistUrl}
/>
</div>
);
};
export default App;

20
README.md 100644
View File

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1I78oivOXWdcjAkJqTyrfS95YiuIOfvTD
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@ -0,0 +1,127 @@
import React from 'react';
import { Play, Pause, SkipBack, SkipForward, Volume2, Upload, Shuffle, Repeat } from 'lucide-react';
import { formatTime } from '../utils/parsers';
import { PlaybackMode } from '../types';
interface ControlsProps {
isPlaying: boolean;
currentTime: number;
duration: number;
onPlayPause: () => void;
onSeek: (time: number) => void;
onNext: () => void;
onPrev: () => void;
title: string;
artist: string;
onUploadClick: () => void;
playbackMode: PlaybackMode;
onToggleMode: () => void;
}
const Controls: React.FC<ControlsProps> = ({
isPlaying,
currentTime,
duration,
onPlayPause,
onSeek,
onNext,
onPrev,
title,
artist,
onUploadClick,
playbackMode,
onToggleMode
}) => {
const progressPercent = duration ? (currentTime / duration) * 100 : 0;
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
onSeek(Number(e.target.value));
};
return (
<div className="w-full glass-panel border-t border-white/10 p-6 flex flex-col gap-4 relative z-40">
{/* Progress Bar */}
<div className="flex items-center gap-3 text-xs font-mono text-neutral-400">
<span className="w-10 text-right">{formatTime(currentTime)}</span>
<div className="flex-1 relative h-1.5 bg-neutral-800 rounded-full group cursor-pointer">
<div
className="absolute top-0 left-0 h-full bg-amber-500 rounded-full"
style={{ width: `${progressPercent}%` }}
></div>
<input
type="range"
min={0}
max={duration || 100}
value={currentTime}
onChange={handleSeek}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</div>
<span className="w-10">{formatTime(duration)}</span>
</div>
<div className="flex items-center justify-between">
{/* Track Info */}
<div className="hidden md:flex flex-col w-1/3">
<h3 className="text-white font-bold truncate">{title}</h3>
<p className="text-neutral-400 text-sm truncate">{artist}</p>
</div>
{/* Main Controls */}
<div className="flex items-center justify-center gap-6 w-full md:w-1/3">
<button
onClick={onToggleMode}
className={`transition-colors ${playbackMode === 'SHUFFLE' ? 'text-amber-500' : 'text-neutral-600 hover:text-neutral-400'}`}
title={playbackMode === 'SHUFFLE' ? "Shuffle On" : "Sequential Play"}
>
{playbackMode === 'SHUFFLE' ? <Shuffle size={20} /> : <Repeat size={20} />}
</button>
<button onClick={onPrev} className="text-neutral-400 hover:text-white transition-colors">
<SkipBack size={24} />
</button>
<button
onClick={onPlayPause}
className="w-14 h-14 rounded-full bg-white text-black flex items-center justify-center hover:scale-105 transition-transform shadow-lg shadow-white/10"
>
{isPlaying ? (
<Pause fill="currentColor" size={24} />
) : (
<Play fill="currentColor" size={24} className="ml-1" />
)}
</button>
<button onClick={onNext} className="text-neutral-400 hover:text-white transition-colors">
<SkipForward size={24} />
</button>
{/* Placeholder for symmetry or maybe Like button later */}
<div className="w-5"></div>
</div>
{/* Secondary Actions */}
<div className="flex items-center justify-end gap-4 w-1/3">
<div className="hidden sm:flex items-center gap-2 text-neutral-400">
<Volume2 size={20} />
<div className="w-20 h-1 bg-neutral-700 rounded-full overflow-hidden">
<div className="w-[70%] h-full bg-neutral-400"></div>
</div>
</div>
<div className="h-6 w-[1px] bg-neutral-700 hidden sm:block"></div>
<button
onClick={onUploadClick}
className="flex items-center gap-2 bg-neutral-800 hover:bg-neutral-700 text-white px-4 py-2 rounded-full text-xs font-medium transition-colors border border-neutral-700"
>
<Upload size={14} />
<span className="hidden sm:inline">Load / Settings</span>
</button>
</div>
</div>
</div>
);
};
export default Controls;

View File

@ -0,0 +1,65 @@
import React, { useEffect, useRef } from 'react';
import { LyricLine } from '../types';
interface LyricsPanelProps {
lyrics: LyricLine[];
currentTime: number;
}
const LyricsPanel: React.FC<LyricsPanelProps> = ({ lyrics, currentTime }) => {
const containerRef = useRef<HTMLDivElement>(null);
const activeIndex = lyrics.findIndex((line, i) => {
const nextLine = lyrics[i + 1];
return currentTime >= line.time && (!nextLine || currentTime < nextLine.time);
});
useEffect(() => {
if (activeIndex !== -1 && containerRef.current) {
const activeElement = containerRef.current.children[activeIndex] as HTMLElement;
if (activeElement) {
// Calculate scroll position to center the active element
const containerHeight = containerRef.current.clientHeight;
const elementTop = activeElement.offsetTop;
const elementHeight = activeElement.clientHeight;
containerRef.current.scrollTo({
top: elementTop - containerHeight / 2 + elementHeight / 2,
behavior: 'smooth'
});
}
}
}, [activeIndex]);
if (lyrics.length === 0) {
return (
<div className="flex items-center justify-center h-full text-neutral-500 italic">
<p>No lyrics available for this track.</p>
</div>
);
}
return (
<div
ref={containerRef}
className="h-full w-full overflow-y-auto no-scrollbar py-[50vh] text-center px-4"
>
{lyrics.map((line, index) => {
const isActive = index === activeIndex;
return (
<div
key={index}
className={`transition-all duration-500 ease-out py-3 origin-center ${
isActive
? 'text-amber-400 text-2xl md:text-3xl font-bold opacity-100 scale-105'
: 'text-neutral-400 text-lg md:text-xl font-medium opacity-40 blur-[0.5px]'
}`}
>
{line.text}
</div>
);
})}
</div>
);
};
export default LyricsPanel;

View File

@ -0,0 +1,105 @@
import React, { useEffect, useState } from 'react';
interface TurntableProps {
isPlaying: boolean;
coverUrl: string;
}
const Turntable: React.FC<TurntableProps> = ({ isPlaying, coverUrl }) => {
const [rotation, setRotation] = useState(0);
// Use requestAnimationFrame for smoother rotation than CSS only when we want to control pause state precisely
useEffect(() => {
let animationFrameId: number;
let lastTime = performance.now();
const animate = (time: number) => {
if (isPlaying) {
const delta = time - lastTime;
// 33 1/3 RPM = ~0.55 rotations per second = ~200 degrees per second
// Removed modulo 360 to prevent jumping when value wraps around
setRotation((prev) => (prev + (delta * 0.15)));
}
lastTime = time;
animationFrameId = requestAnimationFrame(animate);
};
animationFrameId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationFrameId);
}, [isPlaying]);
return (
<div className="relative w-full max-w-[500px] aspect-square flex items-center justify-center">
{/* Plinth / Base */}
<div className="absolute inset-4 bg-[#1a1a1a] rounded-[40px] shadow-2xl border border-neutral-800">
{/* Texture grain */}
<div className="absolute inset-0 opacity-20 bg-[url('https://www.transparenttextures.com/patterns/wood-pattern.png')] rounded-[40px] pointer-events-none"></div>
</div>
{/* Turntable Platter (The metal part under the record) */}
<div className="absolute inset-12 rounded-full bg-neutral-800 shadow-inner border border-neutral-700 flex items-center justify-center">
<div className="w-[98%] h-[98%] rounded-full bg-neutral-900 shadow-lg"></div>
</div>
{/* The Vinyl Record */}
<div
// Removed transition-transform to allow smooth infinite rotation driven by JS without CSS conflicts
className="absolute w-[76%] h-[76%] rounded-full shadow-[0_10px_30px_rgba(0,0,0,0.5)] will-change-transform"
style={{ transform: `rotate(${rotation}deg)` }}
>
{/* Vinyl Grooves Texture */}
<div className="absolute inset-0 rounded-full vinyl-grooves opacity-90 border-[6px] border-[#0a0a0a]"></div>
{/* Shiny reflection on vinyl */}
<div className="absolute inset-0 rounded-full bg-gradient-to-tr from-transparent via-white/5 to-transparent rotate-45 pointer-events-none"></div>
<div className="absolute inset-0 rounded-full bg-gradient-to-bl from-transparent via-white/5 to-transparent rotate-45 pointer-events-none"></div>
{/* Label / Cover Art */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[42%] h-[42%] rounded-full overflow-hidden border-4 border-[#111]">
<img
src={coverUrl}
alt="Album Cover"
className="w-full h-full object-cover"
/>
</div>
{/* Spindle Hole */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-3 h-3 bg-[#e5e5e5] rounded-full border border-black z-10"></div>
</div>
{/* Tonearm Structure */}
<div className="absolute top-4 right-4 w-24 h-64 pointer-events-none z-20">
{/* Pivot Base */}
<div className="absolute top-4 right-4 w-16 h-16 rounded-full bg-gradient-to-br from-neutral-600 to-neutral-800 shadow-xl border border-neutral-600 flex items-center justify-center">
<div className="w-8 h-8 rounded-full bg-neutral-400 shadow-inner"></div>
</div>
{/* The Arm itself */}
<div
className="absolute top-12 right-12 w-8 h-64 origin-[top_center] transition-transform duration-1000 ease-in-out"
style={{
// Adjusted angles: -25deg moves it visibly off the record, 20deg moves it onto the record
transform: isPlaying ? 'rotate(20deg)' : 'rotate(-25deg)',
transformOrigin: '50% 20px'
}}
>
{/* Main rod */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-3 h-48 bg-gradient-to-r from-neutral-400 via-neutral-200 to-neutral-400 rounded-full shadow-lg"></div>
{/* Headstock */}
<div className="absolute bottom-12 left-1/2 -translate-x-1/2 w-8 h-12 bg-neutral-800 rounded-sm border border-neutral-600"></div>
{/* Needle */}
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 w-1 h-4 bg-amber-200"></div>
</div>
</div>
{/* Start/Stop Button Visual on Turntable */}
<div className="absolute bottom-10 left-10 w-12 h-12 rounded-full bg-gradient-to-br from-neutral-700 to-neutral-900 border border-neutral-600 shadow-lg flex items-center justify-center">
<div className={`w-2 h-2 rounded-full ${isPlaying ? 'bg-green-500 shadow-[0_0_10px_#22c55e]' : 'bg-red-500'} transition-colors duration-300`}></div>
</div>
</div>
);
};
export default Turntable;

View File

@ -0,0 +1,284 @@
import React, { useState, ChangeEvent, useEffect } from 'react';
import { Upload, Music, FileText, Image as ImageIcon, X, Link, ListMusic } from 'lucide-react';
import { TrackData } from '../types';
import { parseLrc, fetchPlaylist } from '../utils/parsers';
interface UploadOverlayProps {
isOpen: boolean;
onClose: () => void;
onTrackLoaded: (track: TrackData) => void;
onPlaylistLoaded: (playlist: TrackData[]) => void;
currentPlaylistUrl: string;
}
const UploadOverlay: React.FC<UploadOverlayProps> = ({
isOpen,
onClose,
onTrackLoaded,
onPlaylistLoaded,
currentPlaylistUrl
}) => {
// Playlist State
const [playlistUrl, setPlaylistUrl] = useState(currentPlaylistUrl);
const [playlistLoading, setPlaylistLoading] = useState(false);
// Local Upload State
const [audioFile, setAudioFile] = useState<File | null>(null);
const [coverFile, setCoverFile] = useState<File | null>(null);
const [lrcFile, setLrcFile] = useState<File | null>(null);
const [title, setTitle] = useState('Unknown Track');
const [artist, setArtist] = useState('Unknown Artist');
const [localLoading, setLocalLoading] = useState(false);
// Tab state
const [activeTab, setActiveTab] = useState<'playlist' | 'local'>('playlist');
useEffect(() => {
setPlaylistUrl(currentPlaylistUrl);
}, [currentPlaylistUrl]);
if (!isOpen) return null;
const handleFileChange = (e: ChangeEvent<HTMLInputElement>, setter: (f: File | null) => void) => {
if (e.target.files && e.target.files[0]) {
setter(e.target.files[0]);
}
};
const handleLoadPlaylist = async () => {
if (!playlistUrl) return;
setPlaylistLoading(true);
try {
const tracks = await fetchPlaylist(playlistUrl);
if (tracks.length > 0) {
onPlaylistLoaded(tracks);
onClose();
} else {
alert("No tracks found in playlist or invalid format.");
}
} catch (e) {
alert("Failed to load playlist.");
} finally {
setPlaylistLoading(false);
}
};
const handleLocalLoad = async () => {
if (!audioFile) return;
setLocalLoading(true);
try {
const audioUrl = URL.createObjectURL(audioFile);
const coverUrl = coverFile
? URL.createObjectURL(coverFile)
: 'https://picsum.photos/400/400'; // Fallback
let lyrics: any[] = [];
if (lrcFile) {
const text = await lrcFile.text();
lyrics = parseLrc(text);
}
const track: TrackData = {
title: title || audioFile.name.replace(/\.[^/.]+$/, ""),
artist: artist || "Unknown Artist",
audioUrl,
coverUrl,
lyrics
};
onTrackLoaded(track);
onClose();
} catch (error) {
console.error("Error loading track", error);
} finally {
setLocalLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div className="bg-neutral-900 border border-neutral-700 rounded-2xl p-6 w-full max-w-lg shadow-2xl relative max-h-[90vh] overflow-y-auto">
<button
onClick={onClose}
className="absolute top-4 right-4 text-neutral-400 hover:text-white"
>
<X size={24} />
</button>
<h2 className="text-2xl font-bold mb-6 text-white flex items-center gap-2">
<Music className="text-amber-500" /> Library & Source
</h2>
{/* Tabs */}
<div className="flex gap-4 mb-6 border-b border-neutral-700 pb-2">
<button
onClick={() => setActiveTab('playlist')}
className={`text-sm font-bold pb-2 transition-colors ${activeTab === 'playlist' ? 'text-white border-b-2 border-amber-500' : 'text-neutral-500 hover:text-neutral-300'}`}
>
Remote Playlist
</button>
<button
onClick={() => setActiveTab('local')}
className={`text-sm font-bold pb-2 transition-colors ${activeTab === 'local' ? 'text-white border-b-2 border-amber-500' : 'text-neutral-500 hover:text-neutral-300'}`}
>
Local File (Single)
</button>
</div>
{activeTab === 'playlist' && (
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-neutral-400 mb-2">Playlist JSON URL</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Link size={16} className="absolute left-3 top-3 text-neutral-500" />
<input
type="text"
value={playlistUrl}
onChange={(e) => setPlaylistUrl(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-amber-500"
placeholder="https://example.com/playlist.json"
/>
</div>
</div>
<p className="text-[10px] text-neutral-500 mt-2">
Supported format: JSON array with Title, Artist, Audio, Cover, Lyrics fields.
</p>
</div>
<button
onClick={handleLoadPlaylist}
disabled={playlistLoading || !playlistUrl}
className={`w-full mt-4 py-3 rounded-lg font-bold text-black transition-all ${
playlistLoading || !playlistUrl
? 'bg-neutral-700 cursor-not-allowed text-neutral-500'
: 'bg-amber-500 hover:bg-amber-400 shadow-[0_0_15px_rgba(245,158,11,0.4)]'
}`}
>
{playlistLoading ? 'Fetching Playlist...' : 'Load Playlist'}
</button>
</div>
)}
{activeTab === 'local' && (
<div className="space-y-4">
{/* Metadata Inputs */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-neutral-400 mb-1">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-amber-500"
placeholder="Song Title"
/>
</div>
<div>
<label className="block text-xs font-medium text-neutral-400 mb-1">Artist</label>
<input
type="text"
value={artist}
onChange={(e) => setArtist(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-amber-500"
placeholder="Artist Name"
/>
</div>
</div>
{/* File Inputs */}
<div className="space-y-3">
<div className="bg-neutral-800/50 border border-neutral-700 border-dashed rounded-lg p-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-blue-500/20 flex items-center justify-center text-blue-400">
<Music size={16} />
</div>
<span className="text-sm text-neutral-300 truncate max-w-[150px]">
{audioFile ? audioFile.name : 'Select Audio (MP3/WAV)'}
</span>
</div>
<input
type="file"
accept="audio/*"
className="hidden"
id="audio-upload"
onChange={(e) => handleFileChange(e, setAudioFile)}
/>
<label
htmlFor="audio-upload"
className="text-xs bg-neutral-700 hover:bg-neutral-600 px-3 py-1.5 rounded cursor-pointer transition-colors"
>
Browse
</label>
</div>
<div className="bg-neutral-800/50 border border-neutral-700 border-dashed rounded-lg p-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-purple-500/20 flex items-center justify-center text-purple-400">
<ImageIcon size={16} />
</div>
<span className="text-sm text-neutral-300 truncate max-w-[150px]">
{coverFile ? coverFile.name : 'Select Cover (Img)'}
</span>
</div>
<input
type="file"
accept="image/*"
className="hidden"
id="cover-upload"
onChange={(e) => handleFileChange(e, setCoverFile)}
/>
<label
htmlFor="cover-upload"
className="text-xs bg-neutral-700 hover:bg-neutral-600 px-3 py-1.5 rounded cursor-pointer transition-colors"
>
Browse
</label>
</div>
<div className="bg-neutral-800/50 border border-neutral-700 border-dashed rounded-lg p-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-green-500/20 flex items-center justify-center text-green-400">
<FileText size={16} />
</div>
<span className="text-sm text-neutral-300 truncate max-w-[150px]">
{lrcFile ? lrcFile.name : 'Select Lyrics (.lrc)'}
</span>
</div>
<input
type="file"
accept=".lrc,.txt"
className="hidden"
id="lrc-upload"
onChange={(e) => handleFileChange(e, setLrcFile)}
/>
<label
htmlFor="lrc-upload"
className="text-xs bg-neutral-700 hover:bg-neutral-600 px-3 py-1.5 rounded cursor-pointer transition-colors"
>
Browse
</label>
</div>
</div>
<button
onClick={handleLocalLoad}
disabled={!audioFile || localLoading}
className={`w-full mt-6 py-3 rounded-lg font-bold text-black transition-all ${
!audioFile
? 'bg-neutral-700 cursor-not-allowed text-neutral-500'
: 'bg-amber-500 hover:bg-amber-400 shadow-[0_0_15px_rgba(245,158,11,0.4)]'
}`}
>
{localLoading ? 'Processing...' : 'Load Into Player'}
</button>
</div>
)}
</div>
</div>
);
};
export default UploadOverlay;

52
index.html 100644
View File

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vinyl Vibes</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Custom scrollbar for lyrics */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Vinyl Grooves Texture */
.vinyl-grooves {
background: repeating-radial-gradient(
#111 0,
#111 2px,
#1c1c1c 3px,
#1c1c1c 4px
);
}
/* Glassmorphism utilities not fully covered by standard tailwind cdn config */
.glass-panel {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
</style>
<script type="importmap">
{
"imports": {
"lucide-react": "https://esm.sh/lucide-react@^0.562.0",
"react/": "https://esm.sh/react@^19.2.3/",
"react": "https://esm.sh/react@^19.2.3",
"react-dom/": "https://esm.sh/react-dom@^19.2.3/"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-neutral-950 text-white overflow-hidden">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

15
index.tsx 100644
View File

@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

5
metadata.json 100644
View File

@ -0,0 +1,5 @@
{
"name": "Vinyl Vibes Player",
"description": "A high-fidelity virtual vinyl player experience with synchronized rolling lyrics and custom file support.",
"requestFramePermissions": []
}

22
package.json 100644
View File

@ -0,0 +1,22 @@
{
"name": "vinyl-vibes-player",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

29
tsconfig.json 100644
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

22
types.ts 100644
View File

@ -0,0 +1,22 @@
export interface LyricLine {
time: number; // in seconds
text: string;
}
export interface TrackData {
title: string;
artist: string;
coverUrl: string;
audioUrl: string;
lyrics: LyricLine[];
lyricsSource?: string; // Optional URL or raw string for deferred parsing
}
export enum PlayerState {
IDLE = 'IDLE',
LOADING = 'LOADING',
PLAYING = 'PLAYING',
PAUSED = 'PAUSED',
}
export type PlaybackMode = 'SEQUENCE' | 'SHUFFLE';

152
utils/parsers.ts 100644
View File

@ -0,0 +1,152 @@
import { LyricLine, TrackData } from '../types';
export const parseLrc = (lrcContent: string): LyricLine[] => {
if (!lrcContent) return [];
const lines = lrcContent.split('\n');
const lyrics: LyricLine[] = [];
const timeRegex = /\[(\d{2}):(\d{2})\.(\d{2,3})\]/;
lines.forEach((line) => {
const match = timeRegex.exec(line);
if (match) {
const minutes = parseInt(match[1], 10);
const seconds = parseInt(match[2], 10);
const milliseconds = parseInt(match[3], 10);
// Calculate total time in seconds
const time = minutes * 60 + seconds + milliseconds / (match[3].length === 3 ? 1000 : 100);
const text = line.replace(timeRegex, '').trim();
if (text) {
lyrics.push({ time, text });
}
}
});
return lyrics.sort((a, b) => a.time - b.time);
};
export const formatTime = (seconds: number): string => {
if (isNaN(seconds)) return "0:00";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// Helper to get value from multiple potential keys (case-insensitive)
const getValue = (item: any, keys: string[]) => {
for (const key of keys) {
if (item[key] !== undefined && item[key] !== null && item[key] !== "") return item[key];
const lowerKey = key.toLowerCase();
if (item[lowerKey] !== undefined && item[lowerKey] !== null && item[lowerKey] !== "") return item[lowerKey];
}
return "";
};
// Helper to resolve relative URLs against the playlist URL
const resolveUrl = (url: string, baseUrl: string) => {
if (!url) return "";
try {
// If url is absolute, this ignores baseUrl
return new URL(url, baseUrl).href;
} catch (e) {
return url;
}
};
export const fetchPlaylist = async (url: string): Promise<TrackData[]> => {
try {
let response;
let fetchError;
// 1. Try direct fetch first
try {
response = await fetch(url);
if (!response.ok) throw new Error('Direct fetch status not ok');
} catch (e) {
console.warn("Direct fetch failed, attempting proxy fallback 1.", e);
fetchError = e;
response = undefined;
}
// 2. Try AllOrigins Proxy if direct failed
if (!response || !response.ok) {
try {
// allorigins.win is good for bypassing CORS and Mixed Content
const proxyUrl = `https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`;
response = await fetch(proxyUrl);
if (!response.ok) throw new Error('AllOrigins status not ok');
} catch (e) {
console.warn("AllOrigins proxy failed, attempting proxy fallback 2.", e);
fetchError = e;
response = undefined;
}
}
// 3. Try CORS Proxy IO as last resort
if (!response || !response.ok) {
try {
// corsproxy.io
const proxyUrl = `https://corsproxy.io/?${encodeURIComponent(url)}`;
response = await fetch(proxyUrl);
if (!response.ok) throw new Error('CorsProxyIO status not ok');
} catch(e) {
console.warn("CorsProxyIO failed.", e);
fetchError = e;
response = undefined;
}
}
if (!response || !response.ok) {
console.error("All fetch attempts failed.");
throw fetchError || new Error('Failed to fetch playlist');
}
const data = await response.json();
// Map the external JSON format to our internal TrackData interface
return data.map((item: any) => {
// Use helper to find keys even if casing differs or alternative names are used
let coverUrl = getValue(item, ['Cover', 'img', 'image', 'pic', 'poster']);
let audioUrl = getValue(item, ['Audio', 'src', 'url', 'mp3', 'sound']);
let lyricsSource = getValue(item, ['Lyrics', 'lrc']);
const title = getValue(item, ['Title', 'name', 'song']);
const artist = getValue(item, ['Artist', 'author', 'singer']);
// Resolve relative URLs (if the JSON contains filenames instead of full URLs)
coverUrl = resolveUrl(coverUrl, url);
audioUrl = resolveUrl(audioUrl, url);
lyricsSource = resolveUrl(lyricsSource, url);
// Fix Mixed Content for Cover: Route HTTP images through Weserv proxy
if (coverUrl && coverUrl.startsWith('http:')) {
coverUrl = `https://images.weserv.nl/?url=${encodeURIComponent(coverUrl)}`;
}
// Fix Mixed Content for Audio
if (audioUrl && audioUrl.startsWith('http:')) {
// Strategy: Prefer native HTTPS upgrade if possible, otherwise proxy.
// Qiniu (clouddn.com) and other major CDNs usually support HTTPS.
if (audioUrl.includes('.clouddn.com') || audioUrl.includes('aliyuncs.com')) {
audioUrl = audioUrl.replace('http:', 'https:');
} else {
// For others, use a proxy that supports binary streaming.
// corsproxy.io is usually good.
audioUrl = `https://corsproxy.io/?${encodeURIComponent(audioUrl)}`;
}
}
return {
title: title || "Unknown Title",
artist: artist || "Unknown Artist",
coverUrl: coverUrl,
audioUrl: audioUrl,
lyrics: [],
lyricsSource: lyricsSource
};
});
} catch (error) {
console.error("Error fetching playlist:", error);
return [];
}
};

23
vite.config.ts 100644
View File

@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});