v 0.0.9
commit
df7ba0b471
|
|
@ -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?
|
||||
|
|
@ -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;
|
||||
|
|
@ -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`
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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 [];
|
||||
}
|
||||
};
|
||||
|
|
@ -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, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Loading…
Reference in New Issue