diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index 6195b36..e0da3ee 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -3,15 +3,19 @@
+
+
+
+
@@ -35,6 +39,7 @@
+
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
index 931b96c..16660f1 100644
--- a/.idea/runConfigurations.xml
+++ b/.idea/runConfigurations.xml
@@ -5,8 +5,12 @@
+
+
+
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a3a98a2..b5f6bba 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -8,13 +8,13 @@ plugins {
android {
namespace = "pl.lambada.songsync"
- compileSdk = 34
+ compileSdk = 35
defaultConfig {
applicationId = "pl.lambada.songsync"
minSdk = 21
//noinspection OldTargetApi
- targetSdk = 34
+ targetSdk = 35
versionCode = 421
versionName = "4.2.1"
@@ -88,6 +88,6 @@ dependencies {
implementation(libs.ktor.cio)
implementation(libs.taglib)
implementation(libs.datastore.preferences)
- debugImplementation(libs.ui.tooling)
- debugImplementation(libs.ui.tooling.preview)
+ implementation(libs.ui.tooling) //NOT RECOMMENDED
+ implementation(libs.ui.tooling.preview) //NOT RECOMMENDED
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7371b6e..f176ce8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -42,6 +42,22 @@
+
+
+
+
+
+
+
+
+
+
+ val resultIntent = Intent().apply {
+ action = Intent.ACTION_SEND
+ putExtra("lyrics", lyrics)
+ type = "text/plain"
+ }
+ setResult(RESULT_OK, resultIntent)
+ finish()
+ }
+ )
+ }
+ }
+ }
+ }
+
+
+ private fun handleShareIntent(
+ intent: Intent,
+ sendEvent: (QuickLyricsSearchViewModel.Event) -> Unit
+ ) {
+ when (intent.action) {
+ Intent.ACTION_SEND -> {
+ val songName =
+ intent.getStringExtra("songName")
+ val artistName = intent.getStringExtra("artistName")
+ ?: "" // Artist name is optional. This may be misleading sometimes.
+
+ if (songName.isNullOrBlank()) {
+ Toast.makeText(
+ this,
+ this.getString(R.string.song_name_not_provided),
+ Toast.LENGTH_SHORT
+ ).show()
+ finish()
+ return
+ }
+
+ sendEvent(
+ QuickLyricsSearchViewModel.Event.Fetch(
+ song = songName to artistName,
+ context = this
+ )
+ )
+ }
+ }
+ }
+
+ companion object {
+ lateinit var activityImageLoader: ImageLoader
+ lateinit var userSettingsController: UserSettingsController
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/pl/lambada/songsync/activities/quicksearch/QuickLyricsSearchPage.kt b/app/src/main/java/pl/lambada/songsync/activities/quicksearch/QuickLyricsSearchPage.kt
new file mode 100644
index 0000000..c144fcf
--- /dev/null
+++ b/app/src/main/java/pl/lambada/songsync/activities/quicksearch/QuickLyricsSearchPage.kt
@@ -0,0 +1,255 @@
+package pl.lambada.songsync.activities.quicksearch
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.Crossfade
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.Send
+import androidx.compose.material.icons.filled.Cloud
+import androidx.compose.material.icons.rounded.Subtitles
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import pl.lambada.songsync.R
+import pl.lambada.songsync.activities.quicksearch.components.ButtonWithIconAndText
+import pl.lambada.songsync.activities.quicksearch.components.ErrorCard
+import pl.lambada.songsync.activities.quicksearch.components.ExpandableOutlinedCard
+import pl.lambada.songsync.activities.quicksearch.components.QuickLyricsSongInfo
+import pl.lambada.songsync.activities.quicksearch.components.SyncedLyricsColumn
+import pl.lambada.songsync.activities.quicksearch.viewmodel.QuickLyricsSearchViewModel
+import pl.lambada.songsync.ui.common.AnimatedCardContentTransformation
+import pl.lambada.songsync.util.ResourceState
+import pl.lambada.songsync.util.ScreenState
+
+@Composable
+fun QuickLyricsSearchPage(
+ state: State,
+ onSendLyrics: (String) -> Unit
+) {
+ val lyricsState = state.value.lyricsState
+ val parsedLyrics = state.value.parsedLyrics
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Crossfade(state.value.screenState) { pageState ->
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ state.value.song?.let { song ->
+ item {
+ Heading(
+ song = song,
+ lyricsState = lyricsState,
+ onSendLyrics = onSendLyrics
+ )
+ }
+ }
+
+ item {
+ HorizontalDivider()
+ }
+
+ item {
+ AnimatedContent(
+ modifier = Modifier.fillMaxWidth(),
+ targetState = pageState
+ ) { animatedPageState ->
+ when (animatedPageState) {
+ is ScreenState.Loading -> {
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+
+ is ScreenState.Success -> {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ animatedPageState.data?.let {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Cloud,
+ contentDescription = null,
+ )
+ Text(
+ text = stringResource(R.string.cloud_song).uppercase(),
+ style = MaterialTheme.typography.bodyMedium.copy(
+ letterSpacing = 1.sp,
+ fontWeight = FontWeight.SemiBold
+ )
+ )
+ }
+
+ QuickLyricsSongInfo(
+ songInfo = animatedPageState.data,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ AnimatedContent(
+ modifier = Modifier.fillMaxWidth(),
+ transitionSpec = { AnimatedCardContentTransformation },
+ targetState = state.value.lyricsState
+ ) { lyricsState ->
+ when (lyricsState) {
+ is ResourceState.Loading<*> -> {
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth(
+ 0.8f
+ )
+ )
+ }
+ }
+
+ is ResourceState.Success<*> -> {
+ lyricsState.data?.let { _ ->
+ ExpandableOutlinedCard(
+ title = stringResource(R.string.song_lyrics),
+ subtitle = stringResource(R.string.lyrics_subtitle),
+ icon = Icons.Rounded.Subtitles,
+ isExpanded = false,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ SyncedLyricsColumn(
+ lyricsList = parsedLyrics,
+ modifier = Modifier
+ .heightIn(
+ min = 200.dp, max = 600.dp
+ )
+ .fillMaxWidth()
+ .padding(8.dp)
+ )
+ }
+ }
+ }
+
+ is ResourceState.Error<*> -> {
+ ErrorCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 300.dp),
+ stacktrace = lyricsState.message ?: ""
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ is ScreenState.Error -> {
+ ErrorCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 300.dp),
+ stacktrace = animatedPageState.exception.message
+ ?: animatedPageState.exception.stackTrace.toString()
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun Heading(
+ song: Pair,
+ lyricsState: ResourceState,
+ onSendLyrics: (String) -> Unit
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.showing_lyrics_for).uppercase(),
+ style = MaterialTheme.typography.labelLarge.copy(
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f),
+ letterSpacing = 2.sp
+ )
+ )
+ Text(
+ text = song.first, style = MaterialTheme.typography.headlineSmall
+ )
+ Text(
+ text = buildAnnotatedString {
+ append(stringResource(R.string.by))
+ append(" ")
+ withStyle(MaterialTheme.typography.titleMedium.toSpanStyle()) {
+ append(song.second)
+ }
+ },
+ )
+ }
+ Row(
+ modifier = Modifier
+ .weight(1f)
+ .padding(8.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ with(lyricsState) {
+ ButtonWithIconAndText(
+ icon = Icons.AutoMirrored.Rounded.Send,
+ text = stringResource(R.string.accept),
+ modifier = Modifier.weight(1f),
+ onClick = { onSendLyrics(this.data ?: "") },
+ enabled = this is ResourceState.Success<*>,
+ shape = RoundedCornerShape(8.dp) //RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)
+ )
+// ButtonWithIconAndText(
+// icon = Icons.Filled.Settings,
+// text = stringResource(R.string.settings),
+// modifier = Modifier.weight(1f),
+// shape = RoundedCornerShape(topEnd = 8.dp, bottomEnd = 8.dp)
+// )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/ErrorCard.kt b/app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/ErrorCard.kt
new file mode 100644
index 0000000..6fded37
--- /dev/null
+++ b/app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/ErrorCard.kt
@@ -0,0 +1,97 @@
+package pl.lambada.songsync.activities.quicksearch.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Error
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import pl.lambada.songsync.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ErrorCard(
+ stacktrace: String,
+ modifier: Modifier = Modifier
+) {
+ OutlinedCard(
+ modifier = modifier,
+ colors = CardDefaults.outlinedCardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ ),
+ shape = MaterialTheme.shapes.small
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ modifier = Modifier.weight(0.1f),
+ imageVector = Icons.Rounded.Error,
+ contentDescription = stringResource(R.string.error),
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(6.dp)
+ .weight(1f),
+ ) {
+ Text(
+ modifier = Modifier.alpha(0.65f),
+ text = stringResource(R.string.unknown_error_occurred).uppercase(),
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ text = stacktrace,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ fontWeight = FontWeight.Normal
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun ErrorCardPreview() {
+ ErrorCard(
+ modifier = Modifier.heightIn(max = 300.dp),
+ stacktrace = "java.lang.IllegalArgumentException: Unsupported AnimationVector type\n" +
+ " \tat pl.lambada.songsync.util.ui.DurationBasedCustomAnimationsKt.minus(DurationBasedCustomAnimations.kt:112)\n" +
+ " \tat pl.lambada.songsync.util.ui.DurationBasedCustomAnimationsKt.access\$minus(DurationBasedCustomAnimations.kt:1)\n" +
+ " \tat pl.lambada.songsync.util.ui.VectorizedPixelAnimationSpec.getValueFromNanos(DurationBasedCustomAnimations.kt:57)\n" +
+ " \tat androidx.compose.animation.core.TargetBasedAnimation.getValueFromNanos(Animation.kt:265)\n" +
+ " \tat androidx.compose.animation.core.Transition\$TransitionAnimationState.seekTo\$animation_core_release(Transition.kt:1416)\n" +
+ " \tat androidx.compose.animation.core.Transition.seekAnimations\$animation_core_release(Transition.kt:1256)\n" +
+ " \tat androidx.compose.animation.core.Transition.seekAnimations\$animation_core_release(Transition.kt:1260)\n" +
+ " \tat androidx.compose.animation.core.Transition.seekAnimations\$animation_core_release(Transition.kt:1260)\n" +
+ " \tat androidx.compose.animation.core.SeekableTransitionState.seekToFraction(Transition.kt:744)\n" +
+ " \tat androidx.compose.animation.core.SeekableTransitionState.access\$seekToFraction(Transition.kt:224)\n" +
+ " \tat androidx.compose.animation.core.SeekableTransitionState\$animateOneFrameLambda\$1.invoke(Transition.kt:333)\n" +
+ " \tat androidx.compose.animation.core.SeekableTransitionState\$animateOneFrameLambda\$1.invoke(Transition.kt:311)\n" +
+ " \tat androidx.compose.runtime.BroadcastFrameClock\$FrameAwaiter.resume(BroadcastFrameClock.kt:42)"
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/ExpandableOutlinedCard.kt b/app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/ExpandableOutlinedCard.kt
new file mode 100644
index 0000000..ac9be73
--- /dev/null
+++ b/app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/ExpandableOutlinedCard.kt
@@ -0,0 +1,119 @@
+package pl.lambada.songsync.activities.quicksearch.components
+
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ExpandLess
+import androidx.compose.material.icons.outlined.PermDeviceInformation
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Text
+import androidx.compose.material3.surfaceColorAtElevation
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import pl.lambada.songsync.R
+
+@Composable
+fun ExpandableOutlinedCard(
+ modifier: Modifier = Modifier,
+ isExpanded: Boolean = false,
+ title: String,
+ subtitle: String,
+ icon: ImageVector,
+ content: @Composable () -> Unit,
+) {
+ var expanded by rememberSaveable { mutableStateOf(isExpanded) }
+
+ val animatedDegree =
+ animateFloatAsState(targetValue = if (expanded) 0f else -180f, label = "Button Rotation")
+
+ OutlinedCard(
+ modifier = modifier.animateContentSize(),
+ onClick = { expanded = !expanded },
+ colors = CardDefaults.outlinedCardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ ),
+ shape = MaterialTheme.shapes.small
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ modifier = Modifier.weight(0.1f),
+ imageVector = icon,
+ contentDescription = stringResource(R.string.song_lyrics),
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(6.dp)
+ .weight(1f),
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.62f),
+ fontWeight = FontWeight.Normal
+ )
+ }
+ FilledTonalIconButton(
+ modifier = Modifier.size(24.dp),
+ onClick = { expanded = !expanded },
+ colors = IconButtonDefaults.filledTonalIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp),
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.ExpandLess,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.rotate(animatedDegree.value)
+ )
+ }
+ }
+ AnimatedVisibility(visible = expanded) {
+ content()
+ }
+ }
+}
+
+@Composable
+@Preview
+@Preview(uiMode = UI_MODE_NIGHT_YES)
+private fun ExpandableElevatedCardPreview() {
+ ExpandableOutlinedCard(
+ title = "Title", subtitle = "Subtitle", content = {
+ Text(text = "Content")
+ }, icon = Icons.Outlined.PermDeviceInformation
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/QuickLyricsSongInfo.kt b/app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/QuickLyricsSongInfo.kt
new file mode 100644
index 0000000..a76437e
--- /dev/null
+++ b/app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/QuickLyricsSongInfo.kt
@@ -0,0 +1,128 @@
+package pl.lambada.songsync.activities.quicksearch.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.MusicNote
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import coil.ImageLoader
+import coil.compose.AsyncImage
+import pl.lambada.songsync.R
+import pl.lambada.songsync.activities.quicksearch.QuickLyricsSearchActivity
+import pl.lambada.songsync.domain.model.SongInfo
+
+@Composable
+fun QuickLyricsSongInfo(
+ modifier: Modifier = Modifier,
+ songInfo: SongInfo,
+ imageLoader: ImageLoader = QuickLyricsSearchActivity.activityImageLoader
+) {
+
+ val imageUrl: String? by remember(songInfo.albumCoverLink) {
+ mutableStateOf(songInfo.albumCoverLink)
+ }
+
+ OutlinedCard(
+ modifier = modifier,
+ colors = CardDefaults.outlinedCardColors().copy(
+ containerColor = MaterialTheme.colorScheme.surface
+ ),
+ border = CardDefaults.outlinedCardBorder().copy(
+ width = 2.dp,
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ AsyncImage(
+ modifier = Modifier
+ .size(72.dp)
+ .clip(MaterialTheme.shapes.small),
+ model = imageUrl,
+ contentDescription = stringResource(R.string.album_cover),
+ imageLoader = imageLoader,
+ )
+ Column(
+ modifier = Modifier,
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ horizontalAlignment = Alignment.Start
+ ) {
+ Text(
+ text = songInfo.songName ?: stringResource(R.string.unknown),
+ style = MaterialTheme.typography.bodyLarge.copy(
+ fontWeight = FontWeight.SemiBold
+ )
+ )
+
+ Text(
+ text = songInfo.artistName ?: stringResource(R.string.unknown),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TextWithIcon(
+ icon: ImageVector,
+ text: String,
+ textStyle: TextStyle = MaterialTheme.typography.bodyLarge
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null
+ )
+ Text(text = text, style = textStyle)
+ }
+}
+
+@Preview
+@Composable
+private fun TextWithIconPreview() {
+ TextWithIcon(
+ icon = Icons.Rounded.MusicNote,
+ text = "Song Name"
+ )
+}
+
+@Preview
+@Composable
+private fun QuickLyricsSongInfoPreview() {
+ QuickLyricsSongInfo(
+ songInfo = SongInfo(
+ songName = "Song Name",
+ artistName = "Artist Name",
+ albumCoverLink = "https://example.com/image.jpg"
+ )
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/SquaredButton.kt b/app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/SquaredButton.kt
new file mode 100644
index 0000000..8ce6380
--- /dev/null
+++ b/app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/SquaredButton.kt
@@ -0,0 +1,117 @@
+package pl.lambada.songsync.activities.quicksearch.components
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CornerBasedShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun SquareButtons() {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ ButtonWithIconAndText(
+ icon = Icons.Default.Home,
+ text = "Home",
+ modifier = Modifier.size(96.dp),
+ shape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)
+ )
+ ButtonWithIconAndText(
+ icon = Icons.Default.Settings,
+ text = "Settings",
+ modifier = Modifier.size(96.dp),
+ shape = RoundedCornerShape(topEnd = 8.dp, bottomEnd = 8.dp)
+ )
+ }
+}
+
+@Composable
+fun ButtonWithIconAndText(
+ modifier: Modifier = Modifier,
+ icon: ImageVector,
+ text: String,
+ enabled: Boolean = true,
+ border: Boolean = false,
+ backgroundColor: Color = MaterialTheme.colorScheme.secondaryContainer,
+ shape: CornerBasedShape = MaterialTheme.shapes.small,
+ onClick: () -> Unit = {}
+) {
+
+ val animatedAlpha by animateFloatAsState(
+ targetValue = if (enabled) 1f else 0.4f
+ )
+
+ Surface(
+ modifier = modifier
+ .semantics { role = Role.Button }
+ .alpha(animatedAlpha),
+ onClick = onClick,
+ enabled = enabled,
+ shape = shape,
+ border = if (border) BorderStroke(1.dp, MaterialTheme.colorScheme.outline) else null,
+ color = backgroundColor
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
+ modifier = Modifier
+ .padding(8.dp)
+ .defaultMinSize(
+ minWidth = ButtonDefaults.MinWidth,
+ minHeight = ButtonDefaults.MinHeight
+ )
+ .fillMaxWidth()
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodySmall.copy(
+ fontWeight = FontWeight.Medium,
+ ),
+ color = MaterialTheme.colorScheme.onSurface,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun SquareButtonsPreview() {
+ SquareButtons()
+}
\ No newline at end of file
diff --git a/app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/SyncedLyricsLine.kt b/app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/SyncedLyricsLine.kt
new file mode 100644
index 0000000..7fb0618
--- /dev/null
+++ b/app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/SyncedLyricsLine.kt
@@ -0,0 +1,66 @@
+package pl.lambada.songsync.activities.quicksearch.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun SyncedLyricsLine(
+ time: String,
+ lyrics: String,
+ modifier: Modifier = Modifier
+) {
+ Row(modifier = modifier) {
+ val formattedTime = buildAnnotatedString {
+ append(time.substring(0, time.length - 4))
+ withStyle(
+ style = SpanStyle(
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
+ )
+ ) {
+ append(time.takeLast(4))
+ }
+ }
+ Text(
+ text = formattedTime,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontFamily = FontFamily.Monospace
+ )
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = lyrics,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+}
+
+@Composable
+fun SyncedLyricsColumn(
+ lyricsList: List>,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier.verticalScroll(rememberScrollState())
+ ) {
+ lyricsList.forEach { (time, lyrics) ->
+ SyncedLyricsLine(time = time, lyrics = lyrics)
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/pl/lambada/songsync/activities/quicksearch/viewmodel/QuickLyricsSearchViewModel.kt b/app/src/main/java/pl/lambada/songsync/activities/quicksearch/viewmodel/QuickLyricsSearchViewModel.kt
new file mode 100644
index 0000000..34de372
--- /dev/null
+++ b/app/src/main/java/pl/lambada/songsync/activities/quicksearch/viewmodel/QuickLyricsSearchViewModel.kt
@@ -0,0 +1,149 @@
+package pl.lambada.songsync.activities.quicksearch.viewmodel
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import pl.lambada.songsync.R
+import pl.lambada.songsync.data.UserSettingsController
+import pl.lambada.songsync.data.remote.lyrics_providers.LyricsProviderService
+import pl.lambada.songsync.domain.model.SongInfo
+import pl.lambada.songsync.util.ResourceState
+import pl.lambada.songsync.util.ScreenState
+import pl.lambada.songsync.util.ext.getVersion
+import pl.lambada.songsync.util.parseLyrics
+
+class QuickLyricsSearchViewModel(
+ val userSettingsController: UserSettingsController,
+ private val lyricsProviderService: LyricsProviderService
+) : ViewModel() {
+ private val mutableState = MutableStateFlow(QuickSearchViewState())
+ val state = mutableState.asStateFlow()
+
+ data class QuickSearchViewState(
+ val song: Pair? = null, // Pair of song title and artist's name
+ val screenState: ScreenState = ScreenState.Loading,
+ val lyricsState: ResourceState = ResourceState.Loading(),
+ val parsedLyrics: List> = emptyList()
+ )
+
+ private fun fetchSongData(song: Pair, context: Context) {
+ updateScreenState(ScreenState.Loading)
+
+ viewModelScope.launch(Dispatchers.IO) {
+ val songInfoCall = runCatching {
+ lyricsProviderService
+ .getSongInfo(
+ query = SongInfo(song.first, song.second),
+ offset = 0,
+ provider = userSettingsController.selectedProvider
+ )
+ }
+
+ if (songInfoCall.isSuccess) {
+ val result = songInfoCall.getOrNull()
+
+ if (result == null) {
+ updateScreenState(
+ ScreenState.Error(
+ Exception("The song information retrieved is null")
+ )
+ )
+ } else {
+ updateScreenState(ScreenState.Success(result))
+ fetchLyrics(result.songLink, context)
+ }
+
+ } else {
+ val exception = songInfoCall.exceptionOrNull()
+ updateScreenState(
+ ScreenState.Error(
+ exception ?: Exception("An unknown error has occurred")
+ )
+ )
+ }
+ }
+ }
+
+ private fun fetchLyrics(songLink: String?, context: Context) {
+ updateLyricsState(ResourceState.Loading())
+ viewModelScope.launch(Dispatchers.IO) {
+
+ val lyricsCall = runCatching {
+ getSyncedLyrics(
+ link = songLink,
+ version = context.getVersion()
+ )
+ }
+
+ if (lyricsCall.isSuccess) {
+ val syncedLyrics = lyricsCall.getOrNull()
+
+ if (syncedLyrics == null) updateLyricsState(
+ ResourceState.Error("The fetched lyrics content is null.")
+ ) else {
+ updateLyricsState(ResourceState.Success(syncedLyrics))
+ parseLyrics(syncedLyrics).let { parsedLyrics ->
+ mutableState.update {
+ it.copy(parsedLyrics = parsedLyrics)
+ }
+ }
+ }
+
+ } else {
+ val exception = lyricsCall.exceptionOrNull()
+ updateLyricsState(
+ ResourceState.Error(
+ exception?.localizedMessage
+ ?: (context.getString(R.string.unknown) + exception?.stackTrace.toString())
+ )
+ )
+ }
+ }
+ }
+
+ private suspend fun getSyncedLyrics(link: String?, version: String): String? =
+ lyricsProviderService.getSyncedLyrics(
+ link,
+ version,
+ userSettingsController.selectedProvider,
+ userSettingsController.includeTranslation,
+ userSettingsController.multiPersonWordByWord,
+ userSettingsController.syncedMusixmatch
+ )
+
+ private fun updateScreenState(screenState: ScreenState) {
+ if (screenState != mutableState.value.screenState) {
+ mutableState.update {
+ it.copy(screenState = screenState)
+ }
+ }
+ }
+
+ private fun updateLyricsState(lyricsState: ResourceState) {
+ if (lyricsState != mutableState.value.lyricsState) {
+ mutableState.update {
+ it.copy(lyricsState = lyricsState)
+ }
+ }
+ }
+
+ fun onEvent(event: Event) {
+ when (event) {
+ is Event.Fetch -> {
+ mutableState.update {
+ it.copy(song = event.song)
+ }
+ fetchSongData(event.song, event.context)
+ }
+ }
+ }
+
+ interface Event {
+ data class Fetch(val song: Pair, val context: Context) : Event
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/pl/lambada/songsync/activities/quicksearch/viewmodel/QuickLyricsSearchViewModelFactory.kt b/app/src/main/java/pl/lambada/songsync/activities/quicksearch/viewmodel/QuickLyricsSearchViewModelFactory.kt
new file mode 100644
index 0000000..eac6e39
--- /dev/null
+++ b/app/src/main/java/pl/lambada/songsync/activities/quicksearch/viewmodel/QuickLyricsSearchViewModelFactory.kt
@@ -0,0 +1,19 @@
+package pl.lambada.songsync.activities.quicksearch.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import pl.lambada.songsync.data.UserSettingsController
+import pl.lambada.songsync.data.remote.lyrics_providers.LyricsProviderService
+
+class QuickLyricsSearchViewModelFactory(
+ private val userSettingsController: UserSettingsController,
+ private val lyricsProviderService: LyricsProviderService
+) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ if (modelClass.isAssignableFrom(QuickLyricsSearchViewModel::class.java)) {
+ @Suppress("UNCHECKED_CAST")
+ return QuickLyricsSearchViewModel(userSettingsController, lyricsProviderService) as T
+ }
+ throw IllegalArgumentException("Unknown ViewModel class")
+ }
+}
\ 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 53a129b..cd0058c 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
@@ -49,8 +49,11 @@ class LyricsProviderService {
* @return The SongInfo object containing the song information.
*/
@Throws(
- UnknownHostException::class, FileNotFoundException::class, NoTrackFoundException::class,
- EmptyQueryException::class, InternalErrorException::class
+ UnknownHostException::class,
+ FileNotFoundException::class,
+ NoTrackFoundException::class,
+ EmptyQueryException::class,
+ InternalErrorException::class
)
suspend fun getSongInfo(query: SongInfo, offset: Int = 0, provider: Providers): SongInfo? {
return try {
@@ -72,14 +75,11 @@ class LyricsProviderService {
musixmatchSongInfo = it
} ?: throw NoTrackFoundException()
}
- } catch (e: InternalErrorException) {
- throw e
- } catch (e: NoTrackFoundException) {
- throw e
- } catch (e: EmptyQueryException) {
- throw e
} catch (e: Exception) {
- throw InternalErrorException(Log.getStackTraceString(e))
+ when (e) {
+ is InternalErrorException, is NoTrackFoundException, is EmptyQueryException -> throw e
+ else -> throw InternalErrorException(Log.getStackTraceString(e))
+ }
}
}
@@ -97,25 +97,20 @@ class LyricsProviderService {
multiPersonWordByWord: Boolean = false,
syncedMusixmatch: Boolean = true
): String? {
- return try {
- when (provider) {
- Providers.SPOTIFY -> SpotifyLyricsAPI().getSyncedLyrics(songLink!!, version)
- Providers.LRCLIB -> LRCLibAPI().getSyncedLyrics(lrcLibID)
- Providers.NETEASE -> NeteaseAPI().getSyncedLyrics(
- neteaseID,
- includeTranslationNetEase
- )
- Providers.APPLE -> AppleAPI().getSyncedLyrics(
- appleID,
- multiPersonWordByWord
- )
- Providers.MUSIXMATCH -> MusixmatchAPI().getLyrics(
- musixmatchSongInfo,
- syncedMusixmatch
- )
- }
- } catch (e: Exception) {
- null
+ return when (provider) {
+ Providers.SPOTIFY -> SpotifyLyricsAPI().getSyncedLyrics(songLink!!, version)
+ Providers.LRCLIB -> LRCLibAPI().getSyncedLyrics(lrcLibID)
+ Providers.NETEASE -> NeteaseAPI().getSyncedLyrics(
+ neteaseID, includeTranslationNetEase
+ )
+
+ Providers.APPLE -> AppleAPI().getSyncedLyrics(
+ appleID, multiPersonWordByWord
+ )
+
+ Providers.MUSIXMATCH -> MusixmatchAPI().getLyrics(
+ musixmatchSongInfo, syncedMusixmatch
+ )
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/pl/lambada/songsync/ui/Navigator.kt b/app/src/main/java/pl/lambada/songsync/ui/Navigator.kt
index 64cc336..4fbe573 100644
--- a/app/src/main/java/pl/lambada/songsync/ui/Navigator.kt
+++ b/app/src/main/java/pl/lambada/songsync/ui/Navigator.kt
@@ -6,17 +6,17 @@ import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
-import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import kotlinx.serialization.Serializable
import pl.lambada.songsync.data.UserSettingsController
import pl.lambada.songsync.data.remote.lyrics_providers.LyricsProviderService
-import pl.lambada.songsync.ui.screens.settings.SettingsScreen
-import pl.lambada.songsync.ui.screens.settings.SettingsViewModel
+import pl.lambada.songsync.ui.common.animatedComposable
import pl.lambada.songsync.ui.screens.home.HomeScreen
import pl.lambada.songsync.ui.screens.home.HomeViewModel
import pl.lambada.songsync.ui.screens.lyricsFetch.LyricsFetchScreen
import pl.lambada.songsync.ui.screens.lyricsFetch.LyricsFetchViewModel
+import pl.lambada.songsync.ui.screens.settings.SettingsScreen
+import pl.lambada.songsync.ui.screens.settings.SettingsViewModel
/**
* Composable function for handling navigation within the app.
@@ -35,7 +35,7 @@ fun Navigator(
navController = navController,
startDestination = ScreenHome
) {
- composable {
+ animatedComposable {
HomeScreen(
navController = navController,
viewModel = viewModel {
@@ -46,7 +46,7 @@ fun Navigator(
)
}
- composable() {
+ animatedComposable() {
val args = it.toRoute()
LyricsFetchScreen(
@@ -61,7 +61,7 @@ fun Navigator(
animatedVisibilityScope = this,
)
}
- composable {
+ animatedComposable {
SettingsScreen(
viewModel = viewModel { SettingsViewModel() },
userSettingsController,
diff --git a/app/src/main/java/pl/lambada/songsync/ui/common/AnimatedComposables.kt b/app/src/main/java/pl/lambada/songsync/ui/common/AnimatedComposables.kt
new file mode 100644
index 0000000..83bd604
--- /dev/null
+++ b/app/src/main/java/pl/lambada/songsync/ui/common/AnimatedComposables.kt
@@ -0,0 +1,185 @@
+package pl.lambada.songsync.ui.common
+
+import android.os.Build
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.VisibilityThreshold
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.IntOffset
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavDeepLink
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavType
+import androidx.navigation.compose.composable
+import pl.lambada.songsync.util.ui.EmphasizedAccelerate
+import pl.lambada.songsync.util.ui.EmphasizedDecelerate
+import pl.lambada.songsync.util.ui.EmphasizedEasing
+import pl.lambada.songsync.util.ui.MotionConstants.DURATION_ENTER
+import pl.lambada.songsync.util.ui.MotionConstants.DURATION_EXIT
+import pl.lambada.songsync.util.ui.MotionConstants.InitialOffset
+import pl.lambada.songsync.util.ui.materialSharedAxisXIn
+import pl.lambada.songsync.util.ui.materialSharedAxisXOut
+import pl.lambada.songsync.util.ui.materialSharedAxisYIn
+import pl.lambada.songsync.util.ui.materialSharedAxisYOut
+import kotlin.reflect.KType
+
+fun enterTween() = tween(durationMillis = DURATION_ENTER, easing = EmphasizedEasing)
+
+fun exitTween() = tween(durationMillis = DURATION_ENTER, easing = EmphasizedEasing)
+
+private val fadeSpring =
+ spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium)
+
+private val fadeTween = tween(durationMillis = DURATION_EXIT)
+val fadeSpec = fadeTween
+
+inline fun NavGraphBuilder.animatedComposable(
+ deepLinks: List = emptyList(),
+ usePredictiveBack: Boolean = Build.VERSION.SDK_INT >= 34,
+ noinline content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit,
+) {
+ if (usePredictiveBack) {
+ animatedComposablePredictiveBack(deepLinks, content)
+ } else {
+ animatedComposableLegacy(deepLinks, content)
+ }
+}
+
+inline fun NavGraphBuilder.slideInVerticallyComposable(
+ deepLinks: List = emptyList(),
+ typeMap: Map> = emptyMap(),
+ usePredictiveBack: Boolean = Build.VERSION.SDK_INT >= 34,
+ noinline content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit,
+) {
+ if (usePredictiveBack) {
+ slideInVerticallyComposablePredictiveBack(deepLinks, typeMap, content)
+ } else {
+ slideInVerticallyComposableLegacy(deepLinks, typeMap, content)
+ }
+}
+
+inline fun NavGraphBuilder.animatedComposablePredictiveBack(
+ deepLinks: List = emptyList(),
+ noinline content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit,
+) =
+ composable(
+ deepLinks = deepLinks,
+ enterTransition = { materialSharedAxisXIn(initialOffsetX = { (it * 0.15f).toInt() }) },
+ exitTransition = {
+ materialSharedAxisXOut(targetOffsetX = { -(it * InitialOffset).toInt() })
+ },
+ popEnterTransition = {
+ scaleIn(
+ animationSpec = tween(durationMillis = 350, easing = EmphasizedDecelerate),
+ initialScale = 0.9f,
+ ) + materialSharedAxisXIn(initialOffsetX = { -(it * InitialOffset).toInt() })
+ },
+ popExitTransition = {
+ materialSharedAxisXOut(targetOffsetX = { (it * InitialOffset).toInt() }) +
+ scaleOut(
+ targetScale = 0.9f,
+ animationSpec = tween(durationMillis = 350, easing = EmphasizedAccelerate),
+ )
+ },
+ content = content,
+ )
+
+inline fun NavGraphBuilder.animatedComposableLegacy(
+ deepLinks: List = emptyList(),
+ noinline content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit,
+) =
+ composable(
+ deepLinks = deepLinks,
+ enterTransition = {
+ materialSharedAxisXIn(initialOffsetX = { (it * InitialOffset).toInt() })
+ },
+ exitTransition = {
+ materialSharedAxisXOut(targetOffsetX = { -(it * InitialOffset).toInt() })
+ },
+ popEnterTransition = {
+ materialSharedAxisXIn(initialOffsetX = { -(it * InitialOffset).toInt() })
+ },
+ popExitTransition = {
+ materialSharedAxisXOut(targetOffsetX = { (it * InitialOffset).toInt() })
+ },
+ content = content,
+ )
+
+inline fun NavGraphBuilder.animatedComposableVariant(
+ deepLinks: List = emptyList(),
+ noinline content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit,
+) =
+ composable(
+ deepLinks = deepLinks,
+ enterTransition = {
+ slideInHorizontally(enterTween(), initialOffsetX = { (it * InitialOffset).toInt() }) +
+ fadeIn(fadeSpec)
+ },
+ exitTransition = { fadeOut(fadeSpec) },
+ popEnterTransition = { fadeIn(fadeSpec) },
+ popExitTransition = {
+ slideOutHorizontally(exitTween(), targetOffsetX = { (it * InitialOffset).toInt() }) +
+ fadeOut(fadeSpec)
+ },
+ content = content,
+ )
+
+val springSpec =
+ spring(stiffness = Spring.StiffnessMedium, visibilityThreshold = IntOffset.VisibilityThreshold)
+
+inline fun NavGraphBuilder.slideInVerticallyComposableLegacy(
+ deepLinks: List = emptyList(),
+ typeMap: Map> = emptyMap(),
+ noinline content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit,
+) =
+ composable(
+ deepLinks = deepLinks,
+ typeMap = typeMap,
+ enterTransition = {
+ slideInVertically(initialOffsetY = { it }, animationSpec = enterTween()) + fadeIn()
+ },
+ exitTransition = { slideOutVertically() },
+ popEnterTransition = { slideInVertically() },
+ popExitTransition = {
+ slideOutVertically(targetOffsetY = { it }, animationSpec = enterTween()) + fadeOut()
+ },
+ content = content,
+ )
+
+inline fun NavGraphBuilder.slideInVerticallyComposablePredictiveBack(
+ deepLinks: List = emptyList(),
+ typeMap: Map> = emptyMap(),
+ noinline content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit,
+) =
+ composable(
+ deepLinks = deepLinks,
+ typeMap = typeMap,
+ enterTransition = { materialSharedAxisYIn(initialOffsetY = { (it * 0.25f).toInt() }) },
+ exitTransition = {
+ materialSharedAxisYOut(targetOffsetY = { -(it * InitialOffset * 1.5f).toInt() })
+ },
+ popEnterTransition = {
+ scaleIn(
+ animationSpec = tween(durationMillis = 400, easing = EmphasizedDecelerate),
+ initialScale = 0.85f,
+ ) + materialSharedAxisYIn(initialOffsetY = { -(it * InitialOffset * 1.5f).toInt() })
+ },
+ popExitTransition = {
+ materialSharedAxisYOut(targetOffsetY = { (it * InitialOffset * 1.5f).toInt() }) +
+ scaleOut(
+ targetScale = 0.85f,
+ animationSpec = tween(durationMillis = 400, easing = EmphasizedAccelerate),
+ )
+ },
+ content = content,
+ )
\ No newline at end of file
diff --git a/app/src/main/java/pl/lambada/songsync/ui/common/ComposableAnimations.kt b/app/src/main/java/pl/lambada/songsync/ui/common/ComposableAnimations.kt
new file mode 100644
index 0000000..e479150
--- /dev/null
+++ b/app/src/main/java/pl/lambada/songsync/ui/common/ComposableAnimations.kt
@@ -0,0 +1,20 @@
+package pl.lambada.songsync.ui.common
+
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.SizeTransform
+import pl.lambada.songsync.util.ui.materialSharedAxisXIn
+import pl.lambada.songsync.util.ui.materialSharedAxisXOut
+import pl.lambada.songsync.util.ui.materialSharedAxisYIn
+import pl.lambada.songsync.util.ui.materialSharedAxisYOut
+
+val AnimatedTextContentTransformation = ContentTransform(
+ materialSharedAxisXIn(initialOffsetX = { it / 10 }),
+ materialSharedAxisXOut(targetOffsetX = { -it / 10 }),
+ sizeTransform = SizeTransform(clip = false)
+)
+
+val AnimatedCardContentTransformation = ContentTransform(
+ materialSharedAxisYIn(initialOffsetY = { it / 10 }),
+ materialSharedAxisYOut(targetOffsetY = { -it / 10 }),
+ sizeTransform = SizeTransform(clip = false)
+)
\ No newline at end of file
diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/home/HomeScreen.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/home/HomeScreen.kt
index 10a463b..290b75e 100644
--- a/app/src/main/java/pl/lambada/songsync/ui/screens/home/HomeScreen.kt
+++ b/app/src/main/java/pl/lambada/songsync/ui/screens/home/HomeScreen.kt
@@ -48,6 +48,7 @@ import pl.lambada.songsync.ui.screens.home.components.SongItem
import pl.lambada.songsync.ui.screens.home.components.SortDialog
import pl.lambada.songsync.util.ext.BackPressHandler
import pl.lambada.songsync.util.ext.lowercaseWithLocale
+import pl.lambada.songsync.util.ui.SearchFABBoundsTransform
/**
* Composable function representing the home screen.
@@ -109,9 +110,11 @@ fun HomeScreen(
with(sharedTransitionScope) {
FloatingActionButton(
modifier = Modifier
+ .skipToLookaheadSize()
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "fab"),
animatedVisibilityScope = animatedVisibilityScope,
+ boundsTransform = SearchFABBoundsTransform,
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
),
onClick = { navController.navigate(LyricsFetchScreen()) }
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 14e6af9..8fd34e4 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
@@ -276,14 +276,18 @@ class HomeViewModel(
lyricsProviderService.getSongInfo(query, provider = userSettingsController.selectedProvider)
suspend fun getSyncedLyrics(link: String?, version: String): String? {
- return lyricsProviderService.getSyncedLyrics(
- link,
- version,
- provider = userSettingsController.selectedProvider,
- includeTranslationNetEase = userSettingsController.includeTranslation,
- multiPersonWordByWord = userSettingsController.multiPersonWordByWord,
- syncedMusixmatch = userSettingsController.syncedMusixmatch
- )
+ return try {
+ lyricsProviderService.getSyncedLyrics(
+ link,
+ version,
+ provider = userSettingsController.selectedProvider,
+ includeTranslationNetEase = userSettingsController.includeTranslation,
+ multiPersonWordByWord = userSettingsController.multiPersonWordByWord,
+ syncedMusixmatch = userSettingsController.syncedMusixmatch
+ )
+ } catch (e: Exception) {
+ null
+ }
}
fun selectSong(song: Song, newValue: Boolean) {
diff --git a/app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt b/app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt
index 212cf16..160b0a0 100644
--- a/app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt
+++ b/app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt
@@ -346,4 +346,22 @@ fun applyOffsetToLyrics(lyrics: String, offset: Int): String {
"${startChar}${applyOffset(minute, second, millisecond)}$endChar"
}
+}
+
+fun parseLyrics(lyrics: String): List> {
+ val timestampRegex = Regex("""[\[<](\d+):(\d+)\.(\d+)[]>]""")
+ val lines = lyrics.lines()
+
+ return lines.mapNotNull { line ->
+ val match = timestampRegex.find(line) ?: return@mapNotNull null
+ val (minute, second, millisecond) = match.destructured
+
+ val startChar = line[0]
+ val endChar = if (startChar == '[') ']' else '>'
+
+ val timestamp = "${minute}:${second}.${millisecond.padStart(3, '0')}"
+ val text = line.substringAfter(endChar).trim()
+
+ timestamp to text
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/pl/lambada/songsync/util/ResourceState.kt b/app/src/main/java/pl/lambada/songsync/util/ResourceState.kt
new file mode 100644
index 0000000..5fb5b46
--- /dev/null
+++ b/app/src/main/java/pl/lambada/songsync/util/ResourceState.kt
@@ -0,0 +1,35 @@
+package pl.lambada.songsync.util
+
+/**
+ * A sealed class representing the state of a resource.
+ *
+ * @param T The type of data associated with the resource state.
+ * @property data The data associated with the resource state.
+ * @property message An optional message associated with the resource state.
+ */
+sealed class ResourceState(val data: T? = null, val message: String? = null) {
+ /**
+ * Represents a loading state.
+ *
+ * @param T The type of data.
+ * @property data The data associated with the loading state.
+ */
+ class Loading(data: T? = null) : ResourceState(data)
+
+ /**
+ * Represents a success state with optional data.
+ *
+ * @param T The type of data.
+ * @property data The data associated with the success state.
+ */
+ class Success(data: T?) : ResourceState(data)
+
+ /**
+ * Represents an error state with an optional message and data.
+ *
+ * @param T The type of data.
+ * @property message The message associated with the error state.
+ * @property data The data associated with the error state.
+ */
+ class Error(message: String, data: T? = null) : ResourceState(data, message)
+}
\ No newline at end of file
diff --git a/app/src/main/java/pl/lambada/songsync/util/ScreenState.kt b/app/src/main/java/pl/lambada/songsync/util/ScreenState.kt
new file mode 100644
index 0000000..eb61c30
--- /dev/null
+++ b/app/src/main/java/pl/lambada/songsync/util/ScreenState.kt
@@ -0,0 +1,28 @@
+package pl.lambada.songsync.util
+
+/**
+ * A sealed class representing the state of a screen.
+ *
+ * @param T The type of data associated with the success state.
+ */
+sealed class ScreenState {
+ /**
+ * Represents a loading state.
+ */
+ data object Loading : ScreenState()
+
+ /**
+ * Represents a success state with optional data.
+ *
+ * @param T The type of data.
+ * @property data The data associated with the success state.
+ */
+ data class Success(val data: T?) : ScreenState()
+
+ /**
+ * Represents an error state with an exception.
+ *
+ * @property exception The exception associated with the error state.
+ */
+ data class Error(val exception: Throwable) : ScreenState()
+}
\ No newline at end of file
diff --git a/app/src/main/java/pl/lambada/songsync/util/ext/ContextExt.kt b/app/src/main/java/pl/lambada/songsync/util/ext/ContextExt.kt
index df4fe06..5eb362d 100644
--- a/app/src/main/java/pl/lambada/songsync/util/ext/ContextExt.kt
+++ b/app/src/main/java/pl/lambada/songsync/util/ext/ContextExt.kt
@@ -11,5 +11,5 @@ fun Context.getVersion(): String {
} else {
@Suppress("deprecation") packageManager.getPackageInfo(packageName, 0)
}
- return pInfo.versionName
+ return pInfo.versionName.toString()
}
\ No newline at end of file
diff --git a/app/src/main/java/pl/lambada/songsync/util/ui/AnimationSpecs.kt b/app/src/main/java/pl/lambada/songsync/util/ui/AnimationSpecs.kt
new file mode 100644
index 0000000..a3af981
--- /dev/null
+++ b/app/src/main/java/pl/lambada/songsync/util/ui/AnimationSpecs.kt
@@ -0,0 +1,73 @@
+package pl.lambada.songsync.util.ui
+
+import android.graphics.Path
+import android.view.animation.PathInterpolator
+import androidx.compose.animation.BoundsTransform
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.core.ArcMode
+import androidx.compose.animation.core.CubicBezierEasing
+import androidx.compose.animation.core.Easing
+import androidx.compose.animation.core.ExperimentalAnimationSpecApi
+import androidx.compose.animation.core.keyframes
+import androidx.compose.animation.core.tween
+import pl.lambada.songsync.util.ui.MotionConstants.DURATION
+import pl.lambada.songsync.util.ui.MotionConstants.DURATION_ENTER
+import pl.lambada.songsync.util.ui.MotionConstants.DURATION_ENTER_SHORT
+import pl.lambada.songsync.util.ui.MotionConstants.DURATION_EXIT
+import pl.lambada.songsync.util.ui.MotionConstants.DURATION_EXIT_SHORT
+
+fun PathInterpolator.toEasing(): Easing {
+ return Easing { f -> this.getInterpolation(f) }
+}
+
+private val path = Path().apply {
+ moveTo(0f, 0f)
+ cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F)
+ cubicTo(0.208333F, 0.82F, 0.25F, 1F, 1F, 1F)
+}
+
+val EmphasizedPathInterpolator = PathInterpolator(path)
+val EmphasizedEasing = EmphasizedPathInterpolator.toEasing()
+
+val EmphasizeEasingVariant = CubicBezierEasing(.2f, 0f, 0f, 1f)
+val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
+val EmphasizedAccelerate = CubicBezierEasing(0.3f, 0f, 1f, 1f)
+val EmphasizedDecelerateEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
+val EmphasizedAccelerateEasing = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f)
+
+val StandardDecelerate = CubicBezierEasing(.0f, .0f, 0f, 1f)
+val MotionEasingStandard = CubicBezierEasing(0.4F, 0.0F, 0.2F, 1F)
+
+val tweenSpec = tween(durationMillis = DURATION_ENTER, easing = EmphasizedEasing)
+
+fun tweenEnter(
+ delayMillis: Int = DURATION_EXIT,
+ durationMillis: Int = DURATION_ENTER
+) =
+ tween(
+ delayMillis = delayMillis,
+ durationMillis = durationMillis,
+ easing = EmphasizedDecelerateEasing
+ )
+
+fun tweenExit(
+ durationMillis: Int = DURATION_EXIT_SHORT,
+) = tween(
+ durationMillis = durationMillis,
+ easing = EmphasizedAccelerateEasing
+)
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+val DefaultBoundsTransform = BoundsTransform { _, _ ->
+ tween(easing = EmphasizedEasing, durationMillis = DURATION)
+}
+
+@OptIn(ExperimentalAnimationSpecApi::class, ExperimentalSharedTransitionApi::class)
+val SearchFABBoundsTransform = BoundsTransform { initialBounds, targetBounds ->
+ keyframes {
+ durationMillis = DURATION_ENTER_SHORT
+ initialBounds at 0 using ArcMode.ArcBelow using MotionEasingStandard
+ targetBounds at DURATION_ENTER_SHORT using ArcMode.ArcAbove using MotionEasingStandard
+ }
+}
+
diff --git a/app/src/main/java/pl/lambada/songsync/util/ui/MaterialSharedAxis.kt b/app/src/main/java/pl/lambada/songsync/util/ui/MaterialSharedAxis.kt
new file mode 100644
index 0000000..88ece94
--- /dev/null
+++ b/app/src/main/java/pl/lambada/songsync/util/ui/MaterialSharedAxis.kt
@@ -0,0 +1,240 @@
+package pl.lambada.songsync.util.ui
+
+/*
+ * Copyright 2021 SOUP
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.core.FastOutLinearInEasing
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.LinearOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.animation.togetherWith
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+
+
+/**
+ * Returns the provided [Dp] as an [Int] value by the [LocalDensity].
+ *
+ * @param slideDistance Value to the slide distance dimension, 30dp by default.
+ */
+@Composable
+fun rememberSlideDistance(
+ slideDistance: Dp = MotionConstants.DefaultSlideDistance,
+): Int {
+ val density = LocalDensity.current
+ return remember(density, slideDistance) {
+ with(density) { slideDistance.roundToPx() }
+ }
+}
+
+private const val ProgressThreshold = 0.35f
+
+private val Int.ForOutgoing: Int
+ get() = (this * ProgressThreshold).toInt()
+
+private val Int.ForIncoming: Int
+ get() = this - this.ForOutgoing
+
+/**
+ * [materialSharedAxisX] allows to switch a layout with shared X-axis transition.
+ *
+ */
+fun materialSharedAxisX(
+ initialOffsetX: (fullWidth: Int) -> Int,
+ targetOffsetX: (fullWidth: Int) -> Int,
+ durationMillis: Int = MotionConstants.DefaultMotionDuration,
+): ContentTransform = materialSharedAxisXIn(
+ initialOffsetX = initialOffsetX,
+ durationMillis = durationMillis
+) togetherWith materialSharedAxisXOut(
+ targetOffsetX = targetOffsetX,
+ durationMillis = durationMillis
+)
+
+/**
+ * [materialSharedAxisXIn] allows to switch a layout with shared X-axis enter transition.
+ */
+fun materialSharedAxisXIn(
+ initialOffsetX: (fullWidth: Int) -> Int,
+ durationMillis: Int = MotionConstants.DefaultMotionDuration,
+): EnterTransition = slideInHorizontally(
+ animationSpec = tween(
+ durationMillis = durationMillis,
+ easing = FastOutSlowInEasing
+ ),
+ initialOffsetX = initialOffsetX
+) + fadeIn(
+ animationSpec = tween(
+ durationMillis = durationMillis.ForIncoming,
+ delayMillis = durationMillis.ForOutgoing,
+ easing = LinearOutSlowInEasing
+ )
+)
+
+/**
+ * [materialSharedAxisXOut] allows to switch a layout with shared X-axis exit transition.
+ *
+ */
+fun materialSharedAxisXOut(
+ targetOffsetX: (fullWidth: Int) -> Int,
+ durationMillis: Int = MotionConstants.DefaultMotionDuration,
+): ExitTransition = slideOutHorizontally(
+ animationSpec = tween(
+ durationMillis = durationMillis,
+ easing = FastOutSlowInEasing
+ ),
+ targetOffsetX = targetOffsetX
+) + fadeOut(
+ animationSpec = tween(
+ durationMillis = durationMillis.ForOutgoing,
+ delayMillis = 0,
+ easing = FastOutLinearInEasing
+ )
+)
+
+/**
+ * [materialSharedAxisY] allows to switch a layout with shared Y-axis transition.
+ *
+ */
+fun materialSharedAxisY(
+ initialOffsetX: (fullWidth: Int) -> Int,
+ targetOffsetY: (fullWidth: Int) -> Int,
+ durationMillis: Int = MotionConstants.DefaultMotionDuration,
+): ContentTransform = materialSharedAxisYIn(
+ initialOffsetY = initialOffsetX,
+ durationMillis = durationMillis
+) togetherWith materialSharedAxisYOut(
+ targetOffsetY = targetOffsetY,
+ durationMillis = durationMillis
+)
+
+/**
+ * [materialSharedAxisYIn] allows to switch a layout with shared Y-axis enter transition.
+ *
+ */
+fun materialSharedAxisYIn(
+ initialOffsetY: (fullWidth: Int) -> Int,
+ durationMillis: Int = MotionConstants.DefaultMotionDuration,
+): EnterTransition = slideInVertically(
+ animationSpec = tween(
+ durationMillis = durationMillis,
+ easing = FastOutSlowInEasing
+ ),
+ initialOffsetY = initialOffsetY
+) + fadeIn(
+ animationSpec = tween(
+ durationMillis = durationMillis.ForIncoming,
+ delayMillis = durationMillis.ForOutgoing,
+ easing = LinearOutSlowInEasing
+ )
+)
+
+/**
+ * [materialSharedAxisYOut] allows to switch a layout with shared Y-axis exit transition.
+ *
+ */
+fun materialSharedAxisYOut(
+ targetOffsetY: (fullWidth: Int) -> Int,
+ durationMillis: Int = MotionConstants.DefaultMotionDuration,
+): ExitTransition = slideOutVertically(
+ animationSpec = tween(
+ durationMillis = durationMillis,
+ easing = FastOutSlowInEasing
+ ),
+ targetOffsetY = targetOffsetY
+) + fadeOut(
+ animationSpec = tween(
+ durationMillis = durationMillis.ForOutgoing,
+ delayMillis = 0,
+ easing = FastOutLinearInEasing
+ )
+)
+
+/**
+ * [materialSharedAxisZ] allows to switch a layout with shared Z-axis transition.
+ *
+ * @param forward whether the direction of the animation is forward.
+ * @param durationMillis the duration of transition.
+ */
+fun materialSharedAxisZ(
+ forward: Boolean,
+ durationMillis: Int = MotionConstants.DefaultMotionDuration,
+): ContentTransform = materialSharedAxisZIn(
+ forward = forward,
+ durationMillis = durationMillis
+) togetherWith materialSharedAxisZOut(
+ forward = forward,
+ durationMillis = durationMillis
+)
+
+/**
+ * [materialSharedAxisZIn] allows to switch a layout with shared Z-axis enter transition.
+ *
+ * @param forward whether the direction of the animation is forward.
+ * @param durationMillis the duration of the enter transition.
+ */
+fun materialSharedAxisZIn(
+ forward: Boolean,
+ durationMillis: Int = MotionConstants.DefaultMotionDuration,
+): EnterTransition = fadeIn(
+ animationSpec = tween(
+ durationMillis = durationMillis.ForIncoming,
+ delayMillis = durationMillis.ForOutgoing,
+ easing = LinearOutSlowInEasing
+ )
+) + scaleIn(
+ animationSpec = tween(
+ durationMillis = durationMillis,
+ easing = FastOutSlowInEasing
+ ),
+ initialScale = if (forward) 0.8f else 1.1f
+)
+
+/**
+ * [materialSharedAxisZOut] allows to switch a layout with shared Z-axis exit transition.
+ *
+ * @param forward whether the direction of the animation is forward.
+ * @param durationMillis the duration of the exit transition.
+ */
+fun materialSharedAxisZOut(
+ forward: Boolean,
+ durationMillis: Int = MotionConstants.DefaultMotionDuration,
+): ExitTransition = fadeOut(
+ animationSpec = tween(
+ durationMillis = durationMillis.ForOutgoing,
+ delayMillis = 0,
+ easing = FastOutLinearInEasing
+ )
+) + scaleOut(
+ animationSpec = tween(
+ durationMillis = durationMillis,
+ easing = FastOutSlowInEasing
+ ),
+ targetScale = if (forward) 1.1f else 0.8f
+)
diff --git a/app/src/main/java/pl/lambada/songsync/util/ui/MotionConstants.kt b/app/src/main/java/pl/lambada/songsync/util/ui/MotionConstants.kt
new file mode 100644
index 0000000..d0fbf4c
--- /dev/null
+++ b/app/src/main/java/pl/lambada/songsync/util/ui/MotionConstants.kt
@@ -0,0 +1,36 @@
+package pl.lambada.songsync.util.ui
+
+/*
+ * Copyright 2021 SOUP
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+object MotionConstants {
+ const val DefaultMotionDuration: Int = 300
+ const val DefaultFadeInDuration: Int = 150
+ const val DefaultFadeOutDuration: Int = 75
+ val DefaultSlideDistance: Dp = 30.dp
+
+ const val DURATION = 600
+ const val DURATION_ENTER = 400
+ const val DURATION_ENTER_SHORT = 300
+ const val DURATION_EXIT = 200
+ const val DURATION_EXIT_SHORT = 100
+
+ const val InitialOffset = 0.10f
+}
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f7dba66..f91afbb 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -146,4 +146,11 @@
Translation
Help us translate the app to your language!
Open Weblate
+ Showing lyrics for
+ by
+ Accept
+ Song lyrics
+ "Retrieved song lyrics that will be sent to the application "
+ The song name has not been provided
+ An unknown error occurred
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index afa13d7..930927b 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -2,4 +2,13 @@
+
+
\ No newline at end of file