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:
parent
d272cb51e1
commit
023fee470a
21 changed files with 2124 additions and 300 deletions
1185
backend-rust/Cargo.lock
generated
1185
backend-rust/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"] }
|
||||||
|
|
|
||||||
|
|
@ -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"})))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ async fn main() {
|
||||||
.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);
|
||||||
|
|
|
||||||
77
frontend-vite/package-lock.json
generated
77
frontend-vite/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = progress;
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
audioRef.current?.play().catch(() => { });
|
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,66 +491,101 @@ 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
|
||||||
|
videoId={currentTrack.id}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
onTimeUpdate={(time) => {
|
||||||
|
// Sync video time with player progress
|
||||||
|
if (Math.abs(time - progress) > 2) {
|
||||||
|
setProgress(time);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPlay={() => {
|
||||||
|
setIsVideoReady(true);
|
||||||
|
// Only toggle play if we're not already playing to avoid infinite loop
|
||||||
|
if (!isPlaying) {
|
||||||
|
togglePlay();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPause={() => {
|
||||||
|
// Only toggle pause if we're already playing to avoid infinite loop
|
||||||
|
if (isPlaying) {
|
||||||
|
togglePlay();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEnded={() => {
|
||||||
|
nextTrack();
|
||||||
|
}}
|
||||||
|
className="w-full h-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{/* Subtle gradient overlay for depth */}
|
||||||
<iframe
|
<div className="absolute inset-0 rounded-2xl bg-gradient-to-t from-black/30 via-transparent to-transparent pointer-events-none" />
|
||||||
key={`${currentTrack.id}-${playerMode}`}
|
</div>
|
||||||
ref={iframeRef}
|
|
||||||
width="100%"
|
{/* Song Info Below Video */}
|
||||||
height="100%"
|
<div className="text-center max-w-full px-4">
|
||||||
src={`https://www.youtube.com/embed/${currentTrack.id}?autoplay=1&playsinline=1&modestbranding=1&rel=0&controls=1&enablejsapi=1&fs=1&vq=hd1080`}
|
<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>
|
||||||
title="YouTube video player"
|
<p
|
||||||
frameBorder="0"
|
onClick={() => { setPlayerMode('audio'); setIsFullScreenOpen(false); navigate(`/artist/${encodeURIComponent(currentTrack.artist)}`); }}
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
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"
|
||||||
allowFullScreen
|
>
|
||||||
className={`pointer-events-auto transition-opacity duration-500 ${isVideoReady ? 'opacity-100' : 'opacity-0'}`}
|
{currentTrack.artist}
|
||||||
onLoad={() => setIsVideoReady(true)}
|
</p>
|
||||||
></iframe>
|
|
||||||
</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">
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-[280px] md:max-w-[360px] mb-6 md:mb-8 cursor-pointer"
|
||||||
|
onClick={() => setIsInfoOpen(true)}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={currentTrack.cover_url}
|
src={currentTrack.cover_url}
|
||||||
alt={currentTrack.title}
|
alt={currentTrack.title}
|
||||||
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]"
|
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
|
|
||||||
onClick={() => { setPlayerMode('audio'); setIsFullScreenOpen(false); navigate(`/artist/${encodeURIComponent(currentTrack.artist)}`); }}
|
|
||||||
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'}`}
|
|
||||||
>
|
|
||||||
{currentTrack.artist}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Secondary Actions */}
|
|
||||||
<div className="flex items-center gap-4 text-white">
|
|
||||||
<button onClick={() => toggleLike(currentTrack)} className={`p-3 rounded-full hover:bg-white/10 transition ${likedTracks.has(currentTrack.id) ? 'text-green-500' : 'text-white/60'}`}>
|
<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"} />
|
<Heart size={32} fill={likedTracks.has(currentTrack.id) ? "currentColor" : "none"} />
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => toggleLyrics()} className={`p-3 rounded-full hover:bg-white/10 transition ${isLyricsOpen ? 'text-green-500' : 'text-white/60 hover:text-white'}`}>
|
||||||
|
<Mic2 size={28} />
|
||||||
|
</button>
|
||||||
<button onClick={() => setIsInfoOpen(true)} className="p-3 rounded-full hover:bg-white/10 transition text-white/60 hover:text-white">
|
<button onClick={() => setIsInfoOpen(true)} className="p-3 rounded-full hover:bg-white/10 transition text-white/60 hover:text-white">
|
||||||
<Info size={28} />
|
<Info size={28} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Scrubber & Controls */}
|
{/* Scrubber & Controls */}
|
||||||
<div className="max-w-screen-md mx-auto mt-8">
|
<div className="max-w-screen-md mx-auto mt-8">
|
||||||
|
|
@ -679,6 +695,7 @@ export default function PlayerBar() {
|
||||||
currentTime={progress}
|
currentTime={progress}
|
||||||
isOpen={isLyricsOpen}
|
isOpen={isLyricsOpen}
|
||||||
onClose={closeLyrics}
|
onClose={closeLyrics}
|
||||||
|
videoId={currentTrack.id}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ export default function Recommendations({
|
||||||
</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" />
|
||||||
|
|
@ -107,7 +107,7 @@ export default function Recommendations({
|
||||||
</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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
161
frontend-vite/src/components/VideoPlayer.tsx
Normal file
161
frontend-vite/src/components/VideoPlayer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
if (!trackTitle || !artistName || !enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
setLoading(true);
|
||||||
setLyrics(null);
|
setLyrics(null);
|
||||||
setSyncedLines([]);
|
setSyncedLines([]);
|
||||||
|
|
||||||
libraryService.getLyrics(trackTitle, artistName)
|
// 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));
|
||||||
} else {
|
setLyrics(null);
|
||||||
setLyrics(data.plainLyrics || "No lyrics available.");
|
} else if (data.plainLyrics) {
|
||||||
}
|
setLyrics(data.plainLyrics);
|
||||||
|
setSyncedLines([]);
|
||||||
} else {
|
} else {
|
||||||
setLyrics(null);
|
setLyrics(null);
|
||||||
|
setSyncedLines([]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Cache empty result to avoid repeated failed requests
|
||||||
|
lyricsCache.set(trackKey, {});
|
||||||
|
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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ export default function Artist() {
|
||||||
<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}
|
||||||
|
|
@ -220,7 +220,7 @@ export default function Artist() {
|
||||||
{/* 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}
|
||||||
|
|
|
||||||
|
|
@ -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,7 +232,7 @@ 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" />
|
||||||
|
|
@ -264,7 +264,7 @@ export default function Home() {
|
||||||
</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">
|
||||||
|
|
@ -314,7 +314,7 @@ function RecentlyListenedSection({ playHistory, playTrack }: { playHistory: Trac
|
||||||
|
|
||||||
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>
|
||||||
|
|
@ -379,7 +379,7 @@ function MadeForYouSection() {
|
||||||
|
|
||||||
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>
|
||||||
|
|
@ -388,7 +388,7 @@ function MadeForYouSection() {
|
||||||
</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" />
|
||||||
|
|
@ -398,7 +398,7 @@ function MadeForYouSection() {
|
||||||
))}
|
))}
|
||||||
</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);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export default function Section() {
|
||||||
|
|
||||||
{/* 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" />
|
||||||
|
|
@ -49,7 +49,7 @@ export default function Section() {
|
||||||
))}
|
))}
|
||||||
</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">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
Loading…
Reference in a new issue