feat: center video player and improve lyrics functionality with Vietnamese support

- Center video player in full-screen mode (like album art)
- Add Plyr video player with proper sync and cleanup
- Improve lyrics functionality with multiple API sources (lyrics.ovh, LRCLIB, SimpMusic, ZingMP3)
- Add Vietnamese language detection for lyrics
- Add helpful links to ZingMP3/NhacCuaTui when lyrics not found
- Mobile UI improvements (gaps, settings panel removal, centered cover)
- Add origin parameter to YouTube embed for better security
- Fix TypeScript errors and improve code quality
This commit is contained in:
Khoa Vo 2026-03-21 20:55:53 +07:00
parent d272cb51e1
commit 023fee470a
21 changed files with 2124 additions and 300 deletions

1185
backend-rust/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -17,3 +17,4 @@ tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.6.8", features = ["cors", "fs"] } tower-http = { version = "0.6.8", features = ["cors", "fs"] }
urlencoding = "2.1.3" urlencoding = "2.1.3"
futures = "0.3" futures = "0.3"
reqwest = { version = "0.12", features = ["json"] }

View file

@ -172,3 +172,147 @@ pub async fn recommendations_handler(
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e}))), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e}))),
} }
} }
#[derive(Deserialize)]
pub struct LyricsQuery {
pub track: String,
pub artist: String,
}
pub async fn lyrics_handler(
Query(params): Query<LyricsQuery>,
) -> impl IntoResponse {
let track = params.track.trim();
let artist = params.artist.trim();
if track.is_empty() || artist.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Track and artist required"})));
}
// Try multiple lyrics APIs in sequence for better coverage
let apis = [
format!("https://api.lyrics.ovh/v1/{}/{}",
urlencoding::encode(artist),
urlencoding::encode(track)),
format!("https://lrclib.net/api/search?artist_name={}&track_name={}",
urlencoding::encode(artist),
urlencoding::encode(track)),
];
for api_url in &apis {
match reqwest::get(api_url).await {
Ok(response) => {
if response.status().is_success() {
match response.text().await {
Ok(text) => {
// Parse response based on API
if api_url.contains("lyrics.ovh") {
// lyrics.ovh returns { "lyrics": "..." }
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(lyrics) = json.get("lyrics").and_then(|l| l.as_str()) {
return (StatusCode::OK, Json(serde_json::json!({
"plainLyrics": lyrics
})));
}
}
} else if api_url.contains("lrclib.net") {
// LRCLIB returns array of results
if let Ok(results) = serde_json::from_str::<Vec<serde_json::Value>>(&text) {
if let Some(first) = results.first() {
let plain = first.get("plainLyrics").and_then(|l| l.as_str());
let synced = first.get("syncedLyrics").and_then(|l| l.as_str());
return (StatusCode::OK, Json(serde_json::json!({
"plainLyrics": plain,
"syncedLyrics": synced
})));
}
}
}
}
Err(_) => continue,
}
}
}
Err(_) => continue,
}
}
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Lyrics not found"})))
}
pub async fn zingmp3_lyrics_handler(
Query(params): Query<LyricsQuery>,
) -> impl IntoResponse {
let track = params.track.trim();
let artist = params.artist.trim();
if track.is_empty() || artist.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Track and artist required"})));
}
// Clean up track name for better search
let clean_track = track
.replace(r"(?i)\s*\(.*?\)", "")
.replace(r"(?i)\s*\[.*?\]", "")
.replace(r"(?i)\s*-\s*Official Audio", "")
.replace(r"(?i)\s*-\s*Lyrics Video", "")
.replace(r"(?i)\s*-\s*MV", "")
.replace(r"(?i)\s*-\s*Audio", "")
.replace(r"(?i)\s*-\s*Video", "")
.trim()
.to_string();
let clean_artist = artist
.replace(r"(?i)\s*\(.*?\)", "")
.replace(r"(?i)\s*\[.*?\]", "")
.replace(r"(?i)\s*-\s*Official", "")
.replace(r"(?i)\s*-\s*Topic", "")
.trim()
.to_string();
// Try ZingMP3 API for Vietnamese lyrics
// Search for the song
let search_query = format!("{} {}", clean_artist, clean_track);
let search_url = format!("https://zingmp3.vn/api/v2/search?query={}&type=song&limit=5",
urlencoding::encode(&search_query));
match reqwest::get(&search_url).await {
Ok(response) => {
if response.status().is_success() {
if let Ok(text) = response.text().await {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(songs) = json.get("data").and_then(|d| d.as_array()) {
for song in songs {
if let Some(song_id) = song.get("id").and_then(|id| id.as_str()) {
// Try to get lyrics for this song
let lyrics_url = format!("https://zingmp3.vn/api/v2/song/get/lyrics?id={}", song_id);
if let Ok(lyrics_response) = reqwest::get(&lyrics_url).await {
if lyrics_response.status().is_success() {
if let Ok(lyrics_text) = lyrics_response.text().await {
if let Ok(lyrics_json) = serde_json::from_str::<serde_json::Value>(&lyrics_text) {
if let Some(data) = lyrics_json.get("data") {
if let Some(lyrics) = data.get("lyrics").and_then(|l| l.as_str()) {
return (StatusCode::OK, Json(serde_json::json!({
"plainLyrics": lyrics
})));
}
}
}
}
}
}
}
}
}
}
}
}
}
Err(_) => {
// ZingMP3 API might have CORS issues, try alternative
}
}
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Lyrics not found on ZingMP3"})))
}

View file

@ -32,12 +32,14 @@ async fn main() {
.allow_methods(Any) .allow_methods(Any)
.allow_headers(Any); .allow_headers(Any);
let app = Router::new() let app = Router::new()
.route("/api/search", get(api::search_handler)) .route("/api/search", get(api::search_handler))
.route("/api/stream/{id}", get(api::stream_handler)) .route("/api/stream/{id}", get(api::stream_handler))
.route("/api/artist/info", get(api::artist_info_handler)) .route("/api/artist/info", get(api::artist_info_handler))
.route("/api/browse", get(api::browse_handler)) .route("/api/browse", get(api::browse_handler))
.route("/api/recommendations", get(api::recommendations_handler)) .route("/api/recommendations", get(api::recommendations_handler))
.route("/api/lyrics", get(api::lyrics_handler))
.route("/api/lyrics/zingmp3", get(api::zingmp3_lyrics_handler))
.fallback_service(ServeDir::new("static")) .fallback_service(ServeDir::new("static"))
.layer(cors) .layer(cors)
.with_state(app_state); .with_state(app_state);

View file

@ -8,10 +8,12 @@
"name": "frontend-vite", "name": "frontend-vite",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"artplayer": "^5.4.0",
"framer-motion": "^11.2.11", "framer-motion": "^11.2.11",
"idb": "^8.0.0", "idb": "^8.0.0",
"lucide-react": "^0.395.0", "lucide-react": "^0.395.0",
"music-metadata-browser": "^2.5.10", "music-metadata-browser": "^2.5.10",
"plyr": "^3.8.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.24.1" "react-router-dom": "^6.24.1"
@ -3640,6 +3642,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/artplayer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/artplayer/-/artplayer-5.4.0.tgz",
"integrity": "sha512-2B+plbx8N2yNsjK4nJU3+EOG8TULm1LRZk/QPkWRAMEX2Ee/MSnZG/WJYz8kcoZxZuLKcQ3uXifqLuPxZOH29A==",
"license": "MIT",
"dependencies": {
"option-validator": "^2.0.6"
}
},
"node_modules/async": { "node_modules/async": {
"version": "3.2.6", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@ -4102,6 +4113,17 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true "dev": true
}, },
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-js-compat": { "node_modules/core-js-compat": {
"version": "3.48.0", "version": "3.48.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz",
@ -4158,6 +4180,12 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true "dev": true
}, },
"node_modules/custom-event-polyfill": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz",
"integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==",
"license": "MIT"
},
"node_modules/data-view-buffer": { "node_modules/data-view-buffer": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@ -6110,6 +6138,15 @@
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
}, },
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/leven": { "node_modules/leven": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@ -6151,6 +6188,12 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true "dev": true
}, },
"node_modules/loadjs": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz",
"integrity": "sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==",
"license": "MIT"
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -6476,6 +6519,15 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/option-validator": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/option-validator/-/option-validator-2.0.6.tgz",
"integrity": "sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==",
"license": "MIT",
"dependencies": {
"kind-of": "^6.0.3"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -6678,6 +6730,19 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/plyr": {
"version": "3.8.4",
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.4.tgz",
"integrity": "sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==",
"license": "MIT",
"dependencies": {
"core-js": "^3.45.1",
"custom-event-polyfill": "^1.0.7",
"loadjs": "^4.3.0",
"rangetouch": "^2.0.1",
"url-polyfill": "^1.1.13"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@ -6915,6 +6980,12 @@
"safe-buffer": "^5.1.0" "safe-buffer": "^5.1.0"
} }
}, },
"node_modules/rangetouch": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz",
"integrity": "sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==",
"license": "MIT"
},
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@ -8378,6 +8449,12 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/url-polyfill": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz",
"integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==",
"license": "MIT"
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View file

