diff --git a/app/.DS_Store b/app/.DS_Store index fc824dc..3984261 100644 Binary files a/app/.DS_Store and b/app/.DS_Store differ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 493f04e..7fc8f4a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.sosauce.cutemusic" minSdk = 26 targetSdk = 35 - versionCode = 17 - versionName = "2.3.0" + versionCode = 18 + versionName = "2.3.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true @@ -81,7 +81,7 @@ android { implementation(libs.koin.androidx.compose) //implementation("com.materialkolor:material-kolor:2.0.0") implementation(libs.koin.androidx.startup) - implementation(libs.jaudiotagger) + implementation(libs.taglib) debugImplementation(libs.androidx.ui.tooling) } } diff --git a/app/release/baselineProfiles/0/app-release.dm b/app/release/baselineProfiles/0/app-release.dm index 11ab5b4..27a5863 100644 Binary files a/app/release/baselineProfiles/0/app-release.dm and b/app/release/baselineProfiles/0/app-release.dm differ diff --git a/app/release/baselineProfiles/1/app-release.dm b/app/release/baselineProfiles/1/app-release.dm index 33f76fd..0bd0151 100644 Binary files a/app/release/baselineProfiles/1/app-release.dm and b/app/release/baselineProfiles/1/app-release.dm differ diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json index 10124c9..40069aa 100644 --- a/app/release/output-metadata.json +++ b/app/release/output-metadata.json @@ -11,8 +11,8 @@ "type": "SINGLE", "filters": [], "attributes": [], - "versionCode": 16, - "versionName": "2.2.5", + "versionCode": 17, + "versionName": "2.3.0", "outputFile": "app-release.apk" } ], diff --git a/app/src/main/java/com/sosauce/cutemusic/data/actions/MetadataActions.kt b/app/src/main/java/com/sosauce/cutemusic/data/actions/MetadataActions.kt index 332040c..827899e 100644 --- a/app/src/main/java/com/sosauce/cutemusic/data/actions/MetadataActions.kt +++ b/app/src/main/java/com/sosauce/cutemusic/data/actions/MetadataActions.kt @@ -3,12 +3,18 @@ package com.sosauce.cutemusic.data.actions import android.net.Uri sealed interface MetadataActions { + data class LoadSong( val path: String, val uri: Uri ) : MetadataActions + data class UpdateAudioArt( + val newArtUri: Uri + ) : MetadataActions + data object SaveChanges : MetadataActions - data object ClearState : MetadataActions + data object RemoveArtwork : MetadataActions + } \ No newline at end of file diff --git a/app/src/main/java/com/sosauce/cutemusic/di/AppModule.kt b/app/src/main/java/com/sosauce/cutemusic/di/AppModule.kt index 8a87562..4656c8d 100644 --- a/app/src/main/java/com/sosauce/cutemusic/di/AppModule.kt +++ b/app/src/main/java/com/sosauce/cutemusic/di/AppModule.kt @@ -15,7 +15,7 @@ val appModule = module { MediaStoreHelperImpl(androidContext()) } viewModel { - PostViewModel(get(), androidApplication()) + PostViewModel(get()) } viewModel { MusicViewModel(androidApplication(), get()) diff --git a/app/src/main/java/com/sosauce/cutemusic/domain/repository/MediaStoreHelper.kt b/app/src/main/java/com/sosauce/cutemusic/domain/repository/MediaStoreHelper.kt index f85400e..dbf2943 100644 --- a/app/src/main/java/com/sosauce/cutemusic/domain/repository/MediaStoreHelper.kt +++ b/app/src/main/java/com/sosauce/cutemusic/domain/repository/MediaStoreHelper.kt @@ -7,6 +7,7 @@ import androidx.media3.common.MediaItem import com.sosauce.cutemusic.domain.model.Album import com.sosauce.cutemusic.domain.model.Artist import com.sosauce.cutemusic.domain.model.Folder +import kotlinx.coroutines.flow.Flow interface MediaStoreHelper { @@ -16,8 +17,10 @@ interface MediaStoreHelper { val folders: List fun fetchMusics(): List + fun fetchLatestMusics(): Flow> fun fetchAlbums(): List + fun fetchLatestAlbums(): Flow> fun fetchArtists(): List diff --git a/app/src/main/java/com/sosauce/cutemusic/domain/repository/MediaStoreHelperImpl.kt b/app/src/main/java/com/sosauce/cutemusic/domain/repository/MediaStoreHelperImpl.kt index 123f73f..aecf820 100644 --- a/app/src/main/java/com/sosauce/cutemusic/domain/repository/MediaStoreHelperImpl.kt +++ b/app/src/main/java/com/sosauce/cutemusic/domain/repository/MediaStoreHelperImpl.kt @@ -2,6 +2,7 @@ package com.sosauce.cutemusic.domain.repository +import android.annotation.SuppressLint import android.content.ContentUris import android.content.ContentValues import android.content.Context @@ -19,21 +20,27 @@ import com.sosauce.cutemusic.data.datastore.getBlacklistedFolder import com.sosauce.cutemusic.domain.model.Album import com.sosauce.cutemusic.domain.model.Artist import com.sosauce.cutemusic.domain.model.Folder +import com.sosauce.cutemusic.utils.observe import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking +@SuppressLint("UnsafeOptInUsageError") class MediaStoreHelperImpl( private val context: Context ) : MediaStoreHelper { - private fun getBlacklistedFoldersAsync(): Set = - runBlocking { getBlacklistedFolder(context) } + private fun getBlacklistedFoldersAsync(): Set = runBlocking { getBlacklistedFolder(context) } private val blacklistedFolders = getBlacklistedFoldersAsync() - private val selection = - blacklistedFolders.joinToString(" AND ") { "${MediaStore.Audio.Media.DATA} NOT LIKE ?" } + private val selection = blacklistedFolders.joinToString(" AND ") { "${MediaStore.Audio.Media.DATA} NOT LIKE ?" } private val selectionArgs = blacklistedFolders.map { "$it%" }.toTypedArray() + + override fun fetchLatestMusics(): Flow> = context.contentResolver.observe(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI).map { fetchMusics() } + override fun fetchLatestAlbums(): Flow> = context.contentResolver.observe(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI).map { fetchAlbums() } + @UnstableApi override fun fetchMusics(): List { diff --git a/app/src/main/java/com/sosauce/cutemusic/domain/repository/MediaStoreObserver.kt b/app/src/main/java/com/sosauce/cutemusic/domain/repository/MediaStoreObserver.kt deleted file mode 100644 index 814314d..0000000 --- a/app/src/main/java/com/sosauce/cutemusic/domain/repository/MediaStoreObserver.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.sosauce.cutemusic.domain.repository - -import android.database.ContentObserver -import android.os.Handler -import android.os.Looper - -class MediaStoreObserver( - private val onMediaStoreChanged: () -> Unit -) : ContentObserver(Handler(Looper.getMainLooper())) { - - override fun onChange(selfChange: Boolean) { - super.onChange(selfChange) - onMediaStoreChanged() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/sosauce/cutemusic/ui/navigation/Navigation.kt b/app/src/main/java/com/sosauce/cutemusic/ui/navigation/Navigation.kt index 3a61f7d..2122a1d 100644 --- a/app/src/main/java/com/sosauce/cutemusic/ui/navigation/Navigation.kt +++ b/app/src/main/java/com/sosauce/cutemusic/ui/navigation/Navigation.kt @@ -26,7 +26,6 @@ import com.sosauce.cutemusic.ui.screens.playing.NowPlayingScreen import com.sosauce.cutemusic.ui.screens.settings.SettingsScreen import com.sosauce.cutemusic.ui.shared_components.MusicViewModel import com.sosauce.cutemusic.ui.shared_components.PostViewModel -import com.sosauce.cutemusic.utils.ListToHandle import org.koin.androidx.compose.koinViewModel // https://stackoverflow.com/a/78771053 @@ -39,9 +38,9 @@ fun Nav() { val viewModel = koinViewModel() val postViewModel = koinViewModel() val metadataViewModel = koinViewModel() - val musics = postViewModel.musics + val musics by postViewModel.musics.collectAsStateWithLifecycle() val musicState by viewModel.musicState.collectAsStateWithLifecycle() - val folders = postViewModel.folders + val albums by postViewModel.albums.collectAsStateWithLifecycle() SharedTransitionLayout { @@ -65,7 +64,6 @@ fun Nav() { }, animatedVisibilityScope = this, onLoadMetadata = { path, uri -> - metadataViewModel.onHandleMetadataActions(MetadataActions.ClearState) metadataViewModel.onHandleMetadataActions( MetadataActions.LoadSong( path, @@ -82,43 +80,20 @@ fun Nav() { intentSender ) }, - onHandleSorting = { sortingType -> - postViewModel.handleFiltering( - listToHandle = ListToHandle.TRACKS, - sortingType = sortingType - ) - }, - onHandleSearching = { query -> - postViewModel.handleSearch( - listToHandle = ListToHandle.TRACKS, - query = query - ) - }, onChargeAlbumSongs = postViewModel::albumSongs, onChargeArtistLists = { postViewModel.artistSongs(it) postViewModel.artistAlbums(it) - }, - musicState = musicState + } ) } + composable { + AlbumsScreen( - albums = postViewModel.albums, + albums = albums, animatedVisibilityScope = this, - onHandleSorting = { sortingType -> - postViewModel.handleFiltering( - listToHandle = ListToHandle.ALBUMS, - sortingType = sortingType - ) - }, - onHandleSearching = { query -> - postViewModel.handleSearch( - listToHandle = ListToHandle.ALBUMS, - query = query - ) - }, currentlyPlaying = musicState.currentlyPlaying, chargePVMAlbumSongs = postViewModel::albumSongs, isPlayerReady = musicState.isPlayerReady, @@ -132,7 +107,6 @@ fun Nav() { } }, selectedIndex = viewModel.selectedItem, - musicState = musicState ) } composable { @@ -154,20 +128,7 @@ fun Nav() { onHandlePlayerActions = viewModel::handlePlayerActions, isPlaying = musicState.isCurrentlyPlaying, animatedVisibilityScope = this, - isPlayerReady = musicState.isPlayerReady, - onHandleSorting = { sortingType -> - postViewModel.handleFiltering( - listToHandle = ListToHandle.ARTISTS, - sortingType = sortingType - ) - }, - onHandleSearching = { query -> - postViewModel.handleSearch( - listToHandle = ListToHandle.ARTISTS, - query = query - ) - }, - musicState = musicState + isPlayerReady = musicState.isPlayerReady ) } @@ -192,7 +153,7 @@ fun Nav() { } composable { val index = it.toRoute() - postViewModel.albums.find { album -> album.id == index.id }?.let { album -> + albums.find { album -> album.id == index.id }?.let { album -> AlbumDetailsScreen( album = album, viewModel = viewModel, diff --git a/app/src/main/java/com/sosauce/cutemusic/ui/screens/album/AlbumScreen.kt b/app/src/main/java/com/sosauce/cutemusic/ui/screens/album/AlbumScreen.kt index 39491ee..c3a1dd2 100644 --- a/app/src/main/java/com/sosauce/cutemusic/ui/screens/album/AlbumScreen.kt +++ b/app/src/main/java/com/sosauce/cutemusic/ui/screens/album/AlbumScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -48,7 +49,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.sosauce.cutemusic.R -import com.sosauce.cutemusic.data.MusicState import com.sosauce.cutemusic.data.actions.PlayerActions import com.sosauce.cutemusic.data.datastore.rememberIsLandscape import com.sosauce.cutemusic.domain.model.Album @@ -58,7 +58,6 @@ import com.sosauce.cutemusic.ui.shared_components.CuteText import com.sosauce.cutemusic.ui.shared_components.NavigationItem import com.sosauce.cutemusic.ui.shared_components.ScreenSelection import com.sosauce.cutemusic.utils.ImageUtils -import com.sosauce.cutemusic.utils.SortingType import com.sosauce.cutemusic.utils.rememberSearchbarAlignment import com.sosauce.cutemusic.utils.rememberSearchbarMaxFloatValue import com.sosauce.cutemusic.utils.rememberSearchbarRightPadding @@ -68,8 +67,6 @@ import com.sosauce.cutemusic.utils.thenIf fun SharedTransitionScope.AlbumsScreen( albums: List, animatedVisibilityScope: AnimatedVisibilityScope, - onHandleSorting: (SortingType) -> Unit, - onHandleSearching: (String) -> Unit, currentlyPlaying: String, chargePVMAlbumSongs: (String) -> Unit, onNavigate: (Screen) -> Unit, @@ -78,7 +75,6 @@ fun SharedTransitionScope.AlbumsScreen( onHandlePlayerActions: (PlayerActions) -> Unit, isPlayerReady: Boolean, onNavigationItemClicked: (Int, NavigationItem) -> Unit, - musicState: MusicState ) { val isLandscape = rememberIsLandscape() var query by remember { mutableStateOf("") } @@ -92,8 +88,25 @@ fun SharedTransitionScope.AlbumsScreen( if (isLandscape) 4 else 2 } + val displayAlbums by remember(isSortedByASC, albums, query) { + derivedStateOf { + if (query.isNotEmpty()) { + albums.filter { + it.name.contains( + other = query, + ignoreCase = true + ) == true + } + } else { + if (isSortedByASC) albums + else albums.sortedByDescending { it.name } + } + + } + } + Box { - if (albums.isEmpty()) { + if (displayAlbums.isEmpty()) { Column( modifier = Modifier .fillMaxSize() @@ -114,7 +127,7 @@ fun SharedTransitionScope.AlbumsScreen( .fillMaxSize() ) { itemsIndexed( - items = albums, + items = displayAlbums, key = { _, album -> album.id } ) { index, album -> AlbumCard( @@ -138,10 +151,7 @@ fun SharedTransitionScope.AlbumsScreen( } CuteSearchbar( query = query, - onQueryChange = { - query = it - onHandleSearching(query) - }, + onQueryChange = { query = it }, modifier = Modifier .navigationBarsPadding() .fillMaxWidth(rememberSearchbarMaxFloatValue()) @@ -182,18 +192,7 @@ fun SharedTransitionScope.AlbumsScreen( trailingIcon = { Row { IconButton( - onClick = { - isSortedByASC = !isSortedByASC - when (isSortedByASC) { - true -> { - onHandleSorting(SortingType.ASCENDING) - } - - false -> { - onHandleSorting(SortingType.DESCENDING) - } - } - } + onClick = { isSortedByASC = !isSortedByASC } ) { Icon( imageVector = Icons.Rounded.ArrowUpward, diff --git a/app/src/main/java/com/sosauce/cutemusic/ui/screens/artist/ArtistsScreen.kt b/app/src/main/java/com/sosauce/cutemusic/ui/screens/artist/ArtistsScreen.kt index e858f33..c6cc289 100644 --- a/app/src/main/java/com/sosauce/cutemusic/ui/screens/artist/ArtistsScreen.kt +++ b/app/src/main/java/com/sosauce/cutemusic/ui/screens/artist/ArtistsScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -44,7 +45,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.sosauce.cutemusic.R -import com.sosauce.cutemusic.data.MusicState import com.sosauce.cutemusic.data.actions.PlayerActions import com.sosauce.cutemusic.domain.model.Artist import com.sosauce.cutemusic.ui.navigation.Screen @@ -52,7 +52,6 @@ import com.sosauce.cutemusic.ui.shared_components.CuteSearchbar import com.sosauce.cutemusic.ui.shared_components.CuteText import com.sosauce.cutemusic.ui.shared_components.NavigationItem import com.sosauce.cutemusic.ui.shared_components.ScreenSelection -import com.sosauce.cutemusic.utils.SortingType import com.sosauce.cutemusic.utils.rememberSearchbarAlignment import com.sosauce.cutemusic.utils.rememberSearchbarMaxFloatValue import com.sosauce.cutemusic.utils.rememberSearchbarRightPadding @@ -61,8 +60,6 @@ import com.sosauce.cutemusic.utils.rememberSearchbarRightPadding fun SharedTransitionScope.ArtistsScreen( artist: List, animatedVisibilityScope: AnimatedVisibilityScope, - onHandleSorting: (SortingType) -> Unit, - onHandleSearching: (String) -> Unit, currentlyPlaying: String, onChargeArtistLists: (String) -> Unit, onNavigate: (Screen) -> Unit, @@ -71,7 +68,6 @@ fun SharedTransitionScope.ArtistsScreen( onHandlePlayerActions: (PlayerActions) -> Unit, isPlayerReady: Boolean, onNavigationItemClicked: (Int, NavigationItem) -> Unit, - musicState: MusicState ) { var query by remember { mutableStateOf("") } @@ -81,10 +77,26 @@ fun SharedTransitionScope.ArtistsScreen( targetValue = if (isSortedByASC) 45f else 135f, label = "Arrow Icon Animation" ) + val displayArtists by remember(isSortedByASC, artist, query) { + derivedStateOf { + if (query.isNotEmpty()) { + artist.filter { + it.name.contains( + other = query, + ignoreCase = true + ) == true + } + } else { + if (isSortedByASC) artist + else artist.sortedByDescending { it.name } + } + + } + } Scaffold { values -> Box { - if (artist.isEmpty()) { + if (displayArtists.isEmpty()) { Column( modifier = Modifier .fillMaxSize() @@ -106,7 +118,7 @@ fun SharedTransitionScope.ArtistsScreen( .padding(values), ) { items( - items = artist, + items = displayArtists, key = { it.id } ) { Column( @@ -127,10 +139,7 @@ fun SharedTransitionScope.ArtistsScreen( } CuteSearchbar( query = query, - onQueryChange = { - query = it - onHandleSearching(query) - }, + onQueryChange = { query = it }, modifier = Modifier .navigationBarsPadding() .fillMaxWidth(rememberSearchbarMaxFloatValue()) @@ -170,18 +179,7 @@ fun SharedTransitionScope.ArtistsScreen( trailingIcon = { Row { IconButton( - onClick = { - isSortedByASC = !isSortedByASC - when (isSortedByASC) { - true -> { - onHandleSorting(SortingType.ASCENDING) - } - - false -> { - onHandleSorting(SortingType.DESCENDING) - } - } - } + onClick = { isSortedByASC = !isSortedByASC } ) { Icon( imageVector = Icons.Rounded.ArrowUpward, diff --git a/app/src/main/java/com/sosauce/cutemusic/ui/screens/blacklisted/BlacklistedScreen.kt b/app/src/main/java/com/sosauce/cutemusic/ui/screens/blacklisted/BlacklistedScreen.kt index 3f48254..e5d2e55 100644 --- a/app/src/main/java/com/sosauce/cutemusic/ui/screens/blacklisted/BlacklistedScreen.kt +++ b/app/src/main/java/com/sosauce/cutemusic/ui/screens/blacklisted/BlacklistedScreen.kt @@ -111,7 +111,6 @@ private fun BlacklistedScreenContent( AppBar( title = stringResource(id = R.string.blacklisted_folders), showBackArrow = true, - showMenuIcon = false, onPopBackStack = { onPopBackStack() } ) }, diff --git a/app/src/main/java/com/sosauce/cutemusic/ui/screens/lyrics/LyricsView.kt b/app/src/main/java/com/sosauce/cutemusic/ui/screens/lyrics/LyricsView.kt index c37fa8c..374af3c 100644 --- a/app/src/main/java/com/sosauce/cutemusic/ui/screens/lyrics/LyricsView.kt +++ b/app/src/main/java/com/sosauce/cutemusic/ui/screens/lyrics/LyricsView.kt @@ -101,7 +101,7 @@ fun LyricsView( if (musicState.currentLyrics.isEmpty()) { item { CuteText( - text = viewModel.loadEmbeddedLyrics(musicState.currentPath).toString(), + text = viewModel.loadEmbeddedLyrics(), ) } } else { diff --git a/app/src/main/java/com/sosauce/cutemusic/ui/screens/main/MainScreen.kt b/app/src/main/java/com/sosauce/cutemusic/ui/screens/main/MainScreen.kt index da1f7bf..bc0967b 100644 --- a/app/src/main/java/com/sosauce/cutemusic/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/sosauce/cutemusic/ui/screens/main/MainScreen.kt @@ -74,9 +74,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi import coil3.compose.AsyncImage import com.sosauce.cutemusic.R -import com.sosauce.cutemusic.data.MusicState import com.sosauce.cutemusic.data.actions.PlayerActions import com.sosauce.cutemusic.data.datastore.rememberHasSeenTip import com.sosauce.cutemusic.ui.navigation.Screen @@ -87,7 +87,6 @@ import com.sosauce.cutemusic.ui.shared_components.MusicDetailsDialog import com.sosauce.cutemusic.ui.shared_components.NavigationItem import com.sosauce.cutemusic.ui.shared_components.ScreenSelection import com.sosauce.cutemusic.utils.ImageUtils -import com.sosauce.cutemusic.utils.SortingType import com.sosauce.cutemusic.utils.rememberSearchbarAlignment import com.sosauce.cutemusic.utils.rememberSearchbarMaxFloatValue import com.sosauce.cutemusic.utils.rememberSearchbarRightPadding @@ -108,11 +107,8 @@ fun SharedTransitionScope.MainScreen( currentMusicUri: String, onHandlePlayerAction: (PlayerActions) -> Unit, onDeleteMusic: (List, ActivityResultLauncher) -> Unit, - onHandleSorting: (SortingType) -> Unit, - onHandleSearching: (String) -> Unit, onChargeAlbumSongs: (String) -> Unit, onChargeArtistLists: (String) -> Unit, - musicState: MusicState ) { var query by remember { mutableStateOf("") } val state = rememberLazyListState() @@ -139,171 +135,172 @@ fun SharedTransitionScope.MainScreen( } } + val displayMusics by remember(isSortedByASC, musics, query) { + derivedStateOf { + if (query.isNotEmpty()) { + musics.filter { + it.mediaMetadata.title?.contains( + other = query, + ignoreCase = true + ) == true + } + } else { + if (isSortedByASC) musics + else musics.sortedByDescending { it.mediaMetadata.title.toString() } + } - Scaffold { _ -> - Box(Modifier.fillMaxSize()) { - LazyColumn( - state = state - ) { - if (musics.isEmpty()) { - item { - CuteText( - text = stringResource(id = R.string.no_musics_found), + } + } + + + Box(Modifier.fillMaxSize()) { + LazyColumn( + state = state + ) { + if (displayMusics.isEmpty()) { + item { + CuteText( + text = stringResource(id = R.string.no_musics_found), + modifier = Modifier + .statusBarsPadding() + .padding(16.dp) + .fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } else { + itemsIndexed( + items = displayMusics, + key = { _, music -> music.mediaId } + ) { index, music -> + Column( + modifier = Modifier + .animateItem() + .padding( + vertical = 2.dp, + horizontal = 4.dp + ) + ) { + MusicListItem( + onShortClick = { onShortClick(music.mediaId) }, + music = music, + onNavigate = { onNavigate(it) }, + currentMusicUri = currentMusicUri, + onLoadMetadata = onLoadMetadata, + showBottomSheet = true, + onDeleteMusic = onDeleteMusic, + onChargeAlbumSongs = onChargeAlbumSongs, + onChargeArtistLists = onChargeArtistLists, modifier = Modifier - .statusBarsPadding() - .padding(16.dp) - .fillMaxWidth(), - textAlign = TextAlign.Center + .thenIf( + index == 0, + Modifier.statusBarsPadding() + ), + isPlayerReady = isPlayerReady ) } - } else { - itemsIndexed( - items = musics, - key = { _, music -> music.mediaId } - ) { index, music -> - Column( - modifier = Modifier - .animateItem() - .padding( - vertical = 2.dp, - horizontal = 4.dp - ) - ) { - MusicListItem( - onShortClick = { onShortClick(music.mediaId) }, - music = music, - onNavigate = { onNavigate(it) }, - currentMusicUri = currentMusicUri, - onLoadMetadata = onLoadMetadata, - showBottomSheet = true, - onDeleteMusic = onDeleteMusic, - onChargeAlbumSongs = onChargeAlbumSongs, - onChargeArtistLists = onChargeArtistLists, - modifier = Modifier - .thenIf( - index == 0, - Modifier.statusBarsPadding() - ), - isPlayerReady = isPlayerReady - ) - } - } } } + } - // TODO : How do you make it NOT scroll to the first item when sorting changes !!!!! - Crossfade( - targetState = showCuteSearchbar, - label = "", - modifier = Modifier.align(rememberSearchbarAlignment()) - ) { visible -> - if (visible) { - val transition = rememberInfiniteTransition(label = "Infinite Color Change") - val color by transition.animateColor( - initialValue = LocalContentColor.current, - targetValue = MaterialTheme.colorScheme.errorContainer, - animationSpec = infiniteRepeatable( - tween(500), - repeatMode = RepeatMode.Reverse - ), - label = "" - ) - var hasSeenTip by rememberHasSeenTip() + // TODO : How do you make it NOT scroll to the first item when sorting changes !!!!! + Crossfade( + targetState = showCuteSearchbar, + label = "", + modifier = Modifier.align(rememberSearchbarAlignment()) + ) { visible -> + if (visible) { + val transition = rememberInfiniteTransition(label = "Infinite Color Change") + val color by transition.animateColor( + initialValue = LocalContentColor.current, + targetValue = MaterialTheme.colorScheme.errorContainer, + animationSpec = infiniteRepeatable( + tween(500), + repeatMode = RepeatMode.Reverse + ), + label = "" + ) + var hasSeenTip by rememberHasSeenTip() - CuteSearchbar( - query = query, - onQueryChange = { - query = it - onHandleSearching(query) - }, - modifier = Modifier - .navigationBarsPadding() - .fillMaxWidth(rememberSearchbarMaxFloatValue()) - .padding( - bottom = 5.dp, - end = rememberSearchbarRightPadding() + CuteSearchbar( + query = query, + onQueryChange = { query = it }, + modifier = Modifier + .navigationBarsPadding() + .fillMaxWidth(rememberSearchbarMaxFloatValue()) + .padding( + bottom = 5.dp, + end = rememberSearchbarRightPadding() + ), + placeholder = { + CuteText( + text = stringResource(id = R.string.search) + " " + stringResource( + id = R.string.music ), - placeholder = { - CuteText( - text = stringResource(id = R.string.search) + " " + stringResource( - id = R.string.music - ), - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), - ) - }, - leadingIcon = { - IconButton( - onClick = { - screenSelectionExpanded = true - if (!hasSeenTip) { - hasSeenTip = true - } + ) + }, + leadingIcon = { + IconButton( + onClick = { + screenSelectionExpanded = true + if (!hasSeenTip) { + hasSeenTip = true } + } + ) { + Icon( + painter = painterResource(R.drawable.music_note_rounded), + contentDescription = null, + tint = if (!hasSeenTip) color else LocalContentColor.current + ) + } + + + DropdownMenu( + expanded = screenSelectionExpanded, + onDismissRequest = { screenSelectionExpanded = false }, + modifier = Modifier + .width(180.dp) + .background(color = MaterialTheme.colorScheme.surface), + shape = RoundedCornerShape(24.dp) + ) { + ScreenSelection( + onNavigationItemClicked = onNavigationItemClicked, + selectedIndex = selectedIndex + ) + } + }, + trailingIcon = { + Row { + IconButton( + onClick = { isSortedByASC = !isSortedByASC } ) { Icon( - painter = painterResource(R.drawable.music_note_rounded), + imageVector = Icons.Rounded.ArrowUpward, contentDescription = null, - tint = if (!hasSeenTip) color else LocalContentColor.current + modifier = Modifier.rotate(float) ) } - - - DropdownMenu( - expanded = screenSelectionExpanded, - onDismissRequest = { screenSelectionExpanded = false }, - modifier = Modifier - .width(180.dp) - .background(color = MaterialTheme.colorScheme.surface), - shape = RoundedCornerShape(24.dp) + IconButton( + onClick = { onNavigate(Screen.Settings) } ) { - ScreenSelection( - onNavigationItemClicked = onNavigationItemClicked, - selectedIndex = selectedIndex + Icon( + imageVector = Icons.Rounded.Settings, + contentDescription = null ) } - }, - trailingIcon = { - Row { - IconButton( - onClick = { - isSortedByASC = !isSortedByASC - when (isSortedByASC) { - true -> { - onHandleSorting(SortingType.ASCENDING) - } - - false -> { - onHandleSorting(SortingType.DESCENDING) - } - } - } - ) { - Icon( - imageVector = Icons.Rounded.ArrowUpward, - contentDescription = null, - modifier = Modifier.rotate(float) - ) - } - IconButton( - onClick = { onNavigate(Screen.Settings) } - ) { - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = null - ) - } - } - }, - currentlyPlaying = currentlyPlaying, - onHandlePlayerActions = onHandlePlayerAction, - isPlaying = isCurrentlyPlaying, - animatedVisibilityScope = animatedVisibilityScope, - isPlayerReady = isPlayerReady, - onNavigate = { onNavigate(Screen.NowPlaying) }, - onClickFAB = { onHandlePlayerAction(PlayerActions.PlayRandom) } - ) - } + } + }, + currentlyPlaying = currentlyPlaying, + onHandlePlayerActions = onHandlePlayerAction, + isPlaying = isCurrentlyPlaying, + animatedVisibilityScope = animatedVisibilityScope, + isPlayerReady = isPlayerReady, + onNavigate = { onNavigate(Screen.NowPlaying) }, + onClickFAB = { onHandlePlayerAction(PlayerActions.PlayRandom) } + ) } } } @@ -449,22 +446,22 @@ fun MusicListItem( ) } ) -// DropdownMenuItem( -// onClick = { -// isDropDownExpanded = false -// onLoadMetadata(path ?: "", uri) -// onNavigate(Screen.MetadataEditor(music.mediaId)) -// }, -// text = { -// CuteText(stringResource(R.string.edit)) -// }, -// leadingIcon = { -// Icon( -// painter = painterResource(R.drawable.edit_rounded), -// contentDescription = null -// ) -// } -// ) + DropdownMenuItem( + onClick = { + isDropDownExpanded = false + onLoadMetadata(path ?: "", uri) + onNavigate(Screen.MetadataEditor(music.mediaId)) + }, + text = { + CuteText(stringResource(R.string.edit)) + }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.edit_rounded), + contentDescription = null + ) + } + ) DropdownMenuItem( onClick = { isDropDownExpanded = false diff --git a/app/src/main/java/com/sosauce/cutemusic/ui/screens/metadata/MetadataEditor.kt b/app/src/main/java/com/sosauce/cutemusic/ui/screens/metadata/MetadataEditor.kt index b9132d5..1e19daf 100644 --- a/app/src/main/java/com/sosauce/cutemusic/ui/screens/metadata/MetadataEditor.kt +++ b/app/src/main/java/com/sosauce/cutemusic/ui/screens/metadata/MetadataEditor.kt @@ -2,11 +2,11 @@ package com.sosauce.cutemusic.ui.screens.metadata import android.app.Activity import android.net.Uri -import android.util.Log import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable @@ -65,7 +65,6 @@ fun MetadataEditor( onNavigate = onNavigate, metadataState = metadataState, onMetadataAction = { metadataViewModel.onHandleMetadataActions(it) }, - //vm = metadataViewModel, onEditMusic = onEditMusic ) } @@ -78,23 +77,15 @@ fun MetadataEditorContent( metadataState: MetadataState, onMetadataAction: (MetadataActions) -> Unit, onEditMusic: (List, ActivityResultLauncher) -> Unit - //vm: MetadataViewModel ) { val context = LocalContext.current val uri = Uri.parse(music.mediaMetadata.extras?.getString("uri")) -// var selectedImageUri by remember { mutableStateOf(null) } -// -// val photoPickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { -// selectedImageUri = it -// vm.changeImage(getFilePathFromUri(context, selectedImageUri!!)) -// } + val photoPickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { onMetadataAction(MetadataActions.UpdateAudioArt(it ?: Uri.EMPTY)) } val editSongLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartIntentSenderForResult() ) { - Log.d("resulta", it.resultCode.toString()) - if (it.resultCode == Activity.RESULT_OK) { onMetadataAction(MetadataActions.SaveChanges) Toast.makeText( @@ -102,7 +93,6 @@ fun MetadataEditorContent( context.getString(R.string.success), Toast.LENGTH_SHORT ).show() - //onPopBackStack() } else { Toast.makeText( context, @@ -133,85 +123,86 @@ fun MetadataEditorContent( AppBar( title = stringResource(R.string.editor), showBackArrow = true, - showMenuIcon = false, onPopBackStack = { onPopBackStack() }, - onNavigate = { onNavigate(it) } ) } - ) { value -> + ) { pv -> Column( modifier = Modifier - .padding(value) + .padding(pv) .fillMaxSize() ) { - Row( + Column( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { +// IconButton( +// onClick = { onMetadataAction(MetadataActions.RemoveArtwork) }, +// modifier = Modifier.align(Alignment.End) +// ) { +// Icon( +// imageVector = Icons.Rounded.Close, +// contentDescription = null +// ) +// } AsyncImage( model = ImageUtils.imageRequester( - img = music.mediaMetadata.artworkUri, + img = metadataState.art?.data, context = context ), contentDescription = stringResource(id = R.string.artwork), modifier = Modifier .size(200.dp) - .padding(10.dp) + .padding(bottom = 10.dp) .clip(RoundedCornerShape(5)) .clickable { -// photoPickerLauncher.launch( -// PickVisualMediaRequest( -// ActivityResultContracts.PickVisualMedia.ImageOnly -// )) - Toast - .makeText( - context, - "Image editing will be available in the future!", - Toast.LENGTH_SHORT - ) - .show() + photoPickerLauncher.launch( + PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly + )) }, contentScale = ContentScale.Crop ) } + Column { EditTextField( - value = metadataState.mutablePropertiesMap[0], + value = metadataState.mutablePropertiesMap["TITLE"], label = { CuteText( text = stringResource(R.string.title) ) } ) { title -> - metadataState.mutablePropertiesMap[0] = title + metadataState.mutablePropertiesMap["TITLE"] = title } EditTextField( - value = metadataState.mutablePropertiesMap[1], + value = metadataState.mutablePropertiesMap["ARTIST"], label = { CuteText( text = stringResource(R.string.artists).removeSuffix("s") // I'm too lazy to do plurals ) } ) { artist -> - metadataState.mutablePropertiesMap[1] = artist + metadataState.mutablePropertiesMap["ARTIST"] = artist } EditTextField( - value = metadataState.mutablePropertiesMap[2], + value = metadataState.mutablePropertiesMap["ALBUM"], label = { CuteText( text = stringResource(R.string.albums).removeSuffix("s") // I'm too lazy to do plurals ) } ) { album -> - metadataState.mutablePropertiesMap[2] = album + metadataState.mutablePropertiesMap["ALBUM"] = album } Spacer(Modifier.height(25.dp)) Row { EditTextField( - value = metadataState.mutablePropertiesMap[3], + value = metadataState.mutablePropertiesMap["DATE"], label = { CuteText( text = stringResource(R.string.year) @@ -220,10 +211,10 @@ fun MetadataEditorContent( modifier = Modifier.weight(1f), keyboardType = KeyboardType.Number ) { year -> - metadataState.mutablePropertiesMap[3] = year + metadataState.mutablePropertiesMap["DATE"] = year } EditTextField( - value = metadataState.mutablePropertiesMap[4], + value = metadataState.mutablePropertiesMap["GENRE"], label = { CuteText( text = stringResource(R.string.genre) @@ -231,12 +222,12 @@ fun MetadataEditorContent( }, modifier = Modifier.weight(1f) ) { genre -> - metadataState.mutablePropertiesMap[4] = genre + metadataState.mutablePropertiesMap["GENRE"] = genre } } Row { EditTextField( - value = metadataState.mutablePropertiesMap[5], + value = metadataState.mutablePropertiesMap["TRACKNUMBER"], label = { CuteText( text = stringResource(R.string.track_nb), @@ -246,10 +237,10 @@ fun MetadataEditorContent( modifier = Modifier.weight(1f), keyboardType = KeyboardType.Number ) { track -> - metadataState.mutablePropertiesMap[5] = track + metadataState.mutablePropertiesMap["TRACKNUMBER"] = track } EditTextField( - value = metadataState.mutablePropertiesMap[6], + value = metadataState.mutablePropertiesMap["DISCNUMBER"], label = { CuteText( text = stringResource(R.string.disc_nb), @@ -259,11 +250,11 @@ fun MetadataEditorContent( modifier = Modifier.weight(1f), keyboardType = KeyboardType.Number ) { disc -> - metadataState.mutablePropertiesMap[6] = disc + metadataState.mutablePropertiesMap["DISCNUMBER"] = disc } } EditTextField( - value = metadataState.mutablePropertiesMap[7], + value = metadataState.mutablePropertiesMap["LYRICS"], label = { CuteText( text = "Lyrics", @@ -271,7 +262,7 @@ fun MetadataEditorContent( ) } ) { lyrics -> - metadataState.mutablePropertiesMap[7] = lyrics + metadataState.mutablePropertiesMap["LYRICS"] = lyrics } } } diff --git a/app/src/main/java/com/sosauce/cutemusic/ui/screens/metadata/MetadataState.kt b/app/src/main/java/com/sosauce/cutemusic/ui/screens/metadata/MetadataState.kt index af95e10..dde5dee 100644 --- a/app/src/main/java/com/sosauce/cutemusic/ui/screens/metadata/MetadataState.kt +++ b/app/src/main/java/com/sosauce/cutemusic/ui/screens/metadata/MetadataState.kt @@ -1,12 +1,18 @@ package com.sosauce.cutemusic.ui.screens.metadata import android.net.Uri -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.snapshots.SnapshotStateMap +import com.kyant.taglib.AudioProperties +import com.kyant.taglib.Metadata +import com.kyant.taglib.Picture data class MetadataState( - val mutablePropertiesMap: SnapshotStateList = mutableStateListOf(), + val mutablePropertiesMap: SnapshotStateMap = mutableStateMapOf(), val songPath: String = "", - val songUri: Uri = Uri.EMPTY - //var art: Artwork? = null + val songUri: Uri = Uri.EMPTY, + val metadata: Metadata? = null, + val audioProperties: AudioProperties? = null, + val art: Picture? = null, + val newArtUri: Uri = Uri.EMPTY ) \ No newline at end of file diff --git a/app/src/main/java/com/sosauce/cutemusic/ui/screens/metadata/MetadataViewModel.kt b/app/src/main/java/com/sosauce/cutemusic/ui/screens/metadata/MetadataViewModel.kt index 8a83888..e0713e8 100644 --- a/app/src/main/java/com/sosauce/cutemusic/ui/screens/metadata/MetadataViewModel.kt +++ b/app/src/main/java/com/sosauce/cutemusic/ui/screens/metadata/MetadataViewModel.kt @@ -3,18 +3,35 @@ package com.sosauce.cutemusic.ui.screens.metadata import android.annotation.SuppressLint import android.app.Application import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaScannerConnection import android.net.Uri import android.os.ParcelFileDescriptor import android.provider.MediaStore import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.kyant.taglib.AudioProperties +import com.kyant.taglib.AudioPropertiesReadStyle +import com.kyant.taglib.Metadata +import com.kyant.taglib.Picture +import com.kyant.taglib.TagLib import com.sosauce.cutemusic.data.actions.MetadataActions +import com.sosauce.cutemusic.utils.toAudioFileMetadata +import com.sosauce.cutemusic.utils.toModifiableMap +import com.sosauce.cutemusic.utils.toPropertyMap +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import java.io.File import java.io.FileNotFoundException +// Inspired by Metadator and TagLib ! + class MetadataViewModel( private val application: Application ) : AndroidViewModel(application) { @@ -28,39 +45,110 @@ class MetadataViewModel( _metadata.value.mutablePropertiesMap.clear() } - private fun loadMetadataJAudio(path: String) { - -// val audioFile = AudioFileIO -// .read(File(path)) -// -// audioFile.tag.apply { -// val tagList = listOf( -// getFirst(FieldKey.TITLE), -// getFirst(FieldKey.ARTIST), -// getFirst(FieldKey.ALBUM), -// getFirst(FieldKey.YEAR), -// getFirst(FieldKey.GENRE), -// getFirst(FieldKey.TRACK), -// getFirst(FieldKey.DISC_NO), -// getFirst(FieldKey.LYRICS), -// ) -// -// -// tagList.forEach { -// _metadata.value.mutablePropertiesMap.add(it) -// } -// //_metadata.value.art = firstArtwork ?: null -// } + suspend fun loadMetadata() { + runCatching { + getFileDescriptorFromPath(application, metadataState.value.songPath)?.use { fd -> + val metadata = loadAudioMetadata(fd) + val audioProperties = loadAudioProperties(fd) + val audioArt = loadAudioArt(fd) + + _metadata.value = _metadata.value.copy( + metadata = metadata, + audioProperties = audioProperties, + art = audioArt + ) + + } + }.onSuccess { + metadataState.value.metadata?.propertyMap?.toModifiableMap()?.forEach { + metadataState.value.mutablePropertiesMap[it.key] = it.value ?: "" + } + } + } + + + private suspend fun loadAudioMetadata(songFd: ParcelFileDescriptor): Metadata? { + val fd = songFd.dup()?.detachFd() ?: throw NullPointerException() + + return withContext(Dispatchers.IO) { + TagLib.getMetadata(fd) + } + } + + private suspend fun loadAudioProperties( + songFd: ParcelFileDescriptor, + readStyle: AudioPropertiesReadStyle = AudioPropertiesReadStyle.Fast + ): AudioProperties? { + val fd = songFd.dup()?.detachFd() ?: throw NullPointerException() + + return withContext(Dispatchers.IO) { + TagLib.getAudioProperties(fd, readStyle) + } + } + + private suspend fun loadAudioArt(songFd: ParcelFileDescriptor): Picture? { + val fd = songFd.dup()?.detachFd() ?: throw NullPointerException() + + return withContext(Dispatchers.IO) { + TagLib.getFrontCover(fd) + } } + private fun saveAllChanges() { + try { + val fd = getFileDescriptorFromPath(application, metadataState.value.songPath, "w") + + + fd?.dup()?.detachFd()?.let { + TagLib.savePropertyMap(it, metadataState.value.mutablePropertiesMap.toAudioFileMetadata().toPropertyMap()) + } + + fd?.dup()?.detachFd()?.let { + if (metadataState.value.art != null) { + TagLib.savePictures(it, arrayOf(metadataState.value.art!!)) + } + } + + MediaScannerConnection.scanFile( + application.applicationContext, + arrayOf(metadataState.value.songPath), + null, + null + ) + } catch (e: Exception) { + Log.d("hello", "some error occured") + e.printStackTrace() + } } + private fun saveNewAudioArt(uri: Uri) { + // App will crash if it tries to open an input stream on an empty uri ! + if (uri == Uri.EMPTY) return - private fun clearState() { - _metadata.value.mutablePropertiesMap.clear() - //_metadata.value.art + val mimeType = application.contentResolver.getType(uri) + val byteArray = application.contentResolver.openInputStream(uri)?.use { inputStream -> + + val baos = ByteArrayOutputStream() + BitmapFactory.decodeStream(inputStream).apply { + compress(Bitmap.CompressFormat.JPEG, 100, baos) + } + + baos.toByteArray() + } + + + val picture = Picture( + data = byteArray ?: byteArrayOf(), + description = "", + pictureType = "Front Cover", + mimeType = mimeType ?: "image/jpeg" + ) + + _metadata.value = _metadata.value.copy( + art = picture + ) } @SuppressLint("Range") @@ -106,12 +194,15 @@ class MetadataViewModel( songPath = action.path, songUri = action.uri ) - loadMetadataJAudio(metadataState.value.songPath) + loadMetadata() } } + is MetadataActions.UpdateAudioArt -> { saveNewAudioArt(action.newArtUri) } - is MetadataActions.ClearState -> { - clearState() + is MetadataActions.RemoveArtwork -> { + _metadata.value = _metadata.value.copy( + art = null + ) } } } diff --git a/app/src/main/java/com/sosauce/cutemusic/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/sosauce/cutemusic/ui/screens/settings/SettingsScreen.kt index 9ae277c..440d28f 100644 --- a/app/src/main/java/com/sosauce/cutemusic/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/sosauce/cutemusic/ui/screens/settings/SettingsScreen.kt @@ -36,9 +36,7 @@ fun SettingsScreen( AppBar( title = stringResource(id = R.string.settings), showBackArrow = true, - showMenuIcon = false, - onPopBackStack = { onPopBackStack() }, - onNavigate = { onNavigate(it) } + onPopBackStack = { onPopBackStack() } ) }, modifier = Modifier diff --git a/app/src/main/java/com/sosauce/cutemusic/ui/shared_components/AppBar.kt b/app/src/main/java/com/sosauce/cutemusic/ui/shared_components/AppBar.kt index 6f618ad..1eccc5d 100644 --- a/app/src/main/java/com/sosauce/cutemusic/ui/shared_components/AppBar.kt +++ b/app/src/main/java/com/sosauce/cutemusic/ui/shared_components/AppBar.kt @@ -2,14 +2,11 @@ package com.sosauce.cutemusic.ui.shared_components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import com.sosauce.cutemusic.ui.navigation.Screen @OptIn(ExperimentalMaterial3Api::class) @@ -17,9 +14,7 @@ import com.sosauce.cutemusic.ui.navigation.Screen fun AppBar( title: String, showBackArrow: Boolean, - showMenuIcon: Boolean, onPopBackStack: (() -> Unit)? = null, - onNavigate: ((Screen) -> Unit)? = null ) { TopAppBar( title = { @@ -37,17 +32,6 @@ fun AppBar( ) } } - }, - actions = { - if (showMenuIcon) { - IconButton(onClick = { onNavigate?.invoke(Screen.Settings) }) { - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = "More", - tint = MaterialTheme.colorScheme.onBackground - ) - } - } } ) } diff --git a/app/src/main/java/com/sosauce/cutemusic/ui/shared_components/MusicViewModel.kt b/app/src/main/java/com/sosauce/cutemusic/ui/shared_components/MusicViewModel.kt index 11918b1..8da77c2 100644 --- a/app/src/main/java/com/sosauce/cutemusic/ui/shared_components/MusicViewModel.kt +++ b/app/src/main/java/com/sosauce/cutemusic/ui/shared_components/MusicViewModel.kt @@ -1,7 +1,13 @@ package com.sosauce.cutemusic.ui.shared_components +import android.annotation.SuppressLint import android.app.Application import android.content.ComponentName +import android.content.Context +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.MediaStore +import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -16,6 +22,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import com.google.common.util.concurrent.MoreExecutors +import com.kyant.taglib.TagLib import com.sosauce.cutemusic.data.MusicState import com.sosauce.cutemusic.data.actions.PlayerActions import com.sosauce.cutemusic.domain.model.Lyrics @@ -32,12 +39,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.jaudiotagger.audio.AudioFileIO -import org.jaudiotagger.tag.FieldKey import java.io.File +import java.io.FileNotFoundException class MusicViewModel( - application: Application, + private val application: Application, private val mediaStoreHelper: MediaStoreHelper ) : AndroidViewModel(application) { @@ -205,20 +211,45 @@ class MusicViewModel( } } - fun loadEmbeddedLyrics( - path: String - ): String { - val file = AudioFileIO.read(File(path)) + fun loadEmbeddedLyrics(): String { - file.tag.apply { - val embeddedLyrics = getFirst(FieldKey.LYRICS) + val fd = getFileDescriptorFromPath(application, musicState.value.currentPath) + return fd?.dup()?.detachFd()?.let { + TagLib.getMetadata(it)?.propertyMap["LYRICS"]?.getOrNull(0) ?: "No Lyrics Found !" + } ?: "No Lyrics Found !" - return if (embeddedLyrics != "") { - embeddedLyrics - } else { - "No lyrics found !" + } + + @SuppressLint("Range") + private fun getFileDescriptorFromPath( + context: Context, + filePath: String, + mode: String = "r" + ): ParcelFileDescriptor? { + val resolver = context.contentResolver + val uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + + val projection = arrayOf(MediaStore.Files.FileColumns._ID) + val selection = "${MediaStore.Files.FileColumns.DATA}=?" + val selectionArgs = arrayOf(filePath) + + resolver.query(uri, projection, selection, selectionArgs, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val fileId = cursor.getInt(cursor.getColumnIndex(MediaStore.Files.FileColumns._ID)) + if (fileId == -1) { + return null + } else { + val fileUri = Uri.withAppendedPath(uri, fileId.toString()) + try { + return resolver.openFileDescriptor(fileUri, mode) + } catch (e: FileNotFoundException) { + Log.e("MediaStoreReceiver", "File not found: ${e.message}") + } + } } } + + return null } override fun onCleared() { diff --git a/app/src/main/java/com/sosauce/cutemusic/ui/shared_components/PostViewModel.kt b/app/src/main/java/com/sosauce/cutemusic/ui/shared_components/PostViewModel.kt index 9cd783f..76e8f5c 100644 --- a/app/src/main/java/com/sosauce/cutemusic/ui/shared_components/PostViewModel.kt +++ b/app/src/main/java/com/sosauce/cutemusic/ui/shared_components/PostViewModel.kt @@ -1,36 +1,40 @@ package com.sosauce.cutemusic.ui.shared_components -import android.app.Application import android.net.Uri -import android.provider.MediaStore import android.util.Log import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.media3.common.MediaItem import com.sosauce.cutemusic.domain.model.Album import com.sosauce.cutemusic.domain.repository.MediaStoreHelper -import com.sosauce.cutemusic.domain.repository.MediaStoreObserver -import com.sosauce.cutemusic.utils.ListToHandle -import com.sosauce.cutemusic.utils.SortingType +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlin.collections.filter class PostViewModel( - private val mediaStoreHelper: MediaStoreHelper, - private val application: Application -) : AndroidViewModel(application) { + private val mediaStoreHelper: MediaStoreHelper +) : ViewModel() { - var musics by mutableStateOf( + + + var musics = mediaStoreHelper.fetchLatestMusics().stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), mediaStoreHelper.musics ) - var albums by mutableStateOf( - mediaStoreHelper.albums + var albums = mediaStoreHelper.fetchLatestAlbums().stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptyList() ) var artists by mutableStateOf( @@ -41,35 +45,23 @@ class PostViewModel( mediaStoreHelper.folders ) - private val observer = MediaStoreObserver { - musics = mediaStoreHelper.fetchMusics() - } - - init { - application.contentResolver.registerContentObserver( - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, - true, - observer - ) - } - companion object { + private companion object { const val CUTE_ERROR = "CuteError" } - override fun onCleared() { - super.onCleared() - application.contentResolver.unregisterContentObserver(observer) - } - var albumSongs by mutableStateOf(listOf()) var artistSongs by mutableStateOf(listOf()) var artistAlbums by mutableStateOf(listOf()) fun albumSongs(album: String) { try { - albumSongs = musics.filter { it.mediaMetadata.albumTitle.toString() == album } + viewModelScope.launch { + musics.collectLatest { + albumSongs = it.filter { it.mediaMetadata.albumTitle.toString() == album } + } + } } catch (e: Exception) { Log.e(CUTE_ERROR, e.message, e) } @@ -77,7 +69,11 @@ class PostViewModel( fun artistSongs(artistName: String) { try { - artistSongs = musics.filter { it.mediaMetadata.artist == artistName } + viewModelScope.launch { + musics.collectLatest { + artistSongs = it.filter { it.mediaMetadata.artist == artistName } + } + } } catch (e: Exception) { Log.e(CUTE_ERROR, e.message, e) } @@ -86,7 +82,7 @@ class PostViewModel( fun artistAlbums(artistName: String) { try { - artistAlbums = albums.filter { it.artist == artistName } + artistAlbums = albums.value.filter { it.artist == artistName } } catch (e: Exception) { Log.e(CUTE_ERROR, e.message, e) } @@ -117,66 +113,6 @@ class PostViewModel( } } - fun handleFiltering( - listToHandle: ListToHandle, - sortingType: SortingType, - ) { - when (listToHandle) { - ListToHandle.TRACKS -> { - musics = if (sortingType == SortingType.ASCENDING) - musics.sortedBy { it.mediaMetadata.title.toString() } - else - musics.sortedByDescending { it.mediaMetadata.title.toString() } - } - - ListToHandle.ALBUMS -> { - albums = if (sortingType == SortingType.ASCENDING) - albums.sortedBy { it.name } - else - albums.sortedByDescending { it.name } - } - - ListToHandle.ARTISTS -> { - artists = if (sortingType == SortingType.ASCENDING) - artists.sortedBy { it.name } - else - artists.sortedByDescending { it.name } - } - } - } - - fun handleSearch( - listToHandle: ListToHandle, - query: String = "" - ) { - when (listToHandle) { - ListToHandle.TRACKS -> { - musics = mediaStoreHelper.musics.filter { - it.mediaMetadata.title?.contains( - other = query, - ignoreCase = true - ) == true - } - } - ListToHandle.ALBUMS -> { - albums = mediaStoreHelper.albums.filter { - it.name.contains( - other = query, - ignoreCase = true - ) - } - } - - ListToHandle.ARTISTS -> { - artists = mediaStoreHelper.artists.filter { - it.name.contains( - other = query, - ignoreCase = true - ) - } - } - } - } } diff --git a/app/src/main/java/com/sosauce/cutemusic/utils/Enums.kt b/app/src/main/java/com/sosauce/cutemusic/utils/Enums.kt index 9e6bc28..e69edd1 100644 --- a/app/src/main/java/com/sosauce/cutemusic/utils/Enums.kt +++ b/app/src/main/java/com/sosauce/cutemusic/utils/Enums.kt @@ -1,12 +1 @@ package com.sosauce.cutemusic.utils - -enum class ListToHandle { - TRACKS, - ALBUMS, - ARTISTS -} - -enum class SortingType { - ASCENDING, - DESCENDING -} \ No newline at end of file diff --git a/app/src/main/java/com/sosauce/cutemusic/utils/Extensions.kt b/app/src/main/java/com/sosauce/cutemusic/utils/Extensions.kt index 3de9a1c..c6df397 100644 --- a/app/src/main/java/com/sosauce/cutemusic/utils/Extensions.kt +++ b/app/src/main/java/com/sosauce/cutemusic/utils/Extensions.kt @@ -1,10 +1,13 @@ package com.sosauce.cutemusic.utils +import android.content.ContentResolver import android.content.Context import android.content.Intent +import android.database.ContentObserver import android.media.MediaMetadataRetriever import android.net.Uri import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -13,7 +16,10 @@ import androidx.compose.ui.unit.dp import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player +import com.kyant.taglib.PropertyMap import com.sosauce.cutemusic.data.datastore.rememberIsLandscape +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow import java.util.Locale fun Modifier.thenIf( @@ -158,6 +164,77 @@ fun Long.formatToReadableTime(): String { return String.format(Locale.getDefault(), "%d:%02d", minutes, seconds) } +fun PropertyMap.toModifiableMap(separator: String = ", "): MutableMap { + return mutableMapOf( + "TITLE" to this["TITLE"]?.getOrNull(0), + "ARTIST" to this["ARTIST"]?.joinToString(separator), + "ALBUM" to this["ALBUM"]?.getOrNull(0), + "TRACKNUMBER" to this["TRACKNUMBER"]?.getOrNull(0), + "DISCNUMBER" to this["DISCNUMBER"]?.getOrNull(0), + "DATE" to this["DATE"]?.getOrNull(0), + "GENRE" to this["GENRE"]?.joinToString(separator), + "LYRICS" to this["LYRICS"]?.getOrNull(0), + "DATE" to this["DATE"]?.getOrNull(0), + ) +} + +fun String?.formatForField(separator: String = ","): Array { + return this?.split(separator)?.map { it.trim() }?.toTypedArray() ?: arrayOf(this ?: "") +} + + + +@Stable +data class AudioFileMetadata( + val title: String?, + val artist: String?, + val album: String?, + val trackNumber: String?, + val discNumber: String?, + val date: String?, + val genre: String?, + val lyrics: String? +) + +fun Map.toAudioFileMetadata(): AudioFileMetadata { + return AudioFileMetadata( + title = this["TITLE"], + artist = this["ARTIST"], + album = this["ALBUM"], + trackNumber = this["TRACKNUMBER"], + discNumber = this["DISCNUMBER"], + date = this["DATE"], + genre = this["GENRE"], + lyrics = this["LYRICS"] + ) +} + +fun AudioFileMetadata.toPropertyMap(): PropertyMap { + return hashMapOf( + "TITLE" to arrayOf(title ?: ""), + "ARTIST" to artist.formatForField(), + "ALBUM" to arrayOf(album ?: ""), + "TRACKNUMBER" to arrayOf(trackNumber ?: ""), + "DISCNUMBER" to arrayOf(discNumber ?: ""), + "DATE" to arrayOf(date ?: ""), + "GENRE" to genre.formatForField(), + "LYRICS" to arrayOf(lyrics ?: "") + ) +} + +fun ContentResolver.observe(uri: Uri) = callbackFlow { + val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(selfChange) + } + } + registerContentObserver(uri, true, observer) + trySend(false) + awaitClose { + unregisterContentObserver(observer) + } +} + @Composable fun rememberSearchbarAlignment( ): Alignment { diff --git a/app/src/main/java/com/sosauce/cutemusic/utils/ImageUtils.kt b/app/src/main/java/com/sosauce/cutemusic/utils/ImageUtils.kt index 4e3bc77..973f713 100644 --- a/app/src/main/java/com/sosauce/cutemusic/utils/ImageUtils.kt +++ b/app/src/main/java/com/sosauce/cutemusic/utils/ImageUtils.kt @@ -3,6 +3,7 @@ package com.sosauce.cutemusic.utils import android.content.ContentUris import android.content.Context import androidx.core.net.toUri +import coil3.request.CachePolicy import coil3.request.crossfade import coil3.request.transformations import coil3.transform.RoundedCornersTransformation @@ -17,6 +18,8 @@ object ImageUtils { .transformations( RoundedCornersTransformation(15f) ) + .diskCacheKey(img.toString()) + .memoryCacheKey(img.toString()) .build() return request diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5775c8b..8f5ef37 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,5 @@ [versions] agp = "8.7.2" -jaudiotagger = "3.0.1" koinAndroid = "4.0.0" koinAndroidxCompose = "4.0.0" koinAndroidxStartup = "4.0.0" @@ -20,6 +19,7 @@ media3Session = "1.5.0" navigationCompose = "2.8.4" squigglyslider = "1.0.0" serialization = "2.0.0" +taglib = "1.0.0-alpha25" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } @@ -39,12 +39,12 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose androidx-ui = { module = "androidx.compose.ui:ui" } androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } -jaudiotagger = { module = "net.jthink:jaudiotagger", version.ref = "jaudiotagger" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koinAndroid" } koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koinAndroidxCompose" } koin-androidx-startup = { module = "io.insert-koin:koin-androidx-startup", version.ref = "koinAndroidxStartup" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } squigglyslider = { module = "me.saket.squigglyslider:squigglyslider", version.ref = "squigglyslider" } +taglib = { module = "com.github.Kyant0:taglib", version.ref = "taglib" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }