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,