@ -10,10 +10,12 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"artplayer": "^5.4.0",
"framer-motion": "^11.2.11", "framer-motion": "^11.2.11",
"idb": "^8.0.0", "idb": "^8.0.0",
"lucide-react": "^0.395.0", "lucide-react": "^0.395.0",
"music-metadata-browser": "^2.5.10", "music-metadata-browser": "^2.5.10",
"plyr": "^3.8.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.24.1" "react-router-dom": "^6.24.1"

View file

@ -1,12 +1,9 @@
import { Home, Search, Library, Settings } from 'lucide-react'; import { Home, Search, Library } from 'lucide-react';
import { useLocation, Link } from 'react-router-dom'; import { useLocation, Link } from 'react-router-dom';
import { useState } from 'react';
import SettingsModal from './SettingsModal';
export default function BottomNav() { export default function BottomNav() {
const location = useLocation(); const location = useLocation();
const path = location.pathname; const path = location.pathname;
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const tabs = [ const tabs = [
{ name: 'Home', icon: Home, path: '/' }, { name: 'Home', icon: Home, path: '/' },
@ -31,19 +28,7 @@ export default function BottomNav() {
</Link> </Link>
); );
})} })}
<button
onClick={() => setIsSettingsOpen(true)}
className="flex flex-col items-center justify-center w-full h-full transition-colors text-neutral-500 hover:text-neutral-300"
>
<Settings className="w-6 h-6 mb-1" strokeWidth={2} />
<span className="text-[10px] uppercase font-medium tracking-wide">Settings</span>
</button>
</div> </div>
<SettingsModal
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
/>
</div> </div>
); );
} }

View file

