From 87591c41eb843eb1d50d4cd3296126fef553e216 Mon Sep 17 00:00:00 2001 From: Antonio Date: Mon, 4 May 2026 10:33:13 +0200 Subject: [PATCH 1/3] feat: add lyrics feature with repository, UI, and navigation - Implemented lyrics navigation in CoreNavigator and NavGraphs. - Created data transfer objects (DTOs) for lyrics handling including LyricsCheckDto, LyricsDto, LyricsLineDto, LyricsRequestDto, PluginLyricsRequestDto, and PluginLyricsResultDto. - Developed DataLyricsRepository to manage lyrics fetching and searching online. - Added Lyrics and LyricsLine domain models to represent lyrics data. - Introduced LyricsUiState to manage UI state for lyrics display. - Created LyricsViewModel to handle business logic for lyrics. - Designed rough LyricsScreen composable for displaying lyrics with synchronization and online search capabilities. - Updated AppSettings to include preferences for lyrics plugin usage and auto-download settings. --- .../presentation/navigator/CoreNavigator.kt | 7 + .../presentation/navigator/NavGraphs.kt | 2 + .../core/data/dto/LyricsCheckDto.kt | 8 + .../swingmusic/core/data/dto/LyricsDto.kt | 14 + .../swingmusic/core/data/dto/LyricsLineDto.kt | 10 + .../core/data/dto/LyricsRequestDto.kt | 10 + .../core/data/dto/PluginLyricsRequestDto.kt | 16 + .../core/data/dto/PluginLyricsResultDto.kt | 12 + .../swingmusic/core/data/mapper/Mapper.kt | 33 ++ .../swingmusic/core/domain/model/Lyrics.kt | 8 + .../core/domain/model/LyricsLine.kt | 6 + .../presentation/navigator/CommonNavigator.kt | 2 + feature/player/build.gradle.kts | 2 + .../player/data/di/RepositoryModule.kt | 9 + .../data/repository/DataLyricsRepository.kt | 107 ++++++ .../domain/repository/LyricsRepository.kt | 20 + .../presentation/event/LyricsUiEvent.kt | 10 + .../presentation/screen/LyricsScreen.kt | 359 ++++++++++++++++++ .../player/presentation/screen/NowPlaying.kt | 9 +- .../presentation/state/LyricsUiState.kt | 17 + .../presentation/viewmodel/LyricsViewModel.kt | 211 ++++++++++ .../settings/data/datastore/AppSettings.kt | 25 ++ .../repository/AppSettingsDataRepository.kt | 17 + .../repository/AppSettingsRepository.kt | 8 + .../data/api/service/NetworkApiService.kt | 26 ++ 25 files changed, 942 insertions(+), 6 deletions(-) create mode 100644 core/src/main/java/com/android/swingmusic/core/data/dto/LyricsCheckDto.kt create mode 100644 core/src/main/java/com/android/swingmusic/core/data/dto/LyricsDto.kt create mode 100644 core/src/main/java/com/android/swingmusic/core/data/dto/LyricsLineDto.kt create mode 100644 core/src/main/java/com/android/swingmusic/core/data/dto/LyricsRequestDto.kt create mode 100644 core/src/main/java/com/android/swingmusic/core/data/dto/PluginLyricsRequestDto.kt create mode 100644 core/src/main/java/com/android/swingmusic/core/data/dto/PluginLyricsResultDto.kt create mode 100644 core/src/main/java/com/android/swingmusic/core/domain/model/Lyrics.kt create mode 100644 core/src/main/java/com/android/swingmusic/core/domain/model/LyricsLine.kt create mode 100644 feature/player/src/main/java/com/android/swingmusic/player/data/repository/DataLyricsRepository.kt create mode 100644 feature/player/src/main/java/com/android/swingmusic/player/domain/repository/LyricsRepository.kt create mode 100644 feature/player/src/main/java/com/android/swingmusic/player/presentation/event/LyricsUiEvent.kt create mode 100644 feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/LyricsScreen.kt create mode 100644 feature/player/src/main/java/com/android/swingmusic/player/presentation/state/LyricsUiState.kt create mode 100644 feature/player/src/main/java/com/android/swingmusic/player/presentation/viewmodel/LyricsViewModel.kt diff --git a/app/src/main/java/com/android/swingmusic/presentation/navigator/CoreNavigator.kt b/app/src/main/java/com/android/swingmusic/presentation/navigator/CoreNavigator.kt index 41eb420b..1b226173 100644 --- a/app/src/main/java/com/android/swingmusic/presentation/navigator/CoreNavigator.kt +++ b/app/src/main/java/com/android/swingmusic/presentation/navigator/CoreNavigator.kt @@ -9,6 +9,7 @@ import com.android.swingmusic.auth.presentation.screen.destinations.LoginWithUse import com.android.swingmusic.common.presentation.navigator.CommonNavigator import com.android.swingmusic.folder.presentation.screen.destinations.FoldersAndTracksScreenDestination import com.android.swingmusic.home.presentation.destinations.HomeDestination +import com.android.swingmusic.player.presentation.screen.destinations.LyricsScreenDestination import com.android.swingmusic.player.presentation.screen.destinations.QueueScreenDestination import com.android.swingmusic.search.presentation.screen.destinations.ViewAllSearchResultsDestination import com.ramcosta.composedestinations.navigation.navigate @@ -145,4 +146,10 @@ class CoreNavigator( launchSingleTop = true } } + + override fun gotoLyrics() { + navController.navigate(LyricsScreenDestination) { + launchSingleTop = true + } + } } diff --git a/app/src/main/java/com/android/swingmusic/presentation/navigator/NavGraphs.kt b/app/src/main/java/com/android/swingmusic/presentation/navigator/NavGraphs.kt index 97a7a9a2..6a6d06c5 100644 --- a/app/src/main/java/com/android/swingmusic/presentation/navigator/NavGraphs.kt +++ b/app/src/main/java/com/android/swingmusic/presentation/navigator/NavGraphs.kt @@ -9,6 +9,7 @@ import com.android.swingmusic.auth.presentation.screen.destinations.LoginWithQrC import com.android.swingmusic.auth.presentation.screen.destinations.LoginWithUsernameScreenDestination import com.android.swingmusic.folder.presentation.screen.destinations.FoldersAndTracksScreenDestination import com.android.swingmusic.home.presentation.destinations.HomeDestination +import com.android.swingmusic.player.presentation.screen.destinations.LyricsScreenDestination import com.android.swingmusic.player.presentation.screen.destinations.NowPlayingScreenDestination import com.android.swingmusic.player.presentation.screen.destinations.QueueScreenDestination import com.android.swingmusic.search.presentation.screen.destinations.SearchScreenDestination @@ -43,6 +44,7 @@ object NavGraphs { // inner destinations NowPlayingScreenDestination, QueueScreenDestination, + LyricsScreenDestination, AlbumWithInfoScreenDestination, ViewAllScreenOnArtistDestination, ArtistInfoScreenDestination, diff --git a/core/src/main/java/com/android/swingmusic/core/data/dto/LyricsCheckDto.kt b/core/src/main/java/com/android/swingmusic/core/data/dto/LyricsCheckDto.kt new file mode 100644 index 00000000..bd0db58c --- /dev/null +++ b/core/src/main/java/com/android/swingmusic/core/data/dto/LyricsCheckDto.kt @@ -0,0 +1,8 @@ +package com.android.swingmusic.core.data.dto + +import com.google.gson.annotations.SerializedName + +data class LyricsCheckDto( + @SerializedName("exists") + val exists: Boolean? +) diff --git a/core/src/main/java/com/android/swingmusic/core/data/dto/LyricsDto.kt b/core/src/main/java/com/android/swingmusic/core/data/dto/LyricsDto.kt new file mode 100644 index 00000000..df8f26d0 --- /dev/null +++ b/core/src/main/java/com/android/swingmusic/core/data/dto/LyricsDto.kt @@ -0,0 +1,14 @@ +package com.android.swingmusic.core.data.dto + +import com.google.gson.annotations.SerializedName + +data class LyricsDto( + @SerializedName("error") + val error: Boolean?, + @SerializedName("synced") + val synced: Boolean?, + @SerializedName("lyrics") + val lyrics: List?, + @SerializedName("copyright") + val copyright: String? +) diff --git a/core/src/main/java/com/android/swingmusic/core/data/dto/LyricsLineDto.kt b/core/src/main/java/com/android/swingmusic/core/data/dto/LyricsLineDto.kt new file mode 100644 index 00000000..b6753d77 --- /dev/null +++ b/core/src/main/java/com/android/swingmusic/core/data/dto/LyricsLineDto.kt @@ -0,0 +1,10 @@ +package com.android.swingmusic.core.data.dto + +import com.google.gson.annotations.SerializedName + +data class LyricsLineDto( + @SerializedName("time") + val time: Long?, + @SerializedName("text") + val text: String? +) diff --git a/core/src/main/java/com/android/swingmusic/core/data/dto/LyricsRequestDto.kt b/core/src/main/java/com/android/swingmusic/core/data/dto/LyricsRequestDto.kt new file mode 100644 index 00000000..29161e7e --- /dev/null +++ b/core/src/main/java/com/android/swingmusic/core/data/dto/LyricsRequestDto.kt @@ -0,0 +1,10 @@ +package com.android.swingmusic.core.data.dto + +import com.google.gson.annotations.SerializedName + +data class LyricsRequestDto( + @SerializedName("filepath") + val filepath: String, + @SerializedName("trackhash") + val trackhash: String +) diff --git a/core/src/main/java/com/android/swingmusic/core/data/dto/PluginLyricsRequestDto.kt b/core/src/main/java/com/android/swingmusic/core/data/dto/PluginLyricsRequestDto.kt new file mode 100644 index 00000000..8e336bfd --- /dev/null +++ b/core/src/main/java/com/android/swingmusic/core/data/dto/PluginLyricsRequestDto.kt @@ -0,0 +1,16 @@ +package com.android.swingmusic.core.data.dto + +import com.google.gson.annotations.SerializedName + +data class PluginLyricsRequestDto( + @SerializedName("trackhash") + val trackhash: String, + @SerializedName("title") + val title: String, + @SerializedName("artist") + val artist: String, + @SerializedName("filepath") + val filepath: String, + @SerializedName("album") + val album: String +) diff --git a/core/src/main/java/com/android/swingmusic/core/data/dto/PluginLyricsResultDto.kt b/core/src/main/java/com/android/swingmusic/core/data/dto/PluginLyricsResultDto.kt new file mode 100644 index 00000000..06638bd2 --- /dev/null +++ b/core/src/main/java/com/android/swingmusic/core/data/dto/PluginLyricsResultDto.kt @@ -0,0 +1,12 @@ +package com.android.swingmusic.core.data.dto + +import com.google.gson.annotations.SerializedName + +data class PluginLyricsResultDto( + @SerializedName("trackhash") + val trackhash: String?, + @SerializedName("lyrics") + val lyrics: List?, + @SerializedName("error") + val error: String? +) diff --git a/core/src/main/java/com/android/swingmusic/core/data/mapper/Mapper.kt b/core/src/main/java/com/android/swingmusic/core/data/mapper/Mapper.kt index 9e650408..bd360f8d 100644 --- a/core/src/main/java/com/android/swingmusic/core/data/mapper/Mapper.kt +++ b/core/src/main/java/com/android/swingmusic/core/data/mapper/Mapper.kt @@ -18,6 +18,9 @@ import com.android.swingmusic.core.data.dto.FolderDto import com.android.swingmusic.core.data.dto.FoldersAndTracksDto import com.android.swingmusic.core.data.dto.FoldersAndTracksRequestDto import com.android.swingmusic.core.data.dto.GenreDto +import com.android.swingmusic.core.data.dto.LyricsDto +import com.android.swingmusic.core.data.dto.LyricsLineDto +import com.android.swingmusic.core.data.dto.PluginLyricsResultDto import com.android.swingmusic.core.data.dto.RootDirsDto import com.android.swingmusic.core.data.dto.TopResultItemDto import com.android.swingmusic.core.data.dto.TopSearchResultsDto @@ -42,6 +45,8 @@ import com.android.swingmusic.core.domain.model.Folder import com.android.swingmusic.core.domain.model.FoldersAndTracks import com.android.swingmusic.core.domain.model.FoldersAndTracksRequest import com.android.swingmusic.core.domain.model.Genre +import com.android.swingmusic.core.domain.model.Lyrics +import com.android.swingmusic.core.domain.model.LyricsLine import com.android.swingmusic.core.domain.model.RootDirs import com.android.swingmusic.core.domain.model.TopResultItem import com.android.swingmusic.core.domain.model.TopSearchResults @@ -112,6 +117,34 @@ object Map { ) } + fun LyricsLineDto.toLyricsLine(): LyricsLine { + return LyricsLine( + time = time ?: 0L, + text = text ?: "" + ) + } + + fun LyricsDto.toLyrics(): Lyrics { + val lines = lyrics?.map { it.toLyricsLine() } ?: emptyList() + val hasError = error == true + return Lyrics( + synced = synced ?: true, + lines = if (hasError) emptyList() else lines, + copyright = copyright ?: "", + exists = !hasError && lines.isNotEmpty() + ) + } + + fun PluginLyricsResultDto.toLyrics(): Lyrics { + val lines = lyrics?.map { it.toLyricsLine() } ?: emptyList() + return Lyrics( + synced = true, + lines = lines, + copyright = "", + exists = lines.isNotEmpty() + ) + } + fun FoldersAndTracksRequestDto.toFolderAndTracksRequest(): FoldersAndTracksRequest { return FoldersAndTracksRequest( folder = folder ?: "", diff --git a/core/src/main/java/com/android/swingmusic/core/domain/model/Lyrics.kt b/core/src/main/java/com/android/swingmusic/core/domain/model/Lyrics.kt new file mode 100644 index 00000000..a24f2693 --- /dev/null +++ b/core/src/main/java/com/android/swingmusic/core/domain/model/Lyrics.kt @@ -0,0 +1,8 @@ +package com.android.swingmusic.core.domain.model + +data class Lyrics( + val synced: Boolean, + val lines: List, + val copyright: String, + val exists: Boolean +) diff --git a/core/src/main/java/com/android/swingmusic/core/domain/model/LyricsLine.kt b/core/src/main/java/com/android/swingmusic/core/domain/model/LyricsLine.kt new file mode 100644 index 00000000..2f5a1524 --- /dev/null +++ b/core/src/main/java/com/android/swingmusic/core/domain/model/LyricsLine.kt @@ -0,0 +1,6 @@ +package com.android.swingmusic.core.domain.model + +data class LyricsLine( + val time: Long, + val text: String +) diff --git a/feature/common/src/main/java/com/android/swingmusic/common/presentation/navigator/CommonNavigator.kt b/feature/common/src/main/java/com/android/swingmusic/common/presentation/navigator/CommonNavigator.kt index 8a4099be..ee7f7ccb 100644 --- a/feature/common/src/main/java/com/android/swingmusic/common/presentation/navigator/CommonNavigator.kt +++ b/feature/common/src/main/java/com/android/swingmusic/common/presentation/navigator/CommonNavigator.kt @@ -25,4 +25,6 @@ interface CommonNavigator { fun gotoSourceFolder(name: String, path: String) + fun gotoLyrics() + } diff --git a/feature/player/build.gradle.kts b/feature/player/build.gradle.kts index 273c4350..6e1a05ed 100644 --- a/feature/player/build.gradle.kts +++ b/feature/player/build.gradle.kts @@ -46,6 +46,7 @@ dependencies { implementation(project(":uicomponent")) // Common implementation(project(":feature:common")) + implementation(project(":feature:settings")) // Core implementation(libs.androidx.core.ktx) @@ -66,6 +67,7 @@ dependencies { // Hilt DI implementation(libs.hilt.android) ksp(libs.hilt.android.compiler) + implementation(libs.androidx.hilt.navigation.compose) // Retrofit implementation(libs.retrofit) diff --git a/feature/player/src/main/java/com/android/swingmusic/player/data/di/RepositoryModule.kt b/feature/player/src/main/java/com/android/swingmusic/player/data/di/RepositoryModule.kt index e90b0630..49b93602 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/data/di/RepositoryModule.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/data/di/RepositoryModule.kt @@ -1,12 +1,15 @@ package com.android.swingmusic.player.data.di +import com.android.swingmusic.player.data.repository.DataLyricsRepository import com.android.swingmusic.player.data.repository.DataPLayerRepository +import com.android.swingmusic.player.domain.repository.LyricsRepository import com.android.swingmusic.player.domain.repository.PLayerRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) @@ -15,4 +18,10 @@ abstract class RepositoryModule { abstract fun bindQueueRepository( dataQueueRepository: DataPLayerRepository ): PLayerRepository + + @Binds + @Singleton + abstract fun bindLyricsRepository( + dataLyricsRepository: DataLyricsRepository + ): LyricsRepository } diff --git a/feature/player/src/main/java/com/android/swingmusic/player/data/repository/DataLyricsRepository.kt b/feature/player/src/main/java/com/android/swingmusic/player/data/repository/DataLyricsRepository.kt new file mode 100644 index 00000000..750e02c3 --- /dev/null +++ b/feature/player/src/main/java/com/android/swingmusic/player/data/repository/DataLyricsRepository.kt @@ -0,0 +1,107 @@ +package com.android.swingmusic.player.data.repository + +import com.android.swingmusic.auth.data.baseurlholder.BaseUrlHolder +import com.android.swingmusic.auth.data.tokenholder.AuthTokenHolder +import com.android.swingmusic.auth.domain.repository.AuthRepository +import com.android.swingmusic.core.data.dto.LyricsRequestDto +import com.android.swingmusic.core.data.dto.PluginLyricsRequestDto +import com.android.swingmusic.core.data.mapper.Map.toLyrics +import com.android.swingmusic.core.data.util.Resource +import com.android.swingmusic.core.domain.model.Lyrics +import com.android.swingmusic.network.data.api.service.NetworkApiService +import com.android.swingmusic.player.domain.repository.LyricsRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import retrofit2.HttpException +import timber.log.Timber +import java.io.IOException +import javax.inject.Inject + +class DataLyricsRepository @Inject constructor( + private val networkApiService: NetworkApiService, + private val authRepository: AuthRepository +) : LyricsRepository { + + private suspend fun authHeader(): String { + val token = AuthTokenHolder.accessToken ?: authRepository.getAccessToken() + return "Bearer ${token ?: "TOKEN NOT FOUND"}" + } + + private suspend fun baseUrl(): String { + return BaseUrlHolder.baseUrl ?: authRepository.getBaseUrl() ?: "" + } + + override suspend fun getLyrics(filepath: String, trackHash: String): Flow> = + flow { + try { + emit(Resource.Loading()) + val dto = networkApiService.getLyrics( + url = "${baseUrl()}lyrics", + request = LyricsRequestDto(filepath = filepath, trackhash = trackHash), + bearerToken = authHeader() + ) + emit(Resource.Success(dto.toLyrics())) + } catch (e: IOException) { + emit(Resource.Error(message = "Unable to fetch lyrics\nCheck your connection and try again!")) + } catch (e: HttpException) { + emit(Resource.Error(message = "Unable to fetch lyrics")) + } catch (e: Exception) { + Timber.tag("LYRICS").e(e) + emit(Resource.Error(message = "Connection Failed")) + } + } + + override suspend fun checkLyricsExist(filepath: String, trackHash: String): Boolean { + return try { + val dto = networkApiService.checkLyricsExist( + url = "${baseUrl()}lyrics/check", + request = LyricsRequestDto(filepath = filepath, trackhash = trackHash), + bearerToken = authHeader() + ) + dto.exists ?: false + } catch (e: Exception) { + false + } + } + + override suspend fun searchLyricsOnline( + trackHash: String, + title: String, + artist: String, + filepath: String, + album: String + ): Flow> = flow { + try { + emit(Resource.Loading()) + val dto = networkApiService.searchLyricsOnline( + url = "${baseUrl()}plugins/lyrics/search", + request = PluginLyricsRequestDto( + trackhash = trackHash, + title = title, + artist = artist, + filepath = filepath, + album = album + ), + bearerToken = authHeader() + ) + val pluginError = dto.error + if (!pluginError.isNullOrEmpty()) { + emit(Resource.Error(message = pluginError)) + } else { + val lyrics = dto.toLyrics() + if (lyrics.lines.isEmpty()) { + emit(Resource.Error(message = "No lyrics found")) + } else { + emit(Resource.Success(lyrics.copy(exists = true))) + } + } + } catch (e: IOException) { + emit(Resource.Error(message = "Unable to search lyrics\nCheck your connection and try again!")) + } catch (e: HttpException) { + emit(Resource.Error(message = "Unable to search lyrics")) + } catch (e: Exception) { + Timber.tag("LYRICS").e(e) + emit(Resource.Error(message = "Search failed")) + } + } +} diff --git a/feature/player/src/main/java/com/android/swingmusic/player/domain/repository/LyricsRepository.kt b/feature/player/src/main/java/com/android/swingmusic/player/domain/repository/LyricsRepository.kt new file mode 100644 index 00000000..2d94ea80 --- /dev/null +++ b/feature/player/src/main/java/com/android/swingmusic/player/domain/repository/LyricsRepository.kt @@ -0,0 +1,20 @@ +package com.android.swingmusic.player.domain.repository + +import com.android.swingmusic.core.data.util.Resource +import com.android.swingmusic.core.domain.model.Lyrics +import kotlinx.coroutines.flow.Flow + +interface LyricsRepository { + + suspend fun getLyrics(filepath: String, trackHash: String): Flow> + + suspend fun checkLyricsExist(filepath: String, trackHash: String): Boolean + + suspend fun searchLyricsOnline( + trackHash: String, + title: String, + artist: String, + filepath: String, + album: String + ): Flow> +} diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/event/LyricsUiEvent.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/event/LyricsUiEvent.kt new file mode 100644 index 00000000..fc919e20 --- /dev/null +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/event/LyricsUiEvent.kt @@ -0,0 +1,10 @@ +package com.android.swingmusic.player.presentation.event + +import com.android.swingmusic.core.domain.model.Track + +sealed interface LyricsUiEvent { + data class LoadLyrics(val track: Track) : LyricsUiEvent + data class PositionChanged(val positionMs: Long) : LyricsUiEvent + data class SetUserScrolled(val value: Boolean) : LyricsUiEvent + data class SearchOnline(val track: Track) : LyricsUiEvent +} diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/LyricsScreen.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/LyricsScreen.kt new file mode 100644 index 00000000..a17a9344 --- /dev/null +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/LyricsScreen.kt @@ -0,0 +1,359 @@ +package com.android.swingmusic.player.presentation.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.AsyncImage +import coil.request.ImageRequest +import androidx.compose.ui.platform.LocalContext +import com.android.swingmusic.common.presentation.navigator.CommonNavigator +import com.android.swingmusic.core.domain.model.Track +import com.android.swingmusic.player.presentation.event.LyricsUiEvent +import com.android.swingmusic.player.presentation.event.PlayerUiEvent +import com.android.swingmusic.player.presentation.viewmodel.LyricsViewModel +import com.android.swingmusic.player.presentation.viewmodel.MediaControllerViewModel +import com.ramcosta.composedestinations.annotation.Destination + +@Destination +@Composable +fun LyricsScreen( + mediaControllerViewModel: MediaControllerViewModel, + navigator: CommonNavigator, + lyricsViewModel: LyricsViewModel = hiltViewModel() +) { + val playerUiState by mediaControllerViewModel.playerUiState.collectAsState() + val baseUrl by mediaControllerViewModel.baseUrl.collectAsState() + val lyricsState by lyricsViewModel.state.collectAsState() + val track = playerUiState.nowPlayingTrack + + LaunchedEffect(track?.trackHash) { + track?.let { lyricsViewModel.onEvent(LyricsUiEvent.LoadLyrics(it)) } + } + + LaunchedEffect(playerUiState.seekPosition, lyricsState.exists, lyricsState.synced) { + if (lyricsState.exists && lyricsState.synced) { + val positionMs = (playerUiState.seekPosition * 1000F).toLong() + lyricsViewModel.onEvent(LyricsUiEvent.PositionChanged(positionMs)) + } + } + + Scaffold( + topBar = { + LyricsHeader( + track = track, + baseUrl = baseUrl ?: "", + synced = lyricsState.synced, + exists = lyricsState.exists, + onBack = { navigator.navigateBack() }, + onClickArtist = { hash -> navigator.gotoArtistInfo(hash) } + ) + } + ) { padding -> + LyricsBody( + padding = padding, + track = track, + state = lyricsState, + onSeek = { timeMs -> + if (track != null) { + val durationMs = track.duration * 1000F + if (durationMs > 0F) { + val fraction = (timeMs.toFloat() / durationMs).coerceIn(0F, 1F) + mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnSeekPlayBack(fraction)) + } + } + }, + onUserScrolled = { lyricsViewModel.onEvent(LyricsUiEvent.SetUserScrolled(it)) }, + onSearchOnline = { + track?.let { lyricsViewModel.onEvent(LyricsUiEvent.SearchOnline(it)) } + } + ) + } +} + +@Composable +private fun LyricsHeader( + track: Track?, + baseUrl: String, + synced: Boolean, + exists: Boolean, + onBack: () -> Unit, + onClickArtist: (String) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack) { + Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "Back") + } + if (track != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data("${baseUrl}img/thumbnail/${track.image}") + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(8.dp)) + ) + Spacer(Modifier.size(12.dp)) + Column(modifier = Modifier.weight(1F)) { + Text( + text = track.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1 + ) + val artistText = track.trackArtists.joinToString(", ") { it.name } + Text( + text = artistText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7F), + maxLines = 1, + modifier = if (track.trackArtists.isNotEmpty()) { + Modifier.clickable { + onClickArtist(track.trackArtists.first().artistHash) + } + } else Modifier + ) + } + if (exists && !synced) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = "unsynced", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } + } +} + +@Composable +private fun LyricsBody( + padding: PaddingValues, + track: Track?, + state: com.android.swingmusic.player.presentation.state.LyricsUiState, + onSeek: (Long) -> Unit, + onUserScrolled: (Boolean) -> Unit, + onSearchOnline: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + when { + state.isLoading && state.lines.isEmpty() -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + + state.lines.isEmpty() -> { + EmptyLyricsState( + message = state.errorMessage ?: "No lyrics available", + pluginSearching = state.pluginSearching, + pluginError = state.pluginError, + onSearchOnline = onSearchOnline + ) + } + + state.synced -> SyncedLyricsList( + state = state, + onSeek = onSeek, + onUserScrolled = onUserScrolled + ) + + else -> UnsyncedLyricsList(state = state) + } + } +} + +@Composable +private fun SyncedLyricsList( + state: com.android.swingmusic.player.presentation.state.LyricsUiState, + onSeek: (Long) -> Unit, + onUserScrolled: (Boolean) -> Unit +) { + val listState = rememberLazyListState() + + LaunchedEffect(listState.isScrollInProgress) { + if (listState.isScrollInProgress) onUserScrolled(true) + } + + LaunchedEffect(state.currentLine, state.trackHash) { + if (state.currentLine < 0) return@LaunchedEffect + val visible = listState.layoutInfo.visibleItemsInfo + val isCentered = visible.any { it.index == state.currentLine } + && visible.firstOrNull { it.index == state.currentLine }?.let { info -> + val viewportStart = listState.layoutInfo.viewportStartOffset + val viewportEnd = listState.layoutInfo.viewportEndOffset + val third = (viewportEnd - viewportStart) / 3 + info.offset >= viewportStart + third && info.offset <= viewportEnd - third + } == true + + if (!state.userScrolled || !isCentered) { + val target = state.currentLine.coerceAtLeast(0) + val viewportHeight = listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset + listState.animateScrollToItem(target, scrollOffset = -(viewportHeight / 3)) + onUserScrolled(false) + } + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + itemsIndexed(state.lines, key = { i, _ -> i }) { index, line -> + val current = state.currentLine + val alpha = when { + index == current -> 1F + index < current -> 0.55F + index == current + 1 -> 0.9F + index == current + 2 -> 0.8F + else -> 0.7F + } + val color = if (index == current) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = alpha) + } + + Text( + text = line.text.ifBlank { "♪" }, + fontSize = 28.sp, + fontWeight = if (index == current) FontWeight.Bold else FontWeight.SemiBold, + color = color, + modifier = Modifier + .fillMaxWidth() + .clickable { onSeek(line.time) } + .padding(vertical = 4.dp) + ) + } + if (state.copyright.isNotBlank()) { + item { + Text( + text = state.copyright, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5F), + modifier = Modifier.padding(top = 24.dp) + ) + } + } + item { Spacer(Modifier.height(128.dp)) } + } +} + +@Composable +private fun UnsyncedLyricsList( + state: com.android.swingmusic.player.presentation.state.LyricsUiState +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(state.lines) { line -> + Text( + text = line.text.ifBlank { " " }, + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } + if (state.copyright.isNotBlank()) { + item { + Text( + text = state.copyright, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5F) + ) + } + } + item { Spacer(Modifier.height(128.dp)) } + } +} + +@Composable +private fun EmptyLyricsState( + message: String, + pluginSearching: Boolean, + pluginError: String?, + onSearchOnline: () -> Unit +) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7F) + ) + Spacer(Modifier.height(16.dp)) + if (pluginSearching) { + CircularProgressIndicator() + Spacer(Modifier.height(8.dp)) + Text("Searching online…", style = MaterialTheme.typography.bodySmall) + } else if (!pluginError.isNullOrBlank()) { + Text( + text = pluginError, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } else { + TextButton(onClick = onSearchOnline) { + Text("Search online") + } + } + } + } +} diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/NowPlaying.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/NowPlaying.kt index c70e2a74..97c9c505 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/NowPlaying.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/NowPlaying.kt @@ -540,15 +540,14 @@ private fun NowPlaying( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - // TODO: Return this when lyrics is ready - /*IconButton(onClick = { + IconButton(onClick = { onClickLyricsIcon() }) { Icon( painter = painterResource(id = R.drawable.lyrics_icon), contentDescription = "Lyrics" ) - }*/ + } IconButton(onClick = { onToggleRepeatMode(repeatMode) @@ -672,9 +671,7 @@ fun NowPlayingScreen( ) }, onClickLyricsIcon = { - mediaControllerViewModel.onPlayerUiEvent( - PlayerUiEvent.OnClickLyricsIcon - ) + navigator.gotoLyrics() }, onToggleFavorite = { isFavorite, trackHash -> mediaControllerViewModel.onPlayerUiEvent( diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/state/LyricsUiState.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/state/LyricsUiState.kt new file mode 100644 index 00000000..163d0f8d --- /dev/null +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/state/LyricsUiState.kt @@ -0,0 +1,17 @@ +package com.android.swingmusic.player.presentation.state + +import com.android.swingmusic.core.domain.model.LyricsLine + +data class LyricsUiState( + val lines: List = emptyList(), + val synced: Boolean = true, + val exists: Boolean = false, + val copyright: String = "", + val currentLine: Int = -1, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val pluginSearching: Boolean = false, + val pluginError: String? = null, + val userScrolled: Boolean = false, + val trackHash: String = "" +) diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/viewmodel/LyricsViewModel.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/viewmodel/LyricsViewModel.kt new file mode 100644 index 00000000..521924b7 --- /dev/null +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/viewmodel/LyricsViewModel.kt @@ -0,0 +1,211 @@ +package com.android.swingmusic.player.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.swingmusic.core.data.util.Resource +import com.android.swingmusic.core.domain.model.Lyrics +import com.android.swingmusic.core.domain.model.Track +import com.android.swingmusic.player.domain.repository.LyricsRepository +import com.android.swingmusic.player.presentation.event.LyricsUiEvent +import com.android.swingmusic.player.presentation.state.LyricsUiState +import com.android.swingmusic.settings.domain.repository.AppSettingsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LyricsViewModel @Inject constructor( + private val lyricsRepository: LyricsRepository, + private val settings: AppSettingsRepository +) : ViewModel() { + + private val _state = MutableStateFlow(LyricsUiState()) + val state: StateFlow get() = _state + + private var advanceJob: Job? = null + private var fetchJob: Job? = null + + fun onEvent(event: LyricsUiEvent) { + when (event) { + is LyricsUiEvent.LoadLyrics -> loadLyrics(event.track) + is LyricsUiEvent.PositionChanged -> onPosition(event.positionMs) + is LyricsUiEvent.SetUserScrolled -> _state.update { it.copy(userScrolled = event.value) } + is LyricsUiEvent.SearchOnline -> searchOnline(event.track) + } + } + + private fun loadLyrics(track: Track) { + if (track.trackHash == _state.value.trackHash && _state.value.lines.isNotEmpty()) return + + cancelTimers() + _state.update { + LyricsUiState( + isLoading = true, + trackHash = track.trackHash + ) + } + + fetchJob?.cancel() + fetchJob = viewModelScope.launch { + lyricsRepository.getLyrics(track.filepath, track.trackHash).collect { resource -> + when (resource) { + is Resource.Loading -> _state.update { it.copy(isLoading = true) } + + is Resource.Success -> { + val lyrics: Lyrics = resource.data ?: Lyrics(true, emptyList(), "", false) + _state.update { + it.copy( + lines = lyrics.lines, + synced = lyrics.synced, + exists = lyrics.exists, + copyright = lyrics.copyright, + isLoading = false, + errorMessage = null, + currentLine = -1 + ) + } + maybeAutoSearchOnline(track, lyrics) + } + + is Resource.Error -> { + _state.update { + it.copy( + lines = emptyList(), + exists = false, + isLoading = false, + errorMessage = resource.message + ) + } + maybeAutoSearchOnline(track, null) + } + } + } + } + } + + private suspend fun maybeAutoSearchOnline(track: Track, lyrics: Lyrics?) { + val pluginEnabled = settings.useLyricsPlugin.first() + if (!pluginEnabled) return + + val autoDownload = settings.lyricsAutoDownload.first() + val overrideUnsynced = settings.lyricsOverrideUnsynced.first() + + val noLocalLyrics = lyrics == null || !lyrics.exists || lyrics.lines.isEmpty() + val unsyncedAndOverride = lyrics != null && lyrics.exists && !lyrics.synced && overrideUnsynced + + if ((noLocalLyrics && autoDownload) || unsyncedAndOverride) { + searchOnline(track) + } + } + + private fun searchOnline(track: Track) { + if (_state.value.pluginSearching) return + _state.update { it.copy(pluginSearching = true, pluginError = null) } + + viewModelScope.launch { + val artistName = track.trackArtists.joinToString(", ") { it.name } + lyricsRepository.searchLyricsOnline( + trackHash = track.trackHash, + title = track.title, + artist = artistName, + filepath = track.filepath, + album = track.album + ).collect { resource -> + when (resource) { + is Resource.Loading -> Unit + + is Resource.Success -> { + if (track.trackHash != _state.value.trackHash) { + _state.update { it.copy(pluginSearching = false) } + return@collect + } + val lyrics = resource.data ?: return@collect + _state.update { + it.copy( + lines = lyrics.lines, + synced = lyrics.synced, + exists = true, + copyright = lyrics.copyright, + pluginSearching = false, + pluginError = null, + errorMessage = null, + currentLine = -1 + ) + } + } + + is Resource.Error -> { + _state.update { + it.copy( + pluginSearching = false, + pluginError = resource.message + ) + } + delay(5_000) + _state.update { it.copy(pluginError = null) } + } + } + } + } + } + + private fun onPosition(positionMs: Long) { + val s = _state.value + if (!s.exists || !s.synced || s.lines.isEmpty()) return + + val newLine = calculateLineIndex(s.lines, positionMs) + if (newLine != s.currentLine) { + advanceJob?.cancel() + _state.update { it.copy(currentLine = newLine) } + } + + scheduleNextLine(positionMs) + } + + private fun scheduleNextLine(positionMs: Long) { + val s = _state.value + val nextIndex = s.currentLine + 1 + if (nextIndex !in s.lines.indices) return + val nextTime = s.lines[nextIndex].time + val diff = nextTime - positionMs + if (diff !in 0..1200) return + if (advanceJob?.isActive == true) return + + advanceJob = viewModelScope.launch { + val sleep = (diff - 300).coerceAtLeast(0) + delay(sleep) + val current = _state.value + if (current.trackHash != s.trackHash) return@launch + val next = current.currentLine + 1 + if (next in current.lines.indices) { + _state.update { it.copy(currentLine = next) } + } + } + } + + private fun calculateLineIndex(lines: List, positionMs: Long): Int { + if (lines.isEmpty()) return -1 + var idx = -1 + for (i in lines.indices) { + if (lines[i].time <= positionMs) idx = i else break + } + return idx + } + + private fun cancelTimers() { + advanceJob?.cancel() + advanceJob = null + } + + override fun onCleared() { + cancelTimers() + fetchJob?.cancel() + super.onCleared() + } +} diff --git a/feature/settings/src/main/java/com/android/swingmusic/settings/data/datastore/AppSettings.kt b/feature/settings/src/main/java/com/android/swingmusic/settings/data/datastore/AppSettings.kt index c1e1e525..ce1b676c 100644 --- a/feature/settings/src/main/java/com/android/swingmusic/settings/data/datastore/AppSettings.kt +++ b/feature/settings/src/main/java/com/android/swingmusic/settings/data/datastore/AppSettings.kt @@ -3,6 +3,7 @@ package com.android.swingmusic.settings.data.datastore import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey @@ -29,6 +30,10 @@ class AppSettings @Inject constructor( private val ARTIST_GRID_COUNT = intPreferencesKey("artist_grid_count") private val ARTIST_SORT_BY = stringPreferencesKey("artist_sort_by") private val ARTIST_SORT_ORDER = stringPreferencesKey("artist_sort_order") + + private val USE_LYRICS_PLUGIN = booleanPreferencesKey("use_lyrics_plugin") + private val LYRICS_AUTO_DOWNLOAD = booleanPreferencesKey("lyrics_auto_download") + private val LYRICS_OVERRIDE_UNSYNCED = booleanPreferencesKey("lyrics_override_unsynced") } // Album Flows @@ -72,4 +77,24 @@ class AppSettings @Inject constructor( suspend fun updateArtistSortOrder(value: String) = context.dataStore.edit { it[ARTIST_SORT_ORDER] = value } + + // Lyrics Flows + val getUseLyricsPlugin: Flow = context.dataStore.data.map { + it[USE_LYRICS_PLUGIN] ?: false + } + val getLyricsAutoDownload: Flow = context.dataStore.data.map { + it[LYRICS_AUTO_DOWNLOAD] ?: false + } + val getLyricsOverrideUnsynced: Flow = context.dataStore.data.map { + it[LYRICS_OVERRIDE_UNSYNCED] ?: false + } + + suspend fun updateUseLyricsPlugin(value: Boolean) = + context.dataStore.edit { it[USE_LYRICS_PLUGIN] = value } + + suspend fun updateLyricsAutoDownload(value: Boolean) = + context.dataStore.edit { it[LYRICS_AUTO_DOWNLOAD] = value } + + suspend fun updateLyricsOverrideUnsynced(value: Boolean) = + context.dataStore.edit { it[LYRICS_OVERRIDE_UNSYNCED] = value } } diff --git a/feature/settings/src/main/java/com/android/swingmusic/settings/data/repository/AppSettingsDataRepository.kt b/feature/settings/src/main/java/com/android/swingmusic/settings/data/repository/AppSettingsDataRepository.kt index 9294f7b8..bf7f590d 100644 --- a/feature/settings/src/main/java/com/android/swingmusic/settings/data/repository/AppSettingsDataRepository.kt +++ b/feature/settings/src/main/java/com/android/swingmusic/settings/data/repository/AppSettingsDataRepository.kt @@ -59,4 +59,21 @@ class AppSettingsDataRepository @Inject constructor( override suspend fun setArtistSortOrder(order: SortOrder) { appSettings.updateArtistSortOrder(order.name) } + + // --- Lyrics Settings --- + override val useLyricsPlugin: Flow = appSettings.getUseLyricsPlugin + override val lyricsAutoDownload: Flow = appSettings.getLyricsAutoDownload + override val lyricsOverrideUnsynced: Flow = appSettings.getLyricsOverrideUnsynced + + override suspend fun setUseLyricsPlugin(enabled: Boolean) { + appSettings.updateUseLyricsPlugin(enabled) + } + + override suspend fun setLyricsAutoDownload(enabled: Boolean) { + appSettings.updateLyricsAutoDownload(enabled) + } + + override suspend fun setLyricsOverrideUnsynced(enabled: Boolean) { + appSettings.updateLyricsOverrideUnsynced(enabled) + } } diff --git a/feature/settings/src/main/java/com/android/swingmusic/settings/domain/repository/AppSettingsRepository.kt b/feature/settings/src/main/java/com/android/swingmusic/settings/domain/repository/AppSettingsRepository.kt index d94d656b..02e540c4 100644 --- a/feature/settings/src/main/java/com/android/swingmusic/settings/domain/repository/AppSettingsRepository.kt +++ b/feature/settings/src/main/java/com/android/swingmusic/settings/domain/repository/AppSettingsRepository.kt @@ -20,4 +20,12 @@ interface AppSettingsRepository { suspend fun setArtistGridCount(count: Int) suspend fun setArtistSortBy(sortBy: SortBy) suspend fun setArtistSortOrder(order: SortOrder) + + val useLyricsPlugin: Flow + val lyricsAutoDownload: Flow + val lyricsOverrideUnsynced: Flow + + suspend fun setUseLyricsPlugin(enabled: Boolean) + suspend fun setLyricsAutoDownload(enabled: Boolean) + suspend fun setLyricsOverrideUnsynced(enabled: Boolean) } diff --git a/network/src/main/java/com/android/swingmusic/network/data/api/service/NetworkApiService.kt b/network/src/main/java/com/android/swingmusic/network/data/api/service/NetworkApiService.kt index bc406dc3..2fb6d797 100644 --- a/network/src/main/java/com/android/swingmusic/network/data/api/service/NetworkApiService.kt +++ b/network/src/main/java/com/android/swingmusic/network/data/api/service/NetworkApiService.kt @@ -9,6 +9,11 @@ import com.android.swingmusic.core.data.dto.ArtistInfoDto import com.android.swingmusic.core.data.dto.ArtistsSearchResultDto import com.android.swingmusic.core.data.dto.FoldersAndTracksDto import com.android.swingmusic.core.data.dto.FoldersAndTracksRequestDto +import com.android.swingmusic.core.data.dto.LyricsCheckDto +import com.android.swingmusic.core.data.dto.LyricsDto +import com.android.swingmusic.core.data.dto.LyricsRequestDto +import com.android.swingmusic.core.data.dto.PluginLyricsRequestDto +import com.android.swingmusic.core.data.dto.PluginLyricsResultDto import com.android.swingmusic.core.data.dto.TopSearchResultsDto import com.android.swingmusic.core.data.dto.TrackDto import com.android.swingmusic.core.data.dto.TracksSearchResultDto @@ -161,4 +166,25 @@ interface NetworkApiService { @Query("itemtype") itemType: String = "artists", @Query("q") searchParams: String ): ArtistsSearchResultDto + + @POST + suspend fun getLyrics( + @Url url: String, + @Body request: LyricsRequestDto, + @Header("Authorization") bearerToken: String + ): LyricsDto + + @POST + suspend fun checkLyricsExist( + @Url url: String, + @Body request: LyricsRequestDto, + @Header("Authorization") bearerToken: String + ): LyricsCheckDto + + @POST + suspend fun searchLyricsOnline( + @Url url: String, + @Body request: PluginLyricsRequestDto, + @Header("Authorization") bearerToken: String + ): PluginLyricsResultDto } From cafe172c3bc396f9c82ad3d1c4240d5a0e88b381 Mon Sep 17 00:00:00 2001 From: Joel Kanyi Date: Mon, 8 Jun 2026 20:16:23 +0300 Subject: [PATCH 2/3] wire lyrics POC into the animated player and fix sync (#113) The POC modified NowPlaying.kt which isn't the active screen post-#106. Move the lyrics icon into AnimatedPlayerSheet's bottom control row and collapse the sheet on tap so the lyrics screen is actually visible. Other fixes spotted while testing: - LyricsScreen was computing positionMs as seekPosition*1000, but seekPosition is a 0..1 fraction, so the value passed to PositionChanged maxed at 1000ms and the highlight never advanced past line 0. Multiply by track.duration too. - LyricsLineDto.time is Double. The server returns fractional ms like 128021.99999999999 which Gson refused to parse into Long, killing every lyrics fetch. - Drop the 300ms look-ahead in scheduleNextLine. Lines now advance at the actual line timestamp, which matches the audio for tracks with good LRC data. - Replace deprecated Icons.Default.ArrowBack with the AutoMirrored variant. --- .../swingmusic/core/data/dto/LyricsLineDto.kt | 2 +- .../swingmusic/core/data/mapper/Mapper.kt | 2 +- .../presentation/screen/AnimatedPlayerSheet.kt | 16 +++++++++++++++- .../player/presentation/screen/LyricsScreen.kt | 10 ++++++---- .../presentation/viewmodel/LyricsViewModel.kt | 2 +- 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/com/android/swingmusic/core/data/dto/LyricsLineDto.kt b/core/src/main/java/com/android/swingmusic/core/data/dto/LyricsLineDto.kt index b6753d77..42661fa5 100644 --- a/core/src/main/java/com/android/swingmusic/core/data/dto/LyricsLineDto.kt +++ b/core/src/main/java/com/android/swingmusic/core/data/dto/LyricsLineDto.kt @@ -4,7 +4,7 @@ import com.google.gson.annotations.SerializedName data class LyricsLineDto( @SerializedName("time") - val time: Long?, + val time: Double?, @SerializedName("text") val text: String? ) diff --git a/core/src/main/java/com/android/swingmusic/core/data/mapper/Mapper.kt b/core/src/main/java/com/android/swingmusic/core/data/mapper/Mapper.kt index bd360f8d..a4e3261a 100644 --- a/core/src/main/java/com/android/swingmusic/core/data/mapper/Mapper.kt +++ b/core/src/main/java/com/android/swingmusic/core/data/mapper/Mapper.kt @@ -119,7 +119,7 @@ object Map { fun LyricsLineDto.toLyricsLine(): LyricsLine { return LyricsLine( - time = time ?: 0L, + time = (time ?: 0.0).toLong(), text = text ?: "" ) } diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt index a8bb55a2..6a28b8c0 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt @@ -338,6 +338,12 @@ fun AnimatedPlayerSheet( }, onAllowSheetClose = { allowSheetClose = true + }, + onClickLyricsIcon = { + coroutineScope.launch { + bottomSheetState.bottomSheetState.partialExpand() + navigator.gotoLyrics() + } } ) } @@ -420,7 +426,8 @@ private fun AnimatedSheetContent( onToggleShuffleMode: () -> Unit, onSeekPlayBack: (Float) -> Unit, onToggleFavorite: (Boolean, String) -> Unit, - onAllowSheetClose: () -> Unit + onAllowSheetClose: () -> Unit, + onClickLyricsIcon: () -> Unit ) { val coroutineScope = rememberCoroutineScope() val configuration = LocalConfiguration.current @@ -1224,6 +1231,13 @@ private fun AnimatedSheetContent( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { + IconButton(onClick = { onClickLyricsIcon() }) { + Icon( + painter = painterResource(id = R.drawable.lyrics_icon), + contentDescription = "Lyrics" + ) + } + IconButton(onClick = { onToggleRepeatMode() }) { Icon( painter = painterResource(id = repeatModeIcon), diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/LyricsScreen.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/LyricsScreen.kt index a17a9344..e0fd50d3 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/LyricsScreen.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/LyricsScreen.kt @@ -13,13 +13,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -68,8 +69,8 @@ fun LyricsScreen( } LaunchedEffect(playerUiState.seekPosition, lyricsState.exists, lyricsState.synced) { - if (lyricsState.exists && lyricsState.synced) { - val positionMs = (playerUiState.seekPosition * 1000F).toLong() + if (lyricsState.exists && lyricsState.synced && track != null) { + val positionMs = (playerUiState.seekPosition * track.duration * 1000F).toLong() lyricsViewModel.onEvent(LyricsUiEvent.PositionChanged(positionMs)) } } @@ -119,11 +120,12 @@ private fun LyricsHeader( Row( modifier = Modifier .fillMaxWidth() + .statusBarsPadding() .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = onBack) { - Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "Back") + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } if (track != null) { AsyncImage( diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/viewmodel/LyricsViewModel.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/viewmodel/LyricsViewModel.kt index 521924b7..007b6d1d 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/viewmodel/LyricsViewModel.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/viewmodel/LyricsViewModel.kt @@ -178,7 +178,7 @@ class LyricsViewModel @Inject constructor( if (advanceJob?.isActive == true) return advanceJob = viewModelScope.launch { - val sleep = (diff - 300).coerceAtLeast(0) + val sleep = diff.coerceAtLeast(0) delay(sleep) val current = _state.value if (current.trackHash != s.trackHash) return@launch From a07ee302b236e9f9ede997d54cc6edae6c4886fc Mon Sep 17 00:00:00 2001 From: Joel Kanyi Date: Tue, 9 Jun 2026 18:14:08 +0300 Subject: [PATCH 3/3] feat: Enhance lyric animations and styling in LyricsScreen - Implement smooth scaling and color transitions for lyric lines using `animateFloatAsState` and `animateColorAsState`. - Add a dynamic scaling effect where the active lyric line is larger than surrounding lines. - Refine alpha transparency logic to better distinguish between past, active, and upcoming lines. - Increase font size to 32.sp and adjust line height for improved readability. - Reduce vertical spacing between lines in the `LazyColumn`. - Apply `graphicsLayer` with a start-aligned `TransformOrigin` to anchor the scaling effect. --- .../presentation/screen/LyricsScreen.kt | 57 ++++++++++++++----- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/LyricsScreen.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/LyricsScreen.kt index e0fd50d3..b1971e18 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/LyricsScreen.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/LyricsScreen.kt @@ -1,5 +1,9 @@ package com.android.swingmusic.player.presentation.screen +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -36,6 +40,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -51,6 +57,7 @@ import com.android.swingmusic.player.presentation.event.PlayerUiEvent import com.android.swingmusic.player.presentation.viewmodel.LyricsViewModel import com.android.swingmusic.player.presentation.viewmodel.MediaControllerViewModel import com.ramcosta.composedestinations.annotation.Destination +import kotlin.math.abs @Destination @Composable @@ -254,31 +261,53 @@ private fun SyncedLyricsList( state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 24.dp, vertical = 32.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { itemsIndexed(state.lines, key = { i, _ -> i }) { index, line -> val current = state.currentLine - val alpha = when { - index == current -> 1F - index < current -> 0.55F - index == current + 1 -> 0.9F - index == current + 2 -> 0.8F - else -> 0.7F + val isActive = index == current + val distance = if (current < 0) Int.MAX_VALUE else abs(index - current) + val isPast = current >= 0 && index < current + + val targetScale = when { + isActive -> 1F + distance == 1 -> 0.72F + distance == 2 -> 0.62F + else -> 0.56F } - val color = if (index == current) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = alpha) + val targetAlpha = when { + isActive -> 1F + isPast -> 0.35F + distance == 1 -> 0.75F + distance == 2 -> 0.55F + else -> 0.4F } + val animatedScale by animateFloatAsState( + targetValue = targetScale, + animationSpec = tween(durationMillis = 450, easing = FastOutSlowInEasing), + label = "lyricScale" + ) + val animatedColor by animateColorAsState( + targetValue = MaterialTheme.colorScheme.onSurface.copy(alpha = targetAlpha), + animationSpec = tween(durationMillis = 450, easing = FastOutSlowInEasing), + label = "lyricColor" + ) + Text( text = line.text.ifBlank { "♪" }, - fontSize = 28.sp, - fontWeight = if (index == current) FontWeight.Bold else FontWeight.SemiBold, - color = color, + fontSize = 32.sp, + lineHeight = 40.sp, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.SemiBold, + color = animatedColor, modifier = Modifier .fillMaxWidth() .clickable { onSeek(line.time) } + .graphicsLayer { + scaleX = animatedScale + scaleY = animatedScale + transformOrigin = TransformOrigin(0F, 0.5F) + } .padding(vertical = 4.dp) ) }