From 13f7ab6e5fa521b62a9fd31a1cefdc2787a1a8af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20=C4=90=E1=BB=A9c=20Tu=E1=BA=A5n=20Minh?= Date: Sun, 5 Nov 2023 20:37:20 +0700 Subject: [PATCH] Add support Android Auto Success --- app/src/main/AndroidManifest.xml | 33 +- .../simpmusic/di/MusicServiceModule.kt | 6 +- .../com/maxrave/simpmusic/extension/AllExt.kt | 69 +++- .../simpmusic/service/SimpleMediaService.kt | 44 ++- .../service/SimpleMediaSessionCallback.kt | 229 +++++++++++- .../com/maxrave/simpmusic/ui/MainActivity.kt | 89 ++++- .../ui/fragment/player/NowPlayingFragment.kt | 325 +++++++++++++----- .../simpmusic/viewModel/SharedViewModel.kt | 47 ++- .../main/res/layout/fragment_downloaded.xml | 21 +- app/src/main/res/layout/fragment_favorite.xml | 5 +- app/src/main/res/layout/fragment_followed.xml | 5 +- .../main/res/layout/fragment_most_played.xml | 3 +- app/src/main/res/layout/fragment_playlist.xml | 3 +- app/src/main/res/layout/fragment_podcast.xml | 3 +- .../res/layout/fragment_recently_songs.xml | 31 +- app/src/main/res/xml/automotive_app_desc.xml | 3 +- .../metadata/android/en-US/changelogs/11.txt | 0 .../android/en-US/full_description.txt | 6 +- .../android/vi-VN/full_description.txt | 4 +- 19 files changed, 751 insertions(+), 175 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/11.txt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0cb00c30..1814514e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,6 +18,7 @@ android:usesCleartextTraffic="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:appCategory="audio" android:supportsRtl="true" android:localeConfig="@xml/locale_config" android:networkSecurityConfig="@xml/network_security_config" @@ -124,16 +125,24 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/provider_paths" /> + + + + android:enabled="true" + android:exported="true"> - + + + - + + + + + + + + diff --git a/app/src/main/java/com/maxrave/simpmusic/di/MusicServiceModule.kt b/app/src/main/java/com/maxrave/simpmusic/di/MusicServiceModule.kt index 6a4e6f75..03aebce7 100644 --- a/app/src/main/java/com/maxrave/simpmusic/di/MusicServiceModule.kt +++ b/app/src/main/java/com/maxrave/simpmusic/di/MusicServiceModule.kt @@ -8,6 +8,7 @@ import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.NoOpCacheEvictor import androidx.media3.datasource.cache.SimpleCache import com.maxrave.simpmusic.data.dataStore.DataStoreManager +import com.maxrave.simpmusic.data.repository.MainRepository import com.maxrave.simpmusic.service.SimpleMediaSessionCallback import dagger.Module import dagger.Provides @@ -65,5 +66,8 @@ object MusicServiceModule { @Provides @Singleton - fun provideMediaSessionCallback() : SimpleMediaSessionCallback = SimpleMediaSessionCallback() + fun provideMediaSessionCallback( + @ApplicationContext context: Context, + mainRepository: MainRepository + ): SimpleMediaSessionCallback = SimpleMediaSessionCallback(context, mainRepository) } \ No newline at end of file diff --git a/app/src/main/java/com/maxrave/simpmusic/extension/AllExt.kt b/app/src/main/java/com/maxrave/simpmusic/extension/AllExt.kt index b57e9b69..60545c60 100644 --- a/app/src/main/java/com/maxrave/simpmusic/extension/AllExt.kt +++ b/app/src/main/java/com/maxrave/simpmusic/extension/AllExt.kt @@ -9,6 +9,7 @@ import android.text.Html import android.view.View import android.view.ViewGroup import android.widget.ImageButton +import android.widget.TextView import androidx.core.net.toUri import androidx.datastore.preferences.preferencesDataStore import androidx.lifecycle.LiveData @@ -299,8 +300,28 @@ fun MediaItem?.toSongEntity(): SongEntity? { downloadState = 0 ) else null } + +@JvmName("MediaItemtoSongEntity") +@UnstableApi +fun SongEntity.toMediaItem(): MediaItem { + return MediaItem.Builder() + .setMediaId(this.videoId) + .setUri(this.videoId) + .setCustomCacheKey(this.videoId) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(this.title) + .setArtist(this.artistName?.connectArtists()) + .setArtworkUri(this.thumbnails?.toUri()) + .setAlbumTitle(this.albumName) + .build() + ) + .build() +} + +@JvmName("TracktoMediaItem") @UnstableApi -fun Track.toMediaItem() : MediaItem { +fun Track.toMediaItem(): MediaItem { return MediaItem.Builder() .setMediaId(this.videoId) .setUri(this.videoId) @@ -649,6 +670,52 @@ fun List.toListTrack(): ArrayList { return listTrack } +fun TextView.setTextAnimation( + text: String, + duration: Long = 300, + completion: (() -> Unit)? = null +) { + if (text != "null") { + fadOutAnimation(duration) { + this.text = text + fadInAnimation(duration) { + completion?.let { + it() + } + } + } + } +} + +fun View.fadOutAnimation( + duration: Long = 300, + visibility: Int = View.INVISIBLE, + completion: (() -> Unit)? = null +) { + animate() + .alpha(0f) + .setDuration(duration) + .withEndAction { + this.visibility = visibility + completion?.let { + it() + } + } +} + +fun View.fadInAnimation(duration: Long = 300, completion: (() -> Unit)? = null) { + alpha = 0f + visibility = View.VISIBLE + animate() + .alpha(1f) + .setDuration(duration) + .withEndAction { + completion?.let { + it() + } + } +} + operator fun File.div(child: String): File = File(this, child) fun String.toSQLiteQuery(): SimpleSQLiteQuery = SimpleSQLiteQuery(this) fun InputStream.zipInputStream(): ZipInputStream = ZipInputStream(this) diff --git a/app/src/main/java/com/maxrave/simpmusic/service/SimpleMediaService.kt b/app/src/main/java/com/maxrave/simpmusic/service/SimpleMediaService.kt index 86235b7b..ceff14a1 100644 --- a/app/src/main/java/com/maxrave/simpmusic/service/SimpleMediaService.kt +++ b/app/src/main/java/com/maxrave/simpmusic/service/SimpleMediaService.kt @@ -9,9 +9,6 @@ import android.os.Binder import android.os.IBinder import android.util.Log import androidx.core.net.toUri -import androidx.media3.cast.CastPlayer -import androidx.media3.cast.DefaultMediaItemConverter -import androidx.media3.cast.SessionAvailabilityListener import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.Player @@ -34,11 +31,9 @@ import androidx.media3.extractor.mkv.MatroskaExtractor import androidx.media3.extractor.mp4.FragmentedMp4Extractor import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.MediaController +import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession -import androidx.media3.session.MediaSessionService import androidx.media3.session.SessionToken -import coil.ImageLoader -import com.google.android.gms.cast.framework.CastContext import com.google.common.util.concurrent.MoreExecutors import com.maxrave.simpmusic.R import com.maxrave.simpmusic.common.MEDIA_NOTIFICATION @@ -54,16 +49,15 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking -import java.util.concurrent.Executors import javax.inject.Inject @AndroidEntryPoint @UnstableApi -class SimpleMediaService : MediaSessionService() { +class SimpleMediaService : MediaLibraryService() { lateinit var player: ExoPlayer - lateinit var mediaSession: MediaSession + lateinit var mediaSession: MediaLibrarySession @Inject lateinit var dataStoreManager: DataStoreManager @@ -104,10 +98,11 @@ class SimpleMediaService : MediaSessionService() { .setRenderersFactory(provideRendererFactory(this)) .build() - mediaSession = provideMediaSession( - context = this, - player = player, - callback = simpleMediaSessionCallback, + mediaSession = provideMediaLibrarySession( + this, + this, + player, + simpleMediaSessionCallback ) val sessionToken = SessionToken(this, ComponentName(this, SimpleMediaService::class.java)) val controllerFuture = MediaController.Builder(this, sessionToken).buildAsync() @@ -119,7 +114,7 @@ class SimpleMediaService : MediaSessionService() { return super.onStartCommand(intent, flags, startId) } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession = + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = mediaSession @UnstableApi @@ -284,9 +279,28 @@ class SimpleMediaService : MediaSessionService() { @UnstableApi fun provideCoilBitmapLoader(context: Context): CoilBitmapLoader = CoilBitmapLoader(context) + + @UnstableApi + fun provideMediaLibrarySession( + context: Context, + service: MediaLibraryService, + player: ExoPlayer, + callback: SimpleMediaSessionCallback + ): MediaLibrarySession = MediaLibrarySession.Builder( + service, player, callback + ) + .setSessionActivity( + PendingIntent.getActivity( + context, 0, Intent(context, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE + ) + ) + .setBitmapLoader(provideCoilBitmapLoader(context)) + .build() + @UnstableApi fun provideMediaSession( - context: Context, + context: Context, player: ExoPlayer, callback: SimpleMediaSessionCallback ): MediaSession = diff --git a/app/src/main/java/com/maxrave/simpmusic/service/SimpleMediaSessionCallback.kt b/app/src/main/java/com/maxrave/simpmusic/service/SimpleMediaSessionCallback.kt index 6bf9f597..21a96823 100644 --- a/app/src/main/java/com/maxrave/simpmusic/service/SimpleMediaSessionCallback.kt +++ b/app/src/main/java/com/maxrave/simpmusic/service/SimpleMediaSessionCallback.kt @@ -1,17 +1,44 @@ package com.maxrave.simpmusic.service +import android.content.ContentResolver +import android.content.Context +import android.net.Uri import android.os.Bundle +import android.util.Log +import androidx.annotation.DrawableRes +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaLibraryService.MediaLibrarySession import androidx.media3.session.MediaSession import androidx.media3.session.SessionCommand import androidx.media3.session.SessionResult +import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture +import com.maxrave.simpmusic.R import com.maxrave.simpmusic.common.MEDIA_CUSTOM_COMMAND +import com.maxrave.simpmusic.data.db.entities.SongEntity +import com.maxrave.simpmusic.data.repository.MainRepository +import com.maxrave.simpmusic.extension.toMediaItem +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.guava.future +import javax.inject.Inject -class SimpleMediaSessionCallback: MediaSession.Callback { +class SimpleMediaSessionCallback @Inject constructor( + @ApplicationContext private val context: Context, + private val mainRepository: MainRepository, +) : MediaLibrarySession.Callback { var toggleLike: () -> Unit = {} + private val scope = CoroutineScope(Dispatchers.Main + Job()) override fun onConnect( session: MediaSession, @@ -52,4 +79,204 @@ class SimpleMediaSessionCallback: MediaSession.Callback { } return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } + + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: MediaLibraryService.LibraryParams?, + ): ListenableFuture> = Futures.immediateFuture( + LibraryResult.ofItem( + MediaItem.Builder() + .setMediaId(ROOT) + .setMediaMetadata( + MediaMetadata.Builder() + .setIsPlayable(false) + .setIsBrowsable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) + .build() + ) + .build(), + params + ) + ) + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: MediaLibraryService.LibraryParams?, + ): ListenableFuture>> = scope.future(Dispatchers.IO) { + LibraryResult.ofItemList( + when (parentId) { + ROOT -> listOf( + browsableMediaItem( + SONG, + context.getString(R.string.songs), + null, + drawableUri(R.drawable.baseline_album_24), + MediaMetadata.MEDIA_TYPE_PLAYLIST + ), + browsableMediaItem( + PLAYLIST, + context.getString(R.string.playlists), + null, + drawableUri(R.drawable.baseline_playlist_add_24), + MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS + ) + ) + + SONG -> mainRepository.getAllSongs().first().sortedBy { it.inLibrary } + .map { it.toMediaItem(parentId) } + + + PLAYLIST -> mainRepository.getAllLocalPlaylists().first().sortedBy { it.inLibrary } + .map { + browsableMediaItem( + "$PLAYLIST/${it.id}", + it.title, + "${it.tracks?.size ?: 0} ${context.getString(R.string.track)}", + it.thumbnail?.toUri(), + MediaMetadata.MEDIA_TYPE_PLAYLIST + ) + } + + else -> { + when { + parentId.startsWith("$PLAYLIST/") -> { + val playlistId = parentId.split("/").getOrNull(1) + if (playlistId != null) { + val playlist = + mainRepository.getLocalPlaylist(playlistId.toLong()).first() + Log.w("SimpleMediaSessionCallback", "onGetChildren: $playlist") + if (playlist.tracks.isNullOrEmpty()) { + emptyList() + } else { + mainRepository.getSongsByListVideoId(playlist.tracks).first() + .map { it.toMediaItem(parentId) } + } + } else { + emptyList() + } + } + + else -> emptyList() + } + } + + }, + params + ) + } + + @UnstableApi + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String, + ): ListenableFuture> = scope.future(Dispatchers.IO) { + mainRepository.getSongById(mediaId).first()?.toMediaItem()?.let { + LibraryResult.ofItem(it, null) + } ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN) + } + + @UnstableApi + override fun onSetMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long, + ): ListenableFuture = scope.future { + // Play from Android Auto + val defaultResult = + MediaSession.MediaItemsWithStartPosition(emptyList(), startIndex, startPositionMs) + val path = mediaItems.firstOrNull()?.mediaId?.split("/") + ?: return@future defaultResult + when (path.firstOrNull()) { + SONG -> { + val songId = path.getOrNull(1) ?: return@future defaultResult + val allSongs = mainRepository.getAllSongs().first().sortedBy { it.inLibrary } + MediaSession.MediaItemsWithStartPosition( + allSongs.map { it.toMediaItem() }, + allSongs.indexOfFirst { it.videoId == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) + } + + PLAYLIST -> { + val songId = path.getOrNull(2) ?: return@future defaultResult + val playlistId = path.getOrNull(1) ?: return@future defaultResult + Log.d("SimpleMediaSessionCallback", "onSetMediaItems: $playlistId") + val songs = + mainRepository.getLocalPlaylist(playlistId.toLong()).first().tracks?.let { + mainRepository.getSongsByListVideoId(it) + }?.first() + Log.w("SimpleMediaSessionCallback", "onSetMediaItems: $songs") + if (songs.isNullOrEmpty()) { + defaultResult + } else { + MediaSession.MediaItemsWithStartPosition( + songs.map { it.toMediaItem() }, + songs.indexOfFirst { it.videoId == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) + } + } + + else -> defaultResult + } + } + + private fun drawableUri(@DrawableRes id: Int) = Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(context.resources.getResourcePackageName(id)) + .appendPath(context.resources.getResourceTypeName(id)) + .appendPath(context.resources.getResourceEntryName(id)) + .build() + + private fun browsableMediaItem( + id: String, + title: String, + subtitle: String?, + iconUri: Uri?, + mediaType: Int = MediaMetadata.MEDIA_TYPE_MUSIC + ) = + MediaItem.Builder() + .setMediaId(id) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setArtist(subtitle) + .setArtworkUri(iconUri) + .setIsPlayable(false) + .setIsBrowsable(true) + .setMediaType(mediaType) + .build() + ) + .build() + + private fun SongEntity.toMediaItem(path: String) = + MediaItem.Builder() + .setMediaId("$path/${this.videoId}") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(this.title) + .setSubtitle(this.artistName?.joinToString(", ")) + .setArtist(this.artistName?.joinToString(" ")) + .setArtworkUri(this.thumbnails?.toUri()) + .setIsPlayable(true) + .setIsBrowsable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) + .build() + ) + .build() + + companion object { + const val ROOT = "root" + const val SONG = "song" + const val PLAYLIST = "playlist" + } } \ No newline at end of file diff --git a/app/src/main/java/com/maxrave/simpmusic/ui/MainActivity.kt b/app/src/main/java/com/maxrave/simpmusic/ui/MainActivity.kt index dc69be7e..332cf0ae 100644 --- a/app/src/main/java/com/maxrave/simpmusic/ui/MainActivity.kt +++ b/app/src/main/java/com/maxrave/simpmusic/ui/MainActivity.kt @@ -11,7 +11,6 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.os.IBinder -import android.os.PersistableBundle import android.util.Log import android.view.View import android.view.animation.AnimationUtils @@ -34,8 +33,7 @@ import androidx.navigation.findNavController import androidx.navigation.fragment.findNavController import androidx.navigation.ui.setupWithNavController import androidx.palette.graphics.Palette -import coil.ImageLoader -import coil.request.ImageRequest +import coil.load import coil.size.Size import coil.transform.Transformation import com.daimajia.swipe.SwipeLayout @@ -58,6 +56,7 @@ import com.maxrave.simpmusic.data.repository.MainRepository import com.maxrave.simpmusic.databinding.ActivityMainBinding import com.maxrave.simpmusic.extension.isMyServiceRunning import com.maxrave.simpmusic.extension.navigateSafe +import com.maxrave.simpmusic.extension.setTextAnimation import com.maxrave.simpmusic.service.SimpleMediaService import com.maxrave.simpmusic.service.SimpleMediaServiceHandler import com.maxrave.simpmusic.viewModel.SharedViewModel @@ -122,29 +121,32 @@ class MainActivity : AppCompatActivity() { repeatOnLifecycle(Lifecycle.State.CREATED) { val job5 = launch { viewModel.simpleMediaServiceHandler?.nowPlaying?.collect { - if (it != null){ - Log.w("Test service", viewModel.simpleMediaServiceHandler?.getCurrentMediaItem()?.mediaMetadata?.title.toString()) - binding.songTitle.text = it.mediaMetadata.title + if (it != null) { + Log.w( + "Test service", + viewModel.simpleMediaServiceHandler?.getCurrentMediaItem()?.mediaMetadata?.title.toString() + ) + binding.songTitle.setTextAnimation(it.mediaMetadata.title.toString()) binding.songTitle.isSelected = true - binding.songArtist.text = it.mediaMetadata.artist + binding.songArtist.setTextAnimation(it.mediaMetadata.artist.toString()) binding.songArtist.isSelected = true - val request = ImageRequest.Builder(this@MainActivity) - .data(it.mediaMetadata.artworkUri) - .target( - onSuccess = { result -> - binding.ivArt.setImageDrawable(result) - }, - ) - .transformations(object : Transformation { + binding.ivArt.load(it.mediaMetadata.artworkUri) { + crossfade(true) + crossfade(300) + placeholder(R.drawable.outline_album_24) + transformations(object : Transformation { override val cacheKey: String get() = it.mediaMetadata.artworkUri.toString() - override suspend fun transform(input: Bitmap, size: Size): Bitmap { + override suspend fun transform( + input: Bitmap, + size: Size + ): Bitmap { val p = Palette.from(input).generate() val defaultColor = 0x000000 var startColor = p.getDarkVibrantColor(defaultColor) Log.d("Check Start Color", "transform: $startColor") - if (startColor == defaultColor){ + if (startColor == defaultColor) { startColor = p.getDarkMutedColor(defaultColor) if (startColor == defaultColor){ startColor = p.getVibrantColor(defaultColor) @@ -176,8 +178,57 @@ class MainActivity : AppCompatActivity() { } }) - .build() - ImageLoader(this@MainActivity).execute(request) + } +// val request = ImageRequest.Builder(this@MainActivity) +// .data(it.mediaMetadata.artworkUri) +// .target( +// onSuccess = { result -> +// binding.ivArt.setImageDrawable(result) +// }, +// ) +// .transformations(object : Transformation { +// override val cacheKey: String +// get() = it.mediaMetadata.artworkUri.toString() +// +// override suspend fun transform(input: Bitmap, size: Size): Bitmap { +// val p = Palette.from(input).generate() +// val defaultColor = 0x000000 +// var startColor = p.getDarkVibrantColor(defaultColor) +// Log.d("Check Start Color", "transform: $startColor") +// if (startColor == defaultColor){ +// startColor = p.getDarkMutedColor(defaultColor) +// if (startColor == defaultColor){ +// startColor = p.getVibrantColor(defaultColor) +// if (startColor == defaultColor){ +// startColor = p.getMutedColor(defaultColor) +// if (startColor == defaultColor){ +// startColor = p.getLightVibrantColor(defaultColor) +// if (startColor == defaultColor){ +// startColor = p.getLightMutedColor(defaultColor) +// } +// } +// } +// } +// Log.d("Check Start Color", "transform: $startColor") +// } +// val endColor = 0x1b1a1f +// val gd = GradientDrawable( +// GradientDrawable.Orientation.TOP_BOTTOM, +// intArrayOf(startColor, endColor) +// ) +// gd.cornerRadius = 0f +// gd.gradientType = GradientDrawable.LINEAR_GRADIENT +// gd.gradientRadius = 0.5f +// gd.alpha = 150 +// val bg = ColorUtils.setAlphaComponent(startColor, 255) +// binding.card.setCardBackgroundColor(bg) +// binding.cardBottom.setCardBackgroundColor(bg) +// return input +// } +// +// }) +// .build() +// ImageLoader(this@MainActivity).execute(request) } } } diff --git a/app/src/main/java/com/maxrave/simpmusic/ui/fragment/player/NowPlayingFragment.kt b/app/src/main/java/com/maxrave/simpmusic/ui/fragment/player/NowPlayingFragment.kt index 22843597..4741c469 100644 --- a/app/src/main/java/com/maxrave/simpmusic/ui/fragment/player/NowPlayingFragment.kt +++ b/app/src/main/java/com/maxrave/simpmusic/ui/fragment/player/NowPlayingFragment.kt @@ -3,6 +3,7 @@ package com.maxrave.simpmusic.ui.fragment.player import android.content.Intent import android.graphics.Bitmap import android.graphics.Color +import android.graphics.drawable.ColorDrawable import android.graphics.drawable.GradientDrawable import android.graphics.drawable.TransitionDrawable import android.net.Uri @@ -28,10 +29,8 @@ import androidx.media3.exoplayer.offline.DownloadService import androidx.navigation.fragment.findNavController import androidx.palette.graphics.Palette import androidx.recyclerview.widget.LinearLayoutManager -import coil.ImageLoader import coil.load import coil.request.CachePolicy -import coil.request.ImageRequest import coil.size.Size import coil.transform.Transformation import com.daimajia.swipe.SwipeLayout @@ -67,6 +66,7 @@ import com.maxrave.simpmusic.extension.connectArtists import com.maxrave.simpmusic.extension.navigateSafe import com.maxrave.simpmusic.extension.removeConflicts import com.maxrave.simpmusic.extension.setEnabledAll +import com.maxrave.simpmusic.extension.setTextAnimation import com.maxrave.simpmusic.extension.toListName import com.maxrave.simpmusic.extension.toTrack import com.maxrave.simpmusic.service.RepeatState @@ -183,7 +183,7 @@ class NowPlayingFragment : Fragment() { updateUIfromCurrentMediaItem(viewModel.getCurrentMediaItem()) } else { Log.i("Now Playing Fragment", "Song Click") - binding.ivArt.visibility = View.GONE + binding.ivArt.setImageResource(0) binding.loadingArt.visibility = View.VISIBLE viewModel.gradientDrawable.postValue(null) viewModel.lyricsBackground.postValue(null) @@ -221,7 +221,7 @@ class NowPlayingFragment : Fragment() { SHARE -> { viewModel.playlistId.value = null viewModel.stopPlayer() - binding.ivArt.visibility = View.GONE + binding.ivArt.setImageResource(0) binding.loadingArt.visibility = View.VISIBLE viewModel.gradientDrawable.postValue(null) viewModel.lyricsBackground.postValue(null) @@ -279,7 +279,7 @@ class NowPlayingFragment : Fragment() { } else { // if (!viewModel.songTransitions.value){ Log.i("Now Playing Fragment", "Video Click") - binding.ivArt.visibility = View.GONE + binding.ivArt.setImageResource(0) binding.loadingArt.visibility = View.VISIBLE viewModel.gradientDrawable.postValue(null) viewModel.lyricsBackground.postValue(null) @@ -322,8 +322,8 @@ class NowPlayingFragment : Fragment() { viewModel.playlistId.value = playlistId } // if (!viewModel.songTransitions.value){ - Log.i("Now Playing Fragment", "Album Click") - binding.ivArt.visibility = View.GONE + Log.i("Now Playing Fragment", "Album Click") + binding.ivArt.setImageResource(0) binding.loadingArt.visibility = View.VISIBLE viewModel.gradientDrawable.postValue(null) viewModel.lyricsBackground.postValue(null) @@ -382,8 +382,8 @@ class NowPlayingFragment : Fragment() { if (playlistId != null) { viewModel.playlistId.value = playlistId } - Log.i("Now Playing Fragment", "Playlist Click") - binding.ivArt.visibility = View.GONE + Log.i("Now Playing Fragment", "Playlist Click") + binding.ivArt.setImageResource(0) binding.loadingArt.visibility = View.VISIBLE viewModel.gradientDrawable.postValue(null) viewModel.lyricsBackground.postValue(null) @@ -464,7 +464,7 @@ class NowPlayingFragment : Fragment() { // Log.i("Now Playing Fragment", "Bên dưới") // Log.d("Song Transition", "Song Transition") // videoId = viewModel.videoId.value -// binding.ivArt.visibility = View.GONE +// binding.ivArt.setImageResource(0) // binding.loadingArt.visibility = View.VISIBLE // Log.d("Check Lyrics", viewModel._lyrics.value?.data.toString()) // updateUIfromCurrentMediaItem(song) @@ -483,7 +483,7 @@ class NowPlayingFragment : Fragment() { // viewModel.getFormat(song.mediaId) Log.i("Now Playing Fragment", "song ${song.mediaMetadata.title}") videoId = viewModel.videoId.value - binding.ivArt.visibility = View.GONE + binding.ivArt.setImageResource(0) binding.loadingArt.visibility = View.VISIBLE Log.d("Check Lyrics", viewModel._lyrics.value?.data.toString()) updateUIfromCurrentMediaItem(song) @@ -619,13 +619,6 @@ class NowPlayingFragment : Fragment() { } } } - val job6 = launch { - viewModel.lyricsFull.observe(viewLifecycleOwner) { - if (it != null) { -// binding.tvFullLyrics.text = it - } - } - } val job8 = launch { viewModel.shuffleModeEnabled.collect { shuffle -> when (shuffle) { @@ -811,7 +804,6 @@ class NowPlayingFragment : Fragment() { job3.join() job4.join() job5.join() - job6.join() job7.join() job8.join() job9.join() @@ -1294,31 +1286,38 @@ class NowPlayingFragment : Fragment() { val nowPlaying = Queue.getNowPlaying() if (nowPlaying != null) { // viewModel.getFormat(nowPlaying.videoId) - binding.ivArt.visibility = View.GONE + binding.ivArt.setImageResource(0) binding.loadingArt.visibility = View.VISIBLE Log.d("Update UI", "current: ${nowPlaying.title}") var thumbUrl = nowPlaying.thumbnails?.last()?.url!! if (thumbUrl.contains("w120")) { thumbUrl = Regex("([wh])120").replace(thumbUrl, "$1544") } - val request = ImageRequest.Builder(requireContext()) - .data(Uri.parse(thumbUrl)) - .diskCacheKey(nowPlaying.videoId) - .diskCachePolicy(CachePolicy.ENABLED) - .target( + binding.ivArt.load(Uri.parse(thumbUrl)) { + diskCacheKey(nowPlaying.videoId) + diskCachePolicy(CachePolicy.ENABLED) + listener( onStart = { - binding.ivArt.visibility = View.GONE + binding.ivArt.setImageResource(0) binding.loadingArt.visibility = View.VISIBLE Log.d("Update UI", "onStart: ") }, - onSuccess = { result -> + onSuccess = { _, _ -> binding.ivArt.visibility = View.VISIBLE binding.loadingArt.visibility = View.GONE - binding.ivArt.setImageDrawable(result) Log.d("Update UI", "onSuccess: ") if (viewModel.gradientDrawable.value != null) { viewModel.gradientDrawable.observe(viewLifecycleOwner) { - binding.rootLayout.background = it + if (it != null) { + var start = binding.rootLayout.background + if (start == null) { + start = ColorDrawable(Color.BLACK) + } + val transition = TransitionDrawable(arrayOf(start, it)) + binding.rootLayout.background = transition + transition.isCrossFadeEnabled = true + transition.startTransition(500) + } // viewModel.lyricsBackground.observe(viewLifecycleOwner, Observer { color -> // binding.lyricsLayout.setCardBackgroundColor(color) // Log.d("Update UI", "Lyrics: $color") @@ -1335,49 +1334,129 @@ class NowPlayingFragment : Fragment() { // songChangeListener.onNowPlayingSongChange() }, ) - .transformations(object : Transformation { - override val cacheKey: String - get() = nowPlaying.videoId + transformations( + object : Transformation { + override val cacheKey: String + get() = nowPlaying.videoId - override suspend fun transform(input: Bitmap, size: Size): Bitmap { - val p = Palette.from(input).generate() - val defaultColor = 0x000000 - var startColor = p.getDarkVibrantColor(defaultColor) - Log.d("Check Start Color", "transform: $startColor") - if (startColor == defaultColor) { - startColor = p.getDarkMutedColor(defaultColor) + override suspend fun transform(input: Bitmap, size: Size): Bitmap { + val p = Palette.from(input).generate() + val defaultColor = 0x000000 + var startColor = p.getDarkVibrantColor(defaultColor) + Log.d("Check Start Color", "transform: $startColor") if (startColor == defaultColor) { - startColor = p.getVibrantColor(defaultColor) + startColor = p.getDarkMutedColor(defaultColor) if (startColor == defaultColor) { - startColor = p.getMutedColor(defaultColor) + startColor = p.getVibrantColor(defaultColor) if (startColor == defaultColor) { - startColor = p.getLightVibrantColor(defaultColor) + startColor = p.getMutedColor(defaultColor) if (startColor == defaultColor) { - startColor = p.getLightMutedColor(defaultColor) + startColor = p.getLightVibrantColor(defaultColor) + if (startColor == defaultColor) { + startColor = p.getLightMutedColor(defaultColor) + } } } } + Log.d("Check Start Color", "transform: $startColor") } - Log.d("Check Start Color", "transform: $startColor") - } // val centerColor = 0x6C6C6C - val endColor = 0x1b1a1f - val gd = GradientDrawable( - GradientDrawable.Orientation.TOP_BOTTOM, - intArrayOf(startColor, endColor) - ) - gd.cornerRadius = 0f - gd.gradientType = GradientDrawable.LINEAR_GRADIENT - gd.gradientRadius = 0.5f - gd.alpha = 150 - val bg = ColorUtils.setAlphaComponent(startColor, 230) - viewModel.gradientDrawable.postValue(gd) - viewModel.lyricsBackground.postValue(bg) - return input - } + val endColor = 0x1b1a1f + val gd = GradientDrawable( + GradientDrawable.Orientation.TOP_BOTTOM, + intArrayOf(startColor, endColor) + ) + gd.cornerRadius = 0f + gd.gradientType = GradientDrawable.LINEAR_GRADIENT + gd.gradientRadius = 0.5f + gd.alpha = 150 + val bg = ColorUtils.setAlphaComponent(startColor, 230) + viewModel.gradientDrawable.postValue(gd) + viewModel.lyricsBackground.postValue(bg) + return input + } - }) - .build() + } + ) + } +// val request = ImageRequest.Builder(requireContext()) +// .data(Uri.parse(thumbUrl)) +// .diskCacheKey(nowPlaying.videoId) +// .diskCachePolicy(CachePolicy.ENABLED) +// .target( +// onStart = { +// binding.ivArt.setImageResource(0) +// binding.loadingArt.visibility = View.VISIBLE +// Log.d("Update UI", "onStart: ") +// }, +// onSuccess = { result -> +// binding.ivArt.visibility = View.VISIBLE +// binding.loadingArt.visibility = View.GONE +// binding.ivArt.setImageDrawable(result) +// Log.d("Update UI", "onSuccess: ") +// if (viewModel.gradientDrawable.value != null) { +// viewModel.gradientDrawable.observe(viewLifecycleOwner) { +// binding.rootLayout.background = it +//// viewModel.lyricsBackground.observe(viewLifecycleOwner, Observer { color -> +//// binding.lyricsLayout.setCardBackgroundColor(color) +//// Log.d("Update UI", "Lyrics: $color") +//// updateStatusBarColor(color) +//// }) +// viewModel.lyricsBackground.value?.let { it1 -> +// binding.lyricsLayout.setCardBackgroundColor( +// it1 +// ) +// } +// } +// Log.d("Update UI", "updateUI: NULL") +// } +//// songChangeListener.onNowPlayingSongChange() +// }, +// ) +// .transformations(object : Transformation { +// override val cacheKey: String +// get() = nowPlaying.videoId +// +// override suspend fun transform(input: Bitmap, size: Size): Bitmap { +// val p = Palette.from(input).generate() +// val defaultColor = 0x000000 +// var startColor = p.getDarkVibrantColor(defaultColor) +// Log.d("Check Start Color", "transform: $startColor") +// if (startColor == defaultColor) { +// startColor = p.getDarkMutedColor(defaultColor) +// if (startColor == defaultColor) { +// startColor = p.getVibrantColor(defaultColor) +// if (startColor == defaultColor) { +// startColor = p.getMutedColor(defaultColor) +// if (startColor == defaultColor) { +// startColor = p.getLightVibrantColor(defaultColor) +// if (startColor == defaultColor) { +// startColor = p.getLightMutedColor(defaultColor) +// } +// } +// } +// } +// Log.d("Check Start Color", "transform: $startColor") +// } +//// val centerColor = 0x6C6C6C +// val endColor = 0x1b1a1f +// val gd = GradientDrawable( +// GradientDrawable.Orientation.TOP_BOTTOM, +// intArrayOf(startColor, endColor) +// ) +// gd.cornerRadius = 0f +// gd.gradientType = GradientDrawable.LINEAR_GRADIENT +// gd.gradientRadius = 0.5f +// gd.alpha = 150 +// val bg = ColorUtils.setAlphaComponent(startColor, 230) +// viewModel.gradientDrawable.postValue(gd) +// viewModel.lyricsBackground.postValue(bg) +// return input +// } +// +// }) +// .build() +// ImageLoader(requireContext()).enqueue(request) binding.topAppBar.subtitle = from viewModel.from.postValue(from) binding.tvSongTitle.text = nowPlaying.title @@ -1393,13 +1472,13 @@ class NowPlayingFragment : Fragment() { binding.tvSongArtist.isSelected = true binding.tvSongTitle.visibility = View.VISIBLE binding.tvSongArtist.visibility = View.VISIBLE - ImageLoader(requireContext()).enqueue(request) + } } private fun updateUIfromCurrentMediaItem(mediaItem: MediaItem?) { if (mediaItem != null) { - binding.ivArt.visibility = View.GONE + binding.ivArt.setImageResource(0) binding.loadingArt.visibility = View.VISIBLE // viewModel.getFormat(mediaItem.mediaId) Log.d("Update UI", "current: ${mediaItem.mediaMetadata.title}") @@ -1407,30 +1486,32 @@ class NowPlayingFragment : Fragment() { binding.tvSongArtist.visibility = View.VISIBLE binding.topAppBar.subtitle = from viewModel.from.postValue(from) - binding.tvSongTitle.text = mediaItem.mediaMetadata.title + binding.tvSongTitle.setTextAnimation(mediaItem.mediaMetadata.title.toString()) binding.tvSongTitle.isSelected = true - binding.tvSongArtist.text = mediaItem.mediaMetadata.artist + binding.tvSongArtist.setTextAnimation(mediaItem.mediaMetadata.artist.toString()) binding.tvSongArtist.isSelected = true - val request = ImageRequest.Builder(requireContext()) - .data(mediaItem.mediaMetadata.artworkUri) - .diskCacheKey(mediaItem.mediaId) - .diskCachePolicy(CachePolicy.ENABLED) - .target( + binding.ivArt.load(mediaItem.mediaMetadata.artworkUri) { + diskCacheKey(mediaItem.mediaId) + diskCachePolicy(CachePolicy.ENABLED) + crossfade(true) + crossfade(300) + listener( onStart = { - binding.ivArt.visibility = View.GONE + binding.ivArt.setImageResource(0) binding.loadingArt.visibility = View.VISIBLE Log.d("Update UI", "onStart: ") }, - onSuccess = { result -> + onSuccess = { _, _ -> binding.ivArt.visibility = View.VISIBLE binding.loadingArt.visibility = View.GONE - binding.ivArt.setImageDrawable(result) Log.d("Update UI", "onSuccess: ") if (viewModel.gradientDrawable.value != null) { viewModel.gradientDrawable.observe(viewLifecycleOwner) { - binding.rootLayout.background = it if (it != null) { - val start = binding.rootLayout.background + var start = binding.rootLayout.background + if (start == null) { + start = ColorDrawable(Color.BLACK) + } val transition = TransitionDrawable(arrayOf(start, it)) binding.rootLayout.background = transition transition.isCrossFadeEnabled = true @@ -1452,9 +1533,7 @@ class NowPlayingFragment : Fragment() { // songChangeListener.onNowPlayingSongChange() }, ) - .diskCacheKey(mediaItem.mediaMetadata.artworkUri.toString()) - .diskCachePolicy(CachePolicy.ENABLED) - .transformations(object : Transformation { + transformations(object : Transformation { override val cacheKey: String get() = "paletteArtTransformer" @@ -1494,10 +1573,94 @@ class NowPlayingFragment : Fragment() { viewModel.lyricsBackground.postValue(bg) return input } - }) - .build() - ImageLoader(requireContext()).enqueue(request) + } +// val request = ImageRequest.Builder(requireContext()) +// .data(mediaItem.mediaMetadata.artworkUri) +// .diskCacheKey(mediaItem.mediaId) +// .diskCachePolicy(CachePolicy.ENABLED) +// .target( +// onStart = { +// binding.ivArt.setImageResource(0) +// binding.loadingArt.visibility = View.VISIBLE +// Log.d("Update UI", "onStart: ") +// }, +// onSuccess = { result -> +// binding.ivArt.visibility = View.VISIBLE +// binding.loadingArt.visibility = View.GONE +// binding.ivArt.setImageDrawable(result) +// Log.d("Update UI", "onSuccess: ") +// if (viewModel.gradientDrawable.value != null) { +// viewModel.gradientDrawable.observe(viewLifecycleOwner) { +// if (it != null) { +// val start = binding.rootLayout.background +// val transition = TransitionDrawable(arrayOf(start, it)) +// binding.rootLayout.background = transition +// transition.isCrossFadeEnabled = true +// transition.startTransition(500) +// } +//// viewModel.lyricsBackground.observe(viewLifecycleOwner, Observer { color -> +//// binding.lyricsLayout.setCardBackgroundColor(color) +//// Log.d("Update UI", "Lyrics: $color") +//// updateStatusBarColor(color) +//// }) +// viewModel.lyricsBackground.value?.let { it1 -> +// binding.lyricsLayout.setCardBackgroundColor( +// it1 +// ) +// } +// } +// Log.d("Update UI", "updateUI: NULL") +// } +//// songChangeListener.onNowPlayingSongChange() +// }, +// ) +// .diskCacheKey(mediaItem.mediaMetadata.artworkUri.toString()) +// .diskCachePolicy(CachePolicy.ENABLED) +// .transformations(object : Transformation { +// override val cacheKey: String +// get() = "paletteArtTransformer" +// +// override suspend fun transform(input: Bitmap, size: Size): Bitmap { +// val p = Palette.from(input).generate() +// val defaultColor = 0x000000 +// var startColor = p.getDarkVibrantColor(defaultColor) +// Log.d("Check Start Color", "transform: $startColor") +// if (startColor == defaultColor) { +// startColor = p.getDarkMutedColor(defaultColor) +// if (startColor == defaultColor) { +// startColor = p.getVibrantColor(defaultColor) +// if (startColor == defaultColor) { +// startColor = p.getMutedColor(defaultColor) +// if (startColor == defaultColor) { +// startColor = p.getLightVibrantColor(defaultColor) +// if (startColor == defaultColor) { +// startColor = p.getLightMutedColor(defaultColor) +// } +// } +// } +// } +// Log.d("Check Start Color", "transform: $startColor") +// } +//// val centerColor = 0x6C6C6C +// val endColor = 0x1b1a1f +// val gd = GradientDrawable( +// GradientDrawable.Orientation.TOP_BOTTOM, +// intArrayOf(startColor, endColor) +// ) +// gd.cornerRadius = 0f +// gd.gradientType = GradientDrawable.LINEAR_GRADIENT +// gd.gradientRadius = 0.5f +// gd.alpha = 150 +// val bg = ColorUtils.setAlphaComponent(startColor, 230) +// viewModel.gradientDrawable.postValue(gd) +// viewModel.lyricsBackground.postValue(bg) +// return input +// } +// +// }) +// .build() +// ImageLoader(requireContext()).enqueue(request) } } diff --git a/app/src/main/java/com/maxrave/simpmusic/viewModel/SharedViewModel.kt b/app/src/main/java/com/maxrave/simpmusic/viewModel/SharedViewModel.kt index d4ddbdc1..166d71c6 100644 --- a/app/src/main/java/com/maxrave/simpmusic/viewModel/SharedViewModel.kt +++ b/app/src/main/java/com/maxrave/simpmusic/viewModel/SharedViewModel.kt @@ -66,7 +66,6 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn @@ -234,25 +233,37 @@ class SharedViewModel @Inject constructor(private var dataStoreManager: DataStor if (nowPlaying != null && getCurrentMediaItemIndex() > 0) { _nowPlayingMediaItem.postValue(nowPlaying) var downloaded = false - val tempSong = simpleMediaServiceHandler!!.catalogMetadata.get(getCurrentMediaItemIndex()) - Log.d("Check tempSong", tempSong.toString()) - mainRepository.insertSong(tempSong.toSongEntity()) - mainRepository.getSongById(tempSong.videoId) - .collectLatest { songEntity -> - _songDB.value = songEntity - if (songEntity != null) { - _liked.value = songEntity.liked - simpleMediaServiceHandler!!.like(songEntity.liked) - downloaded = - songEntity.downloadState == DownloadState.STATE_DOWNLOADED - Log.d("Check like", songEntity.toString()) + val tempSong = simpleMediaServiceHandler!!.catalogMetadata.getOrNull( + getCurrentMediaItemIndex() + ) + if (tempSong != null) { + Log.d("Check tempSong", tempSong.toString()) + mainRepository.insertSong(tempSong.toSongEntity()) + mainRepository.getSongById(tempSong.videoId) + .collectLatest { songEntity -> + _songDB.value = songEntity + if (songEntity != null) { + _liked.value = songEntity.liked + simpleMediaServiceHandler!!.like(songEntity.liked) + downloaded = + songEntity.downloadState == DownloadState.STATE_DOWNLOADED + Log.d("Check like", songEntity.toString()) + } } + mainRepository.updateSongInLibrary( + LocalDateTime.now(), + tempSong.videoId + ) + mainRepository.updateListenCount(tempSong.videoId) + tempSong.durationSeconds?.let { + mainRepository.updateDurationSeconds( + it, + tempSong.videoId + ) } - mainRepository.updateSongInLibrary(LocalDateTime.now(), tempSong.videoId) - mainRepository.updateListenCount(tempSong.videoId) - tempSong.durationSeconds?.let { mainRepository.updateDurationSeconds(it, tempSong.videoId) } - videoId.postValue(tempSong.videoId) - _nowPlayingMediaItem.value = nowPlaying + videoId.postValue(tempSong.videoId) + _nowPlayingMediaItem.value = nowPlaying + } } } } diff --git a/app/src/main/res/layout/fragment_downloaded.xml b/app/src/main/res/layout/fragment_downloaded.xml index 644fed96..72e4d4c3 100644 --- a/app/src/main/res/layout/fragment_downloaded.xml +++ b/app/src/main/res/layout/fragment_downloaded.xml @@ -26,17 +26,18 @@ android:layout_below="@id/topAppBarLayout" android:orientation="vertical" android:layout_width="match_parent" - android:layout_height="wrap_content"> + android:layout_height="wrap_content" + android:layout_marginHorizontal="10dp"> - + - + diff --git a/app/src/main/res/layout/fragment_favorite.xml b/app/src/main/res/layout/fragment_favorite.xml index 84d772ab..d07542ff 100644 --- a/app/src/main/res/layout/fragment_favorite.xml +++ b/app/src/main/res/layout/fragment_favorite.xml @@ -26,11 +26,14 @@ android:layout_below="@id/topAppBarLayout" android:orientation="vertical" android:layout_width="match_parent" - android:layout_height="wrap_content"> + android:layout_height="wrap_content" + android:layout_marginHorizontal="10dp"> + + + android:layout_height="wrap_content" + android:layout_marginHorizontal="10dp"> + + + android:layout_height="wrap_content" + android:layout_marginHorizontal="10dp"> diff --git a/app/src/main/res/layout/fragment_podcast.xml b/app/src/main/res/layout/fragment_podcast.xml index f5abdbf0..4ef06d04 100644 --- a/app/src/main/res/layout/fragment_podcast.xml +++ b/app/src/main/res/layout/fragment_podcast.xml @@ -53,7 +53,8 @@ android:layout_marginTop="?attr/actionBarSize" android:id="@+id/ivPlaylistArt" android:layout_width="wrap_content" - android:layout_height="250sp" + android:layout_height="250dp" + android:minWidth="250dp" android:layout_marginBottom="15dp" android:scaleType="centerCrop" android:layout_gravity="center_horizontal"> diff --git a/app/src/main/res/layout/fragment_recently_songs.xml b/app/src/main/res/layout/fragment_recently_songs.xml index 7848163a..89938105 100644 --- a/app/src/main/res/layout/fragment_recently_songs.xml +++ b/app/src/main/res/layout/fragment_recently_songs.xml @@ -16,25 +16,28 @@ android:id="@+id/topAppBar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" - app:title="Recently Songs" + app:title="@string/recently" app:navigationIcon="@drawable/baseline_arrow_back_ios_new_24" app:navigationIconTint="@android:color/white" app:titleTextAppearance="@style/TextAppearance.Material3.TitleMedium" app:layout_scrollFlags="scroll|enterAlways|snap"/> - + + - + android:layout_height="wrap_content" + tools:listitem="@layout/item_songs_search_result" + android:paddingBottom="135sp" + android:clipToPadding="false"> - - + + \ No newline at end of file diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml index 59ee4e3b..0f739ff8 100644 --- a/app/src/main/res/xml/automotive_app_desc.xml +++ b/app/src/main/res/xml/automotive_app_desc.xml @@ -1,3 +1,4 @@ + - + \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/11.txt b/fastlane/metadata/android/en-US/changelogs/11.txt new file mode 100644 index 00000000..e69de29b diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 4ba7094f..4e5b1377 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -3,11 +3,11 @@ A simple music app using YouTube Music for backend
Features: - Play music from YouTube Music or YouTube free without ads in the background -- Browsing Home, Charts, Moods & Genre with YouTube Music data with high speed +- Browsing Home, Charts, Podcast, Moods & Genre with YouTube Music data with high speed - Search everything on YouTube -- Analyze your playing data, create custom playlists and sync with YouTube Music ... +- Analyze your playing data, create custom playlists and sync with YouTube Music... - Caching and can save data for offline playback -- Synced lyrics from Musixmatch +- Synced lyrics from Musixmatch and translate lyrics (Community translation from Musixmatch) - Support SponsorBlock - Sleep Timer - And many more \ No newline at end of file diff --git a/fastlane/metadata/android/vi-VN/full_description.txt b/fastlane/metadata/android/vi-VN/full_description.txt index 86248318..8946cb56 100644 --- a/fastlane/metadata/android/vi-VN/full_description.txt +++ b/fastlane/metadata/android/vi-VN/full_description.txt @@ -3,11 +3,11 @@
Tính năng: - Nghe nhạc từ YouTube Music hoặc YouTube miễn phí không có quảng cáo trong nền -- Duyệt tất cả các nội dung như bảng xếp hạng, thể loại, tâm trạng, ... từ YouTube Music với tốc độ cao +- Duyệt tất cả các nội dung như bảng xếp hạng, thể loại, tâm trạng, podcast, ... từ YouTube Music với tốc độ cao - Tìm kiếm mọi thứ trên YouTube - Thống kê dữ liệu nghe nhạc của bạn, tạo danh sách phát tùy chỉnh và đồng bộ với YouTube Music - Lưu trữ dữ liệu ngoại tuyến -- Lời bài hát được đồng bộ từ Musixmatch +- Lời bài hát được đồng bộ từ Musixmatch, dịch lời bài hát với cộng đồng từ Musixmatch - Hỗ trợ SponsorBlock - Hẹn giờ đi ngủ - Và rất nhiều tính năng khác \ No newline at end of file