@ -1,5 +1,5 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { libraryService } from '../services/library'; import { useLyrics } from '../hooks/useLyrics';
interface LyricsProps { interface LyricsProps {
trackTitle: string; trackTitle: string;
@ -7,56 +7,25 @@ interface LyricsProps {
currentTime: number; currentTime: number;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
videoId?: string;
} }
interface LyricLine { export default function Lyrics({ trackTitle, artistName, currentTime, isOpen, onClose, videoId }: LyricsProps) {
time: number;
text: string;
}
export default function Lyrics({ trackTitle, artistName, currentTime, isOpen, onClose }: LyricsProps) {
const [lyrics, setLyrics] = useState<string | null>(null);
const [syncedLines, setSyncedLines] = useState<LyricLine[]>([]);
const [loading, setLoading] = useState(false);
const activeLineRef = useRef<HTMLParagraphElement>(null); const activeLineRef = useRef<HTMLParagraphElement>(null);
useEffect(() => { // Use the optimized useLyrics hook
if (isOpen && trackTitle) { const {
setLoading(true); lyrics,
setLyrics(null); syncedLines,
setSyncedLines([]); loading,
activeIndex
libraryService.getLyrics(trackTitle, artistName) } = useLyrics(trackTitle, artistName, currentTime, isOpen, videoId);
.then(data => {
if (data) {
if (data.syncedLyrics) {
setSyncedLines(parseSyncedLyrics(data.syncedLyrics));
} else {
setLyrics(data.plainLyrics || "No lyrics available.");
}
} else {
setLyrics("Lyrics not found.");
}
setLoading(false);
})
.catch(() => {
setLyrics("Failed to load lyrics.");
setLoading(false);
});
}
}, [trackTitle, artistName, isOpen]);
// Find active line
const activeIndex = syncedLines.findIndex((line, i) => {
const nextLine = syncedLines[i + 1];
return currentTime >= line.time && (!nextLine || currentTime < nextLine.time);
});
useEffect(() => { useEffect(() => {
if (activeLineRef.current) { if (activeLineRef.current) {
activeLineRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); activeLineRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
}, [activeIndex]); // Only scroll when line changes! }, [activeIndex]);
return ( return (
<div className="fixed inset-0 bg-black/95 z-[80] flex flex-col animate-in slide-in-from-bottom"> <div className="fixed inset-0 bg-black/95 z-[80] flex flex-col animate-in slide-in-from-bottom">
@ -82,8 +51,9 @@ export default function Lyrics({ trackTitle, artistName, currentTime, isOpen, on
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 text-center no-scrollbar mask-gradient"> <div className="flex-1 overflow-y-auto px-6 py-4 text-center no-scrollbar mask-gradient">
{loading ? ( {loading ? (
<div className="h-full flex items-center justify-center"> <div className="h-full flex flex-col items-center justify-center gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-white"></div> <div className="animate-spin rounded-full h-8 w-8 border-t-2 border-white"></div>
<span className="text-neutral-400 text-sm">Searching for lyrics...</span>
</div> </div>
) : syncedLines.length > 0 ? ( ) : syncedLines.length > 0 ? (
<div className="space-y-6 py-[50vh]"> <div className="space-y-6 py-[50vh]">
@ -100,31 +70,50 @@ export default function Lyrics({ trackTitle, artistName, currentTime, isOpen, on
</p> </p>
))} ))}
</div> </div>
) : lyrics ? (
<div className="h-full overflow-y-auto py-8 mask-gradient">
<div className="whitespace-pre-wrap text-lg md:text-2xl leading-relaxed text-neutral-300 px-4 text-center">
{lyrics.split('\n').map((line, i) => (
<p key={i} className="mb-4 hover:text-white transition-colors">
{line || '\u00A0'}
</p>
))}
</div>
</div>
) : ( ) : (
<div className="h-full flex items-center justify-center whitespace-pre-wrap text-lg md:text-2xl leading-relaxed text-neutral-300"> <div className="h-full flex flex-col items-center justify-center gap-4 px-8">
{lyrics} <div className="w-16 h-16 rounded-full bg-neutral-800 flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-neutral-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
</svg>
</div>
<h3 className="text-xl font-bold text-white">Không tìm thấy lời bài hát</h3>
<p className="text-neutral-400 text-center text-sm leading-relaxed">
Lyrics for this song are not available.<br/>
<span className="text-neutral-500">
For Vietnamese songs, try searching on:{' '}
<a
href={`https://zingmp3.vn/tim-kiem?q=${encodeURIComponent(trackTitle || '')}`}
target="_blank"
rel="noopener noreferrer"
className="text-[#1DB954] hover:underline"
>
ZingMP3
</a>
{' '}or{' '}
<a
href={`https://www.nhaccuatui.com/tim-kiem?q=${encodeURIComponent(trackTitle || '')}`}
target="_blank"
rel="noopener noreferrer"
className="text-[#1DB954] hover:underline"
>
NhacCuaTui
</a>
</span>
</p>
</div> </div>
)} )}
</div> </div>
</div> </div>
); );
} }
function parseSyncedLyrics(lrc: string): LyricLine[] {
const lines = lrc.split('\n');
const result: LyricLine[] = [];
const regex = /\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)/;
for (const line of lines) {
const match = line.match(regex);
if (match) {
const min = parseInt(match[1]);
const sec = parseInt(match[2]);
const ms = parseInt(match[3].length === 2 ? match[3] + '0' : match[3]); // Normalize ms
const time = min * 60 + sec + ms / 1000;
const text = match[4].trim();
if (text) result.push({ time, text });
}
}
return result;
}

View file

@ -5,6 +5,7 @@ import { useNavigate, useLocation } from "react-router-dom";
import TechSpecs from './TechSpecs'; import TechSpecs from './TechSpecs';
import AddToPlaylistModal from "./AddToPlaylistModal"; import AddToPlaylistModal from "./AddToPlaylistModal";
import Lyrics from './Lyrics'; import Lyrics from './Lyrics';
import VideoPlayer from './VideoPlayer';
import QueueModal from './QueueModal'; import QueueModal from './QueueModal';
import Recommendations from './Recommendations'; import Recommendations from './Recommendations';
import { useDominantColor } from '../hooks/useDominantColor'; import { useDominantColor } from '../hooks/useDominantColor';
@ -29,7 +30,8 @@ export default function PlayerBar() {
currentTrack?.title || '', currentTrack?.title || '',
currentTrack?.artist || '', currentTrack?.artist || '',
progress, progress,
isLyricsOpen || hasInteractedWithLyrics // Only fetch if opened or previously interacted isLyricsOpen || hasInteractedWithLyrics, // Only fetch if opened or previously interacted
currentTrack?.id || undefined // Pass video ID for better lyrics search
); );
// Swipe Logic // Swipe Logic
@ -108,47 +110,26 @@ export default function PlayerBar() {
// Handle audio/video mode switching // Handle audio/video mode switching
const handleModeSwitch = (mode: 'audio' | 'video') => { const handleModeSwitch = (mode: 'audio' | 'video') => {
if (mode === 'video') { if (mode === 'video') {
// Pause audio ref but DON'T toggle isPlaying state to false
// The iframe useEffect will pick up isPlaying=true and start the video
audioRef.current?.pause(); audioRef.current?.pause();
setIsVideoReady(false); setIsVideoReady(false);
// Video will autoplay via URL parameter
// If currently playing, start video playback after iframe loads
if (isPlaying && iframeRef.current && iframeRef.current.contentWindow) {
setTimeout(() => {
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(JSON.stringify({
event: 'command',
func: 'playVideo'
}), '*');
}
}, 1000);
}
} else { } else {
// Switching back to audio // Switching back to audio - sync audio time with video progress
if (isPlaying) { if (audioRef.current) {
audioRef.current?.play().catch(() => { }); audioRef.current.currentTime = progress;
if (isPlaying) {
audioRef.current.play().catch(() => { });
}
} }
} }
setPlayerMode(mode); setPlayerMode(mode);
}; };
// Handle play/pause for video mode - send command to YouTube iframe only // Handle play/pause for video mode - controlled by Artplayer via isPlaying prop
const handleVideoPlayPause = () => { const handleVideoPlayPause = () => {
if (playerMode !== 'video' || !iframeRef.current || !iframeRef.current.contentWindow) return; if (playerMode !== 'video') return;
// Send play/pause command directly to YouTube // Toggle play state - Artplayer will respond via the isPlaying prop
const action = isPlaying ? 'pauseVideo' : 'playVideo';
try {
iframeRef.current.contentWindow.postMessage(JSON.stringify({
event: 'command',
func: action
}), '*');
} catch (e) {
// Ignore cross-origin errors
}
// Toggle local state for UI sync only (audio won't play since it's paused)
togglePlay(); togglePlay();
}; };
@ -510,64 +491,99 @@ export default function PlayerBar() {
onTouchStart={resetIdleTimer} onTouchStart={resetIdleTimer}
> >
{playerMode === 'video' ? ( {playerMode === 'video' ? (
/* CINEMATIC VIDEO MODE: Full Background Video */ /* CENTERED VIDEO MODE: Video Player centered like album art */
<div className="absolute inset-0 z-0 bg-black"> <div className="h-full flex flex-col items-center justify-center p-8 md:p-12 pb-48 md:pb-40 animate-in zoom-in-95 duration-500">
<div className="w-full h-full transform scale-[1.01]"> {/* Slight scale to hide any possible edges */} <div className="relative w-full max-w-[480px] md:max-w-[640px] mb-6 md:mb-8">
{!isVideoReady && ( {/* Video Container with 16:9 aspect ratio */}
<div className="absolute inset-0 flex items-center justify-center"> <div className="aspect-video w-full rounded-2xl overflow-hidden shadow-[0_30px_60px_rgba(0,0,0,0.5)] bg-black relative">
<div className="w-12 h-12 border-4 border-white/30 border-t-white rounded-full animate-spin" /> <VideoPlayer
</div> videoId={currentTrack.id}
)} isPlaying={isPlaying}
<iframe onTimeUpdate={(time) => {
key={`${currentTrack.id}-${playerMode}`} // Sync video time with player progress
ref={iframeRef} if (Math.abs(time - progress) > 2) {
width="100%" setProgress(time);
height="100%" }
src={`https://www.youtube.com/embed/${currentTrack.id}?autoplay=1&playsinline=1&modestbranding=1&rel=0&controls=1&enablejsapi=1&fs=1&vq=hd1080`} }}
title="YouTube video player" onPlay={() => {
frameBorder="0" setIsVideoReady(true);
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" // Only toggle play if we're not already playing to avoid infinite loop
allowFullScreen if (!isPlaying) {
className={`pointer-events-auto transition-opacity duration-500 ${isVideoReady ? 'opacity-100' : 'opacity-0'}`} togglePlay();
onLoad={() => setIsVideoReady(true)} }
></iframe> }}
onPause={() => {
// Only toggle pause if we're already playing to avoid infinite loop
if (isPlaying) {
togglePlay();
}
}}
onEnded={() => {
nextTrack();
}}
className="w-full h-full"
/>
</div>
{/* Subtle gradient overlay for depth */}
<div className="absolute inset-0 rounded-2xl bg-gradient-to-t from-black/30 via-transparent to-transparent pointer-events-none" />
</div>
{/* Song Info Below Video */}
<div className="text-center max-w-full px-4">
<h2 className="font-black text-white text-2xl md:text-3xl mb-1 md:mb-2 drop-shadow-lg tracking-tight line-clamp-2">{currentTrack.title}</h2>
<p
onClick={() => { setPlayerMode('audio'); setIsFullScreenOpen(false); navigate(`/artist/${encodeURIComponent(currentTrack.artist)}`); }}
className="text-white/80 font-medium text-base md:text-lg cursor-pointer hover:text-white hover:underline transition drop-shadow-md line-clamp-1"
>
{currentTrack.artist}
</p>
</div> </div>
{/* Overlay Gradient for cinematic feel */}
<div className={`absolute inset-0 bg-gradient-to-t from-black via-transparent to-black/40 pointer-events-none transition-opacity duration-1000 ${isIdle ? 'opacity-20' : 'opacity-60'}`} />
</div> </div>
) : ( ) : (
/* SONG MODE: Centered Case */ /* SONG MODE: Centered Case */
<div className="h-full flex items-center justify-center p-8 md:p-12 animate-in zoom-in-95 duration-500"> <div className="h-full flex flex-col items-center justify-center p-8 md:p-12 pb-48 md:pb-40 animate-in zoom-in-95 duration-500">
<img <div
src={currentTrack.cover_url} className="relative w-full max-w-[280px] md:max-w-[360px] mb-6 md:mb-8 cursor-pointer"
alt={currentTrack.title} onClick={() => setIsInfoOpen(true)}
className="w-full aspect-square object-cover rounded-[2rem] shadow-[0_30px_60px_rgba(0,0,0,0.5)] max-h-[50vh] md:max-h-[60vh] transition-transform duration-700 group-hover:scale-[1.02]" >
/> <img
src={currentTrack.cover_url}
alt={currentTrack.title}
className="w-full aspect-square object-cover rounded-2xl shadow-[0_30px_60px_rgba(0,0,0,0.5)] transition-transform duration-700 hover:scale-[1.03]"
/>
{/* Subtle gradient overlay for depth */}
<div className="absolute inset-0 rounded-2xl bg-gradient-to-t from-black/30 via-transparent to-transparent pointer-events-none" />
</div>
{/* Song Info Below Cover */}
<div className="text-center max-w-full px-4">
<h2 className="font-black text-white text-2xl md:text-3xl mb-1 md:mb-2 drop-shadow-lg tracking-tight line-clamp-2">{currentTrack.title}</h2>
<p
onClick={() => { setPlayerMode('audio'); setIsFullScreenOpen(false); navigate(`/artist/${encodeURIComponent(currentTrack.artist)}`); }}
className="text-white/80 font-medium text-base md:text-lg cursor-pointer hover:text-white hover:underline transition drop-shadow-md line-clamp-1"
>
{currentTrack.artist}
</p>
</div>
</div> </div>
)} )}
{/* Controls Overlay (Bottom) */} {/* Controls Overlay (Bottom) */}
<div className={`absolute bottom-0 left-0 right-0 z-20 px-8 pb-12 transition-all duration-700 ${playerMode === 'video' ? 'bg-gradient-to-t from-black via-black/40 to-transparent' : ''} ${isIdle && playerMode === 'video' ? 'opacity-0 translate-y-4 pointer-events-none' : 'opacity-100 translate-y-0'}`}> <div className={`absolute bottom-0 left-0 right-0 z-20 px-8 pb-12 transition-all duration-700 ${playerMode === 'video' ? 'bg-gradient-to-t from-black via-black/40 to-transparent' : ''} ${isIdle && playerMode === 'video' ? 'opacity-0 translate-y-4 pointer-events-none' : 'opacity-100 translate-y-0'}`}>
<div className="max-w-screen-xl mx-auto flex flex-col md:flex-row md:items-end gap-8"> <div className="max-w-screen-xl mx-auto flex flex-col md:flex-row md:items-end gap-8">
{/* Metadata */} {/* Secondary Actions Only (Metadata moved below cover in song mode) */}
<div className="flex-1"> <div className="flex-1 flex justify-center">
<h2 className={`font-black text-white mb-2 drop-shadow-lg tracking-tight transition-all duration-500 ${playerMode === 'video' ? 'text-xl md:text-3xl' : 'text-3xl md:text-5xl'}`}>{currentTrack.title}</h2> <div className="flex items-center gap-6 text-white">
<p <button onClick={() => toggleLike(currentTrack)} className={`p-3 rounded-full hover:bg-white/10 transition ${likedTracks.has(currentTrack.id) ? 'text-green-500' : 'text-white/60'}`}>
onClick={() => { setPlayerMode('audio'); setIsFullScreenOpen(false); navigate(`/artist/${encodeURIComponent(currentTrack.artist)}`); }} <Heart size={32} fill={likedTracks.has(currentTrack.id) ? "currentColor" : "none"} />
className={`text-white/70 font-medium cursor-pointer hover:text-white hover:underline transition drop-shadow-md ${playerMode === 'video' ? 'text-base md:text-xl' : 'text-lg md:text-2xl'}`} </button>
> <button onClick={() => toggleLyrics()} className={`p-3 rounded-full hover:bg-white/10 transition ${isLyricsOpen ? 'text-green-500' : 'text-white/60 hover:text-white'}`}>
{currentTrack.artist} <Mic2 size={28} />
</p> </button>
</div> <button onClick={() => setIsInfoOpen(true)} className="p-3 rounded-full hover:bg-white/10 transition text-white/60 hover:text-white">
<Info size={28} />
{/* Secondary Actions */} </button>
<div className="flex items-center gap-4 text-white"> </div>
<button onClick={() => toggleLike(currentTrack)} className={`p-3 rounded-full hover:bg-white/10 transition ${likedTracks.has(currentTrack.id) ? 'text-green-500' : 'text-white/60'}`}>
<Heart size={32} fill={likedTracks.has(currentTrack.id) ? "currentColor" : "none"} />
</button>
<button onClick={() => setIsInfoOpen(true)} className="p-3 rounded-full hover:bg-white/10 transition text-white/60 hover:text-white">
<Info size={28} />
</button>
</div> </div>
</div> </div>
@ -679,6 +695,7 @@ export default function PlayerBar() {
currentTime={progress} currentTime={progress}
isOpen={isLyricsOpen} isOpen={isLyricsOpen}
onClose={closeLyrics} onClose={closeLyrics}
videoId={currentTrack.id}
/> />
)} )}
</> </>

