diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ad9a8a8..988471b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -35,6 +35,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/java/pl/lambada/songsync/data/remote/UserSettingsController.kt b/app/src/main/java/pl/lambada/songsync/data/remote/UserSettingsController.kt index 7ef190b..346f694 100644 --- a/app/src/main/java/pl/lambada/songsync/data/remote/UserSettingsController.kt +++ b/app/src/main/java/pl/lambada/songsync/data/remote/UserSettingsController.kt @@ -35,6 +35,9 @@ class UserSettingsController(private val dataStore: DataStore) { var multiPersonWordByWord by mutableStateOf(dataStore.get(multiPersonWordByWordKey, false)) private set + var syncedMusixmatch by mutableStateOf(dataStore.get(syncedMusixmatchKey, true)) + private set + var pureBlack by mutableStateOf(dataStore.get(pureBlackKey, false)) private set @@ -74,6 +77,11 @@ class UserSettingsController(private val dataStore: DataStore) { multiPersonWordByWord = to } + fun updateSyncedMusixmatch(to: Boolean) { + dataStore.set(syncedMusixmatchKey, to) + syncedMusixmatch = to + } + fun updateDisableMarquee(to: Boolean) { dataStore.set(disableMarqueeKey, to) disableMarquee = to @@ -96,6 +104,7 @@ private val blacklistedFoldersKey = stringPreferencesKey("blacklist") private val hideLyricsKey = booleanPreferencesKey("hide_lyrics") private val includeTranslationKey = booleanPreferencesKey("include_translation") private val multiPersonWordByWordKey = booleanPreferencesKey("multi_person_word_by_word") +private val syncedMusixmatchKey = booleanPreferencesKey("synced_lyrics") private val disableMarqueeKey = booleanPreferencesKey("marquee_disable") private val pureBlackKey = booleanPreferencesKey("pure_black") private val sdCardPathKey = stringPreferencesKey("sd_card_path") \ No newline at end of file diff --git a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/LyricsProviderService.kt b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/LyricsProviderService.kt index fb02bbe..87de702 100644 --- a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/LyricsProviderService.kt +++ b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/LyricsProviderService.kt @@ -31,8 +31,8 @@ class LyricsProviderService { // Apple Track ID private var appleID = 0L - // Musixmatch Track ID - private var musixmatchID = 0L + // Musixmatch Song Info + private var musixmatchSongInfo: SongInfo? = null // TODO: Use values from SongInfo object returned by search instead of storing them here /** @@ -69,7 +69,7 @@ class LyricsProviderService { } ?: throw NoTrackFoundException() Providers.MUSIXMATCH -> MusixmatchAPI().getSongInfo(query, offset).also { - musixmatchID = it?.musixmatchID ?: 0 + musixmatchSongInfo = it } ?: throw NoTrackFoundException() } } catch (e: InternalErrorException) { @@ -94,7 +94,8 @@ class LyricsProviderService { provider: Providers, // TODO providers could be a sealed interface to include such parameters includeTranslationNetEase: Boolean = false, - multiPersonWordByWord: Boolean = false + multiPersonWordByWord: Boolean = false, + syncedMusixmatch: Boolean = true ): String? { return try { when (provider) { @@ -108,7 +109,10 @@ class LyricsProviderService { appleID, multiPersonWordByWord ) - Providers.MUSIXMATCH -> MusixmatchAPI().getSyncedLyrics(musixmatchID) + Providers.MUSIXMATCH -> MusixmatchAPI().getLyrics( + musixmatchSongInfo, + syncedMusixmatch + ) } } catch (e: Exception) { null diff --git a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/MusixmatchAPI.kt b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/MusixmatchAPI.kt index 6de0b1f..ffb4e4f 100644 --- a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/MusixmatchAPI.kt +++ b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/MusixmatchAPI.kt @@ -5,7 +5,6 @@ import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import pl.lambada.songsync.domain.model.SongInfo -import pl.lambada.songsync.domain.model.lyrics_providers.others.MusixmatchLyricsResponse import pl.lambada.songsync.domain.model.lyrics_providers.others.MusixmatchSearchResponse import pl.lambada.songsync.util.EmptyQueryException import pl.lambada.songsync.util.networking.Ktor.client @@ -21,25 +20,32 @@ class MusixmatchAPI { * @return Search result as a SongInfo object. */ suspend fun getSongInfo(query: SongInfo, offset: Int = 0): SongInfo? { - val search = withContext(Dispatchers.IO) { + val artistName = withContext(Dispatchers.IO) { URLEncoder.encode( - "${query.songName} ${query.artistName}", + "${query.artistName}", Charsets.UTF_8.toString() ) } - if (search == " ") + val songName = withContext(Dispatchers.IO) { + URLEncoder.encode( + "${query.songName}", + Charsets.UTF_8.toString() + ) + } + + if (artistName == "" || songName == "") throw EmptyQueryException() val response = client.get( - "$baseURL/search?q=$search" + "$baseURL/full?artist=$artistName&track=$songName" ) val responseBody = response.bodyAsText(Charsets.UTF_8) - if (response.status.value !in 200..299 || responseBody == "[]") + if (response.status.value !in 200..299) return null - val json = json.decodeFromString>(responseBody) + val json = json.decodeFromString(responseBody) val result = try { json[offset] @@ -53,24 +59,20 @@ class MusixmatchAPI { songLink = result.url, albumCoverLink = result.artwork, musixmatchID = result.id, + hasSyncedLyrics = result.hasSyncedLyrics, + hasUnsyncedLyrics = result.hasSyncedLyrics, + syncedLyrics = result.syncedLyrics?.lyrics, + unsyncedLyrics = result.unsyncedLyrics?.lyrics ) } /** - * Searches for synced lyrics using the song name and artist name. - * @param id The ID of the song from search results. - * @return The synced lyrics as a string. + * Returns the lyrics. + * @param songInfo The SongInfo of the song from search results. + * @return The lyrics as a string or null if the lyrics were not found. */ - suspend fun getSyncedLyrics(id: Long): String? { - val response = client.get( - "$baseURL/lyrics?id=$id" - ) - val responseBody = response.bodyAsText(Charsets.UTF_8) - - if (response.status.value !in 200..299) - return null - - val json = json.decodeFromString(responseBody) - return json.lyrics + fun getLyrics(songInfo: SongInfo?, synced: Boolean = true): String? { + return if(synced) songInfo?.syncedLyrics + else songInfo?.unsyncedLyrics } } \ No newline at end of file diff --git a/app/src/main/java/pl/lambada/songsync/domain/model/SongInfo.kt b/app/src/main/java/pl/lambada/songsync/domain/model/SongInfo.kt index 598c8fc..1767b8a 100644 --- a/app/src/main/java/pl/lambada/songsync/domain/model/SongInfo.kt +++ b/app/src/main/java/pl/lambada/songsync/domain/model/SongInfo.kt @@ -23,4 +23,8 @@ data class SongInfo( var neteaseID: Long? = null, // Netease-only var appleID: Long? = null, // Apple-only var musixmatchID: Long? = null, // Musixmatch-only + var hasSyncedLyrics: Boolean? = null, // Musixmatch-only + var hasUnsyncedLyrics: Boolean? = null, // Musixmatch-only + var syncedLyrics: String? = null, // Musixmatch-only + var unsyncedLyrics: String? = null, // Musixmatch-only ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/Musixmatch.kt b/app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/Musixmatch.kt index 223ce36..917052a 100644 --- a/app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/Musixmatch.kt +++ b/app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/Musixmatch.kt @@ -14,14 +14,24 @@ data class MusixmatchSearchResponse( val url: String, val albumId: Long, val hasSyncedLyrics: Boolean, - val hasUnsyncedLyrics: Boolean + val hasUnsyncedLyrics: Boolean, + val syncedLyrics: SyncedLyricsResponse? = null, + val unsyncedLyrics: UnsyncedLyricsResponse? = null ) @Serializable -data class MusixmatchLyricsResponse( +data class SyncedLyricsResponse( val id: Long, val duration: Int, val language: String, val updatedTime: String, + val lyrics: String +) + +@Serializable +data class UnsyncedLyricsResponse( + val id: Long, + val language: String, + val updatedTime: String, val lyrics: String, ) diff --git a/app/src/main/java/pl/lambada/songsync/ui/components/SongCard.kt b/app/src/main/java/pl/lambada/songsync/ui/components/SongCard.kt index 2b82b5f..0c34bfc 100644 --- a/app/src/main/java/pl/lambada/songsync/ui/components/SongCard.kt +++ b/app/src/main/java/pl/lambada/songsync/ui/components/SongCard.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -28,6 +29,7 @@ import coil.compose.rememberAsyncImagePainter import coil.imageLoader import coil.request.ImageRequest import pl.lambada.songsync.R +import pl.lambada.songsync.util.openFileFromPath @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -41,11 +43,16 @@ fun SharedTransitionScope.SongCard( animatedVisibilityScope: AnimatedVisibilityScope, ) { val unknownArtistString = stringResource(R.string.unknown) + val context = LocalContext.current OutlinedCard( shape = RoundedCornerShape(10.dp), modifier = CombinedModifier( - outer = Modifier.fillMaxWidth(), + outer = Modifier + .fillMaxWidth() + .clickable(filePath != null) { + openFileFromPath(context, filePath!!) + }, inner = modifier ) ) { diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/about/AboutScreen.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/about/AboutScreen.kt index 3099d88..57f1f06 100644 --- a/app/src/main/java/pl/lambada/songsync/ui/screens/about/AboutScreen.kt +++ b/app/src/main/java/pl/lambada/songsync/ui/screens/about/AboutScreen.kt @@ -29,6 +29,7 @@ import pl.lambada.songsync.ui.screens.about.components.MultiPersonSwitch import pl.lambada.songsync.ui.screens.about.components.PureBlackThemeSwitch import pl.lambada.songsync.ui.screens.about.components.SdCardPathSetting import pl.lambada.songsync.ui.screens.about.components.SupportSection +import pl.lambada.songsync.ui.screens.about.components.SyncedLyricsSwitch import pl.lambada.songsync.ui.screens.about.components.TranslationSwitch import pl.lambada.songsync.ui.screens.about.components.UpdateAvailableDialog import pl.lambada.songsync.util.ext.getVersion @@ -89,6 +90,13 @@ fun AboutScreen( ) } + item { + SyncedLyricsSwitch( + selected = userSettingsController.syncedMusixmatch, + onToggle = { userSettingsController.updateSyncedMusixmatch(it) } + ) + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { item { SdCardPathSetting( diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/about/components/SyncedLyricsSwitch.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/about/components/SyncedLyricsSwitch.kt new file mode 100644 index 0000000..3c0aa1d --- /dev/null +++ b/app/src/main/java/pl/lambada/songsync/ui/screens/about/components/SyncedLyricsSwitch.kt @@ -0,0 +1,18 @@ +package pl.lambada.songsync.ui.screens.about.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import pl.lambada.songsync.R +import pl.lambada.songsync.ui.components.AboutItem +import pl.lambada.songsync.ui.components.SwitchItem + +@Composable +fun SyncedLyricsSwitch(selected: Boolean, onToggle: (Boolean) -> Unit) { + AboutItem(label = stringResource(id = R.string.synced_lyrics)) { + SwitchItem( + label = stringResource(id = R.string.synced_lyrics_summary), + selected = selected, + onClick = { onToggle(!selected) } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/home/HomeViewModel.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/home/HomeViewModel.kt index fa742a4..0090cc3 100644 --- a/app/src/main/java/pl/lambada/songsync/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/pl/lambada/songsync/ui/screens/home/HomeViewModel.kt @@ -278,7 +278,8 @@ class HomeViewModel( version, provider = userSettingsController.selectedProvider, includeTranslationNetEase = userSettingsController.includeTranslation, - multiPersonWordByWord = userSettingsController.multiPersonWordByWord + multiPersonWordByWord = userSettingsController.multiPersonWordByWord, + syncedMusixmatch = userSettingsController.syncedMusixmatch ) } diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/LyricsFetchViewModel.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/LyricsFetchViewModel.kt index 4ff1f27..027baea 100644 --- a/app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/LyricsFetchViewModel.kt +++ b/app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/LyricsFetchViewModel.kt @@ -48,7 +48,8 @@ class LyricsFetchViewModel( version, userSettingsController.selectedProvider, userSettingsController.includeTranslation, - userSettingsController.multiPersonWordByWord + userSettingsController.multiPersonWordByWord, + userSettingsController.syncedMusixmatch ) fun loadSongInfo(context: Context, tryingAgain: Boolean = false) { diff --git a/app/src/main/java/pl/lambada/songsync/util/MiscelaneousUtils.kt b/app/src/main/java/pl/lambada/songsync/util/MiscelaneousUtils.kt index 40d293b..bde2b66 100644 --- a/app/src/main/java/pl/lambada/songsync/util/MiscelaneousUtils.kt +++ b/app/src/main/java/pl/lambada/songsync/util/MiscelaneousUtils.kt @@ -1,8 +1,12 @@ package pl.lambada.songsync.util import android.content.Context +import android.content.Intent +import android.net.Uri import android.os.Build import android.widget.Toast +import androidx.core.content.FileProvider +import java.io.File fun isLegacyFileAccessRequired(filePath: String?): Boolean { // Before Android 11, not in internal storage @@ -10,6 +14,30 @@ fun isLegacyFileAccessRequired(filePath: String?): Boolean { && filePath?.contains("/storage/emulated/0/") == false } +fun openFileFromPath(context: Context, filePath: String) { + val file = File(filePath) + if (!file.exists()) { + showToast(context, "File does not exist") + return + } + + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + FileProvider.getUriForFile(context, context.packageName + ".provider", file) + } else { + Uri.fromFile(file) + } + + val intent = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "audio/mp3") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + } else { + Toast.makeText(context, "No app found to open the music file.", Toast.LENGTH_SHORT).show() + } +} + fun showToast(context: Context, messageResId: Int, vararg args: Any, long: Boolean = true) { Toast .makeText( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c20f643..9fe76ea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -125,4 +125,6 @@ You tried to embed the lyrics to a non-local file. Aborting operation. Multi-person word by word lyrics Use multi-person lyrics format when getting lyrics from Apple Music + Get Synced Lyrics + Toggle to switch between synced and unsynced lyrics from Musixmatch. \ No newline at end of file diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..746a0cf --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file