commit df7ba0b4719b439d2a5c73741bb7ba232650a173 Author: mula.liu Date: Sun Jan 18 15:52:00 2026 +0800 v 0.0.9 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -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? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..6ac5fc8 --- /dev/null +++ b/App.tsx @@ -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(null); + + // Player State + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + + // Playlist State + const [playlist, setPlaylist] = useState([INITIAL_TRACK]); + const [currentIndex, setCurrentIndex] = useState(0); + const [playbackMode, setPlaybackMode] = useState('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 ( +
+ {/* Dynamic Background */} +
+
+ + {/* Main Content Area */} +
+ + {/* Left/Top: Turntable Area */} +
+ {/* Header branding */} +
+ + Vinyl Vibes +
+ + +
+ + {/* Right/Bottom: Lyrics Area */} +
+ + + {/* Gradient masks for lyrics fade effect */} +
+
+
+
+ + {/* Controls */} + { + 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 */} +
+ ); +}; + +export default App; \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a8e2fb --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# 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` diff --git a/components/Controls.tsx b/components/Controls.tsx new file mode 100644 index 0000000..b786c9c --- /dev/null +++ b/components/Controls.tsx @@ -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 = ({ + isPlaying, + currentTime, + duration, + onPlayPause, + onSeek, + onNext, + onPrev, + title, + artist, + onUploadClick, + playbackMode, + onToggleMode +}) => { + const progressPercent = duration ? (currentTime / duration) * 100 : 0; + + const handleSeek = (e: React.ChangeEvent) => { + onSeek(Number(e.target.value)); + }; + + return ( +
+ {/* Progress Bar */} +
+ {formatTime(currentTime)} +
+
+ +
+ {formatTime(duration)} +
+ +
+ {/* Track Info */} +
+

{title}

+

{artist}

+
+ + {/* Main Controls */} +
+ + + + + + + + + {/* Placeholder for symmetry or maybe Like button later */} +
+
+ + {/* Secondary Actions */} +
+
+ +
+
+
+
+ +
+ + +
+
+
+ ); +}; + +export default Controls; \ No newline at end of file diff --git a/components/LyricsPanel.tsx b/components/LyricsPanel.tsx new file mode 100644 index 0000000..f9dfbd7 --- /dev/null +++ b/components/LyricsPanel.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useRef } from 'react'; +import { LyricLine } from '../types'; + +interface LyricsPanelProps { + lyrics: LyricLine[]; + currentTime: number; +} + +const LyricsPanel: React.FC = ({ lyrics, currentTime }) => { + const containerRef = useRef(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 ( +
+

No lyrics available for this track.

+
+ ); + } + + return ( +
+ {lyrics.map((line, index) => { + const isActive = index === activeIndex; + return ( +
+ {line.text} +
+ ); + })} +
+ ); +}; + +export default LyricsPanel; \ No newline at end of file diff --git a/components/Turntable.tsx b/components/Turntable.tsx new file mode 100644 index 0000000..c93072d --- /dev/null +++ b/components/Turntable.tsx @@ -0,0 +1,105 @@ +import React, { useEffect, useState } from 'react'; + +interface TurntableProps { + isPlaying: boolean; + coverUrl: string; +} + +const Turntable: React.FC = ({ 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 ( +
+ {/* Plinth / Base */} +
+ {/* Texture grain */} +
+
+ + {/* Turntable Platter (The metal part under the record) */} +
+
+
+ + {/* The Vinyl Record */} +
+ {/* Vinyl Grooves Texture */} +
+ + {/* Shiny reflection on vinyl */} +
+
+ + {/* Label / Cover Art */} +
+ Album Cover +
+ + {/* Spindle Hole */} +
+
+ + {/* Tonearm Structure */} +
+ {/* Pivot Base */} +
+
+
+ + {/* The Arm itself */} +
+ {/* Main rod */} +
+ + {/* Headstock */} +
+ + {/* Needle */} +
+
+
+ + {/* Start/Stop Button Visual on Turntable */} +
+
+
+
+ ); +}; + +export default Turntable; \ No newline at end of file diff --git a/components/UploadOverlay.tsx b/components/UploadOverlay.tsx new file mode 100644 index 0000000..f7226d8 --- /dev/null +++ b/components/UploadOverlay.tsx @@ -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 = ({ + isOpen, + onClose, + onTrackLoaded, + onPlaylistLoaded, + currentPlaylistUrl +}) => { + // Playlist State + const [playlistUrl, setPlaylistUrl] = useState(currentPlaylistUrl); + const [playlistLoading, setPlaylistLoading] = useState(false); + + // Local Upload State + const [audioFile, setAudioFile] = useState(null); + const [coverFile, setCoverFile] = useState(null); + const [lrcFile, setLrcFile] = useState(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, 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 ( +
+
+ + +

+ Library & Source +

+ + {/* Tabs */} +
+ + +
+ + {activeTab === 'playlist' && ( +
+
+ +
+
+ + 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" + /> +
+
+

+ Supported format: JSON array with Title, Artist, Audio, Cover, Lyrics fields. +

+
+ + +
+ )} + + {activeTab === 'local' && ( +
+ {/* Metadata Inputs */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + {/* File Inputs */} +
+
+
+
+ +
+ + {audioFile ? audioFile.name : 'Select Audio (MP3/WAV)'} + +
+ handleFileChange(e, setAudioFile)} + /> + +
+ +
+
+
+ +
+ + {coverFile ? coverFile.name : 'Select Cover (Img)'} + +
+ handleFileChange(e, setCoverFile)} + /> + +
+ +
+
+
+ +
+ + {lrcFile ? lrcFile.name : 'Select Lyrics (.lrc)'} + +
+ handleFileChange(e, setLrcFile)} + /> + +
+
+ + +
+ )} + +
+
+ ); +}; + +export default UploadOverlay; \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..2a7df49 --- /dev/null +++ b/index.html @@ -0,0 +1,52 @@ + + + + + + Vinyl Vibes + + + + + + +
+ + + \ No newline at end of file diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..6ca5361 --- /dev/null +++ b/index.tsx @@ -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( + + + +); \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..719a01b --- /dev/null +++ b/metadata.json @@ -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": [] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..2494129 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/tsconfig.json @@ -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 + } +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..b8f7488 --- /dev/null +++ b/types.ts @@ -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'; \ No newline at end of file diff --git a/utils/parsers.ts b/utils/parsers.ts new file mode 100644 index 0000000..52469d6 --- /dev/null +++ b/utils/parsers.ts @@ -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 => { + 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 []; + } +}; \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ee5fb8d --- /dev/null +++ b/vite.config.ts @@ -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, '.'), + } + } + }; +});