View file

@ -95,19 +95,19 @@ export default function Recommendations({
<h2 className="text-2xl font-bold hover:underline cursor-pointer">{title}</h2> <h2 className="text-2xl font-bold hover:underline cursor-pointer">{title}</h2>
</div> </div>
{isLoading && ( {isLoading && (
<div className="grid grid-cols-2 fold:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-4"> <div className="grid grid-cols-2 fold:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-2">
{[1, 2, 3, 4, 5].map(i => ( {[1, 2, 3, 4, 5].map(i => (
<div key={`skel-${i}`} className="bg-[#181818] p-3 md:p-4 rounded-xl space-y-3 md:space-y-4"> <div key={`skel-${i}`} className="bg-[#181818] p-3 md:p-4 rounded-xl space-y-3 md:space-y-4">
<div className="w-full aspect-square bg-neutral-800 rounded-2xl animate-pulse" /> <div className="w-full aspect-square bg-neutral-800 rounded-2xl animate-pulse" />
<div className="h-4 bg-neutral-800 rounded w-3/4" /> <div className="h-4 bg-neutral-800 rounded w-3/4" />
<div className="h-3 bg-neutral-800 rounded w-1/2" /> <div className="h-3 bg-neutral-800 rounded w-1/2" />
</div> </div>
))} ))}
</div> </div>
)} )}
<div className="grid grid-cols-2 fold:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-4"> <div className="grid grid-cols-2 fold:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-2">
{/* Tracks */} {/* Tracks */}
{showTracks && data.tracks.slice(0, 8).map((track) => ( {showTracks && data.tracks.slice(0, 8).map((track) => (
<div <div

View file

@ -76,7 +76,7 @@ localStorage.removeItem('ytm_browse_cache_v8');
{/* Header */} {/* Header */}
<div className={`flex items-center justify-between p-5 border-b ${isApple ? 'border-white/10' : 'border-[#282828]'}`}> <div className={`flex items-center justify-between p-5 border-b ${isApple ? 'border-white/10' : 'border-[#282828]'}`}>
<div className="flex items-center gap-3"> <div className="flex items-center">
<div className={`p-2 rounded-xl ${isApple ? 'bg-[#fa2d48]/20 text-[#fa2d48]' : 'bg-green-500/20 text-green-500'}`}> <div className={`p-2 rounded-xl ${isApple ? 'bg-[#fa2d48]/20 text-[#fa2d48]' : 'bg-green-500/20 text-green-500'}`}>
<Activity className="w-5 h-5" /> <Activity className="w-5 h-5" />
</div> </div>

View file

@ -0,0 +1,161 @@
import { useEffect, useRef, useState } from 'react';
import Plyr from 'plyr';
import 'plyr/dist/plyr.css';
interface VideoPlayerProps {
videoId: string;
isPlaying: boolean;
onTimeUpdate?: (time: number) => void;
onPlay?: () => void;
onPause?: () => void;
onEnded?: () => void;
className?: string;
}
export default function VideoPlayer({
videoId,
isPlaying,
onTimeUpdate,
onPlay,
onPause,
onEnded,
className = ''
}: VideoPlayerProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<Plyr | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [isReady, setIsReady] = useState(false);
const isParentControlledRef = useRef<boolean>(false);
// Initialize Plyr
useEffect(() => {
if (!containerRef.current || !videoId) return;
// Clean up previous instance
if (playerRef.current) {
playerRef.current.destroy();
playerRef.current = null;
setIsReady(false);
}
// Clear container
containerRef.current.innerHTML = '';
// Create wrapper div for Plyr
const wrapper = document.createElement('div');
wrapper.className = 'plyr__video-embed';
containerRef.current.appendChild(wrapper);
// Create iframe
const iframe = document.createElement('iframe');
const origin = encodeURIComponent(window.location.origin);
iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=0&modestbranding=1&rel=0&showinfo=0&origin=${origin}`;
iframe.allow = 'autoplay; fullscreen';
iframe.allowFullscreen = true;
iframe.setAttribute('allowtransparency', 'true');
wrapper.appendChild(iframe);
iframeRef.current = iframe;
// Initialize Plyr
const player = new Plyr(wrapper, {
controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'fullscreen'],
youtube: {
noCookie: false,
rel: 0,
showinfo: 0,
iv_load_policy: 3,
modestbranding: 1,
},
ratio: '16:9',
fullscreen: {
enabled: true,
fallback: true,
iosNative: true,
},
clickToPlay: true,
hideControls: true,
resetOnEnd: false,
});
playerRef.current = player;
// Event handlers
player.on('ready', () => {
setIsReady(true);
if (isPlaying) {
isParentControlledRef.current = true;
player.play();
// Reset flag after a short delay to allow the play event to be processed
setTimeout(() => {
isParentControlledRef.current = false;
}, 100);
}
});
player.on('play', () => {
// Only call onPlay if this event was triggered by user interaction (not by parent)
if (!isParentControlledRef.current) {
onPlay?.();
}
});
player.on('pause', () => {
// Only call onPause if this event was triggered by user interaction (not by parent)
if (!isParentControlledRef.current) {
onPause?.();
}
});
player.on('timeupdate', () => {
if (onTimeUpdate && player.currentTime) {
onTimeUpdate(player.currentTime);
}
});
player.on('ended', () => {
onEnded?.();
});
// Clean up on unmount or videoId change
return () => {
if (playerRef.current) {
playerRef.current.destroy();
playerRef.current = null;
}
// Clear container
if (containerRef.current) {
containerRef.current.innerHTML = '';
}
setIsReady(false);
};
}, [videoId]);
// Handle play/pause from parent
useEffect(() => {
if (!playerRef.current || !isReady) return;
isParentControlledRef.current = true;
if (isPlaying) {
playerRef.current.play();
} else {
playerRef.current.pause();
}
// Reset flag after a short delay to allow the play/pause event to be processed
setTimeout(() => {
isParentControlledRef.current = false;
}, 100);
}, [isPlaying, isReady]);
return (
<div className={`w-full h-full ${className}`}>
<div ref={containerRef} className="w-full h-full plyr__video-embed" />
{!isReady && (
<div className="absolute inset-0 flex items-center justify-center bg-black">
<div className="w-12 h-12 border-4 border-white/30 border-t-[#1DB954] rounded-full animate-spin" />
</div>
)}
</div>
);
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
import { libraryService } from '../services/library'; import { libraryService } from '../services/library';
export interface LyricLine { export interface LyricLine {
@ -6,33 +6,104 @@ export interface LyricLine {
text: string; text: string;
} }
export function useLyrics(trackTitle: string, artistName: string, currentTime: number, enabled: boolean = true) { // Cache for lyrics to avoid repeated API calls
const lyricsCache = new Map<string, { plainLyrics?: string; syncedLyrics?: string }>();
export function useLyrics(trackTitle: string, artistName: string, currentTime: number, enabled: boolean = true, videoId?: string) {
const [lyrics, setLyrics] = useState<string | null>(null); const [lyrics, setLyrics] = useState<string | null>(null);
const [syncedLines, setSyncedLines] = useState<LyricLine[]>([]); const [syncedLines, setSyncedLines] = useState<LyricLine[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const lastFetchTime = useRef<number>(0);
const currentTrackRef = useRef<string>('');
useEffect(() => { useEffect(() => {
if (trackTitle && artistName && enabled) { // Only fetch if we have track info and it's enabled
setLoading(true); if (!trackTitle || !artistName || !enabled) {
setLyrics(null); return;
setSyncedLines([]); }
libraryService.getLyrics(trackTitle, artistName) const trackKey = `${artistName}:${trackTitle}:${videoId || ''}`.toLowerCase();
// Check cache first
const cached = lyricsCache.get(trackKey);
if (cached) {
if (cached.syncedLyrics) {
setSyncedLines(parseSyncedLyrics(cached.syncedLyrics));
setLyrics(null);
} else if (cached.plainLyrics) {
setLyrics(cached.plainLyrics);
setSyncedLines([]);
} else {
setLyrics(null);
setSyncedLines([]);
}
setLoading(false);
return;
}
// Avoid rapid successive calls
const now = Date.now();
if (now - lastFetchTime.current < 500 && currentTrackRef.current === trackKey) {
return;
}
lastFetchTime.current = now;
currentTrackRef.current = trackKey;
setLoading(true);
setLyrics(null);
setSyncedLines([]);
// Add timeout to prevent hanging requests
const timeoutId = setTimeout(() => {
setLoading(false);
}, 5000);
libraryService.getLyrics(trackTitle, artistName, videoId)
.then(data => { .then(data => {
clearTimeout(timeoutId);
if (data) { if (data) {
// Cache the result
lyricsCache.set(trackKey, data);
if (data.syncedLyrics) { if (data.syncedLyrics) {
setSyncedLines(parseSyncedLyrics(data.syncedLyrics)); setSyncedLines(parseSyncedLyrics(data.syncedLyrics));
setLyrics(null);
} else if (data.plainLyrics) {
setLyrics(data.plainLyrics);
setSyncedLines([]);
} else { } else {
setLyrics(data.plainLyrics || "No lyrics available."); setLyrics(null);
setSyncedLines([]);
} }
} else { } else {
// Cache empty result to avoid repeated failed requests
lyricsCache.set(trackKey, {});
setLyrics(null); setLyrics(null);
setSyncedLines([]);
} }
setLoading(false); setLoading(false);
}) })
.catch(() => { .catch(() => {
clearTimeout(timeoutId);
setLoading(false); setLoading(false);
}); });
// Cleanup timeout on unmount
return () => clearTimeout(timeoutId);
}, [trackTitle, artistName, enabled]);
// Clear cache when track changes to prevent stale data
useEffect(() => {
const trackKey = `${artistName}:${trackTitle}`.toLowerCase();
if (!lyricsCache.has(trackKey)) {
// Clear old cache entries if cache gets too large
if (lyricsCache.size > 50) {
const firstKey = lyricsCache.keys().next().value;
if (firstKey) {
lyricsCache.delete(firstKey);
}
}
} }
}, [trackTitle, artistName]); }, [trackTitle, artistName]);

View file

@ -95,10 +95,10 @@ export default function Album() {
/> />
)} )}
<div className="relative z-10 flex flex-col md:flex-row gap-4 md:gap-8 p-4 md:p-12 items-center md:items-end pt-16 md:pt-16"> <div className="relative z-10 flex flex-col md:flex-row p-4 md:p-12 items-center md:items-end pt-16 md:pt-16 gap-6 md:gap-8">
{/* Cover */} {/* Cover */}
<div <div
className="w-48 h-48 md:w-64 md:h-64 shadow-[0_20px_50px_rgba(0,0,0,0.5)] rounded-2xl overflow-hidden shrink-0 mt-8 md:mt-0 cursor-pointer group/cover relative" className="w-48 h-48 md:w-64 md:h-64 shadow-[0_20px_50px_rgba(0,0,0,0.5)] rounded-2xl overflow-hidden shrink-0 cursor-pointer group/cover relative"
onClick={() => { onClick={() => {
if (tracks.length > 0) { if (tracks.length > 0) {
playTrack(tracks[0], tracks); playTrack(tracks[0], tracks);
@ -113,16 +113,16 @@ export default function Album() {
</div> </div>
{/* Info */} {/* Info */}
<div className="flex flex-col items-center md:items-start text-center md:text-left gap-2 md:gap-4 flex-1"> <div className="flex flex-col items-center md:items-start text-center md:text-left gap-2 md:gap-3 flex-1">
<span className="text-xs md:text-sm font-bold tracking-widest uppercase text-white/70">Album</span> <span className="text-xs md:text-sm font-bold tracking-widest uppercase text-white/70">Album</span>
<h1 className="text-2xl md:text-6xl font-black text-white leading-tight line-clamp-3 text-ellipsis overflow-hidden">{albumInfo.title}</h1> <h1 className="text-2xl md:text-6xl font-black text-white leading-tight line-clamp-3 text-ellipsis overflow-hidden">{albumInfo.title}</h1>
<div className="flex flex-wrap justify-center md:justify-start items-center gap-2 text-white/80 font-medium text-sm md:text-base"> <div className="flex items-center gap-2 text-white/80 font-medium text-sm md:text-base mt-1 md:mt-2">
<img src={albumInfo.cover} className="w-6 h-6 rounded-full" /> <img src={albumInfo.cover} className="w-6 h-6 rounded-full" />
<span className="hover:underline cursor-pointer">{albumInfo.artist}</span> <span className="hover:underline cursor-pointer">{albumInfo.artist}</span>
<span></span> <span className="text-white/40"></span>
<span>{albumInfo.year}</span> <span className="text-white/60">{albumInfo.year}</span>
<span></span> <span className="text-white/40"></span>
<span>{tracks.length} songs, {formattedDuration}</span> <span className="text-white/60">{tracks.length} songs, {formattedDuration}</span>
</div> </div>
</div> </div>
</div> </div>
@ -197,7 +197,7 @@ export default function Album() {
<span className="text-xs font-bold text-[#b3b3b3] uppercase tracking-wider hover:text-white cursor-pointer">Show discography</span> <span className="text-xs font-bold text-[#b3b3b3] uppercase tracking-wider hover:text-white cursor-pointer">Show discography</span>
</Link> </Link>
</div> </div>
<div className="grid grid-cols-2 fold:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-4"> <div className="grid grid-cols-2 fold:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-2">
{moreByArtist.map((track) => ( {moreByArtist.map((track) => (
<div <div
className="bg-[#181818] p-3 md:p-4 rounded-xl hover:bg-[#282828] transition duration-300 group cursor-pointer relative flex flex-col" className="bg-[#181818] p-3 md:p-4 rounded-xl hover:bg-[#282828] transition duration-300 group cursor-pointer relative flex flex-col"

View file

@ -183,14 +183,14 @@ export default function Artist() {
</div> </div>
</section> </section>
{/* Albums (Mock UI for now as strict album search is hard with yt-dlp only) */} {/* Albums (Mock UI for now as strict album search is hard with yt-dlp only) */}
<section> <section>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">Albums</h2> <h2 className="text-2xl font-bold">Albums</h2>
<button className="text-sm font-bold text-neutral-400 hover:text-white uppercase tracking-wider">See All</button> <button className="text-sm font-bold text-neutral-400 hover:text-white uppercase tracking-wider">See All</button>
</div> </div>
{/* Placeholder Logic: Show top song covers as "Albums" for visual parity if no real albums */} {/* Placeholder Logic: Show top song covers as "Albums" for visual parity if no real albums */}
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-6"> <div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-2">
{artist.topSongs.slice(0, 5).map((track) => ( {artist.topSongs.slice(0, 5).map((track) => (
<div <div
key={track.id} key={track.id}
@ -217,10 +217,10 @@ export default function Artist() {
</div> </div>
</section> </section>
{/* Singles */} {/* Singles */}
<section> <section>
<h2 className="text-2xl font-bold mb-6">Singles</h2> <h2 className="text-2xl font-bold mb-6">Singles</h2>
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-6"> <div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-2">
{artist.topSongs.slice(0, 4).reverse().map((track) => ( {artist.topSongs.slice(0, 4).reverse().map((track) => (
<div <div
key={track.id} key={track.id}

View file

@ -171,7 +171,7 @@ export default function Home() {
{loading ? ( {loading ? (
<div className="mb-8"> <div className="mb-8">
<Skeleton className="h-8 w-48 mb-4" /> <Skeleton className="h-8 w-48 mb-4" />
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-2">
{[1, 2, 3, 4, 5].map(j => ( {[1, 2, 3, 4, 5].map(j => (
<div key={j} className="space-y-3"> <div key={j} className="space-y-3">
<Skeleton className="w-full aspect-square rounded-md" /> <Skeleton className="w-full aspect-square rounded-md" />
@ -192,7 +192,7 @@ export default function Home() {
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold capitalize hover:underline cursor-pointer">Top Albums</h2> <h2 className="text-2xl font-bold capitalize hover:underline cursor-pointer">Top Albums</h2>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1 md:gap-2"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-2">
{uniqueAlbums.slice(0, 15).map((album) => ( {uniqueAlbums.slice(0, 15).map((album) => (
<Link to={`/album/${album.id}`} key={album.id}> <Link to={`/album/${album.id}`} key={album.id}>
<div className="bg-transparent md:bg-spotify-card p-0 md:p-3 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col"> <div className="bg-transparent md:bg-spotify-card p-0 md:p-3 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col">
@ -232,15 +232,15 @@ export default function Home() {
{[1, 2].map(i => ( {[1, 2].map(i => (
<div key={i}> <div key={i}>
<Skeleton className="h-8 w-48 mb-4" /> <Skeleton className="h-8 w-48 mb-4" />
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-2">
{[1, 2, 3, 4, 5].map(j => ( {[1, 2, 3, 4, 5].map(j => (
<div key={j} className="space-y-3"> <div key={j} className="space-y-3">
<Skeleton className="w-full aspect-square rounded-md" /> <Skeleton className="w-full aspect-square rounded-md" />
<Skeleton className="h-4 w-3/4" /> <Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" /> <Skeleton className="h-3 w-1/2" />
</div> </div>
))} ))}
</div> </div>
</div> </div>
))} ))}
</div> </div>
@ -263,8 +263,8 @@ export default function Home() {
</Link> </Link>
</div> </div>
{/* USER REQUEST: Bigger Grid, Smaller Text, Smaller Gap */} {/* USER REQUEST: Bigger Grid, Smaller Text, Smaller Gap */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1 md:gap-2"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-2">
{sortPlaylists(uniquePlaylists).slice(0, 15).map((playlist) => ( {sortPlaylists(uniquePlaylists).slice(0, 15).map((playlist) => (
<Link to={`/playlist/${playlist.id}`} key={playlist.id}> <Link to={`/playlist/${playlist.id}`} key={playlist.id}>
<div className="bg-transparent md:bg-spotify-card p-0 md:p-4 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col"> <div className="bg-transparent md:bg-spotify-card p-0 md:p-4 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col">
@ -313,11 +313,11 @@ function RecentlyListenedSection({ playHistory, playTrack }: { playHistory: Trac
if (playHistory.length === 0) return null; if (playHistory.length === 0) return null;
return ( return (
<div className="mb-8 animate-in"> <div className="mb-8 animate-in">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center mb-4">
<Clock className="w-5 h-5 text-[#1DB954]" /> <Clock className="w-5 h-5 text-[#1DB954]" />
<h2 className="text-2xl font-bold">Recently Listened</h2> <h2 className="text-2xl font-bold">Recently Listened</h2>
</div> </div>
<div className="flex gap-4 overflow-x-auto pb-4 no-scrollbar"> <div className="flex gap-4 overflow-x-auto pb-4 no-scrollbar">
{playHistory.slice(0, 10).map((track, i) => ( {playHistory.slice(0, 10).map((track, i) => (
@ -378,50 +378,50 @@ function MadeForYouSection() {
if (!loading && recommendations.length === 0) return null; if (!loading && recommendations.length === 0) return null;
return ( return (
<div className="mb-8 animate-in"> <div className="mb-8 animate-in">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center mb-2">
<Music2 className="w-5 h-5 text-[#1DB954]" /> <Music2 className="w-5 h-5 text-[#1DB954]" />
<h2 className="text-2xl font-bold">Made For You</h2> <h2 className="text-2xl font-bold">Made For You</h2>
</div> </div>
<p className="text-sm text-[#a7a7a7] mb-4"> <p className="text-sm text-[#a7a7a7] mb-4">
{seedTrack ? <>Because you listened to <span className="text-white font-medium">{seedTrack.artist}</span></> : "Recommended for you"} {seedTrack ? <>Because you listened to <span className="text-white font-medium">{seedTrack.artist}</span></> : "Recommended for you"}
</p> </p>
{loading ? ( {loading ? (
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6"> <div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-2">
{[1, 2, 3, 4, 5].map(i => ( {[1, 2, 3, 4, 5].map(i => (
<div key={i} className="space-y-3"> <div key={i} className="space-y-3">
<Skeleton className="w-full aspect-square rounded-md" /> <Skeleton className="w-full aspect-square rounded-md" />
<Skeleton className="h-4 w-3/4" /> <Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" /> <Skeleton className="h-3 w-1/2" />
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1 md:gap-2"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-2">
{recommendations.slice(0, 10).map((track, i) => ( {recommendations.slice(0, 10).map((track, i) => (
<div key={i} onClick={() => { <div key={i} onClick={() => {
playTrack(track, recommendations); playTrack(track, recommendations);
}} className="bg-transparent md:bg-spotify-card p-0 md:p-4 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col"> }} className="bg-transparent md:bg-spotify-card p-0 md:p-4 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-2 md:mb-4"> <div className="relative mb-2 md:mb-4">
<CoverImage <CoverImage
src={track.cover_url} src={track.cover_url}
alt={track.title} alt={track.title}
className="w-full aspect-square rounded-2xl shadow-lg" className="w-full aspect-square rounded-2xl shadow-lg"
fallbackText={track.title?.substring(0, 2).toUpperCase()} fallbackText={track.title?.substring(0, 2).toUpperCase()}
/> />
<div className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl"> <div className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-8 h-8 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105"> <div className="w-8 h-8 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" /> <Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</div> </div>
</div> </div>
</div> </div>
<h3 className="font-bold mb-0.5 truncate text-[11px] md:text-base">{track.title}</h3> <h3 className="font-bold mb-0.5 truncate text-[11px] md:text-base">{track.title}</h3>
<p className="text-[10px] md:text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p> <p className="text-[10px] md:text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p>
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
); );
} }

View file

@ -199,12 +199,12 @@ export default function Playlist() {
)} )}
{/* Hero Header */} {/* Hero Header */}
<div className="relative z-10 flex flex-col md:flex-row gap-4 md:gap-8 p-4 md:p-12 items-center md:items-end pt-16 md:pt-16"> <div className="relative z-10 flex flex-col md:flex-row p-4 md:p-12 items-center md:items-end pt-16 md:pt-16 gap-6 md:gap-8">
<Link to="/library" className="absolute top-4 left-4 md:hidden"> <Link to="/library" className="absolute top-4 left-4 md:hidden">
<ArrowLeft className="w-6 h-6" /> <ArrowLeft className="w-6 h-6" />
</Link> </Link>
<div <div
className="w-48 h-48 md:w-64 md:h-64 shadow-[0_20px_50px_rgba(0,0,0,0.5)] rounded-2xl overflow-hidden shrink-0 mt-8 md:mt-0 cursor-pointer group/cover relative" className="w-48 h-48 md:w-64 md:h-64 shadow-[0_20px_50px_rgba(0,0,0,0.5)] rounded-2xl overflow-hidden shrink-0 cursor-pointer group/cover relative"
onClick={() => { onClick={() => {
if (playlist && playlist.tracks.length > 0) { if (playlist && playlist.tracks.length > 0) {
playTrack(playlist.tracks[0], playlist.tracks); playTrack(playlist.tracks[0], playlist.tracks);
@ -222,21 +222,21 @@ export default function Playlist() {
<Play fill="white" size={48} className="text-white drop-shadow-2xl" /> <Play fill="white" size={48} className="text-white drop-shadow-2xl" />
</div> </div>
</div> </div>
<div className="flex flex-col items-center md:items-start text-center md:text-left gap-2 md:gap-4 flex-1"> <div className="flex flex-col items-center md:items-start text-center md:text-left gap-2 md:gap-3 flex-1">
<span className="text-xs md:text-sm font-bold tracking-widest uppercase text-white/70">Playlist</span> <span className="text-xs md:text-sm font-bold tracking-widest uppercase text-white/70">Playlist</span>
<h1 className="text-2xl md:text-6xl font-black text-white leading-tight line-clamp-2">{playlist.title}</h1> <h1 className="text-2xl md:text-6xl font-black text-white leading-tight line-clamp-2">{playlist.title}</h1>
<div className="flex flex-wrap justify-center md:justify-start items-center gap-2 text-white/80 font-medium text-sm md:text-base"> <div className="flex flex-wrap justify-center md:justify-start items-center gap-2 text-white/80 font-medium text-sm md:text-base mt-1 md:mt-2">
{'description' in playlist && playlist.description && ( {'description' in playlist && playlist.description && (
<span className="text-neutral-300">{playlist.description}</span> <span className="text-neutral-300">{playlist.description}</span>
)} )}
<span></span> <span className="text-white/40"></span>
<span className="text-white"> <span className="text-white/80">
{loadingTracks ? 'Updating...' : `${playlist.tracks.length} songs`} {loadingTracks ? 'Updating...' : `${playlist.tracks.length} songs`}
</span> </span>
{totalDuration > 0 && ( {totalDuration > 0 && (
<> <>
<span></span> <span className="text-white/40"></span>
<span>{Math.floor(totalDuration / 60)} min</span> <span className="text-white/60">{Math.floor(totalDuration / 60)} min</span>
</> </>
)} )}
</div> </div>
@ -374,7 +374,7 @@ export default function Playlist() {
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold hover:underline cursor-pointer">More like this</h2> <h2 className="text-2xl font-bold hover:underline cursor-pointer">More like this</h2>
</div> </div>
<div className="grid grid-cols-2 fold:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-4"> <div className="grid grid-cols-2 fold:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-2">
{moreLikeThis.map((track) => ( {moreLikeThis.map((track) => (
<div <div
className="bg-[#181818] p-3 md:p-4 rounded-xl hover:bg-[#282828] transition duration-300 group cursor-pointer relative flex flex-col" className="bg-[#181818] p-3 md:p-4 rounded-xl hover:bg-[#282828] transition duration-300 group cursor-pointer relative flex flex-col"

View file

@ -150,7 +150,7 @@ export default function Search() {
{loading && results.length === 0 ? ( {loading && results.length === 0 ? (
<div className="space-y-8 animate-pulse"> <div className="space-y-8 animate-pulse">
<Skeleton className="h-8 w-48 mb-4" /> <Skeleton className="h-8 w-48 mb-4" />
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-2">
{[1, 2, 3, 4].map(i => <Skeleton key={i} className="aspect-square rounded-xl" />)} {[1, 2, 3, 4].map(i => <Skeleton key={i} className="aspect-square rounded-xl" />)}
</div> </div>
</div> </div>

View file

@ -38,18 +38,18 @@ export default function Section() {
</div> </div>
{/* Grid */} {/* Grid */}
{loading ? ( {loading ? (
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6"> <div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-2">
{[1, 2, 3, 4, 5, 6, 7, 8].map(i => ( {[1, 2, 3, 4, 5, 6, 7, 8].map(i => (
<div key={i} className="space-y-3"> <div key={i} className="space-y-3">
<Skeleton className="w-full aspect-square rounded-md" /> <Skeleton className="w-full aspect-square rounded-md" />
<Skeleton className="h-4 w-3/4" /> <Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" /> <Skeleton className="h-3 w-1/2" />
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6"> <div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-2">
{playlists.map((playlist) => ( {playlists.map((playlist) => (
<Link to={`/playlist/${playlist.id}`} key={playlist.id}> <Link to={`/playlist/${playlist.id}`} key={playlist.id}>
<div className="bg-[#181818] p-2 md:p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer h-full flex flex-col"> <div className="bg-[#181818] p-2 md:p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer h-full flex flex-col">

View file

@ -236,10 +236,10 @@ export const libraryService = {
}, },
async getArtistInfo(artistName: string): Promise<{ bio?: string; photo?: string }> { async getArtistInfo(artistName: string): Promise<{ bio?: string; photo?: string }> {
// Method 1: Try backend API for real YouTube channel photo // Method 1: Try backend API for real YouTube channel photo (with short timeout)
try { try {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout const timeoutId = setTimeout(() => controller.abort(), 2000); // Reduced to 2 second timeout
const res = await fetch(`/api/artist/info?q=${encodeURIComponent(artistName)}`, { const res = await fetch(`/api/artist/info?q=${encodeURIComponent(artistName)}`, {
signal: controller.signal signal: controller.signal
@ -248,29 +248,227 @@ export const libraryService = {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
console.log(`[ArtistInfo] ${artistName}:`, data); if (data.image && data.image !== '') {
if (data.image) { console.log(`[ArtistInfo] Found real image for ${artistName}`);
return { photo: data.image }; return { photo: data.image };
} }
} }
} catch (e) { } catch (e) {
console.error(`[ArtistInfo] Error for ${artistName}:`, e); // Silently fall through to fallback - this is expected behavior
// Fall through to next method // console.log(`[ArtistInfo] Using fallback for ${artistName}`);
} }
// Method 2: Use UI-Avatars API (instant, always works) // Method 2: Use UI-Avatars API (instant, always works)
// Using smaller size (128) for faster loading // This is the primary fallback since the backend is often slow
const encodedName = encodeURIComponent(artistName); const encodedName = encodeURIComponent(artistName);
const avatarUrl = `https://ui-avatars.com/api/?name=${encodedName}&background=random&color=fff&size=128&rounded=true&bold=true&font-size=0.33`; const avatarUrl = `https://ui-avatars.com/api/?name=${encodedName}&background=random&color=fff&size=128&rounded=true&bold=true&font-size=0.33`;
return { photo: avatarUrl }; return { photo: avatarUrl };
}, },
async getLyrics(track: string, artist: string): Promise<{ plainLyrics?: string; syncedLyrics?: string; } | null> { async getLyrics(track: string, artist: string, videoId?: string): Promise<{ plainLyrics?: string; syncedLyrics?: string; } | null> {
try { try {
const res = await apiFetch(`/lyrics?track=${encodeURIComponent(track)}&artist=${encodeURIComponent(artist)}`); // More aggressive track name cleaning for better search results
if (res && (res.plainLyrics || res.syncedLyrics)) { const cleanTrack = track
return res; .replace(/\(.*?\)/g, '') // Remove parentheses content
.replace(/\[.*?\]/g, '') // Remove brackets content
.replace(/ feat\..*/gi, '') // Remove "feat." and everything after
.replace(/ ft\..*/gi, '') // Remove "ft." and everything after
.replace(/ - lyrics video/gi, '') // Remove "lyrics video" suffix
.replace(/ - official video/gi, '') // Remove "official video" suffix
.replace(/ - official audio/gi, '') // Remove "official audio" suffix
.replace(/ - mv/gi, '') // Remove "mv" suffix
.replace(/ - audio/gi, '') // Remove "audio" suffix
.replace(/ - video/gi, '') // Remove "video" suffix
.replace(/ - lyric/gi, '') // Remove "lyric" suffix
.replace(/ - live/gi, '') // Remove "live" suffix
.replace(/ - acoustic/gi, '') // Remove "acoustic" suffix
.replace(/ - cover/gi, '') // Remove "cover" suffix
.replace(/ - remix/gi, '') // Remove "remix" suffix
.replace(/ - ver\./gi, '') // Remove "ver." suffix
.replace(/ - version/gi, '') // Remove "version" suffix
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
const cleanArtist = artist
.replace(/ \(.*?\)/g, '') // Remove parentheses content
.replace(/ \[.*?\]/g, '') // Remove brackets content
.replace(/ - official/gi, '') // Remove "official" suffix
.replace(/ - topic/gi, '') // Remove "topic" suffix
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
console.log(`Searching lyrics for: "${cleanTrack}" by "${cleanArtist}"`);
// Helper function to try fetching lyrics from a URL
const tryFetch = async (url: string, parser: (data: any) => { plainLyrics?: string; syncedLyrics?: string } | null): Promise<{ plainLyrics?: string; syncedLyrics?: string } | null> => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
const response = await fetch(url, {
signal: controller.signal,
headers: { 'Accept': 'application/json' }
});
clearTimeout(timeoutId);
if (response.ok) {
const data = await response.json();
return parser(data);
}
} catch (e) {
console.log(`API error for ${url}:`, e);
}
return null;
};
// Try lyrics.ovh (best for English songs)
const lyricsOvhResult = await tryFetch(
`https://api.lyrics.ovh/v1/${encodeURIComponent(cleanArtist)}/${encodeURIComponent(cleanTrack)}`,
(data) => data.lyrics ? { plainLyrics: data.lyrics } : null
);
if (lyricsOvhResult) {
console.log('Found lyrics from lyrics.ovh');
return lyricsOvhResult;
} }
// Try LRCLIB (good for synced lyrics)
const lrclibResult = await tryFetch(
`https://lrclib.net/api/search?artist_name=${encodeURIComponent(cleanArtist)}&track_name=${encodeURIComponent(cleanTrack)}`,
(data) => {
if (Array.isArray(data) && data.length > 0) {
const first = data[0];
return {
plainLyrics: first.plainLyrics || undefined,
syncedLyrics: first.syncedLyrics || undefined
};
}
return null;
}
);
if (lrclibResult) {
console.log('Found lyrics from LRCLIB');
return lrclibResult;
}
// Helper function to check if text is likely Vietnamese
const isVietnameseText = (text: string): boolean => {
// Vietnamese characters: àáảãạâầấẩẫậăằắẳẵặèéẻẽẹêềếểễệìíỉĩịòóỏõọôồốổỗộơờớởỡợùúủũụưừứửữựỳýỷỹỵđ
const vietnamesePattern = /[àáảãạâầấẩẫậăằắẳẵặèéẻẽẹêềếểễệìíỉĩịòóỏõọôồốổỗộơờớởỡợùúủũụưừứửữựỳýỷỹỵđ]/i;
return vietnamesePattern.test(text);
};
// If we have a video ID, try SimpMusic with video ID
if (videoId) {
console.log('Trying SimpMusic with video ID:', videoId);
const simpmusicVideoResult = await tryFetch(
`https://api-lyrics.simpmusic.org/v1/${videoId}`,
(data) => {
console.log('SimpMusic FULL response:', data);
// Handle SimpMusic API response format: { type: 'success', data: Array(1), success: true }
if (data && data.type === 'success' && Array.isArray(data.data) && data.data.length > 0) {
const lyricsData = data.data[0];
console.log('SimpMusic first item:', lyricsData);
const lyrics = lyricsData.lyrics || lyricsData.syncedLyrics;
if (lyrics) {
// Check if lyrics are in Vietnamese
if (isVietnameseText(lyrics)) {
console.log('SimpMusic Vietnamese lyrics found:', lyrics.substring(0, 100));
return {
plainLyrics: lyricsData.lyrics || undefined,
syncedLyrics: lyricsData.syncedLyrics || undefined
};
} else {
console.log('SimpMusic lyrics are not Vietnamese, skipping');
return null;
}
} else {
console.log('SimpMusic data item has no lyrics property');
return null;
}
} else if (data && data.type === 'error') {
console.log('SimpMusic error:', data.error?.reason || 'Unknown error');
return null;
}
console.log('SimpMusic: Unexpected response format');
return null;
}
);
if (simpmusicVideoResult) {
console.log('Found Vietnamese lyrics from SimpMusic (video ID)');
return simpmusicVideoResult;
} else {
console.log('SimpMusic video ID search returned null');
}
}
// Try with simplified names (remove special characters)
const simpleTrack = cleanTrack.replace(/[^\w\s]/g, '').trim();
const simpleArtist = cleanArtist.replace(/[^\w\s]/g, '').trim();
if (simpleTrack !== cleanTrack || simpleArtist !== cleanArtist) {
console.log(`Trying simplified search: "${simpleTrack}" by "${simpleArtist}"`);
const simpleResult = await tryFetch(
`https://api.lyrics.ovh/v1/${encodeURIComponent(simpleArtist)}/${encodeURIComponent(simpleTrack)}`,
(data) => data.lyrics ? { plainLyrics: data.lyrics } : null
);
if (simpleResult) {
console.log('Found lyrics with simplified search');
return simpleResult;
}
}
// Last resort: Try SimpMusic search by title
console.log('Trying SimpMusic search by title...');
const simpmusicSearchResult = await tryFetch(
`https://api-lyrics.simpmusic.org/v1/search/title?title=${encodeURIComponent(cleanTrack)}`,
(data) => {
console.log('SimpMusic search response:', data);
if (data && data.type === 'success' && Array.isArray(data.data) && data.data.length > 0) {
const first = data.data[0];
const lyrics = first.lyrics || first.syncedLyrics;
if (lyrics) {
// Check if lyrics are in Vietnamese
if (isVietnameseText(lyrics)) {
console.log('SimpMusic search found Vietnamese lyrics:', lyrics.substring(0, 100));
return {
plainLyrics: first.lyrics || undefined,
syncedLyrics: first.syncedLyrics || undefined
};
} else {
console.log('SimpMusic search lyrics are not Vietnamese, skipping');
return null;
}
}
}
return null;
}
);
if (simpmusicSearchResult) {
console.log('Found lyrics from SimpMusic search');
return simpmusicSearchResult;
}
// Try ZingMP3 API for Vietnamese lyrics (via proxy)
console.log('Trying ZingMP3 API for Vietnamese lyrics...');
try {
const zingmp3Response = await fetch(`/api/lyrics/zingmp3?artist=${encodeURIComponent(cleanArtist)}&track=${encodeURIComponent(cleanTrack)}`);
if (zingmp3Response.ok) {
const zingmp3Data = await zingmp3Response.json();
if (zingmp3Data && zingmp3Data.lyrics) {
console.log('Found lyrics from ZingMP3');
return { plainLyrics: zingmp3Data.lyrics };
}
}
} catch (e) {
console.log('ZingMP3 API error:', e);
}
console.log('No lyrics found from any API');
return null; return null;
} catch (e) { } catch (e) {
console.error("Failed to fetch lyrics", e); console.error("Failed to fetch lyrics", e);

View file

@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/components/addtoplaylistmodal.tsx","./src/components/animatedbackground.tsx","./src/components/bottomnav.tsx","./src/components/coverimage.tsx","./src/components/createplaylistmodal.tsx","./src/components/layout.tsx","./src/components/logo.tsx","./src/components/lyrics.tsx","./src/components/playerbar.tsx","./src/components/queuemodal.tsx","./src/components/settingsmodal.tsx","./src/components/sidebar.tsx","./src/components/skeleton.tsx","./src/components/techspecs.tsx","./src/context/librarycontext.tsx","./src/context/playercontext.tsx","./src/context/themecontext.tsx","./src/data/seed_data.ts","./src/data/seed_data_real.ts","./src/hooks/usedominantcolor.ts","./src/hooks/useinfinitescroll.ts","./src/hooks/uselyrics.ts","./src/pages/album.tsx","./src/pages/artist.tsx","./src/pages/collection.tsx","./src/pages/home.tsx","./src/pages/library.tsx","./src/pages/playlist.tsx","./src/pages/search.tsx","./src/pages/section.tsx","./src/services/db.ts","./src/services/library.ts","./src/types/index.ts"],"version":"5.9.3"} {"root":["./src/app.tsx","./src/main.tsx","./src/components/addtoplaylistmodal.tsx","./src/components/animatedbackground.tsx","./src/components/bottomnav.tsx","./src/components/coverimage.tsx","./src/components/createplaylistmodal.tsx","./src/components/layout.tsx","./src/components/logo.tsx","./src/components/lyrics.tsx","./src/components/playerbar.tsx","./src/components/queuemodal.tsx","./src/components/recommendations.tsx","./src/components/settingsmodal.tsx","./src/components/sidebar.tsx","./src/components/skeleton.tsx","./src/components/techspecs.tsx","./src/components/videoplayer.tsx","./src/context/librarycontext.tsx","./src/context/playercontext.tsx","./src/context/themecontext.tsx","./src/data/seed_data.ts","./src/data/seed_data_real.ts","./src/hooks/usedominantcolor.ts","./src/hooks/useinfinitescroll.ts","./src/hooks/uselyrics.ts","./src/pages/album.tsx","./src/pages/artist.tsx","./src/pages/collection.tsx","./src/pages/home.tsx","./src/pages/library.tsx","./src/pages/playlist.tsx","./src/pages/search.tsx","./src/pages/section.tsx","./src/services/db.ts","./src/services/library.ts","./src/types/index.ts"],"version":"5.9.3"}