From 023fee470abd6c27b26b74db349582c4deb727cb Mon Sep 17 00:00:00 2001 From: Khoa Vo Date: Sat, 21 Mar 2026 20:55:53 +0700 Subject: [PATCH] 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 --- backend-rust/Cargo.lock | 1185 ++++++++++++++++- backend-rust/Cargo.toml | 1 + backend-rust/src/api.rs | 144 ++ backend-rust/src/main.rs | 4 +- frontend-vite/package-lock.json | 77 ++ frontend-vite/package.json | 2 + frontend-vite/src/components/BottomNav.tsx | 17 +- frontend-vite/src/components/Lyrics.tsx | 119 +- frontend-vite/src/components/PlayerBar.tsx | 177 +-- .../src/components/Recommendations.tsx | 24 +- .../src/components/SettingsModal.tsx | 2 +- frontend-vite/src/components/VideoPlayer.tsx | 161 +++ frontend-vite/src/hooks/useLyrics.ts | 87 +- frontend-vite/src/pages/Album.tsx | 18 +- frontend-vite/src/pages/Artist.tsx | 24 +- frontend-vite/src/pages/Home.tsx | 116 +- frontend-vite/src/pages/Playlist.tsx | 18 +- frontend-vite/src/pages/Search.tsx | 2 +- frontend-vite/src/pages/Section.tsx | 24 +- frontend-vite/src/services/library.ts | 220 ++- frontend-vite/tsconfig.tsbuildinfo | 2 +- 21 files changed, 2124 insertions(+), 300 deletions(-) create mode 100644 frontend-vite/src/components/VideoPlayer.tsx diff --git a/backend-rust/Cargo.lock b/backend-rust/Cargo.lock index 29c38c0..8e6cc2f 100644 --- a/backend-rust/Cargo.lock +++ b/backend-rust/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -66,6 +72,7 @@ version = "0.1.0" dependencies = [ "axum", "futures", + "reqwest", "serde", "serde_json", "tokio", @@ -75,24 +82,98 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -100,9 +181,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -200,6 +320,70 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "http" version = "1.4.0" @@ -261,6 +445,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -270,6 +455,39 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", ] [[package]] @@ -278,13 +496,159 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", + "system-configuration", "tokio", "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", ] [[package]] @@ -293,12 +657,40 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "lock_api" version = "0.4.14" @@ -350,7 +742,24 @@ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", ] [[package]] @@ -359,6 +768,50 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -400,6 +853,31 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -418,6 +896,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -427,18 +911,162 @@ dependencies = [ "bitflags", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -505,6 +1133,12 @@ dependencies = [ "serde", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -534,9 +1168,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -553,6 +1199,64 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] [[package]] name = "tokio" @@ -568,7 +1272,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -582,6 +1286,26 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -626,12 +1350,14 @@ dependencies = [ "http-body-util", "http-range-header", "httpdate", + "iri-string", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", @@ -669,6 +1395,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicase" version = "2.9.0" @@ -681,24 +1413,228 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "urlencoding" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -708,6 +1644,247 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/backend-rust/Cargo.toml b/backend-rust/Cargo.toml index 8702145..ff3caa2 100644 --- a/backend-rust/Cargo.toml +++ b/backend-rust/Cargo.toml @@ -17,3 +17,4 @@ tower = { version = "0.5.3", features = ["util"] } tower-http = { version = "0.6.8", features = ["cors", "fs"] } urlencoding = "2.1.3" futures = "0.3" +reqwest = { version = "0.12", features = ["json"] } diff --git a/backend-rust/src/api.rs b/backend-rust/src/api.rs index f3815a8..c3c52ab 100644 --- a/backend-rust/src/api.rs +++ b/backend-rust/src/api.rs @@ -172,3 +172,147 @@ pub async fn recommendations_handler( 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, +) -> 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::(&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::>(&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, +) -> 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::(&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::(&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"}))) +} diff --git a/backend-rust/src/main.rs b/backend-rust/src/main.rs index 2152f7e..2f8ad5e 100644 --- a/backend-rust/src/main.rs +++ b/backend-rust/src/main.rs @@ -32,12 +32,14 @@ async fn main() { .allow_methods(Any) .allow_headers(Any); - let app = Router::new() +let app = Router::new() .route("/api/search", get(api::search_handler)) .route("/api/stream/{id}", get(api::stream_handler)) .route("/api/artist/info", get(api::artist_info_handler)) .route("/api/browse", get(api::browse_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")) .layer(cors) .with_state(app_state); diff --git a/frontend-vite/package-lock.json b/frontend-vite/package-lock.json index 03ced72..3ecba90 100644 --- a/frontend-vite/package-lock.json +++ b/frontend-vite/package-lock.json @@ -8,10 +8,12 @@ "name": "frontend-vite", "version": "0.0.0", "dependencies": { + "artplayer": "^5.4.0", "framer-motion": "^11.2.11", "idb": "^8.0.0", "lucide-react": "^0.395.0", "music-metadata-browser": "^2.5.10", + "plyr": "^3.8.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.1" @@ -3640,6 +3642,15 @@ "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": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -4102,6 +4113,17 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "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": { "version": "3.48.0", "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==", "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -6110,6 +6138,15 @@ "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -6151,6 +6188,12 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "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": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6476,6 +6519,15 @@ "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": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6678,6 +6730,19 @@ "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": { "version": "1.1.0", "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" } }, + "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": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -8378,6 +8449,12 @@ "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/frontend-vite/package.json b/frontend-vite/package.json index c17174a..2ed493d 100644 --- a/frontend-vite/package.json +++ b/frontend-vite/package.json @@ -10,10 +10,12 @@ "preview": "vite preview" }, "dependencies": { + "artplayer": "^5.4.0", "framer-motion": "^11.2.11", "idb": "^8.0.0", "lucide-react": "^0.395.0", "music-metadata-browser": "^2.5.10", + "plyr": "^3.8.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.1" diff --git a/frontend-vite/src/components/BottomNav.tsx b/frontend-vite/src/components/BottomNav.tsx index a0ce4d4..c52a137 100644 --- a/frontend-vite/src/components/BottomNav.tsx +++ b/frontend-vite/src/components/BottomNav.tsx @@ -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 { useState } from 'react'; -import SettingsModal from './SettingsModal'; export default function BottomNav() { const location = useLocation(); const path = location.pathname; - const [isSettingsOpen, setIsSettingsOpen] = useState(false); const tabs = [ { name: 'Home', icon: Home, path: '/' }, @@ -31,19 +28,7 @@ export default function BottomNav() { ); })} - - - setIsSettingsOpen(false)} - /> ); } diff --git a/frontend-vite/src/components/Lyrics.tsx b/frontend-vite/src/components/Lyrics.tsx index 1a9d671..b3122b3 100644 --- a/frontend-vite/src/components/Lyrics.tsx +++ b/frontend-vite/src/components/Lyrics.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useRef } from 'react'; -import { libraryService } from '../services/library'; +import { useLyrics } from '../hooks/useLyrics'; interface LyricsProps { trackTitle: string; @@ -7,56 +7,25 @@ interface LyricsProps { currentTime: number; isOpen: boolean; onClose: () => void; + videoId?: string; } -interface LyricLine { - time: number; - text: string; -} - -export default function Lyrics({ trackTitle, artistName, currentTime, isOpen, onClose }: LyricsProps) { - const [lyrics, setLyrics] = useState(null); - const [syncedLines, setSyncedLines] = useState([]); - const [loading, setLoading] = useState(false); +export default function Lyrics({ trackTitle, artistName, currentTime, isOpen, onClose, videoId }: LyricsProps) { const activeLineRef = useRef(null); - - useEffect(() => { - if (isOpen && trackTitle) { - setLoading(true); - setLyrics(null); - setSyncedLines([]); - - libraryService.getLyrics(trackTitle, artistName) - .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); - }); + + // Use the optimized useLyrics hook + const { + lyrics, + syncedLines, + loading, + activeIndex + } = useLyrics(trackTitle, artistName, currentTime, isOpen, videoId); useEffect(() => { if (activeLineRef.current) { activeLineRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); } - }, [activeIndex]); // Only scroll when line changes! + }, [activeIndex]); return (
@@ -82,8 +51,9 @@ export default function Lyrics({ trackTitle, artistName, currentTime, isOpen, on {/* Content */}
{loading ? ( -
+
+ Searching for lyrics...
) : syncedLines.length > 0 ? (
@@ -100,31 +70,50 @@ export default function Lyrics({ trackTitle, artistName, currentTime, isOpen, on

))}
+ ) : lyrics ? ( +
+
+ {lyrics.split('\n').map((line, i) => ( +

+ {line || '\u00A0'} +

+ ))} +
+
) : ( -
- {lyrics} +
+
+ + + +
+

Không tìm thấy lời bài hát

+

+ Lyrics for this song are not available.
+ + For Vietnamese songs, try searching on:{' '} + + ZingMP3 + + {' '}or{' '} + + NhacCuaTui + + +

)}
); } - -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; -} diff --git a/frontend-vite/src/components/PlayerBar.tsx b/frontend-vite/src/components/PlayerBar.tsx index 273b4ac..760803a 100644 --- a/frontend-vite/src/components/PlayerBar.tsx +++ b/frontend-vite/src/components/PlayerBar.tsx @@ -5,6 +5,7 @@ import { useNavigate, useLocation } from "react-router-dom"; import TechSpecs from './TechSpecs'; import AddToPlaylistModal from "./AddToPlaylistModal"; import Lyrics from './Lyrics'; +import VideoPlayer from './VideoPlayer'; import QueueModal from './QueueModal'; import Recommendations from './Recommendations'; import { useDominantColor } from '../hooks/useDominantColor'; @@ -29,7 +30,8 @@ export default function PlayerBar() { currentTrack?.title || '', currentTrack?.artist || '', 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 @@ -108,47 +110,26 @@ export default function PlayerBar() { // Handle audio/video mode switching const handleModeSwitch = (mode: 'audio' | '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(); setIsVideoReady(false); - - // 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); - } + // Video will autoplay via URL parameter } else { - // Switching back to audio - if (isPlaying) { - audioRef.current?.play().catch(() => { }); + // Switching back to audio - sync audio time with video progress + if (audioRef.current) { + audioRef.current.currentTime = progress; + if (isPlaying) { + audioRef.current.play().catch(() => { }); + } } } 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 = () => { - if (playerMode !== 'video' || !iframeRef.current || !iframeRef.current.contentWindow) return; + if (playerMode !== 'video') return; - // Send play/pause command directly to YouTube - 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) + // Toggle play state - Artplayer will respond via the isPlaying prop togglePlay(); }; @@ -510,64 +491,99 @@ export default function PlayerBar() { onTouchStart={resetIdleTimer} > {playerMode === 'video' ? ( - /* CINEMATIC VIDEO MODE: Full Background Video */ -
-
{/* Slight scale to hide any possible edges */} - {!isVideoReady && ( -
-
-
- )} - + /* CENTERED VIDEO MODE: Video Player centered like album art */ +
+
+ {/* Video Container with 16:9 aspect ratio */} +
+ { + // 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" + /> +
+ {/* Subtle gradient overlay for depth */} +
+
+ + {/* Song Info Below Video */} +
+

{currentTrack.title}

+

{ 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} +

- {/* Overlay Gradient for cinematic feel */} -
) : ( /* SONG MODE: Centered Case */ -
- {currentTrack.title} +
+
setIsInfoOpen(true)} + > + {currentTrack.title} + {/* Subtle gradient overlay for depth */} +
+
+ + {/* Song Info Below Cover */} +
+

{currentTrack.title}

+

{ 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} +

+
)} {/* Controls Overlay (Bottom) */}
- {/* Metadata */} -
-

{currentTrack.title}

-

{ 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} -

-
- - {/* Secondary Actions */} -
- - + {/* Secondary Actions Only (Metadata moved below cover in song mode) */} +
+
+ + + +
@@ -679,6 +695,7 @@ export default function PlayerBar() { currentTime={progress} isOpen={isLyricsOpen} onClose={closeLyrics} + videoId={currentTrack.id} /> )} diff --git a/frontend-vite/src/components/Recommendations.tsx b/frontend-vite/src/components/Recommendations.tsx index cddacb2..650078e 100644 --- a/frontend-vite/src/components/Recommendations.tsx +++ b/frontend-vite/src/components/Recommendations.tsx @@ -95,19 +95,19 @@ export default function Recommendations({

{title}

- {isLoading && ( -
- {[1, 2, 3, 4, 5].map(i => ( -
-
-
-
-
- ))} -
- )} +{isLoading && ( +
+ {[1, 2, 3, 4, 5].map(i => ( +
+
+
+
+
+ ))} +
+ )} -
+
{/* Tracks */} {showTracks && data.tracks.slice(0, 8).map((track) => (
-
+
diff --git a/frontend-vite/src/components/VideoPlayer.tsx b/frontend-vite/src/components/VideoPlayer.tsx new file mode 100644 index 0000000..82ab56c --- /dev/null +++ b/frontend-vite/src/components/VideoPlayer.tsx @@ -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(null); + const playerRef = useRef(null); + const iframeRef = useRef(null); + const [isReady, setIsReady] = useState(false); + const isParentControlledRef = useRef(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 ( +
+
+ {!isReady && ( +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend-vite/src/hooks/useLyrics.ts b/frontend-vite/src/hooks/useLyrics.ts index 08fb816..e545a29 100644 --- a/frontend-vite/src/hooks/useLyrics.ts +++ b/frontend-vite/src/hooks/useLyrics.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import { libraryService } from '../services/library'; export interface LyricLine { @@ -6,33 +6,104 @@ export interface LyricLine { 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(); + +export function useLyrics(trackTitle: string, artistName: string, currentTime: number, enabled: boolean = true, videoId?: string) { const [lyrics, setLyrics] = useState(null); const [syncedLines, setSyncedLines] = useState([]); const [loading, setLoading] = useState(false); + const lastFetchTime = useRef(0); + const currentTrackRef = useRef(''); useEffect(() => { - if (trackTitle && artistName && enabled) { - setLoading(true); - setLyrics(null); - setSyncedLines([]); + // Only fetch if we have track info and it's enabled + if (!trackTitle || !artistName || !enabled) { + return; + } - 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 => { + clearTimeout(timeoutId); + if (data) { + // Cache the result + lyricsCache.set(trackKey, data); + if (data.syncedLyrics) { setSyncedLines(parseSyncedLyrics(data.syncedLyrics)); + setLyrics(null); + } else if (data.plainLyrics) { + setLyrics(data.plainLyrics); + setSyncedLines([]); } else { - setLyrics(data.plainLyrics || "No lyrics available."); + setLyrics(null); + setSyncedLines([]); } } else { + // Cache empty result to avoid repeated failed requests + lyricsCache.set(trackKey, {}); setLyrics(null); + setSyncedLines([]); } setLoading(false); }) .catch(() => { + clearTimeout(timeoutId); 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]); diff --git a/frontend-vite/src/pages/Album.tsx b/frontend-vite/src/pages/Album.tsx index 742d0eb..f59a3a2 100644 --- a/frontend-vite/src/pages/Album.tsx +++ b/frontend-vite/src/pages/Album.tsx @@ -95,10 +95,10 @@ export default function Album() { /> )} -
+
{/* Cover */}
{ if (tracks.length > 0) { playTrack(tracks[0], tracks); @@ -113,16 +113,16 @@ export default function Album() {
{/* Info */} -
+
Album

{albumInfo.title}

-
+
{albumInfo.artist} - - {albumInfo.year} - - {tracks.length} songs, {formattedDuration} + + {albumInfo.year} + + {tracks.length} songs, {formattedDuration}
@@ -197,7 +197,7 @@ export default function Album() { Show discography
-
+
{moreByArtist.map((track) => (
- {/* Albums (Mock UI for now as strict album search is hard with yt-dlp only) */} -
-
-

Albums

- -
- {/* Placeholder Logic: Show top song covers as "Albums" for visual parity if no real albums */} -
+ {/* Albums (Mock UI for now as strict album search is hard with yt-dlp only) */} +
+
+

Albums

+ +
+ {/* Placeholder Logic: Show top song covers as "Albums" for visual parity if no real albums */} +
{artist.topSongs.slice(0, 5).map((track) => (
- {/* Singles */} -
-

Singles

-
+ {/* Singles */} +
+

Singles

+
{artist.topSongs.slice(0, 4).reverse().map((track) => (
-
+
{[1, 2, 3, 4, 5].map(j => (
@@ -192,7 +192,7 @@ export default function Home() {

Top Albums

-
+
{uniqueAlbums.slice(0, 15).map((album) => (
@@ -232,15 +232,15 @@ export default function Home() { {[1, 2].map(i => (
-
- {[1, 2, 3, 4, 5].map(j => ( -
- - - -
- ))} -
+
+ {[1, 2, 3, 4, 5].map(j => ( +
+ + + +
+ ))} +
))}
@@ -263,8 +263,8 @@ export default function Home() {
- {/* USER REQUEST: Bigger Grid, Smaller Text, Smaller Gap */} -
+{/* USER REQUEST: Bigger Grid, Smaller Text, Smaller Gap */} +
{sortPlaylists(uniquePlaylists).slice(0, 15).map((playlist) => (
@@ -313,11 +313,11 @@ function RecentlyListenedSection({ playHistory, playTrack }: { playHistory: Trac if (playHistory.length === 0) return null; return ( -
-
- -

Recently Listened

-
+
+
+ +

Recently Listened

+
{playHistory.slice(0, 10).map((track, i) => ( @@ -378,50 +378,50 @@ function MadeForYouSection() { if (!loading && recommendations.length === 0) return null; return ( -
-
- -

Made For You

-
+
+
+ +

Made For You

+

{seedTrack ? <>Because you listened to {seedTrack.artist} : "Recommended for you"}

- {loading ? ( -
- {[1, 2, 3, 4, 5].map(i => ( -
- - - -
- ))} -
- ) : ( -
- {recommendations.slice(0, 10).map((track, i) => ( -
{ - 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"> -
- -
-
- -
-
-
-

{track.title}

-

{track.artist}

-
- ))} -
- )} +{loading ? ( +
+ {[1, 2, 3, 4, 5].map(i => ( +
+ + + +
+ ))} +
+ ) : ( +
+ {recommendations.slice(0, 10).map((track, i) => ( +
{ + 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"> +
+ +
+
+ +
+
+
+

{track.title}

+

{track.artist}

+
+ ))} +
+ )}
); } diff --git a/frontend-vite/src/pages/Playlist.tsx b/frontend-vite/src/pages/Playlist.tsx index a67e3a8..87f9a7f 100644 --- a/frontend-vite/src/pages/Playlist.tsx +++ b/frontend-vite/src/pages/Playlist.tsx @@ -199,12 +199,12 @@ export default function Playlist() { )} {/* Hero Header */} -
+
{ if (playlist && playlist.tracks.length > 0) { playTrack(playlist.tracks[0], playlist.tracks); @@ -222,21 +222,21 @@ export default function Playlist() {
-
+
Playlist

{playlist.title}

-
+
{'description' in playlist && playlist.description && ( {playlist.description} )} - - + + {loadingTracks ? 'Updating...' : `${playlist.tracks.length} songs`} {totalDuration > 0 && ( <> - - {Math.floor(totalDuration / 60)} min + + {Math.floor(totalDuration / 60)} min )}
@@ -374,7 +374,7 @@ export default function Playlist() {

More like this

-
+
{moreLikeThis.map((track) => (
-
+
{[1, 2, 3, 4].map(i => )}
diff --git a/frontend-vite/src/pages/Section.tsx b/frontend-vite/src/pages/Section.tsx index c0cb21f..f9f5a83 100644 --- a/frontend-vite/src/pages/Section.tsx +++ b/frontend-vite/src/pages/Section.tsx @@ -38,18 +38,18 @@ export default function Section() {
{/* Grid */} - {loading ? ( -
- {[1, 2, 3, 4, 5, 6, 7, 8].map(i => ( -
- - - -
- ))} -
- ) : ( -
+{loading ? ( +
+ {[1, 2, 3, 4, 5, 6, 7, 8].map(i => ( +
+ + + +
+ ))} +
+ ) : ( +
{playlists.map((playlist) => (
diff --git a/frontend-vite/src/services/library.ts b/frontend-vite/src/services/library.ts index ff87871..87cfa9a 100644 --- a/frontend-vite/src/services/library.ts +++ b/frontend-vite/src/services/library.ts @@ -236,10 +236,10 @@ export const libraryService = { }, 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 { 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)}`, { signal: controller.signal @@ -248,29 +248,227 @@ export const libraryService = { if (res.ok) { const data = await res.json(); - console.log(`[ArtistInfo] ${artistName}:`, data); - if (data.image) { + if (data.image && data.image !== '') { + console.log(`[ArtistInfo] Found real image for ${artistName}`); return { photo: data.image }; } } } catch (e) { - console.error(`[ArtistInfo] Error for ${artistName}:`, e); - // Fall through to next method + // Silently fall through to fallback - this is expected behavior + // console.log(`[ArtistInfo] Using fallback for ${artistName}`); } // 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 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 }; }, - 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 { - const res = await apiFetch(`/lyrics?track=${encodeURIComponent(track)}&artist=${encodeURIComponent(artist)}`); - if (res && (res.plainLyrics || res.syncedLyrics)) { - return res; + // More aggressive track name cleaning for better search results + const cleanTrack = track + .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; } catch (e) { console.error("Failed to fetch lyrics", e); diff --git a/frontend-vite/tsconfig.tsbuildinfo b/frontend-vite/tsconfig.tsbuildinfo index 25ba334..e1a785d 100644 --- a/frontend-vite/tsconfig.tsbuildinfo +++ b/frontend-vite/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file