diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 02eb490e1..7aceb042e 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,8 +24,8 @@ android { applicationId = "com.zionhuang.music" minSdk = 24 targetSdk = 33 - versionCode = 18 - versionName = "0.5.2" + versionCode = 19 + versionName = "0.5.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -33,11 +33,9 @@ android { isMinifyEnabled = true isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - resValue("string", "app_name", "InnerTune") } debug { applicationIdSuffix = ".debug" - resValue("string", "app_name", "InnerTune Debug") } } flavorDimensions += "version" @@ -107,6 +105,7 @@ dependencies { implementation(libs.compose.ui.tooling) implementation(libs.compose.animation) implementation(libs.compose.animation.graphics) + implementation(libs.compose.reorderable) implementation(libs.viewmodel) implementation(libs.viewmodel.compose) diff --git a/app/src/debug/res/values/app_name.xml b/app/src/debug/res/values/app_name.xml new file mode 100644 index 000000000..929850995 --- /dev/null +++ b/app/src/debug/res/values/app_name.xml @@ -0,0 +1,4 @@ + + + InnerTune Debug + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d981d51ae..a80f8b853 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -121,7 +121,6 @@ diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index dfd5b330c..44dbfa8e6 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -52,8 +52,6 @@ import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem import androidx.media3.common.Player -import androidx.media3.session.MediaController -import androidx.media3.session.SessionToken import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -63,7 +61,6 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import coil.imageLoader import coil.request.ImageRequest -import com.google.common.util.concurrent.MoreExecutors import com.valentinilk.shimmer.LocalShimmerTheme import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.SongItem @@ -121,7 +118,6 @@ class MainActivity : ComponentActivity() { lateinit var downloadUtil: DownloadUtil private var playerConnection by mutableStateOf(null) - private var mediaController: MediaController? = null private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { if (service is MusicBinder) { @@ -138,17 +134,13 @@ class MainActivity : ComponentActivity() { override fun onStart() { super.onStart() + startService(Intent(this, MusicService::class.java)) bindService(Intent(this, MusicService::class.java), serviceConnection, Context.BIND_AUTO_CREATE) } override fun onStop() { - super.onStop() unbindService(serviceConnection) - } - - override fun onDestroy() { - super.onDestroy() - mediaController?.release() + super.onStop() } @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @@ -157,14 +149,6 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) - // Connect to service so that notification and background playing will work - val sessionToken = SessionToken(this, ComponentName(this, MusicService::class.java)) - val controllerFuture = MediaController.Builder(this, sessionToken).buildAsync() - controllerFuture.addListener( - { mediaController = controllerFuture.get() }, - MoreExecutors.directExecutor() - ) - setupRemoteConfig() setContent { @@ -794,7 +778,6 @@ class MainActivity : ComponentActivity() { YouTubeSongMenu( song = song, navController = navController, - playerConnection = playerConnection, onDismiss = { sharedSong = null } ) } diff --git a/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt b/app/src/main/java/com/zionhuang/music/constants/Dimensions.kt similarity index 95% rename from app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt rename to app/src/main/java/com/zionhuang/music/constants/Dimensions.kt index 8c0426ae9..42a8312b3 100644 --- a/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt +++ b/app/src/main/java/com/zionhuang/music/constants/Dimensions.kt @@ -26,4 +26,6 @@ val AlbumThumbnailSize = 144.dp val ThumbnailCornerRadius = 6.dp +val PlayerHorizontalPadding = 32.dp + val NavigationBarAnimationSpec = spring(stiffness = Spring.StiffnessMediumLow) diff --git a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt index 4157a7e90..a410026d9 100644 --- a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt @@ -49,10 +49,22 @@ val ArtistSongSortDescendingKey = booleanPreferencesKey("artistSongSortDescendin val SongFilterKey = stringPreferencesKey("songFilter") val ArtistFilterKey = stringPreferencesKey("artistFilter") +val ArtistViewTypeKey = stringPreferencesKey("artistViewType") val AlbumFilterKey = stringPreferencesKey("albumFilter") +val AlbumViewTypeKey = stringPreferencesKey("albumViewType") +val PlaylistViewTypeKey = stringPreferencesKey("playlistViewType") val PlaylistEditLockKey = booleanPreferencesKey("playlistEditLock") +enum class LibraryViewType { + LIST, GRID; + + fun toggle() = when (this) { + LIST -> GRID + GRID -> LIST + } +} + enum class SongSortType { CREATE_DATE, NAME, ARTIST, PLAY_TIME } @@ -106,6 +118,7 @@ val VisitorDataKey = stringPreferencesKey("visitorData") val InnerTubeCookieKey = stringPreferencesKey("innerTubeCookie") val AccountNameKey = stringPreferencesKey("accountName") val AccountEmailKey = stringPreferencesKey("accountEmail") +val AccountChannelHandleKey = stringPreferencesKey("accountChannelHandle") val LanguageCodeToName = mapOf( "af" to "Afrikaans", diff --git a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt index 642dcd5fa..359e73c8f 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -310,8 +310,8 @@ interface DatabaseDao { .reversed(descending) } - @Query("SELECT * FROM artist WHERE id = :id") - fun artist(id: String): Flow + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE id = :id") + fun artist(id: String): Flow @Transaction @Query("SELECT * FROM album WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) ORDER BY rowId") diff --git a/app/src/main/java/com/zionhuang/music/di/AppModule.kt b/app/src/main/java/com/zionhuang/music/di/AppModule.kt index 622932a7b..a8281b56f 100644 --- a/app/src/main/java/com/zionhuang/music/di/AppModule.kt +++ b/app/src/main/java/com/zionhuang/music/di/AppModule.kt @@ -43,19 +43,29 @@ object AppModule { @Singleton @Provides @PlayerCache - fun providePlayerCache(@ApplicationContext context: Context, databaseProvider: DatabaseProvider): SimpleCache = - SimpleCache( - context.filesDir.resolve("exoplayer"), - when (val cacheSize = context.dataStore[MaxSongCacheSizeKey] ?: 1024) { - -1 -> NoOpCacheEvictor() - else -> LeastRecentlyUsedCacheEvictor(cacheSize * 1024 * 1024L) - }, - databaseProvider - ) + fun providePlayerCache(@ApplicationContext context: Context, databaseProvider: DatabaseProvider): SimpleCache { + val constructor = { + SimpleCache( + context.filesDir.resolve("exoplayer"), + when (val cacheSize = context.dataStore[MaxSongCacheSizeKey] ?: 1024) { + -1 -> NoOpCacheEvictor() + else -> LeastRecentlyUsedCacheEvictor(cacheSize * 1024 * 1024L) + }, + databaseProvider + ) + } + constructor().release() + return constructor() + } @Singleton @Provides @DownloadCache - fun provideDownloadCache(@ApplicationContext context: Context, databaseProvider: DatabaseProvider): SimpleCache = - SimpleCache(context.filesDir.resolve("download"), NoOpCacheEvictor(), databaseProvider) + fun provideDownloadCache(@ApplicationContext context: Context, databaseProvider: DatabaseProvider): SimpleCache { + val constructor = { + SimpleCache(context.filesDir.resolve("download"), NoOpCacheEvictor(), databaseProvider) + } + constructor().release() + return constructor() + } } diff --git a/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt index c46f1513d..9de45c5f9 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt @@ -3,7 +3,6 @@ package com.zionhuang.music.extensions fun List.reversed(reversed: Boolean) = if (reversed) asReversed() else this fun MutableList.move(fromIndex: Int, toIndex: Int): MutableList { - val item = removeAt(fromIndex) - add(toIndex, item) + add(toIndex, removeAt(fromIndex)) return this } diff --git a/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt b/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt index f2785c09b..735c4f787 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt @@ -9,6 +9,9 @@ import com.zionhuang.music.models.MediaMetadata import java.util.ArrayDeque fun Player.togglePlayPause() { + if (!playWhenReady && playbackState == Player.STATE_IDLE) { + prepare() + } playWhenReady = !playWhenReady } diff --git a/app/src/main/java/com/zionhuang/music/playback/MediaLibrarySessionCallback.kt b/app/src/main/java/com/zionhuang/music/playback/MediaLibrarySessionCallback.kt new file mode 100644 index 000000000..dd9b2d45d --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/playback/MediaLibrarySessionCallback.kt @@ -0,0 +1,300 @@ +package com.zionhuang.music.playback + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.os.Bundle +import androidx.annotation.DrawableRes +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.exoplayer.offline.Download +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.zionhuang.music.R +import com.zionhuang.music.constants.MediaSessionConstants +import com.zionhuang.music.constants.SongSortType +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.PlaylistEntity +import com.zionhuang.music.db.entities.Song +import com.zionhuang.music.extensions.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.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.guava.future +import kotlinx.coroutines.plus +import javax.inject.Inject + +class MediaLibrarySessionCallback @Inject constructor( + @ApplicationContext val context: Context, + val database: MusicDatabase, + val downloadUtil: DownloadUtil, +) : MediaLibrarySession.Callback { + private val scope = CoroutineScope(Dispatchers.Main) + Job() + var toggleLike: () -> Unit = {} + var toggleLibrary: () -> Unit = {} + + override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { + val connectionResult = super.onConnect(session, controller) + return MediaSession.ConnectionResult.accept( + connectionResult.availableSessionCommands.buildUpon() + .add(MediaSessionConstants.CommandToggleLibrary) + .add(MediaSessionConstants.CommandToggleLike).build(), + connectionResult.availablePlayerCommands + ) + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle, + ): ListenableFuture { + when (customCommand.customAction) { + MediaSessionConstants.ACTION_TOGGLE_LIKE -> toggleLike() + MediaSessionConstants.ACTION_TOGGLE_LIBRARY -> toggleLibrary() + } + 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(MusicService.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) { + MusicService.ROOT -> listOf( + browsableMediaItem(MusicService.SONG, context.getString(R.string.songs), null, drawableUri(R.drawable.music_note), MediaMetadata.MEDIA_TYPE_PLAYLIST), + browsableMediaItem(MusicService.ARTIST, context.getString(R.string.artists), null, drawableUri(R.drawable.artist), MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS), + browsableMediaItem(MusicService.ALBUM, context.getString(R.string.albums), null, drawableUri(R.drawable.album), MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), + browsableMediaItem(MusicService.PLAYLIST, context.getString(R.string.playlists), null, drawableUri(R.drawable.queue_music), MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS) + ) + + MusicService.SONG -> database.songsByCreateDateAsc().first().map { it.toMediaItem(parentId) } + MusicService.ARTIST -> database.artistsByCreateDateAsc().first().map { artist -> + browsableMediaItem("${MusicService.ARTIST}/${artist.id}", artist.artist.name, context.resources.getQuantityString(R.plurals.n_song, artist.songCount, artist.songCount), artist.artist.thumbnailUrl?.toUri(), MediaMetadata.MEDIA_TYPE_ARTIST) + } + + MusicService.ALBUM -> database.albumsByCreateDateAsc().first().map { album -> + browsableMediaItem("${MusicService.ALBUM}/${album.id}", album.album.title, album.artists.joinToString(), album.album.thumbnailUrl?.toUri(), MediaMetadata.MEDIA_TYPE_ALBUM) + } + + MusicService.PLAYLIST -> { + val likedSongCount = database.likedSongsCount().first() + val downloadedSongCount = downloadUtil.downloads.value.size + listOf( + browsableMediaItem("${MusicService.PLAYLIST}/${PlaylistEntity.LIKED_PLAYLIST_ID}", context.getString(R.string.liked_songs), context.resources.getQuantityString(R.plurals.n_song, likedSongCount, likedSongCount), drawableUri(R.drawable.favorite), MediaMetadata.MEDIA_TYPE_PLAYLIST), + browsableMediaItem("${MusicService.PLAYLIST}/${PlaylistEntity.DOWNLOADED_PLAYLIST_ID}", context.getString(R.string.downloaded_songs), context.resources.getQuantityString(R.plurals.n_song, downloadedSongCount, downloadedSongCount), drawableUri(R.drawable.download), MediaMetadata.MEDIA_TYPE_PLAYLIST) + ) + database.playlistsByCreateDateAsc().first().map { playlist -> + browsableMediaItem("${MusicService.PLAYLIST}/${playlist.id}", playlist.playlist.name, context.resources.getQuantityString(R.plurals.n_song, playlist.songCount, playlist.songCount), playlist.thumbnails.firstOrNull()?.toUri(), MediaMetadata.MEDIA_TYPE_PLAYLIST) + } + } + + else -> when { + parentId.startsWith("${MusicService.ARTIST}/") -> + database.artistSongsByCreateDateAsc(parentId.removePrefix("${MusicService.ARTIST}/")).first().map { + it.toMediaItem(parentId) + } + + parentId.startsWith("${MusicService.ALBUM}/") -> + database.albumSongs(parentId.removePrefix("${MusicService.ALBUM}/")).first().map { + it.toMediaItem(parentId) + } + + parentId.startsWith("${MusicService.PLAYLIST}/") -> + when (val playlistId = parentId.removePrefix("${MusicService.PLAYLIST}/")) { + PlaylistEntity.LIKED_PLAYLIST_ID -> database.likedSongs(SongSortType.CREATE_DATE, true) + PlaylistEntity.DOWNLOADED_PLAYLIST_ID -> { + val downloads = downloadUtil.downloads.value + database.allSongs() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { + downloads[it.id]?.state == Download.STATE_COMPLETED + } + } + .map { songs -> + songs.map { it to downloads[it.id] } + .sortedBy { it.second?.updateTimeMs ?: 0L } + .map { it.first } + } + } + + else -> database.playlistSongs(playlistId).map { list -> + list.map { it.song } + } + }.first().map { + it.toMediaItem(parentId) + } + + else -> emptyList() + } + }, + params + ) + } + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String, + ): ListenableFuture> = scope.future(Dispatchers.IO) { + database.song(mediaId).first()?.toMediaItem()?.let { + LibraryResult.ofItem(it, null) + } ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN) + } + + 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()) { + MusicService.SONG -> { + val songId = path.getOrNull(1) ?: return@future defaultResult + val allSongs = database.songsByCreateDateAsc().first() + MediaSession.MediaItemsWithStartPosition( + allSongs.map { it.toMediaItem() }, + allSongs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) + } + + MusicService.ARTIST -> { + val songId = path.getOrNull(2) ?: return@future defaultResult + val artistId = path.getOrNull(1) ?: return@future defaultResult + val songs = database.artistSongsByCreateDateAsc(artistId).first() + MediaSession.MediaItemsWithStartPosition( + songs.map { it.toMediaItem() }, + songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) + } + + MusicService.ALBUM -> { + val songId = path.getOrNull(2) ?: return@future defaultResult + val albumId = path.getOrNull(1) ?: return@future defaultResult + val albumWithSongs = database.albumWithSongs(albumId).first() ?: return@future defaultResult + MediaSession.MediaItemsWithStartPosition( + albumWithSongs.songs.map { it.toMediaItem() }, + albumWithSongs.songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) + } + + MusicService.PLAYLIST -> { + val songId = path.getOrNull(2) ?: return@future defaultResult + val playlistId = path.getOrNull(1) ?: return@future defaultResult + val songs = when (playlistId) { + PlaylistEntity.LIKED_PLAYLIST_ID -> database.likedSongs(SongSortType.CREATE_DATE, descending = true) + PlaylistEntity.DOWNLOADED_PLAYLIST_ID -> { + val downloads = downloadUtil.downloads.value + database.allSongs() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { + downloads[it.id]?.state == Download.STATE_COMPLETED + } + } + .map { songs -> + songs.map { it to downloads[it.id] } + .sortedBy { it.second?.updateTimeMs ?: 0L } + .map { it.first } + } + } + + else -> database.playlistSongs(playlistId).map { list -> + list.map { it.song } + } + }.first() + MediaSession.MediaItemsWithStartPosition( + songs.map { it.toMediaItem() }, + songs.indexOfFirst { it.id == 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 Song.toMediaItem(path: String) = + MediaItem.Builder() + .setMediaId("$path/$id") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(song.title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(song.thumbnailUrl?.toUri()) + .setIsPlayable(true) + .setIsBrowsable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) + .build() + ) + .build() +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt index 6a63749aa..958fffa7b 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -1,33 +1,19 @@ package com.zionhuang.music.playback import android.app.PendingIntent -import android.content.ContentResolver +import android.content.ComponentName import android.content.Context import android.content.Intent import android.database.SQLException import android.media.audiofx.AudioEffect import android.net.ConnectivityManager -import android.net.Uri import android.os.Binder -import android.os.Bundle -import androidx.annotation.DrawableRes -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.core.content.getSystemService import androidx.core.net.toUri import androidx.datastore.preferences.core.edit import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.MediaMetadata.MEDIA_TYPE_ALBUM -import androidx.media3.common.MediaMetadata.MEDIA_TYPE_ARTIST -import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS -import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS -import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS -import androidx.media3.common.MediaMetadata.MEDIA_TYPE_MUSIC -import androidx.media3.common.MediaMetadata.MEDIA_TYPE_PLAYLIST import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.Player.EVENT_IS_PLAYING_CHANGED @@ -38,7 +24,7 @@ import androidx.media3.common.Player.EVENT_TIMELINE_CHANGED import androidx.media3.common.Player.STATE_ENDED import androidx.media3.common.Player.STATE_IDLE import androidx.media3.common.Timeline -import androidx.media3.database.DatabaseProvider +import androidx.media3.common.audio.SonicAudioProcessor import androidx.media3.datasource.DataSource import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.ResolvingDataSource @@ -51,11 +37,8 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.analytics.PlaybackStats import androidx.media3.exoplayer.analytics.PlaybackStatsListener -import androidx.media3.exoplayer.audio.AudioCapabilities import androidx.media3.exoplayer.audio.DefaultAudioSink import androidx.media3.exoplayer.audio.SilenceSkippingAudioProcessor -import androidx.media3.exoplayer.audio.SonicAudioProcessor -import androidx.media3.exoplayer.offline.Download import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder import androidx.media3.extractor.ExtractorsFactory @@ -63,14 +46,11 @@ import androidx.media3.extractor.mkv.MatroskaExtractor import androidx.media3.extractor.mp4.FragmentedMp4Extractor import androidx.media3.session.CommandButton import androidx.media3.session.DefaultMediaNotificationProvider -import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaController import androidx.media3.session.MediaLibraryService 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 androidx.media3.session.SessionToken +import com.google.common.util.concurrent.MoreExecutors import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.SongItem import com.zionhuang.innertube.models.WatchEndpoint @@ -80,8 +60,6 @@ import com.zionhuang.music.R import com.zionhuang.music.constants.AudioNormalizationKey import com.zionhuang.music.constants.AudioQuality import com.zionhuang.music.constants.AudioQualityKey -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIBRARY -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIKE import com.zionhuang.music.constants.MediaSessionConstants.CommandToggleLibrary import com.zionhuang.music.constants.MediaSessionConstants.CommandToggleLike import com.zionhuang.music.constants.PauseListenHistoryKey @@ -90,15 +68,11 @@ import com.zionhuang.music.constants.PlayerVolumeKey import com.zionhuang.music.constants.RepeatModeKey import com.zionhuang.music.constants.ShowLyricsKey import com.zionhuang.music.constants.SkipSilenceKey -import com.zionhuang.music.constants.SongSortType import com.zionhuang.music.db.MusicDatabase import com.zionhuang.music.db.entities.Event import com.zionhuang.music.db.entities.FormatEntity import com.zionhuang.music.db.entities.LyricsEntity -import com.zionhuang.music.db.entities.PlaylistEntity.Companion.DOWNLOADED_PLAYLIST_ID -import com.zionhuang.music.db.entities.PlaylistEntity.Companion.LIKED_PLAYLIST_ID import com.zionhuang.music.db.entities.RelatedSongMap -import com.zionhuang.music.db.entities.Song import com.zionhuang.music.di.DownloadCache import com.zionhuang.music.di.PlayerCache import com.zionhuang.music.extensions.SilentHandler @@ -129,15 +103,16 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.guava.future +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.runBlocking @@ -152,23 +127,23 @@ import java.time.LocalDateTime import javax.inject.Inject import kotlin.math.min import kotlin.math.pow -import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @AndroidEntryPoint class MusicService : MediaLibraryService(), Player.Listener, - PlaybackStatsListener.Callback, - MediaLibraryService.MediaLibrarySession.Callback { + PlaybackStatsListener.Callback { @Inject lateinit var database: MusicDatabase @Inject - lateinit var downloadUtil: DownloadUtil + lateinit var lyricsHelper: LyricsHelper @Inject - lateinit var lyricsHelper: LyricsHelper + lateinit var mediaLibrarySessionCallback: MediaLibrarySessionCallback + private val scope = CoroutineScope(Dispatchers.Main) + Job() private val binder = MusicBinder() @@ -180,23 +155,17 @@ class MusicService : MediaLibraryService(), var queueTitle: String? = null val currentMediaMetadata = MutableStateFlow(null) - private val currentSongFlow = currentMediaMetadata.flatMapLatest { mediaMetadata -> + private val currentSong = currentMediaMetadata.flatMapLatest { mediaMetadata -> database.song(mediaMetadata?.id) - } + }.stateIn(scope, SharingStarted.Lazily, null) private val currentFormat = currentMediaMetadata.flatMapLatest { mediaMetadata -> database.format(mediaMetadata?.id) } - private var currentSong: Song? = null private val normalizeFactor = MutableStateFlow(1f) val playerVolume = MutableStateFlow(dataStore.get(PlayerVolumeKey, 1f).coerceIn(0f, 1f)) - private var sleepTimerJob: Job? = null - var sleepTimerTriggerTime by mutableStateOf(-1L) - var pauseWhenSongEnd by mutableStateOf(false) - - @Inject - lateinit var databaseProvider: DatabaseProvider + lateinit var sleepTimer: SleepTimer @Inject @PlayerCache @@ -233,10 +202,16 @@ class MusicService : MediaLibraryService(), .build() .apply { addListener(this@MusicService) + sleepTimer = SleepTimer(scope, this) + addListener(sleepTimer) addAnalyticsListener(PlaybackStatsListener(false, this@MusicService)) repeatMode = dataStore.get(RepeatModeKey, Player.REPEAT_MODE_OFF) } - mediaSession = MediaLibrarySession.Builder(this, player, this) + mediaLibrarySessionCallback.apply { + toggleLike = ::toggleLike + toggleLibrary = ::toggleLibrary + } + mediaSession = MediaLibrarySession.Builder(this, player, mediaLibrarySessionCallback) .setSessionActivity( PendingIntent.getActivity( this, @@ -247,6 +222,11 @@ class MusicService : MediaLibraryService(), ) .setBitmapLoader(CoilBitmapLoader(this, scope)) .build() + // Keep a connected controller so that notification works + val sessionToken = SessionToken(this, ComponentName(this, MusicService::class.java)) + val controllerFuture = MediaController.Builder(this, sessionToken).buildAsync() + controllerFuture.addListener({ controllerFuture.get() }, MoreExecutors.directExecutor()) + connectivityManager = getSystemService()!! combine(playerVolume, normalizeFactor) { playerVolume, normalizeFactor -> @@ -261,9 +241,23 @@ class MusicService : MediaLibraryService(), } } - currentSongFlow.collect(scope) { song -> - currentSong = song - updateNotification(song) + currentSong.collect(scope) { song -> + mediaSession.setCustomLayout( + listOf( + CommandButton.Builder() + .setDisplayName(getString(if (song?.song?.inLibrary != null) R.string.remove_from_library else R.string.add_to_library)) + .setIconResId(if (song?.song?.inLibrary != null) R.drawable.library_add_check else R.drawable.library_add) + .setSessionCommand(CommandToggleLibrary) + .setEnabled(song != null) + .build(), + CommandButton.Builder() + .setDisplayName(getString(if (song?.song?.liked == true) R.string.action_remove_like else R.string.action_like)) + .setIconResId(if (song?.song?.liked == true) R.drawable.favorite else R.drawable.favorite_border) + .setSessionCommand(CommandToggleLike) + .setEnabled(song != null) + .build() + ) + ) } combine( @@ -326,30 +320,23 @@ class MusicService : MediaLibraryService(), ) } } - } - private fun updateNotification(song: Song?) { - mediaSession.setCustomLayout( - listOf( - CommandButton.Builder() - .setDisplayName(getString(if (song?.song?.inLibrary != null) R.string.remove_from_library else R.string.add_to_library)) - .setIconResId(if (song?.song?.inLibrary != null) R.drawable.library_add_check else R.drawable.library_add) - .setSessionCommand(CommandToggleLibrary) - .setEnabled(song != null) - .build(), - CommandButton.Builder() - .setDisplayName(getString(if (currentSong?.song?.liked == true) R.string.action_remove_like else R.string.action_like)) - .setIconResId(if (song?.song?.liked == true) R.drawable.favorite else R.drawable.favorite_border) - .setSessionCommand(CommandToggleLike) - .setEnabled(song != null) - .build() - ) - ) + // Save queue periodically to prevent queue loss from crash or force kill + scope.launch { + while (isActive) { + delay(30.seconds) + if (dataStore.get(PersistentQueueKey, true)) { + saveQueueToDisk() + } + } + } } private suspend fun recoverSong(mediaId: String, playerResponse: PlayerResponse? = null) { val song = database.song(mediaId).first() - val mediaMetadata = withContext(Dispatchers.Main) { player.findNextMediaItemById(mediaId)?.metadata } ?: return + val mediaMetadata = withContext(Dispatchers.Main) { + player.findNextMediaItemById(mediaId)?.metadata + } ?: return val duration = song?.song?.duration?.takeIf { it != -1 } ?: mediaMetadata.duration.takeIf { it != -1 } ?: (playerResponse ?: YouTube.player(mediaId).getOrNull())?.videoDetails?.lengthSeconds?.toInt() @@ -376,13 +363,9 @@ class MusicService : MediaLibraryService(), } } - private fun updateQueueTitle(title: String?) { - queueTitle = title - } - fun playQueue(queue: Queue, playWhenReady: Boolean = true) { currentQueue = queue - updateQueueTitle(null) + queueTitle = null player.shuffleModeEnabled = false if (queue.preloadItem != null) { player.setMediaItem(queue.preloadItem!!.toMediaItem()) @@ -393,9 +376,10 @@ class MusicService : MediaLibraryService(), scope.launch(SilentHandler) { val initialStatus = withContext(Dispatchers.IO) { queue.getInitialStatus() } if (queue.preloadItem != null && player.playbackState == STATE_IDLE) return@launch - initialStatus.title?.let { queueTitle -> - updateQueueTitle(queueTitle) + if (initialStatus.title != null) { + queueTitle = initialStatus.title } + if (initialStatus.items.isEmpty()) return@launch if (queue.preloadItem != null) { player.addMediaItems(0, initialStatus.items.subList(0, initialStatus.mediaItemIndex)) player.addMediaItems(initialStatus.items.subList(initialStatus.mediaItemIndex + 1, initialStatus.items.size)) @@ -414,8 +398,8 @@ class MusicService : MediaLibraryService(), scope.launch(SilentHandler) { val radioQueue = YouTubeQueue(endpoint = WatchEndpoint(videoId = currentMediaMetadata.id)) val initialStatus = radioQueue.getInitialStatus() - initialStatus.title?.let { queueTitle -> - updateQueueTitle(queueTitle) + if (initialStatus.title != null) { + queueTitle = initialStatus.title } player.addMediaItems(initialStatus.items.drop(1)) currentQueue = radioQueue @@ -434,7 +418,7 @@ class MusicService : MediaLibraryService(), fun toggleLibrary() { database.query { - currentSong?.let { + currentSong.value?.let { update(it.song.toggleLibrary()) } } @@ -442,34 +426,12 @@ class MusicService : MediaLibraryService(), fun toggleLike() { database.query { - currentSong?.let { + currentSong.value?.let { update(it.song.toggleLike()) } } } - fun setSleepTimer(minute: Int) { - sleepTimerJob?.cancel() - sleepTimerJob = null - if (minute == -1) { - pauseWhenSongEnd = true - } else { - sleepTimerTriggerTime = System.currentTimeMillis() + minute.minutes.inWholeMilliseconds - sleepTimerJob = scope.launch { - delay(minute.minutes) - player.pause() - sleepTimerTriggerTime = -1L - } - } - } - - fun clearSleepTimer() { - sleepTimerJob?.cancel() - sleepTimerJob = null - pauseWhenSongEnd = false - sleepTimerTriggerTime = -1L - } - private fun openAudioEffectSession() { sendBroadcast( Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply { @@ -488,10 +450,8 @@ class MusicService : MediaLibraryService(), ) } - /** - * Auto load more - */ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + // Auto load more songs if (reason != Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT && player.playbackState != STATE_IDLE && player.mediaItemCount - player.currentMediaItemIndex <= 5 && @@ -504,23 +464,13 @@ class MusicService : MediaLibraryService(), } } } - if (pauseWhenSongEnd) { - pauseWhenSongEnd = false - player.pause() - } } override fun onPlaybackStateChanged(@Player.State playbackState: Int) { if (playbackState == STATE_IDLE) { currentQueue = EmptyQueue player.shuffleModeEnabled = false - updateQueueTitle("") - } - if (playbackState == STATE_ENDED) { - if (pauseWhenSongEnd) { - pauseWhenSongEnd = false - player.pause() - } + queueTitle = null } } @@ -557,32 +507,34 @@ class MusicService : MediaLibraryService(), } } - private fun createOkHttpDataSourceFactory() = - OkHttpDataSource.Factory( - OkHttpClient.Builder() - .proxy(YouTube.proxy) - .build() - ) - - private fun createCacheDataSource(): CacheDataSource.Factory { - return CacheDataSource.Factory() + private fun createCacheDataSource(): CacheDataSource.Factory = + CacheDataSource.Factory() .setCache(downloadCache) .setUpstreamDataSourceFactory( CacheDataSource.Factory() .setCache(playerCache) - .setUpstreamDataSourceFactory(DefaultDataSource.Factory(this, createOkHttpDataSourceFactory())) + .setUpstreamDataSourceFactory( + DefaultDataSource.Factory( + this, + OkHttpDataSource.Factory( + OkHttpClient.Builder() + .proxy(YouTube.proxy) + .build() + ) + ) + ) ) .setCacheWriteDataSinkFactory(null) .setFlags(FLAG_IGNORE_CACHE_ON_ERROR) - } private fun createDataSourceFactory(): DataSource.Factory { val songUrlCache = HashMap>() return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> val mediaId = dataSpec.key ?: error("No media id") - val length = if (dataSpec.length >= 0) dataSpec.length else 1 - if (downloadCache.isCached(mediaId, dataSpec.position, length) || playerCache.isCached(mediaId, dataSpec.position, length)) { + if (downloadCache.isCached(mediaId, dataSpec.position, if (dataSpec.length >= 0) dataSpec.length else 1) || + playerCache.isCached(mediaId, dataSpec.position, CHUNK_LENGTH) + ) { scope.launch(Dispatchers.IO) { recoverSong(mediaId) } return@Factory dataSpec } @@ -653,28 +605,30 @@ class MusicService : MediaLibraryService(), } } - private fun createExtractorsFactory() = ExtractorsFactory { - arrayOf(MatroskaExtractor(), FragmentedMp4Extractor()) - } + private fun createMediaSourceFactory() = + DefaultMediaSourceFactory( + createDataSourceFactory(), + ExtractorsFactory { + arrayOf(MatroskaExtractor(), FragmentedMp4Extractor()) + } + ) - private fun createMediaSourceFactory() = DefaultMediaSourceFactory(createDataSourceFactory(), createExtractorsFactory()) - - private fun createRenderersFactory() = object : DefaultRenderersFactory(this) { - override fun buildAudioSink(context: Context, enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean, enableOffload: Boolean) = - DefaultAudioSink.Builder() - .setAudioCapabilities(AudioCapabilities.getCapabilities(context)) - .setEnableFloatOutput(enableFloatOutput) - .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) - .setOffloadMode(if (enableOffload) DefaultAudioSink.OFFLOAD_MODE_ENABLED_GAPLESS_REQUIRED else DefaultAudioSink.OFFLOAD_MODE_DISABLED) - .setAudioProcessorChain( - DefaultAudioSink.DefaultAudioProcessorChain( - emptyArray(), - SilenceSkippingAudioProcessor(2_000_000, 20_000, 256), - SonicAudioProcessor() + private fun createRenderersFactory() = + object : DefaultRenderersFactory(this) { + override fun buildAudioSink(context: Context, enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean, enableOffload: Boolean) = + DefaultAudioSink.Builder(this@MusicService) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .setOffloadMode(if (enableOffload) DefaultAudioSink.OFFLOAD_MODE_ENABLED_GAPLESS_REQUIRED else DefaultAudioSink.OFFLOAD_MODE_DISABLED) + .setAudioProcessorChain( + DefaultAudioSink.DefaultAudioProcessorChain( + emptyArray(), + SilenceSkippingAudioProcessor(2_000_000, 20_000, 256), + SonicAudioProcessor() + ) ) - ) - .build() - } + .build() + } override fun onPlaybackStatsReady(eventTime: AnalyticsListener.EventTime, playbackStats: PlaybackStats) { val mediaItem = eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem @@ -723,8 +677,8 @@ class MusicService : MediaLibraryService(), } mediaSession.release() player.removeListener(this) + player.removeListener(sleepTimer) player.release() - playerCache.release() super.onDestroy() } @@ -737,259 +691,6 @@ class MusicService : MediaLibraryService(), override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession - override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { - val connectionResult = super.onConnect(session, controller) - return MediaSession.ConnectionResult.accept( - connectionResult.availableSessionCommands.buildUpon() - .add(CommandToggleLibrary) - .add(CommandToggleLike).build(), - connectionResult.availablePlayerCommands - ) - } - - override fun onCustomCommand( - session: MediaSession, - controller: MediaSession.ControllerInfo, - customCommand: SessionCommand, - args: Bundle, - ): ListenableFuture { - when (customCommand.customAction) { - ACTION_TOGGLE_LIKE -> toggleLike() - ACTION_TOGGLE_LIBRARY -> toggleLibrary() - } - return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - - override fun onGetLibraryRoot( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - params: 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: LibraryParams?, - ): ListenableFuture>> = scope.future(Dispatchers.IO) { - LibraryResult.ofItemList( - when (parentId) { - ROOT -> listOf( - browsableMediaItem(SONG, getString(R.string.songs), null, drawableUri(R.drawable.music_note), MEDIA_TYPE_PLAYLIST), - browsableMediaItem(ARTIST, getString(R.string.artists), null, drawableUri(R.drawable.artist), MEDIA_TYPE_FOLDER_ARTISTS), - browsableMediaItem(ALBUM, getString(R.string.albums), null, drawableUri(R.drawable.album), MEDIA_TYPE_FOLDER_ALBUMS), - browsableMediaItem(PLAYLIST, getString(R.string.playlists), null, drawableUri(R.drawable.queue_music), MEDIA_TYPE_FOLDER_PLAYLISTS) - ) - - SONG -> database.songsByCreateDateAsc().first().map { it.toMediaItem(parentId) } - ARTIST -> database.artistsByCreateDateAsc().first().map { artist -> - browsableMediaItem("$ARTIST/${artist.id}", artist.artist.name, resources.getQuantityString(R.plurals.n_song, artist.songCount, artist.songCount), artist.artist.thumbnailUrl?.toUri(), MEDIA_TYPE_ARTIST) - } - - ALBUM -> database.albumsByCreateDateAsc().first().map { album -> - browsableMediaItem("$ALBUM/${album.id}", album.album.title, album.artists.joinToString(), album.album.thumbnailUrl?.toUri(), MEDIA_TYPE_ALBUM) - } - - PLAYLIST -> { - val likedSongCount = database.likedSongsCount().first() - val downloadedSongCount = downloadUtil.downloads.value.size - listOf( - browsableMediaItem("$PLAYLIST/$LIKED_PLAYLIST_ID", getString(R.string.liked_songs), resources.getQuantityString(R.plurals.n_song, likedSongCount, likedSongCount), drawableUri(R.drawable.favorite), MEDIA_TYPE_PLAYLIST), - browsableMediaItem("$PLAYLIST/$DOWNLOADED_PLAYLIST_ID", getString(R.string.downloaded_songs), resources.getQuantityString(R.plurals.n_song, downloadedSongCount, downloadedSongCount), drawableUri(R.drawable.download), MEDIA_TYPE_PLAYLIST) - ) + database.playlistsByCreateDateAsc().first().map { playlist -> - browsableMediaItem("$PLAYLIST/${playlist.id}", playlist.playlist.name, resources.getQuantityString(R.plurals.n_song, playlist.songCount, playlist.songCount), playlist.thumbnails.firstOrNull()?.toUri(), MEDIA_TYPE_PLAYLIST) - } - } - - else -> when { - parentId.startsWith("$ARTIST/") -> - database.artistSongsByCreateDateAsc(parentId.removePrefix("$ARTIST/")).first().map { - it.toMediaItem(parentId) - } - - parentId.startsWith("$ALBUM/") -> - database.albumSongs(parentId.removePrefix("$ALBUM/")).first().map { - it.toMediaItem(parentId) - } - - parentId.startsWith("$PLAYLIST/") -> - when (val playlistId = parentId.removePrefix("$PLAYLIST/")) { - LIKED_PLAYLIST_ID -> database.likedSongs(SongSortType.CREATE_DATE, true) - DOWNLOADED_PLAYLIST_ID -> { - val downloads = downloadUtil.downloads.value - database.allSongs() - .flowOn(Dispatchers.IO) - .map { songs -> - songs.filter { - downloads[it.id]?.state == Download.STATE_COMPLETED - } - } - .map { songs -> - songs.map { it to downloads[it.id] } - .sortedBy { it.second?.updateTimeMs ?: 0L } - .map { it.first } - } - } - - else -> database.playlistSongs(playlistId).map { list -> - list.map { it.song } - } - }.first().map { - it.toMediaItem(parentId) - } - - else -> emptyList() - } - }, - params - ) - } - - override fun onGetItem( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - mediaId: String, - ): ListenableFuture> = scope.future(Dispatchers.IO) { - database.song(mediaId).first()?.toMediaItem()?.let { - LibraryResult.ofItem(it, null) - } ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN) - } - - 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 = database.songsByCreateDateAsc().first() - MediaSession.MediaItemsWithStartPosition( - allSongs.map { it.toMediaItem() }, - allSongs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, - startPositionMs - ) - } - - ARTIST -> { - val songId = path.getOrNull(2) ?: return@future defaultResult - val artistId = path.getOrNull(1) ?: return@future defaultResult - val songs = database.artistSongsByCreateDateAsc(artistId).first() - MediaSession.MediaItemsWithStartPosition( - songs.map { it.toMediaItem() }, - songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, - startPositionMs - ) - } - - ALBUM -> { - val songId = path.getOrNull(2) ?: return@future defaultResult - val albumId = path.getOrNull(1) ?: return@future defaultResult - val albumWithSongs = database.albumWithSongs(albumId).first() ?: return@future defaultResult - MediaSession.MediaItemsWithStartPosition( - albumWithSongs.songs.map { it.toMediaItem() }, - albumWithSongs.songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, - startPositionMs - ) - } - - PLAYLIST -> { - val songId = path.getOrNull(2) ?: return@future defaultResult - val playlistId = path.getOrNull(1) ?: return@future defaultResult - val songs = when (playlistId) { - LIKED_PLAYLIST_ID -> database.likedSongs(SongSortType.CREATE_DATE, descending = true) - DOWNLOADED_PLAYLIST_ID -> { - val downloads = downloadUtil.downloads.value - database.allSongs() - .flowOn(Dispatchers.IO) - .map { songs -> - songs.filter { - downloads[it.id]?.state == Download.STATE_COMPLETED - } - } - .map { songs -> - songs.map { it to downloads[it.id] } - .sortedBy { it.second?.updateTimeMs ?: 0L } - .map { it.first } - } - } - - else -> database.playlistSongs(playlistId).map { list -> - list.map { it.song } - } - }.first() - MediaSession.MediaItemsWithStartPosition( - songs.map { it.toMediaItem() }, - songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, - startPositionMs - ) - } - - else -> defaultResult - } - } - - private fun drawableUri(@DrawableRes id: Int) = Uri.Builder() - .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) - .authority(resources.getResourcePackageName(id)) - .appendPath(resources.getResourceTypeName(id)) - .appendPath(resources.getResourceEntryName(id)) - .build() - - private fun browsableMediaItem(id: String, title: String, subtitle: String?, iconUri: Uri?, mediaType: Int = 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 Song.toMediaItem(path: String) = - MediaItem.Builder() - .setMediaId("$path/$id") - .setMediaMetadata( - MediaMetadata.Builder() - .setTitle(song.title) - .setSubtitle(artists.joinToString { it.name }) - .setArtist(artists.joinToString { it.name }) - .setArtworkUri(song.thumbnailUrl?.toUri()) - .setIsPlayable(true) - .setIsBrowsable(false) - .setMediaType(MEDIA_TYPE_MUSIC) - .build() - ) - .build() - inner class MusicBinder : Binder() { val service: MusicService get() = this@MusicService diff --git a/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt b/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt index e66ef1253..f7ed0f3d0 100644 --- a/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt +++ b/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt @@ -56,7 +56,7 @@ class PlayerConnection( val translating = MutableStateFlow(false) val currentLyrics = combine( context.dataStore.data.map { - it[TranslateLyricsKey] ?: true + it[TranslateLyricsKey] ?: false }.distinctUntilChanged(), mediaMetadata.flatMapLatest { mediaMetadata -> database.lyrics(mediaMetadata?.id) diff --git a/app/src/main/java/com/zionhuang/music/playback/SleepTimer.kt b/app/src/main/java/com/zionhuang/music/playback/SleepTimer.kt new file mode 100644 index 000000000..98f9e2c8d --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/playback/SleepTimer.kt @@ -0,0 +1,61 @@ +package com.zionhuang.music.playback + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.minutes + +class SleepTimer( + private val scope: CoroutineScope, + val player: Player, +) : Player.Listener { + private var sleepTimerJob: Job? = null + var triggerTime by mutableStateOf(-1L) + private set + var pauseWhenSongEnd by mutableStateOf(false) + private set + val isActive: Boolean + get() = triggerTime != -1L || pauseWhenSongEnd + + fun start(minute: Int) { + sleepTimerJob?.cancel() + sleepTimerJob = null + if (minute == -1) { + pauseWhenSongEnd = true + } else { + triggerTime = System.currentTimeMillis() + minute.minutes.inWholeMilliseconds + sleepTimerJob = scope.launch { + delay(minute.minutes) + player.pause() + triggerTime = -1L + } + } + } + + fun clear() { + sleepTimerJob?.cancel() + sleepTimerJob = null + pauseWhenSongEnd = false + triggerTime = -1L + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (pauseWhenSongEnd) { + pauseWhenSongEnd = false + player.pause() + } + } + + override fun onPlaybackStateChanged(@Player.State playbackState: Int) { + if (playbackState == Player.STATE_ENDED && pauseWhenSongEnd) { + pauseWhenSongEnd = false + player.pause() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/component/BottomSheet.kt b/app/src/main/java/com/zionhuang/music/ui/component/BottomSheet.kt index d345d2000..0b8bc6fbe 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/BottomSheet.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/BottomSheet.kt @@ -1,17 +1,38 @@ package com.zionhuang.music.ui.component import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.* +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.SpringSpec +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.DraggableState import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer @@ -70,6 +91,12 @@ fun BottomSheet( } ) } + .clip( + RoundedCornerShape( + topStart = if (!state.isExpanded) 16.dp else 0.dp, + topEnd = if (!state.isExpanded) 16.dp else 0.dp + ) + ) .background(backgroundColor) ) { if (!state.isCollapsed && !state.isDismissed) { @@ -206,6 +233,7 @@ class BottomSheetState( collapse() } } + in l1..l2 -> collapse() in l2..l3 -> expand() else -> Unit diff --git a/app/src/main/java/com/zionhuang/music/ui/component/HideOnScrollFAB.kt b/app/src/main/java/com/zionhuang/music/ui/component/HideOnScrollFAB.kt index 64581e51b..f34d2ceb2 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/HideOnScrollFAB.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/HideOnScrollFAB.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -51,6 +52,36 @@ fun BoxScope.HideOnScrollFAB( } } +@Composable +fun BoxScope.HideOnScrollFAB( + visible: Boolean = true, + lazyListState: LazyGridState, + @DrawableRes icon: Int, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = visible && lazyListState.isScrollingUp(), + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + ) { + FloatingActionButton( + modifier = Modifier.padding(16.dp), + onClick = onClick + ) { + Icon( + painter = painterResource(icon), + contentDescription = null + ) + } + } +} + @Composable fun BoxScope.HideOnScrollFAB( visible: Boolean = true, diff --git a/app/src/main/java/com/zionhuang/music/ui/component/Items.kt b/app/src/main/java/com/zionhuang/music/ui/component/Items.kt index 88045a713..713702746 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/Items.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/Items.kt @@ -7,7 +7,10 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkOut import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -23,6 +26,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -37,6 +41,7 @@ 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.Shape import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -56,6 +61,7 @@ import androidx.media3.exoplayer.offline.Download.STATE_QUEUED import coil.compose.AsyncImage import coil.compose.AsyncImagePainter import coil.request.ImageRequest +import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.AlbumItem import com.zionhuang.innertube.models.ArtistItem import com.zionhuang.innertube.models.PlaylistItem @@ -63,6 +69,7 @@ import com.zionhuang.innertube.models.SongItem import com.zionhuang.innertube.models.YTItem import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalDownloadUtil +import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.GridThumbnailHeight import com.zionhuang.music.constants.ListItemHeight @@ -72,12 +79,18 @@ import com.zionhuang.music.db.entities.Album import com.zionhuang.music.db.entities.Artist import com.zionhuang.music.db.entities.Playlist import com.zionhuang.music.db.entities.Song +import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.playback.queues.ListQueue import com.zionhuang.music.ui.theme.extractThemeColor import com.zionhuang.music.utils.joinByBullet import com.zionhuang.music.utils.makeTimeString +import com.zionhuang.music.utils.reportException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable inline fun ListItem( @@ -155,13 +168,33 @@ fun ListItem( fun GridItem( modifier: Modifier = Modifier, title: String, - subtitle: (@Composable RowScope.() -> Unit)? = null, - thumbnailContent: @Composable () -> Unit, + subtitle: String, + badges: @Composable RowScope.() -> Unit = {}, + thumbnailContent: @Composable BoxWithConstraintsScope.() -> Unit, + thumbnailShape: Shape, + thumbnailRatio: Float = 1f, + fillMaxWidth: Boolean = false, ) { Column( - modifier = modifier.padding(12.dp) + modifier = if (fillMaxWidth) { + modifier + .padding(12.dp) + .fillMaxWidth() + } else { + modifier + .padding(12.dp) + .width(GridThumbnailHeight * thumbnailRatio) + } ) { - Box { + BoxWithConstraints( + modifier = if (fillMaxWidth) { + Modifier.fillMaxWidth() + } else { + Modifier.height(GridThumbnailHeight) + } + .aspectRatio(thumbnailRatio) + .clip(thumbnailShape) + ) { thumbnailContent() } @@ -172,13 +205,21 @@ fun GridItem( style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold, maxLines = 2, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() ) - if (subtitle != null) { - Row(verticalAlignment = Alignment.CenterVertically) { - subtitle() - } + Row(verticalAlignment = Alignment.CenterVertically) { + badges() + + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) } } } @@ -290,10 +331,23 @@ fun SongListItem( fun ArtistListItem( artist: Artist, modifier: Modifier = Modifier, + badges: @Composable RowScope.() -> Unit = { + if (artist.artist.bookmarkedAt != null) { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, trailingContent: @Composable RowScope.() -> Unit = {}, ) = ListItem( title = artist.artist.name, subtitle = pluralStringResource(R.plurals.n_song, artist.songCount, artist.songCount), + badges = badges, thumbnailContent = { AsyncImage( model = artist.artist.thumbnailUrl, @@ -307,6 +361,40 @@ fun ArtistListItem( modifier = modifier ) +@Composable +fun ArtistGridItem( + artist: Artist, + modifier: Modifier = Modifier, + badges: @Composable RowScope.() -> Unit = { + if (artist.artist.bookmarkedAt != null) { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + fillMaxWidth: Boolean = false, +) = GridItem( + title = artist.artist.name, + subtitle = pluralStringResource(R.plurals.n_song, artist.songCount, artist.songCount), + badges = badges, + thumbnailContent = { + AsyncImage( + model = artist.artist.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + }, + thumbnailShape = CircleShape, + fillMaxWidth = fillMaxWidth, + modifier = modifier +) + @Composable fun AlbumListItem( album: Album, @@ -428,6 +516,167 @@ fun AlbumListItem( modifier = modifier ) +@Composable +fun AlbumGridItem( + album: Album, + modifier: Modifier = Modifier, + coroutineScope: CoroutineScope, + badges: @Composable RowScope.() -> Unit = { + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + var songs by remember { + mutableStateOf(emptyList()) + } + + LaunchedEffect(Unit) { + database.albumSongs(album.id).collect { + songs = it + } + } + + var downloadState by remember { + mutableStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(songs) { + if (songs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it.id]?.state == STATE_COMPLETED }) + STATE_COMPLETED + else if (songs.all { + downloads[it.id]?.state == STATE_QUEUED + || downloads[it.id]?.state == STATE_DOWNLOADING + || downloads[it.id]?.state == STATE_COMPLETED + }) + STATE_DOWNLOADING + else + Download.STATE_STOPPED + } + } + + if (album.album.bookmarkedAt != null) { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + + when (downloadState) { + STATE_COMPLETED -> Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + + STATE_DOWNLOADING -> CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier + .size(16.dp) + .padding(end = 2.dp) + ) + + else -> {} + } + }, + isActive: Boolean = false, + isPlaying: Boolean = false, + fillMaxWidth: Boolean = false, +) = GridItem( + title = album.album.title, + subtitle = album.artists.joinToString { it.name }, + badges = badges, + thumbnailContent = { + AsyncImage( + model = album.album.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + + AnimatedVisibility( + visible = isActive, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = 0.4f), + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + ) { + if (isPlaying) { + PlayingIndicator( + color = Color.White, + modifier = Modifier.height(24.dp) + ) + } else { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White + ) + } + } + } + + AnimatedVisibility( + visible = !isActive, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + ) { + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return@AnimatedVisibility + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.4f)) + .clickable { + coroutineScope.launch { + database + .albumWithSongs(album.id) + .first() + ?.songs + ?.map { it.toMediaItem() } + ?.let { + playerConnection.playQueue( + ListQueue( + title = album.album.title, + items = it + ) + ) + } + } + } + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White + ) + } + } + }, + thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius), + fillMaxWidth = fillMaxWidth, + modifier = modifier +) + @Composable fun PlaylistListItem( playlist: Playlist, @@ -480,6 +729,65 @@ fun PlaylistListItem( modifier = modifier ) +@Composable +fun PlaylistGridItem( + playlist: Playlist, + modifier: Modifier = Modifier, + badges: @Composable RowScope.() -> Unit = { }, + fillMaxWidth: Boolean = false, +) = GridItem( + title = playlist.playlist.name, + subtitle = pluralStringResource(R.plurals.n_song, playlist.songCount, playlist.songCount), + badges = badges, + thumbnailContent = { + val width = maxWidth + when (playlist.thumbnails.size) { + 0 -> Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null, + tint = LocalContentColor.current.copy(alpha = 0.8f), + modifier = Modifier + .size(width / 2) + .align(Alignment.Center) + ) + + 1 -> AsyncImage( + model = playlist.thumbnails[0], + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(width) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + + else -> Box( + modifier = Modifier + .size(width) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) { + listOf( + Alignment.TopStart, + Alignment.TopEnd, + Alignment.BottomStart, + Alignment.BottomEnd + ).fastForEachIndexed { index, alignment -> + AsyncImage( + model = playlist.thumbnails.getOrNull(index), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .align(alignment) + .size(width / 2) + ) + } + } + } + }, + thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius), + fillMaxWidth = fillMaxWidth, + modifier = modifier +) + @Composable fun MediaMetadataListItem( mediaMetadata: MediaMetadata, @@ -639,6 +947,7 @@ fun YouTubeListItem( fun YouTubeGridItem( item: YTItem, modifier: Modifier = Modifier, + coroutineScope: CoroutineScope? = null, badges: @Composable RowScope.() -> Unit = { val database = LocalDatabase.current val song by database.song(item.id).collectAsState(initial = null) @@ -758,6 +1067,62 @@ fun YouTubeGridItem( } } } + + androidx.compose.animation.AnimatedVisibility( + visible = item is AlbumItem && !isActive, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + ) { + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return@AnimatedVisibility + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.4f)) + .clickable { + coroutineScope?.launch(Dispatchers.IO) { + var songs = database + .albumWithSongs(item.id) + .first()?.songs?.map { it.toMediaItem() } + if (songs == null) { + YouTube + .album(item.id) + .onSuccess { albumPage -> + database.transaction { + insert(albumPage) + } + songs = albumPage.songs.map { it.toMediaItem() } + } + .onFailure { + reportException(it) + } + } + songs?.let { + withContext(Dispatchers.Main) { + playerConnection.playQueue( + ListQueue( + title = item.title, + items = it + ) + ) + } + } + } + } + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White + ) + } + } } Spacer(modifier = Modifier.height(6.dp)) diff --git a/app/src/main/java/com/zionhuang/music/ui/component/Lyrics.kt b/app/src/main/java/com/zionhuang/music/ui/component/Lyrics.kt index ae8e3acad..493bad6f9 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/Lyrics.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/Lyrics.kt @@ -248,7 +248,7 @@ fun Lyrics( .align(Alignment.BottomEnd) .padding(end = 12.dp) ) { - if (BuildConfig.FLAVOR == "full") { + if (BuildConfig.FLAVOR != "foss") { IconButton( onClick = { translationEnabled = !translationEnabled diff --git a/app/src/main/java/com/zionhuang/music/ui/component/NavigationTile.kt b/app/src/main/java/com/zionhuang/music/ui/component/NavigationTile.kt index ecd3771f9..7664c5a66 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/NavigationTile.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/NavigationTile.kt @@ -1,14 +1,18 @@ package com.zionhuang.music.ui.component import androidx.annotation.DrawableRes +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.padding -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -27,15 +31,21 @@ fun NavigationTile( Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier - .clip(RoundedCornerShape(4.dp)) - .clickable(onClick = onClick) - .padding(4.dp), + modifier = modifier.padding(6.dp), ) { - Icon( - painter = painterResource(icon), - contentDescription = null - ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) + .clickable(onClick = onClick) + ) { + Icon( + painter = painterResource(icon), + contentDescription = null + ) + } Text( text = title, diff --git a/app/src/main/java/com/zionhuang/music/ui/component/NavigationTitle.kt b/app/src/main/java/com/zionhuang/music/ui/component/NavigationTitle.kt index 37551bdc8..dbdcaffef 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/NavigationTitle.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/NavigationTitle.kt @@ -1,7 +1,6 @@ package com.zionhuang.music.ui.component import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -17,6 +16,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.zionhuang.music.R @@ -34,21 +34,21 @@ fun NavigationTitle( .clickable(enabled = onClick != null) { onClick?.invoke() } - .padding(12.dp) + .padding(horizontal = 12.dp, vertical = 12.dp) ) { - Column( + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f) - ) { - Text( - text = title, - style = MaterialTheme.typography.headlineSmall - ) - } + ) if (onClick != null) { Icon( - painter = painterResource(R.drawable.navigate_next), - contentDescription = null + painter = painterResource(R.drawable.arrow_forward), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary ) } } diff --git a/app/src/main/java/com/zionhuang/music/ui/component/SearchBar.kt b/app/src/main/java/com/zionhuang/music/ui/component/SearchBar.kt index 42db9b6c6..c29f8b39b 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/SearchBar.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/SearchBar.kt @@ -9,15 +9,49 @@ import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.* +import androidx.compose.material3.Decoration +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBarColors +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Strings +import androidx.compose.material3.Surface +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.contentColorFor +import androidx.compose.material3.getString import androidx.compose.material3.tokens.MotionTokens -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -85,7 +119,8 @@ fun SearchBar( animationSpec = tween( durationMillis = AnimationDurationMillis, easing = MotionTokens.EasingLegacyCubicBezier, - ) + ), + label = "" ) val defaultInputFieldShape = SearchBarDefaults.inputFieldShape @@ -98,6 +133,7 @@ fun SearchBar( val animatedRadius = SearchBarCornerRadius * (1 - animationProgress) RoundedCornerShape(CornerSize(animatedRadius)) } + animationProgress == 1f -> defaultFullScreenShape else -> shape } @@ -206,7 +242,7 @@ private fun SearchBarInputField( trailingIcon: @Composable (() -> Unit)? = null, colors: TextFieldColors = SearchBarDefaults.inputFieldColors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - focusRequester: FocusRequester = remember { FocusRequester() } + focusRequester: FocusRequester = remember { FocusRequester() }, ) { val searchSemantics = getString(Strings.SearchBarSearch) val suggestionsAvailableSemantics = getString(Strings.SuggestionsAvailable) diff --git a/app/src/main/java/com/zionhuang/music/ui/component/SortHeader.kt b/app/src/main/java/com/zionhuang/music/ui/component/SortHeader.kt index 57922f5d8..d072aa90f 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/SortHeader.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/SortHeader.kt @@ -3,7 +3,6 @@ package com.zionhuang.music.ui.component import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn @@ -35,14 +34,13 @@ inline fun > SortHeader( crossinline onSortTypeChange: (T) -> Unit, crossinline onSortDescendingChange: (Boolean) -> Unit, crossinline sortTypeText: (T) -> Int, - trailingText: String, modifier: Modifier = Modifier, ) { var menuExpanded by remember { mutableStateOf(false) } Row( verticalAlignment = Alignment.CenterVertically, - modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp) + modifier = modifier.padding(vertical = 8.dp) ) { Text( text = stringResource(sortTypeText(sortType)), @@ -96,13 +94,5 @@ inline fun > SortHeader( onClick = { onSortDescendingChange(!sortDescending) } ) } - - Spacer(Modifier.weight(1f)) - - Text( - text = trailingText, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.secondary - ) } } diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/AlbumMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/AlbumMenu.kt index 3ab27542b..4d81f571e 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/AlbumMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/AlbumMenu.kt @@ -47,6 +47,7 @@ import androidx.navigation.NavController import coil.compose.AsyncImage import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalDownloadUtil +import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.constants.ListThumbnailSize @@ -55,7 +56,6 @@ import com.zionhuang.music.db.entities.PlaylistSongMap import com.zionhuang.music.db.entities.Song import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.playback.ExoDownloadService -import com.zionhuang.music.playback.PlayerConnection import com.zionhuang.music.ui.component.AlbumListItem import com.zionhuang.music.ui.component.DownloadGridMenu import com.zionhuang.music.ui.component.GridMenu @@ -66,12 +66,12 @@ import com.zionhuang.music.ui.component.ListDialog fun AlbumMenu( originalAlbum: Album, navController: NavController, - playerConnection: PlayerConnection, onDismiss: () -> Unit, ) { val context = LocalContext.current val database = LocalDatabase.current val downloadUtil = LocalDownloadUtil.current + val playerConnection = LocalPlayerConnection.current ?: return val libraryAlbum by database.album(originalAlbum.id).collectAsState(initial = originalAlbum) val album = libraryAlbum ?: originalAlbum var songs by remember { diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/ArtistMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/ArtistMenu.kt new file mode 100644 index 000000000..00af650db --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/menu/ArtistMenu.kt @@ -0,0 +1,130 @@ +package com.zionhuang.music.ui.menu + +import android.content.Intent +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.ArtistSongSortType +import com.zionhuang.music.db.entities.Artist +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.ui.component.ArtistListItem +import com.zionhuang.music.ui.component.GridMenu +import com.zionhuang.music.ui.component.GridMenuItem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun ArtistMenu( + originalArtist: Artist, + coroutineScope: CoroutineScope, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val artistState = database.artist(originalArtist.id).collectAsState(initial = originalArtist) + val artist = artistState.value ?: originalArtist + + ArtistListItem( + artist = artist, + badges = {}, + trailingContent = { + IconButton( + onClick = { + database.transaction { + update(artist.artist.toggleLike()) + } + } + ) { + Icon( + painter = painterResource(if (artist.artist.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (artist.artist.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null + ) + } + } + ) + + Divider() + + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + if (artist.songCount > 0) { + GridMenuItem( + icon = R.drawable.play, + title = R.string.play + ) { + coroutineScope.launch { + val songs = withContext(Dispatchers.IO) { + database.artistSongs(artist.id, ArtistSongSortType.CREATE_DATE, true).first() + .map { it.toMediaItem() } + } + playerConnection.playQueue( + ListQueue( + title = artist.artist.name, + items = songs + ) + ) + } + onDismiss() + } + GridMenuItem( + icon = R.drawable.shuffle, + title = R.string.shuffle + ) { + coroutineScope.launch { + val songs = withContext(Dispatchers.IO) { + database.artistSongs(artist.id, ArtistSongSortType.CREATE_DATE, true).first() + .map { it.toMediaItem() } + .shuffled() + } + playerConnection.playQueue( + ListQueue( + title = artist.artist.name, + items = songs + ) + ) + } + onDismiss() + } + } + if (artist.artist.isYouTubeArtist) { + GridMenuItem( + icon = R.drawable.share, + title = R.string.share + ) { + onDismiss() + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/channel/${artist.id}") + } + context.startActivity(Intent.createChooser(intent, null)) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/PlayerMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/PlayerMenu.kt index 963d2acd8..f431cf0d6 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/PlayerMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/PlayerMenu.kt @@ -57,7 +57,6 @@ import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.db.entities.PlaylistSongMap import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.playback.ExoDownloadService -import com.zionhuang.music.playback.PlayerConnection import com.zionhuang.music.ui.component.BigSeekBar import com.zionhuang.music.ui.component.BottomSheetState import com.zionhuang.music.ui.component.DownloadGridMenu @@ -73,13 +72,13 @@ fun PlayerMenu( mediaMetadata: MediaMetadata?, navController: NavController, playerBottomSheetState: BottomSheetState, - playerConnection: PlayerConnection, onShowDetailsDialog: () -> Unit, onDismiss: () -> Unit, ) { mediaMetadata ?: return val context = LocalContext.current val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return val playerVolume = playerConnection.service.playerVolume.collectAsState() val activityResultLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/SongMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/SongMenu.kt index e4d94cc90..763f39e79 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/SongMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/SongMenu.kt @@ -45,6 +45,7 @@ import coil.compose.AsyncImage import com.zionhuang.innertube.models.WatchEndpoint import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalDownloadUtil +import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.constants.ListThumbnailSize @@ -54,7 +55,6 @@ import com.zionhuang.music.db.entities.Song import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.ExoDownloadService -import com.zionhuang.music.playback.PlayerConnection import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.component.DownloadGridMenu import com.zionhuang.music.ui.component.GridMenu @@ -68,11 +68,11 @@ fun SongMenu( originalSong: Song, event: Event? = null, navController: NavController, - playerConnection: PlayerConnection, onDismiss: () -> Unit, ) { val context = LocalContext.current val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return val songState = database.song(originalSong.id).collectAsState(initial = originalSong) val song = songState.value ?: originalSong val download by LocalDownloadUtil.current.getDownload(originalSong.id).collectAsState(initial = null) diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeAlbumMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeAlbumMenu.kt index 711b718df..d6b919d14 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeAlbumMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeAlbumMenu.kt @@ -42,12 +42,12 @@ import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.AlbumItem import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalDownloadUtil +import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.db.entities.PlaylistSongMap import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.playback.ExoDownloadService -import com.zionhuang.music.playback.PlayerConnection import com.zionhuang.music.playback.queues.YouTubeAlbumRadio import com.zionhuang.music.ui.component.DownloadGridMenu import com.zionhuang.music.ui.component.GridMenu @@ -60,12 +60,12 @@ import com.zionhuang.music.utils.reportException fun YouTubeAlbumMenu( albumItem: AlbumItem, navController: NavController, - playerConnection: PlayerConnection, onDismiss: () -> Unit, ) { val context = LocalContext.current val database = LocalDatabase.current val downloadUtil = LocalDownloadUtil.current + val playerConnection = LocalPlayerConnection.current ?: return val album by database.albumWithSongs(albumItem.id).collectAsState(initial = null) LaunchedEffect(Unit) { diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeArtistMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeArtistMenu.kt index 117721f79..2fb38e612 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeArtistMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeArtistMenu.kt @@ -5,23 +5,71 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.zionhuang.innertube.models.ArtistItem +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R -import com.zionhuang.music.playback.PlayerConnection +import com.zionhuang.music.db.entities.ArtistEntity import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.component.GridMenu import com.zionhuang.music.ui.component.GridMenuItem +import com.zionhuang.music.ui.component.YouTubeListItem +import java.time.LocalDateTime @Composable fun YouTubeArtistMenu( artist: ArtistItem, - playerConnection: PlayerConnection, onDismiss: () -> Unit, ) { val context = LocalContext.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val libraryArtist by database.artist(artist.id).collectAsState(initial = null) + + YouTubeListItem( + item = artist, + trailingContent = { + IconButton( + onClick = { + database.query { + val libraryArtist = libraryArtist + if (libraryArtist != null) { + update(libraryArtist.artist.toggleLike()) + } else { + insert( + ArtistEntity( + id = artist.id, + name = artist.title, + thumbnailUrl = artist.thumbnail, + bookmarkedAt = LocalDateTime.now() + ) + ) + } + } + } + ) { + Icon( + painter = painterResource(if (libraryArtist?.artist?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (libraryArtist?.artist?.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null + ) + } + + } + ) + + Divider() GridMenu( contentPadding = PaddingValues( diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubePlaylistMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubePlaylistMenu.kt index cee8308e3..eb0a9bdab 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubePlaylistMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubePlaylistMenu.kt @@ -17,11 +17,11 @@ import com.zionhuang.innertube.models.PlaylistItem import com.zionhuang.innertube.models.SongItem import com.zionhuang.innertube.utils.completed import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.db.entities.PlaylistSongMap import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.models.toMediaMetadata -import com.zionhuang.music.playback.PlayerConnection import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.component.GridMenu import com.zionhuang.music.ui.component.GridMenuItem @@ -34,12 +34,12 @@ import kotlinx.coroutines.withContext fun YouTubePlaylistMenu( playlist: PlaylistItem, songs: List = emptyList(), - playerConnection: PlayerConnection, coroutineScope: CoroutineScope, onDismiss: () -> Unit, ) { val context = LocalContext.current val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return var showChoosePlaylistDialog by rememberSaveable { mutableStateOf(false) diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeSongMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeSongMenu.kt index fb9c4a0a4..4d222cd2e 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeSongMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeSongMenu.kt @@ -44,6 +44,7 @@ import com.zionhuang.innertube.models.SongItem import com.zionhuang.innertube.models.WatchEndpoint import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalDownloadUtil +import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.constants.ListThumbnailSize @@ -54,7 +55,6 @@ import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.ExoDownloadService -import com.zionhuang.music.playback.PlayerConnection import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.component.DownloadGridMenu import com.zionhuang.music.ui.component.GridMenu @@ -69,11 +69,11 @@ import java.time.LocalDateTime fun YouTubeSongMenu( song: SongItem, navController: NavController, - playerConnection: PlayerConnection, onDismiss: () -> Unit, ) { val context = LocalContext.current val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return val librarySong by database.song(song.id).collectAsState(initial = null) val download by LocalDownloadUtil.current.getDownload(song.id).collectAsState(initial = null) val artists = remember { diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Player.kt b/app/src/main/java/com/zionhuang/music/ui/player/Player.kt index 934411645..6a147d7b2 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/Player.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/Player.kt @@ -1,6 +1,9 @@ package com.zionhuang.music.ui.player import android.content.res.Configuration +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -21,7 +24,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBarDefaults @@ -57,6 +60,7 @@ import androidx.media3.common.Player.STATE_READY import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R +import com.zionhuang.music.constants.PlayerHorizontalPadding import com.zionhuang.music.constants.QueuePeekHeight import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.models.MediaMetadata @@ -126,13 +130,24 @@ fun BottomSheetPlayer( } ) { val controlsContent: @Composable ColumnScope.(MediaMetadata) -> Unit = { mediaMetadata -> + val playPauseRoundness by animateDpAsState( + targetValue = if (isPlaying) 24.dp else 36.dp, + animationSpec = tween(durationMillis = 100, easing = LinearEasing), + label = "playPauseRoundness" + ) + Text( text = mediaMetadata.title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 16.dp) + modifier = Modifier + .padding(horizontal = PlayerHorizontalPadding) + .clickable(enabled = mediaMetadata.album != null) { + navController.navigate("album/${mediaMetadata.album!!.id}") + state.collapseSoft() + } ) Spacer(Modifier.height(6.dp)) @@ -141,7 +156,7 @@ fun BottomSheetPlayer( horizontalArrangement = Arrangement.Center, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp) + .padding(horizontal = PlayerHorizontalPadding) ) { mediaMetadata.artists.fastForEachIndexed { index, artist -> Text( @@ -180,7 +195,7 @@ fun BottomSheetPlayer( } sliderPosition = null }, - modifier = Modifier.padding(horizontal = 16.dp) + modifier = Modifier.padding(horizontal = PlayerHorizontalPadding) ) Row( @@ -188,7 +203,7 @@ fun BottomSheetPlayer( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp) + .padding(horizontal = PlayerHorizontalPadding + 4.dp) ) { Text( text = makeTimeString(sliderPosition ?: position), @@ -211,7 +226,7 @@ fun BottomSheetPlayer( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp) + .padding(horizontal = PlayerHorizontalPadding) ) { Box(modifier = Modifier.weight(1f)) { ResizableIconButton( @@ -241,7 +256,7 @@ fun BottomSheetPlayer( Box( modifier = Modifier .size(72.dp) - .clip(CircleShape) + .clip(RoundedCornerShape(playPauseRoundness)) .background(MaterialTheme.colorScheme.secondaryContainer) .clickable { if (playbackState == STATE_ENDED) { diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt index 0719dc1b8..9544632af 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt @@ -24,8 +24,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -47,6 +47,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -67,6 +68,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties +import androidx.media3.common.Timeline import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerConnection @@ -81,16 +83,17 @@ import com.zionhuang.music.ui.component.BottomSheetState import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.MediaMetadataListItem import com.zionhuang.music.ui.menu.PlayerMenu -import com.zionhuang.music.ui.utils.reordering.ReorderingLazyColumn -import com.zionhuang.music.ui.utils.reordering.draggedItem -import com.zionhuang.music.ui.utils.reordering.rememberReorderingState -import com.zionhuang.music.ui.utils.reordering.reorder import com.zionhuang.music.utils.makeTimeString import com.zionhuang.music.utils.rememberPreference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import org.burnoutcrew.reorderable.ReorderableItem +import org.burnoutcrew.reorderable.detectReorder +import org.burnoutcrew.reorderable.detectReorderAfterLongPress +import org.burnoutcrew.reorderable.rememberReorderableLazyListState +import org.burnoutcrew.reorderable.reorderable import kotlin.math.roundToInt @OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class) @@ -116,8 +119,8 @@ fun Queue( var showLyrics by rememberPreference(ShowLyricsKey, defaultValue = false) - val sleepTimerEnabled = remember(playerConnection.service.sleepTimerTriggerTime, playerConnection.service.pauseWhenSongEnd) { - playerConnection.service.sleepTimerTriggerTime != -1L || playerConnection.service.pauseWhenSongEnd + val sleepTimerEnabled = remember(playerConnection.service.sleepTimer.triggerTime, playerConnection.service.sleepTimer.pauseWhenSongEnd) { + playerConnection.service.sleepTimer.isActive } var sleepTimerTimeLeft by remember { @@ -127,10 +130,10 @@ fun Queue( LaunchedEffect(sleepTimerEnabled) { if (sleepTimerEnabled) { while (isActive) { - sleepTimerTimeLeft = if (playerConnection.service.pauseWhenSongEnd) { + sleepTimerTimeLeft = if (playerConnection.service.sleepTimer.pauseWhenSongEnd) { playerConnection.player.duration - playerConnection.player.currentPosition } else { - playerConnection.service.sleepTimerTriggerTime - System.currentTimeMillis() + playerConnection.service.sleepTimer.triggerTime - System.currentTimeMillis() } delay(1000L) } @@ -154,7 +157,7 @@ fun Queue( TextButton( onClick = { showSleepTimerDialog = false - playerConnection.service.setSleepTimer(sleepTimerValue.roundToInt()) + playerConnection.service.sleepTimer.start(sleepTimerValue.roundToInt()) } ) { Text(stringResource(android.R.string.ok)) @@ -184,7 +187,7 @@ fun Queue( OutlinedButton( onClick = { showSleepTimerDialog = false - playerConnection.service.setSleepTimer(-1) + playerConnection.service.sleepTimer.start(-1) } ) { Text(stringResource(R.string.end_of_song)) @@ -287,6 +290,7 @@ fun Queue( ) } AnimatedContent( + label = "sleepTimer", targetState = sleepTimerEnabled ) { sleepTimerEnabled -> if (sleepTimerEnabled) { @@ -295,7 +299,7 @@ fun Queue( style = MaterialTheme.typography.labelLarge, modifier = Modifier .clip(RoundedCornerShape(50)) - .clickable(onClick = playerConnection.service::clearSleepTimer) + .clickable(onClick = playerConnection.service.sleepTimer::clear) .padding(8.dp) ) } else { @@ -320,7 +324,6 @@ fun Queue( mediaMetadata = mediaMetadata, navController = navController, playerBottomSheetState = playerBottomSheetState, - playerConnection = playerConnection, onShowDetailsDialog = { showDetailsDialog = true }, onDismiss = menuState::dismiss ) @@ -337,31 +340,39 @@ fun Queue( ) { val queueTitle by playerConnection.queueTitle.collectAsState() val queueWindows by playerConnection.queueWindows.collectAsState() + val mutableQueueWindows = remember { mutableStateListOf() } val queueLength = remember(queueWindows) { queueWindows.sumOf { it.mediaItem.metadata!!.duration } } val coroutineScope = rememberCoroutineScope() - val reorderingState = rememberReorderingState( - lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = currentWindowIndex), - key = queueWindows, - onDragEnd = { currentIndex, newIndex -> + val reorderableState = rememberReorderableLazyListState( + onMove = { from, to -> + mutableQueueWindows.move(from.index, to.index) + }, + onDragEnd = { fromIndex, toIndex -> if (!playerConnection.player.shuffleModeEnabled) { - playerConnection.player.moveMediaItem(currentIndex, newIndex) + playerConnection.player.moveMediaItem(fromIndex, toIndex) } else { playerConnection.player.setShuffleOrder( DefaultShuffleOrder( - queueWindows.map { it.firstPeriodIndex }.toMutableList().move(currentIndex, newIndex).toIntArray(), + queueWindows.map { it.firstPeriodIndex }.toMutableList().move(fromIndex, toIndex).toIntArray(), System.currentTimeMillis() ) ) } - }, - extraItemCount = 0 + } ) - ReorderingLazyColumn( - reorderingState = reorderingState, + LaunchedEffect(queueWindows) { + mutableQueueWindows.apply { + clear() + addAll(queueWindows) + } + } + + LazyColumn( + state = reorderableState.listState, contentPadding = WindowInsets.systemBars .add( WindowInsets( @@ -370,67 +381,67 @@ fun Queue( ) ) .asPaddingValues(), - modifier = Modifier.nestedScroll(state.preUpPostDownNestedScrollConnection) + modifier = Modifier + .reorderable(reorderableState) + .nestedScroll(state.preUpPostDownNestedScrollConnection) ) { itemsIndexed( - items = queueWindows, + items = mutableQueueWindows, key = { _, item -> item.uid.hashCode() } ) { index, window -> - val currentItem by rememberUpdatedState(window) - val dismissState = rememberDismissState( - positionalThreshold = { totalDistance -> - totalDistance - }, - confirmValueChange = { dismissValue -> - if (dismissValue == DismissValue.DismissedToEnd || dismissValue == DismissValue.DismissedToStart) { - playerConnection.player.removeMediaItem(currentItem.firstPeriodIndex) + ReorderableItem( + reorderableState = reorderableState, + key = window.uid.hashCode() + ) { + val currentItem by rememberUpdatedState(window) + val dismissState = rememberDismissState( + positionalThreshold = { totalDistance -> + totalDistance + }, + confirmValueChange = { dismissValue -> + if (dismissValue == DismissValue.DismissedToEnd || dismissValue == DismissValue.DismissedToStart) { + playerConnection.player.removeMediaItem(currentItem.firstPeriodIndex) + } + true } - true - } - ) - SwipeToDismiss( - state = dismissState, - background = {}, - dismissContent = { - MediaMetadataListItem( - mediaMetadata = window.mediaItem.metadata!!, - isActive = index == currentWindowIndex, - isPlaying = isPlaying, - trailingContent = { - Icon( - painter = painterResource(R.drawable.drag_handle), - contentDescription = null, - modifier = Modifier - .reorder( - reorderingState = reorderingState, - index = index - ) - .clickable( - enabled = false, - onClick = {} + ) + SwipeToDismiss( + state = dismissState, + background = {}, + dismissContent = { + MediaMetadataListItem( + mediaMetadata = window.mediaItem.metadata!!, + isActive = index == currentWindowIndex, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { }, + modifier = Modifier + .detectReorder(reorderableState) + ) { + Icon( + painter = painterResource(R.drawable.drag_handle), + contentDescription = null ) - .padding(8.dp) - ) - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - coroutineScope.launch(Dispatchers.Main) { - if (index == currentWindowIndex) { - playerConnection.player.togglePlayPause() - } else { - playerConnection.player.seekToDefaultPosition(window.firstPeriodIndex) - playerConnection.player.playWhenReady = true + } + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + coroutineScope.launch(Dispatchers.Main) { + if (index == currentWindowIndex) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.player.seekToDefaultPosition(window.firstPeriodIndex) + playerConnection.player.playWhenReady = true + } } } - } - .draggedItem( - reorderingState = reorderingState, - index = index - ) - ) - } - ) + .detectReorderAfterLongPress(reorderableState) + ) + } + ) + } } } @@ -502,8 +513,8 @@ fun Queue( IconButton( modifier = Modifier.align(Alignment.CenterStart), onClick = { - reorderingState.coroutineScope.launch { - reorderingState.lazyListState.animateScrollToItem( + coroutineScope.launch { + reorderableState.listState.animateScrollToItem( if (playerConnection.player.shuffleModeEnabled) playerConnection.player.currentMediaItemIndex else 0 ) }.invokeOnCompletion { diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Thumbnail.kt b/app/src/main/java/com/zionhuang/music/ui/player/Thumbnail.kt index ad549194b..715f011dc 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/Thumbnail.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/Thumbnail.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.constants.PlayerHorizontalPadding import com.zionhuang.music.constants.ShowLyricsKey import com.zionhuang.music.constants.ThumbnailCornerRadius import com.zionhuang.music.ui.component.Lyrics @@ -60,14 +61,14 @@ fun Thumbnail( contentAlignment = Alignment.Center, modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp) + .padding(horizontal = PlayerHorizontalPadding) ) { AsyncImage( model = mediaMetadata?.thumbnailUrl, contentDescription = null, modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .clip(RoundedCornerShape(ThumbnailCornerRadius * 2)) .pointerInput(Unit) { detectTapGestures( onDoubleTap = { offset -> diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/AccountScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/AccountScreen.kt index ad104f7f6..80d7ff3a7 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/AccountScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/AccountScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets -import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.GridThumbnailHeight import com.zionhuang.music.ui.component.LocalMenuState @@ -41,7 +40,6 @@ fun AccountScreen( viewModel: AccountViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current - val playerConnection = LocalPlayerConnection.current ?: return val coroutineScope = rememberCoroutineScope() @@ -67,7 +65,6 @@ fun AccountScreen( menuState.show { YouTubePlaylistMenu( playlist = item, - playerConnection = playerConnection, coroutineScope = coroutineScope, onDismiss = menuState::dismiss ) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/AlbumScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/AlbumScreen.kt index 060769738..6a5fd6061 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/AlbumScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/AlbumScreen.kt @@ -103,7 +103,8 @@ fun AlbumScreen( } LaunchedEffect(albumWithSongs) { - val songs = albumWithSongs?.songs?.map { it.id } ?: return@LaunchedEffect + val songs = albumWithSongs?.songs?.map { it.id } + if (songs.isNullOrEmpty()) return@LaunchedEffect downloadUtil.downloads.collect { downloads -> downloadState = if (songs.all { downloads[it]?.state == Download.STATE_COMPLETED }) @@ -123,7 +124,7 @@ fun AlbumScreen( contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { val albumWithSongs = albumWithSongs - if (albumWithSongs != null) { + if (albumWithSongs != null && albumWithSongs.songs.isNotEmpty()) { item { Column( modifier = Modifier.padding(12.dp) @@ -270,7 +271,6 @@ fun AlbumScreen( AlbumMenu( originalAlbum = Album(albumWithSongs.album, albumWithSongs.artists), navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) } @@ -352,7 +352,6 @@ fun AlbumScreen( SongMenu( originalSong = song, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt index 56ccaf206..3f3719a32 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt @@ -89,7 +89,6 @@ fun HistoryScreen( originalSong = event.song, event = event.event, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/HomeScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/HomeScreen.kt index 83497de52..f9955e9d3 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/HomeScreen.kt @@ -69,6 +69,7 @@ fun HomeScreen( "SAPISID" in parseCookieString(innerTubeCookie) } + val coroutineScope = rememberCoroutineScope() val scrollState = rememberScrollState() SwipeRefresh( @@ -96,10 +97,10 @@ fun HomeScreen( Spacer(Modifier.height(LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateTopPadding())) Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) .padding(horizontal = 12.dp, vertical = 6.dp) + .fillMaxWidth() ) { NavigationTile( title = stringResource(R.string.history), @@ -174,7 +175,6 @@ fun HomeScreen( SongMenu( originalSong = song!!, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) } @@ -222,6 +222,7 @@ fun HomeScreen( item = album, isActive = mediaMetadata?.album?.id == album.id, isPlaying = isPlaying, + coroutineScope = coroutineScope, modifier = Modifier .combinedClickable( onClick = { @@ -232,7 +233,6 @@ fun HomeScreen( YouTubeAlbumMenu( albumItem = album, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/LoginScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/LoginScreen.kt index 75f315fb5..d141019bb 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/LoginScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/LoginScreen.kt @@ -24,6 +24,7 @@ import androidx.navigation.NavController import com.zionhuang.innertube.YouTube import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R +import com.zionhuang.music.constants.AccountChannelHandleKey import com.zionhuang.music.constants.AccountEmailKey import com.zionhuang.music.constants.AccountNameKey import com.zionhuang.music.constants.InnerTubeCookieKey @@ -44,6 +45,7 @@ fun LoginScreen( var innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") var accountName by rememberPreference(AccountNameKey, "") var accountEmail by rememberPreference(AccountEmailKey, "") + var accountChannelHandle by rememberPreference(AccountChannelHandleKey, "") var webView: WebView? = null @@ -60,7 +62,8 @@ fun LoginScreen( GlobalScope.launch { YouTube.accountInfo().onSuccess { accountName = it.name - accountEmail = it.email + accountEmail = it.email.orEmpty() + accountChannelHandle = it.channelHandle.orEmpty() }.onFailure { reportException(it) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/MoodAndGenresScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/MoodAndGenresScreen.kt index 03adecc6f..c2d5536a6 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/MoodAndGenresScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/MoodAndGenresScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -36,6 +35,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R +import com.zionhuang.music.ui.component.NavigationTitle import com.zionhuang.music.ui.component.shimmer.ListItemPlaceHolder import com.zionhuang.music.ui.component.shimmer.ShimmerHost import com.zionhuang.music.viewmodels.MoodAndGenresViewModel @@ -67,12 +67,8 @@ fun MoodAndGenresScreen( moodAndGenresList?.forEach { moodAndGenres -> item { - Text( - text = moodAndGenres.title, - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) + NavigationTitle( + title = moodAndGenres.title ) Column( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/NewReleaseScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/NewReleaseScreen.kt index b2e053024..4e9fc45c7 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/NewReleaseScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/NewReleaseScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -46,6 +47,8 @@ fun NewReleaseScreen( val newReleaseAlbums by viewModel.newReleaseAlbums.collectAsState() + val coroutineScope = rememberCoroutineScope() + LazyVerticalGrid( columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() @@ -59,6 +62,7 @@ fun NewReleaseScreen( isActive = mediaMetadata?.album?.id == album.id, isPlaying = isPlaying, fillMaxWidth = true, + coroutineScope = coroutineScope, modifier = Modifier .combinedClickable( onClick = { @@ -69,7 +73,6 @@ fun NewReleaseScreen( YouTubeAlbumMenu( albumItem = album, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/StatsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/StatsScreen.kt index 82bc94319..97e01cb32 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/StatsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/StatsScreen.kt @@ -1,7 +1,6 @@ package com.zionhuang.music.ui.screens import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues @@ -9,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -18,6 +18,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource @@ -32,13 +33,14 @@ import com.zionhuang.music.constants.StatPeriod import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.YouTubeQueue -import com.zionhuang.music.ui.component.AlbumListItem -import com.zionhuang.music.ui.component.ArtistListItem +import com.zionhuang.music.ui.component.AlbumGridItem +import com.zionhuang.music.ui.component.ArtistGridItem import com.zionhuang.music.ui.component.ChipsRow import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.NavigationTitle import com.zionhuang.music.ui.component.SongListItem import com.zionhuang.music.ui.menu.AlbumMenu +import com.zionhuang.music.ui.menu.ArtistMenu import com.zionhuang.music.ui.menu.SongMenu import com.zionhuang.music.viewmodels.StatsViewModel @@ -58,6 +60,8 @@ fun StatsScreen( val mostPlayedArtists by viewModel.mostPlayedArtists.collectAsState() val mostPlayedAlbums by viewModel.mostPlayedAlbums.collectAsState() + val coroutineScope = rememberCoroutineScope() + LazyColumn( contentPadding = LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom).asPaddingValues(), modifier = Modifier.windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Top)) @@ -77,9 +81,13 @@ fun StatsScreen( ) } - item { - NavigationTitle(stringResource(R.string.most_played_songs)) + item(key = "mostPlayedSongs") { + NavigationTitle( + title = stringResource(R.string.most_played_songs), + modifier = Modifier.animateItemPlacement() + ) } + items( items = mostPlayedSongs, key = { it.id } @@ -95,7 +103,6 @@ fun StatsScreen( SongMenu( originalSong = song, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) } @@ -125,62 +132,82 @@ fun StatsScreen( ) } - item { - NavigationTitle(stringResource(R.string.most_played_artists)) - } - items( - items = mostPlayedArtists, - key = { it.id } - ) { artist -> - ArtistListItem( - artist = artist, - modifier = Modifier - .fillMaxWidth() - .clickable { - navController.navigate("artist/${artist.id}") - } - .animateItemPlacement() + item(key = "mostPlayedArtists") { + NavigationTitle( + title = stringResource(R.string.most_played_artists), + modifier = Modifier.animateItemPlacement() ) - } - if (mostPlayedAlbums.isNotEmpty()) { - item { - NavigationTitle(stringResource(R.string.most_played_albums)) - } - items( - items = mostPlayedAlbums, - key = { it.id } - ) { album -> - AlbumListItem( - album = album, - isActive = album.id == mediaMetadata?.album?.id, - isPlaying = isPlaying, - trailingContent = { - IconButton( - onClick = { - menuState.show { - AlbumMenu( - originalAlbum = album, - navController = navController, - playerConnection = playerConnection, - onDismiss = menuState::dismiss - ) + LazyRow( + modifier = Modifier.animateItemPlacement() + ) { + items( + items = mostPlayedArtists, + key = { it.id } + ) { artist -> + ArtistGridItem( + artist = artist, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("artist/${artist.id}") + }, + onLongClick = { + menuState.show { + ArtistMenu( + originalArtist = artist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } } - } - ) { - Icon( - painter = painterResource(R.drawable.more_vert), - contentDescription = null ) - } - }, - modifier = Modifier - .fillMaxWidth() - .combinedClickable { - navController.navigate("album/${album.id}") - } - .animateItemPlacement() + .animateItemPlacement() + ) + } + } + } + + if (mostPlayedAlbums.isNotEmpty()) { + item(key = "mostPlayedAlbums") { + NavigationTitle( + title = stringResource(R.string.most_played_albums), + modifier = Modifier.animateItemPlacement() ) + + LazyRow( + modifier = Modifier.animateItemPlacement() + ) { + items( + items = mostPlayedAlbums, + key = { it.id } + ) { album -> + AlbumGridItem( + album = album, + isActive = album.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + coroutineScope = coroutineScope, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("album/${album.id}") + }, + onLongClick = { + menuState.show { + AlbumMenu( + originalAlbum = album, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItemPlacement() + ) + } + } } } } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/YouTubeBrowseScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/YouTubeBrowseScreen.kt index 1d037fb7f..aea4e7099 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/YouTubeBrowseScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/YouTubeBrowseScreen.kt @@ -3,14 +3,11 @@ package com.zionhuang.music.ui.screens import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarScrollBehavior @@ -20,7 +17,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.innertube.models.AlbumItem @@ -35,6 +31,7 @@ import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.NavigationTitle import com.zionhuang.music.ui.component.YouTubeListItem import com.zionhuang.music.ui.component.shimmer.ListItemPlaceHolder import com.zionhuang.music.ui.component.shimmer.ShimmerHost @@ -76,13 +73,7 @@ fun YouTubeBrowseScreen( browseResult?.items?.forEach { it.title?.let { title -> item { - Text( - text = title, - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - ) + NavigationTitle(title) } } @@ -103,26 +94,22 @@ fun YouTubeBrowseScreen( is SongItem -> YouTubeSongMenu( song = item, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) is AlbumItem -> YouTubeAlbumMenu( albumItem = item, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) is ArtistItem -> YouTubeArtistMenu( artist = item, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) is PlaylistItem -> YouTubePlaylistMenu( playlist = item, - playerConnection = playerConnection, coroutineScope = coroutineScope, onDismiss = menuState::dismiss ) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistItemsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistItemsScreen.kt index b16d32cb5..66cf5ea45 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistItemsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistItemsScreen.kt @@ -113,26 +113,22 @@ fun ArtistItemsScreen( is SongItem -> YouTubeSongMenu( song = item, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) is AlbumItem -> YouTubeAlbumMenu( albumItem = item, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) is ArtistItem -> YouTubeArtistMenu( artist = item, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) is PlaylistItem -> YouTubePlaylistMenu( playlist = item, - playerConnection = playerConnection, coroutineScope = coroutineScope, onDismiss = menuState::dismiss ) @@ -193,6 +189,7 @@ fun ArtistItemsScreen( }, isPlaying = isPlaying, fillMaxWidth = true, + coroutineScope = coroutineScope, modifier = Modifier .combinedClickable( onClick = { @@ -209,26 +206,22 @@ fun ArtistItemsScreen( is SongItem -> YouTubeSongMenu( song = item, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) is AlbumItem -> YouTubeAlbumMenu( albumItem = item, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) is ArtistItem -> YouTubeArtistMenu( artist = item, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) is PlaylistItem -> YouTubePlaylistMenu( playlist = item, - playerConnection = playerConnection, coroutineScope = coroutineScope, onDismiss = menuState::dismiss ) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistScreen.kt index f8e168dee..cc8438aa8 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistScreen.kt @@ -230,7 +230,6 @@ fun ArtistScreen( SongMenu( originalSong = song, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) } @@ -284,7 +283,6 @@ fun ArtistScreen( YouTubeSongMenu( song = song, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) } @@ -322,6 +320,7 @@ fun ArtistScreen( else -> false }, isPlaying = isPlaying, + coroutineScope = coroutineScope, modifier = Modifier .combinedClickable( onClick = { @@ -338,26 +337,22 @@ fun ArtistScreen( is SongItem -> YouTubeSongMenu( song = item, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) is AlbumItem -> YouTubeAlbumMenu( albumItem = item, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) is ArtistItem -> YouTubeArtistMenu( artist = item, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) is PlaylistItem -> YouTubePlaylistMenu( playlist = item, - playerConnection = playerConnection, coroutineScope = coroutineScope, onDismiss = menuState::dismiss ) @@ -432,7 +427,7 @@ fun ArtistScreen( IconButton( onClick = { database.transaction { - val artist = libraryArtist + val artist = libraryArtist?.artist if (artist != null) { update(artist.toggleLike()) } else { @@ -451,8 +446,8 @@ fun ArtistScreen( } ) { Icon( - painter = painterResource(if (libraryArtist?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), - tint = if (libraryArtist?.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + painter = painterResource(if (libraryArtist?.artist?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (libraryArtist?.artist?.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, contentDescription = null ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt index 0e3ba8458..32e142e25 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt @@ -3,25 +3,31 @@ package com.zionhuang.music.ui.screens.artist import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets @@ -31,7 +37,6 @@ import com.zionhuang.music.constants.ArtistSongSortDescendingKey import com.zionhuang.music.constants.ArtistSongSortType import com.zionhuang.music.constants.ArtistSongSortTypeKey import com.zionhuang.music.constants.CONTENT_TYPE_HEADER -import com.zionhuang.music.constants.CONTENT_TYPE_SONG import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.playback.queues.ListQueue @@ -76,26 +81,37 @@ fun ArtistSongsScreen( key = "header", contentType = CONTENT_TYPE_HEADER ) { - SortHeader( - sortType = sortType, - sortDescending = sortDescending, - onSortTypeChange = onSortTypeChange, - onSortDescendingChange = onSortDescendingChange, - sortTypeText = { sortType -> - when (sortType) { - ArtistSongSortType.CREATE_DATE -> R.string.sort_by_create_date - ArtistSongSortType.NAME -> R.string.sort_by_name - ArtistSongSortType.PLAY_TIME -> R.string.sort_by_play_time + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + ArtistSongSortType.CREATE_DATE -> R.string.sort_by_create_date + ArtistSongSortType.NAME -> R.string.sort_by_name + ArtistSongSortType.PLAY_TIME -> R.string.sort_by_play_time + } } - }, - trailingText = pluralStringResource(R.plurals.n_song, songs.size, songs.size) - ) + ) + + Spacer(Modifier.weight(1f)) + + Text( + text = pluralStringResource(R.plurals.n_song, songs.size, songs.size), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } } itemsIndexed( items = songs, - key = { _, item -> item.id }, - contentType = { _, _ -> CONTENT_TYPE_SONG } + key = { _, item -> item.id } ) { index, song -> SongListItem( song = song, @@ -108,7 +124,6 @@ fun ArtistSongsScreen( SongMenu( originalSong = song, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) } @@ -141,7 +156,7 @@ fun ArtistSongsScreen( } TopAppBar( - title = { Text(artist?.name.orEmpty()) }, + title = { Text(artist?.artist?.name.orEmpty()) }, navigationIcon = { IconButton(onClick = navController::navigateUp) { Icon( @@ -159,7 +174,7 @@ fun ArtistSongsScreen( onClick = { playerConnection.playQueue( ListQueue( - title = artist?.name, + title = artist?.artist?.name, items = songs.shuffled().map { it.toMediaItem() }, ) ) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryAlbumsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryAlbumsScreen.kt index 22f541fef..23626e576 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryAlbumsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryAlbumsScreen.kt @@ -3,27 +3,40 @@ package com.zionhuang.music.ui.screens.library import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.* +import com.zionhuang.music.ui.component.AlbumGridItem import com.zionhuang.music.ui.component.AlbumListItem import com.zionhuang.music.ui.component.ChipsRow import com.zionhuang.music.ui.component.LocalMenuState @@ -45,88 +58,189 @@ fun LibraryAlbumsScreen( val mediaMetadata by playerConnection.mediaMetadata.collectAsState() var filter by rememberEnumPreference(AlbumFilterKey, AlbumFilter.LIBRARY) + var viewType by rememberEnumPreference(AlbumViewTypeKey, LibraryViewType.GRID) val (sortType, onSortTypeChange) = rememberEnumPreference(AlbumSortTypeKey, AlbumSortType.CREATE_DATE) val (sortDescending, onSortDescendingChange) = rememberPreference(AlbumSortDescendingKey, true) val albums by viewModel.allAlbums.collectAsState() - Box( - modifier = Modifier.fillMaxSize() - ) { - LazyColumn( - contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() - ) { - item(key = "filter") { - ChipsRow( - chips = listOf( - AlbumFilter.LIBRARY to stringResource(R.string.filter_library), - AlbumFilter.LIKED to stringResource(R.string.filter_liked) - ), - currentValue = filter, - onValueUpdate = { filter = it } - ) - } + val coroutineScope = rememberCoroutineScope() + + val filterContent = @Composable { + Row { + ChipsRow( + chips = listOf( + AlbumFilter.LIBRARY to stringResource(R.string.filter_library), + AlbumFilter.LIKED to stringResource(R.string.filter_liked) + ), + currentValue = filter, + onValueUpdate = { filter = it }, + modifier = Modifier.weight(1f) + ) - item( - key = "header", - contentType = CONTENT_TYPE_HEADER + IconButton( + onClick = { + viewType = viewType.toggle() + }, + modifier = Modifier.padding(end = 6.dp) ) { - SortHeader( - sortType = sortType, - sortDescending = sortDescending, - onSortTypeChange = onSortTypeChange, - onSortDescendingChange = onSortDescendingChange, - sortTypeText = { sortType -> - when (sortType) { - AlbumSortType.CREATE_DATE -> R.string.sort_by_create_date - AlbumSortType.NAME -> R.string.sort_by_name - AlbumSortType.ARTIST -> R.string.sort_by_artist - AlbumSortType.YEAR -> R.string.sort_by_year - AlbumSortType.SONG_COUNT -> R.string.sort_by_song_count - AlbumSortType.LENGTH -> R.string.sort_by_length - AlbumSortType.PLAY_TIME -> R.string.sort_by_play_time + Icon( + painter = painterResource( + when (viewType) { + LibraryViewType.LIST -> R.drawable.list + LibraryViewType.GRID -> R.drawable.grid_view } - }, - trailingText = pluralStringResource(R.plurals.n_album, albums.size, albums.size) + ), + contentDescription = null ) } + } + } + + val headerContent = @Composable { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + AlbumSortType.CREATE_DATE -> R.string.sort_by_create_date + AlbumSortType.NAME -> R.string.sort_by_name + AlbumSortType.ARTIST -> R.string.sort_by_artist + AlbumSortType.YEAR -> R.string.sort_by_year + AlbumSortType.SONG_COUNT -> R.string.sort_by_song_count + AlbumSortType.LENGTH -> R.string.sort_by_length + AlbumSortType.PLAY_TIME -> R.string.sort_by_play_time + } + } + ) + + Spacer(Modifier.weight(1f)) + + Text( + text = pluralStringResource(R.plurals.n_album, albums.size, albums.size), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + when (viewType) { + LibraryViewType.LIST -> + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + contentType = CONTENT_TYPE_HEADER + ) { + filterContent() + } - items( - items = albums, - key = { it.id }, - contentType = { CONTENT_TYPE_ALBUM } - ) { album -> - AlbumListItem( - album = album, - isActive = album.id == mediaMetadata?.album?.id, - isPlaying = isPlaying, - trailingContent = { - IconButton( - onClick = { - menuState.show { - AlbumMenu( - originalAlbum = album, - navController = navController, - playerConnection = playerConnection, - onDismiss = menuState::dismiss + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + headerContent() + } + + items( + items = albums, + key = { it.id }, + contentType = { CONTENT_TYPE_ALBUM } + ) { album -> + AlbumListItem( + album = album, + isActive = album.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + AlbumMenu( + originalAlbum = album, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null ) } - } - ) { - Icon( - painter = painterResource(R.drawable.more_vert), - contentDescription = null - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .combinedClickable { - navController.navigate("album/${album.id}") - } - .animateItemPlacement() - ) - } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + navController.navigate("album/${album.id}") + } + .animateItemPlacement() + ) + } + } + + LibraryViewType.GRID -> + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER + ) { + filterContent() + } + + item( + key = "header", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER + ) { + headerContent() + } + + items( + items = albums, + key = { it.id }, + contentType = { CONTENT_TYPE_ALBUM } + ) { album -> + AlbumGridItem( + album = album, + isActive = album.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + coroutineScope = coroutineScope, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("album/${album.id}") + }, + onLongClick = { + menuState.show { + AlbumMenu( + originalAlbum = album, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItemPlacement() + ) + } + } } } } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryArtistsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryArtistsScreen.kt index 92b5c784c..a1aa52850 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryArtistsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryArtistsScreen.kt @@ -2,27 +2,37 @@ package com.zionhuang.music.ui.screens.library import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController -import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.constants.ArtistFilter @@ -30,10 +40,17 @@ import com.zionhuang.music.constants.ArtistFilterKey import com.zionhuang.music.constants.ArtistSortDescendingKey import com.zionhuang.music.constants.ArtistSortType import com.zionhuang.music.constants.ArtistSortTypeKey +import com.zionhuang.music.constants.ArtistViewTypeKey import com.zionhuang.music.constants.CONTENT_TYPE_ARTIST +import com.zionhuang.music.constants.CONTENT_TYPE_HEADER +import com.zionhuang.music.constants.GridThumbnailHeight +import com.zionhuang.music.constants.LibraryViewType +import com.zionhuang.music.ui.component.ArtistGridItem import com.zionhuang.music.ui.component.ArtistListItem import com.zionhuang.music.ui.component.ChipsRow +import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.SortHeader +import com.zionhuang.music.ui.menu.ArtistMenu import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.LibraryArtistsViewModel @@ -44,78 +61,182 @@ fun LibraryArtistsScreen( navController: NavController, viewModel: LibraryArtistsViewModel = hiltViewModel(), ) { - val database = LocalDatabase.current + val menuState = LocalMenuState.current var filter by rememberEnumPreference(ArtistFilterKey, ArtistFilter.LIBRARY) + var viewType by rememberEnumPreference(ArtistViewTypeKey, LibraryViewType.GRID) val (sortType, onSortTypeChange) = rememberEnumPreference(ArtistSortTypeKey, ArtistSortType.CREATE_DATE) val (sortDescending, onSortDescendingChange) = rememberPreference(ArtistSortDescendingKey, true) val artists by viewModel.allArtists.collectAsState() + val coroutineScope = rememberCoroutineScope() - Box( - modifier = Modifier.fillMaxSize() - ) { - LazyColumn( - contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() - ) { - item(key = "filter") { - ChipsRow( - chips = listOf( - ArtistFilter.LIBRARY to stringResource(R.string.filter_library), - ArtistFilter.LIKED to stringResource(R.string.filter_liked) - ), - currentValue = filter, - onValueUpdate = { filter = it } - ) - } + val filterContent = @Composable { + Row { + ChipsRow( + chips = listOf( + ArtistFilter.LIBRARY to stringResource(R.string.filter_library), + ArtistFilter.LIKED to stringResource(R.string.filter_liked) + ), + currentValue = filter, + onValueUpdate = { filter = it }, + modifier = Modifier.weight(1f) + ) - item(key = "header") { - SortHeader( - sortType = sortType, - sortDescending = sortDescending, - onSortTypeChange = onSortTypeChange, - onSortDescendingChange = onSortDescendingChange, - sortTypeText = { sortType -> - when (sortType) { - ArtistSortType.CREATE_DATE -> R.string.sort_by_create_date - ArtistSortType.NAME -> R.string.sort_by_name - ArtistSortType.SONG_COUNT -> R.string.sort_by_song_count - ArtistSortType.PLAY_TIME -> R.string.sort_by_play_time + IconButton( + onClick = { + viewType = viewType.toggle() + }, + modifier = Modifier.padding(end = 6.dp) + ) { + Icon( + painter = painterResource( + when (viewType) { + LibraryViewType.LIST -> R.drawable.list + LibraryViewType.GRID -> R.drawable.grid_view } - }, - trailingText = pluralStringResource(R.plurals.n_artist, artists.size, artists.size) + ), + contentDescription = null ) } + } + } + + val headerContent = @Composable { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + ArtistSortType.CREATE_DATE -> R.string.sort_by_create_date + ArtistSortType.NAME -> R.string.sort_by_name + ArtistSortType.SONG_COUNT -> R.string.sort_by_song_count + ArtistSortType.PLAY_TIME -> R.string.sort_by_play_time + } + } + ) - items( - items = artists, - key = { it.id }, - contentType = { CONTENT_TYPE_ARTIST } - ) { artist -> - ArtistListItem( - artist = artist, - trailingContent = { - IconButton( - onClick = { - database.transaction { - update(artist.artist.toggleLike()) + Spacer(Modifier.weight(1f)) + + Text( + text = pluralStringResource(R.plurals.n_artist, artists.size, artists.size), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + when (viewType) { + LibraryViewType.LIST -> + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + contentType = CONTENT_TYPE_HEADER + ) { + filterContent() + } + + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + headerContent() + } + + items( + items = artists, + key = { it.id }, + contentType = { CONTENT_TYPE_ARTIST } + ) { artist -> + ArtistListItem( + artist = artist, + trailingContent = { + IconButton( + onClick = { + menuState.show { + ArtistMenu( + originalArtist = artist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) } - } - ) { - Icon( - painter = painterResource(if (artist.artist.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), - tint = if (artist.artist.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, - contentDescription = null - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - navController.navigate("artist/${artist.id}") - } - .animateItemPlacement() - ) - } + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + navController.navigate("artist/${artist.id}") + } + .animateItemPlacement() + ) + } + } + + LibraryViewType.GRID -> + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER + ) { + filterContent() + } + + item( + key = "header", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER + ) { + headerContent() + } + + items( + items = artists, + key = { it.id }, + contentType = { CONTENT_TYPE_ARTIST } + ) { artist -> + ArtistGridItem( + artist = artist, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("artist/${artist.id}") + }, + onLongClick = { + menuState.show { + ArtistMenu( + originalArtist = artist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItemPlacement() + ) + } + } } } } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryPlaylistsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryPlaylistsScreen.kt index 27e4b5168..64b8a3f98 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryPlaylistsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryPlaylistsScreen.kt @@ -2,15 +2,25 @@ package com.zionhuang.music.ui.screens.library import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -19,10 +29,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalDatabase @@ -30,12 +42,16 @@ import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.constants.CONTENT_TYPE_HEADER import com.zionhuang.music.constants.CONTENT_TYPE_PLAYLIST +import com.zionhuang.music.constants.GridThumbnailHeight +import com.zionhuang.music.constants.LibraryViewType import com.zionhuang.music.constants.PlaylistSortDescendingKey import com.zionhuang.music.constants.PlaylistSortType import com.zionhuang.music.constants.PlaylistSortTypeKey +import com.zionhuang.music.constants.PlaylistViewTypeKey import com.zionhuang.music.db.entities.PlaylistEntity import com.zionhuang.music.ui.component.HideOnScrollFAB import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.PlaylistGridItem import com.zionhuang.music.ui.component.PlaylistListItem import com.zionhuang.music.ui.component.SortHeader import com.zionhuang.music.ui.component.TextFieldDialog @@ -55,12 +71,14 @@ fun LibraryPlaylistsScreen( val coroutineScope = rememberCoroutineScope() + var viewType by rememberEnumPreference(PlaylistViewTypeKey, LibraryViewType.GRID) val (sortType, onSortTypeChange) = rememberEnumPreference(PlaylistSortTypeKey, PlaylistSortType.CREATE_DATE) val (sortDescending, onSortDescendingChange) = rememberPreference(PlaylistSortDescendingKey, true) val playlists by viewModel.allPlaylists.collectAsState() val lazyListState = rememberLazyListState() + val lazyGridState = rememberLazyGridState() var showAddPlaylistDialog by rememberSaveable { mutableStateOf(false) @@ -83,74 +101,164 @@ fun LibraryPlaylistsScreen( ) } - Box( - modifier = Modifier.fillMaxSize() - ) { - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + val headerContent = @Composable { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp) ) { - item( - key = "header", - contentType = CONTENT_TYPE_HEADER + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + PlaylistSortType.CREATE_DATE -> R.string.sort_by_create_date + PlaylistSortType.NAME -> R.string.sort_by_name + PlaylistSortType.SONG_COUNT -> R.string.sort_by_song_count + } + } + ) + + Spacer(Modifier.weight(1f)) + + Text( + text = pluralStringResource(R.plurals.n_playlist, playlists.size, playlists.size), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + + IconButton( + onClick = { + viewType = viewType.toggle() + }, + modifier = Modifier.padding(start = 6.dp, end = 6.dp) ) { - SortHeader( - sortType = sortType, - sortDescending = sortDescending, - onSortTypeChange = onSortTypeChange, - onSortDescendingChange = onSortDescendingChange, - sortTypeText = { sortType -> - when (sortType) { - PlaylistSortType.CREATE_DATE -> R.string.sort_by_create_date - PlaylistSortType.NAME -> R.string.sort_by_name - PlaylistSortType.SONG_COUNT -> R.string.sort_by_song_count + Icon( + painter = painterResource( + when (viewType) { + LibraryViewType.LIST -> R.drawable.list + LibraryViewType.GRID -> R.drawable.grid_view } - }, - trailingText = pluralStringResource(R.plurals.n_playlist, playlists.size, playlists.size) + ), + contentDescription = null ) } + } + } - items( - items = playlists, - key = { it.id }, - contentType = { CONTENT_TYPE_PLAYLIST } - ) { playlist -> - PlaylistListItem( - playlist = playlist, - trailingContent = { - IconButton( - onClick = { - menuState.show { - PlaylistMenu( - playlist = playlist, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss + Box( + modifier = Modifier.fillMaxSize() + ) { + when (viewType) { + LibraryViewType.LIST -> { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + headerContent() + } + + items( + items = playlists, + key = { it.id }, + contentType = { CONTENT_TYPE_PLAYLIST } + ) { playlist -> + PlaylistListItem( + playlist = playlist, + trailingContent = { + IconButton( + onClick = { + menuState.show { + PlaylistMenu( + playlist = playlist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null ) } - } - ) { - Icon( - painter = painterResource(R.drawable.more_vert), - contentDescription = null - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - navController.navigate("local_playlist/${playlist.id}") - } - .animateItemPlacement() + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + navController.navigate("local_playlist/${playlist.id}") + } + .animateItemPlacement() + ) + } + } + + HideOnScrollFAB( + lazyListState = lazyListState, + icon = R.drawable.add, + onClick = { + showAddPlaylistDialog = true + } ) } - } - HideOnScrollFAB( - lazyListState = lazyListState, - icon = R.drawable.add, - onClick = { - showAddPlaylistDialog = true + LibraryViewType.GRID -> { + LazyVerticalGrid( + state = lazyGridState, + columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "header", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER + ) { + headerContent() + } + + items( + items = playlists, + key = { it.id }, + contentType = { CONTENT_TYPE_PLAYLIST } + ) { playlist -> + PlaylistGridItem( + playlist = playlist, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("local_playlist/${playlist.id}") + }, + onLongClick = { + menuState.show { + PlaylistMenu( + playlist = playlist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItemPlacement() + ) + } + } + + HideOnScrollFAB( + lazyListState = lazyGridState, + icon = R.drawable.add, + onClick = { + showAddPlaylistDialog = true + } + ) } - ) + } + } } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibrarySongsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibrarySongsScreen.kt index dda00765a..8a03feb49 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibrarySongsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibrarySongsScreen.kt @@ -8,15 +8,19 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets @@ -63,7 +67,10 @@ fun LibrarySongsScreen( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { - item(key = "filter") { + item( + key = "filter", + contentType = CONTENT_TYPE_HEADER + ) { ChipsRow( chips = listOf( SongFilter.LIBRARY to stringResource(R.string.filter_library), @@ -75,22 +82,37 @@ fun LibrarySongsScreen( ) } - item(key = "header") { - SortHeader( - sortType = sortType, - sortDescending = sortDescending, - onSortTypeChange = onSortTypeChange, - onSortDescendingChange = onSortDescendingChange, - sortTypeText = { sortType -> - when (sortType) { - SongSortType.CREATE_DATE -> R.string.sort_by_create_date - SongSortType.NAME -> R.string.sort_by_name - SongSortType.ARTIST -> R.string.sort_by_artist - SongSortType.PLAY_TIME -> R.string.sort_by_play_time + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + SongSortType.CREATE_DATE -> R.string.sort_by_create_date + SongSortType.NAME -> R.string.sort_by_name + SongSortType.ARTIST -> R.string.sort_by_artist + SongSortType.PLAY_TIME -> R.string.sort_by_play_time + } } - }, - trailingText = pluralStringResource(R.plurals.n_song, songs.size, songs.size) - ) + ) + + Spacer(Modifier.weight(1f)) + + Text( + text = pluralStringResource(R.plurals.n_song, songs.size, songs.size), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } } itemsIndexed( @@ -109,7 +131,6 @@ fun LibrarySongsScreen( SongMenu( originalSong = song, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt index d6cddc2b3..a404d68ac 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt @@ -13,8 +13,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -39,6 +39,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -81,7 +82,9 @@ import com.zionhuang.music.constants.PlaylistSongSortDescendingKey import com.zionhuang.music.constants.PlaylistSongSortType import com.zionhuang.music.constants.PlaylistSongSortTypeKey import com.zionhuang.music.constants.ThumbnailCornerRadius +import com.zionhuang.music.db.entities.PlaylistSong import com.zionhuang.music.db.entities.PlaylistSongMap +import com.zionhuang.music.extensions.move import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.models.toMediaMetadata @@ -95,17 +98,17 @@ import com.zionhuang.music.ui.component.SongListItem import com.zionhuang.music.ui.component.SortHeader import com.zionhuang.music.ui.component.TextFieldDialog import com.zionhuang.music.ui.menu.SongMenu -import com.zionhuang.music.ui.utils.reordering.ReorderingLazyColumn -import com.zionhuang.music.ui.utils.reordering.animateItemPlacement -import com.zionhuang.music.ui.utils.reordering.draggedItem -import com.zionhuang.music.ui.utils.reordering.rememberReorderingState -import com.zionhuang.music.ui.utils.reordering.reorder import com.zionhuang.music.utils.makeTimeString import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.LocalPlaylistViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import org.burnoutcrew.reorderable.ReorderableItem +import org.burnoutcrew.reorderable.detectReorder +import org.burnoutcrew.reorderable.rememberReorderableLazyListState +import org.burnoutcrew.reorderable.reorderable @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable @@ -123,6 +126,7 @@ fun LocalPlaylistScreen( val playlist by viewModel.playlist.collectAsState() val songs by viewModel.playlistSongs.collectAsState() + val mutableSongs = remember { mutableStateListOf() } val playlistLength = remember(songs) { songs.fastSumBy { it.song.song.duration } } @@ -131,21 +135,18 @@ fun LocalPlaylistScreen( var locked by rememberPreference(PlaylistEditLockKey, defaultValue = false) val coroutineScope = rememberCoroutineScope() - val lazyListState = rememberLazyListState() val snackbarHostState = remember { SnackbarHostState() } - val showTopBarTitle by remember { - derivedStateOf { - lazyListState.firstVisibleItemIndex > 0 - } - } - val downloadUtil = LocalDownloadUtil.current var downloadState by remember { mutableStateOf(Download.STATE_STOPPED) } LaunchedEffect(songs) { + mutableSongs.apply { + clear() + addAll(songs) + } if (songs.isEmpty()) return@LaunchedEffect downloadUtil.downloads.collect { downloads -> downloadState = @@ -182,23 +183,35 @@ fun LocalPlaylistScreen( } } - val reorderingState = rememberReorderingState( - lazyListState = lazyListState, - key = songs, - onDragEnd = { fromIndex, toIndex -> - database.query { - move(viewModel.playlistId, fromIndex, toIndex) + val headerItems = 2 + val reorderableState = rememberReorderableLazyListState( + onMove = { from, to -> + if (to.index >= headerItems && from.index >= headerItems) { + mutableSongs.move(from.index - headerItems, to.index - headerItems) } }, - extraItemCount = 1 + onDragEnd = { fromIndex, toIndex -> + database.transaction { + move(viewModel.playlistId, fromIndex - headerItems, toIndex - headerItems) + } + } ) + val showTopBarTitle by remember { + derivedStateOf { + reorderableState.listState.firstVisibleItemIndex > 0 + } + } + + var dismissJob: Job? by remember { mutableStateOf(null) } + Box( modifier = Modifier.fillMaxSize() ) { - ReorderingLazyColumn( - reorderingState = reorderingState, - contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + LazyColumn( + state = reorderableState.listState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + modifier = Modifier.reorderable(reorderableState) ) { playlist?.let { playlist -> if (playlist.songCount == 0) { @@ -431,7 +444,8 @@ fun LocalPlaylistScreen( item { Row( - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp) ) { SortHeader( sortType = sortType, @@ -447,7 +461,6 @@ fun LocalPlaylistScreen( PlaylistSongSortType.PLAY_TIME -> R.string.sort_by_play_time } }, - trailingText = "", modifier = Modifier.weight(1f) ) @@ -466,105 +479,108 @@ fun LocalPlaylistScreen( } itemsIndexed( - items = songs, + items = mutableSongs, key = { _, song -> song.map.id } ) { index, song -> - val currentItem by rememberUpdatedState(song) - val dismissState = rememberDismissState( - positionalThreshold = { totalDistance -> - totalDistance - }, - confirmValueChange = { dismissValue -> - if (dismissValue == DismissValue.DismissedToEnd || dismissValue == DismissValue.DismissedToStart) { - database.transaction { - move(currentItem.map.playlistId, currentItem.map.position, Int.MAX_VALUE) - delete(currentItem.map.copy(position = Int.MAX_VALUE)) - } - coroutineScope.launch { - val snackbarResult = snackbarHostState.showSnackbar( - message = context.getString(R.string.removed_song_from_playlist, currentItem.song.song.title), - actionLabel = context.getString(R.string.undo), - duration = SnackbarDuration.Short - ) - if (snackbarResult == SnackbarResult.ActionPerformed) { - database.transaction { - insert(currentItem.map.copy(position = playlistLength)) - move(currentItem.map.playlistId, playlistLength, currentItem.map.position) - } + ReorderableItem( + reorderableState = reorderableState, + key = song.map.id + ) { + val currentItem by rememberUpdatedState(song) + val dismissState = rememberDismissState( + positionalThreshold = { totalDistance -> + totalDistance + }, + confirmValueChange = { dismissValue -> + if (dismissValue == DismissValue.DismissedToEnd || dismissValue == DismissValue.DismissedToStart) { + database.transaction { + move(currentItem.map.playlistId, currentItem.map.position, Int.MAX_VALUE) + delete(currentItem.map.copy(position = Int.MAX_VALUE)) } - } - } - true - } - ) - - val content: @Composable () -> Unit = { - SongListItem( - song = song.song, - isActive = song.song.id == mediaMetadata?.id, - isPlaying = isPlaying, - showInLibraryIcon = true, - trailingContent = { - IconButton( - onClick = { - menuState.show { - SongMenu( - originalSong = song.song, - navController = navController, - playerConnection = playerConnection, - onDismiss = menuState::dismiss - ) + dismissJob?.cancel() + dismissJob = coroutineScope.launch { + val snackbarResult = snackbarHostState.showSnackbar( + message = context.getString(R.string.removed_song_from_playlist, currentItem.song.song.title), + actionLabel = context.getString(R.string.undo), + duration = SnackbarDuration.Short + ) + if (snackbarResult == SnackbarResult.ActionPerformed) { + database.transaction { + insert(currentItem.map.copy(position = playlistLength)) + move(currentItem.map.playlistId, playlistLength, currentItem.map.position) + } } } - ) { - Icon( - painter = painterResource(R.drawable.more_vert), - contentDescription = null - ) } + true + } + ) - if (sortType == PlaylistSongSortType.CUSTOM && !locked) { + val content: @Composable () -> Unit = { + SongListItem( + song = song.song, + isActive = song.song.id == mediaMetadata?.id, + isPlaying = isPlaying, + showInLibraryIcon = true, + trailingContent = { IconButton( - onClick = { }, - modifier = Modifier.reorder(reorderingState = reorderingState, index = index) + onClick = { + menuState.show { + SongMenu( + originalSong = song.song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } ) { Icon( - painter = painterResource(R.drawable.drag_handle), + painter = painterResource(R.drawable.more_vert), contentDescription = null ) } - } - }, - modifier = Modifier - .fillMaxWidth() - .combinedClickable { - if (song.song.id == mediaMetadata?.id) { - playerConnection.player.togglePlayPause() - } else { - playerConnection.playQueue( - ListQueue( - title = playlist!!.playlist.name, - items = songs.map { it.song.toMediaItem() }, - startIndex = index + + if (sortType == PlaylistSongSortType.CUSTOM && !locked) { + IconButton( + onClick = { }, + modifier = Modifier.detectReorder(reorderableState) + ) { + Icon( + painter = painterResource(R.drawable.drag_handle), + contentDescription = null ) - ) + } } - } - .animateItemPlacement(reorderingState = reorderingState) - .draggedItem(reorderingState = reorderingState, index = index) - ) - } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + if (song.song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = playlist!!.playlist.name, + items = songs.map { it.song.toMediaItem() }, + startIndex = index + ) + ) + } + } + ) + } - if (locked) { - content() - } else { - SwipeToDismiss( - state = dismissState, - background = {}, - dismissContent = { - content() - } - ) + if (locked) { + content() + } else { + SwipeToDismiss( + state = dismissState, + background = {}, + dismissContent = { + content() + } + ) + } } } } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt index 3902578e2..8a6db10c7 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt @@ -212,7 +212,6 @@ fun OnlinePlaylistScreen( YouTubePlaylistMenu( playlist = playlist, songs = songs, - playerConnection = playerConnection, coroutineScope = coroutineScope, onDismiss = menuState::dismiss ) @@ -280,7 +279,6 @@ fun OnlinePlaylistScreen( YouTubeSongMenu( song = song, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/search/LocalSearchScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/search/LocalSearchScreen.kt index 74026d030..8efccbcdd 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/search/LocalSearchScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/search/LocalSearchScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -54,7 +53,7 @@ import com.zionhuang.music.viewmodels.LocalFilter import com.zionhuang.music.viewmodels.LocalSearchViewModel import kotlinx.coroutines.flow.drop -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) @Composable fun LocalSearchScreen( query: String, @@ -155,8 +154,7 @@ fun LocalSearchScreen( menuState.show { SongMenu( originalSong = item, - navController = navController, - playerConnection = playerConnection + navController = navController ) { onDismiss() menuState.dismiss() diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchResult.kt b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchResult.kt index b6e92f45e..0587c592b 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchResult.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchResult.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -64,7 +63,7 @@ import com.zionhuang.music.ui.menu.YouTubeSongMenu import com.zionhuang.music.viewmodels.OnlineSearchViewModel import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun OnlineSearchResult( navController: NavController, @@ -114,26 +113,22 @@ fun OnlineSearchResult( is SongItem -> YouTubeSongMenu( song = item, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) is AlbumItem -> YouTubeAlbumMenu( albumItem = item, navController = navController, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) is ArtistItem -> YouTubeArtistMenu( artist = item, - playerConnection = playerConnection, onDismiss = menuState::dismiss ) is PlaylistItem -> YouTubePlaylistMenu( playlist = item, - playerConnection = playerConnection, coroutineScope = coroutineScope, onDismiss = menuState::dismiss ) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AboutScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AboutScreen.kt index 7444f441c..f8fe4b957 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AboutScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AboutScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.zionhuang.music.BuildConfig @@ -55,11 +56,16 @@ fun AboutScreen( .clickable { } ) - Text( - text = "InnerTune", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(top = 8.dp, bottom = 4.dp) - ) + Row( + verticalAlignment = Alignment.Top, + ) { + Text( + text = "InnerTune", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 8.dp, bottom = 4.dp) + ) + } Row(verticalAlignment = Alignment.CenterVertically) { Text( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt index 42e3e7952..0d4c0d0a8 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt @@ -33,6 +33,7 @@ fun ContentSettings( ) { val accountName by rememberPreference(AccountNameKey, "") val accountEmail by rememberPreference(AccountEmailKey, "") + val accountChannelHandle by rememberPreference(AccountChannelHandleKey, "") val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") val isLoggedIn = remember(innerTubeCookie) { "SAPISID" in parseCookieString(innerTubeCookie) @@ -51,7 +52,10 @@ fun ContentSettings( ) { PreferenceEntry( title = { Text(if (isLoggedIn) accountName else stringResource(R.string.login)) }, - description = if (isLoggedIn) accountEmail else null, + description = if (isLoggedIn) { + accountEmail.takeIf { it.isNotEmpty() } + ?: accountChannelHandle.takeIf { it.isNotEmpty() } + } else null, icon = { Icon(painterResource(R.drawable.person), null) }, onClick = { navController.navigate("login") } ) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/StorageSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/StorageSettings.kt index 89ae0cef0..1aea64f90 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/StorageSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/StorageSettings.kt @@ -198,7 +198,7 @@ fun StorageSettings( }, ) - if (BuildConfig.FLAVOR == "full") { + if (BuildConfig.FLAVOR != "foss") { PreferenceGroupTitle( title = stringResource(R.string.translation_models) ) diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/ScrollUtils.kt b/app/src/main/java/com/zionhuang/music/ui/utils/ScrollUtils.kt index 975e195f4..36cc61c8f 100644 --- a/app/src/main/java/com/zionhuang/music/ui/utils/ScrollUtils.kt +++ b/app/src/main/java/com/zionhuang/music/ui/utils/ScrollUtils.kt @@ -2,6 +2,7 @@ package com.zionhuang.music.ui.utils import androidx.compose.foundation.ScrollState import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -27,6 +28,24 @@ fun LazyListState.isScrollingUp(): Boolean { }.value } +@Composable +fun LazyGridState.isScrollingUp(): Boolean { + var previousIndex by remember(this) { mutableStateOf(firstVisibleItemIndex) } + var previousScrollOffset by remember(this) { mutableStateOf(firstVisibleItemScrollOffset) } + return remember(this) { + derivedStateOf { + if (previousIndex != firstVisibleItemIndex) { + previousIndex > firstVisibleItemIndex + } else { + previousScrollOffset >= firstVisibleItemScrollOffset + }.also { + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + } + } + }.value +} + @Composable fun ScrollState.isScrollingUp(): Boolean { var previousScrollOffset by remember(this) { mutableStateOf(value) } diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimatablesPool.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimatablesPool.kt deleted file mode 100644 index 553d40e6e..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimatablesPool.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.zionhuang.music.ui.utils.reordering - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationVector -import androidx.compose.animation.core.TwoWayConverter -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -class AnimatablesPool( - private val size: Int, - private val initialValue: T, - typeConverter: TwoWayConverter, -) { - private val values = MutableList(size) { - Animatable(initialValue = initialValue, typeConverter = typeConverter) - } - - private val mutex = Mutex() - - init { - require(size > 0) - } - - suspend fun acquire(): Animatable? { - return mutex.withLock { - if (values.isNotEmpty()) values.removeFirst() else null - } - } - - suspend fun release(animatable: Animatable) { - mutex.withLock { - if (values.size < size) { - animatable.snapTo(initialValue) - values.add(animatable) - } - } - } -} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimateItemPlacement.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimateItemPlacement.kt deleted file mode 100644 index 1123b609e..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimateItemPlacement.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.zionhuang.music.ui.utils.reordering - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.ui.Modifier - -context(LazyItemScope) -@ExperimentalFoundationApi -fun Modifier.animateItemPlacement(reorderingState: ReorderingState) = - if (!reorderingState.isDragging) animateItemPlacement() else this diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/DraggedItem.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/DraggedItem.kt deleted file mode 100644 index 2a11c6ef8..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/DraggedItem.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.zionhuang.music.ui.utils.reordering - -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.layout.offset -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.zIndex - -fun Modifier.draggedItem( - reorderingState: ReorderingState, - index: Int, -): Modifier = when (reorderingState.draggingIndex) { - -1 -> this - index -> offset { - when (reorderingState.lazyListState.layoutInfo.orientation) { - Orientation.Vertical -> IntOffset(0, reorderingState.offset.value) - Orientation.Horizontal -> IntOffset(reorderingState.offset.value, 0) - } - }.zIndex(1f) - else -> offset { - val offset = when (index) { - in reorderingState.indexesToAnimate -> reorderingState.indexesToAnimate.getValue(index).value - in (reorderingState.draggingIndex + 1)..reorderingState.reachedIndex -> -reorderingState.draggingItemSize - in reorderingState.reachedIndex until reorderingState.draggingIndex -> reorderingState.draggingItemSize - else -> 0 - } - when (reorderingState.lazyListState.layoutInfo.orientation) { - Orientation.Vertical -> IntOffset(0, offset) - Orientation.Horizontal -> IntOffset(offset, 0) - } - } -} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/Reorder.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/Reorder.kt deleted file mode 100644 index 1d0411912..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/Reorder.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.zionhuang.music.ui.utils.reordering - -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.pointer.PointerInputChange -import androidx.compose.ui.input.pointer.PointerInputScope -import androidx.compose.ui.input.pointer.pointerInput - -private fun Modifier.reorder( - reorderingState: ReorderingState, - index: Int, - detectDragGestures: DetectDragGestures, -): Modifier = pointerInput(reorderingState) { - with(detectDragGestures) { - detectDragGestures( - onDragStart = { reorderingState.onDragStart(index) }, - onDrag = reorderingState::onDrag, - onDragEnd = reorderingState::onDragEnd, - onDragCancel = reorderingState::onDragEnd, - ) - } -} - -fun Modifier.reorder( - reorderingState: ReorderingState, - index: Int, -): Modifier = reorder( - reorderingState = reorderingState, - index = index, - detectDragGestures = PointerInputScope::detectDragGestures, -) - -private fun interface DetectDragGestures { - suspend fun PointerInputScope.detectDragGestures( - onDragStart: (Offset) -> Unit, - onDragEnd: () -> Unit, - onDragCancel: () -> Unit, - onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, - ) -} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyColumn.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyColumn.kt deleted file mode 100644 index 73b2e1b1d..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyColumn.kt +++ /dev/null @@ -1,40 +0,0 @@ -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") - -package com.zionhuang.music.ui.utils.reordering - -import androidx.compose.foundation.gestures.FlingBehavior -import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -@Composable -fun ReorderingLazyColumn( - reorderingState: ReorderingState, - modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(0.dp), - reverseLayout: Boolean = false, - verticalArrangement: Arrangement.Vertical = - if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, - horizontalAlignment: Alignment.Horizontal = Alignment.Start, - flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), - userScrollEnabled: Boolean = true, - content: LazyListScope.() -> Unit, -) { - ReorderingLazyList( - modifier = modifier, - reorderingState = reorderingState, - contentPadding = contentPadding, - flingBehavior = flingBehavior, - horizontalAlignment = horizontalAlignment, - verticalArrangement = verticalArrangement, - isVertical = true, - reverseLayout = reverseLayout, - userScrollEnabled = userScrollEnabled, - content = content - ) -} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyList.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyList.kt deleted file mode 100644 index 7b0facb92..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyList.kt +++ /dev/null @@ -1,274 +0,0 @@ -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") - -package com.zionhuang.music.ui.utils.reordering - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.checkScrollableContainerConstraints -import androidx.compose.foundation.clipScrollableContainer -import androidx.compose.foundation.gestures.FlingBehavior -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.foundation.gestures.scrollable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.lazy.* -import androidx.compose.foundation.lazy.layout.LazyLayout -import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope -import androidx.compose.foundation.lazy.layout.lazyLayoutSemantics -import androidx.compose.foundation.overscroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.MeasureResult -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.* - -@OptIn(ExperimentalFoundationApi::class) -@Composable -internal fun ReorderingLazyList( - modifier: Modifier, - reorderingState: ReorderingState, - contentPadding: PaddingValues, - reverseLayout: Boolean, - isVertical: Boolean, - flingBehavior: FlingBehavior, - userScrollEnabled: Boolean, - beyondBoundsItemCount: Int = 0, - horizontalAlignment: Alignment.Horizontal? = null, - verticalArrangement: Arrangement.Vertical? = null, - verticalAlignment: Alignment.Vertical? = null, - horizontalArrangement: Arrangement.Horizontal? = null, - content: LazyListScope.() -> Unit, -) { - val overscrollEffect = ScrollableDefaults.overscrollEffect() - val itemProvider = rememberLazyListItemProvider(reorderingState.lazyListState, content) - val semanticState = - rememberLazyListSemanticState(reorderingState.lazyListState, itemProvider, reverseLayout, isVertical) - val beyondBoundsInfo = reorderingState.lazyListBeyondBoundsInfo - val scope = rememberCoroutineScope() - val placementAnimator = remember(reorderingState.lazyListState, isVertical) { - LazyListItemPlacementAnimator(scope, isVertical) - } - reorderingState.lazyListState.placementAnimator = placementAnimator - - val measurePolicy = rememberLazyListMeasurePolicy( - itemProvider, - reorderingState.lazyListState, - beyondBoundsInfo, - contentPadding, - reverseLayout, - isVertical, - beyondBoundsItemCount, - horizontalAlignment, - verticalAlignment, - horizontalArrangement, - verticalArrangement, - placementAnimator, - ) - - val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal - LazyLayout( - modifier = modifier - .then(reorderingState.lazyListState.remeasurementModifier) - .then(reorderingState.lazyListState.awaitLayoutModifier) - .lazyLayoutSemantics( - itemProvider = itemProvider, - state = semanticState, - orientation = orientation, - userScrollEnabled = userScrollEnabled - ) - .clipScrollableContainer(orientation) - .lazyListBeyondBoundsModifier(reorderingState.lazyListState, beyondBoundsInfo, reverseLayout, orientation) - .overscroll(overscrollEffect) - .scrollable( - orientation = orientation, - reverseDirection = ScrollableDefaults.reverseDirection( - LocalLayoutDirection.current, - orientation, - reverseLayout - ), - interactionSource = reorderingState.lazyListState.internalInteractionSource, - flingBehavior = flingBehavior, - state = reorderingState.lazyListState, - overscrollEffect = overscrollEffect, - enabled = userScrollEnabled - ), - prefetchState = reorderingState.lazyListState.prefetchState, - measurePolicy = measurePolicy, - itemProvider = itemProvider - ) -} - -@ExperimentalFoundationApi -@Composable -private fun rememberLazyListMeasurePolicy( - itemProvider: LazyListItemProvider, - state: LazyListState, - beyondBoundsInfo: LazyListBeyondBoundsInfo, - contentPadding: PaddingValues, - reverseLayout: Boolean, - isVertical: Boolean, - beyondBoundsItemCount: Int, - horizontalAlignment: Alignment.Horizontal? = null, - verticalAlignment: Alignment.Vertical? = null, - horizontalArrangement: Arrangement.Horizontal? = null, - verticalArrangement: Arrangement.Vertical? = null, - placementAnimator: LazyListItemPlacementAnimator, -) = remember MeasureResult>( - state, - beyondBoundsInfo, - contentPadding, - reverseLayout, - isVertical, - horizontalAlignment, - verticalAlignment, - horizontalArrangement, - verticalArrangement, - placementAnimator -) { - { containerConstraints -> - checkScrollableContainerConstraints( - containerConstraints, - if (isVertical) Orientation.Vertical else Orientation.Horizontal - ) - - // resolve content paddings - val startPadding = - if (isVertical) { - contentPadding.calculateLeftPadding(layoutDirection).roundToPx() - } else { - // in horizontal configuration, padding is reversed by placeRelative - contentPadding.calculateStartPadding(layoutDirection).roundToPx() - } - - val endPadding = - if (isVertical) { - contentPadding.calculateRightPadding(layoutDirection).roundToPx() - } else { - // in horizontal configuration, padding is reversed by placeRelative - contentPadding.calculateEndPadding(layoutDirection).roundToPx() - } - val topPadding = contentPadding.calculateTopPadding().roundToPx() - val bottomPadding = contentPadding.calculateBottomPadding().roundToPx() - val totalVerticalPadding = topPadding + bottomPadding - val totalHorizontalPadding = startPadding + endPadding - val totalMainAxisPadding = if (isVertical) totalVerticalPadding else totalHorizontalPadding - val beforeContentPadding = when { - isVertical && !reverseLayout -> topPadding - isVertical && reverseLayout -> bottomPadding - !isVertical && !reverseLayout -> startPadding - else -> endPadding // !isVertical && reverseLayout - } - val afterContentPadding = totalMainAxisPadding - beforeContentPadding - val contentConstraints = - containerConstraints.offset(-totalHorizontalPadding, -totalVerticalPadding) - - // Update the state's cached Density - state.density = this - - // this will update the scope used by the item composables - itemProvider.itemScope.setMaxSize( - width = contentConstraints.maxWidth, - height = contentConstraints.maxHeight - ) - - val spaceBetweenItemsDp = if (isVertical) { - requireNotNull(verticalArrangement).spacing - } else { - requireNotNull(horizontalArrangement).spacing - } - val spaceBetweenItems = spaceBetweenItemsDp.roundToPx() - - val itemsCount = itemProvider.itemCount - - // can be negative if the content padding is larger than the max size from constraints - val mainAxisAvailableSize = if (isVertical) { - containerConstraints.maxHeight - totalVerticalPadding - } else { - containerConstraints.maxWidth - totalHorizontalPadding - } - val visualItemOffset = if (!reverseLayout || mainAxisAvailableSize > 0) { - IntOffset(startPadding, topPadding) - } else { - // When layout is reversed and paddings together take >100% of the available space, - // layout size is coerced to 0 when positioning. To take that space into account, - // we offset start padding by negative space between paddings. - IntOffset( - if (isVertical) startPadding else startPadding + mainAxisAvailableSize, - if (isVertical) topPadding + mainAxisAvailableSize else topPadding - ) - } - - val measuredItemProvider = LazyMeasuredItemProvider( - contentConstraints, - isVertical, - itemProvider, - this - ) { index, key, placeables -> - // we add spaceBetweenItems as an extra spacing for all items apart from the last one so - // the lazy list measuring logic will take it into account. - val spacing = if (index.value == itemsCount - 1) 0 else spaceBetweenItems - LazyMeasuredItem( - index = index.value, - placeables = placeables, - isVertical = isVertical, - horizontalAlignment = horizontalAlignment, - verticalAlignment = verticalAlignment, - layoutDirection = layoutDirection, - reverseLayout = reverseLayout, - beforeContentPadding = beforeContentPadding, - afterContentPadding = afterContentPadding, - spacing = spacing, - visualOffset = visualItemOffset, - key = key, - placementAnimator = placementAnimator - ) - } - state.premeasureConstraints = measuredItemProvider.childConstraints - - val firstVisibleItemIndex: DataIndex - val firstVisibleScrollOffset: Int - Snapshot.withoutReadObservation { - firstVisibleItemIndex = DataIndex(state.firstVisibleItemIndex) - firstVisibleScrollOffset = state.firstVisibleItemScrollOffset - } - - measureLazyList( - itemsCount = itemsCount, - itemProvider = measuredItemProvider, - mainAxisAvailableSize = mainAxisAvailableSize, - beforeContentPadding = beforeContentPadding, - afterContentPadding = afterContentPadding, - spaceBetweenItems = spaceBetweenItems, - firstVisibleItemIndex = firstVisibleItemIndex, - firstVisibleItemScrollOffset = firstVisibleScrollOffset, - scrollToBeConsumed = state.scrollToBeConsumed, - constraints = contentConstraints, - isVertical = isVertical, - headerIndexes = itemProvider.headerIndexes, - verticalArrangement = verticalArrangement, - horizontalArrangement = horizontalArrangement, - reverseLayout = reverseLayout, - density = this, - placementAnimator = placementAnimator, - beyondBoundsInfo = beyondBoundsInfo, - beyondBoundsItemCount = beyondBoundsItemCount, - pinnedItems = state.pinnedItems, - layout = { width, height, placement -> - layout( - containerConstraints.constrainWidth(width + totalHorizontalPadding), - containerConstraints.constrainHeight(height + totalVerticalPadding), - emptyMap(), - placement - ) - } - ).also { - state.applyMeasureResult(it) - } - } -} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingState.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingState.kt deleted file mode 100644 index e480721ae..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingState.kt +++ /dev/null @@ -1,221 +0,0 @@ -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") - -package com.zionhuang.music.ui.utils.reordering - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationVector1D -import androidx.compose.animation.core.VectorConverter -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.lazy.LazyListBeyondBoundsInfo -import androidx.compose.foundation.lazy.LazyListItemInfo -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.* -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.pointer.PointerInputChange -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlin.math.roundToInt - -/** - * From [ViMusic](https://github.com/vfsfitvnm/ViMusic) - */ -@Stable -class ReorderingState( - val lazyListState: LazyListState, - val coroutineScope: CoroutineScope, - private val lastIndex: Int, - internal val onDragStart: () -> Unit, - internal val onDragEnd: (Int, Int) -> Unit, - private val extraItemCount: Int, -) { - private lateinit var lazyListBeyondBoundsInfoInterval: LazyListBeyondBoundsInfo.Interval - internal val lazyListBeyondBoundsInfo = LazyListBeyondBoundsInfo() - internal val offset = Animatable(0, Int.VectorConverter) - - internal var draggingIndex by mutableStateOf(-1) - internal var reachedIndex by mutableStateOf(-1) - internal var draggingItemSize by mutableStateOf(0) - - lateinit var itemInfo: LazyListItemInfo - - private var previousItemSize = 0 - private var nextItemSize = 0 - - private var overscrolled = 0 - - internal var indexesToAnimate = mutableStateMapOf>() - private var animatablesPool: AnimatablesPool? = null - - val isDragging: Boolean - get() = draggingIndex != -1 - - fun onDragStart(index: Int) { - overscrolled = 0 - itemInfo = lazyListState.layoutInfo.visibleItemsInfo.find { - it.index == index + extraItemCount - } ?: return - onDragStart() - draggingIndex = index - reachedIndex = index - draggingItemSize = itemInfo.size - - nextItemSize = draggingItemSize - previousItemSize = -draggingItemSize - - offset.updateBounds( - lowerBound = -index * draggingItemSize, - upperBound = (lastIndex - index) * draggingItemSize - ) - - lazyListBeyondBoundsInfoInterval = - lazyListBeyondBoundsInfo.addInterval(index + extraItemCount, index + extraItemCount) - - val size = - lazyListState.layoutInfo.viewportEndOffset - lazyListState.layoutInfo.viewportStartOffset - - animatablesPool = AnimatablesPool(size / draggingItemSize + 2, 0, Int.VectorConverter) - } - - fun onDrag(change: PointerInputChange, dragAmount: Offset) { - if (!isDragging) return - change.consume() - - val delta = when (lazyListState.layoutInfo.orientation) { - Orientation.Vertical -> dragAmount.y - Orientation.Horizontal -> dragAmount.x - }.roundToInt() - - val targetOffset = offset.value + delta - - coroutineScope.launch { - offset.snapTo(targetOffset) - } - - if (targetOffset > nextItemSize) { - if (reachedIndex < lastIndex) { - reachedIndex += 1 - nextItemSize += draggingItemSize - previousItemSize += draggingItemSize - - val indexToAnimate = reachedIndex - if (draggingIndex < reachedIndex) 0 else 1 - - coroutineScope.launch { - val animatable = indexesToAnimate.getOrPut(indexToAnimate) { - animatablesPool?.acquire() ?: return@launch - } - - if (draggingIndex < reachedIndex) { - animatable.snapTo(0) - animatable.animateTo(-draggingItemSize) - } else { - animatable.snapTo(draggingItemSize) - animatable.animateTo(0) - } - - indexesToAnimate.remove(indexToAnimate) - animatablesPool?.release(animatable) - } - } - } else if (targetOffset < previousItemSize) { - if (reachedIndex > 0) { - reachedIndex -= 1 - previousItemSize -= draggingItemSize - nextItemSize -= draggingItemSize - - val indexToAnimate = reachedIndex + if (draggingIndex > reachedIndex) 0 else 1 - - coroutineScope.launch { - val animatable = indexesToAnimate.getOrPut(indexToAnimate) { - animatablesPool?.acquire() ?: return@launch - } - - if (draggingIndex > reachedIndex) { - animatable.snapTo(0) - animatable.animateTo(draggingItemSize) - } else { - animatable.snapTo(-draggingItemSize) - animatable.animateTo(0) - } - indexesToAnimate.remove(indexToAnimate) - animatablesPool?.release(animatable) - } - } - } else { - val offsetInViewPort = targetOffset + itemInfo.offset - overscrolled - - val topOverscroll = lazyListState.layoutInfo.viewportStartOffset + - lazyListState.layoutInfo.beforeContentPadding - offsetInViewPort - - val bottomOverscroll = lazyListState.layoutInfo.viewportEndOffset - - lazyListState.layoutInfo.afterContentPadding - offsetInViewPort - itemInfo.size - - if (topOverscroll > 0) { - overscroll(topOverscroll) - } else if (bottomOverscroll < 0) { - overscroll(bottomOverscroll) - } - } - } - - fun onDragEnd() { - if (!isDragging) return - - coroutineScope.launch { - offset.animateTo((previousItemSize + nextItemSize) / 2) - - withContext(Dispatchers.Main) { - onDragEnd(draggingIndex, reachedIndex) - } - - if (areEquals()) { - draggingIndex = -1 - reachedIndex = -1 - draggingItemSize = 0 - offset.snapTo(0) - } - - lazyListBeyondBoundsInfo.removeInterval(lazyListBeyondBoundsInfoInterval) - animatablesPool = null - } - } - - private fun overscroll(overscroll: Int) { - lazyListState.dispatchRawDelta(-overscroll.toFloat()) - coroutineScope.launch { - offset.snapTo(offset.value - overscroll) - } - overscrolled -= overscroll - } - - private fun areEquals(): Boolean { - return lazyListState.layoutInfo.visibleItemsInfo.find { - it.index + extraItemCount == draggingIndex - }?.key == lazyListState.layoutInfo.visibleItemsInfo.find { - it.index + extraItemCount == reachedIndex - }?.key - } -} - -@Composable -fun rememberReorderingState( - lazyListState: LazyListState, - key: Any, - onDragEnd: (Int, Int) -> Unit, - onDragStart: () -> Unit = {}, - extraItemCount: Int = 0, -): ReorderingState { - val coroutineScope = rememberCoroutineScope() - - return remember(key) { - ReorderingState( - lazyListState = lazyListState, - coroutineScope = coroutineScope, - lastIndex = if (key is List<*>) key.lastIndex else lazyListState.layoutInfo.totalItemsCount, - onDragStart = onDragStart, - onDragEnd = onDragEnd, - extraItemCount = extraItemCount, - ) - } -} diff --git a/app/src/main/java/com/zionhuang/music/utils/CoilBitmapLoader.kt b/app/src/main/java/com/zionhuang/music/utils/CoilBitmapLoader.kt index 855ca4933..7a5818a4d 100644 --- a/app/src/main/java/com/zionhuang/music/utils/CoilBitmapLoader.kt +++ b/app/src/main/java/com/zionhuang/music/utils/CoilBitmapLoader.kt @@ -7,11 +7,13 @@ import android.graphics.drawable.BitmapDrawable import android.net.Uri import androidx.media3.session.BitmapLoader import coil.imageLoader +import coil.request.ErrorResult import coil.request.ImageRequest import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.guava.future +import java.util.concurrent.ExecutionException class CoilBitmapLoader( private val context: Context, @@ -30,6 +32,13 @@ class CoilBitmapLoader( .allowHardware(false) .build() ) - (result.drawable as BitmapDrawable).bitmap + if (result is ErrorResult) { + throw ExecutionException(result.throwable) + } + try { + (result.drawable as BitmapDrawable).bitmap + } catch (e: Exception) { + throw ExecutionException(e) + } } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/StatsViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/StatsViewModel.kt index b68686009..d9cd26526 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/StatsViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/StatsViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.time.Duration @@ -29,7 +30,9 @@ class StatsViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) val mostPlayedArtists = statPeriod.flatMapLatest { period -> - database.mostPlayedArtists(period.toTimeMillis()) + database.mostPlayedArtists(period.toTimeMillis()).map { artists -> + artists.filter { it.artist.isYouTubeArtist } + } }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) diff --git a/app/src/main/res/drawable/arrow_forward.xml b/app/src/main/res/drawable/arrow_forward.xml new file mode 100644 index 000000000..786a65f07 --- /dev/null +++ b/app/src/main/res/drawable/arrow_forward.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/grid_view.xml b/app/src/main/res/drawable/grid_view.xml new file mode 100644 index 000000000..1f5809a89 --- /dev/null +++ b/app/src/main/res/drawable/grid_view.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/list.xml b/app/src/main/res/drawable/list.xml new file mode 100644 index 000000000..183a39587 --- /dev/null +++ b/app/src/main/res/drawable/list.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index d6eeefef4..03d729a0c 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -30,7 +30,7 @@ A legtöbbet játszott dalok A legtöbbet játszott előadók - Most played albums + A legtöbbet játszott albumok Keresés @@ -62,7 +62,7 @@ Újra Rádió Keverés - Reset + Visszaállít Részletek @@ -86,7 +86,7 @@ Előzményből eltávolít Keresés online Szinkron. - Advanced + Haladó Létrehozás dátuma @@ -259,7 +259,7 @@ Rólunk App verzió - New version available - Translation Models - Clear translation models + Új verzió érhető el + Fordítási modellek + Fordítási modellek törlése diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index c9cf5e63c..f577b49e6 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -36,9 +36,9 @@ Zoeken Zoeken via YouTube Music… Zoeken in bibliotheek - Library - Liked - Downloaded + Bibliotheek + Geliked + Gedownload Alles Nummers Videos @@ -47,7 +47,7 @@ Afspeellijsten Afspeellijsten van de community Voorgestelde afspeellijsten - Bookmarked + Gebookmarked Geen resultaten gevonden @@ -86,7 +86,7 @@ Verwijder uit geschiedenis Zoek online Synchroniseer - Advanced + Geavanceerd Datum toegevoegd @@ -259,7 +259,7 @@ Over App versie - New version available - Translation Models - Clear translation models + Nieuwe versie beschikbaar + Vertaalmodellen + Verwijder vertaalmodellen diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 7d2b665f3..c2a932d87 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -278,6 +278,6 @@ Версия приложения Доступна новая версия - Translation Models - Clear translation models + Модели перевода + Очистить модели перевода diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 83475b8e8..fbfb48647 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -278,6 +278,6 @@ Версія застосунку Доступна нова версія - Translation Models - Clear translation models + Моделі перекладу + Очистити моделі перекладу diff --git a/app/src/main/res/values/app_name.xml b/app/src/main/res/values/app_name.xml new file mode 100644 index 000000000..fa29dfbb1 --- /dev/null +++ b/app/src/main/res/values/app_name.xml @@ -0,0 +1,4 @@ + + + InnerTune + \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/19.txt b/fastlane/metadata/android/en-US/changelogs/19.txt new file mode 100644 index 000000000..cbe4d8b54 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/19.txt @@ -0,0 +1,3 @@ +- Better UI +- Grid layout for albums and playlists +- Minor enhancement and bug fixes \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 291b47f43..30860b2b0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ compose-compiler = "1.4.0" compose = "1.3.3" lifecycle = "2.6.1" material3 = "1.1.0-alpha05" -media3 = "1.0.2" +media3 = "1.1.1" room = "2.5.2" hilt = "2.46.1" ktor = "2.2.2" @@ -28,6 +28,7 @@ compose-ui-util = { group = "androidx.compose.ui", name = "ui-util", version.ref compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "compose" } compose-animation = { group = "androidx.compose.animation", name = "animation-graphics", version.ref = "compose" } compose-animation-graphics = { group = "androidx.compose.animation", name = "animation-graphics", version.ref = "compose" } +compose-reorderable = { group = "org.burnoutcrew.composereorderable", name = "reorderable", version = "0.9.6" } viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } @@ -39,7 +40,7 @@ material3-windowsize = { group = "androidx.compose.material3", name = "material3 accompanist-swiperefresh = { group = "com.google.accompanist", name = "accompanist-swiperefresh", version = "0.28.0" } -coil = { group = "io.coil-kt", name = "coil-compose", version = "2.2.2" } +coil = { group = "io.coil-kt", name = "coil-compose", version = "2.3.0" } shimmer = { group = "com.valentinilk.shimmer", name = "compose-shimmer", version = "1.0.3" } @@ -75,10 +76,10 @@ junit = { group = "junit", name = "junit", version = "4.13.2" } timber = { group = "com.jakewharton.timber", name = "timber", version = "4.7.1" } google-services = { module = "com.google.gms:google-services", version = "4.3.15" } -firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version = "32.2.0" } +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version = "32.2.3" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" } -firebase-crashlytics-plugin = { module = "com.google.firebase:firebase-crashlytics-gradle", version = "2.9.7" } +firebase-crashlytics-plugin = { module = "com.google.firebase:firebase-crashlytics-gradle", version = "2.9.9" } firebase-config = { group = "com.google.firebase", name = "firebase-config-ktx" } firebase-perf = { group = "com.google.firebase", name = "firebase-perf-ktx" } firebase-perf-plugin = { module = "com.google.firebase:perf-plugin", version = "1.4.2" } diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index 18bf4fed6..68980a438 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -491,7 +491,10 @@ object YouTube { } suspend fun accountInfo(): Result = runCatching { - innerTube.accountMenu(WEB_REMIX).body().actions[0].openPopupAction.popup.multiPageMenuRenderer.header?.activeAccountHeaderRenderer?.toAccountInfo()!! + innerTube.accountMenu(WEB_REMIX).body() + .actions[0].openPopupAction.popup.multiPageMenuRenderer + .header?.activeAccountHeaderRenderer + ?.toAccountInfo()!! } @JvmInline diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/AccountInfo.kt b/innertube/src/main/java/com/zionhuang/innertube/models/AccountInfo.kt index cacedbfed..42475f222 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/AccountInfo.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/AccountInfo.kt @@ -2,5 +2,6 @@ package com.zionhuang.innertube.models data class AccountInfo( val name: String, - val email: String, + val email: String?, + val channelHandle: String?, ) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/AccountMenuResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/AccountMenuResponse.kt index b343157ce..0f48b8448 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/response/AccountMenuResponse.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/AccountMenuResponse.kt @@ -31,11 +31,13 @@ data class AccountMenuResponse( @Serializable data class ActiveAccountHeaderRenderer( val accountName: Runs, - val email: Runs, + val email: Runs?, + val channelHandle: Runs?, ) { fun toAccountInfo() = AccountInfo( - accountName.runs!!.first().text, - email.runs!!.first().text + name = accountName.runs!!.first().text, + email = email?.runs?.first()?.text, + channelHandle = channelHandle?.runs?.first()?.text ) } } diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistPage.kt index fec6bdb54..ba0118bb9 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistPage.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistPage.kt @@ -72,7 +72,7 @@ data class ArtistPage( album = renderer.flexColumns.getOrNull(2)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.let { Album( name = it.text, - id = it.navigationEndpoint?.browseEndpoint?.browseId!! + id = it.navigationEndpoint?.browseEndpoint?.browseId ?: return@let null ) }, duration = null,